@osolmaz/pi-workflows 0.15.3 → 0.16.1

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 (207) hide show
  1. package/README.md +24 -8
  2. package/dist/channels/adapter-entry.d.ts +3 -0
  3. package/dist/channels/adapter-entry.js +203 -0
  4. package/dist/channels/adapter-entry.js.map +1 -0
  5. package/dist/channels/config.d.ts +43 -0
  6. package/dist/channels/config.js +234 -0
  7. package/dist/channels/config.js.map +1 -0
  8. package/dist/channels/protocol.d.ts +94 -0
  9. package/dist/channels/protocol.js +219 -0
  10. package/dist/channels/protocol.js.map +1 -0
  11. package/dist/channels/telegram.d.ts +46 -0
  12. package/dist/channels/telegram.js +285 -0
  13. package/dist/channels/telegram.js.map +1 -0
  14. package/dist/client/activity.d.ts +2 -0
  15. package/dist/client/activity.js +6 -0
  16. package/dist/client/activity.js.map +1 -0
  17. package/dist/client/client.d.ts +102 -0
  18. package/dist/client/client.js +737 -0
  19. package/dist/client/client.js.map +1 -0
  20. package/dist/client/index.d.ts +3 -0
  21. package/dist/client/index.js +3 -0
  22. package/dist/client/index.js.map +1 -0
  23. package/dist/client/materialize.d.ts +7 -0
  24. package/dist/client/materialize.js +177 -0
  25. package/dist/client/materialize.js.map +1 -0
  26. package/dist/client/protocol.d.ts +60 -0
  27. package/dist/client/protocol.js +275 -0
  28. package/dist/client/protocol.js.map +1 -0
  29. package/dist/client/resolver.d.ts +23 -0
  30. package/dist/client/resolver.js +2 -0
  31. package/dist/client/resolver.js.map +1 -0
  32. package/dist/client/view.d.ts +144 -0
  33. package/dist/client/view.js +3 -0
  34. package/dist/client/view.js.map +1 -0
  35. package/dist/controllers/index.d.ts +1 -1
  36. package/dist/controllers/index.js.map +1 -1
  37. package/dist/controllers/sqlite.d.ts +47 -157
  38. package/dist/controllers/sqlite.js +230 -406
  39. package/dist/controllers/sqlite.js.map +1 -1
  40. package/dist/extension/index.d.ts +23 -1
  41. package/dist/extension/index.js +734 -555
  42. package/dist/extension/index.js.map +1 -1
  43. package/dist/extension/recorder.d.ts +95 -0
  44. package/dist/extension/recorder.js +530 -0
  45. package/dist/extension/recorder.js.map +1 -0
  46. package/dist/extension/remote-recorder-store.d.ts +25 -0
  47. package/dist/extension/remote-recorder-store.js +81 -0
  48. package/dist/extension/remote-recorder-store.js.map +1 -0
  49. package/dist/extension/session-events.d.ts +134 -0
  50. package/dist/extension/session-events.js +60 -0
  51. package/dist/extension/session-events.js.map +1 -0
  52. package/dist/extension/session-view.d.ts +9 -2
  53. package/dist/extension/session-view.js +117 -51
  54. package/dist/extension/session-view.js.map +1 -1
  55. package/dist/extension/step-message.d.ts +6 -4
  56. package/dist/extension/step-message.js +12 -6
  57. package/dist/extension/step-message.js.map +1 -1
  58. package/dist/extension/widget.d.ts +3 -2
  59. package/dist/extension/widget.js +41 -24
  60. package/dist/extension/widget.js.map +1 -1
  61. package/dist/extension/workflow-message-coordinator.d.ts +27 -0
  62. package/dist/extension/workflow-message-coordinator.js +294 -0
  63. package/dist/extension/workflow-message-coordinator.js.map +1 -0
  64. package/dist/host/channel-effects.d.ts +72 -0
  65. package/dist/host/channel-effects.js +271 -0
  66. package/dist/host/channel-effects.js.map +1 -0
  67. package/dist/host/channel-supervisor.d.ts +21 -0
  68. package/dist/host/channel-supervisor.js +54 -0
  69. package/dist/host/channel-supervisor.js.map +1 -0
  70. package/dist/host/child-worker-supervisor.js +1 -1
  71. package/dist/host/child-worker-supervisor.js.map +1 -1
  72. package/dist/host/resolver-entry.d.ts +2 -23
  73. package/dist/host/resolver-entry.js +1 -1
  74. package/dist/host/resolver-entry.js.map +1 -1
  75. package/dist/host/runner.d.ts +54 -8
  76. package/dist/host/runner.js +1997 -305
  77. package/dist/host/runner.js.map +1 -1
  78. package/dist/host/state.d.ts +25 -19
  79. package/dist/host/state.js +303 -89
  80. package/dist/host/state.js.map +1 -1
  81. package/dist/host/view.d.ts +83 -0
  82. package/dist/host/view.js +975 -0
  83. package/dist/host/view.js.map +1 -0
  84. package/dist/host/worker-protocol.js +1 -1
  85. package/dist/host/worker-protocol.js.map +1 -1
  86. package/dist/state/database.d.ts +1 -0
  87. package/dist/state/database.js +15 -0
  88. package/dist/state/database.js.map +1 -1
  89. package/dist/state/prune.d.ts +3 -1
  90. package/dist/state/prune.js +9 -33
  91. package/dist/state/prune.js.map +1 -1
  92. package/dist/state/schema.js +88 -98
  93. package/dist/state/schema.js.map +1 -1
  94. package/dist/state/workflow-messages.d.ts +96 -0
  95. package/dist/state/workflow-messages.js +335 -0
  96. package/dist/state/workflow-messages.js.map +1 -0
  97. package/dist/viewer/backup.d.ts +2 -0
  98. package/dist/viewer/backup.js +28 -0
  99. package/dist/viewer/backup.js.map +1 -0
  100. package/dist/viewer/cli.d.ts +4 -0
  101. package/dist/viewer/cli.js +150 -170
  102. package/dist/viewer/cli.js.map +1 -1
  103. package/dist/viewer/render.js +1 -1
  104. package/dist/viewer/render.js.map +1 -1
  105. package/dist/viewer/tui.d.ts +5 -7
  106. package/dist/viewer/tui.js +245 -108
  107. package/dist/viewer/tui.js.map +1 -1
  108. package/dist/workflows/human-decision.d.ts +2 -0
  109. package/dist/workflows/human-decision.js +8 -2
  110. package/dist/workflows/human-decision.js.map +1 -1
  111. package/dist/workflows/index.d.ts +1 -1
  112. package/dist/workflows/index.js.map +1 -1
  113. package/dist/workflows/settings.d.ts +1 -8
  114. package/dist/workflows/settings.js.map +1 -1
  115. package/dist/workflows/store.d.ts +67 -20
  116. package/dist/workflows/store.js +451 -378
  117. package/dist/workflows/store.js.map +1 -1
  118. package/dist/workflows/tool-input.d.ts +21 -0
  119. package/dist/workflows/tool-input.js +23 -1
  120. package/dist/workflows/tool-input.js.map +1 -1
  121. package/dist/workflows/types.d.ts +5 -5
  122. package/dist/workflows/workflow-message-content.d.ts +38 -0
  123. package/dist/workflows/workflow-message-content.js +157 -0
  124. package/dist/workflows/workflow-message-content.js.map +1 -0
  125. package/docs/2026-08-18-herdr-piw-plan.md +2 -1
  126. package/docs/2026-08-20-durable-workflow-launch-plan.md +1 -1
  127. package/docs/2026-08-25-workflow-follow-ups.md +26 -50
  128. package/docs/2026-08-30-out-of-process-workflow-host-plan.md +3 -3
  129. package/docs/2026-09-01-restore-session-delivery-controls-plan.md +4 -0
  130. package/docs/2026-09-01-unified-workflow-client-plan.md +385 -0
  131. package/docs/2026-09-02-installed-live-e2e-plan.md +239 -0
  132. package/docs/2026-09-02-unify-workflow-messages-plan.md +562 -0
  133. package/docs/DEFERRED_TURNS.md +66 -294
  134. package/docs/HUMAN_DECISIONS.md +29 -30
  135. package/docs/HUMAN_DECISION_PRESENTATIONS.md +4 -11
  136. package/docs/SQLITE_STATE.md +31 -25
  137. package/docs/WORKFLOW_HOST.md +155 -77
  138. package/docs/WORKFLOW_STEP_MESSAGES.md +166 -75
  139. package/docs/development.md +2 -1
  140. package/docs/live-replay-protocol.md +74 -132
  141. package/docs/tui-viewer.md +12 -14
  142. package/docs/workflows.md +78 -28
  143. package/herdr-plugin.toml +1 -1
  144. package/package.json +9 -3
  145. package/plugins/herdr/viewer.mjs +14 -5
  146. package/protocol/client.v1.schema.json +137 -0
  147. package/protocol/fixtures/client-v1.json +23 -0
  148. package/src/channels/adapter-entry.ts +220 -0
  149. package/src/channels/config.ts +296 -0
  150. package/src/channels/protocol.ts +333 -0
  151. package/src/channels/telegram.ts +335 -0
  152. package/src/client/activity.ts +6 -0
  153. package/src/client/client.ts +939 -0
  154. package/src/client/index.ts +25 -0
  155. package/src/client/materialize.ts +228 -0
  156. package/src/client/protocol.ts +333 -0
  157. package/src/client/resolver.ts +26 -0
  158. package/src/client/view.ts +163 -0
  159. package/src/controllers/index.ts +0 -1
  160. package/src/controllers/sqlite.ts +339 -808
  161. package/src/extension/index.ts +853 -665
  162. package/src/extension/recorder.ts +687 -0
  163. package/src/extension/remote-recorder-store.ts +126 -0
  164. package/src/extension/session-events.ts +119 -0
  165. package/src/extension/session-view.ts +157 -51
  166. package/src/extension/step-message.ts +19 -9
  167. package/src/extension/widget.ts +53 -25
  168. package/src/extension/workflow-message-coordinator.ts +334 -0
  169. package/src/host/channel-effects.ts +465 -0
  170. package/src/host/channel-supervisor.ts +73 -0
  171. package/src/host/child-worker-supervisor.ts +1 -1
  172. package/src/host/resolver-entry.ts +11 -26
  173. package/src/host/runner.ts +2639 -450
  174. package/src/host/state.ts +404 -137
  175. package/src/host/view.ts +1212 -0
  176. package/src/host/worker-protocol.ts +1 -1
  177. package/src/state/database.ts +13 -0
  178. package/src/state/prune.ts +14 -42
  179. package/src/state/schema.ts +88 -98
  180. package/src/state/workflow-messages.ts +548 -0
  181. package/src/viewer/backup.ts +29 -0
  182. package/src/viewer/cli.ts +171 -185
  183. package/src/viewer/render.ts +1 -5
  184. package/src/viewer/tui.ts +261 -124
  185. package/src/workflows/human-decision.ts +18 -2
  186. package/src/workflows/index.ts +0 -1
  187. package/src/workflows/settings.ts +1 -20
  188. package/src/workflows/store.ts +650 -511
  189. package/src/workflows/tool-input.ts +36 -1
  190. package/src/workflows/types.ts +5 -5
  191. package/src/workflows/workflow-message-content.ts +197 -0
  192. package/dist/extension/decision-channels.d.ts +0 -134
  193. package/dist/extension/decision-channels.js +0 -1307
  194. package/dist/extension/decision-channels.js.map +0 -1
  195. package/dist/host/client.d.ts +0 -48
  196. package/dist/host/client.js +0 -216
  197. package/dist/host/client.js.map +0 -1
  198. package/dist/host/protocol.d.ts +0 -38
  199. package/dist/host/protocol.js +0 -156
  200. package/dist/host/protocol.js.map +0 -1
  201. package/dist/viewer/watch.d.ts +0 -6
  202. package/dist/viewer/watch.js +0 -46
  203. package/dist/viewer/watch.js.map +0 -1
  204. package/src/extension/decision-channels.ts +0 -1826
  205. package/src/host/client.ts +0 -293
  206. package/src/host/protocol.ts +0 -196
  207. package/src/viewer/watch.ts +0 -51
