Files
dsh-slopo agent 1b2ab3613f
Release / release (push) Successful in 6s
fix!: port the settings page and shell seam to dsh 0.1.7-rc.2
0.1.7-rc.2 moved four things this bundle was built on, and every one of them
failed quietly rather than loudly, so the plugin looked installed and did
nothing:

  * `ctx.settings.installSection(...)` is gone. The call threw into a
    `try/catch` that only logged a warning, so the settings namespace was never
    registered and `configure` had nowhere to persist. An entry's exported
    Config IS the form now; the namespace is the Loader entry id the bundle's
    patch declares, and `ctx.settings.configure({ auto: false })` is what claims
    that entry's own page.

  * `settingsScope` and the `settings.plugin.item` slot are gone. The card
    registered into both, so it never rendered — the tab draws the intersection
    of served namespaces and registered cards, and that intersection was empty.
    It now registers into `plugins.bundle.config` keyed by the bundle's package
    name, over the `configForms` service, and uses the framework's own form
    chrome (`SettingsFormModel` / `SettingsForm` / `SettingsValueField` /
    `SettingsSecretField`) instead of the hand-rolled equivalent. A save is one
    revision-fenced write.

  * `ctx.shell.run(spec)` is gone. Every action that runs a command threw
    `shell.run is not a function`; the seam is now
    `await ctx.shell.execute(ctx.shell.resolve(request)).result()`.

  * A job's `owner` is a session id, not the agent object. The wrong owner
    registered a job no session could collect or cancel, and the producer's
    `done` resolved `output` where the registry reads `result`, so a finished
    job handed back nothing.

Two conformance gaps went with them: a Config field that is not marked
`volatile()` is invisible to every settings UI — the form projection keeps only
volatile fields, and an entry with no form is dropped from `settings.describe`
entirely — and `embeddingApiKey` was a plain field, so the key rode every
settings read back to the browser. It is `role('secret')` now: the browser is
told whether a key is stored and never receives the key.

Also declares the `@deepseek-ai/dsh-*` peerDependencies the compatibility gate
reads, so an incompatible runtime refuses this plugin at startup instead of
half-loading it.

Verified against a real 0.1.7-rc.2 boot: `settings.describe` serves the entry
with `autoGenerate: false`, the key redacted and reported only as `set: true`,
and no startup diagnostics. The host mock in the tests now has no `run` and no
`installSection`, so those seams are contract-tested rather than assumed, and
the browser tests read the installed harness's own slot catalog and package
exports — a rename fails a test instead of quietly emptying a page.
2026-09-26 06:38:16 +01:00

310 lines
11 KiB
JavaScript

