@osolmaz/pi-workflows 0.16.0 → 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 (154) hide show
  1. package/README.md +18 -2
  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/client.d.ts +1 -0
  15. package/dist/client/client.js +5 -1
  16. package/dist/client/client.js.map +1 -1
  17. package/dist/client/index.d.ts +1 -1
  18. package/dist/client/protocol.d.ts +1 -1
  19. package/dist/client/protocol.js +11 -5
  20. package/dist/client/protocol.js.map +1 -1
  21. package/dist/client/view.d.ts +42 -16
  22. package/dist/client/view.js.map +1 -1
  23. package/dist/controllers/index.d.ts +1 -1
  24. package/dist/controllers/index.js.map +1 -1
  25. package/dist/controllers/sqlite.d.ts +12 -165
  26. package/dist/controllers/sqlite.js +124 -435
  27. package/dist/controllers/sqlite.js.map +1 -1
  28. package/dist/extension/index.d.ts +23 -2
  29. package/dist/extension/index.js +619 -535
  30. package/dist/extension/index.js.map +1 -1
  31. package/dist/extension/recorder.d.ts +95 -0
  32. package/dist/extension/recorder.js +530 -0
  33. package/dist/extension/recorder.js.map +1 -0
  34. package/dist/extension/remote-recorder-store.d.ts +25 -0
  35. package/dist/extension/remote-recorder-store.js +81 -0
  36. package/dist/extension/remote-recorder-store.js.map +1 -0
  37. package/dist/extension/session-events.d.ts +134 -0
  38. package/dist/extension/session-events.js +60 -0
  39. package/dist/extension/session-events.js.map +1 -0
  40. package/dist/extension/session-view.d.ts +2 -0
  41. package/dist/extension/session-view.js +60 -2
  42. package/dist/extension/session-view.js.map +1 -1
  43. package/dist/extension/step-message.d.ts +6 -4
  44. package/dist/extension/step-message.js +12 -6
  45. package/dist/extension/step-message.js.map +1 -1
  46. package/dist/extension/widget.d.ts +2 -2
  47. package/dist/extension/widget.js +28 -18
  48. package/dist/extension/widget.js.map +1 -1
  49. package/dist/extension/workflow-message-coordinator.d.ts +27 -0
  50. package/dist/extension/workflow-message-coordinator.js +294 -0
  51. package/dist/extension/workflow-message-coordinator.js.map +1 -0
  52. package/dist/host/channel-effects.d.ts +72 -0
  53. package/dist/host/channel-effects.js +271 -0
  54. package/dist/host/channel-effects.js.map +1 -0
  55. package/dist/host/channel-supervisor.d.ts +21 -0
  56. package/dist/host/channel-supervisor.js +54 -0
  57. package/dist/host/channel-supervisor.js.map +1 -0
  58. package/dist/host/runner.d.ts +43 -8
  59. package/dist/host/runner.js +1567 -331
  60. package/dist/host/runner.js.map +1 -1
  61. package/dist/host/state.d.ts +19 -16
  62. package/dist/host/state.js +251 -63
  63. package/dist/host/state.js.map +1 -1
  64. package/dist/host/view.d.ts +19 -9
  65. package/dist/host/view.js +222 -118
  66. package/dist/host/view.js.map +1 -1
  67. package/dist/state/prune.js +3 -25
  68. package/dist/state/prune.js.map +1 -1
  69. package/dist/state/schema.js +76 -97
  70. package/dist/state/schema.js.map +1 -1
  71. package/dist/state/workflow-messages.d.ts +96 -0
  72. package/dist/state/workflow-messages.js +335 -0
  73. package/dist/state/workflow-messages.js.map +1 -0
  74. package/dist/viewer/render.js +1 -1
  75. package/dist/viewer/render.js.map +1 -1
  76. package/dist/viewer/tui.d.ts +1 -1
  77. package/dist/viewer/tui.js +65 -8
  78. package/dist/viewer/tui.js.map +1 -1
  79. package/dist/workflows/human-decision.d.ts +2 -0
  80. package/dist/workflows/human-decision.js +8 -2
  81. package/dist/workflows/human-decision.js.map +1 -1
  82. package/dist/workflows/index.d.ts +1 -1
  83. package/dist/workflows/index.js.map +1 -1
  84. package/dist/workflows/settings.d.ts +1 -8
  85. package/dist/workflows/settings.js.map +1 -1
  86. package/dist/workflows/store.d.ts +5 -19
  87. package/dist/workflows/store.js +121 -354
  88. package/dist/workflows/store.js.map +1 -1
  89. package/dist/workflows/tool-input.d.ts +21 -0
  90. package/dist/workflows/tool-input.js +23 -1
  91. package/dist/workflows/tool-input.js.map +1 -1
  92. package/dist/workflows/types.d.ts +5 -5
  93. package/dist/workflows/workflow-message-content.d.ts +38 -0
  94. package/dist/workflows/workflow-message-content.js +157 -0
  95. package/dist/workflows/workflow-message-content.js.map +1 -0
  96. package/docs/2026-08-18-herdr-piw-plan.md +2 -1
  97. package/docs/2026-08-20-durable-workflow-launch-plan.md +1 -1
  98. package/docs/2026-08-25-workflow-follow-ups.md +26 -50
  99. package/docs/2026-08-30-out-of-process-workflow-host-plan.md +3 -3
  100. package/docs/2026-09-01-restore-session-delivery-controls-plan.md +4 -0
  101. package/docs/2026-09-01-unified-workflow-client-plan.md +5 -1
  102. package/docs/2026-09-02-installed-live-e2e-plan.md +15 -1
  103. package/docs/2026-09-02-unify-workflow-messages-plan.md +562 -0
  104. package/docs/DEFERRED_TURNS.md +66 -294
  105. package/docs/HUMAN_DECISIONS.md +29 -30
  106. package/docs/HUMAN_DECISION_PRESENTATIONS.md +4 -11
  107. package/docs/SQLITE_STATE.md +19 -15
  108. package/docs/WORKFLOW_HOST.md +93 -28
  109. package/docs/WORKFLOW_STEP_MESSAGES.md +166 -75
  110. package/docs/live-replay-protocol.md +8 -4
  111. package/docs/tui-viewer.md +3 -1
  112. package/docs/workflows.md +34 -27
  113. package/herdr-plugin.toml +1 -1
  114. package/package.json +1 -1
  115. package/plugins/herdr/viewer.mjs +14 -5
  116. package/src/channels/adapter-entry.ts +220 -0
  117. package/src/channels/config.ts +296 -0
  118. package/src/channels/protocol.ts +333 -0
  119. package/src/channels/telegram.ts +335 -0
  120. package/src/client/client.ts +6 -2
  121. package/src/client/index.ts +2 -1
  122. package/src/client/protocol.ts +11 -5
  123. package/src/client/view.ts +43 -18
  124. package/src/controllers/index.ts +0 -1
  125. package/src/controllers/sqlite.ts +164 -844
  126. package/src/extension/index.ts +693 -646
  127. package/src/extension/recorder.ts +687 -0
  128. package/src/extension/remote-recorder-store.ts +126 -0
  129. package/src/extension/session-events.ts +119 -0
  130. package/src/extension/session-view.ts +86 -3
  131. package/src/extension/step-message.ts +19 -9
  132. package/src/extension/widget.ts +37 -17
  133. package/src/extension/workflow-message-coordinator.ts +334 -0
  134. package/src/host/channel-effects.ts +465 -0
  135. package/src/host/channel-supervisor.ts +73 -0
  136. package/src/host/runner.ts +2120 -531
  137. package/src/host/state.ts +333 -94
  138. package/src/host/view.ts +249 -121
  139. package/src/state/prune.ts +3 -31
  140. package/src/state/schema.ts +76 -97
  141. package/src/state/workflow-messages.ts +548 -0
  142. package/src/viewer/render.ts +1 -5
  143. package/src/viewer/tui.ts +71 -6
  144. package/src/workflows/human-decision.ts +18 -2
  145. package/src/workflows/index.ts +0 -1
  146. package/src/workflows/settings.ts +1 -20
  147. package/src/workflows/store.ts +173 -489
  148. package/src/workflows/tool-input.ts +36 -1
  149. package/src/workflows/types.ts +5 -5
  150. package/src/workflows/workflow-message-content.ts +197 -0
  151. package/dist/extension/decision-channels.d.ts +0 -134
  152. package/dist/extension/decision-channels.js +0 -1307
  153. package/dist/extension/decision-channels.js.map +0 -1
  154. package/src/extension/decision-channels.ts +0 -1826
