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