@getpipher/armory-fleet 0.11.1 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +371 -14
  2. package/package.json +1 -1
  3. package/src/engine/concurrency-lock.ts +20 -15
  4. package/src/engine/spawnSubagent.ts +57 -4
  5. package/src/index.ts +114 -7
  6. package/src/panel/fleet-panel.ts +177 -11
  7. package/src/panel/fleet-widget.ts +5 -1
  8. package/src/runtime/reconcile.ts +32 -11
  9. package/src/todo-sync/adapter.ts +22 -3
  10. package/src/tools/fleet.ts +179 -0
  11. package/src/workflows/builtin/adversarial-review.js +19 -0
  12. package/src/workflows/builtin/code-review.js +13 -0
  13. package/src/workflows/builtin/codebase-audit.js +16 -0
  14. package/src/workflows/builtin/deep-research.js +12 -0
  15. package/src/workflows/builtin/multi-perspective.js +17 -0
  16. package/src/workflows/helpers/checkpoint.ts +15 -0
  17. package/src/workflows/helpers/completeness-check.ts +18 -0
  18. package/src/workflows/helpers/gate.ts +22 -0
  19. package/src/workflows/helpers/index.ts +8 -0
  20. package/src/workflows/helpers/judge-panel.ts +33 -0
  21. package/src/workflows/helpers/loop-until-dry.ts +21 -0
  22. package/src/workflows/helpers/retry.ts +17 -0
  23. package/src/workflows/helpers/types.ts +19 -0
  24. package/src/workflows/helpers/verify.ts +27 -0
  25. package/src/workflows/journal.ts +76 -0
  26. package/src/workflows/keyword.ts +22 -0
  27. package/src/workflows/panel/workflows-items.ts +150 -0
  28. package/src/workflows/panel/workflows-rows.ts +3 -0
  29. package/src/workflows/panel-host.ts +179 -0
  30. package/src/workflows/registry.ts +68 -0
  31. package/src/workflows/runner.ts +507 -0
  32. package/src/workflows/runtime/adapters.ts +182 -0
  33. package/src/workflows/runtime/controller.ts +493 -0
  34. package/src/workflows/runtime/hydrate.ts +116 -0
  35. package/src/workflows/runtime/pause-gate.ts +41 -0
  36. package/src/workflows/runtime/run-store.ts +31 -0
  37. package/src/workflows/runtime/save.ts +111 -0
  38. package/src/workflows/runtime/types.ts +78 -0
  39. package/src/workflows/source.ts +156 -0
  40. package/src/workflows/vm-realm.ts +106 -0
