@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,13 +1,12 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { isDeepStrictEqual } from "node:util";
3
- import { resolveArtifacts } from "./artifacts.js";
4
3
  import { compileWorkflowDefinition, compositionMetadata, isCompiledWorkflow, } from "./composition.js";
5
4
  import { CancelledError, errorMessage, isAbortLikeError, isClaimLostError, isRunParkedError, RunParkedError, TimeoutError, WorkflowSourceChangedError, } from "./errors.js";
6
5
  import { resolveNext, resolveNextForOutcome, validateWorkflowDefinition } from "./graph.js";
7
6
  import { HumanDecisionStore, createHumanDecisionRequest, validateHumanDecisionResponse, } from "./human-decision.js";
8
7
  import { extractJsonValue } from "./json.js";
9
8
  import { runShellAction, shellResultFromError } from "./shell.js";
10
- import { RUN_STATE_SCHEMA, WorkflowRunStore, createDefinitionSnapshot, createRunId, readRunBundle, } from "./store.js";
9
+ import { RUN_STATE_SCHEMA, WorkflowRunStore, createDefinitionSnapshot, createRunId, } from "./store.js";
11
10
  import { UpdateRateLimiter, updateReceipt, validateWorkflowUpdate } from "./updates.js";
12
11
  const DEFAULT_NODE_TIMEOUT_MS = 15 * 60_000;
13
12
  const DEFAULT_MAX_STEPS = 100;
@@ -18,8 +17,8 @@ const ABORT_CLEANUP_GRACE_MS = 2_000;
18
17
  /**
19
18
  * Executes a workflow graph step by step. Agent steps are delegated to the
20
19
  * configured executor; compute/action/checkpoint nodes run inline. Every
21
- * state transition is persisted to the run bundle before the engine moves on,
22
- * so a live viewer can follow along by watching the bundle directory.
20
+ * state transition is persisted to the SQLite run state before the engine moves on,
21
+ * so a live viewer can follow committed SQLite events.
23
22
  */
