@osolmaz/pi-workflows 0.12.0 → 0.13.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 (257) hide show
  1. package/README.md +56 -31
  2. package/dist/builtins/autodoc.workflow.d.ts +4 -4
  3. package/dist/builtins/autoimplement.workflow.d.ts +797 -142
  4. package/dist/builtins/autoimplement.workflow.js +17 -105
  5. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  6. package/dist/builtins/autoplan.workflow.d.ts +126 -13
  7. package/dist/builtins/autoplan.workflow.js +200 -29
  8. package/dist/builtins/autoplan.workflow.js.map +1 -1
  9. package/dist/builtins/catalog.js +7 -5
  10. package/dist/builtins/catalog.js.map +1 -1
  11. package/dist/builtins/index.d.ts +4 -2
  12. package/dist/builtins/index.js +3 -1
  13. package/dist/builtins/index.js.map +1 -1
  14. package/dist/builtins/monitor.workflow.d.ts +56 -23
  15. package/dist/builtins/monitor.workflow.js +508 -329
  16. package/dist/builtins/monitor.workflow.js.map +1 -1
  17. package/dist/builtins/pi-agent-group.d.ts +72 -0
  18. package/dist/builtins/pi-agent-group.js +1087 -0
  19. package/dist/builtins/pi-agent-group.js.map +1 -0
  20. package/dist/builtins/plain-summary.workflow.d.ts +32 -0
  21. package/dist/builtins/plain-summary.workflow.js +134 -0
  22. package/dist/builtins/plain-summary.workflow.js.map +1 -0
  23. package/dist/builtins/plan-approval.workflow.d.ts +39 -5
  24. package/dist/builtins/plan-approval.workflow.js +92 -14
  25. package/dist/builtins/plan-approval.workflow.js.map +1 -1
  26. package/dist/builtins/plan-change.workflow.d.ts +449 -0
  27. package/dist/builtins/plan-change.workflow.js +256 -0
  28. package/dist/builtins/plan-change.workflow.js.map +1 -0
  29. package/dist/builtins/plan-presentation.js +2 -2
  30. package/dist/builtins/plan-presentation.js.map +1 -1
  31. package/dist/builtins/sanity-check.workflow.d.ts +50 -6
  32. package/dist/builtins/sanity-check.workflow.js +150 -28
  33. package/dist/builtins/sanity-check.workflow.js.map +1 -1
  34. package/dist/controllers/effects.d.ts +3 -2
  35. package/dist/controllers/effects.js +8 -1
  36. package/dist/controllers/effects.js.map +1 -1
  37. package/dist/controllers/index.d.ts +1 -1
  38. package/dist/controllers/index.js +1 -1
  39. package/dist/controllers/index.js.map +1 -1
  40. package/dist/controllers/manager.d.ts +1 -0
  41. package/dist/controllers/manager.js +41 -17
  42. package/dist/controllers/manager.js.map +1 -1
  43. package/dist/controllers/sqlite.d.ts +107 -85
  44. package/dist/controllers/sqlite.js +1611 -1486
  45. package/dist/controllers/sqlite.js.map +1 -1
  46. package/dist/controllers/store.d.ts +9 -8
  47. package/dist/controllers/store.js +1 -31
  48. package/dist/controllers/store.js.map +1 -1
  49. package/dist/controllers/types.d.ts +3 -0
  50. package/dist/controllers/workflow-engine-scheduler.d.ts +1 -1
  51. package/dist/controllers/workflow-engine-scheduler.js +3 -5
  52. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  53. package/dist/controllers/workflows.d.ts +2 -2
  54. package/dist/controllers/workflows.js +11 -10
  55. package/dist/controllers/workflows.js.map +1 -1
  56. package/dist/extension/controller-host.js +2 -2
  57. package/dist/extension/controller-host.js.map +1 -1
  58. package/dist/extension/decision-channels.d.ts +4 -2
  59. package/dist/extension/decision-channels.js +333 -146
  60. package/dist/extension/decision-channels.js.map +1 -1
  61. package/dist/extension/executor.d.ts +9 -2
  62. package/dist/extension/executor.js +89 -2
  63. package/dist/extension/executor.js.map +1 -1
  64. package/dist/extension/herdr-viewer.d.ts +0 -1
  65. package/dist/extension/herdr-viewer.js +0 -4
  66. package/dist/extension/herdr-viewer.js.map +1 -1
  67. package/dist/extension/index.js +209 -127
  68. package/dist/extension/index.js.map +1 -1
  69. package/dist/extension/recorder.d.ts +3 -10
  70. package/dist/extension/recorder.js +13 -22
  71. package/dist/extension/recorder.js.map +1 -1
  72. package/dist/extension/session-events.d.ts +2 -2
  73. package/dist/extension/step-message.d.ts +7 -2
  74. package/dist/extension/step-message.js +94 -2
  75. package/dist/extension/step-message.js.map +1 -1
  76. package/dist/extension/widget.js +29 -3
  77. package/dist/extension/widget.js.map +1 -1
  78. package/dist/host/rpc-executor.d.ts +3 -0
  79. package/dist/host/rpc-executor.js +2 -0
  80. package/dist/host/rpc-executor.js.map +1 -1
  81. package/dist/host/runner.d.ts +3 -6
  82. package/dist/host/runner.js +83 -32
  83. package/dist/host/runner.js.map +1 -1
  84. package/dist/render/graph-render.js +15 -10
  85. package/dist/render/graph-render.js.map +1 -1
  86. package/dist/state/database.d.ts +35 -0
  87. package/dist/state/database.js +287 -0
  88. package/dist/state/database.js.map +1 -0
  89. package/dist/state/index.d.ts +4 -0
  90. package/dist/state/index.js +5 -0
  91. package/dist/state/index.js.map +1 -0
  92. package/dist/state/json.d.ts +6 -0
  93. package/dist/state/json.js +38 -0
  94. package/dist/state/json.js.map +1 -0
  95. package/dist/state/mutation.d.ts +57 -0
  96. package/dist/state/mutation.js +255 -0
  97. package/dist/state/mutation.js.map +1 -0
  98. package/dist/state/schema.d.ts +6 -0
  99. package/dist/state/schema.js +473 -0
  100. package/dist/state/schema.js.map +1 -0
  101. package/dist/viewer/cli.d.ts +2 -2
  102. package/dist/viewer/cli.js +142 -122
  103. package/dist/viewer/cli.js.map +1 -1
  104. package/dist/viewer/render.d.ts +4 -4
  105. package/dist/viewer/render.js +9 -23
  106. package/dist/viewer/render.js.map +1 -1
  107. package/dist/viewer/session-reducer.d.ts +1 -1
  108. package/dist/viewer/session-reducer.js +1 -1
  109. package/dist/viewer/tui.d.ts +3 -3
  110. package/dist/viewer/tui.js +15 -13
  111. package/dist/viewer/tui.js.map +1 -1
  112. package/dist/viewer/watch.d.ts +2 -5
  113. package/dist/viewer/watch.js +13 -13
  114. package/dist/viewer/watch.js.map +1 -1
  115. package/dist/workflows/catalog.js +7 -2
  116. package/dist/workflows/catalog.js.map +1 -1
  117. package/dist/workflows/composition.js +20 -2
  118. package/dist/workflows/composition.js.map +1 -1
  119. package/dist/workflows/decision-presentation.d.ts +1 -1
  120. package/dist/workflows/decision-presentation.js +51 -38
  121. package/dist/workflows/decision-presentation.js.map +1 -1
  122. package/dist/workflows/definition.d.ts +6 -2
  123. package/dist/workflows/definition.js +17 -0
  124. package/dist/workflows/definition.js.map +1 -1
  125. package/dist/workflows/engine.d.ts +7 -7
  126. package/dist/workflows/engine.js +196 -115
  127. package/dist/workflows/engine.js.map +1 -1
  128. package/dist/workflows/errors.d.ts +16 -3
  129. package/dist/workflows/errors.js +18 -3
  130. package/dist/workflows/errors.js.map +1 -1
  131. package/dist/workflows/human-decision.d.ts +45 -8
  132. package/dist/workflows/human-decision.js +641 -274
  133. package/dist/workflows/human-decision.js.map +1 -1
  134. package/dist/workflows/index.d.ts +5 -6
  135. package/dist/workflows/index.js +4 -5
  136. package/dist/workflows/index.js.map +1 -1
  137. package/dist/workflows/progress.d.ts +1 -0
  138. package/dist/workflows/progress.js +15 -3
  139. package/dist/workflows/progress.js.map +1 -1
  140. package/dist/workflows/schema.js +27 -1
  141. package/dist/workflows/schema.js.map +1 -1
  142. package/dist/workflows/store.d.ts +83 -103
  143. package/dist/workflows/store.js +907 -951
  144. package/dist/workflows/store.js.map +1 -1
  145. package/dist/workflows/types.d.ts +88 -126
  146. package/docs/CONTROLLERS.md +10 -8
  147. package/docs/DEFERRED_TURNS.md +2 -2
  148. package/docs/DESIGN_PHILOSOPHY.md +4 -2
  149. package/docs/HUMAN_DECISIONS.md +36 -47
  150. package/docs/HUMAN_DECISION_PRESENTATIONS.md +14 -24
  151. package/docs/MONITOR.md +179 -237
  152. package/docs/SQLITE_STATE.md +219 -0
  153. package/docs/WORKFLOW_COMPOSITION.md +27 -26
  154. package/docs/WORKFLOW_STEP_MESSAGES.md +20 -18
  155. package/docs/WORKFLOW_UPDATES.md +10 -9
  156. package/docs/development.md +9 -9
  157. package/docs/live-replay-protocol.md +28 -37
  158. package/docs/plans/2026-08-16-workflow-updates-plan.md +1 -1
  159. package/docs/plans/2026-08-19-human-decision-gates-plan.md +1 -1
  160. package/docs/plans/2026-08-21-autoimplement-timeout-fallback-plan.md +1 -1
  161. package/docs/plans/2026-08-21-plan-change-approval-policy-plan.md +322 -0
  162. package/docs/plans/2026-08-21-sanity-check-plan.md +217 -95
  163. package/docs/plans/2026-08-22-goal-finishing-monitor-plan.md +200 -0
  164. package/docs/plans/2026-08-23-assistant-agent-completion-plan.md +701 -0
  165. package/docs/plans/2026-08-23-sqlite-state-plan.md +970 -0
  166. package/docs/plans/replayable-run-bundles-implementation-plan.md +2 -2
  167. package/docs/plans/session-event-replay-implementation-plan.md +1 -1
  168. package/docs/plans/tui-viewer-implementation-plan.md +1 -1
  169. package/docs/session-event-journal.md +63 -434
  170. package/docs/tui-viewer.md +13 -15
  171. package/docs/workflows.md +127 -53
  172. package/examples/workflows/approved-plan.workflow.ts +19 -46
  173. package/examples/workflows/plain-summary.workflow.ts +1 -0
  174. package/herdr-plugin.toml +1 -1
  175. package/package.json +7 -7
  176. package/plugins/herdr/viewer.mjs +1 -11
  177. package/schemas/human-decision-accepted-v1.schema.json +15 -3
  178. package/schemas/human-decision-continuation-v1.schema.json +10 -1
  179. package/schemas/human-decision-delivery-v1.schema.json +8 -0
  180. package/schemas/human-decision-receipt-v1.schema.json +8 -0
  181. package/schemas/human-decision-request-v1.schema.json +24 -4
  182. package/skills/autoimplement/SKILL.md +27 -0
  183. package/skills/autoplan/SKILL.md +5 -4
  184. package/skills/monitor/SKILL.md +91 -135
  185. package/skills/pi-workflows/SKILL.md +9 -8
  186. package/skills/sanity-check/SKILL.md +44 -0
  187. package/src/builtins/autoimplement.workflow.ts +19 -118
  188. package/src/builtins/autoplan.workflow.ts +290 -40
  189. package/src/builtins/catalog.ts +7 -5
  190. package/src/builtins/index.ts +18 -1
  191. package/src/builtins/monitor.workflow.ts +673 -382
  192. package/src/builtins/pi-agent-group.ts +1407 -0
  193. package/src/builtins/plain-summary.workflow.ts +185 -0
  194. package/src/builtins/plan-approval.workflow.ts +157 -24
  195. package/src/builtins/plan-change.workflow.ts +321 -0
  196. package/src/builtins/plan-presentation.ts +2 -2
  197. package/src/builtins/sanity-check.workflow.ts +248 -48
  198. package/src/controllers/effects.ts +7 -0
  199. package/src/controllers/index.ts +0 -6
  200. package/src/controllers/manager.ts +72 -32
  201. package/src/controllers/sqlite.ts +2420 -1948
  202. package/src/controllers/store.ts +17 -38
  203. package/src/controllers/types.ts +3 -0
  204. package/src/controllers/workflow-engine-scheduler.ts +4 -8
  205. package/src/controllers/workflows.ts +45 -21
  206. package/src/extension/controller-host.ts +1 -2
  207. package/src/extension/decision-channels.ts +456 -203
  208. package/src/extension/executor.ts +105 -2
  209. package/src/extension/herdr-viewer.ts +0 -5
  210. package/src/extension/index.ts +251 -151
  211. package/src/extension/recorder.ts +13 -22
  212. package/src/extension/session-events.ts +2 -2
  213. package/src/extension/step-message.ts +106 -4
  214. package/src/extension/widget.ts +32 -5
  215. package/src/host/rpc-executor.ts +4 -0
  216. package/src/host/runner.ts +93 -41
  217. package/src/render/graph-render.ts +12 -6
  218. package/src/state/database.ts +375 -0
  219. package/src/state/index.ts +29 -0
  220. package/src/state/json.ts +42 -0
  221. package/src/state/mutation.ts +435 -0
  222. package/src/state/schema.ts +475 -0
  223. package/src/viewer/cli.ts +151 -144
  224. package/src/viewer/render.ts +19 -30
  225. package/src/viewer/session-reducer.ts +1 -1
  226. package/src/viewer/tui.ts +18 -17
  227. package/src/viewer/watch.ts +13 -17
  228. package/src/workflows/catalog.ts +7 -2
  229. package/src/workflows/composition.ts +21 -3
  230. package/src/workflows/decision-presentation.ts +56 -43
  231. package/src/workflows/definition.ts +36 -3
  232. package/src/workflows/engine.ts +228 -130
  233. package/src/workflows/errors.ts +27 -3
  234. package/src/workflows/human-decision.ts +921 -349
  235. package/src/workflows/index.ts +20 -30
  236. package/src/workflows/progress.ts +18 -3
  237. package/src/workflows/schema.ts +36 -1
  238. package/src/workflows/store.ts +1300 -1163
  239. package/src/workflows/types.ts +100 -137
  240. package/dist/builtins/sanity-check-session.d.ts +0 -17
  241. package/dist/builtins/sanity-check-session.js +0 -168
  242. package/dist/builtins/sanity-check-session.js.map +0 -1
  243. package/dist/workflows/artifacts.d.ts +0 -40
  244. package/dist/workflows/artifacts.js +0 -155
  245. package/dist/workflows/artifacts.js.map +0 -1
  246. package/dist/workflows/migrate-sources.d.ts +0 -42
  247. package/dist/workflows/migrate-sources.js +0 -133
  248. package/dist/workflows/migrate-sources.js.map +0 -1
  249. package/docs/run-bundles.md +0 -481
  250. package/schemas/human-decision-accepted-v2.schema.json +0 -50
  251. package/schemas/human-decision-delivery-v2.schema.json +0 -36
  252. package/schemas/human-decision-receipt-v2.schema.json +0 -39
  253. package/schemas/human-decision-request-v2.schema.json +0 -69
  254. package/schemas/human-decision-resolution-v2.schema.json +0 -27
  255. package/src/builtins/sanity-check-session.ts +0 -205
  256. package/src/workflows/artifacts.ts +0 -188
  257. package/src/workflows/migrate-sources.ts +0 -178
