A new capability family at packages/workflow/ in the bash seam shape,
modeled on Claude Code's dynamic workflows: the model writes a JavaScript
orchestration script (export const meta = {...} + plain-JS body), a runtime
executes it, and the script — not the conversation — holds the loop, the
branching, and the intermediate results.
- dsh-workflow (ctx.workflows): abstract WorkflowService + run vocabulary
(WorkflowRun whose result NEVER rejects) + observe-only workflow/* events
carrying data snapshots (id + meta, never the live run), per-listener
contained like subagent/*.
- dsh-workflow-vm: in-process node:vm engine. Meta extraction via a
string/comment-aware scanner (template interpolation rejected; literal
evaluated alone in an empty timed context; statement blanked line-
preservingly so stacks keep script line numbers). Hooks: agent(prompt,
{label, phase, schema, model}) over ctx.subagents, parallel(), pipeline()
(no cross-stage barrier), phase(), log(), args. Fatal-vs-null discipline:
hook misuse (unknown/deferred options, bad arguments, unsupported
schemas, tripped caps, seam start failures, cancellation) throws fatal
WorkflowErrors the combinators RE-THROW — never dissolved into the
per-item null reserved for child failures. Realm boundary: inbound values
materialized by descriptor walks that never invoke accessors (defineProperty
copies, __proto__-safe); outbound values rebuilt in-realm via the
context's own JSON.parse. Determinism bans (Date.now/Math.random/argless
new Date) kept so future resume support cannot break scripts. Caps and
timeouts are validated Config. Every hook promise carries a no-op
rejection consumer (app-boot exits on unhandled rejections).
- dsh-tool-workflow: the model-facing workflow tool, synchronous like
dsh-tool-subagent (start → await → try/finally dispose; abort bridged;
non-completed → isError). Generic render card titled by a textual
meta.name sniff. The tool description carries the authoring contract.
Wired into examples/{coding-agent,acp-agent} with explicit-ask-only
guidance. Coverage at every tier: unit (meta scanner, materializer incl.
counting-getter and __proto__ regressions, combinator semantics,
concurrency ceiling, caps, cancellation, no-unhandled-rejection abandon),
integration over the real spawn stack, with-key e2e (real two-phase run +
the tool through the registry pipeline), and a recorded ACP snapshot
scenario (workflow-run, 1 child session). RFC:
docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md (deferred
work explicitly listed). AGENTS.md budget 1575 → 1590 for the new group's
layout line.
198 lines
9.2 KiB
TypeScript
198 lines
9.2 KiB
TypeScript
/**
|
|
* 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).
|
|
*
|
|
* @module @deepseek-ai/dsh-workflow-vm/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 } 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
|
|
}
|
|
|
|
const META_PREFIX = /^\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*|\s+)*export\s+const\s+meta\s*=\s*/
|
|
|
|
/**
|
|
* Scan `source` from `start` (an opening `{`) to its matching `}`, aware of
|
|
* string literals (`'`/`"`/backtick, with escapes) and comments. Returns the
|
|
* index AFTER the closing brace. Throws `SCRIPT_PARSE` on template
|
|
* interpolation (`${` inside a backtick string) or an unterminated literal.
|
|
*/
|
|
function scanObjectLiteral(source: string, start: number): number {
|
|
let depth = 0
|
|
let index = start
|
|
while (index < source.length) {
|
|
const ch = source.charAt(index)
|
|
if (ch === '/' && source[index + 1] === '/') {
|
|
const end = source.indexOf('\n', index)
|
|
index = end === -1 ? source.length : end + 1
|
|
continue
|
|
}
|
|
if (ch === '/' && source[index + 1] === '*') {
|
|
const end = source.indexOf('*/', index + 2)
|
|
if (end === -1) throw new WorkflowError('meta block has an unterminated comment', 'SCRIPT_PARSE')
|
|
index = end + 2
|
|
continue
|
|
}
|
|
if (ch === '\'' || ch === '"' || ch === '`') {
|
|
index = scanString(source, index, ch)
|
|
continue
|
|
}
|
|
if (ch === '{' || ch === '[') depth += 1
|
|
if (ch === '}' || ch === ']') {
|
|
depth -= 1
|
|
if (depth === 0) return index + 1
|
|
}
|
|
index += 1
|
|
}
|
|
throw new WorkflowError('meta block is not a balanced object literal', 'SCRIPT_PARSE')
|
|
}
|
|
|
|
/** Scan past one string literal starting at `start` (the quote char); returns the index after the closing quote. */
|
|
function scanString(source: string, start: number, quote: string): number {
|
|
let index = start + 1
|
|
while (index < source.length) {
|
|
const ch = source.charAt(index)
|
|
if (ch === '\\') {
|
|
index += 2
|
|
continue
|
|
}
|
|
if (quote === '`' && ch === '$' && source[index + 1] === '{') {
|
|
throw new WorkflowError('template interpolation (`${...}`) is not allowed in the meta block — meta must be a pure literal', 'SCRIPT_PARSE')
|
|
}
|
|
if (ch === quote) return index + 1
|
|
index += 1
|
|
}
|
|
throw new WorkflowError('meta block has an unterminated string literal', 'SCRIPT_PARSE')
|
|
}
|
|
|
|
/** Replace `[from, to)` of `source` with whitespace, preserving every newline (line numbers survive). */
|
|
function blankSpan(source: string, from: number, to: number): string {
|
|
const blanked = source.slice(from, to).replace(/[^\n]/g, ' ')
|
|
return source.slice(0, from) + blanked + source.slice(to)
|
|
}
|
|
|
|
/** Collect shape violations for an evaluated meta value (already materialized to host JSON data). */
|
|
function validateMetaShape(meta: unknown): { meta?: WorkflowMeta; violations: string[] } {
|
|
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'] }
|
|
}
|
|
const record = meta as Record<string, unknown>
|
|
const known = new Set(['name', 'description', 'whenToUse', 'phases'])
|
|
for (const key of Object.keys(record)) {
|
|
if (!known.has(key)) violations.push(`meta.${key} is not a recognized field (name/description/whenToUse/phases)`)
|
|
}
|
|
if (typeof record.name !== 'string' || record.name.length === 0) violations.push('meta.name must be a non-empty string')
|
|
if (typeof record.description !== 'string' || record.description.length === 0) violations.push('meta.description must be a non-empty string')
|
|
if (record.whenToUse !== undefined && typeof record.whenToUse !== 'string') violations.push('meta.whenToUse must be a string')
|
|
const phases: WorkflowPhase[] = []
|
|
if (record.phases !== undefined) {
|
|
if (!Array.isArray(record.phases)) {
|
|
violations.push('meta.phases must be an array')
|
|
} else {
|
|
record.phases.forEach((phase, index) => {
|
|
if (typeof phase !== 'object' || phase === null || Array.isArray(phase)) {
|
|
violations.push(`meta.phases[${index}] must be an object`)
|
|
return
|
|
}
|
|
const entry = phase as Record<string, unknown>
|
|
for (const key of Object.keys(entry)) {
|
|
if (!['title', 'detail', 'model'].includes(key)) violations.push(`meta.phases[${index}].${key} is not a recognized field`)
|
|
}
|
|
if (typeof entry.title !== 'string' || entry.title.length === 0) violations.push(`meta.phases[${index}].title must be a non-empty string`)
|
|
if (entry.detail !== undefined && typeof entry.detail !== 'string') violations.push(`meta.phases[${index}].detail must be a string`)
|
|
if (entry.model !== undefined && typeof entry.model !== 'string') violations.push(`meta.phases[${index}].model must be a string`)
|
|
if (violations.length === 0) {
|
|
phases.push({
|
|
title: entry.title as string,
|
|
...entry.detail !== undefined ? { detail: entry.detail as string } : {},
|
|
...entry.model !== undefined ? { model: entry.model as string } : {},
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
if (violations.length > 0) return { violations }
|
|
return {
|
|
violations,
|
|
meta: {
|
|
name: record.name as string,
|
|
description: record.description as string,
|
|
...record.whenToUse !== undefined ? { whenToUse: record.whenToUse as string } : {},
|
|
...record.phases !== undefined ? { phases } : {},
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 match = META_PREFIX.exec(script)
|
|
if (!match) {
|
|
throw new WorkflowError('script must begin with `export const meta = {...}` (leading comments allowed)', 'SCRIPT_PARSE')
|
|
}
|
|
const literalStart = 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: ${String(error)}`, 'META_INVALID', { cause: error })
|
|
}
|
|
let data: unknown
|
|
try {
|
|
data = materializeFromRealm(evaluated, 'meta')
|
|
} catch (error: unknown) {
|
|
/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */
|
|
if (!(error instanceof MaterializeError)) throw error
|
|
throw new WorkflowError(`meta block is not pure JSON data — ${error.message}`, 'META_INVALID', { cause: error })
|
|
}
|
|
const { meta, violations } = validateMetaShape(data)
|
|
if (meta === undefined) {
|
|
throw new WorkflowError(`invalid meta block: ${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) }
|
|
}
|