@osolmaz/pi-workflows 0.14.0 → 0.15.0

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 (235) hide show
  1. package/README.md +78 -110
  2. package/dist/builtins/autoimplement.workflow.js +3 -1
  3. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  4. package/dist/builtins/change-verification.workflow.js +7 -2
  5. package/dist/builtins/change-verification.workflow.js.map +1 -1
  6. package/dist/builtins/metadata.d.ts +22 -0
  7. package/dist/builtins/metadata.js +10 -0
  8. package/dist/builtins/metadata.js.map +1 -0
  9. package/dist/builtins/monitor.workflow.js +4 -1
  10. package/dist/builtins/monitor.workflow.js.map +1 -1
  11. package/dist/builtins/sanity-check.workflow.js +4 -1
  12. package/dist/builtins/sanity-check.workflow.js.map +1 -1
  13. package/dist/builtins/workspace-preparation.workflow.js +3 -1
  14. package/dist/builtins/workspace-preparation.workflow.js.map +1 -1
  15. package/dist/controllers/index.d.ts +0 -1
  16. package/dist/controllers/index.js +0 -1
  17. package/dist/controllers/index.js.map +1 -1
  18. package/dist/controllers/sqlite.d.ts +51 -0
  19. package/dist/controllers/sqlite.js +433 -57
  20. package/dist/controllers/sqlite.js.map +1 -1
  21. package/dist/controllers/store.d.ts +1 -0
  22. package/dist/extension/controller-command.d.ts +22 -0
  23. package/dist/extension/controller-command.js +32 -0
  24. package/dist/extension/controller-command.js.map +1 -0
  25. package/dist/extension/index.d.ts +2 -9
  26. package/dist/extension/index.js +876 -3447
  27. package/dist/extension/index.js.map +1 -1
  28. package/dist/extension/message-card.d.ts +13 -0
  29. package/dist/extension/message-card.js +46 -0
  30. package/dist/extension/message-card.js.map +1 -0
  31. package/dist/extension/step-message.d.ts +5 -6
  32. package/dist/extension/step-message.js +41 -52
  33. package/dist/extension/step-message.js.map +1 -1
  34. package/dist/host/child-worker-supervisor.d.ts +50 -0
  35. package/dist/host/child-worker-supervisor.js +159 -0
  36. package/dist/host/child-worker-supervisor.js.map +1 -0
  37. package/dist/host/client.d.ts +48 -0
  38. package/dist/host/client.js +216 -0
  39. package/dist/host/client.js.map +1 -0
  40. package/dist/host/controller-worker-entry.d.ts +2 -0
  41. package/dist/host/controller-worker-entry.js +244 -0
  42. package/dist/host/controller-worker-entry.js.map +1 -0
  43. package/dist/host/controller-worker-protocol.d.ts +36 -0
  44. package/dist/host/controller-worker-protocol.js +49 -0
  45. package/dist/host/controller-worker-protocol.js.map +1 -0
  46. package/dist/host/controller-worker-supervisor.d.ts +21 -0
  47. package/dist/host/controller-worker-supervisor.js +54 -0
  48. package/dist/host/controller-worker-supervisor.js.map +1 -0
  49. package/dist/host/host-entry.d.ts +2 -0
  50. package/dist/host/host-entry.js +23 -0
  51. package/dist/host/host-entry.js.map +1 -0
  52. package/dist/host/processes.d.ts +17 -12
  53. package/dist/host/processes.js +154 -50
  54. package/dist/host/processes.js.map +1 -1
  55. package/dist/host/protocol.d.ts +38 -0
  56. package/dist/host/protocol.js +156 -0
  57. package/dist/host/protocol.js.map +1 -0
  58. package/dist/host/resolver-entry.d.ts +49 -0
  59. package/dist/host/resolver-entry.js +149 -0
  60. package/dist/host/resolver-entry.js.map +1 -0
  61. package/dist/host/rpc-executor.d.ts +14 -3
  62. package/dist/host/rpc-executor.js +63 -30
  63. package/dist/host/rpc-executor.js.map +1 -1
  64. package/dist/host/runner.d.ts +95 -29
  65. package/dist/host/runner.js +2311 -348
  66. package/dist/host/runner.js.map +1 -1
  67. package/dist/host/state.d.ts +174 -0
  68. package/dist/host/state.js +689 -0
  69. package/dist/host/state.js.map +1 -0
  70. package/dist/host/worker-entry.d.ts +10 -0
  71. package/dist/host/worker-entry.js +446 -0
  72. package/dist/host/worker-entry.js.map +1 -0
  73. package/dist/host/worker-protocol.d.ts +31 -0
  74. package/dist/host/worker-protocol.js +122 -0
  75. package/dist/host/worker-protocol.js.map +1 -0
  76. package/dist/host/worker-store.d.ts +80 -0
  77. package/dist/host/worker-store.js +91 -0
  78. package/dist/host/worker-store.js.map +1 -0
  79. package/dist/host/worker-supervisor.d.ts +22 -0
  80. package/dist/host/worker-supervisor.js +54 -0
  81. package/dist/host/worker-supervisor.js.map +1 -0
  82. package/dist/render/canvas.d.ts +2 -1
  83. package/dist/render/canvas.js +40 -15
  84. package/dist/render/canvas.js.map +1 -1
  85. package/dist/render/graph-render.d.ts +7 -3
  86. package/dist/render/graph-render.js +125 -74
  87. package/dist/render/graph-render.js.map +1 -1
  88. package/dist/state/database.js +3 -1
  89. package/dist/state/database.js.map +1 -1
  90. package/dist/state/index.d.ts +1 -0
  91. package/dist/state/index.js +1 -0
  92. package/dist/state/index.js.map +1 -1
  93. package/dist/state/schema.d.ts +1 -1
  94. package/dist/state/schema.js +171 -3
  95. package/dist/state/schema.js.map +1 -1
  96. package/dist/state/viewer.d.ts +46 -0
  97. package/dist/state/viewer.js +249 -0
  98. package/dist/state/viewer.js.map +1 -0
  99. package/dist/viewer/cli.d.ts +1 -1
  100. package/dist/viewer/cli.js +47 -18
  101. package/dist/viewer/cli.js.map +1 -1
  102. package/dist/workflows/composition.js +25 -2
  103. package/dist/workflows/composition.js.map +1 -1
  104. package/dist/workflows/definition.d.ts +3 -1
  105. package/dist/workflows/definition.js +25 -0
  106. package/dist/workflows/definition.js.map +1 -1
  107. package/dist/workflows/engine.d.ts +1 -0
  108. package/dist/workflows/engine.js +157 -42
  109. package/dist/workflows/engine.js.map +1 -1
  110. package/dist/workflows/errors.d.ts +3 -1
  111. package/dist/workflows/errors.js +4 -7
  112. package/dist/workflows/errors.js.map +1 -1
  113. package/dist/workflows/human-decision.d.ts +3 -0
  114. package/dist/workflows/human-decision.js +31 -0
  115. package/dist/workflows/human-decision.js.map +1 -1
  116. package/dist/workflows/index.d.ts +1 -1
  117. package/dist/workflows/index.js +1 -1
  118. package/dist/workflows/index.js.map +1 -1
  119. package/dist/workflows/schema.js +17 -6
  120. package/dist/workflows/schema.js.map +1 -1
  121. package/dist/{viewer → workflows}/session-reducer.d.ts +3 -1
  122. package/dist/{viewer → workflows}/session-reducer.js +4 -0
  123. package/dist/workflows/session-reducer.js.map +1 -0
  124. package/dist/workflows/store.d.ts +87 -1
  125. package/dist/workflows/store.js +892 -93
  126. package/dist/workflows/store.js.map +1 -1
  127. package/dist/workflows/tool-input.d.ts +0 -26
  128. package/dist/workflows/tool-input.js +2 -50
  129. package/dist/workflows/tool-input.js.map +1 -1
  130. package/dist/workflows/types.d.ts +36 -4
  131. package/docs/2026-08-30-out-of-process-workflow-host-plan.md +353 -0
  132. package/docs/CONTROLLERS.md +11 -11
  133. package/docs/DEFERRED_TURNS.md +23 -2
  134. package/docs/SQLITE_STATE.md +41 -31
  135. package/docs/WORKFLOW_HOST.md +452 -0
  136. package/docs/development.md +46 -30
  137. package/docs/live-replay-protocol.md +129 -100
  138. package/docs/plans/2026-08-27-workflow-terminal-restart-plan.md +42 -0
  139. package/docs/plans/2026-08-28-piw-incremental-viewer-plan.md +293 -0
  140. package/docs/plans/piw-viewer-experience-implementation-plan.md +11 -1
  141. package/docs/plans/tui-viewer-implementation-plan.md +5 -0
  142. package/docs/tui-viewer.md +18 -3
  143. package/docs/workflows.md +162 -210
  144. package/examples/workflows/command-batch.workflow.ts +2 -0
  145. package/examples/workflows/shell.workflow.ts +2 -1
  146. package/herdr-plugin.toml +1 -1
  147. package/package.json +1 -1
  148. package/src/builtins/autoimplement.workflow.ts +3 -0
  149. package/src/builtins/change-verification.workflow.ts +7 -2
  150. package/src/builtins/metadata.ts +9 -0
  151. package/src/builtins/monitor.workflow.ts +4 -0
  152. package/src/builtins/sanity-check.workflow.ts +4 -0
  153. package/src/builtins/workspace-preparation.workflow.ts +3 -1
  154. package/src/controllers/index.ts +0 -5
  155. package/src/controllers/sqlite.ts +709 -76
  156. package/src/controllers/store.ts +1 -0
  157. package/src/extension/controller-command.ts +45 -0
  158. package/src/extension/index.ts +944 -4163
  159. package/src/extension/message-card.ts +61 -0
  160. package/src/extension/step-message.ts +58 -63
  161. package/src/host/child-worker-supervisor.ts +183 -0
  162. package/src/host/client.ts +293 -0
  163. package/src/host/controller-worker-entry.ts +311 -0
  164. package/src/host/controller-worker-protocol.ts +104 -0
  165. package/src/host/controller-worker-supervisor.ts +79 -0
  166. package/src/host/host-entry.ts +23 -0
  167. package/src/host/processes.ts +171 -54
  168. package/src/host/protocol.ts +196 -0
  169. package/src/host/resolver-entry.ts +241 -0
  170. package/src/host/rpc-executor.ts +76 -34
  171. package/src/host/runner.ts +2813 -422
  172. package/src/host/state.ts +1160 -0
  173. package/src/host/worker-entry.ts +533 -0
  174. package/src/host/worker-protocol.ts +165 -0
  175. package/src/host/worker-store.ts +229 -0
  176. package/src/host/worker-supervisor.ts +74 -0
  177. package/src/render/canvas.ts +44 -10
  178. package/src/render/graph-render.ts +145 -90
  179. package/src/state/database.ts +2 -1
  180. package/src/state/index.ts +14 -0
  181. package/src/state/schema.ts +171 -3
  182. package/src/state/viewer.ts +356 -0
  183. package/src/viewer/cli.ts +49 -17
  184. package/src/workflows/composition.ts +36 -2
  185. package/src/workflows/definition.ts +32 -0
  186. package/src/workflows/engine.ts +157 -54
  187. package/src/workflows/errors.ts +11 -2
  188. package/src/workflows/human-decision.ts +49 -0
  189. package/src/workflows/index.ts +2 -0
  190. package/src/workflows/schema.ts +19 -6
  191. package/src/{viewer → workflows}/session-reducer.ts +11 -1
  192. package/src/workflows/store.ts +1316 -108
  193. package/src/workflows/tool-input.ts +3 -69
  194. package/src/workflows/types.ts +32 -4
  195. package/dist/controllers/workflow-engine-scheduler.d.ts +0 -29
  196. package/dist/controllers/workflow-engine-scheduler.js +0 -175
  197. package/dist/controllers/workflow-engine-scheduler.js.map +0 -1
  198. package/dist/extension/controller-host.d.ts +0 -48
  199. package/dist/extension/controller-host.js +0 -110
  200. package/dist/extension/controller-host.js.map +0 -1
  201. package/dist/extension/deferred-turn-coordinator.d.ts +0 -32
  202. package/dist/extension/deferred-turn-coordinator.js +0 -143
  203. package/dist/extension/deferred-turn-coordinator.js.map +0 -1
  204. package/dist/extension/deferred-turn.d.ts +0 -44
  205. package/dist/extension/deferred-turn.js +0 -110
  206. package/dist/extension/deferred-turn.js.map +0 -1
  207. package/dist/extension/executor.d.ts +0 -86
  208. package/dist/extension/executor.js +0 -311
  209. package/dist/extension/executor.js.map +0 -1
  210. package/dist/extension/follow-up-coordinator.d.ts +0 -27
  211. package/dist/extension/follow-up-coordinator.js +0 -131
  212. package/dist/extension/follow-up-coordinator.js.map +0 -1
  213. package/dist/extension/recorder.d.ts +0 -84
  214. package/dist/extension/recorder.js +0 -528
  215. package/dist/extension/recorder.js.map +0 -1
  216. package/dist/extension/restart-policy.d.ts +0 -38
  217. package/dist/extension/restart-policy.js +0 -116
  218. package/dist/extension/restart-policy.js.map +0 -1
  219. package/dist/extension/session-events.d.ts +0 -133
  220. package/dist/extension/session-events.js +0 -61
  221. package/dist/extension/session-events.js.map +0 -1
  222. package/dist/extension/terminal-decision.d.ts +0 -51
  223. package/dist/extension/terminal-decision.js +0 -110
  224. package/dist/extension/terminal-decision.js.map +0 -1
  225. package/dist/viewer/session-reducer.js.map +0 -1
  226. package/src/controllers/workflow-engine-scheduler.ts +0 -246
  227. package/src/extension/controller-host.ts +0 -167
  228. package/src/extension/deferred-turn-coordinator.ts +0 -171
  229. package/src/extension/deferred-turn.ts +0 -166
  230. package/src/extension/executor.ts +0 -411
  231. package/src/extension/follow-up-coordinator.ts +0 -151
  232. package/src/extension/recorder.ts +0 -655
  233. package/src/extension/restart-policy.ts +0 -163
  234. package/src/extension/session-events.ts +0 -121
  235. package/src/extension/terminal-decision.ts +0 -172
