@osolmaz/pi-workflows 0.12.1 → 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 (209) hide show
  1. package/README.md +52 -28
  2. package/dist/builtins/autodoc.workflow.d.ts +4 -4
  3. package/dist/builtins/autoimplement.workflow.d.ts +369 -73
  4. package/dist/builtins/autoplan.workflow.d.ts +126 -13
  5. package/dist/builtins/autoplan.workflow.js +200 -29
  6. package/dist/builtins/autoplan.workflow.js.map +1 -1
  7. package/dist/builtins/catalog.js +6 -4
  8. package/dist/builtins/catalog.js.map +1 -1
  9. package/dist/builtins/index.d.ts +2 -1
  10. package/dist/builtins/index.js +1 -0
  11. package/dist/builtins/index.js.map +1 -1
  12. package/dist/builtins/monitor.workflow.d.ts +56 -21
  13. package/dist/builtins/monitor.workflow.js +497 -216
  14. package/dist/builtins/monitor.workflow.js.map +1 -1
  15. package/dist/builtins/plain-summary.workflow.d.ts +32 -0
  16. package/dist/builtins/plain-summary.workflow.js +134 -0
  17. package/dist/builtins/plain-summary.workflow.js.map +1 -0
  18. package/dist/builtins/plan-change.workflow.d.ts +170 -22
  19. package/dist/builtins/sanity-check.workflow.d.ts +45 -3
  20. package/dist/builtins/sanity-check.workflow.js +45 -7
  21. package/dist/builtins/sanity-check.workflow.js.map +1 -1
  22. package/dist/controllers/effects.d.ts +3 -2
  23. package/dist/controllers/effects.js +8 -1
  24. package/dist/controllers/effects.js.map +1 -1
  25. package/dist/controllers/index.d.ts +1 -1
  26. package/dist/controllers/index.js +1 -1
  27. package/dist/controllers/index.js.map +1 -1
  28. package/dist/controllers/manager.d.ts +1 -0
  29. package/dist/controllers/manager.js +41 -17
  30. package/dist/controllers/manager.js.map +1 -1
  31. package/dist/controllers/sqlite.d.ts +107 -85
  32. package/dist/controllers/sqlite.js +1611 -1486
  33. package/dist/controllers/sqlite.js.map +1 -1
  34. package/dist/controllers/store.d.ts +9 -8
  35. package/dist/controllers/store.js +1 -31
  36. package/dist/controllers/store.js.map +1 -1
  37. package/dist/controllers/types.d.ts +3 -0
  38. package/dist/controllers/workflow-engine-scheduler.d.ts +1 -1
  39. package/dist/controllers/workflow-engine-scheduler.js +3 -5
  40. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  41. package/dist/controllers/workflows.d.ts +2 -2
  42. package/dist/controllers/workflows.js +11 -10
  43. package/dist/controllers/workflows.js.map +1 -1
  44. package/dist/extension/controller-host.js +2 -2
  45. package/dist/extension/controller-host.js.map +1 -1
  46. package/dist/extension/decision-channels.d.ts +2 -0
  47. package/dist/extension/decision-channels.js +311 -118
  48. package/dist/extension/decision-channels.js.map +1 -1
  49. package/dist/extension/executor.d.ts +9 -2
  50. package/dist/extension/executor.js +89 -2
  51. package/dist/extension/executor.js.map +1 -1
  52. package/dist/extension/herdr-viewer.d.ts +0 -1
  53. package/dist/extension/herdr-viewer.js +0 -4
  54. package/dist/extension/herdr-viewer.js.map +1 -1
  55. package/dist/extension/index.js +156 -103
  56. package/dist/extension/index.js.map +1 -1
  57. package/dist/extension/recorder.d.ts +3 -10
  58. package/dist/extension/recorder.js +13 -22
  59. package/dist/extension/recorder.js.map +1 -1
  60. package/dist/extension/step-message.d.ts +7 -2
  61. package/dist/extension/step-message.js +94 -2
  62. package/dist/extension/step-message.js.map +1 -1
  63. package/dist/extension/widget.js +6 -0
  64. package/dist/extension/widget.js.map +1 -1
  65. package/dist/host/rpc-executor.d.ts +3 -0
  66. package/dist/host/rpc-executor.js +2 -0
  67. package/dist/host/rpc-executor.js.map +1 -1
  68. package/dist/host/runner.d.ts +3 -6
  69. package/dist/host/runner.js +83 -32
  70. package/dist/host/runner.js.map +1 -1
  71. package/dist/render/graph-render.js +14 -8
  72. package/dist/render/graph-render.js.map +1 -1
  73. package/dist/state/database.d.ts +35 -0
  74. package/dist/state/database.js +287 -0
  75. package/dist/state/database.js.map +1 -0
  76. package/dist/state/index.d.ts +4 -0
  77. package/dist/state/index.js +5 -0
  78. package/dist/state/index.js.map +1 -0
  79. package/dist/state/json.d.ts +6 -0
  80. package/dist/state/json.js +38 -0
  81. package/dist/state/json.js.map +1 -0
  82. package/dist/state/mutation.d.ts +57 -0
  83. package/dist/state/mutation.js +255 -0
  84. package/dist/state/mutation.js.map +1 -0
  85. package/dist/state/schema.d.ts +6 -0
  86. package/dist/state/schema.js +473 -0
  87. package/dist/state/schema.js.map +1 -0
  88. package/dist/viewer/cli.d.ts +2 -2
  89. package/dist/viewer/cli.js +142 -122
  90. package/dist/viewer/cli.js.map +1 -1
  91. package/dist/viewer/render.d.ts +4 -4
  92. package/dist/viewer/render.js +2 -17
  93. package/dist/viewer/render.js.map +1 -1
  94. package/dist/viewer/session-reducer.d.ts +1 -1
  95. package/dist/viewer/session-reducer.js +1 -1
  96. package/dist/viewer/tui.d.ts +3 -3
  97. package/dist/viewer/tui.js +15 -13
  98. package/dist/viewer/tui.js.map +1 -1
  99. package/dist/viewer/watch.d.ts +2 -5
  100. package/dist/viewer/watch.js +13 -13
  101. package/dist/viewer/watch.js.map +1 -1
  102. package/dist/workflows/composition.js +12 -2
  103. package/dist/workflows/composition.js.map +1 -1
  104. package/dist/workflows/definition.d.ts +6 -2
  105. package/dist/workflows/definition.js +17 -0
  106. package/dist/workflows/definition.js.map +1 -1
  107. package/dist/workflows/engine.d.ts +6 -6
  108. package/dist/workflows/engine.js +183 -103
  109. package/dist/workflows/engine.js.map +1 -1
  110. package/dist/workflows/errors.d.ts +3 -3
  111. package/dist/workflows/errors.js +3 -3
  112. package/dist/workflows/human-decision.d.ts +29 -4
  113. package/dist/workflows/human-decision.js +547 -283
  114. package/dist/workflows/human-decision.js.map +1 -1
  115. package/dist/workflows/index.d.ts +4 -5
  116. package/dist/workflows/index.js +3 -4
  117. package/dist/workflows/index.js.map +1 -1
  118. package/dist/workflows/schema.js +17 -1
  119. package/dist/workflows/schema.js.map +1 -1
  120. package/dist/workflows/store.d.ts +83 -103
  121. package/dist/workflows/store.js +902 -951
  122. package/dist/workflows/store.js.map +1 -1
  123. package/dist/workflows/types.d.ts +55 -81
  124. package/docs/CONTROLLERS.md +10 -8
  125. package/docs/DEFERRED_TURNS.md +2 -2
  126. package/docs/DESIGN_PHILOSOPHY.md +4 -2
  127. package/docs/HUMAN_DECISIONS.md +13 -14
  128. package/docs/MONITOR.md +179 -231
  129. package/docs/SQLITE_STATE.md +219 -0
  130. package/docs/WORKFLOW_COMPOSITION.md +19 -19
  131. package/docs/WORKFLOW_STEP_MESSAGES.md +20 -18
  132. package/docs/WORKFLOW_UPDATES.md +10 -9
  133. package/docs/development.md +9 -9
  134. package/docs/live-replay-protocol.md +28 -37
  135. package/docs/plans/2026-08-16-workflow-updates-plan.md +1 -1
  136. package/docs/plans/2026-08-19-human-decision-gates-plan.md +1 -1
  137. package/docs/plans/2026-08-21-autoimplement-timeout-fallback-plan.md +1 -1
  138. package/docs/plans/2026-08-21-plan-change-approval-policy-plan.md +1 -1
  139. package/docs/plans/2026-08-21-sanity-check-plan.md +30 -16
  140. package/docs/plans/2026-08-22-goal-finishing-monitor-plan.md +200 -0
  141. package/docs/plans/2026-08-23-assistant-agent-completion-plan.md +701 -0
  142. package/docs/plans/2026-08-23-sqlite-state-plan.md +970 -0
  143. package/docs/plans/replayable-run-bundles-implementation-plan.md +2 -2
  144. package/docs/plans/session-event-replay-implementation-plan.md +1 -1
  145. package/docs/plans/tui-viewer-implementation-plan.md +1 -1
  146. package/docs/session-event-journal.md +63 -434
  147. package/docs/tui-viewer.md +13 -15
  148. package/docs/workflows.md +104 -50
  149. package/examples/workflows/plain-summary.workflow.ts +1 -0
  150. package/herdr-plugin.toml +1 -1
  151. package/package.json +1 -1
  152. package/plugins/herdr/viewer.mjs +1 -11
  153. package/skills/autoplan/SKILL.md +5 -4
  154. package/skills/monitor/SKILL.md +91 -163
  155. package/skills/pi-workflows/SKILL.md +7 -7
  156. package/skills/sanity-check/SKILL.md +1 -1
  157. package/src/builtins/autoplan.workflow.ts +290 -40
  158. package/src/builtins/catalog.ts +6 -4
  159. package/src/builtins/index.ts +7 -1
  160. package/src/builtins/monitor.workflow.ts +663 -249
  161. package/src/builtins/plain-summary.workflow.ts +185 -0
  162. package/src/builtins/sanity-check.workflow.ts +62 -7
  163. package/src/controllers/effects.ts +7 -0
  164. package/src/controllers/index.ts +0 -6
  165. package/src/controllers/manager.ts +72 -32
  166. package/src/controllers/sqlite.ts +2420 -1948
  167. package/src/controllers/store.ts +17 -38
  168. package/src/controllers/types.ts +3 -0
  169. package/src/controllers/workflow-engine-scheduler.ts +4 -8
  170. package/src/controllers/workflows.ts +45 -21
  171. package/src/extension/controller-host.ts +1 -2
  172. package/src/extension/decision-channels.ts +427 -144
  173. package/src/extension/executor.ts +105 -2
  174. package/src/extension/herdr-viewer.ts +0 -5
  175. package/src/extension/index.ts +180 -118
  176. package/src/extension/recorder.ts +13 -22
  177. package/src/extension/step-message.ts +106 -4
  178. package/src/extension/widget.ts +8 -0
  179. package/src/host/rpc-executor.ts +4 -0
  180. package/src/host/runner.ts +93 -41
  181. package/src/render/graph-render.ts +11 -4
  182. package/src/state/database.ts +375 -0
  183. package/src/state/index.ts +29 -0
  184. package/src/state/json.ts +42 -0
  185. package/src/state/mutation.ts +435 -0
  186. package/src/state/schema.ts +475 -0
  187. package/src/viewer/cli.ts +151 -144
  188. package/src/viewer/render.ts +12 -24
  189. package/src/viewer/session-reducer.ts +1 -1
  190. package/src/viewer/tui.ts +18 -17
  191. package/src/viewer/watch.ts +13 -17
  192. package/src/workflows/composition.ts +12 -3
  193. package/src/workflows/definition.ts +36 -3
  194. package/src/workflows/engine.ts +212 -116
  195. package/src/workflows/errors.ts +3 -3
  196. package/src/workflows/human-decision.ts +795 -340
  197. package/src/workflows/index.ts +15 -19
  198. package/src/workflows/schema.ts +19 -1
  199. package/src/workflows/store.ts +1295 -1163
  200. package/src/workflows/types.ts +61 -81
  201. package/dist/workflows/artifacts.d.ts +0 -40
  202. package/dist/workflows/artifacts.js +0 -155
  203. package/dist/workflows/artifacts.js.map +0 -1
  204. package/dist/workflows/migrate-sources.d.ts +0 -42
  205. package/dist/workflows/migrate-sources.js +0 -133
  206. package/dist/workflows/migrate-sources.js.map +0 -1
  207. package/docs/run-bundles.md +0 -481
  208. package/src/workflows/artifacts.ts +0 -188
  209. 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
  }