@@ -0,0 +1,182 @@
1
+ // src/workflows/runtime/adapters.ts — SPEC-6-3 production spawn/lifecycle adapters.
2
+ // Wires real spawnSubagent + runLifecycle into WorkflowRunDeps via a per-workflow concurrency pool.
3
+ // Each admitted child gets a fresh SingleSlotLock (NOT the extension's foreground singleton).
4
+ import { ConcurrencyPool } from "../../runtime/concurrency-pool.ts"
5
+ import { SingleSlotLock } from "../../engine/concurrency-lock.ts"
6
+ import type { SpawnOptions, SpawnResult } from "../../engine/spawnSubagent.ts"
7
+ import type {
8
+ LifecycleRunResult,
9
+ LifecycleRunOpts,
10
+ PhaseSpawnOpts,
11
+ CheckpointFn,
12
+ } from "../../lifecycle/run-lifecycle.ts"
13
+
14
+ /** The deps the adapter needs from index.ts (Task 13). Tests inject spawnSubagentFn + runLifecycleFn. */
15
+ export interface WorkflowAdapterBase {
16
+ registry: Map<string, unknown>
17
+ todoSync: unknown
18
+ runRegistry: unknown
19
+ backendRegistry: unknown
20
+ parentModel: { provider: string; id: string }
21
+ parentCwd: string
22
+ runLog?: unknown
23
+ tierRegistry?: unknown
24
+ modelRegistry?: unknown
25
+ lifecycleDeps: unknown
26
+ spawnSubagentFn: (opts: SpawnOptions) => Promise<SpawnResult>
27
+ runLifecycleFn: (task: string, name: string, opts: LifecycleRunOpts) => Promise<LifecycleRunResult>
28
+ /** Optional bridge for lifecycle checkpoint decisions (maps to the runner's deps.onCheckpoint). */
29
+ onCheckpointBridge?: (prompt: string, opts: Record<string, unknown>) => Promise<unknown>
30
+ }
31
+
32
+ export interface WorkflowAdapterOpts {
33
+ concurrency: number
34
+ signal: AbortSignal
35
+ }
36
+
37
+ export type WorkflowSpawnResult = {
38
+ finalText: string
39
+ runId: string
40
+ status: "completed" | "failed"
41
+ costTotal?: number
42
+ tokenTotal?: number
43
+ }
44
+
45
+ export type WorkflowLifecycleResult = {
46
+ status: "completed" | "failed" | "aborted"
47
+ finalText: string
48
+ costTotal?: number
49
+ tokenTotal?: number
50
+ error?: string
51
+ }
52
+
53
+ export function createWorkflowAdapters(
54
+ base: WorkflowAdapterBase,
55
+ opts: WorkflowAdapterOpts,
56
+ ): Pick<import("../runner.ts").WorkflowRunDeps, "spawn" | "runLifecycle"> {
57
+ const pool = new ConcurrencyPool(Math.min(Math.max(opts.concurrency, 1), 16))
58
+
59
+ /** Build the common SpawnOptions fields shared by direct spawns and lifecycle phase spawns. */
60
+ const commonSpawnFields = (lock: SingleSlotLock, signal: AbortSignal) => ({
61
+ lock,
62
+ signal,
63
+ registry: base.registry as SpawnOptions["registry"],
64
+ todoSync: base.todoSync as SpawnOptions["todoSync"],
65
+ runRegistry: base.runRegistry as SpawnOptions["runRegistry"],
66
+ backendRegistry: base.backendRegistry as SpawnOptions["backendRegistry"],
67
+ parentModel: base.parentModel,
68
+ parentCwd: base.parentCwd,
69
+ ...(base.runLog ? { runLog: base.runLog as SpawnOptions["runLog"] } : {}),
70
+ ...(base.tierRegistry ? { tierRegistry: base.tierRegistry as SpawnOptions["tierRegistry"] } : {}),
71
+ ...(base.modelRegistry ? { modelRegistry: base.modelRegistry as SpawnOptions["modelRegistry"] } : {}),
72
+ })
73
+
74
+ const combineSignal = (timeoutMs?: number): AbortSignal => {
75
+ const signals: AbortSignal[] = [opts.signal]
76
+ if (timeoutMs && timeoutMs > 0) {
77
+ signals.push(AbortSignal.timeout(timeoutMs))
78
+ }
79
+ return AbortSignal.any(signals)
80
+ }
81
+
82
+ const spawn: import("../runner.ts").WorkflowRunDeps["spawn"] = async (prompt, spawnOpts) => {
83
+ return pool.withSlot(async () => {
84
+ const lock = new SingleSlotLock()
85
+ const combined = combineSignal(spawnOpts.timeoutMs)
86
+
87
+ const result = await base.spawnSubagentFn({
88
+ agent: spawnOpts.agent,
89
+ task: prompt,
90
+ ...(spawnOpts.model ? { model: spawnOpts.model } : {}),
91
+ ...(spawnOpts.tier ? { tierOverride: spawnOpts.tier } : {}),
92
+ ...(spawnOpts.skills ? { skillsOverride: spawnOpts.skills } : {}),
93
+ ...(spawnOpts.backend ? { backendOverride: spawnOpts.backend } : {}),
94
+ ...commonSpawnFields(lock, combined),
95
+ })
96
+
97
+ return {
98
+ finalText: result.finalText,
99
+ runId: result.runId,
100
+ status: result.status === "completed" ? "completed" : "failed",
101
+ ...(result.costTotal != null ? { costTotal: result.costTotal } : {}),
102
+ ...(result.tokenTotal != null ? { tokenTotal: result.tokenTotal } : {}),
103
+ }
104
+ })
105
+ }
106
+
107
+ /**
108
+ * Lifecycle phase spawn: runs through the SAME workflow pool with a FRESH lock.
109
+ * Delegates to base.spawnSubagentFn (NOT the opaque lifecycleDeps.spawn) so phase
110
+ * spawns respect workflow concurrency and never reuse the foreground singleton.
111
+ */
112
+ const lifecycleSpawn = async (o: PhaseSpawnOpts): Promise<SpawnResult> => {
113
+ return pool.withSlot(async () => {
114
+ const lock = new SingleSlotLock()
115
+ const combined = combineSignal()
116
+
117
+ return base.spawnSubagentFn({
118
+ agent: o.agent,
119
+ task: o.task,
120
+ ...(o.lifecycleTodoId ? { lifecycleTodoId: o.lifecycleTodoId } : {}),
121
+ ...(o.model ? { model: o.model } : {}),
122
+ ...(o.skills ? { skillsOverride: o.skills } : {}),
123
+ ...(o.backend ? { backendOverride: o.backend } : {}),
124
+ ...commonSpawnFields(lock, combined),
125
+ })
126
+ })
127
+ }
128
+
129
+ /**
130
+ * Bridge lifecycle checkpoint decisions to the workflow runner's onCheckpoint.
131
+ * When no bridge is provided, auto-continue (the v1 headless default).
132
+ */
133
+ const lifecycleOnCheckpoint: CheckpointFn = async (phase, gateResults) => {
134
+ if (!base.onCheckpointBridge) {
135
+ return { action: "continue" as const }
136
+ }
137
+ try {
138
+ const bridgeResult = await base.onCheckpointBridge(
139
+ `Checkpoint: ${phase.name}`,
140
+ { phase: phase.name, status: phase.status, summary: phase.summary, gateResults },
141
+ )
142
+ if (bridgeResult === false || bridgeResult === "abort") {
143
+ return { action: "abort" as const }
144
+ }
145
+ return { action: "continue" as const }
146
+ } catch {
147
+ return { action: "abort" as const }
148
+ }
149
+ }
150
+
151
+ const runLifecycle: import("../runner.ts").WorkflowRunDeps["runLifecycle"] = async (
152
+ task,
153
+ name,
154
+ lcOpts,
155
+ ) => {
156
+ return pool.withSlot(async () => {
157
+ const result = await base.runLifecycleFn(task, name, {
158
+ deps: {
159
+ ...(base.lifecycleDeps as LifecycleRunOpts["deps"]),
160
+ spawn: lifecycleSpawn,
161
+ },
162
+ mode: lcOpts.mode,
163
+ onCheckpoint: lifecycleOnCheckpoint,
164
+ ...(lcOpts.worktreePath ? { worktreePath: lcOpts.worktreePath } : {}),
165
+ })
166
+
167
+ const finalText = result.phases.length > 0
168
+ ? (result.phases[result.phases.length - 1]?.summary ?? result.error ?? "")
169
+ : (result.error ?? "")
170
+
171
+ return {
172
+ status: result.status,
173
+ finalText,
174
+ ...(result.error ? { error: result.error } : {}),
175
+ ...((result as unknown as Record<string, unknown>).costTotal != null ? { costTotal: (result as unknown as Record<string, unknown>).costTotal as number } : {}),
176
+ ...((result as unknown as Record<string, unknown>).tokenTotal != null ? { tokenTotal: (result as unknown as Record<string, unknown>).tokenTotal as number } : {}),
177
+ } as WorkflowLifecycleResult
178
+ })
179
+ }
180
+
181
+ return { spawn, runLifecycle }
182
+ }
@@ -0,0 +1,493 @@
1
+ // SPEC-6-3 §6/§7 — session-scoped workflow controller. Owns starts, background completion,
2
+ // ResultsInbox delivery, atomic Save-as, and the control state machine (pause/resume/stop/
3
+ // respondToCheckpoint). index.ts (Task 13) just constructs this.
4
+ import type { WorkflowRunStore } from "./run-store.ts"
5
+ import type { WorkflowJournal } from "../journal.ts"
6
+ import type { ResultsInbox, RunResult } from "../../runtime/results-inbox.ts"
7
+ import type {
8
+ WorkflowRunDeps,
9
+ WorkflowRunOpts,
10
+ WorkflowRunResult,
11
+ } from "../runner.ts"
12
+ import type {
13
+ WorkflowStartInput,
14
+ WorkflowStartReceipt,
15
+ WorkflowRunState,
16
+ WorkflowSaveInput,
17
+ WorkflowProgressEvent,
18
+ } from "./types.ts"
19
+ import type { WorkflowDef, WorkflowRegistry } from "../registry.ts"
20
+ import { parseWorkflowSource } from "../source.ts"
21
+ import { saveWorkflowAtomic } from "./save.ts"
22
+ import { discoverWorkflows } from "../registry.ts"
23
+ import { PauseGate } from "./pause-gate.ts"
24
+ import { hydrateWorkflowRuns } from "./hydrate.ts"
25
+
26
+ export interface WorkflowControllerDeps {
27
+ registry: WorkflowRegistry
28
+ projectDir: string
29
+ store: WorkflowRunStore
30
+ journal: WorkflowJournal
31
+ runWorkflow: (
32
+ script: string,
33
+ opts: WorkflowRunOpts,
34
+ deps: WorkflowRunDeps,
35
+ ) => Promise<WorkflowRunResult>
36
+ runDepsFactory: (runId: string) => WorkflowRunDeps
37
+ inbox: ResultsInbox
38
+ genRunId: () => string
39
+ notify: (msg: string, level?: "info" | "warning" | "error") => void
40
+ }
41
+
42
+ const SUMMARY_BOUND = 500
43
+
44
+ function safeSummary(value: unknown): string {
45
+ let text: string
46
+ try {
47
+ text = JSON.stringify(value) ?? String(value)
48
+ } catch {
49
+ text = String(value)
50
+ }
51
+ return text.slice(0, SUMMARY_BOUND)
52
+ }
53
+
54
+ interface RunControls {
55
+ abort: AbortController
56
+ gate: PauseGate
57
+ checkpointResolver: ((response: unknown) => void) | undefined
58
+ sourceText: string | undefined
59
+ executable: string
60
+ input: WorkflowStartInput
61
+ }
62
+
63
+ export class WorkflowController {
64
+ private readonly active = new Map<string, Promise<WorkflowRunResult>>()
65
+ private readonly controls = new Map<string, RunControls>()
66
+
67
+ constructor(private readonly deps: WorkflowControllerDeps) {}
68
+
69
+ definitions(): WorkflowDef[] {
70
+ return this.deps.registry.list()
71
+ }
72
+
73
+ runs(): WorkflowRunState[] {
74
+ return this.deps.store.values()
75
+ }
76
+
77
+ getRun(runId: string): WorkflowRunState | undefined {
78
+ return this.deps.store.get(runId)
79
+ }
80
+
81
+ hydrate(): void {
82
+ hydrateWorkflowRuns(this.deps.journal, this.deps.store)
83
+ }
84
+
85
+ start(
86
+ input: WorkflowStartInput,
87
+ _ctx?: { signal?: AbortSignal },
88
+ ): Promise<WorkflowStartReceipt | WorkflowRunResult> {
89
+ return this.startInternal(input)
90
+ }
91
+
92
+ private async startInternal(
93
+ input: WorkflowStartInput,
94
+ ): Promise<WorkflowStartReceipt | WorkflowRunResult> {
95
+ const hasScript = input.script !== undefined
96
+ const hasName = input.workflowName !== undefined
97
+
98
+ if (hasScript && hasName) {
99
+ throw new Error("provide exactly one of script or workflowName")
100
+ }
101
+ if (!hasScript && !hasName) {
102
+ throw new Error("exactly one of script or workflowName is required")
103
+ }
104
+
105
+ const runId = this.deps.genRunId()
106
+ const background = input.background !== false
107
+
108
+ let executable: string
109
+ let sourceText: string | undefined
110
+ let displayName: string
111
+
112
+ if (input.script !== undefined) {
113
+ if (input.name) {
114
+ this.save({ name: input.name, source: input.script })
115
+ }
116
+ const parsed = parseWorkflowSource(input.script, {
117
+ filePath: `${input.name ?? runId}.js`,
118
+ requireMeta: false,
119
+ })
120
+ executable = parsed.executable
121
+ sourceText = parsed.source
122
+ displayName = input.name ?? runId
123
+ } else if (input.workflowName !== undefined) {
124
+ const def = this.deps.registry.get(input.workflowName)
125
+ if (!def) {
126
+ const available = this.deps.registry.list().map((w) => w.name).join(", ")
127
+ throw new Error(
128
+ `workflow '${input.workflowName}' not found; available: ${available}`,
129
+ )
130
+ }
131
+ executable = def.executable
132
+ sourceText = def.sourceText
133
+ displayName = input.workflowName
134
+ } else {
135
+ throw new Error("exactly one of script or workflowName is required")
136
+ }
137
+
138
+ const now = Date.now()
139
+ const state: WorkflowRunState = {
140
+ runId,
141
+ name: displayName,
142
+ script: sourceText ?? executable,
143
+ ...(input.args !== undefined ? { args: input.args } : {}),
144
+ mode: input.mode,
145
+ status: "running",
146
+ startedAt: now,
147
+ currentPhase: "default",
148
+ phases: [],
149
+ childRunIds: [],
150
+ logs: [],
151
+ tokenTotal: 0,
152
+ costTotal: 0,
153
+ ...(input.resumeFromRunId ? { resumeFromRunId: input.resumeFromRunId } : {}),
154
+ }
155
+ this.deps.store.set(runId, state)
156
+
157
+ // Per-run controls: AbortController + PauseGate + optional checkpoint resolver.
158
+ const abort = new AbortController()
159
+ const gate = new PauseGate()
160
+
161
+ const onProgress = (event: WorkflowProgressEvent): void => {
162
+ this.onProgress(runId, event)
163
+ }
164
+
165
+ // The controls object is stored in the map and mutated by onCheckpoint/respondToCheckpoint.
166
+ const controls: RunControls = {
167
+ abort,
168
+ gate,
169
+ checkpointResolver: undefined,
170
+ sourceText,
171
+ executable,
172
+ input,
173
+ }
174
+ this.controls.set(runId, controls)
175
+
176
+ // Build runDeps: base from factory + runtime hooks merged on top.
177
+ const baseRunDeps = this.deps.runDepsFactory(runId)
178
+ const runDeps: WorkflowRunDeps = {
179
+ ...baseRunDeps,
180
+ runtime: {
181
+ signal: abort.signal,
182
+ waitIfPaused: () => gate.wait(abort.signal),
183
+ onProgress,
184
+ },
185
+ ...(input.mode === "checkpointed"
186
+ ? {
187
+ onCheckpoint: (prompt: string, opts: Record<string, unknown>) => {
188
+ return new Promise<unknown>((resolve) => {
189
+ controls.checkpointResolver = resolve
190
+ const existing = this.deps.store.get(runId)
191
+ if (existing) {
192
+ this.deps.store.set(runId, {
193
+ ...existing,
194
+ status: "checkpoint",
195
+ checkpoint: { prompt, opts },
196
+ })
197
+ }
198
+ })
199
+ },
200
+ }
201
+ : {}),
202
+ }
203
+
204
+ const runOpts: WorkflowRunOpts = {
205
+ script: executable,
206
+ ...(sourceText ? { sourceText } : {}),
207
+ ...(input.args !== undefined ? { args: input.args } : {}),
208
+ runId,
209
+ ...(input.resumeFromRunId ? { resumeFromRunId: input.resumeFromRunId } : {}),
210
+ mode: input.mode,
211
+ ...(input.tokenBudget !== undefined ? { budget: { total: input.tokenBudget } } : {}),
212
+ ...(input.maxAgents !== undefined ? { maxAgents: input.maxAgents } : {}),
213
+ ...(input.concurrency !== undefined ? { concurrency: input.concurrency } : {}),
214
+ ...(input.agentRetries !== undefined ? { agentRetries: input.agentRetries } : {}),
215
+ ...(input.agentTimeoutMs !== undefined ? { agentTimeoutMs: input.agentTimeoutMs } : {}),
216
+ }
217
+
218
+ if (background) {
219
+ const promise = this.execute(state, runOpts, runDeps)
220
+ this.active.set(runId, promise)
221
+ void promise.finally(() => {
222
+ this.active.delete(runId)
223
+ // Keep controls until after terminal so stop() can reach them;
224
+ // but if the run settled naturally, clean up.
225
+ const ctrl = this.controls.get(runId)
226
+ if (ctrl && !ctrl.abort.signal.aborted) {
227
+ this.controls.delete(runId)
228
+ }
229
+ })
230
+ return { runId, status: "background" }
231
+ }
232
+
233
+ return this.execute(state, runOpts, runDeps)
234
+ }
235
+
236
+ private async execute(
237
+ state: WorkflowRunState,
238
+ runOpts: WorkflowRunOpts,
239
+ runDeps: WorkflowRunDeps,
240
+ ): Promise<WorkflowRunResult> {
241
+ try {
242
+ const result = await this.deps.runWorkflow(runOpts.script, runOpts, runDeps)
243
+ this.onTerminal(result, state)
244
+ return result
245
+ } catch (e) {
246
+ const result: WorkflowRunResult = {
247
+ runId: state.runId,
248
+ status: "aborted",
249
+ error: (e as Error).message,
250
+ phases: [],
251
+ childRunIds: [],
252
+ logs: [],
253
+ }
254
+ this.onTerminal(result, state)
255
+ return result
256
+ }
257
+ }
258
+
259
+ private onProgress(runId: string, event: WorkflowProgressEvent): void {
260
+ const existing = this.deps.store.get(runId)
261
+ if (!existing) return
262
+
263
+ // Don't overwrite terminal statuses set by onTerminal or stop().
264
+ if (
265
+ existing.status === "completed" ||
266
+ existing.status === "aborted" ||
267
+ existing.status === "failed"
268
+ ) {
269
+ return
270
+ }
271
+
272
+ // Don't overwrite control-set statuses (paused, checkpoint) with runner progress.
273
+ // Patch non-status fields from the snapshot but preserve the control status.
274
+ if (existing.status === "paused" || existing.status === "checkpoint") {
275
+ this.deps.store.set(runId, {
276
+ ...existing,
277
+ ...event.snapshot,
278
+ status: existing.status,
279
+ ...(existing.checkpoint ? { checkpoint: existing.checkpoint } : {}),
280
+ })
281
+ return
282
+ }
283
+
284
+ // For "running" status, patch with the runner's snapshot.
285
+ this.deps.store.set(runId, {
286
+ ...existing,
287
+ ...event.snapshot,
288
+ status: event.snapshot.status,
289
+ })
290
+ }
291
+
292
+ private onTerminal(result: WorkflowRunResult, state: WorkflowRunState): void {
293
+ const existing = this.deps.store.get(result.runId)
294
+ if (!existing) return
295
+
296
+ // Don't overwrite an aborted status set by stop() with a late completion.
297
+ if (existing.status === "aborted" && result.status === "completed") {
298
+ this.controls.delete(result.runId)
299
+ return
300
+ }
301
+
302
+ // Don't overwrite a checkpoint status — the run is awaiting a human response.
303
+ // The runner shouldn't reach terminal while blocked on a checkpoint, but defend anyway.
304
+ if (existing.status === "checkpoint") {
305
+ return
306
+ }
307
+
308
+ const status: WorkflowRunState["status"] =
309
+ result.status === "completed"
310
+ ? "completed"
311
+ : result.status === "aborted"
312
+ ? "aborted"
313
+ : "failed"
314
+
315
+ this.deps.store.set(result.runId, {
316
+ ...existing,
317
+ status,
318
+ endedAt: Date.now(),
319
+ ...(result.result !== undefined ? { result: result.result } : {}),
320
+ ...(result.error ? { error: result.error } : {}),
321
+ ...(result.costTotal !== undefined ? { costTotal: result.costTotal } : {}),
322
+ ...(result.tokenTotal !== undefined ? { tokenTotal: result.tokenTotal } : {}),
323
+ phases: result.phases,
324
+ childRunIds: result.childRunIds,
325
+ logs: result.logs,
326
+ })
327
+
328
+ const inboxResult: RunResult = {
329
+ runId: result.runId,
330
+ task: state.name,
331
+ status: result.status === "completed" ? "completed" : "failed",
332
+ summary: safeSummary(result.result ?? result.error ?? result.status),
333
+ paths: [],
334
+ completedAt: Date.now(),
335
+ }
336
+ this.deps.inbox.push(inboxResult)
337
+ this.controls.delete(result.runId)
338
+ }
339
+
340
+ // ── Control state machine ──
341
+
342
+ pause(runId: string): void {
343
+ const run = this.deps.store.get(runId)
344
+ if (!run) throw new Error(`cannot pause: run '${runId}' not found`)
345
+ const status = run.status
346
+ if (status === "paused") return // idempotent
347
+ if (status !== "running" && status !== "queued") {
348
+ throw new Error(`cannot pause run '${runId}' in status '${status}'`)
349
+ }
350
+ const ctrl = this.controls.get(runId)
351
+ if (ctrl) ctrl.gate.pause()
352
+ this.deps.store.set(runId, { ...run, status: "paused" })
353
+ }
354
+
355
+ resume(runId: string): Promise<WorkflowStartReceipt | WorkflowRunResult> {
356
+ const run = this.deps.store.get(runId)
357
+ if (!run) throw new Error(`cannot resume: run '${runId}' not found`)
358
+ const status = run.status
359
+
360
+ if (status === "running") return Promise.resolve({ runId, status: "background" as const })
361
+ if (status === "interrupted") {
362
+ // Start a NEW background run with original source + resumeFromRunId.
363
+ const ctrl = this.controls.get(runId)
364
+ const source = ctrl?.sourceText ?? run.script
365
+ return this.startInternal({
366
+ script: source,
367
+ mode: run.mode,
368
+ resumeFromRunId: runId,
369
+ })
370
+ }
371
+ if (status !== "paused") {
372
+ throw new Error(`cannot resume run '${runId}' in status '${status}'`)
373
+ }
374
+ const ctrl = this.controls.get(runId)
375
+ if (ctrl) ctrl.gate.resume()
376
+ this.deps.store.set(runId, { ...run, status: "running" })
377
+ return Promise.resolve({ runId, status: "background" as const })
378
+ }
379
+
380
+ async stop(runId: string): Promise<void> {
381
+ const run = this.deps.store.get(runId)
382
+ if (!run) throw new Error(`cannot stop: run '${runId}' not found`)
383
+ const status = run.status
384
+
385
+ if (status === "aborted") return // idempotent
386
+
387
+ if (
388
+ status !== "running" &&
389
+ status !== "queued" &&
390
+ status !== "paused" &&
391
+ status !== "checkpoint" &&
392
+ status !== "interrupted"
393
+ ) {
394
+ throw new Error(`cannot stop run '${runId}' in status '${status}'`)
395
+ }
396
+
397
+ const ctrl = this.controls.get(runId)
398
+
399
+ // Reject checkpoint waiters.
400
+ if (ctrl?.checkpointResolver) {
401
+ ctrl.checkpointResolver(undefined)
402
+ ctrl.checkpointResolver = undefined
403
+ }
404
+
405
+ // Abort the run signal (fires for live children).
406
+ if (ctrl) ctrl.abort.abort()
407
+
408
+ // Resume paused waiters so they observe the abort.
409
+ if (ctrl) ctrl.gate.resume()
410
+
411
+ // Set store status to aborted immediately.
412
+ this.deps.store.set(runId, { ...run, status: "aborted", endedAt: Date.now() })
413
+
414
+ // For interrupted runs (no live controller), journal the abort directly —
415
+ // the runner isn't executing and won't write wf:aborted itself.
416
+ if (!ctrl) {
417
+ this.deps.journal.append(runId, {
418
+ type: "wf:aborted",
419
+ runId,
420
+ reason: "stopped",
421
+ ts: Date.now(),
422
+ })
423
+ }
424
+
425
+ // Await settlement if there's an active promise.
426
+ await this.settled(runId)
427
+
428
+ // Clean up controls.
429
+ this.controls.delete(runId)
430
+ }
431
+
432
+ respondToCheckpoint(runId: string, response: unknown): void {
433
+ const run = this.deps.store.get(runId)
434
+ if (!run) throw new Error(`cannot respond to checkpoint: run '${runId}' not found`)
435
+ if (run.status !== "checkpoint") {
436
+ throw new Error(
437
+ `cannot respond to checkpoint for run '${runId}' in status '${run.status}'`,
438
+ )
439
+ }
440
+
441
+ const ctrl = this.controls.get(runId)
442
+ if (!ctrl || !ctrl.checkpointResolver) {
443
+ throw new Error(`no pending checkpoint for run '${runId}'`)
444
+ }
445
+
446
+ // Resolve the pending checkpoint Promise + clear pending state.
447
+ ctrl.checkpointResolver(response)
448
+ ctrl.checkpointResolver = undefined
449
+
450
+ // Restore running status.
451
+ this.deps.store.set(runId, { ...run, status: "running", checkpoint: undefined })
452
+ }
453
+
454
+ async settled(runId: string): Promise<WorkflowRunResult | undefined> {
455
+ const promise = this.active.get(runId)
456
+ if (promise) return promise
457
+ return undefined
458
+ }
459
+
460
+ save(input: WorkflowSaveInput): WorkflowDef {
461
+ const saved = saveWorkflowAtomic({ ...input, dir: this.deps.projectDir })
462
+ this.refreshRegistry()
463
+ return saved
464
+ }
465
+
466
+ editAndResume(
467
+ runId: string,
468
+ source: string,
469
+ modeOverride?: "auto" | "checkpointed",
470
+ ): Promise<WorkflowStartReceipt | WorkflowRunResult> {
471
+ const existing = this.deps.store.get(runId)
472
+ if (!existing) throw new Error(`run '${runId}' not found`)
473
+ return this.startInternal({
474
+ script: source,
475
+ name: existing.name !== existing.runId ? existing.name : undefined,
476
+ mode: modeOverride ?? existing.mode,
477
+ resumeFromRunId: runId,
478
+ })
479
+ }
480
+
481
+ private refreshRegistry(): void {
482
+ const result = discoverWorkflows({
483
+ projectDir: this.deps.projectDir,
484
+ globalDir: "",
485
+ builtinDir: "",
486
+ })
487
+ const existing = this.deps.registry.list()
488
+ const merged = new Map<string, WorkflowDef>()
489
+ for (const w of existing) merged.set(w.name, w)
490
+ for (const w of result.workflows.values()) merged.set(w.name, w)
491
+ this.deps.registry.replace([...merged.values()])
492
+ }
493
+ }