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
+2 -2
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`
@@ -815,7 +815,7 @@ export interface Config {
maxTotalAgents?: number
/** Items accepted by a single `parallel()`/`pipeline()` call (default 4096). */
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
/**
* How long after a cancellation an unsettled script may keep running before
+4 -3
View File
@@ -8,20 +8,21 @@ Source: [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/work
## 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
interface WorkflowStartRequest {
script: string
meta: WorkflowMeta
args?: unknown
parent: Agent
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
interface WorkflowMeta {
@@ -12,7 +12,7 @@ A workflow capability family at `packages/workflow/` in the bash seam shape (int
### 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.
@@ -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.
**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.
### 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
@@ -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.
- **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`.
- **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.
- **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.
+50 -3
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.
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:
- `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": {
"script": {
"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": {
"type": "object",
@@ -309,7 +355,8 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim
}
},
"required": [
"script"
"script",
"meta"
]
}
```
+36 -17
View File
@@ -13,9 +13,8 @@
* collection is deferred to the cross-tool background redesign.
*
* Render intent (decided up front, per the render-intent RFC): a `generic`
* card whose title carries the script's `meta.name`, sniffed textually from
* the args — presentation must be a pure function of `args`, so it cannot ask
* the engine to parse.
* card whose title carries the workflow's `meta.name`, read directly from the
* call's `meta` parameter — presentation is a pure function of `args`.
*
* Usage policy ships with the tool as a `tool:<toolName>` system-prompt
* 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.
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:
- \`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.`
type WorkflowCallArgs = { script: string; args?: Record<string, unknown> }
/** Best-effort meta.name sniff for presentation (pure textual; no evaluation). */
function sniffMetaName(script: string): string | undefined {
const match = /export\s+const\s+meta\s*=\s*\{[^{}]*?name\s*:\s*(['"`])([^'"`\n]{1,64})\1/.exec(script)
return match?.[2]
type WorkflowCallArgs = {
script: string
meta: { name: string; description: string; whenToUse?: string; phases?: { title: string; detail?: string; model?: string }[] }
args?: Record<string, unknown>
}
/** 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 {
const name = sniffMetaName(args.script)
return {
card: 'generic',
title: name !== undefined ? `workflow: ${name}` : 'workflow',
title: `workflow: ${args.meta.name}`,
rawInput: args.script,
}
}
@@ -139,7 +135,29 @@ export function apply(ctx: Context, config: Config): void {
script: {
type: 'string',
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: {
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)')
}
// Parse failures (SCRIPT_PARSE/META_INVALID) throw synchronously here
// and become isError results via the registry — the model sees the
// violation list and can correct the script.
// Meta/body validation failures (META_INVALID/SCRIPT_PARSE) throw
// synchronously here and become isError results via the registry — the
// model sees the violation list and can correct the call.
const run: WorkflowRun = ctx.workflows.start({
script: args.script,
meta: args.meta,
...args.args !== undefined ? { args: args.args } : {},
parent,
...exec.signal ? { signal: exec.signal } : {},
@@ -55,7 +55,8 @@ async function setup(config?: { toolName?: string; maxResultChars?: number }) {
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> {
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 () => {
const { ctx, engine, parent } = await setup()
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) })
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)
engine.settle({ value: { findings: [1, 2] }, stopReason: 'completed', agentsStarted: 7 })
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 () => {
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) })
engine.settle({ value: null, stopReason: 'error', error: 'script threw: boom', agentsStarted: 2 })
const result = await pending
@@ -97,14 +98,14 @@ describe('dsh-tool-workflow', () => {
it('reports a cancelled run distinctly (with and without a reason)', async () => {
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) })
engine.settle({ value: null, stopReason: 'cancelled', error: 'user', agentsStarted: 0 })
const result = await pending
expect(result.isError).toBe(true)
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) })
engine.settle({ value: null, stopReason: 'cancelled', agentsStarted: 0 })
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 () => {
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) })
engine.settle({ value: null, stopReason: 'error', agentsStarted: 0 })
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 () => {
const { ctx, engine, parent } = await setup()
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) })
controller.abort()
const result = await pending
@@ -130,17 +131,17 @@ describe('dsh-tool-workflow', () => {
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()
engine.startError = new Error('script must begin with `export const meta = {...}`')
const result = await execute(ctx, { script: 'nope' }, { agent: parent })
engine.startError = new Error('invalid meta: meta.name must be a non-empty string')
const result = await execute(ctx, { script: 'nope', meta: { name: '', description: 'd' } }, { agent: parent })
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 () => {
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.content[0] as { text: string }).text).toContain('requires a calling agent')
expect(engine.requests.length).toBe(0)
@@ -157,7 +158,7 @@ describe('dsh-tool-workflow', () => {
const { ctx, engine, parent } = await setup()
const controller = new AbortController()
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(engine.cancels).toContain('parent step aborted')
expect(engine.disposed).toBe(1)
@@ -165,7 +166,7 @@ describe('dsh-tool-workflow', () => {
it('truncates an oversized rendered value with a notice (maxResultChars)', async () => {
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) })
engine.settle({ value: { blob: 'x'.repeat(500) }, stopReason: 'completed', agentsStarted: 1 })
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)
})
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 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 })
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 () => {
const { ctx } = await setup()
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
// 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!({ script: SCRIPT })).toBeUndefined()
})
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 controller = new AbortController()
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 })
// Give the run a beat to start (past its synchronous slice), then abort.
await new Promise(resolve => setTimeout(resolve, 20))
@@ -14,19 +14,19 @@ What the seam guarantees regardless, because benign scripts hit these constantly
## 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).
- **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
`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.
## 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
@@ -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))`. |
| `maxTotalAgents` | `1000` | Total `agent()` calls one run may start (runaway-loop backstop). |
| `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()`. |
@@ -47,10 +47,10 @@ import z from 'schemastery'
import WorkflowService, { WorkflowError, WorkflowRunId } from '@deepseek-ai/dsh-workflow'
import type { WorkflowRun, WorkflowRunInfo, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
import { WorkerRun } from './host.ts'
import { extractMeta } from './meta.ts'
import { validateMeta } from './meta.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 type { HostToWorkerMessage, HostToWorkerPayloads, WorkerToHostMessage, WorkerToHostPayloads } from './protocol.ts'
export { materializeFromRealm, MaterializeError } from './realm.ts'
@@ -75,7 +75,7 @@ export interface Config {
maxTotalAgents?: number
/** Items accepted by a single `parallel()`/`pipeline()` call (default 4096). */
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
/**
* How long after a cancellation an unsettled script may keep running before
@@ -87,13 +87,21 @@ export interface 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
* compiles, so `start()` keeps the seam's synchronous `SCRIPT_PARSE` throw
* (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 {
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 {
// Parse only — the script object is discarded, nothing executes.
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
* {@link WorkflowError} synchronously (`SCRIPT_PARSE`/`META_INVALID`) for a
* script that cannot begin; once a run is returned, every failure resolves
* through `result.stopReason` instead.
* @param request - the script, its `args`, the parent agent, and an
* optional cancel signal.
* Validate and execute a workflow script in a fresh worker thread. Throws
* {@link WorkflowError} synchronously (`META_INVALID` for a malformed meta
* block, `SCRIPT_PARSE` for a body that does not compile) for a request
* that cannot begin; once a run is returned, every failure resolves through
* `result.stopReason` instead.
* @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).
*/
start(request: WorkflowStartRequest): WorkflowRun {
const { meta, body } = extractMeta(request.script, this.config.syncTimeoutMs)
assertBodyParses(body, meta.name)
const meta = validateMeta(request.meta)
assertBodyParses(request.script, meta.name)
const id = WorkflowRunId(randomUUID())
// The event payloads and the run handle get SEPARATE meta clones: a
// listener mutating its snapshot must not corrupt the holder's view.
@@ -155,7 +164,7 @@ export class WorkerWorkflowEngine extends WorkflowService {
}
const init: WorkerInit = {
meta,
body,
body: request.script,
...request.args !== undefined ? { args: request.args } : {},
limits,
}
@@ -1,100 +1,24 @@
/**
* Meta-block extraction: turn a Claude Code-format workflow script —
* `export const meta = {...}` followed by a plain-JS body — into a validated
* {@link WorkflowMeta} plus the body with the meta statement blanked
* line-preservingly (error stacks keep the script's own line numbers).
*
* The scanner is a small string/comment-aware brace matcher, not a JS parser:
* it only has to find the END of the meta object literal, and the literal is
* contractually PURE (no interpolation, no computed values). Template strings
* 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).
* Meta validation: check the caller-provided {@link WorkflowMeta} DATA against
* the shape contract and reject everything else loud, every violation named.
* Meta arrives as plain JSON through the seam (the model-facing tool carries
* 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
* here (an evaluated meta literal could smuggle getters that spin the host
* outside any vm timeout, the exact escape the worker thread exists to
* prevent).
*
* @module @deepseek-ai/dsh-workflow-workerthread/meta
*/
import * as vm from 'node:vm'
import { WorkflowError } 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. */
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). */
/** Collect shape violations for a meta value (plain JSON data by the seam contract). */
function validateMetaShape(meta: unknown): { meta?: WorkflowMeta; 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)) {
return { violations: ['meta must be an object literal'] }
return { violations: ['meta must be an object'] }
}
const record = meta as Record<string, unknown>
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
* comments. A hand-rolled character scan, NOT a prefix regex — an
* all-alternation prefix (`\s*(?:comment|\s+)*`) partitions a whitespace run
* ambiguously and backtracks EXPONENTIALLY when the match ultimately fails,
* so a near-miss script (a comment header, then a forgotten `export`) would
* spin the host synchronously inside `start()`, where no vm timeout applies.
* The near-miss must fail fast into `SCRIPT_PARSE` instead — that error is
* the model's retry signal.
* Validate a caller-provided meta value against the {@link WorkflowMeta}
* contract. Throws `META_INVALID` naming every violation (unknown fields,
* missing/mistyped `name`/`description`, malformed `phases`); the returned
* meta is a NORMALIZED copy built from the validated fields, so the engine
* never aliases the caller's object.
* @param value - the meta data from the start request (plain JSON by the seam contract).
* @returns the validated, normalized meta block.
*/
function skipLeadingTrivia(source: string): number {
let index = 0
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)
export function validateMeta(value: unknown): WorkflowMeta {
const { meta, violations } = validateMetaShape(value)
if (meta === undefined) {
throw new WorkflowError(`invalid meta block: ${violations.join('; ')}`, 'META_INVALID')
throw new WorkflowError(`invalid meta: ${violations.join('; ')}`, 'META_INVALID')
}
// 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) }
return meta
}
@@ -109,7 +109,7 @@ export class WorkflowExecution {
// wrapper, so under one Node version this throw is unreachable in
// production — the session still maps it to an error result defensively.
// 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 {
this.compiled = new vm.Script(`(async () => {\n${body}\n})()`, {
filename: `workflow:${meta.name}`,
@@ -30,9 +30,9 @@ export interface WorkerLimits {
/** The `workerData` payload one run is initialized with (host → worker, once, at spawn). */
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
/** 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
/** The run's `args` value; the workerData structured clone is the copy that isolates the caller. */
args?: unknown
@@ -34,7 +34,8 @@ const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(WorkerWorkflowEngine, {})
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.
parent: { id: 'built-smoke-parent', options: {} },
})
@@ -50,8 +50,8 @@ describe('dsh-workflow-workerthread over the real in-process stack', () => {
const childIds: string[] = []
ctx.on('workflow/agent-start', (_info, agent) => { childIds.push(agent.childId) })
const run = ctx.workflows.start({
script: `export const meta = { name: 'integration', description: 'plain + structured children' }
phase('Read')
meta: { name: 'integration', description: 'plain + structured children' },
script: `phase('Read')
const prose = await agent('read the repo')
phase('Judge')
const judged = await agent('judge: ' + prose, {
@@ -78,8 +78,8 @@ return { prose, verdict: judged.verdict, confidence: judged.confidence }`,
textResponse('still prose after the nudge'),
])
const run = ctx.workflows.start({
script: `export const meta = { name: 'null-path', description: 'schema failure maps to null' }
const judged = await agent('judge it', { schema: { type: 'object', properties: { v: { type: 'string' } } } })
meta: { name: 'null-path', description: 'schema failure maps to null' },
script: `const judged = await agent('judge it', { schema: { type: 'object', properties: { v: { type: 'string' } } } })
return { got: judged === null ? 'null' : 'value' }`,
parent,
})
@@ -1,182 +1,88 @@
import { describe, expect, it } from 'vitest'
import { WorkflowError } from '@deepseek-ai/dsh-workflow'
import { extractMeta } from '../src/meta.ts'
import { validateMeta } from '../src/meta.ts'
const TIMEOUT = 1000
/** Extract and expect success. */
function ok(script: string) {
return extractMeta(script, TIMEOUT)
}
/** The WorkflowError a bad script produces (throws if it extracts cleanly). */
function bad(script: string): WorkflowError {
/** Assert a META_INVALID throw whose message matches every given fragment. */
function expectInvalid(value: unknown, ...fragments: string[]): void {
let thrown: unknown
try {
extractMeta(script, TIMEOUT)
validateMeta(value)
} catch (error: unknown) {
if (error instanceof WorkflowError) return error
throw error
thrown = 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', () => {
it('extracts a full meta block and blanks the statement line-preservingly', () => {
const script = `export const meta = {
name: 'audit-routes',
description: 'Audit every route',
whenToUse: 'when auditing',
phases: [{ title: 'Scan', detail: 'find files' }, { title: 'Fix', model: 'deepseek-v4-pro' }],
}
const x = 1
return x`
const { meta, body } = ok(script)
expect(meta).toEqual({
name: 'audit-routes',
description: 'Audit every route',
whenToUse: 'when auditing',
phases: [{ title: 'Scan', detail: 'find files' }, { title: 'Fix', model: 'deepseek-v4-pro' }],
describe('validateMeta', () => {
it('accepts a minimal meta and returns a normalized copy (no aliasing of the input)', () => {
const input = { name: 'audit', description: 'audit the repo' }
const meta = validateMeta(input)
expect(meta).toEqual({ name: 'audit', description: 'audit the repo' })
expect(meta).not.toBe(input)
input.name = 'mutated'
expect(meta.name).toBe('audit')
})
it('accepts the full shape and rebuilds phases entry by entry', () => {
const meta = validateMeta({
name: 'migrate',
description: 'migrate call sites',
whenToUse: 'large mechanical sweeps',
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', () => {
const script = `// a workflow
/* multi
line */
export const meta = { name: 'x', description: 'y' }
return 1`
expect(ok(script).meta.name).toBe('x')
it('rejects non-object values loud', () => {
expectInvalid(undefined, 'meta must be an object')
expectInvalid('a string', 'meta must be an object')
expectInvalid(null, 'meta must be an object')
expectInvalid([{ name: 'x', description: 'd' }], 'meta must be an object')
})
it('handles braces inside strings and comments while scanning', () => {
const script = `export const meta = {
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('rejects unknown fields by name (accepted-then-ignored is banned)', () => {
expectInvalid({ name: 'x', description: 'd', color: 'red' }, 'meta.color is not a recognized field')
})
it('tolerates template-quoted strings WITHOUT interpolation, escapes included', () => {
const script = 'export const meta = { name: `plain`, description: `esc \\` tick` }\nreturn 1'
expect(ok(script).meta.name).toBe('plain')
it('rejects missing or mistyped name/description/whenToUse', () => {
expectInvalid({ description: 'd' }, 'meta.name must be a non-empty string')
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', () => {
const { body } = ok("export const meta = { name: 'x', description: 'y' };\nreturn 1")
expect(body).not.toContain(';')
expect(body.split('\n')[1]).toBe('return 1')
const spaced = ok("export const meta = { name: 'x', description: 'y' } ;\nreturn 1")
expect(spaced.body).not.toContain(';')
it('rejects malformed phases, entry by entry', () => {
expectInvalid({ name: 'x', description: 'd', phases: 'Scan' }, 'meta.phases must be an array')
expectInvalid({ name: 'x', description: 'd', phases: ['Scan'] }, 'meta.phases[0] must be an object')
expectInvalid({ name: 'x', description: 'd', phases: [{ title: '' }] }, 'meta.phases[0].title must be a non-empty string')
expectInvalid({ name: 'x', description: 'd', phases: [{ title: 'Scan', order: 1 }] }, 'meta.phases[0].order is not a recognized field')
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)', () => {
expect(bad('const a = 1').code).toBe('SCRIPT_PARSE')
expect(bad('').code).toBe('SCRIPT_PARSE')
expect(bad('export const meta = [1]').code).toBe('SCRIPT_PARSE')
})
it('a near-miss prefix (comment header + whitespace, then no `export`) fails FAST as SCRIPT_PARSE', () => {
// 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')
it('names EVERY violation in one throw, not just the first', () => {
expectInvalid(
{ description: 7, extra: true, phases: [{ title: 'Scan' }, 'bad'] },
'meta.extra is not a recognized field',
'meta.name must be a non-empty string',
'meta.description must be a non-empty string',
'meta.phases[1] must be an object',
)
})
})
@@ -42,12 +42,12 @@ async function harness(): Promise<Context> {
return built
}
const SCRIPT = `export const meta = {
const META = {
name: 'e2e-worker-arithmetic',
description: 'two real children through a worker thread: one prose, one structured',
phases: [{ title: 'Ask' }, { title: 'Judge' }],
}
phase('Ask')
const SCRIPT = `phase('Ask')
log('asking the prose child')
const prose = await agent('Reply with exactly one short sentence: what is 2 + 2?')
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
await run.dispose()
@@ -5,7 +5,7 @@ import { AgentId } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SubagentService 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 WorkerWorkflowEngine, { type Config } from '../src/index.ts'
@@ -104,14 +104,14 @@ async function setup(options?: SetupOptions) {
return { ctx, provider, parent: fakeParent() }
}
/** Wrap a body in the minimal valid meta header. */
function script(body: string, metaExtra = ''): string {
return `export const meta = { name: 'test-flow', description: 'a test workflow'${metaExtra} }\n${body}`
/** The standard test meta plus a body, spread into a start request. */
function scripted(body: string, metaExtra?: Partial<WorkflowMeta>): { script: string; meta: WorkflowMeta } {
return { script: body, meta: { name: 'test-flow', description: 'a test workflow', ...metaExtra } }
}
/** Start + await one run, disposing on the way out. */
async function run(ctx: Context, parent: Agent, source: string, args?: unknown): Promise<WorkflowResult> {
const handle = ctx.workflows.start({ script: source, parent, ...args !== undefined ? { args } : {} })
async function run(ctx: Context, parent: Agent, source: { script: string; meta: WorkflowMeta }, args?: unknown): Promise<WorkflowResult> {
const handle = ctx.workflows.start({ ...source, parent, ...args !== undefined ? { args } : {} })
try {
return await handle.result
} 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) {
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')
log('starting with ' + args.files.length + ' files')
const answers = await pipeline(args.files, (prev, item) => agent('read ' + item))
phase('Report')
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.agentsStarted).toBe(2)
@@ -156,7 +156,7 @@ describe('dsh-workflow-workerthread', () => {
const { ctx, parent, provider } = await setup({
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'] } })
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 () => {
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.error).toContain('"isolation" is deferred')
})
it('a provider start failure crosses back as a fatal AGENT_START error', async () => {
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.error).toContain('agent() could not start a child')
})
@@ -200,7 +200,7 @@ describe('dsh-workflow-workerthread', () => {
}
ctx.subagents.registerProvider(provider)
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 } }
`))
expect(result.value).toMatchObject({ name: 'WorkflowError', code: 'AGENT_RESULT', fatal: true })
@@ -223,7 +223,7 @@ describe('dsh-workflow-workerthread', () => {
}
ctx.subagents.registerProvider(provider)
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.value).toBe('fine')
})
@@ -248,17 +248,22 @@ describe('dsh-workflow-workerthread', () => {
}
ctx.subagents.registerProvider(provider)
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.value).toBe('fine')
})
})
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()
expect(() => ctx.workflows.start({ script: 'const x = 1', parent })).toThrow(/must begin with/)
expect(() => ctx.workflows.start({ script: script('return ((('), parent })).toThrow(/does not parse/)
// Meta is DATA — shape violations reject loud, every one named.
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 () => {
@@ -267,7 +272,7 @@ describe('dsh-workflow-workerthread', () => {
ctx.on('workflow/agent-end', (_info, agent) => { ends.push(agent) })
const runEnds: WorkflowResultInfo[] = []
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) })
handle.cancel('user stopped it')
const result = await handle.result
@@ -287,7 +292,7 @@ describe('dsh-workflow-workerthread', () => {
controller.abort()
const logs: string[] = []
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
expect(result.stopReason).toBe('cancelled')
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 () => {
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.
first.cancel()
const firstResult = await first.result
@@ -308,7 +313,7 @@ describe('dsh-workflow-workerthread', () => {
await first.dispose()
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) })
controller.abort()
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
// is initialized — the listener fires on the worker's first message.)
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
expect(result.stopReason).toBe('cancelled')
expect(provider.runs.length).toBe(0)
@@ -342,7 +347,7 @@ describe('dsh-workflow-workerthread', () => {
// host cancellation. The trailing narration exercises host-side
// suppression: posted pre-cancel-processing worker-side, arriving
// post-cancel host-side.
script: script(`
...scripted(`
log('started')
const end = Date.now() + 1000
while (Date.now() < end) {}
@@ -366,7 +371,7 @@ describe('dsh-workflow-workerthread', () => {
const runEnds: WorkflowResultInfo[] = []
ctx.on('workflow/end', (_info, result) => { runEnds.push(result) })
const handle = ctx.workflows.start({
script: script("await new Promise(() => {})\nreturn 'unreachable'"),
...scripted("await new Promise(() => {})\nreturn 'unreachable'"),
parent,
})
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 () => {
const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 50 } })
const handle = ctx.workflows.start({
script: script("await new Promise(() => {})\nreturn 'unreachable'"),
...scripted("await new Promise(() => {})\nreturn 'unreachable'"),
parent,
})
const before = Date.now()
@@ -394,7 +399,7 @@ describe('dsh-workflow-workerthread', () => {
it('dispose() is idempotent and settles cleanly after a completed run', async () => {
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.dispose()
await handle.dispose()
@@ -405,7 +410,7 @@ describe('dsh-workflow-workerthread', () => {
// apart from every other timeout in flight.
const GRACE = 44_444
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
const spy = vi.spyOn(globalThis, 'setTimeout')
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 () => {
const { ctx, parent, provider } = await setup({ manual: true, disposeDelayMs: 40 })
const handle = ctx.workflows.start({
script: script(`
...scripted(`
agent('stray')
return 'done without awaiting'
`),
@@ -468,7 +473,7 @@ describe('dsh-workflow-workerthread', () => {
ctx.subagents.registerProvider(provider)
await ctx.plugin(WorkerWorkflowEngine, { provider: 'signal-only', maxConcurrentAgents: 2 })
const handle = ctx.workflows.start({
script: script(`
...scripted(`
agent('stray, never awaited')
return 'done'
`),
@@ -515,7 +520,7 @@ describe('dsh-workflow-workerthread', () => {
// microtask yields let the agent() continuation POST its child-start
// before the spin seizes the worker's loop (the posted message needs
// no further worker-loop turns to reach the host).
script: script(`
...scripted(`
agent('wedged child')
for (let i = 0; i < 20; i++) await null
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
// its own worker through the documented vm escape — the host must
// settle `error` with the exit diagnostics and wind the child down.
script: script(`
...scripted(`
agent('doomed')
const proc = ${ESCAPE}
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 () => {
const { ctx, parent, provider } = await setup({ manual: true })
const handle = ctx.workflows.start({
script: script(`
...scripted(`
agent('in flight when the worker dies')
const proc = ${ESCAPE}
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
// host-side disposal concurrently while the script goes on to kill
// its own worker — the ack then resolves into a dead thread.
script: script(`
...scripted(`
agent('stray, never awaited')
const proc = ${ESCAPE}
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 () => {
const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 60_000 } })
const handle = ctx.workflows.start({
script: script(`
...scripted(`
const proc = ${ESCAPE}
const st = globalThis.constructor.constructor('return setTimeout')()
log('armed')
@@ -659,8 +664,8 @@ describe('dsh-workflow-workerthread', () => {
const { ctx, parent } = await setup()
let eventMeta: WorkflowRunInfo | undefined
ctx.on('workflow/start', (info) => { eventMeta = info })
const first = ctx.workflows.start({ script: script('return 1'), parent })
const second = ctx.workflows.start({ script: script('return 2'), parent })
const first = ctx.workflows.start({ ...scripted('return 1'), parent })
const second = ctx.workflows.start({ ...scripted('return 2'), parent })
expect(first.id).not.toBe(second.id)
eventMeta!.meta.name = 'corrupted'
expect(second.meta.name).toBe('test-flow')
@@ -676,7 +681,7 @@ describe('dsh-workflow-workerthread', () => {
expect(ctx.get('workflows')).toBeDefined()
// A zero-agent run through the DEFAULT config exercises the auto
// 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)
await fiber.dispose()
expect(ctx.get('workflows')).toBeUndefined()
+1 -1
View File
@@ -11,7 +11,7 @@ The protected `emitWorkflowEvent` helper dispatches the `workflow/*` events with
## 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.
- `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.
- `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.
+14 -8
View File
@@ -35,9 +35,11 @@ export interface WorkflowPhase {
}
/**
* The script's `export const meta` block, validated by the engine before the
* body runs. `name`/`description` are required; the rest is optional
* annotation. Matches the Claude Code dynamic-workflows script format.
* The script's identity block, provided as plain JSON data alongside the
* script body (the model-facing tool carries it as its `meta` parameter) and
* 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 {
/** 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 —
* every `agent()` the script spawns is attributed to it (cwd, lineage, depth
* flow through the subagent seam). `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 workflow run. `meta` and `args` are
* plain JSON DATA by the seam contract (the tool builds both from the model's
* schema-validated call; the engine validates `meta`'s shape and rejects loud
* 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 {
/** 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
/** 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. */
args?: unknown
/** The agent on whose behalf the run executes (parent of every child). */