@@ -7,7 +7,6 @@ import {
7
7
  resourceIdFor,
8
8
  tokenHash,
9
9
  type ActorType,
10
- type LeaseClaim,
11
10
  type MutationActor,
12
11
  type OwnerType,
13
12
  } from "../state/mutation.js";
@@ -18,6 +17,7 @@ import {
18
17
  VIEWER_PAGE_SIZE,
19
18
  type ViewerDeltaDraft,
20
19
  } from "../state/viewer.js";
20
+ import { WorkflowMessageStore, workflowMessageIdFor } from "../state/workflow-messages.js";
21
21
  import { compositionMetadata } from "./composition.js";
22
22
  import { ClaimLostError } from "./errors.js";
23
23
  import { HumanDecisionStore } from "./human-decision.js";
@@ -34,7 +34,6 @@ import {
34
34
  type WorkflowFollowUpQueueRecord,
35
35
  type WorkflowFollowUpRecord,
36
36
  type WorkflowFollowUpState,
37
- type WorkflowPresentationState,
38
37
  type WorkflowQueueFollowUpRequest,
39
38
  type WorkflowRemoveFollowUpRequest,
40
39
  type WorkflowSettingsChangeRecord,
@@ -70,6 +69,7 @@ import type {
70
69
  WorkflowUpdateRecord,
71
70
  } from "./types.js";
72
71
  import { MAX_CURRENT_UPDATES, createUpdateId, updateProjection } from "./updates.js";
72
+ import { followUpWorkflowMessageContent } from "./workflow-message-content.js";
73
73
 
74
74
  export const RUN_STATE_SCHEMA = "pi-workflows.run-state.v1" as const;
75
75
  export const DEFINITION_SNAPSHOT_SCHEMA = "pi-workflows.definition-snapshot.v1" as const;
@@ -78,16 +78,43 @@ export const SESSION_EVENT_SCHEMA = "pi-workflows.session-event.v1" as const;
78
78
  export const SESSION_CAPTURE_SCHEMA = "pi-workflows.session-capture.v1" as const;
79
79
  export const SESSION_EVENT_MAX_BYTES = 1024 * 1024;
80
80
 
81
+ const STEP_ROW_SELECT = `
82
+ SELECT s.step_index AS stepIndex, a.attempt_id AS attemptId, a.node_id AS nodeId,
83
+ a.node_type AS nodeType, a.status,
84
+ a.prompt_hash AS promptHash, a.output_hash AS outputHash,
85
+ s.output_override_hash AS outputOverrideHash,
86
+ a.receipt_hash AS receiptHash, a.error_hash AS errorHash,
87
+ prompt_entry.entry_hash AS promptEntryHash,
88
+ response_entry.entry_hash AS responseEntryHash,
89
+ first_link.entry_id AS firstEntryId, last_link.entry_id AS lastEntryId,
90
+ a.settings_scope_id AS settingsScopeId,
91
+ a.settings_change_number AS settingsChangeNumber,
92
+ a.settings_hash AS settingsHash,
93
+ a.started_at AS startedAt, a.finished_at AS finishedAt
94
+ FROM run_steps s
95
+ JOIN node_attempts a ON a.attempt_id = s.attempt_id
96
+ LEFT JOIN attempt_entries prompt_link
97
+ ON prompt_link.attempt_id = a.attempt_id AND prompt_link.role = 'prompt'
98
+ LEFT JOIN session_entries prompt_entry
99
+ ON prompt_entry.segment_id = prompt_link.segment_id
100
+ AND prompt_entry.entry_id = prompt_link.entry_id
101
+ LEFT JOIN attempt_entries response_link
102
+ ON response_link.attempt_id = a.attempt_id AND response_link.role = 'response'
103
+ LEFT JOIN session_entries response_entry
104
+ ON response_entry.segment_id = response_link.segment_id
105
+ AND response_entry.entry_id = response_link.entry_id
106
+ LEFT JOIN attempt_entries first_link
107
+ ON first_link.attempt_id = a.attempt_id AND first_link.role = 'first'
108
+ LEFT JOIN attempt_entries last_link
109
+ ON last_link.attempt_id = a.attempt_id AND last_link.role = 'last'`;
110
+
81
111
  const FOLLOW_UP_ROW_SELECT = `
82
112
  SELECT f.follow_up_id AS followUpId, f.resource_id AS resourceId,
83
- f.queue_resource_id AS queueResourceId, f.run_id AS runId,
84
- f.request_id AS requestId, f.order_number AS orderNumber,
113
+ f.run_id AS runId, f.request_id AS requestId, f.order_number AS orderNumber,
85
114
  f.target_session_id AS targetSessionId, f.actor_type AS actorType,
86
115
  f.actor_id AS actorId, f.source_type AS sourceType,
87
- f.prompt_hash AS promptHash, f.status,
88
- f.session_entry_id AS sessionEntryId, f.reason_hash AS reasonHash,
89
- f.created_at AS createdAt, f.updated_at AS updatedAt, f.sent_at AS sentAt,
90
- r.revision
116
+ f.prompt_hash AS promptHash, f.status, f.reason_hash AS reasonHash,
117
+ f.created_at AS createdAt, f.updated_at AS updatedAt, r.revision
91
118
  FROM workflow_follow_ups f
92
119
  JOIN resources r ON r.resource_id = f.resource_id`;
93
120
 
@@ -110,6 +137,12 @@ export type RunWriteAuthority = {
110
137
  leaseMs?: number;
111
138
  };
112
139
 
140
+ export type WorkflowRunDisplayState = {
141
+ status: WorkflowRunState["status"];
142
+ paused: boolean;
143
+ error: string | null;
144
+ };
145
+
113
146
  export type WorkflowRunStoreOptions = {
114
147
  authorityProvider?: (runId: string) => RunWriteAuthority | undefined;
115
148
  /** Host-owned projection updates that must commit with each run snapshot. */
@@ -120,6 +153,8 @@ export type WorkflowRunStoreOptions = {
120
153
  database: StateDatabase;
121
154
  now: number;
122
155
  }) => void;
156
+ /** The global host may record an attested Pi session after the worker lease ends. */
157
+ allowHostSessionRecording?: boolean;
123
158
  state?: StateDatabase;
124
159
  readOnly?: boolean;
125
160
  };
@@ -316,21 +351,9 @@ type SettingsChangeRow = {
316
351
  acceptedAt: number;
317
352
  };
318
353
 
