@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,6 +5,7 @@ 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 {
9
10
  EffectRequestConflictError,
10
11
  ResourceConflictError,
@@ -30,8 +31,6 @@ import type {
30
31
  JsonObject,
31
32
  } from "./types.js";
32
33
 
33
- const TURN_INTENT_FACTS_SCHEMA = "pi-workflows.deferred-turn-facts.v1";
34
-
35
34
  export type WorkflowRunLaunchStatus =
36
35
  | "queued"
37
36
  | "starting"
@@ -54,6 +53,9 @@ export type WorkflowRunReservationOptions = {
54
53
  originSessionId: string;
55
54
  executionMode?: "interactive" | "headless";
56
55
  parentRunId?: string;
56
+ lineageKind?: "continuation" | "restart";
57
+ restartNumber?: number;
58
+ parentTerminalFingerprint?: string;
57
59
  now?: string;
58
60
  };
59
61
 
@@ -75,6 +77,10 @@ export type WorkflowRunQueueViewRecord = {
75
77
  originSessionId: string | null;
76
78
  executionMode: "interactive" | "headless";
77
79
  parentRunId: string | null;
80
+ rootRunId: string;
81
+ lineageKind: "continuation" | "restart" | null;
82
+ restartNumber: number;
83
+ parentTerminalFingerprint: string | null;
78
84
  errorCode: string | null;
79
85
  errorMessage: string | null;
80
86
  createdAt: string;
@@ -101,6 +107,10 @@ export type WorkflowRunQueueRecord = {
101
107
  originSessionId: string | null;
102
108
  executionMode: "interactive" | "headless";
103
109
  parentRunId: string | null;
110
+ rootRunId: string;
111
+ lineageKind: "continuation" | "restart" | null;
112
+ restartNumber: number;
113
+ parentTerminalFingerprint: string | null;
104
114
  errorCode: string | null;
105
115
  errorMessage: string | null;
106
116
  createdAt: string;
@@ -119,62 +129,6 @@ export type WorkflowRunPreparationResult =
119
129
  run: WorkflowRunQueueRecord;
120
130
  };
121
131
 
122
- export type WorkflowNotificationRecord = {
123
- notificationId: string;
124
- runId: string;
125
- nodeId: string;
126
- attemptId: string;
127
- notificationIndex: number;
128
- targetSessionId: string;
129
- kind: "progress" | "final";
130
- content: string;
131
- createdAt: string;
132
- deliveryClaimExpiresAt: string | null;
133
- deliveredAt: string | null;
134
- };
135
-
136
- export type WorkflowTurnIntentCause =
137
- | "agentCancelled"
138
- | "timedOut"
139
- | "failed"
140
- | "launchFailed"
141
- | "controllerInterrupted"
142
- | "claimLost"
143
- | "terminal"
144
- | "cancelled";
145
-
146
- export type WorkflowTurnIntentResolution = "workflowPrompt" | "presentation" | "fallback";
147
-
148
- export type WorkflowTurnIntentFacts = JsonObject & {
149
- schema: typeof TURN_INTENT_FACTS_SCHEMA;
150
- workflowName: string;
151
- runId: string;
152
- observedState: string;
153
- cause: WorkflowTurnIntentCause;
154
- nodeId: string | null;
155
- attemptId: string | null;
156
- reason: string | null;
157
- handoff: boolean;
158
- };
159
-
160
- export type WorkflowTurnIntentRecord = {
161
- intentId: string;
162
- sourceEventId: string;
163
- runId: string;
164
- workflowRef: string;
165
- targetSessionId: string;
166
- cause: WorkflowTurnIntentCause;
167
- nodeId: string | null;
168
- attemptId: string | null;
169
- fallbackFacts: WorkflowTurnIntentFacts;
170
- requestedAt: string;
171
- eligibleAt: string | null;
172
- resolvedAt: string | null;
173
- resolution: WorkflowTurnIntentResolution | null;
174
- resolutionMessageId: string | null;
175
- deliveryClaimExpiresAt: string | null;
176
- };
177
-
178
132
  export type RunEventRecord = {
179
133
  seq: number;
180
134
  recordedAt: string;
@@ -226,6 +180,10 @@ type RunRow = {
226
180
  originSessionId: string | null;
227
181
  executionMode: "interactive" | "headless";
228
182
  parentRunId: string | null;
183
+ rootRunId: string;
184
+ lineageKind: "continuation" | "restart" | null;
185
+ restartNumber: number;
186
+ parentTerminalFingerprint: Buffer | null;
229
187
  createdAt: number;
230
188
  updatedAt: number;
231
189
  startedAt: number | null;
@@ -245,6 +203,10 @@ type WorkflowRunViewRow = {
245
203
  originSessionId: string | null;
246
204
  executionMode: "interactive" | "headless";
247
205
  parentRunId: string | null;
206
+ rootRunId: string;
207
+ lineageKind: "continuation" | "restart" | null;
208
+ restartNumber: number;
209
+ parentTerminalFingerprint: Buffer | null;
248
210
  errorCode: string | null;
249
211
  errorMessage: string | null;
250
212
  createdAt: number;
@@ -309,6 +271,7 @@ type WorkflowRow = {
309
271
  export class SqliteControllerStore implements ControllerStore {
310
272
  readonly filePath: string;
311
273
  readonly state: StateDatabase;
274
+ private readonly workflowMessages: WorkflowMessageStore;
312
275
  private readonly ownsState: boolean;
313
276
  private readonly projectId: string | null;
314
277
  private closed = false;
@@ -332,6 +295,7 @@ export class SqliteControllerStore implements ControllerStore {
332
295
  checkLegacyState: filePath === workflowStatePath(),
333
296
  });
334
297
  this.filePath = this.state.filePath;
298
+ this.workflowMessages = new WorkflowMessageStore(this.state);
335
299
  if (options.global === true) {
336
300
  this.projectId = null;
337
301
  } else {
@@ -1129,39 +1093,77 @@ export class SqliteControllerStore implements ControllerStore {
1129
1093
  if (this.getWorkflowRun(options.runId) !== undefined) {
1130
1094
  throw new Error(`Workflow run already reserved: ${options.runId}`);
1131
1095
  }
1096
+ const lineageKind =
1097
+ options.parentRunId === undefined ? null : (options.lineageKind ?? "continuation");
1098
+ if (options.parentRunId === undefined && options.lineageKind !== undefined) {
1099
+ throw new Error("A root workflow run cannot declare lineage");
1100
+ }
1101
+ let rootRunId = options.runId;
1102
+ let restartNumber = 0;
1103
+ let parentTerminalFingerprint: Buffer | null = null;
1132
1104
  if (options.parentRunId !== undefined) {
1133
1105
  const parent = this.requireWorkflowRunRow(options.parentRunId);
1134
1106
  if (parent.originSessionId !== options.originSessionId) {
1135
- throw new Error("Continuation parent belongs to another Pi session");
1107
+ throw new Error("Workflow parent belongs to another Pi session");
1136
1108
  }
1137
- if (parent.status === "parked") {
1138
- const parentLease = this.requireLease(parent.resourceId);
1139
- if (parentLease.ownerId !== null) {
1140
- throw new Error("Continuation parent still has an active owner");
1109
+ rootRunId = parent.rootRunId;
1110
+ if (lineageKind === "restart") {
1111
+ if (
1112
+ !["completed", "failed", "timed_out", "cancelled"].includes(parent.runStatus) ||
1113
+ !["done", "failed", "cancelled"].includes(parent.status)
1114
+ ) {
1115
+ throw new Error("Restart parent is not terminal");
1116
+ }
1117
+ if (options.parentTerminalFingerprint === undefined) {
1118
+ throw new Error("Restart requires the parent terminal fingerprint");
1119
+ }
1120
+ parentTerminalFingerprint = terminalFingerprintBuffer(options.parentTerminalFingerprint);
1121
+ restartNumber = options.restartNumber ?? parent.restartNumber + 1;
1122
+ if (restartNumber !== parent.restartNumber + 1) {
1123
+ throw new Error("Restart number does not follow its parent");
1141
1124
  }
1142
- this.state.connection
1143
- .prepare(
1144
- `UPDATE run_queue
1145
- SET status = 'done', updated_at = ?, finished_at = ?
1146
- WHERE run_id = ? AND status = 'parked'`,
1147
- )
1148
- .run(now, now, parent.runId);
1149
- const parentRevision = this.resourceRevision(parent.resourceId);
1150
- this.bumpResource(parent.resourceId, parentRevision, now);
1151
- this.insertEvent(
1152
- parent.resourceId,
1153
- parentRevision + 1,
1154
- "run.queue_done_for_continuation",
1155
- "session",
1156
- options.originSessionId,
1157
- { continuationRunId: options.runId },
1158
- now,
1159
- );
1160
- } else if (parent.status === "done") {
1161
- throw new Error("Continuation parent already has a reserved continuation");
1162
1125
  } else {
1163
- throw new Error(`Continuation parent queue is ${parent.status}`);
1126
+ if (
1127
+ options.parentTerminalFingerprint !== undefined ||
1128
+ (options.restartNumber !== undefined && options.restartNumber !== parent.restartNumber)
1129
+ ) {
1130
+ throw new Error("Continuation lineage conflicts with restart metadata");
1131
+ }
1132
+ restartNumber = parent.restartNumber;
1133
+ if (parent.status === "parked") {
1134
+ const parentLease = this.requireLease(parent.resourceId);
1135
+ if (parentLease.ownerId !== null) {
1136
+ throw new Error("Continuation parent still has an active owner");
1137
+ }
1138
+ this.state.connection
1139
+ .prepare(
1140
+ `UPDATE run_queue
1141
+ SET status = 'done', updated_at = ?, finished_at = ?
1142
+ WHERE run_id = ? AND status = 'parked'`,
1143
+ )
1144
+ .run(now, now, parent.runId);
1145
+ const parentRevision = this.resourceRevision(parent.resourceId);
1146
+ this.bumpResource(parent.resourceId, parentRevision, now);
1147
+ this.insertEvent(
1148
+ parent.resourceId,
1149
+ parentRevision + 1,
1150
+ "run.queue_done_for_continuation",
1151
+ "session",
1152
+ options.originSessionId,
1153
+ { continuationRunId: options.runId },
1154
+ now,
1155
+ );
1156
+ } else if (parent.status === "done") {
1157
+ throw new Error("Continuation parent already has a reserved continuation");
1158
+ } else {
1159
+ throw new Error(`Continuation parent queue is ${parent.status}`);
1160
+ }
1164
1161
  }
1162
+ } else if (
1163
+ options.restartNumber !== undefined ||
1164
+ options.parentTerminalFingerprint !== undefined
1165
+ ) {
1166
+ throw new Error("A root workflow run cannot declare restart metadata");
1165
1167
  }
1166
1168
  const resourceId = resourceIdFor("run", options.runId);
1167
1169
  const definitionHash = this.state.putJson(options.definitionSnapshot, now);
@@ -1188,16 +1190,21 @@ export class SqliteControllerStore implements ControllerStore {
1188
1190
  this.state.connection
1189
1191
  .prepare(
1190
1192
  `INSERT INTO runs(
1191
- run_id, resource_id, project_id, parent_run_id, definition_digest,
1193
+ run_id, resource_id, project_id, parent_run_id, root_run_id, lineage_kind,
1194
+ restart_number, parent_terminal_fingerprint, definition_digest,
1192
1195
  workflow_ref, launch_options_hash, status, paused,
1193
1196
  input_hash, created_at, updated_at
1194
- ) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`,
1197
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`,
1195
1198
  )
1196
1199
  .run(
1197
1200
  options.runId,
1198
1201
  resourceId,
1199
1202
  this.requireProjectId(),
1200
1203
  options.parentRunId ?? null,
1204
+ rootRunId,
1205
+ lineageKind,
1206
+ restartNumber,
1207
+ parentTerminalFingerprint,
1201
1208
  definitionDigest,
1202
1209
  options.workflowSourceRef,
1203
1210
  launchHash,
@@ -1267,6 +1274,11 @@ export class SqliteControllerStore implements ControllerStore {
1267
1274
  runnerId: options.runnerId,
1268
1275
  originSessionId: options.originSessionId,
1269
1276
  ...(options.parentRunId === undefined ? {} : { parentRunId: options.parentRunId }),
1277
+ ...(options.lineageKind === undefined ? {} : { lineageKind: options.lineageKind }),
1278
+ ...(options.restartNumber === undefined ? {} : { restartNumber: options.restartNumber }),
1279
+ ...(options.parentTerminalFingerprint === undefined
1280
+ ? {}
1281
+ : { parentTerminalFingerprint: options.parentTerminalFingerprint }),
1270
1282
  ...(options.now === undefined ? {} : { now: options.now }),
1271
1283
  });
1272
1284
  }
@@ -1436,6 +1448,7 @@ export class SqliteControllerStore implements ControllerStore {
1436
1448
 
1437
1449
  beginWorkflowRunInteractionTimeout(options: {
1438
1450
  runId: string;
1451
+ targetSessionId: string;
1439
1452
  claimToken: string;
1440
1453
  now?: string;
1441
1454
  }): boolean {
@@ -1456,18 +1469,24 @@ export class SqliteControllerStore implements ControllerStore {
1456
1469
  `SELECT i.request_id AS requestId, i.attempt_id AS attemptId
1457
1470
  FROM interactive_requests i
1458
1471
  JOIN node_attempts a ON a.attempt_id = i.attempt_id
1459
- WHERE i.run_id = ? AND i.status IN ('pending', 'presenting')
1472
+ JOIN runs r ON r.run_id = i.run_id
1473
+ WHERE i.run_id = ? AND i.target_session_id = ?
1474
+ AND i.status = 'pending' AND r.paused = 0
1460
1475
  AND a.deadline_at IS NOT NULL AND a.deadline_at <= ?
1476
+ AND EXISTS (
1477
+ SELECT 1 FROM workflow_messages m
1478
+ JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id
1479
+ WHERE m.source_id = i.request_id AND m.kind = 'step' AND t.state = 'started'
1480
+ )
1461
1481
  ORDER BY a.deadline_at, i.request_id LIMIT 1`,
1462
1482
  )
1463
- .get(options.runId, now);
1483
+ .get(options.runId, options.targetSessionId, now);
1464
1484
  if (!isExpiredInteractionRow(interaction)) return false;
1465
1485
  const request = this.state.connection
1466
1486
  .prepare(
1467
1487
  `UPDATE interactive_requests
1468
- SET status = 'cancelled', presenter_id = NULL,
1469
- presentation_claim_expires_at = NULL, revision = revision + 1, updated_at = ?
1470
- WHERE request_id = ? AND run_id = ? AND status IN ('pending', 'presenting')`,
1488
+ SET status = 'cancelled', revision = revision + 1, updated_at = ?
1489
+ WHERE request_id = ? AND run_id = ? AND status = 'pending'`,
1471
1490
  )
1472
1491
  .run(now, interaction.requestId, options.runId);
1473
1492
  const run = this.state.connection
@@ -1489,7 +1508,7 @@ export class SqliteControllerStore implements ControllerStore {
1489
1508
  if (request.changes !== 1 || run.changes !== 1 || queue.changes !== 1) {
1490
1509
  throw new Error(`Workflow run ${options.runId} changed during interaction timeout`);
1491
1510
  }
1492
- const error = "Workflow node deadline expired before origin-session input arrived";
1511
+ const error = "Workflow node model-turn deadline expired";
1493
1512
  const receiptHash = this.state.putJson({ status: "rejected", error }, now);
1494
1513
  this.state.connection
1495
1514
  .prepare(
@@ -1539,8 +1558,8 @@ export class SqliteControllerStore implements ControllerStore {
1539
1558
  "(l.owner_id IS NULL OR l.expires_at <= ? OR l.owner_id = ?)",
1540
1559
  "(q.affinity_runner_id IS NULL OR q.affinity_runner_id = ? OR q.status IN ('parked', 'starting', 'running'))",
1541
1560
  "NOT (q.status = 'parked' AND (r.status = 'waiting' OR r.paused = 1))",
1542
- "NOT EXISTS (SELECT 1 FROM interactive_requests i WHERE i.run_id = r.run_id AND i.status IN ('pending', 'presenting'))",
1543
- "(q.error_code IS NULL OR q.error_code <> 'workflowSourceChanged')",
1561
+ "NOT EXISTS (SELECT 1 FROM interactive_requests i WHERE i.run_id = r.run_id AND i.status = 'pending')",
1562
+ "(q.error_code IS NULL OR q.error_code NOT IN ('workflowSourceChanged', 'workerNoProgress'))",
1544
1563
  ];
1545
1564
  const params: unknown[] = [now, now, options.runnerId, options.runnerId];
1546
1565
  if (this.projectId !== null) {
@@ -1653,6 +1672,78 @@ export class SqliteControllerStore implements ControllerStore {
1653
1672
  return this.releaseRunClaim(options.runId, options.claimToken, "parked", options.now);
1654
1673
  }
1655
1674
 
1675
+ parkWorkflowRunForWorkerNoProgress(options: {
1676
+ runId: string;
1677
+ claimToken: string;
1678
+ detail: string;
1679
+ now?: string;
1680
+ }): boolean {
1681
+ const now = epoch(validTimestamp(options.now));
1682
+ return this.state.transaction(() => {
1683
+ if (
1684
+ !this.verifyWorkflowRunClaim({
1685
+ runId: options.runId,
1686
+ claimToken: options.claimToken,
1687
+ now: new Date(now).toISOString(),
1688
+ })
1689
+ ) {
1690
+ return false;
1691
+ }
1692
+ const row = this.requireWorkflowRunRow(options.runId);
1693
+ const lease = this.requireLease(row.resourceId);
1694
+ const detail = options.detail.slice(0, 8_192);
1695
+ const errorHash = this.state.putText(detail, now);
1696
+ const run = this.state.connection
1697
+ .prepare(
1698
+ `UPDATE runs SET status_detail = ?, updated_at = ?, finished_at = NULL
1699
+ WHERE run_id = ? AND status NOT IN ('completed', 'failed', 'timed_out', 'cancelled')`,
1700
+ )
1701
+ .run(detail, now, options.runId);
1702
+ const queue = this.state.connection
1703
+ .prepare(
1704
+ `UPDATE run_queue
1705
+ SET status = 'parked', error_code = 'workerNoProgress', error_hash = ?,
1706
+ available_at = ?, updated_at = ?, finished_at = NULL
1707
+ WHERE run_id = ? AND status IN ('queued', 'starting', 'running', 'parked')`,
1708
+ )
1709
+ .run(errorHash, now, now, options.runId);
1710
+ if (run.changes !== 1 || queue.changes !== 1) {
1711
+ throw new Error(`Workflow run ${options.runId} changed during worker recovery`);
1712
+ }
1713
+ const released = this.state.connection
1714
+ .prepare(
1715
+ `UPDATE leases
1716
+ SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
1717
+ acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
1718
+ WHERE resource_id = ? AND token_hash = ? AND generation = ? AND expires_at > ?`,
1719
+ )
1720
+ .run(row.resourceId, tokenHash(options.claimToken), lease.generation, now);
1721
+ /* istanbul ignore if -- the exact live claim is stable in this transaction */
1722
+ if (released.changes !== 1) {
1723
+ throw new Error(`Workflow run ${options.runId} claim changed during worker recovery`);
1724
+ }
1725
+ const revision = this.resourceRevision(row.resourceId);
1726
+ this.bumpResource(row.resourceId, revision, now);
1727
+ this.insertEvent(
1728
+ row.resourceId,
1729
+ revision + 1,
1730
+ "run.worker_no_progress",
1731
+ lease.ownerType ?? "system",
1732
+ lease.ownerId,
1733
+ { status: "parked", code: "workerNoProgress" },
1734
+ now,
1735
+ lease.generation || undefined,
1736
+ );
1737
+ recordViewerDeltas(
1738
+ this.state,
1739
+ options.runId,
1740
+ [{ targetType: "summary" }, { targetType: "replay" }],
1741
+ now,
1742
+ );
1743
+ return true;
1744
+ });
1745
+ }
1746
+
1656
1747
  pauseParkedWorkflowRun(options: { runId: string; now?: string }): boolean {
1657
1748
  const now = epoch(validTimestamp(options.now));
1658
1749
  return this.state.transaction(() => {
@@ -1663,7 +1754,7 @@ export class SqliteControllerStore implements ControllerStore {
1663
1754
  const pending = this.state.connection
1664
1755
  .prepare(
1665
1756
  `SELECT 1 FROM interactive_requests
1666
- WHERE run_id = ? AND status IN ('pending', 'presenting') LIMIT 1`,
1757
+ WHERE run_id = ? AND status = 'pending' LIMIT 1`,
1667
1758
  )
1668
1759
  .get(options.runId);
1669
1760
  if (pending === undefined) return false;
@@ -1714,7 +1805,7 @@ export class SqliteControllerStore implements ControllerStore {
1714
1805
  const pending = this.state.connection
1715
1806
  .prepare(
1716
1807
  `SELECT 1 FROM interactive_requests
1717
- WHERE run_id = ? AND status IN ('pending', 'presenting') LIMIT 1`,
1808
+ WHERE run_id = ? AND status = 'pending' LIMIT 1`,
1718
1809
  )
1719
1810
  .get(options.runId);
1720
1811
  if (pending === undefined) return false;
@@ -1999,6 +2090,7 @@ export class SqliteControllerStore implements ControllerStore {
1999
2090
  if (run.changes !== 1) {
2000
2091
  throw new Error(`Workflow run ${options.runId} has inconsistent durable state`);
2001
2092
  }
2093
+ this.settleWorkflowRunMessages(options.runId, now);
2002
2094
  this.cancelWorkflowRunDependents(options.runId, controlId, errorHash, now);
2003
2095
 
2004
2096
  const released = this.state.connection
@@ -2132,427 +2224,6 @@ export class SqliteControllerStore implements ControllerStore {
2132
2224
  }));
2133
2225
  }
2134
2226
 
2135
- ensureWorkflowTurnIntent(options: {
2136
- intentId: string;
2137
- sourceEventId: string;
2138
- runId: string;
2139
- workflowRef: string;
2140
- targetSessionId: string;
2141
- cause: WorkflowTurnIntentCause;
2142
- nodeId?: string | null;
2143
- attemptId?: string | null;
2144
- fallbackFacts: WorkflowTurnIntentFacts;
2145
- eligible: boolean;
2146
- requestedAt?: string;
2147
- }): WorkflowTurnIntentRecord {
2148
- const existing = this.turnIntentBySource(options.sourceEventId);
2149
- if (existing !== undefined) return existing;
2150
- const now = epoch(validTimestamp(options.requestedAt));
2151
- const run = this.requireWorkflowRunRow(options.runId);
2152
- const intentId = options.intentId;
2153
- const resourceId = resourceIdFor("turn_intent", intentId);
2154
- const effectId = `effect-${randomUUID()}`;
2155
- this.state.transaction(() => {
2156
- const factsHash = this.state.putJson(options.fallbackFacts, now);
2157
- this.insertEffectResource(
2158
- effectId,
2159
- resourceIdFor("effect", effectId),
2160
- run.resourceId,
2161
- this.resourceRevision(run.resourceId),
2162
- "turn.deliver",
2163
- intentId,
2164
- factsHash,
2165
- "run",
2166
- now,
2167
- );
2168
- this.state.connection
2169
- .prepare(
2170
- "INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at) VALUES (?, 'turn_intent', ?, 1, ?, ?)",
2171
- )
2172
- .run(resourceId, intentId, now, now);
2173
- this.state.connection
2174
- .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
2175
- .run(resourceId);
2176
- this.state.connection
2177
- .prepare(
2178
- `INSERT INTO turn_intents(
2179
- turn_intent_id, resource_id, effect_id, source_event_id, run_id,
2180
- workflow_ref, target_session_id, cause, node_id, attempt_id,
2181
- facts_hash, requested_at, eligible_at, created_at
2182
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2183
- )
2184
- .run(
2185
- intentId,
2186
- resourceId,
2187
- effectId,
2188
- options.sourceEventId,
2189
- options.runId,
2190
- options.workflowRef,
2191
- options.targetSessionId,
2192
- options.cause,
2193
- options.nodeId ?? null,
2194
- options.attemptId ?? null,
2195
- factsHash,
2196
- now,
2197
- options.eligible ? now : null,
2198
- now,
2199
- );
2200
- this.insertEvent(
2201
- resourceId,
2202
- 1,
2203
- "turn.requested",
2204
- "session",
2205
- options.targetSessionId,
2206
- {},
2207
- now,
2208
- );
2209
- });
2210
- return this.requireTurnIntent(intentId);
2211
- }
2212
-
2213
- getWorkflowTurnIntent(intentId: string): WorkflowTurnIntentRecord | undefined {
2214
- return this.turnIntent(intentId);
2215
- }
2216
-
2217
- findPendingWorkflowTurnIntent(options: {
2218
- runId: string;
2219
- targetSessionId: string;
2220
- }): WorkflowTurnIntentRecord | undefined {
2221
- const row = this.state.connection
2222
- .prepare(
2223
- turnIntentSelect(
2224
- "WHERE t.run_id = ? AND t.target_session_id = ? AND t.resolved_at IS NULL ORDER BY t.requested_at DESC LIMIT 1",
2225
- ),
2226
- )
2227
- .get(options.runId, options.targetSessionId);
2228
- return isTurnIntentRow(row) ? this.mapTurnIntent(row) : undefined;
2229
- }
2230
-
2231
- isWorkflowTurnIntentClaimLive(options: {
2232
- intentId: string;
2233
- targetSessionId: string;
2234
- claimToken: string;
2235
- now?: string;
2236
- }): boolean {
2237
- const row = this.turnIntentRow(options.intentId);
2238
- return (
2239
- row !== undefined &&
2240
- row.targetSessionId === options.targetSessionId &&
2241
- row.resolvedAt === null &&
2242
- this.verifyEffectToken(row.effectId, options.claimToken, options.now)
2243
- );
2244
- }
2245
-
2246
- claimWorkflowTurnIntent(options: {
2247
- intentId: string;
2248
- targetSessionId: string;
2249
- claimToken: string;
2250
- leaseMs: number;
2251
- now?: string;
2252
- }): WorkflowTurnIntentRecord | undefined {
2253
- const intent = this.turnIntent(options.intentId);
2254
- if (intent?.targetSessionId !== options.targetSessionId) return undefined;
2255
- return this.claimEffectForTurn(
2256
- options.intentId,
2257
- options.claimToken,
2258
- options.leaseMs,
2259
- options.now,
2260
- )
2261
- ? this.requireTurnIntent(options.intentId)
2262
- : undefined;
2263
- }
2264
-
2265
- claimEligibleWorkflowTurnIntents(options: {
2266
- targetSessionId: string;
2267
- claimToken: string;
2268
- leaseMs: number;
2269
- now?: string;
2270
- limit?: number;
2271
- }): WorkflowTurnIntentRecord[] {
2272
- const now = epoch(validTimestamp(options.now));
2273
- const rows = this.state.connection
2274
- .prepare(
2275
- turnIntentSelect(
2276
- "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 ?",
2277
- ),
2278
- )
2279
- .all(options.targetSessionId, now, options.limit ?? 10);
2280
- const claimed: WorkflowTurnIntentRecord[] = [];
2281
- for (const row of rows) {
2282
- if (
2283
- isTurnIntentRow(row) &&
2284
- this.claimEffectForTurn(row.intentId, options.claimToken, options.leaseMs, options.now)
2285
- ) {
2286
- claimed.push(this.requireTurnIntent(row.intentId));
2287
- }
2288
- }
2289
- return claimed;
2290
- }
2291
-
2292
- hasClaimableWorkflowTurnIntent(options: { targetSessionId: string; now?: string }): boolean {
2293
- const now = epoch(validTimestamp(options.now));
2294
- const row = this.state.connection
2295
- .prepare(
2296
- `SELECT 1 AS present
2297
- FROM turn_intents t
2298
- JOIN effects e ON e.effect_id = t.effect_id
2299
- JOIN leases l ON l.resource_id = e.resource_id
2300
- WHERE t.target_session_id = ? AND t.resolved_at IS NULL
2301
- AND t.eligible_at IS NOT NULL AND t.eligible_at <= ?
2302
- AND e.status = 'pending'
2303
- AND (l.owner_id IS NULL OR l.expires_at IS NULL OR l.expires_at <= ?)
2304
- LIMIT 1`,
2305
- )
2306
- .get(options.targetSessionId, now, now);
2307
- return row !== undefined;
2308
- }
2309
-
2310
- makeWorkflowTurnIntentEligible(options: {
2311
- intentId: string;
2312
- fallbackFacts: WorkflowTurnIntentFacts;
2313
- eligibleAt?: string;
2314
- now?: string;
2315
- }): boolean {
2316
- const eligibleAt = epoch(validTimestamp(options.eligibleAt ?? options.now));
2317
- const factsHash = this.state.putJson(options.fallbackFacts, eligibleAt);
2318
- return (
2319
- this.state.connection
2320
- .prepare(
2321
- "UPDATE turn_intents SET eligible_at = ?, facts_hash = ? WHERE turn_intent_id = ? AND resolved_at IS NULL",
2322
- )
2323
- .run(eligibleAt, factsHash, options.intentId).changes === 1
2324
- );
2325
- }
2326
-
2327
- resolveWorkflowTurnIntent(options: {
2328
- intentId: string;
2329
- targetSessionId: string;
2330
- claimToken: string;
2331
- resolution: WorkflowTurnIntentResolution;
2332
- messageId?: string;
2333
- now?: string;
2334
- }): boolean {
2335
- const now = epoch(validTimestamp(options.now));
2336
- return this.state.transaction(() => {
2337
- const row = this.turnIntentRow(options.intentId);
2338
- if (
2339
- row === undefined ||
2340
- row.targetSessionId !== options.targetSessionId ||
2341
- !this.verifyEffectToken(row.effectId, options.claimToken, options.now)
2342
- ) {
2343
- return false;
2344
- }
2345
- const result = this.state.connection
2346
- .prepare(
2347
- `UPDATE turn_intents SET resolved_at = ?, resolution_type = ?, resolution_message_id = ?
2348
- WHERE turn_intent_id = ? AND resolved_at IS NULL`,
2349
- )
2350
- .run(now, options.resolution, options.messageId ?? null, options.intentId);
2351
- if (result.changes === 1) this.completeEffect(row.effectId, "applied", now);
2352
- return result.changes === 1;
2353
- });
2354
- }
2355
-
2356
- releaseWorkflowTurnIntentClaim(options: {
2357
- intentId: string;
2358
- targetSessionId: string;
2359
- claimToken: string;
2360
- }): boolean {
2361
- const row = this.turnIntentRow(options.intentId);
2362
- return (
2363
- row !== undefined &&
2364
- row.targetSessionId === options.targetSessionId &&
2365
- this.releaseEffectLease(row.effectId, options.claimToken)
2366
- );
2367
- }
2368
-
2369
- listWorkflowTurnIntents(
2370
- options: {
2371
- runId?: string;
2372
- targetSessionId?: string;
2373
- unresolvedOnly?: boolean;
2374
- limit?: number;
2375
- } = {},
2376
- ): WorkflowTurnIntentRecord[] {
2377
- const clauses = ["1 = 1"];
2378
- const params: unknown[] = [];
2379
- if (options.runId !== undefined) {
2380
- clauses.push("t.run_id = ?");
2381
- params.push(options.runId);
2382
- }
2383
- if (options.targetSessionId !== undefined) {
2384
- clauses.push("t.target_session_id = ?");
2385
- params.push(options.targetSessionId);
2386
- }
2387
- if (options.unresolvedOnly === true) clauses.push("t.resolved_at IS NULL");
2388
- params.push(options.limit ?? 100);
2389
- const rows = this.state.connection
2390
- .prepare(
2391
- turnIntentSelect(`WHERE ${clauses.join(" AND ")} ORDER BY t.requested_at DESC LIMIT ?`),
2392
- )
2393
- .all(...params);
2394
- return rows.filter(isTurnIntentRow).map((row) => this.mapTurnIntent(row));
2395
- }
2396
-
2397
- enqueueWorkflowNotification(options: {
2398
- runId: string;
2399
- nodeId: string;
2400
- attemptId: string;
2401
- notificationIndex: number;
2402
- targetSessionId: string;
2403
- kind: "progress" | "final";
2404
- content: string;
2405
- now?: string;
2406
- }): WorkflowNotificationRecord {
2407
- const existing = this.notification(options.runId, options.attemptId, options.notificationIndex);
2408
- if (existing !== undefined) return existing;
2409
- const run = this.requireWorkflowRunRow(options.runId);
2410
- const now = epoch(validTimestamp(options.now));
2411
- const notificationId = `notification-${randomUUID()}`;
2412
- const resourceId = resourceIdFor("notification", notificationId);
2413
- const effectId = `effect-${randomUUID()}`;
2414
- this.state.transaction(() => {
2415
- const contentHash = this.state.putText(options.content, now);
2416
- this.insertEffectResource(
2417
- effectId,
2418
- resourceIdFor("effect", effectId),
2419
- run.resourceId,
2420
- this.resourceRevision(run.resourceId),
2421
- "notification.deliver",
2422
- notificationId,
2423
- contentHash,
2424
- "run",
2425
- now,
2426
- );
2427
- this.state.connection
2428
- .prepare(
2429
- "INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at) VALUES (?, 'notification', ?, 1, ?, ?)",
2430
- )
2431
- .run(resourceId, notificationId, now, now);
2432
- this.state.connection
2433
- .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
2434
- .run(resourceId);
2435
- this.state.connection
2436
- .prepare(
2437
- `INSERT INTO notifications(
2438
- notification_id, effect_id, run_id, attempt_id, notification_index,
2439
- target_session_id, notification_type, content_hash, created_at
2440
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2441
- )
2442
- .run(
2443
- notificationId,
2444
- effectId,
2445
- options.runId,
2446
- options.attemptId,
2447
- options.notificationIndex,
2448
- options.targetSessionId,
2449
- options.kind,
2450
- contentHash,
2451
- now,
2452
- );
2453
- this.insertEvent(
2454
- resourceId,
2455
- 1,
2456
- "notification.queued",
2457
- "session",
2458
- options.targetSessionId,
2459
- {},
2460
- now,
2461
- );
2462
- });
2463
- return this.requireNotification(options.runId, options.attemptId, options.notificationIndex);
2464
- }
2465
-
2466
- hasClaimableWorkflowNotification(options: { targetSessionId: string; now?: string }): boolean {
2467
- const now = epoch(validTimestamp(options.now));
2468
- const row = this.state.connection
2469
- .prepare(
2470
- `SELECT 1 AS present
2471
- FROM notifications n
2472
- JOIN effects e ON e.effect_id = n.effect_id
2473
- JOIN leases l ON l.resource_id = e.resource_id
2474
- WHERE n.target_session_id = ? AND e.status = 'pending'
2475
- AND (l.owner_id IS NULL OR l.expires_at IS NULL OR l.expires_at <= ?)
2476
- LIMIT 1`,
2477
- )
2478
- .get(options.targetSessionId, now);
2479
- return row !== undefined;
2480
- }
2481
-
2482
- listPendingWorkflowNotifications(options: {
2483
- targetSessionId: string;
2484
- limit?: number;
2485
- }): WorkflowNotificationRecord[] {
2486
- const now = Date.now();
2487
- return this.notificationRows(options.targetSessionId, options.limit ?? 20)
2488
- .map((row) => this.mapNotification(row))
2489
- .filter(
2490
- (notification) =>
2491
- notification.deliveryClaimExpiresAt === null ||
2492
- Date.parse(notification.deliveryClaimExpiresAt) <= now,
2493
- );
2494
- }
2495
-
2496
- claimPendingWorkflowNotifications(options: {
2497
- targetSessionId: string;
2498
- claimToken: string;
2499
- leaseMs: number;
2500
- now?: string;
2501
- limit?: number;
2502
- }): WorkflowNotificationRecord[] {
2503
- const rows = this.notificationRows(options.targetSessionId, options.limit ?? 20);
2504
- const result: WorkflowNotificationRecord[] = [];
2505
- for (const row of rows) {
2506
- if (
2507
- this.claimEffect(
2508
- row.effectId,
2509
- options.targetSessionId,
2510
- options.claimToken,
2511
- options.leaseMs,
2512
- options.now,
2513
- )
2514
- ) {
2515
- result.push(this.mapNotification(row));
2516
- }
2517
- }
2518
- return result;
2519
- }
2520
-
2521
- isWorkflowNotificationClaimLive(options: {
2522
- notificationId: string;
2523
- targetSessionId: string;
2524
- claimToken: string;
2525
- now?: string;
2526
- }): boolean {
2527
- const row = this.notificationRowById(options.notificationId);
2528
- return (
2529
- row !== undefined &&
2530
- row.targetSessionId === options.targetSessionId &&
2531
- this.verifyEffectToken(row.effectId, options.claimToken, options.now)
2532
- );
2533
- }
2534
-
2535
- markWorkflowNotificationDelivered(options: {
2536
- notificationId: string;
2537
- targetSessionId: string;
2538
- claimToken: string;
2539
- now?: string;
2540
- }): boolean {
2541
- const now = epoch(validTimestamp(options.now));
2542
- return this.state.transaction(() => {
2543
- const row = this.notificationRowById(options.notificationId);
2544
- if (
2545
- row === undefined ||
2546
- row.targetSessionId !== options.targetSessionId ||
2547
- !this.verifyEffectToken(row.effectId, options.claimToken, options.now)
2548
- ) {
2549
- return false;
2550
- }
2551
- this.completeEffect(row.effectId, "applied", now);
2552
- return true;
2553
- });
2554
- }
2555
-
2556
2227
  settleRunEffect(runId: string, effectType: "run.park_queue" | "run.settle_queue"): void {
2557
2228
  const rows = this.state.connection
2558
2229
  .prepare(
@@ -3007,6 +2678,21 @@ export class SqliteControllerStore implements ControllerStore {
3007
2678
  options: WorkflowRunReservationOptions,
3008
2679
  definitionDigest: Buffer,
3009
2680
  ): void {
2681
+ const expectedLineageKind =
2682
+ options.parentRunId === undefined ? null : (options.lineageKind ?? "continuation");
2683
+ const parent =
2684
+ options.parentRunId === undefined
2685
+ ? undefined
2686
+ : this.requireWorkflowRunRow(options.parentRunId);
2687
+ const expectedRootRunId = parent?.rootRunId ?? options.runId;
2688
+ const expectedRestartNumber =
2689
+ expectedLineageKind === "restart"
2690
+ ? (options.restartNumber ?? (parent?.restartNumber ?? -1) + 1)
2691
+ : (parent?.restartNumber ?? 0);
2692
+ const expectedParentTerminalFingerprint =
2693
+ options.parentTerminalFingerprint === undefined
2694
+ ? null
2695
+ : terminalFingerprintBuffer(options.parentTerminalFingerprint);
3010
2696
  const compatible =
3011
2697
  row.workflowName === options.workflowName &&
3012
2698
  row.workflowRef === options.workflowSourceRef &&
@@ -3018,7 +2704,14 @@ export class SqliteControllerStore implements ControllerStore {
3018
2704
  canonicalJson(options.launchOptions ?? {}) &&
3019
2705
  row.originSessionId === options.originSessionId &&
3020
2706
  row.executionMode === (options.executionMode ?? "interactive") &&
3021
- row.parentRunId === (options.parentRunId ?? null);
2707
+ row.parentRunId === (options.parentRunId ?? null) &&
2708
+ row.rootRunId === expectedRootRunId &&
2709
+ row.lineageKind === expectedLineageKind &&
2710
+ row.restartNumber === expectedRestartNumber &&
2711
+ ((row.parentTerminalFingerprint === null && expectedParentTerminalFingerprint === null) ||
2712
+ (row.parentTerminalFingerprint !== null &&
2713
+ expectedParentTerminalFingerprint !== null &&
2714
+ row.parentTerminalFingerprint.equals(expectedParentTerminalFingerprint)));
3022
2715
  if (!compatible) {
3023
2716
  throw new Error(`Workflow run preparation conflicts: ${options.runId}`);
3024
2717
  }
@@ -3052,6 +2745,10 @@ export class SqliteControllerStore implements ControllerStore {
3052
2745
  originSessionId: row.executionMode === "headless" ? null : row.originSessionId,
3053
2746
  executionMode: row.executionMode,
3054
2747
  parentRunId: row.parentRunId,
2748
+ rootRunId: row.rootRunId,
2749
+ lineageKind: row.lineageKind,
2750
+ restartNumber: row.restartNumber,
2751
+ parentTerminalFingerprint: row.parentTerminalFingerprint?.toString("hex") ?? null,
3055
2752
  errorCode: row.errorCode,
3056
2753
  errorMessage:
3057
2754
  row.errorHash === null
@@ -3092,7 +2789,7 @@ export class SqliteControllerStore implements ControllerStore {
3092
2789
  this.state.connection
3093
2790
  .prepare(
3094
2791
  `SELECT 1 FROM interactive_requests
3095
- WHERE run_id = ? AND status IN ('pending', 'presenting') LIMIT 1`,
2792
+ WHERE run_id = ? AND status = 'pending' LIMIT 1`,
3096
2793
  )
3097
2794
  .get(runId) !== undefined
3098
2795
  ) {
@@ -3303,6 +3000,7 @@ export class SqliteControllerStore implements ControllerStore {
3303
3000
  WHERE run_id = ?`,
3304
3001
  )
3305
3002
  .run(status, errorHash, now, now, runId);
3003
+ this.settleWorkflowRunMessages(runId, now);
3306
3004
  if (status === "cancelled") {
3307
3005
  this.cancelWorkflowRunDependents(
3308
3006
  runId,
@@ -3332,6 +3030,11 @@ export class SqliteControllerStore implements ControllerStore {
3332
3030
  });
3333
3031
  }
3334
3032
 
3033
+ private settleWorkflowRunMessages(runId: string, now: number): void {
3034
+ this.workflowMessages.settleOpenTurnsForRun(runId, "lost", now);
3035
+ this.workflowMessages.cancelPendingForRun(runId, now);
3036
+ }
3037
+
3335
3038
  private cancelWorkflowRunDependents(
3336
3039
  runId: string,
3337
3040
  actorId: string,
@@ -3419,262 +3122,12 @@ export class SqliteControllerStore implements ControllerStore {
3419
3122
  this.state.connection
3420
3123
  .prepare(
3421
3124
  `UPDATE interactive_requests
3422
- SET status = 'cancelled', presenter_id = NULL,
3423
- presentation_claim_expires_at = NULL, revision = revision + 1, updated_at = ?
3424
- WHERE run_id = ? AND status IN ('pending', 'presenting')`,
3125
+ SET status = 'cancelled', revision = revision + 1, updated_at = ?
3126
+ WHERE run_id = ? AND status = 'pending'`,
3425
3127
  )
3426
3128
  .run(now, runId);
3427
3129
  }
3428
3130
 
3429
- private turnIntent(intentId: string): WorkflowTurnIntentRecord | undefined {
3430
- const row = this.turnIntentRow(intentId);
3431
- return row === undefined ? undefined : this.mapTurnIntent(row);
3432
- }
3433
-
3434
- private requireTurnIntent(intentId: string): WorkflowTurnIntentRecord {
3435
- const row = this.turnIntent(intentId);
3436
- if (row === undefined) throw new Error(`Workflow turn intent not found: ${intentId}`);
3437
- return row;
3438
- }
3439
-
3440
- private turnIntentRow(intentId: string): TurnIntentRow | undefined {
3441
- const row = this.state.connection
3442
- .prepare(turnIntentSelect("WHERE t.turn_intent_id = ?"))
3443
- .get(intentId);
3444
- return isTurnIntentRow(row) ? row : undefined;
3445
- }
3446
-
3447
- private turnIntentBySource(sourceEventId: string): WorkflowTurnIntentRecord | undefined {
3448
- const row = this.state.connection
3449
- .prepare(turnIntentSelect("WHERE t.source_event_id = ?"))
3450
- .get(sourceEventId);
3451
- return isTurnIntentRow(row) ? this.mapTurnIntent(row) : undefined;
3452
- }
3453
-
3454
- /* istanbul ignore next -- pure projection covered by integration tests */
3455
- private mapTurnIntent(row: TurnIntentRow): WorkflowTurnIntentRecord {
3456
- return {
3457
- intentId: row.intentId,
3458
- sourceEventId: row.sourceEventId,
3459
- runId: row.runId,
3460
- workflowRef: row.workflowRef,
3461
- targetSessionId: row.targetSessionId,
3462
- cause: row.cause,
3463
- nodeId: row.nodeId,
3464
- attemptId: row.attemptId,
3465
- fallbackFacts: this.state.readJson(row.factsHash) as WorkflowTurnIntentFacts,
3466
- requestedAt: new Date(row.requestedAt).toISOString(),
3467
- eligibleAt: row.eligibleAt === null ? null : new Date(row.eligibleAt).toISOString(),
3468
- resolvedAt: row.resolvedAt === null ? null : new Date(row.resolvedAt).toISOString(),
3469
- resolution: row.resolution,
3470
- resolutionMessageId: row.resolutionMessageId,
3471
- deliveryClaimExpiresAt:
3472
- row.claimExpiresAt === null ? null : new Date(row.claimExpiresAt).toISOString(),
3473
- };
3474
- }
3475
-
3476
- private claimEffectForTurn(
3477
- intentId: string,
3478
- token: string,
3479
- leaseMs: number,
3480
- nowValue?: string,
3481
- ): boolean {
3482
- const row = this.turnIntentRow(intentId);
3483
- return (
3484
- row !== undefined &&
3485
- this.claimEffect(row.effectId, row.targetSessionId, token, leaseMs, nowValue)
3486
- );
3487
- }
3488
-
3489
- private claimEffect(
3490
- effectId: string,
3491
- ownerId: string,
3492
- token: string,
3493
- leaseMs: number,
3494
- nowValue?: string,
3495
- ): boolean {
3496
- const now = epoch(validTimestamp(nowValue));
3497
- return this.state.transaction(() => {
3498
- const row = this.state.connection
3499
- .prepare(
3500
- "SELECT resource_id AS resourceId FROM effects WHERE effect_id = ? AND status = 'pending'",
3501
- )
3502
- .get(effectId);
3503
- /* istanbul ignore if -- impossible after exact schema and transaction checks */
3504
- if (!isResourceIdRow(row)) return false;
3505
- const lease = this.requireLease(row.resourceId);
3506
- if (lease.ownerId !== null && lease.expiresAt !== null && lease.expiresAt > now) return false;
3507
- return (
3508
- this.state.connection
3509
- .prepare(
3510
- `UPDATE leases SET generation = ?, owner_type = 'session', owner_id = ?, token_hash = ?,
3511
- acquired_at = ?, heartbeat_at = ?, expires_at = ?
3512
- WHERE resource_id = ? AND generation = ?`,
3513
- )
3514
- .run(
3515
- lease.generation + 1,
3516
- ownerId,
3517
- tokenHash(token),
3518
- now,
3519
- now,
3520
- now + leaseMs,
3521
- row.resourceId,
3522
- lease.generation,
3523
- ).changes === 1
3524
- );
3525
- });
3526
- }
3527
-
3528
- private verifyEffectToken(effectId: string, token: string, nowValue?: string): boolean {
3529
- const now = epoch(validTimestamp(nowValue));
3530
- const row = this.state.connection
3531
- .prepare(
3532
- `SELECT l.token_hash AS tokenHash, l.expires_at AS expiresAt
3533
- FROM effects e JOIN leases l ON l.resource_id = e.resource_id
3534
- WHERE e.effect_id = ?`,
3535
- )
3536
- .get(effectId);
3537
- return (
3538
- isEffectTokenRow(row) &&
3539
- row.tokenHash !== null &&
3540
- row.tokenHash.equals(tokenHash(token)) &&
3541
- row.expiresAt !== null &&
3542
- row.expiresAt > now
3543
- );
3544
- }
3545
-
3546
- private releaseEffectLease(effectId: string, token: string): boolean {
3547
- return (
3548
- this.state.connection
3549
- .prepare(
3550
- `UPDATE leases SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
3551
- acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
3552
- WHERE resource_id = (SELECT resource_id FROM effects WHERE effect_id = ?)
3553
- AND token_hash = ?`,
3554
- )
3555
- .run(effectId, tokenHash(token)).changes === 1
3556
- );
3557
- }
3558
-
3559
- private completeEffect(
3560
- effectId: string,
3561
- status: "applied" | "rejected" | "ambiguous",
3562
- now: number,
3563
- ): void {
3564
- this.state.connection
3565
- .prepare("UPDATE effects SET status = ?, updated_at = ?, settled_at = ? WHERE effect_id = ?")
3566
- .run(status, now, now, effectId);
3567
- this.state.connection
3568
- .prepare(
3569
- `UPDATE leases SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
3570
- acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
3571
- WHERE resource_id = (SELECT resource_id FROM effects WHERE effect_id = ?)`,
3572
- )
3573
- .run(effectId);
3574
- }
3575
-
3576
- private insertEffectResource(
3577
- effectId: string,
3578
- resourceId: string,
3579
- sourceResourceId: string,
3580
- sourceRevision: number,
3581
- type: string,
3582
- key: string,
3583
- payloadHash: Buffer,
3584
- scope: "run" | "controller" | "channel" | "system",
3585
- now: number,
3586
- ): void {
3587
- this.state.connection
3588
- .prepare(
3589
- "INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at) VALUES (?, 'effect', ?, 1, ?, ?)",
3590
- )
3591
- .run(resourceId, effectId, now, now);
3592
- this.state.connection
3593
- .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
3594
- .run(resourceId);
3595
- this.state.connection
3596
- .prepare(
3597
- `INSERT INTO effects(
3598
- effect_id, resource_id, source_resource_id, source_revision, effect_type,
3599
- idempotency_key, payload_hash, owner_scope, status, attempt_count,
3600
- created_at, updated_at
3601
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)`,
3602
- )
3603
- .run(
3604
- effectId,
3605
- resourceId,
3606
- sourceResourceId,
3607
- sourceRevision,
3608
- type,
3609
- key,
3610
- payloadHash,
3611
- scope,
3612
- now,
3613
- now,
3614
- );
3615
- this.insertEvent(resourceId, 1, "effect.created", "system", null, {}, now);
3616
- }
3617
-
3618
- private notification(
3619
- runId: string,
3620
- attemptId: string,
3621
- index: number,
3622
- ): WorkflowNotificationRecord | undefined {
3623
- const row = this.state.connection
3624
- .prepare(
3625
- notificationSelect("WHERE n.run_id = ? AND n.attempt_id = ? AND n.notification_index = ?"),
3626
- )
3627
- .get(runId, attemptId, index);
3628
- return isNotificationRow(row) ? this.mapNotification(row) : undefined;
3629
- }
3630
-
3631
- private requireNotification(
3632
- runId: string,
3633
- attemptId: string,
3634
- index: number,
3635
- ): WorkflowNotificationRecord {
3636
- const record = this.notification(runId, attemptId, index);
3637
- /* istanbul ignore if -- impossible after exact schema and transaction checks */
3638
- if (record === undefined) throw new Error("Workflow notification was not stored");
3639
- return record;
3640
- }
3641
-
3642
- private notificationRows(sessionId: string, limit: number): NotificationRow[] {
3643
- return this.state.connection
3644
- .prepare(
3645
- notificationSelect(
3646
- "WHERE n.target_session_id = ? AND e.status = 'pending' ORDER BY n.created_at LIMIT ?",
3647
- ),
3648
- )
3649
- .all(sessionId, limit)
3650
- .filter(isNotificationRow);
3651
- }
3652
-
3653
- private notificationRowById(id: string): NotificationRow | undefined {
3654
- const row = this.state.connection
3655
- .prepare(notificationSelect("WHERE n.notification_id = ?"))
3656
- .get(id);
3657
- return isNotificationRow(row) ? row : undefined;
3658
- }
3659
-
3660
- /* istanbul ignore next -- pure projection covered by integration tests */
3661
- private mapNotification(row: NotificationRow): WorkflowNotificationRecord {
3662
- return {
3663
- notificationId: row.notificationId,
3664
- runId: row.runId,
3665
- nodeId: row.nodeId,
3666
- attemptId: row.attemptId,
3667
- notificationIndex: row.notificationIndex,
3668
- targetSessionId: row.targetSessionId,
3669
- kind: row.kind,
3670
- content: this.state.readBlob(row.contentHash)?.content.toString("utf8") ?? "",
3671
- createdAt: new Date(row.createdAt).toISOString(),
3672
- deliveryClaimExpiresAt:
3673
- row.claimExpiresAt === null ? null : new Date(row.claimExpiresAt).toISOString(),
3674
- deliveredAt: row.deliveredAt === null ? null : new Date(row.deliveredAt).toISOString(),
3675
- };
3676
- }
3677
-
3678
3131
  private assertWritable(): void {
3679
3132
  if (this.state.mode === "read-only") throw new Error("Controller store is read-only");
3680
3133
  }
@@ -3719,6 +3172,9 @@ function workflowRunSelect(clause: string): string {
3719
3172
  q.consecutive_errors AS consecutiveErrors, q.error_code AS errorCode,
3720
3173
  q.error_hash AS errorHash, b.origin_session_id AS originSessionId,
3721
3174
  b.execution_mode AS executionMode, r.parent_run_id AS parentRunId,
3175
+ r.root_run_id AS rootRunId, r.lineage_kind AS lineageKind,
3176
+ r.restart_number AS restartNumber,
3177
+ r.parent_terminal_fingerprint AS parentTerminalFingerprint,
3722
3178
  q.created_at AS createdAt, q.updated_at AS updatedAt,
3723
3179
  q.started_at AS startedAt, q.finished_at AS finishedAt,
3724
3180
  l.generation AS leaseGeneration, l.owner_id AS ownerId, l.expires_at AS claimExpiresAt
@@ -3733,8 +3189,10 @@ function workflowRunViewSelect(clause: string): string {
3733
3189
  r.workflow_ref AS workflowRef, r.status AS runStatus, r.paused,
3734
3190
  r.definition_digest AS definitionDigest, q.status,
3735
3191
  b.origin_session_id AS originSessionId, b.execution_mode AS executionMode,
3736
- r.parent_run_id AS parentRunId, q.error_code AS errorCode,
3737
- CAST(error.content AS TEXT) AS errorMessage,
3192
+ r.parent_run_id AS parentRunId, r.root_run_id AS rootRunId,
3193
+ r.lineage_kind AS lineageKind, r.restart_number AS restartNumber,
3194
+ r.parent_terminal_fingerprint AS parentTerminalFingerprint,
3195
+ q.error_code AS errorCode, CAST(error.content AS TEXT) AS errorMessage,
3738
3196
  q.created_at AS createdAt, q.updated_at AS updatedAt,
3739
3197
  q.started_at AS startedAt, q.finished_at AS finishedAt,
3740
3198
  source.source_type AS sourceType, source.source_ref AS sourceRef,
@@ -3746,31 +3204,6 @@ function workflowRunViewSelect(clause: string): string {
3746
3204
  LEFT JOIN blobs error ON error.blob_hash = q.error_hash ${clause}`;
3747
3205
  }
3748
3206
 
3749
- function turnIntentSelect(clause: string): string {
3750
- return `SELECT t.turn_intent_id AS intentId, t.source_event_id AS sourceEventId,
3751
- t.run_id AS runId, t.workflow_ref AS workflowRef,
3752
- t.target_session_id AS targetSessionId, t.cause,
3753
- t.node_id AS nodeId, t.attempt_id AS attemptId, t.facts_hash AS factsHash,
3754
- t.requested_at AS requestedAt, t.eligible_at AS eligibleAt,
3755
- t.resolved_at AS resolvedAt, t.resolution_type AS resolution,
3756
- t.resolution_message_id AS resolutionMessageId, l.expires_at AS claimExpiresAt,
3757
- t.effect_id AS effectId
3758
- FROM turn_intents t JOIN effects e ON e.effect_id = t.effect_id
3759
- JOIN leases l ON l.resource_id = e.resource_id ${clause}`;
3760
- }
3761
-
3762
- function notificationSelect(clause: string): string {
3763
- return `SELECT n.notification_id AS notificationId, n.effect_id AS effectId,
3764
- n.run_id AS runId, a.node_id AS nodeId, n.attempt_id AS attemptId,
3765
- n.notification_index AS notificationIndex, n.target_session_id AS targetSessionId,
3766
- n.notification_type AS kind, n.content_hash AS contentHash,
3767
- n.created_at AS createdAt, l.expires_at AS claimExpiresAt,
3768
- e.settled_at AS deliveredAt
3769
- FROM notifications n JOIN effects e ON e.effect_id = n.effect_id
3770
- JOIN leases l ON l.resource_id = e.resource_id
3771
- JOIN node_attempts a ON a.attempt_id = n.attempt_id ${clause}`;
3772
- }
3773
-
3774
3207
  type QueueListRow = {
3775
3208
  controller: string;
3776
3209
  resourceKey: string;
@@ -3795,39 +3228,6 @@ type RunEventRow = {
3795
3228
  runnerId: string | null;
3796
3229
  payloadHash: Buffer | null;
3797
3230
  };
3798
- type TurnIntentRow = {
3799
- intentId: string;
3800
- sourceEventId: string;
3801
- runId: string;
3802
- workflowRef: string;
3803
- targetSessionId: string;
3804
- cause: WorkflowTurnIntentCause;
3805
- nodeId: string | null;
3806
- attemptId: string | null;
3807
- factsHash: Buffer;
3808
- requestedAt: number;
3809
- eligibleAt: number | null;
3810
- resolvedAt: number | null;
3811
- resolution: WorkflowTurnIntentResolution | null;
3812
- resolutionMessageId: string | null;
3813
- claimExpiresAt: number | null;
3814
- effectId: string;
3815
- };
3816
- type NotificationRow = {
3817
- notificationId: string;
3818
- effectId: string;
3819
- runId: string;
3820
- nodeId: string;
3821
- attemptId: string;
3822
- notificationIndex: number;
3823
- targetSessionId: string;
3824
- kind: "progress" | "final";
3825
- contentHash: Buffer;
3826
- createdAt: number;
3827
- claimExpiresAt: number | null;
3828
- deliveredAt: number | null;
3829
- };
3830
-
3831
3231
  function isRecord(value: unknown): value is Record<string, unknown> {
3832
3232
  return typeof value === "object" && value !== null && !Array.isArray(value);
3833
3233
  }
@@ -3855,6 +3255,10 @@ function workflowRunViewRecord(row: WorkflowRunViewRow): WorkflowRunQueueViewRec
3855
3255
  originSessionId: row.executionMode === "headless" ? null : row.originSessionId,
3856
3256
  executionMode: row.executionMode,
3857
3257
  parentRunId: row.parentRunId,
3258
+ rootRunId: row.rootRunId,
3259
+ lineageKind: row.lineageKind,
3260
+ restartNumber: row.restartNumber,
3261
+ parentTerminalFingerprint: row.parentTerminalFingerprint?.toString("hex") ?? null,
3858
3262
  errorCode: row.errorCode,
3859
3263
  errorMessage: row.errorMessage,
3860
3264
  createdAt: new Date(row.createdAt).toISOString(),
@@ -3877,6 +3281,13 @@ function isWorkflowRunViewRow(value: unknown): value is WorkflowRunViewRow {
3877
3281
  (value.originSessionId === null || typeof value.originSessionId === "string") &&
3878
3282
  (value.executionMode === "interactive" || value.executionMode === "headless") &&
3879
3283
  (value.parentRunId === null || typeof value.parentRunId === "string") &&
3284
+ typeof value.rootRunId === "string" &&
3285
+ (value.lineageKind === null ||
3286
+ value.lineageKind === "continuation" ||
3287
+ value.lineageKind === "restart") &&
3288
+ typeof value.restartNumber === "number" &&
3289
+ (value.parentTerminalFingerprint === null ||
3290
+ Buffer.isBuffer(value.parentTerminalFingerprint)) &&
3880
3291
  (value.errorCode === null || typeof value.errorCode === "string") &&
3881
3292
  (value.errorMessage === null || typeof value.errorMessage === "string") &&
3882
3293
  typeof value.createdAt === "number" &&
@@ -3933,12 +3344,6 @@ function isControllerEventRow(value: unknown): value is ControllerEventRow {
3933
3344
  function isRunEventRow(value: unknown): value is RunEventRow {
3934
3345
  return isRecord(value);
3935
3346
  }
3936
- function isTurnIntentRow(value: unknown): value is TurnIntentRow {
3937
- return isRecord(value);
3938
- }
3939
- function isNotificationRow(value: unknown): value is NotificationRow {
3940
- return isRecord(value);
3941
- }
3942
3347
  function isProjectRow(value: unknown): value is { projectId: string } {
3943
3348
  return isRecord(value);
3944
3349
  }
@@ -3963,14 +3368,6 @@ function isSequenceRow(value: unknown): value is { seq: number } {
3963
3368
  function isKeyRow(value: unknown): value is { key: string } {
3964
3369
  return isRecord(value);
3965
3370
  }
3966
- function isResourceIdRow(value: unknown): value is { resourceId: string } {
3967
- return isRecord(value);
3968
- }
3969
- function isEffectTokenRow(
3970
- value: unknown,
3971
- ): value is { tokenHash: Buffer | null; expiresAt: number | null } {
3972
- return isRecord(value);
3973
- }
3974
3371
  function isEffectIdentityRow(value: unknown): value is { effectId: string; resourceId: string } {
3975
3372
  return isRecord(value);
3976
3373
  }
@@ -3993,6 +3390,11 @@ function digestBuffer(value: string): Buffer {
3993
3390
  if (!/^[a-f0-9]{64}$/i.test(hex)) throw new Error("Expected a SHA-256 digest");
3994
3391
  return Buffer.from(hex, "hex");
3995
3392
  }
3393
+ function terminalFingerprintBuffer(value: string): Buffer {
3394
+ const hex = value.startsWith("sha256:") ? value.slice(7) : value;
3395
+ if (!/^[a-f0-9]{64}$/i.test(hex)) throw new Error("Expected a terminal fingerprint");
3396
+ return Buffer.from(hex, "hex");
3397
+ }
3996
3398
  type QueuedSource = {
3997
3399
  root:
3998
3400
  | { kind: "builtin"; id: string; revision: string }