@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
@@ -5,11 +5,12 @@ import { StateDatabase, workflowStatePath } from "../state/database.js";
5
5
  import { canonicalJson } from "../state/json.js";
6
6
  import { resourceIdFor, tokenHash } from "../state/mutation.js";
7
7
  import { initializeViewerRun, recordViewerDeltas } from "../state/viewer.js";
8
+ import { WorkflowMessageStore } from "../state/workflow-messages.js";
8
9
  import { EffectRequestConflictError, ResourceConflictError, ResourceNotFoundError, WorkflowRequestConflictError, } from "./errors.js";
9
- const TURN_INTENT_FACTS_SCHEMA = "pi-workflows.deferred-turn-facts.v1";
10
10
  export class SqliteControllerStore {
11
11
  filePath;
12
12
  state;
13
+ workflowMessages;
13
14
  ownsState;
14
15
  projectId;
15
16
  closed = false;
@@ -23,6 +24,7 @@ export class SqliteControllerStore {
23
24
  checkLegacyState: filePath === workflowStatePath(),
24
25
  });
25
26
  this.filePath = this.state.filePath;
27
+ this.workflowMessages = new WorkflowMessageStore(this.state);
26
28
  if (options.global === true) {
27
29
  this.projectId = null;
28
30
  }
@@ -520,32 +522,65 @@ export class SqliteControllerStore {
520
522
  if (this.getWorkflowRun(options.runId) !== undefined) {
521
523
  throw new Error(`Workflow run already reserved: ${options.runId}`);
522
524
  }
525
+ const lineageKind = options.parentRunId === undefined ? null : (options.lineageKind ?? "continuation");
526
+ if (options.parentRunId === undefined && options.lineageKind !== undefined) {
527
+ throw new Error("A root workflow run cannot declare lineage");
528
+ }
529
+ let rootRunId = options.runId;
530
+ let restartNumber = 0;
531
+ let parentTerminalFingerprint = null;
523
532
  if (options.parentRunId !== undefined) {
524
533
  const parent = this.requireWorkflowRunRow(options.parentRunId);
525
534
  if (parent.originSessionId !== options.originSessionId) {
526
- throw new Error("Continuation parent belongs to another Pi session");
535
+ throw new Error("Workflow parent belongs to another Pi session");
527
536
  }
528
- if (parent.status === "parked") {
529
- const parentLease = this.requireLease(parent.resourceId);
530
- if (parentLease.ownerId !== null) {
531
- throw new Error("Continuation parent still has an active owner");
537
+ rootRunId = parent.rootRunId;
538
+ if (lineageKind === "restart") {
539
+ if (!["completed", "failed", "timed_out", "cancelled"].includes(parent.runStatus) ||
540
+ !["done", "failed", "cancelled"].includes(parent.status)) {
541
+ throw new Error("Restart parent is not terminal");
542
+ }
543
+ if (options.parentTerminalFingerprint === undefined) {
544
+ throw new Error("Restart requires the parent terminal fingerprint");
545
+ }
546
+ parentTerminalFingerprint = terminalFingerprintBuffer(options.parentTerminalFingerprint);
547
+ restartNumber = options.restartNumber ?? parent.restartNumber + 1;
548
+ if (restartNumber !== parent.restartNumber + 1) {
549
+ throw new Error("Restart number does not follow its parent");
532
550
  }
533
- this.state.connection
534
- .prepare(`UPDATE run_queue
535
- SET status = 'done', updated_at = ?, finished_at = ?
536
- WHERE run_id = ? AND status = 'parked'`)
537
- .run(now, now, parent.runId);
538
- const parentRevision = this.resourceRevision(parent.resourceId);
539
- this.bumpResource(parent.resourceId, parentRevision, now);
540
- this.insertEvent(parent.resourceId, parentRevision + 1, "run.queue_done_for_continuation", "session", options.originSessionId, { continuationRunId: options.runId }, now);
541
- }
542
- else if (parent.status === "done") {
543
- throw new Error("Continuation parent already has a reserved continuation");
544
551
  }
545
552
  else {
546
- throw new Error(`Continuation parent queue is ${parent.status}`);
553
+ if (options.parentTerminalFingerprint !== undefined ||
554
+ (options.restartNumber !== undefined && options.restartNumber !== parent.restartNumber)) {
555
+ throw new Error("Continuation lineage conflicts with restart metadata");
556
+ }
557
+ restartNumber = parent.restartNumber;
558
+ if (parent.status === "parked") {
559
+ const parentLease = this.requireLease(parent.resourceId);
560
+ if (parentLease.ownerId !== null) {
561
+ throw new Error("Continuation parent still has an active owner");
562
+ }
563
+ this.state.connection
564
+ .prepare(`UPDATE run_queue
565
+ SET status = 'done', updated_at = ?, finished_at = ?
566
+ WHERE run_id = ? AND status = 'parked'`)
567
+ .run(now, now, parent.runId);
568
+ const parentRevision = this.resourceRevision(parent.resourceId);
569
+ this.bumpResource(parent.resourceId, parentRevision, now);
570
+ this.insertEvent(parent.resourceId, parentRevision + 1, "run.queue_done_for_continuation", "session", options.originSessionId, { continuationRunId: options.runId }, now);
571
+ }
572
+ else if (parent.status === "done") {
573
+ throw new Error("Continuation parent already has a reserved continuation");
574
+ }
575
+ else {
576
+ throw new Error(`Continuation parent queue is ${parent.status}`);
577
+ }
547
578
  }
548
579
  }
580
+ else if (options.restartNumber !== undefined ||
581
+ options.parentTerminalFingerprint !== undefined) {
582
+ throw new Error("A root workflow run cannot declare restart metadata");
583
+ }
549
584
  const resourceId = resourceIdFor("run", options.runId);
550
585
  const definitionHash = this.state.putJson(options.definitionSnapshot, now);
551
586
  const queuedSource = queuedWorkflowSource(options.workflowSource);
@@ -566,11 +601,12 @@ export class SqliteControllerStore {
566
601
  .run(resourceId);
567
602
  this.state.connection
568
603
  .prepare(`INSERT INTO runs(
569
- run_id, resource_id, project_id, parent_run_id, definition_digest,
604
+ run_id, resource_id, project_id, parent_run_id, root_run_id, lineage_kind,
605
+ restart_number, parent_terminal_fingerprint, definition_digest,
570
606
  workflow_ref, launch_options_hash, status, paused,
571
607
  input_hash, created_at, updated_at
572
- ) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`)
573
- .run(options.runId, resourceId, this.requireProjectId(), options.parentRunId ?? null, definitionDigest, options.workflowSourceRef, launchHash, inputHash, now, now);
608
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`)
609
+ .run(options.runId, resourceId, this.requireProjectId(), options.parentRunId ?? null, rootRunId, lineageKind, restartNumber, parentTerminalFingerprint, definitionDigest, options.workflowSourceRef, launchHash, inputHash, now, now);
574
610
  initializeViewerRun(this.state, options.runId, now);
575
611
  insertQueuedRunSources(this.state, options.runId, queuedSource);
576
612
  this.state.connection
@@ -621,6 +657,11 @@ export class SqliteControllerStore {
621
657
  runnerId: options.runnerId,
622
658
  originSessionId: options.originSessionId,
623
659
  ...(options.parentRunId === undefined ? {} : { parentRunId: options.parentRunId }),
660
+ ...(options.lineageKind === undefined ? {} : { lineageKind: options.lineageKind }),
661
+ ...(options.restartNumber === undefined ? {} : { restartNumber: options.restartNumber }),
662
+ ...(options.parentTerminalFingerprint === undefined
663
+ ? {}
664
+ : { parentTerminalFingerprint: options.parentTerminalFingerprint }),
624
665
  ...(options.now === undefined ? {} : { now: options.now }),
625
666
  });
626
667
  }
@@ -736,17 +777,23 @@ export class SqliteControllerStore {
736
777
  .prepare(`SELECT i.request_id AS requestId, i.attempt_id AS attemptId
737
778
  FROM interactive_requests i
738
779
  JOIN node_attempts a ON a.attempt_id = i.attempt_id
739
- WHERE i.run_id = ? AND i.status IN ('pending', 'presenting')
780
+ JOIN runs r ON r.run_id = i.run_id
781
+ WHERE i.run_id = ? AND i.target_session_id = ?
782
+ AND i.status = 'pending' AND r.paused = 0
740
783
  AND a.deadline_at IS NOT NULL AND a.deadline_at <= ?
784
+ AND EXISTS (
785
+ SELECT 1 FROM workflow_messages m
786
+ JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id
787
+ WHERE m.source_id = i.request_id AND m.kind = 'step' AND t.state = 'started'
788
+ )
741
789
  ORDER BY a.deadline_at, i.request_id LIMIT 1`)
742
- .get(options.runId, now);
790
+ .get(options.runId, options.targetSessionId, now);
743
791
  if (!isExpiredInteractionRow(interaction))
744
792
  return false;
745
793
  const request = this.state.connection
746
794
  .prepare(`UPDATE interactive_requests
747
- SET status = 'cancelled', presenter_id = NULL,
748
- presentation_claim_expires_at = NULL, revision = revision + 1, updated_at = ?
749
- WHERE request_id = ? AND run_id = ? AND status IN ('pending', 'presenting')`)
795
+ SET status = 'cancelled', revision = revision + 1, updated_at = ?
796
+ WHERE request_id = ? AND run_id = ? AND status = 'pending'`)
750
797
  .run(now, interaction.requestId, options.runId);
751
798
  const run = this.state.connection
752
799
  .prepare(`UPDATE runs
@@ -763,7 +810,7 @@ export class SqliteControllerStore {
763
810
  if (request.changes !== 1 || run.changes !== 1 || queue.changes !== 1) {
764
811
  throw new Error(`Workflow run ${options.runId} changed during interaction timeout`);
765
812
  }
766
- const error = "Workflow node deadline expired before origin-session input arrived";
813
+ const error = "Workflow node model-turn deadline expired";
767
814
  const receiptHash = this.state.putJson({ status: "rejected", error }, now);
768
815
  this.state.connection
769
816
  .prepare(`UPDATE interactive_submissions SET outcome = 'rejected', receipt_hash = ?
@@ -788,8 +835,8 @@ export class SqliteControllerStore {
788
835
  "(l.owner_id IS NULL OR l.expires_at <= ? OR l.owner_id = ?)",
789
836
  "(q.affinity_runner_id IS NULL OR q.affinity_runner_id = ? OR q.status IN ('parked', 'starting', 'running'))",
790
837
  "NOT (q.status = 'parked' AND (r.status = 'waiting' OR r.paused = 1))",
791
- "NOT EXISTS (SELECT 1 FROM interactive_requests i WHERE i.run_id = r.run_id AND i.status IN ('pending', 'presenting'))",
792
- "(q.error_code IS NULL OR q.error_code <> 'workflowSourceChanged')",
838
+ "NOT EXISTS (SELECT 1 FROM interactive_requests i WHERE i.run_id = r.run_id AND i.status = 'pending')",
839
+ "(q.error_code IS NULL OR q.error_code NOT IN ('workflowSourceChanged', 'workerNoProgress'))",
793
840
  ];
794
841
  const params = [now, now, options.runnerId, options.runnerId];
795
842
  if (this.projectId !== null) {
@@ -863,6 +910,50 @@ export class SqliteControllerStore {
863
910
  parkWorkflowRun(options) {
864
911
  return this.releaseRunClaim(options.runId, options.claimToken, "parked", options.now);
865
912
  }
913
+ parkWorkflowRunForWorkerNoProgress(options) {
914
+ const now = epoch(validTimestamp(options.now));
915
+ return this.state.transaction(() => {
916
+ if (!this.verifyWorkflowRunClaim({
917
+ runId: options.runId,
918
+ claimToken: options.claimToken,
919
+ now: new Date(now).toISOString(),
920
+ })) {
921
+ return false;
922
+ }
923
+ const row = this.requireWorkflowRunRow(options.runId);
924
+ const lease = this.requireLease(row.resourceId);
925
+ const detail = options.detail.slice(0, 8_192);
926
+ const errorHash = this.state.putText(detail, now);
927
+ const run = this.state.connection
928
+ .prepare(`UPDATE runs SET status_detail = ?, updated_at = ?, finished_at = NULL
929
+ WHERE run_id = ? AND status NOT IN ('completed', 'failed', 'timed_out', 'cancelled')`)
930
+ .run(detail, now, options.runId);
931
+ const queue = this.state.connection
932
+ .prepare(`UPDATE run_queue
933
+ SET status = 'parked', error_code = 'workerNoProgress', error_hash = ?,
934
+ available_at = ?, updated_at = ?, finished_at = NULL
935
+ WHERE run_id = ? AND status IN ('queued', 'starting', 'running', 'parked')`)
936
+ .run(errorHash, now, now, options.runId);
937
+ if (run.changes !== 1 || queue.changes !== 1) {
938
+ throw new Error(`Workflow run ${options.runId} changed during worker recovery`);
939
+ }
940
+ const released = this.state.connection
941
+ .prepare(`UPDATE leases
942
+ SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
943
+ acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
944
+ WHERE resource_id = ? AND token_hash = ? AND generation = ? AND expires_at > ?`)
945
+ .run(row.resourceId, tokenHash(options.claimToken), lease.generation, now);
946
+ /* istanbul ignore if -- the exact live claim is stable in this transaction */
947
+ if (released.changes !== 1) {
948
+ throw new Error(`Workflow run ${options.runId} claim changed during worker recovery`);
949
+ }
950
+ const revision = this.resourceRevision(row.resourceId);
951
+ this.bumpResource(row.resourceId, revision, now);
952
+ this.insertEvent(row.resourceId, revision + 1, "run.worker_no_progress", lease.ownerType ?? "system", lease.ownerId, { status: "parked", code: "workerNoProgress" }, now, lease.generation || undefined);
953
+ recordViewerDeltas(this.state, options.runId, [{ targetType: "summary" }, { targetType: "replay" }], now);
954
+ return true;
955
+ });
956
+ }
866
957
  pauseParkedWorkflowRun(options) {
867
958
  const now = epoch(validTimestamp(options.now));
868
959
  return this.state.transaction(() => {
@@ -874,7 +965,7 @@ export class SqliteControllerStore {
874
965
  return false;
875
966
  const pending = this.state.connection
876
967
  .prepare(`SELECT 1 FROM interactive_requests
877
- WHERE run_id = ? AND status IN ('pending', 'presenting') LIMIT 1`)
968
+ WHERE run_id = ? AND status = 'pending' LIMIT 1`)
878
969
  .get(options.runId);
879
970
  if (pending === undefined)
880
971
  return false;
@@ -910,7 +1001,7 @@ export class SqliteControllerStore {
910
1001
  return false;
911
1002
  const pending = this.state.connection
912
1003
  .prepare(`SELECT 1 FROM interactive_requests
913
- WHERE run_id = ? AND status IN ('pending', 'presenting') LIMIT 1`)
1004
+ WHERE run_id = ? AND status = 'pending' LIMIT 1`)
914
1005
  .get(options.runId);
915
1006
  if (pending === undefined)
916
1007
  return false;
@@ -1081,6 +1172,7 @@ export class SqliteControllerStore {
1081
1172
  if (run.changes !== 1) {
1082
1173
  throw new Error(`Workflow run ${options.runId} has inconsistent durable state`);
1083
1174
  }
1175
+ this.settleWorkflowRunMessages(options.runId, now);
1084
1176
  this.cancelWorkflowRunDependents(options.runId, controlId, errorHash, now);
1085
1177
  const released = this.state.connection
1086
1178
  .prepare(`UPDATE leases
@@ -1170,215 +1262,6 @@ export class SqliteControllerStore {
1170
1262
  payload: row.payloadHash === null ? {} : this.state.readJson(row.payloadHash),
1171
1263
  }));
1172
1264
  }
1173
- ensureWorkflowTurnIntent(options) {
1174
- const existing = this.turnIntentBySource(options.sourceEventId);
1175
- if (existing !== undefined)
1176
- return existing;
1177
- const now = epoch(validTimestamp(options.requestedAt));
1178
- const run = this.requireWorkflowRunRow(options.runId);
1179
- const intentId = options.intentId;
1180
- const resourceId = resourceIdFor("turn_intent", intentId);
1181
- const effectId = `effect-${randomUUID()}`;
1182
- this.state.transaction(() => {
1183
- const factsHash = this.state.putJson(options.fallbackFacts, now);
1184
- this.insertEffectResource(effectId, resourceIdFor("effect", effectId), run.resourceId, this.resourceRevision(run.resourceId), "turn.deliver", intentId, factsHash, "run", now);
1185
- this.state.connection
1186
- .prepare("INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at) VALUES (?, 'turn_intent', ?, 1, ?, ?)")
1187
- .run(resourceId, intentId, now, now);
1188
- this.state.connection
1189
- .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
1190
- .run(resourceId);
1191
- this.state.connection
1192
- .prepare(`INSERT INTO turn_intents(
1193
- turn_intent_id, resource_id, effect_id, source_event_id, run_id,
1194
- workflow_ref, target_session_id, cause, node_id, attempt_id,
1195
- facts_hash, requested_at, eligible_at, created_at
1196
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1197
- .run(intentId, resourceId, effectId, options.sourceEventId, options.runId, options.workflowRef, options.targetSessionId, options.cause, options.nodeId ?? null, options.attemptId ?? null, factsHash, now, options.eligible ? now : null, now);
1198
- this.insertEvent(resourceId, 1, "turn.requested", "session", options.targetSessionId, {}, now);
1199
- });
1200
- return this.requireTurnIntent(intentId);
1201
- }
1202
- getWorkflowTurnIntent(intentId) {
1203
- return this.turnIntent(intentId);
1204
- }
1205
- findPendingWorkflowTurnIntent(options) {
1206
- const row = this.state.connection
1207
- .prepare(turnIntentSelect("WHERE t.run_id = ? AND t.target_session_id = ? AND t.resolved_at IS NULL ORDER BY t.requested_at DESC LIMIT 1"))
1208
- .get(options.runId, options.targetSessionId);
1209
- return isTurnIntentRow(row) ? this.mapTurnIntent(row) : undefined;
1210
- }
1211
- isWorkflowTurnIntentClaimLive(options) {
1212
- const row = this.turnIntentRow(options.intentId);
1213
- return (row !== undefined &&
1214
- row.targetSessionId === options.targetSessionId &&
1215
- row.resolvedAt === null &&
1216
- this.verifyEffectToken(row.effectId, options.claimToken, options.now));
1217
- }
1218
- claimWorkflowTurnIntent(options) {
1219
- const intent = this.turnIntent(options.intentId);
1220
- if (intent?.targetSessionId !== options.targetSessionId)
1221
- return undefined;
1222
- return this.claimEffectForTurn(options.intentId, options.claimToken, options.leaseMs, options.now)
1223
- ? this.requireTurnIntent(options.intentId)
1224
- : undefined;
1225
- }
1226
- claimEligibleWorkflowTurnIntents(options) {
1227
- const now = epoch(validTimestamp(options.now));
1228
- const rows = this.state.connection
1229
- .prepare(turnIntentSelect("WHERE t.target_session_id = ? AND t.resolved_at IS NULL AND t.eligible_at IS NOT NULL AND t.eligible_at <= ? ORDER BY t.eligible_at LIMIT ?"))
1230
- .all(options.targetSessionId, now, options.limit ?? 10);
1231
- const claimed = [];
1232
- for (const row of rows) {
1233
- if (isTurnIntentRow(row) &&
1234
- this.claimEffectForTurn(row.intentId, options.claimToken, options.leaseMs, options.now)) {
1235
- claimed.push(this.requireTurnIntent(row.intentId));
1236
- }
1237
- }
1238
- return claimed;
1239
- }
1240
- hasClaimableWorkflowTurnIntent(options) {
1241
- const now = epoch(validTimestamp(options.now));
1242
- const row = this.state.connection
1243
- .prepare(`SELECT 1 AS present
1244
- FROM turn_intents t
1245
- JOIN effects e ON e.effect_id = t.effect_id
1246
- JOIN leases l ON l.resource_id = e.resource_id
1247
- WHERE t.target_session_id = ? AND t.resolved_at IS NULL
1248
- AND t.eligible_at IS NOT NULL AND t.eligible_at <= ?
1249
- AND e.status = 'pending'
1250
- AND (l.owner_id IS NULL OR l.expires_at IS NULL OR l.expires_at <= ?)
1251
- LIMIT 1`)
1252
- .get(options.targetSessionId, now, now);
1253
- return row !== undefined;
1254
- }
1255
- makeWorkflowTurnIntentEligible(options) {
1256
- const eligibleAt = epoch(validTimestamp(options.eligibleAt ?? options.now));
1257
- const factsHash = this.state.putJson(options.fallbackFacts, eligibleAt);
1258
- return (this.state.connection
1259
- .prepare("UPDATE turn_intents SET eligible_at = ?, facts_hash = ? WHERE turn_intent_id = ? AND resolved_at IS NULL")
1260
- .run(eligibleAt, factsHash, options.intentId).changes === 1);
1261
- }
1262
- resolveWorkflowTurnIntent(options) {
1263
- const now = epoch(validTimestamp(options.now));
1264
- return this.state.transaction(() => {
1265
- const row = this.turnIntentRow(options.intentId);
1266
- if (row === undefined ||
1267
- row.targetSessionId !== options.targetSessionId ||
1268
- !this.verifyEffectToken(row.effectId, options.claimToken, options.now)) {
1269
- return false;
1270
- }
1271
- const result = this.state.connection
1272
- .prepare(`UPDATE turn_intents SET resolved_at = ?, resolution_type = ?, resolution_message_id = ?
1273
- WHERE turn_intent_id = ? AND resolved_at IS NULL`)
1274
- .run(now, options.resolution, options.messageId ?? null, options.intentId);
1275
- if (result.changes === 1)
1276
- this.completeEffect(row.effectId, "applied", now);
1277
- return result.changes === 1;
1278
- });
1279
- }
1280
- releaseWorkflowTurnIntentClaim(options) {
1281
- const row = this.turnIntentRow(options.intentId);
1282
- return (row !== undefined &&
1283
- row.targetSessionId === options.targetSessionId &&
1284
- this.releaseEffectLease(row.effectId, options.claimToken));
1285
- }
1286
- listWorkflowTurnIntents(options = {}) {
1287
- const clauses = ["1 = 1"];
1288
- const params = [];
1289
- if (options.runId !== undefined) {
1290
- clauses.push("t.run_id = ?");
1291
- params.push(options.runId);
1292
- }
1293
- if (options.targetSessionId !== undefined) {
1294
- clauses.push("t.target_session_id = ?");
1295
- params.push(options.targetSessionId);
1296
- }
1297
- if (options.unresolvedOnly === true)
1298
- clauses.push("t.resolved_at IS NULL");
1299
- params.push(options.limit ?? 100);
1300
- const rows = this.state.connection
1301
- .prepare(turnIntentSelect(`WHERE ${clauses.join(" AND ")} ORDER BY t.requested_at DESC LIMIT ?`))
1302
- .all(...params);
1303
- return rows.filter(isTurnIntentRow).map((row) => this.mapTurnIntent(row));
1304
- }
1305
- enqueueWorkflowNotification(options) {
1306
- const existing = this.notification(options.runId, options.attemptId, options.notificationIndex);
1307
- if (existing !== undefined)
1308
- return existing;
1309
- const run = this.requireWorkflowRunRow(options.runId);
1310
- const now = epoch(validTimestamp(options.now));
1311
- const notificationId = `notification-${randomUUID()}`;
1312
- const resourceId = resourceIdFor("notification", notificationId);
1313
- const effectId = `effect-${randomUUID()}`;
1314
- this.state.transaction(() => {
1315
- const contentHash = this.state.putText(options.content, now);
1316
- this.insertEffectResource(effectId, resourceIdFor("effect", effectId), run.resourceId, this.resourceRevision(run.resourceId), "notification.deliver", notificationId, contentHash, "run", now);
1317
- this.state.connection
1318
- .prepare("INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at) VALUES (?, 'notification', ?, 1, ?, ?)")
1319
- .run(resourceId, notificationId, now, now);
1320
- this.state.connection
1321
- .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
1322
- .run(resourceId);
1323
- this.state.connection
1324
- .prepare(`INSERT INTO notifications(
1325
- notification_id, effect_id, run_id, attempt_id, notification_index,
1326
- target_session_id, notification_type, content_hash, created_at
1327
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1328
- .run(notificationId, effectId, options.runId, options.attemptId, options.notificationIndex, options.targetSessionId, options.kind, contentHash, now);
1329
- this.insertEvent(resourceId, 1, "notification.queued", "session", options.targetSessionId, {}, now);
1330
- });
1331
- return this.requireNotification(options.runId, options.attemptId, options.notificationIndex);
1332
- }
1333
- hasClaimableWorkflowNotification(options) {
1334
- const now = epoch(validTimestamp(options.now));
1335
- const row = this.state.connection
1336
- .prepare(`SELECT 1 AS present
1337
- FROM notifications n
1338
- JOIN effects e ON e.effect_id = n.effect_id
1339
- JOIN leases l ON l.resource_id = e.resource_id
1340
- WHERE n.target_session_id = ? AND e.status = 'pending'
1341
- AND (l.owner_id IS NULL OR l.expires_at IS NULL OR l.expires_at <= ?)
1342
- LIMIT 1`)
1343
- .get(options.targetSessionId, now);
1344
- return row !== undefined;
1345
- }
1346
- listPendingWorkflowNotifications(options) {
1347
- const now = Date.now();
1348
- return this.notificationRows(options.targetSessionId, options.limit ?? 20)
1349
- .map((row) => this.mapNotification(row))
1350
- .filter((notification) => notification.deliveryClaimExpiresAt === null ||
1351
- Date.parse(notification.deliveryClaimExpiresAt) <= now);
1352
- }
1353
- claimPendingWorkflowNotifications(options) {
1354
- const rows = this.notificationRows(options.targetSessionId, options.limit ?? 20);
1355
- const result = [];
1356
- for (const row of rows) {
1357
- if (this.claimEffect(row.effectId, options.targetSessionId, options.claimToken, options.leaseMs, options.now)) {
1358
- result.push(this.mapNotification(row));
1359
- }
1360
- }
1361
- return result;
1362
- }
1363
- isWorkflowNotificationClaimLive(options) {
1364
- const row = this.notificationRowById(options.notificationId);
1365
- return (row !== undefined &&
1366
- row.targetSessionId === options.targetSessionId &&
1367
- this.verifyEffectToken(row.effectId, options.claimToken, options.now));
1368
- }
1369
- markWorkflowNotificationDelivered(options) {
1370
- const now = epoch(validTimestamp(options.now));
1371
- return this.state.transaction(() => {
1372
- const row = this.notificationRowById(options.notificationId);
1373
- if (row === undefined ||
1374
- row.targetSessionId !== options.targetSessionId ||
1375
- !this.verifyEffectToken(row.effectId, options.claimToken, options.now)) {
1376
- return false;
1377
- }
1378
- this.completeEffect(row.effectId, "applied", now);
1379
- return true;
1380
- });
1381
- }
1382
1265
  settleRunEffect(runId, effectType) {
1383
1266
  const rows = this.state.connection
1384
1267
  .prepare(`SELECT e.effect_id AS effectId, e.resource_id AS resourceId
@@ -1722,6 +1605,17 @@ export class SqliteControllerStore {
1722
1605
  return this.mapWorkflowRun(this.requireWorkflowRunRow(runId));
1723
1606
  }
1724
1607
  assertWorkflowRunPreparationCompatible(row, options, definitionDigest) {
1608
+ const expectedLineageKind = options.parentRunId === undefined ? null : (options.lineageKind ?? "continuation");
1609
+ const parent = options.parentRunId === undefined
1610
+ ? undefined
1611
+ : this.requireWorkflowRunRow(options.parentRunId);
1612
+ const expectedRootRunId = parent?.rootRunId ?? options.runId;
1613
+ const expectedRestartNumber = expectedLineageKind === "restart"
1614
+ ? (options.restartNumber ?? (parent?.restartNumber ?? -1) + 1)
1615
+ : (parent?.restartNumber ?? 0);
1616
+ const expectedParentTerminalFingerprint = options.parentTerminalFingerprint === undefined
1617
+ ? null
1618
+ : terminalFingerprintBuffer(options.parentTerminalFingerprint);
1725
1619
  const compatible = row.workflowName === options.workflowName &&
1726
1620
  row.workflowRef === options.workflowSourceRef &&
1727
1621
  row.definitionDigest.equals(definitionDigest) &&
@@ -1732,7 +1626,14 @@ export class SqliteControllerStore {
1732
1626
  canonicalJson(options.launchOptions ?? {}) &&
1733
1627
  row.originSessionId === options.originSessionId &&
1734
1628
  row.executionMode === (options.executionMode ?? "interactive") &&
1735
- row.parentRunId === (options.parentRunId ?? null);
1629
+ row.parentRunId === (options.parentRunId ?? null) &&
1630
+ row.rootRunId === expectedRootRunId &&
1631
+ row.lineageKind === expectedLineageKind &&
1632
+ row.restartNumber === expectedRestartNumber &&
1633
+ ((row.parentTerminalFingerprint === null && expectedParentTerminalFingerprint === null) ||
1634
+ (row.parentTerminalFingerprint !== null &&
1635
+ expectedParentTerminalFingerprint !== null &&
1636
+ row.parentTerminalFingerprint.equals(expectedParentTerminalFingerprint)));
1736
1637
  if (!compatible) {
1737
1638
  throw new Error(`Workflow run preparation conflicts: ${options.runId}`);
1738
1639
  }
@@ -1763,6 +1664,10 @@ export class SqliteControllerStore {
1763
1664
  originSessionId: row.executionMode === "headless" ? null : row.originSessionId,
1764
1665
  executionMode: row.executionMode,
1765
1666
  parentRunId: row.parentRunId,
1667
+ rootRunId: row.rootRunId,
1668
+ lineageKind: row.lineageKind,
1669
+ restartNumber: row.restartNumber,
1670
+ parentTerminalFingerprint: row.parentTerminalFingerprint?.toString("hex") ?? null,
1766
1671
  errorCode: row.errorCode,
1767
1672
  errorMessage: row.errorHash === null
1768
1673
  ? null
@@ -1783,7 +1688,7 @@ export class SqliteControllerStore {
1783
1688
  if (options.allowPendingInteraction !== true &&
1784
1689
  this.state.connection
1785
1690
  .prepare(`SELECT 1 FROM interactive_requests
1786
- WHERE run_id = ? AND status IN ('pending', 'presenting') LIMIT 1`)
1691
+ WHERE run_id = ? AND status = 'pending' LIMIT 1`)
1787
1692
  .get(runId) !== undefined) {
1788
1693
  return undefined;
1789
1694
  }
@@ -1912,6 +1817,7 @@ export class SqliteControllerStore {
1912
1817
  updated_at = ?, finished_at = ?
1913
1818
  WHERE run_id = ?`)
1914
1819
  .run(status, errorHash, now, now, runId);
1820
+ this.settleWorkflowRunMessages(runId, now);
1915
1821
  if (status === "cancelled") {
1916
1822
  this.cancelWorkflowRunDependents(runId, lease.ownerId ?? "workflow-control", errorHash, now);
1917
1823
  }
@@ -1921,6 +1827,10 @@ export class SqliteControllerStore {
1921
1827
  return true;
1922
1828
  });
1923
1829
  }
1830
+ settleWorkflowRunMessages(runId, now) {
1831
+ this.workflowMessages.settleOpenTurnsForRun(runId, "lost", now);
1832
+ this.workflowMessages.cancelPendingForRun(runId, now);
1833
+ }
1924
1834
  cancelWorkflowRunDependents(runId, actorId, errorHash, now) {
1925
1835
  this.state.connection
1926
1836
  .prepare(`UPDATE node_attempts
@@ -1977,165 +1887,10 @@ export class SqliteControllerStore {
1977
1887
  .run(receiptHash, runId);
1978
1888
  this.state.connection
1979
1889
  .prepare(`UPDATE interactive_requests
1980
- SET status = 'cancelled', presenter_id = NULL,
1981
- presentation_claim_expires_at = NULL, revision = revision + 1, updated_at = ?
1982
- WHERE run_id = ? AND status IN ('pending', 'presenting')`)
1890
+ SET status = 'cancelled', revision = revision + 1, updated_at = ?
1891
+ WHERE run_id = ? AND status = 'pending'`)
1983
1892
  .run(now, runId);
1984
1893
  }
1985
- turnIntent(intentId) {
1986
- const row = this.turnIntentRow(intentId);
1987
- return row === undefined ? undefined : this.mapTurnIntent(row);
1988
- }
1989
- requireTurnIntent(intentId) {
1990
- const row = this.turnIntent(intentId);
1991
- if (row === undefined)
1992
- throw new Error(`Workflow turn intent not found: ${intentId}`);
1993
- return row;
1994
- }
1995
- turnIntentRow(intentId) {
1996
- const row = this.state.connection
1997
- .prepare(turnIntentSelect("WHERE t.turn_intent_id = ?"))
1998
- .get(intentId);
1999
- return isTurnIntentRow(row) ? row : undefined;
2000
- }
2001
- turnIntentBySource(sourceEventId) {
2002
- const row = this.state.connection
2003
- .prepare(turnIntentSelect("WHERE t.source_event_id = ?"))
2004
- .get(sourceEventId);
2005
- return isTurnIntentRow(row) ? this.mapTurnIntent(row) : undefined;
2006
- }
2007
- /* istanbul ignore next -- pure projection covered by integration tests */
2008
- mapTurnIntent(row) {
2009
- return {
2010
- intentId: row.intentId,
2011
- sourceEventId: row.sourceEventId,
2012
- runId: row.runId,
2013
- workflowRef: row.workflowRef,
2014
- targetSessionId: row.targetSessionId,
2015
- cause: row.cause,
2016
- nodeId: row.nodeId,
2017
- attemptId: row.attemptId,
2018
- fallbackFacts: this.state.readJson(row.factsHash),
2019
- requestedAt: new Date(row.requestedAt).toISOString(),
2020
- eligibleAt: row.eligibleAt === null ? null : new Date(row.eligibleAt).toISOString(),
2021
- resolvedAt: row.resolvedAt === null ? null : new Date(row.resolvedAt).toISOString(),
2022
- resolution: row.resolution,
2023
- resolutionMessageId: row.resolutionMessageId,
2024
- deliveryClaimExpiresAt: row.claimExpiresAt === null ? null : new Date(row.claimExpiresAt).toISOString(),
2025
- };
2026
- }
2027
- claimEffectForTurn(intentId, token, leaseMs, nowValue) {
2028
- const row = this.turnIntentRow(intentId);
2029
- return (row !== undefined &&
2030
- this.claimEffect(row.effectId, row.targetSessionId, token, leaseMs, nowValue));
2031
- }
2032
- claimEffect(effectId, ownerId, token, leaseMs, nowValue) {
2033
- const now = epoch(validTimestamp(nowValue));
2034
- return this.state.transaction(() => {
2035
- const row = this.state.connection
2036
- .prepare("SELECT resource_id AS resourceId FROM effects WHERE effect_id = ? AND status = 'pending'")
2037
- .get(effectId);
2038
- /* istanbul ignore if -- impossible after exact schema and transaction checks */
2039
- if (!isResourceIdRow(row))
2040
- return false;
2041
- const lease = this.requireLease(row.resourceId);
2042
- if (lease.ownerId !== null && lease.expiresAt !== null && lease.expiresAt > now)
2043
- return false;
2044
- return (this.state.connection
2045
- .prepare(`UPDATE leases SET generation = ?, owner_type = 'session', owner_id = ?, token_hash = ?,
2046
- acquired_at = ?, heartbeat_at = ?, expires_at = ?
2047
- WHERE resource_id = ? AND generation = ?`)
2048
- .run(lease.generation + 1, ownerId, tokenHash(token), now, now, now + leaseMs, row.resourceId, lease.generation).changes === 1);
2049
- });
2050
- }
2051
- verifyEffectToken(effectId, token, nowValue) {
2052
- const now = epoch(validTimestamp(nowValue));
2053
- const row = this.state.connection
2054
- .prepare(`SELECT l.token_hash AS tokenHash, l.expires_at AS expiresAt
2055
- FROM effects e JOIN leases l ON l.resource_id = e.resource_id
2056
- WHERE e.effect_id = ?`)
2057
- .get(effectId);
2058
- return (isEffectTokenRow(row) &&
2059
- row.tokenHash !== null &&
2060
- row.tokenHash.equals(tokenHash(token)) &&
2061
- row.expiresAt !== null &&
2062
- row.expiresAt > now);
2063
- }
2064
- releaseEffectLease(effectId, token) {
2065
- return (this.state.connection
2066
- .prepare(`UPDATE leases SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
2067
- acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
2068
- WHERE resource_id = (SELECT resource_id FROM effects WHERE effect_id = ?)
2069
- AND token_hash = ?`)
2070
- .run(effectId, tokenHash(token)).changes === 1);
2071
- }
2072
- completeEffect(effectId, status, now) {
2073
- this.state.connection
2074
- .prepare("UPDATE effects SET status = ?, updated_at = ?, settled_at = ? WHERE effect_id = ?")
2075
- .run(status, now, now, effectId);
2076
- this.state.connection
2077
- .prepare(`UPDATE leases SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
2078
- acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
2079
- WHERE resource_id = (SELECT resource_id FROM effects WHERE effect_id = ?)`)
2080
- .run(effectId);
2081
- }
2082
- insertEffectResource(effectId, resourceId, sourceResourceId, sourceRevision, type, key, payloadHash, scope, now) {
2083
- this.state.connection
2084
- .prepare("INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at) VALUES (?, 'effect', ?, 1, ?, ?)")
2085
- .run(resourceId, effectId, now, now);
2086
- this.state.connection
2087
- .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
2088
- .run(resourceId);
2089
- this.state.connection
2090
- .prepare(`INSERT INTO effects(
2091
- effect_id, resource_id, source_resource_id, source_revision, effect_type,
2092
- idempotency_key, payload_hash, owner_scope, status, attempt_count,
2093
- created_at, updated_at
2094
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)`)
2095
- .run(effectId, resourceId, sourceResourceId, sourceRevision, type, key, payloadHash, scope, now, now);
2096
- this.insertEvent(resourceId, 1, "effect.created", "system", null, {}, now);
2097
- }
2098
- notification(runId, attemptId, index) {
2099
- const row = this.state.connection
2100
- .prepare(notificationSelect("WHERE n.run_id = ? AND n.attempt_id = ? AND n.notification_index = ?"))
2101
- .get(runId, attemptId, index);
2102
- return isNotificationRow(row) ? this.mapNotification(row) : undefined;
2103
- }
2104
- requireNotification(runId, attemptId, index) {
2105
- const record = this.notification(runId, attemptId, index);
2106
- /* istanbul ignore if -- impossible after exact schema and transaction checks */
2107
- if (record === undefined)
2108
- throw new Error("Workflow notification was not stored");
2109
- return record;
2110
- }
2111
- notificationRows(sessionId, limit) {
2112
- return this.state.connection
2113
- .prepare(notificationSelect("WHERE n.target_session_id = ? AND e.status = 'pending' ORDER BY n.created_at LIMIT ?"))
2114
- .all(sessionId, limit)
2115
- .filter(isNotificationRow);
2116
- }
2117
- notificationRowById(id) {
2118
- const row = this.state.connection
2119
- .prepare(notificationSelect("WHERE n.notification_id = ?"))
2120
- .get(id);
2121
- return isNotificationRow(row) ? row : undefined;
2122
- }
2123
- /* istanbul ignore next -- pure projection covered by integration tests */
2124
- mapNotification(row) {
2125
- return {
2126
- notificationId: row.notificationId,
2127
- runId: row.runId,
2128
- nodeId: row.nodeId,
2129
- attemptId: row.attemptId,
2130
- notificationIndex: row.notificationIndex,
2131
- targetSessionId: row.targetSessionId,
2132
- kind: row.kind,
2133
- content: this.state.readBlob(row.contentHash)?.content.toString("utf8") ?? "",
2134
- createdAt: new Date(row.createdAt).toISOString(),
2135
- deliveryClaimExpiresAt: row.claimExpiresAt === null ? null : new Date(row.claimExpiresAt).toISOString(),
2136
- deliveredAt: row.deliveredAt === null ? null : new Date(row.deliveredAt).toISOString(),
2137
- };
2138
- }
2139
1894
  assertWritable() {
2140
1895
  if (this.state.mode === "read-only")
2141
1896
  throw new Error("Controller store is read-only");
@@ -2177,6 +1932,9 @@ function workflowRunSelect(clause) {
2177
1932
  q.consecutive_errors AS consecutiveErrors, q.error_code AS errorCode,
2178
1933
  q.error_hash AS errorHash, b.origin_session_id AS originSessionId,
2179
1934
  b.execution_mode AS executionMode, r.parent_run_id AS parentRunId,
1935
+ r.root_run_id AS rootRunId, r.lineage_kind AS lineageKind,
1936
+ r.restart_number AS restartNumber,
1937
+ r.parent_terminal_fingerprint AS parentTerminalFingerprint,
2180
1938
  q.created_at AS createdAt, q.updated_at AS updatedAt,
2181
1939
  q.started_at AS startedAt, q.finished_at AS finishedAt,
2182
1940
  l.generation AS leaseGeneration, l.owner_id AS ownerId, l.expires_at AS claimExpiresAt
@@ -2190,8 +1948,10 @@ function workflowRunViewSelect(clause) {
2190
1948
  r.workflow_ref AS workflowRef, r.status AS runStatus, r.paused,
2191
1949
  r.definition_digest AS definitionDigest, q.status,
2192
1950
  b.origin_session_id AS originSessionId, b.execution_mode AS executionMode,
2193
- r.parent_run_id AS parentRunId, q.error_code AS errorCode,
2194
- CAST(error.content AS TEXT) AS errorMessage,
1951
+ r.parent_run_id AS parentRunId, r.root_run_id AS rootRunId,
1952
+ r.lineage_kind AS lineageKind, r.restart_number AS restartNumber,
1953
+ r.parent_terminal_fingerprint AS parentTerminalFingerprint,
1954
+ q.error_code AS errorCode, CAST(error.content AS TEXT) AS errorMessage,
2195
1955
  q.created_at AS createdAt, q.updated_at AS updatedAt,
2196
1956
  q.started_at AS startedAt, q.finished_at AS finishedAt,
2197
1957
  source.source_type AS sourceType, source.source_ref AS sourceRef,
@@ -2202,29 +1962,6 @@ function workflowRunViewSelect(clause) {
2202
1962
  LEFT JOIN run_sources source ON source.run_id = r.run_id AND source.mount_path = ''
2203
1963
  LEFT JOIN blobs error ON error.blob_hash = q.error_hash ${clause}`;
2204
1964
  }
2205
- function turnIntentSelect(clause) {
2206
- return `SELECT t.turn_intent_id AS intentId, t.source_event_id AS sourceEventId,
2207
- t.run_id AS runId, t.workflow_ref AS workflowRef,
2208
- t.target_session_id AS targetSessionId, t.cause,
2209
- t.node_id AS nodeId, t.attempt_id AS attemptId, t.facts_hash AS factsHash,
2210
- t.requested_at AS requestedAt, t.eligible_at AS eligibleAt,
2211
- t.resolved_at AS resolvedAt, t.resolution_type AS resolution,
2212
- t.resolution_message_id AS resolutionMessageId, l.expires_at AS claimExpiresAt,
2213
- t.effect_id AS effectId
2214
- FROM turn_intents t JOIN effects e ON e.effect_id = t.effect_id
2215
- JOIN leases l ON l.resource_id = e.resource_id ${clause}`;
2216
- }
2217
- function notificationSelect(clause) {
2218
- return `SELECT n.notification_id AS notificationId, n.effect_id AS effectId,
2219
- n.run_id AS runId, a.node_id AS nodeId, n.attempt_id AS attemptId,
2220
- n.notification_index AS notificationIndex, n.target_session_id AS targetSessionId,
2221
- n.notification_type AS kind, n.content_hash AS contentHash,
2222
- n.created_at AS createdAt, l.expires_at AS claimExpiresAt,
2223
- e.settled_at AS deliveredAt
2224
- FROM notifications n JOIN effects e ON e.effect_id = n.effect_id
2225
- JOIN leases l ON l.resource_id = e.resource_id
2226
- JOIN node_attempts a ON a.attempt_id = n.attempt_id ${clause}`;
2227
- }
2228
1965
  function isRecord(value) {
2229
1966
  return typeof value === "object" && value !== null && !Array.isArray(value);
2230
1967
  }
@@ -2251,6 +1988,10 @@ function workflowRunViewRecord(row) {
2251
1988
  originSessionId: row.executionMode === "headless" ? null : row.originSessionId,
2252
1989
  executionMode: row.executionMode,
2253
1990
  parentRunId: row.parentRunId,
1991
+ rootRunId: row.rootRunId,
1992
+ lineageKind: row.lineageKind,
1993
+ restartNumber: row.restartNumber,
1994
+ parentTerminalFingerprint: row.parentTerminalFingerprint?.toString("hex") ?? null,
2254
1995
  errorCode: row.errorCode,
2255
1996
  errorMessage: row.errorMessage,
2256
1997
  createdAt: new Date(row.createdAt).toISOString(),
@@ -2271,6 +2012,13 @@ function isWorkflowRunViewRow(value) {
2271
2012
  (value.originSessionId === null || typeof value.originSessionId === "string") &&
2272
2013
  (value.executionMode === "interactive" || value.executionMode === "headless") &&
2273
2014
  (value.parentRunId === null || typeof value.parentRunId === "string") &&
2015
+ typeof value.rootRunId === "string" &&
2016
+ (value.lineageKind === null ||
2017
+ value.lineageKind === "continuation" ||
2018
+ value.lineageKind === "restart") &&
2019
+ typeof value.restartNumber === "number" &&
2020
+ (value.parentTerminalFingerprint === null ||
2021
+ Buffer.isBuffer(value.parentTerminalFingerprint)) &&
2274
2022
  (value.errorCode === null || typeof value.errorCode === "string") &&
2275
2023
  (value.errorMessage === null || typeof value.errorMessage === "string") &&
2276
2024
  typeof value.createdAt === "number" &&
@@ -2318,12 +2066,6 @@ function isControllerEventRow(value) {
2318
2066
  function isRunEventRow(value) {
2319
2067
  return isRecord(value);
2320
2068
  }
2321
- function isTurnIntentRow(value) {
2322
- return isRecord(value);
2323
- }
2324
- function isNotificationRow(value) {
2325
- return isRecord(value);
2326
- }
2327
2069
  function isProjectRow(value) {
2328
2070
  return isRecord(value);
2329
2071
  }
@@ -2348,12 +2090,6 @@ function isSequenceRow(value) {
2348
2090
  function isKeyRow(value) {
2349
2091
  return isRecord(value);
2350
2092
  }
2351
- function isResourceIdRow(value) {
2352
- return isRecord(value);
2353
- }
2354
- function isEffectTokenRow(value) {
2355
- return isRecord(value);
2356
- }
2357
2093
  function isEffectIdentityRow(value) {
2358
2094
  return isRecord(value);
2359
2095
  }
@@ -2377,6 +2113,12 @@ function digestBuffer(value) {
2377
2113
  throw new Error("Expected a SHA-256 digest");
2378
2114
  return Buffer.from(hex, "hex");
2379
2115
  }
2116
+ function terminalFingerprintBuffer(value) {
2117
+ const hex = value.startsWith("sha256:") ? value.slice(7) : value;
2118
+ if (!/^[a-f0-9]{64}$/i.test(hex))
2119
+ throw new Error("Expected a terminal fingerprint");
2120
+ return Buffer.from(hex, "hex");
2121
+ }
2380
2122
  function queuedWorkflowSource(value) {
2381
2123
  if (isWorkflowSource(value)) {
2382
2124
  return { root: value, mounted: [] };