diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index 0dc3db95..5933bae5 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/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 66062621..ad591163 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 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/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,10 +114,15 @@ 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 -`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, +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 @@ -148,12 +154,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/claude-code-dynamic-workflows.html b/docs/claude-code-dynamic-workflows.html new file mode 100644 index 00000000..d7fa34ac --- /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. +

+
+ +
+
+ + diff --git a/docs/configuration.md b/docs/configuration.md index 4c02eb1a..a7b20b8d 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. | @@ -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 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. | @@ -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 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` +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,12 +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` -skill teaches the model to use only the minimal `devspace agents ls`, -`devspace agents run`, and `devspace agents show` workflow. +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. 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 new file mode 100644 index 00000000..b9ddb350 --- /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 00000000..40fe3f97 --- /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 00000000..180e2fb6 --- /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 00000000..7604ac57 --- /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 00000000..bf67020a --- /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 00000000..b347469d --- /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 00000000..4a2e41c6 --- /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 00000000..268dfb93 --- /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 00000000..6a4b6b00 --- /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 00000000..39c75d34 --- /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 00000000..42dc59ec --- /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 00000000..c30fa49b --- /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 00000000..d0734b99 --- /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 00000000..dfd64fbc --- /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 00000000..c6735f5d --- /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 00000000..b43a40ee --- /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) diff --git a/docs/dynamic-workflow/devspace/plan.md b/docs/dynamic-workflow/devspace/plan.md new file mode 100644 index 00000000..4a73627f --- /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. | +| 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 new file mode 100644 index 00000000..0f23ea6a --- /dev/null +++ b/docs/dynamic-workflow/devspace/primitives-spec.md @@ -0,0 +1,794 @@ +# 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 | Index+key + consume-once cacheKey fallback | +| 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 | (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 (`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` | 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). diff --git a/docs/gotchas.md b/docs/gotchas.md index 779e37ef..18ec1912 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -201,17 +201,22 @@ 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 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 -`subagent-delegation` 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 +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. diff --git a/examples/agents/claude-implementer.md b/examples/agents/claude-implementer.md index b907659f..6f967b5f 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 93a92db6..3645f209 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 f8570619..ae24b1a2 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 250a4d84..884be72d 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 4ed2b9de..e5a4a8a6 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/package-lock.json b/package-lock.json index 0d7decc4..11cd43d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,10 +19,13 @@ "@openai/codex-sdk": "^0.142.5", "@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", "express": "^5.2.1", + "json-schema-to-ts": "^3.1.1", "lucide": "^1.24.0", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -216,7 +219,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" } @@ -752,7 +754,7 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "dist/cli.js" + "pi-ai": "./dist/cli.js" }, "engines": { "node": ">=22.19.0" @@ -1057,7 +1059,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" @@ -3445,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", @@ -4512,7 +4520,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" @@ -5840,8 +5847,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 25f21059..f81a50ba 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/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": [], @@ -44,10 +44,13 @@ "@openai/codex-sdk": "^0.142.5", "@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", "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/skills/dynamic-workflows/SKILL.md b/skills/dynamic-workflows/SKILL.md new file mode 100644 index 00000000..de4ab181 --- /dev/null +++ b/skills/dynamic-workflows/SKILL.md @@ -0,0 +1,163 @@ +--- +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 --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] +``` + +Project named scripts live under `.devspace/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`, `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 | + +**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 +// 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. + +### 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`. +- **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 } +``` + +**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/skills/subagent-delegation/SKILL.md b/skills/subagent-delegation/SKILL.md deleted file mode 100644 index fb269df5..00000000 --- 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 thinking level. To override the -configured/default provider model or thinking level for a run, pass `--model` -or `--thinking`: - -```bash -devspace agents run --model "" -devspace agents run --thinking "" -``` - -Use `--thinking` 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 -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 00000000..caa4faff --- /dev/null +++ b/skills/subagents/SKILL.md @@ -0,0 +1,58 @@ +--- +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 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 + +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 targets [--json] +devspace agents run "" +devspace agents show +devspace agents run "" +devspace agents ls +``` + +`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 + +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/skills/subagents/references/claude.md b/skills/subagents/references/claude.md new file mode 100644 index 00000000..d1d50141 --- /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 00000000..ecc97d4c --- /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 00000000..a23a9c55 --- /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 00000000..09a7a167 --- /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 00000000..ef0ab01d --- /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 00000000..6953eaf5 --- /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/cli.test.ts b/src/cli.test.ts index 97b7084a..38ab51f7 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,10 +80,34 @@ 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)); + 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 7a1ac63f..81bda28b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,24 +12,27 @@ 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, - 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, loadDevspaceFiles, resolveSubagentsFlag, @@ -40,7 +43,13 @@ 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"; +import { + isWorkflowOperationError, + workflowCliExitCode, +} from "./workflow-errors.js"; + +type Command = "serve" | "init" | "doctor" | "config" | "agents" | "workflow" | "help" | "version"; const require = createRequire(import.meta.url); const SUPPORTED_NODE_RANGE = ">=20.12 <27"; @@ -65,8 +74,16 @@ 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": + await runWorkflowCommand(args, loadConfig()); + return; case "help": printHelp(); return; @@ -78,7 +95,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}`); @@ -169,12 +194,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`] : []), ]; @@ -264,6 +286,14 @@ 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"}`); + console.log(`Workflows: ${config.workflows ? "enabled" : "disabled"}`); + if (config.subagents) { + const snapshot = getLocalAgentProviderAvailabilitySnapshot(); + console.log( + `Agent providers (live): ${formatLocalAgentProviderAvailabilitySummary(snapshot)}`, + ); + } } catch (error) { console.log(`Config status: ${error instanceof Error ? error.message : String(error)}`); } @@ -312,6 +342,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:", @@ -327,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; @@ -362,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); @@ -379,7 +433,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,19 +442,27 @@ 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); - 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({ @@ -409,7 +471,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); @@ -459,11 +521,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", @@ -478,41 +552,6 @@ async function runAgentsWorker(args: string[]): Promise { } } -async function runLocalAgentProfile( - profile: LocalAgentProfile, - record: LocalAgentRecord, - prompt: string, -): Promise { - const body = profile.body.trim(); - const fullPrompt = body ? `${body}\n\nTask:\n${prompt}` : prompt; - return runLocalAgentProvider(profile.provider, { - prompt: fullPrompt, - workspace: record.workspaceRoot, - providerSessionId: record.providerSessionId, - writeMode: "allowed", - model: record.model ?? profile.model, - thinking: record.thinking ?? profile.thinking, - }); -} - -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, - thinking: record.thinking, - }); -} - function spawnAgentWorker(agentId: string, promptFile: string): void { const child = spawn(process.execPath, [ ...process.execArgv, @@ -550,11 +589,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 +607,8 @@ function printAgentsHelp(): void { "", "Usage:", " devspace agents ls", - " devspace agents run [--model ] [--thinking ] ", + " devspace agents targets [--json]", + " devspace agents run [--model ] [--effort ] ", " devspace agents show ", ].join("\n"), ); @@ -693,5 +733,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/config.test.ts b/src/config.test.ts index 0b4f99a8..ad916270 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 = { @@ -26,24 +26,30 @@ 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); 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.equal(existsSync(seededSkillPaths[0]), true); -assert.match(readFileSync(seededSkillPaths[0], "utf8"), /name: subagent-delegation/); -assert.deepEqual(ensureDevspaceDefaultSkills({ DEVSPACE_CONFIG_DIR: seededConfigDir }), []); - assert.throws( () => loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "invalid" }), /Invalid DEVSPACE_WIDGETS: invalid/, diff --git a/src/config.ts b/src/config.ts index 4fc1bcbb..974e7b88 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/db/migrations.ts b/src/db/migrations.ts index 2bda20c2..43389ee5 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -22,6 +22,31 @@ const migrations: Migration[] = [ name: "local-agent-sessions", up: migrateLocalAgentSessions, }, + { + version: 4, + name: "local-agent-effort-rename", + up: migrateLocalAgentEffortRename, + }, + { + version: 5, + name: "workflow-journal", + up: migrateWorkflowJournal, + }, + { + version: 6, + name: "workflow-replay-provenance", + up: migrateWorkflowReplayProvenance, + }, + { + version: 7, + name: "workflow-exact-replay", + up: migrateWorkflowExactReplay, + }, + { + version: 8, + name: "workflow-agent-profiles", + up: migrateWorkflowAgentProfiles, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -174,9 +199,134 @@ 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 migrateWorkflowJournal(sqlite: Database.Database): void { + sqlite.exec(` + create table if not exists workflow_runs ( + id text primary key, + name text not null, + source text not null, + script_path text not null, + script_hash text not null, + workspace_root text not null, + workspace_id text, + args_json text not null default 'null', + status text not null, + error text, + error_kind text, + result_json text, + pid integer, + heartbeat_at text, + cancel_requested text not null default 'false', + resumed_from_run_id text, + base_sha text, + created_at text not null, + started_at text, + completed_at text, + updated_at text not null + ); + + create index if not exists workflow_runs_status_updated_idx + on workflow_runs(status, updated_at desc); + + create index if not exists workflow_runs_workspace_updated_idx + on workflow_runs(workspace_root, updated_at desc); + + create index if not exists workflow_runs_heartbeat_idx + on workflow_runs(status, heartbeat_at); + + create index if not exists workflow_runs_resumed_from_idx + on workflow_runs(resumed_from_run_id); + + create table if not exists workflow_events ( + run_id text not null, + seq integer not null, + type text not null, + phase text, + label text, + data_json text not null default '{}', + created_at text not null, + primary key (run_id, seq), + foreign key (run_id) references workflow_runs(id) on delete cascade + ); + + create index if not exists workflow_events_run_seq_idx + on workflow_events(run_id, seq); + + create table if not exists workflow_agent_calls ( + run_id text not null, + call_index integer not null, + cache_key text not null, + provider text not null, + model text, + effort text, + profile_name text, + profile_fingerprint text, + label text, + phase text, + status text not null, + from_cache text not null default 'false', + provider_session_id text, + response_text text, + structured_json text, + return_value_json text, + error text, + isolation text not null default 'shared', + worktree_path text, + dirty text, + created_at text not null, + started_at text, + completed_at text, + updated_at text not null, + primary key (run_id, call_index), + foreign key (run_id) references workflow_runs(id) on delete cascade + ); + + create index if not exists workflow_agent_calls_cache_key_idx + on workflow_agent_calls(run_id, cache_key); + `); +} + +function migrateWorkflowReplayProvenance(sqlite: Database.Database): void { + addColumnIfMissing(sqlite, "workflow_agent_calls", "prompt", "text not null default ''"); + addColumnIfMissing(sqlite, "workflow_agent_calls", "schema_json", "text"); + addColumnIfMissing(sqlite, "workflow_agent_calls", "error_kind", "text"); + addColumnIfMissing(sqlite, "workflow_agent_calls", "replay_match", "text"); + addColumnIfMissing(sqlite, "workflow_agent_calls", "replayed_from_run_id", "text"); + addColumnIfMissing(sqlite, "workflow_agent_calls", "replayed_from_call_index", "integer"); + addColumnIfMissing(sqlite, "workflow_agent_calls", "replay_reason", "text"); + + sqlite.exec(` + create index if not exists workflow_agent_calls_replay_source_idx + on workflow_agent_calls(replayed_from_run_id, replayed_from_call_index); + `); +} + +function migrateWorkflowExactReplay(sqlite: Database.Database): void { + addColumnIfMissing(sqlite, "workflow_agent_calls", "return_value_json", "text"); +} + +function migrateWorkflowAgentProfiles(sqlite: Database.Database): void { + addColumnIfMissing(sqlite, "workflow_agent_calls", "profile_name", "text"); + addColumnIfMissing(sqlite, "workflow_agent_calls", "profile_fingerprint", "text"); +} + function 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 01d13fac..a087bae9 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"), @@ -97,9 +97,111 @@ 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(), + prompt: text("prompt").notNull().default(""), + schemaJson: text("schema_json"), + provider: text("provider").notNull(), + model: text("model"), + effort: text("effort"), + profileName: text("profile_name"), + profileFingerprint: text("profile_fingerprint"), + label: text("label"), + phase: text("phase"), + status: text("status").notNull(), + fromCache: text("from_cache").notNull().default("false"), + providerSessionId: text("provider_session_id"), + responseText: text("response_text"), + structuredJson: text("structured_json"), + returnValueJson: text("return_value_json"), + error: text("error"), + errorKind: text("error_kind"), + replayMatch: text("replay_match"), + replayedFromRunId: text("replayed_from_run_id"), + replayedFromCallIndex: integer("replayed_from_call_index"), + replayReason: text("replay_reason"), + isolation: text("isolation").notNull().default("shared"), + worktreePath: text("worktree_path"), + dirty: text("dirty"), + createdAt: text("created_at").notNull(), + startedAt: text("started_at"), + completedAt: text("completed_at"), + updatedAt: text("updated_at").notNull(), + }, + (table) => [ + primaryKey({ columns: [table.runId, table.callIndex] }), + index("workflow_agent_calls_cache_key_idx").on(table.runId, table.cacheKey), + index("workflow_agent_calls_replay_source_idx").on( + table.replayedFromRunId, + table.replayedFromCallIndex, + ), + ], +); + export 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/json-types.ts b/src/json-types.ts new file mode 100644 index 00000000..a701edd4 --- /dev/null +++ b/src/json-types.ts @@ -0,0 +1,42 @@ +import type { JSONSchema } from "json-schema-to-ts"; +import * as z from "zod/v4"; + +export type JsonPrimitive = string | number | boolean | null; + +export type JsonValue = + | JsonPrimitive + | JsonValue[] + | { [key: string]: JsonValue }; + +export type JsonObject = { [key: string]: JsonValue }; + +/** JSON Schema is the portable contract shared with provider SDKs and Ajv. */ +export type JsonSchema = JSONSchema; + +export const jsonValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.string(), + z.number().finite(), + z.boolean(), + z.null(), + z.array(jsonValueSchema), + z.record(z.string(), jsonValueSchema), + ]), +); + +export const jsonObjectSchema: z.ZodType = z.record( + z.string(), + jsonValueSchema, +); + +export const jsonSchemaSchema = jsonObjectSchema.transform( + (value): JsonSchema => value as JsonSchema, +); + +export function parseJsonValue(value: unknown): JsonValue { + return jsonValueSchema.parse(value); +} + +export function parseJsonText(text: string): JsonValue { + return parseJsonValue(JSON.parse(text) as unknown); +} diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index 0e4cbf0f..93cf4fc0 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -2,14 +2,16 @@ import assert from "node:assert/strict"; import { delimiter } from "node:path"; import { claudeCommandEnvironment, + claudeOutputFormatOptions, createLocalAgentAdapter, + extractClaudeResultPayload, extractOpenCodeFinalResponse, extractPiFinalResponse, extractPiProviderError, 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 +113,7 @@ assert.throws( ); assert.deepEqual( - resolveAcpThinkingConfigUpdate({ + resolveAcpEffortConfigUpdate({ sessionId: "session_1", newSessionResponse: { configOptions: [ @@ -131,7 +133,7 @@ assert.deepEqual( ); assert.deepEqual( - resolveAcpThinkingConfigUpdate({ + resolveAcpEffortConfigUpdate({ sessionId: "session_2", newSessionResponse: { configOptions: [ @@ -157,7 +159,7 @@ assert.deepEqual( ); assert.throws( - () => resolveAcpThinkingConfigUpdate({ + () => resolveAcpEffortConfigUpdate({ sessionId: "session_3", newSessionResponse: { configOptions: [ @@ -174,21 +176,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/, ); { @@ -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"], + } as const; + 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 457b8e08..ce91c90f 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -1,11 +1,22 @@ 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 { 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 { createCodexSdkLocalAgentRuntime, + isNativeSchemaUnsupportedFailure, + ProviderSchemaUnsupportedError, type LocalAgentRunInput, type LocalAgentRunResult, } from "./local-agent-runtime.js"; @@ -25,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 { @@ -59,42 +82,98 @@ 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.thinking ? { thinking: { type: "adaptive" } as const, effort: input.thinking as EffortLevel } : {}), - resume: input.providerSessionId, - permissionMode: "bypassPermissions", - allowDangerouslySkipPermissions: true, - env: claudeCommandEnvironment(process.env), - ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}), - }, - }); + 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 = ""; - 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") { + 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); - finalResponse = record.result; + 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 } : {}), + }; + } catch (error) { + if (input.schema && isNativeSchemaUnsupportedFailure(error)) { + throw new ProviderSchemaUnsupportedError(this.provider, error); + } + throw error; } + } +} - finalResponse = requireFinalResponse("Claude", finalResponse); +/** Build Claude SDK outputFormat when a JSON Schema is requested. */ +export function claudeOutputFormatOptions( + schema: JsonSchema | undefined, +): { outputFormat: OutputFormat } | 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 { - provider: this.provider, - providerSessionId, - finalResponse, - items, + 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 { @@ -209,8 +288,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 +337,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 +418,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, @@ -513,6 +595,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; @@ -524,7 +609,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-availability.ts b/src/local-agent-availability.ts index 747f304f..495a463c 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-capabilities.test.ts b/src/local-agent-capabilities.test.ts new file mode 100644 index 00000000..112a8fc7 --- /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 new file mode 100644 index 00000000..48b2264f --- /dev/null +++ b/src/local-agent-capabilities.ts @@ -0,0 +1,112 @@ +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 = { + codex: { + structuredOutput: "native", + 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"; +} diff --git a/src/local-agent-catalog.test.ts b/src/local-agent-catalog.test.ts new file mode 100644 index 00000000..ad4bd4ad --- /dev/null +++ b/src/local-agent-catalog.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { + buildLocalAgentCatalog, + formatLocalAgentCatalog, +} 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"); +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 new file mode 100644 index 00000000..da8775f2 --- /dev/null +++ b/src/local-agent-catalog.ts @@ -0,0 +1,59 @@ +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[]; +} + +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[], + 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/local-agent-errors.ts b/src/local-agent-errors.ts new file mode 100644 index 00000000..19058d0f --- /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-profiles.test.ts b/src/local-agent-profiles.test.ts index 7665b9f8..b3814049 100644 --- a/src/local-agent-profiles.test.ts +++ b/src/local-agent-profiles.test.ts @@ -3,7 +3,13 @@ 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-")); @@ -35,7 +41,7 @@ try { 'description: "Project reviewer #1."', "provider: claude", "model: sonnet", - "thinking: high", + "effort: high", "---", "", "Project body.", @@ -70,14 +76,23 @@ 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.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.", provider: "claude", model: "sonnet", - thinking: "high", + effort: "high", }); await writeFile( @@ -106,3 +121,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 a7fa0d87..da0de6aa 100644 --- a/src/local-agent-profiles.ts +++ b/src/local-agent-profiles.ts @@ -1,26 +1,27 @@ +import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import { readdir, readFile } from "node:fs/promises"; 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; description: string; provider: LocalAgentProvider; model?: string; - thinking?: string; + effort?: string; filePath: string; body: string; disabled: boolean; @@ -31,7 +32,7 @@ export interface LocalAgentProfileSummary { description: string; provider: LocalAgentProvider; model?: string; - thinking?: string; + effort?: string; } interface ParsedFrontmatter { @@ -46,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, @@ -73,10 +74,33 @@ export function summarizeLocalAgentProfile( description: profile.description, provider: profile.provider, model: profile.model, - thinking: profile.thinking, + effort: profile.effort, }; } +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 []; @@ -157,7 +181,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-resolution.test.ts b/src/local-agent-resolution.test.ts new file mode 100644 index 00000000..7cbc3cc1 --- /dev/null +++ b/src/local-agent-resolution.test.ts @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import type { LocalAgentProfile } from "./local-agent-profiles.js"; +import { + LocalAgentResolutionError, + resolveLocalAgentExecution, +} from "./local-agent-resolution.js"; + +const reviewer: LocalAgentProfile = { + name: "reviewer", + description: "Review changes.", + provider: "codex", + model: "gpt-profile", + effort: "high", + filePath: "/repo/.devspace/agents/reviewer.md", + body: "Review carefully.", + disabled: false, +}; + +const profile = resolveLocalAgentExecution({ + target: "reviewer", + prompt: "Inspect src/auth.ts", + profiles: [reviewer], + availableProviders: ["codex"], +}); +assert.equal(profile.kind, "profile"); +assert.equal(profile.provider, "codex"); +assert.equal(profile.model, "gpt-profile"); +assert.equal(profile.effort, "high"); +assert.equal(profile.prompt, "Review carefully.\n\nTask:\nInspect src/auth.ts"); +assert.equal(profile.profileFingerprint?.length, 64); + +const overridden = resolveLocalAgentExecution({ + profile: "reviewer", + prompt: "Inspect src/auth.ts", + profiles: [reviewer], + availableProviders: ["codex"], + model: "gpt-call", + effort: "xhigh", +}); +assert.equal(overridden.model, "gpt-call"); +assert.equal(overridden.effort, "xhigh"); + +const provider = resolveLocalAgentExecution({ + target: "claude", + prompt: "Investigate the failure", + profiles: [], + availableProviders: ["claude"], +}); +assert.equal(provider.kind, "provider"); +assert.equal(provider.prompt, "Investigate the failure"); + +const fallback = resolveLocalAgentExecution({ + prompt: "Investigate the failure", + profiles: [], + availableProviders: ["pi", "codex"], +}); +assert.equal(fallback.provider, "pi"); + +assert.throws( + () => resolveLocalAgentExecution({ + profile: "missing", + prompt: "x", + profiles: [reviewer], + availableProviders: ["codex"], + }), + (error) => error instanceof LocalAgentResolutionError && error.kind === "profile_not_found", +); + +assert.throws( + () => resolveLocalAgentExecution({ + target: "reviewer", + prompt: "x", + profiles: [reviewer], + availableProviders: ["claude"], + }), + /requires unavailable provider codex/, +); + +assert.throws( + () => resolveLocalAgentExecution({ + prompt: "x", + profiles: [], + availableProviders: [], + }), + (error) => error instanceof LocalAgentResolutionError && error.kind === "no_provider", +); + +console.log("local-agent-resolution.test.ts: ok"); diff --git a/src/local-agent-resolution.ts b/src/local-agent-resolution.ts new file mode 100644 index 00000000..3ab67e1d --- /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-runtime.test.ts b/src/local-agent-runtime.test.ts index 1d45d166..29b3d69f 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 => ({ @@ -13,11 +14,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 +58,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", @@ -63,7 +74,7 @@ await runtime.run({ workspace: "/tmp/project", writeMode: "allowed", model: "gpt-5.4", - thinking: "high", + effort: "high", }); assert.deepEqual(codex.started[1], { @@ -74,6 +85,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 +112,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", @@ -98,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 54130c2e..e43a4aff 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -5,7 +5,20 @@ import type { RunResult, SandboxMode, ThreadOptions, + TurnOptions, } 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"; @@ -15,24 +28,29 @@ export interface LocalAgentRunInput { providerSessionId?: string; writeMode?: LocalAgentWriteMode; model?: string; - thinking?: string; + /** Provider-native effort / reasoning level (was thinking). */ + effort?: string; + /** JSON Schema for native structured output (codex/claude). */ + schema?: JsonSchema; } export interface LocalAgentRunResult { - provider: string; + provider: LocalAgentProvider; providerSessionId: string | null; finalResponse: string; items: unknown[]; + /** Provider-native structured object when schema was requested. */ + structured?: unknown; } export interface LocalAgentRuntime { - readonly provider: string; + readonly provider: LocalAgentProvider; run(input: LocalAgentRunInput): Promise; } interface CodexThreadLike { readonly id: string | null; - run(prompt: string): Promise; + run(prompt: string, turnOptions?: TurnOptions): Promise; } interface CodexClientLike { @@ -60,7 +78,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, }; } @@ -77,17 +95,35 @@ 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; + 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, 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, diff --git a/src/local-agent-store.test.ts b/src/local-agent-store.test.ts index cf7265a9..05adcbcc 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 a850ca9f..a87a0b1e 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 3f1ae08f..737f970a 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 917e2804..c794511b 100644 --- a/src/local-agent-targets.ts +++ b/src/local-agent-targets.ts @@ -1,42 +1,33 @@ 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; prompt: string; model?: string; - thinking?: string; + effort?: string; } -export type LocalAgentTarget = - | { - kind: "profile"; - name: string; - provider: LocalAgentProvider; - model?: string; - thinking?: string; - profile: LocalAgentProfile; - } - | { - kind: "provider"; - name: LocalAgentProvider; - provider: LocalAgentProvider; - model?: string; - thinking?: string; - }; +export type LocalAgentTarget = ResolvedLocalAgentExecution; + +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 +44,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,48 +69,42 @@ 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, + 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, - thinking: thinkingOverride ?? profile.thinking, - profile, - }; - } - - if (isLocalAgentProvider(target)) { - return { - kind: "provider", - name: target, - provider: target, + try { + return resolveLocalAgentExecution({ + target, + prompt: "", + profiles, + availableProviders, model: modelOverride, - thinking: thinkingOverride, - }; + 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/oauth-store.test.ts b/src/oauth-store.test.ts index e1c00338..78a66214 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -44,6 +44,11 @@ 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" }, + { 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/open-workspace-capabilities.test.ts b/src/open-workspace-capabilities.test.ts new file mode 100644 index 00000000..a3093c45 --- /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 7dd9629e..8a142c27 100644 --- a/src/server.ts +++ b/src/server.ts @@ -46,7 +46,11 @@ 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"; +import { loadActiveWorkflowSummaries } from "./workflow-ui.js"; import { formatLocalAgentProviderAvailabilitySummary, getLocalAgentProviderAvailabilitySnapshot, @@ -205,20 +209,11 @@ function formatVisibleAgent(agent: { name: string; provider: string; model?: string; - thinking?: string; - providerAvailable?: boolean; - providerUnavailableReason?: string; + effort?: string; }): string { const model = agent.model ? `, model ${agent.model}` : ""; - const thinking = agent.thinking ? `, thinking ${agent.thinking}` : ""; - const availability = agent.providerAvailable === false - ? `, unavailable: ${agent.providerUnavailableReason ?? "provider unavailable"}` - : ""; - return `${agent.name} (${agent.provider}${model}${thinking}${availability})`; -} - -function formatUnavailableAgentProvider(provider: LocalAgentProviderAvailability): string { - return `${provider.name} (${provider.reason ?? "unavailable"})`; + const effort = agent.effort ? `, effort ${agent.effort}` : ""; + return `${agent.name} (${agent.provider}${model}${effort})`; } function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { @@ -248,21 +243,77 @@ const workspaceLocalAgentOutputSchema = z.object({ description: z.string(), provider: z.string(), model: z.string().optional(), - thinking: z.string().optional(), - providerAvailable: z.boolean().optional(), - providerUnavailableReason: z.string().optional(), + effort: 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(), }); +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(), @@ -704,10 +755,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), @@ -757,29 +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()), - instruction: z.string(), - }, + outputSchema: openWorkspaceOutputSchema(config), ...toolWidgetDescriptorMeta(config, "workspace"), annotations: { readOnlyHint: true }, }, @@ -799,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, @@ -816,6 +840,16 @@ function createMcpServer( const availableAgentsFileOutputs = availableAgentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), })); + const activeWorkflows = config.workflows + ? (() => { + const workflowStore = createWorkflowStore(config); + try { + return loadActiveWorkflowSummaries(workflowStore, workspace.root); + } finally { + 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."; @@ -835,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(", ")}` @@ -869,8 +900,15 @@ function createMcpServer( agentsFiles: loadedAgentsFiles.length, availableAgentsFiles: availableAgentsFileOutputs.length, skills: visibleSkills.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, }, }, @@ -884,8 +922,13 @@ function createMcpServer( agentsFiles: loadedAgentsFiles, availableAgentsFiles: availableAgentsFileOutputs, skills: visibleSkills, - agentProviders: visibleAgentProviders, - agents: visibleAgents, + ...(config.workflows ? { activeWorkflows: activeWorkflows ?? [] } : {}), + ...(config.subagents + ? { + agentProviders: visibleAgentProviders, + agents: visibleAgents, + } + : {}), skillDiagnostics: workspace.skillDiagnostics, instruction, }, @@ -1594,6 +1637,10 @@ function createMcpServer( registerCodexProcessTools(server, config, workspaces, processSessions); } + if (config.workflows) { + registerWorkflowTools(server, config, workspaces); + } + return server; } @@ -1621,6 +1668,15 @@ export function createServer(config = loadConfig()): RunningServer { const localAgentProviders = config.subagents ? getLocalAgentProviderAvailabilitySnapshot() : []; + const workflowReaper = config.workflows + ? 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", @@ -1808,6 +1864,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/skills.test.ts b/src/skills.test.ts index 707dda26..4bc585ea 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -199,16 +199,39 @@ 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", }); + 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); + const codexReference = join(subagentsSkill.baseDir, "references", "codex.md"); + assert.equal(resolveSkillReadPath([subagentsSkill], new Set(), codexReference), undefined); assert.equal( - loadWorkspaceSkills(experimentalConfig, projectRoot).skills.some( - (skill) => skill.name === "subagent-delegation", - ), - true, + resolveSkillReadPath([subagentsSkill], new Set([subagentsSkill.baseDir]), codexReference) + ?.absolutePath, + 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 c1f146a9..b547fbef 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -21,27 +21,22 @@ export interface SkillReadResolution { isSkillFile: boolean; } -const SUBAGENT_DELEGATION_NAME = "subagent-delegation"; -const SUBAGENT_DELEGATION_SKILL = join(SUBAGENT_DELEGATION_NAME, "SKILL.md"); +const SUBAGENTS_NAME = "subagents"; +const LEGACY_SUBAGENT_DELEGATION_NAME = "subagent-delegation"; +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)); -} - +/** Package skills are defaults; user/project copies win by path order. */ 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 || config.workflows ? bundledSkillsDir() : undefined, ]; const defaultPaths = defaultPathCandidates.filter( (path): path is string => path !== undefined && existsSync(path), @@ -71,13 +66,17 @@ export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSk includeDefaults: false, }); - if (config.subagents) return result; - + 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) => 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/ui/card-types.test.ts b/src/ui/card-types.test.ts index eb47e9a0..7e261ace 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 1e3c9409..73c9ebbf 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" @@ -23,7 +26,19 @@ 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; summary?: Record; files?: Array<{ path?: string; @@ -46,6 +61,33 @@ export interface ToolResultCard { description?: string; path?: string; }>; + activeWorkflows?: WorkflowRunSummaryView[]; + callSummary?: { + reused?: number; + live?: number; + failed?: number; + running?: number; + total?: number; + }; + agentProviders?: Array<{ + name?: string; + model?: { + supported?: boolean; + discovery?: string; + }; + effort?: { + supported?: boolean; + semantics?: string; + discovery?: string; + }; + }>; + agents?: Array<{ + name?: string; + description?: string; + provider?: string; + model?: string; + effort?: string; + }>; skillDiagnostics?: unknown[]; instruction?: string; } @@ -66,6 +108,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 +152,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"); } @@ -141,10 +189,15 @@ 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) ); } + 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 22f86002..11d67b7f 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 b9977ac8..0b8c0883 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 7a847631..d989a3f8 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, @@ -123,12 +138,22 @@ 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)); 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"), diff --git a/src/ui/workflow-dashboard.ts b/src/ui/workflow-dashboard.ts new file mode 100644 index 00000000..08b1e0d4 --- /dev/null +++ b/src/ui/workflow-dashboard.ts @@ -0,0 +1,406 @@ +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), + ...(card.activeWorkflows !== undefined || project !== null + ? [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.", + ), + ), + ...(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, + 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.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.", + ); +} + +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 ed81685c..db1503ff 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 78723864..84787372 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/user-config.ts b/src/user-config.ts index c8da90b5..970685cf 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,16 +97,6 @@ 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 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 resolveSubagentsFlag( config: Pick, env: NodeJS.ProcessEnv = process.env, diff --git a/src/workflow-api.ts b/src/workflow-api.ts new file mode 100644 index 00000000..cd0d60da --- /dev/null +++ b/src/workflow-api.ts @@ -0,0 +1,838 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { createHash } from "node:crypto"; +import type { WorkflowSandboxApi } from "./workflow-sandbox.js"; +import { + type LocalAgentProfile, + type LocalAgentProvider, +} from "./local-agent-profiles.js"; +import { + LocalAgentResolutionError, + resolveLocalAgentExecution, +} from "./local-agent-resolution.js"; +import type { JsonSchema, JsonValue } from "./json-types.js"; +import { jsonValueSchema } from "./json-types.js"; +import { + WORKFLOW_LIMITS, + WORKFLOW_MAX_ITEMS, + WORKFLOW_MAX_NEST_DEPTH, + buildAgentCacheKeyInput, + createStubBudget, + type AgentIsolationMode, + type AgentCacheKeyInput, + type AgentOpts, + type AppendWorkflowEventInput, + type WorkflowMeta, +} from "./workflow-types.js"; +import { agentOptsSchema } from "./workflow-contracts.js"; + +// --------------------------------------------------------------------------- +// Host deps (injected by engine; fakes OK in tests) +// --------------------------------------------------------------------------- + +export interface WorkflowProviderRunInput { + provider: LocalAgentProvider; + prompt: string; + providerSessionId?: string; + model?: string; + effort?: string; + workspace: string; + signal?: AbortSignal; + label?: string; + phase?: string; + /** JSON Schema for native structured output (codex/claude). */ + schema?: JsonSchema; +} + +export interface WorkflowProviderRunResult { + finalResponse: string; + providerSessionId?: string; + /** Provider-native structured object when schema was requested. */ + structured?: unknown; +} + +export type WorkflowRunProvider = ( + input: WorkflowProviderRunInput, +) => Promise; + +export interface WorkflowWorktreeHandle { + path: string; + /** Called after agent returns or fails. Success+clean may remove; dirty/failure preserves. */ + finalize: (outcome: "success" | "failure") => Promise<{ dirty: boolean; removed: boolean }>; +} + +export type CreateAgentWorktree = (input: { + runId: string; + callIndex: number; + workspaceRoot: string; + baseSha?: string; +}) => Promise; + +export interface WorkflowReplayHit { + value: JsonValue; + responseText?: string; + structuredJson?: string; + returnValueJson: string; + providerSessionId?: string; + replayMatch: "same_index" | "compatible_key"; + replayedFromRunId: string; + replayedFromCallIndex: number; +} + +export interface WorkflowReplayMiss { + reason: + | "no_compatible_call" + | "prior_call_not_replayable" + | "compatible_result_consumed" + | "identity_changed" + | "prefix_diverged" + | "worktree_not_restored" + | "result_not_persisted" + | "stored_result_invalid"; + changedFields?: Array; +} + +export type WorkflowReplayDecision = + | { hit: WorkflowReplayHit; miss?: never } + | { hit?: never; miss: WorkflowReplayMiss }; + +export interface WorkflowReplay { + decide( + callIndex: number, + cacheKey: string, + input: AgentCacheKeyInput, + ): WorkflowReplayDecision; +} + +export interface WorkflowJournal { + appendEvent( + input: Extract, + ): unknown; + beginAgentCall(input: { + runId: string; + callIndex: number; + cacheKey: string; + prompt: string; + schemaJson?: string; + provider: LocalAgentProvider; + model?: string; + effort?: string; + profileName?: string; + profileFingerprint?: string; + label?: string; + phase?: string; + isolation?: AgentIsolationMode; + worktreePath?: string; + replayMatch?: "same_index" | "compatible_key"; + replayedFromRunId?: string; + replayedFromCallIndex?: number; + replayReason?: string; + }): unknown; + completeAgentCall(input: { + runId: string; + callIndex: number; + responseText?: string; + structuredJson?: string; + returnValueJson?: string; + providerSessionId?: string; + dirty?: boolean; + worktreePath?: string; + fromCache?: boolean; + }): unknown; + failAgentCall(input: { + runId: string; + callIndex: number; + error: string; + errorKind?: import("./workflow-types.js").WorkflowErrorKind; + worktreePath?: string; + dirty?: boolean; + }): unknown; + isCancelRequested(runId: string): boolean; +} + +export interface WorkflowApiDeps { + runId: string; + journal: WorkflowJournal; + meta: WorkflowMeta; + args: JsonValue | undefined; + concurrency: number; + signal: AbortSignal; + workspaceRoot: string; + baseSha?: string; + /** Currently available provider ids in stable preference order. */ + availableProviders: LocalAgentProvider[]; + /** Loaded profiles available to this project. */ + agentProfiles?: LocalAgentProfile[]; + runProvider: WorkflowRunProvider; + createWorktree?: CreateAgentWorktree; + replay?: WorkflowReplay; + /** Nested workflow source loader; required for workflow(). */ + resolveNestedSource?: (nameOrRef: string | { scriptPath: string }) => string | Promise; + /** Run a nested script sharing semaphore/callIndex. */ + executeNested?: (input: { + source: string; + args: JsonValue | undefined; + nestDepth: number; + }) => Promise; + nestDepth?: number; + runtime?: WorkflowApiRuntime; +} + +export interface WorkflowApi extends WorkflowSandboxApi { + getCallCount(): number; + getNestDepth(): number; +} + +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 +// --------------------------------------------------------------------------- + +export class WorkflowSemaphore { + private active = 0; + private readonly waiters: Array<() => void> = []; + + constructor(readonly limit: number) { + if (!Number.isFinite(limit) || limit < 1) { + throw new Error("WorkflowSemaphore limit must be >= 1"); + } + } + + async acquire(signal?: AbortSignal): Promise { + if (signal?.aborted) throw cancelledError(); + if (this.active < this.limit) { + this.active += 1; + return; + } + await new Promise((resolve, reject) => { + const onAbort = () => { + const idx = this.waiters.indexOf(wake); + if (idx >= 0) this.waiters.splice(idx, 1); + reject(cancelledError()); + }; + const wake = () => { + signal?.removeEventListener("abort", onAbort); + this.active += 1; + resolve(); + }; + this.waiters.push(wake); + signal?.addEventListener("abort", onAbort, { once: true }); + }); + } + + release(): void { + this.active = Math.max(0, this.active - 1); + const next = this.waiters.shift(); + if (next) next(); + } +} + +export interface WorkflowApiRuntime { + semaphore: WorkflowSemaphore; + callIndex: number; +} + +export function createWorkflowApiRuntime(concurrency: number): WorkflowApiRuntime { + return { + semaphore: new WorkflowSemaphore(Math.max(1, concurrency)), + callIndex: 0, + }; +} + +// --------------------------------------------------------------------------- +// API factory +// --------------------------------------------------------------------------- + +const phaseAls = new AsyncLocalStorage(); + +export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { + const nestDepth = deps.nestDepth ?? 0; + const runtime = deps.runtime ?? createWorkflowApiRuntime(deps.concurrency); + const semaphore = runtime.semaphore; + + const agent = async (prompt: unknown, opts: unknown = {}): Promise => { + if (typeof prompt !== "string" || !prompt.trim()) { + throw new WorkflowEngineError("internal", "agent(prompt) requires a non-empty string"); + } + const agentOpts = normalizeAgentOpts(opts); + throwIfCancelled(deps); + + const target = resolveAgentTarget(prompt, agentOpts, deps); + const { + provider, + model, + effort, + profileName, + profileFingerprint, + providerPrompt, + } = target; + const phase = agentOpts.phase ?? phaseAls.getStore(); + const isolation: AgentIsolationMode = + agentOpts.isolation === "worktree" ? "worktree" : "shared"; + const index = runtime.callIndex; + runtime.callIndex += 1; + + const cacheKeyInput = buildAgentCacheKeyInput({ + prompt, + profileName, + profileFingerprint, + provider, + model, + effort, + schema: agentOpts.schema, + isolation, + }); + const cacheKey = hashCacheKey(cacheKeyInput); + + const replayDecision = deps.replay?.decide(index, cacheKey, cacheKeyInput); + if (replayDecision?.hit) { + const hit = replayDecision.hit; + deps.journal.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; + } + + await semaphore.acquire(deps.signal); + let worktree: WorkflowWorktreeHandle | null = null; + let worktreePath: string | undefined; + let agentCallBegun = false; + 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, + 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, + prompt: providerPrompt, + model, + effort, + workspace: cwd, + signal: deps.signal, + label: agentOpts.label, + phase, + }; + + let returnValue: unknown; + let structuredJson: string | undefined; + let result: WorkflowProviderRunResult; + + if (agentOpts.schema) { + // Lazy import keeps non-schema paths free of ajv load cost. + const { enforceAgentSchema } = await import("./workflow-schema.js"); + const enforced = await enforceAgentSchema({ + schema: agentOpts.schema, + prompt: providerPrompt, + provider, + run: (p, options) => + deps.runProvider({ + ...providerBase, + prompt: p, + providerSessionId: options.providerSessionId, + ...(options.mode === "native" ? { schema: agentOpts.schema } : {}), + }), + onRetry: ({ attempt, errors, mode }) => { + deps.journal.appendEvent({ + runId: deps.runId, + type: "schema_retry", + phase, + label: agentOpts.label, + data: { callIndex: index, attempt, errors, mode }, + }); + }, + }); + returnValue = enforced.value; + structuredJson = JSON.stringify(enforced.value); + result = { + finalResponse: enforced.finalResponse, + providerSessionId: enforced.providerSessionId, + structured: enforced.value, + }; + } else { + result = await deps.runProvider(providerBase); + returnValue = result.finalResponse; + } + + throwIfCancelled(deps); + + 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: boundedStructuredJson(structuredJson), + returnValueJson: serializeReplayValue(returnValue), + 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); + let cleanupError: string | undefined; + if (worktree) { + try { + const finalized = await worktree.finalize("failure"); + deps.journal.appendEvent({ + runId: deps.runId, + type: "worktree_finalized", + phase, + label: agentOpts.label, + data: { + callIndex: index, + worktreePath, + dirty: finalized.dirty, + removed: finalized.removed, + outcome: "failure", + }, + }); + } catch (cleanupFailure) { + cleanupError = + cleanupFailure instanceof Error + ? cleanupFailure.message + : String(cleanupFailure); + } + } + if (agentCallBegun) { + deps.journal.failAgentCall({ + runId: deps.runId, + callIndex: index, + error: message, + errorKind: error instanceof WorkflowEngineError ? error.kind : "internal", + worktreePath, + }); + } + 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(); + } + }; + + 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 childArgsResult = jsonValueSchema.optional().safeParse(args[1]); + if (!childArgsResult.success) { + throw new WorkflowEngineError( + "internal", + `workflow() args must be JSON-serializable: ${childArgsResult.error.issues[0]?.message ?? "invalid value"}`, + ); + } + const source = await deps.resolveNestedSource(nameOrRef); + return deps.executeNested({ + source, + args: childArgsResult.data, + nestDepth: nestDepth + 1, + }); + }; + + return { + agent: agent as WorkflowSandboxApi["agent"], + parallel: parallel as WorkflowSandboxApi["parallel"], + pipeline: pipeline as WorkflowSandboxApi["pipeline"], + phase: phase as WorkflowSandboxApi["phase"], + log: log as WorkflowSandboxApi["log"], + args: deps.args, + budget: createStubBudget(), + workflow: workflow as WorkflowSandboxApi["workflow"], + meta: deps.meta, + getCallCount: () => runtime.callIndex, + getNestDepth: () => nestDepth, + }; +} + +function formatReplayMiss(miss: WorkflowReplayMiss): string { + return miss.reason === "identity_changed" && miss.changedFields?.length + ? `${miss.reason}:${miss.changedFields.join(",")}` + : miss.reason; +} + +/** Test helper: read current ALS phase (undefined outside phase). */ +export function getCurrentWorkflowPhase(): string | undefined { + return phaseAls.getStore(); +} + +export function hashCacheKey(input: ReturnType): string { + return createHash("sha256").update(JSON.stringify(input)).digest("hex"); +} + +interface ResolvedAgentTarget { + provider: LocalAgentProvider; + model?: string; + effort?: string; + profileName?: string; + profileFingerprint?: string; + providerPrompt: string; +} + +function resolveAgentTarget( + prompt: string, + opts: AgentOpts, + deps: Pick, +): ResolvedAgentTarget { + try { + const resolved = resolveLocalAgentExecution({ + prompt, + profile: opts.profile, + provider: opts.provider, + defaultProvider: deps.meta.defaultProvider, + model: opts.model, + effort: opts.effort, + profiles: deps.agentProfiles ?? [], + availableProviders: deps.availableProviders, + }); + return { + provider: resolved.provider, + model: resolved.model, + effort: resolved.effort, + profileName: resolved.profileName, + profileFingerprint: resolved.profileFingerprint, + providerPrompt: resolved.prompt, + }; + } catch (error) { + if (!(error instanceof LocalAgentResolutionError)) throw error; + const kind = error.kind === "profile_not_found" + ? "profile" + : error.kind === "no_provider" + ? "no_provider" + : "provider_unavailable"; + throw new WorkflowEngineError(kind, error.message); + } +} + +function normalizeAgentOpts(opts: unknown): AgentOpts { + if (opts === undefined || opts === null) return {}; + if (typeof opts === "object" && opts !== null && "writeMode" in opts) { + throw new WorkflowEngineError("internal", "writeMode is not supported on agent() (v1)"); + } + const parsed = agentOptsSchema.safeParse(opts); + if (parsed.success) return parsed.data; + const issue = parsed.error.issues[0]; + const path = issue?.path.join(".") || "opts"; + const kind = path === "schema" + ? "schema" + : path === "isolation" + ? "worktree" + : path === "profile" || issue?.message.includes("profile and provider") + ? "profile" + : "internal"; + throw new WorkflowEngineError( + kind, + `Invalid agent ${path}: ${issue?.message ?? "validation failed"}`, + ); +} + +function assertMaxItems(count: number, label: string): void { + if (count > WORKFLOW_MAX_ITEMS) { + throw new WorkflowEngineError( + "internal", + `${label} exceeds max items ${WORKFLOW_MAX_ITEMS} (got ${count})`, + ); + } +} + +function throwIfCancelled(deps: WorkflowApiDeps): void { + if (deps.signal.aborted || deps.journal.isCancelRequested(deps.runId)) { + throw cancelledError(); + } +} + +function cancelledError(): WorkflowEngineError { + return new WorkflowEngineError("cancelled", "Workflow cancelled"); +} + +function truncate(text: string, maxBytes: number): string { + if (Buffer.byteLength(text, "utf8") <= maxBytes) return text; + const marker = "…"; + const budget = Math.max(0, maxBytes - Buffer.byteLength(marker, "utf8")); + let end = Math.min(text.length, budget); + while (end > 0 && Buffer.byteLength(text.slice(0, end), "utf8") > budget) end -= 1; + return `${text.slice(0, end)}${marker}`; +} + +function 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. */ +export function tryExtractJson(text: string): unknown | undefined { + const trimmed = text.trim(); + try { + return JSON.parse(trimmed); + } catch { + // strip fenced block + const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fence?.[1]) { + try { + return JSON.parse(fence[1].trim()); + } catch { + // fall through + } + } + const start = trimmed.search(/[{\[]/); + if (start < 0) return undefined; + const slice = trimmed.slice(start); + try { + return JSON.parse(slice); + } catch { + return undefined; + } + } +} diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts new file mode 100644 index 00000000..bfd00367 --- /dev/null +++ b/src/workflow-cli.ts @@ -0,0 +1,666 @@ +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, + readProjectWorkflowScriptFile, + readWorkflowScriptFileResult, + resolveNamedWorkflowScript, + 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_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"; + +export async function runWorkflowCommand( + args: string[], + 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); + return; + case "status": + await runWorkflowStatus(rest, config); + return; + case "cancel": + await runWorkflowCancel(rest, config); + return; + case "ls": + case "list": + await runWorkflowList(config); + return; + case "calls": + await runWorkflowCalls(rest, config); + return; + case "call": + await runWorkflowCall(rest, config); + return; + case "tui": { + const { runWorkflowTui } = await import("./workflow-tui.js"); + await runWorkflowTui(rest, config); + return; + } + case "__worker": + await runWorkflowWorker(rest, config); + return; + case undefined: + case "help": + case "--help": + case "-h": + printWorkflowHelp(); + return; + default: + throw new Error(`Unknown workflow command: ${subcommand}`); + } +} + +export function printWorkflowHelp(): void { + console.log( + [ + "DevSpace workflows", + "", + "Usage:", + " devspace workflow run [--file|--script-path | --name ] [--resume ]", + " [--arg key=value]... [--follow]", + " devspace workflow status [--follow]", + " devspace workflow cancel ", + " devspace workflow ls", + " devspace workflow calls ", + " devspace workflow call ", + " devspace workflow tui [runId] # current working directory", + ].join("\n"), + ); +} + +async function runWorkflowRun(args: string[], config: ServerConfig): Promise { + const { flags } = splitFlags(args); + const follow = flags.has("follow"); + const file = flagValue(flags, "script-path") ?? flagValue(flags, "file"); + const name = flagValue(flags, "name"); + const resumeFrom = flagValue(flags, "resume"); + const parsedArgs = parseWorkflowArgFlagsResult(collectArgTokens(args)); + if (parsedArgs.isErr()) throw parsedArgs.error; + const workflowArgs = parsedArgs.value.args; + + if (file && name) { + throw new InvalidWorkflowInputError({ + code: "ambiguous_source", + message: "Provide only one of --file/--script-path or --name", + }); + } + if (!file && !name && !resumeFrom) { + throw new InvalidWorkflowInputError({ + code: "missing_source", + message: + "Usage: devspace workflow run [--file|--script-path | --name ] [--resume ]", + }); + } + + const 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 priorResult = store.getRunResult(resumeFrom); + if (priorResult.isErr()) throw priorResult.error; + const prior = priorResult.value; + if (!prior) throw new WorkflowNotFoundError(resumeFrom); + priorRunId = prior.id; + const overrideResult = file || name + ? await resolveWorkflowScriptFromPathOrNameResult({ + file, + name, + workspaceRoot, + stateDir: config.stateDir, + }) + : await readWorkflowScriptFileResult(prior.scriptPath); + if (overrideResult.isErr()) throw overrideResult.error; + const resolved = overrideResult.value; + source = resolved.source; + scriptHash = resolved.scriptHash; + nameHint = file || name ? resolved.nameHint : prior.name; + priorScriptPath = resolved.scriptPath; + runSource = "resume"; + if (!Object.keys(workflowArgs).length && prior.argsJson && prior.argsJson !== "null") { + try { + const priorArgs = parseJsonText(prior.argsJson); + if (isJsonObject(priorArgs)) Object.assign(workflowArgs, priorArgs); + } catch (cause) { + throw new WorkflowStoredDataError(`${prior.id}.argsJson`, cause); + } + } + } else { + const resolvedResult = await resolveWorkflowScriptFromPathOrNameResult({ + file, + name, + workspaceRoot, + stateDir: config.stateDir, + }); + if (resolvedResult.isErr()) throw resolvedResult.error; + const resolved = resolvedResult.value; + source = resolved.source; + scriptHash = resolved.scriptHash; + nameHint = resolved.nameHint; + runSource = resolved.origin === "named" ? "named" : "inline"; + } + + const parsed = parseWorkflowScript(source, { + filename: file ?? priorScriptPath ?? name ?? "workflow:inline", + }); + const baseSha = await resolveWorkspaceHead(workspaceRoot); + + const run = store.createRun({ + name: parsed.meta.name || nameHint, + source: runSource, + scriptPath: "pending", + scriptHash, + workspaceRoot, + workspaceId: process.env.DEVSPACE_WORKSPACE_ID, + argsJson: JSON.stringify(Object.keys(workflowArgs).length ? workflowArgs : null), + resumedFromRunId: priorRunId, + baseSha, + }); + + const result = await persistWorkflowScriptResult({ + stateDir: config.stateDir, + runId: run.id, + source, + preferredName: parsed.meta.name || nameHint, + }); + if (result.isErr()) throw result.error; + const persisted = result.value; + const updated = store.setScriptPathResult(run.id, persisted); + if (updated.isErr()) throw updated.error; + + 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 { + reapStaleWorkflows(store); + const runResult = store.getRunResult(runId); + if (runResult.isErr()) throw runResult.error; + const run = runResult.value; + if (!run) throw new WorkflowNotFoundError(runId); + console.log(formatRunLine(run)); + console.log(formatCallSummary(store.listAgentCalls(runId))); + 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 { + reapStaleWorkflows(store); + console.log(formatRunLine(await cancelWorkflowRun(store, runId))); + } finally { + store.close(); + } +} + +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."); + return; + } + for (const run of runs) console.log(formatRunLine(run)); + } finally { + store.close(); + } +} + +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[], + 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 resolveNamedWorkflowScript({ + name: ref, + workspaceRoot: claimed.workspaceRoot, + stateDir: config.stateDir, + }); + return named.source; + } + return ( + await readProjectWorkflowScriptFile({ + scriptPath: ref.scriptPath, + workspaceRoot: claimed.workspaceRoot, + }) + ).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 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 = parseWorkflowEventPayload( + "log", + JSON.parse(event.dataJson) as unknown, + ).message; + } catch { + // raw + } + console.log(`${prefix}${message}`); + break; + } + case "phase_started": + console.log(`== phase ${event.phase ?? ""} ==`); + break; + case "agent_call_started": + console.log(`${prefix}agent start ${event.label ?? ""}`.trim()); + break; + case "agent_call_completed": + console.log(`${prefix}agent done ${event.label ?? ""}`.trim()); + break; + case "agent_call_cached": + console.log(`${prefix}agent cache ${event.label ?? ""}`.trim()); + break; + case "agent_call_failed": + console.log(`${prefix}agent fail ${event.label ?? ""} ${event.dataJson}`.trim()); + break; + case "run_completed": + case "run_failed": + case "run_cancelled": + console.log(event.type); + break; + default: + break; + } +} + +function formatRunLine( + run: Pick< + WorkflowRunRecord, + "id" | "status" | "name" | "error" | "scriptPath" | "scriptHash" | "resumedFromRunId" + >, +): string { + const err = run.error ? ` error=${JSON.stringify(run.error)}` : ""; + const resumed = run.resumedFromRunId ? ` resumedFrom=${run.resumedFromRunId}` : ""; + return `${run.id} ${run.status} ${run.name} scriptPath=${JSON.stringify(run.scriptPath)} scriptHash=${run.scriptHash}${resumed}${err}`; +} + +function formatCallLine(call: WorkflowAgentCallRecord): string { + const label = call.label ? ` label=${JSON.stringify(call.label)}` : ""; + const phase = call.phase ? ` phase=${JSON.stringify(call.phase)}` : ""; + const model = call.model ? ` model=${call.model}` : ""; + const duration = callDurationMs(call); + const replay = call.fromCache + ? ` replay=${call.replayMatch ?? "cached"}:${call.replayedFromRunId ?? "?"}#${call.replayedFromCallIndex ?? "?"}` + : call.replayReason + ? ` replayMiss=${call.replayReason}` + : ""; + const worktree = call.worktreePath + ? ` worktree=${JSON.stringify(call.worktreePath)} dirty=${String(call.dirty)}` + : ""; + return `#${call.callIndex} ${call.status} ${call.provider}${model}${label}${phase} durationMs=${duration}${replay}${worktree}`; +} + +function formatCallSummary(calls: WorkflowAgentCallRecord[]): string { + const reused = calls.filter((call) => call.fromCache).length; + const failed = calls.filter((call) => call.status === "failed").length; + const live = calls.filter( + (call) => !call.fromCache && call.status === "completed", + ).length; + const running = calls.filter((call) => call.status === "running").length; + return `calls reused=${reused} live=${live} failed=${failed} running=${running} total=${calls.length}`; +} + +function formatCallDetail(call: WorkflowAgentCallRecord): Record { + return { + ...call, + durationMs: callDurationMs(call), + schema: call.schemaJson ? safeParseJson(call.schemaJson) : undefined, + structured: call.structuredJson ? safeParseJson(call.structuredJson) : undefined, + }; +} + +function callDurationMs(call: WorkflowAgentCallRecord): number | undefined { + if (!call.startedAt || !call.completedAt) return undefined; + return Math.max(0, Date.parse(call.completedAt) - Date.parse(call.startedAt)); +} + +function safeParseJson(text: string): unknown { + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } +} + +function 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[]; +} { + 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)); +} + +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 00000000..5a468e45 --- /dev/null +++ b/src/workflow-contracts.test.ts @@ -0,0 +1,132 @@ +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({ profile: "reviewer", provider: "codex" }), + /mutually exclusive/, +); +assert.throws(() => agentOptsSchema.parse({ schema: [] }), /expected record/i); +assert.throws(() => jsonValueSchema.parse(new Date()), /invalid input/i); +assert.throws(() => jsonValueSchema.parse(() => undefined), /invalid input/i); + +assert.deepEqual( + jsonSchemaSchema.parse({ + type: "object", + properties: { count: { type: "number" } }, + required: ["count"], + }), + { + type: "object", + properties: { count: { type: "number" } }, + required: ["count"], + }, +); + +assert.deepEqual( + parseWorkflowEventPayload("agent_call_completed", { + callIndex: 2, + provider: "claude", + isolation: "shared", + fromCache: false, + }), + { + callIndex: 2, + provider: "claude", + isolation: "shared", + fromCache: false, + }, +); +assert.throws( + () => + parseWorkflowEventPayload("run_completed", { + provider: "codex", + }), + /callCount/, +); + +declare const agent: WorkflowAgent; +declare const parallel: WorkflowParallel; + +if (false) { + const output = await agent("Return a count", { + schema: { + type: "object", + properties: { + count: { type: "number" }, + }, + required: ["count"], + additionalProperties: false, + } as const, + }); + const count: number = output.count; + void count; + + // @ts-expect-error schema-derived output has no `missing` field + void output.missing; + + // @ts-expect-error providers are exhaustive + await agent("x", { provider: "made-up" }); + + await agent("review", { profile: "reviewer", effort: "high" }); + + const tuple = await parallel([ + async () => "text", + async () => 42, + ] as const); + const first: string | null = tuple[0]; + const second: number | null = tuple[1]; + void first; + void second; +} + +console.log("workflow-contracts.test.ts: ok"); diff --git a/src/workflow-contracts.ts b/src/workflow-contracts.ts new file mode 100644 index 00000000..d4641962 --- /dev/null +++ b/src/workflow-contracts.ts @@ -0,0 +1,261 @@ +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(), + profile: z.string().trim().min(1).optional(), + provider: localAgentProviderSchema.optional(), + isolation: z.literal("worktree").optional(), + }) + .strict() + .superRefine((value, context) => { + if (value.profile && value.provider) { + context.addIssue({ + code: "custom", + path: ["provider"], + message: "profile and provider are mutually exclusive", + }); + } + }); + +export type AgentOpts = Omit< + z.infer, + "schema" +> & { + schema?: S; +}; + +export interface WorkflowAgent { + ( + prompt: string, + opts: AgentOpts & { schema: S }, + ): Promise>; + (prompt: string, opts?: AgentOpts): Promise; +} + +export type WorkflowTask = () => T | Promise; + +export interface WorkflowParallel { + ( + tasks: T, + ): Promise<{ + [K in keyof T]: Awaited> | null; + }>; +} + +export interface WorkflowPipeline { + ( + items: readonly T[], + stage: (previous: T, item: T, index: number) => R | Promise, + ): Promise | null>>; + ( + items: readonly T[], + first: (previous: T, item: T, index: number) => A | Promise, + second: (previous: Awaited, item: T, index: number) => R | Promise, + ): Promise | null>>; + (...args: unknown[]): Promise>; +} + +export interface WorkflowNested { + (nameOrRef: string | { scriptPath: string }, args?: JsonValue): Promise; +} + +export const workflowErrorKindSchema = z.enum([ + "syntax", + "meta", + "determinism", + "provider_unavailable", + "no_provider", + "provider", + "profile", + "schema", + "cancelled", + "timeout", + "heartbeat", + "worktree", + "nest_depth", + "path", + "result_too_large", + "args_too_large", + "script_too_large", + "internal", +]); +export type WorkflowErrorKind = z.infer; + +export const WORKFLOW_EVENT_TYPES = [ + "run_started", + "run_completed", + "run_failed", + "run_cancelled", + "phase_started", + "log", + "agent_call_started", + "agent_call_completed", + "agent_call_failed", + "agent_call_cached", + "schema_retry", + "worktree_created", + "worktree_finalized", +] as const; + +export const workflowEventTypeSchema = z.enum(WORKFLOW_EVENT_TYPES); +export type WorkflowEventType = z.infer; + +export const workflowEventPayloadSchemas = { + run_started: z + .object({ + name: z.string(), + scriptHash: z.string(), + concurrency: z.number().int().positive(), + }) + .strict(), + run_completed: z.object({ callCount: z.number().int().nonnegative() }).strict(), + run_failed: z + .object({ error: z.string(), errorKind: workflowErrorKindSchema }) + .strict(), + run_cancelled: z.object({ reason: z.string().optional() }).strict(), + phase_started: z.object({ title: z.string().min(1) }).strict(), + log: z.object({ message: z.string() }).strict(), + agent_call_started: z + .object({ + callIndex: z.number().int().nonnegative(), + cacheKey: z.string(), + provider: localAgentProviderSchema, + isolation: agentIsolationModeSchema, + worktreePath: z.string().optional(), + }) + .strict(), + agent_call_completed: z + .object({ + callIndex: z.number().int().nonnegative(), + provider: localAgentProviderSchema, + isolation: agentIsolationModeSchema, + worktreePath: z.string().optional(), + dirty: z.boolean().optional(), + fromCache: z.boolean(), + }) + .strict(), + agent_call_failed: z + .object({ + callIndex: z.number().int().nonnegative(), + error: z.string(), + cleanupError: z.string().optional(), + isolation: agentIsolationModeSchema, + worktreePath: z.string().optional(), + }) + .strict(), + agent_call_cached: z + .object({ + callIndex: z.number().int().nonnegative(), + cacheKey: z.string(), + provider: localAgentProviderSchema, + replayMatch: z.enum(["same_index", "compatible_key"]), + replayedFromRunId: z.string(), + replayedFromCallIndex: z.number().int().nonnegative(), + }) + .strict(), + schema_retry: z + .object({ + callIndex: z.number().int().nonnegative(), + attempt: z.number().int().positive(), + errors: z.string(), + mode: z.enum(["native", "prompt"]), + }) + .strict(), + worktree_created: z + .object({ + callIndex: z.number().int().nonnegative(), + worktreePath: z.string(), + isolation: z.literal("worktree"), + }) + .strict(), + worktree_finalized: z + .object({ + callIndex: z.number().int().nonnegative(), + worktreePath: z.string().optional(), + dirty: z.boolean(), + removed: z.boolean(), + outcome: z.literal("failure").optional(), + }) + .strict(), +} as const satisfies Record; + +export type WorkflowEventPayloads = { + [K in WorkflowEventType]: z.infer<(typeof workflowEventPayloadSchemas)[K]>; +}; + +export type AppendWorkflowEventInput = { + [P in K]: { + runId: string; + type: P; + phase?: string; + label?: string; + data: WorkflowEventPayloads[P]; + }; +}[K]; + +export function parseWorkflowEventPayload( + type: K, + data: unknown, +): WorkflowEventPayloads[K] { + return workflowEventPayloadSchemas[type].parse(data) as WorkflowEventPayloads[K]; +} + +export type WorkflowProviderId = LocalAgentProvider; diff --git a/src/workflow-engine.test.ts b/src/workflow-engine.test.ts new file mode 100644 index 00000000..fc4bf57d --- /dev/null +++ b/src/workflow-engine.test.ts @@ -0,0 +1,674 @@ +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, WORKFLOW_LIMITS } from "./workflow-types.js"; +import type { LocalAgentProfile } from "./local-agent-profiles.js"; + +// --------------------------------------------------------------------------- +// Semaphore +// --------------------------------------------------------------------------- +{ + const sem = new WorkflowSemaphore(2); + let concurrent = 0; + let maxConcurrent = 0; + await Promise.all( + Array.from({ length: 6 }, async () => { + await sem.acquire(); + concurrent += 1; + maxConcurrent = Math.max(maxConcurrent, concurrent); + await new Promise((r) => setTimeout(r, 20)); + concurrent -= 1; + sem.release(); + }), + ); + assert.equal(maxConcurrent, 2); +} + +// --------------------------------------------------------------------------- +// parallel → null on throw; barrier +// --------------------------------------------------------------------------- +{ + const dir = await mkdtemp(join(tmpdir(), "wf-engine-")); + const store = new WorkflowStore(dir); + const run = store.createRun({ + name: "par", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: dir, + }); + const order: string[] = []; + const api = createWorkflowApi({ + runId: run.id, + journal: store, + meta: { name: "par", description: "d" }, + args: undefined, + concurrency: 4, + signal: new AbortController().signal, + workspaceRoot: dir, + availableProviders: ["codex"], + runProvider: async (input) => { + order.push(`start:${input.prompt}`); + await new Promise((r) => setTimeout(r, 10)); + order.push(`end:${input.prompt}`); + if (input.prompt === "fail") throw new Error("boom"); + return { finalResponse: `ok:${input.prompt}` }; + }, + }); + + const results = await api.parallel([ + () => api.agent("a"), + () => api.agent("fail"), + () => api.agent("b"), + ]); + assert.deepEqual(results, ["ok:a", null, "ok:b"]); + assert.equal(api.getCallCount(), 3); + assert.equal(store.getAgentCall(run.id, 0)?.returnValueJson, JSON.stringify("ok:a")); + assert.equal(store.getAgentCall(run.id, 2)?.returnValueJson, JSON.stringify("ok:b")); + store.close(); + await rm(dir, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// pipeline — no barrier across items (item B can finish stage2 before A stage1 ends) +// --------------------------------------------------------------------------- +{ + const dir = await mkdtemp(join(tmpdir(), "wf-pipe-")); + const store = new WorkflowStore(dir); + const run = store.createRun({ + name: "pipe", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: dir, + }); + const events: string[] = []; + const api = createWorkflowApi({ + runId: run.id, + journal: store, + meta: { name: "pipe", description: "d" }, + args: undefined, + concurrency: 4, + signal: new AbortController().signal, + workspaceRoot: dir, + availableProviders: ["codex"], + runProvider: async () => ({ finalResponse: "x" }), + }); + + const result = await api.pipeline( + ["slow", "fast"], + async (item: unknown) => { + events.push(`s1:${item}:start`); + await new Promise((r) => setTimeout(r, item === "slow" ? 40 : 5)); + events.push(`s1:${item}:end`); + return `${item}-1`; + }, + async (prev: unknown, item: unknown) => { + events.push(`s2:${item}:${prev}`); + return `${prev}-2`; + }, + ); + + assert.deepEqual(result, ["slow-1-2", "fast-1-2"]); + // fast finishes stage1 before slow does + const fastEnd = events.indexOf("s1:fast:end"); + const slowEnd = events.indexOf("s1:slow:end"); + assert.ok(fastEnd >= 0 && slowEnd >= 0 && fastEnd < slowEnd); + // fast may enter stage2 before slow finishes stage1 + const fastS2 = events.indexOf("s2:fast:fast-1"); + assert.ok(fastS2 >= 0 && fastS2 < slowEnd); + + // throw → null for that item + const withNull = await api.pipeline( + [1, 2], + async (n: unknown) => { + if (n === 2) throw new Error("nope"); + return n; + }, + ); + assert.deepEqual(withNull, [1, null]); + + store.close(); + await rm(dir, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// phase ALS — concurrent chains keep separate phases for agent() +// --------------------------------------------------------------------------- +{ + const dir = await mkdtemp(join(tmpdir(), "wf-phase-")); + const store = new WorkflowStore(dir); + const run = store.createRun({ + name: "phase", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: dir, + }); + const seen: Array<{ prompt: string; phase?: string }> = []; + const api = createWorkflowApi({ + runId: run.id, + journal: store, + meta: { name: "phase", description: "d" }, + args: undefined, + concurrency: 4, + signal: new AbortController().signal, + workspaceRoot: dir, + availableProviders: ["codex"], + runProvider: async (input: WorkflowProviderRunInput) => { + seen.push({ prompt: input.prompt, phase: input.phase }); + await new Promise((r) => setTimeout(r, 15)); + return { finalResponse: "ok" }; + }, + }); + + await api.parallel([ + async () => { + api.phase("A"); + assert.equal(getCurrentWorkflowPhase(), "A"); + return api.agent("from-a"); + }, + async () => { + api.phase("B"); + assert.equal(getCurrentWorkflowPhase(), "B"); + return api.agent("from-b"); + }, + ]); + + const a = seen.find((s) => s.prompt === "from-a"); + const b = seen.find((s) => s.prompt === "from-b"); + assert.equal(a?.phase, "A"); + assert.equal(b?.phase, "B"); + + store.close(); + await rm(dir, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// isolation: worktree uses createWorktree path as cwd +// --------------------------------------------------------------------------- +{ + const dir = await mkdtemp(join(tmpdir(), "wf-iso-")); + const store = new WorkflowStore(dir); + const run = store.createRun({ + name: "iso", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: dir, + }); + const worktrees: string[] = []; + const createWorktree: CreateAgentWorktree = async ({ callIndex }) => { + const path = join(dir, `wt-${callIndex}`); + await mkdir(path, { recursive: true }); + worktrees.push(path); + return { + path, + finalize: async () => ({ dirty: false, removed: true }), + }; + }; + const api = createWorkflowApi({ + runId: run.id, + journal: store, + meta: { name: "iso", description: "d" }, + args: undefined, + concurrency: 2, + signal: new AbortController().signal, + workspaceRoot: dir, + availableProviders: ["codex"], + createWorktree, + runProvider: async (input) => { + assert.equal(input.workspace, worktrees[0]); + return { finalResponse: "in-wt" }; + }, + }); + + const out = await api.agent("do", { isolation: "worktree", label: "iso" }); + assert.equal(out, "in-wt"); + const calls = store.listAgentCalls(run.id); + assert.equal(calls[0]?.isolation, "worktree"); + assert.equal(calls[0]?.worktreePath, worktrees[0]); + + store.close(); + await rm(dir, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// worktree setup failure preserves the primary error before journal begin +// --------------------------------------------------------------------------- +{ + const dir = await mkdtemp(join(tmpdir(), "wf-iso-fail-")); + const store = new WorkflowStore(dir); + const run = store.createRun({ + name: "iso-fail", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: dir, + }); + const api = createWorkflowApi({ + runId: run.id, + journal: store, + meta: { name: "iso-fail", description: "d" }, + args: undefined, + concurrency: 1, + signal: new AbortController().signal, + workspaceRoot: dir, + availableProviders: ["codex"], + createWorktree: async () => { + throw new Error("expected worktree setup failure"); + }, + runProvider: async () => ({ finalResponse: "unreachable" }), + }); + + const runIsolated = api.agent as ( + prompt: string, + opts: { isolation: "worktree" }, + ) => Promise; + await assert.rejects( + () => runIsolated("do", { isolation: "worktree" }), + /expected worktree setup failure/, + ); + assert.equal(store.listAgentCalls(run.id).length, 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 +// --------------------------------------------------------------------------- +{ + const dir = await mkdtemp(join(tmpdir(), "wf-prov-")); + const store = new WorkflowStore(dir); + const run = store.createRun({ + name: "prov", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: dir, + }); + const used: string[] = []; + const api = createWorkflowApi({ + runId: run.id, + journal: store, + meta: { name: "prov", description: "d", defaultProvider: "claude" }, + args: undefined, + concurrency: 1, + signal: new AbortController().signal, + workspaceRoot: dir, + availableProviders: ["codex", "claude"], + runProvider: async (input) => { + used.push(input.provider); + return { finalResponse: input.provider }; + }, + }); + assert.equal(await api.agent("x"), "claude"); + assert.equal(await api.agent("y", { provider: "codex" }), "codex"); + await assert.rejects( + async () => api.agent("z", { writeMode: "allowed" } as never), + /writeMode is not supported/, + ); + store.close(); + await rm(dir, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// configured profile selection, defaults, overrides, and prompt instructions +// --------------------------------------------------------------------------- +{ + const dir = await mkdtemp(join(tmpdir(), "wf-profile-")); + const store = new WorkflowStore(dir); + const run = store.createRun({ + name: "profile", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: dir, + }); + const profile: LocalAgentProfile = { + name: "reviewer", + description: "Review changes", + provider: "claude", + model: "sonnet", + effort: "medium", + filePath: join(dir, "reviewer.md"), + body: "Act as an adversarial reviewer.", + disabled: false, + }; + const calls: WorkflowProviderRunInput[] = []; + const api = createWorkflowApi({ + runId: run.id, + journal: store, + meta: { name: "profile", description: "d", defaultProvider: "codex" }, + args: undefined, + concurrency: 1, + signal: new AbortController().signal, + workspaceRoot: dir, + availableProviders: ["codex", "claude"], + agentProfiles: [profile], + runProvider: async (input) => { + calls.push(input); + return { finalResponse: "reviewed" }; + }, + }); + + assert.equal( + await api.agent("Review auth", { + profile: "reviewer", + model: "opus", + effort: "high", + }), + "reviewed", + ); + assert.equal(calls[0]?.provider, "claude"); + assert.equal(calls[0]?.model, "opus"); + assert.equal(calls[0]?.effort, "high"); + assert.equal( + calls[0]?.prompt, + "Act as an adversarial reviewer.\n\nTask:\nReview auth", + ); + assert.equal(store.getAgentCall(run.id, 0)?.profileName, "reviewer"); + assert.equal(store.getAgentCall(run.id, 0)?.profileFingerprint?.length, 64); + + const callAgent = api.agent as (prompt: string, opts?: unknown) => Promise; + await assert.rejects( + () => callAgent("x", { profile: "reviewer", provider: "codex" }), + /mutually exclusive/, + ); + await assert.rejects(() => callAgent("x", { profile: "missing" }), /Unknown agent profile/); + + const unavailableApi = createWorkflowApi({ + runId: run.id, + journal: store, + meta: { name: "profile", description: "d" }, + args: undefined, + concurrency: 1, + signal: new AbortController().signal, + workspaceRoot: dir, + availableProviders: ["codex"], + agentProfiles: [profile], + runProvider: async () => ({ finalResponse: "unreachable" }), + }); + await assert.rejects( + () => + (unavailableApi.agent as (prompt: string, opts?: unknown) => Promise)( + "x", + { profile: "reviewer" }, + ), + /requires unavailable provider claude/, + ); + + store.close(); + await rm(dir, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// schema retry: 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, + availableProviders: ["codex"], + runProvider: async (input) => { + calls.push(input); + if (calls.length === 1) { + return { + finalResponse: '{"n":"bad"}', + structured: { n: "bad" }, + providerSessionId: "sess-1", + }; + } + return { finalResponse: '{"n":2}', providerSessionId: "sess-1" }; + }, + }); + + const out = await api.agent("give n", { + schema: { + type: "object", + properties: { n: { type: "number" } }, + required: ["n"], + }, + }); + assert.deepEqual(out, { n: 2 }); + assert.ok(calls[0]?.schema); + 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, + availableProviders: ["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 }); +} + +// --------------------------------------------------------------------------- +// 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, + availableProviders: ["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 +// --------------------------------------------------------------------------- +{ + const dir = await mkdtemp(join(tmpdir(), "wf-exec-")); + const store = new WorkflowStore(dir); + const run = store.createRun({ + name: "exec", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: dir, + }); + + const childPath = join(dir, "child.js"); + await writeFile( + childPath, + ` +export const meta = { name: 'child', description: 'nested', defaultProvider: 'claude' } +return await agent('nested-prompt') +`, + ); + + const prompts: string[] = []; + const providers: string[] = []; + const { result, callCount } = await executeWorkflow({ + source: ` +export const meta = { name: 'parent', description: 'p', defaultProvider: 'codex' } +const a = await agent('parent-prompt') +const nested = await workflow({ scriptPath: ${JSON.stringify(childPath)} }) +return { a, nested } +`, + runId: run.id, + journal: store, + workspaceRoot: dir, + availableProviders: ["codex", "claude"], + runProvider: async (input) => { + prompts.push(input.prompt); + providers.push(input.provider); + return { finalResponse: `R:${input.prompt}` }; + }, + resolveNestedSource: async (ref) => { + if (typeof ref === "object" && ref.scriptPath) { + const { readFile } = await import("node:fs/promises"); + return readFile(ref.scriptPath, "utf8"); + } + throw new Error("unknown nest ref"); + }, + }); + + assert.deepEqual(result, { + a: "R:parent-prompt", + nested: "R:nested-prompt", + }); + assert.equal(callCount, 2); + assert.deepEqual(prompts, ["parent-prompt", "nested-prompt"]); + assert.deepEqual(providers, ["codex", "claude"]); + + // depth 2 must fail + await assert.rejects( + () => + executeWorkflow({ + source: ` +export const meta = { name: 'deep', description: 'd' } +return await workflow({ scriptPath: ${JSON.stringify(childPath)} }).then(async () => { + // child tries to nest again — child script: + return 1 +}) +`, + runId: run.id, + journal: store, + workspaceRoot: dir, + availableProviders: ["codex"], + runProvider: async () => ({ finalResponse: "x" }), + resolveNestedSource: async () => ` +export const meta = { name: 'mid', description: 'm' } +return await workflow({ scriptPath: 'x' }) +`, + }), + (error: unknown) => + error instanceof WorkflowEngineError && error.kind === "nest_depth", + ); + + store.close(); + await rm(dir, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// cancel via signal +// --------------------------------------------------------------------------- +{ + const dir = await mkdtemp(join(tmpdir(), "wf-cancel-")); + const store = new WorkflowStore(dir); + const run = store.createRun({ + name: "cancel", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: dir, + }); + const ac = new AbortController(); + const api = createWorkflowApi({ + runId: run.id, + journal: store, + meta: { name: "cancel", description: "d" }, + args: undefined, + concurrency: 1, + signal: ac.signal, + workspaceRoot: dir, + availableProviders: ["codex"], + runProvider: async () => { + ac.abort(); + return { finalResponse: "late" }; + }, + }); + // abort before agent + ac.abort(); + await assert.rejects(async () => api.agent("x"), WorkflowEngineError); + store.close(); + await rm(dir, { recursive: true, force: true }); +} + +void createStubBudget; +console.log("workflow-engine.test.ts: ok"); diff --git a/src/workflow-engine.ts b/src/workflow-engine.ts new file mode 100644 index 00000000..72081b53 --- /dev/null +++ b/src/workflow-engine.ts @@ -0,0 +1,171 @@ +import { availableParallelism } from "node:os"; +import type { + LocalAgentProfile, + LocalAgentProvider, +} from "./local-agent-profiles.js"; +import type { JsonValue } from "./json-types.js"; +import { parseWorkflowScript, type ParsedWorkflowScript } from "./workflow-script.js"; +import { runWorkflowSandbox } from "./workflow-sandbox.js"; +import { + createWorkflowApi, + createWorkflowApiRuntime, + type CreateAgentWorktree, + type WorkflowApi, + type WorkflowApiRuntime, + type WorkflowJournal, + type WorkflowReplay, + type WorkflowRunProvider, + WorkflowEngineError, +} from "./workflow-api.js"; +import { + WORKFLOW_HOST_TIMEOUT_MS, + resolveWorkflowConcurrency, + type WorkflowMeta, + type WorkflowErrorKind, +} from "./workflow-types.js"; +import { + isWorkflowOperationError, + workflowErrorKind, +} from "./workflow-errors.js"; + +export interface ExecuteWorkflowOptions { + /** Pre-parsed script, or pass `source` instead. */ + parsed?: ParsedWorkflowScript; + source?: string; + filename?: string; + runId: string; + journal: WorkflowJournal; + args?: JsonValue; + concurrency?: number; + signal?: AbortSignal; + workspaceRoot: string; + baseSha?: string; + availableProviders: LocalAgentProvider[]; + agentProfiles?: LocalAgentProfile[]; + runProvider: WorkflowRunProvider; + createWorktree?: CreateAgentWorktree; + replay?: WorkflowReplay; + resolveNestedSource?: (nameOrRef: string | { scriptPath: string }) => string | Promise; + nestDepth?: number; + timeoutMs?: number; + /** Shared call counter/semaphore for nested workflow execution. */ + runtime?: WorkflowApiRuntime; + /** Optional hooks after API construction (tests). */ + onApi?: (api: WorkflowApi) => void; +} + +export interface ExecuteWorkflowResult { + result: unknown; + meta: WorkflowMeta; + callCount: number; +} + +/** + * Execute one workflow script body (top-level or nested). + * Does not create/claim/complete journal run rows — host/worker owns run lifecycle. + */ +export async function executeWorkflow( + options: ExecuteWorkflowOptions, +): Promise { + const parsed = + options.parsed ?? + parseWorkflowScript(options.source ?? "", { filename: options.filename }); + const nestDepth = options.nestDepth ?? 0; + const signal = options.signal ?? new AbortController().signal; + const concurrency = + options.concurrency ?? + resolveWorkflowConcurrency(parsed.meta.concurrency, availableParallelism()); + + const resolveNestedSource = options.resolveNestedSource; + const runtime = options.runtime ?? createWorkflowApiRuntime(concurrency); + + // Shared callIndex/semaphore for nested scripts via parent API path. + const api = createWorkflowApi({ + runId: options.runId, + journal: options.journal as WorkflowJournal, + meta: parsed.meta, + args: options.args, + concurrency, + signal, + workspaceRoot: options.workspaceRoot, + baseSha: options.baseSha, + availableProviders: options.availableProviders, + agentProfiles: options.agentProfiles, + runProvider: options.runProvider, + createWorktree: options.createWorktree, + replay: options.replay, + runtime, + nestDepth, + resolveNestedSource, + executeNested: resolveNestedSource + ? async (input) => + ( + await executeWorkflow({ + ...options, + parsed: undefined, + source: input.source, + filename: "workflow:nested", + args: input.args, + signal, + concurrency, + runtime, + nestDepth: input.nestDepth, + onApi: undefined, + }) + ).result + : undefined, + }); + options.onApi?.(api); + + if (nestDepth === 0) { + options.journal.appendEvent({ + runId: options.runId, + type: "run_started", + data: { + name: parsed.meta.name, + scriptHash: parsed.scriptHash, + concurrency, + }, + }); + } + + try { + const result = await runWorkflowSandbox({ + parsed, + api, + timeoutMs: options.timeoutMs ?? WORKFLOW_HOST_TIMEOUT_MS, + signal, + }); + return { + result, + meta: parsed.meta, + callCount: api.getCallCount(), + }; + } catch (error) { + if (error instanceof WorkflowEngineError) { + throw error; + } + throw error; + } +} + +export function mapEngineErrorKind(error: unknown): WorkflowErrorKind { + if (error instanceof WorkflowEngineError) { + return error.kind; + } + if (isWorkflowOperationError(error)) { + return workflowErrorKind(error); + } + if (error && typeof error === "object" && "name" in error) { + const name = String((error as { name: string }).name); + if (name === "WorkflowScriptError") { + const kind = (error as { kind?: string }).kind; + if (kind === "meta" || kind === "syntax" || kind === "script_too_large") { + return kind; + } + return "syntax"; + } + if (name === "WorkflowDeterminismError") return "determinism"; + } + return "internal"; +} diff --git a/src/workflow-errors.test.ts b/src/workflow-errors.test.ts new file mode 100644 index 00000000..bbc38590 --- /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 00000000..19e18b2c --- /dev/null +++ b/src/workflow-errors.ts @@ -0,0 +1,361 @@ +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" + | "invalid_path"; + message: string; +}>() {} + +export class WorkflowFileNotFoundError extends TaggedError( + "WorkflowFileNotFoundError", +)<{ + path: string; + message: string; +}>() { + constructor(path: string) { + super({ path, message: `Script file not found: ${path}` }); + } +} + +export class WorkflowFileReadError extends TaggedError( + "WorkflowFileReadError", +)<{ + path: string; + cause: unknown; + message: string; +}>() { + constructor(path: string, cause: unknown) { + super({ + path, + cause, + message: `Unable to read workflow script ${path}: ${errorMessage(cause)}`, + }); + } +} + +export class WorkflowFileWriteError extends TaggedError( + "WorkflowFileWriteError", +)<{ + path: string; + cause: unknown; + message: string; +}>() { + constructor(path: string, cause: unknown) { + super({ + path, + cause, + message: `Unable to persist workflow script ${path}: ${errorMessage(cause)}`, + }); + } +} + +export class NamedWorkflowNotFoundError extends TaggedError( + "NamedWorkflowNotFoundError", +)<{ + name: string; + candidates: string[]; + message: string; +}>() { + constructor(name: string, candidates: string[]) { + super({ + name, + candidates, + message: `Named workflow not found: ${name}. Looked in ${candidates.join(", ")}`, + }); + } +} + +export class WorkflowNotFoundError extends TaggedError( + "WorkflowNotFoundError", +)<{ + runId: string; + message: string; +}>() { + constructor(runId: string) { + super({ runId, message: `Unknown workflow run: ${runId}` }); + } +} + +export class InvalidRunTransitionError extends TaggedError( + "InvalidRunTransitionError", +)<{ + runId: string; + from: WorkflowRunStatus; + operation: "claim" | "complete" | "fail" | "cancel" | "set_script_path"; + message: string; +}>() { + constructor(input: { + runId: string; + from: WorkflowRunStatus; + operation: "claim" | "complete" | "fail" | "cancel" | "set_script_path"; + }) { + super({ + ...input, + message: `Cannot ${input.operation} workflow run ${input.runId} in status ${input.from}`, + }); + } +} + +export class WorkflowStoreError extends TaggedError( + "WorkflowStoreError", +)<{ + operation: string; + cause: unknown; + message: string; +}>() { + constructor(operation: string, cause: unknown) { + super({ + operation, + cause, + message: `Workflow store ${operation} failed: ${errorMessage(cause)}`, + }); + } +} + +export class WorkflowStoredDataError extends TaggedError( + "WorkflowStoredDataError", +)<{ + record: string; + cause: unknown; + message: string; +}>() { + constructor(record: string, cause: unknown) { + super({ + record, + cause, + message: `Stored workflow data is invalid (${record}): ${errorMessage(cause)}`, + }); + } +} + +export class WorktreeOperationError extends TaggedError( + "WorktreeOperationError", +)<{ + operation: "create" | "inspect" | "finalize" | "remove"; + runId?: string; + callIndex?: number; + path?: string; + cause: unknown; + message: string; +}>() { + constructor(input: { + operation: "create" | "inspect" | "finalize" | "remove"; + runId?: string; + callIndex?: number; + path?: string; + cause: unknown; + }) { + super({ + ...input, + message: `Workflow worktree ${input.operation} failed${input.path ? ` at ${input.path}` : ""}: ${errorMessage(input.cause)}`, + }); + } +} + +export interface SchemaIssue { + path: string; + message: string; +} + +export class InvalidAgentJsonError extends TaggedError( + "InvalidAgentJsonError", +)<{ + attempt: number; + mode: "native" | "prompt"; + responseExcerpt: string; + message: string; +}>() { + constructor(input: { + attempt: number; + mode: "native" | "prompt"; + responseExcerpt: string; + }) { + super({ + ...input, + message: `Agent response was not valid JSON on attempt ${input.attempt}`, + }); + } +} + +export class AgentSchemaValidationError extends TaggedError( + "AgentSchemaValidationError", +)<{ + attempt: number; + mode: "native" | "prompt"; + issues: SchemaIssue[]; + message: string; +}>() { + constructor(input: { + attempt: number; + mode: "native" | "prompt"; + issues: SchemaIssue[]; + }) { + super({ + ...input, + message: `Agent response failed schema validation on attempt ${input.attempt}: ${input.issues.map((issue) => `${issue.path} ${issue.message}`).join("; ")}`, + }); + } +} + +export class SchemaConfigurationError extends TaggedError( + "SchemaConfigurationError", +)<{ + cause: unknown; + message: string; +}>() { + constructor(cause: unknown) { + super({ + cause, + message: `Unable to compile agent JSON Schema: ${errorMessage(cause)}`, + }); + } +} + +export type SchemaAttemptError = InvalidAgentJsonError | AgentSchemaValidationError; + +export class SchemaRetriesExhaustedError extends TaggedError( + "SchemaRetriesExhaustedError", +)<{ + attempts: number; + lastFailure: SchemaAttemptError; + message: string; +}>() { + constructor(attempts: number, lastFailure: SchemaAttemptError) { + super({ + attempts, + lastFailure, + message: `Schema validation failed after ${attempts} attempts: ${lastFailure.message}`, + }); + } +} + +export type WorkflowOperationError = + | InvalidWorkflowInputError + | WorkflowFileNotFoundError + | WorkflowFileReadError + | WorkflowFileWriteError + | NamedWorkflowNotFoundError + | WorkflowNotFoundError + | InvalidRunTransitionError + | WorkflowStoreError + | WorkflowStoredDataError + | WorktreeOperationError + | InvalidAgentJsonError + | AgentSchemaValidationError + | SchemaConfigurationError + | SchemaRetriesExhaustedError + | AgentProviderError; + +export function isWorkflowOperationError(error: unknown): error is WorkflowOperationError { + return ( + InvalidWorkflowInputError.is(error) || + WorkflowFileNotFoundError.is(error) || + WorkflowFileReadError.is(error) || + WorkflowFileWriteError.is(error) || + NamedWorkflowNotFoundError.is(error) || + WorkflowNotFoundError.is(error) || + InvalidRunTransitionError.is(error) || + WorkflowStoreError.is(error) || + WorkflowStoredDataError.is(error) || + WorktreeOperationError.is(error) || + InvalidAgentJsonError.is(error) || + AgentSchemaValidationError.is(error) || + SchemaConfigurationError.is(error) || + SchemaRetriesExhaustedError.is(error) || + isAgentProviderError(error) + ); +} + +export function workflowErrorKind(error: WorkflowOperationError): WorkflowErrorKind { + switch (error._tag) { + case "InvalidWorkflowInputError": + case "WorkflowFileNotFoundError": + case "WorkflowFileReadError": + case "WorkflowFileWriteError": + case "NamedWorkflowNotFoundError": + return "path"; + case "WorkflowNotFoundError": + case "InvalidRunTransitionError": + case "WorkflowStoreError": + case "WorkflowStoredDataError": + return "internal"; + case "WorktreeOperationError": + return "worktree"; + case "InvalidAgentJsonError": + case "AgentSchemaValidationError": + case "SchemaConfigurationError": + case "SchemaRetriesExhaustedError": + 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.test.ts b/src/workflow-files.test.ts new file mode 100644 index 00000000..4cd1ecd7 --- /dev/null +++ b/src/workflow-files.test.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + parseWorkflowArgFlags, + persistWorkflowScript, + readProjectWorkflowScriptFile, + 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.replaceAll("\\", "/"), /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/); + 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/, + ); + + 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"), + "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 }), + 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 00000000..5aae5fe5 --- /dev/null +++ b/src/workflow-files.ts @@ -0,0 +1,342 @@ +import { createHash, randomBytes } from "node:crypto"; +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"; +import { jsonValueSchema, type JsonValue } from "./json-types.js"; +import { + InvalidWorkflowInputError, + NamedWorkflowNotFoundError, + WorkflowFileNotFoundError, + WorkflowFileReadError, + WorkflowFileWriteError, +} 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; + scriptHash: string; + nameHint: string; + origin: "file" | "named" | "inline" | "resume"; +} + +export type WorkflowFileResolveError = + | InvalidWorkflowInputError + | NamedWorkflowNotFoundError + | WorkflowFileNotFoundError + | WorkflowFileReadError; + +/** + * Persist script under stateDir for worker re-read / audit. + * Returns absolute path written. + */ +export async function 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; + source: string; + preferredName?: string; +}): Promise> { + const dir = join(input.stateDir, "workflow-scripts", input.runId); + const base = + sanitizeSegment(input.preferredName ?? "script") || + `script-${randomBytes(3).toString("hex")}`; + const path = join(dir, `${base}.js`); + return Result.tryPromise({ + try: async () => { + await mkdir(dir, { recursive: true }); + await writeFile(path, input.source, { encoding: "utf8", mode: 0o600 }); + return path; + }, + catch: (cause) => new WorkflowFileWriteError(path, cause), + }); +} + +export async function 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); + 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), + }); +} + +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` (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; + stateDir?: string; +}): Promise> { + const name = input.name.trim(); + if (!name || name.includes("/") || name.includes("\\") || name.includes("..")) { + return Result.err( + new InvalidWorkflowInputError({ + code: "invalid_name", + message: `Invalid workflow name: ${JSON.stringify(input.name)}`, + }), + ); + } + const candidates = [ + join(input.workspaceRoot, ".devspace", "workflows", `${name}.js`), + ]; + if (input.stateDir) { + candidates.push(join(input.stateDir, "workflows", `${name}.js`)); + } + for (const candidate of candidates) { + const result = await readWorkflowScriptFileResult(candidate); + if (result.isOk()) { + return Result.ok({ ...result.value, nameHint: name, origin: "named" as const }); + } + if (WorkflowFileNotFoundError.is(result.error)) continue; + return result; + } + return Result.err(new NamedWorkflowNotFoundError(name, candidates)); +} + +export async function 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; + workspaceRoot: string; + stateDir?: string; +}): Promise> { + if (input.file && input.name) { + return Result.err( + new InvalidWorkflowInputError({ + code: "ambiguous_source", + message: "Pass only one of --file or --name", + }), + ); + } + if (input.file) { + const path = isAbsolute(input.file) + ? input.file + : resolve(input.workspaceRoot, input.file); + return readWorkflowScriptFileResult(path); + } + if (input.name) { + return resolveNamedWorkflowScriptResult({ + name: input.name, + workspaceRoot: input.workspaceRoot, + stateDir: input.stateDir, + }); + } + return Result.err( + new InvalidWorkflowInputError({ + code: "missing_source", + message: "Provide --file or --name ", + }), + ); +} + +export function 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) { + const token = tokens[i]!; + if (token === "--arg") { + const pair = tokens[++i]; + if (!pair || !pair.includes("=")) { + return Result.err( + new InvalidWorkflowInputError({ + code: "invalid_argument", + message: "--arg requires key=value", + }), + ); + } + const eq = pair.indexOf("="); + const key = pair.slice(0, eq); + const raw = pair.slice(eq + 1); + args[key] = coerceArgValue(raw); + continue; + } + if (token.startsWith("--arg=")) { + const pair = token.slice("--arg=".length); + const eq = pair.indexOf("="); + if (eq < 0) { + return Result.err( + new InvalidWorkflowInputError({ + code: "invalid_argument", + message: "--arg requires key=value", + }), + ); + } + args[pair.slice(0, eq)] = coerceArgValue(pair.slice(eq + 1)); + continue; + } + rest.push(token); + } + return Result.ok({ args, rest }); +} + +function coerceArgValue(raw: string): JsonValue { + try { + return jsonValueSchema.parse(JSON.parse(raw) as unknown); + } catch { + return raw; + } +} + +function sanitizeSegment(value: string): string { + return value + .replace(/[^a-zA-Z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); +} + +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 && + 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-lifecycle.test.ts b/src/workflow-lifecycle.test.ts new file mode 100644 index 00000000..c0d9456c --- /dev/null +++ b/src/workflow-lifecycle.test.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + cancelWorkflowRun, + type WorkflowLifecycleRuntime, +} from "./workflow-lifecycle.js"; +import { WorkflowStore } from "./workflow-store.js"; + +const root = mkdtempSync(join(tmpdir(), "devspace-workflow-lifecycle-test-")); +const store = new WorkflowStore(root); + +try { + { + const run = createRunningRun(store, root, "cooperative", 101); + const signals: NodeJS.Signals[] = []; + let slept = false; + const runtime: WorkflowLifecycleRuntime = { + sleep: async () => { + if (!slept) { + slept = true; + store.cancelRun(run.id, "worker observed cancellation"); + } + }, + terminate: (_pid, signal) => signals.push(signal), + }; + const cancelled = await cancelWorkflowRun(store, run.id, { + graceMs: 100, + pollMs: 1, + runtime, + }); + assert.equal(cancelled.status, "cancelled"); + assert.deepEqual(signals, []); + } + + { + const run = createRunningRun(store, root, "hard", 202); + const signals: NodeJS.Signals[] = []; + const runtime: WorkflowLifecycleRuntime = { + sleep: async () => {}, + terminate: (_pid, signal) => signals.push(signal), + }; + const cancelled = await cancelWorkflowRun(store, run.id, { + graceMs: 0, + termWaitMs: 0, + runtime, + }); + assert.equal(cancelled.status, "cancelled"); + assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]); + assert.equal(store.listEvents(run.id).at(-1)?.type, "run_cancelled"); + } + + { + const run = store.createRun({ + name: "not-claimed", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: root, + }); + const signals: NodeJS.Signals[] = []; + const cancelled = await cancelWorkflowRun(store, run.id, { + graceMs: 0, + termWaitMs: 0, + runtime: { + sleep: async () => {}, + terminate: (_pid, signal) => signals.push(signal), + }, + }); + assert.equal(cancelled.status, "cancelled"); + assert.deepEqual(signals, []); + } + + { + const run = createRunningRun(store, root, "already-done", 303); + store.completeRun(run.id, { callCount: 0 }); + const signals: NodeJS.Signals[] = []; + const completed = await cancelWorkflowRun(store, run.id, { + runtime: { + sleep: async () => {}, + terminate: (_pid, signal) => signals.push(signal), + }, + }); + assert.equal(completed.status, "completed"); + assert.deepEqual(signals, []); + } +} finally { + store.close(); + rmSync(root, { recursive: true, force: true }); +} + +function createRunningRun( + workflowStore: WorkflowStore, + workspaceRoot: string, + name: string, + pid: number, +) { + const run = workflowStore.createRun({ + name, + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot, + }); + return workflowStore.claimRun(run.id, pid)!; +} + +console.log("workflow-lifecycle.test.ts: ok"); diff --git a/src/workflow-lifecycle.ts b/src/workflow-lifecycle.ts new file mode 100644 index 00000000..f66ac7a5 --- /dev/null +++ b/src/workflow-lifecycle.ts @@ -0,0 +1,169 @@ +import type { ServerConfig } from "./config.js"; +import { terminateProcessTree } from "./process-platform.js"; +import { + createWorkflowStore, + type WorkflowStore, +} from "./workflow-store.js"; +import { + WORKFLOW_CANCEL_HARD_MS, + WORKFLOW_HEARTBEAT_MS, + type WorkflowRunRecord, +} from "./workflow-types.js"; + +const DEFAULT_TERM_WAIT_MS = 1_000; +const DEFAULT_POLL_MS = 100; +const DEFAULT_REAPER_INTERVAL_MS = WORKFLOW_HEARTBEAT_MS * 2; +const DEFAULT_STALE_AFTER_MS = WORKFLOW_HEARTBEAT_MS * 3; + +const ACTIVE_STATUSES = new Set(["starting", "running"]); + +export interface WorkflowLifecycleRuntime { + sleep(ms: number): Promise; + terminate(pid: number, signal: NodeJS.Signals): void; +} + +const defaultRuntime: WorkflowLifecycleRuntime = { + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + terminate: (pid, signal) => { + terminateProcessTree( + { + pid, + kill: (requestedSignal = signal) => { + try { + process.kill(pid, requestedSignal); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; + throw error; + } + }, + }, + signal, + true, + ); + }, +}; + +export interface CancelWorkflowRunOptions { + graceMs?: number; + termWaitMs?: number; + pollMs?: number; + runtime?: WorkflowLifecycleRuntime; +} + +/** + * Shared CLI/MCP cancellation path: cooperative flag, grace period, process + * tree termination, then an atomic terminal fallback in the journal. + */ +export async function cancelWorkflowRun( + store: WorkflowStore, + runId: string, + options: CancelWorkflowRunOptions = {}, +): Promise { + const requested = store.requestCancelResult(runId); + if (requested.isErr()) throw requested.error; + if (!isActive(requested.value)) return requested.value; + + const runtime = options.runtime ?? defaultRuntime; + const graceMs = Math.max(0, options.graceMs ?? WORKFLOW_CANCEL_HARD_MS); + const termWaitMs = Math.max(0, options.termWaitMs ?? DEFAULT_TERM_WAIT_MS); + const pollMs = Math.max(1, options.pollMs ?? DEFAULT_POLL_MS); + + const cooperative = await waitForTerminal(store, runId, graceMs, pollMs, runtime); + if (cooperative && !isActive(cooperative)) return cooperative; + + let current = store.getRun(runId); + if (!current) throw new Error(`Unknown workflow run: ${runId}`); + if (!isActive(current)) return current; + + if (current.pid) { + safelyTerminate(runtime, current.pid, "SIGTERM"); + const afterTerm = await waitForTerminal(store, runId, termWaitMs, pollMs, runtime); + if (afterTerm && !isActive(afterTerm)) return afterTerm; + + current = store.getRun(runId) ?? current; + if (isActive(current) && current.pid) { + safelyTerminate(runtime, current.pid, "SIGKILL"); + } + } + + const cancelled = store.cancelRunResult(runId, "cancelled by workflow supervisor"); + if (cancelled.isErr()) throw cancelled.error; + return cancelled.value; +} + +export function reapStaleWorkflows( + store: WorkflowStore, + staleAfterMs = DEFAULT_STALE_AFTER_MS, +): WorkflowRunRecord[] { + return store.reapStale(staleAfterMs); +} + +export interface WorkflowReaperHandle { + close(): void; +} + +export function startWorkflowReaper( + config: ServerConfig, + options: { + intervalMs?: number; + staleAfterMs?: number; + onError?: (error: unknown) => void; + } = {}, +): WorkflowReaperHandle { + const intervalMs = Math.max(1, options.intervalMs ?? DEFAULT_REAPER_INTERVAL_MS); + const staleAfterMs = Math.max(1, options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS); + + const tick = (): void => { + let store: WorkflowStore | undefined; + try { + store = createWorkflowStore(config); + reapStaleWorkflows(store, staleAfterMs); + } catch (error) { + options.onError?.(error); + } finally { + store?.close(); + } + }; + + tick(); + const timer = setInterval(tick, intervalMs); + timer.unref(); + return { + close(): void { + clearInterval(timer); + }, + }; +} + +async function waitForTerminal( + store: WorkflowStore, + runId: string, + waitMs: number, + pollMs: number, + runtime: WorkflowLifecycleRuntime, +): Promise { + const deadline = Date.now() + waitMs; + let current = store.getRun(runId); + while (current && isActive(current) && Date.now() < deadline) { + await runtime.sleep(Math.min(pollMs, Math.max(1, deadline - Date.now()))); + current = store.getRun(runId); + } + return current; +} + +function safelyTerminate( + runtime: WorkflowLifecycleRuntime, + pid: number, + signal: NodeJS.Signals, +): void { + try { + runtime.terminate(pid, signal); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } +} + +function isActive(run: WorkflowRunRecord): boolean { + return ACTIVE_STATUSES.has(run.status); +} diff --git a/src/workflow-replay.test.ts b/src/workflow-replay.test.ts new file mode 100644 index 00000000..7fe518eb --- /dev/null +++ b/src/workflow-replay.test.ts @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import { createWorkflowReplay } from "./workflow-replay.js"; +import type { WorkflowAgentCallRecord } from "./workflow-types.js"; + +function call( + partial: Partial & + Pick, +): WorkflowAgentCallRecord { + return { + runId: "wfr_prior", + prompt: "prompt", + provider: "codex", + status: "completed", + fromCache: false, + isolation: "shared", + createdAt: "t", + updatedAt: "t", + returnValueJson: JSON.stringify(`result-${partial.callIndex}`), + ...partial, + }; +} + +function identity( + prompt = "prompt", + profile: { name: string | null; fingerprint: string | null } = { + name: null, + fingerprint: null, + }, +) { + return { + prompt, + profileName: profile.name, + profileFingerprint: profile.fingerprint, + provider: "codex" as const, + model: null, + effort: null, + schema: null, + isolation: "shared" as const, + }; +} + +{ + const replay = createWorkflowReplay([ + call({ + callIndex: 0, + cacheKey: "profile", + profileName: "reviewer", + profileFingerprint: "fp-1", + }), + ]); + const miss = replay.decide( + 0, + "profile-name-changed", + identity("prompt", { name: "implementer", fingerprint: "fp-1" }), + ).miss; + assert.equal(miss?.reason, "identity_changed"); + assert.deepEqual(miss?.changedFields, ["profileName"]); +} + +{ + const replay = createWorkflowReplay([ + call({ + callIndex: 0, + cacheKey: "profile", + profileName: "reviewer", + profileFingerprint: "fp-1", + }), + ]); + const miss = replay.decide( + 0, + "profile-fingerprint-changed", + identity("prompt", { name: "reviewer", fingerprint: "fp-2" }), + ).miss; + assert.equal(miss?.reason, "identity_changed"); + assert.deepEqual(miss?.changedFields, ["profileFingerprint"]); +} + +{ + const replay = createWorkflowReplay([ + call({ callIndex: 0, cacheKey: "k0", returnValueJson: JSON.stringify("a") }), + call({ callIndex: 1, cacheKey: "k1", returnValueJson: JSON.stringify("b") }), + ]); + assert.equal(replay.decide(0, "k0", identity()).hit?.value, "a"); + assert.equal(replay.decide(1, "k1", identity()).hit?.value, "b"); + assert.equal(replay.decide(2, "k2", identity()).miss?.reason, "no_compatible_call"); +} + +{ + const replay = createWorkflowReplay([ + call({ callIndex: 0, cacheKey: "ka", returnValueJson: JSON.stringify("A") }), + call({ callIndex: 1, cacheKey: "kb", returnValueJson: JSON.stringify("B") }), + ]); + const changed = replay.decide(0, "kb", identity()).miss; + assert.equal(changed?.reason, "identity_changed"); + assert.equal(replay.decide(1, "kb", identity()).miss?.reason, "prefix_diverged"); +} + +{ + const replay = createWorkflowReplay([ + call({ + callIndex: 0, + cacheKey: "ks", + responseText: "bounded preview", + structuredJson: '{"ok":true}', + returnValueJson: '{"ok":true,"text":"exact"}', + }), + ]); + assert.deepEqual(replay.decide(0, "ks", identity()).hit?.value, { + ok: true, + text: "exact", + }); +} + +{ + const replay = createWorkflowReplay([ + call({ callIndex: 0, cacheKey: "old", prompt: "old prompt" }), + call({ callIndex: 1, cacheKey: "later" }), + ]); + const miss = replay.decide(0, "new", identity("new prompt")).miss; + assert.equal(miss?.reason, "identity_changed"); + assert.deepEqual(miss?.changedFields, ["prompt"]); + assert.equal(replay.decide(1, "later", identity()).miss?.reason, "prefix_diverged"); +} + +{ + const replay = createWorkflowReplay([ + call({ callIndex: 0, cacheKey: "worktree", isolation: "worktree" }), + call({ callIndex: 1, cacheKey: "later" }), + ]); + assert.equal( + replay.decide(0, "worktree", { ...identity(), isolation: "worktree" }).miss?.reason, + "worktree_not_restored", + ); + assert.equal(replay.decide(1, "later", identity()).miss?.reason, "prefix_diverged"); +} + +{ + const replay = createWorkflowReplay([ + call({ callIndex: 0, cacheKey: "legacy", returnValueJson: undefined }), + ]); + assert.equal(replay.decide(0, "legacy", identity()).miss?.reason, "result_not_persisted"); +} + +{ + const replay = createWorkflowReplay([ + call({ callIndex: 0, cacheKey: "corrupt", returnValueJson: "{" }), + ]); + assert.equal(replay.decide(0, "corrupt", identity()).miss?.reason, "stored_result_invalid"); +} + +console.log("workflow-replay.test.ts: ok"); diff --git a/src/workflow-replay.ts b/src/workflow-replay.ts new file mode 100644 index 00000000..ffbc8eb9 --- /dev/null +++ b/src/workflow-replay.ts @@ -0,0 +1,110 @@ +import type { AgentCacheKeyInput, WorkflowAgentCallRecord } from "./workflow-types.js"; +import type { + WorkflowReplay, + WorkflowReplayDecision, + WorkflowReplayHit, +} from "./workflow-api.js"; +import { parseJsonText } from "./json-types.js"; + +/** + * Deterministic prefix replay inspired by Claude Code dynamic workflows. + * Calls are reused only while the new execution matches the prior execution at + * the same call index. The first mismatch closes replay for the remainder of + * the run, even when a later cache key happens to match. + */ +export function createWorkflowReplay( + priorCalls: WorkflowAgentCallRecord[], +): WorkflowReplay { + const byIndex = new Map(priorCalls.map((call) => [call.callIndex, call])); + let prefixOpen = true; + + return { + decide( + callIndex: number, + cacheKey: string, + input: AgentCacheKeyInput, + ): WorkflowReplayDecision { + if (!prefixOpen) return { miss: { reason: "prefix_diverged" } }; + + const prior = byIndex.get(callIndex); + if (!prior) return close({ miss: { reason: "no_compatible_call" } }); + if (prior.status !== "completed" && prior.status !== "from_cache") { + return close({ miss: { reason: "prior_call_not_replayable" } }); + } + if (prior.isolation === "worktree") { + return close({ miss: { reason: "worktree_not_restored" } }); + } + if (prior.cacheKey !== cacheKey) { + return close({ + miss: { + reason: "identity_changed", + changedFields: changedIdentityFields(prior, input), + }, + }); + } + if (!prior.returnValueJson) { + return close({ miss: { reason: "result_not_persisted" } }); + } + + try { + return { + hit: toHit(prior, parseJsonText(prior.returnValueJson)), + }; + } catch { + return close({ miss: { reason: "stored_result_invalid" } }); + } + }, + }; + + function close(decision: WorkflowReplayDecision): WorkflowReplayDecision { + prefixOpen = false; + return decision; + } +} + +function toHit( + call: WorkflowAgentCallRecord, + value: WorkflowReplayHit["value"], +): WorkflowReplayHit { + return { + value, + responseText: call.responseText, + structuredJson: call.structuredJson, + returnValueJson: call.returnValueJson!, + providerSessionId: call.providerSessionId, + replayMatch: "same_index", + replayedFromRunId: call.runId, + replayedFromCallIndex: call.callIndex, + }; +} + +function changedIdentityFields( + prior: WorkflowAgentCallRecord, + current: AgentCacheKeyInput, +): Array { + const changed: Array = []; + if (prior.prompt !== current.prompt) changed.push("prompt"); + if ((prior.profileName ?? null) !== current.profileName) changed.push("profileName"); + if ((prior.profileFingerprint ?? null) !== current.profileFingerprint) { + changed.push("profileFingerprint"); + } + if (prior.provider !== current.provider) changed.push("provider"); + if ((prior.model ?? null) !== current.model) changed.push("model"); + if ((prior.effort ?? null) !== current.effort) changed.push("effort"); + if (!schemasMatch(prior.schemaJson, current.schema)) changed.push("schema"); + if (prior.isolation !== current.isolation) changed.push("isolation"); + return changed.length > 0 ? changed : ["prompt"]; +} + +function schemasMatch( + priorSchemaJson: string | undefined, + currentSchema: AgentCacheKeyInput["schema"], +): boolean { + try { + const prior = priorSchemaJson ? JSON.stringify(parseJsonText(priorSchemaJson)) : null; + const current = currentSchema === null ? null : JSON.stringify(currentSchema); + return prior === current; + } catch { + return false; + } +} diff --git a/src/workflow-sandbox-child.ts b/src/workflow-sandbox-child.ts new file mode 100644 index 00000000..f26c4de1 --- /dev/null +++ b/src/workflow-sandbox-child.ts @@ -0,0 +1,292 @@ +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; +} + +interface BridgeSuccessEnvelope { + ok: true; + value?: unknown; +} + +interface BridgeErrorEnvelope { + ok: false; + error: SerializedError; +} + +type BridgeEnvelope = BridgeSuccessEnvelope | BridgeErrorEnvelope; + +let nextCallId = 1; +const pending = new Map< + number, + { resolve(value: string): void } +>(); + +process.on("message", (message: StartMessage | CallResultMessage) => { + if (message.type === "call_result") { + const waiter = pending.get(message.id); + if (!waiter) return; + pending.delete(message.id); + const envelope: BridgeEnvelope = message.error + ? { ok: false, error: message.error } + : { ok: true, value: message.value }; + waiter.resolve(JSON.stringify(envelope)); + return; + } + void execute(message); +}); + +async function execute(message: StartMessage): Promise { + try { + const parsed = parseWorkflowScript(message.source, { filename: message.filename }); + const bridge = (method: SandboxMethod, args: unknown[]): unknown => { + if (method === "phase" || method === "log") { + process.send?.({ type: "notify", method, args }); + return undefined; + } + const id = nextCallId; + nextCallId += 1; + process.send?.({ type: "call", id, method, args }); + return new Promise((resolve) => { + pending.set(id, { resolve }); + }); + }; + + const context = vm.createContext({ __workflowBridge: bridge }); + installContextApi(context, message); + const factory = parsed.script.runInContext(context, { + timeout: 5_000, + displayErrors: true, + }) as () => Promise; + if (typeof factory !== "function") { + throw new Error("Workflow script did not compile to a function"); + } + const value = await factory(); + process.send?.({ type: "result", value }, () => disconnect()); + } catch (error) { + process.send?.({ type: "error", error: serializeError(error) }, () => disconnect()); + } +} + +function installContextApi(context: vm.Context, message: StartMessage): void { + const bootstrap = `(() => { + const bridge = globalThis.__workflowBridge; + delete globalThis.__workflowBridge; + + class WorkflowDeterminismError extends Error { + constructor(message) { + super(message); + this.name = "WorkflowDeterminismError"; + } + } + + class WorkflowEngineError extends Error { + constructor(kind, message) { + super(message); + this.name = "WorkflowEngineError"; + this.kind = kind; + } + } + + Object.defineProperty(Math, "random", { + configurable: false, + writable: false, + value() { + throw new WorkflowDeterminismError("Math.random() is banned in workflow scripts"); + }, + }); + + const RealDate = Date; + function DateShim(...dateArgs) { + if (!new.target) { + throw new WorkflowDeterminismError("Date() is banned in workflow scripts"); + } + if (dateArgs.length === 0) { + throw new WorkflowDeterminismError( + "new Date() without arguments is banned in workflow scripts (pass an ISO string)", + ); + } + return Reflect.construct(RealDate, dateArgs, DateShim); + } + DateShim.now = () => { + throw new WorkflowDeterminismError("Date.now() is banned in workflow scripts"); + }; + DateShim.parse = RealDate.parse.bind(RealDate); + DateShim.UTC = RealDate.UTC.bind(RealDate); + DateShim.prototype = Object.create(RealDate.prototype, { + constructor: { + value: DateShim, + writable: false, + configurable: false, + }, + }); + Object.freeze(DateShim.prototype); + Object.freeze(DateShim); + + const rehydrateError = (input) => { + const error = input?.name === "WorkflowDeterminismError" + ? new WorkflowDeterminismError(input.message) + : input?.name === "WorkflowEngineError" && typeof input.kind === "string" + ? new WorkflowEngineError(input.kind, input.message) + : new Error(input?.message ?? "Workflow bridge call failed"); + if (typeof input?.name === "string") error.name = input.name; + if (typeof input?.stack === "string") error.stack = input.stack; + return error; + }; + const 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"); + } + 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 disconnect(): void { + if (process.connected) process.disconnect?.(); +} diff --git a/src/workflow-sandbox.test.ts b/src/workflow-sandbox.test.ts new file mode 100644 index 00000000..35f7de27 --- /dev/null +++ b/src/workflow-sandbox.test.ts @@ -0,0 +1,229 @@ +import assert from "node:assert/strict"; +import { parseWorkflowScript } from "./workflow-script.js"; +import { WorkflowEngineError } from "./workflow-api.js"; +import { + createStubBudget, + type WorkflowMeta, +} from "./workflow-types.js"; +import type { WorkflowSandboxApi } from "./workflow-sandbox.js"; +import { runWorkflowSandbox, WorkflowDeterminismError } from "./workflow-sandbox.js"; + +function api(meta: WorkflowMeta, logs?: string[]): WorkflowSandboxApi { + 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, + } as unknown as WorkflowSandboxApi; +} + +{ + const logs: string[] = []; + const parsed = parseWorkflowScript(` +export const meta = { name: 'console-test', description: 'd' } +console.log('a', { b: 1 }) +console.warn('w') +return 'ok' +`); + const result = await runWorkflowSandbox({ parsed, api: api(parsed.meta, logs) }); + assert.equal(result, "ok"); + assert.equal(logs[0], 'a {"b":1}'); + assert.equal(logs[1], "w"); +} + +{ + const parsed = parseWorkflowScript(` +export const meta = { name: 'math-abs-ok', description: 'd' } +return Math.abs(-3) +`); + const abs = await runWorkflowSandbox({ parsed, api: api(parsed.meta) }); + assert.equal(abs, 3); +} + +{ + await assert.rejects( + () => + runWorkflowSandbox({ + parsed: parseWorkflowScript(` +export const meta = { name: 'fetch-ban', description: 'd' } +return fetch('https://example.com') +`), + api: api({ name: "fetch-ban", description: "d" }), + }), + /fetch is not defined|ReferenceError/, + ); +} + +{ + const parsed = parseWorkflowScript(` +export const meta = { name: 'budget', description: 'd' } +return { total: budget.total, spent: budget.spent(), remaining: budget.remaining() } +`); + const budgetResult = await runWorkflowSandbox({ parsed, api: api(parsed.meta) }); + assert.deepEqual(budgetResult, { total: null, spent: 0, remaining: Infinity }); +} + +{ + await assert.rejects( + () => + runWorkflowSandbox({ + parsed: parseWorkflowScript(` +export const meta = { name: 'rnd', description: 'd' } +return Math.random() +`), + api: api({ name: "rnd", description: "d" }), + }), + (error: unknown) => + error instanceof WorkflowDeterminismError && /Math\.random/.test(error.message), + ); +} + +{ + const started = Date.now(); + await assert.rejects( + () => + runWorkflowSandbox({ + parsed: parseWorkflowScript(` +export const meta = { name: 'sync-loop', description: 'd' } +while (true) {} +`), + api: api({ name: "sync-loop", description: "d" }), + timeoutMs: 100, + }), + /exceeded host timeout/, + ); + assert.ok(Date.now() - started < 5_000, "synchronous loop should be externally terminated"); + + const followup = parseWorkflowScript(` +export const meta = { name: 'after-loop', description: 'd' } +return 'still-alive' +`); + assert.equal( + await runWorkflowSandbox({ parsed: followup, api: api(followup.meta) }), + "still-alive", + ); +} + +{ + const controller = new AbortController(); + const running = runWorkflowSandbox({ + parsed: parseWorkflowScript(` +export const meta = { name: 'abort-loop', description: 'd' } +while (true) {} +`), + api: api({ name: "abort-loop", description: "d" }), + signal: controller.signal, + }); + setTimeout(() => controller.abort(), 50); + await assert.rejects( + () => running, + (error: unknown) => + error instanceof WorkflowEngineError && error.kind === "cancelled", + ); +} + +{ + await assert.rejects( + () => + runWorkflowSandbox({ + parsed: parseWorkflowScript(` +export const meta = { name: 'date-constructor-ban', description: 'd' } +return new Date(0).constructor.now() +`), + api: api({ name: "date-constructor-ban", description: "d" }), + }), + (error: unknown) => + error instanceof WorkflowDeterminismError && /Date\.now/.test(error.message), + ); +} + +{ + await assert.rejects( + () => + runWorkflowSandbox({ + parsed: parseWorkflowScript(` +export const meta = { name: 'constructor-escape', description: 'd' } +return Object.constructor('return process.version')() +`), + api: api({ name: "constructor-escape", description: "d" }), + }), + /process is not defined/, + ); +} + +{ + await assert.rejects( + () => + runWorkflowSandbox({ + parsed: parseWorkflowScript(` +export const meta = { name: 'promise-realm-escape', description: 'd' } +const pending = agent('x') +return pending.constructor.constructor('return process')() +`), + api: api({ name: "promise-realm-escape", description: "d" }), + }), + /process is not defined/, + ); +} + +{ + const hostApi = api({ name: "result-realm-escape", description: "d" }); + hostApi.agent = async () => ({ ok: true }) as never; + await assert.rejects( + () => + runWorkflowSandbox({ + parsed: parseWorkflowScript(` +export const meta = { name: 'result-realm-escape', description: 'd' } +const value = await agent('x') +return value.constructor.constructor('return process')() +`), + api: hostApi, + }), + /process is not defined/, + ); +} + +{ + const hostApi = api({ name: "error-realm-escape", description: "d" }); + hostApi.agent = async () => { + throw new WorkflowEngineError("internal", "boom"); + }; + await assert.rejects( + () => + runWorkflowSandbox({ + parsed: parseWorkflowScript(` +export const meta = { name: 'error-realm-escape', description: 'd' } +try { + await agent('x') +} catch (error) { + return error.constructor.constructor('return process')() +} +`), + api: hostApi, + }), + /process is not defined/, + ); +} + +{ + await assert.rejects( + () => + runWorkflowSandbox({ + parsed: parseWorkflowScript(` +export const meta = { name: 'api-constructor-escape', description: 'd' } +return agent.constructor('return process.version')() +`), + api: api({ name: "api-constructor-escape", description: "d" }), + }), + /process is not defined/, + ); +} + +console.log("workflow-sandbox.test.ts: ok"); diff --git a/src/workflow-sandbox.ts b/src/workflow-sandbox.ts new file mode 100644 index 00000000..3395fbd1 --- /dev/null +++ b/src/workflow-sandbox.ts @@ -0,0 +1,273 @@ +import { fork, type ChildProcess } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { WorkflowEngineError } from "./workflow-api.js"; +import type { ParsedWorkflowScript } from "./workflow-script.js"; +import type { JsonValue } from "./json-types.js"; +import type { + WorkflowAgent, + WorkflowBudget, + WorkflowMeta, + WorkflowNested, + WorkflowParallel, + WorkflowPipeline, +} from "./workflow-types.js"; + +export class WorkflowDeterminismError extends Error { + constructor(message: string) { + super(message); + this.name = "WorkflowDeterminismError"; + } +} + +export interface WorkflowSandboxApi { + agent: WorkflowAgent; + parallel: WorkflowParallel; + pipeline: WorkflowPipeline; + phase: (title: string) => void; + log: (...args: unknown[]) => unknown; + args: JsonValue | undefined; + budget: WorkflowBudget; + workflow: WorkflowNested; + /** Host bookkeeping only; script binds its own `const meta`. */ + meta: WorkflowMeta; +} + +export interface RunWorkflowSandboxOptions { + parsed: ParsedWorkflowScript; + api: WorkflowSandboxApi; + /** Host wall-clock max for the whole script (ms). Default 6h. */ + timeoutMs?: number; + signal?: AbortSignal; +} + +type SandboxMethod = "agent" | "workflow" | "phase" | "log"; + +interface SandboxStartMessage { + type: "start"; + source: string; + filename: string; + args: JsonValue | undefined; + budget: { + total: number | null; + spent: number; + remaining: number; + }; +} + +interface SandboxCallMessage { + type: "call"; + id: number; + method: Extract; + args: unknown[]; +} + +interface SandboxNotifyMessage { + type: "notify"; + method: Extract; + args: unknown[]; +} + +interface SandboxResultMessage { + type: "result"; + value: unknown; +} + +interface SandboxErrorMessage { + type: "error"; + error: SerializedError; +} + +interface SandboxCallResultMessage { + type: "call_result"; + id: number; + value?: unknown; + error?: SerializedError; +} + +interface SerializedError { + name: string; + message: string; + stack?: string; + kind?: string; +} + +type MessageFromChild = + | SandboxCallMessage + | SandboxNotifyMessage + | SandboxResultMessage + | SandboxErrorMessage; + +/** + * Execute a workflow in a disposable child process. The child owns the vm + * context and can be terminated even when model-authored JavaScript blocks its + * event loop with synchronous code. + */ +export async function runWorkflowSandbox( + options: RunWorkflowSandboxOptions, +): Promise { + const timeoutMs = options.timeoutMs ?? 6 * 60 * 60 * 1000; + const child = spawnSandboxChild(); + + return new Promise((resolve, reject) => { + let settled = false; + + const finish = (outcome: { value: unknown } | { error: unknown }): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + options.signal?.removeEventListener("abort", onAbort); + child.removeAllListeners(); + if (child.connected) child.disconnect(); + if (!child.killed && child.exitCode === null) child.kill("SIGKILL"); + if ("error" in outcome) reject(outcome.error); + else resolve(outcome.value); + }; + + const terminate = (error: Error): void => { + finish({ error }); + }; + + const onAbort = (): void => { + terminate(new WorkflowEngineError("cancelled", "Workflow cancelled")); + }; + + const timer = setTimeout(() => { + terminate(new Error(`Workflow script exceeded host timeout (${timeoutMs}ms)`)); + }, timeoutMs); + timer.unref?.(); + + if (options.signal?.aborted) { + onAbort(); + return; + } + options.signal?.addEventListener("abort", onAbort, { once: true }); + + child.on("message", (message: MessageFromChild) => { + void handleChildMessage(child, options.api, message, finish); + }); + child.once("error", (error) => finish({ error })); + child.once("exit", (code, signal) => { + if (settled) return; + finish({ + error: new Error( + `Workflow sandbox exited before returning a result (code=${String(code)}, signal=${String(signal)})`, + ), + }); + }); + + const start: SandboxStartMessage = { + type: "start", + source: options.parsed.source, + filename: options.parsed.filename, + args: options.api.args, + budget: { + total: options.api.budget.total, + spent: options.api.budget.spent(), + remaining: options.api.budget.remaining(), + }, + }; + safeSend(child, start); + }); +} + +async function handleChildMessage( + child: ChildProcess, + api: WorkflowSandboxApi, + message: MessageFromChild, + finish: (outcome: { value: unknown } | { error: unknown }) => void, +): Promise { + switch (message.type) { + case "result": + finish({ value: message.value }); + return; + case "error": + finish({ error: deserializeError(message.error) }); + return; + case "notify": + try { + if (message.method === "phase") { + api.phase(message.args[0] as string); + } else { + api.log(...message.args); + } + } catch (error) { + if (!child.killed) child.kill("SIGKILL"); + finish({ error }); + } + return; + case "call": { + const reply: SandboxCallResultMessage = { + type: "call_result", + id: message.id, + }; + try { + reply.value = message.method === "agent" + ? await api.agent(message.args[0] as string, message.args[1] as never) + : await api.workflow(message.args[0] as never, message.args[1] as never); + } catch (error) { + reply.error = serializeError(error); + } + safeSend(child, reply); + return; + } + } +} + +function spawnSandboxChild(): ChildProcess { + const selfUrl = import.meta.url; + const childUrl = selfUrl.replace( + /workflow-sandbox\.(ts|js)$/, + "workflow-sandbox-child.$1", + ); + if (childUrl === selfUrl) { + throw new Error(`Unable to resolve workflow sandbox child entry from ${selfUrl}`); + } + const childEntry = fileURLToPath(childUrl); + return fork(childEntry, [], { + execArgv: process.execArgv, + stdio: ["ignore", "ignore", "ignore", "ipc"], + serialization: "advanced", + env: { + NODE_ENV: process.env.NODE_ENV ?? "production", + }, + }); +} + +function safeSend(child: ChildProcess, message: object): void { + if (!child.connected) return; + try { + child.send(message, () => { + // The sandbox may close while an agent call is completing. + }); + } catch { + // The child is already being torn down. + } +} + +function serializeError(error: unknown): SerializedError { + if (!(error instanceof Error)) { + return { name: "Error", message: String(error) }; + } + const kind = "kind" in error && typeof error.kind === "string" ? error.kind : undefined; + return { + name: error.name, + message: error.message, + stack: error.stack, + kind, + }; +} + +function deserializeError(input: SerializedError): Error { + const error = input.name === "WorkflowDeterminismError" + ? new WorkflowDeterminismError(input.message) + : input.name === "WorkflowEngineError" && input.kind + ? new WorkflowEngineError( + input.kind as ConstructorParameters[0], + input.message, + ) + : new Error(input.message); + error.name = input.name; + if (input.stack) error.stack = input.stack; + if (input.kind) Object.assign(error, { kind: input.kind }); + return error; +} diff --git a/src/workflow-schema.test.ts b/src/workflow-schema.test.ts new file mode 100644 index 00000000..a05b2dc6 --- /dev/null +++ b/src/workflow-schema.test.ts @@ -0,0 +1,241 @@ +import assert from "node:assert/strict"; +import { + augmentPromptForSchema, + enforceAgentSchema, + formatAjvErrors, +} 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", { + 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", +); + +assert.ok(supportsNativeStructuredOutput("codex")); +assert.ok(supportsNativeStructuredOutput("claude")); +assert.ok(!supportsNativeStructuredOutput("opencode")); + +{ + let attempts = 0; + const result = await enforceAgentSchema({ + schema: { + type: "object", + properties: { n: { type: "number" } }, + required: ["n"], + additionalProperties: false, + }, + prompt: "give n", + provider: "opencode", + 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"); + assert.equal(result.mode, "prompt"); +} + +{ + await assert.rejects( + () => + enforceAgentSchema({ + schema: { type: "object", properties: { n: { type: "number" } }, required: ["n"] }, + prompt: "x", + provider: "opencode", + maxRetries: 1, + run: async () => ({ finalResponse: "not json" }), + }), + (error: unknown) => + error instanceof WorkflowEngineError && error.kind === "schema", + ); +} + +// Native provider: structured on attempt 0 → single attempt, raw prompt. +{ + const seen: Array<{ prompt: string; mode?: string; providerSessionId?: 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; + providerSessionId?: 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, + providerSessionId: opts.providerSessionId, + }); + if (opts.mode === "native") { + return { + finalResponse: '{"n":"bad"}', + structured: { n: "bad" }, + providerSessionId: "sess-native", + }; + } + 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.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", + provider: "opencode", + maxRetries: 0, + onRetry: ({ attempt }) => retries.push(attempt), + run: async () => ({ finalResponse: "not json" }), + }), + ); + assert.deepEqual(retries, []); +} + +// 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); + 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 new file mode 100644 index 00000000..0249c2dd --- /dev/null +++ b/src/workflow-schema.ts @@ -0,0 +1,296 @@ +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 { + 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 { + jsonValueSchema, + type JsonSchema, + type JsonValue, +} from "./json-types.js"; +import { + AgentSchemaValidationError, + InvalidAgentJsonError, + SchemaConfigurationError, + SchemaRetriesExhaustedError, + type SchemaAttemptError, +} from "./workflow-errors.js"; + +const require = createRequire(import.meta.url); + +type AjvLike = new (opts?: object) => { + compile: (schema: JsonSchema) => ((data: unknown) => boolean) & { + errors?: Array<{ instancePath?: string; message?: string }> | null; + }; +}; + +function loadAjv(): AjvLike { + // Prefer direct package; fall back to transitive install under zod or package-lock. + try { + return require("ajv").default ?? require("ajv"); + } catch { + throw new WorkflowEngineError( + "schema", + "ajv is required for opts.schema (add dependency ajv)", + ); + } +} + +export type SchemaEnforceMode = "native" | "prompt"; + +export interface EnforceSchemaInput { + schema: JsonSchema; + prompt: string; + /** + * Provider id for native-vs-prompt policy. When in NATIVE_SCHEMA_PROVIDERS, + * attempt 0 uses raw prompt + native structured path; later attempts repair via prompt. + */ + provider: LocalAgentProvider; + run: ( + prompt: string, + opts: { + mode: SchemaEnforceMode; + providerSessionId?: string; + }, + ) => Promise; + onRetry?: (info: { + attempt: number; + errors: string; + mode: SchemaEnforceMode; + }) => void; + maxRetries?: number; +} + +export interface EnforceSchemaResult { + value: JsonValue; + finalResponse: string; + providerSessionId?: string; + attempts: number; + mode: SchemaEnforceMode; +} + +export type EnforceSchemaError = + | AgentProviderError + | SchemaConfigurationError + | SchemaRetriesExhaustedError; + +/** + * Native-first for codex/claude; otherwise prompt+extract+Ajv. Always Ajv-validate. + * Retries ≤ WORKFLOW_MAX_SCHEMA_RETRIES after the first attempt. + */ +export async function enforceAgentSchema( + input: EnforceSchemaInput, +): Promise { + const result = await enforceAgentSchemaResult(input); + if (result.isOk()) return result.value; + if ( + SchemaConfigurationError.is(result.error) || + SchemaRetriesExhaustedError.is(result.error) + ) { + throw new WorkflowEngineError("schema", result.error.message); + } + throw result.error; +} + +export async function enforceAgentSchemaResult( + input: EnforceSchemaInput, +): Promise> { + const compiled = Result.try({ + try: () => { + const Ajv = loadAjv(); + const ajv = new Ajv({ allErrors: true, strict: false }); + return ajv.compile(input.schema); + }, + catch: (cause) => new SchemaConfigurationError(cause), + }); + if (compiled.isErr()) return compiled; + const validate = compiled.value; + const maxRetries = input.maxRetries ?? WORKFLOW_MAX_SCHEMA_RETRIES; + const native = supportsNativeStructuredOutput(input.provider); + const basePrompt = augmentPromptForSchema(input.prompt, input.schema); + + let lastFailure: SchemaAttemptError | undefined; + let providerSessionId: string | undefined; + + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + const mode: SchemaEnforceMode = native && attempt === 0 ? "native" : "prompt"; + + const prompt = + mode === "native" + ? input.prompt + : attempt === 0 + ? basePrompt + : `${basePrompt}\n\nPrevious JSON failed validation:\n${lastFailure?.message ?? "unknown validation error"}\nReturn only corrected JSON.`; + + const runResult = await Result.tryPromise({ + try: () => input.run(prompt, { mode, providerSessionId }), + catch: (cause) => 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; + } + return runResult; + } + const result = runResult.value; + providerSessionId = result.providerSessionId ?? providerSessionId; + + const candidates = structuredCandidates(result); + if (candidates.length === 0) { + lastFailure = new InvalidAgentJsonError({ + attempt: attempt + 1, + mode, + responseExcerpt: result.finalResponse.slice(0, 500), + }); + if (attempt < maxRetries) { + input.onRetry?.({ attempt: attempt + 1, errors: lastFailure.message, mode }); + } + continue; + } + + for (const candidate of candidates) { + const ok = validate(candidate); + if (ok) { + return Result.ok({ + value: candidate, + finalResponse: result.finalResponse, + providerSessionId, + attempts: attempt + 1, + mode, + }); + } + } + + lastFailure = new AgentSchemaValidationError({ + attempt: attempt + 1, + mode, + issues: toSchemaIssues(validate.errors), + }); + if (attempt < maxRetries) { + input.onRetry?.({ attempt: attempt + 1, errors: lastFailure.message, mode }); + } + } + + return Result.err( + new SchemaRetriesExhaustedError( + maxRetries + 1, + lastFailure ?? + new InvalidAgentJsonError({ + attempt: maxRetries + 1, + mode: native ? "native" : "prompt", + responseExcerpt: "", + }), + ), + ); +} + +export function augmentPromptForSchema(prompt: string, schema: JsonSchema): string { + return [ + prompt, + "", + "Respond with ONLY a JSON value that validates against this JSON Schema (no markdown, no prose):", + JSON.stringify(schema), + ].join("\n"); +} + +export function formatAjvErrors( + errors: Array<{ instancePath?: string; message?: string }> | null | undefined, +): string { + if (!errors || errors.length === 0) return "validation failed"; + return errors + .map((error) => { + const path = error.instancePath || "/"; + return `${path} ${error.message ?? "invalid"}`.trim(); + }) + .join("; "); +} + +function toSchemaIssues( + errors: Array<{ instancePath?: string; message?: string }> | null | undefined, +): Array<{ path: string; message: string }> { + if (!errors || errors.length === 0) { + return [{ path: "/", message: "validation failed" }]; + } + return errors.map((error) => ({ + path: error.instancePath || "/", + message: error.message ?? "invalid", + })); +} + +/** 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) { + const structured = jsonValueSchema.safeParse(result.structured); + if (structured.success) candidates.push(structured.data); + if (typeof result.structured === "string") { + const parsed = tryExtractJson(result.structured); + const parsedJson = jsonValueSchema.safeParse(parsed); + if (parsedJson.success && parsedJson.data !== result.structured) { + candidates.push(parsedJson.data); + } + } + } + const fromText = tryExtractJson(result.finalResponse); + const textJson = jsonValueSchema.safeParse(fromText); + if (textJson.success) candidates.push(textJson.data); + return candidates; +} diff --git a/src/workflow-script.test.ts b/src/workflow-script.test.ts new file mode 100644 index 00000000..75f92750 --- /dev/null +++ b/src/workflow-script.test.ts @@ -0,0 +1,272 @@ +import assert from "node:assert/strict"; +import { parseWorkflowScript, WorkflowScriptError } from "./workflow-script.js"; +import { createStubBudget } from "./workflow-types.js"; +import { + runWorkflowSandbox, + WorkflowDeterminismError, + type WorkflowSandboxApi, +} from "./workflow-sandbox.js"; + +{ + const parsed = parseWorkflowScript(` +export const meta = { + name: 'fanout-review', + description: 'Two reviewers', + phases: [{ title: 'Review', detail: 'parallel' }], + defaultProvider: 'codex', + concurrency: 4, +} + +return { ok: true, name: meta.name } +`); + assert.equal(parsed.meta.name, "fanout-review"); + assert.equal(parsed.meta.description, "Two reviewers"); + assert.equal(parsed.meta.defaultProvider, "codex"); + assert.equal(parsed.meta.concurrency, 4); + assert.deepEqual(parsed.meta.phases, [{ title: "Review", detail: "parallel" }]); + assert.match(parsed.scriptHash, /^[a-f0-9]{64}$/); +} + +{ + assert.throws( + () => parseWorkflowScript(`const x = 1; export const meta = { name: 'a', description: 'b' }`), + (error: unknown) => + error instanceof WorkflowScriptError && + error.kind === "meta" && + /first statement/.test(error.message), + ); +} + +{ + assert.throws( + () => + parseWorkflowScript(` +export const meta = { + name: 'bracket-call', + description: ({})['constructor']['constructor']('return process')(), +} +`), + (error: unknown) => + error instanceof WorkflowScriptError && + error.kind === "meta" && + /literal value/.test(error.message), + ); +} + +{ + assert.throws( + () => + parseWorkflowScript(` +export const meta = { + name: 'computed-key', + ['description']: 'd', +} +`), + (error: unknown) => + error instanceof WorkflowScriptError && + error.kind === "meta" && + /static property name/.test(error.message), + ); +} + +{ + const parsed = parseWorkflowScript(` +export const meta = { + name: 'literal-text', + description: 'Text such as noCall( and export is data, not executable syntax', +} +return meta.description +`); + assert.match(parsed.meta.description, /noCall\(/); +} + +{ + assert.throws( + () => + parseWorkflowScript(` +export const meta = { name: 'wrong-type', description: 'd', concurrency: 'many' } +return 1 +`), + (error: unknown) => + error instanceof WorkflowScriptError && + /meta\.concurrency/.test(error.message) && + !/is required/.test(error.message), + ); +} + +{ + assert.throws( + () => + parseWorkflowScript(` +export const meta = { + name: 'bad', + description: 'x', + concurrency: Math.max(1, 2), +} +`), + (error: unknown) => error instanceof WorkflowScriptError && error.kind === "meta", + ); +} + +{ + assert.throws( + () => parseWorkflowScript(`export const meta = { name: 'Bad_Name', description: 'x' }`), + /meta\.name must match/, + ); +} + +{ + assert.throws( + () => parseWorkflowScript(`export const meta = { description: 'only' }`), + /meta\.name is required/, + ); +} + +{ + // Leading comments OK + const parsed = parseWorkflowScript(`// header +/* block */ +export const meta = { name: 'ok', description: 'd' } +return 1 +`); + assert.equal(parsed.meta.name, "ok"); +} + +async function runBody(source: string): Promise { + const parsed = parseWorkflowScript(source); + const logs: string[] = []; + return runWorkflowSandbox({ + parsed, + api: { + agent: async () => "agent-result", + parallel: async (...args: unknown[]) => { + const thunks = args[0] as Array<() => Promise>; + return Promise.all(thunks.map((t) => t().catch(() => null))); + }, + pipeline: async (...args: unknown[]) => args[0], + phase: () => {}, + log: (msg: unknown) => { + logs.push(String(msg)); + }, + args: { n: 1 }, + budget: createStubBudget(), + workflow: async () => null, + meta: parsed.meta, + } as WorkflowSandboxApi, + }); +} + +{ + const result = await runBody(` +export const meta = { name: 'ret', description: 'd' } +phase('A') +log('hi ' + args.n) +return { v: 1 + 1, fromAgent: await agent('p') } +`); + assert.deepEqual(result, { v: 2, fromAgent: "agent-result" }); +} + +{ + const result = await runBody(` +export const meta = { name: 'module-words', description: 'd' } +// import and export in comments are harmless +const text = 'Explain export syntax and import maps' +const template = \`import/export: \${text}\` +const pattern = /import|export/ +return { text, template, matches: pattern.test(text) } +`); + assert.deepEqual(result, { + text: "Explain export syntax and import maps", + template: "import/export: Explain export syntax and import maps", + matches: true, + }); +} + +{ + assert.throws( + () => + parseWorkflowScript(` +export const meta = { name: 'static-import', description: 'd' } +import value from './value.js' +return value +`), + (error: unknown) => error instanceof WorkflowScriptError && error.kind === "syntax", + ); + assert.throws( + () => + parseWorkflowScript(` +export const meta = { name: 'extra-export', description: 'd' } +export const value = 1 +return value +`), + (error: unknown) => error instanceof WorkflowScriptError && error.kind === "syntax", + ); +} + +{ + await assert.rejects( + () => + runBody(` +export const meta = { name: 'dynamic-import', description: 'd' } +return import('node:fs') +`), + /dynamic import callback/i, + ); +} + +{ + await assert.rejects( + () => + runBody(` +export const meta = { name: 'now', description: 'd' } +return Date.now() +`), + (error: unknown) => + error instanceof WorkflowDeterminismError && /Date\.now/.test(error.message), + ); +} + +{ + await assert.rejects( + () => + runBody(` +export const meta = { name: 'rand', description: 'd' } +return Math.random() +`), + WorkflowDeterminismError, + ); +} + +{ + await assert.rejects( + () => + runBody(` +export const meta = { name: 'date', description: 'd' } +return new Date() +`), + WorkflowDeterminismError, + ); +} + +{ + // Fixed Date is OK + const result = await runBody(` +export const meta = { name: 'fixed-date', description: 'd' } +return new Date('2020-01-01T00:00:00.000Z').toISOString() +`); + assert.equal(result, "2020-01-01T00:00:00.000Z"); +} + +{ + // No process/require + await assert.rejects( + () => + runBody(` +export const meta = { name: 'proc', description: 'd' } +return process.pid +`), + /process is not defined|ReferenceError/, + ); +} + +console.log("workflow-script.test.ts: ok"); diff --git a/src/workflow-script.ts b/src/workflow-script.ts new file mode 100644 index 00000000..0c94b0ea --- /dev/null +++ b/src/workflow-script.ts @@ -0,0 +1,401 @@ +import { createHash } from "node:crypto"; +import vm from "node:vm"; +import { WORKFLOW_LIMITS, type WorkflowMeta } from "./workflow-types.js"; +import { workflowMetaSchema } from "./workflow-contracts.js"; + +export class WorkflowScriptError extends Error { + constructor( + readonly kind: "syntax" | "meta" | "script_too_large", + message: string, + readonly line?: number, + ) { + super(message); + this.name = "WorkflowScriptError"; + } +} + +export interface ParsedWorkflowScript { + meta: WorkflowMeta; + source: string; + scriptHash: string; + /** Compiled async factory. Workflow APIs are installed as sandbox globals. */ + script: vm.Script; + filename: string; +} + +const META_EXPORT = /export\s+const\s+meta\s*=/; + +/** + * Parse + compile a workflow script. + * Expects `export const meta = {…}` as the first statement (optional leading comments/blank). + */ +export function parseWorkflowScript( + source: string, + options: { filename?: string } = {}, +): ParsedWorkflowScript { + if (Buffer.byteLength(source, "utf8") > WORKFLOW_LIMITS.scriptSourceBytes) { + throw new WorkflowScriptError( + "script_too_large", + `Script exceeds ${WORKFLOW_LIMITS.scriptSourceBytes} bytes`, + ); + } + + const filename = options.filename ?? "workflow:inline"; + const normalized = source.replace(/^/, ""); + const { metaLiteral } = extractMetaLiteral(normalized); + const meta = validateMeta(evaluateMetaLiteral(metaLiteral, filename)); + + // Strip only the leading `export ` so line numbers stay aligned (7 spaces). + const body = normalized.replace(META_EXPORT, " const meta ="); + + // Workflow APIs are installed as context-realm globals by the sandbox child. + // Keeping the factory argument-free avoids handing host-realm functions or + // constructors directly to model-authored workflow code. + const wrapped = `(async () => {\n${body}\n})`; + let script: vm.Script; + try { + script = new vm.Script(wrapped, { + filename, + // Outer async wrapper adds one line before user source + lineOffset: -1, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const line = parseErrorLine(message); + throw new WorkflowScriptError("syntax", message, line); + } + + return { + meta, + source: normalized, + scriptHash: hashSource(normalized), + script, + filename, + }; +} + +export function hashSource(source: string): string { + return createHash("sha256").update(source).digest("hex"); +} + +function extractMetaLiteral(source: string): { metaLiteral: string; metaEndIndex: number } { + const match = META_EXPORT.exec(source); + if (!match || match.index === undefined) { + throw new WorkflowScriptError( + "meta", + "Workflow script must start with `export const meta = { … }`", + ); + } + + // Ensure only whitespace/comments before export + const before = source.slice(0, match.index); + if (!isOnlyPreamble(before)) { + throw new WorkflowScriptError( + "meta", + "`export const meta` must be the first statement (comments/blank lines OK)", + ); + } + + const afterAssign = source.slice(match.index + match[0].length); + const trimmedStart = afterAssign.match(/^\s*/)?.[0].length ?? 0; + const objectStart = match.index + match[0].length + trimmedStart; + if (source[objectStart] !== "{") { + throw new WorkflowScriptError("meta", "meta value must be an object literal `{…}`"); + } + + const end = scanBalancedObject(source, objectStart); + const metaLiteral = source.slice(objectStart, end + 1); + + return { metaLiteral, metaEndIndex: end + 1 }; +} + +function scanBalancedObject(source: string, start: number): number { + let depth = 0; + let inString: '"' | "'" | null = null; + let inLineComment = false; + let inBlockComment = false; + let escape = false; + for (let i = start; i < source.length; i += 1) { + const ch = source[i]!; + const next = source[i + 1]; + if (inLineComment) { + if (ch === "\n") inLineComment = false; + continue; + } + if (inBlockComment) { + if (ch === "*" && next === "/") { + inBlockComment = false; + i += 1; + } + continue; + } + if (inString) { + if (escape) { + escape = false; + continue; + } + if (ch === "\\") { + escape = true; + continue; + } + if (ch === inString) inString = null; + continue; + } + if (ch === "/" && next === "/") { + inLineComment = true; + i += 1; + continue; + } + if (ch === "/" && next === "*") { + inBlockComment = true; + i += 1; + continue; + } + if (ch === '"' || ch === "'") { + inString = ch; + continue; + } + if (ch === "{") depth += 1; + else if (ch === "}") { + depth -= 1; + if (depth === 0) return i; + } + } + throw new WorkflowScriptError("meta", "Unclosed meta object literal"); +} + +function isOnlyPreamble(text: string): boolean { + // strip block comments, line comments, whitespace + const stripped = text + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/.*$/gm, "") + .trim(); + return stripped.length === 0; +} + +function evaluateMetaLiteral(literal: string, _filename: string): unknown { + try { + return new PureMetaLiteralParser(literal).parse(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new WorkflowScriptError("meta", `Invalid meta literal: ${message}`); + } +} + +class PureMetaLiteralParser { + private index = 0; + + constructor(private readonly source: string) {} + + parse(): unknown { + this.skipTrivia(); + const value = this.parseValue(); + this.skipTrivia(); + if (this.index !== this.source.length) { + this.fail(`unexpected token ${JSON.stringify(this.source[this.index])}`); + } + return value; + } + + private parseValue(): unknown { + this.skipTrivia(); + const ch = this.source[this.index]; + if (ch === "{") return this.parseObject(); + if (ch === "[") return this.parseArray(); + if (ch === '"' || ch === "'") return this.parseString(); + if (ch === "-" || (ch !== undefined && /\d/.test(ch))) return this.parseNumber(); + if (ch !== undefined && /[A-Za-z_$]/.test(ch)) { + const identifier = this.parseIdentifier(); + if (identifier === "true") return true; + if (identifier === "false") return false; + if (identifier === "null") return null; + this.fail(`identifier ${identifier} is not a literal value`); + } + this.fail(`expected a literal value, got ${JSON.stringify(ch)}`); + } + + private parseObject(): Record { + this.expect("{"); + const value: Record = Object.create(null) as Record; + this.skipTrivia(); + if (this.consume("}")) return value; + + for (;;) { + this.skipTrivia(); + const ch = this.source[this.index]; + const key = ch === '"' || ch === "'" ? this.parseString() : this.parseIdentifier(); + this.skipTrivia(); + this.expect(":"); + value[key] = this.parseValue(); + this.skipTrivia(); + if (this.consume("}")) return value; + this.expect(","); + this.skipTrivia(); + if (this.consume("}")) return value; + } + } + + private parseArray(): unknown[] { + this.expect("["); + const value: unknown[] = []; + this.skipTrivia(); + if (this.consume("]")) return value; + + for (;;) { + value.push(this.parseValue()); + this.skipTrivia(); + if (this.consume("]")) return value; + this.expect(","); + this.skipTrivia(); + if (this.consume("]")) return value; + } + } + + private parseString(): string { + const quote = this.source[this.index]; + if (quote !== '"' && quote !== "'") this.fail("expected a quoted string"); + this.index += 1; + let value = ""; + + while (this.index < this.source.length) { + const ch = this.source[this.index++]!; + if (ch === quote) return value; + if (ch === "\n" || ch === "\r") this.fail("unterminated string literal"); + if (ch !== "\\") { + value += ch; + continue; + } + + if (this.index >= this.source.length) this.fail("unterminated string escape"); + const escaped = this.source[this.index++]!; + const simpleEscapes: Record = { + "\\": "\\", + "\"": "\"", + "'": "'", + n: "\n", + r: "\r", + t: "\t", + b: "\b", + f: "\f", + v: "\v", + "0": "\0", + }; + if (escaped in simpleEscapes) { + value += simpleEscapes[escaped]; + continue; + } + if (escaped === "x") { + value += String.fromCodePoint(this.parseHexDigits(2)); + continue; + } + if (escaped === "u") { + if (this.consume("{")) { + const end = this.source.indexOf("}", this.index); + if (end < 0) this.fail("unterminated Unicode escape"); + const digits = this.source.slice(this.index, end); + if (!/^[0-9a-fA-F]{1,6}$/.test(digits)) this.fail("invalid Unicode escape"); + this.index = end + 1; + const codePoint = Number.parseInt(digits, 16); + if (codePoint > 0x10ffff) this.fail("Unicode escape is out of range"); + value += String.fromCodePoint(codePoint); + } else { + value += String.fromCodePoint(this.parseHexDigits(4)); + } + continue; + } + if (escaped === "\n") continue; + if (escaped === "\r") { + this.consume("\n"); + continue; + } + value += escaped; + } + this.fail("unterminated string literal"); + } + + private parseNumber(): number { + const match = this.source + .slice(this.index) + .match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/); + if (!match) this.fail("invalid number literal"); + this.index += match[0].length; + const value = Number(match[0]); + if (!Number.isFinite(value)) this.fail("number literal must be finite"); + return value; + } + + private parseIdentifier(): string { + const match = this.source.slice(this.index).match(/^[A-Za-z_$][\w$]*/); + if (!match) this.fail("expected a static property name"); + this.index += match[0].length; + return match[0]; + } + + private parseHexDigits(length: number): number { + const digits = this.source.slice(this.index, this.index + length); + if (digits.length !== length || !/^[0-9a-fA-F]+$/.test(digits)) { + this.fail("invalid hexadecimal escape"); + } + this.index += length; + return Number.parseInt(digits, 16); + } + + private skipTrivia(): void { + for (;;) { + while (this.index < this.source.length && /\s/.test(this.source[this.index]!)) { + this.index += 1; + } + if (this.source.startsWith("//", this.index)) { + const end = this.source.indexOf("\n", this.index + 2); + this.index = end < 0 ? this.source.length : end + 1; + continue; + } + if (this.source.startsWith("/*", this.index)) { + const end = this.source.indexOf("*/", this.index + 2); + if (end < 0) this.fail("unterminated block comment"); + this.index = end + 2; + continue; + } + return; + } + } + + private expect(token: string): void { + if (!this.consume(token)) this.fail(`expected ${JSON.stringify(token)}`); + } + + private consume(token: string): boolean { + if (!this.source.startsWith(token, this.index)) return false; + this.index += token.length; + return true; + } + + private fail(message: string): never { + throw new Error(`${message} at offset ${this.index}`); + } +} + +function validateMeta(value: unknown): WorkflowMeta { + const parsed = workflowMetaSchema.safeParse(value, { reportInput: true }); + if (parsed.success) return parsed.data; + + const issue = parsed.error.issues[0]; + const path = issue?.path.length ? `meta.${issue.path.join(".")}` : "meta"; + if (issue?.code === "invalid_type" && issue.input === undefined) { + throw new WorkflowScriptError("meta", `${path} is required`); + } + if (issue?.code === "invalid_format" && issue.format === "regex") { + throw new WorkflowScriptError("meta", `${path} must match /^[a-z0-9-]+$/`); + } + throw new WorkflowScriptError( + "meta", + `${path}: ${issue?.message ?? "validation failed"}`, + ); +} + +function parseErrorLine(message: string): number | undefined { + const match = message.match(/:(\d+)(?::\d+)?\)?$/m) ?? message.match(/line\s+(\d+)/i); + if (!match) return undefined; + const n = Number(match[1]); + return Number.isFinite(n) ? n : undefined; +} diff --git a/src/workflow-store.test.ts b/src/workflow-store.test.ts new file mode 100644 index 00000000..7815e1c8 --- /dev/null +++ b/src/workflow-store.test.ts @@ -0,0 +1,244 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { openDatabase } from "./db/client.js"; +import { 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: { name: run.name, scriptHash: run.scriptHash, concurrency: 1 }, + }); + const e2 = store.appendEvent({ + runId: run.id, + type: "phase_started", + phase: "Review", + label: "r1", + data: { title: "Review" }, + }); + const e3 = store.appendEvent({ runId: run.id, type: "log", data: { message: "hello" } }); + assert.equal(e1.seq, 1); + assert.equal(e2.seq, 2); + assert.equal(e3.seq, 3); + + const page1 = store.drainEvents(run.id, 0, 2); + assert.equal(page1.events.length, 2); + assert.equal(page1.nextSeq, 2); + assert.equal(page1.hasMore, true); + assert.equal(page1.terminal, false); + + const page2 = store.drainEvents(run.id, 2, 10); + assert.equal(page2.events.length, 1); + assert.equal(page2.events[0]?.seq, 3); + assert.equal(page2.nextSeq, 3); + assert.equal(page2.hasMore, false); + + store.beginAgentCall({ + runId: run.id, + callIndex: 0, + cacheKey: "key-a", + prompt: "review", + schemaJson: JSON.stringify({ type: "object" }), + provider: "codex", + model: "gpt-5.4", + effort: "high", + profileName: "reviewer", + profileFingerprint: "profile-hash", + phase: "Review", + isolation: "worktree", + worktreePath: "/tmp/wt", + replayReason: "identity_changed:prompt", + }); + store.completeAgentCall({ + runId: run.id, + callIndex: 0, + responseText: "done", + structuredJson: JSON.stringify({ ok: true }), + returnValueJson: JSON.stringify({ ok: true, exact: true }), + providerSessionId: "sess_1", + dirty: true, + }); + 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"); + 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"); + + store.beginAgentCall({ + runId: run.id, + callIndex: 1, + cacheKey: "key-b", + prompt: "review two", + provider: "claude", + }); + store.failAgentCall({ + runId: run.id, + callIndex: 1, + error: "boom", + errorKind: "provider", + }); + assert.equal(store.getAgentCall(run.id, 1)?.status, "failed"); + assert.equal(store.getAgentCall(run.id, 1)?.errorKind, "provider"); + assert.equal(store.listAgentCalls(run.id).length, 2); + + const cancelled = store.requestCancel(run.id); + assert.equal(cancelled.cancelRequested, true); + assert.equal(store.isCancelRequested(run.id), true); + + const terminal = store.cancelRun(run.id); + assert.equal(terminal.status, "cancelled"); + assert.equal(terminal.errorKind, "cancelled"); + assert.equal(store.cancelRun(run.id).status, "cancelled"); + + const terminalPage1 = store.drainEvents(run.id, 0, 2); + assert.equal(terminalPage1.hasMore, true); + assert.equal(terminalPage1.terminal, false); + const drainDone = store.drainEvents(run.id, 0, 100); + assert.equal(drainDone.events.at(-1)?.type, "run_cancelled"); + assert.equal(drainDone.hasMore, false); + assert.equal(drainDone.terminal, true); + + const run2 = store.createRun({ + name: "done", + source: "named", + scriptPath: join(root, "x.js"), + scriptHash: "h2", + workspaceRoot: join(root, "project"), + }); + store.claimRun(run2.id, process.pid); + store.completeRun(run2.id, { resultJson: JSON.stringify({ ok: 1 }) }); + assert.equal(store.getRun(run2.id)?.status, "completed"); + assert.equal(store.getRun(run2.id)?.resultJson, JSON.stringify({ ok: 1 })); + + const otherProjectRun = store.createRun({ + name: "other-project", + source: "inline", + scriptPath: join(root, "other.js"), + scriptHash: "other", + workspaceRoot: join(root, "other-project"), + }); + 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), [3, 4]); + + // Reap: stale heartbeat + dead pid (force heartbeat via shared sqlite handle) + const run3 = store.createRun({ + name: "stale", + source: "inline", + scriptPath: join(root, "s.js"), + scriptHash: "h3", + workspaceRoot: join(root, "project"), + }); + const dead = spawnSync(process.execPath, ["-e", ""]); + assert.ok(dead.pid); + store.claimRun(run3.id, dead.pid); + const db = openDatabase(root); + try { + db.sqlite + .prepare(`update workflow_runs set heartbeat_at = ? where id = ?`) + .run(new Date(Date.now() - 120_000).toISOString(), run3.id); + } finally { + db.close(); + } + const reaped = store.reapStale(60_000); + assert.ok(reaped.some((r) => r.id === run3.id && r.status === "failed")); + assert.equal(store.getRun(run3.id)?.errorKind, "heartbeat"); + assert.equal(store.listEvents(run3.id).at(-1)?.type, "run_failed"); + + const runStarting = store.createRun({ + name: "never-started", + source: "inline", + scriptPath: join(root, "never.js"), + scriptHash: "never", + workspaceRoot: join(root, "project"), + }); + const staleStartingDb = openDatabase(root); + try { + staleStartingDb.sqlite + .prepare(`update workflow_runs set updated_at = ? where id = ?`) + .run(new Date(Date.now() - 120_000).toISOString(), runStarting.id); + } finally { + staleStartingDb.close(); + } + const reapedStarting = store.reapStale(60_000); + assert.ok(reapedStarting.some((entry) => entry.id === runStarting.id)); + assert.equal(store.getRun(runStarting.id)?.status, "failed"); + + const run4 = store.createRun({ + name: "seq", + source: "inline", + scriptPath: join(root, "seq.js"), + scriptHash: "h4", + workspaceRoot: join(root, "project"), + }); + const seqs = [0, 1, 2, 3, 4].map(() => + store.appendEvent({ runId: run4.id, type: "log", data: { message: "1" } }).seq, + ); + assert.deepEqual(seqs, [1, 2, 3, 4, 5]); + + 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 00000000..5f59df77 --- /dev/null +++ b/src/workflow-store.ts @@ -0,0 +1,985 @@ +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 { + WORKFLOW_LIMITS, + type AgentIsolationMode, + type AppendWorkflowEventInput, + type WorkflowAgentCallRecord, + type WorkflowAgentCallStatus, + type WorkflowErrorKind, + type WorkflowEventRecord, + type WorkflowRunRecord, + type WorkflowRunSource, + type WorkflowRunStatus, +} from "./workflow-types.js"; +import { + localAgentProviderSchema, + parseWorkflowEventPayload, + workflowAgentCallStatusSchema, + workflowEventTypeSchema, + 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; + source: WorkflowRunSource; + scriptPath: string; + scriptHash: string; + workspaceRoot: string; + workspaceId?: string; + argsJson?: string; + resumedFromRunId?: string; + baseSha?: string; +} + +export interface BeginAgentCallInput { + runId: string; + callIndex: number; + cacheKey: string; + prompt: string; + schemaJson?: string; + provider: string; + model?: string; + effort?: string; + profileName?: string; + profileFingerprint?: string; + label?: string; + phase?: string; + isolation?: AgentIsolationMode; + worktreePath?: string; + replayMatch?: "same_index" | "compatible_key"; + replayedFromRunId?: string; + replayedFromCallIndex?: number; + replayReason?: string; +} + +export interface CompleteAgentCallInput { + runId: string; + callIndex: number; + responseText?: string; + structuredJson?: string; + returnValueJson?: string; + providerSessionId?: string; + dirty?: boolean; + worktreePath?: string; + fromCache?: boolean; +} + +export interface FailAgentCallInput { + runId: string; + callIndex: number; + error: string; + errorKind?: WorkflowErrorKind; + worktreePath?: string; + dirty?: boolean; +} + +export interface CompleteRunInput { + resultJson?: string; + callCount?: number; +} + +export interface FailRunInput { + error: string; + errorKind?: WorkflowErrorKind; +} + +export interface DrainEventsResult { + events: WorkflowEventRecord[]; + nextSeq: number; + hasMore: boolean; + terminal: boolean; + run: WorkflowRunRecord; +} + +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; + prompt: string; + schema_json: string | null; + provider: string; + model: string | null; + effort: string | null; + profile_name: string | null; + profile_fingerprint: string | null; + label: string | null; + phase: string | null; + status: string; + from_cache: string; + provider_session_id: string | null; + response_text: string | null; + structured_json: string | null; + return_value_json: string | null; + error: string | null; + error_kind: string | null; + replay_match: string | null; + replayed_from_run_id: string | null; + replayed_from_call_index: number | null; + replay_reason: string | null; + isolation: string; + worktree_path: string | null; + dirty: string | null; + created_at: string; + started_at: string | null; + completed_at: string | null; + updated_at: string; +} + +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; + } + + getRunResult( + id: string, + ): BetterResult { + return Result.try({ + try: () => this.getRun(id), + catch: (cause) => new WorkflowStoreError("get_run", cause), + }); + } + + listRuns(limit = 50): WorkflowRunRecord[] { + const rows = this.database.sqlite + .prepare("select * from workflow_runs order by updated_at desc limit ?") + .all(Math.max(1, Math.min(limit, 500))) as WorkflowRunRow[]; + return rows.map(rowToRun); + } + + listRunsForWorkspace( + workspaceRoot: string, + options: { + statuses?: WorkflowRunStatus[]; + limit?: number; + } = {}, + ): WorkflowRunRecord[] { + const root = resolve(workspaceRoot); + const limit = Math.max(1, Math.min(options.limit ?? 50, 500)); + const statuses = options.statuses?.filter((status, index, values) => + values.indexOf(status) === index, + ); + + if (!statuses?.length) { + const rows = this.database.sqlite + .prepare( + "select * from workflow_runs where workspace_root = ? order by updated_at desc limit ?", + ) + .all(root, limit) as WorkflowRunRow[]; + return rows.map(rowToRun); + } + + const placeholders = statuses.map(() => "?").join(", "); + const rows = this.database.sqlite + .prepare( + `select * from workflow_runs + where workspace_root = ? and status in (${placeholders}) + order by updated_at desc + limit ?`, + ) + .all(root, ...statuses, limit) as WorkflowRunRow[]; + return rows.map(rowToRun); + } + + /** + * Atomically claim a starting run for the worker. + * Returns undefined if the run is missing or not claimable. + */ + setScriptPath(id: string, scriptPath: string): WorkflowRunRecord { + return unwrapRunResult(this.setScriptPathResult(id, scriptPath)); + } + + setScriptPathResult( + id: string, + scriptPath: string, + ): BetterResult { + const current = this.getRunResult(id); + if (current.isErr()) return 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 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 { + this.database.sqlite + .prepare( + `update workflow_runs set heartbeat_at = ?, updated_at = ? where id = ? and status = 'running'`, + ) + .run(at, at, id); + } + + requestCancel(id: string): WorkflowRunRecord { + return unwrapRunResult(this.requestCancelResult(id)); + } + + requestCancelResult( + id: string, + ): BetterResult { + const current = this.getRunResult(id); + if (current.isErr()) return 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(); + const update = this.database.sqlite + .prepare( + `update workflow_runs + set cancel_requested = 'true', updated_at = ? + where id = ? and status in ('starting', 'running')`, + ) + .run(now, id); + if (update.changes === 0) return this.getRun(id); + return this.getRun(id); + }, + catch: (cause) => new WorkflowStoreError("request_cancel", cause), + }); + if (updated.isErr()) return updated; + return updated.value + ? Result.ok(updated.value) + : Result.err(new WorkflowNotFoundError(id)); + } + + isCancelRequested(id: string): boolean { + return this.requireRun(id).cancelRequested; + } + + completeRun(id: string, input: CompleteRunInput = {}): WorkflowRunRecord { + return unwrapRunResult(this.completeRunResult(id, input)); + } + + completeRunResult( + id: string, + input: CompleteRunInput = {}, + ): BetterResult { + return this.transitionRunResult(id, "complete", () => { + if (input.resultJson !== undefined) assertResultSize(input.resultJson); + const now = isoNow(); + const transaction = this.database.sqlite.transaction(() => { + const changes = this.database.sqlite + .prepare( + `update workflow_runs set + status = 'completed', + result_json = ?, + completed_at = ?, + updated_at = ?, + error = null, + error_kind = null + where id = ? and status in ('starting', 'running')`, + ) + .run(input.resultJson ?? null, now, now, id).changes; + if (changes === 0) return 0; + this.insertEventRow( + { + runId: id, + type: "run_completed", + data: { callCount: input.callCount ?? 0 }, + }, + now, + ); + return changes; + }); + return transaction.immediate(); + }); + } + + failRun(id: string, input: FailRunInput): WorkflowRunRecord { + return unwrapRunResult(this.failRunResult(id, input)); + } + + failRunResult( + id: string, + input: FailRunInput, + ): BetterResult { + return this.transitionRunResult(id, "fail", () => { + const now = isoNow(); + const errorKind = input.errorKind ?? "internal"; + const transaction = this.database.sqlite.transaction(() => { + const changes = this.database.sqlite + .prepare( + `update workflow_runs set + status = 'failed', + error = ?, + error_kind = ?, + completed_at = ?, + updated_at = ? + where id = ? and status in ('starting', 'running')`, + ) + .run(input.error, errorKind, now, now, id).changes; + if (changes === 0) return 0; + this.insertEventRow( + { + runId: id, + type: "run_failed", + data: { error: input.error, errorKind }, + }, + now, + ); + return changes; + }); + return transaction.immediate(); + }); + } + + cancelRun(id: string, error = "cancelled"): WorkflowRunRecord { + return unwrapRunResult(this.cancelRunResult(id, error)); + } + + cancelRunResult( + id: string, + error = "cancelled", + ): BetterResult { + return this.transitionRunResult(id, "cancel", () => { + const now = isoNow(); + const transaction = this.database.sqlite.transaction(() => { + const changes = this.database.sqlite + .prepare( + `update workflow_runs set + status = 'cancelled', + error = ?, + error_kind = 'cancelled', + cancel_requested = 'true', + completed_at = ?, + updated_at = ? + where id = ? and status in ('starting', 'running')`, + ) + .run(error, now, now, id).changes; + if (changes === 0) return 0; + this.insertEventRow( + { + runId: id, + type: "run_cancelled", + data: { reason: error }, + }, + now, + ); + return changes; + }); + return transaction.immediate(); + }); + } + + private transitionRunResult( + id: string, + operation: "complete" | "fail" | "cancel", + update: () => number, + ): BetterResult { + const currentResult = this.getRunResult(id); + if (currentResult.isErr()) return 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, + }), + ); + } + 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 { + const createdAt = isoNow(); + const transaction = this.database.sqlite.transaction(() => + this.insertEventRow(input, createdAt), + ); + return transaction.immediate(); + } + + drainEvents(runId: string, sinceSeq = 0, limit: number = WORKFLOW_LIMITS.eventDrainDefault): DrainEventsResult { + const run = this.requireRun(runId); + const capped = Math.max(1, Math.min(limit, WORKFLOW_LIMITS.eventDrainMax)); + const rows = this.database.sqlite + .prepare( + `select * from workflow_events + where run_id = ? and seq > ? + order by seq asc + limit ?`, + ) + .all(runId, sinceSeq, capped + 1) as WorkflowEventRow[]; + const hasMore = rows.length > capped; + const events = rows.slice(0, capped).map(rowToEvent); + const nextSeq = events.length > 0 ? events[events.length - 1]!.seq : sinceSeq; + return { + events, + nextSeq, + hasMore, + terminal: TERMINAL_STATUSES.has(run.status) && !hasMore, + run, + }; + } + + listEvents(runId: string, limit = 100): WorkflowEventRecord[] { + const capped = Math.max(1, Math.min(limit, WORKFLOW_LIMITS.eventDrainMax)); + const rows = this.database.sqlite + .prepare( + `select * from ( + select * from workflow_events + where run_id = ? + order by seq desc + limit ? + ) order by seq asc`, + ) + .all(runId, capped) as WorkflowEventRow[]; + return rows.map(rowToEvent); + } + + 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, prompt, schema_json, provider, model, effort, + profile_name, profile_fingerprint, label, phase, + status, from_cache, isolation, worktree_path, replay_match, + replayed_from_run_id, replayed_from_call_index, replay_reason, + created_at, started_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', 'false', ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + input.runId, + input.callIndex, + input.cacheKey, + input.prompt, + input.schemaJson ?? null, + input.provider, + input.model ?? null, + input.effort ?? null, + input.profileName ?? null, + input.profileFingerprint ?? null, + input.label ?? null, + input.phase ?? null, + isolation, + input.worktreePath ?? null, + input.replayMatch ?? null, + input.replayedFromRunId ?? null, + input.replayedFromCallIndex ?? null, + input.replayReason ?? null, + now, + now, + now, + ); + return this.requireAgentCall(input.runId, input.callIndex); + } + + completeAgentCall(input: CompleteAgentCallInput): WorkflowAgentCallRecord { + 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", + ); + } + 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 = ?, + return_value_json = ?, + provider_session_id = coalesce(?, provider_session_id), + worktree_path = coalesce(?, worktree_path), + dirty = ?, + completed_at = ?, + updated_at = ? + where run_id = ? and call_index = ?`, + ) + .run( + status, + input.fromCache ? "true" : "false", + input.responseText ?? null, + input.structuredJson ?? null, + input.returnValueJson ?? null, + input.providerSessionId ?? null, + input.worktreePath ?? null, + input.dirty === undefined ? null : input.dirty ? "true" : "false", + now, + now, + input.runId, + input.callIndex, + ); + return this.requireAgentCall(input.runId, input.callIndex); + } + + failAgentCall(input: FailAgentCallInput): WorkflowAgentCallRecord { + const now = isoNow(); + this.database.sqlite + .prepare( + `update workflow_agent_calls set + status = 'failed', + error = ?, + error_kind = ?, + worktree_path = coalesce(?, worktree_path), + dirty = ?, + completed_at = ?, + updated_at = ? + where run_id = ? and call_index = ?`, + ) + .run( + input.error, + input.errorKind ?? "internal", + input.worktreePath ?? null, + input.dirty === undefined ? null : input.dirty ? "true" : "false", + now, + now, + input.runId, + input.callIndex, + ); + return this.requireAgentCall(input.runId, input.callIndex); + } + + 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 abandoned starting runs and running runs with a dead worker as failed. + * staleBeforeMs: start/update or heartbeat older than this and no live pid. + */ + reapStale(staleBeforeMs = 60_000, nowMs = Date.now()): WorkflowRunRecord[] { + const cutoff = new Date(nowMs - staleBeforeMs).toISOString(); + const candidates = this.database.sqlite + .prepare( + `select * from workflow_runs + where (status = 'running' and heartbeat_at is not null and heartbeat_at < ?) + or (status = 'starting' and updated_at < ?)`, + ) + .all(cutoff, cutoff) as WorkflowRunRow[]; + + const reaped: WorkflowRunRecord[] = []; + for (const row of candidates) { + const latest = this.getRun(row.id); + if (!latest || (latest.status !== "running" && latest.status !== "starting")) continue; + if (latest.pid !== undefined && isPidAlive(latest.pid)) continue; + const failed = this.failRun(row.id, { + error: latest.status === "starting" ? "workflow worker failed to start" : "worker heartbeat lost", + errorKind: "heartbeat", + }); + if (failed.status === "failed" && failed.errorKind === "heartbeat") { + reaped.push(failed); + } + } + return reaped; + } + + private insertEventRow( + input: AppendWorkflowEventInput, + createdAt: string, + ): WorkflowEventRecord { + const payload = parseWorkflowEventPayload(input.type, input.data); + const dataJson = truncateJson(payload, WORKFLOW_LIMITS.eventDataJsonBytes); + const next = this.database.sqlite + .prepare( + `select coalesce(max(seq), 0) + 1 as next_seq from workflow_events where run_id = ?`, + ) + .get(input.runId) as { next_seq: number }; + const seq = next.next_seq; + this.database.sqlite + .prepare( + `insert into workflow_events (run_id, seq, type, phase, label, data_json, created_at) + values (?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + input.runId, + seq, + input.type, + input.phase ?? null, + input.label ?? null, + dataJson, + createdAt, + ); + this.database.sqlite + .prepare(`update workflow_runs set updated_at = ? where id = ?`) + .run(createdAt, input.runId); + return { + runId: input.runId, + seq, + type: input.type, + phase: input.phase, + label: input.label, + dataJson, + createdAt, + }; + } + + close(): void { + this.database.close(); + } + + private requireRun(id: string): WorkflowRunRecord { + const run = this.getRun(id); + if (!run) throw new Error(`Unknown workflow run: ${id}`); + return run; + } + + private requireAgentCall(runId: string, callIndex: number): WorkflowAgentCallRecord { + const call = this.getAgentCall(runId, callIndex); + if (!call) throw new Error(`Unknown workflow agent call: ${runId}#${callIndex}`); + return call; + } +} + +export function createWorkflowStore(config: ServerConfig): WorkflowStore { + return new WorkflowStore(config.stateDir); +} + +function rowToRun(row: WorkflowRunRow): WorkflowRunRecord { + return { + id: row.id, + name: row.name, + source: workflowRunSourceSchema.parse(row.source), + scriptPath: row.script_path, + scriptHash: row.script_hash, + workspaceRoot: row.workspace_root, + workspaceId: row.workspace_id ?? undefined, + argsJson: row.args_json, + status: workflowRunStatusSchema.parse(row.status), + error: row.error ?? undefined, + errorKind: (row.error_kind as WorkflowErrorKind | null) ?? undefined, + resultJson: row.result_json ?? undefined, + pid: row.pid ?? undefined, + heartbeatAt: row.heartbeat_at ?? undefined, + cancelRequested: row.cancel_requested === "true", + resumedFromRunId: row.resumed_from_run_id ?? undefined, + baseSha: row.base_sha ?? undefined, + createdAt: row.created_at, + startedAt: row.started_at ?? undefined, + completedAt: row.completed_at ?? undefined, + updatedAt: row.updated_at, + }; +} + +function rowToEvent(row: WorkflowEventRow): WorkflowEventRecord { + return { + runId: row.run_id, + seq: row.seq, + type: workflowEventTypeSchema.parse(row.type), + phase: row.phase ?? undefined, + label: row.label ?? undefined, + dataJson: row.data_json, + createdAt: row.created_at, + }; +} + +function rowToAgentCall(row: WorkflowAgentCallRow): WorkflowAgentCallRecord { + return { + runId: row.run_id, + callIndex: row.call_index, + cacheKey: row.cache_key, + prompt: row.prompt, + schemaJson: row.schema_json ?? undefined, + provider: localAgentProviderSchema.parse(row.provider), + model: row.model ?? undefined, + effort: row.effort ?? undefined, + profileName: row.profile_name ?? undefined, + profileFingerprint: row.profile_fingerprint ?? undefined, + label: row.label ?? undefined, + phase: row.phase ?? undefined, + status: workflowAgentCallStatusSchema.parse(row.status), + fromCache: row.from_cache === "true", + providerSessionId: row.provider_session_id ?? undefined, + responseText: row.response_text ?? undefined, + structuredJson: row.structured_json ?? undefined, + returnValueJson: row.return_value_json ?? undefined, + error: row.error ?? undefined, + errorKind: (row.error_kind as WorkflowErrorKind | null) ?? undefined, + replayMatch: + row.replay_match === "same_index" || 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", + 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 (error) { + if ((error as NodeJS.ErrnoException).code === "EPERM") return true; + return false; + } +} + +function assertArgsSize(argsJson: string): void { + assertTextSize(argsJson, WORKFLOW_LIMITS.argsJsonBytes, "argsJson"); +} + +function assertResultSize(resultJson: string): void { + assertTextSize(resultJson, WORKFLOW_LIMITS.resultJsonBytes, "resultJson"); +} + +function assertTextSize(value: string, maxBytes: number, label: string): void { + const bytes = Buffer.byteLength(value, "utf8"); + if (bytes > maxBytes) { + throw new Error(`${label} exceeds limit (${bytes} > ${maxBytes} bytes)`); + } +} + +function truncateJson(value: unknown, maxBytes: number): string { + let text: string; + try { + text = JSON.stringify(value) ?? "null"; + } catch { + text = JSON.stringify({ error: "unserializable" }); + } + if (Buffer.byteLength(text, "utf8") <= maxBytes) return text; + const marker = JSON.stringify({ truncated: true }); + const budget = Math.max(0, maxBytes - Buffer.byteLength(marker, "utf8") - 32); + const slice = Buffer.from(text, "utf8").subarray(0, budget).toString("utf8"); + return JSON.stringify({ truncated: true, preview: slice }); +} + +function unwrapRunResult( + result: BetterResult, +): WorkflowRunRecord { + if (result.isErr()) throw result.error; + return result.value; +} diff --git a/src/workflow-tools.ts b/src/workflow-tools.ts new file mode 100644 index 00000000..6efbb6f6 --- /dev/null +++ b/src/workflow-tools.ts @@ -0,0 +1,568 @@ +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 { + persistWorkflowScriptResult, + resolveNamedWorkflowScriptResult, + readWorkflowScriptFileResult, +} from "./workflow-files.js"; +import { parseWorkflowScript } from "./workflow-script.js"; +import { createWorkflowStore } from "./workflow-store.js"; +import { + WORKFLOW_MCP_YIELD_MS, + WORKFLOW_LIMITS, + 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"; + +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", + }); + } + + 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, + 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, + }); + 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); + + const yieldMs = yieldTimeMs ?? 2_000; + const page = await yieldEvents(store, 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)); +} + +/** 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)); +} diff --git a/src/workflow-tui.test.ts b/src/workflow-tui.test.ts new file mode 100644 index 00000000..59696588 --- /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 00000000..5cdc0e32 --- /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`; +} diff --git a/src/workflow-types.test.ts b/src/workflow-types.test.ts new file mode 100644 index 00000000..55e1abc8 --- /dev/null +++ b/src/workflow-types.test.ts @@ -0,0 +1,67 @@ +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", + profileName: null, + profileFingerprint: null, + provider: "codex", + model: null, + effort: "high", + schema: null, + isolation: "worktree", + }, +); + +assert.deepEqual( + buildAgentCacheKeyInput({ + prompt: "x", + provider: "claude", + }), + { + prompt: "x", + profileName: null, + profileFingerprint: null, + provider: "claude", + model: null, + effort: null, + schema: null, + isolation: "shared", + }, +); + +const budget = createStubBudget(); +assert.equal(budget.total, null); +assert.equal(budget.spent(), 0); +assert.equal(budget.remaining(), Infinity); + +assert.equal(defaultWorkflowConcurrency(8), 6); +assert.equal(defaultWorkflowConcurrency(2), 1); +assert.equal(defaultWorkflowConcurrency(1), 1); +assert.equal(defaultWorkflowConcurrency(32), 16); + +assert.equal(resolveWorkflowConcurrency(undefined, 8), 6); +assert.equal(resolveWorkflowConcurrency(2, 8), 2); +assert.equal(resolveWorkflowConcurrency(100, 8), 6); +assert.equal(resolveWorkflowConcurrency(0, 8), 1); + +console.log("workflow-types.test.ts: ok"); diff --git a/src/workflow-types.ts b/src/workflow-types.ts new file mode 100644 index 00000000..4f46e1d7 --- /dev/null +++ b/src/workflow-types.ts @@ -0,0 +1,232 @@ +/** + * Frozen contracts for DevSpace Dynamic Workflows. + * Engine modules must import these rather than invent parallel shapes. + * + * Locks: + * - No writeMode on AgentOpts (prompt RO/write + isolation containment). + * - budget is a stub shape in v1. + * - nest depth 1; max pipeline/parallel items 4096. + * - concurrency default min(16, max(1, availableParallelism()-2)). + */ + +import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import type { JsonSchema } from "./json-types.js"; +import type { + AgentIsolationMode, + AgentOpts, + WorkflowErrorKind, + WorkflowAgentCallStatus, + WorkflowEventType, + WorkflowMeta, + WorkflowPhaseMeta, + WorkflowRunSource, + WorkflowRunStatus, +} from "./workflow-contracts.js"; + +export type { JsonObject, JsonPrimitive, JsonSchema, JsonValue } from "./json-types.js"; +export type { + AgentIsolationMode, + AgentOpts, + AppendWorkflowEventInput, + WorkflowAgent, + WorkflowAgentCallStatus, + WorkflowErrorKind, + WorkflowEventPayloads, + WorkflowEventType, + WorkflowMeta, + WorkflowNested, + WorkflowParallel, + WorkflowPhaseMeta, + WorkflowPipeline, + WorkflowRunSource, + WorkflowRunStatus, + WorkflowTask, +} from "./workflow-contracts.js"; + +// --------------------------------------------------------------------------- +// Limits +// --------------------------------------------------------------------------- + +export const WORKFLOW_MAX_ITEMS = 4096; +export const WORKFLOW_MAX_NEST_DEPTH = 1; +export const WORKFLOW_MAX_SCHEMA_RETRIES = 2; +export const WORKFLOW_HEARTBEAT_MS = 5_000; +export const WORKFLOW_CANCEL_HARD_MS = 5_000; +export const WORKFLOW_HOST_TIMEOUT_MS = 6 * 60 * 60 * 1000; +export const WORKFLOW_MCP_YIELD_MS = 110_000; + +/** Soft/hard transport + storage caps (not semantic coverage truncation). */ +export const WORKFLOW_LIMITS = { + eventDataJsonBytes: 8 * 1024, + responseTextBytes: 1 * 1024 * 1024, + structuredJsonBytes: 256 * 1024, + replayValueJsonBytes: 1 * 1024 * 1024, + resultJsonBytes: 256 * 1024, + argsJsonBytes: 64 * 1024, + scriptSourceBytes: 512 * 1024, + eventDrainDefault: 200, + eventDrainMax: 500, +} as const; + +export type AgentProviderId = LocalAgentProvider; + +// --------------------------------------------------------------------------- +// Status / events +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Journal row shapes (behavioral; store maps snake_case) +// --------------------------------------------------------------------------- + +export interface WorkflowRunRecord { + id: string; + name: string; + source: WorkflowRunSource; + scriptPath: string; + scriptHash: string; + workspaceRoot: string; + workspaceId?: string; + argsJson: string; + 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; + prompt: string; + schemaJson?: string; + provider: AgentProviderId; + model?: string; + effort?: string; + profileName?: string; + profileFingerprint?: string; + label?: string; + phase?: string; + status: WorkflowAgentCallStatus; + fromCache: boolean; + providerSessionId?: string; + responseText?: string; + structuredJson?: string; + returnValueJson?: string; + error?: string; + errorKind?: WorkflowErrorKind; + replayMatch?: "same_index" | "compatible_key"; + replayedFromRunId?: string; + replayedFromCallIndex?: number; + replayReason?: string; + isolation: AgentIsolationMode; + worktreePath?: string; + dirty?: boolean; + createdAt: string; + startedAt?: string; + completedAt?: string; + updatedAt: string; +} + +// --------------------------------------------------------------------------- +// Cache key +// --------------------------------------------------------------------------- + +/** + * Canonical fields for agent() resume identity. + * Field order for JSON serialization is fixed by buildAgentCacheKeyInput. + */ +export interface AgentCacheKeyInput { + prompt: string; + profileName: string | null; + profileFingerprint: string | null; + provider: AgentProviderId; + model: string | null; + effort: string | null; + schema: JsonSchema | null; + isolation: AgentIsolationMode; +} + +export function buildAgentCacheKeyInput(input: { + prompt: string; + profileName?: string | null; + profileFingerprint?: string | null; + provider: AgentProviderId; + model?: string | null; + effort?: string | null; + schema?: JsonSchema | null; + isolation?: AgentIsolationMode | "worktree" | null; +}): AgentCacheKeyInput { + const isolation: AgentIsolationMode = + input.isolation === "worktree" ? "worktree" : "shared"; + return { + prompt: input.prompt, + profileName: input.profileName ?? null, + profileFingerprint: input.profileFingerprint ?? null, + provider: input.provider, + model: input.model ?? null, + effort: input.effort ?? null, + schema: input.schema ?? null, + isolation, + }; +} + +// --------------------------------------------------------------------------- +// Budget stub (CC-shaped) +// --------------------------------------------------------------------------- + +export interface WorkflowBudget { + readonly total: number | null; + spent(): number; + remaining(): number; +} + +export function createStubBudget(): WorkflowBudget { + return Object.freeze({ + total: null, + spent(): number { + return 0; + }, + remaining(): number { + return Infinity; + }, + }); +} + +// --------------------------------------------------------------------------- +// Concurrency helper +// --------------------------------------------------------------------------- + +export function defaultWorkflowConcurrency(availableParallelism: number): number { + return Math.min(16, Math.max(1, availableParallelism - 2)); +} + +export function resolveWorkflowConcurrency( + metaConcurrency: number | undefined, + availableParallelism: number, +): number { + const base = defaultWorkflowConcurrency(availableParallelism); + if (metaConcurrency === undefined || !Number.isFinite(metaConcurrency)) return base; + const n = Math.floor(metaConcurrency); + if (n < 1) return 1; + return Math.min(base, n); +} diff --git a/src/workflow-ui.test.ts b/src/workflow-ui.test.ts new file mode 100644 index 00000000..1621ce48 --- /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 00000000..b97b526a --- /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; + } +} diff --git a/src/workflow-view.test.ts b/src/workflow-view.test.ts new file mode 100644 index 00000000..a96ddd8b --- /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 00000000..218bc246 --- /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; + } +} diff --git a/src/workflow-worktrees.ts b/src/workflow-worktrees.ts new file mode 100644 index 00000000..1ccf6089 --- /dev/null +++ b/src/workflow-worktrees.ts @@ -0,0 +1,204 @@ +import { execFile } from "node:child_process"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { Result, type Result as BetterResult } from "better-result"; +import type { CreateAgentWorktree, WorkflowWorktreeHandle } from "./workflow-api.js"; +import { WorktreeOperationError } from "./workflow-errors.js"; + +const execFileAsync = promisify(execFile); + +export interface WorkflowWorktreeHost { + worktreeRoot: string; + /** When set, assert worktree paths stay under this root. */ + allowedRoots?: string[]; +} + +/** + * Create a CreateAgentWorktree bound to host config. + * Layout: `/wf//c/` + */ +export function createWorkflowWorktreeFactory( + host: WorkflowWorktreeHost, +): CreateAgentWorktree { + return async (input) => { + const result = await createWorkflowWorktreeResult(host, input); + if (result.isErr()) throw result.error; + return result.value; + }; +} + +export async function createWorkflowWorktreeResult( + host: WorkflowWorktreeHost, + input: Parameters[0], +): Promise> { + return Result.tryPromise({ + try: async () => { + const path = join(host.worktreeRoot, "wf", input.runId, `c${input.callIndex}`); + await mkdir(join(host.worktreeRoot, "wf", input.runId), { recursive: true }); + + let sourceRoot: string; + try { + sourceRoot = ( + await git(["rev-parse", "--show-toplevel"], input.workspaceRoot) + ).trim(); + } catch (error) { + if (isGitUnavailable(error)) { + throw new Error("isolation: 'worktree' requires Git on PATH", { cause: error }); + } + throw new Error( + `isolation: 'worktree' requires a Git repository (not found at ${input.workspaceRoot})`, + { cause: error }, + ); + } + + const baseSha = + input.baseSha ?? + (await git(["rev-parse", "--verify", "HEAD^{commit}"], sourceRoot)).trim(); + + try { + await git(["worktree", "add", "--detach", path, baseSha], sourceRoot); + } catch (error) { + try { + await rm(path, { recursive: true, force: true }); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "Failed to create and clean up agent worktree", + ); + } + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to create agent worktree: ${message}`, { cause: error }); + } + + return createHandle({ path, sourceRoot }); + }, + catch: (cause) => + new WorktreeOperationError({ + operation: "create", + runId: input.runId, + callIndex: input.callIndex, + cause, + }), + }); +} + +function createHandle(input: { + path: string; + sourceRoot: string; +}): WorkflowWorktreeHandle { + return { + path: input.path, + finalize: async (outcome) => { + const dirtyResult = await isDirtyResult(input.path); + if (dirtyResult.isErr()) throw dirtyResult.error; + const dirty = dirtyResult.value; + if (outcome === "success" && !dirty) { + const removed = await removeWorktreeResult(input.sourceRoot, input.path); + if (removed.isErr()) throw removed.error; + return { dirty: false, removed: true }; + } + // Preserve dirty or failed worktrees for diagnosis. + return { dirty, removed: false }; + }, + }; +} + +export async function isDirty(worktreePath: string): Promise { + const result = await isDirtyResult(worktreePath); + return result.isOk() ? result.value : true; +} + +export async function isDirtyResult( + worktreePath: string, +): Promise> { + return Result.tryPromise({ + try: async () => { + const status = (await git(["status", "--porcelain=v1"], worktreePath)).trim(); + return status.length > 0; + }, + catch: (cause) => + new WorktreeOperationError({ + operation: "inspect", + path: worktreePath, + cause, + }), + }); +} + +export async function removeWorktree( + sourceRoot: string, + worktreePath: string, +): Promise { + const result = await removeWorktreeResult(sourceRoot, worktreePath); + if (result.isErr()) throw result.error; +} + +export async function removeWorktreeResult( + sourceRoot: string, + worktreePath: string, +): Promise> { + return Result.tryPromise({ + try: async () => { + try { + await git(["worktree", "remove", "--force", worktreePath], sourceRoot); + } catch (removeError) { + await rm(worktreePath, { recursive: true, force: true }); + try { + await git(["worktree", "prune"], sourceRoot); + } catch (pruneError) { + throw new AggregateError( + [removeError, pruneError], + "Worktree directory was removed but Git metadata pruning failed", + ); + } + } + }, + catch: (cause) => + new WorktreeOperationError({ + operation: "remove", + path: worktreePath, + cause, + }), + }); +} + +export async function resolveWorkspaceHead(workspaceRoot: string): Promise { + try { + return (await git(["rev-parse", "--verify", "HEAD^{commit}"], workspaceRoot)).trim(); + } catch { + return undefined; + } +} + +async function git(args: string[], cwd: string): Promise { + try { + const { stdout } = await execFileAsync("git", args, { + cwd, + maxBuffer: 10 * 1024 * 1024, + }); + return stdout; + } catch (error) { + if (isGitUnavailable(error)) throw error; + const stderr = + typeof error === "object" && error && "stderr" in error + ? String((error as { stderr?: unknown }).stderr ?? "").trim() + : ""; + const stdout = + typeof error === "object" && error && "stdout" in error + ? String((error as { stdout?: unknown }).stdout ?? "").trim() + : ""; + const details = + stderr || stdout || (error instanceof Error ? error.message : String(error)); + throw new Error(details); + } +} + +function isGitUnavailable(error: unknown): boolean { + return Boolean( + typeof error === "object" && + error && + "code" in error && + (error as { code?: unknown }).code === "ENOENT", + ); +}