package/src/viewer/cli.ts CHANGED
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { SqliteControllerStore } from "../controllers/sqlite.js";
6
6
  import { syncHerdrPlugin } from "../herdr/setup.js";
7
+ import { WorkflowHostClient } from "../host/client.js";
7
8
  import { sanitizeText } from "../render/ansi.js";
8
9
  import { StateDatabase } from "../state/database.js";
9
10
  import { pruneState } from "../state/prune.js";
@@ -28,7 +29,10 @@ Usage:
28
29
  pi-workflows state backup <destination>
29
30
  pi-workflows state prune --before <timestamp> --dry-run
30
31
  pi-workflows state prune --before <timestamp> --backup <absolute-path> --apply
31
- pi-workflows host [--project <dir>] [-- <extra pi args>]
32
+ pi-workflows host start
33
+ pi-workflows host status
34
+ pi-workflows host stop
35
+ pi-workflows host run [-- <extra pi args>]
32
36
  pi-workflows herdr sync [--json]
33
37
  pi-workflows herdr setup [--json]
34
38
 
@@ -41,13 +45,13 @@ export type CliArgs = {
41
45
  controllerName?: string;
42
46
  resourceKey?: string;
43
47
  herdrAction?: string;
48
+ hostAction?: "start" | "status" | "stop" | "run";
44
49
  stateAction?: "status" | "verify" | "backup" | "prune";
45
50
  backupDestination?: string;
46
51
  pruneBefore?: string;
47
52
  pruneApply?: boolean;
48
53
  once: boolean;
49
54
  json: boolean;
50
- project?: string | undefined;
51
55
  piArgs?: string[] | undefined;
52
56
  };
