2026-07-05 13:29:35 +08:00
/**
2026-07-13 23:27:00 +08:00
* Per-run worker-side vm hooks, child RPC, concurrency/caps, cancellation, and result shaping; it
* never touches Cordis. Script values leaving the realm are materialized as plain JSON before
* messaging. Values entering the trusted model-written realm are passed directly; `args` alone is
* cloned so script mutation cannot alter initialization data. See `./realm.ts` for the trust model.
2026-07-05 13:29:35 +08:00
*
2026-07-13 23:27:00 +08:00
* Fatal workflow errors—bad hook arguments, unsupported schemas/options, caps, start failures, and
* cancellation—propagate through combinators. Only child failures and ordinary stage errors become
* per-item nulls. Every returned promise has a rejection consumer so dropped script promises cannot
* kill the worker. A cancelled script that never settles emits nothing; the host force-settles the
* run within grace and terminates the thread.
2026-07-09 19:06:55 +08:00
* @module @deepseek-ai/dsh-workflow-workerthread/runtime
2026-07-05 13:29:35 +08:00
*/
import * as vm from 'node:vm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
2026-07-14 01:59:21 +08:00
import { SessionId } from '@deepseek-ai/dsh-session'
2026-07-21 01:11:55 +08:00
import { assertObjectJsonSchema , JsonSchemaError } from '@deepseek-ai/dsh-tools'
import type { ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
2026-07-06 00:48:49 +08:00
import { isFatalWorkflowError , WorkflowError } from '@deepseek-ai/dsh-workflow'
2026-07-05 13:29:35 +08:00
import type {
WorkflowAgentEndInfo ,
WorkflowAgentInfo ,
WorkflowMeta ,
WorkflowResult ,
} from '@deepseek-ai/dsh-workflow'
2026-07-06 00:48:49 +08:00
import { materializeFromRealm , MaterializeError , renderThrown } from './realm.ts'
2026-07-09 18:39:31 +08:00
import type { ChildHandle , ChildPort , WorkerLimits } from './types.ts'
2026-07-05 13:29:35 +08:00
2026-07-09 18:39:31 +08:00
/** The observers the execution reports progress through (the session posts them to the host). */
2026-07-05 13:29:35 +08:00
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. */
2026-07-14 21:57:52 +08:00
const SUPPORTED_AGENT_OPTIONS = new Set ( [ 'label' , 'phase' , 'schema' , 'provider' , 'model' ] )
2026-07-05 13:29:35 +08:00
/** Deferred Claude Code options we name explicitly in the rejection message. */
const DEFERRED_AGENT_OPTIONS = new Set ( [ 'effort' , 'isolation' , 'agentType' ] )
/** 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 ) } … `
}
/**
2026-07-09 18:39:31 +08:00
* One live script execution inside the worker. Constructed per run by the
* session; `drive()` is called exactly once and NEVER rejects — every failure
2026-07-12 22:41:59 +08:00
* becomes a {@link WorkflowResult} with a non-`completed` stop reason. The
* host owns cancellation and cleanup of any dropped child work.
2026-07-05 13:29:35 +08:00
*/
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 currentPhase : string | undefined
private readonly context : vm.Context
private readonly compiled : vm.Script
constructor (
meta : WorkflowMeta ,
body : string ,
args : unknown ,
2026-07-09 18:39:31 +08:00
private readonly limits : WorkerLimits ,
2026-07-05 13:29:35 +08:00
private readonly observer : ExecutionObserver ,
2026-07-09 18:39:31 +08:00
private readonly children : ChildPort ,
2026-07-05 13:29:35 +08:00
) {
// Compile FIRST: a body syntax error must throw out of the constructor
2026-07-09 18:39:31 +08:00
// before any realm state exists. The host pre-parses the identical
// wrapper, so under one Node version this throw is unreachable in
// production — the session still maps it to an error result defensively.
2026-07-05 13:29:35 +08:00
// lineOffset compensates for the wrapper line, so stack traces carry the
2026-07-09 20:09:10 +08:00
// script's own line numbers.
2026-07-05 13:29:35 +08:00
try {
2026-07-06 00:48:49 +08:00
this . compiled = new vm . Script ( ` (async () => { \ n ${ body } \ n})() ` , {
filename : ` workflow: ${ meta . name } ` ,
lineOffset : - 1 ,
} )
2026-07-05 13:29:35 +08:00
} 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 } ` } )
const globals : Record < string , unknown > = {
2026-07-09 18:39:31 +08:00
agent : ( prompt : unknown , opts? : unknown ) = > this . contain ( this . agent ( prompt , opts ) ) ,
2026-07-06 00:48:49 +08:00
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 ) } ,
2026-07-12 22:41:59 +08:00
// workerData already performed the real cross-thread structured clone.
args ,
2026-07-05 13:29:35 +08:00
}
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
}
}
/**
* Whether the run has been cancelled. A METHOD, not an inline property
2026-07-09 18:39:31 +08:00
* read: `cancel()` mutates `cancelReason` concurrently (the session's
* message handler), and an inline read after an `await` gets narrowed by
2026-07-05 13:29:35 +08:00
* control flow into an always-false comparison.
*/
private isCancelled ( ) : boolean {
return this . cancelReason !== undefined
}
2026-07-06 21:02:41 +08:00
/**
* Shared hook entry guard: after {@link cancel}, EVERY hook throws
* `CANCELLED` at its next call — cancellation is the next HOOK boundary,
* not just the next `agent()`, so a script that caught one cancelled
* rejection cannot keep emitting progress through `phase`/`log` or enter a
* combinator.
*/
private throwIfCancelled ( ) : void {
if ( this . isCancelled ( ) ) throw this . cancelledError ( )
}
2026-07-05 13:29:35 +08:00
/**
2026-07-12 22:41:59 +08:00
* Cancel the run: waiting `agent()` slots reject and every future hook call
2026-07-09 18:39:31 +08:00
* throws `CANCELLED` — the script dies at its next await. A script that
* never settles anyway (parked on a promise no hook owns) is the HOST's
* problem: its grace timer force-settles the run and terminates the
* worker. Idempotent; the first reason wins.
2026-07-12 22:41:59 +08:00
* @param reason - human-readable cause carried on the CANCELLED error. The
* host independently aborts the required signal shared by every child.
2026-07-05 13:29:35 +08:00
*/
2026-07-09 18:39:31 +08:00
cancel ( reason : string ) : void {
2026-07-05 13:29:35 +08:00
if ( this . cancelReason !== undefined ) return
2026-07-09 18:39:31 +08:00
this . cancelReason = reason
2026-07-05 13:29:35 +08:00
this . cancelError = new WorkflowError ( ` workflow run cancelled: ${ this . cancelReason } ` , 'CANCELLED' )
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
2026-07-12 22:41:59 +08:00
* cancellation. This method only chooses the result; the session publishes
* it and the host owns terminal child cancellation.
2026-07-07 22:39:05 +08:00
* @returns the settled outcome — this promise NEVER rejects (the seam's
* `result`-never-rejects contract); every failure maps to a variant.
2026-07-05 13:29:35 +08:00
*/
async drive ( ) : Promise < WorkflowResult > {
try {
2026-07-09 18:39:31 +08:00
// Cancelled before the body ever ran (an already-aborted start signal,
// relayed by the host before its `go`): the script must not execute at
// all, let alone report `completed`.
2026-07-05 19:04:38 +08:00
if ( this . isCancelled ( ) ) throw this . cancelledError ( )
2026-07-05 13:29:35 +08:00
const scriptPromise = this . compiled . runInContext ( this . context , { timeout : this.limits.syncTimeoutMs } ) as Promise < unknown >
2026-07-09 18:39:31 +08:00
const raw : unknown = await this . contain ( Promise . resolve ( scriptPromise ) )
2026-07-05 19:04:38 +08:00
// 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 ( )
2026-07-05 13:29:35 +08:00
const value = raw === undefined ? null : this . materializeResult ( raw )
return { value , stopReason : 'completed' , agentsStarted : this.started }
} catch ( error : unknown ) {
2026-07-05 21:24:30 +08:00
// Any failure after cancel() reports `cancelled` with the canonical
2026-07-06 00:48:49 +08:00
// reason — the reject path mirrors the resolve path's post-settle check.
2026-07-05 21:24:30 +08:00
if ( this . isCancelled ( ) ) {
return { value : null , stopReason : 'cancelled' , error : this.cancelledError ( ) . message , agentsStarted : this.started }
2026-07-05 13:29:35 +08:00
}
2026-07-09 18:39:31 +08:00
// renderThrown is total (thrown values of any realm), so this arm
// cannot throw — drive() resolving is the `result` never-rejects seam
// contract.
2026-07-06 00:48:49 +08:00
return { value : null , stopReason : 'error' , error : renderThrown ( error ) , agentsStarted : this.started }
2026-07-05 13:29:35 +08:00
}
}
/**
* Attach a no-op rejection consumer WITHOUT changing what the caller
* receives: if the script drops the promise (no await), cancellation cannot
2026-07-09 18:39:31 +08:00
* become an unhandled rejection (which would kill the worker thread); if
* the script does await it, it still observes the rejection.
2026-07-05 13:29:35 +08:00
*/
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
}
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' )
}
/** 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 > {
2026-07-06 21:02:41 +08:00
this . throwIfCancelled ( )
2026-07-05 13:29:35 +08:00
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 (
2026-07-20 12:56:09 +08:00
` this run reached its total agent cap ( ${ this . limits . maxTotalAgents } ) — a runaway-loop backstop; raise the applicable maxTotalAgents limit if the scale is intentional ` ,
2026-07-05 13:29:35 +08:00
'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 {
2026-07-06 01:39:01 +08:00
// Re-check after the acquire: the await yields at least one microtask
// tick even when a slot is free, and a queued waiter resumes a tick
// after its release — a cancel() landing in either window must not
2026-07-09 18:39:31 +08:00
// reach the host (which would refuse anyway, but the refusal reads as
// a start failure rather than the cancellation it is).
2026-07-06 21:02:41 +08:00
this . throwIfCancelled ( )
2026-07-09 18:39:31 +08:00
let run : ChildHandle
2026-07-05 13:29:35 +08:00
try {
2026-07-09 18:39:31 +08:00
run = await this . children . startAgent ( {
prompt : rawPrompt ,
. . . opts . schema !== undefined ? { schema : opts.schema } : { } ,
2026-07-14 21:57:52 +08:00
. . . opts . provider !== undefined ? { provider : opts.provider } : { } ,
2026-07-09 18:39:31 +08:00
. . . opts . model !== undefined ? { model : opts.model } : { } ,
2026-07-05 13:29:35 +08:00
} )
} catch ( error : unknown ) {
2026-07-09 18:39:31 +08:00
// The host refuses starts once the run is cancelled — a refusal that
// races our own cancel state must read as the cancellation it is,
// not as a broken seam.
if ( this . isCancelled ( ) ) throw this . cancelledError ( )
throw new WorkflowError ( ` agent() could not start a child: ${ renderThrown ( error ) } ` , 'AGENT_START' , { cause : error } )
}
// The start round-trip yields to the event loop, so a cancel CAN land
// between the host starting the child and this continuation running —
// wind the fresh child down instead of leaving it live behind a dead
// script.
if ( this . isCancelled ( ) ) {
await run . dispose ( )
throw this . cancelledError ( )
2026-07-05 13:29:35 +08:00
}
2026-07-14 01:59:21 +08:00
const info : WorkflowAgentInfo = { seq , label , . . . phase !== undefined ? { phase } : { } , childId : SessionId ( run . id ) }
2026-07-05 13:29:35 +08:00
this . observer . agentStart ( info )
try {
2026-07-06 21:02:41 +08:00
let result
try {
result = await run . result
} catch ( error : unknown ) {
2026-07-09 18:39:31 +08:00
// A rejected child result is an INFRASTRUCTURE fault relayed by the
// host — distinct from a child that failed and resolved. Pair the
2026-07-06 21:02:41 +08:00
// lifecycle before propagating, and propagate FATAL: an ordinary
// throw would dissolve to a per-item null inside the combinators,
// and a broken provider must not read as a failed child.
if ( this . isCancelled ( ) ) {
this . observer . agentEnd ( { . . . info , outcome : 'cancelled' } )
throw this . cancelledError ( )
}
this . observer . agentEnd ( { . . . info , outcome : 'failed' } )
throw new WorkflowError ( ` child agent run failed: ${ renderThrown ( error ) } ` , 'AGENT_RESULT' , { cause : error } )
}
2026-07-05 13:29:35 +08:00
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' } )
2026-07-06 00:48:49 +08:00
return result . structured
2026-07-05 13:29:35 +08:00
}
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. */
2026-07-14 21:57:52 +08:00
private readAgentOptions ( rawOpts : unknown ) : {
label? : string
phase? : string
provider? : string
model? : string
2026-07-21 01:11:55 +08:00
schema? : ObjectJsonSchema
2026-07-14 21:57:52 +08:00
} {
2026-07-05 13:29:35 +08:00
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 ) ) {
2026-07-15 10:07:21 +08:00
throw new WorkflowError ( ` agent() option " ${ key } " is deferred and not supported by this engine (supported: label, phase, schema, provider, model) ` , 'UNSUPPORTED_OPTION' )
2026-07-05 13:29:35 +08:00
}
2026-07-14 21:57:52 +08:00
throw new WorkflowError ( ` agent() option " ${ key } " is not recognized (supported: label, phase, schema, provider, model) ` , 'UNSUPPORTED_OPTION' )
2026-07-05 13:29:35 +08:00
}
2026-07-14 21:57:52 +08:00
for ( const key of [ 'label' , 'phase' , 'provider' , 'model' ] as const ) {
2026-07-05 13:29:35 +08:00
if ( record [ key ] !== undefined && typeof record [ key ] !== 'string' ) {
throw new WorkflowError ( ` agent() option " ${ key } " must be a string ` , 'INVALID_ARGUMENT' )
}
}
2026-07-21 01:11:55 +08:00
let schema : ObjectJsonSchema | undefined
2026-07-05 13:29:35 +08:00
if ( record . schema !== undefined ) {
try {
2026-07-21 01:11:55 +08:00
assertObjectJsonSchema ( record . schema )
2026-07-05 13:29:35 +08:00
schema = record . schema
} catch ( error : unknown ) {
2026-07-21 01:11:55 +08:00
/* v8 ignore next -- defensive rethrow arm: assertObjectJsonSchema only throws JsonSchemaError */
if ( ! ( error instanceof JsonSchemaError ) ) throw error
2026-07-05 13:29:35 +08:00
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 } : { } ,
2026-07-14 21:57:52 +08:00
. . . record . provider !== undefined ? { provider : record.provider as string } : { } ,
2026-07-05 13:29:35 +08:00
. . . 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 [ ] > {
2026-07-06 21:02:41 +08:00
this . throwIfCancelled ( )
2026-07-05 13:29:35 +08:00
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
} )
2026-07-06 00:48:49 +08:00
return Promise . all ( thunks . map ( async ( thunk ) = > {
2026-07-05 13:29:35 +08:00
try {
return await thunk ( )
} catch ( error : unknown ) {
2026-07-09 18:39:31 +08:00
// Hook failures are WorkflowErrors built OUTSIDE the script's realm;
// fatality is recognized by `instanceof` against this realm's class —
// a script-built object can never pass it, so fatality cannot be
// forged (nor accidentally dissolved).
2026-07-06 00:48:49 +08:00
if ( isFatalWorkflowError ( error ) ) throw error
2026-07-05 13:29:35 +08:00
return null
}
} ) )
}
/** The `pipeline(items, ...stages)` hook: per-item stage chains, NO cross-stage barrier. */
private async pipeline ( rawItems : unknown , rawStages : unknown [ ] ) : Promise < unknown [ ] > {
2026-07-06 21:02:41 +08:00
this . throwIfCancelled ( )
2026-07-05 13:29:35 +08:00
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
} )
2026-07-06 00:48:49 +08:00
return Promise . all ( rawItems . map ( async ( item : unknown , index ) = > {
2026-07-05 13:29:35 +08:00
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
2026-07-09 18:39:31 +08:00
// remaining stages; a fatal WorkflowError (see parallel()) kills the
// whole script.
2026-07-06 00:48:49 +08:00
if ( isFatalWorkflowError ( error ) ) throw error
2026-07-05 13:29:35 +08:00
return null
}
} ) )
}
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 {
2026-07-06 21:02:41 +08:00
this . throwIfCancelled ( )
2026-07-05 13:29:35 +08:00
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 {
2026-07-06 21:02:41 +08:00
this . throwIfCancelled ( )
2026-07-05 13:29:35 +08:00
if ( typeof message !== 'string' ) {
throw new WorkflowError ( 'log() requires a message string' , 'INVALID_ARGUMENT' )
}
this . observer . log ( message )
}
}