@@ -274,13 +287,15 @@ export class WorkflowEngine {
274
287
  request.requestDigest !== options.humanDecision.requestDigest) {
275
288
  throw new Error("Accepted human decision does not match the waiting request");
276
289
  }
277
- const durableDecision = await new HumanDecisionStore(this.store.outputRoot).readResolved(request.decisionId);
290
+ const durableDecision = await new HumanDecisionStore(this.store.databasePath, {
291
+ state: this.store.state,
292
+ }).readResolved(request.decisionId);
278
293
  if (durableDecision === null || !isDeepStrictEqual(durableDecision, options.humanDecision)) {
279
294
  throw new Error("Accepted human decision does not match the durable decision record");
280
295
  }
281
296
  acceptedResponse = validateHumanDecisionResponse(request, durableDecision.response);
282
297
  acceptedNodeId = request.nodeId;
283
- normalizedInput = await resolveArtifacts(parent.state.input, parent.runDir);
298
+ normalizedInput = structuredClone(parent.state.input);
284
299
  }
285
300
  else {
286
301
  const suppliedInput = input === undefined ? null : input;
@@ -292,11 +307,9 @@ export class WorkflowEngine {
292
307
  }
293
308
  const state = await this.createRunState(workflow, normalizedInput, options.workflowSource, options.runId);
294
309
  state.parentRunId = parentRunId;
295
- // Artifact references point into the parent's bundle, so carried values
296
- // are fully resolved here and re-externalized into the new bundle.
297
- state.outputs = (await resolveArtifacts(parent.state.outputs, parent.runDir));
298
- state.results = (await resolveArtifacts(parent.state.results, parent.runDir));
299
- 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);
300
313
  if (humanContract !== undefined && options.humanDecision !== undefined) {
301
314
  const receipt = {
302
315
  decisionId: options.humanDecision.decisionId,
@@ -329,8 +342,8 @@ export class WorkflowEngine {
329
342
  state.steps[stepIndex] = { ...priorStep, output: acceptedResponse };
330
343
  }
331
344
  state.carriedStepCount = state.steps.length;
332
- const runDir = await this.store.initializeRunBundle(workflow, state);
333
- await this.persist(runDir, state, {
345
+ const runId = await this.store.initializeRun(workflow, state);
346
+ await this.persist(runId, state, {
334
347
  scope: "run",
335
348
  type: "run_started",
336
349
  payload: {
@@ -342,24 +355,24 @@ export class WorkflowEngine {
342
355
  carriedSteps: state.steps.length,
343
356
  },
344
357
  });
345
- await this.onRunStarted?.(runDir, state);
358
+ await this.onRunStarted?.(runId, state);
346
359
  const point = this.resumePointFor(workflow, state, "continue");
347
360
  if (point.nodeId === null) {
348
361
  // The checkpoint was the final node; the answer completes the chain.
349
- await this.finishRun(runDir, state, "completed", { finalOutput: point.lastOutput });
350
- return { runDir, state };
362
+ await this.finishRun(runId, state, "completed", { finalOutput: point.lastOutput });
363
+ return { runId, state };
351
364
  }
352
365
  try {
353
- 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);
354
367
  }
355
368
  catch (error) {
356
369
  if (isRunParkedError(error) || this.parked) {
357
- return { runDir, state };
370
+ return { runId, state };
358
371
  }
359
- await this.finishAfterError(runDir, state, error);
360
- return { runDir, state };
372
+ await this.finishAfterError(runId, state, error);
373
+ return { runId, state };
361
374
  }
362
- return { runDir, state };
375
+ return { runId, state };
363
376
  }
364
377
  /**
365
378
  * Find where a resumed run continues. An in-flight node reruns; otherwise
@@ -401,15 +414,15 @@ export class WorkflowEngine {
401
414
  const next = resolveNextForOutcome(workflow.edges, lastStep.nodeId, result);
402
415
  return next === null ? { nodeId: null, failedResult: result } : { nodeId: next };
403
416
  }
404
- async finishAfterError(runDir, state, error) {
417
+ async finishAfterError(runId, state, error) {
405
418
  const cancelled = this.cancelled || isAbortLikeError(error);
406
419
  try {
407
- await this.finishRun(runDir, state, cancelled ? "cancelled" : "failed", {
420
+ await this.finishRun(runId, state, cancelled ? "cancelled" : "failed", {
408
421
  error: errorMessage(error),
409
422
  });
410
423
  }
411
424
  catch (finishError) {
412
- // A fenced-out runner must not touch the bundle, including terminal
425
+ // A fenced-out runner must not touch run state, including terminal
413
426
  // projections. Propagate the claim loss instead of the node error.
414
427
  if (isClaimLostError(finishError)) {
415
428
  throw finishError;
@@ -461,14 +474,14 @@ export class WorkflowEngine {
461
474
  updates: [],
462
475
  };
463
476
  }
464
- async executeGraph(workflow, state, runDir, startNodeId = workflow.startAt, executedStepsBase = 0, initialLastOutput) {
477
+ async executeGraph(workflow, state, runId, startNodeId = workflow.startAt, executedStepsBase = 0, initialLastOutput, resumedAttempt) {
465
478
  const maxSteps = workflow.maxSteps ?? this.maxSteps;
466
479
  const composition = compositionMetadata(workflow);
467
480
  let currentNodeId = startNodeId;
468
481
  let executedSteps = executedStepsBase;
469
482
  let lastOutput = initialLastOutput;
470
483
  while (currentNodeId !== null) {
471
- await this.holdWhilePaused(state, runDir);
484
+ await this.holdWhilePaused(state, runId);
472
485
  const isTransition = composition?.entries[currentNodeId] !== undefined ||
473
486
  composition?.exits[currentNodeId] !== undefined;
474
487
  if (!isTransition) {
@@ -482,7 +495,9 @@ export class WorkflowEngine {
482
495
  if (!node) {
483
496
  throw new Error(`Workflow node is missing: ${currentNodeId}`);
484
497
  }
485
- 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);
486
501
  if (this.parked) {
487
502
  // Do not record the aborted attempt: the projection keeps the node
488
503
  // as in-flight, and resume reruns it with a fresh attempt.
@@ -491,7 +506,7 @@ export class WorkflowEngine {
491
506
  this.recordAttempt(workflow, state, attempt);
492
507
  // The terminal node event carries the output, receipt, and conversation
493
508
  // linkage so the trace alone is sufficient to reconstruct the run.
494
- await this.persist(runDir, state, {
509
+ await this.persist(runId, state, {
495
510
  scope: "node",
496
511
  type: attempt.result.outcome === "ok" ? "node_finished" : "node_failed",
497
512
  nodeId: attempt.result.nodeId,
@@ -502,6 +517,9 @@ export class WorkflowEngine {
502
517
  ...(attempt.result.outcome === "ok" ? { output: attempt.result.output ?? null } : {}),
503
518
  ...(attempt.result.error !== undefined ? { error: attempt.result.error } : {}),
504
519
  ...(attempt.execution?.action !== undefined ? { action: attempt.execution.action } : {}),
520
+ ...(attempt.execution?.assistantMessage !== undefined
521
+ ? { assistantMessage: attempt.execution.assistantMessage }
522
+ : {}),
505
523
  ...(attempt.execution?.conversation !== undefined
506
524
  ? { conversation: attempt.execution.conversation }
507
525
  : {}),
@@ -514,7 +532,7 @@ export class WorkflowEngine {
514
532
  const entered = composition?.entries[attempt.result.nodeId];
515
533
  if (entered !== undefined) {
516
534
  const value = attempt.result.output;
517
- await this.persist(runDir, state, {
535
+ await this.persist(runId, state, {
518
536
  scope: "run",
519
537
  type: "include_entered",
520
538
  payload: {
@@ -527,7 +545,7 @@ export class WorkflowEngine {
527
545
  const exited = composition?.exits[attempt.result.nodeId];
528
546
  if (exited !== undefined) {
529
547
  const entrySteps = state.steps.filter((step) => step.nodeId === exited.mountPath);
530
- await this.persist(runDir, state, {
548
+ await this.persist(runId, state, {
531
549
  scope: "run",
532
550
  type: "include_exited",
533
551
  payload: {
@@ -541,7 +559,7 @@ export class WorkflowEngine {
541
559
  }
542
560
  lastOutput = attempt.result.output;
543
561
  if (node.nodeType === "checkpoint") {
544
- await this.finishRun(runDir, state, "waiting", {
562
+ await this.finishRun(runId, state, "waiting", {
545
563
  waitingOn: attempt.result.nodeId,
546
564
  finalOutput: lastOutput,
547
565
  });
@@ -549,13 +567,13 @@ export class WorkflowEngine {
549
567
  }
550
568
  currentNodeId = resolveNext(workflow.edges, attempt.result.nodeId, attempt.result.output, attempt.result);
551
569
  }
552
- await this.finishRun(runDir, state, "completed", { finalOutput: lastOutput });
570
+ await this.finishRun(runId, state, "completed", { finalOutput: lastOutput });
553
571
  }
554
572
  /**
555
573
  * Hold the run at the step boundary while a pause is in effect. Pausing
556
574
  * never interrupts a node mid-flight; it only delays the next dispatch.
557
575
  */
558
- async holdWhilePaused(state, runDir) {
576
+ async holdWhilePaused(state, runId) {
559
577
  if (this.parked) {
560
578
  throw new RunParkedError();
561
579
  }
@@ -566,7 +584,7 @@ export class WorkflowEngine {
566
584
  return;
567
585
  }
568
586
  state.paused = true;
569
- await this.persist(runDir, state, { scope: "run", type: "run_paused", payload: {} });
587
+ await this.persist(runId, state, { scope: "run", type: "run_paused", payload: {} });
570
588
  while (this.paused && !this.cancelled && !this.parked) {
571
589
  await new Promise((resolve) => {
572
590
  this.wakePause = resolve;
@@ -580,7 +598,7 @@ export class WorkflowEngine {
580
598
  if (this.cancelled) {
581
599
  throw new CancelledError();
582
600
  }
583
- await this.persist(runDir, state, { scope: "run", type: "run_resumed", payload: {} });
601
+ await this.persist(runId, state, { scope: "run", type: "run_resumed", payload: {} });
584
602
  }
585
603
  routeAfterFailure(workflow, state, attempt) {
586
604
  if (attempt.result.outcome === "cancelled" || this.cancelled) {
@@ -624,6 +642,9 @@ export class WorkflowEngine {
624
642
  output: attempt.result.output ?? null,
625
643
  ...(attempt.result.error !== undefined ? { error: attempt.result.error } : {}),
626
644
  ...(attempt.execution?.action !== undefined ? { action: attempt.execution.action } : {}),
645
+ ...(attempt.execution?.assistantMessage !== undefined
646
+ ? { assistantMessage: attempt.execution.assistantMessage }
647
+ : {}),
627
648
  ...(attempt.execution?.conversation !== undefined
628
649
  ? { conversation: attempt.execution.conversation }
629
650
  : {}),
@@ -634,25 +655,27 @@ export class WorkflowEngine {
634
655
  delete state.currentNodeStartedAt;
635
656
  delete state.statusDetail;
636
657
  }
637
- async executeNode(workflow, state, runDir, nodeId, node) {
638
- const attemptId = randomUUID();
639
- 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();
640
661
  state.currentNode = nodeId;
641
662
  state.currentAttemptId = attemptId;
642
663
  state.currentNodeStartedAt = startedAt;
643
664
  if (node.statusDetail !== undefined) {
644
665
  state.statusDetail = node.statusDetail;
645
666
  }
646
- await this.persist(runDir, state, {
647
- scope: "node",
648
- type: "node_started",
649
- nodeId,
650
- attemptId,
651
- payload: { nodeType: node.nodeType },
652
- });
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
+ }
653
676
  const meta = { promptText: null };
654
677
  try {
655
- 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);
656
679
  return {
657
680
  result: this.createNodeResult(nodeId, node, attemptId, startedAt, "ok", execution.output),
658
681
  execution,
@@ -698,7 +721,7 @@ export class WorkflowEngine {
698
721
  ...(output !== undefined ? { output } : {}),
699
722
  };
700
723
  }
701
- async runNodeWithTimeout(workflow, state, runDir, nodeId, attemptId, node, meta) {
724
+ async runNodeWithTimeout(workflow, state, runId, nodeId, attemptId, node, meta) {
702
725
  const abort = new AbortController();
703
726
  const context = this.createNodeContext(state, abort.signal);
704
727
  let timer;
@@ -722,15 +745,15 @@ export class WorkflowEngine {
722
745
  abort.abort(new TimeoutError(timeoutMs));
723
746
  }, timeoutMs);
724
747
  }
725
- this.activeAttempt = { runDir, state, nodeId, attemptId, signal: abort.signal };
726
- 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);
727
750
  dispatchSettled = dispatched.then(() => undefined, () => undefined);
728
751
  // Race the dispatch against the abort signal so timeouts and cancel
729
752
  // take effect even for node callbacks that never observe the signal.
730
753
  const execution = await Promise.race([dispatched, abortRejection(abort.signal)]);
731
754
  if (execution.output === undefined) {
732
755
  // JSON cannot represent undefined; normalize so the in-memory state
733
- // matches what the persisted bundle round-trips to.
756
+ // matches what persisted canonical JSON round-trips to.
734
757
  execution.output = null;
735
758
  }
736
759
  assertJsonSerializable(execution.output, `Node ${nodeId} output`);
@@ -787,10 +810,10 @@ export class WorkflowEngine {
787
810
  clearTimeout(timer);
788
811
  }
789
812
  }
790
- async dispatchNode(workflow, state, runDir, nodeId, attemptId, node, context, signal, meta) {
813
+ async dispatchNode(workflow, state, runId, nodeId, attemptId, node, context, signal, meta) {
791
814
  switch (node.nodeType) {
792
815
  case "agent":
793
- 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);
794
817
  case "compute":
795
818
  return { output: await node.run(context), promptText: null };
796
819
  case "notify": {
@@ -833,22 +856,40 @@ export class WorkflowEngine {
833
856
  signal,
834
857
  };
835
858
  }
836
- 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
+ }
837
878
  const basePrompt = await node.prompt(context);
838
879
  if (signal.aborted) {
839
880
  // The node timed out or the run was cancelled while the async prompt
840
- // 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
841
882
  // may already be terminal.
842
883
  throw abortError(signal);
843
884
  }
844
885
  const prompt = appendStepContract(basePrompt, workflow.name, nodeId, attemptId, node.expectedOutput);
845
886
  meta.promptText = prompt;
846
- await this.persist(runDir, state, {
887
+ await this.persist(runId, state, {
847
888
  scope: "agent",
848
889
  type: "agent_prompt_sent",
849
890
  nodeId,
850
891
  attemptId,
851
- payload: { prompt },
892
+ payload: { prompt, completion: assistant === undefined ? "submit" : "assistant" },
852
893
  });
853
894
  const submission = await this.executor.runAgentStep({
854
895
  contract: {
@@ -856,7 +897,11 @@ export class WorkflowEngine {
856
897
  workflowName: workflow.name,
857
898
  nodeId,
858
899
  attemptId,
859
- ...(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 } : {}),
860
905
  },
861
906
  prompt,
862
907
  ...(state.runTitle !== undefined || node.statusDetail !== undefined
@@ -873,10 +918,19 @@ export class WorkflowEngine {
873
918
  return {
874
919
  output: submission.output,
875
920
  promptText: prompt,
921
+ ...(submission.assistantMessage !== undefined
922
+ ? { assistantMessage: submission.assistantMessage }
923
+ : {}),
876
924
  ...(submission.conversation !== undefined ? { conversation: submission.conversation } : {}),
877
925
  };
878
926
  }
879
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
+ }
880
934
  try {
881
935
  const normalized = normalizeAgentOutput(output);
882
936
  const validated = node.validate ? await node.validate(normalized, context) : normalized;
@@ -902,8 +956,8 @@ export class WorkflowEngine {
902
956
  const output = await node.run(actionContext);
903
957
  return { output, promptText: null, action: { actionType: "function" } };
904
958
  }
905
- async persist(runDir, state, event) {
906
- 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);
907
961
  try {
908
962
  this.onEvent?.(traceEvent, state);
909
963
  }
@@ -912,14 +966,14 @@ export class WorkflowEngine {
912
966
  // correctness; a throwing observer would otherwise fail the run.
913
967
  }
914
968
  }
915
- async finishRun(runDir, state, status, fields) {
969
+ async finishRun(runId, state, status, fields) {
916
970
  if (status === "failed" && state.status === "timed_out") {
917
971
  status = "timed_out";
918
972
  }
919
973
  // Let observers (e.g. the session recorder) stop and drain before the
920
- // 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.
921
975
  try {
922
- await this.onRunFinishing?.(runDir, state);
976
+ await this.onRunFinishing?.(runId, state);
923
977
  }
924
978
  catch {
925
979
  // Finishing the run wins over observer failures.
@@ -938,7 +992,7 @@ export class WorkflowEngine {
938
992
  delete state.currentNode;
939
993
  delete state.currentAttemptId;
940
994
  delete state.currentNodeStartedAt;
941
- await this.persist(runDir, state, {
995
+ await this.persist(runId, state, {
942
996
  scope: "run",
943
997
  type: `run_${status}`,
944
998
  payload: {
@@ -968,7 +1022,9 @@ async function runCheckpointNode(node, context, execution) {
968
1022
  prompt,
969
1023
  ...(timeout !== undefined ? { timeout } : {}),
970
1024
  });
971
- await new HumanDecisionStore(execution.store.outputRoot).createRequest(request);
1025
+ await new HumanDecisionStore(execution.store.databasePath, {
1026
+ state: execution.store.state,
1027
+ }).createRequest(request);
972
1028
  return { output: request, promptText: null };
973
1029
  }
974
1030
  const output = node.run ? await node.run(context) : { summary: node.summary ?? "checkpoint" };
@@ -1072,7 +1128,7 @@ function assertInvocationStepLimit(metadata, nodeId, steps) {
1072
1128
  }
1073
1129
  }
1074
1130
  /**
1075
- * 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.
1076
1132
  * Failing here turns a bad callback return value into a normal node failure
1077
1133
  * instead of corrupting the run state.
1078
1134
  */
@@ -1130,11 +1186,35 @@ function normalizeAgentOutput(output) {
1130
1186
  return output;
1131
1187
  }
1132
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
+ }
1133
1197
  /**
1134
1198
  * The step contract appended to every agent-node prompt. This is the
1135
1199
  * documented standard for how the model completes a workflow step.
1136
1200
  */
1137
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
+ }
1138
1218
  return [
1139
1219
  prompt.trimEnd(),
1140
1220
  "",
@@ -1145,7 +1225,7 @@ export function appendStepContract(prompt, workflowName, nodeId, attemptId, expe
1145
1225
  `{"action": "update", "step": ${JSON.stringify(nodeId)}, "attempt": ${JSON.stringify(attemptId)}, "update": {"type": "...", "key": "...", "data": {...}}}`,
1146
1226
  "Complete this step by calling the `workflow` tool exactly once with:",
1147
1227
  `{"action": "submit", "step": ${JSON.stringify(nodeId)}, "attempt": ${JSON.stringify(attemptId)}, "output": <your result>}`,
1148
- `Expected output: ${expectedOutput ?? "a JSON object with your result"}`,
1228
+ `Expected output: ${typeof expectedOutput === "string" ? expectedOutput : "a JSON object with your result"}`,
1149
1229
  "The step is complete only after the workflow tool accepts the output.",
1150
1230
  "If the tool reports a validation error, correct the output and call it again.",
1151
1231
  ].join("\n");