@osolmaz/pi-workflows 0.15.3 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.md +24 -8
  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/activity.d.ts +2 -0
  15. package/dist/client/activity.js +6 -0
  16. package/dist/client/activity.js.map +1 -0
  17. package/dist/client/client.d.ts +102 -0
  18. package/dist/client/client.js +737 -0
  19. package/dist/client/client.js.map +1 -0
  20. package/dist/client/index.d.ts +3 -0
  21. package/dist/client/index.js +3 -0
  22. package/dist/client/index.js.map +1 -0
  23. package/dist/client/materialize.d.ts +7 -0
  24. package/dist/client/materialize.js +177 -0
  25. package/dist/client/materialize.js.map +1 -0
  26. package/dist/client/protocol.d.ts +60 -0
  27. package/dist/client/protocol.js +275 -0
  28. package/dist/client/protocol.js.map +1 -0
  29. package/dist/client/resolver.d.ts +23 -0
  30. package/dist/client/resolver.js +2 -0
  31. package/dist/client/resolver.js.map +1 -0
  32. package/dist/client/view.d.ts +144 -0
  33. package/dist/client/view.js +3 -0
  34. package/dist/client/view.js.map +1 -0
  35. package/dist/controllers/index.d.ts +1 -1
  36. package/dist/controllers/index.js.map +1 -1
  37. package/dist/controllers/sqlite.d.ts +47 -157
  38. package/dist/controllers/sqlite.js +230 -406
  39. package/dist/controllers/sqlite.js.map +1 -1
  40. package/dist/extension/index.d.ts +23 -1
  41. package/dist/extension/index.js +734 -555
  42. package/dist/extension/index.js.map +1 -1
  43. package/dist/extension/recorder.d.ts +95 -0
  44. package/dist/extension/recorder.js +530 -0
  45. package/dist/extension/recorder.js.map +1 -0
  46. package/dist/extension/remote-recorder-store.d.ts +25 -0
  47. package/dist/extension/remote-recorder-store.js +81 -0
  48. package/dist/extension/remote-recorder-store.js.map +1 -0
  49. package/dist/extension/session-events.d.ts +134 -0
  50. package/dist/extension/session-events.js +60 -0
  51. package/dist/extension/session-events.js.map +1 -0
  52. package/dist/extension/session-view.d.ts +9 -2
  53. package/dist/extension/session-view.js +117 -51
  54. package/dist/extension/session-view.js.map +1 -1
  55. package/dist/extension/step-message.d.ts +6 -4
  56. package/dist/extension/step-message.js +12 -6
  57. package/dist/extension/step-message.js.map +1 -1
  58. package/dist/extension/widget.d.ts +3 -2
  59. package/dist/extension/widget.js +41 -24
  60. package/dist/extension/widget.js.map +1 -1
  61. package/dist/extension/workflow-message-coordinator.d.ts +27 -0
  62. package/dist/extension/workflow-message-coordinator.js +294 -0
  63. package/dist/extension/workflow-message-coordinator.js.map +1 -0
  64. package/dist/host/channel-effects.d.ts +72 -0
  65. package/dist/host/channel-effects.js +271 -0
  66. package/dist/host/channel-effects.js.map +1 -0
  67. package/dist/host/channel-supervisor.d.ts +21 -0
  68. package/dist/host/channel-supervisor.js +54 -0
  69. package/dist/host/channel-supervisor.js.map +1 -0
  70. package/dist/host/child-worker-supervisor.js +1 -1
  71. package/dist/host/child-worker-supervisor.js.map +1 -1
  72. package/dist/host/resolver-entry.d.ts +2 -23
  73. package/dist/host/resolver-entry.js +1 -1
  74. package/dist/host/resolver-entry.js.map +1 -1
  75. package/dist/host/runner.d.ts +54 -8
  76. package/dist/host/runner.js +1997 -305
  77. package/dist/host/runner.js.map +1 -1
  78. package/dist/host/state.d.ts +25 -19
  79. package/dist/host/state.js +303 -89
  80. package/dist/host/state.js.map +1 -1
  81. package/dist/host/view.d.ts +83 -0
  82. package/dist/host/view.js +975 -0
  83. package/dist/host/view.js.map +1 -0
  84. package/dist/host/worker-protocol.js +1 -1
  85. package/dist/host/worker-protocol.js.map +1 -1
  86. package/dist/state/database.d.ts +1 -0
  87. package/dist/state/database.js +15 -0
  88. package/dist/state/database.js.map +1 -1
  89. package/dist/state/prune.d.ts +3 -1
  90. package/dist/state/prune.js +9 -33
  91. package/dist/state/prune.js.map +1 -1
  92. package/dist/state/schema.js +88 -98
  93. package/dist/state/schema.js.map +1 -1
  94. package/dist/state/workflow-messages.d.ts +96 -0
  95. package/dist/state/workflow-messages.js +335 -0
  96. package/dist/state/workflow-messages.js.map +1 -0
  97. package/dist/viewer/backup.d.ts +2 -0
  98. package/dist/viewer/backup.js +28 -0
  99. package/dist/viewer/backup.js.map +1 -0
  100. package/dist/viewer/cli.d.ts +4 -0
  101. package/dist/viewer/cli.js +150 -170
  102. package/dist/viewer/cli.js.map +1 -1
  103. package/dist/viewer/render.js +1 -1
  104. package/dist/viewer/render.js.map +1 -1
  105. package/dist/viewer/tui.d.ts +5 -7
  106. package/dist/viewer/tui.js +245 -108
  107. package/dist/viewer/tui.js.map +1 -1
  108. package/dist/workflows/human-decision.d.ts +2 -0
  109. package/dist/workflows/human-decision.js +8 -2
  110. package/dist/workflows/human-decision.js.map +1 -1
  111. package/dist/workflows/index.d.ts +1 -1
  112. package/dist/workflows/index.js.map +1 -1
  113. package/dist/workflows/settings.d.ts +1 -8
  114. package/dist/workflows/settings.js.map +1 -1
  115. package/dist/workflows/store.d.ts +67 -20
  116. package/dist/workflows/store.js +451 -378
  117. package/dist/workflows/store.js.map +1 -1
  118. package/dist/workflows/tool-input.d.ts +21 -0
  119. package/dist/workflows/tool-input.js +23 -1
  120. package/dist/workflows/tool-input.js.map +1 -1
  121. package/dist/workflows/types.d.ts +5 -5
  122. package/dist/workflows/workflow-message-content.d.ts +38 -0
  123. package/dist/workflows/workflow-message-content.js +157 -0
  124. package/dist/workflows/workflow-message-content.js.map +1 -0
  125. package/docs/2026-08-18-herdr-piw-plan.md +2 -1
  126. package/docs/2026-08-20-durable-workflow-launch-plan.md +1 -1
  127. package/docs/2026-08-25-workflow-follow-ups.md +26 -50
  128. package/docs/2026-08-30-out-of-process-workflow-host-plan.md +3 -3
  129. package/docs/2026-09-01-restore-session-delivery-controls-plan.md +4 -0
  130. package/docs/2026-09-01-unified-workflow-client-plan.md +385 -0
  131. package/docs/2026-09-02-installed-live-e2e-plan.md +239 -0
  132. package/docs/2026-09-02-unify-workflow-messages-plan.md +562 -0
  133. package/docs/DEFERRED_TURNS.md +66 -294
  134. package/docs/HUMAN_DECISIONS.md +29 -30
  135. package/docs/HUMAN_DECISION_PRESENTATIONS.md +4 -11
  136. package/docs/SQLITE_STATE.md +31 -25
  137. package/docs/WORKFLOW_HOST.md +155 -77
  138. package/docs/WORKFLOW_STEP_MESSAGES.md +166 -75
  139. package/docs/development.md +2 -1
  140. package/docs/live-replay-protocol.md +74 -132
  141. package/docs/tui-viewer.md +12 -14
  142. package/docs/workflows.md +78 -28
  143. package/herdr-plugin.toml +1 -1
  144. package/package.json +9 -3
  145. package/plugins/herdr/viewer.mjs +14 -5
  146. package/protocol/client.v1.schema.json +137 -0
  147. package/protocol/fixtures/client-v1.json +23 -0
  148. package/src/channels/adapter-entry.ts +220 -0
  149. package/src/channels/config.ts +296 -0
  150. package/src/channels/protocol.ts +333 -0
  151. package/src/channels/telegram.ts +335 -0
  152. package/src/client/activity.ts +6 -0
  153. package/src/client/client.ts +939 -0
  154. package/src/client/index.ts +25 -0
  155. package/src/client/materialize.ts +228 -0
  156. package/src/client/protocol.ts +333 -0
  157. package/src/client/resolver.ts +26 -0
  158. package/src/client/view.ts +163 -0
  159. package/src/controllers/index.ts +0 -1
  160. package/src/controllers/sqlite.ts +339 -808
  161. package/src/extension/index.ts +853 -665
  162. package/src/extension/recorder.ts +687 -0
  163. package/src/extension/remote-recorder-store.ts +126 -0
  164. package/src/extension/session-events.ts +119 -0
  165. package/src/extension/session-view.ts +157 -51
  166. package/src/extension/step-message.ts +19 -9
  167. package/src/extension/widget.ts +53 -25
  168. package/src/extension/workflow-message-coordinator.ts +334 -0
  169. package/src/host/channel-effects.ts +465 -0
  170. package/src/host/channel-supervisor.ts +73 -0
  171. package/src/host/child-worker-supervisor.ts +1 -1
  172. package/src/host/resolver-entry.ts +11 -26
  173. package/src/host/runner.ts +2639 -450
  174. package/src/host/state.ts +404 -137
  175. package/src/host/view.ts +1212 -0
  176. package/src/host/worker-protocol.ts +1 -1
  177. package/src/state/database.ts +13 -0
  178. package/src/state/prune.ts +14 -42
  179. package/src/state/schema.ts +88 -98
  180. package/src/state/workflow-messages.ts +548 -0
  181. package/src/viewer/backup.ts +29 -0
  182. package/src/viewer/cli.ts +171 -185
  183. package/src/viewer/render.ts +1 -5
  184. package/src/viewer/tui.ts +261 -124
  185. package/src/workflows/human-decision.ts +18 -2
  186. package/src/workflows/index.ts +0 -1
  187. package/src/workflows/settings.ts +1 -20
  188. package/src/workflows/store.ts +650 -511
  189. package/src/workflows/tool-input.ts +36 -1
  190. package/src/workflows/types.ts +5 -5
  191. package/src/workflows/workflow-message-content.ts +197 -0
  192. package/dist/extension/decision-channels.d.ts +0 -134
  193. package/dist/extension/decision-channels.js +0 -1307
  194. package/dist/extension/decision-channels.js.map +0 -1
  195. package/dist/host/client.d.ts +0 -48
  196. package/dist/host/client.js +0 -216
  197. package/dist/host/client.js.map +0 -1
  198. package/dist/host/protocol.d.ts +0 -38
  199. package/dist/host/protocol.js +0 -156
  200. package/dist/host/protocol.js.map +0 -1
  201. package/dist/viewer/watch.d.ts +0 -6
  202. package/dist/viewer/watch.js +0 -46
  203. package/dist/viewer/watch.js.map +0 -1
  204. package/src/extension/decision-channels.ts +0 -1826
  205. package/src/host/client.ts +0 -293
  206. package/src/host/protocol.ts +0 -196
  207. package/src/viewer/watch.ts +0 -51
