diff --git a/README.md b/README.md index 381930db..94c278a0 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,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) +- [Subagents and Dynamic Workflows](https://github.com/Waishnav/devspace/blob/main/docs/dynamic-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/agent-profile-schema.md b/docs/agent-profile-schema.md index 5ede01ad..94186ce1 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -145,16 +145,40 @@ Recommended body content: ## Model-facing workflow -The Subagent skill teaches only: +The Subagent skill uses the default compact XML fragments: ```bash -devspace agents ls --json -devspace agents targets --json -devspace agents run "" --json -devspace agents continue "" --json -devspace agents show --json +devspace agents targets +devspace agents ls +devspace agents run "" +devspace agents continue "" +devspace agents show +devspace agents wait ... ``` +The commands do not add a document-level wrapper. `targets`, `ls`, and `wait` +print one fragment per item and print nothing for an empty list. This keeps the +model-facing result small: + +```xml + +Read-only code review. + +Review complete. +Provider disconnected. +Subagent not found. +``` + +`show` returns an immediate snapshot. `wait` accepts one or more agent IDs and +waits for all of their current work. It does not stream fragments as individual +agents finish. With `--timeout `, it returns each unique agent in +first-seen order and marks unfinished work with `status="running" +wait="timeout"`. + +`--json` remains available for scripts that need it, but the bundled skill does +not request it. Internal turn records, prompts, provider session IDs, workspace +paths, and timestamps are absent from both output formats. + `open_workspace` exposes compact profile metadata: ```json diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index f6826617..9a954a8c 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -144,7 +144,8 @@ Set `skills.enabled` to `false` to hide skills from workspace output. Enable Subagents and choose providers through `devspace init` or the persisted provider configuration. The bundled `subagents` skill teaches the minimal `devspace agents targets`, `devspace agents ls`, `devspace agents run`, -`devspace agents continue`, and `devspace agents show` workflow. The catalog +`devspace agents continue`, `devspace agents show`, and `devspace agents wait` +workflow. The catalog comes from `open_workspace`; `devspace agents ls` lists existing subagent sessions for that workspace. diff --git a/docs/configuration.md b/docs/configuration.md index 6a6f607b..f48c3b1d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -115,6 +115,11 @@ Subagent providers are explicit. Omitted providers are disabled: "enabled": true, "model": "gpt-5.4", "effort": "high", + "command": "/opt/devspace/bin/codex-wrapper", + "env": { + "CODEX_HOME": "/home/alice/.codex-work", + "OPENAI_BASE_URL": "https://api.example.com/v1", + }, }, { "id": "claude", @@ -130,10 +135,26 @@ Profiles are loaded from `~/.devspace/agents/*.md` and project `.devspace/agents/*.md`. `devspace agents targets` prints the configured targets available in the current workspace. -Provider executable discovery remains process-scoped. The supported overrides -are `CODEX_COMMAND`, `CODEX_HOME`, `CLAUDE_COMMAND`, `CURSOR_COMMAND`, -`COPILOT_COMMAND`, `GROK_COMMAND`, and `GROK_AGENT_PROFILE`. DevSpace does not -persist provider credentials. +`command` names one executable. DevSpace does not split shell arguments, so use +a wrapper executable when startup needs fixed arguments. `env` maps environment +variable names to literal string values and preserves empty strings. DevSpace +does not expand `$NAME` references in these values. + +Codex, Claude, Cursor, Copilot, and Grok accept `command` and `env`. OpenCode and +Pi are embedded, so their provider entries reject both fields. The daemon +inherits its startup environment, then overlays the provider's `env`. An +explicit `command` wins over both the inherited command override and a command +override placed in `env`. + +Existing process-level overrides remain supported: `CODEX_COMMAND`, +`CODEX_HOME`, `CLAUDE_COMMAND`, `CURSOR_COMMAND`, `COPILOT_COMMAND`, +`GROK_COMMAND`, and `GROK_AGENT_PROFILE`. Provider configuration takes +precedence where the same value is set in both places. + +DevSpace writes `config.jsonc` with mode `0600`, but provider environment values +are still plain text on disk. Keep the file out of version control. Leave +credentials in the process environment if you do not want DevSpace to persist +them. ## Native artifact download diff --git a/docs/dynamic-workflows.md b/docs/dynamic-workflows.md new file mode 100644 index 00000000..1f27a294 --- /dev/null +++ b/docs/dynamic-workflows.md @@ -0,0 +1,95 @@ +# Subagents And Dynamic Workflows + +DevSpace exposes one agent execution layer through its CLI. Coding harnesses +such as Codex, Pi, OpenCode, or Cursor can call it directly. ChatGPT and Claude +can call the same commands through DevSpace's ordinary shell or process tools. +There are no dedicated subagent or workflow-execution MCP tools. + +## Setup + +Run `devspace init` and enable agent tooling. Setup probes the supported +providers, asks which ones DevSpace may use, and prints installation commands +for two Coding Agent skills: + +- `subagents` for one bounded delegation and later follow-ups +- `dynamic-workflows` for programmed multi-agent orchestration + +Provider selection is stored as provider objects under `subagents` in +`~/.devspace/config.jsonc`. Runtime availability is checked again before a +provider is shown or used. MCP workspaces load the bundled copies when +subagents are enabled. + +## Project Scope + +Run agent commands from the intended project. When an MCP host invokes the CLI, +DevSpace injects the opened workspace identity. In a standalone harness, +DevSpace discovers the current Git repository or project directory. Lists, +lookups, continuations, status checks, and cancellations stay inside that +scope. + +## Direct Subagents + +```bash +devspace agents targets --json +devspace agents run "" --json +devspace agents show --json +devspace agents continue "" --json +devspace agents stop --json +devspace agents ls --json +``` + +Use a direct subagent for one focused implementation, investigation, review, or +verification task. Profiles can supply role instructions and provider/model +defaults. The child runs independently and returns an id that the orchestrator +polls or continues. + +## Dynamic Workflows + +```bash +devspace workflow run --name --json +devspace workflow run --file --arg key=value --json +devspace workflow status --json +devspace workflow calls --json +devspace workflow call --json +devspace workflow cancel --json +devspace workflow ls --json +devspace workflow tui [run-id] +``` + +Named scripts live in `.devspace/workflows/.js`. A script can combine +`agent`, `parallel`, `pipeline`, `phase`, `log`, and one-level nested +`workflow` calls. Agent calls can request structured JSON or an isolated Git +worktree. + +Agent harnesses should prefer `--json`, retain the returned id, and poll status. +This avoids coupling a long workflow lifetime to one tool-call timeout. +`--follow` remains available for interactive terminals with long-running +process support. + +`workflow tui` opens a project-scoped, read-only Navigator. Without a run id it +starts on the workflow list; with a run id it opens that run directly. Opening a +run shows its declared phases beside the agent calls in the selected phase. +Calls without a declared phase are grouped under `Other`. Terminals narrower +than 80 columns show one pane at a time, with `Tab` switching panes. Opening a +call exposes normalized activity, prompt, result, worktree details, and provider +metadata. Use arrow keys (or `j`/`k`) to navigate, `Tab` to switch panes or +inspector sections, `Enter` to open, `Esc` to go back, and `q` to quit. + +Elapsed time is derived from persisted call timestamps. Token counts are +best-effort provider observations: a running call may show a partial snapshot, +while a completed call shows its final provider-reported total. Providers that +cannot report a value remain visibly unavailable instead of being estimated. +Replayed calls do not contribute tokens to the current run. + +Failed and cancelled workflows are terminal. `workflow run --resume ` +creates a new run, reuses the unchanged successful prefix when safe, and +continues live from the first failed or changed call. + +## MCP Boundary + +`open_workspace` exposes the current compact subagent profile and provider +catalog from the core agent system. It does not include active workflow runs, +workflow phases, session identifiers, or internal counters. Hosts inspect +those only when needed through `devspace workflow` commands. This keeps the +host as the orchestrator without adding a second workflow-specific MCP or +dashboard surface. diff --git a/docs/gotchas.md b/docs/gotchas.md index 5f628867..a5db4be3 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -218,7 +218,7 @@ When Subagents are enabled, DevSpace loads agent profiles from compact profile catalog through `open_workspace`. The bundled `subagents` skill keeps the model-facing workflow to `devspace agents targets`, `devspace agents ls`, `devspace agents run`, -`devspace agents continue`, and `devspace agents show`. +`devspace agents continue`, `devspace agents show`, and `devspace agents wait`. Those commands automatically manage the internal local agent daemon; `devspace serve` is not a prerequisite. `devspace agents ls` lists existing subagent sessions, not profile @@ -229,6 +229,7 @@ For a Coding Agent, run the installation command printed by ```bash npx skills add Waishnav/devspace --skill subagents --global +npx skills add Waishnav/devspace --skill dynamic-workflows --global ``` The Skills CLI handles agent discovery and installation. DevSpace setup does diff --git a/docs/local-agent-daemon.md b/docs/local-agent-daemon.md index e1313b2d..f940cfa2 100644 --- a/docs/local-agent-daemon.md +++ b/docs/local-agent-daemon.md @@ -5,7 +5,7 @@ by the MCP server and not by an individual CLI invocation. The daemon is an internal implementation detail: the normal workflow remains: ```text -devspace agents run/continue/show/ls +devspace agents targets/run/continue/show/wait/ls │ ▼ devspace-agentd @@ -58,15 +58,24 @@ devspace agents daemon stop devspace agents daemon logs ``` -Agent commands accept `--json` when a machine-readable response is needed. -They emit one compact JSON value. `run` and `continue` return only the logical -agent ID and status, `ls` returns session summaries, and `show` returns the -response or structured failure for one agent. Internal workspace paths, -provider session IDs, timestamps, and prior responses are not included in list -or receipt output. Immediate failures are emitted as -`{ error: { code, message, retryable, ... } }` with a non-zero exit code. -Successful `daemon status` and `daemon stop` output the daemon status object, -and successful `daemon logs` output is `{ "logs": "" }`. +The client and daemon compare an internal revision of the provider +configuration. A client replaces an idle daemon when that configuration has +changed. It never stops a daemon with active work; the client returns the +retryable `DAEMON_CONFIG_CHANGED` error until that work finishes. The revision +is not included in status, logs, or agent command output. + +Model-facing agent commands emit compact XML fragments by default. Lists use +one fragment per item without a root wrapper, and empty lists print nothing. +`run` and `continue` return only the logical agent ID and status. `show` returns +an immediate snapshot. `wait` blocks for one or more agents and can return a +complete ordered snapshot at a caller-supplied timeout. It does not stream +individual completions. + +Internal turns, prompts, workspace paths, provider session IDs, timestamps, and +prior responses are not included. Immediate failures use an `` fragment +and a non-zero exit code. `--json` remains available for compatibility and +scripts. Daemon diagnostic commands keep their existing text and JSON output; +they do not use the model-facing XML format. Agent identity is explicit at the client boundary. `agents run` starts a new logical agent from a profile or provider; `agents continue ` continues an diff --git a/docs/setup.md b/docs/setup.md index 6f707bff..e6759c37 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -62,11 +62,13 @@ If you selected Coding Agents, setup prints: ```bash npx skills add Waishnav/devspace --skill subagents --global +npx skills add Waishnav/devspace --skill dynamic-workflows --global ``` -The Skills CLI asks which installed Coding Agents should receive the skill. -The skill uses `devspace agents targets`, `run`, `continue`, `show`, and `ls`. -These commands do not require `devspace serve`. +The Skills CLI asks which installed Coding Agents should receive each skill. +The subagent skill uses `devspace agents targets`, `run`, `continue`, `show`, +`wait`, `stop`, and `ls`. The workflow skill adds the durable `devspace workflow` +runner and Navigator. These commands do not require `devspace serve`. ### Connect ChatGPT diff --git a/package.json b/package.json index 1696333d..b7202224 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "build": "pnpm clean && pnpm build:app && tsc -p tsconfig.build.json", "build:app": "vite build", "dev": "tsx watch --clear-screen=false src/cli.ts serve", + "dev:tui-fixture": "tsx scripts/workflow-tui-fixture.ts", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "prepack": "pnpm build", "schema:config": "tsx scripts/generate-config-schema.ts", @@ -50,6 +51,7 @@ "@modelcontextprotocol/sdk": "^1.29.0", "@opencode-ai/sdk": "^1.17.13", "@pierre/diffs": "^1.2.5", + "ajv": "^8.20.0", "better-result": "^2.10.0", "better-sqlite3": "^12.10.0", "cross-spawn": "^7.0.6", @@ -57,6 +59,7 @@ "drizzle-orm": "^0.45.2", "express": "^5.2.1", "jsonc-parser": "^3.3.1", + "json-schema-to-ts": "^3.1.1", "lucide": "^1.24.0", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26f7684e..5d2655ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@pierre/diffs': specifier: ^1.2.5 version: 1.2.5(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 @@ -53,6 +56,9 @@ importers: express: specifier: ^5.2.1 version: 5.2.1 + json-schema-to-ts: + specifier: ^3.1.1 + version: 3.1.1 jsonc-parser: specifier: ^3.3.1 version: 3.3.1 diff --git a/schema/v1/devspace.schema.json b/schema/v1/devspace.schema.json index e7c18466..944d570a 100644 --- a/schema/v1/devspace.schema.json +++ b/schema/v1/devspace.schema.json @@ -192,6 +192,21 @@ "effort": { "type": "string", "minLength": 1 + }, + "command": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "env": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "additionalProperties": { + "type": "string" + } } }, "required": [ diff --git a/scripts/workflow-tui-fixture.ts b/scripts/workflow-tui-fixture.ts new file mode 100644 index 00000000..89774c6a --- /dev/null +++ b/scripts/workflow-tui-fixture.ts @@ -0,0 +1,412 @@ +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { parseArgs } from "node:util"; +import { databasePath } from "../src/db/client.js"; +import { WorkflowStore } from "../src/workflow-store.js"; +import type { WorkflowRunRecord } from "../src/workflow-types.js"; + +const FIXTURE_VERSION = "large-v2"; +const WORKFLOW_NAME = "Ship multi-service authentication"; +const WORKFLOW_PHASES = [ + { title: "Discovery", detail: "Map the existing authentication surface" }, + { title: "Architecture", detail: "Choose service and data boundaries" }, + { title: "Backend implementation", detail: "Implement services and migrations" }, + { title: "Frontend integration", detail: "Connect the client experience" }, + { title: "Verification", detail: "Exercise security and integration boundaries" }, + { title: "Release", detail: "Prepare the rollout" }, +]; + +const fixtureNames = [ + "empty", + "starting", + "running", + "phased-running", + "replayed", + "call-failed", + "completed", + "failed", + "cancelled", +] as const; + +type FixtureName = (typeof fixtureNames)[number]; + +interface FixtureResult { + name: FixtureName; + stateDir: string; + run?: WorkflowRunRecord; +} + +const { values } = parseArgs({ + options: { + state: { type: "string", default: "all" }, + "state-dir": { type: "string" }, + "workspace-root": { type: "string" }, + }, + strict: true, +}); + +const requestedState = values.state; +const selectedFixtures = requestedState === "all" + ? [...fixtureNames] + : fixtureNames.includes(requestedState as FixtureName) + ? [requestedState as FixtureName] + : fail(`Unknown fixture state: ${requestedState}. Use all or one of: ${fixtureNames.join(", ")}`); +const fixtureRoot = resolve( + values["state-dir"] ?? join(tmpdir(), "devspace-workflow-tui-fixtures"), +); +const workspaceRoot = resolve(values["workspace-root"] ?? process.cwd()); + +const results = selectedFixtures.map((name) => seedFixture(name, fixtureRoot, workspaceRoot)); + +console.log(`Workflow TUI fixtures for ${workspaceRoot}`); +console.log(""); +for (const result of results) { + console.log(`${result.name}:`); + console.log(` database: ${databasePath(result.stateDir)}`); + if (result.run) console.log(` run: ${result.run.id}`); + const runArgument = result.run && !["starting", "running"].includes(result.run.status) + ? ` ${result.run.id}` + : ""; + console.log( + ` DEVSPACE_STATE_DIR=${JSON.stringify(result.stateDir)} DEVSPACE_WORKFLOWS=1 devspace workflow tui${runArgument}`, + ); + console.log(""); +} + +function seedFixture( + name: FixtureName, + root: string, + workspace: string, +): FixtureResult { + const stateDir = join(root, name); + const store = new WorkflowStore(stateDir); + try { + if (name === "empty") return { name, stateDir }; + + const scriptHash = `workflow-tui-fixture:${name}:${FIXTURE_VERSION}`; + const existing = store + .listRunsForWorkspace(workspace) + .find((run) => run.scriptHash === scriptHash); + if (existing) return { name, stateDir, run: existing }; + const replayParent = name === "replayed" + ? seedReplayParent(store, stateDir, workspace, scriptHash) + : undefined; + + const run = store.createRun({ + name: WORKFLOW_NAME, + source: name === "replayed" ? "resume" : "inline", + scriptPath: join(stateDir, "fixtures", `${name}.js`), + scriptHash, + workspaceRoot: workspace, + phases: WORKFLOW_PHASES, + resumedFromRunId: replayParent?.id, + }); + + if (name === "starting") return { name, stateDir, run }; + + store.claimRun(run.id, process.pid); + store.appendEvent({ + runId: run.id, + type: "run_started", + data: { name: run.name, scriptHash, concurrency: 2 }, + }); + + if (name === "running") { + startPhase(store, run.id, "Discovery"); + addCompletedCall(store, run.id, 0, "Discovery", "Map authentication services", "codex"); + addCompletedCall(store, run.id, 1, "Discovery", "Audit token storage", "claude"); + startCall(store, run.id, 2, "Trace client login flows", "codex", "Discovery"); + startCall(store, run.id, 3, "Inventory migration risks", "claude", "Discovery"); + } else if (name === "phased-running") { + addCompletedPhase(store, run.id, "Discovery", 0, [ + ["Map authentication services", "codex"], + ["Audit token storage", "claude"], + ["Trace client login flows", "codex"], + ]); + addCompletedPhase(store, run.id, "Architecture", 3, [ + ["Design session boundaries", "claude"], + ["Plan database migration", "codex"], + ]); + startPhase(store, run.id, "Backend implementation"); + startCall(store, run.id, 5, "Implement OAuth store", "codex", "Backend implementation", true); + startCall(store, run.id, 6, "Add session rotation", "claude", "Backend implementation"); + startCall(store, run.id, 7, "Migrate authentication API", "codex", "Backend implementation", true); + store.appendEvent({ + runId: run.id, + type: "log", + phase: "Backend implementation", + data: { message: "Running service-level authentication tests" }, + }); + } else if (name === "replayed") { + startPhase(store, run.id, "Discovery"); + addCachedCall(store, run.id, replayParent!.id, 0, "Discovery", "Map authentication services", "codex"); + addCachedCall(store, run.id, replayParent!.id, 1, "Discovery", "Audit token storage", "claude"); + addCachedCall(store, run.id, replayParent!.id, 2, "Discovery", "Trace client login flows", "codex"); + startPhase(store, run.id, "Architecture"); + addCachedCall(store, run.id, replayParent!.id, 3, "Architecture", "Design session boundaries", "claude"); + addCachedCall(store, run.id, replayParent!.id, 4, "Architecture", "Plan database migration", "codex"); + startPhase(store, run.id, "Backend implementation"); + startCall(store, run.id, 5, "Implement OAuth store", "codex", "Backend implementation", true); + startCall(store, run.id, 6, "Add session rotation", "claude", "Backend implementation"); + } else if (name === "call-failed") { + addCompletedPhase(store, run.id, "Discovery", 0, [ + ["Map authentication services", "codex"], + ["Audit token storage", "claude"], + ["Trace client login flows", "codex"], + ]); + addCompletedPhase(store, run.id, "Architecture", 3, [ + ["Design session boundaries", "claude"], + ["Plan database migration", "codex"], + ]); + startPhase(store, run.id, "Backend implementation"); + startCall(store, run.id, 5, "Migrate authentication API", "codex", "Backend implementation", true); + store.failAgentCall({ + runId: run.id, + callIndex: 5, + error: "Provider process exited while updating the API", + errorKind: "provider", + }); + startCall(store, run.id, 6, "Implement OAuth store", "claude", "Backend implementation"); + startCall(store, run.id, 7, "Inspect client impact", "codex", "Backend implementation"); + } else if (name === "completed") { + seedCompletedWorkflow(store, run.id); + store.completeRun(run.id, { resultJson: JSON.stringify({ ok: true }), callCount: 12 }); + } else if (name === "failed") { + addCompletedPhase(store, run.id, "Discovery", 0, [ + ["Map authentication services", "codex"], + ["Audit token storage", "claude"], + ]); + addCompletedPhase(store, run.id, "Architecture", 2, [ + ["Design session boundaries", "claude"], + ["Plan database migration", "codex"], + ]); + addCompletedPhase(store, run.id, "Backend implementation", 4, [ + ["Implement OAuth store", "codex"], + ["Add session rotation", "claude"], + ["Migrate authentication API", "codex"], + ]); + addCompletedPhase(store, run.id, "Frontend integration", 7, [ + ["Update login experience", "claude"], + ["Handle session expiry", "codex"], + ]); + startPhase(store, run.id, "Verification"); + startCall(store, run.id, 9, "Run cross-service integration tests", "claude", "Verification"); + store.failAgentCall({ + runId: run.id, + callIndex: 9, + error: "Cross-service integration tests failed", + errorKind: "internal", + }); + store.failRun(run.id, { + error: "Workflow stopped because cross-service integration tests failed", + errorKind: "internal", + }); + } else if (name === "cancelled") { + addCompletedPhase(store, run.id, "Discovery", 0, [ + ["Map authentication services", "codex"], + ["Audit token storage", "claude"], + ["Trace client login flows", "codex"], + ]); + addCompletedPhase(store, run.id, "Architecture", 3, [ + ["Design session boundaries", "claude"], + ["Plan database migration", "codex"], + ]); + startPhase(store, run.id, "Backend implementation"); + store.cancelRun(run.id, "Cancelled by user"); + } + + return { name, stateDir, run: store.getRun(run.id) ?? run }; + } finally { + store.close(); + } +} + +function startCall( + store: WorkflowStore, + runId: string, + callIndex: number, + label: string, + provider: "codex" | "claude", + phase?: string, + worktree = false, +): void { + store.startAgentCall({ + runId, + callIndex, + cacheKey: `fixture-${callIndex}`, + prompt: label, + provider, + model: provider === "codex" ? "gpt-5.4" : "sonnet", + label, + phase, + isolation: worktree ? "worktree" : "shared", + worktreePath: worktree + ? join(tmpdir(), `devspace-fixture-worktree-${callIndex}`) + : undefined, + }); + store.attachAgentSession(runId, callIndex, `${provider}-fixture-${callIndex}`); + store.updateAgentUsage(runId, callIndex, fixtureUsage(callIndex, "partial")); + store.appendAgentActivity({ + runId, + callIndex, + kind: "status", + status: "completed", + label: "session started", + detail: `${provider} accepted the task`, + }); + store.appendAgentActivity({ + runId, + callIndex, + kind: worktree ? "file" : "tool", + status: "running", + label: worktree ? "editing isolated worktree" : "inspecting workspace", + detail: label, + }); +} + +function startPhase(store: WorkflowStore, runId: string, phase: string): void { + store.appendEvent({ + runId, + type: "phase_started", + phase, + data: { title: phase }, + }); +} + +function addCompletedCall( + store: WorkflowStore, + runId: string, + callIndex: number, + phase: string, + label: string, + provider: "codex" | "claude", + worktree = false, +): void { + startCall(store, runId, callIndex, label, provider, phase, worktree); + store.appendAgentActivity({ + runId, + callIndex, + kind: worktree ? "file" : "tool", + status: "completed", + label: worktree ? "updated implementation" : "inspected workspace", + detail: label, + }); + store.updateAgentUsage(runId, callIndex, fixtureUsage(callIndex, "final")); + store.completeAgentCall({ runId, callIndex, responseText: `${label} completed` }); +} + +function fixtureUsage( + callIndex: number, + state: "partial" | "final", +): Parameters[2] { + const multiplier = state === "final" ? 1 : 0.7; + const inputTokens = Math.floor((18_000 + callIndex * 4_300) * multiplier); + const outputTokens = Math.floor((4_500 + callIndex * 1_700) * multiplier); + return { + inputTokens, + cachedInputTokens: Math.floor(inputTokens * 0.25), + outputTokens, + totalTokens: inputTokens + outputTokens, + state, + }; +} + +function addCompletedPhase( + store: WorkflowStore, + runId: string, + phase: string, + firstCallIndex: number, + calls: ReadonlyArray, +): void { + startPhase(store, runId, phase); + calls.forEach(([label, provider], offset) => { + addCompletedCall(store, runId, firstCallIndex + offset, phase, label, provider, offset % 3 === 2); + }); +} + +function addCachedCall( + store: WorkflowStore, + runId: string, + replayedFromRunId: string, + callIndex: number, + phase: string, + label: string, + provider: "codex" | "claude", +): void { + store.cacheAgentCall({ + runId, + callIndex, + cacheKey: `fixture-replayed-${callIndex}`, + prompt: label, + provider, + model: provider === "codex" ? "gpt-5.4" : "sonnet", + label, + phase, + replayMatch: "same_index", + replayedFromRunId, + replayedFromCallIndex: callIndex, + responseText: `${label} reused from the previous run`, + }); +} + +function seedReplayParent( + store: WorkflowStore, + stateDir: string, + workspaceRoot: string, + childScriptHash: string, +): WorkflowRunRecord { + const scriptHash = `${childScriptHash}:parent`; + const existing = store + .listRunsForWorkspace(workspaceRoot) + .find((run) => run.scriptHash === scriptHash); + if (existing) return existing; + + const parent = store.createRun({ + name: `${WORKFLOW_NAME} (previous run)`, + source: "inline", + scriptPath: join(stateDir, "fixtures", "replayed-parent.js"), + scriptHash, + workspaceRoot, + }); + store.claimRun(parent.id, process.pid); + seedCompletedWorkflow(store, parent.id); + store.completeRun(parent.id, { resultJson: JSON.stringify({ ok: true }), callCount: 12 }); + return store.getRun(parent.id) ?? parent; +} + +function seedCompletedWorkflow(store: WorkflowStore, runId: string): void { + addCompletedPhase(store, runId, "Discovery", 0, [ + ["Map authentication services", "codex"], + ["Audit token storage", "claude"], + ["Trace client login flows", "codex"], + ]); + addCompletedPhase(store, runId, "Architecture", 3, [ + ["Design session boundaries", "claude"], + ["Plan database migration", "codex"], + ]); + addCompletedPhase(store, runId, "Backend implementation", 5, [ + ["Implement OAuth store", "codex"], + ["Add session rotation", "claude"], + ["Migrate authentication API", "codex"], + ]); + addCompletedPhase(store, runId, "Frontend integration", 8, [ + ["Update login experience", "claude"], + ["Handle session expiry", "codex"], + ]); + addCompletedPhase(store, runId, "Verification", 10, [ + ["Run cross-service integration tests", "claude"], + ["Review security boundaries", "codex"], + ]); + startPhase(store, runId, "Release"); + store.appendEvent({ + runId, + type: "log", + phase: "Release", + data: { message: "Authentication rollout is ready" }, + }); +} + +function fail(message: string): never { + console.error(message); + process.exit(1); +} diff --git a/skills/dynamic-workflows/SKILL.md b/skills/dynamic-workflows/SKILL.md new file mode 100644 index 00000000..e20273c8 --- /dev/null +++ b/skills/dynamic-workflows/SKILL.md @@ -0,0 +1,105 @@ +--- +name: dynamic-workflows +description: Create and run resumable multi-agent orchestration with the DevSpace CLI. Use when work needs programmed fan-out, multiple phases, per-item pipelines, structured aggregation, isolated parallel writers, or recovery after a failed workflow; use a direct subagent for one bounded delegation. +--- + +# DevSpace Dynamic Workflows + +Use the DevSpace CLI through the host's shell or process tool. Run commands from the project the workflow should operate on. DevSpace scopes runs to the host workspace when supplied, otherwise to the current Git repository or project directory. + +Prefer `--json` from an agent harness: it starts or inspects work without holding one tool call open. Retain the returned workflow id and poll explicitly. Use `--follow` only when streaming output is useful and the shell tool supports a long-running process. Do not combine `--json` and `--follow`. + +## Run and inspect + +```bash +devspace workflow run --name [--arg key=value]... --json +devspace workflow run --file [--arg key=value]... --json +devspace workflow status --json +devspace workflow calls --json +devspace workflow call --json +devspace workflow cancel --json +devspace workflow ls --json +``` + +Named workflows live at `.devspace/workflows/.js`. `--script-path` is an alias for `--file`. `--arg key=value` accepts repeated run inputs through the script's `args` value. + +Poll `status --json` until the workflow reaches `completed`, `failed`, or `cancelled`. Use `calls` for the compact child-call list and `call` for one call's prompt, result, or error. + +## Write a workflow + +The first executable statement must export literal metadata. The script then uses the provided orchestration primitives and returns a JSON-compatible result. + +```js +export const meta = { + name: 'review-auth', + description: 'Review auth changes from two perspectives', + phases: [{ title: 'Review' }, { title: 'Synthesize' }], + concurrency: 2, +} + +phase('Review') +const findings = await parallel([ + () => agent('Review the auth diff for correctness.', { label: 'correctness' }), + () => agent('Review the auth diff for security.', { label: 'security' }), +]) + +phase('Synthesize') +const summary = await agent( + `Synthesize these findings: ${JSON.stringify(findings)}`, + { label: 'summary' }, +) + +return { findings, summary } +``` + +Available primitives: + +- `agent(prompt, options?)` delegates one bounded task. Options are `label`, `phase`, `schema`, `profile`, `provider`, `model`, `effort`, and `isolation: 'worktree'`. `profile` and `provider` are mutually exclusive. +- `parallel([thunks])` runs independent tasks concurrently and preserves input order. A failed branch produces `null` in its slot. +- `pipeline(items, ...stages)` processes each item through dependent stages; failed item chains produce `null` without stopping unrelated items. +- `phase(title)` and `log(message)` record meaningful progress. +- `workflow(nameOrRef, args?)` composes another named workflow or `{ scriptPath }` one level deep. +- `args` contains values passed with `--arg`. + +Use `devspace agents targets --json` before choosing a profile or provider. Prefer profiles for reusable role instructions and defaults. Only pass model or effort overrides when their exact values are already known. + +Use `schema` when later workflow steps need typed JSON rather than prose: + +```js +const review = await agent('Return the discovered bugs.', { + schema: { + type: 'object', + properties: { + bugs: { type: 'array', items: { type: 'string' } }, + }, + required: ['bugs'], + }, +}) +``` + +Use `isolation: 'worktree'` for parallel agents that may modify overlapping checkouts. Shared isolation is appropriate for readers or intentionally sequential writers. + +Workflow scripts must be replayable: do not use `Date.now()`, `Math.random()`, or `new Date()` without an argument. Pass changing values through `args`. + +## Recover a run + +Failed and cancelled runs are terminal. Inspect the prior run, fix or replace its script, then create a resumed run: + +```bash +devspace workflow status --json +devspace workflow calls --json +devspace workflow call --json +devspace workflow run --resume --json +devspace workflow run --resume --file --json +``` + +Keep completed calls' prompts and options stable when their results should be reused. Resume reuses the unchanged successful prefix and executes from the first call that failed, changed, or cannot be reused. + +A completed `isolation: 'worktree'` call cannot be reused because its checkout is not restored. When resume reaches one, that call and every later call execute again, even if their inputs are unchanged. Do not assume mutations from the prior isolated checkout are present in the resumed run. + +## Good uses + +- Fan out a change review across correctness, security, and tests, then synthesize it. +- Analyze many files with the same staged pipeline. +- Run parallel implementations in isolated worktrees and compare their results. +- Encode a repeatable migrate, review, and verify sequence. diff --git a/skills/subagents/SKILL.md b/skills/subagents/SKILL.md index 2e6180b5..439d1ca5 100644 --- a/skills/subagents/SKILL.md +++ b/skills/subagents/SKILL.md @@ -5,49 +5,67 @@ description: Delegate focused coding, research, review, or verification work to # DevSpace subagents -Use the DevSpace CLI through the shell or process tool. Run commands from the project the subagent should work on. +Run the DevSpace CLI through the shell or process tool from the project the subagent should use. Agent commands print compact XML fragments by default. Read that output directly. Do not add `--json`. ## Choose a target Discover usable targets instead of guessing names: ```bash -devspace agents targets --json +devspace agents targets ``` -Configured profiles include a description and may define provider, model, effort, and task instructions. Choose a matching profile when one fits. Use a provider target when no profile fits or a specific provider is needed. - -Usually rely on the target's configured model and effort. Pass `--model` or `--effort` only with a value supported by that provider. DevSpace passes these values through without translating them between providers. +Each line is a `` or `description` fragment. Prefer a matching profile. Use a provider target when no profile fits or the task needs a specific provider. Keep the configured model and effort unless the task requires a supported override. ## Start work -Give the subagent a self-contained brief. Include the objective, relevant paths, constraints, decisions it needs from the current conversation, and the expected result. The subagent receives the brief and its profile instructions, not the parent conversation. +Give the subagent a self-contained brief with the objective, relevant paths, constraints, context it cannot infer, and the expected result. The subagent receives this brief and its profile instructions, not the parent conversation. + +```bash +devspace agents run "" +devspace agents run --model --effort "" +``` + +The command returns an `` receipt. Keep the DevSpace agent ID for inspection, waiting, or follow-up. + +## Wait or inspect + +Use `wait` when work must finish before you proceed. One call can wait for several agents: + +```bash +devspace agents wait +devspace agents wait +devspace agents wait --timeout 60 +``` + +Without `--timeout`, the command waits until every named agent's current work finishes. A timeout returns one fragment per unique agent in first-seen order; unfinished work has `status="running" wait="timeout"`. Completed output is the element text. Failures include `code` and `retryable` attributes. The command does not stream partial results. + +Use `show` for an immediate snapshot. Do not poll it when `wait` can express the dependency. ```bash -devspace agents run "" --json -devspace agents run --model --effort "" --json +devspace agents show +devspace agents ls ``` -The result contains a DevSpace agent `id` and its current status. Execution continues independently, so retain the ID for later inspection or follow-up. +`ls` lists agents for the current project. Empty `targets` and `ls` results print nothing. -## Inspect and continue +## Continue related work + +Continue an agent when its existing provider context helps. Start another agent for unrelated work. ```bash -devspace agents show --json -devspace agents continue "" --json -devspace agents ls --json +devspace agents continue "" +devspace agents wait ``` -- `show` waits briefly for active work, then returns the current status and any - available response or error. -- `continue` gives the same subagent another turn with its existing provider - session and context. -- `ls` returns sessions belonging to the current project. +Cancel only the active turn when its result is no longer needed: + +```bash +devspace agents stop +``` -Run `devspace agents show --json` again later while the status is `running`. -`completed` includes the response. `failed` includes a structured error, and -`stopped` is terminal without a successful response. Continue an agent when its -existing context is useful; start another agent for unrelated work. +`stop` waits until the daemon has persisted the terminal `stopped` state. The +same agent can still receive a later `continue` turn. ## Good uses diff --git a/src/cli-output.test.ts b/src/cli-output.test.ts new file mode 100644 index 00000000..b7c436d9 --- /dev/null +++ b/src/cli-output.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { + workflowCallOutput, + workflowRunOutput, +} from "./cli-output.js"; +import type { WorkflowAgentCallRecord, WorkflowRunRecord } from "./workflow-types.js"; + +const now = "2026-08-08T00:00:00.000Z"; +const run: WorkflowRunRecord = { + id: "wfr_123", + name: "review", + source: "inline", + scriptPath: "/project/review.js", + scriptHash: "internal-hash", + workspaceRoot: "/private/project", + workspaceId: "ws_private", + argsJson: "null", + status: "completed", + resultJson: JSON.stringify({ ok: true }), + cancelRequested: false, + createdAt: now, + updatedAt: now, +}; +const call: WorkflowAgentCallRecord = { + runId: run.id, + callIndex: 0, + cacheKey: "internal-cache-key", + prompt: "Review", + provider: "codex", + profileFingerprint: "internal-fingerprint", + status: "completed", + fromCache: false, + providerSessionId: "provider-secret", + structuredJson: JSON.stringify({ bugs: [] }), + isolation: "shared", + createdAt: now, + startedAt: now, + completedAt: now, + updatedAt: now, +}; +const runJson = workflowRunOutput(run, [call]); +assert.deepEqual(runJson.result, { ok: true }); +assert.deepEqual(runJson.calls, { + running: 0, + completed: 1, + failed: 0, + cancelled: 0, + total: 1, +}); +assert.equal("scriptHash" in runJson, false); + +const callJson = workflowCallOutput(call, { detailed: true }); +assert.deepEqual(callJson.structured, { bugs: [] }); +assert.equal("cacheKey" in callJson, false); +assert.equal("providerSessionId" in callJson, false); +assert.equal("profileFingerprint" in callJson, false); + +console.log("cli-output.test.ts: ok"); diff --git a/src/cli-output.ts b/src/cli-output.ts new file mode 100644 index 00000000..8f14f0ae --- /dev/null +++ b/src/cli-output.ts @@ -0,0 +1,100 @@ +import type { + WorkflowAgentCallRecord, + WorkflowRunRecord, +} from "./workflow-types.js"; + +export function workflowRunOutput( + run: WorkflowRunRecord, + calls?: WorkflowAgentCallRecord[], +): Record { + return { + id: run.id, + name: run.name, + status: run.status, + source: run.source, + scriptPath: run.scriptPath, + resumedFromRunId: run.resumedFromRunId, + cancelRequested: run.cancelRequested, + calls: calls ? workflowCallCounts(calls) : undefined, + result: parseStoredJson(run.resultJson), + error: run.error + ? { kind: run.errorKind, message: parseStoredJson(run.error) } + : undefined, + createdAt: run.createdAt, + startedAt: run.startedAt, + completedAt: run.completedAt, + updatedAt: run.updatedAt, + }; +} + +export function workflowCallOutput( + call: WorkflowAgentCallRecord, + options: { detailed?: boolean } = {}, +): Record { + return { + index: call.callIndex, + status: call.status, + label: call.label, + phase: call.phase, + target: call.profileName ?? call.provider, + provider: call.provider, + model: call.model, + effort: call.effort, + cached: call.fromCache, + durationMs: workflowCallDurationMs(call), + isolation: call.isolation, + worktree: call.worktreePath + ? { path: call.worktreePath, dirty: call.dirty } + : undefined, + error: call.error + ? { kind: call.errorKind, message: parseStoredJson(call.error) } + : undefined, + replay: call.replayedFromRunId + ? { + runId: call.replayedFromRunId, + callIndex: call.replayedFromCallIndex, + } + : call.replayReason + ? { reason: call.replayReason } + : undefined, + ...(options.detailed + ? { + prompt: call.prompt, + schema: parseStoredJson(call.schemaJson), + response: call.responseText, + structured: parseStoredJson(call.structuredJson), + result: parseStoredJson(call.returnValueJson), + } + : {}), + createdAt: call.createdAt, + startedAt: call.startedAt, + completedAt: call.completedAt, + updatedAt: call.updatedAt, + }; +} + +function workflowCallCounts(calls: WorkflowAgentCallRecord[]): Record { + return { + running: calls.filter((call) => call.status === "running").length, + completed: calls.filter((call) => + call.status === "completed" || call.status === "from_cache" + ).length, + failed: calls.filter((call) => call.status === "failed").length, + cancelled: calls.filter((call) => call.status === "cancelled").length, + total: calls.length, + }; +} + +function workflowCallDurationMs(call: WorkflowAgentCallRecord): number | undefined { + if (!call.startedAt || !call.completedAt) return undefined; + return Math.max(0, Date.parse(call.completedAt) - Date.parse(call.startedAt)); +} + +function parseStoredJson(value: string | undefined): unknown { + if (value === undefined) return undefined; + try { + return JSON.parse(value) as unknown; + } catch { + return value; + } +} diff --git a/src/cli-workspace.ts b/src/cli-workspace.ts index d09cb253..499d1468 100644 --- a/src/cli-workspace.ts +++ b/src/cli-workspace.ts @@ -8,6 +8,11 @@ export interface CliWorkspaceContext { workspaceRoot: string; } +export interface WorkspaceScopedRecord { + workspaceId?: string; + workspaceRoot: string; +} + /** Resolve the project context used by local agent commands. */ export function resolveCliWorkspaceContext( allowedRoots: readonly string[], @@ -28,6 +33,24 @@ export function resolveCliWorkspaceContext( }; } +export function isRecordInCliWorkspace( + record: WorkspaceScopedRecord, + context: CliWorkspaceContext, +): boolean { + if (context.workspaceId) return record.workspaceId === context.workspaceId; + return canonicalizePath(record.workspaceRoot) === context.workspaceRoot; +} + +export function assertRecordInCliWorkspace( + record: WorkspaceScopedRecord, + context: CliWorkspaceContext, + label: string, +): void { + if (!isRecordInCliWorkspace(record, context)) { + throw new Error(`${label} does not belong to the current project.`); + } +} + function canonicalizePath(path: string): string { try { return realpathSync.native(path); diff --git a/src/cli.test.ts b/src/cli.test.ts index 0983417c..216b2a90 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -8,7 +8,10 @@ import { join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { loadConfig } from "./config.js"; -import { localAgentDaemonPaths } from "./local-agent-daemon-lifecycle.js"; +import { + LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + localAgentDaemonPaths, +} from "./local-agent-daemon-lifecycle.js"; import { encodeLocalAgentDaemonResponse } from "./local-agent-daemon-protocol.js"; import { LocalAgentStore } from "./local-agent-store.js"; import { writeTestDevspaceConfig } from "./test-support/config.test.js"; @@ -100,7 +103,7 @@ try { if (request.method === "agent.start") { socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ok: false, error: { code: "UNKNOWN_TARGET", @@ -113,21 +116,31 @@ try { } const result = request.method === "agent.list" ? [current] + : request.method === "agent.get" + ? current + : request.method === "agent.wait" + ? [ + { id: current.id, status: "completed", response: "Review complete." }, + { id: other.id, status: "running", wait: "timeout" }, + ] : request.method === "hello" ? { - state: "ready", - protocolVersion: 3, - pid: process.pid, - endpoint: daemonSocket, - startedAt: "now", - activeTurns: 0, - runtimeCount: 0, - clientConnections: 1, + status: { + state: "ready", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + pid: process.pid, + endpoint: daemonSocket, + startedAt: "now", + activeTurns: 0, + runtimeCount: 0, + clientConnections: 1, + }, + configMatches: true, } : null; socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ok: true, result, })); @@ -150,7 +163,10 @@ try { }, }); - assert.equal(output.trim(), `${current.id} completed reviewer`); + assert.equal( + output.trim(), + ``, + ); const { stdout: jsonOutput } = await execFileAsync( "node", @@ -189,6 +205,69 @@ try { const directList = [...daemonRequests].reverse().find((request) => request.method === "agent.list"); assert.deepEqual(directList?.params, { workspaceRoot: realpathSync.native(projectRoot) }); + const { stdout: showOutput } = await execFileAsync( + "node", + ["--import", "tsx", "src/cli.ts", "agents", "show", current.id], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + ...cliConfigEnv, + DEVSPACE_WORKSPACE_ID: "ws_current", + DEVSPACE_WORKSPACE_ROOT: projectRoot, + }, + }, + ); + assert.equal( + showOutput, + `Review complete.\n`, + ); + assert.equal( + daemonRequests.filter((request) => request.method === "agent.get").length, + 1, + "show must be an immediate snapshot", + ); + + const { stdout: waitOutput } = await execFileAsync( + "node", + [ + "--import", + "tsx", + "src/cli.ts", + "agents", + "wait", + current.id, + other.id, + "--timeout", + "0", + ], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + ...cliConfigEnv, + DEVSPACE_WORKSPACE_ID: "ws_current", + DEVSPACE_WORKSPACE_ROOT: projectRoot, + }, + }, + ); + assert.equal( + waitOutput, + [ + `Review complete.`, + ``, + "", + ].join("\n"), + ); + const waitRequest = daemonRequests.find((request) => request.method === "agent.wait"); + assert.deepEqual(waitRequest?.params, { + ids: [current.id, other.id], + scope: { workspaceId: "ws_current", workspaceRoot: realpathSync.native(projectRoot) }, + timeoutMs: 0, + }); + let commandFailure: unknown; try { await execFileAsync( @@ -217,6 +296,31 @@ try { assert.equal(payload.error.retryable, false); assert.equal(payload.error.target, "missing"); + let xmlCommandFailure: unknown; + try { + await execFileAsync( + "node", + ["--import", "tsx", "src/cli.ts", "agents", "run", "missing", "inspect"], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + ...cliConfigEnv, + DEVSPACE_WORKSPACE_ID: "ws_current", + DEVSPACE_WORKSPACE_ROOT: projectRoot, + }, + }, + ); + } catch (error) { + xmlCommandFailure = error; + } + assert.ok(xmlCommandFailure, "XML CLI errors should exit non-zero"); + assert.equal( + (xmlCommandFailure as { stderr?: string }).stderr, + 'Unknown subagent profile or provider: missing.\n', + ); + await assert.rejects( execFileAsync( "node", @@ -243,7 +347,10 @@ try { }, ), (error: unknown) => { - assert.match((error as { stderr?: string }).stderr ?? "", /Unknown option: --unknown/); + assert.equal( + (error as { stderr?: string }).stderr, + 'Unknown option: --unknown. Use -- before prompt text that starts with a dash.\n', + ); return true; }, ); diff --git a/src/cli.ts b/src/cli.ts index b521556a..febec5ec 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -25,6 +25,7 @@ import { import { createLocalAgentClient } from "./local-agent-client.js"; import { toAgentErrorPayload, type LocalAgentError } from "./local-agent-errors.js"; import { + formatAgentCommandError, formatAgentObservation, formatAgentReceipt, formatAgentSummary, @@ -52,6 +53,11 @@ import { import { expandHomePath } from "./roots.js"; import { readReviewRef } from "./review-checkpoints.js"; import { shutdownHttpServer } from "./server-shutdown.js"; +import { runWorkflowCommand } from "./workflow-cli.js"; +import { + isWorkflowOperationError, + workflowCliExitCode, +} from "./workflow-errors.js"; type Command = | "serve" @@ -59,6 +65,7 @@ type Command = | "doctor" | "config" | "agents" + | "workflow" | "show-changes" | "help" | "version"; @@ -88,6 +95,9 @@ async function main(argv: string[]): Promise { case "agents": await runAgentsCommand(args); return; + case "workflow": + await runWorkflowCommand(args, loadConfig()); + return; case "show-changes": await runShowChanges(args); return; @@ -107,6 +117,7 @@ function normalizeCommand(command: string | undefined): Command { || command === "doctor" || command === "config" || command === "agents" + || command === "workflow" || command === "show-changes" ) return command; if (command === "help" || command === "--help" || command === "-h") return "help"; @@ -210,7 +221,10 @@ async function runInit({ force }: { force: boolean }): Promise { } const currentSubagents = files.config.subagents; - const availability = getLocalAgentProviderAvailabilitySnapshot(); + const availability = getLocalAgentProviderAvailabilitySnapshot( + process.env, + currentSubagents, + ); const configuredProviders = currentSubagents.providers .filter((provider) => provider.enabled) .map((provider) => provider.id); @@ -274,9 +288,9 @@ async function runInit({ force }: { force: boolean }): Promise { [ SUBAGENT_SKILL_INSTALL_COMMAND, "", - "The Skills CLI will let you choose which Coding Agents receive it.", + "The Skills CLI will let you choose which Coding Agents receive them.", ].join("\n"), - "Install the Subagents skill", + "Install the agent skills", ); } const nextSteps = [ @@ -360,7 +374,7 @@ async function runDoctor(): Promise { console.log(`Allowed hosts: ${config.allowedHosts.join(", ")}`); const providers = buildLocalAgentProviderStatuses( config.subagents, - getLocalAgentProviderAvailabilitySnapshot(), + getLocalAgentProviderAvailabilitySnapshot(process.env, config.subagents), ); console.log(`Subagents: ${config.subagents.enabled ? "enabled" : "disabled"}`); console.log(`Subagent providers: ${formatLocalAgentProviderStatusSummary(providers)}`); @@ -410,11 +424,15 @@ function printHelp(): void { " devspace config get Print persisted config", " devspace config set publicBaseUrl ", " devspace show-changes [--json]", + " devspace agents targets [--json] List usable subagent providers and profiles", " devspace agents ls List subagent sessions", " devspace agents run [--model ] [--effort ] ", " devspace agents continue [--model ] [--effort ] ", + " devspace agents stop ", " devspace agents show ", + " devspace agents wait ... [--timeout ] [--json]", " devspace agents daemon ", + " devspace workflow run|status|cancel|ls|calls|call|tui", " devspace -v, --version Print the installed version", "", "For temporary tunnels:", @@ -447,19 +465,25 @@ async function runAgentsCommand(args: string[]): Promise { switch (subcommand) { case "ls": case "list": - await runAgentsList(commandArgs, json); + await runAgentWorkflowCommand(json, () => runAgentsList(commandArgs, json)); return; case "run": - await runAgentsRun(commandArgs, json); + await runAgentWorkflowCommand(json, () => runAgentsRun(commandArgs, json)); return; case "continue": - await runAgentsContinue(commandArgs, json); + await runAgentWorkflowCommand(json, () => runAgentsContinue(commandArgs, json)); return; case "show": - await runAgentsShow(commandArgs, json); + await runAgentWorkflowCommand(json, () => runAgentsShow(commandArgs, json)); + return; + case "wait": + await runAgentWorkflowCommand(json, () => runAgentsWait(commandArgs, json)); + return; + case "stop": + await runAgentWorkflowCommand(json, () => runAgentsStop(commandArgs, json)); return; case "targets": - await runAgentsTargets(commandArgs, json); + await runAgentWorkflowCommand(json, () => runAgentsTargets(commandArgs, json)); return; case "daemon": await runAgentsDaemon(commandArgs, json); @@ -471,7 +495,7 @@ async function runAgentsCommand(args: string[]): Promise { printAgentsHelp(); return; default: - throw new Error(`Unknown agents command: ${subcommand}`); + writeAgentWorkflowError(`Unknown agents command: ${subcommand}`, json); } } @@ -482,12 +506,12 @@ async function runAgentsTargets(args: string[], json: boolean): Promise { const profiles = await loadLocalAgentProfiles(config, scope.workspaceRoot); const providers = buildLocalAgentProviderStatuses( config.subagents, - getLocalAgentProviderAvailabilitySnapshot(), + getLocalAgentProviderAvailabilitySnapshot(process.env, config.subagents), ); const catalog = buildLocalAgentCatalog(config.subagents, profiles, providers); const output = presentAgentTargetCatalog(catalog); if (json) printJson(output); - else console.log(formatAgentTargetCatalog(output)); + else printAgentXml(formatAgentTargetCatalog(output)); } async function runAgentsList(args: string[], json: boolean): Promise { @@ -495,7 +519,7 @@ async function runAgentsList(args: string[], json: boolean): Promise { const config = loadConfig(); const client = createLocalAgentClient(config); const result = await client.list(resolveCliWorkspaceContext(config.allowedRoots)); - const agents = presentAgentResult(result, json); + const agents = presentAgentWorkflowResult(result, json); if (!agents) return; const summaries = agents.map(presentAgentSummary); @@ -504,14 +528,7 @@ async function runAgentsList(args: string[], json: boolean): Promise { return; } - if (agents.length === 0) { - console.log("No subagent sessions found for this workspace."); - return; - } - - for (const summary of summaries) { - console.log(formatAgentSummary(summary)); - } + printAgentXml(summaries.map(formatAgentSummary).join("\n")); } async function runAgentsRun(args: string[], json: boolean): Promise { @@ -527,14 +544,14 @@ async function runAgentsRun(args: string[], json: boolean): Promise { model: parsed.model, effort: parsed.effort, }); - const record = presentAgentResult(result, json); + const record = presentAgentWorkflowResult(result, json); if (!record) return; const receipt = presentAgentReceipt(record); if (json) { printJson(receipt); return; } - console.log(formatAgentReceipt(receipt)); + printAgentXml(formatAgentReceipt(receipt)); } async function runAgentsContinue(args: string[], json: boolean): Promise { @@ -546,14 +563,14 @@ async function runAgentsContinue(args: string[], json: boolean): Promise { model: parsed.model, effort: parsed.effort, }, scope); - const record = presentAgentResult(result, json); + const record = presentAgentWorkflowResult(result, json); if (!record) return; const receipt = presentAgentReceipt(record); if (json) { printJson(receipt); return; } - console.log(formatAgentReceipt(receipt)); + printAgentXml(formatAgentReceipt(receipt)); } async function runAgentsShow(args: string[], json: boolean): Promise { @@ -564,20 +581,74 @@ async function runAgentsShow(args: string[], json: boolean): Promise { const client = createLocalAgentClient(config); const scope = resolveCliWorkspaceContext(config.allowedRoots); const initial = await client.get(id, scope); - let record = presentAgentResult(initial, json); + const record = presentAgentWorkflowResult(initial, json); if (!record) return; - const deadline = Date.now() + 15_000; - while ((record.status === "starting" || record.status === "running") && Date.now() < deadline) { - await sleep(500); - const refreshed = presentAgentResult(await client.get(id, scope), json); - if (!refreshed) return; - record = refreshed; - } - const observation = presentAgentObservation(record); if (json) printJson(observation); - else console.log(formatAgentObservation(observation)); + else printAgentXml(formatAgentObservation(observation)); +} + +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 results = presentAgentWorkflowResult(await client.wait(ids, scope, timeoutMs), json); + if (!results) return; + if (json) { + printJson(results); + return; + } + printAgentXml(results.map(formatAgentObservation).join("\n")); +} + +function parseAgentsWaitArgs(args: string[]): { ids: string[]; timeoutMs?: number } { + const ids: string[] = []; + let timeoutMs: number | undefined; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]!; + if (argument === "--timeout") { + timeoutMs = parseAgentWaitTimeout(args[index + 1]); + index += 1; + continue; + } + if (argument.startsWith("--timeout=")) { + timeoutMs = parseAgentWaitTimeout(argument.slice("--timeout=".length)); + continue; + } + if (argument.startsWith("-")) throw new Error(`Unknown option: ${argument}.`); + ids.push(argument); + } + if (ids.length === 0) { + throw new Error("Usage: devspace agents wait ... [--timeout ] [--json]"); + } + return { ids, ...(timeoutMs === undefined ? {} : { timeoutMs }) }; +} + +function parseAgentWaitTimeout(value: string | undefined): number { + if (!value || !/^\d+$/.test(value)) { + throw new Error("Agent wait timeout must be a non-negative integer number of seconds."); + } + const timeoutMs = Number(value) * 1_000; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs > 2_147_483_647) { + throw new Error("Agent wait timeout is too large."); + } + return timeoutMs; +} + +async function runAgentsStop(args: string[], json: boolean): Promise { + const [id, ...extra] = args; + if (!id || extra.length > 0) throw new Error("Usage: devspace agents stop [--json]"); + + const config = loadConfig(); + const client = createLocalAgentClient(config); + const scope = resolveCliWorkspaceContext(config.allowedRoots); + const record = presentAgentWorkflowResult(await client.cancel(id, scope), json); + if (!record) return; + const receipt = presentAgentReceipt(record); + if (json) printJson(receipt); + else printAgentXml(formatAgentReceipt(receipt)); } async function runAgentsDaemon(args: string[], json: boolean): Promise { @@ -643,12 +714,39 @@ function presentAgentResult( throw new Error(result.error.message); } -function printJson(value: unknown): void { - console.log(JSON.stringify(value)); +function presentAgentWorkflowResult( + result: BetterResult, + json: boolean, +): T | undefined { + if (result.isOk()) return result.value; + const error = toAgentErrorPayload(result.error); + if (json) printJson({ error }); + else console.error(formatAgentCommandError(error)); + process.exitCode = 1; + return undefined; +} + +async function runAgentWorkflowCommand(json: boolean, command: () => Promise): Promise { + try { + await command(); + } catch (error) { + writeAgentWorkflowError(error instanceof Error ? error.message : String(error), json); + } } -function sleep(ms: number): Promise { - return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); +function writeAgentWorkflowError(message: string, json: boolean): void { + const error = { code: "AGENT_COMMAND_ERROR", message, retryable: false }; + if (json) printJson({ error }); + else console.error(formatAgentCommandError(error)); + process.exitCode = 1; +} + +function printAgentXml(fragment: string): void { + if (fragment) console.log(fragment); +} + +function printJson(value: unknown): void { + console.log(JSON.stringify(value)); } function printAgentsHelp(): void { @@ -661,6 +759,8 @@ function printAgentsHelp(): void { " devspace agents run [--model ] [--effort ] [--json] ", " devspace agents continue [--model ] [--effort ] [--json] ", " devspace agents show [--json]", + " devspace agents stop [--json]", + " devspace agents wait ... [--timeout ] [--json]", " devspace agents targets [--json]", " devspace agents daemon [--json]", ].join("\n"), @@ -779,5 +879,5 @@ function checkBashShell(): string { main(process.argv.slice(2)).catch((error) => { console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; + process.exitCode = isWorkflowOperationError(error) ? workflowCliExitCode(error) : 1; }); diff --git a/src/db/migrations.test.ts b/src/db/migrations.test.ts new file mode 100644 index 00000000..3e9152e7 --- /dev/null +++ b/src/db/migrations.test.ts @@ -0,0 +1,170 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import Database from "better-sqlite3"; +import { databasePath, openDatabase } from "./client.js"; + +const stateDir = mkdtempSync(join(tmpdir(), "devspace-workflow-migration-test-")); + +try { + const legacy = new Database(databasePath(stateDir)); + legacy.exec(` + create table devspace_schema_migrations ( + version integer primary key, + name text not null, + applied_at text not null + ); + create table workspace_sessions ( + id text primary key, + root text not null, + status text not null default 'active', + mode text not null default 'checkout', + source_root text, + base_ref text, + base_sha text, + managed text not null default 'false', + created_at text not null, + last_used_at text not null + ); + create table local_agent_sessions ( + id text primary key, + workspace_id text, + workspace_root text not null, + profile_name text not null, + provider text not null, + model text, + thinking text, + provider_session_id text, + status text not null, + latest_response text, + error text, + created_at text not null, + updated_at text not null + ); + create table workflow_runs ( + id text primary key, + name text not null, + source text not null, + script_path text not null, + script_hash text not null, + workspace_root text not null, + workspace_id text, + args_json text not null default 'null', + status text not null, + error text, + error_kind text, + result_json text, + pid integer, + heartbeat_at text, + cancel_requested text not null default 'false', + resumed_from_run_id text, + base_sha text, + created_at text not null, + started_at text, + completed_at text, + updated_at text not null + ); + create table workflow_agent_calls ( + run_id text not null, + call_index integer not null, + cache_key text not null, + prompt text not null default '', + schema_json text, + provider text not null, + model text, + effort text, + profile_name text, + profile_fingerprint text, + label text, + phase text, + status text not null, + from_cache text not null default 'false', + provider_session_id text, + response_text text, + structured_json text, + return_value_json text, + error text, + error_kind text, + replay_match text, + replayed_from_run_id text, + replayed_from_call_index integer, + replay_reason text, + isolation text not null default 'shared', + worktree_path text, + dirty text, + created_at text not null, + started_at text, + completed_at text, + updated_at text not null, + primary key (run_id, call_index) + ); + `); + const recordMigration = legacy.prepare( + "insert into devspace_schema_migrations (version, name, applied_at) values (?, ?, ?)", + ); + const oldStackMigrations = [ + "workspace-state", + "oauth-state", + "local-agent-sessions", + "local-agent-effort-rename", + "workflow-journal", + "workflow-replay-provenance", + "workflow-exact-replay", + "workflow-agent-profiles", + "workflow-observability", + ]; + for (const [index, name] of oldStackMigrations.entries()) { + recordMigration.run(index + 1, name, "2026-08-01T00:00:00.000Z"); + } + legacy.close(); + + const upgraded = openDatabase(stateDir); + try { + assert.equal(tableExists(upgraded.sqlite, "workspace_conversation_bindings"), true); + assert.equal(tableExists(upgraded.sqlite, "local_agent_turns"), true); + assert.equal(tableExists(upgraded.sqlite, "workflow_agent_activity"), true); + assert.equal(columnExists(upgraded.sqlite, "local_agent_sessions", "effort"), true); + assert.equal(columnExists(upgraded.sqlite, "local_agent_sessions", "error_code"), true); + assert.equal(columnExists(upgraded.sqlite, "local_agent_sessions", "usage_json"), true); + assert.equal(columnExists(upgraded.sqlite, "local_agent_sessions", "activity_json"), true); + assert.equal(columnExists(upgraded.sqlite, "workflow_runs", "phases_json"), true); + assert.equal( + columnExists(upgraded.sqlite, "workflow_agent_calls", "usage_total_tokens"), + true, + ); + assert.deepEqual( + upgraded.sqlite + .prepare("select version, name from devspace_schema_migrations where version >= 10 order by version") + .all(), + [ + { version: 10, name: "workflow-exact-replay" }, + { version: 11, name: "workflow-agent-profiles" }, + { version: 12, name: "workflow-observability" }, + { version: 13, name: "reconcile-workflow-stack-schema" }, + { version: 14, name: "local-agent-observability" }, + ], + ); + } finally { + upgraded.close(); + } +} finally { + rmSync(stateDir, { recursive: true, force: true }); +} + +function tableExists(sqlite: Database.Database, table: string): boolean { + return sqlite.prepare( + "select 1 from sqlite_master where type = 'table' and name = ?", + ).get(table) !== undefined; +} + +function columnExists( + sqlite: Database.Database, + table: string, + column: string, +): boolean { + const columns = sqlite.prepare(`pragma table_info(${table})`).all() as Array<{ name: string }>; + return columns.some((candidate) => candidate.name === column); +} + +console.log("database migration tests passed"); diff --git a/src/db/migrations.ts b/src/db/migrations.ts index df192caa..7c36f668 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -37,6 +37,46 @@ const migrations: Migration[] = [ name: "local-agent-effort-rename", up: migrateLocalAgentEffortRename, }, + { + version: 7, + name: "local-agent-turns", + up: migrateLocalAgentTurns, + }, + { + version: 8, + name: "workflow-journal", + up: migrateWorkflowJournal, + }, + { + version: 9, + name: "workflow-replay-provenance", + up: migrateWorkflowReplayProvenance, + }, + { + version: 10, + name: "workflow-exact-replay", + up: migrateWorkflowExactReplay, + }, + { + version: 11, + name: "workflow-agent-profiles", + up: migrateWorkflowAgentProfiles, + }, + { + version: 12, + name: "workflow-observability", + up: migrateWorkflowObservability, + }, + { + version: 13, + name: "reconcile-workflow-stack-schema", + up: reconcileWorkflowStackSchema, + }, + { + version: 14, + name: "local-agent-observability", + up: migrateLocalAgentObservability, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -235,9 +275,197 @@ function migrateLocalAgentEffortRename(sqlite: Database.Database): void { sqlite.exec("alter table local_agent_sessions rename column thinking to effort"); } +function migrateLocalAgentTurns(sqlite: Database.Database): void { + sqlite.exec(` + create table if not exists local_agent_turns ( + id integer primary key autoincrement, + agent_id text not null, + prompt text not null, + status text not null, + response text, + error text, + error_code text, + error_retryable text, + created_at text not null, + completed_at text, + foreign key (agent_id) references local_agent_sessions(id) on delete cascade + ); + + create index if not exists local_agent_turns_agent_id_idx + on local_agent_turns(agent_id, id desc); + + create index if not exists local_agent_turns_status_idx + on local_agent_turns(status); + `); +} + +function migrateWorkflowJournal(sqlite: Database.Database): void { + sqlite.exec(` + create table if not exists workflow_runs ( + id text primary key, + name text not null, + source text not null, + script_path text not null, + script_hash text not null, + workspace_root text not null, + workspace_id text, + args_json text not null default 'null', + status text not null, + error text, + error_kind text, + result_json text, + pid integer, + heartbeat_at text, + cancel_requested text not null default 'false', + resumed_from_run_id text, + base_sha text, + created_at text not null, + started_at text, + completed_at text, + updated_at text not null + ); + + create index if not exists workflow_runs_status_updated_idx + on workflow_runs(status, updated_at desc); + create index if not exists workflow_runs_workspace_updated_idx + on workflow_runs(workspace_root, updated_at desc); + create index if not exists workflow_runs_heartbeat_idx + on workflow_runs(status, heartbeat_at); + create index if not exists workflow_runs_resumed_from_idx + on workflow_runs(resumed_from_run_id); + + create table if not exists workflow_events ( + run_id text not null, + seq integer not null, + type text not null, + phase text, + label text, + data_json text not null default '{}', + created_at text not null, + primary key (run_id, seq), + foreign key (run_id) references workflow_runs(id) on delete cascade + ); + + create index if not exists workflow_events_run_seq_idx + on workflow_events(run_id, seq); + + create table if not exists workflow_agent_calls ( + run_id text not null, + call_index integer not null, + cache_key text not null, + provider text not null, + model text, + effort text, + profile_name text, + profile_fingerprint text, + label text, + phase text, + status text not null, + from_cache text not null default 'false', + provider_session_id text, + response_text text, + structured_json text, + return_value_json text, + error text, + isolation text not null default 'shared', + worktree_path text, + dirty text, + created_at text not null, + started_at text, + completed_at text, + updated_at text not null, + primary key (run_id, call_index), + foreign key (run_id) references workflow_runs(id) on delete cascade + ); + + create index if not exists workflow_agent_calls_cache_key_idx + on workflow_agent_calls(run_id, cache_key); + `); +} + +function migrateWorkflowReplayProvenance(sqlite: Database.Database): void { + addColumnIfMissing(sqlite, "workflow_agent_calls", "prompt", "text not null default ''"); + addColumnIfMissing(sqlite, "workflow_agent_calls", "schema_json", "text"); + addColumnIfMissing(sqlite, "workflow_agent_calls", "error_kind", "text"); + addColumnIfMissing(sqlite, "workflow_agent_calls", "replay_match", "text"); + addColumnIfMissing(sqlite, "workflow_agent_calls", "replayed_from_run_id", "text"); + addColumnIfMissing(sqlite, "workflow_agent_calls", "replayed_from_call_index", "integer"); + addColumnIfMissing(sqlite, "workflow_agent_calls", "replay_reason", "text"); + sqlite.exec(` + create index if not exists workflow_agent_calls_replay_source_idx + on workflow_agent_calls(replayed_from_run_id, replayed_from_call_index); + `); +} + +function migrateWorkflowExactReplay(sqlite: Database.Database): void { + addColumnIfMissing(sqlite, "workflow_agent_calls", "return_value_json", "text"); +} + +function migrateWorkflowAgentProfiles(sqlite: Database.Database): void { + addColumnIfMissing(sqlite, "workflow_agent_calls", "profile_name", "text"); + addColumnIfMissing(sqlite, "workflow_agent_calls", "profile_fingerprint", "text"); +} + +function migrateWorkflowObservability(sqlite: Database.Database): void { + addColumnIfMissing(sqlite, "workflow_runs", "phases_json", "text not null default '[]'"); + addColumnIfMissing(sqlite, "workflow_agent_calls", "usage_input_tokens", "integer"); + addColumnIfMissing(sqlite, "workflow_agent_calls", "usage_cached_input_tokens", "integer"); + addColumnIfMissing(sqlite, "workflow_agent_calls", "usage_cache_creation_input_tokens", "integer"); + addColumnIfMissing(sqlite, "workflow_agent_calls", "usage_output_tokens", "integer"); + addColumnIfMissing(sqlite, "workflow_agent_calls", "usage_total_tokens", "integer"); + addColumnIfMissing(sqlite, "workflow_agent_calls", "usage_state", "text"); + addColumnIfMissing(sqlite, "workflow_agent_calls", "usage_updated_at", "text"); + + sqlite.exec(` + create table if not exists workflow_agent_activity ( + run_id text not null, + call_index integer not null, + seq integer not null, + kind text not null, + status text not null, + label text not null, + detail text, + started_at text, + completed_at text, + created_at text not null, + primary key (run_id, call_index, seq), + foreign key (run_id) references workflow_runs(id) on delete cascade + ); + + create index if not exists workflow_agent_activity_call_seq_idx + on workflow_agent_activity(run_id, call_index, seq); + `); +} + +/** + * Old workflow-stack checkouts used migration versions 4 through 9 for a + * different sequence than current main. Reapplying every affected migration + * idempotently lets both histories converge without rewriting migration rows. + */ +function reconcileWorkflowStackSchema(sqlite: Database.Database): void { + migrateWorkspaceConversationBindings(sqlite); + migrateLocalAgentStructuredErrors(sqlite); + migrateLocalAgentEffortRename(sqlite); + migrateLocalAgentTurns(sqlite); + migrateWorkflowJournal(sqlite); + migrateWorkflowReplayProvenance(sqlite); + migrateWorkflowExactReplay(sqlite); + migrateWorkflowAgentProfiles(sqlite); + migrateWorkflowObservability(sqlite); +} + +function migrateLocalAgentObservability(sqlite: Database.Database): void { + addColumnIfMissing(sqlite, "local_agent_sessions", "usage_json", "text"); + addColumnIfMissing(sqlite, "local_agent_sessions", "activity_json", "text"); +} + function addColumnIfMissing( sqlite: Database.Database, - table: "workspace_sessions" | "local_agent_sessions", + table: + | "workspace_sessions" + | "local_agent_sessions" + | "workflow_runs" + | "workflow_agent_calls", column: string, definition: string, ): void { diff --git a/src/db/schema.ts b/src/db/schema.ts index c16da892..4f622be3 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -106,6 +106,8 @@ export const localAgentSessions = sqliteTable( error: text("error"), errorCode: text("error_code"), errorRetryable: text("error_retryable"), + usageJson: text("usage_json"), + activityJson: text("activity_json"), createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }, @@ -116,6 +118,133 @@ export const localAgentSessions = sqliteTable( ], ); +export const workflowRuns = sqliteTable( + "workflow_runs", + { + id: text("id").primaryKey(), + name: text("name").notNull(), + source: text("source").notNull(), + scriptPath: text("script_path").notNull(), + scriptHash: text("script_hash").notNull(), + workspaceRoot: text("workspace_root").notNull(), + workspaceId: text("workspace_id"), + argsJson: text("args_json").notNull().default("null"), + phasesJson: text("phases_json").notNull().default("[]"), + status: text("status").notNull(), + error: text("error"), + errorKind: text("error_kind"), + resultJson: text("result_json"), + pid: integer("pid"), + heartbeatAt: text("heartbeat_at"), + cancelRequested: text("cancel_requested").notNull().default("false"), + resumedFromRunId: text("resumed_from_run_id"), + baseSha: text("base_sha"), + createdAt: text("created_at").notNull(), + startedAt: text("started_at"), + completedAt: text("completed_at"), + updatedAt: text("updated_at").notNull(), + }, + (table) => [ + index("workflow_runs_status_updated_idx").on(table.status, table.updatedAt), + index("workflow_runs_workspace_updated_idx").on(table.workspaceRoot, table.updatedAt), + index("workflow_runs_heartbeat_idx").on(table.status, table.heartbeatAt), + index("workflow_runs_resumed_from_idx").on(table.resumedFromRunId), + ], +); + +export const workflowEvents = sqliteTable( + "workflow_events", + { + runId: text("run_id") + .notNull() + .references(() => workflowRuns.id, { onDelete: "cascade" }), + seq: integer("seq").notNull(), + type: text("type").notNull(), + phase: text("phase"), + label: text("label"), + dataJson: text("data_json").notNull().default("{}"), + createdAt: text("created_at").notNull(), + }, + (table) => [ + primaryKey({ columns: [table.runId, table.seq] }), + index("workflow_events_run_seq_idx").on(table.runId, table.seq), + ], +); + +export const workflowAgentCalls = sqliteTable( + "workflow_agent_calls", + { + runId: text("run_id") + .notNull() + .references(() => workflowRuns.id, { onDelete: "cascade" }), + callIndex: integer("call_index").notNull(), + cacheKey: text("cache_key").notNull(), + prompt: text("prompt").notNull().default(""), + schemaJson: text("schema_json"), + provider: text("provider").notNull(), + model: text("model"), + effort: text("effort"), + profileName: text("profile_name"), + profileFingerprint: text("profile_fingerprint"), + label: text("label"), + phase: text("phase"), + status: text("status").notNull(), + fromCache: text("from_cache").notNull().default("false"), + providerSessionId: text("provider_session_id"), + usageInputTokens: integer("usage_input_tokens"), + usageCachedInputTokens: integer("usage_cached_input_tokens"), + usageCacheCreationInputTokens: integer("usage_cache_creation_input_tokens"), + usageOutputTokens: integer("usage_output_tokens"), + usageTotalTokens: integer("usage_total_tokens"), + usageState: text("usage_state"), + usageUpdatedAt: text("usage_updated_at"), + responseText: text("response_text"), + structuredJson: text("structured_json"), + returnValueJson: text("return_value_json"), + error: text("error"), + errorKind: text("error_kind"), + replayMatch: text("replay_match"), + replayedFromRunId: text("replayed_from_run_id"), + replayedFromCallIndex: integer("replayed_from_call_index"), + replayReason: text("replay_reason"), + isolation: text("isolation").notNull().default("shared"), + worktreePath: text("worktree_path"), + dirty: text("dirty"), + createdAt: text("created_at").notNull(), + startedAt: text("started_at"), + completedAt: text("completed_at"), + updatedAt: text("updated_at").notNull(), + }, + (table) => [ + primaryKey({ columns: [table.runId, table.callIndex] }), + index("workflow_agent_calls_cache_key_idx").on(table.runId, table.cacheKey), + index("workflow_agent_calls_replay_source_idx").on( + table.replayedFromRunId, + table.replayedFromCallIndex, + ), + ], +); + +export const workflowAgentActivity = sqliteTable( + "workflow_agent_activity", + { + runId: text("run_id").notNull().references(() => workflowRuns.id, { onDelete: "cascade" }), + callIndex: integer("call_index").notNull(), + seq: integer("seq").notNull(), + kind: text("kind").notNull(), + status: text("status").notNull(), + label: text("label").notNull(), + detail: text("detail"), + startedAt: text("started_at"), + completedAt: text("completed_at"), + createdAt: text("created_at").notNull(), + }, + (table) => [ + primaryKey({ columns: [table.runId, table.callIndex, table.seq] }), + index("workflow_agent_activity_call_seq_idx").on(table.runId, table.callIndex, table.seq), + ], +); + export type WorkspaceSessionRow = typeof workspaceSessions.$inferSelect; export type NewWorkspaceSessionRow = typeof workspaceSessions.$inferInsert; export type LoadedAgentFileRow = typeof loadedAgentFiles.$inferSelect; @@ -124,3 +253,7 @@ export type WorkspaceConversationBindingRow = typeof workspaceConversationBindin export type NewWorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferInsert; export type LocalAgentSessionRow = typeof localAgentSessions.$inferSelect; export type NewLocalAgentSessionRow = typeof localAgentSessions.$inferInsert; +export type WorkflowRunRow = typeof workflowRuns.$inferSelect; +export type WorkflowEventRow = typeof workflowEvents.$inferSelect; +export type WorkflowAgentCallRow = typeof workflowAgentCalls.$inferSelect; +export type WorkflowAgentActivityRow = typeof workflowAgentActivity.$inferSelect; diff --git a/src/json-types.ts b/src/json-types.ts new file mode 100644 index 00000000..a701edd4 --- /dev/null +++ b/src/json-types.ts @@ -0,0 +1,42 @@ +import type { JSONSchema } from "json-schema-to-ts"; +import * as z from "zod/v4"; + +export type JsonPrimitive = string | number | boolean | null; + +export type JsonValue = + | JsonPrimitive + | JsonValue[] + | { [key: string]: JsonValue }; + +export type JsonObject = { [key: string]: JsonValue }; + +/** JSON Schema is the portable contract shared with provider SDKs and Ajv. */ +export type JsonSchema = JSONSchema; + +export const jsonValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.string(), + z.number().finite(), + z.boolean(), + z.null(), + z.array(jsonValueSchema), + z.record(z.string(), jsonValueSchema), + ]), +); + +export const jsonObjectSchema: z.ZodType = z.record( + z.string(), + jsonValueSchema, +); + +export const jsonSchemaSchema = jsonObjectSchema.transform( + (value): JsonSchema => value as JsonSchema, +); + +export function parseJsonValue(value: unknown): JsonValue { + return jsonValueSchema.parse(value); +} + +export function parseJsonText(text: string): JsonValue { + return parseJsonValue(JSON.parse(text) as unknown); +} diff --git a/src/local-agent-acp.ts b/src/local-agent-acp.ts index a5d9c369..495ae137 100644 --- a/src/local-agent-acp.ts +++ b/src/local-agent-acp.ts @@ -10,6 +10,7 @@ import { isProgrammerDefect, } from "./local-agent-errors.js"; import { terminateProcessTree } from "./process-platform.js"; +import { observeAcpUpdate } from "./local-agent-observation.js"; import { GrokPromptCompletionRegistry, GROK_DEFAULT_MODEL, @@ -49,6 +50,7 @@ const ACP_COMMANDS: Record = { interface AcpConnectionLike { agent: { request(method: string, params?: unknown): Promise; + cancel?(params: { sessionId: string }): Promise; }; close(error?: unknown): void; closed: Promise; @@ -75,6 +77,7 @@ export interface AcpRuntimeOptions { liveSessions?: Set; sessionWriteModes?: Map; sessionMetadata?: Map; + sessionCallbacks?: Map; grokCompletionRegistry?: GrokPromptCompletionRegistry; promptCompletionTimeoutMs?: number; } @@ -88,6 +91,7 @@ export class AcpRuntime implements LocalAgentRuntime { private readonly liveSessions: Set; private readonly sessionWriteModes: Map; private readonly sessionMetadata: Map; + private readonly sessionCallbacks: Map; private readonly grokCompletionRegistry?: GrokPromptCompletionRegistry; private readonly promptCompletionTimeoutMs: number; private readonly activeSessions = new Set(); @@ -104,6 +108,7 @@ export class AcpRuntime implements LocalAgentRuntime { this.liveSessions = options.liveSessions ?? new Set(); this.sessionWriteModes = options.sessionWriteModes ?? new Map(); this.sessionMetadata = options.sessionMetadata ?? new Map(); + this.sessionCallbacks = options.sessionCallbacks ?? new Map(); this.grokCompletionRegistry = options.grokCompletionRegistry; this.promptCompletionTimeoutMs = options.promptCompletionTimeoutMs ?? ACP_GROK_PROMPT_COMPLETION_TIMEOUT_MS; void this.connection.closed.then(() => { @@ -161,6 +166,13 @@ export class AcpRuntime implements LocalAgentRuntime { : undefined; try { queue.values.length = 0; + if (callbacks) this.sessionCallbacks.set(sessionId, callbacks); + const onAbort = () => { + void this.connection.agent.cancel?.({ sessionId }).catch(() => undefined); + }; + input.signal?.addEventListener("abort", onAbort, { once: true }); + if (input.signal?.aborted) onAbort(); + try { const standardResponse = this.connection.agent.request("session/prompt", { sessionId, prompt: [{ type: "text", text: input.prompt }], @@ -192,7 +204,11 @@ export class AcpRuntime implements LocalAgentRuntime { finalResponse, items: updates, }; + } finally { + input.signal?.removeEventListener("abort", onAbort); + } } finally { + this.sessionCallbacks.delete(sessionId); if (promptId) this.grokCompletionRegistry?.remove(sessionId, promptId); this.activeSessions.delete(sessionId); } @@ -221,6 +237,7 @@ export class AcpRuntime implements LocalAgentRuntime { this.liveSessions.clear(); this.sessionWriteModes.clear(); this.sessionMetadata.clear(); + this.sessionCallbacks.clear(); this.activeSessions.clear(); this.grokCompletionRegistry?.rejectAll(new Error(`${this.provider} ACP runtime closed.`)); this.connection.close(new Error(`${this.provider} ACP runtime closed.`)); @@ -488,6 +505,7 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { try { const { client, methods, ndJsonStream } = await import("@agentclientprotocol/sdk"); const queues = new Map(); + const sessionCallbacks = new Map(); const sessionWriteModes = new Map(); const grokCompletionRegistry = this.provider === "grok" ? new GrokPromptCompletionRegistry() @@ -503,7 +521,10 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { .onNotification(methods.client.session.update, (context) => { const sessionId = context.params.sessionId; const queue = queues.get(sessionId); - if (queue) appendAcpQueueValue(queue, context.params); + if (queue) { + appendAcpQueueValue(queue, context.params); + observeAcpUpdate(context.params, sessionCallbacks.get(sessionId)); + } }); if (grokCompletionRegistry) { for (const method of [ @@ -542,6 +563,7 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { child, capabilities, queues, + sessionCallbacks, sessionWriteModes, grokCompletionRegistry, }, connection); diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 03a5cc40..d3b7b815 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -1,3 +1,8 @@ +import { + localAgentProviderEnvironment, + type SubagentsConfig, +} from "./local-agent-config.js"; +import type { LocalAgentProvider } from "./local-agent-profiles.js"; import { AcpLocalAgentDriver, resolveAcpCommand, @@ -27,6 +32,7 @@ export type LocalAgentAdapter = LocalAgentDriver; export interface LocalAgentDriverOptions { env?: NodeJS.ProcessEnv; + subagents?: SubagentsConfig; claudeQueryFactory?: ClaudeQueryFactory; opencodeFactory?: OpencodeFactory; piSessionFactory?: PiSessionFactory; @@ -35,14 +41,18 @@ export interface LocalAgentDriverOptions { export function createLocalAgentDrivers( options: LocalAgentDriverOptions = {}, ): LocalAgentDriver[] { + const env = options.env ?? process.env; + const providerEnv = (provider: LocalAgentProvider) => options.subagents + ? localAgentProviderEnvironment(options.subagents, provider, env) + : env; return [ - new CodexLocalAgentDriver(options.env), - new ClaudeLocalAgentDriver(options.claudeQueryFactory, options.env), + new CodexLocalAgentDriver(providerEnv("codex")), + new ClaudeLocalAgentDriver(options.claudeQueryFactory, providerEnv("claude")), new OpencodeLocalAgentDriver(options.opencodeFactory), new PiLocalAgentDriver(options.piSessionFactory), - new AcpLocalAgentDriver("cursor", options.env), - new AcpLocalAgentDriver("copilot", options.env), - new AcpLocalAgentDriver("grok", options.env), + new AcpLocalAgentDriver("cursor", providerEnv("cursor")), + new AcpLocalAgentDriver("copilot", providerEnv("copilot")), + new AcpLocalAgentDriver("grok", providerEnv("grok")), ]; } diff --git a/src/local-agent-availability.test.ts b/src/local-agent-availability.test.ts index 7e0ebc1c..b9a380b7 100644 --- a/src/local-agent-availability.test.ts +++ b/src/local-agent-availability.test.ts @@ -1,4 +1,7 @@ import assert from "node:assert/strict"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { getLocalAgentProviderAvailabilitySnapshot } from "./local-agent-availability.js"; const snapshot = getLocalAgentProviderAvailabilitySnapshot({ @@ -10,3 +13,36 @@ assert.deepEqual(snapshot.find((provider) => provider.name === "codex"), { available: false, reason: "/definitely/missing/devspace-codex executable not found", }); + +{ + const directory = mkdtempSync(join(tmpdir(), "devspace-provider-command-")); + const executable = join(directory, "codex-wrapper"); + try { + writeFileSync(executable, "#!/bin/sh\nexit 0\n"); + chmodSync(executable, 0o700); + const availability = getLocalAgentProviderAvailabilitySnapshot( + { + ...process.env, + CODEX_COMMAND: "/definitely/missing/devspace-codex", + OPENAI_API_KEY: "must-not-appear", + }, + { + enabled: true, + providers: [{ + id: "codex", + enabled: true, + command: executable, + env: { OPENAI_API_KEY: "configured-secret", EMPTY_VALUE: "" }, + }], + }, + ).find((provider) => provider.name === "codex"); + assert.deepEqual(availability, { + name: "codex", + available: true, + note: "available", + }); + assert.doesNotMatch(JSON.stringify(availability), /configured-secret|must-not-appear/); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index 3a67b98f..05a932a8 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -4,6 +4,10 @@ import { LOCAL_AGENT_PROVIDERS, type LocalAgentProvider, } from "./local-agent-profiles.js"; +import { + localAgentProviderEnvironment, + type SubagentsConfig, +} from "./local-agent-config.js"; export interface LocalAgentProviderAvailability { name: LocalAgentProvider; @@ -14,37 +18,45 @@ export interface LocalAgentProviderAvailability { export function getLocalAgentProviderAvailabilitySnapshot( env: NodeJS.ProcessEnv = process.env, + config?: SubagentsConfig, ): LocalAgentProviderAvailability[] { - return LOCAL_AGENT_PROVIDERS.map((provider) => checkLocalAgentProviderAvailability(provider, env)); + return LOCAL_AGENT_PROVIDERS.map((provider) => ( + checkLocalAgentProviderAvailability(provider, env, config) + )); } function checkLocalAgentProviderAvailability( provider: LocalAgentProvider, env: NodeJS.ProcessEnv = process.env, + config?: SubagentsConfig, ): LocalAgentProviderAvailability { + const providerEnv = config ? localAgentProviderEnvironment(config, provider, env) : env; switch (provider) { case "codex": - return codexAvailability(env); + return codexAvailability(providerEnv); case "claude": - return packageAvailability(provider, "@anthropic-ai/claude-agent-sdk"); + return providerEnv.CLAUDE_COMMAND + ? commandAvailability(provider, providerEnv.CLAUDE_COMMAND, providerEnv) + : packageAvailability(provider, "@anthropic-ai/claude-agent-sdk"); case "opencode": return packageAvailability(provider, "@opencode-ai/sdk/v2"); case "pi": return packageAvailability(provider, "@earendil-works/pi-coding-agent"); case "cursor": - return commandAvailability(provider, env.CURSOR_COMMAND ?? "cursor-agent", env); + return commandAvailability(provider, providerEnv.CURSOR_COMMAND ?? "cursor-agent", providerEnv); case "copilot": - return commandAvailability(provider, env.COPILOT_COMMAND ?? "copilot", env); + return commandAvailability(provider, providerEnv.COPILOT_COMMAND ?? "copilot", providerEnv); case "grok": - return commandAvailability(provider, env.GROK_COMMAND ?? "grok", env); + return commandAvailability(provider, providerEnv.GROK_COMMAND ?? "grok", providerEnv); } } export function assertLocalAgentProviderAvailable( provider: LocalAgentProvider, env: NodeJS.ProcessEnv = process.env, + config?: SubagentsConfig, ): void { - const availability = checkLocalAgentProviderAvailability(provider, env); + const availability = checkLocalAgentProviderAvailability(provider, env, config); if (availability.available) return; throw new Error( `${provider} provider is not available: ${availability.reason ?? "provider preflight failed"}`, diff --git a/src/local-agent-capabilities.ts b/src/local-agent-capabilities.ts new file mode 100644 index 00000000..9dce45e5 --- /dev/null +++ b/src/local-agent-capabilities.ts @@ -0,0 +1,8 @@ +import type { LocalAgentProvider } from "./local-agent-profiles.js"; + +export function supportsNativeStructuredOutput(provider: LocalAgentProvider): boolean { + // The daemon contract currently carries text prompts and responses. Workflow + // schemas therefore use provider-independent prompt repair until structured + // output becomes a first-class daemon capability. + return false; +} diff --git a/src/local-agent-claude.test.ts b/src/local-agent-claude.test.ts index e8b3d506..14e36400 100644 --- a/src/local-agent-claude.test.ts +++ b/src/local-agent-claude.test.ts @@ -5,6 +5,8 @@ import { type ClaudeQueryLike, type ClaudeUserMessage, } from "./local-agent-claude.js"; +import { createLocalAgentDrivers } from "./local-agent-adapters.js"; +import { subagentsConfigSchema } from "./local-agent-config.js"; import type { LocalAgentRuntimeContext } from "./local-agent-runtime.js"; class FakeClaudeQuery implements ClaudeQueryLike, AsyncIterator { @@ -230,3 +232,39 @@ await assert.rejects( TypeError, "programmer defects must not be reclassified as provider failures", ); + +let configuredOptions: Record | undefined; +const configuredDriver = createLocalAgentDrivers({ + env: { + PATH: "/usr/bin", + CLAUDE_COMMAND: "/usr/bin/claude", + ANTHROPIC_API_KEY: "inherited", + INHERITED: "yes", + }, + subagents: subagentsConfigSchema.parse({ + enabled: true, + providers: [{ + id: "claude", + enabled: true, + command: "/opt/bin/claude-wrapper", + env: { ANTHROPIC_API_KEY: "configured", EMPTY_VALUE: "" }, + }], + }), + claudeQueryFactory: ({ prompt, options }) => { + configuredOptions = options; + return new FakeClaudeQuery(prompt); + }, +}).find((driver) => driver.provider === "claude"); +assert.ok(configuredDriver); +const configuredRuntime = await configuredDriver.createRuntime(context); +assert.equal(configuredRuntime.isOk(), true); +if (configuredRuntime.isErr()) throw configuredRuntime.error; +assert.equal(configuredOptions?.pathToClaudeCodeExecutable, "/opt/bin/claude-wrapper"); +assert.deepEqual(configuredOptions?.env, { + PATH: "/usr/bin", + CLAUDE_COMMAND: "/opt/bin/claude-wrapper", + ANTHROPIC_API_KEY: "configured", + INHERITED: "yes", + EMPTY_VALUE: "", +}); +await configuredRuntime.value.close(); diff --git a/src/local-agent-claude.ts b/src/local-agent-claude.ts index a639f2f4..13bef235 100644 --- a/src/local-agent-claude.ts +++ b/src/local-agent-claude.ts @@ -13,8 +13,10 @@ import type { LocalAgentRunResult, LocalAgentRuntime, LocalAgentRuntimeContext, + LocalAgentUsageSnapshot, LocalAgentWriteMode, } from "./local-agent-runtime.js"; +import { observeClaudeMessage } from "./local-agent-observation.js"; type ClaudePermissionMode = "default" | "acceptEdits" | "bypassPermissions" | "plan" | "dontAsk" | "auto"; @@ -28,6 +30,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; @@ -124,71 +127,82 @@ 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); + let usage: LocalAgentUsageSnapshot | undefined; + const onAbort = () => { + void this.query.interrupt?.().catch(() => undefined); + }; + input.signal?.addEventListener("abort", onAbort, { once: true }); + if (input.signal?.aborted) onAbort(); + try { + 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); + usage = observeClaudeMessage(message, usage, callbacks); + 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.", + }); + } + 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); } }, }); diff --git a/src/local-agent-client.ts b/src/local-agent-client.ts index 01f8c1cd..2f9fd89d 100644 --- a/src/local-agent-client.ts +++ b/src/local-agent-client.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import { matchError, Result, type Result as BetterResult } from "better-result"; import type { ServerConfig } from "./config.js"; import { + AgentDaemonConfigChangedError, AgentDaemonInvalidRequestError, AgentDaemonInvalidResponseError, AgentDaemonProtocolMismatchError, @@ -22,6 +23,8 @@ import { import { decodeAgentRecord, decodeAgentRecordList, + decodeAgentWaitResults, + decodeDaemonHello, decodeDaemonLogs, decodeDaemonStatus, decodeLocalAgentDaemonResponse, @@ -32,6 +35,7 @@ import { type LocalAgentDaemonResponse, type LocalAgentDaemonStatus, } from "./local-agent-daemon-protocol.js"; +import { localAgentProviderConfigRevision } from "./local-agent-config.js"; import { LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ensureLocalAgentDaemonSecret, @@ -42,9 +46,12 @@ import { } from "./local-agent-daemon-lifecycle.js"; import type { AgentContinueError, + AgentCancelError, AgentListError, AgentLookupError, AgentStartError, + AgentWaitError, + LocalAgentWaitResult, RunOverrides, StartLocalAgentInput, } from "./local-agent-manager.js"; @@ -58,12 +65,15 @@ const RETRY_DELAY_MS = 40; type RequestError = M extends "agent.start" ? AgentStartError | AgentDaemonError : M extends "agent.continue" ? AgentContinueError | AgentDaemonError + : M extends "agent.cancel" ? AgentCancelError | AgentDaemonError : M extends "agent.get" ? AgentLookupError | AgentDaemonError : M extends "agent.list" ? AgentListError | AgentDaemonError + : M extends "agent.wait" ? AgentWaitError | AgentDaemonError : AgentDaemonError; export interface LocalAgentClientOptions { stateDir: string; + configRevision: string; configDir?: string; startupTimeoutMs?: number; requestTimeoutMs?: number; @@ -74,6 +84,7 @@ export interface LocalAgentClientOptions { export class LocalAgentClient { private readonly stateDir: string; private readonly paths: LocalAgentDaemonPaths; + private readonly configRevision: string; private readonly endpoint: string; private readonly startupTimeoutMs: number; private readonly requestTimeoutMs: number; @@ -82,6 +93,7 @@ export class LocalAgentClient { constructor(options: LocalAgentClientOptions) { this.stateDir = options.stateDir; + this.configRevision = options.configRevision; this.paths = localAgentDaemonPaths(options.stateDir); this.endpoint = options.endpoint ?? this.paths.endpoint; this.startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS; @@ -127,6 +139,14 @@ export class LocalAgentClient { return decodeRequestResult(result, "agent.get", decodeAgentRecord); } + async cancel( + agentId: string, + scope: LocalAgentWorkspaceScope, + ): Promise> { + const result = await this.request("agent.cancel", { id: agentId, scope }); + return decodeRequestResult(result, "agent.cancel", decodeAgentRecord); + } + async list( scope: LocalAgentWorkspaceScope, ): Promise> { @@ -134,6 +154,22 @@ export class LocalAgentClient { return decodeRequestResult(result, "agent.list", decodeAgentRecordList); } + async wait( + agentIds: readonly string[], + scope: LocalAgentWorkspaceScope, + timeoutMs?: number, + ): Promise> { + const transportTimeoutMs = timeoutMs === undefined + ? null + : Math.min(2_147_483_647, timeoutMs + this.requestTimeoutMs); + const result = await this.request("agent.wait", { + ids: [...agentIds], + scope, + ...(timeoutMs === undefined ? {} : { timeoutMs }), + }, transportTimeoutMs); + return decodeRequestResult(result, "agent.wait", decodeAgentWaitResults); + } + async status(): Promise> { const result = await this.requestExisting("daemon.status", {}); return decodeRequestResult(result, "daemon.status", decodeDaemonStatus); @@ -206,6 +242,7 @@ export class LocalAgentClient { authToken: authToken.value, method: "hello", params: {}, + configRevision: this.configRevision, }, this.requestTimeoutMs); if (response.isErr()) { if ( @@ -233,8 +270,28 @@ export class LocalAgentClient { } return error.code === "DAEMON_UNAVAILABLE" ? Result.ok(undefined) : Result.err(error); } - const decoded = decodeValue(response.value.result, "hello", decodeDaemonStatus); - return decoded.map((status) => status.state === "ready" ? status : undefined); + const decoded = decodeValue(response.value.result, "hello", decodeDaemonHello); + if (decoded.isErr()) return decoded; + if (!decoded.value.configMatches) { + return this.replaceIdleChangedDaemon(authToken.value, decoded.value.status); + } + return Result.ok(decoded.value.status.state === "ready" ? decoded.value.status : undefined); + } + + private async replaceIdleChangedDaemon( + authToken: string, + status: LocalAgentDaemonStatus, + ): Promise> { + const changed = new AgentDaemonConfigChangedError({ + code: "DAEMON_CONFIG_CHANGED", + operation: "startup", + retryable: true, + message: status.activeTurns > 0 + ? "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); + return this.stopIdleDaemon(authToken, LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, status, changed); } private async replaceIdleOlderDaemon( @@ -248,6 +305,7 @@ export class LocalAgentClient { authToken, method: "hello", params: {}, + configRevision: this.configRevision, }, this.requestTimeoutMs); if (statusResponse.isErr() || !statusResponse.value.ok) return Result.err(mismatch); const status = decodeValue(statusResponse.value.result, "hello", decodeDaemonStatus); @@ -262,14 +320,27 @@ export class LocalAgentClient { })); } + return this.stopIdleDaemon(authToken, protocolVersion, status.value, mismatch); + } + + private async stopIdleDaemon( + authToken: string, + protocolVersion: number, + status: LocalAgentDaemonStatus, + cause: AgentDaemonProtocolMismatchError | AgentDaemonConfigChangedError, + ): Promise> { const stopResponse = await sendRequest(this.endpoint, { requestId: randomUUID(), protocolVersion, authToken, method: "daemon.stop", - params: {}, + // Older daemons do not support atomic idle replacement. Their existing + // best-effort upgrade path remains available through the legacy shape. + params: protocolVersion === LOCAL_AGENT_DAEMON_PROTOCOL_VERSION + ? { ifIdle: true } + : {}, }, this.requestTimeoutMs); - if (stopResponse.isErr() || !stopResponse.value.ok) return Result.err(mismatch); + if (stopResponse.isErr() || !stopResponse.value.ok) return Result.err(cause); const deadline = Date.now() + this.startupTimeoutMs; while (Date.now() < deadline) { @@ -280,34 +351,40 @@ export class LocalAgentClient { authToken, method: "hello", params: {}, + configRevision: this.configRevision, }, Math.min(this.requestTimeoutMs, 250)); if (probe.isErr() && probe.error.code === "DAEMON_UNAVAILABLE") { - if (!existsSync(this.paths.lockPath) || !isProcessAlive(status.value.pid)) { + if (!existsSync(this.paths.lockPath) || !isProcessAlive(status.pid)) { return Result.ok(undefined); } continue; } - if ( - probe.isOk() - && probe.value.protocolVersion >= LOCAL_AGENT_DAEMON_PROTOCOL_VERSION - ) { + if (probe.isOk() && probe.value.protocolVersion > protocolVersion) { // Another client completed the replacement while this client was // waiting for the old endpoint to disappear. return this.tryHello(); } + if (probe.isOk() && probe.value.ok && protocolVersion === LOCAL_AGENT_DAEMON_PROTOCOL_VERSION) { + const hello = decodeValue(probe.value.result, "hello", decodeDaemonHello); + if (hello.isErr()) return hello; + if (hello.value.configMatches && hello.value.status.state === "ready") { + return Result.ok(hello.value.status); + } + } } return Result.err(new AgentDaemonStartupError({ code: "DAEMON_STARTUP_FAILURE", operation: "startup", retryable: true, - cause: mismatch, - message: "The older local agent daemon did not stop in time for the upgrade.", + cause, + message: "The local agent daemon did not stop in time for replacement.", })); } private async request( method: M, params: Extract['params'], + timeoutMs: number | null = this.requestTimeoutMs, ): Promise>> { const ready = await this.ensureReady(); if (ready.isErr()) return ready as BetterResult>; @@ -319,7 +396,7 @@ export class LocalAgentClient { authToken: authToken.value, method, params, - } as LocalAgentDaemonRequest, this.requestTimeoutMs); + } as LocalAgentDaemonRequest, timeoutMs ?? undefined); if (response.isErr()) return response as BetterResult>; if (!response.value.ok) { const error = decodeRemoteError(response.value.error, method); @@ -409,9 +486,13 @@ export class LocalAgentClient { } export function createLocalAgentClient( - config: Pick, + config: Pick, ): LocalAgentClient { - return new LocalAgentClient({ configDir: config.configDir, stateDir: config.stateDir }); + return new LocalAgentClient({ + configDir: config.configDir, + stateDir: config.stateDir, + configRevision: localAgentProviderConfigRevision(config.subagents), + }); } export function spawnLocalAgentDaemon( @@ -459,20 +540,22 @@ export function resolveDaemonEntrypoint(): string { async function sendRequest( endpoint: string, request: LocalAgentDaemonRequest, - timeoutMs: number, + timeoutMs?: number, ): Promise> { return new Promise((resolve) => { const socket = createConnection(endpoint); let buffer = ""; let settled = false; - const timer = setTimeout(() => { - finish(Result.err(new AgentDaemonTimeoutError({ - code: "DAEMON_TIMEOUT", - operation: request.method, - retryable: true, - message: "Timed out waiting for the local agent daemon.", - })), true); - }, timeoutMs); + const timer = timeoutMs === undefined + ? undefined + : setTimeout(() => { + finish(Result.err(new AgentDaemonTimeoutError({ + code: "DAEMON_TIMEOUT", + operation: request.method, + retryable: true, + message: "Timed out waiting for the local agent daemon.", + })), true); + }, timeoutMs); const finish = ( result: BetterResult, @@ -480,7 +563,7 @@ async function sendRequest( ) => { if (settled) return; settled = true; - clearTimeout(timer); + if (timer) clearTimeout(timer); if (destroy) socket.destroy(); resolve(result); }; @@ -584,6 +667,7 @@ function isRequestError( AgentDaemonStartupError: () => "daemon" as const, AgentDaemonTimeoutError: () => "daemon" as const, AgentDaemonProtocolMismatchError: () => "daemon" as const, + AgentDaemonConfigChangedError: () => "daemon" as const, AgentDaemonUnauthorizedError: () => "daemon" as const, AgentDaemonInvalidRequestError: () => "daemon" as const, AgentDaemonInvalidResponseError: () => "daemon" as const, @@ -599,6 +683,8 @@ function isRequestError( || category === "conflict" || category === "store"; case "agent.get": + case "agent.cancel": + case "agent.wait": return category === "target" || category === "scope" || category === "store"; case "agent.list": return category === "scope" || category === "store"; diff --git a/src/local-agent-codex.ts b/src/local-agent-codex.ts index dac27cc3..4cf98c0f 100644 --- a/src/local-agent-codex.ts +++ b/src/local-agent-codex.ts @@ -9,6 +9,7 @@ import { captureAgentProviderResult, } from "./local-agent-errors.js"; import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; +import { observeCodexEvent } from "./local-agent-observation.js"; import { terminateProcessTree } from "./process-platform.js"; import type { LocalAgentDriver, @@ -146,7 +147,12 @@ 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), + callbacks, + input.signal, + ); const parsed = parseCompletedTurn(completed.event.params, completed.items); if (parsed.failure) { throw new AgentProviderExecutionError({ @@ -320,6 +326,7 @@ interface CodexTurnAccumulator { turnId?: string; items: unknown[]; completed?: CodexEvent; + callbacks?: LocalAgentRunCallbacks; resolve: (result: CodexTurnResult) => void; reject: (error: Error) => void; } @@ -359,7 +366,12 @@ class CodexAppServerRpc { this.write({ method, ...(params === undefined ? {} : { params }) }); } - async runTurn(threadId: string, params: unknown): Promise { + async runTurn( + threadId: string, + params: unknown, + callbacks?: LocalAgentRunCallbacks, + signal?: AbortSignal, + ): Promise { 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; @@ -371,16 +383,29 @@ class CodexAppServerRpc { const turn: CodexTurnAccumulator = { threadId, items: [], + callbacks, resolve: resolveTurn, reject: rejectTurn, }; this.turns.set(threadId, turn); + let started = false; + const onAbort = () => { + if (!started) return; + if (turn.turnId) { + void this.request("turn/interrupt", { threadId, turnId: turn.turnId }).catch(() => undefined); + } + turn.reject(abortError("Codex turn interrupted.")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); try { const response = await this.request("turn/start", params); turn.turnId = readString(asRecord(response)?.turn, "id"); + started = true; + 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); } } @@ -430,6 +455,7 @@ class CodexAppServerRpc { const turn = this.findTurn(event); if (!turn) return; const params = asRecord(event.params); + observeCodexEvent(event.method, event.params, turn.callbacks); if (params?.item !== undefined) { turn.items.push(params.item); if (turn.items.length > MAX_TURN_ITEMS) turn.items.shift(); @@ -451,6 +477,10 @@ class CodexAppServerRpc { } } +function abortError(message: string): Error { + return Object.assign(new Error(message), { name: "AbortError" }); +} + function threadParams(input: LocalAgentRunInput): Record { return { ...(input.providerSessionId ? { threadId: input.providerSessionId } : {}), diff --git a/src/local-agent-config.test.ts b/src/local-agent-config.test.ts index e37ceafa..237f92b7 100644 --- a/src/local-agent-config.test.ts +++ b/src/local-agent-config.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import { isSubagentProviderEnabled, + localAgentProviderConfigRevision, + localAgentProviderEnvironment, subagentProviderConfig, subagentsConfigSchema, } from "./local-agent-config.js"; @@ -8,14 +10,28 @@ import { const config = subagentsConfigSchema.parse({ enabled: true, providers: [ - { id: "codex", enabled: true, model: " gpt-5.4 ", effort: " high " }, + { + id: "codex", + enabled: true, + model: " gpt-5.4 ", + effort: " high ", + command: " /opt/bin/codex-wrapper ", + env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" }, + }, { id: "claude", enabled: false, model: "sonnet" }, ], }); assert.deepEqual(config, { enabled: true, providers: [ - { id: "codex", enabled: true, model: "gpt-5.4", effort: "high" }, + { + id: "codex", + enabled: true, + model: "gpt-5.4", + effort: "high", + command: "/opt/bin/codex-wrapper", + env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" }, + }, { id: "claude", enabled: false, model: "sonnet" }, ], }); @@ -24,6 +40,49 @@ assert.equal(isSubagentProviderEnabled(config, "claude"), false); assert.equal(isSubagentProviderEnabled(config, "pi"), false); assert.equal(subagentProviderConfig(config, "codex")?.model, "gpt-5.4"); +const inherited = { + CODEX_COMMAND: "/usr/bin/codex", + OPENAI_API_KEY: "inherited", + UNCHANGED: "yes", +}; +assert.deepEqual(localAgentProviderEnvironment(config, "codex", inherited), { + CODEX_COMMAND: "/opt/bin/codex-wrapper", + OPENAI_API_KEY: "configured", + EMPTY_VALUE: "", + UNCHANGED: "yes", +}); +assert.deepEqual(inherited, { + CODEX_COMMAND: "/usr/bin/codex", + OPENAI_API_KEY: "inherited", + UNCHANGED: "yes", +}); +assert.equal( + localAgentProviderConfigRevision(config), + localAgentProviderConfigRevision(subagentsConfigSchema.parse({ + enabled: true, + providers: [ + { id: "claude", enabled: false, model: "sonnet" }, + { + id: "codex", + enabled: true, + effort: "high", + model: "gpt-5.4", + command: "/opt/bin/codex-wrapper", + env: { EMPTY_VALUE: "", OPENAI_API_KEY: "configured" }, + }, + ], + })), + "provider and environment key order must not restart the daemon", +); +assert.notEqual( + localAgentProviderConfigRevision(config), + localAgentProviderConfigRevision(subagentsConfigSchema.parse({ + ...config, + providers: config.providers.map((provider) => provider.id === "codex" + ? { ...provider, command: "/opt/bin/another-wrapper" } + : provider), + })), +); assert.throws( () => subagentsConfigSchema.parse({ enabled: true, @@ -45,3 +104,26 @@ assert.throws( }), /Too small/, ); +assert.throws( + () => subagentsConfigSchema.parse({ + enabled: true, + providers: [{ id: "codex", enabled: true, command: " " }], + }), + /non-whitespace character/, +); +assert.throws( + () => subagentsConfigSchema.parse({ + enabled: true, + providers: [{ id: "codex", enabled: true, env: { "INVALID-NAME": "value" } }], + }), + /Invalid environment variable name/, +); +for (const id of ["opencode", "pi"] as const) { + assert.throws( + () => subagentsConfigSchema.parse({ + enabled: true, + providers: [{ id, enabled: true, command: "/opt/bin/agent" }], + }), + new RegExp(`${id} is embedded and does not support command or env configuration`), + ); +} diff --git a/src/local-agent-config.ts b/src/local-agent-config.ts index 62e9c35a..572b2dfd 100644 --- a/src/local-agent-config.ts +++ b/src/local-agent-config.ts @@ -1,15 +1,34 @@ +import { createHash } from "node:crypto"; import * as z from "zod/v4"; import { LOCAL_AGENT_PROVIDERS, type LocalAgentProvider, } from "./local-agent-profiles.js"; +const environmentSchema = z.record( + z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "Invalid environment variable name"), + z.string(), +); + const providerSchema = z.object({ id: z.enum(LOCAL_AGENT_PROVIDERS as [LocalAgentProvider, ...LocalAgentProvider[]]), enabled: z.boolean(), model: z.string().trim().min(1).optional(), effort: z.string().trim().min(1).optional(), -}).strict(); + command: z.string() + .regex(/\S/, "Command must contain a non-whitespace character") + .trim() + .min(1) + .optional(), + env: environmentSchema.optional(), +}).strict().superRefine((value, context) => { + if ((value.id === "opencode" || value.id === "pi") && (value.command || value.env)) { + context.addIssue({ + code: "custom", + message: `${value.id} is embedded and does not support command or env configuration.`, + }); + } +}); export const subagentsConfigSchema = z.object({ enabled: z.boolean(), @@ -50,3 +69,50 @@ export function isSubagentProviderEnabled( ): boolean { return config.enabled && subagentProviderConfig(config, provider)?.enabled === true; } + +export function localAgentProviderEnvironment( + config: SubagentsConfig, + provider: LocalAgentProvider, + inherited: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const providerConfig = subagentProviderConfig(config, provider); + const env = { ...inherited, ...providerConfig?.env }; + const commandVariable = providerCommandVariable(provider); + if (commandVariable && providerConfig?.command) env[commandVariable] = providerConfig.command; + return env; +} + +export function providerCommandVariable(provider: LocalAgentProvider): string | undefined { + switch (provider) { + case "codex": return "CODEX_COMMAND"; + case "claude": return "CLAUDE_COMMAND"; + case "cursor": return "CURSOR_COMMAND"; + case "copilot": return "COPILOT_COMMAND"; + case "grok": return "GROK_COMMAND"; + case "opencode": + case "pi": + return undefined; + } +} + +export function localAgentProviderConfigRevision(config: SubagentsConfig): string { + const providers = [...config.providers] + .sort((left, right) => left.id.localeCompare(right.id)) + .map((provider) => ({ + id: provider.id, + enabled: provider.enabled, + ...(provider.model ? { model: provider.model } : {}), + ...(provider.effort ? { effort: provider.effort } : {}), + ...(provider.command ? { command: provider.command } : {}), + ...(provider.env && Object.keys(provider.env).length > 0 + ? { + env: Object.fromEntries( + Object.entries(provider.env).sort(([left], [right]) => left.localeCompare(right)), + ), + } + : {}), + })); + return createHash("sha256") + .update(JSON.stringify({ enabled: config.enabled, providers })) + .digest("hex"); +} diff --git a/src/local-agent-daemon-lifecycle.ts b/src/local-agent-daemon-lifecycle.ts index df0b81b9..82573c77 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 = 3; +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 b1e0e09d..08586d5a 100644 --- a/src/local-agent-daemon-main.ts +++ b/src/local-agent-daemon-main.ts @@ -10,6 +10,7 @@ import { 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"; const config = loadConfig(); const DEFAULT_DAEMON_SHUTDOWN_TIMEOUT_MS = 10_000; @@ -22,17 +23,18 @@ const log = ( const store = new LocalAgentStore(paths.stateDir); const manager = new LocalAgentManager({ store, - drivers: createLocalAgentDrivers(), + drivers: createLocalAgentDrivers({ subagents: config.subagents }), pool: new LocalAgentRuntimePool({ logger: log }), loadProfiles: (workspaceRoot) => loadLocalAgentProfiles(config, workspaceRoot, { includeDisabled: true }), agentDir: config.agentDir, - allowedRoots: config.allowedRoots, + allowedRoots: [...config.allowedRoots, config.worktreeRoot], logger: log, subagents: config.subagents, }); const daemon = new LocalAgentDaemon({ stateDir: paths.stateDir, manager, + configRevision: localAgentProviderConfigRevision(config.subagents), onLockAcquired: () => { const reconciled = manager.reconcileActiveRuns(); if (reconciled.isErr()) throw reconciled.error; diff --git a/src/local-agent-daemon-protocol.test.ts b/src/local-agent-daemon-protocol.test.ts index 70818098..9e808e11 100644 --- a/src/local-agent-daemon-protocol.test.ts +++ b/src/local-agent-daemon-protocol.test.ts @@ -1,15 +1,18 @@ import assert from "node:assert/strict"; import { decodeAgentRecord, + decodeAgentWaitResults, + decodeDaemonHello, decodeLocalAgentDaemonRequest, decodeLocalAgentDaemonResponse, encodeLocalAgentDaemonResponse, LocalAgentDaemonProtocolError, } from "./local-agent-daemon-protocol.js"; +import { LOCAL_AGENT_DAEMON_PROTOCOL_VERSION } from "./local-agent-daemon-lifecycle.js"; const request = decodeLocalAgentDaemonRequest({ requestId: "req_1", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, authToken: "test-secret", method: "agent.start", params: { @@ -26,7 +29,7 @@ assert.equal(request.params.writeMode, "read_only"); const whitespaceRequest = decodeLocalAgentDaemonRequest({ requestId: "req_whitespace", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, authToken: "test-secret", method: "agent.start", params: { @@ -41,7 +44,7 @@ assert.equal(whitespaceRequest.params.prompt, " keep prompt whitespace \n"); const directRequest = decodeLocalAgentDaemonRequest({ requestId: "req_direct", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, authToken: "test-secret", method: "agent.start", params: { @@ -53,10 +56,69 @@ const directRequest = decodeLocalAgentDaemonRequest({ if (directRequest.method !== "agent.start") throw new Error("expected agent.start request"); assert.equal(directRequest.params.workspaceId, undefined); +const cancelRequest = decodeLocalAgentDaemonRequest({ + requestId: "req_cancel", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + authToken: "test-secret", + method: "agent.cancel", + params: { + id: "agt_1234", + scope: { workspaceId: "ws_test", workspaceRoot: "/tmp/project" }, + }, +}); +assert.equal(cancelRequest.method, "agent.cancel"); + +const helloRequest = decodeLocalAgentDaemonRequest({ + requestId: "req_hello", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + authToken: "test-secret", + method: "hello", + params: {}, + configRevision: "provider-config-revision", +}); +assert.equal(helloRequest.method, "hello"); +if (helloRequest.method !== "hello") throw new Error("expected hello request"); +assert.equal(helloRequest.configRevision, "provider-config-revision"); +const conditionalStop = decodeLocalAgentDaemonRequest({ + requestId: "req_stop", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + authToken: "test-secret", + method: "daemon.stop", + params: { ifIdle: true }, +}); +assert.equal(conditionalStop.method, "daemon.stop"); +if (conditionalStop.method !== "daemon.stop") throw new Error("expected daemon.stop request"); +assert.equal(conditionalStop.params.ifIdle, true); +assert.deepEqual(decodeDaemonHello({ + status: { + state: "ready", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + pid: 123, + endpoint: "/tmp/agentd.sock", + startedAt: "now", + activeTurns: 0, + runtimeCount: 0, + clientConnections: 1, + }, + configMatches: false, +}), { + status: { + state: "ready", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + pid: 123, + endpoint: "/tmp/agentd.sock", + startedAt: "now", + activeTurns: 0, + runtimeCount: 0, + clientConnections: 1, + }, + configMatches: false, +}); + assert.throws( () => decodeLocalAgentDaemonRequest({ requestId: "req_2", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, authToken: "test-secret", method: "agent.start", params: { target: "reviewer", prompt: "" }, @@ -72,18 +134,22 @@ const record = decodeAgentRecord({ provider: "codex", status: "idle", latestResponse: " response whitespace \n", + usage: { inputTokens: 8, outputTokens: 2, totalTokens: 10, state: "final" }, + activity: [{ kind: "tool", status: "completed", label: "read" }], createdAt: "now", updatedAt: "now", }); assert.equal(record.id, "agt_1234"); assert.equal(record.latestResponse, " response whitespace \n"); +assert.equal(record.usage?.totalTokens, 10); +assert.deepEqual(record.activity, [{ kind: "tool", status: "completed", label: "read" }]); const directRecord = decodeAgentRecord({ ...record, workspaceId: undefined }); assert.equal(directRecord.workspaceId, undefined); const response = decodeLocalAgentDaemonResponse({ requestId: "req_1", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ok: true, result: record, }); @@ -91,7 +157,7 @@ assert.equal(response.ok, true); const errorResponse = decodeLocalAgentDaemonResponse(JSON.parse(encodeLocalAgentDaemonResponse({ requestId: "req_error", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ok: false, error: { code: "PROVIDER_UNAVAILABLE", @@ -126,3 +192,37 @@ const failedRecord = decodeAgentRecord({ }); assert.equal(failedRecord.errorCode, "DAEMON_TIMEOUT"); assert.equal(failedRecord.errorRetryable, true); + +const waitRequest = decodeLocalAgentDaemonRequest({ + requestId: "req_wait", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + authToken: "test-secret", + method: "agent.wait", + params: { + ids: ["agt_one", "agt_two"], + scope: { workspaceId: "ws_test", workspaceRoot: "/tmp/project" }, + timeoutMs: 5_000, + }, +}); +assert.equal(waitRequest.method, "agent.wait"); +if (waitRequest.method !== "agent.wait") throw new Error("expected agent.wait request"); +assert.deepEqual(waitRequest.params.ids, ["agt_one", "agt_two"]); +assert.equal(waitRequest.params.timeoutMs, 5_000); + +assert.deepEqual(decodeAgentWaitResults([ + { id: "agt_one", status: "completed", response: "Done." }, + { id: "agt_two", status: "running", wait: "timeout" }, + { + id: "agt_three", + status: "failed", + error: { code: "PROVIDER_EXECUTION_ERROR", message: "Failed.", retryable: true }, + }, +]), [ + { id: "agt_one", status: "completed", response: "Done." }, + { id: "agt_two", status: "running", wait: "timeout" }, + { + id: "agt_three", + status: "failed", + error: { code: "PROVIDER_EXECUTION_ERROR", message: "Failed.", retryable: true }, + }, +]); diff --git a/src/local-agent-daemon-protocol.ts b/src/local-agent-daemon-protocol.ts index bf9bf8e6..091e25b8 100644 --- a/src/local-agent-daemon-protocol.ts +++ b/src/local-agent-daemon-protocol.ts @@ -4,30 +4,43 @@ import type { LocalAgentWorkspaceScope, } from "./local-agent-store.js"; import type { + LocalAgentWaitResult, RunOverrides, StartLocalAgentInput, } from "./local-agent-manager.js"; -import type { LocalAgentWriteMode } from "./local-agent-runtime.js"; +import type { + LocalAgentActivity, + LocalAgentUsageSnapshot, + LocalAgentWriteMode, +} from "./local-agent-runtime.js"; import { LOCAL_AGENT_DAEMON_PROTOCOL_VERSION } from "./local-agent-daemon-lifecycle.js"; export type LocalAgentDaemonMethod = | "hello" | "agent.start" | "agent.continue" + | "agent.cancel" | "agent.get" | "agent.list" + | "agent.wait" | "daemon.status" | "daemon.stop" | "daemon.logs"; export type LocalAgentDaemonRequest = - | AgentDaemonRequestBase<"hello", Record> + | (AgentDaemonRequestBase<"hello", Record> & { configRevision?: string }) | AgentDaemonRequestBase<"agent.start", StartLocalAgentInput> | AgentDaemonRequestBase<"agent.continue", { id: string; prompt: string; scope: LocalAgentWorkspaceScope; overrides?: RunOverrides }> + | AgentDaemonRequestBase<"agent.cancel", { id: string; scope: LocalAgentWorkspaceScope }> | AgentDaemonRequestBase<"agent.get", { id: string; scope: LocalAgentWorkspaceScope }> | AgentDaemonRequestBase<"agent.list", LocalAgentWorkspaceScope> + | AgentDaemonRequestBase<"agent.wait", { + ids: string[]; + scope: LocalAgentWorkspaceScope; + timeoutMs?: number; + }> | AgentDaemonRequestBase<"daemon.status", Record> - | AgentDaemonRequestBase<"daemon.stop", Record> + | AgentDaemonRequestBase<"daemon.stop", { ifIdle?: boolean }> | AgentDaemonRequestBase<"daemon.logs", { lines?: number }>; interface AgentDaemonRequestBase< @@ -52,6 +65,11 @@ export interface LocalAgentDaemonStatus { clientConnections: number; } +export interface LocalAgentDaemonHello { + status: LocalAgentDaemonStatus; + configMatches: boolean; +} + export interface LocalAgentDaemonErrorPayload { code: string; message: string; @@ -95,9 +113,24 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR switch (method) { case "hello": + return { + requestId, + protocolVersion, + authToken, + method, + params: decodeEmptyParams(params), + configRevision: optionalString(record?.configRevision), + }; case "daemon.status": - case "daemon.stop": return { requestId, protocolVersion, authToken, method, params: decodeEmptyParams(params) } as LocalAgentDaemonRequest; + case "daemon.stop": + return { + requestId, + protocolVersion, + authToken, + method, + params: decodeStopParams(params), + }; case "agent.start": return { requestId, @@ -115,6 +148,7 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR params: decodeContinueInput(params), } as LocalAgentDaemonRequest; case "agent.get": + case "agent.cancel": return { requestId, protocolVersion, @@ -133,6 +167,14 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR method, params: decodeListScope(params), } as LocalAgentDaemonRequest; + case "agent.wait": + return { + requestId, + protocolVersion, + authToken, + method, + params: decodeWaitParams(params), + } as LocalAgentDaemonRequest; case "daemon.logs": return { requestId, @@ -192,16 +234,106 @@ export function decodeAgentRecord(value: unknown): LocalAgentRecord { error: optionalContentString(record?.error), errorCode: optionalString(record?.errorCode), errorRetryable: optionalBoolean(record?.errorRetryable), + usage: decodeOptionalUsage(record?.usage), + activity: decodeOptionalActivity(record?.activity), createdAt: requiredString(record?.createdAt, "createdAt"), updatedAt: requiredString(record?.updatedAt, "updatedAt"), }; } +function decodeOptionalUsage(value: unknown): LocalAgentUsageSnapshot | undefined { + if (value === undefined) return undefined; + const record = asRecord(value); + const state = record?.state; + const totalTokens = optionalNonNegativeInteger(record?.totalTokens); + if (!record || (state !== "partial" && state !== "final") || totalTokens === undefined) { + throw new LocalAgentDaemonProtocolError("INVALID_RECORD", "Invalid agent token usage."); + } + return { + inputTokens: optionalNonNegativeInteger(record.inputTokens), + cachedInputTokens: optionalNonNegativeInteger(record.cachedInputTokens), + cacheCreationInputTokens: optionalNonNegativeInteger(record.cacheCreationInputTokens), + outputTokens: optionalNonNegativeInteger(record.outputTokens), + totalTokens, + state, + }; +} + +function decodeOptionalActivity(value: unknown): LocalAgentActivity[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) { + throw new LocalAgentDaemonProtocolError("INVALID_RECORD", "Invalid agent activity."); + } + return value.map((item): LocalAgentActivity => { + const record = asRecord(item); + if (!record) { + throw new LocalAgentDaemonProtocolError("INVALID_RECORD", "Invalid agent activity item."); + } + const kind = record?.kind; + const status = record?.status; + if (kind !== "tool" && kind !== "command" && kind !== "file" && kind !== "status") { + throw new LocalAgentDaemonProtocolError("INVALID_RECORD", "Invalid agent activity kind."); + } + if (status !== "running" && status !== "completed" && status !== "failed") { + throw new LocalAgentDaemonProtocolError("INVALID_RECORD", "Invalid agent activity status."); + } + const detail = optionalContentString(record.detail); + const startedAt = optionalString(record.startedAt); + const completedAt = optionalString(record.completedAt); + return { + kind, + status, + label: requiredString(record.label, "activity.label"), + ...(detail === undefined ? {} : { detail }), + ...(startedAt === undefined ? {} : { startedAt }), + ...(completedAt === undefined ? {} : { completedAt }), + }; + }); +} + +function optionalNonNegativeInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined; +} + export function decodeAgentRecordList(value: unknown): LocalAgentRecord[] { if (!Array.isArray(value)) throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned an invalid agent list."); return value.map(decodeAgentRecord); } +export function decodeAgentWaitResults(value: unknown): LocalAgentWaitResult[] { + if (!Array.isArray(value)) { + throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned invalid agent wait results."); + } + return value.map((entry): LocalAgentWaitResult => { + const record = asRecord(entry); + const id = requiredString(record?.id, "id"); + const status = requiredString(record?.status, "status"); + switch (status) { + case "running": { + const wait = optionalString(record?.wait); + if (wait !== undefined && wait !== "timeout") { + throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid agent wait state."); + } + return { id, status, ...(wait ? { wait } : {}) }; + } + case "completed": { + const response = optionalContentString(record?.response); + return { id, status, ...(response === undefined ? {} : { response }) }; + } + case "failed": + return { id, status, error: decodeWaitError(record?.error) }; + case "stopped": + return { + id, + status, + ...(record?.error === undefined ? {} : { error: decodeWaitError(record.error) }), + }; + default: + throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid agent wait result status."); + } + }); +} + export function decodeDaemonStatus(value: unknown): LocalAgentDaemonStatus { const record = asRecord(value); const state = requiredString(record?.state, "state"); @@ -220,6 +352,14 @@ export function decodeDaemonStatus(value: unknown): LocalAgentDaemonStatus { }; } +export function decodeDaemonHello(value: unknown): LocalAgentDaemonHello { + const record = asRecord(value); + return { + status: decodeDaemonStatus(record?.status), + configMatches: requiredBoolean(record?.configMatches, "configMatches"), + }; +} + export function decodeDaemonLogs(value: unknown): string { if (typeof value !== "string") throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned invalid logs."); return value; @@ -282,6 +422,55 @@ function decodeListScope(value: unknown): LocalAgentWorkspaceScope { return decodeWorkspaceScope(value); } +function decodeStopParams(value: unknown): { ifIdle?: boolean } { + const record = asRecord(value); + if (!record) throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Daemon stop options must be an object."); + const ifIdle = optionalBoolean(record.ifIdle); + return ifIdle === undefined ? {} : { ifIdle }; +} + +function decodeWaitParams(value: unknown): { + ids: string[]; + scope: LocalAgentWorkspaceScope; + timeoutMs?: number; +} { + const record = asRecord(value); + if (!record) { + throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Agent wait options must be an object."); + } + const ids = record?.ids; + if (!Array.isArray(ids) || ids.length === 0) { + throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "At least one subagent id is required."); + } + const timeoutMs = record.timeoutMs; + if ( + timeoutMs !== undefined + && (typeof timeoutMs !== "number" + || !Number.isSafeInteger(timeoutMs) + || timeoutMs < 0 + || timeoutMs > 2_147_483_647) + ) { + throw new LocalAgentDaemonProtocolError( + "INVALID_PARAMS", + "Wait timeout must be an integer between 0 and 2147483647 milliseconds.", + ); + } + return { + ids: ids.map((id, index) => requiredString(id, `ids[${index}]`)), + scope: decodeWorkspaceScope(record.scope), + ...(timeoutMs === undefined ? {} : { timeoutMs }), + }; +} + +function decodeWaitError(value: unknown): { code: string; message: string; retryable: boolean } { + const record = asRecord(value); + return { + code: requiredString(record?.code, "error.code"), + message: requiredContentString(record?.message, "error.message"), + retryable: optionalBoolean(record?.retryable) ?? false, + }; +} + function decodeLogsParams(value: unknown): { lines?: number } { if (value === undefined) return {}; const record = asRecord(value); @@ -323,6 +512,13 @@ function requiredInteger(value: unknown, field: string): number { return value; } +function requiredBoolean(value: unknown, field: string): boolean { + if (typeof value !== "boolean") { + throw new LocalAgentDaemonProtocolError("INVALID_PROTOCOL", `Invalid ${field}.`); + } + return value; +} + function optionalString(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index 6ea66e65..529f47e4 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -24,6 +24,7 @@ import type { RunOverrides, StartLocalAgentInput } from "./local-agent-manager.j import type { LocalAgentRecord } from "./local-agent-store.js"; const root = await mkdtemp(join(tmpdir(), "devspace-agentd-test-")); +const CONFIG_REVISION = "test-provider-config"; const record: LocalAgentRecord = { id: "agt_test", workspaceId: "ws_test", @@ -40,12 +41,28 @@ class FakeManager implements LocalAgentDaemonManager { runtimeCount = 0; closed = false; lastInput?: StartLocalAgentInput; + blockWaitUntilAbort = false; + blockStartUntilRelease = false; + startStarted = false; + waitStarted = false; + waitAborted = false; + private releaseStart?: () => void; async start(input: StartLocalAgentInput) { this.lastInput = input; + this.startStarted = true; + if (this.blockStartUntilRelease) { + await new Promise((resolveStart) => { this.releaseStart = resolveStart; }); + this.activeTurnCount = 1; + } return Result.ok(record); } + releaseBlockedStart(): void { + this.releaseStart?.(); + this.releaseStart = undefined; + } + async continue( _agentId: string, _prompt: string, @@ -55,6 +72,13 @@ class FakeManager implements LocalAgentDaemonManager { return Result.ok({ ...record, status: "running" } as LocalAgentRecord); } + async cancel( + _agentId: string, + _scope: { workspaceId?: string; workspaceRoot: string }, + ) { + return Result.ok({ ...record, status: "stopped" } as LocalAgentRecord); + } + get(_id: string, _scope: { workspaceId: string; workspaceRoot: string }) { return Result.ok(record); } @@ -63,6 +87,21 @@ class FakeManager implements LocalAgentDaemonManager { return Result.ok([record]); } + async wait(agentIds: readonly string[], _scope: unknown, _timeoutMs?: number, signal?: AbortSignal) { + this.waitStarted = true; + 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 Result.ok(agentIds.map((id) => ({ id, status: "running" as const }))); + } + async evictIdle(): Promise {} async close(): Promise { @@ -74,11 +113,13 @@ class FakeManager implements LocalAgentDaemonManager { const manager = new FakeManager(); const daemon = new LocalAgentDaemon({ stateDir: join(root, "state"), + configRevision: CONFIG_REVISION, manager, idleShutdownMs: 60_000, }); const client = new LocalAgentClient({ stateDir: join(root, "state"), + configRevision: CONFIG_REVISION, startupTimeoutMs: 2_000, requestTimeoutMs: 2_000, spawnDaemon: () => { void daemon.start(); }, @@ -88,6 +129,7 @@ const missingDaemonStateDir = join(root, "missing-daemon-state"); let diagnosticSpawnCount = 0; const missingDaemonClient = new LocalAgentClient({ stateDir: missingDaemonStateDir, + configRevision: CONFIG_REVISION, startupTimeoutMs: 50, requestTimeoutMs: 50, spawnDaemon: () => { diagnosticSpawnCount += 1; }, @@ -135,6 +177,9 @@ try { const recordScope = { workspaceId: record.workspaceId!, workspaceRoot: record.workspaceRoot }; assert.equal(unwrap(await client.get(record.id, recordScope)).id, record.id); assert.equal(unwrap(await client.list(recordScope))[0]?.id, record.id); + assert.deepEqual(unwrap(await client.wait([record.id], recordScope, 0)), [ + { id: record.id, status: "running" }, + ]); assert.equal(unwrap(await client.status()).state, "ready"); unwrap(await client.stop()); @@ -148,12 +193,14 @@ const idleManager = new FakeManager(); idleManager.activeTurnCount = 0; const idleDaemon = new LocalAgentDaemon({ stateDir: idleStateDir, + configRevision: CONFIG_REVISION, manager: idleManager, idleShutdownMs: 200, idleCheckIntervalMs: 10, }); const idleClient = new LocalAgentClient({ stateDir: idleStateDir, + configRevision: CONFIG_REVISION, startupTimeoutMs: 2_000, requestTimeoutMs: 2_000, spawnDaemon: () => { void idleDaemon.start(); }, @@ -172,11 +219,13 @@ const ownerManager = new FakeManager(); const competingManager = new FakeManager(); const ownerDaemon = new LocalAgentDaemon({ stateDir: ownershipStateDir, + configRevision: CONFIG_REVISION, manager: ownerManager, idleShutdownMs: 60_000, }); const competingDaemon = new LocalAgentDaemon({ stateDir: ownershipStateDir, + configRevision: CONFIG_REVISION, manager: competingManager, idleShutdownMs: 60_000, }); @@ -202,6 +251,7 @@ try { assert.equal(readFileSync(ownerDaemon.paths.pidPath, "utf8"), pidBefore); const ownerClient = new LocalAgentClient({ stateDir: ownershipStateDir, + configRevision: CONFIG_REVISION, spawnDaemon: () => { throw new Error("the winning daemon should already be reachable"); }, }); assert.equal(unwrap(await ownerClient.status()).pid, process.pid); @@ -212,6 +262,7 @@ try { const startupFailureClient = new LocalAgentClient({ stateDir: join(root, "startup-failure-state"), + configRevision: CONFIG_REVISION, startupTimeoutMs: 20, requestTimeoutMs: 10, spawnDaemon: () => { throw new Error("spawn failed"); }, @@ -220,6 +271,125 @@ const startupFailure = await startupFailureClient.ensureReady(); assert.equal(startupFailure.isErr(), true); if (startupFailure.isErr()) assert.equal(startupFailure.error.code, "DAEMON_STARTUP_FAILURE"); +// Keep Unix socket paths below macOS's short sockaddr_un path limit. +const staleIdleStateDir = join(root, "si"); +const staleIdleManager = new FakeManager(); +staleIdleManager.activeTurnCount = 0; +const staleIdleDaemon = new LocalAgentDaemon({ + stateDir: staleIdleStateDir, + configRevision: "old-provider-config", + manager: staleIdleManager, + idleShutdownMs: 60_000, +}); +const currentManager = new FakeManager(); +currentManager.activeTurnCount = 0; +const currentDaemon = new LocalAgentDaemon({ + stateDir: staleIdleStateDir, + configRevision: CONFIG_REVISION, + manager: currentManager, + idleShutdownMs: 60_000, +}); +let currentDaemonSpawns = 0; +const staleIdleClient = new LocalAgentClient({ + stateDir: staleIdleStateDir, + configRevision: CONFIG_REVISION, + startupTimeoutMs: 2_000, + requestTimeoutMs: 500, + spawnDaemon: () => { + currentDaemonSpawns += 1; + void currentDaemon.start(); + }, +}); +try { + await staleIdleDaemon.start(); + assert.equal(unwrap(await staleIdleClient.ensureReady()).state, "ready"); + assert.equal(staleIdleManager.closed, true); + assert.equal(currentDaemonSpawns, 1); +} finally { + await staleIdleDaemon.close(); + await currentDaemon.close(); +} + +const staleActiveStateDir = join(root, "sa"); +const staleActiveManager = new FakeManager(); +const staleActiveDaemon = new LocalAgentDaemon({ + stateDir: staleActiveStateDir, + configRevision: "old-provider-config", + manager: staleActiveManager, + idleShutdownMs: 60_000, +}); +let staleActiveSpawns = 0; +const staleActiveClient = new LocalAgentClient({ + stateDir: staleActiveStateDir, + configRevision: CONFIG_REVISION, + startupTimeoutMs: 500, + requestTimeoutMs: 500, + spawnDaemon: () => { staleActiveSpawns += 1; }, +}); +try { + await staleActiveDaemon.start(); + const changed = await staleActiveClient.ensureReady(); + assert.equal(changed.isErr(), true); + if (changed.isErr()) { + assert.equal(changed.error.code, "DAEMON_CONFIG_CHANGED"); + assert.equal(changed.error.retryable, true); + } + assert.equal(staleActiveSpawns, 0); + assert.equal(staleActiveManager.closed, false); + assert.deepEqual(Object.keys(unwrap(await staleActiveClient.status())).sort(), [ + "activeTurns", + "clientConnections", + "endpoint", + "pid", + "protocolVersion", + "runtimeCount", + "startedAt", + "state", + ]); +} finally { + await staleActiveDaemon.close(); +} + +const configRaceStateDir = join(root, "sr"); +const configRaceManager = new FakeManager(); +configRaceManager.activeTurnCount = 0; +configRaceManager.blockStartUntilRelease = true; +const configRaceDaemon = new LocalAgentDaemon({ + stateDir: configRaceStateDir, + configRevision: "old-provider-config", + manager: configRaceManager, + idleShutdownMs: 60_000, +}); +const matchingRaceClient = new LocalAgentClient({ + stateDir: configRaceStateDir, + configRevision: "old-provider-config", + spawnDaemon: () => { throw new Error("the existing daemon should be used"); }, +}); +const changedRaceClient = new LocalAgentClient({ + stateDir: configRaceStateDir, + configRevision: CONFIG_REVISION, + spawnDaemon: () => { throw new Error("a busy daemon must not be replaced"); }, +}); +try { + await configRaceDaemon.start(); + const starting = matchingRaceClient.run({ + target: "reviewer", + prompt: "race with replacement", + workspaceId: record.workspaceId, + workspaceRoot: record.workspaceRoot, + }); + await waitFor(() => configRaceManager.startStarted); + const changed = await changedRaceClient.ensureReady(); + assert.equal(changed.isErr(), true); + if (changed.isErr()) assert.equal(changed.error.code, "DAEMON_CONFIG_CHANGED"); + assert.equal(configRaceManager.closed, false); + configRaceManager.releaseBlockedStart(); + unwrap(await starting); +} finally { + configRaceManager.releaseBlockedStart(); + await configRaceDaemon.close(); +} + const upgradeStateDir = join(root, "upgrade-state"); await mkdir(upgradeStateDir, { recursive: true }); const upgradePaths = localAgentDaemonPaths(upgradeStateDir); @@ -247,7 +417,7 @@ const legacyServer = createNetServer((socket) => { ok: false, error: { code: "DAEMON_PROTOCOL_MISMATCH", - message: "Unsupported daemon protocol version 3; expected 1.", + message: `Unsupported daemon protocol version ${LOCAL_AGENT_DAEMON_PROTOCOL_VERSION}; expected 1.`, retryable: false, }, })); @@ -285,6 +455,7 @@ const replacementManager = new FakeManager(); replacementManager.activeTurnCount = 0; const replacementDaemon = new LocalAgentDaemon({ stateDir: upgradeStateDir, + configRevision: CONFIG_REVISION, manager: replacementManager, idleShutdownMs: 60_000, }); @@ -292,6 +463,7 @@ let replacementSpawns = 0; let spawnedBeforeLegacyLockReleased = false; const upgradeClient = new LocalAgentClient({ stateDir: upgradeStateDir, + configRevision: CONFIG_REVISION, startupTimeoutMs: 2_000, requestTimeoutMs: 500, spawnDaemon: () => { @@ -301,10 +473,17 @@ const upgradeClient = new LocalAgentClient({ }, }); try { - assert.equal(unwrap(await upgradeClient.ensureReady()).protocolVersion, 3); + assert.equal( + unwrap(await upgradeClient.ensureReady()).protocolVersion, + LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + ); assert.equal(replacementSpawns, 1); assert.equal(spawnedBeforeLegacyLockReleased, false); - assert.deepEqual(legacyMethods.slice(0, 3), ["hello:3", "hello:1", "daemon.stop:1"]); + assert.deepEqual(legacyMethods.slice(0, 3), [ + `hello:${LOCAL_AGENT_DAEMON_PROTOCOL_VERSION}`, + "hello:1", + "daemon.stop:1", + ]); } finally { legacyLock.release(); await replacementDaemon.close(); @@ -340,20 +519,24 @@ const replacementRaceServer = createNetServer((socket) => { })); return; } + const status = { + state: request.method === "daemon.stop" ? "stopping" as const : "ready" as const, + protocolVersion: replacementRaceProtocol, + pid: process.pid, + endpoint: replacementRacePaths.endpoint, + startedAt: "now", + activeTurns: 0, + runtimeCount: 0, + clientConnections: 1, + }; socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, protocolVersion: replacementRaceProtocol, ok: true, - result: { - state: request.method === "daemon.stop" ? "stopping" : "ready", - protocolVersion: replacementRaceProtocol, - pid: process.pid, - endpoint: replacementRacePaths.endpoint, - startedAt: "now", - activeTurns: 0, - runtimeCount: 0, - clientConnections: 1, - }, + result: request.method === "hello" + && replacementRaceProtocol === LOCAL_AGENT_DAEMON_PROTOCOL_VERSION + ? { status, configMatches: true } + : status, }), () => { if (request.method === "daemon.stop") { replacementRaceProtocol = LOCAL_AGENT_DAEMON_PROTOCOL_VERSION; @@ -367,6 +550,7 @@ await new Promise((resolveListen, rejectListen) => { }); const replacementRaceClient = new LocalAgentClient({ stateDir: replacementRaceStateDir, + configRevision: CONFIG_REVISION, startupTimeoutMs: 500, requestTimeoutMs: 100, spawnDaemon: () => { @@ -397,11 +581,11 @@ const timeoutServer = createNetServer((socket) => { if (request.method !== "hello") return; socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ok: true, result: { state: "ready", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, pid: process.pid, endpoint: timeoutPaths.endpoint, startedAt: "now", @@ -419,6 +603,7 @@ await new Promise((resolveListen, rejectListen) => { try { const timeoutClient = new LocalAgentClient({ stateDir: timeoutStateDir, + configRevision: CONFIG_REVISION, endpoint: timeoutPaths.endpoint, requestTimeoutMs: 20, spawnDaemon: () => { throw new Error("existing daemon should be used"); }, @@ -443,7 +628,7 @@ const invalidServer = createNetServer((socket) => { if (!buffer.includes("\n")) return; socket.end(encodeLocalAgentDaemonResponse({ requestId: "wrong_request_id", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ok: true, result: {}, })); @@ -456,6 +641,7 @@ await new Promise((resolveListen, rejectListen) => { try { const invalidClient = new LocalAgentClient({ stateDir: invalidStateDir, + configRevision: CONFIG_REVISION, endpoint: invalidPaths.endpoint, requestTimeoutMs: 50, spawnDaemon: () => { throw new Error("existing daemon should be used"); }, @@ -479,6 +665,7 @@ const socketManager = new FakeManager(); socketManager.activeTurnCount = 0; const socketDaemon = new LocalAgentDaemon({ stateDir: socketStateDir, + configRevision: CONFIG_REVISION, manager: socketManager, requestReadTimeoutMs: 30, shutdownTimeoutMs: 100, @@ -487,6 +674,27 @@ const socketDaemon = new LocalAgentDaemon({ try { await socketDaemon.start(); + socketManager.blockWaitUntilAbort = true; + const waitSocket = createConnection(socketDaemon.paths.endpoint); + await new Promise((resolveConnect, rejectConnect) => { + waitSocket.once("error", rejectConnect); + waitSocket.once("connect", resolveConnect); + }); + waitSocket.write(JSON.stringify({ + requestId: "disconnect-wait", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + authToken: ensureLocalAgentDaemonSecret(socketDaemon.paths), + method: "agent.wait", + params: { + ids: [record.id], + scope: { workspaceId: record.workspaceId, workspaceRoot: record.workspaceRoot }, + }, + }) + "\n"); + await waitFor(() => socketManager.waitStarted); + waitSocket.destroy(); + await waitFor(() => socketManager.waitAborted); + socketManager.blockWaitUntilAbort = false; + const timedOutRequest = await sendRawRequest(socketDaemon.paths.endpoint); assert.equal(timedOutRequest.ok, false); if (!timedOutRequest.ok) { @@ -497,10 +705,11 @@ try { const unauthorized = await sendRawRequest(socketDaemon.paths.endpoint, JSON.stringify({ requestId: "unauthorized", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, authToken: "wrong-secret", method: "hello", params: {}, + configRevision: CONFIG_REVISION, }) + "\n"); assert.equal(unauthorized.ok, false); if (!unauthorized.ok) assert.equal(unauthorized.error.code, "DAEMON_UNAUTHORIZED"); diff --git a/src/local-agent-daemon.ts b/src/local-agent-daemon.ts index dfd3a149..e4206ed5 100644 --- a/src/local-agent-daemon.ts +++ b/src/local-agent-daemon.ts @@ -34,9 +34,12 @@ import { import type { Result } from "better-result"; import type { AgentContinueError, + AgentCancelError, AgentListError, AgentLookupError, AgentStartError, + AgentWaitError, + LocalAgentWaitResult, RunOverrides, StartLocalAgentInput, } from "./local-agent-manager.js"; @@ -51,8 +54,15 @@ const DEFAULT_DAEMON_SHUTDOWN_TIMEOUT_MS = 10_000; export interface LocalAgentDaemonManager { start(input: StartLocalAgentInput): Promise>; continue(agentId: string, prompt: string, overrides: RunOverrides | undefined, scope: LocalAgentWorkspaceScope): Promise>; + cancel(agentId: string, scope: LocalAgentWorkspaceScope): Promise>; get(agentId: string, scope: LocalAgentWorkspaceScope): Result; list(scope: LocalAgentWorkspaceScope): Result; + wait( + agentIds: readonly string[], + scope: LocalAgentWorkspaceScope, + timeoutMs?: number, + signal?: AbortSignal, + ): Promise>; evictIdle(now?: number): Promise; close(): Promise; readonly activeTurnCount: number; @@ -62,6 +72,7 @@ export interface LocalAgentDaemonManager { export interface LocalAgentDaemonOptions { stateDir: string; manager: LocalAgentDaemonManager; + configRevision: string; idleShutdownMs?: number; idleCheckIntervalMs?: number; requestReadTimeoutMs?: number; @@ -75,6 +86,7 @@ export interface LocalAgentDaemonOptions { export class LocalAgentDaemon { readonly paths: LocalAgentDaemonPaths; private readonly manager: LocalAgentDaemonManager; + private readonly configRevision: string; private readonly lock: LocalAgentDaemonLock; private readonly idleShutdownMs: number; private readonly idleCheckIntervalMs: number; @@ -91,12 +103,14 @@ export class LocalAgentDaemon { private startedAt?: string; private accepting = false; private stopping = false; + private activeTurnRequests = 0; private authToken?: string; private ownsLock = false; constructor(options: LocalAgentDaemonOptions) { this.paths = options.paths ?? localAgentDaemonPaths(options.stateDir); this.manager = options.manager; + this.configRevision = options.configRevision; this.lock = new LocalAgentDaemonLock(this.paths); this.idleShutdownMs = options.idleShutdownMs ?? DEFAULT_DAEMON_IDLE_SHUTDOWN_MS; this.idleCheckIntervalMs = options.idleCheckIntervalMs ?? DEFAULT_IDLE_CHECK_INTERVAL_MS; @@ -210,6 +224,7 @@ export class LocalAgentDaemon { private handleConnection(socket: Socket): void { this.sockets.add(socket); + const disconnected = new AbortController(); socket.setEncoding("utf8"); let buffer = ""; let handled = false; @@ -243,14 +258,17 @@ export class LocalAgentDaemon { handled = true; clearTimeout(requestTimer); const line = buffer.slice(0, newline); - void this.handleLine(socket, line); + void this.handleLine(socket, line, disconnected.signal); }); socket.on("error", () => undefined); - socket.on("close", () => this.sockets.delete(socket)); + socket.on("close", () => { + disconnected.abort(); + this.sockets.delete(socket); + }); socket.on("error", () => clearTimeout(requestTimer)); } - private async handleLine(socket: Socket, line: string): Promise { + private async handleLine(socket: Socket, line: string, signal: AbortSignal): Promise { let requestId = ""; try { let parsed: unknown; @@ -261,7 +279,7 @@ export class LocalAgentDaemon { } requestId = readRequestId(parsed); const request = decodeLocalAgentDaemonRequest(parsed); - const response = await this.dispatch(request); + const response = await this.dispatch(request, signal); socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, @@ -274,7 +292,7 @@ export class LocalAgentDaemon { } } - private async dispatch(request: LocalAgentDaemonRequest): Promise { + private async dispatch(request: LocalAgentDaemonRequest, signal: AbortSignal): Promise { if (request.protocolVersion !== LOCAL_AGENT_DAEMON_PROTOCOL_VERSION) { throw new LocalAgentDaemonProtocolError( "PROTOCOL_MISMATCH", @@ -282,6 +300,12 @@ export class LocalAgentDaemon { ); } this.assertAuthenticated(request.authToken); + if (request.method === "hello" && !request.configRevision) { + throw new LocalAgentDaemonProtocolError( + "INVALID_REQUEST", + "Daemon hello requires a provider configuration revision.", + ); + } if (!this.accepting && request.method !== "hello" && request.method !== "daemon.status") { throw new AgentDaemonUnavailableError({ code: "DAEMON_UNAVAILABLE", @@ -293,23 +317,50 @@ export class LocalAgentDaemon { switch (request.method) { case "hello": - return this.status(); + return { + status: this.status(), + configMatches: request.configRevision === this.configRevision, + }; case "agent.start": - return unwrapManagerResult(await this.manager.start(request.params)); + return this.runTurnRequest(() => this.manager.start(request.params)); case "agent.continue": - return unwrapManagerResult(await this.manager.continue( + return this.runTurnRequest(() => this.manager.continue( request.params.id, request.params.prompt, request.params.overrides, request.params.scope, )); + case "agent.cancel": + return unwrapManagerResult(await this.manager.cancel( + request.params.id, + request.params.scope, + )); case "agent.get": return unwrapManagerResult(this.manager.get(request.params.id, request.params.scope)); case "agent.list": return unwrapManagerResult(this.manager.list(request.params)); + case "agent.wait": + return unwrapManagerResult(await this.manager.wait( + request.params.ids, + request.params.scope, + request.params.timeoutMs, + signal, + )); case "daemon.status": return this.status(); case "daemon.stop": + if (request.params.ifIdle) { + this.accepting = false; + if (this.activeTurnRequests > 0 || this.manager.activeTurnCount > 0) { + this.accepting = true; + throw new AgentDaemonUnavailableError({ + code: "DAEMON_UNAVAILABLE", + operation: "daemon.stop", + retryable: true, + message: "Local agent daemon became busy before it could be replaced.", + }); + } + } this.stopping = true; this.accepting = false; return this.status(); @@ -318,6 +369,17 @@ export class LocalAgentDaemon { } } + private async runTurnRequest( + operation: () => Promise>, + ): Promise { + this.activeTurnRequests += 1; + try { + return unwrapManagerResult(await operation()); + } finally { + this.activeTurnRequests -= 1; + } + } + private writeError(socket: Socket, requestId: string, error: LocalAgentDaemonErrorPayload): void { socket.end(encodeLocalAgentDaemonResponse({ requestId, diff --git a/src/local-agent-errors.ts b/src/local-agent-errors.ts index 0df50b86..32c33cd7 100644 --- a/src/local-agent-errors.ts +++ b/src/local-agent-errors.ts @@ -103,6 +103,10 @@ export class AgentDaemonProtocolMismatchError extends TaggedError( "AgentDaemonProtocolMismatchError", )() {} +export class AgentDaemonConfigChangedError extends TaggedError( + "AgentDaemonConfigChangedError", +)() {} + export class AgentDaemonUnauthorizedError extends TaggedError( "AgentDaemonUnauthorizedError", )() {} @@ -124,6 +128,7 @@ export type AgentDaemonError = | AgentDaemonStartupError | AgentDaemonTimeoutError | AgentDaemonProtocolMismatchError + | AgentDaemonConfigChangedError | AgentDaemonUnauthorizedError | AgentDaemonInvalidRequestError | AgentDaemonInvalidResponseError @@ -179,6 +184,7 @@ export function isAgentDaemonError(error: unknown): error is AgentDaemonError { || AgentDaemonStartupError.is(error) || AgentDaemonTimeoutError.is(error) || AgentDaemonProtocolMismatchError.is(error) + || AgentDaemonConfigChangedError.is(error) || AgentDaemonUnauthorizedError.is(error) || AgentDaemonInvalidRequestError.is(error) || AgentDaemonInvalidResponseError.is(error) @@ -207,6 +213,7 @@ export function toAgentErrorPayload(error: LocalAgentError): AgentErrorPayload { AgentDaemonStartupError: daemonErrorPayload, AgentDaemonTimeoutError: daemonErrorPayload, AgentDaemonProtocolMismatchError: daemonErrorPayload, + AgentDaemonConfigChangedError: daemonErrorPayload, AgentDaemonUnauthorizedError: daemonErrorPayload, AgentDaemonInvalidRequestError: daemonErrorPayload, AgentDaemonInvalidResponseError: daemonErrorPayload, @@ -315,6 +322,13 @@ export function agentErrorFromPayload(payload: { retryable, message: payload.message, }); + case "DAEMON_CONFIG_CHANGED": + return new AgentDaemonConfigChangedError({ + code: payload.code, + operation: payload.operation ?? "hello", + retryable, + message: payload.message, + }); case "DAEMON_UNAUTHORIZED": return new AgentDaemonUnauthorizedError({ code: payload.code, diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index 4ca5ed28..b74e2628 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -11,6 +11,7 @@ import { import type { LocalAgentProfile } from "./local-agent-profiles.js"; import type { LocalAgentDriver, + LocalAgentRunCallbacks, LocalAgentRunInput, LocalAgentRunResult, LocalAgentRuntime, @@ -54,7 +55,7 @@ class FakeRuntime implements LocalAgentRuntime { async run( input: LocalAgentRunInput, - callbacks?: { onSessionId?: (id: string) => void | Promise }, + callbacks?: LocalAgentRunCallbacks, ): Promise> { this.inputs.push(input); if (input.prompt.includes("early-fail")) { @@ -64,8 +65,13 @@ 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 }); + }); } + callbacks?.onUsage?.({ inputTokens: 8, outputTokens: 2, totalTokens: 10, state: "final" }); + callbacks?.onActivity?.({ kind: "tool", status: "completed", label: "read" }); return Result.ok({ provider: this.provider, providerSessionId: "thread_test", @@ -121,7 +127,8 @@ const stale = store.create({ profileName: "reviewer", provider: "codex", }); -store.update(stale.id, { status: "running", latestResponse: "previous response" }); +const staleTurn = store.beginTurn(stale.id, { prompt: "interrupted turn" }); +store.update(stale.id, { latestResponse: "previous response" }); const manager = new LocalAgentManager({ store, @@ -222,6 +229,8 @@ assert.equal(getRecord(stale.id).latestResponse, "previous response"); assert.equal(getRecord(stale.id).error, "DevSpace restarted while this agent turn was running."); assert.equal(getRecord(stale.id).errorCode, "DAEMON_UNAVAILABLE"); assert.equal(getRecord(stale.id).errorRetryable, true); +assert.equal(store.getTurnById(staleTurn.turn.id)?.status, "failed"); +assert.equal(store.getTurnById(staleTurn.turn.id)?.errorCode, "DAEMON_UNAVAILABLE"); const first = unwrap(await manager.start({ target: "reviewer", @@ -244,6 +253,14 @@ runtimes.get(first.id)!.release(); await waitFor(() => getRecord(first.id).status === "idle"); assert.equal(getRecord(first.id).providerSessionId, "thread_test"); assert.match(getRecord(first.id).latestResponse ?? "", /Task:\nhold/); +assert.equal(getRecord(first.id).usage?.totalTokens, 10); +assert.deepEqual(getRecord(first.id).activity, [ + { kind: "tool", status: "completed", label: "read" }, +]); +assert.deepEqual( + store.listTurns(first.id).map((turn) => ({ prompt: turn.prompt, status: turn.status })), + [{ prompt: "hold", status: "completed" }], +); const continued = unwrap(await manager.continue(first.id, "continue", { model: "gpt-run", @@ -253,6 +270,13 @@ 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"); +assert.deepEqual( + store.listTurns(first.id).map((turn) => ({ prompt: turn.prompt, status: turn.status })), + [ + { prompt: "hold", status: "completed" }, + { prompt: "continue", status: "completed" }, + ], +); const second = unwrap(await manager.start({ target: "reviewer", @@ -264,6 +288,20 @@ await waitFor(() => getRecord(second.id).status === "idle"); assert.notEqual(first.id, second.id); assert.equal(runtimes.size, 2, "different agents receive independent logical runtimes"); +const cancelled = unwrap(await manager.start({ + target: "reviewer", + prompt: "hold for cancellation", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await waitFor(() => runtimes.get(cancelled.id)?.inputs.length === 1); +const stoppedWait = manager.wait([cancelled.id], scope); +const stopped = unwrap(await manager.cancel(cancelled.id, scope)); +assert.equal(stopped.status, "stopped"); +assert.equal(getRecord(cancelled.id).status, "stopped"); +assert.equal(store.getLatestTurn(cancelled.id)?.status, "stopped"); +assert.deepEqual(unwrap(await stoppedWait), [{ id: cancelled.id, status: "stopped" }]); + const failed = unwrap(await manager.start({ target: "reviewer", prompt: "fail", @@ -274,6 +312,8 @@ await waitFor(() => getRecord(failed.id).status === "error"); assert.equal(getRecord(failed.id).error, "provider failed"); assert.equal(getRecord(failed.id).errorCode, "PROVIDER_EXECUTION_ERROR"); assert.equal(getRecord(failed.id).errorRetryable, false); +assert.equal(store.getLatestTurn(failed.id)?.status, "failed"); +assert.equal(store.getLatestTurn(failed.id)?.error, "provider failed"); const recovered = unwrap(await manager.continue(failed.id, "recovered", {}, scope)); assert.equal(recovered.status, "running", "provider Err releases active-turn ownership"); await waitFor(() => getRecord(failed.id).status === "idle"); @@ -287,6 +327,76 @@ const earlyFailure = unwrap(await manager.start({ await waitFor(() => getRecord(earlyFailure.id).status === "error"); assert.equal(getRecord(earlyFailure.id).providerSessionId, "thread_early"); +const waitingOne = unwrap(await manager.start({ + target: "reviewer", + prompt: "hold wait one", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +const waitingTwo = unwrap(await manager.start({ + target: "reviewer", + prompt: "hold wait two", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await waitFor(() => runtimes.get(waitingOne.id)?.inputs.length === 1); +await waitFor(() => runtimes.get(waitingTwo.id)?.inputs.length === 1); +let multiWaitSettled = false; +const multiWait = manager.wait([waitingOne.id, waitingTwo.id, waitingOne.id], scope) + .then((result) => { + multiWaitSettled = true; + return result; + }); +runtimes.get(waitingOne.id)!.release(); +await waitFor(() => getRecord(waitingOne.id).status === "idle"); +assert.equal(multiWaitSettled, false, "multi-agent wait must remain pending until every turn finishes"); +runtimes.get(waitingTwo.id)!.release(); +assert.deepEqual(unwrap(await multiWait).map((result) => ({ id: result.id, status: result.status })), [ + { id: waitingOne.id, status: "completed" }, + { id: waitingTwo.id, status: "completed" }, +]); + +const timedWaitAgent = unwrap(await manager.start({ + target: "reviewer", + prompt: "hold timed wait", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await waitFor(() => runtimes.get(timedWaitAgent.id)?.inputs.length === 1); +assert.deepEqual(unwrap(await manager.wait([earlyFailure.id, timedWaitAgent.id], scope, 5)), [ + { + id: earlyFailure.id, + status: "failed", + error: { + code: "PROVIDER_EXECUTION_ERROR", + message: "provider failed after session creation", + retryable: false, + }, + }, + { id: timedWaitAgent.id, status: "running", wait: "timeout" }, +]); +runtimes.get(timedWaitAgent.id)!.release(); +await waitFor(() => getRecord(timedWaitAgent.id).status === "idle"); + +const cancelledWaitAgent = unwrap(await manager.start({ + target: "reviewer", + prompt: "hold cancelled wait", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await waitFor(() => runtimes.get(cancelledWaitAgent.id)?.inputs.length === 1); +const waitAbort = new AbortController(); +const cancelledWait = manager.wait([cancelledWaitAgent.id], scope, undefined, waitAbort.signal); +waitAbort.abort(); +assert.deepEqual(unwrap(await cancelledWait), [{ id: cancelledWaitAgent.id, status: "running" }]); +assert.equal(getRecord(cancelledWaitAgent.id).status, "running", "cancelling a waiter must not stop its turn"); +runtimes.get(cancelledWaitAgent.id)!.release(); +await waitFor(() => getRecord(cancelledWaitAgent.id).status === "idle"); + +const invalidWait = await manager.wait([waitingOne.id, "agt_missing"], scope, 5); +assert.equal(invalidWait.isErr(), true); +if (invalidWait.isErr()) assert.equal(invalidWait.error.code, "AGENT_NOT_FOUND"); + const wrongWorkspace = await manager.continue( first.id, "wrong workspace", diff --git a/src/local-agent-manager.ts b/src/local-agent-manager.ts index dbd80d86..f2b41dd3 100644 --- a/src/local-agent-manager.ts +++ b/src/local-agent-manager.ts @@ -20,6 +20,7 @@ import { import { type LocalAgentRecord, type LocalAgentStore, + type LocalAgentTurnRecord, type LocalAgentWorkspaceScope, } from "./local-agent-store.js"; import { @@ -70,7 +71,21 @@ export interface LocalAgentManagerOptions { export type AgentStartError = AgentTargetError | AgentScopeError | AgentConflictError | AgentStoreError; export type AgentContinueError = AgentStartError; export type AgentLookupError = AgentTargetError | AgentScopeError | AgentStoreError; +export type AgentCancelError = AgentLookupError; export type AgentListError = AgentScopeError | AgentStoreError; +export type AgentWaitError = AgentLookupError; + +export type LocalAgentWaitResult = + | { id: string; status: "running"; wait?: "timeout" } + | { id: string; status: "completed"; response?: string } + | { id: string; status: "failed"; error: { code: string; message: string; retryable: boolean } } + | { id: string; status: "stopped"; error?: { code: string; message: string; retryable: boolean } }; + +interface ActiveLocalAgentTurn { + turnId: number; + completion: Promise; + controller: AbortController; +} /** * Owns one durable DevSpace agent's turn lifecycle. Provider runtimes remain @@ -78,6 +93,7 @@ export type AgentListError = AgentScopeError | AgentStoreError; * persists the result. */ export class LocalAgentManager { + private static readonly activityLimit = 500; private readonly store: LocalAgentStore; private readonly drivers = new Map(); private readonly pool: LocalAgentRuntimePool; @@ -86,7 +102,7 @@ export class LocalAgentManager { private readonly allowedRoots?: readonly string[]; private readonly logger?: LocalAgentManagerLogger; private readonly subagents: SubagentsConfig; - private readonly activeTurns = new Map>(); + private readonly activeTurns = new Map(); private accepting = true; private closePromise?: Promise; @@ -199,10 +215,77 @@ export class LocalAgentManager { )); } + async cancel( + agentId: string, + scope: LocalAgentWorkspaceScope, + ): Promise> { + const lookup = this.store.getByIdResult(agentId); + if (lookup.isErr()) return lookup; + const record = lookup.value; + if (!record) return Result.err(agentNotFound(agentId)); + const scoped = this.agentWorkspaceResult(record, scope, "cancel"); + if (scoped.isErr()) return scoped; + + const active = this.activeTurns.get(agentId); + if (!active) return Result.ok(record); + active.controller.abort(); + await active.completion; + const updated = this.store.getByIdResult(agentId); + if (updated.isErr()) return updated; + return updated.value ? Result.ok(updated.value) : Result.err(agentNotFound(agentId)); + } + + async wait( + agentIds: readonly string[], + scope: LocalAgentWorkspaceScope, + timeoutMs?: number, + signal?: AbortSignal, + ): Promise> { + const captures: Array<{ agent: LocalAgentRecord; turn?: LocalAgentTurnRecord }> = []; + for (const agentId of unique(agentIds)) { + const agent = this.get(agentId, scope); + if (agent.isErr()) return agent; + const turn = this.store.getLatestTurnResult(agentId); + if (turn.isErr()) return turn; + captures.push({ agent: agent.value, turn: turn.value }); + } + + const pending: Promise[] = []; + for (const capture of captures) { + if (capture.turn?.status !== "running") continue; + const active = this.activeTurns.get(capture.agent.id); + if (active?.turnId !== capture.turn.id) { + return Result.err(new AgentStoreError( + "wait", + new Error(`Turn ${capture.turn.id} is not active.`), + `Running turn state is unavailable for subagent ${capture.agent.id}.`, + )); + } + pending.push(active.completion); + } + + const timedOut = pending.length > 0 + ? await waitForTurns(pending, timeoutMs, signal) + : false; + const results: LocalAgentWaitResult[] = []; + for (const capture of captures) { + if (!capture.turn) { + results.push(waitResultFromAgent(capture.agent, timedOut)); + continue; + } + const turn = this.store.getTurnByIdResult(capture.turn.id); + if (turn.isErr()) return turn; + results.push(turn.value + ? waitResultFromTurn(turn.value, timedOut) + : waitResultFromAgent(capture.agent, timedOut)); + } + return Result.ok(results); + } + async close(): Promise { if (this.closePromise) return this.closePromise; this.accepting = false; - const turns = Array.from(this.activeTurns.values()); + const turns = Array.from(this.activeTurns.values(), (turn) => turn.completion); this.closePromise = (async () => { // Closing pooled runtimes is what interrupts provider turns. Waiting for // those turns first can strand a provider process indefinitely. @@ -246,31 +329,41 @@ export class LocalAgentManager { })); } - const updated = this.store.updateResult(record.id, { - status: "running", + const begun = this.store.beginTurnResult(record.id, { + prompt, model: overrides.model ?? record.model, effort: overrides.effort ?? record.effort, - latestResponse: undefined, - error: undefined, - errorCode: undefined, - errorRetryable: undefined, }); - if (updated.isErr()) return updated; + 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(updated.value, prompt, overrides, workspaceId) + this.runTurn( + begun.value.agent, + begun.value.turn.id, + prompt, + overrides, + workspaceId, + controller.signal, + ) )); - this.activeTurns.set(record.id, turn); + this.activeTurns.set(record.id, { + turnId: begun.value.turn.id, + completion: turn, + controller, + }); void turn.catch(() => undefined); - return updated; + return Result.ok(begun.value.agent); } private async runTurn( record: LocalAgentRecord, + turnId: number, prompt: string, overrides: RunOverrides, workspaceId?: string, + signal?: AbortSignal, ): Promise { const startedAt = Date.now(); this.log("info", "agent_run_started", { @@ -281,7 +374,7 @@ export class LocalAgentManager { try { const authorized = this.authorizeWorkspace(record.workspaceRoot, workspaceId, "run"); if (authorized.isErr()) { - this.persistRunError(record, authorized.error, startedAt); + this.persistRunError(record, turnId, authorized.error, startedAt); return; } const workspaceRoot = authorized.value; @@ -290,22 +383,28 @@ export class LocalAgentManager { : { ...record, workspaceRoot }; const profiles = await this.loadProfilesResult(workspaceRoot, record.profileName); if (profiles.isErr()) { - this.persistRunError(record, profiles.error, startedAt); + this.persistRunError(record, turnId, profiles.error, startedAt); return; } const profile = this.profileForRecordResult(record, profiles.value); if (profile.isErr()) { - this.persistRunError(record, profile.error, startedAt); + 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, input.error, startedAt); + this.persistRunError(record, turnId, input.error, startedAt); return; } const driver = this.driverResult(record.provider, "run", record.id); if (driver.isErr()) { - this.persistRunError(record, driver.error, startedAt); + this.persistRunError(record, turnId, driver.error, startedAt); return; } const context: LocalAgentRuntimeContext = { @@ -326,23 +425,37 @@ export class LocalAgentManager { const updated = this.store.updateResult(record.id, { providerSessionId }); if (updated.isErr()) throw updated.error; }, + onUsage: (usage) => { + const updated = this.store.updateResult(record.id, { usage }); + if (updated.isErr()) throw updated.error; + }, + onActivity: (activity) => { + const current = this.store.getByIdResult(record.id); + if (current.isErr()) throw current.error; + if (!current.value) return; + const next = [...(current.value.activity ?? []), activity] + .slice(-LocalAgentManager.activityLimit); + const updated = this.store.updateResult(record.id, { activity: next }); + if (updated.isErr()) throw updated.error; + }, }; const result = await this.pool.run(driver.value, context, input.value, callbacks); + if (signal?.aborted) { + this.persistStopped(record, turnId); + return; + } if (result.isErr()) { - this.persistRunError(record, result.error, startedAt); + this.persistRunError(record, turnId, result.error, startedAt); return; } const runResult = result.value; const current = this.store.getByIdResult(record.id); if (current.isErr()) throw current.error; if (!current.value) return; - const updated = this.store.updateResult(record.id, { + const updated = this.store.finishTurnResult(record.id, turnId, { providerSessionId: runResult.providerSessionId ?? current.value.providerSessionId, - status: "idle", - latestResponse: runResult.finalResponse, - error: undefined, - errorCode: undefined, - errorRetryable: undefined, + status: "completed", + response: runResult.finalResponse, }); if (updated.isErr()) throw updated.error; this.log("info", "agent_run_completed", { @@ -352,12 +465,16 @@ export class LocalAgentManager { durationMs: Math.max(0, Date.now() - startedAt), }); } catch (error) { + if (signal?.aborted) { + this.persistStopped(record, turnId); + return; + } if (isLocalAgentError(error)) { - this.persistRunError(record, error, startedAt); + this.persistRunError(record, turnId, error, startedAt); return; } - const persisted = this.store.updateResult(record.id, { - status: "error", + const persisted = this.store.finishTurnResult(record.id, turnId, { + status: "failed", error: "Unexpected internal subagent failure.", errorCode: "AGENT_INTERNAL_ERROR", errorRetryable: false, @@ -379,11 +496,12 @@ export class LocalAgentManager { private persistRunError( record: LocalAgentRecord, + turnId: number, error: LocalAgentError, startedAt: number, ): void { - const persisted = this.store.updateResult(record.id, { - status: "error", + const persisted = this.store.finishTurnResult(record.id, turnId, { + status: "failed", error: error.message, errorCode: error.code, errorRetryable: error.retryable, @@ -400,11 +518,24 @@ export class LocalAgentManager { }); } + private persistStopped(record: LocalAgentRecord, turnId: number): void { + const persisted = this.store.finishTurnResult(record.id, turnId, { + status: "stopped", + }); + if (persisted.isErr()) throw persisted.error; + this.log("info", "agent_run_stopped", { + provider: record.provider, + agentId: record.id, + providerSessionIdPrefix: record.providerSessionId?.slice(0, 8), + }); + } + private buildRunInputResult( record: LocalAgentRecord, profile: LocalAgentProfile | undefined, prompt: string, overrides: RunOverrides, + signal?: AbortSignal, ): BetterResult { const isRawProvider = record.profileName === record.provider; if (!profile && !isRawProvider) { @@ -421,6 +552,7 @@ export class LocalAgentManager { return Result.ok({ prompt: fullPrompt, workspaceRoot: record.workspaceRoot, + signal, providerSessionId: record.providerSessionId, writeMode: overrides.writeMode ?? "allowed", model: record.model ?? profile?.model, @@ -609,3 +741,108 @@ function agentNotFound(agentId: string): AgentTargetError { message: `Unknown subagent id: ${agentId}.`, }); } + +function unique(values: readonly string[]): string[] { + return [...new Set(values)]; +} + +async function waitForTurns( + turns: readonly Promise[], + timeoutMs: number | undefined, + signal: AbortSignal | undefined, +): Promise { + let timer: NodeJS.Timeout | undefined; + let onAbort: (() => void) | undefined; + const timeout = timeoutMs === undefined + ? undefined + : new Promise<"timeout">((resolveTimeout) => { + timer = setTimeout(() => resolveTimeout("timeout"), timeoutMs); + }); + const aborted = signal + ? new Promise<"aborted">((resolveAbort) => { + onAbort = () => resolveAbort("aborted"); + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + }) + : undefined; + try { + const result = await Promise.race([ + Promise.allSettled(turns).then(() => "completed" as const), + ...(timeout ? [timeout] : []), + ...(aborted ? [aborted] : []), + ]); + return result === "timeout"; + } finally { + if (timer) clearTimeout(timer); + if (signal && onAbort) signal.removeEventListener("abort", onAbort); + } +} + +function waitResultFromTurn(turn: LocalAgentTurnRecord, timedOut: boolean): LocalAgentWaitResult { + switch (turn.status) { + case "running": + return { id: turn.agentId, status: "running", ...(timedOut ? { wait: "timeout" } : {}) }; + case "completed": + return { + id: turn.agentId, + status: "completed", + ...(turn.response === undefined ? {} : { response: turn.response }), + }; + case "failed": + return { id: turn.agentId, status: "failed", error: turnFailure(turn) }; + case "stopped": + return { + id: turn.agentId, + status: "stopped", + ...(hasTurnFailure(turn) ? { error: turnFailure(turn) } : {}), + }; + } +} + +function waitResultFromAgent(agent: LocalAgentRecord, timedOut: boolean): LocalAgentWaitResult { + switch (agent.status) { + case "starting": + case "running": + return { id: agent.id, status: "running", ...(timedOut ? { wait: "timeout" } : {}) }; + case "idle": + return { + id: agent.id, + status: "completed", + ...(agent.latestResponse === undefined ? {} : { response: agent.latestResponse }), + }; + case "error": + return { + id: agent.id, + status: "failed", + error: { + code: agent.errorCode ?? "AGENT_FAILED", + message: agent.error ?? "Subagent failed without an error message.", + retryable: agent.errorRetryable ?? false, + }, + }; + case "stopped": + return { + id: agent.id, + status: "stopped", + ...(agent.error || agent.errorCode || agent.errorRetryable !== undefined + ? { error: { + code: agent.errorCode ?? "AGENT_STOPPED", + message: agent.error ?? "Subagent stopped.", + retryable: agent.errorRetryable ?? false, + } } + : {}), + }; + } +} + +function hasTurnFailure(turn: LocalAgentTurnRecord): boolean { + return turn.error !== undefined || turn.errorCode !== undefined || turn.errorRetryable !== undefined; +} + +function turnFailure(turn: LocalAgentTurnRecord): { code: string; message: string; retryable: boolean } { + return { + code: turn.errorCode ?? "AGENT_FAILED", + message: turn.error ?? "Subagent failed without an error message.", + retryable: turn.errorRetryable ?? false, + }; +} diff --git a/src/local-agent-observation.test.ts b/src/local-agent-observation.test.ts new file mode 100644 index 00000000..d203a07f --- /dev/null +++ b/src/local-agent-observation.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { + observeAcpUpdate, + observeClaudeMessage, + observeOpenCodeResult, + observePiEvent, +} from "./local-agent-observation.js"; +import type { LocalAgentActivity, LocalAgentUsageSnapshot } from "./local-agent-runtime.js"; + +const observedActivity: LocalAgentActivity[] = []; +const observedUsage: LocalAgentUsageSnapshot[] = []; +const observer = { + onActivity: (activity: LocalAgentActivity) => observedActivity.push(activity), + onUsage: (usage: LocalAgentUsageSnapshot) => observedUsage.push(usage), +}; + +const openCodeUsage = observeOpenCodeResult({ + data: [{ + info: { + role: "assistant", + tokens: { input: 1_000, output: 250, cache: { read: 400, write: 50 } }, + }, + parts: [{ type: "tool", tool: "bash", state: { status: "completed" } }], + }], +}, observer); +assert.equal(openCodeUsage?.totalTokens, 1_250); +assert.equal(observedUsage.shift()?.state, "final"); +assert.deepEqual(observedActivity.shift(), { + kind: "command", + status: "completed", + label: "bash", +}); + +const piUsage = observePiEvent({ + type: "agent_end", + usage: { input: 800, output: 200, total: 1_000 }, +}, observer); +assert.equal(piUsage?.totalTokens, 1_000); +assert.deepEqual(observedUsage.shift(), piUsage); + +observeAcpUpdate({ + update: { + sessionUpdate: "tool_call_update", + kind: "execute", + title: "Run tests", + status: "failed", + }, +}, observer); +assert.deepEqual(observedActivity.shift(), { + kind: "command", + status: "failed", + label: "Run tests", +}); + +let claudeUsage = observeClaudeMessage({ + type: "assistant", + message: { usage: { input_tokens: 100, output_tokens: 30 } }, +}, undefined, observer); +claudeUsage = observeClaudeMessage({ + type: "result", + usage: { input_tokens: 200, output_tokens: 60 }, +}, claudeUsage, observer); +assert.deepEqual(claudeUsage, { + inputTokens: 200, + cachedInputTokens: undefined, + cacheCreationInputTokens: undefined, + outputTokens: 60, + totalTokens: 260, + state: "final", +}); + +console.log("local-agent-observation.test.ts: ok"); diff --git a/src/local-agent-observation.ts b/src/local-agent-observation.ts new file mode 100644 index 00000000..163ec70b --- /dev/null +++ b/src/local-agent-observation.ts @@ -0,0 +1,313 @@ +import type { + LocalAgentActivity, + LocalAgentRunCallbacks, + LocalAgentUsageSnapshot, +} from "./local-agent-runtime.js"; + +export function observeCodexEvent( + method: string, + params: unknown, + callbacks?: LocalAgentRunCallbacks, +): void { + const record = asRecord(params); + const item = asRecord(record?.item); + if (item) { + const status = method.includes("completed") + ? item.status === "failed" ? "failed" : "completed" + : "running"; + const activity = codexItemActivity(item, status); + if (activity) callbacks?.onActivity?.(activity); + } + + if (method !== "turn/completed") return; + const turn = asRecord(record?.turn); + const usage = tokenUsage( + asRecord(record?.usage) ?? asRecord(turn?.usage), + "final", + ); + if (usage) callbacks?.onUsage?.(usage); +} + +export function observeClaudeMessage( + value: unknown, + accumulated: LocalAgentUsageSnapshot | undefined, + callbacks?: LocalAgentRunCallbacks, +): LocalAgentUsageSnapshot | undefined { + const record = asRecord(value); + if (!record) return accumulated; + notifyClaudeActivity(record, callbacks); + + const final = record.type === "result"; + const message = asRecord(record.message); + const current = claudeUsage(final ? record.usage : message?.usage, final ? "final" : "partial"); + if (!current) return accumulated; + + const usage = final ? current : addUsage(accumulated, current); + callbacks?.onUsage?.(usage); + return usage; +} + +export function observeOpenCodeResult( + value: unknown, + callbacks?: LocalAgentRunCallbacks, +): LocalAgentUsageSnapshot | undefined { + const messages = openCodeMessages(value); + let usage: LocalAgentUsageSnapshot | undefined; + for (const message of messages) { + const info = asRecord(message.info) ?? message; + if (info.role !== "assistant") continue; + const snapshot = tokenUsage(asRecord(info.tokens), "final"); + if (snapshot) usage = snapshot; + for (const partValue of readArray(message, "parts") ?? readArray(message, "content") ?? []) { + const part = asRecord(partValue); + if (part?.type !== "tool") continue; + const state = asRecord(part.state); + callbacks?.onActivity?.({ + kind: toolKind(directString(part.tool) ?? directString(part.name)), + status: normalizeActivityStatus(state?.status ?? part.status), + label: directString(part.tool) ?? directString(part.name) ?? "tool", + }); + } + } + if (usage) callbacks?.onUsage?.(usage); + return usage; +} + +export function observePiEvent( + event: unknown, + callbacks?: LocalAgentRunCallbacks, +): LocalAgentUsageSnapshot | undefined { + const record = asRecord(event); + if (!record) return undefined; + const usage = tokenUsage( + asRecord(record.usage) ?? asRecord(asRecord(record.message)?.usage), + record.type === "agent_end" ? "final" : "partial", + ); + if (usage) callbacks?.onUsage?.(usage); + + const tool = asRecord(record.tool) ?? asRecord(record.toolCall) ?? asRecord(record.toolExecution); + const name = directString(record.toolName) ?? directString(tool?.name); + if (!name) return usage; + const detail = directString(record.command) ?? directString(asRecord(tool?.arguments)?.command); + callbacks?.onActivity?.({ + kind: toolKind(name), + status: normalizeActivityStatus( + record.status ?? tool?.status ?? (record.type === "tool_execution_end" ? "completed" : "running"), + ), + label: name, + ...(detail ? { detail } : {}), + }); + return usage; +} + +export function observeAcpUpdate( + value: unknown, + callbacks?: LocalAgentRunCallbacks, +): void { + const record = asRecord(value); + const update = asRecord(record?.update) ?? record; + if (!update) return; + if (update.sessionUpdate === "usage_update") { + const usage = tokenUsage(asRecord(update.usage), "partial"); + if (usage) callbacks?.onUsage?.(usage); + return; + } + if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") return; + const label = directString(update.title) ?? directString(update.kind) ?? "tool"; + callbacks?.onActivity?.({ + kind: acpToolKind(directString(update.kind)), + status: normalizeActivityStatus(update.status), + label, + }); +} + +function notifyClaudeActivity( + record: Record, + callbacks?: LocalAgentRunCallbacks, +): void { + if (record.type === "tool_progress" && typeof record.tool_name === "string") { + callbacks?.onActivity?.({ kind: "tool", status: "running", label: record.tool_name }); + return; + } + if (record.type === "tool_use_summary" && typeof record.summary === "string") { + callbacks?.onActivity?.({ kind: "tool", status: "completed", label: record.summary }); + return; + } + if (record.type !== "assistant") return; + const content = Array.isArray(asRecord(record.message)?.content) + ? asRecord(record.message)?.content as unknown[] + : []; + for (const block of content) { + const item = asRecord(block); + if (item?.type !== "tool_use" || typeof item.name !== "string") continue; + callbacks?.onActivity?.({ + kind: toolKind(item.name), + status: "running", + label: item.name, + ...(claudeToolDetail(item.input) ? { detail: claudeToolDetail(item.input) } : {}), + }); + } +} + +function codexItemActivity( + item: Record, + status: LocalAgentActivity["status"], +): LocalAgentActivity | undefined { + const type = directString(item.type); + if (type === "command_execution" || type === "commandExecution") { + return { kind: "command", status, label: directString(item.command) ?? "command" }; + } + if (type === "file_change" || type === "fileChange") { + const changes = Array.isArray(item.changes) + ? item.changes.map((change) => { + const record = asRecord(change); + return [directString(record?.kind), directString(record?.path)].filter(Boolean).join(" "); + }).filter(Boolean).join(", ") + : undefined; + return { + kind: "file", + status, + label: "apply file changes", + ...(changes ? { detail: changes } : {}), + }; + } + if (type === "mcp_tool_call" || type === "mcpToolCall") { + const server = directString(item.server); + const tool = directString(item.tool); + return { kind: "tool", status, label: [server, tool].filter(Boolean).join(".") || "MCP tool" }; + } + if (type === "web_search" || type === "webSearch") { + return { + kind: "tool", + status, + label: "web search", + ...(directString(item.query) ? { detail: directString(item.query) } : {}), + }; + } + return undefined; +} + +function claudeUsage(value: unknown, state: "partial" | "final"): LocalAgentUsageSnapshot | undefined { + const usage = asRecord(value); + if (!usage) return undefined; + const inputTokens = nonNegativeInteger(usage.input_tokens); + const cachedInputTokens = nonNegativeInteger(usage.cache_read_input_tokens); + const cacheCreationInputTokens = nonNegativeInteger(usage.cache_creation_input_tokens); + const outputTokens = nonNegativeInteger(usage.output_tokens); + if ( + inputTokens === undefined + && cachedInputTokens === undefined + && cacheCreationInputTokens === undefined + && outputTokens === undefined + ) return undefined; + return { + inputTokens, + cachedInputTokens, + cacheCreationInputTokens, + outputTokens, + totalTokens: + (inputTokens ?? 0) + + (cachedInputTokens ?? 0) + + (cacheCreationInputTokens ?? 0) + + (outputTokens ?? 0), + state, + }; +} + +function tokenUsage( + value: Record | undefined, + state: "partial" | "final", +): LocalAgentUsageSnapshot | undefined { + if (!value) return undefined; + const inputTokens = nonNegativeInteger(value.input ?? value.input_tokens ?? value.inputTokens); + const outputTokens = nonNegativeInteger(value.output ?? value.output_tokens ?? value.outputTokens); + const explicitTotal = nonNegativeInteger(value.total ?? value.total_tokens ?? value.totalTokens); + if (inputTokens === undefined && outputTokens === undefined && explicitTotal === undefined) return undefined; + const cache = asRecord(value.cache); + return { + inputTokens, + cachedInputTokens: nonNegativeInteger( + value.cached_input_tokens ?? value.cachedInputTokens ?? cache?.read ?? value.cached_read_tokens, + ), + cacheCreationInputTokens: nonNegativeInteger( + value.cache_creation_input_tokens ?? value.cacheCreationInputTokens ?? cache?.write, + ), + outputTokens, + totalTokens: explicitTotal ?? (inputTokens ?? 0) + (outputTokens ?? 0), + state, + }; +} + +function addUsage( + accumulated: LocalAgentUsageSnapshot | undefined, + current: LocalAgentUsageSnapshot, +): LocalAgentUsageSnapshot { + return { + inputTokens: sumOptional(accumulated?.inputTokens, current.inputTokens), + cachedInputTokens: sumOptional(accumulated?.cachedInputTokens, current.cachedInputTokens), + cacheCreationInputTokens: sumOptional( + accumulated?.cacheCreationInputTokens, + current.cacheCreationInputTokens, + ), + outputTokens: sumOptional(accumulated?.outputTokens, current.outputTokens), + totalTokens: (accumulated?.totalTokens ?? 0) + current.totalTokens, + state: current.state, + }; +} + +function openCodeMessages(value: unknown): Record[] { + const record = asRecord(value); + const data = record?.data; + const values = Array.isArray(data) ? data : data ? [data] : []; + return values.map(asRecord).filter((item): item is Record => item !== undefined); +} + +function claudeToolDetail(input: unknown): string | undefined { + const record = asRecord(input); + for (const key of ["command", "file_path", "path", "query"]) { + if (typeof record?.[key] === "string") return record[key]; + } + return undefined; +} + +function normalizeActivityStatus(value: unknown): LocalAgentActivity["status"] { + if (value === "failed" || value === "error") return "failed"; + if (value === "completed" || value === "complete" || value === "success") return "completed"; + return "running"; +} + +function toolKind(name: string | undefined): LocalAgentActivity["kind"] { + const normalized = name?.toLowerCase(); + if (normalized === "bash" || normalized === "shell" || normalized === "command") return "command"; + if (normalized === "write" || normalized === "edit" || normalized === "patch") return "file"; + return "tool"; +} + +function acpToolKind(kind: string | undefined): LocalAgentActivity["kind"] { + if (kind === "execute") return "command"; + if (kind === "edit" || kind === "delete" || kind === "move") return "file"; + return "tool"; +} + +function nonNegativeInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined; +} + +function sumOptional(left: number | undefined, right: number | undefined): number | undefined { + return left === undefined && right === undefined ? undefined : (left ?? 0) + (right ?? 0); +} + +function readArray(value: unknown, key: string): unknown[] | undefined { + const result = asRecord(value)?.[key]; + return Array.isArray(result) ? result : undefined; +} + +function directString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index 88cd6c05..d1306466 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -19,6 +19,7 @@ import type { LocalAgentRuntime, LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; +import { observeOpenCodeResult } from "./local-agent-observation.js"; const OPENCODE_SESSION_POLL_INTERVAL_MS = 250; const OPENCODE_SESSION_POLL_TIMEOUT_MS = 5 * 60_000; @@ -64,6 +65,12 @@ export class OpencodeRuntime implements LocalAgentRuntime { const initialModel = input.model ? parseOpencodeModel(input.model, input.effort) : undefined; const sessionId = input.providerSessionId ?? await createOpencodeSession(this.client, input, initialModel); await callbacks?.onSessionId?.(sessionId); + const onAbort = () => { + void this.client.v2.session.interrupt({ sessionID: sessionId }).catch(() => undefined); + }; + input.signal?.addEventListener("abort", onAbort, { once: true }); + if (input.signal?.aborted) onAbort(); + try { await this.client.v2.session.switchAgent({ sessionID: sessionId, agent: opencodeAgentFor(input.writeMode), @@ -77,6 +84,7 @@ export class OpencodeRuntime implements LocalAgentRuntime { await waitForOpencodeSession(this.client, sessionId, promptResult); const promptId = extractOpenCodePromptId(promptResult); const messages = await readOpencodeMessages(this.client, sessionId, promptId); + observeOpenCodeResult(messages, callbacks); const finalResponse = requireFinalResponse( extractOpenCodeFinalResponse(messages) || extractOpenCodeFinalResponse(promptResult), ); @@ -86,6 +94,9 @@ export class OpencodeRuntime implements LocalAgentRuntime { finalResponse, items: [promptResult, messages], }; + } finally { + input.signal?.removeEventListener("abort", onAbort); + } } catch (error) { if (isOpenCodeTransportFailure(error)) { this.alive = false; diff --git a/src/local-agent-pi.ts b/src/local-agent-pi.ts index 8880983b..906c95d3 100644 --- a/src/local-agent-pi.ts +++ b/src/local-agent-pi.ts @@ -14,6 +14,7 @@ import type { LocalAgentRuntime, LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; +import { observePiEvent } from "./local-agent-observation.js"; import { createPiSandboxExtension, createPiSandboxModeRef, @@ -38,7 +39,7 @@ export type PiSessionLike = Pick< | "setModel" | "setThinkingLevel" | "dispose" ->; +> & { abort?(): Promise }; export type PiSessionFactory = ( context: LocalAgentRuntimeContext, @@ -52,6 +53,7 @@ export class PiSessionRuntime implements LocalAgentRuntime { private closed = false; private collectingEvents = false; private events: unknown[] = []; + private callbacks?: LocalAgentRunCallbacks; constructor( private readonly session: PiSessionLike, @@ -60,6 +62,7 @@ export class PiSessionRuntime implements LocalAgentRuntime { if (!this.collectingEvents) return; if (this.events.length >= MAX_PI_EVENTS) this.events.shift(); this.events.push(event); + observePiEvent(event, this.callbacks); }); } @@ -80,12 +83,18 @@ export class PiSessionRuntime implements LocalAgentRuntime { await callbacks?.onSessionId?.(this.session.sessionId); await this.applyOverrides(input); this.events = []; + this.callbacks = callbacks; const messageStart = this.session.messages.length; this.collectingEvents = true; + const onAbort = () => { void this.session.abort?.().catch(() => undefined); }; + input.signal?.addEventListener("abort", onAbort, { once: true }); + if (input.signal?.aborted) onAbort(); try { await this.session.prompt(input.prompt); } finally { + input.signal?.removeEventListener("abort", onAbort); this.collectingEvents = false; + this.callbacks = undefined; } const currentMessages = this.session.messages.slice(messageStart); const finalResponse = extractPiFinalResponse({ messages: currentMessages }); diff --git a/src/local-agent-presentation.ts b/src/local-agent-presentation.ts index 21916afa..f5ff569e 100644 --- a/src/local-agent-presentation.ts +++ b/src/local-agent-presentation.ts @@ -38,8 +38,15 @@ export interface AgentFailureOutput { retryable: boolean; } +export interface AgentCommandErrorOutput { + code: string; + message: string; + retryable?: boolean; + agentId?: string; +} + export type AgentObservationOutput = - | { id: string; status: "running" } + | { id: string; status: "running"; wait?: "timeout" } | { id: string; status: "completed"; response?: string } | { id: string; status: "failed"; error: AgentFailureOutput } | { id: string; status: "stopped"; error?: AgentFailureOutput }; @@ -98,37 +105,62 @@ export function presentAgentObservation(record: LocalAgentRecord): AgentObservat } export function formatAgentTargetCatalog(catalog: AgentTargetCatalogOutput): string { - if (catalog.targets.length === 0) return "No usable subagent targets."; return catalog.targets.map((target) => { - const settings = [ - target.model ? `model=${target.model}` : undefined, - target.effort ? `effort=${target.effort}` : undefined, - ].filter(Boolean).join(" "); + const settings = xmlAttributes({ model: target.model, effort: target.effort }); if (target.kind === "provider") { - return `${target.name} [provider]${settings ? ` ${settings}` : ""}`; + return ``; } - return `${target.name} [profile, ${target.provider}]${settings ? ` ${settings}` : ""} - ${target.description}`; + return `${escapeXmlText(target.description)}`; }).join("\n"); } export function formatAgentReceipt(receipt: AgentReceiptOutput): string { - return `${receipt.id} ${receipt.status}`; + return ``; } export function formatAgentSummary(summary: AgentSummaryOutput): string { - return `${formatAgentReceipt(summary)} ${summary.target}`; + return ``; } export function formatAgentObservation(observation: AgentObservationOutput): string { - const line = formatAgentReceipt(observation); + if (observation.status === "running" && observation.wait) { + return ``; + } if (observation.status === "completed" && observation.response !== undefined) { - return `${line}\n\n${observation.response}`; + return `${escapeXmlText(observation.response)}`; } if ((observation.status === "failed" || observation.status === "stopped") && observation.error) { - const retryable = observation.error.retryable ? " [retryable]" : ""; - return `${line} ${observation.error.code}: ${observation.error.message}${retryable}`; + return `${escapeXmlText(observation.error.message)}`; } - return line; + return formatAgentReceipt(observation); +} + +export function formatAgentCommandError(error: AgentCommandErrorOutput): string { + const agentId = error.agentId ? ` agent-id="${escapeXmlAttribute(error.agentId)}"` : ""; + return `${escapeXmlText(error.message)}`; +} + +function xmlAttributes(values: Record): string { + return Object.entries(values) + .filter((entry): entry is [string, string] => entry[1] !== undefined) + .map(([name, value]) => ` ${name}="${escapeXmlAttribute(value)}"`) + .join(""); +} + +function escapeXmlAttribute(value: string): string { + return escapeXml(value).replaceAll('"', """).replaceAll("'", "'"); +} + +function escapeXmlText(value: string): string { + return escapeXml(value); +} + +function escapeXml(value: string): string { + return value + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g, "\uFFFD") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); } function presentAgentStatus(status: LocalAgentStatus): AgentCommandStatus { diff --git a/src/local-agent-resolution.test.ts b/src/local-agent-resolution.test.ts new file mode 100644 index 00000000..7cbc3cc1 --- /dev/null +++ b/src/local-agent-resolution.test.ts @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import type { LocalAgentProfile } from "./local-agent-profiles.js"; +import { + LocalAgentResolutionError, + resolveLocalAgentExecution, +} from "./local-agent-resolution.js"; + +const reviewer: LocalAgentProfile = { + name: "reviewer", + description: "Review changes.", + provider: "codex", + model: "gpt-profile", + effort: "high", + filePath: "/repo/.devspace/agents/reviewer.md", + body: "Review carefully.", + disabled: false, +}; + +const profile = resolveLocalAgentExecution({ + target: "reviewer", + prompt: "Inspect src/auth.ts", + profiles: [reviewer], + availableProviders: ["codex"], +}); +assert.equal(profile.kind, "profile"); +assert.equal(profile.provider, "codex"); +assert.equal(profile.model, "gpt-profile"); +assert.equal(profile.effort, "high"); +assert.equal(profile.prompt, "Review carefully.\n\nTask:\nInspect src/auth.ts"); +assert.equal(profile.profileFingerprint?.length, 64); + +const overridden = resolveLocalAgentExecution({ + profile: "reviewer", + prompt: "Inspect src/auth.ts", + profiles: [reviewer], + availableProviders: ["codex"], + model: "gpt-call", + effort: "xhigh", +}); +assert.equal(overridden.model, "gpt-call"); +assert.equal(overridden.effort, "xhigh"); + +const provider = resolveLocalAgentExecution({ + target: "claude", + prompt: "Investigate the failure", + profiles: [], + availableProviders: ["claude"], +}); +assert.equal(provider.kind, "provider"); +assert.equal(provider.prompt, "Investigate the failure"); + +const fallback = resolveLocalAgentExecution({ + prompt: "Investigate the failure", + profiles: [], + availableProviders: ["pi", "codex"], +}); +assert.equal(fallback.provider, "pi"); + +assert.throws( + () => resolveLocalAgentExecution({ + profile: "missing", + prompt: "x", + profiles: [reviewer], + availableProviders: ["codex"], + }), + (error) => error instanceof LocalAgentResolutionError && error.kind === "profile_not_found", +); + +assert.throws( + () => resolveLocalAgentExecution({ + target: "reviewer", + prompt: "x", + profiles: [reviewer], + availableProviders: ["claude"], + }), + /requires unavailable provider codex/, +); + +assert.throws( + () => resolveLocalAgentExecution({ + prompt: "x", + profiles: [], + availableProviders: [], + }), + (error) => error instanceof LocalAgentResolutionError && error.kind === "no_provider", +); + +console.log("local-agent-resolution.test.ts: ok"); diff --git a/src/local-agent-resolution.ts b/src/local-agent-resolution.ts new file mode 100644 index 00000000..f2252970 --- /dev/null +++ b/src/local-agent-resolution.ts @@ -0,0 +1,154 @@ +import { + isLocalAgentProvider, + type LocalAgentProfile, + type LocalAgentProvider, +} from "./local-agent-profiles.js"; +import { createHash } from "node:crypto"; + +export type LocalAgentResolutionErrorKind = + | "target_not_found" + | "profile_not_found" + | "provider_unavailable" + | "no_provider"; + +export class LocalAgentResolutionError extends Error { + constructor( + readonly kind: LocalAgentResolutionErrorKind, + message: string, + ) { + super(message); + this.name = "LocalAgentResolutionError"; + } +} + +export interface ResolvedLocalAgentExecution { + kind: "profile" | "provider"; + name: string; + provider: LocalAgentProvider; + model?: string; + effort?: string; + prompt: string; + profile?: LocalAgentProfile; + profileName?: string; + profileFingerprint?: string; +} + +export interface ResolveLocalAgentExecutionInput { + prompt: string; + profiles: LocalAgentProfile[]; + availableProviders: LocalAgentProvider[]; + /** CLI-style target. Profiles shadow a raw provider with the same name. */ + target?: string; + /** Workflow-style explicit profile selection. */ + profile?: string; + /** Workflow-style explicit provider selection. */ + provider?: LocalAgentProvider; + /** Workflow metadata fallback before the first available provider. */ + defaultProvider?: LocalAgentProvider; + model?: string; + effort?: string; +} + +/** + * Resolve the executable provider, prompt, and model controls for both direct + * subagents and workflow agent() calls. Provider policy defaults can be added + * here later without changing either caller. + */ +export function resolveLocalAgentExecution( + input: ResolveLocalAgentExecutionInput, +): ResolvedLocalAgentExecution { + if (input.target !== undefined) { + const profile = input.profiles.find((candidate) => candidate.name === input.target); + if (profile) return resolveProfile(profile, input); + if (!isLocalAgentProvider(input.target)) { + throw new LocalAgentResolutionError( + "target_not_found", + `Unknown subagent profile or provider: ${input.target}`, + ); + } + return resolveProvider(input.target, input, "requested"); + } + + if (input.profile) { + const profile = input.profiles.find((candidate) => candidate.name === input.profile); + if (!profile) { + const available = input.profiles.map((candidate) => candidate.name).join(", "); + throw new LocalAgentResolutionError( + "profile_not_found", + `Unknown agent profile: ${input.profile}${available ? `. Available profiles: ${available}` : ""}`, + ); + } + return resolveProfile(profile, input); + } + + if (input.provider) return resolveProvider(input.provider, input, "requested"); + if (input.defaultProvider) return resolveProvider(input.defaultProvider, input, "default"); + + const provider = input.availableProviders[0]; + if (!provider) { + throw new LocalAgentResolutionError("no_provider", "No agent providers are available"); + } + return resolveProvider(provider, input, "fallback"); +} + +function resolveProfile( + profile: LocalAgentProfile, + input: ResolveLocalAgentExecutionInput, +): ResolvedLocalAgentExecution { + if (!input.availableProviders.includes(profile.provider)) { + throw new LocalAgentResolutionError( + "provider_unavailable", + `Agent profile ${profile.name} requires unavailable provider ${profile.provider}`, + ); + } + return { + kind: "profile", + name: profile.name, + provider: profile.provider, + model: input.model ?? profile.model, + effort: input.effort ?? profile.effort, + prompt: buildProfilePrompt(profile, input.prompt), + profile, + profileName: profile.name, + profileFingerprint: fingerprintProfile(profile), + }; +} + +function buildProfilePrompt(profile: LocalAgentProfile, prompt: string): string { + const instructions = profile.body.trim(); + return instructions ? `${instructions}\n\nTask:\n${prompt}` : prompt; +} + +function fingerprintProfile(profile: LocalAgentProfile): string { + return createHash("sha256") + .update(JSON.stringify({ + name: profile.name, + provider: profile.provider, + model: profile.model, + effort: profile.effort, + body: profile.body, + })) + .digest("hex"); +} + +function resolveProvider( + provider: LocalAgentProvider, + input: ResolveLocalAgentExecutionInput, + source: "requested" | "default" | "fallback", +): ResolvedLocalAgentExecution { + if (!input.availableProviders.includes(provider)) { + const label = source === "default" ? "Default provider" : "Provider"; + throw new LocalAgentResolutionError( + "provider_unavailable", + `${label} ${provider} is not available`, + ); + } + return { + kind: "provider", + name: provider, + provider, + model: input.model, + effort: input.effort, + prompt: input.prompt, + }; +} diff --git a/src/local-agent-runtime-pool.ts b/src/local-agent-runtime-pool.ts index a0315024..89b1f24d 100644 --- a/src/local-agent-runtime-pool.ts +++ b/src/local-agent-runtime-pool.ts @@ -129,6 +129,8 @@ export class LocalAgentRuntimePool { if (reservationError) throw reservationError; await inputCallbacks?.onSessionId?.(providerSessionId); }, + onUsage: (usage) => inputCallbacks?.onUsage?.(usage), + onActivity: (activity) => inputCallbacks?.onActivity?.(activity), }; const startedAt = this.now(); try { diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index ecc40f54..99285ec3 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -7,6 +7,7 @@ export type LocalAgentWriteMode = "read_only" | "allowed" | "full_access"; export interface LocalAgentRunInput { prompt: string; workspaceRoot: string; + signal?: AbortSignal; providerSessionId?: string; writeMode?: LocalAgentWriteMode; model?: string; @@ -22,6 +23,24 @@ export interface LocalAgentRunResult { items: unknown[]; } +export interface LocalAgentUsageSnapshot { + inputTokens?: number; + cachedInputTokens?: number; + cacheCreationInputTokens?: number; + outputTokens?: number; + totalTokens: number; + state: "partial" | "final"; +} + +export interface LocalAgentActivity { + kind: "tool" | "command" | "file" | "status"; + status: "running" | "completed" | "failed"; + label: string; + detail?: string; + startedAt?: string; + completedAt?: string; +} + export interface LocalAgentRunCallbacks { /** * Called as soon as a provider creates or resolves a durable continuation @@ -29,6 +48,8 @@ export interface LocalAgentRunCallbacks { * could otherwise fail and lose that identity. */ onSessionId?: (providerSessionId: string) => void | Promise; + onUsage?: (usage: LocalAgentUsageSnapshot) => void; + onActivity?: (activity: LocalAgentActivity) => void; } export interface LocalAgentRuntimeContext { diff --git a/src/local-agent-store.test.ts b/src/local-agent-store.test.ts index 829940f9..3e0041c5 100644 --- a/src/local-agent-store.test.ts +++ b/src/local-agent-store.test.ts @@ -35,6 +35,8 @@ try { error: "Codex executable was not found.", errorCode: "PROVIDER_UNAVAILABLE", errorRetryable: false, + usage: { inputTokens: 10, outputTokens: 4, totalTokens: 14, state: "final" }, + activity: [{ kind: "command", status: "completed", label: "npm test" }], }); assert.equal(updated.status, "error"); @@ -46,15 +48,87 @@ try { assert.equal(storedError?.error, "Codex executable was not found."); assert.equal(storedError?.errorCode, "PROVIDER_UNAVAILABLE"); assert.equal(storedError?.errorRetryable, false); + assert.deepEqual(storedError?.usage, { + inputTokens: 10, + cachedInputTokens: undefined, + cacheCreationInputTokens: undefined, + outputTokens: 4, + totalTokens: 14, + state: "final", + }); + assert.deepEqual(storedError?.activity, [ + { kind: "command", status: "completed", label: "npm test" }, + ]); assert.equal(store.update(created.id, { latestResponse: undefined }).latestResponse, undefined); assert.deepEqual( store.list({ workspaceRoot: join(root, "project") }).map((agent) => agent.latestResponse), [undefined], ); -assert.deepEqual(store.list({ workspaceId: "ws_1" }).map((agent) => agent.id), [created.id]); -assert.deepEqual(store.list({ workspaceId: "ws_other" }), []); -assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "other") }), []); -assert.deepEqual(store.list({ workspaceRoot: join(root, "other") }), []); + assert.deepEqual(store.list({ workspaceId: "ws_1" }).map((agent) => agent.id), [created.id]); + assert.deepEqual(store.list({ workspaceId: "ws_other" }), []); + assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "other") }), []); + assert.deepEqual(store.list({ workspaceRoot: join(root, "other") }), []); + + const begun = store.beginTurn(created.id, { + prompt: "Review the current changes.", + model: updated.model, + effort: updated.effort, + }); + assert.equal(begun.agent.status, "running"); + assert.equal(begun.turn.agentId, created.id); + assert.equal(begun.turn.prompt, "Review the current changes."); + assert.equal(begun.turn.status, "running"); + assert.equal(begun.turn.completedAt, undefined); + assert.equal(begun.agent.usage, undefined); + assert.deepEqual(begun.agent.activity, []); + + const completed = store.finishTurn(created.id, begun.turn.id, { + status: "completed", + response: "No issues found.", + providerSessionId: "thread_456", + }); + assert.equal(completed.status, "idle"); + assert.equal(completed.latestResponse, "No issues found."); + assert.equal(completed.providerSessionId, "thread_456"); + const completedTurn = store.getLatestTurn(created.id); + assert.equal(completedTurn?.id, begun.turn.id); + assert.equal(completedTurn?.status, "completed"); + assert.equal(completedTurn?.response, "No issues found."); + assert.ok(completedTurn?.completedAt); + + const failing = store.beginTurn(created.id, { + prompt: "Retry the review.", + model: completed.model, + effort: completed.effort, + }); + store.finishTurn(created.id, failing.turn.id, { + status: "failed", + error: "Provider disconnected.", + errorCode: "PROVIDER_EXECUTION_ERROR", + errorRetryable: true, + }); + assert.deepEqual( + store.listTurns(created.id).map((turn) => ({ + prompt: turn.prompt, + status: turn.status, + response: turn.response, + errorCode: turn.errorCode, + })), + [ + { + prompt: "Review the current changes.", + status: "completed", + response: "No issues found.", + errorCode: undefined, + }, + { + prompt: "Retry the review.", + status: "failed", + response: undefined, + errorCode: "PROVIDER_EXECUTION_ERROR", + }, + ], + ); const otherStore = new LocalAgentStore(root); stores.push(otherStore); @@ -69,6 +143,7 @@ assert.deepEqual(store.list({ workspaceRoot: join(root, "other") }), []); store.list({ workspaceId: "ws_1" }).map((agent) => agent.id).sort(), [created.id, createdFromOtherStore.id].sort(), ); + assert.equal(otherStore.listTurns(created.id).length, 2); const legacyStateDir = join(root, "legacy-state"); mkdirSync(legacyStateDir, { recursive: true }); @@ -137,6 +212,12 @@ assert.deepEqual(store.list({ workspaceRoot: join(root, "other") }), []); assert.equal(reloadedRecord?.error, "old error"); assert.equal(reloadedRecord?.errorCode, "DAEMON_TIMEOUT"); assert.equal(reloadedRecord?.errorRetryable, true); + const legacyTurn = upgradedStore.beginTurn("agt_legacy", { + prompt: "Continue after upgrade.", + model: reloadedRecord?.model, + effort: reloadedRecord?.effort, + }); + assert.equal(legacyTurn.turn.status, "running"); } finally { for (const store of stores) { store.close(); diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index 74bf875d..b401ac33 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -3,8 +3,10 @@ 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 { LocalAgentActivity, LocalAgentUsageSnapshot } from "./local-agent-runtime.js"; export type LocalAgentStatus = "starting" | "running" | "idle" | "error" | "stopped"; +export type LocalAgentTurnStatus = "running" | "completed" | "failed" | "stopped"; export interface LocalAgentRecord { id: string; @@ -20,6 +22,8 @@ export interface LocalAgentRecord { error?: string; errorCode?: string; errorRetryable?: boolean; + usage?: LocalAgentUsageSnapshot; + activity?: LocalAgentActivity[]; createdAt: string; updatedAt: string; } @@ -33,6 +37,35 @@ export interface CreateLocalAgentRecordInput { effort?: string; } +export interface LocalAgentTurnRecord { + id: number; + agentId: string; + prompt: string; + status: LocalAgentTurnStatus; + response?: string; + error?: string; + errorCode?: string; + errorRetryable?: boolean; + createdAt: string; + completedAt?: string; +} + +export interface BeginLocalAgentTurnInput { + prompt: string; + model?: string; + effort?: string; +} + +export type FinishLocalAgentTurnInput = + | { status: "completed"; response?: string; providerSessionId?: string } + | { status: "failed"; error: string; errorCode: string; errorRetryable: boolean } + | { status: "stopped"; error?: string; errorCode?: string; errorRetryable?: boolean }; + +export interface BegunLocalAgentTurn { + agent: LocalAgentRecord; + turn: LocalAgentTurnRecord; +} + export interface LocalAgentWorkspaceScope { workspaceId?: string; workspaceRoot: string; @@ -57,10 +90,25 @@ interface LocalAgentRow { error: string | null; error_code: string | null; error_retryable: string | null; + usage_json: string | null; + activity_json: string | null; created_at: string; updated_at: string; } +interface LocalAgentTurnRow { + id: number; + agent_id: string; + prompt: string; + status: string; + response: string | null; + error: string | null; + error_code: string | null; + error_retryable: string | null; + created_at: string; + completed_at: string | null; +} + export class LocalAgentStore { private readonly database: DatabaseHandle; @@ -205,6 +253,8 @@ export class LocalAgentStore { error = ?, error_code = ?, error_retryable = ?, + usage_json = ?, + activity_json = ?, updated_at = ? where id = ?`, ) @@ -221,6 +271,8 @@ export class LocalAgentStore { updated.error ?? null, updated.errorCode ?? null, updated.errorRetryable === undefined ? null : String(updated.errorRetryable), + updated.usage === undefined ? null : JSON.stringify(updated.usage), + updated.activity === undefined ? null : JSON.stringify(updated.activity), updated.updatedAt, updated.id, ); @@ -235,16 +287,164 @@ export class LocalAgentStore { return storeResult("update", () => this.update(id, patch)); } + beginTurn(agentId: string, input: BeginLocalAgentTurnInput): BegunLocalAgentTurn { + return this.database.sqlite.transaction(() => { + const agent = this.update(agentId, { + status: "running", + model: input.model, + effort: input.effort, + latestResponse: undefined, + error: undefined, + errorCode: undefined, + errorRetryable: undefined, + usage: undefined, + activity: [], + }); + const result = this.database.sqlite + .prepare( + `insert into local_agent_turns ( + agent_id, + prompt, + status, + created_at + ) values (?, ?, 'running', ?)`, + ) + .run(agentId, input.prompt, agent.updatedAt); + const turn = this.getTurnById(Number(result.lastInsertRowid)); + if (!turn) throw new Error(`Unable to load the new turn for subagent ${agentId}.`); + return { agent, turn }; + }).immediate(); + } + + beginTurnResult( + agentId: string, + input: BeginLocalAgentTurnInput, + ): BetterResult { + return storeResult("begin_turn", () => this.beginTurn(agentId, input)); + } + + finishTurn( + agentId: string, + turnId: number, + completion: FinishLocalAgentTurnInput, + ): LocalAgentRecord { + return this.database.sqlite.transaction(() => { + const turn = this.getTurnById(turnId); + if (!turn || turn.agentId !== agentId) { + throw new Error(`Unknown turn ${turnId} for subagent ${agentId}.`); + } + if (turn.status !== "running") { + throw new Error(`Turn ${turnId} for subagent ${agentId} is already ${turn.status}.`); + } + const currentAgent = this.getById(agentId); + if (!currentAgent) throw new Error(`Unknown subagent id: ${agentId}`); + + const completedAt = new Date().toISOString(); + this.database.sqlite + .prepare( + `update local_agent_turns set + status = ?, + response = ?, + error = ?, + error_code = ?, + error_retryable = ?, + completed_at = ? + where id = ? and agent_id = ?`, + ) + .run( + completion.status, + completion.status === "completed" ? completion.response ?? null : null, + completion.status === "completed" ? null : completion.error ?? null, + completion.status === "completed" ? null : completion.errorCode ?? null, + completion.status === "completed" || completion.errorRetryable === undefined + ? null + : String(completion.errorRetryable), + completedAt, + turnId, + agentId, + ); + + if (completion.status === "completed") { + return this.update(agentId, { + providerSessionId: completion.providerSessionId ?? currentAgent.providerSessionId, + status: "idle", + latestResponse: completion.response, + error: undefined, + errorCode: undefined, + errorRetryable: undefined, + }); + } + return this.update(agentId, { + status: completion.status === "failed" ? "error" : "stopped", + latestResponse: undefined, + error: completion.error, + errorCode: completion.errorCode, + errorRetryable: completion.errorRetryable, + }); + }).immediate(); + } + + finishTurnResult( + agentId: string, + turnId: number, + completion: FinishLocalAgentTurnInput, + ): BetterResult { + return storeResult("finish_turn", () => this.finishTurn(agentId, turnId, completion)); + } + + getTurnById(turnId: number): LocalAgentTurnRecord | undefined { + const row = this.database.sqlite + .prepare("select * from local_agent_turns where id = ? limit 1") + .get(turnId) as LocalAgentTurnRow | undefined; + return row ? rowToLocalAgentTurnRecord(row) : undefined; + } + + getTurnByIdResult( + turnId: number, + ): BetterResult { + return storeResult("get_turn", () => this.getTurnById(turnId)); + } + + getLatestTurn(agentId: string): LocalAgentTurnRecord | undefined { + const row = this.database.sqlite + .prepare("select * from local_agent_turns where agent_id = ? order by id desc limit 1") + .get(agentId) as LocalAgentTurnRow | undefined; + return row ? rowToLocalAgentTurnRecord(row) : undefined; + } + + getLatestTurnResult( + agentId: string, + ): BetterResult { + return storeResult("get_latest_turn", () => this.getLatestTurn(agentId)); + } + + listTurns(agentId: string): LocalAgentTurnRecord[] { + const rows = this.database.sqlite + .prepare("select * from local_agent_turns where agent_id = ? order by id asc") + .all(agentId) as LocalAgentTurnRow[]; + return rows.map(rowToLocalAgentTurnRecord); + } + reconcileActiveRuns(message = "DevSpace restarted while this agent turn was running."): number { - const now = new Date().toISOString(); - const result = this.database.sqlite - .prepare( - `update local_agent_sessions - set status = 'error', error = ?, error_code = 'DAEMON_UNAVAILABLE', error_retryable = 'true', updated_at = ? - where status in ('starting', 'running')`, - ) - .run(message, now); - return Number(result.changes); + return this.database.sqlite.transaction(() => { + const now = new Date().toISOString(); + this.database.sqlite + .prepare( + `update local_agent_turns + set status = 'failed', error = ?, error_code = 'DAEMON_UNAVAILABLE', + error_retryable = 'true', completed_at = ? + where status = 'running'`, + ) + .run(message, now); + const result = this.database.sqlite + .prepare( + `update local_agent_sessions + set status = 'error', error = ?, error_code = 'DAEMON_UNAVAILABLE', error_retryable = 'true', updated_at = ? + where status in ('starting', 'running')`, + ) + .run(message, now); + return Number(result.changes); + }).immediate(); } reconcileActiveRunsResult( @@ -278,11 +478,88 @@ function rowToLocalAgentRecord(row: LocalAgentRow): LocalAgentRecord { error: row.error ?? undefined, errorCode: row.error_code ?? undefined, errorRetryable: readOptionalBoolean(row.error_retryable), + usage: readUsage(row.usage_json), + activity: readActivity(row.activity_json), createdAt: row.created_at, updatedAt: row.updated_at, }; } +function readUsage(value: string | null): LocalAgentUsageSnapshot | undefined { + if (!value) return undefined; + const parsed = JSON.parse(value) as unknown; + if (!isRecord(parsed)) return undefined; + const state = parsed.state; + const totalTokens = parsed.totalTokens; + if ((state !== "partial" && state !== "final") || !isNonNegativeInteger(totalTokens)) { + return undefined; + } + return { + inputTokens: optionalNonNegativeInteger(parsed.inputTokens), + cachedInputTokens: optionalNonNegativeInteger(parsed.cachedInputTokens), + cacheCreationInputTokens: optionalNonNegativeInteger(parsed.cacheCreationInputTokens), + outputTokens: optionalNonNegativeInteger(parsed.outputTokens), + totalTokens, + state, + }; +} + +function readActivity(value: string | null): LocalAgentActivity[] | undefined { + if (!value) return undefined; + const parsed = JSON.parse(value) as unknown; + if (!Array.isArray(parsed)) return undefined; + return parsed.flatMap((item): LocalAgentActivity[] => { + if (!isRecord(item)) return []; + if ( + item.kind !== "tool" && item.kind !== "command" && item.kind !== "file" && item.kind !== "status" + ) return []; + if (item.status !== "running" && item.status !== "completed" && item.status !== "failed") return []; + if (typeof item.label !== "string" || !item.label) return []; + return [{ + kind: item.kind, + status: item.status, + label: item.label, + ...(typeof item.detail === "string" ? { detail: item.detail } : {}), + ...(typeof item.startedAt === "string" ? { startedAt: item.startedAt } : {}), + ...(typeof item.completedAt === "string" ? { completedAt: item.completedAt } : {}), + }]; + }); +} + +function optionalNonNegativeInteger(value: unknown): number | undefined { + return isNonNegativeInteger(value) ? value : undefined; +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 0; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function rowToLocalAgentTurnRecord(row: LocalAgentTurnRow): LocalAgentTurnRecord { + return { + id: row.id, + agentId: row.agent_id, + prompt: row.prompt, + status: readTurnStatus(row.status), + response: row.response ?? undefined, + error: row.error ?? undefined, + errorCode: row.error_code ?? undefined, + errorRetryable: readOptionalBoolean(row.error_retryable), + createdAt: row.created_at, + completedAt: row.completed_at ?? undefined, + }; +} + +function readTurnStatus(status: string): LocalAgentTurnStatus { + if (status === "running" || status === "completed" || status === "failed" || status === "stopped") { + return status; + } + throw new Error(`Invalid stored local agent turn status: ${status}`); +} + function readOptionalBoolean(value: string | null): boolean | undefined { if (value === "true") return true; if (value === "false") return false; diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 225f9fdf..8c2217e0 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -47,6 +47,14 @@ async function testDatabaseConfiguration(stateDir: string): Promise { { version: 4, name: "workspace-conversation-bindings" }, { version: 5, name: "local-agent-structured-errors" }, { version: 6, name: "local-agent-effort-rename" }, + { version: 7, name: "local-agent-turns" }, + { version: 8, name: "workflow-journal" }, + { version: 9, name: "workflow-replay-provenance" }, + { version: 10, name: "workflow-exact-replay" }, + { version: 11, name: "workflow-agent-profiles" }, + { version: 12, name: "workflow-observability" }, + { version: 13, name: "reconcile-workflow-stack-schema" }, + { version: 14, name: "local-agent-observability" }, ]); } finally { database.close(); diff --git a/src/onboarding.test.ts b/src/onboarding.test.ts index b4236e9c..6c57335e 100644 --- a/src/onboarding.test.ts +++ b/src/onboarding.test.ts @@ -30,7 +30,14 @@ assert.deepEqual( const configured = { enabled: true, providers: [ - { id: "codex" as const, enabled: true, model: "gpt-5.4", effort: "high" }, + { + id: "codex" as const, + enabled: true, + model: "gpt-5.4", + effort: "high", + command: "/opt/bin/codex-wrapper", + env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" }, + }, { id: "claude" as const, enabled: true, model: "sonnet" }, ], }; @@ -39,7 +46,14 @@ assert.deepEqual( { enabled: true, providers: [ - { id: "codex", enabled: false, model: "gpt-5.4", effort: "high" }, + { + id: "codex", + enabled: false, + model: "gpt-5.4", + effort: "high", + command: "/opt/bin/codex-wrapper", + env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" }, + }, { id: "claude", enabled: true, model: "sonnet" }, ], }, diff --git a/src/onboarding.ts b/src/onboarding.ts index 2642e776..370c9c36 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -5,7 +5,10 @@ import { } from "./local-agent-profiles.js"; export const SUBAGENT_SKILL_INSTALL_COMMAND = - "npx skills add Waishnav/devspace --skill subagents --global"; + [ + "npx skills add Waishnav/devspace --skill subagents --global", + "npx skills add Waishnav/devspace --skill dynamic-workflows --global", + ].join("\n"); export const ONBOARDING_DESTINATIONS = ["chatgpt", "coding-agents"] as const; export type OnboardingDestination = typeof ONBOARDING_DESTINATIONS[number]; diff --git a/src/server.ts b/src/server.ts index 9e7ded7f..21a2123f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -734,11 +734,11 @@ export function createServer( const processSessions = new ProcessSessionManager(); const localAgentProviders = buildLocalAgentProviderStatuses( config.subagents, - getLocalAgentProviderAvailabilitySnapshot(), + getLocalAgentProviderAvailabilitySnapshot(process.env, config.subagents), ); const resolveLocalAgentProviders = () => buildLocalAgentProviderStatuses( config.subagents, - getLocalAgentProviderAvailabilitySnapshot(), + getLocalAgentProviderAvailabilitySnapshot(process.env, config.subagents), ); const logSessionCloseResults = ( diff --git a/src/skills.ts b/src/skills.ts index cf4fa332..7226524f 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -23,13 +23,16 @@ export interface SkillReadResolution { const SUBAGENTS_SKILL_NAME = "subagents"; const SUBAGENTS_SKILL = join(SUBAGENTS_SKILL_NAME, "SKILL.md"); +const DYNAMIC_WORKFLOWS_SKILL_NAME = "dynamic-workflows"; +const DYNAMIC_WORKFLOWS_SKILL = join(DYNAMIC_WORKFLOWS_SKILL_NAME, "SKILL.md"); function bundledSkillsDir(): string { return fileURLToPath(new URL("../skills", import.meta.url)); } -function hasSubagentsSkill(skillDir: string): boolean { - return existsSync(join(skillDir, SUBAGENTS_SKILL)); +function hasBundledAgentSkills(skillDir: string): boolean { + return existsSync(join(skillDir, SUBAGENTS_SKILL)) + && existsSync(join(skillDir, DYNAMIC_WORKFLOWS_SKILL)); } export function effectiveSkillPaths(config: ServerConfig, cwd: string): string[] { @@ -39,7 +42,7 @@ export function effectiveSkillPaths(config: ServerConfig, cwd: string): string[] resolve(cwd, ".agents", "skills"), config.devspaceSkillsDir, join(config.agentDir, "skills"), - config.subagents.enabled && !hasSubagentsSkill(config.devspaceSkillsDir) + config.subagents.enabled && !hasBundledAgentSkills(config.devspaceSkillsDir) ? bundledSkills : undefined, ]; @@ -74,10 +77,19 @@ export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSk if (config.subagents.enabled) return result; return { - skills: result.skills.filter((skill) => skill.name !== SUBAGENTS_SKILL_NAME), + skills: result.skills.filter((skill) => ( + skill.name !== SUBAGENTS_SKILL_NAME + && skill.name !== DYNAMIC_WORKFLOWS_SKILL_NAME + )), diagnostics: result.diagnostics.filter((diagnostic) => { const collision = diagnostic.collision; - return !(collision?.resourceType === "skill" && collision.name === SUBAGENTS_SKILL_NAME); + return !( + collision?.resourceType === "skill" + && ( + collision.name === SUBAGENTS_SKILL_NAME + || collision.name === DYNAMIC_WORKFLOWS_SKILL_NAME + ) + ); }), }; } diff --git a/src/workflow-agent-observer.ts b/src/workflow-agent-observer.ts new file mode 100644 index 00000000..b3fca3a6 --- /dev/null +++ b/src/workflow-agent-observer.ts @@ -0,0 +1,94 @@ +import type { WorkflowStore } from "./workflow-store.js"; +import type { + WorkflowAgentActivityKind, + WorkflowAgentActivityStatus, +} from "./workflow-types.js"; + +export interface WorkflowAgentUsageSnapshot { + inputTokens?: number; + cachedInputTokens?: number; + cacheCreationInputTokens?: number; + outputTokens?: number; + totalTokens: number; + state: "partial" | "final"; +} + +export interface WorkflowAgentObserver { + onSession?(providerSessionId: string): void; + onActivity?(activity: { + kind: WorkflowAgentActivityKind; + status: WorkflowAgentActivityStatus; + label: string; + detail?: string; + startedAt?: string; + completedAt?: string; + }): void; + onUsage?(usage: WorkflowAgentUsageSnapshot): void; +} + +const USAGE_WRITE_INTERVAL_MS = 5_000; + +export function createWorkflowAgentObserver( + store: WorkflowStore, + runId: string, + callIndex: number, + intervalMs = USAGE_WRITE_INTERVAL_MS, +): WorkflowAgentObserver & { close(): void } { + const baseline = store.getAgentCall(runId, callIndex)?.usage; + let lastUsageWrite = 0; + let pendingUsage: WorkflowAgentUsageSnapshot | undefined; + let timer: NodeJS.Timeout | undefined; + + const persistUsage = (usage: WorkflowAgentUsageSnapshot): void => { + pendingUsage = undefined; + if (timer) clearTimeout(timer); + timer = undefined; + lastUsageWrite = Date.now(); + store.updateAgentUsage(runId, callIndex, { + inputTokens: sumOptional(baseline?.inputTokens, usage.inputTokens), + cachedInputTokens: sumOptional(baseline?.cachedInputTokens, usage.cachedInputTokens), + cacheCreationInputTokens: sumOptional( + baseline?.cacheCreationInputTokens, + usage.cacheCreationInputTokens, + ), + outputTokens: sumOptional(baseline?.outputTokens, usage.outputTokens), + totalTokens: (baseline?.totalTokens ?? 0) + usage.totalTokens, + state: usage.state, + }); + }; + + const scheduleUsage = (): void => { + if (timer) return; + const wait = Math.max(0, intervalMs - (Date.now() - lastUsageWrite)); + timer = setTimeout(() => { + if (pendingUsage) persistUsage(pendingUsage); + }, wait); + timer.unref(); + }; + + return { + onSession(providerSessionId) { + store.attachAgentSession(runId, callIndex, providerSessionId); + }, + onActivity(activity) { + store.appendAgentActivity({ runId, callIndex, ...activity }); + }, + onUsage(usage) { + if (usage.state === "final" || Date.now() - lastUsageWrite >= intervalMs) { + persistUsage(usage); + return; + } + pendingUsage = usage; + scheduleUsage(); + }, + close() { + if (pendingUsage) persistUsage(pendingUsage); + if (timer) clearTimeout(timer); + timer = undefined; + }, + }; +} + +function sumOptional(left: number | undefined, right: number | undefined): number | undefined { + return left === undefined && right === undefined ? undefined : (left ?? 0) + (right ?? 0); +} diff --git a/src/workflow-api.ts b/src/workflow-api.ts new file mode 100644 index 00000000..158bc2c8 --- /dev/null +++ b/src/workflow-api.ts @@ -0,0 +1,830 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { createHash } from "node:crypto"; +import type { WorkflowSandboxApi } from "./workflow-sandbox.js"; +import { + type LocalAgentProfile, + type LocalAgentProvider, +} from "./local-agent-profiles.js"; +import { + LocalAgentResolutionError, + resolveLocalAgentExecution, +} from "./local-agent-resolution.js"; +import type { JsonSchema, JsonValue } from "./json-types.js"; +import { jsonValueSchema } from "./json-types.js"; +import { + WORKFLOW_LIMITS, + WORKFLOW_MAX_AGENT_CALLS, + WORKFLOW_MAX_ITEMS, + WORKFLOW_MAX_NEST_DEPTH, + buildAgentCacheKeyInput, + createStubBudget, + type AgentIsolationMode, + type AgentCacheKeyInput, + type AgentOpts, + type AppendWorkflowEventInput, + type WorkflowMeta, +} from "./workflow-types.js"; +import { agentOptsSchema } from "./workflow-contracts.js"; +import { WorkflowEngineError } from "./workflow-errors.js"; + +export { WorkflowEngineError } from "./workflow-errors.js"; + +// --------------------------------------------------------------------------- +// Host deps (injected by engine; fakes OK in tests) +// --------------------------------------------------------------------------- + +export interface WorkflowProviderRunInput { + callIndex: number; + provider: LocalAgentProvider; + prompt: string; + providerSessionId?: string; + model?: string; + effort?: string; + workspace: string; + signal?: AbortSignal; + label?: string; + phase?: string; + /** JSON Schema for native structured output (codex/claude). */ + schema?: JsonSchema; +} + +export interface WorkflowProviderRunResult { + finalResponse: string; + providerSessionId?: string; + /** Provider-native structured object when schema was requested. */ + structured?: unknown; +} + +export type WorkflowRunProvider = ( + input: WorkflowProviderRunInput, +) => Promise; + +export interface WorkflowWorktreeHandle { + path: string; + /** Called after agent returns or fails. Success+clean may remove; dirty/failure preserves. */ + finalize: (outcome: "success" | "failure") => Promise<{ dirty: boolean; removed: boolean }>; +} + +export type CreateAgentWorktree = (input: { + runId: string; + callIndex: number; + workspaceRoot: string; + baseSha?: string; +}) => Promise; + +export interface WorkflowReplayHit { + value: JsonValue; + responseText?: string; + structuredJson?: string; + returnValueJson: string; + providerSessionId?: string; + replayMatch: "same_index"; + replayedFromRunId: string; + replayedFromCallIndex: number; +} + +export interface WorkflowReplayMiss { + reason: + | "no_compatible_call" + | "prior_call_not_replayable" + | "compatible_result_consumed" + | "identity_changed" + | "prefix_diverged" + | "worktree_not_restored" + | "result_not_persisted" + | "stored_result_invalid"; + changedFields?: Array; +} + +export type WorkflowReplayDecision = + | { hit: WorkflowReplayHit; miss?: never } + | { hit?: never; miss: WorkflowReplayMiss }; + +export interface WorkflowReplay { + decide( + callIndex: number, + cacheKey: string, + input: AgentCacheKeyInput, + ): WorkflowReplayDecision; +} + +export interface WorkflowJournal { + appendEvent( + input: Extract, + ): unknown; + startAgentCall(input: { + runId: string; + callIndex: number; + cacheKey: string; + prompt: string; + schemaJson?: string; + provider: LocalAgentProvider; + model?: string; + effort?: string; + profileName?: string; + profileFingerprint?: string; + label?: string; + phase?: string; + isolation?: AgentIsolationMode; + worktreePath?: string; + replayMatch?: "same_index"; + replayedFromRunId?: string; + replayedFromCallIndex?: number; + replayReason?: string; + }): unknown; + cacheAgentCall(input: { + runId: string; + callIndex: number; + cacheKey: string; + prompt: string; + schemaJson?: string; + provider: LocalAgentProvider; + model?: string; + effort?: string; + profileName?: string; + profileFingerprint?: string; + label?: string; + phase?: string; + isolation?: AgentIsolationMode; + replayMatch: "same_index"; + replayedFromRunId: string; + replayedFromCallIndex: number; + responseText?: string; + structuredJson?: string; + returnValueJson?: string; + providerSessionId?: string; + }): unknown; + completeAgentCall(input: { + runId: string; + callIndex: number; + responseText?: string; + structuredJson?: string; + returnValueJson?: string; + providerSessionId?: string; + dirty?: boolean; + worktreePath?: string; + fromCache?: boolean; + }): unknown; + failAgentCall(input: { + runId: string; + callIndex: number; + error: string; + errorKind?: import("./workflow-types.js").WorkflowErrorKind; + worktreePath?: string; + dirty?: boolean; + cleanupError?: string; + }): unknown; + isCancelRequested(runId: string): boolean; +} + +export interface WorkflowApiDeps { + runId: string; + journal: WorkflowJournal; + meta: WorkflowMeta; + args: JsonValue | undefined; + concurrency: number; + signal: AbortSignal; + workspaceRoot: string; + baseSha?: string; + /** Currently available provider ids in stable preference order. */ + availableProviders: LocalAgentProvider[]; + /** Loaded profiles available to this project. */ + agentProfiles?: LocalAgentProfile[]; + runProvider: WorkflowRunProvider; + createWorktree?: CreateAgentWorktree; + replay?: WorkflowReplay; + /** Nested workflow source loader; required for workflow(). */ + resolveNestedSource?: (nameOrRef: string | { scriptPath: string }) => string | Promise; + /** Run a nested script sharing semaphore/callIndex. */ + executeNested?: (input: { + source: string; + args: JsonValue | undefined; + nestDepth: number; + }) => Promise; + nestDepth?: number; + runtime?: WorkflowApiRuntime; +} + +export interface WorkflowApi extends WorkflowSandboxApi { + getCallCount(): number; + getNestDepth(): number; +} + +// --------------------------------------------------------------------------- +// Semaphore +// --------------------------------------------------------------------------- + +export class WorkflowSemaphore { + private active = 0; + private readonly waiters: Array<() => void> = []; + + constructor(readonly limit: number) { + if (!Number.isFinite(limit) || limit < 1) { + throw new Error("WorkflowSemaphore limit must be >= 1"); + } + } + + async acquire(signal?: AbortSignal): Promise { + if (signal?.aborted) throw cancelledError(); + if (this.active < this.limit) { + this.active += 1; + return; + } + await new Promise((resolve, reject) => { + const onAbort = () => { + const idx = this.waiters.indexOf(wake); + if (idx >= 0) this.waiters.splice(idx, 1); + reject(cancelledError()); + }; + const wake = () => { + signal?.removeEventListener("abort", onAbort); + this.active += 1; + resolve(); + }; + this.waiters.push(wake); + signal?.addEventListener("abort", onAbort, { once: true }); + }); + } + + release(): void { + this.active = Math.max(0, this.active - 1); + const next = this.waiters.shift(); + if (next) next(); + } +} + +export interface WorkflowApiRuntime { + semaphore: WorkflowSemaphore; + callIndex: number; +} + +export function createWorkflowApiRuntime(concurrency: number): WorkflowApiRuntime { + return { + semaphore: new WorkflowSemaphore(Math.max(1, concurrency)), + callIndex: 0, + }; +} + +// --------------------------------------------------------------------------- +// API factory +// --------------------------------------------------------------------------- + +const phaseAls = new AsyncLocalStorage(); + +export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { + const nestDepth = deps.nestDepth ?? 0; + const runtime = deps.runtime ?? createWorkflowApiRuntime(deps.concurrency); + const semaphore = runtime.semaphore; + + const agent = async (prompt: unknown, opts: unknown = {}): Promise => { + if (typeof prompt !== "string" || !prompt.trim()) { + throw new WorkflowEngineError("internal", "agent(prompt) requires a non-empty string"); + } + const agentOpts = normalizeAgentOpts(opts); + throwIfCancelled(deps); + + const target = resolveAgentTarget(prompt, agentOpts, deps); + const { + provider, + model, + effort, + profileName, + profileFingerprint, + providerPrompt, + } = target; + const phase = agentOpts.phase ?? phaseAls.getStore(); + const isolation: AgentIsolationMode = + agentOpts.isolation === "worktree" ? "worktree" : "shared"; + const index = allocateAgentCallIndex(runtime); + + const cacheKeyInput = buildAgentCacheKeyInput({ + prompt, + profileName, + profileFingerprint, + provider, + model, + effort, + schema: agentOpts.schema, + isolation, + }); + const cacheKey = hashCacheKey(cacheKeyInput); + + const replayDecision = deps.replay?.decide(index, cacheKey, cacheKeyInput); + if (replayDecision?.hit) { + const hit = replayDecision.hit; + deps.journal.cacheAgentCall({ + runId: deps.runId, + callIndex: index, + cacheKey, + prompt, + schemaJson: agentOpts.schema ? JSON.stringify(agentOpts.schema) : undefined, + provider, + model, + effort, + profileName, + profileFingerprint, + label: agentOpts.label, + phase, + isolation, + replayMatch: hit.replayMatch, + replayedFromRunId: hit.replayedFromRunId, + replayedFromCallIndex: hit.replayedFromCallIndex, + responseText: hit.responseText, + structuredJson: hit.structuredJson, + returnValueJson: hit.returnValueJson, + providerSessionId: hit.providerSessionId, + }); + return hit.value; + } + + await semaphore.acquire(deps.signal); + let worktree: WorkflowWorktreeHandle | null = null; + let worktreePath: string | undefined; + let agentCallBegun = false; + try { + throwIfCancelled(deps); + + deps.journal.startAgentCall({ + runId: deps.runId, + callIndex: index, + cacheKey, + prompt, + schemaJson: agentOpts.schema ? JSON.stringify(agentOpts.schema) : undefined, + provider, + model, + effort, + profileName, + profileFingerprint, + label: agentOpts.label, + phase, + isolation, + replayReason: replayDecision?.miss + ? formatReplayMiss(replayDecision.miss) + : undefined, + }); + agentCallBegun = true; + + if (isolation === "worktree") { + if (!deps.createWorktree) { + throw new WorkflowEngineError( + "worktree", + "isolation: 'worktree' requires createWorktree host support", + ); + } + worktree = await deps.createWorktree({ + runId: deps.runId, + callIndex: index, + workspaceRoot: deps.workspaceRoot, + baseSha: deps.baseSha, + }); + worktreePath = worktree.path; + deps.journal.appendEvent({ + runId: deps.runId, + type: "worktree_created", + phase, + label: agentOpts.label, + data: { callIndex: index, worktreePath, isolation }, + }); + } + + const cwd = worktreePath ?? deps.workspaceRoot; + const providerBase = { + callIndex: index, + provider, + prompt: providerPrompt, + model, + effort, + workspace: cwd, + signal: deps.signal, + label: agentOpts.label, + phase, + }; + + let returnValue: unknown; + let structuredJson: string | undefined; + let result: WorkflowProviderRunResult; + + if (agentOpts.schema) { + // Lazy import keeps non-schema paths free of ajv load cost. + const { enforceAgentSchema } = await import("./workflow-schema.js"); + const enforced = await enforceAgentSchema({ + schema: agentOpts.schema, + prompt: providerPrompt, + provider, + run: (p, options) => + deps.runProvider({ + ...providerBase, + prompt: p, + providerSessionId: options.providerSessionId, + ...(options.mode === "native" ? { schema: agentOpts.schema } : {}), + }), + onRetry: ({ attempt, errors, mode }) => { + deps.journal.appendEvent({ + runId: deps.runId, + type: "schema_retry", + phase, + label: agentOpts.label, + data: { callIndex: index, attempt, errors, mode }, + }); + }, + }); + returnValue = enforced.value; + structuredJson = JSON.stringify(enforced.value); + result = { + finalResponse: enforced.finalResponse, + providerSessionId: enforced.providerSessionId, + structured: enforced.value, + }; + } else { + result = await deps.runProvider(providerBase); + returnValue = result.finalResponse; + } + + throwIfCancelled(deps); + + const returnValueJson = serializeReplayValueOrThrow(returnValue); + if (structuredJson !== undefined) { + assertStructuredJsonBudget(structuredJson); + } + + let dirty: boolean | undefined; + if (worktree) { + const finalized = await worktree.finalize("success"); + dirty = finalized.dirty; + deps.journal.appendEvent({ + runId: deps.runId, + type: "worktree_finalized", + phase, + label: agentOpts.label, + data: { + callIndex: index, + worktreePath, + dirty: finalized.dirty, + removed: finalized.removed, + }, + }); + worktree = null; + } + + deps.journal.completeAgentCall({ + runId: deps.runId, + callIndex: index, + responseText: truncate(result.finalResponse, WORKFLOW_LIMITS.responseTextBytes), + structuredJson, + returnValueJson, + providerSessionId: result.providerSessionId, + dirty, + worktreePath, + }); + return returnValue; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + let cleanupError: string | undefined; + if (worktree) { + try { + const finalized = await worktree.finalize("failure"); + deps.journal.appendEvent({ + runId: deps.runId, + type: "worktree_finalized", + phase, + label: agentOpts.label, + data: { + callIndex: index, + worktreePath, + dirty: finalized.dirty, + removed: finalized.removed, + outcome: "failure", + }, + }); + } catch (cleanupFailure) { + cleanupError = + cleanupFailure instanceof Error + ? cleanupFailure.message + : String(cleanupFailure); + } + } + if (agentCallBegun) { + deps.journal.failAgentCall({ + runId: deps.runId, + callIndex: index, + error: message, + errorKind: error instanceof WorkflowEngineError ? error.kind : "internal", + worktreePath, + cleanupError, + }); + } + throw error; + } finally { + semaphore.release(); + } + }; + + const parallel = async (...args: unknown[]): Promise> => { + const thunks = args[0]; + if (!Array.isArray(thunks)) { + throw new WorkflowEngineError("internal", "parallel(thunks) requires an array of functions"); + } + assertMaxItems(thunks.length, "parallel"); + return Promise.all( + thunks.map(async (thunk, index) => { + if (typeof thunk !== "function") { + throw new WorkflowEngineError( + "internal", + `parallel thunks[${index}] must be a function`, + ); + } + try { + return await (thunk as () => Promise)(); + } catch { + return null; + } + }), + ); + }; + + const pipeline = async (...args: unknown[]): Promise> => { + const items = args[0]; + const stages = args.slice(1); + if (!Array.isArray(items)) { + throw new WorkflowEngineError("internal", "pipeline(items, ...stages) requires an items array"); + } + assertMaxItems(items.length, "pipeline"); + for (let i = 0; i < stages.length; i += 1) { + if (typeof stages[i] !== "function") { + throw new WorkflowEngineError("internal", `pipeline stage[${i}] must be a function`); + } + } + return Promise.all( + items.map(async (item, index) => { + let prev: unknown = item; + for (const stage of stages) { + try { + prev = await (stage as (prev: unknown, item: unknown, index: number) => unknown)( + prev, + item, + index, + ); + } catch { + return null; + } + } + return prev; + }), + ); + }; + + const phase = (...args: unknown[]): void => { + const title = args[0]; + if (typeof title !== "string" || !title.trim()) { + throw new WorkflowEngineError("internal", "phase(title) requires a non-empty string"); + } + // In-process tests still use host ALS. Sandbox scripts track phase in the + // child and inject opts.phase / log payloads across IPC. + phaseAls.enterWith(title); + deps.journal.appendEvent({ + runId: deps.runId, + type: "phase_started", + phase: title, + data: { title }, + }); + }; + + const log = (...args: unknown[]): void => { + let message: string; + let phaseTitle = phaseAls.getStore(); + if ( + args.length === 1 && + args[0] && + typeof args[0] === "object" && + !Array.isArray(args[0]) && + "message" in (args[0] as object) + ) { + const payload = args[0] as { message?: unknown; phase?: unknown }; + message = String(payload.message ?? ""); + if (typeof payload.phase === "string" && payload.phase.trim()) { + phaseTitle = payload.phase; + } + } else { + message = args.map(String).join(" "); + } + deps.journal.appendEvent({ + runId: deps.runId, + type: "log", + phase: phaseTitle, + data: { message: truncate(message, WORKFLOW_LIMITS.eventDataJsonBytes) }, + }); + }; + + const workflow = async (...args: unknown[]): Promise => { + if (nestDepth >= WORKFLOW_MAX_NEST_DEPTH) { + throw new WorkflowEngineError( + "nest_depth", + `workflow() nesting limited to ${WORKFLOW_MAX_NEST_DEPTH} level`, + ); + } + if (!deps.resolveNestedSource || !deps.executeNested) { + throw new WorkflowEngineError("internal", "nested workflow() is not configured on this host"); + } + const nameOrRef = args[0] as string | { scriptPath: string }; + const childArgsResult = jsonValueSchema.optional().safeParse(args[1]); + if (!childArgsResult.success) { + throw new WorkflowEngineError( + "internal", + `workflow() args must be JSON-serializable: ${childArgsResult.error.issues[0]?.message ?? "invalid value"}`, + ); + } + const source = await deps.resolveNestedSource(nameOrRef); + return deps.executeNested({ + source, + args: childArgsResult.data, + nestDepth: nestDepth + 1, + }); + }; + + return { + agent: agent as WorkflowSandboxApi["agent"], + parallel: parallel as WorkflowSandboxApi["parallel"], + pipeline: pipeline as WorkflowSandboxApi["pipeline"], + phase: phase as WorkflowSandboxApi["phase"], + log: log as WorkflowSandboxApi["log"], + args: deps.args, + budget: createStubBudget(), + workflow: workflow as WorkflowSandboxApi["workflow"], + meta: deps.meta, + getCallCount: () => runtime.callIndex, + getNestDepth: () => nestDepth, + }; +} + +function formatReplayMiss(miss: WorkflowReplayMiss): string { + return miss.reason === "identity_changed" && miss.changedFields?.length + ? `${miss.reason}:${miss.changedFields.join(",")}` + : miss.reason; +} + +/** Test helper: read current ALS phase (undefined outside phase). */ +export function getCurrentWorkflowPhase(): string | undefined { + return phaseAls.getStore(); +} + +export function hashCacheKey(input: ReturnType): string { + return createHash("sha256").update(JSON.stringify(input)).digest("hex"); +} + +interface ResolvedAgentTarget { + provider: LocalAgentProvider; + model?: string; + effort?: string; + profileName?: string; + profileFingerprint?: string; + providerPrompt: string; +} + +function resolveAgentTarget( + prompt: string, + opts: AgentOpts, + deps: Pick, +): ResolvedAgentTarget { + try { + const resolved = resolveLocalAgentExecution({ + prompt, + profile: opts.profile, + provider: opts.provider, + defaultProvider: deps.meta.defaultProvider, + model: opts.model, + effort: opts.effort, + profiles: deps.agentProfiles ?? [], + availableProviders: deps.availableProviders, + }); + return { + provider: resolved.provider, + model: resolved.model, + effort: resolved.effort, + profileName: resolved.profileName, + profileFingerprint: resolved.profileFingerprint, + providerPrompt: resolved.prompt, + }; + } catch (error) { + if (!(error instanceof LocalAgentResolutionError)) throw error; + const kind = error.kind === "profile_not_found" + ? "profile" + : error.kind === "no_provider" + ? "no_provider" + : "provider_unavailable"; + throw new WorkflowEngineError(kind, error.message); + } +} + +function normalizeAgentOpts(opts: unknown): AgentOpts { + if (opts === undefined || opts === null) return {}; + if (typeof opts === "object" && opts !== null && "writeMode" in opts) { + throw new WorkflowEngineError("internal", "writeMode is not supported on agent() (v1)"); + } + const parsed = agentOptsSchema.safeParse(opts); + if (parsed.success) return parsed.data; + const issue = parsed.error.issues[0]; + const path = issue?.path.join(".") || "opts"; + const kind = path === "schema" + ? "schema" + : path === "isolation" + ? "worktree" + : path === "profile" || issue?.message.includes("profile and provider") + ? "profile" + : "internal"; + throw new WorkflowEngineError( + kind, + `Invalid agent ${path}: ${issue?.message ?? "validation failed"}`, + ); +} + +function assertMaxItems(count: number, label: string): void { + if (count > WORKFLOW_MAX_ITEMS) { + throw new WorkflowEngineError( + "internal", + `${label} exceeds max items ${WORKFLOW_MAX_ITEMS} (got ${count})`, + ); + } +} + +function allocateAgentCallIndex(runtime: WorkflowApiRuntime): number { + if (runtime.callIndex >= WORKFLOW_MAX_AGENT_CALLS) { + throw new WorkflowEngineError( + "call_limit", + `Workflow exceeded the limit of ${WORKFLOW_MAX_AGENT_CALLS} agent calls`, + ); + } + const index = runtime.callIndex; + runtime.callIndex += 1; + return index; +} + +function throwIfCancelled(deps: WorkflowApiDeps): void { + if (deps.signal.aborted || deps.journal.isCancelRequested(deps.runId)) { + throw cancelledError(); + } +} + +function cancelledError(): WorkflowEngineError { + return new WorkflowEngineError("cancelled", "Workflow cancelled"); +} + +function truncate(text: string, maxBytes: number): string { + if (Buffer.byteLength(text, "utf8") <= maxBytes) return text; + const marker = "…"; + const budget = Math.max(0, maxBytes - Buffer.byteLength(marker, "utf8")); + let end = Math.min(text.length, budget); + while (end > 0 && Buffer.byteLength(text.slice(0, end), "utf8") > budget) end -= 1; + return `${text.slice(0, end)}${marker}`; +} + +function assertStructuredJsonBudget(value: string): void { + if (Buffer.byteLength(value, "utf8") <= WORKFLOW_LIMITS.structuredJsonBytes) return; + throw new WorkflowEngineError( + "result_too_large", + `agent() structured result exceeds ${WORKFLOW_LIMITS.structuredJsonBytes} bytes; return a smaller object or write large artifacts to disk and return paths`, + ); +} + +function serializeReplayValueOrThrow(value: unknown): string | undefined { + let json: string | undefined; + try { + json = JSON.stringify(value); + } catch { + throw new WorkflowEngineError( + "result_too_large", + "agent() return value is not JSON-serializable and cannot be replayed", + ); + } + if (json === undefined) return undefined; + if (Buffer.byteLength(json, "utf8") <= WORKFLOW_LIMITS.replayValueJsonBytes) return json; + throw new WorkflowEngineError( + "result_too_large", + `agent() return value exceeds ${WORKFLOW_LIMITS.replayValueJsonBytes} bytes replay budget; return a smaller summary or write large artifacts to disk and return paths`, + ); +} + +/** Minimal JSON extract for schema path until Ajv module lands. */ +export function tryExtractJson(text: string): unknown | undefined { + const trimmed = text.trim(); + try { + return JSON.parse(trimmed); + } catch { + // strip fenced block + const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fence?.[1]) { + try { + return JSON.parse(fence[1].trim()); + } catch { + // fall through + } + } + const start = trimmed.search(/[{\[]/); + if (start < 0) return undefined; + const slice = trimmed.slice(start); + try { + return JSON.parse(slice); + } catch { + return undefined; + } + } +} diff --git a/src/workflow-cli-entry.ts b/src/workflow-cli-entry.ts new file mode 100644 index 00000000..a5330f55 --- /dev/null +++ b/src/workflow-cli-entry.ts @@ -0,0 +1,15 @@ +import { statSync } from "node:fs"; +import { dirname, extname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** Resolve the sibling CLI module used by detached workflow workers. */ +export function resolveCliEntry(moduleUrl = import.meta.url): string { + const modulePath = fileURLToPath(moduleUrl); + const candidate = join(dirname(modulePath), `cli${extname(modulePath)}`); + try { + if (statSync(candidate).isFile()) return candidate; + } catch { + // Report the stable candidate below. + } + throw new Error(`DevSpace CLI entry does not exist: ${candidate}`); +} diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts new file mode 100644 index 00000000..c73ed0b4 --- /dev/null +++ b/src/workflow-cli.ts @@ -0,0 +1,550 @@ +import { fileURLToPath } from "node:url"; +import { + assertRecordInCliWorkspace, + resolveCliWorkspaceContext, + type CliWorkspaceContext, +} from "./cli-workspace.js"; +import { workflowCallOutput, workflowRunOutput } from "./cli-output.js"; +import type { ServerConfig } from "./config.js"; +import { parseWorkflowArgFlagsResult } from "./workflow-files.js"; +import { + cancelWorkflowRun, + reapStaleWorkflows, +} from "./workflow-lifecycle.js"; +import { createWorkflowStore, type WorkflowStore } from "./workflow-store.js"; +import { + WORKFLOW_LIMITS, + type WorkflowEventRecord, + type WorkflowAgentCallRecord, + type WorkflowRunRecord, +} from "./workflow-types.js"; +import { parseWorkflowEventPayload } from "./workflow-contracts.js"; +import { + InvalidWorkflowInputError, + WorkflowNotFoundError, +} from "./workflow-errors.js"; +import { + launchWorkflowRun, + type LaunchWorkflowSource, +} from "./workflow-launch.js"; +import { + runWorkflowWorker, + spawnWorkflowWorker, + spawnWorkflowWorkerFromCli, +} from "./workflow-worker.js"; +import { resolveCliEntry } from "./workflow-cli-entry.js"; + +export { runWorkflowWorker, spawnWorkflowWorker, spawnWorkflowWorkerFromCli }; + +export async function runWorkflowCommand( + args: string[], + config: ServerConfig, +): Promise { + const [subcommand, ...rest] = args; + switch (subcommand) { + case "run": + await runWorkflowRun(rest, config); + return; + case "status": + await runWorkflowStatus(rest, config); + return; + case "cancel": + await runWorkflowCancel(rest, config); + return; + case "ls": + case "list": + await runWorkflowList(rest, config); + return; + case "calls": + await runWorkflowCalls(rest, config); + return; + case "call": + await runWorkflowCall(rest, config); + return; + case "tui": { + const { runWorkflowTui } = await import("./workflow-tui.js"); + await runWorkflowTui(rest, config); + return; + } + case "__worker": + await runWorkflowWorker(rest, config); + return; + case undefined: + case "help": + case "--help": + case "-h": + printWorkflowHelp(); + return; + default: + throw new InvalidWorkflowInputError({ + code: "invalid_argument", + message: `Unknown workflow command: ${subcommand}`, + }); + } +} + +export function printWorkflowHelp(): void { + console.log( + [ + "DevSpace workflows", + "", + "Usage:", + " devspace workflow run [--file|--script-path | --name ] [--resume ]", + " [--arg key=value]... [--follow] [--json]", + " devspace workflow status [--follow] [--json]", + " devspace workflow cancel [--json]", + " devspace workflow ls [--json]", + " devspace workflow calls [--json]", + " devspace workflow call [--json]", + " devspace workflow tui [runId] # current working directory", + ].join("\n"), + ); +} + +async function runWorkflowRun(args: string[], config: ServerConfig): Promise { + const { flags } = splitFlags(args); + const follow = flags.has("follow"); + const json = flags.has("json"); + if (follow && json) { + throw new InvalidWorkflowInputError({ + code: "invalid_argument", + message: "Use either --follow or --json, then poll workflow status.", + }); + } + const file = flagValue(flags, "script-path") ?? flagValue(flags, "file"); + const name = flagValue(flags, "name"); + const resumeFrom = flagValue(flags, "resume"); + const parsedArgs = parseWorkflowArgFlagsResult(collectArgTokens(args)); + if (parsedArgs.isErr()) throw parsedArgs.error; + const workflowArgs = parsedArgs.value.args; + + if (file && name) { + throw new InvalidWorkflowInputError({ + code: "ambiguous_source", + message: "Provide only one of --file/--script-path or --name", + }); + } + if (!file && !name && !resumeFrom) { + throw new InvalidWorkflowInputError({ + code: "missing_source", + message: + "Usage: devspace workflow run [--file|--script-path | --name ] [--resume ]", + }); + } + + const source = buildCliLaunchSource({ file, name, resumeFrom }); + const store = createWorkflowStore(config); + try { + const workspace = resolveCliWorkspaceContext(config.allowedRoots); + const workspaceRoot = workspace.workspaceRoot; + if (resumeFrom) { + const prior = store.getRun(resumeFrom); + if (!prior) throw new WorkflowNotFoundError(resumeFrom); + assertWorkflowInCurrentProject(prior, workspace); + } + // Undefined args on resume make launch reload the prior run's args. + const argsValue = Object.keys(workflowArgs).length ? workflowArgs : undefined; + + const launched = await launchWorkflowRun({ + store, + config, + workspaceRoot, + workspaceId: workspace.workspaceId, + source, + args: argsValue, + scriptFileScope: "local", + cliEntry: resolveCliEntry(), + }); + if (launched.isErr()) throw launched.error; + + if (json) printJson({ workflow: workflowRunOutput(launched.value.run) }); + else console.log(formatRunLine(launched.value.run)); + + if (follow) { + await followRun(store, launched.value.run.id); + } + } finally { + store.close(); + } +} + +function buildCliLaunchSource(input: { + file?: string; + name?: string; + resumeFrom?: string; +}): LaunchWorkflowSource { + if (input.resumeFrom) { + const override = input.file + ? ({ kind: "file", path: input.file } as const) + : input.name + ? ({ kind: "named", name: input.name } as const) + : undefined; + return { kind: "resume", runId: input.resumeFrom, override }; + } + if (input.file) return { kind: "file", path: input.file }; + if (input.name) return { kind: "named", name: input.name }; + throw new InvalidWorkflowInputError({ + code: "missing_source", + message: "Provide --file/--script-path, --name, or --resume", + }); +} + +async function runWorkflowStatus(args: string[], config: ServerConfig): Promise { + const follow = args.includes("--follow"); + const json = args.includes("--json"); + if (follow && json) { + throw new InvalidWorkflowInputError({ + code: "invalid_argument", + message: "Use either --follow or --json, then poll workflow status.", + }); + } + const runId = args.find((a) => !a.startsWith("-")); + if (!runId) { + throw new InvalidWorkflowInputError({ + code: "invalid_argument", + message: "Usage: devspace workflow status [--follow]", + }); + } + + const store = createWorkflowStore(config); + try { + reapStaleWorkflows(store); + const workspace = resolveCliWorkspaceContext(config.allowedRoots); + const runResult = store.getRunResult(runId); + if (runResult.isErr()) throw runResult.error; + const run = runResult.value; + if (!run) throw new WorkflowNotFoundError(runId); + assertWorkflowInCurrentProject(run, workspace); + const calls = store.listAgentCalls(runId); + if (json) { + printJson({ workflow: workflowRunOutput(run, calls) }); + return; + } + console.log(formatRunLine(run)); + console.log(formatCallSummary(calls)); + if (follow) { + await followRun(store, runId); + return; + } + if (run.resultJson) console.log(run.resultJson); + else if (run.error) console.log(run.error); + } finally { + store.close(); + } +} + +async function runWorkflowCancel(args: string[], config: ServerConfig): Promise { + const json = args.includes("--json"); + const [runId, ...unknownArgs] = args.filter((arg) => arg !== "--json"); + if (!runId) { + throw new InvalidWorkflowInputError({ + code: "invalid_argument", + message: "Usage: devspace workflow cancel ", + }); + } + if (unknownArgs.length > 0) { + throw new InvalidWorkflowInputError({ + code: "invalid_argument", + message: "Usage: devspace workflow cancel [--json]", + }); + } + const store = createWorkflowStore(config); + try { + reapStaleWorkflows(store); + const run = store.getRun(runId); + if (!run) throw new WorkflowNotFoundError(runId); + assertWorkflowInCurrentProject(run, resolveCliWorkspaceContext(config.allowedRoots)); + const cancelled = await cancelWorkflowRun(store, runId); + if (json) printJson({ workflow: workflowRunOutput(cancelled) }); + else console.log(formatRunLine(cancelled)); + } finally { + store.close(); + } +} + +async function runWorkflowList(args: string[], config: ServerConfig): Promise { + const json = parseJsonOnlyOption(args, "devspace workflow ls [--json]"); + const store = createWorkflowStore(config); + try { + reapStaleWorkflows(store); + const runs = store.listRunsForScope(resolveCliWorkspaceContext(config.allowedRoots), { + limit: 50, + }); + if (json) { + printJson({ workflows: runs.map((run) => workflowRunOutput(run)) }); + return; + } + if (runs.length === 0) { + console.log("No workflow runs."); + return; + } + for (const run of runs) console.log(formatRunLine(run)); + } finally { + store.close(); + } +} + +async function runWorkflowCalls(args: string[], config: ServerConfig): Promise { + const json = args.includes("--json"); + const [runId, ...unknownArgs] = args.filter((arg) => arg !== "--json"); + if (!runId) { + throw new InvalidWorkflowInputError({ + code: "invalid_argument", + message: "Usage: devspace workflow calls ", + }); + } + if (unknownArgs.length > 0) { + throw new InvalidWorkflowInputError({ + code: "invalid_argument", + message: "Usage: devspace workflow calls [--json]", + }); + } + const store = createWorkflowStore(config); + try { + const run = store.getRun(runId); + if (!run) throw new WorkflowNotFoundError(runId); + assertWorkflowInCurrentProject(run, resolveCliWorkspaceContext(config.allowedRoots)); + const calls = store.listAgentCalls(runId); + if (json) { + printJson({ + workflowId: runId, + calls: calls.map((call) => workflowCallOutput(call)), + }); + return; + } + if (calls.length === 0) { + console.log("No workflow agent calls."); + return; + } + for (const call of calls) console.log(formatCallLine(call)); + } finally { + store.close(); + } +} + +async function runWorkflowCall(args: string[], config: ServerConfig): Promise { + const json = args.includes("--json"); + const [runId, callIndexValue, ...unknownArgs] = args.filter((arg) => arg !== "--json"); + const callIndex = Number(callIndexValue); + if (!runId || !Number.isInteger(callIndex) || callIndex < 0) { + throw new InvalidWorkflowInputError({ + code: "invalid_argument", + message: "Usage: devspace workflow call ", + }); + } + if (unknownArgs.length > 0) { + throw new InvalidWorkflowInputError({ + code: "invalid_argument", + message: "Usage: devspace workflow call [--json]", + }); + } + const store = createWorkflowStore(config); + try { + const run = store.getRun(runId); + if (!run) throw new WorkflowNotFoundError(runId); + assertWorkflowInCurrentProject(run, resolveCliWorkspaceContext(config.allowedRoots)); + const call = store.getAgentCall(runId, callIndex); + if (!call) { + throw new InvalidWorkflowInputError({ + code: "invalid_argument", + message: `Unknown workflow agent call: ${runId}#${callIndex}`, + }); + } + if (json) printJson({ call: workflowCallOutput(call, { detailed: true }) }); + else console.log(JSON.stringify(formatCallDetail(call), null, 2)); + } finally { + store.close(); + } +} + +function assertWorkflowInCurrentProject( + run: WorkflowRunRecord, + workspace: CliWorkspaceContext, +): void { + assertRecordInCliWorkspace(run, workspace, "Workflow run"); +} + +function parseJsonOnlyOption(args: string[], usage: string): boolean { + if (args.some((arg) => arg !== "--json")) { + throw new InvalidWorkflowInputError({ + code: "invalid_argument", + message: `Usage: ${usage}`, + }); + } + return args.includes("--json"); +} + +function printJson(value: unknown): void { + console.log(JSON.stringify(value, null, 2)); +} + +async function followRun(store: WorkflowStore, runId: string): Promise { + let sinceSeq = 0; + for (;;) { + const page = store.drainEvents(runId, sinceSeq, WORKFLOW_LIMITS.eventDrainDefault); + for (const event of page.events) printEvent(event); + sinceSeq = page.nextSeq; + if (page.terminal) { + const run = page.run; + if (run.resultJson) console.log(run.resultJson); + else if (run.error) console.log(run.error); + return; + } + await sleep(300); + } +} + +function printEvent(event: WorkflowEventRecord): void { + const prefix = event.phase ? `[${event.phase}] ` : ""; + switch (event.type) { + case "log": { + let message = event.dataJson; + try { + message = parseWorkflowEventPayload( + "log", + JSON.parse(event.dataJson) as unknown, + ).message; + } catch { + // raw + } + console.log(`${prefix}${message}`); + break; + } + case "phase_started": + console.log(`== phase ${event.phase ?? ""} ==`); + break; + case "agent_call_started": + console.log(`${prefix}agent start ${event.label ?? ""}`.trim()); + break; + case "agent_call_completed": + console.log(`${prefix}agent done ${event.label ?? ""}`.trim()); + break; + case "agent_call_cached": + console.log(`${prefix}agent cache ${event.label ?? ""}`.trim()); + break; + case "agent_call_failed": + console.log(`${prefix}agent fail ${event.label ?? ""} ${event.dataJson}`.trim()); + break; + case "run_completed": + case "run_failed": + case "run_cancelled": + console.log(event.type); + break; + default: + break; + } +} + +function formatRunLine( + run: Pick< + WorkflowRunRecord, + "id" | "status" | "name" | "error" | "scriptPath" | "scriptHash" | "resumedFromRunId" + >, +): string { + const err = run.error ? ` error=${JSON.stringify(run.error)}` : ""; + const resumed = run.resumedFromRunId ? ` resumedFrom=${run.resumedFromRunId}` : ""; + return `${run.id} ${run.status} ${run.name} scriptPath=${JSON.stringify(run.scriptPath)} scriptHash=${run.scriptHash}${resumed}${err}`; +} + +function formatCallLine(call: WorkflowAgentCallRecord): string { + const label = call.label ? ` label=${JSON.stringify(call.label)}` : ""; + const phase = call.phase ? ` phase=${JSON.stringify(call.phase)}` : ""; + const model = call.model ? ` model=${call.model}` : ""; + const duration = callDurationMs(call); + const replay = call.fromCache + ? ` replay=${call.replayMatch ?? "cached"}:${call.replayedFromRunId ?? "?"}#${call.replayedFromCallIndex ?? "?"}` + : call.replayReason + ? ` replayMiss=${call.replayReason}` + : ""; + const worktree = call.worktreePath + ? ` worktree=${JSON.stringify(call.worktreePath)} dirty=${String(call.dirty)}` + : ""; + return `#${call.callIndex} ${call.status} ${call.provider}${model}${label}${phase} durationMs=${duration}${replay}${worktree}`; +} + +function formatCallSummary(calls: WorkflowAgentCallRecord[]): string { + const reused = calls.filter((call) => call.fromCache).length; + const failed = calls.filter((call) => call.status === "failed").length; + const live = calls.filter( + (call) => !call.fromCache && call.status === "completed", + ).length; + const running = calls.filter((call) => call.status === "running").length; + return `calls reused=${reused} live=${live} failed=${failed} running=${running} total=${calls.length}`; +} + +function formatCallDetail(call: WorkflowAgentCallRecord): Record { + return { + ...call, + durationMs: callDurationMs(call), + schema: call.schemaJson ? safeParseJson(call.schemaJson) : undefined, + structured: call.structuredJson ? safeParseJson(call.structuredJson) : undefined, + }; +} + +function callDurationMs(call: WorkflowAgentCallRecord): number | undefined { + if (!call.startedAt || !call.completedAt) return undefined; + return Math.max(0, Date.parse(call.completedAt) - Date.parse(call.startedAt)); +} + +function safeParseJson(text: string): unknown { + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } +} + +function splitFlags(args: string[]): { + flags: Map; + positionals: string[]; +} { + const flags = new Map(); + const positionals: string[] = []; + for (let i = 0; i < args.length; i += 1) { + const token = args[i]!; + if (token === "--") { + positionals.push(...args.slice(i + 1)); + break; + } + if (token.startsWith("--")) { + const eq = token.indexOf("="); + if (eq >= 0) { + flags.set(token.slice(2, eq), token.slice(eq + 1)); + continue; + } + const key = token.slice(2); + const next = args[i + 1]; + if (next && !next.startsWith("-") && key !== "follow" && key !== "json") { + flags.set(key, next); + i += 1; + } else { + flags.set(key, true); + } + continue; + } + positionals.push(token); + } + return { flags, positionals }; +} + +function flagValue(flags: Map, key: string): string | undefined { + const value = flags.get(key); + return typeof value === "string" ? value : undefined; +} + +function collectArgTokens(args: string[]): string[] { + const out: string[] = []; + for (let i = 0; i < args.length; i += 1) { + const token = args[i]!; + if (token === "--arg") { + out.push(token, args[++i] ?? ""); + continue; + } + if (token.startsWith("--arg=")) out.push(token); + } + return out; +} + +function sleep(ms: number): Promise { + return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); +} diff --git a/src/workflow-contracts.test.ts b/src/workflow-contracts.test.ts new file mode 100644 index 00000000..de1b9d74 --- /dev/null +++ b/src/workflow-contracts.test.ts @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import { + agentOptsSchema, + localAgentProviderSchema, + parseWorkflowEventPayload, + workflowMetaSchema, + type WorkflowAgent, + type WorkflowParallel, +} from "./workflow-contracts.js"; +import { + jsonSchemaSchema, + jsonValueSchema, +} from "./json-types.js"; +import { LOCAL_AGENT_PROVIDERS } from "./local-agent-profiles.js"; + +assert.deepEqual(localAgentProviderSchema.options, LOCAL_AGENT_PROVIDERS); + +assert.deepEqual( + workflowMetaSchema.parse({ + name: "typed-review", + description: "Review with typed contracts", + defaultProvider: "codex", + phases: [{ title: "Review" }], + }), + { + name: "typed-review", + description: "Review with typed contracts", + defaultProvider: "codex", + phases: [{ title: "Review" }], + }, +); + +assert.throws( + () => + workflowMetaSchema.parse({ + name: "typed-review", + description: "d", + unknown: true, + }), + /Unrecognized key/, +); + +assert.throws( + () => agentOptsSchema.parse({ provider: "made-up" }), + /Invalid option/, +); +assert.throws( + () => agentOptsSchema.parse({ profile: "reviewer", provider: "codex" }), + /mutually exclusive/, +); +assert.throws(() => agentOptsSchema.parse({ schema: [] }), /expected record/i); +assert.throws(() => jsonValueSchema.parse(new Date()), /invalid input/i); +assert.throws(() => jsonValueSchema.parse(() => undefined), /invalid input/i); + +assert.deepEqual( + jsonSchemaSchema.parse({ + type: "object", + properties: { count: { type: "number" } }, + required: ["count"], + }), + { + type: "object", + properties: { count: { type: "number" } }, + required: ["count"], + }, +); + +assert.deepEqual( + parseWorkflowEventPayload("agent_call_completed", { + callIndex: 2, + provider: "claude", + isolation: "shared", + fromCache: false, + }), + { + callIndex: 2, + provider: "claude", + isolation: "shared", + fromCache: false, + }, +); +assert.throws( + () => + parseWorkflowEventPayload("run_completed", { + provider: "codex", + }), + /callCount/, +); + +declare const agent: WorkflowAgent; +declare const parallel: WorkflowParallel; + +if (false) { + const output = await agent("Return a count", { + schema: { + type: "object", + properties: { + count: { type: "number" }, + }, + required: ["count"], + additionalProperties: false, + } as const, + }); + const count: number = output.count; + void count; + + // @ts-expect-error schema-derived output has no `missing` field + void output.missing; + + // @ts-expect-error providers are exhaustive + await agent("x", { provider: "made-up" }); + + await agent("review", { profile: "reviewer", effort: "high" }); + + const tuple = await parallel([ + async () => "text", + async () => 42, + ] as const); + const first: string | null = tuple[0]; + const second: number | null = tuple[1]; + void first; + void second; +} + +console.log("workflow-contracts.test.ts: ok"); diff --git a/src/workflow-contracts.ts b/src/workflow-contracts.ts new file mode 100644 index 00000000..6394ee0a --- /dev/null +++ b/src/workflow-contracts.ts @@ -0,0 +1,263 @@ +import type { FromSchema } from "json-schema-to-ts"; +import * as z from "zod/v4"; +import { LOCAL_AGENT_PROVIDERS } from "./local-agent-profiles.js"; +import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import { jsonSchemaSchema, type JsonSchema, type JsonValue } from "./json-types.js"; + +export const localAgentProviderSchema = z.enum(LOCAL_AGENT_PROVIDERS); +export const workflowTokenUsageStateSchema = z.enum(["partial", "final"]); +export const workflowAgentActivityKindSchema = z.enum(["tool", "command", "file", "status"]); +export const workflowAgentActivityStatusSchema = z.enum(["running", "completed", "failed"]); + +export const workflowPhaseMetaSchema = z + .object({ + title: z.string().trim().min(1), + detail: z.string().trim().min(1).optional(), + }) + .strict(); + +export const workflowMetaSchema = z + .object({ + name: z.string().trim().min(1).regex(/^[a-z0-9-]+$/), + description: z.string().trim().min(1), + phases: z.array(workflowPhaseMetaSchema).optional(), + whenToUse: z.string().trim().min(1).optional(), + defaultProvider: localAgentProviderSchema.optional(), + concurrency: z.number().finite().int().positive().optional(), + }) + .strict(); + +export type WorkflowMeta = z.infer; +export type WorkflowPhaseMeta = NonNullable[number]; + +export const agentIsolationModeSchema = z.enum(["shared", "worktree"]); +export type AgentIsolationMode = z.infer; + +export const workflowRunStatusSchema = z.enum([ + "starting", + "running", + "completed", + "failed", + "cancelled", +]); +export type WorkflowRunStatus = z.infer; + +export const workflowAgentCallStatusSchema = z.enum([ + "running", + "completed", + "failed", + "cancelled", + "from_cache", +]); +export type WorkflowAgentCallStatus = z.infer; + +export const workflowRunSourceSchema = z.enum(["inline", "file", "named", "resume"]); +export type WorkflowRunSource = z.infer; + +export const agentOptsSchema = z + .object({ + label: z.string().trim().min(1).optional(), + phase: z.string().trim().min(1).optional(), + schema: jsonSchemaSchema.optional(), + model: z.string().trim().min(1).optional(), + effort: z.string().trim().min(1).optional(), + profile: z.string().trim().min(1).optional(), + provider: localAgentProviderSchema.optional(), + isolation: z.literal("worktree").optional(), + }) + .strict() + .superRefine((value, context) => { + if (value.profile && value.provider) { + context.addIssue({ + code: "custom", + path: ["provider"], + message: "profile and provider are mutually exclusive", + }); + } + }); + +export type AgentOpts = Omit< + z.infer, + "schema" +> & { + schema?: S; +}; + +export interface WorkflowAgent { + ( + prompt: string, + opts: AgentOpts & { schema: S }, + ): Promise>; + (prompt: string, opts?: AgentOpts): Promise; +} + +export type WorkflowTask = () => T | Promise; + +export interface WorkflowParallel { + ( + tasks: T, + ): Promise<{ + [K in keyof T]: Awaited> | null; + }>; +} + +export interface WorkflowPipeline { + ( + items: readonly T[], + stage: (previous: T, item: T, index: number) => R | Promise, + ): Promise | null>>; + ( + items: readonly T[], + first: (previous: T, item: T, index: number) => A | Promise, + second: (previous: Awaited, item: T, index: number) => R | Promise, + ): Promise | null>>; + (...args: unknown[]): Promise>; +} + +export interface WorkflowNested { + (nameOrRef: string | { scriptPath: string }, args?: JsonValue): Promise; +} + +export const workflowErrorKindSchema = z.enum([ + "syntax", + "meta", + "determinism", + "provider_unavailable", + "no_provider", + "provider", + "profile", + "schema", + "cancelled", + "timeout", + "heartbeat", + "worktree", + "nest_depth", + "call_limit", + "path", + "result_too_large", + "args_too_large", + "script_too_large", + "internal", +]); +export type WorkflowErrorKind = z.infer; + +export const WORKFLOW_EVENT_TYPES = [ + "run_started", + "run_completed", + "run_failed", + "run_cancelled", + "phase_started", + "log", + "agent_call_started", + "agent_call_completed", + "agent_call_failed", + "agent_call_cached", + "schema_retry", + "worktree_created", + "worktree_finalized", +] as const; + +export const workflowEventTypeSchema = z.enum(WORKFLOW_EVENT_TYPES); +export type WorkflowEventType = z.infer; + +export const workflowEventPayloadSchemas = { + run_started: z + .object({ + name: z.string(), + scriptHash: z.string(), + concurrency: z.number().int().positive(), + }) + .strict(), + run_completed: z.object({ callCount: z.number().int().nonnegative() }).strict(), + run_failed: z + .object({ error: z.string(), errorKind: workflowErrorKindSchema }) + .strict(), + run_cancelled: z.object({ reason: z.string().optional() }).strict(), + phase_started: z.object({ title: z.string().min(1) }).strict(), + log: z.object({ message: z.string() }).strict(), + agent_call_started: z + .object({ + callIndex: z.number().int().nonnegative(), + cacheKey: z.string(), + provider: localAgentProviderSchema, + isolation: agentIsolationModeSchema, + worktreePath: z.string().optional(), + }) + .strict(), + agent_call_completed: z + .object({ + callIndex: z.number().int().nonnegative(), + provider: localAgentProviderSchema, + isolation: agentIsolationModeSchema, + worktreePath: z.string().optional(), + dirty: z.boolean().optional(), + fromCache: z.boolean(), + }) + .strict(), + agent_call_failed: z + .object({ + callIndex: z.number().int().nonnegative(), + error: z.string(), + cleanupError: z.string().optional(), + isolation: agentIsolationModeSchema, + worktreePath: z.string().optional(), + }) + .strict(), + agent_call_cached: z + .object({ + callIndex: z.number().int().nonnegative(), + cacheKey: z.string(), + provider: localAgentProviderSchema, + replayMatch: z.enum(["same_index"]), + replayedFromRunId: z.string(), + replayedFromCallIndex: z.number().int().nonnegative(), + }) + .strict(), + schema_retry: z + .object({ + callIndex: z.number().int().nonnegative(), + attempt: z.number().int().positive(), + errors: z.string(), + mode: z.enum(["native", "prompt"]), + }) + .strict(), + worktree_created: z + .object({ + callIndex: z.number().int().nonnegative(), + worktreePath: z.string(), + isolation: z.literal("worktree"), + }) + .strict(), + worktree_finalized: z + .object({ + callIndex: z.number().int().nonnegative(), + worktreePath: z.string().optional(), + dirty: z.boolean(), + removed: z.boolean(), + outcome: z.literal("failure").optional(), + }) + .strict(), +} as const satisfies Record; + +export type WorkflowEventPayloads = { + [K in WorkflowEventType]: z.infer<(typeof workflowEventPayloadSchemas)[K]>; +}; + +export type AppendWorkflowEventInput = { + [P in K]: { + runId: string; + type: P; + phase?: string; + label?: string; + data: WorkflowEventPayloads[P]; + }; +}[K]; + +export function parseWorkflowEventPayload( + type: K, + data: unknown, +): WorkflowEventPayloads[K] { + return workflowEventPayloadSchemas[type].parse(data) as WorkflowEventPayloads[K]; +} + +export type WorkflowProviderId = LocalAgentProvider; diff --git a/src/workflow-engine.test.ts b/src/workflow-engine.test.ts new file mode 100644 index 00000000..9185c301 --- /dev/null +++ b/src/workflow-engine.test.ts @@ -0,0 +1,748 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { WorkflowStore } from "./workflow-store.js"; +import { executeWorkflow } from "./workflow-engine.js"; +import { + createWorkflowApi, + createWorkflowApiRuntime, + WorkflowEngineError, + WorkflowSemaphore, + getCurrentWorkflowPhase, + type WorkflowProviderRunInput, + type CreateAgentWorktree, +} from "./workflow-api.js"; +import { + createStubBudget, + WORKFLOW_LIMITS, + WORKFLOW_MAX_AGENT_CALLS, +} from "./workflow-types.js"; +import type { LocalAgentProfile } from "./local-agent-profiles.js"; + +// --------------------------------------------------------------------------- +// Semaphore +// --------------------------------------------------------------------------- +{ + const sem = new WorkflowSemaphore(2); + let concurrent = 0; + let maxConcurrent = 0; + await Promise.all( + Array.from({ length: 6 }, async () => { + await sem.acquire(); + concurrent += 1; + maxConcurrent = Math.max(maxConcurrent, concurrent); + await new Promise((r) => setTimeout(r, 20)); + concurrent -= 1; + sem.release(); + }), + ); + assert.equal(maxConcurrent, 2); +} + +// --------------------------------------------------------------------------- +// Per-run agent call budget +// --------------------------------------------------------------------------- +{ + const dir = await mkdtemp(join(tmpdir(), "wf-call-limit-")); + const store = new WorkflowStore(dir); + const run = store.createRun({ + name: "call-limit", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: dir, + }); + const runtime = createWorkflowApiRuntime(2); + runtime.callIndex = WORKFLOW_MAX_AGENT_CALLS - 1; + let providerCalls = 0; + const api = createWorkflowApi({ + runId: run.id, + journal: store, + meta: { name: "call-limit", description: "d" }, + args: undefined, + concurrency: 2, + signal: new AbortController().signal, + workspaceRoot: dir, + availableProviders: ["codex"], + runtime, + runProvider: async (input) => { + providerCalls += 1; + return { finalResponse: `ok:${input.prompt}` }; + }, + }); + + const [lastAllowed, overflow] = await Promise.allSettled([ + api.agent("last allowed"), + api.agent("overflow"), + ]); + assert.equal(lastAllowed.status, "fulfilled"); + assert.equal(overflow.status, "rejected"); + assert.ok( + overflow.status === "rejected" && + overflow.reason instanceof WorkflowEngineError && + overflow.reason.kind === "call_limit", + ); + assert.equal(providerCalls, 1); + assert.equal(api.getCallCount(), WORKFLOW_MAX_AGENT_CALLS); + assert.equal(store.listAgentCalls(run.id).length, 1); + assert.equal( + store.getAgentCall(run.id, WORKFLOW_MAX_AGENT_CALLS - 1)?.status, + "completed", + ); + + store.close(); + await rm(dir, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// parallel → null on throw; barrier +// --------------------------------------------------------------------------- +{ + const dir = await mkdtemp(join(tmpdir(), "wf-engine-")); + const store = new WorkflowStore(dir); + const run = store.createRun({ + name: "par", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: dir, + }); + const order: string[] = []; + const api = createWorkflowApi({ + runId: run.id, + journal: store, + meta: { name: "par", description: "d" }, + args: undefined, + concurrency: 4, + signal: new AbortController().signal, + workspaceRoot: dir, + availableProviders: ["codex"], + runProvider: async (input) => { + order.push(`start:${input.prompt}`); + await new Promise((r) => setTimeout(r, 10)); + order.push(`end:${input.prompt}`); + if (input.prompt === "fail") throw new Error("boom"); + return { finalResponse: `ok:${input.prompt}` }; + }, + }); + + const results = await api.parallel([ + () => api.agent("a"), + () => api.agent("fail"), + () => api.agent("b"), + ]); + assert.deepEqual(results, ["ok:a", null, "ok:b"]); + assert.equal(api.getCallCount(), 3); + assert.equal(store.getAgentCall(run.id, 0)?.returnValueJson, JSON.stringify("ok:a")); + assert.equal(store.getAgentCall(run.id, 2)?.returnValueJson, JSON.stringify("ok:b")); + store.close(); + await rm(dir, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// pipeline — no barrier across items (item B can finish stage2 before A stage1 ends) +// --------------------------------------------------------------------------- +{ + const dir = await mkdtemp(join(tmpdir(), "wf-pipe-")); + const store = new WorkflowStore(dir); + const run = store.createRun({ + name: "pipe", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: dir, + }); + const events: string[] = []; + const api = createWorkflowApi({ + runId: run.id, + journal: store, + meta: { name: "pipe", description: "d" }, + args: undefined, + concurrency: 4, + signal: new AbortController().signal, + workspaceRoot: dir, + availableProviders: ["codex"], + runProvider: async () => ({ finalResponse: "x" }), + }); + + const result = await api.pipeline( + ["slow", "fast"], + async (item: unknown) => { + events.push(`s1:${item}:start`); + await new Promise((r) => setTimeout(r, item === "slow" ? 40 : 5)); + events.push(`s1:${item}:end`); + return `${item}-1`; + }, + async (prev: unknown, item: unknown) => { + events.push(`s2:${item}:${prev}`); + return `${prev}-2`; + }, + ); + + assert.deepEqual(result, ["slow-1-2", "fast-1-2"]); + // fast finishes stage1 before slow does + const fastEnd = events.indexOf("s1:fast:end"); + const slowEnd = events.indexOf("s1:slow:end"); + assert.ok(fastEnd >= 0 && slowEnd >= 0 && fastEnd < slowEnd); + // fast may enter stage2 before slow finishes stage1 + const fastS2 = events.indexOf("s2:fast:fast-1"); + assert.ok(fastS2 >= 0 && fastS2 < slowEnd); + + // throw → null for that item + const withNull = await api.pipeline( + [1, 2], + async (n: unknown) => { + if (n === 2) throw new Error("nope"); + return n; + }, + ); + assert.deepEqual(withNull, [1, null]); + + store.close(); + await rm(dir, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// phase ALS — concurrent chains keep separate phases for agent() +// --------------------------------------------------------------------------- +{ + const dir = await mkdtemp(join(tmpdir(), "wf-phase-")); + const store = new WorkflowStore(dir); + const run = store.createRun({ + name: "phase", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: dir, + }); + const seen: Array<{ prompt: string; phase?: string }> = []; + const api = createWorkflowApi({ + runId: run.id, + journal: store, + meta: { name: "phase", description: "d" }, + args: undefined, + concurrency: 4, + signal: new AbortController().signal, + workspaceRoot: dir, + availableProviders: ["codex"], + runProvider: async (input: WorkflowProviderRunInput) => { + seen.push({ prompt: input.prompt, phase: input.phase }); + await new Promise((r) => setTimeout(r, 15)); + return { finalResponse: "ok" }; + }, + }); + + await api.parallel([ + async () => { + api.phase("A"); + assert.equal(getCurrentWorkflowPhase(), "A"); + return api.agent("from-a"); + }, + async () => { + api.phase("B"); + assert.equal(getCurrentWorkflowPhase(), "B"); + return api.agent("from-b"); + }, + ]); + + const a = seen.find((s) => s.prompt === "from-a"); + const b = seen.find((s) => s.prompt === "from-b"); + assert.equal(a?.phase, "A"); + assert.equal(b?.phase, "B"); + + store.close(); + await rm(dir, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// isolation: worktree uses createWorktree path as cwd +// --------------------------------------------------------------------------- +{ + const dir = await mkdtemp(join(tmpdir(), "wf-iso-")); + const store = new WorkflowStore(dir); + const run = store.createRun({ + name: "iso", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: dir, + }); + const worktrees: string[] = []; + const createWorktree: CreateAgentWorktree = async ({ callIndex }) => { + const path = join(dir, `wt-${callIndex}`); + await mkdir(path, { recursive: true }); + worktrees.push(path); + return { + path, + finalize: async () => ({ dirty: false, removed: true }), + }; + }; + const api = createWorkflowApi({ + runId: run.id, + journal: store, + meta: { name: "iso", description: "d" }, + args: undefined, + concurrency: 2, + signal: new AbortController().signal, + workspaceRoot: dir, + availableProviders: ["codex"], + createWorktree, + runProvider: async (input) => { + assert.equal(input.workspace, worktrees[0]); + return { finalResponse: "in-wt" }; + }, + }); + + const out = await api.agent("do", { isolation: "worktree", label: "iso" }); + assert.equal(out, "in-wt"); + const calls = store.listAgentCalls(run.id); + assert.equal(calls[0]?.isolation, "worktree"); + assert.equal(calls[0]?.worktreePath, worktrees[0]); + + store.close(); + await rm(dir, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// worktree setup failure preserves the primary error before journal begin +// --------------------------------------------------------------------------- +{ + const dir = await mkdtemp(join(tmpdir(), "wf-iso-fail-")); + const store = new WorkflowStore(dir); + const run = store.createRun({ + name: "iso-fail", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: dir, + }); + const api = createWorkflowApi({ + runId: run.id, + journal: store, + meta: { name: "iso-fail", description: "d" }, + args: undefined, + concurrency: 1, + signal: new AbortController().signal, + workspaceRoot: dir, + availableProviders: ["codex"], + createWorktree: async () => { + throw new Error("expected worktree setup failure"); + }, + runProvider: async () => ({ finalResponse: "unreachable" }), + }); + + const runIsolated = api.agent as ( + prompt: string, + opts: { isolation: "worktree" }, + ) => Promise; + await assert.rejects( + () => runIsolated("do", { isolation: "worktree" }), + /expected worktree setup failure/, + ); + assert.equal(store.listAgentCalls(run.id).length, 1); + assert.equal(store.getAgentCall(run.id, 0)?.status, "failed"); + const failed = store + .drainEvents(run.id) + .events.find((event) => event.type === "agent_call_failed"); + assert.ok(failed); + + store.close(); + await rm(dir, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// provider resolve order + no writeMode +// --------------------------------------------------------------------------- +{ + const dir = await mkdtemp(join(tmpdir(), "wf-prov-")); + const store = new WorkflowStore(dir); + const run = store.createRun({ + name: "prov", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: dir, + }); + const used: string[] = []; + const api = createWorkflowApi({ + runId: run.id, + journal: store, + meta: { name: "prov", description: "d", defaultProvider: "claude" }, + args: undefined, + concurrency: 1, + signal: new AbortController().signal, + workspaceRoot: dir, + availableProviders: ["codex", "claude"], + runProvider: async (input) => { + used.push(input.provider); + return { finalResponse: input.provider }; + }, + }); + assert.equal(await api.agent("x"), "claude"); + assert.equal(await api.agent("y", { provider: "codex" }), "codex"); + await assert.rejects( + async () => api.agent("z", { writeMode: "allowed" } as never), + /writeMode is not supported/, + ); + store.close(); + await rm(dir, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// configured profile selection, defaults, overrides, and prompt instructions +// --------------------------------------------------------------------------- +{ + const dir = await mkdtemp(join(tmpdir(), "wf-profile-")); + const store = new WorkflowStore(dir); + const run = store.createRun({ + name: "profile", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: dir, + }); + const profile: LocalAgentProfile = { + name: "reviewer", + description: "Review changes", + provider: "claude", + model: "sonnet", + effort: "medium", + filePath: join(dir, "reviewer.md"), + body: "Act as an adversarial reviewer.", + disabled: false, + }; + const calls: WorkflowProviderRunInput[] = []; + const api = createWorkflowApi({ + runId: run.id, + journal: store, + meta: { name: "profile", description: "d", defaultProvider: "codex" }, + args: undefined, + concurrency: 1, + signal: new AbortController().signal, + workspaceRoot: dir, + availableProviders: ["codex", "claude"], + agentProfiles: [profile], + runProvider: async (input) => { + calls.push(input); + return { finalResponse: "reviewed" }; + }, + }); + + assert.equal( + await api.agent("Review auth", { + profile: "reviewer", + model: "opus", + effort: "high", + }), + "reviewed", + ); + assert.equal(calls[0]?.provider, "claude"); + assert.equal(calls[0]?.model, "opus"); + assert.equal(calls[0]?.effort, "high"); + assert.equal( + calls[0]?.prompt, + "Act as an adversarial reviewer.\n\nTask:\nReview auth", + ); + assert.equal(store.getAgentCall(run.id, 0)?.profileName, "reviewer"); + assert.equal(store.getAgentCall(run.id, 0)?.profileFingerprint?.length, 64); + + const callAgent = api.agent as (prompt: string, opts?: unknown) => Promise; + await assert.rejects( + () => callAgent("x", { profile: "reviewer", provider: "codex" }), + /mutually exclusive/, + ); + await assert.rejects(() => callAgent("x", { profile: "missing" }), /Unknown agent profile/); + + const unavailableApi = createWorkflowApi({ + runId: run.id, + journal: store, + meta: { name: "profile", description: "d" }, + args: undefined, + concurrency: 1, + signal: new AbortController().signal, + workspaceRoot: dir, + availableProviders: ["codex"], + agentProfiles: [profile], + runProvider: async () => ({ finalResponse: "unreachable" }), + }); + await assert.rejects( + () => + (unavailableApi.agent as (prompt: string, opts?: unknown) => Promise)( + "x", + { profile: "reviewer" }, + ), + /requires unavailable provider claude/, + ); + + store.close(); + await rm(dir, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// schema retry: prompt contract on each attempt + provider session reuse +// --------------------------------------------------------------------------- +{ + const dir = await mkdtemp(join(tmpdir(), "wf-schema-retry-")); + const store = new WorkflowStore(dir); + const run = store.createRun({ + name: "schema-retry", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: dir, + }); + const calls: WorkflowProviderRunInput[] = []; + const api = createWorkflowApi({ + runId: run.id, + journal: store, + meta: { name: "schema-retry", description: "d" }, + args: undefined, + concurrency: 1, + signal: new AbortController().signal, + workspaceRoot: dir, + availableProviders: ["codex"], + runProvider: async (input) => { + calls.push(input); + if (calls.length === 1) { + return { + finalResponse: '{"n":"bad"}', + structured: { n: "bad" }, + providerSessionId: "sess-1", + }; + } + return { finalResponse: '{"n":2}', providerSessionId: "sess-1" }; + }, + }); + + const out = await api.agent("give n", { + schema: { + type: "object", + properties: { n: { type: "number" } }, + required: ["n"], + }, + }); + assert.deepEqual(out, { n: 2 }); + assert.equal(calls[0]?.schema, undefined); + assert.match(calls[0]?.prompt ?? "", /ONLY a JSON/); + assert.equal(calls[0]?.providerSessionId, undefined); + assert.equal(calls[1]?.schema, undefined); + assert.match(calls[1]?.prompt ?? "", /ONLY a JSON/); + assert.equal(calls[1]?.providerSessionId, "sess-1"); + assert.equal(store.getAgentCall(run.id, 0)?.returnValueJson, JSON.stringify({ n: 2 })); + + store.close(); + await rm(dir, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// oversized exact replay values fail the live call (completed ⇒ replayable) +// --------------------------------------------------------------------------- +{ + const dir = await mkdtemp(join(tmpdir(), "wf-replay-size-")); + const store = new WorkflowStore(dir); + const run = store.createRun({ + name: "replay-size", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: dir, + }); + const response = "x".repeat(WORKFLOW_LIMITS.replayValueJsonBytes + 1); + const api = createWorkflowApi({ + runId: run.id, + journal: store, + meta: { name: "replay-size", description: "d" }, + args: undefined, + concurrency: 1, + signal: new AbortController().signal, + workspaceRoot: dir, + availableProviders: ["codex"], + runProvider: async () => ({ finalResponse: response }), + }); + + await assert.rejects( + () => api.agent("large"), + (error: unknown) => + error instanceof WorkflowEngineError && error.kind === "result_too_large", + ); + assert.equal(store.getAgentCall(run.id, 0)?.status, "failed"); + assert.equal(store.getAgentCall(run.id, 0)?.errorKind, "result_too_large"); + store.close(); + await rm(dir, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// oversized structured results fail the live call +// --------------------------------------------------------------------------- +{ + const dir = await mkdtemp(join(tmpdir(), "wf-structured-size-")); + const store = new WorkflowStore(dir); + const run = store.createRun({ + name: "structured-size", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: dir, + }); + const big = "x".repeat(WORKFLOW_LIMITS.structuredJsonBytes + 1); + const api = createWorkflowApi({ + runId: run.id, + journal: store, + meta: { name: "structured-size", description: "d" }, + args: undefined, + concurrency: 1, + signal: new AbortController().signal, + workspaceRoot: dir, + availableProviders: ["codex"], + runProvider: async () => ({ + finalResponse: JSON.stringify({ big }), + structured: { big }, + }), + }); + + const oversizedStructured = () => + (api.agent as (prompt: string, opts?: object) => Promise)( + "large structured", + { + schema: { + type: "object", + properties: { big: { type: "string" } }, + required: ["big"], + }, + }, + ); + await assert.rejects( + oversizedStructured, + (error: unknown) => + error instanceof WorkflowEngineError && error.kind === "result_too_large", + ); + assert.equal(store.getAgentCall(run.id, 0)?.status, "failed"); + store.close(); + await rm(dir, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// executeWorkflow end-to-end with sandbox + nest depth +// --------------------------------------------------------------------------- +{ + const dir = await mkdtemp(join(tmpdir(), "wf-exec-")); + const store = new WorkflowStore(dir); + const run = store.createRun({ + name: "exec", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: dir, + }); + + const childPath = join(dir, "child.js"); + await writeFile( + childPath, + ` +export const meta = { name: 'child', description: 'nested', defaultProvider: 'claude' } +return await agent('nested-prompt') +`, + ); + + const prompts: string[] = []; + const providers: string[] = []; + const { result, callCount } = await executeWorkflow({ + source: ` +export const meta = { name: 'parent', description: 'p', defaultProvider: 'codex' } +const a = await agent('parent-prompt') +const nested = await workflow({ scriptPath: ${JSON.stringify(childPath)} }) +return { a, nested } +`, + runId: run.id, + journal: store, + workspaceRoot: dir, + availableProviders: ["codex", "claude"], + runProvider: async (input) => { + prompts.push(input.prompt); + providers.push(input.provider); + return { finalResponse: `R:${input.prompt}` }; + }, + resolveNestedSource: async (ref) => { + if (typeof ref === "object" && ref.scriptPath) { + const { readFile } = await import("node:fs/promises"); + return readFile(ref.scriptPath, "utf8"); + } + throw new Error("unknown nest ref"); + }, + }); + + assert.deepEqual(result, { + a: "R:parent-prompt", + nested: "R:nested-prompt", + }); + assert.equal(callCount, 2); + assert.deepEqual(prompts, ["parent-prompt", "nested-prompt"]); + assert.deepEqual(providers, ["codex", "claude"]); + + // depth 2 must fail + await assert.rejects( + () => + executeWorkflow({ + source: ` +export const meta = { name: 'deep', description: 'd' } +return await workflow({ scriptPath: ${JSON.stringify(childPath)} }).then(async () => { + // child tries to nest again — child script: + return 1 +}) +`, + runId: run.id, + journal: store, + workspaceRoot: dir, + availableProviders: ["codex"], + runProvider: async () => ({ finalResponse: "x" }), + resolveNestedSource: async () => ` +export const meta = { name: 'mid', description: 'm' } +return await workflow({ scriptPath: 'x' }) +`, + }), + (error: unknown) => + error instanceof WorkflowEngineError && error.kind === "nest_depth", + ); + + store.close(); + await rm(dir, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// cancel via signal +// --------------------------------------------------------------------------- +{ + const dir = await mkdtemp(join(tmpdir(), "wf-cancel-")); + const store = new WorkflowStore(dir); + const run = store.createRun({ + name: "cancel", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: dir, + }); + const ac = new AbortController(); + const api = createWorkflowApi({ + runId: run.id, + journal: store, + meta: { name: "cancel", description: "d" }, + args: undefined, + concurrency: 1, + signal: ac.signal, + workspaceRoot: dir, + availableProviders: ["codex"], + runProvider: async () => { + ac.abort(); + return { finalResponse: "late" }; + }, + }); + // abort before agent + ac.abort(); + await assert.rejects(async () => api.agent("x"), WorkflowEngineError); + store.close(); + await rm(dir, { recursive: true, force: true }); +} + +void createStubBudget; +console.log("workflow-engine.test.ts: ok"); diff --git a/src/workflow-engine.ts b/src/workflow-engine.ts new file mode 100644 index 00000000..5fc08dac --- /dev/null +++ b/src/workflow-engine.ts @@ -0,0 +1,171 @@ +import { availableParallelism } from "node:os"; +import type { + LocalAgentProfile, + LocalAgentProvider, +} from "./local-agent-profiles.js"; +import type { JsonValue } from "./json-types.js"; +import { parseWorkflowScript, type ParsedWorkflowScript } from "./workflow-script.js"; +import { runWorkflowSandbox } from "./workflow-sandbox.js"; +import { + createWorkflowApi, + createWorkflowApiRuntime, + type CreateAgentWorktree, + type WorkflowApi, + type WorkflowApiRuntime, + type WorkflowJournal, + type WorkflowReplay, + type WorkflowRunProvider, + WorkflowEngineError, +} from "./workflow-api.js"; +import { + WORKFLOW_HOST_TIMEOUT_MS, + resolveWorkflowConcurrency, + type WorkflowMeta, + type WorkflowErrorKind, +} from "./workflow-types.js"; +import { + isWorkflowOperationError, + workflowErrorKind, +} from "./workflow-errors.js"; + +export interface ExecuteWorkflowOptions { + /** Pre-parsed script, or pass `source` instead. */ + parsed?: ParsedWorkflowScript; + source?: string; + filename?: string; + runId: string; + journal: WorkflowJournal; + args?: JsonValue; + concurrency?: number; + signal?: AbortSignal; + workspaceRoot: string; + baseSha?: string; + availableProviders: LocalAgentProvider[]; + agentProfiles?: LocalAgentProfile[]; + runProvider: WorkflowRunProvider; + createWorktree?: CreateAgentWorktree; + replay?: WorkflowReplay; + resolveNestedSource?: (nameOrRef: string | { scriptPath: string }) => string | Promise; + nestDepth?: number; + timeoutMs?: number; + /** Shared call counter/semaphore for nested workflow execution. */ + runtime?: WorkflowApiRuntime; + /** Optional hooks after API construction (tests). */ + onApi?: (api: WorkflowApi) => void; +} + +export interface ExecuteWorkflowResult { + result: unknown; + meta: WorkflowMeta; + callCount: number; +} + +/** + * Execute one workflow script body (top-level or nested). + * Does not create/claim/complete journal run rows — host/worker owns run lifecycle. + */ +export async function executeWorkflow( + options: ExecuteWorkflowOptions, +): Promise { + const parsed = + options.parsed ?? + parseWorkflowScript(options.source ?? "", { filename: options.filename }); + const nestDepth = options.nestDepth ?? 0; + const signal = options.signal ?? new AbortController().signal; + const concurrency = + options.concurrency ?? + resolveWorkflowConcurrency(parsed.meta.concurrency, availableParallelism()); + + const resolveNestedSource = options.resolveNestedSource; + const runtime = options.runtime ?? createWorkflowApiRuntime(concurrency); + + // Shared callIndex/semaphore for nested scripts via parent API path. + const api = createWorkflowApi({ + runId: options.runId, + journal: options.journal as WorkflowJournal, + meta: parsed.meta, + args: options.args, + concurrency, + signal, + workspaceRoot: options.workspaceRoot, + baseSha: options.baseSha, + availableProviders: options.availableProviders, + agentProfiles: options.agentProfiles, + runProvider: options.runProvider, + createWorktree: options.createWorktree, + replay: options.replay, + runtime, + nestDepth, + resolveNestedSource, + executeNested: resolveNestedSource + ? async (input) => + ( + await executeWorkflow({ + ...options, + parsed: undefined, + source: input.source, + filename: "workflow:nested", + args: input.args, + signal, + concurrency, + runtime, + nestDepth: input.nestDepth, + onApi: undefined, + }) + ).result + : undefined, + }); + options.onApi?.(api); + + if (nestDepth === 0) { + options.journal.appendEvent({ + runId: options.runId, + type: "run_started", + data: { + name: parsed.meta.name, + scriptHash: parsed.scriptHash, + concurrency, + }, + }); + } + + try { + const result = await runWorkflowSandbox({ + parsed, + api, + timeoutMs: options.timeoutMs ?? WORKFLOW_HOST_TIMEOUT_MS, + signal, + }); + return { + result, + meta: parsed.meta, + callCount: api.getCallCount(), + }; + } catch (error) { + if (error instanceof WorkflowEngineError) { + throw error; + } + throw error; + } +} + +export function mapEngineErrorKind(error: unknown): WorkflowErrorKind { + if (error instanceof WorkflowEngineError) { + return error.kind as WorkflowErrorKind; + } + if (isWorkflowOperationError(error)) { + return workflowErrorKind(error); + } + if (error && typeof error === "object" && "name" in error) { + const name = String((error as { name: string }).name); + if (name === "WorkflowScriptError") { + const kind = (error as { kind?: string }).kind; + if (kind === "meta" || kind === "syntax" || kind === "script_too_large") { + return kind; + } + return "syntax"; + } + if (name === "WorkflowDeterminismError") return "determinism"; + } + return "internal"; +} diff --git a/src/workflow-errors.test.ts b/src/workflow-errors.test.ts new file mode 100644 index 00000000..c05df761 --- /dev/null +++ b/src/workflow-errors.test.ts @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + AgentProviderCancelledError, + AgentProviderExecutionError, + providerErrorFromCause, +} from "./local-agent-errors.js"; +import { + parseWorkflowArgFlagsResult, + readWorkflowScriptFileResult, + resolveNamedWorkflowScriptResult, +} from "./workflow-files.js"; +import { + InvalidRunTransitionError, + InvalidWorkflowInputError, + NamedWorkflowNotFoundError, + SchemaRetriesExhaustedError, + WorkflowFileNotFoundError, + WorkflowNotFoundError, + WorktreeOperationError, + serializeWorkflowError, + workflowCliExitCode, + workflowErrorKind, +} from "./workflow-errors.js"; +import { WorkflowStore } from "./workflow-store.js"; +import { enforceAgentSchemaResult } from "./workflow-schema.js"; +import { createWorkflowWorktreeResult } from "./workflow-worktrees.js"; + +{ + const invalid = parseWorkflowArgFlagsResult(["--arg", "missing-equals"]); + assert.ok(invalid.isErr()); + if (invalid.isErr()) assert.ok(InvalidWorkflowInputError.is(invalid.error)); +} + +{ + const missing = await readWorkflowScriptFileResult("/definitely/missing/workflow.js"); + assert.ok(missing.isErr()); + if (missing.isErr()) assert.ok(WorkflowFileNotFoundError.is(missing.error)); +} + +{ + const root = await mkdtemp(join(tmpdir(), "wf-result-files-")); + try { + const missing = await resolveNamedWorkflowScriptResult({ + name: "missing", + workspaceRoot: root, + }); + assert.ok(missing.isErr()); + if (missing.isErr()) assert.ok(NamedWorkflowNotFoundError.is(missing.error)); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +{ + const cancelled = Object.assign(new Error("cancel"), { name: "AbortError" }); + assert.ok(AgentProviderCancelledError.is(providerErrorFromCause({ + provider: "codex", + operation: "workflow.agent", + cause: cancelled, + }))); + assert.ok( + AgentProviderExecutionError.is( + providerErrorFromCause({ + provider: "opencode", + operation: "workflow.agent", + cause: new Error("authentication failed"), + }), + ), + ); + + const execution = new AgentProviderExecutionError({ + code: "PROVIDER_EXECUTION_ERROR", + provider: "codex", + operation: "workflow.agent", + retryable: false, + message: "Codex workflow agent failed.", + }); + assert.equal(workflowCliExitCode(execution), 1); + assert.deepEqual(serializeWorkflowError(execution), { + code: "AgentProviderExecutionError", + message: execution.message, + kind: "provider", + retryable: false, + }); +} + +{ + const root = await mkdtemp(join(tmpdir(), "wf-result-store-")); + const store = new WorkflowStore(root); + try { + const missing = store.claimRunResult("wfr_missing", process.pid); + assert.ok(missing.isErr()); + if (missing.isErr()) assert.ok(WorkflowNotFoundError.is(missing.error)); + + const run = store.createRun({ + name: "result-store", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: root, + }); + assert.ok(store.claimRunResult(run.id, process.pid).isOk()); + const duplicate = store.claimRunResult(run.id, process.pid); + assert.ok(duplicate.isErr()); + if (duplicate.isErr()) assert.ok(InvalidRunTransitionError.is(duplicate.error)); + } finally { + store.close(); + await rm(root, { recursive: true, force: true }); + } +} + +{ + const exhausted = await enforceAgentSchemaResult({ + schema: { type: "object" }, + prompt: "return json", + provider: "opencode", + maxRetries: 0, + run: async () => ({ finalResponse: "not json" }), + }); + assert.ok(exhausted.isErr()); + if (exhausted.isErr()) { + assert.ok(SchemaRetriesExhaustedError.is(exhausted.error)); + assert.equal(workflowErrorKind(exhausted.error), "schema"); + } +} + +{ + const root = await mkdtemp(join(tmpdir(), "wf-result-worktree-")); + try { + const created = await createWorkflowWorktreeResult( + { worktreeRoot: join(root, "worktrees") }, + { + runId: "wfr_result", + callIndex: 0, + workspaceRoot: root, + }, + ); + assert.ok(created.isErr()); + if (created.isErr()) { + assert.ok(WorktreeOperationError.is(created.error)); + assert.equal(workflowErrorKind(created.error), "worktree"); + assert.ok(created.error.cause); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +console.log("workflow-errors.test.ts: ok"); diff --git a/src/workflow-errors.ts b/src/workflow-errors.ts new file mode 100644 index 00000000..64efaafc --- /dev/null +++ b/src/workflow-errors.ts @@ -0,0 +1,380 @@ +import { TaggedError } from "better-result"; +import { + isAgentProviderError, + type AgentProviderError, +} from "./local-agent-errors.js"; +import type { + WorkflowErrorKind, + WorkflowRunStatus, +} from "./workflow-types.js"; + +/** Domain failures inside agent()/sandbox orchestration (throw, not Result). */ +export class WorkflowEngineError extends Error { + constructor( + readonly kind: + | "cancelled" + | "provider_unavailable" + | "no_provider" + | "profile" + | "nest_depth" + | "call_limit" + | "worktree" + | "schema" + | "path" + | "result_too_large" + | "internal", + message: string, + ) { + super(message); + this.name = "WorkflowEngineError"; + } +} + +export class InvalidWorkflowInputError extends TaggedError( + "InvalidWorkflowInputError", +)<{ + code: + | "ambiguous_source" + | "missing_source" + | "invalid_name" + | "invalid_argument" + | "invalid_path"; + message: string; +}>() {} + +export class WorkflowFileNotFoundError extends TaggedError( + "WorkflowFileNotFoundError", +)<{ + path: string; + message: string; +}>() { + constructor(path: string) { + super({ path, message: `Script file not found: ${path}` }); + } +} + +export class WorkflowFileReadError extends TaggedError( + "WorkflowFileReadError", +)<{ + path: string; + cause: unknown; + message: string; +}>() { + constructor(path: string, cause: unknown) { + super({ + path, + cause, + message: `Unable to read workflow script ${path}: ${errorMessage(cause)}`, + }); + } +} + +export class WorkflowFileWriteError extends TaggedError( + "WorkflowFileWriteError", +)<{ + path: string; + cause: unknown; + message: string; +}>() { + constructor(path: string, cause: unknown) { + super({ + path, + cause, + message: `Unable to persist workflow script ${path}: ${errorMessage(cause)}`, + }); + } +} + +export class NamedWorkflowNotFoundError extends TaggedError( + "NamedWorkflowNotFoundError", +)<{ + name: string; + candidates: string[]; + message: string; +}>() { + constructor(name: string, candidates: string[]) { + super({ + name, + candidates, + message: `Named workflow not found: ${name}. Looked in ${candidates.join(", ")}`, + }); + } +} + +export class WorkflowNotFoundError extends TaggedError( + "WorkflowNotFoundError", +)<{ + runId: string; + message: string; +}>() { + constructor(runId: string) { + super({ runId, message: `Unknown workflow run: ${runId}` }); + } +} + +export class InvalidRunTransitionError extends TaggedError( + "InvalidRunTransitionError", +)<{ + runId: string; + from: WorkflowRunStatus; + operation: "claim" | "complete" | "fail" | "cancel" | "set_script_path"; + message: string; +}>() { + constructor(input: { + runId: string; + from: WorkflowRunStatus; + operation: "claim" | "complete" | "fail" | "cancel" | "set_script_path"; + }) { + super({ + ...input, + message: `Cannot ${input.operation} workflow run ${input.runId} in status ${input.from}`, + }); + } +} + +export class WorkflowStoreError extends TaggedError( + "WorkflowStoreError", +)<{ + operation: string; + cause: unknown; + message: string; +}>() { + constructor(operation: string, cause: unknown) { + super({ + operation, + cause, + message: `Workflow store ${operation} failed: ${errorMessage(cause)}`, + }); + } +} + +export class WorkflowStoredDataError extends TaggedError( + "WorkflowStoredDataError", +)<{ + record: string; + cause: unknown; + message: string; +}>() { + constructor(record: string, cause: unknown) { + super({ + record, + cause, + message: `Stored workflow data is invalid (${record}): ${errorMessage(cause)}`, + }); + } +} + +export class WorktreeOperationError extends TaggedError( + "WorktreeOperationError", +)<{ + operation: "create" | "inspect" | "finalize" | "remove"; + runId?: string; + callIndex?: number; + path?: string; + cause: unknown; + message: string; +}>() { + constructor(input: { + operation: "create" | "inspect" | "finalize" | "remove"; + runId?: string; + callIndex?: number; + path?: string; + cause: unknown; + }) { + super({ + ...input, + message: `Workflow worktree ${input.operation} failed${input.path ? ` at ${input.path}` : ""}: ${errorMessage(input.cause)}`, + }); + } +} + +export interface SchemaIssue { + path: string; + message: string; +} + +export class InvalidAgentJsonError extends TaggedError( + "InvalidAgentJsonError", +)<{ + attempt: number; + mode: "native" | "prompt"; + responseExcerpt: string; + message: string; +}>() { + constructor(input: { + attempt: number; + mode: "native" | "prompt"; + responseExcerpt: string; + }) { + super({ + ...input, + message: `Agent response was not valid JSON on attempt ${input.attempt}`, + }); + } +} + +export class AgentSchemaValidationError extends TaggedError( + "AgentSchemaValidationError", +)<{ + attempt: number; + mode: "native" | "prompt"; + issues: SchemaIssue[]; + message: string; +}>() { + constructor(input: { + attempt: number; + mode: "native" | "prompt"; + issues: SchemaIssue[]; + }) { + super({ + ...input, + message: `Agent response failed schema validation on attempt ${input.attempt}: ${input.issues.map((issue) => `${issue.path} ${issue.message}`).join("; ")}`, + }); + } +} + +export class SchemaConfigurationError extends TaggedError( + "SchemaConfigurationError", +)<{ + cause: unknown; + message: string; +}>() { + constructor(cause: unknown) { + super({ + cause, + message: `Unable to compile agent JSON Schema: ${errorMessage(cause)}`, + }); + } +} + +export type SchemaAttemptError = InvalidAgentJsonError | AgentSchemaValidationError; + +export class SchemaRetriesExhaustedError extends TaggedError( + "SchemaRetriesExhaustedError", +)<{ + attempts: number; + lastFailure: SchemaAttemptError; + message: string; +}>() { + constructor(attempts: number, lastFailure: SchemaAttemptError) { + super({ + attempts, + lastFailure, + message: `Schema validation failed after ${attempts} attempts: ${lastFailure.message}`, + }); + } +} + +export type WorkflowOperationError = + | InvalidWorkflowInputError + | WorkflowFileNotFoundError + | WorkflowFileReadError + | WorkflowFileWriteError + | NamedWorkflowNotFoundError + | WorkflowNotFoundError + | InvalidRunTransitionError + | WorkflowStoreError + | WorkflowStoredDataError + | WorktreeOperationError + | InvalidAgentJsonError + | AgentSchemaValidationError + | SchemaConfigurationError + | SchemaRetriesExhaustedError + | AgentProviderError; + +export function isWorkflowOperationError(error: unknown): error is WorkflowOperationError { + return ( + InvalidWorkflowInputError.is(error) || + WorkflowFileNotFoundError.is(error) || + WorkflowFileReadError.is(error) || + WorkflowFileWriteError.is(error) || + NamedWorkflowNotFoundError.is(error) || + WorkflowNotFoundError.is(error) || + InvalidRunTransitionError.is(error) || + WorkflowStoreError.is(error) || + WorkflowStoredDataError.is(error) || + WorktreeOperationError.is(error) || + InvalidAgentJsonError.is(error) || + AgentSchemaValidationError.is(error) || + SchemaConfigurationError.is(error) || + SchemaRetriesExhaustedError.is(error) || + isAgentProviderError(error) + ); +} + +export function workflowErrorKind(error: WorkflowOperationError): WorkflowErrorKind { + switch (error._tag) { + case "InvalidWorkflowInputError": + case "WorkflowFileNotFoundError": + case "WorkflowFileReadError": + case "WorkflowFileWriteError": + case "NamedWorkflowNotFoundError": + return "path"; + case "WorkflowNotFoundError": + case "InvalidRunTransitionError": + case "WorkflowStoreError": + case "WorkflowStoredDataError": + return "internal"; + case "WorktreeOperationError": + return "worktree"; + case "InvalidAgentJsonError": + case "AgentSchemaValidationError": + case "SchemaConfigurationError": + case "SchemaRetriesExhaustedError": + return "schema"; + case "AgentProviderCancelledError": + return "cancelled"; + case "AgentProviderUnavailableError": + return "provider_unavailable"; + case "AgentProviderProtocolError": + case "AgentProviderExecutionError": + return "provider"; + } +} + +export function workflowCliExitCode(error: WorkflowOperationError): number { + switch (error._tag) { + case "InvalidWorkflowInputError": + return 2; + case "WorkflowFileNotFoundError": + case "NamedWorkflowNotFoundError": + case "WorkflowNotFoundError": + return 3; + case "AgentProviderUnavailableError": + return 4; + case "AgentProviderCancelledError": + return 130; + case "InvalidAgentJsonError": + case "AgentSchemaValidationError": + case "SchemaConfigurationError": + case "SchemaRetriesExhaustedError": + return 5; + case "WorkflowFileReadError": + case "WorkflowFileWriteError": + case "InvalidRunTransitionError": + case "WorkflowStoreError": + case "WorkflowStoredDataError": + case "WorktreeOperationError": + case "AgentProviderProtocolError": + case "AgentProviderExecutionError": + return 1; + } +} + +export function serializeWorkflowError(error: WorkflowOperationError): { + code: WorkflowOperationError["_tag"]; + message: string; + kind: WorkflowErrorKind; + retryable: boolean; +} { + return { + code: error._tag, + message: error.message, + kind: workflowErrorKind(error), + retryable: isAgentProviderError(error) ? error.retryable : false, + }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/workflow-files.test.ts b/src/workflow-files.test.ts new file mode 100644 index 00000000..37f445e5 --- /dev/null +++ b/src/workflow-files.test.ts @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + parseWorkflowArgFlagsResult, + persistWorkflowScriptResult, + readProjectWorkflowScriptFileResult, + resolveNamedWorkflowScriptResult, + resolveWorkflowScriptFromPathOrNameResult, +} from "./workflow-files.js"; +import { + InvalidWorkflowInputError, + NamedWorkflowNotFoundError, +} from "./workflow-errors.js"; +import { hashSource } from "./workflow-script.js"; + +{ + const parsed = parseWorkflowArgFlagsResult([ + "--arg", + "n=1", + "--arg", + 'files=["a.ts"]', + "--follow", + "extra", + ]); + assert.equal(parsed.isOk(), true); + if (parsed.isOk()) { + assert.deepEqual(parsed.value.args, { n: 1, files: ["a.ts"] }); + assert.deepEqual(parsed.value.rest, ["--follow", "extra"]); + } +} + +{ + const dir = await mkdtemp(join(tmpdir(), "wf-files-")); + const persisted = await persistWorkflowScriptResult({ + stateDir: dir, + runId: "wfr_test", + source: "export const meta = { name: 'x', description: 'd' }\nreturn 1\n", + preferredName: "demo", + }); + assert.equal(persisted.isOk(), true); + if (!persisted.isOk()) throw persisted.error; + const path = persisted.value; + assert.match(path.replaceAll("\\", "/"), /workflow-scripts\/wfr_test\/demo\.js$/); + + const file = await resolveWorkflowScriptFromPathOrNameResult({ + file: path, + workspaceRoot: dir, + }); + assert.equal(file.isOk(), true); + if (!file.isOk()) throw file.error; + assert.equal(file.value.origin, "file"); + assert.equal(file.value.scriptHash, hashSource(file.value.source)); + + await mkdir(join(dir, ".devspace", "workflows"), { recursive: true }); + await writeFile( + join(dir, ".devspace", "workflows", "named.js"), + "export const meta = { name: 'named', description: 'd' }\nreturn 2\n", + ); + const named = await resolveNamedWorkflowScriptResult({ + name: "named", + workspaceRoot: dir, + }); + assert.equal(named.isOk(), true); + if (!named.isOk()) throw named.error; + assert.equal(named.value.origin, "named"); + assert.match(named.value.source, /named/); + + const projectRead = await readProjectWorkflowScriptFileResult({ + scriptPath: join(dir, ".devspace", "workflows", "named.js"), + workspaceRoot: dir, + }); + assert.equal(projectRead.isOk(), true); + if (!projectRead.isOk()) throw projectRead.error; + assert.equal(projectRead.value.nameHint, "named"); + + const outsideProject = await readProjectWorkflowScriptFileResult({ + scriptPath: path, + workspaceRoot: dir, + }); + assert.equal(outsideProject.isErr(), true); + if (outsideProject.isErr()) { + assert.equal(InvalidWorkflowInputError.is(outsideProject.error), true); + assert.match(outsideProject.error.message, /must be inside/); + } + + if (process.platform !== "win32") { + const outside = await mkdtemp(join(tmpdir(), "wf-files-outside-")); + try { + const outsideScript = join(outside, "escape.js"); + await writeFile( + outsideScript, + "export const meta = { name: 'escape', description: 'd' }\nreturn 4\n", + ); + await symlink( + outsideScript, + join(dir, ".devspace", "workflows", "escape.js"), + ); + const escaped = await readProjectWorkflowScriptFileResult({ + scriptPath: "escape.js", + workspaceRoot: dir, + }); + assert.equal(escaped.isErr(), true); + if (escaped.isErr()) { + assert.equal(InvalidWorkflowInputError.is(escaped.error), true); + assert.match(escaped.error.message, /resolves outside/); + } + } finally { + await rm(outside, { recursive: true, force: true }); + } + } + + await mkdir(join(dir, "workflows"), { recursive: true }); + await writeFile( + join(dir, "workflows", "legacy.js"), + "export const meta = { name: 'legacy', description: 'd' }\nreturn 3\n", + ); + const legacy = await resolveNamedWorkflowScriptResult({ + name: "legacy", + workspaceRoot: dir, + }); + assert.equal(legacy.isErr(), true); + if (legacy.isErr()) { + assert.equal(NamedWorkflowNotFoundError.is(legacy.error), true); + } + + const missing = await resolveNamedWorkflowScriptResult({ + name: "missing", + workspaceRoot: dir, + }); + assert.equal(missing.isErr(), true); + if (missing.isErr()) { + assert.equal(NamedWorkflowNotFoundError.is(missing.error), true); + } + + await rm(dir, { recursive: true, force: true }); +} + +console.log("workflow-files.test.ts: ok"); diff --git a/src/workflow-files.ts b/src/workflow-files.ts new file mode 100644 index 00000000..90f3016d --- /dev/null +++ b/src/workflow-files.ts @@ -0,0 +1,261 @@ +import { randomBytes } from "node:crypto"; +import { mkdir, readFile, realpath, writeFile } from "node:fs/promises"; +import { basename, extname, isAbsolute, join, resolve } from "node:path"; +import { Result, type Result as BetterResult } from "better-result"; +import { hashSource } from "./workflow-script.js"; +import { jsonValueSchema, type JsonValue } from "./json-types.js"; +import { + InvalidWorkflowInputError, + NamedWorkflowNotFoundError, + WorkflowFileNotFoundError, + WorkflowFileReadError, + WorkflowFileWriteError, +} from "./workflow-errors.js"; +import { isPathInsideRoot } from "./roots.js"; + +export interface ResolvedWorkflowScript { + source: string; + scriptPath: string; + scriptHash: string; + nameHint: string; + origin: "file" | "named" | "inline" | "resume"; +} + +export type WorkflowFileResolveError = + | InvalidWorkflowInputError + | NamedWorkflowNotFoundError + | WorkflowFileNotFoundError + | WorkflowFileReadError; + +/** + * Persist script under stateDir for worker re-read / audit. + * Returns absolute path written. + */ +export async function persistWorkflowScriptResult(input: { + stateDir: string; + runId: string; + source: string; + preferredName?: string; +}): Promise> { + const dir = join(input.stateDir, "workflow-scripts", input.runId); + const base = + sanitizeSegment(input.preferredName ?? "script") || + `script-${randomBytes(3).toString("hex")}`; + const path = join(dir, `${base}.js`); + return Result.tryPromise({ + try: async () => { + await mkdir(dir, { recursive: true }); + await writeFile(path, input.source, { encoding: "utf8", mode: 0o600 }); + return path; + }, + catch: (cause) => new WorkflowFileWriteError(path, cause), + }); +} + +export async function readWorkflowScriptFileResult( + path: string, +): Promise> { + const scriptPath = resolve(path); + return Result.tryPromise({ + try: async () => { + const source = await readFile(scriptPath, "utf8"); + return { + source, + scriptPath, + scriptHash: hashSource(source), + nameHint: basename(scriptPath, extname(scriptPath)), + origin: "file" as const, + }; + }, + catch: (cause) => + isFileNotFound(cause) + ? new WorkflowFileNotFoundError(scriptPath) + : new WorkflowFileReadError(scriptPath, cause), + }); +} + +/** Resolve an explicit nested script only inside `/.devspace/workflows`. */ +export async function readProjectWorkflowScriptFileResult(input: { + scriptPath: string; + workspaceRoot: string; +}): Promise> { + const projectWorkflowRoot = resolve(input.workspaceRoot, ".devspace", "workflows"); + const requestedPath = isAbsolute(input.scriptPath) + ? resolve(input.scriptPath) + : resolve(projectWorkflowRoot, input.scriptPath); + + if (!isPathInsideRoot(requestedPath, projectWorkflowRoot)) { + return Result.err( + new InvalidWorkflowInputError({ + code: "invalid_path", + message: `Nested workflow script must be inside ${projectWorkflowRoot}`, + }), + ); + } + + let canonicalRoot: string; + let canonicalPath: string; + try { + [canonicalRoot, canonicalPath] = await Promise.all([ + realpath(projectWorkflowRoot), + realpath(requestedPath), + ]); + } catch (cause) { + return Result.err( + isFileNotFound(cause) + ? new WorkflowFileNotFoundError(requestedPath) + : new WorkflowFileReadError(requestedPath, cause), + ); + } + + if (!isPathInsideRoot(canonicalPath, canonicalRoot)) { + return Result.err( + new InvalidWorkflowInputError({ + code: "invalid_path", + message: `Nested workflow script resolves outside ${projectWorkflowRoot}`, + }), + ); + } + return readWorkflowScriptFileResult(canonicalPath); +} + +/** + * Resolve named workflow script. + * Search order: + * 1. `/.devspace/workflows/.js` + * 2. `/workflows/.js` (if stateDir provided) + */ +export async function resolveNamedWorkflowScriptResult(input: { + name: string; + workspaceRoot: string; + stateDir?: string; +}): Promise> { + const name = input.name.trim(); + if (!name || name.includes("/") || name.includes("\\") || name.includes("..")) { + return Result.err( + new InvalidWorkflowInputError({ + code: "invalid_name", + message: `Invalid workflow name: ${JSON.stringify(input.name)}`, + }), + ); + } + const candidates = [ + join(input.workspaceRoot, ".devspace", "workflows", `${name}.js`), + ]; + if (input.stateDir) { + candidates.push(join(input.stateDir, "workflows", `${name}.js`)); + } + for (const candidate of candidates) { + const result = await readWorkflowScriptFileResult(candidate); + if (result.isOk()) { + return Result.ok({ ...result.value, nameHint: name, origin: "named" as const }); + } + if (WorkflowFileNotFoundError.is(result.error)) continue; + return result; + } + return Result.err(new NamedWorkflowNotFoundError(name, candidates)); +} + +export async function resolveWorkflowScriptFromPathOrNameResult(input: { + file?: string; + name?: string; + workspaceRoot: string; + stateDir?: string; +}): Promise> { + if (input.file && input.name) { + return Result.err( + new InvalidWorkflowInputError({ + code: "ambiguous_source", + message: "Pass only one of --file or --name", + }), + ); + } + if (input.file) { + const path = isAbsolute(input.file) + ? input.file + : resolve(input.workspaceRoot, input.file); + return readWorkflowScriptFileResult(path); + } + if (input.name) { + return resolveNamedWorkflowScriptResult({ + name: input.name, + workspaceRoot: input.workspaceRoot, + stateDir: input.stateDir, + }); + } + return Result.err( + new InvalidWorkflowInputError({ + code: "missing_source", + message: "Provide --file or --name ", + }), + ); +} + +export function parseWorkflowArgFlagsResult( + tokens: string[], +): BetterResult< + { args: Record; rest: string[] }, + InvalidWorkflowInputError +> { + const args: Record = {}; + const rest: string[] = []; + for (let i = 0; i < tokens.length; i += 1) { + const token = tokens[i]!; + if (token === "--arg") { + const pair = tokens[++i]; + if (!pair || !pair.includes("=")) { + return Result.err( + new InvalidWorkflowInputError({ + code: "invalid_argument", + message: "--arg requires key=value", + }), + ); + } + const eq = pair.indexOf("="); + const key = pair.slice(0, eq); + const raw = pair.slice(eq + 1); + args[key] = coerceArgValue(raw); + continue; + } + if (token.startsWith("--arg=")) { + const pair = token.slice("--arg=".length); + const eq = pair.indexOf("="); + if (eq < 0) { + return Result.err( + new InvalidWorkflowInputError({ + code: "invalid_argument", + message: "--arg requires key=value", + }), + ); + } + args[pair.slice(0, eq)] = coerceArgValue(pair.slice(eq + 1)); + continue; + } + rest.push(token); + } + return Result.ok({ args, rest }); +} + +function coerceArgValue(raw: string): JsonValue { + try { + return jsonValueSchema.parse(JSON.parse(raw) as unknown); + } catch { + return raw; + } +} + +function sanitizeSegment(value: string): string { + return value + .replace(/[^a-zA-Z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); +} + +function isFileNotFound(error: unknown): boolean { + return Boolean( + error && + typeof error === "object" && + "code" in error && + (error as { code?: unknown }).code === "ENOENT", + ); +} diff --git a/src/workflow-launch.test.ts b/src/workflow-launch.test.ts new file mode 100644 index 00000000..001ab638 --- /dev/null +++ b/src/workflow-launch.test.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveCliEntry } from "./workflow-cli-entry.js"; +import { launchWorkflowRun } from "./workflow-launch.js"; +import { WorkflowStore } from "./workflow-store.js"; + +const script = (name: string, value = 1) => + `export const meta = { name: '${name}', description: 'd' }\nreturn ${value}\n`; + +{ + const dir = await mkdtemp(join(tmpdir(), "wf-launch-")); + const store = new WorkflowStore(dir); + const common = { + store, + config: { stateDir: dir }, + workspaceRoot: dir, + workspaceId: "ws_owner", + scriptFileScope: "local" as const, + cliEntry: "/tmp/devspace-cli-not-used", + spawn: false, + }; + + const launched = await launchWorkflowRun({ + ...common, + source: { + kind: "inline", + script: `export const meta = { name: 'launch-demo', description: 'd', phases: [{ title: 'Plan' }, { title: 'Build', detail: 'Implement it' }] }\nreturn 1\n`, + }, + args: { n: 1 }, + }); + if (launched.isErr()) throw launched.error; + assert.equal(launched.value.run.status, "starting"); + assert.equal(launched.value.run.argsJson, JSON.stringify({ n: 1 })); + assert.deepEqual(launched.value.run.phases, [ + { title: "Plan" }, + { title: "Build", detail: "Implement it" }, + ]); + + const workflowDir = join(dir, ".devspace", "workflows"); + await mkdir(workflowDir, { recursive: true }); + await writeFile(join(workflowDir, "named.js"), script("named", 2)); + await writeFile(join(dir, "file.js"), script("file", 3)); + + const named = await launchWorkflowRun({ + ...common, + source: { kind: "named", name: "named" }, + }); + assert.ok(named.isOk()); + if (named.isOk()) assert.equal(named.value.source, "named"); + + const file = await launchWorkflowRun({ + ...common, + source: { kind: "file", path: "file.js" }, + }); + assert.ok(file.isOk()); + if (file.isOk()) assert.equal(file.value.source, "file"); + + const resumed = await launchWorkflowRun({ + ...common, + source: { + kind: "resume", + runId: launched.value.run.id, + override: { kind: "file", path: "named.js" }, + }, + scriptFileScope: "project-workflows", + }); + assert.ok(resumed.isOk()); + if (resumed.isOk()) { + assert.equal(resumed.value.source, "resume"); + assert.equal(resumed.value.run.argsJson, JSON.stringify({ n: 1 })); + } + + const outside = await launchWorkflowRun({ + ...common, + source: { kind: "file", path: join(dir, "file.js") }, + scriptFileScope: "project-workflows", + }); + assert.ok(outside.isErr()); + + const crossWorkspace = await launchWorkflowRun({ + ...common, + workspaceId: "ws_other", + source: { kind: "resume", runId: launched.value.run.id }, + }); + assert.ok(crossWorkspace.isErr()); + + store.close(); + await rm(dir, { recursive: true, force: true }); +} + +{ + assert.match(resolveCliEntry().replaceAll("\\", "/"), /\/src\/cli\.ts$/); + const dir = await mkdtemp(join(tmpdir(), "wf-launch-failure-")); + const stateFile = join(dir, "not-a-directory"); + await writeFile(stateFile, "blocked"); + const store = new WorkflowStore(join(dir, "store")); + const failed = await launchWorkflowRun({ + store, + config: { stateDir: stateFile }, + workspaceRoot: dir, + source: { kind: "inline", script: script("failure") }, + scriptFileScope: "local", + cliEntry: "/tmp/devspace-cli-not-used", + spawn: false, + }); + assert.ok(failed.isErr()); + assert.equal(store.listRuns(1)[0]?.status, "failed"); + store.close(); + await rm(dir, { recursive: true, force: true }); +} + +console.log("workflow-launch.test.ts: ok"); diff --git a/src/workflow-launch.ts b/src/workflow-launch.ts new file mode 100644 index 00000000..855f0152 --- /dev/null +++ b/src/workflow-launch.ts @@ -0,0 +1,319 @@ +import { resolve } from "node:path"; +import type { ServerConfig } from "./config.js"; +import { parseJsonText, type JsonObject, type JsonValue } from "./json-types.js"; +import { + persistWorkflowScriptResult, + readProjectWorkflowScriptFileResult, + readWorkflowScriptFileResult, + resolveNamedWorkflowScriptResult, + resolveWorkflowScriptFromPathOrNameResult, +} from "./workflow-files.js"; +import { parseWorkflowScript, WorkflowScriptError } from "./workflow-script.js"; +import type { WorkflowStore } from "./workflow-store.js"; +import type { WorkflowRunRecord, WorkflowRunSource } from "./workflow-types.js"; +import { + InvalidWorkflowInputError, + WorkflowNotFoundError, + WorkflowStoredDataError, + isWorkflowOperationError, + type WorkflowOperationError, + type WorkflowFileWriteError, +} from "./workflow-errors.js"; +import { resolveWorkspaceHead } from "./workflow-worktrees.js"; +import { spawnWorkflowWorker } from "./workflow-worker.js"; +import { Result, type Result as BetterResult } from "better-result"; +import type { WorkflowRunTransitionError } from "./workflow-store.js"; + +export type LaunchWorkflowSource = + | { kind: "inline"; script: string; filename?: string } + | { kind: "file"; path: string } + | { kind: "named"; name: string } + | { + kind: "resume"; + runId: string; + /** Optional replacement source while resuming. */ + override?: + | { kind: "inline"; script: string; filename?: string } + | { kind: "file"; path: string } + | { kind: "named"; name: string }; + }; + +export interface LaunchWorkflowRunInput { + store: WorkflowStore; + config: Pick; + workspaceRoot: string; + workspaceId?: string; + source: LaunchWorkflowSource; + args?: JsonValue; + /** Local CLI paths or MCP paths constrained to the project's workflow directory. */ + scriptFileScope: "local" | "project-workflows"; + /** Absolute path to cli entry used to spawn `workflow __worker`. */ + cliEntry: string; + /** When false, create the run row but do not spawn (tests). Default true. */ + spawn?: boolean; +} + +export type LaunchWorkflowError = + | WorkflowOperationError + | WorkflowScriptError + | WorkflowFileWriteError + | WorkflowRunTransitionError; + +export interface LaunchWorkflowRunResult { + run: WorkflowRunRecord; + parsedName: string; + scriptHash: string; + source: WorkflowRunSource; +} + +/** + * Shared CLI/MCP start path: resolve script → parse → create run → persist → spawn. + */ +export async function launchWorkflowRun( + input: LaunchWorkflowRunInput, +): Promise> { + try { + const resolved = await resolveLaunchSource(input); + if (resolved.isErr()) return Result.err(resolved.error); + + const { + sourceText, + scriptHash, + nameHint, + runSource, + priorRunId, + filename, + args, + } = resolved.value; + + const parsed = parseWorkflowScript(sourceText, { filename }); + const baseSha = await resolveWorkspaceHead(input.workspaceRoot); + const preferredName = parsed.meta.name || nameHint; + + const run = input.store.createRun({ + name: preferredName, + source: runSource, + scriptPath: "pending", + scriptHash, + workspaceRoot: input.workspaceRoot, + workspaceId: input.workspaceId, + argsJson: JSON.stringify(args === undefined ? null : args), + phases: parsed.meta.phases, + resumedFromRunId: priorRunId, + baseSha, + }); + + const persisted = await persistWorkflowScriptResult({ + stateDir: input.config.stateDir, + runId: run.id, + source: sourceText, + preferredName, + }); + if (persisted.isErr()) { + failStartedRun(input.store, run.id, persisted.error); + return Result.err(persisted.error); + } + + const updated = input.store.setScriptPathResult(run.id, persisted.value); + if (updated.isErr()) { + failStartedRun(input.store, run.id, updated.error); + return Result.err(updated.error); + } + + if (input.spawn !== false) { + try { + await spawnWorkflowWorker(run.id, input.cliEntry); + } catch (error) { + failStartedRun(input.store, run.id, error); + throw error; + } + } + + return Result.ok({ + run: updated.value, + parsedName: preferredName, + scriptHash, + source: runSource, + }); + } catch (error) { + if (isLaunchError(error)) return Result.err(error); + throw error; + } +} + +interface ResolvedLaunch { + sourceText: string; + scriptHash: string; + nameHint: string; + runSource: WorkflowRunSource; + priorRunId?: string; + filename: string; + args: JsonValue | undefined; +} + +async function resolveLaunchSource( + input: LaunchWorkflowRunInput, +): Promise> { + const { source, store, config, workspaceRoot } = input; + let args = input.args; + + if (source.kind === "resume") { + const priorResult = store.getRunResult(source.runId); + if (priorResult.isErr()) return Result.err(priorResult.error); + const prior = priorResult.value; + if (!prior) return Result.err(new WorkflowNotFoundError(source.runId)); + if (!runBelongsToWorkspace(prior, input.workspaceId, workspaceRoot)) { + return Result.err(new WorkflowNotFoundError(source.runId)); + } + + let sourceText: string; + let scriptHash: string; + let nameHint: string; + let filename: string; + + if (source.override?.kind === "inline") { + sourceText = source.override.script; + const overrideParsed = parseWorkflowScript(sourceText, { + filename: source.override.filename ?? "workflow:inline", + }); + scriptHash = overrideParsed.scriptHash; + nameHint = overrideParsed.meta.name; + filename = source.override.filename ?? "workflow:inline"; + } else if (source.override?.kind === "named") { + const named = await resolveNamedWorkflowScriptResult({ + name: source.override.name, + workspaceRoot, + stateDir: config.stateDir, + }); + if (named.isErr()) return Result.err(named.error); + sourceText = named.value.source; + scriptHash = named.value.scriptHash; + nameHint = named.value.nameHint; + filename = named.value.scriptPath; + } else if (source.override?.kind === "file") { + const file = await resolveExplicitWorkflowFile(input, source.override.path); + if (file.isErr()) return Result.err(file.error); + sourceText = file.value.source; + scriptHash = file.value.scriptHash; + nameHint = file.value.nameHint; + filename = file.value.scriptPath; + } else { + const priorScript = await readWorkflowScriptFileResult(prior.scriptPath); + if (priorScript.isErr()) return Result.err(priorScript.error); + sourceText = priorScript.value.source; + scriptHash = priorScript.value.scriptHash; + nameHint = prior.name; + filename = prior.scriptPath; + } + + if (args === undefined && prior.argsJson && prior.argsJson !== "null") { + try { + args = parseJsonText(prior.argsJson); + } catch (cause) { + return Result.err(new WorkflowStoredDataError(`${prior.id}.argsJson`, cause)); + } + } + + return Result.ok({ + sourceText, + scriptHash, + nameHint, + runSource: "resume", + priorRunId: prior.id, + filename, + args, + }); + } + + if (source.kind === "inline") { + const parsed = parseWorkflowScript(source.script, { + filename: source.filename ?? "workflow:inline", + }); + return Result.ok({ + sourceText: source.script, + scriptHash: parsed.scriptHash, + nameHint: parsed.meta.name, + runSource: "inline", + filename: source.filename ?? "workflow:inline", + args, + }); + } + + if (source.kind === "named") { + const named = await resolveNamedWorkflowScriptResult({ + name: source.name, + workspaceRoot, + stateDir: config.stateDir, + }); + if (named.isErr()) return Result.err(named.error); + return Result.ok({ + sourceText: named.value.source, + scriptHash: named.value.scriptHash, + nameHint: named.value.nameHint, + runSource: "named", + filename: named.value.scriptPath, + args, + }); + } + + if (source.kind === "file") { + const file = await resolveExplicitWorkflowFile(input, source.path); + if (file.isErr()) return Result.err(file.error); + return Result.ok({ + sourceText: file.value.source, + scriptHash: file.value.scriptHash, + nameHint: file.value.nameHint, + runSource: "file", + filename: file.value.scriptPath, + args, + }); + } + + return Result.err( + new InvalidWorkflowInputError({ + code: "missing_source", + message: "Provide a workflow script source", + }), + ); +} + +function isLaunchError(error: unknown): error is LaunchWorkflowError { + return error instanceof WorkflowScriptError || isWorkflowOperationError(error); +} + +function resolveExplicitWorkflowFile( + input: LaunchWorkflowRunInput, + path: string, +) { + if (input.scriptFileScope === "project-workflows") { + return readProjectWorkflowScriptFileResult({ + scriptPath: path, + workspaceRoot: input.workspaceRoot, + }); + } + return resolveWorkflowScriptFromPathOrNameResult({ + file: path, + workspaceRoot: input.workspaceRoot, + stateDir: input.config.stateDir, + }); +} + +function runBelongsToWorkspace( + run: Pick, + workspaceId: string | undefined, + workspaceRoot: string, +): boolean { + if (run.workspaceId) return run.workspaceId === workspaceId; + return resolve(run.workspaceRoot) === resolve(workspaceRoot); +} + +function failStartedRun(store: WorkflowStore, runId: string, error: unknown): void { + store.failRunResult(runId, { + error: error instanceof Error ? error.message : String(error), + errorKind: "internal", + }); +} + +export function isJsonObject(value: JsonValue): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/workflow-lifecycle.test.ts b/src/workflow-lifecycle.test.ts new file mode 100644 index 00000000..c0d9456c --- /dev/null +++ b/src/workflow-lifecycle.test.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + cancelWorkflowRun, + type WorkflowLifecycleRuntime, +} from "./workflow-lifecycle.js"; +import { WorkflowStore } from "./workflow-store.js"; + +const root = mkdtempSync(join(tmpdir(), "devspace-workflow-lifecycle-test-")); +const store = new WorkflowStore(root); + +try { + { + const run = createRunningRun(store, root, "cooperative", 101); + const signals: NodeJS.Signals[] = []; + let slept = false; + const runtime: WorkflowLifecycleRuntime = { + sleep: async () => { + if (!slept) { + slept = true; + store.cancelRun(run.id, "worker observed cancellation"); + } + }, + terminate: (_pid, signal) => signals.push(signal), + }; + const cancelled = await cancelWorkflowRun(store, run.id, { + graceMs: 100, + pollMs: 1, + runtime, + }); + assert.equal(cancelled.status, "cancelled"); + assert.deepEqual(signals, []); + } + + { + const run = createRunningRun(store, root, "hard", 202); + const signals: NodeJS.Signals[] = []; + const runtime: WorkflowLifecycleRuntime = { + sleep: async () => {}, + terminate: (_pid, signal) => signals.push(signal), + }; + const cancelled = await cancelWorkflowRun(store, run.id, { + graceMs: 0, + termWaitMs: 0, + runtime, + }); + assert.equal(cancelled.status, "cancelled"); + assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]); + assert.equal(store.listEvents(run.id).at(-1)?.type, "run_cancelled"); + } + + { + const run = store.createRun({ + name: "not-claimed", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: root, + }); + const signals: NodeJS.Signals[] = []; + const cancelled = await cancelWorkflowRun(store, run.id, { + graceMs: 0, + termWaitMs: 0, + runtime: { + sleep: async () => {}, + terminate: (_pid, signal) => signals.push(signal), + }, + }); + assert.equal(cancelled.status, "cancelled"); + assert.deepEqual(signals, []); + } + + { + const run = createRunningRun(store, root, "already-done", 303); + store.completeRun(run.id, { callCount: 0 }); + const signals: NodeJS.Signals[] = []; + const completed = await cancelWorkflowRun(store, run.id, { + runtime: { + sleep: async () => {}, + terminate: (_pid, signal) => signals.push(signal), + }, + }); + assert.equal(completed.status, "completed"); + assert.deepEqual(signals, []); + } +} finally { + store.close(); + rmSync(root, { recursive: true, force: true }); +} + +function createRunningRun( + workflowStore: WorkflowStore, + workspaceRoot: string, + name: string, + pid: number, +) { + const run = workflowStore.createRun({ + name, + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot, + }); + return workflowStore.claimRun(run.id, pid)!; +} + +console.log("workflow-lifecycle.test.ts: ok"); diff --git a/src/workflow-lifecycle.ts b/src/workflow-lifecycle.ts new file mode 100644 index 00000000..f66ac7a5 --- /dev/null +++ b/src/workflow-lifecycle.ts @@ -0,0 +1,169 @@ +import type { ServerConfig } from "./config.js"; +import { terminateProcessTree } from "./process-platform.js"; +import { + createWorkflowStore, + type WorkflowStore, +} from "./workflow-store.js"; +import { + WORKFLOW_CANCEL_HARD_MS, + WORKFLOW_HEARTBEAT_MS, + type WorkflowRunRecord, +} from "./workflow-types.js"; + +const DEFAULT_TERM_WAIT_MS = 1_000; +const DEFAULT_POLL_MS = 100; +const DEFAULT_REAPER_INTERVAL_MS = WORKFLOW_HEARTBEAT_MS * 2; +const DEFAULT_STALE_AFTER_MS = WORKFLOW_HEARTBEAT_MS * 3; + +const ACTIVE_STATUSES = new Set(["starting", "running"]); + +export interface WorkflowLifecycleRuntime { + sleep(ms: number): Promise; + terminate(pid: number, signal: NodeJS.Signals): void; +} + +const defaultRuntime: WorkflowLifecycleRuntime = { + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + terminate: (pid, signal) => { + terminateProcessTree( + { + pid, + kill: (requestedSignal = signal) => { + try { + process.kill(pid, requestedSignal); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; + throw error; + } + }, + }, + signal, + true, + ); + }, +}; + +export interface CancelWorkflowRunOptions { + graceMs?: number; + termWaitMs?: number; + pollMs?: number; + runtime?: WorkflowLifecycleRuntime; +} + +/** + * Shared CLI/MCP cancellation path: cooperative flag, grace period, process + * tree termination, then an atomic terminal fallback in the journal. + */ +export async function cancelWorkflowRun( + store: WorkflowStore, + runId: string, + options: CancelWorkflowRunOptions = {}, +): Promise { + const requested = store.requestCancelResult(runId); + if (requested.isErr()) throw requested.error; + if (!isActive(requested.value)) return requested.value; + + const runtime = options.runtime ?? defaultRuntime; + const graceMs = Math.max(0, options.graceMs ?? WORKFLOW_CANCEL_HARD_MS); + const termWaitMs = Math.max(0, options.termWaitMs ?? DEFAULT_TERM_WAIT_MS); + const pollMs = Math.max(1, options.pollMs ?? DEFAULT_POLL_MS); + + const cooperative = await waitForTerminal(store, runId, graceMs, pollMs, runtime); + if (cooperative && !isActive(cooperative)) return cooperative; + + let current = store.getRun(runId); + if (!current) throw new Error(`Unknown workflow run: ${runId}`); + if (!isActive(current)) return current; + + if (current.pid) { + safelyTerminate(runtime, current.pid, "SIGTERM"); + const afterTerm = await waitForTerminal(store, runId, termWaitMs, pollMs, runtime); + if (afterTerm && !isActive(afterTerm)) return afterTerm; + + current = store.getRun(runId) ?? current; + if (isActive(current) && current.pid) { + safelyTerminate(runtime, current.pid, "SIGKILL"); + } + } + + const cancelled = store.cancelRunResult(runId, "cancelled by workflow supervisor"); + if (cancelled.isErr()) throw cancelled.error; + return cancelled.value; +} + +export function reapStaleWorkflows( + store: WorkflowStore, + staleAfterMs = DEFAULT_STALE_AFTER_MS, +): WorkflowRunRecord[] { + return store.reapStale(staleAfterMs); +} + +export interface WorkflowReaperHandle { + close(): void; +} + +export function startWorkflowReaper( + config: ServerConfig, + options: { + intervalMs?: number; + staleAfterMs?: number; + onError?: (error: unknown) => void; + } = {}, +): WorkflowReaperHandle { + const intervalMs = Math.max(1, options.intervalMs ?? DEFAULT_REAPER_INTERVAL_MS); + const staleAfterMs = Math.max(1, options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS); + + const tick = (): void => { + let store: WorkflowStore | undefined; + try { + store = createWorkflowStore(config); + reapStaleWorkflows(store, staleAfterMs); + } catch (error) { + options.onError?.(error); + } finally { + store?.close(); + } + }; + + tick(); + const timer = setInterval(tick, intervalMs); + timer.unref(); + return { + close(): void { + clearInterval(timer); + }, + }; +} + +async function waitForTerminal( + store: WorkflowStore, + runId: string, + waitMs: number, + pollMs: number, + runtime: WorkflowLifecycleRuntime, +): Promise { + const deadline = Date.now() + waitMs; + let current = store.getRun(runId); + while (current && isActive(current) && Date.now() < deadline) { + await runtime.sleep(Math.min(pollMs, Math.max(1, deadline - Date.now()))); + current = store.getRun(runId); + } + return current; +} + +function safelyTerminate( + runtime: WorkflowLifecycleRuntime, + pid: number, + signal: NodeJS.Signals, +): void { + try { + runtime.terminate(pid, signal); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } +} + +function isActive(run: WorkflowRunRecord): boolean { + return ACTIVE_STATUSES.has(run.status); +} diff --git a/src/workflow-providers.ts b/src/workflow-providers.ts new file mode 100644 index 00000000..360049f7 --- /dev/null +++ b/src/workflow-providers.ts @@ -0,0 +1,19 @@ +import { getLocalAgentProviderAvailabilitySnapshot } from "./local-agent-availability.js"; +import { + LOCAL_AGENT_PROVIDERS, + type LocalAgentProvider, +} from "./local-agent-profiles.js"; +import type { ServerConfig } from "./config.js"; + +/** Live providers in stable product order for workflow agent() resolution. */ +export function resolveWorkflowLiveProviders( + config: Pick, +): LocalAgentProvider[] { + if (!config.subagents.enabled) return []; + const enabled = config.subagents.providers + .filter((provider) => provider.enabled) + .map((provider) => provider.id); + const snapshot = getLocalAgentProviderAvailabilitySnapshot(process.env); + const live = new Set(snapshot.filter((row) => row.available).map((row) => row.name)); + return LOCAL_AGENT_PROVIDERS.filter((id) => enabled.includes(id) && live.has(id)); +} diff --git a/src/workflow-replay.test.ts b/src/workflow-replay.test.ts new file mode 100644 index 00000000..7fe518eb --- /dev/null +++ b/src/workflow-replay.test.ts @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import { createWorkflowReplay } from "./workflow-replay.js"; +import type { WorkflowAgentCallRecord } from "./workflow-types.js"; + +function call( + partial: Partial & + Pick, +): WorkflowAgentCallRecord { + return { + runId: "wfr_prior", + prompt: "prompt", + provider: "codex", + status: "completed", + fromCache: false, + isolation: "shared", + createdAt: "t", + updatedAt: "t", + returnValueJson: JSON.stringify(`result-${partial.callIndex}`), + ...partial, + }; +} + +function identity( + prompt = "prompt", + profile: { name: string | null; fingerprint: string | null } = { + name: null, + fingerprint: null, + }, +) { + return { + prompt, + profileName: profile.name, + profileFingerprint: profile.fingerprint, + provider: "codex" as const, + model: null, + effort: null, + schema: null, + isolation: "shared" as const, + }; +} + +{ + const replay = createWorkflowReplay([ + call({ + callIndex: 0, + cacheKey: "profile", + profileName: "reviewer", + profileFingerprint: "fp-1", + }), + ]); + const miss = replay.decide( + 0, + "profile-name-changed", + identity("prompt", { name: "implementer", fingerprint: "fp-1" }), + ).miss; + assert.equal(miss?.reason, "identity_changed"); + assert.deepEqual(miss?.changedFields, ["profileName"]); +} + +{ + const replay = createWorkflowReplay([ + call({ + callIndex: 0, + cacheKey: "profile", + profileName: "reviewer", + profileFingerprint: "fp-1", + }), + ]); + const miss = replay.decide( + 0, + "profile-fingerprint-changed", + identity("prompt", { name: "reviewer", fingerprint: "fp-2" }), + ).miss; + assert.equal(miss?.reason, "identity_changed"); + assert.deepEqual(miss?.changedFields, ["profileFingerprint"]); +} + +{ + const replay = createWorkflowReplay([ + call({ callIndex: 0, cacheKey: "k0", returnValueJson: JSON.stringify("a") }), + call({ callIndex: 1, cacheKey: "k1", returnValueJson: JSON.stringify("b") }), + ]); + assert.equal(replay.decide(0, "k0", identity()).hit?.value, "a"); + assert.equal(replay.decide(1, "k1", identity()).hit?.value, "b"); + assert.equal(replay.decide(2, "k2", identity()).miss?.reason, "no_compatible_call"); +} + +{ + const replay = createWorkflowReplay([ + call({ callIndex: 0, cacheKey: "ka", returnValueJson: JSON.stringify("A") }), + call({ callIndex: 1, cacheKey: "kb", returnValueJson: JSON.stringify("B") }), + ]); + const changed = replay.decide(0, "kb", identity()).miss; + assert.equal(changed?.reason, "identity_changed"); + assert.equal(replay.decide(1, "kb", identity()).miss?.reason, "prefix_diverged"); +} + +{ + const replay = createWorkflowReplay([ + call({ + callIndex: 0, + cacheKey: "ks", + responseText: "bounded preview", + structuredJson: '{"ok":true}', + returnValueJson: '{"ok":true,"text":"exact"}', + }), + ]); + assert.deepEqual(replay.decide(0, "ks", identity()).hit?.value, { + ok: true, + text: "exact", + }); +} + +{ + const replay = createWorkflowReplay([ + call({ callIndex: 0, cacheKey: "old", prompt: "old prompt" }), + call({ callIndex: 1, cacheKey: "later" }), + ]); + const miss = replay.decide(0, "new", identity("new prompt")).miss; + assert.equal(miss?.reason, "identity_changed"); + assert.deepEqual(miss?.changedFields, ["prompt"]); + assert.equal(replay.decide(1, "later", identity()).miss?.reason, "prefix_diverged"); +} + +{ + const replay = createWorkflowReplay([ + call({ callIndex: 0, cacheKey: "worktree", isolation: "worktree" }), + call({ callIndex: 1, cacheKey: "later" }), + ]); + assert.equal( + replay.decide(0, "worktree", { ...identity(), isolation: "worktree" }).miss?.reason, + "worktree_not_restored", + ); + assert.equal(replay.decide(1, "later", identity()).miss?.reason, "prefix_diverged"); +} + +{ + const replay = createWorkflowReplay([ + call({ callIndex: 0, cacheKey: "legacy", returnValueJson: undefined }), + ]); + assert.equal(replay.decide(0, "legacy", identity()).miss?.reason, "result_not_persisted"); +} + +{ + const replay = createWorkflowReplay([ + call({ callIndex: 0, cacheKey: "corrupt", returnValueJson: "{" }), + ]); + assert.equal(replay.decide(0, "corrupt", identity()).miss?.reason, "stored_result_invalid"); +} + +console.log("workflow-replay.test.ts: ok"); diff --git a/src/workflow-replay.ts b/src/workflow-replay.ts new file mode 100644 index 00000000..ffbc8eb9 --- /dev/null +++ b/src/workflow-replay.ts @@ -0,0 +1,110 @@ +import type { AgentCacheKeyInput, WorkflowAgentCallRecord } from "./workflow-types.js"; +import type { + WorkflowReplay, + WorkflowReplayDecision, + WorkflowReplayHit, +} from "./workflow-api.js"; +import { parseJsonText } from "./json-types.js"; + +/** + * Deterministic prefix replay inspired by Claude Code dynamic workflows. + * Calls are reused only while the new execution matches the prior execution at + * the same call index. The first mismatch closes replay for the remainder of + * the run, even when a later cache key happens to match. + */ +export function createWorkflowReplay( + priorCalls: WorkflowAgentCallRecord[], +): WorkflowReplay { + const byIndex = new Map(priorCalls.map((call) => [call.callIndex, call])); + let prefixOpen = true; + + return { + decide( + callIndex: number, + cacheKey: string, + input: AgentCacheKeyInput, + ): WorkflowReplayDecision { + if (!prefixOpen) return { miss: { reason: "prefix_diverged" } }; + + const prior = byIndex.get(callIndex); + if (!prior) return close({ miss: { reason: "no_compatible_call" } }); + if (prior.status !== "completed" && prior.status !== "from_cache") { + return close({ miss: { reason: "prior_call_not_replayable" } }); + } + if (prior.isolation === "worktree") { + return close({ miss: { reason: "worktree_not_restored" } }); + } + if (prior.cacheKey !== cacheKey) { + return close({ + miss: { + reason: "identity_changed", + changedFields: changedIdentityFields(prior, input), + }, + }); + } + if (!prior.returnValueJson) { + return close({ miss: { reason: "result_not_persisted" } }); + } + + try { + return { + hit: toHit(prior, parseJsonText(prior.returnValueJson)), + }; + } catch { + return close({ miss: { reason: "stored_result_invalid" } }); + } + }, + }; + + function close(decision: WorkflowReplayDecision): WorkflowReplayDecision { + prefixOpen = false; + return decision; + } +} + +function toHit( + call: WorkflowAgentCallRecord, + value: WorkflowReplayHit["value"], +): WorkflowReplayHit { + return { + value, + responseText: call.responseText, + structuredJson: call.structuredJson, + returnValueJson: call.returnValueJson!, + providerSessionId: call.providerSessionId, + replayMatch: "same_index", + replayedFromRunId: call.runId, + replayedFromCallIndex: call.callIndex, + }; +} + +function changedIdentityFields( + prior: WorkflowAgentCallRecord, + current: AgentCacheKeyInput, +): Array { + const changed: Array = []; + if (prior.prompt !== current.prompt) changed.push("prompt"); + if ((prior.profileName ?? null) !== current.profileName) changed.push("profileName"); + if ((prior.profileFingerprint ?? null) !== current.profileFingerprint) { + changed.push("profileFingerprint"); + } + if (prior.provider !== current.provider) changed.push("provider"); + if ((prior.model ?? null) !== current.model) changed.push("model"); + if ((prior.effort ?? null) !== current.effort) changed.push("effort"); + if (!schemasMatch(prior.schemaJson, current.schema)) changed.push("schema"); + if (prior.isolation !== current.isolation) changed.push("isolation"); + return changed.length > 0 ? changed : ["prompt"]; +} + +function schemasMatch( + priorSchemaJson: string | undefined, + currentSchema: AgentCacheKeyInput["schema"], +): boolean { + try { + const prior = priorSchemaJson ? JSON.stringify(parseJsonText(priorSchemaJson)) : null; + const current = currentSchema === null ? null : JSON.stringify(currentSchema); + return prior === current; + } catch { + return false; + } +} diff --git a/src/workflow-sandbox-child.ts b/src/workflow-sandbox-child.ts new file mode 100644 index 00000000..ffe90e2b --- /dev/null +++ b/src/workflow-sandbox-child.ts @@ -0,0 +1,329 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import vm from "node:vm"; +import { parseWorkflowScript } from "./workflow-script.js"; +import type { JsonValue } from "./json-types.js"; +import { WORKFLOW_MAX_ITEMS } from "./workflow-types.js"; + +/** Phase context for concurrent script chains (must live in the child process). */ +const phaseAls = new AsyncLocalStorage(); + +type SandboxMethod = "agent" | "workflow" | "phase" | "log"; + +interface StartMessage { + type: "start"; + source: string; + filename: string; + args: JsonValue | undefined; + budget: { + total: number | null; + spent: number; + remaining: number; + }; +} + +interface CallResultMessage { + type: "call_result"; + id: number; + value?: unknown; + error?: SerializedError; +} + +interface SerializedError { + name: string; + message: string; + stack?: string; + kind?: string; +} + +interface BridgeSuccessEnvelope { + ok: true; + value?: unknown; +} + +interface BridgeErrorEnvelope { + ok: false; + error: SerializedError; +} + +type BridgeEnvelope = BridgeSuccessEnvelope | BridgeErrorEnvelope; + +let nextCallId = 1; +const pending = new Map< + number, + { resolve(value: string): void } +>(); + +process.on("message", (message: StartMessage | CallResultMessage) => { + if (message.type === "call_result") { + const waiter = pending.get(message.id); + if (!waiter) return; + pending.delete(message.id); + const envelope: BridgeEnvelope = message.error + ? { ok: false, error: message.error } + : { ok: true, value: message.value }; + waiter.resolve(JSON.stringify(envelope)); + return; + } + void execute(message); +}); + +async function execute(message: StartMessage): Promise { + try { + const parsed = parseWorkflowScript(message.source, { filename: message.filename }); + const bridge = (method: SandboxMethod, args: unknown[]): unknown => { + if (method === "phase" || method === "log") { + process.send?.({ type: "notify", method, args }); + return undefined; + } + const id = nextCallId; + nextCallId += 1; + process.send?.({ type: "call", id, method, args }); + return new Promise((resolve) => { + pending.set(id, { resolve }); + }); + }; + + const context = vm.createContext({ + __workflowBridge: bridge, + __workflowPhaseAls: { + enterWith(title: string) { + phaseAls.enterWith(title); + }, + getStore() { + return phaseAls.getStore(); + }, + }, + }); + installContextApi(context, message); + const factory = parsed.script.runInContext(context, { + timeout: 5_000, + displayErrors: true, + }) as () => Promise; + if (typeof factory !== "function") { + throw new Error("Workflow script did not compile to a function"); + } + const value = await factory(); + process.send?.({ type: "result", value }, () => disconnect()); + } catch (error) { + process.send?.({ type: "error", error: serializeError(error) }, () => disconnect()); + } +} + +function installContextApi(context: vm.Context, message: StartMessage): void { + const bootstrap = `(() => { + const bridge = globalThis.__workflowBridge; + delete globalThis.__workflowBridge; + + class WorkflowDeterminismError extends Error { + constructor(message) { + super(message); + this.name = "WorkflowDeterminismError"; + } + } + + class WorkflowEngineError extends Error { + constructor(kind, message) { + super(message); + this.name = "WorkflowEngineError"; + this.kind = kind; + } + } + + Object.defineProperty(Math, "random", { + configurable: false, + writable: false, + value() { + throw new WorkflowDeterminismError("Math.random() is banned in workflow scripts"); + }, + }); + + const RealDate = Date; + function DateShim(...dateArgs) { + if (!new.target) { + throw new WorkflowDeterminismError("Date() is banned in workflow scripts"); + } + if (dateArgs.length === 0) { + throw new WorkflowDeterminismError( + "new Date() without arguments is banned in workflow scripts (pass an ISO string)", + ); + } + return Reflect.construct(RealDate, dateArgs, DateShim); + } + DateShim.now = () => { + throw new WorkflowDeterminismError("Date.now() is banned in workflow scripts"); + }; + DateShim.parse = RealDate.parse.bind(RealDate); + DateShim.UTC = RealDate.UTC.bind(RealDate); + DateShim.prototype = Object.create(RealDate.prototype, { + constructor: { + value: DateShim, + writable: false, + configurable: false, + }, + }); + Object.freeze(DateShim.prototype); + Object.freeze(DateShim); + + const rehydrateError = (input) => { + const error = input?.name === "WorkflowDeterminismError" + ? new WorkflowDeterminismError(input.message) + : input?.name === "WorkflowEngineError" && typeof input.kind === "string" + ? new WorkflowEngineError(input.kind, input.message) + : new Error(input?.message ?? "Workflow bridge call failed"); + if (typeof input?.name === "string") error.name = input.name; + if (typeof input?.stack === "string") error.stack = input.stack; + return error; + }; + const phaseAls = globalThis.__workflowPhaseAls; + delete globalThis.__workflowPhaseAls; + const call = (method, callArgs) => new Promise((resolve, reject) => { + bridge(method, callArgs).then( + (payloadJson) => { + let payload; + try { + payload = JSON.parse(payloadJson); + } catch { + reject(new WorkflowEngineError("internal", "Workflow bridge returned invalid JSON")); + return; + } + if (payload?.ok === true) resolve(payload.value); + else reject(rehydrateError(payload?.error)); + }, + () => reject(new WorkflowEngineError("internal", "Workflow bridge call failed")), + ); + }); + // Inject current ALS phase so host journal/agent rows stay correct even though + // host phase() only records events (host ALS is not on the script chain). + const agent = (prompt, opts = {}) => { + const inherited = typeof phaseAls?.getStore === "function" ? phaseAls.getStore() : undefined; + const nextOpts = + opts && typeof opts === "object" + ? { + ...opts, + phase: + typeof opts.phase === "string" && opts.phase.trim() + ? opts.phase + : inherited, + } + : inherited + ? { phase: inherited } + : opts; + return call("agent", [prompt, nextOpts]); + }; + const workflow = (...callArgs) => call("workflow", callArgs); + const phase = (title) => { + if (typeof title !== "string" || !title.trim()) { + throw new WorkflowEngineError("internal", "phase(title) requires a non-empty string"); + } + phaseAls.enterWith(title); + return bridge("phase", [title]); + }; + const emitLog = (...callArgs) => { + const message = callArgs.map(stringifyConsoleArg).join(" "); + const inherited = typeof phaseAls?.getStore === "function" ? phaseAls.getStore() : undefined; + return bridge("log", [{ message, phase: inherited }]); + }; + const parallel = async (tasks) => { + if (!Array.isArray(tasks)) { + throw new WorkflowEngineError("internal", "parallel(thunks) requires an array of functions"); + } + if (tasks.length > ${WORKFLOW_MAX_ITEMS}) { + throw new WorkflowEngineError( + "internal", + "parallel exceeds max items ${WORKFLOW_MAX_ITEMS} (got " + tasks.length + ")", + ); + } + return Promise.all(tasks.map(async (task, index) => { + if (typeof task !== "function") { + throw new WorkflowEngineError( + "internal", + "parallel thunks[" + index + "] must be a function", + ); + } + try { return await task(); } catch { return null; } + })); + }; + const pipeline = async (items, ...stages) => { + if (!Array.isArray(items)) { + throw new WorkflowEngineError( + "internal", + "pipeline(items, ...stages) requires an items array", + ); + } + if (items.length > ${WORKFLOW_MAX_ITEMS}) { + throw new WorkflowEngineError( + "internal", + "pipeline exceeds max items ${WORKFLOW_MAX_ITEMS} (got " + items.length + ")", + ); + } + for (let index = 0; index < stages.length; index += 1) { + if (typeof stages[index] !== "function") { + throw new WorkflowEngineError( + "internal", + "pipeline stage[" + index + "] must be a function", + ); + } + } + return Promise.all(items.map(async (item, index) => { + let value = item; + for (const stage of stages) { + try { value = await stage(value, item, index); } catch { return null; } + } + return value; + })); + }; + const args = JSON.parse(${JSON.stringify(JSON.stringify(message.args ?? null))}); + const budget = Object.freeze({ + total: ${JSON.stringify(message.budget.total)}, + spent: () => ${JSON.stringify(message.budget.spent)}, + remaining: () => ${String(message.budget.remaining)}, + }); + const stringifyConsoleArg = (value) => { + if (typeof value === "string") return value; + try { return JSON.stringify(value); } catch { return String(value); } + }; + const console = Object.freeze({ + log: emitLog, + warn: emitLog, + error: emitLog, + info: emitLog, + debug: emitLog, + }); + + Object.defineProperties(globalThis, { + agent: { value: Object.freeze(agent), writable: false, configurable: false }, + workflow: { value: Object.freeze(workflow), writable: false, configurable: false }, + phase: { value: Object.freeze(phase), writable: false, configurable: false }, + log: { value: Object.freeze(emitLog), writable: false, configurable: false }, + parallel: { value: Object.freeze(parallel), writable: false, configurable: false }, + pipeline: { value: Object.freeze(pipeline), writable: false, configurable: false }, + args: { value: Object.freeze(args), writable: false, configurable: false }, + budget: { value: budget, writable: false, configurable: false }, + console: { value: console, writable: false, configurable: false }, + Date: { value: DateShim, writable: false, configurable: false }, + }); + })()`; + vm.runInContext(bootstrap, context, { timeout: 5_000 }); +} + +function serializeError(error: unknown): SerializedError { + if (!error || typeof error !== "object") { + return { name: "Error", message: String(error) }; + } + const record = error as { + name?: unknown; + message?: unknown; + stack?: unknown; + kind?: unknown; + }; + return { + name: typeof record.name === "string" ? record.name : "Error", + message: typeof record.message === "string" ? record.message : String(error), + stack: typeof record.stack === "string" ? record.stack : undefined, + kind: typeof record.kind === "string" ? record.kind : undefined, + }; +} + +function disconnect(): void { + if (process.connected) process.disconnect?.(); +} diff --git a/src/workflow-sandbox.test.ts b/src/workflow-sandbox.test.ts new file mode 100644 index 00000000..e33dd618 --- /dev/null +++ b/src/workflow-sandbox.test.ts @@ -0,0 +1,285 @@ +import assert from "node:assert/strict"; +import { parseWorkflowScript } from "./workflow-script.js"; +import { WorkflowEngineError } from "./workflow-api.js"; +import { + createStubBudget, + type WorkflowMeta, +} from "./workflow-types.js"; +import type { WorkflowSandboxApi } from "./workflow-sandbox.js"; +import { runWorkflowSandbox, WorkflowDeterminismError } from "./workflow-sandbox.js"; + +function api( + meta: WorkflowMeta, + logs?: string[], + hooks?: { + agent?: WorkflowSandboxApi["agent"]; + phaseTitles?: string[]; + }, +): WorkflowSandboxApi { + return { + agent: hooks?.agent ?? (async () => ""), + parallel: async () => [], + pipeline: async () => [], + phase: (title: string) => { + hooks?.phaseTitles?.push(title); + }, + log: (msg: unknown) => { + if (msg && typeof msg === "object" && !Array.isArray(msg) && "message" in msg) { + logs?.push(String((msg as { message: unknown }).message)); + return; + } + logs?.push(String(msg)); + }, + args: undefined as unknown, + budget: createStubBudget(), + workflow: async () => null, + meta, + } as unknown as WorkflowSandboxApi; +} + +{ + const logs: string[] = []; + const parsed = parseWorkflowScript(` +export const meta = { name: 'console-test', description: 'd' } +console.log('a', { b: 1 }) +console.warn('w') +return 'ok' +`); + const result = await runWorkflowSandbox({ parsed, api: api(parsed.meta, logs) }); + assert.equal(result, "ok"); + assert.equal(logs[0], 'a {"b":1}'); + assert.equal(logs[1], "w"); +} + +{ + const parsed = parseWorkflowScript(` +export const meta = { name: 'math-abs-ok', description: 'd' } +return Math.abs(-3) +`); + const abs = await runWorkflowSandbox({ parsed, api: api(parsed.meta) }); + assert.equal(abs, 3); +} + +{ + await assert.rejects( + () => + runWorkflowSandbox({ + parsed: parseWorkflowScript(` +export const meta = { name: 'fetch-ban', description: 'd' } +return fetch('https://example.com') +`), + api: api({ name: "fetch-ban", description: "d" }), + }), + /fetch is not defined|ReferenceError/, + ); +} + +{ + const parsed = parseWorkflowScript(` +export const meta = { name: 'budget', description: 'd' } +return { total: budget.total, spent: budget.spent(), remaining: budget.remaining() } +`); + const budgetResult = await runWorkflowSandbox({ parsed, api: api(parsed.meta) }); + assert.deepEqual(budgetResult, { total: null, spent: 0, remaining: Infinity }); +} + +{ + await assert.rejects( + () => + runWorkflowSandbox({ + parsed: parseWorkflowScript(` +export const meta = { name: 'rnd', description: 'd' } +return Math.random() +`), + api: api({ name: "rnd", description: "d" }), + }), + (error: unknown) => + error instanceof WorkflowDeterminismError && /Math\.random/.test(error.message), + ); +} + +{ + const started = Date.now(); + await assert.rejects( + () => + runWorkflowSandbox({ + parsed: parseWorkflowScript(` +export const meta = { name: 'sync-loop', description: 'd' } +while (true) {} +`), + api: api({ name: "sync-loop", description: "d" }), + timeoutMs: 100, + }), + /exceeded host timeout/, + ); + assert.ok(Date.now() - started < 5_000, "synchronous loop should be externally terminated"); + + const followup = parseWorkflowScript(` +export const meta = { name: 'after-loop', description: 'd' } +return 'still-alive' +`); + assert.equal( + await runWorkflowSandbox({ parsed: followup, api: api(followup.meta) }), + "still-alive", + ); +} + +{ + const controller = new AbortController(); + const running = runWorkflowSandbox({ + parsed: parseWorkflowScript(` +export const meta = { name: 'abort-loop', description: 'd' } +while (true) {} +`), + api: api({ name: "abort-loop", description: "d" }), + signal: controller.signal, + }); + setTimeout(() => controller.abort(), 50); + await assert.rejects( + () => running, + (error: unknown) => + error instanceof WorkflowEngineError && error.kind === "cancelled", + ); +} + +{ + await assert.rejects( + () => + runWorkflowSandbox({ + parsed: parseWorkflowScript(` +export const meta = { name: 'date-constructor-ban', description: 'd' } +return new Date(0).constructor.now() +`), + api: api({ name: "date-constructor-ban", description: "d" }), + }), + (error: unknown) => + error instanceof WorkflowDeterminismError && /Date\.now/.test(error.message), + ); +} + +{ + await assert.rejects( + () => + runWorkflowSandbox({ + parsed: parseWorkflowScript(` +export const meta = { name: 'constructor-escape', description: 'd' } +return Object.constructor('return process.version')() +`), + api: api({ name: "constructor-escape", description: "d" }), + }), + /process is not defined/, + ); +} + +{ + await assert.rejects( + () => + runWorkflowSandbox({ + parsed: parseWorkflowScript(` +export const meta = { name: 'promise-realm-escape', description: 'd' } +const pending = agent('x') +return pending.constructor.constructor('return process')() +`), + api: api({ name: "promise-realm-escape", description: "d" }), + }), + /process is not defined/, + ); +} + +{ + const hostApi = api({ name: "result-realm-escape", description: "d" }); + hostApi.agent = async () => ({ ok: true }) as never; + await assert.rejects( + () => + runWorkflowSandbox({ + parsed: parseWorkflowScript(` +export const meta = { name: 'result-realm-escape', description: 'd' } +const value = await agent('x') +return value.constructor.constructor('return process')() +`), + api: hostApi, + }), + /process is not defined/, + ); +} + +{ + const hostApi = api({ name: "error-realm-escape", description: "d" }); + hostApi.agent = async () => { + throw new WorkflowEngineError("internal", "boom"); + }; + await assert.rejects( + () => + runWorkflowSandbox({ + parsed: parseWorkflowScript(` +export const meta = { name: 'error-realm-escape', description: 'd' } +try { + await agent('x') +} catch (error) { + return error.constructor.constructor('return process')() +} +`), + api: hostApi, + }), + /process is not defined/, + ); +} + +{ + await assert.rejects( + () => + runWorkflowSandbox({ + parsed: parseWorkflowScript(` +export const meta = { name: 'api-constructor-escape', description: 'd' } +return agent.constructor('return process.version')() +`), + api: api({ name: "api-constructor-escape", description: "d" }), + }), + /process is not defined/, + ); +} + +// Child-owned phase ALS: concurrent chains inject distinct opts.phase over IPC. +{ + const seen: Array<{ prompt: string; phase?: string }> = []; + const phaseTitles: string[] = []; + const hostApi = api( + { name: "phase-ipc", description: "d" }, + undefined, + { + phaseTitles, + agent: async (prompt: string, opts?: { phase?: string }) => { + seen.push({ prompt, phase: opts?.phase }); + await new Promise((r) => setTimeout(r, 20)); + return `ok:${prompt}`; + }, + }, + ); + // Host parallel is unused; child implements parallel. Agent is bridged. + const result = await runWorkflowSandbox({ + parsed: parseWorkflowScript(` +export const meta = { name: 'phase-ipc', description: 'd' } +return await parallel([ + async () => { + phase('A') + log('in-a') + return await agent('from-a') + }, + async () => { + phase('B') + log('in-b') + return await agent('from-b') + }, +]) +`), + api: hostApi, + }); + assert.deepEqual(result, ["ok:from-a", "ok:from-b"]); + assert.deepEqual(new Set(phaseTitles), new Set(["A", "B"])); + const a = seen.find((row) => row.prompt === "from-a"); + const b = seen.find((row) => row.prompt === "from-b"); + assert.equal(a?.phase, "A"); + assert.equal(b?.phase, "B"); +} + +console.log("workflow-sandbox.test.ts: ok"); diff --git a/src/workflow-sandbox.ts b/src/workflow-sandbox.ts new file mode 100644 index 00000000..3395fbd1 --- /dev/null +++ b/src/workflow-sandbox.ts @@ -0,0 +1,273 @@ +import { fork, type ChildProcess } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { WorkflowEngineError } from "./workflow-api.js"; +import type { ParsedWorkflowScript } from "./workflow-script.js"; +import type { JsonValue } from "./json-types.js"; +import type { + WorkflowAgent, + WorkflowBudget, + WorkflowMeta, + WorkflowNested, + WorkflowParallel, + WorkflowPipeline, +} from "./workflow-types.js"; + +export class WorkflowDeterminismError extends Error { + constructor(message: string) { + super(message); + this.name = "WorkflowDeterminismError"; + } +} + +export interface WorkflowSandboxApi { + agent: WorkflowAgent; + parallel: WorkflowParallel; + pipeline: WorkflowPipeline; + phase: (title: string) => void; + log: (...args: unknown[]) => unknown; + args: JsonValue | undefined; + budget: WorkflowBudget; + workflow: WorkflowNested; + /** Host bookkeeping only; script binds its own `const meta`. */ + meta: WorkflowMeta; +} + +export interface RunWorkflowSandboxOptions { + parsed: ParsedWorkflowScript; + api: WorkflowSandboxApi; + /** Host wall-clock max for the whole script (ms). Default 6h. */ + timeoutMs?: number; + signal?: AbortSignal; +} + +type SandboxMethod = "agent" | "workflow" | "phase" | "log"; + +interface SandboxStartMessage { + type: "start"; + source: string; + filename: string; + args: JsonValue | undefined; + budget: { + total: number | null; + spent: number; + remaining: number; + }; +} + +interface SandboxCallMessage { + type: "call"; + id: number; + method: Extract; + args: unknown[]; +} + +interface SandboxNotifyMessage { + type: "notify"; + method: Extract; + args: unknown[]; +} + +interface SandboxResultMessage { + type: "result"; + value: unknown; +} + +interface SandboxErrorMessage { + type: "error"; + error: SerializedError; +} + +interface SandboxCallResultMessage { + type: "call_result"; + id: number; + value?: unknown; + error?: SerializedError; +} + +interface SerializedError { + name: string; + message: string; + stack?: string; + kind?: string; +} + +type MessageFromChild = + | SandboxCallMessage + | SandboxNotifyMessage + | SandboxResultMessage + | SandboxErrorMessage; + +/** + * Execute a workflow in a disposable child process. The child owns the vm + * context and can be terminated even when model-authored JavaScript blocks its + * event loop with synchronous code. + */ +export async function runWorkflowSandbox( + options: RunWorkflowSandboxOptions, +): Promise { + const timeoutMs = options.timeoutMs ?? 6 * 60 * 60 * 1000; + const child = spawnSandboxChild(); + + return new Promise((resolve, reject) => { + let settled = false; + + const finish = (outcome: { value: unknown } | { error: unknown }): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + options.signal?.removeEventListener("abort", onAbort); + child.removeAllListeners(); + if (child.connected) child.disconnect(); + if (!child.killed && child.exitCode === null) child.kill("SIGKILL"); + if ("error" in outcome) reject(outcome.error); + else resolve(outcome.value); + }; + + const terminate = (error: Error): void => { + finish({ error }); + }; + + const onAbort = (): void => { + terminate(new WorkflowEngineError("cancelled", "Workflow cancelled")); + }; + + const timer = setTimeout(() => { + terminate(new Error(`Workflow script exceeded host timeout (${timeoutMs}ms)`)); + }, timeoutMs); + timer.unref?.(); + + if (options.signal?.aborted) { + onAbort(); + return; + } + options.signal?.addEventListener("abort", onAbort, { once: true }); + + child.on("message", (message: MessageFromChild) => { + void handleChildMessage(child, options.api, message, finish); + }); + child.once("error", (error) => finish({ error })); + child.once("exit", (code, signal) => { + if (settled) return; + finish({ + error: new Error( + `Workflow sandbox exited before returning a result (code=${String(code)}, signal=${String(signal)})`, + ), + }); + }); + + const start: SandboxStartMessage = { + type: "start", + source: options.parsed.source, + filename: options.parsed.filename, + args: options.api.args, + budget: { + total: options.api.budget.total, + spent: options.api.budget.spent(), + remaining: options.api.budget.remaining(), + }, + }; + safeSend(child, start); + }); +} + +async function handleChildMessage( + child: ChildProcess, + api: WorkflowSandboxApi, + message: MessageFromChild, + finish: (outcome: { value: unknown } | { error: unknown }) => void, +): Promise { + switch (message.type) { + case "result": + finish({ value: message.value }); + return; + case "error": + finish({ error: deserializeError(message.error) }); + return; + case "notify": + try { + if (message.method === "phase") { + api.phase(message.args[0] as string); + } else { + api.log(...message.args); + } + } catch (error) { + if (!child.killed) child.kill("SIGKILL"); + finish({ error }); + } + return; + case "call": { + const reply: SandboxCallResultMessage = { + type: "call_result", + id: message.id, + }; + try { + reply.value = message.method === "agent" + ? await api.agent(message.args[0] as string, message.args[1] as never) + : await api.workflow(message.args[0] as never, message.args[1] as never); + } catch (error) { + reply.error = serializeError(error); + } + safeSend(child, reply); + return; + } + } +} + +function spawnSandboxChild(): ChildProcess { + const selfUrl = import.meta.url; + const childUrl = selfUrl.replace( + /workflow-sandbox\.(ts|js)$/, + "workflow-sandbox-child.$1", + ); + if (childUrl === selfUrl) { + throw new Error(`Unable to resolve workflow sandbox child entry from ${selfUrl}`); + } + const childEntry = fileURLToPath(childUrl); + return fork(childEntry, [], { + execArgv: process.execArgv, + stdio: ["ignore", "ignore", "ignore", "ipc"], + serialization: "advanced", + env: { + NODE_ENV: process.env.NODE_ENV ?? "production", + }, + }); +} + +function safeSend(child: ChildProcess, message: object): void { + if (!child.connected) return; + try { + child.send(message, () => { + // The sandbox may close while an agent call is completing. + }); + } catch { + // The child is already being torn down. + } +} + +function serializeError(error: unknown): SerializedError { + if (!(error instanceof Error)) { + return { name: "Error", message: String(error) }; + } + const kind = "kind" in error && typeof error.kind === "string" ? error.kind : undefined; + return { + name: error.name, + message: error.message, + stack: error.stack, + kind, + }; +} + +function deserializeError(input: SerializedError): Error { + const error = input.name === "WorkflowDeterminismError" + ? new WorkflowDeterminismError(input.message) + : input.name === "WorkflowEngineError" && input.kind + ? new WorkflowEngineError( + input.kind as ConstructorParameters[0], + input.message, + ) + : new Error(input.message); + error.name = input.name; + if (input.stack) error.stack = input.stack; + if (input.kind) Object.assign(error, { kind: input.kind }); + return error; +} diff --git a/src/workflow-schema.test.ts b/src/workflow-schema.test.ts new file mode 100644 index 00000000..cfff73f1 --- /dev/null +++ b/src/workflow-schema.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { AgentProviderExecutionError } from "./local-agent-errors.js"; +import { WorkflowEngineError } from "./workflow-api.js"; +import { supportsNativeStructuredOutput } from "./local-agent-capabilities.js"; +import { + augmentPromptForSchema, + enforceAgentSchema, + formatAjvErrors, +} from "./workflow-schema.js"; + +const schema = { + type: "object", + properties: { n: { type: "number" } }, + required: ["n"], + additionalProperties: false, +} as const; + +const prompt = augmentPromptForSchema("find bugs", schema); +assert.match(prompt, /ONLY a JSON/); +assert.match(prompt, /"n"/); +assert.equal( + formatAjvErrors([{ instancePath: "/n", message: "must be number" }]), + "/n must be number", +); + +assert.equal(supportsNativeStructuredOutput("codex"), false); +assert.equal(supportsNativeStructuredOutput("claude"), false); +assert.equal(supportsNativeStructuredOutput("grok"), false); + +{ + const seen: Array<{ prompt: string; session?: string }> = []; + const result = await enforceAgentSchema({ + schema, + prompt: "give n", + provider: "codex", + run: async (attemptPrompt, options) => { + seen.push({ prompt: attemptPrompt, session: options.providerSessionId }); + if (seen.length === 1) { + return { finalResponse: '{"n":"bad"}', providerSessionId: "session_1" }; + } + return { finalResponse: '{"n":2}', providerSessionId: "session_1" }; + }, + }); + assert.deepEqual(result.value, { n: 2 }); + assert.equal(result.mode, "prompt"); + assert.equal(result.attempts, 2); + assert.equal(seen[1]?.session, "session_1"); + assert.ok(seen.every((attempt) => attempt.prompt.includes("ONLY a JSON"))); +} + +await assert.rejects( + () => enforceAgentSchema({ + schema, + prompt: "give n", + provider: "opencode", + maxRetries: 0, + run: async () => ({ finalResponse: "not json" }), + }), + (error: unknown) => error instanceof WorkflowEngineError && error.kind === "schema", +); + +await assert.rejects( + () => enforceAgentSchema({ + schema, + prompt: "give n", + provider: "codex", + run: async () => { + throw new Error("authentication failed"); + }, + }), + (error: unknown) => AgentProviderExecutionError.is(error), +); + +console.log("workflow-schema.test.ts: ok"); diff --git a/src/workflow-schema.ts b/src/workflow-schema.ts new file mode 100644 index 00000000..d2e5f064 --- /dev/null +++ b/src/workflow-schema.ts @@ -0,0 +1,253 @@ +import { createRequire } from "node:module"; +import { Result, type Result as BetterResult } from "better-result"; +import { WORKFLOW_MAX_SCHEMA_RETRIES } from "./workflow-types.js"; +import { tryExtractJson, WorkflowEngineError } from "./workflow-api.js"; +import type { WorkflowProviderRunResult } from "./workflow-api.js"; +import { + providerErrorFromCause, + type AgentProviderError, +} from "./local-agent-errors.js"; +import { supportsNativeStructuredOutput } from "./local-agent-capabilities.js"; +import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import { + jsonValueSchema, + type JsonSchema, + type JsonValue, +} from "./json-types.js"; +import { + AgentSchemaValidationError, + InvalidAgentJsonError, + SchemaConfigurationError, + SchemaRetriesExhaustedError, + type SchemaAttemptError, +} from "./workflow-errors.js"; + +const require = createRequire(import.meta.url); + +type AjvLike = new (opts?: object) => { + compile: (schema: JsonSchema) => ((data: unknown) => boolean) & { + errors?: Array<{ instancePath?: string; message?: string }> | null; + }; +}; + +function loadAjv(): AjvLike { + // Prefer direct package; fall back to transitive install under zod or package-lock. + try { + return require("ajv").default ?? require("ajv"); + } catch { + throw new WorkflowEngineError( + "schema", + "ajv is required for opts.schema (add dependency ajv)", + ); + } +} + +export type SchemaEnforceMode = "native" | "prompt"; + +export interface EnforceSchemaInput { + schema: JsonSchema; + prompt: string; + /** + * Provider id for native-vs-prompt policy. When in NATIVE_SCHEMA_PROVIDERS, + * attempt 0 uses raw prompt + native structured path; later attempts repair via prompt. + */ + provider: LocalAgentProvider; + run: ( + prompt: string, + opts: { + mode: SchemaEnforceMode; + providerSessionId?: string; + }, + ) => Promise; + onRetry?: (info: { + attempt: number; + errors: string; + mode: SchemaEnforceMode; + }) => void; + maxRetries?: number; +} + +export interface EnforceSchemaResult { + value: JsonValue; + finalResponse: string; + providerSessionId?: string; + attempts: number; + mode: SchemaEnforceMode; +} + +export type EnforceSchemaError = + | AgentProviderError + | SchemaConfigurationError + | SchemaRetriesExhaustedError; + +/** + * Native-first for codex/claude; otherwise prompt+extract+Ajv. Always Ajv-validate. + * Retries ≤ WORKFLOW_MAX_SCHEMA_RETRIES after the first attempt. + */ +export async function enforceAgentSchema( + input: EnforceSchemaInput, +): Promise { + const result = await enforceAgentSchemaResult(input); + if (result.isOk()) return result.value; + if ( + SchemaConfigurationError.is(result.error) || + SchemaRetriesExhaustedError.is(result.error) + ) { + throw new WorkflowEngineError("schema", result.error.message); + } + throw result.error; +} + +export async function enforceAgentSchemaResult( + input: EnforceSchemaInput, +): Promise> { + const compiled = Result.try({ + try: () => { + const Ajv = loadAjv(); + const ajv = new Ajv({ allErrors: true, strict: false }); + return ajv.compile(input.schema); + }, + catch: (cause) => new SchemaConfigurationError(cause), + }); + if (compiled.isErr()) return Result.err(compiled.error); + const validate = compiled.value; + const maxRetries = input.maxRetries ?? WORKFLOW_MAX_SCHEMA_RETRIES; + const native = supportsNativeStructuredOutput(input.provider); + const basePrompt = augmentPromptForSchema(input.prompt, input.schema); + + let lastFailure: SchemaAttemptError | undefined; + let providerSessionId: string | undefined; + + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + const mode: SchemaEnforceMode = native && attempt === 0 ? "native" : "prompt"; + + const prompt = + mode === "native" + ? input.prompt + : attempt === 0 + ? basePrompt + : `${basePrompt}\n\nPrevious JSON failed validation:\n${lastFailure?.message ?? "unknown validation error"}\nReturn only corrected JSON.`; + + const runResult = await Result.tryPromise({ + try: () => input.run(prompt, { mode, providerSessionId }), + catch: (cause) => classifyWorkflowProviderError(input.provider, cause), + }); + if (runResult.isErr()) { + return Result.err(runResult.error); + } + const result = runResult.value; + providerSessionId = result.providerSessionId ?? providerSessionId; + + const candidates = structuredCandidates(result); + if (candidates.length === 0) { + lastFailure = new InvalidAgentJsonError({ + attempt: attempt + 1, + mode, + responseExcerpt: result.finalResponse.slice(0, 500), + }); + if (attempt < maxRetries) { + input.onRetry?.({ attempt: attempt + 1, errors: lastFailure.message, mode }); + } + continue; + } + + for (const candidate of candidates) { + const ok = validate(candidate); + if (ok) { + return Result.ok({ + value: candidate, + finalResponse: result.finalResponse, + providerSessionId, + attempts: attempt + 1, + mode, + }); + } + } + + lastFailure = new AgentSchemaValidationError({ + attempt: attempt + 1, + mode, + issues: toSchemaIssues(validate.errors), + }); + if (attempt < maxRetries) { + input.onRetry?.({ attempt: attempt + 1, errors: lastFailure.message, mode }); + } + } + + return Result.err( + new SchemaRetriesExhaustedError( + maxRetries + 1, + lastFailure ?? + new InvalidAgentJsonError({ + attempt: maxRetries + 1, + mode: native ? "native" : "prompt", + responseExcerpt: "", + }), + ), + ); +} + +function classifyWorkflowProviderError( + provider: LocalAgentProvider, + cause: unknown, +): AgentProviderError { + const classified = providerErrorFromCause({ + provider, + operation: "workflow.agent", + cause, + }); + if (classified) return classified; + throw cause; +} + +export function augmentPromptForSchema(prompt: string, schema: JsonSchema): string { + return [ + prompt, + "", + "Respond with ONLY a JSON value that validates against this JSON Schema (no markdown, no prose):", + JSON.stringify(schema), + ].join("\n"); +} + +export function formatAjvErrors( + errors: Array<{ instancePath?: string; message?: string }> | null | undefined, +): string { + if (!errors || errors.length === 0) return "validation failed"; + return errors + .map((error) => { + const path = error.instancePath || "/"; + return `${path} ${error.message ?? "invalid"}`.trim(); + }) + .join("; "); +} + +function toSchemaIssues( + errors: Array<{ instancePath?: string; message?: string }> | null | undefined, +): Array<{ path: string; message: string }> { + if (!errors || errors.length === 0) { + return [{ path: "/", message: "validation failed" }]; + } + return errors.map((error) => ({ + path: error.instancePath || "/", + message: error.message ?? "invalid", + })); +} + +function structuredCandidates(result: WorkflowProviderRunResult): JsonValue[] { + const candidates: JsonValue[] = []; + if (result.structured !== undefined) { + const structured = jsonValueSchema.safeParse(result.structured); + if (structured.success) candidates.push(structured.data); + if (typeof result.structured === "string") { + const parsed = tryExtractJson(result.structured); + const parsedJson = jsonValueSchema.safeParse(parsed); + if (parsedJson.success && parsedJson.data !== result.structured) { + candidates.push(parsedJson.data); + } + } + } + const fromText = tryExtractJson(result.finalResponse); + const textJson = jsonValueSchema.safeParse(fromText); + if (textJson.success) candidates.push(textJson.data); + return candidates; +} diff --git a/src/workflow-script.test.ts b/src/workflow-script.test.ts new file mode 100644 index 00000000..75f92750 --- /dev/null +++ b/src/workflow-script.test.ts @@ -0,0 +1,272 @@ +import assert from "node:assert/strict"; +import { parseWorkflowScript, WorkflowScriptError } from "./workflow-script.js"; +import { createStubBudget } from "./workflow-types.js"; +import { + runWorkflowSandbox, + WorkflowDeterminismError, + type WorkflowSandboxApi, +} from "./workflow-sandbox.js"; + +{ + const parsed = parseWorkflowScript(` +export const meta = { + name: 'fanout-review', + description: 'Two reviewers', + phases: [{ title: 'Review', detail: 'parallel' }], + defaultProvider: 'codex', + concurrency: 4, +} + +return { ok: true, name: meta.name } +`); + assert.equal(parsed.meta.name, "fanout-review"); + assert.equal(parsed.meta.description, "Two reviewers"); + assert.equal(parsed.meta.defaultProvider, "codex"); + assert.equal(parsed.meta.concurrency, 4); + assert.deepEqual(parsed.meta.phases, [{ title: "Review", detail: "parallel" }]); + assert.match(parsed.scriptHash, /^[a-f0-9]{64}$/); +} + +{ + assert.throws( + () => parseWorkflowScript(`const x = 1; export const meta = { name: 'a', description: 'b' }`), + (error: unknown) => + error instanceof WorkflowScriptError && + error.kind === "meta" && + /first statement/.test(error.message), + ); +} + +{ + assert.throws( + () => + parseWorkflowScript(` +export const meta = { + name: 'bracket-call', + description: ({})['constructor']['constructor']('return process')(), +} +`), + (error: unknown) => + error instanceof WorkflowScriptError && + error.kind === "meta" && + /literal value/.test(error.message), + ); +} + +{ + assert.throws( + () => + parseWorkflowScript(` +export const meta = { + name: 'computed-key', + ['description']: 'd', +} +`), + (error: unknown) => + error instanceof WorkflowScriptError && + error.kind === "meta" && + /static property name/.test(error.message), + ); +} + +{ + const parsed = parseWorkflowScript(` +export const meta = { + name: 'literal-text', + description: 'Text such as noCall( and export is data, not executable syntax', +} +return meta.description +`); + assert.match(parsed.meta.description, /noCall\(/); +} + +{ + assert.throws( + () => + parseWorkflowScript(` +export const meta = { name: 'wrong-type', description: 'd', concurrency: 'many' } +return 1 +`), + (error: unknown) => + error instanceof WorkflowScriptError && + /meta\.concurrency/.test(error.message) && + !/is required/.test(error.message), + ); +} + +{ + assert.throws( + () => + parseWorkflowScript(` +export const meta = { + name: 'bad', + description: 'x', + concurrency: Math.max(1, 2), +} +`), + (error: unknown) => error instanceof WorkflowScriptError && error.kind === "meta", + ); +} + +{ + assert.throws( + () => parseWorkflowScript(`export const meta = { name: 'Bad_Name', description: 'x' }`), + /meta\.name must match/, + ); +} + +{ + assert.throws( + () => parseWorkflowScript(`export const meta = { description: 'only' }`), + /meta\.name is required/, + ); +} + +{ + // Leading comments OK + const parsed = parseWorkflowScript(`// header +/* block */ +export const meta = { name: 'ok', description: 'd' } +return 1 +`); + assert.equal(parsed.meta.name, "ok"); +} + +async function runBody(source: string): Promise { + const parsed = parseWorkflowScript(source); + const logs: string[] = []; + return runWorkflowSandbox({ + parsed, + api: { + agent: async () => "agent-result", + parallel: async (...args: unknown[]) => { + const thunks = args[0] as Array<() => Promise>; + return Promise.all(thunks.map((t) => t().catch(() => null))); + }, + pipeline: async (...args: unknown[]) => args[0], + phase: () => {}, + log: (msg: unknown) => { + logs.push(String(msg)); + }, + args: { n: 1 }, + budget: createStubBudget(), + workflow: async () => null, + meta: parsed.meta, + } as WorkflowSandboxApi, + }); +} + +{ + const result = await runBody(` +export const meta = { name: 'ret', description: 'd' } +phase('A') +log('hi ' + args.n) +return { v: 1 + 1, fromAgent: await agent('p') } +`); + assert.deepEqual(result, { v: 2, fromAgent: "agent-result" }); +} + +{ + const result = await runBody(` +export const meta = { name: 'module-words', description: 'd' } +// import and export in comments are harmless +const text = 'Explain export syntax and import maps' +const template = \`import/export: \${text}\` +const pattern = /import|export/ +return { text, template, matches: pattern.test(text) } +`); + assert.deepEqual(result, { + text: "Explain export syntax and import maps", + template: "import/export: Explain export syntax and import maps", + matches: true, + }); +} + +{ + assert.throws( + () => + parseWorkflowScript(` +export const meta = { name: 'static-import', description: 'd' } +import value from './value.js' +return value +`), + (error: unknown) => error instanceof WorkflowScriptError && error.kind === "syntax", + ); + assert.throws( + () => + parseWorkflowScript(` +export const meta = { name: 'extra-export', description: 'd' } +export const value = 1 +return value +`), + (error: unknown) => error instanceof WorkflowScriptError && error.kind === "syntax", + ); +} + +{ + await assert.rejects( + () => + runBody(` +export const meta = { name: 'dynamic-import', description: 'd' } +return import('node:fs') +`), + /dynamic import callback/i, + ); +} + +{ + await assert.rejects( + () => + runBody(` +export const meta = { name: 'now', description: 'd' } +return Date.now() +`), + (error: unknown) => + error instanceof WorkflowDeterminismError && /Date\.now/.test(error.message), + ); +} + +{ + await assert.rejects( + () => + runBody(` +export const meta = { name: 'rand', description: 'd' } +return Math.random() +`), + WorkflowDeterminismError, + ); +} + +{ + await assert.rejects( + () => + runBody(` +export const meta = { name: 'date', description: 'd' } +return new Date() +`), + WorkflowDeterminismError, + ); +} + +{ + // Fixed Date is OK + const result = await runBody(` +export const meta = { name: 'fixed-date', description: 'd' } +return new Date('2020-01-01T00:00:00.000Z').toISOString() +`); + assert.equal(result, "2020-01-01T00:00:00.000Z"); +} + +{ + // No process/require + await assert.rejects( + () => + runBody(` +export const meta = { name: 'proc', description: 'd' } +return process.pid +`), + /process is not defined|ReferenceError/, + ); +} + +console.log("workflow-script.test.ts: ok"); diff --git a/src/workflow-script.ts b/src/workflow-script.ts new file mode 100644 index 00000000..0c94b0ea --- /dev/null +++ b/src/workflow-script.ts @@ -0,0 +1,401 @@ +import { createHash } from "node:crypto"; +import vm from "node:vm"; +import { WORKFLOW_LIMITS, type WorkflowMeta } from "./workflow-types.js"; +import { workflowMetaSchema } from "./workflow-contracts.js"; + +export class WorkflowScriptError extends Error { + constructor( + readonly kind: "syntax" | "meta" | "script_too_large", + message: string, + readonly line?: number, + ) { + super(message); + this.name = "WorkflowScriptError"; + } +} + +export interface ParsedWorkflowScript { + meta: WorkflowMeta; + source: string; + scriptHash: string; + /** Compiled async factory. Workflow APIs are installed as sandbox globals. */ + script: vm.Script; + filename: string; +} + +const META_EXPORT = /export\s+const\s+meta\s*=/; + +/** + * Parse + compile a workflow script. + * Expects `export const meta = {…}` as the first statement (optional leading comments/blank). + */ +export function parseWorkflowScript( + source: string, + options: { filename?: string } = {}, +): ParsedWorkflowScript { + if (Buffer.byteLength(source, "utf8") > WORKFLOW_LIMITS.scriptSourceBytes) { + throw new WorkflowScriptError( + "script_too_large", + `Script exceeds ${WORKFLOW_LIMITS.scriptSourceBytes} bytes`, + ); + } + + const filename = options.filename ?? "workflow:inline"; + const normalized = source.replace(/^/, ""); + const { metaLiteral } = extractMetaLiteral(normalized); + const meta = validateMeta(evaluateMetaLiteral(metaLiteral, filename)); + + // Strip only the leading `export ` so line numbers stay aligned (7 spaces). + const body = normalized.replace(META_EXPORT, " const meta ="); + + // Workflow APIs are installed as context-realm globals by the sandbox child. + // Keeping the factory argument-free avoids handing host-realm functions or + // constructors directly to model-authored workflow code. + const wrapped = `(async () => {\n${body}\n})`; + let script: vm.Script; + try { + script = new vm.Script(wrapped, { + filename, + // Outer async wrapper adds one line before user source + lineOffset: -1, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const line = parseErrorLine(message); + throw new WorkflowScriptError("syntax", message, line); + } + + return { + meta, + source: normalized, + scriptHash: hashSource(normalized), + script, + filename, + }; +} + +export function hashSource(source: string): string { + return createHash("sha256").update(source).digest("hex"); +} + +function extractMetaLiteral(source: string): { metaLiteral: string; metaEndIndex: number } { + const match = META_EXPORT.exec(source); + if (!match || match.index === undefined) { + throw new WorkflowScriptError( + "meta", + "Workflow script must start with `export const meta = { … }`", + ); + } + + // Ensure only whitespace/comments before export + const before = source.slice(0, match.index); + if (!isOnlyPreamble(before)) { + throw new WorkflowScriptError( + "meta", + "`export const meta` must be the first statement (comments/blank lines OK)", + ); + } + + const afterAssign = source.slice(match.index + match[0].length); + const trimmedStart = afterAssign.match(/^\s*/)?.[0].length ?? 0; + const objectStart = match.index + match[0].length + trimmedStart; + if (source[objectStart] !== "{") { + throw new WorkflowScriptError("meta", "meta value must be an object literal `{…}`"); + } + + const end = scanBalancedObject(source, objectStart); + const metaLiteral = source.slice(objectStart, end + 1); + + return { metaLiteral, metaEndIndex: end + 1 }; +} + +function scanBalancedObject(source: string, start: number): number { + let depth = 0; + let inString: '"' | "'" | null = null; + let inLineComment = false; + let inBlockComment = false; + let escape = false; + for (let i = start; i < source.length; i += 1) { + const ch = source[i]!; + const next = source[i + 1]; + if (inLineComment) { + if (ch === "\n") inLineComment = false; + continue; + } + if (inBlockComment) { + if (ch === "*" && next === "/") { + inBlockComment = false; + i += 1; + } + continue; + } + if (inString) { + if (escape) { + escape = false; + continue; + } + if (ch === "\\") { + escape = true; + continue; + } + if (ch === inString) inString = null; + continue; + } + if (ch === "/" && next === "/") { + inLineComment = true; + i += 1; + continue; + } + if (ch === "/" && next === "*") { + inBlockComment = true; + i += 1; + continue; + } + if (ch === '"' || ch === "'") { + inString = ch; + continue; + } + if (ch === "{") depth += 1; + else if (ch === "}") { + depth -= 1; + if (depth === 0) return i; + } + } + throw new WorkflowScriptError("meta", "Unclosed meta object literal"); +} + +function isOnlyPreamble(text: string): boolean { + // strip block comments, line comments, whitespace + const stripped = text + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/.*$/gm, "") + .trim(); + return stripped.length === 0; +} + +function evaluateMetaLiteral(literal: string, _filename: string): unknown { + try { + return new PureMetaLiteralParser(literal).parse(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new WorkflowScriptError("meta", `Invalid meta literal: ${message}`); + } +} + +class PureMetaLiteralParser { + private index = 0; + + constructor(private readonly source: string) {} + + parse(): unknown { + this.skipTrivia(); + const value = this.parseValue(); + this.skipTrivia(); + if (this.index !== this.source.length) { + this.fail(`unexpected token ${JSON.stringify(this.source[this.index])}`); + } + return value; + } + + private parseValue(): unknown { + this.skipTrivia(); + const ch = this.source[this.index]; + if (ch === "{") return this.parseObject(); + if (ch === "[") return this.parseArray(); + if (ch === '"' || ch === "'") return this.parseString(); + if (ch === "-" || (ch !== undefined && /\d/.test(ch))) return this.parseNumber(); + if (ch !== undefined && /[A-Za-z_$]/.test(ch)) { + const identifier = this.parseIdentifier(); + if (identifier === "true") return true; + if (identifier === "false") return false; + if (identifier === "null") return null; + this.fail(`identifier ${identifier} is not a literal value`); + } + this.fail(`expected a literal value, got ${JSON.stringify(ch)}`); + } + + private parseObject(): Record { + this.expect("{"); + const value: Record = Object.create(null) as Record; + this.skipTrivia(); + if (this.consume("}")) return value; + + for (;;) { + this.skipTrivia(); + const ch = this.source[this.index]; + const key = ch === '"' || ch === "'" ? this.parseString() : this.parseIdentifier(); + this.skipTrivia(); + this.expect(":"); + value[key] = this.parseValue(); + this.skipTrivia(); + if (this.consume("}")) return value; + this.expect(","); + this.skipTrivia(); + if (this.consume("}")) return value; + } + } + + private parseArray(): unknown[] { + this.expect("["); + const value: unknown[] = []; + this.skipTrivia(); + if (this.consume("]")) return value; + + for (;;) { + value.push(this.parseValue()); + this.skipTrivia(); + if (this.consume("]")) return value; + this.expect(","); + this.skipTrivia(); + if (this.consume("]")) return value; + } + } + + private parseString(): string { + const quote = this.source[this.index]; + if (quote !== '"' && quote !== "'") this.fail("expected a quoted string"); + this.index += 1; + let value = ""; + + while (this.index < this.source.length) { + const ch = this.source[this.index++]!; + if (ch === quote) return value; + if (ch === "\n" || ch === "\r") this.fail("unterminated string literal"); + if (ch !== "\\") { + value += ch; + continue; + } + + if (this.index >= this.source.length) this.fail("unterminated string escape"); + const escaped = this.source[this.index++]!; + const simpleEscapes: Record = { + "\\": "\\", + "\"": "\"", + "'": "'", + n: "\n", + r: "\r", + t: "\t", + b: "\b", + f: "\f", + v: "\v", + "0": "\0", + }; + if (escaped in simpleEscapes) { + value += simpleEscapes[escaped]; + continue; + } + if (escaped === "x") { + value += String.fromCodePoint(this.parseHexDigits(2)); + continue; + } + if (escaped === "u") { + if (this.consume("{")) { + const end = this.source.indexOf("}", this.index); + if (end < 0) this.fail("unterminated Unicode escape"); + const digits = this.source.slice(this.index, end); + if (!/^[0-9a-fA-F]{1,6}$/.test(digits)) this.fail("invalid Unicode escape"); + this.index = end + 1; + const codePoint = Number.parseInt(digits, 16); + if (codePoint > 0x10ffff) this.fail("Unicode escape is out of range"); + value += String.fromCodePoint(codePoint); + } else { + value += String.fromCodePoint(this.parseHexDigits(4)); + } + continue; + } + if (escaped === "\n") continue; + if (escaped === "\r") { + this.consume("\n"); + continue; + } + value += escaped; + } + this.fail("unterminated string literal"); + } + + private parseNumber(): number { + const match = this.source + .slice(this.index) + .match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/); + if (!match) this.fail("invalid number literal"); + this.index += match[0].length; + const value = Number(match[0]); + if (!Number.isFinite(value)) this.fail("number literal must be finite"); + return value; + } + + private parseIdentifier(): string { + const match = this.source.slice(this.index).match(/^[A-Za-z_$][\w$]*/); + if (!match) this.fail("expected a static property name"); + this.index += match[0].length; + return match[0]; + } + + private parseHexDigits(length: number): number { + const digits = this.source.slice(this.index, this.index + length); + if (digits.length !== length || !/^[0-9a-fA-F]+$/.test(digits)) { + this.fail("invalid hexadecimal escape"); + } + this.index += length; + return Number.parseInt(digits, 16); + } + + private skipTrivia(): void { + for (;;) { + while (this.index < this.source.length && /\s/.test(this.source[this.index]!)) { + this.index += 1; + } + if (this.source.startsWith("//", this.index)) { + const end = this.source.indexOf("\n", this.index + 2); + this.index = end < 0 ? this.source.length : end + 1; + continue; + } + if (this.source.startsWith("/*", this.index)) { + const end = this.source.indexOf("*/", this.index + 2); + if (end < 0) this.fail("unterminated block comment"); + this.index = end + 2; + continue; + } + return; + } + } + + private expect(token: string): void { + if (!this.consume(token)) this.fail(`expected ${JSON.stringify(token)}`); + } + + private consume(token: string): boolean { + if (!this.source.startsWith(token, this.index)) return false; + this.index += token.length; + return true; + } + + private fail(message: string): never { + throw new Error(`${message} at offset ${this.index}`); + } +} + +function validateMeta(value: unknown): WorkflowMeta { + const parsed = workflowMetaSchema.safeParse(value, { reportInput: true }); + if (parsed.success) return parsed.data; + + const issue = parsed.error.issues[0]; + const path = issue?.path.length ? `meta.${issue.path.join(".")}` : "meta"; + if (issue?.code === "invalid_type" && issue.input === undefined) { + throw new WorkflowScriptError("meta", `${path} is required`); + } + if (issue?.code === "invalid_format" && issue.format === "regex") { + throw new WorkflowScriptError("meta", `${path} must match /^[a-z0-9-]+$/`); + } + throw new WorkflowScriptError( + "meta", + `${path}: ${issue?.message ?? "validation failed"}`, + ); +} + +function parseErrorLine(message: string): number | undefined { + const match = message.match(/:(\d+)(?::\d+)?\)?$/m) ?? message.match(/line\s+(\d+)/i); + if (!match) return undefined; + const n = Number(match[1]); + return Number.isFinite(n) ? n : undefined; +} diff --git a/src/workflow-store.test.ts b/src/workflow-store.test.ts new file mode 100644 index 00000000..009ca2c8 --- /dev/null +++ b/src/workflow-store.test.ts @@ -0,0 +1,487 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { openDatabase } from "./db/client.js"; +import { createWorkflowAgentObserver } from "./workflow-agent-observer.js"; +import { WorkflowStore } from "./workflow-store.js"; + +const root = mkdtempSync(join(tmpdir(), "devspace-workflow-store-test-")); +const stores: WorkflowStore[] = []; + +try { + const store = new WorkflowStore(root); + stores.push(store); + + const run = store.createRun({ + name: "fanout", + source: "inline", + scriptPath: join(root, "runs", "wfr_test.js"), + scriptHash: "abc123", + workspaceRoot: join(root, "project"), + workspaceId: "ws_1", + argsJson: JSON.stringify({ files: ["a.ts"] }), + phases: [ + { title: "Planning", detail: "Understand the change" }, + { title: "Review" }, + ], + }); + + assert.match(run.id, /^wfr_[a-f0-9]{12}$/); + assert.equal(run.status, "starting"); + assert.equal(run.cancelRequested, false); + assert.equal(store.getRun(run.id)?.name, "fanout"); + assert.deepEqual(store.getRun(run.id)?.phases, [ + { title: "Planning", detail: "Understand the change" }, + { title: "Review" }, + ]); + assert.throws( + () => + store.createRun({ + name: "invalid phases", + source: "inline", + scriptPath: join(root, "invalid.js"), + scriptHash: "invalid-phases", + workspaceRoot: join(root, "project"), + phases: [{ title: "" }], + }), + /Too small/, + ); + + const claimed = store.claimRun(run.id, process.pid); + assert.equal(claimed?.status, "running"); + assert.equal(claimed?.pid, process.pid); + assert.ok(claimed?.startedAt); + assert.equal(store.claimRun(run.id, 99999), undefined); + + store.setHeartbeat(run.id); + assert.ok(store.getRun(run.id)?.heartbeatAt); + + const e1 = store.appendEvent({ + runId: run.id, + type: "run_started", + data: { name: run.name, scriptHash: run.scriptHash, concurrency: 1 }, + }); + const e2 = store.appendEvent({ + runId: run.id, + type: "phase_started", + phase: "Review", + label: "r1", + data: { title: "Review" }, + }); + const e3 = store.appendEvent({ runId: run.id, type: "log", data: { message: "hello" } }); + assert.equal(e1.seq, 1); + assert.equal(e2.seq, 2); + assert.equal(e3.seq, 3); + + const page1 = store.drainEvents(run.id, 0, 2); + assert.equal(page1.events.length, 2); + assert.equal(page1.nextSeq, 2); + assert.equal(page1.hasMore, true); + assert.equal(page1.terminal, false); + + const page2 = store.drainEvents(run.id, 2, 10); + assert.equal(page2.events.length, 1); + assert.equal(page2.events[0]?.seq, 3); + assert.equal(page2.nextSeq, 3); + assert.equal(page2.hasMore, false); + + store.startAgentCall({ + runId: run.id, + callIndex: 0, + cacheKey: "key-a", + prompt: "review", + schemaJson: JSON.stringify({ type: "object" }), + provider: "codex", + model: "gpt-5.4", + effort: "high", + profileName: "reviewer", + profileFingerprint: "profile-hash", + phase: "Review", + isolation: "worktree", + worktreePath: "/tmp/wt", + replayReason: "identity_changed:prompt", + }); + store.attachAgentSession(run.id, 0, "sess_live"); + const partialUsage = store.updateAgentUsage(run.id, 0, { + inputTokens: 1_000, + cachedInputTokens: 700, + outputTokens: 200, + totalTokens: 1_200, + state: "partial", + }); + assert.equal(partialUsage.state, "partial"); + assert.throws( + () => + store.updateAgentUsage(run.id, 0, { + totalTokens: 1_300, + state: "unknown" as never, + }), + /Invalid option/, + ); + assert.equal(store.getAgentCall(run.id, 0)?.usage?.totalTokens, 1_200); + store.appendAgentActivity({ + runId: run.id, + callIndex: 0, + kind: "command", + status: "running", + label: "npm test", + }); + store.appendAgentActivity({ + runId: run.id, + callIndex: 0, + kind: "command", + status: "completed", + label: "npm test", + detail: "passed", + }); + assert.deepEqual( + store.listAgentActivity(run.id, 0).map((activity) => activity.status), + ["running", "completed"], + ); + assert.throws( + () => + store.appendAgentActivity({ + runId: run.id, + callIndex: 0, + kind: "network" as never, + status: "running", + label: "invalid activity", + }), + /Invalid option/, + ); + assert.equal(store.listAgentActivity(run.id, 0).length, 2); + store.completeAgentCall({ + runId: run.id, + callIndex: 0, + responseText: "done", + structuredJson: JSON.stringify({ ok: true }), + returnValueJson: JSON.stringify({ ok: true, exact: true }), + providerSessionId: "sess_1", + dirty: true, + }); + store.updateAgentUsage(run.id, 0, { + inputTokens: 1_100, + cachedInputTokens: 700, + outputTokens: 250, + totalTokens: 1_350, + state: "final", + }); + const call = store.getAgentCall(run.id, 0); + assert.equal(call?.status, "completed"); + assert.equal(call?.isolation, "worktree"); + assert.equal(call?.dirty, true); + assert.equal(call?.providerSessionId, "sess_1"); + assert.equal(call?.usage?.totalTokens, 1_350); + assert.equal(call?.usage?.state, "final"); + assert.equal(call?.effort, "high"); + assert.equal(call?.profileName, "reviewer"); + assert.equal(call?.profileFingerprint, "profile-hash"); + assert.equal(call?.prompt, "review"); + assert.equal(call?.returnValueJson, JSON.stringify({ ok: true, exact: true })); + assert.equal(call?.replayReason, "identity_changed:prompt"); + assert.deepEqual( + store.listEvents(run.id).slice(-2).map((event) => event.type), + ["agent_call_started", "agent_call_completed"], + ); + + store.startAgentCall({ + runId: run.id, + callIndex: 1, + cacheKey: "key-b", + prompt: "review two", + provider: "claude", + }); + store.failAgentCall({ + runId: run.id, + callIndex: 1, + error: "boom", + errorKind: "provider", + }); + assert.equal(store.getAgentCall(run.id, 1)?.status, "failed"); + assert.equal(store.getAgentCall(run.id, 1)?.errorKind, "provider"); + assert.equal(store.listAgentCalls(run.id).length, 2); + assert.deepEqual( + store.listEvents(run.id).slice(-2).map((event) => event.type), + ["agent_call_started", "agent_call_failed"], + ); + + const cancelled = store.requestCancel(run.id); + assert.equal(cancelled.cancelRequested, true); + assert.equal(store.isCancelRequested(run.id), true); + + const terminal = store.cancelRun(run.id); + assert.equal(terminal.status, "cancelled"); + assert.equal(terminal.errorKind, "cancelled"); + assert.equal(store.cancelRun(run.id).status, "cancelled"); + + const terminalPage1 = store.drainEvents(run.id, 0, 2); + assert.equal(terminalPage1.hasMore, true); + assert.equal(terminalPage1.terminal, false); + const drainDone = store.drainEvents(run.id, 0, 100); + assert.equal(drainDone.events.at(-1)?.type, "run_cancelled"); + assert.equal(drainDone.hasMore, false); + assert.equal(drainDone.terminal, true); + + const run2 = store.createRun({ + name: "done", + source: "named", + scriptPath: join(root, "x.js"), + scriptHash: "h2", + workspaceRoot: join(root, "project"), + }); + store.claimRun(run2.id, process.pid); + store.completeRun(run2.id, { resultJson: JSON.stringify({ ok: 1 }) }); + assert.equal(store.getRun(run2.id)?.status, "completed"); + assert.equal(store.getRun(run2.id)?.resultJson, JSON.stringify({ ok: 1 })); + + const otherProjectRun = store.createRun({ + name: "other-project", + source: "inline", + scriptPath: join(root, "other.js"), + scriptHash: "other", + workspaceRoot: join(root, "other-project"), + }); + const otherWorkspaceRun = store.createRun({ + name: "other-workspace", + source: "inline", + scriptPath: join(root, "other-workspace.js"), + scriptHash: "other-workspace", + workspaceRoot: join(root, "project"), + workspaceId: "ws_2", + }); + assert.deepEqual( + store + .listRunsForWorkspace(join(root, "project")) + .map((entry) => entry.id) + .sort(), + [run.id, run2.id, otherWorkspaceRun.id].sort(), + ); + assert.deepEqual( + store + .listRunsForScope({ + workspaceId: "ws_1", + workspaceRoot: join(root, "project"), + }) + .map((entry) => entry.id) + .sort(), + [run.id, run2.id].sort(), + ); + assert.deepEqual( + store.listRunsForScope({ + workspaceId: "ws_1", + workspaceRoot: join(root, "project"), + }, { statuses: ["completed"] }).map((entry) => entry.id), + [run2.id], + ); + assert.deepEqual( + store + .listRunsForScope({ workspaceRoot: join(root, "project") }) + .map((entry) => entry.id), + [run2.id], + ); + assert.deepEqual( + store + .listRunsForWorkspace(join(root, "project"), { statuses: ["completed"] }) + .map((entry) => entry.id), + [run2.id], + ); + assert.equal( + store.listRunsForWorkspace(join(root, "other-project"))[0]?.id, + otherProjectRun.id, + ); + assert.deepEqual( + store.listEvents(run.id, 2).map((event) => event.type), + ["agent_call_failed", "run_cancelled"], + ); + + // Reap: stale heartbeat + dead pid (force heartbeat via shared sqlite handle) + const run3 = store.createRun({ + name: "stale", + source: "inline", + scriptPath: join(root, "s.js"), + scriptHash: "h3", + workspaceRoot: join(root, "project"), + }); + const dead = spawnSync(process.execPath, ["-e", ""]); + assert.ok(dead.pid); + store.claimRun(run3.id, dead.pid); + const db = openDatabase(root); + try { + db.sqlite + .prepare(`update workflow_runs set heartbeat_at = ? where id = ?`) + .run(new Date(Date.now() - 120_000).toISOString(), run3.id); + } finally { + db.close(); + } + const reaped = store.reapStale(60_000); + assert.ok(reaped.some((r) => r.id === run3.id && r.status === "failed")); + assert.equal(store.getRun(run3.id)?.errorKind, "heartbeat"); + assert.equal(store.listEvents(run3.id).at(-1)?.type, "run_failed"); + + const runStarting = store.createRun({ + name: "never-started", + source: "inline", + scriptPath: join(root, "never.js"), + scriptHash: "never", + workspaceRoot: join(root, "project"), + }); + const staleStartingDb = openDatabase(root); + try { + staleStartingDb.sqlite + .prepare(`update workflow_runs set updated_at = ? where id = ?`) + .run(new Date(Date.now() - 120_000).toISOString(), runStarting.id); + } finally { + staleStartingDb.close(); + } + const reapedStarting = store.reapStale(60_000); + assert.ok(reapedStarting.some((entry) => entry.id === runStarting.id)); + assert.equal(store.getRun(runStarting.id)?.status, "failed"); + + const run4 = store.createRun({ + name: "seq", + source: "inline", + scriptPath: join(root, "seq.js"), + scriptHash: "h4", + workspaceRoot: join(root, "project"), + }); + const seqs = [0, 1, 2, 3, 4].map(() => + store.appendEvent({ runId: run4.id, type: "log", data: { message: "1" } }).seq, + ); + assert.deepEqual(seqs, [1, 2, 3, 4, 5]); + + const atomicRun = store.createRun({ + name: "atomic-agent-calls", + source: "inline", + scriptPath: join(root, "atomic.js"), + scriptHash: "atomic", + workspaceRoot: join(root, "project"), + }); + store.claimRun(atomicRun.id, process.pid); + const atomicDb = openDatabase(root); + try { + atomicDb.sqlite.exec(` + create trigger reject_agent_call_started + before insert on workflow_events + when new.type = 'agent_call_started' + begin + select raise(abort, 'reject started event'); + end; + `); + assert.throws(() => + store.startAgentCall({ + runId: atomicRun.id, + callIndex: 0, + cacheKey: "atomic-start", + prompt: "start", + provider: "codex", + }), + ); + assert.equal(store.getAgentCall(atomicRun.id, 0), undefined); + atomicDb.sqlite.exec(`drop trigger reject_agent_call_started`); + + store.startAgentCall({ + runId: atomicRun.id, + callIndex: 0, + cacheKey: "atomic-start", + prompt: "start", + provider: "codex", + }); + atomicDb.sqlite.exec(` + create trigger reject_agent_call_completed + before insert on workflow_events + when new.type = 'agent_call_completed' + begin + select raise(abort, 'reject completed event'); + end; + `); + assert.throws(() => + store.completeAgentCall({ + runId: atomicRun.id, + callIndex: 0, + responseText: "done", + returnValueJson: JSON.stringify("done"), + }), + ); + assert.equal(store.getAgentCall(atomicRun.id, 0)?.status, "running"); + atomicDb.sqlite.exec(`drop trigger reject_agent_call_completed`); + + store.completeAgentCall({ + runId: atomicRun.id, + callIndex: 0, + responseText: "done", + returnValueJson: JSON.stringify("done"), + }); + + atomicDb.sqlite.exec(` + create trigger reject_agent_call_cached + before insert on workflow_events + when new.type = 'agent_call_cached' + begin + select raise(abort, 'reject cached event'); + end; + `); + assert.throws(() => + store.cacheAgentCall({ + runId: atomicRun.id, + callIndex: 1, + cacheKey: "atomic-cache", + prompt: "cached", + provider: "codex", + replayMatch: "same_index", + replayedFromRunId: "wfr_prior", + replayedFromCallIndex: 0, + responseText: "cached", + returnValueJson: JSON.stringify("cached"), + }), + ); + assert.equal(store.getAgentCall(atomicRun.id, 1), undefined); + atomicDb.sqlite.exec(`drop trigger reject_agent_call_cached`); + } finally { + atomicDb.close(); + } + + assert.ok(store.listRuns().length >= 3); + + const observedRun = store.createRun({ + name: "Observe provider", + source: "inline", + scriptPath: join(root, "observe.js"), + scriptHash: "observer-test", + workspaceRoot: join(root, "project"), + }); + store.startAgentCall({ + runId: observedRun.id, + callIndex: 0, + cacheKey: "observer", + prompt: "Inspect the project", + provider: "codex", + }); + const observer = createWorkflowAgentObserver(store, observedRun.id, 0, 60_000); + observer.onSession?.("session_123"); + observer.onActivity?.({ kind: "command", status: "running", label: "npm test" }); + observer.onUsage?.({ inputTokens: 100, outputTokens: 20, totalTokens: 120, state: "partial" }); + observer.onUsage?.({ inputTokens: 180, outputTokens: 40, totalTokens: 220, state: "final" }); + observer.close(); + + const retryObserver = createWorkflowAgentObserver(store, observedRun.id, 0, 60_000); + retryObserver.onUsage?.({ inputTokens: 50, outputTokens: 30, totalTokens: 80, state: "final" }); + retryObserver.close(); + assert.equal(store.getAgentCall(observedRun.id, 0)?.providerSessionId, "session_123"); + assert.equal(store.getAgentCall(observedRun.id, 0)?.usage?.inputTokens, 230); + assert.equal(store.getAgentCall(observedRun.id, 0)?.usage?.outputTokens, 70); + assert.equal(store.getAgentCall(observedRun.id, 0)?.usage?.totalTokens, 300); + assert.equal(store.getAgentCall(observedRun.id, 0)?.usage?.state, "final"); + assert.equal(store.listAgentActivity(observedRun.id, 0)[0]?.label, "npm test"); + + // Second store instance sees same rows + const other = new WorkflowStore(root); + stores.push(other); + assert.equal(other.getRun(run.id)?.status, "cancelled"); +} finally { + for (const store of stores) store.close(); + rmSync(root, { recursive: true, force: true }); +} + +console.log("workflow-store.test.ts: ok"); diff --git a/src/workflow-store.ts b/src/workflow-store.ts new file mode 100644 index 00000000..aa7bf761 --- /dev/null +++ b/src/workflow-store.ts @@ -0,0 +1,1392 @@ +import { randomUUID } from "node:crypto"; +import { resolve } from "node:path"; +import { Result, type Result as BetterResult } from "better-result"; +import * as z from "zod/v4"; +import { openDatabase, type DatabaseHandle } from "./db/client.js"; +import type { ServerConfig } from "./config.js"; +import { + WORKFLOW_LIMITS, + type AgentIsolationMode, + type AppendWorkflowEventInput, + type WorkflowAgentCallRecord, + type WorkflowAgentCallStatus, + type WorkflowAgentActivityKind, + type WorkflowAgentActivityRecord, + type WorkflowAgentActivityStatus, + type WorkflowErrorKind, + type WorkflowEventRecord, + type WorkflowRunRecord, + type WorkflowRunSource, + type WorkflowRunStatus, + type WorkflowPhaseMeta, + type WorkflowTokenUsage, +} from "./workflow-types.js"; +import { + localAgentProviderSchema, + parseWorkflowEventPayload, + workflowAgentCallStatusSchema, + workflowEventTypeSchema, + workflowAgentActivityKindSchema, + workflowAgentActivityStatusSchema, + workflowRunSourceSchema, + workflowRunStatusSchema, + workflowPhaseMetaSchema, + workflowTokenUsageStateSchema, +} from "./workflow-contracts.js"; +import { + InvalidRunTransitionError, + WorkflowNotFoundError, + WorkflowStoreError, +} from "./workflow-errors.js"; + +export type WorkflowRunTransitionError = + | WorkflowNotFoundError + | InvalidRunTransitionError + | WorkflowStoreError; + +export interface CreateWorkflowRunInput { + name: string; + source: WorkflowRunSource; + scriptPath: string; + scriptHash: string; + workspaceRoot: string; + workspaceId?: string; + argsJson?: string; + phases?: WorkflowPhaseMeta[]; + resumedFromRunId?: string; + baseSha?: string; +} + +export interface AppendWorkflowAgentActivityInput { + runId: string; + callIndex: number; + kind: WorkflowAgentActivityKind; + status: WorkflowAgentActivityStatus; + label: string; + detail?: string; + startedAt?: string; + completedAt?: string; +} + +export interface BeginAgentCallInput { + runId: string; + callIndex: number; + cacheKey: string; + prompt: string; + schemaJson?: string; + provider: string; + model?: string; + effort?: string; + profileName?: string; + profileFingerprint?: string; + label?: string; + phase?: string; + isolation?: AgentIsolationMode; + worktreePath?: string; + replayMatch?: "same_index"; + replayedFromRunId?: string; + replayedFromCallIndex?: number; + replayReason?: string; +} + +export interface CompleteAgentCallInput { + runId: string; + callIndex: number; + responseText?: string; + structuredJson?: string; + returnValueJson?: string; + providerSessionId?: string; + dirty?: boolean; + worktreePath?: string; + fromCache?: boolean; +} + +export interface CacheAgentCallInput extends BeginAgentCallInput { + replayMatch: "same_index"; + replayedFromRunId: string; + replayedFromCallIndex: number; + responseText?: string; + structuredJson?: string; + returnValueJson?: string; + providerSessionId?: string; +} + +export interface FailAgentCallInput { + runId: string; + callIndex: number; + error: string; + errorKind?: WorkflowErrorKind; + worktreePath?: string; + dirty?: boolean; + cleanupError?: string; +} + +export interface CompleteRunInput { + resultJson?: string; + callCount?: number; +} + +export interface FailRunInput { + error: string; + errorKind?: WorkflowErrorKind; +} + +export interface DrainEventsResult { + events: WorkflowEventRecord[]; + nextSeq: number; + hasMore: boolean; + terminal: boolean; + run: WorkflowRunRecord; +} + +export interface WorkflowRunScope { + workspaceId?: string; + workspaceRoot: string; +} + +interface WorkflowRunRow { + id: string; + name: string; + source: string; + script_path: string; + script_hash: string; + workspace_root: string; + workspace_id: string | null; + args_json: string; + phases_json: string; + status: string; + error: string | null; + error_kind: string | null; + result_json: string | null; + pid: number | null; + heartbeat_at: string | null; + cancel_requested: string; + resumed_from_run_id: string | null; + base_sha: string | null; + created_at: string; + started_at: string | null; + completed_at: string | null; + updated_at: string; +} + +interface WorkflowEventRow { + run_id: string; + seq: number; + type: string; + phase: string | null; + label: string | null; + data_json: string; + created_at: string; +} + +interface WorkflowAgentCallRow { + run_id: string; + call_index: number; + cache_key: string; + prompt: string; + schema_json: string | null; + provider: string; + model: string | null; + effort: string | null; + profile_name: string | null; + profile_fingerprint: string | null; + label: string | null; + phase: string | null; + status: string; + from_cache: string; + provider_session_id: string | null; + usage_input_tokens: number | null; + usage_cached_input_tokens: number | null; + usage_cache_creation_input_tokens: number | null; + usage_output_tokens: number | null; + usage_total_tokens: number | null; + usage_state: string | null; + usage_updated_at: string | null; + response_text: string | null; + structured_json: string | null; + return_value_json: string | null; + error: string | null; + error_kind: string | null; + replay_match: string | null; + replayed_from_run_id: string | null; + replayed_from_call_index: number | null; + replay_reason: string | null; + isolation: string; + worktree_path: string | null; + dirty: string | null; + created_at: string; + started_at: string | null; + completed_at: string | null; + updated_at: string; +} + +interface WorkflowAgentActivityRow { + run_id: string; + call_index: number; + seq: number; + kind: string; + status: string; + label: string; + detail: string | null; + started_at: string | null; + completed_at: string | null; + created_at: string; +} + +const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]); + +export class WorkflowStore { + private readonly database: DatabaseHandle; + + constructor(stateDir: string) { + this.database = openDatabase(stateDir); + } + + createRun(input: CreateWorkflowRunInput): WorkflowRunRecord { + const now = isoNow(); + const argsJson = input.argsJson ?? "null"; + const phases = z.array(workflowPhaseMetaSchema).parse(input.phases ?? []); + const phasesJson = JSON.stringify(phases); + assertArgsSize(argsJson); + + const record: WorkflowRunRecord = { + id: `wfr_${randomUUID().replaceAll("-", "").slice(0, 12)}`, + name: input.name, + source: input.source, + scriptPath: input.scriptPath, + scriptHash: input.scriptHash, + workspaceRoot: resolve(input.workspaceRoot), + workspaceId: input.workspaceId, + argsJson, + phases, + status: "starting", + cancelRequested: false, + resumedFromRunId: input.resumedFromRunId, + baseSha: input.baseSha, + createdAt: now, + updatedAt: now, + }; + + this.database.sqlite + .prepare( + `insert into workflow_runs ( + id, name, source, script_path, script_hash, workspace_root, workspace_id, + args_json, phases_json, status, cancel_requested, resumed_from_run_id, base_sha, + created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + record.id, + record.name, + record.source, + record.scriptPath, + record.scriptHash, + record.workspaceRoot, + record.workspaceId ?? null, + record.argsJson, + phasesJson, + record.status, + "false", + record.resumedFromRunId ?? null, + record.baseSha ?? null, + record.createdAt, + record.updatedAt, + ); + + return record; + } + + getRun(id: string): WorkflowRunRecord | undefined { + const row = this.database.sqlite + .prepare("select * from workflow_runs where id = ?") + .get(id) as WorkflowRunRow | undefined; + return row ? rowToRun(row) : undefined; + } + + getRunResult( + id: string, + ): BetterResult { + return Result.try({ + try: () => this.getRun(id), + catch: (cause) => new WorkflowStoreError("get_run", cause), + }); + } + + listRuns(limit = 50): WorkflowRunRecord[] { + const rows = this.database.sqlite + .prepare("select * from workflow_runs order by updated_at desc limit ?") + .all(Math.max(1, Math.min(limit, 500))) as WorkflowRunRow[]; + return rows.map(rowToRun); + } + + listRunsForWorkspace( + workspaceRoot: string, + options: { + statuses?: WorkflowRunStatus[]; + limit?: number; + } = {}, + ): WorkflowRunRecord[] { + const root = resolve(workspaceRoot); + const limit = Math.max(1, Math.min(options.limit ?? 50, 500)); + const statuses = options.statuses?.filter((status, index, values) => + values.indexOf(status) === index, + ); + + if (!statuses?.length) { + const rows = this.database.sqlite + .prepare( + "select * from workflow_runs where workspace_root = ? order by updated_at desc limit ?", + ) + .all(root, limit) as WorkflowRunRow[]; + return rows.map(rowToRun); + } + + const placeholders = statuses.map(() => "?").join(", "); + const rows = this.database.sqlite + .prepare( + `select * from workflow_runs + where workspace_root = ? and status in (${placeholders}) + order by updated_at desc + limit ?`, + ) + .all(root, ...statuses, limit) as WorkflowRunRow[]; + return rows.map(rowToRun); + } + + listRunsForScope( + scope: WorkflowRunScope, + options: { + statuses?: WorkflowRunStatus[]; + limit?: number; + } = {}, + ): WorkflowRunRecord[] { + const root = resolve(scope.workspaceRoot); + const limit = Math.max(1, Math.min(options.limit ?? 50, 500)); + const statuses = options.statuses?.filter((status, index, values) => + values.indexOf(status) === index, + ); + if (!scope.workspaceId) { + if (!statuses?.length) { + const rows = this.database.sqlite + .prepare( + `select * from workflow_runs + where workspace_id is null and workspace_root = ? + order by updated_at desc limit ?`, + ) + .all(root, limit) as WorkflowRunRow[]; + return rows.map(rowToRun); + } + + const placeholders = statuses.map(() => "?").join(", "); + const rows = this.database.sqlite + .prepare( + `select * from workflow_runs + where workspace_id is null and workspace_root = ? + and status in (${placeholders}) + order by updated_at desc + limit ?`, + ) + .all(root, ...statuses, limit) as WorkflowRunRow[]; + return rows.map(rowToRun); + } + + if (!statuses?.length) { + const rows = this.database.sqlite + .prepare( + `select * from workflow_runs + where workspace_id = ? or (workspace_id is null and workspace_root = ?) + order by updated_at desc limit ?`, + ) + .all(scope.workspaceId, root, limit) as WorkflowRunRow[]; + return rows.map(rowToRun); + } + + const placeholders = statuses.map(() => "?").join(", "); + const rows = this.database.sqlite + .prepare( + `select * from workflow_runs + where (workspace_id = ? or (workspace_id is null and workspace_root = ?)) + and status in (${placeholders}) + order by updated_at desc + limit ?`, + ) + .all(scope.workspaceId, root, ...statuses, limit) as WorkflowRunRow[]; + return rows.map(rowToRun); + } + + /** + * Atomically claim a starting run for the worker. + * Returns undefined if the run is missing or not claimable. + */ + setScriptPath(id: string, scriptPath: string): WorkflowRunRecord { + return unwrapRunResult(this.setScriptPathResult(id, scriptPath)); + } + + setScriptPathResult( + id: string, + scriptPath: string, + ): BetterResult { + const current = this.getRunResult(id); + if (current.isErr()) return Result.err(current.error); + const run = current.value; + if (!run) return Result.err(new WorkflowNotFoundError(id)); + const updated = Result.try({ + try: () => { + const now = isoNow(); + this.database.sqlite + .prepare( + `UPDATE workflow_runs SET script_path = ?, updated_at = ? WHERE id = ?`, + ) + .run(scriptPath, now, id); + return this.getRun(id); + }, + catch: (cause) => new WorkflowStoreError("set_script_path", cause), + }); + if (updated.isErr()) return Result.err(updated.error); + return updated.value + ? Result.ok(updated.value) + : Result.err(new WorkflowNotFoundError(id)); + } + + claimRun(id: string, pid: number): WorkflowRunRecord | undefined { + const result = this.claimRunResult(id, pid); + if (result.isOk()) return result.value; + if ( + WorkflowNotFoundError.is(result.error) || + InvalidRunTransitionError.is(result.error) + ) { + return undefined; + } + throw result.error; + } + + claimRunResult( + id: string, + pid: number, + ): BetterResult { + const currentResult = this.getRunResult(id); + if (currentResult.isErr()) return Result.err(currentResult.error); + const current = currentResult.value; + if (!current) return Result.err(new WorkflowNotFoundError(id)); + if (current.status !== "starting") { + return Result.err( + new InvalidRunTransitionError({ + runId: id, + from: current.status, + operation: "claim", + }), + ); + } + + const claimed = Result.try({ + try: () => { + const now = isoNow(); + const update = this.database.sqlite + .prepare( + `update workflow_runs set + status = 'running', + pid = ?, + heartbeat_at = ?, + started_at = coalesce(started_at, ?), + updated_at = ? + where id = ? and status = 'starting'`, + ) + .run(pid, now, now, now, id); + return update.changes; + }, + catch: (cause) => new WorkflowStoreError("claim_run", cause), + }); + if (claimed.isErr()) return Result.err(claimed.error); + if (claimed.value === 0) { + const latestResult = this.getRunResult(id); + if (latestResult.isErr()) return Result.err(latestResult.error); + const latest = latestResult.value; + return latest + ? Result.err( + new InvalidRunTransitionError({ + runId: id, + from: latest.status, + operation: "claim", + }), + ) + : Result.err(new WorkflowNotFoundError(id)); + } + const runResult = this.getRunResult(id); + if (runResult.isErr()) return Result.err(runResult.error); + const run = runResult.value; + return run ? Result.ok(run) : Result.err(new WorkflowNotFoundError(id)); + } + + setHeartbeat(id: string, at = isoNow()): void { + this.database.sqlite + .prepare( + `update workflow_runs set heartbeat_at = ?, updated_at = ? where id = ? and status = 'running'`, + ) + .run(at, at, id); + } + + requestCancel(id: string): WorkflowRunRecord { + return unwrapRunResult(this.requestCancelResult(id)); + } + + requestCancelResult( + id: string, + ): BetterResult { + const current = this.getRunResult(id); + if (current.isErr()) return Result.err(current.error); + const run = current.value; + if (!run) return Result.err(new WorkflowNotFoundError(id)); + if (TERMINAL_STATUSES.has(run.status)) return Result.ok(run); + + const updated = Result.try({ + try: () => { + const now = isoNow(); + const update = this.database.sqlite + .prepare( + `update workflow_runs + set cancel_requested = 'true', updated_at = ? + where id = ? and status in ('starting', 'running')`, + ) + .run(now, id); + if (update.changes === 0) return this.getRun(id); + return this.getRun(id); + }, + catch: (cause) => new WorkflowStoreError("request_cancel", cause), + }); + if (updated.isErr()) return Result.err(updated.error); + return updated.value + ? Result.ok(updated.value) + : Result.err(new WorkflowNotFoundError(id)); + } + + isCancelRequested(id: string): boolean { + return this.requireRun(id).cancelRequested; + } + + completeRun(id: string, input: CompleteRunInput = {}): WorkflowRunRecord { + return unwrapRunResult(this.completeRunResult(id, input)); + } + + completeRunResult( + id: string, + input: CompleteRunInput = {}, + ): BetterResult { + return this.transitionRunResult(id, "complete", () => { + if (input.resultJson !== undefined) assertResultSize(input.resultJson); + const now = isoNow(); + const transaction = this.database.sqlite.transaction(() => { + const changes = this.database.sqlite + .prepare( + `update workflow_runs set + status = 'completed', + result_json = ?, + completed_at = ?, + updated_at = ?, + error = null, + error_kind = null + where id = ? and status in ('starting', 'running')`, + ) + .run(input.resultJson ?? null, now, now, id).changes; + if (changes === 0) return 0; + this.insertEventRow( + { + runId: id, + type: "run_completed", + data: { callCount: input.callCount ?? 0 }, + }, + now, + ); + return changes; + }); + return transaction.immediate(); + }); + } + + failRun(id: string, input: FailRunInput): WorkflowRunRecord { + return unwrapRunResult(this.failRunResult(id, input)); + } + + failRunResult( + id: string, + input: FailRunInput, + ): BetterResult { + return this.transitionRunResult(id, "fail", () => { + const now = isoNow(); + const errorKind = input.errorKind ?? "internal"; + const transaction = this.database.sqlite.transaction(() => { + const changes = this.database.sqlite + .prepare( + `update workflow_runs set + status = 'failed', + error = ?, + error_kind = ?, + completed_at = ?, + updated_at = ? + where id = ? and status in ('starting', 'running')`, + ) + .run(input.error, errorKind, now, now, id).changes; + if (changes === 0) return 0; + this.insertEventRow( + { + runId: id, + type: "run_failed", + data: { error: input.error, errorKind }, + }, + now, + ); + return changes; + }); + return transaction.immediate(); + }); + } + + cancelRun(id: string, error = "cancelled"): WorkflowRunRecord { + return unwrapRunResult(this.cancelRunResult(id, error)); + } + + cancelRunResult( + id: string, + error = "cancelled", + ): BetterResult { + return this.transitionRunResult(id, "cancel", () => { + const now = isoNow(); + const transaction = this.database.sqlite.transaction(() => { + const changes = this.database.sqlite + .prepare( + `update workflow_runs set + status = 'cancelled', + error = ?, + error_kind = 'cancelled', + cancel_requested = 'true', + completed_at = ?, + updated_at = ? + where id = ? and status in ('starting', 'running')`, + ) + .run(error, now, now, id).changes; + if (changes === 0) return 0; + this.insertEventRow( + { + runId: id, + type: "run_cancelled", + data: { reason: error }, + }, + now, + ); + return changes; + }); + return transaction.immediate(); + }); + } + + private transitionRunResult( + id: string, + operation: "complete" | "fail" | "cancel", + update: () => number, + ): BetterResult { + const currentResult = this.getRunResult(id); + if (currentResult.isErr()) return Result.err(currentResult.error); + const current = currentResult.value; + if (!current) return Result.err(new WorkflowNotFoundError(id)); + if (TERMINAL_STATUSES.has(current.status)) return Result.ok(current); + + const updated = Result.try({ + try: update, + catch: (cause) => new WorkflowStoreError(`${operation}_run`, cause), + }); + if (updated.isErr()) return Result.err(updated.error); + if (updated.value === 0) { + const latestResult = this.getRunResult(id); + if (latestResult.isErr()) return Result.err(latestResult.error); + const latest = latestResult.value; + if (!latest) return Result.err(new WorkflowNotFoundError(id)); + if (TERMINAL_STATUSES.has(latest.status)) return Result.ok(latest); + return Result.err( + new InvalidRunTransitionError({ + runId: id, + from: latest.status, + operation, + }), + ); + } + const runResult = this.getRunResult(id); + if (runResult.isErr()) return Result.err(runResult.error); + const run = runResult.value; + return run ? Result.ok(run) : Result.err(new WorkflowNotFoundError(id)); + } + + appendEvent(input: AppendWorkflowEventInput): WorkflowEventRecord { + const createdAt = isoNow(); + const transaction = this.database.sqlite.transaction(() => + this.insertEventRow(input, createdAt), + ); + return transaction.immediate(); + } + + drainEvents(runId: string, sinceSeq = 0, limit: number = WORKFLOW_LIMITS.eventDrainDefault): DrainEventsResult { + const run = this.requireRun(runId); + const capped = Math.max(1, Math.min(limit, WORKFLOW_LIMITS.eventDrainMax)); + const rows = this.database.sqlite + .prepare( + `select * from workflow_events + where run_id = ? and seq > ? + order by seq asc + limit ?`, + ) + .all(runId, sinceSeq, capped + 1) as WorkflowEventRow[]; + const hasMore = rows.length > capped; + const events = rows.slice(0, capped).map(rowToEvent); + const nextSeq = events.length > 0 ? events[events.length - 1]!.seq : sinceSeq; + return { + events, + nextSeq, + hasMore, + terminal: TERMINAL_STATUSES.has(run.status) && !hasMore, + run, + }; + } + + listEvents(runId: string, limit = 100): WorkflowEventRecord[] { + const capped = Math.max(1, Math.min(limit, WORKFLOW_LIMITS.eventDrainMax)); + const rows = this.database.sqlite + .prepare( + `select * from ( + select * from workflow_events + where run_id = ? + order by seq desc + limit ? + ) order by seq asc`, + ) + .all(runId, capped) as WorkflowEventRow[]; + return rows.map(rowToEvent); + } + + startAgentCall(input: BeginAgentCallInput): WorkflowAgentCallRecord { + const now = isoNow(); + const transaction = this.database.sqlite.transaction(() => { + const call = this.insertAgentCallRow(input, now); + this.insertEventRow( + { + runId: input.runId, + type: "agent_call_started", + phase: input.phase, + label: input.label, + data: { + callIndex: input.callIndex, + cacheKey: input.cacheKey, + provider: call.provider, + isolation: call.isolation, + worktreePath: call.worktreePath, + }, + }, + now, + ); + return call; + }); + return transaction.immediate(); + } + + cacheAgentCall(input: CacheAgentCallInput): WorkflowAgentCallRecord { + this.assertAgentCallResultSizes(input); + const now = isoNow(); + const transaction = this.database.sqlite.transaction(() => { + this.insertAgentCallRow(input, now); + const call = this.updateCompletedAgentCallRow( + { + runId: input.runId, + callIndex: input.callIndex, + responseText: input.responseText, + structuredJson: input.structuredJson, + returnValueJson: input.returnValueJson, + providerSessionId: input.providerSessionId, + fromCache: true, + }, + now, + ); + this.insertEventRow( + { + runId: input.runId, + type: "agent_call_cached", + phase: input.phase, + label: input.label, + data: { + callIndex: input.callIndex, + cacheKey: input.cacheKey, + provider: call.provider, + replayMatch: input.replayMatch, + replayedFromRunId: input.replayedFromRunId, + replayedFromCallIndex: input.replayedFromCallIndex, + }, + }, + now, + ); + return call; + }); + return transaction.immediate(); + } + + private insertAgentCallRow( + input: BeginAgentCallInput, + now: string, + ): WorkflowAgentCallRecord { + const isolation: AgentIsolationMode = input.isolation === "worktree" ? "worktree" : "shared"; + this.database.sqlite + .prepare( + `insert into workflow_agent_calls ( + run_id, call_index, cache_key, prompt, schema_json, provider, model, effort, + profile_name, profile_fingerprint, label, phase, + status, from_cache, isolation, worktree_path, replay_match, + replayed_from_run_id, replayed_from_call_index, replay_reason, + created_at, started_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', 'false', ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + input.runId, + input.callIndex, + input.cacheKey, + input.prompt, + input.schemaJson ?? null, + input.provider, + input.model ?? null, + input.effort ?? null, + input.profileName ?? null, + input.profileFingerprint ?? null, + input.label ?? null, + input.phase ?? null, + isolation, + input.worktreePath ?? null, + input.replayMatch ?? null, + input.replayedFromRunId ?? null, + input.replayedFromCallIndex ?? null, + input.replayReason ?? null, + now, + now, + now, + ); + return this.requireAgentCall(input.runId, input.callIndex); + } + + completeAgentCall(input: CompleteAgentCallInput): WorkflowAgentCallRecord { + this.assertAgentCallResultSizes(input); + const now = isoNow(); + const transaction = this.database.sqlite.transaction(() => { + const call = this.updateCompletedAgentCallRow(input, now); + this.insertEventRow( + { + runId: input.runId, + type: "agent_call_completed", + phase: call.phase, + label: call.label, + data: { + callIndex: input.callIndex, + provider: call.provider, + isolation: call.isolation, + worktreePath: call.worktreePath, + dirty: call.dirty, + fromCache: call.fromCache, + }, + }, + now, + ); + return call; + }); + return transaction.immediate(); + } + + private updateCompletedAgentCallRow( + input: CompleteAgentCallInput, + now: string, + ): WorkflowAgentCallRecord { + const status: WorkflowAgentCallStatus = input.fromCache ? "from_cache" : "completed"; + this.database.sqlite + .prepare( + `update workflow_agent_calls set + status = ?, + from_cache = ?, + response_text = ?, + structured_json = ?, + return_value_json = ?, + provider_session_id = coalesce(?, provider_session_id), + worktree_path = coalesce(?, worktree_path), + dirty = ?, + completed_at = ?, + updated_at = ? + where run_id = ? and call_index = ?`, + ) + .run( + status, + input.fromCache ? "true" : "false", + input.responseText ?? null, + input.structuredJson ?? null, + input.returnValueJson ?? null, + input.providerSessionId ?? null, + input.worktreePath ?? null, + input.dirty === undefined ? null : input.dirty ? "true" : "false", + now, + now, + input.runId, + input.callIndex, + ); + return this.requireAgentCall(input.runId, input.callIndex); + } + + failAgentCall(input: FailAgentCallInput): WorkflowAgentCallRecord { + const now = isoNow(); + const transaction = this.database.sqlite.transaction(() => { + const call = this.updateFailedAgentCallRow(input, now); + this.insertEventRow( + { + runId: input.runId, + type: "agent_call_failed", + phase: call.phase, + label: call.label, + data: { + callIndex: input.callIndex, + error: input.error, + cleanupError: input.cleanupError, + isolation: call.isolation, + worktreePath: call.worktreePath, + }, + }, + now, + ); + return call; + }); + return transaction.immediate(); + } + + private updateFailedAgentCallRow( + input: FailAgentCallInput, + now: string, + ): WorkflowAgentCallRecord { + this.database.sqlite + .prepare( + `update workflow_agent_calls set + status = 'failed', + error = ?, + error_kind = ?, + worktree_path = coalesce(?, worktree_path), + dirty = ?, + completed_at = ?, + updated_at = ? + where run_id = ? and call_index = ?`, + ) + .run( + input.error, + input.errorKind ?? "internal", + input.worktreePath ?? null, + input.dirty === undefined ? null : input.dirty ? "true" : "false", + now, + now, + input.runId, + input.callIndex, + ); + return this.requireAgentCall(input.runId, input.callIndex); + } + + private assertAgentCallResultSizes(input: { + responseText?: string; + structuredJson?: string; + returnValueJson?: string; + }): void { + if (input.responseText !== undefined) { + assertTextSize(input.responseText, WORKFLOW_LIMITS.responseTextBytes, "responseText"); + } + if (input.structuredJson !== undefined) { + assertTextSize(input.structuredJson, WORKFLOW_LIMITS.structuredJsonBytes, "structuredJson"); + } + if (input.returnValueJson !== undefined) { + assertTextSize( + input.returnValueJson, + WORKFLOW_LIMITS.replayValueJsonBytes, + "returnValueJson", + ); + } + } + + getAgentCall(runId: string, callIndex: number): WorkflowAgentCallRecord | undefined { + const row = this.database.sqlite + .prepare(`select * from workflow_agent_calls where run_id = ? and call_index = ?`) + .get(runId, callIndex) as WorkflowAgentCallRow | undefined; + return row ? rowToAgentCall(row) : undefined; + } + + listAgentCalls(runId: string): WorkflowAgentCallRecord[] { + const rows = this.database.sqlite + .prepare( + `select * from workflow_agent_calls where run_id = ? order by call_index asc`, + ) + .all(runId) as WorkflowAgentCallRow[]; + return rows.map(rowToAgentCall); + } + + attachAgentSession(runId: string, callIndex: number, providerSessionId: string): void { + const sessionId = providerSessionId.trim(); + if (!sessionId) throw new Error("providerSessionId cannot be empty"); + const now = isoNow(); + const update = this.database.sqlite + .prepare( + `update workflow_agent_calls + set provider_session_id = ?, updated_at = ? + where run_id = ? and call_index = ?`, + ) + .run(sessionId, now, runId, callIndex); + if (update.changes === 0) this.requireAgentCall(runId, callIndex); + } + + updateAgentUsage( + runId: string, + callIndex: number, + usage: Omit, + ): WorkflowTokenUsage { + const state = workflowTokenUsageStateSchema.parse(usage.state); + for (const value of [ + usage.inputTokens, + usage.cachedInputTokens, + usage.cacheCreationInputTokens, + usage.outputTokens, + usage.totalTokens, + ]) { + if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) { + throw new Error("Workflow token usage must contain non-negative integers"); + } + } + const now = isoNow(); + const update = this.database.sqlite + .prepare( + `update workflow_agent_calls set + usage_input_tokens = ?, + usage_cached_input_tokens = ?, + usage_cache_creation_input_tokens = ?, + usage_output_tokens = ?, + usage_total_tokens = ?, + usage_state = ?, + usage_updated_at = ?, + updated_at = ? + where run_id = ? and call_index = ?`, + ) + .run( + usage.inputTokens ?? null, + usage.cachedInputTokens ?? null, + usage.cacheCreationInputTokens ?? null, + usage.outputTokens ?? null, + usage.totalTokens, + state, + now, + now, + runId, + callIndex, + ); + if (update.changes === 0) this.requireAgentCall(runId, callIndex); + return { ...usage, state, updatedAt: now }; + } + + appendAgentActivity(input: AppendWorkflowAgentActivityInput): WorkflowAgentActivityRecord { + const kind = workflowAgentActivityKindSchema.parse(input.kind); + const status = workflowAgentActivityStatusSchema.parse(input.status); + const now = isoNow(); + const transaction = this.database.sqlite.transaction(() => { + this.requireAgentCall(input.runId, input.callIndex); + const next = this.database.sqlite + .prepare( + `select coalesce(max(seq), 0) + 1 as next_seq + from workflow_agent_activity where run_id = ? and call_index = ?`, + ) + .get(input.runId, input.callIndex) as { next_seq: number }; + this.database.sqlite + .prepare( + `insert into workflow_agent_activity ( + run_id, call_index, seq, kind, status, label, detail, + started_at, completed_at, created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + input.runId, + input.callIndex, + next.next_seq, + kind, + status, + input.label, + input.detail ?? null, + input.startedAt ?? null, + input.completedAt ?? null, + now, + ); + this.database.sqlite + .prepare( + `delete from workflow_agent_activity + where run_id = ? and call_index = ? and seq <= ?`, + ) + .run(input.runId, input.callIndex, next.next_seq - WORKFLOW_LIMITS.activityPerCall); + return { + ...input, + kind, + status, + seq: next.next_seq, + createdAt: now, + }; + }); + return transaction.immediate(); + } + + listAgentActivity( + runId: string, + callIndex: number, + limit = WORKFLOW_LIMITS.activityPerCall, + ): WorkflowAgentActivityRecord[] { + const capped = Math.max(1, Math.min(limit, WORKFLOW_LIMITS.activityPerCall)); + const rows = this.database.sqlite + .prepare( + `select * from ( + select * from workflow_agent_activity + where run_id = ? and call_index = ? + order by seq desc limit ? + ) order by seq asc`, + ) + .all(runId, callIndex, capped) as WorkflowAgentActivityRow[]; + return rows.map(rowToAgentActivity); + } + + /** + * Mark abandoned starting runs and running runs with a dead worker as failed. + * staleBeforeMs: start/update or heartbeat older than this and no live pid. + */ + reapStale(staleBeforeMs = 60_000, nowMs = Date.now()): WorkflowRunRecord[] { + const cutoff = new Date(nowMs - staleBeforeMs).toISOString(); + const candidates = this.database.sqlite + .prepare( + `select * from workflow_runs + where (status = 'running' and heartbeat_at is not null and heartbeat_at < ?) + or (status = 'starting' and updated_at < ?)`, + ) + .all(cutoff, cutoff) as WorkflowRunRow[]; + + const reaped: WorkflowRunRecord[] = []; + for (const row of candidates) { + const latest = this.getRun(row.id); + if (!latest || (latest.status !== "running" && latest.status !== "starting")) continue; + if (latest.pid !== undefined && isPidAlive(latest.pid)) continue; + const failed = this.failRun(row.id, { + error: latest.status === "starting" ? "workflow worker failed to start" : "worker heartbeat lost", + errorKind: "heartbeat", + }); + if (failed.status === "failed" && failed.errorKind === "heartbeat") { + reaped.push(failed); + } + } + return reaped; + } + + private insertEventRow( + input: AppendWorkflowEventInput, + createdAt: string, + ): WorkflowEventRecord { + const payload = parseWorkflowEventPayload(input.type, input.data); + const dataJson = truncateJson(payload, WORKFLOW_LIMITS.eventDataJsonBytes); + const next = this.database.sqlite + .prepare( + `select coalesce(max(seq), 0) + 1 as next_seq from workflow_events where run_id = ?`, + ) + .get(input.runId) as { next_seq: number }; + const seq = next.next_seq; + this.database.sqlite + .prepare( + `insert into workflow_events (run_id, seq, type, phase, label, data_json, created_at) + values (?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + input.runId, + seq, + input.type, + input.phase ?? null, + input.label ?? null, + dataJson, + createdAt, + ); + this.database.sqlite + .prepare(`update workflow_runs set updated_at = ? where id = ?`) + .run(createdAt, input.runId); + return { + runId: input.runId, + seq, + type: input.type, + phase: input.phase, + label: input.label, + dataJson, + createdAt, + }; + } + + close(): void { + this.database.close(); + } + + private requireRun(id: string): WorkflowRunRecord { + const run = this.getRun(id); + if (!run) throw new Error(`Unknown workflow run: ${id}`); + return run; + } + + private requireAgentCall(runId: string, callIndex: number): WorkflowAgentCallRecord { + const call = this.getAgentCall(runId, callIndex); + if (!call) throw new Error(`Unknown workflow agent call: ${runId}#${callIndex}`); + return call; + } +} + +export function createWorkflowStore(config: ServerConfig): WorkflowStore { + return new WorkflowStore(config.stateDir); +} + +function rowToRun(row: WorkflowRunRow): WorkflowRunRecord { + return { + id: row.id, + name: row.name, + source: workflowRunSourceSchema.parse(row.source), + scriptPath: row.script_path, + scriptHash: row.script_hash, + workspaceRoot: row.workspace_root, + workspaceId: row.workspace_id ?? undefined, + argsJson: row.args_json, + phases: z.array(workflowPhaseMetaSchema).parse(JSON.parse(row.phases_json)), + status: workflowRunStatusSchema.parse(row.status), + error: row.error ?? undefined, + errorKind: (row.error_kind as WorkflowErrorKind | null) ?? undefined, + resultJson: row.result_json ?? undefined, + pid: row.pid ?? undefined, + heartbeatAt: row.heartbeat_at ?? undefined, + cancelRequested: row.cancel_requested === "true", + resumedFromRunId: row.resumed_from_run_id ?? undefined, + baseSha: row.base_sha ?? undefined, + createdAt: row.created_at, + startedAt: row.started_at ?? undefined, + completedAt: row.completed_at ?? undefined, + updatedAt: row.updated_at, + }; +} + +function rowToEvent(row: WorkflowEventRow): WorkflowEventRecord { + return { + runId: row.run_id, + seq: row.seq, + type: workflowEventTypeSchema.parse(row.type), + phase: row.phase ?? undefined, + label: row.label ?? undefined, + dataJson: row.data_json, + createdAt: row.created_at, + }; +} + +function rowToAgentCall(row: WorkflowAgentCallRow): WorkflowAgentCallRecord { + return { + runId: row.run_id, + callIndex: row.call_index, + cacheKey: row.cache_key, + prompt: row.prompt, + schemaJson: row.schema_json ?? undefined, + provider: localAgentProviderSchema.parse(row.provider), + model: row.model ?? undefined, + effort: row.effort ?? undefined, + profileName: row.profile_name ?? undefined, + profileFingerprint: row.profile_fingerprint ?? undefined, + label: row.label ?? undefined, + phase: row.phase ?? undefined, + status: workflowAgentCallStatusSchema.parse(row.status), + fromCache: row.from_cache === "true", + providerSessionId: row.provider_session_id ?? undefined, + usage: row.usage_total_tokens === null || row.usage_updated_at === null + ? undefined + : { + inputTokens: row.usage_input_tokens ?? undefined, + cachedInputTokens: row.usage_cached_input_tokens ?? undefined, + cacheCreationInputTokens: row.usage_cache_creation_input_tokens ?? undefined, + outputTokens: row.usage_output_tokens ?? undefined, + totalTokens: row.usage_total_tokens, + state: workflowTokenUsageStateSchema.parse(row.usage_state), + updatedAt: row.usage_updated_at, + }, + responseText: row.response_text ?? undefined, + structuredJson: row.structured_json ?? undefined, + returnValueJson: row.return_value_json ?? undefined, + error: row.error ?? undefined, + errorKind: (row.error_kind as WorkflowErrorKind | null) ?? undefined, + replayMatch: + row.replay_match === "same_index" + ? "same_index" + : undefined, + replayedFromRunId: row.replayed_from_run_id ?? undefined, + replayedFromCallIndex: row.replayed_from_call_index ?? undefined, + replayReason: row.replay_reason ?? undefined, + isolation: row.isolation === "worktree" ? "worktree" : "shared", + worktreePath: row.worktree_path ?? undefined, + dirty: row.dirty === null ? undefined : row.dirty === "true", + createdAt: row.created_at, + startedAt: row.started_at ?? undefined, + completedAt: row.completed_at ?? undefined, + updatedAt: row.updated_at, + }; +} + +function rowToAgentActivity(row: WorkflowAgentActivityRow): WorkflowAgentActivityRecord { + return { + runId: row.run_id, + callIndex: row.call_index, + seq: row.seq, + kind: workflowAgentActivityKindSchema.parse(row.kind), + status: workflowAgentActivityStatusSchema.parse(row.status), + label: row.label, + detail: row.detail ?? undefined, + startedAt: row.started_at ?? undefined, + completedAt: row.completed_at ?? undefined, + createdAt: row.created_at, + }; +} + +function isoNow(): string { + return new Date().toISOString(); +} + +function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EPERM") return true; + return false; + } +} + +function assertArgsSize(argsJson: string): void { + assertTextSize(argsJson, WORKFLOW_LIMITS.argsJsonBytes, "argsJson"); +} + +function assertResultSize(resultJson: string): void { + assertTextSize(resultJson, WORKFLOW_LIMITS.resultJsonBytes, "resultJson"); +} + +function assertTextSize(value: string, maxBytes: number, label: string): void { + const bytes = Buffer.byteLength(value, "utf8"); + if (bytes > maxBytes) { + throw new Error(`${label} exceeds limit (${bytes} > ${maxBytes} bytes)`); + } +} + +function truncateJson(value: unknown, maxBytes: number): string { + let text: string; + try { + text = JSON.stringify(value) ?? "null"; + } catch { + text = JSON.stringify({ error: "unserializable" }); + } + if (Buffer.byteLength(text, "utf8") <= maxBytes) return text; + const marker = JSON.stringify({ truncated: true }); + const budget = Math.max(0, maxBytes - Buffer.byteLength(marker, "utf8") - 32); + const slice = Buffer.from(text, "utf8").subarray(0, budget).toString("utf8"); + return JSON.stringify({ truncated: true, preview: slice }); +} + +function unwrapRunResult( + result: BetterResult, +): WorkflowRunRecord { + if (result.isErr()) throw result.error; + return result.value; +} diff --git a/src/workflow-summary.test.ts b/src/workflow-summary.test.ts new file mode 100644 index 00000000..7bf73159 --- /dev/null +++ b/src/workflow-summary.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { WorkflowStore } from "./workflow-store.js"; +import { loadActiveWorkflowSummaries } from "./workflow-summary.js"; + +const root = mkdtempSync(join(tmpdir(), "devspace-workflow-summary-test-")); +const store = new WorkflowStore(root); + +try { + const workspaceRoot = join(root, "project"); + const run = store.createRun({ + name: "Review", + source: "named", + scriptPath: join(root, "run.js"), + scriptHash: "abc", + workspaceRoot, + }); + store.createRun({ + name: "Other workspace", + source: "named", + scriptPath: join(root, "other.js"), + scriptHash: "other", + workspaceRoot, + workspaceId: "workspace-2", + }); + store.claimRun(run.id, process.pid); + store.startAgentCall({ + runId: run.id, + callIndex: 0, + cacheKey: "running", + prompt: "Review auth", + provider: "codex", + isolation: "shared", + }); + store.startAgentCall({ + runId: run.id, + callIndex: 1, + cacheKey: "completed", + prompt: "Review tests", + provider: "codex", + isolation: "shared", + }); + store.completeAgentCall({ + runId: run.id, + callIndex: 1, + responseText: "done", + }); + + assert.deepEqual(loadActiveWorkflowSummaries(store, { + workspaceId: "workspace-1", + workspaceRoot, + }), [ + { + id: run.id, + name: "Review", + status: "running", + calls: { running: 1, completed: 1, failed: 0 }, + }, + ]); +} finally { + store.close(); + rmSync(root, { recursive: true, force: true }); +} + +console.log("workflow-summary.test.ts: ok"); diff --git a/src/workflow-summary.ts b/src/workflow-summary.ts new file mode 100644 index 00000000..eb41b58f --- /dev/null +++ b/src/workflow-summary.ts @@ -0,0 +1,43 @@ +import type { WorkflowRunScope, WorkflowStore } from "./workflow-store.js"; +import type { WorkflowRunStatus } from "./workflow-types.js"; + +const ACTIVE_WORKFLOW_STATUSES = ["starting", "running"] as const satisfies readonly WorkflowRunStatus[]; +type ActiveWorkflowStatus = (typeof ACTIVE_WORKFLOW_STATUSES)[number]; + +export interface ActiveWorkflowSummary { + id: string; + name: string; + status: ActiveWorkflowStatus; + calls: { + running: number; + completed: number; + failed: number; + }; +} + +export function loadActiveWorkflowSummaries( + store: WorkflowStore, + scope: WorkflowRunScope, +): ActiveWorkflowSummary[] { + return store + .listRunsForScope(scope, { + statuses: [...ACTIVE_WORKFLOW_STATUSES], + limit: 50, + }) + .flatMap((run) => { + if (run.status !== "starting" && run.status !== "running") return []; + const calls = store.listAgentCalls(run.id); + return [{ + id: run.id, + name: run.name, + status: run.status, + calls: { + running: calls.filter((call) => call.status === "running").length, + completed: calls.filter((call) => + call.status === "completed" || call.status === "from_cache" + ).length, + failed: calls.filter((call) => call.status === "failed").length, + }, + }]; + }); +} diff --git a/src/workflow-tui.test.ts b/src/workflow-tui.test.ts new file mode 100644 index 00000000..fe611648 --- /dev/null +++ b/src/workflow-tui.test.ts @@ -0,0 +1,197 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + createWorkflowTuiState, + reduceWorkflowTuiState, + reconcileWorkflowTuiState, + renderWorkflowTui, + resolveWorkflowTuiWorkspaceRoot, +} from "./workflow-tui.js"; +import type { WorkflowProjectView } from "./workflow-view.js"; + +const project: WorkflowProjectView = { + workspaceRoot: "/tmp/project", + version: "1", + runs: [ + { + id: "wfr_1", + name: "Review auth", + status: "running", + source: "named", + scriptPath: "/tmp/review.js", + scriptHash: "abc", + workspaceRoot: "/tmp/project", + currentPhase: "Implementation", + calls: { + running: 1, + completed: 1, + cached: 0, + failed: 0, + cancelled: 0, + observed: 2, + }, + totalTokens: 2_400, + phases: [ + { + title: "Planning", + status: "completed", + calls: [], + }, + { + title: "Implementation", + status: "running", + calls: [ + { + callIndex: 1, + status: "running", + provider: "codex", + label: "Patch auth", + phase: "Implementation", + isolation: "worktree", + fromCache: false, + prompt: "Patch the auth flow", + providerSessionId: "session_1", + usage: { + inputTokens: 1_600, + outputTokens: 800, + totalTokens: 2_400, + state: "partial", + updatedAt: "2026-07-26T10:00:02.000Z", + }, + updatedAt: "2026-07-26T10:00:02.000Z", + }, + ], + }, + ], + unphasedCalls: [{ + callIndex: 2, + status: "completed", + provider: "claude", + label: "Summarize rollout", + isolation: "shared", + fromCache: false, + prompt: "Summarize the rollout", + responseText: "Ready", + updatedAt: "2026-07-26T10:00:03.000Z", + }], + recentActivity: [ + { + seq: 1, + type: "log", + detail: "Running tests", + createdAt: "2026-07-26T10:00:03.000Z", + }, + ], + latestEventSeq: 1, + version: "v1", + createdAt: "2026-07-26T10:00:00.000Z", + startedAt: "2026-07-26T10:00:00.000Z", + updatedAt: "2026-07-26T10:00:03.000Z", + }, + ], +}; + +let state = createWorkflowTuiState(project); +let rendered = renderWorkflowTui(project, state, 100, 30, { ansi: false }); +assert.match(rendered, /Workflows · \/tmp\/project/); +assert.match(rendered, /Review auth Implementation/); + +state = reduceWorkflowTuiState(project, state, "return"); +assert.equal(state.screen, "workflow"); +rendered = renderWorkflowTui(project, state, 100, 30, { ansi: false }); +assert.match(rendered, /Workflow › Review auth/); +assert.match(rendered, /PHASES\s+│ AGENTS · Implementation/); +assert.match(rendered, /Patch auth codex 2\.4k/); +assert.match(rendered, /Other 1\/1/); + +state = reduceWorkflowTuiState(project, state, "tab"); +state = reduceWorkflowTuiState(project, state, "return"); +assert.equal(state.screen, "call"); +rendered = renderWorkflowTui(project, state, 72, 30, { + ansi: false, + activity: [{ + runId: "wfr_1", + callIndex: 1, + seq: 1, + kind: "tool", + status: "completed", + label: "bash", + detail: "npm test", + createdAt: "2026-07-26T10:00:03.000Z", + }], +}); +assert.match(rendered, /Workflow › Implementation › Patch auth/); +assert.match(rendered, /tool\s+bash · npm test/); + +let unphasedState = createWorkflowTuiState(project, "wfr_1"); +unphasedState = reduceWorkflowTuiState(project, unphasedState, "down"); +assert.equal(unphasedState.screen === "workflow" && unphasedState.phaseIndex, 2); +unphasedState = reduceWorkflowTuiState(project, unphasedState, "tab"); +unphasedState = reduceWorkflowTuiState(project, unphasedState, "return"); +assert.equal(unphasedState.screen, "call"); +assert.match(renderWorkflowTui(project, unphasedState, 80, 20, { ansi: false }), /Other › Summarize rollout/); + +const reorderedProject = { ...project, runs: [{ ...project.runs[0]!, id: "wfr_new" }, project.runs[0]!] }; +const reconciled = reconcileWorkflowTuiState(project, reorderedProject, { + screen: "workflow", + runIndex: 0, + phaseIndex: 1, + callIndex: 0, + focus: "calls", +}); +assert.equal(reconciled.runIndex, 1); + +const unsafeProject = { + ...project, + workspaceRoot: "/tmp/project\u001b]52;c;clipboard\u0007", + runs: [{ ...project.runs[0]!, name: "Review\u001b[2Jauth" }], +}; +const safeRender = renderWorkflowTui(unsafeProject, createWorkflowTuiState(unsafeProject), 100, 20, { ansi: false }); +assert.doesNotMatch(safeRender, /\u001b|\u0007/); +assert.match(safeRender, /\\x1b/); + +const narrow = renderWorkflowTui(project, { + screen: "workflow", + runIndex: 0, + phaseIndex: 1, + callIndex: 0, + focus: "phases", +}, 60, 20, { ansi: false }); +assert.match(narrow, /PHASES/); +assert.doesNotMatch(narrow, /AGENTS · Implementation/); + +const longPromptProject = { + ...project, + runs: [{ + ...project.runs[0]!, + phases: [{ + ...project.runs[0]!.phases[1]!, + calls: [{ ...project.runs[0]!.phases[1]!.calls[0]!, prompt: "one\ntwo\nthree" }], + }], + }], +}; +let scrollState = createWorkflowTuiState(longPromptProject, "wfr_1"); +scrollState = reduceWorkflowTuiState(longPromptProject, scrollState, "return"); +scrollState = reduceWorkflowTuiState(longPromptProject, scrollState, "return"); +scrollState = reduceWorkflowTuiState(longPromptProject, scrollState, "tab"); +for (let index = 0; index < 10; index += 1) { + scrollState = reduceWorkflowTuiState(longPromptProject, scrollState, "down"); +} +assert.equal(scrollState.screen === "call" && scrollState.scroll, 2); +const overflow = renderWorkflowTui(longPromptProject, scrollState, 80, 7, { ansi: false }); +assert.match(overflow, /Esc back · q quit$/); + +const previousRoot = process.env.DEVSPACE_WORKSPACE_ROOT; +const isolated = mkdtempSync(join(tmpdir(), "devspace-tui-root-")); +delete process.env.DEVSPACE_WORKSPACE_ROOT; +try { + assert.equal(resolveWorkflowTuiWorkspaceRoot(isolated), resolve(isolated)); +} finally { + if (previousRoot === undefined) delete process.env.DEVSPACE_WORKSPACE_ROOT; + else process.env.DEVSPACE_WORKSPACE_ROOT = previousRoot; + rmSync(isolated, { recursive: true }); +} + +console.log("workflow-tui.test.ts: ok"); diff --git a/src/workflow-tui.ts b/src/workflow-tui.ts new file mode 100644 index 00000000..7ac1dedf --- /dev/null +++ b/src/workflow-tui.ts @@ -0,0 +1,516 @@ +import { emitKeypressEvents } from "node:readline"; +import type { ServerConfig } from "./config.js"; +import { resolveCliWorkspaceContext } from "./cli-workspace.js"; +import { createWorkflowStore } from "./workflow-store.js"; +import type { WorkflowAgentActivityRecord } from "./workflow-types.js"; +import { + ACTIVE_WORKFLOW_STATUSES, + loadWorkflowProjectView, + type WorkflowCallView, + type WorkflowPhaseView, + type WorkflowProjectView, + type WorkflowRunView, +} from "./workflow-view.js"; + +const REFRESH_MS = 750; +const INSPECTOR_TABS = ["activity", "prompt", "result", "files", "metadata"] as const; +type InspectorTab = (typeof INSPECTOR_TABS)[number]; + +export type WorkflowTuiState = + | { screen: "workflows"; runIndex: number } + | { + screen: "workflow"; + runIndex: number; + phaseIndex: number; + callIndex: number; + focus: "phases" | "calls"; + } + | { + screen: "call"; + runIndex: number; + phaseIndex: number; + callIndex: number; + tab: InspectorTab; + scroll: number; + }; + +export async function runWorkflowTui(args: string[], config: ServerConfig): Promise { + const requestedRunId = args.find((arg) => !arg.startsWith("-")); + const workspaceRoot = resolveWorkflowTuiWorkspaceRoot(process.cwd(), config.allowedRoots); + const store = createWorkflowStore(config); + const load = (includeTerminal = false): WorkflowProjectView => + loadWorkflowProjectView(store, workspaceRoot, { + statuses: requestedRunId || includeTerminal ? undefined : [...ACTIVE_WORKFLOW_STATUSES], + limit: 50, + eventLimit: 100, + }); + + let project = load(); + let state = createWorkflowTuiState(project, requestedRunId); + + const activityForState = (): WorkflowAgentActivityRecord[] => { + if (state.screen !== "call") return []; + const call = selectedCall(project, state); + const run = project.runs[state.runIndex]; + return run && call ? store.listAgentActivity(run.id, call.callIndex) : []; + }; + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + try { + process.stdout.write( + `${renderWorkflowTui(project, state, 100, 40, { ansi: false, activity: activityForState() })}\n`, + ); + return; + } finally { + store.close(); + } + } + + let closed = false; + let rendering = false; + await new Promise((done, reject) => { + let timer: NodeJS.Timeout | undefined; + const finish = (error?: unknown): void => { + if (closed) return; + closed = true; + if (timer) clearInterval(timer); + process.stdin.off("keypress", onKeypress); + process.stdout.off("resize", render); + process.off("SIGINT", finish); + try { + process.stdin.setRawMode(false); + process.stdin.pause(); + process.stdout.write("\u001b[?25h\u001b[?1049l"); + } catch (cleanupError) { + error ??= cleanupError; + } finally { + store.close(); + } + if (error) reject(error); + else done(); + }; + const render = (): void => { + if (rendering || closed) return; + rendering = true; + try { + const previousProject = project; + project = load(state.screen !== "workflows"); + state = reconcileWorkflowTuiState(previousProject, project, state); + process.stdout.write( + `\u001b[H\u001b[2J${renderWorkflowTui( + project, + state, + process.stdout.columns || 100, + process.stdout.rows || 40, + { ansi: true, activity: activityForState() }, + )}`, + ); + } catch (error) { + finish(error); + } finally { + rendering = false; + } + }; + const onKeypress = (_input: string, key?: { name?: string; ctrl?: boolean }): void => { + if (!key) return; + if ((key.ctrl && key.name === "c") || key.name === "q") return finish(); + state = reduceWorkflowTuiState(project, state, key.name ?? "", activityForState()); + render(); + }; + emitKeypressEvents(process.stdin); + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.on("keypress", onKeypress); + process.stdout.on("resize", render); + process.on("SIGINT", finish); + process.stdout.write("\u001b[?1049h\u001b[?25l"); + timer = setInterval(render, REFRESH_MS); + render(); + }); +} + +export function resolveWorkflowTuiWorkspaceRoot( + cwd = process.cwd(), + allowedRoots: readonly string[] = [], +): string { + return resolveCliWorkspaceContext(allowedRoots, process.env, cwd).workspaceRoot; +} + +export function createWorkflowTuiState( + project: WorkflowProjectView, + requestedRunId?: string, +): WorkflowTuiState { + if (!requestedRunId) return { screen: "workflows", runIndex: 0 }; + const runIndex = project.runs.findIndex((run) => run.id === requestedRunId); + if (runIndex < 0) { + throw new Error(`Workflow ${requestedRunId} does not belong to the current project: ${project.workspaceRoot}`); + } + return { + screen: "workflow", + runIndex, + phaseIndex: initialPhaseIndex(project.runs[runIndex]!), + callIndex: 0, + focus: "phases", + }; +} + +export function reduceWorkflowTuiState( + project: WorkflowProjectView, + state: WorkflowTuiState, + key: string, + activity: WorkflowAgentActivityRecord[] = [], +): WorkflowTuiState { + const run = project.runs[state.runIndex]; + if (state.screen === "workflows") { + if (key === "up" || key === "k") return { ...state, runIndex: Math.max(0, state.runIndex - 1) }; + if (key === "down" || key === "j") { + return { ...state, runIndex: Math.min(Math.max(0, project.runs.length - 1), state.runIndex + 1) }; + } + if ((key === "return" || key === "right") && run) { + return { + screen: "workflow", + runIndex: state.runIndex, + phaseIndex: initialPhaseIndex(run), + callIndex: 0, + focus: "phases", + }; + } + return state; + } + if (state.screen === "workflow") { + if (key === "escape" || key === "left") return { screen: "workflows", runIndex: state.runIndex }; + if (key === "tab") return { ...state, focus: state.focus === "phases" ? "calls" : "phases" }; + if (!run) return state; + if (state.focus === "phases") { + if (key === "up" || key === "k") return { ...state, phaseIndex: Math.max(0, state.phaseIndex - 1), callIndex: 0 }; + if (key === "down" || key === "j") { + return { ...state, phaseIndex: Math.min(Math.max(0, navigatorPhases(run).length - 1), state.phaseIndex + 1), callIndex: 0 }; + } + if (key === "return" || key === "right") return { ...state, focus: "calls" }; + } else { + const calls = callsForPhase(run, state.phaseIndex); + if (key === "up" || key === "k") return { ...state, callIndex: Math.max(0, state.callIndex - 1) }; + if (key === "down" || key === "j") { + return { ...state, callIndex: Math.min(Math.max(0, calls.length - 1), state.callIndex + 1) }; + } + if ((key === "return" || key === "right") && calls[state.callIndex]) { + return { screen: "call", runIndex: state.runIndex, phaseIndex: state.phaseIndex, callIndex: state.callIndex, tab: "activity", scroll: 0 }; + } + } + return state; + } + if (key === "escape" || key === "left") { + return { screen: "workflow", runIndex: state.runIndex, phaseIndex: state.phaseIndex, callIndex: state.callIndex, focus: "calls" }; + } + if (key === "tab" || key === "right") { + const index = INSPECTOR_TABS.indexOf(state.tab); + return { ...state, tab: INSPECTOR_TABS[(index + 1) % INSPECTOR_TABS.length]!, scroll: 0 }; + } + if (key === "up" || key === "k") return { ...state, scroll: Math.max(0, state.scroll - 1) }; + if (key === "down" || key === "j") { + const run = project.runs[state.runIndex]; + const call = run ? selectedCall(project, state) : undefined; + const maxScroll = call ? Math.max(0, inspectorBody(state.tab, call, activity).length - 1) : 0; + return { ...state, scroll: Math.min(maxScroll, state.scroll + 1) }; + } + return state; +} + +export function renderWorkflowTui( + project: WorkflowProjectView, + state: WorkflowTuiState, + columns: number, + rows: number, + options: { ansi?: boolean; activity?: WorkflowAgentActivityRecord[] } = {}, +): string { + project = sanitizeTerminalValue(project); + const activity = sanitizeTerminalValue(options.activity ?? []); + const width = Math.max(48, columns); + const ansi = options.ansi !== false; + const lines = state.screen === "workflows" + ? renderWorkflowList(project, state, width, ansi) + : state.screen === "workflow" + ? renderNavigator(project, state, width, ansi) + : renderCallInspector(project, state, width, ansi, activity); + return fitRows(lines, rows).join("\n"); +} + +function renderWorkflowList( + project: WorkflowProjectView, + state: Extract, + width: number, + ansi: boolean, +): string[] { + const lines = [style(`Workflows · ${project.workspaceRoot}`, "bold", ansi), rule(width)]; + if (project.runs.length === 0) { + lines.push("No active workflows in this project.", "", style("q quit", "muted", ansi)); + return lines; + } + for (const [index, run] of project.runs.entries()) { + const phase = run.currentPhase ? ` ${run.currentPhase}` : ""; + lines.push(truncate(`${index === state.runIndex ? "›" : " "} ${statusGlyph(run.status)} ${run.name}${phase} ${callSummary(run)} ${elapsedLabel(run)}`, width)); + } + lines.push(rule(width), style("↑/↓ select · Enter open · q quit", "muted", ansi)); + return lines; +} + +function renderNavigator( + project: WorkflowProjectView, + state: Extract, + width: number, + ansi: boolean, +): string[] { + const run = project.runs[state.runIndex]; + if (!run) { + return [ + "Workflow is no longer available.", + rule(width), + style("Esc back · q quit", "muted", ansi), + ]; + } + const lines = [ + style(`Workflow › ${run.name}`, "bold", ansi), + truncate(`${statusGlyph(run.status)} ${run.status.toUpperCase()} ${elapsedLabel(run)} · ${callSummary(run)}${run.totalTokens ? ` · ${formatTokens(run.totalTokens)} tokens observed` : ""}`, width), + rule(width), + ]; + const phases = navigatorPhases(run); + const phase = phases[state.phaseIndex]; + const calls = callsForPhase(run, state.phaseIndex); + if (width < 80) { + lines.push(style(state.focus === "phases" ? "PHASES" : `AGENTS · ${phase?.title ?? "Other"}`, "heading", ansi)); + if (state.focus === "phases") appendPhaseLines(lines, phases, state.phaseIndex, width); + else appendCallLines(lines, calls, state.callIndex, width); + } else { + const leftWidth = Math.min(32, Math.floor(width * 0.35)); + const rightWidth = width - leftWidth - 3; + lines.push(`${style("PHASES".padEnd(leftWidth), "heading", ansi)} │ ${style(`AGENTS · ${phase?.title ?? "Other"}`, "heading", ansi)}`); + const left = phaseRows(phases, state.phaseIndex, leftWidth); + const right = callRows(calls, state.callIndex, rightWidth); + const count = Math.max(left.length, right.length, 1); + for (let index = 0; index < count; index += 1) { + lines.push(`${(left[index] ?? "").padEnd(leftWidth)} │ ${right[index] ?? ""}`); + } + } + lines.push(rule(width), style("↑/↓ select · Tab switch pane · Enter inspect · Esc back · q quit", "muted", ansi)); + return lines; +} + +function renderCallInspector( + project: WorkflowProjectView, + state: Extract, + width: number, + ansi: boolean, + activity: WorkflowAgentActivityRecord[], +): string[] { + const run = project.runs[state.runIndex]; + const call = run ? selectedCall(project, state) : undefined; + if (!run || !call) { + return [ + "Agent call is no longer available.", + rule(width), + style("Esc back · q quit", "muted", ansi), + ]; + } + const label = call.label ?? `Agent #${call.callIndex}`; + const target = call.model ? `${call.provider}/${call.model}` : call.provider; + const lines = [ + style(`Workflow › ${call.phase ?? "Other"} › ${label}`, "bold", ansi), + truncate(`${statusGlyph(call.status)} ${call.status} · ${target} · ${callElapsedLabel(call)}${call.usage ? ` · ${formatTokens(call.usage.totalTokens)} tokens ${call.usage.state}` : ""}`, width), + "", + INSPECTOR_TABS.map((tab) => tab === state.tab ? `[${capitalize(tab)}]` : capitalize(tab)).join(" "), + rule(width), + ]; + const body = inspectorBody(state.tab, call, activity).slice(state.scroll); + lines.push(...body.map((line) => truncate(line, width))); + lines.push(rule(width), style("Tab next section · ↑/↓ scroll · Esc back · q quit", "muted", ansi)); + return lines; +} + +function inspectorBody(tab: InspectorTab, call: WorkflowCallView, activity: WorkflowAgentActivityRecord[]): string[] { + if (tab === "activity") { + if (activity.length === 0) return ["No agent activity has been observed yet."]; + return activity.map((event) => `${timeLabel(event.createdAt)} ${statusGlyph(event.status)} ${event.kind.padEnd(7)} ${event.label}${event.detail ? ` · ${event.detail}` : ""}`); + } + if (tab === "prompt") return call.prompt.split("\n"); + if (tab === "result") { + if (call.error) return [`${call.errorKind ?? "error"}: ${call.error}`]; + return (call.responseText ?? call.structuredJson ?? call.returnValueJson ?? "No result yet.").split("\n"); + } + if (tab === "files") { + return [ + `Isolation ${call.isolation}`, + `Worktree ${call.worktreePath ?? "shared checkout"}`, + `Dirty ${call.dirty === undefined ? "unknown" : call.dirty ? "yes" : "no"}`, + ]; + } + return [ + `Call #${call.callIndex}`, + `Provider ${call.provider}`, + `Model ${call.model ?? "default"}`, + `Effort ${call.effort ?? "default"}`, + `Session ${call.providerSessionId ?? "unavailable"}`, + `Started ${call.startedAt ?? "not recorded"}`, + `Completed ${call.completedAt ?? "running"}`, + `Tokens ${call.usage ? `${call.usage.totalTokens} (${call.usage.state})` : "unavailable"}`, + `Replay ${call.replayedFromRunId ? `${call.replayedFromRunId}#${call.replayedFromCallIndex}` : "no"}`, + ]; +} + +export function reconcileWorkflowTuiState( + previousProject: WorkflowProjectView, + project: WorkflowProjectView, + state: WorkflowTuiState, +): WorkflowTuiState { + const previousRunId = previousProject.runs[state.runIndex]?.id; + const matchingRunIndex = previousRunId + ? project.runs.findIndex((run) => run.id === previousRunId) + : -1; + const runIndex = matchingRunIndex >= 0 + ? matchingRunIndex + : Math.min(Math.max(0, state.runIndex), Math.max(0, project.runs.length - 1)); + if (state.screen === "workflows") return { ...state, runIndex }; + const run = project.runs[runIndex]; + const phaseIndex = Math.min( + Math.max(0, state.phaseIndex), + Math.max(0, (run ? navigatorPhases(run).length : 1) - 1), + ); + const calls = run ? callsForPhase(run, phaseIndex) : []; + const callIndex = Math.min(Math.max(0, state.callIndex), Math.max(0, calls.length - 1)); + return { ...state, runIndex, phaseIndex, callIndex }; +} + +function selectedCall(project: WorkflowProjectView, state: { runIndex: number; phaseIndex: number; callIndex: number }): WorkflowCallView | undefined { + const run = project.runs[state.runIndex]; + return run ? callsForPhase(run, state.phaseIndex)[state.callIndex] : undefined; +} + +function callsForPhase(run: WorkflowRunView, phaseIndex: number): WorkflowCallView[] { + return navigatorPhases(run)[phaseIndex]?.calls ?? []; +} + +function initialPhaseIndex(run: WorkflowRunView): number { + const index = run.currentPhase + ? run.phases.findIndex((phase) => phase.title === run.currentPhase) + : -1; + return index < 0 ? 0 : index; +} + +function navigatorPhases(run: WorkflowRunView): WorkflowPhaseView[] { + if (run.unphasedCalls.length === 0) return run.phases; + const calls = run.unphasedCalls; + const status: WorkflowPhaseView["status"] = calls.some((call) => call.status === "failed") + ? "failed" + : calls.some((call) => call.status === "running") + ? "running" + : calls.every((call) => call.status === "cancelled") + ? "cancelled" + : "completed"; + return [...run.phases, { title: "Other", status, calls }]; +} + +function appendPhaseLines(lines: string[], phases: WorkflowPhaseView[], selected: number, width: number): void { + lines.push(...phaseRows(phases, selected, width)); +} + +function appendCallLines(lines: string[], calls: WorkflowCallView[], selected: number, width: number): void { + lines.push(...callRows(calls, selected, width)); +} + +function phaseRows(phases: WorkflowPhaseView[], selected: number, width: number): string[] { + if (phases.length === 0) return ["No phases observed yet."]; + return phases.map((phase, index) => truncate(`${index === selected ? "›" : " "} ${statusGlyph(phase.status)} ${phase.title} ${phaseProgress(phase)}`, width)); +} + +function callRows(calls: WorkflowCallView[], selected: number, width: number): string[] { + if (calls.length === 0) return ["No agent calls in this phase yet."]; + return calls.map((call, index) => { + const tokens = call.usage ? formatTokens(call.usage.totalTokens) : "—"; + return truncate(`${index === selected ? "›" : " "} ${statusGlyph(call.status)} ${call.label ?? `Agent #${call.callIndex}`} ${call.provider} ${tokens} ${callElapsedLabel(call)}`, width); + }); +} + +function phaseProgress(phase: WorkflowPhaseView): string { + if (phase.calls.length === 0) return "—"; + const done = phase.calls.filter((call) => call.status === "completed" || call.status === "from_cache").length; + return `${done}/${phase.calls.length}`; +} + +function callSummary(run: WorkflowRunView): string { + const parts = [ + run.calls.completed ? `${run.calls.completed} done` : undefined, + run.calls.cached ? `${run.calls.cached} replayed` : undefined, + run.calls.running ? `${run.calls.running} running` : undefined, + run.calls.failed ? `${run.calls.failed} failed` : undefined, + ].filter((part): part is string => Boolean(part)); + return parts.length ? parts.join(" · ") : "no agent calls yet"; +} + +function elapsedLabel(run: WorkflowRunView): string { + return durationLabel(run.startedAt ?? run.createdAt, run.completedAt); +} + +function callElapsedLabel(call: WorkflowCallView): string { + return call.fromCache ? "replayed" : durationLabel(call.startedAt ?? call.updatedAt, call.completedAt); +} + +function durationLabel(startValue: string, endValue?: string): string { + const seconds = Math.max(0, Math.floor(((endValue ? Date.parse(endValue) : Date.now()) - Date.parse(startValue)) / 1_000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ${seconds % 60}s`; + return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; +} + +function statusGlyph(status: string): string { + if (status === "completed" || status === "from_cache") return "✓"; + if (status === "failed") return "✕"; + if (status === "cancelled") return "−"; + if (status === "running") return "●"; + return "○"; +} + +function formatTokens(tokens: number): string { + if (tokens < 1_000) return String(tokens); + if (tokens < 1_000_000) return `${(tokens / 1_000).toFixed(tokens < 10_000 ? 1 : 0)}k`; + return `${(tokens / 1_000_000).toFixed(1)}m`; +} + +function timeLabel(value: string): string { + return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} + +function capitalize(value: string): string { + return `${value[0]!.toUpperCase()}${value.slice(1)}`; +} + +function rule(width: number): string { return "─".repeat(width); } +function truncate(value: string, width: number): string { + return value.length <= width ? value : `${value.slice(0, Math.max(0, width - 1))}…`; +} +function fitRows(lines: string[], rows: number): string[] { + if (rows <= 0 || lines.length <= rows) return lines; + const keep = Math.max(1, rows); + if (keep <= 2) return lines.slice(-keep); + return [...lines.slice(0, keep - 2), ...lines.slice(-2)]; +} +function style(value: string, tone: "bold" | "heading" | "muted", ansi: boolean): string { + if (!ansi) return value; + if (tone === "bold") return `\u001b[1m${value}\u001b[0m`; + if (tone === "heading") return `\u001b[1;36m${value}\u001b[0m`; + return `\u001b[2m${value}\u001b[0m`; +} + +function sanitizeTerminalValue(value: T): T { + if (typeof value === "string") { + return value.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, (character) => + `\\x${character.charCodeAt(0).toString(16).padStart(2, "0")}`, + ) as T; + } + if (Array.isArray(value)) return value.map(sanitizeTerminalValue) as T; + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, sanitizeTerminalValue(child)]), + ) as T; + } + return value; +} diff --git a/src/workflow-types.test.ts b/src/workflow-types.test.ts new file mode 100644 index 00000000..b8b06d7a --- /dev/null +++ b/src/workflow-types.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { + WORKFLOW_MAX_AGENT_CALLS, + WORKFLOW_MAX_ITEMS, + WORKFLOW_MAX_NEST_DEPTH, + buildAgentCacheKeyInput, + createStubBudget, + defaultWorkflowConcurrency, + resolveWorkflowConcurrency, +} from "./workflow-types.js"; + +assert.equal(WORKFLOW_MAX_ITEMS, 4096); +assert.equal(WORKFLOW_MAX_AGENT_CALLS, 256); +assert.equal(WORKFLOW_MAX_NEST_DEPTH, 1); + +assert.deepEqual( + buildAgentCacheKeyInput({ + prompt: "hi", + provider: "codex", + model: undefined, + effort: "high", + schema: null, + isolation: "worktree", + }), + { + prompt: "hi", + profileName: null, + profileFingerprint: null, + provider: "codex", + model: null, + effort: "high", + schema: null, + isolation: "worktree", + }, +); + +assert.deepEqual( + buildAgentCacheKeyInput({ + prompt: "x", + provider: "claude", + }), + { + prompt: "x", + profileName: null, + profileFingerprint: null, + provider: "claude", + model: null, + effort: null, + schema: null, + isolation: "shared", + }, +); + +const budget = createStubBudget(); +assert.equal(budget.total, null); +assert.equal(budget.spent(), 0); +assert.equal(budget.remaining(), Infinity); + +assert.equal(defaultWorkflowConcurrency(8), 6); +assert.equal(defaultWorkflowConcurrency(2), 1); +assert.equal(defaultWorkflowConcurrency(1), 1); +assert.equal(defaultWorkflowConcurrency(32), 16); + +assert.equal(resolveWorkflowConcurrency(undefined, 8), 6); +assert.equal(resolveWorkflowConcurrency(2, 8), 2); +assert.equal(resolveWorkflowConcurrency(100, 8), 6); +assert.equal(resolveWorkflowConcurrency(0, 8), 1); + +console.log("workflow-types.test.ts: ok"); diff --git a/src/workflow-types.ts b/src/workflow-types.ts new file mode 100644 index 00000000..8535092b --- /dev/null +++ b/src/workflow-types.ts @@ -0,0 +1,262 @@ +/** + * Frozen contracts for DevSpace Dynamic Workflows. + * Engine modules must import these rather than invent parallel shapes. + * + * Locks: + * - No writeMode on AgentOpts (prompt RO/write + isolation containment). + * - budget is a stub shape in v1. + * - nest depth 1; max pipeline/parallel items 4096. + * - concurrency default min(16, max(1, availableParallelism()-2)). + */ + +import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import type { JsonSchema } from "./json-types.js"; +import type { + AgentIsolationMode, + AgentOpts, + WorkflowErrorKind, + WorkflowAgentCallStatus, + WorkflowEventType, + WorkflowMeta, + WorkflowPhaseMeta, + WorkflowRunSource, + WorkflowRunStatus, +} from "./workflow-contracts.js"; + +export type { JsonObject, JsonPrimitive, JsonSchema, JsonValue } from "./json-types.js"; +export type { + AgentIsolationMode, + AgentOpts, + AppendWorkflowEventInput, + WorkflowAgent, + WorkflowAgentCallStatus, + WorkflowErrorKind, + WorkflowEventPayloads, + WorkflowEventType, + WorkflowMeta, + WorkflowNested, + WorkflowParallel, + WorkflowPhaseMeta, + WorkflowPipeline, + WorkflowRunSource, + WorkflowRunStatus, + WorkflowTask, +} from "./workflow-contracts.js"; + +// --------------------------------------------------------------------------- +// Limits +// --------------------------------------------------------------------------- + +export const WORKFLOW_MAX_ITEMS = 4096; +export const WORKFLOW_MAX_AGENT_CALLS = 256; +export const WORKFLOW_MAX_NEST_DEPTH = 1; +export const WORKFLOW_MAX_SCHEMA_RETRIES = 2; +export const WORKFLOW_HEARTBEAT_MS = 5_000; +export const WORKFLOW_CANCEL_HARD_MS = 5_000; +export const WORKFLOW_HOST_TIMEOUT_MS = 6 * 60 * 60 * 1000; +export const WORKFLOW_MCP_YIELD_MS = 110_000; + +/** Soft/hard transport + storage caps (not semantic coverage truncation). */ +export const WORKFLOW_LIMITS = { + eventDataJsonBytes: 8 * 1024, + responseTextBytes: 1 * 1024 * 1024, + structuredJsonBytes: 256 * 1024, + replayValueJsonBytes: 1 * 1024 * 1024, + resultJsonBytes: 256 * 1024, + argsJsonBytes: 64 * 1024, + scriptSourceBytes: 512 * 1024, + eventDrainDefault: 200, + eventDrainMax: 500, + activityPerCall: 500, +} as const; + +export type AgentProviderId = LocalAgentProvider; + +// --------------------------------------------------------------------------- +// Status / events +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Journal row shapes (behavioral; store maps snake_case) +// --------------------------------------------------------------------------- + +export interface WorkflowRunRecord { + id: string; + name: string; + source: WorkflowRunSource; + scriptPath: string; + scriptHash: string; + workspaceRoot: string; + workspaceId?: string; + argsJson: string; + phases?: WorkflowPhaseMeta[]; + status: WorkflowRunStatus; + error?: string; + errorKind?: WorkflowErrorKind; + resultJson?: string; + pid?: number; + heartbeatAt?: string; + cancelRequested: boolean; + resumedFromRunId?: string; + /** Pinned at run start for isolation: worktree reproducibility. */ + baseSha?: string; + createdAt: string; + startedAt?: string; + completedAt?: string; + updatedAt: string; +} + +export interface WorkflowTokenUsage { + inputTokens?: number; + cachedInputTokens?: number; + cacheCreationInputTokens?: number; + outputTokens?: number; + totalTokens: number; + state: "partial" | "final"; + updatedAt: string; +} + +export type WorkflowAgentActivityKind = "tool" | "command" | "file" | "status"; +export type WorkflowAgentActivityStatus = "running" | "completed" | "failed"; + +export interface WorkflowAgentActivityRecord { + runId: string; + callIndex: number; + seq: number; + kind: WorkflowAgentActivityKind; + status: WorkflowAgentActivityStatus; + label: string; + detail?: string; + startedAt?: string; + completedAt?: string; + createdAt: string; +} + +export interface WorkflowEventRecord { + runId: string; + seq: number; + type: WorkflowEventType; + phase?: string; + label?: string; + dataJson: string; + createdAt: string; +} + +export interface WorkflowAgentCallRecord { + runId: string; + callIndex: number; + cacheKey: string; + prompt: string; + schemaJson?: string; + provider: AgentProviderId; + model?: string; + effort?: string; + profileName?: string; + profileFingerprint?: string; + label?: string; + phase?: string; + status: WorkflowAgentCallStatus; + fromCache: boolean; + providerSessionId?: string; + usage?: WorkflowTokenUsage; + responseText?: string; + structuredJson?: string; + returnValueJson?: string; + error?: string; + errorKind?: WorkflowErrorKind; + replayMatch?: "same_index"; + replayedFromRunId?: string; + replayedFromCallIndex?: number; + replayReason?: string; + isolation: AgentIsolationMode; + worktreePath?: string; + dirty?: boolean; + createdAt: string; + startedAt?: string; + completedAt?: string; + updatedAt: string; +} + +// --------------------------------------------------------------------------- +// Cache key +// --------------------------------------------------------------------------- + +/** + * Canonical fields for agent() resume identity. + * Field order for JSON serialization is fixed by buildAgentCacheKeyInput. + */ +export interface AgentCacheKeyInput { + prompt: string; + profileName: string | null; + profileFingerprint: string | null; + provider: AgentProviderId; + model: string | null; + effort: string | null; + schema: JsonSchema | null; + isolation: AgentIsolationMode; +} + +export function buildAgentCacheKeyInput(input: { + prompt: string; + profileName?: string | null; + profileFingerprint?: string | null; + provider: AgentProviderId; + model?: string | null; + effort?: string | null; + schema?: JsonSchema | null; + isolation?: AgentIsolationMode | "worktree" | null; +}): AgentCacheKeyInput { + const isolation: AgentIsolationMode = + input.isolation === "worktree" ? "worktree" : "shared"; + return { + prompt: input.prompt, + profileName: input.profileName ?? null, + profileFingerprint: input.profileFingerprint ?? null, + provider: input.provider, + model: input.model ?? null, + effort: input.effort ?? null, + schema: input.schema ?? null, + isolation, + }; +} + +// --------------------------------------------------------------------------- +// Budget stub (CC-shaped) +// --------------------------------------------------------------------------- + +export interface WorkflowBudget { + readonly total: number | null; + spent(): number; + remaining(): number; +} + +export function createStubBudget(): WorkflowBudget { + return Object.freeze({ + total: null, + spent(): number { + return 0; + }, + remaining(): number { + return Infinity; + }, + }); +} + +// --------------------------------------------------------------------------- +// Concurrency helper +// --------------------------------------------------------------------------- + +export function defaultWorkflowConcurrency(availableParallelism: number): number { + return Math.min(16, Math.max(1, availableParallelism - 2)); +} + +export function resolveWorkflowConcurrency( + metaConcurrency: number | undefined, + availableParallelism: number, +): number { + const base = defaultWorkflowConcurrency(availableParallelism); + if (metaConcurrency === undefined || !Number.isFinite(metaConcurrency)) return base; + const n = Math.floor(metaConcurrency); + if (n < 1) return 1; + return Math.min(base, n); +} diff --git a/src/workflow-view.test.ts b/src/workflow-view.test.ts new file mode 100644 index 00000000..0baf3c26 --- /dev/null +++ b/src/workflow-view.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { buildWorkflowRunView } from "./workflow-view.js"; +import type { + WorkflowAgentCallRecord, + WorkflowEventRecord, + WorkflowRunRecord, +} from "./workflow-types.js"; + +const run: WorkflowRunRecord = { + id: "wfr_view", + name: "Review auth", + source: "named", + scriptPath: "/tmp/review-auth.js", + scriptHash: "abc", + workspaceRoot: "/tmp/project", + argsJson: "null", + status: "running", + phases: [ + { title: "Planning" }, + { title: "Implementation", detail: "Patch the approved plan" }, + { title: "Verification" }, + ], + cancelRequested: false, + createdAt: "2026-07-26T10:00:00.000Z", + startedAt: "2026-07-26T10:00:01.000Z", + updatedAt: "2026-07-26T10:00:05.000Z", +}; + +const calls: WorkflowAgentCallRecord[] = [ + { + runId: run.id, + callIndex: 0, + cacheKey: "a", + prompt: "Inspect auth", + provider: "codex", + label: "Inspect auth", + phase: "Planning", + status: "completed", + fromCache: false, + isolation: "shared", + createdAt: "2026-07-26T10:00:02.000Z", + startedAt: "2026-07-26T10:00:02.000Z", + completedAt: "2026-07-26T10:00:03.000Z", + updatedAt: "2026-07-26T10:00:03.000Z", + }, + { + runId: run.id, + callIndex: 1, + cacheKey: "b", + prompt: "Patch auth", + provider: "claude", + label: "Patch auth", + phase: "Implementation", + status: "running", + fromCache: false, + isolation: "worktree", + usage: { + inputTokens: 1_000, + outputTokens: 500, + totalTokens: 1_500, + state: "partial", + updatedAt: "2026-07-26T10:00:04.000Z", + }, + worktreePath: "/tmp/worktree", + createdAt: "2026-07-26T10:00:04.000Z", + startedAt: "2026-07-26T10:00:04.000Z", + updatedAt: "2026-07-26T10:00:04.000Z", + }, + { + runId: run.id, + callIndex: 2, + cacheKey: "c", + prompt: "Cached review", + provider: "claude", + status: "from_cache", + fromCache: true, + replayMatch: "same_index", + replayedFromRunId: "wfr_old", + replayedFromCallIndex: 2, + isolation: "shared", + createdAt: "2026-07-26T10:00:04.000Z", + completedAt: "2026-07-26T10:00:04.000Z", + updatedAt: "2026-07-26T10:00:04.000Z", + }, +]; + +const events: WorkflowEventRecord[] = [ + { + runId: run.id, + seq: 1, + type: "phase_started", + phase: "Planning", + dataJson: JSON.stringify({ title: "Planning" }), + createdAt: "2026-07-26T10:00:01.000Z", + }, + { + runId: run.id, + seq: 2, + type: "phase_started", + phase: "Implementation", + dataJson: JSON.stringify({ title: "Implementation" }), + createdAt: "2026-07-26T10:00:04.000Z", + }, + { + runId: run.id, + seq: 3, + type: "log", + phase: "Implementation", + dataJson: JSON.stringify({ message: "Running tests" }), + createdAt: "2026-07-26T10:00:05.000Z", + }, +]; + +const view = buildWorkflowRunView(run, calls, events); +assert.equal(view.currentPhase, "Implementation"); +assert.equal(view.calls.completed, 1); +assert.equal(view.calls.running, 1); +assert.equal(view.calls.cached, 1); +assert.equal(view.calls.observed, 3); +assert.equal(view.totalTokens, 1_500); +assert.deepEqual(view.phases.map((phase) => phase.title), ["Planning", "Implementation", "Verification"]); +assert.deepEqual(view.phases.map((phase) => phase.status), ["completed", "running", "not_started"]); +assert.equal(view.phases[1]?.detail, "Patch the approved plan"); +assert.equal(view.phases[1]?.calls[0]?.worktreePath, "/tmp/worktree"); +assert.equal(view.unphasedCalls[0]?.replayedFromRunId, "wfr_old"); +assert.equal(view.recentActivity.at(-1)?.detail, "Running tests"); +assert.equal(view.latestEventSeq, 3); + +console.log("workflow-view.test.ts: ok"); diff --git a/src/workflow-view.ts b/src/workflow-view.ts new file mode 100644 index 00000000..d89e8785 --- /dev/null +++ b/src/workflow-view.ts @@ -0,0 +1,317 @@ +import { resolve } from "node:path"; +import { parseWorkflowEventPayload } from "./workflow-contracts.js"; +import type { WorkflowStore } from "./workflow-store.js"; +import type { + WorkflowAgentCallRecord, + WorkflowAgentCallStatus, + WorkflowErrorKind, + WorkflowEventRecord, + WorkflowEventType, + WorkflowRunRecord, + WorkflowRunSource, + WorkflowRunStatus, + WorkflowTokenUsage, + WorkflowAgentActivityRecord, +} from "./workflow-types.js"; + +export const ACTIVE_WORKFLOW_STATUSES = ["starting", "running"] as const satisfies readonly WorkflowRunStatus[]; + +export interface WorkflowCallCounts { + running: number; + completed: number; + cached: number; + failed: number; + cancelled: number; + observed: number; +} + +export interface WorkflowCallView { + callIndex: number; + status: WorkflowAgentCallStatus; + provider: string; + model?: string; + effort?: string; + label?: string; + phase?: string; + isolation: "shared" | "worktree"; + worktreePath?: string; + dirty?: boolean; + fromCache: boolean; + replayMatch?: "same_index"; + replayedFromRunId?: string; + replayedFromCallIndex?: number; + replayReason?: string; + error?: string; + errorKind?: WorkflowErrorKind; + providerSessionId?: string; + usage?: WorkflowTokenUsage; + prompt: string; + responseText?: string; + structuredJson?: string; + returnValueJson?: string; + startedAt?: string; + completedAt?: string; + updatedAt: string; +} + +export interface WorkflowPhaseView { + title: string; + detail?: string; + status: "not_started" | "running" | "completed" | "failed" | "cancelled"; + calls: WorkflowCallView[]; +} + +export interface WorkflowActivityView { + seq: number; + type: WorkflowEventType; + phase?: string; + label?: string; + detail?: string; + createdAt: string; +} + +export interface WorkflowRunView { + id: string; + name: string; + status: WorkflowRunStatus; + source: WorkflowRunSource; + scriptPath: string; + scriptHash: string; + workspaceRoot: string; + resumedFromRunId?: string; + currentPhase?: string; + calls: WorkflowCallCounts; + totalTokens: number; + phases: WorkflowPhaseView[]; + unphasedCalls: WorkflowCallView[]; + recentActivity: WorkflowActivityView[]; + latestEventSeq: number; + version: string; + error?: string; + errorKind?: WorkflowErrorKind; + createdAt: string; + startedAt?: string; + completedAt?: string; + updatedAt: string; +} + +export interface WorkflowProjectView { + workspaceRoot: string; + runs: WorkflowRunView[]; + version: string; +} + +export interface WorkflowCallInspectorView { + run: WorkflowRunView; + call: WorkflowCallView; + activity: WorkflowAgentActivityRecord[]; +} + +export function loadWorkflowProjectView( + store: WorkflowStore, + workspaceRoot: string, + options: { + statuses?: WorkflowRunStatus[]; + limit?: number; + eventLimit?: number; + } = {}, +): WorkflowProjectView { + const root = resolve(workspaceRoot); + const runs = store + .listRunsForWorkspace(root, { + statuses: options.statuses, + limit: options.limit, + }) + .map((run) => + buildWorkflowRunView( + run, + store.listAgentCalls(run.id), + store.listEvents(run.id, options.eventLimit ?? 100), + ), + ); + + return { + workspaceRoot: root, + runs, + version: runs.map((run) => `${run.id}:${run.version}`).join("|"), + }; +} + +export function buildWorkflowRunView( + run: WorkflowRunRecord, + calls: WorkflowAgentCallRecord[], + events: WorkflowEventRecord[], +): WorkflowRunView { + const callViews = calls.map(toCallView); + const phaseOrder: string[] = []; + let currentPhase: string | undefined; + + for (const event of events) { + if (event.type !== "phase_started") continue; + const title = event.phase ?? parsePhaseTitle(event); + if (!title) continue; + currentPhase = title; + if (!phaseOrder.includes(title)) phaseOrder.push(title); + } + for (const call of callViews) { + if (call.phase && !phaseOrder.includes(call.phase)) phaseOrder.push(call.phase); + } + + const declaredPhases = run.phases ?? []; + for (const phase of declaredPhases) { + if (!phaseOrder.includes(phase.title)) phaseOrder.push(phase.title); + } + phaseOrder.sort((left, right) => { + const leftDeclared = declaredPhases.findIndex((phase) => phase.title === left); + const rightDeclared = declaredPhases.findIndex((phase) => phase.title === right); + if (leftDeclared < 0 && rightDeclared < 0) return 0; + if (leftDeclared < 0) return 1; + if (rightDeclared < 0) return -1; + return leftDeclared - rightDeclared; + }); + const currentPhaseIndex = currentPhase ? phaseOrder.indexOf(currentPhase) : -1; + const phases = phaseOrder.map((title, index) => ({ + title, + detail: declaredPhases.find((phase) => phase.title === title)?.detail, + status: phaseStatus(run.status, index, currentPhaseIndex), + calls: callViews.filter((call) => call.phase === title), + })); + const latestEventSeq = events.at(-1)?.seq ?? 0; + const latestCallUpdate = calls.reduce( + (latest, call) => call.updatedAt > latest ? call.updatedAt : latest, + run.updatedAt, + ); + + return { + id: run.id, + name: run.name, + status: run.status, + source: run.source, + scriptPath: run.scriptPath, + scriptHash: run.scriptHash, + workspaceRoot: run.workspaceRoot, + resumedFromRunId: run.resumedFromRunId, + currentPhase, + calls: countCalls(callViews), + totalTokens: callViews.reduce( + (total, call) => total + (call.fromCache ? 0 : call.usage?.totalTokens ?? 0), + 0, + ), + phases, + unphasedCalls: callViews.filter((call) => !call.phase), + recentActivity: events.map(toActivityView), + latestEventSeq, + version: `${run.updatedAt}:${latestCallUpdate}:${latestEventSeq}`, + error: run.error, + errorKind: run.errorKind, + createdAt: run.createdAt, + startedAt: run.startedAt, + completedAt: run.completedAt, + updatedAt: run.updatedAt, + }; +} + +function toCallView(call: WorkflowAgentCallRecord): WorkflowCallView { + return { + callIndex: call.callIndex, + status: call.status, + provider: call.provider, + model: call.model, + effort: call.effort, + label: call.label, + phase: call.phase, + isolation: call.isolation, + worktreePath: call.worktreePath, + dirty: call.dirty, + fromCache: call.fromCache, + replayMatch: call.replayMatch, + replayedFromRunId: call.replayedFromRunId, + replayedFromCallIndex: call.replayedFromCallIndex, + replayReason: call.replayReason, + error: call.error, + errorKind: call.errorKind, + providerSessionId: call.providerSessionId, + usage: call.usage, + prompt: call.prompt, + responseText: call.responseText, + structuredJson: call.structuredJson, + returnValueJson: call.returnValueJson, + startedAt: call.startedAt, + completedAt: call.completedAt, + updatedAt: call.updatedAt, + }; +} + +function phaseStatus( + runStatus: WorkflowRunStatus, + index: number, + currentIndex: number, +): WorkflowPhaseView["status"] { + if (currentIndex < 0) { + return runStatus === "completed" ? "completed" : "not_started"; + } + if (index < currentIndex) return "completed"; + if (index > currentIndex) return "not_started"; + if (runStatus === "failed") return "failed"; + if (runStatus === "cancelled") return "cancelled"; + if (runStatus === "completed") return "completed"; + return "running"; +} + +function countCalls(calls: WorkflowCallView[]): WorkflowCallCounts { + const counts: WorkflowCallCounts = { + running: 0, + completed: 0, + cached: 0, + failed: 0, + cancelled: 0, + observed: calls.length, + }; + for (const call of calls) { + if (call.status === "running") counts.running += 1; + else if (call.status === "completed") counts.completed += 1; + else if (call.status === "from_cache") counts.cached += 1; + else if (call.status === "failed") counts.failed += 1; + else if (call.status === "cancelled") counts.cancelled += 1; + } + return counts; +} + +function toActivityView(event: WorkflowEventRecord): WorkflowActivityView { + return { + seq: event.seq, + type: event.type, + phase: event.phase, + label: event.label, + detail: activityDetail(event), + createdAt: event.createdAt, + }; +} + +function activityDetail(event: WorkflowEventRecord): string | undefined { + try { + if (event.type === "log") { + return parseWorkflowEventPayload("log", JSON.parse(event.dataJson) as unknown).message; + } + if (event.type === "agent_call_failed") { + return parseWorkflowEventPayload( + "agent_call_failed", + JSON.parse(event.dataJson) as unknown, + ).error; + } + } catch { + return undefined; + } + return undefined; +} + +function parsePhaseTitle(event: WorkflowEventRecord): string | undefined { + try { + return parseWorkflowEventPayload( + "phase_started", + JSON.parse(event.dataJson) as unknown, + ).title; + } catch { + return undefined; + } +} diff --git a/src/workflow-worker.ts b/src/workflow-worker.ts new file mode 100644 index 00000000..666858a2 --- /dev/null +++ b/src/workflow-worker.ts @@ -0,0 +1,322 @@ +import { spawn } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { availableParallelism } from "node:os"; +import type { ServerConfig } from "./config.js"; +import { parseJsonText, type JsonValue } from "./json-types.js"; +import { createLocalAgentClient, type LocalAgentClient } from "./local-agent-client.js"; +import { createWorkflowAgentObserver } from "./workflow-agent-observer.js"; +import { agentErrorFromPayload } from "./local-agent-errors.js"; +import { + isLocalAgentProvider, + loadLocalAgentProfiles, +} from "./local-agent-profiles.js"; +import { executeWorkflow, mapEngineErrorKind } from "./workflow-engine.js"; +import { + readProjectWorkflowScriptFileResult, + resolveNamedWorkflowScriptResult, +} from "./workflow-files.js"; +import { createWorkflowReplay } from "./workflow-replay.js"; +import { parseWorkflowScript } from "./workflow-script.js"; +import { createWorkflowStore } from "./workflow-store.js"; +import { + WORKFLOW_HEARTBEAT_MS, + WORKFLOW_LIMITS, + resolveWorkflowConcurrency, +} from "./workflow-types.js"; +import { WorkflowStoredDataError } from "./workflow-errors.js"; +import { createWorkflowWorktreeFactory } from "./workflow-worktrees.js"; +import { resolveWorkflowLiveProviders } from "./workflow-providers.js"; +import type { LocalAgentRecord, LocalAgentWorkspaceScope } from "./local-agent-store.js"; +import type { LocalAgentActivity, LocalAgentUsageSnapshot } from "./local-agent-runtime.js"; + +/** Detached worker entry: claim run, heartbeat, execute, complete/fail. */ +export async function runWorkflowWorker( + args: string[], + config: ServerConfig, +): Promise { + const runId = args[0]; + if (!runId) throw new Error("Usage: devspace workflow __worker "); + + const store = createWorkflowStore(config); + const claim = store.claimRunResult(runId, process.pid); + if (claim.isErr()) { + store.close(); + throw claim.error; + } + const claimed = claim.value; + + const abort = new AbortController(); + const heartbeat = setInterval(() => { + try { + store.setHeartbeat(runId); + if (store.isCancelRequested(runId)) abort.abort(); + } catch { + // store closed + } + }, WORKFLOW_HEARTBEAT_MS); + + try { + const source = await readFile(claimed.scriptPath, "utf8"); + const parsed = parseWorkflowScript(source, { filename: claimed.scriptPath }); + const availableProviders = resolveWorkflowLiveProviders(config); + const agentProfiles = await loadLocalAgentProfiles(config, claimed.workspaceRoot); + const concurrency = resolveWorkflowConcurrency( + parsed.meta.concurrency, + availableParallelism(), + ); + + let argsValue: JsonValue | undefined; + try { + argsValue = parseJsonText(claimed.argsJson); + if (argsValue === null) argsValue = undefined; + } catch (cause) { + throw new WorkflowStoredDataError(`${claimed.id}.argsJson`, cause); + } + + const replay = claimed.resumedFromRunId + ? createWorkflowReplay(store.listAgentCalls(claimed.resumedFromRunId)) + : undefined; + + const createWorktree = createWorkflowWorktreeFactory({ + worktreeRoot: config.worktreeRoot, + allowedRoots: config.allowedRoots, + }); + const agentClient = createLocalAgentClient(config); + const workflowAgentsBySession = new Map(); + + const { result, callCount } = await executeWorkflow({ + parsed, + runId, + journal: store, + args: argsValue, + concurrency, + signal: abort.signal, + workspaceRoot: claimed.workspaceRoot, + baseSha: claimed.baseSha, + availableProviders, + agentProfiles, + createWorktree, + replay, + runProvider: async (input) => { + if (!isLocalAgentProvider(input.provider)) { + throw new Error(`Unknown provider: ${input.provider}`); + } + if (abort.signal.aborted || store.isCancelRequested(runId)) { + throw Object.assign(new Error("Workflow cancelled"), { name: "AbortError" }); + } + const observer = createWorkflowAgentObserver(store, runId, input.callIndex); + const scope: LocalAgentWorkspaceScope = { + workspaceId: claimed.workspaceId, + workspaceRoot: input.workspace, + }; + const startedAt = new Date().toISOString(); + observer.onActivity?.({ + kind: "status", + status: "running", + label: `${input.provider} subagent turn`, + startedAt, + }); + try { + const existingAgentId = input.providerSessionId + ? workflowAgentsBySession.get(input.providerSessionId) + : undefined; + const started = existingAgentId + ? await agentClient.continue(existingAgentId, input.prompt, { + model: input.model, + effort: input.effort, + writeMode: "allowed", + }, scope) + : await agentClient.start({ + target: input.provider, + prompt: input.prompt, + workspaceRoot: input.workspace, + workspaceId: claimed.workspaceId, + model: input.model, + effort: input.effort, + writeMode: "allowed", + }); + if (started.isErr()) throw started.error; + + const completed = await waitForWorkflowAgent({ + client: agentClient, + initial: started.value, + scope, + signal: abort.signal, + isCancelled: () => store.isCancelRequested(runId), + onSession: (providerSessionId) => { + workflowAgentsBySession.set(providerSessionId, started.value.id); + observer.onSession?.(providerSessionId); + }, + onUsage: (usage) => observer.onUsage?.(usage), + onActivity: (activity) => observer.onActivity?.(activity), + }); + observer.onActivity?.({ + kind: "status", + status: "completed", + label: `${input.provider} subagent turn`, + startedAt, + completedAt: new Date().toISOString(), + }); + return { + finalResponse: completed.latestResponse ?? "", + providerSessionId: completed.providerSessionId, + }; + } catch (error) { + observer.onActivity?.({ + kind: "status", + status: "failed", + label: `${input.provider} subagent turn`, + startedAt, + completedAt: new Date().toISOString(), + }); + throw error; + } finally { + observer.close(); + } + }, + resolveNestedSource: async (ref) => { + if (typeof ref === "string") { + const named = await resolveNamedWorkflowScriptResult({ + name: ref, + workspaceRoot: claimed.workspaceRoot, + stateDir: config.stateDir, + }); + if (named.isErr()) throw named.error; + return named.value.source; + } + const nested = await readProjectWorkflowScriptFileResult({ + scriptPath: ref.scriptPath, + workspaceRoot: claimed.workspaceRoot, + }); + if (nested.isErr()) throw nested.error; + return nested.value.source; + }, + }); + + if (abort.signal.aborted || store.isCancelRequested(runId)) { + store.cancelRun(runId); + return; + } + + let resultJson: string | undefined; + if (result !== undefined) { + resultJson = JSON.stringify(result); + if (Buffer.byteLength(resultJson, "utf8") > WORKFLOW_LIMITS.resultJsonBytes) { + store.failRun(runId, { + error: `result exceeds ${WORKFLOW_LIMITS.resultJsonBytes} bytes`, + errorKind: "result_too_large", + }); + return; + } + } + + store.completeRun(runId, { resultJson, callCount }); + } catch (error) { + if (store.isCancelRequested(runId) || abort.signal.aborted) { + try { + store.cancelRun(runId); + } catch { + // already terminal + } + return; + } + const message = error instanceof Error ? error.message : String(error); + const errorKind = mapEngineErrorKind(error); + try { + store.failRun(runId, { error: message, errorKind }); + } catch { + // terminal race + } + } finally { + clearInterval(heartbeat); + store.close(); + } +} + +export function spawnWorkflowWorker(runId: string, cliEntry: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [...process.execArgv, cliEntry, "workflow", "__worker", runId], + { + detached: true, + stdio: "ignore", + env: process.env, + }, + ); + child.once("error", reject); + child.once("spawn", () => { + child.unref(); + resolve(); + }); + }); +} + +/** @deprecated Use spawnWorkflowWorker */ +export const spawnWorkflowWorkerFromCli = spawnWorkflowWorker; + +async function waitForWorkflowAgent(input: { + client: LocalAgentClient; + initial: LocalAgentRecord; + scope: LocalAgentWorkspaceScope; + signal: AbortSignal; + isCancelled: () => boolean; + onSession: (providerSessionId: string) => void; + onUsage: (usage: LocalAgentUsageSnapshot) => void; + onActivity: (activity: LocalAgentActivity) => void; +}): Promise { + let record = input.initial; + let activityCursor = 0; + let usageKey: string | undefined; + for (;;) { + if (activityCursor > (record.activity?.length ?? 0)) activityCursor = 0; + for (const activity of record.activity?.slice(activityCursor) ?? []) { + input.onActivity(activity); + } + activityCursor = record.activity?.length ?? 0; + if (record.usage) { + const nextUsageKey = JSON.stringify(record.usage); + if (nextUsageKey !== usageKey) { + input.onUsage(record.usage); + usageKey = nextUsageKey; + } + } + if (record.providerSessionId) input.onSession(record.providerSessionId); + if (record.status === "idle") { + if (record.latestResponse === undefined) { + throw new Error(`Subagent ${record.id} completed without a response.`); + } + return record; + } + if (record.status === "error") throw agentRecordError(record); + if (record.status === "stopped") { + throw Object.assign(new Error(`Subagent ${record.id} was stopped.`), { name: "AbortError" }); + } + if (input.signal.aborted || input.isCancelled()) { + await input.client.cancel(record.id, input.scope); + throw Object.assign(new Error("Workflow cancelled"), { name: "AbortError" }); + } + await delay(250); + const refreshed = await input.client.get(record.id, input.scope); + if (refreshed.isErr()) throw refreshed.error; + record = refreshed.value; + } +} + +function agentRecordError(record: LocalAgentRecord): Error { + const typed = record.errorCode + ? agentErrorFromPayload({ + code: record.errorCode, + message: record.error ?? `Subagent ${record.id} failed.`, + retryable: record.errorRetryable, + provider: record.provider, + agentId: record.id, + operation: "workflow.agent", + }) + : undefined; + return typed ?? new Error(record.error ?? `Subagent ${record.id} failed.`); +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/src/workflow-worktrees.ts b/src/workflow-worktrees.ts new file mode 100644 index 00000000..1ccf6089 --- /dev/null +++ b/src/workflow-worktrees.ts @@ -0,0 +1,204 @@ +import { execFile } from "node:child_process"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { Result, type Result as BetterResult } from "better-result"; +import type { CreateAgentWorktree, WorkflowWorktreeHandle } from "./workflow-api.js"; +import { WorktreeOperationError } from "./workflow-errors.js"; + +const execFileAsync = promisify(execFile); + +export interface WorkflowWorktreeHost { + worktreeRoot: string; + /** When set, assert worktree paths stay under this root. */ + allowedRoots?: string[]; +} + +/** + * Create a CreateAgentWorktree bound to host config. + * Layout: `/wf//c/` + */ +export function createWorkflowWorktreeFactory( + host: WorkflowWorktreeHost, +): CreateAgentWorktree { + return async (input) => { + const result = await createWorkflowWorktreeResult(host, input); + if (result.isErr()) throw result.error; + return result.value; + }; +} + +export async function createWorkflowWorktreeResult( + host: WorkflowWorktreeHost, + input: Parameters[0], +): Promise> { + return Result.tryPromise({ + try: async () => { + const path = join(host.worktreeRoot, "wf", input.runId, `c${input.callIndex}`); + await mkdir(join(host.worktreeRoot, "wf", input.runId), { recursive: true }); + + let sourceRoot: string; + try { + sourceRoot = ( + await git(["rev-parse", "--show-toplevel"], input.workspaceRoot) + ).trim(); + } catch (error) { + if (isGitUnavailable(error)) { + throw new Error("isolation: 'worktree' requires Git on PATH", { cause: error }); + } + throw new Error( + `isolation: 'worktree' requires a Git repository (not found at ${input.workspaceRoot})`, + { cause: error }, + ); + } + + const baseSha = + input.baseSha ?? + (await git(["rev-parse", "--verify", "HEAD^{commit}"], sourceRoot)).trim(); + + try { + await git(["worktree", "add", "--detach", path, baseSha], sourceRoot); + } catch (error) { + try { + await rm(path, { recursive: true, force: true }); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "Failed to create and clean up agent worktree", + ); + } + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to create agent worktree: ${message}`, { cause: error }); + } + + return createHandle({ path, sourceRoot }); + }, + catch: (cause) => + new WorktreeOperationError({ + operation: "create", + runId: input.runId, + callIndex: input.callIndex, + cause, + }), + }); +} + +function createHandle(input: { + path: string; + sourceRoot: string; +}): WorkflowWorktreeHandle { + return { + path: input.path, + finalize: async (outcome) => { + const dirtyResult = await isDirtyResult(input.path); + if (dirtyResult.isErr()) throw dirtyResult.error; + const dirty = dirtyResult.value; + if (outcome === "success" && !dirty) { + const removed = await removeWorktreeResult(input.sourceRoot, input.path); + if (removed.isErr()) throw removed.error; + return { dirty: false, removed: true }; + } + // Preserve dirty or failed worktrees for diagnosis. + return { dirty, removed: false }; + }, + }; +} + +export async function isDirty(worktreePath: string): Promise { + const result = await isDirtyResult(worktreePath); + return result.isOk() ? result.value : true; +} + +export async function isDirtyResult( + worktreePath: string, +): Promise> { + return Result.tryPromise({ + try: async () => { + const status = (await git(["status", "--porcelain=v1"], worktreePath)).trim(); + return status.length > 0; + }, + catch: (cause) => + new WorktreeOperationError({ + operation: "inspect", + path: worktreePath, + cause, + }), + }); +} + +export async function removeWorktree( + sourceRoot: string, + worktreePath: string, +): Promise { + const result = await removeWorktreeResult(sourceRoot, worktreePath); + if (result.isErr()) throw result.error; +} + +export async function removeWorktreeResult( + sourceRoot: string, + worktreePath: string, +): Promise> { + return Result.tryPromise({ + try: async () => { + try { + await git(["worktree", "remove", "--force", worktreePath], sourceRoot); + } catch (removeError) { + await rm(worktreePath, { recursive: true, force: true }); + try { + await git(["worktree", "prune"], sourceRoot); + } catch (pruneError) { + throw new AggregateError( + [removeError, pruneError], + "Worktree directory was removed but Git metadata pruning failed", + ); + } + } + }, + catch: (cause) => + new WorktreeOperationError({ + operation: "remove", + path: worktreePath, + cause, + }), + }); +} + +export async function resolveWorkspaceHead(workspaceRoot: string): Promise { + try { + return (await git(["rev-parse", "--verify", "HEAD^{commit}"], workspaceRoot)).trim(); + } catch { + return undefined; + } +} + +async function git(args: string[], cwd: string): Promise { + try { + const { stdout } = await execFileAsync("git", args, { + cwd, + maxBuffer: 10 * 1024 * 1024, + }); + return stdout; + } catch (error) { + if (isGitUnavailable(error)) throw error; + const stderr = + typeof error === "object" && error && "stderr" in error + ? String((error as { stderr?: unknown }).stderr ?? "").trim() + : ""; + const stdout = + typeof error === "object" && error && "stdout" in error + ? String((error as { stdout?: unknown }).stdout ?? "").trim() + : ""; + const details = + stderr || stdout || (error instanceof Error ? error.message : String(error)); + throw new Error(details); + } +} + +function isGitUnavailable(error: unknown): boolean { + return Boolean( + typeof error === "object" && + error && + "code" in error && + (error as { code?: unknown }).code === "ENOENT", + ); +}