@osolmaz/pi-workflows 0.16.4 → 0.16.6

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 (173) hide show
  1. package/README.md +5 -5
  2. package/dist/builtins/autoimplement.workflow.d.ts +5 -1
  3. package/dist/builtins/autoimplement.workflow.js +29 -10
  4. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  5. package/dist/client/client.js +3 -1
  6. package/dist/client/client.js.map +1 -1
  7. package/dist/client/protocol.d.ts +1 -1
  8. package/dist/client/protocol.js +18 -1
  9. package/dist/client/protocol.js.map +1 -1
  10. package/dist/client/view.d.ts +7 -5
  11. package/dist/extension/index.d.ts +2 -1
  12. package/dist/extension/index.js +115 -165
  13. package/dist/extension/index.js.map +1 -1
  14. package/dist/extension/recorder.d.ts +1 -1
  15. package/dist/extension/recorder.js +2 -2
  16. package/dist/extension/recorder.js.map +1 -1
  17. package/dist/extension/step-message.d.ts +1 -1
  18. package/dist/extension/step-message.js +4 -4
  19. package/dist/extension/step-message.js.map +1 -1
  20. package/dist/extension/workflow-message-coordinator.d.ts +12 -1
  21. package/dist/extension/workflow-message-coordinator.js +65 -54
  22. package/dist/extension/workflow-message-coordinator.js.map +1 -1
  23. package/dist/resource-managers/index.d.ts +1 -2
  24. package/dist/resource-managers/index.js +1 -2
  25. package/dist/resource-managers/index.js.map +1 -1
  26. package/dist/resource-managers/sqlite.d.ts +2 -295
  27. package/dist/resource-managers/sqlite.js +71 -1528
  28. package/dist/resource-managers/sqlite.js.map +1 -1
  29. package/dist/resource-managers/types.d.ts +2 -7
  30. package/dist/server/resource-runner-entry.js +2 -6
  31. package/dist/server/resource-runner-entry.js.map +1 -1
  32. package/dist/server/rpc-bridge.js +1 -1
  33. package/dist/server/rpc-bridge.js.map +1 -1
  34. package/dist/server/rpc-executor.js +2 -4
  35. package/dist/server/rpc-executor.js.map +1 -1
  36. package/dist/server/server-entry.js +3 -0
  37. package/dist/server/server-entry.js.map +1 -1
  38. package/dist/server/server.d.ts +22 -8
  39. package/dist/server/server.js +498 -543
  40. package/dist/server/server.js.map +1 -1
  41. package/dist/server/state.d.ts +16 -29
  42. package/dist/server/state.js +105 -319
  43. package/dist/server/state.js.map +1 -1
  44. package/dist/server/view.d.ts +4 -5
  45. package/dist/server/view.js +51 -51
  46. package/dist/server/view.js.map +1 -1
  47. package/dist/server/workflow-runner-entry.d.ts +1 -1
  48. package/dist/server/workflow-runner-entry.js +1 -49
  49. package/dist/server/workflow-runner-entry.js.map +1 -1
  50. package/dist/server/workflow-runner-protocol.d.ts +2 -7
  51. package/dist/server/workflow-runner-protocol.js +5 -10
  52. package/dist/server/workflow-runner-protocol.js.map +1 -1
  53. package/dist/server/workflow-runner-store.d.ts +5 -6
  54. package/dist/server/workflow-runner-store.js +7 -14
  55. package/dist/server/workflow-runner-store.js.map +1 -1
  56. package/dist/state/attempt-time.d.ts +21 -0
  57. package/dist/state/attempt-time.js +95 -0
  58. package/dist/state/attempt-time.js.map +1 -0
  59. package/dist/state/json.d.ts +2 -1
  60. package/dist/state/json.js.map +1 -1
  61. package/dist/state/project-store.d.ts +39 -0
  62. package/dist/state/project-store.js +154 -0
  63. package/dist/state/project-store.js.map +1 -0
  64. package/dist/state/prune.d.ts +28 -0
  65. package/dist/state/prune.js +278 -83
  66. package/dist/state/prune.js.map +1 -1
  67. package/dist/state/schema.js +23 -23
  68. package/dist/state/workflow-messages.d.ts +1 -1
  69. package/dist/state/workflow-messages.js +3 -1
  70. package/dist/state/workflow-messages.js.map +1 -1
  71. package/dist/viewer/backup.js +5 -0
  72. package/dist/viewer/backup.js.map +1 -1
  73. package/dist/workflows/diagnostics.d.ts +8 -0
  74. package/dist/workflows/diagnostics.js +61 -0
  75. package/dist/workflows/diagnostics.js.map +1 -0
  76. package/dist/workflows/engine.d.ts +2 -15
  77. package/dist/workflows/engine.js +125 -245
  78. package/dist/workflows/engine.js.map +1 -1
  79. package/dist/workflows/human-decision.d.ts +3 -7
  80. package/dist/workflows/human-decision.js +2 -56
  81. package/dist/workflows/human-decision.js.map +1 -1
  82. package/dist/workflows/index.d.ts +2 -1
  83. package/dist/workflows/index.js +1 -0
  84. package/dist/workflows/index.js.map +1 -1
  85. package/dist/workflows/queue.d.ts +267 -0
  86. package/dist/workflows/queue.js +1248 -0
  87. package/dist/workflows/queue.js.map +1 -0
  88. package/dist/workflows/requests.d.ts +47 -0
  89. package/dist/workflows/requests.js +158 -0
  90. package/dist/workflows/requests.js.map +1 -0
  91. package/dist/workflows/schema.js +2 -4
  92. package/dist/workflows/schema.js.map +1 -1
  93. package/dist/workflows/store.d.ts +11 -16
  94. package/dist/workflows/store.js +174 -169
  95. package/dist/workflows/store.js.map +1 -1
  96. package/dist/workflows/tool-input.d.ts +6 -9
  97. package/dist/workflows/tool-input.js +15 -12
  98. package/dist/workflows/tool-input.js.map +1 -1
  99. package/dist/workflows/transitions.d.ts +38 -0
  100. package/dist/workflows/transitions.js +276 -0
  101. package/dist/workflows/transitions.js.map +1 -0
  102. package/dist/workflows/types.d.ts +9 -42
  103. package/dist/workflows/workflow-message-content.d.ts +7 -1
  104. package/dist/workflows/workflow-message-content.js +22 -4
  105. package/dist/workflows/workflow-message-content.js.map +1 -1
  106. package/docs/2026-08-25-workflow-follow-ups.md +5 -5
  107. package/docs/2026-09-02-unify-workflow-messages-plan.md +2 -0
  108. package/docs/2026-09-04-workflow-run-state-plan.md +2 -0
  109. package/docs/2026-09-06-durable-execution-plan.md +176 -0
  110. package/docs/DEFERRED_TURNS.md +84 -105
  111. package/docs/HUMAN_DECISIONS.md +17 -19
  112. package/docs/HUMAN_DECISION_PRESENTATIONS.md +1 -1
  113. package/docs/SQLITE_STATE.md +87 -30
  114. package/docs/WORKFLOW_SERVER.md +50 -11
  115. package/docs/WORKFLOW_STEP_MESSAGES.md +72 -54
  116. package/docs/plans/2026-08-04-controller-runtime-plan.md +2 -0
  117. package/docs/plans/2026-08-21-deferred-turn-intents-plan.md +2 -0
  118. package/docs/plans/2026-08-27-workflow-terminal-restart-plan.md +2 -0
  119. package/docs/plans/2026-09-04-automatic-state-retention-plan.md +283 -0
  120. package/docs/workflows.md +109 -44
  121. package/examples/workflows/autoresearch.workflow.ts +0 -2
  122. package/examples/workflows/branch.workflow.ts +0 -4
  123. package/examples/workflows/echo.workflow.ts +0 -2
  124. package/examples/workflows/live-settings.workflow.ts +0 -2
  125. package/examples/workflows/two-turn.workflow.ts +0 -2
  126. package/herdr-plugin.toml +1 -1
  127. package/package.json +1 -1
  128. package/protocol/fixtures/client-v1.json +1 -1
  129. package/skills/pi-workflows/SKILL.md +8 -7
  130. package/src/builtins/autoimplement.workflow.ts +31 -10
  131. package/src/client/client.ts +3 -1
  132. package/src/client/protocol.ts +21 -1
  133. package/src/client/view.ts +9 -5
  134. package/src/extension/index.ts +117 -189
  135. package/src/extension/recorder.ts +2 -2
  136. package/src/extension/step-message.ts +4 -4
  137. package/src/extension/workflow-message-coordinator.ts +78 -50
  138. package/src/resource-managers/index.ts +1 -9
  139. package/src/resource-managers/sqlite.ts +330 -2655
  140. package/src/resource-managers/types.ts +2 -3
  141. package/src/server/resource-runner-entry.ts +2 -7
  142. package/src/server/rpc-bridge.ts +1 -1
  143. package/src/server/rpc-executor.ts +4 -9
  144. package/src/server/server-entry.ts +3 -0
  145. package/src/server/server.ts +568 -644
  146. package/src/server/state.ts +122 -466
  147. package/src/server/view.ts +56 -60
  148. package/src/server/workflow-runner-entry.ts +2 -55
  149. package/src/server/workflow-runner-protocol.ts +5 -15
  150. package/src/server/workflow-runner-store.ts +12 -27
  151. package/src/state/attempt-time.ts +122 -0
  152. package/src/state/json.ts +2 -1
  153. package/src/state/project-store.ts +218 -0
  154. package/src/state/prune.ts +408 -95
  155. package/src/state/schema.ts +23 -23
  156. package/src/state/workflow-messages.ts +4 -2
  157. package/src/viewer/backup.ts +7 -0
  158. package/src/workflows/diagnostics.ts +78 -0
  159. package/src/workflows/engine.ts +133 -291
  160. package/src/workflows/human-decision.ts +3 -81
  161. package/src/workflows/index.ts +8 -2
  162. package/src/workflows/queue.ts +2091 -0
  163. package/src/workflows/requests.ts +283 -0
  164. package/src/workflows/schema.ts +2 -6
  165. package/src/workflows/store.ts +223 -231
  166. package/src/workflows/tool-input.ts +20 -12
  167. package/src/workflows/transitions.ts +330 -0
  168. package/src/workflows/types.ts +9 -46
  169. package/src/workflows/workflow-message-content.ts +28 -4
  170. package/dist/resource-managers/runtime.d.ts +0 -59
  171. package/dist/resource-managers/runtime.js +0 -423
  172. package/dist/resource-managers/runtime.js.map +0 -1
  173. package/src/resource-managers/runtime.ts +0 -576