@@ -30,8 +30,6 @@ import type {
30
30
  JsonObject,
31
31
  } from "./types.js";
32
32
 
33
- const TURN_INTENT_FACTS_SCHEMA = "pi-workflows.deferred-turn-facts.v1";
34
-
35
33
  export type WorkflowRunLaunchStatus =
36
34
  | "queued"
37
35
  | "starting"
@@ -54,6 +52,9 @@ export type WorkflowRunReservationOptions = {
54
52
  originSessionId: string;
55
53
  executionMode?: "interactive" | "headless";
56
54
  parentRunId?: string;
55
+ lineageKind?: "continuation" | "restart";
56
+ restartNumber?: number;
57
+ parentTerminalFingerprint?: string;
57
58
  now?: string;
58
59
  };
59
60
 
@@ -62,6 +63,31 @@ export type WorkflowRunClaimOptions = WorkflowRunReservationOptions & {
62
63
  leaseMs: number;
63
64
  };
64
65
 
66
+ export type WorkflowRunQueueViewRecord = {
67
+ runId: string;
68
+ workflowName: string;
69
+ workflowSourceRef: string;
70
+ workflowSource: unknown;
71
+ initialized: boolean;
72
+ definitionDigest: string;
73
+ runStateStatus: string;
74
+ paused: boolean;
75
+ status: WorkflowRunLaunchStatus;
76
+ originSessionId: string | null;
77
+ executionMode: "interactive" | "headless";
78
+ parentRunId: string | null;
79
+ rootRunId: string;
80
+ lineageKind: "continuation" | "restart" | null;
81
+ restartNumber: number;
82
+ parentTerminalFingerprint: string | null;
83
+ errorCode: string | null;
84
+ errorMessage: string | null;
85
+ createdAt: string;
86
+ updatedAt: string;
87
+ startedAt: string | null;
88
+ finishedAt: string | null;
89
+ };
90
+
65
91
  export type WorkflowRunQueueRecord = {
66
92
  runId: string;
67
93
  workflowName: string;
@@ -80,6 +106,10 @@ export type WorkflowRunQueueRecord = {
80
106
  originSessionId: string | null;
81
107
  executionMode: "interactive" | "headless";
82
108
  parentRunId: string | null;
109
+ rootRunId: string;
110
+ lineageKind: "continuation" | "restart" | null;
111
+ restartNumber: number;
112
+ parentTerminalFingerprint: string | null;
83
113
  errorCode: string | null;
84
114
  errorMessage: string | null;
85
115
  createdAt: string;
@@ -98,62 +128,6 @@ export type WorkflowRunPreparationResult =
98
128
  run: WorkflowRunQueueRecord;
99
129
  };
100
130
 
101
- export type WorkflowNotificationRecord = {
102
- notificationId: string;
103
- runId: string;
104
- nodeId: string;
105
- attemptId: string;
106
- notificationIndex: number;
107
- targetSessionId: string;
108
- kind: "progress" | "final";
109
- content: string;
110
- createdAt: string;
111
- deliveryClaimExpiresAt: string | null;
112
- deliveredAt: string | null;
113
- };
114
-
115
- export type WorkflowTurnIntentCause =
116
- | "agentCancelled"
117
- | "timedOut"
118
- | "failed"
119
- | "launchFailed"
120
- | "controllerInterrupted"
121
- | "claimLost"
122
- | "terminal"
123
- | "cancelled";
124
-
125
- export type WorkflowTurnIntentResolution = "workflowPrompt" | "presentation" | "fallback";
126
-
127
- export type WorkflowTurnIntentFacts = JsonObject & {
128
- schema: typeof TURN_INTENT_FACTS_SCHEMA;
129
- workflowName: string;
130
- runId: string;
131
- observedState: string;
132
- cause: WorkflowTurnIntentCause;
133
- nodeId: string | null;
134
- attemptId: string | null;
135
- reason: string | null;
136
- handoff: boolean;
137
- };
138
-
139
- export type WorkflowTurnIntentRecord = {
140
- intentId: string;
141
- sourceEventId: string;
142
- runId: string;
143
- workflowRef: string;
144
- targetSessionId: string;
145
- cause: WorkflowTurnIntentCause;
146
- nodeId: string | null;
147
- attemptId: string | null;
148
- fallbackFacts: WorkflowTurnIntentFacts;
149
- requestedAt: string;
150
- eligibleAt: string | null;
151
- resolvedAt: string | null;
152
- resolution: WorkflowTurnIntentResolution | null;
153
- resolutionMessageId: string | null;
154
- deliveryClaimExpiresAt: string | null;
155
- };
156
-
157
131
  export type RunEventRecord = {
158
132
  seq: number;
159
133
  recordedAt: string;
@@ -205,6 +179,10 @@ type RunRow = {
205
179
  originSessionId: string | null;
206
180
  executionMode: "interactive" | "headless";
207
181
  parentRunId: string | null;
182
+ rootRunId: string;
183
+ lineageKind: "continuation" | "restart" | null;
184
+ restartNumber: number;
185
+ parentTerminalFingerprint: Buffer | null;
208
186
  createdAt: number;
209
187
  updatedAt: number;
210
188
  startedAt: number | null;
@@ -213,6 +191,38 @@ type RunRow = {
213
191
  ownerId: string | null;
214
192
  claimExpiresAt: number | null;
215
193
  };
194
+ type WorkflowRunViewRow = {
195
+ runId: string;
196
+ workflowName: string;
197
+ workflowRef: string;
198
+ runStatus: string;
199
+ paused: number;
200
+ definitionDigest: Buffer;
201
+ status: WorkflowRunLaunchStatus;
202
+ originSessionId: string | null;
203
+ executionMode: "interactive" | "headless";
204
+ parentRunId: string | null;
205
+ rootRunId: string;
206
+ lineageKind: "continuation" | "restart" | null;
207
+ restartNumber: number;
208
+ parentTerminalFingerprint: Buffer | null;
209
+ errorCode: string | null;
210
+ errorMessage: string | null;
211
+ createdAt: number;
212
+ updatedAt: number;
213
+ startedAt: number | null;
214
+ finishedAt: number | null;
215
+ sourceType: "builtin" | "file";
216
+ sourceRef: string;
217
+ sourceRevision: string;
218
+ };
219
+
220
+ type RunListRevisionRow = {
221
+ count: number;
222
+ revisionSum: number;
223
+ updatedAt: number;
224
+ };
225
+
216
226
  type RunSourceIdentityRow = {
217
227
  mountPath: string;
218
228
  sourceType: "builtin" | "file";
@@ -1080,39 +1090,77 @@ export class SqliteControllerStore implements ControllerStore {
1080
1090
  if (this.getWorkflowRun(options.runId) !== undefined) {
1081
1091
  throw new Error(`Workflow run already reserved: ${options.runId}`);
1082
1092
  }
1093
+ const lineageKind =
1094
+ options.parentRunId === undefined ? null : (options.lineageKind ?? "continuation");
1095
+ if (options.parentRunId === undefined && options.lineageKind !== undefined) {
1096
+ throw new Error("A root workflow run cannot declare lineage");
1097
+ }
1098
+ let rootRunId = options.runId;
1099
+ let restartNumber = 0;
1100
+ let parentTerminalFingerprint: Buffer | null = null;
1083
1101
  if (options.parentRunId !== undefined) {
1084
1102
  const parent = this.requireWorkflowRunRow(options.parentRunId);
1085
1103
  if (parent.originSessionId !== options.originSessionId) {
1086
- throw new Error("Continuation parent belongs to another Pi session");
1104
+ throw new Error("Workflow parent belongs to another Pi session");
1087
1105
  }
1088
- if (parent.status === "parked") {
1089
- const parentLease = this.requireLease(parent.resourceId);
1090
- if (parentLease.ownerId !== null) {
1091
- throw new Error("Continuation parent still has an active owner");
1106
+ rootRunId = parent.rootRunId;
1107
+ if (lineageKind === "restart") {
1108
+ if (
1109
+ !["completed", "failed", "timed_out", "cancelled"].includes(parent.runStatus) ||
1110
+ !["done", "failed", "cancelled"].includes(parent.status)
1111
+ ) {
1112
+ throw new Error("Restart parent is not terminal");
1113
+ }
1114
+ if (options.parentTerminalFingerprint === undefined) {
1115
+ throw new Error("Restart requires the parent terminal fingerprint");
1116
+ }
1117
+ parentTerminalFingerprint = terminalFingerprintBuffer(options.parentTerminalFingerprint);
1118
+ restartNumber = options.restartNumber ?? parent.restartNumber + 1;
1119
+ if (restartNumber !== parent.restartNumber + 1) {
1120
+ throw new Error("Restart number does not follow its parent");
1092
1121
  }
1093
- this.state.connection
1094
- .prepare(
1095
- `UPDATE run_queue
1096
- SET status = 'done', updated_at = ?, finished_at = ?
1097
- WHERE run_id = ? AND status = 'parked'`,
1098
- )
1099
- .run(now, now, parent.runId);
1100
- const parentRevision = this.resourceRevision(parent.resourceId);
1101
- this.bumpResource(parent.resourceId, parentRevision, now);
1102
- this.insertEvent(
1103
- parent.resourceId,
1104
- parentRevision + 1,
1105
- "run.queue_done_for_continuation",
1106
- "session",
1107
- options.originSessionId,
1108
- { continuationRunId: options.runId },
1109
- now,
1110
- );
1111
- } else if (parent.status === "done") {
1112
- throw new Error("Continuation parent already has a reserved continuation");
1113
1122
  } else {
1114
- throw new Error(`Continuation parent queue is ${parent.status}`);
1123
+ if (
1124
+ options.parentTerminalFingerprint !== undefined ||
1125
+ (options.restartNumber !== undefined && options.restartNumber !== parent.restartNumber)
1126
+ ) {
1127
+ throw new Error("Continuation lineage conflicts with restart metadata");
1128
+ }
1129
+ restartNumber = parent.restartNumber;
1130
+ if (parent.status === "parked") {
1131
+ const parentLease = this.requireLease(parent.resourceId);
1132
+ if (parentLease.ownerId !== null) {
1133
+ throw new Error("Continuation parent still has an active owner");
1134
+ }
1135
+ this.state.connection
1136
+ .prepare(
1137
+ `UPDATE run_queue
1138
+ SET status = 'done', updated_at = ?, finished_at = ?
1139
+ WHERE run_id = ? AND status = 'parked'`,
1140
+ )
1141
+ .run(now, now, parent.runId);
1142
+ const parentRevision = this.resourceRevision(parent.resourceId);
1143
+ this.bumpResource(parent.resourceId, parentRevision, now);
1144
+ this.insertEvent(
1145
+ parent.resourceId,
1146
+ parentRevision + 1,
1147
+ "run.queue_done_for_continuation",
1148
+ "session",
1149
+ options.originSessionId,
1150
+ { continuationRunId: options.runId },
1151
+ now,
1152
+ );
1153
+ } else if (parent.status === "done") {
1154
+ throw new Error("Continuation parent already has a reserved continuation");
1155
+ } else {
1156
+ throw new Error(`Continuation parent queue is ${parent.status}`);
1157
+ }
1115
1158
  }
1159
+ } else if (
1160
+ options.restartNumber !== undefined ||
1161
+ options.parentTerminalFingerprint !== undefined
1162
+ ) {
1163
+ throw new Error("A root workflow run cannot declare restart metadata");
1116
1164
  }
1117
1165
  const resourceId = resourceIdFor("run", options.runId);
1118
1166
  const definitionHash = this.state.putJson(options.definitionSnapshot, now);
@@ -1139,16 +1187,21 @@ export class SqliteControllerStore implements ControllerStore {
1139
1187
  this.state.connection
1140
1188
  .prepare(
1141
1189
  `INSERT INTO runs(
1142
- run_id, resource_id, project_id, parent_run_id, definition_digest,
1190
+ run_id, resource_id, project_id, parent_run_id, root_run_id, lineage_kind,
1191
+ restart_number, parent_terminal_fingerprint, definition_digest,
1143
1192
  workflow_ref, launch_options_hash, status, paused,
1144
1193
  input_hash, created_at, updated_at
1145
- ) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`,
1194
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`,
1146
1195
  )
1147
1196
  .run(
1148
1197
  options.runId,
1149
1198
  resourceId,
1150
1199
  this.requireProjectId(),
1151
1200
  options.parentRunId ?? null,
1201
+ rootRunId,
1202
+ lineageKind,
1203
+ restartNumber,
1204
+ parentTerminalFingerprint,
1152
1205
  definitionDigest,
1153
1206
  options.workflowSourceRef,
1154
1207
  launchHash,
@@ -1218,6 +1271,11 @@ export class SqliteControllerStore implements ControllerStore {
1218
1271
  runnerId: options.runnerId,
1219
1272
  originSessionId: options.originSessionId,
1220
1273
  ...(options.parentRunId === undefined ? {} : { parentRunId: options.parentRunId }),
1274
+ ...(options.lineageKind === undefined ? {} : { lineageKind: options.lineageKind }),
1275
+ ...(options.restartNumber === undefined ? {} : { restartNumber: options.restartNumber }),
1276
+ ...(options.parentTerminalFingerprint === undefined
1277
+ ? {}
1278
+ : { parentTerminalFingerprint: options.parentTerminalFingerprint }),
1221
1279
  ...(options.now === undefined ? {} : { now: options.now }),
1222
1280
  });
1223
1281
  }
@@ -1239,6 +1297,40 @@ export class SqliteControllerStore implements ControllerStore {
1239
1297
  return row === undefined ? undefined : this.mapWorkflowRun(row);
1240
1298
  }
1241
1299
 
1300
+ getWorkflowRunView(runId: string): WorkflowRunQueueViewRecord | undefined {
1301
+ const row = this.workflowRunViewRows("WHERE r.run_id = ?", [runId])[0];
1302
+ return row === undefined ? undefined : workflowRunViewRecord(row);
1303
+ }
1304
+
1305
+ workflowRunListRevision(): { total: number; revision: string } {
1306
+ const row = this.state.connection
1307
+ .prepare(
1308
+ `SELECT count(*) AS count, COALESCE(sum(res.revision), 0) AS revisionSum,
1309
+ COALESCE(max(q.updated_at), 0) AS updatedAt
1310
+ FROM runs r JOIN run_queue q ON q.run_id = r.run_id
1311
+ JOIN resources res ON res.resource_id = r.resource_id`,
1312
+ )
1313
+ .get();
1314
+ if (!isRunListRevisionRow(row)) throw new Error("Workflow run list revision is invalid");
1315
+ return {
1316
+ total: row.count,
1317
+ revision: `${row.count}:${row.revisionSum}:${row.updatedAt}`,
1318
+ };
1319
+ }
1320
+
1321
+ listWorkflowRunViews(options: { offset: number; limit: number }): {
1322
+ total: number;
1323
+ revision: string;
1324
+ runs: WorkflowRunQueueViewRecord[];
1325
+ } {
1326
+ const current = this.workflowRunListRevision();
1327
+ const rows = this.workflowRunViewRows("ORDER BY q.created_at DESC LIMIT ? OFFSET ?", [
1328
+ options.limit,
1329
+ options.offset,
1330
+ ]);
1331
+ return { ...current, runs: rows.map(workflowRunViewRecord) };
1332
+ }
1333
+
1242
1334
  workflowRunProjectPath(runId: string): string | undefined {
1243
1335
  const row = this.state.connection
1244
1336
  .prepare(
@@ -1270,11 +1362,11 @@ export class SqliteControllerStore implements ControllerStore {
1270
1362
  clauses.push(`r.run_id NOT IN (${options.excludeRunIds.map(() => "?").join(", ")})`);
1271
1363
  params.push(...options.excludeRunIds);
1272
1364
  }
1273
- params.push(options.limit ?? 100);
1365
+ if (options.limit !== undefined) params.push(options.limit);
1274
1366
  const rows = this.state.connection
1275
1367
  .prepare(
1276
1368
  workflowRunSelect(
1277
- `${clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`} ORDER BY q.created_at DESC LIMIT ?`,
1369
+ `${clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`} ORDER BY q.created_at DESC${options.limit === undefined ? "" : " LIMIT ?"}`,
1278
1370
  ),
1279
1371
  )
1280
1372
  .all(...params);
@@ -1292,6 +1384,14 @@ export class SqliteControllerStore implements ControllerStore {
1292
1384
  return isRunRow(row) ? this.mapWorkflowRun(row) : undefined;
1293
1385
  }
1294
1386
 
1387
+ findSessionReservationView(sessionId: string): WorkflowRunQueueViewRecord | undefined {
1388
+ const row = this.workflowRunViewRows(
1389
+ "WHERE b.origin_session_id = ? AND q.status NOT IN ('done', 'failed', 'cancelled') ORDER BY q.created_at DESC LIMIT 1",
1390
+ [sessionId],
1391
+ )[0];
1392
+ return row === undefined ? undefined : workflowRunViewRecord(row);
1393
+ }
1394
+
1295
1395
  claimWorkflowRun(options: {
1296
1396
  runId: string;
1297
1397
  runnerId: string;
@@ -1345,6 +1445,7 @@ export class SqliteControllerStore implements ControllerStore {
1345
1445
 
1346
1446
  beginWorkflowRunInteractionTimeout(options: {
1347
1447
  runId: string;
1448
+ targetSessionId: string;
1348
1449
  claimToken: string;
1349
1450
  now?: string;
1350
1451
  }): boolean {
@@ -1365,18 +1466,24 @@ export class SqliteControllerStore implements ControllerStore {
1365
1466
  `SELECT i.request_id AS requestId, i.attempt_id AS attemptId
1366
1467
  FROM interactive_requests i
1367
1468
  JOIN node_attempts a ON a.attempt_id = i.attempt_id
1368
- WHERE i.run_id = ? AND i.status IN ('pending', 'presenting')
1469
+ JOIN runs r ON r.run_id = i.run_id
1470
+ WHERE i.run_id = ? AND i.target_session_id = ?
1471
+ AND i.status = 'pending' AND r.paused = 0
1369
1472
  AND a.deadline_at IS NOT NULL AND a.deadline_at <= ?
1473
+ AND EXISTS (
1474
+ SELECT 1 FROM workflow_messages m
1475
+ JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id
1476
+ WHERE m.source_id = i.request_id AND m.kind = 'step' AND t.state = 'started'
1477
+ )
1370
1478
  ORDER BY a.deadline_at, i.request_id LIMIT 1`,
1371
1479
  )
1372
- .get(options.runId, now);
1480
+ .get(options.runId, options.targetSessionId, now);
1373
1481
  if (!isExpiredInteractionRow(interaction)) return false;
1374
1482
  const request = this.state.connection
1375
1483
  .prepare(
1376
1484
  `UPDATE interactive_requests
1377
- SET status = 'cancelled', presenter_id = NULL,
1378
- presentation_claim_expires_at = NULL, revision = revision + 1, updated_at = ?
1379
- WHERE request_id = ? AND run_id = ? AND status IN ('pending', 'presenting')`,
1485
+ SET status = 'cancelled', revision = revision + 1, updated_at = ?
1486
+ WHERE request_id = ? AND run_id = ? AND status = 'pending'`,
1380
1487
  )
1381
1488
  .run(now, interaction.requestId, options.runId);
1382
1489
  const run = this.state.connection
@@ -1398,7 +1505,7 @@ export class SqliteControllerStore implements ControllerStore {
1398
1505
  if (request.changes !== 1 || run.changes !== 1 || queue.changes !== 1) {
1399
1506
  throw new Error(`Workflow run ${options.runId} changed during interaction timeout`);
1400
1507
  }
1401
- const error = "Workflow node deadline expired before origin-session input arrived";
1508
+ const error = "Workflow node model-turn deadline expired";
1402
1509
  const receiptHash = this.state.putJson({ status: "rejected", error }, now);
1403
1510
  this.state.connection
1404
1511
  .prepare(
@@ -1448,7 +1555,7 @@ export class SqliteControllerStore implements ControllerStore {
1448
1555
  "(l.owner_id IS NULL OR l.expires_at <= ? OR l.owner_id = ?)",
1449
1556
  "(q.affinity_runner_id IS NULL OR q.affinity_runner_id = ? OR q.status IN ('parked', 'starting', 'running'))",
1450
1557
  "NOT (q.status = 'parked' AND (r.status = 'waiting' OR r.paused = 1))",
1451
- "NOT EXISTS (SELECT 1 FROM interactive_requests i WHERE i.run_id = r.run_id AND i.status IN ('pending', 'presenting'))",
1558
+ "NOT EXISTS (SELECT 1 FROM interactive_requests i WHERE i.run_id = r.run_id AND i.status = 'pending')",
1452
1559
  "(q.error_code IS NULL OR q.error_code <> 'workflowSourceChanged')",
1453
1560
  ];
1454
1561
  const params: unknown[] = [now, now, options.runnerId, options.runnerId];
@@ -1572,7 +1679,7 @@ export class SqliteControllerStore implements ControllerStore {
1572
1679
  const pending = this.state.connection
1573
1680
  .prepare(
1574
1681
  `SELECT 1 FROM interactive_requests
1575
- WHERE run_id = ? AND status IN ('pending', 'presenting') LIMIT 1`,
1682
+ WHERE run_id = ? AND status = 'pending' LIMIT 1`,
1576
1683
  )
1577
1684
  .get(options.runId);
1578
1685
  if (pending === undefined) return false;
@@ -1623,7 +1730,7 @@ export class SqliteControllerStore implements ControllerStore {
1623
1730
  const pending = this.state.connection
1624
1731
  .prepare(
1625
1732
  `SELECT 1 FROM interactive_requests
1626
- WHERE run_id = ? AND status IN ('pending', 'presenting') LIMIT 1`,
1733
+ WHERE run_id = ? AND status = 'pending' LIMIT 1`,
1627
1734
  )
1628
1735
  .get(options.runId);
1629
1736
  if (pending === undefined) return false;
@@ -2041,393 +2148,6 @@ export class SqliteControllerStore implements ControllerStore {
2041
2148
  }));
2042
2149
  }
2043
2150
 
2044
- ensureWorkflowTurnIntent(options: {
2045
- intentId: string;
2046
- sourceEventId: string;
2047
- runId: string;
2048
- workflowRef: string;
2049
- targetSessionId: string;
2050
- cause: WorkflowTurnIntentCause;
2051
- nodeId?: string | null;
2052
- attemptId?: string | null;
2053
- fallbackFacts: WorkflowTurnIntentFacts;
2054
- eligible: boolean;
2055
- requestedAt?: string;
2056
- }): WorkflowTurnIntentRecord {
2057
- const existing = this.turnIntentBySource(options.sourceEventId);
2058
- if (existing !== undefined) return existing;
2059
- const now = epoch(validTimestamp(options.requestedAt));
2060
- const run = this.requireWorkflowRunRow(options.runId);
2061
- const intentId = options.intentId;
2062
- const resourceId = resourceIdFor("turn_intent", intentId);
2063
- const effectId = `effect-${randomUUID()}`;
2064
- this.state.transaction(() => {
2065
- const factsHash = this.state.putJson(options.fallbackFacts, now);
2066
- this.insertEffectResource(
2067
- effectId,
2068
- resourceIdFor("effect", effectId),
2069
- run.resourceId,
2070
- this.resourceRevision(run.resourceId),
2071
- "turn.deliver",
2072
- intentId,
2073
- factsHash,
2074
- "run",
2075
- now,
2076
- );
2077
- this.state.connection
2078
- .prepare(
2079
- "INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at) VALUES (?, 'turn_intent', ?, 1, ?, ?)",
2080
- )
2081
- .run(resourceId, intentId, now, now);
2082
- this.state.connection
2083
- .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
2084
- .run(resourceId);
2085
- this.state.connection
2086
- .prepare(
2087
- `INSERT INTO turn_intents(
2088
- turn_intent_id, resource_id, effect_id, source_event_id, run_id,
2089
- workflow_ref, target_session_id, cause, node_id, attempt_id,
2090
- facts_hash, requested_at, eligible_at, created_at
2091
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2092
- )
2093
- .run(
2094
- intentId,
2095
- resourceId,
2096
- effectId,
2097
- options.sourceEventId,
2098
- options.runId,
2099
- options.workflowRef,
2100
- options.targetSessionId,
2101
- options.cause,
2102
- options.nodeId ?? null,
2103
- options.attemptId ?? null,
2104
- factsHash,
2105
- now,
2106
- options.eligible ? now : null,
2107
- now,
2108
- );
2109
- this.insertEvent(
2110
- resourceId,
2111
- 1,
2112
- "turn.requested",
2113
- "session",
2114
- options.targetSessionId,
2115
- {},
2116
- now,
2117
- );
2118
- });
2119
- return this.requireTurnIntent(intentId);
2120
- }
2121
-
2122
- getWorkflowTurnIntent(intentId: string): WorkflowTurnIntentRecord | undefined {
2123
- return this.turnIntent(intentId);
2124
- }
2125
-
2126
- findPendingWorkflowTurnIntent(options: {
2127
- runId: string;
2128
- targetSessionId: string;
2129
- }): WorkflowTurnIntentRecord | undefined {
2130
- const row = this.state.connection
2131
- .prepare(
2132
- turnIntentSelect(
2133
- "WHERE t.run_id = ? AND t.target_session_id = ? AND t.resolved_at IS NULL ORDER BY t.requested_at DESC LIMIT 1",
2134
- ),
2135
- )
2136
- .get(options.runId, options.targetSessionId);
2137
- return isTurnIntentRow(row) ? this.mapTurnIntent(row) : undefined;
2138
- }
2139
-
2140
- isWorkflowTurnIntentClaimLive(options: {
2141
- intentId: string;
2142
- targetSessionId: string;
2143
- claimToken: string;
2144
- now?: string;
2145
- }): boolean {
2146
- const row = this.turnIntentRow(options.intentId);
2147
- return (
2148
- row !== undefined &&
2149
- row.targetSessionId === options.targetSessionId &&
2150
- row.resolvedAt === null &&
2151
- this.verifyEffectToken(row.effectId, options.claimToken, options.now)
2152
- );
2153
- }
2154
-
2155
- claimWorkflowTurnIntent(options: {
2156
- intentId: string;
2157
- targetSessionId: string;
2158
- claimToken: string;
2159
- leaseMs: number;
2160
- now?: string;
2161
- }): WorkflowTurnIntentRecord | undefined {
2162
- const intent = this.turnIntent(options.intentId);
2163
- if (intent?.targetSessionId !== options.targetSessionId) return undefined;
2164
- return this.claimEffectForTurn(
2165
- options.intentId,
2166
- options.claimToken,
2167
- options.leaseMs,
2168
- options.now,
2169
- )
2170
- ? this.requireTurnIntent(options.intentId)
2171
- : undefined;
2172
- }
2173
-
2174
- claimEligibleWorkflowTurnIntents(options: {
2175
- targetSessionId: string;
2176
- claimToken: string;
2177
- leaseMs: number;
2178
- now?: string;
2179
- limit?: number;
2180
- }): WorkflowTurnIntentRecord[] {
2181
- const now = epoch(validTimestamp(options.now));
2182
- const rows = this.state.connection
2183
- .prepare(
2184
- turnIntentSelect(
2185
- "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 ?",
2186
- ),
2187
- )
2188
- .all(options.targetSessionId, now, options.limit ?? 10);
2189
- const claimed: WorkflowTurnIntentRecord[] = [];
2190
- for (const row of rows) {
2191
- if (
2192
- isTurnIntentRow(row) &&
2193
- this.claimEffectForTurn(row.intentId, options.claimToken, options.leaseMs, options.now)
2194
- ) {
2195
- claimed.push(this.requireTurnIntent(row.intentId));
2196
- }
2197
- }
2198
- return claimed;
2199
- }
2200
-
2201
- makeWorkflowTurnIntentEligible(options: {
2202
- intentId: string;
2203
- fallbackFacts: WorkflowTurnIntentFacts;
2204
- eligibleAt?: string;
2205
- now?: string;
2206
- }): boolean {
2207
- const eligibleAt = epoch(validTimestamp(options.eligibleAt ?? options.now));
2208
- const factsHash = this.state.putJson(options.fallbackFacts, eligibleAt);
2209
- return (
2210
- this.state.connection
2211
- .prepare(
2212
- "UPDATE turn_intents SET eligible_at = ?, facts_hash = ? WHERE turn_intent_id = ? AND resolved_at IS NULL",
2213
- )
2214
- .run(eligibleAt, factsHash, options.intentId).changes === 1
2215
- );
2216
- }
2217
-
2218
- resolveWorkflowTurnIntent(options: {
2219
- intentId: string;
2220
- targetSessionId: string;
2221
- claimToken: string;
2222
- resolution: WorkflowTurnIntentResolution;
2223
- messageId?: string;
2224
- now?: string;
2225
- }): boolean {
2226
- const now = epoch(validTimestamp(options.now));
2227
- return this.state.transaction(() => {
2228
- const row = this.turnIntentRow(options.intentId);
2229
- if (
2230
- row === undefined ||
2231
- row.targetSessionId !== options.targetSessionId ||
2232
- !this.verifyEffectToken(row.effectId, options.claimToken, options.now)
2233
- ) {
2234
- return false;
2235
- }
2236
- const result = this.state.connection
2237
- .prepare(
2238
- `UPDATE turn_intents SET resolved_at = ?, resolution_type = ?, resolution_message_id = ?
2239
- WHERE turn_intent_id = ? AND resolved_at IS NULL`,
2240
- )
2241
- .run(now, options.resolution, options.messageId ?? null, options.intentId);
2242
- if (result.changes === 1) this.completeEffect(row.effectId, "applied", now);
2243
- return result.changes === 1;
2244
- });
2245
- }
2246
-
2247
- releaseWorkflowTurnIntentClaim(options: {
2248
- intentId: string;
2249
- targetSessionId: string;
2250
- claimToken: string;
2251
- }): boolean {
2252
- const row = this.turnIntentRow(options.intentId);
2253
- return (
2254
- row !== undefined &&
2255
- row.targetSessionId === options.targetSessionId &&
2256
- this.releaseEffectLease(row.effectId, options.claimToken)
2257
- );
2258
- }
2259
-
2260
- listWorkflowTurnIntents(
2261
- options: {
2262
- runId?: string;
2263
- targetSessionId?: string;
2264
- unresolvedOnly?: boolean;
2265
- limit?: number;
2266
- } = {},
2267
- ): WorkflowTurnIntentRecord[] {
2268
- const clauses = ["1 = 1"];
2269
- const params: unknown[] = [];
2270
- if (options.runId !== undefined) {
2271
- clauses.push("t.run_id = ?");
2272
- params.push(options.runId);
2273
- }
2274
- if (options.targetSessionId !== undefined) {
2275
- clauses.push("t.target_session_id = ?");
2276
- params.push(options.targetSessionId);
2277
- }
2278
- if (options.unresolvedOnly === true) clauses.push("t.resolved_at IS NULL");
2279
- params.push(options.limit ?? 100);
2280
- const rows = this.state.connection
2281
- .prepare(
2282
- turnIntentSelect(`WHERE ${clauses.join(" AND ")} ORDER BY t.requested_at DESC LIMIT ?`),
2283
- )
2284
- .all(...params);
2285
- return rows.filter(isTurnIntentRow).map((row) => this.mapTurnIntent(row));
2286
- }
2287
-
2288
- enqueueWorkflowNotification(options: {
2289
- runId: string;
2290
- nodeId: string;
2291
- attemptId: string;
2292
- notificationIndex: number;
2293
- targetSessionId: string;
2294
- kind: "progress" | "final";
2295
- content: string;
2296
- now?: string;
2297
- }): WorkflowNotificationRecord {
2298
- const existing = this.notification(options.runId, options.attemptId, options.notificationIndex);
2299
- if (existing !== undefined) return existing;
2300
- const run = this.requireWorkflowRunRow(options.runId);
2301
- const now = epoch(validTimestamp(options.now));
2302
- const notificationId = `notification-${randomUUID()}`;
2303
- const resourceId = resourceIdFor("notification", notificationId);
2304
- const effectId = `effect-${randomUUID()}`;
2305
- this.state.transaction(() => {
2306
- const contentHash = this.state.putText(options.content, now);
2307
- this.insertEffectResource(
2308
- effectId,
2309
- resourceIdFor("effect", effectId),
2310
- run.resourceId,
2311
- this.resourceRevision(run.resourceId),
2312
- "notification.deliver",
2313
- notificationId,
2314
- contentHash,
2315
- "run",
2316
- now,
2317
- );
2318
- this.state.connection
2319
- .prepare(
2320
- "INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at) VALUES (?, 'notification', ?, 1, ?, ?)",
2321
- )
2322
- .run(resourceId, notificationId, now, now);
2323
- this.state.connection
2324
- .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
2325
- .run(resourceId);
2326
- this.state.connection
2327
- .prepare(
2328
- `INSERT INTO notifications(
2329
- notification_id, effect_id, run_id, attempt_id, notification_index,
2330
- target_session_id, notification_type, content_hash, created_at
2331
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2332
- )
2333
- .run(
2334
- notificationId,
2335
- effectId,
2336
- options.runId,
2337
- options.attemptId,
2338
- options.notificationIndex,
2339
- options.targetSessionId,
2340
- options.kind,
2341
- contentHash,
2342
- now,
2343
- );
2344
- this.insertEvent(
2345
- resourceId,
2346
- 1,
2347
- "notification.queued",
2348
- "session",
2349
- options.targetSessionId,
2350
- {},
2351
- now,
2352
- );
2353
- });
2354
- return this.requireNotification(options.runId, options.attemptId, options.notificationIndex);
2355
- }
2356
-
2357
- listPendingWorkflowNotifications(options: {
2358
- targetSessionId: string;
2359
- limit?: number;
2360
- }): WorkflowNotificationRecord[] {
2361
- const now = Date.now();
2362
- return this.notificationRows(options.targetSessionId, options.limit ?? 20)
2363
- .map((row) => this.mapNotification(row))
2364
- .filter(
2365
- (notification) =>
2366
- notification.deliveryClaimExpiresAt === null ||
2367
- Date.parse(notification.deliveryClaimExpiresAt) <= now,
2368
- );
2369
- }
2370
-
2371
- claimPendingWorkflowNotifications(options: {
2372
- targetSessionId: string;
2373
- claimToken: string;
2374
- leaseMs: number;
2375
- now?: string;
2376
- limit?: number;
2377
- }): WorkflowNotificationRecord[] {
2378
- const rows = this.notificationRows(options.targetSessionId, options.limit ?? 20);
2379
- const result: WorkflowNotificationRecord[] = [];
2380
- for (const row of rows) {
2381
- if (
2382
- this.claimEffect(
2383
- row.effectId,
2384
- options.targetSessionId,
2385
- options.claimToken,
2386
- options.leaseMs,
2387
- options.now,
2388
- )
2389
- ) {
2390
- result.push(this.mapNotification(row));
2391
- }
2392
- }
2393
- return result;
2394
- }
2395
-
2396
- isWorkflowNotificationClaimLive(options: {
2397
- notificationId: string;
2398
- targetSessionId: string;
2399
- claimToken: string;
2400
- now?: string;
2401
- }): boolean {
2402
- const row = this.notificationRowById(options.notificationId);
2403
- return (
2404
- row !== undefined &&
2405
- row.targetSessionId === options.targetSessionId &&
2406
- this.verifyEffectToken(row.effectId, options.claimToken, options.now)
2407
- );
2408
- }
2409
-
2410
- markWorkflowNotificationDelivered(options: {
2411
- notificationId: string;
2412
- targetSessionId: string;
2413
- claimToken: string;
2414
- now?: string;
2415
- }): boolean {
2416
- const now = epoch(validTimestamp(options.now));
2417
- return this.state.transaction(() => {
2418
- const row = this.notificationRowById(options.notificationId);
2419
- if (
2420
- row === undefined ||
2421
- row.targetSessionId !== options.targetSessionId ||
2422
- !this.verifyEffectToken(row.effectId, options.claimToken, options.now)
2423
- ) {
2424
- return false;
2425
- }
2426
- this.completeEffect(row.effectId, "applied", now);
2427
- return true;
2428
- });
2429
- }
2430
-
2431
2151
  settleRunEffect(runId: string, effectType: "run.park_queue" | "run.settle_queue"): void {
2432
2152
  const rows = this.state.connection
2433
2153
  .prepare(
@@ -2882,6 +2602,21 @@ export class SqliteControllerStore implements ControllerStore {
2882
2602
  options: WorkflowRunReservationOptions,
2883
2603
  definitionDigest: Buffer,
2884
2604
  ): void {
2605
+ const expectedLineageKind =
2606
+ options.parentRunId === undefined ? null : (options.lineageKind ?? "continuation");
2607
+ const parent =
2608
+ options.parentRunId === undefined
2609
+ ? undefined
2610
+ : this.requireWorkflowRunRow(options.parentRunId);
2611
+ const expectedRootRunId = parent?.rootRunId ?? options.runId;
2612
+ const expectedRestartNumber =
2613
+ expectedLineageKind === "restart"
2614
+ ? (options.restartNumber ?? (parent?.restartNumber ?? -1) + 1)
2615
+ : (parent?.restartNumber ?? 0);
2616
+ const expectedParentTerminalFingerprint =
2617
+ options.parentTerminalFingerprint === undefined
2618
+ ? null
2619
+ : terminalFingerprintBuffer(options.parentTerminalFingerprint);
2885
2620
  const compatible =
2886
2621
  row.workflowName === options.workflowName &&
2887
2622
  row.workflowRef === options.workflowSourceRef &&
@@ -2893,12 +2628,26 @@ export class SqliteControllerStore implements ControllerStore {
2893
2628
  canonicalJson(options.launchOptions ?? {}) &&
2894
2629
  row.originSessionId === options.originSessionId &&
2895
2630
  row.executionMode === (options.executionMode ?? "interactive") &&
2896
- row.parentRunId === (options.parentRunId ?? null);
2631
+ row.parentRunId === (options.parentRunId ?? null) &&
2632
+ row.rootRunId === expectedRootRunId &&
2633
+ row.lineageKind === expectedLineageKind &&
2634
+ row.restartNumber === expectedRestartNumber &&
2635
+ ((row.parentTerminalFingerprint === null && expectedParentTerminalFingerprint === null) ||
2636
+ (row.parentTerminalFingerprint !== null &&
2637
+ expectedParentTerminalFingerprint !== null &&
2638
+ row.parentTerminalFingerprint.equals(expectedParentTerminalFingerprint)));
2897
2639
  if (!compatible) {
2898
2640
  throw new Error(`Workflow run preparation conflicts: ${options.runId}`);
2899
2641
  }
2900
2642
  }
2901
2643
 
2644
+ private workflowRunViewRows(clause: string, params: unknown[]): WorkflowRunViewRow[] {
2645
+ return this.state.connection
2646
+ .prepare(workflowRunViewSelect(clause))
2647
+ .all(...params)
2648
+ .filter(isWorkflowRunViewRow);
2649
+ }
2650
+
2902
2651
  /* istanbul ignore next -- pure projection covered by integration tests */
2903
2652
  private mapWorkflowRun(row: RunRow): WorkflowRunQueueRecord {
2904
2653
  return {
@@ -2920,6 +2669,10 @@ export class SqliteControllerStore implements ControllerStore {
2920
2669
  originSessionId: row.executionMode === "headless" ? null : row.originSessionId,
2921
2670
  executionMode: row.executionMode,
2922
2671
  parentRunId: row.parentRunId,
2672
+ rootRunId: row.rootRunId,
2673
+ lineageKind: row.lineageKind,
2674
+ restartNumber: row.restartNumber,
2675
+ parentTerminalFingerprint: row.parentTerminalFingerprint?.toString("hex") ?? null,
2923
2676
  errorCode: row.errorCode,
2924
2677
  errorMessage:
2925
2678
  row.errorHash === null
@@ -2960,7 +2713,7 @@ export class SqliteControllerStore implements ControllerStore {
2960
2713
  this.state.connection
2961
2714
  .prepare(
2962
2715
  `SELECT 1 FROM interactive_requests
2963
- WHERE run_id = ? AND status IN ('pending', 'presenting') LIMIT 1`,
2716
+ WHERE run_id = ? AND status = 'pending' LIMIT 1`,
2964
2717
  )
2965
2718
  .get(runId) !== undefined
2966
2719
  ) {
@@ -3287,262 +3040,12 @@ export class SqliteControllerStore implements ControllerStore {
3287
3040
  this.state.connection
3288
3041
  .prepare(
3289
3042
  `UPDATE interactive_requests
3290
- SET status = 'cancelled', presenter_id = NULL,
3291
- presentation_claim_expires_at = NULL, revision = revision + 1, updated_at = ?
3292
- WHERE run_id = ? AND status IN ('pending', 'presenting')`,
3043
+ SET status = 'cancelled', revision = revision + 1, updated_at = ?
3044
+ WHERE run_id = ? AND status = 'pending'`,
3293
3045
  )
3294
3046
  .run(now, runId);
3295
3047
  }
3296
3048
 
3297
- private turnIntent(intentId: string): WorkflowTurnIntentRecord | undefined {
3298
- const row = this.turnIntentRow(intentId);
3299
- return row === undefined ? undefined : this.mapTurnIntent(row);
3300
- }
3301
-
3302
- private requireTurnIntent(intentId: string): WorkflowTurnIntentRecord {
3303
- const row = this.turnIntent(intentId);
3304
- if (row === undefined) throw new Error(`Workflow turn intent not found: ${intentId}`);
3305
- return row;
3306
- }
3307
-
3308
- private turnIntentRow(intentId: string): TurnIntentRow | undefined {
3309
- const row = this.state.connection
3310
- .prepare(turnIntentSelect("WHERE t.turn_intent_id = ?"))
3311
- .get(intentId);
3312
- return isTurnIntentRow(row) ? row : undefined;
3313
- }
3314
-
3315
- private turnIntentBySource(sourceEventId: string): WorkflowTurnIntentRecord | undefined {
3316
- const row = this.state.connection
3317
- .prepare(turnIntentSelect("WHERE t.source_event_id = ?"))
3318
- .get(sourceEventId);
3319
- return isTurnIntentRow(row) ? this.mapTurnIntent(row) : undefined;
3320
- }
3321
-
3322
- /* istanbul ignore next -- pure projection covered by integration tests */
3323
- private mapTurnIntent(row: TurnIntentRow): WorkflowTurnIntentRecord {
3324
- return {
3325
- intentId: row.intentId,
3326
- sourceEventId: row.sourceEventId,
3327
- runId: row.runId,
3328
- workflowRef: row.workflowRef,
3329
- targetSessionId: row.targetSessionId,
3330
- cause: row.cause,
3331
- nodeId: row.nodeId,
3332
- attemptId: row.attemptId,
3333
- fallbackFacts: this.state.readJson(row.factsHash) as WorkflowTurnIntentFacts,
3334
- requestedAt: new Date(row.requestedAt).toISOString(),
3335
- eligibleAt: row.eligibleAt === null ? null : new Date(row.eligibleAt).toISOString(),
3336
- resolvedAt: row.resolvedAt === null ? null : new Date(row.resolvedAt).toISOString(),
3337
- resolution: row.resolution,
3338
- resolutionMessageId: row.resolutionMessageId,
3339
- deliveryClaimExpiresAt:
3340
- row.claimExpiresAt === null ? null : new Date(row.claimExpiresAt).toISOString(),
3341
- };
3342
- }
3343
-
3344
- private claimEffectForTurn(
3345
- intentId: string,
3346
- token: string,
3347
- leaseMs: number,
3348
- nowValue?: string,
3349
- ): boolean {
3350
- const row = this.turnIntentRow(intentId);
3351
- return (
3352
- row !== undefined &&
3353
- this.claimEffect(row.effectId, row.targetSessionId, token, leaseMs, nowValue)
3354
- );
3355
- }
3356
-
3357
- private claimEffect(
3358
- effectId: string,
3359
- ownerId: string,
3360
- token: string,
3361
- leaseMs: number,
3362
- nowValue?: string,
3363
- ): boolean {
3364
- const now = epoch(validTimestamp(nowValue));
3365
- return this.state.transaction(() => {
3366
- const row = this.state.connection
3367
- .prepare(
3368
- "SELECT resource_id AS resourceId FROM effects WHERE effect_id = ? AND status = 'pending'",
3369
- )
3370
- .get(effectId);
3371
- /* istanbul ignore if -- impossible after exact schema and transaction checks */
3372
- if (!isResourceIdRow(row)) return false;
3373
- const lease = this.requireLease(row.resourceId);
3374
- if (lease.ownerId !== null && lease.expiresAt !== null && lease.expiresAt > now) return false;
3375
- return (
3376
- this.state.connection
3377
- .prepare(
3378
- `UPDATE leases SET generation = ?, owner_type = 'session', owner_id = ?, token_hash = ?,
3379
- acquired_at = ?, heartbeat_at = ?, expires_at = ?
3380
- WHERE resource_id = ? AND generation = ?`,
3381
- )
3382
- .run(
3383
- lease.generation + 1,
3384
- ownerId,
3385
- tokenHash(token),
3386
- now,
3387
- now,
3388
- now + leaseMs,
3389
- row.resourceId,
3390
- lease.generation,
3391
- ).changes === 1
3392
- );
3393
- });
3394
- }
3395
-
3396
- private verifyEffectToken(effectId: string, token: string, nowValue?: string): boolean {
3397
- const now = epoch(validTimestamp(nowValue));
3398
- const row = this.state.connection
3399
- .prepare(
3400
- `SELECT l.token_hash AS tokenHash, l.expires_at AS expiresAt
3401
- FROM effects e JOIN leases l ON l.resource_id = e.resource_id
3402
- WHERE e.effect_id = ?`,
3403
- )
3404
- .get(effectId);
3405
- return (
3406
- isEffectTokenRow(row) &&
3407
- row.tokenHash !== null &&
3408
- row.tokenHash.equals(tokenHash(token)) &&
3409
- row.expiresAt !== null &&
3410
- row.expiresAt > now
3411
- );
3412
- }
3413
-
3414
- private releaseEffectLease(effectId: string, token: string): boolean {
3415
- return (
3416
- this.state.connection
3417
- .prepare(
3418
- `UPDATE leases SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
3419
- acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
3420
- WHERE resource_id = (SELECT resource_id FROM effects WHERE effect_id = ?)
3421
- AND token_hash = ?`,
3422
- )
3423
- .run(effectId, tokenHash(token)).changes === 1
3424
- );
3425
- }
3426
-
3427
- private completeEffect(
3428
- effectId: string,
3429
- status: "applied" | "rejected" | "ambiguous",
3430
- now: number,
3431
- ): void {
3432
- this.state.connection
3433
- .prepare("UPDATE effects SET status = ?, updated_at = ?, settled_at = ? WHERE effect_id = ?")
3434
- .run(status, now, now, effectId);
3435
- this.state.connection
3436
- .prepare(
3437
- `UPDATE leases SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
3438
- acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
3439
- WHERE resource_id = (SELECT resource_id FROM effects WHERE effect_id = ?)`,
3440
- )
3441
- .run(effectId);
3442
- }
3443
-
3444
- private insertEffectResource(
3445
- effectId: string,
3446
- resourceId: string,
3447
- sourceResourceId: string,
3448
- sourceRevision: number,
3449
- type: string,
3450
- key: string,
3451
- payloadHash: Buffer,
3452
- scope: "run" | "controller" | "channel" | "system",
3453
- now: number,
3454
- ): void {
3455
- this.state.connection
3456
- .prepare(
3457
- "INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at) VALUES (?, 'effect', ?, 1, ?, ?)",
3458
- )
3459
- .run(resourceId, effectId, now, now);
3460
- this.state.connection
3461
- .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
3462
- .run(resourceId);
3463
- this.state.connection
3464
- .prepare(
3465
- `INSERT INTO effects(
3466
- effect_id, resource_id, source_resource_id, source_revision, effect_type,
3467
- idempotency_key, payload_hash, owner_scope, status, attempt_count,
3468
- created_at, updated_at
3469
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)`,
3470
- )
3471
- .run(
3472
- effectId,
3473
- resourceId,
3474
- sourceResourceId,
3475
- sourceRevision,
3476
- type,
3477
- key,
3478
- payloadHash,
3479
- scope,
3480
- now,
3481
- now,
3482
- );
3483
- this.insertEvent(resourceId, 1, "effect.created", "system", null, {}, now);
3484
- }
3485
-
3486
- private notification(
3487
- runId: string,
3488
- attemptId: string,
3489
- index: number,
3490
- ): WorkflowNotificationRecord | undefined {
3491
- const row = this.state.connection
3492
- .prepare(
3493
- notificationSelect("WHERE n.run_id = ? AND n.attempt_id = ? AND n.notification_index = ?"),
3494
- )
3495
- .get(runId, attemptId, index);
3496
- return isNotificationRow(row) ? this.mapNotification(row) : undefined;
3497
- }
3498
-
3499
- private requireNotification(
3500
- runId: string,
3501
- attemptId: string,
3502
- index: number,
3503
- ): WorkflowNotificationRecord {
3504
- const record = this.notification(runId, attemptId, index);
3505
- /* istanbul ignore if -- impossible after exact schema and transaction checks */
3506
- if (record === undefined) throw new Error("Workflow notification was not stored");
3507
- return record;
3508
- }
3509
-
3510
- private notificationRows(sessionId: string, limit: number): NotificationRow[] {
3511
- return this.state.connection
3512
- .prepare(
3513
- notificationSelect(
3514
- "WHERE n.target_session_id = ? AND e.status = 'pending' ORDER BY n.created_at LIMIT ?",
3515
- ),
3516
- )
3517
- .all(sessionId, limit)
3518
- .filter(isNotificationRow);
3519
- }
3520
-
3521
- private notificationRowById(id: string): NotificationRow | undefined {
3522
- const row = this.state.connection
3523
- .prepare(notificationSelect("WHERE n.notification_id = ?"))
3524
- .get(id);
3525
- return isNotificationRow(row) ? row : undefined;
3526
- }
3527
-
3528
- /* istanbul ignore next -- pure projection covered by integration tests */
3529
- private mapNotification(row: NotificationRow): WorkflowNotificationRecord {
3530
- return {
3531
- notificationId: row.notificationId,
3532
- runId: row.runId,
3533
- nodeId: row.nodeId,
3534
- attemptId: row.attemptId,
3535
- notificationIndex: row.notificationIndex,
3536
- targetSessionId: row.targetSessionId,
3537
- kind: row.kind,
3538
- content: this.state.readBlob(row.contentHash)?.content.toString("utf8") ?? "",
3539
- createdAt: new Date(row.createdAt).toISOString(),
3540
- deliveryClaimExpiresAt:
3541
- row.claimExpiresAt === null ? null : new Date(row.claimExpiresAt).toISOString(),
3542
- deliveredAt: row.deliveredAt === null ? null : new Date(row.deliveredAt).toISOString(),
3543
- };
3544
- }
3545
-
3546
3049
  private assertWritable(): void {
3547
3050
  if (this.state.mode === "read-only") throw new Error("Controller store is read-only");
3548
3051
  }
@@ -3587,6 +3090,9 @@ function workflowRunSelect(clause: string): string {
3587
3090
  q.consecutive_errors AS consecutiveErrors, q.error_code AS errorCode,
3588
3091
  q.error_hash AS errorHash, b.origin_session_id AS originSessionId,
3589
3092
  b.execution_mode AS executionMode, r.parent_run_id AS parentRunId,
3093
+ r.root_run_id AS rootRunId, r.lineage_kind AS lineageKind,
3094
+ r.restart_number AS restartNumber,
3095
+ r.parent_terminal_fingerprint AS parentTerminalFingerprint,
3590
3096
  q.created_at AS createdAt, q.updated_at AS updatedAt,
3591
3097
  q.started_at AS startedAt, q.finished_at AS finishedAt,
3592
3098
  l.generation AS leaseGeneration, l.owner_id AS ownerId, l.expires_at AS claimExpiresAt
@@ -3596,29 +3102,24 @@ function workflowRunSelect(clause: string): string {
3596
3102
  JOIN leases l ON l.resource_id = r.resource_id ${clause}`;
3597
3103
  }
3598
3104
 
3599
- function turnIntentSelect(clause: string): string {
3600
- return `SELECT t.turn_intent_id AS intentId, t.source_event_id AS sourceEventId,
3601
- t.run_id AS runId, t.workflow_ref AS workflowRef,
3602
- t.target_session_id AS targetSessionId, t.cause,
3603
- t.node_id AS nodeId, t.attempt_id AS attemptId, t.facts_hash AS factsHash,
3604
- t.requested_at AS requestedAt, t.eligible_at AS eligibleAt,
3605
- t.resolved_at AS resolvedAt, t.resolution_type AS resolution,
3606
- t.resolution_message_id AS resolutionMessageId, l.expires_at AS claimExpiresAt,
3607
- t.effect_id AS effectId
3608
- FROM turn_intents t JOIN effects e ON e.effect_id = t.effect_id
3609
- JOIN leases l ON l.resource_id = e.resource_id ${clause}`;
3610
- }
3611
-
3612
- function notificationSelect(clause: string): string {
3613
- return `SELECT n.notification_id AS notificationId, n.effect_id AS effectId,
3614
- n.run_id AS runId, a.node_id AS nodeId, n.attempt_id AS attemptId,
3615
- n.notification_index AS notificationIndex, n.target_session_id AS targetSessionId,
3616
- n.notification_type AS kind, n.content_hash AS contentHash,
3617
- n.created_at AS createdAt, l.expires_at AS claimExpiresAt,
3618
- e.settled_at AS deliveredAt
3619
- FROM notifications n JOIN effects e ON e.effect_id = n.effect_id
3620
- JOIN leases l ON l.resource_id = e.resource_id
3621
- JOIN node_attempts a ON a.attempt_id = n.attempt_id ${clause}`;
3105
+ function workflowRunViewSelect(clause: string): string {
3106
+ return `SELECT r.run_id AS runId, d.workflow_name AS workflowName,
3107
+ r.workflow_ref AS workflowRef, r.status AS runStatus, r.paused,
3108
+ r.definition_digest AS definitionDigest, q.status,
3109
+ b.origin_session_id AS originSessionId, b.execution_mode AS executionMode,
3110
+ r.parent_run_id AS parentRunId, r.root_run_id AS rootRunId,
3111
+ r.lineage_kind AS lineageKind, r.restart_number AS restartNumber,
3112
+ r.parent_terminal_fingerprint AS parentTerminalFingerprint,
3113
+ q.error_code AS errorCode, CAST(error.content AS TEXT) AS errorMessage,
3114
+ q.created_at AS createdAt, q.updated_at AS updatedAt,
3115
+ q.started_at AS startedAt, q.finished_at AS finishedAt,
3116
+ source.source_type AS sourceType, source.source_ref AS sourceRef,
3117
+ source.source_revision AS sourceRevision
3118
+ FROM runs r JOIN workflow_definitions d ON d.definition_digest = r.definition_digest
3119
+ JOIN run_queue q ON q.run_id = r.run_id
3120
+ LEFT JOIN run_bindings b ON b.run_id = r.run_id
3121
+ LEFT JOIN run_sources source ON source.run_id = r.run_id AND source.mount_path = ''
3122
+ LEFT JOIN blobs error ON error.blob_hash = q.error_hash ${clause}`;
3622
3123
  }
3623
3124
 
3624
3125
  type QueueListRow = {
@@ -3645,39 +3146,6 @@ type RunEventRow = {
3645
3146
  runnerId: string | null;
3646
3147
  payloadHash: Buffer | null;
3647
3148
  };
3648
- type TurnIntentRow = {
3649
- intentId: string;
3650
- sourceEventId: string;
3651
- runId: string;
3652
- workflowRef: string;
3653
- targetSessionId: string;
3654
- cause: WorkflowTurnIntentCause;
3655
- nodeId: string | null;
3656
- attemptId: string | null;
3657
- factsHash: Buffer;
3658
- requestedAt: number;
3659
- eligibleAt: number | null;
3660
- resolvedAt: number | null;
3661
- resolution: WorkflowTurnIntentResolution | null;
3662
- resolutionMessageId: string | null;
3663
- claimExpiresAt: number | null;
3664
- effectId: string;
3665
- };
3666
- type NotificationRow = {
3667
- notificationId: string;
3668
- effectId: string;
3669
- runId: string;
3670
- nodeId: string;
3671
- attemptId: string;
3672
- notificationIndex: number;
3673
- targetSessionId: string;
3674
- kind: "progress" | "final";
3675
- contentHash: Buffer;
3676
- createdAt: number;
3677
- claimExpiresAt: number | null;
3678
- deliveredAt: number | null;
3679
- };
3680
-
3681
3149
  function isRecord(value: unknown): value is Record<string, unknown> {
3682
3150
  return typeof value === "object" && value !== null && !Array.isArray(value);
3683
3151
  }
@@ -3687,6 +3155,78 @@ function isControllerRow(value: unknown): value is ControllerRow {
3687
3155
  function isLeaseRow(value: unknown): value is LeaseRow {
3688
3156
  return isRecord(value);
3689
3157
  }
3158
+ function workflowRunViewRecord(row: WorkflowRunViewRow): WorkflowRunQueueViewRecord {
3159
+ const root =
3160
+ row.sourceType === "builtin"
3161
+ ? { kind: "builtin", id: row.sourceRef, revision: row.sourceRevision }
3162
+ : { kind: "file", path: row.sourceRef, hash: row.sourceRevision };
3163
+ return {
3164
+ runId: row.runId,
3165
+ workflowName: row.workflowName,
3166
+ workflowSourceRef: row.workflowRef,
3167
+ workflowSource: { root, mounted: [] },
3168
+ initialized: row.runStatus !== "queued",
3169
+ definitionDigest: `sha256:${row.definitionDigest.toString("hex")}`,
3170
+ runStateStatus: row.runStatus,
3171
+ paused: row.paused === 1,
3172
+ status: row.status,
3173
+ originSessionId: row.executionMode === "headless" ? null : row.originSessionId,
3174
+ executionMode: row.executionMode,
3175
+ parentRunId: row.parentRunId,
3176
+ rootRunId: row.rootRunId,
3177
+ lineageKind: row.lineageKind,
3178
+ restartNumber: row.restartNumber,
3179
+ parentTerminalFingerprint: row.parentTerminalFingerprint?.toString("hex") ?? null,
3180
+ errorCode: row.errorCode,
3181
+ errorMessage: row.errorMessage,
3182
+ createdAt: new Date(row.createdAt).toISOString(),
3183
+ updatedAt: new Date(row.updatedAt).toISOString(),
3184
+ startedAt: row.startedAt === null ? null : new Date(row.startedAt).toISOString(),
3185
+ finishedAt: row.finishedAt === null ? null : new Date(row.finishedAt).toISOString(),
3186
+ };
3187
+ }
3188
+
3189
+ function isWorkflowRunViewRow(value: unknown): value is WorkflowRunViewRow {
3190
+ return (
3191
+ isRecord(value) &&
3192
+ typeof value.runId === "string" &&
3193
+ typeof value.workflowName === "string" &&
3194
+ typeof value.workflowRef === "string" &&
3195
+ typeof value.runStatus === "string" &&
3196
+ typeof value.paused === "number" &&
3197
+ Buffer.isBuffer(value.definitionDigest) &&
3198
+ typeof value.status === "string" &&
3199
+ (value.originSessionId === null || typeof value.originSessionId === "string") &&
3200
+ (value.executionMode === "interactive" || value.executionMode === "headless") &&
3201
+ (value.parentRunId === null || typeof value.parentRunId === "string") &&
3202
+ typeof value.rootRunId === "string" &&
3203
+ (value.lineageKind === null ||
3204
+ value.lineageKind === "continuation" ||
3205
+ value.lineageKind === "restart") &&
3206
+ typeof value.restartNumber === "number" &&
3207
+ (value.parentTerminalFingerprint === null ||
3208
+ Buffer.isBuffer(value.parentTerminalFingerprint)) &&
3209
+ (value.errorCode === null || typeof value.errorCode === "string") &&
3210
+ (value.errorMessage === null || typeof value.errorMessage === "string") &&
3211
+ typeof value.createdAt === "number" &&
3212
+ typeof value.updatedAt === "number" &&
3213
+ (value.startedAt === null || typeof value.startedAt === "number") &&
3214
+ (value.finishedAt === null || typeof value.finishedAt === "number") &&
3215
+ (value.sourceType === "builtin" || value.sourceType === "file") &&
3216
+ typeof value.sourceRef === "string" &&
3217
+ typeof value.sourceRevision === "string"
3218
+ );
3219
+ }
3220
+
3221
+ function isRunListRevisionRow(value: unknown): value is RunListRevisionRow {
3222
+ return (
3223
+ isRecord(value) &&
3224
+ typeof value.count === "number" &&
3225
+ typeof value.revisionSum === "number" &&
3226
+ typeof value.updatedAt === "number"
3227
+ );
3228
+ }
3229
+
3690
3230
  function isRunRow(value: unknown): value is RunRow {
3691
3231
  return isRecord(value);
3692
3232
  }
@@ -3722,12 +3262,6 @@ function isControllerEventRow(value: unknown): value is ControllerEventRow {
3722
3262
  function isRunEventRow(value: unknown): value is RunEventRow {
3723
3263
  return isRecord(value);
3724
3264
  }
3725
- function isTurnIntentRow(value: unknown): value is TurnIntentRow {
3726
- return isRecord(value);
3727
- }
3728
- function isNotificationRow(value: unknown): value is NotificationRow {
3729
- return isRecord(value);
3730
- }
3731
3265
  function isProjectRow(value: unknown): value is { projectId: string } {
3732
3266
  return isRecord(value);
3733
3267
  }
@@ -3752,14 +3286,6 @@ function isSequenceRow(value: unknown): value is { seq: number } {
3752
3286
  function isKeyRow(value: unknown): value is { key: string } {
3753
3287
  return isRecord(value);
3754
3288
  }
3755
- function isResourceIdRow(value: unknown): value is { resourceId: string } {
3756
- return isRecord(value);
3757
- }
3758
- function isEffectTokenRow(
3759
- value: unknown,
3760
- ): value is { tokenHash: Buffer | null; expiresAt: number | null } {
3761
- return isRecord(value);
3762
- }
3763
3289
  function isEffectIdentityRow(value: unknown): value is { effectId: string; resourceId: string } {
3764
3290
  return isRecord(value);
3765
3291
  }
@@ -3782,6 +3308,11 @@ function digestBuffer(value: string): Buffer {
3782
3308
  if (!/^[a-f0-9]{64}$/i.test(hex)) throw new Error("Expected a SHA-256 digest");
3783
3309
  return Buffer.from(hex, "hex");
3784
3310
  }
3311
+ function terminalFingerprintBuffer(value: string): Buffer {
3312
+ const hex = value.startsWith("sha256:") ? value.slice(7) : value;
3313
+ if (!/^[a-f0-9]{64}$/i.test(hex)) throw new Error("Expected a terminal fingerprint");
3314
+ return Buffer.from(hex, "hex");
3315
+ }
3785
3316
  type QueuedSource = {
3786
3317
  root:
3787
3318
  | { kind: "builtin"; id: string; revision: string }