Introduce IWorkflowEngine port to abstract workflow execution
Problem
@archon/workflows' execution engine (executeWorkflow / hydrateResumableRun in
packages/workflows/src/executor.ts, dag-executor.ts) is called directly by all four
current hosts — packages/server/src/services/workflow-resume-service.ts,
packages/cli/src/commands/workflow.ts, and two call sites in
packages/core/src/orchestrator/{orchestrator,orchestrator-agent}.ts. There is no
interface between "start/resume a run" and those callers, unlike every other seam in this
codebase: IWorkflowStore abstracts persistence, IWorkflowPlatform abstracts chat/CLI/
Web transport, but execution itself is a direct function call into a 12k-line module.
This is the one place AGENTS.md's own rule — "Workflow execution depends on injected
contracts, not on core database or adapter implementations" — is not yet applied to
execution itself, only to what execution depends on.
Concretely today: cancellation is implemented three different, ad hoc ways depending on
caller (CLI does a DB status write + hard process exit on SIGINT, writing status='failed';
the orchestrator's cancel/abandon tool action writes status='cancelled'; the resume
poller has no cancel path at all — a permanently-blocked continuation just retries every
60s forever). Neither executeWorkflow nor hydrateResumableRun accepts an AbortSignal
today: the DAG loop only cooperatively polls run status every ~10s and aborts in-flight
node streams once it observes a non-running/paused status, so any cancel operation this
issue introduces is necessarily a documented request, not a synchronous interrupt. Event
streaming is split three ways (CLI subscribes to a process-local WorkflowEventEmitter,
Web streams via its own SSE bridge plus a separate DB-polling bridge for cross-process
runs, the resume poller doesn't observe events at all) with no single contract describing
what "the engine" offers.
Why
The codebase already uses interface segregation for storage and platform I/O successfully
(IWorkflowStore, IWorkflowPlatform, IRunTreeStore, IWorkflowRunNodeSessionStore).
Extending the same pattern to execution:
- Gives every caller (server, CLI, orchestrator) one documented contract instead of three independently-evolved call patterns to the same underlying functions.
- Formalizes cancellation as a real operation instead of three different DB-write-and-exit variants, one of which (the resume poller) doesn't have one at all today.
- Costs nothing at runtime for current behavior: the first (and, for this issue, only) implementation is a thin wrapper around the exact code already running.
Why now
Low cost, no urgency-driven deadline — but doing it before any second execution substrate is ever considered (in-process is the only one that exists or is planned) means the interface gets shaped by what four real callers actually need, not by a hypothetical future backend. That's a better basis for the contract than designing it later around a specific second implementation's constraints.
Desired outcome
An IWorkflowEngine interface exists in @archon/workflows, with a single in-process
implementation that wraps today's executeWorkflow/hydrateResumableRun with no behavior
change, and all four current callers go through it instead of calling the engine functions
directly. Cancellation and event subscription become real, documented operations on the
interface rather than caller-specific ad hoc code.
Acceptance
-
IWorkflowEngineinterface defined (submit,resume,cancel,subscribe) in@archon/workflows, alongside its existingIWorkflowStore/IWorkflowPlatformports. Follows their id-based convention (operations take a plainrunId: string, not an opaque handle/token type — no handle exists anywhere in this codebase today).-
subscribe(runId, listener)delivers events for that run and its descendant sub-runs, resolved via the existingIRunTreeStore. It is not keyed by conversation id: a conversation is a transport concept owned byIWorkflowPlatform, and keying execution by it would couple execution to transport. Note today's CLI does subscribe by conversation id (packages/cli/src/commands/workflow.ts:3093→subscribeForConversation, filtering viaconversationMap.get(event.runId)atpackages/workflows/src/event-emitter.ts:316), so naive run-id keying would silently drop child sub-run rendering. NoincludeDescendantsoption — there is one consumer and it needs descendants. -
subscribereads throughIWorkflowStore's event-read methods, not a database directly, so a file-backed store satisfies the contract (direction.md§standalone-core: a database is an optional persistence adapter, not a prerequisite). -
cancel(runId, reason?)is documented in the interface as a cooperative request, not a synchronous interrupt: the DAG loop observes run status on aCANCEL_CHECK_INTERVAL_MS = 10_000throttle (packages/workflows/src/dag-executor.ts:953-955) andpauseddeliberately does not abort an in-flight stream (:976). - The emitter's existing 1-arg
subscribe(listener)(packages/workflows/src/event-emitter.ts:295) is renamed rather than overloaded.
-
- One implementation (
InProcessWorkflowEngine) delegatessubmit/resumeto today'sexecuteWorkflow/hydrateResumableRunwith no logic change; a new shared contract-test suite (e.g.runWorkflowEngineContractTests(makeEngine), authored from scratch — no such suite exists forIWorkflowStore/IWorkflowPlatformeither) confirms identical behavior to calling those functions directly. The suite must be observed failing against a deliberately broken engine before it counts as evidence (engineering.md§Taste: "a guard is evidence only after it has been seen red"). - Port code handles
WorkflowExecutionResultcorrectly: it discriminates onsuccess: booleanonly, and the paused member{success: true; paused: true}structurally overlaps plain success (packages/workflows/src/schemas/workflow.ts:392-395). Narrowing is via'paused' in result, not a three-way tag. -
workflow-resume-service.tscallsengine.resume(...)instead ofhydrateResumableRun+executeWorkflowdirectly; existing tests pass unmodified. - The event fields the CLI renders become persisted, written into the existing
remote_agent_workflow_events.dataJSON column — no DDL, no migration:workflow_started.transcriptPath(rendered atpackages/cli/src/commands/workflow.ts:1002, absent from the fields persisted atpackages/workflows/src/executor.ts:2903-2916), the node display name onnode_completed/node_failed(packages/workflows/src/node-event-write.ts:66-68,129vs.packages/workflows/src/dag-executor.ts:3206-3220, 3930-3942), and the node id (nonode_idcolumn exists; onlystep_namecarrying a loop prefix). Without this, a durable-log-backedsubscribecannot reproduce today's CLI output. This isengineering.md§Taste, "fix drift on the path you touch — one recording a field the other omits". - CLI's SIGINT/SIGTERM handling calls
engine.cancel(...)instead of its current ad hoc DB-write-and-exit. Note: today's SIGINT/SIGTERM path writesstatus='failed'(failWorkflowRun), notstatus='cancelled'— routing it throughengine.cancel()(backed bycancelWorkflowRun) means an operator-initiated Ctrl-C will now producestatus='cancelled'instead of'failed'. That is a deliberate, in-scope behavior change (an operator stop is not an execution failure), called out explicitly since it is the one place this migration isn't a pure no-op wrapper — not an oversight. Three invariants must survive:- The
runLiveOwner?.isStopRequested()handoff atpackages/cli/src/commands/workflow.ts:2991is preserved exactly. It already implementsAGENTS.md's rule against marking work terminal when a live owner cannot be distinguished;engine.cancel()must not flatten it. -
failWorkflowRunthrows on CAS miss (packages/core/src/db/workflows.ts:1286) whilecancelWorkflowRunreturns{cancelled: false}idempotently (:1333). The two are not conflated; the port keeps the Result-object convention. - This criterion sits in two of
engineering.md§Risk taxonomy's categories at once (terminal state transitions; lifecycle ownership) and therefore takes adversarial review depth and explicit SIGINT/SIGTERM integration coverage, not diff review.
- The
- CLI's event rendering subscribes via
engine.subscribe(...)backed by the durable event log instead of the process-localWorkflowEventEmitter. (Web SSE and the dashboard's separate DB-polling bridge are not touched by this issue — only CLI's consumer needs to move ontoengine.subscribe(...).) - All host call sites go through
engine.submit/engine.resume: the 4 inorchestrator.ts/orchestrator-agent.ts, plus CLI and the resume service. Explicitly excluded: the two intra-module recursive calls inexecutor.ts(:1504child sub-run,:1715parent auto-resume, withhydrateResumableRunat:1390/:1684). They remain direct calls internal toInProcessWorkflowEngine; routing them through the port would require injecting the engine intoWorkflowDeps, which the engine itself constructs — new circular coupling with no caller requiring it. There are 8 productionexecuteWorkflowcall sites in total, not 6. - No public export is removed from
@archon/workflows(executeWorkflowandhydrateResumableRunmay remain exported, used internally by the in-process implementation) — this issue does not require or perform that removal. - No second
IWorkflowEngineimplementation (Temporal or otherwise) is built as part of this issue. Design choices in this issue are justified on their own merits — durable-backedsubscribefor surviving a CLI process restart mid-run,cancel()for removing three inconsistent terminal-status writers — not on portability to a hypothetical second backend, which is not an admissible justification under YAGNI.
Constraints and related work
- Must remain true: no version bump, no schema migration (no DDL: added event fields go
into the existing
dataJSON column ofremote_agent_workflow_events), no removal of an existing public export;ExecuteWorkflowOptions/WorkflowExecutionResultshapes are unchanged (the former is the actual exported type name inexecutor.ts, notWorkflowExecutionOptions). - Scope boundary: this issue is the interface + in-process wrapper + caller migration only.
It explicitly does not include designing or building a Temporal-backed (or any other
second) engine implementation — that would be a separate, later initiative requiring its
own product-direction decision. It also does not include unifying Web SSE / the
dashboard's DB-polling bridge onto
engine.subscribe(...)— CLI only, per Acceptance. - Known prerequisites or blockers: none —
IWorkflowStoreandIWorkflowPlatformalready exist as the precedent pattern to follow. No shared contract-test infrastructure exists yet for those two interfaces either, so the contract-test suite required by Acceptance needs to be authored from scratch, not extended from an existing one. - Solution steering: Hint — follow the existing
IWorkflowStore/IWorkflowPlatforminterface-segregation style (narrow ports, composed rather than one god-interface, id-based rather than handle-based operations) rather than introducing a new DI convention.
Additional notes
This mirrors a pattern already proven outside this repo: @workflowbuilder/temporal
(a sibling project) makes the same "engine executes a graph of typed nodes" contract
swappable between an in-process runner and Temporal, via a narrow WorkflowEnginePort.
Archon's node/graph model (WorkflowDefinition/GraphPlan/DagNode) is already
structurally comparable. This issue only proposes the interface-extraction half of that —
not adopting Temporal — but keeps that door open for a future, separately-decided initiative
without committing to it now.
Source: coleam00/Archon