@@ -18,8 +18,9 @@ import {
18
18
  WorkflowSourceChangedError,
19
19
  } from "./errors.js";
20
20
  import { resolveNext, resolveNextForOutcome, validateWorkflowDefinition } from "./graph.js";
21
- import { createHumanDecisionRequest, validateHumanDecisionResponse } from "./human-decision.js";
21
+ import { createHumanDecisionRequest } from "./human-decision.js";
22
22
  import { extractJsonValue } from "./json.js";
23
+ import { workflowRequestId } from "./requests.js";
23
24
  import {
24
25
  parseWorkflowSettingsValue,
25
26
  resolveInitialWorkflowSettings,
@@ -33,11 +34,12 @@ import {
33
34
  createRunId,
34
35
  type WorkflowExecutionStore,
35
36
  } from "./store.js";
37
+ import { executionTransition } from "./transitions.js";
36
38
  import type {
37
- ResolvedHumanDecision,
38
39
  AgentExpectedOutput,
39
40
  AgentNodeDefinition,
40
41
  AgentStepExecutor,
42
+ AgentStepContract,
41
43
  AssistantMessageOutput,
42
44
  AssistantMessageReceipt,
43
45
  ActionNodeDefinition,
@@ -54,7 +56,6 @@ import type {
54
56
  WorkflowNodeOutcome,
55
57
  WorkflowNodeResult,
56
58
  WorkflowNotificationSink,
57
- HumanDecisionRequest,
58
59
  WorkflowRunResult,
59
60
  WorkflowRunState,
60
61
  WorkflowSource,
@@ -63,7 +64,12 @@ import type {
63
64
  WorkflowUpdateInput,
64
65
  WorkflowUpdateReceipt,
65
66
  } from "./types.js";
66
- import { UpdateRateLimiter, updateReceipt, validateWorkflowUpdate } from "./updates.js";
67
+ import {
68
+ UpdateRateLimiter,
69
+ updateProjection,
70
+ updateReceipt,
71
+ validateWorkflowUpdate,
72
+ } from "./updates.js";
67
73
 
68
74
  const DEFAULT_NODE_TIMEOUT_MS = 15 * 60_000;
69
75
  const DEFAULT_MAX_STEPS = 100;
@@ -101,7 +107,8 @@ type ResumedNodeAttempt = {
101
107
  nodeId: string;
102
108
  attemptId: string;
103
109
  startedAt: string;
104
- deadlineAt?: string | null;
110
+ timeoutMs?: number | null;
111
+ elapsedMs?: number;
105
112
  settings?: WorkflowSettingsScopeRecord;
106
113
  };
107
114
 
@@ -119,7 +126,6 @@ export class WorkflowEngine {
119
126
  private readonly maxSteps: number;
120
127
  private readonly onEvent?: WorkflowEngineOptions["onEvent"];
121
128
  private readonly onRunStarted?: WorkflowEngineOptions["onRunStarted"];
122
- private readonly onRunFinishing?: WorkflowEngineOptions["onRunFinishing"];
123
129
  private activeAbort: AbortController | null = null;
124
130
  private activeAttempt:
125
131
  | {
@@ -136,7 +142,6 @@ export class WorkflowEngine {
136
142
  private cancelled = false;
137
143
  private parked = false;
138
144
  private paused = false;
139
- private presentationRequired = false;
140
145
  private wakePause: (() => void) | null = null;
141
146
 
142
147
  constructor(options: WorkflowEngineOptions) {
@@ -147,7 +152,6 @@ export class WorkflowEngine {
147
152
  this.maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS;
148
153
  this.onEvent = options.onEvent;
149
154
  this.onRunStarted = options.onRunStarted;
150
- this.onRunFinishing = options.onRunFinishing;
151
155
  }
152
156
 
153
157
  get databasePath(): string {
@@ -190,12 +194,14 @@ export class WorkflowEngine {
190
194
  limiter.take();
191
195
  const { event, record } = await this.store.publishUpdate(
192
196
  active.runId,
193
- active.state,
194
197
  active.nodeId,
195
198
  active.attemptId,
196
199
  update,
197
200
  { signal: active.signal },
198
201
  );
202
+ active.state.updates = updateProjection(active.state.updates, record);
203
+ active.state.traceSeq = event.seq;
204
+ active.state.updatedAt = event.at;
199
205
  try {
200
206
  this.onEvent?.(event, active.state);
201
207
  } catch {
@@ -257,7 +263,6 @@ export class WorkflowEngine {
257
263
  ): Promise<WorkflowRunResult> {
258
264
  workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
259
265
  validateWorkflowDefinition(workflow);
260
- this.presentationRequired = workflow.presentationPrompt !== undefined;
261
266
  // Fail before any run row exists so bad input cannot leave partial state.
262
267
  const suppliedInput = input === undefined ? null : input;
263
268
  const normalizedInput = workflow.input ? await workflow.input(suppliedInput) : suppliedInput;
@@ -330,19 +335,20 @@ export class WorkflowEngine {
330
335
  ): Promise<WorkflowRunResult> {
331
336
  workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
332
337
  validateWorkflowDefinition(workflow);
333
- this.presentationRequired = workflow.presentationPrompt !== undefined;
334
338
  // Reset before any await: a park or cancel landing during preparation
335
339
  // must survive, or a server drain would hang while the run executes.
336
340
  this.cancelled = false;
337
341
  this.paused = false;
338
342
  this.parked = false;
339
- const state = await this.store.prepareRunResume(runId);
340
- const sourceMismatch = workflowIdentityMismatch(state, workflow, options.workflowSource);
343
+ const stored = await this.store.readRunState(runId);
344
+ if (stored === null) throw new Error(`Cannot resume unreadable workflow run: ${runId}`);
345
+ const sourceMismatch = workflowIdentityMismatch(stored, workflow, options.workflowSource);
341
346
  if (sourceMismatch && options.force !== true) {
342
347
  throw new WorkflowSourceChangedError(runId);
343
348
  }
349
+ const state = await this.store.prepareRunResume(runId);
344
350
 
345
- const point = this.resumePointFor(workflow, state, "wait");
351
+ const point = this.resumePointFor(workflow, state);
346
352
  const interruptedNode =
347
353
  state.currentNode !== undefined ? workflow.nodes[state.currentNode] : undefined;
348
354
  let resumedAttempt: ResumedNodeAttempt | undefined;
@@ -350,18 +356,19 @@ export class WorkflowEngine {
350
356
  state.currentNode !== undefined &&
351
357
  state.currentAttemptId !== undefined &&
352
358
  state.currentNodeStartedAt !== undefined &&
353
- interruptedNode?.nodeType === "agent" &&
354
- (assistantMessageConfig(interruptedNode) !== undefined ||
355
- options.resumeInteractionAttemptId === state.currentAttemptId)
359
+ (interruptedNode?.nodeType === "checkpoint" ||
360
+ (interruptedNode?.nodeType === "agent" &&
361
+ (assistantMessageConfig(interruptedNode) !== undefined ||
362
+ options.resumeInteractionAttemptId === state.currentAttemptId)))
356
363
  ) {
357
364
  const resumedSettings = await this.persistedSettingsBinding(state);
358
365
  resumedAttempt = {
359
366
  nodeId: state.currentNode,
360
367
  attemptId: state.currentAttemptId,
361
368
  startedAt: state.currentNodeStartedAt,
362
- ...(state.currentNodeDeadlineAt === undefined
369
+ ...(state.currentNodeTimeoutMs === undefined
363
370
  ? {}
364
- : { deadlineAt: state.currentNodeDeadlineAt }),
371
+ : { timeoutMs: state.currentNodeTimeoutMs, elapsedMs: state.currentNodeElapsedMs ?? 0 }),
365
372
  ...(resumedSettings !== undefined ? { settings: resumedSettings } : {}),
366
373
  };
367
374
  }
@@ -374,7 +381,8 @@ export class WorkflowEngine {
374
381
  delete state.currentNode;
375
382
  delete state.currentAttemptId;
376
383
  delete state.currentNodeStartedAt;
377
- delete state.currentNodeDeadlineAt;
384
+ delete state.currentNodeTimeoutMs;
385
+ delete state.currentNodeElapsedMs;
378
386
  delete state.currentSettingsScopeId;
379
387
  delete state.currentSettingsChangeNumber;
380
388
  delete state.currentSettingsHash;
@@ -393,15 +401,8 @@ export class WorkflowEngine {
393
401
  await this.onRunStarted?.(runId, state);
394
402
 
395
403
  if (point.nodeId === null) {
396
- // The last recorded transition already finished the graph; the crash
397
- // happened before the terminal event was written. A finished
398
- // checkpoint restores its waiting gate rather than completing.
399
- if (point.waitingOn !== undefined) {
400
- await this.finishRun(runId, state, "waiting", {
401
- waitingOn: point.waitingOn,
402
- finalOutput: point.lastOutput,
403
- });
404
- } else if (point.failedResult === undefined) {
404
+ // The last attempt finished before the terminal event was committed.
405
+ if (point.failedResult === undefined) {
405
406
  await this.finishRun(runId, state, "completed", { finalOutput: point.lastOutput });
406
407
  } else {
407
408
  const timedOut = point.failedResult.outcome === "timed_out";
@@ -433,161 +434,6 @@ export class WorkflowEngine {
433
434
  return { runId, state };
434
435
  }
435
436
 
436
- /**
437
- * Start a continuation run from a checkpointed parent. The new run gets a
438
- * fresh run and event stream, carries forward the parent's outputs, results,
439
- * and step accounting, and continues routing after the checkpoint.
440
- */
441
- async continueRun(
442
- workflow: WorkflowDefinition,
443
- parentRunId: string,
444
- input: unknown,
445
- options: {
446
- workflowSource?: WorkflowSource;
447
- runId?: string;
448
- force?: boolean;
449
- humanDecision?: ResolvedHumanDecision;
450
- } = {},
451
- ): Promise<WorkflowRunResult> {
452
- workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
453
- validateWorkflowDefinition(workflow);
454
- this.presentationRequired = workflow.presentationPrompt !== undefined;
455
- this.cancelled = false;
456
- this.paused = false;
457
- this.parked = false;
458
- const parentState = await this.store.readRunState(parentRunId);
459
- if (parentState === null) {
460
- throw new Error(`Cannot continue from unreadable workflow run: ${parentRunId}`);
461
- }
462
- if (parentState.status !== "waiting" || parentState.waitingOn === undefined) {
463
- throw new Error(
464
- `Cannot continue workflow run ${parentRunId} with status ${parentState.status}`,
465
- );
466
- }
467
- const sourceMismatch = workflowIdentityMismatch(parentState, workflow, options.workflowSource);
468
- if (sourceMismatch && options.force !== true) {
469
- throw new WorkflowSourceChangedError(parentRunId);
470
- }
471
-
472
- const waitingNodeId = parentState.waitingOn;
473
- const waitingNode = workflow.nodes[waitingNodeId];
474
- const humanContract =
475
- waitingNode?.nodeType === "checkpoint" ? waitingNode.humanDecision : undefined;
476
- let acceptedResponse: unknown;
477
- let acceptedNodeId: string | undefined;
478
- let normalizedInput: unknown;
479
- if (humanContract !== undefined) {
480
- if (options.humanDecision === undefined) {
481
- throw new Error(`Checkpoint ${waitingNodeId} requires an accepted verified human decision`);
482
- }
483
- const request = parentState.finalOutput as HumanDecisionRequest;
484
- if (
485
- request?.schema !== "pi-workflows.human-decision-request.v1" ||
486
- request.decisionId !== options.humanDecision.decisionId ||
487
- request.requestDigest !== options.humanDecision.requestDigest
488
- ) {
489
- throw new Error("Accepted human decision does not match the waiting request");
490
- }
491
- const durableDecision = await this.store.readResolvedHumanDecision(request.decisionId);
492
- if (durableDecision === null || !isDeepStrictEqual(durableDecision, options.humanDecision)) {
493
- throw new Error("Accepted human decision does not match the durable decision record");
494
- }
495
- acceptedResponse = validateHumanDecisionResponse(request, durableDecision.response);
496
- acceptedNodeId = request.nodeId;
497
- normalizedInput = structuredClone(parentState.input);
498
- } else {
499
- const suppliedInput = input === undefined ? null : input;
500
- normalizedInput = workflow.input ? await workflow.input(suppliedInput) : suppliedInput;
501
- }
502
- assertJsonSerializable(normalizedInput, "Workflow run input");
503
- if (options.runId !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(options.runId)) {
504
- throw new Error(`Invalid workflow run id: ${JSON.stringify(options.runId)}`);
505
- }
506
-
507
- const state = await this.createRunState(
508
- workflow,
509
- normalizedInput,
510
- options.workflowSource,
511
- options.runId,
512
- );
513
- state.parentRunId = parentRunId;
514
- state.outputs = structuredClone(parentState.outputs);
515
- state.results = structuredClone(parentState.results);
516
- state.steps = structuredClone(parentState.steps);
517
- if (humanContract !== undefined && options.humanDecision !== undefined) {
518
- const receipt = {
519
- decisionId: options.humanDecision.decisionId,
520
- requestDigest: options.humanDecision.requestDigest,
521
- nodeId: acceptedNodeId ?? waitingNodeId,
522
- response: options.humanDecision.response,
523
- provenance: options.humanDecision.provenance,
524
- acceptedAt: options.humanDecision.acceptedAt,
525
- answerDigest: options.humanDecision.answerDigest,
526
- };
527
- state.humanDecision = {
528
- schema: "pi-workflows.human-decision-receipt.v1",
529
- ...receipt,
530
- subjectDigest: options.humanDecision.subjectDigest,
531
- presentationDigest: options.humanDecision.presentationDigest,
532
- revision: options.humanDecision.revision,
533
- };
534
- state.outputs[waitingNodeId] = acceptedResponse;
535
- const priorResult = state.results[waitingNodeId];
536
- if (priorResult === undefined) {
537
- throw new Error(`Waiting human decision result is missing for ${waitingNodeId}`);
538
- }
539
- state.results[waitingNodeId] = { ...priorResult, output: acceptedResponse };
540
- const stepIndex = state.steps.findLastIndex((step) => step.nodeId === waitingNodeId);
541
- if (stepIndex < 0)
542
- throw new Error(`Waiting human decision step is missing for ${waitingNodeId}`);
543
- const priorStep = state.steps[stepIndex];
544
- if (priorStep === undefined)
545
- throw new Error("Waiting human decision step became unavailable");
546
- state.steps[stepIndex] = { ...priorStep, output: acceptedResponse };
547
- }
548
- state.carriedStepCount = state.steps.length;
549
-
550
- const runId = await this.store.initializeRun(workflow, state);
551
- await this.persist(runId, state, {
552
- scope: "run",
553
- type: "run_started",
554
- payload: {
555
- workflowName: workflow.name,
556
- ...(state.runTitle ? { runTitle: state.runTitle } : {}),
557
- input: state.input,
558
- continuedFrom: parentRunId,
559
- checkpoint: parentState.waitingOn,
560
- carriedSteps: state.steps.length,
561
- },
562
- });
563
- await this.onRunStarted?.(runId, state);
564
-
565
- const point = this.resumePointFor(workflow, state, "continue");
566
- if (point.nodeId === null) {
567
- // The checkpoint was the final node; the answer completes the chain.
568
- await this.finishRun(runId, state, "completed", { finalOutput: point.lastOutput });
569
- return { runId, state };
570
- }
571
- try {
572
- await this.executeGraph(
573
- workflow,
574
- state,
575
- runId,
576
- point.nodeId,
577
- countExecutableSteps(workflow, state.steps),
578
- point.lastOutput,
579
- );
580
- } catch (error) {
581
- if (isClaimLostError(error)) throw error;
582
- if (isRunParkedError(error) || this.parked) {
583
- return { runId, state };
584
- }
585
- await this.finishAfterError(runId, state, error);
586
- return { runId, state };
587
- }
588
- return { runId, state };
589
- }
590
-
591
437
  /**
592
438
  * Find where a resumed run continues. An in-flight node reruns; otherwise
593
439
  * routing continues from the last recorded step. A null nodeId means the
@@ -596,12 +442,10 @@ export class WorkflowEngine {
596
442
  private resumePointFor(
597
443
  workflow: WorkflowDefinition,
598
444
  state: WorkflowRunState,
599
- checkpointBehavior: "wait" | "continue",
600
445
  ): {
601
446
  nodeId: string | null;
602
447
  lastOutput?: unknown;
603
448
  failedResult?: WorkflowNodeResult;
604
- waitingOn?: string;
605
449
  } {
606
450
  if (state.currentNode !== undefined) {
607
451
  if (workflow.nodes[state.currentNode] === undefined) {
@@ -618,19 +462,6 @@ export class WorkflowEngine {
618
462
  return { nodeId: lastStep.nodeId };
619
463
  }
620
464
  if (result.outcome === "ok") {
621
- // A recorded checkpoint means the run should be waiting; a crash
622
- // before the run_waiting persist restores the gate instead of
623
- // routing past it. The gate applies to this run's own checkpoint
624
- // only: a continuation's carried steps end with the parent's
625
- // already-answered checkpoint, and routing must continue from it.
626
- const isCarriedStep = state.steps.length <= (state.carriedStepCount ?? 0);
627
- if (
628
- checkpointBehavior === "wait" &&
629
- !isCarriedStep &&
630
- workflow.nodes[lastStep.nodeId]?.nodeType === "checkpoint"
631
- ) {
632
- return { nodeId: null, waitingOn: lastStep.nodeId, lastOutput: result.output };
633
- }
634
465
  const next = resolveNext(workflow.edges, lastStep.nodeId, result.output, result);
635
466
  return next === null
636
467
  ? { nodeId: null, lastOutput: result.output }
@@ -833,6 +664,7 @@ export class WorkflowEngine {
833
664
  scopeId: latest.scopeId,
834
665
  previousChangeNumber: attempt.settings.changeNumber,
835
666
  changeNumber: latest.changeNumber,
667
+ settingsHash: latest.settingsHash,
836
668
  },
837
669
  });
838
670
  resumedAttempt = {
@@ -881,13 +713,6 @@ export class WorkflowEngine {
881
713
  }
882
714
 
883
715
  lastOutput = attempt.result.output;
884
- if (node.nodeType === "checkpoint") {
885
- await this.finishRun(runId, state, "waiting", {
886
- waitingOn: attempt.result.nodeId,
887
- finalOutput: lastOutput,
888
- });
889
- return;
890
- }
891
716
  currentNodeId = resolveNext(
892
717
  workflow.edges,
893
718
  attempt.result.nodeId,
@@ -999,7 +824,8 @@ export class WorkflowEngine {
999
824
  delete state.currentNode;
1000
825
  delete state.currentAttemptId;
1001
826
  delete state.currentNodeStartedAt;
1002
- delete state.currentNodeDeadlineAt;
827
+ delete state.currentNodeTimeoutMs;
828
+ delete state.currentNodeElapsedMs;
1003
829
  delete state.currentSettingsScopeId;
1004
830
  delete state.currentSettingsChangeNumber;
1005
831
  delete state.currentSettingsHash;
@@ -1063,7 +889,8 @@ export class WorkflowEngine {
1063
889
  node,
1064
890
  meta,
1065
891
  settings,
1066
- resumedAttempt?.deadlineAt,
892
+ resumedAttempt?.timeoutMs,
893
+ resumedAttempt?.elapsedMs,
1067
894
  );
1068
895
  return {
1069
896
  result: this.createNodeResult(nodeId, node, attemptId, startedAt, "ok", execution.output),
@@ -1131,7 +958,8 @@ export class WorkflowEngine {
1131
958
  node: WorkflowNodeDefinition,
1132
959
  meta: NodeExecutionMeta,
1133
960
  settings?: WorkflowSettingsScopeRecord,
1134
- persistedDeadlineAt?: string | null,
961
+ persistedTimeoutMs?: number | null,
962
+ elapsedMs = 0,
1135
963
  ): Promise<NodeExecution> {
1136
964
  const abort = new AbortController();
1137
965
  const context = this.createNodeContext(state, abort.signal, settings);
@@ -1150,39 +978,40 @@ export class WorkflowEngine {
1150
978
 
1151
979
  let timeoutMs: number | null;
1152
980
  let timeoutErrorMs: number | null;
1153
- if (persistedDeadlineAt === undefined) {
981
+ if (persistedTimeoutMs === undefined) {
1154
982
  timeoutMs = await this.resolveNodeTimeout(node, context, abort);
1155
983
  timeoutErrorMs = timeoutMs;
1156
- if (node.nodeType === "agent" && this.executor.preservesDeadlineWhileParked === true) {
1157
- state.currentNodeDeadlineAt =
1158
- timeoutMs === null ? null : new Date(Date.now() + timeoutMs).toISOString();
984
+ if (node.nodeType === "agent" && this.executor.preservesActiveTimeBudget === true) {
985
+ state.currentNodeTimeoutMs = timeoutMs;
986
+ state.currentNodeElapsedMs = 0;
1159
987
  await this.persist(runId, state, {
1160
988
  scope: "node",
1161
- type: "node_deadline_set",
989
+ type: "node_timeout_set",
1162
990
  nodeId,
1163
991
  attemptId,
1164
- payload: { deadlineAt: state.currentNodeDeadlineAt },
992
+ payload: { timeoutMs },
1165
993
  });
1166
- if (state.currentNodeDeadlineAt !== null && timeoutErrorMs !== null) {
1167
- timeoutMs = Date.parse(state.currentNodeDeadlineAt) - Date.now();
1168
- if (timeoutMs <= 0) throw new TimeoutError(timeoutErrorMs);
1169
- }
1170
994
  } else {
1171
- delete state.currentNodeDeadlineAt;
995
+ delete state.currentNodeTimeoutMs;
996
+ delete state.currentNodeElapsedMs;
1172
997
  }
1173
- } else if (persistedDeadlineAt === null) {
1174
- state.currentNodeDeadlineAt = null;
998
+ } else if (persistedTimeoutMs === null) {
999
+ state.currentNodeTimeoutMs = null;
1175
1000
  timeoutMs = null;
1176
1001
  timeoutErrorMs = null;
1177
1002
  } else {
1178
- const deadline = Date.parse(persistedDeadlineAt);
1179
- const started = Date.parse(state.currentNodeStartedAt ?? persistedDeadlineAt);
1180
- if (!Number.isFinite(deadline) || !Number.isFinite(started)) {
1181
- throw new Error("Persisted node deadline is invalid");
1003
+ if (
1004
+ !Number.isFinite(persistedTimeoutMs) ||
1005
+ persistedTimeoutMs <= 0 ||
1006
+ !Number.isFinite(elapsedMs) ||
1007
+ elapsedMs < 0
1008
+ ) {
1009
+ throw new Error("Persisted node timeout is invalid");
1182
1010
  }
1183
- state.currentNodeDeadlineAt = persistedDeadlineAt;
1184
- timeoutMs = deadline - Date.now();
1185
- timeoutErrorMs = Math.max(1, deadline - started);
1011
+ state.currentNodeTimeoutMs = persistedTimeoutMs;
1012
+ state.currentNodeElapsedMs = elapsedMs;
1013
+ timeoutMs = persistedTimeoutMs - elapsedMs;
1014
+ timeoutErrorMs = persistedTimeoutMs;
1186
1015
  if (timeoutMs <= 0) throw new TimeoutError(timeoutErrorMs);
1187
1016
  }
1188
1017
  if (abort.signal.aborted) {
@@ -1333,13 +1162,40 @@ export class WorkflowEngine {
1333
1162
  }
1334
1163
  case "action":
1335
1164
  return await this.runActionNode(node, context, nodeId, attemptId, signal, meta);
1336
- case "checkpoint":
1337
- return await runCheckpointNode(node, context, {
1338
- store: this.store,
1339
- workflowName: workflow.name,
1340
- nodeId,
1341
- attemptId,
1342
- });
1165
+ case "checkpoint": {
1166
+ const saved = await this.store.readCheckpoint(state.runId, attemptId);
1167
+ if (saved !== undefined) {
1168
+ if (saved.request.status === "cancelled")
1169
+ throw new Error("Checkpoint request is cancelled");
1170
+ if (saved.request.status === "settled") {
1171
+ if (saved.humanDecision !== undefined) state.humanDecision = saved.humanDecision;
1172
+ return { output: saved.output, promptText: null };
1173
+ }
1174
+ } else {
1175
+ const request = await checkpointRequest(node, context, {
1176
+ workflowName: workflow.name,
1177
+ nodeId,
1178
+ attemptId,
1179
+ });
1180
+ const event = await this.store.commitTransition(state.runId, {
1181
+ kind: "waitForInput",
1182
+ ...request,
1183
+ event: {
1184
+ scope: "node",
1185
+ type: "checkpoint_requested",
1186
+ nodeId,
1187
+ attemptId,
1188
+ payload: { requestId: request.requestId, requestKind: request.requestKind },
1189
+ },
1190
+ });
1191
+ state.traceSeq = event.seq;
1192
+ state.updatedAt = event.at;
1193
+ }
1194
+ state.status = "waiting";
1195
+ state.waitingOn = nodeId;
1196
+ delete state.finishedAt;
1197
+ throw new RunParkedError();
1198
+ }
1343
1199
  }
1344
1200
  }
1345
1201
 
@@ -1414,11 +1270,12 @@ export class WorkflowEngine {
1414
1270
  signal: AbortSignal,
1415
1271
  settings?: WorkflowSettingsScopeRecord,
1416
1272
  ): WorkflowNodeContext {
1273
+ const snapshot = deepFreezeJson(structuredClone(state));
1417
1274
  return {
1418
- input: state.input,
1419
- outputs: state.outputs,
1420
- results: state.results,
1421
- state,
1275
+ input: snapshot.input,
1276
+ outputs: snapshot.outputs,
1277
+ results: snapshot.results,
1278
+ state: snapshot,
1422
1279
  ...(settings !== undefined
1423
1280
  ? {
1424
1281
  settings: deepFreezeJson(structuredClone(settings.settings)),
@@ -1468,13 +1325,17 @@ export class WorkflowEngine {
1468
1325
  // may already be terminal.
1469
1326
  throw abortError(signal);
1470
1327
  }
1471
- const prompt = appendStepContract(
1472
- basePrompt,
1473
- workflow.name,
1328
+ const contract: AgentStepContract = {
1329
+ requestId: workflowRequestId(state.runId, attemptId),
1330
+ runId: state.runId,
1331
+ workflowName: workflow.name,
1474
1332
  nodeId,
1475
1333
  attemptId,
1476
- node.expectedOutput,
1477
- );
1334
+ completion: assistant === undefined ? "submit" : "assistant",
1335
+ ...(typeof node.expectedOutput === "string" ? { expectedOutput: node.expectedOutput } : {}),
1336
+ ...(assistant?.maxChars !== undefined ? { maxOutputChars: assistant.maxChars } : {}),
1337
+ };
1338
+ const prompt = appendStepContract(basePrompt, contract);
1478
1339
  meta.promptText = prompt;
1479
1340
  await this.persist(runId, state, {
1480
1341
  scope: "agent",
@@ -1486,17 +1347,7 @@ export class WorkflowEngine {
1486
1347
 
1487
1348
  const submission = await this.executor.runAgentStep(
1488
1349
  {
1489
- contract: {
1490
- runId: state.runId,
1491
- workflowName: workflow.name,
1492
- nodeId,
1493
- attemptId,
1494
- completion: assistant === undefined ? "submit" : "assistant",
1495
- ...(typeof node.expectedOutput === "string"
1496
- ? { expectedOutput: node.expectedOutput }
1497
- : {}),
1498
- ...(assistant?.maxChars !== undefined ? { maxOutputChars: assistant.maxChars } : {}),
1499
- },
1350
+ contract,
1500
1351
  prompt,
1501
1352
  ...(state.runTitle !== undefined || node.statusDetail !== undefined
1502
1353
  ? {
@@ -1632,7 +1483,9 @@ export class WorkflowEngine {
1632
1483
  state: WorkflowRunState,
1633
1484
  event: WorkflowTraceEventDraft,
1634
1485
  ): Promise<void> {
1635
- const traceEvent = await this.store.writeSnapshot(runId, state, event);
1486
+ const traceEvent = await this.store.commitTransition(runId, executionTransition(state, event));
1487
+ state.traceSeq = traceEvent.seq;
1488
+ state.updatedAt = traceEvent.at;
1636
1489
  try {
1637
1490
  this.onEvent?.(traceEvent, state);
1638
1491
  } catch {
@@ -1645,8 +1498,8 @@ export class WorkflowEngine {
1645
1498
  private async finishRun(
1646
1499
  runId: string,
1647
1500
  state: WorkflowRunState,
1648
- status: WorkflowRunState["status"],
1649
- fields: { error?: string; waitingOn?: string; finalOutput?: unknown },
1501
+ status: "completed" | "failed" | "timed_out" | "cancelled",
1502
+ fields: { error?: string; finalOutput?: unknown },
1650
1503
  ): Promise<void> {
1651
1504
  if (status === "failed" && state.status === "timed_out") {
1652
1505
  status = "timed_out";
@@ -1656,50 +1509,38 @@ export class WorkflowEngine {
1656
1509
  if (fields.error !== undefined) {
1657
1510
  state.error = fields.error;
1658
1511
  }
1659
- if (fields.waitingOn !== undefined) {
1660
- state.waitingOn = fields.waitingOn;
1661
- }
1662
1512
  if (fields.finalOutput !== undefined) {
1663
1513
  state.finalOutput = fields.finalOutput;
1664
1514
  }
1665
1515
  delete state.currentNode;
1666
1516
  delete state.currentAttemptId;
1667
1517
  delete state.currentNodeStartedAt;
1668
- delete state.currentNodeDeadlineAt;
1518
+ delete state.currentNodeTimeoutMs;
1519
+ delete state.currentNodeElapsedMs;
1669
1520
  delete state.currentSettingsScopeId;
1670
1521
  delete state.currentSettingsChangeNumber;
1671
1522
  delete state.currentSettingsHash;
1672
- // Let observers stop, drain, and prepare terminal delivery from the final
1673
- // in-memory state before the immutable terminal event is committed.
1674
- try {
1675
- await this.onRunFinishing?.(runId, state);
1676
- } catch {
1677
- // Finishing the run wins over observer failures.
1678
- }
1679
1523
  await this.persist(runId, state, {
1680
1524
  scope: "run",
1681
1525
  type: `run_${status}`,
1682
1526
  payload: {
1683
1527
  status,
1684
1528
  ...(fields.error !== undefined ? { error: fields.error } : {}),
1685
- ...(fields.waitingOn !== undefined ? { waitingOn: fields.waitingOn } : {}),
1686
1529
  ...(fields.finalOutput !== undefined ? { finalOutput: fields.finalOutput } : {}),
1687
- presentationRequired: status === "completed" && this.presentationRequired,
1688
1530
  },
1689
1531
  });
1690
1532
  }
1691
1533
  }
1692
1534
 
1693
- async function runCheckpointNode(
1535
+ async function checkpointRequest(
1694
1536
  node: CheckpointNodeDefinition,
1695
1537
  context: WorkflowNodeContext,
1696
1538
  execution: {
1697
- store: WorkflowExecutionStore;
1698
1539
  workflowName: string;
1699
1540
  nodeId: string;
1700
1541
  attemptId: string;
1701
1542
  },
1702
- ): Promise<NodeExecution> {
1543
+ ): Promise<{ requestId: string; requestKind: "checkpoint" | "decision"; contract: JsonValue }> {
1703
1544
  if (node.humanDecision !== undefined) {
1704
1545
  const prompt = await node.humanDecision.request(context);
1705
1546
  const audience =
@@ -1719,11 +1560,18 @@ async function runCheckpointNode(
1719
1560
  prompt,
1720
1561
  ...(timeout !== undefined ? { timeout } : {}),
1721
1562
  });
1722
- await execution.store.createHumanDecisionRequest(request);
1723
- return { output: request, promptText: null };
1563
+ return {
1564
+ requestId: request.decisionId,
1565
+ requestKind: "decision",
1566
+ contract: request as unknown as JsonValue,
1567
+ };
1724
1568
  }
1725
1569
  const output = node.run ? await node.run(context) : { summary: node.summary ?? "checkpoint" };
1726
- return { output, promptText: null };
1570
+ return {
1571
+ requestId: workflowRequestId(context.state.runId, execution.attemptId),
1572
+ requestKind: "checkpoint",
1573
+ contract: output as JsonValue,
1574
+ };
1727
1575
  }
1728
1576
 
1729
1577
  function shellReceipt(result: ShellActionResult): WorkflowActionReceipt {
@@ -2040,15 +1888,9 @@ function settingsDefinitionForNode(
2040
1888
  * The step contract appended to every agent-node prompt. This is the
2041
1889
  * documented standard for how the model completes a workflow step.
2042
1890
  */
2043
- export function appendStepContract(
2044
- prompt: string,
2045
- workflowName: string,
2046
- nodeId: string,
2047
- attemptId: string,
2048
- expectedOutput: AgentExpectedOutput | undefined,
2049
- ): string {
2050
- const assistant = assistantMessageOutput(expectedOutput);
2051
- if (assistant !== undefined) {
1891
+ export function appendStepContract(prompt: string, contract: AgentStepContract): string {
1892
+ const { requestId, workflowName, nodeId, attemptId, expectedOutput } = contract;
1893
+ if (contract.completion === "assistant") {
2052
1894
  return [
2053
1895
  prompt.trimEnd(),
2054
1896
  "",
@@ -2058,8 +1900,8 @@ export function appendStepContract(
2058
1900
  "Reply with a normal assistant message.",
2059
1901
  "Do not call the workflow tool to complete this step.",
2060
1902
  "Your visible reply becomes the workflow step output after the turn settles.",
2061
- ...(assistant.maxChars !== undefined
2062
- ? [`Keep the visible reply within ${assistant.maxChars} characters.`]
1903
+ ...(contract.maxOutputChars !== undefined
1904
+ ? [`Keep the visible reply within ${contract.maxOutputChars} characters.`]
2063
1905
  : []),
2064
1906
  ].join("\n");
2065
1907
  }
@@ -2070,9 +1912,9 @@ export function appendStepContract(
2070
1912
  `Workflow step contract (workflow: ${workflowName}, step: ${nodeId}, attempt: ${attemptId})`,
2071
1913
  "",
2072
1914
  "While this step is active, you may publish non-completing updates with:",
2073
- `{"action": "update", "step": ${JSON.stringify(nodeId)}, "attempt": ${JSON.stringify(attemptId)}, "update": {"type": "...", "key": "...", "data": {...}}}`,
1915
+ `{"action": "update", "requestId": ${JSON.stringify(requestId)}, "update": {"type": "...", "key": "...", "data": {...}}}`,
2074
1916
  "Complete this step by calling the `workflow` tool exactly once with:",
2075
- `{"action": "submit", "step": ${JSON.stringify(nodeId)}, "attempt": ${JSON.stringify(attemptId)}, "output": <your result>}`,
1917
+ `{"action": "submit", "requestId": ${JSON.stringify(requestId)}, "output": <your result>}`,
2076
1918
  `Expected output: ${typeof expectedOutput === "string" ? expectedOutput : "a JSON object with your result"}`,
2077
1919
  "The step is complete only after the workflow tool accepts the output.",
2078
1920
  "If the tool reports a validation error, correct the output and call it again.",