@osolmaz/pi-workflows 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +18 -2
  2. package/dist/channels/adapter-entry.d.ts +3 -0
  3. package/dist/channels/adapter-entry.js +203 -0
  4. package/dist/channels/adapter-entry.js.map +1 -0
  5. package/dist/channels/config.d.ts +43 -0
  6. package/dist/channels/config.js +234 -0
  7. package/dist/channels/config.js.map +1 -0
  8. package/dist/channels/protocol.d.ts +94 -0
  9. package/dist/channels/protocol.js +219 -0
  10. package/dist/channels/protocol.js.map +1 -0
  11. package/dist/channels/telegram.d.ts +46 -0
  12. package/dist/channels/telegram.js +285 -0
  13. package/dist/channels/telegram.js.map +1 -0
  14. package/dist/client/client.d.ts +1 -0
  15. package/dist/client/client.js +5 -1
  16. package/dist/client/client.js.map +1 -1
  17. package/dist/client/index.d.ts +1 -1
  18. package/dist/client/protocol.d.ts +1 -1
  19. package/dist/client/protocol.js +11 -5
  20. package/dist/client/protocol.js.map +1 -1
  21. package/dist/client/view.d.ts +42 -16
  22. package/dist/client/view.js.map +1 -1
  23. package/dist/controllers/index.d.ts +1 -1
  24. package/dist/controllers/index.js.map +1 -1
  25. package/dist/controllers/sqlite.d.ts +12 -165
  26. package/dist/controllers/sqlite.js +124 -435
  27. package/dist/controllers/sqlite.js.map +1 -1
  28. package/dist/extension/index.d.ts +23 -2
  29. package/dist/extension/index.js +619 -535
  30. package/dist/extension/index.js.map +1 -1
  31. package/dist/extension/recorder.d.ts +95 -0
  32. package/dist/extension/recorder.js +530 -0
  33. package/dist/extension/recorder.js.map +1 -0
  34. package/dist/extension/remote-recorder-store.d.ts +25 -0
  35. package/dist/extension/remote-recorder-store.js +81 -0
  36. package/dist/extension/remote-recorder-store.js.map +1 -0
  37. package/dist/extension/session-events.d.ts +134 -0
  38. package/dist/extension/session-events.js +60 -0
  39. package/dist/extension/session-events.js.map +1 -0
  40. package/dist/extension/session-view.d.ts +2 -0
  41. package/dist/extension/session-view.js +60 -2
  42. package/dist/extension/session-view.js.map +1 -1
  43. package/dist/extension/step-message.d.ts +6 -4
  44. package/dist/extension/step-message.js +12 -6
  45. package/dist/extension/step-message.js.map +1 -1
  46. package/dist/extension/widget.d.ts +2 -2
  47. package/dist/extension/widget.js +28 -18
  48. package/dist/extension/widget.js.map +1 -1
  49. package/dist/extension/workflow-message-coordinator.d.ts +27 -0
  50. package/dist/extension/workflow-message-coordinator.js +294 -0
  51. package/dist/extension/workflow-message-coordinator.js.map +1 -0
  52. package/dist/host/channel-effects.d.ts +72 -0
  53. package/dist/host/channel-effects.js +271 -0
  54. package/dist/host/channel-effects.js.map +1 -0
  55. package/dist/host/channel-supervisor.d.ts +21 -0
  56. package/dist/host/channel-supervisor.js +54 -0
  57. package/dist/host/channel-supervisor.js.map +1 -0
  58. package/dist/host/runner.d.ts +43 -8
  59. package/dist/host/runner.js +1567 -331
  60. package/dist/host/runner.js.map +1 -1
  61. package/dist/host/state.d.ts +19 -16
  62. package/dist/host/state.js +251 -63
  63. package/dist/host/state.js.map +1 -1
  64. package/dist/host/view.d.ts +19 -9
  65. package/dist/host/view.js +222 -118
  66. package/dist/host/view.js.map +1 -1
  67. package/dist/state/prune.js +3 -25
  68. package/dist/state/prune.js.map +1 -1
  69. package/dist/state/schema.js +76 -97
  70. package/dist/state/schema.js.map +1 -1
  71. package/dist/state/workflow-messages.d.ts +96 -0
  72. package/dist/state/workflow-messages.js +335 -0
  73. package/dist/state/workflow-messages.js.map +1 -0
  74. package/dist/viewer/render.js +1 -1
  75. package/dist/viewer/render.js.map +1 -1
  76. package/dist/viewer/tui.d.ts +1 -1
  77. package/dist/viewer/tui.js +65 -8
  78. package/dist/viewer/tui.js.map +1 -1
  79. package/dist/workflows/human-decision.d.ts +2 -0
  80. package/dist/workflows/human-decision.js +8 -2
  81. package/dist/workflows/human-decision.js.map +1 -1
  82. package/dist/workflows/index.d.ts +1 -1
  83. package/dist/workflows/index.js.map +1 -1
  84. package/dist/workflows/settings.d.ts +1 -8
  85. package/dist/workflows/settings.js.map +1 -1
  86. package/dist/workflows/store.d.ts +5 -19
  87. package/dist/workflows/store.js +121 -354
  88. package/dist/workflows/store.js.map +1 -1
  89. package/dist/workflows/tool-input.d.ts +21 -0
  90. package/dist/workflows/tool-input.js +23 -1
  91. package/dist/workflows/tool-input.js.map +1 -1
  92. package/dist/workflows/types.d.ts +5 -5
  93. package/dist/workflows/workflow-message-content.d.ts +38 -0
  94. package/dist/workflows/workflow-message-content.js +157 -0
  95. package/dist/workflows/workflow-message-content.js.map +1 -0
  96. package/docs/2026-08-18-herdr-piw-plan.md +2 -1
  97. package/docs/2026-08-20-durable-workflow-launch-plan.md +1 -1
  98. package/docs/2026-08-25-workflow-follow-ups.md +26 -50
  99. package/docs/2026-08-30-out-of-process-workflow-host-plan.md +3 -3
  100. package/docs/2026-09-01-restore-session-delivery-controls-plan.md +4 -0
  101. package/docs/2026-09-01-unified-workflow-client-plan.md +5 -1
  102. package/docs/2026-09-02-installed-live-e2e-plan.md +15 -1
  103. package/docs/2026-09-02-unify-workflow-messages-plan.md +562 -0
  104. package/docs/DEFERRED_TURNS.md +66 -294
  105. package/docs/HUMAN_DECISIONS.md +29 -30
  106. package/docs/HUMAN_DECISION_PRESENTATIONS.md +4 -11
  107. package/docs/SQLITE_STATE.md +19 -15
  108. package/docs/WORKFLOW_HOST.md +93 -28
  109. package/docs/WORKFLOW_STEP_MESSAGES.md +166 -75
  110. package/docs/live-replay-protocol.md +8 -4
  111. package/docs/tui-viewer.md +3 -1
  112. package/docs/workflows.md +34 -27
  113. package/herdr-plugin.toml +1 -1
  114. package/package.json +1 -1
  115. package/plugins/herdr/viewer.mjs +14 -5
  116. package/src/channels/adapter-entry.ts +220 -0
  117. package/src/channels/config.ts +296 -0
  118. package/src/channels/protocol.ts +333 -0
  119. package/src/channels/telegram.ts +335 -0
  120. package/src/client/client.ts +6 -2
  121. package/src/client/index.ts +2 -1
  122. package/src/client/protocol.ts +11 -5
  123. package/src/client/view.ts +43 -18
  124. package/src/controllers/index.ts +0 -1
  125. package/src/controllers/sqlite.ts +164 -844
  126. package/src/extension/index.ts +693 -646
  127. package/src/extension/recorder.ts +687 -0
  128. package/src/extension/remote-recorder-store.ts +126 -0
  129. package/src/extension/session-events.ts +119 -0
  130. package/src/extension/session-view.ts +86 -3
  131. package/src/extension/step-message.ts +19 -9
  132. package/src/extension/widget.ts +37 -17
  133. package/src/extension/workflow-message-coordinator.ts +334 -0
  134. package/src/host/channel-effects.ts +465 -0
  135. package/src/host/channel-supervisor.ts +73 -0
  136. package/src/host/runner.ts +2120 -531
  137. package/src/host/state.ts +333 -94
  138. package/src/host/view.ts +249 -121
  139. package/src/state/prune.ts +3 -31
  140. package/src/state/schema.ts +76 -97
  141. package/src/state/workflow-messages.ts +548 -0
  142. package/src/viewer/render.ts +1 -5
  143. package/src/viewer/tui.ts +71 -6
  144. package/src/workflows/human-decision.ts +18 -2
  145. package/src/workflows/index.ts +0 -1
  146. package/src/workflows/settings.ts +1 -20
  147. package/src/workflows/store.ts +173 -489
  148. package/src/workflows/tool-input.ts +36 -1
  149. package/src/workflows/types.ts +5 -5
  150. package/src/workflows/workflow-message-content.ts +197 -0
  151. package/dist/extension/decision-channels.d.ts +0 -134
  152. package/dist/extension/decision-channels.js +0 -1307
  153. package/dist/extension/decision-channels.js.map +0 -1
  154. package/src/extension/decision-channels.ts +0 -1826
