@osolmaz/pi-workflows 0.16.0 → 0.16.2

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 (175) 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 +48 -16
  22. package/dist/client/view.js +1 -0
  23. package/dist/client/view.js.map +1 -1
  24. package/dist/controllers/index.d.ts +1 -1
  25. package/dist/controllers/index.js.map +1 -1
  26. package/dist/controllers/sqlite.d.ts +20 -165
  27. package/dist/controllers/sqlite.js +178 -436
  28. package/dist/controllers/sqlite.js.map +1 -1
  29. package/dist/extension/index.d.ts +23 -2
  30. package/dist/extension/index.js +637 -534
  31. package/dist/extension/index.js.map +1 -1
  32. package/dist/extension/recorder.d.ts +95 -0
  33. package/dist/extension/recorder.js +530 -0
  34. package/dist/extension/recorder.js.map +1 -0
  35. package/dist/extension/remote-recorder-store.d.ts +25 -0
  36. package/dist/extension/remote-recorder-store.js +81 -0
  37. package/dist/extension/remote-recorder-store.js.map +1 -0
  38. package/dist/extension/session-events.d.ts +134 -0
  39. package/dist/extension/session-events.js +60 -0
  40. package/dist/extension/session-events.js.map +1 -0
  41. package/dist/extension/session-view.d.ts +2 -0
  42. package/dist/extension/session-view.js +60 -2
  43. package/dist/extension/session-view.js.map +1 -1
  44. package/dist/extension/step-message.d.ts +6 -4
  45. package/dist/extension/step-message.js +12 -6
  46. package/dist/extension/step-message.js.map +1 -1
  47. package/dist/extension/widget.d.ts +2 -2
  48. package/dist/extension/widget.js +28 -18
  49. package/dist/extension/widget.js.map +1 -1
  50. package/dist/extension/workflow-message-coordinator.d.ts +28 -0
  51. package/dist/extension/workflow-message-coordinator.js +346 -0
  52. package/dist/extension/workflow-message-coordinator.js.map +1 -0
  53. package/dist/host/channel-effects.d.ts +72 -0
  54. package/dist/host/channel-effects.js +271 -0
  55. package/dist/host/channel-effects.js.map +1 -0
  56. package/dist/host/channel-supervisor.d.ts +21 -0
  57. package/dist/host/channel-supervisor.js +54 -0
  58. package/dist/host/channel-supervisor.js.map +1 -0
  59. package/dist/host/runner.d.ts +50 -8
  60. package/dist/host/runner.js +1632 -341
  61. package/dist/host/runner.js.map +1 -1
  62. package/dist/host/state.d.ts +19 -16
  63. package/dist/host/state.js +251 -63
  64. package/dist/host/state.js.map +1 -1
  65. package/dist/host/view.d.ts +19 -9
  66. package/dist/host/view.js +227 -119
  67. package/dist/host/view.js.map +1 -1
  68. package/dist/host/worker-entry.d.ts +4 -1
  69. package/dist/host/worker-entry.js +23 -24
  70. package/dist/host/worker-entry.js.map +1 -1
  71. package/dist/host/worker-protocol.d.ts +15 -0
  72. package/dist/host/worker-protocol.js.map +1 -1
  73. package/dist/state/prune.js +3 -25
  74. package/dist/state/prune.js.map +1 -1
  75. package/dist/state/schema.js +76 -97
  76. package/dist/state/schema.js.map +1 -1
  77. package/dist/state/workflow-messages.d.ts +98 -0
  78. package/dist/state/workflow-messages.js +366 -0
  79. package/dist/state/workflow-messages.js.map +1 -0
  80. package/dist/viewer/render.js +1 -1
  81. package/dist/viewer/render.js.map +1 -1
  82. package/dist/viewer/tui.d.ts +1 -1
  83. package/dist/viewer/tui.js +65 -8
  84. package/dist/viewer/tui.js.map +1 -1
  85. package/dist/workflows/composition.js +0 -4
  86. package/dist/workflows/composition.js.map +1 -1
  87. package/dist/workflows/definition.js +0 -8
  88. package/dist/workflows/definition.js.map +1 -1
  89. package/dist/workflows/engine.js +4 -5
  90. package/dist/workflows/engine.js.map +1 -1
  91. package/dist/workflows/human-decision.d.ts +2 -0
  92. package/dist/workflows/human-decision.js +8 -2
  93. package/dist/workflows/human-decision.js.map +1 -1
  94. package/dist/workflows/index.d.ts +1 -1
  95. package/dist/workflows/index.js.map +1 -1
  96. package/dist/workflows/schema.js +0 -4
  97. package/dist/workflows/schema.js.map +1 -1
  98. package/dist/workflows/settings.d.ts +1 -8
  99. package/dist/workflows/settings.js.map +1 -1
  100. package/dist/workflows/store.d.ts +20 -19
  101. package/dist/workflows/store.js +163 -354
  102. package/dist/workflows/store.js.map +1 -1
  103. package/dist/workflows/tool-input.d.ts +21 -0
  104. package/dist/workflows/tool-input.js +23 -1
  105. package/dist/workflows/tool-input.js.map +1 -1
  106. package/dist/workflows/types.d.ts +6 -8
  107. package/dist/workflows/workflow-message-content.d.ts +38 -0
  108. package/dist/workflows/workflow-message-content.js +157 -0
  109. package/dist/workflows/workflow-message-content.js.map +1 -0
  110. package/docs/2026-08-18-herdr-piw-plan.md +2 -1
  111. package/docs/2026-08-20-durable-workflow-launch-plan.md +1 -1
  112. package/docs/2026-08-25-workflow-follow-ups.md +26 -50
  113. package/docs/2026-08-30-out-of-process-workflow-host-plan.md +3 -3
  114. package/docs/2026-09-01-restore-session-delivery-controls-plan.md +4 -0
  115. package/docs/2026-09-01-unified-workflow-client-plan.md +5 -1
  116. package/docs/2026-09-02-installed-live-e2e-plan.md +15 -1
  117. package/docs/2026-09-02-unify-workflow-messages-plan.md +562 -0
  118. package/docs/2026-09-04-workflow-run-state-plan.md +363 -0
  119. package/docs/DEFERRED_TURNS.md +66 -294
  120. package/docs/HUMAN_DECISIONS.md +29 -30
  121. package/docs/HUMAN_DECISION_PRESENTATIONS.md +4 -11
  122. package/docs/SQLITE_STATE.md +25 -17
  123. package/docs/WORKFLOW_HOST.md +105 -31
  124. package/docs/WORKFLOW_STEP_MESSAGES.md +166 -75
  125. package/docs/live-replay-protocol.md +8 -4
  126. package/docs/tui-viewer.md +3 -1
  127. package/docs/workflows.md +44 -28
  128. package/herdr-plugin.toml +1 -1
  129. package/package.json +1 -1
  130. package/plugins/herdr/viewer.mjs +14 -5
  131. package/src/channels/adapter-entry.ts +220 -0
  132. package/src/channels/config.ts +296 -0
  133. package/src/channels/protocol.ts +333 -0
  134. package/src/channels/telegram.ts +335 -0
  135. package/src/client/client.ts +6 -2
  136. package/src/client/index.ts +2 -1
  137. package/src/client/protocol.ts +11 -5
  138. package/src/client/view.ts +51 -18
  139. package/src/controllers/index.ts +0 -1
  140. package/src/controllers/sqlite.ts +247 -845
  141. package/src/extension/index.ts +712 -647
  142. package/src/extension/recorder.ts +687 -0
  143. package/src/extension/remote-recorder-store.ts +126 -0
  144. package/src/extension/session-events.ts +119 -0
  145. package/src/extension/session-view.ts +86 -3
  146. package/src/extension/step-message.ts +19 -9
  147. package/src/extension/widget.ts +37 -17
  148. package/src/extension/workflow-message-coordinator.ts +398 -0
  149. package/src/host/channel-effects.ts +465 -0
  150. package/src/host/channel-supervisor.ts +73 -0
  151. package/src/host/runner.ts +2163 -525
  152. package/src/host/state.ts +333 -94
  153. package/src/host/view.ts +254 -122
  154. package/src/host/worker-entry.ts +38 -30
  155. package/src/host/worker-protocol.ts +11 -0
  156. package/src/state/prune.ts +3 -31
  157. package/src/state/schema.ts +76 -97
  158. package/src/state/workflow-messages.ts +599 -0
  159. package/src/viewer/render.ts +1 -5
  160. package/src/viewer/tui.ts +71 -6
  161. package/src/workflows/composition.ts +0 -5
  162. package/src/workflows/definition.ts +0 -8
  163. package/src/workflows/engine.ts +4 -6
  164. package/src/workflows/human-decision.ts +18 -2
  165. package/src/workflows/index.ts +0 -1
  166. package/src/workflows/schema.ts +0 -6
  167. package/src/workflows/settings.ts +1 -20
  168. package/src/workflows/store.ts +237 -489
  169. package/src/workflows/tool-input.ts +36 -1
  170. package/src/workflows/types.ts +6 -8
  171. package/src/workflows/workflow-message-content.ts +197 -0
  172. package/dist/extension/decision-channels.d.ts +0 -134
  173. package/dist/extension/decision-channels.js +0 -1307
  174. package/dist/extension/decision-channels.js.map +0 -1
  175. 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,26 @@ 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'
