mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
workflow: dynamic workflows — script-driven multi-agent orchestration
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.
This commit is contained in:
29
packages/workflow/workflow/README.md
Normal file
29
packages/workflow/workflow/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# @deepseek-ai/dsh-workflow
|
||||
|
||||
The **workflow seam** (`ctx.workflows`): an abstract service defining WHAT a workflow engine does — execute a model-written orchestration script that fans out subagents — without saying HOW. The bash-shaped third of the [workflow family](../README.md): implementations subclass `WorkflowService` and register as the `workflows` service (one per context); [`dsh-workflow-vm`](../workflow-vm/README.md) is the first, and [`dsh-tool-workflow`](../tool-workflow/README.md) is the model-facing consumer.
|
||||
|
||||
## Service: `WorkflowService` (abstract)
|
||||
|
||||
`start(request: WorkflowStartRequest): WorkflowRun` — parse and execute a script. Throws synchronously (`SCRIPT_PARSE`/`META_INVALID`) for a script that cannot begin; once a run is returned, its `result` NEVER rejects — every failure resolves with `stopReason: 'error'` (or `'cancelled'`). `dispose()` must reach quiescence within a bounded grace (cancel → wait → abandon), never hanging its caller.
|
||||
|
||||
The protected `emitWorkflowEvent` helper dispatches the `workflow/*` events with PER-LISTENER containment (a throwing subscriber is logged, never propagated, and cannot starve later listeners) — the same guarantee as the subagent seam's lifecycle emits.
|
||||
|
||||
## 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`).
|
||||
- `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.
|
||||
|
||||
## Events
|
||||
|
||||
All observe-only emits carrying DATA SNAPSHOTS (`WorkflowRunInfo` = id + meta) — never the live `WorkflowRun`, so a listener cannot gain `cancel`/`dispose`; control stays with the `start()` caller:
|
||||
|
||||
- `workflow/start`(info) / `workflow/end`(info, resultInfo) — run lifecycle; `resultInfo` deliberately omits the value.
|
||||
- `workflow/phase`(info, title) / `workflow/log`(info, message) — script narration.
|
||||
- `workflow/agent-start`(info, agent) / `workflow/agent-end`(info, agent + outcome) — one pair per `agent()` call, correlated by `seq`.
|
||||
|
||||
## Non-goals (this cut)
|
||||
|
||||
Background collection, journaling/resume, saved workflows, nested `workflow()`, token budgets — see the [RFC's deferred section](../../../docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md).
|
||||
37
packages/workflow/workflow/package.json
Normal file
37
packages/workflow/workflow/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-workflow",
|
||||
"description": "Workflow capability seam: ctx.workflows service, run vocabulary, and workflow/* events",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
224
packages/workflow/workflow/src/index.ts
Normal file
224
packages/workflow/workflow/src/index.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* The workflow capability seam (`ctx.workflows`): an abstract service defining
|
||||
* WHAT a workflow engine does — execute a model-written orchestration script
|
||||
* that fans out subagents — without saying HOW. Implementations subclass
|
||||
* {@link WorkflowService} and register as the `workflows` service (one
|
||||
* implementation per context, cordis' standard duplicate-service behavior);
|
||||
* `@deepseek-ai/dsh-workflow-vm` (an in-process `node:vm` engine) is the
|
||||
* first. Future engines (a worker-thread or isolated-vm sandbox) swap in
|
||||
* without touching the model-facing tool that consumes them
|
||||
* (`@deepseek-ai/dsh-tool-workflow`).
|
||||
*
|
||||
* The `workflow/*` lifecycle events are OBSERVE-ONLY data snapshots: they
|
||||
* carry {@link WorkflowRunInfo} (id + meta), never the live {@link WorkflowRun}
|
||||
* — a listener must not gain `cancel`/`dispose`; control stays with the
|
||||
* `start()` caller holding the run. Every emit is per-listener contained (a
|
||||
* throwing subscriber is logged, never propagated), so one bad observer can
|
||||
* neither strand a live run nor starve later listeners.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workflow
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
WorkflowAgentEndInfo,
|
||||
WorkflowAgentInfo,
|
||||
WorkflowResultInfo,
|
||||
WorkflowRun,
|
||||
WorkflowRunInfo,
|
||||
WorkflowStartRequest,
|
||||
} from './types.ts'
|
||||
|
||||
export { WorkflowRunId } from './types.ts'
|
||||
export type {
|
||||
WorkflowAgentEndInfo,
|
||||
WorkflowAgentInfo,
|
||||
WorkflowAgentOutcome,
|
||||
WorkflowMeta,
|
||||
WorkflowPhase,
|
||||
WorkflowResult,
|
||||
WorkflowResultInfo,
|
||||
WorkflowRun,
|
||||
WorkflowRunInfo,
|
||||
WorkflowStartRequest,
|
||||
WorkflowStopReason,
|
||||
} from './types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
workflows: WorkflowService
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* A workflow run started — the script's meta block validated, the body
|
||||
* about to execute. Paired with {@link Events['workflow/end']}.
|
||||
* @param info - the run's identity snapshot (id + meta).
|
||||
* @mode emit
|
||||
*/
|
||||
'workflow/start'(info: WorkflowRunInfo): void
|
||||
/**
|
||||
* The script entered a phase (a `phase(title)` call) — progress grouping
|
||||
* for observers; no execution semantics.
|
||||
* @param info - the run's identity snapshot.
|
||||
* @param title - the phase title, verbatim.
|
||||
* @mode emit
|
||||
*/
|
||||
'workflow/phase'(info: WorkflowRunInfo, title: string): void
|
||||
/**
|
||||
* The script emitted a narration line (a `log(message)` call).
|
||||
* @param info - the run's identity snapshot.
|
||||
* @param message - the logged message, verbatim.
|
||||
* @mode emit
|
||||
*/
|
||||
'workflow/log'(info: WorkflowRunInfo, message: string): void
|
||||
/**
|
||||
* One `agent()` call started a child run. Paired with
|
||||
* {@link Events['workflow/agent-end']} by `agent.seq`.
|
||||
* @param info - the run's identity snapshot.
|
||||
* @param agent - the call's sequence number, label, phase, and child id.
|
||||
* @mode emit
|
||||
*/
|
||||
'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void
|
||||
/**
|
||||
* One `agent()` call settled (clean result, child failure, or run
|
||||
* cancellation). Paired with {@link Events['workflow/agent-start']}.
|
||||
* @param info - the run's identity snapshot.
|
||||
* @param agent - the call identity plus its outcome.
|
||||
* @mode emit
|
||||
*/
|
||||
'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void
|
||||
/**
|
||||
* A workflow run settled (any stop reason). Fired when
|
||||
* {@link WorkflowRun.result} resolves. Paired with
|
||||
* {@link Events['workflow/start']}.
|
||||
* @param info - the run's identity snapshot.
|
||||
* @param result - the outcome data (stop reason, error, agent count) —
|
||||
* deliberately WITHOUT the result value (see {@link WorkflowResultInfo}).
|
||||
* @mode emit
|
||||
*/
|
||||
'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void
|
||||
}
|
||||
}
|
||||
|
||||
/** The full set of `workflow/*` event names {@link WorkflowService.emitWorkflowEvent} dispatches. */
|
||||
export type WorkflowEventName =
|
||||
| 'workflow/start'
|
||||
| 'workflow/phase'
|
||||
| 'workflow/log'
|
||||
| 'workflow/agent-start'
|
||||
| 'workflow/agent-end'
|
||||
| 'workflow/end'
|
||||
|
||||
/**
|
||||
* The workflow-seam error codes. Every one of these is FATAL when it reaches
|
||||
* a script (see {@link WorkflowError.fatal}): the combinators re-throw it
|
||||
* instead of dissolving it into an ordinary per-item `null`.
|
||||
*
|
||||
* - `SCRIPT_PARSE` — the script (or its meta statement) does not parse.
|
||||
* - `META_INVALID` — the meta block evaluated but fails the shape contract.
|
||||
* - `INVALID_ARGUMENT` — a hook was called with malformed arguments.
|
||||
* - `UNSUPPORTED_OPTION` — an `agent()` option this engine does not support
|
||||
* (deferred: `effort`/`isolation`/`agentType`) or does not know.
|
||||
* - `UNSUPPORTED_SCHEMA` — an `agent()` schema outside the structured-output
|
||||
* subset (see dsh-tools).
|
||||
* - `AGENT_CAP` / `ITEM_CAP` — the run/agent caps tripped.
|
||||
* - `AGENT_START` — the subagent seam refused to start a child.
|
||||
* - `RESULT_UNSERIALIZABLE` — a value crossing the realm boundary is not
|
||||
* plain JSON data.
|
||||
* - `CANCELLED` — the run was cancelled; pending and future hooks reject
|
||||
* with this (the script-kill mechanism).
|
||||
*/
|
||||
export type WorkflowErrorCode =
|
||||
| 'SCRIPT_PARSE'
|
||||
| 'META_INVALID'
|
||||
| 'INVALID_ARGUMENT'
|
||||
| 'UNSUPPORTED_OPTION'
|
||||
| 'UNSUPPORTED_SCHEMA'
|
||||
| 'AGENT_CAP'
|
||||
| 'ITEM_CAP'
|
||||
| 'AGENT_START'
|
||||
| 'RESULT_UNSERIALIZABLE'
|
||||
| 'CANCELLED'
|
||||
|
||||
/**
|
||||
* Typed error for workflow-seam failures. Extends {@link HarnessError}, so the
|
||||
* `code` is machine-routable taxonomy. `fatal` drives the combinator
|
||||
* discipline: `parallel()`/`pipeline()` re-throw a fatal error (a typo'd
|
||||
* option or a tripped cap must kill the script loudly), and reserve the
|
||||
* per-item `null` for child-run failures and ordinary in-stage script errors.
|
||||
* Every {@link WorkflowErrorCode} is fatal in this cut; the flag exists so the
|
||||
* distinction is explicit at every catch site rather than implied.
|
||||
*/
|
||||
export class WorkflowError extends HarnessError {
|
||||
/** Whether combinators must propagate this error instead of nulling the item. */
|
||||
readonly fatal: boolean
|
||||
|
||||
constructor(message: string, code: WorkflowErrorCode, options?: ErrorOptions & { fatal?: boolean }) {
|
||||
super(message, code, options)
|
||||
this.name = 'WorkflowError'
|
||||
this.fatal = options?.fatal ?? true
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether combinators must re-throw `error` instead of mapping the item to `null`. */
|
||||
export function isFatalWorkflowError(error: unknown): boolean {
|
||||
return error instanceof WorkflowError && error.fatal
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract workflow execution service. Subclass, implement {@link start}, and
|
||||
* load the subclass as a plugin — it registers as `ctx.workflows` (one
|
||||
* implementation per context; loading a second throws, cordis' standard
|
||||
* duplicate-service behavior).
|
||||
*
|
||||
* Semantics every implementation must honor:
|
||||
* - {@link start} throws synchronously for a request that cannot begin (an
|
||||
* unparseable script, an invalid meta block). Once it returns a
|
||||
* {@link WorkflowRun}, `result` NEVER rejects — every failure resolves with
|
||||
* `stopReason: 'error'` (or `'cancelled'`).
|
||||
* - The `workflow/*` events fire through {@link emitWorkflowEvent} (data
|
||||
* snapshots, per-listener containment); `workflow/end` fires exactly once
|
||||
* per started run, after `result` is settled or as it settles.
|
||||
* - `dispose()` reaches quiescence within a bounded grace: it cancels, waits
|
||||
* for the script to settle, and abandons a stuck script rather than
|
||||
* hanging its caller (the engine documents what abandonment leaves behind).
|
||||
*/
|
||||
export abstract class WorkflowService extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'workflows')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and execute a workflow script.
|
||||
* @param request - the script, its `args`, the parent agent, and an
|
||||
* optional cancel signal.
|
||||
* @returns the live run; its `result` resolves when the script settles.
|
||||
*/
|
||||
abstract start(request: WorkflowStartRequest): WorkflowRun
|
||||
|
||||
/**
|
||||
* Emit one `workflow/*` lifecycle event with PER-LISTENER containment:
|
||||
* dispatch each subscriber individually and log (never propagate) a thrown
|
||||
* one, so one bad subscriber can neither fail the engine mid-run, surface as
|
||||
* an unhandled rejection on a detached settle hook, nor starve the listeners
|
||||
* registered after it (cordis `emit` halts on the first throw — same
|
||||
* guarantee as the subagent seam's lifecycle emits).
|
||||
* @param name - the `workflow/*` event to dispatch.
|
||||
* @param args - the event's payload, matching its declared signature.
|
||||
*/
|
||||
protected emitWorkflowEvent(name: WorkflowEventName, ...args: unknown[]): void {
|
||||
for (const callback of this.ctx.events.dispatch('emit', [name, ...args])) {
|
||||
try {
|
||||
// The declared workflow/* signatures are all void-returning emits; the
|
||||
// dispatch callback applies the payload tuple.
|
||||
;(callback as (...payload: unknown[]) => void)(...args)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`workflow: ${name} listener threw: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default WorkflowService
|
||||
154
packages/workflow/workflow/src/types.ts
Normal file
154
packages/workflow/workflow/src/types.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Workflow seam vocabulary: the request/run/result types a workflow engine
|
||||
* consumes and produces, plus the payload shapes of the `workflow/*` events.
|
||||
* Types only (plus the id-brand factory), per the package convention.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workflow/types
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
/** Identifies one workflow run. */
|
||||
export type WorkflowRunId = Branded<'WorkflowRunId'>
|
||||
|
||||
/** Brand a string as a {@link WorkflowRunId}. */
|
||||
export function WorkflowRunId(id: string): WorkflowRunId {
|
||||
return id as WorkflowRunId
|
||||
}
|
||||
|
||||
/**
|
||||
* One phase declared in a script's `meta.phases` (progress vocabulary only —
|
||||
* phases group agents in observers/UIs; they impose no execution structure).
|
||||
*/
|
||||
export interface WorkflowPhase {
|
||||
/** The phase title; `phase()` calls match against it by exact string. */
|
||||
title: string
|
||||
/** Optional one-line description of what the phase does. */
|
||||
detail?: string
|
||||
/** Optional model override this phase is expected to use (informational). */
|
||||
model?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export interface WorkflowMeta {
|
||||
/** Short kebab-case workflow name (display + persistence key). */
|
||||
name: string
|
||||
/** One-line description of what the workflow does. */
|
||||
description: string
|
||||
/** Optional guidance on when this workflow applies (shown in listings). */
|
||||
whenToUse?: string
|
||||
/** Optional phase declarations matched by `phase()` calls. */
|
||||
phases?: WorkflowPhase[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export interface WorkflowStartRequest {
|
||||
/** The full script text: `export const meta = {...}` + a plain-JS body. */
|
||||
script: string
|
||||
/** 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). */
|
||||
parent: Agent
|
||||
/** Cancels the run when aborted (the tool's `exec.signal`). */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a run settled. CLOSED union (engine-owned, consumers may exhaust):
|
||||
* `completed` = the script ran to its final `return`; `cancelled` = the run
|
||||
* was cancelled (caller `cancel()`/signal); `error` = the script threw, a
|
||||
* fatal `WorkflowError` propagated, or the result failed materialization.
|
||||
*/
|
||||
export type WorkflowStopReason = 'completed' | 'cancelled' | 'error'
|
||||
|
||||
/**
|
||||
* The outcome of one run, resolved by {@link WorkflowRun.result}. `value` is
|
||||
* the script's materialized return value (plain host-realm JSON data; `null`
|
||||
* when the script returned `undefined`) — meaningful only for `completed`.
|
||||
* A non-`completed` reason carries the failure in `error`; the consumer maps
|
||||
* it to an `isError` tool result rather than reporting partial output.
|
||||
*/
|
||||
export interface WorkflowResult {
|
||||
/** The script's return value (host JSON data; `null` for no return). */
|
||||
value: unknown
|
||||
/** Why the run settled. */
|
||||
stopReason: WorkflowStopReason
|
||||
/** The failure message (present iff `stopReason` is not `completed`). */
|
||||
error?: string
|
||||
/** How many `agent()` calls the run started (across its whole lifetime). */
|
||||
agentsStarted: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The handle the consumer holds while a script executes. The consumer awaits
|
||||
* `result`, may `cancel` mid-flight, and MUST `dispose` on every path.
|
||||
* `result` does NOT reject — a script failure resolves with `stopReason:
|
||||
* 'error'` — so the consumer maps a non-`completed` reason to an `isError`
|
||||
* result. `dispose()` cancels, then waits a bounded grace for the script to
|
||||
* settle before abandoning it (the engine documents the abandonment
|
||||
* semantics); it never hangs on a stuck script.
|
||||
*/
|
||||
export interface WorkflowRun {
|
||||
readonly id: WorkflowRunId
|
||||
/** The validated meta block (available before the body runs). */
|
||||
readonly meta: WorkflowMeta
|
||||
readonly result: Promise<WorkflowResult>
|
||||
/** Cancel the run: children abort, pending hooks reject, the script dies at its next await. */
|
||||
cancel(reason?: string): void
|
||||
/** Cancel + bounded-grace settle; safe to call on every path (idempotent). */
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
/** Identifying detail for a run, carried by every `workflow/*` event (a data snapshot, never the live run). */
|
||||
export interface WorkflowRunInfo {
|
||||
/** The run's id. */
|
||||
id: WorkflowRunId
|
||||
/** The run's validated meta block. */
|
||||
meta: WorkflowMeta
|
||||
}
|
||||
|
||||
/** One `agent()` call's identity within a run (the `workflow/agent-start` payload). */
|
||||
export interface WorkflowAgentInfo {
|
||||
/** 1-based sequence number of this `agent()` call within the run. */
|
||||
seq: number
|
||||
/** The display label (the `label` option, or a prompt snippet). */
|
||||
label: string
|
||||
/** The phase this agent belongs to (the `phase` option, else the current `phase()` title). */
|
||||
phase?: string
|
||||
/** The child agent's id on the subagent seam. */
|
||||
childId: AgentId
|
||||
}
|
||||
|
||||
/** How one `agent()` call settled: clean result, child failure (script sees `null`), or run cancellation. */
|
||||
export type WorkflowAgentOutcome = 'completed' | 'failed' | 'cancelled'
|
||||
|
||||
/** One `agent()` call's settlement (the `workflow/agent-end` payload). */
|
||||
export interface WorkflowAgentEndInfo extends WorkflowAgentInfo {
|
||||
/** How the call settled. */
|
||||
outcome: WorkflowAgentOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* A settled run's outcome as event data (the `workflow/end` payload): the
|
||||
* {@link WorkflowResult} minus `value` (a listener observing outcomes must not
|
||||
* receive a mutable alias of the caller's result value; a consumer that needs
|
||||
* the value holds the run and awaits `result`).
|
||||
*/
|
||||
export interface WorkflowResultInfo {
|
||||
/** Why the run settled. */
|
||||
stopReason: WorkflowStopReason
|
||||
/** The failure message (present iff `stopReason` is not `completed`). */
|
||||
error?: string
|
||||
/** How many `agent()` calls the run started. */
|
||||
agentsStarted: number
|
||||
}
|
||||
86
packages/workflow/workflow/tests/workflow.spec.ts
Normal file
86
packages/workflow/workflow/tests/workflow.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import WorkflowServiceDefault, {
|
||||
isFatalWorkflowError,
|
||||
WorkflowError,
|
||||
WorkflowRunId,
|
||||
WorkflowService,
|
||||
} from '../src/index.ts'
|
||||
import type { WorkflowRun, WorkflowRunInfo, WorkflowStartRequest } from '../src/index.ts'
|
||||
|
||||
/** A minimal concrete subclass exposing the protected emit helper for tests. */
|
||||
class StubEngine extends WorkflowService {
|
||||
start(request: WorkflowStartRequest): WorkflowRun {
|
||||
void request
|
||||
throw new Error('not under test')
|
||||
}
|
||||
|
||||
emit(name: Parameters<WorkflowService['emitWorkflowEvent']>[0], ...args: unknown[]): void {
|
||||
this.emitWorkflowEvent(name, ...args)
|
||||
}
|
||||
}
|
||||
|
||||
const INFO: WorkflowRunInfo = { id: WorkflowRunId('run-1'), meta: { name: 'w', description: 'd' } }
|
||||
|
||||
describe('dsh-workflow (interface)', () => {
|
||||
it('WorkflowRunId brands a string (identity at runtime)', () => {
|
||||
expect(WorkflowRunId('abc')).toBe('abc')
|
||||
})
|
||||
|
||||
it('WorkflowError carries code + fatal (default true) and reads as a HarnessError', () => {
|
||||
const error = new WorkflowError('cap hit', 'AGENT_CAP')
|
||||
expect(error.code).toBe('AGENT_CAP')
|
||||
expect(error.fatal).toBe(true)
|
||||
expect(error.name).toBe('WorkflowError')
|
||||
const soft = new WorkflowError('advisory', 'ITEM_CAP', { fatal: false })
|
||||
expect(soft.fatal).toBe(false)
|
||||
})
|
||||
|
||||
it('isFatalWorkflowError: true only for a fatal WorkflowError', () => {
|
||||
expect(isFatalWorkflowError(new WorkflowError('x', 'CANCELLED'))).toBe(true)
|
||||
expect(isFatalWorkflowError(new WorkflowError('x', 'CANCELLED', { fatal: false }))).toBe(false)
|
||||
expect(isFatalWorkflowError(new Error('plain'))).toBe(false)
|
||||
expect(isFatalWorkflowError('string')).toBe(false)
|
||||
})
|
||||
|
||||
it('registers as ctx.workflows and unregisters when its fiber is disposed (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(StubEngine)
|
||||
expect(ctx.get('workflows')).toBeInstanceOf(StubEngine)
|
||||
await fiber.dispose()
|
||||
expect(ctx.get('workflows')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('emitWorkflowEvent dispatches to every listener with the payload tuple', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(StubEngine)
|
||||
const seen: unknown[][] = []
|
||||
ctx.on('workflow/log', (info, message) => { seen.push([info, message]) })
|
||||
ctx.on('workflow/agent-start', (info, agent) => { seen.push([info, agent]) })
|
||||
const engine = ctx.workflows as StubEngine
|
||||
engine.emit('workflow/log', INFO, 'hello')
|
||||
engine.emit('workflow/agent-start', INFO, { seq: 1, label: 'l', childId: 'c' })
|
||||
expect(seen).toEqual([
|
||||
[INFO, 'hello'],
|
||||
[INFO, { seq: 1, label: 'l', childId: 'c' }],
|
||||
])
|
||||
})
|
||||
|
||||
it('contains a throwing listener PER LISTENER: later listeners still run, nothing propagates', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(StubEngine)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => ctx.logger)
|
||||
const reached: string[] = []
|
||||
ctx.on('workflow/phase', () => { throw new Error('bad listener') })
|
||||
ctx.on('workflow/phase', (_info, title) => { reached.push(title) })
|
||||
const engine = ctx.workflows as StubEngine
|
||||
expect(() => { engine.emit('workflow/phase', INFO, 'Scan') }).not.toThrow()
|
||||
expect(reached).toEqual(['Scan'])
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
expect(String(warn.mock.calls[0]![0])).toContain('workflow/phase listener threw')
|
||||
})
|
||||
|
||||
it('has the expected export surface (default = the abstract service class)', () => {
|
||||
expect(WorkflowServiceDefault).toBe(WorkflowService)
|
||||
})
|
||||
})
|
||||
27
packages/workflow/workflow/tsconfig.json
Normal file
27
packages/workflow/workflow/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user