From 9a8442500b1edaedc4372a363a18349a937fd09f Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 21 Jul 2026 12:10:26 +0530 Subject: [PATCH 001/132] docs: claude's dw for implementation inspiration --- docs/dynamic-workflow/claude/README.md | 47 ++++ docs/dynamic-workflow/claude/agent.md | 205 ++++++++++++++ docs/dynamic-workflow/claude/architecture.md | 101 +++++++ docs/dynamic-workflow/claude/cheatsheet.md | 154 +++++++++++ docs/dynamic-workflow/claude/concurrency.md | 191 +++++++++++++ .../dynamic-workflow/claude/control-and-io.md | 179 ++++++++++++ docs/dynamic-workflow/claude/lifecycle.md | 108 ++++++++ docs/dynamic-workflow/claude/limits.md | 76 ++++++ docs/dynamic-workflow/claude/opt-in.md | 67 +++++ docs/dynamic-workflow/claude/orchestration.md | 138 ++++++++++ docs/dynamic-workflow/claude/patterns.md | 254 ++++++++++++++++++ docs/dynamic-workflow/claude/primitives.md | 62 +++++ docs/dynamic-workflow/claude/resume.md | 94 +++++++ .../claude/script-contract.md | 145 ++++++++++ docs/dynamic-workflow/claude/usecases.md | 180 +++++++++++++ docs/dynamic-workflow/claude/workflow-tool.md | 139 ++++++++++ 16 files changed, 2140 insertions(+) create mode 100644 docs/dynamic-workflow/claude/README.md create mode 100644 docs/dynamic-workflow/claude/agent.md create mode 100644 docs/dynamic-workflow/claude/architecture.md create mode 100644 docs/dynamic-workflow/claude/cheatsheet.md create mode 100644 docs/dynamic-workflow/claude/concurrency.md create mode 100644 docs/dynamic-workflow/claude/control-and-io.md create mode 100644 docs/dynamic-workflow/claude/lifecycle.md create mode 100644 docs/dynamic-workflow/claude/limits.md create mode 100644 docs/dynamic-workflow/claude/opt-in.md create mode 100644 docs/dynamic-workflow/claude/orchestration.md create mode 100644 docs/dynamic-workflow/claude/patterns.md create mode 100644 docs/dynamic-workflow/claude/primitives.md create mode 100644 docs/dynamic-workflow/claude/resume.md create mode 100644 docs/dynamic-workflow/claude/script-contract.md create mode 100644 docs/dynamic-workflow/claude/usecases.md create mode 100644 docs/dynamic-workflow/claude/workflow-tool.md diff --git a/docs/dynamic-workflow/claude/README.md b/docs/dynamic-workflow/claude/README.md new file mode 100644 index 000000000..b9ddb3502 --- /dev/null +++ b/docs/dynamic-workflow/claude/README.md @@ -0,0 +1,47 @@ +# Claude Code Dynamic Workflows + +In-depth reference for Claude Code’s **dynamic workflow** system: the model-facing `Workflow` tool, the JavaScript script contract, every primitive injected into the script, resume/budget semantics, quality patterns, and how this enables a stronger model to orchestrate subagents one level above ordinary tool use. + +| Document | Contents | +|---|---| +| [Architecture](./architecture.md) | Three layers, control flow vs worker content, mental model | +| [Opt-in & Ultracode](./opt-in.md) | When the model may call Workflow; standing multi-agent mode | +| [Workflow tool API](./workflow-tool.md) | Tool inputs, return envelope, launch/iterate/named workflows | +| [Script contract](./script-contract.md) | `export const meta`, language rules, determinism bans | +| [Primitives overview](./primitives.md) | Map of all script hooks | +| [agent()](./agent.md) | Spawn API, schema, model/effort, worktree, agentType | +| [pipeline() & parallel()](./concurrency.md) | No-barrier default vs barrier; when each is correct | +| [phase, log, args, budget, workflow()](./control-and-io.md) | Progress UX, parameterization, token ceiling, nesting | +| [Limits & sandbox](./limits.md) | Concurrency caps, agent caps, script isolation | +| [Resume & journal](./resume.md) | `resumeFromRunId`, cache identity, `journal.jsonl` | +| [Quality patterns](./patterns.md) | Adversarial verify, judge panel, loop-until-dry, … | +| [Lifecycle & UX](./lifecycle.md) | Permission, `/workflows`, notifications, multi-phase | +| [One level above](./orchestration.md) | Bigger brain orchestrates smaller hands | +| [Use cases](./usecases.md) | Review fleets, migrations, research, self-repair | +| [Cheatsheet](./cheatsheet.md) | One-page API card | + +Related: standalone HTML overview at [`docs/claude-code-dynamic-workflows.html`](../../claude-code-dynamic-workflows.html). + +--- + +## One-sentence thesis + +**Deterministic control flow + stochastic workers + one orchestrator brain.** + +A single agent loop confuses *what to do next* with *how to do the work*. Dynamic workflows split them: the orchestrator model authors a short plain-JS script; the harness runs loops, conditionals, and fan-out as **code**; only `agent()` escapes into a model with tools. + +## Why it exists + +| Without workflows | With workflows | +|---|---| +| Fan-out is ad-hoc tool spam each turn | Fan-out is `parallel` / `pipeline` in a script | +| Verification is optional and forgettable | Verification is a stage in the graph | +| One context holds plan + all tool churn | Workers isolate tool churn; script aggregates returns | +| Scale = longer single conversation | Scale = fleet size × stages under budget | + +## Scope of this doc set + +- **In scope:** model-facing API as exposed by Claude Code ~2.1.x (`Workflow` / `RunWorkflow` tool, script primitives, opt-in, resume, budget, patterns). +- **Out of scope:** Anthropic product marketing, undocumented internal harness code, DevSpace’s separate durable workflow engine (see feature branches / other docs if present). + +Source basis: Claude Code Workflow tool description, session `workflows/scripts/*.js` examples, and engine rules encoded in the tool prompt (opt-in, ultracode, resume, concurrency). diff --git a/docs/dynamic-workflow/claude/agent.md b/docs/dynamic-workflow/claude/agent.md new file mode 100644 index 000000000..40fe3f97b --- /dev/null +++ b/docs/dynamic-workflow/claude/agent.md @@ -0,0 +1,205 @@ +# `agent()` + +The only primitive that spends model/tool budget on real work. Everything else in the script is control flow, UX, or nesting. + +## Signature + +```ts +agent( + prompt: string, + opts?: { + label?: string + phase?: string + schema?: object // JSON Schema + model?: string // e.g. session model ids / 'sonnet' | 'opus' | 'haiku' + effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max' + isolation?: 'worktree' + agentType?: string // registry name: 'general-purpose', 'code-reviewer', 'claude', … + } +): Promise +``` + +## Return value + +| Mode | Resolves to | +|---|---| +| No `schema` | Final assistant text (`string`) | +| With `schema` | **Validated object** matching the JSON Schema (StructuredOutput tool; model retries on mismatch) | +| User skip / terminal API death after retries | `null` | + +Always treat results as possibly null when fan-out is large: + +```js +const rows = (await parallel(tasks)).filter(Boolean) +``` + +## Semantics + +1. Spawns a **subagent** with its own tool loop (Read, Bash, Edit, …). +2. Subagents are instructed that their **final text/object is the return value** to the coordinator script — not a user-facing message. +3. Prompt should be **self-contained**: workers do not inherit the full main-session transcript. +4. Session-connected **MCP tools** are reachable via ToolSearch (on-demand schemas). Interactively authenticated MCP may be missing in headless/cron. +5. Errors in the agent path surface as `null` for that call in combinators that swallow rejections; check journals if results look empty ([resume](./resume.md)). + +## Options + +### `label` + +Short string for `/workflows` progress UI (e.g. `review:security`, `verify:src/auth.ts`). Does not affect model behavior. + +### `phase` + +Explicit progress group assignment. **Prefer this inside `pipeline` / `parallel` stages** to avoid races on the global `phase()` state. Same string → same group box. Should match titles in `meta.phases` when you want tidy UI. + +### `schema` + +JSON Schema object. Forces structured output: + +- Validation at the tool-call layer. +- `agent()` returns the object — no `JSON.parse` of prose. +- Composes with `agentType` (StructuredOutput instruction is appended to that agent’s system prompt). + +Example: + +```js +const FINDINGS = { + type: 'object', + properties: { + findings: { + type: 'array', + items: { + type: 'object', + properties: { + file: { type: 'string' }, + line: { type: 'integer' }, + issue: { type: 'string' }, + severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] }, + }, + required: ['file', 'line', 'issue', 'severity'], + additionalProperties: false, + }, + }, + summary: { type: 'string' }, + }, + required: ['findings', 'summary'], + additionalProperties: false, +} + +const result = await agent( + 'Review auth changes for session fixation. Read-only. Cite file:line.', + { + label: 'review:auth', + phase: 'Review', + schema: FINDINGS, + effort: 'medium', + } +) +// result.findings is already shaped +``` + +### `model` + +Override the model for this call. + +- **Default: omit** — inherit the main-loop / session model (almost always correct). +- Set only when you are confident a different tier fits (e.g. small model for mechanical map, large for hard judge). +- When unsure, omit. + +### `effort` + +Reasoning effort for this call: `'low' | 'medium' | 'high' | 'xhigh' | 'max'`. + +- Omit → inherit session effort. +- Use `'low'` for cheap mechanical stages (enumerate files, simple extract). +- Reserve higher tiers for hard verify / judge / design stages. + +### `isolation: 'worktree'` + +Runs the agent in a **fresh git worktree**. + +| Property | Detail | +|---|---| +| Cost | Expensive (~200–500ms setup + disk) per agent | +| When | **Only** when agents **mutate files in parallel** and would otherwise conflict | +| Cleanup | Auto-removed if unchanged | +| When not | Read-only review, single writer, sequential pipeline of mutators on one tree | + +### `agentType` + +Custom subagent from the **same registry as the Agent tool** (e.g. `general-purpose`, `code-reviewer`, `Explore`, project-defined types, or `claude` where configured). + +- Overrides the default workflow subagent personality/tools policy for that call. +- Composes with `schema`. + +## Prompting workers well + +Workers start with **only** the prompt you pass (+ profile/system for `agentType`). Patterns: + +**Implementation** + +```text +Goal: … +Context: … +Relevant files: … +Acceptance criteria: +- … +Rules: +- Keep changes focused +- Do not unrelated-refactor +- Report blockers clearly +``` + +**Read-only investigation** + +```text +Question: … +Scope: … +Rules: +- Do not modify files +- Cite paths and symbols +- Separate facts from guesses +``` + +**Structured judge / refuter** + +```text +Try to REFUTE: +Default to refuted=true if uncertain. +Return only via the schema fields. +``` + +Pass prior stage data by **embedding it in the prompt** (stringified structured JSON), not by shared mutable state — scripts have no shared worker memory beyond what you thread through returns. + +## Cost & altitude tips + +| Stage kind | Typical opts | +|---|---| +| Enumerate / map / extract | `effort: 'low'`, maybe smaller `model` | +| Implement / edit | inherit model; medium effort; `isolation: 'worktree'` if parallel | +| Review dimension | schema + medium effort | +| Adversarial judge | higher effort; schema; independent prompts | +| Final verify gates | low/medium; focused prompt | + +The orchestrator stays high-altitude by keeping **structure** in the script and **content** in workers. See [orchestration](./orchestration.md). + +## Null and failure hygiene + +```js +const reviews = await parallel([ + () => agent(p1, { schema: FINDINGS }), + () => agent(p2, { schema: FINDINGS }), + () => agent(p3, { schema: FINDINGS }), +]).then(xs => xs.filter(Boolean)) + +if (!reviews.length) { + log('all reviewers failed or were skipped') + return { confirmed: [], error: 'no_reviews' } +} +``` + +Before claiming “workflow returned empty,” read `transcriptDir/journal.jsonl` — cached or failed agents may explain it ([resume](./resume.md)). + +## Next + +- [pipeline & parallel](./concurrency.md) +- [Patterns](./patterns.md) diff --git a/docs/dynamic-workflow/claude/architecture.md b/docs/dynamic-workflow/claude/architecture.md new file mode 100644 index 000000000..180e2fb62 --- /dev/null +++ b/docs/dynamic-workflow/claude/architecture.md @@ -0,0 +1,101 @@ +# Architecture + +## Three layers + +``` +User intent + │ + ▼ +┌──────────────────────────────────────────┐ +│ 1. Coordinator (main session model) │ +│ · talks to user · scouts work-list │ +│ · authors / selects script │ +│ · calls Workflow({ script, args }) │ +│ · synthesizes return value for user │ +└───────────────────┬──────────────────────┘ + │ Workflow tool (async) + ▼ +┌──────────────────────────────────────────┐ +│ 2. Workflow engine (JS runtime) │ +│ · parses export const meta │ +│ · runs script in async context │ +│ · hosts agent/pipeline/parallel/… │ +│ · enforces concurrency & agent caps │ +│ · journals each agent() for resume │ +└───────────────────┬──────────────────────┘ + │ agent() × N + ┌───────────┼───────────┐ + ▼ ▼ ▼ +┌────────────┐ ┌────────────┐ ┌────────────┐ +│ Subagent A │ │ Subagent B │ │ Subagent C │ +│ own tools │ │ own tools │ │ own tools │ +│ optional │ │ schema / │ │ worktree / │ +│ return │ │ model / │ │ agentType │ +│ text|obj │ │ effort │ │ │ +└────────────┘ └────────────┘ └────────────┘ +``` + +### 1. Coordinator (main loop) + +- Owns the conversation with the human. +- Scouts the repo / discovers the work-list *before* orchestration when possible (hybrid default). +- Decides whether Workflow is allowed ([opt-in](./opt-in.md)). +- Authors or selects the script and `args`. +- Receives the script’s return value after completion and narrates / chains the next phase. + +The coordinator is the **only** place that should redesign the global plan. Workers execute units; they do not renegotiate the graph with each other. + +### 2. Workflow engine + +- Not another chat peer. It is a **small concurrent orchestration runtime**. +- Script language: plain JavaScript (not TypeScript). +- Injected APIs only: see [primitives](./primitives.md). +- Progress UI: `/workflows` groups agents by `phase` / `label`. +- Persistence: script path under session directory, `runId`, transcripts, `journal.jsonl`. + +### 3. Subagents + +- Full tool loops of their own (Read, Bash, Edit, MCP via ToolSearch, …). +- Final text **is** the return value (or a schema-validated object) — not a user-facing essay unless the prompt asks for one. +- Optional isolation (`isolation: 'worktree'`), model, effort, and `agentType` overrides per call. + +## Mental model + +```js +// Coordinator decides STRUCTURE +Workflow({ script, args }) + → JS engine runs control flow + → agent(prompt, opts) × N // workers decide CONTENT + → script return value +→ coordinator narrates to user +``` + +| Concern | Who owns it | +|---|---| +| User intent, product judgment | Coordinator | +| Graph shape (fan-out, verify, merge) | Script (authored by coordinator) | +| Tool use, file reads, edits | Subagents | +| Concurrency caps, resume cache, budget hard stop | Engine | +| Permission to multi-agent at all | User (opt-in / ultracode) | + +## Hybrid default + +You do **not** need the full orchestration shape before starting the *task*. You need it before the *orchestration step*: + +1. Scout inline (list files, scope diff, find call sites). +2. Build the work-list in the coordinator context. +3. Call `Workflow` to pipeline over that list. +4. Read the result; optionally chain another workflow for the next phase. + +For larger product work, prefer **several well-scoped workflows across turns** over one forever-script. + +## What is not a layer + +- Workers do not form a free-form multi-agent chat room. +- The script has no filesystem or network — it cannot “just run shell.” Escape is only `agent()` / nested `workflow()`. +- The Workflow tool returns **immediately** (async launch). Completion arrives via task notification; live progress is `/workflows`. + +## Next + +- [Opt-in & Ultracode](./opt-in.md) +- [Workflow tool API](./workflow-tool.md) diff --git a/docs/dynamic-workflow/claude/cheatsheet.md b/docs/dynamic-workflow/claude/cheatsheet.md new file mode 100644 index 000000000..7604ac572 --- /dev/null +++ b/docs/dynamic-workflow/claude/cheatsheet.md @@ -0,0 +1,154 @@ +# Cheatsheet + +One-page API card. Details in the linked docs. + +## Tool + +```js +Workflow({ + script?, // inline JS; must start with pure-literal meta + name?, // built-in or .claude/workflows/ + scriptPath?, // persisted path; wins over script/name + args?, // verbatim → global args (real JSON, not stringified) + resumeFromRunId?, // ^wf_[a-z0-9-]{6,}$ stop prior run first +}) +// need: script | name | scriptPath +// returns async launch: taskId, runId, scriptPath, transcriptDir, … +``` + +[workflow-tool.md](./workflow-tool.md) · [opt-in.md](./opt-in.md) + +## Script header + +```js +export const meta = { + name: '…', // required, pure literal + description: '…', // required — permission dialog + phases: [ // optional + { title: 'Scan', detail: '…', model: 'sonnet' }, + ], + // whenToUse?: '…' +} +// plain JS only — no TS types +// no Date.now / Math.random / bare new Date +``` + +[script-contract.md](./script-contract.md) + +## Primitives + +```ts +agent(prompt, { + label?, phase?, schema?, model?, effort?, + isolation?: 'worktree', agentType?, +}): Promise + +pipeline(items, stage1, stage2, …): Promise +// stage(prev, originalItem, index) — NO barrier between stages + +parallel(thunks: Array<() => Promise>): Promise +// BARRIER; slots null on error; never rejects + +phase(title: string): void +log(message: string): void + +args: any +budget: { total: number|null, spent(): number, remaining(): number } + +workflow(name | { scriptPath }, args?): Promise +// nest depth 1 only +``` + +[primitives.md](./primitives.md) · [agent.md](./agent.md) · [concurrency.md](./concurrency.md) · [control-and-io.md](./control-and-io.md) + +## Rules of thumb + +1. Default multi-stage → **`pipeline`**; barrier only for cross-item merge. +2. Always **`.filter(Boolean)`** on parallel / nullable agent results. +3. Prefer **`schema`** for structured handoffs. +4. Guard budget loops: **`budget.total && budget.remaining() > …`**. +5. No entropy in scripts (resume safety). +6. **`isolation: 'worktree'`** only for parallel mutators. +7. **`log()`** anything a silent cap would hide. +8. Hybrid: scout → Workflow → synthesize → maybe next phase. +9. Omit **`model`** unless tier fit is clear. +10. Dedup open-ended hunts against **`seen`**, not only confirmed. + +## Caps (engine) + +| Cap | Value | +|---|---| +| Concurrent agents | `min(16, cores-2)` / workflow (queue rest) | +| Lifetime agents | 1000 / run | +| Items / parallel|pipeline | 4096 | +| Nested workflow | depth 1 | +| Budget | hard throw when spent ≥ total | + +[limits.md](./limits.md) + +## Resume + +```js +// stop prior run, then: +Workflow({ + scriptPath, + resumeFromRunId: runId, + args: sameArgs, +}) +// longest unchanged agent() prefix → cache +// read transcriptDir/journal.jsonl if results look wrong +``` + +[resume.md](./resume.md) + +## Pattern stubs + +```js +// adversarial verify +const votes = await parallel(Array.from({ length: 3 }, () => () => + agent(`Refute: ${claim}. Default refuted=true if uncertain.`, { schema: V }) +)) +const ok = votes.filter(Boolean).filter(v => !v.refuted).length >= 2 + +// loop-until-budget +while (budget.total && budget.remaining() > 50_000) { + const r = await agent('…', { schema: S }) + /* accumulate */ log(`${budget.remaining()} left`) +} + +// canonical review pipeline +await pipeline( + DIMENSIONS, + d => agent(d.prompt, { phase: 'Review', schema: F }), + review => parallel(review.findings.map(f => () => + agent(`Verify: ${f.title}`, { phase: 'Verify', schema: V }) + .then(v => ({ ...f, verdict: v })) + )) +) +``` + +[patterns.md](./patterns.md) + +## Opt-in (must have one) + +- User said `ultracode` / session ultracode on +- User asked for workflow / fan-out / multi-agent orchestration +- Skill/command requires Workflow +- Named workflow requested + +Else: single `Agent` or ask. + +## Altitude + +| Layer | Owns | +|---|---| +| Orchestrator | Intent, graph, schemas, synthesis, user | +| Script | Loops, fan-out, votes, budget stops | +| Workers | Tools, content, optional worktree | +| Engine | Caps, journal, UI, permissions plumbing | + +[orchestration.md](./orchestration.md) · [usecases.md](./usecases.md) + +## Index + +[README](./README.md) · [Architecture](./architecture.md) · [Lifecycle](./lifecycle.md) diff --git a/docs/dynamic-workflow/claude/concurrency.md b/docs/dynamic-workflow/claude/concurrency.md new file mode 100644 index 000000000..bf67020ac --- /dev/null +++ b/docs/dynamic-workflow/claude/concurrency.md @@ -0,0 +1,191 @@ +# `pipeline()` & `parallel()` + +These two combinators are the heart of multi-agent structure. Using the wrong one wastes wall-clock or forces incorrect synchronization. + +## Quick contrast + +| | `pipeline` | `parallel` | +|---|---|---| +| Input | `items[]` + stage functions | `thunks[]` of `() => Promise` | +| Sync model | **No barrier** between stages | **Barrier** — wait for all thunks | +| Wall-clock | ≈ slowest **item chain** | ≈ slowest **thunk** (then next barrier stage) | +| Failure | Stage throw → that item becomes `null`, later stages skipped for it | Thunk throw / agent error → slot `null`; call never rejects | +| Default for multi-stage? | **Yes** | No — only when you need all results together | + +--- + +## `pipeline` + +### Signature + +```ts +pipeline( + items: any[], + stage1: (prev, originalItem, index) => any | Promise, + stage2?: (prev, originalItem, index) => any | Promise, + // ... +): Promise +``` + +### Semantics + +- Each **item** flows through **all stages independently**. +- Item A may be in stage 3 while item B is still in stage 1. +- Every stage receives `(prevResult, originalItem, index)`: + - Use `originalItem` / `index` to label work without stuffing identity only into stage-1 returns. +- A stage that **throws** drops that item to `null` and skips remaining stages for that item. +- Max items per call: **4096** (hard error if exceeded) — see [limits](./limits.md). + +### Canonical multi-stage pattern + +Review by dimension, then verify each finding **as soon as that dimension finishes** (not after all dimensions finish): + +```js +export const meta = { + name: 'review-changes', + description: 'Review changed files across dimensions, verify each finding', + phases: [{ title: 'Review' }, { title: 'Verify' }], +} + +const DIMENSIONS = [ + { key: 'bugs', prompt: '…' }, + { key: 'perf', prompt: '…' }, +] + +const results = await pipeline( + DIMENSIONS, + d => agent(d.prompt, { + label: `review:${d.key}`, + phase: 'Review', + schema: FINDINGS_SCHEMA, + }), + review => parallel( + review.findings.map(f => () => + agent(`Adversarially verify: ${f.title}`, { + label: `verify:${f.file}`, + phase: 'Verify', + schema: VERDICT_SCHEMA, + }).then(v => ({ ...f, verdict: v })) + ) + ) +) + +const confirmed = results + .flat() + .filter(Boolean) + .filter(f => f.verdict?.isReal) + +return { confirmed } +// Dimension "bugs" findings verify while "perf" is still reviewing. +``` + +### Transform inside a stage (no extra barrier) + +```js +// ❌ Smell: barrier only to flatten +const a = await parallel(items.map(i => () => agent(…))) +const b = a.filter(Boolean).flatMap(x => x.findings) +const c = await parallel(b.map(f => () => agent(verify(f)))) + +// ✅ Pipeline with transform in a stage +const c = await pipeline( + items, + i => agent(…), + r => r.findings, // pure transform + f => agent(verify(f), { schema: V }) // or map to parallel inside if many findings +) +``` + +If one item produces many findings, a stage may return `parallel(findings.map(...))` as in the canonical example. + +--- + +## `parallel` + +### Signature + +```ts +parallel(thunks: Array<() => Promise>): Promise +``` + +### Semantics + +- Runs thunks **concurrently**. +- **Barrier:** does not resolve until every thunk settles. +- A throwing thunk (or agent error) becomes **`null`** in that index — the `parallel` call **itself never rejects**. +- Always `.filter(Boolean)` before treating results as data. +- Same concurrency / item caps as overall engine ([limits](./limits.md)). + +### When a barrier is correct + +Use `parallel` (or a barrier between pipeline stages implemented via collecting all items) **only** when stage N needs **cross-item** context from **all** of stage N−1: + +1. **Dedup / merge** across the full set before expensive work. +2. **Early-exit** if total count is zero (“0 bugs → skip verification”). +3. Next prompt **references “the other findings”** for comparison. + +```js +// Correct barrier: need ALL findings before expensive verification +const all = await parallel( + DIMENSIONS.map(d => () => agent(d.prompt, { schema: FINDINGS_SCHEMA })) +) +const deduped = dedupeByFileAndLine( + all.filter(Boolean).flatMap(r => r.findings) +) +if (!deduped.length) { + log('0 findings — skip verify') + return { confirmed: [] } +} +const verified = await parallel( + deduped.map(f => () => agent(verifyPrompt(f), { schema: VERDICT_SCHEMA })) +) +``` + +### When a barrier is NOT justified + +| Bad reason | Do this instead | +|---|---| +| “I need to flatten/map/filter first” | Transform inside a `pipeline` stage | +| “Stages are conceptually separate” | `pipeline` already models separate stages without sync | +| “It’s cleaner code” | Barrier latency is real — if 5 finders run and the slowest is 3× the fastest, a barrier wastes most of the fast agents’ idle time | + +**Smell test:** if you wrote `parallel → transform → parallel` with no cross-item dependency, rewrite as `pipeline`. + +--- + +## Nested concurrency + +Stages of a `pipeline` may call `parallel` (per item). Outer `parallel` may launch whole pipelines. Nested `workflow()` shares the parent concurrency pool. + +```js +// Per-item: many judges after one finder +await pipeline( + targets, + t => agent(findPrompt(t), { schema: BUGS }), + found => parallel( + found.bugs.map(b => () => agent(judgePrompt(b), { schema: VERDICT })) + ) +) +``` + +## Concurrency cap interaction + +Only ~`min(16, cpu_cores - 2)` agents run at once per workflow; the rest **queue**. You can still pass large arrays — they complete, they just don’t all run simultaneously. See [limits](./limits.md). + +## Decision flowchart + +``` +Need multi-stage over a list? + │ + ├─ Does stage N need the FULL set from stage N-1? + │ yes → parallel (barrier) then next stage + │ no → pipeline(items, stage1, stage2, …) + │ + └─ Single fan-out, one stage only? + → parallel([() => agent…, …]) or pipeline(items, oneStage) +``` + +## Next + +- [phase, log, args, budget, workflow()](./control-and-io.md) +- [Patterns](./patterns.md) diff --git a/docs/dynamic-workflow/claude/control-and-io.md b/docs/dynamic-workflow/claude/control-and-io.md new file mode 100644 index 000000000..b347469db --- /dev/null +++ b/docs/dynamic-workflow/claude/control-and-io.md @@ -0,0 +1,179 @@ +# `phase`, `log`, `args`, `budget`, `workflow()` + +Progress UX, parameterization, token ceilings, and one-level nesting. + +--- + +## `phase` + +```ts +phase(title: string): void +``` + +- Starts a **progress group** in `/workflows`. +- Subsequent `agent()` calls **without** `opts.phase` group under this title. +- Titles should match `meta.phases[].title` exactly for clean UI; unmatched titles still get their own group. +- Inside concurrent stages, prefer **`opts.phase`** on each `agent()` to avoid races on the global phase state: + +```js +// Global phase — fine for sequential sections +phase('Implement') +await agent('…', { label: 'impl' }) + +// Concurrent — set phase per agent +await parallel([ + () => agent('…', { phase: 'Review', label: 'r1' }), + () => agent('…', { phase: 'Review', label: 'r2' }), +]) +``` + +`phase` is UX only — it does not create isolation, budget buckets, or barriers. + +--- + +## `log` + +```ts +log(message: string): void +``` + +- Emits a **narrator line** above the progress tree. +- Use for counts, early exits, dropped coverage, loop progress. + +**Rule: no silent caps.** If the workflow bounds coverage (top-N, sampling, “first 20 files”), `log()` what was dropped. Silent truncation reads as “we covered everything.” + +```js +if (files.length > 50) { + log(`scoping to first 50 of ${files.length} files`) + files = files.slice(0, 50) +} +``` + +--- + +## `args` + +```ts +args: any // Workflow({ args }) value, or undefined if omitted +``` + +### Rules + +1. Value is **verbatim** from the tool call. +2. Pass **real** JSON arrays/objects in the tool invocation — **not** a stringified JSON blob. + +```js +// ✅ +Workflow({ script, args: ['a.ts', 'b.ts'] }) +// in script: args.map(f => …) + +// ❌ +Workflow({ script, args: '["a.ts","b.ts"]' }) +// args is a string → args.map throws +``` + +3. Primary channel for **parameterizing** named workflows (research question, path list, config). +4. Primary channel for values that must stay **stable across resume** (fixed timestamps, seeds) — see [script determinism](./script-contract.md). + +```js +// script +const topic = args?.topic ?? 'authentication' +const files = args?.files ?? [] +await agent(`Review ${topic} in ${files.join(', ')}`, { schema: FINDINGS }) +``` + +--- + +## `budget` + +```ts +budget: { + total: number | null + spent(): number + remaining(): number // max(0, total - spent) or Infinity if no target +} +``` + +### Semantics + +| Field / method | Meaning | +|---|---| +| `total` | Turn token target from user “+500k”-style directives; `null` if unset | +| `spent()` | Output tokens spent this turn across **main loop + all workflows** (shared pool) | +| `remaining()` | `max(0, total - spent())`, or **`Infinity`** if no target | + +### Hard ceiling + +Once `spent()` reaches `total`, further **`agent()` calls throw**. This is not advisory. + +### Guard loops + +Without a target, `remaining()` is `Infinity` and a `while (budget.remaining() > …)` loop runs until the **1000-agent** lifetime cap. Always guard: + +```js +const bugs = [] +while (budget.total && budget.remaining() > 50_000) { + const result = await agent('Find bugs in this codebase.', { schema: BUGS_SCHEMA }) + bugs.push(...result.bugs) + log(`${bugs.length} found, ${Math.round(budget.remaining() / 1000)}k remaining`) +} +``` + +### Static fleet sizing + +```js +const FLEET = budget.total + ? Math.floor(budget.total / 100_000) + : 5 +``` + +### Loop-until-count (no budget) + +```js +const bugs = [] +while (bugs.length < 10) { + const result = await agent('Find bugs…', { schema: BUGS_SCHEMA }) + bugs.push(...result.bugs) + log(`${bugs.length}/10 found`) +} +``` + +Prefer budget-aware or dry-round stops for open-ended hunts ([patterns](./patterns.md)). + +--- + +## Nested `workflow()` + +```ts +workflow( + nameOrRef: string | { scriptPath: string }, + args?: any +): Promise +``` + +### Semantics + +| Aspect | Detail | +|---|---| +| Purpose | Run another workflow **inline** as a sub-step; return its return value | +| `string` | Name in saved/built-in registry (same as `Workflow({ name })`) | +| `{ scriptPath }` | Script file on disk (e.g. previously persisted) | +| Shared with parent | Concurrency cap, agent counter, abort signal, token `budget` | +| UI | Child agents under a nested group in `/workflows` | +| Nesting depth | **One level only** — `workflow()` inside a child **throws** | +| Errors | Unknown name / unreadable path / child syntax error → throw (catch to handle) | + +```js +const map = await workflow('understand-subsystem', { root: 'src/auth' }) +const plan = await workflow('design-panel', { context: map }) +return { map, plan } +``` + +Use nesting to compose **reusable** named workflows. Prefer sequential top-level `Workflow` tool calls across turns when the coordinator must read results and replan with the user. + +--- + +## Next + +- [Limits & sandbox](./limits.md) +- [Resume & journal](./resume.md) diff --git a/docs/dynamic-workflow/claude/lifecycle.md b/docs/dynamic-workflow/claude/lifecycle.md new file mode 100644 index 000000000..4a2e41c65 --- /dev/null +++ b/docs/dynamic-workflow/claude/lifecycle.md @@ -0,0 +1,108 @@ +# Lifecycle & operator UX + +How a workflow run feels end-to-end. + +## Happy path + +``` +1. Authoring + Coordinator scouts → builds work-list → writes inline script (or picks name) + +2. Permission + User sees meta.description (+ size guideline if configured) + +3. Launch + Workflow tool returns immediately: + taskId, runId, scriptPath, transcriptDir, workflowName, status + +4. Progress + /workflows shows: + · phase groups + · agent labels + · nested child workflow groups + · narrator log() lines + +5. Completion + delivers script return value to coordinator + +6. Synthesis + Coordinator may: + · answer the user + · edit scriptPath + resume + · launch another Workflow for the next phase +``` + +## Multi-phase product work + +Prefer **several workflows across turns** over one mega-script: + +| Turn | Workflow | Coordinator does after | +|---|---|---| +| 1 | Understand | Read map; decide design scope | +| 2 | Design panel | Pick approach with user if needed | +| 3 | Implement + review repair | Inspect diff; request fixes | +| 4 | Verify / audit | Ship narrative + residual risk | + +The coordinator **stays in the loop** between phases — that is a feature. Ultracode makes this the default for substantive work ([opt-in](./opt-in.md)). + +## Hybrid single-phase + +``` +scout (inline tools) + → Workflow(pipeline over discovered items) + → synthesize answer +``` + +You need the work-list shape before the orchestration step, not before any investigation. + +## Background vs blocking mental model + +From the model’s perspective: + +- The `Workflow` **tool call** returns once the run is **registered** (async launch). +- The **result of the script** arrives later via notification / task completion channel. +- Do not assume the tool return value is the script’s `return {…}` object. + +Operators watch `/workflows` for live structure. + +## Iteration loop + +``` +launch → observe → Edit(scriptPath) → stop if needed → resumeFromRunId +``` + +See [resume](./resume.md). + +## Failure / skip paths + +| Event | Typical handling | +|---|---| +| Syntax error in script | `error` on launch result; fix script, relaunch | +| User denies permission | No run; ask or fall back to single Agent | +| User skips individual agent | That `agent()` → `null`; filter and continue or abort in script | +| Budget exhausted | Further `agent()` throws; catch / end loop; return partial | +| Agent terminal API failure | `null`; log; optionally retry with new call (not automatic) | + +## Named vs inline scripts + +| Mode | When | +|---|---| +| Inline `script` | First design of a one-off harness | +| `scriptPath` iterate | Evolving a run without re-pasting | +| `name` / `.claude/workflows/` | Reusable team harnesses | +| Nested `workflow(name)` | Compose reusable pieces inside a parent script | + +## Common single-phase catalog + +| Name | Intent | +|---|---| +| Understand | Parallel readers → structured map | +| Design | Judge panel → scored synthesis | +| Review | Dimensions → find → adversarially verify | +| Research | Multi-modal sweep → deep-read → synthesize | +| Migrate | Discover → transform (worktree) → verify | + +## Next + +- [One level above / orchestration](./orchestration.md) +- [Use cases](./usecases.md) diff --git a/docs/dynamic-workflow/claude/limits.md b/docs/dynamic-workflow/claude/limits.md new file mode 100644 index 000000000..268dfb93a --- /dev/null +++ b/docs/dynamic-workflow/claude/limits.md @@ -0,0 +1,76 @@ +# Limits & sandbox + +Hard bounds and isolation properties of the Claude Code workflow engine (model-facing behavior). + +## Engine caps + +| Limit | Value | Behavior if exceeded | +|---|---|---| +| Concurrent `agent()` calls | `min(16, cpu_cores - 2)` **per workflow** | Excess **queue**; still complete | +| Lifetime agent count | **1000** per workflow run | Runaway-loop backstop | +| Items per `parallel` / `pipeline` call | **4096** | **Explicit error** (not silent truncate) | +| Script non-determinism | `Date.now` / `Math.random` / bare `new Date` | **Throw** | +| Nested `workflow()` depth | **1** | Nested call inside child **throws** | +| Token budget | User “+N” target if set | Further `agent()` **throws** when spent ≥ total | + +Queued concurrency means you can pass large work-lists safely; wall-clock still stretches when the queue is deep. + +## Soft guidelines (not hard engine caps) + +| Guideline | Source | +|---|---| +| Workflow size: small ≈ 5, medium ≈ 15, large ≈ 50, unrestricted | User `/config` workflow size guideline | +| Thoroughness vs brevity | Task wording (“any bugs” vs “thoroughly audit”) | + +The model should treat size guidelines as authoring policy unless the user explicitly overrides with scale language or ultracode. + +## Script sandbox + +| Available | Not available | +|---|---| +| Plain JS built-ins (`JSON`, `Math`, `Array`, …) | Node APIs, `require`, `process` | +| Injected primitives | Filesystem, network, subprocess from script | +| `agent` / nested `workflow` as escape hatches | Direct shell or edit from script | + +Side effects (file edits, network via tools, git) happen **inside subagents** under normal Claude Code tool permission policy — not as raw script I/O. + +## Worktree isolation (per agent) + +```js +await agent(prompt, { isolation: 'worktree' }) +``` + +| Property | Detail | +|---|---| +| Cost | ~200–500ms setup + disk **per agent** | +| Use when | Parallel **mutators** would conflict on one checkout | +| Cleanup | Auto-remove if worktree unchanged | +| Avoid when | Read-only work, single writer, or sequential mutators | + +This is **opt-in per `agent()`**, not a default for all workers. + +## Permission / product gates + +Separate from engine caps: + +- User must [opt in](./opt-in.md) (or enable ultracode) before `Workflow` is called. +- Script `meta.description` surfaces in the permission dialog. +- Individual agents may still hit tool permission prompts per session policy. + +## MCP caveats + +Workflow agents can use session-connected MCP tools via ToolSearch. **Interactively authenticated** MCP servers (e.g. browser login flows) may be absent in headless or cron-style runs. + +## Practical sizing + +| Ask | Rough shape | +|---|---| +| “Find any bugs” | Few finders, single-vote verify | +| “Thoroughly audit” | Larger finder pool, 3–5 vote adversarial pass, synthesis | +| Open-ended hunt + budget | `while (budget.total && budget.remaining() > …)` | +| Open-ended hunt, no budget | loop-until-dry with dry-round counter — not unbounded `while(true)` without exit | + +## Next + +- [Resume & journal](./resume.md) +- [Patterns](./patterns.md) diff --git a/docs/dynamic-workflow/claude/opt-in.md b/docs/dynamic-workflow/claude/opt-in.md new file mode 100644 index 000000000..6a4b6b001 --- /dev/null +++ b/docs/dynamic-workflow/claude/opt-in.md @@ -0,0 +1,67 @@ +# Opt-in & Ultracode + +Workflows can spawn dozens of agents and consume large token budgets. Claude Code therefore gates the `Workflow` tool behind **explicit user opt-in**. A task that would merely “benefit from parallelism” is not enough. + +## When Workflow is allowed + +The model may call `Workflow` only when at least one of these holds: + +1. **Keyword `ultracode` in the user prompt** + A system-reminder confirms it for that turn/session context. + +2. **Session ultracode is on** + Standing opt-in for every substantive task (see below). + +3. **User asks in their own words** for multi-agent orchestration + Examples: “use a workflow”, “run a workflow”, “fan out agents”, “orchestrate this with subagents”. + The ask must be in the **user’s** words — not inferred from task complexity. + +4. **A skill / slash command’s instructions** tell the model to call Workflow. + +5. **User asks for a specific named / saved workflow.** + +## When Workflow is not allowed + +Even if multi-agent would clearly help: + +- Silent model decision that “parallel is better.” +- Inferring opt-in from difficulty alone. +- “This would be faster with more agents” without user request. + +**Fallback:** + +- Use the single `Agent` tool for one-off subagents, or +- Briefly describe what a multi-agent workflow would do and rough cost, and ask. Mention the user can say “use a workflow” later to skip the ask. + +## Ultracode mode + +When a system-reminder confirms ultracode is **on**: + +| Rule | Behavior | +|---|---| +| Opt-in | Standing — every substantive task may/should use a workflow | +| Goal | Most exhaustive correct answer; token cost is not a constraint | +| Multi-phase | Often **several workflows in sequence** (understand → design → implement → review) so the coordinator stays in the loop between phases | +| Patterns | Prefer adversarial verify, multi-modal sweep, completeness critic, loop-until-dry where they fit | +| Solo exception | Pure conversation or trivial mechanical edits only | + +When ultracode is **off**, revert to the explicit opt-in list above. + +## Hybrid still applies + +Opt-in does not mean “dump the whole task into one script immediately.” + +1. Scout inline → discover work-list. +2. Author workflow over that list. +3. Synthesize; chain next phase if needed. + +## Cost and size guidelines + +Users may set a **workflow size guideline** in `/config` (e.g. small ≈ 5 agents, medium ≈ 15, large ≈ 50, unrestricted). This is a **guideline** for the model’s authoring behavior, not the same as the engine’s hard caps ([limits](./limits.md)). + +Budget directives (“+500k”-style) feed `budget.total` inside scripts — a **hard** ceiling on further `agent()` calls once spent. See [control-and-io](./control-and-io.md). + +## Next + +- [Workflow tool API](./workflow-tool.md) +- [Orchestration altitude](./orchestration.md) diff --git a/docs/dynamic-workflow/claude/orchestration.md b/docs/dynamic-workflow/claude/orchestration.md new file mode 100644 index 000000000..39c75d34d --- /dev/null +++ b/docs/dynamic-workflow/claude/orchestration.md @@ -0,0 +1,138 @@ +# One level above agents + +Classic multi-agent demos put several peers in a chat room and hope coordination emerges. Dynamic workflows **invert** that: + +> **Coordination is a program** written by a high-capability model. +> **Workers are replaceable execution units.** + +That is “one level above” ordinary agent tool use. + +## Split of responsibility + +### Orchestrator (main session) + +- Understand user intent and constraints +- Discover the work-list (files, bugs, modules, APIs) +- Choose pattern (pipeline vs barrier, depth vs breadth) +- Author the script, schemas, and worker prompts +- Allocate model / effort tiers per stage +- Interpret structured returns; decide the next phase +- Talk to the human; own the correctness narrative + +### Workers (`agent()`) + +- Execute one bounded prompt with tools +- Return raw data or schema-validated objects +- Stay isolated (optional worktree) +- Do **not** redesign the global plan +- May be cheaper/faster models for mechanical stages +- May be specialized `agentType`s (reviewer, explorer, …) + +### Engine + +- Run control flow faithfully +- Enforce concurrency, agent caps, budget hard stop +- Journal for resume +- Present progress UI + +## Why altitude matters + +| Problem in a flat agent loop | Workflow fix | +|---|---| +| Plan and tool churn share one context | Workers isolate tool churn; script aggregates returns only | +| Model “forgets” to verify | Verify is a stage in code | +| Fan-out is improvised each turn | Fan-out is `parallel` / `pipeline` | +| Hard to scale thoroughness | Scale fleet size, votes, dry rounds, budget | +| Parallel edits stomp each other | `isolation: 'worktree'` on mutators | + +``` +User intent + │ + ▼ +┌──────────────────────────────────────────┐ +│ Orchestrator model (main session) │ +│ · plans · schemas · phase selection │ +│ · Workflow({ script, args }) │ +└───────────────────┬──────────────────────┘ + │ deterministic JS spine + ┌───────────┼───────────┐ + ▼ ▼ ▼ + agent() agent() agent() + worker A worker B worker C + │ │ │ + └───────────┼───────────┘ + ▼ + structured returns + │ + ▼ + orchestrator synthesizes + │ + ▼ + user-facing answer +``` + +## Model tiering pattern + +Keep the **session model strong** for orchestration (authoring scripts, reading results, deciding phases). + +Inside the workflow: + +| Stage | Typical choice | +|---|---| +| Mechanical map / extract | `effort: 'low'`; optional smaller `model` | +| Default work | **Omit `model`** — inherit session model | +| Hard judges / design | higher `effort`; keep strong model | +| Parallel mutators | same model + `isolation: 'worktree'` | + +When unsure about `model`, **omit**. Wrong downgrades are worse than paying full price on a small fleet. + +## Structured handoffs + +The interface between altitude layers is **data**, not chat: + +```js +// worker → script +{ findings: [{ file, line, issue, severity }] } + +// script → orchestrator +{ confirmed, dropped, stats } + +// orchestrator → user +narrative + residual risk + links to paths +``` + +Schemas make handoffs machine-checkable. Prefer them at every stage boundary that feeds another stage. + +## What the orchestrator must not outsource + +- Final user-facing judgment (“is this safe to merge?”) without reading key evidence +- Opt-in / cost honesty +- Choosing silent truncation +- Replacing a missing verification stage with “the workers looked careful” + +Workers can be wrong in correlated ways; adversarial / diverse-lens patterns exist to fight that ([patterns](./patterns.md)). + +## Comparison: Agent tool vs Workflow altitude + +| | Single `Agent` | `Workflow` | +|---|---|---| +| Altitude | Peer subagent | Programmed fleet under orchestrator | +| Coordination | Prompt prose | Code | +| Resume multi-step graph | Weak | Prefix journal | +| Best for | One bounded digression | Structured multi-agent jobs | + +## Enabling “bigger brain, many hands” + +Dynamic workflows let you: + +1. Put the **expensive reasoning** in graph design and synthesis. +2. Put the **expensive tokens** in parallel worker contexts that do not pollute each other. +3. Put the **reliability** in deterministic stages (verify, majority, dry-stop). +4. Put the **human** at phase boundaries instead of inside every tool call. + +That is the product of the feature — not just “more agents.” + +## Next + +- [Use cases](./usecases.md) +- [Cheatsheet](./cheatsheet.md) diff --git a/docs/dynamic-workflow/claude/patterns.md b/docs/dynamic-workflow/claude/patterns.md new file mode 100644 index 000000000..42dc59ecc --- /dev/null +++ b/docs/dynamic-workflow/claude/patterns.md @@ -0,0 +1,254 @@ +# Quality patterns + +These are **not** extra APIs. They are recipes composed from [`agent`](./agent.md), [`pipeline` / `parallel`](./concurrency.md), [`budget`](./control-and-io.md), and [`log`](./control-and-io.md). Pick by task; compose freely. + +## Scale to the ask + +| User language | Shape | +|---|---| +| “Find any bugs” | Few finders, single-vote verify | +| “Thoroughly audit” / “be comprehensive” | Larger finder pool, 3–5 vote adversarial pass, synthesis | +| Unsure on research/review/audit | Lean thorough | +| Quick check | Lean brief | + +--- + +## Adversarial verify + +Spawn N independent skeptics per claim, each prompted to **REFUTE**. Kill if ≥ majority refute. Prevents plausible-but-wrong findings from surviving. + +```js +const votes = await parallel( + Array.from({ length: 3 }, () => () => + agent( + `Try to refute: ${claim}. Default to refuted=true if uncertain.`, + { schema: VERDICT, phase: 'Verify', effort: 'high' } + ) + ) +) +const survives = + votes.filter(Boolean).filter(v => !v.refuted).length >= 2 +``` + +--- + +## Perspective-diverse verify + +When a finding can fail in more than one way, give each verifier a **distinct lens** (correctness, security, perf, does-it-reproduce) instead of N identical refuters. Diversity catches failure modes redundancy cannot. + +```js +const lenses = ['correctness', 'security', 'repro'] +const votes = await parallel( + lenses.map(lens => () => + agent(`Judge "${desc}" via the ${lens} lens — real?`, { + schema: VERDICT, + phase: 'Verify', + label: `judge:${lens}`, + }) + ) +) +const real = votes.filter(Boolean).filter(v => v.real).length >= 2 +``` + +--- + +## Judge panel (design) + +Generate N independent attempts from different angles (MVP-first, risk-first, user-first). Score with parallel judges. Synthesize from the winner while grafting best ideas from runners-up. Beats single-attempt iteration when the solution space is wide. + +```js +const ANGLES = ['mvp-first', 'risk-first', 'user-first'] +const drafts = await parallel( + ANGLES.map(a => () => + agent(`Propose a design (${a}). Constraints: ${constraints}`, { + schema: DESIGN_SCHEMA, + phase: 'Design', + label: `draft:${a}`, + }) + ) +).then(xs => xs.filter(Boolean)) + +const scored = await parallel( + drafts.map(d => () => + agent(`Score this design vs criteria…\n${JSON.stringify(d)}`, { + schema: SCORE_SCHEMA, + phase: 'Score', + }) + ) +).then(xs => xs.filter(Boolean)) + +const winner = pickWinner(drafts, scored) +const synthesis = await agent( + `Synthesize final design from winner + graft runners-up…`, + { schema: DESIGN_SCHEMA, phase: 'Synthesize', effort: 'high' } +) +return { winner, synthesis, runnersUp: drafts } +``` + +--- + +## Loop-until-dry + +Unknown-size discovery (bugs, issues, edge cases): keep spawning finders until **K consecutive rounds** return nothing new. Simple `while (count < N)` misses the tail. + +**Critical:** dedup against **all `seen`**, not only `confirmed`. If you only track confirmed, judge-rejected findings reappear every round and the loop never converges. + +```js +const seen = new Set() +const confirmed = [] +let dry = 0 + +while (dry < 2) { + const found = ( + await parallel( + FINDERS.map(f => () => + agent(f.prompt, { phase: 'Find', schema: BUGS }) + ) + ) + ) + .filter(Boolean) + .flatMap(r => r.bugs) + + const fresh = found.filter(b => !seen.has(key(b))) + if (!fresh.length) { + dry++ + log(`dry round ${dry}/2`) + continue + } + dry = 0 + fresh.forEach(b => seen.add(key(b))) + log(`${fresh.length} fresh findings (${seen.size} seen total)`) + + const judged = await parallel( + fresh.map(b => () => + parallel( + ['correctness', 'security', 'repro'].map(lens => () => + agent(`Judge "${b.desc}" via ${lens} — real?`, { + phase: 'Verify', + schema: VERDICT, + }) + ) + ).then(vs => ({ + b, + real: vs.filter(Boolean).filter(v => v.real).length >= 2, + })) + ) + ) + + confirmed.push(...judged.filter(v => v.real).map(v => v.b)) +} + +return confirmed +``` + +Combine with [budget](./control-and-io.md#budget) for cost-bounded open-ended hunts: + +```js +while (dry < 2 && budget.total && budget.remaining() > 50_000) { + // … +} +``` + +--- + +## Multi-modal sweep + +Parallel agents each search a **different way** (by-container, by-content, by-entity, by-time). Each is blind to what the others surface — covers angles one search cannot. + +```js +const MODES = [ + { key: 'by-path', prompt: 'Find X by directory layout…' }, + { key: 'by-symbol', prompt: 'Find X by type/symbol names…' }, + { key: 'by-test', prompt: 'Find X by failing or related tests…' }, + { key: 'by-history', prompt: 'Find X by recent git history…' }, +] + +const sweeps = await parallel( + MODES.map(m => () => + agent(m.prompt, { + phase: 'Sweep', + label: `sweep:${m.key}`, + schema: HITS_SCHEMA, + effort: 'low', + }) + ) +).then(xs => xs.filter(Boolean)) + +const merged = dedupe(sweeps.flatMap(s => s.hits)) +// then deep-read top hits, then synthesize +``` + +--- + +## Completeness critic + +A final agent asks what is missing — modality not run, claim unverified, source unread. Output becomes the next work round. + +```js +const gaps = await agent( + `Given work done:\n${JSON.stringify(summary)}\nWhat is missing?`, + { schema: GAPS_SCHEMA, phase: 'Critic', effort: 'medium' } +) +if (gaps.items.length) { + log(`critic found ${gaps.items.length} gaps`) + // feed gaps into another pipeline / loop iteration +} +``` + +--- + +## Self-repair implementation loop + +Encode a real engineering process as a script: + +```text +Implement → multi-reviewer parallel → repair from structured findings → verify gates +``` + +See the implement/review/repair/verify example in [script contract](./script-contract.md). + +Tips: + +- Reviewers **read-only**; repair agent owns writes. +- Structured `FINDINGS` schema forces actionable file/line/fix fields. +- Final verify re-runs typecheck/tests and reports residual risk. +- Resume after fixing only the repair prompt if implement+review were good. + +--- + +## Review pipeline (default multi-stage) + +Already detailed in [concurrency](./concurrency.md): + +```text +dimensions → (per dimension) findings → (per finding) adversarial verify +``` + +Use barrier+dedup only when verification must see the global merged set first. + +--- + +## No silent caps + +If you bound coverage: + +```js +const MAX = 40 +if (sites.length > MAX) { + log(`transforming ${MAX}/${sites.length} sites; remainder skipped`) +} +const batch = sites.slice(0, MAX) +``` + +Silent truncation reads as full coverage. + +--- + +## Compose novel harnesses + +The list is not exhaustive. Valid compositions include tournament brackets, staged escalation (cheap finder → expensive judge only on survivors), and multi-phase product delivery under ultracode ([lifecycle](./lifecycle.md)). + +## Next + +- [Lifecycle & UX](./lifecycle.md) +- [Orchestration altitude](./orchestration.md) diff --git a/docs/dynamic-workflow/claude/primitives.md b/docs/dynamic-workflow/claude/primitives.md new file mode 100644 index 000000000..c30fa49ba --- /dev/null +++ b/docs/dynamic-workflow/claude/primitives.md @@ -0,0 +1,62 @@ +# Primitives overview + +The workflow script body is an async JS context with a **closed** API surface. Only these hooks are injected. Everything else is ordinary JavaScript (with [determinism bans](./script-contract.md)). + +## Inventory + +| Primitive | Kind | Role | +|---|---|---| +| [`agent`](./agent.md) | async call | Spawn one subagent; get string or schema-validated object | +| [`pipeline`](./concurrency.md#pipeline) | combinator | Per-item multi-stage fan-out **without** barriers | +| [`parallel`](./concurrency.md#parallel) | combinator | Concurrent thunks; **barrier** until all complete | +| [`phase`](./control-and-io.md#phase) | side effect | Start a progress group for following agents | +| [`log`](./control-and-io.md#log) | side effect | Narrator line in `/workflows` progress UI | +| [`args`](./control-and-io.md#args) | binding | `Workflow({ args })` value, verbatim | +| [`budget`](./control-and-io.md#budget) | binding | Shared turn token ceiling: `total`, `spent()`, `remaining()` | +| [`workflow`](./control-and-io.md#nested-workflow) | async call | Run one nested named/path workflow (max depth 1) | + +## How they compose + +``` +meta (literal header) + │ + ▼ +phase / log ─────────────────────────── UX only + │ + ├── agent ─────────────────────────── unit of model work + │ + ├── parallel([() => agent…, …]) ──── barrier fan-out + │ + ├── pipeline(items, s1, s2, …) ───── streaming multi-stage + │ └── stages may call agent / parallel + │ + ├── budget.* ──────────────────────── scale / stop loops + │ + └── workflow(name|path) ───────────── nested graph (1 level) +``` + +## Design rules (short) + +1. **Default multi-stage shape is `pipeline`**, not barrier-then-map. +2. Use **`parallel` only** when stage N needs the **full** stage N−1 result set. +3. Always **`.filter(Boolean)`** after `parallel` / nullable `agent` results. +4. Prefer **`schema`** on `agent` for structured returns — no JSON parse roulette. +5. **`log()`** anything a silent cap would hide (top-N, drops, early exit). +6. Guard budget loops with **`budget.total &&`** (else `remaining()` is `Infinity`). +7. Put identity for later stages in **`(prev, originalItem, index)`**, not only in stage-1 return blobs. + +## Not primitives (but matter) + +| Concern | Where documented | +|---|---| +| Tool launch API | [workflow-tool.md](./workflow-tool.md) | +| Script / meta rules | [script-contract.md](./script-contract.md) | +| Caps & isolation | [limits.md](./limits.md) | +| Resume cache | [resume.md](./resume.md) | +| Recipes | [patterns.md](./patterns.md) | + +## Next + +- [agent()](./agent.md) +- [pipeline & parallel](./concurrency.md) +- [phase, log, args, budget, workflow()](./control-and-io.md) diff --git a/docs/dynamic-workflow/claude/resume.md b/docs/dynamic-workflow/claude/resume.md new file mode 100644 index 000000000..d0734b99d --- /dev/null +++ b/docs/dynamic-workflow/claude/resume.md @@ -0,0 +1,94 @@ +# Resume & journal + +Dynamic workflows are editable programs. Resume lets you change the plan mid-flight (or after a kill) without redoing finished `agent()` work. + +## Handles returned at launch + +| Field | Use | +|---|---| +| `runId` | Pass as `resumeFromRunId` on the next `Workflow` call | +| `scriptPath` | Edit in place; re-invoke without resending full `script` | +| `transcriptDir` | Subagent transcripts + `journal.jsonl` | +| `taskId` | Stop / track the background task | + +## How to resume + +1. **Stop** the prior run if it is still running (background task stop / equivalent). +2. Relaunch: + +```js +Workflow({ + scriptPath: '/…/workflows/scripts/review-wf_abc.js', + resumeFromRunId: 'wf_abc…', + args: previousArgs, // keep identical for full cache when script unchanged +}) +``` + +Same-session only for `resumeFromRunId` (local runs). + +## Cache identity rule + +The engine finds the **longest unchanged prefix** of `agent()` calls: + +- Same **prompt** + same **opts** (as hashed for identity) → return **cached** result instantly. +- First **edited or new** `agent()` call and **everything after it** run live. + +| Scenario | Result | +|---|---| +| Same script + same `args` | ~100% cache hit | +| Edit only post-processing after the last `agent()` | Cache hit all agents; re-run pure JS tail | +| Change prompt of agent #3 of 10 | Agents 1–2 cached; 3–10 live | +| Insert a new `agent()` early | From that call onward live | + +## Why scripts ban entropy + +`Date.now()`, `Math.random()`, and bare `new Date()` throw in scripts so control flow and prompt construction cannot silently diverge between original run and resume. See [script contract](./script-contract.md). + +If you need wall-clock: + +- Pass a fixed ISO string via `args` at launch, or +- Stamp times in the coordinator after the workflow returns. + +## `journal.jsonl` + +Path: `/journal.jsonl` + +- Records each agent’s **actual return value**. +- Before diagnosing empty or surprising workflow results, **read the journal** — do not assume cached results are non-empty. +- Fallback if no journal: read `agent-.jsonl` files in the transcript directory and hand-author a continuation script. + +## Operational patterns + +### Fix a bad verify stage after a long review + +1. Leave review `agent()` prompts unchanged. +2. Edit only verify-stage prompts / schema in `scriptPath`. +3. Resume with same `args` → review results cache; verify re-runs. + +### Add a completeness-critic pass + +1. Append a new phase + `agent()` at the end of the script. +2. Resume → entire prior prefix caches; only the new agent runs. + +### Re-run pure aggregation + +1. Change only the `return` / merge logic (no `agent()` signature changes). +2. Resume → full agent cache; new aggregation. + +## Failure modes to watch + +| Symptom | Check | +|---|---| +| Empty confirmed list | Journal: did judges return `null`? schema fail? | +| Unexpected re-run of early agents | Prompt/opts drift (template changed, args differ) | +| Resume rejected / no cache | Wrong session, missing `runId`, prior run not stopped | +| Divergent args | Even with same script, different `args` can change prompts that embed `args` → cache miss from first embedded call | + +## Relation to durability + +Claude Code resume is **session-oriented prefix replay** of orchestration journals. It is not the same as a multi-day durable job supervisor with external leases (a different control plane). For long-lived external orchestration, see product-specific durable systems; this doc describes the model-facing Workflow resume API only. + +## Next + +- [Patterns](./patterns.md) +- [Lifecycle](./lifecycle.md) diff --git a/docs/dynamic-workflow/claude/script-contract.md b/docs/dynamic-workflow/claude/script-contract.md new file mode 100644 index 000000000..dfd64fbcb --- /dev/null +++ b/docs/dynamic-workflow/claude/script-contract.md @@ -0,0 +1,145 @@ +# Script contract + +A workflow script is plain JavaScript that starts with a pure-literal `meta` export, then runs in an async context with only the injected orchestration primitives available. + +## Minimal shape + +```js +export const meta = { + name: 'find-flaky-tests', + description: 'Find flaky tests and propose fixes', // shown in permission dialog + phases: [ + { title: 'Scan', detail: 'grep test logs for retries' }, + { title: 'Fix', detail: 'one agent per flaky test', model: 'sonnet' }, + ], + // optional: whenToUse — shown in workflow lists +} + +// body — async context; await freely +phase('Scan') +const flaky = await agent('grep CI logs for retry markers', { schema: FLAKY_SCHEMA }) +// ... +return { flaky } +``` + +## `meta` rules + +| Rule | Detail | +|---|---| +| Position | Must be the **first statement** in the script | +| Purity | **Pure literal only** — no variables, function calls, spreads, or template interpolation | +| Required | `name`, `description` | +| Optional | `whenToUse`, `phases` | +| Phase entries | `{ title, detail?, model? }` | +| Phase titles | Must match `phase('…')` call strings **exactly** for UI grouping; unmatched `phase()` still gets its own progress group | +| Per-phase model | Optional override for agents in that phase’s UI group (agent-level `opts.model` still applies per call) | +| Permission UX | `description` is what the user sees in the approval dialog | + +Invalid example (not pure literal): + +```js +const n = 'review' +export const meta = { name: n, description: `Review ${topic}` } // ❌ +``` + +## Language + +| Allowed | Forbidden | +|---|---| +| Plain JavaScript | TypeScript annotations, interfaces, generics | +| `async` body with top-level `await` | Node APIs (`fs`, `process`, `require`, …) | +| `JSON`, `Math`, `Array`, `Object`, `Map`, `Set`, … | Filesystem, network, subprocess | +| Template strings / normal expressions in the **body** | Non-determinism listed below | + +Type annotations like `: string[]` **fail to parse**. Keep types in comments or in JSON Schema objects as plain data. + +## Determinism bans (resume safety) + +These throw if called in the script (argless / pure entropy): + +- `Date.now()` +- `Math.random()` +- argless `new Date()` + +**Why:** [Resume](./resume.md) replays the longest unchanged prefix of `agent()` calls by hashing prompt + options. If the script branched on wall-clock or random, cache identity would lie and partial replay would be unsafe. + +**What to do instead:** + +- Pass fixed timestamps / seeds via `args`. +- Stamp wall-clock **after** the workflow returns, in the coordinator. +- For “random-like” diversity among agents, vary **prompt text or label by index** (deterministic in the script, different per worker). + +## Only escape hatches into models + +From the script you can only: + +1. Call **`agent()`** — spawn a subagent (tools, optional schema). +2. Call **`workflow()`** — run one nested saved/path workflow (one level only). + +There is no raw shell, no write-file, no HTTP from the orchestration body. That is intentional: orchestration stays pure; side effects live inside agents under normal permission/tool policy. + +## Return value + +Whatever the script `return`s becomes the workflow result delivered to the coordinator (via task notification). Prefer structured objects: + +```js +return { confirmed, dropped, stats: { found: seen.size } } +``` + +Subagents should return **raw data** (or schema objects), not user essays — the coordinator narrates. + +## Real-world example (implement → review → repair → verify) + +Condensed from a session script: + +```js +export const meta = { + name: 'implement-workflow-foundation', + description: 'Implement and verify durable workflow foundation', + phases: [ + { title: 'Implement', detail: 'build store and orchestrator', model: 'sonnet' }, + { title: 'Review', detail: 'audit correctness and tests' }, + { title: 'Repair', detail: 'apply verified fixes', model: 'sonnet' }, + { title: 'Verify', detail: 'run full validation' }, + ], +} + +phase('Implement') +const implementation = await agent(`…implementation prompt…`, { + label: 'implement:durable-foundation', + phase: 'Implement', + effort: 'medium', + agentType: 'claude', +}) + +phase('Review') +const FINDINGS = { /* JSON Schema */ } +const reviews = await parallel([ + () => agent(`…persistence audit…\n${implementation}`, { + label: 'review:persistence', phase: 'Review', schema: FINDINGS, effort: 'medium', agentType: 'claude', + }), + () => agent(`…correctness audit…\n${implementation}`, { + label: 'review:correctness', phase: 'Review', schema: FINDINGS, effort: 'medium', agentType: 'claude', + }), + () => agent(`…test quality audit…\n${implementation}`, { + label: 'review:tests', phase: 'Review', schema: FINDINGS, effort: 'low', agentType: 'claude', + }), +]).then(xs => xs.filter(Boolean)) + +phase('Repair') +const repair = await agent(`…fix from ${JSON.stringify(reviews)}…`, { + label: 'repair:review-findings', phase: 'Repair', effort: 'medium', agentType: 'claude', +}) + +phase('Verify') +const verification = await agent(`…gates…`, { + label: 'verify:full-gates', phase: 'Verify', effort: 'low', agentType: 'claude', +}) + +return { implementation, reviews, repair, verification } +``` + +## Next + +- [Primitives overview](./primitives.md) +- [agent()](./agent.md) diff --git a/docs/dynamic-workflow/claude/usecases.md b/docs/dynamic-workflow/claude/usecases.md new file mode 100644 index 000000000..c6735f5df --- /dev/null +++ b/docs/dynamic-workflow/claude/usecases.md @@ -0,0 +1,180 @@ +# Use cases + +Workloads that were awkward or unreliable as a single flat agent loop, and how dynamic workflows fit them. Pair with [patterns](./patterns.md) and [orchestration](./orchestration.md). + +## Comprehensive code review + +**Goal:** High confidence that findings are real before the user acts. + +**Shape:** + +```text +scout diff → dimensions (security, correctness, tests, perf) + → (pipeline) per-dimension findings + → adversarial / multi-lens verify per finding + → return survivors only +``` + +**Why workflow:** Verification is not optional prose — it is stages. Vote count scales with “thoroughly audit” vs “any issues.” + +**Primitives:** `pipeline`, `parallel`, `schema`, higher `effort` on judges. + +--- + +## Large migrations / refactors + +**Goal:** Touch many call sites without stomping edits or losing progress. + +**Shape:** + +```text +discover sites → pipeline(site → transform → local verify) + isolation: 'worktree' on mutators + resume after fixing one stage’s prompt +``` + +**Why workflow:** One context cannot hold hundreds of site-specific tool traces. Prefix resume avoids redoing finished sites when the transform prompt improves. + +**Primitives:** `pipeline`, `isolation: 'worktree'`, `resumeFromRunId`, `log` for skipped tails. + +--- + +## Research & multi-source synthesis + +**Goal:** Broad coverage then deep reading then a cited synthesis. + +**Shape:** + +```text +multi-modal sweep (parallel angles) + → merge/dedup hits + → deep-read top sources (pipeline) + → completeness critic + → synthesize +``` + +**Why workflow:** Sweeps are embarrassingly parallel; synthesis needs the merged set (barrier). Budget bounds open-ended browsing. + +**Primitives:** `parallel`, barrier merge, `budget`, critic `agent`. + +--- + +## Design exploration + +**Goal:** Explore a wide solution space without anchoring on the first idea. + +**Shape:** + +```text +N drafts from different angles (parallel) + → score panel (parallel) + → synthesize winner + graft runners-up +``` + +**Why workflow:** Single-thread iteration biases early. Independent drafts + structured scores beat one long chat. + +**Primitives:** judge panel pattern, `schema` for design objects, high effort on synthesis. + +--- + +## Unknown-size bug / issue hunts + +**Goal:** Keep finding until the map is dry, not until an arbitrary count. + +**Shape:** + +```text +loop-until-dry: + parallel finders → dedup vs seen → multi-lens judge → accumulate confirmed +``` + +**Why workflow:** `while (n < 10)` misses the tail; dry rounds + `seen` set converge. Budget optional hard stop. + +**Primitives:** loops, `parallel`, `Set` dedup, `budget.total && …`. + +--- + +## Self-repair implementation + +**Goal:** Ship a change with independent review pressure, not self-congratulation. + +**Shape:** + +```text +implement → parallel reviewers (schema findings) + → repair agent applies real issues + → verify gates (typecheck/tests) +``` + +**Why workflow:** Separation of implementer and reviewers; structured findings; deterministic phase order. + +**Primitives:** sequential `phase`s, `parallel` reviewers, schema, medium/low effort mix. + +**Example skeleton:** [script contract](./script-contract.md). + +--- + +## Heterogeneous agent fleets + +**Goal:** Specialists for map / edit / audit under one plan. + +**Shape:** + +```text +explorer agentType (read-only map) + → implementer agentType (edits, maybe worktree) + → reviewer agentType (schema audit) +``` + +**Why workflow:** `agentType` + model/effort per stage without the user manually jockeying three chats. + +**Primitives:** `agentType`, `model`/`effort` overrides, nested `workflow` for reusable specialist packs. + +--- + +## Phased product delivery under ultracode + +**Goal:** Maximum exhaustiveness for multi-day product work with human checkpoints. + +**Shape:** + +```text +turn 1: Understand workflow +turn 2: Design workflow +turn 3: Implement+repair workflow +turn 4: Review/audit workflow +``` + +**Why workflow:** Standing opt-in; each workflow is a well-scoped fan-out; coordinator synthesizes between turns. + +**Primitives:** full stack + [lifecycle](./lifecycle.md) multi-phase. + +--- + +## What this feature deliberately is not + +| Not | Because | +|---|---| +| Free-form multi-agent chat room | Workers do not negotiate the plan with each other | +| Silent always-on multi-agent | Cost; requires [opt-in](./opt-in.md) / ultracode | +| Multi-day durable external job system | Resume is session-oriented prefix replay, not external leases | +| Replacement for small tasks | Overhead of scripting + fleet is real; use single Agent or inline tools | + +--- + +## Choosing a shape quickly + +| Symptom | Reach for | +|---|---| +| Many independent units | `pipeline` or `parallel` fan-out | +| “I’m not sure we covered it” | multi-modal sweep + completeness critic | +| “Findings feel flaky” | adversarial / multi-lens verify | +| “Solution space is wide” | judge panel | +| “Don’t know how many exist” | loop-until-dry | +| “Parallel edits conflict” | `isolation: 'worktree'` | +| “Reran everything after a prompt tweak” | `resumeFromRunId` + stable prefix | + +## Next + +- [Cheatsheet](./cheatsheet.md) +- [README index](./README.md) diff --git a/docs/dynamic-workflow/claude/workflow-tool.md b/docs/dynamic-workflow/claude/workflow-tool.md new file mode 100644 index 000000000..b43a40ee7 --- /dev/null +++ b/docs/dynamic-workflow/claude/workflow-tool.md @@ -0,0 +1,139 @@ +# Workflow tool API + +The model-facing tool name is **`Workflow`** (alias **`RunWorkflow`**). + +- **Search hint:** orchestrate subagents with deterministic JavaScript workflow +- **Execution:** background — tool returns immediately with a task id +- **Completion:** `` when the script finishes +- **Live progress:** `/workflows` + +## When to use the tool (product intent) + +A workflow structures work across many agents to be: + +- **Comprehensive** — decompose and cover in parallel +- **Confident** — independent perspectives and adversarial checks before committing +- **Scalable** — migrations, audits, broad sweeps that one context cannot hold + +The script encodes structure: what fans out, what verifies, what synthesizes. + +Control flow should be **deterministic** (loops, conditionals, fan-out in code) rather than re-decided free-form by the model mid-orchestration. + +Common single-phase shapes (chain across turns for larger work): + +| Phase | Pattern | +|---|---| +| Understand | parallel readers over subsystems → structured map | +| Design | judge panel of N approaches → scored synthesis | +| Review | dimensions → find → adversarially verify | +| Research | multi-modal sweep → deep-read → synthesize | +| Migrate | discover sites → transform (worktree) → verify | + +See [opt-in](./opt-in.md) for permission to call this tool. + +## Input fields + +At least one of `script`, `name`, or `scriptPath` is required. + +| Field | Type | Role | +|---|---|---| +| `script` | string (optional, length-bounded) | Inline self-contained workflow script. Must begin with pure-literal `export const meta = { name, description, phases }`. **Preferred on first invocation** — do not Write a file first. | +| `name` | string (optional) | Predefined workflow: built-in or from `.claude/workflows/`. Resolves to a full script. | +| `scriptPath` | string (optional) | Path to a script on disk. Every invocation **persists** its script under the session directory and returns the path. Iterate with Write/Edit + re-invoke. **Takes precedence** over `script` and `name`. | +| `args` | any (optional) | Exposed to the script as global `args`, **verbatim**. Pass real JSON arrays/objects — **not** a JSON-encoded string (stringified lists break `args.map` / `args.filter`). | +| `resumeFromRunId` | string `^wf_[a-z0-9-]{6,}$` (optional) | Prior run id. Unchanged prefix of `agent()` calls replays from cache; first edited/new call and everything after runs live. Same-session only. **Stop the prior run first** before resuming. | +| `description` | string (optional) | **Ignored** — set description in script `meta`. | +| `title` | string (optional) | **Ignored** — set title/name in script `meta`. | + +### First run + +```js +Workflow({ + script: ` +export const meta = { + name: 'review-changes', + description: 'Review and adversarially verify findings', + phases: [ + { title: 'Review' }, + { title: 'Verify' }, + ], +} +// ... body using agent/pipeline/parallel ... +return { confirmed } +`, + args: { files: ['src/auth.ts', 'src/session.ts'] }, +}) +``` + +### Iterate without resending the full script + +```js +// Edit the returned scriptPath via Write/Edit, then: +Workflow({ + scriptPath: returnedScriptPath, + resumeFromRunId: runId, // optional: reuse cached agent() prefix + args: { files: ['src/auth.ts', 'src/session.ts'] }, +}) +``` + +### Named workflow + +```js +Workflow({ + name: 'review-changes', + args: { topic: 'authentication' }, +}) +``` + +## Return envelope (conceptual) + +The tool launches asynchronously. A typical success-shaped result includes: + +```ts +{ + status: 'async_launched' | 'remote_launched', + taskId: string, + taskType?: 'local_workflow' | 'remote_agent', + workflowName?: string, // meta.name + runId?: string, // for resumeFromRunId (local) + transcriptDir?: string, // subagent transcripts + journal.jsonl + scriptPath?: string, // persisted script for this invocation + summary?: string, + sessionUrl?: string, // when remote_launched + warning?: string, // non-blocking heads-up + error?: string, // e.g. syntax check failed +} +``` + +Notes: + +- `runId` is the handle for [resume](./resume.md). +- `scriptPath` is the handle for iteration without resending `script`. +- `transcriptDir` holds per-agent logs and `journal.jsonl` (actual agent return values). +- Remote launches may use `sessionUrl` instead of local `runId` as the resume handle. + +## Resolution order (engine behavior) + +Conceptually the engine resolves input as: + +1. If `scriptPath` → load (and optionally pair with inline `script` for built-in match checks). +2. Else if `name` → resolve from built-ins / `.claude/workflows/`. +3. Else if `script` → use inline body. +4. Else → validation error: must provide script, name, or scriptPath. + +## Relationship to the single Agent tool + +| | `Agent` tool | `Workflow` tool | +|---|---|---| +| Count | One subagent (or a few manual launches) | Many, under a script graph | +| Control flow | Model re-decides each turn | Script encodes loops/fan-out | +| Structured multi-stage | Manual | `pipeline` / `parallel` + schema | +| Cost risk | Lower | Higher — gated by opt-in | +| Resume of a multi-step graph | Limited | Prefix-cached by agent call identity | + +Use `Agent` for isolated one-offs. Use `Workflow` when the **structure** of multi-agent work must be reliable. + +## Next + +- [Script contract](./script-contract.md) +- [Primitives](./primitives.md) From a7c95ec0655f706b3f13eb85fbf12d26639a004f Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 21 Jul 2026 12:10:43 +0530 Subject: [PATCH 002/132] docs: devspace plan --- docs/dynamic-workflow/devspace/plan.md | 369 ++++++++ .../devspace/primitives-spec.md | 864 ++++++++++++++++++ 2 files changed, 1233 insertions(+) create mode 100644 docs/dynamic-workflow/devspace/plan.md create mode 100644 docs/dynamic-workflow/devspace/primitives-spec.md diff --git a/docs/dynamic-workflow/devspace/plan.md b/docs/dynamic-workflow/devspace/plan.md new file mode 100644 index 000000000..2c13780c3 --- /dev/null +++ b/docs/dynamic-workflow/devspace/plan.md @@ -0,0 +1,369 @@ +# DevSpace Dynamic Workflow Engine — Plan + +Builds on the locked bigger-model plan. Scope = **this worktree only**. +Subagents stay **CLI-only**. Workflows get **CLI + MCP** over shared primitives. + +--- + +## 0. Non-goals / locks + +| Lock | Meaning | +|---|---| +| No MCP `agent_run` / `agent_wait` / `agent_show` | Subagent feature surface remains `devspace agents *` (+ skill + shell). | +| Workflow workers call adapters **in-process** | `runLocalAgentProvider` / same registry as CLI worker. No shell-out to `agents run` for `agent()`. | +| No dashboard v1 | Events via store drain + CLI `--follow` / MCP status long-poll. | +| CC script API parity | `meta`, `agent`, `parallel`, `pipeline`, `phase`, `log`, `args`, `budget`, `workflow` + determinism bans. | +| Yolo sub-agents | Fixed write-capable adapter policy; **no** `writeMode` on `agent()`. | +| `isolation: 'worktree'` | **Must-have** on `agent()` (CC-like); default shared checkout. | +| `effort` (not `thinking`) | Profiles, CLI, store, adapters, `agent()` opts — rename across stack. | +| `budget` stub v1 | `{ total: null, spent: () => 0, remaining: () => Infinity }`. | +| Dual surface | `devspace workflow *` **and** MCP `run_workflow` / `workflow_status` / `workflow_cancel`. | +| All 6 providers v1 | codex/claude/opencode/pi/cursor/copilot via existing adapters. | +| `agentProviders.enabled` | Ordered config from onboarding; default provider = first enabled ∩ live. | +| Resume-by-replay right after engine core | Same milestone order as locked plan. | + +--- + +## 1. Control planes (do not conflate) + +``` +A) One-shot subagents (existing, unchanged API) + host/shell → devspace agents run|show|ls + → detached __worker → adapters → local_agent_sessions + +B) Dynamic workflows (new) + host MCP / CLI → run row + spawn workflow __worker + → sandboxed script + → agent() → adapters (in-process) + → workflow_* tables (not local_agent_sessions) +``` + +**Implication:** `devspace agents ls` does **not** list workflow-spawned agents. Observability = workflow events + `workflow_agent_calls`. Optional later dual-write — not v1. + +--- + +## 2. Architecture + +``` +┌─ CLI: workflow run|status|cancel|ls ─┐ ┌─ MCP: run_workflow|status|cancel ─┐ +│ parse / create run / spawn │ │ same primitives via workflow-tools │ +└──────────────────┬───────────────────┘ └──────────────────┬────────────────┘ + ▼ │ + WorkflowStore (SQLite WAL) ◄────────────────────────┘ + │ + │ detached: node cli.js workflow __worker + ▼ + workflow-engine + sandbox + api + │ + │ agent() [semaphore] + ▼ + runLocalAgentProvider(provider, input) ← existing adapters + │ + ▼ + journal: events + agent_calls (+ schema retries) +``` + +Server/CLI = **launcher + journal reader**. Worker owns execution, heartbeat, cancel watch, self group-kill. + +--- + +## 3. Accept bigger plan as-is (core) + +Keep their file split (flat `src/`): + +| Module | Role | +|---|---| +| `workflow-script.ts` | meta extract + wrap + `vm.Script` | +| `workflow-sandbox.ts` | context, determinism bans, console→log | +| `workflow-store.ts` | runs / events / agent_calls / cancel / reap | +| `workflow-api.ts` | agent/parallel/pipeline/phase/log/args/budget/workflow + semaphore | +| `workflow-engine.ts` | execute + `__worker` guts | +| `workflow-replay.ts` | resume cache | +| `workflow-schema.ts` | Ajv + retries | +| `workflow-files.ts` | named + persist scriptPath | +| `workflow-tools.ts` | MCP registration | +| `skills/dynamic-workflows/SKILL.md` | teaching | + +DB migration **v4** (v3 = `local_agent_sessions` ✓). +Tables: `workflow_runs`, `workflow_events`, `workflow_agent_calls` as specified. +Spawn pattern copy `spawnAgentWorker` (detached, stdio ignore, unref). + +API semantics: keep their CC-parity table (throws vs parallel→null, pipeline stages, ALS for phase, nested workflow depth 1, budget stub). + +MCP contracts + yield windows: keep (status max ~110s matches `MAX_POLL_YIELD_MS`). + +Milestones 1→8: keep order and verifiability. + +--- + +## 4. Refinements / deltas on the bigger plan + +### 4.1 Explicit separation from subagent CLI + +In SKILL + serverInstructions + tool descriptions: + +- Workflows = multi-agent **graphs**. +- One-off second opinions = still `devspace agents run` (CLI/skill). +- Do **not** tell models to implement workflows by shelling many `agents run` when `run_workflow` exists. + +### 4.2 `agent()` backend = adapters, not CLI + +```ts +// conceptual +runProvider({ provider, prompt, workspace, model, effort, providerSessionId? }) + → runLocalAgentProvider(provider, { prompt, workspace, writeMode: "allowed", model, effort, providerSessionId? }) +``` + +- Schema retries reuse `providerSessionId` when adapter returns it (codex/claude path). +- Do not create `local_agent_sessions` rows per call (avoids polluting `agents ls`, simpler cancel). +- If product later wants unified list, add a flag — not v1. +- `workspace` is either shared `workspaceRoot` or a managed worktree path when `opts.isolation === 'worktree'`. + +### 4.3 Provider resolution + config + +Config add (see primitives-spec §3): + +```ts +agentProviders?: { + enabled: AgentProviderId[] // order = preference; [0] = default + detectedAt?: string + lastProbe?: Array<{ id, available, detail? }> +} +``` + +Algorithm: `opts.provider` → `meta.defaultProvider` → first of `enabled ∩ liveAvailable`. +Missing `agentProviders` → compat all-available in code order. +Onboarding (`init`/`doctor`) probes PATH and writes `enabled`. +Unknown/unavailable: fail that `agent()` (throw → parallel null). + +### 4.4 Skills gating fix (required, not optional) + +Today `effectiveSkillPaths` drops **entire** bundled dir if user has seeded `subagent-delegation` — hides any new bundled skill. + +v1: include bundled **per-skill** (user copy wins on name collision). Seed `dynamic-workflows` in `user-config` next to subagent skill. + +### 4.5 MCP vs CLI symmetry + +| Op | CLI | MCP | +|---|---|---| +| Start | `workflow run --file\|--name\|--resume` | `run_workflow` | +| Poll | `status --follow` | `workflow_status` long-poll | +| Cancel | `cancel` | `workflow_cancel` | +| List | `ls` | (optional later; status by id enough v1) | + +Same store. Detached worker survives MCP session death (critical acceptance test). + +### 4.6 Replay: document deliberate CC divergence + +CC: longest unchanged **call-index** prefix. +v1: index+key, then **consume-once cacheKey** fallback (fan-out completion order). + +Document in SKILL under Resume. Do not pretend full CC resume identity. + +### 4.7 Sandbox choice + +Locked: `node:vm` + shadow Date/Math + no require/process/fetch/timers. +Host wall-clock max (default 6h). +Not SES (not in this tree; avoid new heavy dep). Accept vm is not a security boundary for hostile multi-tenant — DevSpace is single-user local. + +### 4.8 Cancel / kill + +1. `cancelRequested` flag. +2. Worker heartbeat (5s) → AbortController + journal `run_cancelled` + group SIGTERM. +3. Hard path after ≤5s: `terminateProcessTree` pid shim (existing `process-platform`). + +Known: in-flight adapter SDKs may not abort cleanly; group-kill is the backstop (already accepted). + +### 4.9 Pi timeout + +Document `PI_AGENT_TIMEOUT_MS = 120_000` in SKILL. Follow-up: make configurable — not milestone blocker. + +### 4.10 Script authoring feedback + +`run_workflow` / CLI parse **before** spawn. Syntax/meta errors return cheat-sheet snippet (tool desc + error). Line numbers preserved via export-strip + lineOffset. + +### 4.11 Concurrency + +`min(16, max(1, os.availableParallelism()-2))`, clamp by `meta.concurrency` if set. Semaphore gates **`agent()` only** (not pure JS stages). + +### 4.12 Named workflows paths + +1. `/.devspace/workflows/.js` +2. `~/.devspace/workflows/.js` (via config dir helper used by profiles) + +Name: `[a-z0-9-]+`. Persist exact source to `/workflows/runs/.js` for resume/edit. + +### 4.13 `workflow()` nest + +Same run, shared journal/semaphore/call counter, depth ≤ 1. Resolve name via `workflow-files`. No new process. + +### 4.14 package.json + +- Direct dep: `ajv` +- Tests: append new `*.test.ts` to existing per-file tsx chain +- Node engines already `>=22.19` (ok for `availableParallelism`) + +### 4.15 Docs location + +Keep design notes under `docs/dynamic-workflow/devspace/` (this plan + later runtime notes). Claude reference stays under `docs/dynamic-workflow/claude/`. + +### 4.16 `effort` rename (profiles + agent stack) + +| Today | Target | +|---|---| +| Profile `thinking:` | `effort:` | +| CLI `--thinking` | `--effort` (+ short deprecation alias optional) | +| DB/store `thinking` | `effort` (rename column in new mig or dual-read) | +| `LocalAgentRunInput.thinking` | `effort` | +| Workflow `agent()` opts | `effort` only | +| Replay cache key | includes `effort` | + +Provider-native strings pass through unchanged. + +### 4.17 `isolation: 'worktree'` (must-have) + +- Opt-in per call: `agent(prompt, { isolation: 'worktree', … })`. +- Default: shared `workspaceRoot`. +- Create under `config.worktreeRoot` / existing git-worktrees helpers; pin base SHA at run start. +- Adapter `cwd` = worktree path. +- Clean success → auto-remove; dirty/fail/cancel → preserve + journal `worktreePath`. +- **No** auto-merge into source. +- Non-git workspace → throw. +- Cache key includes `isolation`. +- Module touch: extend `workflow-api` + small worktree helper (wrap `git-worktrees.ts`). +- Skill: use for parallel mutators only. + +### 4.18 Milestone impact + +| Milestone | Extra | +|---|---| +| **3 Engine** | `isolation` path with fake/temp git repos in tests | +| **4 Worker+CLI** | real worktree create/cleanup; journal fields | +| **5 Resume** | cache key includes isolation | +| **8 Teach** | skill isolation + effort; seed `agentProviders` docs | +| Cross-cutting | rename `thinking`→`effort` in profile/CLI/store/adapters (can land with M3–4) | +| Config | `agentProviders` on user-config + init probe (with M4 or M8) | + +--- + +## 5. Script API (v1 contract — implement exactly) + +```js +export const meta = { + name: '…', + description: '…', + phases: [{ title: '…', detail?: '…' }], + // devspace-only: + defaultProvider?: 'codex'|'claude'|…, + concurrency?: number, +} + +phase('Review') +const rows = await parallel([ + () => agent(p1, { provider: 'claude', label: 'r1', effort: 'high', schema: S }), + () => agent(p2, { provider: 'codex', label: 'r2', schema: S }), +]) +const mut = await agent(implPrompt, { + provider: 'codex', + isolation: 'worktree', // parallel-safe writes + schema: DiffSummary, +}) +const out = await pipeline(items, stage1, stage2) +log('…') +// args, budget (stub), workflow(name, args?) +return { … } +``` + +Determinism bans: `Date.now`, `Math.random`, argless `new Date` → `WorkflowDeterminismError`. + +--- + +## 6. Milestones (same spine, sharper exit criteria) + +| # | Deliverable | Done when | +|---|---|---| +| **1 Journal** | schema + mig v4 + store + tests | create/append/drain/reap unit green | +| **2 Script/sandbox** | parse + vm + bans | meta edge cases + line nos + bans green | +| **3 Engine core** | api+engine, fake provider | semaphore, parallel null, pipeline no-barrier, phase ALS, nest depth | +| **4 Worker+CLI** | router, spawn, heartbeat, cancel, files | `--follow` log-only + 1 real provider; kill -9 → reap; cancel → group empty | +| **5 Resume** | replay + `--resume` | cancel mid-run; resume shows cached prefix events | +| **6 Schema** | ajv enforce + retries | bad JSON → schema_retry → success/exhaust | +| **7 MCP** | 3 tools + server wiring | Inspector: run+status; **kill MCP, worker still finishes** | +| **8 Teach** | skill, seed, skills.ts fix, instructions | fresh + pre-seeded config both advertise skill | + +E2E: `npm test` + `npm run typecheck`; live fan-out 2 providers CLI; same MCP; cancel+resume. + +--- + +## 7. Mapping to existing code (touch list) + +| Existing | Use | +|---|---| +| `local-agent-adapters.ts` / `runLocalAgentProvider` | `agent()` backend | +| `local-agent-availability.ts` | provider pick / error text | +| `local-agent-store.ts` | **pattern only** (not dual-write) | +| `cli.ts` `spawnAgentWorker` / `agents __worker` | copy for `workflow __worker` | +| `process-platform.terminateProcessTree` | hard cancel | +| `db/client` WAL + busy_timeout 5000 | multi-process journal | +| `server.ts` `registerAppTool` + `config.subagents` gate | tools only if subagents on | +| `skills.ts` / `user-config.ts` | gate fix + seed | +| `process-sessions` yield bounds | MCP status yield caps | + +--- + +## 8. Risk register (accepted + one process risk) + +| Risk | Mitigation | +|---|---| +| Adapter no abort | group-kill worker | +| Daemonizing child escapes group | document; SIGTERM+adapter finally | +| Pi 120s cap | SKILL note | +| Replay key fallback ≠ CC | document | +| Laptop sleep heartbeat false fail | `kill(pid,0)` before reap | +| Host model still shells `agents run` for graphs | skill + tool cheat-sheet steer to `run_workflow` | +| Long MCP poll vs proxy timeouts | yield ≤110s; client re-calls status | + +--- + +## 9. What we explicitly do **not** build in v1 + +- MCP tools for raw subagents +- Dashboard / live TUI +- Real token `budget` tied to host +- `writeMode` on `agent()` (isolation **is** in scope) +- Auto-merge of agent worktrees into source checkout +- Auto file-change / diff events per stage +- Declaring DAG JSON alternate API (script is the API) +- Dual-write to `local_agent_sessions` +- SES lockdown + +--- + +## 10. Implementation order for a coding agent + +1. Mig + store (no behavior risk). +2. Script + sandbox (pure). +3. Engine against fakes (locks API). +4. Wire CLI worker to real adapters. +5. Replay. +6. Schema. +7. MCP. +8. Skill/docs/gating. + +Do not open MCP before CLI smoke — debug path must work headless without a host. + +--- + +## Resolved questions (see also [primitives-spec.md](./primitives-spec.md)) + +1. **Default provider:** `opts.provider` → `meta.defaultProvider` → first of **onboarding-configured** `agentProviders.enabled` ∩ live available. Full config schema in primitives-spec §3. +2. **writeMode:** **not in v1 API**; skill teaches prompt-based RO/write. +3. **Isolation:** **`isolation?: 'worktree'` is v1 must-have** on `agent()`; default shared; no auto-merge. +4. **Effort rename:** `thinking` → **`effort`** across profiles, CLI, store, adapters, `agent()` opts, cache keys. +5. **MCP list:** skip; **CLI** `workflow ls` yes. +6. **Size caps:** transport/storage bounds (§8 of primitives-spec); not “coverage” truncation. +7. **Nested workflow:** CC-like `name | { scriptPath }`, depth 1, shared journal/semaphore. +8. **Cancel:** cooperative flag → then group-kill. + +**File-change tracking:** out of scope. +**Schema:** `opts.schema` + Ajv + retries — in scope. \ No newline at end of file diff --git a/docs/dynamic-workflow/devspace/primitives-spec.md b/docs/dynamic-workflow/devspace/primitives-spec.md new file mode 100644 index 000000000..ef53d5966 --- /dev/null +++ b/docs/dynamic-workflow/devspace/primitives-spec.md @@ -0,0 +1,864 @@ +# DevSpace Dynamic Workflow — Primitives & API Spec + +Implementation + contract spec for every surface, inspired by Claude Code’s Workflow environment. +Pairs with [plan.md](./plan.md). Subagents remain CLI-only; this document is **workflow only**. + +--- + +## 0. Product goals (locks) + +| Goal | Surface | +|---|---| +| DW for coding agents that lack Workflow (pi, codex, opencode, cursor, …) | **CLI + skill** — host agent authors script, runs `devspace workflow *` | +| ChatGPT as orchestrator, not implementer | **MCP workflow tools** (togglable with subagents) — plan + `run_workflow` / status / cancel | +| Ship both in dev | One engine; two entrypoints; converge later on performance/UX | + +``` +coding agent ── skill + CLI ──► engine ── agent() ──► adapters +ChatGPT ── MCP tools ──► engine ── agent() ──► adapters +``` + +--- + +## 1. Resolved decisions + +| # | Topic | Decision | +|---|---|---| +| 1 | Default provider | **Configured enabled provider list** (onboarding auto-detect CLIs → `config.json`). Runtime: `opts.provider` → `meta.defaultProvider` → **first entry of enabled+available list**. | +| 2 | Access / writeMode | **Not in v1 API.** No `writeMode`. Skill teaches **prompt-based** RO vs write. Isolation handles *where* writes land (see isolation). | +| 3 | List runs | **No MCP list tool v1.** **CLI** `devspace workflow ls` yes. | +| 4 | Size caps | Soft/hard bounds on journal + results (§8). | +| 5 | Nested `workflow()` | CC-inspired: `name \| { scriptPath }`, depth 1, shared journal/semaphore (§7.8). | +| 6 | Cancel | Cooperative flag → worker abort → hard `terminateProcessTree` (§9). | +| 7 | **`effort` rename** | Profile frontmatter, CLI (`--effort`), store column, runtime input, and `agent()` opts use **`effort`** (not `thinking`). Adapters map `effort` → provider-native flags. | +| 8 | **`isolation`** | **Must-have v1** on `agent()`: `opts.isolation?: 'worktree'`. Shared checkout default; worktree when set (§7.1, §7.1b). | +| — | File-change tracking | Out of scope. Shared disk / worktree is truth; no auto per-stage diff. | +| — | Structured output | **In scope:** `opts.schema` + Ajv + retries (§7.1). | + +--- + +## 2. Claude Code inspiration map + +| CC concept | CC behavior (model-facing) | DevSpace v1 | +|---|---|---| +| `Workflow` tool | Host tool; async; script/name/scriptPath/args/resume | CLI `workflow run` + MCP `run_workflow` | +| `export const meta` | Pure literal; name, description, phases | Same + optional `defaultProvider`, `concurrency` | +| `agent(prompt, opts)` | Spawn worker; string or schema object; null on skip/death in combinators | Same return contract; **throw** on failure; `parallel` → null | +| `opts.schema` | StructuredOutput / validated object | Ajv enforce + retry in engine | +| `opts.model` / `effort` | Tier/effort overrides | `model` + **`effort`** (renamed from `thinking`; provider passthrough) | +| `opts.isolation: 'worktree'` | Per-agent worktree | **v1 must-have** — same semantics, DevSpace-managed worktrees | +| Access / sandbox | Session permission mode; not `writeMode` on agent() | Prompt RO/write + **isolation for write containment** | +| `pipeline` | No barrier; per-item chains | Same | +| `parallel` | Barrier; null slots | Same | +| `phase` / `log` | Progress UX | Journal events + CLI follow / MCP drain | +| `args` | Verbatim tool args | Same | +| `budget` | Shared host token hard ceiling | **Stub** `{ total: null, spent:0, remaining: Infinity }` | +| `workflow()` | Nested name/scriptPath; depth 1; shared caps | Same spirit | +| Determinism bans | Date.now / Math.random / bare new Date | Same | +| Resume | Prefix cache by prompt+opts | Index+key + consume-once cacheKey fallback | +| File diffs per stage | **Not a primitive** | Same — no auto-diff | + +--- + +## 3. Config: agent providers (what to add) + +### Today (`DevspaceUserConfig` / `.devspace/config.json`) + +Existing fields (unchanged conceptually): + +```ts +// src/user-config.ts — today +interface DevspaceUserConfig { + host?: string + port?: number + allowedRoots?: string[] + publicBaseUrl?: string | null + allowedHosts?: string[] + stateDir?: string + worktreeRoot?: string + agentDir?: string + subagents?: boolean // master switch only +} +``` + +There is **no** persisted enable-list. Runtime exposes every implemented provider that is **currently on PATH** (`getLocalAgentProviderAvailabilitySnapshot`). Order is code order of `LOCAL_AGENT_PROVIDERS`, not user preference. No onboarding write-back. + +### Add: `agentProviders` on user config + +```ts +/** Known built-in ids — keep in sync with LocalAgentProvider */ +type AgentProviderId = + | "codex" + | "claude" + | "opencode" + | "pi" + | "cursor" + | "copilot" + +interface AgentProvidersConfig { + /** + * Ordered enable-list. Order = preference. + * index 0 = default fallback for agent() when provider omitted. + * Only ids in this list may be used by workflows/subagents (if present). + * Missing/empty → fall back to "all currently available" (compat) OR + * require init (prefer: treat missing as "auto = all available in code order"). + */ + enabled: AgentProviderId[] + + /** ISO time of last successful probe (init/doctor). Optional. */ + detectedAt?: string + + /** + * Optional last probe snapshot for doctor UI (not required at runtime). + * Do not use as source of truth for enablement — `enabled` is. + */ + lastProbe?: Array<{ + id: AgentProviderId + available: boolean + detail?: string // path or error + }> +} + +interface DevspaceUserConfig { + // ...existing... + subagents?: boolean + agentProviders?: AgentProvidersConfig // NEW +} +``` + +### Example `~/.devspace/config.json` + +```json +{ + "host": "127.0.0.1", + "port": 7676, + "allowedRoots": ["/home/you/work"], + "subagents": true, + "agentProviders": { + "enabled": ["codex", "claude", "opencode", "pi"], + "detectedAt": "2026-07-21T12:00:00.000Z", + "lastProbe": [ + { "id": "codex", "available": true, "detail": "/usr/bin/codex" }, + { "id": "claude", "available": true, "detail": "/home/you/.local/bin/claude" }, + { "id": "opencode", "available": true }, + { "id": "pi", "available": true }, + { "id": "cursor", "available": false, "detail": "not found" }, + { "id": "copilot", "available": false, "detail": "not found" } + ] + } +} +``` + +### Semantics + +| Concern | Spec | +|---|---| +| **Master switch** | `subagents: true` still required for workflow tools + agent CLI + skills. | +| **Enable-list** | `agentProviders.enabled` is the only user-facing allowlist. | +| **Order** | First entry = default `agent()` provider after availability filter. | +| **Live ∩ config** | `candidates = enabled.filter(id => currentlyAvailable(id))`. Stale enable of uninstalled CLI → skipped with doctor warning, not hard-fail until no candidates. | +| **Unknown ids** | Reject on write/init; ignore-with-warn at read if config hand-edited. | +| **Missing `agentProviders`** | Compat: `enabled` effective = all available in built-in order (today’s behavior). Init should still write the block. | +| **Empty `enabled: []`** | Error at first `agent()` / `agents run`: “no providers enabled”. | +| **Env override (optional)** | `DEVSPACE_AGENT_PROVIDERS=codex,claude` replaces `enabled` for process (ops/debug). | +| **ServerConfig** | Load into `ServerConfig.agentProviders: { enabled: AgentProviderId[] }` resolved at boot. | + +### Onboarding (`devspace init` / `doctor`) + +1. Probe all six providers (reuse `local-agent-availability`). +2. Set `enabled` = available ids in **stable product order**: + `codex → claude → opencode → pi → cursor → copilot` (only those available). +3. Write `detectedAt` + optional `lastProbe`. +4. `doctor` re-probes; offers to refresh `enabled` (add newly installed; optionally keep user-disabled by not auto-re-adding removed ids — v1: refresh = rewrite available set, document that). + +### Default provider algorithm + +``` +resolveProvider(opts, meta, config): + enabled = config.agentProviders?.enabled + ?? ALL_IMPLEMENTED_IN_CODE_ORDER + candidates = enabled ∩ liveAvailable(PATH) + if opts.provider: + if opts.provider ∉ enabled → throw (disabled in config) + if opts.provider ∉ liveAvailable → throw (not installed) + return opts.provider + if meta.defaultProvider: + same checks against candidates + return meta.defaultProvider + if candidates[0] → return candidates[0] + throw NoProviderError +``` + +Skill: “Pass `provider` when you care; else first enabled+available provider.” +--- + +## 4. Entry surfaces + +### 4.1 CLI + +``` +devspace workflow run (--file | --name | --resume ) + [--arg key=value]... [--follow] +devspace workflow status [--follow] +devspace workflow cancel +devspace workflow ls +devspace workflow __worker # hidden +``` + +| Flag | Spec | +|---|---| +| `--file` | Read script from path (must be under allowed roots when policy applies). | +| `--name` | Resolve via [§6 named files](#6-script-sources). | +| `--resume` | New run row; replay journal from prior runId. | +| `--arg k=v` | Build `args` object (values: JSON-parse if possible else string). | +| `--follow` | Drain events until terminal; print log/phase/agent lines. | + +Spawn: same pattern as `agents __worker` (detached, stdio ignore, unref). Inputs only from run row. + +### 4.2 MCP (togglable with `config.subagents`) + +| Tool | Input | Output (conceptual) | +|---|---|---| +| `run_workflow` | `workspaceId`, `script?` \| `name?` \| `resumeFromRunId?`, `args?`, `yieldTimeMs?` | `{ runId, status, events, nextSeq, result? }` after parse+spawn+short yield | +| `workflow_status` | `runId`, `sinceSeq?`, `yieldTimeMs?` | long-poll events / terminal | +| `workflow_cancel` | `runId` | `{ runId, status }` | + +**No** `workflow_ls` on MCP v1. +**No** `agent_*` MCP tools. + +Tool description embeds ~25-line API cheat-sheet (CC-style education in-band). + +### 4.3 Skill + +`skills/dynamic-workflows/SKILL.md` (+ seed on init): + +- When to use CLI vs when host is ChatGPT (MCP). +- Full primitive reference. +- Prompt patterns for read-only vs write (instead of writeMode). +- Provider list / default fallback. +- Schema examples, resume, cancel, 3 worked examples. + +--- + +## 5. Script contract + +### 5.1 Shape + +```js +export const meta = { + name: 'review-auth', + description: 'Fan-out review of auth changes', + phases: [ + { title: 'Review', detail: 'parallel reviewers' }, + { title: 'Synthesize' }, + ], + // DevSpace extensions (optional): + defaultProvider: 'codex', + concurrency: 4, +} + +// body — async IIFE context +phase('Review') +// ... +return { summary } +``` + +### 5.2 `meta` rules (CC + DS) + +| Rule | Spec | +|---|---| +| First statement | `export const meta = {…}` | +| Pure literal | No vars, calls, spreads, templates in meta object | +| Required | `name`, `description` | +| Optional CC | `phases[]` `{ title, detail? }`, `whenToUse?` | +| Optional DS | `defaultProvider?`, `concurrency?` (clamped to engine max) | +| Validation | Zod `WorkflowMetaSchema` + JSON round-trip purity | +| Extract | Regex start + balanced-brace scanner; `vm.runInNewContext('('+literal+')')` | + +### 5.3 Transform pipeline (`workflow-script.ts`) + +1. Extract/validate meta. +2. Strip leading `export ` → 7 spaces (preserve line numbers). +3. Reject stray `import` / top-level `export` after meta. +4. Wrap: + +```js +(async ({ agent, parallel, pipeline, phase, log, args, budget, workflow, meta, console }) => { + // user body +}) +``` + +5. `new vm.Script(wrapped, { filename: 'workflow:'+name, lineOffset: -1 })`. +6. Friendly errors: missing meta, syntax (with line), purity fail. + +### 5.4 Language bans (CC) + +| Banned in script | Behavior | +|---|---| +| `Date.now()` | `WorkflowDeterminismError` | +| `Math.random()` | same | +| argless `new Date()` | same | +| `require` / `process` / `fetch` / timers | not in context | +| TypeScript syntax | parse fail | + +Allowed: normal JS, `JSON`, `Array`, `Map`, `Set`, `Date.parse`, `new Date(isoString)`. + +`console.log/warn/error` → `log` events. + +--- + +## 6. Script sources + +| Source | Resolution | +|---|---| +| Inline (`--file` content / MCP `script`) | Persist to `/workflows/runs/.js` | +| Named (`--name` / MCP `name`) | (1) `/.devspace/workflows/.js` (2) `~/.devspace/workflows/.js` | +| Resume | Load persisted path on prior run (user may edit that copy) | + +Name sanitization: `^[a-z0-9-]+$`. +Run row stores `scriptPath`, `scriptHash`, `source: inline|named`. + +--- + +## 7. Primitives (spec + implementation) + +All injected into the sandbox. Host deps: `{ journal, runProvider, availableProviders, replay?, concurrency, signal, workspaceRoot }`. + +--- + +### 7.1 `agent(prompt, opts?)` + +#### Spec (public) + +```ts +type AgentOpts = { + label?: string + phase?: string // overrides current ALS phase for this call + schema?: object // JSON Schema → validated object return + model?: string + effort?: string // was "thinking"; provider-native effort/reasoning level + provider?: string // DevSpace; default via §3 + isolation?: "worktree" // must-have; omit = shared workspace root + // NO writeMode in v1 +} + +function agent(prompt: string, opts?: AgentOpts): Promise +// with schema → Promise (validated) +// without → Promise (finalResponse text) +``` + +| Behavior | Spec | +|---|---| +| Failure | **Throw**. `parallel` maps throw → `null`. | +| Success string | Adapter `finalResponse`. | +| Success schema | Validated object; raw text also journaled. | +| Call index | Program order at invocation (before semaphore). | +| Semaphore | Only `agent()` acquires permit. | +| Cancel | Abort signal → throw cancelled. | +| Replay | Cache key includes isolation; hits journal `from_cache`. | +| Isolation | See §7.1b. | + +#### Implementation notes + +``` +async function agent(prompt, opts) { + const callIndex = nextCallIndex() + const provider = resolveProvider(opts, meta, config) + const phase = opts.phase ?? alsPhase.getStore() + const isolation = opts.isolation === "worktree" ? "worktree" : "shared" + const cacheKey = sha256(canonicalJson({ + prompt, provider, + model: opts.model ?? null, + effort: opts.effort ?? null, + schema: opts.schema ?? null, + isolation, + })) + if (replay) { + const hit = replay.match(callIndex, cacheKey) + if (hit) { journal.completeCached(...); return hit.value } + } + await semaphore.acquire(signal) + let worktree: WorktreeHandle | null = null + try { + journal.beginAgentCall({ callIndex, cacheKey, provider, isolation, ... }) + const cwd = isolation === "worktree" + ? (worktree = await createAgentWorktree({ runId, callIndex, workspaceRoot })).path + : workspaceRoot + const run = (p) => runProvider({ + provider, prompt: p, model: opts.model, effort: opts.effort, workspace: cwd, + }) + const result = opts.schema + ? await enforceSchema({ schema: opts.schema, prompt, run, journal, callIndex }) + : (await run(prompt)).finalResponse + journal.completeAgentCall(...) + return result + } catch (e) { + journal.failAgentCall(...) + throw e + } finally { + semaphore.release() + if (worktree) await finalizeAgentWorktree(worktree) // §7.1b + } +} +``` + +`runProvider` wraps `runLocalAgentProvider` with **`effort`** (not `thinking`) on `LocalAgentRunInput`. **No** `local_agent_sessions` dual-write v1. + +### 7.1b `isolation: 'worktree'` (must-have) + +Inspired by CC: expensive (~setup+disk); use when parallel **mutators** would conflict. Not a read-only switch. + +| Rule | Spec | +|---|---| +| Default | Omit / undefined → agent `cwd` = workflow `workspaceRoot` (shared checkout). | +| `"worktree"` | Fresh git worktree under managed root (reuse `config.worktreeRoot` / existing git-worktrees helpers). | +| Base | Pin to workspace HEAD (or open-workspace base SHA if known) at **run start**; all worktrees for the run share that pin unless documented otherwise. | +| Path layout | e.g. `/wf//c/` or UUID; must stay inside managed root. | +| Adapter cwd | Provider runs with `workspace: worktreePath`. | +| Success + dirty | **Preserve** worktree; journal `worktreePath` + `dirty: true` on agent_call / event data. **Do not** auto-merge/cherry-pick into source. | +| Success + clean | Optional auto-remove (CC: remove if unchanged). v1: remove if `git status` clean. | +| Failure / cancel | Preserve for diagnosis; retention e.g. 7d cleanup job later; v1: leave on disk + path in journal. | +| Handoff | Later stages **do not** see worktree files unless they use the same path or agent return text lists paths. Prefer **schema returns** for findings; implementer stages that must compose should use **shared** isolation or sequential shared agents. | +| Parallel safety | Multiple `isolation: 'worktree'` agents concurrent = OK. Mixing worktree + shared writers = caller responsibility (skill: don’t). | +| Non-git workspace | `isolation: 'worktree'` → throw clear error (worktrees require git). | +| Cost | Skill: use only for parallel mutators. | +| Cache key | Includes `isolation` so resume doesn’t reuse shared result for worktree call. | + +Events/data extras: + +```ts +// agent_call_started / completed data +{ worktreePath?: string, isolation: "shared" | "worktree", dirty?: boolean } +``` + +**Not v1:** auto-apply worktree diffs to main checkout; multi-worktree merge tools. +#### Structured output (`workflow-schema.ts`) + +Inspired by CC `schema` → StructuredOutput: + +1. Augment prompt: respond with **only** JSON conforming to schema. +2. Run provider. +3. Extract JSON (fences strip + balanced-brace). +4. Ajv validate (`allErrors: true`, `strict: false`). +5. On fail: journal `schema_retry`; re-run with error text; reuse `providerSessionId` if adapter returned one (max 2 retries). +6. Exhaustion → throw; parallel → null. + +--- + +### 7.2 `parallel(thunks)` + +#### Spec (CC) + +```ts +function parallel(thunks: Array<() => Promise>): Promise> +``` + +| Rule | Spec | +|---|---| +| Barrier | Await all thunks before resolve. | +| Error | Thunk throw / agent throw → that index `null`; **parallel never rejects**. | +| Empty | `[]` → `[]`. | +| Cap | Max **4096** thunks (hard error). | +| Concurrency | Limited by agent semaphore only (thunks can start together; agents queue). | + +#### Implementation + +```js +async function parallel(thunks) { + assertMaxItems(thunks.length) + const results = await Promise.all( + thunks.map(t => t().then(v => v, () => null)) + ) + return results +} +``` + +--- + +### 7.3 `pipeline(items, ...stages)` + +#### Spec (CC) + +```ts +type Stage = (prev: any, originalItem: any, index: number) => any | Promise + +function pipeline(items: any[], ...stages: Stage[]): Promise +``` + +| Rule | Spec | +|---|---| +| Sync | **No barrier** between stages across items. | +| Per item | Sequential stages for that item’s chain. | +| Stage args | `(prevResult, originalItem, index)`. First stage `prev` = item. | +| Throw | That item becomes `null`; remaining stages skipped for it. | +| Cap | Max **4096** items. | +| Wall-clock | ≈ slowest item chain (true concurrency across items). | + +#### Implementation sketch + +```js +async function pipeline(items, ...stages) { + assertMaxItems(items.length) + return Promise.all(items.map((item, index) => + (async () => { + let prev = item + for (const stage of stages) { + try { prev = await stage(prev, item, index) } + catch { return null } + } + return prev + })() + )) +} +``` + +--- + +### 7.4 `phase(title)` + +#### Spec (CC) + +```ts +function phase(title: string): void +``` + +| Rule | Spec | +|---|---| +| Effect | Sets **current phase** for subsequent agents without `opts.phase`. | +| Events | Journal `phase_started` (and optional end on next phase). | +| Concurrency | **AsyncLocalStorage** so concurrent pipeline chains don’t race. | +| UI | CLI `--follow` / MCP events group by phase; match `meta.phases[].title` when possible. | + +```js +function phase(title) { + alsPhase.enterWith(title) // or run with ALS in engine wrapper + journal.appendEvent({ type: 'phase_started', phase: title }) +} +``` + +Prefer documenting: inside concurrent stages set `opts.phase` explicitly (same advice as CC). + +--- + +### 7.5 `log(message)` + +#### Spec (CC) + +```ts +function log(message: string): void +``` + +- Journal `log` event; data truncated per §8. +- CLI follow prints narrator lines. +- Skill: log drops/caps (“no silent caps”). + +`console.log` → same path. + +--- + +### 7.6 `args` + +#### Spec (CC) + +```ts +const args: unknown // frozen; from run input; undefined if omitted +``` + +| Rule | Spec | +|---|---| +| MCP | Pass real JSON object/array — not stringified JSON string. | +| CLI | `--arg k=v` → object; values JSON-parsed when valid. | +| Freeze | `Object.freeze` deep where practical. | +| Resume | Same args required for max cache hits when prompts embed args. | + +--- + +### 7.7 `budget` (stub v1) + +#### Spec (CC shape, stub values) + +```ts +const budget = Object.freeze({ + total: null as number | null, + spent(): number { return 0 }, + remaining(): number { return Infinity }, +}) +``` + +| Future | Wire `total` from CLI/MCP optional `maxAgentCalls` or token directive; hard-throw when exceeded. | +| v1 | Shape present so scripts/skills match CC; loops must still use dry-round or count, not infinite budget loops. | + +Skill warns: do not `while (budget.remaining() > x)` without other exit — remaining is Infinity. + +--- + +### 7.8 `workflow(nameOrRef, args?)` — nested + +#### How CC behaves (inspiration) + +- `workflow(name | { scriptPath }, args?)` +- Runs child **inline** in same run. +- Shares concurrency cap, agent counter, abort, token budget. +- Child agents appear nested in progress UI. +- **Depth 1 only** — nest inside child throws. +- Return value = child’s script return. +- Errors: unknown name / unreadable path / syntax → throw. + +#### DevSpace v1 + +```ts +function workflow( + nameOrRef: string | { scriptPath: string }, + childArgs?: unknown, +): Promise +``` + +| Rule | Spec | +|---|---| +| `string` | Resolve named file (§6). | +| `{ scriptPath }` | Absolute/resolved path to `.js` (must pass root allowlist if enforced). | +| Depth | `nestDepth` ALS/counter; `> 1` → throw. | +| Shared | Same journal runId, semaphore, call-index sequence, cancel signal. | +| Meta | Child meta used for phase titles optionally; run name stays parent. | +| Events | Optional `phase` prefix or `label: nest:childName`. | +| No new process | In-process second script execute. | +| Resume | Child `agent()` calls continue global callIndex — replay still works. | + +```js +async function workflow(nameOrRef, childArgs) { + if (nestDepth >= 1) throw new Error('workflow() nesting limited to one level') + const source = resolveNestedSource(nameOrRef, workspaceRoot) + const parsed = parseWorkflowScript(source) + return executeNested({ parsed, args: childArgs, nestDepth: nestDepth + 1, ...sharedDeps }) +} +``` + +--- + +## 8. Size caps (education + defaults) + +### Why caps exist + +Without bounds: + +- One agent can return multi‑MB logs → SQLite bloat, slow drain. +- MCP tool results can exceed host message limits. +- Event `dataJson` spam freezes `--follow`. +- Malicious/buggy script `return` of huge graphs. + +This is **not** semantic truncation of “coverage”; it’s **transport/storage safety**. Skill still says: if you intentionally sample files, `log()` that you did. + +### Recommended v1 limits + +| Asset | Cap | On exceed | +|---|---|---| +| Event `dataJson` | ~8 KiB string | Truncate + `"truncated": true` | +| `responseText` on agent_calls | e.g. 1 MiB | Truncate stored copy; prefer schema path for structure | +| `structuredJson` | e.g. 256 KiB | Fail agent call (throw) | +| Script `return` → `resultJson` | e.g. 256 KiB | Fail run `errorKind: 'result_too_large'` | +| `args` JSON | e.g. 64 KiB | Reject at createRun | +| Inline script source | e.g. 512 KiB | Reject at parse | +| Events drain page | limit param default 100–500 | Cursor `nextSeq` | + +Numbers can be constants in `workflow-store.ts`; tune later. + +--- + +## 9. Cancel, heartbeat, reap + +| Step | Spec | +|---|---| +| Heartbeat | Worker every 5s updates `heartbeatAt`; polls `cancelRequested`. | +| Cooperative | Set flag → worker AbortController → journal `run_cancelled` → group SIGTERM. | +| Hard | After ≤5s: `terminateProcessTree` on pid; mark cancelled. | +| Reap | `heartbeat` stale >60s **and** `kill(pid,0)` dead → mark failed `errorKind: 'heartbeat'`. | +| Sleep gap | Liveness check avoids false fail after laptop sleep. | + +Adapters: no individual abort API — accepted; group-kill is backstop. + +--- + +## 10. Resume / replay + +| Piece | Spec | +|---|---| +| New run | `--resume` / `resumeFromRunId` creates new run with `resumedFromRunId`. | +| Cache key | `sha256(canonicalJson({ prompt, provider, model, effort, schema, isolation }))` | +| Match | (1) same callIndex + key (2) on first miss, consume-once by key (fan-out order). | +| Record | Cache hits written as new rows `from_cache=1` so chains chain. | +| Determinism | Bans make prompt construction stable if args fixed. | + +Document CC divergence (consume-once) in skill. + +--- + +## 11. Journal schema (behavioral) + +### `workflow_runs` + +id, name, source, scriptPath, scriptHash, workspaceRoot, workspaceId?, argsJson, status (`starting|running|completed|failed|cancelled`), error?, errorKind?, resultJson?, pid?, heartbeatAt?, cancelRequested, resumedFromRunId?, timestamps. + +### `workflow_events` + +(runId, seq) PK; type enum including `run_started`, `phase_started`, `log`, `agent_call_*`, `schema_retry`, `run_*`; phase; label; dataJson truncated. + +### `workflow_agent_calls` + +(runId, callIndex) PK; cacheKey; provider; model; label; phase; status; fromCache; providerSessionId?; responseText; structuredJson?; error?; times. + +Adapter `items[]` **not** persisted. + +--- + +## 12. Access model: prompt + isolation (no writeMode) + +### What Claude Code does + +CC `agent()` opts include `label`, `phase`, `schema`, `model`, `effort`, **`isolation`**, `agentType` — **not** `writeMode`. + +| Layer | Role | +|---|---| +| Host permission mode | Approve / bypass tools | +| `agentType` / tools | Read-oriented vs full agents | +| **`isolation: 'worktree'`** | Mutations in private tree; no auto-merge | +| **Prompt** | “Do not modify files” / implementer instructions | + +### What DevSpace does in v1 + +| Layer | Behavior | +|---|---| +| API | **No writeMode**; **yes `isolation?: 'worktree'`** | +| Adapter | Fixed yolo-style policy (current profile behavior) | +| Isolation | Engine creates managed worktree; cwd for that agent only | +| Skill | RO vs write **prompts** + when to set isolation | + +```text +READ-ONLY reviewer: +- Do not modify files. Return findings via schema. + +IMPLEMENTER (shared tree — sequential): +- Minimal edits; report paths. + +IMPLEMENTER (parallel): +- isolation: 'worktree' +- Report worktree-relative paths + summary in return value. +- Orchestrator decides merge; engine will not auto-merge. +``` +--- + +## 13. File changes (explicit non-primitive) + +| Approach | v1 | +|---|---| +| Shared workspace; later agents see prior edits on disk | Yes | +| Return structured paths/findings between stages | Yes (schema) | +| Auto git snapshot / diff after each agent | **No** | +| Per-agent worktree | **No** (follow-up) | +| Host `show_changes` after whole workflow | Optional host behavior; not engine | + +--- + +## 14. End-to-end authoring examples + +### Fan-out review (ChatGPT or local agent) + +```js +export const meta = { + name: 'fanout-review', + description: 'Two reviewers then synthesize', + phases: [{ title: 'Review' }, { title: 'Synthesize' }], +} + +const S = { /* FINDINGS schema */ } +phase('Review') +const reviews = await parallel([ + () => agent('Read-only review security…', { provider: 'claude', label: 'sec', schema: S }), + () => agent('Read-only review tests…', { provider: 'codex', label: 'test', schema: S }), +]) +phase('Synthesize') +const summary = await agent( + `Merge findings:\n${JSON.stringify(reviews.filter(Boolean))}`, + { label: 'merge', schema: { type: 'object', properties: { summary: { type: 'string' } }, required: ['summary'] } }, +) +return { reviews, summary } +``` + +### Pipeline over files (coding agent CLI) + +```js +export const meta = { + name: 'migrate-files', + description: 'Per-file transform', + phases: [{ title: 'Edit' }], +} + +const files = args.files +return pipeline( + files, + (f) => agent(`Update imports in ${f}. Minimal edit. Report path.`, { + label: `edit:${f}`, + phase: 'Edit', + }), +) +``` + +--- + +## 15. Implementation checklist (by primitive) + +| Primitive / surface | Module | Tests focus | +|---|---|---| +| meta parse | `workflow-script.ts` | purity, line nos, missing meta | +| sandbox bans | `workflow-sandbox.ts` | Date/Math throw; console→log | +| agent | `workflow-api.ts` | provider resolve, throw, callIndex order | +| schema | `workflow-schema.ts` | retry, validate, exhaust | +| parallel | `workflow-api.ts` | null on error, barrier | +| pipeline | `workflow-api.ts` | no-barrier proof, stage args | +| phase ALS | `workflow-api.ts` | concurrent chains | +| log / args / budget | `workflow-api.ts` | freeze, stub budget | +| workflow nest | `workflow-api.ts` + engine | depth 1, shared journal | +| store | `workflow-store.ts` | seq, reap, cancel | +| replay | `workflow-replay.ts` | index+key, consume-once | +| CLI | `cli.ts` | run/status/cancel/ls/__worker | +| MCP | `workflow-tools.ts` | yield, survive disconnect | +| skill | `skills/dynamic-workflows` | education | +| providers config | `user-config` / init / availability | ordered default | + +--- + +## 16. Non-goals recap (v1) + +- MCP raw agent tools +- `writeMode` on `agent()` (isolation **is** in scope) +- Auto-merge of worktrees into source checkout +- Real host token budget +- Auto file-change / diff events per stage +- MCP run list +- Dashboard +- Dual-write `local_agent_sessions` + +--- + +## 17. `effort` rename (profiles + runtime + agent opts) + +| Surface today | Target | +|---|---| +| Profile YAML `thinking:` | `effort:` | +| CLI `devspace agents run --thinking` | `--effort` | +| `LocalAgentRecord.thinking` / DB column | `effort` (migration: rename column or accept both briefly) | +| `LocalAgentRunInput.thinking` | `effort` | +| Adapter mapping (`modelReasoningEffort`, claude effort, pi `--thinking`) | Read from `input.effort` | +| Docs / examples / skill | `effort` only | +| Workflow `agent()` opts | `effort` only | +| Workflow journal / cache key | `effort` | + +Provider passthrough values stay free strings (`low`, `high`, `xhigh`, …) — DevSpace does not translate between providers. + +**Compat (optional short window):** read profile `thinking` if `effort` missing; CLI accept `--thinking` as alias deprecated. Prefer clean break if you’re fine breaking profile files (examples are under our control). + +## 18. Open only if product changes mind + +1. Exact byte constants for §8. +2. Nested `{ scriptPath }` must be under workspace only? +3. Worktree retention days / cleanup job timing. +4. Whether `agents run` CLI also gains `--isolation worktree` (workflow-first is enough for v1). From 2800b806a15b539654e9a2892012b2e3072afbe4 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 21 Jul 2026 12:10:52 +0530 Subject: [PATCH 003/132] html response --- docs/claude-code-dynamic-workflows.html | 1150 +++++++++++++++++++++++ 1 file changed, 1150 insertions(+) create mode 100644 docs/claude-code-dynamic-workflows.html diff --git a/docs/claude-code-dynamic-workflows.html b/docs/claude-code-dynamic-workflows.html new file mode 100644 index 000000000..d7fa34ac3 --- /dev/null +++ b/docs/claude-code-dynamic-workflows.html @@ -0,0 +1,1150 @@ + + + + + + + Claude Code Dynamic Workflows — API & Primitives Reference + + + + + + + + + +
+
+
+
Claude Code · 2.1.x
+

Dynamic Workflows — API & Primitives

+
+
+ Workflow tool + agent / pipeline / parallel + resume + budget + orchestrator tier +
+
+
+ +
+ + + +
+ + +
+
+

01 · Thesis

+

+ Deterministic control flow.
+ Stochastic workers. One orchestrator brain. +

+
+
+
+

Problem

+

A single agent loop confuses what to do next with how to do the work. Fan-out, verification, and synthesis become ad-hoc tool calls that the model re-invents every turn.

+
+
+

Mechanism

+

Claude Code exposes a Workflow tool. The model authors a short plain-JS script. The harness runs that script: loops, conditionals, and fan-out are code — not free-form model decisions mid-orchestration.

+
+
+

Payoff

+

A stronger “orchestrator” model designs the graph once. Weaker/cheaper or specialized subagents execute units of work. Scale, confidence, and isolation become programmable.

+
+
+
+ Key insight. Dynamic workflows are not “another agent.” They are a programmable multi-agent runtime the model can call. The script is the plan; agent() is the only way work escapes into a model. +
+
+ + +
+
+

02 · Architecture

+

Three layers

+
+ +
+
Main session
orchestrator model
+
Workflow tool
+
JS runtime
script + hooks
+
agent()
+
Subagents
N isolated workers
+
+ +
+
+

1. Coordinator (main loop)

+
    +
  • Talks to the user
  • +
  • Scouts the repo / work-list
  • +
  • Authors or selects a workflow script
  • +
  • Calls the Workflow tool
  • +
  • Synthesizes the returned result
  • +
+
+
+

2. Workflow engine

+
    +
  • Parses export const meta
  • +
  • Runs the script in an async JS context
  • +
  • Hosts agent / pipeline / parallel / phase / log / budget / workflow
  • +
  • Enforces concurrency & agent caps
  • +
  • Journals each agent call for resume
  • +
+
+
+

3. Subagents

+
    +
  • Own tool loops (Read, Bash, Edit, …)
  • +
  • Optional structured output via schema
  • +
  • Optional worktree isolation
  • +
  • Optional model / effort / agentType overrides
  • +
  • Final text (or validated object) is the return value
  • +
+
+
+ +
+

Mental model

+
// Coordinator decides STRUCTURE +Workflow({ script, args }) + → JS engine runs control flow + → agent(prompt, opts) × N // workers decide CONTENT + → script return value +→ coordinator narrates to user
+
+
+ + +
+
+

03 · Opt-in policy

+

When the model may call Workflow

+
+

+ Workflows can spawn dozens of agents and burn large token budgets. Claude Code therefore gates the tool behind explicit user opt-in. A task that would “benefit from parallelism” is not enough. +

+
+
+

Allowed triggers

+
    +
  • User prompt contains the keyword ultracode (system-reminder confirms).
  • +
  • Session ultracode is on (standing opt-in for every substantive task).
  • +
  • User asks in their own words: “use a workflow”, “fan out agents”, “orchestrate with subagents”, etc.
  • +
  • A skill / slash command’s instructions tell the model to call Workflow.
  • +
  • User asks for a specific named / saved workflow.
  • +
+
+
+

Not enough

+
    +
  • “This would be faster in parallel.”
  • +
  • The model silently deciding multi-agent is better.
  • +
  • Inferring opt-in from task complexity alone.
  • +
+

Fallback: use the single Agent tool, or describe the multi-agent plan + rough cost and ask.

+
+
+ +
+

Ultracode mode

+

When ultracode is on, opt-in is standing. The model should author and run a workflow for every substantive task. Token cost is not a constraint; the goal is the most exhaustive correct answer. Multi-phase work is often several workflows in sequence (understand → design → implement → review) so the coordinator stays in the loop between phases. Solo only for pure conversation or trivial mechanical edits.

+
+ +
+ Hybrid default. Scout inline first (list files, scope the diff, discover the work-list), then call Workflow to pipeline over that list. You need the shape before the orchestration step, not before the whole task. +
+
+ + +
+
+

04 · Workflow tool API

+

Tool surface the model calls

+
+

Tool name: Workflow (alias RunWorkflow). Search hint: orchestrate subagents with deterministic JavaScript workflow. Runs in the background — returns immediately with a task ID; a <task-notification> arrives on completion. Live progress: /workflows.

+ +

Input fields

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRole
scriptstring (optional, max-bounded)Inline self-contained workflow script. Must start with pure-literal export const meta = {...}. Preferred first invocation — do not Write a file first.
namestring (optional)Named workflow from built-ins or .claude/workflows/. Resolves to a script.
scriptPathstring (optional)Path to a persisted script on disk. Every invocation writes one under the session dir and returns the path. Iterate with Write/Edit + re-invoke. Takes precedence over script / name.
argsany (optional)Value exposed to the script as global args, verbatim. Pass real JSON arrays/objects — not stringified JSON (stringified lists break args.map).
resumeFromRunIdstring matching ^wf_[a-z0-9-]{6,}$Prior run ID. Unchanged prefix of agent() calls replays from cache; first changed/new call and after run live. Same-session only. Stop the prior run first.
description / titleignoredSet display name/description in script meta, not these fields.
+
+

Validation: at least one of script, name, or scriptPath is required.

+ +

Return envelope (conceptual)

+
+
// async launch — tool returns before script finishes +{ + status: "async_launched" | "remote_launched", + taskId: "...", + taskType: "local_workflow" | "remote_agent", + workflowName: "review-changes", // meta.name + runId: "wf_…", // for resumeFromRunId + transcriptDir: "/…/…", // subagent transcripts + journal.jsonl + scriptPath: "/…/workflows/scripts/….js", + summary?: "…", + warning?: "…", + error?: "…" // e.g. syntax check failed +}
+
+ +
+
+

First run

+
Workflow({ + script: `export const meta = {…} +…`, + args: { files: changed } +})
+
+
+

Iterate

+
// edit the returned scriptPath, then: +Workflow({ + scriptPath: returnedPath, + resumeFromRunId: runId, // optional cache + args: { files: changed } +})
+
+
+
+ + +
+
+

05 · Script contract

+

What a valid workflow script is

+
+ +
+
export const meta = { + name: 'find-flaky-tests', + description: 'Find flaky tests and propose fixes', + phases: [ + { title: 'Scan', detail: 'grep test logs for retries' }, + { title: 'Fix', detail: 'one agent per flaky test', model: 'sonnet' }, + ], + // optional: whenToUse (workflow list), model on a phase +} + +// body runs in an async context — await freely +phase('Scan') +const flaky = await agent('…', { schema: FLAKY_SCHEMA }) +// … +return { flaky }
+
+ +
+
+

meta rules

+
    +
  • Must be the first statement.
  • +
  • Pure literal only — no variables, function calls, spreads, or template interpolation.
  • +
  • Required: name, description.
  • +
  • Optional: whenToUse, phases[] with title, detail, optional per-phase model.
  • +
  • Phase titles in meta.phases must match phase() calls exactly for UI grouping.
  • +
  • description is shown in the permission dialog.
  • +
+
+
+

Language & environment

+
    +
  • Plain JavaScript only — not TypeScript. Type annotations, interfaces, generics fail to parse.
  • +
  • Standard built-ins: JSON, Math, Array, etc.
  • +
  • Forbidden for determinism: Date.now(), Math.random(), argless new Date() — they throw (would break resume).
  • +
  • No filesystem, no Node APIs, no network from the script.
  • +
  • Only escape hatch into models/tools: agent() (and nested workflow()).
  • +
  • Pass timestamps via args; stamp wall-clock after the workflow returns.
  • +
+
+
+ +
+ Why non-determinism is banned. Resume replays the longest unchanged prefix of agent() calls by hashing prompt + opts. If the script could branch on time or random, cache identity would lie. Keep control flow pure; put entropy in agent prompts (vary by index) or in post-processing outside the script. +
+
+ + +
+
+

06 · Script primitives

+

Every hook the script can call

+

These are the only APIs injected into the script body. Together they form a small concurrent orchestration language.

+
+ + +
+
+ core +

agent()

+
+
agent(prompt: string, opts?: { + label?: string, + phase?: string, + schema?: object, // JSON Schema + model?: string, // e.g. 'sonnet' | 'opus' | 'haiku' | session ids + effort?: 'low'|'medium'|'high'|'xhigh'|'max', + isolation?: 'worktree', + agentType?: string // registry name, e.g. 'general-purpose', 'code-reviewer', 'claude' +}): Promise<any>
+ +
+
+

Semantics

+
    +
  • Spawns one subagent with its own tool loop.
  • +
  • Without schema: resolves to the agent’s final text (string).
  • +
  • With schema: forces a StructuredOutput tool call; returns the validated object — no fragile JSON parsing.
  • +
  • Returns null if the user skips the agent or it dies after terminal API retries. Always .filter(Boolean) before use.
  • +
  • Subagents are told: final text/object is the return value, not a user-facing message.
  • +
+
+
+

Options in practice

+
    +
  • label — UI/progress label (review:security).
  • +
  • phase — assign progress group inside pipeline/parallel (avoids races on global phase()).
  • +
  • model — omit by default (inherit session model). Override only when tier fit is clear.
  • +
  • effortlow for mechanical stages; higher only for hard verify/judge stages.
  • +
  • isolation: 'worktree' — expensive (~200–500ms + disk). Use only when parallel mutators would conflict. Unchanged worktrees auto-remove.
  • +
  • agentType — custom subagent from the same registry as the Agent tool; composes with schema.
  • +
+
+
+ +
+
const FINDINGS = { + type: 'object', + properties: { + findings: { + type: 'array', + items: { + type: 'object', + properties: { + file: { type: 'string' }, + line: { type: 'integer' }, + issue: { type: 'string' }, + }, + required: ['file', 'line', 'issue'], + additionalProperties: false, + }, + }, + }, + required: ['findings'], + additionalProperties: false, +} + +const result = await agent( + 'Review auth changes for session fixation. Read-only.', + { + label: 'review:auth', + phase: 'Review', + schema: FINDINGS, + effort: 'medium', + agentType: 'claude', + } +) +// result.findings is already typed-shaped JSON
+
+ +
+ MCP access. Workflow subagents can reach session-connected MCP tools via ToolSearch (schemas load on demand). Interactively authenticated MCP servers may be missing in headless/cron runs. +
+
+ + +
+
+ default multi-stage +

pipeline()

+
+
pipeline(items: any[], stage1, stage2, ...): Promise<any[]> +// each stage: (prevResult, originalItem, index) => Promise<any> | any
+
+
+

Semantics

+
    +
  • Each item flows through all stages independently.
  • +
  • No barrier between stages: item A can be in stage 3 while item B is still in stage 1.
  • +
  • Wall-clock ≈ slowest single-item chain — not sum of per-stage slowest times.
  • +
  • Stage callbacks receive (prevResult, originalItem, index) so later stages can label work without stuffing identity into stage-1 returns.
  • +
  • A throwing stage drops that item to null and skips remaining stages for it.
  • +
+
+
+

When to use

+

Default for multi-stage work. Prefer over barrier-then-map whenever each item’s next stage does not need the full previous stage’s result set.

+

Smell test: if you wrote parallel → transform → parallel with no cross-item dependency, rewrite as pipeline with the transform inside a stage.

+
+
+
+
const DIMENSIONS = [ + { key: 'bugs', prompt: '…' }, + { key: 'perf', prompt: '…' }, +] +const results = await pipeline( + DIMENSIONS, + d => agent(d.prompt, { + label: `review:${d.key}`, + phase: 'Review', + schema: FINDINGS_SCHEMA, + }), + review => parallel( + review.findings.map(f => () => + agent(`Adversarially verify: ${f.title}`, { + label: `verify:${f.file}`, + phase: 'Verify', + schema: VERDICT_SCHEMA, + }).then(v => ({ ...f, verdict: v })) + ) + ) +) +// bugs findings verify while perf is still reviewing +const confirmed = results.flat().filter(Boolean) + .filter(f => f.verdict?.isReal)
+
+
+ + +
+
+ barrier +

parallel()

+
+
parallel(thunks: Array<() => Promise<any>>): Promise<any[]>
+
+
+

Semantics

+
    +
  • Runs thunks concurrently.
  • +
  • Barrier: awaits all before returning.
  • +
  • Throwing thunk / agent error → that slot is null. The call itself never rejects — always .filter(Boolean).
  • +
  • Use only when you genuinely need all results together.
  • +
+
+
+

Barrier is correct when…

+
    +
  • Dedup / merge across the full set before expensive work.
  • +
  • Early-exit if total count is zero.
  • +
  • Next stage’s prompt references “the other findings.”
  • +
+

Not justified by…

+
    +
  • “I need to flatten first” — do it inside a pipeline stage.
  • +
  • “Stages are conceptually separate” — pipeline already models that.
  • +
  • “Cleaner code” — barrier latency is real.
  • +
+
+
+
+
// Correct barrier: need ALL findings before expensive verification +const all = await parallel( + DIMENSIONS.map(d => () => agent(d.prompt, { schema: FINDINGS_SCHEMA })) +) +const deduped = dedupeByFileAndLine( + all.filter(Boolean).flatMap(r => r.findings) +) +const verified = await parallel( + deduped.map(f => () => agent(verifyPrompt(f), { schema: VERDICT_SCHEMA })) +)
+
+
+ + +
+
+ progress UX +

phase() · log()

+
+
phase(title: string): void +log(message: string): void
+
+
+

phase(title)

+

Starts a progress group. Subsequent agent() calls without explicit opts.phase group under this title in /workflows. Inside concurrent stages, prefer opts.phase to avoid races on the global phase state. Same string → same group box.

+
+
+

log(message)

+

Narrator line above the progress tree. Use for counts, dropped coverage, early-exit reasons — anything a silent cap would hide. “No silent caps” is a first-class quality rule.

+
+
+
+ + +
+
+ inputs & cost +

args · budget

+
+
args: any +// value of Workflow({ args }) — undefined if omitted + +budget: { + total: number | null, + spent(): number, + remaining(): number // max(0, total - spent) or Infinity if no target +}
+
+
+

args

+
    +
  • Parameterize named workflows: research question, file list, config object.
  • +
  • Pass real JSON: args: ["a.ts", "b.ts"] — not a stringified list.
  • +
  • Only channel for non-deterministic / external inputs that must stay stable across resume (timestamps, seeds as fixed values).
  • +
+
+
+

budget

+
    +
  • Turn token target from user “+500k”-style directives.
  • +
  • budget.total is null when no target was set.
  • +
  • spent() is shared across main loop + all workflows this turn.
  • +
  • Hard ceiling: further agent() calls throw once spent ≥ total.
  • +
  • Always guard loops with budget.total && — else remaining() is Infinity and you hit the 1000-agent cap.
  • +
+
+
+
+
// Scale depth to budget +const bugs = [] +while (budget.total && budget.remaining() > 50_000) { + const result = await agent('Find bugs…', { schema: BUGS_SCHEMA }) + bugs.push(...result.bugs) + log(`${bugs.length} found, ${Math.round(budget.remaining()/1000)}k remaining`) +} + +// Or static fleet sizing +const FLEET = budget.total + ? Math.floor(budget.total / 100_000) + : 5
+
+
+ + +
+
+ composition +

workflow()

+
+
workflow( + nameOrRef: string | { scriptPath: string }, + args?: any +): Promise<any>
+
+
    +
  • Run another workflow inline as a sub-step; return whatever it returns.
  • +
  • String name → saved/built-in registry (same as Workflow({ name })).
  • +
  • { scriptPath } → run a script file already on disk.
  • +
  • Child shares parent’s concurrency cap, agent counter, abort signal, and token budget.
  • +
  • Child agents appear under a nested group in /workflows.
  • +
  • Nesting is one level onlyworkflow() inside a child throws.
  • +
  • Throws on unknown name / unreadable path / child syntax error; catch to handle.
  • +
+
+
+
+ + +
+
+

07 · Limits & sandbox

+

Hard bounds the engine enforces

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LimitValueNotes
Concurrent agent() callsmin(16, cpu_cores - 2) per workflowExcess queues; all items still complete.
Lifetime agent count1000Runaway-loop backstop.
Items per parallel/pipeline call4096 maxMore → explicit error, not silent truncate.
Workflow size guidelineuser /config: small≈5, medium≈15, large≈50, unrestrictedGuideline, not hard engine cap (unless user configures otherwise).
Script determinismno Date.now / Math.random / bare new DateRequired for resume cache identity.
Script I/OnoneNo FS/network/Node; only agent/workflow escapes.
Worktree isolationopt-in per agentExpensive; auto-clean if unchanged.
+
+
+ + +
+
+

08 · Resume & journal

+

Edit the plan without redoing finished work

+
+

+ Every launch returns a runId and persists the script. After a pause, kill, or script edit: stop the prior run, then relaunch with Workflow({ scriptPath, resumeFromRunId }). +

+
+
+

Cache hit rule

+

Longest unchanged prefix of agent() calls (same prompt + opts) returns cached results instantly. First edited/new call and everything after runs live.

+
+
+

Perfect replay

+

Same script + same args → 100% cache hit. Use this for pure post-processing edits after a completed run.

+
+
+

journal.jsonl

+

Under transcriptDir. Records each agent’s actual return value. Before diagnosing empty/weird results, read the journal — do not assume cached results are non-empty.

+
+
+
+
Workflow({ + scriptPath: '/…/workflows/scripts/review-wf_abc.js', + resumeFromRunId: 'wf_abc…', + args: previousArgs, +})
+
+
+ Fallback. If no journal is available, read agent-<id>.jsonl files in the transcript directory and hand-author a continuation script. +
+
+ + +
+
+

09 · Quality patterns

+

Composable harness shapes

+

These are not special APIs — they are recipes built from the primitives. Pick by task; compose freely.

+
+ +
+
+

Adversarial verify

+

N independent skeptics per claim, prompted to refute. Kill if ≥ majority refute. Kills plausible-but-wrong findings.

+
const votes = await parallel(Array.from({length: 3}, () => () => + agent(`Try to refute: ${claim}. Default refuted=true if uncertain.`, { schema: VERDICT }) +)) +const survives = votes.filter(Boolean) + .filter(v => !v.refuted).length >= 2
+
+
+

Perspective-diverse verify

+

Distinct lenses (correctness, security, repro, perf) instead of N identical refuters. Diversity catches failure modes redundancy cannot.

+
+
+

Judge panel

+

N independent attempts from different angles (MVP-first, risk-first, user-first). Score in parallel; synthesize from winner while grafting runner-up ideas. Beats single-attempt iteration when the solution space is wide.

+
+
+

Loop-until-dry

+

Unknown-size discovery: keep finding until K consecutive rounds return nothing new. Dedup against all seen, not only confirmed — else rejected findings reappear forever.

+
+
+

Multi-modal sweep

+

Parallel agents each search a different way (by-container, by-content, by-entity, by-time). Each is blind to the others — covers angles one search cannot.

+
+
+

Completeness critic

+

Final agent asks: modality not run? claim unverified? source unread? Output becomes the next work round.

+
+
+ +
+

Exhaustive review composition

+
const seen = new Set(), confirmed = [] +let dry = 0 +while (dry < 2) { + const found = (await parallel(FINDERS.map(f => () => + agent(f.prompt, { phase: 'Find', schema: BUGS }) + ))).filter(Boolean).flatMap(r => r.bugs) + const fresh = found.filter(b => !seen.has(key(b))) + if (!fresh.length) { dry++; continue } + dry = 0; fresh.forEach(b => seen.add(key(b))) + const judged = await parallel(fresh.map(b => () => + parallel(['correctness','security','repro'].map(lens => () => + agent(`Judge "${b.desc}" via ${lens} — real?`, { + phase: 'Verify', schema: VERDICT + }) + )).then(vs => ({ + b, + real: vs.filter(Boolean).filter(v => v.real).length >= 2 + })) + )) + confirmed.push(...judged.filter(v => v.real).map(v => v.b)) +} +return confirmed
+
+ +
+ Scale to the ask. “Find any bugs” → few finders, single-vote verify. “Thoroughly audit” → larger pool, 3–5 vote adversarial pass, synthesis stage. When unsure on research/review/audit: lean thorough; on quick checks: lean brief. +
+
+ + +
+
+

10 · Lifecycle & operator UX

+

How a run feels from the outside

+
+
    +
  1. Authoring. Coordinator scouts, then writes inline script (or picks name).
  2. +
  3. Permission. User sees meta.description (and size guideline if set).
  4. +
  5. Launch. Tool returns immediately with taskId, runId, scriptPath, transcriptDir.
  6. +
  7. Progress. /workflows shows phase groups, labels, nested child workflows, narrator log() lines.
  8. +
  9. Completion. <task-notification> delivers the script’s return value to the coordinator.
  10. +
  11. Synthesis. Coordinator may run another workflow, resume with edits, or answer the user.
  12. +
+
+
+

Common single-phase workflows

+
    +
  • Understand — parallel readers → structured map
  • +
  • Design — judge panel of N approaches → scored synthesis
  • +
  • Review — dimensions → find → adversarially verify
  • +
  • Research — multi-modal sweep → deep-read → synthesize
  • +
  • Migrate — discover sites → transform (worktree) → verify
  • +
+
+
+

Multi-phase product work

+

Run several workflows in sequence across turns. The coordinator reads each result before choosing the next phase. Each workflow stays a well-scoped fan-out — not a giant forever-script.

+
+
+
+ + +
+
+

11 · One level above agents

+

Bigger brain orchestrates smaller hands

+
+

+ Classic multi-agent demos put several peers in a chat room and hope coordination emerges. Dynamic workflows invert that: coordination is a program written by a high-capability model; workers are replaceable execution units. +

+ +
+
+

Orchestrator responsibilities

+
    +
  • Understand user intent and constraints
  • +
  • Discover the work-list (files, bugs, APIs, modules)
  • +
  • Choose pattern (pipeline vs barrier, depth vs breadth)
  • +
  • Author the script + schemas + prompts
  • +
  • Allocate model/effort tiers per stage
  • +
  • Interpret structured returns; decide next phase
  • +
  • Talk to the human; own correctness narrative
  • +
+
+
+

Worker responsibilities

+
    +
  • Execute one bounded prompt with tools
  • +
  • Return raw data or schema-validated objects
  • +
  • Stay isolated (optional worktree)
  • +
  • Do not redesign the global plan
  • +
  • May be cheaper/faster models for mechanical stages
  • +
  • May be specialized agentTypes (reviewer, explorer)
  • +
+
+
+ +
+

Why this is “one level above”

+
+
+

Control altitude

+ The orchestrator reasons about graphs, budgets, and verification policy — not about every file read. Workers absorb token-heavy tool churn inside their own contexts. +
+
+

Context isolation

+ Each agent() gets a clean context for its unit of work. The script aggregates only return values. One agent’s rabbit hole cannot pollute another’s prompt. +
+
+

Deterministic spine

+ Loops, fan-out, early-exit, and majority votes are code. They do not “forget” to verify on a bad day. The model invents the harness once; the engine executes it faithfully. +
+
+
+ +
+ Model tiering pattern. Keep the session model strong for orchestration (authoring scripts, reading results, deciding phases). Inside the workflow, omit model for most calls (inherit), or pin effort: 'low' / smaller models for mechanical map stages and reserve high effort for adversarial judges. The orchestrator’s context stays small; total work scales with fleet size. +
+ +
+

Altitude diagram

+
User intent + │ + ▼ +┌──────────────────────────────────────────┐ +│ Orchestrator model (main session) │ +│ · plans · schemas · phase selection │ +│ · Workflow({ script, args }) │ +└───────────────────┬──────────────────────┘ + │ deterministic JS spine + ┌───────────┼───────────┐ + ▼ ▼ ▼ + agent() agent() agent() + worker A worker B worker C + (tools) (tools) (tools) + │ │ │ + └───────────┼───────────┘ + ▼ + structured returns + │ + ▼ + orchestrator synthesizes + │ + ▼ + user-facing answer
+
+
+ + +
+
+

12 · What this feature enables

+

Workloads that were awkward before

+
+ +
+
+

Comprehensive code review

+

Fan out by dimension (security, correctness, tests, perf). Verify each finding adversarially. Merge only survivors. Scale vote count to thoroughness of the ask.

+
+
+

Large migrations / refactors

+

Discover call sites, pipeline each site through transform + verify with isolation: 'worktree' so parallel mutators do not clobber each other. Resume after fixing one stage’s prompt.

+
+
+

Research & multi-source synthesis

+

Multi-modal sweep (web, code, docs, git history), deep-read promising hits, completeness critic, then cited synthesis — with budget-bounded loops.

+
+
+

Design exploration

+

Judge panel: N independent designs from different angles, scored in parallel, grafted synthesis. Better than iterating one design in a single context.

+
+
+

Unknown-size bug hunts

+

Loop-until-dry finders + diverse-lens judges. Dedup against seen set. Stop when two dry rounds pass. Depth scales with budget.total.

+
+
+

Self-repair implementation loops

+

Implement → multi-reviewer parallel → repair from structured findings → verify gates. Same shape as real engineering process, encoded as a script the orchestrator can re-run with resume.

+
+
+

Heterogeneous agent fleets

+

Mix agentTypes and models: explorer for map, implementer for edit, reviewer for audit. Orchestrator stays vendor of truth; workers stay specialists.

+
+
+

Phased product delivery under ultracode

+

Standing multi-agent mode: every substantive step is a workflow. Human watches /workflows; orchestrator chains phases across turns without stuffing everything into one mega-context.

+
+
+ +
+

What it deliberately is not

+
    +
  • Not a durable multi-day job system with external supervisors (that is a different control plane).
  • +
  • Not free-form multi-agent chat; workers do not negotiate plan changes with each other.
  • +
  • Not automatic: user must opt in (or enable ultracode).
  • +
  • Not a replacement for single-agent work on small tasks — overhead is real.
  • +
+
+
+ + +
+
+

13 · Cheatsheet

+

Quick reference

+
+
+
// Tool +Workflow({ script | name | scriptPath, args?, resumeFromRunId? }) + +// Script header (pure literal) +export const meta = { name, description, phases? } + +// Primitives +agent(prompt, { label, phase, schema, model, effort, isolation, agentType }) +pipeline(items, stage1, stage2, …) // no barrier — default +parallel([ () => …, … ]) // barrier — rare +phase(title) +log(message) +args // Workflow args, verbatim +budget.{ total, spent(), remaining() } // hard token ceiling +workflow(name | { scriptPath }, args?) // one-level nest + +// Rules of thumb +// 1. Default to pipeline; barrier only for cross-item merge. +// 2. Always .filter(Boolean) on parallel/agent results. +// 3. Prefer schema for structured returns. +// 4. Guard budget loops with budget.total && … +// 5. No Date.now / Math.random in scripts. +// 6. isolation:'worktree' only for parallel mutators. +// 7. log() anything a silent cap would hide. +// 8. Hybrid: scout → Workflow → synthesize → maybe next phase.
+
+ +
+
+

Primitive count

+

8

+

agent · pipeline · parallel · phase · log · args · budget · workflow

+
+
+

Tool inputs

+

5

+

script · name · scriptPath · args · resumeFromRunId

+
+
+

Design goal

+

Altitude

+

Strong model plans; many workers execute; engine enforces the graph

+
+
+ +

+ Source of truth for this document: Claude Code binary tool description for the Workflow tool (v2.1.x family), observed script examples under session workflows/scripts/, and the runtime rules encoded in the tool prompt (opt-in, ultracode, resume, budget, concurrency). This is a model-facing API reference, not Anthropic product documentation. +

+
+ +
+
+ + From 28f3e5b8e6077bbacc389e0877bdd42ee66748be Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 21 Jul 2026 17:01:59 +0530 Subject: [PATCH 004/132] feat(workflow): freeze public types and limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add workflow-types contract module (statuses, AgentOpts, journal shapes, cache key helper, budget stub, concurrency). Wire test into package.json. Fix primitives-spec §13: isolation worktree is v1 must-have. Co-Authored-By: Claude --- .../devspace/primitives-spec.md | 2 +- package.json | 2 +- src/workflow-types.test.ts | 63 ++++ src/workflow-types.ts | 291 ++++++++++++++++++ 4 files changed, 356 insertions(+), 2 deletions(-) create mode 100644 src/workflow-types.test.ts create mode 100644 src/workflow-types.ts diff --git a/docs/dynamic-workflow/devspace/primitives-spec.md b/docs/dynamic-workflow/devspace/primitives-spec.md index ef53d5966..042b9715b 100644 --- a/docs/dynamic-workflow/devspace/primitives-spec.md +++ b/docs/dynamic-workflow/devspace/primitives-spec.md @@ -753,7 +753,7 @@ IMPLEMENTER (parallel): | Shared workspace; later agents see prior edits on disk | Yes | | Return structured paths/findings between stages | Yes (schema) | | Auto git snapshot / diff after each agent | **No** | -| Per-agent worktree | **No** (follow-up) | +| Per-agent worktree (`isolation: 'worktree'`) | **Yes v1** (must-have; §7.1b) | | Host `show_changes` after whole workflow | Optional host behavior; not engine | --- diff --git a/package.json b/package.json index 25f210599..ee020b1a0 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-types.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/workflow-types.test.ts b/src/workflow-types.test.ts new file mode 100644 index 000000000..374e167d5 --- /dev/null +++ b/src/workflow-types.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { + 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_NEST_DEPTH, 1); + +assert.deepEqual( + buildAgentCacheKeyInput({ + prompt: "hi", + provider: "codex", + model: undefined, + effort: "high", + schema: null, + isolation: "worktree", + }), + { + prompt: "hi", + provider: "codex", + model: null, + effort: "high", + schema: null, + isolation: "worktree", + }, +); + +assert.deepEqual( + buildAgentCacheKeyInput({ + prompt: "x", + provider: "claude", + }), + { + prompt: "x", + 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 000000000..11b14193e --- /dev/null +++ b/src/workflow-types.ts @@ -0,0 +1,291 @@ +/** + * 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"; + +// --------------------------------------------------------------------------- +// Limits +// --------------------------------------------------------------------------- + +export const WORKFLOW_MAX_ITEMS = 4096; +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, + resultJsonBytes: 256 * 1024, + argsJsonBytes: 64 * 1024, + scriptSourceBytes: 512 * 1024, + eventDrainDefault: 200, + eventDrainMax: 500, +} as const; + +// --------------------------------------------------------------------------- +// Provider config (user config / ServerConfig) +// --------------------------------------------------------------------------- + +export type AgentProviderId = LocalAgentProvider; + +export interface AgentProviderProbe { + id: AgentProviderId; + available: boolean; + detail?: string; +} + +/** + * Ordered enable-list. index 0 = default fallback after live availability filter. + * Missing block on disk → compat all-available in product order. + * Explicit enabled: [] → no providers; first agent() fails. + */ +export interface AgentProvidersConfig { + enabled: AgentProviderId[]; + detectedAt?: string; + lastProbe?: AgentProviderProbe[]; +} + +// --------------------------------------------------------------------------- +// Script meta + agent opts +// --------------------------------------------------------------------------- + +export interface WorkflowPhaseMeta { + title: string; + detail?: string; +} + +export interface WorkflowMeta { + name: string; + description: string; + phases?: WorkflowPhaseMeta[]; + whenToUse?: string; + /** DevSpace extension */ + defaultProvider?: AgentProviderId; + /** DevSpace extension; clamped to engine max */ + concurrency?: number; +} + +/** + * Public agent() options. Deliberately no writeMode. + */ +export interface AgentOpts { + label?: string; + phase?: string; + schema?: object; + model?: string; + /** Provider-native effort/reasoning level (was thinking). */ + effort?: string; + provider?: AgentProviderId | string; + isolation?: "worktree"; +} + +export type AgentIsolationMode = "shared" | "worktree"; + +// --------------------------------------------------------------------------- +// Status / events +// --------------------------------------------------------------------------- + +export type WorkflowRunStatus = + | "starting" + | "running" + | "completed" + | "failed" + | "cancelled"; + +export type WorkflowAgentCallStatus = + | "running" + | "completed" + | "failed" + | "cancelled" + | "from_cache"; + +export type WorkflowEventType = + | "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"; + +export type WorkflowErrorKind = + | "syntax" + | "meta" + | "determinism" + | "provider_disabled" + | "provider_unavailable" + | "no_provider" + | "provider" + | "schema" + | "cancelled" + | "timeout" + | "heartbeat" + | "worktree" + | "nest_depth" + | "path" + | "result_too_large" + | "args_too_large" + | "script_too_large" + | "internal"; + +// --------------------------------------------------------------------------- +// Journal row shapes (behavioral; store maps snake_case) +// --------------------------------------------------------------------------- + +export type WorkflowRunSource = "inline" | "named" | "resume"; + +export interface WorkflowRunRecord { + id: string; + name: string; + source: WorkflowRunSource; + scriptPath: string; + scriptHash: string; + workspaceRoot: string; + workspaceId?: string; + argsJson: string; + 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 WorkflowEventRecord { + runId: string; + seq: number; + type: WorkflowEventType; + phase?: string; + label?: string; + dataJson: string; + createdAt: string; +} + +export interface WorkflowAgentCallRecord { + runId: string; + callIndex: number; + cacheKey: string; + provider: string; + model?: string; + effort?: string; + label?: string; + phase?: string; + status: WorkflowAgentCallStatus; + fromCache: boolean; + providerSessionId?: string; + responseText?: string; + structuredJson?: string; + error?: 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; + provider: string; + model: string | null; + effort: string | null; + schema: object | null; + isolation: AgentIsolationMode; +} + +export function buildAgentCacheKeyInput(input: { + prompt: string; + provider: string; + model?: string | null; + effort?: string | null; + schema?: object | null; + isolation?: AgentIsolationMode | "worktree" | null; +}): AgentCacheKeyInput { + const isolation: AgentIsolationMode = + input.isolation === "worktree" ? "worktree" : "shared"; + return { + prompt: input.prompt, + 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); +} From f01a96ec3c3a37e2c80874604b948c8efd86872e Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 21 Jul 2026 17:05:26 +0530 Subject: [PATCH 005/132] refactor(agents): rename thinking to effort Normalize LocalAgentRunInput, profiles, CLI, store, and adapters on effort. CLI accepts --thinking as a one-release alias; profile YAML thinking: still maps to effort. DB migration v4 renames the column. Co-Authored-By: Claude --- src/cli.test.ts | 6 ++--- src/cli.ts | 20 ++++++++-------- src/db/migrations.ts | 19 +++++++++++++++ src/db/schema.ts | 2 +- src/local-agent-adapters.test.ts | 16 ++++++------- src/local-agent-adapters.ts | 21 ++++++++++------- src/local-agent-profiles.test.ts | 40 +++++++++++++++++++++++++++++--- src/local-agent-profiles.ts | 9 +++---- src/local-agent-runtime.test.ts | 2 +- src/local-agent-runtime.ts | 5 ++-- src/local-agent-store.test.ts | 10 ++++---- src/local-agent-store.ts | 18 +++++++------- src/local-agent-targets.test.ts | 36 +++++++++++++++++----------- src/local-agent-targets.ts | 38 +++++++++++++++++++----------- src/oauth-store.test.ts | 1 + src/server.ts | 8 +++---- 16 files changed, 164 insertions(+), 87 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index 97b7084a8..95048e645 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -35,7 +35,7 @@ try { "description: Read-only reviewer.", "provider: codex", "model: gpt-5.4", - "thinking: high", + "effort: high", "---", "", "Review only.", @@ -50,7 +50,7 @@ try { profileName: "reviewer", provider: "codex", model: "gpt-5.4", - thinking: "high", + effort: "high", }).id, { status: "idle" }, ); @@ -80,7 +80,7 @@ try { }, }); - assert.match(output, new RegExp(`${current.id} idle reviewer codex gpt-5\\.4 thinking=high`)); + assert.match(output, new RegExp(`${current.id} idle reviewer codex gpt-5\\.4 effort=high`)); assert.doesNotMatch(output, /profile reviewer/); assert.doesNotMatch(output, new RegExp(other.id)); diff --git a/src/cli.ts b/src/cli.ts index 7a1ac63fe..88dffa909 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -379,7 +379,7 @@ async function runAgentsRun(args: string[]): Promise { store.update(existing.id, { status: "starting", model: parsed.model ?? existing.model, - thinking: parsed.thinking ?? existing.thinking, + effort: parsed.effort ?? existing.effort, latestResponse: undefined, error: undefined, }); @@ -388,13 +388,13 @@ async function runAgentsRun(args: string[]): Promise { ...existing, status: "running", model: parsed.model ?? existing.model, - thinking: parsed.thinking ?? existing.thinking, + effort: parsed.effort ?? existing.effort, })); return; } const profiles = await loadLocalAgentProfiles(config, workspaceRoot); - const target = resolveLocalAgentTarget(parsed.target, profiles, parsed.model, parsed.thinking); + const target = resolveLocalAgentTarget(parsed.target, profiles, parsed.model, parsed.effort); if (!target) { throw new Error( `Unknown subagent profile, provider, or id: ${parsed.target}. Available ${formatAvailableLocalAgentTargets(profiles)}`, @@ -409,7 +409,7 @@ async function runAgentsRun(args: string[]): Promise { profileName: target.name, provider: target.provider, model: target.model, - thinking: target.thinking, + effort: target.effort, }); spawnAgentWorker(record.id, promptFile); @@ -491,7 +491,7 @@ async function runLocalAgentProfile( providerSessionId: record.providerSessionId, writeMode: "allowed", model: record.model ?? profile.model, - thinking: record.thinking ?? profile.thinking, + effort: record.effort ?? profile.effort, }); } @@ -509,7 +509,7 @@ async function runRawLocalAgentProvider( providerSessionId: record.providerSessionId, writeMode: "allowed", model: record.model, - thinking: record.thinking, + effort: record.effort, }); } @@ -550,11 +550,11 @@ function resolveCurrentWorkspaceScope(): { workspaceId?: string; workspaceRoot: function formatAgentLine(agent: Pick< LocalAgentRecord, - "id" | "status" | "profileName" | "provider" | "model" | "thinking" + "id" | "status" | "profileName" | "provider" | "model" | "effort" >): string { const model = agent.model ? ` ${agent.model}` : ""; - const thinking = agent.thinking ? ` thinking=${agent.thinking}` : ""; - return `${agent.id} ${agent.status} ${agent.profileName} ${agent.provider}${model}${thinking}`; + const effort = agent.effort ? ` effort=${agent.effort}` : ""; + return `${agent.id} ${agent.status} ${agent.profileName} ${agent.provider}${model}${effort}`; } function sleep(ms: number): Promise { @@ -568,7 +568,7 @@ function printAgentsHelp(): void { "", "Usage:", " devspace agents ls", - " devspace agents run [--model ] [--thinking ] ", + " devspace agents run [--model ] [--effort ] ", " devspace agents show ", ].join("\n"), ); diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 2bda20c28..4888fffe8 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -22,6 +22,11 @@ const migrations: Migration[] = [ name: "local-agent-sessions", up: migrateLocalAgentSessions, }, + { + version: 4, + name: "local-agent-effort-rename", + up: migrateLocalAgentEffortRename, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -174,6 +179,20 @@ function migrateLocalAgentSessions(sqlite: Database.Database): void { addColumnIfMissing(sqlite, "local_agent_sessions", "thinking", "text"); } +/** Rename thinking → effort on local_agent_sessions (SQLite 3.25+). */ +function migrateLocalAgentEffortRename(sqlite: Database.Database): void { + const columns = sqlite.prepare("pragma table_info(local_agent_sessions)").all() as Array<{ + name: string; + }>; + const names = new Set(columns.map((column) => column.name)); + if (names.has("effort")) return; + if (!names.has("thinking")) { + addColumnIfMissing(sqlite, "local_agent_sessions", "effort", "text"); + return; + } + sqlite.exec("alter table local_agent_sessions rename column thinking to effort"); +} + function addColumnIfMissing( sqlite: Database.Database, table: "workspace_sessions" | "local_agent_sessions", diff --git a/src/db/schema.ts b/src/db/schema.ts index 01d13facc..cb46ecf1b 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -82,7 +82,7 @@ export const localAgentSessions = sqliteTable( profileName: text("profile_name").notNull(), provider: text("provider").notNull(), model: text("model"), - thinking: text("thinking"), + effort: text("effort"), providerSessionId: text("provider_session_id"), status: text("status").notNull(), latestResponse: text("latest_response"), diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index 0e4cbf0fa..19c7dd87a 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -9,7 +9,7 @@ import { extractPiStreamingText, piCommandEnvironment, resolveAcpModelConfigUpdate, - resolveAcpThinkingConfigUpdate, + resolveAcpEffortConfigUpdate, } from "./local-agent-adapters.js"; import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; import type { LocalAgentProvider } from "./local-agent-profiles.js"; @@ -111,7 +111,7 @@ assert.throws( ); assert.deepEqual( - resolveAcpThinkingConfigUpdate({ + resolveAcpEffortConfigUpdate({ sessionId: "session_1", newSessionResponse: { configOptions: [ @@ -131,7 +131,7 @@ assert.deepEqual( ); assert.deepEqual( - resolveAcpThinkingConfigUpdate({ + resolveAcpEffortConfigUpdate({ sessionId: "session_2", newSessionResponse: { configOptions: [ @@ -157,7 +157,7 @@ assert.deepEqual( ); assert.throws( - () => resolveAcpThinkingConfigUpdate({ + () => resolveAcpEffortConfigUpdate({ sessionId: "session_3", newSessionResponse: { configOptions: [ @@ -174,21 +174,21 @@ assert.throws( ); assert.throws( - () => resolveAcpThinkingConfigUpdate(undefined, "high", "copilot"), + () => resolveAcpEffortConfigUpdate(undefined, "high", "copilot"), /session metadata/, ); assert.throws( - () => resolveAcpThinkingConfigUpdate({ newSessionResponse: { configOptions: [] } }, "high", "copilot"), + () => resolveAcpEffortConfigUpdate({ newSessionResponse: { configOptions: [] } }, "high", "copilot"), /session id/, ); assert.throws( - () => resolveAcpThinkingConfigUpdate({ + () => resolveAcpEffortConfigUpdate({ sessionId: "session_4", newSessionResponse: { configOptions: [] }, }, "high", "copilot"), - /does not expose a thinking option/, + /does not expose a effort option/, ); { diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 457b8e08e..68e8b6637 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -64,7 +64,7 @@ class ClaudeLocalAgentAdapter implements LocalAgentAdapter { options: { cwd: input.workspace, model: input.model, - ...(input.thinking ? { thinking: { type: "adaptive" } as const, effort: input.thinking as EffortLevel } : {}), + ...(input.effort ? { thinking: { type: "adaptive" } as const, effort: input.effort as EffortLevel } : {}), resume: input.providerSessionId, permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true, @@ -209,8 +209,8 @@ class AcpLocalAgentAdapter implements LocalAgentAdapter { const config = resolveAcpModelConfigUpdate(session, input.model, this.provider); await context.request(methods.agent.session.setConfigOption, config); } - if (input.thinking) { - const config = resolveAcpThinkingConfigUpdate(session, input.thinking, this.provider); + if (input.effort) { + const config = resolveAcpEffortConfigUpdate(session, input.effort, this.provider); await context.request(methods.agent.session.setConfigOption, config); } const prompt = session.prompt(input.prompt); @@ -258,19 +258,22 @@ export function resolveAcpModelConfigUpdate( }); } -export function resolveAcpThinkingConfigUpdate( +export function resolveAcpEffortConfigUpdate( session: unknown, - thinking: string, + effort: string, provider: string, ): { sessionId: string; configId: string; value: string } { return resolveAcpSelectConfigUpdate(session, { category: "thought_level", - label: "thinking option", + label: "effort option", provider, - value: thinking, + value: effort, }); } +/** @deprecated Use resolveAcpEffortConfigUpdate */ +export const resolveAcpThinkingConfigUpdate = resolveAcpEffortConfigUpdate; + function resolveAcpSelectConfigUpdate( session: unknown, options: { @@ -336,7 +339,7 @@ class PiRpcLocalAgentAdapter implements LocalAgentAdapter { async run(input: LocalAgentRunInput): Promise { const args = ["--mode", "rpc"]; if (input.model) args.push("--model", input.model); - if (input.thinking) args.push("--thinking", input.thinking); + if (input.effort) args.push("--thinking", input.effort); if (input.providerSessionId) args.push("--session", input.providerSessionId); const child = spawn(process.env.PI_COMMAND ?? "pi", args, { cwd: input.workspace, @@ -524,7 +527,7 @@ async function promptOpencodeSession( prompt: { parts: [{ type: "text", text: input.prompt }] }, parts: [{ type: "text", text: input.prompt }], ...(input.model ? { model: parseOpencodeModel(input.model) } : {}), - ...(input.thinking ? { variant: input.thinking } : {}), + ...(input.effort ? { variant: input.effort } : {}), }; return session.prompt(promptInput, { throwOnError: true }); } diff --git a/src/local-agent-profiles.test.ts b/src/local-agent-profiles.test.ts index 7665b9f8a..b30f9474b 100644 --- a/src/local-agent-profiles.test.ts +++ b/src/local-agent-profiles.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig } from "./config.js"; import { loadLocalAgentProfiles, summarizeLocalAgentProfile } from "./local-agent-profiles.js"; +import type { ServerConfig } from "./config.js"; const root = await mkdtemp(join(tmpdir(), "devspace-agent-profiles-test-")); @@ -35,7 +36,7 @@ try { 'description: "Project reviewer #1."', "provider: claude", "model: sonnet", - "thinking: high", + "effort: high", "---", "", "Project body.", @@ -70,14 +71,14 @@ try { assert.equal(profiles[0]?.description, "Project reviewer #1."); assert.equal(profiles[0]?.provider, "claude"); assert.equal(profiles[0]?.model, "sonnet"); - assert.equal(profiles[0]?.thinking, "high"); + assert.equal(profiles[0]?.effort, "high"); assert.equal(profiles[0]?.body, "Project body."); assert.deepEqual(summarizeLocalAgentProfile(profiles[0]!), { name: "reviewer", description: "Project reviewer #1.", provider: "claude", model: "sonnet", - thinking: "high", + effort: "high", }); await writeFile( @@ -106,3 +107,36 @@ try { } finally { await rm(root, { recursive: true, force: true }); } + +// legacy thinking: maps to effort +{ + const legacyRoot = await mkdtemp(join(tmpdir(), "devspace-profile-legacy-")); + try { + const dir = join(legacyRoot, "agents"); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, "legacy.md"), + [ + "---", + "name: legacy", + "description: Legacy thinking key.", + "provider: codex", + "thinking: medium", + "---", + "", + "Body.", + "", + ].join("\n"), + ); + const profiles = await loadLocalAgentProfiles( + { + subagents: true, + devspaceAgentsDir: dir, + } as ServerConfig, + legacyRoot, + ); + assert.equal(profiles[0]?.effort, "medium"); + } finally { + await rm(legacyRoot, { recursive: true, force: true }); + } +} diff --git a/src/local-agent-profiles.ts b/src/local-agent-profiles.ts index a7fa0d876..f29316dd4 100644 --- a/src/local-agent-profiles.ts +++ b/src/local-agent-profiles.ts @@ -20,7 +20,7 @@ export interface LocalAgentProfile { description: string; provider: LocalAgentProvider; model?: string; - thinking?: string; + effort?: string; filePath: string; body: string; disabled: boolean; @@ -31,7 +31,7 @@ export interface LocalAgentProfileSummary { description: string; provider: LocalAgentProvider; model?: string; - thinking?: string; + effort?: string; } interface ParsedFrontmatter { @@ -73,7 +73,7 @@ export function summarizeLocalAgentProfile( description: profile.description, provider: profile.provider, model: profile.model, - thinking: profile.thinking, + effort: profile.effort, }; } @@ -157,7 +157,8 @@ function profileFromFrontmatter( description, provider, model: readString(frontmatter, "model"), - thinking: readString(frontmatter, "thinking"), + // Prefer effort; accept legacy thinking for one release. + effort: readString(frontmatter, "effort") ?? readString(frontmatter, "thinking"), filePath, body, disabled: frontmatter.disabled === true, diff --git a/src/local-agent-runtime.test.ts b/src/local-agent-runtime.test.ts index 1d45d1662..491623f64 100644 --- a/src/local-agent-runtime.test.ts +++ b/src/local-agent-runtime.test.ts @@ -63,7 +63,7 @@ await runtime.run({ workspace: "/tmp/project", writeMode: "allowed", model: "gpt-5.4", - thinking: "high", + effort: "high", }); assert.deepEqual(codex.started[1], { diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index 54130c2e2..ebd99feb6 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -15,7 +15,8 @@ export interface LocalAgentRunInput { providerSessionId?: string; writeMode?: LocalAgentWriteMode; model?: string; - thinking?: string; + /** Provider-native effort / reasoning level (was thinking). */ + effort?: string; } export interface LocalAgentRunResult { @@ -60,7 +61,7 @@ function threadOptionsFor(input: LocalAgentRunInput): ThreadOptions { sandboxMode: sandboxModeFor(input.writeMode), approvalPolicy: "never", model: input.model, - modelReasoningEffort: input.thinking as ModelReasoningEffort | undefined, + modelReasoningEffort: input.effort as ModelReasoningEffort | undefined, }; } diff --git a/src/local-agent-store.test.ts b/src/local-agent-store.test.ts index cf7265a9f..05adcbccc 100644 --- a/src/local-agent-store.test.ts +++ b/src/local-agent-store.test.ts @@ -16,12 +16,12 @@ try { profileName: "reviewer", provider: "codex", model: "gpt-5.4", - thinking: "high", + effort: "high", }); assert.match(created.id, /^agt_[a-f0-9]{8}$/); assert.equal(created.status, "starting"); - assert.equal(store.get(created.id)?.thinking, "high"); + assert.equal(store.get(created.id)?.effort, "high"); assert.equal(store.get(created.id)?.profileName, "reviewer"); assert.equal(store.get(created.id.slice(0, 7))?.id, created.id); @@ -29,13 +29,13 @@ try { status: "idle", latestResponse: "done", providerSessionId: "thread_123", - thinking: "medium", + effort: "medium", }); assert.equal(updated.status, "idle"); - assert.equal(updated.thinking, "medium"); + assert.equal(updated.effort, "medium"); assert.equal(store.get("thread_123")?.id, created.id); - assert.equal(store.get(created.id)?.thinking, "medium"); + assert.equal(store.get(created.id)?.effort, "medium"); assert.equal(store.update(created.id, { latestResponse: undefined }).latestResponse, undefined); assert.deepEqual( store.list({ workspaceRoot: join(root, "project") }).map((agent) => agent.latestResponse), diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index a850ca9f6..a87a0b1ec 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -12,7 +12,7 @@ export interface LocalAgentRecord { profileName: string; provider: string; model?: string; - thinking?: string; + effort?: string; providerSessionId?: string; status: LocalAgentStatus; latestResponse?: string; @@ -27,7 +27,7 @@ export interface CreateLocalAgentRecordInput { profileName: string; provider: string; model?: string; - thinking?: string; + effort?: string; } export interface LocalAgentListScope { @@ -42,7 +42,7 @@ interface LocalAgentRow { profile_name: string; provider: string; model: string | null; - thinking: string | null; + effort: string | null; provider_session_id: string | null; status: string; latest_response: string | null; @@ -94,7 +94,7 @@ export class LocalAgentStore { profileName: input.profileName, provider: input.provider, model: input.model, - thinking: input.thinking, + effort: input.effort, status: "starting", createdAt: now, updatedAt: now, @@ -109,7 +109,7 @@ export class LocalAgentStore { profile_name, provider, model, - thinking, + effort, status, created_at, updated_at @@ -122,7 +122,7 @@ export class LocalAgentStore { record.profileName, record.provider, record.model ?? null, - record.thinking ?? null, + record.effort ?? null, record.status, record.createdAt, record.updatedAt, @@ -170,7 +170,7 @@ export class LocalAgentStore { profile_name = ?, provider = ?, model = ?, - thinking = ?, + effort = ?, provider_session_id = ?, status = ?, latest_response = ?, @@ -184,7 +184,7 @@ export class LocalAgentStore { updated.profileName, updated.provider, updated.model ?? null, - updated.thinking ?? null, + updated.effort ?? null, updated.providerSessionId ?? null, updated.status, updated.latestResponse ?? null, @@ -220,7 +220,7 @@ function rowToLocalAgentRecord(row: LocalAgentRow): LocalAgentRecord { profileName: row.profile_name, provider: row.provider, model: row.model ?? undefined, - thinking: row.thinking ?? undefined, + effort: row.effort ?? undefined, providerSessionId: row.provider_session_id ?? undefined, status: readStatus(row.status), latestResponse: row.latest_response ?? undefined, diff --git a/src/local-agent-targets.test.ts b/src/local-agent-targets.test.ts index 3f1ae08f0..737f970a5 100644 --- a/src/local-agent-targets.test.ts +++ b/src/local-agent-targets.test.ts @@ -12,7 +12,7 @@ const profiles: LocalAgentProfile[] = [ description: "Review changes.", provider: "codex", model: "gpt-5-codex", - thinking: "high", + effort: "high", filePath: "/workspace/.devspace/agents/reviewer.md", body: "Review carefully.", disabled: false, @@ -32,35 +32,43 @@ assert.deepEqual(parseLocalAgentRunArgs(["codex", "hello", "world"]), { target: "codex", prompt: "hello world", model: undefined, - thinking: undefined, + effort: undefined, }); assert.deepEqual(parseLocalAgentRunArgs(["codex", "--model", "gpt-5.1", "hello"]), { target: "codex", prompt: "hello", model: "gpt-5.1", - thinking: undefined, + effort: undefined, }); assert.deepEqual(parseLocalAgentRunArgs(["codex", "--model=gpt-5.1", "hello"]), { target: "codex", prompt: "hello", model: "gpt-5.1", - thinking: undefined, + effort: undefined, }); -assert.deepEqual(parseLocalAgentRunArgs(["codex", "--thinking", "high", "hello"]), { +assert.deepEqual(parseLocalAgentRunArgs(["codex", "--effort", "high", "hello"]), { target: "codex", prompt: "hello", model: undefined, - thinking: "high", + effort: "high", }); -assert.deepEqual(parseLocalAgentRunArgs(["codex", "--thinking=high", "hello"]), { +assert.deepEqual(parseLocalAgentRunArgs(["codex", "--effort=high", "hello"]), { + target: "codex", + prompt: "hello", + model: undefined, + effort: "high", +}); + +// Legacy --thinking alias maps to effort. +assert.deepEqual(parseLocalAgentRunArgs(["codex", "--thinking", "high", "hello"]), { target: "codex", prompt: "hello", model: undefined, - thinking: "high", + effort: "high", }); assert.throws( @@ -69,8 +77,8 @@ assert.throws( ); assert.throws( - () => parseLocalAgentRunArgs(["codex", "--thinking"]), - /Missing value for --thinking/, + () => parseLocalAgentRunArgs(["codex", "--effort"]), + /Missing value for --effort/, ); { @@ -79,14 +87,14 @@ assert.throws( assert.equal(target?.name, "reviewer"); assert.equal(target?.provider, "codex"); assert.equal(target?.model, "gpt-5-codex"); - assert.equal(target?.thinking, "high"); + assert.equal(target?.effort, "high"); } { const target = resolveLocalAgentTarget("reviewer", profiles, "gpt-5.2", "xhigh"); assert.equal(target?.kind, "profile"); assert.equal(target?.model, "gpt-5.2"); - assert.equal(target?.thinking, "xhigh"); + assert.equal(target?.effort, "xhigh"); } { @@ -95,14 +103,14 @@ assert.throws( assert.equal(target?.name, "opencode"); assert.equal(target?.provider, "opencode"); assert.equal(target?.model, undefined); - assert.equal(target?.thinking, undefined); + assert.equal(target?.effort, undefined); } { const target = resolveLocalAgentTarget("opencode", profiles, "kimi-k2", "deep"); assert.equal(target?.kind, "provider"); assert.equal(target?.model, "kimi-k2"); - assert.equal(target?.thinking, "deep"); + assert.equal(target?.effort, "deep"); } { diff --git a/src/local-agent-targets.ts b/src/local-agent-targets.ts index 917e28041..77d279304 100644 --- a/src/local-agent-targets.ts +++ b/src/local-agent-targets.ts @@ -9,7 +9,7 @@ export interface ParsedLocalAgentRunArgs { target: string; prompt: string; model?: string; - thinking?: string; + effort?: string; } export type LocalAgentTarget = @@ -18,7 +18,7 @@ export type LocalAgentTarget = name: string; provider: LocalAgentProvider; model?: string; - thinking?: string; + effort?: string; profile: LocalAgentProfile; } | { @@ -26,17 +26,20 @@ export type LocalAgentTarget = name: LocalAgentProvider; provider: LocalAgentProvider; model?: string; - thinking?: string; + effort?: string; }; +const USAGE = + 'Usage: devspace agents run [--model ] [--effort ] ""'; + export function parseLocalAgentRunArgs(args: string[]): ParsedLocalAgentRunArgs { const [target, ...rest] = args; if (!target) { - throw new Error('Usage: devspace agents run [--model ] [--thinking ] ""'); + throw new Error(USAGE); } let model: string | undefined; - let thinking: string | undefined; + let effort: string | undefined; const promptParts: string[] = []; for (let index = 0; index < rest.length; index += 1) { const part = rest[index]; @@ -53,17 +56,24 @@ export function parseLocalAgentRunArgs(args: string[]): ParsedLocalAgentRunArgs model = value; continue; } - if (part === "--thinking") { + if (part === "--effort" || part === "--thinking") { + const flag = part; const value = rest[index + 1]?.trim(); - if (!value) throw new Error("Missing value for --thinking."); - thinking = value; + if (!value) throw new Error(`Missing value for ${flag}.`); + effort = value; index += 1; continue; } + if (part?.startsWith("--effort=")) { + const value = part.slice("--effort=".length).trim(); + if (!value) throw new Error("Missing value for --effort."); + effort = value; + continue; + } if (part?.startsWith("--thinking=")) { const value = part.slice("--thinking=".length).trim(); if (!value) throw new Error("Missing value for --thinking."); - thinking = value; + effort = value; continue; } promptParts.push(part ?? ""); @@ -71,17 +81,17 @@ export function parseLocalAgentRunArgs(args: string[]): ParsedLocalAgentRunArgs const prompt = promptParts.join(" ").trim(); if (!prompt) { - throw new Error('Usage: devspace agents run [--model ] [--thinking ] ""'); + throw new Error(USAGE); } - return { target, prompt, model, thinking }; + return { target, prompt, model, effort }; } export function resolveLocalAgentTarget( target: string, profiles: LocalAgentProfile[], modelOverride?: string, - thinkingOverride?: string, + effortOverride?: string, ): LocalAgentTarget | undefined { const profile = profiles.find((candidate) => candidate.name === target); if (profile) { @@ -90,7 +100,7 @@ export function resolveLocalAgentTarget( name: profile.name, provider: profile.provider, model: modelOverride ?? profile.model, - thinking: thinkingOverride ?? profile.thinking, + effort: effortOverride ?? profile.effort, profile, }; } @@ -101,7 +111,7 @@ export function resolveLocalAgentTarget( name: target, provider: target, model: modelOverride, - thinking: thinkingOverride, + effort: effortOverride, }; } diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index e1c003382..032f5c038 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -44,6 +44,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise { { version: 1, name: "workspace-state" }, { version: 2, name: "oauth-state" }, { version: 3, name: "local-agent-sessions" }, + { version: 4, name: "local-agent-effort-rename" }, ]); } finally { database.close(); diff --git a/src/server.ts b/src/server.ts index 7dd9629e0..e47aeb1e6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -205,16 +205,16 @@ function formatVisibleAgent(agent: { name: string; provider: string; model?: string; - thinking?: string; + effort?: string; providerAvailable?: boolean; providerUnavailableReason?: string; }): string { const model = agent.model ? `, model ${agent.model}` : ""; - const thinking = agent.thinking ? `, thinking ${agent.thinking}` : ""; + const effort = agent.effort ? `, effort ${agent.effort}` : ""; const availability = agent.providerAvailable === false ? `, unavailable: ${agent.providerUnavailableReason ?? "provider unavailable"}` : ""; - return `${agent.name} (${agent.provider}${model}${thinking}${availability})`; + return `${agent.name} (${agent.provider}${model}${effort}${availability})`; } function formatUnavailableAgentProvider(provider: LocalAgentProviderAvailability): string { @@ -248,7 +248,7 @@ const workspaceLocalAgentOutputSchema = z.object({ description: z.string(), provider: z.string(), model: z.string().optional(), - thinking: z.string().optional(), + effort: z.string().optional(), providerAvailable: z.boolean().optional(), providerUnavailableReason: z.string().optional(), }); From 5209347b5696c186050e9c96511fb0ae7e5b6e61 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 21 Jul 2026 17:05:26 +0530 Subject: [PATCH 006/132] docs(agents): document effort field and CLI flag Update profile schema, examples, and subagent skill for effort. Note legacy thinking alias and provider-native pi --thinking flag. Co-Authored-By: Claude --- docs/agent-profile-schema.md | 16 +++++++++------- examples/agents/claude-implementer.md | 2 +- examples/agents/codex-explorer.md | 2 +- examples/agents/codex-qa-tester.md | 2 +- examples/agents/opencode-explorer.md | 2 +- examples/agents/pi-reviewer.md | 2 +- skills/subagent-delegation/SKILL.md | 12 ++++++------ 7 files changed, 20 insertions(+), 18 deletions(-) diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index 0dc3db951..5933bae59 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -20,7 +20,7 @@ name: reviewer description: Read-only reviewer for bugs, security risks, and missing tests. provider: codex model: gpt-5.4 -thinking: high +effort: high disabled: false --- @@ -87,26 +87,28 @@ model: gpt-5.4 model: sonnet ``` -### `thinking` +### `effort` Optional provider reasoning effort, thinking level, or model variant. If omitted, DevSpace lets the provider default apply. Values are provider-specific passthrough strings; DevSpace does not translate names between harnesses. ```yaml -thinking: low -thinking: high -thinking: xhigh +effort: low +effort: high +effort: xhigh ``` DevSpace passes this through to providers that expose a matching control: - `claude`: SDK effort with adaptive thinking. - `codex`: SDK model reasoning effort. -- `pi`: `--thinking`. +- `pi`: CLI `--thinking` (provider-native flag; DevSpace field is still `effort`). - `opencode`: model variant. - `cursor` and `copilot`: ACP thought-level config when supported. +Legacy profile frontmatter key `thinking:` is still accepted and maps to `effort`. + ### `disabled` Optional boolean. Disabled profiles are not exposed. @@ -145,7 +147,7 @@ devspace agents show "description": "Read-only reviewer for bugs, security risks, and missing tests.", "provider": "codex", "model": "gpt-5.4", - "thinking": "high" + "effort": "high" } ``` diff --git a/examples/agents/claude-implementer.md b/examples/agents/claude-implementer.md index b907659fe..6f967b5f7 100644 --- a/examples/agents/claude-implementer.md +++ b/examples/agents/claude-implementer.md @@ -4,7 +4,7 @@ name: claude-implementer description: Implementation profile for multi-file changes, careful refactors, and failing test repair. provider: claude model: sonnet -thinking: high +effort: high --- Take ownership of the requested implementation while keeping the change narrow. diff --git a/examples/agents/codex-explorer.md b/examples/agents/codex-explorer.md index 93a92db6c..3645f2091 100644 --- a/examples/agents/codex-explorer.md +++ b/examples/agents/codex-explorer.md @@ -4,7 +4,7 @@ name: codex-explorer description: Read-only profile for bounded codebase questions, architecture tracing, and risk discovery. provider: codex model: gpt-5.4-mini -thinking: high +effort: high --- Investigate without editing. Use this profile to answer bounded questions such diff --git a/examples/agents/codex-qa-tester.md b/examples/agents/codex-qa-tester.md index f85706197..ae24b1a24 100644 --- a/examples/agents/codex-qa-tester.md +++ b/examples/agents/codex-qa-tester.md @@ -4,7 +4,7 @@ name: codex-qa-tester description: Manual QA profile for browser testing, workflow verification, and regression checks. provider: codex model: gpt-5.4-mini -thinking: high +effort: high --- Verify the requested user workflow from the outside, like a QA pass before diff --git a/examples/agents/opencode-explorer.md b/examples/agents/opencode-explorer.md index 250a4d84a..884be72db 100644 --- a/examples/agents/opencode-explorer.md +++ b/examples/agents/opencode-explorer.md @@ -4,7 +4,7 @@ name: opencode-explorer description: Read-only profile for fast relevant-file discovery and small architecture questions. provider: opencode model: opencode/deepseek-v4-flash-free -thinking: high +effort: high --- Find the answer quickly without editing. Use this profile when the main need is diff --git a/examples/agents/pi-reviewer.md b/examples/agents/pi-reviewer.md index 4ed2b9ded..e5a4a8a62 100644 --- a/examples/agents/pi-reviewer.md +++ b/examples/agents/pi-reviewer.md @@ -4,7 +4,7 @@ name: pi-reviewer description: Read-only review profile for quick risk checks and targeted implementation questions. provider: pi model: openai-codex/gpt-5.5 -thinking: high +effort: high --- Review or investigate only the area requested. This profile is best for quick diff --git a/skills/subagent-delegation/SKILL.md b/skills/subagent-delegation/SKILL.md index fb269df5c..0251f1a46 100644 --- a/skills/subagent-delegation/SKILL.md +++ b/skills/subagent-delegation/SKILL.md @@ -48,18 +48,18 @@ Choose profiles from the compact subagent profile catalog returned by profile fits and delegation is still appropriate, use a built-in provider name from `open_workspace`. -Profiles may declare a model and optional thinking level. To override the -configured/default provider model or thinking level for a run, pass `--model` -or `--thinking`: +Profiles may declare a model and optional effort level. To override the +configured/default provider model or effort for a run, pass `--model` +or `--effort` (legacy `--thinking` is accepted as an alias): ```bash devspace agents run --model "" -devspace agents run --thinking "" +devspace agents run --effort "" ``` -Use `--thinking` only when the user asks for a specific reasoning depth or when +Use `--effort` only when the user asks for a specific reasoning depth or when the task clearly needs a different effort than the configured profile default. -Thinking values are provider-specific passthrough values. Use names supported by +Effort values are provider-specific passthrough values. Use names supported by the selected local agent harness; DevSpace does not translate values between providers. From 0443586b78ed3c704bd76981af336e07a6cb2e4f Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 21 Jul 2026 17:10:28 +0530 Subject: [PATCH 007/132] feat(db): add workflow journal tables (v5) Create workflow_runs, workflow_events, and workflow_agent_calls with indexes. Effort rename remains v4; journal schema is v5. Co-Authored-By: Claude --- src/db/migrations.ts | 89 +++++++++++++++++++++++++++++++++++++++++ src/db/schema.ts | 88 ++++++++++++++++++++++++++++++++++++++++ src/oauth-store.test.ts | 1 + 3 files changed, 178 insertions(+) diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 4888fffe8..b3e481ad8 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -27,6 +27,11 @@ const migrations: Migration[] = [ name: "local-agent-effort-rename", up: migrateLocalAgentEffortRename, }, + { + version: 5, + name: "workflow-journal", + up: migrateWorkflowJournal, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -193,6 +198,90 @@ function migrateLocalAgentEffortRename(sqlite: Database.Database): void { sqlite.exec("alter table local_agent_sessions rename column thinking to effort"); } +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, + 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, + 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 addColumnIfMissing( sqlite: Database.Database, table: "workspace_sessions" | "local_agent_sessions", diff --git a/src/db/schema.ts b/src/db/schema.ts index cb46ecf1b..36104a972 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -97,9 +97,97 @@ 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"), + 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(), + provider: text("provider").notNull(), + model: text("model"), + effort: text("effort"), + label: text("label"), + phase: text("phase"), + status: text("status").notNull(), + fromCache: text("from_cache").notNull().default("false"), + providerSessionId: text("provider_session_id"), + responseText: text("response_text"), + structuredJson: text("structured_json"), + error: text("error"), + 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), + ], +); + export type WorkspaceSessionRow = typeof workspaceSessions.$inferSelect; export type NewWorkspaceSessionRow = typeof workspaceSessions.$inferInsert; export type LoadedAgentFileRow = typeof loadedAgentFiles.$inferSelect; export type NewLoadedAgentFileRow = typeof loadedAgentFiles.$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; diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 032f5c038..a5cfaec4e 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -45,6 +45,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise { { version: 2, name: "oauth-state" }, { version: 3, name: "local-agent-sessions" }, { version: 4, name: "local-agent-effort-rename" }, + { version: 5, name: "workflow-journal" }, ]); } finally { database.close(); From f39bc2c763e5e920c29b65218759c5f8938be803 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 21 Jul 2026 17:10:28 +0530 Subject: [PATCH 008/132] feat(workflow): implement WorkflowStore journal API Create/claim/cancel/complete runs, monotonic event append+drain, agent call lifecycle, and stale-worker reaping. Wire unit tests. Co-Authored-By: Claude --- package.json | 2 +- src/workflow-store.test.ts | 165 ++++++++++ src/workflow-store.ts | 654 +++++++++++++++++++++++++++++++++++++ 3 files changed, 820 insertions(+), 1 deletion(-) create mode 100644 src/workflow-store.test.ts create mode 100644 src/workflow-store.ts diff --git a/package.json b/package.json index ee020b1a0..c6a093ba7 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-types.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/workflow-store.test.ts b/src/workflow-store.test.ts new file mode 100644 index 000000000..f436a5ca5 --- /dev/null +++ b/src/workflow-store.test.ts @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { openDatabase } from "./db/client.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"] }), + }); + + 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"); + + 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: { ok: true } }); + const e2 = store.appendEvent({ + runId: run.id, + type: "phase_started", + phase: "Review", + label: "r1", + }); + 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.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); + + store.beginAgentCall({ + runId: run.id, + callIndex: 0, + cacheKey: "key-a", + provider: "codex", + model: "gpt-5.4", + effort: "high", + phase: "Review", + isolation: "worktree", + worktreePath: "/tmp/wt", + }); + store.completeAgentCall({ + runId: run.id, + callIndex: 0, + responseText: "done", + structuredJson: JSON.stringify({ ok: true }), + providerSessionId: "sess_1", + dirty: true, + }); + 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?.effort, "high"); + + store.beginAgentCall({ + runId: run.id, + callIndex: 1, + cacheKey: "key-b", + provider: "claude", + }); + store.failAgentCall({ runId: run.id, callIndex: 1, error: "boom" }); + assert.equal(store.getAgentCall(run.id, 1)?.status, "failed"); + assert.equal(store.listAgentCalls(run.id).length, 2); + + 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 drainDone = store.drainEvents(run.id, 0, 100); + 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 })); + + // 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"), + }); + store.claimRun(run3.id, 2_147_483_646); + 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"); + + 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: { n: 1 } }).seq, + ); + assert.deepEqual(seqs, [1, 2, 3, 4, 5]); + + assert.ok(store.listRuns().length >= 3); + + // 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 000000000..da29c711c --- /dev/null +++ b/src/workflow-store.ts @@ -0,0 +1,654 @@ +import { randomUUID } from "node:crypto"; +import { resolve } from "node:path"; +import { openDatabase, type DatabaseHandle } from "./db/client.js"; +import type { ServerConfig } from "./config.js"; +import { + WORKFLOW_LIMITS, + type AgentIsolationMode, + type WorkflowAgentCallRecord, + type WorkflowAgentCallStatus, + type WorkflowErrorKind, + type WorkflowEventRecord, + type WorkflowEventType, + type WorkflowRunRecord, + type WorkflowRunSource, + type WorkflowRunStatus, +} from "./workflow-types.js"; + +export interface CreateWorkflowRunInput { + name: string; + source: WorkflowRunSource; + scriptPath: string; + scriptHash: string; + workspaceRoot: string; + workspaceId?: string; + argsJson?: string; + resumedFromRunId?: string; + baseSha?: string; +} + +export interface AppendWorkflowEventInput { + runId: string; + type: WorkflowEventType; + phase?: string; + label?: string; + data?: unknown; +} + +export interface BeginAgentCallInput { + runId: string; + callIndex: number; + cacheKey: string; + provider: string; + model?: string; + effort?: string; + label?: string; + phase?: string; + isolation?: AgentIsolationMode; + worktreePath?: string; +} + +export interface CompleteAgentCallInput { + runId: string; + callIndex: number; + responseText?: string; + structuredJson?: string; + providerSessionId?: string; + dirty?: boolean; + worktreePath?: string; + fromCache?: boolean; +} + +export interface FailAgentCallInput { + runId: string; + callIndex: number; + error: string; + worktreePath?: string; + dirty?: boolean; +} + +export interface CompleteRunInput { + resultJson?: string; +} + +export interface FailRunInput { + error: string; + errorKind?: WorkflowErrorKind; +} + +export interface DrainEventsResult { + events: WorkflowEventRecord[]; + nextSeq: number; + terminal: boolean; + run: WorkflowRunRecord; +} + +interface WorkflowRunRow { + id: string; + name: string; + source: string; + script_path: string; + script_hash: string; + workspace_root: string; + workspace_id: string | null; + args_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; + provider: string; + model: string | null; + effort: string | null; + label: string | null; + phase: string | null; + status: string; + from_cache: string; + provider_session_id: string | null; + response_text: string | null; + structured_json: string | null; + error: 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; +} + +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"; + 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, + 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, 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, + 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; + } + + 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); + } + + /** + * Atomically claim a starting run for the worker. + * Returns undefined if the run is missing or not claimable. + */ + claimRun(id: string, pid: number): WorkflowRunRecord | undefined { + const now = isoNow(); + const result = 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); + if (result.changes === 0) return undefined; + return this.getRun(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 { + const run = this.requireRun(id); + if (TERMINAL_STATUSES.has(run.status)) return run; + + const now = isoNow(); + this.database.sqlite + .prepare( + `update workflow_runs set cancel_requested = 'true', updated_at = ? where id = ?`, + ) + .run(now, id); + return this.requireRun(id); + } + + isCancelRequested(id: string): boolean { + return this.requireRun(id).cancelRequested; + } + + completeRun(id: string, input: CompleteRunInput = {}): WorkflowRunRecord { + if (input.resultJson !== undefined) assertResultSize(input.resultJson); + const now = isoNow(); + const result = 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); + if (result.changes === 0) { + const run = this.requireRun(id); + if (TERMINAL_STATUSES.has(run.status)) return run; + throw new Error(`Cannot complete workflow run ${id} in status ${run.status}`); + } + return this.requireRun(id); + } + + failRun(id: string, input: FailRunInput): WorkflowRunRecord { + const now = isoNow(); + const result = 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, input.errorKind ?? "internal", now, now, id); + if (result.changes === 0) { + const run = this.requireRun(id); + if (TERMINAL_STATUSES.has(run.status)) return run; + throw new Error(`Cannot fail workflow run ${id} in status ${run.status}`); + } + return this.requireRun(id); + } + + cancelRun(id: string, error = "cancelled"): WorkflowRunRecord { + const now = isoNow(); + const result = 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); + if (result.changes === 0) { + const run = this.requireRun(id); + if (TERMINAL_STATUSES.has(run.status)) return run; + throw new Error(`Cannot cancel workflow run ${id} in status ${run.status}`); + } + return this.requireRun(id); + } + + appendEvent(input: AppendWorkflowEventInput): WorkflowEventRecord { + const dataJson = truncateJson(input.data ?? {}, WORKFLOW_LIMITS.eventDataJsonBytes); + const createdAt = isoNow(); + + const insert = this.database.sqlite.transaction(() => { + 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 seq; + }); + + const seq = insert(); + return { + runId: input.runId, + seq, + type: input.type, + phase: input.phase, + label: input.label, + dataJson, + createdAt, + }; + } + + 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) as WorkflowEventRow[]; + const events = rows.map(rowToEvent); + const nextSeq = events.length > 0 ? events[events.length - 1]!.seq : sinceSeq; + return { + events, + nextSeq, + terminal: TERMINAL_STATUSES.has(run.status), + run, + }; + } + + beginAgentCall(input: BeginAgentCallInput): WorkflowAgentCallRecord { + const now = isoNow(); + const isolation: AgentIsolationMode = input.isolation === "worktree" ? "worktree" : "shared"; + this.database.sqlite + .prepare( + `insert into workflow_agent_calls ( + run_id, call_index, cache_key, provider, model, effort, label, phase, + status, from_cache, isolation, worktree_path, created_at, started_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, 'running', 'false', ?, ?, ?, ?, ?)`, + ) + .run( + input.runId, + input.callIndex, + input.cacheKey, + input.provider, + input.model ?? null, + input.effort ?? null, + input.label ?? null, + input.phase ?? null, + isolation, + input.worktreePath ?? null, + now, + now, + now, + ); + return this.requireAgentCall(input.runId, input.callIndex); + } + + completeAgentCall(input: CompleteAgentCallInput): WorkflowAgentCallRecord { + if (input.responseText !== undefined) { + assertTextSize(input.responseText, WORKFLOW_LIMITS.responseTextBytes, "responseText"); + } + if (input.structuredJson !== undefined) { + assertTextSize(input.structuredJson, WORKFLOW_LIMITS.structuredJsonBytes, "structuredJson"); + } + const now = isoNow(); + const status: WorkflowAgentCallStatus = input.fromCache ? "from_cache" : "completed"; + this.database.sqlite + .prepare( + `update workflow_agent_calls set + status = ?, + from_cache = ?, + response_text = ?, + structured_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.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(); + this.database.sqlite + .prepare( + `update workflow_agent_calls set + status = 'failed', + error = ?, + worktree_path = coalesce(?, worktree_path), + dirty = ?, + completed_at = ?, + updated_at = ? + where run_id = ? and call_index = ?`, + ) + .run( + input.error, + input.worktreePath ?? null, + input.dirty === undefined ? null : input.dirty ? "true" : "false", + now, + now, + input.runId, + input.callIndex, + ); + return this.requireAgentCall(input.runId, input.callIndex); + } + + 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); + } + + /** + * Mark running runs with a dead worker as failed. + * staleBeforeMs: heartbeat older than this AND pid not alive. + */ + 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 < ?`, + ) + .all(cutoff) as WorkflowRunRow[]; + + const reaped: WorkflowRunRecord[] = []; + for (const row of candidates) { + if (row.pid !== null && isPidAlive(row.pid)) continue; + reaped.push( + this.failRun(row.id, { + error: "worker heartbeat lost", + errorKind: "heartbeat", + }), + ); + } + return reaped; + } + + 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: row.source as WorkflowRunSource, + scriptPath: row.script_path, + scriptHash: row.script_hash, + workspaceRoot: row.workspace_root, + workspaceId: row.workspace_id ?? undefined, + argsJson: row.args_json, + status: row.status as WorkflowRunStatus, + 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: row.type as WorkflowEventType, + 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, + provider: row.provider, + model: row.model ?? undefined, + effort: row.effort ?? undefined, + label: row.label ?? undefined, + phase: row.phase ?? undefined, + status: row.status as WorkflowAgentCallStatus, + fromCache: row.from_cache === "true", + providerSessionId: row.provider_session_id ?? undefined, + responseText: row.response_text ?? undefined, + structuredJson: row.structured_json ?? undefined, + error: row.error ?? 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 isoNow(): string { + return new Date().toISOString(); +} + +function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + 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 }); +} From 930e8b35186ec74eab1fc67f7839939f21a02c4a Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 21 Jul 2026 17:16:18 +0530 Subject: [PATCH 009/132] feat(workflow): add script parser and restricted sandbox Parse export const meta (pure literal), compile scripts as vm.Script, and run them with banned Date.now/Math.random/bare new Date plus no process/require/fetch. Rehydrate results into the host realm. Co-Authored-By: Claude --- package.json | 2 +- src/workflow-sandbox.test.ts | 83 +++++++++++ src/workflow-sandbox.ts | 216 +++++++++++++++++++++++++++ src/workflow-script.test.ts | 163 ++++++++++++++++++++ src/workflow-script.ts | 280 +++++++++++++++++++++++++++++++++++ 5 files changed, 743 insertions(+), 1 deletion(-) create mode 100644 src/workflow-sandbox.test.ts create mode 100644 src/workflow-sandbox.ts create mode 100644 src/workflow-script.test.ts create mode 100644 src/workflow-script.ts diff --git a/package.json b/package.json index c6a093ba7..bb371809e 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/workflow-sandbox.test.ts b/src/workflow-sandbox.test.ts new file mode 100644 index 000000000..9620cd1d9 --- /dev/null +++ b/src/workflow-sandbox.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { parseWorkflowScript } from "./workflow-script.js"; +import { createStubBudget, type WorkflowMeta } from "./workflow-types.js"; +import { runWorkflowSandbox, WorkflowDeterminismError } from "./workflow-sandbox.js"; + +function api(meta: WorkflowMeta, logs?: string[]) { + return { + agent: async () => "", + parallel: async () => [], + pipeline: async () => [], + phase: () => {}, + log: (msg: unknown) => { + logs?.push(String(msg)); + }, + args: undefined as unknown, + budget: createStubBudget(), + workflow: async () => null, + meta, + }; +} + +{ + 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), + ); +} + +console.log("workflow-sandbox.test.ts: ok"); diff --git a/src/workflow-sandbox.ts b/src/workflow-sandbox.ts new file mode 100644 index 000000000..f05a17f1f --- /dev/null +++ b/src/workflow-sandbox.ts @@ -0,0 +1,216 @@ +import vm from "node:vm"; +import type { ParsedWorkflowScript } from "./workflow-script.js"; +import type { WorkflowBudget, WorkflowMeta } from "./workflow-types.js"; + +export class WorkflowDeterminismError extends Error { + constructor(message: string) { + super(message); + this.name = "WorkflowDeterminismError"; + } +} + +export interface WorkflowSandboxApi { + agent: (...args: unknown[]) => unknown; + parallel: (...args: unknown[]) => unknown; + pipeline: (...args: unknown[]) => unknown; + phase: (...args: unknown[]) => unknown; + log: (...args: unknown[]) => unknown; + args: unknown; + budget: WorkflowBudget; + workflow: (...args: unknown[]) => unknown; + /** 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; +} + +/** + * Execute a compiled workflow script in a restricted node:vm context. + * Not a hostile multi-tenant security boundary — determinism + capability reduction. + */ +export async function runWorkflowSandbox( + options: RunWorkflowSandboxOptions, +): Promise { + const { parsed, api } = options; + const timeoutMs = options.timeoutMs ?? 6 * 60 * 60 * 1000; + + const consoleProxy = { + log: (...args: unknown[]) => { + api.log(args.map(stringifyConsoleArg).join(" ")); + }, + warn: (...args: unknown[]) => { + api.log(args.map(stringifyConsoleArg).join(" ")); + }, + error: (...args: unknown[]) => { + api.log(args.map(stringifyConsoleArg).join(" ")); + }, + info: (...args: unknown[]) => { + api.log(args.map(stringifyConsoleArg).join(" ")); + }, + debug: (...args: unknown[]) => { + api.log(args.map(stringifyConsoleArg).join(" ")); + }, + }; + + // Script params: host APIs only. `meta`/`console` are not params (meta is script-local; + // console lives on sandbox globals so console.log works). + const sandboxApi = { + agent: api.agent, + parallel: api.parallel, + pipeline: api.pipeline, + phase: api.phase, + log: api.log, + args: api.args, + budget: api.budget, + workflow: api.workflow, + }; + + const context = vm.createContext(createSandboxGlobals(consoleProxy)); + const factory = parsed.script.runInContext(context, { + timeout: 5_000, + displayErrors: true, + }) as (api: typeof sandboxApi) => Promise; + + if (typeof factory !== "function") { + throw new Error("Workflow script did not compile to a function"); + } + + const result = await withTimeout( + Promise.resolve().then(() => factory(sandboxApi)), + timeoutMs, + ); + // Context objects keep the sandbox realm's prototypes; rehydrate for host use. + return rehydrateHostValue(result); +} + +/** Copy a sandbox value into the host realm (plain objects / arrays / primitives). */ +export function rehydrateHostValue(value: unknown): unknown { + if (value === null || value === undefined) return value; + const t = typeof value; + if (t === "string" || t === "number" || t === "boolean" || t === "bigint") return value; + if (t === "function" || t === "symbol") return value; + if (Array.isArray(value)) { + return Array.from(value as unknown[], (item) => rehydrateHostValue(item)); + } + if (value instanceof Date) { + return new Date(value.getTime()); + } + const out: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + out[key] = rehydrateHostValue(entry); + } + return out; +} + +function createSandboxGlobals( + consoleProxy: Record void>, +): Record { + return { + Object, + Array, + String, + Number, + Boolean, + Map, + Set, + WeakMap, + WeakSet, + JSON, + Math: createBannedMath(), + Date: createBannedDate(), + RegExp, + Error, + TypeError, + RangeError, + SyntaxError, + URIError, + EvalError, + Promise, + Symbol, + Proxy, + Reflect, + parseInt, + parseFloat, + isNaN, + isFinite, + encodeURI, + decodeURI, + encodeURIComponent, + decodeURIComponent, + undefined, + NaN, + Infinity, + console: consoleProxy, + // Explicitly absent: require, process, fetch, Buffer, setTimeout, setInterval, ... + }; +} + +function createBannedDate(): typeof Date { + const RealDate = Date; + + function DateShim(this: unknown, ...args: unknown[]): string | Date { + if (new.target) { + if (args.length === 0) { + throw new WorkflowDeterminismError( + "new Date() without arguments is banned in workflow scripts (pass an ISO string)", + ); + } + return new (RealDate as unknown as new (...a: unknown[]) => Date)(...args); + } + throw new WorkflowDeterminismError("Date() is banned in workflow scripts"); + } + + DateShim.now = function bannedNow(): number { + throw new WorkflowDeterminismError("Date.now() is banned in workflow scripts"); + }; + DateShim.parse = RealDate.parse.bind(RealDate); + DateShim.UTC = RealDate.UTC.bind(RealDate); + Object.setPrototypeOf(DateShim, RealDate); + DateShim.prototype = RealDate.prototype; + return DateShim as unknown as typeof Date; +} + +function createBannedMath(): Math { + return new Proxy(Math, { + get(target, prop, receiver) { + if (prop === "random") { + return () => { + throw new WorkflowDeterminismError("Math.random() is banned in workflow scripts"); + }; + } + return Reflect.get(target, prop, receiver); + }, + }); +} + +function stringifyConsoleArg(value: unknown): string { + if (typeof value === "string") return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Workflow script exceeded host timeout (${ms}ms)`)); + }, ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} diff --git a/src/workflow-script.test.ts b/src/workflow-script.test.ts new file mode 100644 index 000000000..c8e614238 --- /dev/null +++ b/src/workflow-script.test.ts @@ -0,0 +1,163 @@ +import assert from "node:assert/strict"; +import { parseWorkflowScript, WorkflowScriptError } from "./workflow-script.js"; +import { createStubBudget } from "./workflow-types.js"; +import { runWorkflowSandbox, WorkflowDeterminismError } 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: '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, + }, + }); +} + +{ + 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" }); +} + +{ + 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 000000000..5847f08a2 --- /dev/null +++ b/src/workflow-script.ts @@ -0,0 +1,280 @@ +import { createHash } from "node:crypto"; +import vm from "node:vm"; +import { + LOCAL_AGENT_PROVIDERS, + type LocalAgentProvider, + isLocalAgentProvider, +} from "./local-agent-profiles.js"; +import { WORKFLOW_LIMITS, type WorkflowMeta } from "./workflow-types.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: (api) => Promise */ + script: vm.Script; + filename: string; +} + +const META_EXPORT = /export\s+const\s+meta\s*=/; +const NAME_RE = /^[a-z0-9-]+$/; + +/** + * 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 ="); + + // Reject further imports / exports after transform + if (/\bimport\s+/.test(body) || /\bexport\s+/.test(body)) { + throw new WorkflowScriptError( + "syntax", + "Workflow scripts may not use import or additional export statements", + ); + } + + // Inject host APIs as params. `meta` stays as the script's own `const meta` + // (would TDZ/redeclare if also injected). `console` lives on the sandbox globals. + const wrapped = `(async ({ agent, parallel, pipeline, phase, log, args, budget, workflow }) => {\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); + + // Purity: no calls, spreads, templates inside meta (rough static checks) + if (/[`$]/.test(metaLiteral) && /\$\{/.test(metaLiteral)) { + throw new WorkflowScriptError("meta", "meta must be a pure literal (no template interpolation)"); + } + if (/\.\.\./.test(metaLiteral)) { + throw new WorkflowScriptError("meta", "meta must be a pure literal (no spreads)"); + } + // Disallow identifier references that look like calls: word( + if (/\b[A-Za-z_$][\w$]*\s*\(/.test(metaLiteral)) { + throw new WorkflowScriptError("meta", "meta must be a pure literal (no function calls)"); + } + + return { metaLiteral, metaEndIndex: end + 1 }; +} + +function scanBalancedObject(source: string, start: number): number { + let depth = 0; + let inString: '"' | "'" | null = null; + let escape = false; + for (let i = start; i < source.length; i += 1) { + const ch = source[i]!; + if (inString) { + if (escape) { + escape = false; + continue; + } + if (ch === "\\") { + escape = true; + continue; + } + if (ch === inString) inString = null; + 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 { + const value = vm.runInNewContext(`(${literal})`, Object.create(null), { + filename: `${filename}:meta`, + timeout: 1000, + }); + // Rehydrate into the host realm — vm values keep context prototypes which + // break assert.deepEqual and other host identity checks. + return JSON.parse(JSON.stringify(value)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new WorkflowScriptError("meta", `Invalid meta literal: ${message}`); + } +} + +function validateMeta(value: unknown): WorkflowMeta { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new WorkflowScriptError("meta", "meta must be an object"); + } + const record = value as Record; + const name = readRequiredString(record, "name"); + if (!NAME_RE.test(name)) { + throw new WorkflowScriptError( + "meta", + `meta.name must match ${NAME_RE} (got ${JSON.stringify(name)})`, + ); + } + const description = readRequiredString(record, "description"); + + let phases: WorkflowMeta["phases"]; + if (record.phases !== undefined) { + if (!Array.isArray(record.phases)) { + throw new WorkflowScriptError("meta", "meta.phases must be an array"); + } + const hostPhases: NonNullable = []; + for (let index = 0; index < record.phases.length; index += 1) { + const phase = record.phases[index]; + if (!phase || typeof phase !== "object" || Array.isArray(phase)) { + throw new WorkflowScriptError("meta", `meta.phases[${index}] must be an object`); + } + const p = phase as Record; + const title = readRequiredString(p, "title", `meta.phases[${index}].title`); + const detail = optionalString(p.detail); + hostPhases.push(detail === undefined ? { title } : { title, detail }); + } + phases = hostPhases; + } + + const whenToUse = optionalString(record.whenToUse); + let defaultProvider: LocalAgentProvider | undefined; + if (record.defaultProvider !== undefined) { + if (typeof record.defaultProvider !== "string" || !isLocalAgentProvider(record.defaultProvider)) { + throw new WorkflowScriptError( + "meta", + `meta.defaultProvider must be one of: ${LOCAL_AGENT_PROVIDERS.join(", ")}`, + ); + } + defaultProvider = record.defaultProvider; + } + + let concurrency: number | undefined; + if (record.concurrency !== undefined) { + if (typeof record.concurrency !== "number" || !Number.isFinite(record.concurrency)) { + throw new WorkflowScriptError("meta", "meta.concurrency must be a number"); + } + concurrency = Math.floor(record.concurrency); + if (concurrency < 1) { + throw new WorkflowScriptError("meta", "meta.concurrency must be >= 1"); + } + } + + return { + name, + description, + ...(phases ? { phases } : {}), + ...(whenToUse ? { whenToUse } : {}), + ...(defaultProvider ? { defaultProvider } : {}), + ...(concurrency !== undefined ? { concurrency } : {}), + }; +} + +function readRequiredString( + record: Record, + key: string, + label = `meta.${key}`, +): string { + const value = record[key]; + if (typeof value !== "string" || !value.trim()) { + throw new WorkflowScriptError("meta", `${label} is required`); + } + return value.trim(); +} + +function optionalString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +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; +} From 775f7f049f4945417c1fab7a95902415149dca04 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 21 Jul 2026 17:19:50 +0530 Subject: [PATCH 010/132] feat(workflow): implement engine API with fakes agent/parallel/pipeline/phase/log/workflow primitives, semaphore, ALS phase, isolation worktree hook, nest depth 1, and executeWorkflow against injectable runProvider for unit tests. Co-Authored-By: Claude --- package.json | 2 +- src/workflow-api.ts | 675 ++++++++++++++++++++++++++++++++++++ src/workflow-engine.test.ts | 402 +++++++++++++++++++++ src/workflow-engine.ts | 206 +++++++++++ 4 files changed, 1284 insertions(+), 1 deletion(-) create mode 100644 src/workflow-api.ts create mode 100644 src/workflow-engine.test.ts create mode 100644 src/workflow-engine.ts diff --git a/package.json b/package.json index bb371809e..d5c880d06 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/workflow-api.ts b/src/workflow-api.ts new file mode 100644 index 000000000..05249df1a --- /dev/null +++ b/src/workflow-api.ts @@ -0,0 +1,675 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { createHash } from "node:crypto"; +import type { WorkflowSandboxApi } from "./workflow-sandbox.js"; +import { + WORKFLOW_LIMITS, + WORKFLOW_MAX_ITEMS, + WORKFLOW_MAX_NEST_DEPTH, + buildAgentCacheKeyInput, + createStubBudget, + type AgentIsolationMode, + type AgentOpts, + type WorkflowMeta, +} from "./workflow-types.js"; + +// --------------------------------------------------------------------------- +// Host deps (injected by engine; fakes OK in tests) +// --------------------------------------------------------------------------- + +export interface WorkflowProviderRunInput { + provider: string; + prompt: string; + model?: string; + effort?: string; + workspace: string; + signal?: AbortSignal; + label?: string; + phase?: string; +} + +export interface WorkflowProviderRunResult { + finalResponse: string; + providerSessionId?: string; +} + +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: unknown; + responseText?: string; + structuredJson?: string; + providerSessionId?: string; +} + +export interface WorkflowReplay { + match(callIndex: number, cacheKey: string): WorkflowReplayHit | null; +} + +export interface WorkflowJournal { + appendEvent(input: { + runId: string; + type: + | "phase_started" + | "log" + | "agent_call_started" + | "agent_call_completed" + | "agent_call_failed" + | "agent_call_cached" + | "schema_retry" + | "worktree_created" + | "worktree_finalized"; + phase?: string; + label?: string; + data?: unknown; + }): unknown; + beginAgentCall(input: { + runId: string; + callIndex: number; + cacheKey: string; + provider: string; + model?: string; + effort?: string; + label?: string; + phase?: string; + isolation?: AgentIsolationMode; + worktreePath?: string; + }): unknown; + completeAgentCall(input: { + runId: string; + callIndex: number; + responseText?: string; + structuredJson?: string; + providerSessionId?: string; + dirty?: boolean; + worktreePath?: string; + fromCache?: boolean; + }): unknown; + failAgentCall(input: { + runId: string; + callIndex: number; + error: string; + worktreePath?: string; + dirty?: boolean; + }): unknown; + isCancelRequested(runId: string): boolean; +} + +export interface WorkflowApiDeps { + runId: string; + journal: WorkflowJournal; + meta: WorkflowMeta; + args: unknown; + concurrency: number; + signal: AbortSignal; + workspaceRoot: string; + baseSha?: string; + /** Already-filtered enabled ∩ live provider ids, preference order. */ + enabledProviders: string[]; + 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: unknown; + nestDepth: number; + }) => Promise; + nestDepth?: number; +} + +export interface WorkflowApi extends WorkflowSandboxApi { + getCallCount(): number; + getNestDepth(): number; +} + +export class WorkflowEngineError extends Error { + constructor( + readonly kind: + | "cancelled" + | "provider_disabled" + | "provider_unavailable" + | "no_provider" + | "nest_depth" + | "worktree" + | "schema" + | "path" + | "internal", + message: string, + ) { + super(message); + this.name = "WorkflowEngineError"; + } +} + +// --------------------------------------------------------------------------- +// 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(); + } +} + +// --------------------------------------------------------------------------- +// API factory +// --------------------------------------------------------------------------- + +const phaseAls = new AsyncLocalStorage(); + +export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { + const nestDepth = deps.nestDepth ?? 0; + const semaphore = new WorkflowSemaphore(Math.max(1, deps.concurrency)); + let callIndex = 0; + + 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 provider = resolveProvider(agentOpts.provider, deps.meta, deps.enabledProviders); + const phase = agentOpts.phase ?? phaseAls.getStore(); + const isolation: AgentIsolationMode = + agentOpts.isolation === "worktree" ? "worktree" : "shared"; + const index = callIndex; + callIndex += 1; + + const cacheKeyInput = buildAgentCacheKeyInput({ + prompt, + provider, + model: agentOpts.model, + effort: agentOpts.effort, + schema: agentOpts.schema, + isolation, + }); + const cacheKey = hashCacheKey(cacheKeyInput); + + if (deps.replay) { + const hit = deps.replay.match(index, cacheKey); + if (hit) { + deps.journal.beginAgentCall({ + runId: deps.runId, + callIndex: index, + cacheKey, + provider, + model: agentOpts.model, + effort: agentOpts.effort, + label: agentOpts.label, + phase, + isolation, + }); + deps.journal.completeAgentCall({ + runId: deps.runId, + callIndex: index, + responseText: hit.responseText, + structuredJson: hit.structuredJson, + providerSessionId: hit.providerSessionId, + fromCache: true, + }); + deps.journal.appendEvent({ + runId: deps.runId, + type: "agent_call_cached", + phase, + label: agentOpts.label, + data: { callIndex: index, cacheKey, provider }, + }); + return hit.value; + } + } + + await semaphore.acquire(deps.signal); + let worktree: WorkflowWorktreeHandle | null = null; + let worktreePath: string | undefined; + try { + throwIfCancelled(deps); + + 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 }, + }); + } + + deps.journal.beginAgentCall({ + runId: deps.runId, + callIndex: index, + cacheKey, + provider, + model: agentOpts.model, + effort: agentOpts.effort, + label: agentOpts.label, + phase, + isolation, + worktreePath, + }); + deps.journal.appendEvent({ + runId: deps.runId, + type: "agent_call_started", + phase, + label: agentOpts.label, + data: { + callIndex: index, + cacheKey, + provider, + isolation, + worktreePath, + }, + }); + + const cwd = worktreePath ?? deps.workspaceRoot; + const result = await deps.runProvider({ + provider, + prompt, + model: agentOpts.model, + effort: agentOpts.effort, + workspace: cwd, + signal: deps.signal, + label: agentOpts.label, + phase, + }); + + throwIfCancelled(deps); + + let returnValue: unknown = result.finalResponse; + let structuredJson: string | undefined; + if (agentOpts.schema) { + // Full Ajv enforcement lands in M6; for now extract JSON object when schema set. + const extracted = tryExtractJson(result.finalResponse); + if (extracted === undefined) { + throw new WorkflowEngineError( + "schema", + "agent() schema set but response was not valid JSON", + ); + } + returnValue = extracted; + structuredJson = JSON.stringify(extracted); + } + + 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: structuredJson + ? truncate(structuredJson, WORKFLOW_LIMITS.structuredJsonBytes) + : undefined, + providerSessionId: result.providerSessionId, + dirty, + worktreePath, + }); + deps.journal.appendEvent({ + runId: deps.runId, + type: "agent_call_completed", + phase, + label: agentOpts.label, + data: { + callIndex: index, + provider, + isolation, + worktreePath, + dirty, + fromCache: false, + }, + }); + return returnValue; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + 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 { + // preserve original error + } + } + deps.journal.failAgentCall({ + runId: deps.runId, + callIndex: index, + error: message, + worktreePath, + }); + deps.journal.appendEvent({ + runId: deps.runId, + type: "agent_call_failed", + phase, + label: agentOpts.label, + data: { callIndex: index, error: message, isolation, worktreePath }, + }); + 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"); + } + phaseAls.enterWith(title); + deps.journal.appendEvent({ + runId: deps.runId, + type: "phase_started", + phase: title, + data: { title }, + }); + }; + + const log = (...args: unknown[]): void => { + const message = args.map(String).join(" "); + deps.journal.appendEvent({ + runId: deps.runId, + type: "log", + phase: phaseAls.getStore(), + 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 childArgs = args[1]; + const source = await deps.resolveNestedSource(nameOrRef); + return deps.executeNested({ + source, + args: childArgs, + 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: () => callIndex, + getNestDepth: () => nestDepth, + }; +} + +/** 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"); +} + +export function resolveProvider( + optsProvider: string | undefined, + meta: WorkflowMeta, + enabledProviders: string[], +): string { + if (optsProvider) { + if (!enabledProviders.includes(optsProvider)) { + throw new WorkflowEngineError( + "provider_disabled", + `Provider ${optsProvider} is not enabled or not available`, + ); + } + return optsProvider; + } + if (meta.defaultProvider) { + if (!enabledProviders.includes(meta.defaultProvider)) { + throw new WorkflowEngineError( + "provider_unavailable", + `meta.defaultProvider ${meta.defaultProvider} is not enabled or not available`, + ); + } + return meta.defaultProvider; + } + const first = enabledProviders[0]; + if (!first) { + throw new WorkflowEngineError("no_provider", "No agent providers enabled"); + } + return first; +} + +function normalizeAgentOpts(opts: unknown): AgentOpts { + if (opts === undefined || opts === null) return {}; + if (typeof opts !== "object" || Array.isArray(opts)) { + throw new WorkflowEngineError("internal", "agent opts must be an object"); + } + const record = opts as Record; + const out: AgentOpts = {}; + if (typeof record.label === "string") out.label = record.label; + if (typeof record.phase === "string") out.phase = record.phase; + if (record.schema !== undefined) { + if (!record.schema || typeof record.schema !== "object" || Array.isArray(record.schema)) { + throw new WorkflowEngineError("schema", "agent opts.schema must be an object"); + } + out.schema = record.schema as object; + } + if (typeof record.model === "string") out.model = record.model; + if (typeof record.effort === "string") out.effort = record.effort; + if (typeof record.provider === "string") out.provider = record.provider; + if (record.isolation !== undefined) { + if (record.isolation !== "worktree") { + throw new WorkflowEngineError("worktree", 'agent opts.isolation must be "worktree" when set'); + } + out.isolation = "worktree"; + } + if ("writeMode" in record) { + throw new WorkflowEngineError("internal", "writeMode is not supported on agent() (v1)"); + } + return out; +} + +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 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; + // rough char truncate for journal safety + let end = Math.min(text.length, maxBytes); + while (end > 0 && Buffer.byteLength(text.slice(0, end), "utf8") > maxBytes) end -= 1; + return `${text.slice(0, end)}…`; +} + +/** 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-engine.test.ts b/src/workflow-engine.test.ts new file mode 100644 index 000000000..ad83aedb6 --- /dev/null +++ b/src/workflow-engine.test.ts @@ -0,0 +1,402 @@ +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, + WorkflowEngineError, + WorkflowSemaphore, + getCurrentWorkflowPhase, + type WorkflowProviderRunInput, + type CreateAgentWorktree, +} from "./workflow-api.js"; +import { createStubBudget } from "./workflow-types.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); +} + +// --------------------------------------------------------------------------- +// 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, + enabledProviders: ["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); + 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, + enabledProviders: ["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, + enabledProviders: ["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, + enabledProviders: ["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 }); +} + +// --------------------------------------------------------------------------- +// 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, + enabledProviders: ["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 }); +} + +// --------------------------------------------------------------------------- +// 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' } +return await agent('nested-prompt') +`, + ); + + const prompts: string[] = []; + const { result, callCount } = await executeWorkflow({ + source: ` +export const meta = { name: 'parent', description: 'p' } +const a = await agent('parent-prompt') +const nested = await workflow({ scriptPath: ${JSON.stringify(childPath)} }) +return { a, nested } +`, + runId: run.id, + journal: store, + workspaceRoot: dir, + enabledProviders: ["codex"], + runProvider: async (input) => { + prompts.push(input.prompt); + 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"]); + + // 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, + enabledProviders: ["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, + enabledProviders: ["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 000000000..856dc292b --- /dev/null +++ b/src/workflow-engine.ts @@ -0,0 +1,206 @@ +import { availableParallelism } from "node:os"; +import { parseWorkflowScript, type ParsedWorkflowScript } from "./workflow-script.js"; +import { runWorkflowSandbox } from "./workflow-sandbox.js"; +import { + createWorkflowApi, + type CreateAgentWorktree, + type WorkflowApi, + 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"; + +export interface ExecuteWorkflowOptions { + /** Pre-parsed script, or pass `source` instead. */ + parsed?: ParsedWorkflowScript; + source?: string; + filename?: string; + runId: string; + journal: WorkflowJournal & { + appendEvent(input: { + runId: string; + type: string; + phase?: string; + label?: string; + data?: unknown; + }): unknown; + }; + args?: unknown; + concurrency?: number; + signal?: AbortSignal; + workspaceRoot: string; + baseSha?: string; + enabledProviders: string[]; + runProvider: WorkflowRunProvider; + createWorktree?: CreateAgentWorktree; + replay?: WorkflowReplay; + resolveNestedSource?: (nameOrRef: string | { scriptPath: string }) => string | Promise; + nestDepth?: number; + timeoutMs?: number; + /** 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; + + // 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, + enabledProviders: options.enabledProviders, + runProvider: options.runProvider, + createWorktree: options.createWorktree, + replay: options.replay, + nestDepth, + resolveNestedSource, + executeNested: resolveNestedSource + ? async (input) => + executeNestedOnApi({ + parentOptions: options, + parentApi: api, + source: input.source, + args: input.args, + nestDepth: input.nestDepth, + }) + : 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, + }); + return { + result, + meta: parsed.meta, + callCount: api.getCallCount(), + }; + } catch (error) { + if (error instanceof WorkflowEngineError) { + throw error; + } + throw error; + } +} + +/** + * Nested script execution reusing parent's agent() call counter + semaphore + * by constructing a child API that shares internal state via re-entry. + * + * Implementation: run child sandbox with a new API that has nestDepth+1 but + * delegates agent/parallel/pipeline to the parent API (same callIndex). + */ +async function executeNestedOnApi(input: { + parentOptions: ExecuteWorkflowOptions; + parentApi: WorkflowApi; + source: string; + args: unknown; + nestDepth: number; +}): Promise { + if (input.nestDepth > WORKFLOW_MAX_NEST_DEPTH_LOCAL) { + throw new WorkflowEngineError( + "nest_depth", + `workflow() nesting limited to ${WORKFLOW_MAX_NEST_DEPTH_LOCAL} level`, + ); + } + const parsed = parseWorkflowScript(input.source, { + filename: "workflow:nested", + }); + + // Child surface: reuse parent agent/parallel/pipeline/phase/log/budget/workflow + // so callIndex + semaphore stay shared. Override args + meta for the child body. + const childApi: WorkflowApi = { + agent: input.parentApi.agent, + parallel: input.parentApi.parallel, + pipeline: input.parentApi.pipeline, + phase: input.parentApi.phase, + log: input.parentApi.log, + args: input.args, + budget: input.parentApi.budget, + // Child workflow() must see nestDepth via a wrapper that throws at depth>1. + workflow: async (...args: unknown[]) => { + throw new WorkflowEngineError( + "nest_depth", + `workflow() nesting limited to ${WORKFLOW_MAX_NEST_DEPTH_LOCAL} level`, + ); + }, + meta: parsed.meta, + getCallCount: () => input.parentApi.getCallCount(), + getNestDepth: () => input.nestDepth, + }; + + return runWorkflowSandbox({ + parsed, + api: childApi, + timeoutMs: input.parentOptions.timeoutMs ?? WORKFLOW_HOST_TIMEOUT_MS, + }); +} + +const WORKFLOW_MAX_NEST_DEPTH_LOCAL = 1; + +export function mapEngineErrorKind(error: unknown): WorkflowErrorKind { + if (error instanceof WorkflowEngineError) { + return error.kind; + } + 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"; +} From d5ea59d39c780e3d7ca497902d30730374447097 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 21 Jul 2026 17:26:28 +0530 Subject: [PATCH 011/132] feat(workflow): add files, worktrees, replay, and schema Named/file script resolve, agent worktree factory, resume matcher (index+key then consume-once), and Ajv schema enforcement wired into agent(). Adds ajv dependency. Co-Authored-By: Claude --- package-lock.json | 5 +- package.json | 3 +- src/workflow-api.ts | 47 ++++++--- src/workflow-files.test.ts | 64 +++++++++++++ src/workflow-files.ts | 186 ++++++++++++++++++++++++++++++++++++ src/workflow-replay.test.ts | 55 +++++++++++ src/workflow-replay.ts | 81 ++++++++++++++++ src/workflow-schema.test.ts | 59 ++++++++++++ src/workflow-schema.ts | 132 +++++++++++++++++++++++++ src/workflow-worktrees.ts | 146 ++++++++++++++++++++++++++++ 10 files changed, 760 insertions(+), 18 deletions(-) create mode 100644 src/workflow-files.test.ts create mode 100644 src/workflow-files.ts create mode 100644 src/workflow-replay.test.ts create mode 100644 src/workflow-replay.ts create mode 100644 src/workflow-schema.test.ts create mode 100644 src/workflow-schema.ts create mode 100644 src/workflow-worktrees.ts diff --git a/package-lock.json b/package-lock.json index 0d7decc48..2029860ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@openai/codex-sdk": "^0.142.5", "@opencode-ai/sdk": "^1.17.13", "@pierre/diffs": "^1.2.5", + "ajv": "^8.20.0", "better-sqlite3": "^12.10.0", "diff": "^8.0.3", "drizzle-orm": "^0.45.2", @@ -752,7 +753,7 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "dist/cli.js" + "pi-ai": "./dist/cli.js" }, "engines": { "node": ">=22.19.0" @@ -1057,7 +1058,7 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/float": { - "version": "1.0.3", + "version": "1.0.2", "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" diff --git a/package.json b/package.json index d5c880d06..f43b5dffb 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], @@ -44,6 +44,7 @@ "@openai/codex-sdk": "^0.142.5", "@opencode-ai/sdk": "^1.17.13", "@pierre/diffs": "^1.2.5", + "ajv": "^8.20.0", "better-sqlite3": "^12.10.0", "diff": "^8.0.3", "drizzle-orm": "^0.45.2", diff --git a/src/workflow-api.ts b/src/workflow-api.ts index 05249df1a..ea093a0f7 100644 --- a/src/workflow-api.ts +++ b/src/workflow-api.ts @@ -325,7 +325,7 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { }); const cwd = worktreePath ?? deps.workspaceRoot; - const result = await deps.runProvider({ + const providerBase = { provider, prompt, model: agentOpts.model, @@ -334,25 +334,42 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { signal: deps.signal, label: agentOpts.label, phase, - }); - - throwIfCancelled(deps); + }; - let returnValue: unknown = result.finalResponse; + let returnValue: unknown; let structuredJson: string | undefined; + let result: WorkflowProviderRunResult; + if (agentOpts.schema) { - // Full Ajv enforcement lands in M6; for now extract JSON object when schema set. - const extracted = tryExtractJson(result.finalResponse); - if (extracted === undefined) { - throw new WorkflowEngineError( - "schema", - "agent() schema set but response was not valid JSON", - ); - } - returnValue = extracted; - structuredJson = JSON.stringify(extracted); + // 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, + run: (p) => deps.runProvider({ ...providerBase, prompt: p }), + onRetry: ({ attempt, errors }) => { + deps.journal.appendEvent({ + runId: deps.runId, + type: "schema_retry", + phase, + label: agentOpts.label, + data: { callIndex: index, attempt, errors }, + }); + }, + }); + returnValue = enforced.value; + structuredJson = JSON.stringify(enforced.value); + result = { + finalResponse: enforced.finalResponse, + providerSessionId: enforced.providerSessionId, + }; + } else { + result = await deps.runProvider(providerBase); + returnValue = result.finalResponse; } + throwIfCancelled(deps); + let dirty: boolean | undefined; if (worktree) { const finalized = await worktree.finalize("success"); diff --git a/src/workflow-files.test.ts b/src/workflow-files.test.ts new file mode 100644 index 000000000..40fd4e6b2 --- /dev/null +++ b/src/workflow-files.test.ts @@ -0,0 +1,64 @@ +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 { + parseWorkflowArgFlags, + persistWorkflowScript, + resolveNamedWorkflowScript, + resolveWorkflowScriptFromPathOrName, + WorkflowPathError, +} from "./workflow-files.js"; +import { hashSource } from "./workflow-script.js"; + +{ + const { args, rest } = parseWorkflowArgFlags([ + "--arg", + "n=1", + "--arg", + 'files=["a.ts"]', + "--follow", + "extra", + ]); + assert.deepEqual(args, { n: 1, files: ["a.ts"] }); + assert.deepEqual(rest, ["--follow", "extra"]); +} + +{ + const dir = await mkdtemp(join(tmpdir(), "wf-files-")); + const path = await persistWorkflowScript({ + stateDir: dir, + runId: "wfr_test", + source: "export const meta = { name: 'x', description: 'd' }\nreturn 1\n", + preferredName: "demo", + }); + assert.match(path, /workflow-scripts\/wfr_test\/demo\.js$/); + + const file = await resolveWorkflowScriptFromPathOrName({ + file: path, + workspaceRoot: dir, + }); + assert.equal(file.origin, "file"); + assert.equal(file.scriptHash, hashSource(file.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 resolveNamedWorkflowScript({ + name: "named", + workspaceRoot: dir, + }); + assert.equal(named.origin, "named"); + assert.match(named.source, /named/); + + await assert.rejects( + () => resolveNamedWorkflowScript({ name: "missing", workspaceRoot: dir }), + WorkflowPathError, + ); + + 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 000000000..12b122288 --- /dev/null +++ b/src/workflow-files.ts @@ -0,0 +1,186 @@ +import { createHash, randomBytes } from "node:crypto"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path"; +import { hashSource } from "./workflow-script.js"; + +export class WorkflowPathError extends Error { + constructor(message: string) { + super(message); + this.name = "WorkflowPathError"; + } +} + +export interface ResolvedWorkflowScript { + source: string; + scriptPath: string; + scriptHash: string; + nameHint: string; + origin: "file" | "named" | "inline" | "resume"; +} + +/** + * Persist script under stateDir for worker re-read / audit. + * Returns absolute path written. + */ +export async function persistWorkflowScript(input: { + stateDir: string; + runId: string; + source: string; + preferredName?: string; +}): Promise { + const dir = join(input.stateDir, "workflow-scripts", input.runId); + await mkdir(dir, { recursive: true }); + const base = + sanitizeSegment(input.preferredName ?? "script") || + `script-${randomBytes(3).toString("hex")}`; + const path = join(dir, `${base}.js`); + await writeFile(path, input.source, { encoding: "utf8", mode: 0o600 }); + return path; +} + +export async function readWorkflowScriptFile(path: string): Promise { + const scriptPath = resolve(path); + await assertReadableFile(scriptPath); + const source = await readFile(scriptPath, "utf8"); + return { + source, + scriptPath, + scriptHash: hashSource(source), + nameHint: basename(scriptPath, extname(scriptPath)), + origin: "file", + }; +} + +/** + * Resolve named workflow script. + * Search order: + * 1. `/.devspace/workflows/.js` + * 2. `/workflows/.js` + * 3. `/workflows/.js` (if stateDir provided) + */ +export async function resolveNamedWorkflowScript(input: { + name: string; + workspaceRoot: string; + stateDir?: string; +}): Promise { + const name = input.name.trim(); + if (!name || name.includes("/") || name.includes("\\") || name.includes("..")) { + throw new WorkflowPathError(`Invalid workflow name: ${JSON.stringify(input.name)}`); + } + const candidates = [ + join(input.workspaceRoot, ".devspace", "workflows", `${name}.js`), + join(input.workspaceRoot, "workflows", `${name}.js`), + ]; + if (input.stateDir) { + candidates.push(join(input.stateDir, "workflows", `${name}.js`)); + } + for (const candidate of candidates) { + try { + await assertReadableFile(candidate); + const source = await readFile(candidate, "utf8"); + return { + source, + scriptPath: candidate, + scriptHash: hashSource(source), + nameHint: name, + origin: "named", + }; + } catch { + // try next + } + } + throw new WorkflowPathError( + `Named workflow not found: ${name}. Looked in ${candidates.join(", ")}`, + ); +} + +export async function resolveWorkflowScriptFromPathOrName(input: { + file?: string; + name?: string; + workspaceRoot: string; + stateDir?: string; +}): Promise { + if (input.file && input.name) { + throw new WorkflowPathError("Pass only one of --file or --name"); + } + if (input.file) { + const path = isAbsolute(input.file) + ? input.file + : resolve(input.workspaceRoot, input.file); + return readWorkflowScriptFile(path); + } + if (input.name) { + return resolveNamedWorkflowScript({ + name: input.name, + workspaceRoot: input.workspaceRoot, + stateDir: input.stateDir, + }); + } + throw new WorkflowPathError("Provide --file or --name "); +} + +export function parseWorkflowArgFlags(tokens: string[]): { + args: Record; + rest: string[]; +} { + 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("=")) { + throw new WorkflowPathError("--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) throw new WorkflowPathError("--arg requires key=value"); + args[pair.slice(0, eq)] = coerceArgValue(pair.slice(eq + 1)); + continue; + } + rest.push(token); + } + return { args, rest }; +} + +function coerceArgValue(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return raw; + } +} + +async function assertReadableFile(path: string): Promise { + try { + await access(path); + } catch { + throw new WorkflowPathError(`Script file not found: ${path}`); + } +} + +function sanitizeSegment(value: string): string { + return value + .replace(/[^a-zA-Z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); +} + +export function workflowScriptDirForRun(stateDir: string, runId: string): string { + return join(stateDir, "workflow-scripts", runId); +} + +export function contentHash(source: string): string { + return createHash("sha256").update(source).digest("hex"); +} + +export function dirnameOf(path: string): string { + return dirname(path); +} diff --git a/src/workflow-replay.test.ts b/src/workflow-replay.test.ts new file mode 100644 index 000000000..5258809e9 --- /dev/null +++ b/src/workflow-replay.test.ts @@ -0,0 +1,55 @@ +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", + provider: "codex", + status: "completed", + fromCache: false, + isolation: "shared", + createdAt: "t", + updatedAt: "t", + ...partial, + }; +} + +{ + const replay = createWorkflowReplay([ + call({ callIndex: 0, cacheKey: "k0", responseText: "a" }), + call({ callIndex: 1, cacheKey: "k1", responseText: "b" }), + ]); + assert.equal(replay.match(0, "k0")?.value, "a"); + assert.equal(replay.match(1, "k1")?.value, "b"); + assert.equal(replay.match(2, "k0"), null); +} + +{ + // fan-out reorder: callIndex mismatch, consume-once by key + const replay = createWorkflowReplay([ + call({ callIndex: 0, cacheKey: "ka", responseText: "A" }), + call({ callIndex: 1, cacheKey: "kb", responseText: "B" }), + ]); + // new run asks index0 for kb first + assert.equal(replay.match(0, "kb")?.value, "B"); + assert.equal(replay.match(1, "ka")?.value, "A"); + assert.equal(replay.match(2, "ka"), null); +} + +{ + const replay = createWorkflowReplay([ + call({ + callIndex: 0, + cacheKey: "ks", + responseText: '{"ok":true}', + structuredJson: '{"ok":true}', + }), + ]); + assert.deepEqual(replay.match(0, "ks")?.value, { ok: true }); +} + +console.log("workflow-replay.test.ts: ok"); diff --git a/src/workflow-replay.ts b/src/workflow-replay.ts new file mode 100644 index 000000000..bff7b935c --- /dev/null +++ b/src/workflow-replay.ts @@ -0,0 +1,81 @@ +import type { WorkflowAgentCallRecord } from "./workflow-types.js"; +import type { WorkflowReplay, WorkflowReplayHit } from "./workflow-api.js"; + +/** + * Resume matcher: + * 1. Prefer same callIndex + cacheKey + * 2. On first miss for an index, fall back to consume-once by cacheKey + * (handles fan-out reordering vs prior run). + */ +export function createWorkflowReplay( + priorCalls: WorkflowAgentCallRecord[], +): WorkflowReplay { + const byIndex = new Map(); + const byKeyQueue = new Map(); + + for (const call of priorCalls) { + if (call.status !== "completed" && call.status !== "from_cache") continue; + byIndex.set(call.callIndex, call); + const queue = byKeyQueue.get(call.cacheKey) ?? []; + queue.push(call); + byKeyQueue.set(call.cacheKey, queue); + } + + const consumed = new Set(); // `${callIndex}` of prior rows consumed + + return { + match(callIndex: number, cacheKey: string): WorkflowReplayHit | null { + const exact = byIndex.get(callIndex); + if (exact && exact.cacheKey === cacheKey && !consumed.has(indexKey(exact))) { + consumed.add(indexKey(exact)); + removeFromKeyQueue(byKeyQueue, exact); + return toHit(exact); + } + + const queue = byKeyQueue.get(cacheKey); + if (!queue || queue.length === 0) return null; + const next = queue.shift()!; + consumed.add(indexKey(next)); + if (queue.length === 0) byKeyQueue.delete(cacheKey); + return toHit(next); + }, + }; +} + +function indexKey(call: WorkflowAgentCallRecord): string { + return `${call.runId}:${call.callIndex}`; +} + +function removeFromKeyQueue( + map: Map, + call: WorkflowAgentCallRecord, +): void { + const queue = map.get(call.cacheKey); + if (!queue) return; + const idx = queue.findIndex( + (row) => row.runId === call.runId && row.callIndex === call.callIndex, + ); + if (idx >= 0) queue.splice(idx, 1); + if (queue.length === 0) map.delete(call.cacheKey); +} + +function toHit(call: WorkflowAgentCallRecord): WorkflowReplayHit { + if (call.structuredJson) { + try { + return { + value: JSON.parse(call.structuredJson), + responseText: call.responseText, + structuredJson: call.structuredJson, + providerSessionId: call.providerSessionId, + }; + } catch { + // fall through to text + } + } + return { + value: call.responseText ?? "", + responseText: call.responseText, + structuredJson: call.structuredJson, + providerSessionId: call.providerSessionId, + }; +} diff --git a/src/workflow-schema.test.ts b/src/workflow-schema.test.ts new file mode 100644 index 000000000..67ad58f41 --- /dev/null +++ b/src/workflow-schema.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { + augmentPromptForSchema, + enforceAgentSchema, + formatAjvErrors, +} from "./workflow-schema.js"; +import { WorkflowEngineError } from "./workflow-api.js"; + +{ + const prompt = augmentPromptForSchema("find bugs", { + type: "object", + properties: { n: { type: "number" } }, + required: ["n"], + }); + assert.match(prompt, /ONLY a JSON/); + assert.match(prompt, /"n"/); +} + +assert.equal( + formatAjvErrors([{ instancePath: "/n", message: "must be number" }]), + "/n must be number", +); + +{ + let attempts = 0; + const result = await enforceAgentSchema({ + schema: { + type: "object", + properties: { n: { type: "number" } }, + required: ["n"], + additionalProperties: false, + }, + prompt: "give n", + run: async () => { + attempts += 1; + if (attempts === 1) return { finalResponse: '{"n":"x"}' }; + return { finalResponse: '{"n":2}', providerSessionId: "sess" }; + }, + }); + assert.deepEqual(result.value, { n: 2 }); + assert.equal(result.attempts, 2); + assert.equal(result.providerSessionId, "sess"); +} + +{ + await assert.rejects( + () => + enforceAgentSchema({ + schema: { type: "object", properties: { n: { type: "number" } }, required: ["n"] }, + prompt: "x", + maxRetries: 1, + run: async () => ({ finalResponse: "not json" }), + }), + (error: unknown) => + error instanceof WorkflowEngineError && error.kind === "schema", + ); +} + +console.log("workflow-schema.test.ts: ok"); diff --git a/src/workflow-schema.ts b/src/workflow-schema.ts new file mode 100644 index 000000000..476e97b6f --- /dev/null +++ b/src/workflow-schema.ts @@ -0,0 +1,132 @@ +import { createRequire } from "node:module"; +import { WORKFLOW_MAX_SCHEMA_RETRIES } from "./workflow-types.js"; +import { tryExtractJson, WorkflowEngineError } from "./workflow-api.js"; +import type { WorkflowProviderRunResult, WorkflowRunProvider } from "./workflow-api.js"; + +const require = createRequire(import.meta.url); + +type AjvLike = new (opts?: object) => { + compile: (schema: object) => ((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 interface EnforceSchemaInput { + schema: object; + prompt: string; + run: (prompt: string) => Promise; + onRetry?: (info: { attempt: number; errors: string }) => void; + maxRetries?: number; +} + +export interface EnforceSchemaResult { + value: unknown; + finalResponse: string; + providerSessionId?: string; + attempts: number; +} + +/** + * Augment prompt → run → extract JSON → Ajv validate → retry ≤2. + */ +export async function enforceAgentSchema( + input: EnforceSchemaInput, +): Promise { + const Ajv = loadAjv(); + const ajv = new Ajv({ allErrors: true, strict: false }); + const validate = ajv.compile(input.schema); + const maxRetries = input.maxRetries ?? WORKFLOW_MAX_SCHEMA_RETRIES; + const basePrompt = augmentPromptForSchema(input.prompt, input.schema); + + let lastResponse = ""; + let lastSession: string | undefined; + let lastErrors = "unknown validation error"; + + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + const prompt = + attempt === 0 + ? basePrompt + : `${basePrompt}\n\nPrevious JSON failed validation:\n${lastErrors}\nReturn only corrected JSON.`; + + const result = await input.run(prompt); + lastResponse = result.finalResponse; + lastSession = result.providerSessionId ?? lastSession; + + const extracted = tryExtractJson(result.finalResponse); + if (extracted === undefined) { + lastErrors = "Response was not valid JSON"; + input.onRetry?.({ attempt: attempt + 1, errors: lastErrors }); + continue; + } + + const ok = validate(extracted); + if (ok) { + return { + value: extracted, + finalResponse: result.finalResponse, + providerSessionId: result.providerSessionId, + attempts: attempt + 1, + }; + } + + lastErrors = formatAjvErrors(validate.errors); + input.onRetry?.({ attempt: attempt + 1, errors: lastErrors }); + } + + throw new WorkflowEngineError( + "schema", + `Schema validation failed after ${maxRetries + 1} attempts: ${lastErrors}`, + ); +} + +export function augmentPromptForSchema(prompt: string, schema: object): 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("; "); +} + +/** Helper for wiring into agent(): wrap a one-shot provider as retrying schema runner. */ +export function schemaAwareRunProvider( + runProvider: WorkflowRunProvider, + schema: object, + base: Parameters[0], + onRetry?: EnforceSchemaInput["onRetry"], +): Promise { + return enforceAgentSchema({ + schema, + prompt: base.prompt, + onRetry, + run: (prompt) => + runProvider({ + ...base, + prompt, + }), + }); +} diff --git a/src/workflow-worktrees.ts b/src/workflow-worktrees.ts new file mode 100644 index 000000000..1bbb3414d --- /dev/null +++ b/src/workflow-worktrees.ts @@ -0,0 +1,146 @@ +import { execFile } from "node:child_process"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import type { CreateAgentWorktree, WorkflowWorktreeHandle } from "./workflow-api.js"; +import { WorkflowEngineError } from "./workflow-api.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 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 WorkflowEngineError( + "worktree", + "isolation: 'worktree' requires Git on PATH", + ); + } + throw new WorkflowEngineError( + "worktree", + `isolation: 'worktree' requires a Git repository (not found at ${input.workspaceRoot})`, + ); + } + + const baseSha = + input.baseSha ?? + (await git(["rev-parse", "--verify", "HEAD^{commit}"], sourceRoot)).trim(); + + try { + await git(["worktree", "add", "--detach", path, baseSha], sourceRoot); + } catch (error) { + await rm(path, { recursive: true, force: true }).catch(() => undefined); + const message = error instanceof Error ? error.message : String(error); + throw new WorkflowEngineError( + "worktree", + `Failed to create agent worktree: ${message}`, + ); + } + + return createHandle({ path, sourceRoot }); + }; +} + +function createHandle(input: { + path: string; + sourceRoot: string; +}): WorkflowWorktreeHandle { + return { + path: input.path, + finalize: async (outcome) => { + const dirty = await isDirty(input.path); + if (outcome === "success" && !dirty) { + await removeWorktree(input.sourceRoot, input.path); + return { dirty: false, removed: true }; + } + // Preserve dirty or failed worktrees for diagnosis. + return { dirty, removed: false }; + }, + }; +} + +export async function isDirty(worktreePath: string): Promise { + try { + const status = (await git(["status", "--porcelain=v1"], worktreePath)).trim(); + return status.length > 0; + } catch { + // If status fails, treat as dirty so we don't delete. + return true; + } +} + +export async function removeWorktree( + sourceRoot: string, + worktreePath: string, +): Promise { + try { + await git(["worktree", "remove", "--force", worktreePath], sourceRoot); + } catch { + await rm(worktreePath, { recursive: true, force: true }); + try { + await git(["worktree", "prune"], sourceRoot); + } catch { + // ignore + } + } +} + +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", + ); +} From d14d67ca9b5a9e50a51d96d853b89026492e7ee0 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 21 Jul 2026 17:26:28 +0530 Subject: [PATCH 012/132] feat(workflow): add CLI run/status/cancel/ls and worker Detached __worker with heartbeat/cancel, real adapters via runLocalAgentProvider, worktree isolation, and resume wiring. setScriptPath persists script after run create. Co-Authored-By: Claude --- src/cli.ts | 18 +- src/workflow-cli.ts | 536 ++++++++++++++++++++++++++++++++++++++++++ src/workflow-store.ts | 11 + 3 files changed, 563 insertions(+), 2 deletions(-) create mode 100644 src/workflow-cli.ts diff --git a/src/cli.ts b/src/cli.ts index 88dffa909..9cc413606 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -40,7 +40,9 @@ import { import { expandHomePath } from "./roots.js"; import { shutdownHttpServer } from "./server-shutdown.js"; -type Command = "serve" | "init" | "doctor" | "config" | "agents" | "help" | "version"; +import { runWorkflowCommand } from "./workflow-cli.js"; + +type Command = "serve" | "init" | "doctor" | "config" | "agents" | "workflow" | "help" | "version"; const require = createRequire(import.meta.url); const SUPPORTED_NODE_RANGE = ">=20.12 <27"; @@ -67,6 +69,9 @@ async function main(argv: string[]): Promise { case "agents": await runAgentsCommand(args); return; + case "workflow": + await runWorkflowCommand(args, loadConfig()); + return; case "help": printHelp(); return; @@ -78,7 +83,15 @@ async function main(argv: string[]): Promise { function normalizeCommand(command: string | undefined): Command { if (!command || command === "serve" || command === "start") return "serve"; - if (command === "init" || command === "doctor" || command === "config" || command === "agents") return command; + if ( + command === "init" || + command === "doctor" || + command === "config" || + command === "agents" || + command === "workflow" + ) { + return command; + } if (command === "help" || command === "--help" || command === "-h") return "help"; if (command === "version" || command === "--version" || command === "-v") return "version"; throw new Error(`Unknown command: ${command}`); @@ -312,6 +325,7 @@ function printHelp(): void { " devspace agents ls List subagent sessions", " devspace agents run [--model ] ", " devspace agents show ", + " devspace workflow run|status|cancel|ls", " devspace -v, --version Print the installed version", "", "For temporary tunnels:", diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts new file mode 100644 index 000000000..620cd50c7 --- /dev/null +++ b/src/workflow-cli.ts @@ -0,0 +1,536 @@ +import { spawn } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { availableParallelism } from "node:os"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { ServerConfig } from "./config.js"; +import { runLocalAgentProvider } from "./local-agent-adapters.js"; +import { getLocalAgentProviderAvailabilitySnapshot } from "./local-agent-availability.js"; +import { + isLocalAgentProvider, + LOCAL_AGENT_PROVIDERS, +} from "./local-agent-profiles.js"; +import { executeWorkflow, mapEngineErrorKind } from "./workflow-engine.js"; +import { + parseWorkflowArgFlags, + persistWorkflowScript, + readWorkflowScriptFile, + resolveNamedWorkflowScript, + resolveWorkflowScriptFromPathOrName, +} from "./workflow-files.js"; +import { createWorkflowReplay } from "./workflow-replay.js"; +import { parseWorkflowScript } from "./workflow-script.js"; +import { createWorkflowStore, type WorkflowStore } from "./workflow-store.js"; +import { + WORKFLOW_CANCEL_HARD_MS, + WORKFLOW_HEARTBEAT_MS, + WORKFLOW_LIMITS, + resolveWorkflowConcurrency, + type WorkflowEventRecord, + type WorkflowRunRecord, + type WorkflowRunSource, +} from "./workflow-types.js"; +import { + createWorkflowWorktreeFactory, + resolveWorkspaceHead, +} from "./workflow-worktrees.js"; + +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(config); + return; + case "__worker": + await runWorkflowWorker(rest, config); + return; + case undefined: + case "help": + case "--help": + case "-h": + printWorkflowHelp(); + return; + default: + throw new Error(`Unknown workflow command: ${subcommand}`); + } +} + +export function printWorkflowHelp(): void { + console.log( + [ + "DevSpace workflows", + "", + "Usage:", + " devspace workflow run (--file | --name | --resume )", + " [--arg key=value]... [--follow]", + " devspace workflow status [--follow]", + " devspace workflow cancel ", + " devspace workflow ls", + ].join("\n"), + ); +} + +async function runWorkflowRun(args: string[], config: ServerConfig): Promise { + const { flags } = splitFlags(args); + const follow = flags.has("follow"); + const file = flagValue(flags, "file"); + const name = flagValue(flags, "name"); + const resumeFrom = flagValue(flags, "resume"); + const { args: workflowArgs } = parseWorkflowArgFlags(collectArgTokens(args)); + + if (!file && !name && !resumeFrom) { + throw new Error( + "Usage: devspace workflow run (--file | --name | --resume )", + ); + } + + const store = createWorkflowStore(config); + try { + const workspaceRoot = resolve(process.env.DEVSPACE_WORKSPACE_ROOT || process.cwd()); + let source: string; + let scriptHash: string; + let nameHint: string; + let runSource: WorkflowRunSource = "inline"; + let priorRunId: string | undefined; + let priorScriptPath: string | undefined; + + if (resumeFrom) { + const prior = store.getRun(resumeFrom); + if (!prior) throw new Error(`Unknown workflow run to resume: ${resumeFrom}`); + priorRunId = prior.id; + priorScriptPath = prior.scriptPath; + const resolved = await readWorkflowScriptFile(prior.scriptPath); + source = resolved.source; + scriptHash = prior.scriptHash; + nameHint = prior.name; + runSource = "resume"; + if (!Object.keys(workflowArgs).length && prior.argsJson && prior.argsJson !== "null") { + try { + Object.assign(workflowArgs, JSON.parse(prior.argsJson) as object); + } catch { + // keep empty + } + } + } else { + const resolved = await resolveWorkflowScriptFromPathOrName({ + file, + name, + workspaceRoot, + stateDir: config.stateDir, + }); + source = resolved.source; + scriptHash = resolved.scriptHash; + nameHint = resolved.nameHint; + runSource = resolved.origin === "named" ? "named" : "inline"; + } + + const parsed = parseWorkflowScript(source, { + filename: priorScriptPath ?? file ?? name ?? "workflow:inline", + }); + const baseSha = await resolveWorkspaceHead(workspaceRoot); + + const run = store.createRun({ + name: parsed.meta.name || nameHint, + source: runSource, + scriptPath: priorScriptPath ?? "pending", + scriptHash, + workspaceRoot, + workspaceId: process.env.DEVSPACE_WORKSPACE_ID, + argsJson: JSON.stringify(Object.keys(workflowArgs).length ? workflowArgs : null), + resumedFromRunId: priorRunId, + baseSha, + }); + + const persisted = + priorScriptPath ?? + (await persistWorkflowScript({ + stateDir: config.stateDir, + runId: run.id, + source, + preferredName: parsed.meta.name || nameHint, + })); + if (!priorScriptPath) { + store.setScriptPath(run.id, persisted); + } + + spawnWorkflowWorkerFromCli( + run.id, + fileURLToPath(import.meta.url.replace(/workflow-cli\.(ts|js)$/, "cli.$1")), + ); + + console.log(formatRunLine(store.getRun(run.id) ?? { ...run, scriptPath: persisted })); + + if (follow) { + await followRun(store, run.id); + } + } finally { + store.close(); + } +} + +async function runWorkflowStatus(args: string[], config: ServerConfig): Promise { + const follow = args.includes("--follow"); + const runId = args.find((a) => !a.startsWith("-")); + if (!runId) throw new Error("Usage: devspace workflow status [--follow]"); + + const store = createWorkflowStore(config); + try { + const run = store.getRun(runId); + if (!run) throw new Error(`Unknown workflow run: ${runId}`); + console.log(formatRunLine(run)); + 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 runId = args[0]; + if (!runId) throw new Error("Usage: devspace workflow cancel "); + const store = createWorkflowStore(config); + try { + const run = store.requestCancel(runId); + console.log(formatRunLine(run)); + if (run.pid && (run.status === "running" || run.status === "starting")) { + try { + process.kill(run.pid, "SIGTERM"); + } catch { + // already dead + } + await sleep(WORKFLOW_CANCEL_HARD_MS); + const again = store.getRun(runId); + if (again && (again.status === "running" || again.status === "starting") && again.pid) { + try { + process.kill(-again.pid, "SIGKILL"); + } catch { + try { + process.kill(again.pid, "SIGKILL"); + } catch { + // gone + } + } + const latest = store.getRun(runId); + if (latest && (latest.status === "running" || latest.status === "starting")) { + store.cancelRun(runId, "cancelled (hard kill)"); + } + } + } + console.log(formatRunLine(store.getRun(runId)!)); + } finally { + store.close(); + } +} + +async function runWorkflowList(config: ServerConfig): Promise { + const store = createWorkflowStore(config); + try { + const runs = store.listRuns(50); + if (runs.length === 0) { + console.log("No workflow runs."); + return; + } + for (const run of runs) console.log(formatRunLine(run)); + } finally { + store.close(); + } +} + +/** 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 claimed = store.claimRun(runId, process.pid); + if (!claimed) { + store.close(); + throw new Error(`Cannot claim workflow run ${runId} (missing or not starting)`); + } + + 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 enabledProviders = resolveEnabledProviders(); + const concurrency = resolveWorkflowConcurrency( + parsed.meta.concurrency, + availableParallelism(), + ); + + let argsValue: unknown; + try { + argsValue = JSON.parse(claimed.argsJson); + if (argsValue === null) argsValue = undefined; + } catch { + argsValue = undefined; + } + + const replay = claimed.resumedFromRunId + ? createWorkflowReplay(store.listAgentCalls(claimed.resumedFromRunId)) + : undefined; + + const createWorktree = createWorkflowWorktreeFactory({ + worktreeRoot: config.worktreeRoot, + allowedRoots: config.allowedRoots, + }); + + const { result, callCount } = await executeWorkflow({ + parsed, + runId, + journal: store, + args: argsValue, + concurrency, + signal: abort.signal, + workspaceRoot: claimed.workspaceRoot, + baseSha: claimed.baseSha, + enabledProviders, + 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 providerResult = await runLocalAgentProvider(input.provider, { + prompt: input.prompt, + workspace: input.workspace, + model: input.model, + effort: input.effort, + writeMode: "allowed", + }); + return { + finalResponse: providerResult.finalResponse, + providerSessionId: providerResult.providerSessionId ?? undefined, + }; + }, + resolveNestedSource: async (ref) => { + if (typeof ref === "string") { + const named = await resolveNamedWorkflowScript({ + name: ref, + workspaceRoot: claimed.workspaceRoot, + stateDir: config.stateDir, + }); + return named.source; + } + return readFile(ref.scriptPath, "utf8"); + }, + }); + + 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 }); + store.appendEvent({ + runId, + type: "run_completed", + data: { 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 }); + store.appendEvent({ + runId, + type: "run_failed", + data: { error: message, errorKind }, + }); + } catch { + // terminal race + } + } finally { + clearInterval(heartbeat); + store.close(); + } +} + +export function spawnWorkflowWorkerFromCli(runId: string, cliEntry: string): void { + const child = spawn( + process.execPath, + [...process.execArgv, cliEntry, "workflow", "__worker", runId], + { + detached: true, + stdio: "ignore", + env: process.env, + }, + ); + child.unref(); +} + +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 = String( + (JSON.parse(event.dataJson) as { message?: string }).message ?? event.dataJson, + ); + } 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, +): string { + const err = run.error ? ` error=${JSON.stringify(run.error)}` : ""; + return `${run.id} ${run.status} ${run.name}${err}`; +} + +function resolveEnabledProviders(): string[] { + const snapshot = getLocalAgentProviderAvailabilitySnapshot(); + const live = new Set(snapshot.filter((row) => row.available).map((row) => row.name)); + return LOCAL_AGENT_PROVIDERS.filter((id) => live.has(id)); +} + +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") { + 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-store.ts b/src/workflow-store.ts index da29c711c..4e57e184e 100644 --- a/src/workflow-store.ts +++ b/src/workflow-store.ts @@ -218,6 +218,17 @@ export class WorkflowStore { * Atomically claim a starting run for the worker. * Returns undefined if the run is missing or not claimable. */ + setScriptPath(id: string, scriptPath: string): WorkflowRunRecord { + this.requireRun(id); + const now = isoNow(); + this.database.sqlite + .prepare( + `UPDATE workflow_runs SET script_path = ?, updated_at = ? WHERE id = ?`, + ) + .run(scriptPath, now, id); + return this.requireRun(id); + } + claimRun(id: string, pid: number): WorkflowRunRecord | undefined { const now = isoNow(); const result = this.database.sqlite From c921419284ec90053ae0b8308b1dad2ef58bd39e Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 21 Jul 2026 17:42:38 +0530 Subject: [PATCH 013/132] feat(workflow): add dynamic-workflows skill and seed both defaults Always include bundled skills root when subagents enabled so seeding subagent-delegation no longer hides later skills. Seed dynamic-workflows alongside subagent-delegation on init. Co-Authored-By: Claude --- skills/dynamic-workflows/SKILL.md | 132 ++++++++++++++++++++++++++++++ src/config.test.ts | 7 +- src/skills.ts | 25 +++--- src/user-config.ts | 26 ++++-- 4 files changed, 171 insertions(+), 19 deletions(-) create mode 100644 skills/dynamic-workflows/SKILL.md diff --git a/skills/dynamic-workflows/SKILL.md b/skills/dynamic-workflows/SKILL.md new file mode 100644 index 000000000..e7ab685d0 --- /dev/null +++ b/skills/dynamic-workflows/SKILL.md @@ -0,0 +1,132 @@ +--- +name: dynamic-workflows +description: Orchestrate multi-agent coding workflows via DevSpace Dynamic Workflows (CLI or MCP). +--- + +# Dynamic Workflows + +Use this skill when the user wants multi-step, multi-agent orchestration — fan-out +review, migrate-and-verify, research panels — **not** a single subagent turn. + +## Entry points + +| Host | Surface | +|---|---| +| Coding agent (Claude Code, Codex, pi, …) | CLI + this skill | +| ChatGPT / MCP client | MCP tools `run_workflow` / `workflow_status` / `workflow_cancel` | + +```bash +devspace workflow run --file path/to/script.js [--arg k=v]... [--follow] +devspace workflow run --name review-auth [--follow] +devspace workflow run --resume +devspace workflow status [--follow] +devspace workflow cancel +devspace workflow ls +``` + +Named scripts: `.devspace/workflows/.js` or `workflows/.js`. + +## Script shape + +```js +export const meta = { + name: 'review-auth', + description: 'Fan-out review of auth changes', + phases: [{ title: 'Review' }, { title: 'Synthesize' }], + // optional DevSpace: + // defaultProvider: 'codex', + // concurrency: 4, +} + +phase('Review') +const findings = await parallel([ + () => agent('Review for correctness…', { label: 'correctness' }), + () => agent('Review for security…', { label: 'security' }), +]) +phase('Synthesize') +const summary = await agent(`Synthesize: ${JSON.stringify(findings)}`) +return { summary, findings } +``` + +### Primitives + +| API | Notes | +|---|---| +| `agent(prompt, opts?)` | Throws on failure. `opts`: `label`, `phase`, `schema`, `model`, `effort`, `provider`, `isolation: 'worktree'` | +| `parallel(thunks)` | Barrier; throw → `null` slot | +| `pipeline(items, ...stages)` | Per-item chains; no cross-item barrier | +| `phase(title)` / `log(msg)` | Progress; journaled | +| `args` | Run input (object preferred) | +| `budget` | Stub: `total: null`, `remaining(): Infinity` — do not loop on budget alone | +| `workflow(name\|{scriptPath}, args?)` | Nested, depth 1, shared call index | + +**No `writeMode`.** Teach read-only vs write in the prompt. Use `isolation: 'worktree'` when parallel mutators would conflict (git required). + +### Determinism bans + +`Date.now()`, `Math.random()`, and `new Date()` without args throw. Pass timestamps via `args` if needed. + +### Schema + +```js +const out = await agent('Return JSON findings', { + schema: { + type: 'object', + properties: { bugs: { type: 'array', items: { type: 'string' } } }, + required: ['bugs'], + }, +}) +// out is validated object; engine retries ≤2 on invalid JSON +``` + +### Providers + +Default: first **enabled ∩ available** provider (`agentProviders.enabled` in config, else all live providers in product order). Override with `opts.provider` or `meta.defaultProvider`. + +### Resume + +`devspace workflow run --resume ` creates a **new** run that replays completed agent calls by cache key (callIndex+key, then consume-once by key). + +### Cancel + +`workflow cancel` sets a cooperative flag; worker aborts then hard-kills if needed. + +## When to use CLI vs MCP + +- **CLI**: host agent can shell; prefer for long runs + `--follow`. +- **MCP**: ChatGPT plans; call `run_workflow`, then `workflow_status` until terminal. Disconnecting MCP does **not** kill the worker. + +## Worked mini-examples + +**1. Parallel review** + +```js +export const meta = { name: 'p-review', description: 'Two reviewers' } +const [a, b] = await parallel([ + () => agent('Correctness review of the diff', { label: 'corr' }), + () => agent('Security review of the diff', { label: 'sec' }), +]) +return { a, b } +``` + +**2. Pipeline with schema** + +```js +export const meta = { name: 'pipe', description: 'Find then fix plan' } +return await pipeline( + args.files, + (file) => agent(`List bugs in ${file}`, { schema: { type: 'object', properties: { bugs: { type: 'array', items: { type: 'string' } } }, required: ['bugs'] } }), + (findings, file) => agent(`Plan fixes for ${file}: ${JSON.stringify(findings)}`), +) +``` + +**3. Isolation for parallel writers** + +```js +export const meta = { name: 'iso', description: 'Parallel mutators' } +await parallel([ + () => agent('Implement feature A in isolation', { isolation: 'worktree', label: 'a' }), + () => agent('Implement feature B in isolation', { isolation: 'worktree', label: 'b' }), +]) +// dirty worktrees preserved; compose via return text / shared follow-up +``` diff --git a/src/config.test.ts b/src/config.test.ts index 0b4f99a8c..d4c7b90fa 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -39,9 +39,14 @@ assert.equal(resolveSubagentsFlag({}, { DEVSPACE_SUBAGENTS: "1" }), true); const seededConfigDir = mkdtempSync(join(tmpdir(), "devspace-seeded-skills-test-")); const seededSkillPaths = ensureDevspaceDefaultSkills({ DEVSPACE_CONFIG_DIR: seededConfigDir }); -assert.deepEqual(seededSkillPaths, [join(seededConfigDir, "skills", "subagent-delegation", "SKILL.md")]); +assert.deepEqual(seededSkillPaths, [ + join(seededConfigDir, "skills", "subagent-delegation", "SKILL.md"), + join(seededConfigDir, "skills", "dynamic-workflows", "SKILL.md"), +]); assert.equal(existsSync(seededSkillPaths[0]), true); +assert.equal(existsSync(seededSkillPaths[1]), true); assert.match(readFileSync(seededSkillPaths[0], "utf8"), /name: subagent-delegation/); +assert.match(readFileSync(seededSkillPaths[1], "utf8"), /name: dynamic-workflows/); assert.deepEqual(ensureDevspaceDefaultSkills({ DEVSPACE_CONFIG_DIR: seededConfigDir }), []); assert.throws( diff --git a/src/skills.ts b/src/skills.ts index c1f146a9f..36e413b65 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -22,26 +22,25 @@ export interface SkillReadResolution { } const SUBAGENT_DELEGATION_NAME = "subagent-delegation"; -const SUBAGENT_DELEGATION_SKILL = join(SUBAGENT_DELEGATION_NAME, "SKILL.md"); +const DYNAMIC_WORKFLOWS_NAME = "dynamic-workflows"; function bundledSkillsDir(): string { return fileURLToPath(new URL("../skills", import.meta.url)); } -function hasSubagentDelegationSkill(skillDir: string): boolean { - return existsSync(join(skillDir, SUBAGENT_DELEGATION_SKILL)); -} - +/** + * Always include the bundled skills root when subagents are enabled. + * Previously the whole dir was dropped if the user had seeded + * subagent-delegation — that hid later skills (e.g. dynamic-workflows). + * User/devspace copies still win via earlier path order + name collisions. + */ export function effectiveSkillPaths(config: ServerConfig, cwd: string): string[] { - const bundledSkills = bundledSkillsDir(); const defaultPathCandidates = [ join(homedir(), ".agents", "skills"), resolve(cwd, ".agents", "skills"), config.devspaceSkillsDir, join(config.agentDir, "skills"), - config.subagents && !hasSubagentDelegationSkill(config.devspaceSkillsDir) - ? bundledSkills - : undefined, + config.subagents ? bundledSkillsDir() : undefined, ]; const defaultPaths = defaultPathCandidates.filter( (path): path is string => path !== undefined && existsSync(path), @@ -73,11 +72,15 @@ export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSk if (config.subagents) return result; + const gated = new Set([SUBAGENT_DELEGATION_NAME, DYNAMIC_WORKFLOWS_NAME]); return { - skills: result.skills.filter((skill) => skill.name !== SUBAGENT_DELEGATION_NAME), + skills: result.skills.filter((skill) => !gated.has(skill.name)), diagnostics: result.diagnostics.filter((diagnostic) => { const collision = diagnostic.collision; - return !(collision?.resourceType === "skill" && collision.name === SUBAGENT_DELEGATION_NAME); + return !( + collision?.resourceType === "skill" && + gated.has(collision.name) + ); }), }; } diff --git a/src/user-config.ts b/src/user-config.ts index c8da90b50..5371b04b5 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -8,6 +8,7 @@ import { import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { expandHomePath } from "./roots.js"; +import type { AgentProvidersConfig } from "./workflow-types.js"; export interface DevspaceUserConfig { host?: string; @@ -19,6 +20,8 @@ export interface DevspaceUserConfig { worktreeRoot?: string; agentDir?: string; subagents?: boolean; + /** Ordered enable-list for local agent providers used by workflows/subagents. */ + agentProviders?: AgentProvidersConfig; } export interface DevspaceAuthConfig { @@ -97,14 +100,23 @@ export function generateOwnerToken(): string { return randomBytes(32).toString("base64url"); } -export function ensureDevspaceDefaultSkills(env: NodeJS.ProcessEnv = process.env): string[] { - const targetPath = join(devspaceSkillsDir(env), "subagent-delegation", "SKILL.md"); - if (existsSync(targetPath)) return []; +const DEFAULT_SKILLS = ["subagent-delegation", "dynamic-workflows"] as const; - const sourcePath = new URL("../skills/subagent-delegation/SKILL.md", import.meta.url); - mkdirSync(dirname(targetPath), { recursive: true }); - writeFileSync(targetPath, readFileSync(sourcePath, "utf8"), { mode: 0o644 }); - return [targetPath]; +export function ensureDevspaceDefaultSkills(env: NodeJS.ProcessEnv = process.env): string[] { + const seeded: string[] = []; + for (const name of DEFAULT_SKILLS) { + const targetPath = join(devspaceSkillsDir(env), name, "SKILL.md"); + if (existsSync(targetPath)) continue; + const sourcePath = new URL(`../skills/${name}/SKILL.md`, import.meta.url); + try { + mkdirSync(dirname(targetPath), { recursive: true }); + writeFileSync(targetPath, readFileSync(sourcePath, "utf8"), { mode: 0o644 }); + seeded.push(targetPath); + } catch { + // skill may not exist in package yet; skip + } + } + return seeded; } export function resolveSubagentsFlag( From 349b52ce3c928c518970905449186a2fadd181aa Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 21 Jul 2026 17:42:38 +0530 Subject: [PATCH 014/132] feat(agents): add agentProviders config + init/doctor probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Load ordered enable-list from config/env, probe available providers on init and doctor, and filter workflow CLI providers by enabled∩live. Co-Authored-By: Claude --- src/cli.ts | 70 ++++++++++++++++++++++++++++++++++++++++++++- src/config.ts | 47 ++++++++++++++++++++++++++++++ src/workflow-cli.ts | 11 +++++-- 3 files changed, 124 insertions(+), 4 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 9cc413606..4789a324c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,11 +15,13 @@ import { runLocalAgentProvider } from "./local-agent-adapters.js"; import { isLocalAgentProvider, loadLocalAgentProfiles, + LOCAL_AGENT_PROVIDERS, type LocalAgentProfile, } from "./local-agent-profiles.js"; import { assertLocalAgentProviderAvailable, formatLocalAgentProviderAvailabilitySummary, + getLocalAgentProviderAvailabilitySnapshot, } from "./local-agent-availability.js"; import { formatAvailableLocalAgentTargets, @@ -169,12 +171,18 @@ async function runInit({ force }: { force: boolean }): Promise { validate: validateRequiredPublicBaseUrl, })); + const subagents = resolveSubagentsFlag(files.config); + const agentProviders = + subagents === true + ? probeAndBuildAgentProviders(files.config.agentProviders) + : files.config.agentProviders; const config: DevspaceUserConfig = { host: files.config.host ?? "127.0.0.1", port, allowedRoots, publicBaseUrl, - subagents: resolveSubagentsFlag(files.config), + subagents, + ...(agentProviders ? { agentProviders } : {}), }; const auth = { ownerToken: files.auth.ownerToken ?? generateOwnerToken(), @@ -277,11 +285,71 @@ async function runDoctor(): Promise { console.log(`Public MCP URL: ${new URL("/mcp", config.publicBaseUrl).toString()}`); console.log(`Allowed roots: ${config.allowedRoots.join(", ")}`); console.log(`Allowed hosts: ${config.allowedHosts.join(", ")}`); + console.log(`Subagents: ${config.subagents ? "enabled" : "disabled"}`); + if (config.subagents) { + const snapshot = getLocalAgentProviderAvailabilitySnapshot(); + console.log( + `Agent providers (live): ${formatLocalAgentProviderAvailabilitySummary(snapshot)}`, + ); + if (config.agentProviders) { + console.log( + `Agent providers (enabled): ${ + config.agentProviders.enabled.length + ? config.agentProviders.enabled.join(", ") + : "(empty — no providers)" + }`, + ); + if (config.agentProviders.detectedAt) { + console.log(`Agent providers last probe: ${config.agentProviders.detectedAt}`); + } + } else { + console.log("Agent providers (config): missing (compat = all available)"); + } + + // Refresh lastProbe write-back when subagents on and config exists + if (files.configExists) { + const refreshed = probeAndBuildAgentProviders(files.config.agentProviders); + writeDevspaceConfig({ + ...files.config, + agentProviders: { + // keep user enable-list if set; only refresh probe metadata + available adds when empty + enabled: + files.config.agentProviders?.enabled ?? refreshed.enabled, + detectedAt: refreshed.detectedAt, + lastProbe: refreshed.lastProbe, + }, + }); + console.log(`Agent providers probe written to ${files.configPath}`); + } + } } catch (error) { console.log(`Config status: ${error instanceof Error ? error.message : String(error)}`); } } +/** Probe PATH and build AgentProvidersConfig (available ids in product order). */ +function probeAndBuildAgentProviders( + existing?: DevspaceUserConfig["agentProviders"], +): NonNullable { + const snapshot = getLocalAgentProviderAvailabilitySnapshot(); + const available = new Set( + snapshot.filter((row) => row.available).map((row) => row.name), + ); + const enabled = + existing?.enabled && existing.enabled.length > 0 + ? existing.enabled.filter((id) => LOCAL_AGENT_PROVIDERS.includes(id as never)) + : LOCAL_AGENT_PROVIDERS.filter((id) => available.has(id)); + return { + enabled, + detectedAt: new Date().toISOString(), + lastProbe: snapshot.map((row) => ({ + id: row.name, + available: row.available, + detail: row.reason, + })), + }; +} + function runConfigCommand(args: string[]): void { const [subcommand, key, ...rest] = args; const files = loadDevspaceFiles(); diff --git a/src/config.ts b/src/config.ts index 4fc1bcbb0..65d10a3eb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,6 +4,12 @@ import { expandHomePath } from "./roots.js"; import type { LoggingConfig, LogFormat, LogLevel } from "./logger.js"; import type { OAuthConfig } from "./oauth-provider.js"; import { devspaceAgentsDir, devspaceSkillsDir, loadDevspaceFiles } from "./user-config.js"; +import type { AgentProvidersConfig } from "./workflow-types.js"; +import { + isLocalAgentProvider, + LOCAL_AGENT_PROVIDERS, + type LocalAgentProvider, +} from "./local-agent-profiles.js"; export type ToolMode = "minimal" | "full" | "codex"; export type WidgetMode = "off" | "changes" | "full"; @@ -26,6 +32,12 @@ export interface ServerConfig { devspaceSkillsDir: string; devspaceAgentsDir: string; subagents: boolean; + /** + * Resolved enable-list for agent providers. + * Missing user config → undefined (compat: all live providers). + * Explicit empty → no providers. + */ + agentProviders?: AgentProvidersConfig; agentDir: string; logging: LoggingConfig; } @@ -234,11 +246,46 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { env.DEVSPACE_SUBAGENTS === undefined ? files.config.subagents === true : parseBoolean(env.DEVSPACE_SUBAGENTS), + agentProviders: parseAgentProvidersConfig( + env.DEVSPACE_AGENT_PROVIDERS, + files.config.agentProviders, + ), agentDir: resolve(expandHomePath(env.DEVSPACE_AGENT_DIR ?? files.config.agentDir ?? defaultAgentDir())), logging: parseLoggingConfig(env), }; } +/** + * Env `DEVSPACE_AGENT_PROVIDERS=codex,claude` replaces enabled list. + * Missing config → undefined (compat all-available). + * Explicit enabled: [] stays empty. + */ +export function parseAgentProvidersConfig( + envValue: string | undefined, + fileConfig: AgentProvidersConfig | undefined, +): AgentProvidersConfig | undefined { + if (envValue !== undefined) { + const enabled = envValue + .split(",") + .map((entry) => entry.trim()) + .filter((entry): entry is LocalAgentProvider => isLocalAgentProvider(entry)); + return { enabled }; + } + if (!fileConfig) return undefined; + const enabled = (fileConfig.enabled ?? []).filter((id): id is LocalAgentProvider => + isLocalAgentProvider(id), + ); + return { + enabled, + detectedAt: fileConfig.detectedAt, + lastProbe: fileConfig.lastProbe, + }; +} + +export function defaultAgentProvidersOrder(): LocalAgentProvider[] { + return [...LOCAL_AGENT_PROVIDERS]; +} + function parsePublicBaseUrl(value: string): string { const parsed = new URL(value); parsed.hash = ""; diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts index 620cd50c7..3a7062dba 100644 --- a/src/workflow-cli.ts +++ b/src/workflow-cli.ts @@ -281,7 +281,7 @@ export async function runWorkflowWorker( try { const source = await readFile(claimed.scriptPath, "utf8"); const parsed = parseWorkflowScript(source, { filename: claimed.scriptPath }); - const enabledProviders = resolveEnabledProviders(); + const enabledProviders = resolveEnabledProviders(config.agentProviders); const concurrency = resolveWorkflowConcurrency( parsed.meta.concurrency, availableParallelism(), @@ -474,10 +474,15 @@ function formatRunLine( return `${run.id} ${run.status} ${run.name}${err}`; } -function resolveEnabledProviders(): string[] { +function resolveEnabledProviders( + agentProviders?: ServerConfig["agentProviders"], +): string[] { const snapshot = getLocalAgentProviderAvailabilitySnapshot(); const live = new Set(snapshot.filter((row) => row.available).map((row) => row.name)); - return LOCAL_AGENT_PROVIDERS.filter((id) => live.has(id)); + if (!agentProviders) { + return LOCAL_AGENT_PROVIDERS.filter((id) => live.has(id)); + } + return agentProviders.enabled.filter((id) => live.has(id as never)); } function splitFlags(args: string[]): { From e1ec0499c729ad1727cd65b26213438fc334db0d Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 21 Jul 2026 17:42:38 +0530 Subject: [PATCH 015/132] feat(workflow): register MCP run/status/cancel tools Gate on config.subagents. run_workflow spawns the same detached worker as CLI; status long-polls journal events; cancel requests cooperative stop. Co-Authored-By: Claude --- src/server.ts | 5 + src/workflow-tools.ts | 317 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 322 insertions(+) create mode 100644 src/workflow-tools.ts diff --git a/src/server.ts b/src/server.ts index e47aeb1e6..c44f50328 100644 --- a/src/server.ts +++ b/src/server.ts @@ -47,6 +47,7 @@ import { formatPathForPrompt } from "./skills.js"; import { createWorkspaceStore } from "./workspace-store.js"; import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; import { summarizeLocalAgentProfile } from "./local-agent-profiles.js"; +import { registerWorkflowTools } from "./workflow-tools.js"; import { formatLocalAgentProviderAvailabilitySummary, getLocalAgentProviderAvailabilitySnapshot, @@ -1594,6 +1595,10 @@ function createMcpServer( registerCodexProcessTools(server, config, workspaces, processSessions); } + if (config.subagents) { + registerWorkflowTools(server, config, workspaces); + } + return server; } diff --git a/src/workflow-tools.ts b/src/workflow-tools.ts new file mode 100644 index 000000000..8551701f0 --- /dev/null +++ b/src/workflow-tools.ts @@ -0,0 +1,317 @@ +import { fileURLToPath } from "node:url"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; +import * as z from "zod/v4"; +import type { ServerConfig } from "./config.js"; +import type { WorkspaceRegistry } from "./workspaces.js"; +import { + persistWorkflowScript, + resolveNamedWorkflowScript, + readWorkflowScriptFile, +} from "./workflow-files.js"; +import { parseWorkflowScript } from "./workflow-script.js"; +import { createWorkflowStore } from "./workflow-store.js"; +import { + WORKFLOW_MCP_YIELD_MS, + WORKFLOW_LIMITS, + type AgentProvidersConfig, + type WorkflowEventRecord, + type WorkflowRunRecord, +} from "./workflow-types.js"; +import { resolveWorkspaceHead } from "./workflow-worktrees.js"; +import { spawnWorkflowWorkerFromCli } from "./workflow-cli.js"; +import { getLocalAgentProviderAvailabilitySnapshot } from "./local-agent-availability.js"; +import { + isLocalAgentProvider, + LOCAL_AGENT_PROVIDERS, + type LocalAgentProvider, +} from "./local-agent-profiles.js"; + +const WORKFLOW_API_CHEATSHEET = ` +Workflow scripts (JS only): + export const meta = { name, description, phases?, defaultProvider?, concurrency? } + agent(prompt, { label?, phase?, schema?, model?, effort?, provider?, isolation?: 'worktree' }) + parallel(thunks) → Array // barrier; throw → null + pipeline(items, ...stages) // no cross-item barrier + phase(title); log(msg); args; budget (stub) + workflow(name | { scriptPath }, args?) // nest depth 1 +Bans: Date.now(), Math.random(), new Date() without args. +No writeMode — teach RO vs write in prompts; isolation contains writes. +`.trim(); + +export function registerWorkflowTools( + server: McpServer, + config: ServerConfig, + workspaces: WorkspaceRegistry, +): void { + if (!config.subagents) return; + + registerAppTool( + server, + "run_workflow", + { + title: "Run workflow", + description: + `Start a DevSpace Dynamic Workflow in an open workspace. Prefer named scripts or short inline scripts. ` + + `Poll with workflow_status until terminal. Cancel with workflow_cancel. ${WORKFLOW_API_CHEATSHEET}`, + inputSchema: { + workspaceId: z.string().describe("Workspace id from open_workspace."), + script: z + .string() + .optional() + .describe("Inline workflow script source (export const meta = …)."), + name: z.string().optional().describe("Named workflow under .devspace/workflows/.js"), + resumeFromRunId: z.string().optional().describe("Prior run id to resume (new run + cache)."), + args: z.unknown().optional().describe("Args object/array passed to script as `args`."), + yieldTimeMs: z + .number() + .int() + .min(0) + .max(WORKFLOW_MCP_YIELD_MS) + .optional() + .describe(`Ms to wait for early completion (default 2000, max ${WORKFLOW_MCP_YIELD_MS}).`), + }, + annotations: { readOnlyHint: false }, + _meta: {}, + }, + async ({ workspaceId, script, name, resumeFromRunId, args, yieldTimeMs }) => { + const workspace = workspaces.getWorkspace(workspaceId); + const store = createWorkflowStore(config); + try { + const provided = [script, name, resumeFromRunId].filter((v) => v !== undefined); + if (provided.length !== 1) { + throw new Error("Provide exactly one of script, name, or resumeFromRunId"); + } + + let source: string; + let scriptHash: string; + let nameHint: string; + let priorRunId: string | undefined; + let priorScriptPath: string | undefined; + let runSource: "inline" | "named" | "resume" = "inline"; + + if (resumeFromRunId) { + const prior = store.getRun(resumeFromRunId); + if (!prior) throw new Error(`Unknown run: ${resumeFromRunId}`); + priorRunId = prior.id; + priorScriptPath = prior.scriptPath; + const resolved = await readWorkflowScriptFile(prior.scriptPath); + source = resolved.source; + scriptHash = prior.scriptHash; + nameHint = prior.name; + runSource = "resume"; + if (args === undefined && prior.argsJson && prior.argsJson !== "null") { + try { + args = JSON.parse(prior.argsJson); + } catch { + // keep undefined + } + } + } else if (name) { + const resolved = await resolveNamedWorkflowScript({ + name, + workspaceRoot: workspace.root, + stateDir: config.stateDir, + }); + source = resolved.source; + scriptHash = resolved.scriptHash; + nameHint = resolved.nameHint; + runSource = "named"; + } else { + source = script!; + const parsed = parseWorkflowScript(source); + scriptHash = parsed.scriptHash; + nameHint = parsed.meta.name; + runSource = "inline"; + } + + const parsed = parseWorkflowScript(source); + const baseSha = await resolveWorkspaceHead(workspace.root); + const run = store.createRun({ + name: parsed.meta.name || nameHint, + source: runSource, + scriptPath: priorScriptPath ?? "pending", + scriptHash, + workspaceRoot: workspace.root, + workspaceId, + argsJson: JSON.stringify(args ?? null), + resumedFromRunId: priorRunId, + baseSha, + }); + + const persisted = + priorScriptPath ?? + (await persistWorkflowScript({ + stateDir: config.stateDir, + runId: run.id, + source, + preferredName: parsed.meta.name || nameHint, + })); + if (!priorScriptPath) store.setScriptPath(run.id, persisted); + + const cliEntry = fileURLToPath( + import.meta.url.replace(/workflow-tools\.(ts|js)$/, "cli.$1"), + ); + spawnWorkflowWorkerFromCli(run.id, cliEntry); + + const yieldMs = yieldTimeMs ?? 2_000; + const page = await yieldEvents(store, run.id, 0, yieldMs); + return toolResult(page); + } finally { + store.close(); + } + }, + ); + + registerAppTool( + server, + "workflow_status", + { + title: "Workflow status", + description: "Drain events for a workflow run; optional long-poll yield.", + inputSchema: { + runId: z.string(), + sinceSeq: z.number().int().min(0).optional(), + yieldTimeMs: z + .number() + .int() + .min(0) + .max(WORKFLOW_MCP_YIELD_MS) + .optional() + .describe(`Long-poll ms (default 0, max ${WORKFLOW_MCP_YIELD_MS}).`), + }, + annotations: { readOnlyHint: true }, + _meta: {}, + }, + async ({ runId, sinceSeq, yieldTimeMs }) => { + const store = createWorkflowStore(config); + try { + if (!store.getRun(runId)) throw new Error(`Unknown workflow run: ${runId}`); + const page = await yieldEvents(store, runId, sinceSeq ?? 0, yieldTimeMs ?? 0); + return toolResult(page); + } finally { + store.close(); + } + }, + ); + + registerAppTool( + server, + "workflow_cancel", + { + title: "Cancel workflow", + description: "Request cooperative cancel of a running workflow.", + inputSchema: { + runId: z.string(), + }, + annotations: { readOnlyHint: false }, + _meta: {}, + }, + async ({ runId }) => { + const store = createWorkflowStore(config); + try { + const run = store.requestCancel(runId); + if (run.pid && (run.status === "running" || run.status === "starting")) { + try { + process.kill(run.pid, "SIGTERM"); + } catch { + // already gone + } + } + const latest = store.getRun(runId)!; + return { + content: [{ type: "text" as const, text: JSON.stringify({ runId, status: latest.status }) }], + structuredContent: { runId, status: latest.status }, + }; + } finally { + store.close(); + } + }, + ); + + } + +async function yieldEvents( + store: ReturnType, + runId: string, + sinceSeq: number, + yieldMs: number, +): Promise<{ + run: WorkflowRunRecord; + events: WorkflowEventRecord[]; + nextSeq: number; + terminal: boolean; +}> { + const deadline = Date.now() + Math.min(yieldMs, WORKFLOW_MCP_YIELD_MS); + let cursor = sinceSeq; + let events: WorkflowEventRecord[] = []; + let terminal = false; + let run = store.getRun(runId)!; + + for (;;) { + const page = store.drainEvents(runId, cursor, WORKFLOW_LIMITS.eventDrainDefault); + events = events.concat(page.events); + cursor = page.nextSeq; + terminal = page.terminal; + run = page.run; + if (terminal || Date.now() >= deadline) break; + await sleep(250); + } + + return { run, events, nextSeq: cursor, terminal }; +} + +function toolResult(page: { + run: WorkflowRunRecord; + events: WorkflowEventRecord[]; + nextSeq: number; + terminal: boolean; +}) { + const payload = { + runId: page.run.id, + status: page.run.status, + events: page.events.map((e) => ({ + seq: e.seq, + type: e.type, + phase: e.phase, + label: e.label, + dataJson: e.dataJson, + })), + nextSeq: page.nextSeq, + result: page.run.resultJson ? safeJson(page.run.resultJson) : undefined, + error: page.run.error, + errorKind: page.run.errorKind, + }; + return { + content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }], + structuredContent: payload, + }; +} + +function safeJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return text; + } +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +/** Resolve enabled ∩ live providers for workflows. */ +export function resolveWorkflowEnabledProviders( + agentProviders: AgentProvidersConfig | undefined, +): LocalAgentProvider[] { + const snapshot = getLocalAgentProviderAvailabilitySnapshot(); + const live = new Set( + snapshot.filter((row) => row.available).map((row) => row.name), + ); + if (!agentProviders) { + return LOCAL_AGENT_PROVIDERS.filter((id) => live.has(id)); + } + return agentProviders.enabled.filter( + (id): id is LocalAgentProvider => isLocalAgentProvider(id) && live.has(id), + ); +} From 3030d867268658622e27df1a29971fd99a6670e5 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 21 Jul 2026 22:55:44 +0530 Subject: [PATCH 016/132] feat(agents): pass schema as Codex turn outputSchema Wire LocalAgentRunInput.schema through CodexSdkLocalAgentRuntime to thread.run turn options, and surface parsed structured output. --- src/local-agent-runtime.test.ts | 31 ++++++++++++++++++++++++++++++- src/local-agent-runtime.ts | 23 +++++++++++++++++++++-- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/local-agent-runtime.test.ts b/src/local-agent-runtime.test.ts index 491623f64..e6bd84bed 100644 --- a/src/local-agent-runtime.test.ts +++ b/src/local-agent-runtime.test.ts @@ -13,11 +13,19 @@ const emptyTurn = (finalResponse: string): RunResult => ({ class FakeThread { prompts: string[] = []; + turnOptions: Array<{ outputSchema?: unknown; signal?: AbortSignal } | undefined> = []; constructor(readonly id: string | null) {} - async run(prompt: string): Promise { + async run( + prompt: string, + turnOptions?: { outputSchema?: unknown; signal?: AbortSignal }, + ): Promise { this.prompts.push(prompt); + this.turnOptions.push(turnOptions); + if (turnOptions?.outputSchema) { + return emptyTurn('{"ok":true}'); + } return emptyTurn(`response:${prompt}`); } } @@ -49,7 +57,9 @@ const readOnly = await runtime.run({ assert.equal(readOnly.provider, "codex"); assert.equal(readOnly.providerSessionId, "new-thread"); assert.equal(readOnly.finalResponse, "response:inspect only"); +assert.equal(readOnly.structured, undefined); assert.deepEqual(codex.startThreadInstance.prompts, ["inspect only"]); +assert.deepEqual(codex.startThreadInstance.turnOptions, [undefined]); assert.deepEqual(codex.started[0], { workingDirectory: "/tmp/project", sandboxMode: "read-only", @@ -74,6 +84,24 @@ assert.deepEqual(codex.started[1], { modelReasoningEffort: "high", }); +const schema = { + type: "object", + properties: { ok: { type: "boolean" } }, + required: ["ok"], +} as const; + +const structured = await runtime.run({ + prompt: "return structured", + workspace: "/tmp/project", + schema, +}); + +assert.equal(structured.finalResponse, '{"ok":true}'); +assert.deepEqual(structured.structured, { ok: true }); +assert.deepEqual(codex.startThreadInstance.turnOptions.at(-1), { + outputSchema: schema, +}); + const resumed = await runtime.run({ prompt: "continue", workspace: "/tmp/project", @@ -83,6 +111,7 @@ const resumed = await runtime.run({ assert.equal(resumed.providerSessionId, "resumed-thread"); assert.deepEqual(codex.resumeThreadInstance.prompts, ["continue"]); +assert.deepEqual(codex.resumeThreadInstance.turnOptions, [undefined]); assert.deepEqual(codex.resumed, [ { id: "existing-thread", diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index ebd99feb6..39afe078d 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -17,6 +17,8 @@ export interface LocalAgentRunInput { model?: string; /** Provider-native effort / reasoning level (was thinking). */ effort?: string; + /** JSON Schema for native structured output (codex/claude). */ + schema?: object; } export interface LocalAgentRunResult { @@ -24,6 +26,8 @@ export interface LocalAgentRunResult { providerSessionId: string | null; finalResponse: string; items: unknown[]; + /** Provider-native structured object when schema was requested. */ + structured?: unknown; } export interface LocalAgentRuntime { @@ -31,9 +35,14 @@ export interface LocalAgentRuntime { run(input: LocalAgentRunInput): Promise; } +interface CodexTurnOptions { + outputSchema?: unknown; + signal?: AbortSignal; +} + interface CodexThreadLike { readonly id: string | null; - run(prompt: string): Promise; + run(prompt: string, turnOptions?: CodexTurnOptions): Promise; } interface CodexClientLike { @@ -78,17 +87,27 @@ export class CodexSdkLocalAgentRuntime implements LocalAgentRuntime { const thread = input.providerSessionId ? this.codex.resumeThread(input.providerSessionId, options) : this.codex.startThread(options); - const turn = await thread.run(input.prompt); + const turnOptions = input.schema ? { outputSchema: input.schema } : undefined; + const turn = await thread.run(input.prompt, turnOptions); return { provider: this.provider, providerSessionId: thread.id, finalResponse: turn.finalResponse, items: turn.items, + ...(input.schema ? { structured: tryParseJson(turn.finalResponse) } : {}), }; } } +function tryParseJson(text: string): unknown | undefined { + try { + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +} + export async function createCodexSdkLocalAgentRuntime( options?: CodexOptions, codexFactory?: CodexFactory, From 6af99da21750ca9cf00a854d4110e9be97ea17e7 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 21 Jul 2026 22:56:43 +0530 Subject: [PATCH 017/132] feat(agents): Claude native outputFormat + structured_output Pass JSON Schema via outputFormat on query options and prefer structured_output from result messages. OpenCode stays prompt-path only. --- src/local-agent-adapters.test.ts | 40 ++++++++++++++++++++++ src/local-agent-adapters.ts | 58 +++++++++++++++++++++++++++++--- 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index 19c7dd87a..4438f6cc3 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -2,7 +2,9 @@ import assert from "node:assert/strict"; import { delimiter } from "node:path"; import { claudeCommandEnvironment, + claudeOutputFormatOptions, createLocalAgentAdapter, + extractClaudeResultPayload, extractOpenCodeFinalResponse, extractPiFinalResponse, extractPiProviderError, @@ -388,3 +390,41 @@ assert.equal( assert.equal(env.PATH, [devspaceBin, "/home/user/.local/bin"].join(delimiter)); } + +{ + assert.deepEqual(claudeOutputFormatOptions(undefined), {}); + const schema = { + type: "object", + properties: { n: { type: "number" } }, + required: ["n"], + }; + assert.deepEqual(claudeOutputFormatOptions(schema), { + outputFormat: { type: "json_schema", schema }, + }); +} + +{ + assert.deepEqual( + extractClaudeResultPayload({ + type: "result", + result: '{"n":1}', + structured_output: { n: 1 }, + }), + { finalResponse: '{"n":1}', structured: { n: 1 } }, + ); + assert.deepEqual( + extractClaudeResultPayload({ + type: "result", + structured_output: { n: 2 }, + }), + { finalResponse: '{"n":2}', structured: { n: 2 } }, + ); + assert.deepEqual( + extractClaudeResultPayload({ + type: "result", + result: "plain text only", + }), + { finalResponse: "plain text only" }, + ); + assert.equal(extractClaudeResultPayload({ type: "assistant" }), undefined); +} diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 68e8b6637..6f86e48e4 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -70,20 +70,25 @@ class ClaudeLocalAgentAdapter implements LocalAgentAdapter { allowDangerouslySkipPermissions: true, env: claudeCommandEnvironment(process.env), ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}), + ...claudeOutputFormatOptions(input.schema), }, }); let providerSessionId = input.providerSessionId ?? null; let finalResponse = ""; + let structured: unknown | undefined; const items: unknown[] = []; for await (const message of messages) { items.push(message); const record = message as Record; if (typeof record.session_id === "string") providerSessionId = record.session_id; - if (record.type === "result" && typeof record.result === "string") { - const resultError = claudeResultError(record); - if (resultError) throw new Error(resultError); - finalResponse = record.result; + if (record.type !== "result") continue; + const resultError = claudeResultError(record); + if (resultError) throw new Error(resultError); + const extracted = extractClaudeResultPayload(record); + if (extracted) { + finalResponse = extracted.finalResponse; + structured = extracted.structured; } } @@ -93,10 +98,52 @@ class ClaudeLocalAgentAdapter implements LocalAgentAdapter { providerSessionId, finalResponse, items, + ...(structured !== undefined ? { structured } : {}), }; } } +/** Build Claude SDK outputFormat when a JSON Schema is requested. */ +export function claudeOutputFormatOptions( + schema: object | undefined, +): { outputFormat: { type: "json_schema"; schema: Record } } | Record { + if (!schema) return {}; + return { + outputFormat: { + type: "json_schema", + schema: schema as Record, + }, + }; +} + +/** + * Prefer structured_output from a Claude result message; fall back to text result. + * When only structured is present, stringify it for journal finalResponse. + */ +export function extractClaudeResultPayload( + record: Record, +): { finalResponse: string; structured?: unknown } | undefined { + const hasStructured = Object.prototype.hasOwnProperty.call(record, "structured_output"); + const structured = hasStructured ? record.structured_output : undefined; + const text = typeof record.result === "string" ? record.result : undefined; + + if (hasStructured && structured !== undefined) { + return { + finalResponse: + text && text.trim() + ? text + : typeof structured === "string" + ? structured + : JSON.stringify(structured), + structured, + }; + } + if (text !== undefined) { + return { finalResponse: text }; + } + return undefined; +} + function claudeResultError(record: Record): string | undefined { const subtype = typeof record.subtype === "string" ? record.subtype : undefined; const isError = record.is_error === true || subtype?.startsWith("error"); @@ -516,6 +563,9 @@ async function promptOpencodeSession( sessionId: string, input: LocalAgentRunInput, ): Promise { + // OpenCode SessionPrompt accepts format: { type: 'json_schema', schema }, but + // per-model support is not programmatically discoverable — workflow agent({ schema }) + // keeps the prompt+Ajv path for opencode (no native structured output here). const session = (client as { session: { prompt(parameters?: unknown, options?: unknown): Promise; From 7d797e3315d0a1d119502a13b4f541d4d366d0c7 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 21 Jul 2026 22:57:52 +0530 Subject: [PATCH 018/132] feat(workflow): native-first schema for codex/claude agent() Hardcode NATIVE_SCHEMA_PROVIDERS; attempt 0 uses adapter schema without prompt bloat, then prompt-repair retries with Ajv. Wire schema through runProvider / CLI worker. Document in skill. --- skills/dynamic-workflows/SKILL.md | 1 + src/workflow-api.ts | 18 +++++-- src/workflow-cli.ts | 2 + src/workflow-schema.test.ts | 87 +++++++++++++++++++++++++++++++ src/workflow-schema.ts | 55 ++++++++++++++----- 5 files changed, 146 insertions(+), 17 deletions(-) diff --git a/skills/dynamic-workflows/SKILL.md b/skills/dynamic-workflows/SKILL.md index e7ab685d0..ccb34570f 100644 --- a/skills/dynamic-workflows/SKILL.md +++ b/skills/dynamic-workflows/SKILL.md @@ -77,6 +77,7 @@ const out = await agent('Return JSON findings', { }, }) // out is validated object; engine retries ≤2 on invalid JSON +// codex/claude: native structured output first, then prompt repair; others: prompt+Ajv ``` ### Providers diff --git a/src/workflow-api.ts b/src/workflow-api.ts index ea093a0f7..c5a0aa000 100644 --- a/src/workflow-api.ts +++ b/src/workflow-api.ts @@ -25,11 +25,15 @@ export interface WorkflowProviderRunInput { signal?: AbortSignal; label?: string; phase?: string; + /** JSON Schema for native structured output (codex/claude). */ + schema?: object; } export interface WorkflowProviderRunResult { finalResponse: string; providerSessionId?: string; + /** Provider-native structured object when schema was requested. */ + structured?: unknown; } export type WorkflowRunProvider = ( @@ -346,14 +350,21 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { const enforced = await enforceAgentSchema({ schema: agentOpts.schema, prompt, - run: (p) => deps.runProvider({ ...providerBase, prompt: p }), - onRetry: ({ attempt, errors }) => { + provider, + run: (p) => + deps.runProvider({ + ...providerBase, + prompt: p, + // Keep schema on adapter for codex/claude native+repair attempts. + 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 }, + data: { callIndex: index, attempt, errors, mode }, }); }, }); @@ -362,6 +373,7 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { result = { finalResponse: enforced.finalResponse, providerSessionId: enforced.providerSessionId, + structured: enforced.value, }; } else { result = await deps.runProvider(providerBase); diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts index 3a7062dba..e27076814 100644 --- a/src/workflow-cli.ts +++ b/src/workflow-cli.ts @@ -329,10 +329,12 @@ export async function runWorkflowWorker( model: input.model, effort: input.effort, writeMode: "allowed", + schema: input.schema, }); return { finalResponse: providerResult.finalResponse, providerSessionId: providerResult.providerSessionId ?? undefined, + structured: providerResult.structured, }; }, resolveNestedSource: async (ref) => { diff --git a/src/workflow-schema.test.ts b/src/workflow-schema.test.ts index 67ad58f41..cd068b5f4 100644 --- a/src/workflow-schema.test.ts +++ b/src/workflow-schema.test.ts @@ -3,6 +3,7 @@ import { augmentPromptForSchema, enforceAgentSchema, formatAjvErrors, + NATIVE_SCHEMA_PROVIDERS, } from "./workflow-schema.js"; import { WorkflowEngineError } from "./workflow-api.js"; @@ -21,6 +22,10 @@ assert.equal( "/n must be number", ); +assert.ok(NATIVE_SCHEMA_PROVIDERS.has("codex")); +assert.ok(NATIVE_SCHEMA_PROVIDERS.has("claude")); +assert.ok(!NATIVE_SCHEMA_PROVIDERS.has("opencode")); + { let attempts = 0; const result = await enforceAgentSchema({ @@ -40,6 +45,7 @@ assert.equal( assert.deepEqual(result.value, { n: 2 }); assert.equal(result.attempts, 2); assert.equal(result.providerSessionId, "sess"); + assert.equal(result.mode, "prompt"); } { @@ -56,4 +62,85 @@ assert.equal( ); } +// Native provider: structured on attempt 0 → single attempt, raw prompt. +{ + const seen: Array<{ prompt: string; mode?: string }> = []; + const result = await enforceAgentSchema({ + schema: { + type: "object", + properties: { n: { type: "number" } }, + required: ["n"], + additionalProperties: false, + }, + prompt: "give n", + provider: "codex", + run: async (prompt, opts) => { + seen.push({ prompt, mode: opts?.mode }); + return { finalResponse: "noise", structured: { n: 7 } }; + }, + }); + assert.deepEqual(result.value, { n: 7 }); + assert.equal(result.attempts, 1); + assert.equal(result.mode, "native"); + assert.equal(seen.length, 1); + assert.equal(seen[0]?.prompt, "give n"); + assert.equal(seen[0]?.mode, "native"); + assert.ok(!seen[0]?.prompt.includes("ONLY a JSON")); +} + +// Native fail then prompt repair. +{ + const seen: Array<{ prompt: string; mode?: string }> = []; + const retries: Array<{ attempt: number; mode: string }> = []; + const result = await enforceAgentSchema({ + schema: { + type: "object", + properties: { n: { type: "number" } }, + required: ["n"], + additionalProperties: false, + }, + prompt: "give n", + provider: "claude", + onRetry: ({ attempt, mode }) => { + retries.push({ attempt, mode }); + }, + run: async (prompt, opts) => { + seen.push({ prompt, mode: opts?.mode }); + if (opts?.mode === "native") { + return { finalResponse: '{"n":"bad"}', structured: { n: "bad" } }; + } + return { finalResponse: '{"n":3}', structured: { n: 3 } }; + }, + }); + assert.deepEqual(result.value, { n: 3 }); + assert.equal(result.attempts, 2); + assert.equal(result.mode, "prompt"); + assert.equal(seen[0]?.mode, "native"); + assert.equal(seen[1]?.mode, "prompt"); + assert.ok(seen[1]?.prompt.includes("ONLY a JSON")); + assert.deepEqual(retries[0], { attempt: 1, mode: "native" }); +} + +// Non-native never gets native mode. +{ + const modes: string[] = []; + const result = await enforceAgentSchema({ + schema: { + type: "object", + properties: { n: { type: "number" } }, + required: ["n"], + }, + prompt: "give n", + provider: "opencode", + run: async (prompt, opts) => { + modes.push(opts?.mode ?? "missing"); + assert.ok(prompt.includes("ONLY a JSON")); + return { finalResponse: '{"n":1}' }; + }, + }); + assert.deepEqual(result.value, { n: 1 }); + assert.deepEqual(modes, ["prompt"]); + assert.equal(result.mode, "prompt"); +} + console.log("workflow-schema.test.ts: ok"); diff --git a/src/workflow-schema.ts b/src/workflow-schema.ts index 476e97b6f..b76e6f038 100644 --- a/src/workflow-schema.ts +++ b/src/workflow-schema.ts @@ -5,6 +5,9 @@ import type { WorkflowProviderRunResult, WorkflowRunProvider } from "./workflow- const require = createRequire(import.meta.url); +/** Providers with a real structured-output API (hardcoded — no capability probe). */ +export const NATIVE_SCHEMA_PROVIDERS = new Set(["codex", "claude"]); + type AjvLike = new (opts?: object) => { compile: (schema: object) => ((data: unknown) => boolean) & { errors?: Array<{ instancePath?: string; message?: string }> | null; @@ -23,11 +26,25 @@ function loadAjv(): AjvLike { } } +export type SchemaEnforceMode = "native" | "prompt"; + export interface EnforceSchemaInput { schema: object; prompt: string; - run: (prompt: string) => Promise; - onRetry?: (info: { attempt: number; errors: string }) => void; + /** + * 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?: string; + run: ( + prompt: string, + opts?: { mode: SchemaEnforceMode }, + ) => Promise; + onRetry?: (info: { + attempt: number; + errors: string; + mode: SchemaEnforceMode; + }) => void; maxRetries?: number; } @@ -36,10 +53,12 @@ export interface EnforceSchemaResult { finalResponse: string; providerSessionId?: string; attempts: number; + mode: SchemaEnforceMode; } /** - * Augment prompt → run → extract JSON → Ajv validate → retry ≤2. + * 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, @@ -48,26 +67,31 @@ export async function enforceAgentSchema( const ajv = new Ajv({ allErrors: true, strict: false }); const validate = ajv.compile(input.schema); const maxRetries = input.maxRetries ?? WORKFLOW_MAX_SCHEMA_RETRIES; + const native = Boolean(input.provider && NATIVE_SCHEMA_PROVIDERS.has(input.provider)); const basePrompt = augmentPromptForSchema(input.prompt, input.schema); - let lastResponse = ""; - let lastSession: string | undefined; let lastErrors = "unknown validation error"; for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + const mode: SchemaEnforceMode = native && attempt === 0 ? "native" : "prompt"; + const prompt = - attempt === 0 - ? basePrompt - : `${basePrompt}\n\nPrevious JSON failed validation:\n${lastErrors}\nReturn only corrected JSON.`; + mode === "native" + ? input.prompt + : attempt === 0 + ? basePrompt + : `${basePrompt}\n\nPrevious JSON failed validation:\n${lastErrors}\nReturn only corrected JSON.`; + + const result = await input.run(prompt, { mode }); - const result = await input.run(prompt); - lastResponse = result.finalResponse; - lastSession = result.providerSessionId ?? lastSession; + const extracted = + result.structured !== undefined + ? result.structured + : tryExtractJson(result.finalResponse); - const extracted = tryExtractJson(result.finalResponse); if (extracted === undefined) { lastErrors = "Response was not valid JSON"; - input.onRetry?.({ attempt: attempt + 1, errors: lastErrors }); + input.onRetry?.({ attempt: attempt + 1, errors: lastErrors, mode }); continue; } @@ -78,11 +102,12 @@ export async function enforceAgentSchema( finalResponse: result.finalResponse, providerSessionId: result.providerSessionId, attempts: attempt + 1, + mode, }; } lastErrors = formatAjvErrors(validate.errors); - input.onRetry?.({ attempt: attempt + 1, errors: lastErrors }); + input.onRetry?.({ attempt: attempt + 1, errors: lastErrors, mode }); } throw new WorkflowEngineError( @@ -122,11 +147,13 @@ export function schemaAwareRunProvider( return enforceAgentSchema({ schema, prompt: base.prompt, + provider: base.provider, onRetry, run: (prompt) => runProvider({ ...base, prompt, + schema, }), }); } From a8b8e5a7efab0e86c33de7e68af1e015bc25d510 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 22 Jul 2026 00:22:17 +0530 Subject: [PATCH 019/132] fix(workflow): make native schema fallback session-aware --- src/local-agent-adapters.ts | 87 ++++++++++++++------------ src/local-agent-runtime.test.ts | 9 +++ src/local-agent-runtime.ts | 51 ++++++++++++++-- src/workflow-api.ts | 7 ++- src/workflow-cli.ts | 1 + src/workflow-engine.test.ts | 53 ++++++++++++++++ src/workflow-schema.test.ts | 104 ++++++++++++++++++++++++++++++-- src/workflow-schema.ts | 73 ++++++++++++++++------ 8 files changed, 313 insertions(+), 72 deletions(-) diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 6f86e48e4..fe56e81dd 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -6,6 +6,8 @@ import type { LocalAgentProvider } from "./local-agent-profiles.js"; import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; import { createCodexSdkLocalAgentRuntime, + isNativeSchemaUnsupportedFailure, + ProviderSchemaUnsupportedError, type LocalAgentRunInput, type LocalAgentRunResult, } from "./local-agent-runtime.js"; @@ -59,47 +61,56 @@ class ClaudeLocalAgentAdapter implements LocalAgentAdapter { async run(input: LocalAgentRunInput): Promise { const { query } = await import("@anthropic-ai/claude-agent-sdk"); const claudeExecutable = process.env.CLAUDE_COMMAND ?? resolveExecutable("claude"); - const messages = query({ - prompt: input.prompt, - options: { - cwd: input.workspace, - model: input.model, - ...(input.effort ? { thinking: { type: "adaptive" } as const, effort: input.effort as EffortLevel } : {}), - resume: input.providerSessionId, - permissionMode: "bypassPermissions", - allowDangerouslySkipPermissions: true, - env: claudeCommandEnvironment(process.env), - ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}), - ...claudeOutputFormatOptions(input.schema), - }, - }); + try { + const messages = query({ + prompt: input.prompt, + options: { + cwd: input.workspace, + model: input.model, + ...(input.effort + ? { thinking: { type: "adaptive" } as const, effort: input.effort as EffortLevel } + : {}), + resume: input.providerSessionId, + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + env: claudeCommandEnvironment(process.env), + ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}), + ...claudeOutputFormatOptions(input.schema), + }, + }); - let providerSessionId = input.providerSessionId ?? null; - let finalResponse = ""; - let structured: unknown | undefined; - const items: unknown[] = []; - for await (const message of messages) { - items.push(message); - const record = message as Record; - if (typeof record.session_id === "string") providerSessionId = record.session_id; - if (record.type !== "result") continue; - const resultError = claudeResultError(record); - if (resultError) throw new Error(resultError); - const extracted = extractClaudeResultPayload(record); - if (extracted) { - finalResponse = extracted.finalResponse; - structured = extracted.structured; + let providerSessionId = input.providerSessionId ?? null; + let finalResponse = ""; + let structured: unknown | undefined; + const items: unknown[] = []; + for await (const message of messages) { + items.push(message); + const record = message as Record; + if (typeof record.session_id === "string") providerSessionId = record.session_id; + if (record.type !== "result") continue; + const resultError = claudeResultError(record); + if (resultError) throw new Error(resultError); + const extracted = extractClaudeResultPayload(record); + if (extracted) { + finalResponse = extracted.finalResponse; + structured = extracted.structured; + } } - } - finalResponse = requireFinalResponse("Claude", finalResponse); - return { - provider: this.provider, - providerSessionId, - finalResponse, - items, - ...(structured !== undefined ? { structured } : {}), - }; + finalResponse = requireFinalResponse("Claude", finalResponse); + return { + provider: this.provider, + providerSessionId, + finalResponse, + items, + ...(structured !== undefined ? { structured } : {}), + }; + } catch (error) { + if (input.schema && isNativeSchemaUnsupportedFailure(error)) { + throw new ProviderSchemaUnsupportedError(this.provider, error); + } + throw error; + } } } diff --git a/src/local-agent-runtime.test.ts b/src/local-agent-runtime.test.ts index e6bd84bed..29b3d69fc 100644 --- a/src/local-agent-runtime.test.ts +++ b/src/local-agent-runtime.test.ts @@ -3,6 +3,7 @@ import type { RunResult, ThreadOptions } from "@openai/codex-sdk"; import { CodexSdkLocalAgentRuntime, createCodexSdkLocalAgentRuntime, + isNativeSchemaUnsupportedFailure, } from "./local-agent-runtime.js"; const emptyTurn = (finalResponse: string): RunResult => ({ @@ -127,3 +128,11 @@ assert.deepEqual(codex.resumed, [ const created = await createCodexSdkLocalAgentRuntime(undefined, () => new FakeCodex()); assert.equal(created.provider, "codex"); + +assert.equal( + isNativeSchemaUnsupportedFailure( + new Error("Invalid output schema: keyword is not supported"), + ), + true, +); +assert.equal(isNativeSchemaUnsupportedFailure(new Error("authentication failed")), false); diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index 39afe078d..5690e4696 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -5,6 +5,7 @@ import type { RunResult, SandboxMode, ThreadOptions, + TurnOptions, } from "@openai/codex-sdk"; export type LocalAgentWriteMode = "read_only" | "allowed" | "full_access"; @@ -35,14 +36,42 @@ export interface LocalAgentRuntime { run(input: LocalAgentRunInput): Promise; } -interface CodexTurnOptions { - outputSchema?: unknown; - signal?: AbortSignal; +export class ProviderSchemaUnsupportedError extends Error { + constructor( + readonly provider: string, + readonly cause: unknown, + ) { + super(`${provider} does not support the requested native output schema: ${errorMessage(cause)}`); + this.name = "ProviderSchemaUnsupportedError"; + } +} + +export function isProviderSchemaUnsupportedError( + error: unknown, +): error is ProviderSchemaUnsupportedError { + return error instanceof ProviderSchemaUnsupportedError; +} + +export function isNativeSchemaUnsupportedFailure(error: unknown): boolean { + const message = errorMessage(error).toLowerCase(); + const mentionsSchema = + /output[ _-]?schema/.test(message) || + /json[ _-]?schema/.test(message) || + /structured[ _-]?output/.test(message) || + /output[ _-]?format/.test(message); + const unsupported = + /not supported/.test(message) || + /unsupported/.test(message) || + /invalid (?:output|json )?schema/.test(message) || + /schema (?:is )?invalid/.test(message) || + /unknown (?:field|parameter|option)/.test(message) || + /not available/.test(message); + return mentionsSchema && unsupported; } interface CodexThreadLike { readonly id: string | null; - run(prompt: string, turnOptions?: CodexTurnOptions): Promise; + run(prompt: string, turnOptions?: TurnOptions): Promise; } interface CodexClientLike { @@ -88,7 +117,15 @@ export class CodexSdkLocalAgentRuntime implements LocalAgentRuntime { ? this.codex.resumeThread(input.providerSessionId, options) : this.codex.startThread(options); const turnOptions = input.schema ? { outputSchema: input.schema } : undefined; - const turn = await thread.run(input.prompt, turnOptions); + let turn: RunResult; + try { + turn = await thread.run(input.prompt, turnOptions); + } catch (error) { + if (input.schema && isNativeSchemaUnsupportedFailure(error)) { + throw new ProviderSchemaUnsupportedError(this.provider, error); + } + throw error; + } return { provider: this.provider, @@ -120,3 +157,7 @@ async function defaultCodexFactory(): Promise { const module = await import("@openai/codex-sdk"); return (options) => new module.Codex(options) as Codex; } + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/workflow-api.ts b/src/workflow-api.ts index c5a0aa000..6e0eddafb 100644 --- a/src/workflow-api.ts +++ b/src/workflow-api.ts @@ -19,6 +19,7 @@ import { export interface WorkflowProviderRunInput { provider: string; prompt: string; + providerSessionId?: string; model?: string; effort?: string; workspace: string; @@ -351,12 +352,12 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { schema: agentOpts.schema, prompt, provider, - run: (p) => + run: (p, options) => deps.runProvider({ ...providerBase, prompt: p, - // Keep schema on adapter for codex/claude native+repair attempts. - schema: agentOpts.schema, + providerSessionId: options.providerSessionId, + ...(options.mode === "native" ? { schema: agentOpts.schema } : {}), }), onRetry: ({ attempt, errors, mode }) => { deps.journal.appendEvent({ diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts index e27076814..e1c459c07 100644 --- a/src/workflow-cli.ts +++ b/src/workflow-cli.ts @@ -326,6 +326,7 @@ export async function runWorkflowWorker( const providerResult = await runLocalAgentProvider(input.provider, { prompt: input.prompt, workspace: input.workspace, + providerSessionId: input.providerSessionId, model: input.model, effort: input.effort, writeMode: "allowed", diff --git a/src/workflow-engine.test.ts b/src/workflow-engine.test.ts index ad83aedb6..030fb590f 100644 --- a/src/workflow-engine.test.ts +++ b/src/workflow-engine.test.ts @@ -279,6 +279,59 @@ import { createStubBudget } from "./workflow-types.js"; await rm(dir, { recursive: true, force: true }); } +// --------------------------------------------------------------------------- +// schema retry: native schema only on first 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, + enabledProviders: ["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.ok(calls[0]?.schema); + assert.equal(calls[0]?.providerSessionId, undefined); + assert.equal(calls[1]?.schema, undefined); + assert.equal(calls[1]?.providerSessionId, "sess-1"); + + store.close(); + await rm(dir, { recursive: true, force: true }); +} + // --------------------------------------------------------------------------- // executeWorkflow end-to-end with sandbox + nest depth // --------------------------------------------------------------------------- diff --git a/src/workflow-schema.test.ts b/src/workflow-schema.test.ts index cd068b5f4..2084f5137 100644 --- a/src/workflow-schema.test.ts +++ b/src/workflow-schema.test.ts @@ -6,6 +6,7 @@ import { NATIVE_SCHEMA_PROVIDERS, } from "./workflow-schema.js"; import { WorkflowEngineError } from "./workflow-api.js"; +import { ProviderSchemaUnsupportedError } from "./local-agent-runtime.js"; { const prompt = augmentPromptForSchema("find bugs", { @@ -64,7 +65,7 @@ assert.ok(!NATIVE_SCHEMA_PROVIDERS.has("opencode")); // Native provider: structured on attempt 0 → single attempt, raw prompt. { - const seen: Array<{ prompt: string; mode?: string }> = []; + const seen: Array<{ prompt: string; mode?: string; providerSessionId?: string }> = []; const result = await enforceAgentSchema({ schema: { type: "object", @@ -90,7 +91,11 @@ assert.ok(!NATIVE_SCHEMA_PROVIDERS.has("opencode")); // Native fail then prompt repair. { - const seen: Array<{ prompt: string; mode?: string }> = []; + const seen: Array<{ + prompt: string; + mode?: string; + providerSessionId?: string; + }> = []; const retries: Array<{ attempt: number; mode: string }> = []; const result = await enforceAgentSchema({ schema: { @@ -105,9 +110,17 @@ assert.ok(!NATIVE_SCHEMA_PROVIDERS.has("opencode")); retries.push({ attempt, mode }); }, run: async (prompt, opts) => { - seen.push({ prompt, mode: opts?.mode }); - if (opts?.mode === "native") { - return { finalResponse: '{"n":"bad"}', structured: { n: "bad" } }; + seen.push({ + prompt, + mode: opts.mode, + providerSessionId: opts.providerSessionId, + }); + if (opts.mode === "native") { + return { + finalResponse: '{"n":"bad"}', + structured: { n: "bad" }, + providerSessionId: "sess-native", + }; } return { finalResponse: '{"n":3}', structured: { n: 3 } }; }, @@ -117,10 +130,89 @@ assert.ok(!NATIVE_SCHEMA_PROVIDERS.has("opencode")); assert.equal(result.mode, "prompt"); assert.equal(seen[0]?.mode, "native"); assert.equal(seen[1]?.mode, "prompt"); + assert.equal(seen[1]?.providerSessionId, "sess-native"); assert.ok(seen[1]?.prompt.includes("ONLY a JSON")); assert.deepEqual(retries[0], { attempt: 1, mode: "native" }); } +// Native structured strings are parsed when the schema expects a non-string value. +{ + const result = await enforceAgentSchema({ + schema: { + type: "object", + properties: { n: { type: "number" } }, + required: ["n"], + }, + prompt: "give n", + provider: "claude", + run: async () => ({ + finalResponse: '{"n":4}', + structured: '{"n":4}', + }), + }); + assert.deepEqual(result.value, { n: 4 }); +} + +// A classified native-schema capability failure falls back to prompt mode. +{ + const modes: string[] = []; + const result = await enforceAgentSchema({ + schema: { + type: "object", + properties: { n: { type: "number" } }, + required: ["n"], + }, + prompt: "give n", + provider: "codex", + run: async (_prompt, opts) => { + modes.push(opts.mode); + if (opts.mode === "native") { + throw new ProviderSchemaUnsupportedError( + "codex", + new Error("output schema is not supported"), + ); + } + return { finalResponse: '{"n":5}' }; + }, + }); + assert.deepEqual(result.value, { n: 5 }); + assert.deepEqual(modes, ["native", "prompt"]); +} + +// Arbitrary provider failures are not disguised as schema fallback. +{ + let calls = 0; + await assert.rejects( + () => + enforceAgentSchema({ + schema: { type: "object" }, + prompt: "x", + provider: "codex", + run: async () => { + calls += 1; + throw new Error("authentication failed"); + }, + }), + /authentication failed/, + ); + assert.equal(calls, 1); +} + +// Do not report a retry when the retry budget is exhausted. +{ + const retries: number[] = []; + await assert.rejects(() => + enforceAgentSchema({ + schema: { type: "object" }, + prompt: "x", + maxRetries: 0, + onRetry: ({ attempt }) => retries.push(attempt), + run: async () => ({ finalResponse: "not json" }), + }), + ); + assert.deepEqual(retries, []); +} + // Non-native never gets native mode. { const modes: string[] = []; @@ -133,7 +225,7 @@ assert.ok(!NATIVE_SCHEMA_PROVIDERS.has("opencode")); prompt: "give n", provider: "opencode", run: async (prompt, opts) => { - modes.push(opts?.mode ?? "missing"); + modes.push(opts.mode); assert.ok(prompt.includes("ONLY a JSON")); return { finalResponse: '{"n":1}' }; }, diff --git a/src/workflow-schema.ts b/src/workflow-schema.ts index b76e6f038..1d93fe5f5 100644 --- a/src/workflow-schema.ts +++ b/src/workflow-schema.ts @@ -2,6 +2,7 @@ import { createRequire } from "node:module"; import { WORKFLOW_MAX_SCHEMA_RETRIES } from "./workflow-types.js"; import { tryExtractJson, WorkflowEngineError } from "./workflow-api.js"; import type { WorkflowProviderRunResult, WorkflowRunProvider } from "./workflow-api.js"; +import { isProviderSchemaUnsupportedError } from "./local-agent-runtime.js"; const require = createRequire(import.meta.url); @@ -38,7 +39,10 @@ export interface EnforceSchemaInput { provider?: string; run: ( prompt: string, - opts?: { mode: SchemaEnforceMode }, + opts: { + mode: SchemaEnforceMode; + providerSessionId?: string; + }, ) => Promise; onRetry?: (info: { attempt: number; @@ -71,6 +75,7 @@ export async function enforceAgentSchema( const basePrompt = augmentPromptForSchema(input.prompt, input.schema); let lastErrors = "unknown validation error"; + let providerSessionId: string | undefined; for (let attempt = 0; attempt <= maxRetries; attempt += 1) { const mode: SchemaEnforceMode = native && attempt === 0 ? "native" : "prompt"; @@ -82,32 +87,45 @@ export async function enforceAgentSchema( ? basePrompt : `${basePrompt}\n\nPrevious JSON failed validation:\n${lastErrors}\nReturn only corrected JSON.`; - const result = await input.run(prompt, { mode }); - - const extracted = - result.structured !== undefined - ? result.structured - : tryExtractJson(result.finalResponse); + let result: WorkflowProviderRunResult; + try { + result = await input.run(prompt, { mode, providerSessionId }); + } catch (error) { + if (mode === "native" && isProviderSchemaUnsupportedError(error) && attempt < maxRetries) { + lastErrors = error.message; + input.onRetry?.({ attempt: attempt + 1, errors: lastErrors, mode }); + continue; + } + throw error; + } + providerSessionId = result.providerSessionId ?? providerSessionId; - if (extracted === undefined) { + const candidates = structuredCandidates(result); + if (candidates.length === 0) { lastErrors = "Response was not valid JSON"; - input.onRetry?.({ attempt: attempt + 1, errors: lastErrors, mode }); + if (attempt < maxRetries) { + input.onRetry?.({ attempt: attempt + 1, errors: lastErrors, mode }); + } continue; } - const ok = validate(extracted); - if (ok) { - return { - value: extracted, - finalResponse: result.finalResponse, - providerSessionId: result.providerSessionId, - attempts: attempt + 1, - mode, + for (const candidate of candidates) { + const ok = validate(candidate); + if (ok) { + return { + value: candidate, + finalResponse: result.finalResponse, + providerSessionId, + attempts: attempt + 1, + mode, + }; }; } lastErrors = formatAjvErrors(validate.errors); - input.onRetry?.({ attempt: attempt + 1, errors: lastErrors, mode }); + if (attempt < maxRetries) { + input.onRetry?.({ attempt: attempt + 1, errors: lastErrors, mode }); + } } throw new WorkflowEngineError( @@ -149,11 +167,26 @@ export function schemaAwareRunProvider( prompt: base.prompt, provider: base.provider, onRetry, - run: (prompt) => + run: (prompt, options) => runProvider({ ...base, prompt, - schema, + providerSessionId: options.providerSessionId, + ...(options.mode === "native" ? { schema } : {}), }), }); } + +function structuredCandidates(result: WorkflowProviderRunResult): unknown[] { + const candidates: unknown[] = []; + if (result.structured !== undefined) { + candidates.push(result.structured); + if (typeof result.structured === "string") { + const parsed = tryExtractJson(result.structured); + if (parsed !== undefined && parsed !== result.structured) candidates.push(parsed); + } + } + const fromText = tryExtractJson(result.finalResponse); + if (fromText !== undefined) candidates.push(fromText); + return candidates; +} From 3343404fdccbdf4e979b8388f7a4dc6708e604db Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 22 Jul 2026 00:22:17 +0530 Subject: [PATCH 020/132] test(workflow): normalize persisted paths on windows --- src/workflow-files.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/workflow-files.test.ts b/src/workflow-files.test.ts index 40fd4e6b2..e15c89bfa 100644 --- a/src/workflow-files.test.ts +++ b/src/workflow-files.test.ts @@ -32,7 +32,7 @@ import { hashSource } from "./workflow-script.js"; source: "export const meta = { name: 'x', description: 'd' }\nreturn 1\n", preferredName: "demo", }); - assert.match(path, /workflow-scripts\/wfr_test\/demo\.js$/); + assert.match(path.replaceAll("\\", "/"), /workflow-scripts\/wfr_test\/demo\.js$/); const file = await resolveWorkflowScriptFromPathOrName({ file: path, From 4cb4007441fb9e54541d3eca33f4124101efd8e3 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 22 Jul 2026 00:34:08 +0530 Subject: [PATCH 021/132] refactor(workflow): strengthen contracts and zod boundaries --- package-lock.json | 6 +- package.json | 3 +- src/json-types.ts | 42 ++++++ src/local-agent-adapters.test.ts | 2 +- src/local-agent-adapters.ts | 10 +- src/local-agent-capabilities.ts | 51 +++++++ src/local-agent-profiles.ts | 8 +- src/local-agent-runtime.ts | 8 +- src/workflow-api.ts | 88 +++++------ src/workflow-cli.ts | 25 +++- src/workflow-contracts.test.ts | 126 ++++++++++++++++ src/workflow-contracts.ts | 247 +++++++++++++++++++++++++++++++ src/workflow-engine.ts | 18 +-- src/workflow-files.ts | 9 +- src/workflow-replay.ts | 3 +- src/workflow-sandbox.test.ts | 10 +- src/workflow-sandbox.ts | 22 ++- src/workflow-schema.test.ts | 8 +- src/workflow-schema.ts | 39 +++-- src/workflow-script.test.ts | 8 +- src/workflow-script.ts | 97 ++---------- src/workflow-store.test.ts | 9 +- src/workflow-store.ts | 31 ++-- src/workflow-tools.ts | 9 +- src/workflow-types.ts | 129 +++++----------- 25 files changed, 687 insertions(+), 321 deletions(-) create mode 100644 src/json-types.ts create mode 100644 src/local-agent-capabilities.ts create mode 100644 src/workflow-contracts.test.ts create mode 100644 src/workflow-contracts.ts diff --git a/package-lock.json b/package-lock.json index 2029860ad..39efe57c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "diff": "^8.0.3", "drizzle-orm": "^0.45.2", "express": "^5.2.1", + "json-schema-to-ts": "^3.1.1", "lucide": "^1.24.0", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -217,7 +218,6 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -4513,7 +4513,6 @@ "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" @@ -5841,8 +5840,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tslib": { "version": "2.8.1", diff --git a/package.json b/package.json index f43b5dffb..07f242f6e 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], @@ -49,6 +49,7 @@ "diff": "^8.0.3", "drizzle-orm": "^0.45.2", "express": "^5.2.1", + "json-schema-to-ts": "^3.1.1", "lucide": "^1.24.0", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/src/json-types.ts b/src/json-types.ts new file mode 100644 index 000000000..a701edd41 --- /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-adapters.test.ts b/src/local-agent-adapters.test.ts index 4438f6cc3..93cf4fc04 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -397,7 +397,7 @@ assert.equal( type: "object", properties: { n: { type: "number" } }, required: ["n"], - }; + } as const; assert.deepEqual(claudeOutputFormatOptions(schema), { outputFormat: { type: "json_schema", schema }, }); diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index fe56e81dd..d452c1bab 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -1,7 +1,11 @@ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; import { resolve } from "node:path"; import { Readable, Writable } from "node:stream"; -import type { EffortLevel } from "@anthropic-ai/claude-agent-sdk"; +import type { + EffortLevel, + OutputFormat, +} from "@anthropic-ai/claude-agent-sdk"; +import type { JsonSchema } from "./json-types.js"; import type { LocalAgentProvider } from "./local-agent-profiles.js"; import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; import { @@ -116,8 +120,8 @@ class ClaudeLocalAgentAdapter implements LocalAgentAdapter { /** Build Claude SDK outputFormat when a JSON Schema is requested. */ export function claudeOutputFormatOptions( - schema: object | undefined, -): { outputFormat: { type: "json_schema"; schema: Record } } | Record { + schema: JsonSchema | undefined, +): { outputFormat: OutputFormat } | Record { if (!schema) return {}; return { outputFormat: { diff --git a/src/local-agent-capabilities.ts b/src/local-agent-capabilities.ts new file mode 100644 index 000000000..f9a5f34af --- /dev/null +++ b/src/local-agent-capabilities.ts @@ -0,0 +1,51 @@ +import type { LocalAgentProvider } from "./local-agent-profiles.js"; + +export interface LocalAgentProviderCapabilities { + structuredOutput: "native" | "prompt"; + resumableSessions: boolean; + cancellation: "signal" | "process"; + supportsWorkspaceIsolation: boolean; +} + +export const LOCAL_AGENT_PROVIDER_CAPABILITIES = { + codex: { + structuredOutput: "native", + resumableSessions: true, + cancellation: "signal", + supportsWorkspaceIsolation: true, + }, + claude: { + structuredOutput: "native", + resumableSessions: true, + cancellation: "signal", + supportsWorkspaceIsolation: true, + }, + opencode: { + structuredOutput: "prompt", + resumableSessions: true, + cancellation: "process", + supportsWorkspaceIsolation: true, + }, + pi: { + structuredOutput: "prompt", + resumableSessions: true, + cancellation: "process", + supportsWorkspaceIsolation: true, + }, + cursor: { + structuredOutput: "prompt", + resumableSessions: true, + cancellation: "process", + supportsWorkspaceIsolation: true, + }, + copilot: { + structuredOutput: "prompt", + resumableSessions: true, + cancellation: "process", + supportsWorkspaceIsolation: true, + }, +} as const satisfies Record; + +export function supportsNativeStructuredOutput(provider: LocalAgentProvider): boolean { + return LOCAL_AGENT_PROVIDER_CAPABILITIES[provider].structuredOutput === "native"; +} diff --git a/src/local-agent-profiles.ts b/src/local-agent-profiles.ts index f29316dd4..9cf846f68 100644 --- a/src/local-agent-profiles.ts +++ b/src/local-agent-profiles.ts @@ -4,16 +4,16 @@ import { basename, join, resolve } from "node:path"; import { parse as parseYaml } from "yaml"; import type { ServerConfig } from "./config.js"; -export type LocalAgentProvider = "codex" | "claude" | "opencode" | "pi" | "cursor" | "copilot"; - -export const LOCAL_AGENT_PROVIDERS: readonly LocalAgentProvider[] = [ +export const LOCAL_AGENT_PROVIDERS = [ "codex", "claude", "opencode", "pi", "cursor", "copilot", -]; +] as const; + +export type LocalAgentProvider = (typeof LOCAL_AGENT_PROVIDERS)[number]; export interface LocalAgentProfile { name: string; diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index 5690e4696..0f3896eda 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -7,6 +7,8 @@ import type { ThreadOptions, TurnOptions, } from "@openai/codex-sdk"; +import type { JsonSchema } from "./json-types.js"; +import type { LocalAgentProvider } from "./local-agent-profiles.js"; export type LocalAgentWriteMode = "read_only" | "allowed" | "full_access"; @@ -19,11 +21,11 @@ export interface LocalAgentRunInput { /** Provider-native effort / reasoning level (was thinking). */ effort?: string; /** JSON Schema for native structured output (codex/claude). */ - schema?: object; + schema?: JsonSchema; } export interface LocalAgentRunResult { - provider: string; + provider: LocalAgentProvider; providerSessionId: string | null; finalResponse: string; items: unknown[]; @@ -32,7 +34,7 @@ export interface LocalAgentRunResult { } export interface LocalAgentRuntime { - readonly provider: string; + readonly provider: LocalAgentProvider; run(input: LocalAgentRunInput): Promise; } diff --git a/src/workflow-api.ts b/src/workflow-api.ts index 6e0eddafb..b1b09c933 100644 --- a/src/workflow-api.ts +++ b/src/workflow-api.ts @@ -1,6 +1,9 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; import type { WorkflowSandboxApi } from "./workflow-sandbox.js"; +import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import type { JsonSchema, JsonValue } from "./json-types.js"; +import { jsonValueSchema } from "./json-types.js"; import { WORKFLOW_LIMITS, WORKFLOW_MAX_ITEMS, @@ -9,15 +12,17 @@ import { createStubBudget, type AgentIsolationMode, type AgentOpts, + type AppendWorkflowEventInput, type WorkflowMeta, } from "./workflow-types.js"; +import { agentOptsSchema } from "./workflow-contracts.js"; // --------------------------------------------------------------------------- // Host deps (injected by engine; fakes OK in tests) // --------------------------------------------------------------------------- export interface WorkflowProviderRunInput { - provider: string; + provider: LocalAgentProvider; prompt: string; providerSessionId?: string; model?: string; @@ -27,7 +32,7 @@ export interface WorkflowProviderRunInput { label?: string; phase?: string; /** JSON Schema for native structured output (codex/claude). */ - schema?: object; + schema?: JsonSchema; } export interface WorkflowProviderRunResult { @@ -55,7 +60,7 @@ export type CreateAgentWorktree = (input: { }) => Promise; export interface WorkflowReplayHit { - value: unknown; + value: JsonValue; responseText?: string; structuredJson?: string; providerSessionId?: string; @@ -66,27 +71,14 @@ export interface WorkflowReplay { } export interface WorkflowJournal { - appendEvent(input: { - runId: string; - type: - | "phase_started" - | "log" - | "agent_call_started" - | "agent_call_completed" - | "agent_call_failed" - | "agent_call_cached" - | "schema_retry" - | "worktree_created" - | "worktree_finalized"; - phase?: string; - label?: string; - data?: unknown; - }): unknown; + appendEvent( + input: Extract, + ): unknown; beginAgentCall(input: { runId: string; callIndex: number; cacheKey: string; - provider: string; + provider: LocalAgentProvider; model?: string; effort?: string; label?: string; @@ -118,13 +110,13 @@ export interface WorkflowApiDeps { runId: string; journal: WorkflowJournal; meta: WorkflowMeta; - args: unknown; + args: JsonValue | undefined; concurrency: number; signal: AbortSignal; workspaceRoot: string; baseSha?: string; /** Already-filtered enabled ∩ live provider ids, preference order. */ - enabledProviders: string[]; + enabledProviders: LocalAgentProvider[]; runProvider: WorkflowRunProvider; createWorktree?: CreateAgentWorktree; replay?: WorkflowReplay; @@ -133,7 +125,7 @@ export interface WorkflowApiDeps { /** Run a nested script sharing semaphore/callIndex. */ executeNested?: (input: { source: string; - args: unknown; + args: JsonValue | undefined; nestDepth: number; }) => Promise; nestDepth?: number; @@ -558,11 +550,17 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { throw new WorkflowEngineError("internal", "nested workflow() is not configured on this host"); } const nameOrRef = args[0] as string | { scriptPath: string }; - const childArgs = args[1]; + 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: childArgs, + args: childArgsResult.data, nestDepth: nestDepth + 1, }); }; @@ -592,10 +590,10 @@ export function hashCacheKey(input: ReturnType): } export function resolveProvider( - optsProvider: string | undefined, + optsProvider: LocalAgentProvider | undefined, meta: WorkflowMeta, - enabledProviders: string[], -): string { + enabledProviders: LocalAgentProvider[], +): LocalAgentProvider { if (optsProvider) { if (!enabledProviders.includes(optsProvider)) { throw new WorkflowEngineError( @@ -623,32 +621,18 @@ export function resolveProvider( function normalizeAgentOpts(opts: unknown): AgentOpts { if (opts === undefined || opts === null) return {}; - if (typeof opts !== "object" || Array.isArray(opts)) { - throw new WorkflowEngineError("internal", "agent opts must be an object"); - } - const record = opts as Record; - const out: AgentOpts = {}; - if (typeof record.label === "string") out.label = record.label; - if (typeof record.phase === "string") out.phase = record.phase; - if (record.schema !== undefined) { - if (!record.schema || typeof record.schema !== "object" || Array.isArray(record.schema)) { - throw new WorkflowEngineError("schema", "agent opts.schema must be an object"); - } - out.schema = record.schema as object; - } - if (typeof record.model === "string") out.model = record.model; - if (typeof record.effort === "string") out.effort = record.effort; - if (typeof record.provider === "string") out.provider = record.provider; - if (record.isolation !== undefined) { - if (record.isolation !== "worktree") { - throw new WorkflowEngineError("worktree", 'agent opts.isolation must be "worktree" when set'); - } - out.isolation = "worktree"; - } - if ("writeMode" in record) { + if (typeof opts === "object" && opts !== null && "writeMode" in opts) { throw new WorkflowEngineError("internal", "writeMode is not supported on agent() (v1)"); } - return out; + 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" : "internal"; + throw new WorkflowEngineError( + kind, + `Invalid agent ${path}: ${issue?.message ?? "validation failed"}`, + ); } function assertMaxItems(count: number, label: string): void { diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts index e1c459c07..68d9f9170 100644 --- a/src/workflow-cli.ts +++ b/src/workflow-cli.ts @@ -4,11 +4,13 @@ import { availableParallelism } from "node:os"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { ServerConfig } from "./config.js"; +import { parseJsonText, type JsonObject, type JsonValue } from "./json-types.js"; import { runLocalAgentProvider } from "./local-agent-adapters.js"; import { getLocalAgentProviderAvailabilitySnapshot } from "./local-agent-availability.js"; import { isLocalAgentProvider, LOCAL_AGENT_PROVIDERS, + type LocalAgentProvider, } from "./local-agent-profiles.js"; import { executeWorkflow, mapEngineErrorKind } from "./workflow-engine.js"; import { @@ -30,6 +32,7 @@ import { type WorkflowRunRecord, type WorkflowRunSource, } from "./workflow-types.js"; +import { parseWorkflowEventPayload } from "./workflow-contracts.js"; import { createWorkflowWorktreeFactory, resolveWorkspaceHead, @@ -119,7 +122,8 @@ async function runWorkflowRun(args: string[], config: ServerConfig): Promise row.available).map((row) => row.name)); if (!agentProviders) { return LOCAL_AGENT_PROVIDERS.filter((id) => live.has(id)); } - return agentProviders.enabled.filter((id) => live.has(id as never)); + return agentProviders.enabled.filter((id) => live.has(id)); } function splitFlags(args: string[]): { @@ -542,3 +547,7 @@ function collectArgTokens(args: string[]): string[] { function sleep(ms: number): Promise { return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); } + +function isJsonObject(value: JsonValue): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/workflow-contracts.test.ts b/src/workflow-contracts.test.ts new file mode 100644 index 000000000..417035b49 --- /dev/null +++ b/src/workflow-contracts.test.ts @@ -0,0 +1,126 @@ +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_PROVIDER_CAPABILITIES, +} from "./local-agent-capabilities.js"; +import { LOCAL_AGENT_PROVIDERS } from "./local-agent-profiles.js"; + +assert.deepEqual(localAgentProviderSchema.options, LOCAL_AGENT_PROVIDERS); +assert.deepEqual( + Object.keys(LOCAL_AGENT_PROVIDER_CAPABILITIES).sort(), + [...LOCAL_AGENT_PROVIDERS].sort(), +); + +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({ 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" }); + + 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 000000000..bf43d58e7 --- /dev/null +++ b/src/workflow-contracts.ts @@ -0,0 +1,247 @@ +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 workflowMetaSchema = z + .object({ + name: z.string().trim().min(1).regex(/^[a-z0-9-]+$/), + description: z.string().trim().min(1), + phases: z + .array( + z + .object({ + title: z.string().trim().min(1), + detail: z.string().trim().min(1).optional(), + }) + .strict(), + ) + .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", "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(), + provider: localAgentProviderSchema.optional(), + isolation: z.literal("worktree").optional(), + }) + .strict(); + +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_disabled", + "provider_unavailable", + "no_provider", + "provider", + "schema", + "cancelled", + "timeout", + "heartbeat", + "worktree", + "nest_depth", + "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(), + isolation: agentIsolationModeSchema, + worktreePath: z.string().optional(), + }) + .strict(), + agent_call_cached: z + .object({ + callIndex: z.number().int().nonnegative(), + cacheKey: z.string(), + provider: localAgentProviderSchema, + }) + .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.ts b/src/workflow-engine.ts index 856dc292b..ff2a47268 100644 --- a/src/workflow-engine.ts +++ b/src/workflow-engine.ts @@ -1,4 +1,6 @@ import { availableParallelism } from "node:os"; +import type { 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 { @@ -23,21 +25,13 @@ export interface ExecuteWorkflowOptions { source?: string; filename?: string; runId: string; - journal: WorkflowJournal & { - appendEvent(input: { - runId: string; - type: string; - phase?: string; - label?: string; - data?: unknown; - }): unknown; - }; - args?: unknown; + journal: WorkflowJournal; + args?: JsonValue; concurrency?: number; signal?: AbortSignal; workspaceRoot: string; baseSha?: string; - enabledProviders: string[]; + enabledProviders: LocalAgentProvider[]; runProvider: WorkflowRunProvider; createWorktree?: CreateAgentWorktree; replay?: WorkflowReplay; @@ -143,7 +137,7 @@ async function executeNestedOnApi(input: { parentOptions: ExecuteWorkflowOptions; parentApi: WorkflowApi; source: string; - args: unknown; + args: JsonValue | undefined; nestDepth: number; }): Promise { if (input.nestDepth > WORKFLOW_MAX_NEST_DEPTH_LOCAL) { diff --git a/src/workflow-files.ts b/src/workflow-files.ts index 12b122288..7615980ff 100644 --- a/src/workflow-files.ts +++ b/src/workflow-files.ts @@ -2,6 +2,7 @@ import { createHash, randomBytes } from "node:crypto"; import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path"; import { hashSource } from "./workflow-script.js"; +import { jsonValueSchema, type JsonValue } from "./json-types.js"; export class WorkflowPathError extends Error { constructor(message: string) { @@ -120,10 +121,10 @@ export async function resolveWorkflowScriptFromPathOrName(input: { } export function parseWorkflowArgFlags(tokens: string[]): { - args: Record; + args: Record; rest: string[]; } { - const args: Record = {}; + const args: Record = {}; const rest: string[] = []; for (let i = 0; i < tokens.length; i += 1) { const token = tokens[i]!; @@ -150,9 +151,9 @@ export function parseWorkflowArgFlags(tokens: string[]): { return { args, rest }; } -function coerceArgValue(raw: string): unknown { +function coerceArgValue(raw: string): JsonValue { try { - return JSON.parse(raw); + return jsonValueSchema.parse(JSON.parse(raw) as unknown); } catch { return raw; } diff --git a/src/workflow-replay.ts b/src/workflow-replay.ts index bff7b935c..3038d1b0b 100644 --- a/src/workflow-replay.ts +++ b/src/workflow-replay.ts @@ -1,5 +1,6 @@ import type { WorkflowAgentCallRecord } from "./workflow-types.js"; import type { WorkflowReplay, WorkflowReplayHit } from "./workflow-api.js"; +import { parseJsonText } from "./json-types.js"; /** * Resume matcher: @@ -63,7 +64,7 @@ function toHit(call: WorkflowAgentCallRecord): WorkflowReplayHit { if (call.structuredJson) { try { return { - value: JSON.parse(call.structuredJson), + value: parseJsonText(call.structuredJson), responseText: call.responseText, structuredJson: call.structuredJson, providerSessionId: call.providerSessionId, diff --git a/src/workflow-sandbox.test.ts b/src/workflow-sandbox.test.ts index 9620cd1d9..7805aa75b 100644 --- a/src/workflow-sandbox.test.ts +++ b/src/workflow-sandbox.test.ts @@ -1,9 +1,13 @@ import assert from "node:assert/strict"; import { parseWorkflowScript } from "./workflow-script.js"; -import { createStubBudget, type WorkflowMeta } from "./workflow-types.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[]) { +function api(meta: WorkflowMeta, logs?: string[]): WorkflowSandboxApi { return { agent: async () => "", parallel: async () => [], @@ -16,7 +20,7 @@ function api(meta: WorkflowMeta, logs?: string[]) { budget: createStubBudget(), workflow: async () => null, meta, - }; + } as unknown as WorkflowSandboxApi; } { diff --git a/src/workflow-sandbox.ts b/src/workflow-sandbox.ts index f05a17f1f..656e128c6 100644 --- a/src/workflow-sandbox.ts +++ b/src/workflow-sandbox.ts @@ -1,6 +1,14 @@ import vm from "node:vm"; import type { ParsedWorkflowScript } from "./workflow-script.js"; -import type { WorkflowBudget, WorkflowMeta } from "./workflow-types.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) { @@ -10,14 +18,14 @@ export class WorkflowDeterminismError extends Error { } export interface WorkflowSandboxApi { - agent: (...args: unknown[]) => unknown; - parallel: (...args: unknown[]) => unknown; - pipeline: (...args: unknown[]) => unknown; - phase: (...args: unknown[]) => unknown; + agent: WorkflowAgent; + parallel: WorkflowParallel; + pipeline: WorkflowPipeline; + phase: (title: string) => void; log: (...args: unknown[]) => unknown; - args: unknown; + args: JsonValue | undefined; budget: WorkflowBudget; - workflow: (...args: unknown[]) => unknown; + workflow: WorkflowNested; /** Host bookkeeping only; script binds its own `const meta`. */ meta: WorkflowMeta; } diff --git a/src/workflow-schema.test.ts b/src/workflow-schema.test.ts index 2084f5137..8e92ffcdf 100644 --- a/src/workflow-schema.test.ts +++ b/src/workflow-schema.test.ts @@ -3,10 +3,10 @@ import { augmentPromptForSchema, enforceAgentSchema, formatAjvErrors, - NATIVE_SCHEMA_PROVIDERS, } from "./workflow-schema.js"; import { WorkflowEngineError } from "./workflow-api.js"; import { ProviderSchemaUnsupportedError } from "./local-agent-runtime.js"; +import { supportsNativeStructuredOutput } from "./local-agent-capabilities.js"; { const prompt = augmentPromptForSchema("find bugs", { @@ -23,9 +23,9 @@ assert.equal( "/n must be number", ); -assert.ok(NATIVE_SCHEMA_PROVIDERS.has("codex")); -assert.ok(NATIVE_SCHEMA_PROVIDERS.has("claude")); -assert.ok(!NATIVE_SCHEMA_PROVIDERS.has("opencode")); +assert.ok(supportsNativeStructuredOutput("codex")); +assert.ok(supportsNativeStructuredOutput("claude")); +assert.ok(!supportsNativeStructuredOutput("opencode")); { let attempts = 0; diff --git a/src/workflow-schema.ts b/src/workflow-schema.ts index 1d93fe5f5..b5234b45a 100644 --- a/src/workflow-schema.ts +++ b/src/workflow-schema.ts @@ -3,14 +3,18 @@ import { WORKFLOW_MAX_SCHEMA_RETRIES } from "./workflow-types.js"; import { tryExtractJson, WorkflowEngineError } from "./workflow-api.js"; import type { WorkflowProviderRunResult, WorkflowRunProvider } from "./workflow-api.js"; import { isProviderSchemaUnsupportedError } from "./local-agent-runtime.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"; const require = createRequire(import.meta.url); -/** Providers with a real structured-output API (hardcoded — no capability probe). */ -export const NATIVE_SCHEMA_PROVIDERS = new Set(["codex", "claude"]); - type AjvLike = new (opts?: object) => { - compile: (schema: object) => ((data: unknown) => boolean) & { + compile: (schema: JsonSchema) => ((data: unknown) => boolean) & { errors?: Array<{ instancePath?: string; message?: string }> | null; }; }; @@ -30,13 +34,13 @@ function loadAjv(): AjvLike { export type SchemaEnforceMode = "native" | "prompt"; export interface EnforceSchemaInput { - schema: object; + 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?: string; + provider?: LocalAgentProvider; run: ( prompt: string, opts: { @@ -53,7 +57,7 @@ export interface EnforceSchemaInput { } export interface EnforceSchemaResult { - value: unknown; + value: JsonValue; finalResponse: string; providerSessionId?: string; attempts: number; @@ -71,7 +75,7 @@ export async function enforceAgentSchema( const ajv = new Ajv({ allErrors: true, strict: false }); const validate = ajv.compile(input.schema); const maxRetries = input.maxRetries ?? WORKFLOW_MAX_SCHEMA_RETRIES; - const native = Boolean(input.provider && NATIVE_SCHEMA_PROVIDERS.has(input.provider)); + const native = Boolean(input.provider && supportsNativeStructuredOutput(input.provider)); const basePrompt = augmentPromptForSchema(input.prompt, input.schema); let lastErrors = "unknown validation error"; @@ -134,7 +138,7 @@ export async function enforceAgentSchema( ); } -export function augmentPromptForSchema(prompt: string, schema: object): string { +export function augmentPromptForSchema(prompt: string, schema: JsonSchema): string { return [ prompt, "", @@ -158,7 +162,7 @@ export function formatAjvErrors( /** Helper for wiring into agent(): wrap a one-shot provider as retrying schema runner. */ export function schemaAwareRunProvider( runProvider: WorkflowRunProvider, - schema: object, + schema: JsonSchema, base: Parameters[0], onRetry?: EnforceSchemaInput["onRetry"], ): Promise { @@ -177,16 +181,21 @@ export function schemaAwareRunProvider( }); } -function structuredCandidates(result: WorkflowProviderRunResult): unknown[] { - const candidates: unknown[] = []; +function structuredCandidates(result: WorkflowProviderRunResult): JsonValue[] { + const candidates: JsonValue[] = []; if (result.structured !== undefined) { - candidates.push(result.structured); + const structured = jsonValueSchema.safeParse(result.structured); + if (structured.success) candidates.push(structured.data); if (typeof result.structured === "string") { const parsed = tryExtractJson(result.structured); - if (parsed !== undefined && parsed !== result.structured) candidates.push(parsed); + const parsedJson = jsonValueSchema.safeParse(parsed); + if (parsedJson.success && parsedJson.data !== result.structured) { + candidates.push(parsedJson.data); + } } } const fromText = tryExtractJson(result.finalResponse); - if (fromText !== undefined) candidates.push(fromText); + 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 index c8e614238..d64c5cfcf 100644 --- a/src/workflow-script.test.ts +++ b/src/workflow-script.test.ts @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import { parseWorkflowScript, WorkflowScriptError } from "./workflow-script.js"; import { createStubBudget } from "./workflow-types.js"; -import { runWorkflowSandbox, WorkflowDeterminismError } from "./workflow-sandbox.js"; +import { + runWorkflowSandbox, + WorkflowDeterminismError, + type WorkflowSandboxApi, +} from "./workflow-sandbox.js"; { const parsed = parseWorkflowScript(` @@ -91,7 +95,7 @@ async function runBody(source: string): Promise { budget: createStubBudget(), workflow: async () => null, meta: parsed.meta, - }, + } as WorkflowSandboxApi, }); } diff --git a/src/workflow-script.ts b/src/workflow-script.ts index 5847f08a2..cf949568b 100644 --- a/src/workflow-script.ts +++ b/src/workflow-script.ts @@ -1,11 +1,7 @@ import { createHash } from "node:crypto"; import vm from "node:vm"; -import { - LOCAL_AGENT_PROVIDERS, - type LocalAgentProvider, - isLocalAgentProvider, -} from "./local-agent-profiles.js"; import { WORKFLOW_LIMITS, type WorkflowMeta } from "./workflow-types.js"; +import { workflowMetaSchema } from "./workflow-contracts.js"; export class WorkflowScriptError extends Error { constructor( @@ -28,7 +24,6 @@ export interface ParsedWorkflowScript { } const META_EXPORT = /export\s+const\s+meta\s*=/; -const NAME_RE = /^[a-z0-9-]+$/; /** * Parse + compile a workflow script. @@ -189,87 +184,21 @@ function evaluateMetaLiteral(literal: string, filename: string): unknown { } function validateMeta(value: unknown): WorkflowMeta { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new WorkflowScriptError("meta", "meta must be an object"); - } - const record = value as Record; - const name = readRequiredString(record, "name"); - if (!NAME_RE.test(name)) { - throw new WorkflowScriptError( - "meta", - `meta.name must match ${NAME_RE} (got ${JSON.stringify(name)})`, - ); - } - const description = readRequiredString(record, "description"); - - let phases: WorkflowMeta["phases"]; - if (record.phases !== undefined) { - if (!Array.isArray(record.phases)) { - throw new WorkflowScriptError("meta", "meta.phases must be an array"); - } - const hostPhases: NonNullable = []; - for (let index = 0; index < record.phases.length; index += 1) { - const phase = record.phases[index]; - if (!phase || typeof phase !== "object" || Array.isArray(phase)) { - throw new WorkflowScriptError("meta", `meta.phases[${index}] must be an object`); - } - const p = phase as Record; - const title = readRequiredString(p, "title", `meta.phases[${index}].title`); - const detail = optionalString(p.detail); - hostPhases.push(detail === undefined ? { title } : { title, detail }); - } - phases = hostPhases; - } - - const whenToUse = optionalString(record.whenToUse); - let defaultProvider: LocalAgentProvider | undefined; - if (record.defaultProvider !== undefined) { - if (typeof record.defaultProvider !== "string" || !isLocalAgentProvider(record.defaultProvider)) { - throw new WorkflowScriptError( - "meta", - `meta.defaultProvider must be one of: ${LOCAL_AGENT_PROVIDERS.join(", ")}`, - ); - } - defaultProvider = record.defaultProvider; - } + const parsed = workflowMetaSchema.safeParse(value); + if (parsed.success) return parsed.data; - let concurrency: number | undefined; - if (record.concurrency !== undefined) { - if (typeof record.concurrency !== "number" || !Number.isFinite(record.concurrency)) { - throw new WorkflowScriptError("meta", "meta.concurrency must be a number"); - } - concurrency = Math.floor(record.concurrency); - if (concurrency < 1) { - throw new WorkflowScriptError("meta", "meta.concurrency must be >= 1"); - } + 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`); } - - return { - name, - description, - ...(phases ? { phases } : {}), - ...(whenToUse ? { whenToUse } : {}), - ...(defaultProvider ? { defaultProvider } : {}), - ...(concurrency !== undefined ? { concurrency } : {}), - }; -} - -function readRequiredString( - record: Record, - key: string, - label = `meta.${key}`, -): string { - const value = record[key]; - if (typeof value !== "string" || !value.trim()) { - throw new WorkflowScriptError("meta", `${label} is required`); + if (issue?.code === "invalid_format" && issue.format === "regex") { + throw new WorkflowScriptError("meta", `${path} must match /^[a-z0-9-]+$/`); } - return value.trim(); -} - -function optionalString(value: unknown): string | undefined { - if (typeof value !== "string") return undefined; - const trimmed = value.trim(); - return trimmed || undefined; + throw new WorkflowScriptError( + "meta", + `${path}: ${issue?.message ?? "validation failed"}`, + ); } function parseErrorLine(message: string): number | undefined { diff --git a/src/workflow-store.test.ts b/src/workflow-store.test.ts index f436a5ca5..314ab2895 100644 --- a/src/workflow-store.test.ts +++ b/src/workflow-store.test.ts @@ -36,12 +36,17 @@ try { store.setHeartbeat(run.id); assert.ok(store.getRun(run.id)?.heartbeatAt); - const e1 = store.appendEvent({ runId: run.id, type: "run_started", data: { ok: true } }); + 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); @@ -147,7 +152,7 @@ try { workspaceRoot: join(root, "project"), }); const seqs = [0, 1, 2, 3, 4].map(() => - store.appendEvent({ runId: run4.id, type: "log", data: { n: 1 } }).seq, + store.appendEvent({ runId: run4.id, type: "log", data: { message: "1" } }).seq, ); assert.deepEqual(seqs, [1, 2, 3, 4, 5]); diff --git a/src/workflow-store.ts b/src/workflow-store.ts index 4e57e184e..90d866c30 100644 --- a/src/workflow-store.ts +++ b/src/workflow-store.ts @@ -5,15 +5,23 @@ import type { ServerConfig } from "./config.js"; import { WORKFLOW_LIMITS, type AgentIsolationMode, + type AppendWorkflowEventInput, type WorkflowAgentCallRecord, type WorkflowAgentCallStatus, type WorkflowErrorKind, type WorkflowEventRecord, - type WorkflowEventType, type WorkflowRunRecord, type WorkflowRunSource, type WorkflowRunStatus, } from "./workflow-types.js"; +import { + localAgentProviderSchema, + parseWorkflowEventPayload, + workflowAgentCallStatusSchema, + workflowEventTypeSchema, + workflowRunSourceSchema, + workflowRunStatusSchema, +} from "./workflow-contracts.js"; export interface CreateWorkflowRunInput { name: string; @@ -27,14 +35,6 @@ export interface CreateWorkflowRunInput { baseSha?: string; } -export interface AppendWorkflowEventInput { - runId: string; - type: WorkflowEventType; - phase?: string; - label?: string; - data?: unknown; -} - export interface BeginAgentCallInput { runId: string; callIndex: number; @@ -338,7 +338,8 @@ export class WorkflowStore { } appendEvent(input: AppendWorkflowEventInput): WorkflowEventRecord { - const dataJson = truncateJson(input.data ?? {}, WORKFLOW_LIMITS.eventDataJsonBytes); + const payload = parseWorkflowEventPayload(input.type, input.data); + const dataJson = truncateJson(payload, WORKFLOW_LIMITS.eventDataJsonBytes); const createdAt = isoNow(); const insert = this.database.sqlite.transaction(() => { @@ -562,13 +563,13 @@ function rowToRun(row: WorkflowRunRow): WorkflowRunRecord { return { id: row.id, name: row.name, - source: row.source as WorkflowRunSource, + 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, - status: row.status as WorkflowRunStatus, + status: workflowRunStatusSchema.parse(row.status), error: row.error ?? undefined, errorKind: (row.error_kind as WorkflowErrorKind | null) ?? undefined, resultJson: row.result_json ?? undefined, @@ -588,7 +589,7 @@ function rowToEvent(row: WorkflowEventRow): WorkflowEventRecord { return { runId: row.run_id, seq: row.seq, - type: row.type as WorkflowEventType, + type: workflowEventTypeSchema.parse(row.type), phase: row.phase ?? undefined, label: row.label ?? undefined, dataJson: row.data_json, @@ -601,12 +602,12 @@ function rowToAgentCall(row: WorkflowAgentCallRow): WorkflowAgentCallRecord { runId: row.run_id, callIndex: row.call_index, cacheKey: row.cache_key, - provider: row.provider, + provider: localAgentProviderSchema.parse(row.provider), model: row.model ?? undefined, effort: row.effort ?? undefined, label: row.label ?? undefined, phase: row.phase ?? undefined, - status: row.status as WorkflowAgentCallStatus, + status: workflowAgentCallStatusSchema.parse(row.status), fromCache: row.from_cache === "true", providerSessionId: row.provider_session_id ?? undefined, responseText: row.response_text ?? undefined, diff --git a/src/workflow-tools.ts b/src/workflow-tools.ts index 8551701f0..faefce755 100644 --- a/src/workflow-tools.ts +++ b/src/workflow-tools.ts @@ -3,6 +3,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; import * as z from "zod/v4"; import type { ServerConfig } from "./config.js"; +import { jsonValueSchema, parseJsonText, type JsonValue } from "./json-types.js"; import type { WorkspaceRegistry } from "./workspaces.js"; import { persistWorkflowScript, @@ -62,7 +63,7 @@ export function registerWorkflowTools( .describe("Inline workflow script source (export const meta = …)."), name: z.string().optional().describe("Named workflow under .devspace/workflows/.js"), resumeFromRunId: z.string().optional().describe("Prior run id to resume (new run + cache)."), - args: z.unknown().optional().describe("Args object/array passed to script as `args`."), + args: jsonValueSchema.optional().describe("JSON args passed to script as `args`."), yieldTimeMs: z .number() .int() @@ -102,7 +103,7 @@ export function registerWorkflowTools( runSource = "resume"; if (args === undefined && prior.argsJson && prior.argsJson !== "null") { try { - args = JSON.parse(prior.argsJson); + args = parseJsonText(prior.argsJson); } catch { // keep undefined } @@ -288,9 +289,9 @@ function toolResult(page: { }; } -function safeJson(text: string): unknown { +function safeJson(text: string): JsonValue { try { - return JSON.parse(text); + return parseJsonText(text); } catch { return text; } diff --git a/src/workflow-types.ts b/src/workflow-types.ts index 11b14193e..ad9b9d8a0 100644 --- a/src/workflow-types.ts +++ b/src/workflow-types.ts @@ -10,6 +10,38 @@ */ 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 @@ -58,101 +90,14 @@ export interface AgentProvidersConfig { lastProbe?: AgentProviderProbe[]; } -// --------------------------------------------------------------------------- -// Script meta + agent opts -// --------------------------------------------------------------------------- - -export interface WorkflowPhaseMeta { - title: string; - detail?: string; -} - -export interface WorkflowMeta { - name: string; - description: string; - phases?: WorkflowPhaseMeta[]; - whenToUse?: string; - /** DevSpace extension */ - defaultProvider?: AgentProviderId; - /** DevSpace extension; clamped to engine max */ - concurrency?: number; -} - -/** - * Public agent() options. Deliberately no writeMode. - */ -export interface AgentOpts { - label?: string; - phase?: string; - schema?: object; - model?: string; - /** Provider-native effort/reasoning level (was thinking). */ - effort?: string; - provider?: AgentProviderId | string; - isolation?: "worktree"; -} - -export type AgentIsolationMode = "shared" | "worktree"; - // --------------------------------------------------------------------------- // Status / events // --------------------------------------------------------------------------- -export type WorkflowRunStatus = - | "starting" - | "running" - | "completed" - | "failed" - | "cancelled"; - -export type WorkflowAgentCallStatus = - | "running" - | "completed" - | "failed" - | "cancelled" - | "from_cache"; - -export type WorkflowEventType = - | "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"; - -export type WorkflowErrorKind = - | "syntax" - | "meta" - | "determinism" - | "provider_disabled" - | "provider_unavailable" - | "no_provider" - | "provider" - | "schema" - | "cancelled" - | "timeout" - | "heartbeat" - | "worktree" - | "nest_depth" - | "path" - | "result_too_large" - | "args_too_large" - | "script_too_large" - | "internal"; - // --------------------------------------------------------------------------- // Journal row shapes (behavioral; store maps snake_case) // --------------------------------------------------------------------------- -export type WorkflowRunSource = "inline" | "named" | "resume"; - export interface WorkflowRunRecord { id: string; name: string; @@ -192,7 +137,7 @@ export interface WorkflowAgentCallRecord { runId: string; callIndex: number; cacheKey: string; - provider: string; + provider: AgentProviderId; model?: string; effort?: string; label?: string; @@ -222,19 +167,19 @@ export interface WorkflowAgentCallRecord { */ export interface AgentCacheKeyInput { prompt: string; - provider: string; + provider: AgentProviderId; model: string | null; effort: string | null; - schema: object | null; + schema: JsonSchema | null; isolation: AgentIsolationMode; } export function buildAgentCacheKeyInput(input: { prompt: string; - provider: string; + provider: AgentProviderId; model?: string | null; effort?: string | null; - schema?: object | null; + schema?: JsonSchema | null; isolation?: AgentIsolationMode | "worktree" | null; }): AgentCacheKeyInput { const isolation: AgentIsolationMode = From d5b00f5611aa03535cfd2badc14f09c7ca42ee83 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 22 Jul 2026 00:59:27 +0530 Subject: [PATCH 022/132] refactor(workflow): model operational failures with better-result --- package-lock.json | 7 + package.json | 3 +- src/cli.ts | 6 +- src/local-agent-adapters.ts | 19 +- src/local-agent-errors.ts | 133 ++++++++++++++ src/local-agent-runtime.ts | 47 +---- src/workflow-api.ts | 32 +++- src/workflow-cli.ts | 87 +++++---- src/workflow-contracts.ts | 1 + src/workflow-engine.test.ts | 46 +++++ src/workflow-engine.ts | 7 + src/workflow-errors.test.ts | 150 +++++++++++++++ src/workflow-errors.ts | 356 ++++++++++++++++++++++++++++++++++++ src/workflow-files.ts | 190 ++++++++++++++----- src/workflow-replay.ts | 8 +- src/workflow-schema.test.ts | 3 + src/workflow-schema.ts | 149 ++++++++++++--- src/workflow-store.ts | 335 ++++++++++++++++++++++++--------- src/workflow-tools.ts | 70 +++++-- src/workflow-worktrees.ts | 170 +++++++++++------ 20 files changed, 1507 insertions(+), 312 deletions(-) create mode 100644 src/local-agent-errors.ts create mode 100644 src/workflow-errors.test.ts create mode 100644 src/workflow-errors.ts diff --git a/package-lock.json b/package-lock.json index 39efe57c8..11cd43d2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@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", "diff": "^8.0.3", "drizzle-orm": "^0.45.2", @@ -3446,6 +3447,12 @@ ], "license": "MIT" }, + "node_modules/better-result": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.10.0.tgz", + "integrity": "sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==", + "license": "MIT" + }, "node_modules/better-sqlite3": { "version": "12.10.0", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", diff --git a/package.json b/package.json index 07f242f6e..f30a7c989 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], @@ -45,6 +45,7 @@ "@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", "diff": "^8.0.3", "drizzle-orm": "^0.45.2", diff --git a/src/cli.ts b/src/cli.ts index 4789a324c..cd70843c8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -43,6 +43,10 @@ import { expandHomePath } from "./roots.js"; import { shutdownHttpServer } from "./server-shutdown.js"; import { runWorkflowCommand } from "./workflow-cli.js"; +import { + isWorkflowOperationError, + workflowCliExitCode, +} from "./workflow-errors.js"; type Command = "serve" | "init" | "doctor" | "config" | "agents" | "workflow" | "help" | "version"; const require = createRequire(import.meta.url); @@ -775,5 +779,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/local-agent-adapters.ts b/src/local-agent-adapters.ts index d452c1bab..ce91c90f0 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -1,11 +1,16 @@ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; import { resolve } from "node:path"; import { Readable, Writable } from "node:stream"; +import { Result, type Result as BetterResult } from "better-result"; import type { EffortLevel, OutputFormat, } from "@anthropic-ai/claude-agent-sdk"; import type { JsonSchema } from "./json-types.js"; +import { + classifyAgentProviderError, + type AgentProviderError, +} from "./local-agent-errors.js"; import type { LocalAgentProvider } from "./local-agent-profiles.js"; import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; import { @@ -31,7 +36,19 @@ export async function runLocalAgentProvider( provider: LocalAgentProvider, input: LocalAgentRunInput, ): Promise { - return createLocalAgentAdapter(provider).run(input); + const result = await runLocalAgentProviderResult(provider, input); + if (result.isErr()) throw result.error; + return result.value; +} + +export async function runLocalAgentProviderResult( + provider: LocalAgentProvider, + input: LocalAgentRunInput, +): Promise> { + return Result.tryPromise({ + try: () => createLocalAgentAdapter(provider).run(input), + catch: (cause) => classifyAgentProviderError(provider, cause), + }); } export function createLocalAgentAdapter(provider: LocalAgentProvider): LocalAgentAdapter { diff --git a/src/local-agent-errors.ts b/src/local-agent-errors.ts new file mode 100644 index 000000000..19058d0fa --- /dev/null +++ b/src/local-agent-errors.ts @@ -0,0 +1,133 @@ +import { TaggedError } from "better-result"; +import type { LocalAgentProvider } from "./local-agent-profiles.js"; + +export class ProviderUnavailableError extends TaggedError( + "ProviderUnavailableError", +)<{ + provider: LocalAgentProvider; + message: string; +}>() { + constructor(provider: LocalAgentProvider, message?: string) { + super({ + provider, + message: message ?? `Agent provider is unavailable: ${provider}`, + }); + } +} + +export class ProviderSchemaUnsupportedError extends TaggedError( + "ProviderSchemaUnsupportedError", +)<{ + provider: LocalAgentProvider; + cause: unknown; + message: string; +}>() { + constructor(provider: LocalAgentProvider, cause: unknown) { + super({ + provider, + cause, + message: `${provider} does not support the requested native output schema: ${errorMessage(cause)}`, + }); + } +} + +export class ProviderCancelledError extends TaggedError( + "ProviderCancelledError", +)<{ + provider: LocalAgentProvider; + cause: unknown; + message: string; +}>() { + constructor(provider: LocalAgentProvider, cause: unknown) { + super({ + provider, + cause, + message: `Agent provider was cancelled: ${provider}`, + }); + } +} + +export class ProviderExecutionError extends TaggedError( + "ProviderExecutionError", +)<{ + provider: LocalAgentProvider; + retryable: boolean; + cause: unknown; + message: string; +}>() { + constructor(input: { + provider: LocalAgentProvider; + cause: unknown; + retryable?: boolean; + }) { + super({ + provider: input.provider, + retryable: input.retryable ?? false, + cause: input.cause, + message: `${input.provider} agent execution failed: ${errorMessage(input.cause)}`, + }); + } +} + +export type AgentProviderError = + | ProviderUnavailableError + | ProviderSchemaUnsupportedError + | ProviderCancelledError + | ProviderExecutionError; + +export function isAgentProviderError(error: unknown): error is AgentProviderError { + return ( + ProviderUnavailableError.is(error) || + ProviderSchemaUnsupportedError.is(error) || + ProviderCancelledError.is(error) || + ProviderExecutionError.is(error) + ); +} + +export function isProviderSchemaUnsupportedError( + error: unknown, +): error is ProviderSchemaUnsupportedError { + return ProviderSchemaUnsupportedError.is(error); +} + +export function isNativeSchemaUnsupportedFailure(error: unknown): boolean { + const message = errorMessage(error).toLowerCase(); + const mentionsSchema = + /output[ _-]?schema/.test(message) || + /json[ _-]?schema/.test(message) || + /structured[ _-]?output/.test(message) || + /output[ _-]?format/.test(message); + const unsupported = + /not supported/.test(message) || + /unsupported/.test(message) || + /invalid (?:output|json )?schema/.test(message) || + /schema (?:is )?invalid/.test(message) || + /unknown (?:field|parameter|option)/.test(message) || + /not available/.test(message); + return mentionsSchema && unsupported; +} + +export function classifyAgentProviderError( + provider: LocalAgentProvider, + cause: unknown, +): AgentProviderError { + if (isAgentProviderError(cause)) return cause; + if (isCancellation(cause)) return new ProviderCancelledError(provider, cause); + if (isNativeSchemaUnsupportedFailure(cause)) { + return new ProviderSchemaUnsupportedError(provider, cause); + } + return new ProviderExecutionError({ provider, cause }); +} + +function isCancellation(error: unknown): boolean { + return Boolean( + error && + typeof error === "object" && + "name" in error && + String((error as { name?: unknown }).name) === "AbortError", + ); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index 0f3896eda..e43a4afff 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -9,6 +9,16 @@ import type { } from "@openai/codex-sdk"; import type { JsonSchema } from "./json-types.js"; import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import { + isNativeSchemaUnsupportedFailure, + ProviderSchemaUnsupportedError, +} from "./local-agent-errors.js"; + +export { + isNativeSchemaUnsupportedFailure, + isProviderSchemaUnsupportedError, + ProviderSchemaUnsupportedError, +} from "./local-agent-errors.js"; export type LocalAgentWriteMode = "read_only" | "allowed" | "full_access"; @@ -38,39 +48,6 @@ export interface LocalAgentRuntime { run(input: LocalAgentRunInput): Promise; } -export class ProviderSchemaUnsupportedError extends Error { - constructor( - readonly provider: string, - readonly cause: unknown, - ) { - super(`${provider} does not support the requested native output schema: ${errorMessage(cause)}`); - this.name = "ProviderSchemaUnsupportedError"; - } -} - -export function isProviderSchemaUnsupportedError( - error: unknown, -): error is ProviderSchemaUnsupportedError { - return error instanceof ProviderSchemaUnsupportedError; -} - -export function isNativeSchemaUnsupportedFailure(error: unknown): boolean { - const message = errorMessage(error).toLowerCase(); - const mentionsSchema = - /output[ _-]?schema/.test(message) || - /json[ _-]?schema/.test(message) || - /structured[ _-]?output/.test(message) || - /output[ _-]?format/.test(message); - const unsupported = - /not supported/.test(message) || - /unsupported/.test(message) || - /invalid (?:output|json )?schema/.test(message) || - /schema (?:is )?invalid/.test(message) || - /unknown (?:field|parameter|option)/.test(message) || - /not available/.test(message); - return mentionsSchema && unsupported; -} - interface CodexThreadLike { readonly id: string | null; run(prompt: string, turnOptions?: TurnOptions): Promise; @@ -159,7 +136,3 @@ async function defaultCodexFactory(): Promise { const module = await import("@openai/codex-sdk"); return (options) => new module.Codex(options) as Codex; } - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/src/workflow-api.ts b/src/workflow-api.ts index b1b09c933..78953f54f 100644 --- a/src/workflow-api.ts +++ b/src/workflow-api.ts @@ -269,6 +269,7 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { await semaphore.acquire(deps.signal); let worktree: WorkflowWorktreeHandle | null = null; let worktreePath: string | undefined; + let agentCallBegun = false; try { throwIfCancelled(deps); @@ -307,6 +308,7 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { isolation, worktreePath, }); + agentCallBegun = true; deps.journal.appendEvent({ runId: deps.runId, type: "agent_call_started", @@ -422,6 +424,7 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { 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"); @@ -438,22 +441,33 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { outcome: "failure", }, }); - } catch { - // preserve original error + } catch (cleanupFailure) { + cleanupError = + cleanupFailure instanceof Error + ? cleanupFailure.message + : String(cleanupFailure); } } - deps.journal.failAgentCall({ - runId: deps.runId, - callIndex: index, - error: message, - worktreePath, - }); + if (agentCallBegun) { + deps.journal.failAgentCall({ + runId: deps.runId, + callIndex: index, + error: message, + worktreePath, + }); + } deps.journal.appendEvent({ runId: deps.runId, type: "agent_call_failed", phase, label: agentOpts.label, - data: { callIndex: index, error: message, isolation, worktreePath }, + data: { + callIndex: index, + error: message, + cleanupError, + isolation, + worktreePath, + }, }); throw error; } finally { diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts index 68d9f9170..5ed5bc2cd 100644 --- a/src/workflow-cli.ts +++ b/src/workflow-cli.ts @@ -5,7 +5,7 @@ import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { ServerConfig } from "./config.js"; import { parseJsonText, type JsonObject, type JsonValue } from "./json-types.js"; -import { runLocalAgentProvider } from "./local-agent-adapters.js"; +import { runLocalAgentProviderResult } from "./local-agent-adapters.js"; import { getLocalAgentProviderAvailabilitySnapshot } from "./local-agent-availability.js"; import { isLocalAgentProvider, @@ -14,11 +14,11 @@ import { } from "./local-agent-profiles.js"; import { executeWorkflow, mapEngineErrorKind } from "./workflow-engine.js"; import { - parseWorkflowArgFlags, - persistWorkflowScript, - readWorkflowScriptFile, + parseWorkflowArgFlagsResult, + persistWorkflowScriptResult, + readWorkflowScriptFileResult, resolveNamedWorkflowScript, - resolveWorkflowScriptFromPathOrName, + resolveWorkflowScriptFromPathOrNameResult, } from "./workflow-files.js"; import { createWorkflowReplay } from "./workflow-replay.js"; import { parseWorkflowScript } from "./workflow-script.js"; @@ -33,6 +33,11 @@ import { type WorkflowRunSource, } from "./workflow-types.js"; import { parseWorkflowEventPayload } from "./workflow-contracts.js"; +import { + InvalidWorkflowInputError, + WorkflowNotFoundError, + WorkflowStoredDataError, +} from "./workflow-errors.js"; import { createWorkflowWorktreeFactory, resolveWorkspaceHead, @@ -92,12 +97,16 @@ async function runWorkflowRun(args: string[], config: ServerConfig): Promise | --name | --resume )", - ); + throw new InvalidWorkflowInputError({ + code: "missing_source", + message: + "Usage: devspace workflow run (--file | --name | --resume )", + }); } const store = createWorkflowStore(config); @@ -111,11 +120,15 @@ async function runWorkflowRun(args: string[], config: ServerConfig): Promise"); const store = createWorkflowStore(config); try { - const run = store.requestCancel(runId); + const requested = store.requestCancelResult(runId); + if (requested.isErr()) throw requested.error; + const run = requested.value; console.log(formatRunLine(run)); if (run.pid && (run.status === "running" || run.status === "starting")) { try { @@ -233,7 +254,8 @@ async function runWorkflowCancel(args: string[], config: ServerConfig): Promise< } const latest = store.getRun(runId); if (latest && (latest.status === "running" || latest.status === "starting")) { - store.cancelRun(runId, "cancelled (hard kill)"); + const cancelled = store.cancelRunResult(runId, "cancelled (hard kill)"); + if (cancelled.isErr()) throw cancelled.error; } } } @@ -266,11 +288,12 @@ export async function runWorkflowWorker( if (!runId) throw new Error("Usage: devspace workflow __worker "); const store = createWorkflowStore(config); - const claimed = store.claimRun(runId, process.pid); - if (!claimed) { + const claim = store.claimRunResult(runId, process.pid); + if (claim.isErr()) { store.close(); - throw new Error(`Cannot claim workflow run ${runId} (missing or not starting)`); + throw claim.error; } + const claimed = claim.value; const abort = new AbortController(); const heartbeat = setInterval(() => { @@ -295,8 +318,8 @@ export async function runWorkflowWorker( try { argsValue = parseJsonText(claimed.argsJson); if (argsValue === null) argsValue = undefined; - } catch { - argsValue = undefined; + } catch (cause) { + throw new WorkflowStoredDataError(`${claimed.id}.argsJson`, cause); } const replay = claimed.resumedFromRunId @@ -327,7 +350,7 @@ export async function runWorkflowWorker( if (abort.signal.aborted || store.isCancelRequested(runId)) { throw Object.assign(new Error("Workflow cancelled"), { name: "AbortError" }); } - const providerResult = await runLocalAgentProvider(input.provider, { + const providerRun = await runLocalAgentProviderResult(input.provider, { prompt: input.prompt, workspace: input.workspace, providerSessionId: input.providerSessionId, @@ -336,6 +359,8 @@ export async function runWorkflowWorker( writeMode: "allowed", schema: input.schema, }); + if (providerRun.isErr()) throw providerRun.error; + const providerResult = providerRun.value; return { finalResponse: providerResult.finalResponse, providerSessionId: providerResult.providerSessionId ?? undefined, diff --git a/src/workflow-contracts.ts b/src/workflow-contracts.ts index bf43d58e7..a6947c63b 100644 --- a/src/workflow-contracts.ts +++ b/src/workflow-contracts.ts @@ -186,6 +186,7 @@ export const workflowEventPayloadSchemas = { .object({ callIndex: z.number().int().nonnegative(), error: z.string(), + cleanupError: z.string().optional(), isolation: agentIsolationModeSchema, worktreePath: z.string().optional(), }) diff --git a/src/workflow-engine.test.ts b/src/workflow-engine.test.ts index 030fb590f..5c3438b31 100644 --- a/src/workflow-engine.test.ts +++ b/src/workflow-engine.test.ts @@ -241,6 +241,52 @@ import { createStubBudget } from "./workflow-types.js"; 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, + enabledProviders: ["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, 0); + 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 // --------------------------------------------------------------------------- diff --git a/src/workflow-engine.ts b/src/workflow-engine.ts index ff2a47268..1611e7ca5 100644 --- a/src/workflow-engine.ts +++ b/src/workflow-engine.ts @@ -18,6 +18,10 @@ import { 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. */ @@ -185,6 +189,9 @@ export function mapEngineErrorKind(error: unknown): WorkflowErrorKind { if (error instanceof WorkflowEngineError) { return error.kind; } + 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") { diff --git a/src/workflow-errors.test.ts b/src/workflow-errors.test.ts new file mode 100644 index 000000000..bbc385904 --- /dev/null +++ b/src/workflow-errors.test.ts @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + classifyAgentProviderError, + ProviderCancelledError, + ProviderExecutionError, + ProviderSchemaUnsupportedError, +} 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(ProviderCancelledError.is(classifyAgentProviderError("codex", cancelled))); + assert.ok( + ProviderSchemaUnsupportedError.is( + classifyAgentProviderError( + "claude", + new Error("structured output format is not supported"), + ), + ), + ); + assert.ok( + ProviderExecutionError.is( + classifyAgentProviderError("opencode", new Error("authentication failed")), + ), + ); + + const unavailable = new ProviderSchemaUnsupportedError( + "codex", + new Error("output schema unsupported"), + ); + assert.equal(workflowCliExitCode(unavailable), 5); + assert.deepEqual(serializeWorkflowError(unavailable), { + code: "ProviderSchemaUnsupportedError", + message: unavailable.message, + kind: "schema", + 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 000000000..4349cc5fd --- /dev/null +++ b/src/workflow-errors.ts @@ -0,0 +1,356 @@ +import { TaggedError } from "better-result"; +import { + isAgentProviderError, + ProviderExecutionError, + ProviderUnavailableError, + type AgentProviderError, +} from "./local-agent-errors.js"; +import type { + WorkflowErrorKind, + WorkflowRunStatus, +} from "./workflow-types.js"; + +export class InvalidWorkflowInputError extends TaggedError( + "InvalidWorkflowInputError", +)<{ + code: "ambiguous_source" | "missing_source" | "invalid_name" | "invalid_argument"; + 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": + case "ProviderSchemaUnsupportedError": + return "schema"; + case "ProviderCancelledError": + return "cancelled"; + case "ProviderUnavailableError": + return "provider_unavailable"; + case "ProviderExecutionError": + return "provider"; + } +} + +export function workflowCliExitCode(error: WorkflowOperationError): number { + switch (error._tag) { + case "InvalidWorkflowInputError": + return 2; + case "WorkflowFileNotFoundError": + case "NamedWorkflowNotFoundError": + case "WorkflowNotFoundError": + return 3; + case "ProviderUnavailableError": + return 4; + case "ProviderCancelledError": + return 130; + case "InvalidAgentJsonError": + case "AgentSchemaValidationError": + case "SchemaConfigurationError": + case "SchemaRetriesExhaustedError": + case "ProviderSchemaUnsupportedError": + return 5; + case "WorkflowFileReadError": + case "WorkflowFileWriteError": + case "InvalidRunTransitionError": + case "WorkflowStoreError": + case "WorkflowStoredDataError": + case "WorktreeOperationError": + case "ProviderExecutionError": + 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: + ProviderExecutionError.is(error) ? error.retryable : ProviderUnavailableError.is(error), + }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/workflow-files.ts b/src/workflow-files.ts index 7615980ff..2a0cfadf2 100644 --- a/src/workflow-files.ts +++ b/src/workflow-files.ts @@ -1,8 +1,16 @@ import { createHash, randomBytes } from "node:crypto"; -import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { basename, dirname, 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"; export class WorkflowPathError extends Error { constructor(message: string) { @@ -19,6 +27,12 @@ export interface ResolvedWorkflowScript { 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. @@ -29,27 +43,58 @@ export async function persistWorkflowScript(input: { source: string; preferredName?: string; }): Promise { + const result = await persistWorkflowScriptResult(input); + if (result.isErr()) throw result.error; + return result.value; +} + +export async function persistWorkflowScriptResult(input: { + stateDir: string; + runId: string; + source: string; + preferredName?: string; +}): Promise> { const dir = join(input.stateDir, "workflow-scripts", input.runId); - await mkdir(dir, { recursive: true }); const base = sanitizeSegment(input.preferredName ?? "script") || `script-${randomBytes(3).toString("hex")}`; const path = join(dir, `${base}.js`); - await writeFile(path, input.source, { encoding: "utf8", mode: 0o600 }); - return path; + 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 readWorkflowScriptFile(path: string): Promise { + const result = await readWorkflowScriptFileResult(path); + if (result.isErr()) throwPathCompatibilityError(result.error); + return result.value; +} + +export async function readWorkflowScriptFileResult( + path: string, +): Promise> { const scriptPath = resolve(path); - await assertReadableFile(scriptPath); - const source = await readFile(scriptPath, "utf8"); - return { - source, - scriptPath, - scriptHash: hashSource(source), - nameHint: basename(scriptPath, extname(scriptPath)), - origin: "file", - }; + 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), + }); } /** @@ -64,9 +109,24 @@ export async function resolveNamedWorkflowScript(input: { workspaceRoot: string; stateDir?: string; }): Promise { + const result = await resolveNamedWorkflowScriptResult(input); + if (result.isErr()) throwPathCompatibilityError(result.error); + return result.value; +} + +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("..")) { - throw new WorkflowPathError(`Invalid workflow name: ${JSON.stringify(input.name)}`); + 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`), @@ -76,23 +136,14 @@ export async function resolveNamedWorkflowScript(input: { candidates.push(join(input.stateDir, "workflows", `${name}.js`)); } for (const candidate of candidates) { - try { - await assertReadableFile(candidate); - const source = await readFile(candidate, "utf8"); - return { - source, - scriptPath: candidate, - scriptHash: hashSource(source), - nameHint: name, - origin: "named", - }; - } catch { - // try next + 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; } - throw new WorkflowPathError( - `Named workflow not found: ${name}. Looked in ${candidates.join(", ")}`, - ); + return Result.err(new NamedWorkflowNotFoundError(name, candidates)); } export async function resolveWorkflowScriptFromPathOrName(input: { @@ -101,29 +152,61 @@ export async function resolveWorkflowScriptFromPathOrName(input: { workspaceRoot: string; stateDir?: string; }): Promise { + const result = await resolveWorkflowScriptFromPathOrNameResult(input); + if (result.isErr()) throwPathCompatibilityError(result.error); + return result.value; +} + +export async function resolveWorkflowScriptFromPathOrNameResult(input: { + file?: string; + name?: string; + workspaceRoot: string; + stateDir?: string; +}): Promise> { if (input.file && input.name) { - throw new WorkflowPathError("Pass only one of --file or --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 readWorkflowScriptFile(path); + return readWorkflowScriptFileResult(path); } if (input.name) { - return resolveNamedWorkflowScript({ + return resolveNamedWorkflowScriptResult({ name: input.name, workspaceRoot: input.workspaceRoot, stateDir: input.stateDir, }); } - throw new WorkflowPathError("Provide --file or --name "); + return Result.err( + new InvalidWorkflowInputError({ + code: "missing_source", + message: "Provide --file or --name ", + }), + ); } export function parseWorkflowArgFlags(tokens: string[]): { args: Record; rest: string[]; } { + const result = parseWorkflowArgFlagsResult(tokens); + if (result.isErr()) throwPathCompatibilityError(result.error); + return result.value; +} + +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) { @@ -131,7 +214,12 @@ export function parseWorkflowArgFlags(tokens: string[]): { if (token === "--arg") { const pair = tokens[++i]; if (!pair || !pair.includes("=")) { - throw new WorkflowPathError("--arg requires key=value"); + return Result.err( + new InvalidWorkflowInputError({ + code: "invalid_argument", + message: "--arg requires key=value", + }), + ); } const eq = pair.indexOf("="); const key = pair.slice(0, eq); @@ -142,13 +230,20 @@ export function parseWorkflowArgFlags(tokens: string[]): { if (token.startsWith("--arg=")) { const pair = token.slice("--arg=".length); const eq = pair.indexOf("="); - if (eq < 0) throw new WorkflowPathError("--arg requires key=value"); + 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 { args, rest }; + return Result.ok({ args, rest }); } function coerceArgValue(raw: string): JsonValue { @@ -159,14 +254,6 @@ function coerceArgValue(raw: string): JsonValue { } } -async function assertReadableFile(path: string): Promise { - try { - await access(path); - } catch { - throw new WorkflowPathError(`Script file not found: ${path}`); - } -} - function sanitizeSegment(value: string): string { return value .replace(/[^a-zA-Z0-9._-]+/g, "-") @@ -185,3 +272,18 @@ export function contentHash(source: string): string { export function dirnameOf(path: string): string { return dirname(path); } + +function isFileNotFound(error: unknown): boolean { + return Boolean( + error && + typeof error === "object" && + "code" in error && + (error as { code?: unknown }).code === "ENOENT", + ); +} + +function throwPathCompatibilityError(error: Error): never { + const compatible = new WorkflowPathError(error.message); + compatible.cause = error; + throw compatible; +} diff --git a/src/workflow-replay.ts b/src/workflow-replay.ts index 3038d1b0b..24f354183 100644 --- a/src/workflow-replay.ts +++ b/src/workflow-replay.ts @@ -1,6 +1,7 @@ import type { WorkflowAgentCallRecord } from "./workflow-types.js"; import type { WorkflowReplay, WorkflowReplayHit } from "./workflow-api.js"; import { parseJsonText } from "./json-types.js"; +import { WorkflowStoredDataError } from "./workflow-errors.js"; /** * Resume matcher: @@ -69,8 +70,11 @@ function toHit(call: WorkflowAgentCallRecord): WorkflowReplayHit { structuredJson: call.structuredJson, providerSessionId: call.providerSessionId, }; - } catch { - // fall through to text + } catch (cause) { + throw new WorkflowStoredDataError( + `${call.runId}.agentCalls[${call.callIndex}].structuredJson`, + cause, + ); } } return { diff --git a/src/workflow-schema.test.ts b/src/workflow-schema.test.ts index 8e92ffcdf..a05b2dc6f 100644 --- a/src/workflow-schema.test.ts +++ b/src/workflow-schema.test.ts @@ -37,6 +37,7 @@ assert.ok(!supportsNativeStructuredOutput("opencode")); additionalProperties: false, }, prompt: "give n", + provider: "opencode", run: async () => { attempts += 1; if (attempts === 1) return { finalResponse: '{"n":"x"}' }; @@ -55,6 +56,7 @@ assert.ok(!supportsNativeStructuredOutput("opencode")); enforceAgentSchema({ schema: { type: "object", properties: { n: { type: "number" } }, required: ["n"] }, prompt: "x", + provider: "opencode", maxRetries: 1, run: async () => ({ finalResponse: "not json" }), }), @@ -205,6 +207,7 @@ assert.ok(!supportsNativeStructuredOutput("opencode")); enforceAgentSchema({ schema: { type: "object" }, prompt: "x", + provider: "opencode", maxRetries: 0, onRetry: ({ attempt }) => retries.push(attempt), run: async () => ({ finalResponse: "not json" }), diff --git a/src/workflow-schema.ts b/src/workflow-schema.ts index b5234b45a..0249c2dd5 100644 --- a/src/workflow-schema.ts +++ b/src/workflow-schema.ts @@ -1,8 +1,13 @@ 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, WorkflowRunProvider } from "./workflow-api.js"; -import { isProviderSchemaUnsupportedError } from "./local-agent-runtime.js"; +import { + classifyAgentProviderError, + isProviderSchemaUnsupportedError, + type AgentProviderError, +} from "./local-agent-errors.js"; import { supportsNativeStructuredOutput } from "./local-agent-capabilities.js"; import type { LocalAgentProvider } from "./local-agent-profiles.js"; import { @@ -10,6 +15,13 @@ import { 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); @@ -40,7 +52,7 @@ export interface EnforceSchemaInput { * 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; + provider: LocalAgentProvider; run: ( prompt: string, opts: { @@ -64,6 +76,11 @@ export interface EnforceSchemaResult { 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. @@ -71,14 +88,35 @@ export interface EnforceSchemaResult { export async function enforceAgentSchema( input: EnforceSchemaInput, ): Promise { - const Ajv = loadAjv(); - const ajv = new Ajv({ allErrors: true, strict: false }); - const validate = ajv.compile(input.schema); + 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 compiled; + const validate = compiled.value; const maxRetries = input.maxRetries ?? WORKFLOW_MAX_SCHEMA_RETRIES; - const native = Boolean(input.provider && supportsNativeStructuredOutput(input.provider)); + const native = supportsNativeStructuredOutput(input.provider); const basePrompt = augmentPromptForSchema(input.prompt, input.schema); - let lastErrors = "unknown validation error"; + let lastFailure: SchemaAttemptError | undefined; let providerSessionId: string | undefined; for (let attempt = 0; attempt <= maxRetries; attempt += 1) { @@ -89,26 +127,39 @@ export async function enforceAgentSchema( ? input.prompt : attempt === 0 ? basePrompt - : `${basePrompt}\n\nPrevious JSON failed validation:\n${lastErrors}\nReturn only corrected JSON.`; - - let result: WorkflowProviderRunResult; - try { - result = await input.run(prompt, { mode, providerSessionId }); - } catch (error) { - if (mode === "native" && isProviderSchemaUnsupportedError(error) && attempt < maxRetries) { - lastErrors = error.message; - input.onRetry?.({ attempt: attempt + 1, errors: lastErrors, mode }); + : `${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) => classifyAgentProviderError(input.provider, cause), + }); + if (runResult.isErr()) { + if ( + mode === "native" && + isProviderSchemaUnsupportedError(runResult.error) && + attempt < maxRetries + ) { + input.onRetry?.({ + attempt: attempt + 1, + errors: runResult.error.message, + mode, + }); continue; } - throw error; + return runResult; } + const result = runResult.value; providerSessionId = result.providerSessionId ?? providerSessionId; const candidates = structuredCandidates(result); if (candidates.length === 0) { - lastErrors = "Response was not valid JSON"; + lastFailure = new InvalidAgentJsonError({ + attempt: attempt + 1, + mode, + responseExcerpt: result.finalResponse.slice(0, 500), + }); if (attempt < maxRetries) { - input.onRetry?.({ attempt: attempt + 1, errors: lastErrors, mode }); + input.onRetry?.({ attempt: attempt + 1, errors: lastFailure.message, mode }); } continue; } @@ -116,25 +167,36 @@ export async function enforceAgentSchema( for (const candidate of candidates) { const ok = validate(candidate); if (ok) { - return { + return Result.ok({ value: candidate, finalResponse: result.finalResponse, providerSessionId, attempts: attempt + 1, mode, - }; - }; + }); + } } - lastErrors = formatAjvErrors(validate.errors); + lastFailure = new AgentSchemaValidationError({ + attempt: attempt + 1, + mode, + issues: toSchemaIssues(validate.errors), + }); if (attempt < maxRetries) { - input.onRetry?.({ attempt: attempt + 1, errors: lastErrors, mode }); + input.onRetry?.({ attempt: attempt + 1, errors: lastFailure.message, mode }); } } - throw new WorkflowEngineError( - "schema", - `Schema validation failed after ${maxRetries + 1} attempts: ${lastErrors}`, + return Result.err( + new SchemaRetriesExhaustedError( + maxRetries + 1, + lastFailure ?? + new InvalidAgentJsonError({ + attempt: maxRetries + 1, + mode: native ? "native" : "prompt", + responseExcerpt: "", + }), + ), ); } @@ -159,6 +221,18 @@ export function formatAjvErrors( .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", + })); +} + /** Helper for wiring into agent(): wrap a one-shot provider as retrying schema runner. */ export function schemaAwareRunProvider( runProvider: WorkflowRunProvider, @@ -181,6 +255,27 @@ export function schemaAwareRunProvider( }); } +export function schemaAwareRunProviderResult( + runProvider: WorkflowRunProvider, + schema: JsonSchema, + base: Parameters[0], + onRetry?: EnforceSchemaInput["onRetry"], +): Promise> { + return enforceAgentSchemaResult({ + schema, + prompt: base.prompt, + provider: base.provider, + onRetry, + run: (prompt, options) => + runProvider({ + ...base, + prompt, + providerSessionId: options.providerSessionId, + ...(options.mode === "native" ? { schema } : {}), + }), + }); +} + function structuredCandidates(result: WorkflowProviderRunResult): JsonValue[] { const candidates: JsonValue[] = []; if (result.structured !== undefined) { diff --git a/src/workflow-store.ts b/src/workflow-store.ts index 90d866c30..085757d2e 100644 --- a/src/workflow-store.ts +++ b/src/workflow-store.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { resolve } from "node:path"; +import { Result, type Result as BetterResult } from "better-result"; import { openDatabase, type DatabaseHandle } from "./db/client.js"; import type { ServerConfig } from "./config.js"; import { @@ -22,6 +23,16 @@ import { workflowRunSourceSchema, workflowRunStatusSchema, } from "./workflow-contracts.js"; +import { + InvalidRunTransitionError, + WorkflowNotFoundError, + WorkflowStoreError, +} from "./workflow-errors.js"; + +export type WorkflowRunTransitionError = + | WorkflowNotFoundError + | InvalidRunTransitionError + | WorkflowStoreError; export interface CreateWorkflowRunInput { name: string; @@ -207,6 +218,15 @@ export class WorkflowStore { 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 ?") @@ -219,31 +239,102 @@ export class WorkflowStore { * Returns undefined if the run is missing or not claimable. */ setScriptPath(id: string, scriptPath: string): WorkflowRunRecord { - this.requireRun(id); - const now = isoNow(); - this.database.sqlite - .prepare( - `UPDATE workflow_runs SET script_path = ?, updated_at = ? WHERE id = ?`, - ) - .run(scriptPath, now, id); - return this.requireRun(id); + return unwrapRunResult(this.setScriptPathResult(id, scriptPath)); + } + + setScriptPathResult( + id: string, + scriptPath: string, + ): BetterResult { + const current = this.getRunResult(id); + if (current.isErr()) return current; + 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 updated; + return updated.value + ? Result.ok(updated.value) + : Result.err(new WorkflowNotFoundError(id)); } claimRun(id: string, pid: number): WorkflowRunRecord | undefined { - const now = isoNow(); - const result = 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); - if (result.changes === 0) return undefined; - return this.getRun(id); + 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 currentResult; + 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 claimed; + if (claimed.value === 0) { + const latestResult = this.getRunResult(id); + if (latestResult.isErr()) return latestResult; + 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 runResult; + const run = runResult.value; + return run ? Result.ok(run) : Result.err(new WorkflowNotFoundError(id)); } setHeartbeat(id: string, at = isoNow()): void { @@ -255,16 +346,34 @@ export class WorkflowStore { } requestCancel(id: string): WorkflowRunRecord { - const run = this.requireRun(id); - if (TERMINAL_STATUSES.has(run.status)) return run; + return unwrapRunResult(this.requestCancelResult(id)); + } - const now = isoNow(); - this.database.sqlite - .prepare( - `update workflow_runs set cancel_requested = 'true', updated_at = ? where id = ?`, - ) - .run(now, id); - return this.requireRun(id); + requestCancelResult( + id: string, + ): BetterResult { + const current = this.getRunResult(id); + if (current.isErr()) return current; + 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(); + this.database.sqlite + .prepare( + `update workflow_runs set cancel_requested = 'true', updated_at = ? where id = ?`, + ) + .run(now, id); + return this.getRun(id); + }, + catch: (cause) => new WorkflowStoreError("request_cancel", cause), + }); + if (updated.isErr()) return updated; + return updated.value + ? Result.ok(updated.value) + : Result.err(new WorkflowNotFoundError(id)); } isCancelRequested(id: string): boolean { @@ -272,69 +381,114 @@ export class WorkflowStore { } completeRun(id: string, input: CompleteRunInput = {}): WorkflowRunRecord { - if (input.resultJson !== undefined) assertResultSize(input.resultJson); - const now = isoNow(); - const result = 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); - if (result.changes === 0) { - const run = this.requireRun(id); - if (TERMINAL_STATUSES.has(run.status)) return run; - throw new Error(`Cannot complete workflow run ${id} in status ${run.status}`); - } - return this.requireRun(id); + 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(); + return 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; + }); } failRun(id: string, input: FailRunInput): WorkflowRunRecord { - const now = isoNow(); - const result = 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, input.errorKind ?? "internal", now, now, id); - if (result.changes === 0) { - const run = this.requireRun(id); - if (TERMINAL_STATUSES.has(run.status)) return run; - throw new Error(`Cannot fail workflow run ${id} in status ${run.status}`); - } - return this.requireRun(id); + return unwrapRunResult(this.failRunResult(id, input)); + } + + failRunResult( + id: string, + input: FailRunInput, + ): BetterResult { + return this.transitionRunResult(id, "fail", () => { + const now = isoNow(); + return 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, input.errorKind ?? "internal", now, now, id).changes; + }); } cancelRun(id: string, error = "cancelled"): WorkflowRunRecord { - const now = isoNow(); - const result = 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); - if (result.changes === 0) { - const run = this.requireRun(id); - if (TERMINAL_STATUSES.has(run.status)) return run; - throw new Error(`Cannot cancel workflow run ${id} in status ${run.status}`); + return unwrapRunResult(this.cancelRunResult(id, error)); + } + + cancelRunResult( + id: string, + error = "cancelled", + ): BetterResult { + return this.transitionRunResult(id, "cancel", () => { + const now = isoNow(); + return 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; + }); + } + + private transitionRunResult( + id: string, + operation: "complete" | "fail" | "cancel", + update: () => number, + ): BetterResult { + const currentResult = this.getRunResult(id); + if (currentResult.isErr()) return currentResult; + 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 updated; + if (updated.value === 0) { + const latestResult = this.getRunResult(id); + if (latestResult.isErr()) return latestResult; + 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, + }), + ); } - return this.requireRun(id); + const runResult = this.getRunResult(id); + if (runResult.isErr()) return runResult; + const run = runResult.value; + return run ? Result.ok(run) : Result.err(new WorkflowNotFoundError(id)); } appendEvent(input: AppendWorkflowEventInput): WorkflowEventRecord { @@ -664,3 +818,10 @@ function truncateJson(value: unknown, maxBytes: number): string { 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-tools.ts b/src/workflow-tools.ts index faefce755..7de46bf67 100644 --- a/src/workflow-tools.ts +++ b/src/workflow-tools.ts @@ -6,9 +6,9 @@ import type { ServerConfig } from "./config.js"; import { jsonValueSchema, parseJsonText, type JsonValue } from "./json-types.js"; import type { WorkspaceRegistry } from "./workspaces.js"; import { - persistWorkflowScript, - resolveNamedWorkflowScript, - readWorkflowScriptFile, + persistWorkflowScriptResult, + resolveNamedWorkflowScriptResult, + readWorkflowScriptFileResult, } from "./workflow-files.js"; import { parseWorkflowScript } from "./workflow-script.js"; import { createWorkflowStore } from "./workflow-store.js"; @@ -27,6 +27,13 @@ import { LOCAL_AGENT_PROVIDERS, type LocalAgentProvider, } from "./local-agent-profiles.js"; +import { + InvalidWorkflowInputError, + isWorkflowOperationError, + serializeWorkflowError, + WorkflowNotFoundError, + WorkflowStoredDataError, +} from "./workflow-errors.js"; const WORKFLOW_API_CHEATSHEET = ` Workflow scripts (JS only): @@ -81,7 +88,10 @@ export function registerWorkflowTools( try { const provided = [script, name, resumeFromRunId].filter((v) => v !== undefined); if (provided.length !== 1) { - throw new Error("Provide exactly one of script, name, or resumeFromRunId"); + throw new InvalidWorkflowInputError({ + code: provided.length === 0 ? "missing_source" : "ambiguous_source", + message: "Provide exactly one of script, name, or resumeFromRunId", + }); } let source: string; @@ -93,10 +103,12 @@ export function registerWorkflowTools( if (resumeFromRunId) { const prior = store.getRun(resumeFromRunId); - if (!prior) throw new Error(`Unknown run: ${resumeFromRunId}`); + if (!prior) throw new WorkflowNotFoundError(resumeFromRunId); priorRunId = prior.id; priorScriptPath = prior.scriptPath; - const resolved = await readWorkflowScriptFile(prior.scriptPath); + const resolvedResult = await readWorkflowScriptFileResult(prior.scriptPath); + if (resolvedResult.isErr()) throw resolvedResult.error; + const resolved = resolvedResult.value; source = resolved.source; scriptHash = prior.scriptHash; nameHint = prior.name; @@ -104,16 +116,18 @@ export function registerWorkflowTools( if (args === undefined && prior.argsJson && prior.argsJson !== "null") { try { args = parseJsonText(prior.argsJson); - } catch { - // keep undefined + } catch (cause) { + throw new WorkflowStoredDataError(`${prior.id}.argsJson`, cause); } } } else if (name) { - const resolved = await resolveNamedWorkflowScript({ + const resolvedResult = await resolveNamedWorkflowScriptResult({ name, workspaceRoot: workspace.root, stateDir: config.stateDir, }); + if (resolvedResult.isErr()) throw resolvedResult.error; + const resolved = resolvedResult.value; source = resolved.source; scriptHash = resolved.scriptHash; nameHint = resolved.nameHint; @@ -140,15 +154,19 @@ export function registerWorkflowTools( baseSha, }); - const persisted = - priorScriptPath ?? - (await persistWorkflowScript({ + let persisted = priorScriptPath; + if (!persisted) { + const persistedResult = await persistWorkflowScriptResult({ stateDir: config.stateDir, runId: run.id, source, preferredName: parsed.meta.name || nameHint, - })); - if (!priorScriptPath) store.setScriptPath(run.id, persisted); + }); + if (persistedResult.isErr()) throw persistedResult.error; + persisted = persistedResult.value; + const updated = store.setScriptPathResult(run.id, persisted); + if (updated.isErr()) throw updated.error; + } const cliEntry = fileURLToPath( import.meta.url.replace(/workflow-tools\.(ts|js)$/, "cli.$1"), @@ -158,6 +176,9 @@ export function registerWorkflowTools( const yieldMs = yieldTimeMs ?? 2_000; const page = await yieldEvents(store, run.id, 0, yieldMs); return toolResult(page); + } catch (error) { + if (isWorkflowOperationError(error)) return workflowToolError(error); + throw error; } finally { store.close(); } @@ -187,9 +208,12 @@ export function registerWorkflowTools( async ({ runId, sinceSeq, yieldTimeMs }) => { const store = createWorkflowStore(config); try { - if (!store.getRun(runId)) throw new Error(`Unknown workflow run: ${runId}`); + if (!store.getRun(runId)) throw new WorkflowNotFoundError(runId); const page = await yieldEvents(store, runId, sinceSeq ?? 0, yieldTimeMs ?? 0); return toolResult(page); + } catch (error) { + if (isWorkflowOperationError(error)) return workflowToolError(error); + throw error; } finally { store.close(); } @@ -211,7 +235,9 @@ export function registerWorkflowTools( async ({ runId }) => { const store = createWorkflowStore(config); try { - const run = store.requestCancel(runId); + const requested = store.requestCancelResult(runId); + if (requested.isErr()) throw requested.error; + const run = requested.value; if (run.pid && (run.status === "running" || run.status === "starting")) { try { process.kill(run.pid, "SIGTERM"); @@ -224,6 +250,9 @@ export function registerWorkflowTools( content: [{ type: "text" as const, text: JSON.stringify({ runId, status: latest.status }) }], structuredContent: { runId, status: latest.status }, }; + } catch (error) { + if (isWorkflowOperationError(error)) return workflowToolError(error); + throw error; } finally { store.close(); } @@ -289,6 +318,15 @@ function toolResult(page: { }; } +function workflowToolError(error: Parameters[0]) { + const payload = { error: serializeWorkflowError(error) }; + return { + content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }], + structuredContent: payload, + isError: true, + }; +} + function safeJson(text: string): JsonValue { try { return parseJsonText(text); diff --git a/src/workflow-worktrees.ts b/src/workflow-worktrees.ts index 1bbb3414d..1ccf6089e 100644 --- a/src/workflow-worktrees.ts +++ b/src/workflow-worktrees.ts @@ -2,8 +2,9 @@ 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 { WorkflowEngineError } from "./workflow-api.js"; +import { WorktreeOperationError } from "./workflow-errors.js"; const execFileAsync = promisify(execFile); @@ -21,44 +22,65 @@ export function createWorkflowWorktreeFactory( host: WorkflowWorktreeHost, ): CreateAgentWorktree { return async (input) => { - 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 WorkflowEngineError( - "worktree", - "isolation: 'worktree' requires Git on PATH", + 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 }, ); } - throw new WorkflowEngineError( - "worktree", - `isolation: 'worktree' requires a Git repository (not found at ${input.workspaceRoot})`, - ); - } - - const baseSha = - input.baseSha ?? - (await git(["rev-parse", "--verify", "HEAD^{commit}"], sourceRoot)).trim(); - - try { - await git(["worktree", "add", "--detach", path, baseSha], sourceRoot); - } catch (error) { - await rm(path, { recursive: true, force: true }).catch(() => undefined); - const message = error instanceof Error ? error.message : String(error); - throw new WorkflowEngineError( - "worktree", - `Failed to create agent worktree: ${message}`, - ); - } - - return createHandle({ path, sourceRoot }); - }; + + 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: { @@ -68,9 +90,12 @@ function createHandle(input: { return { path: input.path, finalize: async (outcome) => { - const dirty = await isDirty(input.path); + const dirtyResult = await isDirtyResult(input.path); + if (dirtyResult.isErr()) throw dirtyResult.error; + const dirty = dirtyResult.value; if (outcome === "success" && !dirty) { - await removeWorktree(input.sourceRoot, input.path); + 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. @@ -80,29 +105,62 @@ function createHandle(input: { } export async function isDirty(worktreePath: string): Promise { - try { - const status = (await git(["status", "--porcelain=v1"], worktreePath)).trim(); - return status.length > 0; - } catch { - // If status fails, treat as dirty so we don't delete. - return true; - } + 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 { - try { - await git(["worktree", "remove", "--force", worktreePath], sourceRoot); - } catch { - await rm(worktreePath, { recursive: true, force: true }); - try { - await git(["worktree", "prune"], sourceRoot); - } catch { - // ignore - } - } + 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 { From f9a1dc7677c19339c5c5190d9e1fab26c5c1ca68 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:37:30 +0000 Subject: [PATCH 023/132] feat(workflow): persist replay provenance --- src/db/migrations.ts | 22 ++++++++++- src/db/schema.ts | 11 ++++++ src/oauth-store.test.ts | 1 + src/workflow-api.ts | 63 ++++++++++++++++++++++++++++--- src/workflow-contracts.ts | 3 ++ src/workflow-replay.test.ts | 40 ++++++++++++++++---- src/workflow-replay.ts | 75 ++++++++++++++++++++++++++++++++----- src/workflow-store.test.ts | 14 ++++++- src/workflow-store.ts | 40 ++++++++++++++++++-- src/workflow-types.ts | 7 ++++ 10 files changed, 249 insertions(+), 27 deletions(-) diff --git a/src/db/migrations.ts b/src/db/migrations.ts index b3e481ad8..85f2fa681 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -32,6 +32,11 @@ const migrations: Migration[] = [ name: "workflow-journal", up: migrateWorkflowJournal, }, + { + version: 6, + name: "workflow-replay-provenance", + up: migrateWorkflowReplayProvenance, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -282,9 +287,24 @@ function migrateWorkflowJournal(sqlite: Database.Database): void { `); } +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 addColumnIfMissing( sqlite: Database.Database, - table: "workspace_sessions" | "local_agent_sessions", + table: "workspace_sessions" | "local_agent_sessions" | "workflow_agent_calls", column: string, definition: string, ): void { diff --git a/src/db/schema.ts b/src/db/schema.ts index 36104a972..8f7e15186 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -157,6 +157,8 @@ export const workflowAgentCalls = sqliteTable( .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"), @@ -168,6 +170,11 @@ export const workflowAgentCalls = sqliteTable( responseText: text("response_text"), structuredJson: text("structured_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"), @@ -179,6 +186,10 @@ export const workflowAgentCalls = sqliteTable( (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, + ), ], ); diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index a5cfaec4e..bae526695 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -46,6 +46,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise { { version: 3, name: "local-agent-sessions" }, { version: 4, name: "local-agent-effort-rename" }, { version: 5, name: "workflow-journal" }, + { version: 6, name: "workflow-replay-provenance" }, ]); } finally { database.close(); diff --git a/src/workflow-api.ts b/src/workflow-api.ts index 78953f54f..8ae13c92b 100644 --- a/src/workflow-api.ts +++ b/src/workflow-api.ts @@ -11,6 +11,7 @@ import { buildAgentCacheKeyInput, createStubBudget, type AgentIsolationMode, + type AgentCacheKeyInput, type AgentOpts, type AppendWorkflowEventInput, type WorkflowMeta, @@ -64,10 +65,30 @@ export interface WorkflowReplayHit { responseText?: string; structuredJson?: string; providerSessionId?: string; + replayMatch: "same_index" | "compatible_key"; + replayedFromRunId: string; + replayedFromCallIndex: number; } +export interface WorkflowReplayMiss { + reason: + | "no_compatible_call" + | "prior_call_not_replayable" + | "compatible_result_consumed" + | "identity_changed"; + changedFields?: Array; +} + +export type WorkflowReplayDecision = + | { hit: WorkflowReplayHit; miss?: never } + | { hit?: never; miss: WorkflowReplayMiss }; + export interface WorkflowReplay { - match(callIndex: number, cacheKey: string): WorkflowReplayHit | null; + decide( + callIndex: number, + cacheKey: string, + input: AgentCacheKeyInput, + ): WorkflowReplayDecision; } export interface WorkflowJournal { @@ -78,6 +99,8 @@ export interface WorkflowJournal { runId: string; callIndex: number; cacheKey: string; + prompt: string; + schemaJson?: string; provider: LocalAgentProvider; model?: string; effort?: string; @@ -85,6 +108,10 @@ export interface WorkflowJournal { phase?: string; isolation?: AgentIsolationMode; worktreePath?: string; + replayMatch?: "same_index" | "compatible_key"; + replayedFromRunId?: string; + replayedFromCallIndex?: number; + replayReason?: string; }): unknown; completeAgentCall(input: { runId: string; @@ -100,6 +127,7 @@ export interface WorkflowJournal { runId: string; callIndex: number; error: string; + errorKind?: import("./workflow-types.js").WorkflowErrorKind; worktreePath?: string; dirty?: boolean; }): unknown; @@ -233,19 +261,24 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { }); const cacheKey = hashCacheKey(cacheKeyInput); - if (deps.replay) { - const hit = deps.replay.match(index, cacheKey); - if (hit) { + const replayDecision = deps.replay?.decide(index, cacheKey, cacheKeyInput); + if (replayDecision?.hit) { + const hit = replayDecision.hit; deps.journal.beginAgentCall({ runId: deps.runId, callIndex: index, cacheKey, + prompt, + schemaJson: agentOpts.schema ? JSON.stringify(agentOpts.schema) : undefined, provider, model: agentOpts.model, effort: agentOpts.effort, label: agentOpts.label, phase, isolation, + replayMatch: hit.replayMatch, + replayedFromRunId: hit.replayedFromRunId, + replayedFromCallIndex: hit.replayedFromCallIndex, }); deps.journal.completeAgentCall({ runId: deps.runId, @@ -260,10 +293,16 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { type: "agent_call_cached", phase, label: agentOpts.label, - data: { callIndex: index, cacheKey, provider }, + data: { + callIndex: index, + cacheKey, + provider, + replayMatch: hit.replayMatch, + replayedFromRunId: hit.replayedFromRunId, + replayedFromCallIndex: hit.replayedFromCallIndex, + }, }); return hit.value; - } } await semaphore.acquire(deps.signal); @@ -300,6 +339,8 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { runId: deps.runId, callIndex: index, cacheKey, + prompt, + schemaJson: agentOpts.schema ? JSON.stringify(agentOpts.schema) : undefined, provider, model: agentOpts.model, effort: agentOpts.effort, @@ -307,6 +348,9 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { phase, isolation, worktreePath, + replayReason: replayDecision?.miss + ? formatReplayMiss(replayDecision.miss) + : undefined, }); agentCallBegun = true; deps.journal.appendEvent({ @@ -453,6 +497,7 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { runId: deps.runId, callIndex: index, error: message, + errorKind: error instanceof WorkflowEngineError ? error.kind : "internal", worktreePath, }); } @@ -594,6 +639,12 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { }; } +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(); diff --git a/src/workflow-contracts.ts b/src/workflow-contracts.ts index a6947c63b..1944b1c50 100644 --- a/src/workflow-contracts.ts +++ b/src/workflow-contracts.ts @@ -196,6 +196,9 @@ export const workflowEventPayloadSchemas = { callIndex: z.number().int().nonnegative(), cacheKey: z.string(), provider: localAgentProviderSchema, + replayMatch: z.enum(["same_index", "compatible_key"]), + replayedFromRunId: z.string(), + replayedFromCallIndex: z.number().int().nonnegative(), }) .strict(), schema_retry: z diff --git a/src/workflow-replay.test.ts b/src/workflow-replay.test.ts index 5258809e9..7b0ac9933 100644 --- a/src/workflow-replay.test.ts +++ b/src/workflow-replay.test.ts @@ -8,6 +8,7 @@ function call( ): WorkflowAgentCallRecord { return { runId: "wfr_prior", + prompt: "prompt", provider: "codex", status: "completed", fromCache: false, @@ -18,14 +19,25 @@ function call( }; } +function identity(prompt = "prompt") { + return { + prompt, + provider: "codex" as const, + model: null, + effort: null, + schema: null, + isolation: "shared" as const, + }; +} + { const replay = createWorkflowReplay([ call({ callIndex: 0, cacheKey: "k0", responseText: "a" }), call({ callIndex: 1, cacheKey: "k1", responseText: "b" }), ]); - assert.equal(replay.match(0, "k0")?.value, "a"); - assert.equal(replay.match(1, "k1")?.value, "b"); - assert.equal(replay.match(2, "k0"), null); + 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, "k0", identity()).miss?.reason, "compatible_result_consumed"); } { @@ -35,9 +47,14 @@ function call( call({ callIndex: 1, cacheKey: "kb", responseText: "B" }), ]); // new run asks index0 for kb first - assert.equal(replay.match(0, "kb")?.value, "B"); - assert.equal(replay.match(1, "ka")?.value, "A"); - assert.equal(replay.match(2, "ka"), null); + const reorderedB = replay.decide(0, "kb", identity()).hit; + assert.equal(reorderedB?.value, "B"); + assert.equal(reorderedB?.replayMatch, "compatible_key"); + assert.equal(replay.decide(1, "ka", identity()).hit?.value, "A"); + assert.equal( + replay.decide(2, "ka", identity()).miss?.reason, + "compatible_result_consumed", + ); } { @@ -49,7 +66,16 @@ function call( structuredJson: '{"ok":true}', }), ]); - assert.deepEqual(replay.match(0, "ks")?.value, { ok: true }); + assert.deepEqual(replay.decide(0, "ks", identity()).hit?.value, { ok: true }); +} + +{ + const replay = createWorkflowReplay([ + call({ callIndex: 0, cacheKey: "old", prompt: "old prompt", responseText: "a" }), + ]); + const miss = replay.decide(0, "new", identity("new prompt")).miss; + assert.equal(miss?.reason, "identity_changed"); + assert.deepEqual(miss?.changedFields, ["prompt"]); } console.log("workflow-replay.test.ts: ok"); diff --git a/src/workflow-replay.ts b/src/workflow-replay.ts index 24f354183..25e2273a6 100644 --- a/src/workflow-replay.ts +++ b/src/workflow-replay.ts @@ -1,5 +1,10 @@ import type { WorkflowAgentCallRecord } from "./workflow-types.js"; -import type { WorkflowReplay, WorkflowReplayHit } from "./workflow-api.js"; +import type { + WorkflowReplay, + WorkflowReplayDecision, + WorkflowReplayHit, +} from "./workflow-api.js"; +import type { AgentCacheKeyInput } from "./workflow-types.js"; import { parseJsonText } from "./json-types.js"; import { WorkflowStoredDataError } from "./workflow-errors.js"; @@ -26,20 +31,46 @@ export function createWorkflowReplay( const consumed = new Set(); // `${callIndex}` of prior rows consumed return { - match(callIndex: number, cacheKey: string): WorkflowReplayHit | null { + decide( + callIndex: number, + cacheKey: string, + input: AgentCacheKeyInput, + ): WorkflowReplayDecision { const exact = byIndex.get(callIndex); if (exact && exact.cacheKey === cacheKey && !consumed.has(indexKey(exact))) { consumed.add(indexKey(exact)); removeFromKeyQueue(byKeyQueue, exact); - return toHit(exact); + return { hit: toHit(exact, "same_index") }; } const queue = byKeyQueue.get(cacheKey); - if (!queue || queue.length === 0) return null; - const next = queue.shift()!; - consumed.add(indexKey(next)); - if (queue.length === 0) byKeyQueue.delete(cacheKey); - return toHit(next); + if (queue && queue.length > 0) { + const next = queue.shift()!; + consumed.add(indexKey(next)); + if (queue.length === 0) byKeyQueue.delete(cacheKey); + return { hit: toHit(next, "compatible_key") }; + } + + const priorAtIndex = priorCalls.find((call) => call.callIndex === callIndex); + if (priorAtIndex) { + if (priorAtIndex.status !== "completed" && priorAtIndex.status !== "from_cache") { + return { miss: { reason: "prior_call_not_replayable" } }; + } + if (priorAtIndex.cacheKey !== cacheKey) { + return { + miss: { + reason: "identity_changed", + changedFields: changedIdentityFields(priorAtIndex, input), + }, + }; + } + return { miss: { reason: "compatible_result_consumed" } }; + } + + if (priorCalls.some((call) => call.cacheKey === cacheKey)) { + return { miss: { reason: "compatible_result_consumed" } }; + } + return { miss: { reason: "no_compatible_call" } }; }, }; } @@ -61,7 +92,15 @@ function removeFromKeyQueue( if (queue.length === 0) map.delete(call.cacheKey); } -function toHit(call: WorkflowAgentCallRecord): WorkflowReplayHit { +function toHit( + call: WorkflowAgentCallRecord, + replayMatch: WorkflowReplayHit["replayMatch"], +): WorkflowReplayHit { + const provenance = { + replayMatch, + replayedFromRunId: call.runId, + replayedFromCallIndex: call.callIndex, + } as const; if (call.structuredJson) { try { return { @@ -69,6 +108,7 @@ function toHit(call: WorkflowAgentCallRecord): WorkflowReplayHit { responseText: call.responseText, structuredJson: call.structuredJson, providerSessionId: call.providerSessionId, + ...provenance, }; } catch (cause) { throw new WorkflowStoredDataError( @@ -82,5 +122,22 @@ function toHit(call: WorkflowAgentCallRecord): WorkflowReplayHit { responseText: call.responseText, structuredJson: call.structuredJson, providerSessionId: call.providerSessionId, + ...provenance, }; } + +function changedIdentityFields( + prior: WorkflowAgentCallRecord, + current: AgentCacheKeyInput, +): Array { + const changed: Array = []; + if (prior.prompt !== current.prompt) changed.push("prompt"); + 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"); + const priorSchema = prior.schemaJson ? JSON.stringify(parseJsonText(prior.schemaJson)) : null; + const currentSchema = current.schema === null ? null : JSON.stringify(current.schema); + if (priorSchema !== currentSchema) changed.push("schema"); + if (prior.isolation !== current.isolation) changed.push("isolation"); + return changed.length > 0 ? changed : ["prompt"]; +} diff --git a/src/workflow-store.test.ts b/src/workflow-store.test.ts index 314ab2895..142e6a2c8 100644 --- a/src/workflow-store.test.ts +++ b/src/workflow-store.test.ts @@ -67,12 +67,15 @@ try { runId: run.id, callIndex: 0, cacheKey: "key-a", + prompt: "review", + schemaJson: JSON.stringify({ type: "object" }), provider: "codex", model: "gpt-5.4", effort: "high", phase: "Review", isolation: "worktree", worktreePath: "/tmp/wt", + replayReason: "identity_changed:prompt", }); store.completeAgentCall({ runId: run.id, @@ -88,15 +91,24 @@ try { assert.equal(call?.dirty, true); assert.equal(call?.providerSessionId, "sess_1"); assert.equal(call?.effort, "high"); + assert.equal(call?.prompt, "review"); + assert.equal(call?.replayReason, "identity_changed:prompt"); store.beginAgentCall({ runId: run.id, callIndex: 1, cacheKey: "key-b", + prompt: "review two", provider: "claude", }); - store.failAgentCall({ runId: run.id, callIndex: 1, error: "boom" }); + 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); const cancelled = store.requestCancel(run.id); diff --git a/src/workflow-store.ts b/src/workflow-store.ts index 085757d2e..0cfdaae3b 100644 --- a/src/workflow-store.ts +++ b/src/workflow-store.ts @@ -50,6 +50,8 @@ export interface BeginAgentCallInput { runId: string; callIndex: number; cacheKey: string; + prompt: string; + schemaJson?: string; provider: string; model?: string; effort?: string; @@ -57,6 +59,10 @@ export interface BeginAgentCallInput { phase?: string; isolation?: AgentIsolationMode; worktreePath?: string; + replayMatch?: "same_index" | "compatible_key"; + replayedFromRunId?: string; + replayedFromCallIndex?: number; + replayReason?: string; } export interface CompleteAgentCallInput { @@ -74,6 +80,7 @@ export interface FailAgentCallInput { runId: string; callIndex: number; error: string; + errorKind?: WorkflowErrorKind; worktreePath?: string; dirty?: boolean; } @@ -132,6 +139,8 @@ 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; @@ -143,6 +152,11 @@ interface WorkflowAgentCallRow { response_text: string | null; structured_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; @@ -562,14 +576,18 @@ export class WorkflowStore { this.database.sqlite .prepare( `insert into workflow_agent_calls ( - run_id, call_index, cache_key, provider, model, effort, label, phase, - status, from_cache, isolation, worktree_path, created_at, started_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, 'running', 'false', ?, ?, ?, ?, ?)`, + run_id, call_index, cache_key, prompt, schema_json, provider, model, effort, 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, @@ -577,6 +595,10 @@ export class WorkflowStore { input.phase ?? null, isolation, input.worktreePath ?? null, + input.replayMatch ?? null, + input.replayedFromRunId ?? null, + input.replayedFromCallIndex ?? null, + input.replayReason ?? null, now, now, now, @@ -630,6 +652,7 @@ export class WorkflowStore { `update workflow_agent_calls set status = 'failed', error = ?, + error_kind = ?, worktree_path = coalesce(?, worktree_path), dirty = ?, completed_at = ?, @@ -638,6 +661,7 @@ export class WorkflowStore { ) .run( input.error, + input.errorKind ?? "internal", input.worktreePath ?? null, input.dirty === undefined ? null : input.dirty ? "true" : "false", now, @@ -756,6 +780,8 @@ function rowToAgentCall(row: WorkflowAgentCallRow): WorkflowAgentCallRecord { 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, @@ -767,6 +793,14 @@ function rowToAgentCall(row: WorkflowAgentCallRow): WorkflowAgentCallRecord { responseText: row.response_text ?? undefined, structuredJson: row.structured_json ?? undefined, error: row.error ?? undefined, + errorKind: (row.error_kind as WorkflowErrorKind | null) ?? undefined, + replayMatch: + row.replay_match === "same_index" || row.replay_match === "compatible_key" + ? row.replay_match + : 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", diff --git a/src/workflow-types.ts b/src/workflow-types.ts index ad9b9d8a0..ad050e1cd 100644 --- a/src/workflow-types.ts +++ b/src/workflow-types.ts @@ -137,6 +137,8 @@ export interface WorkflowAgentCallRecord { runId: string; callIndex: number; cacheKey: string; + prompt: string; + schemaJson?: string; provider: AgentProviderId; model?: string; effort?: string; @@ -148,6 +150,11 @@ export interface WorkflowAgentCallRecord { responseText?: string; structuredJson?: string; error?: string; + errorKind?: WorkflowErrorKind; + replayMatch?: "same_index" | "compatible_key"; + replayedFromRunId?: string; + replayedFromCallIndex?: number; + replayReason?: string; isolation: AgentIsolationMode; worktreePath?: string; dirty?: boolean; From c8f0b65478a9f9ab04b9c3275a8080c683f99b8b Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:37:30 +0000 Subject: [PATCH 024/132] feat(workflow): add script iteration and call inspection --- src/workflow-cli.ts | 158 +++++++++++++++++++++++++++++++++++------- src/workflow-tools.ts | 105 ++++++++++++++++++++-------- 2 files changed, 209 insertions(+), 54 deletions(-) diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts index 5ed5bc2cd..10e3cc0d7 100644 --- a/src/workflow-cli.ts +++ b/src/workflow-cli.ts @@ -29,6 +29,7 @@ import { WORKFLOW_LIMITS, resolveWorkflowConcurrency, type WorkflowEventRecord, + type WorkflowAgentCallRecord, type WorkflowRunRecord, type WorkflowRunSource, } from "./workflow-types.js"; @@ -62,6 +63,12 @@ export async function runWorkflowCommand( case "list": await runWorkflowList(config); return; + case "calls": + await runWorkflowCalls(rest, config); + return; + case "call": + await runWorkflowCall(rest, config); + return; case "__worker": await runWorkflowWorker(rest, config); return; @@ -82,11 +89,13 @@ export function printWorkflowHelp(): void { "DevSpace workflows", "", "Usage:", - " devspace workflow run (--file | --name | --resume )", + " devspace workflow run [--file|--script-path | --name ] [--resume ]", " [--arg key=value]... [--follow]", " devspace workflow status [--follow]", " devspace workflow cancel ", " devspace workflow ls", + " devspace workflow calls ", + " devspace workflow call ", ].join("\n"), ); } @@ -94,18 +103,24 @@ export function printWorkflowHelp(): void { async function runWorkflowRun(args: string[], config: ServerConfig): Promise { const { flags } = splitFlags(args); const follow = flags.has("follow"); - const file = flagValue(flags, "file"); + 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 | --name | --resume )", + "Usage: devspace workflow run [--file|--script-path | --name ] [--resume ]", }); } @@ -125,13 +140,20 @@ async function runWorkflowRun(args: string[], config: ServerConfig): Promise { } } +async function runWorkflowCalls(args: string[], config: ServerConfig): Promise { + const runId = args[0]; + if (!runId) throw new Error("Usage: devspace workflow calls "); + const store = createWorkflowStore(config); + try { + if (!store.getRun(runId)) throw new WorkflowNotFoundError(runId); + const calls = store.listAgentCalls(runId); + 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 runId = args[0]; + const callIndex = Number(args[1]); + if (!runId || !Number.isInteger(callIndex) || callIndex < 0) { + throw new Error("Usage: devspace workflow call "); + } + const store = createWorkflowStore(config); + try { + if (!store.getRun(runId)) throw new WorkflowNotFoundError(runId); + const call = store.getAgentCall(runId, callIndex); + if (!call) throw new Error(`Unknown workflow agent call: ${runId}#${callIndex}`); + console.log(JSON.stringify(formatCallDetail(call), null, 2)); + } finally { + store.close(); + } +} + /** Detached worker entry: claim run, heartbeat, execute, complete/fail. */ export async function runWorkflowWorker( args: string[], @@ -501,10 +555,62 @@ function printEvent(event: WorkflowEventRecord): void { } function formatRunLine( - run: Pick, + run: Pick< + WorkflowRunRecord, + "id" | "status" | "name" | "error" | "scriptPath" | "scriptHash" | "resumedFromRunId" + >, ): string { const err = run.error ? ` error=${JSON.stringify(run.error)}` : ""; - return `${run.id} ${run.status} ${run.name}${err}`; + 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 resolveEnabledProviders( diff --git a/src/workflow-tools.ts b/src/workflow-tools.ts index 7de46bf67..5f8c2c6cf 100644 --- a/src/workflow-tools.ts +++ b/src/workflow-tools.ts @@ -41,7 +41,7 @@ Workflow scripts (JS only): agent(prompt, { label?, phase?, schema?, model?, effort?, provider?, isolation?: 'worktree' }) parallel(thunks) → Array // barrier; throw → null pipeline(items, ...stages) // no cross-item barrier - phase(title); log(msg); args; budget (stub) + phase(title); log(msg); args workflow(name | { scriptPath }, args?) // nest depth 1 Bans: Date.now(), Math.random(), new Date() without args. No writeMode — teach RO vs write in prompts; isolation contains writes. @@ -69,6 +69,10 @@ export function registerWorkflowTools( .optional() .describe("Inline workflow script source (export const meta = …)."), name: z.string().optional().describe("Named workflow under .devspace/workflows/.js"), + scriptPath: z + .string() + .optional() + .describe("Existing workflow script path. May be combined with resumeFromRunId."), resumeFromRunId: z.string().optional().describe("Prior run id to resume (new run + cache)."), args: jsonValueSchema.optional().describe("JSON args passed to script as `args`."), yieldTimeMs: z @@ -82,15 +86,16 @@ export function registerWorkflowTools( annotations: { readOnlyHint: false }, _meta: {}, }, - async ({ workspaceId, script, name, resumeFromRunId, args, yieldTimeMs }) => { + async ({ workspaceId, script, name, scriptPath, resumeFromRunId, args, yieldTimeMs }) => { const workspace = workspaces.getWorkspace(workspaceId); const store = createWorkflowStore(config); try { - const provided = [script, name, resumeFromRunId].filter((v) => v !== undefined); - if (provided.length !== 1) { + const providedSources = [script, name, scriptPath].filter((v) => v !== undefined); + if (providedSources.length > 1 || (providedSources.length === 0 && !resumeFromRunId)) { throw new InvalidWorkflowInputError({ - code: provided.length === 0 ? "missing_source" : "ambiguous_source", - message: "Provide exactly one of script, name, or resumeFromRunId", + code: providedSources.length === 0 ? "missing_source" : "ambiguous_source", + message: + "Provide one of script, name, or scriptPath; resumeFromRunId may accompany that source or reuse the prior script", }); } @@ -105,13 +110,30 @@ export function registerWorkflowTools( const prior = store.getRun(resumeFromRunId); if (!prior) throw new WorkflowNotFoundError(resumeFromRunId); priorRunId = prior.id; - priorScriptPath = prior.scriptPath; - const resolvedResult = await readWorkflowScriptFileResult(prior.scriptPath); - if (resolvedResult.isErr()) throw resolvedResult.error; - const resolved = resolvedResult.value; - source = resolved.source; - scriptHash = prior.scriptHash; - nameHint = prior.name; + const overridePath = scriptPath; + if (script !== undefined) { + source = script; + const overrideParsed = parseWorkflowScript(source); + scriptHash = overrideParsed.scriptHash; + nameHint = overrideParsed.meta.name; + } else if (name) { + const resolvedResult = await resolveNamedWorkflowScriptResult({ + name, + workspaceRoot: workspace.root, + stateDir: config.stateDir, + }); + if (resolvedResult.isErr()) throw resolvedResult.error; + source = resolvedResult.value.source; + scriptHash = resolvedResult.value.scriptHash; + nameHint = resolvedResult.value.nameHint; + } else { + priorScriptPath = overridePath ?? prior.scriptPath; + const resolvedResult = await readWorkflowScriptFileResult(priorScriptPath); + if (resolvedResult.isErr()) throw resolvedResult.error; + source = resolvedResult.value.source; + scriptHash = resolvedResult.value.scriptHash; + nameHint = prior.name; + } runSource = "resume"; if (args === undefined && prior.argsJson && prior.argsJson !== "null") { try { @@ -132,6 +154,12 @@ export function registerWorkflowTools( scriptHash = resolved.scriptHash; nameHint = resolved.nameHint; runSource = "named"; + } else if (scriptPath) { + const resolvedResult = await readWorkflowScriptFileResult(scriptPath); + if (resolvedResult.isErr()) throw resolvedResult.error; + source = resolvedResult.value.source; + scriptHash = resolvedResult.value.scriptHash; + nameHint = resolvedResult.value.nameHint; } else { source = script!; const parsed = parseWorkflowScript(source); @@ -145,7 +173,7 @@ export function registerWorkflowTools( const run = store.createRun({ name: parsed.meta.name || nameHint, source: runSource, - scriptPath: priorScriptPath ?? "pending", + scriptPath: "pending", scriptHash, workspaceRoot: workspace.root, workspaceId, @@ -154,19 +182,16 @@ export function registerWorkflowTools( baseSha, }); - let persisted = priorScriptPath; - if (!persisted) { - const persistedResult = await persistWorkflowScriptResult({ - stateDir: config.stateDir, - runId: run.id, - source, - preferredName: parsed.meta.name || nameHint, - }); - if (persistedResult.isErr()) throw persistedResult.error; - persisted = persistedResult.value; - const updated = store.setScriptPathResult(run.id, persisted); - if (updated.isErr()) throw updated.error; - } + const persistedResult = await persistWorkflowScriptResult({ + stateDir: config.stateDir, + runId: run.id, + source, + preferredName: parsed.meta.name || nameHint, + }); + if (persistedResult.isErr()) throw persistedResult.error; + const persisted = persistedResult.value; + const updated = store.setScriptPathResult(run.id, persisted); + if (updated.isErr()) throw updated.error; const cliEntry = fileURLToPath( import.meta.url.replace(/workflow-tools\.(ts|js)$/, "cli.$1"), @@ -271,6 +296,7 @@ async function yieldEvents( events: WorkflowEventRecord[]; nextSeq: number; terminal: boolean; + callSummary: ReturnType; }> { const deadline = Date.now() + Math.min(yieldMs, WORKFLOW_MCP_YIELD_MS); let cursor = sinceSeq; @@ -288,7 +314,13 @@ async function yieldEvents( await sleep(250); } - return { run, events, nextSeq: cursor, terminal }; + return { + run, + events, + nextSeq: cursor, + terminal, + callSummary: summarizeCalls(store.listAgentCalls(runId)), + }; } function toolResult(page: { @@ -296,10 +328,17 @@ function toolResult(page: { events: WorkflowEventRecord[]; nextSeq: number; terminal: boolean; + callSummary: ReturnType; }) { const payload = { runId: page.run.id, status: page.run.status, + name: page.run.name, + source: page.run.source, + scriptPath: page.run.scriptPath, + scriptHash: page.run.scriptHash, + resumedFromRunId: page.run.resumedFromRunId, + callSummary: page.callSummary, events: page.events.map((e) => ({ seq: e.seq, type: e.type, @@ -318,6 +357,16 @@ function toolResult(page: { }; } +function summarizeCalls(calls: ReturnType["listAgentCalls"]>) { + return { + reused: calls.filter((call) => call.fromCache).length, + live: calls.filter((call) => !call.fromCache && call.status === "completed").length, + failed: calls.filter((call) => call.status === "failed").length, + running: calls.filter((call) => call.status === "running").length, + total: calls.length, + }; +} + function workflowToolError(error: Parameters[0]) { const payload = { error: serializeWorkflowError(error) }; return { From 058297bff7fbec8be35811c60c173dc7e5ae2a1b Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:37:30 +0000 Subject: [PATCH 025/132] docs(workflow): teach recovery without budget stub --- skills/dynamic-workflows/SKILL.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/skills/dynamic-workflows/SKILL.md b/skills/dynamic-workflows/SKILL.md index ccb34570f..87322cb81 100644 --- a/skills/dynamic-workflows/SKILL.md +++ b/skills/dynamic-workflows/SKILL.md @@ -17,11 +17,14 @@ review, migrate-and-verify, research panels — **not** a single subagent turn. ```bash devspace workflow run --file path/to/script.js [--arg k=v]... [--follow] +devspace workflow run --script-path path/to/script.js [--resume ] [--follow] devspace workflow run --name review-auth [--follow] devspace workflow run --resume devspace workflow status [--follow] devspace workflow cancel devspace workflow ls +devspace workflow calls +devspace workflow call ``` Named scripts: `.devspace/workflows/.js` or `workflows/.js`. @@ -57,7 +60,6 @@ return { summary, findings } | `pipeline(items, ...stages)` | Per-item chains; no cross-item barrier | | `phase(title)` / `log(msg)` | Progress; journaled | | `args` | Run input (object preferred) | -| `budget` | Stub: `total: null`, `remaining(): Infinity` — do not loop on budget alone | | `workflow(name\|{scriptPath}, args?)` | Nested, depth 1, shared call index | **No `writeMode`.** Teach read-only vs write in the prompt. Use `isolation: 'worktree'` when parallel mutators would conflict (git required). @@ -86,7 +88,25 @@ Default: first **enabled ∩ available** provider (`agentProviders.enabled` in c ### Resume -`devspace workflow run --resume ` creates a **new** run that replays completed agent calls by cache key (callIndex+key, then consume-once by key). +Failed and cancelled runs are terminal. Recovery creates a **new** run: + +1. Inspect the prior run with `workflow status`, `workflow calls`, and + `workflow call`. +2. Edit the persisted `scriptPath` reported by the run, or pass a different + `--script-path`. +3. Keep prompts and agent options stable for completed calls whose return values + should be reused. +4. Run `devspace workflow run --resume ` (optionally with + `--script-path `). + +Replay first matches the same call index and cache key, then consumes one +compatible prior cache key after reordering. The new run records whether each +call was reused by same-index or compatible-key matching, and where it came +from. Failed, interrupted, changed, or unmatched calls execute live. + +Replay restores an agent's **return value**. It does not recreate shared-checkout +edits or reapply a prior worktree diff. Verify required filesystem state before +depending on a replayed mutating call. ### Cancel From 332aa8e7a6ef44a852f4f8154dbf54e027edf645 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:30:41 +0000 Subject: [PATCH 026/132] feat(workflow): query runs by workspace --- src/workflow-store.test.ts | 26 ++++++++++++++++++++ src/workflow-store.ts | 49 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/workflow-store.test.ts b/src/workflow-store.test.ts index 142e6a2c8..3865a4f2e 100644 --- a/src/workflow-store.test.ts +++ b/src/workflow-store.test.ts @@ -135,6 +135,32 @@ try { 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"), + }); + assert.deepEqual( + store + .listRunsForWorkspace(join(root, "project")) + .map((entry) => entry.id) + .sort(), + [run.id, run2.id].sort(), + ); + 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.seq), [2, 3]); + // Reap: stale heartbeat + dead pid (force heartbeat via shared sqlite handle) const run3 = store.createRun({ name: "stale", diff --git a/src/workflow-store.ts b/src/workflow-store.ts index 0cfdaae3b..7568dce59 100644 --- a/src/workflow-store.ts +++ b/src/workflow-store.ts @@ -248,6 +248,40 @@ export class WorkflowStore { 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); + } + /** * Atomically claim a starting run for the worker. * Returns undefined if the run is missing or not claimable. @@ -570,6 +604,21 @@ export class WorkflowStore { }; } + 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); + } + beginAgentCall(input: BeginAgentCallInput): WorkflowAgentCallRecord { const now = isoNow(); const isolation: AgentIsolationMode = input.isolation === "worktree" ? "worktree" : "shared"; From a499c2e8b0a35ce48fba813f170917cd99bd225d Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:32:37 +0000 Subject: [PATCH 027/132] feat(workflow): project workflow read model --- package.json | 2 +- src/workflow-view.test.ts | 114 +++++++++++++++++ src/workflow-view.ts | 259 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 src/workflow-view.test.ts create mode 100644 src/workflow-view.ts diff --git a/package.json b/package.json index f30a7c989..0b4ed820f 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/workflow-view.test.ts b/src/workflow-view.test.ts new file mode 100644 index 000000000..a96ddd8be --- /dev/null +++ b/src/workflow-view.test.ts @@ -0,0 +1,114 @@ +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", + 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", + 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.deepEqual(view.phases.map((phase) => phase.title), ["Planning", "Implementation"]); +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 000000000..218bc2466 --- /dev/null +++ b/src/workflow-view.ts @@ -0,0 +1,259 @@ +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, +} 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" | "compatible_key"; + replayedFromRunId?: string; + replayedFromCallIndex?: number; + replayReason?: string; + error?: string; + errorKind?: WorkflowErrorKind; + startedAt?: string; + completedAt?: string; + updatedAt: string; +} + +export interface WorkflowPhaseView { + title: string; + 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; + 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 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 phases = phaseOrder.map((title) => ({ + title, + 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), + 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, + startedAt: call.startedAt, + completedAt: call.completedAt, + updatedAt: call.updatedAt, + }; +} + +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; + } +} From 5ee0022e94edaab38ebc7701860247eaeb9c390c Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:36:10 +0000 Subject: [PATCH 028/132] feat(workflow): add read-only project TUI --- package.json | 2 +- skills/dynamic-workflows/SKILL.md | 2 + src/workflow-cli.ts | 6 + src/workflow-tui.test.ts | 71 +++++++ src/workflow-tui.ts | 304 ++++++++++++++++++++++++++++++ 5 files changed, 384 insertions(+), 1 deletion(-) create mode 100644 src/workflow-tui.test.ts create mode 100644 src/workflow-tui.ts diff --git a/package.json b/package.json index 0b4ed820f..77f4d9821 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/skills/dynamic-workflows/SKILL.md b/skills/dynamic-workflows/SKILL.md index 87322cb81..d0ba95620 100644 --- a/skills/dynamic-workflows/SKILL.md +++ b/skills/dynamic-workflows/SKILL.md @@ -25,6 +25,7 @@ devspace workflow cancel devspace workflow ls devspace workflow calls devspace workflow call +devspace workflow tui [runId] ``` Named scripts: `.devspace/workflows/.js` or `workflows/.js`. @@ -115,6 +116,7 @@ depending on a replayed mutating call. ## When to use CLI vs MCP - **CLI**: host agent can shell; prefer for long runs + `--follow`. +- **TUI**: `devspace workflow tui` opens a read-only live view for workflows associated with the current working directory. - **MCP**: ChatGPT plans; call `run_workflow`, then `workflow_status` until terminal. Disconnecting MCP does **not** kill the worker. ## Worked mini-examples diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts index 10e3cc0d7..1dbc7bdc4 100644 --- a/src/workflow-cli.ts +++ b/src/workflow-cli.ts @@ -69,6 +69,11 @@ export async function runWorkflowCommand( 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; @@ -96,6 +101,7 @@ export function printWorkflowHelp(): void { " devspace workflow ls", " devspace workflow calls ", " devspace workflow call ", + " devspace workflow tui [runId] # current working directory", ].join("\n"), ); } diff --git a/src/workflow-tui.test.ts b/src/workflow-tui.test.ts new file mode 100644 index 000000000..596965885 --- /dev/null +++ b/src/workflow-tui.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { + 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, + }, + phases: [ + { + title: "Implementation", + calls: [ + { + callIndex: 1, + status: "running", + provider: "codex", + label: "Patch auth", + isolation: "worktree", + fromCache: false, + updatedAt: "2026-07-26T10:00:02.000Z", + }, + ], + }, + ], + unphasedCalls: [], + 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", + }, + ], +}; + +const rendered = renderWorkflowTui(project, 0, 100, 30, { ansi: false }); +assert.match(rendered, /DevSpace workflows · \/tmp\/project/); +assert.match(rendered, /Review auth · Implementation/); +assert.match(rendered, /Patch auth codex · worktree/); +assert.match(rendered, /Running tests/); +assert.match(rendered, /refreshes automatically/); +assert.equal(resolveWorkflowTuiWorkspaceRoot("./test-project").endsWith("test-project"), 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 000000000..5cdc0e324 --- /dev/null +++ b/src/workflow-tui.ts @@ -0,0 +1,304 @@ +import { resolve } from "node:path"; +import { emitKeypressEvents } from "node:readline"; +import type { ServerConfig } from "./config.js"; +import { createWorkflowStore } from "./workflow-store.js"; +import { + ACTIVE_WORKFLOW_STATUSES, + loadWorkflowProjectView, + type WorkflowCallView, + type WorkflowProjectView, + type WorkflowRunView, +} from "./workflow-view.js"; + +const REFRESH_MS = 750; + +export async function runWorkflowTui( + args: string[], + config: ServerConfig, +): Promise { + const requestedRunId = args.find((arg) => !arg.startsWith("-")); + const workspaceRoot = resolveWorkflowTuiWorkspaceRoot(); + const store = createWorkflowStore(config); + + const load = (): WorkflowProjectView => + loadWorkflowProjectView(store, workspaceRoot, { + statuses: requestedRunId ? undefined : [...ACTIVE_WORKFLOW_STATUSES], + limit: 50, + eventLimit: 100, + }); + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + try { + const view = load(); + const selectedIndex = findInitialSelection(view, requestedRunId); + process.stdout.write( + `${renderWorkflowTui(view, selectedIndex, 100, 40, { ansi: false })}\n`, + ); + return; + } finally { + store.close(); + } + } + + let project = load(); + let selectedIndex = findInitialSelection(project, requestedRunId); + let closed = false; + let rendering = false; + + const render = (): void => { + if (rendering || closed) return; + rendering = true; + try { + project = load(); + selectedIndex = clampSelection(project, selectedIndex, requestedRunId); + process.stdout.write( + `\u001b[H\u001b[2J${renderWorkflowTui( + project, + selectedIndex, + process.stdout.columns || 100, + process.stdout.rows || 40, + { ansi: true }, + )}`, + ); + } finally { + rendering = false; + } + }; + + await new Promise((done) => { + let timer: NodeJS.Timeout; + + const finish = (): void => { + if (closed) return; + closed = true; + clearInterval(timer); + process.stdin.off("keypress", onKeypress); + process.stdout.off("resize", render); + process.off("SIGINT", finish); + process.stdin.setRawMode(false); + process.stdin.pause(); + process.stdout.write("\u001b[?25h\u001b[?1049l"); + store.close(); + done(); + }; + + const onKeypress = ( + _input: string, + key: { name?: string; ctrl?: boolean }, + ): void => { + if ((key.ctrl && key.name === "c") || key.name === "q" || key.name === "escape") { + finish(); + return; + } + if (key.name === "up") { + selectedIndex = Math.max(0, selectedIndex - 1); + render(); + } else if (key.name === "down") { + selectedIndex = Math.min(Math.max(0, project.runs.length - 1), selectedIndex + 1); + 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()): string { + return resolve(cwd); +} + +export function renderWorkflowTui( + project: WorkflowProjectView, + selectedIndex: number, + columns: number, + rows: number, + options: { ansi?: boolean } = {}, +): string { + const ansi = options.ansi !== false; + const width = Math.max(48, columns); + const selected = project.runs[selectedIndex]; + const lines: string[] = []; + + lines.push(style(truncate(`DevSpace workflows · ${project.workspaceRoot}`, width), "bold", ansi)); + lines.push(rule(width)); + + if (project.runs.length === 0) { + lines.push("No active workflows in the current directory."); + lines.push(""); + lines.push(style("q quit", "muted", ansi)); + return fitRows(lines, rows).join("\n"); + } + + const maxRunRows = Math.max(3, Math.min(8, Math.floor(rows / 4))); + lines.push(style("Active workflows", "heading", ansi)); + for (const [index, run] of project.runs.slice(0, maxRunRows).entries()) { + const marker = index === selectedIndex ? "›" : " "; + const phase = run.currentPhase ? ` · ${run.currentPhase}` : ""; + lines.push( + truncate( + `${marker} ${statusGlyph(run.status)} ${run.name}${phase} · ${callSummary(run)}`, + width, + ), + ); + } + if (project.runs.length > maxRunRows) { + lines.push(style(` +${project.runs.length - maxRunRows} more`, "muted", ansi)); + } + + lines.push(rule(width)); + if (selected) renderRunDetails(lines, selected, width, rows, ansi); + lines.push(rule(width)); + lines.push(style("↑/↓ select · q/esc quit · refreshes automatically", "muted", ansi)); + return fitRows(lines, rows).join("\n"); +} + +function renderRunDetails( + lines: string[], + run: WorkflowRunView, + width: number, + rows: number, + ansi: boolean, +): void { + lines.push(`${style(run.name, "bold", ansi)} ${statusGlyph(run.status)} ${run.status}`); + lines.push( + truncate( + `${run.currentPhase ? `Phase: ${run.currentPhase} · ` : ""}${callSummary(run)} · ${elapsedLabel(run)}`, + width, + ), + ); + + const phaseBudget = Math.max(4, Math.floor(rows / 2)); + let renderedCalls = 0; + for (const phase of run.phases) { + if (renderedCalls >= phaseBudget) break; + lines.push(style(`\n${phase.title}`, "heading", ansi)); + for (const call of phase.calls) { + if (renderedCalls >= phaseBudget) break; + lines.push(truncate(formatCall(call), width)); + renderedCalls += 1; + } + } + if (run.unphasedCalls.length > 0 && renderedCalls < phaseBudget) { + lines.push(style("\nOther calls", "heading", ansi)); + for (const call of run.unphasedCalls) { + if (renderedCalls >= phaseBudget) break; + lines.push(truncate(formatCall(call), width)); + renderedCalls += 1; + } + } + + const activity = run.recentActivity.slice(-4); + if (activity.length > 0) { + lines.push(style("\nRecent activity", "heading", ansi)); + for (const event of activity) { + const time = new Date(event.createdAt).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + const label = event.label ?? event.phase ?? event.type.replaceAll("_", " "); + const detail = event.detail ? `: ${event.detail}` : ""; + lines.push(truncate(`${time} ${label}${detail}`, width)); + } + } + + if (run.error) { + lines.push(style(`\n${run.errorKind ?? "error"}: ${run.error}`, "error", ansi)); + } +} + +function findInitialSelection( + project: WorkflowProjectView, + requestedRunId: string | undefined, +): number { + if (!requestedRunId) return 0; + const index = project.runs.findIndex((run) => run.id === requestedRunId); + if (index < 0) { + throw new Error( + `Workflow ${requestedRunId} does not belong to the current directory: ${project.workspaceRoot}`, + ); + } + return index; +} + +function clampSelection( + project: WorkflowProjectView, + selectedIndex: number, + requestedRunId: string | undefined, +): number { + if (requestedRunId) return findInitialSelection(project, requestedRunId); + return Math.min(Math.max(0, selectedIndex), Math.max(0, project.runs.length - 1)); +} + +function formatCall(call: WorkflowCallView): string { + const label = call.label ?? `Agent #${call.callIndex}`; + const provider = call.model ? `${call.provider}/${call.model}` : call.provider; + const worktree = call.isolation === "worktree" ? " · worktree" : ""; + const replay = call.fromCache ? " · replayed" : ""; + const error = call.error ? ` · ${call.errorKind ?? "error"}: ${call.error}` : ""; + return ` ${statusGlyph(call.status)} ${label} ${provider}${worktree}${replay}${error}`; +} + +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, + run.calls.cancelled ? `${run.calls.cancelled} cancelled` : undefined, + ].filter((part): part is string => Boolean(part)); + return parts.length > 0 ? parts.join(" · ") : "no agent calls yet"; +} + +function elapsedLabel(run: WorkflowRunView): string { + const start = Date.parse(run.startedAt ?? run.createdAt); + const end = run.completedAt ? Date.parse(run.completedAt) : Date.now(); + const seconds = Math.max(0, Math.floor((end - start) / 1_000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remaining = seconds % 60; + if (minutes < 60) return `${minutes}m ${remaining}s`; + return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; +} + +function statusGlyph(status: WorkflowRunView["status"] | WorkflowCallView["status"]): string { + if (status === "completed" || status === "from_cache") return "✓"; + if (status === "failed") return "✕"; + if (status === "cancelled") return "−"; + if (status === "running") return "●"; + return "◌"; +} + +function rule(width: number): string { + return "─".repeat(width); +} + +function truncate(value: string, width: number): string { + if (value.length <= width) return value; + return `${value.slice(0, Math.max(0, width - 1))}…`; +} + +function fitRows(lines: string[], rows: number): string[] { + if (rows <= 0 || lines.length <= rows) return lines; + return lines.slice(0, Math.max(1, rows)); +} + +function style( + value: string, + tone: "bold" | "heading" | "muted" | "error", + 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`; + if (tone === "error") return `\u001b[31m${value}\u001b[0m`; + return `\u001b[2m${value}\u001b[0m`; +} From dbe26f7d8590b1d88becaa308c6b39036964d597 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:40:32 +0000 Subject: [PATCH 029/132] feat(workflow): add UI snapshot projections --- package.json | 2 +- src/workflow-ui.test.ts | 64 ++++++++++++++++++ src/workflow-ui.ts | 142 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 src/workflow-ui.test.ts create mode 100644 src/workflow-ui.ts diff --git a/package.json b/package.json index 77f4d9821..3f17654a7 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-ui.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/workflow-ui.test.ts b/src/workflow-ui.test.ts new file mode 100644 index 000000000..1621ce486 --- /dev/null +++ b/src/workflow-ui.test.ts @@ -0,0 +1,64 @@ +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, + loadWorkflowUiCallDetail, + loadWorkflowUiProject, + loadWorkflowUiRun, +} from "./workflow-ui.js"; + +const root = mkdtempSync(join(tmpdir(), "devspace-workflow-ui-test-")); +const store = new WorkflowStore(root); + +try { + const workspaceRoot = join(root, "project"); + const run = store.createRun({ + name: "UI run", + source: "named", + scriptPath: join(root, "run.js"), + scriptHash: "abc", + workspaceRoot, + }); + store.claimRun(run.id, process.pid); + store.appendEvent({ + runId: run.id, + type: "phase_started", + phase: "Review", + data: { title: "Review" }, + }); + store.beginAgentCall({ + runId: run.id, + callIndex: 0, + cacheKey: "key", + prompt: "Review auth", + schemaJson: JSON.stringify({ type: "object" }), + provider: "codex", + label: "Auth review", + phase: "Review", + isolation: "worktree", + worktreePath: join(root, "wt"), + }); + + const summaries = loadActiveWorkflowSummaries(store, workspaceRoot); + assert.equal(summaries[0]?.id, run.id); + assert.equal(summaries[0]?.currentPhase, "Review"); + assert.equal(summaries[0]?.calls.running, 1); + + const project = loadWorkflowUiProject(store, workspaceRoot); + assert.equal(project.runs[0]?.phases[0]?.title, "Review"); + assert.equal(loadWorkflowUiRun(store, run.id)?.name, "UI run"); + + const detail = loadWorkflowUiCallDetail(store, run.id, 0); + assert.equal(detail?.prompt, "Review auth"); + assert.deepEqual(detail?.schema, { type: "object" }); + assert.equal(detail?.worktreePath, join(root, "wt")); + assert.equal(loadWorkflowUiCallDetail(store, run.id, 99), undefined); +} finally { + store.close(); + rmSync(root, { recursive: true, force: true }); +} + +console.log("workflow-ui.test.ts: ok"); diff --git a/src/workflow-ui.ts b/src/workflow-ui.ts new file mode 100644 index 000000000..b97b526ad --- /dev/null +++ b/src/workflow-ui.ts @@ -0,0 +1,142 @@ +import type { JsonValue } from "./json-types.js"; +import type { WorkflowStore } from "./workflow-store.js"; +import { + ACTIVE_WORKFLOW_STATUSES, + buildWorkflowRunView, + loadWorkflowProjectView, + type WorkflowCallCounts, + type WorkflowProjectView, + type WorkflowRunView, +} from "./workflow-view.js"; + +export interface WorkflowRunSummaryView { + id: string; + name: string; + status: WorkflowRunView["status"]; + currentPhase?: string; + calls: WorkflowCallCounts; + updatedAt: string; +} + +export interface WorkflowCallDetailView { + runId: string; + callIndex: number; + status: string; + provider: string; + model?: string; + effort?: string; + label?: string; + phase?: string; + prompt: string; + schema?: JsonValue | string; + responseText?: string; + structured?: JsonValue | string; + error?: string; + errorKind?: string; + providerSessionId?: string; + isolation: "shared" | "worktree"; + worktreePath?: string; + dirty?: boolean; + fromCache: boolean; + replayMatch?: "same_index" | "compatible_key"; + replayedFromRunId?: string; + replayedFromCallIndex?: number; + replayReason?: string; + createdAt: string; + startedAt?: string; + completedAt?: string; + updatedAt: string; +} + +export function loadActiveWorkflowSummaries( + store: WorkflowStore, + workspaceRoot: string, +): WorkflowRunSummaryView[] { + return loadWorkflowProjectView(store, workspaceRoot, { + statuses: [...ACTIVE_WORKFLOW_STATUSES], + limit: 50, + eventLimit: 50, + }).runs.map(summarizeWorkflowRun); +} + +export function loadWorkflowUiProject( + store: WorkflowStore, + workspaceRoot: string, +): WorkflowProjectView { + return loadWorkflowProjectView(store, workspaceRoot, { + statuses: [...ACTIVE_WORKFLOW_STATUSES], + limit: 50, + eventLimit: 100, + }); +} + +export function loadWorkflowUiRun( + store: WorkflowStore, + runId: string, +): WorkflowRunView | undefined { + const run = store.getRun(runId); + if (!run) return undefined; + return buildWorkflowRunView( + run, + store.listAgentCalls(run.id), + store.listEvents(run.id, 100), + ); +} + +export function loadWorkflowUiCallDetail( + store: WorkflowStore, + runId: string, + callIndex: number, +): WorkflowCallDetailView | undefined { + const call = store.getAgentCall(runId, callIndex); + if (!call) return undefined; + return { + runId, + callIndex, + status: call.status, + provider: call.provider, + model: call.model, + effort: call.effort, + label: call.label, + phase: call.phase, + prompt: call.prompt, + schema: parseStoredJson(call.schemaJson), + responseText: call.responseText, + structured: parseStoredJson(call.structuredJson), + error: call.error, + errorKind: call.errorKind, + providerSessionId: call.providerSessionId, + isolation: call.isolation, + worktreePath: call.worktreePath, + dirty: call.dirty, + fromCache: call.fromCache, + replayMatch: call.replayMatch, + replayedFromRunId: call.replayedFromRunId, + replayedFromCallIndex: call.replayedFromCallIndex, + replayReason: call.replayReason, + createdAt: call.createdAt, + startedAt: call.startedAt, + completedAt: call.completedAt, + updatedAt: call.updatedAt, + }; +} + +export function summarizeWorkflowRun(run: WorkflowRunView): WorkflowRunSummaryView { + return { + id: run.id, + name: run.name, + status: run.status, + currentPhase: run.currentPhase, + calls: run.calls, + updatedAt: run.updatedAt, + }; +} + +function parseStoredJson(value: string | undefined): JsonValue | string | undefined { + if (value === undefined) return undefined; + try { + return JSON.parse(value) as JsonValue; + } catch { + return value; + } +} From d3643e8b40c323e436dde97663d8d084439ea6b8 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:43:46 +0000 Subject: [PATCH 030/132] feat(workflow): expose read-only app snapshots --- src/server.ts | 33 ++++++++ src/workflow-tools.ts | 180 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 208 insertions(+), 5 deletions(-) diff --git a/src/server.ts b/src/server.ts index c44f50328..4f4d2bab4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -48,6 +48,8 @@ import { createWorkspaceStore } from "./workspace-store.js"; import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; import { summarizeLocalAgentProfile } from "./local-agent-profiles.js"; import { registerWorkflowTools } from "./workflow-tools.js"; +import { createWorkflowStore } from "./workflow-store.js"; +import { loadActiveWorkflowSummaries } from "./workflow-ui.js"; import { formatLocalAgentProviderAvailabilitySummary, getLocalAgentProviderAvailabilitySnapshot, @@ -264,6 +266,24 @@ const workspaceAvailableAgentsFileOutputSchema = z.object({ path: z.string(), }); +const workflowCallCountsOutputSchema = z.object({ + running: z.number(), + completed: z.number(), + cached: z.number(), + failed: z.number(), + cancelled: z.number(), + observed: z.number(), +}); + +const workflowRunSummaryOutputSchema = z.object({ + id: z.string(), + name: z.string(), + status: z.enum(["starting", "running", "completed", "failed", "cancelled"]), + currentPhase: z.string().optional(), + calls: workflowCallCountsOutputSchema, + updatedAt: z.string(), +}); + const reviewFileOutputSchema = z.object({ path: z.string(), previousPath: z.string().optional(), @@ -779,6 +799,7 @@ function createMcpServer( agentProviders: z.array(workspaceLocalAgentProviderOutputSchema), agents: z.array(workspaceLocalAgentOutputSchema), skillDiagnostics: z.array(z.unknown()), + activeWorkflows: z.array(workflowRunSummaryOutputSchema), instruction: z.string(), }, ...toolWidgetDescriptorMeta(config, "workspace"), @@ -817,6 +838,16 @@ function createMcpServer( const availableAgentsFileOutputs = availableAgentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), })); + const activeWorkflows = config.subagents + ? (() => { + const workflowStore = createWorkflowStore(config); + try { + return loadActiveWorkflowSummaries(workflowStore, workspace.root); + } finally { + workflowStore.close(); + } + })() + : []; const instruction = config.skillsEnabled ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; @@ -870,6 +901,7 @@ function createMcpServer( agentsFiles: loadedAgentsFiles.length, availableAgentsFiles: availableAgentsFileOutputs.length, skills: visibleSkills.length, + activeWorkflows: activeWorkflows.length, agentProviders: visibleAgentProviders.length, agents: visibleAgents.length, skillDiagnostics: workspace.skillDiagnostics.length, @@ -885,6 +917,7 @@ function createMcpServer( agentsFiles: loadedAgentsFiles, availableAgentsFiles: availableAgentsFileOutputs, skills: visibleSkills, + activeWorkflows, agentProviders: visibleAgentProviders, agents: visibleAgents, skillDiagnostics: workspace.skillDiagnostics, diff --git a/src/workflow-tools.ts b/src/workflow-tools.ts index 5f8c2c6cf..1a329aca0 100644 --- a/src/workflow-tools.ts +++ b/src/workflow-tools.ts @@ -34,6 +34,14 @@ import { WorkflowNotFoundError, WorkflowStoredDataError, } from "./workflow-errors.js"; +import { + loadWorkflowUiCallDetail, + loadWorkflowUiProject, + loadWorkflowUiRun, +} from "./workflow-ui.js"; + +const WORKSPACE_APP_URI = "ui://devspace/workspace-app.html"; +const WORKFLOW_UI_WAIT_MAX_MS = 30_000; const WORKFLOW_API_CHEATSHEET = ` Workflow scripts (JS only): @@ -84,7 +92,7 @@ export function registerWorkflowTools( .describe(`Ms to wait for early completion (default 2000, max ${WORKFLOW_MCP_YIELD_MS}).`), }, annotations: { readOnlyHint: false }, - _meta: {}, + _meta: workflowWidgetMeta(config), }, async ({ workspaceId, script, name, scriptPath, resumeFromRunId, args, yieldTimeMs }) => { const workspace = workspaces.getWorkspace(workspaceId); @@ -200,7 +208,7 @@ export function registerWorkflowTools( const yieldMs = yieldTimeMs ?? 2_000; const page = await yieldEvents(store, run.id, 0, yieldMs); - return toolResult(page); + return toolResult(page, "run_workflow"); } catch (error) { if (isWorkflowOperationError(error)) return workflowToolError(error); throw error; @@ -228,14 +236,14 @@ export function registerWorkflowTools( .describe(`Long-poll ms (default 0, max ${WORKFLOW_MCP_YIELD_MS}).`), }, annotations: { readOnlyHint: true }, - _meta: {}, + _meta: workflowWidgetMeta(config), }, async ({ runId, sinceSeq, yieldTimeMs }) => { const store = createWorkflowStore(config); try { if (!store.getRun(runId)) throw new WorkflowNotFoundError(runId); const page = await yieldEvents(store, runId, sinceSeq ?? 0, yieldTimeMs ?? 0); - return toolResult(page); + return toolResult(page, "workflow_status"); } catch (error) { if (isWorkflowOperationError(error)) return workflowToolError(error); throw error; @@ -284,7 +292,104 @@ export function registerWorkflowTools( }, ); + if (config.widgets !== "off") { + registerWorkflowUiTools(server, config, workspaces); } +} + +function registerWorkflowUiTools( + server: McpServer, + config: ServerConfig, + workspaces: WorkspaceRegistry, +): void { + registerAppTool( + server, + "workspace_workflow_activity", + { + title: "Workspace workflow activity", + description: "Read-only workflow activity for the DevSpace app.", + inputSchema: { + workspaceId: z.string(), + knownVersion: z.string().optional(), + waitMs: z.number().int().min(0).max(WORKFLOW_UI_WAIT_MAX_MS).optional(), + }, + annotations: { readOnlyHint: true }, + _meta: appOnlyToolMeta(), + }, + async ({ workspaceId, knownVersion, waitMs }) => { + const workspace = workspaces.getWorkspace(workspaceId); + const store = createWorkflowStore(config); + try { + const project = await waitForProjectSnapshot( + store, + workspace.root, + knownVersion, + waitMs ?? 0, + ); + return appToolResult({ workspaceId, project }); + } finally { + store.close(); + } + }, + ); + + registerAppTool( + server, + "workflow_ui_snapshot", + { + title: "Workflow UI snapshot", + description: "Read-only workflow snapshot for the DevSpace app.", + inputSchema: { + runId: z.string(), + knownVersion: z.string().optional(), + waitMs: z.number().int().min(0).max(WORKFLOW_UI_WAIT_MAX_MS).optional(), + }, + annotations: { readOnlyHint: true }, + _meta: appOnlyToolMeta(), + }, + async ({ runId, knownVersion, waitMs }) => { + const store = createWorkflowStore(config); + try { + const run = await waitForRunSnapshot(store, runId, knownVersion, waitMs ?? 0); + if (!run) throw new WorkflowNotFoundError(runId); + return appToolResult({ run }); + } finally { + store.close(); + } + }, + ); + + registerAppTool( + server, + "workflow_ui_call_detail", + { + title: "Workflow call detail", + description: "Read-only workflow call detail for the DevSpace app.", + inputSchema: { + runId: z.string(), + callIndex: z.number().int().min(0), + }, + annotations: { readOnlyHint: true }, + _meta: appOnlyToolMeta(), + }, + async ({ runId, callIndex }) => { + const store = createWorkflowStore(config); + try { + if (!store.getRun(runId)) throw new WorkflowNotFoundError(runId); + const call = loadWorkflowUiCallDetail(store, runId, callIndex); + if (!call) { + throw new InvalidWorkflowInputError({ + code: "invalid_argument", + message: `Unknown workflow agent call: ${runId}#${callIndex}`, + }); + } + return appToolResult({ call }); + } finally { + store.close(); + } + }, + ); +} async function yieldEvents( store: ReturnType, @@ -329,7 +434,7 @@ function toolResult(page: { nextSeq: number; terminal: boolean; callSummary: ReturnType; -}) { +}, tool: "run_workflow" | "workflow_status") { const payload = { runId: page.run.id, status: page.run.status, @@ -354,9 +459,74 @@ function toolResult(page: { return { content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }], structuredContent: payload, + _meta: { + tool, + card: { + runId: page.run.id, + status: page.run.status, + name: page.run.name, + }, + }, + }; +} + +function workflowWidgetMeta(config: ServerConfig) { + if (config.widgets !== "full") return {}; + return { + ui: { + resourceUri: WORKSPACE_APP_URI, + visibility: ["model"] as const, + }, + }; +} + +function appOnlyToolMeta() { + return { + ui: { + visibility: ["app"] as const, + }, }; } +function appToolResult(structuredContent: Record) { + return { + content: [{ type: "text" as const, text: JSON.stringify(structuredContent) }], + structuredContent, + }; +} + +async function waitForProjectSnapshot( + store: ReturnType, + workspaceRoot: string, + knownVersion: string | undefined, + waitMs: number, +) { + const deadline = Date.now() + Math.min(waitMs, WORKFLOW_UI_WAIT_MAX_MS); + for (;;) { + const project = loadWorkflowUiProject(store, workspaceRoot); + if (!knownVersion || project.version !== knownVersion || Date.now() >= deadline) { + return project; + } + await sleep(250); + } +} + +async function waitForRunSnapshot( + store: ReturnType, + runId: string, + knownVersion: string | undefined, + waitMs: number, +) { + const deadline = Date.now() + Math.min(waitMs, WORKFLOW_UI_WAIT_MAX_MS); + for (;;) { + const run = loadWorkflowUiRun(store, runId); + if (!run || !knownVersion || run.version !== knownVersion || Date.now() >= deadline) { + return run; + } + await sleep(250); + } +} + function summarizeCalls(calls: ReturnType["listAgentCalls"]>) { return { reused: calls.filter((call) => call.fromCache).length, From 57296a6c48aeed2f4af47932980fdcaf3527b441 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:44:47 +0000 Subject: [PATCH 031/132] feat(ui): recognize workflow result cards --- src/ui/card-types.ts | 35 +++++++++++++++++++++++++++++++++++ src/ui/icons.ts | 6 ++++++ src/ui/tool-display.test.ts | 11 +++++++++++ src/ui/tool-display.ts | 24 ++++++++++++++++++++++++ 4 files changed, 76 insertions(+) diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index 1e3c9409f..2f8cb2098 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -1,7 +1,10 @@ import type { App } from "@modelcontextprotocol/ext-apps"; +import type { WorkflowRunSummaryView } from "../workflow-ui.js"; export type ToolName = | "open_workspace" + | "run_workflow" + | "workflow_status" | "show_changes" | "apply_patch" | "exec_command" @@ -24,6 +27,8 @@ export interface ToolResultCard { path?: string; root?: string; status?: string; + name?: string; + runId?: string; summary?: Record; files?: Array<{ path?: string; @@ -46,6 +51,28 @@ export interface ToolResultCard { description?: string; path?: string; }>; + activeWorkflows?: WorkflowRunSummaryView[]; + callSummary?: { + reused?: number; + live?: number; + failed?: number; + running?: number; + total?: number; + }; + agentProviders?: Array<{ + name?: string; + available?: boolean; + reason?: string; + }>; + agents?: Array<{ + name?: string; + description?: string; + provider?: string; + model?: string; + effort?: string; + providerAvailable?: boolean; + providerUnavailableReason?: string; + }>; skillDiagnostics?: unknown[]; instruction?: string; } @@ -66,6 +93,8 @@ export interface ToolPayload { export function isToolName(value: unknown): value is ToolName { return ( value === "open_workspace" || + value === "run_workflow" || + value === "workflow_status" || value === "show_changes" || value === "apply_patch" || value === "exec_command" || @@ -108,6 +137,10 @@ export function isReviewTool(tool: ToolName): boolean { return tool === "show_changes"; } +export function isWorkflowTool(tool: ToolName): boolean { + return tool === "run_workflow" || tool === "workflow_status"; +} + export function isToolResultCard(value: unknown): value is Omit { return Boolean(value && typeof value === "object"); } @@ -145,6 +178,8 @@ export function isExpandableCard(card: ToolResultCard): boolean { ); } + if (isWorkflowTool(card.tool)) return Boolean(card.runId); + if (isReviewTool(card.tool)) return Boolean(card.files?.length || card.payload?.patch); if (isPatchTool(card.tool)) return Boolean(card.payload?.patch); diff --git a/src/ui/icons.ts b/src/ui/icons.ts index 22f860026..11d67b7f4 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -9,9 +9,12 @@ import { FolderOpen, FolderTree, LoaderCircle, + Maximize2, + Minimize2, Search, SquareTerminal, Terminal, + Workflow, createElement, type IconNode, } from "lucide"; @@ -25,10 +28,13 @@ export const toolIcons = { folderOpen: FolderOpen, folderTree: FolderTree, loading: LoaderCircle, + maximize: Maximize2, + minimize: Minimize2, readFile: FileText, search: Search, terminal: Terminal, terminalSquare: SquareTerminal, + workflow: Workflow, writeFile: FilePlus, } as const satisfies Record; diff --git a/src/ui/tool-display.test.ts b/src/ui/tool-display.test.ts index b9977ac8e..0b8c0883c 100644 --- a/src/ui/tool-display.test.ts +++ b/src/ui/tool-display.test.ts @@ -5,6 +5,8 @@ import { getToolDisplay, getToolHeaderSummary } from "./tool-display.js"; const displayCases: Array<[ToolResultCard, { title: string; tone: string }]> = [ [{ tool: "open_workspace", root: "/tmp/project" }, { title: "Opened workspace", tone: "workspace" }], + [{ tool: "run_workflow", runId: "wfr_1", name: "Review" }, { title: "Started workflow", tone: "workflow" }], + [{ tool: "workflow_status", runId: "wfr_1", name: "Review" }, { title: "Workflow status", tone: "workflow" }], [{ tool: "read", path: "src/read.ts" }, { title: "Read file", tone: "read" }], [{ tool: "write", path: "src/write.ts" }, { title: "Wrote file", tone: "write" }], [{ tool: "edit", path: "src/edit.ts" }, { title: "Edited file", tone: "edit" }], @@ -25,6 +27,7 @@ for (const [card, expected] of displayCases) { } assert.equal(getToolDisplay({ tool: "open_workspace", root: "/tmp/project" }).label, "/tmp/project"); +assert.equal(getToolDisplay({ tool: "run_workflow", runId: "wfr_1" }).label, "wfr_1"); assert.equal( getToolDisplay({ tool: "grep", summary: { pattern: "needle", scope: "src" } }).label, "needle in src", @@ -120,6 +123,14 @@ assert.deepEqual( getToolHeaderSummary({ tool: "open_workspace" }), { kind: "empty" }, ); +assert.deepEqual( + getToolHeaderSummary({ + tool: "workflow_status", + status: "running", + callSummary: { running: 2, failed: 1 }, + }), + { kind: "text", text: "running · 2 running · 1 failed" }, +); function pickDisplay(display: ReturnType) { return { diff --git a/src/ui/tool-display.ts b/src/ui/tool-display.ts index 7a8476316..d14d746d2 100644 --- a/src/ui/tool-display.ts +++ b/src/ui/tool-display.ts @@ -3,6 +3,7 @@ import { isPatchTool, isReviewTool, isShellTool, + isWorkflowTool, isWriteTool, summaryNumber, type ToolResultCard, @@ -31,6 +32,20 @@ export function getToolDisplay(card: ToolResultCard): ToolDisplay { label: card.root ?? card.path, tone: "workspace", }; + case "run_workflow": + return { + icon: toolIcons.workflow, + title: card.status === "completed" ? "Workflow completed" : "Started workflow", + label: card.name ?? card.runId, + tone: "workflow", + }; + case "workflow_status": + return { + icon: toolIcons.workflow, + title: "Workflow status", + label: card.name ?? card.runId, + tone: "workflow", + }; case "read": return { icon: toolIcons.readFile, @@ -129,6 +144,15 @@ export function getToolHeaderSummary(card: ToolResultCard): ToolHeaderSummary { return parts.length > 0 ? { kind: "text", text: parts.join(" · ") } : { kind: "empty" }; } + if (isWorkflowTool(card.tool)) { + const parts = [ + card.status, + card.callSummary?.running ? `${card.callSummary.running} running` : undefined, + card.callSummary?.failed ? `${card.callSummary.failed} failed` : undefined, + ].filter((part): part is string => Boolean(part)); + return parts.length > 0 ? { kind: "text", text: parts.join(" · ") } : { kind: "empty" }; + } + if (isShellTool(card.tool)) { const parts = [ countLabel(summaryNumber(summary, "lines"), "line"), From 4bc9558df1b453544fc6ea2f21b6886a1635c80e Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:52:09 +0000 Subject: [PATCH 032/132] feat(ui): add live workspace and workflow dashboards --- src/server.ts | 4 +- src/ui/card-types.test.ts | 31 ++- src/ui/card-types.ts | 13 ++ src/ui/tool-display.ts | 1 + src/ui/workflow-dashboard.ts | 400 +++++++++++++++++++++++++++++++++++ src/ui/workspace-app.css | 297 ++++++++++++++++++++++++++ src/ui/workspace-app.tsx | 196 +++++++++++++---- src/workflow-tools.ts | 4 +- 8 files changed, 905 insertions(+), 41 deletions(-) create mode 100644 src/ui/workflow-dashboard.ts diff --git a/src/server.ts b/src/server.ts index 4f4d2bab4..4b7300b63 100644 --- a/src/server.ts +++ b/src/server.ts @@ -725,10 +725,10 @@ function createMcpServer( registerAppResource( server, - "DevSpace Diff Card", + "DevSpace App", WORKSPACE_APP_URI, { - description: "Interactive card for viewing DevSpace file diffs.", + description: "Interactive DevSpace workspace, workflow, and file-change views.", _meta: { ui: { csp: appCsp(config), diff --git a/src/ui/card-types.test.ts b/src/ui/card-types.test.ts index eb47e9a04..7e261ace7 100644 --- a/src/ui/card-types.test.ts +++ b/src/ui/card-types.test.ts @@ -7,7 +7,13 @@ import { isToolName, } from "./card-types.js"; -for (const tool of ["apply_patch", "exec_command", "write_stdin"]) { +for (const tool of [ + "apply_patch", + "exec_command", + "write_stdin", + "run_workflow", + "workflow_status", +]) { assert.equal(isToolName(tool), true, `${tool} should be a recognized card tool`); } @@ -23,3 +29,26 @@ assert.equal( true, ); assert.equal(isExpandableCard({ tool: "apply_patch" }), false); +assert.equal(isExpandableCard({ tool: "run_workflow", runId: "wfr_1" }), true); +assert.equal( + isExpandableCard({ + tool: "open_workspace", + activeWorkflows: [ + { + id: "wfr_1", + name: "Review", + status: "running", + calls: { + running: 1, + completed: 0, + cached: 0, + failed: 0, + cancelled: 0, + observed: 1, + }, + updatedAt: "2026-07-26T00:00:00.000Z", + }, + ], + }), + true, +); diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index 2f8cb2098..67b19fb80 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -26,6 +26,16 @@ export interface ToolResultCard { workspaceId?: string; path?: string; root?: string; + mode?: "checkout" | "worktree"; + sourceRoot?: string; + worktree?: { + path?: string; + baseRef?: string; + baseSha?: string; + dirtySource?: boolean; + detached?: boolean; + managed?: boolean; + }; status?: string; name?: string; runId?: string; @@ -174,6 +184,9 @@ export function isExpandableCard(card: ToolResultCard): boolean { Boolean(card.agentsFiles?.length) || Boolean(card.availableAgentsFiles?.length) || Boolean(card.skills?.length) || + Boolean(card.activeWorkflows?.length) || + Boolean(card.agentProviders?.length) || + Boolean(card.agents?.length) || Boolean(card.skillDiagnostics?.length) ); } diff --git a/src/ui/tool-display.ts b/src/ui/tool-display.ts index d14d746d2..d989a3f88 100644 --- a/src/ui/tool-display.ts +++ b/src/ui/tool-display.ts @@ -138,6 +138,7 @@ export function getToolHeaderSummary(card: ToolResultCard): ToolHeaderSummary { if (card.tool === "open_workspace") { const parts = [ typeof summary.mode === "string" ? summary.mode : undefined, + countLabel(summaryNumber(summary, "activeWorkflows"), "workflow"), countLabel(summaryNumber(summary, "agentsFiles"), "instruction"), countLabel(summaryNumber(summary, "skills"), "skill"), ].filter((part): part is string => Boolean(part)); diff --git a/src/ui/workflow-dashboard.ts b/src/ui/workflow-dashboard.ts new file mode 100644 index 000000000..2503fa8d0 --- /dev/null +++ b/src/ui/workflow-dashboard.ts @@ -0,0 +1,400 @@ +import type { WorkflowRunSummaryView } from "../workflow-ui.js"; +import type { + WorkflowCallView, + WorkflowProjectView, + WorkflowRunView, +} from "../workflow-view.js"; +import type { ToolResultCard } from "./card-types.js"; +import { renderIcon, toolIcons } from "./icons.js"; + +export interface DashboardDisplayOptions { + canFullscreen: boolean; + fullscreen: boolean; + onToggleFullscreen(): void; +} + +export function renderWorkspaceDashboard( + container: HTMLElement, + card: ToolResultCard, + project: WorkflowProjectView | null, + display: DashboardDisplayOptions, +): void { + const root = node("div", { + className: `workspace-dashboard ${display.fullscreen ? "fullscreen" : "inline"}`, + }); + const runs = project?.runs ?? card.activeWorkflows ?? []; + + root.append( + renderDashboardToolbar("Workspace overview", display), + renderWorkflowSummarySection(runs), + renderAccordion( + "Workspace", + true, + renderKeyValues([ + ["Root", card.root ?? card.path ?? "Unknown"], + ["Workspace", card.workspaceId ?? "Unknown"], + ["Mode", card.mode ?? stringValue(card.summary?.mode) ?? "checkout"], + ...(card.sourceRoot ? [["Source root", card.sourceRoot] as [string, string]] : []), + ...(card.worktree?.baseRef ? [["Base ref", card.worktree.baseRef] as [string, string]] : []), + ...(card.worktree?.baseSha ? [["Base SHA", card.worktree.baseSha] as [string, string]] : []), + ]), + ), + renderAccordion( + `Loaded skills · ${card.skills?.length ?? 0}`, + false, + renderList( + card.skills?.map((skill) => ({ + title: skill.name ?? "Unnamed skill", + description: skill.description ?? skill.path, + meta: skill.path, + })) ?? [], + "No skills loaded.", + ), + ), + renderAccordion( + `Project instructions · ${card.agentsFiles?.length ?? 0}`, + false, + renderList( + card.agentsFiles?.map((file) => ({ + title: file.path ?? "AGENTS.md", + description: summarizeText(file.content), + })) ?? [], + "No project instructions loaded.", + ), + ), + renderAccordion( + `Nested instructions · ${card.availableAgentsFiles?.length ?? 0}`, + false, + renderList( + card.availableAgentsFiles?.map((file) => ({ title: file.path ?? "Unknown path" })) ?? [], + "No nested instruction files discovered.", + ), + ), + renderAccordion( + `Agent providers · ${card.agentProviders?.filter((provider) => provider.available).length ?? 0} available`, + false, + renderProviderList(card), + ), + renderAccordion( + `Agent profiles · ${card.agents?.length ?? 0}`, + false, + renderList( + card.agents?.map((agent) => ({ + title: agent.name ?? "Unnamed profile", + description: [ + agent.provider, + agent.model, + agent.effort, + agent.providerAvailable === false ? agent.providerUnavailableReason ?? "Unavailable" : undefined, + ].filter(Boolean).join(" · "), + meta: agent.description, + })) ?? [], + "No agent profiles loaded.", + ), + ), + renderAccordion( + `Warnings · ${card.skillDiagnostics?.length ?? 0}`, + false, + renderList( + card.skillDiagnostics?.map((diagnostic, index) => ({ + title: `Diagnostic ${index + 1}`, + description: summarizeDiagnostic(diagnostic), + })) ?? [], + "No workspace warnings.", + ), + ), + renderAccordion( + "Model handoff", + false, + node("div", { className: "workspace-handoff", text: card.instruction ?? "No handoff instruction." }), + ), + ); + + container.replaceChildren(root); +} + +export function renderWorkflowDashboard( + container: HTMLElement, + run: WorkflowRunView | null, + fallback: ToolResultCard, + display: DashboardDisplayOptions, +): void { + const root = node("div", { + className: `workflow-dashboard ${display.fullscreen ? "fullscreen" : "inline"}`, + }); + root.append(renderDashboardToolbar("Workflow monitor", display)); + + if (!run) { + root.append( + node("div", { + className: "dashboard-empty", + text: fallback.runId ? "Loading workflow activity…" : "No workflow run selected.", + }), + ); + container.replaceChildren(root); + return; + } + + const heading = node("section", { className: "workflow-heading" }); + const titleRow = node("div", { className: "workflow-title-row" }); + titleRow.append( + node("span", { className: `workflow-status-dot ${run.status}`, ariaHidden: "true" }), + node("div", { className: "workflow-title-copy" }, [ + node("strong", { text: run.name }), + node("span", { + className: "workflow-subtitle", + text: `${run.status}${run.currentPhase ? ` · ${run.currentPhase}` : ""}`, + }), + ]), + ); + heading.append(titleRow, renderCallCounts(run)); + root.append(heading); + + const phases = node("section", { className: "workflow-phases" }); + for (const phase of run.phases) { + const phaseSection = node("section", { className: "workflow-phase" }); + phaseSection.append(node("h3", { text: phase.title })); + const calls = node("div", { className: "workflow-call-list" }); + for (const call of phase.calls) calls.append(renderCall(call)); + if (phase.calls.length === 0) { + calls.append(node("div", { className: "dashboard-empty", text: "No observed calls in this phase." })); + } + phaseSection.append(calls); + phases.append(phaseSection); + } + if (run.unphasedCalls.length > 0) { + const unphased = node("section", { className: "workflow-phase" }); + unphased.append(node("h3", { text: "Other calls" })); + const calls = node("div", { className: "workflow-call-list" }); + for (const call of run.unphasedCalls) calls.append(renderCall(call)); + unphased.append(calls); + phases.append(unphased); + } + if (run.phases.length === 0 && run.unphasedCalls.length === 0) { + phases.append(node("div", { className: "dashboard-empty", text: "No agent calls observed yet." })); + } + root.append(phases); + + if (run.recentActivity.length > 0) { + const activity = node("section", { className: "workflow-activity" }); + activity.append(node("h3", { text: "Recent activity" })); + for (const event of run.recentActivity.slice(-8).reverse()) { + activity.append( + node("div", { className: "workflow-event" }, [ + node("time", { text: formatTime(event.createdAt) }), + node("span", { + text: `${event.label ?? event.phase ?? event.type.replaceAll("_", " ")}${event.detail ? ` · ${event.detail}` : ""}`, + }), + ]), + ); + } + root.append(activity); + } + + if (run.error) { + root.append( + node("section", { className: "workflow-error" }, [ + node("strong", { text: run.errorKind ?? "Workflow error" }), + node("p", { text: run.error }), + ]), + ); + } + + container.replaceChildren(root); +} + +function renderDashboardToolbar( + title: string, + display: DashboardDisplayOptions, +): HTMLElement { + const toolbar = node("div", { className: "dashboard-toolbar" }); + toolbar.append(node("strong", { text: title })); + if (display.canFullscreen || display.fullscreen) { + const button = node("button", { + className: "display-mode-button", + type: "button", + text: display.fullscreen ? "Exit fullscreen" : "Open dashboard", + }); + button.prepend(renderIcon(display.fullscreen ? toolIcons.minimize : toolIcons.maximize)); + button.addEventListener("click", display.onToggleFullscreen); + toolbar.append(button); + } + return toolbar; +} + +function renderWorkflowSummarySection( + runs: Array, +): HTMLElement { + const section = node("section", { className: "active-workflows" }); + section.append(node("h3", { text: `Active workflows · ${runs.length}` })); + if (runs.length === 0) { + section.append(node("div", { className: "dashboard-empty", text: "No active workflows." })); + return section; + } + for (const run of runs) { + const row = node("div", { className: "active-workflow-row" }); + row.append( + node("span", { className: `workflow-status-dot ${run.status}`, ariaHidden: "true" }), + node("div", { className: "active-workflow-copy" }, [ + node("strong", { text: run.name }), + node("span", { + text: `${run.currentPhase ?? run.status} · ${summaryCounts(run.calls)}`, + }), + ]), + ); + section.append(row); + } + return section; +} + +function renderCallCounts(run: WorkflowRunView): HTMLElement { + const counts = node("div", { className: "workflow-counts" }); + const values = [ + ["Completed", run.calls.completed], + ["Replayed", run.calls.cached], + ["Running", run.calls.running], + ["Failed", run.calls.failed], + ] as const; + for (const [label, value] of values) { + if (!value) continue; + counts.append(node("span", { text: `${value} ${label.toLowerCase()}` })); + } + if (counts.childElementCount === 0) { + counts.append(node("span", { text: "No agent calls yet" })); + } + return counts; +} + +function renderCall(call: WorkflowCallView): HTMLElement { + const row = node("article", { className: `workflow-call ${call.status}` }); + const main = node("div", { className: "workflow-call-main" }); + main.append( + node("span", { className: `call-status ${call.status}`, text: callGlyph(call.status) }), + node("div", { className: "workflow-call-copy" }, [ + node("strong", { text: call.label ?? `Agent #${call.callIndex}` }), + node("span", { + text: [ + call.model ? `${call.provider}/${call.model}` : call.provider, + call.isolation === "worktree" ? "worktree" : undefined, + call.fromCache ? "replayed" : undefined, + ].filter(Boolean).join(" · "), + }), + ]), + ); + row.append(main); + if (call.error) { + row.append(node("p", { className: "workflow-call-error", text: `${call.errorKind ?? "error"}: ${call.error}` })); + } + return row; +} + +function renderAccordion(title: string, open: boolean, content: HTMLElement): HTMLElement { + const details = node("details", { className: "workspace-accordion" }) as HTMLDetailsElement; + details.open = open; + details.append(node("summary", { text: title }), content); + return details; +} + +function renderKeyValues(entries: Array<[string, string]>): HTMLElement { + const list = node("dl", { className: "workspace-key-values" }); + for (const [label, value] of entries) { + list.append(node("dt", { text: label }), node("dd", { text: value, title: value })); + } + return list; +} + +function renderProviderList(card: ToolResultCard): HTMLElement { + return renderList( + card.agentProviders?.map((provider) => ({ + title: provider.name ?? "Unknown provider", + description: provider.available ? "Available" : provider.reason ?? "Unavailable", + })) ?? [], + "No subagent providers exposed.", + ); +} + +function renderList( + items: Array<{ title: string; description?: string; meta?: string }>, + emptyText: string, +): HTMLElement { + const list = node("div", { className: "workspace-list" }); + if (items.length === 0) { + list.append(node("div", { className: "dashboard-empty", text: emptyText })); + return list; + } + for (const item of items) { + list.append( + node("div", { className: "workspace-list-row" }, [ + node("strong", { text: item.title }), + item.description ? node("span", { text: item.description }) : undefined, + item.meta ? node("code", { text: item.meta, title: item.meta }) : undefined, + ].filter((child): child is HTMLElement => Boolean(child))), + ); + } + return list; +} + +function summaryCounts(calls: WorkflowRunSummaryView["calls"]): string { + const parts = [ + calls.completed ? `${calls.completed} done` : undefined, + calls.cached ? `${calls.cached} replayed` : undefined, + calls.running ? `${calls.running} running` : undefined, + calls.failed ? `${calls.failed} failed` : undefined, + ].filter((part): part is string => Boolean(part)); + return parts.join(" · ") || "no calls yet"; +} + +function callGlyph(status: WorkflowCallView["status"]): string { + if (status === "completed" || status === "from_cache") return "✓"; + if (status === "failed") return "✕"; + if (status === "cancelled") return "−"; + return "●"; +} + +function formatTime(value: string): string { + return new Date(value).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +function summarizeText(value: string | undefined): string | undefined { + if (!value) return undefined; + const compact = value.replace(/\s+/g, " ").trim(); + return compact.length > 140 ? `${compact.slice(0, 139)}…` : compact; +} + +function summarizeDiagnostic(value: unknown): string { + if (typeof value === "string") return value; + try { + return JSON.stringify(value); + } catch { + return "Unserializable diagnostic"; + } +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function node( + tag: K, + options: { + className?: string; + text?: string; + type?: string; + title?: string; + ariaHidden?: string; + } = {}, + children: HTMLElement[] = [], +): HTMLElementTagNameMap[K] { + const element = document.createElement(tag); + if (options.className) element.className = options.className; + if (options.text !== undefined) element.textContent = options.text; + if (options.type !== undefined) element.setAttribute("type", options.type); + if (options.title !== undefined) element.title = options.title; + if (options.ariaHidden !== undefined) element.setAttribute("aria-hidden", options.ariaHidden); + element.append(...children); + return element; +} diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index ed81685cb..db1503ffe 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -371,3 +371,300 @@ body { grid-template-columns: 42px minmax(0, 1fr) auto 18px; } } + +html[data-display-mode="fullscreen"], +html[data-display-mode="fullscreen"] body { + min-height: 100%; + overflow: auto; +} + +html[data-display-mode="fullscreen"] .shell, +html[data-display-mode="fullscreen"] .tool-card { + min-height: 100vh; +} + +html[data-display-mode="fullscreen"] .tool-card { + border: 0; + border-radius: 0; +} + +.workspace-dashboard, +.workflow-dashboard { + display: grid; + gap: 0; + color: var(--color-text-primary, #f5f5f6); +} + +.workspace-dashboard.fullscreen, +.workflow-dashboard.fullscreen { + min-height: calc(100vh - 83px); + align-content: start; +} + +.dashboard-toolbar { + display: flex; + min-height: 52px; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 16px; + border-bottom: 1px solid var(--tool-card-divider); +} + +.display-mode-button { + display: inline-flex; + min-height: 34px; + align-items: center; + gap: 7px; + padding: 0 10px; + border: 0; + border-radius: 8px; + background: var(--tool-card-hover-bg); + color: var(--color-text-secondary, #d6d6dc); + cursor: pointer; + font: inherit; + font-size: var(--font-text-sm-size, 13px); +} + +.display-mode-button:hover { + color: var(--color-text-primary, #f5f5f6); +} + +.display-mode-button .icon-svg { + width: 15px; + height: 15px; +} + +.active-workflows, +.workflow-heading, +.workflow-phases, +.workflow-activity, +.workflow-error { + padding: 16px; +} + +.active-workflows, +.workflow-heading, +.workflow-phases, +.workflow-activity { + border-bottom: 1px solid var(--tool-card-divider); +} + +.active-workflows h3, +.workflow-phase h3, +.workflow-activity h3 { + margin: 0 0 12px; + font-size: var(--font-text-sm-size, 13px); + font-weight: 600; +} + +.active-workflow-row, +.workflow-title-row, +.workflow-call-main { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.active-workflow-row + .active-workflow-row { + margin-top: 12px; +} + +.workflow-status-dot { + width: 9px; + height: 9px; + flex: 0 0 auto; + margin-top: 5px; + border-radius: 999px; + background: var(--color-text-tertiary, #a3a3aa); +} + +.workflow-status-dot.running, +.workflow-status-dot.starting { + background: var(--color-info-text, #72a7ff); +} + +.workflow-status-dot.completed { + background: var(--color-success-text, #6fda83); +} + +.workflow-status-dot.failed, +.workflow-status-dot.cancelled { + background: var(--color-danger-text, #ee7676); +} + +.active-workflow-copy, +.workflow-title-copy, +.workflow-call-copy, +.workspace-list-row { + display: grid; + min-width: 0; + gap: 3px; +} + +.active-workflow-copy span, +.workflow-subtitle, +.workflow-call-copy span, +.workspace-list-row span, +.workspace-list-row code { + overflow: hidden; + color: var(--color-text-secondary, #b7b7bf); + font-size: var(--font-text-sm-size, 12px); + line-height: 1.45; + text-overflow: ellipsis; +} + +.workspace-list-row code { + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); + white-space: nowrap; +} + +.workspace-accordion { + border-bottom: 1px solid var(--tool-card-divider); +} + +.workspace-accordion summary { + min-height: 46px; + padding: 14px 16px; + cursor: pointer; + color: var(--color-text-primary, #f5f5f6); + font-size: var(--font-text-sm-size, 13px); + font-weight: 500; +} + +.workspace-accordion summary:hover { + background: var(--tool-card-hover-bg); +} + +.workspace-key-values { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 8px 16px; + margin: 0; + padding: 0 16px 16px; + font-size: var(--font-text-sm-size, 12px); +} + +.workspace-key-values dt { + color: var(--color-text-tertiary, #a3a3aa); +} + +.workspace-key-values dd { + min-width: 0; + margin: 0; + overflow: hidden; + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-list { + display: grid; + gap: 12px; + padding: 0 16px 16px; +} + +.workspace-handoff { + padding: 0 16px 16px; + color: var(--color-text-secondary, #d6d6dc); + font-size: var(--font-text-sm-size, 12px); + line-height: 1.55; +} + +.workflow-heading { + display: grid; + gap: 12px; +} + +.workflow-counts { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.workflow-counts span { + padding: 4px 8px; + border-radius: 999px; + background: var(--tool-card-hover-bg); + color: var(--color-text-secondary, #d6d6dc); + font-size: var(--font-text-sm-size, 12px); +} + +.workflow-phases { + display: grid; + gap: 20px; +} + +.workflow-call-list { + display: grid; + gap: 8px; +} + +.workflow-call { + padding: 10px 12px; + border-radius: 9px; + background: color-mix(in srgb, var(--tool-card-hover-bg) 54%, transparent); +} + +.call-status { + width: 16px; + flex: 0 0 auto; + color: var(--color-text-tertiary, #a3a3aa); + text-align: center; +} + +.call-status.completed, +.call-status.from_cache { + color: var(--color-success-text, #6fda83); +} + +.call-status.failed { + color: var(--color-danger-text, #ee7676); +} + +.workflow-call-error, +.workflow-error p { + margin: 8px 0 0 26px; + color: var(--color-danger-text, #ee7676); + font-size: var(--font-text-sm-size, 12px); + line-height: 1.45; +} + +.workflow-activity { + display: grid; + gap: 8px; +} + +.workflow-event { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 10px; + color: var(--color-text-secondary, #d6d6dc); + font-size: var(--font-text-sm-size, 12px); +} + +.workflow-event time { + color: var(--color-text-tertiary, #a3a3aa); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); +} + +.workflow-error { + color: var(--color-danger-text, #ee7676); +} + +.dashboard-empty { + color: var(--color-text-secondary, #b7b7bf); + font-size: var(--font-text-sm-size, 13px); +} + +@media (min-width: 860px) { + .workspace-dashboard.fullscreen, + .workflow-dashboard.fullscreen { + width: min(1120px, 100%); + margin: 0 auto; + } + + .workflow-dashboard.fullscreen .workflow-phases { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index 78723864c..847873726 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -5,6 +5,7 @@ import { applyHostStyleVariables, } from "@modelcontextprotocol/ext-apps"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { WorkflowProjectView, WorkflowRunView } from "../workflow-view.js"; import { isEditTool, isExpandableCard, @@ -13,6 +14,7 @@ import { isReviewTool, isToolName, isToolResultCard, + isWorkflowTool, isWriteTool, payloadText, type HostContext, @@ -25,6 +27,10 @@ import { getToolHeaderSummary, type ToolDisplay, } from "./tool-display.js"; +import { + renderWorkflowDashboard, + renderWorkspaceDashboard, +} from "./workflow-dashboard.js"; import "./workspace-app.css"; interface MountedPayload { @@ -47,6 +53,10 @@ let reviewFilesExpanded = false; let errorMessage: string | null = null; let currentPayload: MountedPayload | null = null; let currentPayloadContainer: HTMLElement | null = null; +let workflowProject: WorkflowProjectView | null = null; +let workflowRun: WorkflowRunView | null = null; +let workflowRefreshKey: string | null = null; +let workflowRefreshGeneration = 0; const maybeAppRoot = document.querySelector("#app"); @@ -75,6 +85,7 @@ async function boot(): Promise { const tool = toolNameFromMeta(result); if (!tool || !isToolResultCard(structured)) { + stopWorkflowRefresh(); card = null; expanded = false; reviewFilesExpanded = false; @@ -84,8 +95,11 @@ async function boot(): Promise { } const nextCard = { ...structured, tool }; + stopWorkflowRefresh(); + workflowProject = null; + workflowRun = null; card = nextCard; - expanded = isReviewTool(tool) && isExpandableCard(nextCard); + expanded = (isReviewTool(tool) || isWorkflowTool(tool)) && isExpandableCard(nextCard); reviewFilesExpanded = false; errorMessage = null; render(); @@ -97,10 +111,11 @@ async function boot(): Promise { ...ctx, }; applyHostContext(); - renderPayloadIfNeeded(); + render(); }; app.onteardown = async () => { + stopWorkflowRefresh(); unmountPayload(); return {}; }; @@ -121,6 +136,7 @@ async function boot(): Promise { } function applyHostContext(): void { + document.documentElement.dataset.displayMode = hostContext?.displayMode ?? "inline"; if (hostContext?.theme) applyDocumentTheme(hostContext.theme); if (hostContext?.styles?.variables) { applyHostStyleVariables(hostContext.styles.variables); @@ -172,6 +188,7 @@ function render(): void { if (expandable) { button.addEventListener("click", () => { expanded = !expanded; + if (!expanded) stopWorkflowRefresh(); render(); }); } @@ -226,7 +243,14 @@ async function renderPayloadIfNeeded(): Promise { } if (card.tool === "open_workspace") { - renderPrePayload(target, workspacePayloadText(card), "open_workspace"); + renderWorkspaceDashboard(target, card, workflowProject, dashboardDisplayOptions()); + ensureWorkflowRefresh(); + return; + } + + if (isWorkflowTool(card.tool)) { + renderWorkflowDashboard(target, workflowRun, card, dashboardDisplayOptions()); + ensureWorkflowRefresh(); return; } @@ -298,6 +322,139 @@ function shouldUseHeavyPayload(card: ToolResultCard): boolean { return isReadTool(card.tool) || isEditTool(card.tool) || isWriteTool(card.tool); } +function dashboardDisplayOptions() { + const fullscreen = hostContext?.displayMode === "fullscreen"; + return { + canFullscreen: Boolean(hostContext?.availableDisplayModes?.includes("fullscreen")), + fullscreen, + onToggleFullscreen: () => { + void toggleFullscreen(); + }, + }; +} + +async function toggleFullscreen(): Promise { + if (!app) return; + const fullscreen = hostContext?.displayMode === "fullscreen"; + const mode = fullscreen ? "inline" : "fullscreen"; + if (!fullscreen && !hostContext?.availableDisplayModes?.includes("fullscreen")) return; + + try { + const result = await app.requestDisplayMode({ mode }); + hostContext = { + ...hostContext, + displayMode: result.mode, + }; + applyHostContext(); + render(); + } catch (displayError) { + errorMessage = displayError instanceof Error + ? displayError.message + : "Unable to change display mode."; + renderPayloadIfNeeded(); + } +} + +function ensureWorkflowRefresh(): void { + if (!app || !card || !expanded) return; + + const request = card.tool === "open_workspace" && card.workspaceId + ? { + key: `workspace:${card.workspaceId}`, + name: "workspace_workflow_activity", + args: { workspaceId: card.workspaceId }, + kind: "project" as const, + } + : isWorkflowTool(card.tool) && card.runId + ? { + key: `run:${card.runId}`, + name: "workflow_ui_snapshot", + args: { runId: card.runId }, + kind: "run" as const, + } + : null; + + if (!request || workflowRefreshKey === request.key) return; + workflowRefreshKey = request.key; + const generation = ++workflowRefreshGeneration; + void refreshWorkflowLoop(request, generation); +} + +async function refreshWorkflowLoop( + request: { + key: string; + name: string; + args: Record; + kind: "project" | "run"; + }, + generation: number, +): Promise { + let knownVersion = request.kind === "project" + ? workflowProject?.version + : workflowRun?.version; + + while ( + app && + expanded && + workflowRefreshGeneration === generation && + workflowRefreshKey === request.key + ) { + try { + const result = await app.callServerTool({ + name: request.name, + arguments: { + ...request.args, + knownVersion, + waitMs: 20_000, + }, + }); + if ( + workflowRefreshGeneration !== generation || + workflowRefreshKey !== request.key + ) { + return; + } + + if (request.kind === "project") { + const structured = getStructuredContent<{ project?: WorkflowProjectView }>(result); + if (structured?.project) { + workflowProject = structured.project; + knownVersion = structured.project.version; + } + } else { + const structured = getStructuredContent<{ run?: WorkflowRunView }>(result); + if (structured?.run) { + workflowRun = structured.run; + knownVersion = structured.run.version; + } + } + + await renderPayloadIfNeeded(); + if ( + request.kind === "run" && + workflowRun && + ["completed", "failed", "cancelled"].includes(workflowRun.status) + ) { + workflowRefreshKey = null; + return; + } + } catch (refreshError) { + if (workflowRefreshGeneration !== generation) return; + errorMessage = refreshError instanceof Error + ? refreshError.message + : "Unable to refresh workflow activity."; + workflowRefreshKey = null; + await renderPayloadIfNeeded(); + return; + } + } +} + +function stopWorkflowRefresh(): void { + workflowRefreshKey = null; + workflowRefreshGeneration += 1; +} + function unmountPayload(): void { unmountCurrentPayload(); currentPayload = null; @@ -445,39 +602,6 @@ function setPayloadLoading(container: HTMLElement, loading: boolean): void { if (button) button.setAttribute("aria-busy", String(loading)); } -function workspacePayloadText(card: ToolResultCard): string { - const agentsFiles = card.agentsFiles ?? []; - const availableAgentsFiles = card.availableAgentsFiles ?? []; - const skills = card.skills ?? []; - const lines = [ - card.workspaceId ? `Workspace: ${card.workspaceId}` : undefined, - card.root ? `Root: ${card.root}` : undefined, - skills.length > 0 - ? `Skills: ${skills.map((skill) => skill.name ?? skill.path ?? "unnamed").join(", ")}` - : "Skills: none", - availableAgentsFiles.length > 0 - ? `Nested instructions: ${availableAgentsFiles.map((file) => file.path ?? "unknown").join(", ")}` - : undefined, - agentsFiles.length > 0 - ? `\n${formatAgentsFilesForPayload(agentsFiles)}` - : "\nAGENTS.md: none loaded", - ].filter((line): line is string => typeof line === "string"); - - return lines.join("\n"); -} - -function formatAgentsFilesForPayload( - agentsFiles: NonNullable, -): string { - return agentsFiles - .map((file) => { - const path = file.path ?? "AGENTS.md"; - const content = file.content?.trim(); - return content ? `${path}\n\n${content}` : `${path}\n\nNo content loaded.`; - }) - .join("\n\n"); -} - function toolNameFromMeta(result: CallToolResult): ToolName | undefined { const meta = result._meta as Record | undefined; const tool = meta?.tool; diff --git a/src/workflow-tools.ts b/src/workflow-tools.ts index 1a329aca0..b3255d9d9 100644 --- a/src/workflow-tools.ts +++ b/src/workflow-tools.ts @@ -504,7 +504,7 @@ async function waitForProjectSnapshot( const deadline = Date.now() + Math.min(waitMs, WORKFLOW_UI_WAIT_MAX_MS); for (;;) { const project = loadWorkflowUiProject(store, workspaceRoot); - if (!knownVersion || project.version !== knownVersion || Date.now() >= deadline) { + if (knownVersion === undefined || project.version !== knownVersion || Date.now() >= deadline) { return project; } await sleep(250); @@ -520,7 +520,7 @@ async function waitForRunSnapshot( const deadline = Date.now() + Math.min(waitMs, WORKFLOW_UI_WAIT_MAX_MS); for (;;) { const run = loadWorkflowUiRun(store, runId); - if (!run || !knownVersion || run.version !== knownVersion || Date.now() >= deadline) { + if (!run || knownVersion === undefined || run.version !== knownVersion || Date.now() >= deadline) { return run; } await sleep(250); From 4777bab526ba9213bb2335706638e6cfbb21424a Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:54:04 +0000 Subject: [PATCH 033/132] docs(ui): document read-only workflow dashboards --- docs/chatgpt-coding-workflow.md | 16 +++++++++++++--- docs/configuration.md | 2 +- skills/dynamic-workflows/SKILL.md | 2 +- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 660626211..1ab0b90f3 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -148,12 +148,22 @@ registered. `exec_command` returns a process session ID when a command is still running after its yield window. Use `write_stdin` to poll it, send input, resize a PTY, or send Ctrl-C. Set `tty: true` only for commands that need a terminal. -## Show Changes +## Widget UI and Show Changes By default, `DEVSPACE_WIDGETS=full`. -In that mode, DevSpace attaches widget UI to the exposed workspace, file, edit, -and shell tools. The aggregate `show_changes` tool is not exposed by default. +In that mode, DevSpace attaches widget UI to the exposed workspace, workflow, +file, edit, and shell tools. The `open_workspace` dropdown presents the opened +root, loaded skills and instructions, available agent providers/profiles, and +currently active workflows for that workspace. + +Dynamic Workflow views are read-only. They refresh through app-only MCP tools +and show observed phases, agent calls, replay state, worktree isolation, errors, +and recent activity. When the host supports MCP Apps fullscreen display mode, +the card offers an **Open dashboard** presentation control. It does not add +cancel, resume, apply, or cleanup actions. + +The aggregate `show_changes` tool is not exposed by default. Use `DEVSPACE_WIDGETS=off` to disable widget UI, or `DEVSPACE_WIDGETS=changes` to expose the aggregate show-changes flow. diff --git a/docs/configuration.md b/docs/configuration.md index 4c02eb1aa..14bc2a54c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -83,7 +83,7 @@ sessions. | Value | Behavior | | --- | --- | -| `full` | Default. Widget UI is attached to exposed workspace, file, edit, and shell tools. | +| `full` | Default. Widget UI is attached to exposed workspace, workflow, file, edit, and shell tools, including read-only live workflow dashboards. | | `changes` | Enables the aggregate `show_changes` tool and attaches widget UI to `open_workspace` and `show_changes`. | | `off` | Disables widget UI. | diff --git a/skills/dynamic-workflows/SKILL.md b/skills/dynamic-workflows/SKILL.md index d0ba95620..433f8b693 100644 --- a/skills/dynamic-workflows/SKILL.md +++ b/skills/dynamic-workflows/SKILL.md @@ -117,7 +117,7 @@ depending on a replayed mutating call. - **CLI**: host agent can shell; prefer for long runs + `--follow`. - **TUI**: `devspace workflow tui` opens a read-only live view for workflows associated with the current working directory. -- **MCP**: ChatGPT plans; call `run_workflow`, then `workflow_status` until terminal. Disconnecting MCP does **not** kill the worker. +- **MCP**: ChatGPT plans; call `run_workflow`, then `workflow_status` until terminal. With full widgets enabled, workflow tool cards and the `open_workspace` dashboard show read-only live activity, including workflows launched through the CLI. Disconnecting MCP does **not** kill the worker. ## Worked mini-examples From f5e9252f1383c4463303493a33cf51c6e4b254d1 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:06:21 +0000 Subject: [PATCH 034/132] feat(workflow): isolate script execution in child process --- src/workflow-engine.ts | 2 + src/workflow-sandbox-child.ts | 253 ++++++++++++++++++++++++ src/workflow-sandbox.ts | 350 +++++++++++++++++++--------------- src/workflow-script.ts | 9 +- 4 files changed, 452 insertions(+), 162 deletions(-) create mode 100644 src/workflow-sandbox-child.ts diff --git a/src/workflow-engine.ts b/src/workflow-engine.ts index 1611e7ca5..64a4dfc1b 100644 --- a/src/workflow-engine.ts +++ b/src/workflow-engine.ts @@ -116,6 +116,7 @@ export async function executeWorkflow( parsed, api, timeoutMs: options.timeoutMs ?? WORKFLOW_HOST_TIMEOUT_MS, + signal, }); return { result, @@ -180,6 +181,7 @@ async function executeNestedOnApi(input: { parsed, api: childApi, timeoutMs: input.parentOptions.timeoutMs ?? WORKFLOW_HOST_TIMEOUT_MS, + signal: input.parentOptions.signal, }); } diff --git a/src/workflow-sandbox-child.ts b/src/workflow-sandbox-child.ts new file mode 100644 index 000000000..6e361e036 --- /dev/null +++ b/src/workflow-sandbox-child.ts @@ -0,0 +1,253 @@ +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"; + +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; +} + +let nextCallId = 1; +const pending = new Map< + number, + { resolve(value: unknown): void; reject(error: unknown): 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); + if (message.error) waiter.reject(deserializeError(message.error)); + else waiter.resolve(message.value); + 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, reject) => { + pending.set(id, { resolve, reject }); + }); + }; + + const context = vm.createContext({ __workflowBridge: bridge }); + 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, RealDate); + } + 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 = RealDate.prototype; + Object.freeze(DateShim); + + const agent = (...callArgs) => bridge("agent", callArgs); + const workflow = (...callArgs) => bridge("workflow", callArgs); + const phase = (title) => { + if (typeof title !== "string" || !title.trim()) { + throw new WorkflowEngineError("internal", "phase(title) requires a non-empty string"); + } + return bridge("phase", [title]); + }; + const log = (...callArgs) => bridge("log", callArgs); + 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 consoleLine = (...callArgs) => log(callArgs.map(stringifyConsoleArg).join(" ")); + const console = Object.freeze({ + log: consoleLine, + warn: consoleLine, + error: consoleLine, + info: consoleLine, + debug: consoleLine, + }); + + 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(log), 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 deserializeError(input: SerializedError): Error { + const error = 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; +} + +function disconnect(): void { + if (process.connected) process.disconnect?.(); +} diff --git a/src/workflow-sandbox.ts b/src/workflow-sandbox.ts index 656e128c6..bad69c407 100644 --- a/src/workflow-sandbox.ts +++ b/src/workflow-sandbox.ts @@ -1,4 +1,6 @@ -import vm from "node:vm"; +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 { @@ -35,190 +37,222 @@ export interface RunWorkflowSandboxOptions { 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 compiled workflow script in a restricted node:vm context. - * Not a hostile multi-tenant security boundary — determinism + capability reduction. + * 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 { parsed, api } = options; const timeoutMs = options.timeoutMs ?? 6 * 60 * 60 * 1000; + const child = spawnSandboxChild(); - const consoleProxy = { - log: (...args: unknown[]) => { - api.log(args.map(stringifyConsoleArg).join(" ")); - }, - warn: (...args: unknown[]) => { - api.log(args.map(stringifyConsoleArg).join(" ")); - }, - error: (...args: unknown[]) => { - api.log(args.map(stringifyConsoleArg).join(" ")); - }, - info: (...args: unknown[]) => { - api.log(args.map(stringifyConsoleArg).join(" ")); - }, - debug: (...args: unknown[]) => { - api.log(args.map(stringifyConsoleArg).join(" ")); - }, - }; + return new Promise((resolve, reject) => { + let settled = false; - // Script params: host APIs only. `meta`/`console` are not params (meta is script-local; - // console lives on sandbox globals so console.log works). - const sandboxApi = { - agent: api.agent, - parallel: api.parallel, - pipeline: api.pipeline, - phase: api.phase, - log: api.log, - args: api.args, - budget: api.budget, - workflow: api.workflow, - }; + 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 ("error" in outcome) reject(outcome.error); + else resolve(outcome.value); + }; - const context = vm.createContext(createSandboxGlobals(consoleProxy)); - const factory = parsed.script.runInContext(context, { - timeout: 5_000, - displayErrors: true, - }) as (api: typeof sandboxApi) => Promise; + const terminate = (error: Error): void => { + if (!child.killed) child.kill("SIGKILL"); + finish({ error }); + }; - if (typeof factory !== "function") { - throw new Error("Workflow script did not compile to a function"); - } + const onAbort = (): void => { + const error = new Error("Workflow cancelled"); + error.name = "AbortError"; + terminate(error); + }; - const result = await withTimeout( - Promise.resolve().then(() => factory(sandboxApi)), - timeoutMs, - ); - // Context objects keep the sandbox realm's prototypes; rehydrate for host use. - return rehydrateHostValue(result); -} + const timer = setTimeout(() => { + terminate(new Error(`Workflow script exceeded host timeout (${timeoutMs}ms)`)); + }, timeoutMs); + timer.unref?.(); -/** Copy a sandbox value into the host realm (plain objects / arrays / primitives). */ -export function rehydrateHostValue(value: unknown): unknown { - if (value === null || value === undefined) return value; - const t = typeof value; - if (t === "string" || t === "number" || t === "boolean" || t === "bigint") return value; - if (t === "function" || t === "symbol") return value; - if (Array.isArray(value)) { - return Array.from(value as unknown[], (item) => rehydrateHostValue(item)); - } - if (value instanceof Date) { - return new Date(value.getTime()); - } - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = rehydrateHostValue(entry); - } - return out; -} + if (options.signal?.aborted) { + onAbort(); + return; + } + options.signal?.addEventListener("abort", onAbort, { once: true }); -function createSandboxGlobals( - consoleProxy: Record void>, -): Record { - return { - Object, - Array, - String, - Number, - Boolean, - Map, - Set, - WeakMap, - WeakSet, - JSON, - Math: createBannedMath(), - Date: createBannedDate(), - RegExp, - Error, - TypeError, - RangeError, - SyntaxError, - URIError, - EvalError, - Promise, - Symbol, - Proxy, - Reflect, - parseInt, - parseFloat, - isNaN, - isFinite, - encodeURI, - decodeURI, - encodeURIComponent, - decodeURIComponent, - undefined, - NaN, - Infinity, - console: consoleProxy, - // Explicitly absent: require, process, fetch, Buffer, setTimeout, setInterval, ... - }; -} + 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)})`, + ), + }); + }); -function createBannedDate(): typeof Date { - const RealDate = Date; + 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(), + }, + }; + child.send(start); + }); +} - function DateShim(this: unknown, ...args: unknown[]): string | Date { - if (new.target) { - if (args.length === 0) { - throw new WorkflowDeterminismError( - "new Date() without arguments is banned in workflow scripts (pass an ISO string)", - ); +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); } - return new (RealDate as unknown as new (...a: unknown[]) => Date)(...args); + if (child.connected) child.send(reply); + return; } - throw new WorkflowDeterminismError("Date() is banned in workflow scripts"); } - - DateShim.now = function bannedNow(): number { - throw new WorkflowDeterminismError("Date.now() is banned in workflow scripts"); - }; - DateShim.parse = RealDate.parse.bind(RealDate); - DateShim.UTC = RealDate.UTC.bind(RealDate); - Object.setPrototypeOf(DateShim, RealDate); - DateShim.prototype = RealDate.prototype; - return DateShim as unknown as typeof Date; } -function createBannedMath(): Math { - return new Proxy(Math, { - get(target, prop, receiver) { - if (prop === "random") { - return () => { - throw new WorkflowDeterminismError("Math.random() is banned in workflow scripts"); - }; - } - return Reflect.get(target, prop, receiver); +function spawnSandboxChild(): ChildProcess { + const childEntry = fileURLToPath( + import.meta.url.replace(/workflow-sandbox\.(ts|js)$/, "workflow-sandbox-child.$1"), + ); + return fork(childEntry, [], { + execArgv: process.execArgv, + stdio: ["ignore", "ignore", "ignore", "ipc"], + serialization: "advanced", + env: { + NODE_ENV: process.env.NODE_ENV ?? "production", }, }); } -function stringifyConsoleArg(value: unknown): string { - if (typeof value === "string") return value; - try { - return JSON.stringify(value); - } catch { - return String(value); +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 withTimeout(promise: Promise, ms: number): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error(`Workflow script exceeded host timeout (${ms}ms)`)); - }, ms); - promise.then( - (value) => { - clearTimeout(timer); - resolve(value); - }, - (error) => { - clearTimeout(timer); - reject(error); - }, - ); - }); +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-script.ts b/src/workflow-script.ts index cf949568b..daaf766d1 100644 --- a/src/workflow-script.ts +++ b/src/workflow-script.ts @@ -18,7 +18,7 @@ export interface ParsedWorkflowScript { meta: WorkflowMeta; source: string; scriptHash: string; - /** Compiled async factory: (api) => Promise */ + /** Compiled async factory. Workflow APIs are installed as sandbox globals. */ script: vm.Script; filename: string; } @@ -56,9 +56,10 @@ export function parseWorkflowScript( ); } - // Inject host APIs as params. `meta` stays as the script's own `const meta` - // (would TDZ/redeclare if also injected). `console` lives on the sandbox globals. - const wrapped = `(async ({ agent, parallel, pipeline, phase, log, args, budget, workflow }) => {\n${body}\n})`; + // 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, { From 09a7fc4fb5869601f8e8e98dc5ac4b21087beb4b Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:06:21 +0000 Subject: [PATCH 035/132] test(workflow): cover sandbox termination and escapes --- src/workflow-sandbox.test.ts | 54 ++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/workflow-sandbox.test.ts b/src/workflow-sandbox.test.ts index 7805aa75b..cb5e9e263 100644 --- a/src/workflow-sandbox.test.ts +++ b/src/workflow-sandbox.test.ts @@ -84,4 +84,58 @@ return Math.random() ); } +{ + 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", + ); +} + +{ + 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: 'api-constructor-escape', description: 'd' } +return agent.constructor('return process.version')() +`), + api: api({ name: "api-constructor-escape", description: "d" }), + }), + /process is not defined/, + ); +} + console.log("workflow-sandbox.test.ts: ok"); From 12cee076970492bdad2bf9253d239835827086fe Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:32:30 +0000 Subject: [PATCH 036/132] fix(workflow): keep sandbox values in vm realm --- src/workflow-sandbox-child.ts | 73 +++++++++++++++++++++++++++-------- src/workflow-sandbox.test.ts | 70 +++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 17 deletions(-) diff --git a/src/workflow-sandbox-child.ts b/src/workflow-sandbox-child.ts index 6e361e036..f26c4de1d 100644 --- a/src/workflow-sandbox-child.ts +++ b/src/workflow-sandbox-child.ts @@ -31,10 +31,22 @@ interface SerializedError { 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: unknown): void; reject(error: unknown): void } + { resolve(value: string): void } >(); process.on("message", (message: StartMessage | CallResultMessage) => { @@ -42,8 +54,10 @@ process.on("message", (message: StartMessage | CallResultMessage) => { const waiter = pending.get(message.id); if (!waiter) return; pending.delete(message.id); - if (message.error) waiter.reject(deserializeError(message.error)); - else waiter.resolve(message.value); + const envelope: BridgeEnvelope = message.error + ? { ok: false, error: message.error } + : { ok: true, value: message.value }; + waiter.resolve(JSON.stringify(envelope)); return; } void execute(message); @@ -60,8 +74,8 @@ async function execute(message: StartMessage): Promise { const id = nextCallId; nextCallId += 1; process.send?.({ type: "call", id, method, args }); - return new Promise((resolve, reject) => { - pending.set(id, { resolve, reject }); + return new Promise((resolve) => { + pending.set(id, { resolve }); }); }; @@ -119,18 +133,51 @@ function installContextApi(context: vm.Context, message: StartMessage): void { "new Date() without arguments is banned in workflow scripts (pass an ISO string)", ); } - return Reflect.construct(RealDate, dateArgs, RealDate); + 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 = RealDate.prototype; + DateShim.prototype = Object.create(RealDate.prototype, { + constructor: { + value: DateShim, + writable: false, + configurable: false, + }, + }); + Object.freeze(DateShim.prototype); Object.freeze(DateShim); - const agent = (...callArgs) => bridge("agent", callArgs); - const workflow = (...callArgs) => bridge("workflow", callArgs); + 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 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")), + ); + }); + const agent = (...callArgs) => call("agent", callArgs); + 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"); @@ -240,14 +287,6 @@ function serializeError(error: unknown): SerializedError { }; } -function deserializeError(input: SerializedError): Error { - const error = 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; -} - function disconnect(): void { if (process.connected) process.disconnect?.(); } diff --git a/src/workflow-sandbox.test.ts b/src/workflow-sandbox.test.ts index cb5e9e263..1c479b1f2 100644 --- a/src/workflow-sandbox.test.ts +++ b/src/workflow-sandbox.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { parseWorkflowScript } from "./workflow-script.js"; +import { WorkflowEngineError } from "./workflow-api.js"; import { createStubBudget, type WorkflowMeta, @@ -110,6 +111,21 @@ return 'still-alive' ); } +{ + 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( () => @@ -124,6 +140,60 @@ return Object.constructor('return process.version')() ); } +{ + 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( () => From d4efd02da1bb8d56787fddf2283a6e4961cecd78 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:33:17 +0000 Subject: [PATCH 037/132] fix(workflow): harden sandbox process teardown --- src/workflow-sandbox.test.ts | 18 ++++++++++++++++++ src/workflow-sandbox.ts | 31 +++++++++++++++++++++++-------- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/workflow-sandbox.test.ts b/src/workflow-sandbox.test.ts index 1c479b1f2..35f7de273 100644 --- a/src/workflow-sandbox.test.ts +++ b/src/workflow-sandbox.test.ts @@ -111,6 +111,24 @@ return '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( () => diff --git a/src/workflow-sandbox.ts b/src/workflow-sandbox.ts index bad69c407..3395fbd10 100644 --- a/src/workflow-sandbox.ts +++ b/src/workflow-sandbox.ts @@ -118,19 +118,17 @@ export async function runWorkflowSandbox( 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 => { - if (!child.killed) child.kill("SIGKILL"); finish({ error }); }; const onAbort = (): void => { - const error = new Error("Workflow cancelled"); - error.name = "AbortError"; - terminate(error); + terminate(new WorkflowEngineError("cancelled", "Workflow cancelled")); }; const timer = setTimeout(() => { @@ -168,7 +166,7 @@ export async function runWorkflowSandbox( remaining: options.api.budget.remaining(), }, }; - child.send(start); + safeSend(child, start); }); } @@ -209,16 +207,22 @@ async function handleChildMessage( } catch (error) { reply.error = serializeError(error); } - if (child.connected) child.send(reply); + safeSend(child, reply); return; } } } function spawnSandboxChild(): ChildProcess { - const childEntry = fileURLToPath( - import.meta.url.replace(/workflow-sandbox\.(ts|js)$/, "workflow-sandbox-child.$1"), + 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"], @@ -229,6 +233,17 @@ function spawnSandboxChild(): ChildProcess { }); } +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) }; From a78357aa83e35051c79f306c7ee57026f067217f Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:13:29 +0000 Subject: [PATCH 038/132] fix(workflow): journal terminal transitions atomically --- src/workflow-cli.ts | 12 +- src/workflow-store.test.ts | 15 ++- src/workflow-store.ts | 229 +++++++++++++++++++++++-------------- src/workflow-tools.ts | 7 ++ 4 files changed, 163 insertions(+), 100 deletions(-) diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts index 1dbc7bdc4..1e1bc9368 100644 --- a/src/workflow-cli.ts +++ b/src/workflow-cli.ts @@ -457,12 +457,7 @@ export async function runWorkflowWorker( } } - store.completeRun(runId, { resultJson }); - store.appendEvent({ - runId, - type: "run_completed", - data: { callCount }, - }); + store.completeRun(runId, { resultJson, callCount }); } catch (error) { if (store.isCancelRequested(runId) || abort.signal.aborted) { try { @@ -476,11 +471,6 @@ export async function runWorkflowWorker( const errorKind = mapEngineErrorKind(error); try { store.failRun(runId, { error: message, errorKind }); - store.appendEvent({ - runId, - type: "run_failed", - data: { error: message, errorKind }, - }); } catch { // terminal race } diff --git a/src/workflow-store.test.ts b/src/workflow-store.test.ts index 3865a4f2e..88a139cee 100644 --- a/src/workflow-store.test.ts +++ b/src/workflow-store.test.ts @@ -1,4 +1,5 @@ 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"; @@ -56,12 +57,14 @@ try { 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.beginAgentCall({ runId: run.id, @@ -120,7 +123,12 @@ try { 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({ @@ -159,7 +167,7 @@ try { store.listRunsForWorkspace(join(root, "other-project"))[0]?.id, otherProjectRun.id, ); - assert.deepEqual(store.listEvents(run.id, 2).map((event) => event.seq), [2, 3]); + assert.deepEqual(store.listEvents(run.id, 2).map((event) => event.seq), [3, 4]); // Reap: stale heartbeat + dead pid (force heartbeat via shared sqlite handle) const run3 = store.createRun({ @@ -169,7 +177,9 @@ try { scriptHash: "h3", workspaceRoot: join(root, "project"), }); - store.claimRun(run3.id, 2_147_483_646); + const dead = spawnSync(process.execPath, ["-e", ""]); + assert.ok(dead.pid); + store.claimRun(run3.id, dead.pid); const db = openDatabase(root); try { db.sqlite @@ -181,6 +191,7 @@ try { 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 run4 = store.createRun({ name: "seq", diff --git a/src/workflow-store.ts b/src/workflow-store.ts index 7568dce59..ae0854a47 100644 --- a/src/workflow-store.ts +++ b/src/workflow-store.ts @@ -87,6 +87,7 @@ export interface FailAgentCallInput { export interface CompleteRunInput { resultJson?: string; + callCount?: number; } export interface FailRunInput { @@ -97,6 +98,7 @@ export interface FailRunInput { export interface DrainEventsResult { events: WorkflowEventRecord[]; nextSeq: number; + hasMore: boolean; terminal: boolean; run: WorkflowRunRecord; } @@ -409,11 +411,14 @@ export class WorkflowStore { const updated = Result.try({ try: () => { const now = isoNow(); - this.database.sqlite + const update = this.database.sqlite .prepare( - `update workflow_runs set cancel_requested = 'true', updated_at = ? where id = ?`, + `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), @@ -439,18 +444,31 @@ export class WorkflowStore { return this.transitionRunResult(id, "complete", () => { if (input.resultJson !== undefined) assertResultSize(input.resultJson); const now = isoNow(); - return 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; + 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(); }); } @@ -464,17 +482,31 @@ export class WorkflowStore { ): BetterResult { return this.transitionRunResult(id, "fail", () => { const now = isoNow(); - return 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, input.errorKind ?? "internal", now, now, id).changes; + 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(); }); } @@ -488,18 +520,31 @@ export class WorkflowStore { ): BetterResult { return this.transitionRunResult(id, "cancel", () => { const now = isoNow(); - return 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; + 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(); }); } @@ -540,47 +585,11 @@ export class WorkflowStore { } appendEvent(input: AppendWorkflowEventInput): WorkflowEventRecord { - const payload = parseWorkflowEventPayload(input.type, input.data); - const dataJson = truncateJson(payload, WORKFLOW_LIMITS.eventDataJsonBytes); const createdAt = isoNow(); - - const insert = this.database.sqlite.transaction(() => { - 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 seq; - }); - - const seq = insert(); - return { - runId: input.runId, - seq, - type: input.type, - phase: input.phase, - label: input.label, - dataJson, - createdAt, - }; + const transaction = this.database.sqlite.transaction(() => + this.insertEventRow(input, createdAt), + ); + return transaction.immediate(); } drainEvents(runId: string, sinceSeq = 0, limit: number = WORKFLOW_LIMITS.eventDrainDefault): DrainEventsResult { @@ -593,13 +602,15 @@ export class WorkflowStore { order by seq asc limit ?`, ) - .all(runId, sinceSeq, capped) as WorkflowEventRow[]; - const events = rows.map(rowToEvent); + .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, - terminal: TERMINAL_STATUSES.has(run.status), + hasMore, + terminal: TERMINAL_STATUSES.has(run.status) && !hasMore, run, }; } @@ -755,16 +766,59 @@ export class WorkflowStore { const reaped: WorkflowRunRecord[] = []; for (const row of candidates) { if (row.pid !== null && isPidAlive(row.pid)) continue; - reaped.push( - this.failRun(row.id, { - error: "worker heartbeat lost", - errorKind: "heartbeat", - }), - ); + const latest = this.getRun(row.id); + if (!latest || latest.status !== "running") continue; + const failed = this.failRun(row.id, { + error: "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(); } @@ -868,7 +922,8 @@ function isPidAlive(pid: number): boolean { try { process.kill(pid, 0); return true; - } catch { + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EPERM") return true; return false; } } diff --git a/src/workflow-tools.ts b/src/workflow-tools.ts index b3255d9d9..e0e038e0a 100644 --- a/src/workflow-tools.ts +++ b/src/workflow-tools.ts @@ -400,12 +400,14 @@ async function yieldEvents( run: WorkflowRunRecord; events: WorkflowEventRecord[]; nextSeq: number; + hasMore: boolean; terminal: boolean; callSummary: ReturnType; }> { const deadline = Date.now() + Math.min(yieldMs, WORKFLOW_MCP_YIELD_MS); let cursor = sinceSeq; let events: WorkflowEventRecord[] = []; + let hasMore = false; let terminal = false; let run = store.getRun(runId)!; @@ -413,9 +415,11 @@ async function yieldEvents( const page = store.drainEvents(runId, cursor, WORKFLOW_LIMITS.eventDrainDefault); events = events.concat(page.events); cursor = page.nextSeq; + hasMore = page.hasMore; terminal = page.terminal; run = page.run; if (terminal || Date.now() >= deadline) break; + if (hasMore) continue; await sleep(250); } @@ -423,6 +427,7 @@ async function yieldEvents( run, events, nextSeq: cursor, + hasMore, terminal, callSummary: summarizeCalls(store.listAgentCalls(runId)), }; @@ -432,6 +437,7 @@ function toolResult(page: { run: WorkflowRunRecord; events: WorkflowEventRecord[]; nextSeq: number; + hasMore: boolean; terminal: boolean; callSummary: ReturnType; }, tool: "run_workflow" | "workflow_status") { @@ -452,6 +458,7 @@ function toolResult(page: { dataJson: e.dataJson, })), nextSeq: page.nextSeq, + hasMore: page.hasMore, result: page.run.resultJson ? safeJson(page.run.resultJson) : undefined, error: page.run.error, errorKind: page.run.errorKind, From 5528337da50b5dbd75fdbf1a34c9f7a10d6e1774 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:18:17 +0000 Subject: [PATCH 039/132] feat(workflow): add shared lifecycle supervisor --- package.json | 2 +- src/workflow-lifecycle.test.ts | 109 +++++++++++++++++++++ src/workflow-lifecycle.ts | 168 +++++++++++++++++++++++++++++++++ 3 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 src/workflow-lifecycle.test.ts create mode 100644 src/workflow-lifecycle.ts diff --git a/package.json b/package.json index 3f17654a7..8a776a85d 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-ui.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-lifecycle.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-ui.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/workflow-lifecycle.test.ts b/src/workflow-lifecycle.test.ts new file mode 100644 index 000000000..c0d9456c8 --- /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 000000000..d87bf7f7a --- /dev/null +++ b/src/workflow-lifecycle.ts @@ -0,0 +1,168 @@ +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 => { + const store = createWorkflowStore(config); + try { + 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); +} From e0b078abf6303b19cbd61c95aeb1a5be146787e6 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:18:17 +0000 Subject: [PATCH 040/132] fix(workflow): supervise cancellation and stale workers --- src/server.ts | 11 +++++++++++ src/workflow-cli.ts | 39 ++++++++------------------------------ src/workflow-store.test.ts | 19 +++++++++++++++++++ src/workflow-store.ts | 15 +++++++-------- src/workflow-tools.ts | 13 ++----------- 5 files changed, 47 insertions(+), 50 deletions(-) diff --git a/src/server.ts b/src/server.ts index 4b7300b63..707dc204c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -48,6 +48,7 @@ import { createWorkspaceStore } from "./workspace-store.js"; import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; import { summarizeLocalAgentProfile } from "./local-agent-profiles.js"; import { registerWorkflowTools } from "./workflow-tools.js"; +import { startWorkflowReaper } from "./workflow-lifecycle.js"; import { createWorkflowStore } from "./workflow-store.js"; import { loadActiveWorkflowSummaries } from "./workflow-ui.js"; import { @@ -1659,6 +1660,15 @@ export function createServer(config = loadConfig()): RunningServer { const localAgentProviders = config.subagents ? getLocalAgentProviderAvailabilitySnapshot() : []; + const workflowReaper = config.subagents + ? startWorkflowReaper(config, { + onError: (error) => { + logEvent(config.logging, "warn", "workflow_reaper_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }, + }) + : undefined; const logSessionCloseResults = ( reason: "idle_timeout" | "server_shutdown", @@ -1846,6 +1856,7 @@ export function createServer(config = loadConfig()): RunningServer { close: () => { closePromise ??= (async () => { clearInterval(sessionCleanupTimer); + workflowReaper?.close(); const results = await transports.closeAll(); logSessionCloseResults("server_shutdown", results); processSessions.shutdown(); diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts index 1e1bc9368..c666a84e1 100644 --- a/src/workflow-cli.ts +++ b/src/workflow-cli.ts @@ -21,10 +21,13 @@ import { resolveWorkflowScriptFromPathOrNameResult, } from "./workflow-files.js"; import { createWorkflowReplay } from "./workflow-replay.js"; +import { + cancelWorkflowRun, + reapStaleWorkflows, +} from "./workflow-lifecycle.js"; import { parseWorkflowScript } from "./workflow-script.js"; import { createWorkflowStore, type WorkflowStore } from "./workflow-store.js"; import { - WORKFLOW_CANCEL_HARD_MS, WORKFLOW_HEARTBEAT_MS, WORKFLOW_LIMITS, resolveWorkflowConcurrency, @@ -234,6 +237,7 @@ async function runWorkflowStatus(args: string[], config: ServerConfig): Promise< const store = createWorkflowStore(config); try { + reapStaleWorkflows(store); const runResult = store.getRunResult(runId); if (runResult.isErr()) throw runResult.error; const run = runResult.value; @@ -256,36 +260,8 @@ async function runWorkflowCancel(args: string[], config: ServerConfig): Promise< if (!runId) throw new Error("Usage: devspace workflow cancel "); const store = createWorkflowStore(config); try { - const requested = store.requestCancelResult(runId); - if (requested.isErr()) throw requested.error; - const run = requested.value; - console.log(formatRunLine(run)); - if (run.pid && (run.status === "running" || run.status === "starting")) { - try { - process.kill(run.pid, "SIGTERM"); - } catch { - // already dead - } - await sleep(WORKFLOW_CANCEL_HARD_MS); - const again = store.getRun(runId); - if (again && (again.status === "running" || again.status === "starting") && again.pid) { - try { - process.kill(-again.pid, "SIGKILL"); - } catch { - try { - process.kill(again.pid, "SIGKILL"); - } catch { - // gone - } - } - const latest = store.getRun(runId); - if (latest && (latest.status === "running" || latest.status === "starting")) { - const cancelled = store.cancelRunResult(runId, "cancelled (hard kill)"); - if (cancelled.isErr()) throw cancelled.error; - } - } - } - console.log(formatRunLine(store.getRun(runId)!)); + reapStaleWorkflows(store); + console.log(formatRunLine(await cancelWorkflowRun(store, runId))); } finally { store.close(); } @@ -294,6 +270,7 @@ async function runWorkflowCancel(args: string[], config: ServerConfig): Promise< async function runWorkflowList(config: ServerConfig): Promise { const store = createWorkflowStore(config); try { + reapStaleWorkflows(store); const runs = store.listRuns(50); if (runs.length === 0) { console.log("No workflow runs."); diff --git a/src/workflow-store.test.ts b/src/workflow-store.test.ts index 88a139cee..c0ab9f50d 100644 --- a/src/workflow-store.test.ts +++ b/src/workflow-store.test.ts @@ -193,6 +193,25 @@ try { 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", diff --git a/src/workflow-store.ts b/src/workflow-store.ts index ae0854a47..fe4035d37 100644 --- a/src/workflow-store.ts +++ b/src/workflow-store.ts @@ -749,27 +749,26 @@ export class WorkflowStore { } /** - * Mark running runs with a dead worker as failed. - * staleBeforeMs: heartbeat older than this AND pid not alive. + * 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 < ?`, + where (status = 'running' and heartbeat_at is not null and heartbeat_at < ?) + or (status = 'starting' and updated_at < ?)`, ) - .all(cutoff) as WorkflowRunRow[]; + .all(cutoff, cutoff) as WorkflowRunRow[]; const reaped: WorkflowRunRecord[] = []; for (const row of candidates) { if (row.pid !== null && isPidAlive(row.pid)) continue; const latest = this.getRun(row.id); - if (!latest || latest.status !== "running") continue; + if (!latest || (latest.status !== "running" && latest.status !== "starting")) continue; const failed = this.failRun(row.id, { - error: "worker heartbeat lost", + error: latest.status === "starting" ? "workflow worker failed to start" : "worker heartbeat lost", errorKind: "heartbeat", }); if (failed.status === "failed" && failed.errorKind === "heartbeat") { diff --git a/src/workflow-tools.ts b/src/workflow-tools.ts index e0e038e0a..cbae54b7b 100644 --- a/src/workflow-tools.ts +++ b/src/workflow-tools.ts @@ -21,6 +21,7 @@ import { } from "./workflow-types.js"; import { resolveWorkspaceHead } from "./workflow-worktrees.js"; import { spawnWorkflowWorkerFromCli } from "./workflow-cli.js"; +import { cancelWorkflowRun } from "./workflow-lifecycle.js"; import { getLocalAgentProviderAvailabilitySnapshot } from "./local-agent-availability.js"; import { isLocalAgentProvider, @@ -268,17 +269,7 @@ export function registerWorkflowTools( async ({ runId }) => { const store = createWorkflowStore(config); try { - const requested = store.requestCancelResult(runId); - if (requested.isErr()) throw requested.error; - const run = requested.value; - if (run.pid && (run.status === "running" || run.status === "starting")) { - try { - process.kill(run.pid, "SIGTERM"); - } catch { - // already gone - } - } - const latest = store.getRun(runId)!; + const latest = await cancelWorkflowRun(store, runId); return { content: [{ type: "text" as const, text: JSON.stringify({ runId, status: latest.status }) }], structuredContent: { runId, status: latest.status }, From 244d42eb173b3b1df4dd0c7d41fdddb48d032fbd Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:38:07 +0000 Subject: [PATCH 041/132] fix(workflow): close stale reaper races --- src/workflow-lifecycle.ts | 5 +++-- src/workflow-store.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/workflow-lifecycle.ts b/src/workflow-lifecycle.ts index d87bf7f7a..f66ac7a53 100644 --- a/src/workflow-lifecycle.ts +++ b/src/workflow-lifecycle.ts @@ -115,13 +115,14 @@ export function startWorkflowReaper( const staleAfterMs = Math.max(1, options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS); const tick = (): void => { - const store = createWorkflowStore(config); + let store: WorkflowStore | undefined; try { + store = createWorkflowStore(config); reapStaleWorkflows(store, staleAfterMs); } catch (error) { options.onError?.(error); } finally { - store.close(); + store?.close(); } }; diff --git a/src/workflow-store.ts b/src/workflow-store.ts index fe4035d37..cc3c7ac2f 100644 --- a/src/workflow-store.ts +++ b/src/workflow-store.ts @@ -764,9 +764,9 @@ export class WorkflowStore { const reaped: WorkflowRunRecord[] = []; for (const row of candidates) { - if (row.pid !== null && isPidAlive(row.pid)) continue; 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", From 0b52e5eb9d4658b4dd21629421c1c21cfad18ce7 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:38:07 +0000 Subject: [PATCH 042/132] fix(workflow): bound MCP event pages --- src/workflow-tools.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/workflow-tools.ts b/src/workflow-tools.ts index cbae54b7b..d31e499e7 100644 --- a/src/workflow-tools.ts +++ b/src/workflow-tools.ts @@ -410,7 +410,7 @@ async function yieldEvents( terminal = page.terminal; run = page.run; if (terminal || Date.now() >= deadline) break; - if (hasMore) continue; + if (hasMore) break; await sleep(250); } From ed623da91ccd80a587172b75aeb8feba2f24491c Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:27:59 +0000 Subject: [PATCH 043/132] feat(workflow): persist exact replay values --- src/db/migrations.ts | 10 ++++++++++ src/db/schema.ts | 1 + src/oauth-store.test.ts | 1 + src/workflow-store.test.ts | 2 ++ src/workflow-store.ts | 12 ++++++++++++ src/workflow-types.ts | 2 ++ 6 files changed, 28 insertions(+) diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 85f2fa681..91b4857cd 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -37,6 +37,11 @@ const migrations: Migration[] = [ name: "workflow-replay-provenance", up: migrateWorkflowReplayProvenance, }, + { + version: 7, + name: "workflow-exact-replay", + up: migrateWorkflowExactReplay, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -270,6 +275,7 @@ function migrateWorkflowJournal(sqlite: Database.Database): void { 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, @@ -302,6 +308,10 @@ function migrateWorkflowReplayProvenance(sqlite: Database.Database): void { `); } +function migrateWorkflowExactReplay(sqlite: Database.Database): void { + addColumnIfMissing(sqlite, "workflow_agent_calls", "return_value_json", "text"); +} + function addColumnIfMissing( sqlite: Database.Database, table: "workspace_sessions" | "local_agent_sessions" | "workflow_agent_calls", diff --git a/src/db/schema.ts b/src/db/schema.ts index 8f7e15186..01a01cf86 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -169,6 +169,7 @@ export const workflowAgentCalls = sqliteTable( providerSessionId: text("provider_session_id"), responseText: text("response_text"), structuredJson: text("structured_json"), + returnValueJson: text("return_value_json"), error: text("error"), errorKind: text("error_kind"), replayMatch: text("replay_match"), diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index bae526695..ecfc89a4a 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -47,6 +47,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise { { version: 4, name: "local-agent-effort-rename" }, { version: 5, name: "workflow-journal" }, { version: 6, name: "workflow-replay-provenance" }, + { version: 7, name: "workflow-exact-replay" }, ]); } finally { database.close(); diff --git a/src/workflow-store.test.ts b/src/workflow-store.test.ts index c0ab9f50d..58a47d31d 100644 --- a/src/workflow-store.test.ts +++ b/src/workflow-store.test.ts @@ -85,6 +85,7 @@ try { callIndex: 0, responseText: "done", structuredJson: JSON.stringify({ ok: true }), + returnValueJson: JSON.stringify({ ok: true, exact: true }), providerSessionId: "sess_1", dirty: true, }); @@ -95,6 +96,7 @@ try { assert.equal(call?.providerSessionId, "sess_1"); assert.equal(call?.effort, "high"); assert.equal(call?.prompt, "review"); + assert.equal(call?.returnValueJson, JSON.stringify({ ok: true, exact: true })); assert.equal(call?.replayReason, "identity_changed:prompt"); store.beginAgentCall({ diff --git a/src/workflow-store.ts b/src/workflow-store.ts index cc3c7ac2f..b383919df 100644 --- a/src/workflow-store.ts +++ b/src/workflow-store.ts @@ -70,6 +70,7 @@ export interface CompleteAgentCallInput { callIndex: number; responseText?: string; structuredJson?: string; + returnValueJson?: string; providerSessionId?: string; dirty?: boolean; worktreePath?: string; @@ -153,6 +154,7 @@ interface WorkflowAgentCallRow { provider_session_id: 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; @@ -673,6 +675,13 @@ export class WorkflowStore { if (input.structuredJson !== undefined) { assertTextSize(input.structuredJson, WORKFLOW_LIMITS.structuredJsonBytes, "structuredJson"); } + if (input.returnValueJson !== undefined) { + assertTextSize( + input.returnValueJson, + WORKFLOW_LIMITS.replayValueJsonBytes, + "returnValueJson", + ); + } const now = isoNow(); const status: WorkflowAgentCallStatus = input.fromCache ? "from_cache" : "completed"; this.database.sqlite @@ -682,6 +691,7 @@ export class WorkflowStore { from_cache = ?, response_text = ?, structured_json = ?, + return_value_json = ?, provider_session_id = coalesce(?, provider_session_id), worktree_path = coalesce(?, worktree_path), dirty = ?, @@ -694,6 +704,7 @@ export class WorkflowStore { 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", @@ -894,6 +905,7 @@ function rowToAgentCall(row: WorkflowAgentCallRow): WorkflowAgentCallRecord { providerSessionId: row.provider_session_id ?? undefined, 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: diff --git a/src/workflow-types.ts b/src/workflow-types.ts index ad050e1cd..cfff22edf 100644 --- a/src/workflow-types.ts +++ b/src/workflow-types.ts @@ -60,6 +60,7 @@ 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, @@ -149,6 +150,7 @@ export interface WorkflowAgentCallRecord { providerSessionId?: string; responseText?: string; structuredJson?: string; + returnValueJson?: string; error?: string; errorKind?: WorkflowErrorKind; replayMatch?: "same_index" | "compatible_key"; From 749ebb7e4adfde835814823f650958da7edc913a Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:27:59 +0000 Subject: [PATCH 044/132] fix(workflow): replay only deterministic call prefixes --- src/workflow-api.ts | 42 +++++++++-- src/workflow-engine.test.ts | 37 ++++++++- src/workflow-replay.test.ts | 64 +++++++++++----- src/workflow-replay.ts | 147 ++++++++++++++---------------------- 4 files changed, 170 insertions(+), 120 deletions(-) diff --git a/src/workflow-api.ts b/src/workflow-api.ts index 8ae13c92b..2d8309e94 100644 --- a/src/workflow-api.ts +++ b/src/workflow-api.ts @@ -64,6 +64,7 @@ export interface WorkflowReplayHit { value: JsonValue; responseText?: string; structuredJson?: string; + returnValueJson: string; providerSessionId?: string; replayMatch: "same_index" | "compatible_key"; replayedFromRunId: string; @@ -75,7 +76,11 @@ export interface WorkflowReplayMiss { | "no_compatible_call" | "prior_call_not_replayable" | "compatible_result_consumed" - | "identity_changed"; + | "identity_changed" + | "prefix_diverged" + | "worktree_not_restored" + | "result_not_persisted" + | "stored_result_invalid"; changedFields?: Array; } @@ -118,6 +123,7 @@ export interface WorkflowJournal { callIndex: number; responseText?: string; structuredJson?: string; + returnValueJson?: string; providerSessionId?: string; dirty?: boolean; worktreePath?: string; @@ -285,6 +291,7 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { callIndex: index, responseText: hit.responseText, structuredJson: hit.structuredJson, + returnValueJson: hit.returnValueJson, providerSessionId: hit.providerSessionId, fromCache: true, }); @@ -444,9 +451,8 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { runId: deps.runId, callIndex: index, responseText: truncate(result.finalResponse, WORKFLOW_LIMITS.responseTextBytes), - structuredJson: structuredJson - ? truncate(structuredJson, WORKFLOW_LIMITS.structuredJsonBytes) - : undefined, + structuredJson: boundedStructuredJson(structuredJson), + returnValueJson: serializeReplayValue(returnValue), providerSessionId: result.providerSessionId, dirty, worktreePath, @@ -721,10 +727,30 @@ function cancelledError(): WorkflowEngineError { function truncate(text: string, maxBytes: number): string { if (Buffer.byteLength(text, "utf8") <= maxBytes) return text; - // rough char truncate for journal safety - let end = Math.min(text.length, maxBytes); - while (end > 0 && Buffer.byteLength(text.slice(0, end), "utf8") > maxBytes) end -= 1; - return `${text.slice(0, end)}…`; + 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 boundedStructuredJson(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + return Buffer.byteLength(value, "utf8") <= WORKFLOW_LIMITS.structuredJsonBytes + ? value + : undefined; +} + +function serializeReplayValue(value: unknown): string | undefined { + try { + const json = JSON.stringify(value); + if (json === undefined) return undefined; + return Buffer.byteLength(json, "utf8") <= WORKFLOW_LIMITS.replayValueJsonBytes + ? json + : undefined; + } catch { + return undefined; + } } /** Minimal JSON extract for schema path until Ajv module lands. */ diff --git a/src/workflow-engine.test.ts b/src/workflow-engine.test.ts index 5c3438b31..4603c50e4 100644 --- a/src/workflow-engine.test.ts +++ b/src/workflow-engine.test.ts @@ -12,7 +12,7 @@ import { type WorkflowProviderRunInput, type CreateAgentWorktree, } from "./workflow-api.js"; -import { createStubBudget } from "./workflow-types.js"; +import { createStubBudget, WORKFLOW_LIMITS } from "./workflow-types.js"; // --------------------------------------------------------------------------- // Semaphore @@ -73,6 +73,8 @@ import { createStubBudget } from "./workflow-types.js"; ]); 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 }); } @@ -373,11 +375,44 @@ import { createStubBudget } from "./workflow-types.js"; assert.equal(calls[0]?.providerSessionId, undefined); assert.equal(calls[1]?.schema, undefined); 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 do not fail the live call +// --------------------------------------------------------------------------- +{ + 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, + enabledProviders: ["codex"], + runProvider: async () => ({ finalResponse: response }), + }); + + assert.equal(await api.agent("large"), response); + assert.equal(store.getAgentCall(run.id, 0)?.returnValueJson, undefined); + store.close(); + await rm(dir, { recursive: true, force: true }); +} + // --------------------------------------------------------------------------- // executeWorkflow end-to-end with sandbox + nest depth // --------------------------------------------------------------------------- diff --git a/src/workflow-replay.test.ts b/src/workflow-replay.test.ts index 7b0ac9933..0ab7beeeb 100644 --- a/src/workflow-replay.test.ts +++ b/src/workflow-replay.test.ts @@ -4,7 +4,7 @@ import type { WorkflowAgentCallRecord } from "./workflow-types.js"; function call( partial: Partial & - Pick, + Pick, ): WorkflowAgentCallRecord { return { runId: "wfr_prior", @@ -15,6 +15,7 @@ function call( isolation: "shared", createdAt: "t", updatedAt: "t", + returnValueJson: JSON.stringify(`result-${partial.callIndex}`), ...partial, }; } @@ -32,29 +33,22 @@ function identity(prompt = "prompt") { { const replay = createWorkflowReplay([ - call({ callIndex: 0, cacheKey: "k0", responseText: "a" }), - call({ callIndex: 1, cacheKey: "k1", responseText: "b" }), + 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, "k0", identity()).miss?.reason, "compatible_result_consumed"); + assert.equal(replay.decide(2, "k2", identity()).miss?.reason, "no_compatible_call"); } { - // fan-out reorder: callIndex mismatch, consume-once by key const replay = createWorkflowReplay([ - call({ callIndex: 0, cacheKey: "ka", responseText: "A" }), - call({ callIndex: 1, cacheKey: "kb", responseText: "B" }), + call({ callIndex: 0, cacheKey: "ka", returnValueJson: JSON.stringify("A") }), + call({ callIndex: 1, cacheKey: "kb", returnValueJson: JSON.stringify("B") }), ]); - // new run asks index0 for kb first - const reorderedB = replay.decide(0, "kb", identity()).hit; - assert.equal(reorderedB?.value, "B"); - assert.equal(reorderedB?.replayMatch, "compatible_key"); - assert.equal(replay.decide(1, "ka", identity()).hit?.value, "A"); - assert.equal( - replay.decide(2, "ka", identity()).miss?.reason, - "compatible_result_consumed", - ); + 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"); } { @@ -62,20 +56,52 @@ function identity(prompt = "prompt") { call({ callIndex: 0, cacheKey: "ks", - responseText: '{"ok":true}', + responseText: "bounded preview", structuredJson: '{"ok":true}', + returnValueJson: '{"ok":true,"text":"exact"}', }), ]); - assert.deepEqual(replay.decide(0, "ks", identity()).hit?.value, { ok: true }); + assert.deepEqual(replay.decide(0, "ks", identity()).hit?.value, { + ok: true, + text: "exact", + }); } { const replay = createWorkflowReplay([ - call({ callIndex: 0, cacheKey: "old", prompt: "old prompt", responseText: "a" }), + 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 index 25e2273a6..0daee9cbe 100644 --- a/src/workflow-replay.ts +++ b/src/workflow-replay.ts @@ -1,34 +1,22 @@ -import type { WorkflowAgentCallRecord } from "./workflow-types.js"; +import type { AgentCacheKeyInput, WorkflowAgentCallRecord } from "./workflow-types.js"; import type { WorkflowReplay, WorkflowReplayDecision, WorkflowReplayHit, } from "./workflow-api.js"; -import type { AgentCacheKeyInput } from "./workflow-types.js"; import { parseJsonText } from "./json-types.js"; -import { WorkflowStoredDataError } from "./workflow-errors.js"; /** - * Resume matcher: - * 1. Prefer same callIndex + cacheKey - * 2. On first miss for an index, fall back to consume-once by cacheKey - * (handles fan-out reordering vs prior run). + * 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(); - const byKeyQueue = new Map(); - - for (const call of priorCalls) { - if (call.status !== "completed" && call.status !== "from_cache") continue; - byIndex.set(call.callIndex, call); - const queue = byKeyQueue.get(call.cacheKey) ?? []; - queue.push(call); - byKeyQueue.set(call.cacheKey, queue); - } - - const consumed = new Set(); // `${callIndex}` of prior rows consumed + const byIndex = new Map(priorCalls.map((call) => [call.callIndex, call])); + let prefixOpen = true; return { decide( @@ -36,93 +24,57 @@ export function createWorkflowReplay( cacheKey: string, input: AgentCacheKeyInput, ): WorkflowReplayDecision { - const exact = byIndex.get(callIndex); - if (exact && exact.cacheKey === cacheKey && !consumed.has(indexKey(exact))) { - consumed.add(indexKey(exact)); - removeFromKeyQueue(byKeyQueue, exact); - return { hit: toHit(exact, "same_index") }; - } + if (!prefixOpen) return { miss: { reason: "prefix_diverged" } }; - const queue = byKeyQueue.get(cacheKey); - if (queue && queue.length > 0) { - const next = queue.shift()!; - consumed.add(indexKey(next)); - if (queue.length === 0) byKeyQueue.delete(cacheKey); - return { hit: toHit(next, "compatible_key") }; + 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" } }); } - - const priorAtIndex = priorCalls.find((call) => call.callIndex === callIndex); - if (priorAtIndex) { - if (priorAtIndex.status !== "completed" && priorAtIndex.status !== "from_cache") { - return { miss: { reason: "prior_call_not_replayable" } }; - } - if (priorAtIndex.cacheKey !== cacheKey) { - return { - miss: { - reason: "identity_changed", - changedFields: changedIdentityFields(priorAtIndex, input), - }, - }; - } - return { miss: { reason: "compatible_result_consumed" } }; + 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" } }); } - if (priorCalls.some((call) => call.cacheKey === cacheKey)) { - return { miss: { reason: "compatible_result_consumed" } }; + try { + return { + hit: toHit(prior, parseJsonText(prior.returnValueJson)), + }; + } catch { + return close({ miss: { reason: "stored_result_invalid" } }); } - return { miss: { reason: "no_compatible_call" } }; }, }; -} - -function indexKey(call: WorkflowAgentCallRecord): string { - return `${call.runId}:${call.callIndex}`; -} -function removeFromKeyQueue( - map: Map, - call: WorkflowAgentCallRecord, -): void { - const queue = map.get(call.cacheKey); - if (!queue) return; - const idx = queue.findIndex( - (row) => row.runId === call.runId && row.callIndex === call.callIndex, - ); - if (idx >= 0) queue.splice(idx, 1); - if (queue.length === 0) map.delete(call.cacheKey); + function close(decision: WorkflowReplayDecision): WorkflowReplayDecision { + prefixOpen = false; + return decision; + } } function toHit( call: WorkflowAgentCallRecord, - replayMatch: WorkflowReplayHit["replayMatch"], + value: WorkflowReplayHit["value"], ): WorkflowReplayHit { - const provenance = { - replayMatch, - replayedFromRunId: call.runId, - replayedFromCallIndex: call.callIndex, - } as const; - if (call.structuredJson) { - try { - return { - value: parseJsonText(call.structuredJson), - responseText: call.responseText, - structuredJson: call.structuredJson, - providerSessionId: call.providerSessionId, - ...provenance, - }; - } catch (cause) { - throw new WorkflowStoredDataError( - `${call.runId}.agentCalls[${call.callIndex}].structuredJson`, - cause, - ); - } - } return { - value: call.responseText ?? "", + value, responseText: call.responseText, structuredJson: call.structuredJson, + returnValueJson: call.returnValueJson!, providerSessionId: call.providerSessionId, - ...provenance, + replayMatch: "same_index", + replayedFromRunId: call.runId, + replayedFromCallIndex: call.callIndex, }; } @@ -135,9 +87,20 @@ function changedIdentityFields( 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"); - const priorSchema = prior.schemaJson ? JSON.stringify(parseJsonText(prior.schemaJson)) : null; - const currentSchema = current.schema === null ? null : JSON.stringify(current.schema); - if (priorSchema !== currentSchema) changed.push("schema"); + 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; + } +} From 4b0802fb67d6d22270d6c7d581a18c76fcede090 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:28:54 +0000 Subject: [PATCH 045/132] docs(workflow): explain deterministic prefix replay --- skills/dynamic-workflows/SKILL.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/skills/dynamic-workflows/SKILL.md b/skills/dynamic-workflows/SKILL.md index 433f8b693..c6799c41d 100644 --- a/skills/dynamic-workflows/SKILL.md +++ b/skills/dynamic-workflows/SKILL.md @@ -100,14 +100,16 @@ Failed and cancelled runs are terminal. Recovery creates a **new** run: 4. Run `devspace workflow run --resume ` (optionally with `--script-path `). -Replay first matches the same call index and cache key, then consumes one -compatible prior cache key after reordering. The new run records whether each -call was reused by same-index or compatible-key matching, and where it came -from. Failed, interrupted, changed, or unmatched calls execute live. - -Replay restores an agent's **return value**. It does not recreate shared-checkout -edits or reapply a prior worktree diff. Verify required filesystem state before -depending on a replayed mutating call. +Replay walks the prior run in call-index order and reuses the longest unchanged +prefix. The first failed, interrupted, changed, missing, corrupt, or unavailable +result executes live and closes replay for every later call, even when a later +cache key happens to match. Exact return values are stored separately from +bounded UI previews. + +Replay restores an agent's **return value**, not its execution. Shared-checkout +calls assume their existing filesystem effects are still present. Worktree calls +are never reused unless their exact worktree can be restored, so they currently +end the reusable prefix and run live. ### Cancel From 8f302252d52101ae9fd1a853b890a75878390bee Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:41:23 +0000 Subject: [PATCH 046/132] test(workflow): cover oversized structured results --- src/workflow-engine.test.ts | 44 +++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/workflow-engine.test.ts b/src/workflow-engine.test.ts index 4603c50e4..7d64bfc9c 100644 --- a/src/workflow-engine.test.ts +++ b/src/workflow-engine.test.ts @@ -413,6 +413,50 @@ import { createStubBudget, WORKFLOW_LIMITS } from "./workflow-types.js"; await rm(dir, { recursive: true, force: true }); } +// --------------------------------------------------------------------------- +// oversized structured results remain intact without persisting invalid JSON +// --------------------------------------------------------------------------- +{ + 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, + enabledProviders: ["codex"], + runProvider: async () => ({ + finalResponse: JSON.stringify({ big }), + structured: { big }, + }), + }); + + assert.deepEqual( + await api.agent("large structured", { + schema: { + type: "object", + properties: { big: { type: "string" } }, + required: ["big"], + }, + }), + { big }, + ); + assert.equal(store.getAgentCall(run.id, 0)?.structuredJson, undefined); + store.close(); + await rm(dir, { recursive: true, force: true }); +} + // --------------------------------------------------------------------------- // executeWorkflow end-to-end with sandbox + nest depth // --------------------------------------------------------------------------- From 287cbce9648f3b7deccdbabb8a6bc82f8e4456ec Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:42:48 +0000 Subject: [PATCH 047/132] refactor(agents): share profile prompt identity helpers --- src/cli.ts | 4 ++-- src/local-agent-profiles.test.ts | 16 +++++++++++++++- src/local-agent-profiles.ts | 24 ++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index cd70843c8..a30108daa 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,6 +13,7 @@ import { satisfies } from "semver"; import { loadConfig } from "./config.js"; import { runLocalAgentProvider } from "./local-agent-adapters.js"; import { + buildLocalAgentProfilePrompt, isLocalAgentProvider, loadLocalAgentProfiles, LOCAL_AGENT_PROVIDERS, @@ -569,8 +570,7 @@ async function runLocalAgentProfile( record: LocalAgentRecord, prompt: string, ): Promise { - const body = profile.body.trim(); - const fullPrompt = body ? `${body}\n\nTask:\n${prompt}` : prompt; + const fullPrompt = buildLocalAgentProfilePrompt(profile, prompt); return runLocalAgentProvider(profile.provider, { prompt: fullPrompt, workspace: record.workspaceRoot, diff --git a/src/local-agent-profiles.test.ts b/src/local-agent-profiles.test.ts index b30f9474b..b38140497 100644 --- a/src/local-agent-profiles.test.ts +++ b/src/local-agent-profiles.test.ts @@ -3,7 +3,12 @@ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig } from "./config.js"; -import { loadLocalAgentProfiles, summarizeLocalAgentProfile } from "./local-agent-profiles.js"; +import { + buildLocalAgentProfilePrompt, + fingerprintLocalAgentProfile, + loadLocalAgentProfiles, + summarizeLocalAgentProfile, +} from "./local-agent-profiles.js"; import type { ServerConfig } from "./config.js"; const root = await mkdtemp(join(tmpdir(), "devspace-agent-profiles-test-")); @@ -73,6 +78,15 @@ try { assert.equal(profiles[0]?.model, "sonnet"); assert.equal(profiles[0]?.effort, "high"); assert.equal(profiles[0]?.body, "Project body."); + assert.equal( + buildLocalAgentProfilePrompt(profiles[0]!, "Review auth"), + "Project body.\n\nTask:\nReview auth", + ); + assert.equal(fingerprintLocalAgentProfile(profiles[0]!).length, 64); + assert.notEqual( + fingerprintLocalAgentProfile(profiles[0]!), + fingerprintLocalAgentProfile({ ...profiles[0]!, body: "Changed body." }), + ); assert.deepEqual(summarizeLocalAgentProfile(profiles[0]!), { name: "reviewer", description: "Project reviewer #1.", diff --git a/src/local-agent-profiles.ts b/src/local-agent-profiles.ts index 9cf846f68..0dd919d2a 100644 --- a/src/local-agent-profiles.ts +++ b/src/local-agent-profiles.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import { readdir, readFile } from "node:fs/promises"; import { basename, join, resolve } from "node:path"; @@ -77,6 +78,29 @@ export function summarizeLocalAgentProfile( }; } +export function fingerprintLocalAgentProfile(profile: LocalAgentProfile): string { + return createHash("sha256") + .update( + JSON.stringify({ + name: profile.name, + description: profile.description, + provider: profile.provider, + model: profile.model ?? null, + effort: profile.effort ?? null, + body: profile.body, + }), + ) + .digest("hex"); +} + +export function buildLocalAgentProfilePrompt( + profile: Pick, + task: string, +): string { + const body = profile.body.trim(); + return body ? `${body}\n\nTask:\n${task}` : task; +} + async function loadProfilesFromDirectory(directory: string): Promise { const resolvedDirectory = resolve(directory); if (!existsSync(resolvedDirectory)) return []; From 550f776685ea14588d9a0db90d816a10ea6e8ec7 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:42:48 +0000 Subject: [PATCH 048/132] feat(workflow): select configured agent profiles --- src/db/migrations.ts | 12 +++++ src/db/schema.ts | 2 + src/oauth-store.test.ts | 1 + src/workflow-api.ts | 104 +++++++++++++++++++++++++++++++----- src/workflow-cli.ts | 3 ++ src/workflow-contracts.ts | 13 ++++- src/workflow-engine.test.ts | 91 +++++++++++++++++++++++++++++++ src/workflow-engine.ts | 7 ++- src/workflow-replay.test.ts | 2 + src/workflow-replay.ts | 4 ++ src/workflow-store.test.ts | 4 ++ src/workflow-store.ts | 13 ++++- src/workflow-types.test.ts | 4 ++ src/workflow-types.ts | 8 +++ 14 files changed, 251 insertions(+), 17 deletions(-) diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 91b4857cd..43389ee5c 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -42,6 +42,11 @@ const migrations: Migration[] = [ name: "workflow-exact-replay", up: migrateWorkflowExactReplay, }, + { + version: 8, + name: "workflow-agent-profiles", + up: migrateWorkflowAgentProfiles, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -268,6 +273,8 @@ function migrateWorkflowJournal(sqlite: Database.Database): void { provider text not null, model text, effort text, + profile_name text, + profile_fingerprint text, label text, phase text, status text not null, @@ -312,6 +319,11 @@ 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 addColumnIfMissing( sqlite: Database.Database, table: "workspace_sessions" | "local_agent_sessions" | "workflow_agent_calls", diff --git a/src/db/schema.ts b/src/db/schema.ts index 01a01cf86..a087bae90 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -162,6 +162,8 @@ export const workflowAgentCalls = sqliteTable( 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(), diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index ecfc89a4a..78a662149 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -48,6 +48,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise { { version: 5, name: "workflow-journal" }, { version: 6, name: "workflow-replay-provenance" }, { version: 7, name: "workflow-exact-replay" }, + { version: 8, name: "workflow-agent-profiles" }, ]); } finally { database.close(); diff --git a/src/workflow-api.ts b/src/workflow-api.ts index 2d8309e94..314d82185 100644 --- a/src/workflow-api.ts +++ b/src/workflow-api.ts @@ -1,7 +1,12 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; import type { WorkflowSandboxApi } from "./workflow-sandbox.js"; -import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import { + buildLocalAgentProfilePrompt, + fingerprintLocalAgentProfile, + type LocalAgentProfile, + type LocalAgentProvider, +} from "./local-agent-profiles.js"; import type { JsonSchema, JsonValue } from "./json-types.js"; import { jsonValueSchema } from "./json-types.js"; import { @@ -109,6 +114,8 @@ export interface WorkflowJournal { provider: LocalAgentProvider; model?: string; effort?: string; + profileName?: string; + profileFingerprint?: string; label?: string; phase?: string; isolation?: AgentIsolationMode; @@ -151,6 +158,8 @@ export interface WorkflowApiDeps { baseSha?: string; /** Already-filtered enabled ∩ live provider ids, preference order. */ enabledProviders: LocalAgentProvider[]; + /** Loaded, enabled profiles exposed by open_workspace for this project. */ + agentProfiles?: LocalAgentProfile[]; runProvider: WorkflowRunProvider; createWorktree?: CreateAgentWorktree; replay?: WorkflowReplay; @@ -177,6 +186,7 @@ export class WorkflowEngineError extends Error { | "provider_disabled" | "provider_unavailable" | "no_provider" + | "profile" | "nest_depth" | "worktree" | "schema" @@ -250,7 +260,15 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { const agentOpts = normalizeAgentOpts(opts); throwIfCancelled(deps); - const provider = resolveProvider(agentOpts.provider, deps.meta, deps.enabledProviders); + 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"; @@ -259,9 +277,11 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { const cacheKeyInput = buildAgentCacheKeyInput({ prompt, + profileName, + profileFingerprint, provider, - model: agentOpts.model, - effort: agentOpts.effort, + model, + effort, schema: agentOpts.schema, isolation, }); @@ -277,8 +297,10 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { prompt, schemaJson: agentOpts.schema ? JSON.stringify(agentOpts.schema) : undefined, provider, - model: agentOpts.model, - effort: agentOpts.effort, + model, + effort, + profileName, + profileFingerprint, label: agentOpts.label, phase, isolation, @@ -349,8 +371,10 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { prompt, schemaJson: agentOpts.schema ? JSON.stringify(agentOpts.schema) : undefined, provider, - model: agentOpts.model, - effort: agentOpts.effort, + model, + effort, + profileName, + profileFingerprint, label: agentOpts.label, phase, isolation, @@ -377,9 +401,9 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { const cwd = worktreePath ?? deps.workspaceRoot; const providerBase = { provider, - prompt, - model: agentOpts.model, - effort: agentOpts.effort, + prompt: providerPrompt, + model, + effort, workspace: cwd, signal: deps.signal, label: agentOpts.label, @@ -395,7 +419,7 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { const { enforceAgentSchema } = await import("./workflow-schema.js"); const enforced = await enforceAgentSchema({ schema: agentOpts.schema, - prompt, + prompt: providerPrompt, provider, run: (p, options) => deps.runProvider({ @@ -690,6 +714,54 @@ export function resolveProvider( return first; } +interface ResolvedAgentTarget { + provider: LocalAgentProvider; + model?: string; + effort?: string; + profileName?: string; + profileFingerprint?: string; + providerPrompt: string; +} + +function resolveAgentTarget( + prompt: string, + opts: AgentOpts, + deps: Pick, +): ResolvedAgentTarget { + if (!opts.profile) { + return { + provider: resolveProvider(opts.provider, deps.meta, deps.enabledProviders), + model: opts.model, + effort: opts.effort, + providerPrompt: prompt, + }; + } + + const profile = deps.agentProfiles?.find((candidate) => candidate.name === opts.profile); + if (!profile) { + const available = deps.agentProfiles?.map((candidate) => candidate.name).join(", "); + throw new WorkflowEngineError( + "profile", + `Unknown agent profile: ${opts.profile}${available ? `. Available profiles: ${available}` : ""}`, + ); + } + if (!deps.enabledProviders.includes(profile.provider)) { + throw new WorkflowEngineError( + "provider_unavailable", + `Agent profile ${profile.name} requires unavailable provider ${profile.provider}`, + ); + } + + return { + provider: profile.provider, + model: opts.model ?? profile.model, + effort: opts.effort ?? profile.effort, + profileName: profile.name, + profileFingerprint: fingerprintLocalAgentProfile(profile), + providerPrompt: buildLocalAgentProfilePrompt(profile, prompt), + }; +} + function normalizeAgentOpts(opts: unknown): AgentOpts { if (opts === undefined || opts === null) return {}; if (typeof opts === "object" && opts !== null && "writeMode" in opts) { @@ -699,7 +771,13 @@ function normalizeAgentOpts(opts: unknown): AgentOpts { 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" : "internal"; + 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"}`, diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts index c666a84e1..2b9a5a434 100644 --- a/src/workflow-cli.ts +++ b/src/workflow-cli.ts @@ -9,6 +9,7 @@ import { runLocalAgentProviderResult } from "./local-agent-adapters.js"; import { getLocalAgentProviderAvailabilitySnapshot } from "./local-agent-availability.js"; import { isLocalAgentProvider, + loadLocalAgentProfiles, LOCAL_AGENT_PROVIDERS, type LocalAgentProvider, } from "./local-agent-profiles.js"; @@ -346,6 +347,7 @@ export async function runWorkflowWorker( const source = await readFile(claimed.scriptPath, "utf8"); const parsed = parseWorkflowScript(source, { filename: claimed.scriptPath }); const enabledProviders = resolveEnabledProviders(config.agentProviders); + const agentProfiles = await loadLocalAgentProfiles(config, claimed.workspaceRoot); const concurrency = resolveWorkflowConcurrency( parsed.meta.concurrency, availableParallelism(), @@ -378,6 +380,7 @@ export async function runWorkflowWorker( workspaceRoot: claimed.workspaceRoot, baseSha: claimed.baseSha, enabledProviders, + agentProfiles, createWorktree, replay, runProvider: async (input) => { diff --git a/src/workflow-contracts.ts b/src/workflow-contracts.ts index 1944b1c50..bbdd091df 100644 --- a/src/workflow-contracts.ts +++ b/src/workflow-contracts.ts @@ -60,10 +60,20 @@ export const agentOptsSchema = z 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(); + .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, @@ -115,6 +125,7 @@ export const workflowErrorKindSchema = z.enum([ "provider_unavailable", "no_provider", "provider", + "profile", "schema", "cancelled", "timeout", diff --git a/src/workflow-engine.test.ts b/src/workflow-engine.test.ts index 7d64bfc9c..2fa10f885 100644 --- a/src/workflow-engine.test.ts +++ b/src/workflow-engine.test.ts @@ -13,6 +13,7 @@ import { type CreateAgentWorktree, } from "./workflow-api.js"; import { createStubBudget, WORKFLOW_LIMITS } from "./workflow-types.js"; +import type { LocalAgentProfile } from "./local-agent-profiles.js"; // --------------------------------------------------------------------------- // Semaphore @@ -327,6 +328,96 @@ import { createStubBudget, WORKFLOW_LIMITS } from "./workflow-types.js"; 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, + enabledProviders: ["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, + enabledProviders: ["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: native schema only on first attempt + provider session reuse // --------------------------------------------------------------------------- diff --git a/src/workflow-engine.ts b/src/workflow-engine.ts index 64a4dfc1b..e9a2fbbe4 100644 --- a/src/workflow-engine.ts +++ b/src/workflow-engine.ts @@ -1,5 +1,8 @@ import { availableParallelism } from "node:os"; -import type { LocalAgentProvider } from "./local-agent-profiles.js"; +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"; @@ -36,6 +39,7 @@ export interface ExecuteWorkflowOptions { workspaceRoot: string; baseSha?: string; enabledProviders: LocalAgentProvider[]; + agentProfiles?: LocalAgentProfile[]; runProvider: WorkflowRunProvider; createWorktree?: CreateAgentWorktree; replay?: WorkflowReplay; @@ -81,6 +85,7 @@ export async function executeWorkflow( workspaceRoot: options.workspaceRoot, baseSha: options.baseSha, enabledProviders: options.enabledProviders, + agentProfiles: options.agentProfiles, runProvider: options.runProvider, createWorktree: options.createWorktree, replay: options.replay, diff --git a/src/workflow-replay.test.ts b/src/workflow-replay.test.ts index 0ab7beeeb..d6e34fcd2 100644 --- a/src/workflow-replay.test.ts +++ b/src/workflow-replay.test.ts @@ -23,6 +23,8 @@ function call( function identity(prompt = "prompt") { return { prompt, + profileName: null, + profileFingerprint: null, provider: "codex" as const, model: null, effort: null, diff --git a/src/workflow-replay.ts b/src/workflow-replay.ts index 0daee9cbe..ffbc8eb99 100644 --- a/src/workflow-replay.ts +++ b/src/workflow-replay.ts @@ -84,6 +84,10 @@ function changedIdentityFields( ): 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"); diff --git a/src/workflow-store.test.ts b/src/workflow-store.test.ts index 58a47d31d..7815e1c83 100644 --- a/src/workflow-store.test.ts +++ b/src/workflow-store.test.ts @@ -75,6 +75,8 @@ try { provider: "codex", model: "gpt-5.4", effort: "high", + profileName: "reviewer", + profileFingerprint: "profile-hash", phase: "Review", isolation: "worktree", worktreePath: "/tmp/wt", @@ -95,6 +97,8 @@ try { assert.equal(call?.dirty, true); assert.equal(call?.providerSessionId, "sess_1"); 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"); diff --git a/src/workflow-store.ts b/src/workflow-store.ts index b383919df..5f59df778 100644 --- a/src/workflow-store.ts +++ b/src/workflow-store.ts @@ -55,6 +55,8 @@ export interface BeginAgentCallInput { provider: string; model?: string; effort?: string; + profileName?: string; + profileFingerprint?: string; label?: string; phase?: string; isolation?: AgentIsolationMode; @@ -147,6 +149,8 @@ interface WorkflowAgentCallRow { provider: string; model: string | null; effort: string | null; + profile_name: string | null; + profile_fingerprint: string | null; label: string | null; phase: string | null; status: string; @@ -638,11 +642,12 @@ export class WorkflowStore { this.database.sqlite .prepare( `insert into workflow_agent_calls ( - run_id, call_index, cache_key, prompt, schema_json, provider, model, effort, label, phase, + 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', ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', 'false', ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( input.runId, @@ -653,6 +658,8 @@ export class WorkflowStore { input.provider, input.model ?? null, input.effort ?? null, + input.profileName ?? null, + input.profileFingerprint ?? null, input.label ?? null, input.phase ?? null, isolation, @@ -898,6 +905,8 @@ function rowToAgentCall(row: WorkflowAgentCallRow): WorkflowAgentCallRecord { 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), diff --git a/src/workflow-types.test.ts b/src/workflow-types.test.ts index 374e167d5..55e1abc87 100644 --- a/src/workflow-types.test.ts +++ b/src/workflow-types.test.ts @@ -22,6 +22,8 @@ assert.deepEqual( }), { prompt: "hi", + profileName: null, + profileFingerprint: null, provider: "codex", model: null, effort: "high", @@ -37,6 +39,8 @@ assert.deepEqual( }), { prompt: "x", + profileName: null, + profileFingerprint: null, provider: "claude", model: null, effort: null, diff --git a/src/workflow-types.ts b/src/workflow-types.ts index cfff22edf..09bbe6bdc 100644 --- a/src/workflow-types.ts +++ b/src/workflow-types.ts @@ -143,6 +143,8 @@ export interface WorkflowAgentCallRecord { provider: AgentProviderId; model?: string; effort?: string; + profileName?: string; + profileFingerprint?: string; label?: string; phase?: string; status: WorkflowAgentCallStatus; @@ -176,6 +178,8 @@ export interface WorkflowAgentCallRecord { */ export interface AgentCacheKeyInput { prompt: string; + profileName: string | null; + profileFingerprint: string | null; provider: AgentProviderId; model: string | null; effort: string | null; @@ -185,6 +189,8 @@ export interface AgentCacheKeyInput { export function buildAgentCacheKeyInput(input: { prompt: string; + profileName?: string | null; + profileFingerprint?: string | null; provider: AgentProviderId; model?: string | null; effort?: string | null; @@ -195,6 +201,8 @@ export function buildAgentCacheKeyInput(input: { 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, From 229f28270afeb1abd958957dc4cac90a408d8111 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:51:28 +0000 Subject: [PATCH 049/132] fix(workflow): honor nested workflow context --- src/workflow-api.ts | 23 +++++++--- src/workflow-engine.test.ts | 9 ++-- src/workflow-engine.ts | 83 +++++++++---------------------------- 3 files changed, 44 insertions(+), 71 deletions(-) diff --git a/src/workflow-api.ts b/src/workflow-api.ts index 314d82185..82fbbfd0c 100644 --- a/src/workflow-api.ts +++ b/src/workflow-api.ts @@ -172,6 +172,7 @@ export interface WorkflowApiDeps { nestDepth: number; }) => Promise; nestDepth?: number; + runtime?: WorkflowApiRuntime; } export interface WorkflowApi extends WorkflowSandboxApi { @@ -242,6 +243,18 @@ export class WorkflowSemaphore { } } +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 // --------------------------------------------------------------------------- @@ -250,8 +263,8 @@ const phaseAls = new AsyncLocalStorage(); export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { const nestDepth = deps.nestDepth ?? 0; - const semaphore = new WorkflowSemaphore(Math.max(1, deps.concurrency)); - let callIndex = 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()) { @@ -272,8 +285,8 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { const phase = agentOpts.phase ?? phaseAls.getStore(); const isolation: AgentIsolationMode = agentOpts.isolation === "worktree" ? "worktree" : "shared"; - const index = callIndex; - callIndex += 1; + const index = runtime.callIndex; + runtime.callIndex += 1; const cacheKeyInput = buildAgentCacheKeyInput({ prompt, @@ -664,7 +677,7 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { budget: createStubBudget(), workflow: workflow as WorkflowSandboxApi["workflow"], meta: deps.meta, - getCallCount: () => callIndex, + getCallCount: () => runtime.callIndex, getNestDepth: () => nestDepth, }; } diff --git a/src/workflow-engine.test.ts b/src/workflow-engine.test.ts index 2fa10f885..4eb6aa173 100644 --- a/src/workflow-engine.test.ts +++ b/src/workflow-engine.test.ts @@ -566,15 +566,16 @@ import type { LocalAgentProfile } from "./local-agent-profiles.js"; await writeFile( childPath, ` -export const meta = { name: 'child', description: 'nested' } +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' } +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 } @@ -582,9 +583,10 @@ return { a, nested } runId: run.id, journal: store, workspaceRoot: dir, - enabledProviders: ["codex"], + enabledProviders: ["codex", "claude"], runProvider: async (input) => { prompts.push(input.prompt); + providers.push(input.provider); return { finalResponse: `R:${input.prompt}` }; }, resolveNestedSource: async (ref) => { @@ -602,6 +604,7 @@ return { a, nested } }); assert.equal(callCount, 2); assert.deepEqual(prompts, ["parent-prompt", "nested-prompt"]); + assert.deepEqual(providers, ["codex", "claude"]); // depth 2 must fail await assert.rejects( diff --git a/src/workflow-engine.ts b/src/workflow-engine.ts index e9a2fbbe4..7d7d34f01 100644 --- a/src/workflow-engine.ts +++ b/src/workflow-engine.ts @@ -8,8 +8,10 @@ import { parseWorkflowScript, type ParsedWorkflowScript } from "./workflow-scrip import { runWorkflowSandbox } from "./workflow-sandbox.js"; import { createWorkflowApi, + createWorkflowApiRuntime, type CreateAgentWorktree, type WorkflowApi, + type WorkflowApiRuntime, type WorkflowJournal, type WorkflowReplay, type WorkflowRunProvider, @@ -46,6 +48,8 @@ export interface ExecuteWorkflowOptions { 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; } @@ -73,6 +77,7 @@ export async function executeWorkflow( 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({ @@ -89,17 +94,25 @@ export async function executeWorkflow( runProvider: options.runProvider, createWorktree: options.createWorktree, replay: options.replay, + runtime, nestDepth, resolveNestedSource, executeNested: resolveNestedSource ? async (input) => - executeNestedOnApi({ - parentOptions: options, - parentApi: api, - source: input.source, - args: input.args, - nestDepth: input.nestDepth, - }) + ( + 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); @@ -136,62 +149,6 @@ export async function executeWorkflow( } } -/** - * Nested script execution reusing parent's agent() call counter + semaphore - * by constructing a child API that shares internal state via re-entry. - * - * Implementation: run child sandbox with a new API that has nestDepth+1 but - * delegates agent/parallel/pipeline to the parent API (same callIndex). - */ -async function executeNestedOnApi(input: { - parentOptions: ExecuteWorkflowOptions; - parentApi: WorkflowApi; - source: string; - args: JsonValue | undefined; - nestDepth: number; -}): Promise { - if (input.nestDepth > WORKFLOW_MAX_NEST_DEPTH_LOCAL) { - throw new WorkflowEngineError( - "nest_depth", - `workflow() nesting limited to ${WORKFLOW_MAX_NEST_DEPTH_LOCAL} level`, - ); - } - const parsed = parseWorkflowScript(input.source, { - filename: "workflow:nested", - }); - - // Child surface: reuse parent agent/parallel/pipeline/phase/log/budget/workflow - // so callIndex + semaphore stay shared. Override args + meta for the child body. - const childApi: WorkflowApi = { - agent: input.parentApi.agent, - parallel: input.parentApi.parallel, - pipeline: input.parentApi.pipeline, - phase: input.parentApi.phase, - log: input.parentApi.log, - args: input.args, - budget: input.parentApi.budget, - // Child workflow() must see nestDepth via a wrapper that throws at depth>1. - workflow: async (...args: unknown[]) => { - throw new WorkflowEngineError( - "nest_depth", - `workflow() nesting limited to ${WORKFLOW_MAX_NEST_DEPTH_LOCAL} level`, - ); - }, - meta: parsed.meta, - getCallCount: () => input.parentApi.getCallCount(), - getNestDepth: () => input.nestDepth, - }; - - return runWorkflowSandbox({ - parsed, - api: childApi, - timeoutMs: input.parentOptions.timeoutMs ?? WORKFLOW_HOST_TIMEOUT_MS, - signal: input.parentOptions.signal, - }); -} - -const WORKFLOW_MAX_NEST_DEPTH_LOCAL = 1; - export function mapEngineErrorKind(error: unknown): WorkflowErrorKind { if (error instanceof WorkflowEngineError) { return error.kind; From 7675c14f958995f4d600b562b7af76c9b7109372 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:51:28 +0000 Subject: [PATCH 050/132] fix(workflow): confine project workflow scripts --- skills/dynamic-workflows/SKILL.md | 2 +- src/workflow-cli.ts | 8 +++- src/workflow-errors.ts | 7 +++- src/workflow-files.test.ts | 28 ++++++++++++++ src/workflow-files.ts | 61 +++++++++++++++++++++++++++++-- 5 files changed, 99 insertions(+), 7 deletions(-) diff --git a/skills/dynamic-workflows/SKILL.md b/skills/dynamic-workflows/SKILL.md index c6799c41d..3b0a94449 100644 --- a/skills/dynamic-workflows/SKILL.md +++ b/skills/dynamic-workflows/SKILL.md @@ -28,7 +28,7 @@ devspace workflow call devspace workflow tui [runId] ``` -Named scripts: `.devspace/workflows/.js` or `workflows/.js`. +Project named scripts live under `.devspace/workflows/.js`. ## Script shape diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts index 2b9a5a434..002d476ce 100644 --- a/src/workflow-cli.ts +++ b/src/workflow-cli.ts @@ -17,6 +17,7 @@ import { executeWorkflow, mapEngineErrorKind } from "./workflow-engine.js"; import { parseWorkflowArgFlagsResult, persistWorkflowScriptResult, + readProjectWorkflowScriptFile, readWorkflowScriptFileResult, resolveNamedWorkflowScript, resolveWorkflowScriptFromPathOrNameResult, @@ -416,7 +417,12 @@ export async function runWorkflowWorker( }); return named.source; } - return readFile(ref.scriptPath, "utf8"); + return ( + await readProjectWorkflowScriptFile({ + scriptPath: ref.scriptPath, + workspaceRoot: claimed.workspaceRoot, + }) + ).source; }, }); diff --git a/src/workflow-errors.ts b/src/workflow-errors.ts index 4349cc5fd..19e18b2c8 100644 --- a/src/workflow-errors.ts +++ b/src/workflow-errors.ts @@ -13,7 +13,12 @@ import type { export class InvalidWorkflowInputError extends TaggedError( "InvalidWorkflowInputError", )<{ - code: "ambiguous_source" | "missing_source" | "invalid_name" | "invalid_argument"; + code: + | "ambiguous_source" + | "missing_source" + | "invalid_name" + | "invalid_argument" + | "invalid_path"; message: string; }>() {} diff --git a/src/workflow-files.test.ts b/src/workflow-files.test.ts index e15c89bfa..9cd5a2a5e 100644 --- a/src/workflow-files.test.ts +++ b/src/workflow-files.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { parseWorkflowArgFlags, persistWorkflowScript, + readProjectWorkflowScriptFile, resolveNamedWorkflowScript, resolveWorkflowScriptFromPathOrName, WorkflowPathError, @@ -52,6 +53,33 @@ import { hashSource } from "./workflow-script.js"; }); assert.equal(named.origin, "named"); assert.match(named.source, /named/); + assert.equal( + ( + await readProjectWorkflowScriptFile({ + scriptPath: join(dir, ".devspace", "workflows", "named.js"), + workspaceRoot: dir, + }) + ).nameHint, + "named", + ); + await assert.rejects( + () => + readProjectWorkflowScriptFile({ + scriptPath: path, + workspaceRoot: dir, + }), + /must be inside/, + ); + + await mkdir(join(dir, "workflows"), { recursive: true }); + await writeFile( + join(dir, "workflows", "legacy.js"), + "export const meta = { name: 'legacy', description: 'd' }\nreturn 3\n", + ); + await assert.rejects( + () => resolveNamedWorkflowScript({ name: "legacy", workspaceRoot: dir }), + WorkflowPathError, + ); await assert.rejects( () => resolveNamedWorkflowScript({ name: "missing", workspaceRoot: dir }), diff --git a/src/workflow-files.ts b/src/workflow-files.ts index 2a0cfadf2..5aae5fe56 100644 --- a/src/workflow-files.ts +++ b/src/workflow-files.ts @@ -1,5 +1,5 @@ import { createHash, randomBytes } from "node:crypto"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, realpath, writeFile } from "node:fs/promises"; import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path"; import { Result, type Result as BetterResult } from "better-result"; import { hashSource } from "./workflow-script.js"; @@ -11,6 +11,7 @@ import { WorkflowFileReadError, WorkflowFileWriteError, } from "./workflow-errors.js"; +import { isPathInsideRoot } from "./roots.js"; export class WorkflowPathError extends Error { constructor(message: string) { @@ -97,12 +98,65 @@ export async function readWorkflowScriptFileResult( }); } +export async function readProjectWorkflowScriptFile(input: { + scriptPath: string; + workspaceRoot: string; +}): Promise { + const result = await readProjectWorkflowScriptFileResult(input); + if (result.isErr()) throwPathCompatibilityError(result.error); + return result.value; +} + +/** 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` - * 3. `/workflows/.js` (if stateDir provided) + * 2. `/workflows/.js` (if stateDir provided) */ export async function resolveNamedWorkflowScript(input: { name: string; @@ -130,7 +184,6 @@ export async function resolveNamedWorkflowScriptResult(input: { } const candidates = [ join(input.workspaceRoot, ".devspace", "workflows", `${name}.js`), - join(input.workspaceRoot, "workflows", `${name}.js`), ]; if (input.stateDir) { candidates.push(join(input.stateDir, "workflows", `${name}.js`)); From b9c78eba66b38a97933de319cd56a6cdd33ad3f2 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:51:28 +0000 Subject: [PATCH 051/132] fix(workflow): parse module syntax without raw scans --- src/workflow-script.test.ts | 73 ++++++++++++++++++++ src/workflow-script.ts | 134 ++++++++++++++++++++++++++++++------ 2 files changed, 187 insertions(+), 20 deletions(-) diff --git a/src/workflow-script.test.ts b/src/workflow-script.test.ts index d64c5cfcf..df51b77a8 100644 --- a/src/workflow-script.test.ts +++ b/src/workflow-script.test.ts @@ -37,6 +37,31 @@ return { ok: true, name: meta.name } ); } +{ + 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( () => @@ -109,6 +134,54 @@ 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( () => diff --git a/src/workflow-script.ts b/src/workflow-script.ts index daaf766d1..ffb02757b 100644 --- a/src/workflow-script.ts +++ b/src/workflow-script.ts @@ -48,14 +48,6 @@ export function parseWorkflowScript( // Strip only the leading `export ` so line numbers stay aligned (7 spaces). const body = normalized.replace(META_EXPORT, " const meta ="); - // Reject further imports / exports after transform - if (/\bimport\s+/.test(body) || /\bexport\s+/.test(body)) { - throw new WorkflowScriptError( - "syntax", - "Workflow scripts may not use import or additional export statements", - ); - } - // 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. @@ -114,17 +106,7 @@ function extractMetaLiteral(source: string): { metaLiteral: string; metaEndIndex const end = scanBalancedObject(source, objectStart); const metaLiteral = source.slice(objectStart, end + 1); - // Purity: no calls, spreads, templates inside meta (rough static checks) - if (/[`$]/.test(metaLiteral) && /\$\{/.test(metaLiteral)) { - throw new WorkflowScriptError("meta", "meta must be a pure literal (no template interpolation)"); - } - if (/\.\.\./.test(metaLiteral)) { - throw new WorkflowScriptError("meta", "meta must be a pure literal (no spreads)"); - } - // Disallow identifier references that look like calls: word( - if (/\b[A-Za-z_$][\w$]*\s*\(/.test(metaLiteral)) { - throw new WorkflowScriptError("meta", "meta must be a pure literal (no function calls)"); - } + assertPureMetaLiteral(metaLiteral); return { metaLiteral, metaEndIndex: end + 1 }; } @@ -132,9 +114,23 @@ function extractMetaLiteral(source: string): { metaLiteral: string; metaEndIndex 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; @@ -147,6 +143,16 @@ function scanBalancedObject(source: string, start: number): number { 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; @@ -160,6 +166,94 @@ function scanBalancedObject(source: string, start: number): number { throw new WorkflowScriptError("meta", "Unclosed meta object literal"); } +function assertPureMetaLiteral(literal: string): void { + for (let i = 0; i < literal.length; i += 1) { + const ch = literal[i]!; + const next = literal[i + 1]; + if (ch === '"' || ch === "'") { + i = skipQuoted(literal, i, ch); + continue; + } + if (ch === "/" && next === "/") { + i = skipLineComment(literal, i + 2); + continue; + } + if (ch === "/" && next === "*") { + i = skipBlockComment(literal, i + 2); + continue; + } + if (ch === "`") { + throw new WorkflowScriptError("meta", "meta must be a pure literal (no templates)"); + } + if (literal.startsWith("...", i)) { + throw new WorkflowScriptError("meta", "meta must be a pure literal (no spreads)"); + } + if (literal.startsWith("=>", i)) { + throw new WorkflowScriptError("meta", "meta must be a pure literal (no functions)"); + } + if (!/[A-Za-z_$]/.test(ch)) continue; + + const identifierStart = i; + i += 1; + while (i < literal.length && /[\w$]/.test(literal[i]!)) i += 1; + const identifier = literal.slice(identifierStart, i); + if (identifier === "function" || identifier === "class" || identifier === "new") { + throw new WorkflowScriptError("meta", "meta must be a pure literal (no executable values)"); + } + i = skipTrivia(literal, i) - 1; + if (literal[i + 1] === "(") { + throw new WorkflowScriptError("meta", "meta must be a pure literal (no function calls)"); + } + } +} + +function skipQuoted(source: string, start: number, quote: '"' | "'"): number { + let escape = false; + for (let i = start + 1; i < source.length; i += 1) { + const ch = source[i]!; + if (escape) { + escape = false; + continue; + } + if (ch === "\\") { + escape = true; + continue; + } + if (ch === quote) return i; + } + return source.length - 1; +} + +function skipLineComment(source: string, start: number): number { + const newline = source.indexOf("\n", start); + return newline < 0 ? source.length - 1 : newline; +} + +function skipBlockComment(source: string, start: number): number { + const end = source.indexOf("*/", start); + return end < 0 ? source.length - 1 : end + 1; +} + +function skipTrivia(source: string, start: number): number { + let i = start; + while (i < source.length) { + if (/\s/.test(source[i]!)) { + i += 1; + continue; + } + if (source.startsWith("//", i)) { + i = skipLineComment(source, i + 2) + 1; + continue; + } + if (source.startsWith("/*", i)) { + i = skipBlockComment(source, i + 2) + 1; + continue; + } + break; + } + return i; +} + function isOnlyPreamble(text: string): boolean { // strip block comments, line comments, whitespace const stripped = text @@ -185,7 +279,7 @@ function evaluateMetaLiteral(literal: string, filename: string): unknown { } function validateMeta(value: unknown): WorkflowMeta { - const parsed = workflowMetaSchema.safeParse(value); + const parsed = workflowMetaSchema.safeParse(value, { reportInput: true }); if (parsed.success) return parsed.data; const issue = parsed.error.issues[0]; From 323fe50ef294fb35f7b1e7690035d89a2a1ce02f Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:53:17 +0000 Subject: [PATCH 052/132] docs(workflow): teach profile selection and project scripts --- skills/dynamic-workflows/SKILL.md | 10 ++++++++-- src/workflow-contracts.test.ts | 6 ++++++ src/workflow-tools.ts | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/skills/dynamic-workflows/SKILL.md b/skills/dynamic-workflows/SKILL.md index 3b0a94449..c5d42cd89 100644 --- a/skills/dynamic-workflows/SKILL.md +++ b/skills/dynamic-workflows/SKILL.md @@ -56,7 +56,7 @@ return { summary, findings } | API | Notes | |---|---| -| `agent(prompt, opts?)` | Throws on failure. `opts`: `label`, `phase`, `schema`, `model`, `effort`, `provider`, `isolation: 'worktree'` | +| `agent(prompt, opts?)` | Throws on failure. `opts`: `label`, `phase`, `schema`, `model`, `effort`, `profile` or `provider`, `isolation: 'worktree'` | | `parallel(thunks)` | Barrier; throw → `null` slot | | `pipeline(items, ...stages)` | Per-item chains; no cross-item barrier | | `phase(title)` / `log(msg)` | Progress; journaled | @@ -85,7 +85,13 @@ const out = await agent('Return JSON findings', { ### Providers -Default: first **enabled ∩ available** provider (`agentProviders.enabled` in config, else all live providers in product order). Override with `opts.provider` or `meta.defaultProvider`. +Profiles exposed by `open_workspace` may be selected with `opts.profile`. The +profile supplies instructions, provider, model, and effort defaults; per-call +`model` and `effort` override those defaults. `profile` and `provider` are +mutually exclusive. + +Without a profile, default provider resolution is `opts.provider` → +`meta.defaultProvider` → first **enabled ∩ available** provider. ### Resume diff --git a/src/workflow-contracts.test.ts b/src/workflow-contracts.test.ts index 417035b49..5a468e454 100644 --- a/src/workflow-contracts.test.ts +++ b/src/workflow-contracts.test.ts @@ -51,6 +51,10 @@ 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); @@ -113,6 +117,8 @@ if (false) { // @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, diff --git a/src/workflow-tools.ts b/src/workflow-tools.ts index d31e499e7..ef63c1be0 100644 --- a/src/workflow-tools.ts +++ b/src/workflow-tools.ts @@ -47,7 +47,7 @@ const WORKFLOW_UI_WAIT_MAX_MS = 30_000; const WORKFLOW_API_CHEATSHEET = ` Workflow scripts (JS only): export const meta = { name, description, phases?, defaultProvider?, concurrency? } - agent(prompt, { label?, phase?, schema?, model?, effort?, provider?, isolation?: 'worktree' }) + agent(prompt, { label?, phase?, schema?, model?, effort?, profile? | provider?, isolation?: 'worktree' }) parallel(thunks) → Array // barrier; throw → null pipeline(items, ...stages) // no cross-item barrier phase(title); log(msg); args From a23be1c6c290fde15f9f561c7385380eb15c9e5c Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:58:57 +0000 Subject: [PATCH 053/132] fix(workflow): preserve resume names and typed reads --- src/workflow-cli.ts | 4 ++-- src/workflow-tools.ts | 10 +++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts index 002d476ce..c8ac24116 100644 --- a/src/workflow-cli.ts +++ b/src/workflow-cli.ts @@ -163,8 +163,8 @@ async function runWorkflowRun(args: string[], config: ServerConfig): Promise { const store = createWorkflowStore(config); try { - if (!store.getRun(runId)) throw new WorkflowNotFoundError(runId); + const runResult = store.getRunResult(runId); + if (runResult.isErr()) throw runResult.error; + if (!runResult.value) throw new WorkflowNotFoundError(runId); const page = await yieldEvents(store, runId, sinceSeq ?? 0, yieldTimeMs ?? 0); return toolResult(page, "workflow_status"); } catch (error) { From 94ac40d3445637c3b64c0e30d992b66ac00738d9 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:47:48 +0000 Subject: [PATCH 054/132] fix(workflow): parse metadata as pure literals --- src/workflow-script.test.ts | 32 ++++ src/workflow-script.ts | 287 ++++++++++++++++++++++++------------ 2 files changed, 224 insertions(+), 95 deletions(-) diff --git a/src/workflow-script.test.ts b/src/workflow-script.test.ts index df51b77a8..75f927504 100644 --- a/src/workflow-script.test.ts +++ b/src/workflow-script.test.ts @@ -37,6 +37,38 @@ return { ok: true, name: meta.name } ); } +{ + 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 = { diff --git a/src/workflow-script.ts b/src/workflow-script.ts index ffb02757b..0c94b0ead 100644 --- a/src/workflow-script.ts +++ b/src/workflow-script.ts @@ -106,8 +106,6 @@ function extractMetaLiteral(source: string): { metaLiteral: string; metaEndIndex const end = scanBalancedObject(source, objectStart); const metaLiteral = source.slice(objectStart, end + 1); - assertPureMetaLiteral(metaLiteral); - return { metaLiteral, metaEndIndex: end + 1 }; } @@ -166,115 +164,214 @@ function scanBalancedObject(source: string, start: number): number { throw new WorkflowScriptError("meta", "Unclosed meta object literal"); } -function assertPureMetaLiteral(literal: string): void { - for (let i = 0; i < literal.length; i += 1) { - const ch = literal[i]!; - const next = literal[i + 1]; - if (ch === '"' || ch === "'") { - i = skipQuoted(literal, i, ch); - continue; - } - if (ch === "/" && next === "/") { - i = skipLineComment(literal, i + 2); - continue; - } - if (ch === "/" && next === "*") { - i = skipBlockComment(literal, i + 2); - continue; - } - if (ch === "`") { - throw new WorkflowScriptError("meta", "meta must be a pure literal (no templates)"); - } - if (literal.startsWith("...", i)) { - throw new WorkflowScriptError("meta", "meta must be a pure literal (no spreads)"); - } - if (literal.startsWith("=>", i)) { - throw new WorkflowScriptError("meta", "meta must be a pure literal (no functions)"); - } - if (!/[A-Za-z_$]/.test(ch)) continue; - - const identifierStart = i; - i += 1; - while (i < literal.length && /[\w$]/.test(literal[i]!)) i += 1; - const identifier = literal.slice(identifierStart, i); - if (identifier === "function" || identifier === "class" || identifier === "new") { - throw new WorkflowScriptError("meta", "meta must be a pure literal (no executable values)"); +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])}`); } - i = skipTrivia(literal, i) - 1; - if (literal[i + 1] === "(") { - throw new WorkflowScriptError("meta", "meta must be a pure literal (no function calls)"); + 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)}`); } -} -function skipQuoted(source: string, start: number, quote: '"' | "'"): number { - let escape = false; - for (let i = start + 1; i < source.length; i += 1) { - const ch = source[i]!; - if (escape) { - escape = false; - continue; + 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; } - if (ch === "\\") { - escape = true; - continue; + } + + 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; } - if (ch === quote) return i; } - return source.length - 1; -} -function skipLineComment(source: string, start: number): number { - const newline = source.indexOf("\n", start); - return newline < 0 ? source.length - 1 : newline; -} + private parseString(): string { + const quote = this.source[this.index]; + if (quote !== '"' && quote !== "'") this.fail("expected a quoted string"); + this.index += 1; + let value = ""; -function skipBlockComment(source: string, start: number): number { - const end = source.indexOf("*/", start); - return end < 0 ? source.length - 1 : end + 1; -} + 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; + } -function skipTrivia(source: string, start: number): number { - let i = start; - while (i < source.length) { - if (/\s/.test(source[i]!)) { - i += 1; - 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; } - if (source.startsWith("//", i)) { - i = skipLineComment(source, i + 2) + 1; - continue; + 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"); } - if (source.startsWith("/*", i)) { - i = skipBlockComment(source, i + 2) + 1; - continue; + 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; } - break; } - return i; -} -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; -} + private expect(token: string): void { + if (!this.consume(token)) this.fail(`expected ${JSON.stringify(token)}`); + } -function evaluateMetaLiteral(literal: string, filename: string): unknown { - try { - const value = vm.runInNewContext(`(${literal})`, Object.create(null), { - filename: `${filename}:meta`, - timeout: 1000, - }); - // Rehydrate into the host realm — vm values keep context prototypes which - // break assert.deepEqual and other host identity checks. - return JSON.parse(JSON.stringify(value)); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new WorkflowScriptError("meta", `Invalid meta literal: ${message}`); + 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}`); } } From 3f01013d739992daf00df38507df1a657b861b67 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:47:48 +0000 Subject: [PATCH 055/132] test(workflow): cover path and profile identity guards --- src/workflow-files.test.ts | 27 ++++++++++++++++++++- src/workflow-replay.test.ts | 48 ++++++++++++++++++++++++++++++++++--- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/workflow-files.test.ts b/src/workflow-files.test.ts index 9cd5a2a5e..4cd1ecd79 100644 --- a/src/workflow-files.test.ts +++ b/src/workflow-files.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -71,6 +71,31 @@ import { hashSource } from "./workflow-script.js"; /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"), + ); + await assert.rejects( + () => + readProjectWorkflowScriptFile({ + scriptPath: "escape.js", + workspaceRoot: dir, + }), + /resolves outside/, + ); + } finally { + await rm(outside, { recursive: true, force: true }); + } + } + await mkdir(join(dir, "workflows"), { recursive: true }); await writeFile( join(dir, "workflows", "legacy.js"), diff --git a/src/workflow-replay.test.ts b/src/workflow-replay.test.ts index d6e34fcd2..7fe518ebe 100644 --- a/src/workflow-replay.test.ts +++ b/src/workflow-replay.test.ts @@ -20,11 +20,17 @@ function call( }; } -function identity(prompt = "prompt") { +function identity( + prompt = "prompt", + profile: { name: string | null; fingerprint: string | null } = { + name: null, + fingerprint: null, + }, +) { return { prompt, - profileName: null, - profileFingerprint: null, + profileName: profile.name, + profileFingerprint: profile.fingerprint, provider: "codex" as const, model: null, effort: null, @@ -33,6 +39,42 @@ function identity(prompt = "prompt") { }; } +{ + 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") }), From 620b18095aeeb3994e0854b6fd35448888286673 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:43:53 +0000 Subject: [PATCH 056/132] refactor(config): remove experimental provider persistence --- src/cli.ts | 62 +------------------------------------------ src/config.ts | 47 -------------------------------- src/user-config.ts | 3 --- src/workflow-cli.ts | 11 +++----- src/workflow-tools.ts | 15 +++-------- src/workflow-types.ts | 21 --------------- 6 files changed, 7 insertions(+), 152 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index a30108daa..a3d76b548 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,7 +16,6 @@ import { buildLocalAgentProfilePrompt, isLocalAgentProvider, loadLocalAgentProfiles, - LOCAL_AGENT_PROVIDERS, type LocalAgentProfile, } from "./local-agent-profiles.js"; import { @@ -176,18 +175,12 @@ async function runInit({ force }: { force: boolean }): Promise { validate: validateRequiredPublicBaseUrl, })); - const subagents = resolveSubagentsFlag(files.config); - const agentProviders = - subagents === true - ? probeAndBuildAgentProviders(files.config.agentProviders) - : files.config.agentProviders; const config: DevspaceUserConfig = { host: files.config.host ?? "127.0.0.1", port, allowedRoots, publicBaseUrl, - subagents, - ...(agentProviders ? { agentProviders } : {}), + subagents: resolveSubagentsFlag(files.config), }; const auth = { ownerToken: files.auth.ownerToken ?? generateOwnerToken(), @@ -296,65 +289,12 @@ async function runDoctor(): Promise { console.log( `Agent providers (live): ${formatLocalAgentProviderAvailabilitySummary(snapshot)}`, ); - if (config.agentProviders) { - console.log( - `Agent providers (enabled): ${ - config.agentProviders.enabled.length - ? config.agentProviders.enabled.join(", ") - : "(empty — no providers)" - }`, - ); - if (config.agentProviders.detectedAt) { - console.log(`Agent providers last probe: ${config.agentProviders.detectedAt}`); - } - } else { - console.log("Agent providers (config): missing (compat = all available)"); - } - - // Refresh lastProbe write-back when subagents on and config exists - if (files.configExists) { - const refreshed = probeAndBuildAgentProviders(files.config.agentProviders); - writeDevspaceConfig({ - ...files.config, - agentProviders: { - // keep user enable-list if set; only refresh probe metadata + available adds when empty - enabled: - files.config.agentProviders?.enabled ?? refreshed.enabled, - detectedAt: refreshed.detectedAt, - lastProbe: refreshed.lastProbe, - }, - }); - console.log(`Agent providers probe written to ${files.configPath}`); - } } } catch (error) { console.log(`Config status: ${error instanceof Error ? error.message : String(error)}`); } } -/** Probe PATH and build AgentProvidersConfig (available ids in product order). */ -function probeAndBuildAgentProviders( - existing?: DevspaceUserConfig["agentProviders"], -): NonNullable { - const snapshot = getLocalAgentProviderAvailabilitySnapshot(); - const available = new Set( - snapshot.filter((row) => row.available).map((row) => row.name), - ); - const enabled = - existing?.enabled && existing.enabled.length > 0 - ? existing.enabled.filter((id) => LOCAL_AGENT_PROVIDERS.includes(id as never)) - : LOCAL_AGENT_PROVIDERS.filter((id) => available.has(id)); - return { - enabled, - detectedAt: new Date().toISOString(), - lastProbe: snapshot.map((row) => ({ - id: row.name, - available: row.available, - detail: row.reason, - })), - }; -} - function runConfigCommand(args: string[]): void { const [subcommand, key, ...rest] = args; const files = loadDevspaceFiles(); diff --git a/src/config.ts b/src/config.ts index 65d10a3eb..4fc1bcbb0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,12 +4,6 @@ import { expandHomePath } from "./roots.js"; import type { LoggingConfig, LogFormat, LogLevel } from "./logger.js"; import type { OAuthConfig } from "./oauth-provider.js"; import { devspaceAgentsDir, devspaceSkillsDir, loadDevspaceFiles } from "./user-config.js"; -import type { AgentProvidersConfig } from "./workflow-types.js"; -import { - isLocalAgentProvider, - LOCAL_AGENT_PROVIDERS, - type LocalAgentProvider, -} from "./local-agent-profiles.js"; export type ToolMode = "minimal" | "full" | "codex"; export type WidgetMode = "off" | "changes" | "full"; @@ -32,12 +26,6 @@ export interface ServerConfig { devspaceSkillsDir: string; devspaceAgentsDir: string; subagents: boolean; - /** - * Resolved enable-list for agent providers. - * Missing user config → undefined (compat: all live providers). - * Explicit empty → no providers. - */ - agentProviders?: AgentProvidersConfig; agentDir: string; logging: LoggingConfig; } @@ -246,46 +234,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { env.DEVSPACE_SUBAGENTS === undefined ? files.config.subagents === true : parseBoolean(env.DEVSPACE_SUBAGENTS), - agentProviders: parseAgentProvidersConfig( - env.DEVSPACE_AGENT_PROVIDERS, - files.config.agentProviders, - ), agentDir: resolve(expandHomePath(env.DEVSPACE_AGENT_DIR ?? files.config.agentDir ?? defaultAgentDir())), logging: parseLoggingConfig(env), }; } -/** - * Env `DEVSPACE_AGENT_PROVIDERS=codex,claude` replaces enabled list. - * Missing config → undefined (compat all-available). - * Explicit enabled: [] stays empty. - */ -export function parseAgentProvidersConfig( - envValue: string | undefined, - fileConfig: AgentProvidersConfig | undefined, -): AgentProvidersConfig | undefined { - if (envValue !== undefined) { - const enabled = envValue - .split(",") - .map((entry) => entry.trim()) - .filter((entry): entry is LocalAgentProvider => isLocalAgentProvider(entry)); - return { enabled }; - } - if (!fileConfig) return undefined; - const enabled = (fileConfig.enabled ?? []).filter((id): id is LocalAgentProvider => - isLocalAgentProvider(id), - ); - return { - enabled, - detectedAt: fileConfig.detectedAt, - lastProbe: fileConfig.lastProbe, - }; -} - -export function defaultAgentProvidersOrder(): LocalAgentProvider[] { - return [...LOCAL_AGENT_PROVIDERS]; -} - function parsePublicBaseUrl(value: string): string { const parsed = new URL(value); parsed.hash = ""; diff --git a/src/user-config.ts b/src/user-config.ts index 5371b04b5..ad3421f26 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -8,7 +8,6 @@ import { import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { expandHomePath } from "./roots.js"; -import type { AgentProvidersConfig } from "./workflow-types.js"; export interface DevspaceUserConfig { host?: string; @@ -20,8 +19,6 @@ export interface DevspaceUserConfig { worktreeRoot?: string; agentDir?: string; subagents?: boolean; - /** Ordered enable-list for local agent providers used by workflows/subagents. */ - agentProviders?: AgentProvidersConfig; } export interface DevspaceAuthConfig { diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts index c8ac24116..b0bd963a6 100644 --- a/src/workflow-cli.ts +++ b/src/workflow-cli.ts @@ -347,7 +347,7 @@ export async function runWorkflowWorker( try { const source = await readFile(claimed.scriptPath, "utf8"); const parsed = parseWorkflowScript(source, { filename: claimed.scriptPath }); - const enabledProviders = resolveEnabledProviders(config.agentProviders); + const enabledProviders = resolveEnabledProviders(); const agentProfiles = await loadLocalAgentProfiles(config, claimed.workspaceRoot); const concurrency = resolveWorkflowConcurrency( parsed.meta.concurrency, @@ -595,15 +595,10 @@ function safeParseJson(text: string): unknown { } } -function resolveEnabledProviders( - agentProviders?: ServerConfig["agentProviders"], -): LocalAgentProvider[] { +function resolveEnabledProviders(): LocalAgentProvider[] { const snapshot = getLocalAgentProviderAvailabilitySnapshot(); const live = new Set(snapshot.filter((row) => row.available).map((row) => row.name)); - if (!agentProviders) { - return LOCAL_AGENT_PROVIDERS.filter((id) => live.has(id)); - } - return agentProviders.enabled.filter((id) => live.has(id)); + return LOCAL_AGENT_PROVIDERS.filter((id) => live.has(id)); } function splitFlags(args: string[]): { diff --git a/src/workflow-tools.ts b/src/workflow-tools.ts index 404f9e0cb..296a6a9d1 100644 --- a/src/workflow-tools.ts +++ b/src/workflow-tools.ts @@ -15,7 +15,6 @@ import { createWorkflowStore } from "./workflow-store.js"; import { WORKFLOW_MCP_YIELD_MS, WORKFLOW_LIMITS, - type AgentProvidersConfig, type WorkflowEventRecord, type WorkflowRunRecord, } from "./workflow-types.js"; @@ -24,7 +23,6 @@ import { spawnWorkflowWorkerFromCli } from "./workflow-cli.js"; import { cancelWorkflowRun } from "./workflow-lifecycle.js"; import { getLocalAgentProviderAvailabilitySnapshot } from "./local-agent-availability.js"; import { - isLocalAgentProvider, LOCAL_AGENT_PROVIDERS, type LocalAgentProvider, } from "./local-agent-profiles.js"; @@ -560,18 +558,11 @@ function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } -/** Resolve enabled ∩ live providers for workflows. */ -export function resolveWorkflowEnabledProviders( - agentProviders: AgentProvidersConfig | undefined, -): LocalAgentProvider[] { +/** Resolve live providers in stable product order for workflows. */ +export function resolveWorkflowEnabledProviders(): LocalAgentProvider[] { const snapshot = getLocalAgentProviderAvailabilitySnapshot(); const live = new Set( snapshot.filter((row) => row.available).map((row) => row.name), ); - if (!agentProviders) { - return LOCAL_AGENT_PROVIDERS.filter((id) => live.has(id)); - } - return agentProviders.enabled.filter( - (id): id is LocalAgentProvider => isLocalAgentProvider(id) && live.has(id), - ); + return LOCAL_AGENT_PROVIDERS.filter((id): id is LocalAgentProvider => live.has(id)); } diff --git a/src/workflow-types.ts b/src/workflow-types.ts index 09bbe6bdc..4f46e1d7d 100644 --- a/src/workflow-types.ts +++ b/src/workflow-types.ts @@ -68,29 +68,8 @@ export const WORKFLOW_LIMITS = { eventDrainMax: 500, } as const; -// --------------------------------------------------------------------------- -// Provider config (user config / ServerConfig) -// --------------------------------------------------------------------------- - export type AgentProviderId = LocalAgentProvider; -export interface AgentProviderProbe { - id: AgentProviderId; - available: boolean; - detail?: string; -} - -/** - * Ordered enable-list. index 0 = default fallback after live availability filter. - * Missing block on disk → compat all-available in product order. - * Explicit enabled: [] → no providers; first agent() fails. - */ -export interface AgentProvidersConfig { - enabled: AgentProviderId[]; - detectedAt?: string; - lastProbe?: AgentProviderProbe[]; -} - // --------------------------------------------------------------------------- // Status / events // --------------------------------------------------------------------------- From d640cf812f5eb7aae4a7f2c907f09a36005ed69e Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:48:12 +0000 Subject: [PATCH 057/132] refactor(agents): share execution target resolution --- package.json | 2 +- src/cli.ts | 80 +++++++---------- src/local-agent-availability.ts | 8 ++ src/local-agent-resolution.test.ts | 88 ++++++++++++++++++ src/local-agent-resolution.ts | 138 +++++++++++++++++++++++++++++ src/local-agent-targets.ts | 60 +++++-------- src/workflow-api.ts | 88 ++++++------------ 7 files changed, 316 insertions(+), 148 deletions(-) create mode 100644 src/local-agent-resolution.test.ts create mode 100644 src/local-agent-resolution.ts diff --git a/package.json b/package.json index 8a776a85d..b9a7d5d75 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-lifecycle.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-ui.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-resolution.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-lifecycle.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-ui.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/cli.ts b/src/cli.ts index a3d76b548..426ea8e2f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,23 +13,21 @@ import { satisfies } from "semver"; import { loadConfig } from "./config.js"; import { runLocalAgentProvider } from "./local-agent-adapters.js"; import { - buildLocalAgentProfilePrompt, isLocalAgentProvider, loadLocalAgentProfiles, - type LocalAgentProfile, } from "./local-agent-profiles.js"; import { assertLocalAgentProviderAvailable, formatLocalAgentProviderAvailabilitySummary, + getAvailableLocalAgentProviders, getLocalAgentProviderAvailabilitySnapshot, } from "./local-agent-availability.js"; import { formatAvailableLocalAgentTargets, parseLocalAgentRunArgs, - resolveLocalAgentTarget, } from "./local-agent-targets.js"; +import { resolveLocalAgentExecution } from "./local-agent-resolution.js"; import { createLocalAgentStore, type LocalAgentRecord } from "./local-agent-store.js"; -import type { LocalAgentRunResult } from "./local-agent-runtime.js"; import { ensureDevspaceDefaultSkills, generateOwnerToken, @@ -421,13 +419,21 @@ async function runAgentsRun(args: string[]): Promise { } const profiles = await loadLocalAgentProfiles(config, workspaceRoot); - const target = resolveLocalAgentTarget(parsed.target, profiles, parsed.model, parsed.effort); - if (!target) { - throw new Error( - `Unknown subagent profile, provider, or id: ${parsed.target}. Available ${formatAvailableLocalAgentTargets(profiles)}`, - ); + const availableProviders = getAvailableLocalAgentProviders(); + let target; + try { + target = resolveLocalAgentExecution({ + target: parsed.target, + prompt: parsed.prompt, + profiles, + availableProviders, + model: parsed.model, + effort: parsed.effort, + }); + } catch (error) { + const suffix = ` Available ${formatAvailableLocalAgentTargets(profiles, availableProviders)}`; + throw new Error(`${error instanceof Error ? error.message : String(error)}.${suffix}`); } - assertLocalAgentProviderAvailable(target.provider); const promptFile = writeAgentPromptFile(parsed.prompt); const record = store.create({ @@ -486,11 +492,23 @@ async function runAgentsWorker(args: string[]): Promise { store.update(record.id, { status: "running", error: undefined }); try { const profiles = await loadLocalAgentProfiles(config, record.workspaceRoot); - const profile = profiles.find((candidate) => candidate.name === record.profileName); const prompt = await readFile(promptFile, "utf8"); - const result = profile - ? await runLocalAgentProfile(profile, record, prompt) - : await runRawLocalAgentProvider(record, prompt); + const target = resolveLocalAgentExecution({ + target: record.profileName, + prompt, + profiles, + availableProviders: getAvailableLocalAgentProviders(), + model: record.model, + effort: record.effort, + }); + const result = await runLocalAgentProvider(target.provider, { + prompt: target.prompt, + workspace: record.workspaceRoot, + providerSessionId: record.providerSessionId, + writeMode: "allowed", + model: target.model, + effort: target.effort, + }); store.update(record.id, { providerSessionId: result.providerSessionId ?? undefined, status: "idle", @@ -505,40 +523,6 @@ async function runAgentsWorker(args: string[]): Promise { } } -async function runLocalAgentProfile( - profile: LocalAgentProfile, - record: LocalAgentRecord, - prompt: string, -): Promise { - const fullPrompt = buildLocalAgentProfilePrompt(profile, prompt); - return runLocalAgentProvider(profile.provider, { - prompt: fullPrompt, - workspace: record.workspaceRoot, - providerSessionId: record.providerSessionId, - writeMode: "allowed", - model: record.model ?? profile.model, - effort: record.effort ?? profile.effort, - }); -} - -async function runRawLocalAgentProvider( - record: LocalAgentRecord, - prompt: string, -): Promise { - if (record.profileName !== record.provider || !isLocalAgentProvider(record.provider)) { - throw new Error(`Subagent profile not found: ${record.profileName}`); - } - - return runLocalAgentProvider(record.provider, { - prompt, - workspace: record.workspaceRoot, - providerSessionId: record.providerSessionId, - writeMode: "allowed", - model: record.model, - effort: record.effort, - }); -} - function spawnAgentWorker(agentId: string, promptFile: string): void { const child = spawn(process.execPath, [ ...process.execArgv, diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index 747f304fa..495a463cc 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -18,6 +18,14 @@ export function getLocalAgentProviderAvailabilitySnapshot( return LOCAL_AGENT_PROVIDERS.map((provider) => checkLocalAgentProviderAvailability(provider, env)); } +export function getAvailableLocalAgentProviders( + env: NodeJS.ProcessEnv = process.env, +): LocalAgentProvider[] { + return getLocalAgentProviderAvailabilitySnapshot(env) + .filter((provider) => provider.available) + .map((provider) => provider.name); +} + export function checkLocalAgentProviderAvailability( provider: LocalAgentProvider, env: NodeJS.ProcessEnv = process.env, diff --git a/src/local-agent-resolution.test.ts b/src/local-agent-resolution.test.ts new file mode 100644 index 000000000..7cbc3cc12 --- /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 000000000..3ab67e1df --- /dev/null +++ b/src/local-agent-resolution.ts @@ -0,0 +1,138 @@ +import { + buildLocalAgentProfilePrompt, + fingerprintLocalAgentProfile, + isLocalAgentProvider, + type LocalAgentProfile, + type LocalAgentProvider, +} from "./local-agent-profiles.js"; + +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: buildLocalAgentProfilePrompt(profile, input.prompt), + profile, + profileName: profile.name, + profileFingerprint: fingerprintLocalAgentProfile(profile), + }; +} + +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-targets.ts b/src/local-agent-targets.ts index 77d279304..c794511b5 100644 --- a/src/local-agent-targets.ts +++ b/src/local-agent-targets.ts @@ -1,9 +1,12 @@ import { - isLocalAgentProvider, LOCAL_AGENT_PROVIDERS, type LocalAgentProfile, type LocalAgentProvider, } from "./local-agent-profiles.js"; +import { + resolveLocalAgentExecution, + type ResolvedLocalAgentExecution, +} from "./local-agent-resolution.js"; export interface ParsedLocalAgentRunArgs { target: string; @@ -12,22 +15,7 @@ export interface ParsedLocalAgentRunArgs { effort?: string; } -export type LocalAgentTarget = - | { - kind: "profile"; - name: string; - provider: LocalAgentProvider; - model?: string; - effort?: string; - profile: LocalAgentProfile; - } - | { - kind: "provider"; - name: LocalAgentProvider; - provider: LocalAgentProvider; - model?: string; - effort?: string; - }; +export type LocalAgentTarget = ResolvedLocalAgentExecution; const USAGE = 'Usage: devspace agents run [--model ] [--effort ] ""'; @@ -92,37 +80,31 @@ export function resolveLocalAgentTarget( profiles: LocalAgentProfile[], modelOverride?: string, effortOverride?: string, + availableProviders: LocalAgentProvider[] = [...LOCAL_AGENT_PROVIDERS], ): LocalAgentTarget | undefined { - const profile = profiles.find((candidate) => candidate.name === target); - if (profile) { - return { - kind: "profile", - name: profile.name, - provider: profile.provider, - model: modelOverride ?? profile.model, - effort: effortOverride ?? profile.effort, - profile, - }; - } - - if (isLocalAgentProvider(target)) { - return { - kind: "provider", - name: target, - provider: target, + try { + return resolveLocalAgentExecution({ + target, + prompt: "", + profiles, + availableProviders, model: modelOverride, effort: effortOverride, - }; + }); + } catch (error) { + if (error instanceof Error && error.name === "LocalAgentResolutionError") return undefined; + throw error; } - - return undefined; } -export function formatAvailableLocalAgentTargets(profiles: LocalAgentProfile[]): string { +export function formatAvailableLocalAgentTargets( + profiles: LocalAgentProfile[], + providers: LocalAgentProvider[] = [...LOCAL_AGENT_PROVIDERS], +): string { const profileNames = profiles.map((profile) => profile.name); const parts = [ profileNames.length > 0 ? `profiles: ${profileNames.join(", ")}` : undefined, - `providers: ${LOCAL_AGENT_PROVIDERS.join(", ")}`, + providers.length > 0 ? `providers: ${providers.join(", ")}` : "providers: none", ].filter(Boolean); return parts.join("; "); } diff --git a/src/workflow-api.ts b/src/workflow-api.ts index 82fbbfd0c..ccf625904 100644 --- a/src/workflow-api.ts +++ b/src/workflow-api.ts @@ -2,11 +2,13 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; import type { WorkflowSandboxApi } from "./workflow-sandbox.js"; import { - buildLocalAgentProfilePrompt, - fingerprintLocalAgentProfile, 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 { @@ -697,36 +699,6 @@ export function hashCacheKey(input: ReturnType): return createHash("sha256").update(JSON.stringify(input)).digest("hex"); } -export function resolveProvider( - optsProvider: LocalAgentProvider | undefined, - meta: WorkflowMeta, - enabledProviders: LocalAgentProvider[], -): LocalAgentProvider { - if (optsProvider) { - if (!enabledProviders.includes(optsProvider)) { - throw new WorkflowEngineError( - "provider_disabled", - `Provider ${optsProvider} is not enabled or not available`, - ); - } - return optsProvider; - } - if (meta.defaultProvider) { - if (!enabledProviders.includes(meta.defaultProvider)) { - throw new WorkflowEngineError( - "provider_unavailable", - `meta.defaultProvider ${meta.defaultProvider} is not enabled or not available`, - ); - } - return meta.defaultProvider; - } - const first = enabledProviders[0]; - if (!first) { - throw new WorkflowEngineError("no_provider", "No agent providers enabled"); - } - return first; -} - interface ResolvedAgentTarget { provider: LocalAgentProvider; model?: string; @@ -741,38 +713,34 @@ function resolveAgentTarget( opts: AgentOpts, deps: Pick, ): ResolvedAgentTarget { - if (!opts.profile) { - return { - provider: resolveProvider(opts.provider, deps.meta, deps.enabledProviders), + try { + const resolved = resolveLocalAgentExecution({ + prompt, + profile: opts.profile, + provider: opts.provider, + defaultProvider: deps.meta.defaultProvider, model: opts.model, effort: opts.effort, - providerPrompt: prompt, + profiles: deps.agentProfiles ?? [], + availableProviders: deps.enabledProviders, + }); + 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); } - - const profile = deps.agentProfiles?.find((candidate) => candidate.name === opts.profile); - if (!profile) { - const available = deps.agentProfiles?.map((candidate) => candidate.name).join(", "); - throw new WorkflowEngineError( - "profile", - `Unknown agent profile: ${opts.profile}${available ? `. Available profiles: ${available}` : ""}`, - ); - } - if (!deps.enabledProviders.includes(profile.provider)) { - throw new WorkflowEngineError( - "provider_unavailable", - `Agent profile ${profile.name} requires unavailable provider ${profile.provider}`, - ); - } - - return { - provider: profile.provider, - model: opts.model ?? profile.model, - effort: opts.effort ?? profile.effort, - profileName: profile.name, - profileFingerprint: fingerprintLocalAgentProfile(profile), - providerPrompt: buildLocalAgentProfilePrompt(profile, prompt), - }; } function normalizeAgentOpts(opts: unknown): AgentOpts { From f2482bbc70a3f5bac11b3ebf20841843cc5f12b9 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:50:06 +0000 Subject: [PATCH 058/132] refactor(agents): describe provider model and effort capabilities --- package.json | 2 +- src/local-agent-capabilities.test.ts | 24 +++++++++++ src/local-agent-capabilities.ts | 61 ++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 src/local-agent-capabilities.test.ts diff --git a/package.json b/package.json index b9a7d5d75..b323d2f02 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-resolution.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-lifecycle.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-ui.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-capabilities.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-resolution.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-lifecycle.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-ui.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/local-agent-capabilities.test.ts b/src/local-agent-capabilities.test.ts new file mode 100644 index 000000000..112a8fc7f --- /dev/null +++ b/src/local-agent-capabilities.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { getLocalAgentProviderCapabilities } from "./local-agent-capabilities.js"; +import { LOCAL_AGENT_PROVIDERS } from "./local-agent-profiles.js"; + +for (const provider of LOCAL_AGENT_PROVIDERS) { + const capabilities = getLocalAgentProviderCapabilities(provider); + assert.equal(typeof capabilities.model.supported, "boolean"); + assert.equal(typeof capabilities.effort.supported, "boolean"); +} + +assert.equal( + getLocalAgentProviderCapabilities("opencode").effort.semantics, + "model_variant", +); +assert.equal( + getLocalAgentProviderCapabilities("pi").effort.semantics, + "thinking_level", +); +assert.equal( + getLocalAgentProviderCapabilities("cursor").effort.discovery, + "session_dynamic", +); + +console.log("local-agent-capabilities.test.ts: ok"); diff --git a/src/local-agent-capabilities.ts b/src/local-agent-capabilities.ts index f9a5f34af..48b2264fb 100644 --- a/src/local-agent-capabilities.ts +++ b/src/local-agent-capabilities.ts @@ -1,10 +1,29 @@ import type { LocalAgentProvider } from "./local-agent-profiles.js"; +export type LocalAgentCapabilityDiscovery = + | "provider_static" + | "model_dependent" + | "session_dynamic"; + +export type LocalAgentEffortSemantics = + | "reasoning_effort" + | "thinking_level" + | "model_variant"; + export interface LocalAgentProviderCapabilities { structuredOutput: "native" | "prompt"; resumableSessions: boolean; cancellation: "signal" | "process"; supportsWorkspaceIsolation: boolean; + model: { + supported: boolean; + discovery: LocalAgentCapabilityDiscovery; + }; + effort: { + supported: boolean; + semantics: LocalAgentEffortSemantics; + discovery: LocalAgentCapabilityDiscovery; + }; } export const LOCAL_AGENT_PROVIDER_CAPABILITIES = { @@ -13,39 +32,81 @@ export const LOCAL_AGENT_PROVIDER_CAPABILITIES = { resumableSessions: true, cancellation: "signal", supportsWorkspaceIsolation: true, + model: { supported: true, discovery: "model_dependent" }, + effort: { + supported: true, + semantics: "reasoning_effort", + discovery: "model_dependent", + }, }, claude: { structuredOutput: "native", resumableSessions: true, cancellation: "signal", supportsWorkspaceIsolation: true, + model: { supported: true, discovery: "model_dependent" }, + effort: { + supported: true, + semantics: "reasoning_effort", + discovery: "model_dependent", + }, }, opencode: { structuredOutput: "prompt", resumableSessions: true, cancellation: "process", supportsWorkspaceIsolation: true, + model: { supported: true, discovery: "model_dependent" }, + effort: { + supported: true, + semantics: "model_variant", + discovery: "model_dependent", + }, }, pi: { structuredOutput: "prompt", resumableSessions: true, cancellation: "process", supportsWorkspaceIsolation: true, + model: { supported: true, discovery: "model_dependent" }, + effort: { + supported: true, + semantics: "thinking_level", + discovery: "model_dependent", + }, }, cursor: { structuredOutput: "prompt", resumableSessions: true, cancellation: "process", supportsWorkspaceIsolation: true, + model: { supported: true, discovery: "session_dynamic" }, + effort: { + supported: true, + semantics: "thinking_level", + discovery: "session_dynamic", + }, }, copilot: { structuredOutput: "prompt", resumableSessions: true, cancellation: "process", supportsWorkspaceIsolation: true, + model: { supported: true, discovery: "session_dynamic" }, + effort: { + supported: true, + semantics: "thinking_level", + discovery: "session_dynamic", + }, }, } as const satisfies Record; +export function getLocalAgentProviderCapabilities( + provider: LocalAgentProvider, +): LocalAgentProviderCapabilities { + return LOCAL_AGENT_PROVIDER_CAPABILITIES[provider]; +} + export function supportsNativeStructuredOutput(provider: LocalAgentProvider): boolean { return LOCAL_AGENT_PROVIDER_CAPABILITIES[provider].structuredOutput === "native"; } From 321b81c192f9aa893b206fe3d5d7f3de350ae6c7 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:51:39 +0000 Subject: [PATCH 059/132] refactor(features): separate subagent and workflow capabilities --- src/cli.ts | 1 + src/config.test.ts | 13 +++++++++++++ src/config.ts | 17 +++++++++++++---- src/local-agent-profiles.ts | 2 +- src/server.ts | 6 +++--- src/workflow-cli.ts | 5 +++++ src/workflow-tools.ts | 2 +- 7 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 426ea8e2f..29d420864 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -282,6 +282,7 @@ async function runDoctor(): Promise { console.log(`Allowed roots: ${config.allowedRoots.join(", ")}`); console.log(`Allowed hosts: ${config.allowedHosts.join(", ")}`); console.log(`Subagents: ${config.subagents ? "enabled" : "disabled"}`); + console.log(`Workflows: ${config.workflows ? "enabled" : "disabled"}`); if (config.subagents) { const snapshot = getLocalAgentProviderAvailabilitySnapshot(); console.log( diff --git a/src/config.test.ts b/src/config.test.ts index d4c7b90fa..f7535ea6e 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -26,12 +26,25 @@ assert.equal(loadConfig(baseEnv).skillsEnabled, true); assert.equal(loadConfig(baseEnv).devspaceSkillsDir, join(emptyConfigDir, "skills")); assert.equal(loadConfig(baseEnv).devspaceAgentsDir, join(emptyConfigDir, "agents")); assert.equal(loadConfig(baseEnv).subagents, false); +assert.equal(loadConfig(baseEnv).workflows, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true); assert.equal( loadConfig({ ...baseEnv, DEVSPACE_SUBAGENTS: "1" }).subagents, true, ); +assert.equal( + loadConfig({ ...baseEnv, DEVSPACE_SUBAGENTS: "1" }).workflows, + true, +); +assert.equal( + loadConfig({ ...baseEnv, DEVSPACE_SUBAGENTS: "1", DEVSPACE_WORKFLOWS: "0" }).workflows, + false, +); +assert.equal( + loadConfig({ ...baseEnv, DEVSPACE_WORKFLOWS: "1" }).workflows, + true, +); assert.equal(resolveSubagentsFlag({}, {}), undefined); assert.equal(resolveSubagentsFlag({ subagents: true }, {}), true); assert.equal(resolveSubagentsFlag({ subagents: true }, { DEVSPACE_SUBAGENTS: "0" }), false); diff --git a/src/config.ts b/src/config.ts index 4fc1bcbb0..974e7b883 100644 --- a/src/config.ts +++ b/src/config.ts @@ -26,6 +26,7 @@ export interface ServerConfig { devspaceSkillsDir: string; devspaceAgentsDir: string; subagents: boolean; + workflows: boolean; agentDir: string; logging: LoggingConfig; } @@ -214,6 +215,16 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { new URL(publicBaseUrl).hostname, ...(files.config.allowedHosts ?? []), ]; + const subagents = + env.DEVSPACE_SUBAGENTS === undefined + ? files.config.subagents === true + : parseBoolean(env.DEVSPACE_SUBAGENTS); + // Experimental compatibility: workflows follow the existing subagents gate + // unless explicitly overridden for runtime testing. + const workflows = + env.DEVSPACE_WORKFLOWS === undefined + ? subagents + : parseBoolean(env.DEVSPACE_WORKFLOWS); return { host, @@ -230,10 +241,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS), devspaceSkillsDir: devspaceSkillsDir(env), devspaceAgentsDir: devspaceAgentsDir(env), - subagents: - env.DEVSPACE_SUBAGENTS === undefined - ? files.config.subagents === true - : parseBoolean(env.DEVSPACE_SUBAGENTS), + subagents, + workflows, agentDir: resolve(expandHomePath(env.DEVSPACE_AGENT_DIR ?? files.config.agentDir ?? defaultAgentDir())), logging: parseLoggingConfig(env), }; diff --git a/src/local-agent-profiles.ts b/src/local-agent-profiles.ts index 0dd919d2a..da0de6aa4 100644 --- a/src/local-agent-profiles.ts +++ b/src/local-agent-profiles.ts @@ -47,7 +47,7 @@ export async function loadLocalAgentProfiles( config: ServerConfig, workspaceRoot: string, ): Promise { - if (!config.subagents) return []; + if (!config.subagents && !config.workflows) return []; const profileDirs = [ config.devspaceAgentsDir, diff --git a/src/server.ts b/src/server.ts index 707dc204c..0fe8928a5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -839,7 +839,7 @@ function createMcpServer( const availableAgentsFileOutputs = availableAgentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), })); - const activeWorkflows = config.subagents + const activeWorkflows = config.workflows ? (() => { const workflowStore = createWorkflowStore(config); try { @@ -1629,7 +1629,7 @@ function createMcpServer( registerCodexProcessTools(server, config, workspaces, processSessions); } - if (config.subagents) { + if (config.workflows) { registerWorkflowTools(server, config, workspaces); } @@ -1660,7 +1660,7 @@ export function createServer(config = loadConfig()): RunningServer { const localAgentProviders = config.subagents ? getLocalAgentProviderAvailabilitySnapshot() : []; - const workflowReaper = config.subagents + const workflowReaper = config.workflows ? startWorkflowReaper(config, { onError: (error) => { logEvent(config.logging, "warn", "workflow_reaper_failed", { diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts index b0bd963a6..76ee2ed49 100644 --- a/src/workflow-cli.ts +++ b/src/workflow-cli.ts @@ -54,6 +54,11 @@ export async function runWorkflowCommand( config: ServerConfig, ): Promise { const [subcommand, ...rest] = args; + if (!config.workflows) { + throw new Error( + "Dynamic workflows are disabled. Set DEVSPACE_WORKFLOWS=1 to enable the experimental feature.", + ); + } switch (subcommand) { case "run": await runWorkflowRun(rest, config); diff --git a/src/workflow-tools.ts b/src/workflow-tools.ts index 296a6a9d1..6efbb6f60 100644 --- a/src/workflow-tools.ts +++ b/src/workflow-tools.ts @@ -59,7 +59,7 @@ export function registerWorkflowTools( config: ServerConfig, workspaces: WorkspaceRegistry, ): void { - if (!config.subagents) return; + if (!config.workflows) return; registerAppTool( server, From 69fee30472faddc129cd683a726c6110862ae47b Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:55:53 +0000 Subject: [PATCH 060/132] fix(workspace): expose only usable agent capabilities --- package.json | 2 +- src/local-agent-catalog.test.ts | 33 ++++++ src/local-agent-catalog.ts | 39 +++++++ src/open-workspace-capabilities.test.ts | 42 ++++++++ src/server.ts | 130 +++++++++++++----------- src/ui/card-types.ts | 13 ++- src/ui/workflow-dashboard.ts | 54 +++++----- 7 files changed, 223 insertions(+), 90 deletions(-) create mode 100644 src/local-agent-catalog.test.ts create mode 100644 src/local-agent-catalog.ts create mode 100644 src/open-workspace-capabilities.test.ts diff --git a/package.json b/package.json index b323d2f02..f81a50bad 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-capabilities.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-resolution.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-lifecycle.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-ui.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", + "test": "tsx src/config.test.ts && tsx src/open-workspace-capabilities.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-capabilities.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-resolution.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-lifecycle.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-ui.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/local-agent-catalog.test.ts b/src/local-agent-catalog.test.ts new file mode 100644 index 000000000..a8dc4c01c --- /dev/null +++ b/src/local-agent-catalog.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { buildLocalAgentCatalog } from "./local-agent-catalog.js"; +import type { LocalAgentProfile } from "./local-agent-profiles.js"; + +const profiles: LocalAgentProfile[] = [ + { + name: "reviewer", + description: "Review changes.", + provider: "codex", + filePath: "/repo/.devspace/agents/reviewer.md", + body: "Review carefully.", + disabled: false, + }, + { + name: "claude-reviewer", + description: "Review with Claude.", + provider: "claude", + filePath: "/repo/.devspace/agents/claude-reviewer.md", + body: "Review carefully.", + disabled: false, + }, +]; + +const catalog = buildLocalAgentCatalog(profiles, [ + { name: "codex", available: true }, + { name: "claude", available: false, reason: "missing" }, +]); + +assert.deepEqual(catalog.providers.map((provider) => provider.name), ["codex"]); +assert.deepEqual(catalog.profiles.map((profile) => profile.name), ["reviewer"]); +assert.equal(catalog.providers[0]?.effort.semantics, "reasoning_effort"); + +console.log("local-agent-catalog.test.ts: ok"); diff --git a/src/local-agent-catalog.ts b/src/local-agent-catalog.ts new file mode 100644 index 000000000..8a7486cff --- /dev/null +++ b/src/local-agent-catalog.ts @@ -0,0 +1,39 @@ +import { getLocalAgentProviderCapabilities } from "./local-agent-capabilities.js"; +import type { LocalAgentProviderAvailability } from "./local-agent-availability.js"; +import { + summarizeLocalAgentProfile, + type LocalAgentProfile, +} from "./local-agent-profiles.js"; + +export interface LocalAgentProviderCatalogEntry { + name: LocalAgentProviderAvailability["name"]; + model: ReturnType["model"]; + effort: ReturnType["effort"]; +} + +export interface LocalAgentCatalog { + providers: LocalAgentProviderCatalogEntry[]; + profiles: ReturnType[]; +} + +/** Build the compact model-facing catalog from currently usable providers. */ +export function buildLocalAgentCatalog( + profiles: LocalAgentProfile[], + availability: LocalAgentProviderAvailability[], +): LocalAgentCatalog { + const usable = availability.filter((provider) => provider.available); + const usableNames = new Set(usable.map((provider) => provider.name)); + return { + providers: usable.map((provider) => { + const capabilities = getLocalAgentProviderCapabilities(provider.name); + return { + name: provider.name, + model: capabilities.model, + effort: capabilities.effort, + }; + }), + profiles: profiles + .filter((profile) => usableNames.has(profile.provider)) + .map(summarizeLocalAgentProfile), + }; +} diff --git a/src/open-workspace-capabilities.test.ts b/src/open-workspace-capabilities.test.ts new file mode 100644 index 000000000..a3093c45c --- /dev/null +++ b/src/open-workspace-capabilities.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig } from "./config.js"; +import { openWorkspaceOutputSchema } from "./server.js"; + +const configDir = mkdtempSync(join(tmpdir(), "devspace-open-workspace-capabilities-")); +const baseEnv = { + DEVSPACE_CONFIG_DIR: configDir, + DEVSPACE_ALLOWED_ROOTS: process.cwd(), + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", +}; + +function fields(env: NodeJS.ProcessEnv): Set { + return new Set(Object.keys(openWorkspaceOutputSchema(loadConfig(env)))); +} + +const disabled = fields(baseEnv); +assert.equal(disabled.has("agentProviders"), false); +assert.equal(disabled.has("agents"), false); +assert.equal(disabled.has("activeWorkflows"), false); + +const subagentsOnly = fields({ + ...baseEnv, + DEVSPACE_SUBAGENTS: "1", + DEVSPACE_WORKFLOWS: "0", +}); +assert.equal(subagentsOnly.has("agentProviders"), true); +assert.equal(subagentsOnly.has("agents"), true); +assert.equal(subagentsOnly.has("activeWorkflows"), false); + +const workflowsOnly = fields({ + ...baseEnv, + DEVSPACE_SUBAGENTS: "0", + DEVSPACE_WORKFLOWS: "1", +}); +assert.equal(workflowsOnly.has("agentProviders"), false); +assert.equal(workflowsOnly.has("agents"), false); +assert.equal(workflowsOnly.has("activeWorkflows"), true); + +console.log("open-workspace-capabilities.test.ts: ok"); diff --git a/src/server.ts b/src/server.ts index 0fe8928a5..8a142c271 100644 --- a/src/server.ts +++ b/src/server.ts @@ -46,7 +46,7 @@ import { shutdownHttpServer } from "./server-shutdown.js"; import { formatPathForPrompt } from "./skills.js"; import { createWorkspaceStore } from "./workspace-store.js"; import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; -import { summarizeLocalAgentProfile } from "./local-agent-profiles.js"; +import { buildLocalAgentCatalog } from "./local-agent-catalog.js"; import { registerWorkflowTools } from "./workflow-tools.js"; import { startWorkflowReaper } from "./workflow-lifecycle.js"; import { createWorkflowStore } from "./workflow-store.js"; @@ -210,19 +210,10 @@ function formatVisibleAgent(agent: { provider: string; model?: string; effort?: string; - providerAvailable?: boolean; - providerUnavailableReason?: string; }): string { const model = agent.model ? `, model ${agent.model}` : ""; const effort = agent.effort ? `, effort ${agent.effort}` : ""; - const availability = agent.providerAvailable === false - ? `, unavailable: ${agent.providerUnavailableReason ?? "provider unavailable"}` - : ""; - return `${agent.name} (${agent.provider}${model}${effort}${availability})`; -} - -function formatUnavailableAgentProvider(provider: LocalAgentProviderAvailability): string { - return `${provider.name} (${provider.reason ?? "unavailable"})`; + return `${agent.name} (${agent.provider}${model}${effort})`; } function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { @@ -253,16 +244,54 @@ const workspaceLocalAgentOutputSchema = z.object({ provider: z.string(), model: z.string().optional(), effort: z.string().optional(), - providerAvailable: z.boolean().optional(), - providerUnavailableReason: z.string().optional(), }); const workspaceLocalAgentProviderOutputSchema = z.object({ name: z.string(), - available: z.boolean(), - reason: z.string().optional(), + model: z.object({ + supported: z.boolean(), + discovery: z.enum(["provider_static", "model_dependent", "session_dynamic"]), + }), + effort: z.object({ + supported: z.boolean(), + semantics: z.enum(["reasoning_effort", "thinking_level", "model_variant"]), + discovery: z.enum(["provider_static", "model_dependent", "session_dynamic"]), + }), }); +export function openWorkspaceOutputSchema(config: ServerConfig): z.ZodRawShape { + return { + workspaceId: z.string(), + root: z.string(), + mode: z.enum(["checkout", "worktree"]), + sourceRoot: z.string().optional(), + worktree: z + .object({ + path: z.string(), + baseRef: z.string(), + baseSha: z.string(), + dirtySource: z.boolean(), + detached: z.boolean(), + managed: z.boolean(), + }) + .optional(), + agentsFiles: z.array(workspaceAgentsFileOutputSchema), + availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema), + skills: z.array(workspaceSkillOutputSchema), + ...(config.subagents + ? { + agentProviders: z.array(workspaceLocalAgentProviderOutputSchema), + agents: z.array(workspaceLocalAgentOutputSchema), + } + : {}), + skillDiagnostics: z.array(z.unknown()), + ...(config.workflows + ? { activeWorkflows: z.array(workflowRunSummaryOutputSchema) } + : {}), + instruction: z.string(), + }; +} + const workspaceAvailableAgentsFileOutputSchema = z.object({ path: z.string(), }); @@ -779,30 +808,7 @@ function createMcpServer( .optional() .describe("Git ref to base a worktree on. Only used with mode=\"worktree\". Defaults to HEAD."), }, - outputSchema: { - workspaceId: z.string(), - root: z.string(), - mode: z.enum(["checkout", "worktree"]), - sourceRoot: z.string().optional(), - worktree: z - .object({ - path: z.string(), - baseRef: z.string(), - baseSha: z.string(), - dirtySource: z.boolean(), - detached: z.boolean(), - managed: z.boolean(), - }) - .optional(), - agentsFiles: z.array(workspaceAgentsFileOutputSchema), - availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema), - skills: z.array(workspaceSkillOutputSchema), - agentProviders: z.array(workspaceLocalAgentProviderOutputSchema), - agents: z.array(workspaceLocalAgentOutputSchema), - skillDiagnostics: z.array(z.unknown()), - activeWorkflows: z.array(workflowRunSummaryOutputSchema), - instruction: z.string(), - }, + outputSchema: openWorkspaceOutputSchema(config), ...toolWidgetDescriptorMeta(config, "workspace"), annotations: { readOnlyHint: true }, }, @@ -822,16 +828,11 @@ function createMcpServer( description: skill.description, path: formatPathForPrompt(skill.filePath), })); - const visibleAgentProviders = config.subagents ? localAgentProviders : []; - const visibleAgents = workspace.agentProfiles.map((profile) => { - const summary = summarizeLocalAgentProfile(profile); - const availability = visibleAgentProviders.find((provider) => provider.name === summary.provider); - return { - ...summary, - providerAvailable: availability?.available, - providerUnavailableReason: availability?.reason, - }; - }); + const agentCatalog = config.subagents + ? buildLocalAgentCatalog(workspace.agentProfiles, localAgentProviders) + : undefined; + const visibleAgentProviders = agentCatalog?.providers ?? []; + const visibleAgents = agentCatalog?.profiles ?? []; const loadedAgentsFiles = agentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), content: file.content, @@ -848,7 +849,7 @@ function createMcpServer( workflowStore.close(); } })() - : []; + : undefined; const instruction = config.skillsEnabled ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; @@ -868,11 +869,8 @@ function createMcpServer( visibleSkills.length > 0 ? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}` : undefined, - visibleAgentProviders.some((provider) => provider.available) - ? `Available subagent providers: ${visibleAgentProviders.filter((provider) => provider.available).map((provider) => provider.name).join(", ")}` - : undefined, - visibleAgentProviders.some((provider) => !provider.available) - ? `Unavailable subagent providers: ${visibleAgentProviders.filter((provider) => !provider.available).map(formatUnavailableAgentProvider).join(", ")}` + visibleAgentProviders.length > 0 + ? `Available subagent providers: ${visibleAgentProviders.map((provider) => provider.name).join(", ")}` : undefined, visibleAgents.length > 0 ? `Available subagent profiles: ${visibleAgents.map(formatVisibleAgent).join(", ")}` @@ -902,9 +900,15 @@ function createMcpServer( agentsFiles: loadedAgentsFiles.length, availableAgentsFiles: availableAgentsFileOutputs.length, skills: visibleSkills.length, - activeWorkflows: activeWorkflows.length, - agentProviders: visibleAgentProviders.length, - agents: visibleAgents.length, + ...(config.workflows + ? { activeWorkflows: activeWorkflows?.length ?? 0 } + : {}), + ...(config.subagents + ? { + agentProviders: visibleAgentProviders.length, + agents: visibleAgents.length, + } + : {}), skillDiagnostics: workspace.skillDiagnostics.length, }, }, @@ -918,9 +922,13 @@ function createMcpServer( agentsFiles: loadedAgentsFiles, availableAgentsFiles: availableAgentsFileOutputs, skills: visibleSkills, - activeWorkflows, - agentProviders: visibleAgentProviders, - agents: visibleAgents, + ...(config.workflows ? { activeWorkflows: activeWorkflows ?? [] } : {}), + ...(config.subagents + ? { + agentProviders: visibleAgentProviders, + agents: visibleAgents, + } + : {}), skillDiagnostics: workspace.skillDiagnostics, instruction, }, diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index 67b19fb80..73c9ebbf9 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -71,8 +71,15 @@ export interface ToolResultCard { }; agentProviders?: Array<{ name?: string; - available?: boolean; - reason?: string; + model?: { + supported?: boolean; + discovery?: string; + }; + effort?: { + supported?: boolean; + semantics?: string; + discovery?: string; + }; }>; agents?: Array<{ name?: string; @@ -80,8 +87,6 @@ export interface ToolResultCard { provider?: string; model?: string; effort?: string; - providerAvailable?: boolean; - providerUnavailableReason?: string; }>; skillDiagnostics?: unknown[]; instruction?: string; diff --git a/src/ui/workflow-dashboard.ts b/src/ui/workflow-dashboard.ts index 2503fa8d0..08b1e0d40 100644 --- a/src/ui/workflow-dashboard.ts +++ b/src/ui/workflow-dashboard.ts @@ -26,7 +26,9 @@ export function renderWorkspaceDashboard( root.append( renderDashboardToolbar("Workspace overview", display), - renderWorkflowSummarySection(runs), + ...(card.activeWorkflows !== undefined || project !== null + ? [renderWorkflowSummarySection(runs)] + : []), renderAccordion( "Workspace", true, @@ -70,28 +72,27 @@ export function renderWorkspaceDashboard( "No nested instruction files discovered.", ), ), - renderAccordion( - `Agent providers · ${card.agentProviders?.filter((provider) => provider.available).length ?? 0} available`, - false, - renderProviderList(card), - ), - renderAccordion( - `Agent profiles · ${card.agents?.length ?? 0}`, - false, - renderList( - card.agents?.map((agent) => ({ - title: agent.name ?? "Unnamed profile", - description: [ - agent.provider, - agent.model, - agent.effort, - agent.providerAvailable === false ? agent.providerUnavailableReason ?? "Unavailable" : undefined, - ].filter(Boolean).join(" · "), - meta: agent.description, - })) ?? [], - "No agent profiles loaded.", - ), - ), + ...(card.agentProviders !== undefined + ? [renderAccordion( + `Agent providers · ${card.agentProviders.length}`, + false, + renderProviderList(card), + )] + : []), + ...(card.agents !== undefined + ? [renderAccordion( + `Agent profiles · ${card.agents.length}`, + false, + renderList( + card.agents.map((agent) => ({ + title: agent.name ?? "Unnamed profile", + description: [agent.provider, agent.model, agent.effort].filter(Boolean).join(" · "), + meta: agent.description, + })), + "No agent profiles loaded.", + ), + )] + : []), renderAccordion( `Warnings · ${card.skillDiagnostics?.length ?? 0}`, false, @@ -307,7 +308,12 @@ function renderProviderList(card: ToolResultCard): HTMLElement { return renderList( card.agentProviders?.map((provider) => ({ title: provider.name ?? "Unknown provider", - description: provider.available ? "Available" : provider.reason ?? "Unavailable", + description: [ + provider.model?.supported ? `model: ${provider.model.discovery ?? "supported"}` : undefined, + provider.effort?.supported + ? `effort: ${provider.effort.semantics ?? "supported"} (${provider.effort.discovery ?? "unknown"})` + : undefined, + ].filter(Boolean).join(" · "), })) ?? [], "No subagent providers exposed.", ); From f25d8e94974208a3640a0abd65ec3287236ff06e Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:58:05 +0000 Subject: [PATCH 061/132] refactor(skills): replace subagent delegation guidance --- skills/subagent-delegation/SKILL.md | 132 ---------------------------- skills/subagents/SKILL.md | 50 +++++++++++ src/config.test.ts | 4 +- src/skills.test.ts | 2 +- src/skills.ts | 9 +- src/user-config.ts | 2 +- 6 files changed, 61 insertions(+), 138 deletions(-) delete mode 100644 skills/subagent-delegation/SKILL.md create mode 100644 skills/subagents/SKILL.md diff --git a/skills/subagent-delegation/SKILL.md b/skills/subagent-delegation/SKILL.md deleted file mode 100644 index 0251f1a46..000000000 --- a/skills/subagent-delegation/SKILL.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -name: subagent-delegation -description: Delegate coding tasks to user-configured DevSpace subagents. ---- - -# Subagent Delegation - -Use this skill when the user explicitly asks to delegate work to another coding -agent, use a named subagent, get a second opinion, compare approaches, or run -a subagent-like workflow. - -Do not use subagents silently. Tell the user when another subagent is -being used. - -## Core commands - -Use only these commands for normal delegation: - -```bash -devspace agents ls -devspace agents run "" -devspace agents show -``` - -`ls` shows existing subagent sessions for the current workspace. DevSpace scopes -it automatically from the shell environment injected by the workspace tool. - -`run ""` starts a new configured profile and prints a -DevSpace agent id. - -`run ""` starts a raw built-in provider when no configured -profile is needed. Built-in providers are listed by `open_workspace`. - -`run ""` sends a follow-up to an existing agent. - -`show ` prints status and the latest response. If the agent is still -running, `show` waits briefly. If there is still no final response, call `show` -again later. - -Do not run provider CLIs such as `codex`, `claude`, `opencode`, `pi`, -`cursor-agent`, or `copilot` directly unless you are explicitly debugging -DevSpace agent integration. - -## Choosing a profile - -Choose profiles from the compact subagent profile catalog returned by -`open_workspace`. Use the profile name with `devspace agents run`. If no -profile fits and delegation is still appropriate, use a built-in provider name -from `open_workspace`. - -Profiles may declare a model and optional effort level. To override the -configured/default provider model or effort for a run, pass `--model` -or `--effort` (legacy `--thinking` is accepted as an alias): - -```bash -devspace agents run --model "" -devspace agents run --effort "" -``` - -Use `--effort` only when the user asks for a specific reasoning depth or when -the task clearly needs a different effort than the configured profile default. -Effort values are provider-specific passthrough values. Use names supported by -the selected local agent harness; DevSpace does not translate values between -providers. - -Good delegation targets: - -- `reviewer`: second opinion, bug risk, security risk, test gaps. -- `explorer`: read-only codebase investigation. -- `implementer`: focused implementation when the user asked for delegation. - -Do not delegate ordinary coding work just because a profile exists. Use normal -DevSpace tools unless the user asked for delegation, another agent's opinion, -parallel work, or a named subagent. - -## Worker prompts - -Agents start with only the prompt you send plus their configured profile -instructions. Make prompts self-contained. - -Implementation prompt shape: - -```text -Goal: - - -Context: - - -Relevant files: - - -Acceptance criteria: -- - -Rules: -- Keep changes focused. -- Do not perform unrelated refactors. -- Report blockers clearly. -``` - -Read-only investigation prompt shape: - -```text -Question: - - -Scope: - - -Rules: -- Do not modify files. -- Cite relevant file paths and symbols. -- Separate facts from guesses. -``` - -## After the worker responds - -Always review the result before presenting it as verified. - -For write-capable tasks, inspect changed files and run or explain relevant -tests. For read-only tasks, verify that important claims are supported by repo -evidence. - -Be transparent in the final response: - -```text -I used . It reported . I verified . Remaining risk: -. -``` - -Never hide that a subagent was used. diff --git a/skills/subagents/SKILL.md b/skills/subagents/SKILL.md new file mode 100644 index 000000000..93dabac33 --- /dev/null +++ b/skills/subagents/SKILL.md @@ -0,0 +1,50 @@ +--- +name: subagents +description: Delegate focused work to isolated DevSpace coding agents. +--- + +Each subagent is headless, has its own context window, cannot see the parent conversation, cannot ask the user, and cannot spawn subagents or workflows. Give every child a self-contained prompt with paths, constraints, and the expected report. + +## Choose a target + +Prefer a matching named profile from `open_workspace`. Use a raw provider when +the user names that harness or no profile fits. Choose only profiles and +providers returned by `open_workspace`. + +## Write the brief + +Describe the task directly. Include decisions and constraints that exist only +in the parent conversation. Mention relevant paths or scope when useful. Do not +repeat project instructions that the child can discover from the repository. + +## Run and continue + +```bash +devspace agents run "" +devspace agents show +devspace agents run "" +devspace agents ls +``` + +`run` with a profile or provider starts a child and returns its id. `show` +reads its latest status and response. `run` with an existing id continues the +same child session. `ls` lists sessions for the current project. + +Do not invoke provider CLIs directly; use `devspace agents` so DevSpace keeps +session and provider handling consistent. + +## Model and effort overrides + +Normally omit `--model` and `--effort`. When an exact override is needed, read +`references/.md` first. Do not guess values or transfer an effort +name between providers merely because both use the same word. + +```bash +devspace agents run --model --effort "" +``` + +## Direct subagent or workflow + +Use a direct subagent for one focused delegation or a follow-up with the same +child. Use a dynamic workflow when the task needs programmed fan-out, stages, +branching, nesting, or replay. diff --git a/src/config.test.ts b/src/config.test.ts index f7535ea6e..e285dcb45 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -53,12 +53,12 @@ assert.equal(resolveSubagentsFlag({}, { DEVSPACE_SUBAGENTS: "1" }), true); const seededConfigDir = mkdtempSync(join(tmpdir(), "devspace-seeded-skills-test-")); const seededSkillPaths = ensureDevspaceDefaultSkills({ DEVSPACE_CONFIG_DIR: seededConfigDir }); assert.deepEqual(seededSkillPaths, [ - join(seededConfigDir, "skills", "subagent-delegation", "SKILL.md"), + join(seededConfigDir, "skills", "subagents", "SKILL.md"), join(seededConfigDir, "skills", "dynamic-workflows", "SKILL.md"), ]); assert.equal(existsSync(seededSkillPaths[0]), true); assert.equal(existsSync(seededSkillPaths[1]), true); -assert.match(readFileSync(seededSkillPaths[0], "utf8"), /name: subagent-delegation/); +assert.match(readFileSync(seededSkillPaths[0], "utf8"), /name: subagents/); assert.match(readFileSync(seededSkillPaths[1], "utf8"), /name: dynamic-workflows/); assert.deepEqual(ensureDevspaceDefaultSkills({ DEVSPACE_CONFIG_DIR: seededConfigDir }), []); diff --git a/src/skills.test.ts b/src/skills.test.ts index 707dda262..4b2940eb3 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -204,7 +204,7 @@ try { }); assert.equal( loadWorkspaceSkills(experimentalConfig, projectRoot).skills.some( - (skill) => skill.name === "subagent-delegation", + (skill) => skill.name === "subagents", ), true, ); diff --git a/src/skills.ts b/src/skills.ts index 36e413b65..78ba7b252 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -21,7 +21,8 @@ export interface SkillReadResolution { isSkillFile: boolean; } -const SUBAGENT_DELEGATION_NAME = "subagent-delegation"; +const SUBAGENTS_NAME = "subagents"; +const LEGACY_SUBAGENT_DELEGATION_NAME = "subagent-delegation"; const DYNAMIC_WORKFLOWS_NAME = "dynamic-workflows"; function bundledSkillsDir(): string { @@ -72,7 +73,11 @@ export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSk if (config.subagents) return result; - const gated = new Set([SUBAGENT_DELEGATION_NAME, DYNAMIC_WORKFLOWS_NAME]); + const gated = new Set([ + SUBAGENTS_NAME, + LEGACY_SUBAGENT_DELEGATION_NAME, + DYNAMIC_WORKFLOWS_NAME, + ]); return { skills: result.skills.filter((skill) => !gated.has(skill.name)), diagnostics: result.diagnostics.filter((diagnostic) => { diff --git a/src/user-config.ts b/src/user-config.ts index ad3421f26..445c1f791 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -97,7 +97,7 @@ export function generateOwnerToken(): string { return randomBytes(32).toString("base64url"); } -const DEFAULT_SKILLS = ["subagent-delegation", "dynamic-workflows"] as const; +const DEFAULT_SKILLS = ["subagents", "dynamic-workflows"] as const; export function ensureDevspaceDefaultSkills(env: NodeJS.ProcessEnv = process.env): string[] { const seeded: string[] = []; From 2f174781936363dccd87f91425d4757c047c3e55 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:00:09 +0000 Subject: [PATCH 062/132] docs(skills): add provider-specific override references --- skills/subagents/references/claude.md | 20 ++++++++++++++++++++ skills/subagents/references/codex.md | 19 +++++++++++++++++++ skills/subagents/references/copilot.md | 12 ++++++++++++ skills/subagents/references/cursor.md | 12 ++++++++++++ skills/subagents/references/opencode.md | 12 ++++++++++++ skills/subagents/references/pi.md | 20 ++++++++++++++++++++ src/skills.test.ts | 11 +++++++++++ 7 files changed, 106 insertions(+) create mode 100644 skills/subagents/references/claude.md create mode 100644 skills/subagents/references/codex.md create mode 100644 skills/subagents/references/copilot.md create mode 100644 skills/subagents/references/cursor.md create mode 100644 skills/subagents/references/opencode.md create mode 100644 skills/subagents/references/pi.md diff --git a/skills/subagents/references/claude.md b/skills/subagents/references/claude.md new file mode 100644 index 000000000..d1d50141a --- /dev/null +++ b/skills/subagents/references/claude.md @@ -0,0 +1,20 @@ +# Claude overrides + +DevSpace passes `--model` to the Claude Agent SDK. When `--effort` is present, +DevSpace passes the SDK effort value with adaptive thinking enabled. + +The SDK effort vocabulary is: + +- `low` +- `medium` +- `high` +- `xhigh` +- `max` + +Support is model-dependent. Some Claude models expose only part of this set or +do not support the effort option. Prefer configured defaults and omit an +override when the selected model's capability is unknown. + +```bash +devspace agents run claude --model --effort "" +``` diff --git a/skills/subagents/references/codex.md b/skills/subagents/references/codex.md new file mode 100644 index 000000000..ecc97d4c4 --- /dev/null +++ b/skills/subagents/references/codex.md @@ -0,0 +1,19 @@ +# Codex overrides + +DevSpace passes `--model` to the Codex SDK and maps `--effort` to model +reasoning effort. + +The SDK accepts these effort labels: + +- `minimal` +- `low` +- `medium` +- `high` +- `xhigh` + +The selected model may support only a subset. Prefer the profile or provider +default. Omit `--effort` when the exact model capability is unknown. + +```bash +devspace agents run codex --model --effort "" +``` diff --git a/skills/subagents/references/copilot.md b/skills/subagents/references/copilot.md new file mode 100644 index 000000000..a23a9c556 --- /dev/null +++ b/skills/subagents/references/copilot.md @@ -0,0 +1,12 @@ +# Copilot overrides + +DevSpace connects to Copilot through ACP. `--model` selects the ACP `model` +option and `--effort` selects the ACP `thought_level` option. + +Both option sets are announced by the running Copilot ACP session and may vary +by version or account. Do not invent a value. Omit the override unless the user +provided an exact value known to that Copilot installation. + +```bash +devspace agents run copilot --model --effort "" +``` diff --git a/skills/subagents/references/cursor.md b/skills/subagents/references/cursor.md new file mode 100644 index 000000000..09a7a1674 --- /dev/null +++ b/skills/subagents/references/cursor.md @@ -0,0 +1,12 @@ +# Cursor overrides + +DevSpace connects to Cursor through ACP. `--model` selects the ACP `model` +option and `--effort` selects the ACP `thought_level` option. + +Both option sets are announced by the running Cursor ACP session and may vary +by version or account. Do not invent a value. Omit the override unless the user +provided an exact value known to that Cursor installation. + +```bash +devspace agents run cursor --model --effort "" +``` diff --git a/skills/subagents/references/opencode.md b/skills/subagents/references/opencode.md new file mode 100644 index 000000000..ef0ab01d0 --- /dev/null +++ b/skills/subagents/references/opencode.md @@ -0,0 +1,12 @@ +# OpenCode overrides + +DevSpace passes `--model` to OpenCode. A model may be written as +`/` when the OpenCode provider id is needed. + +DevSpace maps `--effort` to the OpenCode model `variant` field. Variant names +are model-specific; there is no safe global effort list. Omit `--effort` unless +the exact variant is already known from the user's configuration or request. + +```bash +devspace agents run opencode --model --effort "" +``` diff --git a/skills/subagents/references/pi.md b/skills/subagents/references/pi.md new file mode 100644 index 000000000..6953eaf5b --- /dev/null +++ b/skills/subagents/references/pi.md @@ -0,0 +1,20 @@ +# Pi overrides + +DevSpace passes `--model` to Pi and maps `--effort` to Pi's native +`--thinking` option. + +Pi accepts these thinking labels: + +- `off` +- `minimal` +- `low` +- `medium` +- `high` +- `xhigh` + +Pi applies model-specific capability rules, so a selected model may expose or +honor only a subset. Prefer the profile or provider default when uncertain. + +```bash +devspace agents run pi --model --effort "" +``` diff --git a/src/skills.test.ts b/src/skills.test.ts index 4b2940eb3..f9851d7be 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -208,6 +208,17 @@ try { ), true, ); + const subagentsSkill = loadWorkspaceSkills(experimentalConfig, projectRoot).skills.find( + (skill) => skill.name === "subagents", + ); + assert.ok(subagentsSkill); + const codexReference = join(subagentsSkill.baseDir, "references", "codex.md"); + assert.equal(resolveSkillReadPath([subagentsSkill], new Set(), codexReference), undefined); + assert.equal( + resolveSkillReadPath([subagentsSkill], new Set([subagentsSkill.baseDir]), codexReference) + ?.absolutePath, + codexReference, + ); const duplicateConfig = loadConfig({ DEVSPACE_ALLOWED_ROOTS: projectRoot, From f214c19a3039836a99aa951ad6aca63422b58356 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:01:37 +0000 Subject: [PATCH 063/132] fix(skills): keep bundled agent skills package-managed --- src/cli.ts | 4 ---- src/config.test.ts | 16 ++-------------- src/skills.test.ts | 26 +++++++++++++++++++------- src/skills.ts | 19 +++++-------------- src/user-config.ts | 21 +-------------------- 5 files changed, 27 insertions(+), 59 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 29d420864..f5f4d6b29 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -29,7 +29,6 @@ import { import { resolveLocalAgentExecution } from "./local-agent-resolution.js"; import { createLocalAgentStore, type LocalAgentRecord } from "./local-agent-store.js"; import { - ensureDevspaceDefaultSkills, generateOwnerToken, loadDevspaceFiles, resolveSubagentsFlag, @@ -186,12 +185,9 @@ async function runInit({ force }: { force: boolean }): Promise { const configPath = writeDevspaceConfig(config); const authPath = writeDevspaceAuth(auth); - const seededSkillPaths = config.subagents ? ensureDevspaceDefaultSkills() : []; - const lines = [ `Config: ${configPath}`, `Auth: ${authPath}`, - ...seededSkillPaths.map((path) => `Default skill: ${path}`), `Local MCP URL: http://${config.host}:${config.port}/mcp`, ...(publicBaseUrl ? [`Public MCP URL: ${publicBaseUrl}/mcp`] : []), ]; diff --git a/src/config.test.ts b/src/config.test.ts index e285dcb45..ad9162708 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,9 +1,9 @@ import assert from "node:assert/strict"; -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig } from "./config.js"; -import { ensureDevspaceDefaultSkills, resolveSubagentsFlag } from "./user-config.js"; +import { resolveSubagentsFlag } from "./user-config.js"; const emptyConfigDir = mkdtempSync(join(tmpdir(), "devspace-empty-config-test-")); const baseEnv = { @@ -50,18 +50,6 @@ assert.equal(resolveSubagentsFlag({ subagents: true }, {}), true); assert.equal(resolveSubagentsFlag({ subagents: true }, { DEVSPACE_SUBAGENTS: "0" }), false); assert.equal(resolveSubagentsFlag({}, { DEVSPACE_SUBAGENTS: "1" }), true); -const seededConfigDir = mkdtempSync(join(tmpdir(), "devspace-seeded-skills-test-")); -const seededSkillPaths = ensureDevspaceDefaultSkills({ DEVSPACE_CONFIG_DIR: seededConfigDir }); -assert.deepEqual(seededSkillPaths, [ - join(seededConfigDir, "skills", "subagents", "SKILL.md"), - join(seededConfigDir, "skills", "dynamic-workflows", "SKILL.md"), -]); -assert.equal(existsSync(seededSkillPaths[0]), true); -assert.equal(existsSync(seededSkillPaths[1]), true); -assert.match(readFileSync(seededSkillPaths[0], "utf8"), /name: subagents/); -assert.match(readFileSync(seededSkillPaths[1], "utf8"), /name: dynamic-workflows/); -assert.deepEqual(ensureDevspaceDefaultSkills({ DEVSPACE_CONFIG_DIR: seededConfigDir }), []); - assert.throws( () => loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "invalid" }), /Invalid DEVSPACE_WIDGETS: invalid/, diff --git a/src/skills.test.ts b/src/skills.test.ts index f9851d7be..4bc585ea7 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -199,16 +199,15 @@ try { DEVSPACE_ALLOWED_ROOTS: projectRoot, DEVSPACE_AGENT_DIR: agentDir, DEVSPACE_SUBAGENTS: "1", + DEVSPACE_WORKFLOWS: "0", DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", PORT: "1", }); - assert.equal( - loadWorkspaceSkills(experimentalConfig, projectRoot).skills.some( - (skill) => skill.name === "subagents", - ), - true, - ); - const subagentsSkill = loadWorkspaceSkills(experimentalConfig, projectRoot).skills.find( + const subagentSkills = loadWorkspaceSkills(experimentalConfig, projectRoot).skills; + assert.equal(subagentSkills.some((skill) => skill.name === "subagents"), true); + assert.equal(subagentSkills.some((skill) => skill.name === "dynamic-workflows"), false); + assert.equal(subagentSkills.some((skill) => skill.name === "subagent-delegation"), false); + const subagentsSkill = subagentSkills.find( (skill) => skill.name === "subagents", ); assert.ok(subagentsSkill); @@ -220,6 +219,19 @@ try { codexReference, ); + const workflowsOnlyConfig = loadConfig({ + DEVSPACE_ALLOWED_ROOTS: projectRoot, + DEVSPACE_AGENT_DIR: agentDir, + DEVSPACE_SUBAGENTS: "0", + DEVSPACE_WORKFLOWS: "1", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + PORT: "1", + }); + const workflowSkills = loadWorkspaceSkills(workflowsOnlyConfig, projectRoot).skills; + assert.equal(workflowSkills.some((skill) => skill.name === "subagents"), false); + assert.equal(workflowSkills.some((skill) => skill.name === "dynamic-workflows"), true); + assert.equal(workflowSkills.some((skill) => skill.name === "subagent-delegation"), false); + const duplicateConfig = loadConfig({ DEVSPACE_ALLOWED_ROOTS: projectRoot, DEVSPACE_AGENT_DIR: agentDir, diff --git a/src/skills.ts b/src/skills.ts index 78ba7b252..b547fbef3 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -29,19 +29,14 @@ function bundledSkillsDir(): string { return fileURLToPath(new URL("../skills", import.meta.url)); } -/** - * Always include the bundled skills root when subagents are enabled. - * Previously the whole dir was dropped if the user had seeded - * subagent-delegation — that hid later skills (e.g. dynamic-workflows). - * User/devspace copies still win via earlier path order + name collisions. - */ +/** Package skills are defaults; user/project copies win by path order. */ export function effectiveSkillPaths(config: ServerConfig, cwd: string): string[] { const defaultPathCandidates = [ join(homedir(), ".agents", "skills"), resolve(cwd, ".agents", "skills"), config.devspaceSkillsDir, join(config.agentDir, "skills"), - config.subagents ? bundledSkillsDir() : undefined, + config.subagents || config.workflows ? bundledSkillsDir() : undefined, ]; const defaultPaths = defaultPathCandidates.filter( (path): path is string => path !== undefined && existsSync(path), @@ -71,13 +66,9 @@ export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSk includeDefaults: false, }); - if (config.subagents) return result; - - const gated = new Set([ - SUBAGENTS_NAME, - LEGACY_SUBAGENT_DELEGATION_NAME, - DYNAMIC_WORKFLOWS_NAME, - ]); + const gated = new Set([LEGACY_SUBAGENT_DELEGATION_NAME]); + if (!config.subagents) gated.add(SUBAGENTS_NAME); + if (!config.workflows) gated.add(DYNAMIC_WORKFLOWS_NAME); return { skills: result.skills.filter((skill) => !gated.has(skill.name)), diagnostics: result.diagnostics.filter((diagnostic) => { diff --git a/src/user-config.ts b/src/user-config.ts index 445c1f791..970685cfa 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -6,7 +6,7 @@ import { writeFileSync, } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { join, resolve } from "node:path"; import { expandHomePath } from "./roots.js"; export interface DevspaceUserConfig { @@ -97,25 +97,6 @@ export function generateOwnerToken(): string { return randomBytes(32).toString("base64url"); } -const DEFAULT_SKILLS = ["subagents", "dynamic-workflows"] as const; - -export function ensureDevspaceDefaultSkills(env: NodeJS.ProcessEnv = process.env): string[] { - const seeded: string[] = []; - for (const name of DEFAULT_SKILLS) { - const targetPath = join(devspaceSkillsDir(env), name, "SKILL.md"); - if (existsSync(targetPath)) continue; - const sourcePath = new URL(`../skills/${name}/SKILL.md`, import.meta.url); - try { - mkdirSync(dirname(targetPath), { recursive: true }); - writeFileSync(targetPath, readFileSync(sourcePath, "utf8"), { mode: 0o644 }); - seeded.push(targetPath); - } catch { - // skill may not exist in package yet; skip - } - } - return seeded; -} - export function resolveSubagentsFlag( config: Pick, env: NodeJS.ProcessEnv = process.env, From 1ace1f0a83dc0683c7e063bf38aca40d291f110b Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:05:44 +0000 Subject: [PATCH 064/132] refactor(workflow): name live provider availability accurately --- skills/dynamic-workflows/SKILL.md | 2 +- src/cli.ts | 5 +++++ src/workflow-api.ts | 11 +++++------ src/workflow-cli.ts | 6 +++--- src/workflow-contracts.ts | 1 - src/workflow-engine.test.ts | 26 +++++++++++++------------- src/workflow-engine.ts | 4 ++-- 7 files changed, 29 insertions(+), 26 deletions(-) diff --git a/skills/dynamic-workflows/SKILL.md b/skills/dynamic-workflows/SKILL.md index c5d42cd89..de4ab1814 100644 --- a/skills/dynamic-workflows/SKILL.md +++ b/skills/dynamic-workflows/SKILL.md @@ -91,7 +91,7 @@ profile supplies instructions, provider, model, and effort defaults; per-call mutually exclusive. Without a profile, default provider resolution is `opts.provider` → -`meta.defaultProvider` → first **enabled ∩ available** provider. +`meta.defaultProvider` → first currently available provider. ### Resume diff --git a/src/cli.ts b/src/cli.ts index f5f4d6b29..3f90e8e96 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -70,6 +70,11 @@ async function main(argv: string[]): Promise { runConfigCommand(args); return; case "agents": + if (!loadConfig().subagents) { + throw new Error( + "Subagents are disabled. Set DEVSPACE_SUBAGENTS=1 to enable the experimental feature.", + ); + } await runAgentsCommand(args); return; case "workflow": diff --git a/src/workflow-api.ts b/src/workflow-api.ts index ccf625904..cd0d60da0 100644 --- a/src/workflow-api.ts +++ b/src/workflow-api.ts @@ -158,9 +158,9 @@ export interface WorkflowApiDeps { signal: AbortSignal; workspaceRoot: string; baseSha?: string; - /** Already-filtered enabled ∩ live provider ids, preference order. */ - enabledProviders: LocalAgentProvider[]; - /** Loaded, enabled profiles exposed by open_workspace for this project. */ + /** Currently available provider ids in stable preference order. */ + availableProviders: LocalAgentProvider[]; + /** Loaded profiles available to this project. */ agentProfiles?: LocalAgentProfile[]; runProvider: WorkflowRunProvider; createWorktree?: CreateAgentWorktree; @@ -186,7 +186,6 @@ export class WorkflowEngineError extends Error { constructor( readonly kind: | "cancelled" - | "provider_disabled" | "provider_unavailable" | "no_provider" | "profile" @@ -711,7 +710,7 @@ interface ResolvedAgentTarget { function resolveAgentTarget( prompt: string, opts: AgentOpts, - deps: Pick, + deps: Pick, ): ResolvedAgentTarget { try { const resolved = resolveLocalAgentExecution({ @@ -722,7 +721,7 @@ function resolveAgentTarget( model: opts.model, effort: opts.effort, profiles: deps.agentProfiles ?? [], - availableProviders: deps.enabledProviders, + availableProviders: deps.availableProviders, }); return { provider: resolved.provider, diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts index 76ee2ed49..bfd003679 100644 --- a/src/workflow-cli.ts +++ b/src/workflow-cli.ts @@ -352,7 +352,7 @@ export async function runWorkflowWorker( try { const source = await readFile(claimed.scriptPath, "utf8"); const parsed = parseWorkflowScript(source, { filename: claimed.scriptPath }); - const enabledProviders = resolveEnabledProviders(); + const availableProviders = resolveAvailableProviders(); const agentProfiles = await loadLocalAgentProfiles(config, claimed.workspaceRoot); const concurrency = resolveWorkflowConcurrency( parsed.meta.concurrency, @@ -385,7 +385,7 @@ export async function runWorkflowWorker( signal: abort.signal, workspaceRoot: claimed.workspaceRoot, baseSha: claimed.baseSha, - enabledProviders, + availableProviders, agentProfiles, createWorktree, replay, @@ -600,7 +600,7 @@ function safeParseJson(text: string): unknown { } } -function resolveEnabledProviders(): LocalAgentProvider[] { +function resolveAvailableProviders(): LocalAgentProvider[] { const snapshot = getLocalAgentProviderAvailabilitySnapshot(); const live = new Set(snapshot.filter((row) => row.available).map((row) => row.name)); return LOCAL_AGENT_PROVIDERS.filter((id) => live.has(id)); diff --git a/src/workflow-contracts.ts b/src/workflow-contracts.ts index bbdd091df..d46419625 100644 --- a/src/workflow-contracts.ts +++ b/src/workflow-contracts.ts @@ -121,7 +121,6 @@ export const workflowErrorKindSchema = z.enum([ "syntax", "meta", "determinism", - "provider_disabled", "provider_unavailable", "no_provider", "provider", diff --git a/src/workflow-engine.test.ts b/src/workflow-engine.test.ts index 4eb6aa173..368494769 100644 --- a/src/workflow-engine.test.ts +++ b/src/workflow-engine.test.ts @@ -57,7 +57,7 @@ import type { LocalAgentProfile } from "./local-agent-profiles.js"; concurrency: 4, signal: new AbortController().signal, workspaceRoot: dir, - enabledProviders: ["codex"], + availableProviders: ["codex"], runProvider: async (input) => { order.push(`start:${input.prompt}`); await new Promise((r) => setTimeout(r, 10)); @@ -102,7 +102,7 @@ import type { LocalAgentProfile } from "./local-agent-profiles.js"; concurrency: 4, signal: new AbortController().signal, workspaceRoot: dir, - enabledProviders: ["codex"], + availableProviders: ["codex"], runProvider: async () => ({ finalResponse: "x" }), }); @@ -165,7 +165,7 @@ import type { LocalAgentProfile } from "./local-agent-profiles.js"; concurrency: 4, signal: new AbortController().signal, workspaceRoot: dir, - enabledProviders: ["codex"], + availableProviders: ["codex"], runProvider: async (input: WorkflowProviderRunInput) => { seen.push({ prompt: input.prompt, phase: input.phase }); await new Promise((r) => setTimeout(r, 15)); @@ -226,7 +226,7 @@ import type { LocalAgentProfile } from "./local-agent-profiles.js"; concurrency: 2, signal: new AbortController().signal, workspaceRoot: dir, - enabledProviders: ["codex"], + availableProviders: ["codex"], createWorktree, runProvider: async (input) => { assert.equal(input.workspace, worktrees[0]); @@ -265,7 +265,7 @@ import type { LocalAgentProfile } from "./local-agent-profiles.js"; concurrency: 1, signal: new AbortController().signal, workspaceRoot: dir, - enabledProviders: ["codex"], + availableProviders: ["codex"], createWorktree: async () => { throw new Error("expected worktree setup failure"); }, @@ -312,7 +312,7 @@ import type { LocalAgentProfile } from "./local-agent-profiles.js"; concurrency: 1, signal: new AbortController().signal, workspaceRoot: dir, - enabledProviders: ["codex", "claude"], + availableProviders: ["codex", "claude"], runProvider: async (input) => { used.push(input.provider); return { finalResponse: input.provider }; @@ -360,7 +360,7 @@ import type { LocalAgentProfile } from "./local-agent-profiles.js"; concurrency: 1, signal: new AbortController().signal, workspaceRoot: dir, - enabledProviders: ["codex", "claude"], + availableProviders: ["codex", "claude"], agentProfiles: [profile], runProvider: async (input) => { calls.push(input); @@ -401,7 +401,7 @@ import type { LocalAgentProfile } from "./local-agent-profiles.js"; concurrency: 1, signal: new AbortController().signal, workspaceRoot: dir, - enabledProviders: ["codex"], + availableProviders: ["codex"], agentProfiles: [profile], runProvider: async () => ({ finalResponse: "unreachable" }), }); @@ -440,7 +440,7 @@ import type { LocalAgentProfile } from "./local-agent-profiles.js"; concurrency: 1, signal: new AbortController().signal, workspaceRoot: dir, - enabledProviders: ["codex"], + availableProviders: ["codex"], runProvider: async (input) => { calls.push(input); if (calls.length === 1) { @@ -494,7 +494,7 @@ import type { LocalAgentProfile } from "./local-agent-profiles.js"; concurrency: 1, signal: new AbortController().signal, workspaceRoot: dir, - enabledProviders: ["codex"], + availableProviders: ["codex"], runProvider: async () => ({ finalResponse: response }), }); @@ -583,7 +583,7 @@ return { a, nested } runId: run.id, journal: store, workspaceRoot: dir, - enabledProviders: ["codex", "claude"], + availableProviders: ["codex", "claude"], runProvider: async (input) => { prompts.push(input.prompt); providers.push(input.provider); @@ -620,7 +620,7 @@ return await workflow({ scriptPath: ${JSON.stringify(childPath)} }).then(async ( runId: run.id, journal: store, workspaceRoot: dir, - enabledProviders: ["codex"], + availableProviders: ["codex"], runProvider: async () => ({ finalResponse: "x" }), resolveNestedSource: async () => ` export const meta = { name: 'mid', description: 'm' } @@ -657,7 +657,7 @@ return await workflow({ scriptPath: 'x' }) concurrency: 1, signal: ac.signal, workspaceRoot: dir, - enabledProviders: ["codex"], + availableProviders: ["codex"], runProvider: async () => { ac.abort(); return { finalResponse: "late" }; diff --git a/src/workflow-engine.ts b/src/workflow-engine.ts index 7d7d34f01..72081b538 100644 --- a/src/workflow-engine.ts +++ b/src/workflow-engine.ts @@ -40,7 +40,7 @@ export interface ExecuteWorkflowOptions { signal?: AbortSignal; workspaceRoot: string; baseSha?: string; - enabledProviders: LocalAgentProvider[]; + availableProviders: LocalAgentProvider[]; agentProfiles?: LocalAgentProfile[]; runProvider: WorkflowRunProvider; createWorktree?: CreateAgentWorktree; @@ -89,7 +89,7 @@ export async function executeWorkflow( signal, workspaceRoot: options.workspaceRoot, baseSha: options.baseSha, - enabledProviders: options.enabledProviders, + availableProviders: options.availableProviders, agentProfiles: options.agentProfiles, runProvider: options.runProvider, createWorktree: options.createWorktree, From 5c1ddeab4385916b553766ec0856c94074369161 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:08:21 +0000 Subject: [PATCH 065/132] docs(agents): document experimental capability boundaries --- docs/chatgpt-coding-workflow.md | 14 +- docs/configuration.md | 18 +- docs/dynamic-workflow/devspace/plan.md | 46 ++--- .../devspace/primitives-spec.md | 164 +++++------------- docs/gotchas.md | 10 +- 5 files changed, 102 insertions(+), 150 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 1ab0b90f3..9c3994f6b 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -85,16 +85,17 @@ DevSpace discovers standard Agent Skills from: - project `.agents/skills` - `~/.devspace/skills` -It also keeps compatibility with: +It also includes: -- the bundled `subagent-delegation` skill when `DEVSPACE_SUBAGENTS=1`, unless `~/.devspace/skills/subagent-delegation/SKILL.md` exists +- the package-managed `subagents` skill when `DEVSPACE_SUBAGENTS=1` +- the package-managed `dynamic-workflows` skill when Dynamic Workflows are enabled - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - additional paths from `DEVSPACE_SKILL_PATHS` When Subagents are enabled, DevSpace discovers agent profiles from `~/.devspace/agents/*.md` and project `.devspace/agents/*.md`. `open_workspace` exposes a compact catalog with profile names, descriptions, -providers, and optional models/thinking levels so the model can choose a configured agent +providers, and optional models/effort levels so the model can choose a configured agent without seeing provider-specific launch details. Example profiles are packaged under `examples/agents/` for users who want @@ -113,11 +114,16 @@ Skill paths may be outside the workspace. DevSpace only permits reading: Set `DEVSPACE_SKILLS=0` to hide skills from workspace output. Set `DEVSPACE_SUBAGENTS=1` to expose the experimental subagent catalog and -`subagent-delegation` skill. That skill teaches the minimal +`subagents` skill. That skill teaches the minimal `devspace agents ls`, `devspace agents run`, and `devspace agents show` workflow. The catalog comes from `open_workspace`; `devspace agents ls` lists existing subagent sessions for that workspace. +Set `DEVSPACE_WORKFLOWS=1` to enable Dynamic Workflows independently. When the +variable is omitted it follows `DEVSPACE_SUBAGENTS` for compatibility. Disabled +features are omitted from the `open_workspace` schema and response rather than +returned as empty capability arrays. + ## Tool Names DevSpace exposes these tool names: diff --git a/docs/configuration.md b/docs/configuration.md index 14bc2a54c..abcef947d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -93,6 +93,7 @@ sessions. | --- | --- | | `DEVSPACE_SKILLS` | Set to `0` to hide skills. Enabled by default. | | `DEVSPACE_SUBAGENTS` | Set to `1` to expose configured agent profiles as Subagents. Experimental and disabled by default. | +| `DEVSPACE_WORKFLOWS` | Experimental Dynamic Workflows gate. When unset, it follows `DEVSPACE_SUBAGENTS` for compatibility. | | `DEVSPACE_AGENT_DIR` | Defaults to `~/.codex`; its `skills` child is loaded for compatibility. | | `DEVSPACE_SKILL_PATHS` | Optional comma-separated additional skill directories. | @@ -102,12 +103,16 @@ DevSpace discovers standard Agent Skills from: - project `.agents/skills` - `~/.devspace/skills` -It also keeps compatibility with: +It also includes: -- the bundled `subagent-delegation` skill when `DEVSPACE_SUBAGENTS=1`, unless `~/.devspace/skills/subagent-delegation/SKILL.md` exists +- the package-managed `subagents` skill when `DEVSPACE_SUBAGENTS=1` +- the package-managed `dynamic-workflows` skill when Dynamic Workflows are enabled - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - additional paths from `DEVSPACE_SKILL_PATHS` +User and project skills with the same name take precedence over bundled skills. +DevSpace does not copy bundled skills into `~/.devspace/skills` during setup. + When Subagents are enabled, DevSpace discovers agent profiles from: @@ -115,13 +120,18 @@ from: - project `.devspace/agents/*.md` `open_workspace` returns a compact catalog containing profile names, -descriptions, providers, and optional models/thinking levels so the host model can choose an +descriptions, providers, and optional models/effort levels so the host model can choose an agent without reading provider-specific launch details. `devspace agents ls` lists existing subagent sessions for the current workspace, scoped by the -workspace environment injected into shell commands. The `subagent-delegation` +workspace environment injected into shell commands. The `subagents` skill teaches the model to use only the minimal `devspace agents ls`, `devspace agents run`, and `devspace agents show` workflow. +Provider availability is detected at runtime. DevSpace does not persist probe +timestamps, availability snapshots, or an experimental provider enable-list in +`config.json`. Final provider policy and onboarding are deferred until the +Subagents and Dynamic Workflows features are finalized. + Starter profile templates are available under `examples/agents/`. Copy or adapt them into one of the active profile directories before use. diff --git a/docs/dynamic-workflow/devspace/plan.md b/docs/dynamic-workflow/devspace/plan.md index 2c13780c3..4a73627fb 100644 --- a/docs/dynamic-workflow/devspace/plan.md +++ b/docs/dynamic-workflow/devspace/plan.md @@ -19,7 +19,7 @@ Subagents stay **CLI-only**. Workflows get **CLI + MCP** over shared primitives. | `budget` stub v1 | `{ total: null, spent: () => 0, remaining: () => Infinity }`. | | Dual surface | `devspace workflow *` **and** MCP `run_workflow` / `workflow_status` / `workflow_cancel`. | | All 6 providers v1 | codex/claude/opencode/pi/cursor/copilot via existing adapters. | -| `agentProviders.enabled` | Ordered config from onboarding; default provider = first enabled ∩ live. | +| Provider policy | Runtime uses currently available providers in stable product order. Durable provider policy and onboarding are deferred. | | Resume-by-replay right after engine core | Same milestone order as locked plan. | --- @@ -119,28 +119,28 @@ runProvider({ provider, prompt, workspace, model, effort, providerSessionId? }) - If product later wants unified list, add a flag — not v1. - `workspace` is either shared `workspaceRoot` or a managed worktree path when `opts.isolation === 'worktree'`. -### 4.3 Provider resolution + config +### 4.3 Provider resolution now; policy later -Config add (see primitives-spec §3): +Current experimental runtime: -```ts -agentProviders?: { - enabled: AgentProviderId[] // order = preference; [0] = default - detectedAt?: string - lastProbe?: Array<{ id, available, detail? }> -} -``` +- Probe provider availability at execution time. +- Resolve `opts.provider` → `meta.defaultProvider` → first available provider + in stable product order. +- Keep probe timestamps and unavailable reasons in diagnostics only; do not + persist them in user configuration. +- Unknown or unavailable explicit providers fail that `agent()` call. -Algorithm: `opts.provider` → `meta.defaultProvider` → first of `enabled ∩ liveAvailable`. -Missing `agentProviders` → compat all-available in code order. -Onboarding (`init`/`doctor`) probes PATH and writes `enabled`. -Unknown/unavailable: fail that `agent()` (throw → parallel null). +The final onboarding release may add an ordered array of provider policy +objects with `id`, `enabled`, `defaultModel`, and `defaultEffort`. That contract +is deliberately deferred so the workflow stack does not publish an unfinished +configuration shape. ### 4.4 Skills gating fix (required, not optional) -Today `effectiveSkillPaths` drops **entire** bundled dir if user has seeded `subagent-delegation` — hides any new bundled skill. - -v1: include bundled **per-skill** (user copy wins on name collision). Seed `dynamic-workflows` in `user-config` next to subagent skill. +Bundled `subagents` and `dynamic-workflows` skills remain package-managed. +User/project copies win on name collision. Setup does not copy bundled skills +into `~/.devspace/skills`, which prevents generated copies from shadowing later +package updates. The legacy `subagent-delegation` name is suppressed. ### 4.5 MCP vs CLI symmetry @@ -240,9 +240,9 @@ Provider-native strings pass through unchanged. | **3 Engine** | `isolation` path with fake/temp git repos in tests | | **4 Worker+CLI** | real worktree create/cleanup; journal fields | | **5 Resume** | cache key includes isolation | -| **8 Teach** | skill isolation + effort; seed `agentProviders` docs | +| **8 Teach** | skill isolation + effort; document deferred provider policy | | Cross-cutting | rename `thinking`→`effort` in profile/CLI/store/adapters (can land with M3–4) | -| Config | `agentProviders` on user-config + init probe (with M4 or M8) | +| Config | Keep provider availability runtime-only until final onboarding. | --- @@ -305,8 +305,8 @@ E2E: `npm test` + `npm run typecheck`; live fan-out 2 providers CLI; same MCP; c | `cli.ts` `spawnAgentWorker` / `agents __worker` | copy for `workflow __worker` | | `process-platform.terminateProcessTree` | hard cancel | | `db/client` WAL + busy_timeout 5000 | multi-process journal | -| `server.ts` `registerAppTool` + `config.subagents` gate | tools only if subagents on | -| `skills.ts` / `user-config.ts` | gate fix + seed | +| `server.ts` `registerAppTool` + workflow capability gate | tools only if workflows are enabled | +| `skills.ts` | independent package-managed skill gates | | `process-sessions` yield bounds | MCP status yield caps | --- @@ -356,7 +356,7 @@ Do not open MCP before CLI smoke — debug path must work headless without a hos ## Resolved questions (see also [primitives-spec.md](./primitives-spec.md)) -1. **Default provider:** `opts.provider` → `meta.defaultProvider` → first of **onboarding-configured** `agentProviders.enabled` ∩ live available. Full config schema in primitives-spec §3. +1. **Default provider:** `opts.provider` → `meta.defaultProvider` → first live provider in stable product order. Final provider defaults and enablement are deferred to onboarding finalization. 2. **writeMode:** **not in v1 API**; skill teaches prompt-based RO/write. 3. **Isolation:** **`isolation?: 'worktree'` is v1 must-have** on `agent()`; default shared; no auto-merge. 4. **Effort rename:** `thinking` → **`effort`** across profiles, CLI, store, adapters, `agent()` opts, cache keys. @@ -366,4 +366,4 @@ Do not open MCP before CLI smoke — debug path must work headless without a hos 8. **Cancel:** cooperative flag → then group-kill. **File-change tracking:** out of scope. -**Schema:** `opts.schema` + Ajv + retries — in scope. \ No newline at end of file +**Schema:** `opts.schema` + Ajv + retries — in scope. diff --git a/docs/dynamic-workflow/devspace/primitives-spec.md b/docs/dynamic-workflow/devspace/primitives-spec.md index 042b9715b..0f23ea6af 100644 --- a/docs/dynamic-workflow/devspace/primitives-spec.md +++ b/docs/dynamic-workflow/devspace/primitives-spec.md @@ -10,7 +10,7 @@ Pairs with [plan.md](./plan.md). Subagents remain CLI-only; this document is **w | Goal | Surface | |---|---| | DW for coding agents that lack Workflow (pi, codex, opencode, cursor, …) | **CLI + skill** — host agent authors script, runs `devspace workflow *` | -| ChatGPT as orchestrator, not implementer | **MCP workflow tools** (togglable with subagents) — plan + `run_workflow` / status / cancel | +| ChatGPT as orchestrator, not implementer | **MCP workflow tools** behind the workflow capability gate — plan + `run_workflow` / status / cancel | | Ship both in dev | One engine; two entrypoints; converge later on performance/UX | ``` @@ -24,7 +24,7 @@ ChatGPT ── MCP tools ──► engine ── agent() ──► adapter | # | Topic | Decision | |---|---|---| -| 1 | Default provider | **Configured enabled provider list** (onboarding auto-detect CLIs → `config.json`). Runtime: `opts.provider` → `meta.defaultProvider` → **first entry of enabled+available list**. | +| 1 | Default provider | Runtime: `opts.provider` → `meta.defaultProvider` → first currently available provider in stable product order. Final provider policy is deferred. | | 2 | Access / writeMode | **Not in v1 API.** No `writeMode`. Skill teaches **prompt-based** RO vs write. Isolation handles *where* writes land (see isolation). | | 3 | List runs | **No MCP list tool v1.** **CLI** `devspace workflow ls` yes. | | 4 | Size caps | Soft/hard bounds on journal + results (§8). | @@ -60,136 +60,66 @@ ChatGPT ── MCP tools ──► engine ── agent() ──► adapter --- -## 3. Config: agent providers (what to add) +## 3. Provider availability now; policy after finalization -### Today (`DevspaceUserConfig` / `.devspace/config.json`) +### Current experimental contract -Existing fields (unchanged conceptually): +There is no user-facing `agentProviders` block and no +`DEVSPACE_AGENT_PROVIDERS` environment variable. DevSpace probes implemented +providers at runtime, keeps availability details in memory, and orders usable +providers by `LOCAL_AGENT_PROVIDERS`: -```ts -// src/user-config.ts — today -interface DevspaceUserConfig { - host?: string - port?: number - allowedRoots?: string[] - publicBaseUrl?: string | null - allowedHosts?: string[] - stateDir?: string - worktreeRoot?: string - agentDir?: string - subagents?: boolean // master switch only -} +```text +codex → claude → opencode → pi → cursor → copilot +``` + +`devspace init` does not configure providers. `devspace doctor` may report live +availability but remains read-only. Probe timestamps and unavailable-provider +reasons are diagnostics, not durable user intent. + +Default resolution is: + +```text +explicit agent() provider + → workflow meta.defaultProvider + → first currently available provider ``` -There is **no** persisted enable-list. Runtime exposes every implemented provider that is **currently on PATH** (`getLocalAgentProviderAvailabilitySnapshot`). Order is code order of `LOCAL_AGENT_PROVIDERS`, not user preference. No onboarding write-back. +An explicit provider or profile whose harness is unavailable fails with a clear +typed error. Direct `devspace agents` calls and workflow `agent()` calls use the +same target resolver. -### Add: `agentProviders` on user config +### Deferred final provider policy + +When Subagents and Dynamic Workflows are finalized and incorporated into +onboarding, the intended durable shape is an ordered array of user choices: ```ts -/** Known built-in ids — keep in sync with LocalAgentProvider */ -type AgentProviderId = - | "codex" - | "claude" - | "opencode" - | "pi" - | "cursor" - | "copilot" - -interface AgentProvidersConfig { - /** - * Ordered enable-list. Order = preference. - * index 0 = default fallback for agent() when provider omitted. - * Only ids in this list may be used by workflows/subagents (if present). - * Missing/empty → fall back to "all currently available" (compat) OR - * require init (prefer: treat missing as "auto = all available in code order"). - */ - enabled: AgentProviderId[] - - /** ISO time of last successful probe (init/doctor). Optional. */ - detectedAt?: string - - /** - * Optional last probe snapshot for doctor UI (not required at runtime). - * Do not use as source of truth for enablement — `enabled` is. - */ - lastProbe?: Array<{ - id: AgentProviderId - available: boolean - detail?: string // path or error - }> +interface AgentProviderPolicy { + id: AgentProviderId + enabled: boolean + defaultModel?: string + defaultEffort?: string } interface DevspaceUserConfig { - // ...existing... - subagents?: boolean - agentProviders?: AgentProvidersConfig // NEW -} -``` - -### Example `~/.devspace/config.json` - -```json -{ - "host": "127.0.0.1", - "port": 7676, - "allowedRoots": ["/home/you/work"], - "subagents": true, - "agentProviders": { - "enabled": ["codex", "claude", "opencode", "pi"], - "detectedAt": "2026-07-21T12:00:00.000Z", - "lastProbe": [ - { "id": "codex", "available": true, "detail": "/usr/bin/codex" }, - { "id": "claude", "available": true, "detail": "/home/you/.local/bin/claude" }, - { "id": "opencode", "available": true }, - { "id": "pi", "available": true }, - { "id": "cursor", "available": false, "detail": "not found" }, - { "id": "copilot", "available": false, "detail": "not found" } - ] - } + // ...existing fields... + agentProviders?: AgentProviderPolicy[] } ``` -### Semantics +Array order can define fallback preference. Resolution will then be: -| Concern | Spec | -|---|---| -| **Master switch** | `subagents: true` still required for workflow tools + agent CLI + skills. | -| **Enable-list** | `agentProviders.enabled` is the only user-facing allowlist. | -| **Order** | First entry = default `agent()` provider after availability filter. | -| **Live ∩ config** | `candidates = enabled.filter(id => currentlyAvailable(id))`. Stale enable of uninstalled CLI → skipped with doctor warning, not hard-fail until no candidates. | -| **Unknown ids** | Reject on write/init; ignore-with-warn at read if config hand-edited. | -| **Missing `agentProviders`** | Compat: `enabled` effective = all available in built-in order (today’s behavior). Init should still write the block. | -| **Empty `enabled: []`** | Error at first `agent()` / `agents run`: “no providers enabled”. | -| **Env override (optional)** | `DEVSPACE_AGENT_PROVIDERS=codex,claude` replaces `enabled` for process (ops/debug). | -| **ServerConfig** | Load into `ServerConfig.agentProviders: { enabled: AgentProviderId[] }` resolved at boot. | - -### Onboarding (`devspace init` / `doctor`) - -1. Probe all six providers (reuse `local-agent-availability`). -2. Set `enabled` = available ids in **stable product order**: - `codex → claude → opencode → pi → cursor → copilot` (only those available). -3. Write `detectedAt` + optional `lastProbe`. -4. `doctor` re-probes; offers to refresh `enabled` (add newly installed; optionally keep user-disabled by not auto-re-adding removed ids — v1: refresh = rewrite available set, document that). - -### Default provider algorithm - -``` -resolveProvider(opts, meta, config): - enabled = config.agentProviders?.enabled - ?? ALL_IMPLEMENTED_IN_CODE_ORDER - candidates = enabled ∩ liveAvailable(PATH) - if opts.provider: - if opts.provider ∉ enabled → throw (disabled in config) - if opts.provider ∉ liveAvailable → throw (not installed) - return opts.provider - if meta.defaultProvider: - same checks against candidates - return meta.defaultProvider - if candidates[0] → return candidates[0] - throw NoProviderError +```text +call model/effort override + → profile model/effort + → provider defaultModel/defaultEffort + → provider-native defaults ``` -Skill: “Pass `provider` when you care; else first enabled+available provider.” +Availability snapshots still must not be persisted inside this policy. The +onboarding, config commands, documentation, and provider-management UI should +land together rather than exposing another intermediate configuration shape. --- ## 4. Entry surfaces @@ -215,7 +145,7 @@ devspace workflow __worker # hidden Spawn: same pattern as `agents __worker` (detached, stdio ignore, unref). Inputs only from run row. -### 4.2 MCP (togglable with `config.subagents`) +### 4.2 MCP (togglable with the workflow capability) | Tool | Input | Output (conceptual) | |---|---|---| @@ -230,7 +160,7 @@ Tool description embeds ~25-line API cheat-sheet (CC-style education in-band). ### 4.3 Skill -`skills/dynamic-workflows/SKILL.md` (+ seed on init): +`skills/dynamic-workflows/SKILL.md` (package-managed; not copied on init): - When to use CLI vs when host is ChatGPT (MCP). - Full primitive reference. diff --git a/docs/gotchas.md b/docs/gotchas.md index 779e37ef0..7c90748fd 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -201,18 +201,24 @@ DevSpace looks in standard Agent Skills locations: It also checks compatibility and custom paths: -- the bundled `subagent-delegation` skill when `DEVSPACE_SUBAGENTS=1`, unless `~/.devspace/skills/subagent-delegation/SKILL.md` exists +- the package-managed `subagents` skill when `DEVSPACE_SUBAGENTS=1` +- the package-managed `dynamic-workflows` skill when Dynamic Workflows are enabled - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - additional paths from `DEVSPACE_SKILL_PATHS` When `DEVSPACE_SUBAGENTS=1`, DevSpace loads agent profiles from `~/.devspace/agents/*.md` and project `.devspace/agents/*.md`, then exposes a compact profile catalog through `open_workspace`. The bundled -`subagent-delegation` skill keeps the model-facing workflow to +`subagents` skill keeps the model-facing workflow to `devspace agents ls`, `devspace agents run`, and `devspace agents show`. `devspace agents ls` lists existing subagent sessions, not profile definitions. +Bundled skills remain package-managed and are not copied into +`~/.devspace/skills`. A user-owned skill with the same name intentionally +overrides the bundled copy. The legacy `subagent-delegation` name is no longer +advertised. + Packaged agent profile examples under `examples/agents/` are starter templates. Copy or adapt them into one of the active profile directories before use. From 691998e132fca61aa027f2bdccb27bf8e22936a7 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:05:45 +0000 Subject: [PATCH 066/132] test(workflow): follow provider availability rename --- src/workflow-engine.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/workflow-engine.test.ts b/src/workflow-engine.test.ts index 368494769..fc4bf57db 100644 --- a/src/workflow-engine.test.ts +++ b/src/workflow-engine.test.ts @@ -526,7 +526,7 @@ import type { LocalAgentProfile } from "./local-agent-profiles.js"; concurrency: 1, signal: new AbortController().signal, workspaceRoot: dir, - enabledProviders: ["codex"], + availableProviders: ["codex"], runProvider: async () => ({ finalResponse: JSON.stringify({ big }), structured: { big }, From 5bf53740052c32228f4ec35900f0dba25ab94580 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:04:49 +0000 Subject: [PATCH 067/132] docs(config): describe effective agent capability gates --- docs/chatgpt-coding-workflow.md | 11 ++++++----- docs/configuration.md | 6 +++--- docs/gotchas.md | 6 +++--- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 9c3994f6b..b75fed0b8 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -87,8 +87,8 @@ DevSpace discovers standard Agent Skills from: It also includes: -- the package-managed `subagents` skill when `DEVSPACE_SUBAGENTS=1` -- the package-managed `dynamic-workflows` skill when Dynamic Workflows are enabled +- the package-managed `subagents` skill when the Subagents capability is enabled +- the package-managed `dynamic-workflows` skill when the Dynamic Workflows capability is enabled - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - additional paths from `DEVSPACE_SKILL_PATHS` @@ -120,9 +120,10 @@ workflow. The catalog comes from `open_workspace`; `devspace agents ls` lists existing subagent sessions for that workspace. Set `DEVSPACE_WORKFLOWS=1` to enable Dynamic Workflows independently. When the -variable is omitted it follows `DEVSPACE_SUBAGENTS` for compatibility. Disabled -features are omitted from the `open_workspace` schema and response rather than -returned as empty capability arrays. +variable is omitted, Dynamic Workflows follows the effective Subagents setting, +including persisted config and any environment override. Disabled features are +omitted from the `open_workspace` schema and response rather than returned as +empty capability arrays. ## Tool Names diff --git a/docs/configuration.md b/docs/configuration.md index abcef947d..2d381d954 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -93,7 +93,7 @@ sessions. | --- | --- | | `DEVSPACE_SKILLS` | Set to `0` to hide skills. Enabled by default. | | `DEVSPACE_SUBAGENTS` | Set to `1` to expose configured agent profiles as Subagents. Experimental and disabled by default. | -| `DEVSPACE_WORKFLOWS` | Experimental Dynamic Workflows gate. When unset, it follows `DEVSPACE_SUBAGENTS` for compatibility. | +| `DEVSPACE_WORKFLOWS` | Experimental Dynamic Workflows gate. When unset, it follows the effective Subagents setting, including persisted config and any environment override. | | `DEVSPACE_AGENT_DIR` | Defaults to `~/.codex`; its `skills` child is loaded for compatibility. | | `DEVSPACE_SKILL_PATHS` | Optional comma-separated additional skill directories. | @@ -105,8 +105,8 @@ DevSpace discovers standard Agent Skills from: It also includes: -- the package-managed `subagents` skill when `DEVSPACE_SUBAGENTS=1` -- the package-managed `dynamic-workflows` skill when Dynamic Workflows are enabled +- the package-managed `subagents` skill when the Subagents capability is enabled +- the package-managed `dynamic-workflows` skill when the Dynamic Workflows capability is enabled - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - additional paths from `DEVSPACE_SKILL_PATHS` diff --git a/docs/gotchas.md b/docs/gotchas.md index 7c90748fd..d57053ec9 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -201,12 +201,12 @@ DevSpace looks in standard Agent Skills locations: It also checks compatibility and custom paths: -- the package-managed `subagents` skill when `DEVSPACE_SUBAGENTS=1` -- the package-managed `dynamic-workflows` skill when Dynamic Workflows are enabled +- the package-managed `subagents` skill when the Subagents capability is enabled +- the package-managed `dynamic-workflows` skill when the Dynamic Workflows capability is enabled - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - additional paths from `DEVSPACE_SKILL_PATHS` -When `DEVSPACE_SUBAGENTS=1`, DevSpace loads agent profiles from +When the Subagents capability is enabled, DevSpace loads agent profiles from `~/.devspace/agents/*.md` and project `.devspace/agents/*.md`, then exposes a compact profile catalog through `open_workspace`. The bundled `subagents` skill keeps the model-facing workflow to From 562e54a3436adfb653307d921fb8dbd33e19a9a3 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:07:19 +0000 Subject: [PATCH 068/132] feat(agents): discover usable delegation targets --- docs/chatgpt-coding-workflow.md | 7 +++---- docs/configuration.md | 5 +++-- docs/gotchas.md | 7 +++---- skills/subagents/SKILL.md | 20 ++++++++++++++------ src/cli.test.ts | 24 ++++++++++++++++++++++++ src/cli.ts | 28 ++++++++++++++++++++++++++++ src/local-agent-catalog.test.ts | 7 ++++++- src/local-agent-catalog.ts | 20 ++++++++++++++++++++ 8 files changed, 101 insertions(+), 17 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index b75fed0b8..ad591163c 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -114,10 +114,9 @@ Skill paths may be outside the workspace. DevSpace only permits reading: Set `DEVSPACE_SKILLS=0` to hide skills from workspace output. Set `DEVSPACE_SUBAGENTS=1` to expose the experimental subagent catalog and -`subagents` skill. That skill teaches the minimal -`devspace agents ls`, `devspace agents run`, and `devspace agents show` -workflow. The catalog comes from `open_workspace`; `devspace agents ls` lists -existing subagent sessions for that workspace. +`subagents` skill. That skill can use target information already supplied by the +host or discover it with `devspace agents targets`. `devspace agents ls` lists +existing subagent sessions for the current workspace. Set `DEVSPACE_WORKFLOWS=1` to enable Dynamic Workflows independently. When the variable is omitted, Dynamic Workflows follows the effective Subagents setting, diff --git a/docs/configuration.md b/docs/configuration.md index 2d381d954..a7b20b8d1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -124,8 +124,9 @@ descriptions, providers, and optional models/effort levels so the host model can agent without reading provider-specific launch details. `devspace agents ls` lists existing subagent sessions for the current workspace, scoped by the workspace environment injected into shell commands. The `subagents` -skill teaches the model to use only the minimal `devspace agents ls`, -`devspace agents run`, and `devspace agents show` workflow. +skill teaches the model to discover targets with `devspace agents targets`, +then use the minimal `devspace agents run`, `devspace agents show`, and +`devspace agents ls` workflow. Provider availability is detected at runtime. DevSpace does not persist probe timestamps, availability snapshots, or an experimental provider enable-list in diff --git a/docs/gotchas.md b/docs/gotchas.md index d57053ec9..18ec19129 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -209,10 +209,9 @@ It also checks compatibility and custom paths: When the Subagents capability is enabled, DevSpace loads agent profiles from `~/.devspace/agents/*.md` and project `.devspace/agents/*.md`, then exposes a compact profile catalog through `open_workspace`. The bundled -`subagents` skill keeps the model-facing workflow to -`devspace agents ls`, `devspace agents run`, and `devspace agents show`. -`devspace agents ls` lists existing subagent sessions, not profile -definitions. +`subagents` skill can also discover the same usable targets through +`devspace agents targets` in CLI-only hosts. `devspace agents ls` lists existing +subagent sessions, not profile definitions. Bundled skills remain package-managed and are not copied into `~/.devspace/skills`. A user-owned skill with the same name intentionally diff --git a/skills/subagents/SKILL.md b/skills/subagents/SKILL.md index 93dabac33..caa4faffe 100644 --- a/skills/subagents/SKILL.md +++ b/skills/subagents/SKILL.md @@ -7,9 +7,15 @@ Each subagent is headless, has its own context window, cannot see the parent con ## Choose a target -Prefer a matching named profile from `open_workspace`. Use a raw provider when -the user names that harness or no profile fits. Choose only profiles and -providers returned by `open_workspace`. +Prefer a configured profile that matches the task. Use a raw provider when the +user explicitly names that harness or no profile fits. Use target information +already available in the current host. When the choices are not known, run: + +```bash +devspace agents targets +``` + +Do not guess profile names or provider identifiers. ## Write the brief @@ -20,15 +26,17 @@ repeat project instructions that the child can discover from the repository. ## Run and continue ```bash +devspace agents targets [--json] devspace agents run "" devspace agents show devspace agents run "" devspace agents ls ``` -`run` with a profile or provider starts a child and returns its id. `show` -reads its latest status and response. `run` with an existing id continues the -same child session. `ls` lists sessions for the current project. +`targets` lists currently usable profiles and providers. `run` with a profile +or provider starts a child and returns its id. `show` reads its latest status +and response. `run` with an existing id continues the same child session. `ls` +lists sessions for the current project. Do not invoke provider CLIs directly; use `devspace agents` so DevSpace keeps session and provider handling consistent. diff --git a/src/cli.test.ts b/src/cli.test.ts index 95048e645..38ab51f7c 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -84,6 +84,30 @@ try { assert.doesNotMatch(output, /profile reviewer/); assert.doesNotMatch(output, new RegExp(other.id)); + const targets = JSON.parse(execFileSync( + "node", + ["--import", "tsx", "src/cli.ts", "agents", "targets", "--json"], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + DEVSPACE_CONFIG_DIR: configDir, + DEVSPACE_ALLOWED_ROOTS: projectRoot, + DEVSPACE_STATE_DIR: stateDir, + DEVSPACE_WORKSPACE_ROOT: projectRoot, + DEVSPACE_SUBAGENTS: "1", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + }, + }, + )) as { + profiles: Array<{ name: string; provider: string }>; + providers: Array<{ name: string }>; + }; + assert.deepEqual(targets.profiles.map((profile) => profile.name), ["reviewer"]); + assert.equal(targets.profiles[0]?.provider, "codex"); + assert.equal(targets.providers.some((provider) => provider.name === "codex"), true); + assert.equal(loadConfig({ DEVSPACE_CONFIG_DIR: configDir, DEVSPACE_ALLOWED_ROOTS: projectRoot, diff --git a/src/cli.ts b/src/cli.ts index 3f90e8e96..81bda28b5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,6 +12,10 @@ import { getShellConfig } from "@earendil-works/pi-coding-agent"; import { satisfies } from "semver"; import { loadConfig } from "./config.js"; import { runLocalAgentProvider } from "./local-agent-adapters.js"; +import { + buildLocalAgentCatalog, + formatLocalAgentCatalog, +} from "./local-agent-catalog.js"; import { isLocalAgentProvider, loadLocalAgentProfiles, @@ -354,6 +358,9 @@ async function runAgentsCommand(args: string[]): Promise { case "list": await runAgentsList(); return; + case "targets": + await runAgentsTargets(rest); + return; case "run": await runAgentsRun(rest); return; @@ -389,6 +396,26 @@ async function runAgentsList(): Promise { } } +async function runAgentsTargets(args: string[]): Promise { + const unknownArgs = args.filter((arg) => arg !== "--json"); + if (unknownArgs.length > 0) { + throw new Error("Usage: devspace agents targets [--json]"); + } + + const config = loadConfig(); + const workspaceRoot = resolveCurrentWorkspaceRoot(); + const profiles = await loadLocalAgentProfiles(config, workspaceRoot); + const catalog = buildLocalAgentCatalog( + profiles, + getLocalAgentProviderAvailabilitySnapshot(), + ); + console.log( + args.includes("--json") + ? JSON.stringify(catalog, null, 2) + : formatLocalAgentCatalog(catalog), + ); +} + async function runAgentsRun(args: string[]): Promise { const parsed = parseLocalAgentRunArgs(args); @@ -580,6 +607,7 @@ function printAgentsHelp(): void { "", "Usage:", " devspace agents ls", + " devspace agents targets [--json]", " devspace agents run [--model ] [--effort ] ", " devspace agents show ", ].join("\n"), diff --git a/src/local-agent-catalog.test.ts b/src/local-agent-catalog.test.ts index a8dc4c01c..ad4bd4ad7 100644 --- a/src/local-agent-catalog.test.ts +++ b/src/local-agent-catalog.test.ts @@ -1,5 +1,8 @@ import assert from "node:assert/strict"; -import { buildLocalAgentCatalog } from "./local-agent-catalog.js"; +import { + buildLocalAgentCatalog, + formatLocalAgentCatalog, +} from "./local-agent-catalog.js"; import type { LocalAgentProfile } from "./local-agent-profiles.js"; const profiles: LocalAgentProfile[] = [ @@ -29,5 +32,7 @@ const catalog = buildLocalAgentCatalog(profiles, [ assert.deepEqual(catalog.providers.map((provider) => provider.name), ["codex"]); assert.deepEqual(catalog.profiles.map((profile) => profile.name), ["reviewer"]); assert.equal(catalog.providers[0]?.effort.semantics, "reasoning_effort"); +assert.match(formatLocalAgentCatalog(catalog), /reviewer \(codex\) — Review changes\./); +assert.match(formatLocalAgentCatalog(catalog), /Providers:\n codex/); console.log("local-agent-catalog.test.ts: ok"); diff --git a/src/local-agent-catalog.ts b/src/local-agent-catalog.ts index 8a7486cff..da8775f2d 100644 --- a/src/local-agent-catalog.ts +++ b/src/local-agent-catalog.ts @@ -16,6 +16,26 @@ export interface LocalAgentCatalog { profiles: ReturnType[]; } +export function formatLocalAgentCatalog(catalog: LocalAgentCatalog): string { + const profileLines = catalog.profiles.length > 0 + ? [ + "Profiles:", + ...catalog.profiles.map((profile) => { + const details = [ + profile.provider, + profile.model ? `model=${profile.model}` : undefined, + profile.effort ? `effort=${profile.effort}` : undefined, + ].filter(Boolean).join(", "); + return ` ${profile.name} (${details}) — ${profile.description}`; + }), + ] + : ["Profiles: none"]; + const providerLines = catalog.providers.length > 0 + ? ["Providers:", ...catalog.providers.map((provider) => ` ${provider.name}`)] + : ["Providers: none"]; + return [...profileLines, "", ...providerLines].join("\n"); +} + /** Build the compact model-facing catalog from currently usable providers. */ export function buildLocalAgentCatalog( profiles: LocalAgentProfile[], From cc09a097dc28f2bc81348aee8b5c76e598e93b8b Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 27 Jul 2026 23:05:09 +0530 Subject: [PATCH 069/132] refactor(workflow): house WorkflowEngineError in workflow-errors Move the engine domain error class next to other workflow failures and add result_too_large for upcoming oversized-return policy. Re-export from workflow-api for existing imports. --- src/workflow-api.ts | 22 +++------------------- src/workflow-errors.ts | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/workflow-api.ts b/src/workflow-api.ts index cd0d60da0..3a2394c0d 100644 --- a/src/workflow-api.ts +++ b/src/workflow-api.ts @@ -24,6 +24,9 @@ import { 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) @@ -182,25 +185,6 @@ export interface WorkflowApi extends WorkflowSandboxApi { getNestDepth(): number; } -export class WorkflowEngineError extends Error { - constructor( - readonly kind: - | "cancelled" - | "provider_unavailable" - | "no_provider" - | "profile" - | "nest_depth" - | "worktree" - | "schema" - | "path" - | "internal", - message: string, - ) { - super(message); - this.name = "WorkflowEngineError"; - } -} - // --------------------------------------------------------------------------- // Semaphore // --------------------------------------------------------------------------- diff --git a/src/workflow-errors.ts b/src/workflow-errors.ts index 19e18b2c8..26eca02e9 100644 --- a/src/workflow-errors.ts +++ b/src/workflow-errors.ts @@ -10,6 +10,27 @@ import type { 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" + | "worktree" + | "schema" + | "path" + | "result_too_large" + | "internal", + message: string, + ) { + super(message); + this.name = "WorkflowEngineError"; + } +} + export class InvalidWorkflowInputError extends TaggedError( "InvalidWorkflowInputError", )<{ From 070fbd66dcfa6e8b96b49bf00ca353b60435c616 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 27 Jul 2026 23:06:27 +0530 Subject: [PATCH 070/132] fix(workflow): fail agent calls when return value exceeds replay budget Completed calls must remain resume-safe. Oversized return values and structured JSON now throw result_too_large instead of silently dropping persisted replay data. --- src/workflow-api.ts | 40 ++++++++++++++++++++++++------------- src/workflow-engine.test.ts | 35 +++++++++++++++++++------------- src/workflow-engine.ts | 2 +- 3 files changed, 48 insertions(+), 29 deletions(-) diff --git a/src/workflow-api.ts b/src/workflow-api.ts index 3a2394c0d..86474f7fb 100644 --- a/src/workflow-api.ts +++ b/src/workflow-api.ts @@ -450,6 +450,11 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { throwIfCancelled(deps); + const returnValueJson = serializeReplayValueOrThrow(returnValue); + if (structuredJson !== undefined) { + assertStructuredJsonBudget(structuredJson); + } + let dirty: boolean | undefined; if (worktree) { const finalized = await worktree.finalize("success"); @@ -473,8 +478,8 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { runId: deps.runId, callIndex: index, responseText: truncate(result.finalResponse, WORKFLOW_LIMITS.responseTextBytes), - structuredJson: boundedStructuredJson(structuredJson), - returnValueJson: serializeReplayValue(returnValue), + structuredJson, + returnValueJson, providerSessionId: result.providerSessionId, dirty, worktreePath, @@ -776,23 +781,30 @@ function truncate(text: string, maxBytes: number): string { return `${text.slice(0, end)}${marker}`; } -function boundedStructuredJson(value: string | undefined): string | undefined { - if (value === undefined) return undefined; - return Buffer.byteLength(value, "utf8") <= WORKFLOW_LIMITS.structuredJsonBytes - ? value - : undefined; +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 serializeReplayValue(value: unknown): string | undefined { +function serializeReplayValueOrThrow(value: unknown): string | undefined { + let json: string | undefined; try { - const json = JSON.stringify(value); - if (json === undefined) return undefined; - return Buffer.byteLength(json, "utf8") <= WORKFLOW_LIMITS.replayValueJsonBytes - ? json - : undefined; + json = JSON.stringify(value); } catch { - return undefined; + 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. */ diff --git a/src/workflow-engine.test.ts b/src/workflow-engine.test.ts index fc4bf57db..7378b0465 100644 --- a/src/workflow-engine.test.ts +++ b/src/workflow-engine.test.ts @@ -473,7 +473,7 @@ import type { LocalAgentProfile } from "./local-agent-profiles.js"; } // --------------------------------------------------------------------------- -// oversized exact replay values do not fail the live call +// oversized exact replay values fail the live call (completed ⇒ replayable) // --------------------------------------------------------------------------- { const dir = await mkdtemp(join(tmpdir(), "wf-replay-size-")); @@ -498,14 +498,19 @@ import type { LocalAgentProfile } from "./local-agent-profiles.js"; runProvider: async () => ({ finalResponse: response }), }); - assert.equal(await api.agent("large"), response); - assert.equal(store.getAgentCall(run.id, 0)?.returnValueJson, undefined); + 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 remain intact without persisting invalid JSON +// oversized structured results fail the live call // --------------------------------------------------------------------------- { const dir = await mkdtemp(join(tmpdir(), "wf-structured-size-")); @@ -533,17 +538,19 @@ import type { LocalAgentProfile } from "./local-agent-profiles.js"; }), }); - assert.deepEqual( - await api.agent("large structured", { - schema: { - type: "object", - properties: { big: { type: "string" } }, - required: ["big"], - }, - }), - { big }, + await assert.rejects( + () => + api.agent("large structured", { + schema: { + type: "object", + properties: { big: { type: "string" } }, + required: ["big"], + }, + }), + (error: unknown) => + error instanceof WorkflowEngineError && error.kind === "result_too_large", ); - assert.equal(store.getAgentCall(run.id, 0)?.structuredJson, undefined); + assert.equal(store.getAgentCall(run.id, 0)?.status, "failed"); store.close(); await rm(dir, { recursive: true, force: true }); } diff --git a/src/workflow-engine.ts b/src/workflow-engine.ts index 72081b538..5fc08dac9 100644 --- a/src/workflow-engine.ts +++ b/src/workflow-engine.ts @@ -151,7 +151,7 @@ export async function executeWorkflow( export function mapEngineErrorKind(error: unknown): WorkflowErrorKind { if (error instanceof WorkflowEngineError) { - return error.kind; + return error.kind as WorkflowErrorKind; } if (isWorkflowOperationError(error)) { return workflowErrorKind(error); From 0c8aceff4865726335166f615dfe9bc696d9f7cc Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 27 Jul 2026 23:07:39 +0530 Subject: [PATCH 071/132] refactor(workflow): drop path throw shims; Result-only file APIs Remove WorkflowPathError wrappers and dual throw helpers so CLI/worker callers handle typed TaggedErrors directly. Delete unused contentHash and dirname helpers. --- src/workflow-cli.ts | 21 ++++--- src/workflow-files.test.ts | 121 ++++++++++++++++++++++--------------- src/workflow-files.ts | 85 +------------------------- 3 files changed, 85 insertions(+), 142 deletions(-) diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts index bfd003679..be4f667f9 100644 --- a/src/workflow-cli.ts +++ b/src/workflow-cli.ts @@ -17,9 +17,9 @@ import { executeWorkflow, mapEngineErrorKind } from "./workflow-engine.js"; import { parseWorkflowArgFlagsResult, persistWorkflowScriptResult, - readProjectWorkflowScriptFile, + readProjectWorkflowScriptFileResult, readWorkflowScriptFileResult, - resolveNamedWorkflowScript, + resolveNamedWorkflowScriptResult, resolveWorkflowScriptFromPathOrNameResult, } from "./workflow-files.js"; import { createWorkflowReplay } from "./workflow-replay.js"; @@ -415,19 +415,20 @@ export async function runWorkflowWorker( }, resolveNestedSource: async (ref) => { if (typeof ref === "string") { - const named = await resolveNamedWorkflowScript({ + const named = await resolveNamedWorkflowScriptResult({ name: ref, workspaceRoot: claimed.workspaceRoot, stateDir: config.stateDir, }); - return named.source; + if (named.isErr()) throw named.error; + return named.value.source; } - return ( - await readProjectWorkflowScriptFile({ - scriptPath: ref.scriptPath, - workspaceRoot: claimed.workspaceRoot, - }) - ).source; + const nested = await readProjectWorkflowScriptFileResult({ + scriptPath: ref.scriptPath, + workspaceRoot: claimed.workspaceRoot, + }); + if (nested.isErr()) throw nested.error; + return nested.value.source; }, }); diff --git a/src/workflow-files.test.ts b/src/workflow-files.test.ts index 4cd1ecd79..37f445e5b 100644 --- a/src/workflow-files.test.ts +++ b/src/workflow-files.test.ts @@ -3,17 +3,20 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { - parseWorkflowArgFlags, - persistWorkflowScript, - readProjectWorkflowScriptFile, - resolveNamedWorkflowScript, - resolveWorkflowScriptFromPathOrName, - WorkflowPathError, + parseWorkflowArgFlagsResult, + persistWorkflowScriptResult, + readProjectWorkflowScriptFileResult, + resolveNamedWorkflowScriptResult, + resolveWorkflowScriptFromPathOrNameResult, } from "./workflow-files.js"; +import { + InvalidWorkflowInputError, + NamedWorkflowNotFoundError, +} from "./workflow-errors.js"; import { hashSource } from "./workflow-script.js"; { - const { args, rest } = parseWorkflowArgFlags([ + const parsed = parseWorkflowArgFlagsResult([ "--arg", "n=1", "--arg", @@ -21,55 +24,66 @@ import { hashSource } from "./workflow-script.js"; "--follow", "extra", ]); - assert.deepEqual(args, { n: 1, files: ["a.ts"] }); - assert.deepEqual(rest, ["--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 path = await persistWorkflowScript({ + 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 resolveWorkflowScriptFromPathOrName({ + const file = await resolveWorkflowScriptFromPathOrNameResult({ file: path, workspaceRoot: dir, }); - assert.equal(file.origin, "file"); - assert.equal(file.scriptHash, hashSource(file.source)); + 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 resolveNamedWorkflowScript({ + const named = await resolveNamedWorkflowScriptResult({ name: "named", workspaceRoot: dir, }); - assert.equal(named.origin, "named"); - assert.match(named.source, /named/); - assert.equal( - ( - await readProjectWorkflowScriptFile({ - scriptPath: join(dir, ".devspace", "workflows", "named.js"), - workspaceRoot: dir, - }) - ).nameHint, - "named", - ); - await assert.rejects( - () => - readProjectWorkflowScriptFile({ - scriptPath: path, - workspaceRoot: dir, - }), - /must be inside/, - ); + 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-")); @@ -83,14 +97,15 @@ import { hashSource } from "./workflow-script.js"; outsideScript, join(dir, ".devspace", "workflows", "escape.js"), ); - await assert.rejects( - () => - readProjectWorkflowScriptFile({ - scriptPath: "escape.js", - workspaceRoot: dir, - }), - /resolves outside/, - ); + 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 }); } @@ -101,15 +116,23 @@ import { hashSource } from "./workflow-script.js"; join(dir, "workflows", "legacy.js"), "export const meta = { name: 'legacy', description: 'd' }\nreturn 3\n", ); - await assert.rejects( - () => resolveNamedWorkflowScript({ name: "legacy", workspaceRoot: dir }), - WorkflowPathError, - ); + const legacy = await resolveNamedWorkflowScriptResult({ + name: "legacy", + workspaceRoot: dir, + }); + assert.equal(legacy.isErr(), true); + if (legacy.isErr()) { + assert.equal(NamedWorkflowNotFoundError.is(legacy.error), true); + } - await assert.rejects( - () => resolveNamedWorkflowScript({ name: "missing", workspaceRoot: dir }), - WorkflowPathError, - ); + 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 }); } diff --git a/src/workflow-files.ts b/src/workflow-files.ts index 5aae5fe56..90f3016d3 100644 --- a/src/workflow-files.ts +++ b/src/workflow-files.ts @@ -1,6 +1,6 @@ -import { createHash, randomBytes } from "node:crypto"; +import { randomBytes } from "node:crypto"; import { mkdir, readFile, realpath, writeFile } from "node:fs/promises"; -import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path"; +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"; @@ -13,13 +13,6 @@ import { } from "./workflow-errors.js"; import { isPathInsideRoot } from "./roots.js"; -export class WorkflowPathError extends Error { - constructor(message: string) { - super(message); - this.name = "WorkflowPathError"; - } -} - export interface ResolvedWorkflowScript { source: string; scriptPath: string; @@ -38,17 +31,6 @@ export type WorkflowFileResolveError = * Persist script under stateDir for worker re-read / audit. * Returns absolute path written. */ -export async function persistWorkflowScript(input: { - stateDir: string; - runId: string; - source: string; - preferredName?: string; -}): Promise { - const result = await persistWorkflowScriptResult(input); - if (result.isErr()) throw result.error; - return result.value; -} - export async function persistWorkflowScriptResult(input: { stateDir: string; runId: string; @@ -70,12 +52,6 @@ export async function persistWorkflowScriptResult(input: { }); } -export async function readWorkflowScriptFile(path: string): Promise { - const result = await readWorkflowScriptFileResult(path); - if (result.isErr()) throwPathCompatibilityError(result.error); - return result.value; -} - export async function readWorkflowScriptFileResult( path: string, ): Promise> { @@ -98,15 +74,6 @@ export async function readWorkflowScriptFileResult( }); } -export async function readProjectWorkflowScriptFile(input: { - scriptPath: string; - workspaceRoot: string; -}): Promise { - const result = await readProjectWorkflowScriptFileResult(input); - if (result.isErr()) throwPathCompatibilityError(result.error); - return result.value; -} - /** Resolve an explicit nested script only inside `/.devspace/workflows`. */ export async function readProjectWorkflowScriptFileResult(input: { scriptPath: string; @@ -158,16 +125,6 @@ export async function readProjectWorkflowScriptFileResult(input: { * 1. `/.devspace/workflows/.js` * 2. `/workflows/.js` (if stateDir provided) */ -export async function resolveNamedWorkflowScript(input: { - name: string; - workspaceRoot: string; - stateDir?: string; -}): Promise { - const result = await resolveNamedWorkflowScriptResult(input); - if (result.isErr()) throwPathCompatibilityError(result.error); - return result.value; -} - export async function resolveNamedWorkflowScriptResult(input: { name: string; workspaceRoot: string; @@ -199,17 +156,6 @@ export async function resolveNamedWorkflowScriptResult(input: { return Result.err(new NamedWorkflowNotFoundError(name, candidates)); } -export async function resolveWorkflowScriptFromPathOrName(input: { - file?: string; - name?: string; - workspaceRoot: string; - stateDir?: string; -}): Promise { - const result = await resolveWorkflowScriptFromPathOrNameResult(input); - if (result.isErr()) throwPathCompatibilityError(result.error); - return result.value; -} - export async function resolveWorkflowScriptFromPathOrNameResult(input: { file?: string; name?: string; @@ -245,15 +191,6 @@ export async function resolveWorkflowScriptFromPathOrNameResult(input: { ); } -export function parseWorkflowArgFlags(tokens: string[]): { - args: Record; - rest: string[]; -} { - const result = parseWorkflowArgFlagsResult(tokens); - if (result.isErr()) throwPathCompatibilityError(result.error); - return result.value; -} - export function parseWorkflowArgFlagsResult( tokens: string[], ): BetterResult< @@ -314,18 +251,6 @@ function sanitizeSegment(value: string): string { .slice(0, 80); } -export function workflowScriptDirForRun(stateDir: string, runId: string): string { - return join(stateDir, "workflow-scripts", runId); -} - -export function contentHash(source: string): string { - return createHash("sha256").update(source).digest("hex"); -} - -export function dirnameOf(path: string): string { - return dirname(path); -} - function isFileNotFound(error: unknown): boolean { return Boolean( error && @@ -334,9 +259,3 @@ function isFileNotFound(error: unknown): boolean { (error as { code?: unknown }).code === "ENOENT", ); } - -function throwPathCompatibilityError(error: Error): never { - const compatible = new WorkflowPathError(error.message); - compatible.cause = error; - throw compatible; -} From 8cffabe538a27d65a4f23d0fd619f65977884227 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 27 Jul 2026 23:10:56 +0530 Subject: [PATCH 072/132] fix(workflow): track phase in sandbox child via AsyncLocalStorage Host ALS on IPC handlers does not follow concurrent script chains. Inject phase ALS into the child vm, stamp agent opts.phase and log payloads across the bridge, and cover concurrent phase via sandbox e2e. --- src/workflow-api.ts | 22 +++++++++++-- src/workflow-sandbox-child.ts | 57 ++++++++++++++++++++++++++------ src/workflow-sandbox.test.ts | 62 +++++++++++++++++++++++++++++++++-- 3 files changed, 126 insertions(+), 15 deletions(-) diff --git a/src/workflow-api.ts b/src/workflow-api.ts index 86474f7fb..071b0d59f 100644 --- a/src/workflow-api.ts +++ b/src/workflow-api.ts @@ -612,6 +612,8 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { 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, @@ -622,11 +624,27 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { }; const log = (...args: unknown[]): void => { - const message = args.map(String).join(" "); + 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: phaseAls.getStore(), + phase: phaseTitle, data: { message: truncate(message, WORKFLOW_LIMITS.eventDataJsonBytes) }, }); }; diff --git a/src/workflow-sandbox-child.ts b/src/workflow-sandbox-child.ts index f26c4de1d..ffe90e2b2 100644 --- a/src/workflow-sandbox-child.ts +++ b/src/workflow-sandbox-child.ts @@ -1,8 +1,12 @@ +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 { @@ -79,7 +83,17 @@ async function execute(message: StartMessage): Promise { }); }; - const context = vm.createContext({ __workflowBridge: bridge }); + 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, @@ -160,6 +174,8 @@ function installContextApi(context: vm.Context, message: StartMessage): void { 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) => { @@ -176,15 +192,37 @@ function installContextApi(context: vm.Context, message: StartMessage): void { () => reject(new WorkflowEngineError("internal", "Workflow bridge call failed")), ); }); - const agent = (...callArgs) => call("agent", callArgs); + // 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 log = (...callArgs) => bridge("log", callArgs); + 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"); @@ -244,20 +282,19 @@ function installContextApi(context: vm.Context, message: StartMessage): void { if (typeof value === "string") return value; try { return JSON.stringify(value); } catch { return String(value); } }; - const consoleLine = (...callArgs) => log(callArgs.map(stringifyConsoleArg).join(" ")); const console = Object.freeze({ - log: consoleLine, - warn: consoleLine, - error: consoleLine, - info: consoleLine, - debug: consoleLine, + 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(log), 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 }, diff --git a/src/workflow-sandbox.test.ts b/src/workflow-sandbox.test.ts index 35f7de273..e33dd618e 100644 --- a/src/workflow-sandbox.test.ts +++ b/src/workflow-sandbox.test.ts @@ -8,13 +8,26 @@ import { import type { WorkflowSandboxApi } from "./workflow-sandbox.js"; import { runWorkflowSandbox, WorkflowDeterminismError } from "./workflow-sandbox.js"; -function api(meta: WorkflowMeta, logs?: string[]): WorkflowSandboxApi { +function api( + meta: WorkflowMeta, + logs?: string[], + hooks?: { + agent?: WorkflowSandboxApi["agent"]; + phaseTitles?: string[]; + }, +): WorkflowSandboxApi { return { - agent: async () => "", + agent: hooks?.agent ?? (async () => ""), parallel: async () => [], pipeline: async () => [], - phase: () => {}, + 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, @@ -226,4 +239,47 @@ return agent.constructor('return process.version')() ); } +// 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"); From 94e198f323e79190b727b47b17923d23659140b8 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 27 Jul 2026 23:16:36 +0530 Subject: [PATCH 073/132] refactor(workflow): extract shared launch and worker modules CLI and MCP both start runs through launchWorkflowRun. Detached worker spawn and execution live in workflow-worker; live provider ordering is shared via workflow-providers. Adds launch unit coverage without spawn. --- package.json | 2 +- src/workflow-cli.ts | 376 +++++++++--------------------------- src/workflow-engine.test.ts | 12 +- src/workflow-launch.test.ts | 66 +++++++ src/workflow-launch.ts | 276 ++++++++++++++++++++++++++ src/workflow-providers.ts | 12 ++ src/workflow-tools.ts | 170 +++++----------- src/workflow-worker.ts | 191 ++++++++++++++++++ 8 files changed, 692 insertions(+), 413 deletions(-) create mode 100644 src/workflow-launch.test.ts create mode 100644 src/workflow-launch.ts create mode 100644 src/workflow-providers.ts create mode 100644 src/workflow-worker.ts diff --git a/package.json b/package.json index f81a50bad..6e1aad7a2 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/open-workspace-capabilities.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-capabilities.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-resolution.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-lifecycle.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-ui.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", + "test": "tsx src/config.test.ts && tsx src/open-workspace-capabilities.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-capabilities.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-resolution.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-lifecycle.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-ui.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-launch.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts index be4f667f9..6100332ca 100644 --- a/src/workflow-cli.ts +++ b/src/workflow-cli.ts @@ -1,53 +1,34 @@ -import { spawn } from "node:child_process"; -import { readFile } from "node:fs/promises"; -import { availableParallelism } from "node:os"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { ServerConfig } from "./config.js"; -import { parseJsonText, type JsonObject, type JsonValue } from "./json-types.js"; -import { runLocalAgentProviderResult } from "./local-agent-adapters.js"; -import { getLocalAgentProviderAvailabilitySnapshot } from "./local-agent-availability.js"; -import { - isLocalAgentProvider, - loadLocalAgentProfiles, - LOCAL_AGENT_PROVIDERS, - type LocalAgentProvider, -} from "./local-agent-profiles.js"; -import { executeWorkflow, mapEngineErrorKind } from "./workflow-engine.js"; -import { - parseWorkflowArgFlagsResult, - persistWorkflowScriptResult, - readProjectWorkflowScriptFileResult, - readWorkflowScriptFileResult, - resolveNamedWorkflowScriptResult, - resolveWorkflowScriptFromPathOrNameResult, -} from "./workflow-files.js"; -import { createWorkflowReplay } from "./workflow-replay.js"; +import { parseWorkflowArgFlagsResult } from "./workflow-files.js"; import { cancelWorkflowRun, reapStaleWorkflows, } from "./workflow-lifecycle.js"; -import { parseWorkflowScript } from "./workflow-script.js"; import { createWorkflowStore, type WorkflowStore } from "./workflow-store.js"; import { - WORKFLOW_HEARTBEAT_MS, WORKFLOW_LIMITS, - resolveWorkflowConcurrency, type WorkflowEventRecord, type WorkflowAgentCallRecord, type WorkflowRunRecord, - type WorkflowRunSource, } from "./workflow-types.js"; import { parseWorkflowEventPayload } from "./workflow-contracts.js"; import { InvalidWorkflowInputError, WorkflowNotFoundError, - WorkflowStoredDataError, } from "./workflow-errors.js"; import { - createWorkflowWorktreeFactory, - resolveWorkspaceHead, -} from "./workflow-worktrees.js"; + launchWorkflowRun, + type LaunchWorkflowSource, +} from "./workflow-launch.js"; +import { + runWorkflowWorker, + spawnWorkflowWorker, + spawnWorkflowWorkerFromCli, +} from "./workflow-worker.js"; + +export { runWorkflowWorker, spawnWorkflowWorker, spawnWorkflowWorkerFromCli }; export async function runWorkflowCommand( args: string[], @@ -55,9 +36,11 @@ export async function runWorkflowCommand( ): Promise { const [subcommand, ...rest] = args; if (!config.workflows) { - throw new Error( - "Dynamic workflows are disabled. Set DEVSPACE_WORKFLOWS=1 to enable the experimental feature.", - ); + throw new InvalidWorkflowInputError({ + code: "invalid_argument", + message: + "Dynamic workflows are disabled. Set DEVSPACE_WORKFLOWS=1 to enable the experimental feature.", + }); } switch (subcommand) { case "run": @@ -94,7 +77,10 @@ export async function runWorkflowCommand( printWorkflowHelp(); return; default: - throw new Error(`Unknown workflow command: ${subcommand}`); + throw new InvalidWorkflowInputError({ + code: "invalid_argument", + message: `Unknown workflow command: ${subcommand}`, + }); } } @@ -140,107 +126,71 @@ async function runWorkflowRun(args: string[], config: ServerConfig): Promise { const follow = args.includes("--follow"); const runId = args.find((a) => !a.startsWith("-")); - if (!runId) throw new Error("Usage: devspace workflow status [--follow]"); + if (!runId) { + throw new InvalidWorkflowInputError({ + code: "invalid_argument", + message: "Usage: devspace workflow status [--follow]", + }); + } const store = createWorkflowStore(config); try { @@ -264,7 +214,12 @@ async function runWorkflowStatus(args: string[], config: ServerConfig): Promise< async function runWorkflowCancel(args: string[], config: ServerConfig): Promise { const runId = args[0]; - if (!runId) throw new Error("Usage: devspace workflow cancel "); + if (!runId) { + throw new InvalidWorkflowInputError({ + code: "invalid_argument", + message: "Usage: devspace workflow cancel ", + }); + } const store = createWorkflowStore(config); try { reapStaleWorkflows(store); @@ -291,7 +246,12 @@ async function runWorkflowList(config: ServerConfig): Promise { async function runWorkflowCalls(args: string[], config: ServerConfig): Promise { const runId = args[0]; - if (!runId) throw new Error("Usage: devspace workflow calls "); + if (!runId) { + throw new InvalidWorkflowInputError({ + code: "invalid_argument", + message: "Usage: devspace workflow calls ", + }); + } const store = createWorkflowStore(config); try { if (!store.getRun(runId)) throw new WorkflowNotFoundError(runId); @@ -310,181 +270,27 @@ async function runWorkflowCall(args: string[], config: ServerConfig): Promise "); + throw new InvalidWorkflowInputError({ + code: "invalid_argument", + message: "Usage: devspace workflow call ", + }); } const store = createWorkflowStore(config); try { if (!store.getRun(runId)) throw new WorkflowNotFoundError(runId); const call = store.getAgentCall(runId, callIndex); - if (!call) throw new Error(`Unknown workflow agent call: ${runId}#${callIndex}`); - console.log(JSON.stringify(formatCallDetail(call), null, 2)); - } finally { - store.close(); - } -} - -/** 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 = resolveAvailableProviders(); - 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 { 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 providerRun = await runLocalAgentProviderResult(input.provider, { - prompt: input.prompt, - workspace: input.workspace, - providerSessionId: input.providerSessionId, - model: input.model, - effort: input.effort, - writeMode: "allowed", - schema: input.schema, - }); - if (providerRun.isErr()) throw providerRun.error; - const providerResult = providerRun.value; - return { - finalResponse: providerResult.finalResponse, - providerSessionId: providerResult.providerSessionId ?? undefined, - structured: providerResult.structured, - }; - }, - 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 + if (!call) { + throw new InvalidWorkflowInputError({ + code: "invalid_argument", + message: `Unknown workflow agent call: ${runId}#${callIndex}`, + }); } + console.log(JSON.stringify(formatCallDetail(call), null, 2)); } finally { - clearInterval(heartbeat); store.close(); } } -export function spawnWorkflowWorkerFromCli(runId: string, cliEntry: string): void { - const child = spawn( - process.execPath, - [...process.execArgv, cliEntry, "workflow", "__worker", runId], - { - detached: true, - stdio: "ignore", - env: process.env, - }, - ); - child.unref(); -} - async function followRun(store: WorkflowStore, runId: string): Promise { let sinceSeq = 0; for (;;) { @@ -601,12 +407,6 @@ function safeParseJson(text: string): unknown { } } -function resolveAvailableProviders(): LocalAgentProvider[] { - const snapshot = getLocalAgentProviderAvailabilitySnapshot(); - const live = new Set(snapshot.filter((row) => row.available).map((row) => row.name)); - return LOCAL_AGENT_PROVIDERS.filter((id) => live.has(id)); -} - function splitFlags(args: string[]): { flags: Map; positionals: string[]; @@ -661,7 +461,3 @@ function collectArgTokens(args: string[]): string[] { function sleep(ms: number): Promise { return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); } - -function isJsonObject(value: JsonValue): value is JsonObject { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/src/workflow-engine.test.ts b/src/workflow-engine.test.ts index 7378b0465..83091f2b5 100644 --- a/src/workflow-engine.test.ts +++ b/src/workflow-engine.test.ts @@ -538,15 +538,19 @@ import type { LocalAgentProfile } from "./local-agent-profiles.js"; }), }); - await assert.rejects( - () => - api.agent("large structured", { + 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", ); diff --git a/src/workflow-launch.test.ts b/src/workflow-launch.test.ts new file mode 100644 index 000000000..3f40f9192 --- /dev/null +++ b/src/workflow-launch.test.ts @@ -0,0 +1,66 @@ +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 { launchWorkflowRun } from "./workflow-launch.js"; + +{ + const dir = await mkdtemp(join(tmpdir(), "wf-launch-")); + const store = new WorkflowStore(dir); + const launched = await launchWorkflowRun({ + store, + config: { stateDir: dir }, + workspaceRoot: dir, + source: { + kind: "inline", + script: `export const meta = { name: 'launch-demo', description: 'd' }\nreturn 1\n`, + }, + args: { n: 1 }, + cliEntry: "/tmp/devspace-cli-not-used", + spawn: false, + }); + assert.equal(launched.isOk(), true); + if (!launched.isOk()) throw launched.error; + assert.equal(launched.value.run.name, "launch-demo"); + assert.equal(launched.value.run.status, "starting"); + assert.match(launched.value.run.scriptPath.replaceAll("\\", "/"), /workflow-scripts\//); + assert.equal(launched.value.run.argsJson, JSON.stringify({ n: 1 })); + + await mkdir(join(dir, ".devspace", "workflows"), { recursive: true }); + await writeFile( + join(dir, ".devspace", "workflows", "named-wf.js"), + `export const meta = { name: 'named-wf', description: 'd' }\nreturn 2\n`, + ); + const named = await launchWorkflowRun({ + store, + config: { stateDir: dir }, + workspaceRoot: dir, + source: { kind: "named", name: "named-wf" }, + cliEntry: "/tmp/devspace-cli-not-used", + spawn: false, + }); + assert.equal(named.isOk(), true); + if (!named.isOk()) throw named.error; + assert.equal(named.value.source, "named"); + assert.equal(named.value.run.name, "named-wf"); + + const resumed = await launchWorkflowRun({ + store, + config: { stateDir: dir }, + workspaceRoot: dir, + source: { kind: "resume", runId: launched.value.run.id }, + cliEntry: "/tmp/devspace-cli-not-used", + spawn: false, + }); + assert.equal(resumed.isOk(), true); + if (!resumed.isOk()) throw resumed.error; + assert.equal(resumed.value.source, "resume"); + assert.equal(resumed.value.run.resumedFromRunId, launched.value.run.id); + assert.equal(resumed.value.run.argsJson, JSON.stringify({ n: 1 })); + + 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 000000000..23d441441 --- /dev/null +++ b/src/workflow-launch.ts @@ -0,0 +1,276 @@ +import type { ServerConfig } from "./config.js"; +import { parseJsonText, type JsonObject, type JsonValue } from "./json-types.js"; +import { + persistWorkflowScriptResult, + readWorkflowScriptFileResult, + resolveNamedWorkflowScriptResult, + resolveWorkflowScriptFromPathOrNameResult, +} from "./workflow-files.js"; +import { parseWorkflowScript } from "./workflow-script.js"; +import type { WorkflowStore } from "./workflow-store.js"; +import type { WorkflowRunRecord, WorkflowRunSource } from "./workflow-types.js"; +import { + InvalidWorkflowInputError, + WorkflowNotFoundError, + WorkflowStoredDataError, + type WorkflowOperationError, +} 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 { WorkflowScriptError } from "./workflow-script.js"; +import type { WorkflowFileWriteError } from "./workflow-errors.js"; +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; + /** 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 resolved; + + 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), + resumedFromRunId: priorRunId, + baseSha, + }); + + const persisted = await persistWorkflowScriptResult({ + stateDir: input.config.stateDir, + runId: run.id, + source: sourceText, + preferredName, + }); + if (persisted.isErr()) return persisted; + + const updated = input.store.setScriptPathResult(run.id, persisted.value); + if (updated.isErr()) return updated; + + if (input.spawn !== false) { + spawnWorkflowWorker(run.id, input.cliEntry); + } + + 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 priorResult; + const prior = priorResult.value; + if (!prior) 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 named; + 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 readWorkflowScriptFileResult(source.override.path); + if (file.isErr()) return file; + 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 priorScript; + 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 named; + 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 resolveWorkflowScriptFromPathOrNameResult({ + file: source.path, + workspaceRoot, + stateDir: config.stateDir, + }); + if (file.isErr()) return file; + return Result.ok({ + sourceText: file.value.source, + scriptHash: file.value.scriptHash, + nameHint: file.value.nameHint, + runSource: file.value.origin === "named" ? "named" : "inline", + 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 ( + typeof error === "object" && + error !== null && + "name" in error && + (error as { name?: string }).name === "WorkflowScriptError" + ); +} + +export function isJsonObject(value: JsonValue): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/workflow-providers.ts b/src/workflow-providers.ts new file mode 100644 index 000000000..31836fd3d --- /dev/null +++ b/src/workflow-providers.ts @@ -0,0 +1,12 @@ +import { getLocalAgentProviderAvailabilitySnapshot } from "./local-agent-availability.js"; +import { + LOCAL_AGENT_PROVIDERS, + type LocalAgentProvider, +} from "./local-agent-profiles.js"; + +/** Live providers in stable product order for workflow agent() resolution. */ +export function resolveWorkflowLiveProviders(): LocalAgentProvider[] { + const snapshot = getLocalAgentProviderAvailabilitySnapshot(); + const live = new Set(snapshot.filter((row) => row.available).map((row) => row.name)); + return LOCAL_AGENT_PROVIDERS.filter((id) => live.has(id)); +} diff --git a/src/workflow-tools.ts b/src/workflow-tools.ts index 6efbb6f60..e648467af 100644 --- a/src/workflow-tools.ts +++ b/src/workflow-tools.ts @@ -5,12 +5,6 @@ import * as z from "zod/v4"; import type { ServerConfig } from "./config.js"; import { jsonValueSchema, parseJsonText, type JsonValue } from "./json-types.js"; import type { WorkspaceRegistry } from "./workspaces.js"; -import { - persistWorkflowScriptResult, - resolveNamedWorkflowScriptResult, - readWorkflowScriptFileResult, -} from "./workflow-files.js"; -import { parseWorkflowScript } from "./workflow-script.js"; import { createWorkflowStore } from "./workflow-store.js"; import { WORKFLOW_MCP_YIELD_MS, @@ -18,26 +12,23 @@ import { type WorkflowEventRecord, type WorkflowRunRecord, } from "./workflow-types.js"; -import { resolveWorkspaceHead } from "./workflow-worktrees.js"; -import { spawnWorkflowWorkerFromCli } from "./workflow-cli.js"; import { cancelWorkflowRun } from "./workflow-lifecycle.js"; -import { getLocalAgentProviderAvailabilitySnapshot } from "./local-agent-availability.js"; -import { - LOCAL_AGENT_PROVIDERS, - type LocalAgentProvider, -} from "./local-agent-profiles.js"; import { InvalidWorkflowInputError, isWorkflowOperationError, serializeWorkflowError, WorkflowNotFoundError, - WorkflowStoredDataError, } from "./workflow-errors.js"; import { loadWorkflowUiCallDetail, loadWorkflowUiProject, loadWorkflowUiRun, } from "./workflow-ui.js"; +import { + launchWorkflowRun, + type LaunchWorkflowSource, +} from "./workflow-launch.js"; +import { resolveWorkflowLiveProviders } from "./workflow-providers.js"; const WORKSPACE_APP_URI = "ui://devspace/workspace-app.html"; const WORKFLOW_UI_WAIT_MAX_MS = 30_000; @@ -106,109 +97,30 @@ export function registerWorkflowTools( }); } - let source: string; - let scriptHash: string; - let nameHint: string; - let priorRunId: string | undefined; - let priorScriptPath: string | undefined; - let runSource: "inline" | "named" | "resume" = "inline"; - - if (resumeFromRunId) { - const priorResult = store.getRunResult(resumeFromRunId); - if (priorResult.isErr()) throw priorResult.error; - const prior = priorResult.value; - if (!prior) throw new WorkflowNotFoundError(resumeFromRunId); - priorRunId = prior.id; - const overridePath = scriptPath; - if (script !== undefined) { - source = script; - const overrideParsed = parseWorkflowScript(source); - scriptHash = overrideParsed.scriptHash; - nameHint = overrideParsed.meta.name; - } else if (name) { - const resolvedResult = await resolveNamedWorkflowScriptResult({ - name, - workspaceRoot: workspace.root, - stateDir: config.stateDir, - }); - if (resolvedResult.isErr()) throw resolvedResult.error; - source = resolvedResult.value.source; - scriptHash = resolvedResult.value.scriptHash; - nameHint = resolvedResult.value.nameHint; - } else { - priorScriptPath = overridePath ?? prior.scriptPath; - const resolvedResult = await readWorkflowScriptFileResult(priorScriptPath); - if (resolvedResult.isErr()) throw resolvedResult.error; - source = resolvedResult.value.source; - scriptHash = resolvedResult.value.scriptHash; - nameHint = overridePath ? resolvedResult.value.nameHint : prior.name; - } - runSource = "resume"; - if (args === undefined && prior.argsJson && prior.argsJson !== "null") { - try { - args = parseJsonText(prior.argsJson); - } catch (cause) { - throw new WorkflowStoredDataError(`${prior.id}.argsJson`, cause); - } - } - } else if (name) { - const resolvedResult = await resolveNamedWorkflowScriptResult({ - name, - workspaceRoot: workspace.root, - stateDir: config.stateDir, - }); - if (resolvedResult.isErr()) throw resolvedResult.error; - const resolved = resolvedResult.value; - source = resolved.source; - scriptHash = resolved.scriptHash; - nameHint = resolved.nameHint; - runSource = "named"; - } else if (scriptPath) { - const resolvedResult = await readWorkflowScriptFileResult(scriptPath); - if (resolvedResult.isErr()) throw resolvedResult.error; - source = resolvedResult.value.source; - scriptHash = resolvedResult.value.scriptHash; - nameHint = resolvedResult.value.nameHint; - } else { - source = script!; - const parsed = parseWorkflowScript(source); - scriptHash = parsed.scriptHash; - nameHint = parsed.meta.name; - runSource = "inline"; - } - - const parsed = parseWorkflowScript(source); - const baseSha = await resolveWorkspaceHead(workspace.root); - const run = store.createRun({ - name: parsed.meta.name || nameHint, - source: runSource, - scriptPath: "pending", - scriptHash, + const source = buildMcpLaunchSource({ + script, + name, + scriptPath, + resumeFromRunId, + }); + const launched = await launchWorkflowRun({ + store, + config, workspaceRoot: workspace.root, workspaceId, - argsJson: JSON.stringify(args ?? null), - resumedFromRunId: priorRunId, - baseSha, - }); - - const persistedResult = await persistWorkflowScriptResult({ - stateDir: config.stateDir, - runId: run.id, source, - preferredName: parsed.meta.name || nameHint, + args, + cliEntry: fileURLToPath( + import.meta.url.replace(/workflow-tools\.(ts|js)$/, "cli.$1"), + ), }); - if (persistedResult.isErr()) throw persistedResult.error; - const persisted = persistedResult.value; - const updated = store.setScriptPathResult(run.id, persisted); - if (updated.isErr()) throw updated.error; - - const cliEntry = fileURLToPath( - import.meta.url.replace(/workflow-tools\.(ts|js)$/, "cli.$1"), - ); - spawnWorkflowWorkerFromCli(run.id, cliEntry); + if (launched.isErr()) { + if (isWorkflowOperationError(launched.error)) return workflowToolError(launched.error); + throw launched.error; + } const yieldMs = yieldTimeMs ?? 2_000; - const page = await yieldEvents(store, run.id, 0, yieldMs); + const page = await yieldEvents(store, launched.value.run.id, 0, yieldMs); return toolResult(page, "run_workflow"); } catch (error) { if (isWorkflowOperationError(error)) return workflowToolError(error); @@ -558,11 +470,33 @@ function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } -/** Resolve live providers in stable product order for workflows. */ -export function resolveWorkflowEnabledProviders(): LocalAgentProvider[] { - const snapshot = getLocalAgentProviderAvailabilitySnapshot(); - const live = new Set( - snapshot.filter((row) => row.available).map((row) => row.name), - ); - return LOCAL_AGENT_PROVIDERS.filter((id): id is LocalAgentProvider => live.has(id)); +function buildMcpLaunchSource(input: { + script?: string; + name?: string; + scriptPath?: string; + resumeFromRunId?: string; +}): LaunchWorkflowSource { + if (input.resumeFromRunId) { + const override = + input.script !== undefined + ? ({ kind: "inline", script: input.script } as const) + : input.name + ? ({ kind: "named", name: input.name } as const) + : input.scriptPath + ? ({ kind: "file", path: input.scriptPath } as const) + : undefined; + return { kind: "resume", runId: input.resumeFromRunId, override }; + } + if (input.name) return { kind: "named", name: input.name }; + if (input.scriptPath) return { kind: "file", path: input.scriptPath }; + if (input.script !== undefined) return { kind: "inline", script: input.script }; + throw new InvalidWorkflowInputError({ + code: "missing_source", + message: "Provide script, name, scriptPath, or resumeFromRunId", + }); +} + +/** @deprecated Prefer resolveWorkflowLiveProviders from workflow-providers.js */ +export function resolveWorkflowEnabledProviders() { + return resolveWorkflowLiveProviders(); } diff --git a/src/workflow-worker.ts b/src/workflow-worker.ts new file mode 100644 index 000000000..619b5ba04 --- /dev/null +++ b/src/workflow-worker.ts @@ -0,0 +1,191 @@ +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 { runLocalAgentProviderResult } from "./local-agent-adapters.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"; + +/** 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(); + 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 { 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 providerRun = await runLocalAgentProviderResult(input.provider, { + prompt: input.prompt, + workspace: input.workspace, + providerSessionId: input.providerSessionId, + model: input.model, + effort: input.effort, + writeMode: "allowed", + schema: input.schema, + }); + if (providerRun.isErr()) throw providerRun.error; + const providerResult = providerRun.value; + return { + finalResponse: providerResult.finalResponse, + providerSessionId: providerResult.providerSessionId ?? undefined, + structured: providerResult.structured, + }; + }, + 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): void { + const child = spawn( + process.execPath, + [...process.execArgv, cliEntry, "workflow", "__worker", runId], + { + detached: true, + stdio: "ignore", + env: process.env, + }, + ); + child.unref(); +} + +/** @deprecated Use spawnWorkflowWorker */ +export const spawnWorkflowWorkerFromCli = spawnWorkflowWorker; From 8337f2c23402e6d6e1a8bf93f4d915e5ab1bd785 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 27 Jul 2026 23:17:38 +0530 Subject: [PATCH 074/132] refactor(workflow): drop consume-once compatible_key and unused schema helpers Prefix-only replay never emits compatible_key. Narrow contracts/types to same_index and remove unused schemaAwareRunProvider wrappers. --- src/workflow-api.ts | 4 ++-- src/workflow-contracts.ts | 2 +- src/workflow-schema.ts | 45 +-------------------------------------- src/workflow-store.ts | 6 +++--- src/workflow-types.ts | 2 +- src/workflow-ui.ts | 2 +- src/workflow-view.ts | 2 +- 7 files changed, 10 insertions(+), 53 deletions(-) diff --git a/src/workflow-api.ts b/src/workflow-api.ts index 071b0d59f..525fba035 100644 --- a/src/workflow-api.ts +++ b/src/workflow-api.ts @@ -76,7 +76,7 @@ export interface WorkflowReplayHit { structuredJson?: string; returnValueJson: string; providerSessionId?: string; - replayMatch: "same_index" | "compatible_key"; + replayMatch: "same_index"; replayedFromRunId: string; replayedFromCallIndex: number; } @@ -125,7 +125,7 @@ export interface WorkflowJournal { phase?: string; isolation?: AgentIsolationMode; worktreePath?: string; - replayMatch?: "same_index" | "compatible_key"; + replayMatch?: "same_index"; replayedFromRunId?: string; replayedFromCallIndex?: number; replayReason?: string; diff --git a/src/workflow-contracts.ts b/src/workflow-contracts.ts index d46419625..b06841d05 100644 --- a/src/workflow-contracts.ts +++ b/src/workflow-contracts.ts @@ -206,7 +206,7 @@ export const workflowEventPayloadSchemas = { callIndex: z.number().int().nonnegative(), cacheKey: z.string(), provider: localAgentProviderSchema, - replayMatch: z.enum(["same_index", "compatible_key"]), + replayMatch: z.enum(["same_index"]), replayedFromRunId: z.string(), replayedFromCallIndex: z.number().int().nonnegative(), }) diff --git a/src/workflow-schema.ts b/src/workflow-schema.ts index 0249c2dd5..5bdd856e3 100644 --- a/src/workflow-schema.ts +++ b/src/workflow-schema.ts @@ -2,7 +2,7 @@ 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, WorkflowRunProvider } from "./workflow-api.js"; +import type { WorkflowProviderRunResult } from "./workflow-api.js"; import { classifyAgentProviderError, isProviderSchemaUnsupportedError, @@ -233,49 +233,6 @@ function toSchemaIssues( })); } -/** Helper for wiring into agent(): wrap a one-shot provider as retrying schema runner. */ -export function schemaAwareRunProvider( - runProvider: WorkflowRunProvider, - schema: JsonSchema, - base: Parameters[0], - onRetry?: EnforceSchemaInput["onRetry"], -): Promise { - return enforceAgentSchema({ - schema, - prompt: base.prompt, - provider: base.provider, - onRetry, - run: (prompt, options) => - runProvider({ - ...base, - prompt, - providerSessionId: options.providerSessionId, - ...(options.mode === "native" ? { schema } : {}), - }), - }); -} - -export function schemaAwareRunProviderResult( - runProvider: WorkflowRunProvider, - schema: JsonSchema, - base: Parameters[0], - onRetry?: EnforceSchemaInput["onRetry"], -): Promise> { - return enforceAgentSchemaResult({ - schema, - prompt: base.prompt, - provider: base.provider, - onRetry, - run: (prompt, options) => - runProvider({ - ...base, - prompt, - providerSessionId: options.providerSessionId, - ...(options.mode === "native" ? { schema } : {}), - }), - }); -} - function structuredCandidates(result: WorkflowProviderRunResult): JsonValue[] { const candidates: JsonValue[] = []; if (result.structured !== undefined) { diff --git a/src/workflow-store.ts b/src/workflow-store.ts index 5f59df778..5c30fa51e 100644 --- a/src/workflow-store.ts +++ b/src/workflow-store.ts @@ -61,7 +61,7 @@ export interface BeginAgentCallInput { phase?: string; isolation?: AgentIsolationMode; worktreePath?: string; - replayMatch?: "same_index" | "compatible_key"; + replayMatch?: "same_index"; replayedFromRunId?: string; replayedFromCallIndex?: number; replayReason?: string; @@ -918,8 +918,8 @@ function rowToAgentCall(row: WorkflowAgentCallRow): WorkflowAgentCallRecord { error: row.error ?? undefined, errorKind: (row.error_kind as WorkflowErrorKind | null) ?? undefined, replayMatch: - row.replay_match === "same_index" || row.replay_match === "compatible_key" - ? row.replay_match + row.replay_match === "same_index" + ? "same_index" : undefined, replayedFromRunId: row.replayed_from_run_id ?? undefined, replayedFromCallIndex: row.replayed_from_call_index ?? undefined, diff --git a/src/workflow-types.ts b/src/workflow-types.ts index 4f46e1d7d..2e3996b7d 100644 --- a/src/workflow-types.ts +++ b/src/workflow-types.ts @@ -134,7 +134,7 @@ export interface WorkflowAgentCallRecord { returnValueJson?: string; error?: string; errorKind?: WorkflowErrorKind; - replayMatch?: "same_index" | "compatible_key"; + replayMatch?: "same_index"; replayedFromRunId?: string; replayedFromCallIndex?: number; replayReason?: string; diff --git a/src/workflow-ui.ts b/src/workflow-ui.ts index b97b526ad..a2c4b81e9 100644 --- a/src/workflow-ui.ts +++ b/src/workflow-ui.ts @@ -38,7 +38,7 @@ export interface WorkflowCallDetailView { worktreePath?: string; dirty?: boolean; fromCache: boolean; - replayMatch?: "same_index" | "compatible_key"; + replayMatch?: "same_index"; replayedFromRunId?: string; replayedFromCallIndex?: number; replayReason?: string; diff --git a/src/workflow-view.ts b/src/workflow-view.ts index 218bc2466..919d53479 100644 --- a/src/workflow-view.ts +++ b/src/workflow-view.ts @@ -35,7 +35,7 @@ export interface WorkflowCallView { worktreePath?: string; dirty?: boolean; fromCache: boolean; - replayMatch?: "same_index" | "compatible_key"; + replayMatch?: "same_index"; replayedFromRunId?: string; replayedFromCallIndex?: number; replayReason?: string; From e55f20eeba87d5dab564d8bafb050ea900cfe05f Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 27 Jul 2026 23:17:54 +0530 Subject: [PATCH 075/132] docs(workflow): align resume docs with prefix-only replay Update primitives-spec and the dynamic-workflows skill so resume semantics and oversized return failure match the implementation. --- docs/dynamic-workflow/devspace/primitives-spec.md | 9 +++++---- skills/dynamic-workflows/SKILL.md | 4 ++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/dynamic-workflow/devspace/primitives-spec.md b/docs/dynamic-workflow/devspace/primitives-spec.md index 0f23ea6af..219408f3e 100644 --- a/docs/dynamic-workflow/devspace/primitives-spec.md +++ b/docs/dynamic-workflow/devspace/primitives-spec.md @@ -55,7 +55,7 @@ ChatGPT ── MCP tools ──► engine ── agent() ──► adapter | `budget` | Shared host token hard ceiling | **Stub** `{ total: null, spent:0, remaining: Infinity }` | | `workflow()` | Nested name/scriptPath; depth 1; shared caps | Same spirit | | Determinism bans | Date.now / Math.random / bare new Date | Same | -| Resume | Prefix cache by prompt+opts | Index+key + consume-once cacheKey fallback | +| Resume | Prefix cache by prompt+opts | Deterministic call-index prefix only (first miss closes replay) | | File diffs per stage | **Not a primitive** | Same — no auto-diff | --- @@ -614,11 +614,12 @@ Adapters: no individual abort API — accepted; group-kill is backstop. |---|---| | New run | `--resume` / `resumeFromRunId` creates new run with `resumedFromRunId`. | | Cache key | `sha256(canonicalJson({ prompt, provider, model, effort, schema, isolation }))` | -| Match | (1) same callIndex + key (2) on first miss, consume-once by key (fan-out order). | +| Match | Same callIndex + cache key while the prefix remains open. | +| Close | First failed, interrupted, changed, missing, corrupt, worktree, or unpersisted result executes live and closes replay for later calls. | | Record | Cache hits written as new rows `from_cache=1` so chains chain. | | Determinism | Bans make prompt construction stable if args fixed. | -Document CC divergence (consume-once) in skill. +Document prefix-only resume (no consume-once key fallback) in skill. --- @@ -748,7 +749,7 @@ return pipeline( | log / args / budget | `workflow-api.ts` | freeze, stub budget | | workflow nest | `workflow-api.ts` + engine | depth 1, shared journal | | store | `workflow-store.ts` | seq, reap, cancel | -| replay | `workflow-replay.ts` | index+key, consume-once | +| replay | `workflow-replay.ts` | deterministic call-index prefix | | CLI | `cli.ts` | run/status/cancel/ls/__worker | | MCP | `workflow-tools.ts` | yield, survive disconnect | | skill | `skills/dynamic-workflows` | education | diff --git a/skills/dynamic-workflows/SKILL.md b/skills/dynamic-workflows/SKILL.md index de4ab1814..49487cbc6 100644 --- a/skills/dynamic-workflows/SKILL.md +++ b/skills/dynamic-workflows/SKILL.md @@ -117,6 +117,10 @@ calls assume their existing filesystem effects are still present. Worktree calls are never reused unless their exact worktree can be restored, so they currently end the reusable prefix and run live. +Return values must fit the replay budget (~1 MiB JSON). Oversized returns fail +the `agent()` call with `result_too_large` — prefer summaries or paths to large +artifacts on disk. + ### Cancel `workflow cancel` sets a cooperative flag; worker aborts then hard-kills if needed. From f916212c194fe2b8a4313f8deb6c0c4a8ac7b6fe Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:55:02 +0000 Subject: [PATCH 076/132] refactor(workflow): transact agent call lifecycle events --- src/workflow-api.ts | 172 +++++++++++++++++------------------------- src/workflow-store.ts | 168 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 222 insertions(+), 118 deletions(-) diff --git a/src/workflow-api.ts b/src/workflow-api.ts index 525fba035..4b6ce47ec 100644 --- a/src/workflow-api.ts +++ b/src/workflow-api.ts @@ -110,7 +110,7 @@ export interface WorkflowJournal { appendEvent( input: Extract, ): unknown; - beginAgentCall(input: { + startAgentCall(input: { runId: string; callIndex: number; cacheKey: string; @@ -130,6 +130,28 @@ export interface WorkflowJournal { 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; @@ -148,6 +170,7 @@ export interface WorkflowJournal { errorKind?: import("./workflow-types.js").WorkflowErrorKind; worktreePath?: string; dirty?: boolean; + cleanupError?: string; }): unknown; isCancelRequested(runId: string): boolean; } @@ -288,48 +311,29 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { const replayDecision = deps.replay?.decide(index, cacheKey, cacheKeyInput); if (replayDecision?.hit) { const hit = replayDecision.hit; - deps.journal.beginAgentCall({ - 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, - }); - deps.journal.completeAgentCall({ - runId: deps.runId, - callIndex: index, - responseText: hit.responseText, - structuredJson: hit.structuredJson, - returnValueJson: hit.returnValueJson, - providerSessionId: hit.providerSessionId, - fromCache: true, - }); - deps.journal.appendEvent({ - runId: deps.runId, - type: "agent_call_cached", - phase, - label: agentOpts.label, - data: { - callIndex: index, - cacheKey, - provider, - replayMatch: hit.replayMatch, - replayedFromRunId: hit.replayedFromRunId, - replayedFromCallIndex: hit.replayedFromCallIndex, - }, - }); - return hit.value; + 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); @@ -339,6 +343,26 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { 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( @@ -362,40 +386,6 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { }); } - deps.journal.beginAgentCall({ - 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, - worktreePath, - replayReason: replayDecision?.miss - ? formatReplayMiss(replayDecision.miss) - : undefined, - }); - agentCallBegun = true; - deps.journal.appendEvent({ - runId: deps.runId, - type: "agent_call_started", - phase, - label: agentOpts.label, - data: { - callIndex: index, - cacheKey, - provider, - isolation, - worktreePath, - }, - }); - const cwd = worktreePath ?? deps.workspaceRoot; const providerBase = { provider, @@ -484,20 +474,6 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { dirty, worktreePath, }); - deps.journal.appendEvent({ - runId: deps.runId, - type: "agent_call_completed", - phase, - label: agentOpts.label, - data: { - callIndex: index, - provider, - isolation, - worktreePath, - dirty, - fromCache: false, - }, - }); return returnValue; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -532,21 +508,9 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { error: message, errorKind: error instanceof WorkflowEngineError ? error.kind : "internal", worktreePath, + cleanupError, }); } - deps.journal.appendEvent({ - runId: deps.runId, - type: "agent_call_failed", - phase, - label: agentOpts.label, - data: { - callIndex: index, - error: message, - cleanupError, - isolation, - worktreePath, - }, - }); throw error; } finally { semaphore.release(); diff --git a/src/workflow-store.ts b/src/workflow-store.ts index 5c30fa51e..629b22b2a 100644 --- a/src/workflow-store.ts +++ b/src/workflow-store.ts @@ -79,6 +79,16 @@ export interface CompleteAgentCallInput { 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; @@ -86,6 +96,7 @@ export interface FailAgentCallInput { errorKind?: WorkflowErrorKind; worktreePath?: string; dirty?: boolean; + cleanupError?: string; } export interface CompleteRunInput { @@ -636,8 +647,74 @@ export class WorkflowStore { return rows.map(rowToEvent); } - beginAgentCall(input: BeginAgentCallInput): WorkflowAgentCallRecord { + 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( @@ -676,20 +753,36 @@ export class WorkflowStore { } completeAgentCall(input: CompleteAgentCallInput): WorkflowAgentCallRecord { - 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", - ); - } + 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( @@ -725,6 +818,33 @@ export class WorkflowStore { 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 @@ -750,6 +870,26 @@ export class WorkflowStore { 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 = ?`) From 4d958b3f3e20cf653203e6f748d1797eb03a5625 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:55:02 +0000 Subject: [PATCH 077/132] test(workflow): cover atomic agent call transitions --- src/workflow-engine.test.ts | 3 +- src/workflow-store.test.ts | 108 +++++++++++++++++++++++++++++++++++- src/workflow-ui.test.ts | 2 +- 3 files changed, 108 insertions(+), 5 deletions(-) diff --git a/src/workflow-engine.test.ts b/src/workflow-engine.test.ts index 83091f2b5..8279f0b79 100644 --- a/src/workflow-engine.test.ts +++ b/src/workflow-engine.test.ts @@ -280,7 +280,8 @@ import type { LocalAgentProfile } from "./local-agent-profiles.js"; () => runIsolated("do", { isolation: "worktree" }), /expected worktree setup failure/, ); - assert.equal(store.listAgentCalls(run.id).length, 0); + 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"); diff --git a/src/workflow-store.test.ts b/src/workflow-store.test.ts index 7815e1c83..a33081f43 100644 --- a/src/workflow-store.test.ts +++ b/src/workflow-store.test.ts @@ -66,7 +66,7 @@ try { assert.equal(page2.nextSeq, 3); assert.equal(page2.hasMore, false); - store.beginAgentCall({ + store.startAgentCall({ runId: run.id, callIndex: 0, cacheKey: "key-a", @@ -102,8 +102,12 @@ try { 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.beginAgentCall({ + store.startAgentCall({ runId: run.id, callIndex: 1, cacheKey: "key-b", @@ -119,6 +123,10 @@ try { 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); @@ -173,7 +181,10 @@ try { store.listRunsForWorkspace(join(root, "other-project"))[0]?.id, otherProjectRun.id, ); - assert.deepEqual(store.listEvents(run.id, 2).map((event) => event.seq), [3, 4]); + 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({ @@ -230,6 +241,97 @@ try { ); 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); // Second store instance sees same rows diff --git a/src/workflow-ui.test.ts b/src/workflow-ui.test.ts index 1621ce486..baf286561 100644 --- a/src/workflow-ui.test.ts +++ b/src/workflow-ui.test.ts @@ -29,7 +29,7 @@ try { phase: "Review", data: { title: "Review" }, }); - store.beginAgentCall({ + store.startAgentCall({ runId: run.id, callIndex: 0, cacheKey: "key", From 03a1a726cefd1adbf6cbd299e7c40efb1deb4af9 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:59:36 +0000 Subject: [PATCH 078/132] feat(workflow): enforce per-run agent call budget --- src/workflow-api.ts | 16 ++++++++++++++-- src/workflow-contracts.ts | 1 + src/workflow-errors.ts | 1 + src/workflow-types.ts | 1 + 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/workflow-api.ts b/src/workflow-api.ts index 4b6ce47ec..d30f4df29 100644 --- a/src/workflow-api.ts +++ b/src/workflow-api.ts @@ -13,6 +13,7 @@ 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, @@ -293,8 +294,7 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { const phase = agentOpts.phase ?? phaseAls.getStore(); const isolation: AgentIsolationMode = agentOpts.isolation === "worktree" ? "worktree" : "shared"; - const index = runtime.callIndex; - runtime.callIndex += 1; + const index = allocateAgentCallIndex(runtime); const cacheKeyInput = buildAgentCacheKeyInput({ prompt, @@ -744,6 +744,18 @@ function assertMaxItems(count: number, label: string): void { } } +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(); diff --git a/src/workflow-contracts.ts b/src/workflow-contracts.ts index b06841d05..8c15c75aa 100644 --- a/src/workflow-contracts.ts +++ b/src/workflow-contracts.ts @@ -131,6 +131,7 @@ export const workflowErrorKindSchema = z.enum([ "heartbeat", "worktree", "nest_depth", + "call_limit", "path", "result_too_large", "args_too_large", diff --git a/src/workflow-errors.ts b/src/workflow-errors.ts index 26eca02e9..262ab3e2b 100644 --- a/src/workflow-errors.ts +++ b/src/workflow-errors.ts @@ -19,6 +19,7 @@ export class WorkflowEngineError extends Error { | "no_provider" | "profile" | "nest_depth" + | "call_limit" | "worktree" | "schema" | "path" diff --git a/src/workflow-types.ts b/src/workflow-types.ts index 2e3996b7d..75286338c 100644 --- a/src/workflow-types.ts +++ b/src/workflow-types.ts @@ -48,6 +48,7 @@ export type { // --------------------------------------------------------------------------- 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; From 9860cbd84b550e051fd5a02175ab1517555f41bf Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:59:36 +0000 Subject: [PATCH 079/132] test(workflow): cover agent call budget boundary --- src/workflow-engine.test.ts | 62 ++++++++++++++++++++++++++++++++++++- src/workflow-types.test.ts | 2 ++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/workflow-engine.test.ts b/src/workflow-engine.test.ts index 8279f0b79..8fd1bee80 100644 --- a/src/workflow-engine.test.ts +++ b/src/workflow-engine.test.ts @@ -6,13 +6,18 @@ 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 } from "./workflow-types.js"; +import { + createStubBudget, + WORKFLOW_LIMITS, + WORKFLOW_MAX_AGENT_CALLS, +} from "./workflow-types.js"; import type { LocalAgentProfile } from "./local-agent-profiles.js"; // --------------------------------------------------------------------------- @@ -35,6 +40,61 @@ import type { LocalAgentProfile } from "./local-agent-profiles.js"; 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 // --------------------------------------------------------------------------- diff --git a/src/workflow-types.test.ts b/src/workflow-types.test.ts index 55e1abc87..b8b06d7a8 100644 --- a/src/workflow-types.test.ts +++ b/src/workflow-types.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { + WORKFLOW_MAX_AGENT_CALLS, WORKFLOW_MAX_ITEMS, WORKFLOW_MAX_NEST_DEPTH, buildAgentCacheKeyInput, @@ -9,6 +10,7 @@ import { } 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( From 65da3e663b05fa57e58eac1494d30e0aa359b30f Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 05:54:37 +0530 Subject: [PATCH 080/132] fix(cli): scope agent work to the current project --- package.json | 2 +- src/cli-workspace.test.ts | 57 +++++++++++++++++++++++++++ src/cli-workspace.ts | 81 ++++++++++++++++++++++++++++++++++++++ src/cli.ts | 27 ++++++------- src/workflow-cli.ts | 40 ++++++++++++++++--- src/workflow-store.test.ts | 17 +++++++- src/workflow-store.ts | 39 ++++++++++++++++++ 7 files changed, 240 insertions(+), 23 deletions(-) create mode 100644 src/cli-workspace.test.ts create mode 100644 src/cli-workspace.ts diff --git a/package.json b/package.json index 6e1aad7a2..6b2125372 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/open-workspace-capabilities.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-capabilities.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-resolution.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-lifecycle.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-ui.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-launch.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", + "test": "tsx src/config.test.ts && tsx src/cli-workspace.test.ts && tsx src/open-workspace-capabilities.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-capabilities.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-resolution.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-lifecycle.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-ui.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-launch.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/cli-workspace.test.ts b/src/cli-workspace.test.ts new file mode 100644 index 000000000..bbffa9242 --- /dev/null +++ b/src/cli-workspace.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + assertRecordInCliWorkspace, + resolveCliWorkspaceContext, +} from "./cli-workspace.js"; + +const root = mkdtempSync(join(tmpdir(), "devspace-cli-workspace-")); +try { + const repository = join(root, "repository"); + const nested = join(repository, "packages", "app"); + mkdirSync(nested, { recursive: true }); + execFileSync("git", ["init", "--quiet", repository]); + + assert.deepEqual(resolveCliWorkspaceContext({}, nested), { + workspaceId: undefined, + workspaceRoot: resolve(repository), + }); + + mkdirSync(join(repository, "packages", ".devspace")); + assert.equal( + resolveCliWorkspaceContext({}, nested).workspaceRoot, + resolve(repository, "packages"), + ); + + assert.deepEqual( + resolveCliWorkspaceContext({ + DEVSPACE_WORKSPACE_ID: "ws_injected", + DEVSPACE_WORKSPACE_ROOT: nested, + }, root), + { + workspaceId: "ws_injected", + workspaceRoot: resolve(nested), + }, + ); + + assert.doesNotThrow(() => assertRecordInCliWorkspace( + { workspaceId: "ws_injected", workspaceRoot: root }, + { workspaceId: "ws_injected", workspaceRoot: nested }, + "Subagent", + )); + assert.throws( + () => assertRecordInCliWorkspace( + { workspaceId: "ws_other", workspaceRoot: nested }, + { workspaceId: "ws_injected", workspaceRoot: nested }, + "Subagent", + ), + /does not belong to the current project/, + ); +} finally { + rmSync(root, { recursive: true, force: true }); +} + +console.log("cli-workspace.test.ts: ok"); diff --git a/src/cli-workspace.ts b/src/cli-workspace.ts new file mode 100644 index 000000000..cb3e06f7d --- /dev/null +++ b/src/cli-workspace.ts @@ -0,0 +1,81 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, resolve } from "node:path"; + +export interface CliWorkspaceContext { + workspaceId?: string; + workspaceRoot: string; +} + +export interface WorkspaceScopedRecord { + workspaceId?: string; + workspaceRoot: string; +} + +/** Resolve a stable project scope for CLI commands launched inside or outside DevSpace. */ +export function resolveCliWorkspaceContext( + env: NodeJS.ProcessEnv = process.env, + cwd = process.cwd(), +): CliWorkspaceContext { + const injectedRoot = env.DEVSPACE_WORKSPACE_ROOT?.trim(); + const gitRoot = injectedRoot ? undefined : findGitRoot(cwd); + return { + workspaceId: env.DEVSPACE_WORKSPACE_ID?.trim() || undefined, + workspaceRoot: injectedRoot + ? resolve(injectedRoot) + : findDevspaceProjectRoot(cwd, env, gitRoot) ?? gitRoot ?? resolve(cwd), + }; +} + +export function isRecordInCliWorkspace( + record: WorkspaceScopedRecord, + context: CliWorkspaceContext, +): boolean { + if (context.workspaceId) return record.workspaceId === context.workspaceId; + return resolve(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 findDevspaceProjectRoot( + cwd: string, + env: NodeJS.ProcessEnv, + gitRoot?: string, +): string | undefined { + const configDir = resolve(env.DEVSPACE_CONFIG_DIR ?? resolve(homedir(), ".devspace")); + let current = resolve(cwd); + for (;;) { + const marker = resolve(current, ".devspace"); + if ( + marker !== configDir && + existsSync(marker) && + statSync(marker).isDirectory() + ) { + return current; + } + if (gitRoot && current === gitRoot) return undefined; + const parent = dirname(current); + if (parent === current) return undefined; + current = parent; + } +} + +function findGitRoot(cwd: string): string | undefined { + const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { + cwd: resolve(cwd), + encoding: "utf8", + windowsHide: true, + }); + if (result.status !== 0) return undefined; + const root = result.stdout.trim(); + return root ? resolve(root) : undefined; +} diff --git a/src/cli.ts b/src/cli.ts index 81bda28b5..82ad6bf1b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -42,6 +42,10 @@ import { } from "./user-config.js"; import { expandHomePath } from "./roots.js"; import { shutdownHttpServer } from "./server-shutdown.js"; +import { + assertRecordInCliWorkspace, + resolveCliWorkspaceContext, +} from "./cli-workspace.js"; import { runWorkflowCommand } from "./workflow-cli.js"; import { @@ -384,7 +388,7 @@ async function runAgentsCommand(args: string[]): Promise { async function runAgentsList(): Promise { const config = loadConfig(); const store = createLocalAgentStore(config); - const agents = store.list(resolveCurrentWorkspaceScope()); + const agents = store.list(resolveCliWorkspaceContext()); if (agents.length === 0) { console.log("No subagent sessions found for this workspace."); @@ -403,7 +407,7 @@ async function runAgentsTargets(args: string[]): Promise { } const config = loadConfig(); - const workspaceRoot = resolveCurrentWorkspaceRoot(); + const { workspaceRoot } = resolveCliWorkspaceContext(); const profiles = await loadLocalAgentProfiles(config, workspaceRoot); const catalog = buildLocalAgentCatalog( profiles, @@ -420,11 +424,13 @@ async function runAgentsRun(args: string[]): Promise { const parsed = parseLocalAgentRunArgs(args); const config = loadConfig(); - const workspaceRoot = resolveCurrentWorkspaceRoot(); + const workspace = resolveCliWorkspaceContext(); + const workspaceRoot = workspace.workspaceRoot; const store = createLocalAgentStore(config); const existing = store.get(parsed.target); if (existing) { + assertRecordInCliWorkspace(existing, workspace, "Subagent session"); if (!isLocalAgentProvider(existing.provider)) { throw new Error(`Unknown subagent provider for existing session: ${existing.provider}`); } @@ -466,7 +472,7 @@ async function runAgentsRun(args: string[]): Promise { const promptFile = writeAgentPromptFile(parsed.prompt); const record = store.create({ - workspaceId: process.env.DEVSPACE_WORKSPACE_ID, + workspaceId: workspace.workspaceId, workspaceRoot, profileName: target.name, provider: target.provider, @@ -484,8 +490,10 @@ async function runAgentsShow(args: string[]): Promise { const config = loadConfig(); const store = createLocalAgentStore(config); + const workspace = resolveCliWorkspaceContext(); let record = store.get(id); if (!record) throw new Error(`Unknown subagent id: ${id}`); + assertRecordInCliWorkspace(record, workspace, "Subagent session"); const deadline = Date.now() + 15_000; while ((record.status === "starting" || record.status === "running") && Date.now() < deadline) { @@ -576,17 +584,6 @@ function writeAgentPromptFile(prompt: string): string { return filePath; } -function resolveCurrentWorkspaceRoot(): string { - return resolve(process.env.DEVSPACE_WORKSPACE_ROOT || process.cwd()); -} - -function resolveCurrentWorkspaceScope(): { workspaceId?: string; workspaceRoot: string } { - return { - workspaceId: process.env.DEVSPACE_WORKSPACE_ID, - workspaceRoot: resolveCurrentWorkspaceRoot(), - }; -} - function formatAgentLine(agent: Pick< LocalAgentRecord, "id" | "status" | "profileName" | "provider" | "model" | "effort" diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts index 6100332ca..a19233fe8 100644 --- a/src/workflow-cli.ts +++ b/src/workflow-cli.ts @@ -1,5 +1,9 @@ -import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { + assertRecordInCliWorkspace, + resolveCliWorkspaceContext, + type CliWorkspaceContext, +} from "./cli-workspace.js"; import type { ServerConfig } from "./config.js"; import { parseWorkflowArgFlagsResult } from "./workflow-files.js"; import { @@ -129,7 +133,13 @@ async function runWorkflowRun(args: string[], config: ServerConfig): Promise { const store = createWorkflowStore(config); try { reapStaleWorkflows(store); - const runs = store.listRuns(50); + const runs = store.listRunsForScope(resolveCliWorkspaceContext(), { + limit: 50, + }); if (runs.length === 0) { console.log("No workflow runs."); return; @@ -254,7 +271,9 @@ async function runWorkflowCalls(args: string[], config: ServerConfig): Promise { let sinceSeq = 0; for (;;) { diff --git a/src/workflow-store.test.ts b/src/workflow-store.test.ts index a33081f43..1ee61f89e 100644 --- a/src/workflow-store.test.ts +++ b/src/workflow-store.test.ts @@ -164,12 +164,27 @@ try { 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].sort(), + [run.id, run2.id, otherWorkspaceRun.id].sort(), + ); + assert.deepEqual( + store.listRunsForScope({ + workspaceId: "ws_1", + workspaceRoot: join(root, "project"), + }).map((entry) => entry.id), + [run.id], ); assert.deepEqual( store diff --git a/src/workflow-store.ts b/src/workflow-store.ts index 629b22b2a..60cb66085 100644 --- a/src/workflow-store.ts +++ b/src/workflow-store.ts @@ -117,6 +117,11 @@ export interface DrainEventsResult { run: WorkflowRunRecord; } +export interface WorkflowRunScope { + workspaceId?: string; + workspaceRoot: string; +} + interface WorkflowRunRow { id: string; name: string; @@ -301,6 +306,40 @@ export class WorkflowStore { return rows.map(rowToRun); } + listRunsForScope( + scope: WorkflowRunScope, + options: { + statuses?: WorkflowRunStatus[]; + limit?: number; + } = {}, + ): WorkflowRunRecord[] { + if (!scope.workspaceId) return this.listRunsForWorkspace(scope.workspaceRoot, options); + + 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_id = ? order by updated_at desc limit ?", + ) + .all(scope.workspaceId, 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 = ? and status in (${placeholders}) + order by updated_at desc + limit ?`, + ) + .all(scope.workspaceId, ...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. From 26fb3ebd7ae5ec9c23cfd016733ec9e622caf4be Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 05:59:06 +0530 Subject: [PATCH 081/132] feat(cli): add structured agent and workflow output --- package.json | 2 +- src/cli-output.test.ts | 108 +++++++++++++++++++++++++++++++ src/cli-output.ts | 140 +++++++++++++++++++++++++++++++++++++++++ src/cli.test.ts | 64 +++++++++++++++++++ src/cli.ts | 62 +++++++++++++----- src/workflow-cli.ts | 108 ++++++++++++++++++++++++++----- 6 files changed, 451 insertions(+), 33 deletions(-) create mode 100644 src/cli-output.test.ts create mode 100644 src/cli-output.ts diff --git a/package.json b/package.json index 6b2125372..2a433f8f0 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/cli-workspace.test.ts && tsx src/open-workspace-capabilities.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-capabilities.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-resolution.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-lifecycle.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-ui.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-launch.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", + "test": "tsx src/config.test.ts && tsx src/cli-workspace.test.ts && tsx src/cli-output.test.ts && tsx src/open-workspace-capabilities.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-capabilities.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-resolution.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-lifecycle.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-ui.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-launch.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/cli-output.test.ts b/src/cli-output.test.ts new file mode 100644 index 000000000..721afb519 --- /dev/null +++ b/src/cli-output.test.ts @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import { + localAgentOutput, + localAgentTargetsOutput, + workflowCallOutput, + workflowRunOutput, +} from "./cli-output.js"; +import type { LocalAgentCatalog } from "./local-agent-catalog.js"; +import type { LocalAgentRecord } from "./local-agent-store.js"; +import type { WorkflowAgentCallRecord, WorkflowRunRecord } from "./workflow-types.js"; + +const now = "2026-08-08T00:00:00.000Z"; +const agent: LocalAgentRecord = { + id: "agt_123", + workspaceId: "ws_private", + workspaceRoot: "/private/project", + profileName: "reviewer", + provider: "codex", + providerSessionId: "provider-secret", + status: "idle", + latestResponse: "done", + createdAt: now, + updatedAt: now, +}; +const agentJson = localAgentOutput(agent, { includeResult: true }); +assert.equal(agentJson.response, "done"); +assert.equal("providerSessionId" in agentJson, false); +assert.equal("workspaceRoot" in agentJson, false); + +const catalog: LocalAgentCatalog = { + profiles: [{ + name: "reviewer", + description: "Review changes.", + provider: "codex", + model: "gpt", + effort: "high", + }], + providers: [{ + name: "codex", + model: { supported: true, discovery: "model_dependent" }, + effort: { + supported: true, + semantics: "reasoning_effort", + discovery: "model_dependent", + }, + }], +}; +assert.deepEqual(localAgentTargetsOutput(catalog), { + profiles: [{ + name: "reviewer", + description: "Review changes.", + provider: "codex", + model: "gpt", + effort: "high", + }], + providers: [{ name: "codex", overrides: ["model", "effort"] }], +}); + +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 000000000..667771b77 --- /dev/null +++ b/src/cli-output.ts @@ -0,0 +1,140 @@ +import type { LocalAgentCatalog } from "./local-agent-catalog.js"; +import type { LocalAgentRecord } from "./local-agent-store.js"; +import type { + WorkflowAgentCallRecord, + WorkflowRunRecord, +} from "./workflow-types.js"; + +export function localAgentTargetsOutput(catalog: LocalAgentCatalog): Record { + return { + profiles: catalog.profiles.map((profile) => ({ + name: profile.name, + description: profile.description, + provider: profile.provider, + model: profile.model, + effort: profile.effort, + })), + providers: catalog.providers.map((provider) => ({ + name: provider.name, + overrides: [ + ...(provider.model.supported ? ["model"] : []), + ...(provider.effort.supported ? ["effort"] : []), + ], + })), + }; +} + +export function localAgentOutput( + agent: LocalAgentRecord, + options: { includeResult?: boolean } = {}, +): Record { + return { + id: agent.id, + status: agent.status, + target: agent.profileName, + provider: agent.provider, + model: agent.model, + effort: agent.effort, + ...(options.includeResult + ? { response: agent.latestResponse, error: agent.error } + : {}), + createdAt: agent.createdAt, + updatedAt: agent.updatedAt, + }; +} + +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.test.ts b/src/cli.test.ts index 38ab51f7c..5da2173a3 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig } from "./config.js"; import { LocalAgentStore } from "./local-agent-store.js"; +import { WorkflowStore } from "./workflow-store.js"; const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string; @@ -108,6 +109,69 @@ try { assert.equal(targets.profiles[0]?.provider, "codex"); assert.equal(targets.providers.some((provider) => provider.name === "codex"), true); + const agentsJson = JSON.parse(execFileSync( + "node", + ["--import", "tsx", "src/cli.ts", "agents", "ls", "--json"], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + DEVSPACE_CONFIG_DIR: configDir, + DEVSPACE_ALLOWED_ROOTS: projectRoot, + DEVSPACE_STATE_DIR: stateDir, + DEVSPACE_WORKSPACE_ID: "ws_current", + DEVSPACE_WORKSPACE_ROOT: projectRoot, + DEVSPACE_SUBAGENTS: "1", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + }, + }, + )) as { agents: Array<{ id: string }> }; + assert.deepEqual(agentsJson.agents.map((agent) => agent.id), [current.id]); + + const workflowStore = new WorkflowStore(stateDir); + const currentWorkflow = workflowStore.createRun({ + name: "current", + source: "inline", + scriptPath: join(projectRoot, "current.js"), + scriptHash: "current", + workspaceRoot: projectRoot, + workspaceId: "ws_current", + }); + workflowStore.createRun({ + name: "other", + source: "inline", + scriptPath: join(projectRoot, "other.js"), + scriptHash: "other", + workspaceRoot: projectRoot, + workspaceId: "ws_other", + }); + workflowStore.close(); + + const workflowsJson = JSON.parse(execFileSync( + "node", + ["--import", "tsx", "src/cli.ts", "workflow", "ls", "--json"], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + DEVSPACE_CONFIG_DIR: configDir, + DEVSPACE_ALLOWED_ROOTS: projectRoot, + DEVSPACE_STATE_DIR: stateDir, + DEVSPACE_WORKSPACE_ID: "ws_current", + DEVSPACE_WORKSPACE_ROOT: projectRoot, + DEVSPACE_SUBAGENTS: "1", + DEVSPACE_WORKFLOWS: "1", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + }, + }, + )) as { workflows: Array<{ id: string }> }; + assert.deepEqual( + workflowsJson.workflows.map((workflow) => workflow.id), + [currentWorkflow.id], + ); + assert.equal(loadConfig({ DEVSPACE_CONFIG_DIR: configDir, DEVSPACE_ALLOWED_ROOTS: projectRoot, diff --git a/src/cli.ts b/src/cli.ts index 82ad6bf1b..45d5c8005 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -46,6 +46,10 @@ import { assertRecordInCliWorkspace, resolveCliWorkspaceContext, } from "./cli-workspace.js"; +import { + localAgentOutput, + localAgentTargetsOutput, +} from "./cli-output.js"; import { runWorkflowCommand } from "./workflow-cli.js"; import { @@ -360,7 +364,7 @@ async function runAgentsCommand(args: string[]): Promise { switch (subcommand) { case "ls": case "list": - await runAgentsList(); + await runAgentsList(rest); return; case "targets": await runAgentsTargets(rest); @@ -385,11 +389,16 @@ async function runAgentsCommand(args: string[]): Promise { } } -async function runAgentsList(): Promise { +async function runAgentsList(args: string[]): Promise { + const json = parseJsonOnlyOption(args, "devspace agents ls [--json]"); const config = loadConfig(); const store = createLocalAgentStore(config); const agents = store.list(resolveCliWorkspaceContext()); + if (json) { + printJson({ agents: agents.map((agent) => localAgentOutput(agent)) }); + return; + } if (agents.length === 0) { console.log("No subagent sessions found for this workspace."); return; @@ -415,13 +424,14 @@ async function runAgentsTargets(args: string[]): Promise { ); console.log( args.includes("--json") - ? JSON.stringify(catalog, null, 2) + ? JSON.stringify(localAgentTargetsOutput(catalog), null, 2) : formatLocalAgentCatalog(catalog), ); } async function runAgentsRun(args: string[]): Promise { - const parsed = parseLocalAgentRunArgs(args); + const json = args.includes("--json"); + const parsed = parseLocalAgentRunArgs(args.filter((arg) => arg !== "--json")); const config = loadConfig(); const workspace = resolveCliWorkspaceContext(); @@ -444,12 +454,14 @@ async function runAgentsRun(args: string[]): Promise { error: undefined, }); spawnAgentWorker(existing.id, promptFile); - console.log(formatAgentLine({ + const running = { ...existing, status: "running", model: parsed.model ?? existing.model, effort: parsed.effort ?? existing.effort, - })); + } as LocalAgentRecord; + if (json) printJson({ agent: localAgentOutput(running) }); + else console.log(formatAgentLine(running)); return; } @@ -481,12 +493,16 @@ async function runAgentsRun(args: string[]): Promise { }); spawnAgentWorker(record.id, promptFile); - console.log(formatAgentLine({ ...record, status: "running" })); + const running = { ...record, status: "running" } as LocalAgentRecord; + if (json) printJson({ agent: localAgentOutput(running) }); + else console.log(formatAgentLine(running)); } async function runAgentsShow(args: string[]): Promise { - const [id] = args; + const json = args.includes("--json"); + const [id, ...unknownArgs] = args.filter((arg) => arg !== "--json"); if (!id) throw new Error("Usage: devspace agents show "); + if (unknownArgs.length > 0) throw new Error("Usage: devspace agents show [--json]"); const config = loadConfig(); const store = createLocalAgentStore(config); @@ -495,10 +511,17 @@ async function runAgentsShow(args: string[]): Promise { if (!record) throw new Error(`Unknown subagent id: ${id}`); assertRecordInCliWorkspace(record, workspace, "Subagent session"); - const deadline = Date.now() + 15_000; - while ((record.status === "starting" || record.status === "running") && Date.now() < deadline) { - await sleep(500); - record = store.get(id) ?? record; + if (!json) { + const deadline = Date.now() + 15_000; + while ((record.status === "starting" || record.status === "running") && Date.now() < deadline) { + await sleep(500); + record = store.get(id) ?? record; + } + } + + if (json) { + printJson({ agent: localAgentOutput(record, { includeResult: true }) }); + return; } console.log(formatAgentLine(record)); @@ -593,6 +616,15 @@ function formatAgentLine(agent: Pick< return `${agent.id} ${agent.status} ${agent.profileName} ${agent.provider}${model}${effort}`; } +function parseJsonOnlyOption(args: string[], usage: string): boolean { + if (args.some((arg) => arg !== "--json")) throw new Error(`Usage: ${usage}`); + return args.includes("--json"); +} + +function printJson(value: unknown): void { + console.log(JSON.stringify(value, null, 2)); +} + function sleep(ms: number): Promise { return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); } @@ -603,10 +635,10 @@ function printAgentsHelp(): void { "DevSpace agents", "", "Usage:", - " devspace agents ls", + " devspace agents ls [--json]", " devspace agents targets [--json]", - " devspace agents run [--model ] [--effort ] ", - " devspace agents show ", + " devspace agents run [--model ] [--effort ] [--json]", + " devspace agents show [--json]", ].join("\n"), ); } diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts index a19233fe8..2e79046a3 100644 --- a/src/workflow-cli.ts +++ b/src/workflow-cli.ts @@ -4,6 +4,10 @@ import { 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 { @@ -58,7 +62,7 @@ export async function runWorkflowCommand( return; case "ls": case "list": - await runWorkflowList(config); + await runWorkflowList(rest, config); return; case "calls": await runWorkflowCalls(rest, config); @@ -95,12 +99,12 @@ export function printWorkflowHelp(): void { "", "Usage:", " devspace workflow run [--file|--script-path | --name ] [--resume ]", - " [--arg key=value]... [--follow]", - " devspace workflow status [--follow]", - " devspace workflow cancel ", - " devspace workflow ls", - " devspace workflow calls ", - " devspace workflow call ", + " [--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"), ); @@ -109,6 +113,13 @@ export function printWorkflowHelp(): void { 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"); @@ -161,7 +172,8 @@ async function runWorkflowRun(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({ @@ -211,8 +230,13 @@ async function runWorkflowStatus(args: string[], config: ServerConfig): Promise< 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(store.listAgentCalls(runId))); + console.log(formatCallSummary(calls)); if (follow) { await followRun(store, runId); return; @@ -225,32 +249,46 @@ async function runWorkflowStatus(args: string[], config: ServerConfig): Promise< } async function runWorkflowCancel(args: string[], config: ServerConfig): Promise { - const runId = args[0]; + 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()); - console.log(formatRunLine(await cancelWorkflowRun(store, runId))); + const cancelled = await cancelWorkflowRun(store, runId); + if (json) printJson({ workflow: workflowRunOutput(cancelled) }); + else console.log(formatRunLine(cancelled)); } finally { store.close(); } } -async function runWorkflowList(config: ServerConfig): Promise { +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(), { limit: 50, }); + if (json) { + printJson({ workflows: runs.map((run) => workflowRunOutput(run)) }); + return; + } if (runs.length === 0) { console.log("No workflow runs."); return; @@ -262,19 +300,33 @@ async function runWorkflowList(config: ServerConfig): Promise { } async function runWorkflowCalls(args: string[], config: ServerConfig): Promise { - const runId = args[0]; + 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()); 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; @@ -286,14 +338,21 @@ async function runWorkflowCalls(args: string[], config: ServerConfig): Promise { - const runId = args[0]; - const callIndex = Number(args[1]); + 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); @@ -306,7 +365,8 @@ async function runWorkflowCall(args: string[], config: ServerConfig): Promise 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 (;;) { @@ -455,7 +529,7 @@ function splitFlags(args: string[]): { } const key = token.slice(2); const next = args[i + 1]; - if (next && !next.startsWith("-") && key !== "follow") { + if (next && !next.startsWith("-") && key !== "follow" && key !== "json") { flags.set(key, next); i += 1; } else { From c3b60ef8b245e18b9bd945fdfee2ea83cbf28473 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 06:20:35 +0530 Subject: [PATCH 082/132] fix(workflow): preserve result types across error paths --- src/workflow-launch.ts | 18 +++++++++--------- src/workflow-schema.ts | 4 ++-- src/workflow-store.ts | 24 ++++++++++++------------ 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/workflow-launch.ts b/src/workflow-launch.ts index 23d441441..ef324fcef 100644 --- a/src/workflow-launch.ts +++ b/src/workflow-launch.ts @@ -70,7 +70,7 @@ export async function launchWorkflowRun( ): Promise> { try { const resolved = await resolveLaunchSource(input); - if (resolved.isErr()) return resolved; + if (resolved.isErr()) return Result.err(resolved.error); const { sourceText, @@ -104,10 +104,10 @@ export async function launchWorkflowRun( source: sourceText, preferredName, }); - if (persisted.isErr()) return persisted; + if (persisted.isErr()) return Result.err(persisted.error); const updated = input.store.setScriptPathResult(run.id, persisted.value); - if (updated.isErr()) return updated; + if (updated.isErr()) return Result.err(updated.error); if (input.spawn !== false) { spawnWorkflowWorker(run.id, input.cliEntry); @@ -143,7 +143,7 @@ async function resolveLaunchSource( if (source.kind === "resume") { const priorResult = store.getRunResult(source.runId); - if (priorResult.isErr()) return priorResult; + if (priorResult.isErr()) return Result.err(priorResult.error); const prior = priorResult.value; if (!prior) return Result.err(new WorkflowNotFoundError(source.runId)); @@ -166,21 +166,21 @@ async function resolveLaunchSource( workspaceRoot, stateDir: config.stateDir, }); - if (named.isErr()) return named; + 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 readWorkflowScriptFileResult(source.override.path); - if (file.isErr()) return file; + 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 priorScript; + if (priorScript.isErr()) return Result.err(priorScript.error); sourceText = priorScript.value.source; scriptHash = priorScript.value.scriptHash; nameHint = prior.name; @@ -226,7 +226,7 @@ async function resolveLaunchSource( workspaceRoot, stateDir: config.stateDir, }); - if (named.isErr()) return named; + if (named.isErr()) return Result.err(named.error); return Result.ok({ sourceText: named.value.source, scriptHash: named.value.scriptHash, @@ -243,7 +243,7 @@ async function resolveLaunchSource( workspaceRoot, stateDir: config.stateDir, }); - if (file.isErr()) return file; + if (file.isErr()) return Result.err(file.error); return Result.ok({ sourceText: file.value.source, scriptHash: file.value.scriptHash, diff --git a/src/workflow-schema.ts b/src/workflow-schema.ts index 5bdd856e3..43e141b66 100644 --- a/src/workflow-schema.ts +++ b/src/workflow-schema.ts @@ -110,7 +110,7 @@ export async function enforceAgentSchemaResult( }, catch: (cause) => new SchemaConfigurationError(cause), }); - if (compiled.isErr()) return compiled; + 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); @@ -146,7 +146,7 @@ export async function enforceAgentSchemaResult( }); continue; } - return runResult; + return Result.err(runResult.error); } const result = runResult.value; providerSessionId = result.providerSessionId ?? providerSessionId; diff --git a/src/workflow-store.ts b/src/workflow-store.ts index 60cb66085..96d31b25b 100644 --- a/src/workflow-store.ts +++ b/src/workflow-store.ts @@ -353,7 +353,7 @@ export class WorkflowStore { scriptPath: string, ): BetterResult { const current = this.getRunResult(id); - if (current.isErr()) return current; + if (current.isErr()) return Result.err(current.error); const run = current.value; if (!run) return Result.err(new WorkflowNotFoundError(id)); const updated = Result.try({ @@ -368,7 +368,7 @@ export class WorkflowStore { }, catch: (cause) => new WorkflowStoreError("set_script_path", cause), }); - if (updated.isErr()) return updated; + if (updated.isErr()) return Result.err(updated.error); return updated.value ? Result.ok(updated.value) : Result.err(new WorkflowNotFoundError(id)); @@ -391,7 +391,7 @@ export class WorkflowStore { pid: number, ): BetterResult { const currentResult = this.getRunResult(id); - if (currentResult.isErr()) return currentResult; + if (currentResult.isErr()) return Result.err(currentResult.error); const current = currentResult.value; if (!current) return Result.err(new WorkflowNotFoundError(id)); if (current.status !== "starting") { @@ -422,10 +422,10 @@ export class WorkflowStore { }, catch: (cause) => new WorkflowStoreError("claim_run", cause), }); - if (claimed.isErr()) return claimed; + if (claimed.isErr()) return Result.err(claimed.error); if (claimed.value === 0) { const latestResult = this.getRunResult(id); - if (latestResult.isErr()) return latestResult; + if (latestResult.isErr()) return Result.err(latestResult.error); const latest = latestResult.value; return latest ? Result.err( @@ -438,7 +438,7 @@ export class WorkflowStore { : Result.err(new WorkflowNotFoundError(id)); } const runResult = this.getRunResult(id); - if (runResult.isErr()) return runResult; + if (runResult.isErr()) return Result.err(runResult.error); const run = runResult.value; return run ? Result.ok(run) : Result.err(new WorkflowNotFoundError(id)); } @@ -459,7 +459,7 @@ export class WorkflowStore { id: string, ): BetterResult { const current = this.getRunResult(id); - if (current.isErr()) return current; + 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); @@ -479,7 +479,7 @@ export class WorkflowStore { }, catch: (cause) => new WorkflowStoreError("request_cancel", cause), }); - if (updated.isErr()) return updated; + if (updated.isErr()) return Result.err(updated.error); return updated.value ? Result.ok(updated.value) : Result.err(new WorkflowNotFoundError(id)); @@ -610,7 +610,7 @@ export class WorkflowStore { update: () => number, ): BetterResult { const currentResult = this.getRunResult(id); - if (currentResult.isErr()) return currentResult; + 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); @@ -619,10 +619,10 @@ export class WorkflowStore { try: update, catch: (cause) => new WorkflowStoreError(`${operation}_run`, cause), }); - if (updated.isErr()) return updated; + if (updated.isErr()) return Result.err(updated.error); if (updated.value === 0) { const latestResult = this.getRunResult(id); - if (latestResult.isErr()) return latestResult; + 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); @@ -635,7 +635,7 @@ export class WorkflowStore { ); } const runResult = this.getRunResult(id); - if (runResult.isErr()) return runResult; + if (runResult.isErr()) return Result.err(runResult.error); const run = runResult.value; return run ? Result.ok(run) : Result.err(new WorkflowNotFoundError(id)); } From 36da4903aaf49eb335818a544151682301e25d67 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 06:27:08 +0530 Subject: [PATCH 083/132] test(cli): compare canonical Git workspace paths --- src/cli-workspace.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/cli-workspace.test.ts b/src/cli-workspace.test.ts index bbffa9242..a2c4781d9 100644 --- a/src/cli-workspace.test.ts +++ b/src/cli-workspace.test.ts @@ -14,10 +14,15 @@ try { const nested = join(repository, "packages", "app"); mkdirSync(nested, { recursive: true }); execFileSync("git", ["init", "--quiet", repository]); + const gitRoot = execFileSync( + "git", + ["-C", nested, "rev-parse", "--show-toplevel"], + { encoding: "utf8" }, + ).trim(); assert.deepEqual(resolveCliWorkspaceContext({}, nested), { workspaceId: undefined, - workspaceRoot: resolve(repository), + workspaceRoot: resolve(gitRoot), }); mkdirSync(join(repository, "packages", ".devspace")); From 1f6c021ebfa88fbe9c95c4d47457133ded51c46b Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 06:36:57 +0530 Subject: [PATCH 084/132] fix(cli): preserve json tokens inside agent prompts --- src/cli.ts | 7 +++---- src/local-agent-targets.test.ts | 22 ++++++++++++++++++++++ src/local-agent-targets.ts | 6 ++++-- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 45d5c8005..bf634201d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -430,8 +430,7 @@ async function runAgentsTargets(args: string[]): Promise { } async function runAgentsRun(args: string[]): Promise { - const json = args.includes("--json"); - const parsed = parseLocalAgentRunArgs(args.filter((arg) => arg !== "--json")); + const parsed = parseLocalAgentRunArgs(args); const config = loadConfig(); const workspace = resolveCliWorkspaceContext(); @@ -460,7 +459,7 @@ async function runAgentsRun(args: string[]): Promise { model: parsed.model ?? existing.model, effort: parsed.effort ?? existing.effort, } as LocalAgentRecord; - if (json) printJson({ agent: localAgentOutput(running) }); + if (parsed.json) printJson({ agent: localAgentOutput(running) }); else console.log(formatAgentLine(running)); return; } @@ -494,7 +493,7 @@ async function runAgentsRun(args: string[]): Promise { spawnAgentWorker(record.id, promptFile); const running = { ...record, status: "running" } as LocalAgentRecord; - if (json) printJson({ agent: localAgentOutput(running) }); + if (parsed.json) printJson({ agent: localAgentOutput(running) }); else console.log(formatAgentLine(running)); } diff --git a/src/local-agent-targets.test.ts b/src/local-agent-targets.test.ts index 737f970a5..e4b0f534e 100644 --- a/src/local-agent-targets.test.ts +++ b/src/local-agent-targets.test.ts @@ -33,6 +33,7 @@ assert.deepEqual(parseLocalAgentRunArgs(["codex", "hello", "world"]), { prompt: "hello world", model: undefined, effort: undefined, + json: false, }); assert.deepEqual(parseLocalAgentRunArgs(["codex", "--model", "gpt-5.1", "hello"]), { @@ -40,6 +41,7 @@ assert.deepEqual(parseLocalAgentRunArgs(["codex", "--model", "gpt-5.1", "hello"] prompt: "hello", model: "gpt-5.1", effort: undefined, + json: false, }); assert.deepEqual(parseLocalAgentRunArgs(["codex", "--model=gpt-5.1", "hello"]), { @@ -47,6 +49,7 @@ assert.deepEqual(parseLocalAgentRunArgs(["codex", "--model=gpt-5.1", "hello"]), prompt: "hello", model: "gpt-5.1", effort: undefined, + json: false, }); assert.deepEqual(parseLocalAgentRunArgs(["codex", "--effort", "high", "hello"]), { @@ -54,6 +57,7 @@ assert.deepEqual(parseLocalAgentRunArgs(["codex", "--effort", "high", "hello"]), prompt: "hello", model: undefined, effort: "high", + json: false, }); assert.deepEqual(parseLocalAgentRunArgs(["codex", "--effort=high", "hello"]), { @@ -61,6 +65,7 @@ assert.deepEqual(parseLocalAgentRunArgs(["codex", "--effort=high", "hello"]), { prompt: "hello", model: undefined, effort: "high", + json: false, }); // Legacy --thinking alias maps to effort. @@ -69,6 +74,23 @@ assert.deepEqual(parseLocalAgentRunArgs(["codex", "--thinking", "high", "hello"] prompt: "hello", model: undefined, effort: "high", + json: false, +}); + +assert.deepEqual(parseLocalAgentRunArgs(["codex", "explain", "--json", "output"]), { + target: "codex", + prompt: "explain --json output", + model: undefined, + effort: undefined, + json: false, +}); + +assert.deepEqual(parseLocalAgentRunArgs(["codex", "review changes", "--json"]), { + target: "codex", + prompt: "review changes", + model: undefined, + effort: undefined, + json: true, }); assert.throws( diff --git a/src/local-agent-targets.ts b/src/local-agent-targets.ts index c794511b5..2b801aec8 100644 --- a/src/local-agent-targets.ts +++ b/src/local-agent-targets.ts @@ -13,6 +13,7 @@ export interface ParsedLocalAgentRunArgs { prompt: string; model?: string; effort?: string; + json: boolean; } export type LocalAgentTarget = ResolvedLocalAgentExecution; @@ -21,7 +22,8 @@ const USAGE = 'Usage: devspace agents run [--model ] [--effort ] ""'; export function parseLocalAgentRunArgs(args: string[]): ParsedLocalAgentRunArgs { - const [target, ...rest] = args; + const json = args.at(-1) === "--json"; + const [target, ...rest] = json ? args.slice(0, -1) : args; if (!target) { throw new Error(USAGE); } @@ -72,7 +74,7 @@ export function parseLocalAgentRunArgs(args: string[]): ParsedLocalAgentRunArgs throw new Error(USAGE); } - return { target, prompt, model, effort }; + return { target, prompt, model, effort, json }; } export function resolveLocalAgentTarget( From 801feb56876cf8f18fba94ab0a547e74f1c859da Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 06:37:39 +0530 Subject: [PATCH 085/132] fix(cli): include root-scoped workflows in workspace views --- src/workflow-store.test.ts | 14 ++++++++++++-- src/workflow-store.ts | 12 ++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/workflow-store.test.ts b/src/workflow-store.test.ts index 1ee61f89e..1b03d61e0 100644 --- a/src/workflow-store.test.ts +++ b/src/workflow-store.test.ts @@ -179,12 +179,22 @@ try { .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"), - }).map((entry) => entry.id), - [run.id], + }, { statuses: ["completed"] }).map((entry) => entry.id), + [run2.id], ); assert.deepEqual( store diff --git a/src/workflow-store.ts b/src/workflow-store.ts index 96d31b25b..ea40d3dcf 100644 --- a/src/workflow-store.ts +++ b/src/workflow-store.ts @@ -315,6 +315,7 @@ export class WorkflowStore { ): WorkflowRunRecord[] { if (!scope.workspaceId) return this.listRunsForWorkspace(scope.workspaceRoot, options); + 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, @@ -322,9 +323,11 @@ export class WorkflowStore { if (!statuses?.length) { const rows = this.database.sqlite .prepare( - "select * from workflow_runs where workspace_id = ? order by updated_at desc limit ?", + `select * from workflow_runs + where workspace_id = ? or (workspace_id is null and workspace_root = ?) + order by updated_at desc limit ?`, ) - .all(scope.workspaceId, limit) as WorkflowRunRow[]; + .all(scope.workspaceId, root, limit) as WorkflowRunRow[]; return rows.map(rowToRun); } @@ -332,11 +335,12 @@ export class WorkflowStore { const rows = this.database.sqlite .prepare( `select * from workflow_runs - where workspace_id = ? and status in (${placeholders}) + where (workspace_id = ? or (workspace_id is null and workspace_root = ?)) + and status in (${placeholders}) order by updated_at desc limit ?`, ) - .all(scope.workspaceId, ...statuses, limit) as WorkflowRunRow[]; + .all(scope.workspaceId, root, ...statuses, limit) as WorkflowRunRow[]; return rows.map(rowToRun); } From ad95856a0a7ffa2f6185345975f77341b208bf5a Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 06:02:41 +0530 Subject: [PATCH 086/132] refactor(mcp): remove workflow orchestration tools --- src/server.ts | 5 - src/ui/card-types.test.ts | 3 - src/ui/card-types.ts | 20 -- src/ui/icons.ts | 2 - src/ui/tool-display.test.ts | 12 - src/ui/tool-display.ts | 25 -- src/ui/workflow-dashboard.ts | 156 +---------- src/ui/workspace-app.tsx | 129 +-------- src/workflow-tools.ts | 502 ----------------------------------- 9 files changed, 5 insertions(+), 849 deletions(-) delete mode 100644 src/workflow-tools.ts diff --git a/src/server.ts b/src/server.ts index 8a142c271..91df1b362 100644 --- a/src/server.ts +++ b/src/server.ts @@ -47,7 +47,6 @@ import { formatPathForPrompt } from "./skills.js"; import { createWorkspaceStore } from "./workspace-store.js"; import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; import { buildLocalAgentCatalog } from "./local-agent-catalog.js"; -import { registerWorkflowTools } from "./workflow-tools.js"; import { startWorkflowReaper } from "./workflow-lifecycle.js"; import { createWorkflowStore } from "./workflow-store.js"; import { loadActiveWorkflowSummaries } from "./workflow-ui.js"; @@ -1637,10 +1636,6 @@ function createMcpServer( registerCodexProcessTools(server, config, workspaces, processSessions); } - if (config.workflows) { - registerWorkflowTools(server, config, workspaces); - } - return server; } diff --git a/src/ui/card-types.test.ts b/src/ui/card-types.test.ts index 7e261ace7..c080a3536 100644 --- a/src/ui/card-types.test.ts +++ b/src/ui/card-types.test.ts @@ -11,8 +11,6 @@ for (const tool of [ "apply_patch", "exec_command", "write_stdin", - "run_workflow", - "workflow_status", ]) { assert.equal(isToolName(tool), true, `${tool} should be a recognized card tool`); } @@ -29,7 +27,6 @@ assert.equal( true, ); assert.equal(isExpandableCard({ tool: "apply_patch" }), false); -assert.equal(isExpandableCard({ tool: "run_workflow", runId: "wfr_1" }), true); assert.equal( isExpandableCard({ tool: "open_workspace", diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index 73c9ebbf9..cc9017ceb 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -3,8 +3,6 @@ import type { WorkflowRunSummaryView } from "../workflow-ui.js"; export type ToolName = | "open_workspace" - | "run_workflow" - | "workflow_status" | "show_changes" | "apply_patch" | "exec_command" @@ -36,9 +34,6 @@ export interface ToolResultCard { detached?: boolean; managed?: boolean; }; - status?: string; - name?: string; - runId?: string; summary?: Record; files?: Array<{ path?: string; @@ -62,13 +57,6 @@ export interface ToolResultCard { path?: string; }>; activeWorkflows?: WorkflowRunSummaryView[]; - callSummary?: { - reused?: number; - live?: number; - failed?: number; - running?: number; - total?: number; - }; agentProviders?: Array<{ name?: string; model?: { @@ -108,8 +96,6 @@ export interface ToolPayload { export function isToolName(value: unknown): value is ToolName { return ( value === "open_workspace" || - value === "run_workflow" || - value === "workflow_status" || value === "show_changes" || value === "apply_patch" || value === "exec_command" || @@ -152,10 +138,6 @@ export function isReviewTool(tool: ToolName): boolean { return tool === "show_changes"; } -export function isWorkflowTool(tool: ToolName): boolean { - return tool === "run_workflow" || tool === "workflow_status"; -} - export function isToolResultCard(value: unknown): value is Omit { return Boolean(value && typeof value === "object"); } @@ -196,8 +178,6 @@ export function isExpandableCard(card: ToolResultCard): boolean { ); } - if (isWorkflowTool(card.tool)) return Boolean(card.runId); - if (isReviewTool(card.tool)) return Boolean(card.files?.length || card.payload?.patch); if (isPatchTool(card.tool)) return Boolean(card.payload?.patch); diff --git a/src/ui/icons.ts b/src/ui/icons.ts index 11d67b7f4..3bb911460 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -14,7 +14,6 @@ import { Search, SquareTerminal, Terminal, - Workflow, createElement, type IconNode, } from "lucide"; @@ -34,7 +33,6 @@ export const toolIcons = { search: Search, terminal: Terminal, terminalSquare: SquareTerminal, - workflow: Workflow, writeFile: FilePlus, } as const satisfies Record; diff --git a/src/ui/tool-display.test.ts b/src/ui/tool-display.test.ts index 0b8c0883c..16855da37 100644 --- a/src/ui/tool-display.test.ts +++ b/src/ui/tool-display.test.ts @@ -5,8 +5,6 @@ import { getToolDisplay, getToolHeaderSummary } from "./tool-display.js"; const displayCases: Array<[ToolResultCard, { title: string; tone: string }]> = [ [{ tool: "open_workspace", root: "/tmp/project" }, { title: "Opened workspace", tone: "workspace" }], - [{ tool: "run_workflow", runId: "wfr_1", name: "Review" }, { title: "Started workflow", tone: "workflow" }], - [{ tool: "workflow_status", runId: "wfr_1", name: "Review" }, { title: "Workflow status", tone: "workflow" }], [{ tool: "read", path: "src/read.ts" }, { title: "Read file", tone: "read" }], [{ tool: "write", path: "src/write.ts" }, { title: "Wrote file", tone: "write" }], [{ tool: "edit", path: "src/edit.ts" }, { title: "Edited file", tone: "edit" }], @@ -27,7 +25,6 @@ for (const [card, expected] of displayCases) { } assert.equal(getToolDisplay({ tool: "open_workspace", root: "/tmp/project" }).label, "/tmp/project"); -assert.equal(getToolDisplay({ tool: "run_workflow", runId: "wfr_1" }).label, "wfr_1"); assert.equal( getToolDisplay({ tool: "grep", summary: { pattern: "needle", scope: "src" } }).label, "needle in src", @@ -123,15 +120,6 @@ assert.deepEqual( getToolHeaderSummary({ tool: "open_workspace" }), { kind: "empty" }, ); -assert.deepEqual( - getToolHeaderSummary({ - tool: "workflow_status", - status: "running", - callSummary: { running: 2, failed: 1 }, - }), - { kind: "text", text: "running · 2 running · 1 failed" }, -); - function pickDisplay(display: ReturnType) { return { title: display.title, diff --git a/src/ui/tool-display.ts b/src/ui/tool-display.ts index d989a3f88..d7550576f 100644 --- a/src/ui/tool-display.ts +++ b/src/ui/tool-display.ts @@ -3,7 +3,6 @@ import { isPatchTool, isReviewTool, isShellTool, - isWorkflowTool, isWriteTool, summaryNumber, type ToolResultCard, @@ -32,20 +31,6 @@ export function getToolDisplay(card: ToolResultCard): ToolDisplay { label: card.root ?? card.path, tone: "workspace", }; - case "run_workflow": - return { - icon: toolIcons.workflow, - title: card.status === "completed" ? "Workflow completed" : "Started workflow", - label: card.name ?? card.runId, - tone: "workflow", - }; - case "workflow_status": - return { - icon: toolIcons.workflow, - title: "Workflow status", - label: card.name ?? card.runId, - tone: "workflow", - }; case "read": return { icon: toolIcons.readFile, @@ -145,15 +130,6 @@ export function getToolHeaderSummary(card: ToolResultCard): ToolHeaderSummary { return parts.length > 0 ? { kind: "text", text: parts.join(" · ") } : { kind: "empty" }; } - if (isWorkflowTool(card.tool)) { - const parts = [ - card.status, - card.callSummary?.running ? `${card.callSummary.running} running` : undefined, - card.callSummary?.failed ? `${card.callSummary.failed} failed` : undefined, - ].filter((part): part is string => Boolean(part)); - return parts.length > 0 ? { kind: "text", text: parts.join(" · ") } : { kind: "empty" }; - } - if (isShellTool(card.tool)) { const parts = [ countLabel(summaryNumber(summary, "lines"), "line"), @@ -222,4 +198,3 @@ function durationLabel(durationMs: number | undefined): string | undefined { if (durationMs < 1_000) return `${Math.round(durationMs)}ms`; return `${(durationMs / 1_000).toFixed(durationMs < 10_000 ? 1 : 0)}s`; } - diff --git a/src/ui/workflow-dashboard.ts b/src/ui/workflow-dashboard.ts index 08b1e0d40..546801dbf 100644 --- a/src/ui/workflow-dashboard.ts +++ b/src/ui/workflow-dashboard.ts @@ -1,9 +1,4 @@ import type { WorkflowRunSummaryView } from "../workflow-ui.js"; -import type { - WorkflowCallView, - WorkflowProjectView, - WorkflowRunView, -} from "../workflow-view.js"; import type { ToolResultCard } from "./card-types.js"; import { renderIcon, toolIcons } from "./icons.js"; @@ -16,17 +11,16 @@ export interface DashboardDisplayOptions { export function renderWorkspaceDashboard( container: HTMLElement, card: ToolResultCard, - project: WorkflowProjectView | null, display: DashboardDisplayOptions, ): void { const root = node("div", { className: `workspace-dashboard ${display.fullscreen ? "fullscreen" : "inline"}`, }); - const runs = project?.runs ?? card.activeWorkflows ?? []; + const runs = card.activeWorkflows ?? []; root.append( renderDashboardToolbar("Workspace overview", display), - ...(card.activeWorkflows !== undefined || project !== null + ...(card.activeWorkflows !== undefined ? [renderWorkflowSummarySection(runs)] : []), renderAccordion( @@ -114,96 +108,6 @@ export function renderWorkspaceDashboard( container.replaceChildren(root); } -export function renderWorkflowDashboard( - container: HTMLElement, - run: WorkflowRunView | null, - fallback: ToolResultCard, - display: DashboardDisplayOptions, -): void { - const root = node("div", { - className: `workflow-dashboard ${display.fullscreen ? "fullscreen" : "inline"}`, - }); - root.append(renderDashboardToolbar("Workflow monitor", display)); - - if (!run) { - root.append( - node("div", { - className: "dashboard-empty", - text: fallback.runId ? "Loading workflow activity…" : "No workflow run selected.", - }), - ); - container.replaceChildren(root); - return; - } - - const heading = node("section", { className: "workflow-heading" }); - const titleRow = node("div", { className: "workflow-title-row" }); - titleRow.append( - node("span", { className: `workflow-status-dot ${run.status}`, ariaHidden: "true" }), - node("div", { className: "workflow-title-copy" }, [ - node("strong", { text: run.name }), - node("span", { - className: "workflow-subtitle", - text: `${run.status}${run.currentPhase ? ` · ${run.currentPhase}` : ""}`, - }), - ]), - ); - heading.append(titleRow, renderCallCounts(run)); - root.append(heading); - - const phases = node("section", { className: "workflow-phases" }); - for (const phase of run.phases) { - const phaseSection = node("section", { className: "workflow-phase" }); - phaseSection.append(node("h3", { text: phase.title })); - const calls = node("div", { className: "workflow-call-list" }); - for (const call of phase.calls) calls.append(renderCall(call)); - if (phase.calls.length === 0) { - calls.append(node("div", { className: "dashboard-empty", text: "No observed calls in this phase." })); - } - phaseSection.append(calls); - phases.append(phaseSection); - } - if (run.unphasedCalls.length > 0) { - const unphased = node("section", { className: "workflow-phase" }); - unphased.append(node("h3", { text: "Other calls" })); - const calls = node("div", { className: "workflow-call-list" }); - for (const call of run.unphasedCalls) calls.append(renderCall(call)); - unphased.append(calls); - phases.append(unphased); - } - if (run.phases.length === 0 && run.unphasedCalls.length === 0) { - phases.append(node("div", { className: "dashboard-empty", text: "No agent calls observed yet." })); - } - root.append(phases); - - if (run.recentActivity.length > 0) { - const activity = node("section", { className: "workflow-activity" }); - activity.append(node("h3", { text: "Recent activity" })); - for (const event of run.recentActivity.slice(-8).reverse()) { - activity.append( - node("div", { className: "workflow-event" }, [ - node("time", { text: formatTime(event.createdAt) }), - node("span", { - text: `${event.label ?? event.phase ?? event.type.replaceAll("_", " ")}${event.detail ? ` · ${event.detail}` : ""}`, - }), - ]), - ); - } - root.append(activity); - } - - if (run.error) { - root.append( - node("section", { className: "workflow-error" }, [ - node("strong", { text: run.errorKind ?? "Workflow error" }), - node("p", { text: run.error }), - ]), - ); - } - - container.replaceChildren(root); -} - function renderDashboardToolbar( title: string, display: DashboardDisplayOptions, @@ -248,47 +152,6 @@ function renderWorkflowSummarySection( return section; } -function renderCallCounts(run: WorkflowRunView): HTMLElement { - const counts = node("div", { className: "workflow-counts" }); - const values = [ - ["Completed", run.calls.completed], - ["Replayed", run.calls.cached], - ["Running", run.calls.running], - ["Failed", run.calls.failed], - ] as const; - for (const [label, value] of values) { - if (!value) continue; - counts.append(node("span", { text: `${value} ${label.toLowerCase()}` })); - } - if (counts.childElementCount === 0) { - counts.append(node("span", { text: "No agent calls yet" })); - } - return counts; -} - -function renderCall(call: WorkflowCallView): HTMLElement { - const row = node("article", { className: `workflow-call ${call.status}` }); - const main = node("div", { className: "workflow-call-main" }); - main.append( - node("span", { className: `call-status ${call.status}`, text: callGlyph(call.status) }), - node("div", { className: "workflow-call-copy" }, [ - node("strong", { text: call.label ?? `Agent #${call.callIndex}` }), - node("span", { - text: [ - call.model ? `${call.provider}/${call.model}` : call.provider, - call.isolation === "worktree" ? "worktree" : undefined, - call.fromCache ? "replayed" : undefined, - ].filter(Boolean).join(" · "), - }), - ]), - ); - row.append(main); - if (call.error) { - row.append(node("p", { className: "workflow-call-error", text: `${call.errorKind ?? "error"}: ${call.error}` })); - } - return row; -} - function renderAccordion(title: string, open: boolean, content: HTMLElement): HTMLElement { const details = node("details", { className: "workspace-accordion" }) as HTMLDetailsElement; details.open = open; @@ -350,21 +213,6 @@ function summaryCounts(calls: WorkflowRunSummaryView["calls"]): string { return parts.join(" · ") || "no calls yet"; } -function callGlyph(status: WorkflowCallView["status"]): string { - if (status === "completed" || status === "from_cache") return "✓"; - if (status === "failed") return "✕"; - if (status === "cancelled") return "−"; - return "●"; -} - -function formatTime(value: string): string { - return new Date(value).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); -} - function summarizeText(value: string | undefined): string | undefined { if (!value) return undefined; const compact = value.replace(/\s+/g, " ").trim(); diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index 847873726..b0bdeec28 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -5,7 +5,6 @@ import { applyHostStyleVariables, } from "@modelcontextprotocol/ext-apps"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import type { WorkflowProjectView, WorkflowRunView } from "../workflow-view.js"; import { isEditTool, isExpandableCard, @@ -14,7 +13,6 @@ import { isReviewTool, isToolName, isToolResultCard, - isWorkflowTool, isWriteTool, payloadText, type HostContext, @@ -27,10 +25,7 @@ import { getToolHeaderSummary, type ToolDisplay, } from "./tool-display.js"; -import { - renderWorkflowDashboard, - renderWorkspaceDashboard, -} from "./workflow-dashboard.js"; +import { renderWorkspaceDashboard } from "./workflow-dashboard.js"; import "./workspace-app.css"; interface MountedPayload { @@ -53,10 +48,6 @@ let reviewFilesExpanded = false; let errorMessage: string | null = null; let currentPayload: MountedPayload | null = null; let currentPayloadContainer: HTMLElement | null = null; -let workflowProject: WorkflowProjectView | null = null; -let workflowRun: WorkflowRunView | null = null; -let workflowRefreshKey: string | null = null; -let workflowRefreshGeneration = 0; const maybeAppRoot = document.querySelector("#app"); @@ -85,7 +76,6 @@ async function boot(): Promise { const tool = toolNameFromMeta(result); if (!tool || !isToolResultCard(structured)) { - stopWorkflowRefresh(); card = null; expanded = false; reviewFilesExpanded = false; @@ -95,11 +85,8 @@ async function boot(): Promise { } const nextCard = { ...structured, tool }; - stopWorkflowRefresh(); - workflowProject = null; - workflowRun = null; card = nextCard; - expanded = (isReviewTool(tool) || isWorkflowTool(tool)) && isExpandableCard(nextCard); + expanded = isReviewTool(tool) && isExpandableCard(nextCard); reviewFilesExpanded = false; errorMessage = null; render(); @@ -115,7 +102,6 @@ async function boot(): Promise { }; app.onteardown = async () => { - stopWorkflowRefresh(); unmountPayload(); return {}; }; @@ -188,7 +174,6 @@ function render(): void { if (expandable) { button.addEventListener("click", () => { expanded = !expanded; - if (!expanded) stopWorkflowRefresh(); render(); }); } @@ -243,14 +228,7 @@ async function renderPayloadIfNeeded(): Promise { } if (card.tool === "open_workspace") { - renderWorkspaceDashboard(target, card, workflowProject, dashboardDisplayOptions()); - ensureWorkflowRefresh(); - return; - } - - if (isWorkflowTool(card.tool)) { - renderWorkflowDashboard(target, workflowRun, card, dashboardDisplayOptions()); - ensureWorkflowRefresh(); + renderWorkspaceDashboard(target, card, dashboardDisplayOptions()); return; } @@ -355,106 +333,6 @@ async function toggleFullscreen(): Promise { } } -function ensureWorkflowRefresh(): void { - if (!app || !card || !expanded) return; - - const request = card.tool === "open_workspace" && card.workspaceId - ? { - key: `workspace:${card.workspaceId}`, - name: "workspace_workflow_activity", - args: { workspaceId: card.workspaceId }, - kind: "project" as const, - } - : isWorkflowTool(card.tool) && card.runId - ? { - key: `run:${card.runId}`, - name: "workflow_ui_snapshot", - args: { runId: card.runId }, - kind: "run" as const, - } - : null; - - if (!request || workflowRefreshKey === request.key) return; - workflowRefreshKey = request.key; - const generation = ++workflowRefreshGeneration; - void refreshWorkflowLoop(request, generation); -} - -async function refreshWorkflowLoop( - request: { - key: string; - name: string; - args: Record; - kind: "project" | "run"; - }, - generation: number, -): Promise { - let knownVersion = request.kind === "project" - ? workflowProject?.version - : workflowRun?.version; - - while ( - app && - expanded && - workflowRefreshGeneration === generation && - workflowRefreshKey === request.key - ) { - try { - const result = await app.callServerTool({ - name: request.name, - arguments: { - ...request.args, - knownVersion, - waitMs: 20_000, - }, - }); - if ( - workflowRefreshGeneration !== generation || - workflowRefreshKey !== request.key - ) { - return; - } - - if (request.kind === "project") { - const structured = getStructuredContent<{ project?: WorkflowProjectView }>(result); - if (structured?.project) { - workflowProject = structured.project; - knownVersion = structured.project.version; - } - } else { - const structured = getStructuredContent<{ run?: WorkflowRunView }>(result); - if (structured?.run) { - workflowRun = structured.run; - knownVersion = structured.run.version; - } - } - - await renderPayloadIfNeeded(); - if ( - request.kind === "run" && - workflowRun && - ["completed", "failed", "cancelled"].includes(workflowRun.status) - ) { - workflowRefreshKey = null; - return; - } - } catch (refreshError) { - if (workflowRefreshGeneration !== generation) return; - errorMessage = refreshError instanceof Error - ? refreshError.message - : "Unable to refresh workflow activity."; - workflowRefreshKey = null; - await renderPayloadIfNeeded(); - return; - } - } -} - -function stopWorkflowRefresh(): void { - workflowRefreshKey = null; - workflowRefreshGeneration += 1; -} - function unmountPayload(): void { unmountCurrentPayload(); currentPayload = null; @@ -642,4 +520,3 @@ function element( } return node; } - diff --git a/src/workflow-tools.ts b/src/workflow-tools.ts deleted file mode 100644 index e648467af..000000000 --- a/src/workflow-tools.ts +++ /dev/null @@ -1,502 +0,0 @@ -import { fileURLToPath } from "node:url"; -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; -import * as z from "zod/v4"; -import type { ServerConfig } from "./config.js"; -import { jsonValueSchema, parseJsonText, type JsonValue } from "./json-types.js"; -import type { WorkspaceRegistry } from "./workspaces.js"; -import { createWorkflowStore } from "./workflow-store.js"; -import { - WORKFLOW_MCP_YIELD_MS, - WORKFLOW_LIMITS, - type WorkflowEventRecord, - type WorkflowRunRecord, -} from "./workflow-types.js"; -import { cancelWorkflowRun } from "./workflow-lifecycle.js"; -import { - InvalidWorkflowInputError, - isWorkflowOperationError, - serializeWorkflowError, - WorkflowNotFoundError, -} from "./workflow-errors.js"; -import { - loadWorkflowUiCallDetail, - loadWorkflowUiProject, - loadWorkflowUiRun, -} from "./workflow-ui.js"; -import { - launchWorkflowRun, - type LaunchWorkflowSource, -} from "./workflow-launch.js"; -import { resolveWorkflowLiveProviders } from "./workflow-providers.js"; - -const WORKSPACE_APP_URI = "ui://devspace/workspace-app.html"; -const WORKFLOW_UI_WAIT_MAX_MS = 30_000; - -const WORKFLOW_API_CHEATSHEET = ` -Workflow scripts (JS only): - export const meta = { name, description, phases?, defaultProvider?, concurrency? } - agent(prompt, { label?, phase?, schema?, model?, effort?, profile? | provider?, isolation?: 'worktree' }) - parallel(thunks) → Array // barrier; throw → null - pipeline(items, ...stages) // no cross-item barrier - phase(title); log(msg); args - workflow(name | { scriptPath }, args?) // nest depth 1 -Bans: Date.now(), Math.random(), new Date() without args. -No writeMode — teach RO vs write in prompts; isolation contains writes. -`.trim(); - -export function registerWorkflowTools( - server: McpServer, - config: ServerConfig, - workspaces: WorkspaceRegistry, -): void { - if (!config.workflows) return; - - registerAppTool( - server, - "run_workflow", - { - title: "Run workflow", - description: - `Start a DevSpace Dynamic Workflow in an open workspace. Prefer named scripts or short inline scripts. ` + - `Poll with workflow_status until terminal. Cancel with workflow_cancel. ${WORKFLOW_API_CHEATSHEET}`, - inputSchema: { - workspaceId: z.string().describe("Workspace id from open_workspace."), - script: z - .string() - .optional() - .describe("Inline workflow script source (export const meta = …)."), - name: z.string().optional().describe("Named workflow under .devspace/workflows/.js"), - scriptPath: z - .string() - .optional() - .describe("Existing workflow script path. May be combined with resumeFromRunId."), - resumeFromRunId: z.string().optional().describe("Prior run id to resume (new run + cache)."), - args: jsonValueSchema.optional().describe("JSON args passed to script as `args`."), - yieldTimeMs: z - .number() - .int() - .min(0) - .max(WORKFLOW_MCP_YIELD_MS) - .optional() - .describe(`Ms to wait for early completion (default 2000, max ${WORKFLOW_MCP_YIELD_MS}).`), - }, - annotations: { readOnlyHint: false }, - _meta: workflowWidgetMeta(config), - }, - async ({ workspaceId, script, name, scriptPath, resumeFromRunId, args, yieldTimeMs }) => { - const workspace = workspaces.getWorkspace(workspaceId); - const store = createWorkflowStore(config); - try { - const providedSources = [script, name, scriptPath].filter((v) => v !== undefined); - if (providedSources.length > 1 || (providedSources.length === 0 && !resumeFromRunId)) { - throw new InvalidWorkflowInputError({ - code: providedSources.length === 0 ? "missing_source" : "ambiguous_source", - message: - "Provide one of script, name, or scriptPath; resumeFromRunId may accompany that source or reuse the prior script", - }); - } - - const source = buildMcpLaunchSource({ - script, - name, - scriptPath, - resumeFromRunId, - }); - const launched = await launchWorkflowRun({ - store, - config, - workspaceRoot: workspace.root, - workspaceId, - source, - args, - cliEntry: fileURLToPath( - import.meta.url.replace(/workflow-tools\.(ts|js)$/, "cli.$1"), - ), - }); - if (launched.isErr()) { - if (isWorkflowOperationError(launched.error)) return workflowToolError(launched.error); - throw launched.error; - } - - const yieldMs = yieldTimeMs ?? 2_000; - const page = await yieldEvents(store, launched.value.run.id, 0, yieldMs); - return toolResult(page, "run_workflow"); - } catch (error) { - if (isWorkflowOperationError(error)) return workflowToolError(error); - throw error; - } finally { - store.close(); - } - }, - ); - - registerAppTool( - server, - "workflow_status", - { - title: "Workflow status", - description: "Drain events for a workflow run; optional long-poll yield.", - inputSchema: { - runId: z.string(), - sinceSeq: z.number().int().min(0).optional(), - yieldTimeMs: z - .number() - .int() - .min(0) - .max(WORKFLOW_MCP_YIELD_MS) - .optional() - .describe(`Long-poll ms (default 0, max ${WORKFLOW_MCP_YIELD_MS}).`), - }, - annotations: { readOnlyHint: true }, - _meta: workflowWidgetMeta(config), - }, - async ({ runId, sinceSeq, yieldTimeMs }) => { - const store = createWorkflowStore(config); - try { - const runResult = store.getRunResult(runId); - if (runResult.isErr()) throw runResult.error; - if (!runResult.value) throw new WorkflowNotFoundError(runId); - const page = await yieldEvents(store, runId, sinceSeq ?? 0, yieldTimeMs ?? 0); - return toolResult(page, "workflow_status"); - } catch (error) { - if (isWorkflowOperationError(error)) return workflowToolError(error); - throw error; - } finally { - store.close(); - } - }, - ); - - registerAppTool( - server, - "workflow_cancel", - { - title: "Cancel workflow", - description: "Request cooperative cancel of a running workflow.", - inputSchema: { - runId: z.string(), - }, - annotations: { readOnlyHint: false }, - _meta: {}, - }, - async ({ runId }) => { - const store = createWorkflowStore(config); - try { - const latest = await cancelWorkflowRun(store, runId); - return { - content: [{ type: "text" as const, text: JSON.stringify({ runId, status: latest.status }) }], - structuredContent: { runId, status: latest.status }, - }; - } catch (error) { - if (isWorkflowOperationError(error)) return workflowToolError(error); - throw error; - } finally { - store.close(); - } - }, - ); - - if (config.widgets !== "off") { - registerWorkflowUiTools(server, config, workspaces); - } -} - -function registerWorkflowUiTools( - server: McpServer, - config: ServerConfig, - workspaces: WorkspaceRegistry, -): void { - registerAppTool( - server, - "workspace_workflow_activity", - { - title: "Workspace workflow activity", - description: "Read-only workflow activity for the DevSpace app.", - inputSchema: { - workspaceId: z.string(), - knownVersion: z.string().optional(), - waitMs: z.number().int().min(0).max(WORKFLOW_UI_WAIT_MAX_MS).optional(), - }, - annotations: { readOnlyHint: true }, - _meta: appOnlyToolMeta(), - }, - async ({ workspaceId, knownVersion, waitMs }) => { - const workspace = workspaces.getWorkspace(workspaceId); - const store = createWorkflowStore(config); - try { - const project = await waitForProjectSnapshot( - store, - workspace.root, - knownVersion, - waitMs ?? 0, - ); - return appToolResult({ workspaceId, project }); - } finally { - store.close(); - } - }, - ); - - registerAppTool( - server, - "workflow_ui_snapshot", - { - title: "Workflow UI snapshot", - description: "Read-only workflow snapshot for the DevSpace app.", - inputSchema: { - runId: z.string(), - knownVersion: z.string().optional(), - waitMs: z.number().int().min(0).max(WORKFLOW_UI_WAIT_MAX_MS).optional(), - }, - annotations: { readOnlyHint: true }, - _meta: appOnlyToolMeta(), - }, - async ({ runId, knownVersion, waitMs }) => { - const store = createWorkflowStore(config); - try { - const run = await waitForRunSnapshot(store, runId, knownVersion, waitMs ?? 0); - if (!run) throw new WorkflowNotFoundError(runId); - return appToolResult({ run }); - } finally { - store.close(); - } - }, - ); - - registerAppTool( - server, - "workflow_ui_call_detail", - { - title: "Workflow call detail", - description: "Read-only workflow call detail for the DevSpace app.", - inputSchema: { - runId: z.string(), - callIndex: z.number().int().min(0), - }, - annotations: { readOnlyHint: true }, - _meta: appOnlyToolMeta(), - }, - async ({ runId, callIndex }) => { - const store = createWorkflowStore(config); - try { - if (!store.getRun(runId)) throw new WorkflowNotFoundError(runId); - const call = loadWorkflowUiCallDetail(store, runId, callIndex); - if (!call) { - throw new InvalidWorkflowInputError({ - code: "invalid_argument", - message: `Unknown workflow agent call: ${runId}#${callIndex}`, - }); - } - return appToolResult({ call }); - } finally { - store.close(); - } - }, - ); -} - -async function yieldEvents( - store: ReturnType, - runId: string, - sinceSeq: number, - yieldMs: number, -): Promise<{ - run: WorkflowRunRecord; - events: WorkflowEventRecord[]; - nextSeq: number; - hasMore: boolean; - terminal: boolean; - callSummary: ReturnType; -}> { - const deadline = Date.now() + Math.min(yieldMs, WORKFLOW_MCP_YIELD_MS); - let cursor = sinceSeq; - let events: WorkflowEventRecord[] = []; - let hasMore = false; - let terminal = false; - let run = store.getRun(runId)!; - - for (;;) { - const page = store.drainEvents(runId, cursor, WORKFLOW_LIMITS.eventDrainDefault); - events = events.concat(page.events); - cursor = page.nextSeq; - hasMore = page.hasMore; - terminal = page.terminal; - run = page.run; - if (terminal || Date.now() >= deadline) break; - if (hasMore) break; - await sleep(250); - } - - return { - run, - events, - nextSeq: cursor, - hasMore, - terminal, - callSummary: summarizeCalls(store.listAgentCalls(runId)), - }; -} - -function toolResult(page: { - run: WorkflowRunRecord; - events: WorkflowEventRecord[]; - nextSeq: number; - hasMore: boolean; - terminal: boolean; - callSummary: ReturnType; -}, tool: "run_workflow" | "workflow_status") { - const payload = { - runId: page.run.id, - status: page.run.status, - name: page.run.name, - source: page.run.source, - scriptPath: page.run.scriptPath, - scriptHash: page.run.scriptHash, - resumedFromRunId: page.run.resumedFromRunId, - callSummary: page.callSummary, - events: page.events.map((e) => ({ - seq: e.seq, - type: e.type, - phase: e.phase, - label: e.label, - dataJson: e.dataJson, - })), - nextSeq: page.nextSeq, - hasMore: page.hasMore, - result: page.run.resultJson ? safeJson(page.run.resultJson) : undefined, - error: page.run.error, - errorKind: page.run.errorKind, - }; - return { - content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }], - structuredContent: payload, - _meta: { - tool, - card: { - runId: page.run.id, - status: page.run.status, - name: page.run.name, - }, - }, - }; -} - -function workflowWidgetMeta(config: ServerConfig) { - if (config.widgets !== "full") return {}; - return { - ui: { - resourceUri: WORKSPACE_APP_URI, - visibility: ["model"] as const, - }, - }; -} - -function appOnlyToolMeta() { - return { - ui: { - visibility: ["app"] as const, - }, - }; -} - -function appToolResult(structuredContent: Record) { - return { - content: [{ type: "text" as const, text: JSON.stringify(structuredContent) }], - structuredContent, - }; -} - -async function waitForProjectSnapshot( - store: ReturnType, - workspaceRoot: string, - knownVersion: string | undefined, - waitMs: number, -) { - const deadline = Date.now() + Math.min(waitMs, WORKFLOW_UI_WAIT_MAX_MS); - for (;;) { - const project = loadWorkflowUiProject(store, workspaceRoot); - if (knownVersion === undefined || project.version !== knownVersion || Date.now() >= deadline) { - return project; - } - await sleep(250); - } -} - -async function waitForRunSnapshot( - store: ReturnType, - runId: string, - knownVersion: string | undefined, - waitMs: number, -) { - const deadline = Date.now() + Math.min(waitMs, WORKFLOW_UI_WAIT_MAX_MS); - for (;;) { - const run = loadWorkflowUiRun(store, runId); - if (!run || knownVersion === undefined || run.version !== knownVersion || Date.now() >= deadline) { - return run; - } - await sleep(250); - } -} - -function summarizeCalls(calls: ReturnType["listAgentCalls"]>) { - return { - reused: calls.filter((call) => call.fromCache).length, - live: calls.filter((call) => !call.fromCache && call.status === "completed").length, - failed: calls.filter((call) => call.status === "failed").length, - running: calls.filter((call) => call.status === "running").length, - total: calls.length, - }; -} - -function workflowToolError(error: Parameters[0]) { - const payload = { error: serializeWorkflowError(error) }; - return { - content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }], - structuredContent: payload, - isError: true, - }; -} - -function safeJson(text: string): JsonValue { - try { - return parseJsonText(text); - } catch { - return text; - } -} - -function sleep(ms: number): Promise { - return new Promise((r) => setTimeout(r, ms)); -} - -function buildMcpLaunchSource(input: { - script?: string; - name?: string; - scriptPath?: string; - resumeFromRunId?: string; -}): LaunchWorkflowSource { - if (input.resumeFromRunId) { - const override = - input.script !== undefined - ? ({ kind: "inline", script: input.script } as const) - : input.name - ? ({ kind: "named", name: input.name } as const) - : input.scriptPath - ? ({ kind: "file", path: input.scriptPath } as const) - : undefined; - return { kind: "resume", runId: input.resumeFromRunId, override }; - } - if (input.name) return { kind: "named", name: input.name }; - if (input.scriptPath) return { kind: "file", path: input.scriptPath }; - if (input.script !== undefined) return { kind: "inline", script: input.script }; - throw new InvalidWorkflowInputError({ - code: "missing_source", - message: "Provide script, name, scriptPath, or resumeFromRunId", - }); -} - -/** @deprecated Prefer resolveWorkflowLiveProviders from workflow-providers.js */ -export function resolveWorkflowEnabledProviders() { - return resolveWorkflowLiveProviders(); -} From a7b0d27cc92d7997fadaea9da84966e0463e7226 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 06:05:54 +0530 Subject: [PATCH 087/132] refactor(mcp): minimize workspace agent summaries --- package.json | 2 +- src/open-workspace-capabilities.test.ts | 32 ++++++ src/server.ts | 52 ++------- src/ui/card-types.test.ts | 4 - src/ui/card-types.ts | 25 +---- src/ui/workflow-dashboard.ts | 42 +------ src/workflow-summary.test.ts | 56 ++++++++++ src/workflow-summary.ts | 43 +++++++ src/workflow-ui.test.ts | 64 ----------- src/workflow-ui.ts | 142 ------------------------ 10 files changed, 152 insertions(+), 310 deletions(-) create mode 100644 src/workflow-summary.test.ts create mode 100644 src/workflow-summary.ts delete mode 100644 src/workflow-ui.test.ts delete mode 100644 src/workflow-ui.ts diff --git a/package.json b/package.json index 2a433f8f0..3e1a25d2c 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/cli-workspace.test.ts && tsx src/cli-output.test.ts && tsx src/open-workspace-capabilities.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-capabilities.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-resolution.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-lifecycle.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-ui.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-launch.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", + "test": "tsx src/config.test.ts && tsx src/cli-workspace.test.ts && tsx src/cli-output.test.ts && tsx src/open-workspace-capabilities.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-capabilities.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-resolution.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-lifecycle.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-summary.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-launch.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/open-workspace-capabilities.test.ts b/src/open-workspace-capabilities.test.ts index a3093c45c..206665575 100644 --- a/src/open-workspace-capabilities.test.ts +++ b/src/open-workspace-capabilities.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { z } from "zod"; import { loadConfig } from "./config.js"; import { openWorkspaceOutputSchema } from "./server.js"; @@ -39,4 +40,35 @@ assert.equal(workflowsOnly.has("agentProviders"), false); assert.equal(workflowsOnly.has("agents"), false); assert.equal(workflowsOnly.has("activeWorkflows"), true); +const enabledSchema = z.object(openWorkspaceOutputSchema(loadConfig({ + ...baseEnv, + DEVSPACE_SUBAGENTS: "1", + DEVSPACE_WORKFLOWS: "1", +}))); +const parsed = enabledSchema.parse({ + workspaceId: "workspace-1", + root: process.cwd(), + mode: "checkout", + agentsFiles: [], + availableAgentsFiles: [], + skills: [], + agentProviders: ["codex"], + agents: [{ name: "reviewer", description: "Review changes." }], + activeWorkflows: [{ + id: "wfr_1", + name: "Review", + status: "running", + calls: { running: 1, completed: 2, failed: 0 }, + }], + instruction: "Reuse this workspace.", +}); +assert.deepEqual(parsed.agentProviders, ["codex"]); +assert.deepEqual(parsed.agents, [{ name: "reviewer", description: "Review changes." }]); +assert.deepEqual(parsed.activeWorkflows?.[0]?.calls, { + running: 1, + completed: 2, + failed: 0, +}); +assert.equal("skillDiagnostics" in parsed, false); + console.log("open-workspace-capabilities.test.ts: ok"); diff --git a/src/server.ts b/src/server.ts index 91df1b362..a7e3bbb75 100644 --- a/src/server.ts +++ b/src/server.ts @@ -49,7 +49,7 @@ import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; import { buildLocalAgentCatalog } from "./local-agent-catalog.js"; import { startWorkflowReaper } from "./workflow-lifecycle.js"; import { createWorkflowStore } from "./workflow-store.js"; -import { loadActiveWorkflowSummaries } from "./workflow-ui.js"; +import { loadActiveWorkflowSummaries } from "./workflow-summary.js"; import { formatLocalAgentProviderAvailabilitySummary, getLocalAgentProviderAvailabilitySnapshot, @@ -204,17 +204,6 @@ function serverInstructions(config: ServerConfig): string { return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, and shell tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${showChangesInstruction}`; } -function formatVisibleAgent(agent: { - name: string; - provider: string; - model?: string; - effort?: string; -}): string { - const model = agent.model ? `, model ${agent.model}` : ""; - const effort = agent.effort ? `, effort ${agent.effort}` : ""; - return `${agent.name} (${agent.provider}${model}${effort})`; -} - function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { return { result: z @@ -240,22 +229,6 @@ const workspaceAgentsFileOutputSchema = z.object({ const workspaceLocalAgentOutputSchema = z.object({ name: z.string(), description: z.string(), - provider: z.string(), - model: z.string().optional(), - effort: z.string().optional(), -}); - -const workspaceLocalAgentProviderOutputSchema = z.object({ - name: z.string(), - model: z.object({ - supported: z.boolean(), - discovery: z.enum(["provider_static", "model_dependent", "session_dynamic"]), - }), - effort: z.object({ - supported: z.boolean(), - semantics: z.enum(["reasoning_effort", "thinking_level", "model_variant"]), - discovery: z.enum(["provider_static", "model_dependent", "session_dynamic"]), - }), }); export function openWorkspaceOutputSchema(config: ServerConfig): z.ZodRawShape { @@ -279,11 +252,10 @@ export function openWorkspaceOutputSchema(config: ServerConfig): z.ZodRawShape { skills: z.array(workspaceSkillOutputSchema), ...(config.subagents ? { - agentProviders: z.array(workspaceLocalAgentProviderOutputSchema), + agentProviders: z.array(z.string()), agents: z.array(workspaceLocalAgentOutputSchema), } : {}), - skillDiagnostics: z.array(z.unknown()), ...(config.workflows ? { activeWorkflows: z.array(workflowRunSummaryOutputSchema) } : {}), @@ -298,19 +270,14 @@ const workspaceAvailableAgentsFileOutputSchema = z.object({ const workflowCallCountsOutputSchema = z.object({ running: z.number(), completed: z.number(), - cached: z.number(), failed: z.number(), - cancelled: z.number(), - observed: z.number(), }); const workflowRunSummaryOutputSchema = z.object({ id: z.string(), name: z.string(), - status: z.enum(["starting", "running", "completed", "failed", "cancelled"]), - currentPhase: z.string().optional(), + status: z.enum(["starting", "running"]), calls: workflowCallCountsOutputSchema, - updatedAt: z.string(), }); const reviewFileOutputSchema = z.object({ @@ -830,8 +797,11 @@ function createMcpServer( const agentCatalog = config.subagents ? buildLocalAgentCatalog(workspace.agentProfiles, localAgentProviders) : undefined; - const visibleAgentProviders = agentCatalog?.providers ?? []; - const visibleAgents = agentCatalog?.profiles ?? []; + const visibleAgentProviders = agentCatalog?.providers.map((provider) => provider.name) ?? []; + const visibleAgents = agentCatalog?.profiles.map((agent) => ({ + name: agent.name, + description: agent.description, + })) ?? []; const loadedAgentsFiles = agentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), content: file.content, @@ -869,10 +839,10 @@ function createMcpServer( ? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}` : undefined, visibleAgentProviders.length > 0 - ? `Available subagent providers: ${visibleAgentProviders.map((provider) => provider.name).join(", ")}` + ? `Available subagent providers: ${visibleAgentProviders.join(", ")}` : undefined, visibleAgents.length > 0 - ? `Available subagent profiles: ${visibleAgents.map(formatVisibleAgent).join(", ")}` + ? `Available subagent profiles: ${visibleAgents.map((agent) => `${agent.name} — ${agent.description}`).join(", ")}` : undefined, instruction, ].filter(Boolean).join("\n"), @@ -908,7 +878,6 @@ function createMcpServer( agents: visibleAgents.length, } : {}), - skillDiagnostics: workspace.skillDiagnostics.length, }, }, }, @@ -928,7 +897,6 @@ function createMcpServer( agents: visibleAgents, } : {}), - skillDiagnostics: workspace.skillDiagnostics, instruction, }, }; diff --git a/src/ui/card-types.test.ts b/src/ui/card-types.test.ts index c080a3536..7738e445f 100644 --- a/src/ui/card-types.test.ts +++ b/src/ui/card-types.test.ts @@ -38,12 +38,8 @@ assert.equal( calls: { running: 1, completed: 0, - cached: 0, failed: 0, - cancelled: 0, - observed: 1, }, - updatedAt: "2026-07-26T00:00:00.000Z", }, ], }), diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index cc9017ceb..160bc53db 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -1,5 +1,5 @@ import type { App } from "@modelcontextprotocol/ext-apps"; -import type { WorkflowRunSummaryView } from "../workflow-ui.js"; +import type { ActiveWorkflowSummary } from "../workflow-summary.js"; export type ToolName = | "open_workspace" @@ -56,27 +56,12 @@ export interface ToolResultCard { description?: string; path?: string; }>; - activeWorkflows?: WorkflowRunSummaryView[]; - agentProviders?: Array<{ - name?: string; - model?: { - supported?: boolean; - discovery?: string; - }; - effort?: { - supported?: boolean; - semantics?: string; - discovery?: string; - }; - }>; + activeWorkflows?: ActiveWorkflowSummary[]; + agentProviders?: string[]; agents?: Array<{ name?: string; description?: string; - provider?: string; - model?: string; - effort?: string; }>; - skillDiagnostics?: unknown[]; instruction?: string; } @@ -167,14 +152,12 @@ export function isExpandableCard(card: ToolResultCard): boolean { return ( Number(card.summary?.agentsFiles ?? 0) > 0 || Number(card.summary?.skills ?? 0) > 0 || - Number(card.summary?.skillDiagnostics ?? 0) > 0 || Boolean(card.agentsFiles?.length) || Boolean(card.availableAgentsFiles?.length) || Boolean(card.skills?.length) || Boolean(card.activeWorkflows?.length) || Boolean(card.agentProviders?.length) || - Boolean(card.agents?.length) || - Boolean(card.skillDiagnostics?.length) + Boolean(card.agents?.length) ); } diff --git a/src/ui/workflow-dashboard.ts b/src/ui/workflow-dashboard.ts index 546801dbf..321e96ac2 100644 --- a/src/ui/workflow-dashboard.ts +++ b/src/ui/workflow-dashboard.ts @@ -1,4 +1,4 @@ -import type { WorkflowRunSummaryView } from "../workflow-ui.js"; +import type { ActiveWorkflowSummary } from "../workflow-summary.js"; import type { ToolResultCard } from "./card-types.js"; import { renderIcon, toolIcons } from "./icons.js"; @@ -80,24 +80,12 @@ export function renderWorkspaceDashboard( renderList( card.agents.map((agent) => ({ title: agent.name ?? "Unnamed profile", - description: [agent.provider, agent.model, agent.effort].filter(Boolean).join(" · "), - meta: agent.description, + description: agent.description, })), "No agent profiles loaded.", ), )] : []), - renderAccordion( - `Warnings · ${card.skillDiagnostics?.length ?? 0}`, - false, - renderList( - card.skillDiagnostics?.map((diagnostic, index) => ({ - title: `Diagnostic ${index + 1}`, - description: summarizeDiagnostic(diagnostic), - })) ?? [], - "No workspace warnings.", - ), - ), renderAccordion( "Model handoff", false, @@ -128,7 +116,7 @@ function renderDashboardToolbar( } function renderWorkflowSummarySection( - runs: Array, + runs: ActiveWorkflowSummary[], ): HTMLElement { const section = node("section", { className: "active-workflows" }); section.append(node("h3", { text: `Active workflows · ${runs.length}` })); @@ -143,7 +131,7 @@ function renderWorkflowSummarySection( node("div", { className: "active-workflow-copy" }, [ node("strong", { text: run.name }), node("span", { - text: `${run.currentPhase ?? run.status} · ${summaryCounts(run.calls)}`, + text: `${run.status} · ${summaryCounts(run.calls)}`, }), ]), ); @@ -169,15 +157,7 @@ function renderKeyValues(entries: Array<[string, string]>): HTMLElement { function renderProviderList(card: ToolResultCard): HTMLElement { return renderList( - card.agentProviders?.map((provider) => ({ - title: provider.name ?? "Unknown provider", - description: [ - provider.model?.supported ? `model: ${provider.model.discovery ?? "supported"}` : undefined, - provider.effort?.supported - ? `effort: ${provider.effort.semantics ?? "supported"} (${provider.effort.discovery ?? "unknown"})` - : undefined, - ].filter(Boolean).join(" · "), - })) ?? [], + card.agentProviders?.map((provider) => ({ title: provider })) ?? [], "No subagent providers exposed.", ); } @@ -203,10 +183,9 @@ function renderList( return list; } -function summaryCounts(calls: WorkflowRunSummaryView["calls"]): string { +function summaryCounts(calls: ActiveWorkflowSummary["calls"]): string { const parts = [ calls.completed ? `${calls.completed} done` : undefined, - calls.cached ? `${calls.cached} replayed` : undefined, calls.running ? `${calls.running} running` : undefined, calls.failed ? `${calls.failed} failed` : undefined, ].filter((part): part is string => Boolean(part)); @@ -219,15 +198,6 @@ function summarizeText(value: string | undefined): string | undefined { return compact.length > 140 ? `${compact.slice(0, 139)}…` : compact; } -function summarizeDiagnostic(value: unknown): string { - if (typeof value === "string") return value; - try { - return JSON.stringify(value); - } catch { - return "Unserializable diagnostic"; - } -} - function stringValue(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } diff --git a/src/workflow-summary.test.ts b/src/workflow-summary.test.ts new file mode 100644 index 000000000..0ed8462d0 --- /dev/null +++ b/src/workflow-summary.test.ts @@ -0,0 +1,56 @@ +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.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, 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 000000000..485eefc20 --- /dev/null +++ b/src/workflow-summary.ts @@ -0,0 +1,43 @@ +import type { 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, + workspaceRoot: string, +): ActiveWorkflowSummary[] { + return store + .listRunsForWorkspace(workspaceRoot, { + 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-ui.test.ts b/src/workflow-ui.test.ts deleted file mode 100644 index baf286561..000000000 --- a/src/workflow-ui.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -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, - loadWorkflowUiCallDetail, - loadWorkflowUiProject, - loadWorkflowUiRun, -} from "./workflow-ui.js"; - -const root = mkdtempSync(join(tmpdir(), "devspace-workflow-ui-test-")); -const store = new WorkflowStore(root); - -try { - const workspaceRoot = join(root, "project"); - const run = store.createRun({ - name: "UI run", - source: "named", - scriptPath: join(root, "run.js"), - scriptHash: "abc", - workspaceRoot, - }); - store.claimRun(run.id, process.pid); - store.appendEvent({ - runId: run.id, - type: "phase_started", - phase: "Review", - data: { title: "Review" }, - }); - store.startAgentCall({ - runId: run.id, - callIndex: 0, - cacheKey: "key", - prompt: "Review auth", - schemaJson: JSON.stringify({ type: "object" }), - provider: "codex", - label: "Auth review", - phase: "Review", - isolation: "worktree", - worktreePath: join(root, "wt"), - }); - - const summaries = loadActiveWorkflowSummaries(store, workspaceRoot); - assert.equal(summaries[0]?.id, run.id); - assert.equal(summaries[0]?.currentPhase, "Review"); - assert.equal(summaries[0]?.calls.running, 1); - - const project = loadWorkflowUiProject(store, workspaceRoot); - assert.equal(project.runs[0]?.phases[0]?.title, "Review"); - assert.equal(loadWorkflowUiRun(store, run.id)?.name, "UI run"); - - const detail = loadWorkflowUiCallDetail(store, run.id, 0); - assert.equal(detail?.prompt, "Review auth"); - assert.deepEqual(detail?.schema, { type: "object" }); - assert.equal(detail?.worktreePath, join(root, "wt")); - assert.equal(loadWorkflowUiCallDetail(store, run.id, 99), undefined); -} finally { - store.close(); - rmSync(root, { recursive: true, force: true }); -} - -console.log("workflow-ui.test.ts: ok"); diff --git a/src/workflow-ui.ts b/src/workflow-ui.ts deleted file mode 100644 index a2c4b81e9..000000000 --- a/src/workflow-ui.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { JsonValue } from "./json-types.js"; -import type { WorkflowStore } from "./workflow-store.js"; -import { - ACTIVE_WORKFLOW_STATUSES, - buildWorkflowRunView, - loadWorkflowProjectView, - type WorkflowCallCounts, - type WorkflowProjectView, - type WorkflowRunView, -} from "./workflow-view.js"; - -export interface WorkflowRunSummaryView { - id: string; - name: string; - status: WorkflowRunView["status"]; - currentPhase?: string; - calls: WorkflowCallCounts; - updatedAt: string; -} - -export interface WorkflowCallDetailView { - runId: string; - callIndex: number; - status: string; - provider: string; - model?: string; - effort?: string; - label?: string; - phase?: string; - prompt: string; - schema?: JsonValue | string; - responseText?: string; - structured?: JsonValue | string; - error?: string; - errorKind?: string; - providerSessionId?: string; - isolation: "shared" | "worktree"; - worktreePath?: string; - dirty?: boolean; - fromCache: boolean; - replayMatch?: "same_index"; - replayedFromRunId?: string; - replayedFromCallIndex?: number; - replayReason?: string; - createdAt: string; - startedAt?: string; - completedAt?: string; - updatedAt: string; -} - -export function loadActiveWorkflowSummaries( - store: WorkflowStore, - workspaceRoot: string, -): WorkflowRunSummaryView[] { - return loadWorkflowProjectView(store, workspaceRoot, { - statuses: [...ACTIVE_WORKFLOW_STATUSES], - limit: 50, - eventLimit: 50, - }).runs.map(summarizeWorkflowRun); -} - -export function loadWorkflowUiProject( - store: WorkflowStore, - workspaceRoot: string, -): WorkflowProjectView { - return loadWorkflowProjectView(store, workspaceRoot, { - statuses: [...ACTIVE_WORKFLOW_STATUSES], - limit: 50, - eventLimit: 100, - }); -} - -export function loadWorkflowUiRun( - store: WorkflowStore, - runId: string, -): WorkflowRunView | undefined { - const run = store.getRun(runId); - if (!run) return undefined; - return buildWorkflowRunView( - run, - store.listAgentCalls(run.id), - store.listEvents(run.id, 100), - ); -} - -export function loadWorkflowUiCallDetail( - store: WorkflowStore, - runId: string, - callIndex: number, -): WorkflowCallDetailView | undefined { - const call = store.getAgentCall(runId, callIndex); - if (!call) return undefined; - return { - runId, - callIndex, - status: call.status, - provider: call.provider, - model: call.model, - effort: call.effort, - label: call.label, - phase: call.phase, - prompt: call.prompt, - schema: parseStoredJson(call.schemaJson), - responseText: call.responseText, - structured: parseStoredJson(call.structuredJson), - error: call.error, - errorKind: call.errorKind, - providerSessionId: call.providerSessionId, - isolation: call.isolation, - worktreePath: call.worktreePath, - dirty: call.dirty, - fromCache: call.fromCache, - replayMatch: call.replayMatch, - replayedFromRunId: call.replayedFromRunId, - replayedFromCallIndex: call.replayedFromCallIndex, - replayReason: call.replayReason, - createdAt: call.createdAt, - startedAt: call.startedAt, - completedAt: call.completedAt, - updatedAt: call.updatedAt, - }; -} - -export function summarizeWorkflowRun(run: WorkflowRunView): WorkflowRunSummaryView { - return { - id: run.id, - name: run.name, - status: run.status, - currentPhase: run.currentPhase, - calls: run.calls, - updatedAt: run.updatedAt, - }; -} - -function parseStoredJson(value: string | undefined): JsonValue | string | undefined { - if (value === undefined) return undefined; - try { - return JSON.parse(value) as JsonValue; - } catch { - return value; - } -} From 6e7fe724ed8b9df0b6454dab23dfef81b538a511 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 06:24:00 +0530 Subject: [PATCH 088/132] fix(mcp): scope workflow summaries by workspace --- src/open-workspace-capabilities.test.ts | 11 ++++++----- src/server.ts | 5 ++++- src/workflow-summary.test.ts | 10 +++++++++- src/workflow-summary.ts | 6 +++--- 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/open-workspace-capabilities.test.ts b/src/open-workspace-capabilities.test.ts index 206665575..a2e9b2e3a 100644 --- a/src/open-workspace-capabilities.test.ts +++ b/src/open-workspace-capabilities.test.ts @@ -64,11 +64,12 @@ const parsed = enabledSchema.parse({ }); assert.deepEqual(parsed.agentProviders, ["codex"]); assert.deepEqual(parsed.agents, [{ name: "reviewer", description: "Review changes." }]); -assert.deepEqual(parsed.activeWorkflows?.[0]?.calls, { - running: 1, - completed: 2, - failed: 0, -}); +assert.deepEqual(parsed.activeWorkflows, [{ + id: "wfr_1", + name: "Review", + status: "running", + calls: { running: 1, completed: 2, failed: 0 }, +}]); assert.equal("skillDiagnostics" in parsed, false); console.log("open-workspace-capabilities.test.ts: ok"); diff --git a/src/server.ts b/src/server.ts index a7e3bbb75..5248e0b49 100644 --- a/src/server.ts +++ b/src/server.ts @@ -813,7 +813,10 @@ function createMcpServer( ? (() => { const workflowStore = createWorkflowStore(config); try { - return loadActiveWorkflowSummaries(workflowStore, workspace.root); + return loadActiveWorkflowSummaries(workflowStore, { + workspaceId: workspace.id, + workspaceRoot: workspace.root, + }); } finally { workflowStore.close(); } diff --git a/src/workflow-summary.test.ts b/src/workflow-summary.test.ts index 0ed8462d0..3a6e9324b 100644 --- a/src/workflow-summary.test.ts +++ b/src/workflow-summary.test.ts @@ -16,6 +16,7 @@ try { scriptPath: join(root, "run.js"), scriptHash: "abc", workspaceRoot, + workspaceId: "workspace-1", }); store.claimRun(run.id, process.pid); store.startAgentCall({ @@ -40,7 +41,10 @@ try { responseText: "done", }); - assert.deepEqual(loadActiveWorkflowSummaries(store, workspaceRoot), [ + assert.deepEqual(loadActiveWorkflowSummaries(store, { + workspaceId: "workspace-1", + workspaceRoot, + }), [ { id: run.id, name: "Review", @@ -48,6 +52,10 @@ try { calls: { running: 1, completed: 1, failed: 0 }, }, ]); + assert.deepEqual(loadActiveWorkflowSummaries(store, { + workspaceId: "workspace-2", + workspaceRoot, + }), []); } finally { store.close(); rmSync(root, { recursive: true, force: true }); diff --git a/src/workflow-summary.ts b/src/workflow-summary.ts index 485eefc20..eb41b58f5 100644 --- a/src/workflow-summary.ts +++ b/src/workflow-summary.ts @@ -1,4 +1,4 @@ -import type { WorkflowStore } from "./workflow-store.js"; +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[]; @@ -17,10 +17,10 @@ export interface ActiveWorkflowSummary { export function loadActiveWorkflowSummaries( store: WorkflowStore, - workspaceRoot: string, + scope: WorkflowRunScope, ): ActiveWorkflowSummary[] { return store - .listRunsForWorkspace(workspaceRoot, { + .listRunsForScope(scope, { statuses: [...ACTIVE_WORKFLOW_STATUSES], limit: 50, }) From 7d602ea22f994abd850894f9135a2c0d3f05273b Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 06:38:07 +0530 Subject: [PATCH 089/132] test(mcp): include standalone workflows in summaries --- src/workflow-summary.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/workflow-summary.test.ts b/src/workflow-summary.test.ts index 3a6e9324b..7bf731593 100644 --- a/src/workflow-summary.test.ts +++ b/src/workflow-summary.test.ts @@ -16,7 +16,14 @@ try { scriptPath: join(root, "run.js"), scriptHash: "abc", workspaceRoot, - workspaceId: "workspace-1", + }); + 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({ @@ -52,10 +59,6 @@ try { calls: { running: 1, completed: 1, failed: 0 }, }, ]); - assert.deepEqual(loadActiveWorkflowSummaries(store, { - workspaceId: "workspace-2", - workspaceRoot, - }), []); } finally { store.close(); rmSync(root, { recursive: true, force: true }); From e01dd87585e2d4a0504564117ec415eacd8e45d1 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 06:07:51 +0530 Subject: [PATCH 090/132] docs(skill): teach CLI subagent delegation --- skills/subagents/SKILL.md | 64 ++++++++++++------------- skills/subagents/references/claude.md | 20 -------- skills/subagents/references/codex.md | 19 -------- skills/subagents/references/copilot.md | 12 ----- skills/subagents/references/cursor.md | 12 ----- skills/subagents/references/opencode.md | 12 ----- skills/subagents/references/pi.md | 20 -------- 7 files changed, 30 insertions(+), 129 deletions(-) delete mode 100644 skills/subagents/references/claude.md delete mode 100644 skills/subagents/references/codex.md delete mode 100644 skills/subagents/references/copilot.md delete mode 100644 skills/subagents/references/cursor.md delete mode 100644 skills/subagents/references/opencode.md delete mode 100644 skills/subagents/references/pi.md diff --git a/skills/subagents/SKILL.md b/skills/subagents/SKILL.md index caa4faffe..67f69fffe 100644 --- a/skills/subagents/SKILL.md +++ b/skills/subagents/SKILL.md @@ -1,58 +1,54 @@ --- name: subagents -description: Delegate focused work to isolated DevSpace coding agents. +description: Delegate focused coding, research, review, or verification work to a bounded DevSpace subagent. Use for one independent task, a specialist perspective, or a follow-up with the same worker; use Dynamic Workflows instead for programmed fan-out or multiple dependent stages. --- -Each subagent is headless, has its own context window, cannot see the parent conversation, cannot ask the user, and cannot spawn subagents or workflows. Give every child a self-contained prompt with paths, constraints, and the expected report. +# DevSpace subagents + +Use the DevSpace CLI through the host's shell or process tool. Run commands from the project the subagent should work on. DevSpace scopes sessions to the host workspace when supplied, otherwise to the current Git repository or project directory. ## Choose a target -Prefer a configured profile that matches the task. Use a raw provider when the -user explicitly names that harness or no profile fits. Use target information -already available in the current host. When the choices are not known, run: +Discover usable targets instead of guessing names: ```bash -devspace agents targets +devspace agents targets --json ``` -Do not guess profile names or provider identifiers. +Prefer a configured profile whose description matches the task. Use a provider target when no profile fits or the user requests that provider. Unavailable providers are omitted. -## Write the brief +Profiles carry their own provider, instructions, model, and effort defaults. Only pass `--model` or `--effort` when the user supplied an exact value or the value is already known to be valid for that target. -Describe the task directly. Include decisions and constraints that exist only -in the parent conversation. Mention relevant paths or scope when useful. Do not -repeat project instructions that the child can discover from the repository. +## Start work -## Run and continue +Give the child a self-contained brief. Include the objective, relevant paths, constraints, decisions from the parent conversation, and the expected result. A child cannot see the parent conversation or ask the user for missing context. ```bash -devspace agents targets [--json] -devspace agents run "" -devspace agents show -devspace agents run "" -devspace agents ls +devspace agents run "" --json +devspace agents run --model --effort "" --json ``` -`targets` lists currently usable profiles and providers. `run` with a profile -or provider starts a child and returns its id. `show` reads its latest status -and response. `run` with an existing id continues the same child session. `ls` -lists sessions for the current project. - -Do not invoke provider CLIs directly; use `devspace agents` so DevSpace keeps -session and provider handling consistent. - -## Model and effort overrides +The result contains an agent `id` and current status. Execution continues independently, so retain the id. -Normally omit `--model` and `--effort`. When an exact override is needed, read -`references/.md` first. Do not guess values or transfer an effort -name between providers merely because both use the same word. +## Inspect and continue ```bash -devspace agents run --model --effort "" +devspace agents show --json +devspace agents run "" --json +devspace agents ls --json ``` -## Direct subagent or workflow +- `show` returns the current status and includes the response or error when available. +- `run ` continues the same agent session with a new prompt. +- `ls` returns sessions belonging to the current project. + +Poll `show --json` until the status is terminal. Use a continuation only when the same context is valuable; start a new subagent for independent work. + +## Good uses + +- Review a change for correctness, security, or test gaps. +- Investigate a bounded part of a codebase and report findings. +- Implement one isolated feature with clear acceptance criteria. +- Run a focused verification pass after another agent's work. -Use a direct subagent for one focused delegation or a follow-up with the same -child. Use a dynamic workflow when the task needs programmed fan-out, stages, -branching, nesting, or replay. +Use a Dynamic Workflow when the task needs several agents, explicit phases, fan-out, pipelines, structured aggregation, or resumable orchestration. diff --git a/skills/subagents/references/claude.md b/skills/subagents/references/claude.md deleted file mode 100644 index d1d50141a..000000000 --- a/skills/subagents/references/claude.md +++ /dev/null @@ -1,20 +0,0 @@ -# Claude overrides - -DevSpace passes `--model` to the Claude Agent SDK. When `--effort` is present, -DevSpace passes the SDK effort value with adaptive thinking enabled. - -The SDK effort vocabulary is: - -- `low` -- `medium` -- `high` -- `xhigh` -- `max` - -Support is model-dependent. Some Claude models expose only part of this set or -do not support the effort option. Prefer configured defaults and omit an -override when the selected model's capability is unknown. - -```bash -devspace agents run claude --model --effort "" -``` diff --git a/skills/subagents/references/codex.md b/skills/subagents/references/codex.md deleted file mode 100644 index ecc97d4c4..000000000 --- a/skills/subagents/references/codex.md +++ /dev/null @@ -1,19 +0,0 @@ -# Codex overrides - -DevSpace passes `--model` to the Codex SDK and maps `--effort` to model -reasoning effort. - -The SDK accepts these effort labels: - -- `minimal` -- `low` -- `medium` -- `high` -- `xhigh` - -The selected model may support only a subset. Prefer the profile or provider -default. Omit `--effort` when the exact model capability is unknown. - -```bash -devspace agents run codex --model --effort "" -``` diff --git a/skills/subagents/references/copilot.md b/skills/subagents/references/copilot.md deleted file mode 100644 index a23a9c556..000000000 --- a/skills/subagents/references/copilot.md +++ /dev/null @@ -1,12 +0,0 @@ -# Copilot overrides - -DevSpace connects to Copilot through ACP. `--model` selects the ACP `model` -option and `--effort` selects the ACP `thought_level` option. - -Both option sets are announced by the running Copilot ACP session and may vary -by version or account. Do not invent a value. Omit the override unless the user -provided an exact value known to that Copilot installation. - -```bash -devspace agents run copilot --model --effort "" -``` diff --git a/skills/subagents/references/cursor.md b/skills/subagents/references/cursor.md deleted file mode 100644 index 09a7a1674..000000000 --- a/skills/subagents/references/cursor.md +++ /dev/null @@ -1,12 +0,0 @@ -# Cursor overrides - -DevSpace connects to Cursor through ACP. `--model` selects the ACP `model` -option and `--effort` selects the ACP `thought_level` option. - -Both option sets are announced by the running Cursor ACP session and may vary -by version or account. Do not invent a value. Omit the override unless the user -provided an exact value known to that Cursor installation. - -```bash -devspace agents run cursor --model --effort "" -``` diff --git a/skills/subagents/references/opencode.md b/skills/subagents/references/opencode.md deleted file mode 100644 index ef0ab01d0..000000000 --- a/skills/subagents/references/opencode.md +++ /dev/null @@ -1,12 +0,0 @@ -# OpenCode overrides - -DevSpace passes `--model` to OpenCode. A model may be written as -`/` when the OpenCode provider id is needed. - -DevSpace maps `--effort` to the OpenCode model `variant` field. Variant names -are model-specific; there is no safe global effort list. Omit `--effort` unless -the exact variant is already known from the user's configuration or request. - -```bash -devspace agents run opencode --model --effort "" -``` diff --git a/skills/subagents/references/pi.md b/skills/subagents/references/pi.md deleted file mode 100644 index 6953eaf5b..000000000 --- a/skills/subagents/references/pi.md +++ /dev/null @@ -1,20 +0,0 @@ -# Pi overrides - -DevSpace passes `--model` to Pi and maps `--effort` to Pi's native -`--thinking` option. - -Pi accepts these thinking labels: - -- `off` -- `minimal` -- `low` -- `medium` -- `high` -- `xhigh` - -Pi applies model-specific capability rules, so a selected model may expose or -honor only a subset. Prefer the profile or provider default when uncertain. - -```bash -devspace agents run pi --model --effort "" -``` From ee2693ca227ec78781de88b7b3492ba5d792ccfb Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 06:08:29 +0530 Subject: [PATCH 091/132] docs(skill): teach CLI workflow orchestration --- skills/dynamic-workflows/SKILL.md | 180 ++++++++++-------------------- 1 file changed, 58 insertions(+), 122 deletions(-) diff --git a/skills/dynamic-workflows/SKILL.md b/skills/dynamic-workflows/SKILL.md index 49487cbc6..e328009bb 100644 --- a/skills/dynamic-workflows/SKILL.md +++ b/skills/dynamic-workflows/SKILL.md @@ -1,167 +1,103 @@ --- name: dynamic-workflows -description: Orchestrate multi-agent coding workflows via DevSpace Dynamic Workflows (CLI or MCP). +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. --- -# Dynamic Workflows +# DevSpace Dynamic Workflows -Use this skill when the user wants multi-step, multi-agent orchestration — fan-out -review, migrate-and-verify, research panels — **not** a single subagent turn. +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. -## Entry points +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`. -| Host | Surface | -|---|---| -| Coding agent (Claude Code, Codex, pi, …) | CLI + this skill | -| ChatGPT / MCP client | MCP tools `run_workflow` / `workflow_status` / `workflow_cancel` | +## Run and inspect ```bash -devspace workflow run --file path/to/script.js [--arg k=v]... [--follow] -devspace workflow run --script-path path/to/script.js [--resume ] [--follow] -devspace workflow run --name review-auth [--follow] -devspace workflow run --resume -devspace workflow status [--follow] -devspace workflow cancel -devspace workflow ls -devspace workflow calls -devspace workflow call -devspace workflow tui [runId] +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 ``` -Project named scripts live under `.devspace/workflows/.js`. +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. -## Script shape +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: 'Fan-out review of auth changes', + description: 'Review auth changes from two perspectives', phases: [{ title: 'Review' }, { title: 'Synthesize' }], - // optional DevSpace: - // defaultProvider: 'codex', - // concurrency: 4, + concurrency: 2, } phase('Review') const findings = await parallel([ - () => agent('Review for correctness…', { label: 'correctness' }), - () => agent('Review for security…', { label: 'security' }), + () => 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: ${JSON.stringify(findings)}`) -return { summary, findings } -``` -### Primitives +phase('Synthesize') +const summary = await agent( + `Synthesize these findings: ${JSON.stringify(findings)}`, + { label: 'summary' }, +) -| API | Notes | -|---|---| -| `agent(prompt, opts?)` | Throws on failure. `opts`: `label`, `phase`, `schema`, `model`, `effort`, `profile` or `provider`, `isolation: 'worktree'` | -| `parallel(thunks)` | Barrier; throw → `null` slot | -| `pipeline(items, ...stages)` | Per-item chains; no cross-item barrier | -| `phase(title)` / `log(msg)` | Progress; journaled | -| `args` | Run input (object preferred) | -| `workflow(name\|{scriptPath}, args?)` | Nested, depth 1, shared call index | +return { findings, summary } +``` -**No `writeMode`.** Teach read-only vs write in the prompt. Use `isolation: 'worktree'` when parallel mutators would conflict (git required). +Available primitives: -### Determinism bans +- `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`. -`Date.now()`, `Math.random()`, and `new Date()` without args throw. Pass timestamps via `args` if needed. +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. -### Schema +Use `schema` when later workflow steps need typed JSON rather than prose: ```js -const out = await agent('Return JSON findings', { +const review = await agent('Return the discovered bugs.', { schema: { type: 'object', - properties: { bugs: { type: 'array', items: { type: 'string' } } }, + properties: { + bugs: { type: 'array', items: { type: 'string' } }, + }, required: ['bugs'], }, }) -// out is validated object; engine retries ≤2 on invalid JSON -// codex/claude: native structured output first, then prompt repair; others: prompt+Ajv ``` -### Providers - -Profiles exposed by `open_workspace` may be selected with `opts.profile`. The -profile supplies instructions, provider, model, and effort defaults; per-call -`model` and `effort` override those defaults. `profile` and `provider` are -mutually exclusive. - -Without a profile, default provider resolution is `opts.provider` → -`meta.defaultProvider` → first currently available provider. - -### Resume - -Failed and cancelled runs are terminal. Recovery creates a **new** run: - -1. Inspect the prior run with `workflow status`, `workflow calls`, and - `workflow call`. -2. Edit the persisted `scriptPath` reported by the run, or pass a different - `--script-path`. -3. Keep prompts and agent options stable for completed calls whose return values - should be reused. -4. Run `devspace workflow run --resume ` (optionally with - `--script-path `). - -Replay walks the prior run in call-index order and reuses the longest unchanged -prefix. The first failed, interrupted, changed, missing, corrupt, or unavailable -result executes live and closes replay for every later call, even when a later -cache key happens to match. Exact return values are stored separately from -bounded UI previews. - -Replay restores an agent's **return value**, not its execution. Shared-checkout -calls assume their existing filesystem effects are still present. Worktree calls -are never reused unless their exact worktree can be restored, so they currently -end the reusable prefix and run live. +Use `isolation: 'worktree'` for parallel agents that may modify overlapping checkouts. Shared isolation is appropriate for readers or intentionally sequential writers. -Return values must fit the replay budget (~1 MiB JSON). Oversized returns fail -the `agent()` call with `result_too_large` — prefer summaries or paths to large -artifacts on disk. +Workflow scripts must be replayable: do not use `Date.now()`, `Math.random()`, or `new Date()` without an argument. Pass changing values through `args`. -### Cancel +## Recover a run -`workflow cancel` sets a cooperative flag; worker aborts then hard-kills if needed. +Failed and cancelled runs are terminal. Inspect the prior run, fix or replace its script, then create a resumed run: -## When to use CLI vs MCP - -- **CLI**: host agent can shell; prefer for long runs + `--follow`. -- **TUI**: `devspace workflow tui` opens a read-only live view for workflows associated with the current working directory. -- **MCP**: ChatGPT plans; call `run_workflow`, then `workflow_status` until terminal. With full widgets enabled, workflow tool cards and the `open_workspace` dashboard show read-only live activity, including workflows launched through the CLI. Disconnecting MCP does **not** kill the worker. - -## Worked mini-examples - -**1. Parallel review** - -```js -export const meta = { name: 'p-review', description: 'Two reviewers' } -const [a, b] = await parallel([ - () => agent('Correctness review of the diff', { label: 'corr' }), - () => agent('Security review of the diff', { label: 'sec' }), -]) -return { a, b } +```bash +devspace workflow status --json +devspace workflow calls --json +devspace workflow call --json +devspace workflow run --resume --json +devspace workflow run --resume --file --json ``` -**2. Pipeline with schema** +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. -```js -export const meta = { name: 'pipe', description: 'Find then fix plan' } -return await pipeline( - args.files, - (file) => agent(`List bugs in ${file}`, { schema: { type: 'object', properties: { bugs: { type: 'array', items: { type: 'string' } } }, required: ['bugs'] } }), - (findings, file) => agent(`Plan fixes for ${file}: ${JSON.stringify(findings)}`), -) -``` - -**3. Isolation for parallel writers** +## Good uses -```js -export const meta = { name: 'iso', description: 'Parallel mutators' } -await parallel([ - () => agent('Implement feature A in isolation', { isolation: 'worktree', label: 'a' }), - () => agent('Implement feature B in isolation', { isolation: 'worktree', label: 'b' }), -]) -// dirty worktrees preserved; compose via return text / shared follow-up -``` +- 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. From 2eb4c84b054630fd38447b4024f9350195be720d Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 06:11:44 +0530 Subject: [PATCH 092/132] test(skill): drop provider reference assumptions --- src/skills.test.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/skills.test.ts b/src/skills.test.ts index 4bc585ea7..a287c47c5 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -207,18 +207,6 @@ try { assert.equal(subagentSkills.some((skill) => skill.name === "subagents"), true); assert.equal(subagentSkills.some((skill) => skill.name === "dynamic-workflows"), false); assert.equal(subagentSkills.some((skill) => skill.name === "subagent-delegation"), false); - const subagentsSkill = subagentSkills.find( - (skill) => skill.name === "subagents", - ); - assert.ok(subagentsSkill); - const codexReference = join(subagentsSkill.baseDir, "references", "codex.md"); - assert.equal(resolveSkillReadPath([subagentsSkill], new Set(), codexReference), undefined); - assert.equal( - resolveSkillReadPath([subagentsSkill], new Set([subagentsSkill.baseDir]), codexReference) - ?.absolutePath, - codexReference, - ); - const workflowsOnlyConfig = loadConfig({ DEVSPACE_ALLOWED_ROOTS: projectRoot, DEVSPACE_AGENT_DIR: agentDir, From 411e3933ec3901cb78682a6b312d349ba632a712 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 06:38:41 +0530 Subject: [PATCH 093/132] docs(skill): clarify polling and resume boundaries --- skills/dynamic-workflows/SKILL.md | 2 ++ skills/subagents/SKILL.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/skills/dynamic-workflows/SKILL.md b/skills/dynamic-workflows/SKILL.md index e328009bb..e20273c84 100644 --- a/skills/dynamic-workflows/SKILL.md +++ b/skills/dynamic-workflows/SKILL.md @@ -95,6 +95,8 @@ 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. diff --git a/skills/subagents/SKILL.md b/skills/subagents/SKILL.md index 67f69fffe..53ab44d66 100644 --- a/skills/subagents/SKILL.md +++ b/skills/subagents/SKILL.md @@ -42,7 +42,7 @@ devspace agents ls --json - `run ` continues the same agent session with a new prompt. - `ls` returns sessions belonging to the current project. -Poll `show --json` until the status is terminal. Use a continuation only when the same context is valuable; start a new subagent for independent work. +Poll `show --json` while the status is `starting` or `running`. `idle` means the response is ready; `error` and `stopped` are terminal without a successful response. Use a continuation only when the same context is valuable; start a new subagent for independent work. ## Good uses From 2e301f0d2030a0ef263731222bffa2cf89405ea7 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 06:10:37 +0530 Subject: [PATCH 094/132] feat(config): select enabled agent providers --- src/cli.test.ts | 24 ++++++++++++++++++++++ src/cli.ts | 21 +++++++++++++------ src/config.test.ts | 18 +++++++++++++++++ src/config.ts | 30 ++++++++++++++++++++++++++-- src/local-agent-availability.test.ts | 8 ++++++++ src/local-agent-availability.ts | 10 ++++++++-- src/server.ts | 2 +- src/user-config.ts | 2 ++ src/workflow-providers.ts | 12 ++++++++--- src/workflow-worker.ts | 2 +- 10 files changed, 114 insertions(+), 15 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index 5da2173a3..fa01eeee7 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -109,6 +109,30 @@ try { assert.equal(targets.profiles[0]?.provider, "codex"); assert.equal(targets.providers.some((provider) => provider.name === "codex"), true); + const filteredTargets = JSON.parse(execFileSync( + "node", + ["--import", "tsx", "src/cli.ts", "agents", "targets", "--json"], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + DEVSPACE_CONFIG_DIR: configDir, + DEVSPACE_ALLOWED_ROOTS: projectRoot, + DEVSPACE_STATE_DIR: stateDir, + DEVSPACE_WORKSPACE_ROOT: projectRoot, + DEVSPACE_SUBAGENTS: "1", + DEVSPACE_AGENT_PROVIDERS: "claude", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + }, + }, + )) as { + profiles: Array<{ name: string }>; + providers: Array<{ name: string }>; + }; + assert.deepEqual(filteredTargets.profiles, []); + assert.deepEqual(filteredTargets.providers.map((provider) => provider.name), ["claude"]); + const agentsJson = JSON.parse(execFileSync( "node", ["--import", "tsx", "src/cli.ts", "agents", "ls", "--json"], diff --git a/src/cli.ts b/src/cli.ts index bf634201d..29162453e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -84,7 +84,7 @@ async function main(argv: string[]): Promise { case "agents": if (!loadConfig().subagents) { throw new Error( - "Subagents are disabled. Set DEVSPACE_SUBAGENTS=1 to enable the experimental feature.", + "Agent tooling is disabled. Run `devspace init --force` or set DEVSPACE_SUBAGENTS=1.", ); } await runAgentsCommand(args); @@ -297,7 +297,10 @@ async function runDoctor(): Promise { console.log(`Subagents: ${config.subagents ? "enabled" : "disabled"}`); console.log(`Workflows: ${config.workflows ? "enabled" : "disabled"}`); if (config.subagents) { - const snapshot = getLocalAgentProviderAvailabilitySnapshot(); + const snapshot = getLocalAgentProviderAvailabilitySnapshot( + process.env, + config.agentProviders, + ); console.log( `Agent providers (live): ${formatLocalAgentProviderAvailabilitySummary(snapshot)}`, ); @@ -420,7 +423,7 @@ async function runAgentsTargets(args: string[]): Promise { const profiles = await loadLocalAgentProfiles(config, workspaceRoot); const catalog = buildLocalAgentCatalog( profiles, - getLocalAgentProviderAvailabilitySnapshot(), + getLocalAgentProviderAvailabilitySnapshot(process.env, config.agentProviders), ); console.log( args.includes("--json") @@ -443,7 +446,7 @@ async function runAgentsRun(args: string[]): Promise { if (!isLocalAgentProvider(existing.provider)) { throw new Error(`Unknown subagent provider for existing session: ${existing.provider}`); } - assertLocalAgentProviderAvailable(existing.provider); + assertLocalAgentProviderAvailable(existing.provider, process.env, config.agentProviders); const promptFile = writeAgentPromptFile(parsed.prompt); store.update(existing.id, { status: "starting", @@ -465,7 +468,10 @@ async function runAgentsRun(args: string[]): Promise { } const profiles = await loadLocalAgentProfiles(config, workspaceRoot); - const availableProviders = getAvailableLocalAgentProviders(); + const availableProviders = getAvailableLocalAgentProviders( + process.env, + config.agentProviders, + ); let target; try { target = resolveLocalAgentExecution({ @@ -556,7 +562,10 @@ async function runAgentsWorker(args: string[]): Promise { target: record.profileName, prompt, profiles, - availableProviders: getAvailableLocalAgentProviders(), + availableProviders: getAvailableLocalAgentProviders( + process.env, + config.agentProviders, + ), model: record.model, effort: record.effort, }); diff --git a/src/config.test.ts b/src/config.test.ts index ad9162708..5021e6c46 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -27,6 +27,18 @@ assert.equal(loadConfig(baseEnv).devspaceSkillsDir, join(emptyConfigDir, "skills assert.equal(loadConfig(baseEnv).devspaceAgentsDir, join(emptyConfigDir, "agents")); assert.equal(loadConfig(baseEnv).subagents, false); assert.equal(loadConfig(baseEnv).workflows, false); +assert.deepEqual(loadConfig(baseEnv).agentProviders, [ + "codex", + "claude", + "opencode", + "pi", + "cursor", + "copilot", +]); +assert.deepEqual( + loadConfig({ ...baseEnv, DEVSPACE_AGENT_PROVIDERS: "codex,pi,codex" }).agentProviders, + ["codex", "pi"], +); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true); assert.equal( @@ -66,6 +78,10 @@ assert.throws( () => loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "invalid" }), /Invalid DEVSPACE_TOOL_MODE: invalid/, ); +assert.throws( + () => loadConfig({ ...baseEnv, DEVSPACE_AGENT_PROVIDERS: "codex,unknown" }), + /Invalid agent provider: unknown/, +); assert.deepEqual(loadConfig(baseEnv).logging, { level: "info", @@ -169,6 +185,7 @@ writeFileSync( allowedRoots: [process.cwd()], publicBaseUrl: "https://devspace.example.com", subagents: true, + agentProviders: ["claude"], }), ); writeFileSync( @@ -183,6 +200,7 @@ assert.equal(fileConfig.port, 8787); assert.equal(fileConfig.oauth.ownerToken, "persisted-owner-token-long-enough"); assert.equal(fileConfig.publicBaseUrl, "https://devspace.example.com"); assert.equal(fileConfig.subagents, true); +assert.deepEqual(fileConfig.agentProviders, ["claude"]); assert.deepEqual(fileConfig.allowedHosts, [ "localhost", "127.0.0.1", diff --git a/src/config.ts b/src/config.ts index 974e7b883..982760582 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,6 +4,11 @@ import { expandHomePath } from "./roots.js"; import type { LoggingConfig, LogFormat, LogLevel } from "./logger.js"; import type { OAuthConfig } from "./oauth-provider.js"; import { devspaceAgentsDir, devspaceSkillsDir, loadDevspaceFiles } from "./user-config.js"; +import { + isLocalAgentProvider, + LOCAL_AGENT_PROVIDERS, + type LocalAgentProvider, +} from "./local-agent-profiles.js"; export type ToolMode = "minimal" | "full" | "codex"; export type WidgetMode = "off" | "changes" | "full"; @@ -27,6 +32,7 @@ export interface ServerConfig { devspaceAgentsDir: string; subagents: boolean; workflows: boolean; + agentProviders: LocalAgentProvider[]; agentDir: string; logging: LoggingConfig; } @@ -116,6 +122,22 @@ function parsePathList(value: string | undefined): string[] { ); } +function parseAgentProviders( + value: string | string[] | undefined, +): LocalAgentProvider[] { + if (value === undefined) return [...LOCAL_AGENT_PROVIDERS]; + const entries = (Array.isArray(value) ? value : value.split(",")) + .map((entry) => entry.trim()) + .filter(Boolean); + const invalid = entries.find((entry) => !isLocalAgentProvider(entry)); + if (invalid) { + throw new Error( + `Invalid agent provider: ${invalid}. Expected one of ${LOCAL_AGENT_PROVIDERS.join(", ")}.`, + ); + } + return Array.from(new Set(entries)) as LocalAgentProvider[]; +} + function parseStringList(value: string | undefined, fallback: string[]): string[] { const entries = value ?.split(",") @@ -219,8 +241,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { env.DEVSPACE_SUBAGENTS === undefined ? files.config.subagents === true : parseBoolean(env.DEVSPACE_SUBAGENTS); - // Experimental compatibility: workflows follow the existing subagents gate - // unless explicitly overridden for runtime testing. + // Agent tooling is one user-facing capability: enabling direct subagents + // also enables workflows. Keep the environment override for deployments + // that need to expose only one CLI surface. const workflows = env.DEVSPACE_WORKFLOWS === undefined ? subagents @@ -243,6 +266,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { devspaceAgentsDir: devspaceAgentsDir(env), subagents, workflows, + agentProviders: parseAgentProviders( + env.DEVSPACE_AGENT_PROVIDERS ?? files.config.agentProviders, + ), agentDir: resolve(expandHomePath(env.DEVSPACE_AGENT_DIR ?? files.config.agentDir ?? defaultAgentDir())), logging: parseLoggingConfig(env), }; diff --git a/src/local-agent-availability.test.ts b/src/local-agent-availability.test.ts index 5d56697c9..5060068fb 100644 --- a/src/local-agent-availability.test.ts +++ b/src/local-agent-availability.test.ts @@ -28,6 +28,14 @@ assert.equal(checkLocalAgentProviderAvailability("codex").available, true); assert.equal(snapshot.find((provider) => provider.name === "pi")?.available, false); } +{ + const snapshot = getLocalAgentProviderAvailabilitySnapshot( + process.env, + ["claude", "codex"], + ); + assert.deepEqual(snapshot.map((provider) => provider.name), ["claude", "codex"]); +} + assert.equal( formatLocalAgentProviderAvailabilitySummary([ { name: "codex", available: true }, diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index 495a463cc..617bd17d1 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -14,14 +14,16 @@ export interface LocalAgentProviderAvailability { export function getLocalAgentProviderAvailabilitySnapshot( env: NodeJS.ProcessEnv = process.env, + providers: readonly LocalAgentProvider[] = LOCAL_AGENT_PROVIDERS, ): LocalAgentProviderAvailability[] { - return LOCAL_AGENT_PROVIDERS.map((provider) => checkLocalAgentProviderAvailability(provider, env)); + return providers.map((provider) => checkLocalAgentProviderAvailability(provider, env)); } export function getAvailableLocalAgentProviders( env: NodeJS.ProcessEnv = process.env, + providers: readonly LocalAgentProvider[] = LOCAL_AGENT_PROVIDERS, ): LocalAgentProvider[] { - return getLocalAgentProviderAvailabilitySnapshot(env) + return getLocalAgentProviderAvailabilitySnapshot(env, providers) .filter((provider) => provider.available) .map((provider) => provider.name); } @@ -51,7 +53,11 @@ export function checkLocalAgentProviderAvailability( export function assertLocalAgentProviderAvailable( provider: LocalAgentProvider, env: NodeJS.ProcessEnv = process.env, + enabledProviders: readonly LocalAgentProvider[] = LOCAL_AGENT_PROVIDERS, ): void { + if (!enabledProviders.includes(provider)) { + throw new Error(`${provider} provider is disabled in DevSpace config.`); + } const availability = checkLocalAgentProviderAvailability(provider, env); if (availability.available) return; throw new Error( diff --git a/src/server.ts b/src/server.ts index 5248e0b49..496d426e5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1632,7 +1632,7 @@ export function createServer(config = loadConfig()): RunningServer { const reviewCheckpoints = createReviewCheckpointManager(); const processSessions = new ProcessSessionManager(); const localAgentProviders = config.subagents - ? getLocalAgentProviderAvailabilitySnapshot() + ? getLocalAgentProviderAvailabilitySnapshot(process.env, config.agentProviders) : []; const workflowReaper = config.workflows ? startWorkflowReaper(config, { diff --git a/src/user-config.ts b/src/user-config.ts index 970685cfa..9ba47eca0 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -8,6 +8,7 @@ import { import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { expandHomePath } from "./roots.js"; +import type { LocalAgentProvider } from "./local-agent-profiles.js"; export interface DevspaceUserConfig { host?: string; @@ -19,6 +20,7 @@ export interface DevspaceUserConfig { worktreeRoot?: string; agentDir?: string; subagents?: boolean; + agentProviders?: LocalAgentProvider[]; } export interface DevspaceAuthConfig { diff --git a/src/workflow-providers.ts b/src/workflow-providers.ts index 31836fd3d..2aa60c85c 100644 --- a/src/workflow-providers.ts +++ b/src/workflow-providers.ts @@ -3,10 +3,16 @@ 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(): LocalAgentProvider[] { - const snapshot = getLocalAgentProviderAvailabilitySnapshot(); +export function resolveWorkflowLiveProviders( + config: Pick, +): LocalAgentProvider[] { + const snapshot = getLocalAgentProviderAvailabilitySnapshot( + process.env, + config.agentProviders, + ); const live = new Set(snapshot.filter((row) => row.available).map((row) => row.name)); - return LOCAL_AGENT_PROVIDERS.filter((id) => live.has(id)); + return LOCAL_AGENT_PROVIDERS.filter((id) => config.agentProviders.includes(id) && live.has(id)); } diff --git a/src/workflow-worker.ts b/src/workflow-worker.ts index 619b5ba04..ec4425085 100644 --- a/src/workflow-worker.ts +++ b/src/workflow-worker.ts @@ -54,7 +54,7 @@ export async function runWorkflowWorker( try { const source = await readFile(claimed.scriptPath, "utf8"); const parsed = parseWorkflowScript(source, { filename: claimed.scriptPath }); - const availableProviders = resolveWorkflowLiveProviders(); + const availableProviders = resolveWorkflowLiveProviders(config); const agentProfiles = await loadLocalAgentProfiles(config, claimed.workspaceRoot); const concurrency = resolveWorkflowConcurrency( parsed.meta.concurrency, From f986101ca0afc1d8df0525aeec7f067600a4c871 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 06:13:44 +0530 Subject: [PATCH 095/132] feat(init): onboard agent tooling and install skills --- package.json | 2 +- src/cli.ts | 85 ++++++++++++++++++++++++++++++--------- src/skill-install.test.ts | 39 ++++++++++++++++++ src/skill-install.ts | 76 ++++++++++++++++++++++++++++++++++ 4 files changed, 181 insertions(+), 21 deletions(-) create mode 100644 src/skill-install.test.ts create mode 100644 src/skill-install.ts diff --git a/package.json b/package.json index 3e1a25d2c..a6f012264 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/cli-workspace.test.ts && tsx src/cli-output.test.ts && tsx src/open-workspace-capabilities.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-capabilities.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-resolution.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-lifecycle.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-summary.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-launch.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", + "test": "tsx src/config.test.ts && tsx src/cli-workspace.test.ts && tsx src/cli-output.test.ts && tsx src/open-workspace-capabilities.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-capabilities.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-resolution.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skill-install.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-lifecycle.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-summary.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-launch.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/cli.ts b/src/cli.ts index 29162453e..02ade978d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,7 +18,9 @@ import { } from "./local-agent-catalog.js"; import { isLocalAgentProvider, + LOCAL_AGENT_PROVIDERS, loadLocalAgentProfiles, + type LocalAgentProvider, } from "./local-agent-profiles.js"; import { assertLocalAgentProviderAvailable, @@ -50,6 +52,7 @@ import { localAgentOutput, localAgentTargetsOutput, } from "./cli-output.js"; +import { installBundledAgentSkills } from "./skill-install.js"; import { runWorkflowCommand } from "./workflow-cli.js"; import { @@ -170,31 +173,59 @@ async function runInit({ force }: { force: boolean }): Promise { }); const port = Number(portAnswer); - prompts.note( - [ - "DevSpace needs a public base URL so ChatGPT or Claude can reach this MCP server.", - "Create a tunnel or reverse proxy with Cloudflare Tunnel, ngrok, Pinggy, Tailscale Funnel, or your own HTTPS proxy.", - "Paste the public origin here, without /mcp.", - "", - "Example: https://your-tunnel-host.example.com", - ].join("\n"), - "Public URL required", - ); - const publicBaseUrl = normalizePublicBaseUrl(await textPrompt({ - message: files.config.publicBaseUrl - ? `What is the public base URL? Press Enter to keep ${files.config.publicBaseUrl}` - : "What is the public base URL?", - placeholder: files.config.publicBaseUrl ?? "https://your-tunnel-host.example.com", - defaultValue: files.config.publicBaseUrl ?? "", - validate: validateRequiredPublicBaseUrl, - })); + const remoteMcpAnswer = await prompts.confirm({ + message: "Will ChatGPT or Claude connect to DevSpace over the internet?", + initialValue: Boolean(files.config.publicBaseUrl), + }); + if (prompts.isCancel(remoteMcpAnswer)) throw new SetupCancelledError(); + const publicBaseUrl = remoteMcpAnswer + ? normalizePublicBaseUrl(await textPrompt({ + message: files.config.publicBaseUrl + ? `What is the public base URL? Press Enter to keep ${files.config.publicBaseUrl}` + : "What is the public base URL?", + placeholder: files.config.publicBaseUrl ?? "https://your-tunnel-host.example.com", + defaultValue: files.config.publicBaseUrl ?? "", + validate: validateRequiredPublicBaseUrl, + })) + : null; + + const agentToolingAnswer = await prompts.confirm({ + message: "Enable subagents and Dynamic Workflows?", + initialValue: resolveSubagentsFlag(files.config) ?? true, + }); + if (prompts.isCancel(agentToolingAnswer)) throw new SetupCancelledError(); + + const providerSnapshot = getLocalAgentProviderAvailabilitySnapshot(); + const availableProviders = providerSnapshot + .filter((provider) => provider.available) + .map((provider) => provider.name); + let agentProviders: LocalAgentProvider[] = []; + let subagents = agentToolingAnswer; + if (subagents && availableProviders.length === 0) { + prompts.log.warn("No supported agent providers are currently available; agent tooling was disabled."); + subagents = false; + } else if (subagents) { + const configuredProviders = files.config.agentProviders ?? [...LOCAL_AGENT_PROVIDERS]; + const providerAnswer = await prompts.multiselect({ + message: "Which agent providers should DevSpace use?", + options: availableProviders.map((provider) => ({ value: provider, label: provider })), + initialValues: configuredProviders.filter((provider) => + availableProviders.includes(provider) + ), + required: true, + }); + if (prompts.isCancel(providerAnswer)) throw new SetupCancelledError(); + agentProviders = providerAnswer; + } const config: DevspaceUserConfig = { + ...files.config, host: files.config.host ?? "127.0.0.1", port, allowedRoots, publicBaseUrl, - subagents: resolveSubagentsFlag(files.config), + subagents, + agentProviders, }; const auth = { ownerToken: files.auth.ownerToken ?? generateOwnerToken(), @@ -202,11 +233,16 @@ async function runInit({ force }: { force: boolean }): Promise { const configPath = writeDevspaceConfig(config); const authPath = writeDevspaceAuth(auth); + const installedSkills = subagents ? installBundledAgentSkills() : undefined; const lines = [ `Config: ${configPath}`, `Auth: ${authPath}`, `Local MCP URL: http://${config.host}:${config.port}/mcp`, ...(publicBaseUrl ? [`Public MCP URL: ${publicBaseUrl}/mcp`] : []), + `Agent tooling: ${subagents ? `enabled (${agentProviders.join(", ")})` : "disabled"}`, + ...(installedSkills + ? [`Agent skills: ${installedSkills.directory}`] + : []), ]; prompts.note(lines.join("\n"), "DevSpace configured"); prompts.note( @@ -217,7 +253,16 @@ async function runInit({ force }: { force: boolean }): Promise { ].join("\n"), "Owner password", ); - prompts.outro("Run `devspace serve` to start the MCP server."); + if (installedSkills?.skipped.length) { + prompts.log.warn( + `Kept user-owned skills unchanged: ${installedSkills.skipped.join(", ")}`, + ); + } + prompts.outro( + remoteMcpAnswer + ? "Run `devspace serve` to start the MCP server." + : "Setup complete. Use `devspace agents` and `devspace workflow` from a project directory.", + ); } catch (error) { if (error instanceof SetupCancelledError) { prompts.cancel("Setup cancelled"); diff --git a/src/skill-install.test.ts b/src/skill-install.test.ts new file mode 100644 index 000000000..803eb6b0d --- /dev/null +++ b/src/skill-install.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { installBundledAgentSkills } from "./skill-install.js"; + +const root = mkdtempSync(join(tmpdir(), "devspace-skill-install-test-")); +const env = { DEVSPACE_CONFIG_DIR: root }; + +try { + const first = installBundledAgentSkills(env); + assert.deepEqual(first.installed, ["subagents", "dynamic-workflows"]); + const subagentsDir = join(first.directory, "subagents"); + const subagentsFile = join(subagentsDir, "SKILL.md"); + assert.equal(existsSync(join(subagentsDir, ".devspace-managed")), true); + assert.match(readFileSync(subagentsFile, "utf8"), /devspace agents targets --json/); + + writeFileSync(subagentsFile, "stale managed copy\n"); + const updated = installBundledAgentSkills(env); + assert.deepEqual(updated.updated, ["subagents", "dynamic-workflows"]); + assert.match(readFileSync(subagentsFile, "utf8"), /devspace agents targets --json/); + + unlinkSync(join(subagentsDir, ".devspace-managed")); + writeFileSync(subagentsFile, "user-owned skill\n"); + const skipped = installBundledAgentSkills(env); + assert.deepEqual(skipped.skipped, ["subagents"]); + assert.equal(readFileSync(subagentsFile, "utf8"), "user-owned skill\n"); +} finally { + rmSync(root, { recursive: true, force: true }); +} + +console.log("skill-install.test.ts: ok"); diff --git a/src/skill-install.ts b/src/skill-install.ts new file mode 100644 index 000000000..284152319 --- /dev/null +++ b/src/skill-install.ts @@ -0,0 +1,76 @@ +import { randomUUID } from "node:crypto"; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { devspaceSkillsDir } from "./user-config.js"; + +const BUNDLED_AGENT_SKILLS = ["subagents", "dynamic-workflows"] as const; +const MANAGED_MARKER = ".devspace-managed"; + +export interface AgentSkillInstallResult { + installed: string[]; + updated: string[]; + skipped: string[]; + directory: string; +} + +export function installBundledAgentSkills( + env: NodeJS.ProcessEnv = process.env, +): AgentSkillInstallResult { + const sourceRoot = fileURLToPath(new URL("../skills", import.meta.url)); + const directory = devspaceSkillsDir(env); + mkdirSync(directory, { recursive: true }); + const result: AgentSkillInstallResult = { + installed: [], + updated: [], + skipped: [], + directory, + }; + + for (const name of BUNDLED_AGENT_SKILLS) { + const source = join(sourceRoot, name); + const destination = join(directory, name); + const marker = join(destination, MANAGED_MARKER); + if (existsSync(destination) && !existsSync(marker)) { + result.skipped.push(name); + continue; + } + + const staging = mkdtempSync(join(directory, `.install-${name}-`)); + try { + cpSync(source, staging, { recursive: true }); + writeFileSync( + join(staging, MANAGED_MARKER), + "Managed by `devspace init`; place custom overrides in ~/.agents/skills or a project skill directory.\n", + ); + if (!existsSync(destination)) { + renameSync(staging, destination); + result.installed.push(name); + continue; + } + + const backup = join(directory, `.backup-${name}-${randomUUID()}`); + renameSync(destination, backup); + try { + renameSync(staging, destination); + rmSync(backup, { recursive: true, force: true }); + } catch (error) { + if (!existsSync(destination)) renameSync(backup, destination); + throw error; + } + result.updated.push(name); + } finally { + rmSync(staging, { recursive: true, force: true }); + } + } + + return result; +} From c77dd3635f6284a2f52f4d46fa661ca2a8d95401 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 06:17:37 +0530 Subject: [PATCH 096/132] refactor(ui): remove stale workflow monitor remnants --- src/server.ts | 2 +- src/ui/workspace-app.css | 117 ++---------------- src/ui/workspace-app.tsx | 2 +- ...ow-dashboard.ts => workspace-dashboard.ts} | 0 src/workflow-cli.ts | 2 +- 5 files changed, 10 insertions(+), 113 deletions(-) rename src/ui/{workflow-dashboard.ts => workspace-dashboard.ts} (100%) diff --git a/src/server.ts b/src/server.ts index 496d426e5..1463fe498 100644 --- a/src/server.ts +++ b/src/server.ts @@ -724,7 +724,7 @@ function createMcpServer( "DevSpace App", WORKSPACE_APP_URI, { - description: "Interactive DevSpace workspace, workflow, and file-change views.", + description: "Interactive DevSpace workspace and file-change views.", _meta: { ui: { csp: appCsp(config), diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index db1503ffe..560cca986 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -388,15 +388,13 @@ html[data-display-mode="fullscreen"] .tool-card { border-radius: 0; } -.workspace-dashboard, -.workflow-dashboard { +.workspace-dashboard { display: grid; gap: 0; color: var(--color-text-primary, #f5f5f6); } -.workspace-dashboard.fullscreen, -.workflow-dashboard.fullscreen { +.workspace-dashboard.fullscreen { min-height: calc(100vh - 83px); align-content: start; } @@ -435,32 +433,21 @@ html[data-display-mode="fullscreen"] .tool-card { height: 15px; } -.active-workflows, -.workflow-heading, -.workflow-phases, -.workflow-activity, -.workflow-error { +.active-workflows { padding: 16px; } -.active-workflows, -.workflow-heading, -.workflow-phases, -.workflow-activity { +.active-workflows { border-bottom: 1px solid var(--tool-card-divider); } -.active-workflows h3, -.workflow-phase h3, -.workflow-activity h3 { +.active-workflows h3 { margin: 0 0 12px; font-size: var(--font-text-sm-size, 13px); font-weight: 600; } -.active-workflow-row, -.workflow-title-row, -.workflow-call-main { +.active-workflow-row { display: flex; align-items: flex-start; gap: 10px; @@ -494,8 +481,6 @@ html[data-display-mode="fullscreen"] .tool-card { } .active-workflow-copy, -.workflow-title-copy, -.workflow-call-copy, .workspace-list-row { display: grid; min-width: 0; @@ -503,8 +488,6 @@ html[data-display-mode="fullscreen"] .tool-card { } .active-workflow-copy span, -.workflow-subtitle, -.workflow-call-copy span, .workspace-list-row span, .workspace-list-row code { overflow: hidden; @@ -571,100 +554,14 @@ html[data-display-mode="fullscreen"] .tool-card { line-height: 1.55; } -.workflow-heading { - display: grid; - gap: 12px; -} - -.workflow-counts { - display: flex; - flex-wrap: wrap; - gap: 7px; -} - -.workflow-counts span { - padding: 4px 8px; - border-radius: 999px; - background: var(--tool-card-hover-bg); - color: var(--color-text-secondary, #d6d6dc); - font-size: var(--font-text-sm-size, 12px); -} - -.workflow-phases { - display: grid; - gap: 20px; -} - -.workflow-call-list { - display: grid; - gap: 8px; -} - -.workflow-call { - padding: 10px 12px; - border-radius: 9px; - background: color-mix(in srgb, var(--tool-card-hover-bg) 54%, transparent); -} - -.call-status { - width: 16px; - flex: 0 0 auto; - color: var(--color-text-tertiary, #a3a3aa); - text-align: center; -} - -.call-status.completed, -.call-status.from_cache { - color: var(--color-success-text, #6fda83); -} - -.call-status.failed { - color: var(--color-danger-text, #ee7676); -} - -.workflow-call-error, -.workflow-error p { - margin: 8px 0 0 26px; - color: var(--color-danger-text, #ee7676); - font-size: var(--font-text-sm-size, 12px); - line-height: 1.45; -} - -.workflow-activity { - display: grid; - gap: 8px; -} - -.workflow-event { - display: grid; - grid-template-columns: max-content minmax(0, 1fr); - gap: 10px; - color: var(--color-text-secondary, #d6d6dc); - font-size: var(--font-text-sm-size, 12px); -} - -.workflow-event time { - color: var(--color-text-tertiary, #a3a3aa); - font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); -} - -.workflow-error { - color: var(--color-danger-text, #ee7676); -} - .dashboard-empty { color: var(--color-text-secondary, #b7b7bf); font-size: var(--font-text-sm-size, 13px); } @media (min-width: 860px) { - .workspace-dashboard.fullscreen, - .workflow-dashboard.fullscreen { + .workspace-dashboard.fullscreen { width: min(1120px, 100%); margin: 0 auto; } - - .workflow-dashboard.fullscreen .workflow-phases { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } } diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index b0bdeec28..98887f612 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -25,7 +25,7 @@ import { getToolHeaderSummary, type ToolDisplay, } from "./tool-display.js"; -import { renderWorkspaceDashboard } from "./workflow-dashboard.js"; +import { renderWorkspaceDashboard } from "./workspace-dashboard.js"; import "./workspace-app.css"; interface MountedPayload { diff --git a/src/ui/workflow-dashboard.ts b/src/ui/workspace-dashboard.ts similarity index 100% rename from src/ui/workflow-dashboard.ts rename to src/ui/workspace-dashboard.ts diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts index 2e79046a3..90c95c0f7 100644 --- a/src/workflow-cli.ts +++ b/src/workflow-cli.ts @@ -47,7 +47,7 @@ export async function runWorkflowCommand( throw new InvalidWorkflowInputError({ code: "invalid_argument", message: - "Dynamic workflows are disabled. Set DEVSPACE_WORKFLOWS=1 to enable the experimental feature.", + "Dynamic Workflows are disabled. Run `devspace init --force` or set DEVSPACE_WORKFLOWS=1.", }); } switch (subcommand) { From 17e6a9d825a511598d71b19767d7ae4fe7aa5938 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 06:17:57 +0530 Subject: [PATCH 097/132] docs: document CLI-only agent tooling --- README.md | 21 +- docs/chatgpt-coding-workflow.md | 45 +- docs/claude-code-dynamic-workflows.html | 1150 ----------------- docs/configuration.md | 45 +- docs/dynamic-workflow/claude/README.md | 47 - docs/dynamic-workflow/claude/agent.md | 205 --- docs/dynamic-workflow/claude/architecture.md | 101 -- docs/dynamic-workflow/claude/cheatsheet.md | 154 --- docs/dynamic-workflow/claude/concurrency.md | 191 --- .../dynamic-workflow/claude/control-and-io.md | 179 --- docs/dynamic-workflow/claude/lifecycle.md | 108 -- docs/dynamic-workflow/claude/limits.md | 76 -- docs/dynamic-workflow/claude/opt-in.md | 67 - docs/dynamic-workflow/claude/orchestration.md | 138 -- docs/dynamic-workflow/claude/patterns.md | 254 ---- docs/dynamic-workflow/claude/primitives.md | 62 - docs/dynamic-workflow/claude/resume.md | 94 -- .../claude/script-contract.md | 145 --- docs/dynamic-workflow/claude/usecases.md | 180 --- docs/dynamic-workflow/claude/workflow-tool.md | 139 -- docs/dynamic-workflow/devspace/plan.md | 369 ------ .../devspace/primitives-spec.md | 795 ------------ docs/dynamic-workflows.md | 94 ++ docs/gotchas.md | 16 +- docs/setup.md | 38 +- 25 files changed, 183 insertions(+), 4530 deletions(-) delete mode 100644 docs/claude-code-dynamic-workflows.html delete mode 100644 docs/dynamic-workflow/claude/README.md delete mode 100644 docs/dynamic-workflow/claude/agent.md delete mode 100644 docs/dynamic-workflow/claude/architecture.md delete mode 100644 docs/dynamic-workflow/claude/cheatsheet.md delete mode 100644 docs/dynamic-workflow/claude/concurrency.md delete mode 100644 docs/dynamic-workflow/claude/control-and-io.md delete mode 100644 docs/dynamic-workflow/claude/lifecycle.md delete mode 100644 docs/dynamic-workflow/claude/limits.md delete mode 100644 docs/dynamic-workflow/claude/opt-in.md delete mode 100644 docs/dynamic-workflow/claude/orchestration.md delete mode 100644 docs/dynamic-workflow/claude/patterns.md delete mode 100644 docs/dynamic-workflow/claude/primitives.md delete mode 100644 docs/dynamic-workflow/claude/resume.md delete mode 100644 docs/dynamic-workflow/claude/script-contract.md delete mode 100644 docs/dynamic-workflow/claude/usecases.md delete mode 100644 docs/dynamic-workflow/claude/workflow-tool.md delete mode 100644 docs/dynamic-workflow/devspace/plan.md delete mode 100644 docs/dynamic-workflow/devspace/primitives-spec.md create mode 100644 docs/dynamic-workflows.md diff --git a/README.md b/README.md index 16c8817d0..1b7394cc6 100644 --- a/README.md +++ b/README.md @@ -65,34 +65,37 @@ Install the DevSpace CLI: npm install -g @waishnav/devspace ``` -Then initialize and start the server: +Then initialize DevSpace: ```bash devspace init -devspace serve ``` Or run it without a global install: ```bash npx @waishnav/devspace init -npx @waishnav/devspace serve ``` During setup, DevSpace asks for: -- the local project folders ChatGPT is allowed to open through DevSpace +- the local project folders DevSpace is allowed to open - the local port, usually `7676` -- your public HTTPS base URL from Cloudflare Tunnel, ngrok, Pinggy, Tailscale Funnel, or - another reverse proxy +- whether ChatGPT or Claude will connect remotely; only remote MCP users need a public HTTPS URL +- whether to enable subagents and Dynamic Workflows, and which available providers may run -Use the public origin without `/mcp` during setup: +Setup installs the `subagents` and `dynamic-workflows` skills in +`~/.devspace/skills` when agent tooling is enabled. Coding harnesses can use the +DevSpace CLI directly; MCP users invoke the same CLI through DevSpace's shell or +process tools. + +For remote MCP use, enter the public origin without `/mcp` during setup: ```text https://your-tunnel-host.example.com ``` -You will configure your MCP client with the public `/mcp` URL after setup. +Then configure your MCP client with the public `/mcp` URL after setup. When the client connects, DevSpace opens an Owner password approval page. Enter the Owner password printed by `devspace init`. It is also stored in: @@ -139,6 +142,7 @@ DevSpace gives ChatGPT tools to: - use isolated Git worktrees for parallel coding sessions - follow project instructions from `AGENTS.md` and `CLAUDE.md` - discover local agent skills from your skill folders +- delegate bounded work and run programmable multi-agent workflows through the DevSpace CLI - show tool cards and optional change summaries in ChatGPT Apps-compatible hosts ## Mental Model @@ -180,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) - [Configuration Reference](https://github.com/Waishnav/devspace/blob/main/docs/configuration.md) +- [Subagents and Dynamic Workflows](https://github.com/Waishnav/devspace/blob/main/docs/dynamic-workflows.md) - [Security Model](https://github.com/Waishnav/devspace/blob/main/docs/security.md) - [Troubleshooting Gotchas](https://github.com/Waishnav/devspace/blob/main/docs/gotchas.md) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index ad591163c..93e7e418a 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -85,18 +85,16 @@ DevSpace discovers standard Agent Skills from: - project `.agents/skills` - `~/.devspace/skills` -It also includes: +It also includes the managed `subagents` and `dynamic-workflows` skills that +setup installs in `~/.devspace/skills` when agent tooling is enabled, plus: -- the package-managed `subagents` skill when the Subagents capability is enabled -- the package-managed `dynamic-workflows` skill when the Dynamic Workflows capability is enabled - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - additional paths from `DEVSPACE_SKILL_PATHS` -When Subagents are enabled, DevSpace discovers agent profiles -from `~/.devspace/agents/*.md` and project `.devspace/agents/*.md`. -`open_workspace` exposes a compact catalog with profile names, descriptions, -providers, and optional models/effort levels so the model can choose a configured agent -without seeing provider-specific launch details. +When agent tooling is enabled, DevSpace discovers agent profiles from +`~/.devspace/agents/*.md` and project `.devspace/agents/*.md`. +`open_workspace` exposes only usable provider names and profile names with +descriptions. Disabled or unavailable providers and their profiles are omitted. Example profiles are packaged under `examples/agents/` for users who want starter templates. Copy or adapt them into one of the active profile directories @@ -113,16 +111,15 @@ Skill paths may be outside the workspace. DevSpace only permits reading: - files under a skill directory after that skill's `SKILL.md` has been read Set `DEVSPACE_SKILLS=0` to hide skills from workspace output. Set -`DEVSPACE_SUBAGENTS=1` to expose the experimental subagent catalog and -`subagents` skill. That skill can use target information already supplied by the -host or discover it with `devspace agents targets`. `devspace agents ls` lists -existing subagent sessions for the current workspace. +`DEVSPACE_SUBAGENTS=1` to enable both direct subagents and Dynamic Workflows. +The skills invoke the DevSpace CLI through `bash` or `exec_command`; DevSpace +does not expose dedicated workflow-execution MCP tools. Models discover the +usable execution catalog with `devspace agents targets --json`. -Set `DEVSPACE_WORKFLOWS=1` to enable Dynamic Workflows independently. When the -variable is omitted, Dynamic Workflows follows the effective Subagents setting, -including persisted config and any environment override. Disabled features are -omitted from the `open_workspace` schema and response rather than returned as -empty capability arrays. +`DEVSPACE_AGENT_PROVIDERS` can narrow the configured provider allowlist. +`DEVSPACE_WORKFLOWS` remains an optional runtime override; normally workflows +follow the agent-tooling setting. Disabled features are omitted from the +`open_workspace` schema and response. ## Tool Names @@ -158,16 +155,10 @@ a PTY, or send Ctrl-C. Set `tty: true` only for commands that need a terminal. By default, `DEVSPACE_WIDGETS=full`. -In that mode, DevSpace attaches widget UI to the exposed workspace, workflow, -file, edit, and shell tools. The `open_workspace` dropdown presents the opened -root, loaded skills and instructions, available agent providers/profiles, and -currently active workflows for that workspace. - -Dynamic Workflow views are read-only. They refresh through app-only MCP tools -and show observed phases, agent calls, replay state, worktree isolation, errors, -and recent activity. When the host supports MCP Apps fullscreen display mode, -the card offers an **Open dashboard** presentation control. It does not add -cancel, resume, apply, or cleanup actions. +In that mode, DevSpace attaches widget UI to the exposed workspace, file, edit, +and shell tools. The `open_workspace` dropdown presents the opened root, loaded +skills and instructions, usable agent provider/profile names, and a compact +snapshot of active workflows with running, completed, and failed call counts. The aggregate `show_changes` tool is not exposed by default. diff --git a/docs/claude-code-dynamic-workflows.html b/docs/claude-code-dynamic-workflows.html deleted file mode 100644 index d7fa34ac3..000000000 --- a/docs/claude-code-dynamic-workflows.html +++ /dev/null @@ -1,1150 +0,0 @@ - - - - - - - Claude Code Dynamic Workflows — API & Primitives Reference - - - - - - - - - -
-
-
-
Claude Code · 2.1.x
-

Dynamic Workflows — API & Primitives

-
-
- Workflow tool - agent / pipeline / parallel - resume + budget - orchestrator tier -
-
-
- -
- - - -
- - -
-
-

01 · Thesis

-

- Deterministic control flow.
- Stochastic workers. One orchestrator brain. -

-
-
-
-

Problem

-

A single agent loop confuses what to do next with how to do the work. Fan-out, verification, and synthesis become ad-hoc tool calls that the model re-invents every turn.

-
-
-

Mechanism

-

Claude Code exposes a Workflow tool. The model authors a short plain-JS script. The harness runs that script: loops, conditionals, and fan-out are code — not free-form model decisions mid-orchestration.

-
-
-

Payoff

-

A stronger “orchestrator” model designs the graph once. Weaker/cheaper or specialized subagents execute units of work. Scale, confidence, and isolation become programmable.

-
-
-
- Key insight. Dynamic workflows are not “another agent.” They are a programmable multi-agent runtime the model can call. The script is the plan; agent() is the only way work escapes into a model. -
-
- - -
-
-

02 · Architecture

-

Three layers

-
- -
-
Main session
orchestrator model
-
Workflow tool
-
JS runtime
script + hooks
-
agent()
-
Subagents
N isolated workers
-
- -
-
-

1. Coordinator (main loop)

-
    -
  • Talks to the user
  • -
  • Scouts the repo / work-list
  • -
  • Authors or selects a workflow script
  • -
  • Calls the Workflow tool
  • -
  • Synthesizes the returned result
  • -
-
-
-

2. Workflow engine

-
    -
  • Parses export const meta
  • -
  • Runs the script in an async JS context
  • -
  • Hosts agent / pipeline / parallel / phase / log / budget / workflow
  • -
  • Enforces concurrency & agent caps
  • -
  • Journals each agent call for resume
  • -
-
-
-

3. Subagents

-
    -
  • Own tool loops (Read, Bash, Edit, …)
  • -
  • Optional structured output via schema
  • -
  • Optional worktree isolation
  • -
  • Optional model / effort / agentType overrides
  • -
  • Final text (or validated object) is the return value
  • -
-
-
- -
-

Mental model

-
// Coordinator decides STRUCTURE -Workflow({ script, args }) - → JS engine runs control flow - → agent(prompt, opts) × N // workers decide CONTENT - → script return value -→ coordinator narrates to user
-
-
- - -
-
-

03 · Opt-in policy

-

When the model may call Workflow

-
-

- Workflows can spawn dozens of agents and burn large token budgets. Claude Code therefore gates the tool behind explicit user opt-in. A task that would “benefit from parallelism” is not enough. -

-
-
-

Allowed triggers

-
    -
  • User prompt contains the keyword ultracode (system-reminder confirms).
  • -
  • Session ultracode is on (standing opt-in for every substantive task).
  • -
  • User asks in their own words: “use a workflow”, “fan out agents”, “orchestrate with subagents”, etc.
  • -
  • A skill / slash command’s instructions tell the model to call Workflow.
  • -
  • User asks for a specific named / saved workflow.
  • -
-
-
-

Not enough

-
    -
  • “This would be faster in parallel.”
  • -
  • The model silently deciding multi-agent is better.
  • -
  • Inferring opt-in from task complexity alone.
  • -
-

Fallback: use the single Agent tool, or describe the multi-agent plan + rough cost and ask.

-
-
- -
-

Ultracode mode

-

When ultracode is on, opt-in is standing. The model should author and run a workflow for every substantive task. Token cost is not a constraint; the goal is the most exhaustive correct answer. Multi-phase work is often several workflows in sequence (understand → design → implement → review) so the coordinator stays in the loop between phases. Solo only for pure conversation or trivial mechanical edits.

-
- -
- Hybrid default. Scout inline first (list files, scope the diff, discover the work-list), then call Workflow to pipeline over that list. You need the shape before the orchestration step, not before the whole task. -
-
- - -
-
-

04 · Workflow tool API

-

Tool surface the model calls

-
-

Tool name: Workflow (alias RunWorkflow). Search hint: orchestrate subagents with deterministic JavaScript workflow. Runs in the background — returns immediately with a task ID; a <task-notification> arrives on completion. Live progress: /workflows.

- -

Input fields

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldTypeRole
scriptstring (optional, max-bounded)Inline self-contained workflow script. Must start with pure-literal export const meta = {...}. Preferred first invocation — do not Write a file first.
namestring (optional)Named workflow from built-ins or .claude/workflows/. Resolves to a script.
scriptPathstring (optional)Path to a persisted script on disk. Every invocation writes one under the session dir and returns the path. Iterate with Write/Edit + re-invoke. Takes precedence over script / name.
argsany (optional)Value exposed to the script as global args, verbatim. Pass real JSON arrays/objects — not stringified JSON (stringified lists break args.map).
resumeFromRunIdstring matching ^wf_[a-z0-9-]{6,}$Prior run ID. Unchanged prefix of agent() calls replays from cache; first changed/new call and after run live. Same-session only. Stop the prior run first.
description / titleignoredSet display name/description in script meta, not these fields.
-
-

Validation: at least one of script, name, or scriptPath is required.

- -

Return envelope (conceptual)

-
-
// async launch — tool returns before script finishes -{ - status: "async_launched" | "remote_launched", - taskId: "...", - taskType: "local_workflow" | "remote_agent", - workflowName: "review-changes", // meta.name - runId: "wf_…", // for resumeFromRunId - transcriptDir: "/…/…", // subagent transcripts + journal.jsonl - scriptPath: "/…/workflows/scripts/….js", - summary?: "…", - warning?: "…", - error?: "…" // e.g. syntax check failed -}
-
- -
-
-

First run

-
Workflow({ - script: `export const meta = {…} -…`, - args: { files: changed } -})
-
-
-

Iterate

-
// edit the returned scriptPath, then: -Workflow({ - scriptPath: returnedPath, - resumeFromRunId: runId, // optional cache - args: { files: changed } -})
-
-
-
- - -
-
-

05 · Script contract

-

What a valid workflow script is

-
- -
-
export const meta = { - name: 'find-flaky-tests', - description: 'Find flaky tests and propose fixes', - phases: [ - { title: 'Scan', detail: 'grep test logs for retries' }, - { title: 'Fix', detail: 'one agent per flaky test', model: 'sonnet' }, - ], - // optional: whenToUse (workflow list), model on a phase -} - -// body runs in an async context — await freely -phase('Scan') -const flaky = await agent('…', { schema: FLAKY_SCHEMA }) -// … -return { flaky }
-
- -
-
-

meta rules

-
    -
  • Must be the first statement.
  • -
  • Pure literal only — no variables, function calls, spreads, or template interpolation.
  • -
  • Required: name, description.
  • -
  • Optional: whenToUse, phases[] with title, detail, optional per-phase model.
  • -
  • Phase titles in meta.phases must match phase() calls exactly for UI grouping.
  • -
  • description is shown in the permission dialog.
  • -
-
-
-

Language & environment

-
    -
  • Plain JavaScript only — not TypeScript. Type annotations, interfaces, generics fail to parse.
  • -
  • Standard built-ins: JSON, Math, Array, etc.
  • -
  • Forbidden for determinism: Date.now(), Math.random(), argless new Date() — they throw (would break resume).
  • -
  • No filesystem, no Node APIs, no network from the script.
  • -
  • Only escape hatch into models/tools: agent() (and nested workflow()).
  • -
  • Pass timestamps via args; stamp wall-clock after the workflow returns.
  • -
-
-
- -
- Why non-determinism is banned. Resume replays the longest unchanged prefix of agent() calls by hashing prompt + opts. If the script could branch on time or random, cache identity would lie. Keep control flow pure; put entropy in agent prompts (vary by index) or in post-processing outside the script. -
-
- - -
-
-

06 · Script primitives

-

Every hook the script can call

-

These are the only APIs injected into the script body. Together they form a small concurrent orchestration language.

-
- - -
-
- core -

agent()

-
-
agent(prompt: string, opts?: { - label?: string, - phase?: string, - schema?: object, // JSON Schema - model?: string, // e.g. 'sonnet' | 'opus' | 'haiku' | session ids - effort?: 'low'|'medium'|'high'|'xhigh'|'max', - isolation?: 'worktree', - agentType?: string // registry name, e.g. 'general-purpose', 'code-reviewer', 'claude' -}): Promise<any>
- -
-
-

Semantics

-
    -
  • Spawns one subagent with its own tool loop.
  • -
  • Without schema: resolves to the agent’s final text (string).
  • -
  • With schema: forces a StructuredOutput tool call; returns the validated object — no fragile JSON parsing.
  • -
  • Returns null if the user skips the agent or it dies after terminal API retries. Always .filter(Boolean) before use.
  • -
  • Subagents are told: final text/object is the return value, not a user-facing message.
  • -
-
-
-

Options in practice

-
    -
  • label — UI/progress label (review:security).
  • -
  • phase — assign progress group inside pipeline/parallel (avoids races on global phase()).
  • -
  • model — omit by default (inherit session model). Override only when tier fit is clear.
  • -
  • effortlow for mechanical stages; higher only for hard verify/judge stages.
  • -
  • isolation: 'worktree' — expensive (~200–500ms + disk). Use only when parallel mutators would conflict. Unchanged worktrees auto-remove.
  • -
  • agentType — custom subagent from the same registry as the Agent tool; composes with schema.
  • -
-
-
- -
-
const FINDINGS = { - type: 'object', - properties: { - findings: { - type: 'array', - items: { - type: 'object', - properties: { - file: { type: 'string' }, - line: { type: 'integer' }, - issue: { type: 'string' }, - }, - required: ['file', 'line', 'issue'], - additionalProperties: false, - }, - }, - }, - required: ['findings'], - additionalProperties: false, -} - -const result = await agent( - 'Review auth changes for session fixation. Read-only.', - { - label: 'review:auth', - phase: 'Review', - schema: FINDINGS, - effort: 'medium', - agentType: 'claude', - } -) -// result.findings is already typed-shaped JSON
-
- -
- MCP access. Workflow subagents can reach session-connected MCP tools via ToolSearch (schemas load on demand). Interactively authenticated MCP servers may be missing in headless/cron runs. -
-
- - -
-
- default multi-stage -

pipeline()

-
-
pipeline(items: any[], stage1, stage2, ...): Promise<any[]> -// each stage: (prevResult, originalItem, index) => Promise<any> | any
-
-
-

Semantics

-
    -
  • Each item flows through all stages independently.
  • -
  • No barrier between stages: item A can be in stage 3 while item B is still in stage 1.
  • -
  • Wall-clock ≈ slowest single-item chain — not sum of per-stage slowest times.
  • -
  • Stage callbacks receive (prevResult, originalItem, index) so later stages can label work without stuffing identity into stage-1 returns.
  • -
  • A throwing stage drops that item to null and skips remaining stages for it.
  • -
-
-
-

When to use

-

Default for multi-stage work. Prefer over barrier-then-map whenever each item’s next stage does not need the full previous stage’s result set.

-

Smell test: if you wrote parallel → transform → parallel with no cross-item dependency, rewrite as pipeline with the transform inside a stage.

-
-
-
-
const DIMENSIONS = [ - { key: 'bugs', prompt: '…' }, - { key: 'perf', prompt: '…' }, -] -const results = await pipeline( - DIMENSIONS, - d => agent(d.prompt, { - label: `review:${d.key}`, - phase: 'Review', - schema: FINDINGS_SCHEMA, - }), - review => parallel( - review.findings.map(f => () => - agent(`Adversarially verify: ${f.title}`, { - label: `verify:${f.file}`, - phase: 'Verify', - schema: VERDICT_SCHEMA, - }).then(v => ({ ...f, verdict: v })) - ) - ) -) -// bugs findings verify while perf is still reviewing -const confirmed = results.flat().filter(Boolean) - .filter(f => f.verdict?.isReal)
-
-
- - -
-
- barrier -

parallel()

-
-
parallel(thunks: Array<() => Promise<any>>): Promise<any[]>
-
-
-

Semantics

-
    -
  • Runs thunks concurrently.
  • -
  • Barrier: awaits all before returning.
  • -
  • Throwing thunk / agent error → that slot is null. The call itself never rejects — always .filter(Boolean).
  • -
  • Use only when you genuinely need all results together.
  • -
-
-
-

Barrier is correct when…

-
    -
  • Dedup / merge across the full set before expensive work.
  • -
  • Early-exit if total count is zero.
  • -
  • Next stage’s prompt references “the other findings.”
  • -
-

Not justified by…

-
    -
  • “I need to flatten first” — do it inside a pipeline stage.
  • -
  • “Stages are conceptually separate” — pipeline already models that.
  • -
  • “Cleaner code” — barrier latency is real.
  • -
-
-
-
-
// Correct barrier: need ALL findings before expensive verification -const all = await parallel( - DIMENSIONS.map(d => () => agent(d.prompt, { schema: FINDINGS_SCHEMA })) -) -const deduped = dedupeByFileAndLine( - all.filter(Boolean).flatMap(r => r.findings) -) -const verified = await parallel( - deduped.map(f => () => agent(verifyPrompt(f), { schema: VERDICT_SCHEMA })) -)
-
-
- - -
-
- progress UX -

phase() · log()

-
-
phase(title: string): void -log(message: string): void
-
-
-

phase(title)

-

Starts a progress group. Subsequent agent() calls without explicit opts.phase group under this title in /workflows. Inside concurrent stages, prefer opts.phase to avoid races on the global phase state. Same string → same group box.

-
-
-

log(message)

-

Narrator line above the progress tree. Use for counts, dropped coverage, early-exit reasons — anything a silent cap would hide. “No silent caps” is a first-class quality rule.

-
-
-
- - -
-
- inputs & cost -

args · budget

-
-
args: any -// value of Workflow({ args }) — undefined if omitted - -budget: { - total: number | null, - spent(): number, - remaining(): number // max(0, total - spent) or Infinity if no target -}
-
-
-

args

-
    -
  • Parameterize named workflows: research question, file list, config object.
  • -
  • Pass real JSON: args: ["a.ts", "b.ts"] — not a stringified list.
  • -
  • Only channel for non-deterministic / external inputs that must stay stable across resume (timestamps, seeds as fixed values).
  • -
-
-
-

budget

-
    -
  • Turn token target from user “+500k”-style directives.
  • -
  • budget.total is null when no target was set.
  • -
  • spent() is shared across main loop + all workflows this turn.
  • -
  • Hard ceiling: further agent() calls throw once spent ≥ total.
  • -
  • Always guard loops with budget.total && — else remaining() is Infinity and you hit the 1000-agent cap.
  • -
-
-
-
-
// Scale depth to budget -const bugs = [] -while (budget.total && budget.remaining() > 50_000) { - const result = await agent('Find bugs…', { schema: BUGS_SCHEMA }) - bugs.push(...result.bugs) - log(`${bugs.length} found, ${Math.round(budget.remaining()/1000)}k remaining`) -} - -// Or static fleet sizing -const FLEET = budget.total - ? Math.floor(budget.total / 100_000) - : 5
-
-
- - -
-
- composition -

workflow()

-
-
workflow( - nameOrRef: string | { scriptPath: string }, - args?: any -): Promise<any>
-
-
    -
  • Run another workflow inline as a sub-step; return whatever it returns.
  • -
  • String name → saved/built-in registry (same as Workflow({ name })).
  • -
  • { scriptPath } → run a script file already on disk.
  • -
  • Child shares parent’s concurrency cap, agent counter, abort signal, and token budget.
  • -
  • Child agents appear under a nested group in /workflows.
  • -
  • Nesting is one level onlyworkflow() inside a child throws.
  • -
  • Throws on unknown name / unreadable path / child syntax error; catch to handle.
  • -
-
-
-
- - -
-
-

07 · Limits & sandbox

-

Hard bounds the engine enforces

-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
LimitValueNotes
Concurrent agent() callsmin(16, cpu_cores - 2) per workflowExcess queues; all items still complete.
Lifetime agent count1000Runaway-loop backstop.
Items per parallel/pipeline call4096 maxMore → explicit error, not silent truncate.
Workflow size guidelineuser /config: small≈5, medium≈15, large≈50, unrestrictedGuideline, not hard engine cap (unless user configures otherwise).
Script determinismno Date.now / Math.random / bare new DateRequired for resume cache identity.
Script I/OnoneNo FS/network/Node; only agent/workflow escapes.
Worktree isolationopt-in per agentExpensive; auto-clean if unchanged.
-
-
- - -
-
-

08 · Resume & journal

-

Edit the plan without redoing finished work

-
-

- Every launch returns a runId and persists the script. After a pause, kill, or script edit: stop the prior run, then relaunch with Workflow({ scriptPath, resumeFromRunId }). -

-
-
-

Cache hit rule

-

Longest unchanged prefix of agent() calls (same prompt + opts) returns cached results instantly. First edited/new call and everything after runs live.

-
-
-

Perfect replay

-

Same script + same args → 100% cache hit. Use this for pure post-processing edits after a completed run.

-
-
-

journal.jsonl

-

Under transcriptDir. Records each agent’s actual return value. Before diagnosing empty/weird results, read the journal — do not assume cached results are non-empty.

-
-
-
-
Workflow({ - scriptPath: '/…/workflows/scripts/review-wf_abc.js', - resumeFromRunId: 'wf_abc…', - args: previousArgs, -})
-
-
- Fallback. If no journal is available, read agent-<id>.jsonl files in the transcript directory and hand-author a continuation script. -
-
- - -
-
-

09 · Quality patterns

-

Composable harness shapes

-

These are not special APIs — they are recipes built from the primitives. Pick by task; compose freely.

-
- -
-
-

Adversarial verify

-

N independent skeptics per claim, prompted to refute. Kill if ≥ majority refute. Kills plausible-but-wrong findings.

-
const votes = await parallel(Array.from({length: 3}, () => () => - agent(`Try to refute: ${claim}. Default refuted=true if uncertain.`, { schema: VERDICT }) -)) -const survives = votes.filter(Boolean) - .filter(v => !v.refuted).length >= 2
-
-
-

Perspective-diverse verify

-

Distinct lenses (correctness, security, repro, perf) instead of N identical refuters. Diversity catches failure modes redundancy cannot.

-
-
-

Judge panel

-

N independent attempts from different angles (MVP-first, risk-first, user-first). Score in parallel; synthesize from winner while grafting runner-up ideas. Beats single-attempt iteration when the solution space is wide.

-
-
-

Loop-until-dry

-

Unknown-size discovery: keep finding until K consecutive rounds return nothing new. Dedup against all seen, not only confirmed — else rejected findings reappear forever.

-
-
-

Multi-modal sweep

-

Parallel agents each search a different way (by-container, by-content, by-entity, by-time). Each is blind to the others — covers angles one search cannot.

-
-
-

Completeness critic

-

Final agent asks: modality not run? claim unverified? source unread? Output becomes the next work round.

-
-
- -
-

Exhaustive review composition

-
const seen = new Set(), confirmed = [] -let dry = 0 -while (dry < 2) { - const found = (await parallel(FINDERS.map(f => () => - agent(f.prompt, { phase: 'Find', schema: BUGS }) - ))).filter(Boolean).flatMap(r => r.bugs) - const fresh = found.filter(b => !seen.has(key(b))) - if (!fresh.length) { dry++; continue } - dry = 0; fresh.forEach(b => seen.add(key(b))) - const judged = await parallel(fresh.map(b => () => - parallel(['correctness','security','repro'].map(lens => () => - agent(`Judge "${b.desc}" via ${lens} — real?`, { - phase: 'Verify', schema: VERDICT - }) - )).then(vs => ({ - b, - real: vs.filter(Boolean).filter(v => v.real).length >= 2 - })) - )) - confirmed.push(...judged.filter(v => v.real).map(v => v.b)) -} -return confirmed
-
- -
- Scale to the ask. “Find any bugs” → few finders, single-vote verify. “Thoroughly audit” → larger pool, 3–5 vote adversarial pass, synthesis stage. When unsure on research/review/audit: lean thorough; on quick checks: lean brief. -
-
- - -
-
-

10 · Lifecycle & operator UX

-

How a run feels from the outside

-
-
    -
  1. Authoring. Coordinator scouts, then writes inline script (or picks name).
  2. -
  3. Permission. User sees meta.description (and size guideline if set).
  4. -
  5. Launch. Tool returns immediately with taskId, runId, scriptPath, transcriptDir.
  6. -
  7. Progress. /workflows shows phase groups, labels, nested child workflows, narrator log() lines.
  8. -
  9. Completion. <task-notification> delivers the script’s return value to the coordinator.
  10. -
  11. Synthesis. Coordinator may run another workflow, resume with edits, or answer the user.
  12. -
-
-
-

Common single-phase workflows

-
    -
  • Understand — parallel readers → structured map
  • -
  • Design — judge panel of N approaches → scored synthesis
  • -
  • Review — dimensions → find → adversarially verify
  • -
  • Research — multi-modal sweep → deep-read → synthesize
  • -
  • Migrate — discover sites → transform (worktree) → verify
  • -
-
-
-

Multi-phase product work

-

Run several workflows in sequence across turns. The coordinator reads each result before choosing the next phase. Each workflow stays a well-scoped fan-out — not a giant forever-script.

-
-
-
- - -
-
-

11 · One level above agents

-

Bigger brain orchestrates smaller hands

-
-

- Classic multi-agent demos put several peers in a chat room and hope coordination emerges. Dynamic workflows invert that: coordination is a program written by a high-capability model; workers are replaceable execution units. -

- -
-
-

Orchestrator responsibilities

-
    -
  • Understand user intent and constraints
  • -
  • Discover the work-list (files, bugs, APIs, modules)
  • -
  • Choose pattern (pipeline vs barrier, depth vs breadth)
  • -
  • Author the script + schemas + prompts
  • -
  • Allocate model/effort tiers per stage
  • -
  • Interpret structured returns; decide next phase
  • -
  • Talk to the human; own correctness narrative
  • -
-
-
-

Worker responsibilities

-
    -
  • Execute one bounded prompt with tools
  • -
  • Return raw data or schema-validated objects
  • -
  • Stay isolated (optional worktree)
  • -
  • Do not redesign the global plan
  • -
  • May be cheaper/faster models for mechanical stages
  • -
  • May be specialized agentTypes (reviewer, explorer)
  • -
-
-
- -
-

Why this is “one level above”

-
-
-

Control altitude

- The orchestrator reasons about graphs, budgets, and verification policy — not about every file read. Workers absorb token-heavy tool churn inside their own contexts. -
-
-

Context isolation

- Each agent() gets a clean context for its unit of work. The script aggregates only return values. One agent’s rabbit hole cannot pollute another’s prompt. -
-
-

Deterministic spine

- Loops, fan-out, early-exit, and majority votes are code. They do not “forget” to verify on a bad day. The model invents the harness once; the engine executes it faithfully. -
-
-
- -
- Model tiering pattern. Keep the session model strong for orchestration (authoring scripts, reading results, deciding phases). Inside the workflow, omit model for most calls (inherit), or pin effort: 'low' / smaller models for mechanical map stages and reserve high effort for adversarial judges. The orchestrator’s context stays small; total work scales with fleet size. -
- -
-

Altitude diagram

-
User intent - │ - ▼ -┌──────────────────────────────────────────┐ -│ Orchestrator model (main session) │ -│ · plans · schemas · phase selection │ -│ · Workflow({ script, args }) │ -└───────────────────┬──────────────────────┘ - │ deterministic JS spine - ┌───────────┼───────────┐ - ▼ ▼ ▼ - agent() agent() agent() - worker A worker B worker C - (tools) (tools) (tools) - │ │ │ - └───────────┼───────────┘ - ▼ - structured returns - │ - ▼ - orchestrator synthesizes - │ - ▼ - user-facing answer
-
-
- - -
-
-

12 · What this feature enables

-

Workloads that were awkward before

-
- -
-
-

Comprehensive code review

-

Fan out by dimension (security, correctness, tests, perf). Verify each finding adversarially. Merge only survivors. Scale vote count to thoroughness of the ask.

-
-
-

Large migrations / refactors

-

Discover call sites, pipeline each site through transform + verify with isolation: 'worktree' so parallel mutators do not clobber each other. Resume after fixing one stage’s prompt.

-
-
-

Research & multi-source synthesis

-

Multi-modal sweep (web, code, docs, git history), deep-read promising hits, completeness critic, then cited synthesis — with budget-bounded loops.

-
-
-

Design exploration

-

Judge panel: N independent designs from different angles, scored in parallel, grafted synthesis. Better than iterating one design in a single context.

-
-
-

Unknown-size bug hunts

-

Loop-until-dry finders + diverse-lens judges. Dedup against seen set. Stop when two dry rounds pass. Depth scales with budget.total.

-
-
-

Self-repair implementation loops

-

Implement → multi-reviewer parallel → repair from structured findings → verify gates. Same shape as real engineering process, encoded as a script the orchestrator can re-run with resume.

-
-
-

Heterogeneous agent fleets

-

Mix agentTypes and models: explorer for map, implementer for edit, reviewer for audit. Orchestrator stays vendor of truth; workers stay specialists.

-
-
-

Phased product delivery under ultracode

-

Standing multi-agent mode: every substantive step is a workflow. Human watches /workflows; orchestrator chains phases across turns without stuffing everything into one mega-context.

-
-
- -
-

What it deliberately is not

-
    -
  • Not a durable multi-day job system with external supervisors (that is a different control plane).
  • -
  • Not free-form multi-agent chat; workers do not negotiate plan changes with each other.
  • -
  • Not automatic: user must opt in (or enable ultracode).
  • -
  • Not a replacement for single-agent work on small tasks — overhead is real.
  • -
-
-
- - -
-
-

13 · Cheatsheet

-

Quick reference

-
-
-
// Tool -Workflow({ script | name | scriptPath, args?, resumeFromRunId? }) - -// Script header (pure literal) -export const meta = { name, description, phases? } - -// Primitives -agent(prompt, { label, phase, schema, model, effort, isolation, agentType }) -pipeline(items, stage1, stage2, …) // no barrier — default -parallel([ () => …, … ]) // barrier — rare -phase(title) -log(message) -args // Workflow args, verbatim -budget.{ total, spent(), remaining() } // hard token ceiling -workflow(name | { scriptPath }, args?) // one-level nest - -// Rules of thumb -// 1. Default to pipeline; barrier only for cross-item merge. -// 2. Always .filter(Boolean) on parallel/agent results. -// 3. Prefer schema for structured returns. -// 4. Guard budget loops with budget.total && … -// 5. No Date.now / Math.random in scripts. -// 6. isolation:'worktree' only for parallel mutators. -// 7. log() anything a silent cap would hide. -// 8. Hybrid: scout → Workflow → synthesize → maybe next phase.
-
- -
-
-

Primitive count

-

8

-

agent · pipeline · parallel · phase · log · args · budget · workflow

-
-
-

Tool inputs

-

5

-

script · name · scriptPath · args · resumeFromRunId

-
-
-

Design goal

-

Altitude

-

Strong model plans; many workers execute; engine enforces the graph

-
-
- -

- Source of truth for this document: Claude Code binary tool description for the Workflow tool (v2.1.x family), observed script examples under session workflows/scripts/, and the runtime rules encoded in the tool prompt (opt-in, ultracode, resume, budget, concurrency). This is a model-facing API reference, not Anthropic product documentation. -

-
- -
-
- - diff --git a/docs/configuration.md b/docs/configuration.md index a7b20b8d1..bc5277d06 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -83,17 +83,18 @@ sessions. | Value | Behavior | | --- | --- | -| `full` | Default. Widget UI is attached to exposed workspace, workflow, file, edit, and shell tools, including read-only live workflow dashboards. | +| `full` | Default. Widget UI is attached to exposed workspace, file, edit, and shell tools. The workspace card includes a compact active-workflow summary. | | `changes` | Enables the aggregate `show_changes` tool and attaches widget UI to `open_workspace` and `show_changes`. | | `off` | Disables widget UI. | -## Skills +## Agent Tooling And Skills | Variable | Purpose | | --- | --- | | `DEVSPACE_SKILLS` | Set to `0` to hide skills. Enabled by default. | -| `DEVSPACE_SUBAGENTS` | Set to `1` to expose configured agent profiles as Subagents. Experimental and disabled by default. | -| `DEVSPACE_WORKFLOWS` | Experimental Dynamic Workflows gate. When unset, it follows the effective Subagents setting, including persisted config and any environment override. | +| `DEVSPACE_SUBAGENTS` | Enables direct subagents and, by default, Dynamic Workflows. | +| `DEVSPACE_WORKFLOWS` | Optional runtime override for the workflow CLI. When unset, it follows `DEVSPACE_SUBAGENTS`. | +| `DEVSPACE_AGENT_PROVIDERS` | Optional comma-separated provider allowlist: `codex`, `claude`, `opencode`, `pi`, `cursor`, or `copilot`. | | `DEVSPACE_AGENT_DIR` | Defaults to `~/.codex`; its `skills` child is loaded for compatibility. | | `DEVSPACE_SKILL_PATHS` | Optional comma-separated additional skill directories. | @@ -105,33 +106,31 @@ DevSpace discovers standard Agent Skills from: It also includes: -- the package-managed `subagents` skill when the Subagents capability is enabled -- the package-managed `dynamic-workflows` skill when the Dynamic Workflows capability is enabled +- managed `subagents` and `dynamic-workflows` skills installed by setup in `~/.devspace/skills` - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - additional paths from `DEVSPACE_SKILL_PATHS` -User and project skills with the same name take precedence over bundled skills. -DevSpace does not copy bundled skills into `~/.devspace/skills` during setup. +User and project skills with the same name take precedence. Setup updates only +copies marked as DevSpace-managed and preserves unmarked, user-owned skill +directories. -When Subagents are enabled, DevSpace discovers agent profiles -from: +When agent tooling is enabled, DevSpace discovers agent profiles from: - `~/.devspace/agents/*.md` - project `.devspace/agents/*.md` -`open_workspace` returns a compact catalog containing profile names, -descriptions, providers, and optional models/effort levels so the host model can choose an -agent without reading provider-specific launch details. `devspace agents ls` -lists existing subagent sessions for the current workspace, scoped by the -workspace environment injected into shell commands. The `subagents` -skill teaches the model to discover targets with `devspace agents targets`, -then use the minimal `devspace agents run`, `devspace agents show`, and -`devspace agents ls` workflow. - -Provider availability is detected at runtime. DevSpace does not persist probe -timestamps, availability snapshots, or an experimental provider enable-list in -`config.json`. Final provider policy and onboarding are deferred until the -Subagents and Dynamic Workflows features are finalized. +`open_workspace` returns only usable provider names and profile names with +descriptions. `devspace agents ls` lists existing subagent sessions for the +current workspace, scoped by the workspace environment injected into shell +commands. The `subagents` skill teaches the model to discover targets with +`devspace agents targets`, then use the minimal `devspace agents run`, +`devspace agents show`, and `devspace agents ls` workflow. + +Provider availability is detected at runtime. Setup persists the selected +provider names in `config.json`; unavailable and unselected providers and their +profiles are omitted from model-facing results. `devspace agents targets --json` +shows the complete usable CLI target catalog when a model needs provider, +model, or effort defaults for execution. Starter profile templates are available under `examples/agents/`. Copy or adapt them into one of the active profile directories before use. diff --git a/docs/dynamic-workflow/claude/README.md b/docs/dynamic-workflow/claude/README.md deleted file mode 100644 index b9ddb3502..000000000 --- a/docs/dynamic-workflow/claude/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# Claude Code Dynamic Workflows - -In-depth reference for Claude Code’s **dynamic workflow** system: the model-facing `Workflow` tool, the JavaScript script contract, every primitive injected into the script, resume/budget semantics, quality patterns, and how this enables a stronger model to orchestrate subagents one level above ordinary tool use. - -| Document | Contents | -|---|---| -| [Architecture](./architecture.md) | Three layers, control flow vs worker content, mental model | -| [Opt-in & Ultracode](./opt-in.md) | When the model may call Workflow; standing multi-agent mode | -| [Workflow tool API](./workflow-tool.md) | Tool inputs, return envelope, launch/iterate/named workflows | -| [Script contract](./script-contract.md) | `export const meta`, language rules, determinism bans | -| [Primitives overview](./primitives.md) | Map of all script hooks | -| [agent()](./agent.md) | Spawn API, schema, model/effort, worktree, agentType | -| [pipeline() & parallel()](./concurrency.md) | No-barrier default vs barrier; when each is correct | -| [phase, log, args, budget, workflow()](./control-and-io.md) | Progress UX, parameterization, token ceiling, nesting | -| [Limits & sandbox](./limits.md) | Concurrency caps, agent caps, script isolation | -| [Resume & journal](./resume.md) | `resumeFromRunId`, cache identity, `journal.jsonl` | -| [Quality patterns](./patterns.md) | Adversarial verify, judge panel, loop-until-dry, … | -| [Lifecycle & UX](./lifecycle.md) | Permission, `/workflows`, notifications, multi-phase | -| [One level above](./orchestration.md) | Bigger brain orchestrates smaller hands | -| [Use cases](./usecases.md) | Review fleets, migrations, research, self-repair | -| [Cheatsheet](./cheatsheet.md) | One-page API card | - -Related: standalone HTML overview at [`docs/claude-code-dynamic-workflows.html`](../../claude-code-dynamic-workflows.html). - ---- - -## One-sentence thesis - -**Deterministic control flow + stochastic workers + one orchestrator brain.** - -A single agent loop confuses *what to do next* with *how to do the work*. Dynamic workflows split them: the orchestrator model authors a short plain-JS script; the harness runs loops, conditionals, and fan-out as **code**; only `agent()` escapes into a model with tools. - -## Why it exists - -| Without workflows | With workflows | -|---|---| -| Fan-out is ad-hoc tool spam each turn | Fan-out is `parallel` / `pipeline` in a script | -| Verification is optional and forgettable | Verification is a stage in the graph | -| One context holds plan + all tool churn | Workers isolate tool churn; script aggregates returns | -| Scale = longer single conversation | Scale = fleet size × stages under budget | - -## Scope of this doc set - -- **In scope:** model-facing API as exposed by Claude Code ~2.1.x (`Workflow` / `RunWorkflow` tool, script primitives, opt-in, resume, budget, patterns). -- **Out of scope:** Anthropic product marketing, undocumented internal harness code, DevSpace’s separate durable workflow engine (see feature branches / other docs if present). - -Source basis: Claude Code Workflow tool description, session `workflows/scripts/*.js` examples, and engine rules encoded in the tool prompt (opt-in, ultracode, resume, concurrency). diff --git a/docs/dynamic-workflow/claude/agent.md b/docs/dynamic-workflow/claude/agent.md deleted file mode 100644 index 40fe3f97b..000000000 --- a/docs/dynamic-workflow/claude/agent.md +++ /dev/null @@ -1,205 +0,0 @@ -# `agent()` - -The only primitive that spends model/tool budget on real work. Everything else in the script is control flow, UX, or nesting. - -## Signature - -```ts -agent( - prompt: string, - opts?: { - label?: string - phase?: string - schema?: object // JSON Schema - model?: string // e.g. session model ids / 'sonnet' | 'opus' | 'haiku' - effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max' - isolation?: 'worktree' - agentType?: string // registry name: 'general-purpose', 'code-reviewer', 'claude', … - } -): Promise -``` - -## Return value - -| Mode | Resolves to | -|---|---| -| No `schema` | Final assistant text (`string`) | -| With `schema` | **Validated object** matching the JSON Schema (StructuredOutput tool; model retries on mismatch) | -| User skip / terminal API death after retries | `null` | - -Always treat results as possibly null when fan-out is large: - -```js -const rows = (await parallel(tasks)).filter(Boolean) -``` - -## Semantics - -1. Spawns a **subagent** with its own tool loop (Read, Bash, Edit, …). -2. Subagents are instructed that their **final text/object is the return value** to the coordinator script — not a user-facing message. -3. Prompt should be **self-contained**: workers do not inherit the full main-session transcript. -4. Session-connected **MCP tools** are reachable via ToolSearch (on-demand schemas). Interactively authenticated MCP may be missing in headless/cron. -5. Errors in the agent path surface as `null` for that call in combinators that swallow rejections; check journals if results look empty ([resume](./resume.md)). - -## Options - -### `label` - -Short string for `/workflows` progress UI (e.g. `review:security`, `verify:src/auth.ts`). Does not affect model behavior. - -### `phase` - -Explicit progress group assignment. **Prefer this inside `pipeline` / `parallel` stages** to avoid races on the global `phase()` state. Same string → same group box. Should match titles in `meta.phases` when you want tidy UI. - -### `schema` - -JSON Schema object. Forces structured output: - -- Validation at the tool-call layer. -- `agent()` returns the object — no `JSON.parse` of prose. -- Composes with `agentType` (StructuredOutput instruction is appended to that agent’s system prompt). - -Example: - -```js -const FINDINGS = { - type: 'object', - properties: { - findings: { - type: 'array', - items: { - type: 'object', - properties: { - file: { type: 'string' }, - line: { type: 'integer' }, - issue: { type: 'string' }, - severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] }, - }, - required: ['file', 'line', 'issue', 'severity'], - additionalProperties: false, - }, - }, - summary: { type: 'string' }, - }, - required: ['findings', 'summary'], - additionalProperties: false, -} - -const result = await agent( - 'Review auth changes for session fixation. Read-only. Cite file:line.', - { - label: 'review:auth', - phase: 'Review', - schema: FINDINGS, - effort: 'medium', - } -) -// result.findings is already shaped -``` - -### `model` - -Override the model for this call. - -- **Default: omit** — inherit the main-loop / session model (almost always correct). -- Set only when you are confident a different tier fits (e.g. small model for mechanical map, large for hard judge). -- When unsure, omit. - -### `effort` - -Reasoning effort for this call: `'low' | 'medium' | 'high' | 'xhigh' | 'max'`. - -- Omit → inherit session effort. -- Use `'low'` for cheap mechanical stages (enumerate files, simple extract). -- Reserve higher tiers for hard verify / judge / design stages. - -### `isolation: 'worktree'` - -Runs the agent in a **fresh git worktree**. - -| Property | Detail | -|---|---| -| Cost | Expensive (~200–500ms setup + disk) per agent | -| When | **Only** when agents **mutate files in parallel** and would otherwise conflict | -| Cleanup | Auto-removed if unchanged | -| When not | Read-only review, single writer, sequential pipeline of mutators on one tree | - -### `agentType` - -Custom subagent from the **same registry as the Agent tool** (e.g. `general-purpose`, `code-reviewer`, `Explore`, project-defined types, or `claude` where configured). - -- Overrides the default workflow subagent personality/tools policy for that call. -- Composes with `schema`. - -## Prompting workers well - -Workers start with **only** the prompt you pass (+ profile/system for `agentType`). Patterns: - -**Implementation** - -```text -Goal: … -Context: … -Relevant files: … -Acceptance criteria: -- … -Rules: -- Keep changes focused -- Do not unrelated-refactor -- Report blockers clearly -``` - -**Read-only investigation** - -```text -Question: … -Scope: … -Rules: -- Do not modify files -- Cite paths and symbols -- Separate facts from guesses -``` - -**Structured judge / refuter** - -```text -Try to REFUTE: -Default to refuted=true if uncertain. -Return only via the schema fields. -``` - -Pass prior stage data by **embedding it in the prompt** (stringified structured JSON), not by shared mutable state — scripts have no shared worker memory beyond what you thread through returns. - -## Cost & altitude tips - -| Stage kind | Typical opts | -|---|---| -| Enumerate / map / extract | `effort: 'low'`, maybe smaller `model` | -| Implement / edit | inherit model; medium effort; `isolation: 'worktree'` if parallel | -| Review dimension | schema + medium effort | -| Adversarial judge | higher effort; schema; independent prompts | -| Final verify gates | low/medium; focused prompt | - -The orchestrator stays high-altitude by keeping **structure** in the script and **content** in workers. See [orchestration](./orchestration.md). - -## Null and failure hygiene - -```js -const reviews = await parallel([ - () => agent(p1, { schema: FINDINGS }), - () => agent(p2, { schema: FINDINGS }), - () => agent(p3, { schema: FINDINGS }), -]).then(xs => xs.filter(Boolean)) - -if (!reviews.length) { - log('all reviewers failed or were skipped') - return { confirmed: [], error: 'no_reviews' } -} -``` - -Before claiming “workflow returned empty,” read `transcriptDir/journal.jsonl` — cached or failed agents may explain it ([resume](./resume.md)). - -## Next - -- [pipeline & parallel](./concurrency.md) -- [Patterns](./patterns.md) diff --git a/docs/dynamic-workflow/claude/architecture.md b/docs/dynamic-workflow/claude/architecture.md deleted file mode 100644 index 180e2fb62..000000000 --- a/docs/dynamic-workflow/claude/architecture.md +++ /dev/null @@ -1,101 +0,0 @@ -# Architecture - -## Three layers - -``` -User intent - │ - ▼ -┌──────────────────────────────────────────┐ -│ 1. Coordinator (main session model) │ -│ · talks to user · scouts work-list │ -│ · authors / selects script │ -│ · calls Workflow({ script, args }) │ -│ · synthesizes return value for user │ -└───────────────────┬──────────────────────┘ - │ Workflow tool (async) - ▼ -┌──────────────────────────────────────────┐ -│ 2. Workflow engine (JS runtime) │ -│ · parses export const meta │ -│ · runs script in async context │ -│ · hosts agent/pipeline/parallel/… │ -│ · enforces concurrency & agent caps │ -│ · journals each agent() for resume │ -└───────────────────┬──────────────────────┘ - │ agent() × N - ┌───────────┼───────────┐ - ▼ ▼ ▼ -┌────────────┐ ┌────────────┐ ┌────────────┐ -│ Subagent A │ │ Subagent B │ │ Subagent C │ -│ own tools │ │ own tools │ │ own tools │ -│ optional │ │ schema / │ │ worktree / │ -│ return │ │ model / │ │ agentType │ -│ text|obj │ │ effort │ │ │ -└────────────┘ └────────────┘ └────────────┘ -``` - -### 1. Coordinator (main loop) - -- Owns the conversation with the human. -- Scouts the repo / discovers the work-list *before* orchestration when possible (hybrid default). -- Decides whether Workflow is allowed ([opt-in](./opt-in.md)). -- Authors or selects the script and `args`. -- Receives the script’s return value after completion and narrates / chains the next phase. - -The coordinator is the **only** place that should redesign the global plan. Workers execute units; they do not renegotiate the graph with each other. - -### 2. Workflow engine - -- Not another chat peer. It is a **small concurrent orchestration runtime**. -- Script language: plain JavaScript (not TypeScript). -- Injected APIs only: see [primitives](./primitives.md). -- Progress UI: `/workflows` groups agents by `phase` / `label`. -- Persistence: script path under session directory, `runId`, transcripts, `journal.jsonl`. - -### 3. Subagents - -- Full tool loops of their own (Read, Bash, Edit, MCP via ToolSearch, …). -- Final text **is** the return value (or a schema-validated object) — not a user-facing essay unless the prompt asks for one. -- Optional isolation (`isolation: 'worktree'`), model, effort, and `agentType` overrides per call. - -## Mental model - -```js -// Coordinator decides STRUCTURE -Workflow({ script, args }) - → JS engine runs control flow - → agent(prompt, opts) × N // workers decide CONTENT - → script return value -→ coordinator narrates to user -``` - -| Concern | Who owns it | -|---|---| -| User intent, product judgment | Coordinator | -| Graph shape (fan-out, verify, merge) | Script (authored by coordinator) | -| Tool use, file reads, edits | Subagents | -| Concurrency caps, resume cache, budget hard stop | Engine | -| Permission to multi-agent at all | User (opt-in / ultracode) | - -## Hybrid default - -You do **not** need the full orchestration shape before starting the *task*. You need it before the *orchestration step*: - -1. Scout inline (list files, scope diff, find call sites). -2. Build the work-list in the coordinator context. -3. Call `Workflow` to pipeline over that list. -4. Read the result; optionally chain another workflow for the next phase. - -For larger product work, prefer **several well-scoped workflows across turns** over one forever-script. - -## What is not a layer - -- Workers do not form a free-form multi-agent chat room. -- The script has no filesystem or network — it cannot “just run shell.” Escape is only `agent()` / nested `workflow()`. -- The Workflow tool returns **immediately** (async launch). Completion arrives via task notification; live progress is `/workflows`. - -## Next - -- [Opt-in & Ultracode](./opt-in.md) -- [Workflow tool API](./workflow-tool.md) diff --git a/docs/dynamic-workflow/claude/cheatsheet.md b/docs/dynamic-workflow/claude/cheatsheet.md deleted file mode 100644 index 7604ac572..000000000 --- a/docs/dynamic-workflow/claude/cheatsheet.md +++ /dev/null @@ -1,154 +0,0 @@ -# Cheatsheet - -One-page API card. Details in the linked docs. - -## Tool - -```js -Workflow({ - script?, // inline JS; must start with pure-literal meta - name?, // built-in or .claude/workflows/ - scriptPath?, // persisted path; wins over script/name - args?, // verbatim → global args (real JSON, not stringified) - resumeFromRunId?, // ^wf_[a-z0-9-]{6,}$ stop prior run first -}) -// need: script | name | scriptPath -// returns async launch: taskId, runId, scriptPath, transcriptDir, … -``` - -[workflow-tool.md](./workflow-tool.md) · [opt-in.md](./opt-in.md) - -## Script header - -```js -export const meta = { - name: '…', // required, pure literal - description: '…', // required — permission dialog - phases: [ // optional - { title: 'Scan', detail: '…', model: 'sonnet' }, - ], - // whenToUse?: '…' -} -// plain JS only — no TS types -// no Date.now / Math.random / bare new Date -``` - -[script-contract.md](./script-contract.md) - -## Primitives - -```ts -agent(prompt, { - label?, phase?, schema?, model?, effort?, - isolation?: 'worktree', agentType?, -}): Promise - -pipeline(items, stage1, stage2, …): Promise -// stage(prev, originalItem, index) — NO barrier between stages - -parallel(thunks: Array<() => Promise>): Promise -// BARRIER; slots null on error; never rejects - -phase(title: string): void -log(message: string): void - -args: any -budget: { total: number|null, spent(): number, remaining(): number } - -workflow(name | { scriptPath }, args?): Promise -// nest depth 1 only -``` - -[primitives.md](./primitives.md) · [agent.md](./agent.md) · [concurrency.md](./concurrency.md) · [control-and-io.md](./control-and-io.md) - -## Rules of thumb - -1. Default multi-stage → **`pipeline`**; barrier only for cross-item merge. -2. Always **`.filter(Boolean)`** on parallel / nullable agent results. -3. Prefer **`schema`** for structured handoffs. -4. Guard budget loops: **`budget.total && budget.remaining() > …`**. -5. No entropy in scripts (resume safety). -6. **`isolation: 'worktree'`** only for parallel mutators. -7. **`log()`** anything a silent cap would hide. -8. Hybrid: scout → Workflow → synthesize → maybe next phase. -9. Omit **`model`** unless tier fit is clear. -10. Dedup open-ended hunts against **`seen`**, not only confirmed. - -## Caps (engine) - -| Cap | Value | -|---|---| -| Concurrent agents | `min(16, cores-2)` / workflow (queue rest) | -| Lifetime agents | 1000 / run | -| Items / parallel|pipeline | 4096 | -| Nested workflow | depth 1 | -| Budget | hard throw when spent ≥ total | - -[limits.md](./limits.md) - -## Resume - -```js -// stop prior run, then: -Workflow({ - scriptPath, - resumeFromRunId: runId, - args: sameArgs, -}) -// longest unchanged agent() prefix → cache -// read transcriptDir/journal.jsonl if results look wrong -``` - -[resume.md](./resume.md) - -## Pattern stubs - -```js -// adversarial verify -const votes = await parallel(Array.from({ length: 3 }, () => () => - agent(`Refute: ${claim}. Default refuted=true if uncertain.`, { schema: V }) -)) -const ok = votes.filter(Boolean).filter(v => !v.refuted).length >= 2 - -// loop-until-budget -while (budget.total && budget.remaining() > 50_000) { - const r = await agent('…', { schema: S }) - /* accumulate */ log(`${budget.remaining()} left`) -} - -// canonical review pipeline -await pipeline( - DIMENSIONS, - d => agent(d.prompt, { phase: 'Review', schema: F }), - review => parallel(review.findings.map(f => () => - agent(`Verify: ${f.title}`, { phase: 'Verify', schema: V }) - .then(v => ({ ...f, verdict: v })) - )) -) -``` - -[patterns.md](./patterns.md) - -## Opt-in (must have one) - -- User said `ultracode` / session ultracode on -- User asked for workflow / fan-out / multi-agent orchestration -- Skill/command requires Workflow -- Named workflow requested - -Else: single `Agent` or ask. - -## Altitude - -| Layer | Owns | -|---|---| -| Orchestrator | Intent, graph, schemas, synthesis, user | -| Script | Loops, fan-out, votes, budget stops | -| Workers | Tools, content, optional worktree | -| Engine | Caps, journal, UI, permissions plumbing | - -[orchestration.md](./orchestration.md) · [usecases.md](./usecases.md) - -## Index - -[README](./README.md) · [Architecture](./architecture.md) · [Lifecycle](./lifecycle.md) diff --git a/docs/dynamic-workflow/claude/concurrency.md b/docs/dynamic-workflow/claude/concurrency.md deleted file mode 100644 index bf67020ac..000000000 --- a/docs/dynamic-workflow/claude/concurrency.md +++ /dev/null @@ -1,191 +0,0 @@ -# `pipeline()` & `parallel()` - -These two combinators are the heart of multi-agent structure. Using the wrong one wastes wall-clock or forces incorrect synchronization. - -## Quick contrast - -| | `pipeline` | `parallel` | -|---|---|---| -| Input | `items[]` + stage functions | `thunks[]` of `() => Promise` | -| Sync model | **No barrier** between stages | **Barrier** — wait for all thunks | -| Wall-clock | ≈ slowest **item chain** | ≈ slowest **thunk** (then next barrier stage) | -| Failure | Stage throw → that item becomes `null`, later stages skipped for it | Thunk throw / agent error → slot `null`; call never rejects | -| Default for multi-stage? | **Yes** | No — only when you need all results together | - ---- - -## `pipeline` - -### Signature - -```ts -pipeline( - items: any[], - stage1: (prev, originalItem, index) => any | Promise, - stage2?: (prev, originalItem, index) => any | Promise, - // ... -): Promise -``` - -### Semantics - -- Each **item** flows through **all stages independently**. -- Item A may be in stage 3 while item B is still in stage 1. -- Every stage receives `(prevResult, originalItem, index)`: - - Use `originalItem` / `index` to label work without stuffing identity only into stage-1 returns. -- A stage that **throws** drops that item to `null` and skips remaining stages for that item. -- Max items per call: **4096** (hard error if exceeded) — see [limits](./limits.md). - -### Canonical multi-stage pattern - -Review by dimension, then verify each finding **as soon as that dimension finishes** (not after all dimensions finish): - -```js -export const meta = { - name: 'review-changes', - description: 'Review changed files across dimensions, verify each finding', - phases: [{ title: 'Review' }, { title: 'Verify' }], -} - -const DIMENSIONS = [ - { key: 'bugs', prompt: '…' }, - { key: 'perf', prompt: '…' }, -] - -const results = await pipeline( - DIMENSIONS, - d => agent(d.prompt, { - label: `review:${d.key}`, - phase: 'Review', - schema: FINDINGS_SCHEMA, - }), - review => parallel( - review.findings.map(f => () => - agent(`Adversarially verify: ${f.title}`, { - label: `verify:${f.file}`, - phase: 'Verify', - schema: VERDICT_SCHEMA, - }).then(v => ({ ...f, verdict: v })) - ) - ) -) - -const confirmed = results - .flat() - .filter(Boolean) - .filter(f => f.verdict?.isReal) - -return { confirmed } -// Dimension "bugs" findings verify while "perf" is still reviewing. -``` - -### Transform inside a stage (no extra barrier) - -```js -// ❌ Smell: barrier only to flatten -const a = await parallel(items.map(i => () => agent(…))) -const b = a.filter(Boolean).flatMap(x => x.findings) -const c = await parallel(b.map(f => () => agent(verify(f)))) - -// ✅ Pipeline with transform in a stage -const c = await pipeline( - items, - i => agent(…), - r => r.findings, // pure transform - f => agent(verify(f), { schema: V }) // or map to parallel inside if many findings -) -``` - -If one item produces many findings, a stage may return `parallel(findings.map(...))` as in the canonical example. - ---- - -## `parallel` - -### Signature - -```ts -parallel(thunks: Array<() => Promise>): Promise -``` - -### Semantics - -- Runs thunks **concurrently**. -- **Barrier:** does not resolve until every thunk settles. -- A throwing thunk (or agent error) becomes **`null`** in that index — the `parallel` call **itself never rejects**. -- Always `.filter(Boolean)` before treating results as data. -- Same concurrency / item caps as overall engine ([limits](./limits.md)). - -### When a barrier is correct - -Use `parallel` (or a barrier between pipeline stages implemented via collecting all items) **only** when stage N needs **cross-item** context from **all** of stage N−1: - -1. **Dedup / merge** across the full set before expensive work. -2. **Early-exit** if total count is zero (“0 bugs → skip verification”). -3. Next prompt **references “the other findings”** for comparison. - -```js -// Correct barrier: need ALL findings before expensive verification -const all = await parallel( - DIMENSIONS.map(d => () => agent(d.prompt, { schema: FINDINGS_SCHEMA })) -) -const deduped = dedupeByFileAndLine( - all.filter(Boolean).flatMap(r => r.findings) -) -if (!deduped.length) { - log('0 findings — skip verify') - return { confirmed: [] } -} -const verified = await parallel( - deduped.map(f => () => agent(verifyPrompt(f), { schema: VERDICT_SCHEMA })) -) -``` - -### When a barrier is NOT justified - -| Bad reason | Do this instead | -|---|---| -| “I need to flatten/map/filter first” | Transform inside a `pipeline` stage | -| “Stages are conceptually separate” | `pipeline` already models separate stages without sync | -| “It’s cleaner code” | Barrier latency is real — if 5 finders run and the slowest is 3× the fastest, a barrier wastes most of the fast agents’ idle time | - -**Smell test:** if you wrote `parallel → transform → parallel` with no cross-item dependency, rewrite as `pipeline`. - ---- - -## Nested concurrency - -Stages of a `pipeline` may call `parallel` (per item). Outer `parallel` may launch whole pipelines. Nested `workflow()` shares the parent concurrency pool. - -```js -// Per-item: many judges after one finder -await pipeline( - targets, - t => agent(findPrompt(t), { schema: BUGS }), - found => parallel( - found.bugs.map(b => () => agent(judgePrompt(b), { schema: VERDICT })) - ) -) -``` - -## Concurrency cap interaction - -Only ~`min(16, cpu_cores - 2)` agents run at once per workflow; the rest **queue**. You can still pass large arrays — they complete, they just don’t all run simultaneously. See [limits](./limits.md). - -## Decision flowchart - -``` -Need multi-stage over a list? - │ - ├─ Does stage N need the FULL set from stage N-1? - │ yes → parallel (barrier) then next stage - │ no → pipeline(items, stage1, stage2, …) - │ - └─ Single fan-out, one stage only? - → parallel([() => agent…, …]) or pipeline(items, oneStage) -``` - -## Next - -- [phase, log, args, budget, workflow()](./control-and-io.md) -- [Patterns](./patterns.md) diff --git a/docs/dynamic-workflow/claude/control-and-io.md b/docs/dynamic-workflow/claude/control-and-io.md deleted file mode 100644 index b347469db..000000000 --- a/docs/dynamic-workflow/claude/control-and-io.md +++ /dev/null @@ -1,179 +0,0 @@ -# `phase`, `log`, `args`, `budget`, `workflow()` - -Progress UX, parameterization, token ceilings, and one-level nesting. - ---- - -## `phase` - -```ts -phase(title: string): void -``` - -- Starts a **progress group** in `/workflows`. -- Subsequent `agent()` calls **without** `opts.phase` group under this title. -- Titles should match `meta.phases[].title` exactly for clean UI; unmatched titles still get their own group. -- Inside concurrent stages, prefer **`opts.phase`** on each `agent()` to avoid races on the global phase state: - -```js -// Global phase — fine for sequential sections -phase('Implement') -await agent('…', { label: 'impl' }) - -// Concurrent — set phase per agent -await parallel([ - () => agent('…', { phase: 'Review', label: 'r1' }), - () => agent('…', { phase: 'Review', label: 'r2' }), -]) -``` - -`phase` is UX only — it does not create isolation, budget buckets, or barriers. - ---- - -## `log` - -```ts -log(message: string): void -``` - -- Emits a **narrator line** above the progress tree. -- Use for counts, early exits, dropped coverage, loop progress. - -**Rule: no silent caps.** If the workflow bounds coverage (top-N, sampling, “first 20 files”), `log()` what was dropped. Silent truncation reads as “we covered everything.” - -```js -if (files.length > 50) { - log(`scoping to first 50 of ${files.length} files`) - files = files.slice(0, 50) -} -``` - ---- - -## `args` - -```ts -args: any // Workflow({ args }) value, or undefined if omitted -``` - -### Rules - -1. Value is **verbatim** from the tool call. -2. Pass **real** JSON arrays/objects in the tool invocation — **not** a stringified JSON blob. - -```js -// ✅ -Workflow({ script, args: ['a.ts', 'b.ts'] }) -// in script: args.map(f => …) - -// ❌ -Workflow({ script, args: '["a.ts","b.ts"]' }) -// args is a string → args.map throws -``` - -3. Primary channel for **parameterizing** named workflows (research question, path list, config). -4. Primary channel for values that must stay **stable across resume** (fixed timestamps, seeds) — see [script determinism](./script-contract.md). - -```js -// script -const topic = args?.topic ?? 'authentication' -const files = args?.files ?? [] -await agent(`Review ${topic} in ${files.join(', ')}`, { schema: FINDINGS }) -``` - ---- - -## `budget` - -```ts -budget: { - total: number | null - spent(): number - remaining(): number // max(0, total - spent) or Infinity if no target -} -``` - -### Semantics - -| Field / method | Meaning | -|---|---| -| `total` | Turn token target from user “+500k”-style directives; `null` if unset | -| `spent()` | Output tokens spent this turn across **main loop + all workflows** (shared pool) | -| `remaining()` | `max(0, total - spent())`, or **`Infinity`** if no target | - -### Hard ceiling - -Once `spent()` reaches `total`, further **`agent()` calls throw**. This is not advisory. - -### Guard loops - -Without a target, `remaining()` is `Infinity` and a `while (budget.remaining() > …)` loop runs until the **1000-agent** lifetime cap. Always guard: - -```js -const bugs = [] -while (budget.total && budget.remaining() > 50_000) { - const result = await agent('Find bugs in this codebase.', { schema: BUGS_SCHEMA }) - bugs.push(...result.bugs) - log(`${bugs.length} found, ${Math.round(budget.remaining() / 1000)}k remaining`) -} -``` - -### Static fleet sizing - -```js -const FLEET = budget.total - ? Math.floor(budget.total / 100_000) - : 5 -``` - -### Loop-until-count (no budget) - -```js -const bugs = [] -while (bugs.length < 10) { - const result = await agent('Find bugs…', { schema: BUGS_SCHEMA }) - bugs.push(...result.bugs) - log(`${bugs.length}/10 found`) -} -``` - -Prefer budget-aware or dry-round stops for open-ended hunts ([patterns](./patterns.md)). - ---- - -## Nested `workflow()` - -```ts -workflow( - nameOrRef: string | { scriptPath: string }, - args?: any -): Promise -``` - -### Semantics - -| Aspect | Detail | -|---|---| -| Purpose | Run another workflow **inline** as a sub-step; return its return value | -| `string` | Name in saved/built-in registry (same as `Workflow({ name })`) | -| `{ scriptPath }` | Script file on disk (e.g. previously persisted) | -| Shared with parent | Concurrency cap, agent counter, abort signal, token `budget` | -| UI | Child agents under a nested group in `/workflows` | -| Nesting depth | **One level only** — `workflow()` inside a child **throws** | -| Errors | Unknown name / unreadable path / child syntax error → throw (catch to handle) | - -```js -const map = await workflow('understand-subsystem', { root: 'src/auth' }) -const plan = await workflow('design-panel', { context: map }) -return { map, plan } -``` - -Use nesting to compose **reusable** named workflows. Prefer sequential top-level `Workflow` tool calls across turns when the coordinator must read results and replan with the user. - ---- - -## Next - -- [Limits & sandbox](./limits.md) -- [Resume & journal](./resume.md) diff --git a/docs/dynamic-workflow/claude/lifecycle.md b/docs/dynamic-workflow/claude/lifecycle.md deleted file mode 100644 index 4a2e41c65..000000000 --- a/docs/dynamic-workflow/claude/lifecycle.md +++ /dev/null @@ -1,108 +0,0 @@ -# Lifecycle & operator UX - -How a workflow run feels end-to-end. - -## Happy path - -``` -1. Authoring - Coordinator scouts → builds work-list → writes inline script (or picks name) - -2. Permission - User sees meta.description (+ size guideline if configured) - -3. Launch - Workflow tool returns immediately: - taskId, runId, scriptPath, transcriptDir, workflowName, status - -4. Progress - /workflows shows: - · phase groups - · agent labels - · nested child workflow groups - · narrator log() lines - -5. Completion - delivers script return value to coordinator - -6. Synthesis - Coordinator may: - · answer the user - · edit scriptPath + resume - · launch another Workflow for the next phase -``` - -## Multi-phase product work - -Prefer **several workflows across turns** over one mega-script: - -| Turn | Workflow | Coordinator does after | -|---|---|---| -| 1 | Understand | Read map; decide design scope | -| 2 | Design panel | Pick approach with user if needed | -| 3 | Implement + review repair | Inspect diff; request fixes | -| 4 | Verify / audit | Ship narrative + residual risk | - -The coordinator **stays in the loop** between phases — that is a feature. Ultracode makes this the default for substantive work ([opt-in](./opt-in.md)). - -## Hybrid single-phase - -``` -scout (inline tools) - → Workflow(pipeline over discovered items) - → synthesize answer -``` - -You need the work-list shape before the orchestration step, not before any investigation. - -## Background vs blocking mental model - -From the model’s perspective: - -- The `Workflow` **tool call** returns once the run is **registered** (async launch). -- The **result of the script** arrives later via notification / task completion channel. -- Do not assume the tool return value is the script’s `return {…}` object. - -Operators watch `/workflows` for live structure. - -## Iteration loop - -``` -launch → observe → Edit(scriptPath) → stop if needed → resumeFromRunId -``` - -See [resume](./resume.md). - -## Failure / skip paths - -| Event | Typical handling | -|---|---| -| Syntax error in script | `error` on launch result; fix script, relaunch | -| User denies permission | No run; ask or fall back to single Agent | -| User skips individual agent | That `agent()` → `null`; filter and continue or abort in script | -| Budget exhausted | Further `agent()` throws; catch / end loop; return partial | -| Agent terminal API failure | `null`; log; optionally retry with new call (not automatic) | - -## Named vs inline scripts - -| Mode | When | -|---|---| -| Inline `script` | First design of a one-off harness | -| `scriptPath` iterate | Evolving a run without re-pasting | -| `name` / `.claude/workflows/` | Reusable team harnesses | -| Nested `workflow(name)` | Compose reusable pieces inside a parent script | - -## Common single-phase catalog - -| Name | Intent | -|---|---| -| Understand | Parallel readers → structured map | -| Design | Judge panel → scored synthesis | -| Review | Dimensions → find → adversarially verify | -| Research | Multi-modal sweep → deep-read → synthesize | -| Migrate | Discover → transform (worktree) → verify | - -## Next - -- [One level above / orchestration](./orchestration.md) -- [Use cases](./usecases.md) diff --git a/docs/dynamic-workflow/claude/limits.md b/docs/dynamic-workflow/claude/limits.md deleted file mode 100644 index 268dfb93a..000000000 --- a/docs/dynamic-workflow/claude/limits.md +++ /dev/null @@ -1,76 +0,0 @@ -# Limits & sandbox - -Hard bounds and isolation properties of the Claude Code workflow engine (model-facing behavior). - -## Engine caps - -| Limit | Value | Behavior if exceeded | -|---|---|---| -| Concurrent `agent()` calls | `min(16, cpu_cores - 2)` **per workflow** | Excess **queue**; still complete | -| Lifetime agent count | **1000** per workflow run | Runaway-loop backstop | -| Items per `parallel` / `pipeline` call | **4096** | **Explicit error** (not silent truncate) | -| Script non-determinism | `Date.now` / `Math.random` / bare `new Date` | **Throw** | -| Nested `workflow()` depth | **1** | Nested call inside child **throws** | -| Token budget | User “+N” target if set | Further `agent()` **throws** when spent ≥ total | - -Queued concurrency means you can pass large work-lists safely; wall-clock still stretches when the queue is deep. - -## Soft guidelines (not hard engine caps) - -| Guideline | Source | -|---|---| -| Workflow size: small ≈ 5, medium ≈ 15, large ≈ 50, unrestricted | User `/config` workflow size guideline | -| Thoroughness vs brevity | Task wording (“any bugs” vs “thoroughly audit”) | - -The model should treat size guidelines as authoring policy unless the user explicitly overrides with scale language or ultracode. - -## Script sandbox - -| Available | Not available | -|---|---| -| Plain JS built-ins (`JSON`, `Math`, `Array`, …) | Node APIs, `require`, `process` | -| Injected primitives | Filesystem, network, subprocess from script | -| `agent` / nested `workflow` as escape hatches | Direct shell or edit from script | - -Side effects (file edits, network via tools, git) happen **inside subagents** under normal Claude Code tool permission policy — not as raw script I/O. - -## Worktree isolation (per agent) - -```js -await agent(prompt, { isolation: 'worktree' }) -``` - -| Property | Detail | -|---|---| -| Cost | ~200–500ms setup + disk **per agent** | -| Use when | Parallel **mutators** would conflict on one checkout | -| Cleanup | Auto-remove if worktree unchanged | -| Avoid when | Read-only work, single writer, or sequential mutators | - -This is **opt-in per `agent()`**, not a default for all workers. - -## Permission / product gates - -Separate from engine caps: - -- User must [opt in](./opt-in.md) (or enable ultracode) before `Workflow` is called. -- Script `meta.description` surfaces in the permission dialog. -- Individual agents may still hit tool permission prompts per session policy. - -## MCP caveats - -Workflow agents can use session-connected MCP tools via ToolSearch. **Interactively authenticated** MCP servers (e.g. browser login flows) may be absent in headless or cron-style runs. - -## Practical sizing - -| Ask | Rough shape | -|---|---| -| “Find any bugs” | Few finders, single-vote verify | -| “Thoroughly audit” | Larger finder pool, 3–5 vote adversarial pass, synthesis | -| Open-ended hunt + budget | `while (budget.total && budget.remaining() > …)` | -| Open-ended hunt, no budget | loop-until-dry with dry-round counter — not unbounded `while(true)` without exit | - -## Next - -- [Resume & journal](./resume.md) -- [Patterns](./patterns.md) diff --git a/docs/dynamic-workflow/claude/opt-in.md b/docs/dynamic-workflow/claude/opt-in.md deleted file mode 100644 index 6a4b6b001..000000000 --- a/docs/dynamic-workflow/claude/opt-in.md +++ /dev/null @@ -1,67 +0,0 @@ -# Opt-in & Ultracode - -Workflows can spawn dozens of agents and consume large token budgets. Claude Code therefore gates the `Workflow` tool behind **explicit user opt-in**. A task that would merely “benefit from parallelism” is not enough. - -## When Workflow is allowed - -The model may call `Workflow` only when at least one of these holds: - -1. **Keyword `ultracode` in the user prompt** - A system-reminder confirms it for that turn/session context. - -2. **Session ultracode is on** - Standing opt-in for every substantive task (see below). - -3. **User asks in their own words** for multi-agent orchestration - Examples: “use a workflow”, “run a workflow”, “fan out agents”, “orchestrate this with subagents”. - The ask must be in the **user’s** words — not inferred from task complexity. - -4. **A skill / slash command’s instructions** tell the model to call Workflow. - -5. **User asks for a specific named / saved workflow.** - -## When Workflow is not allowed - -Even if multi-agent would clearly help: - -- Silent model decision that “parallel is better.” -- Inferring opt-in from difficulty alone. -- “This would be faster with more agents” without user request. - -**Fallback:** - -- Use the single `Agent` tool for one-off subagents, or -- Briefly describe what a multi-agent workflow would do and rough cost, and ask. Mention the user can say “use a workflow” later to skip the ask. - -## Ultracode mode - -When a system-reminder confirms ultracode is **on**: - -| Rule | Behavior | -|---|---| -| Opt-in | Standing — every substantive task may/should use a workflow | -| Goal | Most exhaustive correct answer; token cost is not a constraint | -| Multi-phase | Often **several workflows in sequence** (understand → design → implement → review) so the coordinator stays in the loop between phases | -| Patterns | Prefer adversarial verify, multi-modal sweep, completeness critic, loop-until-dry where they fit | -| Solo exception | Pure conversation or trivial mechanical edits only | - -When ultracode is **off**, revert to the explicit opt-in list above. - -## Hybrid still applies - -Opt-in does not mean “dump the whole task into one script immediately.” - -1. Scout inline → discover work-list. -2. Author workflow over that list. -3. Synthesize; chain next phase if needed. - -## Cost and size guidelines - -Users may set a **workflow size guideline** in `/config` (e.g. small ≈ 5 agents, medium ≈ 15, large ≈ 50, unrestricted). This is a **guideline** for the model’s authoring behavior, not the same as the engine’s hard caps ([limits](./limits.md)). - -Budget directives (“+500k”-style) feed `budget.total` inside scripts — a **hard** ceiling on further `agent()` calls once spent. See [control-and-io](./control-and-io.md). - -## Next - -- [Workflow tool API](./workflow-tool.md) -- [Orchestration altitude](./orchestration.md) diff --git a/docs/dynamic-workflow/claude/orchestration.md b/docs/dynamic-workflow/claude/orchestration.md deleted file mode 100644 index 39c75d34d..000000000 --- a/docs/dynamic-workflow/claude/orchestration.md +++ /dev/null @@ -1,138 +0,0 @@ -# One level above agents - -Classic multi-agent demos put several peers in a chat room and hope coordination emerges. Dynamic workflows **invert** that: - -> **Coordination is a program** written by a high-capability model. -> **Workers are replaceable execution units.** - -That is “one level above” ordinary agent tool use. - -## Split of responsibility - -### Orchestrator (main session) - -- Understand user intent and constraints -- Discover the work-list (files, bugs, modules, APIs) -- Choose pattern (pipeline vs barrier, depth vs breadth) -- Author the script, schemas, and worker prompts -- Allocate model / effort tiers per stage -- Interpret structured returns; decide the next phase -- Talk to the human; own the correctness narrative - -### Workers (`agent()`) - -- Execute one bounded prompt with tools -- Return raw data or schema-validated objects -- Stay isolated (optional worktree) -- Do **not** redesign the global plan -- May be cheaper/faster models for mechanical stages -- May be specialized `agentType`s (reviewer, explorer, …) - -### Engine - -- Run control flow faithfully -- Enforce concurrency, agent caps, budget hard stop -- Journal for resume -- Present progress UI - -## Why altitude matters - -| Problem in a flat agent loop | Workflow fix | -|---|---| -| Plan and tool churn share one context | Workers isolate tool churn; script aggregates returns only | -| Model “forgets” to verify | Verify is a stage in code | -| Fan-out is improvised each turn | Fan-out is `parallel` / `pipeline` | -| Hard to scale thoroughness | Scale fleet size, votes, dry rounds, budget | -| Parallel edits stomp each other | `isolation: 'worktree'` on mutators | - -``` -User intent - │ - ▼ -┌──────────────────────────────────────────┐ -│ Orchestrator model (main session) │ -│ · plans · schemas · phase selection │ -│ · Workflow({ script, args }) │ -└───────────────────┬──────────────────────┘ - │ deterministic JS spine - ┌───────────┼───────────┐ - ▼ ▼ ▼ - agent() agent() agent() - worker A worker B worker C - │ │ │ - └───────────┼───────────┘ - ▼ - structured returns - │ - ▼ - orchestrator synthesizes - │ - ▼ - user-facing answer -``` - -## Model tiering pattern - -Keep the **session model strong** for orchestration (authoring scripts, reading results, deciding phases). - -Inside the workflow: - -| Stage | Typical choice | -|---|---| -| Mechanical map / extract | `effort: 'low'`; optional smaller `model` | -| Default work | **Omit `model`** — inherit session model | -| Hard judges / design | higher `effort`; keep strong model | -| Parallel mutators | same model + `isolation: 'worktree'` | - -When unsure about `model`, **omit**. Wrong downgrades are worse than paying full price on a small fleet. - -## Structured handoffs - -The interface between altitude layers is **data**, not chat: - -```js -// worker → script -{ findings: [{ file, line, issue, severity }] } - -// script → orchestrator -{ confirmed, dropped, stats } - -// orchestrator → user -narrative + residual risk + links to paths -``` - -Schemas make handoffs machine-checkable. Prefer them at every stage boundary that feeds another stage. - -## What the orchestrator must not outsource - -- Final user-facing judgment (“is this safe to merge?”) without reading key evidence -- Opt-in / cost honesty -- Choosing silent truncation -- Replacing a missing verification stage with “the workers looked careful” - -Workers can be wrong in correlated ways; adversarial / diverse-lens patterns exist to fight that ([patterns](./patterns.md)). - -## Comparison: Agent tool vs Workflow altitude - -| | Single `Agent` | `Workflow` | -|---|---|---| -| Altitude | Peer subagent | Programmed fleet under orchestrator | -| Coordination | Prompt prose | Code | -| Resume multi-step graph | Weak | Prefix journal | -| Best for | One bounded digression | Structured multi-agent jobs | - -## Enabling “bigger brain, many hands” - -Dynamic workflows let you: - -1. Put the **expensive reasoning** in graph design and synthesis. -2. Put the **expensive tokens** in parallel worker contexts that do not pollute each other. -3. Put the **reliability** in deterministic stages (verify, majority, dry-stop). -4. Put the **human** at phase boundaries instead of inside every tool call. - -That is the product of the feature — not just “more agents.” - -## Next - -- [Use cases](./usecases.md) -- [Cheatsheet](./cheatsheet.md) diff --git a/docs/dynamic-workflow/claude/patterns.md b/docs/dynamic-workflow/claude/patterns.md deleted file mode 100644 index 42dc59ecc..000000000 --- a/docs/dynamic-workflow/claude/patterns.md +++ /dev/null @@ -1,254 +0,0 @@ -# Quality patterns - -These are **not** extra APIs. They are recipes composed from [`agent`](./agent.md), [`pipeline` / `parallel`](./concurrency.md), [`budget`](./control-and-io.md), and [`log`](./control-and-io.md). Pick by task; compose freely. - -## Scale to the ask - -| User language | Shape | -|---|---| -| “Find any bugs” | Few finders, single-vote verify | -| “Thoroughly audit” / “be comprehensive” | Larger finder pool, 3–5 vote adversarial pass, synthesis | -| Unsure on research/review/audit | Lean thorough | -| Quick check | Lean brief | - ---- - -## Adversarial verify - -Spawn N independent skeptics per claim, each prompted to **REFUTE**. Kill if ≥ majority refute. Prevents plausible-but-wrong findings from surviving. - -```js -const votes = await parallel( - Array.from({ length: 3 }, () => () => - agent( - `Try to refute: ${claim}. Default to refuted=true if uncertain.`, - { schema: VERDICT, phase: 'Verify', effort: 'high' } - ) - ) -) -const survives = - votes.filter(Boolean).filter(v => !v.refuted).length >= 2 -``` - ---- - -## Perspective-diverse verify - -When a finding can fail in more than one way, give each verifier a **distinct lens** (correctness, security, perf, does-it-reproduce) instead of N identical refuters. Diversity catches failure modes redundancy cannot. - -```js -const lenses = ['correctness', 'security', 'repro'] -const votes = await parallel( - lenses.map(lens => () => - agent(`Judge "${desc}" via the ${lens} lens — real?`, { - schema: VERDICT, - phase: 'Verify', - label: `judge:${lens}`, - }) - ) -) -const real = votes.filter(Boolean).filter(v => v.real).length >= 2 -``` - ---- - -## Judge panel (design) - -Generate N independent attempts from different angles (MVP-first, risk-first, user-first). Score with parallel judges. Synthesize from the winner while grafting best ideas from runners-up. Beats single-attempt iteration when the solution space is wide. - -```js -const ANGLES = ['mvp-first', 'risk-first', 'user-first'] -const drafts = await parallel( - ANGLES.map(a => () => - agent(`Propose a design (${a}). Constraints: ${constraints}`, { - schema: DESIGN_SCHEMA, - phase: 'Design', - label: `draft:${a}`, - }) - ) -).then(xs => xs.filter(Boolean)) - -const scored = await parallel( - drafts.map(d => () => - agent(`Score this design vs criteria…\n${JSON.stringify(d)}`, { - schema: SCORE_SCHEMA, - phase: 'Score', - }) - ) -).then(xs => xs.filter(Boolean)) - -const winner = pickWinner(drafts, scored) -const synthesis = await agent( - `Synthesize final design from winner + graft runners-up…`, - { schema: DESIGN_SCHEMA, phase: 'Synthesize', effort: 'high' } -) -return { winner, synthesis, runnersUp: drafts } -``` - ---- - -## Loop-until-dry - -Unknown-size discovery (bugs, issues, edge cases): keep spawning finders until **K consecutive rounds** return nothing new. Simple `while (count < N)` misses the tail. - -**Critical:** dedup against **all `seen`**, not only `confirmed`. If you only track confirmed, judge-rejected findings reappear every round and the loop never converges. - -```js -const seen = new Set() -const confirmed = [] -let dry = 0 - -while (dry < 2) { - const found = ( - await parallel( - FINDERS.map(f => () => - agent(f.prompt, { phase: 'Find', schema: BUGS }) - ) - ) - ) - .filter(Boolean) - .flatMap(r => r.bugs) - - const fresh = found.filter(b => !seen.has(key(b))) - if (!fresh.length) { - dry++ - log(`dry round ${dry}/2`) - continue - } - dry = 0 - fresh.forEach(b => seen.add(key(b))) - log(`${fresh.length} fresh findings (${seen.size} seen total)`) - - const judged = await parallel( - fresh.map(b => () => - parallel( - ['correctness', 'security', 'repro'].map(lens => () => - agent(`Judge "${b.desc}" via ${lens} — real?`, { - phase: 'Verify', - schema: VERDICT, - }) - ) - ).then(vs => ({ - b, - real: vs.filter(Boolean).filter(v => v.real).length >= 2, - })) - ) - ) - - confirmed.push(...judged.filter(v => v.real).map(v => v.b)) -} - -return confirmed -``` - -Combine with [budget](./control-and-io.md#budget) for cost-bounded open-ended hunts: - -```js -while (dry < 2 && budget.total && budget.remaining() > 50_000) { - // … -} -``` - ---- - -## Multi-modal sweep - -Parallel agents each search a **different way** (by-container, by-content, by-entity, by-time). Each is blind to what the others surface — covers angles one search cannot. - -```js -const MODES = [ - { key: 'by-path', prompt: 'Find X by directory layout…' }, - { key: 'by-symbol', prompt: 'Find X by type/symbol names…' }, - { key: 'by-test', prompt: 'Find X by failing or related tests…' }, - { key: 'by-history', prompt: 'Find X by recent git history…' }, -] - -const sweeps = await parallel( - MODES.map(m => () => - agent(m.prompt, { - phase: 'Sweep', - label: `sweep:${m.key}`, - schema: HITS_SCHEMA, - effort: 'low', - }) - ) -).then(xs => xs.filter(Boolean)) - -const merged = dedupe(sweeps.flatMap(s => s.hits)) -// then deep-read top hits, then synthesize -``` - ---- - -## Completeness critic - -A final agent asks what is missing — modality not run, claim unverified, source unread. Output becomes the next work round. - -```js -const gaps = await agent( - `Given work done:\n${JSON.stringify(summary)}\nWhat is missing?`, - { schema: GAPS_SCHEMA, phase: 'Critic', effort: 'medium' } -) -if (gaps.items.length) { - log(`critic found ${gaps.items.length} gaps`) - // feed gaps into another pipeline / loop iteration -} -``` - ---- - -## Self-repair implementation loop - -Encode a real engineering process as a script: - -```text -Implement → multi-reviewer parallel → repair from structured findings → verify gates -``` - -See the implement/review/repair/verify example in [script contract](./script-contract.md). - -Tips: - -- Reviewers **read-only**; repair agent owns writes. -- Structured `FINDINGS` schema forces actionable file/line/fix fields. -- Final verify re-runs typecheck/tests and reports residual risk. -- Resume after fixing only the repair prompt if implement+review were good. - ---- - -## Review pipeline (default multi-stage) - -Already detailed in [concurrency](./concurrency.md): - -```text -dimensions → (per dimension) findings → (per finding) adversarial verify -``` - -Use barrier+dedup only when verification must see the global merged set first. - ---- - -## No silent caps - -If you bound coverage: - -```js -const MAX = 40 -if (sites.length > MAX) { - log(`transforming ${MAX}/${sites.length} sites; remainder skipped`) -} -const batch = sites.slice(0, MAX) -``` - -Silent truncation reads as full coverage. - ---- - -## Compose novel harnesses - -The list is not exhaustive. Valid compositions include tournament brackets, staged escalation (cheap finder → expensive judge only on survivors), and multi-phase product delivery under ultracode ([lifecycle](./lifecycle.md)). - -## Next - -- [Lifecycle & UX](./lifecycle.md) -- [Orchestration altitude](./orchestration.md) diff --git a/docs/dynamic-workflow/claude/primitives.md b/docs/dynamic-workflow/claude/primitives.md deleted file mode 100644 index c30fa49ba..000000000 --- a/docs/dynamic-workflow/claude/primitives.md +++ /dev/null @@ -1,62 +0,0 @@ -# Primitives overview - -The workflow script body is an async JS context with a **closed** API surface. Only these hooks are injected. Everything else is ordinary JavaScript (with [determinism bans](./script-contract.md)). - -## Inventory - -| Primitive | Kind | Role | -|---|---|---| -| [`agent`](./agent.md) | async call | Spawn one subagent; get string or schema-validated object | -| [`pipeline`](./concurrency.md#pipeline) | combinator | Per-item multi-stage fan-out **without** barriers | -| [`parallel`](./concurrency.md#parallel) | combinator | Concurrent thunks; **barrier** until all complete | -| [`phase`](./control-and-io.md#phase) | side effect | Start a progress group for following agents | -| [`log`](./control-and-io.md#log) | side effect | Narrator line in `/workflows` progress UI | -| [`args`](./control-and-io.md#args) | binding | `Workflow({ args })` value, verbatim | -| [`budget`](./control-and-io.md#budget) | binding | Shared turn token ceiling: `total`, `spent()`, `remaining()` | -| [`workflow`](./control-and-io.md#nested-workflow) | async call | Run one nested named/path workflow (max depth 1) | - -## How they compose - -``` -meta (literal header) - │ - ▼ -phase / log ─────────────────────────── UX only - │ - ├── agent ─────────────────────────── unit of model work - │ - ├── parallel([() => agent…, …]) ──── barrier fan-out - │ - ├── pipeline(items, s1, s2, …) ───── streaming multi-stage - │ └── stages may call agent / parallel - │ - ├── budget.* ──────────────────────── scale / stop loops - │ - └── workflow(name|path) ───────────── nested graph (1 level) -``` - -## Design rules (short) - -1. **Default multi-stage shape is `pipeline`**, not barrier-then-map. -2. Use **`parallel` only** when stage N needs the **full** stage N−1 result set. -3. Always **`.filter(Boolean)`** after `parallel` / nullable `agent` results. -4. Prefer **`schema`** on `agent` for structured returns — no JSON parse roulette. -5. **`log()`** anything a silent cap would hide (top-N, drops, early exit). -6. Guard budget loops with **`budget.total &&`** (else `remaining()` is `Infinity`). -7. Put identity for later stages in **`(prev, originalItem, index)`**, not only in stage-1 return blobs. - -## Not primitives (but matter) - -| Concern | Where documented | -|---|---| -| Tool launch API | [workflow-tool.md](./workflow-tool.md) | -| Script / meta rules | [script-contract.md](./script-contract.md) | -| Caps & isolation | [limits.md](./limits.md) | -| Resume cache | [resume.md](./resume.md) | -| Recipes | [patterns.md](./patterns.md) | - -## Next - -- [agent()](./agent.md) -- [pipeline & parallel](./concurrency.md) -- [phase, log, args, budget, workflow()](./control-and-io.md) diff --git a/docs/dynamic-workflow/claude/resume.md b/docs/dynamic-workflow/claude/resume.md deleted file mode 100644 index d0734b99d..000000000 --- a/docs/dynamic-workflow/claude/resume.md +++ /dev/null @@ -1,94 +0,0 @@ -# Resume & journal - -Dynamic workflows are editable programs. Resume lets you change the plan mid-flight (or after a kill) without redoing finished `agent()` work. - -## Handles returned at launch - -| Field | Use | -|---|---| -| `runId` | Pass as `resumeFromRunId` on the next `Workflow` call | -| `scriptPath` | Edit in place; re-invoke without resending full `script` | -| `transcriptDir` | Subagent transcripts + `journal.jsonl` | -| `taskId` | Stop / track the background task | - -## How to resume - -1. **Stop** the prior run if it is still running (background task stop / equivalent). -2. Relaunch: - -```js -Workflow({ - scriptPath: '/…/workflows/scripts/review-wf_abc.js', - resumeFromRunId: 'wf_abc…', - args: previousArgs, // keep identical for full cache when script unchanged -}) -``` - -Same-session only for `resumeFromRunId` (local runs). - -## Cache identity rule - -The engine finds the **longest unchanged prefix** of `agent()` calls: - -- Same **prompt** + same **opts** (as hashed for identity) → return **cached** result instantly. -- First **edited or new** `agent()` call and **everything after it** run live. - -| Scenario | Result | -|---|---| -| Same script + same `args` | ~100% cache hit | -| Edit only post-processing after the last `agent()` | Cache hit all agents; re-run pure JS tail | -| Change prompt of agent #3 of 10 | Agents 1–2 cached; 3–10 live | -| Insert a new `agent()` early | From that call onward live | - -## Why scripts ban entropy - -`Date.now()`, `Math.random()`, and bare `new Date()` throw in scripts so control flow and prompt construction cannot silently diverge between original run and resume. See [script contract](./script-contract.md). - -If you need wall-clock: - -- Pass a fixed ISO string via `args` at launch, or -- Stamp times in the coordinator after the workflow returns. - -## `journal.jsonl` - -Path: `/journal.jsonl` - -- Records each agent’s **actual return value**. -- Before diagnosing empty or surprising workflow results, **read the journal** — do not assume cached results are non-empty. -- Fallback if no journal: read `agent-.jsonl` files in the transcript directory and hand-author a continuation script. - -## Operational patterns - -### Fix a bad verify stage after a long review - -1. Leave review `agent()` prompts unchanged. -2. Edit only verify-stage prompts / schema in `scriptPath`. -3. Resume with same `args` → review results cache; verify re-runs. - -### Add a completeness-critic pass - -1. Append a new phase + `agent()` at the end of the script. -2. Resume → entire prior prefix caches; only the new agent runs. - -### Re-run pure aggregation - -1. Change only the `return` / merge logic (no `agent()` signature changes). -2. Resume → full agent cache; new aggregation. - -## Failure modes to watch - -| Symptom | Check | -|---|---| -| Empty confirmed list | Journal: did judges return `null`? schema fail? | -| Unexpected re-run of early agents | Prompt/opts drift (template changed, args differ) | -| Resume rejected / no cache | Wrong session, missing `runId`, prior run not stopped | -| Divergent args | Even with same script, different `args` can change prompts that embed `args` → cache miss from first embedded call | - -## Relation to durability - -Claude Code resume is **session-oriented prefix replay** of orchestration journals. It is not the same as a multi-day durable job supervisor with external leases (a different control plane). For long-lived external orchestration, see product-specific durable systems; this doc describes the model-facing Workflow resume API only. - -## Next - -- [Patterns](./patterns.md) -- [Lifecycle](./lifecycle.md) diff --git a/docs/dynamic-workflow/claude/script-contract.md b/docs/dynamic-workflow/claude/script-contract.md deleted file mode 100644 index dfd64fbcb..000000000 --- a/docs/dynamic-workflow/claude/script-contract.md +++ /dev/null @@ -1,145 +0,0 @@ -# Script contract - -A workflow script is plain JavaScript that starts with a pure-literal `meta` export, then runs in an async context with only the injected orchestration primitives available. - -## Minimal shape - -```js -export const meta = { - name: 'find-flaky-tests', - description: 'Find flaky tests and propose fixes', // shown in permission dialog - phases: [ - { title: 'Scan', detail: 'grep test logs for retries' }, - { title: 'Fix', detail: 'one agent per flaky test', model: 'sonnet' }, - ], - // optional: whenToUse — shown in workflow lists -} - -// body — async context; await freely -phase('Scan') -const flaky = await agent('grep CI logs for retry markers', { schema: FLAKY_SCHEMA }) -// ... -return { flaky } -``` - -## `meta` rules - -| Rule | Detail | -|---|---| -| Position | Must be the **first statement** in the script | -| Purity | **Pure literal only** — no variables, function calls, spreads, or template interpolation | -| Required | `name`, `description` | -| Optional | `whenToUse`, `phases` | -| Phase entries | `{ title, detail?, model? }` | -| Phase titles | Must match `phase('…')` call strings **exactly** for UI grouping; unmatched `phase()` still gets its own progress group | -| Per-phase model | Optional override for agents in that phase’s UI group (agent-level `opts.model` still applies per call) | -| Permission UX | `description` is what the user sees in the approval dialog | - -Invalid example (not pure literal): - -```js -const n = 'review' -export const meta = { name: n, description: `Review ${topic}` } // ❌ -``` - -## Language - -| Allowed | Forbidden | -|---|---| -| Plain JavaScript | TypeScript annotations, interfaces, generics | -| `async` body with top-level `await` | Node APIs (`fs`, `process`, `require`, …) | -| `JSON`, `Math`, `Array`, `Object`, `Map`, `Set`, … | Filesystem, network, subprocess | -| Template strings / normal expressions in the **body** | Non-determinism listed below | - -Type annotations like `: string[]` **fail to parse**. Keep types in comments or in JSON Schema objects as plain data. - -## Determinism bans (resume safety) - -These throw if called in the script (argless / pure entropy): - -- `Date.now()` -- `Math.random()` -- argless `new Date()` - -**Why:** [Resume](./resume.md) replays the longest unchanged prefix of `agent()` calls by hashing prompt + options. If the script branched on wall-clock or random, cache identity would lie and partial replay would be unsafe. - -**What to do instead:** - -- Pass fixed timestamps / seeds via `args`. -- Stamp wall-clock **after** the workflow returns, in the coordinator. -- For “random-like” diversity among agents, vary **prompt text or label by index** (deterministic in the script, different per worker). - -## Only escape hatches into models - -From the script you can only: - -1. Call **`agent()`** — spawn a subagent (tools, optional schema). -2. Call **`workflow()`** — run one nested saved/path workflow (one level only). - -There is no raw shell, no write-file, no HTTP from the orchestration body. That is intentional: orchestration stays pure; side effects live inside agents under normal permission/tool policy. - -## Return value - -Whatever the script `return`s becomes the workflow result delivered to the coordinator (via task notification). Prefer structured objects: - -```js -return { confirmed, dropped, stats: { found: seen.size } } -``` - -Subagents should return **raw data** (or schema objects), not user essays — the coordinator narrates. - -## Real-world example (implement → review → repair → verify) - -Condensed from a session script: - -```js -export const meta = { - name: 'implement-workflow-foundation', - description: 'Implement and verify durable workflow foundation', - phases: [ - { title: 'Implement', detail: 'build store and orchestrator', model: 'sonnet' }, - { title: 'Review', detail: 'audit correctness and tests' }, - { title: 'Repair', detail: 'apply verified fixes', model: 'sonnet' }, - { title: 'Verify', detail: 'run full validation' }, - ], -} - -phase('Implement') -const implementation = await agent(`…implementation prompt…`, { - label: 'implement:durable-foundation', - phase: 'Implement', - effort: 'medium', - agentType: 'claude', -}) - -phase('Review') -const FINDINGS = { /* JSON Schema */ } -const reviews = await parallel([ - () => agent(`…persistence audit…\n${implementation}`, { - label: 'review:persistence', phase: 'Review', schema: FINDINGS, effort: 'medium', agentType: 'claude', - }), - () => agent(`…correctness audit…\n${implementation}`, { - label: 'review:correctness', phase: 'Review', schema: FINDINGS, effort: 'medium', agentType: 'claude', - }), - () => agent(`…test quality audit…\n${implementation}`, { - label: 'review:tests', phase: 'Review', schema: FINDINGS, effort: 'low', agentType: 'claude', - }), -]).then(xs => xs.filter(Boolean)) - -phase('Repair') -const repair = await agent(`…fix from ${JSON.stringify(reviews)}…`, { - label: 'repair:review-findings', phase: 'Repair', effort: 'medium', agentType: 'claude', -}) - -phase('Verify') -const verification = await agent(`…gates…`, { - label: 'verify:full-gates', phase: 'Verify', effort: 'low', agentType: 'claude', -}) - -return { implementation, reviews, repair, verification } -``` - -## Next - -- [Primitives overview](./primitives.md) -- [agent()](./agent.md) diff --git a/docs/dynamic-workflow/claude/usecases.md b/docs/dynamic-workflow/claude/usecases.md deleted file mode 100644 index c6735f5df..000000000 --- a/docs/dynamic-workflow/claude/usecases.md +++ /dev/null @@ -1,180 +0,0 @@ -# Use cases - -Workloads that were awkward or unreliable as a single flat agent loop, and how dynamic workflows fit them. Pair with [patterns](./patterns.md) and [orchestration](./orchestration.md). - -## Comprehensive code review - -**Goal:** High confidence that findings are real before the user acts. - -**Shape:** - -```text -scout diff → dimensions (security, correctness, tests, perf) - → (pipeline) per-dimension findings - → adversarial / multi-lens verify per finding - → return survivors only -``` - -**Why workflow:** Verification is not optional prose — it is stages. Vote count scales with “thoroughly audit” vs “any issues.” - -**Primitives:** `pipeline`, `parallel`, `schema`, higher `effort` on judges. - ---- - -## Large migrations / refactors - -**Goal:** Touch many call sites without stomping edits or losing progress. - -**Shape:** - -```text -discover sites → pipeline(site → transform → local verify) - isolation: 'worktree' on mutators - resume after fixing one stage’s prompt -``` - -**Why workflow:** One context cannot hold hundreds of site-specific tool traces. Prefix resume avoids redoing finished sites when the transform prompt improves. - -**Primitives:** `pipeline`, `isolation: 'worktree'`, `resumeFromRunId`, `log` for skipped tails. - ---- - -## Research & multi-source synthesis - -**Goal:** Broad coverage then deep reading then a cited synthesis. - -**Shape:** - -```text -multi-modal sweep (parallel angles) - → merge/dedup hits - → deep-read top sources (pipeline) - → completeness critic - → synthesize -``` - -**Why workflow:** Sweeps are embarrassingly parallel; synthesis needs the merged set (barrier). Budget bounds open-ended browsing. - -**Primitives:** `parallel`, barrier merge, `budget`, critic `agent`. - ---- - -## Design exploration - -**Goal:** Explore a wide solution space without anchoring on the first idea. - -**Shape:** - -```text -N drafts from different angles (parallel) - → score panel (parallel) - → synthesize winner + graft runners-up -``` - -**Why workflow:** Single-thread iteration biases early. Independent drafts + structured scores beat one long chat. - -**Primitives:** judge panel pattern, `schema` for design objects, high effort on synthesis. - ---- - -## Unknown-size bug / issue hunts - -**Goal:** Keep finding until the map is dry, not until an arbitrary count. - -**Shape:** - -```text -loop-until-dry: - parallel finders → dedup vs seen → multi-lens judge → accumulate confirmed -``` - -**Why workflow:** `while (n < 10)` misses the tail; dry rounds + `seen` set converge. Budget optional hard stop. - -**Primitives:** loops, `parallel`, `Set` dedup, `budget.total && …`. - ---- - -## Self-repair implementation - -**Goal:** Ship a change with independent review pressure, not self-congratulation. - -**Shape:** - -```text -implement → parallel reviewers (schema findings) - → repair agent applies real issues - → verify gates (typecheck/tests) -``` - -**Why workflow:** Separation of implementer and reviewers; structured findings; deterministic phase order. - -**Primitives:** sequential `phase`s, `parallel` reviewers, schema, medium/low effort mix. - -**Example skeleton:** [script contract](./script-contract.md). - ---- - -## Heterogeneous agent fleets - -**Goal:** Specialists for map / edit / audit under one plan. - -**Shape:** - -```text -explorer agentType (read-only map) - → implementer agentType (edits, maybe worktree) - → reviewer agentType (schema audit) -``` - -**Why workflow:** `agentType` + model/effort per stage without the user manually jockeying three chats. - -**Primitives:** `agentType`, `model`/`effort` overrides, nested `workflow` for reusable specialist packs. - ---- - -## Phased product delivery under ultracode - -**Goal:** Maximum exhaustiveness for multi-day product work with human checkpoints. - -**Shape:** - -```text -turn 1: Understand workflow -turn 2: Design workflow -turn 3: Implement+repair workflow -turn 4: Review/audit workflow -``` - -**Why workflow:** Standing opt-in; each workflow is a well-scoped fan-out; coordinator synthesizes between turns. - -**Primitives:** full stack + [lifecycle](./lifecycle.md) multi-phase. - ---- - -## What this feature deliberately is not - -| Not | Because | -|---|---| -| Free-form multi-agent chat room | Workers do not negotiate the plan with each other | -| Silent always-on multi-agent | Cost; requires [opt-in](./opt-in.md) / ultracode | -| Multi-day durable external job system | Resume is session-oriented prefix replay, not external leases | -| Replacement for small tasks | Overhead of scripting + fleet is real; use single Agent or inline tools | - ---- - -## Choosing a shape quickly - -| Symptom | Reach for | -|---|---| -| Many independent units | `pipeline` or `parallel` fan-out | -| “I’m not sure we covered it” | multi-modal sweep + completeness critic | -| “Findings feel flaky” | adversarial / multi-lens verify | -| “Solution space is wide” | judge panel | -| “Don’t know how many exist” | loop-until-dry | -| “Parallel edits conflict” | `isolation: 'worktree'` | -| “Reran everything after a prompt tweak” | `resumeFromRunId` + stable prefix | - -## Next - -- [Cheatsheet](./cheatsheet.md) -- [README index](./README.md) diff --git a/docs/dynamic-workflow/claude/workflow-tool.md b/docs/dynamic-workflow/claude/workflow-tool.md deleted file mode 100644 index b43a40ee7..000000000 --- a/docs/dynamic-workflow/claude/workflow-tool.md +++ /dev/null @@ -1,139 +0,0 @@ -# Workflow tool API - -The model-facing tool name is **`Workflow`** (alias **`RunWorkflow`**). - -- **Search hint:** orchestrate subagents with deterministic JavaScript workflow -- **Execution:** background — tool returns immediately with a task id -- **Completion:** `` when the script finishes -- **Live progress:** `/workflows` - -## When to use the tool (product intent) - -A workflow structures work across many agents to be: - -- **Comprehensive** — decompose and cover in parallel -- **Confident** — independent perspectives and adversarial checks before committing -- **Scalable** — migrations, audits, broad sweeps that one context cannot hold - -The script encodes structure: what fans out, what verifies, what synthesizes. - -Control flow should be **deterministic** (loops, conditionals, fan-out in code) rather than re-decided free-form by the model mid-orchestration. - -Common single-phase shapes (chain across turns for larger work): - -| Phase | Pattern | -|---|---| -| Understand | parallel readers over subsystems → structured map | -| Design | judge panel of N approaches → scored synthesis | -| Review | dimensions → find → adversarially verify | -| Research | multi-modal sweep → deep-read → synthesize | -| Migrate | discover sites → transform (worktree) → verify | - -See [opt-in](./opt-in.md) for permission to call this tool. - -## Input fields - -At least one of `script`, `name`, or `scriptPath` is required. - -| Field | Type | Role | -|---|---|---| -| `script` | string (optional, length-bounded) | Inline self-contained workflow script. Must begin with pure-literal `export const meta = { name, description, phases }`. **Preferred on first invocation** — do not Write a file first. | -| `name` | string (optional) | Predefined workflow: built-in or from `.claude/workflows/`. Resolves to a full script. | -| `scriptPath` | string (optional) | Path to a script on disk. Every invocation **persists** its script under the session directory and returns the path. Iterate with Write/Edit + re-invoke. **Takes precedence** over `script` and `name`. | -| `args` | any (optional) | Exposed to the script as global `args`, **verbatim**. Pass real JSON arrays/objects — **not** a JSON-encoded string (stringified lists break `args.map` / `args.filter`). | -| `resumeFromRunId` | string `^wf_[a-z0-9-]{6,}$` (optional) | Prior run id. Unchanged prefix of `agent()` calls replays from cache; first edited/new call and everything after runs live. Same-session only. **Stop the prior run first** before resuming. | -| `description` | string (optional) | **Ignored** — set description in script `meta`. | -| `title` | string (optional) | **Ignored** — set title/name in script `meta`. | - -### First run - -```js -Workflow({ - script: ` -export const meta = { - name: 'review-changes', - description: 'Review and adversarially verify findings', - phases: [ - { title: 'Review' }, - { title: 'Verify' }, - ], -} -// ... body using agent/pipeline/parallel ... -return { confirmed } -`, - args: { files: ['src/auth.ts', 'src/session.ts'] }, -}) -``` - -### Iterate without resending the full script - -```js -// Edit the returned scriptPath via Write/Edit, then: -Workflow({ - scriptPath: returnedScriptPath, - resumeFromRunId: runId, // optional: reuse cached agent() prefix - args: { files: ['src/auth.ts', 'src/session.ts'] }, -}) -``` - -### Named workflow - -```js -Workflow({ - name: 'review-changes', - args: { topic: 'authentication' }, -}) -``` - -## Return envelope (conceptual) - -The tool launches asynchronously. A typical success-shaped result includes: - -```ts -{ - status: 'async_launched' | 'remote_launched', - taskId: string, - taskType?: 'local_workflow' | 'remote_agent', - workflowName?: string, // meta.name - runId?: string, // for resumeFromRunId (local) - transcriptDir?: string, // subagent transcripts + journal.jsonl - scriptPath?: string, // persisted script for this invocation - summary?: string, - sessionUrl?: string, // when remote_launched - warning?: string, // non-blocking heads-up - error?: string, // e.g. syntax check failed -} -``` - -Notes: - -- `runId` is the handle for [resume](./resume.md). -- `scriptPath` is the handle for iteration without resending `script`. -- `transcriptDir` holds per-agent logs and `journal.jsonl` (actual agent return values). -- Remote launches may use `sessionUrl` instead of local `runId` as the resume handle. - -## Resolution order (engine behavior) - -Conceptually the engine resolves input as: - -1. If `scriptPath` → load (and optionally pair with inline `script` for built-in match checks). -2. Else if `name` → resolve from built-ins / `.claude/workflows/`. -3. Else if `script` → use inline body. -4. Else → validation error: must provide script, name, or scriptPath. - -## Relationship to the single Agent tool - -| | `Agent` tool | `Workflow` tool | -|---|---|---| -| Count | One subagent (or a few manual launches) | Many, under a script graph | -| Control flow | Model re-decides each turn | Script encodes loops/fan-out | -| Structured multi-stage | Manual | `pipeline` / `parallel` + schema | -| Cost risk | Lower | Higher — gated by opt-in | -| Resume of a multi-step graph | Limited | Prefix-cached by agent call identity | - -Use `Agent` for isolated one-offs. Use `Workflow` when the **structure** of multi-agent work must be reliable. - -## Next - -- [Script contract](./script-contract.md) -- [Primitives](./primitives.md) diff --git a/docs/dynamic-workflow/devspace/plan.md b/docs/dynamic-workflow/devspace/plan.md deleted file mode 100644 index 4a73627fb..000000000 --- a/docs/dynamic-workflow/devspace/plan.md +++ /dev/null @@ -1,369 +0,0 @@ -# DevSpace Dynamic Workflow Engine — Plan - -Builds on the locked bigger-model plan. Scope = **this worktree only**. -Subagents stay **CLI-only**. Workflows get **CLI + MCP** over shared primitives. - ---- - -## 0. Non-goals / locks - -| Lock | Meaning | -|---|---| -| No MCP `agent_run` / `agent_wait` / `agent_show` | Subagent feature surface remains `devspace agents *` (+ skill + shell). | -| Workflow workers call adapters **in-process** | `runLocalAgentProvider` / same registry as CLI worker. No shell-out to `agents run` for `agent()`. | -| No dashboard v1 | Events via store drain + CLI `--follow` / MCP status long-poll. | -| CC script API parity | `meta`, `agent`, `parallel`, `pipeline`, `phase`, `log`, `args`, `budget`, `workflow` + determinism bans. | -| Yolo sub-agents | Fixed write-capable adapter policy; **no** `writeMode` on `agent()`. | -| `isolation: 'worktree'` | **Must-have** on `agent()` (CC-like); default shared checkout. | -| `effort` (not `thinking`) | Profiles, CLI, store, adapters, `agent()` opts — rename across stack. | -| `budget` stub v1 | `{ total: null, spent: () => 0, remaining: () => Infinity }`. | -| Dual surface | `devspace workflow *` **and** MCP `run_workflow` / `workflow_status` / `workflow_cancel`. | -| All 6 providers v1 | codex/claude/opencode/pi/cursor/copilot via existing adapters. | -| Provider policy | Runtime uses currently available providers in stable product order. Durable provider policy and onboarding are deferred. | -| Resume-by-replay right after engine core | Same milestone order as locked plan. | - ---- - -## 1. Control planes (do not conflate) - -``` -A) One-shot subagents (existing, unchanged API) - host/shell → devspace agents run|show|ls - → detached __worker → adapters → local_agent_sessions - -B) Dynamic workflows (new) - host MCP / CLI → run row + spawn workflow __worker - → sandboxed script - → agent() → adapters (in-process) - → workflow_* tables (not local_agent_sessions) -``` - -**Implication:** `devspace agents ls` does **not** list workflow-spawned agents. Observability = workflow events + `workflow_agent_calls`. Optional later dual-write — not v1. - ---- - -## 2. Architecture - -``` -┌─ CLI: workflow run|status|cancel|ls ─┐ ┌─ MCP: run_workflow|status|cancel ─┐ -│ parse / create run / spawn │ │ same primitives via workflow-tools │ -└──────────────────┬───────────────────┘ └──────────────────┬────────────────┘ - ▼ │ - WorkflowStore (SQLite WAL) ◄────────────────────────┘ - │ - │ detached: node cli.js workflow __worker - ▼ - workflow-engine + sandbox + api - │ - │ agent() [semaphore] - ▼ - runLocalAgentProvider(provider, input) ← existing adapters - │ - ▼ - journal: events + agent_calls (+ schema retries) -``` - -Server/CLI = **launcher + journal reader**. Worker owns execution, heartbeat, cancel watch, self group-kill. - ---- - -## 3. Accept bigger plan as-is (core) - -Keep their file split (flat `src/`): - -| Module | Role | -|---|---| -| `workflow-script.ts` | meta extract + wrap + `vm.Script` | -| `workflow-sandbox.ts` | context, determinism bans, console→log | -| `workflow-store.ts` | runs / events / agent_calls / cancel / reap | -| `workflow-api.ts` | agent/parallel/pipeline/phase/log/args/budget/workflow + semaphore | -| `workflow-engine.ts` | execute + `__worker` guts | -| `workflow-replay.ts` | resume cache | -| `workflow-schema.ts` | Ajv + retries | -| `workflow-files.ts` | named + persist scriptPath | -| `workflow-tools.ts` | MCP registration | -| `skills/dynamic-workflows/SKILL.md` | teaching | - -DB migration **v4** (v3 = `local_agent_sessions` ✓). -Tables: `workflow_runs`, `workflow_events`, `workflow_agent_calls` as specified. -Spawn pattern copy `spawnAgentWorker` (detached, stdio ignore, unref). - -API semantics: keep their CC-parity table (throws vs parallel→null, pipeline stages, ALS for phase, nested workflow depth 1, budget stub). - -MCP contracts + yield windows: keep (status max ~110s matches `MAX_POLL_YIELD_MS`). - -Milestones 1→8: keep order and verifiability. - ---- - -## 4. Refinements / deltas on the bigger plan - -### 4.1 Explicit separation from subagent CLI - -In SKILL + serverInstructions + tool descriptions: - -- Workflows = multi-agent **graphs**. -- One-off second opinions = still `devspace agents run` (CLI/skill). -- Do **not** tell models to implement workflows by shelling many `agents run` when `run_workflow` exists. - -### 4.2 `agent()` backend = adapters, not CLI - -```ts -// conceptual -runProvider({ provider, prompt, workspace, model, effort, providerSessionId? }) - → runLocalAgentProvider(provider, { prompt, workspace, writeMode: "allowed", model, effort, providerSessionId? }) -``` - -- Schema retries reuse `providerSessionId` when adapter returns it (codex/claude path). -- Do not create `local_agent_sessions` rows per call (avoids polluting `agents ls`, simpler cancel). -- If product later wants unified list, add a flag — not v1. -- `workspace` is either shared `workspaceRoot` or a managed worktree path when `opts.isolation === 'worktree'`. - -### 4.3 Provider resolution now; policy later - -Current experimental runtime: - -- Probe provider availability at execution time. -- Resolve `opts.provider` → `meta.defaultProvider` → first available provider - in stable product order. -- Keep probe timestamps and unavailable reasons in diagnostics only; do not - persist them in user configuration. -- Unknown or unavailable explicit providers fail that `agent()` call. - -The final onboarding release may add an ordered array of provider policy -objects with `id`, `enabled`, `defaultModel`, and `defaultEffort`. That contract -is deliberately deferred so the workflow stack does not publish an unfinished -configuration shape. - -### 4.4 Skills gating fix (required, not optional) - -Bundled `subagents` and `dynamic-workflows` skills remain package-managed. -User/project copies win on name collision. Setup does not copy bundled skills -into `~/.devspace/skills`, which prevents generated copies from shadowing later -package updates. The legacy `subagent-delegation` name is suppressed. - -### 4.5 MCP vs CLI symmetry - -| Op | CLI | MCP | -|---|---|---| -| Start | `workflow run --file\|--name\|--resume` | `run_workflow` | -| Poll | `status --follow` | `workflow_status` long-poll | -| Cancel | `cancel` | `workflow_cancel` | -| List | `ls` | (optional later; status by id enough v1) | - -Same store. Detached worker survives MCP session death (critical acceptance test). - -### 4.6 Replay: document deliberate CC divergence - -CC: longest unchanged **call-index** prefix. -v1: index+key, then **consume-once cacheKey** fallback (fan-out completion order). - -Document in SKILL under Resume. Do not pretend full CC resume identity. - -### 4.7 Sandbox choice - -Locked: `node:vm` + shadow Date/Math + no require/process/fetch/timers. -Host wall-clock max (default 6h). -Not SES (not in this tree; avoid new heavy dep). Accept vm is not a security boundary for hostile multi-tenant — DevSpace is single-user local. - -### 4.8 Cancel / kill - -1. `cancelRequested` flag. -2. Worker heartbeat (5s) → AbortController + journal `run_cancelled` + group SIGTERM. -3. Hard path after ≤5s: `terminateProcessTree` pid shim (existing `process-platform`). - -Known: in-flight adapter SDKs may not abort cleanly; group-kill is the backstop (already accepted). - -### 4.9 Pi timeout - -Document `PI_AGENT_TIMEOUT_MS = 120_000` in SKILL. Follow-up: make configurable — not milestone blocker. - -### 4.10 Script authoring feedback - -`run_workflow` / CLI parse **before** spawn. Syntax/meta errors return cheat-sheet snippet (tool desc + error). Line numbers preserved via export-strip + lineOffset. - -### 4.11 Concurrency - -`min(16, max(1, os.availableParallelism()-2))`, clamp by `meta.concurrency` if set. Semaphore gates **`agent()` only** (not pure JS stages). - -### 4.12 Named workflows paths - -1. `/.devspace/workflows/.js` -2. `~/.devspace/workflows/.js` (via config dir helper used by profiles) - -Name: `[a-z0-9-]+`. Persist exact source to `/workflows/runs/.js` for resume/edit. - -### 4.13 `workflow()` nest - -Same run, shared journal/semaphore/call counter, depth ≤ 1. Resolve name via `workflow-files`. No new process. - -### 4.14 package.json - -- Direct dep: `ajv` -- Tests: append new `*.test.ts` to existing per-file tsx chain -- Node engines already `>=22.19` (ok for `availableParallelism`) - -### 4.15 Docs location - -Keep design notes under `docs/dynamic-workflow/devspace/` (this plan + later runtime notes). Claude reference stays under `docs/dynamic-workflow/claude/`. - -### 4.16 `effort` rename (profiles + agent stack) - -| Today | Target | -|---|---| -| Profile `thinking:` | `effort:` | -| CLI `--thinking` | `--effort` (+ short deprecation alias optional) | -| DB/store `thinking` | `effort` (rename column in new mig or dual-read) | -| `LocalAgentRunInput.thinking` | `effort` | -| Workflow `agent()` opts | `effort` only | -| Replay cache key | includes `effort` | - -Provider-native strings pass through unchanged. - -### 4.17 `isolation: 'worktree'` (must-have) - -- Opt-in per call: `agent(prompt, { isolation: 'worktree', … })`. -- Default: shared `workspaceRoot`. -- Create under `config.worktreeRoot` / existing git-worktrees helpers; pin base SHA at run start. -- Adapter `cwd` = worktree path. -- Clean success → auto-remove; dirty/fail/cancel → preserve + journal `worktreePath`. -- **No** auto-merge into source. -- Non-git workspace → throw. -- Cache key includes `isolation`. -- Module touch: extend `workflow-api` + small worktree helper (wrap `git-worktrees.ts`). -- Skill: use for parallel mutators only. - -### 4.18 Milestone impact - -| Milestone | Extra | -|---|---| -| **3 Engine** | `isolation` path with fake/temp git repos in tests | -| **4 Worker+CLI** | real worktree create/cleanup; journal fields | -| **5 Resume** | cache key includes isolation | -| **8 Teach** | skill isolation + effort; document deferred provider policy | -| Cross-cutting | rename `thinking`→`effort` in profile/CLI/store/adapters (can land with M3–4) | -| Config | Keep provider availability runtime-only until final onboarding. | - ---- - -## 5. Script API (v1 contract — implement exactly) - -```js -export const meta = { - name: '…', - description: '…', - phases: [{ title: '…', detail?: '…' }], - // devspace-only: - defaultProvider?: 'codex'|'claude'|…, - concurrency?: number, -} - -phase('Review') -const rows = await parallel([ - () => agent(p1, { provider: 'claude', label: 'r1', effort: 'high', schema: S }), - () => agent(p2, { provider: 'codex', label: 'r2', schema: S }), -]) -const mut = await agent(implPrompt, { - provider: 'codex', - isolation: 'worktree', // parallel-safe writes - schema: DiffSummary, -}) -const out = await pipeline(items, stage1, stage2) -log('…') -// args, budget (stub), workflow(name, args?) -return { … } -``` - -Determinism bans: `Date.now`, `Math.random`, argless `new Date` → `WorkflowDeterminismError`. - ---- - -## 6. Milestones (same spine, sharper exit criteria) - -| # | Deliverable | Done when | -|---|---|---| -| **1 Journal** | schema + mig v4 + store + tests | create/append/drain/reap unit green | -| **2 Script/sandbox** | parse + vm + bans | meta edge cases + line nos + bans green | -| **3 Engine core** | api+engine, fake provider | semaphore, parallel null, pipeline no-barrier, phase ALS, nest depth | -| **4 Worker+CLI** | router, spawn, heartbeat, cancel, files | `--follow` log-only + 1 real provider; kill -9 → reap; cancel → group empty | -| **5 Resume** | replay + `--resume` | cancel mid-run; resume shows cached prefix events | -| **6 Schema** | ajv enforce + retries | bad JSON → schema_retry → success/exhaust | -| **7 MCP** | 3 tools + server wiring | Inspector: run+status; **kill MCP, worker still finishes** | -| **8 Teach** | skill, seed, skills.ts fix, instructions | fresh + pre-seeded config both advertise skill | - -E2E: `npm test` + `npm run typecheck`; live fan-out 2 providers CLI; same MCP; cancel+resume. - ---- - -## 7. Mapping to existing code (touch list) - -| Existing | Use | -|---|---| -| `local-agent-adapters.ts` / `runLocalAgentProvider` | `agent()` backend | -| `local-agent-availability.ts` | provider pick / error text | -| `local-agent-store.ts` | **pattern only** (not dual-write) | -| `cli.ts` `spawnAgentWorker` / `agents __worker` | copy for `workflow __worker` | -| `process-platform.terminateProcessTree` | hard cancel | -| `db/client` WAL + busy_timeout 5000 | multi-process journal | -| `server.ts` `registerAppTool` + workflow capability gate | tools only if workflows are enabled | -| `skills.ts` | independent package-managed skill gates | -| `process-sessions` yield bounds | MCP status yield caps | - ---- - -## 8. Risk register (accepted + one process risk) - -| Risk | Mitigation | -|---|---| -| Adapter no abort | group-kill worker | -| Daemonizing child escapes group | document; SIGTERM+adapter finally | -| Pi 120s cap | SKILL note | -| Replay key fallback ≠ CC | document | -| Laptop sleep heartbeat false fail | `kill(pid,0)` before reap | -| Host model still shells `agents run` for graphs | skill + tool cheat-sheet steer to `run_workflow` | -| Long MCP poll vs proxy timeouts | yield ≤110s; client re-calls status | - ---- - -## 9. What we explicitly do **not** build in v1 - -- MCP tools for raw subagents -- Dashboard / live TUI -- Real token `budget` tied to host -- `writeMode` on `agent()` (isolation **is** in scope) -- Auto-merge of agent worktrees into source checkout -- Auto file-change / diff events per stage -- Declaring DAG JSON alternate API (script is the API) -- Dual-write to `local_agent_sessions` -- SES lockdown - ---- - -## 10. Implementation order for a coding agent - -1. Mig + store (no behavior risk). -2. Script + sandbox (pure). -3. Engine against fakes (locks API). -4. Wire CLI worker to real adapters. -5. Replay. -6. Schema. -7. MCP. -8. Skill/docs/gating. - -Do not open MCP before CLI smoke — debug path must work headless without a host. - ---- - -## Resolved questions (see also [primitives-spec.md](./primitives-spec.md)) - -1. **Default provider:** `opts.provider` → `meta.defaultProvider` → first live provider in stable product order. Final provider defaults and enablement are deferred to onboarding finalization. -2. **writeMode:** **not in v1 API**; skill teaches prompt-based RO/write. -3. **Isolation:** **`isolation?: 'worktree'` is v1 must-have** on `agent()`; default shared; no auto-merge. -4. **Effort rename:** `thinking` → **`effort`** across profiles, CLI, store, adapters, `agent()` opts, cache keys. -5. **MCP list:** skip; **CLI** `workflow ls` yes. -6. **Size caps:** transport/storage bounds (§8 of primitives-spec); not “coverage” truncation. -7. **Nested workflow:** CC-like `name | { scriptPath }`, depth 1, shared journal/semaphore. -8. **Cancel:** cooperative flag → then group-kill. - -**File-change tracking:** out of scope. -**Schema:** `opts.schema` + Ajv + retries — in scope. diff --git a/docs/dynamic-workflow/devspace/primitives-spec.md b/docs/dynamic-workflow/devspace/primitives-spec.md deleted file mode 100644 index 219408f3e..000000000 --- a/docs/dynamic-workflow/devspace/primitives-spec.md +++ /dev/null @@ -1,795 +0,0 @@ -# DevSpace Dynamic Workflow — Primitives & API Spec - -Implementation + contract spec for every surface, inspired by Claude Code’s Workflow environment. -Pairs with [plan.md](./plan.md). Subagents remain CLI-only; this document is **workflow only**. - ---- - -## 0. Product goals (locks) - -| Goal | Surface | -|---|---| -| DW for coding agents that lack Workflow (pi, codex, opencode, cursor, …) | **CLI + skill** — host agent authors script, runs `devspace workflow *` | -| ChatGPT as orchestrator, not implementer | **MCP workflow tools** behind the workflow capability gate — plan + `run_workflow` / status / cancel | -| Ship both in dev | One engine; two entrypoints; converge later on performance/UX | - -``` -coding agent ── skill + CLI ──► engine ── agent() ──► adapters -ChatGPT ── MCP tools ──► engine ── agent() ──► adapters -``` - ---- - -## 1. Resolved decisions - -| # | Topic | Decision | -|---|---|---| -| 1 | Default provider | Runtime: `opts.provider` → `meta.defaultProvider` → first currently available provider in stable product order. Final provider policy is deferred. | -| 2 | Access / writeMode | **Not in v1 API.** No `writeMode`. Skill teaches **prompt-based** RO vs write. Isolation handles *where* writes land (see isolation). | -| 3 | List runs | **No MCP list tool v1.** **CLI** `devspace workflow ls` yes. | -| 4 | Size caps | Soft/hard bounds on journal + results (§8). | -| 5 | Nested `workflow()` | CC-inspired: `name \| { scriptPath }`, depth 1, shared journal/semaphore (§7.8). | -| 6 | Cancel | Cooperative flag → worker abort → hard `terminateProcessTree` (§9). | -| 7 | **`effort` rename** | Profile frontmatter, CLI (`--effort`), store column, runtime input, and `agent()` opts use **`effort`** (not `thinking`). Adapters map `effort` → provider-native flags. | -| 8 | **`isolation`** | **Must-have v1** on `agent()`: `opts.isolation?: 'worktree'`. Shared checkout default; worktree when set (§7.1, §7.1b). | -| — | File-change tracking | Out of scope. Shared disk / worktree is truth; no auto per-stage diff. | -| — | Structured output | **In scope:** `opts.schema` + Ajv + retries (§7.1). | - ---- - -## 2. Claude Code inspiration map - -| CC concept | CC behavior (model-facing) | DevSpace v1 | -|---|---|---| -| `Workflow` tool | Host tool; async; script/name/scriptPath/args/resume | CLI `workflow run` + MCP `run_workflow` | -| `export const meta` | Pure literal; name, description, phases | Same + optional `defaultProvider`, `concurrency` | -| `agent(prompt, opts)` | Spawn worker; string or schema object; null on skip/death in combinators | Same return contract; **throw** on failure; `parallel` → null | -| `opts.schema` | StructuredOutput / validated object | Ajv enforce + retry in engine | -| `opts.model` / `effort` | Tier/effort overrides | `model` + **`effort`** (renamed from `thinking`; provider passthrough) | -| `opts.isolation: 'worktree'` | Per-agent worktree | **v1 must-have** — same semantics, DevSpace-managed worktrees | -| Access / sandbox | Session permission mode; not `writeMode` on agent() | Prompt RO/write + **isolation for write containment** | -| `pipeline` | No barrier; per-item chains | Same | -| `parallel` | Barrier; null slots | Same | -| `phase` / `log` | Progress UX | Journal events + CLI follow / MCP drain | -| `args` | Verbatim tool args | Same | -| `budget` | Shared host token hard ceiling | **Stub** `{ total: null, spent:0, remaining: Infinity }` | -| `workflow()` | Nested name/scriptPath; depth 1; shared caps | Same spirit | -| Determinism bans | Date.now / Math.random / bare new Date | Same | -| Resume | Prefix cache by prompt+opts | Deterministic call-index prefix only (first miss closes replay) | -| File diffs per stage | **Not a primitive** | Same — no auto-diff | - ---- - -## 3. Provider availability now; policy after finalization - -### Current experimental contract - -There is no user-facing `agentProviders` block and no -`DEVSPACE_AGENT_PROVIDERS` environment variable. DevSpace probes implemented -providers at runtime, keeps availability details in memory, and orders usable -providers by `LOCAL_AGENT_PROVIDERS`: - -```text -codex → claude → opencode → pi → cursor → copilot -``` - -`devspace init` does not configure providers. `devspace doctor` may report live -availability but remains read-only. Probe timestamps and unavailable-provider -reasons are diagnostics, not durable user intent. - -Default resolution is: - -```text -explicit agent() provider - → workflow meta.defaultProvider - → first currently available provider -``` - -An explicit provider or profile whose harness is unavailable fails with a clear -typed error. Direct `devspace agents` calls and workflow `agent()` calls use the -same target resolver. - -### Deferred final provider policy - -When Subagents and Dynamic Workflows are finalized and incorporated into -onboarding, the intended durable shape is an ordered array of user choices: - -```ts -interface AgentProviderPolicy { - id: AgentProviderId - enabled: boolean - defaultModel?: string - defaultEffort?: string -} - -interface DevspaceUserConfig { - // ...existing fields... - agentProviders?: AgentProviderPolicy[] -} -``` - -Array order can define fallback preference. Resolution will then be: - -```text -call model/effort override - → profile model/effort - → provider defaultModel/defaultEffort - → provider-native defaults -``` - -Availability snapshots still must not be persisted inside this policy. The -onboarding, config commands, documentation, and provider-management UI should -land together rather than exposing another intermediate configuration shape. ---- - -## 4. Entry surfaces - -### 4.1 CLI - -``` -devspace workflow run (--file | --name | --resume ) - [--arg key=value]... [--follow] -devspace workflow status [--follow] -devspace workflow cancel -devspace workflow ls -devspace workflow __worker # hidden -``` - -| Flag | Spec | -|---|---| -| `--file` | Read script from path (must be under allowed roots when policy applies). | -| `--name` | Resolve via [§6 named files](#6-script-sources). | -| `--resume` | New run row; replay journal from prior runId. | -| `--arg k=v` | Build `args` object (values: JSON-parse if possible else string). | -| `--follow` | Drain events until terminal; print log/phase/agent lines. | - -Spawn: same pattern as `agents __worker` (detached, stdio ignore, unref). Inputs only from run row. - -### 4.2 MCP (togglable with the workflow capability) - -| Tool | Input | Output (conceptual) | -|---|---|---| -| `run_workflow` | `workspaceId`, `script?` \| `name?` \| `resumeFromRunId?`, `args?`, `yieldTimeMs?` | `{ runId, status, events, nextSeq, result? }` after parse+spawn+short yield | -| `workflow_status` | `runId`, `sinceSeq?`, `yieldTimeMs?` | long-poll events / terminal | -| `workflow_cancel` | `runId` | `{ runId, status }` | - -**No** `workflow_ls` on MCP v1. -**No** `agent_*` MCP tools. - -Tool description embeds ~25-line API cheat-sheet (CC-style education in-band). - -### 4.3 Skill - -`skills/dynamic-workflows/SKILL.md` (package-managed; not copied on init): - -- When to use CLI vs when host is ChatGPT (MCP). -- Full primitive reference. -- Prompt patterns for read-only vs write (instead of writeMode). -- Provider list / default fallback. -- Schema examples, resume, cancel, 3 worked examples. - ---- - -## 5. Script contract - -### 5.1 Shape - -```js -export const meta = { - name: 'review-auth', - description: 'Fan-out review of auth changes', - phases: [ - { title: 'Review', detail: 'parallel reviewers' }, - { title: 'Synthesize' }, - ], - // DevSpace extensions (optional): - defaultProvider: 'codex', - concurrency: 4, -} - -// body — async IIFE context -phase('Review') -// ... -return { summary } -``` - -### 5.2 `meta` rules (CC + DS) - -| Rule | Spec | -|---|---| -| First statement | `export const meta = {…}` | -| Pure literal | No vars, calls, spreads, templates in meta object | -| Required | `name`, `description` | -| Optional CC | `phases[]` `{ title, detail? }`, `whenToUse?` | -| Optional DS | `defaultProvider?`, `concurrency?` (clamped to engine max) | -| Validation | Zod `WorkflowMetaSchema` + JSON round-trip purity | -| Extract | Regex start + balanced-brace scanner; `vm.runInNewContext('('+literal+')')` | - -### 5.3 Transform pipeline (`workflow-script.ts`) - -1. Extract/validate meta. -2. Strip leading `export ` → 7 spaces (preserve line numbers). -3. Reject stray `import` / top-level `export` after meta. -4. Wrap: - -```js -(async ({ agent, parallel, pipeline, phase, log, args, budget, workflow, meta, console }) => { - // user body -}) -``` - -5. `new vm.Script(wrapped, { filename: 'workflow:'+name, lineOffset: -1 })`. -6. Friendly errors: missing meta, syntax (with line), purity fail. - -### 5.4 Language bans (CC) - -| Banned in script | Behavior | -|---|---| -| `Date.now()` | `WorkflowDeterminismError` | -| `Math.random()` | same | -| argless `new Date()` | same | -| `require` / `process` / `fetch` / timers | not in context | -| TypeScript syntax | parse fail | - -Allowed: normal JS, `JSON`, `Array`, `Map`, `Set`, `Date.parse`, `new Date(isoString)`. - -`console.log/warn/error` → `log` events. - ---- - -## 6. Script sources - -| Source | Resolution | -|---|---| -| Inline (`--file` content / MCP `script`) | Persist to `/workflows/runs/.js` | -| Named (`--name` / MCP `name`) | (1) `/.devspace/workflows/.js` (2) `~/.devspace/workflows/.js` | -| Resume | Load persisted path on prior run (user may edit that copy) | - -Name sanitization: `^[a-z0-9-]+$`. -Run row stores `scriptPath`, `scriptHash`, `source: inline|named`. - ---- - -## 7. Primitives (spec + implementation) - -All injected into the sandbox. Host deps: `{ journal, runProvider, availableProviders, replay?, concurrency, signal, workspaceRoot }`. - ---- - -### 7.1 `agent(prompt, opts?)` - -#### Spec (public) - -```ts -type AgentOpts = { - label?: string - phase?: string // overrides current ALS phase for this call - schema?: object // JSON Schema → validated object return - model?: string - effort?: string // was "thinking"; provider-native effort/reasoning level - provider?: string // DevSpace; default via §3 - isolation?: "worktree" // must-have; omit = shared workspace root - // NO writeMode in v1 -} - -function agent(prompt: string, opts?: AgentOpts): Promise -// with schema → Promise (validated) -// without → Promise (finalResponse text) -``` - -| Behavior | Spec | -|---|---| -| Failure | **Throw**. `parallel` maps throw → `null`. | -| Success string | Adapter `finalResponse`. | -| Success schema | Validated object; raw text also journaled. | -| Call index | Program order at invocation (before semaphore). | -| Semaphore | Only `agent()` acquires permit. | -| Cancel | Abort signal → throw cancelled. | -| Replay | Cache key includes isolation; hits journal `from_cache`. | -| Isolation | See §7.1b. | - -#### Implementation notes - -``` -async function agent(prompt, opts) { - const callIndex = nextCallIndex() - const provider = resolveProvider(opts, meta, config) - const phase = opts.phase ?? alsPhase.getStore() - const isolation = opts.isolation === "worktree" ? "worktree" : "shared" - const cacheKey = sha256(canonicalJson({ - prompt, provider, - model: opts.model ?? null, - effort: opts.effort ?? null, - schema: opts.schema ?? null, - isolation, - })) - if (replay) { - const hit = replay.match(callIndex, cacheKey) - if (hit) { journal.completeCached(...); return hit.value } - } - await semaphore.acquire(signal) - let worktree: WorktreeHandle | null = null - try { - journal.beginAgentCall({ callIndex, cacheKey, provider, isolation, ... }) - const cwd = isolation === "worktree" - ? (worktree = await createAgentWorktree({ runId, callIndex, workspaceRoot })).path - : workspaceRoot - const run = (p) => runProvider({ - provider, prompt: p, model: opts.model, effort: opts.effort, workspace: cwd, - }) - const result = opts.schema - ? await enforceSchema({ schema: opts.schema, prompt, run, journal, callIndex }) - : (await run(prompt)).finalResponse - journal.completeAgentCall(...) - return result - } catch (e) { - journal.failAgentCall(...) - throw e - } finally { - semaphore.release() - if (worktree) await finalizeAgentWorktree(worktree) // §7.1b - } -} -``` - -`runProvider` wraps `runLocalAgentProvider` with **`effort`** (not `thinking`) on `LocalAgentRunInput`. **No** `local_agent_sessions` dual-write v1. - -### 7.1b `isolation: 'worktree'` (must-have) - -Inspired by CC: expensive (~setup+disk); use when parallel **mutators** would conflict. Not a read-only switch. - -| Rule | Spec | -|---|---| -| Default | Omit / undefined → agent `cwd` = workflow `workspaceRoot` (shared checkout). | -| `"worktree"` | Fresh git worktree under managed root (reuse `config.worktreeRoot` / existing git-worktrees helpers). | -| Base | Pin to workspace HEAD (or open-workspace base SHA if known) at **run start**; all worktrees for the run share that pin unless documented otherwise. | -| Path layout | e.g. `/wf//c/` or UUID; must stay inside managed root. | -| Adapter cwd | Provider runs with `workspace: worktreePath`. | -| Success + dirty | **Preserve** worktree; journal `worktreePath` + `dirty: true` on agent_call / event data. **Do not** auto-merge/cherry-pick into source. | -| Success + clean | Optional auto-remove (CC: remove if unchanged). v1: remove if `git status` clean. | -| Failure / cancel | Preserve for diagnosis; retention e.g. 7d cleanup job later; v1: leave on disk + path in journal. | -| Handoff | Later stages **do not** see worktree files unless they use the same path or agent return text lists paths. Prefer **schema returns** for findings; implementer stages that must compose should use **shared** isolation or sequential shared agents. | -| Parallel safety | Multiple `isolation: 'worktree'` agents concurrent = OK. Mixing worktree + shared writers = caller responsibility (skill: don’t). | -| Non-git workspace | `isolation: 'worktree'` → throw clear error (worktrees require git). | -| Cost | Skill: use only for parallel mutators. | -| Cache key | Includes `isolation` so resume doesn’t reuse shared result for worktree call. | - -Events/data extras: - -```ts -// agent_call_started / completed data -{ worktreePath?: string, isolation: "shared" | "worktree", dirty?: boolean } -``` - -**Not v1:** auto-apply worktree diffs to main checkout; multi-worktree merge tools. -#### Structured output (`workflow-schema.ts`) - -Inspired by CC `schema` → StructuredOutput: - -1. Augment prompt: respond with **only** JSON conforming to schema. -2. Run provider. -3. Extract JSON (fences strip + balanced-brace). -4. Ajv validate (`allErrors: true`, `strict: false`). -5. On fail: journal `schema_retry`; re-run with error text; reuse `providerSessionId` if adapter returned one (max 2 retries). -6. Exhaustion → throw; parallel → null. - ---- - -### 7.2 `parallel(thunks)` - -#### Spec (CC) - -```ts -function parallel(thunks: Array<() => Promise>): Promise> -``` - -| Rule | Spec | -|---|---| -| Barrier | Await all thunks before resolve. | -| Error | Thunk throw / agent throw → that index `null`; **parallel never rejects**. | -| Empty | `[]` → `[]`. | -| Cap | Max **4096** thunks (hard error). | -| Concurrency | Limited by agent semaphore only (thunks can start together; agents queue). | - -#### Implementation - -```js -async function parallel(thunks) { - assertMaxItems(thunks.length) - const results = await Promise.all( - thunks.map(t => t().then(v => v, () => null)) - ) - return results -} -``` - ---- - -### 7.3 `pipeline(items, ...stages)` - -#### Spec (CC) - -```ts -type Stage = (prev: any, originalItem: any, index: number) => any | Promise - -function pipeline(items: any[], ...stages: Stage[]): Promise -``` - -| Rule | Spec | -|---|---| -| Sync | **No barrier** between stages across items. | -| Per item | Sequential stages for that item’s chain. | -| Stage args | `(prevResult, originalItem, index)`. First stage `prev` = item. | -| Throw | That item becomes `null`; remaining stages skipped for it. | -| Cap | Max **4096** items. | -| Wall-clock | ≈ slowest item chain (true concurrency across items). | - -#### Implementation sketch - -```js -async function pipeline(items, ...stages) { - assertMaxItems(items.length) - return Promise.all(items.map((item, index) => - (async () => { - let prev = item - for (const stage of stages) { - try { prev = await stage(prev, item, index) } - catch { return null } - } - return prev - })() - )) -} -``` - ---- - -### 7.4 `phase(title)` - -#### Spec (CC) - -```ts -function phase(title: string): void -``` - -| Rule | Spec | -|---|---| -| Effect | Sets **current phase** for subsequent agents without `opts.phase`. | -| Events | Journal `phase_started` (and optional end on next phase). | -| Concurrency | **AsyncLocalStorage** so concurrent pipeline chains don’t race. | -| UI | CLI `--follow` / MCP events group by phase; match `meta.phases[].title` when possible. | - -```js -function phase(title) { - alsPhase.enterWith(title) // or run with ALS in engine wrapper - journal.appendEvent({ type: 'phase_started', phase: title }) -} -``` - -Prefer documenting: inside concurrent stages set `opts.phase` explicitly (same advice as CC). - ---- - -### 7.5 `log(message)` - -#### Spec (CC) - -```ts -function log(message: string): void -``` - -- Journal `log` event; data truncated per §8. -- CLI follow prints narrator lines. -- Skill: log drops/caps (“no silent caps”). - -`console.log` → same path. - ---- - -### 7.6 `args` - -#### Spec (CC) - -```ts -const args: unknown // frozen; from run input; undefined if omitted -``` - -| Rule | Spec | -|---|---| -| MCP | Pass real JSON object/array — not stringified JSON string. | -| CLI | `--arg k=v` → object; values JSON-parsed when valid. | -| Freeze | `Object.freeze` deep where practical. | -| Resume | Same args required for max cache hits when prompts embed args. | - ---- - -### 7.7 `budget` (stub v1) - -#### Spec (CC shape, stub values) - -```ts -const budget = Object.freeze({ - total: null as number | null, - spent(): number { return 0 }, - remaining(): number { return Infinity }, -}) -``` - -| Future | Wire `total` from CLI/MCP optional `maxAgentCalls` or token directive; hard-throw when exceeded. | -| v1 | Shape present so scripts/skills match CC; loops must still use dry-round or count, not infinite budget loops. | - -Skill warns: do not `while (budget.remaining() > x)` without other exit — remaining is Infinity. - ---- - -### 7.8 `workflow(nameOrRef, args?)` — nested - -#### How CC behaves (inspiration) - -- `workflow(name | { scriptPath }, args?)` -- Runs child **inline** in same run. -- Shares concurrency cap, agent counter, abort, token budget. -- Child agents appear nested in progress UI. -- **Depth 1 only** — nest inside child throws. -- Return value = child’s script return. -- Errors: unknown name / unreadable path / syntax → throw. - -#### DevSpace v1 - -```ts -function workflow( - nameOrRef: string | { scriptPath: string }, - childArgs?: unknown, -): Promise -``` - -| Rule | Spec | -|---|---| -| `string` | Resolve named file (§6). | -| `{ scriptPath }` | Absolute/resolved path to `.js` (must pass root allowlist if enforced). | -| Depth | `nestDepth` ALS/counter; `> 1` → throw. | -| Shared | Same journal runId, semaphore, call-index sequence, cancel signal. | -| Meta | Child meta used for phase titles optionally; run name stays parent. | -| Events | Optional `phase` prefix or `label: nest:childName`. | -| No new process | In-process second script execute. | -| Resume | Child `agent()` calls continue global callIndex — replay still works. | - -```js -async function workflow(nameOrRef, childArgs) { - if (nestDepth >= 1) throw new Error('workflow() nesting limited to one level') - const source = resolveNestedSource(nameOrRef, workspaceRoot) - const parsed = parseWorkflowScript(source) - return executeNested({ parsed, args: childArgs, nestDepth: nestDepth + 1, ...sharedDeps }) -} -``` - ---- - -## 8. Size caps (education + defaults) - -### Why caps exist - -Without bounds: - -- One agent can return multi‑MB logs → SQLite bloat, slow drain. -- MCP tool results can exceed host message limits. -- Event `dataJson` spam freezes `--follow`. -- Malicious/buggy script `return` of huge graphs. - -This is **not** semantic truncation of “coverage”; it’s **transport/storage safety**. Skill still says: if you intentionally sample files, `log()` that you did. - -### Recommended v1 limits - -| Asset | Cap | On exceed | -|---|---|---| -| Event `dataJson` | ~8 KiB string | Truncate + `"truncated": true` | -| `responseText` on agent_calls | e.g. 1 MiB | Truncate stored copy; prefer schema path for structure | -| `structuredJson` | e.g. 256 KiB | Fail agent call (throw) | -| Script `return` → `resultJson` | e.g. 256 KiB | Fail run `errorKind: 'result_too_large'` | -| `args` JSON | e.g. 64 KiB | Reject at createRun | -| Inline script source | e.g. 512 KiB | Reject at parse | -| Events drain page | limit param default 100–500 | Cursor `nextSeq` | - -Numbers can be constants in `workflow-store.ts`; tune later. - ---- - -## 9. Cancel, heartbeat, reap - -| Step | Spec | -|---|---| -| Heartbeat | Worker every 5s updates `heartbeatAt`; polls `cancelRequested`. | -| Cooperative | Set flag → worker AbortController → journal `run_cancelled` → group SIGTERM. | -| Hard | After ≤5s: `terminateProcessTree` on pid; mark cancelled. | -| Reap | `heartbeat` stale >60s **and** `kill(pid,0)` dead → mark failed `errorKind: 'heartbeat'`. | -| Sleep gap | Liveness check avoids false fail after laptop sleep. | - -Adapters: no individual abort API — accepted; group-kill is backstop. - ---- - -## 10. Resume / replay - -| Piece | Spec | -|---|---| -| New run | `--resume` / `resumeFromRunId` creates new run with `resumedFromRunId`. | -| Cache key | `sha256(canonicalJson({ prompt, provider, model, effort, schema, isolation }))` | -| Match | Same callIndex + cache key while the prefix remains open. | -| Close | First failed, interrupted, changed, missing, corrupt, worktree, or unpersisted result executes live and closes replay for later calls. | -| Record | Cache hits written as new rows `from_cache=1` so chains chain. | -| Determinism | Bans make prompt construction stable if args fixed. | - -Document prefix-only resume (no consume-once key fallback) in skill. - ---- - -## 11. Journal schema (behavioral) - -### `workflow_runs` - -id, name, source, scriptPath, scriptHash, workspaceRoot, workspaceId?, argsJson, status (`starting|running|completed|failed|cancelled`), error?, errorKind?, resultJson?, pid?, heartbeatAt?, cancelRequested, resumedFromRunId?, timestamps. - -### `workflow_events` - -(runId, seq) PK; type enum including `run_started`, `phase_started`, `log`, `agent_call_*`, `schema_retry`, `run_*`; phase; label; dataJson truncated. - -### `workflow_agent_calls` - -(runId, callIndex) PK; cacheKey; provider; model; label; phase; status; fromCache; providerSessionId?; responseText; structuredJson?; error?; times. - -Adapter `items[]` **not** persisted. - ---- - -## 12. Access model: prompt + isolation (no writeMode) - -### What Claude Code does - -CC `agent()` opts include `label`, `phase`, `schema`, `model`, `effort`, **`isolation`**, `agentType` — **not** `writeMode`. - -| Layer | Role | -|---|---| -| Host permission mode | Approve / bypass tools | -| `agentType` / tools | Read-oriented vs full agents | -| **`isolation: 'worktree'`** | Mutations in private tree; no auto-merge | -| **Prompt** | “Do not modify files” / implementer instructions | - -### What DevSpace does in v1 - -| Layer | Behavior | -|---|---| -| API | **No writeMode**; **yes `isolation?: 'worktree'`** | -| Adapter | Fixed yolo-style policy (current profile behavior) | -| Isolation | Engine creates managed worktree; cwd for that agent only | -| Skill | RO vs write **prompts** + when to set isolation | - -```text -READ-ONLY reviewer: -- Do not modify files. Return findings via schema. - -IMPLEMENTER (shared tree — sequential): -- Minimal edits; report paths. - -IMPLEMENTER (parallel): -- isolation: 'worktree' -- Report worktree-relative paths + summary in return value. -- Orchestrator decides merge; engine will not auto-merge. -``` ---- - -## 13. File changes (explicit non-primitive) - -| Approach | v1 | -|---|---| -| Shared workspace; later agents see prior edits on disk | Yes | -| Return structured paths/findings between stages | Yes (schema) | -| Auto git snapshot / diff after each agent | **No** | -| Per-agent worktree (`isolation: 'worktree'`) | **Yes v1** (must-have; §7.1b) | -| Host `show_changes` after whole workflow | Optional host behavior; not engine | - ---- - -## 14. End-to-end authoring examples - -### Fan-out review (ChatGPT or local agent) - -```js -export const meta = { - name: 'fanout-review', - description: 'Two reviewers then synthesize', - phases: [{ title: 'Review' }, { title: 'Synthesize' }], -} - -const S = { /* FINDINGS schema */ } -phase('Review') -const reviews = await parallel([ - () => agent('Read-only review security…', { provider: 'claude', label: 'sec', schema: S }), - () => agent('Read-only review tests…', { provider: 'codex', label: 'test', schema: S }), -]) -phase('Synthesize') -const summary = await agent( - `Merge findings:\n${JSON.stringify(reviews.filter(Boolean))}`, - { label: 'merge', schema: { type: 'object', properties: { summary: { type: 'string' } }, required: ['summary'] } }, -) -return { reviews, summary } -``` - -### Pipeline over files (coding agent CLI) - -```js -export const meta = { - name: 'migrate-files', - description: 'Per-file transform', - phases: [{ title: 'Edit' }], -} - -const files = args.files -return pipeline( - files, - (f) => agent(`Update imports in ${f}. Minimal edit. Report path.`, { - label: `edit:${f}`, - phase: 'Edit', - }), -) -``` - ---- - -## 15. Implementation checklist (by primitive) - -| Primitive / surface | Module | Tests focus | -|---|---|---| -| meta parse | `workflow-script.ts` | purity, line nos, missing meta | -| sandbox bans | `workflow-sandbox.ts` | Date/Math throw; console→log | -| agent | `workflow-api.ts` | provider resolve, throw, callIndex order | -| schema | `workflow-schema.ts` | retry, validate, exhaust | -| parallel | `workflow-api.ts` | null on error, barrier | -| pipeline | `workflow-api.ts` | no-barrier proof, stage args | -| phase ALS | `workflow-api.ts` | concurrent chains | -| log / args / budget | `workflow-api.ts` | freeze, stub budget | -| workflow nest | `workflow-api.ts` + engine | depth 1, shared journal | -| store | `workflow-store.ts` | seq, reap, cancel | -| replay | `workflow-replay.ts` | deterministic call-index prefix | -| CLI | `cli.ts` | run/status/cancel/ls/__worker | -| MCP | `workflow-tools.ts` | yield, survive disconnect | -| skill | `skills/dynamic-workflows` | education | -| providers config | `user-config` / init / availability | ordered default | - ---- - -## 16. Non-goals recap (v1) - -- MCP raw agent tools -- `writeMode` on `agent()` (isolation **is** in scope) -- Auto-merge of worktrees into source checkout -- Real host token budget -- Auto file-change / diff events per stage -- MCP run list -- Dashboard -- Dual-write `local_agent_sessions` - ---- - -## 17. `effort` rename (profiles + runtime + agent opts) - -| Surface today | Target | -|---|---| -| Profile YAML `thinking:` | `effort:` | -| CLI `devspace agents run --thinking` | `--effort` | -| `LocalAgentRecord.thinking` / DB column | `effort` (migration: rename column or accept both briefly) | -| `LocalAgentRunInput.thinking` | `effort` | -| Adapter mapping (`modelReasoningEffort`, claude effort, pi `--thinking`) | Read from `input.effort` | -| Docs / examples / skill | `effort` only | -| Workflow `agent()` opts | `effort` only | -| Workflow journal / cache key | `effort` | - -Provider passthrough values stay free strings (`low`, `high`, `xhigh`, …) — DevSpace does not translate between providers. - -**Compat (optional short window):** read profile `thinking` if `effort` missing; CLI accept `--thinking` as alias deprecated. Prefer clean break if you’re fine breaking profile files (examples are under our control). - -## 18. Open only if product changes mind - -1. Exact byte constants for §8. -2. Nested `{ scriptPath }` must be under workspace only? -3. Worktree retention days / cleanup job timing. -4. Whether `agents run` CLI also gains `--isolation worktree` (workflow-first is enough for v1). diff --git a/docs/dynamic-workflows.md b/docs/dynamic-workflows.md new file mode 100644 index 000000000..8020b1acb --- /dev/null +++ b/docs/dynamic-workflows.md @@ -0,0 +1,94 @@ +# 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 installs two skills in +`~/.devspace/skills`: + +- `subagents` for one bounded delegation and later follow-ups +- `dynamic-workflows` for programmed multi-agent orchestration + +Provider selection is stored as `agentProviders` in +`~/.devspace/config.json`. Runtime availability is checked again before a +provider is shown or used. + +## 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 run "" --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 +``` + +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. + +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 Workspace Summary + +When agent tooling is enabled, `open_workspace` stays deliberately small: + +```json +{ + "agentProviders": ["codex", "claude"], + "agents": [ + { "name": "reviewer", "description": "Review changes and test gaps." } + ], + "activeWorkflows": [ + { + "id": "wfr_123", + "name": "review-auth", + "status": "running", + "calls": { "running": 2, "completed": 3, "failed": 0 } + } + ] +} +``` + +Provider capability metadata, models, effort semantics, session identifiers, +workflow phases, and internal counters are intentionally absent. Models obtain +execution details only when needed through `devspace agents targets --json` or +the workflow inspection commands. diff --git a/docs/gotchas.md b/docs/gotchas.md index 18ec19129..23a58ce2d 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -201,22 +201,20 @@ DevSpace looks in standard Agent Skills locations: It also checks compatibility and custom paths: -- the package-managed `subagents` skill when the Subagents capability is enabled -- the package-managed `dynamic-workflows` skill when the Dynamic Workflows capability is enabled +- managed `subagents` and `dynamic-workflows` skills installed by `devspace init` - `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` - additional paths from `DEVSPACE_SKILL_PATHS` -When the Subagents capability is enabled, DevSpace loads agent profiles from +When agent tooling is enabled, DevSpace loads agent profiles from `~/.devspace/agents/*.md` and project `.devspace/agents/*.md`, then exposes a -compact profile catalog through `open_workspace`. The bundled -`subagents` skill can also discover the same usable targets through +compact profile catalog through `open_workspace`. The `subagents` skill can +also discover the same usable targets through `devspace agents targets` in CLI-only hosts. `devspace agents ls` lists existing subagent sessions, not profile definitions. -Bundled skills remain package-managed and are not copied into -`~/.devspace/skills`. A user-owned skill with the same name intentionally -overrides the bundled copy. The legacy `subagent-delegation` name is no longer -advertised. +Run `devspace init --force` to install or refresh DevSpace-managed skill copies. +An unmarked, user-owned skill with the same name is preserved. The legacy +`subagent-delegation` name is no longer advertised. Packaged agent profile examples under `examples/agents/` are starter templates. Copy or adapt them into one of the active profile directories before use. diff --git a/docs/setup.md b/docs/setup.md index e332f216c..9582d92dd 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -1,7 +1,7 @@ # Setup Guide -This guide is for users who want ChatGPT or another MCP host to work in local -projects through DevSpace. +This guide covers both local coding harnesses that use the DevSpace CLI and MCP +hosts such as ChatGPT or Claude. ## Requirements @@ -9,10 +9,10 @@ projects through DevSpace. - npm - Git - Bash, including Git Bash or WSL on Windows -- a public HTTPS URL that forwards to the local DevSpace server +- a public HTTPS URL only when a remote MCP host must reach DevSpace -DevSpace does not create the public tunnel for you. Use Cloudflare Tunnel, -ngrok, Pinggy, Tailscale Funnel, or your own HTTPS reverse proxy. +DevSpace does not create a public tunnel. Remote MCP users can use Cloudflare +Tunnel, ngrok, Pinggy, Tailscale Funnel, or their own HTTPS reverse proxy. ## Install And Configure @@ -26,8 +26,7 @@ The setup flow asks one question at a time. ### Project Roots -Choose the folders ChatGPT is allowed to open through DevSpace. Keep this -narrow. +Choose the folders DevSpace is allowed to open. Keep this narrow. Examples: @@ -55,8 +54,8 @@ http://127.0.0.1:7676/mcp ### Public Base URL -Start your tunnel or reverse proxy before entering this value. Point the tunnel -at: +Setup first asks whether ChatGPT or Claude will connect over the internet. Say +no for CLI-only use. If yes, start your tunnel or reverse proxy and point it at: ```text http://127.0.0.1:7676 @@ -74,8 +73,29 @@ Configure the MCP client with the full MCP endpoint: https://your-tunnel-host.example.com/mcp ``` +### Agent Tooling + +Enable agent tooling to use both direct subagents and Dynamic Workflows. Setup +shows currently available providers and persists only the providers you select. +Unavailable and unselected providers are not exposed to models. + +The two model skills are installed in: + +```text +~/.devspace/skills/subagents +~/.devspace/skills/dynamic-workflows +``` + +DevSpace updates its managed copies on later forced setup runs and preserves a +same-named directory that does not carry the DevSpace management marker. + +Coding harnesses can now run `devspace agents` and `devspace workflow` from a +project directory without starting the MCP server. + ## Start The Server +This step is only required for MCP clients. + Run: ```bash From b0c0ff184eb376d8ada0caaf5a0ddc87e6cf9379 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 06:39:41 +0530 Subject: [PATCH 098/132] fix(init): preserve provider defaults when tooling is off --- src/cli.ts | 4 +++- src/config.test.ts | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index 02ade978d..83fd8b58c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -225,7 +225,9 @@ async function runInit({ force }: { force: boolean }): Promise { allowedRoots, publicBaseUrl, subagents, - agentProviders, + // Disabling the capability should not turn provider defaults into an + // explicit deny-all list if it is later enabled through the environment. + agentProviders: subagents ? agentProviders : files.config.agentProviders, }; const auth = { ownerToken: files.auth.ownerToken ?? generateOwnerToken(), diff --git a/src/config.test.ts b/src/config.test.ts index 5021e6c46..16d26713e 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -207,3 +207,20 @@ assert.deepEqual(fileConfig.allowedHosts, [ "::1", "devspace.example.com", ]); + +const disabledAgentConfigDir = mkdtempSync(join(tmpdir(), "devspace-disabled-agent-config-test-")); +writeFileSync( + join(disabledAgentConfigDir, "config.json"), + JSON.stringify({ + allowedRoots: [process.cwd()], + subagents: false, + }), +); +writeFileSync( + join(disabledAgentConfigDir, "auth.json"), + JSON.stringify({ ownerToken: "persisted-owner-token-long-enough" }), +); +assert.deepEqual(loadConfig({ + DEVSPACE_CONFIG_DIR: disabledAgentConfigDir, + DEVSPACE_SUBAGENTS: "1", +}).agentProviders, ["codex", "claude", "opencode", "pi", "cursor", "copilot"]); From c373be70560ad529f2b481cd419aa5fe9545031a Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 22:08:07 +0530 Subject: [PATCH 099/132] test(tui): add large workflow fixture gallery --- package.json | 1 + scripts/workflow-tui-fixture.ts | 328 ++++++++++++++++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 scripts/workflow-tui-fixture.ts diff --git a/package.json b/package.json index a6f012264..3cffe4d17 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "build": "npm run clean && npm run build:app && tsc -p tsconfig.build.json", "build:app": "vite build", "dev": "node scripts/dev-server.mjs", + "dev:tui-fixture": "tsx scripts/workflow-tui-fixture.ts", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", "test": "tsx src/config.test.ts && tsx src/cli-workspace.test.ts && tsx src/cli-output.test.ts && tsx src/open-workspace-capabilities.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-capabilities.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-resolution.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skill-install.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-lifecycle.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-summary.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-launch.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", diff --git a/scripts/workflow-tui-fixture.ts b/scripts/workflow-tui-fixture.ts new file mode 100644 index 000000000..3e5d536e0 --- /dev/null +++ b/scripts/workflow-tui-fixture.ts @@ -0,0 +1,328 @@ +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-v1"; +const WORKFLOW_NAME = "Ship multi-service authentication"; + +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 run = store.createRun({ + name: WORKFLOW_NAME, + source: name === "replayed" ? "resume" : "inline", + scriptPath: join(stateDir, "fixtures", `${name}.js`), + scriptHash, + workspaceRoot: workspace, + resumedFromRunId: name === "replayed" ? "wfr_previous_fixture" : undefined, + }); + + 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, 0, "Discovery", "Map authentication services", "codex"); + addCachedCall(store, run.id, 1, "Discovery", "Audit token storage", "claude"); + addCachedCall(store, run.id, 2, "Discovery", "Trace client login flows", "codex"); + startPhase(store, run.id, "Architecture"); + addCachedCall(store, run.id, 3, "Architecture", "Design session boundaries", "claude"); + addCachedCall(store, run.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 ? `/tmp/devspace-fixture-worktree-${callIndex}` : undefined, + }); +} + +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.completeAgentCall({ runId, callIndex, responseText: `${label} completed` }); +} + +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, + 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: "wfr_previous_fixture", + replayedFromCallIndex: callIndex, + responseText: `${label} reused from the previous run`, + }); +} + +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 { + throw new Error(message); +} From d45afcc71c16105d982f188c4d76b0659de838ca Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 22:38:44 +0530 Subject: [PATCH 100/132] feat(workflow): add observability storage contracts --- src/db/migrations.ts | 38 +++++++++++++++++++++++++++++++++++++- src/db/schema.ts | 29 +++++++++++++++++++++++++++++ src/workflow-contracts.ts | 18 ++++++++---------- src/workflow-types.ts | 29 +++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 11 deletions(-) diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 43389ee5c..bd00e71c8 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -47,6 +47,11 @@ const migrations: Migration[] = [ name: "workflow-agent-profiles", up: migrateWorkflowAgentProfiles, }, + { + version: 9, + name: "workflow-observability", + up: migrateWorkflowObservability, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -324,9 +329,40 @@ function migrateWorkflowAgentProfiles(sqlite: Database.Database): void { 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); + `); +} + function addColumnIfMissing( sqlite: Database.Database, - table: "workspace_sessions" | "local_agent_sessions" | "workflow_agent_calls", + 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 a087bae90..62e755d1e 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -108,6 +108,7 @@ export const workflowRuns = sqliteTable( 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"), @@ -169,6 +170,13 @@ export const workflowAgentCalls = sqliteTable( 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"), @@ -196,6 +204,26 @@ export const workflowAgentCalls = sqliteTable( ], ); +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; @@ -205,3 +233,4 @@ 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/workflow-contracts.ts b/src/workflow-contracts.ts index 8c15c75aa..197185c20 100644 --- a/src/workflow-contracts.ts +++ b/src/workflow-contracts.ts @@ -6,20 +6,18 @@ import { jsonSchemaSchema, type JsonSchema, type JsonValue } from "./json-types. export const localAgentProviderSchema = z.enum(LOCAL_AGENT_PROVIDERS); +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( - z - .object({ - title: z.string().trim().min(1), - detail: z.string().trim().min(1).optional(), - }) - .strict(), - ) - .optional(), + phases: z.array(workflowPhaseMetaSchema).optional(), whenToUse: z.string().trim().min(1).optional(), defaultProvider: localAgentProviderSchema.optional(), concurrency: z.number().finite().int().positive().optional(), diff --git a/src/workflow-types.ts b/src/workflow-types.ts index 75286338c..8535092b3 100644 --- a/src/workflow-types.ts +++ b/src/workflow-types.ts @@ -67,6 +67,7 @@ export const WORKFLOW_LIMITS = { scriptSourceBytes: 512 * 1024, eventDrainDefault: 200, eventDrainMax: 500, + activityPerCall: 500, } as const; export type AgentProviderId = LocalAgentProvider; @@ -88,6 +89,7 @@ export interface WorkflowRunRecord { workspaceRoot: string; workspaceId?: string; argsJson: string; + phases?: WorkflowPhaseMeta[]; status: WorkflowRunStatus; error?: string; errorKind?: WorkflowErrorKind; @@ -104,6 +106,32 @@ export interface WorkflowRunRecord { 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; @@ -130,6 +158,7 @@ export interface WorkflowAgentCallRecord { status: WorkflowAgentCallStatus; fromCache: boolean; providerSessionId?: string; + usage?: WorkflowTokenUsage; responseText?: string; structuredJson?: string; returnValueJson?: string; From 30239df0e647df4faecad0872900ebcea466f6f9 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 22:38:44 +0530 Subject: [PATCH 101/132] feat(workflow): persist phases usage and agent activity --- src/workflow-launch.test.ts | 6 +- src/workflow-launch.ts | 1 + src/workflow-store.test.ts | 45 ++++++++ src/workflow-store.ts | 204 +++++++++++++++++++++++++++++++++++- 4 files changed, 253 insertions(+), 3 deletions(-) diff --git a/src/workflow-launch.test.ts b/src/workflow-launch.test.ts index 3f40f9192..1fe9b7001 100644 --- a/src/workflow-launch.test.ts +++ b/src/workflow-launch.test.ts @@ -14,7 +14,7 @@ import { launchWorkflowRun } from "./workflow-launch.js"; workspaceRoot: dir, source: { kind: "inline", - script: `export const meta = { name: 'launch-demo', description: 'd' }\nreturn 1\n`, + script: `export const meta = { name: 'launch-demo', description: 'd', phases: [{ title: 'Plan' }, { title: 'Build', detail: 'Implement it' }] }\nreturn 1\n`, }, args: { n: 1 }, cliEntry: "/tmp/devspace-cli-not-used", @@ -26,6 +26,10 @@ import { launchWorkflowRun } from "./workflow-launch.js"; assert.equal(launched.value.run.status, "starting"); assert.match(launched.value.run.scriptPath.replaceAll("\\", "/"), /workflow-scripts\//); assert.equal(launched.value.run.argsJson, JSON.stringify({ n: 1 })); + assert.deepEqual(launched.value.run.phases, [ + { title: "Plan" }, + { title: "Build", detail: "Implement it" }, + ]); await mkdir(join(dir, ".devspace", "workflows"), { recursive: true }); await writeFile( diff --git a/src/workflow-launch.ts b/src/workflow-launch.ts index ef324fcef..f32c8f79b 100644 --- a/src/workflow-launch.ts +++ b/src/workflow-launch.ts @@ -94,6 +94,7 @@ export async function launchWorkflowRun( workspaceRoot: input.workspaceRoot, workspaceId: input.workspaceId, argsJson: JSON.stringify(args === undefined ? null : args), + phases: parsed.meta.phases, resumedFromRunId: priorRunId, baseSha, }); diff --git a/src/workflow-store.test.ts b/src/workflow-store.test.ts index 1b03d61e0..935662c6f 100644 --- a/src/workflow-store.test.ts +++ b/src/workflow-store.test.ts @@ -21,12 +21,20 @@ try { 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" }, + ]); const claimed = store.claimRun(run.id, process.pid); assert.equal(claimed?.status, "running"); @@ -82,6 +90,34 @@ try { 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"); + 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"], + ); store.completeAgentCall({ runId: run.id, callIndex: 0, @@ -91,11 +127,20 @@ try { 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"); diff --git a/src/workflow-store.ts b/src/workflow-store.ts index ea40d3dcf..254ce3d68 100644 --- a/src/workflow-store.ts +++ b/src/workflow-store.ts @@ -1,6 +1,7 @@ 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 { @@ -9,11 +10,16 @@ import { 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, @@ -22,6 +28,7 @@ import { workflowEventTypeSchema, workflowRunSourceSchema, workflowRunStatusSchema, + workflowPhaseMetaSchema, } from "./workflow-contracts.js"; import { InvalidRunTransitionError, @@ -42,10 +49,22 @@ export interface CreateWorkflowRunInput { 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; @@ -131,6 +150,7 @@ interface WorkflowRunRow { workspace_root: string; workspace_id: string | null; args_json: string; + phases_json: string; status: string; error: string | null; error_kind: string | null; @@ -172,6 +192,13 @@ interface WorkflowAgentCallRow { 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; @@ -190,6 +217,19 @@ interface WorkflowAgentCallRow { 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 { @@ -202,6 +242,7 @@ export class WorkflowStore { createRun(input: CreateWorkflowRunInput): WorkflowRunRecord { const now = isoNow(); const argsJson = input.argsJson ?? "null"; + const phasesJson = JSON.stringify(input.phases ?? []); assertArgsSize(argsJson); const record: WorkflowRunRecord = { @@ -213,6 +254,7 @@ export class WorkflowStore { workspaceRoot: resolve(input.workspaceRoot), workspaceId: input.workspaceId, argsJson, + phases: input.phases ?? [], status: "starting", cancelRequested: false, resumedFromRunId: input.resumedFromRunId, @@ -225,9 +267,9 @@ export class WorkflowStore { .prepare( `insert into workflow_runs ( id, name, source, script_path, script_hash, workspace_root, workspace_id, - args_json, status, cancel_requested, resumed_from_run_id, base_sha, + args_json, phases_json, status, cancel_requested, resumed_from_run_id, base_sha, created_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( record.id, @@ -238,6 +280,7 @@ export class WorkflowStore { record.workspaceRoot, record.workspaceId ?? null, record.argsJson, + phasesJson, record.status, "false", record.resumedFromRunId ?? null, @@ -949,6 +992,128 @@ export class WorkflowStore { 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 { + 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, + usage.state, + now, + now, + runId, + callIndex, + ); + if (update.changes === 0) this.requireAgentCall(runId, callIndex); + return { ...usage, updatedAt: now }; + } + + appendAgentActivity(input: AppendWorkflowAgentActivityInput): WorkflowAgentActivityRecord { + 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, + input.kind, + input.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, + 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. @@ -1050,6 +1215,7 @@ function rowToRun(row: WorkflowRunRow): WorkflowRunRecord { 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, @@ -1095,6 +1261,17 @@ function rowToAgentCall(row: WorkflowAgentCallRow): WorkflowAgentCallRecord { 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: row.usage_state === "final" ? "final" : "partial", + updatedAt: row.usage_updated_at, + }, responseText: row.response_text ?? undefined, structuredJson: row.structured_json ?? undefined, returnValueJson: row.return_value_json ?? undefined, @@ -1117,6 +1294,29 @@ function rowToAgentCall(row: WorkflowAgentCallRow): WorkflowAgentCallRecord { }; } +function rowToAgentActivity(row: WorkflowAgentActivityRow): WorkflowAgentActivityRecord { + const kinds: WorkflowAgentActivityKind[] = ["tool", "command", "file", "status"]; + const statuses: WorkflowAgentActivityStatus[] = ["running", "completed", "failed"]; + if (!kinds.includes(row.kind as WorkflowAgentActivityKind)) { + throw new Error(`Unknown workflow agent activity kind: ${row.kind}`); + } + if (!statuses.includes(row.status as WorkflowAgentActivityStatus)) { + throw new Error(`Unknown workflow agent activity status: ${row.status}`); + } + return { + runId: row.run_id, + callIndex: row.call_index, + seq: row.seq, + kind: row.kind as WorkflowAgentActivityKind, + status: row.status as WorkflowAgentActivityStatus, + 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(); } From 95becf7033f450814656fdcc3a480e9366aded4b Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 22:51:49 +0530 Subject: [PATCH 102/132] test(db): expect workflow observability migration --- src/oauth-store.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 78a662149..63762ecf3 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -49,6 +49,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise { { version: 6, name: "workflow-replay-provenance" }, { version: 7, name: "workflow-exact-replay" }, { version: 8, name: "workflow-agent-profiles" }, + { version: 9, name: "workflow-observability" }, ]); } finally { database.close(); From 8542b0e709472dca396fa25cc93a999eb0ed53fa Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 22:43:01 +0530 Subject: [PATCH 103/132] feat(tui): derive workflow navigator views --- src/workflow-view.test.ts | 17 ++++++++++- src/workflow-view.ts | 60 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/workflow-view.test.ts b/src/workflow-view.test.ts index a96ddd8be..0baf3c26c 100644 --- a/src/workflow-view.test.ts +++ b/src/workflow-view.test.ts @@ -15,6 +15,11 @@ const run: WorkflowRunRecord = { 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", @@ -49,6 +54,13 @@ const calls: WorkflowAgentCallRecord[] = [ 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", @@ -105,7 +117,10 @@ 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.deepEqual(view.phases.map((phase) => phase.title), ["Planning", "Implementation"]); +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"); diff --git a/src/workflow-view.ts b/src/workflow-view.ts index 919d53479..d89e87853 100644 --- a/src/workflow-view.ts +++ b/src/workflow-view.ts @@ -10,6 +10,8 @@ import type { WorkflowRunRecord, WorkflowRunSource, WorkflowRunStatus, + WorkflowTokenUsage, + WorkflowAgentActivityRecord, } from "./workflow-types.js"; export const ACTIVE_WORKFLOW_STATUSES = ["starting", "running"] as const satisfies readonly WorkflowRunStatus[]; @@ -41,6 +43,12 @@ export interface WorkflowCallView { replayReason?: string; error?: string; errorKind?: WorkflowErrorKind; + providerSessionId?: string; + usage?: WorkflowTokenUsage; + prompt: string; + responseText?: string; + structuredJson?: string; + returnValueJson?: string; startedAt?: string; completedAt?: string; updatedAt: string; @@ -48,6 +56,8 @@ export interface WorkflowCallView { export interface WorkflowPhaseView { title: string; + detail?: string; + status: "not_started" | "running" | "completed" | "failed" | "cancelled"; calls: WorkflowCallView[]; } @@ -71,6 +81,7 @@ export interface WorkflowRunView { resumedFromRunId?: string; currentPhase?: string; calls: WorkflowCallCounts; + totalTokens: number; phases: WorkflowPhaseView[]; unphasedCalls: WorkflowCallView[]; recentActivity: WorkflowActivityView[]; @@ -90,6 +101,12 @@ export interface WorkflowProjectView { version: string; } +export interface WorkflowCallInspectorView { + run: WorkflowRunView; + call: WorkflowCallView; + activity: WorkflowAgentActivityRecord[]; +} + export function loadWorkflowProjectView( store: WorkflowStore, workspaceRoot: string, @@ -140,8 +157,23 @@ export function buildWorkflowRunView( if (call.phase && !phaseOrder.includes(call.phase)) phaseOrder.push(call.phase); } - const phases = phaseOrder.map((title) => ({ + 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; @@ -161,6 +193,10 @@ export function buildWorkflowRunView( 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), @@ -194,12 +230,34 @@ function toCallView(call: WorkflowAgentCallRecord): WorkflowCallView { 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, From b9c81c944bc34607289fcdddc4b9efda41cccd66 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 22:43:05 +0530 Subject: [PATCH 104/132] feat(tui): add workflow navigator and call inspector --- src/workflow-tui.test.ts | 68 +++++- src/workflow-tui.ts | 459 +++++++++++++++++++++++++-------------- 2 files changed, 358 insertions(+), 169 deletions(-) diff --git a/src/workflow-tui.test.ts b/src/workflow-tui.test.ts index 596965885..e8dfbf984 100644 --- a/src/workflow-tui.test.ts +++ b/src/workflow-tui.test.ts @@ -1,5 +1,7 @@ import assert from "node:assert/strict"; import { + createWorkflowTuiState, + reduceWorkflowTuiState, renderWorkflowTui, resolveWorkflowTuiWorkspaceRoot, } from "./workflow-tui.js"; @@ -26,17 +28,34 @@ const project: WorkflowProjectView = { 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", }, ], @@ -60,12 +79,47 @@ const project: WorkflowProjectView = { ], }; -const rendered = renderWorkflowTui(project, 0, 100, 30, { ansi: false }); -assert.match(rendered, /DevSpace workflows · \/tmp\/project/); -assert.match(rendered, /Review auth · Implementation/); -assert.match(rendered, /Patch auth codex · worktree/); -assert.match(rendered, /Running tests/); -assert.match(rendered, /refreshes automatically/); -assert.equal(resolveWorkflowTuiWorkspaceRoot("./test-project").endsWith("test-project"), true); +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/); + +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/); + +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/); + +assert.equal(resolveWorkflowTuiWorkspaceRoot(process.cwd()), process.cwd()); console.log("workflow-tui.test.ts: ok"); diff --git a/src/workflow-tui.ts b/src/workflow-tui.ts index 5cdc0e324..9cb255d17 100644 --- a/src/workflow-tui.ts +++ b/src/workflow-tui.ts @@ -1,25 +1,43 @@ -import { resolve } from "node:path"; 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 async function runWorkflowTui( - args: string[], - config: ServerConfig, -): Promise { +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(); const store = createWorkflowStore(config); - const load = (): WorkflowProjectView => loadWorkflowProjectView(store, workspaceRoot, { statuses: requestedRunId ? undefined : [...ACTIVE_WORKFLOW_STATUSES], @@ -27,12 +45,20 @@ export async function runWorkflowTui( 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 { - const view = load(); - const selectedIndex = findInitialSelection(view, requestedRunId); process.stdout.write( - `${renderWorkflowTui(view, selectedIndex, 100, 40, { ansi: false })}\n`, + `${renderWorkflowTui(project, state, 100, 40, { ansi: false, activity: activityForState() })}\n`, ); return; } finally { @@ -40,24 +66,21 @@ export async function runWorkflowTui( } } - let project = load(); - let selectedIndex = findInitialSelection(project, requestedRunId); let closed = false; let rendering = false; - const render = (): void => { if (rendering || closed) return; rendering = true; try { project = load(); - selectedIndex = clampSelection(project, selectedIndex, requestedRunId); + state = clampWorkflowTuiState(project, state); process.stdout.write( `\u001b[H\u001b[2J${renderWorkflowTui( project, - selectedIndex, + state, process.stdout.columns || 100, process.stdout.rows || 40, - { ansi: true }, + { ansi: true, activity: activityForState() }, )}`, ); } finally { @@ -67,7 +90,6 @@ export async function runWorkflowTui( await new Promise((done) => { let timer: NodeJS.Timeout; - const finish = (): void => { if (closed) return; closed = true; @@ -81,24 +103,11 @@ export async function runWorkflowTui( store.close(); done(); }; - - const onKeypress = ( - _input: string, - key: { name?: string; ctrl?: boolean }, - ): void => { - if ((key.ctrl && key.name === "c") || key.name === "q" || key.name === "escape") { - finish(); - return; - } - if (key.name === "up") { - selectedIndex = Math.max(0, selectedIndex - 1); - render(); - } else if (key.name === "down") { - selectedIndex = Math.min(Math.max(0, project.runs.length - 1), selectedIndex + 1); - render(); - } + const onKeypress = (_input: string, key: { name?: string; ctrl?: boolean }): void => { + if ((key.ctrl && key.name === "c") || key.name === "q") return finish(); + state = reduceWorkflowTuiState(project, state, key.name ?? ""); + render(); }; - emitKeypressEvents(process.stdin); process.stdin.setRawMode(true); process.stdin.resume(); @@ -112,139 +121,259 @@ export async function runWorkflowTui( } export function resolveWorkflowTuiWorkspaceRoot(cwd = process.cwd()): string { - return resolve(cwd); + return resolveCliWorkspaceContext(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, +): 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, run.phases.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") return { ...state, scroll: state.scroll + 1 }; + return state; } export function renderWorkflowTui( project: WorkflowProjectView, - selectedIndex: number, + state: WorkflowTuiState, columns: number, rows: number, - options: { ansi?: boolean } = {}, + options: { ansi?: boolean; activity?: WorkflowAgentActivityRecord[] } = {}, ): string { - const ansi = options.ansi !== false; const width = Math.max(48, columns); - const selected = project.runs[selectedIndex]; - const lines: string[] = []; - - lines.push(style(truncate(`DevSpace workflows · ${project.workspaceRoot}`, width), "bold", ansi)); - lines.push(rule(width)); + 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, options.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 the current directory."); - lines.push(""); - lines.push(style("q quit", "muted", ansi)); - return fitRows(lines, rows).join("\n"); + lines.push("No active workflows in this project.", "", style("q quit", "muted", ansi)); + return lines; } - - const maxRunRows = Math.max(3, Math.min(8, Math.floor(rows / 4))); - lines.push(style("Active workflows", "heading", ansi)); - for (const [index, run] of project.runs.slice(0, maxRunRows).entries()) { - const marker = index === selectedIndex ? "›" : " "; - const phase = run.currentPhase ? ` · ${run.currentPhase}` : ""; - lines.push( - truncate( - `${marker} ${statusGlyph(run.status)} ${run.name}${phase} · ${callSummary(run)}`, - width, - ), - ); + 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)); } - if (project.runs.length > maxRunRows) { - lines.push(style(` +${project.runs.length - maxRunRows} more`, "muted", ansi)); - } - - lines.push(rule(width)); - if (selected) renderRunDetails(lines, selected, width, rows, ansi); - lines.push(rule(width)); - lines.push(style("↑/↓ select · q/esc quit · refreshes automatically", "muted", ansi)); - return fitRows(lines, rows).join("\n"); + lines.push(rule(width), style("↑/↓ select · Enter open · q quit", "muted", ansi)); + return lines; } -function renderRunDetails( - lines: string[], - run: WorkflowRunView, +function renderNavigator( + project: WorkflowProjectView, + state: Extract, width: number, - rows: number, ansi: boolean, -): void { - lines.push(`${style(run.name, "bold", ansi)} ${statusGlyph(run.status)} ${run.status}`); - lines.push( - truncate( - `${run.currentPhase ? `Phase: ${run.currentPhase} · ` : ""}${callSummary(run)} · ${elapsedLabel(run)}`, - width, - ), - ); - - const phaseBudget = Math.max(4, Math.floor(rows / 2)); - let renderedCalls = 0; - for (const phase of run.phases) { - if (renderedCalls >= phaseBudget) break; - lines.push(style(`\n${phase.title}`, "heading", ansi)); - for (const call of phase.calls) { - if (renderedCalls >= phaseBudget) break; - lines.push(truncate(formatCall(call), width)); - renderedCalls += 1; - } - } - if (run.unphasedCalls.length > 0 && renderedCalls < phaseBudget) { - lines.push(style("\nOther calls", "heading", ansi)); - for (const call of run.unphasedCalls) { - if (renderedCalls >= phaseBudget) break; - lines.push(truncate(formatCall(call), width)); - renderedCalls += 1; +): string[] { + const run = project.runs[state.runIndex]; + if (!run) return ["Workflow is no longer available."]; + 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 phase = run.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, run.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(run.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; +} - const activity = run.recentActivity.slice(-4); - if (activity.length > 0) { - lines.push(style("\nRecent activity", "heading", ansi)); - for (const event of activity) { - const time = new Date(event.createdAt).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); - const label = event.label ?? event.phase ?? event.type.replaceAll("_", " "); - const detail = event.detail ? `: ${event.detail}` : ""; - lines.push(truncate(`${time} ${label}${detail}`, width)); - } - } +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."]; + 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; +} - if (run.error) { - lines.push(style(`\n${run.errorKind ?? "error"}: ${run.error}`, "error", ansi)); +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"}`, + ]; } -function findInitialSelection( - project: WorkflowProjectView, - requestedRunId: string | undefined, -): number { - if (!requestedRunId) return 0; - const index = project.runs.findIndex((run) => run.id === requestedRunId); - if (index < 0) { - throw new Error( - `Workflow ${requestedRunId} does not belong to the current directory: ${project.workspaceRoot}`, - ); - } - return index; +function clampWorkflowTuiState(project: WorkflowProjectView, state: WorkflowTuiState): WorkflowTuiState { + const runIndex = 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?.phases.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 clampSelection( - project: WorkflowProjectView, - selectedIndex: number, - requestedRunId: string | undefined, -): number { - if (requestedRunId) return findInitialSelection(project, requestedRunId); - return Math.min(Math.max(0, selectedIndex), Math.max(0, project.runs.length - 1)); +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 formatCall(call: WorkflowCallView): string { - const label = call.label ?? `Agent #${call.callIndex}`; - const provider = call.model ? `${call.provider}/${call.model}` : call.provider; - const worktree = call.isolation === "worktree" ? " · worktree" : ""; - const replay = call.fromCache ? " · replayed" : ""; - const error = call.error ? ` · ${call.errorKind ?? "error"}: ${call.error}` : ""; - return ` ${statusGlyph(call.status)} ${label} ${provider}${worktree}${replay}${error}`; +function callsForPhase(run: WorkflowRunView, phaseIndex: number): WorkflowCallView[] { + return run.phases[phaseIndex]?.calls ?? run.unphasedCalls; +} + +function initialPhaseIndex(run: WorkflowRunView): number { + const index = run.currentPhase + ? run.phases.findIndex((phase) => phase.title === run.currentPhase) + : -1; + return index < 0 ? 0 : index; +} + +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 { @@ -253,52 +382,58 @@ function callSummary(run: WorkflowRunView): string { run.calls.cached ? `${run.calls.cached} replayed` : undefined, run.calls.running ? `${run.calls.running} running` : undefined, run.calls.failed ? `${run.calls.failed} failed` : undefined, - run.calls.cancelled ? `${run.calls.cancelled} cancelled` : undefined, ].filter((part): part is string => Boolean(part)); - return parts.length > 0 ? parts.join(" · ") : "no agent calls yet"; + return parts.length ? parts.join(" · ") : "no agent calls yet"; } function elapsedLabel(run: WorkflowRunView): string { - const start = Date.parse(run.startedAt ?? run.createdAt); - const end = run.completedAt ? Date.parse(run.completedAt) : Date.now(); - const seconds = Math.max(0, Math.floor((end - start) / 1_000)); + 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); - const remaining = seconds % 60; - if (minutes < 60) return `${minutes}m ${remaining}s`; + if (minutes < 60) return `${minutes}m ${seconds % 60}s`; return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; } -function statusGlyph(status: WorkflowRunView["status"] | WorkflowCallView["status"]): string { +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 "◌"; + return "○"; } -function rule(width: number): string { - return "─".repeat(width); +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 truncate(value: string, width: number): string { - if (value.length <= width) return value; - return `${value.slice(0, Math.max(0, width - 1))}…`; +function timeLabel(value: string): string { + return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); } -function fitRows(lines: string[], rows: number): string[] { - if (rows <= 0 || lines.length <= rows) return lines; - return lines.slice(0, Math.max(1, rows)); +function capitalize(value: string): string { + return `${value[0]!.toUpperCase()}${value.slice(1)}`; } -function style( - value: string, - tone: "bold" | "heading" | "muted" | "error", - ansi: boolean, -): string { +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[] { + return rows > 0 && lines.length > rows ? lines.slice(0, Math.max(1, rows)) : lines; +} +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`; - if (tone === "error") return `\u001b[31m${value}\u001b[0m`; return `\u001b[2m${value}\u001b[0m`; } From 0ece2a29f23bde5ab5b12f1b80011b7195250d5b Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 22:44:08 +0530 Subject: [PATCH 105/132] test(tui): seed navigator observability states --- docs/dynamic-workflows.md | 14 +++++++++ scripts/workflow-tui-fixture.ts | 54 ++++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/docs/dynamic-workflows.md b/docs/dynamic-workflows.md index 8020b1acb..a5c80729f 100644 --- a/docs/dynamic-workflows.md +++ b/docs/dynamic-workflows.md @@ -51,6 +51,7 @@ 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 @@ -63,6 +64,19 @@ 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. The first screen +lists workflows. Opening a run shows its declared phases beside the agent calls +in the selected phase; 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. diff --git a/scripts/workflow-tui-fixture.ts b/scripts/workflow-tui-fixture.ts index 3e5d536e0..6c77c80d4 100644 --- a/scripts/workflow-tui-fixture.ts +++ b/scripts/workflow-tui-fixture.ts @@ -5,8 +5,16 @@ 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-v1"; +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", @@ -87,6 +95,7 @@ function seedFixture( scriptPath: join(stateDir, "fixtures", `${name}.js`), scriptHash, workspaceRoot: workspace, + phases: WORKFLOW_PHASES, resumedFromRunId: name === "replayed" ? "wfr_previous_fixture" : undefined, }); @@ -230,6 +239,24 @@ function startCall( isolation: worktree ? "worktree" : "shared", worktreePath: worktree ? `/tmp/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 { @@ -251,9 +278,34 @@ function addCompletedCall( 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, From 3aadb3c83aeff01a62ac601494a1671de8e3c76a Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 22:59:00 +0530 Subject: [PATCH 106/132] fix(tui): preserve safe navigator selection --- src/workflow-tui.test.ts | 41 ++++++++++++++++++++++- src/workflow-tui.ts | 71 ++++++++++++++++++++++++++++++++-------- 2 files changed, 98 insertions(+), 14 deletions(-) diff --git a/src/workflow-tui.test.ts b/src/workflow-tui.test.ts index e8dfbf984..64495842a 100644 --- a/src/workflow-tui.test.ts +++ b/src/workflow-tui.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { createWorkflowTuiState, reduceWorkflowTuiState, + reconcileWorkflowTuiState, renderWorkflowTui, resolveWorkflowTuiWorkspaceRoot, } from "./workflow-tui.js"; @@ -61,7 +62,17 @@ const project: WorkflowProjectView = { ], }, ], - unphasedCalls: [], + 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, @@ -90,6 +101,7 @@ 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"); @@ -110,6 +122,33 @@ rendered = renderWorkflowTui(project, state, 72, 30, { 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, diff --git a/src/workflow-tui.ts b/src/workflow-tui.ts index 9cb255d17..fc9fd5c96 100644 --- a/src/workflow-tui.ts +++ b/src/workflow-tui.ts @@ -38,9 +38,9 @@ export async function runWorkflowTui(args: string[], config: ServerConfig): Prom const requestedRunId = args.find((arg) => !arg.startsWith("-")); const workspaceRoot = resolveWorkflowTuiWorkspaceRoot(); const store = createWorkflowStore(config); - const load = (): WorkflowProjectView => + const load = (includeTerminal = false): WorkflowProjectView => loadWorkflowProjectView(store, workspaceRoot, { - statuses: requestedRunId ? undefined : [...ACTIVE_WORKFLOW_STATUSES], + statuses: requestedRunId || includeTerminal ? undefined : [...ACTIVE_WORKFLOW_STATUSES], limit: 50, eventLimit: 100, }); @@ -72,8 +72,9 @@ export async function runWorkflowTui(args: string[], config: ServerConfig): Prom if (rendering || closed) return; rendering = true; try { - project = load(); - state = clampWorkflowTuiState(project, state); + const previousProject = project; + project = load(state.screen !== "workflows"); + state = reconcileWorkflowTuiState(previousProject, project, state); process.stdout.write( `\u001b[H\u001b[2J${renderWorkflowTui( project, @@ -171,7 +172,7 @@ export function reduceWorkflowTuiState( 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, run.phases.length - 1), state.phaseIndex + 1), callIndex: 0 }; + 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 { @@ -205,13 +206,15 @@ export function renderWorkflowTui( 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, options.activity ?? []); + : renderCallInspector(project, state, width, ansi, activity); return fitRows(lines, rows).join("\n"); } @@ -247,17 +250,18 @@ function renderNavigator( truncate(`${statusGlyph(run.status)} ${run.status.toUpperCase()} ${elapsedLabel(run)} · ${callSummary(run)}${run.totalTokens ? ` · ${formatTokens(run.totalTokens)} tokens observed` : ""}`, width), rule(width), ]; - const phase = run.phases[state.phaseIndex]; + 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, run.phases, state.phaseIndex, width); + 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(run.phases, state.phaseIndex, leftWidth); + 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) { @@ -323,11 +327,24 @@ function inspectorBody(tab: InspectorTab, call: WorkflowCallView, activity: Work ]; } -function clampWorkflowTuiState(project: WorkflowProjectView, state: WorkflowTuiState): WorkflowTuiState { - const runIndex = Math.min(Math.max(0, state.runIndex), Math.max(0, project.runs.length - 1)); +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?.phases.length ?? 1) - 1)); + 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 }; @@ -339,7 +356,7 @@ function selectedCall(project: WorkflowProjectView, state: { runIndex: number; p } function callsForPhase(run: WorkflowRunView, phaseIndex: number): WorkflowCallView[] { - return run.phases[phaseIndex]?.calls ?? run.unphasedCalls; + return navigatorPhases(run)[phaseIndex]?.calls ?? []; } function initialPhaseIndex(run: WorkflowRunView): number { @@ -349,6 +366,19 @@ function initialPhaseIndex(run: WorkflowRunView): number { 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)); } @@ -437,3 +467,18 @@ function style(value: string, tone: "bold" | "heading" | "muted", ansi: boolean) 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; +} From c59266feae6778243464bcd6a8305ece5fc74775 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 22:48:37 +0530 Subject: [PATCH 107/132] feat(agents): observe Codex and Claude runs --- src/local-agent-adapters.ts | 81 +++++++++++++++++++++-- src/local-agent-runtime.test.ts | 59 ++++++++++++++++- src/local-agent-runtime.ts | 110 +++++++++++++++++++++++++++++++- 3 files changed, 239 insertions(+), 11 deletions(-) diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index ce91c90f0..ab5f5e5cf 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -19,11 +19,13 @@ import { ProviderSchemaUnsupportedError, type LocalAgentRunInput, type LocalAgentRunResult, + type LocalAgentObserver, + type LocalAgentUsageSnapshot, } from "./local-agent-runtime.js"; export interface LocalAgentAdapter { readonly provider: LocalAgentProvider; - run(input: LocalAgentRunInput): Promise; + run(input: LocalAgentRunInput, observer?: LocalAgentObserver): Promise; } const ACP_COMMANDS: Record<"cursor" | "copilot", [string, ...string[]]> = { @@ -35,8 +37,9 @@ const PI_AGENT_TIMEOUT_MS = 120_000; export async function runLocalAgentProvider( provider: LocalAgentProvider, input: LocalAgentRunInput, + observer?: LocalAgentObserver, ): Promise { - const result = await runLocalAgentProviderResult(provider, input); + const result = await runLocalAgentProviderResult(provider, input, observer); if (result.isErr()) throw result.error; return result.value; } @@ -44,9 +47,10 @@ export async function runLocalAgentProvider( export async function runLocalAgentProviderResult( provider: LocalAgentProvider, input: LocalAgentRunInput, + observer?: LocalAgentObserver, ): Promise> { return Result.tryPromise({ - try: () => createLocalAgentAdapter(provider).run(input), + try: () => createLocalAgentAdapter(provider).run(input, observer), catch: (cause) => classifyAgentProviderError(provider, cause), }); } @@ -70,16 +74,16 @@ export function createLocalAgentAdapter(provider: LocalAgentProvider): LocalAgen class CodexLocalAgentAdapter implements LocalAgentAdapter { readonly provider = "codex" as const; - async run(input: LocalAgentRunInput): Promise { + async run(input: LocalAgentRunInput, observer?: LocalAgentObserver): Promise { const runtime = await createCodexSdkLocalAgentRuntime(); - return runtime.run(input); + return runtime.run(input, observer); } } class ClaudeLocalAgentAdapter implements LocalAgentAdapter { readonly provider = "claude" as const; - async run(input: LocalAgentRunInput): Promise { + async run(input: LocalAgentRunInput, observer?: LocalAgentObserver): Promise { const { query } = await import("@anthropic-ai/claude-agent-sdk"); const claudeExecutable = process.env.CLAUDE_COMMAND ?? resolveExecutable("claude"); try { @@ -107,7 +111,11 @@ class ClaudeLocalAgentAdapter implements LocalAgentAdapter { for await (const message of messages) { items.push(message); const record = message as Record; - if (typeof record.session_id === "string") providerSessionId = record.session_id; + if (typeof record.session_id === "string") { + providerSessionId = record.session_id; + observer?.onSession?.(record.session_id); + } + notifyClaudeActivity(record, observer); if (record.type !== "result") continue; const resultError = claudeResultError(record); if (resultError) throw new Error(resultError); @@ -116,14 +124,21 @@ class ClaudeLocalAgentAdapter implements LocalAgentAdapter { finalResponse = extracted.finalResponse; structured = extracted.structured; } + const usage = claudeUsage(record.usage, "final"); + if (usage) observer?.onUsage?.(usage); } finalResponse = requireFinalResponse("Claude", finalResponse); + const usage = [...items] + .reverse() + .map((item) => claudeUsage((item as Record).usage, "final")) + .find((snapshot) => snapshot !== undefined); return { provider: this.provider, providerSessionId, finalResponse, items, + ...(usage ? { usage } : {}), ...(structured !== undefined ? { structured } : {}), }; } catch (error) { @@ -135,6 +150,58 @@ class ClaudeLocalAgentAdapter implements LocalAgentAdapter { } } +function notifyClaudeActivity(record: Record, observer?: LocalAgentObserver): void { + if (record.type === "tool_progress" && typeof record.tool_name === "string") { + observer?.onActivity?.({ kind: "tool", status: "running", label: record.tool_name }); + return; + } + if (record.type === "tool_use_summary" && typeof record.summary === "string") { + observer?.onActivity?.({ kind: "tool", status: "completed", label: record.summary }); + return; + } + if (record.type !== "assistant") return; + const message = record.message as { content?: unknown[] } | undefined; + for (const block of message?.content ?? []) { + const content = block as Record; + if (content.type !== "tool_use" || typeof content.name !== "string") continue; + observer?.onActivity?.({ + kind: content.name === "Bash" ? "command" : content.name === "Write" || content.name === "Edit" ? "file" : "tool", + status: "running", + label: content.name, + detail: claudeToolDetail(content.input), + }); + } +} + +function claudeToolDetail(input: unknown): string | undefined { + if (!input || typeof input !== "object") return undefined; + const record = input as Record; + for (const key of ["command", "file_path", "path", "query"]) { + if (typeof record[key] === "string") return record[key]; + } + return undefined; +} + +function claudeUsage(value: unknown, state: "partial" | "final"): LocalAgentUsageSnapshot | undefined { + if (!value || typeof value !== "object") return undefined; + const usage = value as Record; + const inputTokens = nonNegativeInteger(usage.input_tokens); + const outputTokens = nonNegativeInteger(usage.output_tokens); + if (inputTokens === undefined && outputTokens === undefined) return undefined; + return { + inputTokens, + cachedInputTokens: nonNegativeInteger(usage.cache_read_input_tokens), + cacheCreationInputTokens: nonNegativeInteger(usage.cache_creation_input_tokens), + outputTokens, + totalTokens: (inputTokens ?? 0) + (outputTokens ?? 0), + state, + }; +} + +function nonNegativeInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined; +} + /** Build Claude SDK outputFormat when a JSON Schema is requested. */ export function claudeOutputFormatOptions( schema: JsonSchema | undefined, diff --git a/src/local-agent-runtime.test.ts b/src/local-agent-runtime.test.ts index 29b3d69fc..055034a8c 100644 --- a/src/local-agent-runtime.test.ts +++ b/src/local-agent-runtime.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import type { RunResult, ThreadOptions } from "@openai/codex-sdk"; +import type { RunResult, RunStreamedResult, ThreadEvent, ThreadOptions } from "@openai/codex-sdk"; import { CodexSdkLocalAgentRuntime, createCodexSdkLocalAgentRuntime, @@ -69,6 +69,63 @@ assert.deepEqual(codex.started[0], { modelReasoningEffort: undefined, }); +const streamedEvents: ThreadEvent[] = [ + { type: "thread.started", thread_id: "stream-thread" }, + { + type: "item.started", + item: { + id: "command-1", + type: "command_execution", + command: "npm test", + aggregated_output: "", + status: "in_progress", + }, + }, + { + type: "item.completed", + item: { + id: "command-1", + type: "command_execution", + command: "npm test", + aggregated_output: "ok", + exit_code: 0, + status: "completed", + }, + }, + { type: "item.completed", item: { id: "message-1", type: "agent_message", text: "done" } }, + { + type: "turn.completed", + usage: { input_tokens: 100, cached_input_tokens: 20, output_tokens: 30, reasoning_output_tokens: 10 }, + }, +]; +const streamingThread = { + id: "stream-thread", + async run(): Promise { throw new Error("unreachable"); }, + async runStreamed(): Promise { + return { events: (async function* () { yield* streamedEvents; })() }; + }, +}; +const observedSessions: string[] = []; +const observedActivity: string[] = []; +const observedUsage: number[] = []; +const streamedRuntime = new CodexSdkLocalAgentRuntime({ + startThread: () => streamingThread, + resumeThread: () => streamingThread, +}); +const streamed = await streamedRuntime.run( + { prompt: "test", workspace: "/tmp/project" }, + { + onSession: (id) => observedSessions.push(id), + onActivity: (activity) => observedActivity.push(`${activity.status}:${activity.label}`), + onUsage: (usage) => observedUsage.push(usage.totalTokens), + }, +); +assert.equal(streamed.finalResponse, "done"); +assert.equal(streamed.usage?.totalTokens, 130); +assert.deepEqual(observedSessions, ["stream-thread"]); +assert.deepEqual(observedActivity, ["running:npm test", "completed:npm test"]); +assert.deepEqual(observedUsage, [130]); + await runtime.run({ prompt: "make change", workspace: "/tmp/project", diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index e43a4afff..b3d7c69b6 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -3,7 +3,10 @@ import type { CodexOptions, ModelReasoningEffort, RunResult, + RunStreamedResult, SandboxMode, + ThreadEvent, + ThreadItem, ThreadOptions, TurnOptions, } from "@openai/codex-sdk"; @@ -41,16 +44,40 @@ export interface LocalAgentRunResult { items: unknown[]; /** Provider-native structured object when schema was requested. */ structured?: unknown; + usage?: LocalAgentUsageSnapshot; +} + +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; +} + +export interface LocalAgentObserver { + onSession?(providerSessionId: string): void; + onUsage?(usage: LocalAgentUsageSnapshot): void; + onActivity?(activity: LocalAgentActivity): void; } export interface LocalAgentRuntime { readonly provider: LocalAgentProvider; - run(input: LocalAgentRunInput): Promise; + run(input: LocalAgentRunInput, observer?: LocalAgentObserver): Promise; } interface CodexThreadLike { readonly id: string | null; run(prompt: string, turnOptions?: TurnOptions): Promise; + runStreamed?(prompt: string, turnOptions?: TurnOptions): Promise; } interface CodexClientLike { @@ -90,15 +117,18 @@ export class CodexSdkLocalAgentRuntime implements LocalAgentRuntime { this.codex = codex; } - async run(input: LocalAgentRunInput): Promise { + async run(input: LocalAgentRunInput, observer?: LocalAgentObserver): Promise { const options = threadOptionsFor(input); const thread = input.providerSessionId ? this.codex.resumeThread(input.providerSessionId, options) : this.codex.startThread(options); const turnOptions = input.schema ? { outputSchema: input.schema } : undefined; let turn: RunResult; + const streamed = thread.runStreamed !== undefined; try { - turn = await thread.run(input.prompt, turnOptions); + turn = thread.runStreamed + ? await collectCodexStream(await thread.runStreamed(input.prompt, turnOptions), observer) + : await thread.run(input.prompt, turnOptions); } catch (error) { if (input.schema && isNativeSchemaUnsupportedFailure(error)) { throw new ProviderSchemaUnsupportedError(this.provider, error); @@ -106,16 +136,90 @@ export class CodexSdkLocalAgentRuntime implements LocalAgentRuntime { throw error; } + if (!streamed && thread.id) observer?.onSession?.(thread.id); + const usage = turn.usage ? codexUsage(turn.usage) : undefined; + if (usage) observer?.onUsage?.(usage); return { provider: this.provider, providerSessionId: thread.id, finalResponse: turn.finalResponse, items: turn.items, + usage, ...(input.schema ? { structured: tryParseJson(turn.finalResponse) } : {}), }; } } +async function collectCodexStream( + streamed: RunStreamedResult, + observer?: LocalAgentObserver, +): Promise { + const items: ThreadItem[] = []; + let finalResponse = ""; + let usage: RunResult["usage"] = null; + for await (const event of streamed.events) { + if (event.type === "thread.started") observer?.onSession?.(event.thread_id); + if (event.type === "item.started") notifyCodexItem(event.item, "running", observer); + if (event.type === "item.completed") { + items.push(event.item); + notifyCodexItem(event.item, codexItemStatus(event.item), observer); + if (event.item.type === "agent_message") finalResponse = event.item.text; + } + if (event.type === "turn.completed") usage = event.usage; + if (event.type === "turn.failed") throw new Error(event.error.message); + if (event.type === "error") throw new Error(event.message); + } + return { items, finalResponse, usage }; +} + +function notifyCodexItem( + item: ThreadItem, + status: LocalAgentActivity["status"], + observer?: LocalAgentObserver, +): void { + const activity = codexItemActivity(item, status); + if (activity) observer?.onActivity?.(activity); +} + +function codexItemActivity( + item: ThreadItem, + status: LocalAgentActivity["status"], +): LocalAgentActivity | undefined { + if (item.type === "command_execution") { + return { kind: "command", status, label: item.command }; + } + if (item.type === "file_change") { + return { + kind: "file", + status, + label: "apply file changes", + detail: item.changes.map((change) => `${change.kind} ${change.path}`).join(", "), + }; + } + if (item.type === "mcp_tool_call") { + return { kind: "tool", status, label: `${item.server}.${item.tool}` }; + } + if (item.type === "web_search") return { kind: "tool", status, label: "web search", detail: item.query }; + return undefined; +} + +function codexItemStatus(item: ThreadItem): LocalAgentActivity["status"] { + if (item.type === "command_execution" || item.type === "mcp_tool_call" || item.type === "file_change") { + return item.status === "failed" ? "failed" : "completed"; + } + return "completed"; +} + +function codexUsage(usage: NonNullable): LocalAgentUsageSnapshot { + return { + inputTokens: usage.input_tokens, + cachedInputTokens: usage.cached_input_tokens, + outputTokens: usage.output_tokens, + totalTokens: usage.input_tokens + usage.output_tokens, + state: "final", + }; +} + function tryParseJson(text: string): unknown | undefined { try { return JSON.parse(text) as unknown; From c4af5c73efea92fc687c87b8687dd6508d39f52b Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 22:48:38 +0530 Subject: [PATCH 108/132] feat(workflow): persist live provider observations --- src/workflow-agent-observer.ts | 54 ++++++++++++++++++++++++++++++++++ src/workflow-api.ts | 2 ++ src/workflow-store.test.ts | 26 ++++++++++++++++ src/workflow-worker.ts | 29 ++++++++++++------ 4 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 src/workflow-agent-observer.ts diff --git a/src/workflow-agent-observer.ts b/src/workflow-agent-observer.ts new file mode 100644 index 000000000..bd89ee185 --- /dev/null +++ b/src/workflow-agent-observer.ts @@ -0,0 +1,54 @@ +import type { LocalAgentObserver, LocalAgentUsageSnapshot } from "./local-agent-runtime.js"; +import type { WorkflowStore } from "./workflow-store.js"; + +const USAGE_WRITE_INTERVAL_MS = 5_000; + +export function createWorkflowAgentObserver( + store: WorkflowStore, + runId: string, + callIndex: number, + intervalMs = USAGE_WRITE_INTERVAL_MS, +): LocalAgentObserver & { close(): void } { + let lastUsageWrite = 0; + let pendingUsage: LocalAgentUsageSnapshot | undefined; + let timer: NodeJS.Timeout | undefined; + + const persistUsage = (usage: LocalAgentUsageSnapshot): void => { + pendingUsage = undefined; + if (timer) clearTimeout(timer); + timer = undefined; + lastUsageWrite = Date.now(); + store.updateAgentUsage(runId, callIndex, usage); + }; + + 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; + }, + }; +} diff --git a/src/workflow-api.ts b/src/workflow-api.ts index d30f4df29..158bc2c81 100644 --- a/src/workflow-api.ts +++ b/src/workflow-api.ts @@ -34,6 +34,7 @@ export { WorkflowEngineError } from "./workflow-errors.js"; // --------------------------------------------------------------------------- export interface WorkflowProviderRunInput { + callIndex: number; provider: LocalAgentProvider; prompt: string; providerSessionId?: string; @@ -388,6 +389,7 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { const cwd = worktreePath ?? deps.workspaceRoot; const providerBase = { + callIndex: index, provider, prompt: providerPrompt, model, diff --git a/src/workflow-store.test.ts b/src/workflow-store.test.ts index 935662c6f..4c41e7975 100644 --- a/src/workflow-store.test.ts +++ b/src/workflow-store.test.ts @@ -4,6 +4,7 @@ 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-")); @@ -404,6 +405,31 @@ try { 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(); + assert.equal(store.getAgentCall(observedRun.id, 0)?.providerSessionId, "session_123"); + assert.equal(store.getAgentCall(observedRun.id, 0)?.usage?.totalTokens, 220); + 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); diff --git a/src/workflow-worker.ts b/src/workflow-worker.ts index ec4425085..f2ded0a47 100644 --- a/src/workflow-worker.ts +++ b/src/workflow-worker.ts @@ -4,6 +4,7 @@ import { availableParallelism } from "node:os"; import type { ServerConfig } from "./config.js"; import { parseJsonText, type JsonValue } from "./json-types.js"; import { runLocalAgentProviderResult } from "./local-agent-adapters.js"; +import { createWorkflowAgentObserver } from "./workflow-agent-observer.js"; import { isLocalAgentProvider, loadLocalAgentProfiles, @@ -98,15 +99,25 @@ export async function runWorkflowWorker( if (abort.signal.aborted || store.isCancelRequested(runId)) { throw Object.assign(new Error("Workflow cancelled"), { name: "AbortError" }); } - const providerRun = await runLocalAgentProviderResult(input.provider, { - prompt: input.prompt, - workspace: input.workspace, - providerSessionId: input.providerSessionId, - model: input.model, - effort: input.effort, - writeMode: "allowed", - schema: input.schema, - }); + const observer = createWorkflowAgentObserver(store, runId, input.callIndex); + let providerRun; + try { + providerRun = await runLocalAgentProviderResult( + input.provider, + { + prompt: input.prompt, + workspace: input.workspace, + providerSessionId: input.providerSessionId, + model: input.model, + effort: input.effort, + writeMode: "allowed", + schema: input.schema, + }, + observer, + ); + } finally { + observer.close(); + } if (providerRun.isErr()) throw providerRun.error; const providerResult = providerRun.value; return { From 84fc2eebca49313c695c28ba4d50bb423c5d0cc6 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 22:59:34 +0530 Subject: [PATCH 109/132] fix(workflow): accumulate usage across provider retries --- src/workflow-agent-observer.ts | 17 ++++++++++++++++- src/workflow-store.test.ts | 8 +++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/workflow-agent-observer.ts b/src/workflow-agent-observer.ts index bd89ee185..5e24cb03f 100644 --- a/src/workflow-agent-observer.ts +++ b/src/workflow-agent-observer.ts @@ -9,6 +9,7 @@ export function createWorkflowAgentObserver( callIndex: number, intervalMs = USAGE_WRITE_INTERVAL_MS, ): LocalAgentObserver & { close(): void } { + const baseline = store.getAgentCall(runId, callIndex)?.usage; let lastUsageWrite = 0; let pendingUsage: LocalAgentUsageSnapshot | undefined; let timer: NodeJS.Timeout | undefined; @@ -18,7 +19,17 @@ export function createWorkflowAgentObserver( if (timer) clearTimeout(timer); timer = undefined; lastUsageWrite = Date.now(); - store.updateAgentUsage(runId, callIndex, usage); + 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 => { @@ -52,3 +63,7 @@ export function createWorkflowAgentObserver( }, }; } + +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-store.test.ts b/src/workflow-store.test.ts index 4c41e7975..30c237e10 100644 --- a/src/workflow-store.test.ts +++ b/src/workflow-store.test.ts @@ -425,8 +425,14 @@ try { 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?.totalTokens, 220); + 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"); From 47cd18fd427d827c7149da953c1248a851f8c08d Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 22:50:42 +0530 Subject: [PATCH 110/132] feat(agents): observe remaining provider activity --- src/local-agent-adapters.test.ts | 50 ++++++++++++ src/local-agent-adapters.ts | 132 +++++++++++++++++++++++++++++-- 2 files changed, 174 insertions(+), 8 deletions(-) diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index 93cf4fc04..dca8ef50a 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -9,12 +9,62 @@ import { extractPiFinalResponse, extractPiProviderError, extractPiStreamingText, + observeAcpUpdate, + observeOpenCodeResult, + observePiEvent, piCommandEnvironment, resolveAcpModelConfigUpdate, resolveAcpEffortConfigUpdate, } from "./local-agent-adapters.js"; import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; import type { LocalAgentProvider } from "./local-agent-profiles.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.deepEqual(observedActivity.shift(), { + kind: "command", + status: "completed", + label: "bash", +}); + +observePiEvent({ + type: "tool_execution_start", + toolName: "read", + tool: { name: "read", arguments: { path: "src/index.ts" } }, +}, observer); +assert.deepEqual(observedActivity.shift(), { + kind: "tool", + status: "running", + label: "read", +}); + +observeAcpUpdate({ + sessionUpdate: "tool_call_update", + kind: "execute", + title: "Run tests", + status: "failed", +}, observer); +assert.deepEqual(observedActivity.shift(), { + kind: "command", + status: "failed", + label: "Run tests", +}); const providers: LocalAgentProvider[] = [ "codex", diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index ab5f5e5cf..d97b66d8f 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -19,6 +19,7 @@ import { ProviderSchemaUnsupportedError, type LocalAgentRunInput, type LocalAgentRunResult, + type LocalAgentActivity, type LocalAgentObserver, type LocalAgentUsageSnapshot, } from "./local-agent-runtime.js"; @@ -287,14 +288,16 @@ export function claudeCommandEnvironment(env: NodeJS.ProcessEnv): NodeJS.Process class OpencodeLocalAgentAdapter implements LocalAgentAdapter { readonly provider = "opencode" as const; - async run(input: LocalAgentRunInput): Promise { + async run(input: LocalAgentRunInput, observer?: LocalAgentObserver): Promise { const { createOpencode } = await import("@opencode-ai/sdk/v2"); const { client, server } = await createOpencode(); try { const sessionId = input.providerSessionId ?? await createOpencodeSession(client, input); + observer?.onSession?.(sessionId); const promptResult = await promptOpencodeSession(client, sessionId, input); await waitForOpencodeSession(client, sessionId); const messages = await readOpencodeMessages(client, sessionId); + const usage = observeOpenCodeResult(messages, observer); const finalResponse = requireFinalResponse( "OpenCode", extractOpenCodeFinalResponse(messages) || extractOpenCodeFinalResponse(promptResult), @@ -304,6 +307,7 @@ class OpencodeLocalAgentAdapter implements LocalAgentAdapter { providerSessionId: sessionId, finalResponse, items: [promptResult, messages], + ...(usage ? { usage } : {}), }; } finally { server.close(); @@ -317,7 +321,7 @@ class AcpLocalAgentAdapter implements LocalAgentAdapter { private readonly command: [string, ...string[]], ) {} - async run(input: LocalAgentRunInput): Promise { + async run(input: LocalAgentRunInput, observer?: LocalAgentObserver): Promise { const { client } = await import("@agentclientprotocol/sdk"); const { methods } = await import("@agentclientprotocol/sdk"); const { ndJsonStream } = await import("@agentclientprotocol/sdk"); @@ -350,6 +354,7 @@ class AcpLocalAgentAdapter implements LocalAgentAdapter { .connectWith(stream, async (context) => { const session = await context.buildSession(input.workspace).start(); providerSessionId = session.sessionId; + observer?.onSession?.(session.sessionId); try { if (input.model) { const config = resolveAcpModelConfigUpdate(session, input.model, this.provider); @@ -364,14 +369,18 @@ class AcpLocalAgentAdapter implements LocalAgentAdapter { for (;;) { const message = await session.nextUpdate(); if (message.kind === "stop") { - await prompt; + const response = await prompt; + const usage = tokenUsage(asRecord(response.usage), "final"); + if (usage) observer?.onUsage?.(usage); return textParts.join("").trim(); } const update = message.update; - if (update.sessionUpdate !== "agent_message_chunk") continue; - const content = update.content; - if (content.type === "text") textParts.push(content.text); + observeAcpUpdate(update, observer); + if (update.sessionUpdate === "agent_message_chunk") { + const content = update.content; + if (content.type === "text") textParts.push(content.text); + } } } finally { session.dispose(); @@ -482,7 +491,7 @@ function selectAcpAllowPermissionOption(options: Array<{ optionId: string; kind: class PiRpcLocalAgentAdapter implements LocalAgentAdapter { readonly provider = "pi" as const; - async run(input: LocalAgentRunInput): Promise { + async run(input: LocalAgentRunInput, observer?: LocalAgentObserver): Promise { const args = ["--mode", "rpc"]; if (input.model) args.push("--model", input.model); if (input.effort) args.push("--thinking", input.effort); @@ -496,10 +505,14 @@ class PiRpcLocalAgentAdapter implements LocalAgentAdapter { assertPipedChild(child); const rpc = new JsonLineRpc(child); const events: unknown[] = []; - rpc.onEvent((event) => events.push(event)); + rpc.onEvent((event) => { + events.push(event); + observePiEvent(event, observer); + }); try { const state = await rpc.request({ type: "get_state" }); const providerSessionId = readNestedString(state, ["sessionId"]) ?? input.providerSessionId ?? null; + if (providerSessionId) observer?.onSession?.(providerSessionId); const done = rpc.waitForEvent((event) => asRecord(event)?.type === "agent_end", PI_AGENT_TIMEOUT_MS); await rpc.request({ type: "prompt", message: input.prompt }); const agentEnd = await done; @@ -528,6 +541,109 @@ class PiRpcLocalAgentAdapter implements LocalAgentAdapter { } } +export function observeOpenCodeResult( + value: unknown, + observer?: LocalAgentObserver, +): 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); + const status = normalizeActivityStatus(state?.status ?? part.status); + observer?.onActivity?.({ + kind: toolKind(directString(part.tool) ?? directString(part.name)), + status, + label: directString(part.tool) ?? directString(part.name) ?? "tool", + }); + } + } + if (usage) observer?.onUsage?.(usage); + return usage; +} + +export function observePiEvent(event: unknown, observer?: LocalAgentObserver): void { + const record = asRecord(event); + if (!record) return; + const usage = tokenUsage(asRecord(record.usage) ?? asRecord(asRecord(record.message)?.usage), record.type === "agent_end" ? "final" : "partial"); + if (usage) observer?.onUsage?.(usage); + const tool = asRecord(record.tool) ?? asRecord(record.toolCall) ?? asRecord(record.toolExecution); + const name = directString(record.toolName) ?? directString(tool?.name); + if (!name) return; + const detail = directString(record.command) ?? directString(asRecord(tool?.arguments)?.command); + observer?.onActivity?.({ + kind: toolKind(name), + status: normalizeActivityStatus(record.status ?? tool?.status ?? (record.type === "tool_execution_end" ? "completed" : "running")), + label: name, + ...(detail ? { detail } : {}), + }); +} + +export function observeAcpUpdate(update: unknown, observer?: LocalAgentObserver): void { + const record = asRecord(update); + if (!record) return; + if (record.sessionUpdate === "usage_update") return; + if (record.sessionUpdate !== "tool_call" && record.sessionUpdate !== "tool_call_update") return; + const label = directString(record.title) ?? directString(record.kind) ?? "tool"; + observer?.onActivity?.({ + kind: acpToolKind(directString(record.kind)), + status: normalizeActivityStatus(record.status), + label, + }); +} + +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 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(cache?.read ?? value.cached_read_tokens), + cacheCreationInputTokens: nonNegativeInteger(cache?.write ?? value.cache_creation_input_tokens), + outputTokens, + totalTokens: explicitTotal ?? (inputTokens ?? 0) + (outputTokens ?? 0), + state, + }; +} + +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"; +} + export function piCommandEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { if (env.PI_COMMAND) return env; const path = env.PATH; From 7f310ed690fd7816ac2eda68ba71b96109bc064c Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 8 Aug 2026 23:00:04 +0530 Subject: [PATCH 111/132] fix(opencode): scope observations to the current turn --- src/local-agent-adapters.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index d97b66d8f..76ffd946c 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -295,9 +295,11 @@ class OpencodeLocalAgentAdapter implements LocalAgentAdapter { const sessionId = input.providerSessionId ?? await createOpencodeSession(client, input); observer?.onSession?.(sessionId); const promptResult = await promptOpencodeSession(client, sessionId, input); + // The prompt response is scoped to this turn; the messages endpoint returns + // the whole session and would reattribute historical tools on resume. + const usage = observeOpenCodeResult(promptResult, observer); await waitForOpencodeSession(client, sessionId); const messages = await readOpencodeMessages(client, sessionId); - const usage = observeOpenCodeResult(messages, observer); const finalResponse = requireFinalResponse( "OpenCode", extractOpenCodeFinalResponse(messages) || extractOpenCodeFinalResponse(promptResult), From bc7555175f2b95ef4ace277dcb7aead8dcbabb9f Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:05:10 +0530 Subject: [PATCH 112/132] fix(agents): retain complete Claude usage --- src/local-agent-adapters.test.ts | 52 ++++++++++++++++++++++++++ src/local-agent-adapters.ts | 63 +++++++++++++++++++++++++++----- 2 files changed, 105 insertions(+), 10 deletions(-) diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index 93cf4fc04..3865ac9c1 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -9,12 +9,64 @@ import { extractPiFinalResponse, extractPiProviderError, extractPiStreamingText, + observeClaudeUsage, piCommandEnvironment, resolveAcpModelConfigUpdate, resolveAcpEffortConfigUpdate, } from "./local-agent-adapters.js"; import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import type { LocalAgentUsageSnapshot } from "./local-agent-runtime.js"; + +const observedClaudeUsage: LocalAgentUsageSnapshot[] = []; +let claudeUsage = observeClaudeUsage({ + type: "assistant", + message: { + usage: { + input_tokens: 100, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 10, + output_tokens: 30, + }, + }, +}, undefined, { onUsage: (usage) => observedClaudeUsage.push(usage) }); +assert.deepEqual(claudeUsage, { + inputTokens: 100, + cachedInputTokens: 20, + cacheCreationInputTokens: 10, + outputTokens: 30, + totalTokens: 160, + state: "partial", +}); + +claudeUsage = observeClaudeUsage({ + type: "result", + usage: { + input_tokens: 200, + cache_read_input_tokens: 40, + cache_creation_input_tokens: 15, + output_tokens: 60, + }, +}, claudeUsage, { onUsage: (usage) => observedClaudeUsage.push(usage) }); +assert.deepEqual(claudeUsage, { + inputTokens: 200, + cachedInputTokens: 40, + cacheCreationInputTokens: 15, + outputTokens: 60, + totalTokens: 315, + state: "final", +}); +assert.deepEqual(observedClaudeUsage, [ + { + inputTokens: 100, + cachedInputTokens: 20, + cacheCreationInputTokens: 10, + outputTokens: 30, + totalTokens: 160, + state: "partial", + }, + claudeUsage, +]); const providers: LocalAgentProvider[] = [ "codex", diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index ab5f5e5cf..2fc055b8d 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -107,6 +107,7 @@ class ClaudeLocalAgentAdapter implements LocalAgentAdapter { let providerSessionId = input.providerSessionId ?? null; let finalResponse = ""; let structured: unknown | undefined; + let usage: LocalAgentUsageSnapshot | undefined; const items: unknown[] = []; for await (const message of messages) { items.push(message); @@ -116,6 +117,7 @@ class ClaudeLocalAgentAdapter implements LocalAgentAdapter { observer?.onSession?.(record.session_id); } notifyClaudeActivity(record, observer); + usage = observeClaudeUsage(record, usage, observer); if (record.type !== "result") continue; const resultError = claudeResultError(record); if (resultError) throw new Error(resultError); @@ -124,15 +126,9 @@ class ClaudeLocalAgentAdapter implements LocalAgentAdapter { finalResponse = extracted.finalResponse; structured = extracted.structured; } - const usage = claudeUsage(record.usage, "final"); - if (usage) observer?.onUsage?.(usage); } finalResponse = requireFinalResponse("Claude", finalResponse); - const usage = [...items] - .reverse() - .map((item) => claudeUsage((item as Record).usage, "final")) - .find((snapshot) => snapshot !== undefined); return { provider: this.provider, providerSessionId, @@ -150,6 +146,21 @@ class ClaudeLocalAgentAdapter implements LocalAgentAdapter { } } +export function observeClaudeUsage( + record: Record, + accumulated: LocalAgentUsageSnapshot | undefined, + observer?: LocalAgentObserver, +): LocalAgentUsageSnapshot | undefined { + const final = record.type === "result"; + const message = record.message as Record | undefined; + const current = claudeUsage(final ? record.usage : message?.usage, final ? "final" : "partial"); + if (!current) return accumulated; + + const usage = final ? current : addUsage(accumulated, current); + observer?.onUsage?.(usage); + return usage; +} + function notifyClaudeActivity(record: Record, observer?: LocalAgentObserver): void { if (record.type === "tool_progress" && typeof record.tool_name === "string") { observer?.onActivity?.({ kind: "tool", status: "running", label: record.tool_name }); @@ -186,18 +197,50 @@ function claudeUsage(value: unknown, state: "partial" | "final"): LocalAgentUsag if (!value || typeof value !== "object") return undefined; const usage = value as Record; 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 && outputTokens === undefined) return undefined; + if ( + inputTokens === undefined && + cachedInputTokens === undefined && + cacheCreationInputTokens === undefined && + outputTokens === undefined + ) return undefined; return { inputTokens, - cachedInputTokens: nonNegativeInteger(usage.cache_read_input_tokens), - cacheCreationInputTokens: nonNegativeInteger(usage.cache_creation_input_tokens), + cachedInputTokens, + cacheCreationInputTokens, outputTokens, - totalTokens: (inputTokens ?? 0) + (outputTokens ?? 0), + totalTokens: + (inputTokens ?? 0) + + (cachedInputTokens ?? 0) + + (cacheCreationInputTokens ?? 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 sumOptional(left: number | undefined, right: number | undefined): number | undefined { + return left === undefined && right === undefined ? undefined : (left ?? 0) + (right ?? 0); +} + function nonNegativeInteger(value: unknown): number | undefined { return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined; } From 350a56fc6651b35a429f356f2720b249ab8a8bd7 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:06:04 +0530 Subject: [PATCH 113/132] fix(workflow): enforce MCP workspace boundaries --- src/workflow-errors.test.ts | 25 ++++++++++++++++++++++++ src/workflow-tools.ts | 39 +++++++++++++++++++++++++++++++++---- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/workflow-errors.test.ts b/src/workflow-errors.test.ts index bbc385904..404f89883 100644 --- a/src/workflow-errors.test.ts +++ b/src/workflow-errors.test.ts @@ -28,6 +28,7 @@ import { import { WorkflowStore } from "./workflow-store.js"; import { enforceAgentSchemaResult } from "./workflow-schema.js"; import { createWorkflowWorktreeResult } from "./workflow-worktrees.js"; +import { requireWorkflowRunInWorkspace } from "./workflow-tools.js"; { const invalid = parseWorkflowArgFlagsResult(["--arg", "missing-equals"]); @@ -35,6 +36,30 @@ import { createWorkflowWorktreeResult } from "./workflow-worktrees.js"; if (invalid.isErr()) assert.ok(InvalidWorkflowInputError.is(invalid.error)); } +{ + const identifiedRun = { + id: "wfr_identified", + workspaceId: "ws_owner", + workspaceRoot: "/project", + }; + assert.doesNotThrow(() => + requireWorkflowRunInWorkspace(identifiedRun, "ws_owner", "/different-checkout"), + ); + assert.throws( + () => requireWorkflowRunInWorkspace(identifiedRun, "ws_other", "/project"), + /Unknown workflow run: wfr_identified/, + ); + + const legacyRun = { id: "wfr_legacy", workspaceRoot: "/project" }; + assert.doesNotThrow(() => + requireWorkflowRunInWorkspace(legacyRun, "ws_current", "/project/./"), + ); + assert.throws( + () => requireWorkflowRunInWorkspace(legacyRun, "ws_current", "/other-project"), + /Unknown workflow run: wfr_legacy/, + ); +} + { const missing = await readWorkflowScriptFileResult("/definitely/missing/workflow.js"); assert.ok(missing.isErr()); diff --git a/src/workflow-tools.ts b/src/workflow-tools.ts index 6efbb6f60..a807f36cf 100644 --- a/src/workflow-tools.ts +++ b/src/workflow-tools.ts @@ -1,3 +1,4 @@ +import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; @@ -7,6 +8,7 @@ import { jsonValueSchema, parseJsonText, type JsonValue } from "./json-types.js" import type { WorkspaceRegistry } from "./workspaces.js"; import { persistWorkflowScriptResult, + readProjectWorkflowScriptFileResult, resolveNamedWorkflowScriptResult, readWorkflowScriptFileResult, } from "./workflow-files.js"; @@ -118,6 +120,7 @@ export function registerWorkflowTools( if (priorResult.isErr()) throw priorResult.error; const prior = priorResult.value; if (!prior) throw new WorkflowNotFoundError(resumeFromRunId); + requireWorkflowRunInWorkspace(prior, workspaceId, workspace.root); priorRunId = prior.id; const overridePath = scriptPath; if (script !== undefined) { @@ -137,7 +140,12 @@ export function registerWorkflowTools( nameHint = resolvedResult.value.nameHint; } else { priorScriptPath = overridePath ?? prior.scriptPath; - const resolvedResult = await readWorkflowScriptFileResult(priorScriptPath); + const resolvedResult = overridePath + ? await readProjectWorkflowScriptFileResult({ + scriptPath: overridePath, + workspaceRoot: workspace.root, + }) + : await readWorkflowScriptFileResult(priorScriptPath); if (resolvedResult.isErr()) throw resolvedResult.error; source = resolvedResult.value.source; scriptHash = resolvedResult.value.scriptHash; @@ -164,7 +172,10 @@ export function registerWorkflowTools( nameHint = resolved.nameHint; runSource = "named"; } else if (scriptPath) { - const resolvedResult = await readWorkflowScriptFileResult(scriptPath); + const resolvedResult = await readProjectWorkflowScriptFileResult({ + scriptPath, + workspaceRoot: workspace.root, + }); if (resolvedResult.isErr()) throw resolvedResult.error; source = resolvedResult.value.source; scriptHash = resolvedResult.value.scriptHash; @@ -226,6 +237,7 @@ export function registerWorkflowTools( title: "Workflow status", description: "Drain events for a workflow run; optional long-poll yield.", inputSchema: { + workspaceId: z.string().describe("Workspace id from open_workspace."), runId: z.string(), sinceSeq: z.number().int().min(0).optional(), yieldTimeMs: z @@ -239,12 +251,14 @@ export function registerWorkflowTools( annotations: { readOnlyHint: true }, _meta: workflowWidgetMeta(config), }, - async ({ runId, sinceSeq, yieldTimeMs }) => { + async ({ workspaceId, runId, sinceSeq, yieldTimeMs }) => { + const workspace = workspaces.getWorkspace(workspaceId); const store = createWorkflowStore(config); try { const runResult = store.getRunResult(runId); if (runResult.isErr()) throw runResult.error; if (!runResult.value) throw new WorkflowNotFoundError(runId); + requireWorkflowRunInWorkspace(runResult.value, workspaceId, workspace.root); const page = await yieldEvents(store, runId, sinceSeq ?? 0, yieldTimeMs ?? 0); return toolResult(page, "workflow_status"); } catch (error) { @@ -263,14 +277,20 @@ export function registerWorkflowTools( title: "Cancel workflow", description: "Request cooperative cancel of a running workflow.", inputSchema: { + workspaceId: z.string().describe("Workspace id from open_workspace."), runId: z.string(), }, annotations: { readOnlyHint: false }, _meta: {}, }, - async ({ runId }) => { + async ({ workspaceId, runId }) => { + const workspace = workspaces.getWorkspace(workspaceId); const store = createWorkflowStore(config); try { + const runResult = store.getRunResult(runId); + if (runResult.isErr()) throw runResult.error; + if (!runResult.value) throw new WorkflowNotFoundError(runId); + requireWorkflowRunInWorkspace(runResult.value, workspaceId, workspace.root); const latest = await cancelWorkflowRun(store, runId); return { content: [{ type: "text" as const, text: JSON.stringify({ runId, status: latest.status }) }], @@ -290,6 +310,17 @@ export function registerWorkflowTools( } } +export function requireWorkflowRunInWorkspace( + run: Pick, + workspaceId: string, + workspaceRoot: string, +): void { + const owned = run.workspaceId + ? run.workspaceId === workspaceId + : resolve(run.workspaceRoot) === resolve(workspaceRoot); + if (!owned) throw new WorkflowNotFoundError(run.id); +} + function registerWorkflowUiTools( server: McpServer, config: ServerConfig, From 2cb5d8b8ad2ead7726c30c90121062e521bd2270 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:07:34 +0530 Subject: [PATCH 114/132] fix(agents): retain ACP and Pi usage --- src/local-agent-adapters.test.ts | 22 ++++++++++++++++++++++ src/local-agent-adapters.ts | 18 +++++++++++++----- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index e04fe1a96..ec501d3bd 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -38,12 +38,34 @@ const openCodeUsage = observeOpenCodeResult({ }], }, observer); assert.equal(openCodeUsage?.totalTokens, 1_250); +assert.deepEqual(observedUsage.shift(), { + inputTokens: 1_000, + cachedInputTokens: 400, + cacheCreationInputTokens: 50, + outputTokens: 250, + totalTokens: 1_250, + 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.deepEqual(piUsage, { + inputTokens: 800, + cachedInputTokens: undefined, + cacheCreationInputTokens: undefined, + outputTokens: 200, + totalTokens: 1_000, + state: "final", +}); +assert.deepEqual(observedUsage.shift(), piUsage); + observePiEvent({ type: "tool_execution_start", toolName: "read", diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 7ac5a2393..6379190d6 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -389,6 +389,7 @@ class AcpLocalAgentAdapter implements LocalAgentAdapter { ); try { let providerSessionId = input.providerSessionId ?? null; + let usage: LocalAgentUsageSnapshot | undefined; const finalResponse = await client({ name: "DevSpace" }) .onRequest(methods.client.session.requestPermission, (context) => { const selected = selectAcpAllowPermissionOption(context.params.options); @@ -415,7 +416,7 @@ class AcpLocalAgentAdapter implements LocalAgentAdapter { const message = await session.nextUpdate(); if (message.kind === "stop") { const response = await prompt; - const usage = tokenUsage(asRecord(response.usage), "final"); + usage = tokenUsage(asRecord(response.usage), "final"); if (usage) observer?.onUsage?.(usage); return textParts.join("").trim(); } @@ -436,6 +437,7 @@ class AcpLocalAgentAdapter implements LocalAgentAdapter { providerSessionId, finalResponse: finalResponse.trim(), items: [], + ...(usage ? { usage } : {}), }; } catch (error) { throw new Error(`${this.provider} ACP run failed: ${errorMessage(error)}${stderr ? `\n${stderr.trim()}` : ""}`); @@ -550,9 +552,10 @@ class PiRpcLocalAgentAdapter implements LocalAgentAdapter { assertPipedChild(child); const rpc = new JsonLineRpc(child); const events: unknown[] = []; + let usage: LocalAgentUsageSnapshot | undefined; rpc.onEvent((event) => { events.push(event); - observePiEvent(event, observer); + usage = observePiEvent(event, observer) ?? usage; }); try { const state = await rpc.request({ type: "get_state" }); @@ -579,6 +582,7 @@ class PiRpcLocalAgentAdapter implements LocalAgentAdapter { providerSessionId, finalResponse, items: [...events, sessionMessages], + ...(usage ? { usage } : {}), }; } finally { child.kill(); @@ -613,14 +617,17 @@ export function observeOpenCodeResult( return usage; } -export function observePiEvent(event: unknown, observer?: LocalAgentObserver): void { +export function observePiEvent( + event: unknown, + observer?: LocalAgentObserver, +): LocalAgentUsageSnapshot | undefined { const record = asRecord(event); - if (!record) return; + if (!record) return undefined; const usage = tokenUsage(asRecord(record.usage) ?? asRecord(asRecord(record.message)?.usage), record.type === "agent_end" ? "final" : "partial"); if (usage) observer?.onUsage?.(usage); const tool = asRecord(record.tool) ?? asRecord(record.toolCall) ?? asRecord(record.toolExecution); const name = directString(record.toolName) ?? directString(tool?.name); - if (!name) return; + if (!name) return usage; const detail = directString(record.command) ?? directString(asRecord(tool?.arguments)?.command); observer?.onActivity?.({ kind: toolKind(name), @@ -628,6 +635,7 @@ export function observePiEvent(event: unknown, observer?: LocalAgentObserver): v label: name, ...(detail ? { detail } : {}), }); + return usage; } export function observeAcpUpdate(update: unknown, observer?: LocalAgentObserver): void { From db143298374b0f36c3e6db64d11e7c8ea158842c Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:11:25 +0530 Subject: [PATCH 115/132] fix(workflow): harden shared workflow launches --- src/workflow-cli-entry.ts | 15 +++++ src/workflow-cli.ts | 16 ++---- src/workflow-contracts.ts | 2 +- src/workflow-launch.test.ts | 111 ++++++++++++++++++++++++------------ src/workflow-launch.ts | 80 ++++++++++++++++++++------ src/workflow-tools.ts | 7 +-- src/workflow-worker.ts | 28 +++++---- 7 files changed, 178 insertions(+), 81 deletions(-) create mode 100644 src/workflow-cli-entry.ts diff --git a/src/workflow-cli-entry.ts b/src/workflow-cli-entry.ts new file mode 100644 index 000000000..a5330f550 --- /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 index 6100332ca..83aae0d25 100644 --- a/src/workflow-cli.ts +++ b/src/workflow-cli.ts @@ -1,5 +1,4 @@ import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; import type { ServerConfig } from "./config.js"; import { parseWorkflowArgFlagsResult } from "./workflow-files.js"; import { @@ -27,6 +26,7 @@ import { spawnWorkflowWorker, spawnWorkflowWorkerFromCli, } from "./workflow-worker.js"; +import { resolveCliEntry } from "./workflow-cli-entry.js"; export { runWorkflowWorker, spawnWorkflowWorker, spawnWorkflowWorkerFromCli }; @@ -130,15 +130,8 @@ async function runWorkflowRun(args: string[], config: ServerConfig): Promise; -export const workflowRunSourceSchema = z.enum(["inline", "named", "resume"]); +export const workflowRunSourceSchema = z.enum(["inline", "file", "named", "resume"]); export type WorkflowRunSource = z.infer; export const agentOptsSchema = z diff --git a/src/workflow-launch.test.ts b/src/workflow-launch.test.ts index 3f40f9192..126632f0f 100644 --- a/src/workflow-launch.test.ts +++ b/src/workflow-launch.test.ts @@ -1,64 +1,105 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { WorkflowStore } from "./workflow-store.js"; +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 launched = await launchWorkflowRun({ + const common = { store, config: { stateDir: dir }, workspaceRoot: dir, - source: { - kind: "inline", - script: `export const meta = { name: 'launch-demo', description: 'd' }\nreturn 1\n`, - }, - args: { n: 1 }, + workspaceId: "ws_owner", + scriptFileScope: "local" as const, cliEntry: "/tmp/devspace-cli-not-used", spawn: false, + }; + + const launched = await launchWorkflowRun({ + ...common, + source: { kind: "inline", script: script("launch-demo") }, + args: { n: 1 }, }); - assert.equal(launched.isOk(), true); - if (!launched.isOk()) throw launched.error; - assert.equal(launched.value.run.name, "launch-demo"); + if (launched.isErr()) throw launched.error; assert.equal(launched.value.run.status, "starting"); - assert.match(launched.value.run.scriptPath.replaceAll("\\", "/"), /workflow-scripts\//); assert.equal(launched.value.run.argsJson, JSON.stringify({ n: 1 })); - await mkdir(join(dir, ".devspace", "workflows"), { recursive: true }); - await writeFile( - join(dir, ".devspace", "workflows", "named-wf.js"), - `export const meta = { name: 'named-wf', description: 'd' }\nreturn 2\n`, - ); + 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({ - store, - config: { stateDir: dir }, - workspaceRoot: dir, - source: { kind: "named", name: "named-wf" }, - cliEntry: "/tmp/devspace-cli-not-used", - spawn: false, + ...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.equal(named.isOk(), true); - if (!named.isOk()) throw named.error; - assert.equal(named.value.source, "named"); - assert.equal(named.value.run.name, "named-wf"); + 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: dir }, + config: { stateDir: stateFile }, workspaceRoot: dir, - source: { kind: "resume", runId: launched.value.run.id }, + source: { kind: "inline", script: script("failure") }, + scriptFileScope: "local", cliEntry: "/tmp/devspace-cli-not-used", spawn: false, }); - assert.equal(resumed.isOk(), true); - if (!resumed.isOk()) throw resumed.error; - assert.equal(resumed.value.source, "resume"); - assert.equal(resumed.value.run.resumedFromRunId, launched.value.run.id); - assert.equal(resumed.value.run.argsJson, JSON.stringify({ n: 1 })); - + assert.ok(failed.isErr()); + assert.equal(store.listRuns(1)[0]?.status, "failed"); store.close(); await rm(dir, { recursive: true, force: true }); } diff --git a/src/workflow-launch.ts b/src/workflow-launch.ts index 23d441441..ec353843a 100644 --- a/src/workflow-launch.ts +++ b/src/workflow-launch.ts @@ -1,25 +1,27 @@ +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 } from "./workflow-script.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 { WorkflowScriptError } from "./workflow-script.js"; -import type { WorkflowFileWriteError } from "./workflow-errors.js"; import type { WorkflowRunTransitionError } from "./workflow-store.js"; export type LaunchWorkflowSource = @@ -43,6 +45,8 @@ export interface LaunchWorkflowRunInput { 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. */ @@ -104,13 +108,24 @@ export async function launchWorkflowRun( source: sourceText, preferredName, }); - if (persisted.isErr()) return persisted; + if (persisted.isErr()) { + failStartedRun(input.store, run.id, persisted.error); + return persisted; + } const updated = input.store.setScriptPathResult(run.id, persisted.value); - if (updated.isErr()) return updated; + if (updated.isErr()) { + failStartedRun(input.store, run.id, updated.error); + return updated; + } if (input.spawn !== false) { - spawnWorkflowWorker(run.id, input.cliEntry); + try { + await spawnWorkflowWorker(run.id, input.cliEntry); + } catch (error) { + failStartedRun(input.store, run.id, error); + throw error; + } } return Result.ok({ @@ -146,6 +161,9 @@ async function resolveLaunchSource( if (priorResult.isErr()) return priorResult; 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; @@ -172,7 +190,7 @@ async function resolveLaunchSource( nameHint = named.value.nameHint; filename = named.value.scriptPath; } else if (source.override?.kind === "file") { - const file = await readWorkflowScriptFileResult(source.override.path); + const file = await resolveExplicitWorkflowFile(input, source.override.path); if (file.isErr()) return file; sourceText = file.value.source; scriptHash = file.value.scriptHash; @@ -238,17 +256,13 @@ async function resolveLaunchSource( } if (source.kind === "file") { - const file = await resolveWorkflowScriptFromPathOrNameResult({ - file: source.path, - workspaceRoot, - stateDir: config.stateDir, - }); + const file = await resolveExplicitWorkflowFile(input, source.path); if (file.isErr()) return file; return Result.ok({ sourceText: file.value.source, scriptHash: file.value.scriptHash, nameHint: file.value.nameHint, - runSource: file.value.origin === "named" ? "named" : "inline", + runSource: "file", filename: file.value.scriptPath, args, }); @@ -263,12 +277,40 @@ async function resolveLaunchSource( } function isLaunchError(error: unknown): error is LaunchWorkflowError { - return ( - typeof error === "object" && - error !== null && - "name" in error && - (error as { name?: string }).name === "WorkflowScriptError" - ); + 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 { diff --git a/src/workflow-tools.ts b/src/workflow-tools.ts index e648467af..b5c7fb35c 100644 --- a/src/workflow-tools.ts +++ b/src/workflow-tools.ts @@ -1,4 +1,3 @@ -import { fileURLToPath } from "node:url"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; import * as z from "zod/v4"; @@ -29,6 +28,7 @@ import { type LaunchWorkflowSource, } from "./workflow-launch.js"; import { resolveWorkflowLiveProviders } from "./workflow-providers.js"; +import { resolveCliEntry } from "./workflow-cli-entry.js"; const WORKSPACE_APP_URI = "ui://devspace/workspace-app.html"; const WORKFLOW_UI_WAIT_MAX_MS = 30_000; @@ -110,9 +110,8 @@ export function registerWorkflowTools( workspaceId, source, args, - cliEntry: fileURLToPath( - import.meta.url.replace(/workflow-tools\.(ts|js)$/, "cli.$1"), - ), + scriptFileScope: "project-workflows", + cliEntry: resolveCliEntry(), }); if (launched.isErr()) { if (isWorkflowOperationError(launched.error)) return workflowToolError(launched.error); diff --git a/src/workflow-worker.ts b/src/workflow-worker.ts index 619b5ba04..414772368 100644 --- a/src/workflow-worker.ts +++ b/src/workflow-worker.ts @@ -174,17 +174,23 @@ export async function runWorkflowWorker( } } -export function spawnWorkflowWorker(runId: string, cliEntry: string): void { - const child = spawn( - process.execPath, - [...process.execArgv, cliEntry, "workflow", "__worker", runId], - { - detached: true, - stdio: "ignore", - env: process.env, - }, - ); - child.unref(); +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 */ From f86f3a5ce1f03421b18f7c5500b4315034f438c6 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:11:25 +0530 Subject: [PATCH 116/132] docs(workflow): include profiles in replay identity --- docs/dynamic-workflow/devspace/primitives-spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dynamic-workflow/devspace/primitives-spec.md b/docs/dynamic-workflow/devspace/primitives-spec.md index 219408f3e..708c2efec 100644 --- a/docs/dynamic-workflow/devspace/primitives-spec.md +++ b/docs/dynamic-workflow/devspace/primitives-spec.md @@ -613,7 +613,7 @@ Adapters: no individual abort API — accepted; group-kill is backstop. | Piece | Spec | |---|---| | New run | `--resume` / `resumeFromRunId` creates new run with `resumedFromRunId`. | -| Cache key | `sha256(canonicalJson({ prompt, provider, model, effort, schema, isolation }))` | +| Cache key | `sha256(canonicalJson({ prompt, provider, model, effort, profileName, profileFingerprint, schema, isolation }))` | | Match | Same callIndex + cache key while the prefix remains open. | | Close | First failed, interrupted, changed, missing, corrupt, worktree, or unpersisted result executes live and closes replay for later calls. | | Record | Cache hits written as new rows `from_cache=1` so chains chain. | From 89e5c8a25a5d7fd0d737f06bff326fbb27e588cc Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:11:40 +0530 Subject: [PATCH 117/132] fix(tui): restore terminal on refresh failures --- docs/dynamic-workflows.md | 14 +++--- src/workflow-tui.test.ts | 35 ++++++++++++- src/workflow-tui.ts | 101 ++++++++++++++++++++++++-------------- 3 files changed, 107 insertions(+), 43 deletions(-) diff --git a/docs/dynamic-workflows.md b/docs/dynamic-workflows.md index a5c80729f..af2e5a324 100644 --- a/docs/dynamic-workflows.md +++ b/docs/dynamic-workflows.md @@ -64,12 +64,14 @@ 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. The first screen -lists workflows. Opening a run shows its declared phases beside the agent calls -in the selected phase; 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. +`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, diff --git a/src/workflow-tui.test.ts b/src/workflow-tui.test.ts index 64495842a..fe611648d 100644 --- a/src/workflow-tui.test.ts +++ b/src/workflow-tui.test.ts @@ -1,4 +1,7 @@ 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, @@ -159,6 +162,36 @@ const narrow = renderWorkflowTui(project, { assert.match(narrow, /PHASES/); assert.doesNotMatch(narrow, /AGENTS · Implementation/); -assert.equal(resolveWorkflowTuiWorkspaceRoot(process.cwd()), process.cwd()); +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 index fc9fd5c96..8ffa861e6 100644 --- a/src/workflow-tui.ts +++ b/src/workflow-tui.ts @@ -68,45 +68,53 @@ export async function runWorkflowTui(args: string[], config: ServerConfig): Prom let closed = false; let rendering = false; - 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() }, - )}`, - ); - } finally { - rendering = false; - } - }; - - await new Promise((done) => { - let timer: NodeJS.Timeout; - const finish = (): void => { + await new Promise((done, reject) => { + let timer: NodeJS.Timeout | undefined; + const finish = (error?: unknown): void => { if (closed) return; closed = true; - clearInterval(timer); + if (timer) clearInterval(timer); process.stdin.off("keypress", onKeypress); process.stdout.off("resize", render); process.off("SIGINT", finish); - process.stdin.setRawMode(false); - process.stdin.pause(); - process.stdout.write("\u001b[?25h\u001b[?1049l"); - store.close(); - done(); + 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 onKeypress = (_input: string, key: { name?: string; ctrl?: boolean }): void => { + 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 ?? ""); + state = reduceWorkflowTuiState(project, state, key.name ?? "", activityForState()); render(); }; emitKeypressEvents(process.stdin); @@ -147,6 +155,7 @@ export function reduceWorkflowTuiState( project: WorkflowProjectView, state: WorkflowTuiState, key: string, + activity: WorkflowAgentActivityRecord[] = [], ): WorkflowTuiState { const run = project.runs[state.runIndex]; if (state.screen === "workflows") { @@ -195,7 +204,12 @@ export function reduceWorkflowTuiState( 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") return { ...state, scroll: 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; } @@ -244,7 +258,13 @@ function renderNavigator( ansi: boolean, ): string[] { const run = project.runs[state.runIndex]; - if (!run) return ["Workflow is no longer available."]; + 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), @@ -281,7 +301,13 @@ function renderCallInspector( ): string[] { const run = project.runs[state.runIndex]; const call = run ? selectedCall(project, state) : undefined; - if (!run || !call) return ["Agent call is no longer available."]; + 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 = [ @@ -459,7 +485,10 @@ 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[] { - return rows > 0 && lines.length > rows ? lines.slice(0, Math.max(1, rows)) : lines; + 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; From f5191ea60d807ddab3f9b0ebe324e33b405ceb12 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:14:26 +0530 Subject: [PATCH 118/132] fix(workflow): isolate unidentified run scopes --- src/workflow-store.test.ts | 6 ++++++ src/workflow-store.ts | 27 +++++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/workflow-store.test.ts b/src/workflow-store.test.ts index 1b03d61e0..96a642533 100644 --- a/src/workflow-store.test.ts +++ b/src/workflow-store.test.ts @@ -196,6 +196,12 @@ try { }, { 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"] }) diff --git a/src/workflow-store.ts b/src/workflow-store.ts index ea40d3dcf..1cfebaf43 100644 --- a/src/workflow-store.ts +++ b/src/workflow-store.ts @@ -313,13 +313,36 @@ export class WorkflowStore { limit?: number; } = {}, ): WorkflowRunRecord[] { - if (!scope.workspaceId) return this.listRunsForWorkspace(scope.workspaceRoot, options); - 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( From 1ed58269af218a8f628a8fc8c5aa49bb7bee9bc4 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:14:26 +0530 Subject: [PATCH 119/132] fix(cli): document trailing JSON output flag --- src/local-agent-targets.test.ts | 5 +++++ src/local-agent-targets.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/local-agent-targets.test.ts b/src/local-agent-targets.test.ts index e4b0f534e..ecb50c89d 100644 --- a/src/local-agent-targets.test.ts +++ b/src/local-agent-targets.test.ts @@ -103,6 +103,11 @@ assert.throws( /Missing value for --effort/, ); +assert.throws( + () => parseLocalAgentRunArgs([]), + /"" \[--json\]$/, +); + { const target = resolveLocalAgentTarget("reviewer", profiles); assert.equal(target?.kind, "profile"); diff --git a/src/local-agent-targets.ts b/src/local-agent-targets.ts index 2b801aec8..d3d657323 100644 --- a/src/local-agent-targets.ts +++ b/src/local-agent-targets.ts @@ -19,7 +19,7 @@ export interface ParsedLocalAgentRunArgs { export type LocalAgentTarget = ResolvedLocalAgentExecution; const USAGE = - 'Usage: devspace agents run [--model ] [--effort ] ""'; + 'Usage: devspace agents run [--model ] [--effort ] "" [--json]'; export function parseLocalAgentRunArgs(args: string[]): ParsedLocalAgentRunArgs { const json = args.at(-1) === "--json"; From 14797aeead61e4368565c58148e855273ea1b162 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:15:23 +0530 Subject: [PATCH 120/132] fix(workflow): validate observability persistence --- src/workflow-contracts.ts | 3 +++ src/workflow-store.test.ts | 33 +++++++++++++++++++++++++++++++++ src/workflow-store.ts | 35 ++++++++++++++++++----------------- 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/src/workflow-contracts.ts b/src/workflow-contracts.ts index 197185c20..7a3912032 100644 --- a/src/workflow-contracts.ts +++ b/src/workflow-contracts.ts @@ -5,6 +5,9 @@ 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({ diff --git a/src/workflow-store.test.ts b/src/workflow-store.test.ts index 935662c6f..29a9cf39a 100644 --- a/src/workflow-store.test.ts +++ b/src/workflow-store.test.ts @@ -35,6 +35,18 @@ try { { 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"); @@ -99,6 +111,15 @@ try { 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, @@ -118,6 +139,18 @@ try { 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, diff --git a/src/workflow-store.ts b/src/workflow-store.ts index 254ce3d68..064fba450 100644 --- a/src/workflow-store.ts +++ b/src/workflow-store.ts @@ -26,9 +26,12 @@ import { parseWorkflowEventPayload, workflowAgentCallStatusSchema, workflowEventTypeSchema, + workflowAgentActivityKindSchema, + workflowAgentActivityStatusSchema, workflowRunSourceSchema, workflowRunStatusSchema, workflowPhaseMetaSchema, + workflowTokenUsageStateSchema, } from "./workflow-contracts.js"; import { InvalidRunTransitionError, @@ -242,7 +245,8 @@ export class WorkflowStore { createRun(input: CreateWorkflowRunInput): WorkflowRunRecord { const now = isoNow(); const argsJson = input.argsJson ?? "null"; - const phasesJson = JSON.stringify(input.phases ?? []); + const phases = z.array(workflowPhaseMetaSchema).parse(input.phases ?? []); + const phasesJson = JSON.stringify(phases); assertArgsSize(argsJson); const record: WorkflowRunRecord = { @@ -254,7 +258,7 @@ export class WorkflowStore { workspaceRoot: resolve(input.workspaceRoot), workspaceId: input.workspaceId, argsJson, - phases: input.phases ?? [], + phases, status: "starting", cancelRequested: false, resumedFromRunId: input.resumedFromRunId, @@ -1011,6 +1015,7 @@ export class WorkflowStore { callIndex: number, usage: Omit, ): WorkflowTokenUsage { + const state = workflowTokenUsageStateSchema.parse(usage.state); for (const value of [ usage.inputTokens, usage.cachedInputTokens, @@ -1042,17 +1047,19 @@ export class WorkflowStore { usage.cacheCreationInputTokens ?? null, usage.outputTokens ?? null, usage.totalTokens, - usage.state, + state, now, now, runId, callIndex, ); if (update.changes === 0) this.requireAgentCall(runId, callIndex); - return { ...usage, updatedAt: now }; + 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); @@ -1073,8 +1080,8 @@ export class WorkflowStore { input.runId, input.callIndex, next.next_seq, - input.kind, - input.status, + kind, + status, input.label, input.detail ?? null, input.startedAt ?? null, @@ -1089,6 +1096,8 @@ export class WorkflowStore { .run(input.runId, input.callIndex, next.next_seq - WORKFLOW_LIMITS.activityPerCall); return { ...input, + kind, + status, seq: next.next_seq, createdAt: now, }; @@ -1269,7 +1278,7 @@ function rowToAgentCall(row: WorkflowAgentCallRow): WorkflowAgentCallRecord { cacheCreationInputTokens: row.usage_cache_creation_input_tokens ?? undefined, outputTokens: row.usage_output_tokens ?? undefined, totalTokens: row.usage_total_tokens, - state: row.usage_state === "final" ? "final" : "partial", + state: workflowTokenUsageStateSchema.parse(row.usage_state), updatedAt: row.usage_updated_at, }, responseText: row.response_text ?? undefined, @@ -1295,20 +1304,12 @@ function rowToAgentCall(row: WorkflowAgentCallRow): WorkflowAgentCallRecord { } function rowToAgentActivity(row: WorkflowAgentActivityRow): WorkflowAgentActivityRecord { - const kinds: WorkflowAgentActivityKind[] = ["tool", "command", "file", "status"]; - const statuses: WorkflowAgentActivityStatus[] = ["running", "completed", "failed"]; - if (!kinds.includes(row.kind as WorkflowAgentActivityKind)) { - throw new Error(`Unknown workflow agent activity kind: ${row.kind}`); - } - if (!statuses.includes(row.status as WorkflowAgentActivityStatus)) { - throw new Error(`Unknown workflow agent activity status: ${row.status}`); - } return { runId: row.run_id, callIndex: row.call_index, seq: row.seq, - kind: row.kind as WorkflowAgentActivityKind, - status: row.status as WorkflowAgentActivityStatus, + kind: workflowAgentActivityKindSchema.parse(row.kind), + status: workflowAgentActivityStatusSchema.parse(row.status), label: row.label, detail: row.detail ?? undefined, startedAt: row.started_at ?? undefined, From f2907f85f382f4df6cf8a7ef81f6391aa1c69ef7 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:16:09 +0530 Subject: [PATCH 121/132] fix(workflow): seed real replay fixture provenance --- scripts/workflow-tui-fixture.ts | 52 ++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/scripts/workflow-tui-fixture.ts b/scripts/workflow-tui-fixture.ts index 3e5d536e0..3bf8046f3 100644 --- a/scripts/workflow-tui-fixture.ts +++ b/scripts/workflow-tui-fixture.ts @@ -5,7 +5,7 @@ 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-v1"; +const FIXTURE_VERSION = "large-v2"; const WORKFLOW_NAME = "Ship multi-service authentication"; const fixtureNames = [ @@ -80,6 +80,9 @@ function seedFixture( .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, @@ -87,7 +90,7 @@ function seedFixture( scriptPath: join(stateDir, "fixtures", `${name}.js`), scriptHash, workspaceRoot: workspace, - resumedFromRunId: name === "replayed" ? "wfr_previous_fixture" : undefined, + resumedFromRunId: replayParent?.id, }); if (name === "starting") return { name, stateDir, run }; @@ -127,12 +130,12 @@ function seedFixture( }); } else if (name === "replayed") { startPhase(store, run.id, "Discovery"); - addCachedCall(store, run.id, 0, "Discovery", "Map authentication services", "codex"); - addCachedCall(store, run.id, 1, "Discovery", "Audit token storage", "claude"); - addCachedCall(store, run.id, 2, "Discovery", "Trace client login flows", "codex"); + 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, 3, "Architecture", "Design session boundaries", "claude"); - addCachedCall(store, run.id, 4, "Architecture", "Plan database migration", "codex"); + 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"); @@ -228,7 +231,9 @@ function startCall( label, phase, isolation: worktree ? "worktree" : "shared", - worktreePath: worktree ? `/tmp/devspace-fixture-worktree-${callIndex}` : undefined, + worktreePath: worktree + ? join(tmpdir(), `devspace-fixture-worktree-${callIndex}`) + : undefined, }); } @@ -270,6 +275,7 @@ function addCompletedPhase( function addCachedCall( store: WorkflowStore, runId: string, + replayedFromRunId: string, callIndex: number, phase: string, label: string, @@ -285,12 +291,37 @@ function addCachedCall( label, phase, replayMatch: "same_index", - replayedFromRunId: "wfr_previous_fixture", + 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"], @@ -324,5 +355,6 @@ function seedCompletedWorkflow(store: WorkflowStore, runId: string): void { } function fail(message: string): never { - throw new Error(message); + console.error(message); + process.exit(1); } From 05f00e49ffce10544647f43587afb3a2a482037f Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:16:13 +0530 Subject: [PATCH 122/132] test(mcp): assert removed workspace schema fields --- src/open-workspace-capabilities.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/open-workspace-capabilities.test.ts b/src/open-workspace-capabilities.test.ts index a2e9b2e3a..8cd696ad1 100644 --- a/src/open-workspace-capabilities.test.ts +++ b/src/open-workspace-capabilities.test.ts @@ -70,6 +70,13 @@ assert.deepEqual(parsed.activeWorkflows, [{ status: "running", calls: { running: 1, completed: 2, failed: 0 }, }]); -assert.equal("skillDiagnostics" in parsed, false); +assert.equal( + fields({ + ...baseEnv, + DEVSPACE_SUBAGENTS: "1", + DEVSPACE_WORKFLOWS: "1", + }).has("skillDiagnostics"), + false, +); console.log("open-workspace-capabilities.test.ts: ok"); From 45417a3a7e2182b02b9e58e8774391cd3599b0dc Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:16:13 +0530 Subject: [PATCH 123/132] fix(ui): tolerate partial workflow summaries --- src/ui/card-types.test.ts | 7 +++++++ src/ui/card-types.ts | 14 ++++++++++++-- src/ui/workflow-dashboard.ts | 32 ++++++++++++++++++++++---------- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/src/ui/card-types.test.ts b/src/ui/card-types.test.ts index 7738e445f..1c5f88af7 100644 --- a/src/ui/card-types.test.ts +++ b/src/ui/card-types.test.ts @@ -6,6 +6,7 @@ import { isShellTool, isToolName, } from "./card-types.js"; +import { workflowSummaryText } from "./workflow-dashboard.js"; for (const tool of [ "apply_patch", @@ -26,6 +27,12 @@ assert.equal( isExpandableCard({ tool: "apply_patch", payload: { patch: "diff --git a/a b/a" } }), true, ); + +assert.deepEqual(workflowSummaryText({}), { + name: "Unnamed workflow", + status: "unknown", + calls: "no calls yet", +}); assert.equal(isExpandableCard({ tool: "apply_patch" }), false); assert.equal( isExpandableCard({ diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index 160bc53db..a5a281ea9 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -1,5 +1,4 @@ import type { App } from "@modelcontextprotocol/ext-apps"; -import type { ActiveWorkflowSummary } from "../workflow-summary.js"; export type ToolName = | "open_workspace" @@ -19,6 +18,17 @@ export type HostContext = NonNullable>; export type PatchOperation = "add" | "update" | "delete" | "move"; +export interface CardActiveWorkflowSummary { + id?: string; + name?: string; + status?: string; + calls?: Partial<{ + running: number; + completed: number; + failed: number; + }>; +} + export interface ToolResultCard { tool: ToolName; workspaceId?: string; @@ -56,7 +66,7 @@ export interface ToolResultCard { description?: string; path?: string; }>; - activeWorkflows?: ActiveWorkflowSummary[]; + activeWorkflows?: CardActiveWorkflowSummary[]; agentProviders?: string[]; agents?: Array<{ name?: string; diff --git a/src/ui/workflow-dashboard.ts b/src/ui/workflow-dashboard.ts index 321e96ac2..827f0edda 100644 --- a/src/ui/workflow-dashboard.ts +++ b/src/ui/workflow-dashboard.ts @@ -1,5 +1,4 @@ -import type { ActiveWorkflowSummary } from "../workflow-summary.js"; -import type { ToolResultCard } from "./card-types.js"; +import type { CardActiveWorkflowSummary, ToolResultCard } from "./card-types.js"; import { renderIcon, toolIcons } from "./icons.js"; export interface DashboardDisplayOptions { @@ -116,7 +115,7 @@ function renderDashboardToolbar( } function renderWorkflowSummarySection( - runs: ActiveWorkflowSummary[], + runs: CardActiveWorkflowSummary[], ): HTMLElement { const section = node("section", { className: "active-workflows" }); section.append(node("h3", { text: `Active workflows · ${runs.length}` })); @@ -125,13 +124,14 @@ function renderWorkflowSummarySection( return section; } for (const run of runs) { + const summary = workflowSummaryText(run); const row = node("div", { className: "active-workflow-row" }); row.append( - node("span", { className: `workflow-status-dot ${run.status}`, ariaHidden: "true" }), + node("span", { className: `workflow-status-dot ${summary.status}`, ariaHidden: "true" }), node("div", { className: "active-workflow-copy" }, [ - node("strong", { text: run.name }), + node("strong", { text: summary.name }), node("span", { - text: `${run.status} · ${summaryCounts(run.calls)}`, + text: `${summary.status} · ${summary.calls}`, }), ]), ); @@ -183,15 +183,27 @@ function renderList( return list; } -function summaryCounts(calls: ActiveWorkflowSummary["calls"]): string { +function summaryCounts(calls: CardActiveWorkflowSummary["calls"]): string { const parts = [ - calls.completed ? `${calls.completed} done` : undefined, - calls.running ? `${calls.running} running` : undefined, - calls.failed ? `${calls.failed} failed` : undefined, + calls?.completed ? `${calls.completed} done` : undefined, + calls?.running ? `${calls.running} running` : undefined, + calls?.failed ? `${calls.failed} failed` : undefined, ].filter((part): part is string => Boolean(part)); return parts.join(" · ") || "no calls yet"; } +export function workflowSummaryText(run: CardActiveWorkflowSummary): { + name: string; + status: string; + calls: string; +} { + return { + name: run.name ?? "Unnamed workflow", + status: run.status ?? "unknown", + calls: summaryCounts(run.calls), + }; +} + function summarizeText(value: string | undefined): string | undefined { if (!value) return undefined; const compact = value.replace(/\s+/g, " ").trim(); From 8734d90dbd79a3ae62445e2956cef54e7ab7ecf4 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:23:46 +0530 Subject: [PATCH 124/132] fix(workflow): preserve launch result error types --- src/workflow-launch.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/workflow-launch.ts b/src/workflow-launch.ts index ec353843a..f88a4378a 100644 --- a/src/workflow-launch.ts +++ b/src/workflow-launch.ts @@ -110,13 +110,13 @@ export async function launchWorkflowRun( }); if (persisted.isErr()) { failStartedRun(input.store, run.id, persisted.error); - return persisted; + 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 updated; + return Result.err(updated.error); } if (input.spawn !== false) { @@ -191,7 +191,7 @@ async function resolveLaunchSource( filename = named.value.scriptPath; } else if (source.override?.kind === "file") { const file = await resolveExplicitWorkflowFile(input, source.override.path); - if (file.isErr()) return file; + if (file.isErr()) return Result.err(file.error); sourceText = file.value.source; scriptHash = file.value.scriptHash; nameHint = file.value.nameHint; @@ -257,7 +257,7 @@ async function resolveLaunchSource( if (source.kind === "file") { const file = await resolveExplicitWorkflowFile(input, source.path); - if (file.isErr()) return file; + if (file.isErr()) return Result.err(file.error); return Result.ok({ sourceText: file.value.source, scriptHash: file.value.scriptHash, From 9a1aebc645e3c95f7b47eac35c3b552811fe1a54 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:23:46 +0530 Subject: [PATCH 125/132] test(workflow): drop removed MCP helper coverage --- src/workflow-errors.test.ts | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/src/workflow-errors.test.ts b/src/workflow-errors.test.ts index 404f89883..bbc385904 100644 --- a/src/workflow-errors.test.ts +++ b/src/workflow-errors.test.ts @@ -28,7 +28,6 @@ import { import { WorkflowStore } from "./workflow-store.js"; import { enforceAgentSchemaResult } from "./workflow-schema.js"; import { createWorkflowWorktreeResult } from "./workflow-worktrees.js"; -import { requireWorkflowRunInWorkspace } from "./workflow-tools.js"; { const invalid = parseWorkflowArgFlagsResult(["--arg", "missing-equals"]); @@ -36,30 +35,6 @@ import { requireWorkflowRunInWorkspace } from "./workflow-tools.js"; if (invalid.isErr()) assert.ok(InvalidWorkflowInputError.is(invalid.error)); } -{ - const identifiedRun = { - id: "wfr_identified", - workspaceId: "ws_owner", - workspaceRoot: "/project", - }; - assert.doesNotThrow(() => - requireWorkflowRunInWorkspace(identifiedRun, "ws_owner", "/different-checkout"), - ); - assert.throws( - () => requireWorkflowRunInWorkspace(identifiedRun, "ws_other", "/project"), - /Unknown workflow run: wfr_identified/, - ); - - const legacyRun = { id: "wfr_legacy", workspaceRoot: "/project" }; - assert.doesNotThrow(() => - requireWorkflowRunInWorkspace(legacyRun, "ws_current", "/project/./"), - ); - assert.throws( - () => requireWorkflowRunInWorkspace(legacyRun, "ws_current", "/other-project"), - /Unknown workflow run: wfr_legacy/, - ); -} - { const missing = await readWorkflowScriptFileResult("/definitely/missing/workflow.js"); assert.ok(missing.isErr()); From 6b5d32af50ceed5a5d9f06178fb9fcd8a5d93366 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:23:46 +0530 Subject: [PATCH 126/132] test(ui): drop removed workflow dashboard coverage --- src/ui/card-types.test.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/ui/card-types.test.ts b/src/ui/card-types.test.ts index 1c5f88af7..6472c2884 100644 --- a/src/ui/card-types.test.ts +++ b/src/ui/card-types.test.ts @@ -6,7 +6,6 @@ import { isShellTool, isToolName, } from "./card-types.js"; -import { workflowSummaryText } from "./workflow-dashboard.js"; for (const tool of [ "apply_patch", @@ -28,11 +27,6 @@ assert.equal( true, ); -assert.deepEqual(workflowSummaryText({}), { - name: "Unnamed workflow", - status: "unknown", - calls: "no calls yet", -}); assert.equal(isExpandableCard({ tool: "apply_patch" }), false); assert.equal( isExpandableCard({ From ccbbee0d3939308bc759d48513f9cae339b3620c Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:46:18 +0530 Subject: [PATCH 127/132] feat(agents): emit compact XML fragments by default --- src/cli.test.ts | 35 +++++++++++++++- src/cli.ts | 71 ++++++++++++++++++++++----------- src/local-agent-presentation.ts | 57 +++++++++++++++++++------- 3 files changed, 124 insertions(+), 39 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index 0983417c3..2caa5ae2a 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -150,7 +150,10 @@ try { }, }); - assert.equal(output.trim(), `${current.id} completed reviewer`); + assert.equal( + output.trim(), + ``, + ); const { stdout: jsonOutput } = await execFileAsync( "node", @@ -217,6 +220,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 +271,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 b521556a3..d18b488cc 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, @@ -447,19 +448,19 @@ 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 "targets": - await runAgentsTargets(commandArgs, json); + await runAgentWorkflowCommand(json, () => runAgentsTargets(commandArgs, json)); return; case "daemon": await runAgentsDaemon(commandArgs, json); @@ -471,7 +472,7 @@ async function runAgentsCommand(args: string[]): Promise { printAgentsHelp(); return; default: - throw new Error(`Unknown agents command: ${subcommand}`); + writeAgentWorkflowError(`Unknown agents command: ${subcommand}`, json); } } @@ -487,7 +488,7 @@ async function runAgentsTargets(args: string[], json: boolean): Promise { 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 +496,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 +505,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 +521,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 +540,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 +558,20 @@ 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); + let 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); + const refreshed = presentAgentWorkflowResult(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 runAgentsDaemon(args: string[], json: boolean): Promise { @@ -643,6 +637,37 @@ function presentAgentResult( throw new Error(result.error.message); } +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 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)); } diff --git a/src/local-agent-presentation.ts b/src/local-agent-presentation.ts index 21916afa5..9def4d39f 100644 --- a/src/local-agent-presentation.ts +++ b/src/local-agent-presentation.ts @@ -38,6 +38,13 @@ 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: "completed"; response?: string } @@ -98,37 +105,59 @@ 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 === "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 { From f6832836afaac03c4e0187cf83ee00ba59da8a10 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:51:30 +0530 Subject: [PATCH 128/132] feat(agents): persist internal turn history --- src/db/migrations.ts | 29 +++++ src/local-agent-manager.test.ts | 18 ++- src/local-agent-manager.ts | 47 ++++--- src/local-agent-store.test.ts | 68 +++++++++- src/local-agent-store.ts | 217 ++++++++++++++++++++++++++++++-- src/oauth-store.test.ts | 1 + 6 files changed, 343 insertions(+), 37 deletions(-) diff --git a/src/db/migrations.ts b/src/db/migrations.ts index df192caa0..cf19f8efa 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -37,6 +37,11 @@ const migrations: Migration[] = [ name: "local-agent-effort-rename", up: migrateLocalAgentEffortRename, }, + { + version: 7, + name: "local-agent-turns", + up: migrateLocalAgentTurns, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -235,6 +240,30 @@ 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 addColumnIfMissing( sqlite: Database.Database, table: "workspace_sessions" | "local_agent_sessions", diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index 4ca5ed28d..803866fb9 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -121,7 +121,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 +223,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 +247,10 @@ 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.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 +260,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", @@ -274,6 +288,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"); diff --git a/src/local-agent-manager.ts b/src/local-agent-manager.ts index dbd80d86b..ef8a3c720 100644 --- a/src/local-agent-manager.ts +++ b/src/local-agent-manager.ts @@ -246,28 +246,25 @@ 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 turn = Promise.resolve().then(() => ( - this.runTurn(updated.value, prompt, overrides, workspaceId) + this.runTurn(begun.value.agent, begun.value.turn.id, prompt, overrides, workspaceId) )); this.activeTurns.set(record.id, turn); 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, @@ -281,7 +278,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 +287,22 @@ 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); 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 = { @@ -329,20 +326,17 @@ export class LocalAgentManager { }; const result = await this.pool.run(driver.value, context, input.value, callbacks); 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", { @@ -353,11 +347,11 @@ export class LocalAgentManager { }); } catch (error) { 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 +373,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, diff --git a/src/local-agent-store.test.ts b/src/local-agent-store.test.ts index 829940f92..7f4d9440f 100644 --- a/src/local-agent-store.test.ts +++ b/src/local-agent-store.test.ts @@ -54,7 +54,66 @@ try { 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({ 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); + + 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 +128,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 +197,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 74bf875d5..f3fa93c3f 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -5,6 +5,7 @@ import { openDatabase, type DatabaseHandle } from "./db/client.js"; import { AgentStoreError, isProgrammerDefect } from "./local-agent-errors.js"; export type LocalAgentStatus = "starting" | "running" | "idle" | "error" | "stopped"; +export type LocalAgentTurnStatus = "running" | "completed" | "failed" | "stopped"; export interface LocalAgentRecord { id: string; @@ -33,6 +34,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; @@ -61,6 +91,19 @@ interface LocalAgentRow { 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; @@ -235,16 +278,150 @@ 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, + }); + 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; + } + + 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; + } + + 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( @@ -283,6 +460,28 @@ function rowToLocalAgentRecord(row: LocalAgentRow): LocalAgentRecord { }; } +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 225f9fdf5..f535234a3 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -47,6 +47,7 @@ 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" }, ]); } finally { database.close(); From d855fa876bd4fbcd2a070b5a0ce8a590bdebe338 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:00:00 +0530 Subject: [PATCH 129/132] feat(agents): add event-driven multi-agent wait --- src/cli.test.ts | 81 ++++++++++- src/cli.ts | 66 +++++++-- src/local-agent-client.ts | 46 +++++-- src/local-agent-daemon-lifecycle.ts | 2 +- src/local-agent-daemon-protocol.test.ts | 48 ++++++- src/local-agent-daemon-protocol.ts | 91 +++++++++++++ src/local-agent-daemon.test.ts | 63 ++++++++- src/local-agent-daemon.ts | 29 +++- src/local-agent-manager.test.ts | 70 ++++++++++ src/local-agent-manager.ts | 171 +++++++++++++++++++++++- src/local-agent-presentation.ts | 5 +- src/local-agent-store.ts | 12 ++ 12 files changed, 633 insertions(+), 51 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index 2caa5ae2a..1d9824159 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,10 +116,17 @@ 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, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, pid: process.pid, endpoint: daemonSocket, startedAt: "now", @@ -127,7 +137,7 @@ try { : null; socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ok: true, result, })); @@ -192,6 +202,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( diff --git a/src/cli.ts b/src/cli.ts index d18b488cc..27e7976c2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -459,6 +459,9 @@ async function runAgentsCommand(args: string[]): Promise { case "show": await runAgentWorkflowCommand(json, () => runAgentsShow(commandArgs, json)); return; + case "wait": + await runAgentWorkflowCommand(json, () => runAgentsWait(commandArgs, json)); + return; case "targets": await runAgentWorkflowCommand(json, () => runAgentsTargets(commandArgs, json)); return; @@ -558,22 +561,62 @@ 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 = presentAgentWorkflowResult(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 = presentAgentWorkflowResult(await client.get(id, scope), json); - if (!refreshed) return; - record = refreshed; - } - const observation = presentAgentObservation(record); if (json) printJson(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 runAgentsDaemon(args: string[], json: boolean): Promise { const [subcommand, ...extra] = args; if (extra.length > 0) throw new Error("Usage: devspace agents daemon [--json]"); @@ -672,10 +715,6 @@ function printJson(value: unknown): void { console.log(JSON.stringify(value)); } -function sleep(ms: number): Promise { - return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); -} - function printAgentsHelp(): void { console.log( [ @@ -686,6 +725,7 @@ function printAgentsHelp(): void { " devspace agents run [--model ] [--effort ] [--json] ", " devspace agents continue [--model ] [--effort ] [--json] ", " devspace agents show [--json]", + " devspace agents wait ... [--timeout ] [--json]", " devspace agents targets [--json]", " devspace agents daemon [--json]", ].join("\n"), diff --git a/src/local-agent-client.ts b/src/local-agent-client.ts index 01f8c1cdc..5d31464b6 100644 --- a/src/local-agent-client.ts +++ b/src/local-agent-client.ts @@ -22,6 +22,7 @@ import { import { decodeAgentRecord, decodeAgentRecordList, + decodeAgentWaitResults, decodeDaemonLogs, decodeDaemonStatus, decodeLocalAgentDaemonResponse, @@ -45,6 +46,8 @@ import type { AgentListError, AgentLookupError, AgentStartError, + AgentWaitError, + LocalAgentWaitResult, RunOverrides, StartLocalAgentInput, } from "./local-agent-manager.js"; @@ -60,6 +63,7 @@ type RequestError = : M extends "agent.continue" ? AgentContinueError | AgentDaemonError : M extends "agent.get" ? AgentLookupError | AgentDaemonError : M extends "agent.list" ? AgentListError | AgentDaemonError + : M extends "agent.wait" ? AgentWaitError | AgentDaemonError : AgentDaemonError; export interface LocalAgentClientOptions { @@ -134,6 +138,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); @@ -308,6 +328,7 @@ export class LocalAgentClient { 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 +340,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); @@ -459,20 +480,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 +503,7 @@ async function sendRequest( ) => { if (settled) return; settled = true; - clearTimeout(timer); + if (timer) clearTimeout(timer); if (destroy) socket.destroy(); resolve(result); }; @@ -599,6 +622,7 @@ function isRequestError( || category === "conflict" || category === "store"; case "agent.get": + case "agent.wait": return category === "target" || category === "scope" || category === "store"; case "agent.list": return category === "scope" || category === "store"; diff --git a/src/local-agent-daemon-lifecycle.ts b/src/local-agent-daemon-lifecycle.ts index df0b81b95..250bb2319 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 = 4; 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-protocol.test.ts b/src/local-agent-daemon-protocol.test.ts index 708180987..b5fb282c1 100644 --- a/src/local-agent-daemon-protocol.test.ts +++ b/src/local-agent-daemon-protocol.test.ts @@ -1,15 +1,17 @@ import assert from "node:assert/strict"; import { decodeAgentRecord, + decodeAgentWaitResults, 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 +28,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 +43,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: { @@ -56,7 +58,7 @@ assert.equal(directRequest.params.workspaceId, undefined); assert.throws( () => decodeLocalAgentDaemonRequest({ requestId: "req_2", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, authToken: "test-secret", method: "agent.start", params: { target: "reviewer", prompt: "" }, @@ -83,7 +85,7 @@ 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 +93,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 +128,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 bf9bf8e65..402b94bc7 100644 --- a/src/local-agent-daemon-protocol.ts +++ b/src/local-agent-daemon-protocol.ts @@ -4,6 +4,7 @@ import type { LocalAgentWorkspaceScope, } from "./local-agent-store.js"; import type { + LocalAgentWaitResult, RunOverrides, StartLocalAgentInput, } from "./local-agent-manager.js"; @@ -16,6 +17,7 @@ export type LocalAgentDaemonMethod = | "agent.continue" | "agent.get" | "agent.list" + | "agent.wait" | "daemon.status" | "daemon.stop" | "daemon.logs"; @@ -26,6 +28,11 @@ export type LocalAgentDaemonRequest = | AgentDaemonRequestBase<"agent.continue", { id: string; prompt: string; scope: LocalAgentWorkspaceScope; overrides?: RunOverrides }> | 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.logs", { lines?: number }>; @@ -133,6 +140,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, @@ -202,6 +217,40 @@ export function decodeAgentRecordList(value: unknown): LocalAgentRecord[] { 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"); @@ -282,6 +331,48 @@ function decodeListScope(value: unknown): LocalAgentWorkspaceScope { return decodeWorkspaceScope(value); } +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); diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index 6ea66e652..4d196ef3b 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -40,6 +40,9 @@ class FakeManager implements LocalAgentDaemonManager { runtimeCount = 0; closed = false; lastInput?: StartLocalAgentInput; + blockWaitUntilAbort = false; + waitStarted = false; + waitAborted = false; async start(input: StartLocalAgentInput) { this.lastInput = input; @@ -63,6 +66,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 { @@ -135,6 +153,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()); @@ -247,7 +268,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, }, })); @@ -301,10 +322,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(); @@ -397,11 +425,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", @@ -443,7 +471,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: {}, })); @@ -487,6 +515,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,7 +546,7 @@ 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: {}, diff --git a/src/local-agent-daemon.ts b/src/local-agent-daemon.ts index dfd3a1499..75a3b6dd4 100644 --- a/src/local-agent-daemon.ts +++ b/src/local-agent-daemon.ts @@ -37,6 +37,8 @@ import type { AgentListError, AgentLookupError, AgentStartError, + AgentWaitError, + LocalAgentWaitResult, RunOverrides, StartLocalAgentInput, } from "./local-agent-manager.js"; @@ -53,6 +55,12 @@ export interface LocalAgentDaemonManager { continue(agentId: string, prompt: string, overrides: RunOverrides | undefined, 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; @@ -210,6 +218,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 +252,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 +273,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 +286,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", @@ -307,6 +319,13 @@ export class LocalAgentDaemon { 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": diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index 803866fb9..758f69ae5 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -303,6 +303,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 ef8a3c720..30fe1e1f4 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 { @@ -71,6 +72,18 @@ export type AgentStartError = AgentTargetError | AgentScopeError | AgentConflict export type AgentContinueError = AgentStartError; export type AgentLookupError = AgentTargetError | AgentScopeError | AgentStoreError; 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; +} /** * Owns one durable DevSpace agent's turn lifecycle. Provider runtimes remain @@ -86,7 +99,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 +212,57 @@ export class LocalAgentManager { )); } + 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. @@ -257,7 +317,7 @@ export class LocalAgentManager { const turn = Promise.resolve().then(() => ( this.runTurn(begun.value.agent, begun.value.turn.id, prompt, overrides, workspaceId) )); - this.activeTurns.set(record.id, turn); + this.activeTurns.set(record.id, { turnId: begun.value.turn.id, completion: turn }); void turn.catch(() => undefined); return Result.ok(begun.value.agent); } @@ -604,3 +664,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-presentation.ts b/src/local-agent-presentation.ts index 9def4d39f..f5ff569ec 100644 --- a/src/local-agent-presentation.ts +++ b/src/local-agent-presentation.ts @@ -46,7 +46,7 @@ export interface AgentCommandErrorOutput { } 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 }; @@ -123,6 +123,9 @@ export function formatAgentSummary(summary: AgentSummaryOutput): string { } export function formatAgentObservation(observation: AgentObservationOutput): string { + if (observation.status === "running" && observation.wait) { + return ``; + } if (observation.status === "completed" && observation.response !== undefined) { return `${escapeXmlText(observation.response)}`; } diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index f3fa93c3f..e2cf29e9c 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -388,6 +388,12 @@ export class LocalAgentStore { 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") @@ -395,6 +401,12 @@ export class LocalAgentStore { 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") From 9bee9052c1287312fe3d4161eec80d0372608e0c Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:06:58 +0530 Subject: [PATCH 130/132] feat(agents): configure provider launch environment --- schema/v1/devspace.schema.json | 15 +++++++ src/cli.ts | 9 +++-- src/local-agent-adapters.ts | 20 +++++++--- src/local-agent-availability.test.ts | 36 +++++++++++++++++ src/local-agent-availability.ts | 26 +++++++++---- src/local-agent-claude.test.ts | 38 ++++++++++++++++++ src/local-agent-config.test.ts | 58 +++++++++++++++++++++++++++- src/local-agent-config.ts | 45 ++++++++++++++++++++- src/local-agent-daemon-main.ts | 2 +- src/onboarding.test.ts | 18 ++++++++- src/server.ts | 4 +- 11 files changed, 248 insertions(+), 23 deletions(-) diff --git a/schema/v1/devspace.schema.json b/schema/v1/devspace.schema.json index e7c18466e..944d570a0 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/src/cli.ts b/src/cli.ts index 27e7976c2..85e3ae3d5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -211,7 +211,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); @@ -361,7 +364,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)}`); @@ -486,7 +489,7 @@ 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); diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 03a5cc40c..d3b7b815b 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 7e0ebc1ce..b9a380b7a 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 3a67b98f7..05a932a8c 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-claude.test.ts b/src/local-agent-claude.test.ts index e8b3d506b..14e364005 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-config.test.ts b/src/local-agent-config.test.ts index e37ceafac..a27812a11 100644 --- a/src/local-agent-config.test.ts +++ b/src/local-agent-config.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { isSubagentProviderEnabled, + localAgentProviderEnvironment, subagentProviderConfig, subagentsConfigSchema, } from "./local-agent-config.js"; @@ -8,14 +9,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 +39,22 @@ 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.throws( () => subagentsConfigSchema.parse({ enabled: true, @@ -45,3 +76,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 62e9c35a0..ae3f42010 100644 --- a/src/local-agent-config.ts +++ b/src/local-agent-config.ts @@ -4,12 +4,30 @@ import { 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 +68,28 @@ 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; + } +} diff --git a/src/local-agent-daemon-main.ts b/src/local-agent-daemon-main.ts index b1e0e09da..0121441df 100644 --- a/src/local-agent-daemon-main.ts +++ b/src/local-agent-daemon-main.ts @@ -22,7 +22,7 @@ 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, diff --git a/src/onboarding.test.ts b/src/onboarding.test.ts index b4236e9c0..6c57335e5 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/server.ts b/src/server.ts index 9e7ded7fd..21a2123fb 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 = ( From 46a8a386340e5e0c4eb562073c3744acc681e83a Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:34:57 +0530 Subject: [PATCH 131/132] fix(agents): refresh daemon provider configuration --- src/cli.test.ts | 19 +-- src/local-agent-client.ts | 77 +++++++++-- src/local-agent-config.test.ts | 28 ++++ src/local-agent-config.ts | 23 ++++ src/local-agent-daemon-lifecycle.ts | 2 +- src/local-agent-daemon-main.ts | 2 + src/local-agent-daemon-protocol.test.ts | 48 +++++++ src/local-agent-daemon-protocol.ts | 48 ++++++- src/local-agent-daemon.test.ts | 173 ++++++++++++++++++++++-- src/local-agent-daemon.ts | 42 +++++- src/local-agent-errors.ts | 14 ++ 11 files changed, 438 insertions(+), 38 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index 1d9824159..216b2a900 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -125,14 +125,17 @@ try { ] : request.method === "hello" ? { - state: "ready", - protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, - 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({ diff --git a/src/local-agent-client.ts b/src/local-agent-client.ts index 5d31464b6..4e6367d9c 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, @@ -23,6 +24,7 @@ import { decodeAgentRecord, decodeAgentRecordList, decodeAgentWaitResults, + decodeDaemonHello, decodeDaemonLogs, decodeDaemonStatus, decodeLocalAgentDaemonResponse, @@ -33,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, @@ -68,6 +71,7 @@ type RequestError = export interface LocalAgentClientOptions { stateDir: string; + configRevision: string; configDir?: string; startupTimeoutMs?: number; requestTimeoutMs?: number; @@ -78,6 +82,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; @@ -86,6 +91,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; @@ -226,6 +232,7 @@ export class LocalAgentClient { authToken: authToken.value, method: "hello", params: {}, + configRevision: this.configRevision, }, this.requestTimeoutMs); if (response.isErr()) { if ( @@ -253,8 +260,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( @@ -268,6 +295,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); @@ -282,14 +310,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) { @@ -300,28 +341,33 @@ 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.", })); } @@ -430,9 +476,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( @@ -607,6 +657,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, diff --git a/src/local-agent-config.test.ts b/src/local-agent-config.test.ts index a27812a11..237f92b71 100644 --- a/src/local-agent-config.test.ts +++ b/src/local-agent-config.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { isSubagentProviderEnabled, + localAgentProviderConfigRevision, localAgentProviderEnvironment, subagentProviderConfig, subagentsConfigSchema, @@ -55,6 +56,33 @@ assert.deepEqual(inherited, { 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, diff --git a/src/local-agent-config.ts b/src/local-agent-config.ts index ae3f42010..572b2dfdf 100644 --- a/src/local-agent-config.ts +++ b/src/local-agent-config.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import * as z from "zod/v4"; import { LOCAL_AGENT_PROVIDERS, @@ -93,3 +94,25 @@ export function providerCommandVariable(provider: LocalAgentProvider): string | 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 250bb2319..5ac32b93b 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 = 4; +export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 5; 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 0121441df..51a9fc477 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; @@ -33,6 +34,7 @@ const manager = new LocalAgentManager({ 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 b5fb282c1..c7771aebe 100644 --- a/src/local-agent-daemon-protocol.test.ts +++ b/src/local-agent-daemon-protocol.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { decodeAgentRecord, decodeAgentWaitResults, + decodeDaemonHello, decodeLocalAgentDaemonRequest, decodeLocalAgentDaemonResponse, encodeLocalAgentDaemonResponse, @@ -55,6 +56,53 @@ const directRequest = decodeLocalAgentDaemonRequest({ if (directRequest.method !== "agent.start") throw new Error("expected agent.start request"); assert.equal(directRequest.params.workspaceId, undefined); +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", diff --git a/src/local-agent-daemon-protocol.ts b/src/local-agent-daemon-protocol.ts index 402b94bc7..a838c7fbf 100644 --- a/src/local-agent-daemon-protocol.ts +++ b/src/local-agent-daemon-protocol.ts @@ -23,7 +23,7 @@ export type LocalAgentDaemonMethod = | "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.get", { id: string; scope: LocalAgentWorkspaceScope }> @@ -34,7 +34,7 @@ export type LocalAgentDaemonRequest = timeoutMs?: number; }> | AgentDaemonRequestBase<"daemon.status", Record> - | AgentDaemonRequestBase<"daemon.stop", Record> + | AgentDaemonRequestBase<"daemon.stop", { ifIdle?: boolean }> | AgentDaemonRequestBase<"daemon.logs", { lines?: number }>; interface AgentDaemonRequestBase< @@ -59,6 +59,11 @@ export interface LocalAgentDaemonStatus { clientConnections: number; } +export interface LocalAgentDaemonHello { + status: LocalAgentDaemonStatus; + configMatches: boolean; +} + export interface LocalAgentDaemonErrorPayload { code: string; message: string; @@ -102,9 +107,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, @@ -269,6 +289,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; @@ -331,6 +359,13 @@ 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; @@ -414,6 +449,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 4d196ef3b..466cf066e 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", @@ -41,14 +42,27 @@ class FakeManager implements LocalAgentDaemonManager { 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, @@ -92,11 +106,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(); }, @@ -106,6 +122,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; }, @@ -169,12 +186,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(); }, @@ -193,11 +212,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, }); @@ -223,6 +244,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); @@ -233,6 +255,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"); }, @@ -241,6 +264,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); @@ -306,6 +448,7 @@ const replacementManager = new FakeManager(); replacementManager.activeTurnCount = 0; const replacementDaemon = new LocalAgentDaemon({ stateDir: upgradeStateDir, + configRevision: CONFIG_REVISION, manager: replacementManager, idleShutdownMs: 60_000, }); @@ -313,6 +456,7 @@ let replacementSpawns = 0; let spawnedBeforeLegacyLockReleased = false; const upgradeClient = new LocalAgentClient({ stateDir: upgradeStateDir, + configRevision: CONFIG_REVISION, startupTimeoutMs: 2_000, requestTimeoutMs: 500, spawnDaemon: () => { @@ -368,20 +512,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; @@ -395,6 +543,7 @@ await new Promise((resolveListen, rejectListen) => { }); const replacementRaceClient = new LocalAgentClient({ stateDir: replacementRaceStateDir, + configRevision: CONFIG_REVISION, startupTimeoutMs: 500, requestTimeoutMs: 100, spawnDaemon: () => { @@ -447,6 +596,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"); }, @@ -484,6 +634,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"); }, @@ -507,6 +658,7 @@ const socketManager = new FakeManager(); socketManager.activeTurnCount = 0; const socketDaemon = new LocalAgentDaemon({ stateDir: socketStateDir, + configRevision: CONFIG_REVISION, manager: socketManager, requestReadTimeoutMs: 30, shutdownTimeoutMs: 100, @@ -550,6 +702,7 @@ try { 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 75a3b6dd4..aac96e9d9 100644 --- a/src/local-agent-daemon.ts +++ b/src/local-agent-daemon.ts @@ -70,6 +70,7 @@ export interface LocalAgentDaemonManager { export interface LocalAgentDaemonOptions { stateDir: string; manager: LocalAgentDaemonManager; + configRevision: string; idleShutdownMs?: number; idleCheckIntervalMs?: number; requestReadTimeoutMs?: number; @@ -83,6 +84,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; @@ -99,12 +101,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; @@ -294,6 +298,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", @@ -305,11 +315,14 @@ 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, @@ -329,6 +342,18 @@ export class LocalAgentDaemon { 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(); @@ -337,6 +362,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 0df50b867..32c33cd77 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, From e4d64c432a1418c1ef37f75dde81d50502fa85cc Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:39:34 +0530 Subject: [PATCH 132/132] docs(agents): teach XML and multi-agent wait --- docs/agent-profile-schema.md | 36 ++++++++++++++++++---- docs/chatgpt-coding-workflow.md | 3 +- docs/configuration.md | 29 +++++++++++++++--- docs/gotchas.md | 2 +- docs/local-agent-daemon.md | 29 +++++++++++------- docs/setup.md | 2 +- skills/subagents/SKILL.md | 53 +++++++++++++++++++-------------- src/cli.ts | 2 ++ 8 files changed, 111 insertions(+), 45 deletions(-) diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index 5ede01ad1..94186ce18 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 f6826617c..9a954a8c9 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 6a6f607bd..f48c3b1d7 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/gotchas.md b/docs/gotchas.md index 5f6288678..3963f171c 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 diff --git a/docs/local-agent-daemon.md b/docs/local-agent-daemon.md index e1313b2db..f940cfa21 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 e5e76d8f9..791c6d7dc 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -65,7 +65,7 @@ npx skills add Waishnav/devspace --skill subagents --global ``` The Skills CLI asks which installed Coding Agents should receive the skill. -The skill uses `devspace agents targets`, `run`, `continue`, `show`, and `ls`. +The skill uses `devspace agents targets`, `run`, `continue`, `show`, `wait`, and `ls`. These commands do not require `devspace serve`. ### Connect ChatGPT diff --git a/skills/subagents/SKILL.md b/skills/subagents/SKILL.md index 2e6180b5c..83fcf68ce 100644 --- a/skills/subagents/SKILL.md +++ b/skills/subagents/SKILL.md @@ -5,49 +5,58 @@ 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 "" --json -devspace agents run --model --effort "" --json +devspace agents run "" +devspace agents run --model --effort "" ``` -The result contains a DevSpace agent `id` and its current status. Execution continues independently, so retain the ID for later inspection or follow-up. +The command returns an `` receipt. Keep the DevSpace agent ID for inspection, waiting, or follow-up. + +## Wait or inspect -## Inspect and continue +Use `wait` when work must finish before you proceed. One call can wait for several agents: ```bash -devspace agents show --json -devspace agents continue "" --json -devspace agents ls --json +devspace agents wait +devspace agents wait +devspace agents wait --timeout 60 ``` -- `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. +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 show +devspace agents ls +``` -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. +`ls` lists agents for the current project. Empty `targets` and `ls` results print nothing. + +## Continue related work + +Continue an agent when its existing provider context helps. Start another agent for unrelated work. + +```bash +devspace agents continue "" +devspace agents wait +``` ## Good uses diff --git a/src/cli.ts b/src/cli.ts index 85e3ae3d5..ca32c60b6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -414,10 +414,12 @@ 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 show ", + " devspace agents wait ... [--timeout ] [--json]", " devspace agents daemon ", " devspace -v, --version Print the installed version", "",