@@ -1,6 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { isDeepStrictEqual } from "node:util";
3
- import { resolveArtifacts } from "./artifacts.js";
4
3
  import {
5
4
  compileWorkflowDefinition,
6
5
  compositionMetadata,
@@ -29,12 +28,14 @@ import {
29
28
  WorkflowRunStore,
30
29
  createDefinitionSnapshot,
31
30
  createRunId,
32
- readRunBundle,
33
31
  } from "./store.js";
34
32
  import type {
35
- AcceptedHumanDecision,
33
+ ResolvedHumanDecision,
34
+ AgentExpectedOutput,
36
35
  AgentNodeDefinition,
37
36
  AgentStepExecutor,
37
+ AssistantMessageOutput,
38
+ AssistantMessageReceipt,
38
39
  ActionNodeDefinition,
39
40
  CheckpointNodeDefinition,
40
41
  ConversationRange,
@@ -71,6 +72,7 @@ type NodeExecution = {
71
72
  output: unknown;
72
73
  promptText: string | null;
73
74
  action?: WorkflowActionReceipt;
75
+ assistantMessage?: AssistantMessageReceipt;
74
76
  conversation?: ConversationRange;
75
77
  };
76
78
 
@@ -89,11 +91,17 @@ type NodeAttempt = {
89
91
  error?: unknown;
90
92
  };
91
93
 
94
+ type ResumedNodeAttempt = {
95
+ nodeId: string;
96
+ attemptId: string;
97
+ startedAt: string;
98
+ };
99
+
92
100
  /**
93
101
  * Executes a workflow graph step by step. Agent steps are delegated to the
94
102
  * configured executor; compute/action/checkpoint nodes run inline. Every
95
- * state transition is persisted to the run bundle before the engine moves on,
96
- * so a live viewer can follow along by watching the bundle directory.
103
+ * state transition is persisted to the SQLite run state before the engine moves on,
104
+ * so a live viewer can follow committed SQLite events.
97
105
  */
98
106
  export class WorkflowEngine {
99
107
  private readonly executor: AgentStepExecutor;
@@ -107,7 +115,7 @@ export class WorkflowEngine {
107
115
  private activeAbort: AbortController | null = null;
108
116
  private activeAttempt:
109
117
  | {
110
- runDir: string;
118
+ runId: string;
111
119
  state: WorkflowRunState;
112
120
  nodeId: string;
113
121
  attemptId: string;
@@ -124,7 +132,7 @@ export class WorkflowEngine {
124
132
  constructor(options: WorkflowEngineOptions) {
125
133
  this.executor = options.executor;
126
134
  this.notificationSink = options.notificationSink;
127
- this.store = options.store ?? new WorkflowRunStore(options.outputRoot);
135
+ this.store = options.store ?? new WorkflowRunStore(options.databasePath);
128
136
  this.defaultNodeTimeoutMs = options.defaultNodeTimeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
129
137
  this.maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS;
130
138
  this.onEvent = options.onEvent;
@@ -132,8 +140,8 @@ export class WorkflowEngine {
132
140
  this.onRunFinishing = options.onRunFinishing;
133
141
  }
134
142
 
135
- get outputRoot(): string {
136
- return this.store.outputRoot;
143
+ get databasePath(): string {
144
+ return this.store.databasePath;
137
145
  }
138
146
 
139
147
  /** Publish a durable update for the currently active attempt without completing it. */
@@ -171,7 +179,7 @@ export class WorkflowEngine {
171
179
  }
172
180
  limiter.take();
173
181
  const { event, record } = await this.store.publishUpdate(
174
- active.runDir,
182
+ active.runId,
175
183
  active.state,
176
184
  active.nodeId,
177
185
  active.attemptId,
@@ -235,8 +243,7 @@ export class WorkflowEngine {
235
243
  ): Promise<WorkflowRunResult> {
236
244
  workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
237
245
  validateWorkflowDefinition(workflow);
238
- // Fail before any bundle exists so bad input cannot leave a partial run
239
- // on disk or silently change shape when state.json round-trips.
246
+ // Fail before any run row exists so bad input cannot leave partial state.
240
247
  const suppliedInput = input === undefined ? null : input;
241
248
  const normalizedInput = workflow.input ? await workflow.input(suppliedInput) : suppliedInput;
242
249
  assertJsonSerializable(normalizedInput, "Workflow run input");
@@ -253,8 +260,8 @@ export class WorkflowEngine {
253
260
  options.workflowSource,
254
261
  options.runId,
255
262
  );
256
- const runDir = await this.store.initializeRunBundle(workflow, state);
257
- await this.persist(runDir, state, {
263
+ const runId = await this.store.initializeRun(workflow, state);
264
+ await this.persist(runId, state, {
258
265
  scope: "run",
259
266
  type: "run_started",
260
267
  payload: {
@@ -266,18 +273,18 @@ export class WorkflowEngine {
266
273
  // Awaited so anything the hook writes (e.g. a session binding and its
267
274
  // `session_bound` event) lands before node events and can never trail
268
275
  // the terminal event of a fast run.
269
- await this.onRunStarted?.(runDir, state);
276
+ await this.onRunStarted?.(runId, state);
270
277
 
271
278
  try {
272
- await this.executeGraph(workflow, state, runDir);
279
+ await this.executeGraph(workflow, state, runId);
273
280
  } catch (error) {
274
281
  if (isRunParkedError(error) || this.parked) {
275
- return { runDir, state };
282
+ return { runId, state };
276
283
  }
277
- await this.finishAfterError(runDir, state, error);
278
- return { runDir, state };
284
+ await this.finishAfterError(runId, state, error);
285
+ return { runId, state };
279
286
  }
280
- return { runDir, state };
287
+ return { runId, state };
281
288
  }
282
289
 
283
290
  /**
@@ -297,76 +304,94 @@ export class WorkflowEngine {
297
304
  this.cancelled = false;
298
305
  this.paused = false;
299
306
  this.parked = false;
300
- const bundle = await this.store.prepareRunResume(runId);
301
- const { runDir } = bundle;
302
- const state = bundle.state;
307
+ const loaded = await this.store.prepareRunResume(runId);
308
+ const state = loaded.state;
303
309
  const sourceMismatch = workflowIdentityMismatch(state, workflow, options.workflowSource);
304
310
  if (sourceMismatch && options.force !== true) {
305
311
  throw new WorkflowSourceChangedError(runId);
306
312
  }
307
313
 
308
314
  const point = this.resumePointFor(workflow, state, "wait");
309
- // A resumed run starts unpaused; the operator can pause again. The
310
- // interrupted node's stale in-flight markers go away before the resume
311
- // event so the projection matches what the engine is about to do.
315
+ const interruptedNode =
316
+ state.currentNode !== undefined ? workflow.nodes[state.currentNode] : undefined;
317
+ const resumedAttempt: ResumedNodeAttempt | undefined =
318
+ state.currentNode !== undefined &&
319
+ state.currentAttemptId !== undefined &&
320
+ state.currentNodeStartedAt !== undefined &&
321
+ interruptedNode?.nodeType === "agent" &&
322
+ assistantMessageConfig(interruptedNode) !== undefined
323
+ ? {
324
+ nodeId: state.currentNode,
325
+ attemptId: state.currentAttemptId,
326
+ startedAt: state.currentNodeStartedAt,
327
+ }
328
+ : undefined;
329
+ // A resumed run starts unpaused. Submitted and non-agent nodes discard
330
+ // stale in-flight markers and start a new attempt. Assistant-message
331
+ // nodes keep their attempt id so the origin session can adopt an already
332
+ // visible response without showing it twice.
312
333
  delete state.paused;
313
- delete state.currentNode;
314
- delete state.currentAttemptId;
315
- delete state.currentNodeStartedAt;
316
- delete state.statusDetail;
317
- await this.persist(runDir, state, {
334
+ if (resumedAttempt === undefined) {
335
+ delete state.currentNode;
336
+ delete state.currentAttemptId;
337
+ delete state.currentNodeStartedAt;
338
+ delete state.statusDetail;
339
+ }
340
+ await this.persist(runId, state, {
318
341
  scope: "run",
319
342
  type: "run_resumed",
320
343
  payload: {
321
344
  ...(point.nodeId !== null ? { resumeAt: point.nodeId } : {}),
345
+ ...(resumedAttempt !== undefined ? { resumedAttemptId: resumedAttempt.attemptId } : {}),
322
346
  replayedSteps: state.steps.length,
323
347
  ...(sourceMismatch ? { workflowSourceMismatch: true, forced: true } : {}),
324
348
  },
325
349
  });
326
- await this.onRunStarted?.(runDir, state);
350
+ await this.onRunStarted?.(runId, state);
327
351
 
328
352
  if (point.nodeId === null) {
329
353
  // The last recorded transition already finished the graph; the crash
330
354
  // happened before the terminal event was written. A finished
331
355
  // checkpoint restores its waiting gate rather than completing.
332
356
  if (point.waitingOn !== undefined) {
333
- await this.finishRun(runDir, state, "waiting", {
357
+ await this.finishRun(runId, state, "waiting", {
334
358
  waitingOn: point.waitingOn,
335
359
  finalOutput: point.lastOutput,
336
360
  });
337
361
  } else if (point.failedResult === undefined) {
338
- await this.finishRun(runDir, state, "completed", { finalOutput: point.lastOutput });
362
+ await this.finishRun(runId, state, "completed", { finalOutput: point.lastOutput });
339
363
  } else {
340
364
  const timedOut = point.failedResult.outcome === "timed_out";
341
- await this.finishRun(runDir, state, timedOut ? "timed_out" : "failed", {
365
+ await this.finishRun(runId, state, timedOut ? "timed_out" : "failed", {
342
366
  error: point.failedResult.error ?? `Workflow node failed: ${point.failedResult.nodeId}`,
343
367
  });
344
368
  }
345
- return { runDir, state };
369
+ return { runId, state };
346
370
  }
347
371
 
348
372
  try {
349
373
  await this.executeGraph(
350
374
  workflow,
351
375
  state,
352
- runDir,
376
+ runId,
353
377
  point.nodeId,
354
378
  countExecutableSteps(workflow, state.steps),
355
379
  point.lastOutput,
380
+ resumedAttempt,
356
381
  );
357
382
  } catch (error) {
358
383
  if (isRunParkedError(error) || this.parked) {
359
- return { runDir, state };
384
+ return { runId, state };
360
385
  }
361
- await this.finishAfterError(runDir, state, error);
362
- return { runDir, state };
386
+ await this.finishAfterError(runId, state, error);
387
+ return { runId, state };
363
388
  }
364
- return { runDir, state };
389
+ return { runId, state };
365
390
  }
366
391
 
367
392
  /**
368
393
  * Start a continuation run from a checkpointed parent. The new run gets a
369
- * fresh bundle and trace, carries forward the parent's outputs, results,
394
+ * fresh run and event stream, carries forward the parent's outputs, results,
370
395
  * and step accounting, and continues routing after the checkpoint.
371
396
  */
372
397
  async continueRun(
@@ -377,7 +402,7 @@ export class WorkflowEngine {
377
402
  workflowSource?: WorkflowSource;
378
403
  runId?: string;
379
404
  force?: boolean;
380
- humanDecision?: AcceptedHumanDecision;
405
+ humanDecision?: ResolvedHumanDecision;
381
406
  } = {},
382
407
  ): Promise<WorkflowRunResult> {
383
408
  workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
@@ -385,7 +410,7 @@ export class WorkflowEngine {
385
410
  this.cancelled = false;
386
411
  this.paused = false;
387
412
  this.parked = false;
388
- const parent = await readRunBundle(this.store.runDirFor(parentRunId));
413
+ const parent = this.store.readRun(parentRunId);
389
414
  if (parent === null) {
390
415
  throw new Error(`Cannot continue from unreadable workflow run: ${parentRunId}`);
391
416
  }
@@ -412,22 +437,21 @@ export class WorkflowEngine {
412
437
  }
413
438
  const request = parent.state.finalOutput as HumanDecisionRequest;
414
439
  if (
415
- (request?.schema !== "pi-workflows.human-decision-request.v1" &&
416
- request?.schema !== "pi-workflows.human-decision-request.v2") ||
440
+ request?.schema !== "pi-workflows.human-decision-request.v1" ||
417
441
  request.decisionId !== options.humanDecision.decisionId ||
418
442
  request.requestDigest !== options.humanDecision.requestDigest
419
443
  ) {
420
444
  throw new Error("Accepted human decision does not match the waiting request");
421
445
  }
422
- const durableDecision = await new HumanDecisionStore(this.store.outputRoot).readAccepted(
423
- request.decisionId,
424
- );
446
+ const durableDecision = await new HumanDecisionStore(this.store.databasePath, {
447
+ state: this.store.state,
448
+ }).readResolved(request.decisionId);
425
449
  if (durableDecision === null || !isDeepStrictEqual(durableDecision, options.humanDecision)) {
426
450
  throw new Error("Accepted human decision does not match the durable decision record");
427
451
  }
428
452
  acceptedResponse = validateHumanDecisionResponse(request, durableDecision.response);
429
453
  acceptedNodeId = request.nodeId;
430
- normalizedInput = await resolveArtifacts(parent.state.input, parent.runDir);
454
+ normalizedInput = structuredClone(parent.state.input);
431
455
  } else {
432
456
  const suppliedInput = input === undefined ? null : input;
433
457
  normalizedInput = workflow.input ? await workflow.input(suppliedInput) : suppliedInput;
@@ -444,39 +468,26 @@ export class WorkflowEngine {
444
468
  options.runId,
445
469
  );
446
470
  state.parentRunId = parentRunId;
447
- // Artifact references point into the parent's bundle, so carried values
448
- // are fully resolved here and re-externalized into the new bundle.
449
- state.outputs = (await resolveArtifacts(
450
- parent.state.outputs,
451
- parent.runDir,
452
- )) as WorkflowRunState["outputs"];
453
- state.results = (await resolveArtifacts(
454
- parent.state.results,
455
- parent.runDir,
456
- )) as WorkflowRunState["results"];
457
- state.steps = (await resolveArtifacts(
458
- parent.state.steps,
459
- parent.runDir,
460
- )) as WorkflowRunState["steps"];
471
+ state.outputs = structuredClone(parent.state.outputs);
472
+ state.results = structuredClone(parent.state.results);
473
+ state.steps = structuredClone(parent.state.steps);
461
474
  if (humanContract !== undefined && options.humanDecision !== undefined) {
462
475
  const receipt = {
463
476
  decisionId: options.humanDecision.decisionId,
464
477
  requestDigest: options.humanDecision.requestDigest,
465
478
  nodeId: acceptedNodeId ?? waitingNodeId,
466
479
  response: options.humanDecision.response,
480
+ provenance: options.humanDecision.provenance,
467
481
  acceptedAt: options.humanDecision.acceptedAt,
468
482
  answerDigest: options.humanDecision.answerDigest,
469
483
  };
470
- state.humanDecision =
471
- options.humanDecision.schema === "pi-workflows.human-decision-accepted.v2"
472
- ? {
473
- schema: "pi-workflows.human-decision-receipt.v2",
474
- ...receipt,
475
- subjectDigest: options.humanDecision.subjectDigest,
476
- presentationDigest: options.humanDecision.presentationDigest,
477
- revision: options.humanDecision.revision,
478
- }
479
- : { schema: "pi-workflows.human-decision-receipt.v1", ...receipt };
484
+ state.humanDecision = {
485
+ schema: "pi-workflows.human-decision-receipt.v1",
486
+ ...receipt,
487
+ subjectDigest: options.humanDecision.subjectDigest,
488
+ presentationDigest: options.humanDecision.presentationDigest,
489
+ revision: options.humanDecision.revision,
490
+ };
480
491
  state.outputs[waitingNodeId] = acceptedResponse;
481
492
  const priorResult = state.results[waitingNodeId];
482
493
  if (priorResult === undefined) {
@@ -493,8 +504,8 @@ export class WorkflowEngine {
493
504
  }
494
505
  state.carriedStepCount = state.steps.length;
495
506
 
496
- const runDir = await this.store.initializeRunBundle(workflow, state);
497
- await this.persist(runDir, state, {
507
+ const runId = await this.store.initializeRun(workflow, state);
508
+ await this.persist(runId, state, {
498
509
  scope: "run",
499
510
  type: "run_started",
500
511
  payload: {
@@ -506,31 +517,31 @@ export class WorkflowEngine {
506
517
  carriedSteps: state.steps.length,
507
518
  },
508
519
  });
509
- await this.onRunStarted?.(runDir, state);
520
+ await this.onRunStarted?.(runId, state);
510
521
 
511
522
  const point = this.resumePointFor(workflow, state, "continue");
512
523
  if (point.nodeId === null) {
513
524
  // The checkpoint was the final node; the answer completes the chain.
514
- await this.finishRun(runDir, state, "completed", { finalOutput: point.lastOutput });
515
- return { runDir, state };
525
+ await this.finishRun(runId, state, "completed", { finalOutput: point.lastOutput });
526
+ return { runId, state };
516
527
  }
517
528
  try {
518
529
  await this.executeGraph(
519
530
  workflow,
520
531
  state,
521
- runDir,
532
+ runId,
522
533
  point.nodeId,
523
534
  countExecutableSteps(workflow, state.steps),
524
535
  point.lastOutput,
525
536
  );
526
537
  } catch (error) {
527
538
  if (isRunParkedError(error) || this.parked) {
528
- return { runDir, state };
539
+ return { runId, state };
529
540
  }
530
- await this.finishAfterError(runDir, state, error);
531
- return { runDir, state };
541
+ await this.finishAfterError(runId, state, error);
542
+ return { runId, state };
532
543
  }
533
- return { runDir, state };
544
+ return { runId, state };
534
545
  }
535
546
 
536
547
  /**
@@ -586,17 +597,17 @@ export class WorkflowEngine {
586
597
  }
587
598
 
588
599
  private async finishAfterError(
589
- runDir: string,
600
+ runId: string,
590
601
  state: WorkflowRunState,
591
602
  error: unknown,
592
603
  ): Promise<void> {
593
604
  const cancelled = this.cancelled || isAbortLikeError(error);
594
605
  try {
595
- await this.finishRun(runDir, state, cancelled ? "cancelled" : "failed", {
606
+ await this.finishRun(runId, state, cancelled ? "cancelled" : "failed", {
596
607
  error: errorMessage(error),
597
608
  });
598
609
  } catch (finishError) {
599
- // A fenced-out runner must not touch the bundle, including terminal
610
+ // A fenced-out runner must not touch run state, including terminal
600
611
  // projections. Propagate the claim loss instead of the node error.
601
612
  if (isClaimLostError(finishError)) {
602
613
  throw finishError;
@@ -664,10 +675,11 @@ export class WorkflowEngine {
664
675
  private async executeGraph(
665
676
  workflow: WorkflowDefinition,
666
677
  state: WorkflowRunState,
667
- runDir: string,
678
+ runId: string,
668
679
  startNodeId: string | null = workflow.startAt,
669
680
  executedStepsBase = 0,
670
681
  initialLastOutput?: unknown,
682
+ resumedAttempt?: ResumedNodeAttempt,
671
683
  ): Promise<void> {
672
684
  const maxSteps = workflow.maxSteps ?? this.maxSteps;
673
685
  const composition = compositionMetadata(workflow);
@@ -676,7 +688,7 @@ export class WorkflowEngine {
676
688
  let lastOutput: unknown = initialLastOutput;
677
689
 
678
690
  while (currentNodeId !== null) {
679
- await this.holdWhilePaused(state, runDir);
691
+ await this.holdWhilePaused(state, runId);
680
692
  const isTransition =
681
693
  composition?.entries[currentNodeId] !== undefined ||
682
694
  composition?.exits[currentNodeId] !== undefined;
@@ -695,7 +707,16 @@ export class WorkflowEngine {
695
707
  throw new Error(`Workflow node is missing: ${currentNodeId}`);
696
708
  }
697
709
 
698
- const attempt = await this.executeNode(workflow, state, runDir, currentNodeId, node);
710
+ const activeResume = resumedAttempt?.nodeId === currentNodeId ? resumedAttempt : undefined;
711
+ resumedAttempt = undefined;
712
+ const attempt = await this.executeNode(
713
+ workflow,
714
+ state,
715
+ runId,
716
+ currentNodeId,
717
+ node,
718
+ activeResume,
719
+ );
699
720
  if (this.parked) {
700
721
  // Do not record the aborted attempt: the projection keeps the node
701
722
  // as in-flight, and resume reruns it with a fresh attempt.
@@ -704,7 +725,7 @@ export class WorkflowEngine {
704
725
  this.recordAttempt(workflow, state, attempt);
705
726
  // The terminal node event carries the output, receipt, and conversation
706
727
  // linkage so the trace alone is sufficient to reconstruct the run.
707
- await this.persist(runDir, state, {
728
+ await this.persist(runId, state, {
708
729
  scope: "node",
709
730
  type: attempt.result.outcome === "ok" ? "node_finished" : "node_failed",
710
731
  nodeId: attempt.result.nodeId,
@@ -715,6 +736,9 @@ export class WorkflowEngine {
715
736
  ...(attempt.result.outcome === "ok" ? { output: attempt.result.output ?? null } : {}),
716
737
  ...(attempt.result.error !== undefined ? { error: attempt.result.error } : {}),
717
738
  ...(attempt.execution?.action !== undefined ? { action: attempt.execution.action } : {}),
739
+ ...(attempt.execution?.assistantMessage !== undefined
740
+ ? { assistantMessage: attempt.execution.assistantMessage }
741
+ : {}),
718
742
  ...(attempt.execution?.conversation !== undefined
719
743
  ? { conversation: attempt.execution.conversation }
720
744
  : {}),
@@ -729,7 +753,7 @@ export class WorkflowEngine {
729
753
  const entered = composition?.entries[attempt.result.nodeId];
730
754
  if (entered !== undefined) {
731
755
  const value = attempt.result.output as { invocation?: number } | undefined;
732
- await this.persist(runDir, state, {
756
+ await this.persist(runId, state, {
733
757
  scope: "run",
734
758
  type: "include_entered",
735
759
  payload: {
@@ -742,7 +766,7 @@ export class WorkflowEngine {
742
766
  const exited = composition?.exits[attempt.result.nodeId];
743
767
  if (exited !== undefined) {
744
768
  const entrySteps = state.steps.filter((step) => step.nodeId === exited.mountPath);
745
- await this.persist(runDir, state, {
769
+ await this.persist(runId, state, {
746
770
  scope: "run",
747
771
  type: "include_exited",
748
772
  payload: {
@@ -757,7 +781,7 @@ export class WorkflowEngine {
757
781
 
758
782
  lastOutput = attempt.result.output;
759
783
  if (node.nodeType === "checkpoint") {
760
- await this.finishRun(runDir, state, "waiting", {
784
+ await this.finishRun(runId, state, "waiting", {
761
785
  waitingOn: attempt.result.nodeId,
762
786
  finalOutput: lastOutput,
763
787
  });
@@ -771,14 +795,14 @@ export class WorkflowEngine {
771
795
  );
772
796
  }
773
797
 
774
- await this.finishRun(runDir, state, "completed", { finalOutput: lastOutput });
798
+ await this.finishRun(runId, state, "completed", { finalOutput: lastOutput });
775
799
  }
776
800
 
777
801
  /**
778
802
  * Hold the run at the step boundary while a pause is in effect. Pausing
779
803
  * never interrupts a node mid-flight; it only delays the next dispatch.
780
804
  */
781
- private async holdWhilePaused(state: WorkflowRunState, runDir: string): Promise<void> {
805
+ private async holdWhilePaused(state: WorkflowRunState, runId: string): Promise<void> {
782
806
  if (this.parked) {
783
807
  throw new RunParkedError();
784
808
  }
@@ -789,7 +813,7 @@ export class WorkflowEngine {
789
813
  return;
790
814
  }
791
815
  state.paused = true;
792
- await this.persist(runDir, state, { scope: "run", type: "run_paused", payload: {} });
816
+ await this.persist(runId, state, { scope: "run", type: "run_paused", payload: {} });
793
817
  while (this.paused && !this.cancelled && !this.parked) {
794
818
  await new Promise<void>((resolve) => {
795
819
  this.wakePause = resolve;
@@ -803,7 +827,7 @@ export class WorkflowEngine {
803
827
  if (this.cancelled) {
804
828
  throw new CancelledError();
805
829
  }
806
- await this.persist(runDir, state, { scope: "run", type: "run_resumed", payload: {} });
830
+ await this.persist(runId, state, { scope: "run", type: "run_resumed", payload: {} });
807
831
  }
808
832
 
809
833
  private routeAfterFailure(
@@ -856,6 +880,9 @@ export class WorkflowEngine {
856
880
  output: attempt.result.output ?? null,
857
881
  ...(attempt.result.error !== undefined ? { error: attempt.result.error } : {}),
858
882
  ...(attempt.execution?.action !== undefined ? { action: attempt.execution.action } : {}),
883
+ ...(attempt.execution?.assistantMessage !== undefined
884
+ ? { assistantMessage: attempt.execution.assistantMessage }
885
+ : {}),
859
886
  ...(attempt.execution?.conversation !== undefined
860
887
  ? { conversation: attempt.execution.conversation }
861
888
  : {}),
@@ -870,32 +897,35 @@ export class WorkflowEngine {
870
897
  private async executeNode(
871
898
  workflow: WorkflowDefinition,
872
899
  state: WorkflowRunState,
873
- runDir: string,
900
+ runId: string,
874
901
  nodeId: string,
875
902
  node: WorkflowNodeDefinition,
903
+ resumedAttempt?: ResumedNodeAttempt,
876
904
  ): Promise<NodeAttempt> {
877
- const attemptId = randomUUID();
878
- const startedAt = new Date().toISOString();
905
+ const attemptId = resumedAttempt?.attemptId ?? randomUUID();
906
+ const startedAt = resumedAttempt?.startedAt ?? new Date().toISOString();
879
907
  state.currentNode = nodeId;
880
908
  state.currentAttemptId = attemptId;
881
909
  state.currentNodeStartedAt = startedAt;
882
910
  if (node.statusDetail !== undefined) {
883
911
  state.statusDetail = node.statusDetail;
884
912
  }
885
- await this.persist(runDir, state, {
886
- scope: "node",
887
- type: "node_started",
888
- nodeId,
889
- attemptId,
890
- payload: { nodeType: node.nodeType },
891
- });
913
+ if (resumedAttempt === undefined) {
914
+ await this.persist(runId, state, {
915
+ scope: "node",
916
+ type: "node_started",
917
+ nodeId,
918
+ attemptId,
919
+ payload: { nodeType: node.nodeType },
920
+ });
921
+ }
892
922
 
893
923
  const meta: NodeExecutionMeta = { promptText: null };
894
924
  try {
895
925
  const execution = await this.runNodeWithTimeout(
896
926
  workflow,
897
927
  state,
898
- runDir,
928
+ runId,
899
929
  nodeId,
900
930
  attemptId,
901
931
  node,
@@ -958,7 +988,7 @@ export class WorkflowEngine {
958
988
  private async runNodeWithTimeout(
959
989
  workflow: WorkflowDefinition,
960
990
  state: WorkflowRunState,
961
- runDir: string,
991
+ runId: string,
962
992
  nodeId: string,
963
993
  attemptId: string,
964
994
  node: WorkflowNodeDefinition,
@@ -988,11 +1018,11 @@ export class WorkflowEngine {
988
1018
  abort.abort(new TimeoutError(timeoutMs));
989
1019
  }, timeoutMs);
990
1020
  }
991
- this.activeAttempt = { runDir, state, nodeId, attemptId, signal: abort.signal };
1021
+ this.activeAttempt = { runId, state, nodeId, attemptId, signal: abort.signal };
992
1022
  const dispatched = this.dispatchNode(
993
1023
  workflow,
994
1024
  state,
995
- runDir,
1025
+ runId,
996
1026
  nodeId,
997
1027
  attemptId,
998
1028
  node,
@@ -1009,7 +1039,7 @@ export class WorkflowEngine {
1009
1039
  const execution = await Promise.race([dispatched, abortRejection(abort.signal)]);
1010
1040
  if (execution.output === undefined) {
1011
1041
  // JSON cannot represent undefined; normalize so the in-memory state
1012
- // matches what the persisted bundle round-trips to.
1042
+ // matches what persisted canonical JSON round-trips to.
1013
1043
  execution.output = null;
1014
1044
  }
1015
1045
  assertJsonSerializable(execution.output, `Node ${nodeId} output`);
@@ -1073,7 +1103,7 @@ export class WorkflowEngine {
1073
1103
  private async dispatchNode(
1074
1104
  workflow: WorkflowDefinition,
1075
1105
  state: WorkflowRunState,
1076
- runDir: string,
1106
+ runId: string,
1077
1107
  nodeId: string,
1078
1108
  attemptId: string,
1079
1109
  node: WorkflowNodeDefinition,
@@ -1086,7 +1116,7 @@ export class WorkflowEngine {
1086
1116
  return await this.runAgentNode(
1087
1117
  workflow,
1088
1118
  state,
1089
- runDir,
1119
+ runId,
1090
1120
  nodeId,
1091
1121
  attemptId,
1092
1122
  node,
@@ -1144,7 +1174,7 @@ export class WorkflowEngine {
1144
1174
  private async runAgentNode(
1145
1175
  workflow: WorkflowDefinition,
1146
1176
  state: WorkflowRunState,
1147
- runDir: string,
1177
+ runId: string,
1148
1178
  nodeId: string,
1149
1179
  attemptId: string,
1150
1180
  node: AgentNodeDefinition,
@@ -1152,10 +1182,29 @@ export class WorkflowEngine {
1152
1182
  signal: AbortSignal,
1153
1183
  meta: NodeExecutionMeta,
1154
1184
  ): Promise<NodeExecution> {
1185
+ const assistant = assistantMessageConfig(node);
1186
+ if (assistant !== undefined) {
1187
+ if (this.executor.assistantMessageMode === "park") {
1188
+ state.statusDetail = "waiting for origin Pi session";
1189
+ await this.persist(runId, state, {
1190
+ scope: "agent",
1191
+ type: "agent_session_required",
1192
+ nodeId,
1193
+ attemptId,
1194
+ payload: { completion: "assistant" },
1195
+ });
1196
+ this.parked = true;
1197
+ throw new RunParkedError();
1198
+ }
1199
+ if (this.executor.assistantMessageMode !== "visible") {
1200
+ throw new Error("Assistant completion requires an origin Pi session");
1201
+ }
1202
+ }
1203
+
1155
1204
  const basePrompt = await node.prompt(context);
1156
1205
  if (signal.aborted) {
1157
1206
  // The node timed out or the run was cancelled while the async prompt
1158
- // builder ran; a late continuation must not write into a bundle that
1207
+ // builder ran; a late continuation must not write into a run that
1159
1208
  // may already be terminal.
1160
1209
  throw abortError(signal);
1161
1210
  }
@@ -1167,12 +1216,12 @@ export class WorkflowEngine {
1167
1216
  node.expectedOutput,
1168
1217
  );
1169
1218
  meta.promptText = prompt;
1170
- await this.persist(runDir, state, {
1219
+ await this.persist(runId, state, {
1171
1220
  scope: "agent",
1172
1221
  type: "agent_prompt_sent",
1173
1222
  nodeId,
1174
1223
  attemptId,
1175
- payload: { prompt },
1224
+ payload: { prompt, completion: assistant === undefined ? "submit" : "assistant" },
1176
1225
  });
1177
1226
 
1178
1227
  const submission = await this.executor.runAgentStep(
@@ -1182,7 +1231,11 @@ export class WorkflowEngine {
1182
1231
  workflowName: workflow.name,
1183
1232
  nodeId,
1184
1233
  attemptId,
1185
- ...(node.expectedOutput !== undefined ? { expectedOutput: node.expectedOutput } : {}),
1234
+ completion: assistant === undefined ? "submit" : "assistant",
1235
+ ...(typeof node.expectedOutput === "string"
1236
+ ? { expectedOutput: node.expectedOutput }
1237
+ : {}),
1238
+ ...(assistant?.maxChars !== undefined ? { maxOutputChars: assistant.maxChars } : {}),
1186
1239
  },
1187
1240
  prompt,
1188
1241
  ...(state.runTitle !== undefined || node.statusDetail !== undefined
@@ -1202,6 +1255,9 @@ export class WorkflowEngine {
1202
1255
  return {
1203
1256
  output: submission.output,
1204
1257
  promptText: prompt,
1258
+ ...(submission.assistantMessage !== undefined
1259
+ ? { assistantMessage: submission.assistantMessage }
1260
+ : {}),
1205
1261
  ...(submission.conversation !== undefined ? { conversation: submission.conversation } : {}),
1206
1262
  };
1207
1263
  }
@@ -1211,6 +1267,13 @@ export class WorkflowEngine {
1211
1267
  context: WorkflowNodeContext,
1212
1268
  output: unknown,
1213
1269
  ): Promise<{ ok: true; value: unknown } | { ok: false; error: string }> {
1270
+ if (assistantMessageConfig(node) !== undefined) {
1271
+ return {
1272
+ ok: false,
1273
+ error:
1274
+ "This step completes with a normal assistant response. Do not submit workflow output.",
1275
+ };
1276
+ }
1214
1277
  try {
1215
1278
  const normalized = normalizeAgentOutput(output);
1216
1279
  const validated = node.validate ? await node.validate(normalized, context) : normalized;
@@ -1245,11 +1308,11 @@ export class WorkflowEngine {
1245
1308
  }
1246
1309
 
1247
1310
  private async persist(
1248
- runDir: string,
1311
+ runId: string,
1249
1312
  state: WorkflowRunState,
1250
1313
  event: WorkflowTraceEventDraft,
1251
1314
  ): Promise<void> {
1252
- const traceEvent = await this.store.writeSnapshot(runDir, state, event);
1315
+ const traceEvent = await this.store.writeSnapshot(runId, state, event);
1253
1316
  try {
1254
1317
  this.onEvent?.(traceEvent, state);
1255
1318
  } catch {
@@ -1259,7 +1322,7 @@ export class WorkflowEngine {
1259
1322
  }
1260
1323
 
1261
1324
  private async finishRun(
1262
- runDir: string,
1325
+ runId: string,
1263
1326
  state: WorkflowRunState,
1264
1327
  status: WorkflowRunState["status"],
1265
1328
  fields: { error?: string; waitingOn?: string; finalOutput?: unknown },
@@ -1268,9 +1331,9 @@ export class WorkflowEngine {
1268
1331
  status = "timed_out";
1269
1332
  }
1270
1333
  // Let observers (e.g. the session recorder) stop and drain before the
1271
- // terminal event exists, so the bundle is immutable from that point on.
1334
+ // terminal event exists, so the terminal fact is immutable from that point on.
1272
1335
  try {
1273
- await this.onRunFinishing?.(runDir, state);
1336
+ await this.onRunFinishing?.(runId, state);
1274
1337
  } catch {
1275
1338
  // Finishing the run wins over observer failures.
1276
1339
  }
@@ -1288,7 +1351,7 @@ export class WorkflowEngine {
1288
1351
  delete state.currentNode;
1289
1352
  delete state.currentAttemptId;
1290
1353
  delete state.currentNodeStartedAt;
1291
- await this.persist(runDir, state, {
1354
+ await this.persist(runId, state, {
1292
1355
  scope: "run",
1293
1356
  type: `run_${status}`,
1294
1357
  payload: {
@@ -1317,6 +1380,10 @@ async function runCheckpointNode(
1317
1380
  typeof node.humanDecision.audience === "function"
1318
1381
  ? await node.humanDecision.audience(context)
1319
1382
  : node.humanDecision.audience;
1383
+ const timeout =
1384
+ typeof node.humanDecision.onTimeout === "function"
1385
+ ? await node.humanDecision.onTimeout(context)
1386
+ : node.humanDecision.onTimeout;
1320
1387
  const request = createHumanDecisionRequest({
1321
1388
  runId: context.state.runId,
1322
1389
  workflowName: execution.workflowName,
@@ -1324,8 +1391,11 @@ async function runCheckpointNode(
1324
1391
  attemptId: execution.attemptId,
1325
1392
  contract: { audience, choices: node.humanDecision.choices },
1326
1393
  prompt,
1394
+ ...(timeout !== undefined ? { timeout } : {}),
1327
1395
  });
1328
- await new HumanDecisionStore(execution.store.outputRoot).createRequest(request);
1396
+ await new HumanDecisionStore(execution.store.databasePath, {
1397
+ state: execution.store.state,
1398
+ }).createRequest(request);
1329
1399
  return { output: request, promptText: null };
1330
1400
  }
1331
1401
  const output = node.run ? await node.run(context) : { summary: node.summary ?? "checkpoint" };
@@ -1456,7 +1526,7 @@ function assertInvocationStepLimit(
1456
1526
  }
1457
1527
 
1458
1528
  /**
1459
- * Outputs are persisted to the run bundle, so they must be JSON-serializable.
1529
+ * Outputs are persisted to the SQLite run state, so they must be JSON-serializable.
1460
1530
  * Failing here turns a bad callback return value into a normal node failure
1461
1531
  * instead of corrupting the run state.
1462
1532
  */
@@ -1525,6 +1595,18 @@ function normalizeAgentOutput(output: unknown): unknown {
1525
1595
  }
1526
1596
  }
1527
1597
 
1598
+ function assistantMessageOutput(
1599
+ expectedOutput: AgentExpectedOutput | undefined,
1600
+ ): AssistantMessageOutput | undefined {
1601
+ return typeof expectedOutput === "object" && expectedOutput?.kind === "assistant-message"
1602
+ ? expectedOutput
1603
+ : undefined;
1604
+ }
1605
+
1606
+ function assistantMessageConfig(node: AgentNodeDefinition): AssistantMessageOutput | undefined {
1607
+ return assistantMessageOutput(node.expectedOutput);
1608
+ }
1609
+
1528
1610
  /**
1529
1611
  * The step contract appended to every agent-node prompt. This is the
1530
1612
  * documented standard for how the model completes a workflow step.
@@ -1534,8 +1616,24 @@ export function appendStepContract(
1534
1616
  workflowName: string,
1535
1617
  nodeId: string,
1536
1618
  attemptId: string,
1537
- expectedOutput: string | undefined,
1619
+ expectedOutput: AgentExpectedOutput | undefined,
1538
1620
  ): string {
1621
+ const assistant = assistantMessageOutput(expectedOutput);
1622
+ if (assistant !== undefined) {
1623
+ return [
1624
+ prompt.trimEnd(),
1625
+ "",
1626
+ "---",
1627
+ `Workflow step contract (workflow: ${workflowName}, step: ${nodeId}, attempt: ${attemptId})`,
1628
+ "",
1629
+ "Reply with a normal assistant message.",
1630
+ "Do not call the workflow tool to complete this step.",
1631
+ "Your visible reply becomes the workflow step output after the turn settles.",
1632
+ ...(assistant.maxChars !== undefined
1633
+ ? [`Keep the visible reply within ${assistant.maxChars} characters.`]
1634
+ : []),
1635
+ ].join("\n");
1636
+ }
1539
1637
  return [
1540
1638
  prompt.trimEnd(),
1541
1639
  "",
@@ -1546,7 +1644,7 @@ export function appendStepContract(
1546
1644
  `{"action": "update", "step": ${JSON.stringify(nodeId)}, "attempt": ${JSON.stringify(attemptId)}, "update": {"type": "...", "key": "...", "data": {...}}}`,
1547
1645
  "Complete this step by calling the `workflow` tool exactly once with:",
1548
1646
  `{"action": "submit", "step": ${JSON.stringify(nodeId)}, "attempt": ${JSON.stringify(attemptId)}, "output": <your result>}`,
1549
- `Expected output: ${expectedOutput ?? "a JSON object with your result"}`,
1647
+ `Expected output: ${typeof expectedOutput === "string" ? expectedOutput : "a JSON object with your result"}`,
1550
1648
  "The step is complete only after the workflow tool accepts the output.",
1551
1649
  "If the tool reports a validation error, correct the output and call it again.",
1552
1650
  ].join("\n");