53
57
 
@@ -80,7 +84,18 @@ export function parseCliArgs(argv: string[]): CliArgs {
80
84
  }
81
85
 
82
86
  if (command !== "herdr" && json) throw new Error("--json is available only for herdr sync");
83
- if (command === "host") return { command, once, json, project, piArgs };
87
+ if (command === "host") {
88
+ if (project !== undefined) throw new Error("The global host does not accept --project");
89
+ const action = positionals[0];
90
+ if (action !== "start" && action !== "status" && action !== "stop" && action !== "run") {
91
+ throw new Error("host requires start, status, stop, or run");
92
+ }
93
+ if (positionals.length !== 1) throw new Error("host accepts one lifecycle action");
94
+ if (piArgs.length > 0 && action !== "run") {
95
+ throw new Error("Extra Pi arguments are available only for host run");
96
+ }
97
+ return { command, hostAction: action, once, json, piArgs };
98
+ }
84
99
  if (command === "controller") {
85
100
  if (positionals.length !== 2) throw new Error("controller requires <controller> and <key>");
86
101
  return {
@@ -266,7 +281,9 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
266
281
  await runStateCommand(databasePath, args);
267
282
  return 0;
268
283
  }
269
- if (args.command === "host") return await runHost(args.project ?? process.cwd(), args.piArgs);
284
+ if (args.command === "host") {
285
+ return await runHost(args.hostAction as "start" | "status" | "stop" | "run", args.piArgs);
286
+ }
270
287
  if (args.command === "herdr") {
271
288
  const result = syncHerdrPlugin(packageRoot());
272
289
  process.stdout.write(args.json ? `${JSON.stringify(result)}\n` : `${result.message}\n`);
@@ -333,20 +350,35 @@ async function runStateCommand(databasePath: string, args: CliArgs): Promise<voi
333
350
  }
334
351
  }
335
352
 
