@osolmaz/pi-workflows 0.13.4 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/README.md +146 -160
  2. package/dist/builtins/autoimplement.workflow.js +3 -1
  3. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  4. package/dist/builtins/change-verification.workflow.js +7 -2
  5. package/dist/builtins/change-verification.workflow.js.map +1 -1
  6. package/dist/builtins/metadata.d.ts +22 -0
  7. package/dist/builtins/metadata.js +10 -0
  8. package/dist/builtins/metadata.js.map +1 -0
  9. package/dist/builtins/monitor.workflow.js +4 -1
  10. package/dist/builtins/monitor.workflow.js.map +1 -1
  11. package/dist/builtins/sanity-check.workflow.js +4 -1
  12. package/dist/builtins/sanity-check.workflow.js.map +1 -1
  13. package/dist/builtins/workspace-preparation.workflow.js +3 -1
  14. package/dist/builtins/workspace-preparation.workflow.js.map +1 -1
  15. package/dist/controllers/index.d.ts +1 -2
  16. package/dist/controllers/index.js +0 -1
  17. package/dist/controllers/index.js.map +1 -1
  18. package/dist/controllers/sqlite.d.ts +85 -31
  19. package/dist/controllers/sqlite.js +541 -126
  20. package/dist/controllers/sqlite.js.map +1 -1
  21. package/dist/controllers/store.d.ts +1 -0
  22. package/dist/extension/controller-command.d.ts +22 -0
  23. package/dist/extension/controller-command.js +32 -0
  24. package/dist/extension/controller-command.js.map +1 -0
  25. package/dist/extension/index.d.ts +2 -9
  26. package/dist/extension/index.js +876 -2928
  27. package/dist/extension/index.js.map +1 -1
  28. package/dist/extension/message-card.d.ts +13 -0
  29. package/dist/extension/message-card.js +46 -0
  30. package/dist/extension/message-card.js.map +1 -0
  31. package/dist/extension/step-message.d.ts +5 -6
  32. package/dist/extension/step-message.js +41 -52
  33. package/dist/extension/step-message.js.map +1 -1
  34. package/dist/host/child-worker-supervisor.d.ts +50 -0
  35. package/dist/host/child-worker-supervisor.js +159 -0
  36. package/dist/host/child-worker-supervisor.js.map +1 -0
  37. package/dist/host/client.d.ts +48 -0
  38. package/dist/host/client.js +216 -0
  39. package/dist/host/client.js.map +1 -0
  40. package/dist/host/controller-worker-entry.d.ts +2 -0
  41. package/dist/host/controller-worker-entry.js +244 -0
  42. package/dist/host/controller-worker-entry.js.map +1 -0
  43. package/dist/host/controller-worker-protocol.d.ts +36 -0
  44. package/dist/host/controller-worker-protocol.js +49 -0
  45. package/dist/host/controller-worker-protocol.js.map +1 -0
  46. package/dist/host/controller-worker-supervisor.d.ts +21 -0
  47. package/dist/host/controller-worker-supervisor.js +54 -0
  48. package/dist/host/controller-worker-supervisor.js.map +1 -0
  49. package/dist/host/host-entry.d.ts +2 -0
  50. package/dist/host/host-entry.js +23 -0
  51. package/dist/host/host-entry.js.map +1 -0
  52. package/dist/host/processes.d.ts +17 -12
  53. package/dist/host/processes.js +154 -50
  54. package/dist/host/processes.js.map +1 -1
  55. package/dist/host/protocol.d.ts +38 -0
  56. package/dist/host/protocol.js +156 -0
  57. package/dist/host/protocol.js.map +1 -0
  58. package/dist/host/resolver-entry.d.ts +49 -0
  59. package/dist/host/resolver-entry.js +149 -0
  60. package/dist/host/resolver-entry.js.map +1 -0
  61. package/dist/host/rpc-executor.d.ts +14 -3
  62. package/dist/host/rpc-executor.js +63 -30
  63. package/dist/host/rpc-executor.js.map +1 -1
  64. package/dist/host/runner.d.ts +95 -29
  65. package/dist/host/runner.js +2311 -348
  66. package/dist/host/runner.js.map +1 -1
  67. package/dist/host/state.d.ts +174 -0
  68. package/dist/host/state.js +689 -0
  69. package/dist/host/state.js.map +1 -0
  70. package/dist/host/worker-entry.d.ts +10 -0
  71. package/dist/host/worker-entry.js +446 -0
  72. package/dist/host/worker-entry.js.map +1 -0
  73. package/dist/host/worker-protocol.d.ts +31 -0
  74. package/dist/host/worker-protocol.js +122 -0
  75. package/dist/host/worker-protocol.js.map +1 -0
  76. package/dist/host/worker-store.d.ts +80 -0
  77. package/dist/host/worker-store.js +91 -0
  78. package/dist/host/worker-store.js.map +1 -0
  79. package/dist/host/worker-supervisor.d.ts +22 -0
  80. package/dist/host/worker-supervisor.js +54 -0
  81. package/dist/host/worker-supervisor.js.map +1 -0
  82. package/dist/render/canvas.d.ts +2 -1
  83. package/dist/render/canvas.js +40 -15
  84. package/dist/render/canvas.js.map +1 -1
  85. package/dist/render/graph-render.d.ts +7 -3
  86. package/dist/render/graph-render.js +125 -74
  87. package/dist/render/graph-render.js.map +1 -1
  88. package/dist/state/database.js +3 -1
  89. package/dist/state/database.js.map +1 -1
  90. package/dist/state/index.d.ts +1 -0
  91. package/dist/state/index.js +1 -0
  92. package/dist/state/index.js.map +1 -1
  93. package/dist/state/prune.js +36 -10
  94. package/dist/state/prune.js.map +1 -1
  95. package/dist/state/schema.d.ts +1 -1
  96. package/dist/state/schema.js +171 -3
  97. package/dist/state/schema.js.map +1 -1
  98. package/dist/state/viewer.d.ts +46 -0
  99. package/dist/state/viewer.js +249 -0
  100. package/dist/state/viewer.js.map +1 -0
  101. package/dist/viewer/cli.d.ts +1 -1
  102. package/dist/viewer/cli.js +47 -18
  103. package/dist/viewer/cli.js.map +1 -1
  104. package/dist/workflows/composition.js +25 -2
  105. package/dist/workflows/composition.js.map +1 -1
  106. package/dist/workflows/definition.d.ts +3 -1
  107. package/dist/workflows/definition.js +25 -0
  108. package/dist/workflows/definition.js.map +1 -1
  109. package/dist/workflows/engine.d.ts +1 -0
  110. package/dist/workflows/engine.js +157 -42
  111. package/dist/workflows/engine.js.map +1 -1
  112. package/dist/workflows/errors.d.ts +3 -1
  113. package/dist/workflows/errors.js +4 -7
  114. package/dist/workflows/errors.js.map +1 -1
  115. package/dist/workflows/human-decision.d.ts +3 -0
  116. package/dist/workflows/human-decision.js +31 -0
  117. package/dist/workflows/human-decision.js.map +1 -1
  118. package/dist/workflows/index.d.ts +1 -1
  119. package/dist/workflows/index.js +1 -1
  120. package/dist/workflows/index.js.map +1 -1
  121. package/dist/workflows/schema.js +17 -6
  122. package/dist/workflows/schema.js.map +1 -1
  123. package/dist/{viewer → workflows}/session-reducer.d.ts +3 -1
  124. package/dist/{viewer → workflows}/session-reducer.js +4 -0
  125. package/dist/workflows/session-reducer.js.map +1 -0
  126. package/dist/workflows/store.d.ts +87 -1
  127. package/dist/workflows/store.js +892 -93
  128. package/dist/workflows/store.js.map +1 -1
  129. package/dist/workflows/tool-input.d.ts +0 -22
  130. package/dist/workflows/tool-input.js +1 -44
  131. package/dist/workflows/tool-input.js.map +1 -1
  132. package/dist/workflows/types.d.ts +36 -4
  133. package/docs/2026-08-25-workflow-follow-ups.md +8 -6
  134. package/docs/2026-08-30-out-of-process-workflow-host-plan.md +353 -0
  135. package/docs/CONTROLLERS.md +11 -11
  136. package/docs/DEFERRED_TURNS.md +61 -27
  137. package/docs/HUMAN_DECISIONS.md +12 -4
  138. package/docs/SQLITE_STATE.md +42 -8
  139. package/docs/WORKFLOW_HOST.md +452 -0
  140. package/docs/development.md +46 -30
  141. package/docs/live-replay-protocol.md +129 -100
  142. package/docs/plans/2026-08-19-human-decision-gates-plan.md +34 -8
  143. package/docs/plans/2026-08-27-workflow-terminal-restart-plan.md +399 -0
  144. package/docs/plans/2026-08-28-piw-incremental-viewer-plan.md +293 -0
  145. package/docs/plans/piw-viewer-experience-implementation-plan.md +11 -1
  146. package/docs/plans/tui-viewer-implementation-plan.md +5 -0
  147. package/docs/tui-viewer.md +18 -3
  148. package/docs/workflows.md +164 -156
  149. package/examples/workflows/command-batch.workflow.ts +2 -0
  150. package/examples/workflows/shell.workflow.ts +2 -1
  151. package/herdr-plugin.toml +1 -1
  152. package/package.json +1 -1
  153. package/skills/autodoc/SKILL.md +1 -1
  154. package/skills/autoimplement/SKILL.md +1 -1
  155. package/skills/autoplan/SKILL.md +1 -1
  156. package/skills/pi-workflows/SKILL.md +2 -0
  157. package/src/builtins/autoimplement.workflow.ts +3 -0
  158. package/src/builtins/change-verification.workflow.ts +7 -2
  159. package/src/builtins/metadata.ts +9 -0
  160. package/src/builtins/monitor.workflow.ts +4 -0
  161. package/src/builtins/sanity-check.workflow.ts +4 -0
  162. package/src/builtins/workspace-preparation.workflow.ts +3 -1
  163. package/src/controllers/index.ts +3 -5
  164. package/src/controllers/sqlite.ts +929 -225
  165. package/src/controllers/store.ts +1 -0
  166. package/src/extension/controller-command.ts +45 -0
  167. package/src/extension/index.ts +948 -3506
  168. package/src/extension/message-card.ts +61 -0
  169. package/src/extension/step-message.ts +58 -63
  170. package/src/host/child-worker-supervisor.ts +183 -0
  171. package/src/host/client.ts +293 -0
  172. package/src/host/controller-worker-entry.ts +311 -0
  173. package/src/host/controller-worker-protocol.ts +104 -0
  174. package/src/host/controller-worker-supervisor.ts +79 -0
  175. package/src/host/host-entry.ts +23 -0
  176. package/src/host/processes.ts +171 -54
  177. package/src/host/protocol.ts +196 -0
  178. package/src/host/resolver-entry.ts +241 -0
  179. package/src/host/rpc-executor.ts +76 -34
  180. package/src/host/runner.ts +2813 -422
  181. package/src/host/state.ts +1160 -0
  182. package/src/host/worker-entry.ts +533 -0
  183. package/src/host/worker-protocol.ts +165 -0
  184. package/src/host/worker-store.ts +229 -0
  185. package/src/host/worker-supervisor.ts +74 -0
  186. package/src/render/canvas.ts +44 -10
  187. package/src/render/graph-render.ts +145 -90
  188. package/src/state/database.ts +2 -1
  189. package/src/state/index.ts +14 -0
  190. package/src/state/prune.ts +35 -9
  191. package/src/state/schema.ts +171 -3
  192. package/src/state/viewer.ts +356 -0
  193. package/src/viewer/cli.ts +49 -17
  194. package/src/workflows/composition.ts +36 -2
  195. package/src/workflows/definition.ts +32 -0
  196. package/src/workflows/engine.ts +157 -54
  197. package/src/workflows/errors.ts +11 -2
  198. package/src/workflows/human-decision.ts +49 -0
  199. package/src/workflows/index.ts +2 -0
  200. package/src/workflows/schema.ts +19 -6
  201. package/src/{viewer → workflows}/session-reducer.ts +11 -1
  202. package/src/workflows/store.ts +1316 -108
  203. package/src/workflows/tool-input.ts +2 -60
  204. package/src/workflows/types.ts +32 -4
  205. package/dist/controllers/workflow-engine-scheduler.d.ts +0 -29
  206. package/dist/controllers/workflow-engine-scheduler.js +0 -175
  207. package/dist/controllers/workflow-engine-scheduler.js.map +0 -1
  208. package/dist/extension/controller-host.d.ts +0 -48
  209. package/dist/extension/controller-host.js +0 -110
  210. package/dist/extension/controller-host.js.map +0 -1
  211. package/dist/extension/deferred-turn-coordinator.d.ts +0 -32
  212. package/dist/extension/deferred-turn-coordinator.js +0 -143
  213. package/dist/extension/deferred-turn-coordinator.js.map +0 -1
  214. package/dist/extension/deferred-turn.d.ts +0 -44
  215. package/dist/extension/deferred-turn.js +0 -110
  216. package/dist/extension/deferred-turn.js.map +0 -1
  217. package/dist/extension/executor.d.ts +0 -86
  218. package/dist/extension/executor.js +0 -311
  219. package/dist/extension/executor.js.map +0 -1
  220. package/dist/extension/follow-up-coordinator.d.ts +0 -27
  221. package/dist/extension/follow-up-coordinator.js +0 -131
  222. package/dist/extension/follow-up-coordinator.js.map +0 -1
  223. package/dist/extension/recorder.d.ts +0 -84
  224. package/dist/extension/recorder.js +0 -528
  225. package/dist/extension/recorder.js.map +0 -1
  226. package/dist/extension/session-events.d.ts +0 -133
  227. package/dist/extension/session-events.js +0 -61
  228. package/dist/extension/session-events.js.map +0 -1
  229. package/dist/viewer/session-reducer.js.map +0 -1
  230. package/src/controllers/workflow-engine-scheduler.ts +0 -246
  231. package/src/extension/controller-host.ts +0 -167
  232. package/src/extension/deferred-turn-coordinator.ts +0 -171
  233. package/src/extension/deferred-turn.ts +0 -166
  234. package/src/extension/executor.ts +0 -411
  235. package/src/extension/follow-up-coordinator.ts +0 -151
  236. package/src/extension/recorder.ts +0 -655
  237. package/src/extension/session-events.ts +0 -121
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { StateDatabase, workflowStatePath } from "../state/database.js";
3
- import { canonicalJson, type JsonValue } from "../state/json.js";
3
+ import { canonicalJson, parseJson, type JsonValue } from "../state/json.js";
4
4
  import {
5
5
  StateMutationStore,
6
6
  StaleResourceError,
@@ -11,8 +11,22 @@ import {
11
11
  type MutationActor,
12
12
  type OwnerType,
13
13
  } from "../state/mutation.js";
14
+ import {
15
+ initializeViewerRun,
16
+ recordViewerDeltas,
17
+ viewerTailPatch,
18
+ VIEWER_PAGE_SIZE,
19
+ type ViewerDeltaDraft,
20
+ } from "../state/viewer.js";
14
21
  import { compositionMetadata } from "./composition.js";
22
+ import { ClaimLostError } from "./errors.js";
23
+ import { HumanDecisionStore } from "./human-decision.js";
15
24
  import { applyJsonPatch, validateJsonPatch } from "./json-patch.js";
25
+ import {
26
+ reduceSessionEvents,
27
+ reduceSessionEventsFromCheckpoint,
28
+ type TemporalSessionState,
29
+ } from "./session-reducer.js";
16
30
  import {
17
31
  applyWorkflowSettingsPatch,
18
32
  workflowSettingsScopeId,
@@ -32,7 +46,11 @@ import {
32
46
  import type {
33
47
  WorkflowDefinition,
34
48
  WorkflowDefinitionSnapshot,
49
+ WorkflowEffectRecovery,
50
+ WorkflowEffectReservation,
35
51
  HumanDecisionReceipt,
52
+ HumanDecisionRequest,
53
+ ResolvedHumanDecision,
36
54
  WorkflowNodeDefinition,
37
55
  WorkflowNodeResult,
38
56
  WorkflowNodeSnapshot,
@@ -44,6 +62,7 @@ import type {
44
62
  WorkflowSessionCapture,
45
63
  WorkflowSessionEntryRecord,
46
64
  WorkflowSessionEventRecord,
65
+ WorkflowSessionEventType,
47
66
  WorkflowSettingsSnapshot,
48
67
  WorkflowTraceEvent,
49
68
  WorkflowTraceEventDraft,
@@ -87,14 +106,85 @@ export type RunWriteAuthority = {
87
106
  ownerId: string;
88
107
  token: string;
89
108
  generation: number;
109
+ /** Duration used when a protected write renews this exact live claim. */
110
+ leaseMs?: number;
90
111
  };
91
112
 
92
113
  export type WorkflowRunStoreOptions = {
93
114
  authorityProvider?: (runId: string) => RunWriteAuthority | undefined;
115
+ /** Host-owned projection updates that must commit with each run snapshot. */
116
+ snapshotLifecycle?: (context: {
117
+ runId: string;
118
+ state: WorkflowRunState;
119
+ event: WorkflowTraceEvent;
120
+ database: StateDatabase;
121
+ now: number;
122
+ }) => void;
94
123
  state?: StateDatabase;
95
124
  readOnly?: boolean;
96
125
  };
97
126
 
127
+ /** The state boundary used by workflow code, including out-of-process workers. */
128
+ export interface WorkflowExecutionStore {
129
+ readonly databasePath: string;
130
+ initializeRun(
131
+ workflow: WorkflowDefinition,
132
+ state: WorkflowRunState,
133
+ options?: InitializeWorkflowRunOptions,
134
+ ): Promise<string>;
135
+ prepareRunResume(runId: string): Promise<LoadedWorkflowRun>;
136
+ readRun(
137
+ runId: string,
138
+ options?: ReadWorkflowRunOptions,
139
+ ): LoadedWorkflowRun | null | Promise<LoadedWorkflowRun | null>;
140
+ writeSnapshot(
141
+ runId: string,
142
+ state: WorkflowRunState,
143
+ event: WorkflowTraceEventDraft,
144
+ ): Promise<WorkflowTraceEvent>;
145
+ publishUpdate(
146
+ runId: string,
147
+ state: WorkflowRunState,
148
+ nodeId: string,
149
+ attemptId: string,
150
+ update: WorkflowUpdateInput,
151
+ options?: { signal?: AbortSignal },
152
+ ): Promise<{ event: WorkflowTraceEvent; record: WorkflowUpdateRecord }>;
153
+ findSettingsScope(
154
+ runId: string,
155
+ mountPath: string,
156
+ invocation: number,
157
+ ): WorkflowSettingsScopeRecord | undefined | Promise<WorkflowSettingsScopeRecord | undefined>;
158
+ ensureSettingsScope(options: {
159
+ runId: string;
160
+ mountPath: string;
161
+ invocation: number;
162
+ settings: JsonValue;
163
+ }): WorkflowSettingsScopeRecord | Promise<WorkflowSettingsScopeRecord>;
164
+ getSettingsScopeAtChange(
165
+ scopeId: string,
166
+ changeNumber: number,
167
+ ): WorkflowSettingsScopeRecord | undefined | Promise<WorkflowSettingsScopeRecord | undefined>;
168
+ createHumanDecisionRequest(request: HumanDecisionRequest): Promise<"created" | "adopted">;
169
+ readResolvedHumanDecision(decisionId: string): Promise<ResolvedHumanDecision | null>;
170
+ reserveEffect(options: {
171
+ runId: string;
172
+ attemptId: string;
173
+ effectType: string;
174
+ idempotencyKey: string;
175
+ request: JsonValue;
176
+ recovery: WorkflowEffectRecovery;
177
+ }): Promise<WorkflowEffectReservation>;
178
+ settleEffect(options: {
179
+ runId: string;
180
+ effectId: string;
181
+ attemptNumber: number;
182
+ outcome: "applied" | "rejected" | "ambiguous" | "cancelled";
183
+ result?: JsonValue;
184
+ error?: string;
185
+ }): Promise<void>;
186
+ }
187
+
98
188
  type RunContext = {
99
189
  revision: number;
100
190
  lock: Promise<void>;
@@ -168,6 +258,23 @@ type EventRow = {
168
258
  recordedAt: number;
169
259
  };
170
260
 
261
+ type ViewerSessionCheckpointRow = {
262
+ eventSeq: number;
263
+ stateHash: Buffer;
264
+ };
265
+
266
+ type ViewerSessionEventRow = {
267
+ runSeq: number;
268
+ eventType: string;
269
+ nodeId: string;
270
+ attemptId: string;
271
+ turnId: string | null;
272
+ messageId: string | null;
273
+ toolCallId: string | null;
274
+ payloadHash: Buffer;
275
+ recordedAt: number;
276
+ };
277
+
171
278
  type SettingsScopeRow = {
172
279
  scopeId: string;
173
280
  resourceId: string;
@@ -317,12 +424,14 @@ export class WorkflowRunStore {
317
424
  private readonly authorityProvider:
318
425
  | ((runId: string) => RunWriteAuthority | undefined)
319
426
  | undefined;
427
+ private readonly snapshotLifecycle: WorkflowRunStoreOptions["snapshotLifecycle"];
320
428
  private readonly contexts = new Map<string, RunContext>();
321
429
  private readonly ownsState: boolean;
322
430
  private readonly mutations: StateMutationStore;
323
431
 
324
432
  constructor(databasePath: string = workflowStatePath(), options: WorkflowRunStoreOptions = {}) {
325
433
  this.authorityProvider = options.authorityProvider;
434
+ this.snapshotLifecycle = options.snapshotLifecycle;
326
435
  this.ownsState = options.state === undefined;
327
436
  this.state =
328
437
  options.state ??
@@ -339,10 +448,327 @@ export class WorkflowRunStore {
339
448
  if (this.ownsState) this.state.close();
340
449
  }
341
450
 
451
+ /** Host-only synchronization after a queue lifecycle mutation on the run resource. */
452
+ synchronizeRevision(runId: string): number {
453
+ const revision = this.resourceRevision(runId);
454
+ const context = this.contexts.get(runId);
455
+ if (context === undefined) {
456
+ this.contexts.set(runId, { revision, lock: Promise.resolve() });
457
+ } else {
458
+ context.revision = revision;
459
+ }
460
+ return revision;
461
+ }
462
+
463
+ async createHumanDecisionRequest(request: HumanDecisionRequest): Promise<"created" | "adopted"> {
464
+ return await new HumanDecisionStore(this.databasePath, { state: this.state }).createRequest(
465
+ request,
466
+ );
467
+ }
468
+
469
+ async readResolvedHumanDecision(decisionId: string): Promise<ResolvedHumanDecision | null> {
470
+ return await new HumanDecisionStore(this.databasePath, { state: this.state }).readResolved(
471
+ decisionId,
472
+ );
473
+ }
474
+
475
+ async reserveEffect(options: {
476
+ runId: string;
477
+ attemptId: string;
478
+ effectType: string;
479
+ idempotencyKey: string;
480
+ request: JsonValue;
481
+ recovery: WorkflowEffectRecovery;
482
+ }): Promise<WorkflowEffectReservation> {
483
+ return await this.withRunLock(options.runId, async () =>
484
+ this.state.transaction(() => {
485
+ requireBoundedEffectText(options.effectType, "type");
486
+ requireBoundedEffectText(options.idempotencyKey, "idempotency key");
487
+ const context = this.contextFor(options.runId);
488
+ const run = this.requireRunRow(options.runId);
489
+ this.assertWriteAuthority(run, context.revision);
490
+ const authority = this.authorityProvider?.(options.runId);
491
+ const payloadHash = this.state.putJson(
492
+ { recovery: options.recovery, request: options.request },
493
+ Date.now(),
494
+ );
495
+ const effectId = workflowEffectId(
496
+ run.resourceId,
497
+ options.effectType,
498
+ options.idempotencyKey,
499
+ );
500
+ const existing = this.state.connection
501
+ .prepare(
502
+ `SELECT status, payload_hash AS payloadHash, result_hash AS resultHash,
503
+ attempt_count AS attemptCount
504
+ FROM effects WHERE effect_id = ?`,
505
+ )
506
+ .get(effectId);
507
+ if (isWorkflowEffectRow(existing)) {
508
+ if (!existing.payloadHash.equals(payloadHash)) {
509
+ throw new Error("Managed effect key was reused with another request");
510
+ }
511
+ if (existing.status === "applied") {
512
+ return {
513
+ effectId,
514
+ attemptNumber: existing.attemptCount,
515
+ disposition: "adopted",
516
+ ...(existing.resultHash === null
517
+ ? {}
518
+ : { result: this.state.readJson(existing.resultHash) }),
519
+ };
520
+ }
521
+ if (existing.status !== "pending") {
522
+ return {
523
+ effectId,
524
+ attemptNumber: existing.attemptCount,
525
+ disposition: "ambiguous",
526
+ };
527
+ }
528
+ return this.beginEffectAttempt(run, effectId, existing.attemptCount + 1, authority);
529
+ }
530
+ const now = Date.now();
531
+ const effectResourceId = this.mutations.ensureResource("effect", effectId, now);
532
+ this.mutations.mutate(
533
+ {
534
+ resourceId: effectResourceId,
535
+ operation: "effect.reserve",
536
+ actor: authority?.actor ?? { type: "system" },
537
+ expectedRevision: 0,
538
+ },
539
+ "effect.reserved",
540
+ () => {
541
+ this.state.connection
542
+ .prepare(
543
+ `INSERT INTO effects(
544
+ effect_id, resource_id, source_resource_id, source_revision,
545
+ effect_type, idempotency_key, payload_hash, owner_scope, status,
546
+ attempt_count, created_at, updated_at
547
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'run', 'applying', 1, ?, ?)`,
548
+ )
549
+ .run(
550
+ effectId,
551
+ effectResourceId,
552
+ run.resourceId,
553
+ context.revision,
554
+ options.effectType,
555
+ options.idempotencyKey,
556
+ payloadHash,
557
+ now,
558
+ now,
559
+ );
560
+ this.insertEffectAttempt(effectId, 1, authority, now);
561
+ },
562
+ { now, payload: { runId: options.runId, attemptId: options.attemptId } },
563
+ );
564
+ return { effectId, attemptNumber: 1, disposition: "execute" };
565
+ }),
566
+ );
567
+ }
568
+
569
+ async settleEffect(options: {
570
+ runId: string;
571
+ effectId: string;
572
+ attemptNumber: number;
573
+ outcome: "applied" | "rejected" | "ambiguous" | "cancelled";
574
+ result?: JsonValue;
575
+ error?: string;
576
+ }): Promise<void> {
577
+ await this.withRunLock(options.runId, async () =>
578
+ this.state.transaction(() => {
579
+ const context = this.contextFor(options.runId);
580
+ const run = this.requireRunRow(options.runId);
581
+ this.assertWriteAuthority(run, context.revision);
582
+ const row = this.state.connection
583
+ .prepare(
584
+ `SELECT e.resource_id AS resourceId, e.status
585
+ FROM effects e WHERE e.effect_id = ? AND e.source_resource_id = ?`,
586
+ )
587
+ .get(options.effectId, run.resourceId);
588
+ if (!isWorkflowEffectIdentityRow(row) || row.status !== "applying") {
589
+ throw new Error("Managed effect is not applying");
590
+ }
591
+ const now = Date.now();
592
+ const revision = this.requireResourceRevision(row.resourceId);
593
+ const resultHash =
594
+ options.result === undefined ? null : this.state.putJson(options.result, now);
595
+ const errorHash =
596
+ options.error === undefined ? null : this.state.putText(options.error, now);
597
+ this.mutations.mutate(
598
+ {
599
+ resourceId: row.resourceId,
600
+ operation: "effect.settle",
601
+ actor: this.authorityProvider?.(options.runId)?.actor ?? { type: "system" },
602
+ expectedRevision: revision,
603
+ },
604
+ `effect.${options.outcome}`,
605
+ () => {
606
+ const changed = this.state.connection
607
+ .prepare(
608
+ `UPDATE effects
609
+ SET status = ?, result_hash = ?, error_hash = ?, updated_at = ?, settled_at = ?
610
+ WHERE effect_id = ? AND status = 'applying' AND attempt_count = ?`,
611
+ )
612
+ .run(
613
+ options.outcome,
614
+ resultHash,
615
+ errorHash,
616
+ now,
617
+ now,
618
+ options.effectId,
619
+ options.attemptNumber,
620
+ );
621
+ /* istanbul ignore if -- serialized effect mutation keeps this attempt current */
622
+ if (changed.changes !== 1) throw new Error("Managed effect attempt changed");
623
+ this.state.connection
624
+ .prepare(
625
+ `UPDATE effect_attempts
626
+ SET finished_at = ?, outcome = ?, result_hash = ?, error_hash = ?
627
+ WHERE effect_id = ? AND attempt_number = ? AND finished_at IS NULL`,
628
+ )
629
+ .run(
630
+ now,
631
+ options.outcome === "cancelled" ? "interrupted" : options.outcome,
632
+ resultHash,
633
+ errorHash,
634
+ options.effectId,
635
+ options.attemptNumber,
636
+ );
637
+ },
638
+ { now, payload: { runId: options.runId, outcome: options.outcome } },
639
+ );
640
+ }),
641
+ );
642
+ }
643
+
644
+ async recoverApplyingEffects(runId: string): Promise<"safe" | "ambiguous"> {
645
+ return await this.withRunLock(runId, async () =>
646
+ this.state.transaction(() => {
647
+ const context = this.contextFor(runId);
648
+ const run = this.requireRunRow(runId);
649
+ this.assertWriteAuthority(run, context.revision);
650
+ const authority = this.authorityProvider?.(runId);
651
+ const rows = this.state.connection
652
+ .prepare(
653
+ `SELECT effect_id AS effectId, resource_id AS resourceId,
654
+ payload_hash AS payloadHash, attempt_count AS attemptCount
655
+ FROM effects WHERE source_resource_id = ? AND status = 'applying'`,
656
+ )
657
+ .all(run.resourceId)
658
+ .filter(isApplyingEffectRow);
659
+ let ambiguous = false;
660
+ for (const row of rows) {
661
+ const payload = this.state.readJson(row.payloadHash);
662
+ const recovery = effectRecoveryFromPayload(payload);
663
+ const status = recovery === "idempotent" ? "pending" : "ambiguous";
664
+ if (status === "ambiguous") ambiguous = true;
665
+ const now = Date.now();
666
+ const revision = this.requireResourceRevision(row.resourceId);
667
+ this.mutations.mutate(
668
+ {
669
+ resourceId: row.resourceId,
670
+ operation: "effect.recover",
671
+ actor: authority?.actor ?? { type: "system" },
672
+ expectedRevision: revision,
673
+ },
674
+ status === "pending" ? "effect.retry_ready" : "effect.ambiguous",
675
+ () => {
676
+ this.state.connection
677
+ .prepare(
678
+ `UPDATE effects SET status = ?, updated_at = ?, settled_at = ?
679
+ WHERE effect_id = ? AND status = 'applying'`,
680
+ )
681
+ .run(status, now, status === "ambiguous" ? now : null, row.effectId);
682
+ this.state.connection
683
+ .prepare(
684
+ `UPDATE effect_attempts SET finished_at = ?, outcome = 'interrupted'
685
+ WHERE effect_id = ? AND attempt_number = ? AND finished_at IS NULL`,
686
+ )
687
+ .run(now, row.effectId, row.attemptCount);
688
+ },
689
+ { now, payload: { runId, recovery } },
690
+ );
691
+ }
692
+ return ambiguous ? "ambiguous" : "safe";
693
+ }),
694
+ );
695
+ }
696
+
697
+ private beginEffectAttempt(
698
+ run: RunRow,
699
+ effectId: string,
700
+ attemptNumber: number,
701
+ authority: RunWriteAuthority | undefined,
702
+ ): WorkflowEffectReservation {
703
+ const now = Date.now();
704
+ const row = this.state.connection
705
+ .prepare("SELECT resource_id AS resourceId FROM effects WHERE effect_id = ?")
706
+ .get(effectId);
707
+ /* istanbul ignore if -- beginEffectAttempt is called only for a persisted effect */
708
+ if (!isResourceIdRow(row)) throw new Error("Managed effect resource is missing");
709
+ const revision = this.requireResourceRevision(row.resourceId);
710
+ this.mutations.mutate(
711
+ {
712
+ resourceId: row.resourceId,
713
+ operation: "effect.retry",
714
+ actor: authority?.actor ?? { type: "system" },
715
+ expectedRevision: revision,
716
+ },
717
+ "effect.retry_started",
718
+ () => {
719
+ this.state.connection
720
+ .prepare(
721
+ `UPDATE effects SET status = 'applying', attempt_count = ?, updated_at = ?,
722
+ settled_at = NULL, error_hash = NULL
723
+ WHERE effect_id = ? AND status = 'pending'`,
724
+ )
725
+ .run(attemptNumber, now, effectId);
726
+ this.insertEffectAttempt(effectId, attemptNumber, authority, now);
727
+ },
728
+ { now, payload: { runId: run.runId, attemptNumber } },
729
+ );
730
+ return { effectId, attemptNumber, disposition: "execute" };
731
+ }
732
+
733
+ private insertEffectAttempt(
734
+ effectId: string,
735
+ attemptNumber: number,
736
+ authority: RunWriteAuthority | undefined,
737
+ now: number,
738
+ ): void {
739
+ this.state.connection
740
+ .prepare(
741
+ `INSERT INTO effect_attempts(
742
+ effect_id, attempt_number, owner_id, lease_generation, started_at
743
+ ) VALUES (?, ?, ?, ?, ?)`,
744
+ )
745
+ .run(
746
+ effectId,
747
+ attemptNumber,
748
+ authority?.ownerId ?? "system",
749
+ authority?.generation ?? 1,
750
+ now,
751
+ );
752
+ }
753
+
342
754
  async initializeRun(
343
755
  workflow: WorkflowDefinition,
344
756
  state: WorkflowRunState,
345
757
  options: InitializeWorkflowRunOptions = {},
758
+ ): Promise<string> {
759
+ return await this.initializeRunFromSnapshot(
760
+ createDefinitionSnapshot(workflow),
761
+ workflow.name,
762
+ state,
763
+ options,
764
+ );
765
+ }
766
+
767
+ async initializeRunFromSnapshot(
768
+ snapshot: WorkflowDefinitionSnapshot,
769
+ workflowName: string,
770
+ state: WorkflowRunState,
771
+ options: InitializeWorkflowRunOptions = {},
346
772
  ): Promise<string> {
347
773
  assertValidRunId(state.runId);
348
774
  if (!this.contexts.has(state.runId)) {
@@ -356,7 +782,6 @@ export class WorkflowRunStore {
356
782
  let acceptedRevision = 1;
357
783
  const now = Date.now();
358
784
  const at = new Date(now).toISOString();
359
- const snapshot = createDefinitionSnapshot(workflow);
360
785
  const definitionJson = canonicalJson(snapshot);
361
786
  const definitionDigest = createHash("sha256").update(definitionJson).digest();
362
787
  const source = sourceForState(state, definitionDigest);
@@ -377,14 +802,33 @@ export class WorkflowRunStore {
377
802
  acceptedRevision = revision;
378
803
  state.traceSeq = revision;
379
804
  state.updatedAt = at;
380
- insertRunEvent(this.state, reserved.resourceId, revision, at, {
805
+ const traceEvent: WorkflowTraceEvent = {
806
+ seq: revision,
807
+ at,
808
+ runId: state.runId,
381
809
  scope: "run",
382
810
  type: "run_initialized",
383
- payload: { workflowName: workflow.name },
384
- });
811
+ payload: { workflowName },
812
+ };
813
+ insertRunEvent(this.state, reserved.resourceId, revision, at, traceEvent);
385
814
  this.persistRunState(reserved, state, revision, now);
815
+ initializeViewerRun(this.state, state.runId, now);
386
816
  this.initializeRunSettingsAndFollowUps(state, options.initialSettings ?? [], now);
817
+ if (state.parentRunId !== undefined) {
818
+ recordViewerDeltas(
819
+ this.state,
820
+ state.parentRunId,
821
+ this.viewerInspectorTargets(state.parentRunId),
822
+ now,
823
+ );
824
+ }
387
825
  this.syncNodeAttempts(state, snapshot, now);
826
+ recordViewerDeltas(
827
+ this.state,
828
+ state.runId,
829
+ [...runViewerTargets(state, traceEvent), ...this.viewerInspectorTargets(state.runId)],
830
+ now,
831
+ );
388
832
  this.syncContinuationIdentity(state);
389
833
  context.revision = revision;
390
834
  return;
@@ -397,7 +841,7 @@ export class WorkflowRunStore {
397
841
  ) VALUES (?, ?, ?, ?)
398
842
  ON CONFLICT(definition_digest) DO NOTHING`,
399
843
  )
400
- .run(definitionDigest, workflow.name, definitionHash, now);
844
+ .run(definitionDigest, workflowName, definitionHash, now);
401
845
  this.state.connection
402
846
  .prepare(
403
847
  `INSERT INTO resources(
@@ -442,14 +886,23 @@ export class WorkflowRunStore {
442
886
  now,
443
887
  state.finishedAt === undefined ? null : Date.parse(state.finishedAt),
444
888
  );
889
+ initializeViewerRun(this.state, state.runId, now);
445
890
  this.insertRunSources(state.runId, source, state.workflowSources ?? []);
446
891
  this.syncContinuationIdentity(state);
447
892
  insertRunEvent(this.state, resourceId, 1, at, {
448
893
  scope: "run",
449
894
  type: "run_created",
450
- payload: { workflowName: workflow.name },
895
+ payload: { workflowName },
451
896
  });
452
897
  this.initializeRunSettingsAndFollowUps(state, options.initialSettings ?? [], now);
898
+ if (state.parentRunId !== undefined) {
899
+ recordViewerDeltas(
900
+ this.state,
901
+ state.parentRunId,
902
+ this.viewerInspectorTargets(state.parentRunId),
903
+ now,
904
+ );
905
+ }
453
906
  this.syncNodeAttempts(state, snapshot, now);
454
907
  });
455
908
  this.contexts.set(state.runId, { revision: acceptedRevision, lock: Promise.resolve() });
@@ -496,6 +949,12 @@ export class WorkflowRunStore {
496
949
  now,
497
950
  now,
498
951
  );
952
+ recordViewerDeltas(
953
+ this.state,
954
+ options.runId,
955
+ this.viewerInspectorTargets(options.runId),
956
+ now,
957
+ );
499
958
  return this.settingsScopeRecord(this.requireSettingsScopeRow(scopeId));
500
959
  });
501
960
  }
@@ -680,6 +1139,12 @@ export class WorkflowRunStore {
680
1139
  `UPDATE workflow_settings SET current_hash = ?, updated_at = ? WHERE scope_id = ?`,
681
1140
  )
682
1141
  .run(savedAfterHash, now, request.scopeId);
1142
+ recordViewerDeltas(
1143
+ this.state,
1144
+ request.runId,
1145
+ this.viewerInspectorTargets(request.runId),
1146
+ now,
1147
+ );
683
1148
  return changeId;
684
1149
  },
685
1150
  {
@@ -784,6 +1249,12 @@ export class WorkflowRunStore {
784
1249
  now,
785
1250
  now,
786
1251
  );
1252
+ recordViewerDeltas(
1253
+ this.state,
1254
+ request.runId,
1255
+ this.viewerInspectorTargets(request.runId),
1256
+ now,
1257
+ );
787
1258
  return followUpId;
788
1259
  },
789
1260
  { payload: { runId: request.runId, requestId: request.requestId } },
@@ -834,6 +1305,12 @@ export class WorkflowRunStore {
834
1305
  if (update.changes !== 1) {
835
1306
  throw new StaleResourceError("Workflow follow-up changed before removal");
836
1307
  }
1308
+ recordViewerDeltas(
1309
+ this.state,
1310
+ request.runId,
1311
+ this.viewerInspectorTargets(request.runId),
1312
+ now,
1313
+ );
837
1314
  },
838
1315
  { payload: { followUpId: request.followUpId, source: request.source } },
839
1316
  );
@@ -956,6 +1433,12 @@ export class WorkflowRunStore {
956
1433
  WHERE run_id = ? AND status = 'pending_presentation'`,
957
1434
  )
958
1435
  .run(now, options.runId);
1436
+ recordViewerDeltas(
1437
+ this.state,
1438
+ options.runId,
1439
+ this.viewerInspectorTargets(options.runId),
1440
+ now,
1441
+ );
959
1442
  },
960
1443
  { payload: { runId: options.runId, state: options.state } },
961
1444
  );
@@ -1036,6 +1519,7 @@ export class WorkflowRunStore {
1036
1519
  if (update.changes !== 1) {
1037
1520
  throw new StaleResourceError("Workflow follow-up changed before delivery completed");
1038
1521
  }
1522
+ recordViewerDeltas(this.state, row.runId, this.viewerInspectorTargets(row.runId), now);
1039
1523
  },
1040
1524
  { payload: { followUpId, sessionEntryId } },
1041
1525
  );
@@ -1071,14 +1555,19 @@ export class WorkflowRunStore {
1071
1555
  WHERE run_id = ? AND status IN ('pending', 'running', 'waiting')`,
1072
1556
  )
1073
1557
  .run(now, runId);
1074
- insertRunEvent(this.state, row.resourceId, nextRevision, at, {
1558
+ const traceEvent: WorkflowTraceEvent = {
1559
+ seq: nextRevision,
1560
+ at,
1561
+ runId,
1075
1562
  scope: "run",
1076
1563
  type: "run_resume_prepared",
1077
1564
  payload: {},
1078
- });
1565
+ };
1566
+ insertRunEvent(this.state, row.resourceId, nextRevision, at, traceEvent);
1079
1567
  loaded.state.traceSeq = nextRevision;
1080
1568
  loaded.state.updatedAt = at;
1081
1569
  this.persistRunState(row, loaded.state, nextRevision, now);
1570
+ recordViewerDeltas(this.state, runId, runViewerTargets(loaded.state, traceEvent), now);
1082
1571
  context.revision = nextRevision;
1083
1572
  });
1084
1573
  const prepared = this.readRun(runId, { includeTrace: true });
@@ -1099,6 +1588,7 @@ export class WorkflowRunStore {
1099
1588
  delete loaded.state.currentNode;
1100
1589
  delete loaded.state.currentAttemptId;
1101
1590
  delete loaded.state.currentNodeStartedAt;
1591
+ delete loaded.state.currentNodeDeadlineAt;
1102
1592
  delete loaded.state.currentSettingsScopeId;
1103
1593
  delete loaded.state.currentSettingsChangeNumber;
1104
1594
  delete loaded.state.currentSettingsHash;
@@ -1120,81 +1610,95 @@ export class WorkflowRunStore {
1120
1610
  update: WorkflowUpdateInput,
1121
1611
  options: { signal?: AbortSignal } = {},
1122
1612
  ): Promise<{ event: WorkflowTraceEvent; record: WorkflowUpdateRecord }> {
1123
- return await this.withRunLock(runId, async () => {
1124
- if (options.signal?.aborted === true) {
1125
- throw options.signal.reason ?? new Error("workflow update attempt is no longer active");
1126
- }
1127
- const exists = (state.updates ?? []).some(
1128
- (record) => record.type === update.type && record.key === update.key,
1129
- );
1130
- if (!exists && (state.updates?.length ?? 0) >= MAX_CURRENT_UPDATES) {
1131
- throw new Error(`workflow run supports at most ${MAX_CURRENT_UPDATES} current updates`);
1132
- }
1133
- const data = structuredClone(update.data) as Record<string, unknown>;
1134
- const updateId = createUpdateId();
1135
- let acceptedEvent: WorkflowTraceEvent | undefined;
1136
- let acceptedRecord: WorkflowUpdateRecord | undefined;
1137
- this.state.transaction(() => {
1138
- const context = this.contextFor(runId);
1139
- const run = this.requireRunRow(runId);
1140
- this.assertWriteAuthority(run, context.revision);
1141
- const revision = context.revision + 1;
1142
- const at = new Date().toISOString();
1143
- const event: WorkflowTraceEvent = {
1144
- seq: revision,
1145
- at,
1146
- runId,
1147
- scope: "node",
1148
- type: "update_published",
1149
- nodeId,
1150
- attemptId,
1151
- payload: { updateId, type: update.type, key: update.key, data },
1152
- };
1153
- const record: WorkflowUpdateRecord = {
1154
- updateId,
1155
- seq: revision,
1156
- at,
1157
- runId,
1158
- nodeId,
1159
- attemptId,
1160
- type: update.type,
1161
- key: update.key,
1162
- data,
1163
- };
1164
- state.updates = updateProjection(state.updates, record);
1165
- state.traceSeq = revision;
1166
- state.updatedAt = at;
1167
- this.ensureAttempt(state, nodeId, attemptId, Date.now());
1168
- const dataHash = this.state.putJson(data);
1169
- const nextUpdateSeq = this.nextUpdateSequence(attemptId);
1170
- this.state.connection
1171
- .prepare(
1172
- `INSERT INTO workflow_updates(
1613
+ return await this.withRunLock(runId, async () =>
1614
+ this.publishUpdateSynchronous(runId, state, nodeId, attemptId, update, options),
1615
+ );
1616
+ }
1617
+
1618
+ /** Host-only synchronous form for an already serialized command transaction. */
1619
+ publishUpdateSynchronous(
1620
+ runId: string,
1621
+ state: WorkflowRunState,
1622
+ nodeId: string,
1623
+ attemptId: string,
1624
+ update: WorkflowUpdateInput,
1625
+ options: { signal?: AbortSignal } = {},
1626
+ ): { event: WorkflowTraceEvent; record: WorkflowUpdateRecord } {
1627
+ if (options.signal?.aborted === true) {
1628
+ throw options.signal.reason ?? new Error("workflow update attempt is no longer active");
1629
+ }
1630
+ const exists = (state.updates ?? []).some(
1631
+ (record) => record.type === update.type && record.key === update.key,
1632
+ );
1633
+ if (!exists && (state.updates?.length ?? 0) >= MAX_CURRENT_UPDATES) {
1634
+ throw new Error(`workflow run supports at most ${MAX_CURRENT_UPDATES} current updates`);
1635
+ }
1636
+ const data = structuredClone(update.data) as Record<string, unknown>;
1637
+ const updateId = createUpdateId();
1638
+ let acceptedEvent: WorkflowTraceEvent | undefined;
1639
+ let acceptedRecord: WorkflowUpdateRecord | undefined;
1640
+ this.state.transaction(() => {
1641
+ const context = this.contextFor(runId);
1642
+ const run = this.requireRunRow(runId);
1643
+ this.assertWriteAuthority(run, context.revision);
1644
+ const revision = context.revision + 1;
1645
+ const at = new Date().toISOString();
1646
+ const event: WorkflowTraceEvent = {
1647
+ seq: revision,
1648
+ at,
1649
+ runId,
1650
+ scope: "node",
1651
+ type: "update_published",
1652
+ nodeId,
1653
+ attemptId,
1654
+ payload: { updateId, type: update.type, key: update.key, data },
1655
+ };
1656
+ const record: WorkflowUpdateRecord = {
1657
+ updateId,
1658
+ seq: revision,
1659
+ at,
1660
+ runId,
1661
+ nodeId,
1662
+ attemptId,
1663
+ type: update.type,
1664
+ key: update.key,
1665
+ data,
1666
+ };
1667
+ state.updates = updateProjection(state.updates, record);
1668
+ state.traceSeq = revision;
1669
+ state.updatedAt = at;
1670
+ this.ensureAttempt(state, nodeId, attemptId, Date.now());
1671
+ const dataHash = this.state.putJson(data);
1672
+ const nextUpdateSeq = this.nextUpdateSequence(attemptId);
1673
+ this.state.connection
1674
+ .prepare(
1675
+ `INSERT INTO workflow_updates(
1173
1676
  update_id, attempt_id, update_seq, run_revision,
1174
1677
  update_type, update_key, data_hash, recorded_at
1175
1678
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
1176
- )
1177
- .run(
1178
- updateId,
1179
- attemptId,
1180
- nextUpdateSeq,
1181
- revision,
1182
- update.type,
1183
- update.key,
1184
- dataHash,
1185
- Date.now(),
1186
- );
1187
- insertRunEvent(this.state, run.resourceId, revision, at, event);
1188
- this.persistRunState(run, state, revision, Date.now());
1189
- context.revision = revision;
1190
- acceptedEvent = event;
1191
- acceptedRecord = record;
1192
- });
1193
- if (acceptedEvent === undefined || acceptedRecord === undefined) {
1194
- throw new Error("Workflow update transaction did not produce a result");
1195
- }
1196
- return { event: acceptedEvent, record: acceptedRecord };
1679
+ )
1680
+ .run(
1681
+ updateId,
1682
+ attemptId,
1683
+ nextUpdateSeq,
1684
+ revision,
1685
+ update.type,
1686
+ update.key,
1687
+ dataHash,
1688
+ Date.now(),
1689
+ );
1690
+ insertRunEvent(this.state, run.resourceId, revision, at, event);
1691
+ const committedAt = Date.now();
1692
+ this.persistRunState(run, state, revision, committedAt);
1693
+ recordViewerDeltas(this.state, runId, runViewerTargets(state, event), committedAt);
1694
+ context.revision = revision;
1695
+ acceptedEvent = event;
1696
+ acceptedRecord = record;
1197
1697
  });
1698
+ if (acceptedEvent === undefined || acceptedRecord === undefined) {
1699
+ throw new Error("Workflow update transaction did not produce a result");
1700
+ }
1701
+ return { event: acceptedEvent, record: acceptedRecord };
1198
1702
  }
1199
1703
 
1200
1704
  async writeSnapshot(
@@ -1220,6 +1724,14 @@ export class WorkflowRunStore {
1220
1724
  this.persistRunState(run, state, revision, now);
1221
1725
  this.transitionFollowUpsForRunState(state, event, now);
1222
1726
  this.syncNodeAttempts(state, snapshot, now);
1727
+ recordViewerDeltas(this.state, runId, runViewerTargets(state, traceEvent), now);
1728
+ this.snapshotLifecycle?.({
1729
+ runId,
1730
+ state,
1731
+ event: traceEvent,
1732
+ database: this.state,
1733
+ now,
1734
+ });
1223
1735
  context.revision = revision;
1224
1736
  accepted = traceEvent;
1225
1737
  });
@@ -1254,6 +1766,7 @@ export class WorkflowRunStore {
1254
1766
  await this.withRunLock(runId, async () => {
1255
1767
  const segmentId = segmentIdFor(runId, attemptId);
1256
1768
  if (this.segmentRow(segmentId) !== undefined) return;
1769
+ const hasExistingSession = this.segmentRows(runId).length > 0;
1257
1770
  this.state.transaction(() => {
1258
1771
  const run = this.requireRunRow(runId);
1259
1772
  const context = this.contextFor(runId);
@@ -1313,7 +1826,7 @@ export class WorkflowRunStore {
1313
1826
  .run(binding.piSessionId, now, runId);
1314
1827
  const revision = context.revision + 1;
1315
1828
  const at = new Date(now).toISOString();
1316
- insertRunEvent(this.state, run.resourceId, revision, at, {
1829
+ const traceEvent: WorkflowTraceEvent = {
1317
1830
  seq: revision,
1318
1831
  at,
1319
1832
  runId,
@@ -1323,11 +1836,38 @@ export class WorkflowRunStore {
1323
1836
  piSessionId: binding.piSessionId,
1324
1837
  ...(attemptId !== undefined ? { captureAttemptId: attemptId } : {}),
1325
1838
  },
1326
- });
1839
+ };
1840
+ insertRunEvent(this.state, run.resourceId, revision, at, traceEvent);
1327
1841
  const current = this.readRunState(run, this.readDefinition(run.definitionHash));
1328
1842
  current.traceSeq = revision;
1329
1843
  current.updatedAt = at;
1330
1844
  this.persistRunState(run, current, revision, now);
1845
+ const targets = runViewerTargets(current, traceEvent);
1846
+ targets[0]?.patch?.push(
1847
+ hasExistingSession
1848
+ ? {
1849
+ op: "add",
1850
+ path: "/session/binding",
1851
+ value: parseJson(canonicalJson(binding)),
1852
+ }
1853
+ : {
1854
+ op: "add",
1855
+ path: "/session",
1856
+ value: parseJson(
1857
+ canonicalJson({
1858
+ binding,
1859
+ presentationRevision: 0,
1860
+ entryPage: { presentationRevision: 0, start: 0, total: 0, items: [] },
1861
+ eventPage: { presentationRevision: 0, start: 0, total: 0, items: [] },
1862
+ eventsMalformed: false,
1863
+ eventsTornTail: false,
1864
+ capture: null,
1865
+ replayCheckpoint: null,
1866
+ }),
1867
+ ),
1868
+ },
1869
+ );
1870
+ recordViewerDeltas(this.state, runId, targets, now);
1331
1871
  context.revision = revision;
1332
1872
  });
1333
1873
  });
@@ -1343,15 +1883,26 @@ export class WorkflowRunStore {
1343
1883
  this.assertSessionWriteAuthority(runId);
1344
1884
  const revision = this.requireResourceRevision(segmentResourceId(segment));
1345
1885
  const sequence = segment.entryCount + 1;
1886
+ const runSequenceRow = this.state.connection
1887
+ .prepare(
1888
+ `SELECT COALESCE(MAX(run_seq), 0) + 1 AS count
1889
+ FROM session_entries WHERE run_id = ?`,
1890
+ )
1891
+ .get(runId);
1892
+ if (!isCountRow(runSequenceRow)) {
1893
+ throw new Error("Workflow session entry sequence is unavailable");
1894
+ }
1895
+ const runSequence = runSequenceRow.count;
1346
1896
  const now = Date.now();
1347
1897
  const entryHash = this.state.putJson(entry, now);
1348
1898
  const entryId = typeof entry.id === "string" ? entry.id : `entry-${sequence}`;
1349
1899
  this.state.connection
1350
1900
  .prepare(
1351
- `INSERT INTO session_entries(segment_id, entry_seq, entry_id, entry_hash, recorded_at)
1352
- VALUES (?, ?, ?, ?, ?)`,
1901
+ `INSERT INTO session_entries(
1902
+ segment_id, run_id, entry_seq, run_seq, entry_id, entry_hash, recorded_at
1903
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`,
1353
1904
  )
1354
- .run(segment.segmentId, sequence, entryId, entryHash, now);
1905
+ .run(segment.segmentId, runId, sequence, runSequence, entryId, entryHash, now);
1355
1906
  this.state.connection
1356
1907
  .prepare("UPDATE session_segments SET entry_count = ? WHERE segment_id = ?")
1357
1908
  .run(sequence, segment.segmentId);
@@ -1366,6 +1917,37 @@ export class WorkflowRunStore {
1366
1917
  null,
1367
1918
  now,
1368
1919
  );
1920
+ recordViewerDeltas(
1921
+ this.state,
1922
+ runId,
1923
+ [
1924
+ {
1925
+ targetType: "conversation",
1926
+ targetKey: "entries:tail",
1927
+ patch: viewerTailPatch(runSequence - 1, [
1928
+ parseJson(
1929
+ canonicalJson({
1930
+ seq: runSequence,
1931
+ at: new Date(now).toISOString(),
1932
+ entry,
1933
+ }),
1934
+ ),
1935
+ ]),
1936
+ },
1937
+ {
1938
+ targetType: "conversation",
1939
+ targetKey: "capture",
1940
+ patch: [
1941
+ {
1942
+ op: "replace",
1943
+ path: "/capture/entryCount",
1944
+ value: runSequence,
1945
+ },
1946
+ ],
1947
+ },
1948
+ ],
1949
+ now,
1950
+ );
1369
1951
  return sequence;
1370
1952
  });
1371
1953
  }
@@ -1382,6 +1964,18 @@ export class WorkflowRunStore {
1382
1964
  const current = this.requireSegment(segment.segmentId);
1383
1965
  if (current.status !== "recording") throw new Error("Session event capture has stopped");
1384
1966
  let expected = current.eventCount + 1;
1967
+ const runSequenceRow = this.state.connection
1968
+ .prepare(
1969
+ `SELECT COALESCE(MAX(run_seq), 0) + 1 AS count
1970
+ FROM session_events WHERE run_id = ?`,
1971
+ )
1972
+ .get(runId);
1973
+ if (!isCountRow(runSequenceRow)) {
1974
+ throw new Error("Workflow session event sequence is unavailable");
1975
+ }
1976
+ let runSequence = runSequenceRow.count;
1977
+ const firstRunSequence = runSequence;
1978
+ const projectedRecords: Array<WorkflowSessionEventRecord & { stepIndex?: number }> = [];
1385
1979
  for (const record of records) {
1386
1980
  validateSessionEventRecord(record);
1387
1981
  if (record.seq !== expected) {
@@ -1394,13 +1988,15 @@ export class WorkflowRunStore {
1394
1988
  this.state.connection
1395
1989
  .prepare(
1396
1990
  `INSERT INTO session_events(
1397
- segment_id, event_seq, event_type, node_id, attempt_id,
1991
+ segment_id, run_id, event_seq, run_seq, event_type, node_id, attempt_id,
1398
1992
  turn_id, message_id, tool_call_id, payload_hash, recorded_at
1399
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1993
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1400
1994
  )
1401
1995
  .run(
1402
1996
  current.segmentId,
1997
+ runId,
1403
1998
  record.seq,
1999
+ runSequence,
1404
2000
  record.type,
1405
2001
  record.nodeId,
1406
2002
  record.attemptId,
@@ -1410,14 +2006,189 @@ export class WorkflowRunStore {
1410
2006
  payloadHash,
1411
2007
  Date.parse(record.at),
1412
2008
  );
2009
+ const stepIndex = this.state.connection
2010
+ .prepare(
2011
+ `SELECT step_index AS stepIndex
2012
+ FROM run_steps WHERE run_id = ? AND attempt_id = ?`,
2013
+ )
2014
+ .get(runId, record.attemptId);
2015
+ projectedRecords.push({
2016
+ ...record,
2017
+ seq: runSequence,
2018
+ ...(isStepIndexRow(stepIndex) ? { stepIndex: stepIndex.stepIndex } : {}),
2019
+ });
1413
2020
  expected += 1;
2021
+ runSequence += 1;
1414
2022
  }
1415
2023
  this.state.connection
1416
2024
  .prepare("UPDATE session_segments SET event_count = ? WHERE segment_id = ?")
1417
2025
  .run(expected - 1, current.segmentId);
2026
+ const recordedAt = Date.now();
2027
+ this.recordSessionReplayCheckpoints(runId, firstRunSequence, projectedRecords, recordedAt);
2028
+ const projectedValues = projectedRecords.map((record) => parseJson(canonicalJson(record)));
2029
+ const nextEventTotal = firstRunSequence - 1 + projectedValues.length;
2030
+ const previousBlock = Math.floor(Math.max(0, firstRunSequence - 2) / VIEWER_PAGE_SIZE);
2031
+ const nextBlock = Math.floor(Math.max(0, nextEventTotal - 1) / VIEWER_PAGE_SIZE);
2032
+ const crossesPageBoundary = previousBlock !== nextBlock;
2033
+ recordViewerDeltas(
2034
+ this.state,
2035
+ runId,
2036
+ [
2037
+ {
2038
+ targetType: "timeline",
2039
+ targetKey: crossesPageBoundary ? "session:reload" : "session:tail",
2040
+ patch: crossesPageBoundary
2041
+ ? [{ op: "replace", path: "/total", value: nextEventTotal }]
2042
+ : [
2043
+ { op: "append", path: "/items", value: projectedValues },
2044
+ { op: "replace", path: "/total", value: nextEventTotal },
2045
+ ],
2046
+ },
2047
+ {
2048
+ targetType: "conversation",
2049
+ targetKey: "capture",
2050
+ patch: [
2051
+ {
2052
+ op: "replace",
2053
+ path: "/capture/eventCount",
2054
+ value: nextEventTotal,
2055
+ },
2056
+ {
2057
+ op: "replace",
2058
+ path: "/capture/lastEventSeq",
2059
+ value: nextEventTotal,
2060
+ },
2061
+ ],
2062
+ },
2063
+ ],
2064
+ recordedAt,
2065
+ );
1418
2066
  });
1419
2067
  }
1420
2068
 
2069
+ private recordSessionReplayCheckpoints(
2070
+ runId: string,
2071
+ firstRunSequence: number,
2072
+ newRecords: WorkflowSessionEventRecord[],
2073
+ now: number,
2074
+ ): void {
2075
+ const row = this.state.connection
2076
+ .prepare(
2077
+ `SELECT event_seq AS eventSeq, state_hash AS stateHash
2078
+ FROM viewer_session_checkpoints
2079
+ WHERE run_id = ? ORDER BY event_seq DESC LIMIT 1`,
2080
+ )
2081
+ .get(runId);
2082
+ let checkpoint = reduceSessionEvents([], [], 0);
2083
+ if (isViewerSessionCheckpointRow(row)) {
2084
+ const saved = this.state.readJson(row.stateHash);
2085
+ if (!isTemporalSessionState(saved) || saved.throughSeq !== row.eventSeq) {
2086
+ throw new Error(`Viewer session checkpoint is invalid for ${runId}`);
2087
+ }
2088
+ checkpoint = saved;
2089
+ }
2090
+ let pending = [
2091
+ ...this.sessionEventRecords(runId, checkpoint.throughSeq, firstRunSequence),
2092
+ ...newRecords,
2093
+ ];
2094
+ const lastSequence = pending.at(-1)?.seq ?? checkpoint.throughSeq;
2095
+ let boundary = (Math.floor(checkpoint.throughSeq / VIEWER_PAGE_SIZE) + 1) * VIEWER_PAGE_SIZE;
2096
+ while (boundary <= lastSequence) {
2097
+ const page = pending.filter((event) => event.seq <= boundary);
2098
+ checkpoint = reduceSessionEventsFromCheckpoint([], page, boundary, checkpoint);
2099
+ checkpoint = boundedTemporalCheckpoint(checkpoint);
2100
+ const stateHash = this.state.putJson(checkpoint, now);
2101
+ this.state.connection
2102
+ .prepare(
2103
+ `INSERT INTO viewer_session_checkpoints(run_id, event_seq, state_hash, recorded_at)
2104
+ VALUES (?, ?, ?, ?)`,
2105
+ )
2106
+ .run(runId, boundary, stateHash, now);
2107
+ pending = pending.filter((event) => event.seq > boundary);
2108
+ boundary += VIEWER_PAGE_SIZE;
2109
+ }
2110
+ }
2111
+
2112
+ private sessionEventRecords(
2113
+ runId: string,
2114
+ afterSequence: number,
2115
+ beforeSequence: number,
2116
+ ): WorkflowSessionEventRecord[] {
2117
+ return this.state.connection
2118
+ .prepare(
2119
+ `SELECT run_seq AS runSeq, event_type AS eventType, node_id AS nodeId,
2120
+ attempt_id AS attemptId, turn_id AS turnId, message_id AS messageId,
2121
+ tool_call_id AS toolCallId, payload_hash AS payloadHash,
2122
+ recorded_at AS recordedAt
2123
+ FROM session_events
2124
+ WHERE run_id = ? AND run_seq > ? AND run_seq < ?
2125
+ ORDER BY run_seq`,
2126
+ )
2127
+ .all(runId, afterSequence, beforeSequence)
2128
+ .map((value) => {
2129
+ if (!isViewerSessionEventRow(value)) {
2130
+ throw new Error(`Viewer session event row is invalid for ${runId}`);
2131
+ }
2132
+ const payload = this.state.readJson(value.payloadHash);
2133
+ if (!isRecord(payload)) {
2134
+ throw new Error(`Viewer session event payload is invalid for ${runId}`);
2135
+ }
2136
+ return {
2137
+ seq: value.runSeq,
2138
+ at: new Date(value.recordedAt).toISOString(),
2139
+ nodeId: value.nodeId,
2140
+ attemptId: value.attemptId,
2141
+ type: assertSessionEventType(value.eventType),
2142
+ payload,
2143
+ ...(value.turnId === null ? {} : { turnId: value.turnId }),
2144
+ ...(value.messageId === null ? {} : { messageId: value.messageId }),
2145
+ ...(value.toolCallId === null ? {} : { toolCallId: value.toolCallId }),
2146
+ };
2147
+ });
2148
+ }
2149
+
2150
+ private viewerSessionCapture(runId: string): WorkflowSessionCapture {
2151
+ const segments = this.segmentRows(runId);
2152
+ let status: WorkflowSessionCapture["status"] = "complete";
2153
+ let failure: WorkflowSessionCapture["failure"];
2154
+ let entryCount = 0;
2155
+ let eventCount = 0;
2156
+ for (const segment of segments) {
2157
+ entryCount += segment.entryCount;
2158
+ eventCount += segment.eventCount;
2159
+ if (segment.status === "failed") {
2160
+ status = "failed";
2161
+ if (failure === undefined && segment.failureHash !== null) {
2162
+ const value = this.state.readJson(segment.failureHash);
2163
+ if (
2164
+ !isRecord(value) ||
2165
+ typeof value.failedAt !== "string" ||
2166
+ typeof value.code !== "string" ||
2167
+ typeof value.message !== "string"
2168
+ ) {
2169
+ throw new Error(`Session capture failure is invalid for ${runId}`);
2170
+ }
2171
+ failure = {
2172
+ failedAt: value.failedAt,
2173
+ code: value.code,
2174
+ message: value.message,
2175
+ };
2176
+ }
2177
+ } else if (segment.status === "recording" && status !== "failed") {
2178
+ status = "recording";
2179
+ }
2180
+ }
2181
+ return {
2182
+ schema: SESSION_CAPTURE_SCHEMA,
2183
+ eventSchema: SESSION_EVENT_SCHEMA,
2184
+ status,
2185
+ eventCount,
2186
+ entryCount,
2187
+ lastEventSeq: eventCount,
2188
+ ...(failure === undefined ? {} : { failure }),
2189
+ };
2190
+ }
2191
+
1421
2192
  async writeSessionCapture(
1422
2193
  runId: string,
1423
2194
  capture: WorkflowSessionCapture,
@@ -1461,6 +2232,25 @@ export class WorkflowRunStore {
1461
2232
  payloadHash,
1462
2233
  now,
1463
2234
  );
2235
+ const viewerCapture = this.viewerSessionCapture(runId);
2236
+ recordViewerDeltas(
2237
+ this.state,
2238
+ runId,
2239
+ [
2240
+ {
2241
+ targetType: "conversation",
2242
+ targetKey: "capture",
2243
+ patch: [
2244
+ {
2245
+ op: "add",
2246
+ path: "/capture",
2247
+ value: parseJson(canonicalJson(viewerCapture)),
2248
+ },
2249
+ ],
2250
+ },
2251
+ ],
2252
+ now,
2253
+ );
1464
2254
  });
1465
2255
  }
1466
2256
 
@@ -1576,12 +2366,6 @@ export class WorkflowRunStore {
1576
2366
  }
1577
2367
 
1578
2368
  private assertWriteAuthority(run: RunRow, expectedRevision: number): void {
1579
- const actualRevision = this.requireResourceRevision(run.resourceId);
1580
- if (actualRevision !== expectedRevision) {
1581
- throw new Error(
1582
- `Workflow run revision conflict: expected ${expectedRevision}, got ${actualRevision}`,
1583
- );
1584
- }
1585
2369
  const lease = this.state.connection
1586
2370
  .prepare(
1587
2371
  `SELECT generation, owner_type AS ownerType, owner_id AS ownerId,
@@ -1591,19 +2375,56 @@ export class WorkflowRunStore {
1591
2375
  .get(run.resourceId);
1592
2376
  /* istanbul ignore if -- exact schema and internal query shape */
1593
2377
  if (!isLeaseAuthorityRow(lease)) throw new Error("Workflow run lease is missing");
1594
- if (lease.ownerId === null) return;
1595
- const authority = this.authorityProvider?.(run.runId);
1596
- if (
1597
- authority === undefined ||
1598
- authority.ownerType !== lease.ownerType ||
1599
- authority.ownerId !== lease.ownerId ||
1600
- authority.generation !== lease.generation ||
1601
- lease.tokenHash === null ||
1602
- !lease.tokenHash.equals(tokenHash(authority.token)) ||
1603
- lease.expiresAt === null ||
1604
- lease.expiresAt <= Date.now()
1605
- ) {
1606
- throw new Error("Workflow run write rejected because ownership changed");
2378
+
2379
+ if (lease.ownerId !== null || this.authorityProvider !== undefined) {
2380
+ const authority = this.authorityProvider?.(run.runId);
2381
+ if (authority === undefined) {
2382
+ throw new ClaimLostError(run.runId, "missingAuthority");
2383
+ }
2384
+ const now = Date.now();
2385
+ if (lease.expiresAt === null || lease.expiresAt <= now) {
2386
+ throw new ClaimLostError(run.runId, "expired");
2387
+ }
2388
+ if (authority.ownerType !== lease.ownerType || authority.ownerId !== lease.ownerId) {
2389
+ throw new ClaimLostError(run.runId, "ownerChanged");
2390
+ }
2391
+ if (lease.tokenHash === null || !lease.tokenHash.equals(tokenHash(authority.token))) {
2392
+ throw new ClaimLostError(run.runId, "tokenChanged");
2393
+ }
2394
+ if (authority.generation !== lease.generation) {
2395
+ throw new ClaimLostError(run.runId, "generationChanged");
2396
+ }
2397
+ const leaseMs = authority.leaseMs ?? 30_000;
2398
+ if (!Number.isInteger(leaseMs) || leaseMs <= 0) {
2399
+ throw new Error("Workflow run authority lease duration must be a positive integer");
2400
+ }
2401
+ const renewal = this.state.connection
2402
+ .prepare(
2403
+ `UPDATE leases SET heartbeat_at = ?, expires_at = ?
2404
+ WHERE resource_id = ? AND owner_type = ? AND owner_id = ?
2405
+ AND token_hash = ? AND generation = ? AND expires_at > ?`,
2406
+ )
2407
+ .run(
2408
+ now,
2409
+ now + leaseMs,
2410
+ run.resourceId,
2411
+ authority.ownerType,
2412
+ authority.ownerId,
2413
+ tokenHash(authority.token),
2414
+ authority.generation,
2415
+ now,
2416
+ );
2417
+ /* istanbul ignore if -- BEGIN IMMEDIATE keeps the checked lease stable */
2418
+ if (renewal.changes !== 1) {
2419
+ throw new ClaimLostError(run.runId, "expired");
2420
+ }
2421
+ }
2422
+
2423
+ const actualRevision = this.requireResourceRevision(run.resourceId);
2424
+ if (actualRevision !== expectedRevision) {
2425
+ throw new Error(
2426
+ `Workflow run revision conflict: expected ${expectedRevision}, got ${actualRevision}`,
2427
+ );
1607
2428
  }
1608
2429
  }
1609
2430
 
@@ -1708,6 +2529,77 @@ export class WorkflowRunStore {
1708
2529
  );
1709
2530
  }
1710
2531
 
2532
+ private viewerInspectorTargets(runId: string): ViewerDeltaDraft[] {
2533
+ const settings = this.state.connection
2534
+ .prepare(
2535
+ `SELECT s.scope_id AS scopeId, s.resource_id AS resourceId,
2536
+ s.origin_run_id AS originRunId, s.active_run_id AS activeRunId,
2537
+ s.mount_path AS mountPath, s.invocation,
2538
+ s.current_hash AS currentHash, r.revision,
2539
+ s.created_at AS createdAt, s.updated_at AS updatedAt
2540
+ FROM workflow_settings s
2541
+ JOIN resources r ON r.resource_id = s.resource_id
2542
+ WHERE s.active_run_id = ?
2543
+ ORDER BY s.mount_path, s.invocation`,
2544
+ )
2545
+ .all(runId)
2546
+ .filter(isSettingsScopeRow)
2547
+ .map((row) => ({
2548
+ scopeId: row.scopeId,
2549
+ mountPath: row.mountPath,
2550
+ invocation: row.invocation,
2551
+ changeNumber: row.revision,
2552
+ settingsHash: row.currentHash.toString("hex"),
2553
+ }));
2554
+ const settingsStart = Math.max(0, settings.length - VIEWER_PAGE_SIZE);
2555
+ const followUps = this.state.connection
2556
+ .prepare(`${FOLLOW_UP_ROW_SELECT} WHERE f.run_id = ? ORDER BY f.order_number`)
2557
+ .all(runId)
2558
+ .filter(isFollowUpRow)
2559
+ .map((row) => ({
2560
+ followUpId: row.followUpId,
2561
+ order: row.orderNumber,
2562
+ state: row.status,
2563
+ source: row.sourceType,
2564
+ sessionEntryId: row.sessionEntryId,
2565
+ }));
2566
+ const followUpStart = Math.max(0, followUps.length - VIEWER_PAGE_SIZE);
2567
+ const queue = this.requireFollowUpQueueRow(runId);
2568
+ return [
2569
+ {
2570
+ targetType: "inspector",
2571
+ targetKey: "settings",
2572
+ patch: [
2573
+ {
2574
+ op: "replace",
2575
+ path: "/settingsScopes",
2576
+ value: parseJson(canonicalJson(settings.slice(settingsStart))),
2577
+ },
2578
+ { op: "replace", path: "/settingsStart", value: settingsStart },
2579
+ { op: "replace", path: "/settingsTotal", value: settings.length },
2580
+ ],
2581
+ },
2582
+ {
2583
+ targetType: "inspector",
2584
+ targetKey: "follow-ups",
2585
+ patch: [
2586
+ {
2587
+ op: "replace",
2588
+ path: "/followUpQueue",
2589
+ value: parseJson(
2590
+ canonicalJson({
2591
+ presentationState: queue.presentationState,
2592
+ items: followUps.slice(followUpStart),
2593
+ }),
2594
+ ),
2595
+ },
2596
+ { op: "replace", path: "/followUpStart", value: followUpStart },
2597
+ { op: "replace", path: "/followUpTotal", value: followUps.length },
2598
+ ],
2599
+ },
2600
+ ];
2601
+ }
2602
+
1711
2603
  private initializeRunSettingsAndFollowUps(
1712
2604
  state: WorkflowRunState,
1713
2605
  initialSettings: InitialWorkflowSettingsScope[],
@@ -2207,6 +3099,20 @@ export class WorkflowRunStore {
2207
3099
  }
2208
3100
  if (state.currentAttemptId !== undefined && state.currentNode !== undefined) {
2209
3101
  this.ensureAttempt(state, state.currentNode, state.currentAttemptId, now, snapshot);
3102
+ if (state.currentNodeDeadlineAt !== undefined) {
3103
+ const deadlineAt =
3104
+ state.currentNodeDeadlineAt === null ? null : Date.parse(state.currentNodeDeadlineAt);
3105
+ if (deadlineAt !== null && !Number.isFinite(deadlineAt)) {
3106
+ throw new Error("Workflow node deadline is invalid");
3107
+ }
3108
+ this.state.connection
3109
+ .prepare(
3110
+ `UPDATE node_attempts SET deadline_at = ?, updated_at = ?
3111
+ WHERE attempt_id = ? AND run_id = ?
3112
+ AND status IN ('pending', 'running', 'waiting', 'interrupted')`,
3113
+ )
3114
+ .run(deadlineAt, now, state.currentAttemptId, state.runId);
3115
+ }
2210
3116
  }
2211
3117
  this.syncRunSteps(state, now);
2212
3118
  }
@@ -2550,6 +3456,14 @@ export class WorkflowRunStore {
2550
3456
  ...(activeAttempt?.startedAt === null || activeAttempt === undefined
2551
3457
  ? {}
2552
3458
  : { currentNodeStartedAt: new Date(activeAttempt.startedAt).toISOString() }),
3459
+ ...(activeAttempt === undefined
3460
+ ? {}
3461
+ : {
3462
+ currentNodeDeadlineAt:
3463
+ activeAttempt.deadlineAt === null
3464
+ ? null
3465
+ : new Date(activeAttempt.deadlineAt).toISOString(),
3466
+ }),
2553
3467
  ...savedCurrentSettingsBinding(
2554
3468
  activeAttempt?.settingsScopeId ?? null,
2555
3469
  activeAttempt?.settingsChangeNumber ?? null,
@@ -2700,6 +3614,7 @@ export class WorkflowRunStore {
2700
3614
  attemptId: string;
2701
3615
  nodeId: string;
2702
3616
  startedAt: number | null;
3617
+ deadlineAt: number | null;
2703
3618
  settingsScopeId: string | null;
2704
3619
  settingsChangeNumber: number | null;
2705
3620
  settingsHash: Buffer | null;
@@ -2708,7 +3623,7 @@ export class WorkflowRunStore {
2708
3623
  const row = this.state.connection
2709
3624
  .prepare(
2710
3625
  `SELECT attempt_id AS attemptId, node_id AS nodeId, started_at AS startedAt,
2711
- settings_scope_id AS settingsScopeId,
3626
+ deadline_at AS deadlineAt, settings_scope_id AS settingsScopeId,
2712
3627
  settings_change_number AS settingsChangeNumber,
2713
3628
  settings_hash AS settingsHash
2714
3629
  FROM node_attempts
@@ -2987,11 +3902,159 @@ export class WorkflowRunStore {
2987
3902
  payloadHash,
2988
3903
  now,
2989
3904
  );
3905
+ const viewerCapture = this.viewerSessionCapture(runId);
3906
+ recordViewerDeltas(
3907
+ this.state,
3908
+ runId,
3909
+ [
3910
+ {
3911
+ targetType: "conversation",
3912
+ targetKey: "capture",
3913
+ patch: [
3914
+ {
3915
+ op: "add",
3916
+ path: "/capture",
3917
+ value: parseJson(canonicalJson(viewerCapture)),
3918
+ },
3919
+ ],
3920
+ },
3921
+ ],
3922
+ now,
3923
+ );
2990
3924
  });
2991
3925
  }
2992
3926
  }
2993
3927
  }
2994
3928
 
3929
+ function runViewerTargets(
3930
+ state: WorkflowRunState,
3931
+ traceEvent: WorkflowTraceEvent,
3932
+ ): ViewerDeltaDraft[] {
3933
+ const stepTotal = state.steps.length;
3934
+ const stepStart = Math.max(0, stepTotal - VIEWER_PAGE_SIZE);
3935
+ const updateTotal = state.updates?.length ?? 0;
3936
+ const updateStart = Math.max(0, updateTotal - VIEWER_PAGE_SIZE);
3937
+ const compactStep = (step: WorkflowStepRecord) => ({
3938
+ attemptId: step.attemptId,
3939
+ nodeId: step.nodeId,
3940
+ nodeType: step.nodeType,
3941
+ outcome: step.outcome,
3942
+ startedAt: step.startedAt,
3943
+ finishedAt: step.finishedAt,
3944
+ prompt: null,
3945
+ output: null,
3946
+ ...(step.settingsScopeId === undefined ? {} : { settingsScopeId: step.settingsScopeId }),
3947
+ ...(step.settingsChangeNumber === undefined
3948
+ ? {}
3949
+ : { settingsChangeNumber: step.settingsChangeNumber }),
3950
+ ...(step.settingsHash === undefined ? {} : { settingsHash: step.settingsHash }),
3951
+ });
3952
+ const latestStepByNode = new Map<string, WorkflowStepRecord>();
3953
+ for (const step of state.steps) latestStepByNode.set(step.nodeId, step);
3954
+ const graphSteps = state.steps
3955
+ .filter((step) => latestStepByNode.get(step.nodeId) === step)
3956
+ .map(compactStep);
3957
+ const transitions = new Set<string>();
3958
+ for (let index = 1; index < state.steps.length; index += 1) {
3959
+ const previous = state.steps[index - 1];
3960
+ const current = state.steps[index];
3961
+ if (previous !== undefined && current !== undefined) {
3962
+ transitions.add(`${previous.nodeId}->${current.nodeId}`);
3963
+ }
3964
+ }
3965
+ const graphPatch: NonNullable<ViewerDeltaDraft["patch"]> = [
3966
+ { op: "replace", path: "/state/traceSeq", value: state.traceSeq },
3967
+ { op: "replace", path: "/state/updatedAt", value: state.updatedAt },
3968
+ { op: "replace", path: "/state/status", value: state.status },
3969
+ {
3970
+ op: "add",
3971
+ path: "/state/updates",
3972
+ value: parseJson(canonicalJson(state.updates?.slice(updateStart) ?? [])),
3973
+ },
3974
+ { op: "add", path: "/state/finishedAt", value: state.finishedAt ?? null },
3975
+ { op: "add", path: "/state/currentNode", value: state.currentNode ?? null },
3976
+ { op: "add", path: "/state/currentAttemptId", value: state.currentAttemptId ?? null },
3977
+ {
3978
+ op: "add",
3979
+ path: "/state/currentNodeStartedAt",
3980
+ value: state.currentNodeStartedAt ?? null,
3981
+ },
3982
+ {
3983
+ op: "add",
3984
+ path: "/state/currentNodeDeadlineAt",
3985
+ value: state.currentNodeDeadlineAt ?? null,
3986
+ },
3987
+ {
3988
+ op: "add",
3989
+ path: "/state/currentSettingsScopeId",
3990
+ value: state.currentSettingsScopeId ?? null,
3991
+ },
3992
+ {
3993
+ op: "add",
3994
+ path: "/state/currentSettingsChangeNumber",
3995
+ value: state.currentSettingsChangeNumber ?? null,
3996
+ },
3997
+ {
3998
+ op: "add",
3999
+ path: "/state/currentSettingsHash",
4000
+ value: state.currentSettingsHash ?? null,
4001
+ },
4002
+ { op: "add", path: "/state/statusDetail", value: state.statusDetail ?? null },
4003
+ { op: "add", path: "/state/paused", value: state.paused ?? false },
4004
+ { op: "add", path: "/state/waitingOn", value: state.waitingOn ?? null },
4005
+ {
4006
+ op: "add",
4007
+ path: "/state/humanDecision",
4008
+ value: parseJson(canonicalJson(state.humanDecision ?? null)),
4009
+ },
4010
+ {
4011
+ op: "replace",
4012
+ path: "/graphSteps",
4013
+ value: parseJson(canonicalJson(graphSteps)),
4014
+ },
4015
+ {
4016
+ op: "replace",
4017
+ path: "/takenTransitions",
4018
+ value: [...transitions].sort(),
4019
+ },
4020
+ { op: "replace", path: "/graphCursor", value: Math.max(0, stepTotal - 1) },
4021
+ { op: "replace", path: "/stepStart", value: stepStart },
4022
+ { op: "replace", path: "/stepTotal", value: stepTotal },
4023
+ { op: "replace", path: "/updateStart", value: updateStart },
4024
+ { op: "replace", path: "/updateTotal", value: updateTotal },
4025
+ { op: "replace", path: "/manifest/status", value: state.status },
4026
+ { op: "add", path: "/manifest/finishedAt", value: state.finishedAt ?? null },
4027
+ {
4028
+ op: "replace",
4029
+ path: "/live",
4030
+ value: state.status === "running" || state.status === "waiting",
4031
+ },
4032
+ ];
4033
+ const targets: ViewerDeltaDraft[] = [
4034
+ { targetType: "graph", patch: graphPatch },
4035
+ {
4036
+ targetType: "timeline",
4037
+ targetKey: "trace:tail",
4038
+ patch: viewerTailPatch(traceEvent.seq - 1, [
4039
+ parseJson(
4040
+ canonicalJson({
4041
+ ...traceEvent,
4042
+ payload: compactTracePayload(traceEvent.type, traceEvent.payload),
4043
+ }),
4044
+ ),
4045
+ ]),
4046
+ },
4047
+ ];
4048
+ if (traceEvent.type === "node_finished" || traceEvent.type === "node_failed") {
4049
+ targets.push({
4050
+ targetType: "replay",
4051
+ targetKey: "steps:reload",
4052
+ patch: [{ op: "replace", path: "/stepTotal", value: stepTotal }],
4053
+ });
4054
+ }
4055
+ return targets;
4056
+ }
4057
+
2995
4058
  export function readWorkflowRun(
2996
4059
  runId: string,
2997
4060
  options: ReadWorkflowRunOptions & { databasePath?: string } = {},
@@ -3265,6 +4328,21 @@ function emptySegment(): SessionCaptureSegment {
3265
4328
  };
3266
4329
  }
3267
4330
 
4331
+ function assertSessionEventType(value: string): WorkflowSessionEventType {
4332
+ switch (value) {
4333
+ case "turn_started":
4334
+ case "turn_finished":
4335
+ case "message_started":
4336
+ case "assistant_event":
4337
+ case "message_finished":
4338
+ case "tool_execution_started":
4339
+ case "tool_execution_finished":
4340
+ return value;
4341
+ default:
4342
+ throw new Error(`Unknown session event type: ${value}`);
4343
+ }
4344
+ }
4345
+
3268
4346
  function validateSessionEventRecord(record: WorkflowSessionEventRecord): void {
3269
4347
  if (!Number.isSafeInteger(record.seq) || record.seq < 1) {
3270
4348
  throw new Error("Session event seq must be a positive safe integer");
@@ -3473,6 +4551,10 @@ function isCountRow(value: unknown): value is { count: number } {
3473
4551
  return isRecord(value) && typeof value.count === "number";
3474
4552
  }
3475
4553
 
4554
+ function isStepIndexRow(value: unknown): value is { stepIndex: number } {
4555
+ return isRecord(value) && typeof value.stepIndex === "number";
4556
+ }
4557
+
3476
4558
  function isLeaseExpiryRow(value: unknown): value is { expiresAt: number | null } {
3477
4559
  return isRecord(value) && (typeof value.expiresAt === "number" || value.expiresAt === null);
3478
4560
  }
@@ -3481,6 +4563,52 @@ function isNextOrderRow(value: unknown): value is { nextOrder: number } {
3481
4563
  return isRecord(value) && typeof value.nextOrder === "number";
3482
4564
  }
3483
4565
 
4566
+ function isViewerSessionCheckpointRow(value: unknown): value is ViewerSessionCheckpointRow {
4567
+ return isRecord(value) && typeof value.eventSeq === "number" && Buffer.isBuffer(value.stateHash);
4568
+ }
4569
+
4570
+ function isViewerSessionEventRow(value: unknown): value is ViewerSessionEventRow {
4571
+ return (
4572
+ isRecord(value) &&
4573
+ typeof value.runSeq === "number" &&
4574
+ typeof value.eventType === "string" &&
4575
+ typeof value.nodeId === "string" &&
4576
+ typeof value.attemptId === "string" &&
4577
+ (typeof value.turnId === "string" || value.turnId === null) &&
4578
+ (typeof value.messageId === "string" || value.messageId === null) &&
4579
+ (typeof value.toolCallId === "string" || value.toolCallId === null) &&
4580
+ Buffer.isBuffer(value.payloadHash) &&
4581
+ typeof value.recordedAt === "number"
4582
+ );
4583
+ }
4584
+
4585
+ function isTemporalSessionState(value: unknown): value is TemporalSessionState {
4586
+ return (
4587
+ isRecord(value) &&
4588
+ typeof value.throughSeq === "number" &&
4589
+ Array.isArray(value.messages) &&
4590
+ Array.isArray(value.tools) &&
4591
+ Array.isArray(value.settledEntryIds) &&
4592
+ value.settledEntryIds.every((item) => typeof item === "string") &&
4593
+ Array.isArray(value.diagnostics) &&
4594
+ value.diagnostics.every((item) => typeof item === "string")
4595
+ );
4596
+ }
4597
+
4598
+ function boundedTemporalCheckpoint(state: TemporalSessionState): TemporalSessionState {
4599
+ const messages = state.messages.filter((message) => message.status === "streaming");
4600
+ const activeMessages = new Set(messages.map((message) => message.messageId));
4601
+ return {
4602
+ ...state,
4603
+ messages,
4604
+ tools: state.tools.filter(
4605
+ (tool) => tool.status === "running" && activeMessages.has(tool.messageId),
4606
+ ),
4607
+ settledEntryIds: [],
4608
+ diagnostics: ["earlier session messages are outside this page"],
4609
+ };
4610
+ }
4611
+
3484
4612
  function assertValidRunId(runId: string): void {
3485
4613
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(runId)) {
3486
4614
  throw new Error(`Invalid workflow run id: ${JSON.stringify(runId)}`);
@@ -3569,6 +4697,7 @@ function isActiveAttemptRow(value: unknown): value is {
3569
4697
  attemptId: string;
3570
4698
  nodeId: string;
3571
4699
  startedAt: number | null;
4700
+ deadlineAt: number | null;
3572
4701
  settingsScopeId: string | null;
3573
4702
  settingsChangeNumber: number | null;
3574
4703
  settingsHash: Buffer | null;
@@ -3635,6 +4764,79 @@ function isLeaseAuthorityRow(value: unknown): value is {
3635
4764
  return isRecord(value);
3636
4765
  }
3637
4766
 
4767
+ type ApplyingEffectRow = {
4768
+ effectId: string;
4769
+ resourceId: string;
4770
+ payloadHash: Buffer;
4771
+ attemptCount: number;
4772
+ };
4773
+
4774
+ type WorkflowEffectRow = {
4775
+ status: "pending" | "applying" | "applied" | "rejected" | "ambiguous" | "cancelled";
4776
+ payloadHash: Buffer;
4777
+ resultHash: Buffer | null;
4778
+ attemptCount: number;
4779
+ };
4780
+
4781
+ function isApplyingEffectRow(value: unknown): value is ApplyingEffectRow {
4782
+ return (
4783
+ isRecord(value) &&
4784
+ typeof value.effectId === "string" &&
4785
+ typeof value.resourceId === "string" &&
4786
+ Buffer.isBuffer(value.payloadHash) &&
4787
+ typeof value.attemptCount === "number"
4788
+ );
4789
+ }
4790
+
4791
+ function effectRecoveryFromPayload(value: JsonValue): WorkflowEffectRecovery {
4792
+ return typeof value === "object" &&
4793
+ value !== null &&
4794
+ !Array.isArray(value) &&
4795
+ value.recovery === "idempotent"
4796
+ ? "idempotent"
4797
+ : "manual";
4798
+ }
4799
+
4800
+ function isWorkflowEffectRow(value: unknown): value is WorkflowEffectRow {
4801
+ return (
4802
+ isRecord(value) &&
4803
+ typeof value.status === "string" &&
4804
+ Buffer.isBuffer(value.payloadHash) &&
4805
+ (value.resultHash === null || Buffer.isBuffer(value.resultHash)) &&
4806
+ typeof value.attemptCount === "number"
4807
+ );
4808
+ }
4809
+
4810
+ function isWorkflowEffectIdentityRow(
4811
+ value: unknown,
4812
+ ): value is { resourceId: string; status: string } {
4813
+ return (
4814
+ isRecord(value) && typeof value.resourceId === "string" && typeof value.status === "string"
4815
+ );
4816
+ }
4817
+
4818
+ function isResourceIdRow(value: unknown): value is { resourceId: string } {
4819
+ return isRecord(value) && typeof value.resourceId === "string";
4820
+ }
4821
+
4822
+ function workflowEffectId(
4823
+ sourceResourceId: string,
4824
+ effectType: string,
4825
+ idempotencyKey: string,
4826
+ ): string {
4827
+ const digest = createHash("sha256")
4828
+ .update(`${sourceResourceId}\0${effectType}\0${idempotencyKey}`)
4829
+ .digest("hex")
4830
+ .slice(0, 40);
4831
+ return `effect-${digest}`;
4832
+ }
4833
+
4834
+ function requireBoundedEffectText(value: string, label: string): void {
4835
+ if (typeof value !== "string" || value.length === 0 || value.length > 256) {
4836
+ throw new Error(`Managed effect ${label} must be nonempty text of at most 256 characters`);
4837
+ }
4838
+ }
4839
+
3638
4840
  export function createDefinitionSnapshot(workflow: WorkflowDefinition): WorkflowDefinitionSnapshot {
3639
4841
  const metadata = compositionMetadata(workflow);
3640
4842
  const composition = metadata?.snapshot;
@@ -3718,7 +4920,13 @@ function snapshotNode(
3718
4920
  ...(typeof node.humanDecision.onTimeout === "function" ? { dynamicTimeout: true } : {}),
3719
4921
  };
3720
4922
  }
3721
- if (node.nodeType === "action") common.actionExecution = "exec" in node ? "shell" : "function";
4923
+ if (node.nodeType === "action") {
4924
+ common.actionExecution = "exec" in node ? "shell" : "function";
4925
+ /* istanbul ignore else -- validated action nodes always declare a managed effect */
4926
+ if (node.effect !== undefined) {
4927
+ common.effect = { type: node.effect.type, recovery: node.effect.recovery };
4928
+ }
4929
+ }
3722
4930
  return common;
3723
4931
  }
3724
4932