package/src/host/view.ts CHANGED
@@ -1,9 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
- import { ORIGIN_ACTIVITY_LEASE_MS } from "../client/activity.js";
3
2
  import {
4
3
  RUN_VIEW_SCHEMA,
5
4
  SESSION_VIEW_SCHEMA,
6
- type OriginActivityReport,
7
5
  type WorkflowDisplay,
8
6
  type WorkflowDisplayStatus,
9
7
  type WorkflowRunListPage,
@@ -19,6 +17,7 @@ import type {
19
17
  } from "../controllers/sqlite.js";
20
18
  import type { StateDatabase } from "../state/database.js";
21
19
  import { canonicalJson, parseJson, type JsonValue } from "../state/json.js";
20
+ import { WorkflowMessageStore, type WorkflowMessage } from "../state/workflow-messages.js";
22
21
  import type { WorkflowRunDisplayState, WorkflowRunStore } from "../workflows/store.js";
23
22
  import type {
24
23
  WorkflowRunState,
@@ -28,14 +27,7 @@ import type {
28
27
  WorkflowTraceEvent,
29
28
  WorkflowUpdateRecord,
30
29
  } from "../workflows/types.js";
31
- import type { HostStateStore, InteractiveRequestRecord } from "./state.js";
32
-
33
- export type { OriginActivityReport } from "../client/view.js";
34
-
35
- type OriginActivity = OriginActivityReport & {
36
- connectionId: string;
37
- expiresAt: number;
38
- };
30
+ import type { HostStateStore } from "./state.js";
39
31
 
40
32
  export const WORKFLOW_PAGE_KINDS = [
41
33
  "steps",
@@ -69,15 +61,16 @@ const VIEW_PAGE_ITEMS = 256;
69
61
  const CONTENT_CHUNK_BYTES = 192 * 1024;
70
62
  const CONTENT_CACHE_BYTES = 64 * 1024 * 1024;
71
63
  const VIEW_CACHE_ITEMS = 64;
64
+ const TERMINAL_VIEW_RETENTION_MS = 60_000;
72
65
 
73
66
  export class HostViewStore {
74
- private readonly activity = new Map<string, OriginActivity>();
75
67
  private readonly contentRecords = new Map<string, ContentRecord>();
76
68
  private readonly listCache = new Map<string, { revision: string; page: WorkflowRunListPage }>();
77
69
  private readonly runCache = new Map<string, { version: string; view: WorkflowRunView | null }>();
78
70
  private readonly sessionCache = new Map<string, { version: string; view: WorkflowSessionView }>();
79
71
  private contentBytes = 0;
80
- private activityRevision = 0;
72
+ private originActivityRevision = 0;
73
+ private readonly workflowMessages: WorkflowMessageStore;
81
74
 
82
75
  constructor(
83
76
  private readonly state: StateDatabase,
@@ -85,14 +78,20 @@ export class HostViewStore {
85
78
  private readonly hostState: HostStateStore,
86
79
  private readonly runs: WorkflowRunStore,
87
80
  private readonly hasLiveWorker: (runId: string) => boolean,
88
- ) {}
81
+ private readonly hasActiveSessionTurn: (targetSessionId: string) => boolean,
82
+ ) {
83
+ this.workflowMessages = hostState.workflowMessages;
84
+ }
85
+
86
+ noteOriginActivityChange(): void {
87
+ this.originActivityRevision += 1;
88
+ }
89
89
 
90
90
  list(cursor = 0, limit?: number): WorkflowRunListPage {
91
- this.expireActivity();
92
91
  return this.state.readTransaction(() => {
93
92
  const pageSize = Math.min(limit ?? VIEW_PAGE_ITEMS, VIEW_PAGE_ITEMS);
94
93
  const current = this.queue.workflowRunListRevision();
95
- const revision = `${current.revision}:${this.activityRevision}`;
94
+ const revision = `${current.revision}:${this.workflowActivityRevision()}`;
96
95
  const cacheKey = `${cursor}:${pageSize}`;
97
96
  const cached = this.listCache.get(cacheKey);
98
97
  if (cached?.revision === revision) {
@@ -137,7 +136,6 @@ export class HostViewStore {
137
136
  }
138
137
 
139
138
  run(runId: string): WorkflowRunView | null {
140
- this.expireActivity();
141
139
  return this.state.readTransaction(() => {
142
140
  const version = this.runVersion(runId);
143
141
  const cached = this.runCache.get(runId);
@@ -152,7 +150,6 @@ export class HostViewStore {
152
150
  }
153
151
 
154
152
  page(runId: string, request: RunPageRequest): WorkflowRunView | null {
155
- this.expireActivity();
156
153
  return this.state.readTransaction(() => this.readRun(runId, request));
157
154
  }
158
155
 
@@ -318,40 +315,68 @@ export class HostViewStore {
318
315
  };
319
316
  }
320
317
 
321
- session(sessionId: string): WorkflowSessionView {
322
- this.expireActivity();
318
+ session(
319
+ sessionId: string,
320
+ coordinator: { epoch: string; active: boolean; branchReportRequired: boolean } | null = null,
321
+ ): WorkflowSessionView {
323
322
  return this.state.readTransaction(() => {
324
- const queue = this.queue.findSessionReservationView(sessionId);
325
- const deliveries = {
326
- notification: this.queue.hasClaimableWorkflowNotification({ targetSessionId: sessionId }),
327
- turn: this.queue.hasClaimableWorkflowTurnIntent({ targetSessionId: sessionId }),
328
- };
329
- const version = [
330
- queue === undefined ? "none" : this.runVersion(queue.runId),
331
- this.pendingSessionRevision(sessionId),
332
- deliveries.notification,
333
- deliveries.turn,
334
- ].join(":");
335
- const cached = this.sessionCache.get(sessionId);
336
- if (cached?.version === version) {
337
- refreshCacheEntry(this.sessionCache, sessionId, cached);
338
- return cached.view;
339
- }
323
+ const activeQueue = this.queue.findSessionReservationView(sessionId);
324
+ const retainedRunId =
325
+ activeQueue === undefined ? this.retainedTerminalRunId(sessionId) : undefined;
326
+ const runId = activeQueue?.runId ?? retainedRunId;
340
327
  const pending = this.hostState.listPendingInteractions(sessionId);
341
328
  const pendingInteractions = byteBoundedForwardPage(pending, (request) =>
342
329
  this.projectRecordField(request.runId, request, "contract"),
343
330
  );
344
- const view: WorkflowSessionView = {
331
+ const workflowMessages = this.workflowMessages.listSession(sessionId);
332
+ const eligible = workflowMessages.find((message) => this.isMessageEligible(message));
333
+ const next =
334
+ coordinator === null || (coordinator.active && !coordinator.branchReportRequired)
335
+ ? eligible
336
+ : undefined;
337
+ const open = this.openWorkflowMessage(workflowMessages);
338
+ const openTurn =
339
+ open === undefined
340
+ ? undefined
341
+ : this.workflowMessages.openTurnForMessage(open.workflowMessageId);
342
+ return {
345
343
  schema: SESSION_VIEW_SCHEMA,
346
344
  sessionId,
347
- run: queue === undefined ? null : this.run(queue.runId),
345
+ run: runId === undefined ? null : this.run(runId),
348
346
  pendingInteractions,
349
347
  pendingInteractionStart: 0,
350
348
  pendingInteractionTotal: pending.length,
351
- deliveries,
349
+ workflowMessages,
350
+ workflowMessageStart: 0,
351
+ workflowMessageTotal: workflowMessages.length,
352
+ workflowMessageWindowComplete: true,
353
+ nextWorkflowMessageId: next?.workflowMessageId ?? null,
354
+ openWorkflowMessageId: open?.workflowMessageId ?? null,
355
+ openWorkflowTurn: openTurn ?? null,
356
+ coordinatorEpoch: coordinator?.epoch ?? null,
357
+ coordinatorActive: coordinator?.active ?? false,
358
+ branchReportRequired: coordinator?.branchReportRequired ?? false,
352
359
  };
353
- rememberCacheEntry(this.sessionCache, sessionId, { version, view });
354
- return view;
360
+ });
361
+ }
362
+
363
+ clearTerminal(sessionId: string, runId?: string, now: number = Date.now()): string | null {
364
+ return this.state.transaction(() => {
365
+ const retained = this.retainedTerminalRunId(sessionId, now);
366
+ if (retained === undefined) return null;
367
+ if (runId !== undefined && retained !== runId) {
368
+ throw new Error(`Retained terminal workflow does not match run ${runId}`);
369
+ }
370
+ this.state.connection
371
+ .prepare(
372
+ `INSERT INTO session_terminal_views(target_session_id, cleared_run_id, cleared_at)
373
+ VALUES (?, ?, ?)
374
+ ON CONFLICT(target_session_id) DO UPDATE SET
375
+ cleared_run_id = excluded.cleared_run_id, cleared_at = excluded.cleared_at`,
376
+ )
377
+ .run(sessionId, retained, now);
378
+ this.sessionCache.delete(sessionId);
379
+ return retained;
355
380
  });
356
381
  }
357
382
 
@@ -587,76 +612,175 @@ export class HostViewStore {
587
612
  });
588
613
  }
589
614
 
590
- reportActivity(connectionId: string, report: OriginActivityReport): void {
591
- if (report.deliveryId !== `interaction:${report.requestId}`) {
592
- throw new Error("Origin activity delivery does not match the interactive request");
593
- }
594
- const key = activityKey(connectionId, report.requestId);
595
- const previous = this.activity.get(key);
596
- if (report.state === "settled") {
597
- if (previous === undefined) return;
615
+ clearConnection(_connectionId: string): void {
616
+ // Coordinator fencing is process-local in the host. Durable turn state is
617
+ // reconciled from the Pi branch after the next coordinator connects.
618
+ }
619
+
620
+ private retainedTerminalRunId(sessionId: string, now: number = Date.now()): string | undefined {
621
+ const messages = this.workflowMessages
622
+ .listSession(sessionId)
623
+ .filter((message) => message.kind === "terminal")
624
+ .reverse();
625
+ for (const message of messages) {
626
+ const run = this.state.connection
627
+ .prepare("SELECT status FROM runs WHERE run_id = ?")
628
+ .get(message.runId);
629
+ if (!isObjectRecord(run) || !isTerminalStatus(run.status)) continue;
630
+ const clear = this.state.connection
631
+ .prepare(
632
+ `SELECT cleared_run_id AS clearedRunId, cleared_at AS clearedAt
633
+ FROM session_terminal_views WHERE target_session_id = ?`,
634
+ )
635
+ .get(sessionId);
598
636
  if (
599
- previous.sessionId !== report.sessionId ||
600
- previous.runId !== report.runId ||
601
- previous.requestId !== report.requestId ||
602
- previous.deliveryId !== report.deliveryId ||
603
- previous.sessionEntryId !== report.sessionEntryId
637
+ isObjectRecord(clear) &&
638
+ clear.clearedRunId === message.runId &&
639
+ typeof clear.clearedAt === "number" &&
640
+ clear.clearedAt >= Date.parse(message.createdAt)
604
641
  ) {
605
- throw new Error("Origin activity identity changed");
642
+ continue;
606
643
  }
607
- if (report.sequence <= previous.sequence) {
608
- throw new Error("Origin activity sequence must increase");
644
+ if (message.status === "pending") return message.runId;
645
+ if (message.status !== "sent") continue;
646
+ const turn = this.workflowMessages.latestTurnForMessage(message.workflowMessageId);
647
+ if (turn === undefined || turn.state === "started") return message.runId;
648
+ if (turn.endedAt !== null && Date.parse(turn.endedAt) + TERMINAL_VIEW_RETENTION_MS > now) {
649
+ return message.runId;
609
650
  }
610
- this.activity.delete(key);
611
- this.activityRevision += 1;
612
- return;
613
651
  }
614
- const request = this.hostState
615
- .listPendingInteractions(report.sessionId)
616
- .find((candidate) => candidate.requestId === report.requestId);
617
- validateActivityRequest(request, report);
618
- if (previous !== undefined) {
652
+ return undefined;
653
+ }
654
+
655
+ private isMessageEligible(message: WorkflowMessage): boolean {
656
+ if (message.status !== "pending") return false;
657
+ if (message.kind === "step" || message.kind === "decision") {
658
+ const request = this.state.connection
659
+ .prepare(
660
+ `SELECT i.status, r.paused FROM interactive_requests i
661
+ JOIN runs r ON r.run_id = i.run_id
662
+ WHERE i.request_id = ? AND i.run_id = ?`,
663
+ )
664
+ .get(message.sourceId, message.runId);
665
+ if (!isObjectRecord(request) || request.status !== "pending") return false;
666
+ return message.kind === "decision" || request.paused === 0;
667
+ }
668
+ if (message.kind === "notification") return true;
669
+ if (message.kind === "terminal") {
670
+ const run = this.state.connection
671
+ .prepare("SELECT status FROM runs WHERE run_id = ?")
672
+ .get(message.runId);
673
+ return isObjectRecord(run) && isTerminalStatus(run.status);
674
+ }
675
+ const source = this.state.connection
676
+ .prepare(
677
+ `SELECT f.run_id AS runId, f.order_number AS orderNumber, f.status
678
+ FROM workflow_follow_ups f WHERE f.follow_up_id = ?`,
679
+ )
680
+ .get(message.sourceId);
681
+ if (
682
+ !isObjectRecord(source) ||
683
+ source.status !== "queued" ||
684
+ typeof source.orderNumber !== "number" ||
685
+ typeof source.runId !== "string"
686
+ ) {
687
+ return false;
688
+ }
689
+ const leaf = this.terminalChainLeaf(source.runId);
690
+ if (leaf === undefined || leaf.status !== "completed") return false;
691
+ const terminal = this.workflowMessages
692
+ .listRun(leaf.runId)
693
+ .filter((candidate) => candidate.kind === "terminal" && candidate.status === "sent")
694
+ .at(-1);
695
+ if (terminal === undefined) return false;
696
+ const terminalTurn = this.workflowMessages.latestTurnForMessage(terminal.workflowMessageId);
697
+ if (terminalTurn?.state !== "ended") return false;
698
+ const prior = this.state.connection
699
+ .prepare(
700
+ `SELECT follow_up_id AS followUpId, status FROM workflow_follow_ups
701
+ WHERE run_id = ? AND order_number < ? ORDER BY order_number`,
702
+ )
703
+ .all(source.runId, source.orderNumber);
704
+ for (const item of prior) {
619
705
  if (
620
- previous.sessionId !== report.sessionId ||
621
- previous.runId !== report.runId ||
622
- previous.requestId !== report.requestId ||
623
- previous.deliveryId !== report.deliveryId ||
624
- previous.sessionEntryId !== report.sessionEntryId
706
+ !isObjectRecord(item) ||
707
+ typeof item.followUpId !== "string" ||
708
+ typeof item.status !== "string"
625
709
  ) {
626
- throw new Error("Origin activity identity changed");
710
+ return false;
627
711
  }
628
- if (report.sequence <= previous.sequence) {
629
- throw new Error("Origin activity sequence must increase");
712
+ if (item.status === "removed" || item.status === "cancelled") continue;
713
+ const priorMessage = this.workflowMessages.latestForSource("followUp", item.followUpId);
714
+ if (
715
+ priorMessage === undefined ||
716
+ this.workflowMessages.latestTurnForMessage(priorMessage.workflowMessageId)?.state !==
717
+ "ended"
718
+ ) {
719
+ return false;
630
720
  }
631
- } else if (report.state !== "started") {
632
- throw new Error("Origin activity must start before refresh");
633
721
  }
634
- this.activity.set(key, {
635
- ...report,
636
- connectionId,
637
- expiresAt: Date.now() + ORIGIN_ACTIVITY_LEASE_MS,
638
- });
639
- if (previous === undefined) this.activityRevision += 1;
722
+ const reservation = this.state.connection
723
+ .prepare(
724
+ `SELECT 1 AS present FROM run_bindings b JOIN runs r ON r.run_id = b.run_id
725
+ WHERE b.origin_session_id = ? AND r.run_id <> ?
726
+ AND r.status IN ('queued', 'running', 'waiting') LIMIT 1`,
727
+ )
728
+ .get(message.targetSessionId, message.runId);
729
+ return reservation === undefined;
640
730
  }
641
731
 
642
- clearConnection(connectionId: string): void {
643
- let changed = false;
644
- for (const [key, value] of this.activity) {
645
- if (value.connectionId !== connectionId) continue;
646
- this.activity.delete(key);
647
- changed = true;
648
- }
649
- if (changed) this.activityRevision += 1;
732
+ private terminalChainLeaf(runId: string): { runId: string; status: string } | undefined {
733
+ const row = this.state.connection
734
+ .prepare(
735
+ `WITH RECURSIVE chain(run_id, status, depth, created_at) AS (
736
+ SELECT run_id, status, 0, created_at FROM runs WHERE run_id = ?
737
+ UNION ALL
738
+ SELECT child.run_id, child.status, chain.depth + 1, child.created_at
739
+ FROM runs child JOIN chain ON child.parent_run_id = chain.run_id
740
+ )
741
+ SELECT run_id AS runId, status FROM chain
742
+ ORDER BY depth DESC, created_at DESC LIMIT 1`,
743
+ )
744
+ .get(runId);
745
+ return isObjectRecord(row) && typeof row.runId === "string" && typeof row.status === "string"
746
+ ? { runId: row.runId, status: row.status }
747
+ : undefined;
650
748
  }
651
749
 
652
- expireActivity(now = Date.now()): void {
653
- let changed = false;
654
- for (const [key, value] of this.activity) {
655
- if (value.expiresAt > now) continue;
656
- this.activity.delete(key);
657
- changed = true;
750
+ private openWorkflowMessage(messages: readonly WorkflowMessage[]): WorkflowMessage | undefined {
751
+ for (const message of [...messages].reverse()) {
752
+ if (message.status !== "sent") continue;
753
+ if (message.kind === "step") {
754
+ const request = this.state.connection
755
+ .prepare(
756
+ `SELECT i.status, r.paused FROM interactive_requests i
757
+ JOIN runs r ON r.run_id = i.run_id WHERE i.request_id = ?`,
758
+ )
759
+ .get(message.sourceId);
760
+ if (isObjectRecord(request) && request.status === "pending" && request.paused === 0) {
761
+ return message;
762
+ }
763
+ } else if (message.kind === "terminal" || message.kind === "followUp") {
764
+ const turn = this.workflowMessages.latestTurnForMessage(message.workflowMessageId);
765
+ if (turn === undefined || turn.state === "started") return message;
766
+ }
658
767
  }
659
- if (changed) this.activityRevision += 1;
768
+ return undefined;
769
+ }
770
+
771
+ private workflowActivityRevision(): string {
772
+ const row = this.state.connection
773
+ .prepare(
774
+ `SELECT
775
+ COALESCE((SELECT max(updated_at) FROM workflow_messages), 0) AS messageUpdatedAt,
776
+ COALESCE((SELECT max(COALESCE(ended_at, started_at)) FROM workflow_turns), 0) AS turnUpdatedAt`,
777
+ )
778
+ .get();
779
+ return isObjectRecord(row) &&
780
+ typeof row.messageUpdatedAt === "number" &&
781
+ typeof row.turnUpdatedAt === "number"
782
+ ? `${row.messageUpdatedAt}:${row.turnUpdatedAt}${this.originActivityRevision === 0 ? "" : `-${this.originActivityRevision}`}`
783
+ : `0:0${this.originActivityRevision === 0 ? "" : `-${this.originActivityRevision}`}`;
660
784
  }
661
785
 
662
786
  private display(
@@ -676,17 +800,25 @@ export class HostViewStore {
676
800
  }
677
801
 
678
802
  private hasActivity(runId: string): boolean {
679
- for (const activity of this.activity.values()) {
680
- if (activity.runId === runId) return true;
681
- }
682
- return false;
803
+ const row = this.state.connection
804
+ .prepare(
805
+ `SELECT t.target_session_id AS targetSessionId FROM workflow_turns t
806
+ JOIN workflow_messages m ON m.workflow_message_id = t.workflow_message_id
807
+ WHERE t.run_id = ? AND t.state = 'started' AND m.kind IN ('step', 'terminal') LIMIT 1`,
808
+ )
809
+ .get(runId);
810
+ return (
811
+ isObjectRecord(row) &&
812
+ typeof row.targetSessionId === "string" &&
813
+ this.hasActiveSessionTurn(row.targetSessionId)
814
+ );
683
815
  }
684
816
 
685
817
  private hasPendingInteraction(runId: string): boolean {
686
818
  const row = this.state.connection
687
819
  .prepare(
688
820
  `SELECT 1 AS present FROM interactive_requests
689
- WHERE run_id = ? AND status IN ('pending', 'presenting') LIMIT 1`,
821
+ WHERE run_id = ? AND status = 'pending' LIMIT 1`,
690
822
  )
691
823
  .get(runId);
692
824
  return row !== undefined;
@@ -721,7 +853,7 @@ export class HostViewStore {
721
853
  row.presentationRevision,
722
854
  row.runStatus,
723
855
  row.paused,
724
- this.activityRevision,
856
+ this.workflowActivityRevision(),
725
857
  this.hasLiveWorker(runId),
726
858
  this.hasActivity(runId),
727
859
  this.hasPendingInteraction(runId),
@@ -735,7 +867,7 @@ export class HostViewStore {
735
867
  `SELECT count(*) AS count, COALESCE(sum(revision), 0) AS revisionSum,
736
868
  COALESCE(max(updated_at), 0) AS updatedAt
737
869
  FROM interactive_requests
738
- WHERE target_session_id = ? AND status IN ('pending', 'presenting')`,
870
+ WHERE target_session_id = ? AND status = 'pending'`,
739
871
  )
740
872
  .get(sessionId);
741
873
  if (!isSessionRevisionRow(row)) throw new Error("Session view revision is invalid");
@@ -769,6 +901,9 @@ export function reduceWorkflowDisplay(facts: WorkflowDisplayFacts): WorkflowDisp
769
901
  if (facts.ambiguous) {
770
902
  status = "ambiguous";
771
903
  reason = "An external effect needs explicit recovery.";
904
+ } else if (facts.workerActive || facts.originTurnActive) {
905
+ status = "running";
906
+ activity = facts.workerActive ? "supervised_worker" : "origin_turn";
772
907
  } else if (
773
908
  facts.durableStatus === "completed" ||
774
909
  facts.durableStatus === "failed" ||
@@ -780,9 +915,6 @@ export function reduceWorkflowDisplay(facts: WorkflowDisplayFacts): WorkflowDisp
780
915
  } else if (facts.paused) {
781
916
  status = "paused";
782
917
  reason = "The workflow is durably paused.";
783
- } else if (facts.workerActive || facts.originTurnActive) {
784
- status = "running";
785
- activity = facts.workerActive ? "supervised_worker" : "origin_turn";
786
918
  } else if (facts.pendingInteraction || facts.durableStatus === "waiting") {
787
919
  status = "waiting";
788
920
  reason = "The workflow is waiting for origin-session input.";
@@ -844,6 +976,10 @@ function projectQueue(
844
976
  originSessionId: run.originSessionId,
845
977
  executionMode: run.executionMode,
846
978
  parentRunId: run.parentRunId,
979
+ rootRunId: run.rootRunId,
980
+ lineageKind: run.lineageKind,
981
+ restartNumber: run.restartNumber,
982
+ parentTerminalFingerprint: run.parentTerminalFingerprint,
847
983
  errorCode: run.errorCode,
848
984
  createdAt: run.createdAt,
849
985
  updatedAt: run.updatedAt,
@@ -862,24 +998,16 @@ function workflowRootSource(value: unknown): JsonValue {
862
998
  return root;
863
999
  }
864
1000
 
865
- function validateActivityRequest(
866
- request: InteractiveRequestRecord | undefined,
867
- report: OriginActivityReport,
868
- ): asserts request is InteractiveRequestRecord {
869
- if (request === undefined) throw new Error("Origin activity request is not pending");
870
- if (request.runId !== report.runId || request.targetSessionId !== report.sessionId) {
871
- throw new Error("Origin activity target does not match the interactive request");
872
- }
873
- if (request.presentationSessionEntryId !== report.sessionEntryId) {
874
- throw new Error("Origin activity session entry was not durably presented");
875
- }
876
- if (!Number.isSafeInteger(report.sequence) || report.sequence < 0) {
877
- throw new Error("Origin activity sequence must be a non-negative integer");
878
- }
1001
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
1002
+ return typeof value === "object" && value !== null && !Array.isArray(value);
879
1003
  }
880
1004
 
881
- function activityKey(connectionId: string, requestId: string): string {
882
- return `${connectionId}\u0000${requestId}`;
1005
+ function isTerminalStatus(
1006
+ value: unknown,
1007
+ ): value is "completed" | "failed" | "timed_out" | "cancelled" {
1008
+ return (
1009
+ value === "completed" || value === "failed" || value === "timed_out" || value === "cancelled"
1010
+ );
883
1011
  }
884
1012
 
885
1013
  function contentKey(runId: string, contentPath: string): string {
@@ -215,13 +215,7 @@ function treeHasBlocker(database: Database.Database, runIds: string[]): boolean
215
215
  hasRow(
216
216
  database,
217
217
  `SELECT 1 FROM workflow_follow_ups
218
- WHERE run_id IN (${values}) AND status IN ('queued', 'pending_presentation', 'ready')`,
219
- runIds,
220
- ) ||
221
- hasRow(
222
- database,
223
- `SELECT 1 FROM workflow_follow_up_queues
224
- WHERE run_id IN (${values}) AND presentation_state = 'pending'`,
218
+ WHERE run_id IN (${values}) AND status = 'queued'`,
225
219
  runIds,
226
220
  ) ||
227
221
  hasRow(
@@ -294,15 +288,8 @@ function deleteRunAggregates(database: Database.Database, runIds: string[]): voi
294
288
  const effectIds = effectRows.map((row) => row.effectId);
295
289
  if (effectIds.length !== 0) {
296
290
  const effectValues = placeholders(effectIds);
297
- database
298
- .prepare(`DELETE FROM notifications WHERE effect_id IN (${effectValues})`)
299
- .run(...effectIds);
300
- database
301
- .prepare(`DELETE FROM turn_intents WHERE effect_id IN (${effectValues})`)
302
- .run(...effectIds);
303
291
  database.prepare(`DELETE FROM effects WHERE effect_id IN (${effectValues})`).run(...effectIds);
304
292
  }
305
- database.prepare(`DELETE FROM turn_intents WHERE run_id IN (${values})`).run(...runIds);
306
293
  database
307
294
  .prepare(
308
295
  `DELETE FROM continuations
@@ -331,13 +318,11 @@ function relatedResourceIds(database: Database.Database, runIds: string[]): stri
331
318
  `SELECT resource_id AS resourceId FROM runs WHERE run_id IN (${values})
332
319
  UNION SELECT resource_id FROM session_segments WHERE run_id IN (${values})
333
320
  UNION SELECT resource_id FROM human_decisions WHERE run_id IN (${values})
334
- UNION SELECT resource_id FROM turn_intents WHERE run_id IN (${values})
335
321
  UNION SELECT resource_id FROM workflow_settings
336
322
  WHERE origin_run_id IN (${values}) OR active_run_id IN (${values})
337
- UNION SELECT resource_id FROM workflow_follow_up_queues WHERE run_id IN (${values})
338
323
  UNION SELECT resource_id FROM workflow_follow_ups WHERE run_id IN (${values})`,
339
324
  )
340
- .all(...runIds, ...runIds, ...runIds, ...runIds, ...runIds, ...runIds, ...runIds, ...runIds)
325
+ .all(...runIds, ...runIds, ...runIds, ...runIds, ...runIds, ...runIds)
341
326
  .filter(isResourceRow);
342
327
  const resources = rows.map((row) => row.resourceId);
343
328
  if (resources.length === 0) return [];
@@ -349,20 +334,7 @@ function relatedResourceIds(database: Database.Database, runIds: string[]): stri
349
334
  .all(...resources)
350
335
  .filter(isResourceRow)
351
336
  .map((row) => row.resourceId);
352
- const notificationResources = database
353
- .prepare(
354
- `SELECT resource.resource_id AS resourceId
355
- FROM notifications notification
356
- JOIN effects effect ON effect.effect_id = notification.effect_id
357
- JOIN resources resource
358
- ON resource.resource_type = 'notification'
359
- AND resource.aggregate_key = notification.notification_id
360
- WHERE effect.source_resource_id IN (${placeholders(resources)})`,
361
- )
362
- .all(...resources)
363
- .filter(isResourceRow)
364
- .map((row) => row.resourceId);
365
- return [...new Set([...resources, ...effectResources, ...notificationResources])];
337
+ return [...new Set([...resources, ...effectResources])];
366
338
  }
367
339
 
368
340
  function selectionSignature(database: Database.Database, runIds: string[]): string {