319
- type FollowUpQueueRow = {
320
- runId: string;
321
- resourceId: string;
322
- originSessionId: string | null;
323
- presentationState: string;
324
- presentationEntryId: string | null;
325
- presentationAssistantEntryId: string | null;
326
- presentationReasonHash: Buffer | null;
327
- revision: number;
328
- };
329
-
330
354
  type FollowUpRow = {
331
355
  followUpId: string;
332
356
  resourceId: string;
333
- queueResourceId: string;
334
357
  runId: string;
335
358
  requestId: string;
336
359
  orderNumber: number;
@@ -340,11 +363,9 @@ type FollowUpRow = {
340
363
  sourceType: string;
341
364
  promptHash: Buffer;
342
365
  status: string;
343
- sessionEntryId: string | null;
344
366
  reasonHash: Buffer | null;
345
367
  createdAt: number;
346
368
  updatedAt: number;
347
- sentAt: number | null;
348
369
  revision: number;
349
370
  };
350
371
 
@@ -410,6 +431,48 @@ export type LoadedWorkflowRun = {
410
431
  followUpQueue?: WorkflowFollowUpQueueRecord | null;
411
432
  };
412
433
 
434
+ export type WorkflowRunViewCounts = {
435
+ steps: number;
436
+ trace: number;
437
+ sessionEntries: number;
438
+ sessionEvents: number;
439
+ settings: number;
440
+ followUps: number;
441
+ updates: number;
442
+ };
443
+
444
+ export type WorkflowRunViewRange = {
445
+ start: number;
446
+ limit: number;
447
+ };
448
+
449
+ export type WorkflowRunViewRead = {
450
+ runId: string;
451
+ state: WorkflowRunState;
452
+ snapshot: WorkflowDefinitionSnapshot;
453
+ traceEvents: WorkflowTraceEvent[];
454
+ sessionBinding: WorkflowSessionBinding | null;
455
+ sessionEntries: WorkflowSessionEntryRecord[];
456
+ sessionEvents: WorkflowSessionEventRecord[];
457
+ sessionCapture: WorkflowSessionCapture | null;
458
+ sessionIntegrity: SessionCaptureIntegrity;
459
+ settingsScopes: WorkflowSettingsScopeRecord[];
460
+ followUpQueue: WorkflowFollowUpQueueRecord | null;
461
+ graphSteps: WorkflowStepRecord[];
462
+ takenTransitions: string[];
463
+ };
464
+
465
+ export type WorkflowRunViewReadOptions = {
466
+ steps: WorkflowRunViewRange;
467
+ trace: WorkflowRunViewRange;
468
+ sessionEntries: WorkflowRunViewRange;
469
+ sessionEvents: WorkflowRunViewRange;
470
+ settings: WorkflowRunViewRange;
471
+ followUps: WorkflowRunViewRange;
472
+ updates: WorkflowRunViewRange;
473
+ graphCursor: number;
474
+ };
475
+
413
476
  export type ReadWorkflowRunOptions = {
414
477
  includeTrace?: boolean;
415
478
  };
@@ -425,13 +488,16 @@ export class WorkflowRunStore {
425
488
  | ((runId: string) => RunWriteAuthority | undefined)
426
489
  | undefined;
427
490
  private readonly snapshotLifecycle: WorkflowRunStoreOptions["snapshotLifecycle"];
491
+ private readonly allowHostSessionRecording: boolean;
428
492
  private readonly contexts = new Map<string, RunContext>();
429
493
  private readonly ownsState: boolean;
430
494
  private readonly mutations: StateMutationStore;
495
+ private readonly workflowMessages: WorkflowMessageStore;
431
496
 
432
497
  constructor(databasePath: string = workflowStatePath(), options: WorkflowRunStoreOptions = {}) {
433
498
  this.authorityProvider = options.authorityProvider;
434
499
  this.snapshotLifecycle = options.snapshotLifecycle;
500
+ this.allowHostSessionRecording = options.allowHostSessionRecording === true;
435
501
  this.ownsState = options.state === undefined;
436
502
  this.state =
437
503
  options.state ??
@@ -442,6 +508,7 @@ export class WorkflowRunStore {
442
508
  });
443
509
  this.databasePath = this.state.filePath;
444
510
  this.mutations = new StateMutationStore(this.state);
511
+ this.workflowMessages = new WorkflowMessageStore(this.state);
445
512
  }
446
513
 
447
514
  close(): void {
@@ -834,6 +901,18 @@ export class WorkflowRunStore {
834
901
  return;
835
902
  }
836
903
  const definitionHash = this.state.putJson(snapshot, now);
904
+ const parentLineage =
905
+ state.parentRunId === undefined
906
+ ? undefined
907
+ : this.state.connection
908
+ .prepare(
909
+ `SELECT root_run_id AS rootRunId, restart_number AS restartNumber
910
+ FROM runs WHERE run_id = ?`,
911
+ )
912
+ .get(state.parentRunId);
913
+ if (state.parentRunId !== undefined && !isRunLineageRow(parentLineage)) {
914
+ throw new Error(`Workflow parent run is missing: ${state.parentRunId}`);
915
+ }
837
916
  this.state.connection
838
917
  .prepare(
839
918
  `INSERT INTO workflow_definitions(
@@ -862,16 +941,20 @@ export class WorkflowRunStore {
862
941
  this.state.connection
863
942
  .prepare(
864
943
  `INSERT INTO runs(
865
- run_id, resource_id, project_id, parent_run_id, definition_digest,
944
+ run_id, resource_id, project_id, parent_run_id, root_run_id, lineage_kind,
945
+ restart_number, parent_terminal_fingerprint, definition_digest,
866
946
  workflow_ref, launch_options_hash, title, status, paused,
867
947
  status_detail, input_hash, final_output_hash, error_hash,
868
948
  created_at, updated_at, finished_at
869
- ) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
949
+ ) VALUES (?, ?, NULL, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
870
950
  )
871
951
  .run(
872
952
  state.runId,
873
953
  resourceId,
874
954
  state.parentRunId ?? null,
955
+ isRunLineageRow(parentLineage) ? parentLineage.rootRunId : state.runId,
956
+ state.parentRunId === undefined ? null : "continuation",
957
+ isRunLineageRow(parentLineage) ? parentLineage.restartNumber : 0,
875
958
  definitionDigest,
876
959
  state.workflowName,
877
960
  launchOptionsHash,
@@ -1033,6 +1116,28 @@ export class WorkflowRunStore {
1033
1116
  .map((row) => this.settingsScopeRecord(row));
1034
1117
  }
1035
1118
 
1119
+ private readSettingsScopeRange(
1120
+ runId: string,
1121
+ range: WorkflowRunViewRange,
1122
+ ): WorkflowSettingsScopeRecord[] {
1123
+ const originRunId = this.logicalOriginRunId(runId);
1124
+ return this.state.connection
1125
+ .prepare(
1126
+ `SELECT s.scope_id AS scopeId, s.resource_id AS resourceId,
1127
+ s.origin_run_id AS originRunId, s.active_run_id AS activeRunId,
1128
+ s.mount_path AS mountPath, s.invocation,
1129
+ s.current_hash AS currentHash, r.revision,
1130
+ s.created_at AS createdAt, s.updated_at AS updatedAt
1131
+ FROM workflow_settings s
1132
+ JOIN resources r ON r.resource_id = s.resource_id
1133
+ WHERE s.origin_run_id = ? OR s.active_run_id = ?
1134
+ ORDER BY s.mount_path, s.invocation LIMIT ? OFFSET ?`,
1135
+ )
1136
+ .all(originRunId, runId, range.limit, range.start)
1137
+ .filter(isSettingsScopeRow)
1138
+ .map((row) => this.settingsScopeRecord(row));
1139
+ }
1140
+
1036
1141
  findSettingsScope(
1037
1142
  runId: string,
1038
1143
  mountPath: string,
@@ -1187,87 +1292,78 @@ export class WorkflowRunStore {
1187
1292
  if (Buffer.byteLength(request.prompt, "utf8") > 64 * 1024) {
1188
1293
  throw new Error("Workflow follow-up prompt cannot exceed 65536 bytes");
1189
1294
  }
1190
- for (let attempt = 0; attempt < 8; attempt += 1) {
1295
+ return this.state.transaction(() => {
1191
1296
  const prior = this.followUpRowByRequest(request.runId, request.requestId);
1192
1297
  if (prior !== undefined) {
1193
1298
  this.assertSameFollowUp(prior, request);
1194
1299
  return { followUp: this.followUpRecord(prior), adopted: true };
1195
1300
  }
1196
1301
  this.requireRunAcceptsSettings(request.runId, true);
1197
- const queue = this.requireFollowUpQueueRow(request.runId);
1198
- try {
1199
- const result = this.mutations.mutate(
1200
- {
1201
- resourceId: queue.resourceId,
1202
- operation: "follow-up.queue",
1203
- actor: request.actor,
1204
- expectedRevision: queue.revision,
1205
- },
1206
- "follow-up.queued",
1207
- ({ database, now }) => {
1208
- this.requireRunAcceptsSettings(request.runId, true);
1209
- const currentQueue = this.requireFollowUpQueueRow(request.runId);
1210
- if (
1211
- currentQueue.originSessionId !== null &&
1212
- currentQueue.originSessionId !== request.targetSessionId
1213
- ) {
1214
- throw new Error("Workflow follow-ups must target the run's origin Pi session");
1215
- }
1216
- if (currentQueue.originSessionId === null) {
1217
- database.connection
1218
- .prepare(
1219
- `UPDATE workflow_follow_up_queues
1220
- SET origin_session_id = ?, updated_at = ? WHERE run_id = ?`,
1221
- )
1222
- .run(request.targetSessionId, now, request.runId);
1223
- }
1224
- const order = this.nextFollowUpOrder(request.runId);
1225
- const followUpId = followUpIdFor(request.runId, request.requestId);
1226
- const resourceId = this.mutations.ensureResource("follow_up", followUpId, now);
1227
- const promptHash = database.putText(request.prompt, now);
1228
- database.connection
1229
- .prepare(
1230
- `INSERT INTO workflow_follow_ups(
1231
- follow_up_id, resource_id, queue_resource_id, run_id,
1232
- request_id, order_number, target_session_id,
1233
- actor_type, actor_id, source_type, prompt_hash, status,
1234
- created_at, updated_at
1235
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?)`,
1236
- )
1237
- .run(
1238
- followUpId,
1239
- resourceId,
1240
- queue.resourceId,
1241
- request.runId,
1242
- request.requestId,
1243
- order,
1244
- request.targetSessionId,
1245
- request.actor.type,
1246
- request.actor.id ?? null,
1247
- request.source,
1248
- promptHash,
1249
- now,
1250
- now,
1251
- );
1252
- recordViewerDeltas(
1253
- this.state,
1254
- request.runId,
1255
- this.viewerInspectorTargets(request.runId),
1256
- now,
1257
- );
1258
- return followUpId;
1259
- },
1260
- { payload: { runId: request.runId, requestId: request.requestId } },
1261
- );
1262
- return {
1263
- followUp: this.followUpRecord(this.requireFollowUpRow(result.value)),
1264
- adopted: false,
1265
- };
1266
- } catch (error) {
1267
- if (!(error instanceof StaleResourceError)) throw error;
1302
+ if (this.originSessionId(request.runId) !== request.targetSessionId) {
1303
+ throw new Error("Workflow follow-ups must target the run's origin Pi session");
1268
1304
  }
1269
- }
1270
- throw new StaleResourceError("Workflow follow-up queue kept changing; retry the request");
1305
+ const now = Date.now();
1306
+ const order = this.nextFollowUpOrder(request.runId);
1307
+ const followUpId = followUpIdFor(request.runId, request.requestId);
1308
+ const resourceId = this.mutations.ensureResource("follow_up", followUpId, now);
1309
+ const promptHash = this.state.putText(request.prompt, now);
1310
+ this.state.connection
1311
+ .prepare(
1312
+ `INSERT INTO workflow_follow_ups(
1313
+ follow_up_id, resource_id, run_id, request_id, order_number,
1314
+ target_session_id, actor_type, actor_id, source_type, prompt_hash,
1315
+ status, created_at, updated_at
1316
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?)`,
1317
+ )
1318
+ .run(
1319
+ followUpId,
1320
+ resourceId,
1321
+ request.runId,
1322
+ request.requestId,
1323
+ order,
1324
+ request.targetSessionId,
1325
+ request.actor.type,
1326
+ request.actor.id ?? null,
1327
+ request.source,
1328
+ promptHash,
1329
+ now,
1330
+ now,
1331
+ );
1332
+ const workflowMessageId = workflowMessageIdFor("followUp", followUpId, "initial");
1333
+ this.workflowMessages.create({
1334
+ workflowMessageId,
1335
+ runId: request.runId,
1336
+ targetSessionId: request.targetSessionId,
1337
+ kind: "followUp",
1338
+ sourceId: followUpId,
1339
+ idempotencyKey: "initial",
1340
+ content: followUpWorkflowMessageContent({
1341
+ workflowMessageId,
1342
+ followUpId,
1343
+ runId: request.runId,
1344
+ prompt: request.prompt,
1345
+ }),
1346
+ now,
1347
+ });
1348
+ this.bumpResource(resourceId, 0, now);
1349
+ insertGenericEvent(
1350
+ this.state,
1351
+ resourceId,
1352
+ 1,
1353
+ "follow-up.queued",
1354
+ request.actor.type,
1355
+ request.actor.id ?? null,
1356
+ this.state.putJson({ runId: request.runId, requestId: request.requestId }, now),
1357
+ now,
1358
+ );
1359
+ recordViewerDeltas(
1360
+ this.state,
1361
+ request.runId,
1362
+ this.viewerInspectorTargets(request.runId),
1363
+ now,
1364
+ );
1365
+ return { followUp: this.followUpRecord(this.requireFollowUpRow(followUpId)), adopted: false };
1366
+ });
1271
1367
  }
1272
1368
 
1273
1369
  removeFollowUp(request: WorkflowRemoveFollowUpRequest): WorkflowFollowUpRecord {
@@ -1277,12 +1373,13 @@ export class WorkflowRunStore {
1277
1373
  throw new Error(`Workflow follow-up is not part of run ${request.runId}`);
1278
1374
  }
1279
1375
  if (row.status === "removed") return this.followUpRecord(row);
1280
- if (row.status === "sent" || row.status === "cancelled") {
1281
- throw new Error(`Workflow follow-up cannot be removed after it is ${row.status}`);
1376
+ if (row.status === "cancelled") {
1377
+ throw new Error("A cancelled workflow follow-up cannot be removed");
1282
1378
  }
1283
1379
  this.assertFollowUpRemovalAuthority(row, request.actor, request.source);
1284
- if (this.hasLiveLease(row.resourceId)) {
1285
- throw new Error("Workflow follow-up is being delivered and cannot be removed");
1380
+ const message = this.workflowMessages.latestForSource("followUp", request.followUpId);
1381
+ if (message?.status === "sent") {
1382
+ throw new Error("A sent workflow follow-up cannot be removed");
1286
1383
  }
1287
1384
  const reason = "Removed before delivery";
1288
1385
  this.mutations.mutate(
@@ -1299,12 +1396,13 @@ export class WorkflowRunStore {
1299
1396
  .prepare(
1300
1397
  `UPDATE workflow_follow_ups
1301
1398
  SET status = 'removed', reason_hash = ?, updated_at = ?
1302
- WHERE follow_up_id = ? AND status IN ('queued', 'pending_presentation', 'ready')`,
1399
+ WHERE follow_up_id = ? AND status = 'queued'`,
1303
1400
  )
1304
1401
  .run(reasonHash, now, request.followUpId);
1305
1402
  if (update.changes !== 1) {
1306
1403
  throw new StaleResourceError("Workflow follow-up changed before removal");
1307
1404
  }
1405
+ this.workflowMessages.cancelPendingForSource(request.followUpId, "followUp", now);
1308
1406
  recordViewerDeltas(
1309
1407
  this.state,
1310
1408
  request.runId,
@@ -1320,10 +1418,8 @@ export class WorkflowRunStore {
1320
1418
  originSessionId(runId: string): string | undefined {
1321
1419
  const row = this.state.connection
1322
1420
  .prepare(
1323
- `SELECT COALESCE(q.origin_session_id, b.origin_session_id) AS originSessionId
1324
- FROM runs r
1325
- LEFT JOIN workflow_follow_up_queues q ON q.run_id = r.run_id
1326
- LEFT JOIN run_bindings b ON b.run_id = r.run_id
1421
+ `SELECT b.origin_session_id AS originSessionId
1422
+ FROM runs r LEFT JOIN run_bindings b ON b.run_id = r.run_id
1327
1423
  WHERE r.run_id = ?`,
1328
1424
  )
1329
1425
  .get(runId);
@@ -1333,202 +1429,30 @@ export class WorkflowRunStore {
1333
1429
  }
1334
1430
 
1335
1431
  readFollowUpQueue(runId: string): WorkflowFollowUpQueueRecord | undefined {
1336
- const queue = this.followUpQueueRow(runId);
1337
- if (queue === undefined) return undefined;
1432
+ const originSessionId = this.originSessionId(runId);
1433
+ if (originSessionId === undefined) return undefined;
1338
1434
  const followUps = this.state.connection
1339
1435
  .prepare(`${FOLLOW_UP_ROW_SELECT} WHERE f.run_id = ? ORDER BY f.order_number`)
1340
1436
  .all(runId)
1341
1437
  .filter(isFollowUpRow)
1342
1438
  .map((row) => this.followUpRecord(row));
1343
- return {
1344
- runId,
1345
- ...(queue.originSessionId !== null ? { originSessionId: queue.originSessionId } : {}),
1346
- presentationState: assertPresentationState(queue.presentationState),
1347
- ...(queue.presentationEntryId !== null
1348
- ? { presentationEntryId: queue.presentationEntryId }
1349
- : {}),
1350
- ...(queue.presentationAssistantEntryId !== null
1351
- ? { presentationAssistantEntryId: queue.presentationAssistantEntryId }
1352
- : {}),
1353
- ...(queue.presentationReasonHash !== null
1354
- ? { presentationReason: this.readText(queue.presentationReasonHash) }
1355
- : {}),
1356
- followUps,
1357
- };
1439
+ return { runId, originSessionId, followUps };
1358
1440
  }
1359
1441
 
1360
- listPendingPresentations(targetSessionId: string): string[] {
1361
- return this.state.connection
1362
- .prepare(
1363
- `SELECT run_id AS runId FROM workflow_follow_up_queues
1364
- WHERE origin_session_id = ? AND presentation_state = 'pending'
1365
- ORDER BY created_at`,
1366
- )
1367
- .all(targetSessionId)
1368
- .flatMap((row) => (isRecord(row) && typeof row.runId === "string" ? [row.runId] : []));
1369
- }
1370
-
1371
- settleFollowUpPresentation(options: {
1372
- runId: string;
1373
- state: "settled" | "unavailable";
1374
- presentationEntryId?: string;
1375
- assistantEntryId?: string;
1376
- reason?: string;
1377
- }): WorkflowFollowUpQueueRecord {
1378
- const queue = this.requireFollowUpQueueRow(options.runId);
1379
- if (queue.presentationState === options.state) {
1380
- return this.readFollowUpQueue(options.runId) as WorkflowFollowUpQueueRecord;
1381
- }
1382
- if (queue.presentationState !== "pending") {
1383
- throw new Error(
1384
- `Workflow presentation is ${queue.presentationState}, not pending for ${options.runId}`,
1385
- );
1386
- }
1387
- if (
1388
- options.state === "settled" &&
1389
- (options.presentationEntryId === undefined || options.assistantEntryId === undefined)
1390
- ) {
1391
- throw new Error("Settled workflow presentation requires both session entry IDs");
1392
- }
1393
- if (options.state === "unavailable" && !options.reason?.trim()) {
1394
- throw new Error("Unavailable workflow presentation requires a reason");
1395
- }
1396
- try {
1397
- this.mutations.mutate(
1398
- {
1399
- resourceId: queue.resourceId,
1400
- operation: "follow-up.presentation",
1401
- actor: {
1402
- type: "session",
1403
- ...(queue.originSessionId !== null ? { id: queue.originSessionId } : {}),
1404
- },
1405
- expectedRevision: queue.revision,
1406
- },
1407
- `follow-up.presentation-${options.state}`,
1408
- ({ database, now }) => {
1409
- const reasonHash =
1410
- options.state === "unavailable"
1411
- ? database.putText(options.reason as string, now)
1412
- : null;
1413
- database.connection
1414
- .prepare(
1415
- `UPDATE workflow_follow_up_queues
1416
- SET presentation_state = ?, presentation_entry_id = ?,
1417
- presentation_assistant_entry_id = ?, presentation_reason_hash = ?,
1418
- presentation_updated_at = ?, updated_at = ?
1419
- WHERE run_id = ? AND presentation_state = 'pending'`,
1420
- )
1421
- .run(
1422
- options.state,
1423
- options.presentationEntryId ?? null,
1424
- options.assistantEntryId ?? null,
1425
- reasonHash,
1426
- now,
1427
- now,
1428
- options.runId,
1429
- );
1430
- database.connection
1431
- .prepare(
1432
- `UPDATE workflow_follow_ups SET status = 'ready', updated_at = ?
1433
- WHERE run_id = ? AND status = 'pending_presentation'`,
1434
- )
1435
- .run(now, options.runId);
1436
- recordViewerDeltas(
1437
- this.state,
1438
- options.runId,
1439
- this.viewerInspectorTargets(options.runId),
1440
- now,
1441
- );
1442
- },
1443
- { payload: { runId: options.runId, state: options.state } },
1444
- );
1445
- } catch (error) {
1446
- if (error instanceof StaleResourceError) {
1447
- const current = this.requireFollowUpQueueRow(options.runId);
1448
- if (current.presentationState === options.state) {
1449
- return this.readFollowUpQueue(options.runId) as WorkflowFollowUpQueueRecord;
1450
- }
1451
- }
1452
- throw error;
1453
- }
1454
- return this.readFollowUpQueue(options.runId) as WorkflowFollowUpQueueRecord;
1455
- }
1456
-
1457
- claimNextFollowUp(
1458
- targetSessionId: string,
1459
- ownerId: string,
1460
- leaseMs = 30_000,
1461
- ): { followUp: WorkflowFollowUpRecord; claim: LeaseClaim } | undefined {
1462
- const rows = this.state.connection
1442
+ private readFollowUpQueueRange(
1443
+ runId: string,
1444
+ range: WorkflowRunViewRange,
1445
+ ): WorkflowFollowUpQueueRecord | null {
1446
+ const originSessionId = this.originSessionId(runId);
1447
+ if (originSessionId === undefined) return null;
1448
+ const followUps = this.state.connection
1463
1449
  .prepare(
1464
- `${FOLLOW_UP_ROW_SELECT}
1465
- WHERE f.target_session_id = ? AND f.status = 'ready'
1466
- ORDER BY f.created_at, f.order_number LIMIT 16`,
1450
+ `${FOLLOW_UP_ROW_SELECT} WHERE f.run_id = ? ORDER BY f.order_number LIMIT ? OFFSET ?`,
1467
1451
  )
1468
- .all(targetSessionId)
1469
- .filter(isFollowUpRow);
1470
- for (const row of rows) {
1471
- let claim: LeaseClaim | undefined;
1472
- try {
1473
- claim = this.mutations.claim({
1474
- resourceId: row.resourceId,
1475
- ownerType: "session",
1476
- ownerId,
1477
- expectedRevision: row.revision,
1478
- leaseMs,
1479
- });
1480
- } catch (error) {
1481
- if (error instanceof StaleResourceError) continue;
1482
- throw error;
1483
- }
1484
- if (claim === undefined) continue;
1485
- const current = this.requireFollowUpRow(row.followUpId);
1486
- if (current.status !== "ready") {
1487
- this.mutations.release(claim, claim.resourceRevision);
1488
- continue;
1489
- }
1490
- return { followUp: this.followUpRecord(current), claim };
1491
- }
1492
- return undefined;
1493
- }
1494
-
1495
- markFollowUpSent(
1496
- followUpId: string,
1497
- claim: LeaseClaim,
1498
- sessionEntryId: string,
1499
- ): WorkflowFollowUpRecord {
1500
- const row = this.requireFollowUpRow(followUpId);
1501
- if (row.status === "sent") return this.followUpRecord(row);
1502
- this.mutations.mutate(
1503
- {
1504
- resourceId: row.resourceId,
1505
- operation: "follow-up.sent",
1506
- actor: { type: "session", id: claim.ownerId },
1507
- expectedRevision: claim.resourceRevision,
1508
- lease: claim,
1509
- },
1510
- "follow-up.sent",
1511
- ({ database, now }) => {
1512
- const update = database.connection
1513
- .prepare(
1514
- `UPDATE workflow_follow_ups
1515
- SET status = 'sent', session_entry_id = ?, sent_at = ?, updated_at = ?
1516
- WHERE follow_up_id = ? AND status = 'ready'`,
1517
- )
1518
- .run(sessionEntryId, now, now, followUpId);
1519
- if (update.changes !== 1) {
1520
- throw new StaleResourceError("Workflow follow-up changed before delivery completed");
1521
- }
1522
- recordViewerDeltas(this.state, row.runId, this.viewerInspectorTargets(row.runId), now);
1523
- },
1524
- { payload: { followUpId, sessionEntryId } },
1525
- );
1526
- return this.followUpRecord(this.requireFollowUpRow(followUpId));
1527
- }
1528
-
1529
- releaseFollowUpClaim(claim: LeaseClaim): void {
1530
- const revision = this.requireResourceRevision(claim.resourceId);
1531
- this.mutations.release(claim, revision);
1452
+ .all(runId, range.limit, range.start)
1453
+ .filter(isFollowUpRow)
1454
+ .map((row) => this.followUpRecord(row));
1455
+ return { runId, originSessionId, followUps };
1532
1456
  }
1533
1457
 
1534
1458
  async prepareRunResume(runId: string): Promise<LoadedWorkflowRun> {
@@ -1540,7 +1464,6 @@ export class WorkflowRunStore {
1540
1464
  this.verifySettingsProjections(runId);
1541
1465
  const revision = this.resourceRevision(runId);
1542
1466
  this.contexts.set(runId, { revision, lock: Promise.resolve() });
1543
- this.finalizeRecordingCaptures(runId, "Workflow host stopped before the run finished");
1544
1467
  this.state.transaction(() => {
1545
1468
  const row = this.requireRunRow(runId);
1546
1469
  const context = this.contextFor(runId);
@@ -1770,7 +1693,7 @@ export class WorkflowRunStore {
1770
1693
  this.state.transaction(() => {
1771
1694
  const run = this.requireRunRow(runId);
1772
1695
  const context = this.contextFor(runId);
1773
- this.assertWriteAuthority(run, context.revision);
1696
+ this.assertSessionWriteAuthority(runId);
1774
1697
  const now = Date.parse(binding.boundAt);
1775
1698
  const resourceId = resourceIdFor("session", segmentId);
1776
1699
  const bindingHash = this.state.putJson(binding, now);
@@ -1817,13 +1740,6 @@ export class WorkflowRunStore {
1817
1740
  ON CONFLICT(run_id) DO NOTHING`,
1818
1741
  )
1819
1742
  .run(runId, binding.piSessionId, now);
1820
- this.state.connection
1821
- .prepare(
1822
- `UPDATE workflow_follow_up_queues
1823
- SET origin_session_id = COALESCE(origin_session_id, ?), updated_at = ?
1824
- WHERE run_id = ?`,
1825
- )
1826
- .run(binding.piSessionId, now, runId);
1827
1743
  const revision = context.revision + 1;
1828
1744
  const at = new Date(now).toISOString();
1829
1745
  const traceEvent: WorkflowTraceEvent = {
@@ -2289,6 +2205,217 @@ export class WorkflowRunStore {
2289
2205
  };
2290
2206
  }
2291
2207
 
2208
+ readRunViewCounts(runId: string): WorkflowRunViewCounts | null {
2209
+ const row = this.readRunRow(runId);
2210
+ if (row === undefined) return null;
2211
+ const segment = this.segmentRow(segmentIdFor(runId));
2212
+ const settingsOrigin = this.logicalOriginRunId(runId);
2213
+ const scalar = (sql: string, ...params: unknown[]): number => {
2214
+ const count = this.state.connection.prepare(sql).get(...params);
2215
+ return isCountRow(count) ? count.count : 0;
2216
+ };
2217
+ return {
2218
+ steps: scalar("SELECT count(*) AS count FROM run_steps WHERE run_id = ?", runId),
2219
+ trace: scalar("SELECT count(*) AS count FROM events WHERE resource_id = ?", row.resourceId),
2220
+ sessionEntries: segment?.entryCount ?? 0,
2221
+ sessionEvents: segment?.eventCount ?? 0,
2222
+ settings: scalar(
2223
+ `SELECT count(*) AS count FROM workflow_settings
2224
+ WHERE origin_run_id = ? OR active_run_id = ?`,
2225
+ settingsOrigin,
2226
+ runId,
2227
+ ),
2228
+ followUps: scalar(
2229
+ "SELECT count(*) AS count FROM workflow_follow_ups WHERE run_id = ?",
2230
+ runId,
2231
+ ),
2232
+ updates: scalar(
2233
+ `SELECT count(*) AS count FROM (
2234
+ SELECT 1 FROM workflow_updates u
2235
+ JOIN node_attempts a ON a.attempt_id = u.attempt_id
2236
+ WHERE a.run_id = ? GROUP BY u.update_type, u.update_key
2237
+ )`,
2238
+ runId,
2239
+ ),
2240
+ };
2241
+ }
2242
+
2243
+ readRunView(runId: string, options: WorkflowRunViewReadOptions): WorkflowRunViewRead | null {
2244
+ const row = this.readRunRow(runId);
2245
+ if (row === undefined) return null;
2246
+ const snapshot = this.readDefinition(row.definitionHash);
2247
+ const steps = this.readStepRange(runId, options.steps);
2248
+ const visibleUpdates = this.readCurrentUpdates(runId, options.updates);
2249
+ const state = this.buildRunState(
2250
+ row,
2251
+ snapshot,
2252
+ this.readLatestSteps(runId),
2253
+ steps,
2254
+ visibleUpdates,
2255
+ );
2256
+ const segment = this.segmentRow(segmentIdFor(runId));
2257
+ const loadedSegment =
2258
+ segment === undefined
2259
+ ? emptySegment()
2260
+ : this.loadSegment(segment, state, {
2261
+ entries: options.sessionEntries,
2262
+ events: options.sessionEvents,
2263
+ });
2264
+ return {
2265
+ runId,
2266
+ state,
2267
+ snapshot,
2268
+ traceEvents: this.traceEvents(row, options.trace),
2269
+ sessionBinding: loadedSegment.binding,
2270
+ sessionEntries: loadedSegment.entries,
2271
+ sessionEvents: loadedSegment.events,
2272
+ sessionCapture: loadedSegment.capture,
2273
+ sessionIntegrity: loadedSegment.integrity,
2274
+ settingsScopes: this.readSettingsScopeRange(runId, options.settings),
2275
+ followUpQueue: this.readFollowUpQueueRange(runId, options.followUps),
2276
+ graphSteps: this.readLatestSteps(runId, options.graphCursor),
2277
+ takenTransitions: this.readTakenTransitions(runId, options.graphCursor),
2278
+ };
2279
+ }
2280
+
2281
+ traceCursorForStep(runId: string, stepCursor: number, traceTotal: number): number {
2282
+ const step = this.state.connection
2283
+ .prepare(
2284
+ `SELECT a.attempt_id AS attemptId, a.node_id AS nodeId
2285
+ FROM run_steps s JOIN node_attempts a ON a.attempt_id = s.attempt_id
2286
+ WHERE s.run_id = ? AND s.step_index = ?`,
2287
+ )
2288
+ .get(runId, stepCursor);
2289
+ if (!isRecord(step) || typeof step.attemptId !== "string" || typeof step.nodeId !== "string") {
2290
+ return Math.min(stepCursor, Math.max(0, traceTotal - 1));
2291
+ }
2292
+ const run = this.readRunRow(runId);
2293
+ if (run === undefined) return 0;
2294
+ const exactAttempt = this.state.connection
2295
+ .prepare(
2296
+ `SELECT e.resource_revision AS revision FROM events e
2297
+ JOIN blobs b ON b.blob_hash = e.payload_hash
2298
+ WHERE e.resource_id = ?
2299
+ AND json_extract(CAST(b.content AS TEXT), '$.attemptId') = ?
2300
+ ORDER BY e.resource_revision DESC LIMIT 1`,
2301
+ )
2302
+ .get(run.resourceId, step.attemptId);
2303
+ const matched = isRevisionRow(exactAttempt)
2304
+ ? exactAttempt
2305
+ : this.state.connection
2306
+ .prepare(
2307
+ `SELECT e.resource_revision AS revision FROM events e
2308
+ JOIN blobs b ON b.blob_hash = e.payload_hash
2309
+ WHERE e.resource_id = ?
2310
+ AND json_extract(CAST(b.content AS TEXT), '$.nodeId') = ?
2311
+ ORDER BY e.resource_revision DESC LIMIT 1`,
2312
+ )
2313
+ .get(run.resourceId, step.nodeId);
2314
+ if (!isRevisionRow(matched)) return Math.min(stepCursor, Math.max(0, traceTotal - 1));
2315
+ const index = this.state.connection
2316
+ .prepare(
2317
+ `SELECT count(*) - 1 AS count FROM events
2318
+ WHERE resource_id = ? AND resource_revision <= ?`,
2319
+ )
2320
+ .get(run.resourceId, matched.revision);
2321
+ return isCountRow(index) ? Math.max(0, index.count) : 0;
2322
+ }
2323
+
2324
+ persistViewContent(runId: string, content: Buffer, mediaType: string): string {
2325
+ if (this.readRunRow(runId) === undefined) throw new Error(`Workflow run not found: ${runId}`);
2326
+ return this.state.transaction(() => {
2327
+ const hash = createHash("sha256").update(content).digest();
2328
+ this.state.connection
2329
+ .prepare(
2330
+ `INSERT INTO run_view_content(
2331
+ run_id, content_hash, media_type, byte_length, content, created_at
2332
+ ) VALUES (?, ?, ?, ?, ?, ?)
2333
+ ON CONFLICT(run_id, content_hash, media_type) DO NOTHING`,
2334
+ )
2335
+ .run(runId, hash, mediaType, content.byteLength, content, Date.now());
2336
+ const stored = this.state.connection
2337
+ .prepare(
2338
+ `SELECT media_type AS mediaType, byte_length AS byteLength, content
2339
+ FROM run_view_content
2340
+ WHERE run_id = ? AND content_hash = ? AND media_type = ?`,
2341
+ )
2342
+ .get(runId, hash, mediaType);
2343
+ if (
2344
+ !isRecord(stored) ||
2345
+ stored.mediaType !== mediaType ||
2346
+ stored.byteLength !== content.byteLength ||
2347
+ !Buffer.isBuffer(stored.content) ||
2348
+ !stored.content.equals(content)
2349
+ ) {
2350
+ throw new Error("Run-scoped view content conflict");
2351
+ }
2352
+ return hash.toString("hex");
2353
+ });
2354
+ }
2355
+
2356
+ readContentBlob(
2357
+ runId: string,
2358
+ digest: string,
2359
+ mediaType: string,
2360
+ ): { mediaType: string; content: Buffer } | undefined {
2361
+ if (!/^[0-9a-f]{64}$/u.test(digest)) return undefined;
2362
+ const stored = this.state.connection
2363
+ .prepare(
2364
+ `SELECT media_type AS mediaType, byte_length AS byteLength, content
2365
+ FROM run_view_content
2366
+ WHERE run_id = ? AND content_hash = ? AND media_type = ?`,
2367
+ )
2368
+ .get(runId, Buffer.from(digest, "hex"), mediaType);
2369
+ if (
2370
+ !isRecord(stored) ||
2371
+ stored.mediaType !== mediaType ||
2372
+ typeof stored.byteLength !== "number" ||
2373
+ !Buffer.isBuffer(stored.content) ||
2374
+ stored.content.byteLength !== stored.byteLength
2375
+ ) {
2376
+ return undefined;
2377
+ }
2378
+ return { mediaType, content: stored.content };
2379
+ }
2380
+
2381
+ readDisplayState(runId: string): WorkflowRunDisplayState | null {
2382
+ const row = this.readRunRow(runId);
2383
+ if (row === undefined) return null;
2384
+ return {
2385
+ status: row.status as WorkflowRunState["status"],
2386
+ paused: row.paused === 1,
2387
+ error: row.errorHash === null ? null : this.readText(row.errorHash),
2388
+ };
2389
+ }
2390
+
2391
+ readSessionReplayCheckpoint(runId: string, throughSequence: number): JsonValue | null {
2392
+ if (!Number.isSafeInteger(throughSequence) || throughSequence < 0) {
2393
+ throw new Error("Session replay checkpoint sequence must be a non-negative integer");
2394
+ }
2395
+ if (throughSequence === 0) return null;
2396
+ const row = this.state.connection
2397
+ .prepare(
2398
+ `SELECT event_seq AS eventSeq, state_hash AS stateHash
2399
+ FROM viewer_session_checkpoints
2400
+ WHERE run_id = ? AND event_seq <= ? ORDER BY event_seq DESC LIMIT 1`,
2401
+ )
2402
+ .get(runId, throughSequence);
2403
+ let checkpoint = reduceSessionEvents([], [], 0);
2404
+ if (isViewerSessionCheckpointRow(row)) {
2405
+ const saved = this.state.readJson(row.stateHash);
2406
+ if (!isTemporalSessionState(saved) || saved.throughSeq !== row.eventSeq) {
2407
+ throw new Error(`Viewer session checkpoint is invalid for ${runId}`);
2408
+ }
2409
+ checkpoint = saved;
2410
+ }
2411
+ if (checkpoint.throughSeq < throughSequence) {
2412
+ const events = this.sessionEventRecords(runId, checkpoint.throughSeq, throughSequence + 1);
2413
+ checkpoint = reduceSessionEventsFromCheckpoint([], events, throughSequence, checkpoint);
2414
+ checkpoint = boundedTemporalCheckpoint(checkpoint);
2415
+ }
2416
+ return parseJson(canonicalJson(checkpoint));
2417
+ }
2418
+
2292
2419
  listRuns(options: ReadWorkflowRunOptions = {}): LoadedWorkflowRun[] {
2293
2420
  const rows = this.state.connection
2294
2421
  .prepare("SELECT run_id AS runId FROM runs ORDER BY created_at DESC, run_id DESC")
@@ -2338,6 +2465,7 @@ export class WorkflowRunStore {
2338
2465
 
2339
2466
  private assertSessionWriteAuthority(runId: string): void {
2340
2467
  const run = this.requireRunRow(runId);
2468
+ if (this.allowHostSessionRecording) return;
2341
2469
  this.assertWriteAuthority(run, this.contextFor(runId).revision);
2342
2470
  }
2343
2471
 
@@ -2561,10 +2689,8 @@ export class WorkflowRunStore {
2561
2689
  order: row.orderNumber,
2562
2690
  state: row.status,
2563
2691
  source: row.sourceType,
2564
- sessionEntryId: row.sessionEntryId,
2565
2692
  }));
2566
2693
  const followUpStart = Math.max(0, followUps.length - VIEWER_PAGE_SIZE);
2567
- const queue = this.requireFollowUpQueueRow(runId);
2568
2694
  return [
2569
2695
  {
2570
2696
  targetType: "inspector",
@@ -2586,12 +2712,7 @@ export class WorkflowRunStore {
2586
2712
  {
2587
2713
  op: "replace",
2588
2714
  path: "/followUpQueue",
2589
- value: parseJson(
2590
- canonicalJson({
2591
- presentationState: queue.presentationState,
2592
- items: followUps.slice(followUpStart),
2593
- }),
2594
- ),
2715
+ value: parseJson(canonicalJson({ items: followUps.slice(followUpStart) })),
2595
2716
  },
2596
2717
  { op: "replace", path: "/followUpStart", value: followUpStart },
2597
2718
  { op: "replace", path: "/followUpTotal", value: followUps.length },
@@ -2605,25 +2726,7 @@ export class WorkflowRunStore {
2605
2726
  initialSettings: InitialWorkflowSettingsScope[],
2606
2727
  now: number,
2607
2728
  ): void {
2608
- const childQueueResourceId = this.ensureFollowUpQueue(state.runId, now);
2609
2729
  if (state.parentRunId !== undefined) {
2610
- const parentQueue = this.followUpQueueRow(state.parentRunId);
2611
- if (parentQueue !== undefined) {
2612
- this.state.connection
2613
- .prepare(
2614
- `UPDATE workflow_follow_up_queues
2615
- SET origin_session_id = COALESCE(origin_session_id, ?), updated_at = ?
2616
- WHERE run_id = ?`,
2617
- )
2618
- .run(parentQueue.originSessionId, now, state.runId);
2619
- this.state.connection
2620
- .prepare(
2621
- `UPDATE workflow_follow_ups
2622
- SET run_id = ?, queue_resource_id = ?, updated_at = ?
2623
- WHERE run_id = ? AND status = 'queued'`,
2624
- )
2625
- .run(state.runId, childQueueResourceId, now, state.parentRunId);
2626
- }
2627
2730
  this.state.connection
2628
2731
  .prepare(
2629
2732
  `UPDATE workflow_settings SET active_run_id = ?, updated_at = ? WHERE active_run_id = ?`,
@@ -2663,19 +2766,6 @@ export class WorkflowRunStore {
2663
2766
  }
2664
2767
  }
2665
2768
 
2666
- private ensureFollowUpQueue(runId: string, now: number): string {
2667
- const resourceId = this.mutations.ensureResource("follow_up_queue", runId, now);
2668
- this.state.connection
2669
- .prepare(
2670
- `INSERT INTO workflow_follow_up_queues(
2671
- run_id, resource_id, created_at, updated_at
2672
- ) VALUES (?, ?, ?, ?)
2673
- ON CONFLICT(run_id) DO NOTHING`,
2674
- )
2675
- .run(runId, resourceId, now, now);
2676
- return resourceId;
2677
- }
2678
-
2679
2769
  private logicalOriginRunId(runId: string): string {
2680
2770
  let current = runId;
2681
2771
  for (let depth = 0; depth < 100; depth += 1) {
@@ -2833,30 +2923,6 @@ export class WorkflowRunStore {
2833
2923
  }
2834
2924
  }
2835
2925
 
2836
- private followUpQueueRow(runId: string): FollowUpQueueRow | undefined {
2837
- const row = this.state.connection
2838
- .prepare(
2839
- `SELECT q.run_id AS runId, q.resource_id AS resourceId,
2840
- q.origin_session_id AS originSessionId,
2841
- q.presentation_state AS presentationState,
2842
- q.presentation_entry_id AS presentationEntryId,
2843
- q.presentation_assistant_entry_id AS presentationAssistantEntryId,
2844
- q.presentation_reason_hash AS presentationReasonHash,
2845
- r.revision
2846
- FROM workflow_follow_up_queues q
2847
- JOIN resources r ON r.resource_id = q.resource_id
2848
- WHERE q.run_id = ?`,
2849
- )
2850
- .get(runId);
2851
- return isFollowUpQueueRow(row) ? row : undefined;
2852
- }
2853
-
2854
- private requireFollowUpQueueRow(runId: string): FollowUpQueueRow {
2855
- const row = this.followUpQueueRow(runId);
2856
- if (row === undefined) throw new Error(`Workflow follow-up queue not found: ${runId}`);
2857
- return row;
2858
- }
2859
-
2860
2926
  private followUpRowByRequest(runId: string, requestId: string): FollowUpRow | undefined {
2861
2927
  const row = this.state.connection
2862
2928
  .prepare(`${FOLLOW_UP_ROW_SELECT} WHERE f.run_id = ? AND f.request_id = ?`)
@@ -2891,11 +2957,9 @@ export class WorkflowRunStore {
2891
2957
  source: row.sourceType,
2892
2958
  prompt: this.readText(row.promptHash),
2893
2959
  state: assertFollowUpState(row.status),
2894
- ...(row.sessionEntryId !== null ? { sessionEntryId: row.sessionEntryId } : {}),
2895
2960
  ...(row.reasonHash !== null ? { reason: this.readText(row.reasonHash) } : {}),
2896
2961
  createdAt: new Date(row.createdAt).toISOString(),
2897
2962
  updatedAt: new Date(row.updatedAt).toISOString(),
2898
- ...(row.sentAt !== null ? { sentAt: new Date(row.sentAt).toISOString() } : {}),
2899
2963
  };
2900
2964
  }
2901
2965
 
@@ -2929,13 +2993,6 @@ export class WorkflowRunStore {
2929
2993
  throw new Error("Workflow follow-up can be removed only by its source or a verified human");
2930
2994
  }
2931
2995
 
2932
- private hasLiveLease(resourceId: string): boolean {
2933
- const row = this.state.connection
2934
- .prepare("SELECT expires_at AS expiresAt FROM leases WHERE resource_id = ?")
2935
- .get(resourceId);
2936
- return isLeaseExpiryRow(row) && row.expiresAt !== null && row.expiresAt > Date.now();
2937
- }
2938
-
2939
2996
  private nextFollowUpOrder(runId: string): number {
2940
2997
  const row = this.state.connection
2941
2998
  .prepare(
@@ -2968,55 +3025,51 @@ export class WorkflowRunStore {
2968
3025
 
2969
3026
  private transitionFollowUpsForRunState(
2970
3027
  state: WorkflowRunState,
2971
- event: WorkflowTraceEventDraft,
3028
+ _event: WorkflowTraceEventDraft,
2972
3029
  now: number,
2973
3030
  ): void {
2974
- if (state.status === "running" || state.status === "waiting") return;
2975
- const queue = this.followUpQueueRow(state.runId);
2976
- if (queue === undefined || queue.presentationState !== "none") return;
2977
- let presentationState: WorkflowPresentationState = "not-needed";
2978
- let followUpState: WorkflowFollowUpState = "cancelled";
2979
- let reasonHash: Buffer | null = null;
2980
- if (state.status === "completed") {
2981
- const required = event.payload.presentationRequired === true;
2982
- presentationState = required ? "pending" : "not-needed";
2983
- followUpState = required ? "pending_presentation" : "ready";
2984
- } else {
2985
- reasonHash = this.state.putText(
2986
- state.error ?? `Workflow ended with status ${state.status}`,
2987
- now,
2988
- );
3031
+ if (state.status === "running" || state.status === "waiting" || state.status === "completed") {
3032
+ return;
2989
3033
  }
2990
- this.state.connection
2991
- .prepare(
2992
- `UPDATE workflow_follow_up_queues
2993
- SET presentation_state = ?, presentation_updated_at = ?, updated_at = ?
2994
- WHERE run_id = ? AND presentation_state = 'none'`,
2995
- )
2996
- .run(presentationState, now, now, state.runId);
2997
- this.state.connection
3034
+ const rows = this.state.connection
2998
3035
  .prepare(
2999
- `UPDATE workflow_follow_ups
3000
- SET status = ?, reason_hash = ?, updated_at = ?
3001
- WHERE run_id = ? AND status IN ('queued', 'pending_presentation', 'ready')`,
3036
+ `${FOLLOW_UP_ROW_SELECT}
3037
+ WHERE f.run_id IN (
3038
+ WITH RECURSIVE ancestors(run_id, parent_run_id) AS (
3039
+ SELECT run_id, parent_run_id FROM runs WHERE run_id = ?
3040
+ UNION ALL
3041
+ SELECT parent.run_id, parent.parent_run_id
3042
+ FROM runs parent JOIN ancestors ON ancestors.parent_run_id = parent.run_id
3043
+ )
3044
+ SELECT run_id FROM ancestors
3045
+ ) AND f.status = 'queued'`,
3002
3046
  )
3003
- .run(followUpState, reasonHash, now, state.runId);
3004
- const revision = this.requireResourceRevision(queue.resourceId);
3005
- this.bumpResource(queue.resourceId, revision, now);
3006
- const payloadHash = this.state.putJson(
3007
- { runId: state.runId, state: presentationState, followUps: followUpState },
3008
- now,
3009
- );
3010
- insertGenericEvent(
3011
- this.state,
3012
- queue.resourceId,
3013
- revision + 1,
3014
- "follow-up.run-finished",
3015
- "system",
3016
- null,
3017
- payloadHash,
3018
- now,
3019
- );
3047
+ .all(state.runId)
3048
+ .filter(isFollowUpRow);
3049
+ for (const row of rows) {
3050
+ const reason = state.error ?? `Workflow ended with status ${state.status}`;
3051
+ const reasonHash = this.state.putText(reason, now);
3052
+ const update = this.state.connection
3053
+ .prepare(
3054
+ `UPDATE workflow_follow_ups
3055
+ SET status = 'cancelled', reason_hash = ?, updated_at = ?
3056
+ WHERE follow_up_id = ? AND status = 'queued'`,
3057
+ )
3058
+ .run(reasonHash, now, row.followUpId);
3059
+ if (update.changes !== 1) continue;
3060
+ this.workflowMessages.cancelPendingForSource(row.followUpId, "followUp", now);
3061
+ this.bumpResource(row.resourceId, row.revision, now);
3062
+ insertGenericEvent(
3063
+ this.state,
3064
+ row.resourceId,
3065
+ row.revision + 1,
3066
+ "follow-up.cancelled",
3067
+ "system",
3068
+ null,
3069
+ this.state.putJson({ runId: state.runId, status: state.status, reason }, now),
3070
+ now,
3071
+ );
3072
+ }
3020
3073
  }
3021
3074
 
3022
3075
  private syncNodeAttempts(
@@ -3213,7 +3266,7 @@ export class WorkflowRunStore {
3213
3266
  JOIN session_segments s ON s.segment_id = e.segment_id
3214
3267
  JOIN blobs b ON b.blob_hash = e.entry_hash
3215
3268
  WHERE s.run_id = ?
3216
- AND json_extract(CAST(b.content AS TEXT), '$.customType') = 'pi-workflows-agent-step'
3269
+ AND json_extract(CAST(b.content AS TEXT), '$.customType') = 'pi-workflows-step'
3217
3270
  AND json_extract(CAST(b.content AS TEXT), '$.details.contract.attemptId') = ?
3218
3271
  ORDER BY e.recorded_at DESC LIMIT 1`,
3219
3272
  )
@@ -3407,13 +3460,37 @@ export class WorkflowRunStore {
3407
3460
 
3408
3461
  private readRunState(row: RunRow, snapshot: WorkflowDefinitionSnapshot): WorkflowRunState {
3409
3462
  const steps = this.readSteps(row.runId);
3463
+ return this.buildRunState(
3464
+ row,
3465
+ snapshot,
3466
+ steps,
3467
+ steps,
3468
+ this.readUpdates(row.runId).updates ?? [],
3469
+ );
3470
+ }
3471
+
3472
+ private buildRunState(
3473
+ row: RunRow,
3474
+ snapshot: WorkflowDefinitionSnapshot,
3475
+ projectionSteps: WorkflowStepRecord[],
3476
+ visibleSteps: WorkflowStepRecord[],
3477
+ visibleUpdates: WorkflowUpdateRecord[],
3478
+ ): WorkflowRunState {
3410
3479
  const sources = this.readRunSources(row.runId, snapshot);
3411
3480
  const carriedStepCount = this.carriedStepCount(row.runId);
3412
- const activeAttempt = row.status === "running" ? this.readActiveAttempt(row.runId) : undefined;
3481
+ const activeAttempt =
3482
+ row.status === "running" || row.status === "waiting"
3483
+ ? this.readActiveAttempt(row.runId)
3484
+ : undefined;
3485
+ const runningAttempt = row.status === "running" ? activeAttempt : undefined;
3486
+ const waitingNode =
3487
+ row.status === "waiting"
3488
+ ? (activeAttempt?.nodeId ?? projectionSteps.at(-1)?.nodeId)
3489
+ : undefined;
3413
3490
  const humanDecision = this.readHumanDecisionReceipt(row.runId);
3414
3491
  const outputs: Record<string, unknown> = {};
3415
3492
  const results: Record<string, WorkflowNodeResult> = {};
3416
- for (const step of steps) {
3493
+ for (const step of projectionSteps) {
3417
3494
  const result = resultForStep(step);
3418
3495
  results[step.nodeId] = result;
3419
3496
  if (step.outcome === "ok") outputs[step.nodeId] = step.output;
@@ -3449,9 +3526,9 @@ export class WorkflowRunStore {
3449
3526
  input: this.state.readJson(row.inputHash),
3450
3527
  outputs,
3451
3528
  results,
3452
- steps,
3453
- ...this.readUpdates(row.runId),
3454
- ...(activeAttempt === undefined ? {} : { currentNode: activeAttempt.nodeId }),
3529
+ steps: visibleSteps,
3530
+ ...(visibleUpdates.length === 0 ? {} : { updates: visibleUpdates }),
3531
+ ...(runningAttempt === undefined ? {} : { currentNode: runningAttempt.nodeId }),
3455
3532
  ...(activeAttempt === undefined ? {} : { currentAttemptId: activeAttempt.attemptId }),
3456
3533
  ...(activeAttempt?.startedAt === null || activeAttempt === undefined
3457
3534
  ? {}
@@ -3472,9 +3549,7 @@ export class WorkflowRunStore {
3472
3549
  ...(row.statusDetail === null ? {} : { statusDetail: row.statusDetail }),
3473
3550
  ...(humanDecision === undefined ? {} : { humanDecision }),
3474
3551
  ...(row.paused === 0 ? {} : { paused: true }),
3475
- ...(row.status === "waiting" && steps.at(-1) !== undefined
3476
- ? { waitingOn: steps.at(-1)?.nodeId as string }
3477
- : {}),
3552
+ ...(waitingNode === undefined ? {} : { waitingOn: waitingNode }),
3478
3553
  ...(row.finalOutputHash === null
3479
3554
  ? {}
3480
3555
  : { finalOutput: this.state.readJson(row.finalOutputHash) }),
@@ -3483,42 +3558,65 @@ export class WorkflowRunStore {
3483
3558
  }
3484
3559
 
3485
3560
  private readSteps(runId: string): WorkflowStepRecord[] {
3561
+ const rows = this.state.connection
3562
+ .prepare(`${STEP_ROW_SELECT} WHERE s.run_id = ? ORDER BY s.step_index`)
3563
+ .all(runId)
3564
+ .filter(isStepRow);
3565
+ return this.mapStepRows(runId, rows, 0);
3566
+ }
3567
+
3568
+ private readStepRange(runId: string, range: WorkflowRunViewRange): WorkflowStepRecord[] {
3569
+ const rows = this.state.connection
3570
+ .prepare(`${STEP_ROW_SELECT} WHERE s.run_id = ? ORDER BY s.step_index LIMIT ? OFFSET ?`)
3571
+ .all(runId, range.limit, range.start)
3572
+ .filter(isStepRow);
3573
+ return this.mapStepRows(runId, rows, range.start);
3574
+ }
3575
+
3576
+ private readLatestSteps(runId: string, through?: number): WorkflowStepRecord[] {
3577
+ const throughClause = through === undefined ? "" : " AND s2.step_index <= ?";
3578
+ const params = through === undefined ? [runId, runId] : [runId, runId, through];
3486
3579
  const rows = this.state.connection
3487
3580
  .prepare(
3488
- `SELECT s.step_index AS stepIndex, a.attempt_id AS attemptId, a.node_id AS nodeId,
3489
- a.node_type AS nodeType, a.status,
3490
- a.prompt_hash AS promptHash, a.output_hash AS outputHash,
3491
- s.output_override_hash AS outputOverrideHash,
3492
- a.receipt_hash AS receiptHash, a.error_hash AS errorHash,
3493
- prompt_entry.entry_hash AS promptEntryHash,
3494
- response_entry.entry_hash AS responseEntryHash,
3495
- first_link.entry_id AS firstEntryId, last_link.entry_id AS lastEntryId,
3496
- a.settings_scope_id AS settingsScopeId,
3497
- a.settings_change_number AS settingsChangeNumber,
3498
- a.settings_hash AS settingsHash,
3499
- a.started_at AS startedAt, a.finished_at AS finishedAt
3500
- FROM run_steps s
3501
- JOIN node_attempts a ON a.attempt_id = s.attempt_id
3502
- LEFT JOIN attempt_entries prompt_link
3503
- ON prompt_link.attempt_id = a.attempt_id AND prompt_link.role = 'prompt'
3504
- LEFT JOIN session_entries prompt_entry
3505
- ON prompt_entry.segment_id = prompt_link.segment_id
3506
- AND prompt_entry.entry_id = prompt_link.entry_id
3507
- LEFT JOIN attempt_entries response_link
3508
- ON response_link.attempt_id = a.attempt_id AND response_link.role = 'response'
3509
- LEFT JOIN session_entries response_entry
3510
- ON response_entry.segment_id = response_link.segment_id
3511
- AND response_entry.entry_id = response_link.entry_id
3512
- LEFT JOIN attempt_entries first_link
3513
- ON first_link.attempt_id = a.attempt_id AND first_link.role = 'first'
3514
- LEFT JOIN attempt_entries last_link
3515
- ON last_link.attempt_id = a.attempt_id AND last_link.role = 'last'
3516
- WHERE s.run_id = ? ORDER BY s.step_index`,
3581
+ `${STEP_ROW_SELECT}
3582
+ WHERE s.run_id = ? AND s.step_index IN (
3583
+ SELECT max(s2.step_index) FROM run_steps s2
3584
+ JOIN node_attempts a2 ON a2.attempt_id = s2.attempt_id
3585
+ WHERE s2.run_id = ?${throughClause} GROUP BY a2.node_id
3586
+ ) ORDER BY s.step_index`,
3517
3587
  )
3518
- .all(runId)
3588
+ .all(...params)
3519
3589
  .filter(isStepRow);
3590
+ return this.mapStepRows(runId, rows);
3591
+ }
3592
+
3593
+ private readTakenTransitions(runId: string, through: number): string[] {
3594
+ const rows = this.state.connection
3595
+ .prepare(
3596
+ `WITH ordered AS (
3597
+ SELECT s.step_index, a.node_id AS nodeId,
3598
+ lag(a.node_id) OVER (ORDER BY s.step_index) AS previousNodeId
3599
+ FROM run_steps s JOIN node_attempts a ON a.attempt_id = s.attempt_id
3600
+ WHERE s.run_id = ? AND s.step_index <= ?
3601
+ )
3602
+ SELECT DISTINCT previousNodeId, nodeId FROM ordered
3603
+ WHERE previousNodeId IS NOT NULL ORDER BY previousNodeId, nodeId`,
3604
+ )
3605
+ .all(runId, through);
3606
+ return rows.flatMap((row) =>
3607
+ isRecord(row) && typeof row.previousNodeId === "string" && typeof row.nodeId === "string"
3608
+ ? [`${row.previousNodeId}->${row.nodeId}`]
3609
+ : [],
3610
+ );
3611
+ }
3612
+
3613
+ private mapStepRows(
3614
+ runId: string,
3615
+ rows: StepRow[],
3616
+ expectedStart?: number,
3617
+ ): WorkflowStepRecord[] {
3520
3618
  return rows.map((row, index) => {
3521
- if (row.stepIndex !== index)
3619
+ if (expectedStart !== undefined && row.stepIndex !== expectedStart + index)
3522
3620
  throw new Error(`Workflow run step sequence has a gap: ${runId}`);
3523
3621
  const receipt =
3524
3622
  row.receiptHash === null ? {} : this.readJsonAs<StoredAttemptReceipt>(row.receiptHash);
@@ -3706,6 +3804,41 @@ export class WorkflowRunStore {
3706
3804
  return updates === undefined ? {} : { updates };
3707
3805
  }
3708
3806
 
3807
+ private readCurrentUpdates(runId: string, range?: WorkflowRunViewRange): WorkflowUpdateRecord[] {
3808
+ const rangeClause = range === undefined ? "" : " LIMIT ? OFFSET ?";
3809
+ const params = range === undefined ? [runId] : [runId, range.limit, range.start];
3810
+ const rows = this.state.connection
3811
+ .prepare(
3812
+ `WITH ranked AS (
3813
+ SELECT u.update_id AS updateId, u.run_revision AS runRevision,
3814
+ a.node_id AS nodeId, u.attempt_id AS attemptId,
3815
+ u.update_type AS updateType, u.update_key AS updateKey,
3816
+ u.data_hash AS dataHash, u.recorded_at AS recordedAt,
3817
+ row_number() OVER (
3818
+ PARTITION BY u.update_type, u.update_key ORDER BY u.run_revision DESC
3819
+ ) AS rank
3820
+ FROM workflow_updates u
3821
+ JOIN node_attempts a ON a.attempt_id = u.attempt_id
3822
+ WHERE a.run_id = ?
3823
+ )
3824
+ SELECT updateId, runRevision, nodeId, attemptId, updateType, updateKey, dataHash, recordedAt
3825
+ FROM ranked WHERE rank = 1 ORDER BY runRevision${rangeClause}`,
3826
+ )
3827
+ .all(...params)
3828
+ .filter(isUpdateRow);
3829
+ return rows.map((row) => ({
3830
+ updateId: row.updateId,
3831
+ seq: row.runRevision,
3832
+ at: new Date(row.recordedAt).toISOString(),
3833
+ runId,
3834
+ nodeId: row.nodeId,
3835
+ attemptId: row.attemptId,
3836
+ type: row.updateType,
3837
+ key: row.updateKey,
3838
+ data: this.readJsonAs<Record<string, unknown>>(row.dataHash),
3839
+ }));
3840
+ }
3841
+
3709
3842
  private readJsonAs<T>(hash: Buffer): T {
3710
3843
  return this.state.readJson(hash) as T;
3711
3844
  }
@@ -3726,14 +3859,17 @@ export class WorkflowRunStore {
3726
3859
  return value as WorkflowDefinitionSnapshot;
3727
3860
  }
3728
3861
 
3729
- private traceEvents(run: RunRow): WorkflowTraceEvent[] {
3862
+ private traceEvents(run: RunRow, range?: WorkflowRunViewRange): WorkflowTraceEvent[] {
3863
+ const rangeClause = range === undefined ? "" : " LIMIT ? OFFSET ?";
3864
+ const params =
3865
+ range === undefined ? [run.resourceId] : [run.resourceId, range.limit, range.start];
3730
3866
  const rows = this.state.connection
3731
3867
  .prepare(
3732
3868
  `SELECT resource_revision AS resourceRevision, event_type AS eventType,
3733
3869
  payload_hash AS payloadHash, recorded_at AS recordedAt
3734
- FROM events WHERE resource_id = ? ORDER BY resource_revision`,
3870
+ FROM events WHERE resource_id = ? ORDER BY resource_revision${rangeClause}`,
3735
3871
  )
3736
- .all(run.resourceId);
3872
+ .all(...params);
3737
3873
  const events: WorkflowTraceEvent[] = [];
3738
3874
  for (const row of rows) {
3739
3875
  /* istanbul ignore if -- exact schema and internal query shape */
@@ -3787,7 +3923,11 @@ export class WorkflowRunStore {
3787
3923
  return row;
3788
3924
  }
3789
3925
 
3790
- private loadSegment(segment: SegmentRow, runState: WorkflowRunState): SessionCaptureSegment {
3926
+ private loadSegment(
3927
+ segment: SegmentRow,
3928
+ runState: WorkflowRunState,
3929
+ ranges?: { entries: WorkflowRunViewRange; events: WorkflowRunViewRange },
3930
+ ): SessionCaptureSegment {
3791
3931
  const binding =
3792
3932
  segment.bindingHash === null
3793
3933
  ? null
@@ -3795,9 +3935,15 @@ export class WorkflowRunStore {
3795
3935
  const entries = this.state.connection
3796
3936
  .prepare(
3797
3937
  `SELECT entry_seq AS entrySeq, entry_hash AS entryHash, recorded_at AS recordedAt
3798
- FROM session_entries WHERE segment_id = ? ORDER BY entry_seq`,
3938
+ FROM session_entries WHERE segment_id = ? ORDER BY entry_seq${
3939
+ ranges === undefined ? "" : " LIMIT ? OFFSET ?"
3940
+ }`,
3941
+ )
3942
+ .all(
3943
+ ...(ranges === undefined
3944
+ ? [segment.segmentId]
3945
+ : [segment.segmentId, ranges.entries.limit, ranges.entries.start]),
3799
3946
  )
3800
- .all(segment.segmentId)
3801
3947
  .filter(isSessionEntryRow)
3802
3948
  .map((row) => ({
3803
3949
  seq: row.entrySeq,
@@ -3810,9 +3956,15 @@ export class WorkflowRunStore {
3810
3956
  attempt_id AS attemptId, turn_id AS turnId, message_id AS messageId,
3811
3957
  tool_call_id AS toolCallId, payload_hash AS payloadHash,
3812
3958
  recorded_at AS recordedAt
3813
- FROM session_events WHERE segment_id = ? ORDER BY event_seq`,
3959
+ FROM session_events WHERE segment_id = ? ORDER BY event_seq${
3960
+ ranges === undefined ? "" : " LIMIT ? OFFSET ?"
3961
+ }`,
3962
+ )
3963
+ .all(
3964
+ ...(ranges === undefined
3965
+ ? [segment.segmentId]
3966
+ : [segment.segmentId, ranges.events.limit, ranges.events.start]),
3814
3967
  )
3815
- .all(segment.segmentId)
3816
3968
  .filter(isSessionEventRow)
3817
3969
  .map((row) => ({
3818
3970
  seq: row.eventSeq,
@@ -3839,7 +3991,17 @@ export class WorkflowRunStore {
3839
3991
  ...(failure === undefined ? {} : { failure }),
3840
3992
  };
3841
3993
  const diagnostics: string[] = [];
3842
- if (entries.length !== segment.entryCount || events.length !== segment.eventCount) {
3994
+ const durableCounts =
3995
+ ranges === undefined
3996
+ ? { entries: entries.length, events: events.length }
3997
+ : {
3998
+ entries: sessionRecordCount(this.state, "session_entries", segment.segmentId),
3999
+ events: sessionRecordCount(this.state, "session_events", segment.segmentId),
4000
+ };
4001
+ if (
4002
+ durableCounts.entries !== segment.entryCount ||
4003
+ durableCounts.events !== segment.eventCount
4004
+ ) {
3843
4005
  diagnostics.push("session capture counts do not match durable rows");
3844
4006
  }
3845
4007
  if (runState.status !== "running" && segment.status === "recording") {
@@ -4219,6 +4381,17 @@ function workflowSourceFromRow(row: RunSourceRow): WorkflowSource {
4219
4381
  : { kind: "file", path: row.sourceRef, hash: row.sourceRevision };
4220
4382
  }
4221
4383
 
4384
+ function sessionRecordCount(
4385
+ state: StateDatabase,
4386
+ table: "session_entries" | "session_events",
4387
+ segmentId: string,
4388
+ ): number {
4389
+ const row = state.connection
4390
+ .prepare(`SELECT count(*) AS count FROM ${table} WHERE segment_id = ?`)
4391
+ .get(segmentId);
4392
+ return isCountRow(row) ? row.count : 0;
4393
+ }
4394
+
4222
4395
  function segmentIdFor(runId: string, attemptId?: string): string {
4223
4396
  return `segment-${createHash("sha256")
4224
4397
  .update(`${runId}\0${attemptId ?? ""}`)
@@ -4421,32 +4594,12 @@ function assertActorType(value: string): ActorType {
4421
4594
  }
4422
4595
 
4423
4596
  function assertFollowUpState(value: string): WorkflowFollowUpState {
4424
- if (
4425
- value !== "queued" &&
4426
- value !== "pending_presentation" &&
4427
- value !== "ready" &&
4428
- value !== "sent" &&
4429
- value !== "removed" &&
4430
- value !== "cancelled"
4431
- ) {
4597
+ if (value !== "queued" && value !== "removed" && value !== "cancelled") {
4432
4598
  throw new Error(`Unknown workflow follow-up state: ${value}`);
4433
4599
  }
4434
4600
  return value;
4435
4601
  }
4436
4602
 
4437
- function assertPresentationState(value: string): WorkflowPresentationState {
4438
- if (
4439
- value !== "none" &&
4440
- value !== "not-needed" &&
4441
- value !== "pending" &&
4442
- value !== "settled" &&
4443
- value !== "unavailable"
4444
- ) {
4445
- throw new Error(`Unknown workflow presentation state: ${value}`);
4446
- }
4447
- return value;
4448
- }
4449
-
4450
4603
  function isSettingsScopeRow(value: unknown): value is SettingsScopeRow {
4451
4604
  return (
4452
4605
  isRecord(value) &&
@@ -4500,27 +4653,11 @@ function isSettingsChangeRow(value: unknown): value is SettingsChangeRow {
4500
4653
  );
4501
4654
  }
4502
4655
 
4503
- function isFollowUpQueueRow(value: unknown): value is FollowUpQueueRow {
4504
- return (
4505
- isRecord(value) &&
4506
- typeof value.runId === "string" &&
4507
- typeof value.resourceId === "string" &&
4508
- (typeof value.originSessionId === "string" || value.originSessionId === null) &&
4509
- typeof value.presentationState === "string" &&
4510
- (typeof value.presentationEntryId === "string" || value.presentationEntryId === null) &&
4511
- (typeof value.presentationAssistantEntryId === "string" ||
4512
- value.presentationAssistantEntryId === null) &&
4513
- (Buffer.isBuffer(value.presentationReasonHash) || value.presentationReasonHash === null) &&
4514
- typeof value.revision === "number"
4515
- );
4516
- }
4517
-
4518
4656
  function isFollowUpRow(value: unknown): value is FollowUpRow {
4519
4657
  return (
4520
4658
  isRecord(value) &&
4521
4659
  typeof value.followUpId === "string" &&
4522
4660
  typeof value.resourceId === "string" &&
4523
- typeof value.queueResourceId === "string" &&
4524
4661
  typeof value.runId === "string" &&
4525
4662
  typeof value.requestId === "string" &&
4526
4663
  typeof value.orderNumber === "number" &&
@@ -4530,11 +4667,9 @@ function isFollowUpRow(value: unknown): value is FollowUpRow {
4530
4667
  typeof value.sourceType === "string" &&
4531
4668
  Buffer.isBuffer(value.promptHash) &&
4532
4669
  typeof value.status === "string" &&
4533
- (typeof value.sessionEntryId === "string" || value.sessionEntryId === null) &&
4534
4670
  (Buffer.isBuffer(value.reasonHash) || value.reasonHash === null) &&
4535
4671
  typeof value.createdAt === "number" &&
4536
4672
  typeof value.updatedAt === "number" &&
4537
- (typeof value.sentAt === "number" || value.sentAt === null) &&
4538
4673
  typeof value.revision === "number"
4539
4674
  );
4540
4675
  }
@@ -4543,6 +4678,14 @@ function isParentRunRow(value: unknown): value is { parentRunId: string | null }
4543
4678
  return isRecord(value) && (typeof value.parentRunId === "string" || value.parentRunId === null);
4544
4679
  }
4545
4680
 
4681
+ function isRunLineageRow(value: unknown): value is { rootRunId: string; restartNumber: number } {
4682
+ return (
4683
+ isRecord(value) &&
4684
+ typeof value.rootRunId === "string" &&
4685
+ typeof value.restartNumber === "number"
4686
+ );
4687
+ }
4688
+
4546
4689
  function isStatusRow(value: unknown): value is { status: string } {
4547
4690
  return isRecord(value) && typeof value.status === "string";
4548
4691
  }
@@ -4555,10 +4698,6 @@ function isStepIndexRow(value: unknown): value is { stepIndex: number } {
4555
4698
  return isRecord(value) && typeof value.stepIndex === "number";
4556
4699
  }
4557
4700
 
4558
- function isLeaseExpiryRow(value: unknown): value is { expiresAt: number | null } {
4559
- return isRecord(value) && (typeof value.expiresAt === "number" || value.expiresAt === null);
4560
- }
4561
-
4562
4701
  function isNextOrderRow(value: unknown): value is { nextOrder: number } {
4563
4702
  return isRecord(value) && typeof value.nextOrder === "number";
4564
4703
  }