@@ -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
 
@@ -75,6 +76,10 @@ export type WorkflowRunQueueViewRecord = {
75
76
  originSessionId: string | null;
76
77
  executionMode: "interactive" | "headless";
77
78
  parentRunId: string | null;
79
+ rootRunId: string;
80
+ lineageKind: "continuation" | "restart" | null;
81
+ restartNumber: number;
82
+ parentTerminalFingerprint: string | null;
78
83
  errorCode: string | null;
79
84
  errorMessage: string | null;
80
85
  createdAt: string;
@@ -101,6 +106,10 @@ export type WorkflowRunQueueRecord = {
101
106
  originSessionId: string | null;
102
107
  executionMode: "interactive" | "headless";
103
108
  parentRunId: string | null;
109
+ rootRunId: string;
110
+ lineageKind: "continuation" | "restart" | null;
111
+ restartNumber: number;
112
+ parentTerminalFingerprint: string | null;
104
113
  errorCode: string | null;
105
114
  errorMessage: string | null;
106
115
  createdAt: string;
@@ -119,62 +128,6 @@ export type WorkflowRunPreparationResult =
119
128
  run: WorkflowRunQueueRecord;
120
129
  };
121
130
 
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
131
  export type RunEventRecord = {
179
132
  seq: number;
180
133
  recordedAt: string;
@@ -226,6 +179,10 @@ type RunRow = {
226
179
  originSessionId: string | null;
227
180
  executionMode: "interactive" | "headless";
228
181
  parentRunId: string | null;
182
+ rootRunId: string;
183
+ lineageKind: "continuation" | "restart" | null;
184
+ restartNumber: number;
185
+ parentTerminalFingerprint: Buffer | null;
229
186
  createdAt: number;
230
187
  updatedAt: number;
231
188
  startedAt: number | null;
@@ -245,6 +202,10 @@ type WorkflowRunViewRow = {
245
202
  originSessionId: string | null;
246
203
  executionMode: "interactive" | "headless";
247
204
  parentRunId: string | null;
205
+ rootRunId: string;
206
+ lineageKind: "continuation" | "restart" | null;
207
+ restartNumber: number;
208
+ parentTerminalFingerprint: Buffer | null;
248
209
  errorCode: string | null;
249
210
  errorMessage: string | null;
250
211
  createdAt: number;
@@ -1129,39 +1090,77 @@ export class SqliteControllerStore implements ControllerStore {
1129
1090
  if (this.getWorkflowRun(options.runId) !== undefined) {
1130
1091
  throw new Error(`Workflow run already reserved: ${options.runId}`);
1131
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;
1132
1101
  if (options.parentRunId !== undefined) {
1133
1102
  const parent = this.requireWorkflowRunRow(options.parentRunId);
1134
1103
  if (parent.originSessionId !== options.originSessionId) {
1135
- throw new Error("Continuation parent belongs to another Pi session");
1104
+ throw new Error("Workflow parent belongs to another Pi session");
1136
1105
  }
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");
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");
1141
1121
  }
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
1122
  } else {
1163
- 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
+ }
1164
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");
1165
1164
  }
1166
1165
  const resourceId = resourceIdFor("run", options.runId);
1167
1166
  const definitionHash = this.state.putJson(options.definitionSnapshot, now);
@@ -1188,16 +1187,21 @@ export class SqliteControllerStore implements ControllerStore {
1188
1187
  this.state.connection
1189
1188
  .prepare(
1190
1189
  `INSERT INTO runs(
1191
- 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,
1192
1192
  workflow_ref, launch_options_hash, status, paused,
1193
1193
  input_hash, created_at, updated_at
1194
- ) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`,
1194
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`,
1195
1195
  )
1196
1196
  .run(
1197
1197
  options.runId,
1198
1198
  resourceId,
1199
1199
  this.requireProjectId(),
1200
1200
  options.parentRunId ?? null,
1201
+ rootRunId,
1202
+ lineageKind,
1203
+ restartNumber,
1204
+ parentTerminalFingerprint,
1201
1205
  definitionDigest,
1202
1206
  options.workflowSourceRef,
1203
1207
  launchHash,
@@ -1267,6 +1271,11 @@ export class SqliteControllerStore implements ControllerStore {
1267
1271
  runnerId: options.runnerId,
1268
1272
  originSessionId: options.originSessionId,
1269
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 }),
1270
1279
  ...(options.now === undefined ? {} : { now: options.now }),
1271
1280
  });
1272
1281
  }
@@ -1436,6 +1445,7 @@ export class SqliteControllerStore implements ControllerStore {
1436
1445
 
1437
1446
  beginWorkflowRunInteractionTimeout(options: {
1438
1447
  runId: string;
1448
+ targetSessionId: string;
1439
1449
  claimToken: string;
1440
1450
  now?: string;
1441
1451
  }): boolean {
@@ -1456,18 +1466,24 @@ export class SqliteControllerStore implements ControllerStore {
1456
1466
  `SELECT i.request_id AS requestId, i.attempt_id AS attemptId
1457
1467
  FROM interactive_requests i
1458
1468
  JOIN node_attempts a ON a.attempt_id = i.attempt_id
1459
- 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
1460
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
+ )
1461
1478
  ORDER BY a.deadline_at, i.request_id LIMIT 1`,
1462
1479
  )
1463
- .get(options.runId, now);
1480
+ .get(options.runId, options.targetSessionId, now);
1464
1481
  if (!isExpiredInteractionRow(interaction)) return false;
1465
1482
  const request = this.state.connection
1466
1483
  .prepare(
1467
1484
  `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')`,
1485
+ SET status = 'cancelled', revision = revision + 1, updated_at = ?
1486
+ WHERE request_id = ? AND run_id = ? AND status = 'pending'`,
1471
1487
  )
1472
1488
  .run(now, interaction.requestId, options.runId);
1473
1489
  const run = this.state.connection
@@ -1489,7 +1505,7 @@ export class SqliteControllerStore implements ControllerStore {
1489
1505
  if (request.changes !== 1 || run.changes !== 1 || queue.changes !== 1) {
1490
1506
  throw new Error(`Workflow run ${options.runId} changed during interaction timeout`);
1491
1507
  }
1492
- const error = "Workflow node deadline expired before origin-session input arrived";
1508
+ const error = "Workflow node model-turn deadline expired";
1493
1509
  const receiptHash = this.state.putJson({ status: "rejected", error }, now);
1494
1510
  this.state.connection
1495
1511
  .prepare(
@@ -1539,7 +1555,7 @@ export class SqliteControllerStore implements ControllerStore {
1539
1555
  "(l.owner_id IS NULL OR l.expires_at <= ? OR l.owner_id = ?)",
1540
1556
  "(q.affinity_runner_id IS NULL OR q.affinity_runner_id = ? OR q.status IN ('parked', 'starting', 'running'))",
1541
1557
  "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'))",
1558
+ "NOT EXISTS (SELECT 1 FROM interactive_requests i WHERE i.run_id = r.run_id AND i.status = 'pending')",
1543
1559
  "(q.error_code IS NULL OR q.error_code <> 'workflowSourceChanged')",
1544
1560
  ];
1545
1561
  const params: unknown[] = [now, now, options.runnerId, options.runnerId];
@@ -1663,7 +1679,7 @@ export class SqliteControllerStore implements ControllerStore {
1663
1679
  const pending = this.state.connection
1664
1680
  .prepare(
1665
1681
  `SELECT 1 FROM interactive_requests
1666
- WHERE run_id = ? AND status IN ('pending', 'presenting') LIMIT 1`,
1682
+ WHERE run_id = ? AND status = 'pending' LIMIT 1`,
1667
1683
  )
1668
1684
  .get(options.runId);
1669
1685
  if (pending === undefined) return false;
@@ -1714,7 +1730,7 @@ export class SqliteControllerStore implements ControllerStore {
1714
1730
  const pending = this.state.connection
1715
1731
  .prepare(
1716
1732
  `SELECT 1 FROM interactive_requests
1717
- WHERE run_id = ? AND status IN ('pending', 'presenting') LIMIT 1`,
1733
+ WHERE run_id = ? AND status = 'pending' LIMIT 1`,
1718
1734
  )
1719
1735
  .get(options.runId);
1720
1736
  if (pending === undefined) return false;
@@ -2132,427 +2148,6 @@ export class SqliteControllerStore implements ControllerStore {
2132
2148
  }));
2133
2149
  }
2134
2150
 
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
2151
  settleRunEffect(runId: string, effectType: "run.park_queue" | "run.settle_queue"): void {
2557
2152
  const rows = this.state.connection
2558
2153
  .prepare(
@@ -3007,6 +2602,21 @@ export class SqliteControllerStore implements ControllerStore {
3007
2602
  options: WorkflowRunReservationOptions,
3008
2603
  definitionDigest: Buffer,
3009
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);
3010
2620
  const compatible =
3011
2621
  row.workflowName === options.workflowName &&
3012
2622
  row.workflowRef === options.workflowSourceRef &&
@@ -3018,7 +2628,14 @@ export class SqliteControllerStore implements ControllerStore {
3018
2628
  canonicalJson(options.launchOptions ?? {}) &&
3019
2629
  row.originSessionId === options.originSessionId &&
3020
2630
  row.executionMode === (options.executionMode ?? "interactive") &&
3021
- 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)));
3022
2639
  if (!compatible) {
3023
2640
  throw new Error(`Workflow run preparation conflicts: ${options.runId}`);
3024
2641
  }
@@ -3052,6 +2669,10 @@ export class SqliteControllerStore implements ControllerStore {
3052
2669
  originSessionId: row.executionMode === "headless" ? null : row.originSessionId,
3053
2670
  executionMode: row.executionMode,
3054
2671
  parentRunId: row.parentRunId,
2672
+ rootRunId: row.rootRunId,
2673
+ lineageKind: row.lineageKind,
2674
+ restartNumber: row.restartNumber,
2675
+ parentTerminalFingerprint: row.parentTerminalFingerprint?.toString("hex") ?? null,
3055
2676
  errorCode: row.errorCode,
3056
2677
  errorMessage:
3057
2678
  row.errorHash === null
@@ -3092,7 +2713,7 @@ export class SqliteControllerStore implements ControllerStore {
3092
2713
  this.state.connection
3093
2714
  .prepare(
3094
2715
  `SELECT 1 FROM interactive_requests
3095
- WHERE run_id = ? AND status IN ('pending', 'presenting') LIMIT 1`,
2716
+ WHERE run_id = ? AND status = 'pending' LIMIT 1`,
3096
2717
  )
3097
2718
  .get(runId) !== undefined
3098
2719
  ) {
@@ -3419,262 +3040,12 @@ export class SqliteControllerStore implements ControllerStore {
3419
3040
  this.state.connection
3420
3041
  .prepare(
3421
3042
  `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')`,
3043
+ SET status = 'cancelled', revision = revision + 1, updated_at = ?
3044
+ WHERE run_id = ? AND status = 'pending'`,
3425
3045
  )
3426
3046
  .run(now, runId);
3427
3047
  }
3428
3048
 
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
3049
  private assertWritable(): void {
3679
3050
  if (this.state.mode === "read-only") throw new Error("Controller store is read-only");
3680
3051
  }
@@ -3719,6 +3090,9 @@ function workflowRunSelect(clause: string): string {
3719
3090
  q.consecutive_errors AS consecutiveErrors, q.error_code AS errorCode,
3720
3091
  q.error_hash AS errorHash, b.origin_session_id AS originSessionId,
3721
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,
3722
3096
  q.created_at AS createdAt, q.updated_at AS updatedAt,
3723
3097
  q.started_at AS startedAt, q.finished_at AS finishedAt,
3724
3098
  l.generation AS leaseGeneration, l.owner_id AS ownerId, l.expires_at AS claimExpiresAt
@@ -3733,8 +3107,10 @@ function workflowRunViewSelect(clause: string): string {
3733
3107
  r.workflow_ref AS workflowRef, r.status AS runStatus, r.paused,
3734
3108
  r.definition_digest AS definitionDigest, q.status,
3735
3109
  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,
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,
3738
3114
  q.created_at AS createdAt, q.updated_at AS updatedAt,
3739
3115
  q.started_at AS startedAt, q.finished_at AS finishedAt,
3740
3116
  source.source_type AS sourceType, source.source_ref AS sourceRef,
@@ -3746,31 +3122,6 @@ function workflowRunViewSelect(clause: string): string {
3746
3122
  LEFT JOIN blobs error ON error.blob_hash = q.error_hash ${clause}`;
3747
3123
  }
3748
3124
 
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
3125
  type QueueListRow = {
3775
3126
  controller: string;
3776
3127
  resourceKey: string;
@@ -3795,39 +3146,6 @@ type RunEventRow = {
3795
3146
  runnerId: string | null;
3796
3147
  payloadHash: Buffer | null;
3797
3148
  };
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
3149
  function isRecord(value: unknown): value is Record<string, unknown> {
3832
3150
  return typeof value === "object" && value !== null && !Array.isArray(value);
3833
3151
  }
@@ -3855,6 +3173,10 @@ function workflowRunViewRecord(row: WorkflowRunViewRow): WorkflowRunQueueViewRec
3855
3173
  originSessionId: row.executionMode === "headless" ? null : row.originSessionId,
3856
3174
  executionMode: row.executionMode,
3857
3175
  parentRunId: row.parentRunId,
3176
+ rootRunId: row.rootRunId,
3177
+ lineageKind: row.lineageKind,
3178
+ restartNumber: row.restartNumber,
3179
+ parentTerminalFingerprint: row.parentTerminalFingerprint?.toString("hex") ?? null,
3858
3180
  errorCode: row.errorCode,
3859
3181
  errorMessage: row.errorMessage,
3860
3182
  createdAt: new Date(row.createdAt).toISOString(),
@@ -3877,6 +3199,13 @@ function isWorkflowRunViewRow(value: unknown): value is WorkflowRunViewRow {
3877
3199
  (value.originSessionId === null || typeof value.originSessionId === "string") &&
3878
3200
  (value.executionMode === "interactive" || value.executionMode === "headless") &&
3879
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)) &&
3880
3209
  (value.errorCode === null || typeof value.errorCode === "string") &&
3881
3210
  (value.errorMessage === null || typeof value.errorMessage === "string") &&
3882
3211
  typeof value.createdAt === "number" &&
@@ -3933,12 +3262,6 @@ function isControllerEventRow(value: unknown): value is ControllerEventRow {
3933
3262
  function isRunEventRow(value: unknown): value is RunEventRow {
3934
3263
  return isRecord(value);
3935
3264
  }
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
3265
  function isProjectRow(value: unknown): value is { projectId: string } {
3943
3266
  return isRecord(value);
3944
3267
  }
@@ -3963,14 +3286,6 @@ function isSequenceRow(value: unknown): value is { seq: number } {
3963
3286
  function isKeyRow(value: unknown): value is { key: string } {
3964
3287
  return isRecord(value);
3965
3288
  }
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
3289
  function isEffectIdentityRow(value: unknown): value is { effectId: string; resourceId: string } {
3975
3290
  return isRecord(value);
3976
3291
  }
@@ -3993,6 +3308,11 @@ function digestBuffer(value: string): Buffer {
3993
3308
  if (!/^[a-f0-9]{64}$/i.test(hex)) throw new Error("Expected a SHA-256 digest");
3994
3309
  return Buffer.from(hex, "hex");
3995
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
+ }
3996
3316
  type QueuedSource = {
3997
3317
  root:
3998
3318
  | { kind: "builtin"; id: string; revision: string }