Release / release (push) Successful in 6s
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.
310 lines
11 KiB
JavaScript
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 ''
|
|
}
|