808
+ AND m.kind IN ('step', 'terminal', 'followUp') LIMIT 1`,
809
+ )
810
+ .get(runId);
811
+ return (
812
+ isObjectRecord(row) &&
813
+ typeof row.targetSessionId === "string" &&
814
+ this.hasActiveSessionTurn(row.targetSessionId)
815
+ );
683
816
  }
684
817
 
685
818
  private hasPendingInteraction(runId: string): boolean {
686
819
  const row = this.state.connection
687
820
  .prepare(
688
821
  `SELECT 1 AS present FROM interactive_requests
689
- WHERE run_id = ? AND status IN ('pending', 'presenting') LIMIT 1`,
822
+ WHERE run_id = ? AND status = 'pending' LIMIT 1`,
690
823
  )
691
824
  .get(runId);
692
825
  return row !== undefined;
@@ -721,7 +854,7 @@ export class HostViewStore {
721
854
  row.presentationRevision,
722
855
  row.runStatus,
723
856
  row.paused,
724
- this.activityRevision,
857
+ this.workflowActivityRevision(),
725
858
  this.hasLiveWorker(runId),
726
859
  this.hasActivity(runId),
727
860
  this.hasPendingInteraction(runId),
@@ -735,7 +868,7 @@ export class HostViewStore {
735
868
  `SELECT count(*) AS count, COALESCE(sum(revision), 0) AS revisionSum,
736
869
  COALESCE(max(updated_at), 0) AS updatedAt
737
870
  FROM interactive_requests
738
- WHERE target_session_id = ? AND status IN ('pending', 'presenting')`,
871
+ WHERE target_session_id = ? AND status = 'pending'`,
739
872
  )
740
873
  .get(sessionId);
741
874
  if (!isSessionRevisionRow(row)) throw new Error("Session view revision is invalid");
@@ -769,6 +902,9 @@ export function reduceWorkflowDisplay(facts: WorkflowDisplayFacts): WorkflowDisp
769
902
  if (facts.ambiguous) {
770
903
  status = "ambiguous";
771
904
  reason = "An external effect needs explicit recovery.";
905
+ } else if (facts.workerActive || facts.originTurnActive) {
906
+ status = "running";
907
+ activity = facts.workerActive ? "supervised_worker" : "origin_turn";
772
908
  } else if (
773
909
  facts.durableStatus === "completed" ||
774
910
  facts.durableStatus === "failed" ||
@@ -780,15 +916,15 @@ export function reduceWorkflowDisplay(facts: WorkflowDisplayFacts): WorkflowDisp
780
916
  } else if (facts.paused) {
781
917
  status = "paused";
782
918
  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
919
  } else if (facts.pendingInteraction || facts.durableStatus === "waiting") {
787
920
  status = "waiting";
788
921
  reason = "The workflow is waiting for origin-session input.";
789
922
  } else if (facts.queueStatus === "parked" || facts.queueStatus === "queued") {
790
923
  status = "queued";
791
- reason = facts.queueStatus === "parked" ? "The workflow is ready to resume." : null;
924
+ reason =
925
+ facts.queueStatus === "parked"
926
+ ? (facts.errorMessage ?? "The workflow is ready to resume.")
927
+ : null;
792
928
  } else if (facts.queueStatus === "done") {
793
929
  status = "completed";
794
930
  } else if (facts.queueStatus === "failed" || facts.queueStatus === "cancelled") {
@@ -844,6 +980,10 @@ function projectQueue(
844
980
  originSessionId: run.originSessionId,
845
981
  executionMode: run.executionMode,
846
982
  parentRunId: run.parentRunId,
983
+ rootRunId: run.rootRunId,
984
+ lineageKind: run.lineageKind,
985
+ restartNumber: run.restartNumber,
986
+ parentTerminalFingerprint: run.parentTerminalFingerprint,
847
987
  errorCode: run.errorCode,
848
988
  createdAt: run.createdAt,
849
989
  updatedAt: run.updatedAt,
@@ -862,24 +1002,16 @@ function workflowRootSource(value: unknown): JsonValue {
862
1002
  return root;
863
1003
  }
864
1004
 
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
- }
1005
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
1006
+ return typeof value === "object" && value !== null && !Array.isArray(value);
879
1007
  }
880
1008
 
881
- function activityKey(connectionId: string, requestId: string): string {
882
- return `${connectionId}\u0000${requestId}`;
1009
+ function isTerminalStatus(
1010
+ value: unknown,
1011
+ ): value is "completed" | "failed" | "timed_out" | "cancelled" {
1012
+ return (
1013
+ value === "completed" || value === "failed" || value === "timed_out" || value === "cancelled"
1014
+ );
883
1015
  }
884
1016
 
885
1017
  function contentKey(runId: string, contentPath: string): string {
@@ -31,6 +31,7 @@ import {
31
31
  parseWorkerResponse,
32
32
  type WorkerMessage,
33
33
  type WorkerResponse,
34
+ type WorkerRunCommand,
34
35
  } from "./worker-protocol.js";
35
36
  import { HostBackedWorkflowStore, type WorkerStoreTransport } from "./worker-store.js";
36
37
 
@@ -38,14 +39,10 @@ const STARTUP_ENV = "PI_WORKFLOWS_WORKER_LAUNCH";
38
39
  const PRESENTATION_TIMEOUT_MS = 30_000;
39
40
 
40
41
  type WorkerBootstrap = {
41
- initialized: boolean;
42
- input: JsonValue;
43
- launchOptions: JsonValue;
44
- parentRunId: string | null;
42
+ command: WorkerRunCommand;
45
43
  originSessionId: string | null;
46
44
  stateDirectory: string;
47
45
  piArgs?: string[];
48
- resumeInteractionAttemptId?: string;
49
46
  candidateInteraction?: {
50
47
  requestId: string;
51
48
  attemptId: string;
@@ -278,6 +275,35 @@ export function validateAcceptedAssistantSubmission(
278
275
  };
279
276
  }
280
277
 
278
+ export async function executeWorkerRunCommand(
279
+ engine: Pick<WorkflowEngine, "run" | "resumeRun" | "continueRun">,
280
+ workflow: WorkflowDefinition,
281
+ runId: string,
282
+ workflowSource: WorkflowSource,
283
+ command: WorkerRunCommand,
284
+ ) {
285
+ switch (command.kind) {
286
+ case "start":
287
+ case "restart":
288
+ return await engine.run(workflow, command.input, { runId, workflowSource });
289
+ case "resume":
290
+ return await engine.resumeRun(workflow, runId, {
291
+ workflowSource,
292
+ ...(command.resumeInteractionAttemptId === undefined
293
+ ? {}
294
+ : { resumeInteractionAttemptId: command.resumeInteractionAttemptId }),
295
+ });
296
+ case "continue":
297
+ return await engine.continueRun(workflow, command.parentRunId, command.input, {
298
+ runId,
299
+ workflowSource,
300
+ ...(command.humanDecision === undefined
301
+ ? {}
302
+ : { humanDecision: command.humanDecision as ResolvedHumanDecision }),
303
+ });
304
+ }
305
+ }
306
+
281
307
  export async function runWorkflowWorker(): Promise<number> {
282
308
  const launch = readLaunchEnvelope();
283
309
  const transport = new StdioWorkerTransport(launch);
@@ -360,32 +386,14 @@ export async function runWorkflowWorker(): Promise<number> {
360
386
  await store.requestPresentation(instructions);
361
387
  },
362
388
  });
363
- const launchOptions =
364
- typeof bootstrap.launchOptions === "object" &&
365
- bootstrap.launchOptions !== null &&
366
- !Array.isArray(bootstrap.launchOptions)
367
- ? (bootstrap.launchOptions as Record<string, unknown>)
368
- : {};
369
389
  try {
370
- const result = bootstrap.initialized
371
- ? await engine.resumeRun(workflow, launch.runId, {
372
- workflowSource: workflowSource.root,
373
- ...(bootstrap.resumeInteractionAttemptId === undefined
374
- ? {}
375
- : { resumeInteractionAttemptId: bootstrap.resumeInteractionAttemptId }),
376
- })
377
- : bootstrap.parentRunId === null
378
- ? await engine.run(workflow, bootstrap.input, {
379
- runId: launch.runId,
380
- workflowSource: workflowSource.root,
381
- })
382
- : await engine.continueRun(workflow, bootstrap.parentRunId, bootstrap.input, {
383
- runId: launch.runId,
384
- workflowSource: workflowSource.root,
385
- ...(launchOptions.humanDecision === undefined
386
- ? {}
387
- : { humanDecision: launchOptions.humanDecision as ResolvedHumanDecision }),
388
- });
390
+ const result = await executeWorkerRunCommand(
391
+ engine,
392
+ workflow,
393
+ launch.runId,
394
+ workflowSource.root,
395
+ bootstrap.command,
396
+ );
389
397
  await transport.control("worker.exiting", {
390
398
  status: result.state.status,
391
399
  traceSeq: result.state.traceSeq,
@@ -4,6 +4,17 @@ import { canonicalJson, parseJson, type JsonValue } from "../state/json.js";
4
4
  export const WORKER_MESSAGE_SCHEMA = "pi-workflows.worker-message.v1" as const;
5
5
  export const WORKER_RESPONSE_SCHEMA = "pi-workflows.worker-response.v1" as const;
6
6
 
7
+ export type WorkerRunCommand =
8
+ | { kind: "start"; input: JsonValue }
9
+ | { kind: "resume"; resumeInteractionAttemptId?: string }
10
+ | {
11
+ kind: "continue";
12
+ parentRunId: string;
13
+ input: JsonValue;
14
+ humanDecision?: JsonValue;
15
+ }
16
+ | { kind: "restart"; input: JsonValue };
17
+
7
18
  export const WORKER_MESSAGE_KINDS = [
8
19
  "worker.ready",
9
20
  "node.started",