diff --git a/README.md b/README.md index d307e707f..c70f56252 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,7 @@ devspace doctor - [Setup Guide](https://github.com/Waishnav/devspace/blob/main/docs/setup.md) - [ChatGPT Coding Workflow](https://github.com/Waishnav/devspace/blob/main/docs/chatgpt-coding-workflow.md) +- [Persisted Workflows](https://github.com/Waishnav/devspace/blob/main/docs/workflows.md) - [Configuration Reference](https://github.com/Waishnav/devspace/blob/main/docs/configuration.md) - [Native File Download](https://github.com/Waishnav/devspace/blob/main/docs/artifact-exchange.md) - [Security Model](https://github.com/Waishnav/devspace/blob/main/docs/security.md) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 4e001e46c..5291b0562 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -124,6 +124,9 @@ managed path `~/.devspace/skills/subagents/SKILL.md`. That copy is refreshed from the installed DevSpace package and wins over other skills named `subagents`. +DevSpace also synchronizes `~/.devspace/skills/workflows/SKILL.md` for persisted +multi-agent workflows submitted with `devspace workflow`. + When Subagents are enabled, DevSpace discovers agent profiles from `~/.devspace/agents/*.md` and project `.devspace/agents/*.md`. `open_workspace` exposes a compact catalog with profile names, descriptions, diff --git a/docs/configuration.md b/docs/configuration.md index 408018c7f..09abef94b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -115,9 +115,9 @@ DevSpace discovers standard Agent Skills from `~/.agents/skills`, project are resolved from the active workspace. When Subagents are enabled for MCP workspaces, DevSpace keeps its bundled -`subagents` skill synchronized at `~/.devspace/skills/subagents/SKILL.md`. -That managed copy is the authoritative `subagents` skill for DevSpace and is -refreshed when the packaged skill changes. +`subagents` and `workflows` skills synchronized under `~/.devspace/skills/`. +Those managed copies are authoritative for DevSpace and are refreshed when the +packaged skills change. Subagent providers are explicit. Omitted providers are disabled: diff --git a/docs/local-agent-daemon.md b/docs/local-agent-daemon.md index f940cfa21..82ea86f45 100644 --- a/docs/local-agent-daemon.md +++ b/docs/local-agent-daemon.md @@ -22,6 +22,11 @@ functionality, but `devspace serve` is not required for local-agent execution. The daemon is scoped to one DevSpace `stateDir`, so one SQLite store and one runtime owner serve all clients using that configuration. +Persisted workflows use the same daemon and local agent manager. Active +workflows keep it alive after the submitting CLI exits. Startup reconciles +agent turns before workflow records; shutdown closes workflow supervisors +before the agent manager. + Communication uses a private Unix domain socket on Linux/macOS or a named pipe on Windows. The endpoint is not exposed through the public MCP HTTP port. Provider session identifiers and logical agent records are durable; live diff --git a/docs/setup.md b/docs/setup.md index 3cbc968ab..fd3db1e34 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -76,8 +76,8 @@ These commands do not require `devspace serve`. This Coding Agent installation is separate from ChatGPT MCP usage. For MCP workspaces with Subagents enabled, DevSpace manages its own copy at -`~/.devspace/skills/subagents/SKILL.md`; users do not install that copy -manually. +`~/.devspace/skills/subagents/SKILL.md` and its bundled workflow skill at +`~/.devspace/skills/workflows/SKILL.md`; users do not install those copies manually. ### Connect ChatGPT diff --git a/docs/workflows.md b/docs/workflows.md new file mode 100644 index 000000000..2854e209c --- /dev/null +++ b/docs/workflows.md @@ -0,0 +1,62 @@ +# Workflows + +DevSpace workflows run persisted multi-agent plans through the same on-demand daemon as local subagents. A CLI process submits a run and exits immediately; the daemon remains alive until active workflows finish. + +Place reusable workflow scripts in `.devspace/workflows/` and run them by name, or provide a file directly: + +```js +export const meta = { name: "review", concurrency: 2 }; + +const findings = await agent("Review the current changes", { + target: "reviewer", + writeMode: "read_only", +}); +return { findings }; +``` + +The script body receives `args` plus the `agent`, `workflow`, `parallel`, `pipeline`, `phase`, and `log` helpers. It must return a JSON value. `agent(prompt, options)` requires a configured profile or provider in `options.target`; `workflow(name, args)` may call one named workflow, with nesting limited to one level. + +`parallel(items, worker, concurrency)` returns one outcome per input instead of failing the whole batch when one worker throws. `pipeline(items, ...stages)` passes each raw item through every stage in order; each stage receives `(currentValue, originalItem, index)`. `phase(name, run)` records durable start, completion, and failure events and gives `run` an agent helper that labels its calls with that phase. + +Run the script by name or provide a file directly: + +```bash +devspace workflow run --name review --args '{"base":"main"}' +devspace workflow run --file ./review.workflow.js --args-file ./inputs.json +``` + +Exactly one of `--file`, `--name`, or `--resume` is required. `--file` reads the source before contacting the daemon. Named workflows are resolved inside the current workspace. Runs default to `read_only`; `--write-mode allowed` permits workflow calls configured for writes. + +`run` returns an asynchronous receipt. Use the returned workflow ID with: + +```bash +devspace workflow status +devspace workflow wait --timeout 60 +devspace workflow calls +devspace workflow call +devspace workflow events --after --json +devspace workflow cancel +devspace workflow ls +``` + +`wait` accepts a timeout from 0 to 60 seconds and returns the latest durable run state on timeout. `calls` returns compact call summaries without prompts, results, or fingerprints. `call` returns the full stored record for one zero-based call index. `events` returns up to 100 persisted log and phase entries after the supplied sequence. All commands accept `--json` for scripts. + +Workflow records, calls, and provider continuation state survive CLI exits and daemon restarts. A daemon restart marks unfinished workflows interrupted for explicit inspection or resume; it does not dispatch calls automatically. At startup the daemon first reconciles local agent turns, then workflow runs. During shutdown it closes workflows before the local agent manager so supervisors can stop their child turns cleanly. + +Named workflows and their arguments are workspace scoped. IDs from another checkout, workspace, or managed worktree are rejected even when the caller can read the shared DevSpace state directory. + +## Bounds + +Workflow metadata may set `concurrency` from 1 to 16; the default is 4. The daemon admits at most eight active workflows and eight aggregate workflow agent turns, with at most sixteen script runners including nested scripts. One run may make at most 128 agent turns, including schema-correction turns, and may invoke at most eight nested workflows. Nesting is limited to one level and shares the parent run's admission, cancellation, and call budgets. + +Scripts run in QuickJS with a 32 MiB heap, 512 KiB stack, and 15-minute wall clock limit. Source is limited to 64 KiB, arguments to 128 KiB, and workflow or agent results to 256 KiB. Guest `log()` and `phase()` events share a 64 KiB limit per run; bounded internal events such as agent turns and nested workflow source snapshots are additional. These are hard product boundaries rather than tuning controls. + +An agent call may include an inline JSON Schema in `options.schema`. DevSpace compiles strict draft-07 structural schemas with at most 16 KiB, 512 nodes, and 16 levels. It rejects `$ref`, `$dynamicRef`, `$recursiveRef`, `$async`, `pattern`, `patternProperties`, and `format`. Invalid output gets one read-only correction in the same agent session; the correction counts toward the 128-turn budget. + +## Resume and worktrees + +Resume reuses only a matching prefix of completed read-only calls. Any call with write authority, isolation, or a named workflow workspace disables replay for the run. DevSpace also hashes the complete workspace root before the original and resumed runs. More than 20,000 entries, more than 64 MiB of files, any symlink, special file, unreadable path, or other hash failure produces `RECOVERY_CONTEXT_CHANGED`. Large generated trees such as `node_modules` can therefore conservatively disable resume. + +An isolated call creates a managed worktree and records it as a workspace. DevSpace retains those worktrees after the run for inspection. Normal stale-worktree cleanup preserves recovery snapshots for commits and dirty tracked changes and skips worktrees with untracked files. + +Profiles are resolved in each call’s execution workspace. For isolated worktrees, use global profiles or commit workspace profiles so they exist in the new worktree. A new worktree starts at the source’s committed `HEAD`; uncommitted source changes are not copied. diff --git a/package.json b/package.json index fa39fb46c..7af9def30 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@modelcontextprotocol/server": "^2.0.0", "@opencode-ai/sdk": "1.17.13", "@pierre/diffs": "^1.3.6", + "ajv": "^8.20.0", "better-result": "^2.10.0", "better-sqlite3": "^12.10.0", "cross-spawn": "^7.0.6", @@ -68,6 +69,7 @@ "jsonc-parser": "^3.3.1", "koffi": "^3.1.2", "lucide": "^1.24.0", + "quickjs-emscripten": "^0.32.0", "react": "^19.2.6", "react-dom": "^19.2.6", "semver": "^7.8.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92ca7f368..443930098 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@pierre/diffs': specifier: ^1.3.6 version: 1.3.6(@shikijs/themes@3.23.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + ajv: + specifier: ^8.20.0 + version: 8.20.0 better-result: specifier: ^2.10.0 version: 2.10.0 @@ -68,6 +71,9 @@ importers: lucide: specifier: ^1.24.0 version: 1.24.0 + quickjs-emscripten: + specifier: ^0.32.0 + version: 0.32.0 react: specifier: ^19.2.6 version: 19.2.6 @@ -509,6 +515,21 @@ packages: peerDependencies: hono: ^4 + '@jitl/quickjs-ffi-types@0.32.0': + resolution: {integrity: sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==} + + '@jitl/quickjs-wasmfile-debug-asyncify@0.32.0': + resolution: {integrity: sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==} + + '@jitl/quickjs-wasmfile-debug-sync@0.32.0': + resolution: {integrity: sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==} + + '@jitl/quickjs-wasmfile-release-asyncify@0.32.0': + resolution: {integrity: sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==} + + '@jitl/quickjs-wasmfile-release-sync@0.32.0': + resolution: {integrity: sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==} + '@koromix/koffi-android-arm64@3.2.1': resolution: {integrity: sha512-1pJQ4jnZlUJduK9u9DC5CGy3aOgDUPvIXpNb6syV3+Dh5Q/ugezAIGCqvY+w+1mgXsve0pd0NVvJRjdZNHQ6MA==} cpu: [arm64] @@ -1873,6 +1894,13 @@ packages: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} + quickjs-emscripten-core@0.32.0: + resolution: {integrity: sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==} + + quickjs-emscripten@0.32.0: + resolution: {integrity: sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==} + engines: {node: '>=16.0.0'} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -2673,6 +2701,24 @@ snapshots: dependencies: hono: 4.12.25 + '@jitl/quickjs-ffi-types@0.32.0': {} + + '@jitl/quickjs-wasmfile-debug-asyncify@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-debug-sync@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-release-asyncify@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-release-sync@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + '@koromix/koffi-android-arm64@3.2.1': optional: true @@ -3878,6 +3924,18 @@ snapshots: dependencies: side-channel: 1.1.0 + quickjs-emscripten-core@0.32.0: + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + quickjs-emscripten@0.32.0: + dependencies: + '@jitl/quickjs-wasmfile-debug-asyncify': 0.32.0 + '@jitl/quickjs-wasmfile-debug-sync': 0.32.0 + '@jitl/quickjs-wasmfile-release-asyncify': 0.32.0 + '@jitl/quickjs-wasmfile-release-sync': 0.32.0 + quickjs-emscripten-core: 0.32.0 + range-parser@1.2.1: {} raw-body@3.0.2: diff --git a/skills/workflows/SKILL.md b/skills/workflows/SKILL.md new file mode 100644 index 000000000..9b91fe472 --- /dev/null +++ b/skills/workflows/SKILL.md @@ -0,0 +1,49 @@ +--- +name: workflows +description: Run a persisted multi-agent DevSpace workflow from a script file or a named project workflow, then wait for or inspect its result. +--- + +# DevSpace workflows + +Use a workflow when a task has a repeatable multi-agent plan. Keep one-off delegation on the `subagents` commands. + +Named workflows live in the current project's `.devspace/workflows/` directory. Run a named workflow or a specific file: + +```bash +devspace workflow run --name [--args ''] +devspace workflow run --file [--args-file ] +``` + +The command returns a compact `` receipt. Keep the ID. Workflow code is read-only by default; add `--write-mode allowed` only when the workflow must modify the project. + +Wait up to 60 seconds at a time. Repeat `wait` if the returned status is still running; do not poll `status` in a loop. + +```bash +devspace workflow wait --timeout 60 +``` + +Inspect current state, calls, or one full call when needed: + +```bash +devspace workflow status +devspace workflow calls +devspace workflow call +devspace workflow events --after +devspace workflow ls +``` + +`events` returns at most 100 persisted log and phase entries. Keep the highest sequence and pass it with `--after` for the next page. + +Cancel only when the parent task no longer needs the run: + +```bash +devspace workflow cancel +``` + +Resume an interrupted or failed run from its durable call history: + +```bash +devspace workflow run --resume [--args ''] +``` + +Use `--json` only for scripts that need structured output. diff --git a/src/cli-workspace.test.ts b/src/cli-workspace.test.ts index 56e5fe357..24a5d7321 100644 --- a/src/cli-workspace.test.ts +++ b/src/cli-workspace.test.ts @@ -45,6 +45,33 @@ try { workspaceRoot: resolve(nestedRoot), }); + let managedChecks = 0; + assert.deepEqual(resolveCliWorkspaceContext([repositoryRoot], { + DEVSPACE_WORKSPACE_ID: "ws_managed", + DEVSPACE_WORKSPACE_ROOT: plainRoot, + }, nestedRoot, (workspaceRoot, workspaceId) => { + managedChecks += 1; + return workspaceId === "ws_managed" && workspaceRoot === plainRoot; + }), { + workspaceId: "ws_managed", + workspaceRoot: plainRoot, + }); + assert.equal(managedChecks, 1); + + resolveCliWorkspaceContext([repositoryRoot], { + DEVSPACE_WORKSPACE_ROOT: plainRoot, + }, nestedRoot, () => { + throw new Error("managed authorization must require an explicit workspace id"); + }); + + assert.throws( + () => resolveCliWorkspaceContext([repositoryRoot], { + DEVSPACE_WORKSPACE_ID: "ws_wrong", + DEVSPACE_WORKSPACE_ROOT: plainRoot, + }, nestedRoot, () => false), + /outside allowed roots/, + ); + if (process.platform !== "win32") { const repositoryAlias = join(root, "repository-alias"); symlinkSync(repositoryRoot, repositoryAlias, "dir"); diff --git a/src/cli-workspace.ts b/src/cli-workspace.ts index d09cb253a..96beec0da 100644 --- a/src/cli-workspace.ts +++ b/src/cli-workspace.ts @@ -13,6 +13,7 @@ export function resolveCliWorkspaceContext( allowedRoots: readonly string[], env: NodeJS.ProcessEnv = process.env, cwd = process.cwd(), + authorizeManagedWorkspace?: (workspaceRoot: string, workspaceId: string) => boolean, ): CliWorkspaceContext { const workspaceId = env.DEVSPACE_WORKSPACE_ID?.trim() || undefined; const injectedRoot = workspaceId ? env.DEVSPACE_WORKSPACE_ROOT?.trim() : undefined; @@ -21,11 +22,17 @@ export function resolveCliWorkspaceContext( ); if (!workspaceId) return { workspaceId, workspaceRoot: candidate }; - - return { - workspaceId, - workspaceRoot: assertAllowedPath(candidate, allowedRoots.map(canonicalizePath)), - }; + try { + return { + workspaceId, + workspaceRoot: assertAllowedPath(candidate, allowedRoots.map(canonicalizePath)), + }; + } catch (error) { + if (authorizeManagedWorkspace?.(candidate, workspaceId)) { + return { workspaceId, workspaceRoot: candidate }; + } + throw error; + } } function canonicalizePath(path: string): string { diff --git a/src/cli.ts b/src/cli.ts index 3037f65e8..9c1790689 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -56,6 +56,8 @@ import { readReviewRef } from "./review-checkpoints.js"; import { shutdownHttpServer } from "./server-shutdown.js"; import { logEvent } from "./logger.js"; import { pruneStaleManagedWorktrees } from "./worktree-prune.js"; +import { runWorkflowCommand } from "./workflow-cli.js"; +import { isManagedWorkflowWorkspace } from "./workflow-workspaces.js"; type Command = | "serve" @@ -64,6 +66,7 @@ type Command = | "config" | "worktrees" | "agents" + | "workflow" | "show-changes" | "help" | "version"; @@ -96,6 +99,11 @@ async function main(argv: string[]): Promise { case "agents": await runAgentsCommand(args); return; + case "workflow": { + const { args: commandArgs, json } = extractJsonOption(args); + await runWorkflowCommand(commandArgs, json); + return; + } case "show-changes": await runShowChanges(args); return; @@ -116,6 +124,7 @@ function normalizeCommand(command: string | undefined): Command { || command === "config" || command === "worktrees" || command === "agents" + || command === "workflow" || command === "show-changes" ) return command; if (command === "help" || command === "--help" || command === "-h") return "help"; @@ -498,6 +507,7 @@ function printHelp(): void { " devspace agents show [--json]", " devspace agents wait ... [--timeout ] [--json]", " devspace agents daemon ", + " devspace workflow [--json]", " devspace -v, --version Print the installed version", "", "For temporary tunnels:", @@ -561,10 +571,19 @@ async function runAgentsCommand(args: string[]): Promise { } } +function resolveAgentCliWorkspaceContext(config: ServerConfig) { + return resolveCliWorkspaceContext( + config.allowedRoots, + process.env, + process.cwd(), + (root, id) => isManagedWorkflowWorkspace(config, root, id), + ); +} + async function runAgentsTargets(args: string[], json: boolean): Promise { if (args.length > 0) throw new Error("Usage: devspace agents targets [--json]"); const config = loadConfig(); - const scope = resolveCliWorkspaceContext(config.allowedRoots); + const scope = resolveAgentCliWorkspaceContext(config); const profiles = await loadLocalAgentProfiles(config, scope.workspaceRoot); const providers = buildLocalAgentProviderStatuses( config.subagents, @@ -580,7 +599,7 @@ async function runAgentsList(args: string[], json: boolean): Promise { if (args.length > 0) throw new Error("Usage: devspace agents ls [--json]"); const config = loadConfig(); const client = createLocalAgentClient(config); - const result = await client.list(resolveCliWorkspaceContext(config.allowedRoots)); + const result = await client.list(resolveAgentCliWorkspaceContext(config)); const agents = presentAgentWorkflowResult(result, json); if (!agents) return; @@ -596,7 +615,7 @@ async function runAgentsList(args: string[], json: boolean): Promise { async function runAgentsRun(args: string[], json: boolean): Promise { const parsed = parseLocalAgentRunArgs(args); const config = loadConfig(); - const scope = resolveCliWorkspaceContext(config.allowedRoots); + const scope = resolveAgentCliWorkspaceContext(config); const client = createLocalAgentClient(config); const result = await client.start({ target: parsed.target, @@ -620,7 +639,7 @@ async function runAgentsContinue(args: string[], json: boolean): Promise { const parsed = parseLocalAgentContinueArgs(args); const config = loadConfig(); const client = createLocalAgentClient(config); - const scope = resolveCliWorkspaceContext(config.allowedRoots); + const scope = resolveAgentCliWorkspaceContext(config); const result = await client.continue(parsed.agentId, parsed.prompt, { model: parsed.model, effort: parsed.effort, @@ -641,7 +660,7 @@ async function runAgentsShow(args: string[], json: boolean): Promise { const config = loadConfig(); const client = createLocalAgentClient(config); - const scope = resolveCliWorkspaceContext(config.allowedRoots); + const scope = resolveAgentCliWorkspaceContext(config); const initial = await client.get(id, scope); const record = presentAgentWorkflowResult(initial, json); if (!record) return; @@ -655,7 +674,7 @@ async function runAgentsWait(args: string[], json: boolean): Promise { const { ids, timeoutMs } = parseAgentsWaitArgs(args); const config = loadConfig(); const client = createLocalAgentClient(config); - const scope = resolveCliWorkspaceContext(config.allowedRoots); + const scope = resolveAgentCliWorkspaceContext(config); const results = presentAgentWorkflowResult(await client.wait(ids, scope, timeoutMs), json); if (!results) return; if (json) { diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 8bb1f7e4f..01add1b98 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -47,6 +47,43 @@ const migrations: Migration[] = [ name: "local-agent-turns", up: migrateLocalAgentTurns, }, + { + version: 9, + name: "local-agent-authority", + up: (sqlite) => { + addColumnIfMissing(sqlite, "local_agent_sessions", "write_mode", "text not null default 'allowed'"); + addColumnIfMissing(sqlite, "local_agent_sessions", "dispatch_signature", "text"); + }, + }, + { + version: 10, + name: "dynamic-workflows", + up: (sqlite) => sqlite.exec(` + create table workflow_runs ( + id text primary key, + workspace_root text not null, + workspace_id text, + record_json text not null + ); + create index workflow_runs_workspace_idx on workflow_runs(workspace_root, workspace_id); + create table workflow_calls ( + run_id text not null references workflow_runs(id), + call_index integer not null, + agent_id text not null, + record_json text not null, + primary key (run_id, call_index) + ); + create index workflow_calls_agent_idx on workflow_calls(agent_id); + create table workflow_events ( + sequence integer primary key autoincrement, + run_id text not null references workflow_runs(id), + type text not null, + data_json text not null, + created_at text not null + ); + create index workflow_events_run_idx on workflow_events(run_id, sequence); + `), + }, ]; export function migrateDatabase(sqlite: Database.Database): void { diff --git a/src/db/schema.ts b/src/db/schema.ts index 77b234368..6048239a8 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -98,6 +98,8 @@ export const localAgentSessions = sqliteTable( workspaceId: text("workspace_id"), workspaceRoot: text("workspace_root").notNull(), profileName: text("profile_name").notNull(), + writeMode: text("write_mode").notNull().default("allowed"), + dispatchSignature: text("dispatch_signature"), provider: text("provider").notNull(), model: text("model"), effort: text("effort"), diff --git a/src/local-agent-acp.test.ts b/src/local-agent-acp.test.ts index 7a5222261..1c8ccd085 100644 --- a/src/local-agent-acp.test.ts +++ b/src/local-agent-acp.test.ts @@ -254,6 +254,104 @@ if (completedOverlappingTurn.isErr()) throw completedOverlappingTurn.error; assert.equal(completedOverlappingTurn.value.finalResponse, "overlap response"); await overlapRuntime.close(); +const cancelQueues = new Map(); +let completeCancelledPrompt!: () => void; +let markCancelledPromptEntered!: () => void; +const cancelledPromptEntered = new Promise((resolve) => { markCancelledPromptEntered = resolve; }); +const cancelledPromptCompletion = new Promise((resolve) => { completeCancelledPrompt = resolve; }); +let acpCancelCalls = 0; +const cancelConnection = { + agent: { + async request(method: string): Promise { + if (method === "session/new") { + cancelQueues.set("cancel_session", { values: [] }); + return { sessionId: "cancel_session" }; + } + if (method === "session/prompt") { + markCancelledPromptEntered(); + await cancelledPromptCompletion; + return { stopReason: "cancelled" }; + } + return {}; + }, + }, + async cancel() { + acpCancelCalls += 1; + completeCancelledPrompt(); + }, + close() {}, + closed: new Promise(() => undefined), +}; +const cancelRuntime = new AcpRuntime({ + provider: "cursor", + command: "cursor-agent", + args: ["acp"], + env: {}, + queues: cancelQueues, +}, cancelConnection); +const acpController = new AbortController(); +const cancelledAcpTurn = cancelRuntime.run({ + prompt: "cancel me", + workspaceRoot: "/tmp/project", + signal: acpController.signal, +}); +await cancelledPromptEntered; +acpController.abort(); +const cancelledAcpResult = await cancelledAcpTurn; +assert.equal(cancelledAcpResult.isErr(), true); +if (cancelledAcpResult.isErr()) assert.equal(cancelledAcpResult.error.code, "PROVIDER_CANCELLED"); +assert.equal(acpCancelCalls, 1); +assert.equal(cancelRuntime.isAlive(), true, "ACP turn cancellation keeps the runtime alive"); +await cancelRuntime.close(); + +const failedCancelQueues = new Map(); +let completeFailedCancelPrompt!: () => void; +let markFailedCancelPromptEntered!: () => void; +const failedCancelPromptEntered = new Promise((resolve) => { markFailedCancelPromptEntered = resolve; }); +const failedCancelPromptCompletion = new Promise((resolve) => { completeFailedCancelPrompt = resolve; }); +const failedCancelRuntime = new AcpRuntime({ + provider: "cursor", + command: "cursor-agent", + args: ["acp"], + env: {}, + queues: failedCancelQueues, +}, { + agent: { + async request(method: string): Promise { + if (method === "session/new") { + failedCancelQueues.set("failed_cancel_session", { values: [] }); + return { sessionId: "failed_cancel_session" }; + } + if (method === "session/prompt") { + markFailedCancelPromptEntered(); + await failedCancelPromptCompletion; + return { stopReason: "cancelled" }; + } + return {}; + }, + }, + async cancel() { throw new Error("ACP cancel failed"); }, + close() {}, + closed: new Promise(() => undefined), +}); +const failedAcpController = new AbortController(); +const failedAcpTurn = failedCancelRuntime.run({ + prompt: "cancel failure", + workspaceRoot: "/tmp/project", + signal: failedAcpController.signal, +}); +await failedCancelPromptEntered; +failedAcpController.abort(); +await new Promise((resolve) => setImmediate(resolve)); +completeFailedCancelPrompt(); +const failedAcpResult = await failedAcpTurn; +assert.equal(failedAcpResult.isErr(), true); +if (failedAcpResult.isErr()) { + assert.equal(failedAcpResult.error.code, "PROVIDER_EXECUTION_ERROR"); + assert.match(String(failedAcpResult.error.cause), /ACP cancel failed/); +} +await failedCancelRuntime.close(); + const cachedContext = { agentId: "agt_acp", provider: "cursor" as const, diff --git a/src/local-agent-acp.ts b/src/local-agent-acp.ts index 078391ea9..10f968203 100644 --- a/src/local-agent-acp.ts +++ b/src/local-agent-acp.ts @@ -50,6 +50,7 @@ interface AcpConnectionLike { agent: { request(method: string, params?: unknown): Promise; }; + cancel?(params: { sessionId: string }): Promise; close(error?: unknown): void; closed: Promise; } @@ -128,6 +129,7 @@ export class AcpRuntime implements LocalAgentRuntime { provider: this.provider, operation: "run", run: async (): Promise => { + input.signal?.throwIfAborted(); if (!this.isAlive()) { throw new AgentProviderUnavailableError({ code: "PROVIDER_UNAVAILABLE", @@ -138,6 +140,7 @@ export class AcpRuntime implements LocalAgentRuntime { }); } const sessionId = await this.openSession(input, callbacks); + input.signal?.throwIfAborted(); if (this.activeSessions.has(sessionId)) { throw new TypeError(`${this.provider} ACP session ${sessionId} already has an active turn.`); } @@ -161,14 +164,45 @@ export class AcpRuntime implements LocalAgentRuntime { : undefined; try { queue.values.length = 0; + let cancellation: Promise<{ ok: true } | { ok: false; error: unknown }> | undefined; + const cancelPrompt = () => { + cancellation ??= Promise.resolve().then(async () => { + if (!this.connection.cancel) throw new Error(`${this.provider} ACP cancellation is unavailable.`); + await this.connection.cancel({ sessionId }); + }).then( + () => ({ ok: true as const }), + (error: unknown) => ({ ok: false as const, error }), + ); + }; + input.signal?.addEventListener("abort", cancelPrompt, { once: true }); + if (input.signal?.aborted) cancelPrompt(); const standardResponse = this.connection.agent.request("session/prompt", { sessionId, prompt: [{ type: "text", text: input.prompt }], ...(promptId ? { _meta: { promptId, requestId: promptId } } : {}), }); - const response = completion - ? await Promise.race([standardResponse, completion]) - : await standardResponse; + let response: unknown; + try { + try { + response = completion + ? await Promise.race([standardResponse, completion]) + : await standardResponse; + } catch (error) { + if (input.signal?.aborted && cancellation) { + const outcome = await cancellation; + if (!outcome.ok) throw outcome.error; + throw abortError(); + } + throw error; + } + if (input.signal?.aborted && cancellation) { + const outcome = await cancellation; + if (!outcome.ok) throw outcome.error; + throw abortError(); + } + } finally { + input.signal?.removeEventListener("abort", cancelPrompt); + } if (completion && isGrokPromptCompletion(response)) { await yieldToAcpQueue(); } else if (promptId) { @@ -408,6 +442,10 @@ export class AcpRuntime implements LocalAgentRuntime { } } +function abortError(): DOMException { + return new DOMException("The operation was aborted.", "AbortError"); +} + export class AcpLocalAgentDriver implements LocalAgentDriver { readonly provider: AcpProvider; // Keep ACP warm briefly, then let the generic pool close the process so the diff --git a/src/local-agent-claude.test.ts b/src/local-agent-claude.test.ts index 14e364005..91259eb19 100644 --- a/src/local-agent-claude.test.ts +++ b/src/local-agent-claude.test.ts @@ -41,6 +41,8 @@ class FakeClaudeQuery implements ClaudeQueryLike, AsyncIterator { this.closeCount += 1; } + async interrupt(): Promise {} + async setPermissionMode(mode: string): Promise { this.permissionModes.push(mode); } @@ -176,6 +178,46 @@ await runtime.close(); await runtime.close(); assert.equal(query?.closeCount, 1); +let releaseInterruptedQuery!: () => void; +let markInterruptibleQueryReady!: () => void; +const interruptibleQueryReady = new Promise((resolve) => { markInterruptibleQueryReady = resolve; }); +const interruptedQuery = new Promise((resolve) => { releaseInterruptedQuery = resolve; }); +let interruptCalls = 0; +class InterruptibleClaudeQuery extends FakeClaudeQuery { + override async next(): Promise> { + const result = await super.next(); + markInterruptibleQueryReady(); + await interruptedQuery; + return result; + } + + override async interrupt(): Promise { + interruptCalls += 1; + releaseInterruptedQuery(); + } +} +const interruptibleRuntimeResult = await new ClaudeLocalAgentDriver(({ prompt }) => ( + new InterruptibleClaudeQuery(prompt) +)).createRuntime(context); +assert.equal(interruptibleRuntimeResult.isOk(), true); +if (interruptibleRuntimeResult.isErr()) throw interruptibleRuntimeResult.error; +const claudeController = new AbortController(); +const interruptedClaudeTurn = interruptibleRuntimeResult.value.run({ + prompt: "cancel", + workspaceRoot: "/tmp/project", + signal: claudeController.signal, +}); +await interruptibleQueryReady; +claudeController.abort(); +const interruptedClaudeResult = await interruptedClaudeTurn; +assert.equal(interruptedClaudeResult.isErr(), true); +if (interruptedClaudeResult.isErr()) { + assert.equal(interruptedClaudeResult.error.code, "PROVIDER_CANCELLED"); +} +assert.equal(interruptCalls, 1); +assert.equal(interruptibleRuntimeResult.value.isAlive(), true); +await interruptibleRuntimeResult.value.close(); + const coldRuntime = await driver.createRuntime({ ...context, providerSessionId: "cold_session" }); assert.equal(coldRuntime.isOk(), true); assert.equal(lastOptions?.resume, "cold_session"); @@ -212,6 +254,7 @@ const brokenStreamQuery: ClaudeQueryLike = { }; }, close() {}, + async interrupt() {}, async setPermissionMode() {}, async applyFlagSettings() {}, }; diff --git a/src/local-agent-claude.ts b/src/local-agent-claude.ts index a639f2f46..a4175bc2b 100644 --- a/src/local-agent-claude.ts +++ b/src/local-agent-claude.ts @@ -28,6 +28,7 @@ const CLAUDE_WORKSPACE_ALLOWED_TOOLS = [ export interface ClaudeQueryLike extends AsyncIterable { close(): void; + interrupt(): Promise; setPermissionMode(mode: ClaudePermissionMode): Promise; applyFlagSettings(settings: Record): Promise; setModel?(model?: string): Promise; @@ -97,6 +98,7 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { provider: "claude", operation: "run", run: async (): Promise => { + input.signal?.throwIfAborted(); if (!this.isAlive()) { throw new AgentProviderUnavailableError({ code: "PROVIDER_UNAVAILABLE", @@ -117,6 +119,14 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { await this.query.applyFlagSettings(flagSettings); await this.query.setPermissionMode(claudePermissionMode(input.writeMode)); if (input.model && this.query.setModel) await this.query.setModel(input.model); + input.signal?.throwIfAborted(); + let interrupt: Promise | undefined; + const onAbort = () => { + interrupt ??= Promise.resolve() + .then(() => this.query.interrupt()) + .then(() => true, () => false); + }; + input.signal?.addEventListener("abort", onAbort, { once: true }); this.inputQueue.push({ type: "user", message: { role: "user", content: input.prompt }, @@ -124,71 +134,79 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { }); const items: unknown[] = []; - for (;;) { - let next: IteratorResult; - try { - next = await this.iterator.next(); - } catch (error) { - this.alive = false; - if (isProgrammerDefect(error)) throw error; - throw new AgentProviderUnavailableError({ - code: "PROVIDER_UNAVAILABLE", - provider: "claude", - operation: "run", - retryable: true, - cause: error, - message: "Claude query stream failed.", - }); - } - if (next.done) { - this.alive = false; - throw new AgentProviderProtocolError({ - code: "PROVIDER_PROTOCOL_ERROR", - provider: "claude", - operation: "run", - retryable: true, - message: "Claude query ended before returning a result.", - }); - } - const message = next.value; - items.push(message); - const record = asRecord(message); - if (typeof record?.session_id === "string") { - const previousSessionId = this.providerSessionId; - this.providerSessionId = record.session_id; - if (previousSessionId !== this.providerSessionId) { - await callbacks?.onSessionId?.(this.providerSessionId); + try { + for (;;) { + let next: IteratorResult; + try { + next = await this.iterator.next(); + } catch (error) { + if (input.signal?.aborted && (await interrupt)) throw abortError(); + this.alive = false; + if (isProgrammerDefect(error)) throw error; + throw new AgentProviderUnavailableError({ + code: "PROVIDER_UNAVAILABLE", + provider: "claude", + operation: "run", + retryable: true, + cause: error, + message: "Claude query stream failed.", + }); } + if (input.signal?.aborted && (await interrupt)) throw abortError(); + if (next.done) { + this.alive = false; + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "claude", + operation: "run", + retryable: true, + message: "Claude query ended before returning a result.", + }); + } + const message = next.value; + items.push(message); + const record = asRecord(message); + if (typeof record?.session_id === "string") { + const previousSessionId = this.providerSessionId; + this.providerSessionId = record.session_id; + if (previousSessionId !== this.providerSessionId) { + await callbacks?.onSessionId?.(this.providerSessionId); + } + } + if (record?.type !== "result") continue; + + const resultError = claudeResultError(record); + if (resultError) { + throw new AgentProviderExecutionError({ + code: "PROVIDER_EXECUTION_ERROR", + provider: "claude", + operation: "run", + retryable: false, + cause: new Error(resultError), + message: "Claude agent turn failed.", + }); + } + const finalResponse = typeof record.result === "string" ? record.result.trim() : ""; + if (!finalResponse) { + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "claude", + operation: "run", + retryable: false, + message: "Claude did not return a final assistant response.", + }); + } + if (input.signal?.aborted && (await interrupt)) throw abortError(); + return { + provider: this.provider, + providerSessionId: this.providerSessionId ?? null, + finalResponse, + items, + }; } - if (record?.type !== "result") continue; - - const resultError = claudeResultError(record); - if (resultError) { - throw new AgentProviderExecutionError({ - code: "PROVIDER_EXECUTION_ERROR", - provider: "claude", - operation: "run", - retryable: false, - cause: new Error(resultError), - message: "Claude agent turn failed.", - }); - } - const finalResponse = typeof record.result === "string" ? record.result.trim() : ""; - if (!finalResponse) { - throw new AgentProviderProtocolError({ - code: "PROVIDER_PROTOCOL_ERROR", - provider: "claude", - operation: "run", - retryable: false, - message: "Claude did not return a final assistant response.", - }); - } - return { - provider: this.provider, - providerSessionId: this.providerSessionId ?? null, - finalResponse, - items, - }; + } finally { + input.signal?.removeEventListener("abort", onAbort); + if (input.signal?.aborted) await interrupt; } }, }); @@ -211,6 +229,10 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { } } +function abortError(): DOMException { + return new DOMException("The operation was aborted.", "AbortError"); +} + export class ClaudeLocalAgentDriver implements LocalAgentDriver { readonly provider = "claude" as const; readonly idleTimeoutMs = 3 * 60_000; diff --git a/src/local-agent-client.ts b/src/local-agent-client.ts index 5aa5bce35..239f938d8 100644 --- a/src/local-agent-client.ts +++ b/src/local-agent-client.ts @@ -28,6 +28,11 @@ import { decodeDaemonLogs, decodeDaemonStatus, decodeLocalAgentDaemonResponse, + decodeWorkflowCall, + decodeWorkflowCallList, + decodeWorkflowEventList, + decodeWorkflowRun, + decodeWorkflowRunList, encodeLocalAgentDaemonRequest, LocalAgentDaemonProtocolError, type LocalAgentDaemonErrorPayload, @@ -56,6 +61,13 @@ import type { } from "./local-agent-manager.js"; import type { LocalAgentRecord, LocalAgentWorkspaceScope } from "./local-agent-store.js"; import { devspaceConfigDir } from "./user-config.js"; +import { + WorkflowError, + type WorkflowCall, + type WorkflowEvent, + type WorkflowRun, + type WorkflowRunInput, +} from "./workflow-types.js"; const DEFAULT_STARTUP_TIMEOUT_MS = 8_000; const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; @@ -67,6 +79,7 @@ type RequestError = : M extends "agent.get" ? AgentLookupError | AgentDaemonError : M extends "agent.list" ? AgentListError | AgentDaemonError : M extends "agent.wait" ? AgentWaitError | AgentDaemonError + : M extends `workflow.${string}` ? WorkflowError | AgentDaemonError : AgentDaemonError; export interface LocalAgentClientOptions { @@ -160,6 +173,71 @@ export class LocalAgentClient { return decodeRequestResult(result, "agent.wait", decodeAgentWaitResults); } + async runWorkflow( + input: WorkflowRunInput, + ): Promise> { + const result = await this.request("workflow.run", input); + return decodeRequestResult(result, "workflow.run", decodeWorkflowRun); + } + + async getWorkflow( + id: string, + scope: LocalAgentWorkspaceScope, + ): Promise> { + const result = await this.request("workflow.get", { id, scope }); + return decodeRequestResult(result, "workflow.get", decodeWorkflowRun); + } + + async listWorkflows( + scope: LocalAgentWorkspaceScope, + ): Promise> { + const result = await this.request("workflow.list", scope); + return decodeRequestResult(result, "workflow.list", decodeWorkflowRunList); + } + + async waitWorkflow( + id: string, + scope: LocalAgentWorkspaceScope, + timeoutMs = 60_000, + ): Promise> { + const result = await this.request("workflow.wait", { id, scope, timeoutMs }, timeoutMs + this.requestTimeoutMs); + return decodeRequestResult(result, "workflow.wait", decodeWorkflowRun); + } + + async workflowCalls( + id: string, + scope: LocalAgentWorkspaceScope, + ): Promise> { + const result = await this.request("workflow.calls", { id, scope }); + return decodeRequestResult(result, "workflow.calls", decodeWorkflowCallList); + } + + async workflowCall( + id: string, + index: number, + scope: LocalAgentWorkspaceScope, + ): Promise> { + const result = await this.request("workflow.call", { id, index, scope }); + return decodeRequestResult(result, "workflow.call", decodeWorkflowCall); + } + + async workflowEvents( + id: string, + scope: LocalAgentWorkspaceScope, + after = 0, + ): Promise> { + const result = await this.request("workflow.events", { id, scope, after }); + return decodeRequestResult(result, "workflow.events", decodeWorkflowEventList); + } + + async cancelWorkflow( + id: string, + scope: LocalAgentWorkspaceScope, + ): Promise> { + const result = await this.request("workflow.cancel", { id, scope }); + return decodeRequestResult(result, "workflow.cancel", decodeWorkflowRun); + } + async status(): Promise> { const result = await this.requestExisting("daemon.status", {}); return decodeRequestResult(result, "daemon.status", decodeDaemonStatus); @@ -272,7 +350,7 @@ export class LocalAgentClient { const decoded = decodeValue(response.value.result, "hello", decodeDaemonHello); if (decoded.isErr()) return decoded; if (!decoded.value.configMatches) { - if (allowStaleBusyConfig && decoded.value.status.activeTurns > 0) { + if (allowStaleBusyConfig && daemonBusy(decoded.value.status)) { return Result.ok(decoded.value.status); } return this.replaceIdleChangedDaemon(authToken.value, decoded.value.status); @@ -288,11 +366,11 @@ export class LocalAgentClient { code: "DAEMON_CONFIG_CHANGED", operation: "startup", retryable: true, - message: status.activeTurns > 0 + message: daemonBusy(status) ? "The local agent daemon is running active turns with an older provider configuration. Retry after they finish." : "The local agent daemon is using an older provider configuration.", }); - if (status.activeTurns > 0) return Result.err(changed); + if (daemonBusy(status)) return Result.err(changed); return this.stopIdleDaemon(authToken, LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, status, changed); } @@ -312,7 +390,7 @@ export class LocalAgentClient { if (statusResponse.isErr() || !statusResponse.value.ok) return Result.err(mismatch); const status = decodeValue(statusResponse.value.result, "hello", decodeDaemonStatus); if (status.isErr()) return status; - if (status.value.activeTurns > 0) { + if (daemonBusy(status.value)) { return Result.err(new AgentDaemonProtocolMismatchError({ code: "DAEMON_PROTOCOL_MISMATCH", operation: "startup", @@ -403,7 +481,9 @@ export class LocalAgentClient { } as LocalAgentDaemonRequest, timeoutMs ?? undefined); if (response.isErr()) return response as BetterResult>; if (!response.value.ok) { - const error = decodeRemoteError(response.value.error, method); + const error = isWorkflowMethod(method) + ? decodeWorkflowRemoteError(response.value.error) + : decodeRemoteError(response.value.error, method); if (!isRequestError(method, error)) { return Result.err(new AgentDaemonInvalidResponseError({ code: "DAEMON_INVALID_RESPONSE", @@ -491,8 +571,11 @@ export class LocalAgentClient { function isObservationRequest( method: LocalAgentDaemonRequest["method"], -): method is "agent.get" | "agent.list" | "agent.wait" { - return method === "agent.get" || method === "agent.list" || method === "agent.wait"; +): boolean { + return method === "agent.get" || method === "agent.list" || method === "agent.wait" + || method === "workflow.get" || method === "workflow.list" || method === "workflow.wait" + || method === "workflow.calls" || method === "workflow.call" || method === "workflow.events" + || method === "workflow.cancel"; } export function createLocalAgentClient( @@ -621,7 +704,7 @@ async function sendRequest( }); } -function decodeRequestResult( +function decodeRequestResult( result: BetterResult, operation: string, decode: (value: unknown) => T, @@ -661,10 +744,24 @@ function decodeRemoteError( }); } +function decodeWorkflowRemoteError(payload: LocalAgentDaemonErrorPayload): LocalAgentError | WorkflowError { + const decoded = agentErrorFromPayload(payload); + return decoded && isAgentDaemonError(decoded) + ? decoded + : new WorkflowError(payload.code, payload.message, payload.retryable ?? false); +} + +type WorkflowDaemonMethod = Extract; + +function isWorkflowMethod(method: LocalAgentDaemonRequest["method"]): method is WorkflowDaemonMethod { + return method.startsWith("workflow."); +} + function isRequestError( method: LocalAgentDaemonRequest["method"], - error: LocalAgentError, + error: LocalAgentError | WorkflowError, ): boolean { + if (error instanceof WorkflowError) return method.startsWith("workflow."); const category = matchError(error, { AgentTargetError: () => "target" as const, AgentConflictError: () => "conflict" as const, @@ -697,6 +794,15 @@ function isRequestError( return category === "target" || category === "scope" || category === "store"; case "agent.list": return category === "scope" || category === "store"; + case "workflow.run": + case "workflow.get": + case "workflow.list": + case "workflow.wait": + case "workflow.calls": + case "workflow.call": + case "workflow.events": + case "workflow.cancel": + return false; case "hello": case "daemon.status": case "daemon.stop": @@ -705,6 +811,10 @@ function isRequestError( } } +function daemonBusy(status: LocalAgentDaemonStatus): boolean { + return status.activeTurns > 0 || (status.activeWorkflows ?? 0) > 0; +} + function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/src/local-agent-codex.test.ts b/src/local-agent-codex.test.ts index ee115e54d..873b25fd9 100644 --- a/src/local-agent-codex.test.ts +++ b/src/local-agent-codex.test.ts @@ -45,6 +45,7 @@ if (process.platform !== "win32") { await writeFile(command, `#!/usr/bin/env node import readline from "node:readline"; let turn = 0; +let pendingTurn; const output = (value) => process.stdout.write(JSON.stringify(value) + "\\n"); readline.createInterface({ input: process.stdin }).on("line", (line) => { const message = JSON.parse(line); @@ -64,6 +65,10 @@ readline.createInterface({ input: process.stdin }).on("line", (line) => { turn += 1; const turnId = "turn_" + turn; output({ id: message.id, result: { turn: { id: turnId } } }); + if (message.params.input[0].text === "cancel" || message.params.input[0].text === "cancel-fail") { + pendingTurn = { threadId: message.params.threadId, turnId, failInterrupt: message.params.input[0].text === "cancel-fail" }; + return; + } setImmediate(() => { if (message.params.input[0].text === "fail") { output({ method: "turn/completed", params: { threadId: message.params.threadId, turn: { id: turnId, status: "failed", error: { message: "fake failure" } } } }); @@ -79,12 +84,26 @@ readline.createInterface({ input: process.stdin }).on("line", (line) => { output({ method: "item/completed", params: { threadId: message.params.threadId, turnId, item } }); output({ method: "turn/completed", params: { threadId: message.params.threadId, turn: { id: turnId, status: "completed", items: [item] } } }); }); + return; + } + if (message.method === "turn/interrupt") { + if (pendingTurn?.failInterrupt) { + output({ id: message.id, error: { code: -32000, message: "fake interrupt failure" } }); + pendingTurn = undefined; + return; + } + output({ id: message.id, result: {} }); + if (pendingTurn) { + output({ method: "turn/completed", params: { threadId: pendingTurn.threadId, turn: { id: pendingTurn.turnId, status: "interrupted", items: [] } } }); + pendingTurn = undefined; + } } }); `, { mode: 0o700 }); await chmod(command, 0o700); - const runtime = new CodexAppServerRuntime({ command, env: process.env }); + const interruptErrors: unknown[] = []; + const runtime = new CodexAppServerRuntime({ command, env: process.env, onInterruptError: (error) => { interruptErrors.push(error); } }); try { await runtime.initialize(); let callbackSessionId: string | undefined; @@ -144,6 +163,38 @@ readline.createInterface({ input: process.stdin }).on("line", (line) => { assert.equal(policy.isOk(), true); if (policy.isErr()) throw policy.error; assert.deepEqual(JSON.parse(policy.value.finalResponse), { type: "workspaceWrite", networkAccess: true }); + const controller = new AbortController(); + const cancelled = runtime.run({ + prompt: "cancel", + workspaceRoot: "/tmp/project", + providerSessionId: first.providerSessionId ?? undefined, + signal: controller.signal, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + controller.abort(); + const cancelledResult = await cancelled; + assert.equal(cancelledResult.isErr(), true); + if (cancelledResult.isErr()) assert.equal(cancelledResult.error.code, "PROVIDER_CANCELLED"); + assert.equal(runtime.isAlive(), true, "turn cancellation keeps the shared Codex runtime alive"); + const failedInterruptController = new AbortController(); + const failedInterrupt = runtime.run({ + prompt: "cancel-fail", + workspaceRoot: "/tmp/project", + providerSessionId: first.providerSessionId ?? undefined, + signal: failedInterruptController.signal, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + failedInterruptController.abort(); + const failedInterruptResult = await failedInterrupt; + assert.equal(failedInterruptResult.isErr(), true); + if (failedInterruptResult.isErr()) assert.equal(failedInterruptResult.error.code, "PROVIDER_EXECUTION_ERROR"); + assert.equal(interruptErrors.length, 1); + const afterCancellation = await runtime.run({ + prompt: "after cancellation", + workspaceRoot: "/tmp/project", + providerSessionId: first.providerSessionId ?? undefined, + }); + assert.equal(afterCancellation.isOk(), true, "the shared runtime accepts a later turn"); await runtime.releaseSession("thread_new"); } finally { await runtime.close(); diff --git a/src/local-agent-codex.ts b/src/local-agent-codex.ts index fe3161ec8..dc80f2d26 100644 --- a/src/local-agent-codex.ts +++ b/src/local-agent-codex.ts @@ -78,6 +78,7 @@ export interface CodexAppServerRuntimeOptions { command: string; env: NodeJS.ProcessEnv; version?: string; + onInterruptError?: (error: unknown) => void; } export class CodexAppServerRuntime implements LocalAgentRuntime { @@ -95,7 +96,7 @@ export class CodexAppServerRuntime implements LocalAgentRuntime { windowsHide: true, shell: usesWindowsCommandShell(options.command), }); - this.rpc = new CodexAppServerRpc(this.child, options.version); + this.rpc = new CodexAppServerRpc(this.child, options.version, options.onInterruptError); this.child.once("exit", (code, signal) => { this.alive = false; this.rpc.fail(new Error( @@ -121,6 +122,7 @@ export class CodexAppServerRuntime implements LocalAgentRuntime { provider: this.provider, operation: "run", run: async (): Promise => { + input.signal?.throwIfAborted(); if (!this.isAlive()) { throw new AgentProviderUnavailableError({ code: "PROVIDER_UNAVAILABLE", @@ -147,7 +149,8 @@ export class CodexAppServerRuntime implements LocalAgentRuntime { } await callbacks?.onSessionId?.(threadId); - const completed = await this.rpc.runTurn(threadId, turnParams(input, threadId)); + const completed = await this.rpc.runTurn(threadId, turnParams(input, threadId), input.signal); + if (codexTurnWasCancelled(completed.event.params)) throw abortError(); const parsed = parseCompletedTurn(completed.event.params, completed.items); if (parsed.failure) { throw new AgentProviderExecutionError({ @@ -339,6 +342,9 @@ class CodexAppServerRpc { constructor( private readonly child: ChildProcessWithoutNullStreams, private readonly version?: string, + private readonly onInterruptError: (error: unknown) => void = (error) => { + console.warn(`Codex turn interrupt failed: ${errorMessage(error)}`); + }, ) { createInterface({ input: child.stdout, crlfDelay: Infinity }).on("line", (line) => this.handleLine(line)); child.stdin.on("error", (error) => this.fail(error)); @@ -360,7 +366,12 @@ class CodexAppServerRpc { this.write({ method, ...(params === undefined ? {} : { params }) }); } - async runTurn(threadId: string, params: unknown): Promise { + async runTurn( + threadId: string, + params: unknown, + signal?: AbortSignal, + ): Promise { + signal?.throwIfAborted(); if (this.fatalError) throw this.fatalError; if (this.turns.has(threadId)) throw new Error(`Codex thread ${threadId} already has an active turn.`); let resolveTurn!: (result: CodexTurnResult) => void; @@ -376,12 +387,26 @@ class CodexAppServerRpc { reject: rejectTurn, }; this.turns.set(threadId, turn); + let interrupt: Promise | undefined; + const onAbort = () => { + const turnId = turn.turnId; + if (!turnId || interrupt) return; + interrupt = Promise.resolve() + .then(() => this.request("turn/interrupt", { threadId, turnId })) + .catch((error) => { + this.onInterruptError(error); + turn.reject(error instanceof Error ? error : new Error(errorMessage(error))); + }); + }; + signal?.addEventListener("abort", onAbort, { once: true }); try { const response = await this.request("turn/start", params); turn.turnId = readString(asRecord(response)?.turn, "id"); + if (signal?.aborted) onAbort(); if (turn.completed) return { event: turn.completed, items: turn.items }; return await completion; } finally { + signal?.removeEventListener("abort", onAbort); if (this.turns.get(threadId) === turn) this.turns.delete(threadId); } } @@ -515,6 +540,15 @@ function parseCompletedTurn(params: unknown, items: unknown[]): { return { finalResponse, items: completedItems, failure }; } +function codexTurnWasCancelled(params: unknown): boolean { + const status = asRecord(asRecord(params)?.turn)?.status; + return status === "interrupted" || status === "cancelled"; +} + +function abortError(): DOMException { + return new DOMException("The operation was aborted.", "AbortError"); +} + export function codexAppServerError(message: string, version?: string, stderr?: string): Error { return new Error([ message, diff --git a/src/local-agent-daemon-lifecycle.ts b/src/local-agent-daemon-lifecycle.ts index 5ac32b93b..82573c779 100644 --- a/src/local-agent-daemon-lifecycle.ts +++ b/src/local-agent-daemon-lifecycle.ts @@ -12,7 +12,7 @@ import { } from "node:fs"; import { join, resolve } from "node:path"; -export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 5; +export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 6; export const LOCAL_AGENT_DAEMON_SOCKET_NAME = "agentd.sock"; export const LOCAL_AGENT_DAEMON_PID_NAME = "agentd.pid"; export const LOCAL_AGENT_DAEMON_LOCK_NAME = "agentd.lock"; diff --git a/src/local-agent-daemon-main.ts b/src/local-agent-daemon-main.ts index 51a9fc477..72ec1591b 100644 --- a/src/local-agent-daemon-main.ts +++ b/src/local-agent-daemon-main.ts @@ -11,6 +11,9 @@ import { LocalAgentManager } from "./local-agent-manager.js"; import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; import { LocalAgentStore } from "./local-agent-store.js"; import { localAgentProviderConfigRevision } from "./local-agent-config.js"; +import { isManagedWorkflowWorkspace } from "./workflow-workspaces.js"; +import { WorkflowManager } from "./workflow-manager.js"; +import { WorkflowStore } from "./workflow-store.js"; const config = loadConfig(); const DEFAULT_DAEMON_SHUTDOWN_TIMEOUT_MS = 10_000; @@ -30,14 +33,30 @@ const manager = new LocalAgentManager({ allowedRoots: config.allowedRoots, logger: log, subagents: config.subagents, + authorizeManagedWorkspace: (workspaceRoot, workspaceId) => isManagedWorkflowWorkspace( + config, + workspaceRoot, + workspaceId, + ), +}); +const workflowStore = new WorkflowStore(paths.stateDir); +const workflows = new WorkflowManager({ + store: workflowStore, + agents: manager, + loadProfiles: (workspaceRoot) => loadLocalAgentProfiles(config, workspaceRoot, { includeDisabled: true }), + subagents: config.subagents, + allowedRoots: config.allowedRoots, + config, }); const daemon = new LocalAgentDaemon({ stateDir: paths.stateDir, manager, + workflows, configRevision: localAgentProviderConfigRevision(config.subagents), onLockAcquired: () => { const reconciled = manager.reconcileActiveRuns(); if (reconciled.isErr()) throw reconciled.error; + workflows.reconcile(); }, onClosed: () => { if (!shuttingDown) process.exit(0); }, idleShutdownMs: parseIdleShutdownMs(process.env.DEVSPACE_AGENTD_IDLE_TIMEOUT_MS), @@ -51,6 +70,7 @@ const shutdown = () => { log("error", "daemon_forced_shutdown", { activeTurns: manager.activeTurnCount, runtimeCount: manager.runtimeCount, + activeWorkflows: workflows.activeRunCount, }); // Active records intentionally remain durable. The next daemon startup // reconciles them to error while preserving provider continuation data. @@ -66,10 +86,12 @@ try { await daemon.start(); } catch (error) { if (error instanceof LocalAgentDaemonAlreadyRunningError) { + await workflows.close(); await manager.close(); process.exit(0); } log("error", "daemon_start_failed", { error: error instanceof Error ? error.message : String(error) }); + await workflows.close(); await manager.close(); console.error(error instanceof Error ? error.message : String(error)); process.exit(1); diff --git a/src/local-agent-daemon-protocol.test.ts b/src/local-agent-daemon-protocol.test.ts index bd340d5b5..b03bab890 100644 --- a/src/local-agent-daemon-protocol.test.ts +++ b/src/local-agent-daemon-protocol.test.ts @@ -5,6 +5,9 @@ import { decodeDaemonHello, decodeLocalAgentDaemonRequest, decodeLocalAgentDaemonResponse, + decodeWorkflowCall, + decodeWorkflowEventList, + decodeWorkflowRun, encodeLocalAgentDaemonResponse, LocalAgentDaemonProtocolError, } from "./local-agent-daemon-protocol.js"; @@ -216,3 +219,94 @@ assert.deepEqual(decodeAgentWaitResults([ error: { code: "PROVIDER_EXECUTION_ERROR", message: "Failed.", retryable: true }, }, ]); + +const workflowRunRequest = decodeLocalAgentDaemonRequest({ + requestId: "req_workflow_run", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + authToken: "test-secret", + method: "workflow.run", + params: { + workspaceId: "ws_test", + workspaceRoot: "/tmp/project", + name: "review", + args: { base: "main" }, + writeMode: "read_only", + }, +}); +assert.equal(workflowRunRequest.method, "workflow.run"); +if (workflowRunRequest.method !== "workflow.run") throw new Error("expected workflow.run request"); +assert.deepEqual(workflowRunRequest.params.args, { base: "main" }); +assert.throws(() => decodeLocalAgentDaemonRequest({ + requestId: "req_workflow_ambiguous", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + authToken: "test-secret", + method: "workflow.run", + params: { workspaceRoot: "/tmp/project", name: "review", resume: "wfl_old" }, +}), /Exactly one workflow/); +assert.throws(() => decodeLocalAgentDaemonRequest({ + requestId: "req_workflow_wait", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + authToken: "test-secret", + method: "workflow.wait", + params: { + id: "wfl_test", + scope: { workspaceRoot: "/tmp/project" }, + timeoutMs: 60_001, + }, +}), /between 0 and 60000/); + +const workflowRun = decodeWorkflowRun({ + id: "wfl_test", + workspaceId: "ws_test", + workspaceRoot: "/tmp/project", + name: "review", + status: "running", + writeMode: "read_only", + concurrency: 2, + createdAt: "now", + updatedAt: "now", + callCount: 1, +}); +assert.equal(workflowRun.id, "wfl_test"); + +const workflowCall = decodeWorkflowCall({ + runId: "wfl_test", + index: 0, + agentId: "agt_test", + status: "running", + prompt: "Review this", + options: { target: "reviewer", label: "review" }, + fingerprint: "fingerprint", + workspaceRoot: "/tmp/project", + createdAt: "now", + updatedAt: "now", +}); +assert.equal(workflowCall.options.target, "reviewer"); + +const workflowEventsRequest = decodeLocalAgentDaemonRequest({ + requestId: "req_workflow_events", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + authToken: "test-secret", + method: "workflow.events", + params: { + id: "wfl_test", + scope: { workspaceRoot: "/tmp/project" }, + after: 12, + }, +}); +assert.equal(workflowEventsRequest.method, "workflow.events"); +if (workflowEventsRequest.method !== "workflow.events") throw new Error("expected workflow.events request"); +assert.equal(workflowEventsRequest.params.after, 12); +assert.deepEqual(decodeWorkflowEventList([{ + sequence: 13, + runId: "wfl_test", + type: "phase_started", + data: { phase: "review" }, + createdAt: "now", +}]), [{ + sequence: 13, + runId: "wfl_test", + type: "phase_started", + data: { phase: "review" }, + createdAt: "now", +}]); diff --git a/src/local-agent-daemon-protocol.ts b/src/local-agent-daemon-protocol.ts index 21aeb6806..8a40a65e6 100644 --- a/src/local-agent-daemon-protocol.ts +++ b/src/local-agent-daemon-protocol.ts @@ -9,6 +9,15 @@ import type { StartLocalAgentInput, } from "./local-agent-manager.js"; import type { LocalAgentWriteMode } from "./local-agent-runtime.js"; +import type { + WorkflowAgentOptions, + WorkflowCall, + WorkflowEvent, + WorkflowRun, + WorkflowRunInput, + WorkflowStatus, + WorkflowWriteMode, +} from "./workflow-types.js"; import { LOCAL_AGENT_DAEMON_PROTOCOL_VERSION } from "./local-agent-daemon-lifecycle.js"; export type LocalAgentDaemonMethod = @@ -18,6 +27,14 @@ export type LocalAgentDaemonMethod = | "agent.get" | "agent.list" | "agent.wait" + | "workflow.run" + | "workflow.get" + | "workflow.list" + | "workflow.wait" + | "workflow.calls" + | "workflow.call" + | "workflow.events" + | "workflow.cancel" | "daemon.status" | "daemon.stop" | "daemon.logs"; @@ -33,6 +50,14 @@ export type LocalAgentDaemonRequest = scope: LocalAgentWorkspaceScope; timeoutMs?: number; }> + | AgentDaemonRequestBase<"workflow.run", WorkflowRunInput> + | AgentDaemonRequestBase<"workflow.get", { id: string; scope: LocalAgentWorkspaceScope }> + | AgentDaemonRequestBase<"workflow.list", LocalAgentWorkspaceScope> + | AgentDaemonRequestBase<"workflow.wait", { id: string; scope: LocalAgentWorkspaceScope; timeoutMs?: number }> + | AgentDaemonRequestBase<"workflow.calls", { id: string; scope: LocalAgentWorkspaceScope }> + | AgentDaemonRequestBase<"workflow.call", { id: string; index: number; scope: LocalAgentWorkspaceScope }> + | AgentDaemonRequestBase<"workflow.events", { id: string; scope: LocalAgentWorkspaceScope; after?: number }> + | AgentDaemonRequestBase<"workflow.cancel", { id: string; scope: LocalAgentWorkspaceScope }> | AgentDaemonRequestBase<"daemon.status", Record> | AgentDaemonRequestBase<"daemon.stop", { ifIdle?: boolean }> | AgentDaemonRequestBase<"daemon.logs", { lines?: number }>; @@ -55,6 +80,7 @@ export interface LocalAgentDaemonStatus { endpoint: string; startedAt: string; activeTurns: number; + activeWorkflows?: number; runtimeCount: number; clientConnections: number; } @@ -168,6 +194,51 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR method, params: decodeWaitParams(params), } as LocalAgentDaemonRequest; + case "workflow.run": + return { requestId, protocolVersion, authToken, method, params: decodeWorkflowRunInput(params) }; + case "workflow.get": + case "workflow.calls": + case "workflow.cancel": + return { requestId, protocolVersion, authToken, method, params: decodeWorkflowLookup(params) }; + case "workflow.events": { + const lookup = decodeWorkflowLookup(params); + const after = asRecord(params)?.after; + return { + requestId, + protocolVersion, + authToken, + method, + params: { + ...lookup, + ...(after === undefined ? {} : { after: requiredNonNegativeInteger(after, "after") }), + }, + }; + } + case "workflow.list": + return { requestId, protocolVersion, authToken, method, params: decodeWorkspaceScope(params) }; + case "workflow.wait": { + const lookup = decodeWorkflowLookup(params); + return { + requestId, + protocolVersion, + authToken, + method, + params: { ...lookup, ...decodeOptionalTimeout(params, 60_000) }, + }; + } + case "workflow.call": { + const lookup = decodeWorkflowLookup(params); + return { + requestId, + protocolVersion, + authToken, + method, + params: { + ...lookup, + index: requiredNonNegativeInteger(asRecord(params)?.index, "index"), + }, + }; + } case "daemon.logs": return { requestId, @@ -221,6 +292,7 @@ export function decodeAgentRecord(value: unknown): LocalAgentRecord { provider: requiredString(record?.provider, "provider"), model: optionalString(record?.model), effort: optionalString(record?.effort), + writeMode: decodeWriteMode(record?.writeMode) ?? "allowed", providerSessionId: optionalString(record?.providerSessionId), status, latestResponse: typeof record?.latestResponse === "string" ? record.latestResponse : undefined, @@ -271,6 +343,97 @@ export function decodeAgentWaitResults(value: unknown): LocalAgentWaitResult[] { }); } +export function decodeWorkflowRun(value: unknown): WorkflowRun { + const record = asRecord(value); + const status = requiredString(record?.status, "status"); + if (!isWorkflowStatus(status)) { + throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid workflow status."); + } + const writeMode = requiredString(record?.writeMode, "writeMode"); + if (!isWorkflowWriteMode(writeMode)) { + throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid workflow write mode."); + } + return { + id: requiredString(record?.id, "id"), + workspaceId: optionalString(record?.workspaceId), + workspaceRoot: requiredString(record?.workspaceRoot, "workspaceRoot"), + name: requiredString(record?.name, "name"), + status, + writeMode, + concurrency: requiredNonNegativeInteger(record?.concurrency, "concurrency"), + resumeOf: optionalString(record?.resumeOf), + ...(record && "result" in record ? { result: record.result } : {}), + ...(record?.error === undefined ? {} : { error: decodeWorkflowFailure(record.error) }), + createdAt: requiredString(record?.createdAt, "createdAt"), + updatedAt: requiredString(record?.updatedAt, "updatedAt"), + callCount: requiredNonNegativeInteger(record?.callCount, "callCount"), + }; +} + +export function decodeWorkflowRunList(value: unknown): WorkflowRun[] { + if (!Array.isArray(value)) throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned an invalid workflow list."); + return value.map(decodeWorkflowRun); +} + +export function decodeWorkflowCall(value: unknown): WorkflowCall { + const record = asRecord(value); + const status = requiredString(record?.status, "status"); + if (!isWorkflowCallStatus(status)) { + throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid workflow call status."); + } + const options = asRecord(record?.options); + if (!options) throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid workflow call options."); + const decodedOptions: WorkflowAgentOptions = { + target: requiredString(options.target, "options.target"), + model: optionalString(options.model), + effort: optionalString(options.effort), + ...(asRecord(options.schema) ? { schema: options.schema as Record } : {}), + label: optionalString(options.label), + phase: optionalString(options.phase), + ...(isWorkflowWriteMode(options.writeMode) ? { writeMode: options.writeMode } : {}), + ...(options.isolation === "worktree" ? { isolation: "worktree" } : {}), + workspace: optionalString(options.workspace), + }; + return { + runId: requiredString(record?.runId, "runId"), + index: requiredNonNegativeInteger(record?.index, "index"), + agentId: requiredString(record?.agentId, "agentId"), + ...(record?.turnId === undefined ? {} : { turnId: requiredNonNegativeInteger(record.turnId, "turnId") }), + status, + prompt: requiredContentString(record?.prompt, "prompt"), + options: decodedOptions, + fingerprint: requiredString(record?.fingerprint, "fingerprint"), + workspaceRoot: requiredString(record?.workspaceRoot, "workspaceRoot"), + workspaceId: optionalString(record?.workspaceId), + ...(record && "result" in record ? { result: record.result } : {}), + ...(record?.error === undefined ? {} : { error: decodeWorkflowFailure(record.error) }), + reusedFrom: optionalString(record?.reusedFrom), + createdAt: requiredString(record?.createdAt, "createdAt"), + updatedAt: requiredString(record?.updatedAt, "updatedAt"), + }; +} + +export function decodeWorkflowCallList(value: unknown): WorkflowCall[] { + if (!Array.isArray(value)) throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned an invalid workflow call list."); + return value.map(decodeWorkflowCall); +} + +export function decodeWorkflowEventList(value: unknown): WorkflowEvent[] { + if (!Array.isArray(value) || value.length > 100) { + throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned an invalid workflow event page."); + } + return value.map((entry): WorkflowEvent => { + const record = asRecord(entry); + return { + sequence: requiredNonNegativeInteger(record?.sequence, "sequence"), + runId: requiredString(record?.runId, "runId"), + type: requiredString(record?.type, "type"), + data: record?.data, + createdAt: requiredString(record?.createdAt, "createdAt"), + }; + }); +} + export function decodeDaemonStatus(value: unknown): LocalAgentDaemonStatus { const record = asRecord(value); const state = requiredString(record?.state, "state"); @@ -284,6 +447,9 @@ export function decodeDaemonStatus(value: unknown): LocalAgentDaemonStatus { endpoint: requiredString(record?.endpoint, "endpoint"), startedAt: requiredString(record?.startedAt, "startedAt"), activeTurns: requiredInteger(record?.activeTurns, "activeTurns"), + ...(record?.activeWorkflows === undefined ? {} : { + activeWorkflows: requiredNonNegativeInteger(record.activeWorkflows, "activeWorkflows"), + }), runtimeCount: requiredInteger(record?.runtimeCount, "runtimeCount"), clientConnections: requiredInteger(record?.clientConnections, "clientConnections"), }; @@ -399,6 +565,46 @@ function decodeWaitParams(value: unknown): { }; } +function decodeWorkflowRunInput(value: unknown): WorkflowRunInput { + const record = asRecord(value); + if (!record) throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Workflow input must be an object."); + const source = optionalContentString(record.source); + const name = optionalString(record.name); + const resume = optionalString(record.resume); + if ([source, name, resume].filter((entry) => entry !== undefined).length !== 1) { + throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Exactly one workflow source, name, or resume id is required."); + } + const writeMode = record.writeMode; + if (writeMode !== undefined && writeMode !== "read_only" && writeMode !== "allowed") { + throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Invalid workflow write mode."); + } + return { + ...decodeWorkspaceScope(record), + ...(source === undefined ? {} : { source }), + ...(name === undefined ? {} : { name }), + ...(resume === undefined ? {} : { resume }), + ...(record.args === undefined ? {} : { args: record.args }), + ...(writeMode === undefined ? {} : { writeMode }), + }; +} + +function decodeWorkflowLookup(value: unknown): { id: string; scope: LocalAgentWorkspaceScope } { + const record = asRecord(value); + return { + id: requiredString(record?.id, "id"), + scope: decodeWorkspaceScope(record?.scope), + }; +} + +function decodeOptionalTimeout(value: unknown, max: number): { timeoutMs?: number } { + const timeoutMs = asRecord(value)?.timeoutMs; + if (timeoutMs === undefined) return {}; + if (!Number.isSafeInteger(timeoutMs) || (timeoutMs as number) < 0 || (timeoutMs as number) > max) { + throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", `Wait timeout must be an integer between 0 and ${max} milliseconds.`); + } + return { timeoutMs: timeoutMs as number }; +} + function decodeWaitError(value: unknown): { code: string; message: string; retryable: boolean } { const record = asRecord(value); return { @@ -408,6 +614,10 @@ function decodeWaitError(value: unknown): { code: string; message: string; retry }; } +function decodeWorkflowFailure(value: unknown): { code: string; message: string; retryable: boolean } { + return decodeWaitError(value); +} + function decodeLogsParams(value: unknown): { lines?: number } { if (value === undefined) return {}; const record = asRecord(value); @@ -430,6 +640,20 @@ function isLocalAgentStatus(value: string): value is LocalAgentStatus { return value === "starting" || value === "running" || value === "idle" || value === "error" || value === "stopped"; } +function isWorkflowStatus(value: string): value is WorkflowStatus { + return value === "starting" || value === "running" || value === "stopping" || value === "completed" + || value === "failed" || value === "cancelled" || value === "interrupted"; +} + +function isWorkflowWriteMode(value: unknown): value is WorkflowWriteMode { + return value === "read_only" || value === "allowed"; +} + +function isWorkflowCallStatus(value: string): value is WorkflowCall["status"] { + return value === "queued" || value === "running" || value === "completed" || value === "failed" + || value === "cancelled" || value === "interrupted"; +} + function requiredString(value: unknown, field: string): string { const result = optionalString(value); if (!result) throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", `Missing ${field}.`); @@ -449,6 +673,12 @@ function requiredInteger(value: unknown, field: string): number { return value; } +function requiredNonNegativeInteger(value: unknown, field: string): number { + const result = requiredInteger(value, field); + if (result < 0) throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", `${field} must be non-negative.`); + return result; +} + function requiredBoolean(value: unknown, field: string): boolean { if (typeof value !== "boolean") { throw new LocalAgentDaemonProtocolError("INVALID_PROTOCOL", `Invalid ${field}.`); diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index 88722dafa..b5f6a3010 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -10,7 +10,11 @@ import { localAgentDaemonEnvironment, LocalAgentClient, } from "./local-agent-client.js"; -import { LocalAgentDaemon, type LocalAgentDaemonManager } from "./local-agent-daemon.js"; +import { + LocalAgentDaemon, + type LocalAgentDaemonManager, + type LocalAgentDaemonWorkflowManager, +} from "./local-agent-daemon.js"; import { ensureLocalAgentDaemonSecret, LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, @@ -22,8 +26,10 @@ import { } from "./local-agent-daemon-protocol.js"; import type { RunOverrides, StartLocalAgentInput } from "./local-agent-manager.js"; import type { LocalAgentRecord } from "./local-agent-store.js"; +import type { WorkflowCall, WorkflowEvent, WorkflowRun, WorkflowRunInput } from "./workflow-types.js"; -const root = await mkdtemp(join(tmpdir(), "devspace-agentd-test-")); +// macOS limits the complete Unix socket path to 104 bytes. +const root = await mkdtemp(join(tmpdir(), "ds-ad-")); const CONFIG_REVISION = "test-provider-config"; const record: LocalAgentRecord = { id: "agt_test", @@ -31,10 +37,44 @@ const record: LocalAgentRecord = { workspaceRoot: join(root, "project"), profileName: "reviewer", provider: "codex", + writeMode: "read_only", + status: "running", + createdAt: "now", + updatedAt: "now", +}; +const workflowRun: WorkflowRun = { + id: "wfl_test", + workspaceId: "ws_test", + workspaceRoot: join(root, "project"), + name: "review", + status: "running", + writeMode: "read_only", + concurrency: 1, + createdAt: "now", + updatedAt: "now", + callCount: 1, +}; +const workflowCall: WorkflowCall = { + runId: workflowRun.id, + index: 0, + agentId: record.id, status: "running", + prompt: "Review this", + options: { target: "reviewer" }, + fingerprint: "fingerprint", + workspaceRoot: workflowRun.workspaceRoot, + workspaceId: workflowRun.workspaceId, createdAt: "now", updatedAt: "now", }; +const workflowEvent: WorkflowEvent = { + sequence: 1, + runId: workflowRun.id, + type: "phase_started", + data: { phase: "review" }, + createdAt: "now", +}; +const closeOrder: string[] = []; class FakeManager implements LocalAgentDaemonManager { activeTurnCount = 1; @@ -98,16 +138,52 @@ class FakeManager implements LocalAgentDaemonManager { async evictIdle(): Promise {} async close(): Promise { + closeOrder.push("agents"); this.closed = true; this.activeTurnCount = 0; } } +class FakeWorkflows implements LocalAgentDaemonWorkflowManager { + activeRunCount = 0; + closed = false; + blockWaitUntilAbort = false; + waitAborted = false; + lastInput?: WorkflowRunInput; + async run(input: WorkflowRunInput): Promise { + this.lastInput = input; + return workflowRun; + } + get(): WorkflowRun { return workflowRun; } + list(): WorkflowRun[] { return [workflowRun]; } + async wait(_id: string, _scope: unknown, _timeoutMs?: number, signal?: AbortSignal): Promise { + if (this.blockWaitUntilAbort) { + await new Promise((resolveAbort) => { + const onAbort = () => { this.waitAborted = true; resolveAbort(); }; + if (signal?.aborted) onAbort(); + else signal?.addEventListener("abort", onAbort, { once: true }); + }); + } + return workflowRun; + } + calls(): WorkflowCall[] { return [workflowCall]; } + call(): WorkflowCall { return workflowCall; } + events(): WorkflowEvent[] { return [workflowEvent]; } + async cancel(): Promise { return { ...workflowRun, status: "cancelled" }; } + async close(): Promise { + closeOrder.push("workflows"); + this.closed = true; + this.activeRunCount = 0; + } +} + const manager = new FakeManager(); +const workflows = new FakeWorkflows(); const daemon = new LocalAgentDaemon({ stateDir: join(root, "state"), configRevision: CONFIG_REVISION, manager, + workflows, idleShutdownMs: 60_000, }); const client = new LocalAgentClient({ @@ -173,14 +249,84 @@ try { assert.deepEqual(unwrap(await client.wait([record.id], recordScope, 0)), [ { id: record.id, status: "running" }, ]); + assert.equal(unwrap(await client.runWorkflow({ + ...recordScope, + name: "review", + })).id, workflowRun.id); + assert.equal(workflows.lastInput?.name, "review"); + assert.equal(workflows.lastInput?.workspaceId, recordScope.workspaceId); + assert.equal(workflows.lastInput?.workspaceRoot, recordScope.workspaceRoot); + assert.equal(unwrap(await client.getWorkflow(workflowRun.id, recordScope)).id, workflowRun.id); + assert.equal(unwrap(await client.listWorkflows(recordScope))[0]?.id, workflowRun.id); + assert.equal(unwrap(await client.waitWorkflow(workflowRun.id, recordScope, 0)).status, "running"); + assert.equal(unwrap(await client.workflowCalls(workflowRun.id, recordScope))[0]?.index, 0); + assert.equal(unwrap(await client.workflowCall(workflowRun.id, 0, recordScope)).agentId, record.id); + assert.equal(unwrap(await client.workflowEvents(workflowRun.id, recordScope))[0]?.type, "phase_started"); + assert.equal(unwrap(await client.cancelWorkflow(workflowRun.id, recordScope)).status, "cancelled"); assert.equal(unwrap(await client.status()).state, "ready"); + closeOrder.length = 0; unwrap(await client.stop()); await waitFor(() => manager.closed && !existsSync(daemon.paths.socketPath)); + assert.deepEqual(closeOrder, ["workflows", "agents"]); } finally { await daemon.close(); } +const workflowWaitStateDir = join(root, "ww"); +const workflowWaitManager = new FakeManager(); +workflowWaitManager.activeTurnCount = 0; +const workflowWaits = new FakeWorkflows(); +workflowWaits.blockWaitUntilAbort = true; +const workflowWaitDaemon = new LocalAgentDaemon({ + stateDir: workflowWaitStateDir, + configRevision: CONFIG_REVISION, + manager: workflowWaitManager, + workflows: workflowWaits, + idleShutdownMs: 60_000, +}); +const workflowWaitClient = new LocalAgentClient({ + stateDir: workflowWaitStateDir, + configRevision: CONFIG_REVISION, + startupTimeoutMs: 2_000, + requestTimeoutMs: 20, + spawnDaemon: () => { void workflowWaitDaemon.start(); }, +}); +try { + const result = await workflowWaitClient.waitWorkflow(workflowRun.id, { + workspaceId: workflowRun.workspaceId, + workspaceRoot: workflowRun.workspaceRoot, + }, 0); + assert.equal(result.isErr(), true); + await waitFor(() => workflowWaits.waitAborted); + assert.equal(workflowWaits.activeRunCount, 0, "disconnecting a wait must not cancel the workflow"); +} finally { + await workflowWaitDaemon.close(); +} + +const activeWorkflowStateDir = join(root, "aw"); +const activeWorkflowManager = new FakeManager(); +activeWorkflowManager.activeTurnCount = 0; +const activeWorkflows = new FakeWorkflows(); +activeWorkflows.activeRunCount = 1; +const activeWorkflowDaemon = new LocalAgentDaemon({ + stateDir: activeWorkflowStateDir, + configRevision: CONFIG_REVISION, + manager: activeWorkflowManager, + workflows: activeWorkflows, + idleShutdownMs: 20, + idleCheckIntervalMs: 5, +}); +try { + await activeWorkflowDaemon.start(); + await new Promise((resolveDelay) => setTimeout(resolveDelay, 60)); + assert.equal(activeWorkflowManager.closed, false, "active workflows must keep the daemon alive"); + activeWorkflows.activeRunCount = 0; + await waitFor(() => activeWorkflowManager.closed); +} finally { + await activeWorkflowDaemon.close(); +} + const idleStateDir = join(root, "idle-state"); const idleManager = new FakeManager(); idleManager.activeTurnCount = 0; @@ -305,10 +451,12 @@ try { const staleActiveStateDir = join(root, "sa"); const staleActiveManager = new FakeManager(); +const staleActiveWorkflows = new FakeWorkflows(); const staleActiveDaemon = new LocalAgentDaemon({ stateDir: staleActiveStateDir, configRevision: "old-provider-config", manager: staleActiveManager, + workflows: staleActiveWorkflows, idleShutdownMs: 60_000, }); let staleActiveSpawns = 0; @@ -335,6 +483,7 @@ try { assert.deepEqual(unwrap(await staleActiveClient.wait([record.id], staleScope, 0)), [ { id: record.id, status: "running" }, ]); + assert.equal(unwrap(await staleActiveClient.cancelWorkflow(workflowRun.id, staleScope)).status, "cancelled"); const blockedStart = await staleActiveClient.run({ target: "reviewer", prompt: "must use current provider config", diff --git a/src/local-agent-daemon.ts b/src/local-agent-daemon.ts index aac96e9d9..a21e3ac8a 100644 --- a/src/local-agent-daemon.ts +++ b/src/local-agent-daemon.ts @@ -43,6 +43,7 @@ import type { StartLocalAgentInput, } from "./local-agent-manager.js"; import type { LocalAgentRecord, LocalAgentWorkspaceScope } from "./local-agent-store.js"; +import { WorkflowError, type WorkflowCall, type WorkflowEvent, type WorkflowRun, type WorkflowRunInput } from "./workflow-types.js"; const MAX_REQUEST_BYTES = 512 * 1024; const DEFAULT_DAEMON_IDLE_SHUTDOWN_MS = 30_000; @@ -67,9 +68,23 @@ export interface LocalAgentDaemonManager { readonly runtimeCount: number; } +export interface LocalAgentDaemonWorkflowManager { + run(input: WorkflowRunInput): Promise; + get(id: string, scope: LocalAgentWorkspaceScope): WorkflowRun; + list(scope: LocalAgentWorkspaceScope): WorkflowRun[]; + wait(id: string, scope: LocalAgentWorkspaceScope, timeoutMs?: number, signal?: AbortSignal): Promise; + calls(id: string, scope: LocalAgentWorkspaceScope): WorkflowCall[]; + call(id: string, index: number, scope: LocalAgentWorkspaceScope): WorkflowCall; + events(id: string, scope: LocalAgentWorkspaceScope, after?: number): WorkflowEvent[]; + cancel(id: string, scope: LocalAgentWorkspaceScope): Promise; + close(): Promise; + readonly activeRunCount: number; +} + export interface LocalAgentDaemonOptions { stateDir: string; manager: LocalAgentDaemonManager; + workflows?: LocalAgentDaemonWorkflowManager; configRevision: string; idleShutdownMs?: number; idleCheckIntervalMs?: number; @@ -84,6 +99,7 @@ export interface LocalAgentDaemonOptions { export class LocalAgentDaemon { readonly paths: LocalAgentDaemonPaths; private readonly manager: LocalAgentDaemonManager; + private readonly workflows?: LocalAgentDaemonWorkflowManager; private readonly configRevision: string; private readonly lock: LocalAgentDaemonLock; private readonly idleShutdownMs: number; @@ -108,6 +124,7 @@ export class LocalAgentDaemon { constructor(options: LocalAgentDaemonOptions) { this.paths = options.paths ?? localAgentDaemonPaths(options.stateDir); this.manager = options.manager; + this.workflows = options.workflows; this.configRevision = options.configRevision; this.lock = new LocalAgentDaemonLock(this.paths); this.idleShutdownMs = options.idleShutdownMs ?? DEFAULT_DAEMON_IDLE_SHUTDOWN_MS; @@ -178,6 +195,7 @@ export class LocalAgentDaemon { endpoint: this.paths.endpoint, startedAt: this.startedAt, activeTurns: this.manager.activeTurnCount, + activeWorkflows: this.workflows?.activeRunCount ?? 0, runtimeCount: this.manager.runtimeCount, clientConnections: this.sockets.size, }; @@ -196,10 +214,11 @@ export class LocalAgentDaemon { }); for (const socket of this.sockets) socket.destroy(); this.sockets.clear(); - const [serverResult, managerResult] = await Promise.allSettled([ - withTimeout(closeServer(this.server), this.shutdownTimeoutMs, "daemon socket shutdown"), - withTimeout(this.manager.close(), this.shutdownTimeoutMs, "daemon manager shutdown"), - ]); + const serverResult = await settled(withTimeout(closeServer(this.server), this.shutdownTimeoutMs, "daemon socket shutdown")); + const workflowResult = this.workflows + ? await settled(withTimeout(this.workflows.close(), this.shutdownTimeoutMs, "workflow manager shutdown")) + : undefined; + const managerResult = await settled(withTimeout(this.manager.close(), this.shutdownTimeoutMs, "daemon manager shutdown")); if (serverResult.status === "rejected") { writeLocalAgentDaemonLog(this.paths, "warn", "daemon_socket_close_failed", { error: errorMessage(serverResult.reason), @@ -210,6 +229,11 @@ export class LocalAgentDaemon { error: errorMessage(managerResult.reason), }); } + if (workflowResult?.status === "rejected") { + writeLocalAgentDaemonLog(this.paths, "warn", "workflow_manager_close_failed", { + error: errorMessage(workflowResult.reason), + }); + } removeLocalAgentDaemonFiles(this.paths); this.lock.release(); writeLocalAgentDaemonLog(this.paths, "info", "daemon_stopped", {}); @@ -339,12 +363,33 @@ export class LocalAgentDaemon { request.params.timeoutMs, signal, )); + case "workflow.run": + return this.runWorkflowRequest(() => this.requireWorkflows().run(request.params)); + case "workflow.get": + return this.requireWorkflows().get(request.params.id, request.params.scope); + case "workflow.list": + return this.requireWorkflows().list(request.params); + case "workflow.wait": + return this.requireWorkflows().wait( + request.params.id, + request.params.scope, + request.params.timeoutMs, + signal, + ); + case "workflow.calls": + return this.requireWorkflows().calls(request.params.id, request.params.scope); + case "workflow.call": + return this.requireWorkflows().call(request.params.id, request.params.index, request.params.scope); + case "workflow.events": + return this.requireWorkflows().events(request.params.id, request.params.scope, request.params.after); + case "workflow.cancel": + return this.runWorkflowRequest(() => this.requireWorkflows().cancel(request.params.id, request.params.scope)); case "daemon.status": return this.status(); case "daemon.stop": if (request.params.ifIdle) { this.accepting = false; - if (this.activeTurnRequests > 0 || this.manager.activeTurnCount > 0) { + if (this.activeTurnRequests > 0 || this.manager.activeTurnCount > 0 || (this.workflows?.activeRunCount ?? 0) > 0) { this.accepting = true; throw new AgentDaemonUnavailableError({ code: "DAEMON_UNAVAILABLE", @@ -373,6 +418,20 @@ export class LocalAgentDaemon { } } + private async runWorkflowRequest(operation: () => Promise): Promise { + this.activeTurnRequests += 1; + try { + return await operation(); + } finally { + this.activeTurnRequests -= 1; + } + } + + private requireWorkflows(): LocalAgentDaemonWorkflowManager { + if (!this.workflows) throw new WorkflowError("WORKFLOWS_UNAVAILABLE", "Workflows are unavailable in this daemon."); + return this.workflows; + } + private writeError(socket: Socket, requestId: string, error: LocalAgentDaemonErrorPayload): void { socket.end(encodeLocalAgentDaemonResponse({ requestId, @@ -391,7 +450,8 @@ export class LocalAgentDaemon { private async maintainIdle(): Promise { await this.manager.evictIdle(this.now()); - if (this.stopping || this.manager.activeTurnCount > 0 || this.manager.runtimeCount > 0 || this.sockets.size > 0) { + if (this.stopping || this.manager.activeTurnCount > 0 || this.manager.runtimeCount > 0 + || (this.workflows?.activeRunCount ?? 0) > 0 || this.sockets.size > 0) { this.idleSince = undefined; return; } @@ -443,6 +503,14 @@ async function withTimeout(promise: Promise, timeoutMs: number, operation: } } +async function settled(promise: Promise): Promise> { + try { + return { status: "fulfilled", value: await promise }; + } catch (reason) { + return { status: "rejected", reason }; + } +} + function safeEqual(actual: string, expected: string): boolean { const actualBuffer = Buffer.from(actual); const expectedBuffer = Buffer.from(expected); @@ -484,6 +552,9 @@ function errorMessage(error: unknown): string { } function daemonErrorPayload(error: unknown): LocalAgentDaemonErrorPayload { + if (error instanceof WorkflowError) { + return { code: error.code, message: error.message, retryable: error.retryable }; + } if (isLocalAgentError(error)) return toAgentErrorPayload(error); if (error instanceof LocalAgentDaemonProtocolError) { if (error.code === "PROTOCOL_MISMATCH") { diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index 0df247006..f7db1795b 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { LocalAgentManager } from "./local-agent-manager.js"; import { + AgentProviderCancelledError, AgentProviderExecutionError, type AgentProviderError, } from "./local-agent-errors.js"; @@ -65,7 +66,19 @@ class FakeRuntime implements LocalAgentRuntime { if (input.prompt.includes("defect")) throw new TypeError("internal defect"); if (input.prompt.includes("fail")) return Result.err(providerFailure("provider failed")); if (input.prompt.includes("hold")) { - await new Promise((resolve) => { this.releaseHold = resolve; }); + await new Promise((resolve) => { + this.releaseHold = resolve; + input.signal?.addEventListener("abort", () => resolve(), { once: true }); + }); + if (input.signal?.aborted && !input.prompt.includes("ignore cancel")) { + return Result.err(new AgentProviderCancelledError({ + code: "PROVIDER_CANCELLED", + provider: "codex", + operation: "run", + retryable: false, + message: "cancelled", + })); + } } return Result.ok({ provider: this.provider, @@ -131,6 +144,9 @@ const manager = new LocalAgentManager({ pool: new LocalAgentRuntimePool(), loadProfiles: async () => [profile, disabledProfile], allowedRoots: [root], + authorizeManagedWorkspace: (workspaceRoot, workspaceId) => ( + workspaceId === "ws_managed" && workspaceRoot === directRoot + ), subagents, }); @@ -156,6 +172,38 @@ await assert.rejects( ); await defectManager.close(); +let releaseQueuedProfiles!: () => void; +const queuedProfiles = new Promise((resolve) => { releaseQueuedProfiles = resolve; }); +let queuedProfileLoads = 0; +const queuedStore = new LocalAgentStore(join(root, "queued-state")); +const queuedManager = new LocalAgentManager({ + store: queuedStore, + drivers: [driver], + pool: new LocalAgentRuntimePool(), + loadProfiles: async () => { + queuedProfileLoads += 1; + if (queuedProfileLoads === 2) await queuedProfiles; + return [profile]; + }, + allowedRoots: [root], + subagents, +}); +const queued = unwrap(await queuedManager.start({ + agentId: "workflow-queued-cancel", + target: "reviewer", + prompt: "must not reach the provider", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await waitFor(() => queuedProfileLoads === 2); +const queuedTurn = unwrap(queuedManager.getTurn(queued.id, scope)); +assert.ok(queuedTurn); +const queuedCancellation = queuedManager.cancel(queued.id, queuedTurn.id, scope); +releaseQueuedProfiles(); +assert.equal(unwrap(await queuedCancellation).status, "stopped"); +assert.equal(runtimes.has(queued.id), false, "queued cancellation never launches the provider runtime"); +await queuedManager.close(); + const outside = await manager.start({ target: "reviewer", prompt: "outside", @@ -165,6 +213,17 @@ const outside = await manager.start({ assert.equal(outside.isErr(), true); if (outside.isErr()) assert.equal(outside.error.code, "WORKSPACE_NOT_ALLOWED"); +const managed = unwrap(await manager.start({ + target: "reviewer", + prompt: "managed worktree", + workspaceId: "ws_managed", + workspaceRoot: directRoot, +})); +await waitFor(() => unwrap(manager.get(managed.id, { + workspaceId: "ws_managed", + workspaceRoot: directRoot, +})).status === "idle"); + const unknown = await manager.start({ target: "missing", prompt: "inspect", @@ -236,6 +295,7 @@ const first = unwrap(await manager.start({ assert.equal(first.status, "running"); assert.equal(first.model, "gpt-default"); assert.equal(first.effort, "medium"); +assert.equal(first.writeMode, "allowed"); await waitFor(() => runtimes.get(first.id)?.inputs.length === 1); const conflict = await manager.continue(first.id, "another prompt", {}, scope); assert.equal(conflict.isErr(), true); @@ -253,6 +313,73 @@ assert.deepEqual( [{ prompt: "hold", status: "completed" }], ); +const duplicateId = "workflow-dispatch-1"; +const duplicateInput = { + agentId: duplicateId, + target: "reviewer", + prompt: "hold duplicate", + workspaceId: scope.workspaceId, + workspaceRoot: root, + writeMode: "read_only" as const, +}; +const [duplicate, simultaneousRetry] = await Promise.all([ + manager.start(duplicateInput).then(unwrap), + manager.start(duplicateInput).then(unwrap), +]); +assert.equal(simultaneousRetry.id, duplicate.id); +await waitFor(() => runtimes.get(duplicate.id)?.inputs.length === 1); +const duplicateRetry = unwrap(await manager.start({ + ...duplicateInput, +})); +assert.equal(duplicateRetry.id, duplicate.id); +assert.equal(runtimes.get(duplicate.id)?.inputs.length, 1); +const mismatchedDuplicate = await manager.start({ + ...duplicateInput, + prompt: "different prompt", +}); +assert.equal(mismatchedDuplicate.isErr(), true); +if (mismatchedDuplicate.isErr()) assert.equal(mismatchedDuplicate.error.code, "AGENT_CONFLICT"); +const reconfiguredDuplicate = await manager.start({ + ...duplicateInput, + model: "gpt-other", +}); +assert.equal(reconfiguredDuplicate.isErr(), true); +if (reconfiguredDuplicate.isErr()) assert.equal(reconfiguredDuplicate.error.code, "AGENT_CONFLICT"); +const duplicateTurn = unwrap(manager.getTurn(duplicate.id, scope)); +assert.ok(duplicateTurn); +const wrongTurnOwner = await manager.cancel(first.id, duplicateTurn.id, scope); +assert.equal(wrongTurnOwner.isErr(), true); +if (wrongTurnOwner.isErr()) assert.equal(wrongTurnOwner.error.code, "AGENT_CONFLICT"); +assert.equal(getRecord(duplicate.id).status, "running", "a caller cannot cancel another agent's turn"); +const cancelledDuplicate = unwrap(await manager.cancel(duplicate.id, duplicateTurn.id, scope)); +assert.equal(cancelledDuplicate.status, "stopped"); +assert.equal(cancelledDuplicate.errorCode, "PROVIDER_CANCELLED"); +assert.equal(getRecord(duplicate.id).status, "stopped"); +assert.equal(unwrap(await manager.cancel(duplicate.id, duplicateTurn.id, scope)).status, "stopped"); +assert.equal(runtimes.get(first.id)?.closed, false, "cancelling one turn does not close another runtime"); + +const authorityUpgrade = await manager.continue(duplicate.id, "expand authority", { + writeMode: "allowed", +}, scope); +assert.equal(authorityUpgrade.isErr(), true); +if (authorityUpgrade.isErr()) assert.equal(authorityUpgrade.error.code, "AGENT_CONFLICT"); + +const unconfirmedCancellation = unwrap(await manager.start({ + target: "reviewer", + prompt: "hold ignore cancel", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await waitFor(() => runtimes.get(unconfirmedCancellation.id)?.inputs.length === 1); +const unconfirmedTurn = unwrap(manager.getTurn(unconfirmedCancellation.id, scope)); +assert.ok(unconfirmedTurn); +const unconfirmedResult = unwrap(await manager.cancel( + unconfirmedCancellation.id, + unconfirmedTurn.id, + scope, +)); +assert.equal(unconfirmedResult.status, "completed", "unconfirmed cancellation cannot persist stopped"); + const continued = unwrap(await manager.continue(first.id, "continue", { model: "gpt-run", effort: "high", @@ -261,14 +388,45 @@ assert.equal(continued.status, "running"); await waitFor(() => getRecord(first.id).status === "idle"); assert.equal(getRecord(first.id).model, "gpt-run"); assert.equal(getRecord(first.id).effort, "high"); +const reduced = unwrap(await manager.continue(first.id, "reduce authority", { + writeMode: "read_only", +}, scope)); +assert.equal(reduced.writeMode, "read_only"); +await waitFor(() => getRecord(first.id).status === "idle"); +assert.equal(getRecord(first.id).writeMode, "read_only", "authority reductions persist across continuations"); +const restoreAuthority = await manager.continue(first.id, "restore authority", { + writeMode: "allowed", +}, scope); +assert.equal(restoreAuthority.isErr(), true); +if (restoreAuthority.isErr()) assert.equal(restoreAuthority.error.code, "AGENT_CONFLICT"); assert.deepEqual( store.listTurns(first.id).map((turn) => ({ prompt: turn.prompt, status: turn.status })), [ { prompt: "hold", status: "completed" }, { prompt: "continue", status: "completed" }, + { prompt: "reduce authority", status: "completed" }, ], ); +const mutableDispatchInput = { + agentId: "workflow-sticky-dispatch", + target: "reviewer", + prompt: "stable dispatch", + workspaceId: scope.workspaceId, + workspaceRoot: root, + writeMode: "allowed" as const, +}; +const mutableDispatch = unwrap(await manager.start(mutableDispatchInput)); +await waitFor(() => getRecord(mutableDispatch.id).status === "idle"); +unwrap(await manager.continue(mutableDispatch.id, "reduce stable dispatch", { + writeMode: "read_only", +}, scope)); +await waitFor(() => getRecord(mutableDispatch.id).status === "idle"); +const retriedMutableDispatch = unwrap(await manager.start(mutableDispatchInput)); +assert.equal(retriedMutableDispatch.id, mutableDispatch.id); +assert.equal(retriedMutableDispatch.writeMode, "read_only"); +assert.equal(store.listTurns(mutableDispatch.id).length, 2, "an idempotent retry never redispatches"); + const second = unwrap(await manager.start({ target: "reviewer", prompt: "second agent", @@ -277,7 +435,8 @@ const second = unwrap(await manager.start({ })); await waitFor(() => getRecord(second.id).status === "idle"); assert.notEqual(first.id, second.id); -assert.equal(runtimes.size, 2, "different agents receive independent logical runtimes"); +assert.equal(runtimes.has(first.id), true); +assert.equal(runtimes.has(second.id), true, "different agents receive independent logical runtimes"); const failed = unwrap(await manager.start({ target: "reviewer", @@ -399,9 +558,10 @@ const directOutside = unwrap(await manager.start({ })); await waitFor(() => unwrap(manager.get(directOutside.id, { workspaceRoot: directRoot })).status === "idle"); assert.equal(directOutside.workspaceId, undefined); -assert.deepEqual(unwrap(manager.list({ workspaceRoot: directRoot })).map((record) => record.id), [ +assert.deepEqual(unwrap(manager.list({ workspaceRoot: directRoot })).map((record) => record.id).sort(), [ directOutside.id, -]); + managed.id, +].sort()); const direct = unwrap(await manager.start({ target: "reviewer", diff --git a/src/local-agent-manager.ts b/src/local-agent-manager.ts index 30fe1e1f4..979126f7a 100644 --- a/src/local-agent-manager.ts +++ b/src/local-agent-manager.ts @@ -1,7 +1,9 @@ +import { createHash } from "node:crypto"; import { resolve } from "node:path"; import { Result, type Result as BetterResult } from "better-result"; import { AgentConflictError, + AgentProviderCancelledError, AgentScopeError, AgentStoreError, AgentTargetError, @@ -19,6 +21,7 @@ import { } from "./local-agent-targets.js"; import { type LocalAgentRecord, + LocalAgentDispatchConflictError, type LocalAgentStore, type LocalAgentTurnRecord, type LocalAgentWorkspaceScope, @@ -38,6 +41,7 @@ import { } from "./local-agent-config.js"; export interface StartLocalAgentInput { + agentId?: string; target: string; prompt: string; workspaceRoot: string; @@ -64,6 +68,7 @@ export interface LocalAgentManagerOptions { loadProfiles: (workspaceRoot: string) => Promise; agentDir?: string; allowedRoots?: readonly string[]; + authorizeManagedWorkspace?: (workspaceRoot: string, workspaceId: string) => boolean; logger?: LocalAgentManagerLogger; subagents: SubagentsConfig; } @@ -73,6 +78,7 @@ export type AgentContinueError = AgentStartError; export type AgentLookupError = AgentTargetError | AgentScopeError | AgentStoreError; export type AgentListError = AgentScopeError | AgentStoreError; export type AgentWaitError = AgentLookupError; +export type AgentCancelError = AgentLookupError | AgentConflictError; export type LocalAgentWaitResult = | { id: string; status: "running"; wait?: "timeout" } @@ -83,6 +89,7 @@ export type LocalAgentWaitResult = interface ActiveLocalAgentTurn { turnId: number; completion: Promise; + controller: AbortController; } /** @@ -97,6 +104,7 @@ export class LocalAgentManager { private readonly loadProfiles: (workspaceRoot: string) => Promise; private readonly agentDir?: string; private readonly allowedRoots?: readonly string[]; + private readonly authorizeManagedWorkspace?: (workspaceRoot: string, workspaceId: string) => boolean; private readonly logger?: LocalAgentManagerLogger; private readonly subagents: SubagentsConfig; private readonly activeTurns = new Map(); @@ -110,6 +118,7 @@ export class LocalAgentManager { this.loadProfiles = options.loadProfiles; this.agentDir = options.agentDir; this.allowedRoots = options.allowedRoots; + this.authorizeManagedWorkspace = options.authorizeManagedWorkspace; this.logger = options.logger; this.subagents = options.subagents; } @@ -154,15 +163,28 @@ export class LocalAgentManager { } yield* manager.providerEnabledResult(target.provider, target.name, "start"); yield* manager.driverResult(target.provider, "start"); - const record = yield* manager.store.createResult({ + const createdResult = manager.store.createResult({ + id: input.agentId, workspaceId: input.workspaceId, workspaceRoot, profileName: target.name, provider: target.provider, model: target.model, effort: target.effort, + writeMode: input.writeMode ?? "allowed", + dispatchSignature: dispatchSignature(input, workspaceRoot, target.model, target.effort), }); - return manager.begin(record, input.prompt, { + if ( + createdResult.isErr() + && createdResult.error.cause instanceof LocalAgentDispatchConflictError + ) { + return Result.err(dispatchConflict(input.agentId ?? "unknown")); + } + const created = yield* createdResult; + if (!created.created) { + return Result.ok(created.record); + } + return manager.begin(created.record, input.prompt, { model: target.model, effort: target.effort, writeMode: input.writeMode, @@ -182,6 +204,7 @@ export class LocalAgentManager { const record = yield* manager.store.getByIdResult(agentId); if (!record) return Result.err(agentNotFound(agentId)); yield* manager.agentWorkspaceResult(record, scope, "continue"); + yield* manager.writeModeResult(record, overrides.writeMode); const profiles = yield* Result.await(manager.loadProfilesResult(record.workspaceRoot, record.profileName)); yield* manager.profileForRecordResult(record, profiles); yield* manager.providerEnabledResult(record.provider, record.profileName, "continue"); @@ -212,6 +235,55 @@ export class LocalAgentManager { )); } + getTurn( + agentId: string, + scope: LocalAgentWorkspaceScope, + ): BetterResult { + const agent = this.get(agentId, scope); + if (agent.isErr()) return agent; + return this.store.getLatestTurnResult(agentId); + } + + async cancel( + agentId: string, + turnId: number, + scope: LocalAgentWorkspaceScope, + ): Promise> { + const agent = this.get(agentId, scope); + if (agent.isErr()) return agent; + const stored = this.store.getTurnByIdResult(turnId); + if (stored.isErr()) return stored; + if (!stored.value || stored.value.agentId !== agentId) { + return Result.err(new AgentConflictError({ + code: "AGENT_CONFLICT", + agentId, + operation: "cancel", + retryable: false, + message: `Turn ${turnId} does not belong to subagent ${agentId}.`, + })); + } + if (stored.value.status !== "running") return Result.ok(stored.value); + const active = this.activeTurns.get(agentId); + if (!active || active.turnId !== turnId) { + return Result.err(new AgentStoreError( + "cancel", + new Error(`Turn ${turnId} is not active.`), + `Running turn state is unavailable for subagent ${agentId}.`, + )); + } + active.controller.abort(); + await active.completion; + const completed = this.store.getTurnByIdResult(turnId); + if (completed.isErr()) return completed; + if (!completed.value) { + return Result.err(new AgentStoreError( + "cancel", + new Error(`Turn ${turnId} disappeared after cancellation.`), + )); + } + return Result.ok(completed.value); + } + async wait( agentIds: readonly string[], scope: LocalAgentWorkspaceScope, @@ -310,14 +382,16 @@ export class LocalAgentManager { prompt, model: overrides.model ?? record.model, effort: overrides.effort ?? record.effort, + writeMode: overrides.writeMode ?? record.writeMode, }); if (begun.isErr()) return begun; // Defer invocation until after the tracking entry is visible. This keeps // cleanup correct even if runTurn later gains a synchronous completion path. + const controller = new AbortController(); const turn = Promise.resolve().then(() => ( - this.runTurn(begun.value.agent, begun.value.turn.id, prompt, overrides, workspaceId) + this.runTurn(begun.value.agent, begun.value.turn.id, prompt, overrides, workspaceId, controller.signal) )); - this.activeTurns.set(record.id, { turnId: begun.value.turn.id, completion: turn }); + this.activeTurns.set(record.id, { turnId: begun.value.turn.id, completion: turn, controller }); void turn.catch(() => undefined); return Result.ok(begun.value.agent); } @@ -328,6 +402,7 @@ export class LocalAgentManager { prompt: string, overrides: RunOverrides, workspaceId?: string, + signal?: AbortSignal, ): Promise { const startedAt = Date.now(); this.log("info", "agent_run_started", { @@ -336,6 +411,10 @@ export class LocalAgentManager { providerSessionIdPrefix: record.providerSessionId?.slice(0, 8), }); try { + if (signal?.aborted) { + this.persistRunCancellation(record, turnId, startedAt); + return; + } const authorized = this.authorizeWorkspace(record.workspaceRoot, workspaceId, "run"); if (authorized.isErr()) { this.persistRunError(record, turnId, authorized.error, startedAt); @@ -346,6 +425,10 @@ export class LocalAgentManager { ? record : { ...record, workspaceRoot }; const profiles = await this.loadProfilesResult(workspaceRoot, record.profileName); + if (signal?.aborted) { + this.persistRunCancellation(record, turnId, startedAt); + return; + } if (profiles.isErr()) { this.persistRunError(record, turnId, profiles.error, startedAt); return; @@ -355,7 +438,7 @@ export class LocalAgentManager { this.persistRunError(record, turnId, profile.error, startedAt); return; } - const input = this.buildRunInputResult(authorizedRecord, profile.value, prompt, overrides); + const input = this.buildRunInputResult(authorizedRecord, profile.value, prompt, overrides, signal); if (input.isErr()) { this.persistRunError(record, turnId, input.error, startedAt); return; @@ -386,6 +469,10 @@ export class LocalAgentManager { }; const result = await this.pool.run(driver.value, context, input.value, callbacks); if (result.isErr()) { + if (AgentProviderCancelledError.is(result.error)) { + this.persistRunCancellation(record, turnId, startedAt); + return; + } this.persistRunError(record, turnId, result.error, startedAt); return; } @@ -427,7 +514,7 @@ export class LocalAgentManager { }); throw error; } finally { - this.activeTurns.delete(record.id); + if (this.activeTurns.get(record.id)?.turnId === turnId) this.activeTurns.delete(record.id); } } @@ -455,11 +542,31 @@ export class LocalAgentManager { }); } + private persistRunCancellation( + record: LocalAgentRecord, + turnId: number, + startedAt: number, + ): void { + const persisted = this.store.finishTurnResult(record.id, turnId, { + status: "stopped", + error: "Subagent turn was cancelled.", + errorCode: "PROVIDER_CANCELLED", + errorRetryable: false, + }); + this.log("info", "agent_run_cancelled", { + provider: record.provider, + agentId: record.id, + durationMs: Math.max(0, Date.now() - startedAt), + persistenceFailed: persisted.isErr(), + }); + } + private buildRunInputResult( record: LocalAgentRecord, profile: LocalAgentProfile | undefined, prompt: string, overrides: RunOverrides, + signal?: AbortSignal, ): BetterResult { const isRawProvider = record.profileName === record.provider; if (!profile && !isRawProvider) { @@ -477,11 +584,12 @@ export class LocalAgentManager { prompt: fullPrompt, workspaceRoot: record.workspaceRoot, providerSessionId: record.providerSessionId, - writeMode: overrides.writeMode ?? "allowed", + writeMode: overrides.writeMode ?? record.writeMode, model: record.model ?? profile?.model, effort: record.effort ?? profile?.effort, modelOverrideRequested: overrides.model !== undefined, effortOverrideRequested: overrides.effort !== undefined, + signal, }); } @@ -557,6 +665,22 @@ export class LocalAgentManager { })); } + private writeModeResult( + record: LocalAgentRecord, + requested: LocalAgentWriteMode | undefined, + ): BetterResult { + if (!requested || writeModeRank(requested) <= writeModeRank(record.writeMode)) { + return Result.ok(undefined); + } + return Result.err(new AgentConflictError({ + code: "AGENT_CONFLICT", + agentId: record.id, + operation: "continue", + retryable: false, + message: `Subagent ${record.id} cannot expand write authority from ${record.writeMode} to ${requested}.`, + })); + } + private acceptingResult( operation: string, agentId?: string, @@ -581,6 +705,13 @@ export class LocalAgentManager { try { return Result.ok(assertAllowedPath(normalized, [...this.allowedRoots])); } catch (cause) { + if (workspaceId) { + try { + if (this.authorizeManagedWorkspace?.(normalized, workspaceId)) return Result.ok(normalized); + } catch { + // Preserve the original containment failure below. + } + } return Result.err(new AgentScopeError({ code: "WORKSPACE_NOT_ALLOWED", operation, @@ -665,6 +796,42 @@ function agentNotFound(agentId: string): AgentTargetError { }); } +function dispatchConflict(agentId: string): AgentConflictError { + return new AgentConflictError({ + code: "AGENT_CONFLICT", + agentId, + operation: "start", + retryable: false, + message: `Subagent id ${agentId} is already owned by a different dispatch.`, + }); +} + +function writeModeRank(writeMode: LocalAgentWriteMode): number { + switch (writeMode) { + case "read_only": return 0; + case "allowed": return 1; + case "full_access": return 2; + } +} + +function dispatchSignature( + input: StartLocalAgentInput, + workspaceRoot: string, + model: string | undefined, + effort: string | undefined, +): string | undefined { + if (!input.agentId) return undefined; + return createHash("sha256").update(JSON.stringify([ + input.workspaceId ?? null, + workspaceRoot, + input.target, + input.prompt, + model ?? null, + effort ?? null, + input.writeMode ?? "allowed", + ])).digest("hex"); +} + function unique(values: readonly string[]): string[] { return [...new Set(values)]; } diff --git a/src/local-agent-opencode.test.ts b/src/local-agent-opencode.test.ts index 69c358004..453fd32d1 100644 --- a/src/local-agent-opencode.test.ts +++ b/src/local-agent-opencode.test.ts @@ -224,6 +224,92 @@ if (timedOutPrompt.isErr()) { } await timeoutRuntime.close(); +let abortCalls = 0; +let abortInput: unknown; +let markCancellationPromptEntered!: () => void; +const cancellationPromptEntered = new Promise((resolve) => { markCancellationPromptEntered = resolve; }); +const cancellationClient = { + global: { async health() { return { data: { healthy: true } }; } }, + session: { + async create() { return { data: { id: "session_cancel" } }; }, + async prompt(_input: unknown, options?: { signal?: AbortSignal }) { + markCancellationPromptEntered(); + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => reject(new DOMException("Aborted", "AbortError")), + { once: true }, + ); + }); + }, + async abort(input: unknown) { abortCalls += 1; abortInput = input; return { data: true }; }, + }, +} as unknown as OpencodeClientLike; +let cancellationServerCloses = 0; +const cancellationRuntime = new OpencodeRuntime( + cancellationClient, + { close: () => { cancellationServerCloses += 1; } }, + 10_000, +); +const cancellationController = new AbortController(); +const cancelledPrompt = cancellationRuntime.run({ + prompt: "cancel", + workspaceRoot: "/tmp/project", + signal: cancellationController.signal, +}); +await cancellationPromptEntered; +cancellationController.abort(); +const cancelledPromptResult = await cancelledPrompt; +assert.equal(cancelledPromptResult.isErr(), true); +if (cancelledPromptResult.isErr()) assert.equal(cancelledPromptResult.error.code, "PROVIDER_CANCELLED"); +assert.equal(abortCalls, 1); +assert.deepEqual(abortInput, { sessionID: "session_cancel", directory: "/tmp/project" }); +assert.equal(cancellationServerCloses, 0, "turn cancellation keeps the shared OpenCode server alive"); +await cancellationRuntime.close(); + +let finishUncancelledPrompt!: () => void; +let markUncancelledPromptEntered!: () => void; +const uncancelledPromptEntered = new Promise((resolve) => { markUncancelledPromptEntered = resolve; }); +const uncancelledPrompt = new Promise((resolve) => { finishUncancelledPrompt = resolve; }); +const failedCancellationClient = { + global: { async health() { return { data: { healthy: true } }; } }, + session: { + async create() { return { data: { id: "session_cancel_failure" } }; }, + async prompt() { + markUncancelledPromptEntered(); + await uncancelledPrompt; + return { + data: { + info: { role: "assistant" }, + parts: [{ type: "text", text: "finished naturally" }], + }, + }; + }, + async abort() { throw new Error("abort rejected"); }, + }, +} as unknown as OpencodeClientLike; +const failedCancellationRuntime = new OpencodeRuntime( + failedCancellationClient, + { close: () => undefined }, + 10_000, +); +const failedCancellationController = new AbortController(); +const naturallyCompletedPrompt = failedCancellationRuntime.run({ + prompt: "cancel fails", + workspaceRoot: "/tmp/project", + signal: failedCancellationController.signal, +}); +await uncancelledPromptEntered; +failedCancellationController.abort(); +finishUncancelledPrompt(); +const naturallyCompletedResult = await naturallyCompletedPrompt; +assert.equal(naturallyCompletedResult.isErr(), true, "failed native abort is surfaced after the prompt settles"); +if (naturallyCompletedResult.isErr()) { + assert.equal(naturallyCompletedResult.error.code, "PROVIDER_EXECUTION_ERROR"); +} +assert.equal(failedCancellationRuntime.isAlive(), true, "failed turn abort keeps the shared runtime alive"); +await failedCancellationRuntime.close(); + assert.equal(opencodeAgentFor("read_only"), "devspace_read_only"); assert.equal(opencodeAgentFor("full_access"), "devspace_full_access"); assert.deepEqual(opencodePermissionFor("allowed"), { diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index d9980aba2..3f87b93cf 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -121,16 +121,38 @@ export class OpencodeRuntime implements LocalAgentRuntime { } private async prompt(sessionId: string, input: LocalAgentRunInput): Promise { + input.signal?.throwIfAborted(); const controller = new AbortController(); this.promptControllers.add(controller); let timedOut = false; + let cancellation: Promise | undefined; + const cancelPrompt = () => { + cancellation ??= Promise.resolve().then(async () => { + if (typeof this.client.session.abort !== "function") { + throw new Error("OpenCode session abort is unavailable."); + } + const result = await this.client.session.abort({ + sessionID: sessionId, + directory: input.workspaceRoot, + }, { throwOnError: true }); + if (unwrapProviderPayload(result) === false) { + throw new Error("OpenCode did not acknowledge the session abort."); + } + controller.abort(); + return true as const; + }); + }; + input.signal?.addEventListener("abort", cancelPrompt, { once: true }); const timer = setTimeout(() => { timedOut = true; controller.abort(); }, this.promptTimeoutMs); try { - return await promptOpencodeSession(this.client, sessionId, input, controller.signal); + const result = await promptOpencodeSession(this.client, sessionId, input, controller.signal); + if (input.signal?.aborted && (await cancellation)) throw abortError(); + return result; } catch (error) { + if (input.signal?.aborted && (await cancellation)) throw abortError(); if (!timedOut) throw error; throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -142,11 +164,16 @@ export class OpencodeRuntime implements LocalAgentRuntime { }); } finally { clearTimeout(timer); + input.signal?.removeEventListener("abort", cancelPrompt); this.promptControllers.delete(controller); } } } +function abortError(): DOMException { + return new DOMException("The operation was aborted.", "AbortError"); +} + export class OpencodeLocalAgentDriver implements LocalAgentDriver { readonly provider = "opencode" as const; readonly idleTimeoutMs = 5 * 60_000; diff --git a/src/local-agent-pi.test.ts b/src/local-agent-pi.test.ts index df111666c..6aa1f866f 100644 --- a/src/local-agent-pi.test.ts +++ b/src/local-agent-pi.test.ts @@ -28,6 +28,8 @@ class FakePiSession implements PiSessionLike { for (const listener of this.listeners) listener({ type: "agent_end" } as AgentSessionEvent); } + async abort(): Promise {} + subscribe(listener: AgentSessionEventListener): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); @@ -152,3 +154,74 @@ if (missingModel.isErr()) { assert.match(missingModel.error.message, /provider\/missing-model/); } await missingModelRuntime.value.close(); + +let releaseCancelledPiPrompt!: () => void; +let markCancelledPiPromptReady!: () => void; +const cancelledPiPromptReady = new Promise((resolve) => { markCancelledPiPromptReady = resolve; }); +const cancelledPiPrompt = new Promise((resolve) => { releaseCancelledPiPrompt = resolve; }); +class CancelPiSession extends FakePiSession { + abortCalls = 0; + + override async prompt(): Promise { + markCancelledPiPromptReady(); + await cancelledPiPrompt; + } + + override async abort(): Promise { + this.abortCalls += 1; + releaseCancelledPiPrompt(); + } +} +const cancelPiSession = new CancelPiSession(); +const cancelPiRuntime = await new PiLocalAgentDriver(async () => cancelPiSession).createRuntime(context); +assert.equal(cancelPiRuntime.isOk(), true); +if (cancelPiRuntime.isErr()) throw cancelPiRuntime.error; +const piController = new AbortController(); +const cancelledPiTurn = cancelPiRuntime.value.run({ + prompt: "cancel", + workspaceRoot: "/tmp/project", + signal: piController.signal, +}); +await cancelledPiPromptReady; +piController.abort(); +const cancelledPiResult = await cancelledPiTurn; +assert.equal(cancelledPiResult.isErr(), true); +if (cancelledPiResult.isErr()) assert.equal(cancelledPiResult.error.code, "PROVIDER_CANCELLED"); +assert.equal(cancelPiSession.abortCalls, 1); +assert.equal(cancelPiRuntime.value.isAlive(), true); +await cancelPiRuntime.value.close(); + +let releaseFailedPiPrompt!: () => void; +let markFailedPiPromptReady!: () => void; +const failedPiPromptReady = new Promise((resolve) => { markFailedPiPromptReady = resolve; }); +const failedPiPrompt = new Promise((resolve) => { releaseFailedPiPrompt = resolve; }); +class FailedCancelPiSession extends FakePiSession { + override async prompt(): Promise { + markFailedPiPromptReady(); + await failedPiPrompt; + } + + override async abort(): Promise { + throw new Error("Pi abort failed"); + } +} +const failedCancelPiRuntime = await new PiLocalAgentDriver(async () => new FailedCancelPiSession()).createRuntime(context); +assert.equal(failedCancelPiRuntime.isOk(), true); +if (failedCancelPiRuntime.isErr()) throw failedCancelPiRuntime.error; +const failedPiController = new AbortController(); +const failedPiTurn = failedCancelPiRuntime.value.run({ + prompt: "cancel failure", + workspaceRoot: "/tmp/project", + signal: failedPiController.signal, +}); +await failedPiPromptReady; +failedPiController.abort(); +await new Promise((resolve) => setImmediate(resolve)); +releaseFailedPiPrompt(); +const failedPiResult = await failedPiTurn; +assert.equal(failedPiResult.isErr(), true); +if (failedPiResult.isErr()) { + assert.equal(failedPiResult.error.code, "PROVIDER_EXECUTION_ERROR"); + assert.match(String(failedPiResult.error.cause), /Pi abort failed/); +} +await failedCancelPiRuntime.value.close(); diff --git a/src/local-agent-pi.ts b/src/local-agent-pi.ts index bfb85cb3a..67c5f6efb 100644 --- a/src/local-agent-pi.ts +++ b/src/local-agent-pi.ts @@ -33,6 +33,7 @@ export type PiSessionLike = Pick< | "messages" | "modelRegistry" | "prompt" + | "abort" | "subscribe" | "setActiveToolsByName" | "setModel" @@ -69,6 +70,7 @@ export class PiSessionRuntime implements LocalAgentRuntime { provider: this.provider, operation: "run", run: async (): Promise => { + input.signal?.throwIfAborted(); if (!this.isAlive()) { throw new AgentProviderUnavailableError({ code: "PROVIDER_UNAVAILABLE", @@ -80,12 +82,37 @@ export class PiSessionRuntime implements LocalAgentRuntime { } await callbacks?.onSessionId?.(this.session.sessionId); await this.applyOverrides(input); + input.signal?.throwIfAborted(); this.events = []; const messageStart = this.session.messages.length; this.collectingEvents = true; + let abort: Promise<{ ok: true } | { ok: false; error: unknown }> | undefined; + const onAbort = () => { + abort ??= Promise.resolve().then(() => this.session.abort()).then( + () => ({ ok: true as const }), + (error: unknown) => ({ ok: false as const, error }), + ); + }; + input.signal?.addEventListener("abort", onAbort, { once: true }); + if (input.signal?.aborted) onAbort(); try { - await this.session.prompt(input.prompt); + try { + await this.session.prompt(input.prompt); + } catch (error) { + if (input.signal?.aborted && abort) { + const outcome = await abort; + if (!outcome.ok) throw outcome.error; + throw abortError(); + } + throw error; + } + if (input.signal?.aborted && abort) { + const outcome = await abort; + if (!outcome.ok) throw outcome.error; + throw abortError(); + } } finally { + input.signal?.removeEventListener("abort", onAbort); this.collectingEvents = false; } const currentMessages = this.session.messages.slice(messageStart); @@ -162,6 +189,10 @@ export class PiSessionRuntime implements LocalAgentRuntime { } } +function abortError(): DOMException { + return new DOMException("The operation was aborted.", "AbortError"); +} + export class PiLocalAgentDriver implements LocalAgentDriver { readonly provider = "pi" as const; readonly idleTimeoutMs = 3 * 60_000; diff --git a/src/local-agent-presentation.test.ts b/src/local-agent-presentation.test.ts index ea1a4d6e6..99da03e24 100644 --- a/src/local-agent-presentation.test.ts +++ b/src/local-agent-presentation.test.ts @@ -16,6 +16,7 @@ const record: LocalAgentRecord = { provider: "codex", model: "gpt-5.4", effort: "high", + writeMode: "allowed", providerSessionId: "provider_private", status: "running", latestResponse: "previous response", diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index ecc40f545..0072ed6f1 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -13,6 +13,7 @@ export interface LocalAgentRunInput { effort?: string; modelOverrideRequested?: boolean; effortOverrideRequested?: boolean; + signal?: AbortSignal; } export interface LocalAgentRunResult { diff --git a/src/local-agent-store.test.ts b/src/local-agent-store.test.ts index e29bd319b..e75d79214 100644 --- a/src/local-agent-store.test.ts +++ b/src/local-agent-store.test.ts @@ -23,10 +23,38 @@ try { assert.match(created.id, /^agt_[a-f0-9]{8}$/); assert.equal(created.status, "starting"); + assert.equal(created.writeMode, "allowed"); assert.equal(store.getById(created.id)?.effort, "high"); assert.equal(store.getById(created.id)?.profileName, "reviewer"); assert.equal(store.getById(created.id.slice(0, 7)), undefined); + const ownedDispatch = store.create({ + id: "workflow-dispatch-store", + workspaceId: "ws_dispatch", + workspaceRoot: join(root, "dispatch-project"), + profileName: "reviewer", + provider: "codex", + writeMode: "read_only", + }); + assert.equal(ownedDispatch.id, "workflow-dispatch-store"); + assert.equal(ownedDispatch.writeMode, "read_only"); + assert.equal(store.create({ + id: ownedDispatch.id, + workspaceId: "ws_dispatch", + workspaceRoot: join(root, "dispatch-project"), + profileName: "reviewer", + provider: "codex", + writeMode: "read_only", + }).id, ownedDispatch.id); + assert.throws(() => store.create({ + id: ownedDispatch.id, + workspaceId: "ws_dispatch", + workspaceRoot: join(root, "other"), + profileName: "reviewer", + provider: "codex", + writeMode: "read_only", + }), /different dispatch/); + const updated = store.update(created.id, { status: "error", latestResponse: "done", diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index 3aab64da6..7e3544f7c 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -3,6 +3,7 @@ import { resolve } from "node:path"; import { Result, type Result as BetterResult } from "better-result"; import { openDatabase, type DatabaseHandle } from "./db/client.js"; import { AgentStoreError, isProgrammerDefect } from "./local-agent-errors.js"; +import type { LocalAgentWriteMode } from "./local-agent-runtime.js"; export type LocalAgentStatus = "starting" | "running" | "idle" | "error" | "stopped"; export type LocalAgentTurnStatus = "running" | "completed" | "failed" | "stopped"; @@ -15,6 +16,7 @@ export interface LocalAgentRecord { provider: string; model?: string; effort?: string; + writeMode: LocalAgentWriteMode; providerSessionId?: string; status: LocalAgentStatus; latestResponse?: string; @@ -26,14 +28,24 @@ export interface LocalAgentRecord { } export interface CreateLocalAgentRecordInput { + id?: string; workspaceId?: string; workspaceRoot: string; profileName: string; provider: string; model?: string; effort?: string; + writeMode?: LocalAgentWriteMode; + dispatchSignature?: string; } +export interface CreatedLocalAgentRecord { + record: LocalAgentRecord; + created: boolean; +} + +export class LocalAgentDispatchConflictError extends Error {} + export interface LocalAgentTurnRecord { id: number; agentId: string; @@ -51,6 +63,7 @@ export interface BeginLocalAgentTurnInput { prompt: string; model?: string; effort?: string; + writeMode?: LocalAgentWriteMode; } export type FinishLocalAgentTurnInput = @@ -81,6 +94,8 @@ interface LocalAgentRow { provider: string; model: string | null; effort: string | null; + write_mode: string; + dispatch_signature: string | null; provider_session_id: string | null; status: string; latest_response: string | null; @@ -151,64 +166,87 @@ export class LocalAgentStore { } create(input: CreateLocalAgentRecordInput): LocalAgentRecord { - const now = new Date().toISOString(); - const record: LocalAgentRecord = { - id: `agt_${randomUUID().replaceAll("-", "").slice(0, 8)}`, - workspaceId: input.workspaceId, - workspaceRoot: resolve(input.workspaceRoot), - profileName: input.profileName, - provider: input.provider, - model: input.model, - effort: input.effort, - status: "starting", - createdAt: now, - updatedAt: now, - }; + return this.createDispatch(input).record; + } - this.database.sqlite - .prepare( - `insert into local_agent_sessions ( - id, - workspace_id, - workspace_root, - profile_name, - provider, - model, - effort, - status, - created_at, - updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - record.id, - record.workspaceId ?? null, - record.workspaceRoot, - record.profileName, - record.provider, - record.model ?? null, - record.effort ?? null, - record.status, - record.createdAt, - record.updatedAt, - ); + private createDispatch(input: CreateLocalAgentRecordInput): CreatedLocalAgentRecord { + return this.database.sqlite.transaction(() => { + const id = input.id ?? `agt_${randomUUID().replaceAll("-", "").slice(0, 8)}`; + validateAgentId(id); + const existing = this.getRowById(id); + if (existing) { + const record = rowToLocalAgentRecord(existing); + validateDispatchIdentity(record, input, existing.dispatch_signature); + return { record, created: false }; + } + const now = new Date().toISOString(); + const record: LocalAgentRecord = { + id, + workspaceId: input.workspaceId, + workspaceRoot: resolve(input.workspaceRoot), + profileName: input.profileName, + provider: input.provider, + model: input.model, + effort: input.effort, + writeMode: input.writeMode ?? "allowed", + status: "starting", + createdAt: now, + updatedAt: now, + }; + + this.database.sqlite + .prepare( + `insert into local_agent_sessions ( + id, + workspace_id, + workspace_root, + profile_name, + provider, + model, + effort, + write_mode, + dispatch_signature, + status, + created_at, + updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + record.id, + record.workspaceId ?? null, + record.workspaceRoot, + record.profileName, + record.provider, + record.model ?? null, + record.effort ?? null, + record.writeMode, + input.dispatchSignature ?? null, + record.status, + record.createdAt, + record.updatedAt, + ); - return record; + return { record, created: true }; + }).immediate(); } - createResult(input: CreateLocalAgentRecordInput): BetterResult { - return storeResult("create", () => this.create(input)); + createResult(input: CreateLocalAgentRecordInput): BetterResult { + return storeResult("create", () => this.createDispatch(input)); } getById(id: string): LocalAgentRecord | undefined { - const exact = this.database.sqlite + const exact = this.getRowById(id); + return exact ? rowToLocalAgentRecord(exact) : undefined; + } + + private getRowById(id: string): LocalAgentRow | undefined { + return this.database.sqlite .prepare( `select * from local_agent_sessions where id = ? limit 1`, ) .get(id) as LocalAgentRow | undefined; - return exact ? rowToLocalAgentRecord(exact) : undefined; } getByIdResult(id: string): BetterResult { @@ -242,6 +280,7 @@ export class LocalAgentStore { provider = ?, model = ?, effort = ?, + write_mode = ?, provider_session_id = ?, status = ?, latest_response = ?, @@ -258,6 +297,7 @@ export class LocalAgentStore { updated.provider, updated.model ?? null, updated.effort ?? null, + updated.writeMode, updated.providerSessionId ?? null, updated.status, updated.latestResponse ?? null, @@ -289,6 +329,7 @@ export class LocalAgentStore { status: "running", model: input.model, effort: input.effort, + writeMode: input.writeMode ?? current.writeMode, latestResponse: undefined, error: undefined, errorCode: undefined, @@ -466,6 +507,7 @@ function rowToLocalAgentRecord(row: LocalAgentRow): LocalAgentRecord { provider: row.provider, model: row.model ?? undefined, effort: row.effort ?? undefined, + writeMode: readWriteMode(row.write_mode), providerSessionId: row.provider_session_id ?? undefined, status: readStatus(row.status), latestResponse: row.latest_response ?? undefined, @@ -526,3 +568,49 @@ function readStatus(status: string): LocalAgentStatus { } return "error"; } + +function readWriteMode(value: string): LocalAgentWriteMode { + if (value === "read_only" || value === "allowed" || value === "full_access") return value; + throw new Error(`Invalid stored local agent write mode: ${value}`); +} + +function validateAgentId(id: string): void { + if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(id)) { + throw new Error("Subagent id must be 1-128 characters using letters, numbers, '.', '_', ':' or '-'."); + } +} + +function validateDispatchIdentity( + existing: LocalAgentRecord, + input: CreateLocalAgentRecordInput, + storedSignature: string | null, +): void { + const expected = { + workspaceId: input.workspaceId, + workspaceRoot: resolve(input.workspaceRoot), + profileName: input.profileName, + provider: input.provider, + }; + for (const key of Object.keys(expected) as Array) { + if (existing[key] !== expected[key]) { + throw new LocalAgentDispatchConflictError( + `Subagent id ${existing.id} is already owned by a different dispatch.`, + ); + } + } + if (storedSignature !== (input.dispatchSignature ?? null)) { + throw new LocalAgentDispatchConflictError( + `Subagent id ${existing.id} is already owned by a different dispatch.`, + ); + } + if (storedSignature !== null) return; + if ( + existing.model !== input.model + || existing.effort !== input.effort + || existing.writeMode !== (input.writeMode ?? "allowed") + ) { + throw new LocalAgentDispatchConflictError( + `Subagent id ${existing.id} is already owned by a different dispatch.`, + ); + } +} diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 90a8ade25..c7d0df7ad 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -52,6 +52,8 @@ async function testDatabaseConfiguration(stateDir: string): Promise { { version: 6, name: "local-agent-effort-rename" }, { version: 7, name: "workspace-recovery-state" }, { version: 8, name: "local-agent-turns" }, + { version: 9, name: "local-agent-authority" }, + { version: 10, name: "dynamic-workflows" }, ]); } finally { database.close(); diff --git a/src/skills.test.ts b/src/skills.test.ts index 357c417db..6b5763b62 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -224,6 +224,9 @@ try { await readFile(join(devspaceSkills, "subagents", "SKILL.md"), "utf8"), /# DevSpace subagents/, ); + const managedWorkflows = experimentalSkills.find((skill) => skill.name === "workflows"); + assert.ok(managedWorkflows); + assert.equal(managedWorkflows.filePath, join(devspaceSkills, "workflows", "SKILL.md")); const duplicateConfig = loadConfig(writeTestDevspaceConfig(configDir, { server: { port: 1 }, diff --git a/src/skills.ts b/src/skills.ts index 8f67d8e07..99a34f417 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -30,19 +30,17 @@ export interface SkillReadResolution { } const SUBAGENTS_SKILL_NAME = "subagents"; -const SUBAGENTS_SKILL = join(SUBAGENTS_SKILL_NAME, "SKILL.md"); +const WORKFLOWS_SKILL_NAME = "workflows"; +const MANAGED_SKILL_NAMES = [SUBAGENTS_SKILL_NAME, WORKFLOWS_SKILL_NAME] as const; function bundledSkillsDir(): string { return fileURLToPath(new URL("../skills", import.meta.url)); } -function bundledSubagentsSkillPath(): string { - return join(bundledSkillsDir(), SUBAGENTS_SKILL); -} - -function syncManagedSubagentsSkill(config: ServerConfig): string { - const sourcePath = bundledSubagentsSkillPath(); - const targetPath = join(config.devspaceSkillsDir, SUBAGENTS_SKILL); +function syncManagedSkill(config: ServerConfig, name: typeof MANAGED_SKILL_NAMES[number]): string { + const skillPath = join(name, "SKILL.md"); + const sourcePath = join(bundledSkillsDir(), skillPath); + const targetPath = join(config.devspaceSkillsDir, skillPath); const source = readFileSync(sourcePath, "utf8"); if (existsSync(targetPath)) { @@ -51,7 +49,7 @@ function syncManagedSubagentsSkill(config: ServerConfig): string { return targetPath; } if (stat.isDirectory()) { - throw new Error(`Managed subagents skill path is a directory: ${targetPath}`); + throw new Error(`Managed ${name} skill path is a directory: ${targetPath}`); } } @@ -97,7 +95,7 @@ export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSk if (!config.skillsEnabled) return { skills: [], diagnostics: [] }; if (config.subagents.enabled) { - syncManagedSubagentsSkill(config); + for (const name of MANAGED_SKILL_NAMES) syncManagedSkill(config, name); } const result = loadSkills({ @@ -107,30 +105,29 @@ export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSk includeDefaults: false, }); - const withoutSubagents = withoutSubagentsSkill(result); - if (!config.subagents.enabled) return withoutSubagents; + const withoutManaged = withoutManagedSkills(result); + if (!config.subagents.enabled) return withoutManaged; - const managedDir = dirname(join(config.devspaceSkillsDir, SUBAGENTS_SKILL)); - const managed = loadSkillsFromDir({ - dir: managedDir, - source: "devspace", - }).skills.find((skill) => skill.name === SUBAGENTS_SKILL_NAME); - if (!managed) { - throw new Error("Managed subagents skill could not be loaded."); - } + const managed = MANAGED_SKILL_NAMES.map((name) => { + const managedDir = dirname(join(config.devspaceSkillsDir, name, "SKILL.md")); + const skill = loadSkillsFromDir({ dir: managedDir, source: "devspace" }) + .skills.find((entry) => entry.name === name); + if (!skill) throw new Error(`Managed ${name} skill could not be loaded.`); + return skill; + }); return { - skills: [...withoutSubagents.skills, managed], - diagnostics: withoutSubagents.diagnostics, + skills: [...withoutManaged.skills, ...managed], + diagnostics: withoutManaged.diagnostics, }; } -function withoutSubagentsSkill(result: LoadSkillsResult): LoadedSkills { +function withoutManagedSkills(result: LoadSkillsResult): LoadedSkills { return { - skills: result.skills.filter((skill) => skill.name !== SUBAGENTS_SKILL_NAME), + skills: result.skills.filter((skill) => !MANAGED_SKILL_NAMES.includes(skill.name as typeof MANAGED_SKILL_NAMES[number])), diagnostics: result.diagnostics.filter((diagnostic) => { const collision = diagnostic.collision; - return !(collision?.resourceType === "skill" && collision.name === SUBAGENTS_SKILL_NAME); + return !(collision?.resourceType === "skill" && MANAGED_SKILL_NAMES.includes(collision.name as typeof MANAGED_SKILL_NAMES[number])); }), }; } diff --git a/src/workflow-cli.test.ts b/src/workflow-cli.test.ts new file mode 100644 index 000000000..6cb12116f --- /dev/null +++ b/src/workflow-cli.test.ts @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { + ensureLocalAgentDaemonSecret, + LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + localAgentDaemonPaths, +} from "./local-agent-daemon-lifecycle.js"; +import { encodeLocalAgentDaemonResponse } from "./local-agent-daemon-protocol.js"; +import { writeTestDevspaceConfig } from "./test-support/config.test.js"; +import type { WorkflowRun } from "./workflow-types.js"; + +const execFileAsync = promisify(execFile); +// macOS limits the complete Unix socket path to 104 bytes. +const root = await mkdtemp(join(tmpdir(), "ds-wf-")); +const configDir = join(root, "config"); +const stateDir = join(root, "state"); +const workspaceDirectory = join(root, "project"); +await mkdir(workspaceDirectory, { recursive: true }); +const workspaceRoot = await realpath(workspaceDirectory); +const env = writeTestDevspaceConfig(configDir, { + workspaces: { allowedRoots: [workspaceRoot] }, + storage: { stateDir }, +}); +const paths = localAgentDaemonPaths(stateDir); +ensureLocalAgentDaemonSecret(paths); +const requests: Array<{ method: string; params: Record }> = []; +const run: WorkflowRun = { + id: "wfl_test", + workspaceId: "ws_test", + workspaceRoot, + name: "\u001b[31mreview\u001b[0m", + status: "running", + writeMode: "read_only", + concurrency: 2, + createdAt: "now", + updatedAt: "now", + callCount: 0, +}; +const daemon = createServer((socket) => { + let buffer = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + const request = JSON.parse(buffer.slice(0, newline)) as { + requestId: string; + method: string; + params: Record; + }; + requests.push(request); + const result = request.method === "hello" + ? { + status: { + state: "ready", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + pid: process.pid, + endpoint: paths.endpoint, + startedAt: "now", + activeTurns: 0, + runtimeCount: 0, + clientConnections: 1, + }, + configMatches: true, + } + : run; + socket.end(encodeLocalAgentDaemonResponse({ + requestId: request.requestId, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + ok: true, + result, + })); + }); +}); + +try { + await new Promise((resolve, reject) => { + daemon.once("error", reject); + daemon.listen(paths.endpoint, resolve); + }); + const workflowFile = join(root, "review.workflow.js"); + await writeFile(workflowFile, "return agent('Review this', { target: 'reviewer' });\n"); + const { stdout } = await execFileAsync("node", [ + "--import", "tsx", "src/cli.ts", "workflow", "run", + "--file", workflowFile, + "--args", '{"base":"main"}', + "--json", + ], { + cwd: process.cwd(), + env: { + ...process.env, + ...env, + DEVSPACE_WORKSPACE_ID: "ws_test", + DEVSPACE_WORKSPACE_ROOT: workspaceRoot, + }, + }); + assert.equal(stdout, `${JSON.stringify({ id: run.id, name: run.name, status: run.status })}\n`); + const submitted = requests.find((request) => request.method === "workflow.run"); + assert.deepEqual(submitted?.params, { + workspaceId: "ws_test", + workspaceRoot, + source: "return agent('Review this', { target: 'reviewer' });\n", + args: { base: "main" }, + }); + const status = await execFileAsync("node", [ + "--import", "tsx", "src/cli.ts", "workflow", "status", run.id, + ], { + cwd: process.cwd(), + env: { + ...process.env, + ...env, + DEVSPACE_WORKSPACE_ID: "ws_test", + DEVSPACE_WORKSPACE_ROOT: workspaceRoot, + }, + }); + assert.equal(status.stdout.includes("\u001b"), false); + assert.match(status.stdout, /name="\\u001b\[31mreview\\u001b\[0m"/); +} finally { + await new Promise((resolve) => daemon.close(() => resolve())); + await rm(root, { recursive: true, force: true }); +} diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts new file mode 100644 index 000000000..469b26486 --- /dev/null +++ b/src/workflow-cli.ts @@ -0,0 +1,276 @@ +import { open } from "node:fs/promises"; +import type { Result } from "better-result"; +import { loadConfig } from "./config.js"; +import { resolveCliWorkspaceContext } from "./cli-workspace.js"; +import { createLocalAgentClient } from "./local-agent-client.js"; +import type { WorkflowCall, WorkflowRun, WorkflowRunInput } from "./workflow-types.js"; +import { isManagedWorkflowWorkspace } from "./workflow-workspaces.js"; + +export async function runWorkflowCommand(args: string[], json: boolean): Promise { + try { + const [command, ...rest] = args; + if (!command || command === "help" || command === "--help" || command === "-h") { + printWorkflowHelp(); + return; + } + const config = loadConfig(); + const scope = resolveCliWorkspaceContext( + config.allowedRoots, + process.env, + process.cwd(), + (root, id) => isManagedWorkflowWorkspace(config, root, id), + ); + const client = createLocalAgentClient(config); + switch (command) { + case "run": { + const input = await parseWorkflowRunArgs(rest); + printWorkflow(unwrap(await client.runWorkflow({ ...scope, ...input })), json, true); + return; + } + case "status": { + const id = oneId(rest, "status"); + printWorkflow(unwrap(await client.getWorkflow(id, scope)), json); + return; + } + case "ls": + case "list": { + if (rest.length > 0) usage("ls"); + const runs = unwrap(await client.listWorkflows(scope)); + if (json) printJson(runs); + else for (const run of runs) console.log(formatWorkflow(run)); + return; + } + case "wait": { + const { id, timeoutMs } = parseWorkflowWaitArgs(rest); + printWorkflow(unwrap(await client.waitWorkflow(id, scope, timeoutMs)), json); + return; + } + case "calls": { + const id = oneId(rest, "calls"); + const calls = unwrap(await client.workflowCalls(id, scope)); + if (json) printJson(calls.map(summarizeCall)); + else for (const call of calls) console.log(formatCall(call)); + return; + } + case "call": { + const [id, rawIndex, ...extra] = rest; + if (!id || !rawIndex || extra.length > 0 || !/^\d+$/.test(rawIndex)) usage("call "); + const call = unwrap(await client.workflowCall(id, Number(rawIndex), scope)); + if (json) printJson(call); + else console.log(formatCall(call, true)); + return; + } + case "events": { + const { id, after } = parseWorkflowEventsArgs(rest); + const events = unwrap(await client.workflowEvents(id, scope, after)); + if (json) printJson(events); + else for (const event of events) { + console.log(`${xml(JSON.stringify(event.data) ?? "null")}`); + } + return; + } + case "cancel": { + const id = oneId(rest, "cancel"); + printWorkflow(unwrap(await client.cancelWorkflow(id, scope)), json); + return; + } + default: + throw new Error(`Unknown workflow command: ${command}`); + } + } catch (error) { + const payload = workflowErrorPayload(error); + if (json) printJson({ error: payload }); + else console.error(`${xml(payload.message)}`); + process.exitCode = 1; + } +} + +async function parseWorkflowRunArgs(args: string[]): Promise> { + let file: string | undefined; + let name: string | undefined; + let resume: string | undefined; + let rawArgs: string | undefined; + let argsFile: string | undefined; + let writeMode: WorkflowRunInput["writeMode"]; + for (let index = 0; index < args.length; index += 1) { + const option = args[index]!; + const value = args[index + 1]; + if (!value) usage("run --file |--name |--resume "); + switch (option) { + case "--file": file = value; break; + case "--name": name = value; break; + case "--resume": resume = value; break; + case "--args": rawArgs = value; break; + case "--args-file": argsFile = value; break; + case "--write-mode": + if (value !== "read_only" && value !== "allowed") throw new Error("Workflow write mode must be read_only or allowed."); + writeMode = value; + break; + default: throw new Error(`Unknown option: ${option}.`); + } + index += 1; + } + if ([file, name, resume].filter(Boolean).length !== 1) { + throw new Error("Exactly one of --file, --name, or --resume is required."); + } + if (rawArgs !== undefined && argsFile !== undefined) { + throw new Error("Use only one of --args or --args-file."); + } + const input: Omit = { + ...(file ? { source: await readBoundedFile(file, 64 * 1024, "Workflow source") } : {}), + ...(name ? { name } : {}), + ...(resume ? { resume } : {}), + ...(writeMode ? { writeMode } : {}), + }; + const argsJson = rawArgs ?? (argsFile ? await readBoundedFile(argsFile, 128 * 1024, "Workflow arguments") : undefined); + if (argsJson !== undefined) { + if (Buffer.byteLength(argsJson) > 128 * 1024) throw new Error("Workflow arguments exceed 131072 bytes."); + try { + input.args = JSON.parse(argsJson) as unknown; + } catch (cause) { + throw new Error(`Workflow arguments are not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`); + } + } + return input; +} + +async function readBoundedFile(path: string, maxBytes: number, label: string): Promise { + const file = await open(path, "r"); + try { + const buffer = Buffer.allocUnsafe(maxBytes + 1); + const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); + if (bytesRead > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes.`); + try { + return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, bytesRead)); + } catch { + throw new Error(`${label} must be UTF-8 text.`); + } + } finally { + await file.close(); + } +} + +function parseWorkflowWaitArgs(args: string[]): { id: string; timeoutMs: number } { + let id: string | undefined; + let timeoutMs = 60_000; + for (let index = 0; index < args.length; index += 1) { + const value = args[index]!; + if (value === "--timeout") { + const seconds = args[index + 1]; + if (!seconds || !/^\d+$/.test(seconds) || Number(seconds) > 60) { + throw new Error("Workflow wait timeout must be an integer from 0 to 60 seconds."); + } + timeoutMs = Number(seconds) * 1_000; + index += 1; + } else if (value.startsWith("-")) { + throw new Error(`Unknown option: ${value}.`); + } else if (id) { + usage("wait [--timeout ]"); + } else { + id = value; + } + } + if (!id) usage("wait [--timeout ]"); + return { id, timeoutMs }; +} + +function parseWorkflowEventsArgs(args: string[]): { id: string; after: number } { + let id: string | undefined; + let after = 0; + for (let index = 0; index < args.length; index += 1) { + const value = args[index]!; + if (value === "--after") { + const sequence = args[index + 1]; + if (!sequence || !/^\d+$/.test(sequence) || !Number.isSafeInteger(Number(sequence))) { + throw new Error("Workflow event sequence must be a non-negative integer."); + } + after = Number(sequence); + index += 1; + } else if (value.startsWith("-")) { + throw new Error(`Unknown option: ${value}.`); + } else if (id) { + usage("events [--after ]"); + } else { + id = value; + } + } + if (!id) usage("events [--after ]"); + return { id, after }; +} + +function oneId(args: string[], command: string): string { + if (args.length !== 1 || !args[0]) usage(`${command} `); + return args[0]; +} + +function unwrap(result: Result): T { + if (result.isErr()) throw result.error; + return result.value; +} + +function printWorkflow(run: WorkflowRun, json: boolean, receipt = false): void { + if (json) printJson(receipt ? { id: run.id, name: run.name, status: run.status } : run); + else console.log(formatWorkflow(run, receipt)); +} + +function formatWorkflow(run: WorkflowRun, receipt = false): string { + const attributes = [ + `id="${xml(run.id)}"`, + `name="${xml(run.name)}"`, + `status="${run.status}"`, + ...(run.error ? [`code="${xml(run.error.code)}"`, `retryable="${run.error.retryable}"`] : []), + ].join(" "); + if (receipt) return ``; + const detail = run.error?.message ?? (run.result === undefined ? "" : JSON.stringify(run.result)); + return detail + ? `${xml(detail)}` + : ``; +} + +function summarizeCall({ prompt: _prompt, result: _result, fingerprint: _fingerprint, ...call }: WorkflowCall): Omit { + return call; +} + +function formatCall(call: WorkflowCall, detailed = false): string { + const attributes = `run="${xml(call.runId)}" index="${call.index}" agent="${xml(call.agentId)}" status="${call.status}" target="${xml(call.options.target)}"`; + if (!detailed) return ``; + return `${xml(JSON.stringify({ prompt: call.prompt, options: call.options, result: call.result, error: call.error }))}`; +} + +function workflowErrorPayload(error: unknown): { code: string; message: string; retryable: boolean } { + const value = error as { code?: unknown; message?: unknown; retryable?: unknown } | undefined; + return { + code: typeof value?.code === "string" ? value.code : "WORKFLOW_COMMAND_ERROR", + message: typeof value?.message === "string" ? value.message : String(error), + retryable: value?.retryable === true, + }; +} + +function xml(value: string): string { + return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`) + .replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">"); +} + +function printJson(value: unknown): void { + console.log(JSON.stringify(value)); +} + +function usage(command: string): never { + throw new Error(`Usage: devspace workflow ${command}`); +} + +export function printWorkflowHelp(): void { + console.log([ + "DevSpace workflow", + "", + "Usage:", + " devspace workflow run --file |--name |--resume [--args |--args-file ] [--write-mode read_only|allowed] [--json]", + " devspace workflow status [--json]", + " devspace workflow wait [--timeout ] [--json]", + " devspace workflow calls [--json]", + " devspace workflow call [--json]", + " devspace workflow events [--after ] [--json]", + " devspace workflow cancel [--json]", + " devspace workflow ls [--json]", + ].join("\n")); +} diff --git a/src/workflow-context.ts b/src/workflow-context.ts new file mode 100644 index 000000000..218076592 --- /dev/null +++ b/src/workflow-context.ts @@ -0,0 +1,56 @@ +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { lstat, open, readdir } from "node:fs/promises"; +import { join } from "node:path"; +const exec = promisify(execFile); + +export function workflowHash(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +/** No guessed cache hits: large/linked/unreadable contexts disable replay. */ +export async function workflowContextHash(root: string): Promise { + const hash = createHash("sha256"); + let count = 0; + let bytes = 0; + try { + const head = await exec("git", ["rev-parse", "HEAD"], { cwd: root, timeout: 5_000 }); + hash.update(JSON.stringify({ head: head.stdout })); + const staged = await exec("git", ["diff", "--cached", "--binary", "--no-ext-diff", "--no-textconv"], { cwd: root, timeout: 5_000 }); + hash.update(JSON.stringify({ staged: staged.stdout })); + async function visit(path: string, relative: string): Promise { + if (++count > 20_000) throw new Error("context too large"); + const stat = await lstat(path); + hash.update(JSON.stringify({ path: relative, mode: stat.mode, size: stat.isFile() ? stat.size : 0, directory: stat.isDirectory() })); + if (stat.isSymbolicLink()) { + // A symlink can expose changing data outside the observed tree. + throw new Error("linked context"); + } + if (stat.isDirectory()) { + for (const entry of (await readdir(path)).sort()) { + if (entry === ".git") continue; + await visit(join(path, entry), `${relative}/${entry}`); + } + } else if (stat.isFile()) { + bytes += stat.size; + if (bytes > 64 * 1024 * 1024) throw new Error("context too large"); + const file = await open(path, "r"); + try { + const buffer = Buffer.alloc(stat.size + 1); + let length = 0; + while (length < buffer.length) { + const chunk = await file.read(buffer, length, buffer.length - length, length); + if (!chunk.bytesRead) break; + length += chunk.bytesRead; + } + const after = await file.stat(); + if (length !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs || after.ino !== stat.ino) throw new Error("changing context"); + hash.update(buffer.subarray(0, length)); + } finally { await file.close(); } + } else throw new Error("special file context"); + } + await visit(root, ""); + return hash.digest("hex"); + } catch { return undefined; } +} diff --git a/src/workflow-manager.test.ts b/src/workflow-manager.test.ts new file mode 100644 index 000000000..de99753d0 --- /dev/null +++ b/src/workflow-manager.test.ts @@ -0,0 +1,302 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { Result } from "better-result"; +import { AgentConflictError, AgentProviderCancelledError } from "./local-agent-errors.js"; +import { LocalAgentManager } from "./local-agent-manager.js"; +import { LocalAgentStore, type LocalAgentTurnRecord } from "./local-agent-store.js"; +import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; +import type { LocalAgentRunInput, LocalAgentRuntime } from "./local-agent-runtime.js"; +import { WorkflowManager } from "./workflow-manager.js"; +import { WorkflowStore } from "./workflow-store.js"; +import type { runWorkflowScript } from "./workflow-runner.js"; +import { WorkflowError } from "./workflow-types.js"; +import type { ServerConfig } from "./config.js"; +import { isManagedWorkflowWorkspace } from "./workflow-workspaces.js"; + +async function fixture(runner?: typeof runWorkflowScript) { + const dir = await mkdtemp(join(tmpdir(), "devspace-workflow-")); + await mkdir(join(dir, "project")); + const root = await realpath(join(dir, "project")); + execFileSync("git", ["init", "-q"], { cwd: root }); + execFileSync("git", ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-qm", "Initial"], { cwd: root }); + const stateDir = join(dir, "state"); + const config = { stateDir, allowedRoots: [root], worktreeRoot: join(dir, "worktrees") } as ServerConfig; + const inputs: LocalAgentRunInput[] = []; + let active = 0; + let maximum = 0; + let releaseHold: (() => void) | undefined; + const runtime: LocalAgentRuntime = { + provider: "codex", isAlive: () => true, close: async () => {}, releaseSession: async () => {}, + run: async (input, callbacks) => { + inputs.push(input); + active++; + maximum = Math.max(maximum, active); + try { + await callbacks?.onSessionId?.(`session_${inputs.length}`); + if (input.prompt === "commit") { + await writeFile(join(input.workspaceRoot, "committed.txt"), "preserve this commit"); + execFileSync("git", ["add", "committed.txt"], { cwd: input.workspaceRoot }); + execFileSync("git", ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "Worker change"], { cwd: input.workspaceRoot }); + await writeFile(join(input.workspaceRoot, "untracked.txt"), "preserve this too"); + } + if (input.prompt === "hold") { + await new Promise((resolve) => { + releaseHold = resolve; + input.signal?.addEventListener("abort", () => resolve(), { once: true }); + if (input.signal?.aborted) resolve(); + }); + if (input.signal?.aborted) return Result.err(new AgentProviderCancelledError({ + code: "PROVIDER_CANCELLED", provider: "codex", operation: "run", retryable: false, message: "Test provider confirmed cancellation.", + })); + } + await new Promise((resolve) => setTimeout(resolve, 5)); + return Result.ok({ provider: "codex" as const, providerSessionId: null, finalResponse: input.prompt.startsWith("Your last response") ? '{"ok":true}' : input.prompt, items: [] }); + } finally { active--; } + }, + }; + const agents = new LocalAgentManager({ + store: new LocalAgentStore(stateDir), pool: new LocalAgentRuntimePool(), loadProfiles: async () => [], + subagents: { enabled: true, instructions: "on-demand", providers: [{ id: "codex", enabled: true }] }, + drivers: [{ provider: "codex", runtimeKey: () => "shared", createRuntime: async () => Result.ok(runtime) }], + allowedRoots: [root], + authorizeManagedWorkspace: (path, id) => isManagedWorkflowWorkspace(config, path, id), + }); + const store = new WorkflowStore(stateDir); + const workflows = new WorkflowManager({ + store, agents, runner, config, loadProfiles: async () => [], allowedRoots: [root], + subagents: { enabled: true, instructions: "on-demand", providers: [{ id: "codex", enabled: true }] }, + }); + const scope = { workspaceRoot: root }; + return { + dir, root, inputs, agents, store, workflows, scope, maximum: () => maximum, + release: () => releaseHold?.(), + close: async () => { releaseHold?.(); await workflows.close(); await agents.close(); await rm(dir, { recursive: true, force: true }); }, + }; +} + +test("real JS pipeline runs in parallel, preserves order and durable turn ownership", async () => { + const f = await fixture(); + try { + const run = await f.workflows.run({ ...f.scope, source: `export const meta = {name:'test',concurrency:2}; + return await pipeline(['a','b','c'], value => agent(value,{target:'codex'}), (value,item) => agent(value+item,{target:'codex'}));` }); + const done = await f.workflows.wait(run.id, f.scope); + assert.equal(done.status, "completed", JSON.stringify(done.error)); + assert.deepEqual(done.result, ["aa", "bb", "cc"].map((value) => ({ status: "completed", value }))); + assert.equal(f.maximum(), 2); + assert.equal(f.workflows.calls(run.id, f.scope).length, 6); + assert.ok(f.workflows.calls(run.id, f.scope).every((call) => call.turnId && call.agentId && call.status === "completed")); + } finally { await f.close(); } +}); + +test("shared daemon capacity bounds simultaneous workflows", async () => { + const f = await fixture(async ({ onAgent }) => { + await Promise.all(Array.from({ length: 16 }, (_, i) => onAgent(String(i), { target: "codex" }))); + return "done"; + }); + try { + const source = "export const meta={concurrency:16}; return null;"; + const runs = await Promise.all([f.workflows.run({ ...f.scope, source }), f.workflows.run({ ...f.scope, source })]); + await Promise.all(runs.map((r) => f.workflows.wait(r.id, f.scope))); + assert.equal(f.maximum(), 8); + } finally { await f.close(); } +}); + +test("cancelling one workflow stops its exact owned turn and preserves another shared-runtime agent", async () => { + const f = await fixture(async ({ onAgent }) => await onAgent("hold", { target: "codex" }) as string); + try { + const run = await f.workflows.run({ ...f.scope, source: "return null;" }); + await until(() => f.inputs.length === 1); + const other = await f.agents.start({ ...f.scope, target: "codex", prompt: "other" }); + assert.ok(other.isOk()); + await f.workflows.cancel(run.id, f.scope); + assert.equal((await f.workflows.wait(run.id, f.scope)).status, "cancelled"); + const call = f.workflows.call(run.id, 0, f.scope); + assert.equal(call.status, "cancelled"); + assert.equal(unwrap(f.agents.getTurn(call.agentId, call))?.status, "stopped"); + assert.equal(unwrap(await f.agents.wait([other.value.id], f.scope))[0]?.status, "completed"); + } finally { await f.close(); } +}); + +test("normal return with unawaited calls cancels children before persisting failure", async () => { + const f = await fixture(async ({ onAgent }) => { + void Promise.resolve(onAgent("hold", { target: "codex" })).catch(() => {}); + await until(() => f.inputs.length === 1); + return "early"; + }); + try { + const run = await f.workflows.run({ ...f.scope, source: "return null;" }); + const done = await f.workflows.wait(run.id, f.scope); + assert.equal(done.status, "failed"); + assert.equal(done.error?.code, "UNAWAITED_CALLS"); + assert.equal(f.agents.activeTurnCount, 0); + } finally { await f.close(); } +}); + +test("schema validates before dispatch and one correction runs with reduced authority", async () => { + const f = await fixture(async ({ onAgent }) => await onAgent("work", { + target: "codex", schema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"], additionalProperties: false }, + }) as { ok: boolean }); + try { + const run = await f.workflows.run({ ...f.scope, source: "return null;", writeMode: "allowed" }); + assert.deepEqual((await f.workflows.wait(run.id, f.scope)).result, { ok: true }); + assert.deepEqual(f.inputs.map((i) => i.writeMode), ["allowed", "read_only"]); + assert.equal(f.workflows.calls(run.id, f.scope).length, 1); + } finally { await f.close(); } + const invalid = await fixture(async ({ onAgent }) => await onAgent("never", { target: "codex", schema: { type: "nonsense" } }) as null); + try { + const run = await invalid.workflows.run({ ...invalid.scope, source: "return null;" }); + assert.equal((await invalid.workflows.wait(run.id, invalid.scope)).error?.code, "INVALID_SCHEMA"); + assert.equal(invalid.inputs.length, 0); + } finally { await invalid.close(); } +}); + +test("resume reuses only compatible read-only prefix and rejects changed context and active runs", async () => { + const f = await fixture(async ({ onAgent }) => await onAgent("review", { target: "codex" }) as string); + try { + const first = await f.workflows.run({ ...f.scope, source: "return null;" }); + await assert.rejects(f.workflows.run({ ...f.scope, resume: first.id }), (e: unknown) => e instanceof WorkflowError && e.code === "WORKFLOW_ACTIVE"); + await f.workflows.wait(first.id, f.scope); + const resumed = await f.workflows.run({ ...f.scope, resume: first.id }); + await f.workflows.wait(resumed.id, f.scope); + assert.equal(f.inputs.length, 1); + assert.equal(f.workflows.call(resumed.id, 0, f.scope).reusedFrom, first.id); + await writeFile(join(f.root, "changed"), "changed"); + await assert.rejects(f.workflows.run({ ...f.scope, resume: first.id }), (e: unknown) => e instanceof WorkflowError && e.code === "RECOVERY_CONTEXT_CHANGED"); + assert.throws(() => f.workflows.get(first.id, { workspaceRoot: f.dir }), /not in this workspace/); + } finally { await f.close(); } +}); + +test("resume rejects staged Git state changes even when worktree files are unchanged", async () => { + const f = await fixture(async () => null); + try { + await writeFile(join(f.root, "staged.txt"), "same content"); + const first = await f.workflows.run({ ...f.scope, source: "return null;" }); + await f.workflows.wait(first.id, f.scope); + execFileSync("git", ["add", "staged.txt"], { cwd: f.root }); + await assert.rejects( + f.workflows.run({ ...f.scope, resume: first.id }), + (error: unknown) => error instanceof WorkflowError && error.code === "RECOVERY_CONTEXT_CHANGED", + ); + } finally { await f.close(); } +}); + +test("nested workflow shares capacity at concurrency one and cannot recursively nest", async () => { + const f = await fixture(); + try { + await mkdir(join(f.root, ".devspace", "workflows"), { recursive: true }); + await writeFile(join(f.root, ".devspace", "workflows", "child.js"), "return await agent('nested',{target:'codex'});"); + const run = await f.workflows.run({ ...f.scope, source: "export const meta={concurrency:1}; return await workflow('child',{});" }); + assert.equal((await f.workflows.wait(run.id, f.scope)).result, "nested"); + await writeFile(join(f.root, ".devspace", "workflows", "child.js"), "try { return await workflow('child',{}); } catch { return 'ignored'; }"); + const recursive = await f.workflows.run({ ...f.scope, source: "return await workflow('child',{});" }); + assert.equal((await f.workflows.wait(recursive.id, f.scope)).status, "failed"); + } finally { await f.close(); } +}); + +test("worktree calls share their logical workspace and preserve commits plus dirty files", async () => { + const f = await fixture(async ({ onAgent }) => { + await onAgent("commit", { target: "codex", isolation: "worktree", workspace: "implementation" }); + return await onAgent("verify", { target: "codex", workspace: "implementation", writeMode: "read_only" }) as string; + }); + try { + const run = await f.workflows.run({ ...f.scope, source: "return null;", writeMode: "allowed" }); + const done = await f.workflows.wait(run.id, f.scope); + assert.equal(done.status, "completed", JSON.stringify(done.error)); + const [implementation, verify] = f.workflows.calls(run.id, f.scope); + assert.equal(implementation!.workspaceRoot, verify!.workspaceRoot); + assert.notEqual(implementation!.workspaceRoot, f.root); + assert.equal(await readFile(join(implementation!.workspaceRoot, "untracked.txt"), "utf8"), "preserve this too"); + assert.equal(execFileSync("git", ["show", "HEAD:committed.txt"], { cwd: implementation!.workspaceRoot, encoding: "utf8" }), "preserve this commit"); + await assert.rejects(readFile(join(f.root, "committed.txt"))); + await assert.rejects(f.workflows.run({ ...f.scope, resume: run.id }), (error: unknown) => error instanceof WorkflowError && error.code === "RECOVERY_REQUIRED"); + } finally { await f.close(); } +}); + +test("restart reconciliation preserves durable ownership without dispatching interrupted calls", async () => { + const f = await fixture(async () => "done"); + try { + const run = await f.workflows.run({ ...f.scope, source: "return null;" }); + await f.workflows.wait(run.id, f.scope); + f.store.addCall({ runId: run.id, index: 0, agentId: "agt_interrupted", status: "queued", prompt: "pending", + options: { target: "codex", writeMode: "read_only" }, fingerprint: "old", ...f.scope, createdAt: "now", updatedAt: "now" }); + const child = unwrap(await f.agents.start({ ...f.scope, agentId: "agt_interrupted", target: "codex", prompt: "done", writeMode: "read_only" })); + await f.agents.wait([child.id], f.scope); + f.store.update(run.id, { status: "running" }); + f.workflows.reconcile(); + assert.equal(f.workflows.get(run.id, f.scope).status, "interrupted"); + const recovered = f.workflows.call(run.id, 0, f.scope); + assert.equal(recovered.agentId, "agt_interrupted"); + assert.ok(recovered.turnId); + assert.equal(recovered.status, "interrupted"); + assert.equal(f.inputs.length, 1); + } finally { await f.close(); } +}); + +test("parallel unnamed isolated calls receive distinct worktrees", async () => { + const f = await fixture(async ({ onAgent }) => { + await Promise.all([0, 1].map((i) => onAgent(String(i), { target: "codex", isolation: "worktree" }))); + return "done"; + }); + try { + const run = await f.workflows.run({ ...f.scope, source: "return null;" }); + const done = await f.workflows.wait(run.id, f.scope); + assert.equal(done.status, "completed", JSON.stringify(done.error)); + const calls = f.workflows.calls(run.id, f.scope); + assert.equal(new Set(calls.map((call) => call.workspaceRoot)).size, 2); + } finally { await f.close(); } +}); + +test("shutdown stops retrying unconfirmed provider cancellation and preserves recovery state", async () => { + const f = await fixture(async ({ onAgent }) => await onAgent("hold", { target: "codex" }) as string); + const originalGetTurn = f.agents.getTurn.bind(f.agents); + const originalCancel = f.agents.cancel.bind(f.agents); + let close: Promise | undefined; + try { + const run = await f.workflows.run({ ...f.scope, source: "return null;" }); + await until(() => f.inputs.length === 1); + await until(() => f.workflows.calls(run.id, f.scope)[0]?.turnId !== undefined); + const call = f.workflows.call(run.id, 0, f.scope); + const runningTurn: LocalAgentTurnRecord = { + id: call.turnId!, agentId: call.agentId, prompt: call.prompt, status: "running", createdAt: "now", + }; + f.agents.getTurn = () => Result.ok(runningTurn); + f.agents.cancel = async () => Result.err(new AgentConflictError({ + code: "AGENT_CONFLICT", agentId: call.agentId, operation: "cancel", retryable: true, + message: "Provider cancellation is unavailable.", + })); + close = f.workflows.close(); + let timer: NodeJS.Timeout | undefined; + await Promise.race([ + close, + new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error("workflow close timed out")), 2_000); }), + ]).finally(() => { if (timer) clearTimeout(timer); }); + const reopened = new WorkflowStore(join(f.dir, "state")); + try { + assert.equal(reopened.get(run.id)?.status, "stopping"); + assert.equal(reopened.get(run.id)?.error?.code, "RECOVERY_REQUIRED"); + } finally { reopened.close(); } + } finally { + f.agents.getTurn = originalGetTurn; + f.agents.cancel = originalCancel; + f.release(); + await close?.catch(() => {}); + await f.agents.close(); + await rm(f.dir, { recursive: true, force: true }); + } +}); + +async function until(check: () => boolean): Promise { + const end = Date.now() + 5_000; + while (!check()) { + if (Date.now() > end) throw new Error("Test condition timed out"); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} +function unwrap(result: import("better-result").Result): T { + if (result.isErr()) throw result.error; + return result.value; +} diff --git a/src/workflow-manager.ts b/src/workflow-manager.ts new file mode 100644 index 000000000..beb708bd8 --- /dev/null +++ b/src/workflow-manager.ts @@ -0,0 +1,480 @@ +import { randomUUID } from "node:crypto"; +import { open, realpath } from "node:fs/promises"; +import { realpathSync } from "node:fs"; +import { join, resolve } from "node:path"; +import type { ServerConfig } from "./config.js"; +import type { SubagentsConfig } from "./local-agent-config.js"; +import type { LocalAgentManager } from "./local-agent-manager.js"; +import type { LocalAgentProfile } from "./local-agent-profiles.js"; +import type { LocalAgentWorkspaceScope } from "./local-agent-store.js"; +import { resolveLocalAgentTarget } from "./local-agent-targets.js"; +import { resolveCanonicalAllowedPath } from "./roots.js"; +import { workflowContextHash, workflowHash } from "./workflow-context.js"; +import { runWorkflowScript } from "./workflow-runner.js"; +import { parseWorkflowScript } from "./workflow-script.js"; +import { compileWorkflowSchema, parseWorkflowOutput } from "./workflow-schema.js"; +import type { WorkflowStore } from "./workflow-store.js"; +import { + WorkflowError, workflowFailure, workflowTerminal, + type WorkflowAgentOptions, type WorkflowCall, type WorkflowEvent, type WorkflowRun, type WorkflowRunInput, type WorkflowSnapshot, +} from "./workflow-types.js"; +import { createWorkflowWorkspace, isManagedWorkflowWorkspace } from "./workflow-workspaces.js"; + +interface WorkflowManagerOptions { + store: WorkflowStore; + agents: LocalAgentManager; + loadProfiles: (workspaceRoot: string) => Promise; + subagents: SubagentsConfig; + allowedRoots?: readonly string[]; + config?: ServerConfig; + /** Tests may exercise the supervisor without a provider or child process. */ + runner?: typeof runWorkflowScript; +} +interface ActiveRun { + run: WorkflowSnapshot; + controller: AbortController; + done: Promise; + pending: Set>; + preparing: Promise; + calls: number; + dispatches: number; + nested: number; + eventBytes: number; + slots: Slots; + workspaces: Map>; + previous: WorkflowCall[]; + replayPrefix: boolean; + unconfirmedStops: boolean; +} + +/** Owns execution; host-authored JavaScript owns the workflow decisions. */ +export class WorkflowManager { + private readonly active = new Map(); + private readonly slots = new Slots(8); + private scripts = 0; + private accepting = true; + constructor(private readonly options: WorkflowManagerOptions) {} + get activeRunCount(): number { return this.active.size; } + + async run(input: WorkflowRunInput): Promise { + if (!this.accepting) throw new WorkflowError("WORKFLOW_STOPPING", "Workflow service is stopping."); + if (this.active.size >= 8) throw new WorkflowError("WORKFLOW_CAPACITY", "Eight workflows are already active. Wait for one to finish.", true); + const scope = await this.authorize(input); + if ([input.source !== undefined, input.name !== undefined, input.resume !== undefined].filter(Boolean).length !== 1) { + throw new WorkflowError("INVALID_WORKFLOW", "Supply exactly one source, name, or resume ID."); + } + const previous = input.resume ? this.snapshot(input.resume, scope) : undefined; + if (previous && (!workflowTerminal(previous.status) || this.active.has(previous.id))) { + throw new WorkflowError("WORKFLOW_ACTIVE", "Cancel and wait for the previous run before resuming."); + } + const previousCalls = previous ? this.options.store.calls(previous.id) : []; + for (const call of previousCalls) { + const record = this.options.agents.get(call.agentId, call); + if (record.isErr() && record.error.code !== "AGENT_NOT_FOUND") { + throw new WorkflowError("RECOVERY_REQUIRED", `Cannot establish ownership of prior agent ${call.agentId}: ${record.error.message}`); + } + if (record.isOk() && (record.value.status === "running" || record.value.status === "starting")) { + throw new WorkflowError("WORKFLOW_ACTIVE", `Child agent ${call.agentId} is still active.`); + } + if (call.options.writeMode !== "read_only" || call.options.isolation || call.options.workspace) { + throw new WorkflowError("RECOVERY_REQUIRED", "This run may have changed a workspace. Inspect its calls and preserved worktrees, then start a new explicit workflow; editing calls are never replayed."); + } + } + if (previous && (input.args !== undefined || input.writeMode !== undefined)) { + throw new WorkflowError("INVALID_WORKFLOW", "Resume uses the original arguments and authority. Start a new run to change them."); + } + const source = previous?.source ?? input.source ?? await this.namedSource(input.name!, scope.workspaceRoot); + if (typeof source !== "string" || Buffer.byteLength(source) > 65_536) throw new WorkflowError("SOURCE_LIMIT", "Workflow source exceeds 64 KiB."); + let meta: ReturnType["meta"]; + try { meta = parseWorkflowScript(source).meta; } + catch (error) { throw new WorkflowError("INVALID_WORKFLOW", workflowFailure(error).message); } + const args = jsonValue(previous ? previous.args : input.args ?? {}, 131_072, "arguments"); + const contextHash = await workflowContextHash(scope.workspaceRoot); + if (previous && (!contextHash || contextHash !== previous.contextHash)) { + throw new WorkflowError("RECOVERY_CONTEXT_CHANGED", "Workspace context changed or cannot be fully fingerprinted. Start a new workflow after reviewing the previous run."); + } + const writeMode = previous?.writeMode ?? input.writeMode ?? "read_only"; + if (writeMode !== "read_only" && writeMode !== "allowed") throw new WorkflowError("INVALID_WORKFLOW", "Invalid workflow write mode."); + const now = new Date().toISOString(); + const run: WorkflowSnapshot = { + id: `wf_${randomUUID()}`, ...scope, name: meta.name, source, args, contextHash: contextHash ?? "unavailable", + writeMode, concurrency: meta.concurrency, status: "starting", resumeOf: previous?.id, + createdAt: now, updatedAt: now, callCount: 0, + }; + if (this.active.size >= 8) throw new WorkflowError("WORKFLOW_CAPACITY", "Eight workflows are already active. Wait for one to finish.", true); + this.options.store.create(run); + const active: ActiveRun = { + run, controller: new AbortController(), pending: new Set(), preparing: Promise.resolve(), + calls: 0, dispatches: 0, nested: 0, eventBytes: 0, slots: new Slots(run.concurrency), workspaces: new Map(), previous: previousCalls, replayPrefix: true, + unconfirmedStops: false, + done: Promise.resolve(), + }; + this.active.set(run.id, active); + active.done = this.execute(active); + // Completion failures remain visible in persisted state; never create an unhandled rejection. + void active.done.catch(() => {}); + return summary(run); + } + + get(id: string, scope: LocalAgentWorkspaceScope): WorkflowRun { return summary(this.snapshot(id, scope)); } + list(scope: LocalAgentWorkspaceScope): WorkflowRun[] { + return this.options.store.list({ ...scope, workspaceRoot: canonicalRoot(scope.workspaceRoot) }).map(summary); + } + calls(id: string, scope: LocalAgentWorkspaceScope): WorkflowCall[] { + this.snapshot(id, scope); + return this.options.store.calls(id); + } + call(id: string, index: number, scope: LocalAgentWorkspaceScope): WorkflowCall { + this.snapshot(id, scope); + const call = this.options.store.call(id, index); + if (!call) throw new WorkflowError("WORKFLOW_CALL_NOT_FOUND", `Unknown workflow call: ${index}`); + return call; + } + events(id: string, scope: LocalAgentWorkspaceScope, after = 0): WorkflowEvent[] { + this.snapshot(id, scope); + if (!Number.isSafeInteger(after) || after < 0) throw new WorkflowError("INVALID_WORKFLOW", "Event cursor must be a nonnegative sequence number."); + return this.options.store.events(id, after); + } + async wait(id: string, scope: LocalAgentWorkspaceScope, timeoutMs = 60_000, signal?: AbortSignal): Promise { + const run = this.get(id, scope); + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 60_000) throw new WorkflowError("INVALID_WORKFLOW", "Wait timeout must be 0–60000 milliseconds."); + const active = this.active.get(id); + if (!active || workflowTerminal(run.status) || signal?.aborted) return run; + await new Promise((done) => { + const finish = () => { clearTimeout(timer); signal?.removeEventListener("abort", finish); done(); }; + const timer = setTimeout(finish, timeoutMs); + signal?.addEventListener("abort", finish, { once: true }); + void active.done.then(finish, finish); + }); + return this.get(id, scope); + } + async cancel(id: string, scope: LocalAgentWorkspaceScope): Promise { + const run = this.get(id, scope); + const active = this.active.get(id); + if (active) { + this.options.store.update(id, { status: "stopping" }); + active.controller.abort(new WorkflowError("WORKFLOW_CANCELLED", "Workflow cancellation requested.")); + } + return active ? this.get(id, scope) : run; + } + reconcile(): void { + for (const run of this.options.store.list()) { + if (workflowTerminal(run.status)) continue; + for (const call of this.options.store.calls(run.id)) { + if (call.status !== "queued" && call.status !== "running") continue; + // Recover a crash between durable agent dispatch and saving the turn link. + const turn = this.options.agents.getTurn(call.agentId, call); + this.options.store.updateCall(run.id, call.index, { + status: "interrupted", turnId: turn.isOk() ? turn.value?.id : call.turnId, + error: { code: "WORKFLOW_INTERRUPTED", message: "Daemon stopped before this call was finalized. Inspect the agent before retrying.", retryable: false }, + }); + } + this.options.store.update(run.id, { + status: "interrupted", error: { code: "WORKFLOW_INTERRUPTED", message: "Daemon stopped. Explicit resume is required; no calls were automatically dispatched.", retryable: false }, + }); + } + } + async close(): Promise { + this.accepting = false; + const active = [...this.active.values()]; + for (const run of active) run.controller.abort(new WorkflowError("WORKFLOW_CANCELLED", "Daemon is stopping.")); + await Promise.allSettled(active.map((run) => run.done)); + this.options.store.close(); + } + + private async execute(active: ActiveRun): Promise { + const { run, controller } = active; + let result: unknown; + let failure: unknown; + try { + this.options.store.update(run.id, { status: "running" }); + result = await this.script(active, run.source, run.args, 0); + if (active.pending.size) throw new WorkflowError("UNAWAITED_CALLS", "Workflow returned while agent calls were still pending. Await all calls before returning."); + if (controller.signal.aborted) throw controller.signal.reason; + } catch (error) { failure = error; } + finally { + // One finalization path: stop admission first, then confirm every owned turn has settled. + controller.abort(failure ?? new WorkflowError("WORKFLOW_FINISHED", "Workflow finished.")); + try { + if (active.pending.size) { + try { this.options.store.update(run.id, { status: "stopping" }); } catch { /* Still stop children if persistence is unavailable. */ } + } + await Promise.allSettled([...active.pending]); + if (active.unconfirmedStops) return; + const error = failure ? workflowFailure(failure) : undefined; + this.options.store.update(run.id, { + status: error ? (error.code === "WORKFLOW_CANCELLED" ? "cancelled" : "failed") : "completed", + result: error ? undefined : result, error, + }); + } finally { this.active.delete(run.id); } + } + } + private async script(active: ActiveRun, source: string, args: unknown, depth: number): Promise { + if (this.scripts >= 16) throw this.fatal(active, new WorkflowError("WORKFLOW_RUNNER_LIMIT", "Sixteen script runners are already active.")); + this.scripts++; + const runner = this.options.runner ?? runWorkflowScript; + try { return await runner({ + source, args, signal: active.controller.signal, depth, + onAgent: (prompt, options) => { + const task = this.agent(active, prompt, options as WorkflowAgentOptions, workflowHash(source)); + active.pending.add(task); + void task.then(() => active.pending.delete(task), () => active.pending.delete(task)); + return task; + }, + onWorkflow: (name, nestedArgs) => { + if (depth >= 1) throw this.fatal(active, new WorkflowError("WORKFLOW_DEPTH_LIMIT", "Nested workflows are limited to one level.")); + if (++active.nested > 8) throw this.fatal(active, new WorkflowError("WORKFLOW_NESTED_LIMIT", "A run can invoke at most eight nested workflows.")); + const task = (async () => { + const nestedSource = await this.namedSource(name, active.run.workspaceRoot); + this.options.store.event(active.run.id, "nested_workflow", { name, source: nestedSource }); + return this.script(active, nestedSource, nestedArgs, depth + 1); + })(); + active.pending.add(task); + void task.then(() => active.pending.delete(task), (error) => { + active.pending.delete(task); + if (workflowFailure(error).code.includes("LIMIT")) this.fatal(active, error); + }); + return task; + }, + onEvent: (type, data) => { + active.eventBytes += Buffer.byteLength(JSON.stringify(data)); + if (active.eventBytes > 65_536) throw this.fatal(active, new WorkflowError("WORKFLOW_LOG_LIMIT", "Combined workflow events exceed 64 KiB.")); + this.options.store.event(active.run.id, type, data); + }, + }); } finally { this.scripts--; } + } + private async agent(active: ActiveRun, prompt: string, value: WorkflowAgentOptions, sourceHash: string): Promise { + const signal = active.controller.signal; + signal.throwIfAborted(); + const index = active.calls++; + if (index >= 128) throw this.fatal(active, new WorkflowError("WORKFLOW_CALL_LIMIT", "Workflow exceeds 128 agent calls.")); + // Serialize preparation/replay decisions, never the agent execution itself. + const prepared = active.preparing.then(async () => { + signal.throwIfAborted(); + const options = agentOptions(value, active.run.writeMode); + if (typeof prompt !== "string" || !prompt.trim() || Buffer.byteLength(prompt) > 65_536) throw new WorkflowError("INVALID_AGENT_CALL", "Agent prompt must be nonempty and at most 64 KiB."); + const validate = options.schema ? compileWorkflowSchema(options.schema) : undefined; + const scope = await this.callWorkspace(active, options, index); + const profiles = await this.options.loadProfiles(scope.workspaceRoot); + const target = resolveLocalAgentTarget(options.target, profiles, options.model, options.effort, this.options.subagents.providers); + if (!target) throw new WorkflowError("UNKNOWN_TARGET", `Unknown agent target: ${options.target}`); + options.model = target.model; + options.effort = target.effort; + const fingerprint = workflowHash({ prompt, options, sourceHash, scope, context: active.run.contextHash, + profile: target.kind === "profile" ? target.profile : null, providers: this.options.subagents }); + const previous = active.previous[index]; + const reuse = active.replayPrefix && previous?.status === "completed" && previous.fingerprint === fingerprint && options.writeMode === "read_only"; + if (!reuse) { + if (active.replayPrefix && active.run.resumeOf) this.options.store.event(active.run.id, "replay_invalidated", { index, reason: previous ? "call context or result changed" : "end of previous calls" }); + active.replayPrefix = false; + } + const now = new Date().toISOString(); + const call: WorkflowCall = { + runId: active.run.id, index, agentId: `agt_${randomUUID()}`, ...scope, prompt, options, fingerprint, + status: reuse ? "completed" : "queued", createdAt: now, updatedAt: now, + ...(reuse ? { reusedFrom: active.run.resumeOf, result: previous.result, agentId: previous.agentId, turnId: previous.turnId } : {}), + }; + this.options.store.addCall(call); + return { call, validate, reuse }; + }); + active.preparing = prepared.catch(() => {}); + const { call, validate, reuse } = await prepared; + if (reuse) return call.result; + let releaseRun: (() => void) | undefined; + let releaseGlobal: (() => void) | undefined; + try { + releaseRun = await active.slots.acquire(signal); + releaseGlobal = await this.slots.acquire(signal); + signal.throwIfAborted(); + this.countDispatch(active); + const fullPrompt = validate ? `${prompt}\n\nReturn only JSON matching this schema:\n${JSON.stringify(call.options.schema)}` : prompt; + const started = await this.options.agents.start({ ...call, ...call.options, prompt: fullPrompt, agentId: call.agentId }); + if (started.isErr()) throw started.error; + let turn = this.options.agents.getTurn(call.agentId, call); + if (turn.isErr()) throw turn.error; + if (!turn.value) throw new WorkflowError("WORKFLOW_INTERNAL", "Agent dispatch returned without a durable turn."); + call.turnId = turn.value.id; + this.options.store.updateCall(call.runId, call.index, { status: "running", turnId: turn.value.id }); + this.options.store.event(call.runId, "agent_turn", { index, agentId: call.agentId, turnId: turn.value.id, repair: false }); + let response = await this.awaitTurn(active, call, turn.value.id); + let output: unknown = response; + if (validate) { + try { output = parseWorkflowOutput(response, validate); } + catch (error) { + // Repair only formatting, in the same session with authority reduced to read-only. + signal.throwIfAborted(); + this.countDispatch(active); + this.options.store.event(call.runId, "schema_repair", { index, reason: workflowFailure(error).message }); + const repair = await this.options.agents.continue(call.agentId, + `Your last response failed JSON validation: ${workflowFailure(error).message}\nReturn corrected JSON only. Do not redo the task or change files. Schema: ${JSON.stringify(call.options.schema)}`, + { writeMode: "read_only" }, call); + if (repair.isErr()) throw repair.error; + turn = this.options.agents.getTurn(call.agentId, call); + if (turn.isErr()) throw turn.error; + if (!turn.value) throw new WorkflowError("WORKFLOW_INTERNAL", "Schema repair has no durable turn."); + call.turnId = turn.value.id; + this.options.store.updateCall(call.runId, call.index, { turnId: turn.value.id }); + this.options.store.event(call.runId, "agent_turn", { index, agentId: call.agentId, turnId: turn.value.id, repair: true }); + response = await this.awaitTurn(active, call, turn.value.id); + output = parseWorkflowOutput(response, validate); + } + } + output = jsonValue(output, 262_144, "agent result"); + this.options.store.updateCall(call.runId, index, { status: "completed", result: output }); + return output; + } catch (error) { + // Resolve ownership again if cancellation raced dispatch or the turn-link write. + await this.stopOwnedCall(active, call); + this.options.store.updateCall(call.runId, index, { status: signal.aborted ? "cancelled" : "failed", error: workflowFailure(error) }); + if (workflowFailure(error).code === "WORKFLOW_INTERNAL" || workflowFailure(error).code.includes("LIMIT")) this.fatal(active, error); + throw error; + } finally { releaseGlobal?.(); releaseRun?.(); } + } + private async awaitTurn(active: ActiveRun, call: WorkflowCall, turnId: number): Promise { + const signal = active.controller.signal; + const waited = await this.options.agents.wait([call.agentId], call, undefined, signal); + if (signal.aborted) { + await this.stopOwnedCall(active, { ...call, turnId }); + throw signal.reason; + } + if (waited.isErr()) throw waited.error; + const result = waited.value[0]; + if (result?.status === "completed") { + const response = result.response ?? ""; + if (Buffer.byteLength(response) > 262_144) throw new WorkflowError("WORKFLOW_VALUE_LIMIT", "Agent result exceeds 256 KiB."); + return response; + } + if (result?.status === "failed" || result?.status === "stopped") throw new WorkflowError(result.error?.code ?? "AGENT_STOPPED", result.error?.message ?? "Agent stopped.", result.error?.retryable); + throw new WorkflowError("WORKFLOW_INTERNAL", "Agent wait ended without a terminal result."); + } + private async stopOwnedCall(active: ActiveRun, call: WorkflowCall): Promise { + for (;;) { + let uncertainty: unknown; + try { + const current = this.options.agents.getTurn(call.agentId, call); + if (current.isErr()) { + if (current.error.code === "AGENT_NOT_FOUND") return; + uncertainty = current.error; + } else { + if (!current.value || current.value.status !== "running") return; + if (call.turnId !== undefined && current.value.id !== call.turnId) return; + call.turnId ??= current.value.id; + const stopped = await this.options.agents.cancel(call.agentId, call.turnId, call); + if (stopped.isOk() && stopped.value.status !== "running") return; + uncertainty = stopped.isErr() ? stopped.error : new Error("Provider has not confirmed stopping."); + } + } catch (error) { uncertainty = error; } + const error = new WorkflowError("RECOVERY_REQUIRED", `Waiting to confirm agent ${call.agentId} stopped: ${workflowFailure(uncertainty).message}`, true); + this.fatal(active, error); + try { + this.options.store.update(active.run.id, { status: "stopping", error: workflowFailure(error) }); + this.options.store.updateCall(call.runId, call.index, { error: workflowFailure(error) }); + } catch { /* Keep ownership in memory until persistence returns or the daemon restarts. */ } + // Admission remains closed while confirmation is unavailable; cancellation is not completion. + if (!this.accepting) { + active.unconfirmedStops = true; + return; + } + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + } + private fatal(active: ActiveRun, error: unknown): unknown { active.controller.abort(error); return error; } + private countDispatch(active: ActiveRun): void { + if (++active.dispatches > 128) throw this.fatal(active, new WorkflowError("WORKFLOW_CALL_LIMIT", "Workflow exceeds 128 agent turns, including schema repairs.")); + } + private snapshot(id: string, scope: LocalAgentWorkspaceScope): WorkflowSnapshot { + const run = this.options.store.get(id); + if (!run || run.workspaceRoot !== canonicalRoot(scope.workspaceRoot) || run.workspaceId !== scope.workspaceId) { + throw new WorkflowError("WORKFLOW_NOT_FOUND", `Workflow is not in this workspace: ${id}`); + } + return run; + } + private async authorize(scope: LocalAgentWorkspaceScope): Promise { + const root = await realpath(scope.workspaceRoot); + if (this.options.allowedRoots) { + const managed = this.options.config && scope.workspaceId && isManagedWorkflowWorkspace(this.options.config, root, scope.workspaceId); + if (!managed) await resolveCanonicalAllowedPath(root, root, [...this.options.allowedRoots]); + } + return { workspaceRoot: root, ...(scope.workspaceId ? { workspaceId: scope.workspaceId } : {}) }; + } + private async namedSource(name: string, root: string): Promise { + if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name)) throw new WorkflowError("INVALID_WORKFLOW", "Workflow name must be a simple name, without paths."); + try { + const path = await resolveCanonicalAllowedPath(join(root, ".devspace", "workflows", `${name}.js`), root, [root]); + const file = await open(path, "r"); + try { + const buffer = Buffer.alloc(65_537); + const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); + if (bytesRead > 65_536) throw new WorkflowError("SOURCE_LIMIT", "Workflow source exceeds 64 KiB."); + return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, bytesRead)); + } finally { await file.close(); } + } catch (error) { + if (error instanceof WorkflowError) throw error; + throw new WorkflowError("INVALID_WORKFLOW", `Cannot read workflow ${name}: ${workflowFailure(error).message}`); + } + } + private async callWorkspace(active: ActiveRun, options: WorkflowAgentOptions, index: number): Promise { + if (!options.workspace && !options.isolation) return { workspaceRoot: active.run.workspaceRoot, workspaceId: active.run.workspaceId }; + const key = options.workspace ?? `@call:${index}`; + let workspace = active.workspaces.get(key); + if (!workspace) { + if (!options.isolation) throw new WorkflowError("INVALID_WORKSPACE", `Unknown workflow workspace: ${key}. Create it with isolation: 'worktree'.`); + if (!this.options.config) throw new WorkflowError("INVALID_WORKSPACE", "Managed worktrees are unavailable."); + workspace = createWorkflowWorkspace(this.options.config, active.run); + active.workspaces.set(key, workspace); + const scope = await workspace; + this.options.store.event(active.run.id, "workspace_created", { key, ...scope, retained: true }); + } + return workspace; + } +} + +function summary({ source: _source, args: _args, contextHash: _hash, ...run }: WorkflowSnapshot): WorkflowRun { return run; } +function canonicalRoot(root: string): string { + try { return realpathSync(root); } catch { return resolve(root); } +} +function jsonValue(value: unknown, bytes: number, label: string): unknown { + let encoded: string | undefined; + try { encoded = JSON.stringify(value); } catch { /* Fail with the same public boundary error. */ } + if (encoded === undefined || Buffer.byteLength(encoded) > bytes) throw new WorkflowError("WORKFLOW_VALUE_LIMIT", `Workflow ${label} must be JSON and at most ${bytes} bytes.`); + return JSON.parse(encoded); +} +function agentOptions(value: WorkflowAgentOptions, authority: "read_only" | "allowed"): WorkflowAgentOptions { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new WorkflowError("INVALID_AGENT_CALL", "Agent options must be an object."); + const options = jsonValue(value, 32_768, "agent options") as WorkflowAgentOptions; + const keys = new Set(["target", "model", "effort", "schema", "label", "phase", "writeMode", "isolation", "workspace"]); + for (const key of Object.keys(options)) if (!keys.has(key)) throw new WorkflowError("INVALID_AGENT_CALL", `Unknown agent option: ${key}`); + if (typeof options.target !== "string" || !options.target.trim()) throw new WorkflowError("INVALID_AGENT_CALL", "Agent target is required."); + for (const key of ["model", "effort", "label", "phase", "workspace"] as const) { + if (options[key] !== undefined && (typeof options[key] !== "string" || options[key]!.length > 256 || !options[key]!.trim())) throw new WorkflowError("INVALID_AGENT_CALL", `Invalid agent ${key}.`); + } + if (options.workspace && !/^[\w-]{1,64}$/.test(options.workspace)) throw new WorkflowError("INVALID_WORKSPACE", "Workspace is a logical name, not a path."); + if (options.isolation !== undefined && options.isolation !== "worktree") throw new WorkflowError("INVALID_AGENT_CALL", "Only worktree isolation is supported."); + options.writeMode ??= authority; + if (!["read_only", "allowed"].includes(options.writeMode) || (authority === "read_only" && options.writeMode !== "read_only")) throw new WorkflowError("WORKFLOW_AUTHORITY", "Agent call cannot increase the workflow's write authority."); + if (options.schema !== undefined && (!options.schema || typeof options.schema !== "object" || Array.isArray(options.schema))) throw new WorkflowError("INVALID_SCHEMA", "Schema must be an object."); + return options; +} + +class Slots { + private used = 0; + private readonly waiting: Array<() => void> = []; + constructor(private readonly limit: number) {} + async acquire(signal: AbortSignal): Promise<() => void> { + signal.throwIfAborted(); + if (this.used >= this.limit) { + await new Promise((resolve, reject) => { + const ready = () => { signal.removeEventListener("abort", aborted); resolve(); }; + const aborted = () => { const index = this.waiting.indexOf(ready); if (index >= 0) this.waiting.splice(index, 1); reject(signal.reason); }; + this.waiting.push(ready); + signal.addEventListener("abort", aborted, { once: true }); + }); + } else this.used++; + if (signal.aborted) { this.release(); throw signal.reason; } + return () => this.release(); + } + private release(): void { + const next = this.waiting.shift(); + if (next) next(); else this.used--; + } +} diff --git a/src/workflow-runner-child.ts b/src/workflow-runner-child.ts new file mode 100644 index 000000000..d59ed1d28 --- /dev/null +++ b/src/workflow-runner-child.ts @@ -0,0 +1,422 @@ +import { + getQuickJS, + type QuickJSContext, + type QuickJSDeferredPromise, + type QuickJSHandle, + type QuickJSRuntime, +} from "quickjs-emscripten"; +import type { WorkflowRunnerLimits } from "./workflow-runner.js"; +import type { WorkflowScriptMeta } from "./workflow-script.js"; + +interface ErrorPayload { code: string; message: string; retryable?: boolean } +interface RunMessage { + type: "run"; + source: string; + args: unknown; + meta: WorkflowScriptMeta; + limits: WorkflowRunnerLimits; +} +interface HostResultMessage { + type: "host-result"; + id: number; + ok: boolean; + value?: unknown; + error?: ErrorPayload; + fatal?: boolean; +} + +let context: QuickJSContext | undefined; +let runtime: QuickJSRuntime | undefined; +let jsonObject: QuickJSHandle | undefined; +let jsonParse: QuickJSHandle | undefined; +let jsonStringify: QuickJSHandle | undefined; +let limits: WorkflowRunnerLimits | undefined; +let deadline = 0; +let nextCallId = 1; +let fatalError: ErrorPayload | undefined; +let logBytes = 0; +let logEntries = 0; +let shuttingDown = false; +const parentPid = process.ppid; +const pending = new Map(); + +process.once("disconnect", () => { + if (!shuttingDown) process.exit(1); +}); + +process.on("message", (message: unknown) => { + if (!isRecord(message)) return; + if (message.type === "run") { + if (runtime) return fail({ code: "WORKFLOW_PROTOCOL_ERROR", message: "Workflow runner received more than one run request." }); + void run(message as unknown as RunMessage); + } else if (message.type === "host-result") { + receiveHostResult(message as unknown as HostResultMessage); + } +}); + +async function run(message: RunMessage): Promise { + try { + limits = message.limits; + deadline = Date.now() + limits.timeoutMs; + const QuickJS = await getQuickJS(); + runtime = QuickJS.newRuntime(); + // ponytail: QuickJS exposes hard limits but no allocator-failure/high-water hook; + // use a custom WASM allocator if caught OOM/stack exceptions must stay observable. + runtime.setMemoryLimit(limits.memoryBytes); + runtime.setMaxStackSize(limits.stackBytes); + runtime.setInterruptHandler(() => { + if (process.ppid !== parentPid) process.exit(1); + if (fatalError) return true; + if (Date.now() <= deadline) return false; + fatalError = { code: "WORKFLOW_TIMEOUT", message: `Workflow exceeded ${limits?.timeoutMs ?? 0}ms.` }; + return true; + }); + context = runtime.newContext(); + jsonObject = context.getProp(context.global, "JSON"); + jsonParse = context.getProp(jsonObject, "parse"); + jsonStringify = context.getProp(jsonObject, "stringify"); + exposeHostFunctions(context); + + const evaluation = context.evalCode(workflowProgram(message), "workflow.js"); + if (evaluation.error) { + const error = quickJsError(evaluation.error); + evaluation.error.dispose(); + return fail(classifyExecutionError(error)); + } + + const promise = evaluation.value; + const resolved = context.resolvePromise(promise); + pumpJobs(); + const result = await resolved; + promise.dispose(); + if (fatalError) return fail(fatalError, true); + if (result.error) { + const error = quickJsError(result.error); + result.error.dispose(); + return fail(classifyExecutionError(error)); + } + if (pending.size > 0) { + return fail({ code: "UNAWAITED_CALLS", message: "Workflow returned while host calls were still pending. Await all calls before returning." }); + } + if (fatalError) return fail(fatalError, true); + + const encoded = encodeGuestJson(context, result.value, "Workflow result"); + result.value.dispose(); + if (Buffer.byteLength(encoded, "utf8") > limits.maxResultBytes) { + return fail({ code: "WORKFLOW_RESULT_LIMIT", message: `Workflow result exceeds the ${limits.maxResultBytes}-byte limit.` }, true); + } + send({ type: "result", value: JSON.parse(encoded) }); + } catch (error) { + fail(classifyExecutionError(error)); + } finally { + cleanup(); + } +} + +function exposeHostFunctions(vm: QuickJSContext): void { + const hostCall = vm.newFunction("__hostCall", (kindHandle, payloadHandle) => { + const deferred = vm.newPromise(); + try { + if (!limits) throw new Error("Workflow limits are unavailable."); + if (pending.size >= limits.maxOutstanding) { + const error = { + code: "WORKFLOW_OUTSTANDING_LIMIT", + message: `Workflow has more than ${limits.maxOutstanding} outstanding host calls.`, + }; + fatalError = error; + reject(deferred, error); + return deferred.handle; + } + const kind = vm.getString(kindHandle); + if (kind !== "agent" && kind !== "workflow") throw new Error("Unknown workflow host call."); + const encoded = encodeGuestJson(vm, payloadHandle, "Workflow host call"); + if (Buffer.byteLength(encoded, "utf8") > limits.maxArgsBytes) { + const error = { + code: "WORKFLOW_SIZE_LIMIT", + message: `Workflow host call exceeds the ${limits.maxArgsBytes}-byte limit.`, + }; + fatalError = error; + reject(deferred, error); + return deferred.handle; + } + const id = nextCallId++; + pending.set(id, deferred); + const safePayload = JSON.parse(encoded) as Record; + send(kind === "agent" + ? { type: "host-call", id, kind, prompt: safePayload.prompt, options: safePayload.options } + : { type: "host-call", id, kind, name: safePayload.name, args: safePayload.args }); + return deferred.handle; + } catch (error) { + reject(deferred, toErrorPayload(error)); + return deferred.handle; + } + }); + vm.setProp(vm.global, "__hostCall", hostCall); + hostCall.dispose(); + + const emit = vm.newFunction("__emit", (typeHandle, dataHandle) => { + if (!limits) throw new Error("Workflow limits are unavailable."); + const eventType = vm.getString(typeHandle); + const encoded = encodeGuestJson(vm, dataHandle, "Workflow event"); + logEntries += 1; + logBytes += Buffer.byteLength(encoded, "utf8") + eventType.length; + if (logEntries > limits.maxLogEntries || logBytes > limits.maxLogBytes) { + fatalError = { + code: "WORKFLOW_LOG_LIMIT", + message: `Workflow events exceed their configured limit.`, + }; + throw Object.assign(new Error(fatalError.message), fatalError); + } + send({ type: "event", eventType, data: JSON.parse(encoded) }); + }); + vm.setProp(vm.global, "__emit", emit); + emit.dispose(); +} + +function receiveHostResult(message: HostResultMessage): void { + if (!context || !runtime) return; + const deferred = pending.get(message.id); + if (!deferred) return; + pending.delete(message.id); + let failed = false; + try { + if (message.ok) { + const handle = valueHandle(context, message.value); + deferred.resolve(handle); + handle.dispose(); + } else { + const error = message.error ?? { code: "WORKFLOW_HOST_ERROR", message: "Workflow host call failed." }; + if (message.fatal) fatalError = error; + reject(deferred, error); + } + pumpJobs(); + } catch (error) { + failed = true; + fatalError = classifyExecutionError(error); + fail(fatalError, true); + } finally { + // The guest owns the duplicated promise returned by the host callback. + // This idempotently releases any host-side handle left after settlement. + try { deferred.dispose(); } finally { if (failed) cleanup(); } + } +} + +function pumpJobs(): void { + if (!runtime) return; + const jobs = runtime.executePendingJobs(); + if (jobs.error) { + const error = quickJsError(jobs.error); + jobs.error.dispose(); + throw error; + } +} + +function workflowProgram(message: RunMessage): string { + const args = JSON.stringify(message.args); + const concurrency = message.meta.concurrency; + return ` +"use strict"; +const args = Object.freeze(JSON.parse(${JSON.stringify(args)})); +const __error = error => ({ + code: typeof error?.code === "string" ? error.code : "WORKFLOW_STEP_FAILED", + message: typeof error?.message === "string" ? error.message : String(error), + retryable: error?.retryable === true, +}); +const { agent, workflow, log, phase, parallel, pipeline } = (() => { +const hostCall = globalThis.__hostCall; +const emit = globalThis.__emit; +delete globalThis.__hostCall; +delete globalThis.__emit; +const agent = (prompt, options = {}) => hostCall("agent", { prompt, options }); +const workflow = (name, workflowArgs = null) => hostCall("workflow", { name, args: workflowArgs }); +const log = (...values) => emit("log", values.length === 1 ? values[0] : values); +const phase = async (name, run) => { + if (typeof name !== "string" || !name || typeof run !== "function") throw new TypeError("phase(name, run) requires a name and function"); + emit("phase_started", { name }); + try { + const value = await run((prompt, options = {}) => agent(prompt, { ...options, phase: name })); + emit("phase_completed", { name }); + return value; + } catch (error) { + emit("phase_failed", { name, error: __error(error) }); + throw error; + } +}; +const parallel = async (items, worker, concurrency = ${concurrency}) => { + if (!Array.isArray(items) || typeof worker !== "function") throw new TypeError("parallel(items, worker) requires an array and function"); + if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 16) throw new RangeError("parallel concurrency must be between 1 and 16"); + const outcomes = new Array(items.length); + let next = 0; + const consume = async () => { + while (next < items.length) { + const index = next++; + try { + outcomes[index] = { status: "completed", value: await worker(items[index], index) }; + } catch (error) { + outcomes[index] = { status: "failed", error: __error(error) }; + } + } + }; + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, consume)); + return outcomes; +}; +const pipeline = (items, ...stages) => { + if (!Array.isArray(stages) || stages.some(stage => typeof stage !== "function")) throw new TypeError("pipeline stages must be functions"); + return parallel(items, async (item, index) => { + let value = item; + for (const stage of stages) value = await stage(value, item, index); + return value; + }, ${concurrency}); +}; +return Object.freeze({ agent, workflow, log, phase, parallel, pipeline }); +})(); +globalThis.__workflowResult = (async () => { +${message.source} +})(); +globalThis.__workflowResult; +`; +} + +function valueHandle(vm: QuickJSContext, value: unknown) { + if (!jsonObject || !jsonParse) throw new Error("Workflow JSON parser is unavailable."); + const encoded = encodeJson(value, "Host result"); + const encodedHandle = vm.newString(encoded); + const result = vm.callFunction(jsonParse, jsonObject, encodedHandle); + encodedHandle.dispose(); + if (result.error) { + const error = quickJsError(result.error); + result.error.dispose(); + throw error; + } + return result.value; +} + +function encodeGuestJson(vm: QuickJSContext, value: QuickJSHandle, label: string): string { + if (!jsonObject || !jsonStringify) throw new Error("Workflow JSON serializer is unavailable."); + const result = vm.callFunction(jsonStringify, jsonObject, value); + if (result.error) { + const error = quickJsError(result.error); + result.error.dispose(); + throw Object.assign(new TypeError(`${label} must be JSON: ${error.message}`), { + code: "WORKFLOW_INVALID_JSON", + }); + } + try { + if (vm.typeof(result.value) !== "string") { + throw Object.assign(new TypeError(`${label} must be JSON.`), { code: "WORKFLOW_INVALID_JSON" }); + } + return vm.getString(result.value); + } finally { + result.value.dispose(); + } +} + +function reject(deferred: QuickJSDeferredPromise, error: ErrorPayload): void { + if (!context) return; + const handle = context.newError(error.message); + const code = context.newString(error.code); + const retryable = error.retryable ? context.true : context.false; + context.setProp(handle, "code", code); + context.setProp(handle, "retryable", retryable); + code.dispose(); + deferred.reject(handle); + handle.dispose(); +} + +function quickJsError(handle: Parameters[0]): Error & { code?: string; retryable?: boolean } { + if (!context) return new Error("Workflow execution failed."); + const value = context.dump(handle) as { name?: unknown; message?: unknown; stack?: unknown } | unknown; + if (isRecord(value)) { + const error: Error & { code?: string; retryable?: boolean } = new Error( + typeof value.message === "string" ? value.message : "Workflow execution failed.", + ); + if (typeof value.name === "string") error.name = value.name; + if (typeof value.stack === "string") error.stack = value.stack; + if (typeof value.code === "string") error.code = value.code; + if (value.retryable === true) error.retryable = true; + return error; + } + return new Error(typeof value === "string" ? value : JSON.stringify(value)); +} + +function classifyExecutionError(error: unknown): ErrorPayload { + if (fatalError) return fatalError; + const payload = toErrorPayload(error); + const text = payload.message.toLowerCase(); + if (text.includes("out of memory") || text.includes("cannot allocate memory")) { + return { code: "WORKFLOW_MEMORY_LIMIT", message: "Workflow exceeded its memory limit." }; + } + if (text.includes("stack overflow") || text.includes("stack size")) { + return { code: "WORKFLOW_STACK_LIMIT", message: "Workflow exceeded its stack limit." }; + } + if (text.includes("interrupted")) return { code: "WORKFLOW_TIMEOUT", message: "Workflow execution was interrupted." }; + return payload; +} + +function toErrorPayload(error: unknown): ErrorPayload { + const value = error as { code?: unknown; message?: unknown; retryable?: unknown } | undefined; + return { + code: typeof value?.code === "string" ? value.code : "WORKFLOW_EXECUTION_ERROR", + message: typeof value?.message === "string" ? value.message : String(error), + ...(value?.retryable === true ? { retryable: true } : {}), + }; +} + +function encodeJson(value: unknown, label: string): string { + let encoded: string | undefined; + try { + encoded = JSON.stringify(value, (_key, current: unknown) => { + if (typeof current === "number" && !Number.isFinite(current)) throw new TypeError("non-finite number"); + if (typeof current === "bigint" || typeof current === "function" || typeof current === "symbol" || current === undefined) { + throw new TypeError(`unsupported ${typeof current}`); + } + return current; + }); + } catch (error) { + throw new TypeError(`${label} must be JSON: ${error instanceof Error ? error.message : String(error)}`); + } + if (encoded === undefined) throw new TypeError(`${label} must be JSON.`); + return encoded; +} + +function fail(error: ErrorPayload, fatal = false): void { + send({ type: fatal || isFatal(error.code) ? "fatal" : "error", error }); +} + +function isFatal(code: string): boolean { + return code.includes("LIMIT") + || code.includes("INTERNAL") + || code.includes("PROTOCOL") + || code === "WORKFLOW_TIMEOUT" + || code === "WORKFLOW_CANCELLED"; +} + +function cleanup(): void { + for (const deferred of pending.values()) deferred.dispose(); + pending.clear(); + jsonParse?.dispose(); + jsonStringify?.dispose(); + jsonObject?.dispose(); + jsonParse = undefined; + jsonStringify = undefined; + jsonObject = undefined; + try { context?.dispose(); } catch { /* The process is disposable. */ } + try { runtime?.dispose(); } catch { /* The process is disposable. */ } + context = undefined; + runtime = undefined; + shuttingDown = true; + if (process.connected) process.disconnect?.(); +} + +function send(message: unknown): void { + if (!process.connected) return; + try { + process.send?.(message as never, () => undefined); + } catch { + if (!shuttingDown) process.exit(1); + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/workflow-runner.test.ts b/src/workflow-runner.test.ts new file mode 100644 index 000000000..89c4c83f8 --- /dev/null +++ b/src/workflow-runner.test.ts @@ -0,0 +1,275 @@ +import assert from "node:assert/strict"; +import { setTimeout as delay } from "node:timers/promises"; +import { runWorkflowScript } from "./workflow-runner.js"; +import { WorkflowError } from "./workflow-types.js"; + +const basic = await runWorkflowScript({ + source: `return { args, host: [typeof process, typeof require, typeof fetch, typeof globalThis.__hostCall, typeof globalThis.__emit] };`, + args: { value: 42 }, + onAgent: () => null, +}); +assert.deepEqual(basic, { + args: { value: 42 }, + host: ["undefined", "undefined", "undefined", "undefined", "undefined"], +}); +assert.deepEqual(await runWorkflowScript({ + source: `return [ + Function("return typeof process")(), + Function("return typeof globalThis.__hostCall")(), + agent.constructor("return typeof globalThis.__emit")(), + ];`, + onAgent: () => null, +}), ["undefined", "undefined", "undefined"], "dynamic code cannot recover host capabilities"); +assert.equal(await runWorkflowScript({ + source: `try { await import("node:fs"); return false; } catch { return true; }`, + onAgent: () => null, +}), true, "the guest has no module loader for host imports"); + +const prototypeArgs = JSON.parse('{"__proto__":{"polluted":true}}') as unknown; +assert.deepEqual(await runWorkflowScript({ + source: `return { own: Object.hasOwn(args, "__proto__"), polluted: ({}).polluted ?? null };`, + args: prototypeArgs, + onAgent: () => null, +}), { own: true, polluted: null }); + +const prototypeResult = JSON.parse('{"__proto__":{"polluted":true}}') as unknown; +assert.deepEqual(await runWorkflowScript({ + source: ` + const value = await agent("proto", { target: "worker" }); + return { own: Object.hasOwn(value, "__proto__"), polluted: ({}).polluted ?? null }; + `, + onAgent: () => prototypeResult, +}), { own: true, polluted: null }); + +const calls: Array<{ prompt: string; target: string }> = []; +const bridged = await runWorkflowScript({ + source: ` + export const meta = { name: 'bridge', concurrency: 2 }; + return agent("inspect", { target: "reviewer", writeMode: "read_only", workspace: "shared" }); + `, + onAgent: async (prompt, options) => { + calls.push({ prompt, target: options.target }); + return { ok: true }; + }, +}); +assert.deepEqual(bridged, { ok: true }); +assert.deepEqual(calls, [{ prompt: "inspect", target: "reviewer" }]); + +const parallel = await runWorkflowScript({ + source: ` + return parallel([30, 5, 10], async (wait, index) => { + const result = await agent(String(wait), { target: "worker", label: String(index) }); + if (index === 1) throw new Error("expected failure"); + return result; + }, 3); + `, + onAgent: async (prompt) => { + await delay(Number(prompt)); + return Number(prompt) * 2; + }, +}); +assert.deepEqual(parallel, [ + { status: "completed", value: 60 }, + { status: "failed", error: { code: "WORKFLOW_STEP_FAILED", message: "expected failure", retryable: false } }, + { status: "completed", value: 20 }, +]); + +const pipeline = await runWorkflowScript({ + source: ` + return pipeline([2, 3], + async (value, item, index) => await agent(String(value * 2 + index), { target: "worker" }), + async (value, item, index) => ({ value, item, index }), + ); + `, + onAgent: (prompt) => Number(prompt), +}); +assert.deepEqual(pipeline, [ + { status: "completed", value: { value: 4, item: 2, index: 0 } }, + { status: "completed", value: { value: 7, item: 3, index: 1 } }, +]); + +const events: Array<{ type: string; data: unknown }> = []; +const phased = await runWorkflowScript({ + source: ` + const value = await phase("review", boundAgent => boundAgent("go", { target: "worker" })); + log("done", value); + return value; + `, + onAgent: (_prompt, options) => options.phase, + onEvent: (type, data) => { events.push({ type, data }); }, +}); +assert.equal(phased, "review"); +assert.deepEqual(events.map(({ type }) => type), ["phase_started", "phase_completed", "log"]); +assert.deepEqual(events[2]?.data, ["done", "review"]); + +const nested = await runWorkflowScript({ + source: `return workflow("child", { n: 1 });`, + onAgent: () => null, + onWorkflow: (name, args) => ({ name, args }), +}); +assert.deepEqual(nested, { name: "child", args: { n: 1 } }); +await assert.rejects( + runWorkflowScript({ + source: `return workflow("grandchild", null);`, + depth: 1, + onAgent: () => null, + onWorkflow: () => null, + }), + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_NESTING_LIMIT", +); +await assert.rejects( + runWorkflowScript({ + source: `try { await workflow("grandchild", null); } catch {} return "swallowed";`, + depth: 1, + onAgent: () => null, + onWorkflow: () => null, + }), + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_NESTING_LIMIT", +); + +const caughtValidation = await runWorkflowScript({ + source: `try { await agent("go", {}); } catch (error) { return error.code; }`, + onAgent: () => null, +}); +assert.equal(caughtValidation, "WORKFLOW_INVALID_AGENT_CALL"); + +await assert.rejects( + runWorkflowScript({ + source: `return agent("fail", { target: "worker" });`, + onAgent: () => { throw Object.assign(new Error("provider unavailable"), { code: "PROVIDER_DOWN", retryable: true }); }, + }), + (error: unknown) => error instanceof WorkflowError + && error.code === "PROVIDER_DOWN" + && error.retryable === true, +); + +let finishDetached!: () => void; +const detachedCall = new Promise((resolve) => { finishDetached = resolve; }); +const detached = runWorkflowScript({ + source: `agent("detached", { target: "worker" }); return "done";`, + onAgent: async () => { await detachedCall; return null; }, +}); +await assert.rejects( + detached, + (error: unknown) => error instanceof WorkflowError && error.code === "UNAWAITED_CALLS", +); +finishDetached(); + +const largeAgentResult = "x".repeat(240 * 1024); +assert.equal(await runWorkflowScript({ + source: ` + for (let index = 0; index < 128; index += 1) { + const value = await agent(String(index), { target: "worker" }); + if (value.length !== ${240 * 1024}) throw new Error("truncated agent result"); + } + return 128; + `, + onAgent: () => largeAgentResult, +}), 128, "settled host-call handles do not accumulate against the QuickJS memory limit"); + +await assert.rejects( + runWorkflowScript({ + source: `try { for (;;) log("0123456789"); } catch {} return "swallowed";`, + limits: { maxLogBytes: 32 }, + onAgent: () => null, + }), + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_LOG_LIMIT", +); + +await assert.rejects( + runWorkflowScript({ + source: `while (true) {}`, + limits: { timeoutMs: 2_000 }, + onAgent: () => null, + }), + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_TIMEOUT", +); + +await assert.rejects( + runWorkflowScript({ + source: `const recurse = () => recurse(); return recurse();`, + limits: { stackBytes: 64 * 1024 }, + onAgent: () => null, + }), + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_STACK_LIMIT", +); + +await assert.rejects( + runWorkflowScript({ + source: `const values = []; for (;;) values.push("x".repeat(1024));`, + limits: { memoryBytes: 4 * 1024 * 1024, timeoutMs: 10_000 }, + onAgent: () => null, + }), + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_MEMORY_LIMIT", +); + +const controller = new AbortController(); +let agentStarted!: () => void; +const started = new Promise((resolve) => { agentStarted = resolve; }); +const aborted = runWorkflowScript({ + source: `return agent("wait", { target: "worker" });`, + signal: controller.signal, + onAgent: async () => { + agentStarted(); + await new Promise(() => undefined); + }, +}); +await started; +controller.abort(); +await assert.rejects( + aborted, + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_CANCELLED", +); + +await assert.rejects( + runWorkflowScript({ + source: `return "x".repeat(100);`, + limits: { maxResultBytes: 32 }, + onAgent: () => null, + }), + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_RESULT_LIMIT", +); + +await assert.rejects( + runWorkflowScript({ + source: `return () => "not JSON";`, + onAgent: () => null, + }), + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_INVALID_JSON", +); + +await assert.rejects( + runWorkflowScript({ + source: `const value = {}; value.self = value; return value;`, + onAgent: () => null, + }), + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_INVALID_JSON", +); + +await assert.rejects( + runWorkflowScript({ + source: "return 1;".repeat(20), + limits: { maxSourceBytes: 32 }, + onAgent: () => null, + }), + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_SIZE_LIMIT", +); + +await assert.rejects( + runWorkflowScript({ + source: `return args;`, + args: "x".repeat(100), + limits: { maxArgsBytes: 32 }, + onAgent: () => null, + }), + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_SIZE_LIMIT", +); + +await assert.rejects( + runWorkflowScript({ + source: `return Promise.all(Array.from({ length: 3 }, () => agent("wait", { target: "worker" })));`, + limits: { maxOutstanding: 2 }, + onAgent: async () => await new Promise(() => undefined), + }), + (error: unknown) => error instanceof WorkflowError && error.code === "WORKFLOW_OUTSTANDING_LIMIT", +); diff --git a/src/workflow-runner.ts b/src/workflow-runner.ts new file mode 100644 index 000000000..fede8d018 --- /dev/null +++ b/src/workflow-runner.ts @@ -0,0 +1,364 @@ +import { fork, type ChildProcess } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { WorkflowError, type WorkflowAgentOptions } from "./workflow-types.js"; +import { parseWorkflowScript } from "./workflow-script.js"; + +export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + +export interface WorkflowRunnerLimits { + timeoutMs: number; + memoryBytes: number; + stackBytes: number; + maxSourceBytes: number; + maxArgsBytes: number; + maxResultBytes: number; + maxLogBytes: number; + maxLogEntries: number; + maxOutstanding: number; +} + +export interface RunWorkflowScriptInput { + source: string; + args?: unknown; + limits?: Partial; + signal?: AbortSignal; + /** Internal nesting marker. A nested runner must pass depth: 1. */ + depth?: number; + onAgent(prompt: string, options: WorkflowAgentOptions): Promise | unknown; + onWorkflow?(name: string, args: unknown): Promise | unknown; + onEvent?(type: string, data: unknown): Promise | void; +} + +export const DEFAULT_WORKFLOW_LIMITS: Readonly = Object.freeze({ + timeoutMs: 15 * 60_000, + memoryBytes: 32 * 1024 * 1024, + stackBytes: 512 * 1024, + maxSourceBytes: 64 * 1024, + maxArgsBytes: 128 * 1024, + maxResultBytes: 256 * 1024, + maxLogBytes: 64 * 1024, + maxLogEntries: 1_024, + maxOutstanding: 256, +}); + +const HARD_LIMITS: WorkflowRunnerLimits = { + timeoutMs: 24 * 60 * 60_000, + memoryBytes: 512 * 1024 * 1024, + stackBytes: 16 * 1024 * 1024, + maxSourceBytes: 4 * 1024 * 1024, + maxArgsBytes: 16 * 1024 * 1024, + maxResultBytes: 64 * 1024 * 1024, + maxLogBytes: 4 * 1024 * 1024, + maxLogEntries: 100_000, + maxOutstanding: 4_096, +}; + +interface RunnerErrorPayload { code: string; message: string; retryable?: boolean } +interface HostCallMessage { + type: "host-call"; + id: number; + kind: "agent" | "workflow"; + prompt?: unknown; + options?: unknown; + name?: unknown; + args?: unknown; +} + +export async function runWorkflowScript(input: RunWorkflowScriptInput): Promise { + const limits = resolveLimits(input.limits); + if (typeof input.source !== "string") { + throw new WorkflowError("WORKFLOW_INVALID_SOURCE", "Workflow source must be a string."); + } + validateBytes(input.source, "Workflow source", limits.maxSourceBytes); + const parsed = parseWorkflowScript(input.source); + const args = validateJson(input.args ?? null, "Workflow arguments", limits.maxArgsBytes); + if (input.depth !== undefined && (!Number.isInteger(input.depth) || input.depth < 0 || input.depth > 1)) { + throw new WorkflowError("WORKFLOW_NESTING_LIMIT", "Workflows may be nested at most one level."); + } + if (input.signal?.aborted) throw cancelled(input.signal.reason); + + const extension = import.meta.url.endsWith(".ts") ? "ts" : "js"; + const childPath = fileURLToPath(new URL(`./workflow-runner-child.${extension}`, import.meta.url)); + const child = fork(childPath, [], { + stdio: ["ignore", "ignore", "pipe", "ipc"], + serialization: "json", + env: {}, + }); + + let settled = false; + let outstanding = 0; + let eventBytes = 0; + let eventEntries = 0; + let stderr = ""; + let eventChain = Promise.resolve(); + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => { stderr = (stderr + chunk).slice(-8_192); }); + + return await new Promise((resolve, reject) => { + const finish = (error?: unknown, value?: JsonValue) => { + if (settled) return; + settled = true; + clearTimeout(timer); + input.signal?.removeEventListener("abort", abort); + child.removeAllListeners(); + if (child.connected) { + try { child.disconnect(); } catch { /* The child is already closing. */ } + } + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + if (error) reject(error); + else resolve(value as JsonValue); + }; + const fatal = (error: unknown) => finish(asWorkflowError(error)); + const abort = () => fatal(cancelled(input.signal?.reason)); + const timer = setTimeout(() => { + fatal(new WorkflowError("WORKFLOW_TIMEOUT", `Workflow exceeded ${limits.timeoutMs}ms.`)); + }, limits.timeoutMs); + timer.unref(); + input.signal?.addEventListener("abort", abort, { once: true }); + + child.once("error", (error) => fatal(new WorkflowError("WORKFLOW_RUNNER_FAILED", error.message))); + child.once("exit", (code, signal) => { + if (settled) return; + const detail = stderr.trim() ? `: ${stderr.trim()}` : ""; + fatal(new WorkflowError( + "WORKFLOW_RUNNER_EXITED", + `Workflow runner exited before returning a result (${signal ?? code ?? "unknown"})${detail}`, + )); + }); + child.on("message", (message: unknown) => { + if (settled || !isRecord(message) || typeof message.type !== "string") return; + if (message.type === "host-call") { + void handleHostCall(message as unknown as HostCallMessage).catch(fatal); + return; + } + if (message.type === "event") { + if (message.eventType !== "log" + && message.eventType !== "phase_started" + && message.eventType !== "phase_completed" + && message.eventType !== "phase_failed") { + fatal(new WorkflowError("WORKFLOW_PROTOCOL_ERROR", "Workflow runner sent an invalid event.")); + return; + } + try { + const data = validateJson(message.data, "Workflow event", limits.maxArgsBytes); + eventEntries += 1; + eventBytes += Buffer.byteLength(JSON.stringify(data), "utf8") + message.eventType.length; + if (eventEntries > limits.maxLogEntries || eventBytes > limits.maxLogBytes) { + throw new WorkflowError("WORKFLOW_LOG_LIMIT", "Workflow events exceed their configured limit."); + } + eventChain = eventChain.then(() => input.onEvent?.(message.eventType as string, data)); + eventChain.catch(fatal); + } catch (error) { + fatal(error); + } + return; + } + if (message.type === "fatal" || message.type === "error") { + fatal(decodeError(message.error, message.type === "fatal" ? "WORKFLOW_LIMIT" : "WORKFLOW_FAILED")); + return; + } + if (message.type === "result") { + let result: JsonValue; + try { + result = validateJson(message.value, "Workflow result", limits.maxResultBytes); + } catch (error) { + fatal(error); + return; + } + void eventChain.then(() => finish(undefined, result), fatal); + } + }); + + const handleHostCall = async (message: HostCallMessage): Promise => { + if (!Number.isSafeInteger(message.id) || message.id < 1 || outstanding >= limits.maxOutstanding) { + throw new WorkflowError("WORKFLOW_OUTSTANDING_LIMIT", "Workflow has too many outstanding host calls."); + } + outstanding += 1; + try { + let value: JsonValue; + if (message.kind === "agent") { + if (typeof message.prompt !== "string" || message.prompt.length === 0) { + return sendHostError(child, message.id, new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", "agent prompt must be a non-empty string.")); + } + validateBytes(message.prompt, "Agent prompt", limits.maxArgsBytes); + const options = validateAgentOptions(message.options, limits.maxArgsBytes); + value = validateJson( + await input.onAgent(message.prompt, options), + "Agent result", + limits.maxResultBytes, + ); + } else if (message.kind === "workflow") { + if ((input.depth ?? 0) >= 1) { + return sendHostError(child, message.id, new WorkflowError("WORKFLOW_NESTING_LIMIT", "Workflows may be nested at most one level.")); + } + if (!input.onWorkflow) { + return sendHostError(child, message.id, new WorkflowError("WORKFLOW_UNAVAILABLE", "Nested workflows are unavailable.")); + } + if (typeof message.name !== "string" || message.name.trim().length === 0 || message.name.length > 128) { + return sendHostError(child, message.id, new WorkflowError("WORKFLOW_INVALID_CALL", "workflow name must be a non-empty string.")); + } + const nestedArgs = validateJson(message.args ?? null, "Nested workflow arguments", limits.maxArgsBytes); + value = validateJson( + await input.onWorkflow(message.name, nestedArgs), + "Nested workflow result", + limits.maxResultBytes, + ); + } else { + throw new WorkflowError("WORKFLOW_PROTOCOL_ERROR", "Workflow runner requested an unknown host call."); + } + send(child, { type: "host-result", id: message.id, ok: true, value }); + } catch (error) { + sendHostError(child, message.id, error); + } finally { + outstanding -= 1; + } + }; + + send(child, { + type: "run", + source: parsed.body, + args, + meta: parsed.meta, + limits, + }); + }); +} + +export function validateJson(value: unknown, label: string, maxBytes: number): JsonValue { + let encoded: string | undefined; + try { + encoded = JSON.stringify(value, (_key, current: unknown) => { + if (typeof current === "number" && !Number.isFinite(current)) throw new TypeError("non-finite number"); + if (typeof current === "bigint" || typeof current === "function" || typeof current === "symbol" || current === undefined) { + throw new TypeError(`unsupported ${typeof current}`); + } + if (current && typeof current === "object" && !Array.isArray(current)) { + const prototype = Object.getPrototypeOf(current); + if (prototype !== Object.prototype && prototype !== null) throw new TypeError("non-plain object"); + } + return current; + }); + } catch (error) { + throw new WorkflowError("WORKFLOW_INVALID_JSON", `${label} must be JSON: ${errorMessage(error)}`); + } + if (encoded === undefined) throw new WorkflowError("WORKFLOW_INVALID_JSON", `${label} must be JSON.`); + validateBytes(encoded, label, maxBytes); + return JSON.parse(encoded) as JsonValue; +} + +function validateAgentOptions(value: unknown, maxBytes: number): WorkflowAgentOptions { + const options = validateJson(value, "Agent options", maxBytes); + if (!isRecord(options)) throw new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", "agent options must be an object."); + const allowed = new Set(["target", "model", "effort", "schema", "label", "phase", "writeMode", "isolation", "workspace"]); + for (const key of Object.keys(options)) { + if (!allowed.has(key)) throw new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", `Unknown agent option: ${key}`); + } + if (!validString(options.target, 128)) { + throw new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", "agent options.target must be a non-empty string."); + } + for (const key of ["model", "effort", "label", "phase"] as const) { + if (options[key] !== undefined && !validString(options[key], 256)) { + throw new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", `agent options.${key} must be a non-empty string.`); + } + } + if (options.schema !== undefined && !isRecord(options.schema)) { + throw new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", "agent options.schema must be an object."); + } + if (options.writeMode !== undefined && options.writeMode !== "read_only" && options.writeMode !== "allowed") { + throw new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", "agent options.writeMode must be read_only or allowed."); + } + if (options.isolation !== undefined && options.isolation !== "worktree") { + throw new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", "agent options.isolation must be worktree."); + } + if (options.workspace !== undefined + && (typeof options.workspace !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(options.workspace))) { + throw new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", "agent options.workspace must be a logical key, not a path."); + } + return options as unknown as WorkflowAgentOptions; +} + +function resolveLimits(input: Partial | undefined): WorkflowRunnerLimits { + const limits = { ...DEFAULT_WORKFLOW_LIMITS, ...input }; + for (const key of Object.keys(DEFAULT_WORKFLOW_LIMITS) as Array) { + const value = limits[key]; + if (!Number.isSafeInteger(value) || value < 1 || value > HARD_LIMITS[key]) { + throw new WorkflowError("WORKFLOW_INVALID_LIMIT", `${key} must be an integer between 1 and ${HARD_LIMITS[key]}.`); + } + } + return limits; +} + +function validateBytes(value: string, label: string, limit: number): void { + if (Buffer.byteLength(value, "utf8") > limit) { + throw new WorkflowError("WORKFLOW_SIZE_LIMIT", `${label} exceeds the ${limit}-byte limit.`); + } +} + +function sendHostError(child: ChildProcess, id: number, error: unknown): void { + const workflowError = asWorkflowError(error); + send(child, { + type: "host-result", + id, + ok: false, + fatal: isFatalCode(workflowError.code), + error: { code: workflowError.code, message: workflowError.message, retryable: workflowError.retryable }, + }); +} + +function send(child: ChildProcess, message: unknown): void { + if (!child.connected) return; + try { + child.send(message as never, () => undefined); + } catch { + // The runner's exit/disconnect path reports the failure. + } +} + +function decodeError(value: unknown, fallbackCode: string): WorkflowError { + if (!isRecord(value)) return new WorkflowError(fallbackCode, "Workflow failed."); + return new WorkflowError( + typeof value.code === "string" ? value.code : fallbackCode, + typeof value.message === "string" ? value.message : "Workflow failed.", + value.retryable === true, + ); +} + +function asWorkflowError(error: unknown): WorkflowError { + if (error instanceof WorkflowError) return error; + const value = error as { code?: unknown; retryable?: unknown } | undefined; + return new WorkflowError( + typeof value?.code === "string" ? value.code : "WORKFLOW_FAILED", + errorMessage(error), + value?.retryable === true, + ); +} + +function cancelled(reason: unknown): WorkflowError { + if (reason instanceof WorkflowError) return reason; + const value = reason as { code?: unknown; message?: unknown; retryable?: unknown } | undefined; + return new WorkflowError( + typeof value?.code === "string" ? value.code : "WORKFLOW_CANCELLED", + typeof value?.message === "string" ? value.message : "Workflow was cancelled.", + value?.retryable === true, + ); +} + +function validString(value: unknown, maximum: number): value is string { + return typeof value === "string" && value.trim().length > 0 && value.length <= maximum; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isFatalCode(code: string): boolean { + return code.includes("LIMIT") + || code.includes("INTERNAL") + || code.includes("PROTOCOL") + || code === "WORKFLOW_TIMEOUT" + || code === "WORKFLOW_CANCELLED"; +} diff --git a/src/workflow-schema.test.ts b/src/workflow-schema.test.ts new file mode 100644 index 000000000..d45573878 --- /dev/null +++ b/src/workflow-schema.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import { compileWorkflowSchema, parseWorkflowOutput } from "./workflow-schema.js"; + +const validate = compileWorkflowSchema({ + type: "object", properties: { summary: { type: "string" }, count: { type: "integer", minimum: 0 } }, + required: ["summary", "count"], additionalProperties: false, +}); +assert.deepEqual(parseWorkflowOutput('{"summary":"ok","count":2}', validate), { summary: "ok", count: 2 }); +assert.throws(() => parseWorkflowOutput('```json\n{}\n```', validate), /not JSON/); +assert.throws(() => parseWorkflowOutput('{"summary":"ok","count":-1}', validate), /JSON output/); +assert.throws(() => compileWorkflowSchema({ type: "not-a-type" }), /Invalid JSON schema/); +assert.throws(() => compileWorkflowSchema({ type: "string", pattern: "(a+)+$" }), /do not support pattern/); +assert.throws(() => compileWorkflowSchema({ $ref: "https://example.com/schema" }), /do not support \$ref/); +assert.throws(() => compileWorkflowSchema({ $async: true, type: "object" }), /do not support \$async/); +assert.doesNotThrow(() => compileWorkflowSchema({ type: "object", properties: { format: { type: "string" } } })); +console.log("Workflow structural schema validation checks passed."); diff --git a/src/workflow-schema.ts b/src/workflow-schema.ts new file mode 100644 index 000000000..7cde691ac --- /dev/null +++ b/src/workflow-schema.ts @@ -0,0 +1,40 @@ +import { Ajv, type ValidateFunction } from "ajv"; +import { WorkflowError } from "./workflow-types.js"; + +/** Schemas are model supplied: bound compilation and disallow executable/recursive regex paths. */ +export function compileWorkflowSchema(schema: Record): ValidateFunction { + if (Buffer.byteLength(JSON.stringify(schema)) > 16_384) throw new WorkflowError("INVALID_SCHEMA", "Schema exceeds 16 KiB."); + let nodes = 0; + function visit(value: unknown, depth: number, position: "schema" | "schemas" | "data"): void { + if (++nodes > 512 || depth > 16) throw new WorkflowError("INVALID_SCHEMA", "Schema is too complex."); + if (!value || typeof value !== "object") return; + for (const [key, child] of Object.entries(value)) { + if (position === "schema" && ["$ref", "$dynamicRef", "$recursiveRef", "$async", "pattern", "patternProperties", "format"].includes(key)) { + throw new WorkflowError("INVALID_SCHEMA", `Workflow schemas do not support ${key}. Use inline structural constraints.`); + } + const childPosition = position === "schemas" ? "schema" + : position !== "schema" ? "data" + : ["properties", "definitions", "$defs", "dependencies", "allOf", "anyOf", "oneOf"].includes(key) ? "schemas" + : ["additionalProperties", "additionalItems", "items", "contains", "not", "if", "then", "else", "propertyNames"].includes(key) ? (Array.isArray(child) ? "schemas" : "schema") + : "data"; + visit(child, depth + 1, childPosition); + } + } + visit(schema, 0, "schema"); + try { + return new Ajv({ strict: true, allErrors: false, logger: false }).compile(schema); + } catch (error) { + throw new WorkflowError("INVALID_SCHEMA", `Invalid JSON schema: ${error instanceof Error ? error.message : String(error)}`); + } +} + +export function parseWorkflowOutput(text: string, validate: ValidateFunction): unknown { + let value: unknown; + try { value = JSON.parse(text); } + catch { throw new WorkflowError("INVALID_OUTPUT", "Agent output was not JSON."); } + if (!validate(value)) { + const error = validate.errors?.[0]; + throw new WorkflowError("INVALID_OUTPUT", `JSON output ${error?.instancePath ?? ""} ${error?.message ?? "does not match the schema"}.`); + } + return value; +} diff --git a/src/workflow-script.test.ts b/src/workflow-script.test.ts new file mode 100644 index 000000000..6aa197ba9 --- /dev/null +++ b/src/workflow-script.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { parseWorkflowScript } from "./workflow-script.js"; + +assert.deepEqual(parseWorkflowScript("return args;"), { + meta: { name: "workflow", concurrency: 4 }, + body: "return args;", +}); + +const parsed = parseWorkflowScript(` + export const meta = { + name: 'review-files', + description: "Review\\nfiles", + concurrency: 8, + }; + return args; +`); +assert.deepEqual(parsed.meta, { + name: "review-files", + description: "Review\nfiles", + concurrency: 8, +}); +assert.match(parsed.body, /return args/); + +assert.deepEqual( + parseWorkflowScript(`export const meta = { "name": "quoted" }; return 1;`).meta, + { name: "quoted", concurrency: 4 }, +); +const commented = parseWorkflowScript(`// @ts-check +/* workflow metadata */ +export const meta = { name: "commented" }; return 1;`); +assert.equal(commented.meta.name, "commented"); +assert.equal(commented.body.trim(), "return 1;"); +assert.throws( + () => parseWorkflowScript("export const meta = { name: getName() }; return 1;"), + /must be strings or numbers/, +); +assert.throws( + () => parseWorkflowScript("export const meta = { unknown: 'value' }; return 1;"), + /Unknown workflow metadata field/, +); +assert.throws( + () => parseWorkflowScript("export const meta = { concurrency: 17 }; return 1;"), + /between 1 and 16/, +); +assert.throws( + () => parseWorkflowScript("export const meta = { name: 'a', name: 'b' }; return 1;"), + /Duplicate workflow metadata field/, +); diff --git a/src/workflow-script.ts b/src/workflow-script.ts new file mode 100644 index 000000000..e80b9066d --- /dev/null +++ b/src/workflow-script.ts @@ -0,0 +1,139 @@ +export interface WorkflowScriptMeta { + name: string; + description?: string; + concurrency: number; +} + +export interface ParsedWorkflowScript { + meta: WorkflowScriptMeta; + body: string; +} + +const DEFAULT_META: WorkflowScriptMeta = { name: "workflow", concurrency: 4 }; +const META_PREFIX = /^(?:(?:\s+)|(?:\/\/[^\r\n]*(?:\r\n?|\n|$))|(?:\/\*[\s\S]*?\*\/))*export\s+const\s+meta\s*=/; + +/** Parse the optional, data-only metadata declaration without executing workflow code. */ +export function parseWorkflowScript(source: string): ParsedWorkflowScript { + const prefix = META_PREFIX.exec(source); + if (!prefix) return { meta: { ...DEFAULT_META }, body: source }; + + const parser = new MetaParser(source, prefix[0].length); + const fields = parser.object(); + parser.space(); + if (source[parser.position] === ";") parser.position += 1; + + const name = fields.name ?? DEFAULT_META.name; + const description = fields.description; + const concurrency = fields.concurrency ?? DEFAULT_META.concurrency; + if (typeof name !== "string" || name.trim().length === 0 || name.length > 128) { + throw new Error("Workflow meta.name must be a non-empty string of at most 128 characters."); + } + if (description !== undefined && (typeof description !== "string" || description.length > 2_048)) { + throw new Error("Workflow meta.description must be a string of at most 2048 characters."); + } + if (!Number.isInteger(concurrency) || (concurrency as number) < 1 || (concurrency as number) > 16) { + throw new Error("Workflow meta.concurrency must be an integer between 1 and 16."); + } + + return { + meta: { + name: name.trim(), + ...(description === undefined ? {} : { description }), + concurrency: concurrency as number, + }, + body: source.slice(parser.position), + }; +} + +class MetaParser { + position: number; + + constructor(private readonly source: string, position: number) { + this.position = position; + } + + object(): Record { + this.space(); + this.expect("{"); + const result: Record = {}; + this.space(); + while (this.source[this.position] !== "}") { + const key = this.key(); + if (key !== "name" && key !== "description" && key !== "concurrency") { + throw this.error(`Unknown workflow metadata field: ${key}`); + } + if (Object.hasOwn(result, key)) throw this.error(`Duplicate workflow metadata field: ${key}`); + this.space(); + this.expect(":"); + this.space(); + result[key] = this.source[this.position] === "'" || this.source[this.position] === '"' + ? this.string() + : this.number(); + this.space(); + if (this.source[this.position] !== ",") break; + this.position += 1; + this.space(); + if (this.source[this.position] === "}") break; + } + this.expect("}"); + return result; + } + + space(): void { + while (/\s/.test(this.source[this.position] ?? "")) this.position += 1; + } + + private key(): string { + this.space(); + if (this.source[this.position] === "'" || this.source[this.position] === '"') return this.string(); + const match = /^[A-Za-z_$][\w$]*/.exec(this.source.slice(this.position)); + if (!match) throw this.error("Expected a workflow metadata field."); + this.position += match[0].length; + return match[0]; + } + + private string(): string { + const quote = this.source[this.position++]; + let value = ""; + while (this.position < this.source.length) { + const character = this.source[this.position++]; + if (character === quote) return value; + if (character === "\n" || character === "\r") throw this.error("Workflow metadata strings cannot span lines."); + if (character !== "\\") { + value += character; + continue; + } + const escaped = this.source[this.position++]; + const simple: Record = { + "\\": "\\", "'": "'", '"': '"', n: "\n", r: "\r", t: "\t", b: "\b", f: "\f", v: "\v", 0: "\0", + }; + if (escaped === "u") { + const hex = this.source.slice(this.position, this.position + 4); + if (!/^[0-9A-Fa-f]{4}$/.test(hex)) throw this.error("Invalid Unicode escape in workflow metadata."); + value += String.fromCharCode(Number.parseInt(hex, 16)); + this.position += 4; + } else if (escaped && Object.hasOwn(simple, escaped)) { + value += simple[escaped]; + } else { + throw this.error("Unsupported escape in workflow metadata."); + } + } + throw this.error("Unterminated workflow metadata string."); + } + + private number(): number { + const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?/.exec(this.source.slice(this.position)); + if (!match) throw this.error("Workflow metadata values must be strings or numbers."); + this.position += match[0].length; + return Number(match[0]); + } + + private expect(character: string): void { + if (this.source[this.position] !== character) throw this.error(`Expected ${character} in workflow metadata.`); + this.position += 1; + } + + private error(message: string): SyntaxError { + return new SyntaxError(`${message} (at character ${this.position})`); + } +} diff --git a/src/workflow-store.ts b/src/workflow-store.ts new file mode 100644 index 000000000..8dc0bc40a --- /dev/null +++ b/src/workflow-store.ts @@ -0,0 +1,70 @@ +import { openDatabase, type DatabaseHandle } from "./db/client.js"; +import type { LocalAgentWorkspaceScope } from "./local-agent-store.js"; +import { WorkflowError, type WorkflowCall, type WorkflowEvent, type WorkflowSnapshot } from "./workflow-types.js"; + +/** Durable ownership is written before dispatch; a provider session is never an ownership key. */ +export class WorkflowStore { + private readonly database: DatabaseHandle; + constructor(stateDir: string) { this.database = openDatabase(stateDir); } + close(): void { this.database.close(); } + + create(run: WorkflowSnapshot): void { + this.database.sqlite.prepare("insert into workflow_runs (id, workspace_root, workspace_id, record_json) values (?, ?, ?, ?)") + .run(run.id, run.workspaceRoot, run.workspaceId ?? null, JSON.stringify(run)); + } + get(id: string): WorkflowSnapshot | undefined { + return decode(this.database.sqlite.prepare("select record_json from workflow_runs where id = ?").get(id)); + } + list(scope?: LocalAgentWorkspaceScope): WorkflowSnapshot[] { + const rows = scope + ? this.database.sqlite.prepare("select record_json from workflow_runs where workspace_root = ? and workspace_id is ? order by rowid desc") + .all(scope.workspaceRoot, scope.workspaceId ?? null) + : this.database.sqlite.prepare("select record_json from workflow_runs order by rowid desc").all(); + return rows.map((row) => decode(row)!); + } + update(id: string, patch: Partial>): WorkflowSnapshot { + const run = this.get(id); + if (!run) throw new WorkflowError("WORKFLOW_NOT_FOUND", `Unknown workflow run: ${id}`); + Object.assign(run, patch, { updatedAt: new Date().toISOString() }); + this.database.sqlite.prepare("update workflow_runs set record_json = ? where id = ?").run(JSON.stringify(run), id); + return run; + } + addCall(call: WorkflowCall): void { + this.database.sqlite.transaction(() => { + const run = this.get(call.runId)!; + this.database.sqlite.prepare("insert into workflow_calls (run_id, call_index, agent_id, record_json) values (?, ?, ?, ?)") + .run(call.runId, call.index, call.agentId, JSON.stringify(call)); + run.callCount++; + run.updatedAt = new Date().toISOString(); + this.database.sqlite.prepare("update workflow_runs set record_json = ? where id = ?").run(JSON.stringify(run), run.id); + })(); + } + calls(runId: string): WorkflowCall[] { + return this.database.sqlite.prepare("select record_json from workflow_calls where run_id = ? order by call_index").all(runId) + .map((row) => decode(row)!); + } + call(runId: string, index: number): WorkflowCall | undefined { + return decode(this.database.sqlite.prepare("select record_json from workflow_calls where run_id = ? and call_index = ?").get(runId, index)); + } + updateCall(runId: string, index: number, patch: Partial): WorkflowCall { + const call = this.call(runId, index); + if (!call) throw new WorkflowError("WORKFLOW_CALL_NOT_FOUND", `Unknown workflow call: ${index}`); + Object.assign(call, patch, { updatedAt: new Date().toISOString() }); + this.database.sqlite.prepare("update workflow_calls set record_json = ? where run_id = ? and call_index = ?") + .run(JSON.stringify(call), runId, index); + return call; + } + event(runId: string, type: string, data: unknown): void { + this.database.sqlite.prepare("insert into workflow_events (run_id, type, data_json, created_at) values (?, ?, ?, ?)") + .run(runId, type, JSON.stringify(data), new Date().toISOString()); + } + events(runId: string, after = 0): WorkflowEvent[] { + const rows = this.database.sqlite.prepare("select * from workflow_events where run_id = ? and sequence > ? order by sequence limit 100").all(runId, after) as Array<{ + sequence: number; run_id: string; type: string; data_json: string; created_at: string; + }>; + return rows.map((r) => ({ sequence: r.sequence, runId: r.run_id, type: r.type, data: JSON.parse(r.data_json), createdAt: r.created_at })); + } +} +function decode(row: unknown): T | undefined { + return row ? JSON.parse((row as { record_json: string }).record_json) as T : undefined; +} diff --git a/src/workflow-types.ts b/src/workflow-types.ts new file mode 100644 index 000000000..0eb36e421 --- /dev/null +++ b/src/workflow-types.ts @@ -0,0 +1,77 @@ +import type { LocalAgentWorkspaceScope } from "./local-agent-store.js"; + +export type WorkflowStatus = "starting" | "running" | "stopping" | "completed" | "failed" | "cancelled" | "interrupted"; +export type WorkflowWriteMode = "read_only" | "allowed"; +export interface WorkflowFailure { code: string; message: string; retryable: boolean } +export class WorkflowError extends Error { + constructor(readonly code: string, message: string, readonly retryable = false) { + super(message); + this.name = "WorkflowError"; + } +} +export interface WorkflowRunInput extends LocalAgentWorkspaceScope { + source?: string; + name?: string; + args?: unknown; + resume?: string; + writeMode?: WorkflowWriteMode; +} +export interface WorkflowRun extends LocalAgentWorkspaceScope { + id: string; + name: string; + status: WorkflowStatus; + writeMode: WorkflowWriteMode; + concurrency: number; + resumeOf?: string; + result?: unknown; + error?: WorkflowFailure; + createdAt: string; + updatedAt: string; + callCount: number; +} +export interface WorkflowSnapshot extends WorkflowRun { + source: string; + args: unknown; + contextHash: string; +} +export interface WorkflowAgentOptions { + target: string; + model?: string; + effort?: string; + schema?: Record; + label?: string; + phase?: string; + writeMode?: WorkflowWriteMode; + isolation?: "worktree"; + /** A logical name, shared by calls in this run; never a filesystem path. */ + workspace?: string; +} +export interface WorkflowCall { + runId: string; + index: number; + agentId: string; + turnId?: number; + status: "queued" | "running" | "completed" | "failed" | "cancelled" | "interrupted"; + prompt: string; + options: WorkflowAgentOptions; + fingerprint: string; + workspaceRoot: string; + workspaceId?: string; + result?: unknown; + error?: WorkflowFailure; + reusedFrom?: string; + createdAt: string; + updatedAt: string; +} +export interface WorkflowEvent { sequence: number; runId: string; type: string; data: unknown; createdAt: string } +export function workflowFailure(error: unknown): WorkflowFailure { + const value = error as { code?: unknown; message?: unknown; retryable?: unknown } | undefined; + return { + code: typeof value?.code === "string" ? value.code : "WORKFLOW_FAILED", + message: typeof value?.message === "string" ? value.message : String(error), + retryable: value?.retryable === true, + }; +} +export function workflowTerminal(status: WorkflowStatus): boolean { + return status === "completed" || status === "failed" || status === "cancelled" || status === "interrupted"; +} diff --git a/src/workflow-workspaces.ts b/src/workflow-workspaces.ts new file mode 100644 index 000000000..842d97848 --- /dev/null +++ b/src/workflow-workspaces.ts @@ -0,0 +1,55 @@ +import { realpathSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { ServerConfig } from "./config.js"; +import { createManagedWorktree } from "./git-worktrees.js"; +import { isPathInsideRoot, resolveCanonicalAllowedPath } from "./roots.js"; +import { SqliteWorkspaceStore } from "./workspace-store.js"; +import type { LocalAgentWorkspaceScope } from "./local-agent-store.js"; +import { WorkflowError } from "./workflow-types.js"; + +export function isManagedWorkflowWorkspace(config: ServerConfig, root: string, id: string): boolean { + const store = new SqliteWorkspaceStore(config.stateDir); + try { + const workspace = store.getSession(id); + if (!workspace || workspace.status !== "active" || !workspace.managed || workspace.mode !== "worktree" || !workspace.sourceRoot) return false; + const canonical = realpathSync(root); + return canonical === realpathSync(workspace.root) + && isPathInsideRoot(canonical, realpathSync(config.worktreeRoot)) + && config.allowedRoots.some((allowed) => { + try { return isPathInsideRoot(realpathSync(workspace.sourceRoot!), realpathSync(allowed)); } + catch { return false; } + }); + } catch { return false; } + finally { store.close(); } +} + +export async function createWorkflowWorkspace(config: ServerConfig, source: LocalAgentWorkspaceScope): Promise { + let sourcePath = source.workspaceRoot; + let baseRef: string | undefined; + if (source.workspaceId && isManagedWorkflowWorkspace(config, sourcePath, source.workspaceId)) { + const store = new SqliteWorkspaceStore(config.stateDir); + try { + const session = store.getSession(source.workspaceId); + if (!session?.sourceRoot || session.status !== "active") throw new WorkflowError("INVALID_WORKSPACE", "Source worktree is no longer active."); + sourcePath = session.sourceRoot; + } + finally { store.close(); } + const head = await promisify(execFile)("git", ["rev-parse", "HEAD"], { cwd: source.workspaceRoot, timeout: 5_000 }); + baseRef = head.stdout.trim(); + } + await resolveCanonicalAllowedPath(sourcePath, sourcePath, config.allowedRoots); + const worktree = await createManagedWorktree({ sourcePath, baseRef, config }); + const root = await resolveCanonicalAllowedPath(worktree.path, worktree.path, [config.worktreeRoot]); + const store = new SqliteWorkspaceStore(config.stateDir); + try { + const workspace = store.createSession({ + id: `ws_${randomUUID()}`, root, mode: "worktree", sourceRoot: worktree.sourceRoot, + baseRef: worktree.baseRef, baseSha: worktree.baseSha, managed: true, + }); + return { workspaceRoot: root, workspaceId: workspace.id }; + } catch (error) { + throw new WorkflowError("WORKSPACE_REGISTRATION_FAILED", `Worktree was retained at ${root}, but registering it failed: ${error instanceof Error ? error.message : String(error)}`); + } finally { store.close(); } +}