mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Codex code-review round 2: errorText() read .stack/.message as plain property gets and fell back to String(error) — a script throwing a value with a throwing accessor (or toString/Symbol.toPrimitive) ran realm code in drive()'s catch and made WorkflowRun.result REJECT, which the detached workflow/end hook turned into an unhandledRejection (process death under dsh-app-boot). Replaced with describeThrown in dsh-workflow-vm/realm: total (never throws), proxy-labelling before any inspection, own-descriptor reads, String() only on primitives, and a CONTAINED stack-getter invocation — modern V8 (Node >= 22) makes stack an own ACCESSOR on genuine Errors, so refusing all accessors would lose every real stack and the lineOffset line numbers; a hostile getter's throw is swallowed and rendering falls back to message. The meta-literal eval catch had the same String(error) exposure and now uses the same renderer. Regression tests: a hostile-thrown-values table through the real engine (throwing stack/message getters, data stack, setter-only stack, proxy, Symbol.toPrimitive, function, null) asserting result resolves 'error' with the expected rendering and NO unhandledRejection fires; a meta-path hostile throw mapping to META_INVALID.
535 lines
24 KiB
TypeScript
535 lines
24 KiB
TypeScript
/**
|
|
* Per-run execution state for the vm workflow engine: the script context and
|
|
* its injected hooks (`agent`/`parallel`/`pipeline`/`phase`/`log`/`args`), the
|
|
* concurrency semaphore and caps, cancellation, and the drive loop that turns
|
|
* a script settlement into a {@link WorkflowResult}.
|
|
*
|
|
* Realm discipline (see also ./realm.ts): values ENTERING the host from the
|
|
* script (hook options, schemas, the return value) are materialized via
|
|
* descriptor walks; values ENTERING the realm from the host (`args`, agent()
|
|
* results) are rebuilt INSIDE the realm through the context's own
|
|
* `JSON.parse`, so the script never holds an object whose prototype chain
|
|
* reaches host intrinsics. The arrays `parallel`/`pipeline` resolve to are
|
|
* realm-built for the same reason (their ELEMENTS are realm values already —
|
|
* only the container needs rebuilding). Realm functions (pipeline stages,
|
|
* parallel thunks) are called, not materialized — their values stay
|
|
* realm-side.
|
|
*
|
|
* Failure discipline: fatal {@link WorkflowError}s (bad hook arguments,
|
|
* unsupported options/schemas, tripped caps, seam start failures,
|
|
* cancellation) ALWAYS propagate through `parallel`/`pipeline`; the per-item
|
|
* `null` is reserved for child-run failures and ordinary in-stage script
|
|
* errors. Every hook-returned promise gets a no-op rejection consumer
|
|
* attached, so a script that drops a promise (fires an `agent()` without
|
|
* awaiting it) cannot surface an unhandled rejection when cancellation
|
|
* rejects it — the app boot layer exits the process on unhandled rejections.
|
|
*
|
|
* @module @deepseek-ai/dsh-workflow-vm/runtime
|
|
*/
|
|
|
|
import * as vm from 'node:vm'
|
|
import type { Context } from 'cordis'
|
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
import type {} from '@deepseek-ai/dsh-subagent'
|
|
import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools'
|
|
import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
|
import { WorkflowError, isFatalWorkflowError } from '@deepseek-ai/dsh-workflow'
|
|
import type {
|
|
WorkflowAgentEndInfo,
|
|
WorkflowAgentInfo,
|
|
WorkflowMeta,
|
|
WorkflowResult,
|
|
} from '@deepseek-ai/dsh-workflow'
|
|
import { materializeFromRealm, MaterializeError, describeThrown } from './realm.ts'
|
|
|
|
/** The per-run knobs the engine resolves from its Config. */
|
|
export interface ExecutionLimits {
|
|
/** The `ctx.subagents` provider name to start children on. */
|
|
provider: string
|
|
/** Concurrent `agent()` ceiling (already auto-resolved; ≥ 1). */
|
|
maxConcurrentAgents: number
|
|
/** Total `agent()` calls per run (the runaway-loop backstop). */
|
|
maxTotalAgents: number
|
|
/** Items accepted by one `parallel()`/`pipeline()` call. */
|
|
maxItemsPerCall: number
|
|
/** vm timeout for the script's initial synchronous slice. */
|
|
syncTimeoutMs: number
|
|
}
|
|
|
|
/** The engine-side observers the execution reports progress through. */
|
|
export interface ExecutionObserver {
|
|
phase(title: string): void
|
|
log(message: string): void
|
|
agentStart(info: WorkflowAgentInfo): void
|
|
agentEnd(info: WorkflowAgentEndInfo): void
|
|
}
|
|
|
|
/** The `agent()` options the script may pass; everything else rejects loud. */
|
|
const SUPPORTED_AGENT_OPTIONS = new Set(['label', 'phase', 'schema', 'model'])
|
|
/** Deferred Claude Code options we name explicitly in the rejection message. */
|
|
const DEFERRED_AGENT_OPTIONS = new Set(['effort', 'isolation', 'agentType'])
|
|
|
|
/** The in-context prelude that bans the nondeterminism sources (kept even though resume is deferred, so scripts stay resume-compatible). */
|
|
const DETERMINISM_PRELUDE = `
|
|
{
|
|
const banned = (name) => () => {
|
|
throw new Error(name + ' is not available in workflow scripts (runs must stay deterministic for future resume support; pass timestamps in via args)')
|
|
}
|
|
Math.random = banned('Math.random()')
|
|
Date.now = banned('Date.now()')
|
|
const RealDate = Date
|
|
globalThis.Date = new Proxy(RealDate, {
|
|
construct(target, args, newTarget) {
|
|
if (args.length === 0) banned('argless new Date()')()
|
|
return Reflect.construct(target, args, newTarget)
|
|
},
|
|
apply: banned('Date()'),
|
|
})
|
|
}
|
|
`
|
|
|
|
/** Flatten a child's final output blocks to text (the non-schema `agent()` result). */
|
|
function outputText(blocks: ContentBlock[]): string {
|
|
return blocks
|
|
.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
|
|
.map(block => block.text)
|
|
.join('')
|
|
}
|
|
|
|
/** A short display label derived from the prompt when the script passes none. */
|
|
function defaultLabel(prompt: string): string {
|
|
const newline = prompt.indexOf('\n')
|
|
const line = newline === -1 ? prompt : prompt.slice(0, newline)
|
|
return line.length <= 48 ? line : `${line.slice(0, 47)}…`
|
|
}
|
|
|
|
/**
|
|
* One live script execution. Constructed per run by the engine; `drive()` is
|
|
* called exactly once and NEVER rejects — every failure becomes a
|
|
* {@link WorkflowResult} with a non-`completed` stop reason.
|
|
*/
|
|
export class WorkflowExecution {
|
|
/** 1-based count of `agent()` calls started (the `agentsStarted` result field). */
|
|
private started = 0
|
|
private activeSlots = 0
|
|
private readonly slotWaiters: { resolve(): void; reject(error: unknown): void }[] = []
|
|
private cancelReason: string | undefined
|
|
private cancelError: WorkflowError | undefined
|
|
private readonly controller = new AbortController()
|
|
private currentPhase: string | undefined
|
|
private readonly context: vm.Context
|
|
private readonly realmJsonParse: (text: string) => unknown
|
|
private readonly realmArrayFrom: (items: unknown[]) => unknown[]
|
|
private readonly compiled: vm.Script
|
|
/** Every live `agent()` call promise — awaited or stray — for {@link quiesce}. */
|
|
private readonly inFlightAgents = new Set<Promise<unknown>>()
|
|
|
|
constructor(
|
|
private readonly ctx: Context,
|
|
meta: WorkflowMeta,
|
|
body: string,
|
|
private readonly parent: Agent,
|
|
args: unknown,
|
|
signal: AbortSignal | undefined,
|
|
private readonly limits: ExecutionLimits,
|
|
private readonly observer: ExecutionObserver,
|
|
) {
|
|
// Compile FIRST: a body syntax error must throw out of the constructor
|
|
// (the engine maps it to SCRIPT_PARSE) before any realm state exists.
|
|
// lineOffset compensates for the wrapper line, so stack traces carry the
|
|
// script's own line numbers (the meta statement was blanked, not removed).
|
|
try {
|
|
this.compiled = new vm.Script(`(async () => {\n${body}\n})()`, {
|
|
filename: `workflow:${meta.name}`,
|
|
lineOffset: -1,
|
|
})
|
|
} catch (error: unknown) {
|
|
throw new WorkflowError(`workflow script does not parse: ${String(error)}`, 'SCRIPT_PARSE', { cause: error })
|
|
}
|
|
|
|
this.context = vm.createContext({}, { name: `workflow:${meta.name}` })
|
|
vm.runInContext(DETERMINISM_PRELUDE, this.context)
|
|
// The realm's own JSON.parse — the host→realm rebuild channel.
|
|
const realmJson = vm.runInContext('JSON', this.context) as { parse(text: string): unknown }
|
|
this.realmJsonParse = (text: string) => realmJson.parse(text)
|
|
// The realm's own Array.from, bound NOW so a script reassigning its
|
|
// globals later cannot swap it: combinator results must be realm arrays.
|
|
this.realmArrayFrom = vm.runInContext('Array.from.bind(Array)', this.context) as (items: unknown[]) => unknown[]
|
|
|
|
const globals: Record<string, unknown> = {
|
|
agent: (prompt: unknown, opts?: unknown) => this.contain(this.track(this.agent(prompt, opts))),
|
|
parallel: (thunks: unknown) => this.contain(this.parallel(thunks)),
|
|
pipeline: (items: unknown, ...stages: unknown[]) => this.contain(this.pipeline(items, stages)),
|
|
phase: (title: unknown) => { this.phase(title) },
|
|
log: (message: unknown) => { this.log(message) },
|
|
args: this.toRealm(args),
|
|
}
|
|
for (const [key, value] of Object.entries(globals)) {
|
|
// Data properties on the contextified global; frozen shape not required —
|
|
// a script overwriting its own hooks only sabotages itself.
|
|
;(this.context as Record<string, unknown>)[key] = typeof value === 'function' ? Object.freeze(value) : value
|
|
}
|
|
|
|
if (signal?.aborted) {
|
|
this.cancel('workflow start signal already aborted')
|
|
} else {
|
|
signal?.addEventListener('abort', () => { this.cancel('workflow signal aborted') }, { once: true })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether the run has been cancelled. A METHOD, not an inline property
|
|
* read: `cancel()` mutates `cancelReason` concurrently (a signal listener,
|
|
* a raced dispose), and an inline read after an `await` gets narrowed by
|
|
* control flow into an always-false comparison.
|
|
*/
|
|
private isCancelled(): boolean {
|
|
return this.cancelReason !== undefined
|
|
}
|
|
|
|
/**
|
|
* Cancel the run: children abort (the shared signal), waiting `agent()`
|
|
* slots reject, and every future hook call throws `CANCELLED` — the script
|
|
* dies at its next await. Idempotent; the first reason wins.
|
|
*/
|
|
cancel(reason?: string): void {
|
|
if (this.cancelReason !== undefined) return
|
|
this.cancelReason = reason ?? 'workflow cancelled'
|
|
this.cancelError = new WorkflowError(`workflow run cancelled: ${this.cancelReason}`, 'CANCELLED')
|
|
this.controller.abort(this.cancelReason)
|
|
for (const waiter of this.slotWaiters.splice(0)) waiter.reject(this.cancelledError())
|
|
}
|
|
|
|
/**
|
|
* Run the script to settlement. Resolves — never rejects — with the run's
|
|
* {@link WorkflowResult}: the materialized return value on `completed`, the
|
|
* failure message on `error`, and `cancelled` when the script died of
|
|
* cancellation. After settlement, any stray children a script fired without
|
|
* awaiting are aborted (their `agent()` wrappers dispose them).
|
|
*/
|
|
async drive(): Promise<WorkflowResult> {
|
|
try {
|
|
// Cancelled before the body ever ran (an already-aborted start signal):
|
|
// the script must not execute at all, let alone report `completed`.
|
|
if (this.isCancelled()) throw this.cancelledError()
|
|
const scriptPromise = this.compiled.runInContext(this.context, { timeout: this.limits.syncTimeoutMs }) as Promise<unknown>
|
|
const raw: unknown = await this.contain(Promise.resolve(scriptPromise))
|
|
// Cancelled while the body ran: a script that settled without touching
|
|
// another hook (or without any) must still report `cancelled` — the
|
|
// holder asked for cancellation and `completed` would be a lie.
|
|
if (this.isCancelled()) throw this.cancelledError()
|
|
const value = raw === undefined ? null : this.materializeResult(raw)
|
|
return { value, stopReason: 'completed', agentsStarted: this.started }
|
|
} catch (error: unknown) {
|
|
if (error instanceof WorkflowError && error.code === 'CANCELLED') {
|
|
return { value: null, stopReason: 'cancelled', error: error.message, agentsStarted: this.started }
|
|
}
|
|
// describeThrown is total and trap-free: a hostile thrown value (a
|
|
// throwing accessor, a proxy) cannot make this catch throw — drive()
|
|
// resolving is the `result` never-rejects seam contract.
|
|
return { value: null, stopReason: 'error', error: describeThrown(error), agentsStarted: this.started }
|
|
} finally {
|
|
// Reap strays: a script that fired agent() calls without awaiting them
|
|
// leaves live children behind after settlement — abort them all. (The
|
|
// per-call wrappers dispose each child; the contain() consumer keeps
|
|
// their rejections from going unhandled.)
|
|
if (this.cancelReason === undefined) this.cancel('workflow settled')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Attach a no-op rejection consumer WITHOUT changing what the caller
|
|
* receives: if the script drops the promise (no await), cancellation cannot
|
|
* become an unhandled rejection (the app boot layer exits the process on
|
|
* those); if the script does await it, it still observes the rejection.
|
|
*/
|
|
private contain<T>(promise: Promise<T>): Promise<T> {
|
|
promise.catch(() => { /* consumed: see method contract — a dropped hook promise must not surface an unhandled rejection */ })
|
|
return promise
|
|
}
|
|
|
|
/**
|
|
* Register one `agent()` call promise for {@link quiesce} tracking; the
|
|
* entry drops when the call fully settles (which is AFTER its child's
|
|
* `dispose()` — the call wrapper disposes in its `finally`).
|
|
*/
|
|
private track<T>(promise: Promise<T>): Promise<T> {
|
|
this.inFlightAgents.add(promise)
|
|
const drop = (): void => { this.inFlightAgents.delete(promise) }
|
|
promise.then(drop, drop)
|
|
return promise
|
|
}
|
|
|
|
/**
|
|
* Settles once every `agent()` call — awaited or stray — has fully settled,
|
|
* INCLUDING each child's `dispose()`. The reap in {@link drive}'s finally
|
|
* aborts strays; this is the wait for those aborts to reach quiescence, so
|
|
* the engine's `dispose()` cannot return while a child is still winding
|
|
* down. Never rejects (the tracked promises' rejections are contained).
|
|
*/
|
|
async quiesce(): Promise<void> {
|
|
while (this.inFlightAgents.size > 0) {
|
|
await Promise.allSettled([...this.inFlightAgents])
|
|
}
|
|
}
|
|
|
|
private cancelledError(): WorkflowError {
|
|
// cancel() arms cancelError before any caller can observe isCancelled()
|
|
// === true; the fallback guards the type, not a reachable path.
|
|
/* v8 ignore next */
|
|
return this.cancelError ?? new WorkflowError('workflow run cancelled', 'CANCELLED')
|
|
}
|
|
|
|
/** Rebuild a host value inside the script realm (via the realm's own JSON.parse). */
|
|
private toRealm(value: unknown): unknown {
|
|
if (value === undefined) return undefined
|
|
if (value === null) return null
|
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value
|
|
return this.realmJsonParse(JSON.stringify(value))
|
|
}
|
|
|
|
/** Materialize the script's return value; violations become RESULT_UNSERIALIZABLE. */
|
|
private materializeResult(raw: unknown): unknown {
|
|
try {
|
|
return materializeFromRealm(raw, 'workflow result')
|
|
} catch (error: unknown) {
|
|
/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */
|
|
if (!(error instanceof MaterializeError)) throw error
|
|
throw new WorkflowError(
|
|
`the workflow's return value is not plain JSON data — ${error.message}. Return only JSON-serializable objects/arrays/scalars.`,
|
|
'RESULT_UNSERIALIZABLE',
|
|
{ cause: error },
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Acquire one concurrency slot (FIFO). Cancellation rejects QUEUED waiters
|
|
* (see {@link cancel}); the callers guard their own entry and post-acquire
|
|
* windows, so no cancelled-precheck is duplicated here.
|
|
*/
|
|
private acquireSlot(): Promise<void> {
|
|
if (this.activeSlots < this.limits.maxConcurrentAgents) {
|
|
this.activeSlots += 1
|
|
return Promise.resolve()
|
|
}
|
|
return new Promise<void>((resolve, reject) => {
|
|
this.slotWaiters.push({
|
|
resolve: () => {
|
|
this.activeSlots += 1
|
|
resolve()
|
|
},
|
|
reject,
|
|
})
|
|
})
|
|
}
|
|
|
|
private releaseSlot(): void {
|
|
this.activeSlots -= 1
|
|
const next = this.slotWaiters.shift()
|
|
if (next) next.resolve()
|
|
}
|
|
|
|
/** The `agent(prompt, opts)` hook. */
|
|
private async agent(rawPrompt: unknown, rawOpts: unknown): Promise<unknown> {
|
|
if (this.isCancelled()) throw this.cancelledError()
|
|
if (typeof rawPrompt !== 'string' || rawPrompt.length === 0) {
|
|
throw new WorkflowError('agent() requires a non-empty prompt string', 'INVALID_ARGUMENT')
|
|
}
|
|
const opts = this.readAgentOptions(rawOpts)
|
|
if (this.started >= this.limits.maxTotalAgents) {
|
|
throw new WorkflowError(
|
|
`this run reached its total agent cap (${this.limits.maxTotalAgents}) — a runaway-loop backstop; raise maxTotalAgents in the engine config if the scale is intentional`,
|
|
'AGENT_CAP',
|
|
)
|
|
}
|
|
this.started += 1
|
|
const seq = this.started
|
|
const label = opts.label ?? defaultLabel(rawPrompt)
|
|
const phase = opts.phase ?? this.currentPhase
|
|
|
|
await this.acquireSlot()
|
|
try {
|
|
// No cancelled re-check here: a cancel cannot interleave between a
|
|
// waiter's resolution and this continuation (single-threaded, no await
|
|
// between them), and a child started moments after a cancel still dies
|
|
// via the shared abort signal — the CANCELLED mapping below covers it.
|
|
let run
|
|
try {
|
|
run = this.ctx.subagents.start(this.limits.provider, {
|
|
prompt: [{ type: 'text', text: rawPrompt }],
|
|
parent: this.parent,
|
|
signal: this.controller.signal,
|
|
...opts.schema !== undefined ? { outputSchema: opts.schema } : {},
|
|
...opts.model !== undefined ? { agentOptions: { model: opts.model } } : {},
|
|
})
|
|
} catch (error: unknown) {
|
|
throw new WorkflowError(`agent() could not start a child on provider "${this.limits.provider}": ${String(error)}`, 'AGENT_START', { cause: error })
|
|
}
|
|
const info: WorkflowAgentInfo = { seq, label, ...phase !== undefined ? { phase } : {}, childId: run.id }
|
|
this.observer.agentStart(info)
|
|
try {
|
|
const result = await run.result
|
|
if (result.stopReason === 'completed') {
|
|
if (opts.schema !== undefined) {
|
|
// The provider honored outputSchema (capability-gated at start), so
|
|
// a completed run without a structured value is a child failure.
|
|
if (result.structured === undefined) {
|
|
this.observer.agentEnd({ ...info, outcome: 'failed' })
|
|
return null
|
|
}
|
|
this.observer.agentEnd({ ...info, outcome: 'completed' })
|
|
return this.toRealm(result.structured)
|
|
}
|
|
this.observer.agentEnd({ ...info, outcome: 'completed' })
|
|
return outputText(result.output)
|
|
}
|
|
// A cancelled RUN kills the script; a child that failed for its own
|
|
// reasons resolves null (scripts .filter(Boolean) per the CC contract).
|
|
if (this.isCancelled()) {
|
|
this.observer.agentEnd({ ...info, outcome: 'cancelled' })
|
|
throw this.cancelledError()
|
|
}
|
|
this.observer.agentEnd({ ...info, outcome: 'failed' })
|
|
return null
|
|
} finally {
|
|
await run.dispose()
|
|
}
|
|
} finally {
|
|
this.releaseSlot()
|
|
}
|
|
}
|
|
|
|
/** Materialize + validate the `agent()` options bag from the realm. */
|
|
private readAgentOptions(rawOpts: unknown): { label?: string; phase?: string; model?: string; schema?: StructuredOutputSchema } {
|
|
if (rawOpts === undefined) return {}
|
|
let opts: unknown
|
|
try {
|
|
opts = materializeFromRealm(rawOpts, 'agent() options')
|
|
} catch (error: unknown) {
|
|
/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */
|
|
if (!(error instanceof MaterializeError)) throw error
|
|
throw new WorkflowError(`agent() options must be plain JSON data — ${error.message}`, 'INVALID_ARGUMENT', { cause: error })
|
|
}
|
|
if (typeof opts !== 'object' || opts === null || Array.isArray(opts)) {
|
|
throw new WorkflowError('agent() options must be an object', 'INVALID_ARGUMENT')
|
|
}
|
|
const record = opts as Record<string, unknown>
|
|
for (const key of Object.keys(record)) {
|
|
if (SUPPORTED_AGENT_OPTIONS.has(key)) continue
|
|
if (DEFERRED_AGENT_OPTIONS.has(key)) {
|
|
throw new WorkflowError(`agent() option "${key}" is deferred and not supported by this engine (supported: label, phase, schema, model)`, 'UNSUPPORTED_OPTION')
|
|
}
|
|
throw new WorkflowError(`agent() option "${key}" is not recognized (supported: label, phase, schema, model)`, 'UNSUPPORTED_OPTION')
|
|
}
|
|
for (const key of ['label', 'phase', 'model'] as const) {
|
|
if (record[key] !== undefined && typeof record[key] !== 'string') {
|
|
throw new WorkflowError(`agent() option "${key}" must be a string`, 'INVALID_ARGUMENT')
|
|
}
|
|
}
|
|
let schema: StructuredOutputSchema | undefined
|
|
if (record.schema !== undefined) {
|
|
try {
|
|
assertSupportedOutputSchema(record.schema)
|
|
schema = record.schema
|
|
} catch (error: unknown) {
|
|
/* v8 ignore next -- defensive rethrow arm: assertSupportedOutputSchema only throws OutputSchemaError */
|
|
if (!(error instanceof OutputSchemaError)) throw error
|
|
throw new WorkflowError(`agent() schema is outside the supported subset — ${error.message}`, 'UNSUPPORTED_SCHEMA', { cause: error })
|
|
}
|
|
}
|
|
return {
|
|
...record.label !== undefined ? { label: record.label as string } : {},
|
|
...record.phase !== undefined ? { phase: record.phase as string } : {},
|
|
...record.model !== undefined ? { model: record.model as string } : {},
|
|
...schema !== undefined ? { schema } : {},
|
|
}
|
|
}
|
|
|
|
/** The `parallel(thunks)` hook: each thunk caught → `null`; fatal errors propagate. */
|
|
private async parallel(rawThunks: unknown): Promise<unknown[]> {
|
|
if (!Array.isArray(rawThunks)) {
|
|
throw new WorkflowError('parallel() requires an array of zero-argument functions', 'INVALID_ARGUMENT')
|
|
}
|
|
this.assertItemCap(rawThunks.length, 'parallel()')
|
|
const thunks = rawThunks.map((thunk, index) => {
|
|
if (typeof thunk !== 'function') {
|
|
throw new WorkflowError(`parallel() item ${index} is not a function`, 'INVALID_ARGUMENT')
|
|
}
|
|
return thunk as () => unknown
|
|
})
|
|
const settled = await Promise.all(thunks.map(async (thunk) => {
|
|
try {
|
|
return await thunk()
|
|
} catch (error: unknown) {
|
|
if (isFatalWorkflowError(error)) throw error
|
|
return null
|
|
}
|
|
}))
|
|
// The container must be a REALM array (module doc); the elements are
|
|
// realm values already.
|
|
return this.realmArrayFrom(settled)
|
|
}
|
|
|
|
/** The `pipeline(items, ...stages)` hook: per-item stage chains, NO cross-stage barrier. */
|
|
private async pipeline(rawItems: unknown, rawStages: unknown[]): Promise<unknown[]> {
|
|
if (!Array.isArray(rawItems)) {
|
|
throw new WorkflowError('pipeline() requires an items array', 'INVALID_ARGUMENT')
|
|
}
|
|
this.assertItemCap(rawItems.length, 'pipeline()')
|
|
if (rawStages.length === 0) {
|
|
throw new WorkflowError('pipeline() requires at least one stage function', 'INVALID_ARGUMENT')
|
|
}
|
|
const stages = rawStages.map((stage, index) => {
|
|
if (typeof stage !== 'function') {
|
|
throw new WorkflowError(`pipeline() stage ${index} is not a function`, 'INVALID_ARGUMENT')
|
|
}
|
|
return stage as (previous: unknown, item: unknown, index: number) => unknown
|
|
})
|
|
const settled = await Promise.all(rawItems.map(async (item: unknown, index) => {
|
|
let value: unknown = item
|
|
try {
|
|
for (const stage of stages) {
|
|
value = await stage(value, item, index)
|
|
}
|
|
return value
|
|
} catch (error: unknown) {
|
|
// An ordinary stage throw drops the ITEM to null and skips its
|
|
// remaining stages; a fatal error kills the whole script.
|
|
if (isFatalWorkflowError(error)) throw error
|
|
return null
|
|
}
|
|
}))
|
|
// The container must be a REALM array (module doc); the elements are
|
|
// realm values already.
|
|
return this.realmArrayFrom(settled)
|
|
}
|
|
|
|
private assertItemCap(length: number, hook: string): void {
|
|
if (length > this.limits.maxItemsPerCall) {
|
|
throw new WorkflowError(
|
|
`${hook} received ${length} items — over the per-call cap (${this.limits.maxItemsPerCall}); split the work or raise maxItemsPerCall in the engine config`,
|
|
'ITEM_CAP',
|
|
)
|
|
}
|
|
}
|
|
|
|
/** The `phase(title)` hook: sets the current label for subsequent `agent()` calls and notifies observers. */
|
|
private phase(title: unknown): void {
|
|
if (typeof title !== 'string' || title.length === 0) {
|
|
throw new WorkflowError('phase() requires a non-empty title string', 'INVALID_ARGUMENT')
|
|
}
|
|
this.currentPhase = title
|
|
this.observer.phase(title)
|
|
}
|
|
|
|
/** The `log(message)` hook: narration to observers. */
|
|
private log(message: unknown): void {
|
|
if (typeof message !== 'string') {
|
|
throw new WorkflowError('log() requires a message string', 'INVALID_ARGUMENT')
|
|
}
|
|
this.observer.log(message)
|
|
}
|
|
}
|