/**
* Locating, installing, and invoking the slopo CLI.
*
* Everything here is a pure function of its arguments so the decisions can be
* tested without a running harness. The one side effect — running `uv` — is
* expressed as a command line that the caller executes through the shell seam.
*
* Slopo is driven as a separate process, never imported. Its package is
* AGPL-3.0-or-later and the README does not accept patches, so the command-line
* interface is the only contract worth building on.
*/
import { existsSync } from 'node:fs'
import { delimiter, join } from 'node:path'
import { managedPythonDir, managedSlopoPath, managedToolDir, userLocalSlopoPath } from './paths.js'
/** The PyPI distribution name. */
export const SLOPO_PACKAGE = 'slopo'
/** Slopo requires Python >= 3.12; uv fetches a managed interpreter for this. */
export const SLOPO_PYTHON = '3.12'
/**
* The agent-config version this bundle's ported skills were written against.
*
* Slopo's `--config-version` flag fails the command when the agent-facing
* contract has moved on, which is the signal that the skill text in `skills/`
* is stale and needs re-porting. Passing it is therefore a deliberate alarm,
* not boilerplate.
*/
export const SLOPO_AGENT_CONFIG_VERSION = 1
const EXE = process.platform === 'win32' ? 'slopo.exe' : 'slopo'
/**
* Binaries to try, best first.
*
* The managed install is checked before `PATH`, because a stale slopo of a
* different version earlier on `PATH` would otherwise silently win.
*
* @param {string} dshHome
* @param {Record<string, string | undefined>} [env]
* @returns {string[]}
*/
export function slopoCandidates(dshHome, env = process.env) {
const candidates = []
const configured = typeof env.DSH_SLOPO_BINARY === 'string' ? env.DSH_SLOPO_BINARY.trim() : ''
if (configured !== '') candidates.push(configured)
candidates.push(managedSlopoPath(dshHome))
for (const dir of (env.PATH ?? '').split(delimiter)) {
if (dir !== '') candidates.push(join(dir, EXE))
}
candidates.push(userLocalSlopoPath(env))
return candidates
}
/**
* The first candidate that exists, or `undefined`.
*
* @param {string} dshHome
* @param {Record<string, string | undefined>} [env]
* @param {(path: string) => boolean} [exists] injected for tests
* @returns {string | undefined}
*/
export function findSlopo(dshHome, env = process.env, exists = existsSync) {
for (const candidate of slopoCandidates(dshHome, env)) {
if (exists(candidate)) return candidate
}
return undefined
}
/**
* The environment `uv` must run under to install into `$DSH_HOME`.
*
* All three variables are redirected: `UV_TOOL_DIR` for the venv and shim,
* `UV_TOOL_BIN_DIR` for the executable, and `UV_PYTHON_INSTALL_DIR` for the
* CPython that slopo requires. Without the last one uv downloads a second
* interpreter into the container's writable layer and the install does not
* survive a container recreation.
*
* @param {string} dshHome
* @param {Record<string, string | undefined>} [env]
* @returns {Record<string, string | undefined>}
*/
export function installEnv(dshHome, env = process.env) {
return {
...env,
UV_TOOL_DIR: managedToolDir(dshHome),
UV_TOOL_BIN_DIR: join(managedToolDir(dshHome), 'bin'),
UV_PYTHON_INSTALL_DIR: managedPythonDir(dshHome),
}
}
/**
* The `uv` arguments that install or upgrade slopo into `$DSH_HOME`.
*
* @param {{ upgrade?: boolean, version?: string }} [options]
* @returns {string[]}
*/
export function uvInstallArgs({ upgrade = false, version = '' } = {}) {
const spec = version === '' ? SLOPO_PACKAGE : `${SLOPO_PACKAGE}==${version.replace(/^v/, '')}`
return ['tool', 'install', spec, '--python', SLOPO_PYTHON, ...(upgrade ? ['--upgrade'] : [])]
}
/** POSIX single-quote a value. The harness shell is `bash -c` on this platform. */
export function shellQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`
}
/** Join argv into a command line the shell seam can execute. */
export function commandLine(argv) {
return argv.map(shellQuote).join(' ')
}
/**
* Run one command through the harness shell seam.
*
* The seam is `execute(resolve(request))`, whose handle carries the foreground
* projection as `result()`. That is deliberately two steps: the handle is the
* background-capable object, and awaiting `result()` is what makes a run
* foreground. The single-step `run(spec)` this plugin used before
* 0.1.7-rc.2 no longer exists on `ShellExecutor`, so calling it threw
* `shell.run is not a function` on every action that needed a command.
*
* `result()` rejects only for infrastructure failures (a spawn that never
* produced a process); non-zero exits, timeout kills and abort kills resolve
* with a descriptive result. The returned shape is the plugin's own flattened
* form, so callers never have to know which generation of the seam they are on.
*
* @param {{ execute: Function, resolve: Function }} shell the `ctx.shell` service
* @param {object} request a `ShellExecRequest`
* @returns {Promise<{
* exitCode: number | null,
* signal: string | null,
* timedOut: boolean,
* aborted: boolean,
* timeoutMs: number,
* sandbox: object | undefined,
* stdout: { text: string, truncated: boolean },
* stderr: { text: string, truncated: boolean },
* }>}
*/
export async function runShell(shell, request) {
const result = await (await shell.execute(shell.resolve(request))).result()
return {
exitCode: result.exitCode,
signal: result.signal,
timedOut: result.timedOut,
aborted: result.aborted,
timeoutMs: result.timeoutMs,
sandbox: result.sandbox,
stdout: { text: result.stdout.text, truncated: result.stdout.truncated },
stderr: { text: result.stderr.text, truncated: result.stderr.truncated },
}
}
/**
* Slopo arguments for one action.
*
* `--config` is a global option on slopo's Typer callback, so it must precede
* the subcommand. That placement was verified against slopo 0.8.0 rather than
* assumed.
*
* @param {string} configPath
* @param {string} action
* @param {{ base?: string, cluster?: string }} [params]
* @returns {string[]}
*/
export function slopoArgs(configPath, action, params = {}) {
const global = ['--config', configPath]
const version = ['--config-version', String(SLOPO_AGENT_CONFIG_VERSION)]
const bare = ['--config', configPath]
switch (action) {
case 'review': {
const args = [...global, 'agent-review', ...version]
if (params.base !== undefined && params.base !== '') args.push('--base', params.base)
return args
}
case 'analyze':
return [...global, 'agent-analyze', ...version]
case 'analyze-one': {
const args = [...global, 'agent-analyze', ...version, '--single']
if (params.cluster !== undefined && params.cluster !== '') args.push('--cluster', params.cluster)
return args
}
case 'index':
return [...bare, 'index']
case 'embed':
return [...bare, 'embed']
case 'show-config':
return [...bare, 'show-config']
case 'version':
return ['--version']
default:
throw new Error(`unknown slopo action "${action}"`)
}
}
/**
* Turn slopo's deliberately terse agent-facing failure into something the model
* can act on.
*
* Slopo prints a short non-revealing line on stderr and writes the real detail
* to the agent log, so a bare relay of stderr leaves the agent with no move. Each
* mapping below was produced by actually triggering the failure against slopo
* 0.8.0, not by reading its error table.
*
* @param {{ stderr: string, stdout: string, logDetail?: string }} input
* @returns {{ code: string, message: string, remedy: string }}
*/
export function explainFailure({ stderr, stdout, logDetail = '' }) {
const text = `${stderr}\n${stdout}\n${logDetail}`
if (/No data in database/i.test(text)) {
return {
code: 'DATABASE_MISSING',
message: 'slopo has no index for this workspace yet.',
remedy:
'Run the `slopo` tool with action "prepare" first. It runs `slopo index`, which is local ' +
'parsing and costs nothing, and then embeds. A review cannot bootstrap the database itself.',
}
}
if (/Configuration mismatch/i.test(text)) {
return {
code: 'CONFIGURATION_MISMATCH',
message:
'The embedding model, its dimensions, the source directory, or a node-count threshold has ' +
'changed since this workspace was first indexed.',
remedy:
'Slopo freezes those five values into the database and refuses to mix generations. Delete ' +
'the database file and run "prepare" again; the next embed costs whatever embedding costs ' +
'on the configured backend, because every unit must be re-embedded.',
}
}
if (/Too many code units to embed/i.test(text)) {
return {
code: 'BACKLOG_TOO_LARGE',
message:
'More than 1000 units are unembedded. Slopo refuses to do that inside an agent call to ' +
'protect your latency.',
remedy:
'Run the `slopo` tool with action "prepare" in the background. That is the same work, just ' +
'somewhere you can watch it and cancel it.',
}
}
if (/Configuration file not found/i.test(text)) {
return {
code: 'CONFIG_MISSING',
message: 'The generated slopo config is missing, or the configured source directory is not a directory.',
remedy: 'Run the `slopo` tool with action "status" to see which settings are unset, then "configure".',
}
}
if (/Loading configuration failed/i.test(text)) {
return {
code: 'CONFIG_INVALID',
message: 'Slopo rejected the generated config file.',
remedy:
'This is a bug in this plugin rather than a problem with your settings — the file is written ' +
'from a closed allow-list. The config path and the parser error are in the detail above.',
}
}
if (/Index is out of date/i.test(text)) {
return {
code: 'INDEX_STALE',
message: 'The index predates the current source tree.',
remedy: 'Run the `slopo` tool with action "prepare" to re-index and re-embed.',
}
}
if (/Git command failed/i.test(text)) {
return {
code: 'GIT_FAILED',
message: 'Slopo shells out to git for a review, and git failed or is missing.',
remedy: 'Check that the workspace is a git repository and that git is installed and on PATH.',
}
}
if (/Embedding failed/i.test(text)) {
return {
code: 'EMBEDDING_FAILED',
message: 'The embedding backend rejected the request.',
remedy:
'Check the embedding model, dimensions and api_base in the settings namespace. The provider ' +
'error itself is in the agent log, not on stdout — read it from the log file named above.',
}
}
return {
code: 'SLOPO_FAILED',
message: 'Slopo exited non-zero and its message did not match a known failure.',
remedy: 'Read the agent log file named above; it carries the full error, while stdout is deliberately terse.',
}
}
/**
* Pull the last error line out of a slopo agent log.
*
* Slopo writes the human-readable error to the log and only a short agent-facing
* line to stderr, so the log is where the actionable text lives. Observed on a
* real failure: stderr said `Embedding failed.` while the log named the exact
* litellm parameter that was rejected.
*
* @param {string} contents
* @returns {string}
*/
export function lastLogError(contents) {
const lines = String(contents).split('\n')
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index].trim()
if (line.startsWith('Error:')) return line
}
return ''
}