workflow: meta rides the seam as data — the engine never evaluates it

P1 review finding: extractMeta timed only the literal's vm evaluation;
materializing the RESULT then read properties ordinarily on the HOST
stack, so a meta literal smuggling a getter (get name() { while(true){} })
could wedge the host outside any timeout — defeating the exact spin
isolation the worker thread exists for.

Rather than harden the evaluator (descriptor walks, AST validation),
delete the mechanism: the workflow's identity now reaches the seam as a
plain JSON field (WorkflowStartRequest.meta), carried by the tool as a
schema-validated `meta` object parameter the model fills directly. The
engine only shape-validates data (validateMeta, every violation named)
and pre-parses the body; the scanner, the vm evaluation, and the
host-side materialization are gone, and with them the hole. A body
still opening with a Claude Code-style `export const meta` statement
gets a pointed SCRIPT_PARSE message (the likeliest authoring slip; a
CC script's body stays drop-in, only its meta header moves into the
parameter). syncTimeoutMs now governs exactly one thing: the initial
synchronous slice inside the worker.

The RFC's decision section is rewritten in place (implemented-RFC
rule); the embedded-meta format moves to alternatives-considered with
the hole as the reason. Tool description, presentation (title now reads
meta.name directly — the textual sniff is gone), seam vocabulary docs,
and catalogs follow.
This commit is contained in:
imccyu
2026-07-09 20:09:10 +08:00
parent af9616f47d
commit 0d0f0204f2
18 changed files with 304 additions and 462 deletions

View File

