@osolmaz/pi-workflows 0.15.2 → 0.16.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 (124) hide show
  1. package/README.md +6 -6
  2. package/dist/client/activity.d.ts +2 -0
  3. package/dist/client/activity.js +6 -0
  4. package/dist/client/activity.js.map +1 -0
  5. package/dist/client/client.d.ts +101 -0
  6. package/dist/client/client.js +733 -0
  7. package/dist/client/client.js.map +1 -0
  8. package/dist/client/index.d.ts +3 -0
  9. package/dist/client/index.js +3 -0
  10. package/dist/client/index.js.map +1 -0
  11. package/dist/client/materialize.d.ts +7 -0
  12. package/dist/client/materialize.js +177 -0
  13. package/dist/client/materialize.js.map +1 -0
  14. package/dist/client/protocol.d.ts +60 -0
  15. package/dist/client/protocol.js +269 -0
  16. package/dist/client/protocol.js.map +1 -0
  17. package/dist/client/resolver.d.ts +23 -0
  18. package/dist/client/resolver.js +2 -0
  19. package/dist/client/resolver.js.map +1 -0
  20. package/dist/client/view.d.ts +118 -0
  21. package/dist/client/view.js +3 -0
  22. package/dist/client/view.js.map +1 -0
  23. package/dist/controllers/sqlite.d.ts +64 -0
  24. package/dist/controllers/sqlite.js +219 -3
  25. package/dist/controllers/sqlite.js.map +1 -1
  26. package/dist/extension/index.d.ts +1 -0
  27. package/dist/extension/index.js +384 -156
  28. package/dist/extension/index.js.map +1 -1
  29. package/dist/extension/session-delivery.d.ts +6 -0
  30. package/dist/extension/session-delivery.js +80 -25
  31. package/dist/extension/session-delivery.js.map +1 -1
  32. package/dist/extension/session-view.d.ts +21 -0
  33. package/dist/extension/session-view.js +127 -0
  34. package/dist/extension/session-view.js.map +1 -0
  35. package/dist/extension/widget.d.ts +2 -1
  36. package/dist/extension/widget.js +15 -7
  37. package/dist/extension/widget.js.map +1 -1
  38. package/dist/host/child-worker-supervisor.js +1 -1
  39. package/dist/host/child-worker-supervisor.js.map +1 -1
  40. package/dist/host/resolver-entry.d.ts +2 -23
  41. package/dist/host/resolver-entry.js +1 -1
  42. package/dist/host/resolver-entry.js.map +1 -1
  43. package/dist/host/runner.d.ts +12 -0
  44. package/dist/host/runner.js +565 -43
  45. package/dist/host/runner.js.map +1 -1
  46. package/dist/host/state.d.ts +8 -3
  47. package/dist/host/state.js +59 -27
  48. package/dist/host/state.js.map +1 -1
  49. package/dist/host/view.d.ts +73 -0
  50. package/dist/host/view.js +871 -0
  51. package/dist/host/view.js.map +1 -0
  52. package/dist/host/worker-protocol.js +1 -1
  53. package/dist/host/worker-protocol.js.map +1 -1
  54. package/dist/state/database.d.ts +1 -0
  55. package/dist/state/database.js +15 -0
  56. package/dist/state/database.js.map +1 -1
  57. package/dist/state/prune.d.ts +3 -1
  58. package/dist/state/prune.js +6 -8
  59. package/dist/state/prune.js.map +1 -1
  60. package/dist/state/schema.js +12 -1
  61. package/dist/state/schema.js.map +1 -1
  62. package/dist/viewer/backup.d.ts +2 -0
  63. package/dist/viewer/backup.js +28 -0
  64. package/dist/viewer/backup.js.map +1 -0
  65. package/dist/viewer/cli.d.ts +4 -0
  66. package/dist/viewer/cli.js +150 -170
  67. package/dist/viewer/cli.js.map +1 -1
  68. package/dist/viewer/tui.d.ts +5 -7
  69. package/dist/viewer/tui.js +188 -108
  70. package/dist/viewer/tui.js.map +1 -1
  71. package/dist/workflows/store.d.ts +62 -1
  72. package/dist/workflows/store.js +350 -44
  73. package/dist/workflows/store.js.map +1 -1
  74. package/docs/2026-09-01-restore-session-delivery-controls-plan.md +139 -0
  75. package/docs/2026-09-01-unified-workflow-client-plan.md +381 -0
  76. package/docs/2026-09-02-installed-live-e2e-plan.md +225 -0
  77. package/docs/SQLITE_STATE.md +13 -11
  78. package/docs/WORKFLOW_HOST.md +81 -64
  79. package/docs/WORKFLOW_STEP_MESSAGES.md +4 -4
  80. package/docs/development.md +2 -1
  81. package/docs/live-replay-protocol.md +70 -132
  82. package/docs/tui-viewer.md +10 -14
  83. package/docs/workflows.md +56 -3
  84. package/herdr-plugin.toml +1 -1
  85. package/package.json +9 -3
  86. package/protocol/client.v1.schema.json +137 -0
  87. package/protocol/fixtures/client-v1.json +23 -0
  88. package/src/client/activity.ts +6 -0
  89. package/src/client/client.ts +935 -0
  90. package/src/client/index.ts +24 -0
  91. package/src/client/materialize.ts +228 -0
  92. package/src/client/protocol.ts +327 -0
  93. package/src/client/resolver.ts +26 -0
  94. package/src/client/view.ts +138 -0
  95. package/src/controllers/sqlite.ts +342 -3
  96. package/src/extension/index.ts +482 -171
  97. package/src/extension/session-delivery.ts +88 -25
  98. package/src/extension/session-view.ts +154 -0
  99. package/src/extension/widget.ts +18 -9
  100. package/src/host/child-worker-supervisor.ts +1 -1
  101. package/src/host/resolver-entry.ts +11 -26
  102. package/src/host/runner.ts +749 -75
  103. package/src/host/state.ts +82 -44
  104. package/src/host/view.ts +1084 -0
  105. package/src/host/worker-protocol.ts +1 -1
  106. package/src/state/database.ts +13 -0
  107. package/src/state/prune.ts +11 -11
  108. package/src/state/schema.ts +12 -1
  109. package/src/viewer/backup.ts +29 -0
  110. package/src/viewer/cli.ts +171 -185
  111. package/src/viewer/tui.ts +196 -124
  112. package/src/workflows/store.ts +500 -45
  113. package/dist/host/client.d.ts +0 -48
  114. package/dist/host/client.js +0 -216
  115. package/dist/host/client.js.map +0 -1
  116. package/dist/host/protocol.d.ts +0 -38
  117. package/dist/host/protocol.js +0 -156
  118. package/dist/host/protocol.js.map +0 -1
  119. package/dist/viewer/watch.d.ts +0 -6
  120. package/dist/viewer/watch.js +0 -46
  121. package/dist/viewer/watch.js.map +0 -1
  122. package/src/host/client.ts +0 -293
  123. package/src/host/protocol.ts +0 -196
  124. package/src/viewer/watch.ts +0 -51