336
- async function runHost(project: string, piArgs: string[] | undefined): Promise<number> {
337
- const { WorkflowHost } = await import("../host/runner.js");
338
- const host = new WorkflowHost({
339
- cwd: project,
340
- piArgs: piArgs ?? [],
341
- onLog: (message) => process.stdout.write(`[host] ${message}\n`),
342
- });
343
- await host.start();
344
- await new Promise<void>((resolve) => {
345
- const shutdown = () => void host.stop().then(resolve);
346
- process.once("SIGTERM", shutdown);
347
- process.once("SIGINT", shutdown);
353
+ async function runHost(
354
+ action: "start" | "status" | "stop" | "run",
355
+ piArgs: string[] | undefined,
356
+ ): Promise<number> {
357
+ if (action === "run") {
358
+ const { WorkflowHost } = await import("../host/runner.js");
359
+ const host = new WorkflowHost({
360
+ piArgs: piArgs ?? [],
361
+ onLog: (message) => process.stdout.write(`[host] ${message}\n`),
362
+ });
363
+ await host.start();
364
+ await new Promise<void>((resolve) => {
365
+ const shutdown = () => void host.stop().then(resolve);
366
+ process.once("SIGTERM", shutdown);
367
+ process.once("SIGINT", shutdown);
368
+ });
369
+ return 0;
370
+ }
371
+ const client = new WorkflowHostClient();
372
+ if (action === "start") {
373
+ const response = await client.ensureRunning();
374
+ process.stdout.write(`${JSON.stringify(response.receipt ?? {})}\n`);
375
+ return response.outcome === "accepted" || response.outcome === "adopted" ? 0 : 1;
376
+ }
377
+ const response = await client.request({
378
+ operation: action === "status" ? "host.status" : "host.stop",
348
379
  });
349
- return 0;
380
+ process.stdout.write(`${JSON.stringify(response.receipt ?? {})}\n`);
381
+ return response.outcome === "accepted" || response.outcome === "adopted" ? 0 : 1;
350
382
  }
351
383
 
352
384
  function openControllerStore(databasePath: string): SqliteControllerStore | undefined {
@@ -14,6 +14,7 @@ import type {
14
14
  WorkflowEdge,
15
15
  WorkflowExitDefinition,
16
16
  WorkflowIncludeDefinition,
17
+ WorkflowManagedEffect,
17
18
  WorkflowMountedSource,
18
19
  WorkflowMountSnapshot,
19
20
  WorkflowNodeContext,
@@ -539,10 +540,18 @@ function wrapNode(
539
540
  return {
540
541
  ...common,
541
542
  nodeType: "action",
542
- exec: (context) => shell.exec(project(context)),
543
+ ...(wrappedEffect(shell, project) === undefined
544
+ ? {}
545
+ : { effect: wrappedEffect(shell, project) }),
546
+ exec: (context) =>
547
+ shell.exec(projectActionContext(context, scopePath, scopes, entries, exits)),
543
548
  ...(shell.parse !== undefined
544
549
  ? {
545
- parse: (result, context) => shell.parse?.(result, project(context)),
550
+ parse: (result, context) =>
551
+ shell.parse?.(
552
+ result,
553
+ projectActionContext(context, scopePath, scopes, entries, exits),
554
+ ),
546
555
  }
547
556
  : {}),
548
557
  ...(shell.updates !== undefined
@@ -564,6 +573,9 @@ function wrapNode(
564
573
  return {
565
574
  ...common,
566
575
  nodeType: "action",
576
+ ...(wrappedEffect(node, project) === undefined
577
+ ? {}
578
+ : { effect: wrappedEffect(node, project) }),
567
579
  run: (context) =>
568
580
  (node as FunctionActionNodeDefinition).run(
569
581
  projectActionContext(context, scopePath, scopes, entries, exits),
@@ -572,6 +584,27 @@ function wrapNode(
572
584
  }
573
585
  }
574
586
 
587
+ function wrappedEffect(
588
+ node: ActionNodeDefinition,
589
+ project: (context: WorkflowNodeContext) => WorkflowNodeContext,
590
+ ): WorkflowManagedEffect | undefined {
591
+ if (node.effect === undefined) return undefined;
592
+ const idempotencyKey = node.effect.idempotencyKey;
593
+ const request = node.effect.request;
594
+ return {
595
+ type: node.effect.type,
596
+ recovery: node.effect.recovery,
597
+ idempotencyKey:
598
+ typeof idempotencyKey === "function"
599
+ ? (context: WorkflowNodeContext) => idempotencyKey(project(context))
600
+ : idempotencyKey,
601
+ request:
602
+ typeof request === "function"
603
+ ? (context: WorkflowNodeContext) => request(project(context))
604
+ : request,
605
+ };
606
+ }
607
+
575
608
  function projectActionContext(
576
609
  context: WorkflowActionContext,
577
610
  scopePath: string,
@@ -581,6 +614,7 @@ function projectActionContext(
581
614
  ): WorkflowActionContext {
582
615
  return {
583
616
  ...projectWorkflowContext(context, scopePath, scopes, entries, exits),
617
+ effect: context.effect,
584
618
  publishUpdate: context.publishUpdate,
585
619
  };
586
620
  }
@@ -25,6 +25,7 @@ import type {
25
25
  WorkflowIncludedResult,
26
26
  WorkflowIncludeMap,
27
27
  WorkflowInputOf,
28
+ WorkflowManagedEffect,
28
29
  WorkflowNodeContext,
29
30
  WorkflowNodeDefinition,
30
31
  WorkflowSettingsOf,
@@ -171,6 +172,37 @@ export function isWorkflowDefinition(value: unknown): value is WorkflowDefinitio
171
172
  );
172
173
  }
173
174
 
175
+ export function manualEffect(type: string): WorkflowManagedEffect {
176
+ return workflowEffect(type, "manual");
177
+ }
178
+
179
+ export function idempotentEffect(type: string): WorkflowManagedEffect {
180
+ return workflowEffect(type, "idempotent");
181
+ }
182
+
183
+ function workflowEffect(
184
+ type: string,
185
+ recovery: WorkflowManagedEffect["recovery"],
186
+ ): WorkflowManagedEffect {
187
+ const normalized = type.trim();
188
+ if (normalized.length === 0 || normalized.length > 256) {
189
+ throw new Error("Managed effect type must be nonempty text of at most 256 characters");
190
+ }
191
+ return {
192
+ type: normalized,
193
+ recovery,
194
+ idempotencyKey: ({ state }: WorkflowNodeContext) => {
195
+ const nodeId = state.currentNode;
196
+ if (nodeId === undefined) {
197
+ throw new Error("Managed effect resolution requires an active workflow node");
198
+ }
199
+ const nodeInvocation = state.steps.filter((step) => step.nodeId === nodeId).length + 1;
200
+ return `${state.runId}:${normalized}:${nodeId}:${nodeInvocation}`;
201
+ },
202
+ request: ({ input, outputs }: WorkflowNodeContext) => ({ input, outputs }),
203
+ };
204
+ }
205
+
174
206
  export function assistantMessage(options: { maxChars?: number } = {}): AssistantMessageOutput {
175
207
  if (options === null || typeof options !== "object" || Array.isArray(options)) {
176
208
  throw new Error("Invalid workflow definition: assistantMessage options must be an object");
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { isDeepStrictEqual } from "node:util";
3
- import { canonicalJson } from "../state/json.js";
3
+ import { canonicalJson, type JsonValue } from "../state/json.js";
4
4
  import { StaleResourceError } from "../state/mutation.js";
5
5
  import {
6
6
  compileWorkflowDefinition,
@@ -18,11 +18,7 @@ import {
18
18
  WorkflowSourceChangedError,
19
19
  } from "./errors.js";
20
20
  import { resolveNext, resolveNextForOutcome, validateWorkflowDefinition } from "./graph.js";
21
- import {
22
- HumanDecisionStore,
23
- createHumanDecisionRequest,
24
- validateHumanDecisionResponse,
25
- } from "./human-decision.js";
21
+ import { createHumanDecisionRequest, validateHumanDecisionResponse } from "./human-decision.js";
26
22
  import { extractJsonValue } from "./json.js";
27
23
  import {
28
24
  parseWorkflowSettingsValue,
@@ -35,6 +31,7 @@ import {
35
31
  WorkflowRunStore,
36
32
  createDefinitionSnapshot,
37
33
  createRunId,
34
+ type WorkflowExecutionStore,
38
35
  } from "./store.js";
39
36
  import type {
40
37
  ResolvedHumanDecision,
@@ -104,6 +101,7 @@ type ResumedNodeAttempt = {
104
101
  nodeId: string;
105
102
  attemptId: string;
106
103
  startedAt: string;
104
+ deadlineAt?: string | null;
107
105
  settings?: WorkflowSettingsScopeRecord;
108
106
  };
109
107
 
@@ -116,7 +114,7 @@ type ResumedNodeAttempt = {
116
114
  export class WorkflowEngine {
117
115
  private readonly executor: AgentStepExecutor;
118
116
  private readonly notificationSink: WorkflowNotificationSink | undefined;
119
- private readonly store: WorkflowRunStore;
117
+ private readonly store: WorkflowExecutionStore;
120
118
  private readonly defaultNodeTimeoutMs: number;
121
119
  private readonly maxSteps: number;
122
120
  private readonly onEvent?: WorkflowEngineOptions["onEvent"];
@@ -306,6 +304,7 @@ export class WorkflowEngine {
306
304
  try {
307
305
  await this.executeGraph(workflow, state, runId);
308
306
  } catch (error) {
307
+ if (isClaimLostError(error)) throw error;
309
308
  if (isRunParkedError(error) || this.parked) {
310
309
  return { runId, state };
311
310
  }
@@ -323,7 +322,11 @@ export class WorkflowEngine {
323
322
  async resumeRun(
324
323
  workflow: WorkflowDefinition,
325
324
  runId: string,
326
- options: { workflowSource?: WorkflowSource; force?: boolean } = {},
325
+ options: {
326
+ workflowSource?: WorkflowSource;
327
+ force?: boolean;
328
+ resumeInteractionAttemptId?: string;
329
+ } = {},
327
330
  ): Promise<WorkflowRunResult> {
328
331
  workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
329
332
  validateWorkflowDefinition(workflow);
@@ -349,25 +352,30 @@ export class WorkflowEngine {
349
352
  state.currentAttemptId !== undefined &&
350
353
  state.currentNodeStartedAt !== undefined &&
351
354
  interruptedNode?.nodeType === "agent" &&
352
- assistantMessageConfig(interruptedNode) !== undefined
355
+ (assistantMessageConfig(interruptedNode) !== undefined ||
356
+ options.resumeInteractionAttemptId === state.currentAttemptId)
353
357
  ) {
354
- const resumedSettings = this.persistedSettingsBinding(state);
358
+ const resumedSettings = await this.persistedSettingsBinding(state);
355
359
  resumedAttempt = {
356
360
  nodeId: state.currentNode,
357
361
  attemptId: state.currentAttemptId,
358
362
  startedAt: state.currentNodeStartedAt,
363
+ ...(state.currentNodeDeadlineAt === undefined
364
+ ? {}
365
+ : { deadlineAt: state.currentNodeDeadlineAt }),
359
366
  ...(resumedSettings !== undefined ? { settings: resumedSettings } : {}),
360
367
  };
361
368
  }
362
- // A resumed run starts unpaused. Submitted and non-agent nodes discard
363
- // stale in-flight markers and start a new attempt. Assistant-message
364
- // nodes keep their attempt id so the origin session can adopt an already
365
- // visible response without showing it twice.
369
+ // A resumed run starts unpaused. Submitted and non-agent nodes normally
370
+ // discard stale in-flight markers and start a new attempt. A submitted
371
+ // interaction and an assistant-message node keep their exact attempt id
372
+ // so durable origin-session work is never detached from its contract.
366
373
  delete state.paused;
367
374
  if (resumedAttempt === undefined) {
368
375
  delete state.currentNode;
369
376
  delete state.currentAttemptId;
370
377
  delete state.currentNodeStartedAt;
378
+ delete state.currentNodeDeadlineAt;
371
379
  delete state.currentSettingsScopeId;
372
380
  delete state.currentSettingsChangeNumber;
373
381
  delete state.currentSettingsHash;
@@ -416,6 +424,7 @@ export class WorkflowEngine {
416
424
  resumedAttempt,
417
425
  );
418
426
  } catch (error) {
427
+ if (isClaimLostError(error)) throw error;
419
428
  if (isRunParkedError(error) || this.parked) {
420
429
  return { runId, state };
421
430
  }
@@ -447,7 +456,7 @@ export class WorkflowEngine {
447
456
  this.cancelled = false;
448
457
  this.paused = false;
449
458
  this.parked = false;
450
- const parent = this.store.readRun(parentRunId);
459
+ const parent = await this.store.readRun(parentRunId);
451
460
  if (parent === null) {
452
461
  throw new Error(`Cannot continue from unreadable workflow run: ${parentRunId}`);
453
462
  }
@@ -480,9 +489,7 @@ export class WorkflowEngine {
480
489
  ) {
481
490
  throw new Error("Accepted human decision does not match the waiting request");
482
491
  }
483
- const durableDecision = await new HumanDecisionStore(this.store.databasePath, {
484
- state: this.store.state,
485
- }).readResolved(request.decisionId);
492
+ const durableDecision = await this.store.readResolvedHumanDecision(request.decisionId);
486
493
  if (durableDecision === null || !isDeepStrictEqual(durableDecision, options.humanDecision)) {
487
494
  throw new Error("Accepted human decision does not match the durable decision record");
488
495
  }
@@ -572,6 +579,7 @@ export class WorkflowEngine {
572
579
  point.lastOutput,
573
580
  );
574
581
  } catch (error) {
582
+ if (isClaimLostError(error)) throw error;
575
583
  if (isRunParkedError(error) || this.parked) {
576
584
  return { runId, state };
577
585
  }
@@ -992,6 +1000,7 @@ export class WorkflowEngine {
992
1000
  delete state.currentNode;
993
1001
  delete state.currentAttemptId;
994
1002
  delete state.currentNodeStartedAt;
1003
+ delete state.currentNodeDeadlineAt;
995
1004
  delete state.currentSettingsScopeId;
996
1005
  delete state.currentSettingsChangeNumber;
997
1006
  delete state.currentSettingsHash;
@@ -1055,6 +1064,7 @@ export class WorkflowEngine {
1055
1064
  node,
1056
1065
  meta,
1057
1066
  settings,
1067
+ resumedAttempt?.deadlineAt,
1058
1068
  );
1059
1069
  return {
1060
1070
  result: this.createNodeResult(nodeId, node, attemptId, startedAt, "ok", execution.output),
@@ -1062,6 +1072,7 @@ export class WorkflowEngine {
1062
1072
  ...(settings !== undefined ? { settings } : {}),
1063
1073
  };
1064
1074
  } catch (error) {
1075
+ if (isRunParkedError(error) || isClaimLostError(error)) throw error;
1065
1076
  const outcome = this.outcomeForError(error);
1066
1077
  return {
1067
1078
  result: {
@@ -1121,6 +1132,7 @@ export class WorkflowEngine {
1121
1132
  node: WorkflowNodeDefinition,
1122
1133
  meta: NodeExecutionMeta,
1123
1134
  settings?: WorkflowSettingsScopeRecord,
1135
+ persistedDeadlineAt?: string | null,
1124
1136
  ): Promise<NodeExecution> {
1125
1137
  const abort = new AbortController();
1126
1138
  const context = this.createNodeContext(state, abort.signal, settings);
@@ -1137,13 +1149,49 @@ export class WorkflowEngine {
1137
1149
  throw new CancelledError();
1138
1150
  }
1139
1151
 
1140
- const timeoutMs = await this.resolveNodeTimeout(node, context, abort);
1152
+ let timeoutMs: number | null;
1153
+ let timeoutErrorMs: number | null;
1154
+ if (persistedDeadlineAt === undefined) {
1155
+ timeoutMs = await this.resolveNodeTimeout(node, context, abort);
1156
+ timeoutErrorMs = timeoutMs;
1157
+ if (node.nodeType === "agent" && this.executor.preservesDeadlineWhileParked === true) {
1158
+ state.currentNodeDeadlineAt =
1159
+ timeoutMs === null ? null : new Date(Date.now() + timeoutMs).toISOString();
1160
+ await this.persist(runId, state, {
1161
+ scope: "node",
1162
+ type: "node_deadline_set",
1163
+ nodeId,
1164
+ attemptId,
1165
+ payload: { deadlineAt: state.currentNodeDeadlineAt },
1166
+ });
1167
+ if (state.currentNodeDeadlineAt !== null && timeoutErrorMs !== null) {
1168
+ timeoutMs = Date.parse(state.currentNodeDeadlineAt) - Date.now();
1169
+ if (timeoutMs <= 0) throw new TimeoutError(timeoutErrorMs);
1170
+ }
1171
+ } else {
1172
+ delete state.currentNodeDeadlineAt;
1173
+ }
1174
+ } else if (persistedDeadlineAt === null) {
1175
+ state.currentNodeDeadlineAt = null;
1176
+ timeoutMs = null;
1177
+ timeoutErrorMs = null;
1178
+ } else {
1179
+ const deadline = Date.parse(persistedDeadlineAt);
1180
+ const started = Date.parse(state.currentNodeStartedAt ?? persistedDeadlineAt);
1181
+ if (!Number.isFinite(deadline) || !Number.isFinite(started)) {
1182
+ throw new Error("Persisted node deadline is invalid");
1183
+ }
1184
+ state.currentNodeDeadlineAt = persistedDeadlineAt;
1185
+ timeoutMs = deadline - Date.now();
1186
+ timeoutErrorMs = Math.max(1, deadline - started);
1187
+ if (timeoutMs <= 0) throw new TimeoutError(timeoutErrorMs);
1188
+ }
1141
1189
  if (abort.signal.aborted) {
1142
1190
  throw abortError(abort.signal);
1143
1191
  }
1144
- if (timeoutMs !== null) {
1192
+ if (timeoutMs !== null && timeoutErrorMs !== null) {
1145
1193
  timer = setTimeout(() => {
1146
- abort.abort(new TimeoutError(timeoutMs));
1194
+ abort.abort(new TimeoutError(timeoutErrorMs));
1147
1195
  }, timeoutMs);
1148
1196
  }
1149
1197
  this.activeAttempt = {
@@ -1323,7 +1371,7 @@ export class WorkflowEngine {
1323
1371
  }
1324
1372
  if (definition === undefined) return undefined;
1325
1373
  const invocation = mountPath === "" ? 1 : currentMountInvocation(state, mountPath);
1326
- const existing = this.store.findSettingsScope(state.runId, mountPath, invocation);
1374
+ const existing = await this.store.findSettingsScope(state.runId, mountPath, invocation);
1327
1375
  if (existing !== undefined) return existing;
1328
1376
  const input = mountPath === "" ? state.input : currentMountInput(state, mountPath);
1329
1377
  const mappedSettings =
@@ -1331,7 +1379,7 @@ export class WorkflowEngine {
1331
1379
  const initial = mappedSettings.present
1332
1380
  ? await parseWorkflowSettingsValue(definition, mappedSettings.value)
1333
1381
  : await resolveInitialWorkflowSettings(definition, input);
1334
- return this.store.ensureSettingsScope({
1382
+ return await this.store.ensureSettingsScope({
1335
1383
  runId: state.runId,
1336
1384
  mountPath,
1337
1385
  invocation,
@@ -1339,9 +1387,9 @@ export class WorkflowEngine {
1339
1387
  });
1340
1388
  }
1341
1389
 
1342
- private persistedSettingsBinding(
1390
+ private async persistedSettingsBinding(
1343
1391
  state: WorkflowRunState,
1344
- ): WorkflowSettingsScopeRecord | undefined {
1392
+ ): Promise<WorkflowSettingsScopeRecord | undefined> {
1345
1393
  if (state.currentSettingsScopeId === undefined) return undefined;
1346
1394
  if (
1347
1395
  state.currentSettingsChangeNumber === undefined ||
@@ -1349,7 +1397,7 @@ export class WorkflowEngine {
1349
1397
  ) {
1350
1398
  throw new Error("Saved workflow settings binding is incomplete");
1351
1399
  }
1352
- const scope = this.store.getSettingsScopeAtChange(
1400
+ const scope = await this.store.getSettingsScopeAtChange(
1353
1401
  state.currentSettingsScopeId,
1354
1402
  state.currentSettingsChangeNumber,
1355
1403
  );
@@ -1508,16 +1556,78 @@ export class WorkflowEngine {
1508
1556
  signal: AbortSignal,
1509
1557
  meta: NodeExecutionMeta,
1510
1558
  ): Promise<NodeExecution> {
1559
+ /* istanbul ignore if -- graph validation rejects action nodes without managed effects */
1560
+ if (node.effect === undefined) {
1561
+ throw new Error(
1562
+ `Action node ${nodeId} must declare a managed effect with an explicit recovery policy`,
1563
+ );
1564
+ }
1565
+ const effectType = node.effect.type.trim();
1566
+ const idempotencyKey =
1567
+ typeof node.effect.idempotencyKey === "function"
1568
+ ? await node.effect.idempotencyKey(context)
1569
+ : node.effect.idempotencyKey;
1570
+ const request =
1571
+ typeof node.effect.request === "function"
1572
+ ? await node.effect.request(context)
1573
+ : node.effect.request;
1574
+ /* istanbul ignore if -- definition validation and effect helpers reject empty fields */
1575
+ if (effectType.length === 0 || idempotencyKey.trim().length === 0) {
1576
+ throw new Error("Managed effect type and idempotency key must be nonempty text");
1577
+ }
1578
+ canonicalJson(request);
1579
+ const reservation = await this.store.reserveEffect({
1580
+ runId: context.state.runId,
1581
+ attemptId,
1582
+ effectType,
1583
+ idempotencyKey,
1584
+ request: request as JsonValue,
1585
+ recovery: node.effect.recovery,
1586
+ });
1587
+ if (reservation.disposition === "ambiguous") throw new RunParkedError();
1588
+ if (reservation.disposition === "adopted") {
1589
+ const adopted = reservation.result;
1590
+ if (adopted === null || typeof adopted !== "object" || Array.isArray(adopted)) {
1591
+ throw new Error("Managed effect receipt is invalid");
1592
+ }
1593
+ const execution = adopted as NodeExecution;
1594
+ if (execution.action !== undefined) meta.action = execution.action;
1595
+ return execution;
1596
+ }
1511
1597
  const actionContext: WorkflowActionContext = {
1512
1598
  ...context,
1599
+ effect: { type: effectType, idempotencyKey, recovery: node.effect.recovery },
1513
1600
  publishUpdate: async (update) => await this.publishUpdate(nodeId, attemptId, update),
1514
1601
  };
1515
- if ("exec" in node) {
1516
- return await runShellActionNode(node, context, actionContext, signal, meta);
1602
+ try {
1603
+ let execution: NodeExecution;
1604
+ if ("exec" in node) {
1605
+ execution = await runShellActionNode(node, actionContext, signal, meta);
1606
+ } else {
1607
+ meta.action = { actionType: "function" };
1608
+ const output = await node.run(actionContext);
1609
+ execution = { output, promptText: null, action: { actionType: "function" } };
1610
+ }
1611
+ canonicalJson(execution);
1612
+ await this.store.settleEffect({
1613
+ runId: context.state.runId,
1614
+ effectId: reservation.effectId,
1615
+ attemptNumber: reservation.attemptNumber,
1616
+ outcome: "applied",
1617
+ result: execution as JsonValue,
1618
+ });
1619
+ return execution;
1620
+ } catch (error) {
1621
+ if (isRunParkedError(error) || isClaimLostError(error)) throw error;
1622
+ await this.store.settleEffect({
1623
+ runId: context.state.runId,
1624
+ effectId: reservation.effectId,
1625
+ attemptNumber: reservation.attemptNumber,
1626
+ outcome: "rejected",
1627
+ error: errorMessage(error),
1628
+ });
1629
+ throw error;
1517
1630
  }
1518
- meta.action = { actionType: "function" };
1519
- const output = await node.run(actionContext);
1520
- return { output, promptText: null, action: { actionType: "function" } };
1521
1631
  }
1522
1632
 
1523
1633
  private async persist(
@@ -1532,6 +1642,7 @@ export class WorkflowEngine {
1532
1642
  // Observers (UI updates, loggers) must never determine workflow
1533
1643
  // correctness; a throwing observer would otherwise fail the run.
1534
1644
  }
1645
+ await new Promise<void>((resolve) => setImmediate(resolve));
1535
1646
  }
1536
1647
 
1537
1648
  private async finishRun(
@@ -1543,13 +1654,6 @@ export class WorkflowEngine {
1543
1654
  if (status === "failed" && state.status === "timed_out") {
1544
1655
  status = "timed_out";
1545
1656
  }
1546
- // Let observers (e.g. the session recorder) stop and drain before the
1547
- // terminal event exists, so the terminal fact is immutable from that point on.
1548
- try {
1549
- await this.onRunFinishing?.(runId, state);
1550
- } catch {
1551
- // Finishing the run wins over observer failures.
1552
- }
1553
1657
  state.status = status;
1554
1658
  state.finishedAt = new Date().toISOString();
1555
1659
  if (fields.error !== undefined) {
@@ -1564,9 +1668,17 @@ export class WorkflowEngine {
1564
1668
  delete state.currentNode;
1565
1669
  delete state.currentAttemptId;
1566
1670
  delete state.currentNodeStartedAt;
1671
+ delete state.currentNodeDeadlineAt;
1567
1672
  delete state.currentSettingsScopeId;
1568
1673
  delete state.currentSettingsChangeNumber;
1569
1674
  delete state.currentSettingsHash;
1675
+ // Let observers stop, drain, and prepare terminal delivery from the final
1676
+ // in-memory state before the immutable terminal event is committed.
1677
+ try {
1678
+ await this.onRunFinishing?.(runId, state);
1679
+ } catch {
1680
+ // Finishing the run wins over observer failures.
1681
+ }
1570
1682
  await this.persist(runId, state, {
1571
1683
  scope: "run",
1572
1684
  type: `run_${status}`,
@@ -1585,7 +1697,7 @@ async function runCheckpointNode(
1585
1697
  node: CheckpointNodeDefinition,
1586
1698
  context: WorkflowNodeContext,
1587
1699
  execution: {
1588
- store: WorkflowRunStore;
1700
+ store: WorkflowExecutionStore;
1589
1701
  workflowName: string;
1590
1702
  nodeId: string;
1591
1703
  attemptId: string;
@@ -1610,9 +1722,7 @@ async function runCheckpointNode(
1610
1722
  prompt,
1611
1723
  ...(timeout !== undefined ? { timeout } : {}),
1612
1724
  });
1613
- await new HumanDecisionStore(execution.store.databasePath, {
1614
- state: execution.store.state,
1615
- }).createRequest(request);
1725
+ await execution.store.createHumanDecisionRequest(request);
1616
1726
  return { output: request, promptText: null };
1617
1727
  }
1618
1728
  const output = node.run ? await node.run(context) : { summary: node.summary ?? "checkpoint" };
@@ -1633,12 +1743,11 @@ function shellReceipt(result: ShellActionResult): WorkflowActionReceipt {
1633
1743
 
1634
1744
  async function runShellActionNode(
1635
1745
  node: ShellActionNodeDefinition,
1636
- context: WorkflowNodeContext,
1637
1746
  actionContext: WorkflowActionContext,
1638
1747
  signal: AbortSignal,
1639
1748
  meta: NodeExecutionMeta,
1640
1749
  ): Promise<NodeExecution> {
1641
- const spec = await node.exec(context);
1750
+ const spec = await node.exec(actionContext);
1642
1751
  const streams = new Set(node.updates?.streams ?? ["stdout"]);
1643
1752
  let result: ShellActionResult;
1644
1753
  try {
@@ -1664,7 +1773,7 @@ async function runShellActionNode(
1664
1773
  throw error;
1665
1774
  }
1666
1775
  meta.action = shellReceipt(result);
1667
- const output = node.parse ? await node.parse(result, context) : result;
1776
+ const output = node.parse ? await node.parse(result, actionContext) : result;
1668
1777
  return { output, promptText: null, action: shellReceipt(result) };
1669
1778
  }
1670
1779
 
@@ -1882,7 +1991,7 @@ function appendLiveControlInstructions(
1882
1991
  nodeId: string,
1883
1992
  context: WorkflowNodeContext,
1884
1993
  ): string {
1885
- const lines = [prompt.trimEnd(), "", "Workflow changes during this run:"];
1994
+ const lines = [prompt.trimEnd(), "", "Workflow context for this run:"];
1886
1995
  if (
1887
1996
  context.settingsScopeId !== undefined &&
1888
1997
  context.settingsChangeNumber !== undefined &&
@@ -1906,18 +2015,12 @@ function appendLiveControlInstructions(
1906
2015
  ? [`- Settings purpose: ${definition.description}`]
1907
2016
  : []),
1908
2017
  `- Current settings: ${summary}`,
1909
- `- Changes allowed from this model step: ${allowed || "none"}`,
1910
- "- Treat the current settings as authoritative for future behavior in this workflow scope.",
1911
- "- To change future workflow settings, use the workflow tool with action change-settings, this scopeId, an RFC 6902 patch, and optionally expectedChangeNumber.",
2018
+ `- Settings permissions: ${allowed || "none"}`,
2019
+ "- Treat the current settings as authoritative for this workflow scope.",
1912
2020
  );
1913
2021
  } else {
1914
2022
  lines.push("- This workflow scope has no editable settings.");
1915
2023
  }
1916
- lines.push(
1917
- "- To save normal user work for after successful completion, use workflow action queue-follow-up once for each prompt.",
1918
- "- To remove an unsent prompt that this model queued, use workflow action remove-follow-up with its followUpId.",
1919
- "- Setting changes affect only future node attempts and future settings routes. They do not change this running attempt or completed work.",
1920
- );
1921
2024
  return lines.join("\n");
1922
2025
  }
1923
2026