@@ -685,7 +685,7 @@ export interface Config {
} }
``` ```
Source: [`packages/workflow/tool-workflow/src/index.ts:40`](../packages/workflow/tool-workflow/src/index.ts) Source: [`packages/workflow/tool-workflow/src/index.ts:39`](../packages/workflow/tool-workflow/src/index.ts)
## `@deepseek-ai/dsh-web` ## `@deepseek-ai/dsh-web`
@@ -815,7 +815,7 @@ export interface Config {
maxTotalAgents?: number maxTotalAgents?: number
/** Items accepted by a single `parallel()`/`pipeline()` call (default 4096). */ /** Items accepted by a single `parallel()`/`pipeline()` call (default 4096). */
maxItemsPerCall?: number maxItemsPerCall?: number
/** vm timeout for the initial synchronous slice (inside the worker) AND the host-side meta evaluation (default 5000 ms). */ /** vm timeout for the script's initial synchronous slice, inside the worker (default 5000 ms). */
syncTimeoutMs?: number syncTimeoutMs?: number
/** /**
* How long after a cancellation an unsettled script may keep running before * How long after a cancellation an unsettled script may keep running before

View File

@@ -8,20 +8,21 @@ Source: [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/work
## The start request ## The start request
What a caller asks for when starting a run. The tool layer builds this from the model's `{ script, args }` plus the calling agent; the engine validates the script's meta block BEFORE the body runs. `parent` is REQUIRED — every child the script spawns is attributed to it (cwd, lineage, and depth flow through the [subagent seam](subagent.md)). `args` must be plain host-realm JSON data; the engine exposes it to the script as the `args` global. What a caller asks for when starting a run. The tool layer builds this from the model's `{ script, meta, args }` call plus the calling agent; `meta` and `args` are plain JSON DATA (the engine shape-validates `meta` and rejects loud BEFORE anything runs — no script text is ever evaluated to obtain it). `parent` is REQUIRED — every child the script spawns is attributed to it (cwd, lineage, and depth flow through the [subagent seam](subagent.md)).
```ts type-equiv ```ts type-equiv
interface WorkflowStartRequest { interface WorkflowStartRequest {
script: string script: string
meta: WorkflowMeta
args?: unknown args?: unknown
parent: Agent parent: Agent
signal?: AbortSignal signal?: AbortSignal
} }
``` ```
## The script's identity: `WorkflowMeta` ## The workflow's identity: `WorkflowMeta`
The validated `export const meta` block (Claude Code dynamic-workflows format — a PURE object literal heading the script). `phases` is progress vocabulary only: `phase()` calls match titles for observers; no execution structure is implied. The identity block carried as data on the start request (the tool's `meta` parameter; the field vocabulary matches the Claude Code dynamic-workflows meta block). `phases` is progress vocabulary only: `phase()` calls match titles for observers; no execution structure is implied.
```ts type-equiv ```ts type-equiv
interface WorkflowMeta { interface WorkflowMeta {

View File

@@ -12,7 +12,7 @@ A workflow capability family at `packages/workflow/` in the bash seam shape (int
### The script contract (Claude Code-compatible) ### The script contract (Claude Code-compatible)
A script is `export const meta = {...}` (a PURE object literal: `name`, `description`, optional `whenToUse`/`phases`) followed by a plain-JS body with top-level `await`, ending in `return <json-value>`. The body sees exactly: `agent(prompt, {label, phase, schema, model})`, `parallel(thunks)`, `pipeline(items, ...stages)` (NO cross-stage barrier; `(prev, item, index)` callbacks), `phase(title)`, `log(message)`, and `args`. CC semantics are preserved where they matter to script authors: a failed child resolves `null` (scripts `.filter(Boolean)`); an ordinary stage throw nulls the ITEM and skips its remaining stages. CC's determinism bans (`Date.now()`/`Math.random()`/argless `new Date()` throwing) are NOT enforced — they exist for CC's journaling/resume, which this cut defers — so a CC-authored script runs unchanged while scripts written here may freely read the clock. A workflow call is two parts: a `meta` JSON parameter (the identity block — `name`, `description`, optional `whenToUse`/`phases`; the field vocabulary matches Claude Code's meta block) and a `script` — a plain-JS body with top-level `await`, ending in `return <json-value>`. Meta is DATA, never code: the engine shape-validates it and evaluates no script text to obtain it (a body still opening with a CC-style `export const meta` statement is rejected with a pointed message). The body sees exactly: `agent(prompt, {label, phase, schema, model})`, `parallel(thunks)`, `pipeline(items, ...stages)` (NO cross-stage barrier; `(prev, item, index)` callbacks), `phase(title)`, `log(message)`, and `args`. CC semantics are preserved where they matter to script authors: a failed child resolves `null` (scripts `.filter(Boolean)`); an ordinary stage throw nulls the ITEM and skips its remaining stages. CC's determinism bans (`Date.now()`/`Math.random()`/argless `new Date()` throwing) are NOT enforced — they exist for CC's journaling/resume, which this cut defers — so a CC-authored BODY runs unchanged (its meta header moves into the parameter) while scripts written here may freely read the clock.
One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferred options (`effort`/`isolation`/`agentType`), malformed arguments, schemas outside the supported subset, tripped caps, seam start failures — throws a `WorkflowError` with `fatal: true`, and the combinators RE-THROW fatal errors instead of nulling the item. Without this, a typo'd option dissolves into a `null` indistinguishable from a child failure — the accepted-then-ignored failure mode this repo bans. One addition: the tool's `args` parameter is a JSON OBJECT (a bare list is wrapped as a field) so the wire schema stays honest. One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferred options (`effort`/`isolation`/`agentType`), malformed arguments, schemas outside the supported subset, tripped caps, seam start failures — throws a `WorkflowError` with `fatal: true`, and the combinators RE-THROW fatal errors instead of nulling the item. Without this, a typo'd option dissolves into a `null` indistinguishable from a child failure — the accepted-then-ignored failure mode this repo bans. One addition: the tool's `args` parameter is a JSON OBJECT (a bare list is wrapped as a field) so the wire schema stays honest.
@@ -24,15 +24,15 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre
**Trust premise (governs every engine decision below)**: workflow scripts are MODEL-WRITTEN — the same trust level as the model's existing bash access — so the engine defends against BUGGY scripts, never hostile ones. In scope: `result` never rejects, no unhandled rejections from dropped hook promises, loud rejection of values JSON cannot carry, fatal-vs-null hook discipline, cancellation that always frees the caller. Out of scope, deliberately: adversarial values (throwing/spinning accessors, proxies with hostile traps, prototype forgery, `prepareStackTrace` hijack) AND Node-API escape from the script's context — the vm context shares object machinery with its surrounding realm, so a script can reach the `Function` constructor (`globalThis.constructor.constructor`) and from it `process` and every Node builtin; the absent globals are API surface, not containment, and a worker thread is NOT a security boundary (an escapee holds process-wide privileges — Node's permission model is per-process). Worker-side code MAY run script code while reading script values, and that is accepted: a synchronous spin costs the script its OWN thread (terminated at the post-cancel grace), never the host loop, so containing error VALUES would be cost without a threat model. Genuine sandboxing (isolated-vm, a separate process) remains an engine swap behind the seam, not incremental defenses here. **Trust premise (governs every engine decision below)**: workflow scripts are MODEL-WRITTEN — the same trust level as the model's existing bash access — so the engine defends against BUGGY scripts, never hostile ones. In scope: `result` never rejects, no unhandled rejections from dropped hook promises, loud rejection of values JSON cannot carry, fatal-vs-null hook discipline, cancellation that always frees the caller. Out of scope, deliberately: adversarial values (throwing/spinning accessors, proxies with hostile traps, prototype forgery, `prepareStackTrace` hijack) AND Node-API escape from the script's context — the vm context shares object machinery with its surrounding realm, so a script can reach the `Function` constructor (`globalThis.constructor.constructor`) and from it `process` and every Node builtin; the absent globals are API surface, not containment, and a worker thread is NOT a security boundary (an escapee holds process-wide privileges — Node's permission model is per-process). Worker-side code MAY run script code while reading script values, and that is accepted: a synchronous spin costs the script its OWN thread (terminated at the post-cancel grace), never the host loop, so containing error VALUES would be cost without a threat model. Genuine sandboxing (isolated-vm, a separate process) remains an engine swap behind the seam, not incremental defenses here.
**Why node:worker_threads**: one run = one worker thread, no pooling — a run is heavyweight (many children), so thread spin-up (~tens of ms) is noise. The script runs in a vm context INSIDE the worker, keeping the script-visible surface exactly the hook contract above (a bare worker realm would leak `setTimeout`/`fetch`/`process` as accidental API), and every `agent()` bridges to `ctx.subagents` by message-port RPC — children are I/O-bound LLM loops and stay on the host loop; the thread isolates the SCRIPT, the only part that can spin. What the thread buys: `start()` never blocks the host (an in-process engine runs the initial synchronous slice inline and cannot kill a spin past the first await — it could only ABANDON such a script, leaving the spin on the host loop), the post-cancel grace ends in a REAL `worker.terminate()`, and the value boundary is serialization by construction. isolated-vm was rejected for actual sandboxing: maintenance mode, `--no-node-snapshot` on EVERY consumer process (including published bins) on Node ≥ 20, node-gyp source-build fallback. Key mechanics (details in the package README): meta extraction and a body pre-parse stay HOST-side (preserving the seam's synchronous throws), a ready→go handshake keeps a run cancelled before start from ever executing the body, `cancel()` drives both child-cancel channels host-side (the shared request signal AND each child's explicit `cancel()` — a wedged worker cannot relay its own cancel RPCs), a host-side child registry backs worker-death reaping and `dispose()` quiescence, the wire protocol is enum-keyed payload maps private to the package, and on a termination path `agentsStarted` degrades to the host-observed count. Coverage puts the worker-side session on an in-process `MessageChannel` (real-Worker code is invisible to main-process v8) and proves the built `lib/worker.js` — a second tsdown entry, sanctioned in the workspace-constraints gate by the `"./worker"` subpath export — under plain node in the built-bin smoke gate. **Why node:worker_threads**: one run = one worker thread, no pooling — a run is heavyweight (many children), so thread spin-up (~tens of ms) is noise. The script runs in a vm context INSIDE the worker, keeping the script-visible surface exactly the hook contract above (a bare worker realm would leak `setTimeout`/`fetch`/`process` as accidental API), and every `agent()` bridges to `ctx.subagents` by message-port RPC — children are I/O-bound LLM loops and stay on the host loop; the thread isolates the SCRIPT, the only part that can spin. What the thread buys: `start()` never blocks the host (an in-process engine runs the initial synchronous slice inline and cannot kill a spin past the first await — it could only ABANDON such a script, leaving the spin on the host loop), the post-cancel grace ends in a REAL `worker.terminate()`, and the value boundary is serialization by construction. isolated-vm was rejected for actual sandboxing: maintenance mode, `--no-node-snapshot` on EVERY consumer process (including published bins) on Node ≥ 20, node-gyp source-build fallback. Key mechanics (details in the package README): meta shape-validation and a body pre-parse stay HOST-side (preserving the seam's synchronous throws), a ready→go handshake keeps a run cancelled before start from ever executing the body, `cancel()` drives both child-cancel channels host-side (the shared request signal AND each child's explicit `cancel()` — a wedged worker cannot relay its own cancel RPCs), a host-side child registry backs worker-death reaping and `dispose()` quiescence, the wire protocol is enum-keyed payload maps private to the package, and on a termination path `agentsStarted` degrades to the host-observed count. Coverage puts the worker-side session on an in-process `MessageChannel` (real-Worker code is invisible to main-process v8) and proves the built `lib/worker.js` — a second tsdown entry, sanctioned in the workspace-constraints gate by the `"./worker"` subpath export — under plain node in the built-bin smoke gate.
**Meta extraction**: a string/comment-aware brace scanner (template interpolation rejected) finds the literal; it is evaluated ALONE in an empty, timed vm context; the result must materialize to plain JSON data and pass shape validation (unknown fields rejected loud); the statement is blanked line-preservingly so stacks keep script line numbers. **Meta as data, never evaluated**: the meta block reaches the seam as a plain JSON request field (the tool's schema-validated `meta` parameter) and the engine only shape-validates it, every violation named. This is a host-isolation invariant, not a convenience: evaluating a meta literal host-side — even one contractually "pure", in an empty timed vm context — hands script-controlled getters a host stack with no timeout the moment the result is READ, defeating the exact spin isolation the worker thread buys.
**Value boundary**: values leaving the script (meta, hook options, schemas, the return value) go through `materializeFromRealm` — a plain recursive walk that rejects loud everything JSON cannot carry (exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`), copying via `Object.defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation; getters are read ordinarily and their RESULT crosses (a throwing read fails loud) — which is also what makes every later postMessage hop total. Values entering the realm (`args`, `agent()` results, hook promises and failures, combinator arrays) are handed over directly as worker-realm values — the script is trusted, so outer prototypes are not a leak; `args` rides the `workerData` structured clone (the caller-isolation copy) and is cloned once more so a script scribbling on it cannot mutate the session's init object. Hook failures are `WorkflowError`s built OUTSIDE the script's context: the combinators recognize fatality by `instanceof` against the engine's own class (unforgeable from the script), and the script-visible consequence — in-script `instanceof Error` is `false` for hook errors; branch on `e.name`/`e.code` — is documented in the engine README. Realm functions (stages, thunks) are called, never materialized. Thrown script values are rendered by a total renderer (stack → message → `String()`, fixed label if rendering throws), so `result` cannot reject. Caps (`maxConcurrentAgents` auto = `min(16, max(1, availableParallelism() - 2))`, `maxTotalAgents` 1000, `maxItemsPerCall` 4096) and timeouts are validated Config, not literals. **Value boundary**: values leaving the script (meta, hook options, schemas, the return value) go through `materializeFromRealm` — a plain recursive walk that rejects loud everything JSON cannot carry (exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`), copying via `Object.defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation; getters are read ordinarily and their RESULT crosses (a throwing read fails loud) — which is also what makes every later postMessage hop total. Values entering the realm (`args`, `agent()` results, hook promises and failures, combinator arrays) are handed over directly as worker-realm values — the script is trusted, so outer prototypes are not a leak; `args` rides the `workerData` structured clone (the caller-isolation copy) and is cloned once more so a script scribbling on it cannot mutate the session's init object. Hook failures are `WorkflowError`s built OUTSIDE the script's context: the combinators recognize fatality by `instanceof` against the engine's own class (unforgeable from the script), and the script-visible consequence — in-script `instanceof Error` is `false` for hook errors; branch on `e.name`/`e.code` — is documented in the engine README. Realm functions (stages, thunks) are called, never materialized. Thrown script values are rendered by a total renderer (stack → message → `String()`, fixed label if rendering throws), so `result` cannot reject. Caps (`maxConcurrentAgents` auto = `min(16, max(1, availableParallelism() - 2))`, `maxTotalAgents` 1000, `maxItemsPerCall` 4096) and timeouts are validated Config, not literals.
### The consumer (dsh-tool-workflow) ### The consumer (dsh-tool-workflow)
A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, await, `try/finally` dispose, abort-bridge `exec.signal`, non-`completed` → `isError`. Render intent: a `generic` card titled by a textual `meta.name` sniff (presentation is a pure function of args). The tool description IS the model-facing authoring spec. The usage policy ships with the tool as its own `tool:<toolName>` prompt section (explicit-ask-only guidance — tool guidance lives in tool plugins, never in the deployment persona); the harness has no ultracode-style effort gate. A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, await, `try/finally` dispose, abort-bridge `exec.signal`, non-`completed` → `isError`. Render intent: a `generic` card titled by the call's `meta.name` parameter (presentation is a pure function of args). The tool description IS the model-facing authoring spec. The usage policy ships with the tool as its own `tool:<toolName>` prompt section (explicit-ask-only guidance — tool guidance lives in tool plugins, never in the deployment persona); the harness has no ultracode-style effort gate.
### The foundation: structured output on the subagent seam ### The foundation: structured output on the subagent seam
@@ -55,7 +55,7 @@ A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, awai
- **In-process `node:vm` execution** (the first cut of this RFC shipped it): mechanically simplest — no RPC, no thread — but `start()` blocks the caller for the script's initial synchronous slice, a synchronous spin past the first await cannot be killed in-process (the vm `timeout` covers only that first slice), and `dispose()` could only ABANDON an unsettling script, leaving the spin on the host loop. Superseded by the worker-thread engine, which keeps the same vm-context script surface while unblocking the host and making termination real. - **In-process `node:vm` execution** (the first cut of this RFC shipped it): mechanically simplest — no RPC, no thread — but `start()` blocks the caller for the script's initial synchronous slice, a synchronous spin past the first await cannot be killed in-process (the vm `timeout` covers only that first slice), and `dispose()` could only ABANDON an unsettling script, leaving the spin on the host loop. Superseded by the worker-thread engine, which keeps the same vm-context script surface while unblocking the host and making termination real.
- **Background execution as the default** (CC's shape): deferred; foreground-synchronous matches `dsh-tool-subagent`'s cut, and background semantics should be designed ONCE across bash/subagent/workflow rather than per-tool. - **Background execution as the default** (CC's shape): deferred; foreground-synchronous matches `dsh-tool-subagent`'s cut, and background semantics should be designed ONCE across bash/subagent/workflow rather than per-tool.
- **Workflow-layer JSON parsing for `agent({schema})`**: duplicating a seam concern at one consumer while the seam's capability flag stayed dishonestly `false`. - **Workflow-layer JSON parsing for `agent({schema})`**: duplicating a seam concern at one consumer while the seam's capability flag stayed dishonestly `false`.
- **Meta as tool parameters instead of `export const meta`**: zero parsing, but scripts stop being self-contained artifacts and CC-authored scripts stop being drop-in. - **Meta embedded in the script as `export const meta = {...}`** (CC's exact format; the first cut shipped it): keeps scripts self-contained and CC scripts drop-in, but obtaining meta means evaluating model-written text on the HOST — the shipped extractor ran the literal in an empty timed vm context, yet reading the RESULT still executed script-controlled getters on the host stack outside any timeout, re-opening the host-spin hole the worker thread exists to close. A JSON parameter deletes the scanner, the evaluation, and the hole outright; the cost is that a CC script's meta header must move into the parameter (the body stays drop-in).
- **`SchemaSpec` as the outputSchema type**: the author-facing DSL cannot express what arrives as data and cannot be validated against without conversion loss. - **`SchemaSpec` as the outputSchema type**: the author-facing DSL cannot express what arrives as data and cannot be validated against without conversion loss.
- **A schema-object library (zod, or the repo's schemastery) for the structured-output subset**: the schema is wire data — plain JSON that crosses the vm realm boundary in `agent({schema})` and lands verbatim in the forced tool's parameters — exactly where live schema objects cannot sit; consuming raw JSON Schema at runtime would need a third-party converter on top (zod core only emits JSON Schema, not the reverse), and it would put a second schema language beside schemastery's config role. - **A schema-object library (zod, or the repo's schemastery) for the structured-output subset**: the schema is wire data — plain JSON that crosses the vm realm boundary in `agent({schema})` and lands verbatim in the forced tool's parameters — exactly where live schema objects cannot sit; consuming raw JSON Schema at runtime would need a third-party converter on top (zod core only emits JSON Schema, not the reverse), and it would put a second schema language beside schemastery's config role.
- **ajv for value validation**: it validates FULL JSON Schema, so the subset gate — the module's actual point, since every accepted keyword must be one the harness enforces — would remain hand-written regardless; it compiles validators through `new Function`; and it would be dsh-tools' first runtime dependency, all to replace the ~70-line value walker while the path-qualified, every-violation error reporting stays custom either way. - **ajv for value validation**: it validates FULL JSON Schema, so the subset gate — the module's actual point, since every accepted keyword must be one the harness enforces — would remain hand-written regardless; it compiles validators through `new Function`; and it would be dsh-tools' first runtime dependency, all to replace the ~70-line value walker while the path-qualified, every-violation error reporting stays custom either way.

View File

@@ -283,7 +283,7 @@ todo_write is session-owned state; UIs render the latest todo/write event as a c
Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.
The script MUST begin with `export const meta = {...}` — a PURE object literal (no variables, calls, or template interpolation) with required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The body after it is plain JavaScript (NOT TypeScript) running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.
Script-body hooks: Script-body hooks:
- `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.
@@ -301,7 +301,53 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim
"properties": { "properties": {
"script": { "script": {
"type": "string", "type": "string",
"description": "The complete workflow script: `export const meta = {...}` followed by the plain-JS body (top-level await allowed; end with `return <json-value>`)." "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`)."
},
"meta": {
"type": "object",
"description": "The workflow identity block (plain JSON — never code).",
"properties": {
"name": {
"type": "string",
"description": "Short kebab-case workflow name."
},
"description": {
"type": "string",
"description": "One-line description of what the workflow does."
},
"whenToUse": {
"type": "string",
"description": "Optional guidance on when this workflow applies."
},
"phases": {
"type": "array",
"description": "Optional phase declarations matched by phase() calls.",
"items": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "The phase title phase() calls match by exact string."
},
"detail": {
"type": "string",
"description": "Optional one-line description of the phase."
},
"model": {
"type": "string",
"description": "Optional model override this phase is expected to use."
}
},
"required": [
"title"
]
}
}
},
"required": [
"name",
"description"
]
}, },
"args": { "args": {
"type": "object", "type": "object",
@@ -309,7 +355,8 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim
} }
}, },
"required": [ "required": [
"script" "script",
"meta"
] ]
} }
``` ```

View File

@@ -13,9 +13,8 @@
* collection is deferred to the cross-tool background redesign. * collection is deferred to the cross-tool background redesign.
* *
* Render intent (decided up front, per the render-intent RFC): a `generic` * Render intent (decided up front, per the render-intent RFC): a `generic`
* card whose title carries the script's `meta.name`, sniffed textually from * card whose title carries the workflow's `meta.name`, read directly from the
* the args — presentation must be a pure function of `args`, so it cannot ask * call's `meta` parameter — presentation is a pure function of `args`.
* the engine to parse.
* *
* Usage policy ships with the tool as a `tool:<toolName>` system-prompt * Usage policy ships with the tool as a `tool:<toolName>` system-prompt
* section (explicit-ask-only guidance) — tool guidance lives in tool plugins, * section (explicit-ask-only guidance) — tool guidance lives in tool plugins,
@@ -58,7 +57,7 @@ type ResolvedConfig = Required<Config>
*/ */
const DESCRIPTION = `Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. const DESCRIPTION = `Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.
The script MUST begin with \`export const meta = {...}\` — a PURE object literal (no variables, calls, or template interpolation) with required \`name\` (short kebab-case) and \`description\` strings, optional \`whenToUse\` string and \`phases\` array (\`{title, detail?, model?}\`). The body after it is plain JavaScript (NOT TypeScript) running with top-level await; end with \`return <value>\` — the value must be JSON-serializable and is this tool's result. The workflow's identity rides the \`meta\` parameter as JSON: required \`name\` (short kebab-case) and \`description\` strings, optional \`whenToUse\` string and \`phases\` array (\`{title, detail?, model?}\`). The \`script\` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO \`export const meta\` statement — meta is a parameter, not code), running with top-level await; end with \`return <value>\` — the value must be JSON-serializable and is this tool's result.
Script-body hooks: Script-body hooks:
- \`agent(prompt, opts?): Promise<any>\` — run one subagent to completion. Without \`opts.schema\` it resolves to the child's final text; with \`opts.schema\` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves \`null\` when the child fails (filter with \`.filter(Boolean)\`). Other opts: \`label\` (display), \`phase\` (progress group), \`model\` (override). Anything else (\`effort\`/\`isolation\`/\`agentType\`) is rejected loudly. - \`agent(prompt, opts?): Promise<any>\` — run one subagent to completion. Without \`opts.schema\` it resolves to the child's final text; with \`opts.schema\` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves \`null\` when the child fails (filter with \`.filter(Boolean)\`). Other opts: \`label\` (display), \`phase\` (progress group), \`model\` (override). Anything else (\`effort\`/\`isolation\`/\`agentType\`) is rejected loudly.
@@ -70,20 +69,17 @@ Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps
Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.` Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.`
type WorkflowCallArgs = { script: string; args?: Record<string, unknown> } type WorkflowCallArgs = {
script: string
/** Best-effort meta.name sniff for presentation (pure textual; no evaluation). */ meta: { name: string; description: string; whenToUse?: string; phases?: { title: string; detail?: string; model?: string }[] }
function sniffMetaName(script: string): string | undefined { args?: Record<string, unknown>
const match = /export\s+const\s+meta\s*=\s*\{[^{}]*?name\s*:\s*(['"`])([^'"`\n]{1,64})\1/.exec(script)
return match?.[2]
} }
/** The pending-state card: a generic card titled by the script's meta name. */ /** The pending-state card: a generic card titled by the workflow's meta name. */
function presentWorkflowCall(args: WorkflowCallArgs): ToolCallView { function presentWorkflowCall(args: WorkflowCallArgs): ToolCallView {
const name = sniffMetaName(args.script)
return { return {
card: 'generic', card: 'generic',
title: name !== undefined ? `workflow: ${name}` : 'workflow', title: `workflow: ${args.meta.name}`,
rawInput: args.script, rawInput: args.script,
} }
} }
@@ -139,7 +135,29 @@ export function apply(ctx: Context, config: Config): void {
script: { script: {
type: 'string', type: 'string',
required: true, required: true,
description: 'The complete workflow script: `export const meta = {...}` followed by the plain-JS body (top-level await allowed; end with `return <json-value>`).', description: 'The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`).',
},
meta: {
type: 'object',
required: true,
description: 'The workflow identity block (plain JSON — never code).',
properties: {
name: { type: 'string', required: true, description: 'Short kebab-case workflow name.' },
description: { type: 'string', required: true, description: 'One-line description of what the workflow does.' },
whenToUse: { type: 'string', description: 'Optional guidance on when this workflow applies.' },
phases: {
type: 'array',
description: 'Optional phase declarations matched by phase() calls.',
items: {
type: 'object',
properties: {
title: { type: 'string', required: true, description: 'The phase title phase() calls match by exact string.' },
detail: { type: 'string', description: 'Optional one-line description of the phase.' },
model: { type: 'string', description: 'Optional model override this phase is expected to use.' },
},
},
},
},
}, },
args: { args: {
type: 'object', type: 'object',
@@ -155,11 +173,12 @@ export function apply(ctx: Context, config: Config): void {
throw new Error('workflow tool requires a calling agent (exec.agent was undefined)') throw new Error('workflow tool requires a calling agent (exec.agent was undefined)')
} }
// Parse failures (SCRIPT_PARSE/META_INVALID) throw synchronously here // Meta/body validation failures (META_INVALID/SCRIPT_PARSE) throw
// and become isError results via the registry — the model sees the // synchronously here and become isError results via the registry — the
// violation list and can correct the script. // model sees the violation list and can correct the call.
const run: WorkflowRun = ctx.workflows.start({ const run: WorkflowRun = ctx.workflows.start({
script: args.script, script: args.script,
meta: args.meta,
...args.args !== undefined ? { args: args.args } : {}, ...args.args !== undefined ? { args: args.args } : {},
parent, parent,
...exec.signal ? { signal: exec.signal } : {}, ...exec.signal ? { signal: exec.signal } : {},

View File

@@ -55,7 +55,8 @@ async function setup(config?: { toolName?: string; maxResultChars?: number }) {
return { ctx, engine, parent } return { ctx, engine, parent }
} }
const SCRIPT = "export const meta = { name: 'audit', description: 'd' }\nreturn 1" const SCRIPT = 'return 1'
const META = { name: 'audit', description: 'd' }
function execute(ctx: Context, args: unknown, extra?: { agent?: Agent; signal?: AbortSignal }): Promise<ToolExecutionResult> { function execute(ctx: Context, args: unknown, extra?: { agent?: Agent; signal?: AbortSignal }): Promise<ToolExecutionResult> {
return ctx.tools.execute({ return ctx.tools.execute({
@@ -71,9 +72,9 @@ describe('dsh-tool-workflow', () => {
it('starts a run with the script/args/parent/signal and renders the completed value', async () => { it('starts a run with the script/args/parent/signal and renders the completed value', async () => {
const { ctx, engine, parent } = await setup() const { ctx, engine, parent } = await setup()
const controller = new AbortController() const controller = new AbortController()
const pending = execute(ctx, { script: SCRIPT, args: { files: ['a.ts'] } }, { agent: parent, signal: controller.signal }) const pending = execute(ctx, { script: SCRIPT, meta: META, args: { files: ['a.ts'] } }, { agent: parent, signal: controller.signal })
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) }) await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
expect(engine.requests[0]).toMatchObject({ script: SCRIPT, args: { files: ['a.ts'] }, parent }) expect(engine.requests[0]).toMatchObject({ script: SCRIPT, meta: META, args: { files: ['a.ts'] }, parent })
expect(engine.requests[0]!.signal).toBe(controller.signal) expect(engine.requests[0]!.signal).toBe(controller.signal)
engine.settle({ value: { findings: [1, 2] }, stopReason: 'completed', agentsStarted: 7 }) engine.settle({ value: { findings: [1, 2] }, stopReason: 'completed', agentsStarted: 7 })
const result = await pending const result = await pending
@@ -86,7 +87,7 @@ describe('dsh-tool-workflow', () => {
it('maps a non-completed stop reason to an isError result (and still disposes)', async () => { it('maps a non-completed stop reason to an isError result (and still disposes)', async () => {
const { ctx, engine, parent } = await setup() const { ctx, engine, parent } = await setup()
const pending = execute(ctx, { script: SCRIPT }, { agent: parent }) const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) }) await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
engine.settle({ value: null, stopReason: 'error', error: 'script threw: boom', agentsStarted: 2 }) engine.settle({ value: null, stopReason: 'error', error: 'script threw: boom', agentsStarted: 2 })
const result = await pending const result = await pending
@@ -97,14 +98,14 @@ describe('dsh-tool-workflow', () => {
it('reports a cancelled run distinctly (with and without a reason)', async () => { it('reports a cancelled run distinctly (with and without a reason)', async () => {
const { ctx, engine, parent } = await setup() const { ctx, engine, parent } = await setup()
const pending = execute(ctx, { script: SCRIPT }, { agent: parent }) const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) }) await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
engine.settle({ value: null, stopReason: 'cancelled', error: 'user', agentsStarted: 0 }) engine.settle({ value: null, stopReason: 'cancelled', error: 'user', agentsStarted: 0 })
const result = await pending const result = await pending
expect(result.isError).toBe(true) expect(result.isError).toBe(true)
expect((result.content[0] as { text: string }).text).toContain('workflow run was cancelled (user)') expect((result.content[0] as { text: string }).text).toContain('workflow run was cancelled (user)')
const bare = execute(ctx, { script: SCRIPT }, { agent: parent }) const bare = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests.length).toBe(2) }) await vi.waitFor(() => { expect(engine.requests.length).toBe(2) })
engine.settle({ value: null, stopReason: 'cancelled', agentsStarted: 0 }) engine.settle({ value: null, stopReason: 'cancelled', agentsStarted: 0 })
expect(((await bare).content[0] as { text: string }).text.trim().endsWith('cancelled')).toBe(true) expect(((await bare).content[0] as { text: string }).text.trim().endsWith('cancelled')).toBe(true)
@@ -112,7 +113,7 @@ describe('dsh-tool-workflow', () => {
it('an error result without a message renders the unknown-error fallback', async () => { it('an error result without a message renders the unknown-error fallback', async () => {
const { ctx, engine, parent } = await setup() const { ctx, engine, parent } = await setup()
const pending = execute(ctx, { script: SCRIPT }, { agent: parent }) const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) }) await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
engine.settle({ value: null, stopReason: 'error', agentsStarted: 0 }) engine.settle({ value: null, stopReason: 'error', agentsStarted: 0 })
expect(((await pending).content[0] as { text: string }).text).toContain('unknown error') expect(((await pending).content[0] as { text: string }).text).toContain('unknown error')
@@ -121,7 +122,7 @@ describe('dsh-tool-workflow', () => {
it('cancels the run when exec.signal aborts MID-FLIGHT (the abort bridge)', async () => { it('cancels the run when exec.signal aborts MID-FLIGHT (the abort bridge)', async () => {
const { ctx, engine, parent } = await setup() const { ctx, engine, parent } = await setup()
const controller = new AbortController() const controller = new AbortController()
const pending = execute(ctx, { script: SCRIPT }, { agent: parent, signal: controller.signal }) const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent, signal: controller.signal })
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) }) await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
controller.abort() controller.abort()
const result = await pending const result = await pending
@@ -130,17 +131,17 @@ describe('dsh-tool-workflow', () => {
expect(engine.disposed).toBe(1) expect(engine.disposed).toBe(1)
}) })
it('a synchronous engine start throw (parse/meta failure) becomes an isError result', async () => { it('a synchronous engine start throw (meta/parse failure) becomes an isError result', async () => {
const { ctx, engine, parent } = await setup() const { ctx, engine, parent } = await setup()
engine.startError = new Error('script must begin with `export const meta = {...}`') engine.startError = new Error('invalid meta: meta.name must be a non-empty string')
const result = await execute(ctx, { script: 'nope' }, { agent: parent }) const result = await execute(ctx, { script: 'nope', meta: { name: '', description: 'd' } }, { agent: parent })
expect(result.isError).toBe(true) expect(result.isError).toBe(true)
expect((result.content[0] as { text: string }).text).toContain('must begin with') expect((result.content[0] as { text: string }).text).toContain('meta.name must be a non-empty string')
}) })
it('requires a calling agent (fails loud without exec.agent)', async () => { it('requires a calling agent (fails loud without exec.agent)', async () => {
const { ctx, engine } = await setup() const { ctx, engine } = await setup()
const result = await execute(ctx, { script: SCRIPT }) const result = await execute(ctx, { script: SCRIPT, meta: META })
expect(result.isError).toBe(true) expect(result.isError).toBe(true)
expect((result.content[0] as { text: string }).text).toContain('requires a calling agent') expect((result.content[0] as { text: string }).text).toContain('requires a calling agent')
expect(engine.requests.length).toBe(0) expect(engine.requests.length).toBe(0)
@@ -157,7 +158,7 @@ describe('dsh-tool-workflow', () => {
const { ctx, engine, parent } = await setup() const { ctx, engine, parent } = await setup()
const controller = new AbortController() const controller = new AbortController()
controller.abort() controller.abort()
const result = await execute(ctx, { script: SCRIPT }, { agent: parent, signal: controller.signal }) const result = await execute(ctx, { script: SCRIPT, meta: META }, { agent: parent, signal: controller.signal })
expect(result.isError).toBe(true) expect(result.isError).toBe(true)
expect(engine.cancels).toContain('parent step aborted') expect(engine.cancels).toContain('parent step aborted')
expect(engine.disposed).toBe(1) expect(engine.disposed).toBe(1)
@@ -165,7 +166,7 @@ describe('dsh-tool-workflow', () => {
it('truncates an oversized rendered value with a notice (maxResultChars)', async () => { it('truncates an oversized rendered value with a notice (maxResultChars)', async () => {
const { ctx, engine, parent } = await setup({ maxResultChars: 40 }) const { ctx, engine, parent } = await setup({ maxResultChars: 40 })
const pending = execute(ctx, { script: SCRIPT }, { agent: parent }) const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) }) await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
engine.settle({ value: { blob: 'x'.repeat(500) }, stopReason: 'completed', agentsStarted: 1 }) engine.settle({ value: { blob: 'x'.repeat(500) }, stopReason: 'completed', agentsStarted: 1 })
const rendered = ((await pending).content[0] as { text: string }).text const rendered = ((await pending).content[0] as { text: string }).text
@@ -193,22 +194,22 @@ describe('dsh-tool-workflow', () => {
expect((await ctx.systemPrompt.assemble()).sections.some(s => s.name === 'tool:orchestrate')).toBe(false) expect((await ctx.systemPrompt.assemble()).sections.some(s => s.name === 'tool:orchestrate')).toBe(false)
}) })
it('presents a generic pending card titled by the sniffed meta name, with the script as rawInput', async () => { it('presents a generic pending card titled by the meta name, with the script as rawInput', async () => {
const { ctx } = await setup() const { ctx } = await setup()
const tool = ctx.tools.get('workflow')! const tool = ctx.tools.get('workflow')!
const view = tool.presentCall!({ script: SCRIPT }) const view = tool.presentCall!({ script: SCRIPT, meta: META })
expect(view).toMatchObject({ card: 'generic', title: 'workflow: audit', rawInput: SCRIPT }) expect(view).toMatchObject({ card: 'generic', title: 'workflow: audit', rawInput: SCRIPT })
const anonymous = tool.presentCall!({ script: 'export const meta = {}\nreturn 1' })
expect(anonymous).toMatchObject({ card: 'generic', title: 'workflow' })
}) })
it('presentResult keeps the generic card; presentation is pure and replay-safe on malformed args', async () => { it('presentResult keeps the generic card; presentation is pure and replay-safe on malformed args', async () => {
const { ctx } = await setup() const { ctx } = await setup()
const tool = ctx.tools.get('workflow')! const tool = ctx.tools.get('workflow')!
expect(tool.presentResult!({ script: SCRIPT }, { content: [], isError: false })).toEqual({ card: 'generic' }) expect(tool.presentResult!({ script: SCRIPT, meta: META }, { content: [], isError: false })).toEqual({ card: 'generic' })
// defineTool soft-validates presentation args: a malformed logged shape // defineTool soft-validates presentation args: a malformed logged shape
// falls back to undefined instead of throwing mid-replay. // (wrong fields entirely, or a call missing its meta) falls back to
// undefined instead of throwing mid-replay.
expect(tool.presentCall!({ not: 'the schema' })).toBeUndefined() expect(tool.presentCall!({ not: 'the schema' })).toBeUndefined()
expect(tool.presentCall!({ script: SCRIPT })).toBeUndefined()
}) })
it('has the namespace-plugin export shape (no stray default)', () => { it('has the namespace-plugin export shape (no stray default)', () => {
@@ -239,7 +240,8 @@ describe('dsh-tool-workflow', () => {
const parent = { id: AgentId('caller'), options: {} } as unknown as Agent const parent = { id: AgentId('caller'), options: {} } as unknown as Agent
const controller = new AbortController() const controller = new AbortController()
const pending = execute(ctx, { const pending = execute(ctx, {
script: "export const meta = { name: 'stuck', description: 'parks forever' }\nawait new Promise(() => {})\nreturn 1", script: 'await new Promise(() => {})\nreturn 1',
meta: { name: 'stuck', description: 'parks forever' },
}, { agent: parent, signal: controller.signal }) }, { agent: parent, signal: controller.signal })
// Give the run a beat to start (past its synchronous slice), then abort. // Give the run a beat to start (past its synchronous slice), then abort.
await new Promise(resolve => setTimeout(resolve, 20)) await new Promise(resolve => setTimeout(resolve, 20))

View File

@@ -14,19 +14,19 @@ What the seam guarantees regardless, because benign scripts hit these constantly
## The script contract it executes ## The script contract it executes
- **Meta extraction** (`extractMeta`, host-side): a string/comment-aware brace scanner finds the leading `export const meta` literal (template interpolation rejected — the literal must be pure), evaluates it ALONE in an empty timed vm context, materializes the result to plain JSON data, validates the shape (`name`/`description` required; unknown fields rejected loud), and blanks the statement line-preservingly so error stacks keep the script's own line numbers. - **Meta as DATA** (`validateMeta`, host-side): the workflow's identity arrives on the start request as plain JSON (the tool carries it as its schema-validated `meta` parameter — never as script text) and is shape-validated loud, every violation named (`name`/`description` required; unknown fields rejected). The engine deliberately evaluates NO script text to obtain meta: an evaluated meta literal could smuggle getters that run on the host outside any vm timeout — the exact spin the worker thread exists to isolate. A body that still opens with a Claude Code-style `export const meta` statement is rejected with a pointed `SCRIPT_PARSE` message.
- **Hooks**: `agent(prompt, {label, phase, schema, model})` (schema = the [structured-output subset](../../core/tools/README.md), forwarded as `outputSchema`; result = validated object, or final text without a schema; a failed child resolves `null`), `parallel(thunks)`, `pipeline(items, ...stages)` with NO cross-stage barrier and `(prev, item, index)` stage callbacks, `phase(title)`, `log(message)`, and the `args` global. Anything else — `effort`/`isolation`/`agentType`, unknown options, malformed arguments, schemas outside the subset — throws a FATAL `WorkflowError` that `parallel`/`pipeline` re-throw rather than nulling (see the seam README's failure discipline). - **Hooks**: `agent(prompt, {label, phase, schema, model})` (schema = the [structured-output subset](../../core/tools/README.md), forwarded as `outputSchema`; result = validated object, or final text without a schema; a failed child resolves `null`), `parallel(thunks)`, `pipeline(items, ...stages)` with NO cross-stage barrier and `(prev, item, index)` stage callbacks, `phase(title)`, `log(message)`, and the `args` global. Anything else — `effort`/`isolation`/`agentType`, unknown options, malformed arguments, schemas outside the subset — throws a FATAL `WorkflowError` that `parallel`/`pipeline` re-throw rather than nulling (see the seam README's failure discipline).
- **No ambient APIs**: no timers, filesystem, or Node APIs are injected into the context (absence is API surface, not containment — see the trust premise). - **No ambient APIs**: no timers, filesystem, or Node APIs are injected into the context (absence is API surface, not containment — see the trust premise).
## How a run executes ## How a run executes
`start()` extracts and validates the meta HOST-side and parse-checks the body with the identical wrapper the worker compiles (`new vm.Script`, discarded), preserving the seam's synchronous `SCRIPT_PARSE`/`META_INVALID` throws; one redundant parse per run is the deliberate price. It then spawns the worker (`src/worker.ts` unbuilt via an explicit tsx `execArgv`; the sibling `lib/worker.js` bundle when built) with the meta, blanked body, `args`, and worker-side limits as `workerData`. `start()` shape-validates the meta DATA host-side and parse-checks the body with the identical wrapper the worker compiles (`new vm.Script`, discarded), preserving the seam's synchronous `META_INVALID`/`SCRIPT_PARSE` throws; one redundant parse per run is the deliberate price. It then spawns the worker (`src/worker.ts` unbuilt via an explicit tsx `execArgv`; the sibling `lib/worker.js` bundle when built) with the meta, body, `args`, and worker-side limits as `workerData`.
Inside the worker, `runWorkerSession` builds the execution core (hooks, combinators, concurrency semaphore, caps, fatal-error discipline) over a **child port**: `agent()` sends `child-start` and the host starts the child on `ctx.subagents` (parent attribution, the shared per-run abort signal, `outputSchema`/`model` pass-through), replying with the child id, its settlement (a JSON projection; an infrastructure REJECTION crosses as `child-failed` and stays the fatal `AGENT_RESULT`), and dispose acks. Observer narration (`phase`/`log`/`agent-start`/`agent-end`) crosses as messages and re-emits as the seam's `workflow/*` events. A **ready→go handshake** gates the body: a cancellation racing worker boot arrives before `go`, so a run cancelled before start never executes the body at all. Inside the worker, `runWorkerSession` builds the execution core (hooks, combinators, concurrency semaphore, caps, fatal-error discipline) over a **child port**: `agent()` sends `child-start` and the host starts the child on `ctx.subagents` (parent attribution, the shared per-run abort signal, `outputSchema`/`model` pass-through), replying with the child id, its settlement (a JSON projection; an infrastructure REJECTION crosses as `child-failed` and stays the fatal `AGENT_RESULT`), and dispose acks. Observer narration (`phase`/`log`/`agent-start`/`agent-end`) crosses as messages and re-emits as the seam's `workflow/*` events. A **ready→go handshake** gates the body: a cancellation racing worker boot arrives before `go`, so a run cancelled before start never executes the body at all.
## The value boundary ## The value boundary
Values LEAVING the script (the meta literal, hook options/schemas, the script's return) are materialized by `materializeFromRealm`: a plain recursive walk that rejects loud everything JSON cannot carry (exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`), copying into plain containers via `defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Getters are read ordinarily — the RESULT is what crosses; a read that throws fails loud. Values ENTERING the realm (`args`, `agent()` results, hook promises and their failures, combinator arrays) are handed over directly as worker-realm values — the script is trusted, so outer prototypes are not a leak; `args` is cloned once at start so a script scribbling on it cannot mutate the caller's object. One script-visible consequence: an error thrown by a hook is built OUTSIDE the script's vm context, so `e instanceof Error` inside the script is `false` — branch on `e.name`/`e.code` instead (the combinators recognize fatality by `instanceof` against their own realm's class, which a script-built object can never pass, so fatal-vs-null cannot be forged or dissolved). Values LEAVING the script (hook options/schemas, the script's return) are materialized by `materializeFromRealm`: a plain recursive walk that rejects loud everything JSON cannot carry (exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`), copying into plain containers via `defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Getters are read ordinarily — the RESULT is what crosses; a read that throws fails loud; both run in the WORKER, never on the host. Values ENTERING the realm (`args`, `agent()` results, hook promises and their failures, combinator arrays) are handed over directly as worker-realm values — the script is trusted, so outer prototypes are not a leak; `args` is cloned once at start so a script scribbling on it cannot mutate the caller's object. One script-visible consequence: an error thrown by a hook is built OUTSIDE the script's vm context, so `e instanceof Error` inside the script is `false` — branch on `e.name`/`e.code` instead (the combinators recognize fatality by `instanceof` against their own realm's class, which a script-built object can never pass, so fatal-vs-null cannot be forged or dissolved).
## Cancellation, death, disposal ## Cancellation, death, disposal
@@ -44,5 +44,5 @@ A worker that dies unexpectedly (an OOM, a script reaching `process.exit` throug
| `maxConcurrentAgents` | `0` (auto) | Concurrent `agent()` ceiling; `0` resolves to `min(16, max(1, cores - 2))`. | | `maxConcurrentAgents` | `0` (auto) | Concurrent `agent()` ceiling; `0` resolves to `min(16, max(1, cores - 2))`. |
| `maxTotalAgents` | `1000` | Total `agent()` calls one run may start (runaway-loop backstop). | | `maxTotalAgents` | `1000` | Total `agent()` calls one run may start (runaway-loop backstop). |
| `maxItemsPerCall` | `4096` | Items accepted by one `parallel()`/`pipeline()` call. | | `maxItemsPerCall` | `4096` | Items accepted by one `parallel()`/`pipeline()` call. |
| `syncTimeoutMs` | `5000` | vm timeout for the initial synchronous slice (in the worker) and the host-side meta evaluation. | | `syncTimeoutMs` | `5000` | vm timeout for the script's initial synchronous slice (in the worker). |
| `disposeGraceMs` | `5000` | How long a cancelled run may stay unsettled before force-settle + terminate; also bounds `dispose()`. | | `disposeGraceMs` | `5000` | How long a cancelled run may stay unsettled before force-settle + terminate; also bounds `dispose()`. |

View File

@@ -47,10 +47,10 @@ import z from 'schemastery'
import WorkflowService, { WorkflowError, WorkflowRunId } from '@deepseek-ai/dsh-workflow' import WorkflowService, { WorkflowError, WorkflowRunId } from '@deepseek-ai/dsh-workflow'
import type { WorkflowRun, WorkflowRunInfo, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow' import type { WorkflowRun, WorkflowRunInfo, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
import { WorkerRun } from './host.ts' import { WorkerRun } from './host.ts'
import { extractMeta } from './meta.ts' import { validateMeta } from './meta.ts'
import type { WorkerInit, WorkerLimits } from './types.ts' import type { WorkerInit, WorkerLimits } from './types.ts'
export { extractMeta, type ExtractedScript } from './meta.ts' export { validateMeta } from './meta.ts'
export { HostToWorkerType, WorkerToHostType } from './protocol.ts' export { HostToWorkerType, WorkerToHostType } from './protocol.ts'
export type { HostToWorkerMessage, HostToWorkerPayloads, WorkerToHostMessage, WorkerToHostPayloads } from './protocol.ts' export type { HostToWorkerMessage, HostToWorkerPayloads, WorkerToHostMessage, WorkerToHostPayloads } from './protocol.ts'
export { materializeFromRealm, MaterializeError } from './realm.ts' export { materializeFromRealm, MaterializeError } from './realm.ts'
@@ -75,7 +75,7 @@ export interface Config {
maxTotalAgents?: number maxTotalAgents?: number
/** Items accepted by a single `parallel()`/`pipeline()` call (default 4096). */ /** Items accepted by a single `parallel()`/`pipeline()` call (default 4096). */
maxItemsPerCall?: number maxItemsPerCall?: number
/** vm timeout for the initial synchronous slice (inside the worker) AND the host-side meta evaluation (default 5000 ms). */ /** vm timeout for the script's initial synchronous slice, inside the worker (default 5000 ms). */
syncTimeoutMs?: number syncTimeoutMs?: number
/** /**
* How long after a cancellation an unsettled script may keep running before * How long after a cancellation an unsettled script may keep running before
@@ -87,13 +87,21 @@ export interface Config {
type ResolvedConfig = Required<Config> type ResolvedConfig = Required<Config>
/** A body that still carries the Claude Code-style meta header (meta rides the seam as data here). */
const META_STATEMENT = /^\s*export\s+const\s+meta\b/
/** /**
* Parse-check the body with the SAME wrapper the worker-side runtime * Parse-check the body with the SAME wrapper the worker-side runtime
* compiles, so `start()` keeps the seam's synchronous `SCRIPT_PARSE` throw * compiles, so `start()` keeps the seam's synchronous `SCRIPT_PARSE` throw
* (the worker's own compile happens a thread away, after `start()` returned). * (the worker's own compile happens a thread away, after `start()` returned).
* One redundant parse per run, bought deliberately for the contract. * One redundant parse per run, bought deliberately for the contract. A body
* opening with `export const meta` gets a pointed message instead of the
* wrapper's bare SyntaxError — the model's likeliest authoring slip.
*/ */
function assertBodyParses(body: string, name: string): void { function assertBodyParses(body: string, name: string): void {
if (META_STATEMENT.test(body)) {
throw new WorkflowError('workflow meta rides the `meta` request field, not the script: remove the `export const meta = {...}` statement from the body', 'SCRIPT_PARSE')
}
try { try {
// Parse only — the script object is discarded, nothing executes. // Parse only — the script object is discarded, nothing executes.
void new vm.Script(`(async () => {\n${body}\n})()`, { filename: `workflow:${name}`, lineOffset: -1 }) void new vm.Script(`(async () => {\n${body}\n})()`, { filename: `workflow:${name}`, lineOffset: -1 })
@@ -130,17 +138,18 @@ export class WorkerWorkflowEngine extends WorkflowService {
} }
/** /**
* Parse and execute a workflow script in a fresh worker thread. Throws * Validate and execute a workflow script in a fresh worker thread. Throws
* {@link WorkflowError} synchronously (`SCRIPT_PARSE`/`META_INVALID`) for a * {@link WorkflowError} synchronously (`META_INVALID` for a malformed meta
* script that cannot begin; once a run is returned, every failure resolves * block, `SCRIPT_PARSE` for a body that does not compile) for a request
* through `result.stopReason` instead. * that cannot begin; once a run is returned, every failure resolves through
* @param request - the script, its `args`, the parent agent, and an * `result.stopReason` instead.
* optional cancel signal. * @param request - the script body, its meta data and `args`, the parent
* agent, and an optional cancel signal.
* @returns the live run (its `result` resolves when the script settles). * @returns the live run (its `result` resolves when the script settles).
*/ */
start(request: WorkflowStartRequest): WorkflowRun { start(request: WorkflowStartRequest): WorkflowRun {
const { meta, body } = extractMeta(request.script, this.config.syncTimeoutMs) const meta = validateMeta(request.meta)
assertBodyParses(body, meta.name) assertBodyParses(request.script, meta.name)
const id = WorkflowRunId(randomUUID()) const id = WorkflowRunId(randomUUID())
// The event payloads and the run handle get SEPARATE meta clones: a // The event payloads and the run handle get SEPARATE meta clones: a
// listener mutating its snapshot must not corrupt the holder's view. // listener mutating its snapshot must not corrupt the holder's view.
@@ -155,7 +164,7 @@ export class WorkerWorkflowEngine extends WorkflowService {
} }
const init: WorkerInit = { const init: WorkerInit = {
meta, meta,
body, body: request.script,
...request.args !== undefined ? { args: request.args } : {}, ...request.args !== undefined ? { args: request.args } : {},
limits, limits,
} }

View File

@@ -1,100 +1,24 @@
/** /**
* Meta-block extraction: turn a Claude Code-format workflow script — * Meta validation: check the caller-provided {@link WorkflowMeta} DATA against
* `export const meta = {...}` followed by a plain-JS body — into a validated * the shape contract and reject everything else loud, every violation named.
* {@link WorkflowMeta} plus the body with the meta statement blanked * Meta arrives as plain JSON through the seam (the model-facing tool carries
* line-preservingly (error stacks keep the script's own line numbers). * it as a schema-validated object parameter) — the engine never evaluates
* * script text to obtain it, so no script-controlled code can run on the host
* The scanner is a small string/comment-aware brace matcher, not a JS parser: * here (an evaluated meta literal could smuggle getters that spin the host
* it only has to find the END of the meta object literal, and the literal is * outside any vm timeout, the exact escape the worker thread exists to
* contractually PURE (no interpolation, no computed values). Template strings * prevent).
* are tolerated as plain quotes but `${` inside one is rejected up front —
* interpolation is where "literal" stops being checkable by evaluation. The
* extracted text is then evaluated ALONE in an empty, timed vm context (a
* non-literal reference throws there; an expression can still RUN, so the
* result — not the source — is the contract: it must materialize to plain
* JSON data and pass the shape validation).
* *
* @module @deepseek-ai/dsh-workflow-workerthread/meta * @module @deepseek-ai/dsh-workflow-workerthread/meta
*/ */
import * as vm from 'node:vm'
import { WorkflowError } from '@deepseek-ai/dsh-workflow' import { WorkflowError } from '@deepseek-ai/dsh-workflow'
import type { WorkflowMeta, WorkflowPhase } from '@deepseek-ai/dsh-workflow' import type { WorkflowMeta, WorkflowPhase } from '@deepseek-ai/dsh-workflow'
import { materializeFromRealm, MaterializeError, renderThrown } from './realm.ts'
/** The result of {@link extractMeta}: the validated meta and the runnable body. */ /** Collect shape violations for a meta value (plain JSON data by the seam contract). */
export interface ExtractedScript {
meta: WorkflowMeta
/** The script with the meta statement blanked (newlines preserved). */
body: string
}
/**
* Scan `source` from `start` (an opening `{`) to its matching `}`, aware of
* string literals (`'`/`"`/backtick, with escapes) and comments. Returns the
* index AFTER the closing brace. Throws `SCRIPT_PARSE` on template
* interpolation (`${` inside a backtick string) or an unterminated literal.
*/
function scanObjectLiteral(source: string, start: number): number {
let depth = 0
let index = start
while (index < source.length) {
const ch = source.charAt(index)
if (ch === '/' && source[index + 1] === '/') {
const end = source.indexOf('\n', index)
index = end === -1 ? source.length : end + 1
continue
}
if (ch === '/' && source[index + 1] === '*') {
const end = source.indexOf('*/', index + 2)
if (end === -1) throw new WorkflowError('meta block has an unterminated comment', 'SCRIPT_PARSE')
index = end + 2
continue
}
if (ch === '\'' || ch === '"' || ch === '`') {
index = scanString(source, index, ch)
continue
}
if (ch === '{' || ch === '[') depth += 1
if (ch === '}' || ch === ']') {
depth -= 1
if (depth === 0) return index + 1
}
index += 1
}
throw new WorkflowError('meta block is not a balanced object literal', 'SCRIPT_PARSE')
}
/** Scan past one string literal starting at `start` (the quote char); returns the index after the closing quote. */
function scanString(source: string, start: number, quote: string): number {
let index = start + 1
while (index < source.length) {
const ch = source.charAt(index)
if (ch === '\\') {
index += 2
continue
}
if (quote === '`' && ch === '$' && source[index + 1] === '{') {
throw new WorkflowError('template interpolation (`${...}`) is not allowed in the meta block — meta must be a pure literal', 'SCRIPT_PARSE')
}
if (ch === quote) return index + 1
index += 1
}
throw new WorkflowError('meta block has an unterminated string literal', 'SCRIPT_PARSE')
}
/** Replace `[from, to)` of `source` with whitespace, preserving every newline (line numbers survive). */
function blankSpan(source: string, from: number, to: number): string {
const blanked = source.slice(from, to).replace(/[^\n]/g, ' ')
return source.slice(0, from) + blanked + source.slice(to)
}
/** Collect shape violations for an evaluated meta value (already materialized to host JSON data). */
function validateMetaShape(meta: unknown): { meta?: WorkflowMeta; violations: string[] } { function validateMetaShape(meta: unknown): { meta?: WorkflowMeta; violations: string[] } {
const violations: string[] = [] const violations: string[] = []
/* v8 ignore next 3 -- defensive: the scanner only extracts a brace-delimited literal, which always evaluates to a plain object */
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) { if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) {
return { violations: ['meta must be an object literal'] } return { violations: ['meta must be an object'] }
} }
const record = meta as Record<string, unknown> const record = meta as Record<string, unknown>
const known = new Set(['name', 'description', 'whenToUse', 'phases']) const known = new Set(['name', 'description', 'whenToUse', 'phases'])
@@ -143,97 +67,19 @@ function validateMetaShape(meta: unknown): { meta?: WorkflowMeta; violations: st
} }
} }
/** `export const meta =`, anchored AFTER {@link skipLeadingTrivia} — its quantifiers cannot backtrack ambiguously. */
const META_HEAD = /^export\s+const\s+meta\s*=\s*/
/** /**
* Index just past the leading trivia: whitespace and `//` / `/*`-style * Validate a caller-provided meta value against the {@link WorkflowMeta}
* comments. A hand-rolled character scan, NOT a prefix regex — an * contract. Throws `META_INVALID` naming every violation (unknown fields,
* all-alternation prefix (`\s*(?:comment|\s+)*`) partitions a whitespace run * missing/mistyped `name`/`description`, malformed `phases`); the returned
* ambiguously and backtracks EXPONENTIALLY when the match ultimately fails, * meta is a NORMALIZED copy built from the validated fields, so the engine
* so a near-miss script (a comment header, then a forgotten `export`) would * never aliases the caller's object.
* spin the host synchronously inside `start()`, where no vm timeout applies. * @param value - the meta data from the start request (plain JSON by the seam contract).
* The near-miss must fail fast into `SCRIPT_PARSE` instead — that error is * @returns the validated, normalized meta block.
* the model's retry signal.
*/ */
function skipLeadingTrivia(source: string): number { export function validateMeta(value: unknown): WorkflowMeta {
let index = 0 const { meta, violations } = validateMetaShape(value)
while (index < source.length) {
const ch = source.charAt(index)
if (/\s/.test(ch)) {
index += 1
continue
}
if (ch === '/' && source[index + 1] === '/') {
const end = source.indexOf('\n', index)
if (end === -1) return source.length
index = end + 1
continue
}
if (ch === '/' && source[index + 1] === '*') {
const end = source.indexOf('*/', index + 2)
if (end === -1) throw new WorkflowError('script has an unterminated comment before the meta block', 'SCRIPT_PARSE')
index = end + 2
continue
}
break
}
return index
}
/**
* Extract and validate the leading `export const meta = {...}` statement.
* Throws {@link WorkflowError} — `SCRIPT_PARSE` when the statement is missing
* or unscannable, `META_INVALID` when the literal evaluates to something
* outside the meta contract (non-JSON data, wrong shape, unknown fields).
* @param script - the full script text.
* @param evalTimeoutMs - the vm timeout for evaluating the extracted literal.
* @returns the validated meta and the line-preservingly blanked body.
*/
export function extractMeta(script: string, evalTimeoutMs: number): ExtractedScript {
const triviaEnd = skipLeadingTrivia(script)
const match = META_HEAD.exec(script.slice(triviaEnd))
if (!match) {
throw new WorkflowError('script must begin with `export const meta = {...}` (leading comments allowed)', 'SCRIPT_PARSE')
}
const literalStart = triviaEnd + match[0].length
if (script[literalStart] !== '{') {
throw new WorkflowError('`export const meta =` must be followed by an object literal', 'SCRIPT_PARSE')
}
const literalEnd = scanObjectLiteral(script, literalStart)
const literal = script.slice(literalStart, literalEnd)
let evaluated: unknown
try {
// An EMPTY context: any non-literal reference (a variable, a call) throws
// here. The result — data only — is what the contract checks; a getter or
// IIFE can still run, which is why the timeout and the materialization
// below are part of the same boundary.
evaluated = vm.runInNewContext(`(${literal})`, undefined, { timeout: evalTimeoutMs })
} catch (error: unknown) {
throw new WorkflowError(
`meta block failed to evaluate as a pure literal: ${renderThrown(error)}`,
'META_INVALID',
{ cause: error },
)
}
let data: unknown
try {
data = materializeFromRealm(evaluated, 'meta')
} catch (error: unknown) {
/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */
if (!(error instanceof MaterializeError)) throw error
throw new WorkflowError(`meta block is not pure JSON data — ${error.message}`, 'META_INVALID', { cause: error })
}
const { meta, violations } = validateMetaShape(data)
if (meta === undefined) { if (meta === undefined) {
throw new WorkflowError(`invalid meta block: ${violations.join('; ')}`, 'META_INVALID') throw new WorkflowError(`invalid meta: ${violations.join('; ')}`, 'META_INVALID')
} }
return meta
// Blank the whole statement (including a trailing semicolon, if any) so the
// body compiles standalone with its original line numbers.
let statementEnd = literalEnd
while (statementEnd < script.length && (script[statementEnd] === ' ' || script[statementEnd] === '\t')) statementEnd += 1
if (script[statementEnd] === ';') statementEnd += 1
return { meta, body: blankSpan(script, 0, statementEnd) }
} }

View File

@@ -109,7 +109,7 @@ export class WorkflowExecution {
// wrapper, so under one Node version this throw is unreachable in // wrapper, so under one Node version this throw is unreachable in
// production — the session still maps it to an error result defensively. // production — the session still maps it to an error result defensively.
// lineOffset compensates for the wrapper line, so stack traces carry the // lineOffset compensates for the wrapper line, so stack traces carry the
// script's own line numbers (the meta statement was blanked, not removed). // script's own line numbers.
try { try {
this.compiled = new vm.Script(`(async () => {\n${body}\n})()`, { this.compiled = new vm.Script(`(async () => {\n${body}\n})()`, {
filename: `workflow:${meta.name}`, filename: `workflow:${meta.name}`,

View File

@@ -30,9 +30,9 @@ export interface WorkerLimits {
/** The `workerData` payload one run is initialized with (host → worker, once, at spawn). */ /** The `workerData` payload one run is initialized with (host → worker, once, at spawn). */
export interface WorkerInit { export interface WorkerInit {
/** The validated meta block (extracted host-side). */ /** The validated meta block (plain data off the start request, validated host-side). */
meta: WorkflowMeta meta: WorkflowMeta
/** The script body with the meta statement blanked (host-side `extractMeta`). */ /** The plain-JS script body, exactly as the start request carried it. */
body: string body: string
/** The run's `args` value; the workerData structured clone is the copy that isolates the caller. */ /** The run's `args` value; the workerData structured clone is the copy that isolates the caller. */
args?: unknown args?: unknown

View File

@@ -34,7 +34,8 @@ const ctx = new Context()
await ctx.plugin(SubagentService) await ctx.plugin(SubagentService)
await ctx.plugin(WorkerWorkflowEngine, {}) await ctx.plugin(WorkerWorkflowEngine, {})
const run = ctx.workflows.start({ const run = ctx.workflows.start({
script: "export const meta = { name: 'built-smoke', description: 'built worker smoke' }\\nreturn 6 * 7", script: 'return 6 * 7',
meta: { name: 'built-smoke', description: 'built worker smoke' },
// A zero-agent script never touches the provider, so a bare id suffices. // A zero-agent script never touches the provider, so a bare id suffices.
parent: { id: 'built-smoke-parent', options: {} }, parent: { id: 'built-smoke-parent', options: {} },
}) })

View File

@@ -50,8 +50,8 @@ describe('dsh-workflow-workerthread over the real in-process stack', () => {
const childIds: string[] = [] const childIds: string[] = []
ctx.on('workflow/agent-start', (_info, agent) => { childIds.push(agent.childId) }) ctx.on('workflow/agent-start', (_info, agent) => { childIds.push(agent.childId) })
const run = ctx.workflows.start({ const run = ctx.workflows.start({
script: `export const meta = { name: 'integration', description: 'plain + structured children' } meta: { name: 'integration', description: 'plain + structured children' },
phase('Read') script: `phase('Read')
const prose = await agent('read the repo') const prose = await agent('read the repo')
phase('Judge') phase('Judge')
const judged = await agent('judge: ' + prose, { const judged = await agent('judge: ' + prose, {
@@ -78,8 +78,8 @@ return { prose, verdict: judged.verdict, confidence: judged.confidence }`,
textResponse('still prose after the nudge'), textResponse('still prose after the nudge'),
]) ])
const run = ctx.workflows.start({ const run = ctx.workflows.start({
script: `export const meta = { name: 'null-path', description: 'schema failure maps to null' } meta: { name: 'null-path', description: 'schema failure maps to null' },
const judged = await agent('judge it', { schema: { type: 'object', properties: { v: { type: 'string' } } } }) script: `const judged = await agent('judge it', { schema: { type: 'object', properties: { v: { type: 'string' } } } })
return { got: judged === null ? 'null' : 'value' }`, return { got: judged === null ? 'null' : 'value' }`,
parent, parent,
}) })

View File

@@ -1,182 +1,88 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { WorkflowError } from '@deepseek-ai/dsh-workflow' import { WorkflowError } from '@deepseek-ai/dsh-workflow'
import { extractMeta } from '../src/meta.ts' import { validateMeta } from '../src/meta.ts'
const TIMEOUT = 1000 /** Assert a META_INVALID throw whose message matches every given fragment. */
function expectInvalid(value: unknown, ...fragments: string[]): void {
/** Extract and expect success. */ let thrown: unknown
function ok(script: string) {
return extractMeta(script, TIMEOUT)
}
/** The WorkflowError a bad script produces (throws if it extracts cleanly). */
function bad(script: string): WorkflowError {
try { try {
extractMeta(script, TIMEOUT) validateMeta(value)
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof WorkflowError) return error thrown = error
throw error }
expect(thrown).toBeInstanceOf(WorkflowError)
expect((thrown as WorkflowError).code).toBe('META_INVALID')
for (const fragment of fragments) {
expect((thrown as WorkflowError).message).toContain(fragment)
} }
throw new Error('expected extraction to fail')
} }
describe('extractMeta', () => { describe('validateMeta', () => {
it('extracts a full meta block and blanks the statement line-preservingly', () => { it('accepts a minimal meta and returns a normalized copy (no aliasing of the input)', () => {
const script = `export const meta = { const input = { name: 'audit', description: 'audit the repo' }
name: 'audit-routes', const meta = validateMeta(input)
description: 'Audit every route', expect(meta).toEqual({ name: 'audit', description: 'audit the repo' })
whenToUse: 'when auditing', expect(meta).not.toBe(input)
phases: [{ title: 'Scan', detail: 'find files' }, { title: 'Fix', model: 'deepseek-v4-pro' }], input.name = 'mutated'
} expect(meta.name).toBe('audit')
const x = 1 })
return x`
const { meta, body } = ok(script) it('accepts the full shape and rebuilds phases entry by entry', () => {
expect(meta).toEqual({ const meta = validateMeta({
name: 'audit-routes', name: 'migrate',
description: 'Audit every route', description: 'migrate call sites',
whenToUse: 'when auditing', whenToUse: 'large mechanical sweeps',
phases: [{ title: 'Scan', detail: 'find files' }, { title: 'Fix', model: 'deepseek-v4-pro' }], phases: [
{ title: 'Discover' },
{ title: 'Transform', detail: 'one agent per file', model: 'deepseek-v4-pro' },
],
})
expect(meta).toEqual({
name: 'migrate',
description: 'migrate call sites',
whenToUse: 'large mechanical sweeps',
phases: [
{ title: 'Discover' },
{ title: 'Transform', detail: 'one agent per file', model: 'deepseek-v4-pro' },
],
}) })
// Same line count; the statement's characters blanked; the body intact.
expect(body.split('\n').length).toBe(script.split('\n').length)
expect(body.split('\n')[6]).toBe('const x = 1')
expect(body).not.toContain('export')
}) })
it('allows leading line and block comments before the meta statement', () => { it('rejects non-object values loud', () => {
const script = `// a workflow expectInvalid(undefined, 'meta must be an object')
/* multi expectInvalid('a string', 'meta must be an object')
line */ expectInvalid(null, 'meta must be an object')
export const meta = { name: 'x', description: 'y' } expectInvalid([{ name: 'x', description: 'd' }], 'meta must be an object')
return 1`
expect(ok(script).meta.name).toBe('x')
}) })
it('handles braces inside strings and comments while scanning', () => { it('rejects unknown fields by name (accepted-then-ignored is banned)', () => {
const script = `export const meta = { expectInvalid({ name: 'x', description: 'd', color: 'red' }, 'meta.color is not a recognized field')
name: 'tricky', // } not a close {
/* } also not } */
description: "has { braces } and 'quotes'",
}
return 2`
expect(ok(script).meta.description).toBe("has { braces } and 'quotes'")
}) })
it('tolerates template-quoted strings WITHOUT interpolation, escapes included', () => { it('rejects missing or mistyped name/description/whenToUse', () => {
const script = 'export const meta = { name: `plain`, description: `esc \\` tick` }\nreturn 1' expectInvalid({ description: 'd' }, 'meta.name must be a non-empty string')
expect(ok(script).meta.name).toBe('plain') expectInvalid({ name: '', description: 'd' }, 'meta.name must be a non-empty string')
expectInvalid({ name: 'x' }, 'meta.description must be a non-empty string')
expectInvalid({ name: 'x', description: 42 }, 'meta.description must be a non-empty string')
expectInvalid({ name: 'x', description: 'd', whenToUse: 3 }, 'meta.whenToUse must be a string')
}) })
it('consumes a trailing semicolon after the literal, spaces included', () => { it('rejects malformed phases, entry by entry', () => {
const { body } = ok("export const meta = { name: 'x', description: 'y' };\nreturn 1") expectInvalid({ name: 'x', description: 'd', phases: 'Scan' }, 'meta.phases must be an array')
expect(body).not.toContain(';') expectInvalid({ name: 'x', description: 'd', phases: ['Scan'] }, 'meta.phases[0] must be an object')
expect(body.split('\n')[1]).toBe('return 1') expectInvalid({ name: 'x', description: 'd', phases: [{ title: '' }] }, 'meta.phases[0].title must be a non-empty string')
const spaced = ok("export const meta = { name: 'x', description: 'y' } ;\nreturn 1") expectInvalid({ name: 'x', description: 'd', phases: [{ title: 'Scan', order: 1 }] }, 'meta.phases[0].order is not a recognized field')
expect(spaced.body).not.toContain(';') expectInvalid({ name: 'x', description: 'd', phases: [{ title: 'Scan', detail: 9 }] }, 'meta.phases[0].detail must be a string')
expectInvalid({ name: 'x', description: 'd', phases: [{ title: 'Scan', model: 9 }] }, 'meta.phases[0].model must be a string')
}) })
it('rejects a script that does not begin with the meta statement (SCRIPT_PARSE)', () => { it('names EVERY violation in one throw, not just the first', () => {
expect(bad('const a = 1').code).toBe('SCRIPT_PARSE') expectInvalid(
expect(bad('').code).toBe('SCRIPT_PARSE') { description: 7, extra: true, phases: [{ title: 'Scan' }, 'bad'] },
expect(bad('export const meta = [1]').code).toBe('SCRIPT_PARSE') 'meta.extra is not a recognized field',
}) 'meta.name must be a non-empty string',
'meta.description must be a non-empty string',
it('a near-miss prefix (comment header + whitespace, then no `export`) fails FAST as SCRIPT_PARSE', () => { 'meta.phases[1] must be an object',
// Regression: the previous all-alternation prefix regex backtracked )
// exponentially on exactly this shape (~×2 per extra whitespace char once
// the match fails), spinning the host synchronously inside start(). The
// linear trivia scan must reject it in effectively zero time.
const nearMiss = `// deep-audit workflow: reviews every route handler\n${' \n'.repeat(40)}/* second header block */\n${' '.repeat(200)}\nconst meta = { name: 'x', description: 'y' }\n`
const started = Date.now()
expect(bad(nearMiss).code).toBe('SCRIPT_PARSE')
expect(Date.now() - started).toBeLessThan(1000)
})
it('an unterminated block comment BEFORE the meta statement is SCRIPT_PARSE', () => {
const error = bad('/* never closed\nexport const meta = { name: "x", description: "y" }')
expect(error.code).toBe('SCRIPT_PARSE')
expect(error.message).toContain('unterminated comment')
})
it('a line comment running to EOF leaves no meta statement (SCRIPT_PARSE)', () => {
expect(bad('// only a comment, no newline').code).toBe('SCRIPT_PARSE')
})
it('rejects template interpolation in the meta block as impure (SCRIPT_PARSE)', () => {
const error = bad('export const meta = { name: `w-${1}`, description: "d" }\nreturn 1')
expect(error.code).toBe('SCRIPT_PARSE')
expect(error.message).toContain('pure literal')
})
it('rejects unbalanced literals, unterminated strings, and unterminated comments (SCRIPT_PARSE)', () => {
expect(bad('export const meta = { name: "x", description: "y"').code).toBe('SCRIPT_PARSE')
expect(bad('export const meta = { name: "x').code).toBe('SCRIPT_PARSE')
expect(bad('export const meta = { /* open').code).toBe('SCRIPT_PARSE')
// A line comment running to EOF (no newline) leaves the literal unbalanced.
expect(bad('export const meta = { name: "x" // eof comment').code).toBe('SCRIPT_PARSE')
})
it('rejects a literal referencing variables or calls (META_INVALID via the empty realm)', () => {
const error = bad('export const meta = { name: someVariable, description: "d" }\nreturn 1')
expect(error.code).toBe('META_INVALID')
expect(error.message).toContain('pure literal')
expect(bad('export const meta = { name: compute(), description: "d" }').code).toBe('META_INVALID')
})
it('rejects a literal evaluating to non-JSON data (META_INVALID via materialization)', () => {
const error = bad('export const meta = { name: "x", description: "d", whenToUse: () => 1 }')
expect(error.code).toBe('META_INVALID')
expect(error.message).toContain('JSON data')
})
it('a meta expression that THROWS maps to META_INVALID carrying the rendered value', () => {
const error = bad('export const meta = { name: (() => { throw "nope" })(), description: "d" }\nreturn 1')
expect(error.code).toBe('META_INVALID')
expect(error.message).toContain('pure literal')
expect(error.message).toContain('nope')
})
it('a spinning meta expression dies by the eval timeout', () => {
try {
extractMeta('export const meta = { name: (() => { while (true) {} })(), description: "d" }', 50)
throw new Error('expected the extraction to time out')
} catch (error: unknown) {
expect(error).toBeInstanceOf(WorkflowError)
expect((error as WorkflowError).code).toBe('META_INVALID')
expect((error as WorkflowError).message.toLowerCase()).toContain('timed out')
}
})
it('rejects shape violations with EVERY violation listed (META_INVALID)', () => {
const error = bad('export const meta = { description: 7, bogus: 1 }\nreturn 1')
expect(error.code).toBe('META_INVALID')
expect(error.message).toContain('meta.name must be a non-empty string')
expect(error.message).toContain('meta.description must be a non-empty string')
expect(error.message).toContain('meta.bogus is not a recognized field')
})
it('rejects malformed whenToUse and phases shapes precisely', () => {
expect(bad('export const meta = { name: "x", description: "d", whenToUse: 3 }').message)
.toContain('meta.whenToUse must be a string')
expect(bad('export const meta = { name: "x", description: "d", phases: "no" }').message)
.toContain('meta.phases must be an array')
expect(bad('export const meta = { name: "x", description: "d", phases: [3] }').message)
.toContain('meta.phases[0] must be an object')
expect(bad('export const meta = { name: "x", description: "d", phases: [{}] }').message)
.toContain('meta.phases[0].title must be a non-empty string')
expect(bad('export const meta = { name: "x", description: "d", phases: [{ title: "t", extra: 1 }] }').message)
.toContain('meta.phases[0].extra is not a recognized field')
expect(bad('export const meta = { name: "x", description: "d", phases: [{ title: "t", detail: 1 }] }').message)
.toContain('meta.phases[0].detail must be a string')
expect(bad('export const meta = { name: "x", description: "d", phases: [{ title: "t", model: 1 }] }').message)
.toContain('meta.phases[0].model must be a string')
})
it('stops scanning at the balanced literal — trailing expression text stays in the body', () => {
// The scanner extracts exactly `{ valueOf: null }`; the ` && 3` is body
// text (which would fail compilation later, but extraction sees only the
// literal and reports its unknown field).
expect(bad('export const meta = { valueOf: null } && 3').message)
.toContain('meta.valueOf is not a recognized field')
}) })
}) })

View File

@@ -42,12 +42,12 @@ async function harness(): Promise<Context> {
return built return built
} }
const SCRIPT = `export const meta = { const META = {
name: 'e2e-worker-arithmetic', name: 'e2e-worker-arithmetic',
description: 'two real children through a worker thread: one prose, one structured', description: 'two real children through a worker thread: one prose, one structured',
phases: [{ title: 'Ask' }, { title: 'Judge' }], phases: [{ title: 'Ask' }, { title: 'Judge' }],
} }
phase('Ask') const SCRIPT = `phase('Ask')
log('asking the prose child') log('asking the prose child')
const prose = await agent('Reply with exactly one short sentence: what is 2 + 2?') const prose = await agent('Reply with exactly one short sentence: what is 2 + 2?')
phase('Judge') phase('Judge')
@@ -76,7 +76,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('worker workflow engine with-key
}) })
} }
const run = ctx.workflows.start({ script: SCRIPT, parent: parentHandle.agent }) const run = ctx.workflows.start({ script: SCRIPT, meta: META, parent: parentHandle.agent })
const result = await run.result const result = await run.result
await run.dispose() await run.dispose()

View File

@@ -5,7 +5,7 @@ import { AgentId } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent'
import SubagentService from '@deepseek-ai/dsh-subagent' import SubagentService from '@deepseek-ai/dsh-subagent'
import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import type { WorkflowResult, WorkflowResultInfo, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' import type { WorkflowMeta, WorkflowResult, WorkflowResultInfo, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow'
import * as workerEngineModule from '../src/index.ts' import * as workerEngineModule from '../src/index.ts'
import WorkerWorkflowEngine, { type Config } from '../src/index.ts' import WorkerWorkflowEngine, { type Config } from '../src/index.ts'
@@ -104,14 +104,14 @@ async function setup(options?: SetupOptions) {
return { ctx, provider, parent: fakeParent() } return { ctx, provider, parent: fakeParent() }
} }
/** Wrap a body in the minimal valid meta header. */ /** The standard test meta plus a body, spread into a start request. */
function script(body: string, metaExtra = ''): string { function scripted(body: string, metaExtra?: Partial<WorkflowMeta>): { script: string; meta: WorkflowMeta } {
return `export const meta = { name: 'test-flow', description: 'a test workflow'${metaExtra} }\n${body}` return { script: body, meta: { name: 'test-flow', description: 'a test workflow', ...metaExtra } }
} }
/** Start + await one run, disposing on the way out. */ /** Start + await one run, disposing on the way out. */
async function run(ctx: Context, parent: Agent, source: string, args?: unknown): Promise<WorkflowResult> { async function run(ctx: Context, parent: Agent, source: { script: string; meta: WorkflowMeta }, args?: unknown): Promise<WorkflowResult> {
const handle = ctx.workflows.start({ script: source, parent, ...args !== undefined ? { args } : {} }) const handle = ctx.workflows.start({ ...source, parent, ...args !== undefined ? { args } : {} })
try { try {
return await handle.result return await handle.result
} finally { } finally {
@@ -127,13 +127,13 @@ describe('dsh-workflow-workerthread', () => {
for (const name of ['workflow/start', 'workflow/phase', 'workflow/log', 'workflow/agent-start', 'workflow/agent-end', 'workflow/end'] as const) { for (const name of ['workflow/start', 'workflow/phase', 'workflow/log', 'workflow/agent-start', 'workflow/agent-end', 'workflow/end'] as const) {
ctx.on(name, (...payload: unknown[]) => { events.push([name, payload]) }) ctx.on(name, (...payload: unknown[]) => { events.push([name, payload]) })
} }
const result = await run(ctx, parent, script(` const result = await run(ctx, parent, scripted(`
phase('Scan') phase('Scan')
log('starting with ' + args.files.length + ' files') log('starting with ' + args.files.length + ' files')
const answers = await pipeline(args.files, (prev, item) => agent('read ' + item)) const answers = await pipeline(args.files, (prev, item) => agent('read ' + item))
phase('Report') phase('Report')
return { answers, count: args.files.length } return { answers, count: args.files.length }
`, ", phases: [{ title: 'Scan' }, { title: 'Report' }]"), { files: ['a.ts', 'b.ts'] }) `, { phases: [{ title: 'Scan' }, { title: 'Report' }] }), { files: ['a.ts', 'b.ts'] })
expect(result.stopReason).toBe('completed') expect(result.stopReason).toBe('completed')
expect(result.agentsStarted).toBe(2) expect(result.agentsStarted).toBe(2)
@@ -156,7 +156,7 @@ describe('dsh-workflow-workerthread', () => {
const { ctx, parent, provider } = await setup({ const { ctx, parent, provider } = await setup({
reply: () => ({ output: [], structured: { files: ['x.ts', 'y.ts'] }, stopReason: 'completed' }), reply: () => ({ output: [], structured: { files: ['x.ts', 'y.ts'] }, stopReason: 'completed' }),
}) })
const result = await run(ctx, parent, script(` const result = await run(ctx, parent, scripted(`
const found = await agent('list files', { model: 'deepseek-v4-pro', schema: { type: 'object', properties: { files: { type: 'array', items: { type: 'string' } } }, required: ['files'] } }) const found = await agent('list files', { model: 'deepseek-v4-pro', schema: { type: 'object', properties: { files: { type: 'array', items: { type: 'string' } } }, required: ['files'] } })
return { first: found.files[0], count: found.files.length } return { first: found.files[0], count: found.files.length }
`)) `))
@@ -172,14 +172,14 @@ describe('dsh-workflow-workerthread', () => {
it('a fatal hook error inside the worker kills the script and reports the error', async () => { it('a fatal hook error inside the worker kills the script and reports the error', async () => {
const { ctx, parent } = await setup() const { ctx, parent } = await setup()
const result = await run(ctx, parent, script("return await parallel([() => agent('x', { isolation: 'worktree' })])")) const result = await run(ctx, parent, scripted("return await parallel([() => agent('x', { isolation: 'worktree' })])"))
expect(result.stopReason).toBe('error') expect(result.stopReason).toBe('error')
expect(result.error).toContain('"isolation" is deferred') expect(result.error).toContain('"isolation" is deferred')
}) })
it('a provider start failure crosses back as a fatal AGENT_START error', async () => { it('a provider start failure crosses back as a fatal AGENT_START error', async () => {
const { ctx, parent } = await setup({ config: { provider: 'nonexistent' } }) const { ctx, parent } = await setup({ config: { provider: 'nonexistent' } })
const result = await run(ctx, parent, script("return await pipeline([1], () => agent('p'))")) const result = await run(ctx, parent, scripted("return await pipeline([1], () => agent('p'))"))
expect(result.stopReason).toBe('error') expect(result.stopReason).toBe('error')
expect(result.error).toContain('agent() could not start a child') expect(result.error).toContain('agent() could not start a child')
}) })
@@ -200,7 +200,7 @@ describe('dsh-workflow-workerthread', () => {
} }
ctx.subagents.registerProvider(provider) ctx.subagents.registerProvider(provider)
await ctx.plugin(WorkerWorkflowEngine, { provider: 'rejecting', maxConcurrentAgents: 2 }) await ctx.plugin(WorkerWorkflowEngine, { provider: 'rejecting', maxConcurrentAgents: 2 })
const result = await run(ctx, fakeParent(), script(` const result = await run(ctx, fakeParent(), scripted(`
try { await agent('p'); return 'unreachable' } catch (e) { return { name: e.name, code: e.code, fatal: e.fatal, message: e.message } } try { await agent('p'); return 'unreachable' } catch (e) { return { name: e.name, code: e.code, fatal: e.fatal, message: e.message } }
`)) `))
expect(result.value).toMatchObject({ name: 'WorkflowError', code: 'AGENT_RESULT', fatal: true }) expect(result.value).toMatchObject({ name: 'WorkflowError', code: 'AGENT_RESULT', fatal: true })
@@ -223,7 +223,7 @@ describe('dsh-workflow-workerthread', () => {
} }
ctx.subagents.registerProvider(provider) ctx.subagents.registerProvider(provider)
await ctx.plugin(WorkerWorkflowEngine, { provider: 'bad-dispose', maxConcurrentAgents: 2 }) await ctx.plugin(WorkerWorkflowEngine, { provider: 'bad-dispose', maxConcurrentAgents: 2 })
const result = await run(ctx, fakeParent(), script("return await agent('p')")) const result = await run(ctx, fakeParent(), scripted("return await agent('p')"))
expect(result.stopReason).toBe('completed') expect(result.stopReason).toBe('completed')
expect(result.value).toBe('fine') expect(result.value).toBe('fine')
}) })
@@ -248,17 +248,22 @@ describe('dsh-workflow-workerthread', () => {
} }
ctx.subagents.registerProvider(provider) ctx.subagents.registerProvider(provider)
await ctx.plugin(WorkerWorkflowEngine, { provider: 'coercion-trap-dispose', maxConcurrentAgents: 2 }) await ctx.plugin(WorkerWorkflowEngine, { provider: 'coercion-trap-dispose', maxConcurrentAgents: 2 })
const result = await run(ctx, fakeParent(), script("return await agent('p')")) const result = await run(ctx, fakeParent(), scripted("return await agent('p')"))
expect(result.stopReason).toBe('completed') expect(result.stopReason).toBe('completed')
expect(result.value).toBe('fine') expect(result.value).toBe('fine')
}) })
}) })
describe('lifecycle: parse errors, cancellation, termination, disposal', () => { describe('lifecycle: parse errors, cancellation, termination, disposal', () => {
it('start() throws synchronously for an unparseable script or invalid meta (host-side pre-parse)', async () => { it('start() throws synchronously for invalid meta data or an unparseable body (host-side pre-checks)', async () => {
const { ctx, parent } = await setup() const { ctx, parent } = await setup()
expect(() => ctx.workflows.start({ script: 'const x = 1', parent })).toThrow(/must begin with/) // Meta is DATA — shape violations reject loud, every one named.
expect(() => ctx.workflows.start({ script: script('return ((('), parent })).toThrow(/does not parse/) expect(() => ctx.workflows.start({ script: 'return 1', meta: { name: '', description: 'd' }, parent })).toThrow(/meta\.name must be a non-empty string/)
expect(() => ctx.workflows.start({ script: 'return 1', meta: { name: 'x', description: 'd', extra: 1 } as unknown as WorkflowMeta, parent })).toThrow(/META_INVALID|not a recognized field/)
expect(() => ctx.workflows.start({ ...scripted('return ((('), parent })).toThrow(/does not parse/)
// The likeliest authoring slip — a Claude Code-style meta header in the
// body — gets a pointed message, not a bare SyntaxError.
expect(() => ctx.workflows.start({ ...scripted("export const meta = { name: 'x', description: 'd' }\nreturn 1"), parent })).toThrow(/meta rides the `meta` request field/)
}) })
it('cancel() aborts in-flight children (signal AND cancel RPC) and settles the run cancelled', async () => { it('cancel() aborts in-flight children (signal AND cancel RPC) and settles the run cancelled', async () => {
@@ -267,7 +272,7 @@ describe('dsh-workflow-workerthread', () => {
ctx.on('workflow/agent-end', (_info, agent) => { ends.push(agent) }) ctx.on('workflow/agent-end', (_info, agent) => { ends.push(agent) })
const runEnds: WorkflowResultInfo[] = [] const runEnds: WorkflowResultInfo[] = []
ctx.on('workflow/end', (_info, result) => { runEnds.push(result) }) ctx.on('workflow/end', (_info, result) => { runEnds.push(result) })
const handle = ctx.workflows.start({ script: script("return await agent('long job')"), parent }) const handle = ctx.workflows.start({ ...scripted("return await agent('long job')"), parent })
await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) await vi.waitFor(() => { expect(provider.runs.length).toBe(1) })
handle.cancel('user stopped it') handle.cancel('user stopped it')
const result = await handle.result const result = await handle.result
@@ -287,7 +292,7 @@ describe('dsh-workflow-workerthread', () => {
controller.abort() controller.abort()
const logs: string[] = [] const logs: string[] = []
ctx.on('workflow/log', (_info, message) => { logs.push(message) }) ctx.on('workflow/log', (_info, message) => { logs.push(message) })
const handle = ctx.workflows.start({ script: script("log('ran')\nreturn 123"), parent, signal: controller.signal }) const handle = ctx.workflows.start({ ...scripted("log('ran')\nreturn 123"), parent, signal: controller.signal })
const result = await handle.result const result = await handle.result
expect(result.stopReason).toBe('cancelled') expect(result.stopReason).toBe('cancelled')
expect(result.value).toBeNull() expect(result.value).toBeNull()
@@ -298,7 +303,7 @@ describe('dsh-workflow-workerthread', () => {
it('cancel() right after start() cancels before the body runs; the signal aborting mid-run cancels like cancel()', async () => { it('cancel() right after start() cancels before the body runs; the signal aborting mid-run cancels like cancel()', async () => {
const { ctx, parent, provider } = await setup({ manual: true }) const { ctx, parent, provider } = await setup({ manual: true })
const first = ctx.workflows.start({ script: script("return await agent('never')"), parent }) const first = ctx.workflows.start({ ...scripted("return await agent('never')"), parent })
// No-reason cancel: the canonical default reason must ride the result. // No-reason cancel: the canonical default reason must ride the result.
first.cancel() first.cancel()
const firstResult = await first.result const firstResult = await first.result
@@ -308,7 +313,7 @@ describe('dsh-workflow-workerthread', () => {
await first.dispose() await first.dispose()
const controller = new AbortController() const controller = new AbortController()
const second = ctx.workflows.start({ script: script("return await agent('job')"), parent, signal: controller.signal }) const second = ctx.workflows.start({ ...scripted("return await agent('job')"), parent, signal: controller.signal })
await vi.waitFor(() => { expect(provider.runs.length).toBe(1) }) await vi.waitFor(() => { expect(provider.runs.length).toBe(1) })
controller.abort() controller.abort()
expect((await second.result).stopReason).toBe('cancelled') expect((await second.result).stopReason).toBe('cancelled')
@@ -323,7 +328,7 @@ describe('dsh-workflow-workerthread', () => {
// timing can hit reliably. (The closure runs only after `handle` below // timing can hit reliably. (The closure runs only after `handle` below
// is initialized — the listener fires on the worker's first message.) // is initialized — the listener fires on the worker's first message.)
ctx.on('workflow/log', () => { handle.cancel('cancelled from the log listener') }) ctx.on('workflow/log', () => { handle.cancel('cancelled from the log listener') })
const handle = ctx.workflows.start({ script: script("log('mark')\nreturn await agent('late')"), parent }) const handle = ctx.workflows.start({ ...scripted("log('mark')\nreturn await agent('late')"), parent })
const result = await handle.result const result = await handle.result
expect(result.stopReason).toBe('cancelled') expect(result.stopReason).toBe('cancelled')
expect(provider.runs.length).toBe(0) expect(provider.runs.length).toBe(0)
@@ -342,7 +347,7 @@ describe('dsh-workflow-workerthread', () => {
// host cancellation. The trailing narration exercises host-side // host cancellation. The trailing narration exercises host-side
// suppression: posted pre-cancel-processing worker-side, arriving // suppression: posted pre-cancel-processing worker-side, arriving
// post-cancel host-side. // post-cancel host-side.
script: script(` ...scripted(`
log('started') log('started')
const end = Date.now() + 1000 const end = Date.now() + 1000
while (Date.now() < end) {} while (Date.now() < end) {}
@@ -366,7 +371,7 @@ describe('dsh-workflow-workerthread', () => {
const runEnds: WorkflowResultInfo[] = [] const runEnds: WorkflowResultInfo[] = []
ctx.on('workflow/end', (_info, result) => { runEnds.push(result) }) ctx.on('workflow/end', (_info, result) => { runEnds.push(result) })
const handle = ctx.workflows.start({ const handle = ctx.workflows.start({
script: script("await new Promise(() => {})\nreturn 'unreachable'"), ...scripted("await new Promise(() => {})\nreturn 'unreachable'"),
parent, parent,
}) })
handle.cancel('user aborted') handle.cancel('user aborted')
@@ -382,7 +387,7 @@ describe('dsh-workflow-workerthread', () => {
it('dispose() on a stuck script returns within the grace instead of hanging (result settles cancelled)', async () => { it('dispose() on a stuck script returns within the grace instead of hanging (result settles cancelled)', async () => {
const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 50 } }) const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 50 } })
const handle = ctx.workflows.start({ const handle = ctx.workflows.start({
script: script("await new Promise(() => {})\nreturn 'unreachable'"), ...scripted("await new Promise(() => {})\nreturn 'unreachable'"),
parent, parent,
}) })
const before = Date.now() const before = Date.now()
@@ -394,7 +399,7 @@ describe('dsh-workflow-workerthread', () => {
it('dispose() is idempotent and settles cleanly after a completed run', async () => { it('dispose() is idempotent and settles cleanly after a completed run', async () => {
const { ctx, parent } = await setup() const { ctx, parent } = await setup()
const handle = ctx.workflows.start({ script: script('return 1'), parent }) const handle = ctx.workflows.start({ ...scripted('return 1'), parent })
await handle.result await handle.result
await handle.dispose() await handle.dispose()
await handle.dispose() await handle.dispose()
@@ -405,7 +410,7 @@ describe('dsh-workflow-workerthread', () => {
// apart from every other timeout in flight. // apart from every other timeout in flight.
const GRACE = 44_444 const GRACE = 44_444
const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: GRACE } }) const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: GRACE } })
const handle = ctx.workflows.start({ script: script('return 1'), parent }) const handle = ctx.workflows.start({ ...scripted('return 1'), parent })
await handle.result await handle.result
const spy = vi.spyOn(globalThis, 'setTimeout') const spy = vi.spyOn(globalThis, 'setTimeout')
try { try {
@@ -424,7 +429,7 @@ describe('dsh-workflow-workerthread', () => {
it('strays: children fired without await are aborted once the script settles, and dispose() waits for their disposal', async () => { it('strays: children fired without await are aborted once the script settles, and dispose() waits for their disposal', async () => {
const { ctx, parent, provider } = await setup({ manual: true, disposeDelayMs: 40 }) const { ctx, parent, provider } = await setup({ manual: true, disposeDelayMs: 40 })
const handle = ctx.workflows.start({ const handle = ctx.workflows.start({
script: script(` ...scripted(`
agent('stray') agent('stray')
return 'done without awaiting' return 'done without awaiting'
`), `),
@@ -468,7 +473,7 @@ describe('dsh-workflow-workerthread', () => {
ctx.subagents.registerProvider(provider) ctx.subagents.registerProvider(provider)
await ctx.plugin(WorkerWorkflowEngine, { provider: 'signal-only', maxConcurrentAgents: 2 }) await ctx.plugin(WorkerWorkflowEngine, { provider: 'signal-only', maxConcurrentAgents: 2 })
const handle = ctx.workflows.start({ const handle = ctx.workflows.start({
script: script(` ...scripted(`
agent('stray, never awaited') agent('stray, never awaited')
return 'done' return 'done'
`), `),
@@ -515,7 +520,7 @@ describe('dsh-workflow-workerthread', () => {
// microtask yields let the agent() continuation POST its child-start // microtask yields let the agent() continuation POST its child-start
// before the spin seizes the worker's loop (the posted message needs // before the spin seizes the worker's loop (the posted message needs
// no further worker-loop turns to reach the host). // no further worker-loop turns to reach the host).
script: script(` ...scripted(`
agent('wedged child') agent('wedged child')
for (let i = 0; i < 20; i++) await null for (let i = 0; i < 20; i++) await null
const end = Date.now() + 1500 const end = Date.now() + 1500
@@ -560,7 +565,7 @@ describe('dsh-workflow-workerthread', () => {
// The stray child's start RPC reaches the host, then the script kills // The stray child's start RPC reaches the host, then the script kills
// its own worker through the documented vm escape — the host must // its own worker through the documented vm escape — the host must
// settle `error` with the exit diagnostics and wind the child down. // settle `error` with the exit diagnostics and wind the child down.
script: script(` ...scripted(`
agent('doomed') agent('doomed')
const proc = ${ESCAPE} const proc = ${ESCAPE}
const st = globalThis.constructor.constructor('return setTimeout')() const st = globalThis.constructor.constructor('return setTimeout')()
@@ -583,7 +588,7 @@ describe('dsh-workflow-workerthread', () => {
it('an uncaught exception inside the worker surfaces as an error result and reaps the in-flight child', async () => { it('an uncaught exception inside the worker surfaces as an error result and reaps the in-flight child', async () => {
const { ctx, parent, provider } = await setup({ manual: true }) const { ctx, parent, provider } = await setup({ manual: true })
const handle = ctx.workflows.start({ const handle = ctx.workflows.start({
script: script(` ...scripted(`
agent('in flight when the worker dies') agent('in flight when the worker dies')
const proc = ${ESCAPE} const proc = ${ESCAPE}
const st = globalThis.constructor.constructor('return setTimeout')() const st = globalThis.constructor.constructor('return setTimeout')()
@@ -613,7 +618,7 @@ describe('dsh-workflow-workerthread', () => {
// The STRAY child settles instantly, so its wrapper starts the slow // The STRAY child settles instantly, so its wrapper starts the slow
// host-side disposal concurrently while the script goes on to kill // host-side disposal concurrently while the script goes on to kill
// its own worker — the ack then resolves into a dead thread. // its own worker — the ack then resolves into a dead thread.
script: script(` ...scripted(`
agent('stray, never awaited') agent('stray, never awaited')
const proc = ${ESCAPE} const proc = ${ESCAPE}
const st = globalThis.constructor.constructor('return setTimeout')() const st = globalThis.constructor.constructor('return setTimeout')()
@@ -632,7 +637,7 @@ describe('dsh-workflow-workerthread', () => {
it('a worker death AFTER a cancel reports cancelled, not error', async () => { it('a worker death AFTER a cancel reports cancelled, not error', async () => {
const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 60_000 } }) const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 60_000 } })
const handle = ctx.workflows.start({ const handle = ctx.workflows.start({
script: script(` ...scripted(`
const proc = ${ESCAPE} const proc = ${ESCAPE}
const st = globalThis.constructor.constructor('return setTimeout')() const st = globalThis.constructor.constructor('return setTimeout')()
log('armed') log('armed')
@@ -659,8 +664,8 @@ describe('dsh-workflow-workerthread', () => {
const { ctx, parent } = await setup() const { ctx, parent } = await setup()
let eventMeta: WorkflowRunInfo | undefined let eventMeta: WorkflowRunInfo | undefined
ctx.on('workflow/start', (info) => { eventMeta = info }) ctx.on('workflow/start', (info) => { eventMeta = info })
const first = ctx.workflows.start({ script: script('return 1'), parent }) const first = ctx.workflows.start({ ...scripted('return 1'), parent })
const second = ctx.workflows.start({ script: script('return 2'), parent }) const second = ctx.workflows.start({ ...scripted('return 2'), parent })
expect(first.id).not.toBe(second.id) expect(first.id).not.toBe(second.id)
eventMeta!.meta.name = 'corrupted' eventMeta!.meta.name = 'corrupted'
expect(second.meta.name).toBe('test-flow') expect(second.meta.name).toBe('test-flow')
@@ -676,7 +681,7 @@ describe('dsh-workflow-workerthread', () => {
expect(ctx.get('workflows')).toBeDefined() expect(ctx.get('workflows')).toBeDefined()
// A zero-agent run through the DEFAULT config exercises the auto // A zero-agent run through the DEFAULT config exercises the auto
// concurrency resolution (cores - 2, capped) in start(). // concurrency resolution (cores - 2, capped) in start().
const result = await run(ctx, fakeParent(), script('return 6 * 7')) const result = await run(ctx, fakeParent(), scripted('return 6 * 7'))
expect(result.value).toBe(42) expect(result.value).toBe(42)
await fiber.dispose() await fiber.dispose()
expect(ctx.get('workflows')).toBeUndefined() expect(ctx.get('workflows')).toBeUndefined()

View File

@@ -11,7 +11,7 @@ The protected `emitWorkflowEvent` helper dispatches the `workflow/*` events with
## Vocabulary ## Vocabulary
- `WorkflowStartRequest` — `{ script, args?, parent: Agent, signal? }`. `parent` is REQUIRED: every child the script spawns is attributed to it. `args` must be plain host-realm JSON data. - `WorkflowStartRequest` — `{ script, args?, parent: Agent, signal? }`. `parent` is REQUIRED: every child the script spawns is attributed to it. `args` must be plain host-realm JSON data.
- `WorkflowMeta` / `WorkflowPhase` — the script's validated `export const meta` block (Claude Code format: required `name`/`description`, optional `whenToUse`/`phases`). - `WorkflowMeta` / `WorkflowPhase` — the workflow's identity block, carried as plain JSON data on the start request (Claude Code meta vocabulary: required `name`/`description`, optional `whenToUse`/`phases`) and shape-validated by the engine.
- `WorkflowRun` — `{ id, meta, result, cancel(reason?), dispose() }`; the consumer awaits `result` and MUST `dispose` on every path. - `WorkflowRun` — `{ id, meta, result, cancel(reason?), dispose() }`; the consumer awaits `result` and MUST `dispose` on every path.
- `WorkflowResult` — `{ value, stopReason: 'completed'|'cancelled'|'error', error?, agentsStarted }`; `value` is the script's materialized return (plain JSON data; `null` for no return). - `WorkflowResult` — `{ value, stopReason: 'completed'|'cancelled'|'error', error?, agentsStarted }`; `value` is the script's materialized return (plain JSON data; `null` for no return).
- `WorkflowError` — `HarnessError` with a `WorkflowErrorCode` and a `fatal` flag driving the combinator discipline: a fatal error (bad hook arguments, unsupported options/schemas, tripped caps, seam start failures, cancellation) always propagates through `parallel()`/`pipeline()` instead of dissolving into a per-item `null`. `isFatalWorkflowError(error)` is the catch-site predicate. - `WorkflowError` — `HarnessError` with a `WorkflowErrorCode` and a `fatal` flag driving the combinator discipline: a fatal error (bad hook arguments, unsupported options/schemas, tripped caps, seam start failures, cancellation) always propagates through `parallel()`/`pipeline()` instead of dissolving into a per-item `null`. `isFatalWorkflowError(error)` is the catch-site predicate.

View File

@@ -35,9 +35,11 @@ export interface WorkflowPhase {
} }
/** /**
* The script's `export const meta` block, validated by the engine before the * The script's identity block, provided as plain JSON data alongside the
* body runs. `name`/`description` are required; the rest is optional * script body (the model-facing tool carries it as its `meta` parameter) and
* annotation. Matches the Claude Code dynamic-workflows script format. * validated by the engine before the body runs. `name`/`description` are
* required; the rest is optional annotation. The field vocabulary matches the
* Claude Code dynamic-workflows meta block.
*/ */
export interface WorkflowMeta { export interface WorkflowMeta {
/** Short kebab-case workflow name (display + persistence key). */ /** Short kebab-case workflow name (display + persistence key). */
@@ -51,14 +53,18 @@ export interface WorkflowMeta {
} }
/** /**
* What a caller asks for when starting a workflow run. `parent` is REQUIRED — * What a caller asks for when starting a workflow run. `meta` and `args` are
* every `agent()` the script spawns is attributed to it (cwd, lineage, depth * plain JSON DATA by the seam contract (the tool builds both from the model's
* flow through the subagent seam). `args` must be plain host-realm JSON data; * schema-validated call; the engine validates `meta`'s shape and rejects loud
* the engine exposes it to the script as the `args` global. * before anything runs) — an engine never evaluates script text to obtain
* them. `parent` is REQUIRED — every `agent()` the script spawns is
* attributed to it (cwd, lineage, depth flow through the subagent seam).
*/ */
export interface WorkflowStartRequest { export interface WorkflowStartRequest {
/** The full script text: `export const meta = {...}` + a plain-JS body. */ /** The plain-JS script body (top-level await allowed; ends with `return <json-value>`). */
script: string script: string
/** The workflow's identity block, as plain JSON data (shape-validated by the engine). */
meta: WorkflowMeta
/** Optional input exposed verbatim to the script as the `args` global. */ /** Optional input exposed verbatim to the script as the `args` global. */
args?: unknown args?: unknown
/** The agent on whose behalf the run executes (parent of every child). */ /** The agent on whose behalf the run executes (parent of every child). */