@@ -78,6 +78,36 @@ 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
113
  f.queue_resource_id AS queueResourceId, f.run_id AS runId,
@@ -110,6 +140,12 @@ export type RunWriteAuthority = {
110
140
  leaseMs?: number;
111
141
  };
112
142
 
143
+ export type WorkflowRunDisplayState = {
144
+ status: WorkflowRunState["status"];
145
+ paused: boolean;
146
+ error: string | null;
147
+ };
148
+
113
149
  export type WorkflowRunStoreOptions = {
114
150
  authorityProvider?: (runId: string) => RunWriteAuthority | undefined;
115
151
  /** Host-owned projection updates that must commit with each run snapshot. */
@@ -410,6 +446,48 @@ export type LoadedWorkflowRun = {
410
446
  followUpQueue?: WorkflowFollowUpQueueRecord | null;
411
447
  };
412
448
 
449
+ export type WorkflowRunViewCounts = {
450
+ steps: number;
451
+ trace: number;
452
+ sessionEntries: number;
453
+ sessionEvents: number;
454
+ settings: number;
455
+ followUps: number;
456
+ updates: number;
457
+ };
458
+
459
+ export type WorkflowRunViewRange = {
460
+ start: number;
461
+ limit: number;
462
+ };
463
+
464
+ export type WorkflowRunViewRead = {
465
+ runId: string;
466
+ state: WorkflowRunState;
467
+ snapshot: WorkflowDefinitionSnapshot;
468
+ traceEvents: WorkflowTraceEvent[];
469
+ sessionBinding: WorkflowSessionBinding | null;
470
+ sessionEntries: WorkflowSessionEntryRecord[];
471
+ sessionEvents: WorkflowSessionEventRecord[];
472
+ sessionCapture: WorkflowSessionCapture | null;
473
+ sessionIntegrity: SessionCaptureIntegrity;
474
+ settingsScopes: WorkflowSettingsScopeRecord[];
475
+ followUpQueue: WorkflowFollowUpQueueRecord | null;
476
+ graphSteps: WorkflowStepRecord[];
477
+ takenTransitions: string[];
478
+ };
479
+
480
+ export type WorkflowRunViewReadOptions = {
481
+ steps: WorkflowRunViewRange;
482
+ trace: WorkflowRunViewRange;
483
+ sessionEntries: WorkflowRunViewRange;
484
+ sessionEvents: WorkflowRunViewRange;
485
+ settings: WorkflowRunViewRange;
486
+ followUps: WorkflowRunViewRange;
487
+ updates: WorkflowRunViewRange;
488
+ graphCursor: number;
489
+ };
490
+
413
491
  export type ReadWorkflowRunOptions = {
414
492
  includeTrace?: boolean;
415
493
  };
@@ -1033,6 +1111,28 @@ export class WorkflowRunStore {
1033
1111
  .map((row) => this.settingsScopeRecord(row));
1034
1112
  }
1035
1113
 
1114
+ private readSettingsScopeRange(
1115
+ runId: string,
1116
+ range: WorkflowRunViewRange,
1117
+ ): WorkflowSettingsScopeRecord[] {
1118
+ const originRunId = this.logicalOriginRunId(runId);
1119
+ return this.state.connection
1120
+ .prepare(
1121
+ `SELECT s.scope_id AS scopeId, s.resource_id AS resourceId,
1122
+ s.origin_run_id AS originRunId, s.active_run_id AS activeRunId,
1123
+ s.mount_path AS mountPath, s.invocation,
1124
+ s.current_hash AS currentHash, r.revision,
1125
+ s.created_at AS createdAt, s.updated_at AS updatedAt
1126
+ FROM workflow_settings s
1127
+ JOIN resources r ON r.resource_id = s.resource_id
1128
+ WHERE s.origin_run_id = ? OR s.active_run_id = ?
1129
+ ORDER BY s.mount_path, s.invocation LIMIT ? OFFSET ?`,
1130
+ )
1131
+ .all(originRunId, runId, range.limit, range.start)
1132
+ .filter(isSettingsScopeRow)
1133
+ .map((row) => this.settingsScopeRecord(row));
1134
+ }
1135
+
1036
1136
  findSettingsScope(
1037
1137
  runId: string,
1038
1138
  mountPath: string,
@@ -1357,6 +1457,36 @@ export class WorkflowRunStore {
1357
1457
  };
1358
1458
  }
1359
1459
 
1460
+ private readFollowUpQueueRange(
1461
+ runId: string,
1462
+ range: WorkflowRunViewRange,
1463
+ ): WorkflowFollowUpQueueRecord | null {
1464
+ const queue = this.followUpQueueRow(runId);
1465
+ if (queue === undefined) return null;
1466
+ const followUps = this.state.connection
1467
+ .prepare(
1468
+ `${FOLLOW_UP_ROW_SELECT} WHERE f.run_id = ? ORDER BY f.order_number LIMIT ? OFFSET ?`,
1469
+ )
1470
+ .all(runId, range.limit, range.start)
1471
+ .filter(isFollowUpRow)
1472
+ .map((row) => this.followUpRecord(row));
1473
+ return {
1474
+ runId,
1475
+ ...(queue.originSessionId !== null ? { originSessionId: queue.originSessionId } : {}),
1476
+ presentationState: assertPresentationState(queue.presentationState),
1477
+ ...(queue.presentationEntryId !== null
1478
+ ? { presentationEntryId: queue.presentationEntryId }
1479
+ : {}),
1480
+ ...(queue.presentationAssistantEntryId !== null
1481
+ ? { presentationAssistantEntryId: queue.presentationAssistantEntryId }
1482
+ : {}),
1483
+ ...(queue.presentationReasonHash !== null
1484
+ ? { presentationReason: this.readText(queue.presentationReasonHash) }
1485
+ : {}),
1486
+ followUps,
1487
+ };
1488
+ }
1489
+
1360
1490
  listPendingPresentations(targetSessionId: string): string[] {
1361
1491
  return this.state.connection
1362
1492
  .prepare(
@@ -2289,6 +2419,217 @@ export class WorkflowRunStore {
2289
2419
  };
2290
2420
  }
2291
2421
 
2422
+ readRunViewCounts(runId: string): WorkflowRunViewCounts | null {
2423
+ const row = this.readRunRow(runId);
2424
+ if (row === undefined) return null;
2425
+ const segment = this.segmentRow(segmentIdFor(runId));
2426
+ const settingsOrigin = this.logicalOriginRunId(runId);
2427
+ const scalar = (sql: string, ...params: unknown[]): number => {
2428
+ const count = this.state.connection.prepare(sql).get(...params);
2429
+ return isCountRow(count) ? count.count : 0;
2430
+ };
2431
+ return {
2432
+ steps: scalar("SELECT count(*) AS count FROM run_steps WHERE run_id = ?", runId),
2433
+ trace: scalar("SELECT count(*) AS count FROM events WHERE resource_id = ?", row.resourceId),
2434
+ sessionEntries: segment?.entryCount ?? 0,
2435
+ sessionEvents: segment?.eventCount ?? 0,
2436
+ settings: scalar(
2437
+ `SELECT count(*) AS count FROM workflow_settings
2438
+ WHERE origin_run_id = ? OR active_run_id = ?`,
2439
+ settingsOrigin,
2440
+ runId,
2441
+ ),
2442
+ followUps: scalar(
2443
+ "SELECT count(*) AS count FROM workflow_follow_ups WHERE run_id = ?",
2444
+ runId,
2445
+ ),
2446
+ updates: scalar(
2447
+ `SELECT count(*) AS count FROM (
2448
+ SELECT 1 FROM workflow_updates u
2449
+ JOIN node_attempts a ON a.attempt_id = u.attempt_id
2450
+ WHERE a.run_id = ? GROUP BY u.update_type, u.update_key
2451
+ )`,
2452
+ runId,
2453
+ ),
2454
+ };
2455
+ }
2456
+
2457
+ readRunView(runId: string, options: WorkflowRunViewReadOptions): WorkflowRunViewRead | null {
2458
+ const row = this.readRunRow(runId);
2459
+ if (row === undefined) return null;
2460
+ const snapshot = this.readDefinition(row.definitionHash);
2461
+ const steps = this.readStepRange(runId, options.steps);
2462
+ const visibleUpdates = this.readCurrentUpdates(runId, options.updates);
2463
+ const state = this.buildRunState(
2464
+ row,
2465
+ snapshot,
2466
+ this.readLatestSteps(runId),
2467
+ steps,
2468
+ visibleUpdates,
2469
+ );
2470
+ const segment = this.segmentRow(segmentIdFor(runId));
2471
+ const loadedSegment =
2472
+ segment === undefined
2473
+ ? emptySegment()
2474
+ : this.loadSegment(segment, state, {
2475
+ entries: options.sessionEntries,
2476
+ events: options.sessionEvents,
2477
+ });
2478
+ return {
2479
+ runId,
2480
+ state,
2481
+ snapshot,
2482
+ traceEvents: this.traceEvents(row, options.trace),
2483
+ sessionBinding: loadedSegment.binding,
2484
+ sessionEntries: loadedSegment.entries,
2485
+ sessionEvents: loadedSegment.events,
2486
+ sessionCapture: loadedSegment.capture,
2487
+ sessionIntegrity: loadedSegment.integrity,
2488
+ settingsScopes: this.readSettingsScopeRange(runId, options.settings),
2489
+ followUpQueue: this.readFollowUpQueueRange(runId, options.followUps),
2490
+ graphSteps: this.readLatestSteps(runId, options.graphCursor),
2491
+ takenTransitions: this.readTakenTransitions(runId, options.graphCursor),
2492
+ };
2493
+ }
2494
+
2495
+ traceCursorForStep(runId: string, stepCursor: number, traceTotal: number): number {
2496
+ const step = this.state.connection
2497
+ .prepare(
2498
+ `SELECT a.attempt_id AS attemptId, a.node_id AS nodeId
2499
+ FROM run_steps s JOIN node_attempts a ON a.attempt_id = s.attempt_id
2500
+ WHERE s.run_id = ? AND s.step_index = ?`,
2501
+ )
2502
+ .get(runId, stepCursor);
2503
+ if (!isRecord(step) || typeof step.attemptId !== "string" || typeof step.nodeId !== "string") {
2504
+ return Math.min(stepCursor, Math.max(0, traceTotal - 1));
2505
+ }
2506
+ const run = this.readRunRow(runId);
2507
+ if (run === undefined) return 0;
2508
+ const exactAttempt = this.state.connection
2509
+ .prepare(
2510
+ `SELECT e.resource_revision AS revision FROM events e
2511
+ JOIN blobs b ON b.blob_hash = e.payload_hash
2512
+ WHERE e.resource_id = ?
2513
+ AND json_extract(CAST(b.content AS TEXT), '$.attemptId') = ?
2514
+ ORDER BY e.resource_revision DESC LIMIT 1`,
2515
+ )
2516
+ .get(run.resourceId, step.attemptId);
2517
+ const matched = isRevisionRow(exactAttempt)
2518
+ ? exactAttempt
2519
+ : this.state.connection
2520
+ .prepare(
2521
+ `SELECT e.resource_revision AS revision FROM events e
2522
+ JOIN blobs b ON b.blob_hash = e.payload_hash
2523
+ WHERE e.resource_id = ?
2524
+ AND json_extract(CAST(b.content AS TEXT), '$.nodeId') = ?
2525
+ ORDER BY e.resource_revision DESC LIMIT 1`,
2526
+ )
2527
+ .get(run.resourceId, step.nodeId);
2528
+ if (!isRevisionRow(matched)) return Math.min(stepCursor, Math.max(0, traceTotal - 1));
2529
+ const index = this.state.connection
2530
+ .prepare(
2531
+ `SELECT count(*) - 1 AS count FROM events
2532
+ WHERE resource_id = ? AND resource_revision <= ?`,
2533
+ )
2534
+ .get(run.resourceId, matched.revision);
2535
+ return isCountRow(index) ? Math.max(0, index.count) : 0;
2536
+ }
2537
+
2538
+ persistViewContent(runId: string, content: Buffer, mediaType: string): string {
2539
+ if (this.readRunRow(runId) === undefined) throw new Error(`Workflow run not found: ${runId}`);
2540
+ return this.state.transaction(() => {
2541
+ const hash = createHash("sha256").update(content).digest();
2542
+ this.state.connection
2543
+ .prepare(
2544
+ `INSERT INTO run_view_content(
2545
+ run_id, content_hash, media_type, byte_length, content, created_at
2546
+ ) VALUES (?, ?, ?, ?, ?, ?)
2547
+ ON CONFLICT(run_id, content_hash, media_type) DO NOTHING`,
2548
+ )
2549
+ .run(runId, hash, mediaType, content.byteLength, content, Date.now());
2550
+ const stored = this.state.connection
2551
+ .prepare(
2552
+ `SELECT media_type AS mediaType, byte_length AS byteLength, content
2553
+ FROM run_view_content
2554
+ WHERE run_id = ? AND content_hash = ? AND media_type = ?`,
2555
+ )
2556
+ .get(runId, hash, mediaType);
2557
+ if (
2558
+ !isRecord(stored) ||
2559
+ stored.mediaType !== mediaType ||
2560
+ stored.byteLength !== content.byteLength ||
2561
+ !Buffer.isBuffer(stored.content) ||
2562
+ !stored.content.equals(content)
2563
+ ) {
2564
+ throw new Error("Run-scoped view content conflict");
2565
+ }
2566
+ return hash.toString("hex");
2567
+ });
2568
+ }
2569
+
2570
+ readContentBlob(
2571
+ runId: string,
2572
+ digest: string,
2573
+ mediaType: string,
2574
+ ): { mediaType: string; content: Buffer } | undefined {
2575
+ if (!/^[0-9a-f]{64}$/u.test(digest)) return undefined;
2576
+ const stored = this.state.connection
2577
+ .prepare(
2578
+ `SELECT media_type AS mediaType, byte_length AS byteLength, content
2579
+ FROM run_view_content
2580
+ WHERE run_id = ? AND content_hash = ? AND media_type = ?`,
2581
+ )
2582
+ .get(runId, Buffer.from(digest, "hex"), mediaType);
2583
+ if (
2584
+ !isRecord(stored) ||
2585
+ stored.mediaType !== mediaType ||
2586
+ typeof stored.byteLength !== "number" ||
2587
+ !Buffer.isBuffer(stored.content) ||
2588
+ stored.content.byteLength !== stored.byteLength
2589
+ ) {
2590
+ return undefined;
2591
+ }
2592
+ return { mediaType, content: stored.content };
2593
+ }
2594
+
2595
+ readDisplayState(runId: string): WorkflowRunDisplayState | null {
2596
+ const row = this.readRunRow(runId);
2597
+ if (row === undefined) return null;
2598
+ return {
2599
+ status: row.status as WorkflowRunState["status"],
2600
+ paused: row.paused === 1,
2601
+ error: row.errorHash === null ? null : this.readText(row.errorHash),
2602
+ };
2603
+ }
2604
+
2605
+ readSessionReplayCheckpoint(runId: string, throughSequence: number): JsonValue | null {
2606
+ if (!Number.isSafeInteger(throughSequence) || throughSequence < 0) {
2607
+ throw new Error("Session replay checkpoint sequence must be a non-negative integer");
2608
+ }
2609
+ if (throughSequence === 0) return null;
2610
+ const row = this.state.connection
2611
+ .prepare(
2612
+ `SELECT event_seq AS eventSeq, state_hash AS stateHash
2613
+ FROM viewer_session_checkpoints
2614
+ WHERE run_id = ? AND event_seq <= ? ORDER BY event_seq DESC LIMIT 1`,
2615
+ )
2616
+ .get(runId, throughSequence);
2617
+ let checkpoint = reduceSessionEvents([], [], 0);
2618
+ if (isViewerSessionCheckpointRow(row)) {
2619
+ const saved = this.state.readJson(row.stateHash);
2620
+ if (!isTemporalSessionState(saved) || saved.throughSeq !== row.eventSeq) {
2621
+ throw new Error(`Viewer session checkpoint is invalid for ${runId}`);
2622
+ }
2623
+ checkpoint = saved;
2624
+ }
2625
+ if (checkpoint.throughSeq < throughSequence) {
2626
+ const events = this.sessionEventRecords(runId, checkpoint.throughSeq, throughSequence + 1);
2627
+ checkpoint = reduceSessionEventsFromCheckpoint([], events, throughSequence, checkpoint);
2628
+ checkpoint = boundedTemporalCheckpoint(checkpoint);
2629
+ }
2630
+ return parseJson(canonicalJson(checkpoint));
2631
+ }
2632
+
2292
2633
  listRuns(options: ReadWorkflowRunOptions = {}): LoadedWorkflowRun[] {
2293
2634
  const rows = this.state.connection
2294
2635
  .prepare("SELECT run_id AS runId FROM runs ORDER BY created_at DESC, run_id DESC")
@@ -3407,13 +3748,29 @@ export class WorkflowRunStore {
3407
3748
 
3408
3749
  private readRunState(row: RunRow, snapshot: WorkflowDefinitionSnapshot): WorkflowRunState {
3409
3750
  const steps = this.readSteps(row.runId);
3751
+ return this.buildRunState(
3752
+ row,
3753
+ snapshot,
3754
+ steps,
3755
+ steps,
3756
+ this.readUpdates(row.runId).updates ?? [],
3757
+ );
3758
+ }
3759
+
3760
+ private buildRunState(
3761
+ row: RunRow,
3762
+ snapshot: WorkflowDefinitionSnapshot,
3763
+ projectionSteps: WorkflowStepRecord[],
3764
+ visibleSteps: WorkflowStepRecord[],
3765
+ visibleUpdates: WorkflowUpdateRecord[],
3766
+ ): WorkflowRunState {
3410
3767
  const sources = this.readRunSources(row.runId, snapshot);
3411
3768
  const carriedStepCount = this.carriedStepCount(row.runId);
3412
3769
  const activeAttempt = row.status === "running" ? this.readActiveAttempt(row.runId) : undefined;
3413
3770
  const humanDecision = this.readHumanDecisionReceipt(row.runId);
3414
3771
  const outputs: Record<string, unknown> = {};
3415
3772
  const results: Record<string, WorkflowNodeResult> = {};
3416
- for (const step of steps) {
3773
+ for (const step of projectionSteps) {
3417
3774
  const result = resultForStep(step);
3418
3775
  results[step.nodeId] = result;
3419
3776
  if (step.outcome === "ok") outputs[step.nodeId] = step.output;
@@ -3449,8 +3806,8 @@ export class WorkflowRunStore {
3449
3806
  input: this.state.readJson(row.inputHash),
3450
3807
  outputs,
3451
3808
  results,
3452
- steps,
3453
- ...this.readUpdates(row.runId),
3809
+ steps: visibleSteps,
3810
+ ...(visibleUpdates.length === 0 ? {} : { updates: visibleUpdates }),
3454
3811
  ...(activeAttempt === undefined ? {} : { currentNode: activeAttempt.nodeId }),
3455
3812
  ...(activeAttempt === undefined ? {} : { currentAttemptId: activeAttempt.attemptId }),
3456
3813
  ...(activeAttempt?.startedAt === null || activeAttempt === undefined
@@ -3472,8 +3829,8 @@ export class WorkflowRunStore {
3472
3829
  ...(row.statusDetail === null ? {} : { statusDetail: row.statusDetail }),
3473
3830
  ...(humanDecision === undefined ? {} : { humanDecision }),
3474
3831
  ...(row.paused === 0 ? {} : { paused: true }),
3475
- ...(row.status === "waiting" && steps.at(-1) !== undefined
3476
- ? { waitingOn: steps.at(-1)?.nodeId as string }
3832
+ ...(row.status === "waiting" && projectionSteps.at(-1) !== undefined
3833
+ ? { waitingOn: projectionSteps.at(-1)?.nodeId as string }
3477
3834
  : {}),
3478
3835
  ...(row.finalOutputHash === null
3479
3836
  ? {}
@@ -3483,42 +3840,65 @@ export class WorkflowRunStore {
3483
3840
  }
3484
3841
 
3485
3842
  private readSteps(runId: string): WorkflowStepRecord[] {
3843
+ const rows = this.state.connection
3844
+ .prepare(`${STEP_ROW_SELECT} WHERE s.run_id = ? ORDER BY s.step_index`)
3845
+ .all(runId)
3846
+ .filter(isStepRow);
3847
+ return this.mapStepRows(runId, rows, 0);
3848
+ }
3849
+
3850
+ private readStepRange(runId: string, range: WorkflowRunViewRange): WorkflowStepRecord[] {
3851
+ const rows = this.state.connection
3852
+ .prepare(`${STEP_ROW_SELECT} WHERE s.run_id = ? ORDER BY s.step_index LIMIT ? OFFSET ?`)
3853
+ .all(runId, range.limit, range.start)
3854
+ .filter(isStepRow);
3855
+ return this.mapStepRows(runId, rows, range.start);
3856
+ }
3857
+
3858
+ private readLatestSteps(runId: string, through?: number): WorkflowStepRecord[] {
3859
+ const throughClause = through === undefined ? "" : " AND s2.step_index <= ?";
3860
+ const params = through === undefined ? [runId, runId] : [runId, runId, through];
3486
3861
  const rows = this.state.connection
3487
3862
  .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`,
3863
+ `${STEP_ROW_SELECT}
3864
+ WHERE s.run_id = ? AND s.step_index IN (
3865
+ SELECT max(s2.step_index) FROM run_steps s2
3866
+ JOIN node_attempts a2 ON a2.attempt_id = s2.attempt_id
3867
+ WHERE s2.run_id = ?${throughClause} GROUP BY a2.node_id
3868
+ ) ORDER BY s.step_index`,
3517
3869
  )
3518
- .all(runId)
3870
+ .all(...params)
3519
3871
  .filter(isStepRow);
3872
+ return this.mapStepRows(runId, rows);
3873
+ }
3874
+
3875
+ private readTakenTransitions(runId: string, through: number): string[] {
3876
+ const rows = this.state.connection
3877
+ .prepare(
3878
+ `WITH ordered AS (
3879
+ SELECT s.step_index, a.node_id AS nodeId,
3880
+ lag(a.node_id) OVER (ORDER BY s.step_index) AS previousNodeId
3881
+ FROM run_steps s JOIN node_attempts a ON a.attempt_id = s.attempt_id
3882
+ WHERE s.run_id = ? AND s.step_index <= ?
3883
+ )
3884
+ SELECT DISTINCT previousNodeId, nodeId FROM ordered
3885
+ WHERE previousNodeId IS NOT NULL ORDER BY previousNodeId, nodeId`,
3886
+ )
3887
+ .all(runId, through);
3888
+ return rows.flatMap((row) =>
3889
+ isRecord(row) && typeof row.previousNodeId === "string" && typeof row.nodeId === "string"
3890
+ ? [`${row.previousNodeId}->${row.nodeId}`]
3891
+ : [],
3892
+ );
3893
+ }
3894
+
3895
+ private mapStepRows(
3896
+ runId: string,
3897
+ rows: StepRow[],
3898
+ expectedStart?: number,
3899
+ ): WorkflowStepRecord[] {
3520
3900
  return rows.map((row, index) => {
3521
- if (row.stepIndex !== index)
3901
+ if (expectedStart !== undefined && row.stepIndex !== expectedStart + index)
3522
3902
  throw new Error(`Workflow run step sequence has a gap: ${runId}`);
3523
3903
  const receipt =
3524
3904
  row.receiptHash === null ? {} : this.readJsonAs<StoredAttemptReceipt>(row.receiptHash);
@@ -3706,6 +4086,41 @@ export class WorkflowRunStore {
3706
4086
  return updates === undefined ? {} : { updates };
3707
4087
  }
3708
4088
 
4089
+ private readCurrentUpdates(runId: string, range?: WorkflowRunViewRange): WorkflowUpdateRecord[] {
4090
+ const rangeClause = range === undefined ? "" : " LIMIT ? OFFSET ?";
4091
+ const params = range === undefined ? [runId] : [runId, range.limit, range.start];
4092
+ const rows = this.state.connection
4093
+ .prepare(
4094
+ `WITH ranked AS (
4095
+ SELECT u.update_id AS updateId, u.run_revision AS runRevision,
4096
+ a.node_id AS nodeId, u.attempt_id AS attemptId,
4097
+ u.update_type AS updateType, u.update_key AS updateKey,
4098
+ u.data_hash AS dataHash, u.recorded_at AS recordedAt,
4099
+ row_number() OVER (
4100
+ PARTITION BY u.update_type, u.update_key ORDER BY u.run_revision DESC
4101
+ ) AS rank
4102
+ FROM workflow_updates u
4103
+ JOIN node_attempts a ON a.attempt_id = u.attempt_id
4104
+ WHERE a.run_id = ?
4105
+ )
4106
+ SELECT updateId, runRevision, nodeId, attemptId, updateType, updateKey, dataHash, recordedAt
4107
+ FROM ranked WHERE rank = 1 ORDER BY runRevision${rangeClause}`,
4108
+ )
4109
+ .all(...params)
4110
+ .filter(isUpdateRow);
4111
+ return rows.map((row) => ({
4112
+ updateId: row.updateId,
4113
+ seq: row.runRevision,
4114
+ at: new Date(row.recordedAt).toISOString(),
4115
+ runId,
4116
+ nodeId: row.nodeId,
4117
+ attemptId: row.attemptId,
4118
+ type: row.updateType,
4119
+ key: row.updateKey,
4120
+ data: this.readJsonAs<Record<string, unknown>>(row.dataHash),
4121
+ }));
4122
+ }
4123
+
3709
4124
  private readJsonAs<T>(hash: Buffer): T {
3710
4125
  return this.state.readJson(hash) as T;
3711
4126
  }
@@ -3726,14 +4141,17 @@ export class WorkflowRunStore {
3726
4141
  return value as WorkflowDefinitionSnapshot;
3727
4142
  }
3728
4143
 
3729
- private traceEvents(run: RunRow): WorkflowTraceEvent[] {
4144
+ private traceEvents(run: RunRow, range?: WorkflowRunViewRange): WorkflowTraceEvent[] {
4145
+ const rangeClause = range === undefined ? "" : " LIMIT ? OFFSET ?";
4146
+ const params =
4147
+ range === undefined ? [run.resourceId] : [run.resourceId, range.limit, range.start];
3730
4148
  const rows = this.state.connection
3731
4149
  .prepare(
3732
4150
  `SELECT resource_revision AS resourceRevision, event_type AS eventType,
3733
4151
  payload_hash AS payloadHash, recorded_at AS recordedAt
3734
- FROM events WHERE resource_id = ? ORDER BY resource_revision`,
4152
+ FROM events WHERE resource_id = ? ORDER BY resource_revision${rangeClause}`,
3735
4153
  )
3736
- .all(run.resourceId);
4154
+ .all(...params);
3737
4155
  const events: WorkflowTraceEvent[] = [];
3738
4156
  for (const row of rows) {
3739
4157
  /* istanbul ignore if -- exact schema and internal query shape */
@@ -3787,7 +4205,11 @@ export class WorkflowRunStore {
3787
4205
  return row;
3788
4206
  }
3789
4207
 
3790
- private loadSegment(segment: SegmentRow, runState: WorkflowRunState): SessionCaptureSegment {
4208
+ private loadSegment(
4209
+ segment: SegmentRow,
4210
+ runState: WorkflowRunState,
4211
+ ranges?: { entries: WorkflowRunViewRange; events: WorkflowRunViewRange },
4212
+ ): SessionCaptureSegment {
3791
4213
  const binding =
3792
4214
  segment.bindingHash === null
3793
4215
  ? null
@@ -3795,9 +4217,15 @@ export class WorkflowRunStore {
3795
4217
  const entries = this.state.connection
3796
4218
  .prepare(
3797
4219
  `SELECT entry_seq AS entrySeq, entry_hash AS entryHash, recorded_at AS recordedAt
3798
- FROM session_entries WHERE segment_id = ? ORDER BY entry_seq`,
4220
+ FROM session_entries WHERE segment_id = ? ORDER BY entry_seq${
4221
+ ranges === undefined ? "" : " LIMIT ? OFFSET ?"
4222
+ }`,
4223
+ )
4224
+ .all(
4225
+ ...(ranges === undefined
4226
+ ? [segment.segmentId]
4227
+ : [segment.segmentId, ranges.entries.limit, ranges.entries.start]),
3799
4228
  )
3800
- .all(segment.segmentId)
3801
4229
  .filter(isSessionEntryRow)
3802
4230
  .map((row) => ({
3803
4231
  seq: row.entrySeq,
@@ -3810,9 +4238,15 @@ export class WorkflowRunStore {
3810
4238
  attempt_id AS attemptId, turn_id AS turnId, message_id AS messageId,
3811
4239
  tool_call_id AS toolCallId, payload_hash AS payloadHash,
3812
4240
  recorded_at AS recordedAt
3813
- FROM session_events WHERE segment_id = ? ORDER BY event_seq`,
4241
+ FROM session_events WHERE segment_id = ? ORDER BY event_seq${
4242
+ ranges === undefined ? "" : " LIMIT ? OFFSET ?"
4243
+ }`,
4244
+ )
4245
+ .all(
4246
+ ...(ranges === undefined
4247
+ ? [segment.segmentId]
4248
+ : [segment.segmentId, ranges.events.limit, ranges.events.start]),
3814
4249
  )
3815
- .all(segment.segmentId)
3816
4250
  .filter(isSessionEventRow)
3817
4251
  .map((row) => ({
3818
4252
  seq: row.eventSeq,
@@ -3839,7 +4273,17 @@ export class WorkflowRunStore {
3839
4273
  ...(failure === undefined ? {} : { failure }),
3840
4274
  };
3841
4275
  const diagnostics: string[] = [];
3842
- if (entries.length !== segment.entryCount || events.length !== segment.eventCount) {
4276
+ const durableCounts =
4277
+ ranges === undefined
4278
+ ? { entries: entries.length, events: events.length }
4279
+ : {
4280
+ entries: sessionRecordCount(this.state, "session_entries", segment.segmentId),
4281
+ events: sessionRecordCount(this.state, "session_events", segment.segmentId),
4282
+ };
4283
+ if (
4284
+ durableCounts.entries !== segment.entryCount ||
4285
+ durableCounts.events !== segment.eventCount
4286
+ ) {
3843
4287
  diagnostics.push("session capture counts do not match durable rows");
3844
4288
  }
3845
4289
  if (runState.status !== "running" && segment.status === "recording") {
@@ -4219,6 +4663,17 @@ function workflowSourceFromRow(row: RunSourceRow): WorkflowSource {
4219
4663
  : { kind: "file", path: row.sourceRef, hash: row.sourceRevision };
4220
4664
  }
4221
4665
 
4666
+ function sessionRecordCount(
4667
+ state: StateDatabase,
4668
+ table: "session_entries" | "session_events",
4669
+ segmentId: string,
4670
+ ): number {
4671
+ const row = state.connection
4672
+ .prepare(`SELECT count(*) AS count FROM ${table} WHERE segment_id = ?`)
4673
+ .get(segmentId);
4674
+ return isCountRow(row) ? row.count : 0;
4675
+ }
4676
+
4222
4677
  function segmentIdFor(runId: string, attemptId?: string): string {
4223
4678
  return `segment-${createHash("sha256")
4224
4679
  .update(`${runId}\0${attemptId ?? ""}`)