24
23
  export class WorkflowEngine {
25
24
  executor;
@@ -41,15 +40,15 @@ export class WorkflowEngine {
41
40
  constructor(options) {
42
41
  this.executor = options.executor;
43
42
  this.notificationSink = options.notificationSink;
44
- this.store = options.store ?? new WorkflowRunStore(options.outputRoot);
43
+ this.store = options.store ?? new WorkflowRunStore(options.databasePath);
45
44
  this.defaultNodeTimeoutMs = options.defaultNodeTimeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
46
45
  this.maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS;
47
46
  this.onEvent = options.onEvent;
48
47
  this.onRunStarted = options.onRunStarted;
49
48
  this.onRunFinishing = options.onRunFinishing;
50
49
  }
51
- get outputRoot() {
52
- return this.store.outputRoot;
50
+ get databasePath() {
51
+ return this.store.databasePath;
53
52
  }
54
53
  /** Publish a durable update for the currently active attempt without completing it. */
55
54
  async publishUpdate(step, attempt, input, idempotencyKey) {
@@ -76,7 +75,7 @@ export class WorkflowEngine {
76
75
  this.updateLimiters.set(active.state.runId, limiter);
77
76
  }
78
77
  limiter.take();
79
- const { event, record } = await this.store.publishUpdate(active.runDir, active.state, active.nodeId, active.attemptId, update, { signal: active.signal });
78
+ const { event, record } = await this.store.publishUpdate(active.runId, active.state, active.nodeId, active.attemptId, update, { signal: active.signal });
80
79
  try {
81
80
  this.onEvent?.(event, active.state);
82
81
  }
@@ -126,8 +125,7 @@ export class WorkflowEngine {
126
125
  async run(workflow, input, options = {}) {
127
126
  workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
128
127
  validateWorkflowDefinition(workflow);
129
- // Fail before any bundle exists so bad input cannot leave a partial run
130
- // on disk or silently change shape when state.json round-trips.
128
+ // Fail before any run row exists so bad input cannot leave partial state.
131
129
  const suppliedInput = input === undefined ? null : input;
132
130
  const normalizedInput = workflow.input ? await workflow.input(suppliedInput) : suppliedInput;
133
131
  assertJsonSerializable(normalizedInput, "Workflow run input");
@@ -138,8 +136,8 @@ export class WorkflowEngine {
138
136
  this.paused = false;
139
137
  this.parked = false;
140
138
  const state = await this.createRunState(workflow, normalizedInput, options.workflowSource, options.runId);
141
- const runDir = await this.store.initializeRunBundle(workflow, state);
142
- await this.persist(runDir, state, {
139
+ const runId = await this.store.initializeRun(workflow, state);
140
+ await this.persist(runId, state, {
143
141
  scope: "run",
144
142
  type: "run_started",
145
143
  payload: {
@@ -151,18 +149,18 @@ export class WorkflowEngine {
151
149
  // Awaited so anything the hook writes (e.g. a session binding and its
152
150
  // `session_bound` event) lands before node events and can never trail
153
151
  // the terminal event of a fast run.
154
- await this.onRunStarted?.(runDir, state);
152
+ await this.onRunStarted?.(runId, state);
155
153
  try {
156
- await this.executeGraph(workflow, state, runDir);
154
+ await this.executeGraph(workflow, state, runId);
157
155
  }
158
156
  catch (error) {
159
157
  if (isRunParkedError(error) || this.parked) {
160
- return { runDir, state };
158
+ return { runId, state };
161
159
  }
162
- await this.finishAfterError(runDir, state, error);
163
- return { runDir, state };
160
+ await this.finishAfterError(runId, state, error);
161
+ return { runId, state };
164
162
  }
165
- return { runDir, state };
163
+ return { runId, state };
166
164
  }
167
165
  /**
168
166
  * Resume an interrupted run at the node it stopped on. The caller must
@@ -177,68 +175,83 @@ export class WorkflowEngine {
177
175
  this.cancelled = false;
178
176
  this.paused = false;
179
177
  this.parked = false;
180
- const bundle = await this.store.prepareRunResume(runId);
181
- const { runDir } = bundle;
182
- const state = bundle.state;
178
+ const loaded = await this.store.prepareRunResume(runId);
179
+ const state = loaded.state;
183
180
  const sourceMismatch = workflowIdentityMismatch(state, workflow, options.workflowSource);
184
181
  if (sourceMismatch && options.force !== true) {
185
182
  throw new WorkflowSourceChangedError(runId);
186
183
  }
187
184
  const point = this.resumePointFor(workflow, state, "wait");
188
- // A resumed run starts unpaused; the operator can pause again. The
189
- // interrupted node's stale in-flight markers go away before the resume
190
- // event so the projection matches what the engine is about to do.
185
+ const interruptedNode = state.currentNode !== undefined ? workflow.nodes[state.currentNode] : undefined;
186
+ const resumedAttempt = state.currentNode !== undefined &&
187
+ state.currentAttemptId !== undefined &&
188
+ state.currentNodeStartedAt !== undefined &&
189
+ interruptedNode?.nodeType === "agent" &&
190
+ assistantMessageConfig(interruptedNode) !== undefined
191
+ ? {
192
+ nodeId: state.currentNode,
193
+ attemptId: state.currentAttemptId,
194
+ startedAt: state.currentNodeStartedAt,
195
+ }
196
+ : undefined;
197
+ // A resumed run starts unpaused. Submitted and non-agent nodes discard
198
+ // stale in-flight markers and start a new attempt. Assistant-message
199
+ // nodes keep their attempt id so the origin session can adopt an already
200
+ // visible response without showing it twice.
191
201
  delete state.paused;
192
- delete state.currentNode;
193
- delete state.currentAttemptId;
194
- delete state.currentNodeStartedAt;
195
- delete state.statusDetail;
196
- await this.persist(runDir, state, {
202
+ if (resumedAttempt === undefined) {
203
+ delete state.currentNode;
204
+ delete state.currentAttemptId;
205
+ delete state.currentNodeStartedAt;
206
+ delete state.statusDetail;
207
+ }
208
+ await this.persist(runId, state, {
197
209
  scope: "run",
198
210
  type: "run_resumed",
199
211
  payload: {
200
212
  ...(point.nodeId !== null ? { resumeAt: point.nodeId } : {}),
213
+ ...(resumedAttempt !== undefined ? { resumedAttemptId: resumedAttempt.attemptId } : {}),
201
214
  replayedSteps: state.steps.length,
202
215
  ...(sourceMismatch ? { workflowSourceMismatch: true, forced: true } : {}),
203
216
  },
204
217
  });
205
- await this.onRunStarted?.(runDir, state);
218
+ await this.onRunStarted?.(runId, state);
206
219
  if (point.nodeId === null) {
207
220
  // The last recorded transition already finished the graph; the crash
208
221
  // happened before the terminal event was written. A finished
209
222
  // checkpoint restores its waiting gate rather than completing.
210
223
  if (point.waitingOn !== undefined) {
211
- await this.finishRun(runDir, state, "waiting", {
224
+ await this.finishRun(runId, state, "waiting", {
212
225
  waitingOn: point.waitingOn,
213
226
  finalOutput: point.lastOutput,
214
227
  });
215
228
  }
216
229
  else if (point.failedResult === undefined) {
217
- await this.finishRun(runDir, state, "completed", { finalOutput: point.lastOutput });
230
+ await this.finishRun(runId, state, "completed", { finalOutput: point.lastOutput });
218
231
  }
219
232
  else {
220
233
  const timedOut = point.failedResult.outcome === "timed_out";
221
- await this.finishRun(runDir, state, timedOut ? "timed_out" : "failed", {
234
+ await this.finishRun(runId, state, timedOut ? "timed_out" : "failed", {
222
235
  error: point.failedResult.error ?? `Workflow node failed: ${point.failedResult.nodeId}`,
223
236
  });
224
237
  }
225
- return { runDir, state };
238
+ return { runId, state };
226
239
  }
227
240
  try {
228
- await this.executeGraph(workflow, state, runDir, point.nodeId, countExecutableSteps(workflow, state.steps), point.lastOutput);
241
+ await this.executeGraph(workflow, state, runId, point.nodeId, countExecutableSteps(workflow, state.steps), point.lastOutput, resumedAttempt);
229
242
  }
230
243
  catch (error) {
231
244
  if (isRunParkedError(error) || this.parked) {
232
- return { runDir, state };
245
+ return { runId, state };
233
246
  }
234
- await this.finishAfterError(runDir, state, error);
235
- return { runDir, state };
247
+ await this.finishAfterError(runId, state, error);
248
+ return { runId, state };
236
249
  }
237
- return { runDir, state };
250
+ return { runId, state };
238
251
  }
239
252
  /**
240
253
  * Start a continuation run from a checkpointed parent. The new run gets a
241
- * fresh bundle and trace, carries forward the parent's outputs, results,
254
+ * fresh run and event stream, carries forward the parent's outputs, results,
242
255
  * and step accounting, and continues routing after the checkpoint.
243
256
  */
244
257
  async continueRun(workflow, parentRunId, input, options = {}) {
@@ -247,7 +260,7 @@ export class WorkflowEngine {
247
260
  this.cancelled = false;
248
261
  this.paused = false;
249
262
  this.parked = false;
250
- const parent = await readRunBundle(this.store.runDirFor(parentRunId));
263
+ const parent = this.store.readRun(parentRunId);
251
264
  if (parent === null) {
252
265
  throw new Error(`Cannot continue from unreadable workflow run: ${parentRunId}`);
253
266
  }
@@ -269,19 +282,20 @@ export class WorkflowEngine {
269
282
  throw new Error(`Checkpoint ${waitingNodeId} requires an accepted verified human decision`);
270
283
  }
271
284
  const request = parent.state.finalOutput;
272
- if ((request?.schema !== "pi-workflows.human-decision-request.v1" &&
273
- request?.schema !== "pi-workflows.human-decision-request.v2") ||
285
+ if (request?.schema !== "pi-workflows.human-decision-request.v1" ||
274
286
  request.decisionId !== options.humanDecision.decisionId ||
275
287
  request.requestDigest !== options.humanDecision.requestDigest) {
276
288
  throw new Error("Accepted human decision does not match the waiting request");
277
289
  }
278
- const durableDecision = await new HumanDecisionStore(this.store.outputRoot).readAccepted(request.decisionId);
290
+ const durableDecision = await new HumanDecisionStore(this.store.databasePath, {
291
+ state: this.store.state,
292
+ }).readResolved(request.decisionId);
279
293
  if (durableDecision === null || !isDeepStrictEqual(durableDecision, options.humanDecision)) {
280
294
  throw new Error("Accepted human decision does not match the durable decision record");
281
295
  }
282
296
  acceptedResponse = validateHumanDecisionResponse(request, durableDecision.response);
283
297
  acceptedNodeId = request.nodeId;
284
- normalizedInput = await resolveArtifacts(parent.state.input, parent.runDir);
298
+ normalizedInput = structuredClone(parent.state.input);
285
299
  }
286
300
  else {
287
301
  const suppliedInput = input === undefined ? null : input;
@@ -293,30 +307,26 @@ export class WorkflowEngine {
293
307
  }
294
308
  const state = await this.createRunState(workflow, normalizedInput, options.workflowSource, options.runId);
295
309
  state.parentRunId = parentRunId;
296
- // Artifact references point into the parent's bundle, so carried values
297
- // are fully resolved here and re-externalized into the new bundle.
298
- state.outputs = (await resolveArtifacts(parent.state.outputs, parent.runDir));
299
- state.results = (await resolveArtifacts(parent.state.results, parent.runDir));
300
- state.steps = (await resolveArtifacts(parent.state.steps, parent.runDir));
310
+ state.outputs = structuredClone(parent.state.outputs);
311
+ state.results = structuredClone(parent.state.results);
312
+ state.steps = structuredClone(parent.state.steps);
301
313
  if (humanContract !== undefined && options.humanDecision !== undefined) {
302
314
  const receipt = {
303
315
  decisionId: options.humanDecision.decisionId,
304
316
  requestDigest: options.humanDecision.requestDigest,
305
317
  nodeId: acceptedNodeId ?? waitingNodeId,
306
318
  response: options.humanDecision.response,
319
+ provenance: options.humanDecision.provenance,
307
320
  acceptedAt: options.humanDecision.acceptedAt,
308
321
  answerDigest: options.humanDecision.answerDigest,
309
322
  };
310
- state.humanDecision =
311
- options.humanDecision.schema === "pi-workflows.human-decision-accepted.v2"
312
- ? {
313
- schema: "pi-workflows.human-decision-receipt.v2",
314
- ...receipt,
315
- subjectDigest: options.humanDecision.subjectDigest,
316
- presentationDigest: options.humanDecision.presentationDigest,
317
- revision: options.humanDecision.revision,
318
- }
319
- : { schema: "pi-workflows.human-decision-receipt.v1", ...receipt };
323
+ state.humanDecision = {
324
+ schema: "pi-workflows.human-decision-receipt.v1",
325
+ ...receipt,
326
+ subjectDigest: options.humanDecision.subjectDigest,
327
+ presentationDigest: options.humanDecision.presentationDigest,
328
+ revision: options.humanDecision.revision,
329
+ };
320
330
  state.outputs[waitingNodeId] = acceptedResponse;
321
331
  const priorResult = state.results[waitingNodeId];
322
332
  if (priorResult === undefined) {
@@ -332,8 +342,8 @@ export class WorkflowEngine {
332
342
  state.steps[stepIndex] = { ...priorStep, output: acceptedResponse };
333
343
  }
334
344
  state.carriedStepCount = state.steps.length;
335
- const runDir = await this.store.initializeRunBundle(workflow, state);
336
- await this.persist(runDir, state, {
345
+ const runId = await this.store.initializeRun(workflow, state);
346
+ await this.persist(runId, state, {
337
347
  scope: "run",
338
348
  type: "run_started",
339
349
  payload: {
@@ -345,24 +355,24 @@ export class WorkflowEngine {
345
355
  carriedSteps: state.steps.length,
346
356
  },
347
357
  });
348
- await this.onRunStarted?.(runDir, state);
358
+ await this.onRunStarted?.(runId, state);
349
359
  const point = this.resumePointFor(workflow, state, "continue");
350
360
  if (point.nodeId === null) {
351
361
  // The checkpoint was the final node; the answer completes the chain.
352
- await this.finishRun(runDir, state, "completed", { finalOutput: point.lastOutput });
353
- return { runDir, state };
362
+ await this.finishRun(runId, state, "completed", { finalOutput: point.lastOutput });
363
+ return { runId, state };
354
364
  }
355
365
  try {
356
- await this.executeGraph(workflow, state, runDir, point.nodeId, countExecutableSteps(workflow, state.steps), point.lastOutput);
366
+ await this.executeGraph(workflow, state, runId, point.nodeId, countExecutableSteps(workflow, state.steps), point.lastOutput);
357
367
  }
358
368
  catch (error) {
359
369
  if (isRunParkedError(error) || this.parked) {
360
- return { runDir, state };
370
+ return { runId, state };
361
371
  }
362
- await this.finishAfterError(runDir, state, error);
363
- return { runDir, state };
372
+ await this.finishAfterError(runId, state, error);
373
+ return { runId, state };
364
374
  }
365
- return { runDir, state };
375
+ return { runId, state };
366
376
  }
367
377
  /**
368
378
  * Find where a resumed run continues. An in-flight node reruns; otherwise
@@ -404,15 +414,15 @@ export class WorkflowEngine {
404
414
  const next = resolveNextForOutcome(workflow.edges, lastStep.nodeId, result);
405
415
  return next === null ? { nodeId: null, failedResult: result } : { nodeId: next };
406
416
  }
407
- async finishAfterError(runDir, state, error) {
417
+ async finishAfterError(runId, state, error) {
408
418
  const cancelled = this.cancelled || isAbortLikeError(error);
409
419
  try {
410
- await this.finishRun(runDir, state, cancelled ? "cancelled" : "failed", {
420
+ await this.finishRun(runId, state, cancelled ? "cancelled" : "failed", {
411
421
  error: errorMessage(error),
412
422
  });
413
423
  }
414
424
  catch (finishError) {
415
- // A fenced-out runner must not touch the bundle, including terminal
425
+ // A fenced-out runner must not touch run state, including terminal
416
426
  // projections. Propagate the claim loss instead of the node error.
417
427
  if (isClaimLostError(finishError)) {
418
428
  throw finishError;
@@ -464,14 +474,14 @@ export class WorkflowEngine {
464
474
  updates: [],
465
475
  };
466
476
  }
467
- async executeGraph(workflow, state, runDir, startNodeId = workflow.startAt, executedStepsBase = 0, initialLastOutput) {
477
+ async executeGraph(workflow, state, runId, startNodeId = workflow.startAt, executedStepsBase = 0, initialLastOutput, resumedAttempt) {
468
478
  const maxSteps = workflow.maxSteps ?? this.maxSteps;
469
479
  const composition = compositionMetadata(workflow);
470
480
  let currentNodeId = startNodeId;
471
481
  let executedSteps = executedStepsBase;
472
482
  let lastOutput = initialLastOutput;
473
483
  while (currentNodeId !== null) {
474
- await this.holdWhilePaused(state, runDir);
484
+ await this.holdWhilePaused(state, runId);
475
485
  const isTransition = composition?.entries[currentNodeId] !== undefined ||
476
486
  composition?.exits[currentNodeId] !== undefined;
477
487
  if (!isTransition) {
@@ -485,7 +495,9 @@ export class WorkflowEngine {
485
495
  if (!node) {
486
496
  throw new Error(`Workflow node is missing: ${currentNodeId}`);
487
497
  }
488
- const attempt = await this.executeNode(workflow, state, runDir, currentNodeId, node);
498
+ const activeResume = resumedAttempt?.nodeId === currentNodeId ? resumedAttempt : undefined;
499
+ resumedAttempt = undefined;
500
+ const attempt = await this.executeNode(workflow, state, runId, currentNodeId, node, activeResume);
489
501
  if (this.parked) {
490
502
  // Do not record the aborted attempt: the projection keeps the node
491
503
  // as in-flight, and resume reruns it with a fresh attempt.
@@ -494,7 +506,7 @@ export class WorkflowEngine {
494
506
  this.recordAttempt(workflow, state, attempt);
495
507
  // The terminal node event carries the output, receipt, and conversation
496
508
  // linkage so the trace alone is sufficient to reconstruct the run.
497
- await this.persist(runDir, state, {
509
+ await this.persist(runId, state, {
498
510
  scope: "node",
499
511
  type: attempt.result.outcome === "ok" ? "node_finished" : "node_failed",
500
512
  nodeId: attempt.result.nodeId,
@@ -505,6 +517,9 @@ export class WorkflowEngine {
505
517
  ...(attempt.result.outcome === "ok" ? { output: attempt.result.output ?? null } : {}),
506
518
  ...(attempt.result.error !== undefined ? { error: attempt.result.error } : {}),
507
519
  ...(attempt.execution?.action !== undefined ? { action: attempt.execution.action } : {}),
520
+ ...(attempt.execution?.assistantMessage !== undefined
521
+ ? { assistantMessage: attempt.execution.assistantMessage }
522
+ : {}),
508
523
  ...(attempt.execution?.conversation !== undefined
509
524
  ? { conversation: attempt.execution.conversation }
510
525
  : {}),
@@ -517,7 +532,7 @@ export class WorkflowEngine {
517
532
  const entered = composition?.entries[attempt.result.nodeId];
518
533
  if (entered !== undefined) {
519
534
  const value = attempt.result.output;
520
- await this.persist(runDir, state, {
535
+ await this.persist(runId, state, {
521
536
  scope: "run",
522
537
  type: "include_entered",
523
538
  payload: {
@@ -530,7 +545,7 @@ export class WorkflowEngine {
530
545
  const exited = composition?.exits[attempt.result.nodeId];
531
546
  if (exited !== undefined) {
532
547
  const entrySteps = state.steps.filter((step) => step.nodeId === exited.mountPath);
533
- await this.persist(runDir, state, {
548
+ await this.persist(runId, state, {
534
549
  scope: "run",
535
550
  type: "include_exited",
536
551
  payload: {
@@ -544,7 +559,7 @@ export class WorkflowEngine {
544
559
  }
545
560
  lastOutput = attempt.result.output;
546
561
  if (node.nodeType === "checkpoint") {
547
- await this.finishRun(runDir, state, "waiting", {
562
+ await this.finishRun(runId, state, "waiting", {
548
563
  waitingOn: attempt.result.nodeId,
549
564
  finalOutput: lastOutput,
550
565
  });
@@ -552,13 +567,13 @@ export class WorkflowEngine {
552
567
  }
553
568
  currentNodeId = resolveNext(workflow.edges, attempt.result.nodeId, attempt.result.output, attempt.result);
554
569
  }
555
- await this.finishRun(runDir, state, "completed", { finalOutput: lastOutput });
570
+ await this.finishRun(runId, state, "completed", { finalOutput: lastOutput });
556
571
  }
557
572
  /**
558
573
  * Hold the run at the step boundary while a pause is in effect. Pausing
559
574
  * never interrupts a node mid-flight; it only delays the next dispatch.
560
575
  */
561
- async holdWhilePaused(state, runDir) {
576
+ async holdWhilePaused(state, runId) {
562
577
  if (this.parked) {
563
578
  throw new RunParkedError();
564
579
  }
@@ -569,7 +584,7 @@ export class WorkflowEngine {
569
584
  return;
570
585
  }
571
586
  state.paused = true;
572
- await this.persist(runDir, state, { scope: "run", type: "run_paused", payload: {} });
587
+ await this.persist(runId, state, { scope: "run", type: "run_paused", payload: {} });
573
588
  while (this.paused && !this.cancelled && !this.parked) {
574
589
  await new Promise((resolve) => {
575
590
  this.wakePause = resolve;
@@ -583,7 +598,7 @@ export class WorkflowEngine {
583
598
  if (this.cancelled) {
584
599
  throw new CancelledError();
585
600
  }
586
- await this.persist(runDir, state, { scope: "run", type: "run_resumed", payload: {} });
601
+ await this.persist(runId, state, { scope: "run", type: "run_resumed", payload: {} });
587
602
  }
588
603
  routeAfterFailure(workflow, state, attempt) {
589
604
  if (attempt.result.outcome === "cancelled" || this.cancelled) {
@@ -627,6 +642,9 @@ export class WorkflowEngine {
627
642
  output: attempt.result.output ?? null,
628
643
  ...(attempt.result.error !== undefined ? { error: attempt.result.error } : {}),
629
644
  ...(attempt.execution?.action !== undefined ? { action: attempt.execution.action } : {}),
645
+ ...(attempt.execution?.assistantMessage !== undefined
646
+ ? { assistantMessage: attempt.execution.assistantMessage }
647
+ : {}),
630
648
  ...(attempt.execution?.conversation !== undefined
631
649
  ? { conversation: attempt.execution.conversation }
632
650
  : {}),
@@ -637,25 +655,27 @@ export class WorkflowEngine {
637
655
  delete state.currentNodeStartedAt;
638
656
  delete state.statusDetail;
639
657
  }
640
- async executeNode(workflow, state, runDir, nodeId, node) {
641
- const attemptId = randomUUID();
642
- const startedAt = new Date().toISOString();
658
+ async executeNode(workflow, state, runId, nodeId, node, resumedAttempt) {
659
+ const attemptId = resumedAttempt?.attemptId ?? randomUUID();
660
+ const startedAt = resumedAttempt?.startedAt ?? new Date().toISOString();
643
661
  state.currentNode = nodeId;
644
662
  state.currentAttemptId = attemptId;
645
663
  state.currentNodeStartedAt = startedAt;
646
664
  if (node.statusDetail !== undefined) {
647
665
  state.statusDetail = node.statusDetail;
648
666
  }
649
- await this.persist(runDir, state, {
650
- scope: "node",
651
- type: "node_started",
652
- nodeId,
653
- attemptId,
654
- payload: { nodeType: node.nodeType },
655
- });
667
+ if (resumedAttempt === undefined) {
668
+ await this.persist(runId, state, {
669
+ scope: "node",
670
+ type: "node_started",
671
+ nodeId,
672
+ attemptId,
673
+ payload: { nodeType: node.nodeType },
674
+ });
675
+ }
656
676
  const meta = { promptText: null };
657
677
  try {
658
- const execution = await this.runNodeWithTimeout(workflow, state, runDir, nodeId, attemptId, node, meta);
678
+ const execution = await this.runNodeWithTimeout(workflow, state, runId, nodeId, attemptId, node, meta);
659
679
  return {
660
680
  result: this.createNodeResult(nodeId, node, attemptId, startedAt, "ok", execution.output),
661
681
  execution,
@@ -701,7 +721,7 @@ export class WorkflowEngine {
701
721
  ...(output !== undefined ? { output } : {}),
702
722
  };
703
723
  }
704
- async runNodeWithTimeout(workflow, state, runDir, nodeId, attemptId, node, meta) {
724
+ async runNodeWithTimeout(workflow, state, runId, nodeId, attemptId, node, meta) {
705
725
  const abort = new AbortController();
706
726
  const context = this.createNodeContext(state, abort.signal);
707
727
  let timer;
@@ -725,15 +745,15 @@ export class WorkflowEngine {
725
745
  abort.abort(new TimeoutError(timeoutMs));
726
746
  }, timeoutMs);
727
747
  }
728
- this.activeAttempt = { runDir, state, nodeId, attemptId, signal: abort.signal };
729
- const dispatched = this.dispatchNode(workflow, state, runDir, nodeId, attemptId, node, context, abort.signal, meta);
748
+ this.activeAttempt = { runId, state, nodeId, attemptId, signal: abort.signal };
749
+ const dispatched = this.dispatchNode(workflow, state, runId, nodeId, attemptId, node, context, abort.signal, meta);
730
750
  dispatchSettled = dispatched.then(() => undefined, () => undefined);
731
751
  // Race the dispatch against the abort signal so timeouts and cancel
732
752
  // take effect even for node callbacks that never observe the signal.
733
753
  const execution = await Promise.race([dispatched, abortRejection(abort.signal)]);
734
754
  if (execution.output === undefined) {
735
755
  // JSON cannot represent undefined; normalize so the in-memory state
736
- // matches what the persisted bundle round-trips to.
756
+ // matches what persisted canonical JSON round-trips to.
737
757
  execution.output = null;
738
758
  }
739
759
  assertJsonSerializable(execution.output, `Node ${nodeId} output`);
@@ -790,10 +810,10 @@ export class WorkflowEngine {
790
810
  clearTimeout(timer);
791
811
  }
792
812
  }
793
- async dispatchNode(workflow, state, runDir, nodeId, attemptId, node, context, signal, meta) {
813
+ async dispatchNode(workflow, state, runId, nodeId, attemptId, node, context, signal, meta) {
794
814
  switch (node.nodeType) {
795
815
  case "agent":
796
- return await this.runAgentNode(workflow, state, runDir, nodeId, attemptId, node, context, signal, meta);
816
+ return await this.runAgentNode(workflow, state, runId, nodeId, attemptId, node, context, signal, meta);
797
817
  case "compute":
798
818
  return { output: await node.run(context), promptText: null };
799
819
  case "notify": {
@@ -836,22 +856,40 @@ export class WorkflowEngine {
836
856
  signal,
837
857
  };
838
858
  }
839
- async runAgentNode(workflow, state, runDir, nodeId, attemptId, node, context, signal, meta) {
859
+ async runAgentNode(workflow, state, runId, nodeId, attemptId, node, context, signal, meta) {
860
+ const assistant = assistantMessageConfig(node);
861
+ if (assistant !== undefined) {
862
+ if (this.executor.assistantMessageMode === "park") {
863
+ state.statusDetail = "waiting for origin Pi session";
864
+ await this.persist(runId, state, {
865
+ scope: "agent",
866
+ type: "agent_session_required",
867
+ nodeId,
868
+ attemptId,
869
+ payload: { completion: "assistant" },
870
+ });
871
+ this.parked = true;
872
+ throw new RunParkedError();
873
+ }
874
+ if (this.executor.assistantMessageMode !== "visible") {
875
+ throw new Error("Assistant completion requires an origin Pi session");
876
+ }
877
+ }
840
878
  const basePrompt = await node.prompt(context);
841
879
  if (signal.aborted) {
842
880
  // The node timed out or the run was cancelled while the async prompt
843
- // builder ran; a late continuation must not write into a bundle that
881
+ // builder ran; a late continuation must not write into a run that
844
882
  // may already be terminal.
845
883
  throw abortError(signal);
846
884
  }
847
885
  const prompt = appendStepContract(basePrompt, workflow.name, nodeId, attemptId, node.expectedOutput);
848
886
  meta.promptText = prompt;
849
- await this.persist(runDir, state, {
887
+ await this.persist(runId, state, {
850
888
  scope: "agent",
851
889
  type: "agent_prompt_sent",
852
890
  nodeId,
853
891
  attemptId,
854
- payload: { prompt },
892
+ payload: { prompt, completion: assistant === undefined ? "submit" : "assistant" },
855
893
  });
856
894
  const submission = await this.executor.runAgentStep({
857
895
  contract: {
@@ -859,7 +897,11 @@ export class WorkflowEngine {
859
897
  workflowName: workflow.name,
860
898
  nodeId,
861
899
  attemptId,
862
- ...(node.expectedOutput !== undefined ? { expectedOutput: node.expectedOutput } : {}),
900
+ completion: assistant === undefined ? "submit" : "assistant",
901
+ ...(typeof node.expectedOutput === "string"
902
+ ? { expectedOutput: node.expectedOutput }
903
+ : {}),
904
+ ...(assistant?.maxChars !== undefined ? { maxOutputChars: assistant.maxChars } : {}),
863
905
  },
864
906
  prompt,
865
907
  ...(state.runTitle !== undefined || node.statusDetail !== undefined
@@ -876,10 +918,19 @@ export class WorkflowEngine {
876
918
  return {
877
919
  output: submission.output,
878
920
  promptText: prompt,
921
+ ...(submission.assistantMessage !== undefined
922
+ ? { assistantMessage: submission.assistantMessage }
923
+ : {}),
879
924
  ...(submission.conversation !== undefined ? { conversation: submission.conversation } : {}),
880
925
  };
881
926
  }
882
927
  async acceptSubmission(node, context, output) {
928
+ if (assistantMessageConfig(node) !== undefined) {
929
+ return {
930
+ ok: false,
931
+ error: "This step completes with a normal assistant response. Do not submit workflow output.",
932
+ };
933
+ }
883
934
  try {
884
935
  const normalized = normalizeAgentOutput(output);
885
936
  const validated = node.validate ? await node.validate(normalized, context) : normalized;
@@ -905,8 +956,8 @@ export class WorkflowEngine {
905
956
  const output = await node.run(actionContext);
906
957
  return { output, promptText: null, action: { actionType: "function" } };
907
958
  }
908
- async persist(runDir, state, event) {
909
- const traceEvent = await this.store.writeSnapshot(runDir, state, event);
959
+ async persist(runId, state, event) {
960
+ const traceEvent = await this.store.writeSnapshot(runId, state, event);
910
961
  try {
911
962
  this.onEvent?.(traceEvent, state);
912
963
  }
@@ -915,14 +966,14 @@ export class WorkflowEngine {
915
966
  // correctness; a throwing observer would otherwise fail the run.
916
967
  }
917
968
  }
918
- async finishRun(runDir, state, status, fields) {
969
+ async finishRun(runId, state, status, fields) {
919
970
  if (status === "failed" && state.status === "timed_out") {
920
971
  status = "timed_out";
921
972
  }
922
973
  // Let observers (e.g. the session recorder) stop and drain before the
923
- // terminal event exists, so the bundle is immutable from that point on.
974
+ // terminal event exists, so the terminal fact is immutable from that point on.
924
975
  try {
925
- await this.onRunFinishing?.(runDir, state);
976
+ await this.onRunFinishing?.(runId, state);
926
977
  }
927
978
  catch {
928
979
  // Finishing the run wins over observer failures.
@@ -941,7 +992,7 @@ export class WorkflowEngine {
941
992
  delete state.currentNode;
942
993
  delete state.currentAttemptId;
943
994
  delete state.currentNodeStartedAt;
944
- await this.persist(runDir, state, {
995
+ await this.persist(runId, state, {
945
996
  scope: "run",
946
997
  type: `run_${status}`,
947
998
  payload: {
@@ -959,6 +1010,9 @@ async function runCheckpointNode(node, context, execution) {
959
1010
  const audience = typeof node.humanDecision.audience === "function"
960
1011
  ? await node.humanDecision.audience(context)
961
1012
  : node.humanDecision.audience;
1013
+ const timeout = typeof node.humanDecision.onTimeout === "function"
1014
+ ? await node.humanDecision.onTimeout(context)
1015
+ : node.humanDecision.onTimeout;
962
1016
  const request = createHumanDecisionRequest({
963
1017
  runId: context.state.runId,
964
1018
  workflowName: execution.workflowName,
@@ -966,8 +1020,11 @@ async function runCheckpointNode(node, context, execution) {
966
1020
  attemptId: execution.attemptId,
967
1021
  contract: { audience, choices: node.humanDecision.choices },
968
1022
  prompt,
1023
+ ...(timeout !== undefined ? { timeout } : {}),
969
1024
  });
970
- await new HumanDecisionStore(execution.store.outputRoot).createRequest(request);
1025
+ await new HumanDecisionStore(execution.store.databasePath, {
1026
+ state: execution.store.state,
1027
+ }).createRequest(request);
971
1028
  return { output: request, promptText: null };
972
1029
  }
973
1030
  const output = node.run ? await node.run(context) : { summary: node.summary ?? "checkpoint" };
@@ -1071,7 +1128,7 @@ function assertInvocationStepLimit(metadata, nodeId, steps) {
1071
1128
  }
1072
1129
  }
1073
1130
  /**
1074
- * Outputs are persisted to the run bundle, so they must be JSON-serializable.
1131
+ * Outputs are persisted to the SQLite run state, so they must be JSON-serializable.
1075
1132
  * Failing here turns a bad callback return value into a normal node failure
1076
1133
  * instead of corrupting the run state.
1077
1134
  */
@@ -1129,11 +1186,35 @@ function normalizeAgentOutput(output) {
1129
1186
  return output;
1130
1187
  }
1131
1188
  }
1189
+ function assistantMessageOutput(expectedOutput) {
1190
+ return typeof expectedOutput === "object" && expectedOutput?.kind === "assistant-message"
1191
+ ? expectedOutput
1192
+ : undefined;
1193
+ }
1194
+ function assistantMessageConfig(node) {
1195
+ return assistantMessageOutput(node.expectedOutput);
1196
+ }
1132
1197
  /**
1133
1198
  * The step contract appended to every agent-node prompt. This is the
1134
1199
  * documented standard for how the model completes a workflow step.
1135
1200
  */
1136
1201
  export function appendStepContract(prompt, workflowName, nodeId, attemptId, expectedOutput) {
1202
+ const assistant = assistantMessageOutput(expectedOutput);
1203
+ if (assistant !== undefined) {
1204
+ return [
1205
+ prompt.trimEnd(),
1206
+ "",
1207
+ "---",
1208
+ `Workflow step contract (workflow: ${workflowName}, step: ${nodeId}, attempt: ${attemptId})`,
1209
+ "",
1210
+ "Reply with a normal assistant message.",
1211
+ "Do not call the workflow tool to complete this step.",
1212
+ "Your visible reply becomes the workflow step output after the turn settles.",
1213
+ ...(assistant.maxChars !== undefined
1214
+ ? [`Keep the visible reply within ${assistant.maxChars} characters.`]
1215
+ : []),
1216
+ ].join("\n");
1217
+ }
1137
1218
  return [
1138
1219
  prompt.trimEnd(),
1139
1220
  "",
@@ -1144,7 +1225,7 @@ export function appendStepContract(prompt, workflowName, nodeId, attemptId, expe
1144
1225
  `{"action": "update", "step": ${JSON.stringify(nodeId)}, "attempt": ${JSON.stringify(attemptId)}, "update": {"type": "...", "key": "...", "data": {...}}}`,
1145
1226
  "Complete this step by calling the `workflow` tool exactly once with:",
1146
1227
  `{"action": "submit", "step": ${JSON.stringify(nodeId)}, "attempt": ${JSON.stringify(attemptId)}, "output": <your result>}`,
1147
- `Expected output: ${expectedOutput ?? "a JSON object with your result"}`,
1228
+ `Expected output: ${typeof expectedOutput === "string" ? expectedOutput : "a JSON object with your result"}`,
1148
1229
  "The step is complete only after the workflow tool accepts the output.",
1149
1230
  "If the tool reports a validation error, correct the output and call it again.",
1150
1231
  ].join("\n");