@osolmaz/pi-workflows 0.12.1 → 0.13.0
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 +52 -28
- package/dist/builtins/autodoc.workflow.d.ts +4 -4
- package/dist/builtins/autoimplement.workflow.d.ts +369 -73
- package/dist/builtins/autoplan.workflow.d.ts +126 -13
- package/dist/builtins/autoplan.workflow.js +200 -29
- package/dist/builtins/autoplan.workflow.js.map +1 -1
- package/dist/builtins/catalog.js +6 -4
- package/dist/builtins/catalog.js.map +1 -1
- package/dist/builtins/index.d.ts +2 -1
- package/dist/builtins/index.js +1 -0
- package/dist/builtins/index.js.map +1 -1
- package/dist/builtins/monitor.workflow.d.ts +56 -21
- package/dist/builtins/monitor.workflow.js +497 -216
- package/dist/builtins/monitor.workflow.js.map +1 -1
- package/dist/builtins/plain-summary.workflow.d.ts +32 -0
- package/dist/builtins/plain-summary.workflow.js +134 -0
- package/dist/builtins/plain-summary.workflow.js.map +1 -0
- package/dist/builtins/plan-change.workflow.d.ts +170 -22
- package/dist/builtins/sanity-check.workflow.d.ts +45 -3
- package/dist/builtins/sanity-check.workflow.js +45 -7
- package/dist/builtins/sanity-check.workflow.js.map +1 -1
- package/dist/controllers/effects.d.ts +3 -2
- package/dist/controllers/effects.js +8 -1
- package/dist/controllers/effects.js.map +1 -1
- package/dist/controllers/index.d.ts +1 -1
- package/dist/controllers/index.js +1 -1
- package/dist/controllers/index.js.map +1 -1
- package/dist/controllers/manager.d.ts +1 -0
- package/dist/controllers/manager.js +41 -17
- package/dist/controllers/manager.js.map +1 -1
- package/dist/controllers/sqlite.d.ts +107 -85
- package/dist/controllers/sqlite.js +1611 -1486
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/controllers/store.d.ts +9 -8
- package/dist/controllers/store.js +1 -31
- package/dist/controllers/store.js.map +1 -1
- package/dist/controllers/types.d.ts +3 -0
- package/dist/controllers/workflow-engine-scheduler.d.ts +1 -1
- package/dist/controllers/workflow-engine-scheduler.js +3 -5
- package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
- package/dist/controllers/workflows.d.ts +2 -2
- package/dist/controllers/workflows.js +11 -10
- package/dist/controllers/workflows.js.map +1 -1
- package/dist/extension/controller-host.js +2 -2
- package/dist/extension/controller-host.js.map +1 -1
- package/dist/extension/decision-channels.d.ts +2 -0
- package/dist/extension/decision-channels.js +311 -118
- package/dist/extension/decision-channels.js.map +1 -1
- package/dist/extension/executor.d.ts +9 -2
- package/dist/extension/executor.js +89 -2
- package/dist/extension/executor.js.map +1 -1
- package/dist/extension/herdr-viewer.d.ts +0 -1
- package/dist/extension/herdr-viewer.js +0 -4
- package/dist/extension/herdr-viewer.js.map +1 -1
- package/dist/extension/index.js +156 -103
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/recorder.d.ts +3 -10
- package/dist/extension/recorder.js +13 -22
- package/dist/extension/recorder.js.map +1 -1
- package/dist/extension/step-message.d.ts +7 -2
- package/dist/extension/step-message.js +94 -2
- package/dist/extension/step-message.js.map +1 -1
- package/dist/extension/widget.js +6 -0
- package/dist/extension/widget.js.map +1 -1
- package/dist/host/rpc-executor.d.ts +3 -0
- package/dist/host/rpc-executor.js +2 -0
- package/dist/host/rpc-executor.js.map +1 -1
- package/dist/host/runner.d.ts +3 -6
- package/dist/host/runner.js +83 -32
- package/dist/host/runner.js.map +1 -1
- package/dist/render/graph-render.js +14 -8
- package/dist/render/graph-render.js.map +1 -1
- package/dist/state/database.d.ts +35 -0
- package/dist/state/database.js +287 -0
- package/dist/state/database.js.map +1 -0
- package/dist/state/index.d.ts +4 -0
- package/dist/state/index.js +5 -0
- package/dist/state/index.js.map +1 -0
- package/dist/state/json.d.ts +6 -0
- package/dist/state/json.js +38 -0
- package/dist/state/json.js.map +1 -0
- package/dist/state/mutation.d.ts +57 -0
- package/dist/state/mutation.js +255 -0
- package/dist/state/mutation.js.map +1 -0
- package/dist/state/schema.d.ts +6 -0
- package/dist/state/schema.js +473 -0
- package/dist/state/schema.js.map +1 -0
- package/dist/viewer/cli.d.ts +2 -2
- package/dist/viewer/cli.js +142 -122
- package/dist/viewer/cli.js.map +1 -1
- package/dist/viewer/render.d.ts +4 -4
- package/dist/viewer/render.js +2 -17
- package/dist/viewer/render.js.map +1 -1
- package/dist/viewer/session-reducer.d.ts +1 -1
- package/dist/viewer/session-reducer.js +1 -1
- package/dist/viewer/tui.d.ts +3 -3
- package/dist/viewer/tui.js +15 -13
- package/dist/viewer/tui.js.map +1 -1
- package/dist/viewer/watch.d.ts +2 -5
- package/dist/viewer/watch.js +13 -13
- package/dist/viewer/watch.js.map +1 -1
- package/dist/workflows/composition.js +12 -2
- package/dist/workflows/composition.js.map +1 -1
- package/dist/workflows/definition.d.ts +6 -2
- package/dist/workflows/definition.js +17 -0
- package/dist/workflows/definition.js.map +1 -1
- package/dist/workflows/engine.d.ts +6 -6
- package/dist/workflows/engine.js +183 -103
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/errors.d.ts +3 -3
- package/dist/workflows/errors.js +3 -3
- package/dist/workflows/human-decision.d.ts +29 -4
- package/dist/workflows/human-decision.js +547 -283
- package/dist/workflows/human-decision.js.map +1 -1
- package/dist/workflows/index.d.ts +4 -5
- package/dist/workflows/index.js +3 -4
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/schema.js +17 -1
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.d.ts +83 -103
- package/dist/workflows/store.js +902 -951
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +55 -81
- package/docs/CONTROLLERS.md +10 -8
- package/docs/DEFERRED_TURNS.md +2 -2
- package/docs/DESIGN_PHILOSOPHY.md +4 -2
- package/docs/HUMAN_DECISIONS.md +13 -14
- package/docs/MONITOR.md +179 -231
- package/docs/SQLITE_STATE.md +219 -0
- package/docs/WORKFLOW_COMPOSITION.md +19 -19
- package/docs/WORKFLOW_STEP_MESSAGES.md +20 -18
- package/docs/WORKFLOW_UPDATES.md +10 -9
- package/docs/development.md +9 -9
- package/docs/live-replay-protocol.md +28 -37
- package/docs/plans/2026-08-16-workflow-updates-plan.md +1 -1
- package/docs/plans/2026-08-19-human-decision-gates-plan.md +1 -1
- package/docs/plans/2026-08-21-autoimplement-timeout-fallback-plan.md +1 -1
- package/docs/plans/2026-08-21-plan-change-approval-policy-plan.md +1 -1
- package/docs/plans/2026-08-21-sanity-check-plan.md +30 -16
- package/docs/plans/2026-08-22-goal-finishing-monitor-plan.md +200 -0
- package/docs/plans/2026-08-23-assistant-agent-completion-plan.md +701 -0
- package/docs/plans/2026-08-23-sqlite-state-plan.md +970 -0
- package/docs/plans/replayable-run-bundles-implementation-plan.md +2 -2
- package/docs/plans/session-event-replay-implementation-plan.md +1 -1
- package/docs/plans/tui-viewer-implementation-plan.md +1 -1
- package/docs/session-event-journal.md +63 -434
- package/docs/tui-viewer.md +13 -15
- package/docs/workflows.md +104 -50
- package/examples/workflows/plain-summary.workflow.ts +1 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/plugins/herdr/viewer.mjs +1 -11
- package/skills/autoplan/SKILL.md +5 -4
- package/skills/monitor/SKILL.md +91 -163
- package/skills/pi-workflows/SKILL.md +7 -7
- package/skills/sanity-check/SKILL.md +1 -1
- package/src/builtins/autoplan.workflow.ts +290 -40
- package/src/builtins/catalog.ts +6 -4
- package/src/builtins/index.ts +7 -1
- package/src/builtins/monitor.workflow.ts +663 -249
- package/src/builtins/plain-summary.workflow.ts +185 -0
- package/src/builtins/sanity-check.workflow.ts +62 -7
- package/src/controllers/effects.ts +7 -0
- package/src/controllers/index.ts +0 -6
- package/src/controllers/manager.ts +72 -32
- package/src/controllers/sqlite.ts +2420 -1948
- package/src/controllers/store.ts +17 -38
- package/src/controllers/types.ts +3 -0
- package/src/controllers/workflow-engine-scheduler.ts +4 -8
- package/src/controllers/workflows.ts +45 -21
- package/src/extension/controller-host.ts +1 -2
- package/src/extension/decision-channels.ts +427 -144
- package/src/extension/executor.ts +105 -2
- package/src/extension/herdr-viewer.ts +0 -5
- package/src/extension/index.ts +180 -118
- package/src/extension/recorder.ts +13 -22
- package/src/extension/step-message.ts +106 -4
- package/src/extension/widget.ts +8 -0
- package/src/host/rpc-executor.ts +4 -0
- package/src/host/runner.ts +93 -41
- package/src/render/graph-render.ts +11 -4
- package/src/state/database.ts +375 -0
- package/src/state/index.ts +29 -0
- package/src/state/json.ts +42 -0
- package/src/state/mutation.ts +435 -0
- package/src/state/schema.ts +475 -0
- package/src/viewer/cli.ts +151 -144
- package/src/viewer/render.ts +12 -24
- package/src/viewer/session-reducer.ts +1 -1
- package/src/viewer/tui.ts +18 -17
- package/src/viewer/watch.ts +13 -17
- package/src/workflows/composition.ts +12 -3
- package/src/workflows/definition.ts +36 -3
- package/src/workflows/engine.ts +212 -116
- package/src/workflows/errors.ts +3 -3
- package/src/workflows/human-decision.ts +795 -340
- package/src/workflows/index.ts +15 -19
- package/src/workflows/schema.ts +19 -1
- package/src/workflows/store.ts +1295 -1163
- package/src/workflows/types.ts +61 -81
- package/dist/workflows/artifacts.d.ts +0 -40
- package/dist/workflows/artifacts.js +0 -155
- package/dist/workflows/artifacts.js.map +0 -1
- package/dist/workflows/migrate-sources.d.ts +0 -42
- package/dist/workflows/migrate-sources.js +0 -133
- package/dist/workflows/migrate-sources.js.map +0 -1
- package/docs/run-bundles.md +0 -481
- package/src/workflows/artifacts.ts +0 -188
- package/src/workflows/migrate-sources.ts +0 -178
|
@@ -1,23 +1,21 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import
|
|
4
|
+
import { StateDatabase, workflowStatePath } from "../state/database.js";
|
|
5
|
+
import { resourceIdFor, tokenHash } from "../state/mutation.js";
|
|
5
6
|
import {
|
|
6
7
|
EffectRequestConflictError,
|
|
7
8
|
ResourceConflictError,
|
|
8
9
|
ResourceNotFoundError,
|
|
9
10
|
WorkflowRequestConflictError,
|
|
10
11
|
} from "./errors.js";
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
type WorkflowRecordUpdate,
|
|
19
|
-
type WorkflowReservation,
|
|
20
|
-
controllerStorePath,
|
|
12
|
+
import type {
|
|
13
|
+
ControllerStore,
|
|
14
|
+
EffectReservation,
|
|
15
|
+
QueueItem,
|
|
16
|
+
QueueRequeueOptions,
|
|
17
|
+
WorkflowRecordUpdate,
|
|
18
|
+
WorkflowReservation,
|
|
21
19
|
} from "./store.js";
|
|
22
20
|
import type {
|
|
23
21
|
ChildWorkflowRecord,
|
|
@@ -30,60 +28,8 @@ import type {
|
|
|
30
28
|
JsonObject,
|
|
31
29
|
} from "./types.js";
|
|
32
30
|
|
|
33
|
-
const MAX_KEY_LENGTH = 512;
|
|
34
|
-
const MAX_ERROR_LENGTH = 8_192;
|
|
35
|
-
const MAX_EVENT_BYTES = 64 * 1024;
|
|
36
|
-
const MAX_RESOURCE_VALUE_BYTES = 1024 * 1024;
|
|
37
31
|
const TURN_INTENT_FACTS_SCHEMA = "pi-workflows.deferred-turn-facts.v1";
|
|
38
32
|
|
|
39
|
-
type ResourceRow = {
|
|
40
|
-
controller: string;
|
|
41
|
-
resource_key: string;
|
|
42
|
-
uid: string;
|
|
43
|
-
resource_version: number;
|
|
44
|
-
generation: number;
|
|
45
|
-
spec_json: string;
|
|
46
|
-
status_json: string;
|
|
47
|
-
deletion_timestamp: string | null;
|
|
48
|
-
finalizers_json: string;
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
type QueueRow = {
|
|
52
|
-
controller: string;
|
|
53
|
-
resource_key: string;
|
|
54
|
-
available_at: number;
|
|
55
|
-
version: number;
|
|
56
|
-
consecutive_errors: number;
|
|
57
|
-
claim_token: string | null;
|
|
58
|
-
claim_version: number | null;
|
|
59
|
-
claim_expires_at: number | null;
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
type EffectRow = {
|
|
63
|
-
effect_key: string;
|
|
64
|
-
resource_uid: string;
|
|
65
|
-
generation: number;
|
|
66
|
-
kind: string;
|
|
67
|
-
state: EffectRecord["state"];
|
|
68
|
-
request_fingerprint: string;
|
|
69
|
-
started_at: string;
|
|
70
|
-
completed_at: string | null;
|
|
71
|
-
external_ref: string | null;
|
|
72
|
-
error: string | null;
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
type WorkflowRow = {
|
|
76
|
-
request_id: string;
|
|
77
|
-
resource_uid: string;
|
|
78
|
-
request_key: string;
|
|
79
|
-
input_fingerprint: string;
|
|
80
|
-
workflow: string;
|
|
81
|
-
run_id: string | null;
|
|
82
|
-
state: ChildWorkflowRecord["state"];
|
|
83
|
-
attempt: number;
|
|
84
|
-
error: string | null;
|
|
85
|
-
};
|
|
86
|
-
|
|
87
33
|
export type WorkflowRunLaunchStatus =
|
|
88
34
|
| "queued"
|
|
89
35
|
| "starting"
|
|
@@ -93,46 +39,21 @@ export type WorkflowRunLaunchStatus =
|
|
|
93
39
|
| "failed"
|
|
94
40
|
| "cancelled";
|
|
95
41
|
|
|
96
|
-
type WorkflowRunQueueRow = {
|
|
97
|
-
run_id: string;
|
|
98
|
-
workflow_ref: string;
|
|
99
|
-
workflow_path: string;
|
|
100
|
-
workflow_source_json: string;
|
|
101
|
-
definition_digest: string;
|
|
102
|
-
input_json: string;
|
|
103
|
-
launch_options_json: string;
|
|
104
|
-
status: WorkflowRunLaunchStatus;
|
|
105
|
-
runner_id: string | null;
|
|
106
|
-
claim_token: string | null;
|
|
107
|
-
claim_expires_at: number | null;
|
|
108
|
-
affinity_runner_id: string | null;
|
|
109
|
-
origin_session_id: string | null;
|
|
110
|
-
parent_run_id: string | null;
|
|
111
|
-
error_code: string | null;
|
|
112
|
-
error_message: string | null;
|
|
113
|
-
created_at: string;
|
|
114
|
-
updated_at: string;
|
|
115
|
-
started_at: string | null;
|
|
116
|
-
finished_at: string | null;
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
/** A user-started workflow run tracked by the durable run queue. */
|
|
120
42
|
export type WorkflowRunQueueRecord = {
|
|
121
43
|
runId: string;
|
|
122
|
-
/** Human-readable workflow name used in status and event output. */
|
|
123
44
|
workflowName: string;
|
|
124
|
-
/** Canonical source reference used to reopen the run. */
|
|
125
45
|
workflowSourceRef: string;
|
|
126
46
|
workflowSource: unknown;
|
|
47
|
+
initialized: boolean;
|
|
127
48
|
definitionDigest: string;
|
|
128
49
|
input: unknown;
|
|
129
50
|
launchOptions: unknown;
|
|
130
51
|
status: WorkflowRunLaunchStatus;
|
|
131
52
|
runnerId: string | null;
|
|
132
53
|
claimToken: string | null;
|
|
54
|
+
claimGeneration: number | null;
|
|
133
55
|
claimExpiresAt: string | null;
|
|
134
56
|
affinityRunnerId: string | null;
|
|
135
|
-
/** Pi session that owns delivery and interactive execution, or null for detached runs. */
|
|
136
57
|
originSessionId: string | null;
|
|
137
58
|
parentRunId: string | null;
|
|
138
59
|
errorCode: string | null;
|
|
@@ -143,21 +64,6 @@ export type WorkflowRunQueueRecord = {
|
|
|
143
64
|
finishedAt: string | null;
|
|
144
65
|
};
|
|
145
66
|
|
|
146
|
-
type WorkflowNotificationRow = {
|
|
147
|
-
notification_id: string;
|
|
148
|
-
run_id: string;
|
|
149
|
-
node_id: string;
|
|
150
|
-
attempt_id: string;
|
|
151
|
-
notification_index: number;
|
|
152
|
-
target_session_id: string;
|
|
153
|
-
kind: string;
|
|
154
|
-
content: string;
|
|
155
|
-
created_at: string;
|
|
156
|
-
delivery_claim_token: string | null;
|
|
157
|
-
delivery_claim_expires_at: number | null;
|
|
158
|
-
delivered_at: string | null;
|
|
159
|
-
};
|
|
160
|
-
|
|
161
67
|
export type WorkflowNotificationRecord = {
|
|
162
68
|
notificationId: string;
|
|
163
69
|
runId: string;
|
|
@@ -194,25 +100,6 @@ export type WorkflowTurnIntentFacts = JsonObject & {
|
|
|
194
100
|
handoff: boolean;
|
|
195
101
|
};
|
|
196
102
|
|
|
197
|
-
type WorkflowTurnIntentRow = {
|
|
198
|
-
intent_id: string;
|
|
199
|
-
source_event_id: string;
|
|
200
|
-
run_id: string;
|
|
201
|
-
workflow_ref: string;
|
|
202
|
-
target_session_id: string;
|
|
203
|
-
cause: string;
|
|
204
|
-
node_id: string | null;
|
|
205
|
-
attempt_id: string | null;
|
|
206
|
-
fallback_facts_json: string;
|
|
207
|
-
requested_at: string;
|
|
208
|
-
eligible_at: string | null;
|
|
209
|
-
resolved_at: string | null;
|
|
210
|
-
resolution: string | null;
|
|
211
|
-
resolution_message_id: string | null;
|
|
212
|
-
delivery_claim_token: string | null;
|
|
213
|
-
delivery_claim_expires_at: number | null;
|
|
214
|
-
};
|
|
215
|
-
|
|
216
103
|
export type WorkflowTurnIntentRecord = {
|
|
217
104
|
intentId: string;
|
|
218
105
|
sourceEventId: string;
|
|
@@ -231,17 +118,6 @@ export type WorkflowTurnIntentRecord = {
|
|
|
231
118
|
deliveryClaimExpiresAt: string | null;
|
|
232
119
|
};
|
|
233
120
|
|
|
234
|
-
type RunEventRow = {
|
|
235
|
-
seq: number;
|
|
236
|
-
recorded_at: string;
|
|
237
|
-
run_id: string;
|
|
238
|
-
workflow_ref: string;
|
|
239
|
-
type: string;
|
|
240
|
-
runner_id: string | null;
|
|
241
|
-
payload_json: string;
|
|
242
|
-
};
|
|
243
|
-
|
|
244
|
-
/** One run lifecycle transition in the cross-session event feed. */
|
|
245
121
|
export type RunEventRecord = {
|
|
246
122
|
seq: number;
|
|
247
123
|
recordedAt: string;
|
|
@@ -252,44 +128,109 @@ export type RunEventRecord = {
|
|
|
252
128
|
payload: JsonObject;
|
|
253
129
|
};
|
|
254
130
|
|
|
255
|
-
type
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
131
|
+
type ControllerRow = {
|
|
132
|
+
controllerResourceId: string;
|
|
133
|
+
resourceId: string;
|
|
134
|
+
resourceVersion: number;
|
|
135
|
+
controllerName: string;
|
|
136
|
+
resourceKey: string;
|
|
137
|
+
uid: string;
|
|
138
|
+
generation: number;
|
|
139
|
+
specHash: Buffer;
|
|
140
|
+
statusHash: Buffer;
|
|
141
|
+
deletionRequestedAt: number | null;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
type LeaseRow = {
|
|
145
|
+
generation: number;
|
|
146
|
+
ownerType: string | null;
|
|
147
|
+
ownerId: string | null;
|
|
148
|
+
tokenHash: Buffer | null;
|
|
149
|
+
expiresAt: number | null;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
type RunRow = {
|
|
153
|
+
runId: string;
|
|
154
|
+
resourceId: string;
|
|
155
|
+
workflowName: string;
|
|
156
|
+
workflowRef: string;
|
|
157
|
+
runStatus: string;
|
|
158
|
+
workflowSourceHash: Buffer;
|
|
159
|
+
definitionDigest: Buffer;
|
|
160
|
+
inputHash: Buffer;
|
|
161
|
+
outputHash: Buffer | null;
|
|
162
|
+
launchOptionsHash: Buffer;
|
|
163
|
+
status: WorkflowRunLaunchStatus;
|
|
164
|
+
availableAt: number;
|
|
165
|
+
affinityRunnerId: string | null;
|
|
166
|
+
consecutiveErrors: number;
|
|
167
|
+
errorCode: string | null;
|
|
168
|
+
errorHash: Buffer | null;
|
|
169
|
+
originSessionId: string | null;
|
|
170
|
+
parentRunId: string | null;
|
|
171
|
+
createdAt: number;
|
|
172
|
+
updatedAt: number;
|
|
173
|
+
startedAt: number | null;
|
|
174
|
+
finishedAt: number | null;
|
|
175
|
+
leaseGeneration: number;
|
|
176
|
+
ownerId: string | null;
|
|
177
|
+
claimExpiresAt: number | null;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
type EffectRow = {
|
|
181
|
+
effectId: string;
|
|
182
|
+
resourceUid: string;
|
|
183
|
+
generation: number;
|
|
184
|
+
kind: string;
|
|
185
|
+
state: string;
|
|
186
|
+
requestFingerprint: string;
|
|
187
|
+
startedAt: number;
|
|
188
|
+
completedAt: number | null;
|
|
189
|
+
externalRef: string | null;
|
|
190
|
+
errorHash: Buffer | null;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
type WorkflowRow = {
|
|
194
|
+
requestId: string;
|
|
195
|
+
resourceUid: string;
|
|
196
|
+
requestKey: string;
|
|
197
|
+
inputFingerprint: Buffer;
|
|
198
|
+
workflowName: string;
|
|
199
|
+
runId: string | null;
|
|
200
|
+
status: ChildWorkflowRecord["state"];
|
|
201
|
+
attemptCount: number;
|
|
202
|
+
errorHash: Buffer | null;
|
|
262
203
|
};
|
|
263
204
|
|
|
264
205
|
export class SqliteControllerStore implements ControllerStore {
|
|
265
206
|
readonly filePath: string;
|
|
266
|
-
|
|
207
|
+
readonly state: StateDatabase;
|
|
208
|
+
private readonly ownsState: boolean;
|
|
209
|
+
private readonly projectId: string | null;
|
|
267
210
|
private closed = false;
|
|
268
211
|
|
|
269
|
-
constructor(
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
this.
|
|
284
|
-
|
|
212
|
+
constructor(
|
|
213
|
+
filePath: string = workflowStatePath(),
|
|
214
|
+
options: { readOnly?: boolean; projectPath?: string; state?: StateDatabase } = {},
|
|
215
|
+
) {
|
|
216
|
+
this.ownsState = options.state === undefined;
|
|
217
|
+
this.state =
|
|
218
|
+
options.state ??
|
|
219
|
+
new StateDatabase({
|
|
220
|
+
filePath,
|
|
221
|
+
mode: options.readOnly === true ? "read-only" : "read-write",
|
|
222
|
+
checkLegacyState: filePath === workflowStatePath(),
|
|
223
|
+
});
|
|
224
|
+
this.filePath = this.state.filePath;
|
|
225
|
+
const projectPath = options.projectPath === undefined ? process.cwd() : options.projectPath;
|
|
226
|
+
this.projectId =
|
|
227
|
+
options.readOnly === true ? this.findProject(projectPath) : this.ensureProject(projectPath);
|
|
285
228
|
}
|
|
286
229
|
|
|
287
230
|
close(): void {
|
|
288
|
-
if (this.closed)
|
|
289
|
-
return;
|
|
290
|
-
}
|
|
231
|
+
if (this.closed) return;
|
|
291
232
|
this.closed = true;
|
|
292
|
-
this.
|
|
233
|
+
if (this.ownsState) this.state.close();
|
|
293
234
|
}
|
|
294
235
|
|
|
295
236
|
putResource<TSpec, TStatus>(options: {
|
|
@@ -299,144 +240,193 @@ export class SqliteControllerStore implements ControllerStore {
|
|
|
299
240
|
initialStatus: TStatus;
|
|
300
241
|
now?: string;
|
|
301
242
|
}): ControllerResource<TSpec, TStatus> {
|
|
243
|
+
this.assertWritable();
|
|
302
244
|
validateName(options.controller, "controller");
|
|
303
245
|
validateKey(options.key, "resource key");
|
|
304
|
-
const now = validTimestamp(options.now);
|
|
305
|
-
const
|
|
306
|
-
validateJsonSize(specJson, "Controller resource spec", MAX_RESOURCE_VALUE_BYTES);
|
|
246
|
+
const now = epoch(validTimestamp(options.now));
|
|
247
|
+
const specHash = this.state.putJson(options.spec, now);
|
|
307
248
|
const initialStatus: ControllerResourceStatus<TStatus> = {
|
|
308
249
|
observedGeneration: 0,
|
|
309
250
|
conditions: [],
|
|
310
251
|
controllerStatus: options.initialStatus,
|
|
311
252
|
};
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
return this.transaction(() => {
|
|
316
|
-
const existing = this.resourceRow({ controller: options.controller, key: options.key });
|
|
253
|
+
const statusHash = this.state.putJson(initialStatus, now);
|
|
254
|
+
return this.state.transaction(() => {
|
|
255
|
+
const existing = this.controllerRow({ controller: options.controller, key: options.key });
|
|
317
256
|
if (existing === undefined) {
|
|
318
257
|
const uid = randomUUID();
|
|
319
|
-
|
|
258
|
+
const controllerResourceId = `controller-resource-${randomUUID()}`;
|
|
259
|
+
const resourceId = resourceIdFor(
|
|
260
|
+
"controller",
|
|
261
|
+
`${this.requireProjectId()}:${options.controller}:${options.key}`,
|
|
262
|
+
);
|
|
263
|
+
this.state.connection
|
|
264
|
+
.prepare(
|
|
265
|
+
`INSERT INTO resources(
|
|
266
|
+
resource_id, resource_type, aggregate_key, revision, created_at, updated_at
|
|
267
|
+
) VALUES (?, 'controller', ?, 1, ?, ?)`,
|
|
268
|
+
)
|
|
269
|
+
.run(resourceId, controllerResourceId, now, now);
|
|
270
|
+
this.state.connection
|
|
271
|
+
.prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
|
|
272
|
+
.run(resourceId);
|
|
273
|
+
this.state.connection
|
|
320
274
|
.prepare(
|
|
321
|
-
`INSERT INTO
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
275
|
+
`INSERT INTO controller_resources(
|
|
276
|
+
controller_resource_id, resource_id, project_id, controller_name,
|
|
277
|
+
resource_key, uid, generation, spec_hash, status_hash, created_at, updated_at
|
|
278
|
+
) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)`,
|
|
325
279
|
)
|
|
326
|
-
.run(
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
280
|
+
.run(
|
|
281
|
+
controllerResourceId,
|
|
282
|
+
resourceId,
|
|
283
|
+
this.requireProjectId(),
|
|
284
|
+
options.controller,
|
|
285
|
+
options.key,
|
|
286
|
+
uid,
|
|
287
|
+
specHash,
|
|
288
|
+
statusHash,
|
|
289
|
+
now,
|
|
290
|
+
now,
|
|
291
|
+
);
|
|
292
|
+
this.enqueueControllerRow(controllerResourceId, now, now);
|
|
293
|
+
this.insertEvent(
|
|
294
|
+
resourceId,
|
|
295
|
+
1,
|
|
296
|
+
"resource.created",
|
|
297
|
+
"control",
|
|
298
|
+
null,
|
|
299
|
+
{ generation: 1 },
|
|
300
|
+
now,
|
|
301
|
+
);
|
|
302
|
+
} else if (!existing.specHash.equals(specHash)) {
|
|
303
|
+
const revision = existing.resourceVersion + 1;
|
|
304
|
+
this.state.connection
|
|
330
305
|
.prepare(
|
|
331
|
-
`UPDATE
|
|
332
|
-
SET
|
|
333
|
-
|
|
334
|
-
WHERE controller = ? AND resource_key = ?`,
|
|
306
|
+
`UPDATE controller_resources
|
|
307
|
+
SET spec_hash = ?, generation = generation + 1, updated_at = ?
|
|
308
|
+
WHERE controller_resource_id = ?`,
|
|
335
309
|
)
|
|
336
|
-
.run(
|
|
337
|
-
this.
|
|
310
|
+
.run(specHash, now, existing.controllerResourceId);
|
|
311
|
+
this.bumpResource(existing.resourceId, existing.resourceVersion, now);
|
|
312
|
+
this.enqueueControllerRow(existing.controllerResourceId, now, now);
|
|
313
|
+
this.insertEvent(
|
|
314
|
+
existing.resourceId,
|
|
315
|
+
revision,
|
|
316
|
+
"resource.spec_updated",
|
|
317
|
+
"control",
|
|
318
|
+
null,
|
|
319
|
+
{ generation: existing.generation + 1 },
|
|
320
|
+
now,
|
|
321
|
+
);
|
|
338
322
|
}
|
|
339
|
-
return this.requireResource
|
|
323
|
+
return this.requireResource({
|
|
340
324
|
controller: options.controller,
|
|
341
325
|
key: options.key,
|
|
342
|
-
})
|
|
326
|
+
}) as ControllerResource<TSpec, TStatus>;
|
|
343
327
|
});
|
|
344
328
|
}
|
|
345
329
|
|
|
346
330
|
getResource<TSpec = unknown, TStatus = unknown>(
|
|
347
331
|
ref: ControllerResourceRef,
|
|
348
332
|
): ControllerResource<TSpec, TStatus> | undefined {
|
|
349
|
-
const row = this.
|
|
350
|
-
return row === undefined
|
|
333
|
+
const row = this.controllerRow(ref);
|
|
334
|
+
return row === undefined
|
|
335
|
+
? undefined
|
|
336
|
+
: (this.mapControllerResource(row) as ControllerResource<TSpec, TStatus>);
|
|
351
337
|
}
|
|
352
338
|
|
|
353
339
|
getResourceByUid(uid: string): ControllerResource | undefined {
|
|
354
|
-
const row = this.
|
|
355
|
-
|
|
356
|
-
| undefined;
|
|
357
|
-
return row === undefined ? undefined : resourceFromRow(row);
|
|
340
|
+
const row = this.state.connection.prepare(controllerSelect("WHERE c.uid = ?")).get(uid);
|
|
341
|
+
return isControllerRow(row) ? this.mapControllerResource(row) : undefined;
|
|
358
342
|
}
|
|
359
343
|
|
|
360
344
|
listResources<TSpec = unknown, TStatus = unknown>(
|
|
361
|
-
options: {
|
|
362
|
-
controller?: string;
|
|
363
|
-
} = {},
|
|
345
|
+
options: { controller?: string } = {},
|
|
364
346
|
): ControllerResource<TSpec, TStatus>[] {
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
347
|
+
const clauses = ["1 = 1"];
|
|
348
|
+
const params: unknown[] = [];
|
|
349
|
+
if (this.projectId !== null) {
|
|
350
|
+
clauses.push("c.project_id = ?");
|
|
351
|
+
params.push(this.projectId);
|
|
352
|
+
}
|
|
353
|
+
if (options.controller !== undefined) {
|
|
354
|
+
clauses.push("c.controller_name = ?");
|
|
355
|
+
params.push(options.controller);
|
|
356
|
+
}
|
|
357
|
+
const rows = this.state.connection
|
|
358
|
+
.prepare(
|
|
359
|
+
controllerSelect(
|
|
360
|
+
`WHERE ${clauses.join(" AND ")} ORDER BY c.controller_name, c.resource_key`,
|
|
361
|
+
),
|
|
362
|
+
)
|
|
363
|
+
.all(...params);
|
|
364
|
+
return rows
|
|
365
|
+
.filter(isControllerRow)
|
|
366
|
+
.map((row) => this.mapControllerResource(row) as ControllerResource<TSpec, TStatus>);
|
|
374
367
|
}
|
|
375
368
|
|
|
376
369
|
updateStatus<TStatus>(options: {
|
|
377
370
|
ref: ControllerResourceRef;
|
|
378
371
|
expectedResourceVersion: number;
|
|
372
|
+
claim: ControllerQueueClaim;
|
|
379
373
|
status: ControllerResourceStatus<TStatus>;
|
|
380
374
|
finalizers?: string[];
|
|
381
375
|
now?: string;
|
|
382
376
|
}): ControllerResource<unknown, TStatus> {
|
|
383
|
-
const now = validTimestamp(options.now);
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
}
|
|
389
|
-
return this.transaction(() => {
|
|
390
|
-
const current = this.resourceRow(options.ref);
|
|
391
|
-
if (current === undefined) {
|
|
392
|
-
throw new ResourceNotFoundError(options.ref.controller, options.ref.key);
|
|
393
|
-
}
|
|
394
|
-
if (current.resource_version !== options.expectedResourceVersion) {
|
|
377
|
+
const now = epoch(validTimestamp(options.now));
|
|
378
|
+
return this.state.transaction(() => {
|
|
379
|
+
const row = this.requireControllerRow(options.ref);
|
|
380
|
+
this.assertControllerClaim(row, options.claim, now);
|
|
381
|
+
if (row.resourceVersion !== options.expectedResourceVersion) {
|
|
395
382
|
throw new ResourceConflictError(options.ref.controller, options.ref.key);
|
|
396
383
|
}
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
? current.finalizers_json
|
|
400
|
-
: canonicalJson(options.finalizers);
|
|
401
|
-
if (current.status_json === statusJson && current.finalizers_json === finalizersJson) {
|
|
402
|
-
return resourceFromRow<unknown, TStatus>(current);
|
|
403
|
-
}
|
|
404
|
-
this.database
|
|
384
|
+
const statusHash = this.state.putJson(options.status, now);
|
|
385
|
+
this.state.connection
|
|
405
386
|
.prepare(
|
|
406
|
-
|
|
407
|
-
SET status_json = ?, finalizers_json = ?,
|
|
408
|
-
resource_version = resource_version + 1, updated_at = ?
|
|
409
|
-
WHERE controller = ? AND resource_key = ? AND resource_version = ?`,
|
|
387
|
+
"UPDATE controller_resources SET status_hash = ?, updated_at = ? WHERE controller_resource_id = ?",
|
|
410
388
|
)
|
|
411
|
-
.run(
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
389
|
+
.run(statusHash, now, row.controllerResourceId);
|
|
390
|
+
if (options.finalizers !== undefined)
|
|
391
|
+
this.replaceFinalizers(row.controllerResourceId, options.finalizers);
|
|
392
|
+
this.bumpResource(row.resourceId, row.resourceVersion, now);
|
|
393
|
+
this.insertEvent(
|
|
394
|
+
row.resourceId,
|
|
395
|
+
row.resourceVersion + 1,
|
|
396
|
+
"resource.status_updated",
|
|
397
|
+
"controller",
|
|
398
|
+
options.claim.ownerId,
|
|
399
|
+
{},
|
|
400
|
+
now,
|
|
401
|
+
options.claim.generation,
|
|
402
|
+
);
|
|
403
|
+
options.claim.resourceVersion = row.resourceVersion + 1;
|
|
404
|
+
return this.requireResource(options.ref) as ControllerResource<unknown, TStatus>;
|
|
420
405
|
});
|
|
421
406
|
}
|
|
422
407
|
|
|
423
|
-
requestDeletion(ref: ControllerResourceRef,
|
|
424
|
-
const
|
|
425
|
-
return this.transaction(() => {
|
|
426
|
-
const row = this.
|
|
427
|
-
if (row ===
|
|
428
|
-
|
|
429
|
-
}
|
|
430
|
-
if (row.deletion_timestamp === null) {
|
|
431
|
-
this.database
|
|
408
|
+
requestDeletion(ref: ControllerResourceRef, nowValue?: string): ControllerResource {
|
|
409
|
+
const now = epoch(validTimestamp(nowValue));
|
|
410
|
+
return this.state.transaction(() => {
|
|
411
|
+
const row = this.requireControllerRow(ref);
|
|
412
|
+
if (row.deletionRequestedAt === null) {
|
|
413
|
+
this.state.connection
|
|
432
414
|
.prepare(
|
|
433
|
-
|
|
434
|
-
SET deletion_timestamp = ?, resource_version = resource_version + 1, updated_at = ?
|
|
435
|
-
WHERE controller = ? AND resource_key = ?`,
|
|
415
|
+
"UPDATE controller_resources SET deletion_requested_at = ?, updated_at = ? WHERE controller_resource_id = ?",
|
|
436
416
|
)
|
|
437
|
-
.run(
|
|
417
|
+
.run(now, now, row.controllerResourceId);
|
|
418
|
+
this.bumpResource(row.resourceId, row.resourceVersion, now);
|
|
419
|
+
this.enqueueControllerRow(row.controllerResourceId, now, now);
|
|
420
|
+
this.insertEvent(
|
|
421
|
+
row.resourceId,
|
|
422
|
+
row.resourceVersion + 1,
|
|
423
|
+
"resource.deletion_requested",
|
|
424
|
+
"control",
|
|
425
|
+
null,
|
|
426
|
+
{},
|
|
427
|
+
now,
|
|
428
|
+
);
|
|
438
429
|
}
|
|
439
|
-
this.enqueueRow(ref, epoch(timestamp));
|
|
440
430
|
return this.requireResource(ref);
|
|
441
431
|
});
|
|
442
432
|
}
|
|
@@ -447,223 +437,265 @@ export class SqliteControllerStore implements ControllerStore {
|
|
|
447
437
|
finalizers: string[];
|
|
448
438
|
now?: string;
|
|
449
439
|
}): ControllerResource {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
.
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
440
|
+
const now = epoch(validTimestamp(options.now));
|
|
441
|
+
return this.state.transaction(() => {
|
|
442
|
+
const row = this.requireControllerRow(options.ref);
|
|
443
|
+
if (row.resourceVersion !== options.expectedResourceVersion) {
|
|
444
|
+
throw new ResourceConflictError(options.ref.controller, options.ref.key);
|
|
445
|
+
}
|
|
446
|
+
this.replaceFinalizers(row.controllerResourceId, options.finalizers);
|
|
447
|
+
this.state.connection
|
|
448
|
+
.prepare("UPDATE controller_resources SET updated_at = ? WHERE controller_resource_id = ?")
|
|
449
|
+
.run(now, row.controllerResourceId);
|
|
450
|
+
this.bumpResource(row.resourceId, row.resourceVersion, now);
|
|
451
|
+
this.insertEvent(
|
|
452
|
+
row.resourceId,
|
|
453
|
+
row.resourceVersion + 1,
|
|
454
|
+
"resource.finalizers_updated",
|
|
455
|
+
"control",
|
|
456
|
+
null,
|
|
457
|
+
{},
|
|
460
458
|
now,
|
|
461
|
-
options.ref.controller,
|
|
462
|
-
options.ref.key,
|
|
463
|
-
options.expectedResourceVersion,
|
|
464
459
|
);
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
}
|
|
468
|
-
return this.requireResource(options.ref);
|
|
460
|
+
return this.requireResource(options.ref);
|
|
461
|
+
});
|
|
469
462
|
}
|
|
470
463
|
|
|
471
|
-
deleteResource(
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
}
|
|
489
|
-
return true;
|
|
464
|
+
deleteResource(
|
|
465
|
+
ref: ControllerResourceRef,
|
|
466
|
+
expectedResourceVersion: number,
|
|
467
|
+
claim: ControllerQueueClaim,
|
|
468
|
+
): boolean {
|
|
469
|
+
return this.state.transaction(() => {
|
|
470
|
+
const row = this.requireControllerRow(ref);
|
|
471
|
+
this.assertControllerClaim(row, claim, Date.now());
|
|
472
|
+
if (row.resourceVersion !== expectedResourceVersion) {
|
|
473
|
+
throw new ResourceConflictError(ref.controller, ref.key);
|
|
474
|
+
}
|
|
475
|
+
if (row.deletionRequestedAt === null || this.finalizers(row.controllerResourceId).length > 0)
|
|
476
|
+
return false;
|
|
477
|
+
this.state.connection
|
|
478
|
+
.prepare("DELETE FROM controller_resources WHERE controller_resource_id = ?")
|
|
479
|
+
.run(row.controllerResourceId);
|
|
480
|
+
return true;
|
|
481
|
+
});
|
|
490
482
|
}
|
|
491
483
|
|
|
492
484
|
enqueue(ref: ControllerResourceRef, availableAt?: string): void {
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}
|
|
497
|
-
this.enqueueRow(ref, epoch(at));
|
|
485
|
+
const row = this.requireControllerRow(ref);
|
|
486
|
+
const now = Date.now();
|
|
487
|
+
this.enqueueControllerRow(row.controllerResourceId, epoch(validTimestamp(availableAt)), now);
|
|
498
488
|
}
|
|
499
489
|
|
|
500
490
|
claimNext(options: {
|
|
501
491
|
controllers: string[];
|
|
492
|
+
ownerId: string;
|
|
502
493
|
leaseMs: number;
|
|
503
494
|
now?: string;
|
|
504
495
|
}): ControllerQueueClaim | undefined {
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
return undefined;
|
|
508
|
-
}
|
|
509
|
-
const now = validTimestamp(options.now);
|
|
510
|
-
const nowMs = epoch(now);
|
|
496
|
+
if (options.controllers.length === 0) return undefined;
|
|
497
|
+
const now = epoch(validTimestamp(options.now));
|
|
511
498
|
const placeholders = options.controllers.map(() => "?").join(", ");
|
|
512
|
-
return this.transaction(() => {
|
|
513
|
-
const row = this.
|
|
499
|
+
return this.state.transaction(() => {
|
|
500
|
+
const row = this.state.connection
|
|
514
501
|
.prepare(
|
|
515
|
-
`
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
502
|
+
`${controllerSelect(`
|
|
503
|
+
JOIN controller_queue q ON q.controller_resource_id = c.controller_resource_id
|
|
504
|
+
JOIN leases l ON l.resource_id = c.resource_id
|
|
505
|
+
WHERE c.project_id = ?
|
|
506
|
+
AND c.controller_name IN (${placeholders})
|
|
507
|
+
AND q.available_at <= ?
|
|
508
|
+
AND (l.owner_id IS NULL OR l.expires_at <= ?)
|
|
509
|
+
ORDER BY q.available_at, c.controller_name, c.resource_key
|
|
510
|
+
LIMIT 1`)}`,
|
|
521
511
|
)
|
|
522
|
-
.get(...options.controllers,
|
|
523
|
-
if (row
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
const
|
|
527
|
-
const
|
|
528
|
-
const result = this.
|
|
512
|
+
.get(this.requireProjectId(), ...options.controllers, now, now);
|
|
513
|
+
if (!isControllerRow(row)) return undefined;
|
|
514
|
+
const lease = this.requireLease(row.resourceId);
|
|
515
|
+
const token = randomBytes(32).toString("base64url");
|
|
516
|
+
const generation = lease.generation + 1;
|
|
517
|
+
const expiresAt = now + options.leaseMs;
|
|
518
|
+
const result = this.state.connection
|
|
529
519
|
.prepare(
|
|
530
|
-
`UPDATE
|
|
531
|
-
SET
|
|
532
|
-
|
|
533
|
-
|
|
520
|
+
`UPDATE leases
|
|
521
|
+
SET generation = ?, owner_type = 'controller', owner_id = ?, token_hash = ?,
|
|
522
|
+
acquired_at = ?, heartbeat_at = ?, expires_at = ?
|
|
523
|
+
WHERE resource_id = ? AND generation = ?
|
|
524
|
+
AND (owner_id IS NULL OR expires_at <= ?)`,
|
|
534
525
|
)
|
|
535
|
-
.run(
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
526
|
+
.run(
|
|
527
|
+
generation,
|
|
528
|
+
options.ownerId,
|
|
529
|
+
tokenHash(token),
|
|
530
|
+
now,
|
|
531
|
+
now,
|
|
532
|
+
expiresAt,
|
|
533
|
+
row.resourceId,
|
|
534
|
+
lease.generation,
|
|
535
|
+
now,
|
|
536
|
+
);
|
|
537
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
538
|
+
if (result.changes !== 1) return undefined;
|
|
539
|
+
this.bumpResource(row.resourceId, row.resourceVersion, now);
|
|
540
|
+
this.insertEvent(
|
|
541
|
+
row.resourceId,
|
|
542
|
+
row.resourceVersion + 1,
|
|
543
|
+
"lease.claimed",
|
|
544
|
+
"controller",
|
|
545
|
+
options.ownerId,
|
|
546
|
+
{ expiresAt },
|
|
547
|
+
now,
|
|
548
|
+
generation,
|
|
549
|
+
);
|
|
539
550
|
return {
|
|
540
|
-
controller: row.
|
|
541
|
-
key: row.
|
|
551
|
+
controller: row.controllerName,
|
|
552
|
+
key: row.resourceKey,
|
|
553
|
+
ownerId: options.ownerId,
|
|
542
554
|
token,
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
555
|
+
generation,
|
|
556
|
+
queueVersion: this.queueVersion(row.controllerResourceId),
|
|
557
|
+
resourceVersion: row.resourceVersion + 1,
|
|
558
|
+
consecutiveErrors: this.queueErrors(row.controllerResourceId),
|
|
559
|
+
expiresAt: new Date(expiresAt).toISOString(),
|
|
546
560
|
};
|
|
547
561
|
});
|
|
548
562
|
}
|
|
549
563
|
|
|
550
|
-
renewClaim(claim: ControllerQueueClaim, leaseMs: number,
|
|
551
|
-
|
|
552
|
-
const
|
|
553
|
-
const
|
|
554
|
-
|
|
564
|
+
renewClaim(claim: ControllerQueueClaim, leaseMs: number, nowValue?: string): boolean {
|
|
565
|
+
const now = epoch(validTimestamp(nowValue));
|
|
566
|
+
const expiresAt = now + leaseMs;
|
|
567
|
+
const row = this.controllerRow({ controller: claim.controller, key: claim.key });
|
|
568
|
+
if (row === undefined) return false;
|
|
569
|
+
const result = this.state.connection
|
|
555
570
|
.prepare(
|
|
556
|
-
`UPDATE
|
|
557
|
-
WHERE
|
|
558
|
-
AND
|
|
571
|
+
`UPDATE leases SET heartbeat_at = ?, expires_at = ?
|
|
572
|
+
WHERE resource_id = ? AND owner_type = 'controller' AND owner_id = ?
|
|
573
|
+
AND token_hash = ? AND generation = ? AND expires_at > ?`,
|
|
559
574
|
)
|
|
560
|
-
.run(
|
|
575
|
+
.run(
|
|
576
|
+
now,
|
|
577
|
+
expiresAt,
|
|
578
|
+
row.resourceId,
|
|
579
|
+
claim.ownerId,
|
|
580
|
+
tokenHash(claim.token),
|
|
581
|
+
claim.generation,
|
|
582
|
+
now,
|
|
583
|
+
);
|
|
584
|
+
if (result.changes === 1) claim.expiresAt = new Date(expiresAt).toISOString();
|
|
561
585
|
return result.changes === 1;
|
|
562
586
|
}
|
|
563
587
|
|
|
564
|
-
settleClaim(claim: ControllerQueueClaim,
|
|
565
|
-
|
|
566
|
-
return this.transaction(() => {
|
|
567
|
-
const row = this.claimedRow(claim);
|
|
568
|
-
if (row === undefined) {
|
|
569
|
-
return false;
|
|
570
|
-
}
|
|
571
|
-
if (row.version === row.claim_version) {
|
|
572
|
-
this.database
|
|
573
|
-
.prepare(
|
|
574
|
-
"DELETE FROM queue WHERE controller = ? AND resource_key = ? AND claim_token = ?",
|
|
575
|
-
)
|
|
576
|
-
.run(claim.controller, claim.key, claim.token);
|
|
577
|
-
} else {
|
|
578
|
-
this.database
|
|
579
|
-
.prepare(
|
|
580
|
-
`UPDATE queue
|
|
581
|
-
SET claim_token = NULL, claim_version = NULL, claim_expires_at = NULL,
|
|
582
|
-
available_at = MIN(available_at, ?), consecutive_errors = 0, last_error = NULL
|
|
583
|
-
WHERE controller = ? AND resource_key = ? AND claim_token = ?`,
|
|
584
|
-
)
|
|
585
|
-
.run(nowMs, claim.controller, claim.key, claim.token);
|
|
586
|
-
}
|
|
587
|
-
return true;
|
|
588
|
-
});
|
|
588
|
+
settleClaim(claim: ControllerQueueClaim, nowValue?: string): boolean {
|
|
589
|
+
return this.settleControllerClaim(claim, undefined, nowValue);
|
|
589
590
|
}
|
|
590
591
|
|
|
591
|
-
requeueClaim(
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
return false;
|
|
598
|
-
}
|
|
599
|
-
const eventArrived = row.version !== row.claim_version;
|
|
600
|
-
const nextAvailableAt = eventArrived ? Math.min(row.available_at, availableAt) : availableAt;
|
|
601
|
-
const nextErrors = options.error === undefined ? 0 : row.consecutive_errors + 1;
|
|
602
|
-
this.database
|
|
603
|
-
.prepare(
|
|
604
|
-
`UPDATE queue
|
|
605
|
-
SET claim_token = NULL, claim_version = NULL, claim_expires_at = NULL,
|
|
606
|
-
available_at = ?, consecutive_errors = ?, last_error = ?
|
|
607
|
-
WHERE controller = ? AND resource_key = ? AND claim_token = ?`,
|
|
608
|
-
)
|
|
609
|
-
.run(nextAvailableAt, nextErrors, error, claim.controller, claim.key, claim.token);
|
|
610
|
-
return true;
|
|
611
|
-
});
|
|
592
|
+
requeueClaim(
|
|
593
|
+
claim: ControllerQueueClaim,
|
|
594
|
+
options: QueueRequeueOptions,
|
|
595
|
+
nowValue?: string,
|
|
596
|
+
): boolean {
|
|
597
|
+
return this.settleControllerClaim(claim, options, nowValue);
|
|
612
598
|
}
|
|
613
599
|
|
|
614
600
|
listQueue(): QueueItem[] {
|
|
615
|
-
const rows = this.
|
|
616
|
-
.prepare(
|
|
617
|
-
|
|
618
|
-
|
|
601
|
+
const rows = this.state.connection
|
|
602
|
+
.prepare(
|
|
603
|
+
`SELECT c.controller_name AS controller, c.resource_key AS resourceKey,
|
|
604
|
+
q.available_at AS availableAt, q.consecutive_errors AS consecutiveErrors,
|
|
605
|
+
l.expires_at AS claimExpiresAt
|
|
606
|
+
FROM controller_queue q
|
|
607
|
+
JOIN controller_resources c ON c.controller_resource_id = q.controller_resource_id
|
|
608
|
+
JOIN leases l ON l.resource_id = c.resource_id
|
|
609
|
+
WHERE c.project_id = ? ORDER BY q.available_at, c.controller_name, c.resource_key`,
|
|
610
|
+
)
|
|
611
|
+
.all(this.requireProjectId());
|
|
612
|
+
return rows.filter(isQueueListRow).map((row) => ({
|
|
619
613
|
controller: row.controller,
|
|
620
|
-
key: row.
|
|
621
|
-
availableAt:
|
|
622
|
-
consecutiveErrors: row.
|
|
623
|
-
...(row.
|
|
614
|
+
key: row.resourceKey,
|
|
615
|
+
availableAt: new Date(row.availableAt).toISOString(),
|
|
616
|
+
consecutiveErrors: row.consecutiveErrors,
|
|
617
|
+
...(row.claimExpiresAt === null
|
|
618
|
+
? {}
|
|
619
|
+
: { claimExpiresAt: new Date(row.claimExpiresAt).toISOString() }),
|
|
624
620
|
}));
|
|
625
621
|
}
|
|
626
622
|
|
|
627
623
|
reserveEffect(options: {
|
|
628
624
|
key: string;
|
|
629
625
|
resourceUid: string;
|
|
626
|
+
claim: ControllerQueueClaim;
|
|
630
627
|
generation: number;
|
|
631
628
|
kind: string;
|
|
632
629
|
requestFingerprint: string;
|
|
633
630
|
now?: string;
|
|
634
631
|
}): EffectReservation {
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
632
|
+
const now = epoch(validTimestamp(options.now));
|
|
633
|
+
return this.state.transaction(() => {
|
|
634
|
+
const source = this.requireControllerRowByUid(options.resourceUid);
|
|
635
|
+
this.assertControllerClaim(source, options.claim, now);
|
|
636
|
+
const effectId = effectIdFor(source.resourceId, options.key);
|
|
639
637
|
const existing = this.effectRow(options.resourceUid, options.key);
|
|
640
638
|
if (existing !== undefined) {
|
|
641
639
|
if (
|
|
642
|
-
existing.request_fingerprint !== options.requestFingerprint ||
|
|
643
640
|
existing.generation !== options.generation ||
|
|
644
|
-
existing.kind !== options.kind
|
|
641
|
+
existing.kind !== options.kind ||
|
|
642
|
+
existing.requestFingerprint !== options.requestFingerprint
|
|
645
643
|
) {
|
|
646
644
|
throw new EffectRequestConflictError(options.key);
|
|
647
645
|
}
|
|
648
|
-
return { record:
|
|
646
|
+
return { record: this.mapEffect(existing), created: false };
|
|
649
647
|
}
|
|
650
|
-
|
|
648
|
+
const effectResourceId = resourceIdFor("effect", effectId);
|
|
649
|
+
const payloadHash = this.state.putJson(
|
|
650
|
+
{
|
|
651
|
+
key: options.key,
|
|
652
|
+
resourceUid: options.resourceUid,
|
|
653
|
+
generation: options.generation,
|
|
654
|
+
kind: options.kind,
|
|
655
|
+
requestFingerprint: options.requestFingerprint,
|
|
656
|
+
},
|
|
657
|
+
now,
|
|
658
|
+
);
|
|
659
|
+
this.state.connection
|
|
660
|
+
.prepare(
|
|
661
|
+
`INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at)
|
|
662
|
+
VALUES (?, 'effect', ?, 1, ?, ?)`,
|
|
663
|
+
)
|
|
664
|
+
.run(effectResourceId, effectId, now, now);
|
|
665
|
+
this.state.connection
|
|
666
|
+
.prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
|
|
667
|
+
.run(effectResourceId);
|
|
668
|
+
this.state.connection
|
|
651
669
|
.prepare(
|
|
652
|
-
`INSERT INTO effects
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
670
|
+
`INSERT INTO effects(
|
|
671
|
+
effect_id, resource_id, source_resource_id, source_revision, effect_type,
|
|
672
|
+
idempotency_key, payload_hash, owner_scope, status, attempt_count,
|
|
673
|
+
created_at, updated_at
|
|
674
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 'controller', 'pending', 0, ?, ?)`,
|
|
656
675
|
)
|
|
657
676
|
.run(
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
677
|
+
effectId,
|
|
678
|
+
effectResourceId,
|
|
679
|
+
source.resourceId,
|
|
680
|
+
source.resourceVersion,
|
|
661
681
|
options.kind,
|
|
662
|
-
options.
|
|
682
|
+
options.key,
|
|
683
|
+
payloadHash,
|
|
684
|
+
now,
|
|
663
685
|
now,
|
|
664
686
|
);
|
|
687
|
+
this.insertEvent(
|
|
688
|
+
effectResourceId,
|
|
689
|
+
1,
|
|
690
|
+
"effect.reserved",
|
|
691
|
+
"controller",
|
|
692
|
+
options.claim.ownerId,
|
|
693
|
+
{},
|
|
694
|
+
now,
|
|
695
|
+
options.claim.generation,
|
|
696
|
+
);
|
|
665
697
|
return {
|
|
666
|
-
record: this.
|
|
698
|
+
record: this.mapEffect(this.requireEffectRow(options.resourceUid, options.key)),
|
|
667
699
|
created: true,
|
|
668
700
|
};
|
|
669
701
|
});
|
|
@@ -671,567 +703,528 @@ export class SqliteControllerStore implements ControllerStore {
|
|
|
671
703
|
|
|
672
704
|
getEffect(resourceUid: string, key: string): EffectRecord | undefined {
|
|
673
705
|
const row = this.effectRow(resourceUid, key);
|
|
674
|
-
return row === undefined ? undefined :
|
|
706
|
+
return row === undefined ? undefined : this.mapEffect(row);
|
|
675
707
|
}
|
|
676
708
|
|
|
677
709
|
updateEffect(options: {
|
|
678
710
|
resourceUid: string;
|
|
679
711
|
key: string;
|
|
712
|
+
claim: ControllerQueueClaim;
|
|
680
713
|
state: EffectRecord["state"];
|
|
681
714
|
externalRef?: string;
|
|
682
715
|
error?: string;
|
|
683
716
|
now?: string;
|
|
684
717
|
}): EffectRecord {
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
718
|
+
const now = epoch(validTimestamp(options.now));
|
|
719
|
+
return this.state.transaction(() => {
|
|
720
|
+
const source = this.requireControllerRowByUid(options.resourceUid);
|
|
721
|
+
this.assertControllerClaim(source, options.claim, now);
|
|
722
|
+
const row = this.requireEffectRow(options.resourceUid, options.key);
|
|
723
|
+
const effectResourceId = resourceIdFor("effect", row.effectId);
|
|
724
|
+
const revision = this.resourceRevision(effectResourceId);
|
|
725
|
+
const errorHash = options.error === undefined ? null : this.state.putText(options.error, now);
|
|
726
|
+
this.state.connection
|
|
727
|
+
.prepare(
|
|
728
|
+
`UPDATE effects
|
|
729
|
+
SET status = ?, external_ref = ?, error_hash = ?, updated_at = ?, settled_at = ?
|
|
730
|
+
WHERE effect_id = ?`,
|
|
731
|
+
)
|
|
732
|
+
.run(
|
|
733
|
+
effectStatus(options.state),
|
|
734
|
+
options.externalRef ?? null,
|
|
735
|
+
errorHash,
|
|
736
|
+
now,
|
|
737
|
+
options.state === "pending" ? null : now,
|
|
738
|
+
row.effectId,
|
|
739
|
+
);
|
|
740
|
+
this.bumpResource(effectResourceId, revision, now);
|
|
741
|
+
this.insertEvent(
|
|
742
|
+
effectResourceId,
|
|
743
|
+
revision + 1,
|
|
744
|
+
`effect.${options.state}`,
|
|
745
|
+
"controller",
|
|
746
|
+
options.claim.ownerId,
|
|
747
|
+
{},
|
|
748
|
+
now,
|
|
749
|
+
options.claim.generation,
|
|
709
750
|
);
|
|
710
|
-
|
|
751
|
+
return this.mapEffect(this.requireEffectRow(options.resourceUid, options.key));
|
|
752
|
+
});
|
|
711
753
|
}
|
|
712
754
|
|
|
713
755
|
listEffects(resourceUid: string): EffectRecord[] {
|
|
714
|
-
const rows = this.
|
|
715
|
-
.prepare("
|
|
716
|
-
.all(resourceUid)
|
|
717
|
-
return rows.map(
|
|
756
|
+
const rows = this.state.connection
|
|
757
|
+
.prepare(effectSelect("WHERE c.uid = ? ORDER BY e.created_at, e.effect_id"))
|
|
758
|
+
.all(resourceUid);
|
|
759
|
+
return rows.filter(isEffectRow).map((row) => this.mapEffect(row));
|
|
718
760
|
}
|
|
719
761
|
|
|
720
762
|
reserveWorkflow(options: {
|
|
721
763
|
resourceUid: string;
|
|
764
|
+
claim: ControllerQueueClaim;
|
|
722
765
|
requestKey: string;
|
|
723
766
|
workflow: string;
|
|
724
767
|
inputFingerprint: string;
|
|
725
768
|
}): WorkflowReservation {
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
769
|
+
const now = Date.now();
|
|
770
|
+
return this.state.transaction(() => {
|
|
771
|
+
const source = this.requireControllerRowByUid(options.resourceUid);
|
|
772
|
+
this.assertControllerClaim(source, options.claim, now);
|
|
729
773
|
const existing = this.workflowRow(options.resourceUid, options.requestKey);
|
|
730
774
|
if (existing !== undefined) {
|
|
731
775
|
if (
|
|
732
|
-
existing.
|
|
733
|
-
existing.
|
|
776
|
+
existing.workflowName !== options.workflow ||
|
|
777
|
+
existing.inputFingerprint.toString("hex") !== options.inputFingerprint
|
|
734
778
|
) {
|
|
735
779
|
throw new WorkflowRequestConflictError(options.requestKey);
|
|
736
780
|
}
|
|
737
|
-
return { record:
|
|
781
|
+
return { record: this.mapWorkflow(existing), created: false };
|
|
738
782
|
}
|
|
739
783
|
const requestId = randomUUID();
|
|
740
|
-
this.
|
|
784
|
+
this.state.connection
|
|
741
785
|
.prepare(
|
|
742
|
-
`INSERT INTO
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
786
|
+
`INSERT INTO controller_workflows(
|
|
787
|
+
request_id, controller_resource_id, request_key, workflow_name,
|
|
788
|
+
input_fingerprint, status, attempt_count, created_at, updated_at
|
|
789
|
+
) VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?)`,
|
|
746
790
|
)
|
|
747
791
|
.run(
|
|
748
792
|
requestId,
|
|
749
|
-
|
|
793
|
+
source.controllerResourceId,
|
|
750
794
|
options.requestKey,
|
|
751
|
-
options.inputFingerprint,
|
|
752
795
|
options.workflow,
|
|
796
|
+
Buffer.from(options.inputFingerprint, "hex"),
|
|
797
|
+
now,
|
|
798
|
+
now,
|
|
753
799
|
);
|
|
754
|
-
return {
|
|
755
|
-
record: this.requireWorkflow(requestId),
|
|
756
|
-
created: true,
|
|
757
|
-
};
|
|
800
|
+
return { record: this.mapWorkflow(this.requireWorkflowRow(requestId)), created: true };
|
|
758
801
|
});
|
|
759
802
|
}
|
|
760
803
|
|
|
761
804
|
getWorkflow(resourceUid: string, requestKey: string): ChildWorkflowRecord | undefined {
|
|
762
805
|
const row = this.workflowRow(resourceUid, requestKey);
|
|
763
|
-
return row === undefined ? undefined :
|
|
806
|
+
return row === undefined ? undefined : this.mapWorkflow(row);
|
|
764
807
|
}
|
|
765
808
|
|
|
766
809
|
getWorkflowByRequestId(requestId: string): ChildWorkflowRecord | undefined {
|
|
767
|
-
const row = this.
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
810
|
+
const row = this.workflowRowById(requestId);
|
|
811
|
+
return row === undefined ? undefined : this.mapWorkflow(row);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
updateWorkflow(
|
|
815
|
+
requestId: string,
|
|
816
|
+
update: WorkflowRecordUpdate,
|
|
817
|
+
claim: ControllerQueueClaim,
|
|
818
|
+
): ChildWorkflowRecord {
|
|
819
|
+
const now = Date.now();
|
|
820
|
+
return this.state.transaction(() => {
|
|
821
|
+
const row = this.requireWorkflowRow(requestId);
|
|
822
|
+
const source = this.requireControllerRowByUid(row.resourceUid);
|
|
823
|
+
this.assertControllerClaim(source, claim, now);
|
|
824
|
+
const errorHash =
|
|
825
|
+
update.error === undefined || update.error === null
|
|
826
|
+
? null
|
|
827
|
+
: this.state.putText(update.error, now);
|
|
828
|
+
this.state.connection
|
|
829
|
+
.prepare(
|
|
830
|
+
`UPDATE controller_workflows
|
|
831
|
+
SET reserved_run_id = COALESCE(?, reserved_run_id),
|
|
832
|
+
run_id = CASE
|
|
833
|
+
WHEN ? IS NOT NULL AND EXISTS(SELECT 1 FROM runs WHERE run_id = ?) THEN ?
|
|
834
|
+
ELSE run_id
|
|
835
|
+
END,
|
|
836
|
+
status = ?, attempt_count = COALESCE(?, attempt_count),
|
|
837
|
+
error_hash = ?, updated_at = ?
|
|
838
|
+
WHERE request_id = ?`,
|
|
839
|
+
)
|
|
840
|
+
.run(
|
|
841
|
+
update.runId ?? null,
|
|
842
|
+
update.runId ?? null,
|
|
843
|
+
update.runId ?? null,
|
|
844
|
+
update.runId ?? null,
|
|
845
|
+
update.state,
|
|
846
|
+
update.attempt ?? null,
|
|
847
|
+
errorHash,
|
|
848
|
+
now,
|
|
849
|
+
requestId,
|
|
850
|
+
);
|
|
851
|
+
return this.mapWorkflow(this.requireWorkflowRow(requestId));
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
completeWorkflow(requestId: string, update: WorkflowRecordUpdate): ChildWorkflowRecord {
|
|
856
|
+
const row = this.requireWorkflowRow(requestId);
|
|
857
|
+
if (update.state === "pending" || update.state === "running") {
|
|
858
|
+
throw new Error("Scheduler completion must report a settled child state");
|
|
859
|
+
}
|
|
860
|
+
if (update.runId !== undefined && row.runId !== null && update.runId !== row.runId) {
|
|
861
|
+
throw new Error("Scheduler completion run ID does not match the reserved child run");
|
|
779
862
|
}
|
|
780
|
-
const
|
|
781
|
-
const
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
: update.error === null
|
|
863
|
+
const source = this.requireControllerRowByUid(row.resourceUid);
|
|
864
|
+
const now = Date.now();
|
|
865
|
+
return this.state.transaction(() => {
|
|
866
|
+
const errorHash =
|
|
867
|
+
update.error === undefined || update.error === null
|
|
786
868
|
? null
|
|
787
|
-
:
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
869
|
+
: this.state.putText(update.error, now);
|
|
870
|
+
this.state.connection
|
|
871
|
+
.prepare(
|
|
872
|
+
`UPDATE controller_workflows
|
|
873
|
+
SET run_id = CASE
|
|
874
|
+
WHEN ? IS NOT NULL AND EXISTS(SELECT 1 FROM runs WHERE run_id = ?) THEN ?
|
|
875
|
+
ELSE run_id
|
|
876
|
+
END,
|
|
877
|
+
status = ?, attempt_count = COALESCE(?, attempt_count),
|
|
878
|
+
error_hash = ?, updated_at = ?
|
|
879
|
+
WHERE request_id = ?`,
|
|
880
|
+
)
|
|
881
|
+
.run(
|
|
882
|
+
update.runId ?? null,
|
|
883
|
+
update.runId ?? null,
|
|
884
|
+
update.runId ?? null,
|
|
885
|
+
update.state,
|
|
886
|
+
update.attempt ?? null,
|
|
887
|
+
errorHash,
|
|
888
|
+
now,
|
|
889
|
+
requestId,
|
|
890
|
+
);
|
|
891
|
+
const revision = this.resourceRevision(source.resourceId);
|
|
892
|
+
this.bumpResource(source.resourceId, revision, now);
|
|
893
|
+
this.insertEvent(
|
|
894
|
+
source.resourceId,
|
|
895
|
+
revision + 1,
|
|
896
|
+
"workflow_state_changed",
|
|
897
|
+
"system",
|
|
898
|
+
null,
|
|
899
|
+
{ requestId, state: update.state, runId: update.runId ?? row.runId },
|
|
900
|
+
now,
|
|
901
|
+
);
|
|
902
|
+
return this.mapWorkflow(this.requireWorkflowRow(requestId));
|
|
903
|
+
});
|
|
796
904
|
}
|
|
797
905
|
|
|
798
906
|
listWorkflows(resourceUid: string): ChildWorkflowRecord[] {
|
|
799
|
-
const rows = this.
|
|
800
|
-
.prepare("
|
|
801
|
-
.all(resourceUid)
|
|
802
|
-
return rows.map(
|
|
907
|
+
const rows = this.state.connection
|
|
908
|
+
.prepare(workflowSelect("WHERE c.uid = ? ORDER BY w.created_at, w.request_id"))
|
|
909
|
+
.all(resourceUid);
|
|
910
|
+
return rows.filter(isWorkflowRow).map((row) => this.mapWorkflow(row));
|
|
803
911
|
}
|
|
804
912
|
|
|
805
913
|
recordEvent(options: {
|
|
806
914
|
controller: string;
|
|
807
915
|
key: string;
|
|
916
|
+
claim?: ControllerQueueClaim;
|
|
808
917
|
type: string;
|
|
809
918
|
payload?: JsonObject;
|
|
810
919
|
now?: string;
|
|
811
920
|
}): ControllerEvent {
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
921
|
+
const now = epoch(validTimestamp(options.now));
|
|
922
|
+
return this.state.transaction(() => {
|
|
923
|
+
const row = this.requireControllerRow({ controller: options.controller, key: options.key });
|
|
924
|
+
if (options.claim !== undefined) this.assertControllerClaim(row, options.claim, now);
|
|
925
|
+
const revision = this.resourceRevision(row.resourceId) + 1;
|
|
926
|
+
this.bumpResource(row.resourceId, revision - 1, now);
|
|
927
|
+
const eventId = this.insertEvent(
|
|
928
|
+
row.resourceId,
|
|
929
|
+
revision,
|
|
930
|
+
options.type,
|
|
931
|
+
options.claim === undefined ? "control" : "controller",
|
|
932
|
+
options.claim?.ownerId ?? null,
|
|
933
|
+
options.payload ?? {},
|
|
934
|
+
now,
|
|
935
|
+
options.claim?.generation,
|
|
936
|
+
);
|
|
937
|
+
if (options.claim !== undefined) options.claim.resourceVersion = revision;
|
|
938
|
+
const event = this.state.connection
|
|
939
|
+
.prepare("SELECT event_seq AS seq FROM events WHERE event_id = ?")
|
|
940
|
+
.get(eventId);
|
|
941
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
942
|
+
if (!isSequenceRow(event)) throw new Error("Controller event was not recorded");
|
|
943
|
+
return {
|
|
944
|
+
seq: event.seq,
|
|
945
|
+
recordedAt: new Date(now).toISOString(),
|
|
946
|
+
controller: options.controller,
|
|
947
|
+
key: options.key,
|
|
948
|
+
type: options.type,
|
|
949
|
+
payload: options.payload ?? {},
|
|
950
|
+
};
|
|
951
|
+
});
|
|
827
952
|
}
|
|
828
953
|
|
|
829
954
|
listEvents(
|
|
830
|
-
options: {
|
|
831
|
-
controller?: string;
|
|
832
|
-
key?: string;
|
|
833
|
-
limit?: number;
|
|
834
|
-
} = {},
|
|
955
|
+
options: { controller?: string; key?: string; limit?: number } = {},
|
|
835
956
|
): ControllerEvent[] {
|
|
836
|
-
const
|
|
837
|
-
|
|
838
|
-
|
|
957
|
+
const clauses = ["r.resource_type = 'controller'"];
|
|
958
|
+
const params: unknown[] = [];
|
|
959
|
+
if (this.projectId !== null) {
|
|
960
|
+
clauses.push("c.project_id = ?");
|
|
961
|
+
params.push(this.projectId);
|
|
839
962
|
}
|
|
840
|
-
const clauses: string[] = [];
|
|
841
|
-
const values: unknown[] = [];
|
|
842
963
|
if (options.controller !== undefined) {
|
|
843
|
-
clauses.push("
|
|
844
|
-
|
|
964
|
+
clauses.push("c.controller_name = ?");
|
|
965
|
+
params.push(options.controller);
|
|
845
966
|
}
|
|
846
967
|
if (options.key !== undefined) {
|
|
847
|
-
clauses.push("resource_key = ?");
|
|
848
|
-
|
|
849
|
-
}
|
|
850
|
-
const where = clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`;
|
|
851
|
-
const rows = this.database
|
|
852
|
-
.prepare(`SELECT * FROM events ${where} ORDER BY seq DESC LIMIT ?`)
|
|
853
|
-
.all(...values, limit) as EventRow[];
|
|
854
|
-
return rows.map(eventFromRow);
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
private configure(readOnly: boolean): void {
|
|
858
|
-
if (!readOnly) {
|
|
859
|
-
this.database.pragma("journal_mode = WAL");
|
|
860
|
-
this.database.pragma("synchronous = FULL");
|
|
861
|
-
this.database.pragma("foreign_keys = ON");
|
|
968
|
+
clauses.push("c.resource_key = ?");
|
|
969
|
+
params.push(options.key);
|
|
862
970
|
}
|
|
863
|
-
|
|
971
|
+
params.push(options.limit ?? 100);
|
|
972
|
+
const rows = this.state.connection
|
|
973
|
+
.prepare(
|
|
974
|
+
`SELECT e.event_seq AS seq, e.recorded_at AS recordedAt,
|
|
975
|
+
c.controller_name AS controller, c.resource_key AS resourceKey,
|
|
976
|
+
e.event_type AS eventType, e.payload_hash AS payloadHash
|
|
977
|
+
FROM events e
|
|
978
|
+
JOIN resources r ON r.resource_id = e.resource_id
|
|
979
|
+
JOIN controller_resources c ON c.resource_id = r.resource_id
|
|
980
|
+
WHERE ${clauses.join(" AND ")}
|
|
981
|
+
ORDER BY e.event_seq DESC LIMIT ?`,
|
|
982
|
+
)
|
|
983
|
+
.all(...params);
|
|
984
|
+
return rows.filter(isControllerEventRow).map((row) => ({
|
|
985
|
+
seq: row.seq,
|
|
986
|
+
recordedAt: new Date(row.recordedAt).toISOString(),
|
|
987
|
+
controller: row.controller,
|
|
988
|
+
key: row.resourceKey,
|
|
989
|
+
type: row.eventType,
|
|
990
|
+
payload: row.payloadHash === null ? {} : (this.state.readJson(row.payloadHash) as JsonObject),
|
|
991
|
+
}));
|
|
864
992
|
}
|
|
865
993
|
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
994
|
+
reserveWorkflowRun(options: {
|
|
995
|
+
runId: string;
|
|
996
|
+
workflowName: string;
|
|
997
|
+
workflowSourceRef: string;
|
|
998
|
+
workflowSource: unknown;
|
|
999
|
+
definitionDigest: string;
|
|
1000
|
+
definitionSnapshot: unknown;
|
|
1001
|
+
input: unknown;
|
|
1002
|
+
launchOptions?: unknown;
|
|
1003
|
+
runnerId: string;
|
|
1004
|
+
originSessionId: string;
|
|
1005
|
+
parentRunId?: string;
|
|
1006
|
+
now?: string;
|
|
1007
|
+
}): WorkflowRunQueueRecord {
|
|
1008
|
+
validateRunId(options.runId);
|
|
1009
|
+
const now = epoch(validTimestamp(options.now));
|
|
1010
|
+
const definitionDigest = digestBuffer(options.definitionDigest);
|
|
1011
|
+
return this.state.transaction(() => {
|
|
1012
|
+
if (this.getWorkflowRun(options.runId) !== undefined) {
|
|
1013
|
+
throw new Error(`Workflow run already reserved: ${options.runId}`);
|
|
884
1014
|
}
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
.
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1015
|
+
if (options.parentRunId !== undefined) {
|
|
1016
|
+
const parent = this.requireWorkflowRunRow(options.parentRunId);
|
|
1017
|
+
if (parent.originSessionId !== options.originSessionId) {
|
|
1018
|
+
throw new Error("Continuation parent belongs to another Pi session");
|
|
1019
|
+
}
|
|
1020
|
+
if (parent.status === "parked") {
|
|
1021
|
+
const parentLease = this.requireLease(parent.resourceId);
|
|
1022
|
+
if (parentLease.ownerId !== null) {
|
|
1023
|
+
throw new Error("Continuation parent still has an active owner");
|
|
1024
|
+
}
|
|
1025
|
+
this.state.connection
|
|
1026
|
+
.prepare(
|
|
1027
|
+
`UPDATE run_queue
|
|
1028
|
+
SET status = 'done', updated_at = ?, finished_at = ?
|
|
1029
|
+
WHERE run_id = ? AND status = 'parked'`,
|
|
1030
|
+
)
|
|
1031
|
+
.run(now, now, parent.runId);
|
|
1032
|
+
const parentRevision = this.resourceRevision(parent.resourceId);
|
|
1033
|
+
this.bumpResource(parent.resourceId, parentRevision, now);
|
|
1034
|
+
this.insertEvent(
|
|
1035
|
+
parent.resourceId,
|
|
1036
|
+
parentRevision + 1,
|
|
1037
|
+
"run.queue_done_for_continuation",
|
|
1038
|
+
"session",
|
|
1039
|
+
options.originSessionId,
|
|
1040
|
+
{ continuationRunId: options.runId },
|
|
1041
|
+
now,
|
|
1042
|
+
);
|
|
1043
|
+
} else if (parent.status === "done") {
|
|
1044
|
+
throw new Error("Continuation parent already has a reserved continuation");
|
|
1045
|
+
} else {
|
|
1046
|
+
throw new Error(`Continuation parent queue is ${parent.status}`);
|
|
1047
|
+
}
|
|
892
1048
|
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
});
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
"updated_at",
|
|
924
|
-
"started_at",
|
|
925
|
-
"finished_at",
|
|
926
|
-
]);
|
|
927
|
-
const actual = new Set(
|
|
928
|
-
(
|
|
929
|
-
this.database.pragma("table_info(workflow_run_queue)") as {
|
|
930
|
-
name: string;
|
|
931
|
-
}[]
|
|
932
|
-
).map((column) => column.name),
|
|
933
|
-
);
|
|
934
|
-
const missing = [...requiredQueueColumns].filter((column) => !actual.has(column));
|
|
935
|
-
if (missing.length > 0) {
|
|
936
|
-
throw new Error(
|
|
937
|
-
"Controller store uses an incompatible alpha layout. Preserve needed run bundles, then reset the project controller store.",
|
|
938
|
-
);
|
|
939
|
-
}
|
|
940
|
-
if (!requireTurnIntents) return;
|
|
941
|
-
const requiredIntentColumns = new Set([
|
|
942
|
-
"intent_id",
|
|
943
|
-
"source_event_id",
|
|
944
|
-
"run_id",
|
|
945
|
-
"workflow_ref",
|
|
946
|
-
"target_session_id",
|
|
947
|
-
"cause",
|
|
948
|
-
"node_id",
|
|
949
|
-
"attempt_id",
|
|
950
|
-
"fallback_facts_json",
|
|
951
|
-
"requested_at",
|
|
952
|
-
"eligible_at",
|
|
953
|
-
"resolved_at",
|
|
954
|
-
"resolution",
|
|
955
|
-
"resolution_message_id",
|
|
956
|
-
"delivery_claim_token",
|
|
957
|
-
"delivery_claim_expires_at",
|
|
958
|
-
]);
|
|
959
|
-
const intentColumns = new Set(
|
|
960
|
-
(
|
|
961
|
-
this.database.pragma("table_info(workflow_turn_intents)") as {
|
|
962
|
-
name: string;
|
|
963
|
-
}[]
|
|
964
|
-
).map((column) => column.name),
|
|
965
|
-
);
|
|
966
|
-
const missingIntentColumns = [...requiredIntentColumns].filter(
|
|
967
|
-
(column) => !intentColumns.has(column),
|
|
968
|
-
);
|
|
969
|
-
if (missingIntentColumns.length > 0) {
|
|
970
|
-
throw new Error(
|
|
971
|
-
"Controller store uses an incompatible alpha turn-intent layout. Preserve needed run bundles, then reset the project controller store.",
|
|
972
|
-
);
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
private assertNoLegacyPendingLaunchNotifications(): void {
|
|
977
|
-
const notificationTable = this.database
|
|
978
|
-
.prepare(
|
|
979
|
-
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'workflow_notifications'",
|
|
980
|
-
)
|
|
981
|
-
.get();
|
|
982
|
-
if (notificationTable === undefined) {
|
|
983
|
-
throw new Error(
|
|
984
|
-
"Controller store uses an incompatible alpha notification layout. Preserve needed run bundles, then reset the project controller store.",
|
|
985
|
-
);
|
|
986
|
-
}
|
|
987
|
-
const legacy = this.database
|
|
988
|
-
.prepare(
|
|
989
|
-
`SELECT 1 FROM workflow_notifications
|
|
990
|
-
WHERE kind = 'launch_failure' AND delivered_at IS NULL LIMIT 1`,
|
|
991
|
-
)
|
|
992
|
-
.get();
|
|
993
|
-
if (legacy !== undefined) {
|
|
994
|
-
throw new Error(
|
|
995
|
-
"Controller store contains pending alpha launch-failure notifications. Preserve needed run bundles, then reset the project controller store.",
|
|
1049
|
+
const resourceId = resourceIdFor("run", options.runId);
|
|
1050
|
+
const definitionHash = this.state.putJson(options.definitionSnapshot, now);
|
|
1051
|
+
const queuedSource = queuedWorkflowSource(options.workflowSource);
|
|
1052
|
+
const sourceHash = this.state.putJson(options.workflowSource, now);
|
|
1053
|
+
const inputHash = this.state.putJson(options.input ?? null, now);
|
|
1054
|
+
const launchHash = this.state.putJson(options.launchOptions ?? {}, now);
|
|
1055
|
+
const queuedStateHash = this.state.putJson(
|
|
1056
|
+
{
|
|
1057
|
+
schema: "pi-workflows.run-state.v1",
|
|
1058
|
+
traceSeq: 1,
|
|
1059
|
+
runId: options.runId,
|
|
1060
|
+
workflowName: options.workflowName,
|
|
1061
|
+
workflowSource: queuedSource.root,
|
|
1062
|
+
...(queuedSource.mounted.length === 0
|
|
1063
|
+
? {}
|
|
1064
|
+
: {
|
|
1065
|
+
workflowSources: queuedSource.mounted,
|
|
1066
|
+
definitionDigest: options.definitionDigest,
|
|
1067
|
+
}),
|
|
1068
|
+
...(options.parentRunId === undefined ? {} : { parentRunId: options.parentRunId }),
|
|
1069
|
+
startedAt: new Date(now).toISOString(),
|
|
1070
|
+
updatedAt: new Date(now).toISOString(),
|
|
1071
|
+
status: "running",
|
|
1072
|
+
input: options.input ?? null,
|
|
1073
|
+
outputs: {},
|
|
1074
|
+
results: {},
|
|
1075
|
+
steps: [],
|
|
1076
|
+
updates: [],
|
|
1077
|
+
},
|
|
1078
|
+
now,
|
|
996
1079
|
);
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
.
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
)
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
return this.database
|
|
1059
|
-
.prepare("SELECT * FROM effects WHERE resource_uid = ? AND effect_key = ?")
|
|
1060
|
-
.get(resourceUid, key) as EffectRow | undefined;
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
private requireEffect(resourceUid: string, key: string): EffectRecord {
|
|
1064
|
-
const effect = this.getEffect(resourceUid, key);
|
|
1065
|
-
if (effect === undefined) {
|
|
1066
|
-
throw new Error(`Effect record not found: ${resourceUid}/${key}`);
|
|
1067
|
-
}
|
|
1068
|
-
return effect;
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
private workflowRow(resourceUid: string, requestKey: string): WorkflowRow | undefined {
|
|
1072
|
-
return this.database
|
|
1073
|
-
.prepare("SELECT * FROM workflow_requests WHERE resource_uid = ? AND request_key = ?")
|
|
1074
|
-
.get(resourceUid, requestKey) as WorkflowRow | undefined;
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
private requireWorkflow(requestId: string): ChildWorkflowRecord {
|
|
1078
|
-
const workflow = this.getWorkflowByRequestId(requestId);
|
|
1079
|
-
if (workflow === undefined) {
|
|
1080
|
-
throw new Error(`Workflow request not found: ${requestId}`);
|
|
1081
|
-
}
|
|
1082
|
-
return workflow;
|
|
1080
|
+
this.state.connection
|
|
1081
|
+
.prepare(
|
|
1082
|
+
`INSERT INTO workflow_definitions(
|
|
1083
|
+
definition_digest, workflow_name, definition_hash, created_at
|
|
1084
|
+
) VALUES (?, ?, ?, ?)
|
|
1085
|
+
ON CONFLICT(definition_digest) DO NOTHING`,
|
|
1086
|
+
)
|
|
1087
|
+
.run(definitionDigest, options.workflowName, definitionHash, now);
|
|
1088
|
+
this.state.connection
|
|
1089
|
+
.prepare(
|
|
1090
|
+
`INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at)
|
|
1091
|
+
VALUES (?, 'run', ?, 1, ?, ?)`,
|
|
1092
|
+
)
|
|
1093
|
+
.run(resourceId, options.runId, now, now);
|
|
1094
|
+
this.state.connection
|
|
1095
|
+
.prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
|
|
1096
|
+
.run(resourceId);
|
|
1097
|
+
const source = sourceParts(options.workflowSourceRef, options.definitionDigest);
|
|
1098
|
+
this.state.connection
|
|
1099
|
+
.prepare(
|
|
1100
|
+
`INSERT INTO runs(
|
|
1101
|
+
run_id, resource_id, project_id, parent_run_id, definition_digest,
|
|
1102
|
+
workflow_ref, workflow_source_hash, launch_options_hash,
|
|
1103
|
+
source_type, source_ref, source_revision, status, paused,
|
|
1104
|
+
input_hash, output_hash, created_at, updated_at
|
|
1105
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?, ?)`,
|
|
1106
|
+
)
|
|
1107
|
+
.run(
|
|
1108
|
+
options.runId,
|
|
1109
|
+
resourceId,
|
|
1110
|
+
this.requireProjectId(),
|
|
1111
|
+
options.parentRunId ?? null,
|
|
1112
|
+
definitionDigest,
|
|
1113
|
+
options.workflowSourceRef,
|
|
1114
|
+
sourceHash,
|
|
1115
|
+
launchHash,
|
|
1116
|
+
source.type,
|
|
1117
|
+
source.ref,
|
|
1118
|
+
source.revision,
|
|
1119
|
+
inputHash,
|
|
1120
|
+
queuedStateHash,
|
|
1121
|
+
now,
|
|
1122
|
+
now,
|
|
1123
|
+
);
|
|
1124
|
+
this.state.connection
|
|
1125
|
+
.prepare(
|
|
1126
|
+
`INSERT INTO run_bindings(run_id, origin_session_id, execution_mode, created_at)
|
|
1127
|
+
VALUES (?, ?, 'interactive', ?)`,
|
|
1128
|
+
)
|
|
1129
|
+
.run(options.runId, options.originSessionId, now);
|
|
1130
|
+
this.state.connection
|
|
1131
|
+
.prepare(
|
|
1132
|
+
`INSERT INTO run_queue(
|
|
1133
|
+
run_id, status, available_at, affinity_runner_id, origin_session_id,
|
|
1134
|
+
consecutive_errors, created_at, updated_at
|
|
1135
|
+
) VALUES (?, 'queued', ?, ?, ?, 0, ?, ?)`,
|
|
1136
|
+
)
|
|
1137
|
+
.run(options.runId, now, options.runnerId, options.originSessionId, now, now);
|
|
1138
|
+
this.insertEvent(resourceId, 1, "run.queued", "session", options.originSessionId, {}, now);
|
|
1139
|
+
return this.requireWorkflowRun(options.runId);
|
|
1140
|
+
});
|
|
1083
1141
|
}
|
|
1084
1142
|
|
|
1085
|
-
|
|
1086
|
-
reserveWorkflowRun(options: {
|
|
1143
|
+
enqueueWorkflowRun(options: {
|
|
1087
1144
|
runId: string;
|
|
1088
1145
|
workflowName: string;
|
|
1089
1146
|
workflowSourceRef: string;
|
|
1090
1147
|
workflowSource: unknown;
|
|
1091
1148
|
definitionDigest: string;
|
|
1092
|
-
|
|
1093
|
-
launchOptions?: unknown;
|
|
1094
|
-
runnerId: string;
|
|
1095
|
-
originSessionId: string;
|
|
1096
|
-
parentRunId?: string;
|
|
1097
|
-
now?: string;
|
|
1098
|
-
}): WorkflowRunQueueRecord {
|
|
1099
|
-
validateRunId(options.runId);
|
|
1100
|
-
validateKey(options.workflowName, "workflow name");
|
|
1101
|
-
validateKey(options.workflowSourceRef, "workflow source ref");
|
|
1102
|
-
validateKey(options.definitionDigest, "workflow definition digest");
|
|
1103
|
-
validateKey(options.runnerId, "runner id");
|
|
1104
|
-
validateKey(options.originSessionId, "origin session id");
|
|
1105
|
-
const inputJson = canonicalJson(options.input ?? null, "workflow run input");
|
|
1106
|
-
const sourceJson = canonicalJson(options.workflowSource, "workflow run source");
|
|
1107
|
-
const launchJson = canonicalJson(options.launchOptions ?? {}, "workflow launch options");
|
|
1108
|
-
validateJsonSize(inputJson, "Workflow run input", MAX_RESOURCE_VALUE_BYTES);
|
|
1109
|
-
validateJsonSize(sourceJson, "Workflow run source", MAX_EVENT_BYTES);
|
|
1110
|
-
validateJsonSize(launchJson, "Workflow launch options", MAX_EVENT_BYTES);
|
|
1111
|
-
const now = validTimestamp(options.now);
|
|
1112
|
-
this.database
|
|
1113
|
-
.prepare(
|
|
1114
|
-
`INSERT INTO workflow_run_queue (
|
|
1115
|
-
run_id, workflow_ref, workflow_path, workflow_source_json, definition_digest,
|
|
1116
|
-
input_json, launch_options_json, status, runner_id, claim_token, claim_expires_at,
|
|
1117
|
-
affinity_runner_id, origin_session_id, parent_run_id, error_code, error_message,
|
|
1118
|
-
created_at, updated_at, started_at, finished_at
|
|
1119
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', NULL, NULL, NULL, ?, ?, ?, NULL, NULL, ?, ?, NULL, NULL)`,
|
|
1120
|
-
)
|
|
1121
|
-
.run(
|
|
1122
|
-
options.runId,
|
|
1123
|
-
options.workflowName,
|
|
1124
|
-
options.workflowSourceRef,
|
|
1125
|
-
sourceJson,
|
|
1126
|
-
options.definitionDigest,
|
|
1127
|
-
inputJson,
|
|
1128
|
-
launchJson,
|
|
1129
|
-
options.runnerId,
|
|
1130
|
-
options.originSessionId,
|
|
1131
|
-
options.parentRunId ?? null,
|
|
1132
|
-
now,
|
|
1133
|
-
now,
|
|
1134
|
-
);
|
|
1135
|
-
return this.requireWorkflowRun(options.runId);
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
/** Insert a run that will start immediately outside a live model turn. */
|
|
1139
|
-
enqueueWorkflowRun(options: {
|
|
1140
|
-
runId: string;
|
|
1141
|
-
workflowName: string;
|
|
1142
|
-
workflowSourceRef: string;
|
|
1143
|
-
workflowSource?: unknown;
|
|
1144
|
-
definitionDigest?: string;
|
|
1149
|
+
definitionSnapshot: unknown;
|
|
1145
1150
|
input: unknown;
|
|
1146
1151
|
launchOptions?: unknown;
|
|
1147
1152
|
runnerId: string;
|
|
1148
1153
|
claimToken: string;
|
|
1149
1154
|
leaseMs: number;
|
|
1150
|
-
|
|
1151
|
-
originSessionId?: string;
|
|
1155
|
+
originSessionId: string;
|
|
1152
1156
|
parentRunId?: string;
|
|
1153
1157
|
now?: string;
|
|
1154
1158
|
}): WorkflowRunQueueRecord {
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1159
|
+
if (this.getWorkflowRun(options.runId) === undefined) {
|
|
1160
|
+
this.reserveWorkflowRun({
|
|
1161
|
+
runId: options.runId,
|
|
1162
|
+
workflowName: options.workflowName,
|
|
1163
|
+
workflowSourceRef: options.workflowSourceRef,
|
|
1164
|
+
workflowSource: options.workflowSource,
|
|
1165
|
+
definitionDigest: options.definitionDigest,
|
|
1166
|
+
definitionSnapshot: options.definitionSnapshot,
|
|
1167
|
+
input: options.input,
|
|
1168
|
+
launchOptions: options.launchOptions ?? {},
|
|
1169
|
+
runnerId: options.runnerId,
|
|
1170
|
+
originSessionId: options.originSessionId,
|
|
1171
|
+
...(options.parentRunId === undefined ? {} : { parentRunId: options.parentRunId }),
|
|
1172
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
1173
|
+
});
|
|
1163
1174
|
}
|
|
1164
|
-
const
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
`INSERT INTO workflow_run_queue (
|
|
1176
|
-
run_id, workflow_ref, workflow_path, workflow_source_json, definition_digest,
|
|
1177
|
-
input_json, launch_options_json, status, runner_id, claim_token, claim_expires_at,
|
|
1178
|
-
affinity_runner_id, origin_session_id, parent_run_id, error_code, error_message,
|
|
1179
|
-
created_at, updated_at, started_at, finished_at
|
|
1180
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, 'starting', ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, NULL)`,
|
|
1181
|
-
)
|
|
1182
|
-
.run(
|
|
1183
|
-
options.runId,
|
|
1184
|
-
options.workflowName,
|
|
1185
|
-
options.workflowSourceRef,
|
|
1186
|
-
sourceJson,
|
|
1187
|
-
options.definitionDigest ?? "unavailable",
|
|
1188
|
-
inputJson,
|
|
1189
|
-
launchJson,
|
|
1190
|
-
options.runnerId,
|
|
1191
|
-
options.claimToken,
|
|
1192
|
-
expiresAt,
|
|
1193
|
-
options.affinityRunnerId ?? options.runnerId,
|
|
1194
|
-
options.originSessionId ?? null,
|
|
1195
|
-
options.parentRunId ?? null,
|
|
1196
|
-
now,
|
|
1197
|
-
now,
|
|
1198
|
-
now,
|
|
1199
|
-
);
|
|
1200
|
-
return this.requireWorkflowRun(options.runId);
|
|
1175
|
+
const claimed = this.claimWorkflowRun({
|
|
1176
|
+
runId: options.runId,
|
|
1177
|
+
runnerId: options.runnerId,
|
|
1178
|
+
claimToken: options.claimToken,
|
|
1179
|
+
leaseMs: options.leaseMs,
|
|
1180
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
1181
|
+
});
|
|
1182
|
+
if (claimed === undefined) {
|
|
1183
|
+
throw new Error(`Workflow run could not be claimed: ${options.runId}`);
|
|
1184
|
+
}
|
|
1185
|
+
return claimed;
|
|
1201
1186
|
}
|
|
1202
1187
|
|
|
1203
1188
|
getWorkflowRun(runId: string): WorkflowRunQueueRecord | undefined {
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
.prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
|
|
1207
|
-
.get(runId) as WorkflowRunQueueRow | undefined;
|
|
1208
|
-
return row === undefined ? undefined : workflowRunFromRow(row);
|
|
1189
|
+
const row = this.workflowRunRow(runId);
|
|
1190
|
+
return row === undefined ? undefined : this.mapWorkflowRun(row);
|
|
1209
1191
|
}
|
|
1210
1192
|
|
|
1211
1193
|
listWorkflowRuns(
|
|
1212
|
-
options: {
|
|
1194
|
+
options: {
|
|
1195
|
+
statuses?: WorkflowRunLaunchStatus[];
|
|
1196
|
+
excludeRunIds?: string[];
|
|
1197
|
+
limit?: number;
|
|
1198
|
+
} = {},
|
|
1213
1199
|
): WorkflowRunQueueRecord[] {
|
|
1214
|
-
const
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1200
|
+
const clauses = ["r.project_id = ?"];
|
|
1201
|
+
const params: unknown[] = [this.requireProjectId()];
|
|
1202
|
+
if (options.statuses !== undefined && options.statuses.length > 0) {
|
|
1203
|
+
clauses.push(`q.status IN (${options.statuses.map(() => "?").join(", ")})`);
|
|
1204
|
+
params.push(...options.statuses);
|
|
1205
|
+
}
|
|
1206
|
+
if (options.excludeRunIds !== undefined && options.excludeRunIds.length > 0) {
|
|
1207
|
+
clauses.push(`r.run_id NOT IN (${options.excludeRunIds.map(() => "?").join(", ")})`);
|
|
1208
|
+
params.push(...options.excludeRunIds);
|
|
1209
|
+
}
|
|
1210
|
+
params.push(options.limit ?? 100);
|
|
1211
|
+
const rows = this.state.connection
|
|
1212
|
+
.prepare(
|
|
1213
|
+
workflowRunSelect(`WHERE ${clauses.join(" AND ")} ORDER BY q.created_at DESC LIMIT ?`),
|
|
1214
|
+
)
|
|
1215
|
+
.all(...params);
|
|
1216
|
+
return rows.filter(isRunRow).map((row) => this.mapWorkflowRun(row));
|
|
1223
1217
|
}
|
|
1224
1218
|
|
|
1225
1219
|
findSessionReservation(sessionId: string): WorkflowRunQueueRecord | undefined {
|
|
1226
|
-
|
|
1227
|
-
const row = this.database
|
|
1220
|
+
const row = this.state.connection
|
|
1228
1221
|
.prepare(
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1222
|
+
workflowRunSelect(
|
|
1223
|
+
"WHERE b.origin_session_id = ? AND q.status NOT IN ('done', 'failed', 'cancelled') ORDER BY q.created_at DESC LIMIT 1",
|
|
1224
|
+
),
|
|
1232
1225
|
)
|
|
1233
|
-
.get(sessionId)
|
|
1234
|
-
return row
|
|
1226
|
+
.get(sessionId);
|
|
1227
|
+
return isRunRow(row) ? this.mapWorkflowRun(row) : undefined;
|
|
1235
1228
|
}
|
|
1236
1229
|
|
|
1237
1230
|
claimWorkflowRun(options: {
|
|
@@ -1241,157 +1234,130 @@ export class SqliteControllerStore implements ControllerStore {
|
|
|
1241
1234
|
leaseMs: number;
|
|
1242
1235
|
now?: string;
|
|
1243
1236
|
}): WorkflowRunQueueRecord | undefined {
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
validateKey(options.claimToken, "claim token");
|
|
1247
|
-
validateDuration(options.leaseMs, "leaseMs");
|
|
1248
|
-
const now = validTimestamp(options.now);
|
|
1249
|
-
const nowMs = epoch(now);
|
|
1250
|
-
const expiresAt = nowMs + options.leaseMs;
|
|
1251
|
-
const result = this.database
|
|
1252
|
-
.prepare(
|
|
1253
|
-
`UPDATE workflow_run_queue
|
|
1254
|
-
SET status = 'starting', runner_id = ?, claim_token = ?, claim_expires_at = ?,
|
|
1255
|
-
started_at = COALESCE(started_at, ?), updated_at = ?
|
|
1256
|
-
WHERE run_id = ? AND (
|
|
1257
|
-
status IN ('queued', 'parked')
|
|
1258
|
-
OR (status = 'starting' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
|
|
1259
|
-
)`,
|
|
1260
|
-
)
|
|
1261
|
-
.run(options.runnerId, options.claimToken, expiresAt, now, now, options.runId, nowMs);
|
|
1262
|
-
return result.changes === 1 ? this.requireWorkflowRun(options.runId) : undefined;
|
|
1237
|
+
const now = epoch(validTimestamp(options.now));
|
|
1238
|
+
return this.claimRun(options.runId, options.runnerId, options.claimToken, options.leaseMs, now);
|
|
1263
1239
|
}
|
|
1264
1240
|
|
|
1265
1241
|
markWorkflowRunRunning(options: { runId: string; claimToken: string; now?: string }): boolean {
|
|
1266
|
-
|
|
1267
|
-
const now = validTimestamp(options.now);
|
|
1268
|
-
const result = this.database
|
|
1269
|
-
.prepare(
|
|
1270
|
-
`UPDATE workflow_run_queue SET status = 'running', updated_at = ?
|
|
1271
|
-
WHERE run_id = ? AND claim_token = ? AND status = 'starting'`,
|
|
1272
|
-
)
|
|
1273
|
-
.run(now, options.runId, options.claimToken);
|
|
1274
|
-
return result.changes === 1;
|
|
1242
|
+
return this.updateClaimedRunStatus(options.runId, options.claimToken, "running", options.now);
|
|
1275
1243
|
}
|
|
1276
1244
|
|
|
1277
|
-
/**
|
|
1278
|
-
* Claim the oldest claimable run, preferring runs with affinity to this
|
|
1279
|
-
* runner. Parked rows are claimable immediately; claimed rows become
|
|
1280
|
-
* claimable once their lease expires, so a dead or stalled runner never
|
|
1281
|
-
* strands a run. Returns undefined when nothing is claimable.
|
|
1282
|
-
*/
|
|
1283
1245
|
claimNextWorkflowRun(options: {
|
|
1284
1246
|
runnerId: string;
|
|
1247
|
+
sessionId?: string;
|
|
1285
1248
|
claimToken: string;
|
|
1286
1249
|
leaseMs: number;
|
|
1287
|
-
excludeRunIds?: string[];
|
|
1288
|
-
/** Interactive sessions provide their id; detached hosts omit it. */
|
|
1289
|
-
sessionId?: string;
|
|
1290
1250
|
now?: string;
|
|
1251
|
+
excludeRunIds?: string[];
|
|
1291
1252
|
}): WorkflowRunQueueRecord | undefined {
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1253
|
+
const now = epoch(validTimestamp(options.now));
|
|
1254
|
+
const clauses = [
|
|
1255
|
+
"r.project_id = ?",
|
|
1256
|
+
"q.status IN ('queued', 'parked', 'starting', 'running')",
|
|
1257
|
+
"q.available_at <= ?",
|
|
1258
|
+
"(l.owner_id IS NULL OR l.expires_at <= ? OR l.owner_id = ?)",
|
|
1259
|
+
"(q.affinity_runner_id IS NULL OR q.affinity_runner_id = ? OR q.status IN ('parked', 'starting', 'running'))",
|
|
1260
|
+
];
|
|
1261
|
+
const params: unknown[] = [
|
|
1262
|
+
this.requireProjectId(),
|
|
1263
|
+
now,
|
|
1264
|
+
now,
|
|
1265
|
+
options.runnerId,
|
|
1266
|
+
options.runnerId,
|
|
1267
|
+
];
|
|
1268
|
+
if (options.sessionId !== undefined) {
|
|
1269
|
+
clauses.push("b.origin_session_id = ?");
|
|
1270
|
+
params.push(options.sessionId);
|
|
1271
|
+
} else {
|
|
1272
|
+
clauses.push("(b.execution_mode = 'headless' OR q.status <> 'queued')");
|
|
1302
1273
|
}
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
`SELECT run_id FROM workflow_run_queue
|
|
1317
|
-
WHERE ${claimable} ${exclusion} ${sessionFilter}
|
|
1318
|
-
ORDER BY
|
|
1319
|
-
CASE WHEN affinity_runner_id = ? THEN 0 ELSE 1 END,
|
|
1320
|
-
created_at ASC
|
|
1321
|
-
LIMIT 1`,
|
|
1322
|
-
)
|
|
1323
|
-
.get(
|
|
1324
|
-
nowMs,
|
|
1325
|
-
...excluded,
|
|
1326
|
-
...(options.sessionId === undefined ? [] : [options.sessionId]),
|
|
1327
|
-
options.runnerId,
|
|
1328
|
-
) as { run_id: string } | undefined;
|
|
1329
|
-
if (candidate === undefined) {
|
|
1330
|
-
return undefined;
|
|
1331
|
-
}
|
|
1332
|
-
const result = this.database
|
|
1333
|
-
.prepare(
|
|
1334
|
-
`UPDATE workflow_run_queue
|
|
1335
|
-
SET status = 'starting', runner_id = ?, claim_token = ?,
|
|
1336
|
-
claim_expires_at = ?, started_at = COALESCE(started_at, ?), updated_at = ?
|
|
1337
|
-
WHERE run_id = ? AND (
|
|
1338
|
-
status IN ('queued', 'parked')
|
|
1339
|
-
OR (status IN ('starting', 'running') AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
|
|
1340
|
-
)`,
|
|
1341
|
-
)
|
|
1342
|
-
.run(options.runnerId, options.claimToken, expiresAt, now, now, candidate.run_id, nowMs);
|
|
1343
|
-
return result.changes === 1 ? this.requireWorkflowRun(candidate.run_id) : undefined;
|
|
1344
|
-
});
|
|
1274
|
+
if (options.excludeRunIds !== undefined && options.excludeRunIds.length > 0) {
|
|
1275
|
+
clauses.push(`r.run_id NOT IN (${options.excludeRunIds.map(() => "?").join(", ")})`);
|
|
1276
|
+
params.push(...options.excludeRunIds);
|
|
1277
|
+
}
|
|
1278
|
+
const row = this.state.connection
|
|
1279
|
+
.prepare(
|
|
1280
|
+
workflowRunSelect(
|
|
1281
|
+
`WHERE ${clauses.join(" AND ")} ORDER BY q.available_at, q.created_at LIMIT 1`,
|
|
1282
|
+
),
|
|
1283
|
+
)
|
|
1284
|
+
.get(...params);
|
|
1285
|
+
if (!isRunRow(row)) return undefined;
|
|
1286
|
+
return this.claimRun(row.runId, options.runnerId, options.claimToken, options.leaseMs, now);
|
|
1345
1287
|
}
|
|
1346
1288
|
|
|
1347
|
-
/** Extend a live claim. Rejects expired or foreign claims. */
|
|
1348
1289
|
renewWorkflowRunClaim(options: {
|
|
1349
1290
|
runId: string;
|
|
1350
1291
|
claimToken: string;
|
|
1351
1292
|
leaseMs: number;
|
|
1352
1293
|
now?: string;
|
|
1353
1294
|
}): boolean {
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
const
|
|
1358
|
-
const result = this.database
|
|
1295
|
+
const now = epoch(validTimestamp(options.now));
|
|
1296
|
+
const row = this.workflowRunRow(options.runId);
|
|
1297
|
+
if (row === undefined || row.ownerId === null) return false;
|
|
1298
|
+
const result = this.state.connection
|
|
1359
1299
|
.prepare(
|
|
1360
|
-
`UPDATE
|
|
1361
|
-
WHERE
|
|
1362
|
-
AND claim_expires_at > ?`,
|
|
1300
|
+
`UPDATE leases SET heartbeat_at = ?, expires_at = ?
|
|
1301
|
+
WHERE resource_id = ? AND token_hash = ? AND owner_id = ? AND expires_at > ?`,
|
|
1363
1302
|
)
|
|
1364
|
-
.run(
|
|
1303
|
+
.run(
|
|
1304
|
+
now,
|
|
1305
|
+
now + options.leaseMs,
|
|
1306
|
+
row.resourceId,
|
|
1307
|
+
tokenHash(options.claimToken),
|
|
1308
|
+
row.ownerId,
|
|
1309
|
+
now,
|
|
1310
|
+
);
|
|
1365
1311
|
return result.changes === 1;
|
|
1366
1312
|
}
|
|
1367
1313
|
|
|
1368
|
-
/** The fence check: true only while this exact claim is live. */
|
|
1369
1314
|
verifyWorkflowRunClaim(options: { runId: string; claimToken: string; now?: string }): boolean {
|
|
1370
|
-
|
|
1371
|
-
const
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1315
|
+
const now = epoch(validTimestamp(options.now));
|
|
1316
|
+
const row = this.workflowRunRow(options.runId);
|
|
1317
|
+
if (row === undefined || row.ownerId === null || row.claimExpiresAt === null) return false;
|
|
1318
|
+
const lease = this.requireLease(row.resourceId);
|
|
1319
|
+
return (
|
|
1320
|
+
lease.tokenHash !== null &&
|
|
1321
|
+
lease.tokenHash.equals(tokenHash(options.claimToken)) &&
|
|
1322
|
+
row.claimExpiresAt > now
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
workflowRunAuthority(
|
|
1327
|
+
runId: string,
|
|
1328
|
+
claimToken: string,
|
|
1329
|
+
):
|
|
1330
|
+
| {
|
|
1331
|
+
actor: { type: "session" | "host"; id: string };
|
|
1332
|
+
ownerType: "session" | "host";
|
|
1333
|
+
ownerId: string;
|
|
1334
|
+
token: string;
|
|
1335
|
+
generation: number;
|
|
1336
|
+
}
|
|
1337
|
+
| undefined {
|
|
1338
|
+
const row = this.workflowRunRow(runId);
|
|
1339
|
+
if (
|
|
1340
|
+
row === undefined ||
|
|
1341
|
+
row.ownerId === null ||
|
|
1342
|
+
row.claimExpiresAt === null ||
|
|
1343
|
+
row.claimExpiresAt <= Date.now()
|
|
1344
|
+
)
|
|
1345
|
+
return undefined;
|
|
1346
|
+
const lease = this.requireLease(row.resourceId);
|
|
1347
|
+
if (lease.tokenHash === null || !lease.tokenHash.equals(tokenHash(claimToken)))
|
|
1348
|
+
return undefined;
|
|
1349
|
+
const ownerType = row.ownerId.startsWith("host-") ? "host" : "session";
|
|
1350
|
+
return {
|
|
1351
|
+
actor: { type: ownerType, id: row.ownerId },
|
|
1352
|
+
ownerType,
|
|
1353
|
+
ownerId: row.ownerId,
|
|
1354
|
+
token: claimToken,
|
|
1355
|
+
generation: row.leaseGeneration,
|
|
1356
|
+
};
|
|
1380
1357
|
}
|
|
1381
1358
|
|
|
1382
|
-
/** Release a claim and park the run so another runner can resume it. */
|
|
1383
1359
|
parkWorkflowRun(options: { runId: string; claimToken: string; now?: string }): boolean {
|
|
1384
|
-
|
|
1385
|
-
const now = validTimestamp(options.now);
|
|
1386
|
-
const result = this.database
|
|
1387
|
-
.prepare(
|
|
1388
|
-
`UPDATE workflow_run_queue
|
|
1389
|
-
SET status = 'parked', runner_id = NULL, claim_token = NULL,
|
|
1390
|
-
claim_expires_at = NULL, updated_at = ?
|
|
1391
|
-
WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')`,
|
|
1392
|
-
)
|
|
1393
|
-
.run(now, options.runId, options.claimToken);
|
|
1394
|
-
return result.changes === 1;
|
|
1360
|
+
return this.releaseRunClaim(options.runId, options.claimToken, "parked", options.now);
|
|
1395
1361
|
}
|
|
1396
1362
|
|
|
1397
1363
|
failWorkflowRun(options: {
|
|
@@ -1401,250 +1367,118 @@ export class SqliteControllerStore implements ControllerStore {
|
|
|
1401
1367
|
errorMessage: string;
|
|
1402
1368
|
now?: string;
|
|
1403
1369
|
}): boolean {
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
const result = this.database
|
|
1413
|
-
.prepare(
|
|
1414
|
-
`UPDATE workflow_run_queue
|
|
1415
|
-
SET status = 'failed', runner_id = NULL, claim_token = NULL,
|
|
1416
|
-
claim_expires_at = NULL, parent_run_id = NULL, input_json = 'null',
|
|
1417
|
-
launch_options_json = '{}', error_code = ?, error_message = ?,
|
|
1418
|
-
finished_at = ?, updated_at = ?
|
|
1419
|
-
WHERE run_id = ? AND status IN ('queued', 'starting', 'running') ${claimFilter}`,
|
|
1420
|
-
)
|
|
1421
|
-
.run(
|
|
1422
|
-
options.errorCode,
|
|
1423
|
-
message,
|
|
1424
|
-
now,
|
|
1425
|
-
now,
|
|
1426
|
-
options.runId,
|
|
1427
|
-
...(options.claimToken === undefined ? [] : [options.claimToken]),
|
|
1428
|
-
);
|
|
1429
|
-
return result.changes === 1;
|
|
1370
|
+
return this.terminalRun(
|
|
1371
|
+
options.runId,
|
|
1372
|
+
options.claimToken,
|
|
1373
|
+
"failed",
|
|
1374
|
+
options.errorCode,
|
|
1375
|
+
options.errorMessage,
|
|
1376
|
+
options.now,
|
|
1377
|
+
);
|
|
1430
1378
|
}
|
|
1431
1379
|
|
|
1432
1380
|
cancelWorkflowRun(options: { runId: string; claimToken?: string; now?: string }): boolean {
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
finished_at = ?, updated_at = ?
|
|
1442
|
-
WHERE run_id = ? AND status IN ('queued', 'starting') ${claimFilter}`,
|
|
1443
|
-
)
|
|
1444
|
-
.run(
|
|
1445
|
-
now,
|
|
1446
|
-
now,
|
|
1447
|
-
options.runId,
|
|
1448
|
-
...(options.claimToken === undefined ? [] : [options.claimToken]),
|
|
1449
|
-
);
|
|
1450
|
-
return result.changes === 1;
|
|
1381
|
+
return this.terminalRun(
|
|
1382
|
+
options.runId,
|
|
1383
|
+
options.claimToken,
|
|
1384
|
+
"cancelled",
|
|
1385
|
+
"cancelled",
|
|
1386
|
+
"Workflow run cancelled",
|
|
1387
|
+
options.now,
|
|
1388
|
+
);
|
|
1451
1389
|
}
|
|
1452
1390
|
|
|
1453
|
-
/**
|
|
1454
|
-
* Delete a claimed row. Used when a continuation fails before its bundle
|
|
1455
|
-
* exists, so the parent's one-continuation slot is not consumed by a run
|
|
1456
|
-
* that never happened.
|
|
1457
|
-
*/
|
|
1458
1391
|
deleteWorkflowRun(options: { runId: string; claimToken: string }): boolean {
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1392
|
+
const now = Date.now();
|
|
1393
|
+
return this.state.transaction(() => {
|
|
1394
|
+
const row = this.workflowRunRow(options.runId);
|
|
1395
|
+
if (row === undefined) return false;
|
|
1396
|
+
const lease = this.requireLease(row.resourceId);
|
|
1397
|
+
if (
|
|
1398
|
+
lease.ownerId === null ||
|
|
1399
|
+
lease.tokenHash === null ||
|
|
1400
|
+
lease.expiresAt === null ||
|
|
1401
|
+
lease.expiresAt <= now ||
|
|
1402
|
+
!lease.tokenHash.equals(tokenHash(options.claimToken))
|
|
1403
|
+
) {
|
|
1404
|
+
return false;
|
|
1405
|
+
}
|
|
1406
|
+
return (
|
|
1407
|
+
this.state.connection
|
|
1408
|
+
.prepare("DELETE FROM resources WHERE resource_id = ?")
|
|
1409
|
+
.run(row.resourceId).changes === 1
|
|
1410
|
+
);
|
|
1411
|
+
});
|
|
1464
1412
|
}
|
|
1465
1413
|
|
|
1466
|
-
/** Release a claim and mark the run terminal in the queue. */
|
|
1467
1414
|
completeWorkflowRun(options: { runId: string; claimToken: string; now?: string }): boolean {
|
|
1468
|
-
|
|
1469
|
-
const now = validTimestamp(options.now);
|
|
1470
|
-
const result = this.database
|
|
1471
|
-
.prepare(
|
|
1472
|
-
`UPDATE workflow_run_queue
|
|
1473
|
-
SET status = 'done', runner_id = NULL, claim_token = NULL,
|
|
1474
|
-
claim_expires_at = NULL, input_json = 'null', launch_options_json = '{}',
|
|
1475
|
-
finished_at = ?, updated_at = ?
|
|
1476
|
-
WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')`,
|
|
1477
|
-
)
|
|
1478
|
-
.run(now, now, options.runId, options.claimToken);
|
|
1479
|
-
return result.changes === 1;
|
|
1415
|
+
return this.releaseRunClaim(options.runId, options.claimToken, "done", options.now);
|
|
1480
1416
|
}
|
|
1481
1417
|
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
runId: string;
|
|
1485
|
-
workflowName: string;
|
|
1486
|
-
workflowSourceRef: string;
|
|
1487
|
-
runnerId: string;
|
|
1488
|
-
claimToken: string;
|
|
1489
|
-
leaseMs: number;
|
|
1490
|
-
now?: string;
|
|
1491
|
-
}): "unchanged" | "claimed" | false {
|
|
1492
|
-
validateRunId(options.runId);
|
|
1493
|
-
validateKey(options.workflowName, "workflow name");
|
|
1494
|
-
validateKey(options.workflowSourceRef, "workflow source ref");
|
|
1495
|
-
validateKey(options.runnerId, "runner id");
|
|
1496
|
-
validateKey(options.claimToken, "claim token");
|
|
1497
|
-
validateDuration(options.leaseMs, "leaseMs");
|
|
1498
|
-
const now = validTimestamp(options.now);
|
|
1499
|
-
const nowMs = epoch(now);
|
|
1500
|
-
const expiresAt = nowMs + options.leaseMs;
|
|
1501
|
-
return this.transaction(() => {
|
|
1502
|
-
const row = this.database
|
|
1503
|
-
.prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
|
|
1504
|
-
.get(options.runId) as WorkflowRunQueueRow | undefined;
|
|
1505
|
-
if (row === undefined || ["done", "failed", "cancelled"].includes(row.status)) {
|
|
1506
|
-
return false;
|
|
1507
|
-
}
|
|
1508
|
-
if (row.workflow_path === options.workflowSourceRef && row.status === "parked") {
|
|
1509
|
-
return "unchanged";
|
|
1510
|
-
}
|
|
1511
|
-
const claimable =
|
|
1512
|
-
row.status === "parked" ||
|
|
1513
|
-
(row.status === "starting" &&
|
|
1514
|
-
row.claim_token === options.claimToken &&
|
|
1515
|
-
row.claim_expires_at !== null &&
|
|
1516
|
-
row.claim_expires_at > nowMs) ||
|
|
1517
|
-
(row.status === "starting" &&
|
|
1518
|
-
row.claim_expires_at !== null &&
|
|
1519
|
-
row.claim_expires_at <= nowMs);
|
|
1520
|
-
if (!claimable) return false;
|
|
1521
|
-
const result = this.database
|
|
1522
|
-
.prepare(
|
|
1523
|
-
`UPDATE workflow_run_queue
|
|
1524
|
-
SET workflow_ref = ?, workflow_path = ?, status = 'starting', runner_id = ?,
|
|
1525
|
-
claim_token = ?, claim_expires_at = ?, updated_at = ?
|
|
1526
|
-
WHERE run_id = ? AND status NOT IN ('done', 'failed', 'cancelled')`,
|
|
1527
|
-
)
|
|
1528
|
-
.run(
|
|
1529
|
-
options.workflowName,
|
|
1530
|
-
options.workflowSourceRef,
|
|
1531
|
-
options.runnerId,
|
|
1532
|
-
options.claimToken,
|
|
1533
|
-
expiresAt,
|
|
1534
|
-
now,
|
|
1535
|
-
options.runId,
|
|
1536
|
-
);
|
|
1537
|
-
return result.changes === 1 ? "claimed" : false;
|
|
1538
|
-
});
|
|
1418
|
+
repairCanonicalWorkflowSourceRun(): never {
|
|
1419
|
+
throw new Error("Legacy workflow source repair is not available after the SQLite cutover");
|
|
1539
1420
|
}
|
|
1540
1421
|
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
runId: string;
|
|
1544
|
-
workflowName: string;
|
|
1545
|
-
oldWorkflowPath: string;
|
|
1546
|
-
workflowSourceRef: string;
|
|
1547
|
-
runnerId: string;
|
|
1548
|
-
claimToken: string;
|
|
1549
|
-
leaseMs: number;
|
|
1550
|
-
now?: string;
|
|
1551
|
-
}): boolean {
|
|
1552
|
-
validateRunId(options.runId);
|
|
1553
|
-
validateKey(options.workflowName, "workflow name");
|
|
1554
|
-
validateKey(options.oldWorkflowPath, "legacy workflow path");
|
|
1555
|
-
validateKey(options.workflowSourceRef, "workflow source ref");
|
|
1556
|
-
validateKey(options.runnerId, "runner id");
|
|
1557
|
-
validateKey(options.claimToken, "claim token");
|
|
1558
|
-
validateDuration(options.leaseMs, "leaseMs");
|
|
1559
|
-
const now = validTimestamp(options.now);
|
|
1560
|
-
const nowMs = epoch(now);
|
|
1561
|
-
const expiresAt = nowMs + options.leaseMs;
|
|
1562
|
-
return this.transaction(() => {
|
|
1563
|
-
const row = this.database
|
|
1564
|
-
.prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
|
|
1565
|
-
.get(options.runId) as WorkflowRunQueueRow | undefined;
|
|
1566
|
-
if (row === undefined || ["done", "failed", "cancelled"].includes(row.status)) {
|
|
1567
|
-
return false;
|
|
1568
|
-
}
|
|
1569
|
-
const sourceMatches =
|
|
1570
|
-
row.workflow_path === options.oldWorkflowPath ||
|
|
1571
|
-
row.workflow_path === options.workflowSourceRef;
|
|
1572
|
-
const claimable =
|
|
1573
|
-
row.status === "parked" ||
|
|
1574
|
-
(row.status === "starting" &&
|
|
1575
|
-
row.claim_token === options.claimToken &&
|
|
1576
|
-
row.claim_expires_at !== null &&
|
|
1577
|
-
row.claim_expires_at > nowMs) ||
|
|
1578
|
-
(row.status === "starting" &&
|
|
1579
|
-
row.claim_expires_at !== null &&
|
|
1580
|
-
row.claim_expires_at <= nowMs);
|
|
1581
|
-
if (!sourceMatches || !claimable) return false;
|
|
1582
|
-
const result = this.database
|
|
1583
|
-
.prepare(
|
|
1584
|
-
`UPDATE workflow_run_queue
|
|
1585
|
-
SET workflow_ref = ?, workflow_path = ?, status = 'starting', runner_id = ?,
|
|
1586
|
-
claim_token = ?, claim_expires_at = ?, updated_at = ?
|
|
1587
|
-
WHERE run_id = ? AND status NOT IN ('done', 'failed', 'cancelled')`,
|
|
1588
|
-
)
|
|
1589
|
-
.run(
|
|
1590
|
-
options.workflowName,
|
|
1591
|
-
options.workflowSourceRef,
|
|
1592
|
-
options.runnerId,
|
|
1593
|
-
options.claimToken,
|
|
1594
|
-
expiresAt,
|
|
1595
|
-
now,
|
|
1596
|
-
options.runId,
|
|
1597
|
-
);
|
|
1598
|
-
return result.changes === 1;
|
|
1599
|
-
});
|
|
1422
|
+
claimLegacyWorkflowSourceRun(): never {
|
|
1423
|
+
throw new Error("Legacy workflow source claims are not available after the SQLite cutover");
|
|
1600
1424
|
}
|
|
1601
1425
|
|
|
1602
|
-
/** Append a run lifecycle transition to the event feed. */
|
|
1603
1426
|
recordRunEvent(options: {
|
|
1604
1427
|
runId: string;
|
|
1605
1428
|
workflowRef: string;
|
|
1606
1429
|
type: string;
|
|
1607
|
-
runnerId?: string;
|
|
1608
1430
|
payload?: JsonObject;
|
|
1431
|
+
runnerId?: string;
|
|
1609
1432
|
now?: string;
|
|
1610
|
-
}):
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
const
|
|
1614
|
-
|
|
1615
|
-
const
|
|
1616
|
-
.
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
)
|
|
1620
|
-
.
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
);
|
|
1628
|
-
|
|
1433
|
+
}): RunEventRecord {
|
|
1434
|
+
const row = this.requireWorkflowRunRow(options.runId);
|
|
1435
|
+
const now = epoch(validTimestamp(options.now));
|
|
1436
|
+
const revision = this.resourceRevision(row.resourceId) + 1;
|
|
1437
|
+
this.bumpResource(row.resourceId, revision - 1, now);
|
|
1438
|
+
const eventId = this.insertEvent(
|
|
1439
|
+
row.resourceId,
|
|
1440
|
+
revision,
|
|
1441
|
+
options.type,
|
|
1442
|
+
options.runnerId?.startsWith("host-") ? "host" : "session",
|
|
1443
|
+
options.runnerId ?? null,
|
|
1444
|
+
options.payload ?? {},
|
|
1445
|
+
now,
|
|
1446
|
+
row.leaseGeneration || undefined,
|
|
1447
|
+
);
|
|
1448
|
+
const seq = this.state.connection
|
|
1449
|
+
.prepare("SELECT event_seq AS seq FROM events WHERE event_id = ?")
|
|
1450
|
+
.get(eventId);
|
|
1451
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
1452
|
+
if (!isSequenceRow(seq)) throw new Error("Run event was not recorded");
|
|
1453
|
+
return {
|
|
1454
|
+
seq: seq.seq,
|
|
1455
|
+
recordedAt: new Date(now).toISOString(),
|
|
1456
|
+
runId: options.runId,
|
|
1457
|
+
workflowRef: options.workflowRef,
|
|
1458
|
+
type: options.type,
|
|
1459
|
+
runnerId: options.runnerId ?? null,
|
|
1460
|
+
payload: options.payload ?? {},
|
|
1461
|
+
};
|
|
1629
1462
|
}
|
|
1630
1463
|
|
|
1631
|
-
/** List run events after a watermark, oldest first. */
|
|
1632
1464
|
listRunEventsAfter(seq: number, options: { limit?: number } = {}): RunEventRecord[] {
|
|
1633
|
-
const
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1465
|
+
const rows = this.state.connection
|
|
1466
|
+
.prepare(
|
|
1467
|
+
`SELECT e.event_seq AS seq, e.recorded_at AS recordedAt, r.run_id AS runId,
|
|
1468
|
+
r.workflow_ref AS workflowRef, e.event_type AS eventType,
|
|
1469
|
+
e.actor_id AS runnerId, e.payload_hash AS payloadHash
|
|
1470
|
+
FROM events e JOIN runs r ON r.resource_id = e.resource_id
|
|
1471
|
+
WHERE e.event_seq > ? ORDER BY e.event_seq LIMIT ?`,
|
|
1472
|
+
)
|
|
1473
|
+
.all(seq, options.limit ?? 100);
|
|
1474
|
+
return rows.filter(isRunEventRow).map((row) => ({
|
|
1641
1475
|
seq: row.seq,
|
|
1642
|
-
recordedAt: row.
|
|
1643
|
-
runId: row.
|
|
1644
|
-
workflowRef: row.
|
|
1645
|
-
type: row.
|
|
1646
|
-
runnerId: row.
|
|
1647
|
-
payload:
|
|
1476
|
+
recordedAt: new Date(row.recordedAt).toISOString(),
|
|
1477
|
+
runId: row.runId,
|
|
1478
|
+
workflowRef: row.workflowRef,
|
|
1479
|
+
type: row.eventType,
|
|
1480
|
+
runnerId: row.runnerId,
|
|
1481
|
+
payload: row.payloadHash === null ? {} : (this.state.readJson(row.payloadHash) as JsonObject),
|
|
1648
1482
|
}));
|
|
1649
1483
|
}
|
|
1650
1484
|
|
|
@@ -1658,85 +1492,90 @@ export class SqliteControllerStore implements ControllerStore {
|
|
|
1658
1492
|
nodeId?: string | null;
|
|
1659
1493
|
attemptId?: string | null;
|
|
1660
1494
|
fallbackFacts: WorkflowTurnIntentFacts;
|
|
1661
|
-
eligible
|
|
1662
|
-
|
|
1495
|
+
eligible: boolean;
|
|
1496
|
+
requestedAt?: string;
|
|
1663
1497
|
}): WorkflowTurnIntentRecord {
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
const
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1498
|
+
const existing = this.turnIntentBySource(options.sourceEventId);
|
|
1499
|
+
if (existing !== undefined) return existing;
|
|
1500
|
+
const now = epoch(validTimestamp(options.requestedAt));
|
|
1501
|
+
const run = this.requireWorkflowRunRow(options.runId);
|
|
1502
|
+
const intentId = options.intentId;
|
|
1503
|
+
const resourceId = resourceIdFor("turn_intent", intentId);
|
|
1504
|
+
const effectId = `effect-${randomUUID()}`;
|
|
1505
|
+
this.state.transaction(() => {
|
|
1506
|
+
const factsHash = this.state.putJson(options.fallbackFacts, now);
|
|
1507
|
+
this.insertEffectResource(
|
|
1508
|
+
effectId,
|
|
1509
|
+
resourceIdFor("effect", effectId),
|
|
1510
|
+
run.resourceId,
|
|
1511
|
+
this.resourceRevision(run.resourceId),
|
|
1512
|
+
"turn.deliver",
|
|
1513
|
+
intentId,
|
|
1514
|
+
factsHash,
|
|
1515
|
+
"run",
|
|
1516
|
+
now,
|
|
1517
|
+
);
|
|
1518
|
+
this.state.connection
|
|
1519
|
+
.prepare(
|
|
1520
|
+
"INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at) VALUES (?, 'turn_intent', ?, 1, ?, ?)",
|
|
1521
|
+
)
|
|
1522
|
+
.run(resourceId, intentId, now, now);
|
|
1523
|
+
this.state.connection
|
|
1524
|
+
.prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
|
|
1525
|
+
.run(resourceId);
|
|
1526
|
+
this.state.connection
|
|
1527
|
+
.prepare(
|
|
1528
|
+
`INSERT INTO turn_intents(
|
|
1529
|
+
turn_intent_id, resource_id, effect_id, source_event_id, run_id,
|
|
1530
|
+
workflow_ref, target_session_id, cause, node_id, attempt_id,
|
|
1531
|
+
facts_hash, requested_at, eligible_at, created_at
|
|
1532
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1533
|
+
)
|
|
1534
|
+
.run(
|
|
1535
|
+
intentId,
|
|
1536
|
+
resourceId,
|
|
1537
|
+
effectId,
|
|
1538
|
+
options.sourceEventId,
|
|
1539
|
+
options.runId,
|
|
1540
|
+
options.workflowRef,
|
|
1541
|
+
options.targetSessionId,
|
|
1542
|
+
options.cause,
|
|
1543
|
+
options.nodeId ?? null,
|
|
1544
|
+
options.attemptId ?? null,
|
|
1545
|
+
factsHash,
|
|
1546
|
+
now,
|
|
1547
|
+
options.eligible ? now : null,
|
|
1548
|
+
now,
|
|
1549
|
+
);
|
|
1550
|
+
this.insertEvent(
|
|
1551
|
+
resourceId,
|
|
1552
|
+
1,
|
|
1553
|
+
"turn.requested",
|
|
1554
|
+
"session",
|
|
1692
1555
|
options.targetSessionId,
|
|
1693
|
-
|
|
1694
|
-
nodeId,
|
|
1695
|
-
attemptId,
|
|
1696
|
-
factsJson,
|
|
1556
|
+
{},
|
|
1697
1557
|
now,
|
|
1698
|
-
eligibleAt,
|
|
1699
1558
|
);
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
throw new Error(`Workflow turn intent was not stored: ${options.intentId}`);
|
|
1703
|
-
}
|
|
1704
|
-
if (
|
|
1705
|
-
existing.sourceEventId !== options.sourceEventId ||
|
|
1706
|
-
existing.runId !== options.runId ||
|
|
1707
|
-
existing.workflowRef !== options.workflowRef ||
|
|
1708
|
-
existing.targetSessionId !== options.targetSessionId ||
|
|
1709
|
-
existing.cause !== options.cause ||
|
|
1710
|
-
existing.nodeId !== nodeId ||
|
|
1711
|
-
existing.attemptId !== attemptId
|
|
1712
|
-
) {
|
|
1713
|
-
throw new Error(`Workflow turn intent identity conflict: ${options.intentId}`);
|
|
1714
|
-
}
|
|
1715
|
-
return existing;
|
|
1559
|
+
});
|
|
1560
|
+
return this.requireTurnIntent(intentId);
|
|
1716
1561
|
}
|
|
1717
1562
|
|
|
1718
1563
|
getWorkflowTurnIntent(intentId: string): WorkflowTurnIntentRecord | undefined {
|
|
1719
|
-
|
|
1720
|
-
const row = this.database
|
|
1721
|
-
.prepare("SELECT * FROM workflow_turn_intents WHERE intent_id = ?")
|
|
1722
|
-
.get(intentId) as WorkflowTurnIntentRow | undefined;
|
|
1723
|
-
return row === undefined ? undefined : workflowTurnIntentFromRow(row);
|
|
1564
|
+
return this.turnIntent(intentId);
|
|
1724
1565
|
}
|
|
1725
1566
|
|
|
1726
1567
|
findPendingWorkflowTurnIntent(options: {
|
|
1727
1568
|
runId: string;
|
|
1728
1569
|
targetSessionId: string;
|
|
1729
1570
|
}): WorkflowTurnIntentRecord | undefined {
|
|
1730
|
-
|
|
1731
|
-
validateKey(options.targetSessionId, "workflow turn target session id");
|
|
1732
|
-
const row = this.database
|
|
1571
|
+
const row = this.state.connection
|
|
1733
1572
|
.prepare(
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1573
|
+
turnIntentSelect(
|
|
1574
|
+
"WHERE t.run_id = ? AND t.target_session_id = ? AND t.resolved_at IS NULL ORDER BY t.requested_at DESC LIMIT 1",
|
|
1575
|
+
),
|
|
1737
1576
|
)
|
|
1738
|
-
.get(options.runId, options.targetSessionId)
|
|
1739
|
-
return row
|
|
1577
|
+
.get(options.runId, options.targetSessionId);
|
|
1578
|
+
return isTurnIntentRow(row) ? this.mapTurnIntent(row) : undefined;
|
|
1740
1579
|
}
|
|
1741
1580
|
|
|
1742
1581
|
claimWorkflowTurnIntent(options: {
|
|
@@ -1746,101 +1585,60 @@ export class SqliteControllerStore implements ControllerStore {
|
|
|
1746
1585
|
leaseMs: number;
|
|
1747
1586
|
now?: string;
|
|
1748
1587
|
}): WorkflowTurnIntentRecord | undefined {
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
.
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
AND (delivery_claim_expires_at IS NULL OR delivery_claim_expires_at <= ?)`,
|
|
1760
|
-
)
|
|
1761
|
-
.run(
|
|
1762
|
-
options.claimToken,
|
|
1763
|
-
nowMs + options.leaseMs,
|
|
1764
|
-
options.intentId,
|
|
1765
|
-
options.targetSessionId,
|
|
1766
|
-
nowMs,
|
|
1767
|
-
);
|
|
1768
|
-
if (result.changes !== 1) return undefined;
|
|
1769
|
-
const row = this.database
|
|
1770
|
-
.prepare("SELECT * FROM workflow_turn_intents WHERE intent_id = ?")
|
|
1771
|
-
.get(options.intentId) as WorkflowTurnIntentRow;
|
|
1772
|
-
return workflowTurnIntentFromRow(row);
|
|
1588
|
+
const intent = this.turnIntent(options.intentId);
|
|
1589
|
+
if (intent?.targetSessionId !== options.targetSessionId) return undefined;
|
|
1590
|
+
return this.claimEffectForTurn(
|
|
1591
|
+
options.intentId,
|
|
1592
|
+
options.claimToken,
|
|
1593
|
+
options.leaseMs,
|
|
1594
|
+
options.now,
|
|
1595
|
+
)
|
|
1596
|
+
? this.requireTurnIntent(options.intentId)
|
|
1597
|
+
: undefined;
|
|
1773
1598
|
}
|
|
1774
1599
|
|
|
1775
1600
|
claimEligibleWorkflowTurnIntents(options: {
|
|
1776
1601
|
targetSessionId: string;
|
|
1777
1602
|
claimToken: string;
|
|
1778
1603
|
leaseMs: number;
|
|
1779
|
-
limit?: number;
|
|
1780
1604
|
now?: string;
|
|
1605
|
+
limit?: number;
|
|
1781
1606
|
}): WorkflowTurnIntentRecord[] {
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1607
|
+
const now = epoch(validTimestamp(options.now));
|
|
1608
|
+
const rows = this.state.connection
|
|
1609
|
+
.prepare(
|
|
1610
|
+
turnIntentSelect(
|
|
1611
|
+
"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 ?",
|
|
1612
|
+
),
|
|
1613
|
+
)
|
|
1614
|
+
.all(options.targetSessionId, now, options.limit ?? 10);
|
|
1615
|
+
const claimed: WorkflowTurnIntentRecord[] = [];
|
|
1616
|
+
for (const row of rows) {
|
|
1617
|
+
if (
|
|
1618
|
+
isTurnIntentRow(row) &&
|
|
1619
|
+
this.claimEffectForTurn(row.intentId, options.claimToken, options.leaseMs, options.now)
|
|
1620
|
+
) {
|
|
1621
|
+
claimed.push(this.requireTurnIntent(row.intentId));
|
|
1622
|
+
}
|
|
1788
1623
|
}
|
|
1789
|
-
|
|
1790
|
-
const nowMs = epoch(now);
|
|
1791
|
-
return this.transaction(() => {
|
|
1792
|
-
const ids = this.database
|
|
1793
|
-
.prepare(
|
|
1794
|
-
`SELECT intent_id FROM workflow_turn_intents
|
|
1795
|
-
WHERE target_session_id = ? AND resolved_at IS NULL AND eligible_at IS NOT NULL
|
|
1796
|
-
AND eligible_at <= ?
|
|
1797
|
-
AND (delivery_claim_expires_at IS NULL OR delivery_claim_expires_at <= ?)
|
|
1798
|
-
ORDER BY eligible_at, intent_id LIMIT ?`,
|
|
1799
|
-
)
|
|
1800
|
-
.all(options.targetSessionId, now, nowMs, limit) as { intent_id: string }[];
|
|
1801
|
-
if (ids.length === 0) return [];
|
|
1802
|
-
const placeholders = ids.map(() => "?").join(", ");
|
|
1803
|
-
this.database
|
|
1804
|
-
.prepare(
|
|
1805
|
-
`UPDATE workflow_turn_intents
|
|
1806
|
-
SET delivery_claim_token = ?, delivery_claim_expires_at = ?
|
|
1807
|
-
WHERE intent_id IN (${placeholders}) AND resolved_at IS NULL
|
|
1808
|
-
AND (delivery_claim_expires_at IS NULL OR delivery_claim_expires_at <= ?)`,
|
|
1809
|
-
)
|
|
1810
|
-
.run(
|
|
1811
|
-
options.claimToken,
|
|
1812
|
-
nowMs + options.leaseMs,
|
|
1813
|
-
...ids.map((row) => row.intent_id),
|
|
1814
|
-
nowMs,
|
|
1815
|
-
);
|
|
1816
|
-
const rows = this.database
|
|
1817
|
-
.prepare(
|
|
1818
|
-
`SELECT * FROM workflow_turn_intents WHERE delivery_claim_token = ?
|
|
1819
|
-
ORDER BY eligible_at, intent_id`,
|
|
1820
|
-
)
|
|
1821
|
-
.all(options.claimToken) as WorkflowTurnIntentRow[];
|
|
1822
|
-
return rows.map(workflowTurnIntentFromRow);
|
|
1823
|
-
});
|
|
1624
|
+
return claimed;
|
|
1824
1625
|
}
|
|
1825
1626
|
|
|
1826
1627
|
makeWorkflowTurnIntentEligible(options: {
|
|
1827
1628
|
intentId: string;
|
|
1828
1629
|
fallbackFacts: WorkflowTurnIntentFacts;
|
|
1630
|
+
eligibleAt?: string;
|
|
1829
1631
|
now?: string;
|
|
1830
1632
|
}): boolean {
|
|
1831
|
-
|
|
1832
|
-
const
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
validTimestamp(options.now),
|
|
1841
|
-
options.intentId,
|
|
1842
|
-
);
|
|
1843
|
-
return result.changes === 1;
|
|
1633
|
+
const eligibleAt = epoch(validTimestamp(options.eligibleAt ?? options.now));
|
|
1634
|
+
const factsHash = this.state.putJson(options.fallbackFacts, eligibleAt);
|
|
1635
|
+
return (
|
|
1636
|
+
this.state.connection
|
|
1637
|
+
.prepare(
|
|
1638
|
+
"UPDATE turn_intents SET eligible_at = ?, facts_hash = ? WHERE turn_intent_id = ? AND resolved_at IS NULL",
|
|
1639
|
+
)
|
|
1640
|
+
.run(eligibleAt, factsHash, options.intentId).changes === 1
|
|
1641
|
+
);
|
|
1844
1642
|
}
|
|
1845
1643
|
|
|
1846
1644
|
resolveWorkflowTurnIntent(options: {
|
|
@@ -1848,31 +1646,28 @@ export class SqliteControllerStore implements ControllerStore {
|
|
|
1848
1646
|
targetSessionId: string;
|
|
1849
1647
|
claimToken: string;
|
|
1850
1648
|
resolution: WorkflowTurnIntentResolution;
|
|
1851
|
-
messageId
|
|
1649
|
+
messageId?: string;
|
|
1852
1650
|
now?: string;
|
|
1853
1651
|
}): boolean {
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
options.resolution,
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
options.claimToken,
|
|
1874
|
-
);
|
|
1875
|
-
return result.changes === 1;
|
|
1652
|
+
const now = epoch(validTimestamp(options.now));
|
|
1653
|
+
return this.state.transaction(() => {
|
|
1654
|
+
const row = this.turnIntentRow(options.intentId);
|
|
1655
|
+
if (
|
|
1656
|
+
row === undefined ||
|
|
1657
|
+
row.targetSessionId !== options.targetSessionId ||
|
|
1658
|
+
!this.verifyEffectToken(row.effectId, options.claimToken, options.now)
|
|
1659
|
+
) {
|
|
1660
|
+
return false;
|
|
1661
|
+
}
|
|
1662
|
+
const result = this.state.connection
|
|
1663
|
+
.prepare(
|
|
1664
|
+
`UPDATE turn_intents SET resolved_at = ?, resolution_type = ?, resolution_message_id = ?
|
|
1665
|
+
WHERE turn_intent_id = ? AND resolved_at IS NULL`,
|
|
1666
|
+
)
|
|
1667
|
+
.run(now, options.resolution, options.messageId ?? null, options.intentId);
|
|
1668
|
+
if (result.changes === 1) this.completeEffect(row.effectId, "applied", now);
|
|
1669
|
+
return result.changes === 1;
|
|
1670
|
+
});
|
|
1876
1671
|
}
|
|
1877
1672
|
|
|
1878
1673
|
releaseWorkflowTurnIntentClaim(options: {
|
|
@@ -1880,53 +1675,40 @@ export class SqliteControllerStore implements ControllerStore {
|
|
|
1880
1675
|
targetSessionId: string;
|
|
1881
1676
|
claimToken: string;
|
|
1882
1677
|
}): boolean {
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
.
|
|
1888
|
-
|
|
1889
|
-
SET delivery_claim_token = NULL, delivery_claim_expires_at = NULL
|
|
1890
|
-
WHERE intent_id = ? AND target_session_id = ? AND resolved_at IS NULL
|
|
1891
|
-
AND delivery_claim_token = ?`,
|
|
1892
|
-
)
|
|
1893
|
-
.run(options.intentId, options.targetSessionId, options.claimToken);
|
|
1894
|
-
return result.changes === 1;
|
|
1678
|
+
const row = this.turnIntentRow(options.intentId);
|
|
1679
|
+
return (
|
|
1680
|
+
row !== undefined &&
|
|
1681
|
+
row.targetSessionId === options.targetSessionId &&
|
|
1682
|
+
this.releaseEffectLease(row.effectId, options.claimToken)
|
|
1683
|
+
);
|
|
1895
1684
|
}
|
|
1896
1685
|
|
|
1897
1686
|
listWorkflowTurnIntents(
|
|
1898
1687
|
options: {
|
|
1899
|
-
targetSessionId?: string;
|
|
1900
1688
|
runId?: string;
|
|
1689
|
+
targetSessionId?: string;
|
|
1690
|
+
unresolvedOnly?: boolean;
|
|
1901
1691
|
limit?: number;
|
|
1902
1692
|
} = {},
|
|
1903
1693
|
): WorkflowTurnIntentRecord[] {
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 100) {
|
|
1910
|
-
throw new Error("Workflow turn intent limit must be an integer from 1 through 100");
|
|
1694
|
+
const clauses = ["1 = 1"];
|
|
1695
|
+
const params: unknown[] = [];
|
|
1696
|
+
if (options.runId !== undefined) {
|
|
1697
|
+
clauses.push("t.run_id = ?");
|
|
1698
|
+
params.push(options.runId);
|
|
1911
1699
|
}
|
|
1912
|
-
const clauses: string[] = [];
|
|
1913
|
-
const values: unknown[] = [];
|
|
1914
1700
|
if (options.targetSessionId !== undefined) {
|
|
1915
|
-
clauses.push("target_session_id = ?");
|
|
1916
|
-
|
|
1701
|
+
clauses.push("t.target_session_id = ?");
|
|
1702
|
+
params.push(options.targetSessionId);
|
|
1917
1703
|
}
|
|
1918
|
-
if (options.
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
}
|
|
1922
|
-
const where = clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`;
|
|
1923
|
-
const rows = this.database
|
|
1704
|
+
if (options.unresolvedOnly === true) clauses.push("t.resolved_at IS NULL");
|
|
1705
|
+
params.push(options.limit ?? 100);
|
|
1706
|
+
const rows = this.state.connection
|
|
1924
1707
|
.prepare(
|
|
1925
|
-
`
|
|
1926
|
-
ORDER BY requested_at DESC, intent_id DESC LIMIT ?`,
|
|
1708
|
+
turnIntentSelect(`WHERE ${clauses.join(" AND ")} ORDER BY t.requested_at DESC LIMIT ?`),
|
|
1927
1709
|
)
|
|
1928
|
-
.all(...
|
|
1929
|
-
return rows.map(
|
|
1710
|
+
.all(...params);
|
|
1711
|
+
return rows.filter(isTurnIntentRow).map((row) => this.mapTurnIntent(row));
|
|
1930
1712
|
}
|
|
1931
1713
|
|
|
1932
1714
|
enqueueWorkflowNotification(options: {
|
|
@@ -1937,96 +1719,90 @@ export class SqliteControllerStore implements ControllerStore {
|
|
|
1937
1719
|
targetSessionId: string;
|
|
1938
1720
|
kind: "progress" | "final";
|
|
1939
1721
|
content: string;
|
|
1940
|
-
notificationId?: string;
|
|
1941
1722
|
now?: string;
|
|
1942
1723
|
}): WorkflowNotificationRecord {
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
notification_id, run_id, node_id, attempt_id, notification_index,
|
|
1959
|
-
target_session_id, kind, content, created_at, delivered_at
|
|
1960
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
|
1961
|
-
ON CONFLICT(run_id, node_id, notification_index) DO NOTHING`,
|
|
1962
|
-
)
|
|
1963
|
-
.run(
|
|
1724
|
+
const existing = this.notification(options.runId, options.attemptId, options.notificationIndex);
|
|
1725
|
+
if (existing !== undefined) return existing;
|
|
1726
|
+
const run = this.requireWorkflowRunRow(options.runId);
|
|
1727
|
+
const now = epoch(validTimestamp(options.now));
|
|
1728
|
+
const notificationId = `notification-${randomUUID()}`;
|
|
1729
|
+
const resourceId = resourceIdFor("notification", notificationId);
|
|
1730
|
+
const effectId = `effect-${randomUUID()}`;
|
|
1731
|
+
this.state.transaction(() => {
|
|
1732
|
+
const contentHash = this.state.putText(options.content, now);
|
|
1733
|
+
this.insertEffectResource(
|
|
1734
|
+
effectId,
|
|
1735
|
+
resourceIdFor("effect", effectId),
|
|
1736
|
+
run.resourceId,
|
|
1737
|
+
this.resourceRevision(run.resourceId),
|
|
1738
|
+
"notification.deliver",
|
|
1964
1739
|
notificationId,
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1740
|
+
contentHash,
|
|
1741
|
+
"run",
|
|
1742
|
+
now,
|
|
1743
|
+
);
|
|
1744
|
+
this.state.connection
|
|
1745
|
+
.prepare(
|
|
1746
|
+
"INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at) VALUES (?, 'notification', ?, 1, ?, ?)",
|
|
1747
|
+
)
|
|
1748
|
+
.run(resourceId, notificationId, now, now);
|
|
1749
|
+
this.state.connection
|
|
1750
|
+
.prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
|
|
1751
|
+
.run(resourceId);
|
|
1752
|
+
this.state.connection
|
|
1753
|
+
.prepare(
|
|
1754
|
+
`INSERT INTO notifications(
|
|
1755
|
+
notification_id, effect_id, run_id, attempt_id, notification_index,
|
|
1756
|
+
target_session_id, notification_type, content_hash, created_at
|
|
1757
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1758
|
+
)
|
|
1759
|
+
.run(
|
|
1760
|
+
notificationId,
|
|
1761
|
+
effectId,
|
|
1762
|
+
options.runId,
|
|
1763
|
+
options.attemptId,
|
|
1764
|
+
options.notificationIndex,
|
|
1765
|
+
options.targetSessionId,
|
|
1766
|
+
options.kind,
|
|
1767
|
+
contentHash,
|
|
1768
|
+
now,
|
|
1769
|
+
);
|
|
1770
|
+
this.insertEvent(
|
|
1771
|
+
resourceId,
|
|
1772
|
+
1,
|
|
1773
|
+
"notification.queued",
|
|
1774
|
+
"session",
|
|
1969
1775
|
options.targetSessionId,
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
validTimestamp(options.now),
|
|
1776
|
+
{},
|
|
1777
|
+
now,
|
|
1973
1778
|
);
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
"SELECT * FROM workflow_notifications WHERE run_id = ? AND node_id = ? AND notification_index = ?",
|
|
1977
|
-
)
|
|
1978
|
-
.get(options.runId, options.nodeId, options.notificationIndex) as WorkflowNotificationRow;
|
|
1979
|
-
return workflowNotificationFromRow(row);
|
|
1779
|
+
});
|
|
1780
|
+
return this.requireNotification(options.runId, options.attemptId, options.notificationIndex);
|
|
1980
1781
|
}
|
|
1981
1782
|
|
|
1982
1783
|
claimPendingWorkflowNotifications(options: {
|
|
1983
1784
|
targetSessionId: string;
|
|
1984
1785
|
claimToken: string;
|
|
1985
1786
|
leaseMs: number;
|
|
1986
|
-
limit?: number;
|
|
1987
1787
|
now?: string;
|
|
1788
|
+
limit?: number;
|
|
1988
1789
|
}): WorkflowNotificationRecord[] {
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
const now = validTimestamp(options.now);
|
|
1997
|
-
const nowMs = epoch(now);
|
|
1998
|
-
return this.transaction(() => {
|
|
1999
|
-
const ids = this.database
|
|
2000
|
-
.prepare(
|
|
2001
|
-
`SELECT notification_id FROM workflow_notifications
|
|
2002
|
-
WHERE target_session_id = ? AND delivered_at IS NULL
|
|
2003
|
-
AND (delivery_claim_expires_at IS NULL OR delivery_claim_expires_at <= ?)
|
|
2004
|
-
ORDER BY created_at, notification_id LIMIT ?`,
|
|
2005
|
-
)
|
|
2006
|
-
.all(options.targetSessionId, nowMs, limit) as { notification_id: string }[];
|
|
2007
|
-
if (ids.length === 0) return [];
|
|
2008
|
-
const placeholders = ids.map(() => "?").join(", ");
|
|
2009
|
-
this.database
|
|
2010
|
-
.prepare(
|
|
2011
|
-
`UPDATE workflow_notifications
|
|
2012
|
-
SET delivery_claim_token = ?, delivery_claim_expires_at = ?
|
|
2013
|
-
WHERE notification_id IN (${placeholders}) AND delivered_at IS NULL
|
|
2014
|
-
AND (delivery_claim_expires_at IS NULL OR delivery_claim_expires_at <= ?)`,
|
|
2015
|
-
)
|
|
2016
|
-
.run(
|
|
1790
|
+
const rows = this.notificationRows(options.targetSessionId, options.limit ?? 20);
|
|
1791
|
+
const result: WorkflowNotificationRecord[] = [];
|
|
1792
|
+
for (const row of rows) {
|
|
1793
|
+
if (
|
|
1794
|
+
this.claimEffect(
|
|
1795
|
+
row.effectId,
|
|
1796
|
+
options.targetSessionId,
|
|
2017
1797
|
options.claimToken,
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
nowMs,
|
|
2021
|
-
);
|
|
2022
|
-
const rows = this.database
|
|
2023
|
-
.prepare(
|
|
2024
|
-
`SELECT * FROM workflow_notifications WHERE delivery_claim_token = ?
|
|
2025
|
-
ORDER BY created_at, notification_id`,
|
|
1798
|
+
options.leaseMs,
|
|
1799
|
+
options.now,
|
|
2026
1800
|
)
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
1801
|
+
) {
|
|
1802
|
+
result.push(this.mapNotification(row));
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
return result;
|
|
2030
1806
|
}
|
|
2031
1807
|
|
|
2032
1808
|
markWorkflowNotificationDelivered(options: {
|
|
@@ -2035,530 +1811,1226 @@ export class SqliteControllerStore implements ControllerStore {
|
|
|
2035
1811
|
claimToken: string;
|
|
2036
1812
|
now?: string;
|
|
2037
1813
|
}): boolean {
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
1814
|
+
const now = epoch(validTimestamp(options.now));
|
|
1815
|
+
return this.state.transaction(() => {
|
|
1816
|
+
const row = this.notificationRowById(options.notificationId);
|
|
1817
|
+
if (
|
|
1818
|
+
row === undefined ||
|
|
1819
|
+
row.targetSessionId !== options.targetSessionId ||
|
|
1820
|
+
!this.verifyEffectToken(row.effectId, options.claimToken, options.now)
|
|
1821
|
+
) {
|
|
1822
|
+
return false;
|
|
1823
|
+
}
|
|
1824
|
+
this.completeEffect(row.effectId, "applied", now);
|
|
1825
|
+
return true;
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
settleRunEffect(runId: string, effectType: "run.park_queue" | "run.settle_queue"): void {
|
|
1830
|
+
const rows = this.state.connection
|
|
2042
1831
|
.prepare(
|
|
2043
|
-
`
|
|
2044
|
-
|
|
2045
|
-
WHERE
|
|
2046
|
-
AND delivery_claim_token = ? AND delivered_at IS NULL`,
|
|
1832
|
+
`SELECT e.effect_id AS effectId, e.resource_id AS resourceId
|
|
1833
|
+
FROM effects e JOIN runs r ON r.resource_id = e.source_resource_id
|
|
1834
|
+
WHERE r.run_id = ? AND e.effect_type = ? AND e.status = 'pending'`,
|
|
2047
1835
|
)
|
|
2048
|
-
.
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
1836
|
+
.all(runId, effectType);
|
|
1837
|
+
for (const row of rows) {
|
|
1838
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
1839
|
+
if (!isEffectIdentityRow(row)) continue;
|
|
1840
|
+
this.state.transaction(() => {
|
|
1841
|
+
const now = Date.now();
|
|
1842
|
+
const revision = this.resourceRevision(row.resourceId);
|
|
1843
|
+
this.state.connection
|
|
1844
|
+
.prepare(
|
|
1845
|
+
`UPDATE effects SET status = 'applied', updated_at = ?, settled_at = ?
|
|
1846
|
+
WHERE effect_id = ? AND status = 'pending'`,
|
|
1847
|
+
)
|
|
1848
|
+
.run(now, now, row.effectId);
|
|
1849
|
+
this.bumpResource(row.resourceId, revision, now);
|
|
1850
|
+
this.insertEvent(
|
|
1851
|
+
row.resourceId,
|
|
1852
|
+
revision + 1,
|
|
1853
|
+
"effect.applied",
|
|
1854
|
+
"system",
|
|
1855
|
+
null,
|
|
1856
|
+
{ runId, effectType },
|
|
1857
|
+
now,
|
|
1858
|
+
);
|
|
1859
|
+
});
|
|
1860
|
+
}
|
|
2055
1861
|
}
|
|
2056
1862
|
|
|
2057
1863
|
setWorkflowRunOriginSession(runId: string, originSessionId: string): boolean {
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
1864
|
+
const now = Date.now();
|
|
1865
|
+
return (
|
|
1866
|
+
this.state.connection
|
|
1867
|
+
.prepare(
|
|
1868
|
+
`INSERT INTO run_bindings(run_id, origin_session_id, execution_mode, created_at)
|
|
1869
|
+
VALUES (?, ?, 'interactive', ?)
|
|
1870
|
+
ON CONFLICT(run_id) DO UPDATE SET origin_session_id = excluded.origin_session_id`,
|
|
1871
|
+
)
|
|
1872
|
+
.run(runId, originSessionId, now).changes === 1
|
|
1873
|
+
);
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
private ensureProject(projectPath: string): string {
|
|
1877
|
+
const canonicalPath = canonicalProjectPath(projectPath);
|
|
1878
|
+
const projectId = projectIdFor(canonicalPath);
|
|
1879
|
+
this.state.connection
|
|
2061
1880
|
.prepare(
|
|
2062
|
-
`
|
|
2063
|
-
|
|
1881
|
+
`INSERT INTO projects(project_id, canonical_path, created_at)
|
|
1882
|
+
VALUES (?, ?, ?) ON CONFLICT(project_id) DO NOTHING`,
|
|
2064
1883
|
)
|
|
2065
|
-
.run(
|
|
2066
|
-
return
|
|
1884
|
+
.run(projectId, canonicalPath, Date.now());
|
|
1885
|
+
return projectId;
|
|
2067
1886
|
}
|
|
2068
1887
|
|
|
2069
|
-
private
|
|
2070
|
-
const
|
|
2071
|
-
|
|
2072
|
-
|
|
1888
|
+
private findProject(projectPath: string): string | null {
|
|
1889
|
+
const row = this.state.connection
|
|
1890
|
+
.prepare("SELECT project_id AS projectId FROM projects WHERE canonical_path = ?")
|
|
1891
|
+
.get(canonicalProjectPath(projectPath));
|
|
1892
|
+
return isProjectRow(row) ? row.projectId : null;
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
private requireProjectId(): string {
|
|
1896
|
+
if (this.projectId === null)
|
|
1897
|
+
throw new Error("Controller project is not registered in the state database");
|
|
1898
|
+
return this.projectId;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
private controllerRow(ref: ControllerResourceRef): ControllerRow | undefined {
|
|
1902
|
+
if (this.projectId === null) return undefined;
|
|
1903
|
+
const row = this.state.connection
|
|
1904
|
+
.prepare(
|
|
1905
|
+
controllerSelect("WHERE c.project_id = ? AND c.controller_name = ? AND c.resource_key = ?"),
|
|
1906
|
+
)
|
|
1907
|
+
.get(this.projectId, ref.controller, ref.key);
|
|
1908
|
+
return isControllerRow(row) ? row : undefined;
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
private requireControllerRow(ref: ControllerResourceRef): ControllerRow {
|
|
1912
|
+
const row = this.controllerRow(ref);
|
|
1913
|
+
if (row === undefined) throw new ResourceNotFoundError(ref.controller, ref.key);
|
|
1914
|
+
return row;
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
private requireControllerRowByUid(uid: string): ControllerRow {
|
|
1918
|
+
const row = this.state.connection.prepare(controllerSelect("WHERE c.uid = ?")).get(uid);
|
|
1919
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
1920
|
+
if (!isControllerRow(row)) throw new Error(`Controller resource not found: ${uid}`);
|
|
1921
|
+
return row;
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
private requireResource(ref: ControllerResourceRef): ControllerResource {
|
|
1925
|
+
const resource = this.getResource(ref);
|
|
1926
|
+
if (resource === undefined) throw new ResourceNotFoundError(ref.controller, ref.key);
|
|
1927
|
+
return resource;
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
/* istanbul ignore next -- pure projection covered by integration tests */
|
|
1931
|
+
private mapControllerResource(row: ControllerRow): ControllerResource {
|
|
1932
|
+
const spec = this.state.readJson(row.specHash);
|
|
1933
|
+
const status = this.state.readJson(row.statusHash) as ControllerResourceStatus<unknown>;
|
|
1934
|
+
return {
|
|
1935
|
+
metadata: {
|
|
1936
|
+
uid: row.uid,
|
|
1937
|
+
controller: row.controllerName,
|
|
1938
|
+
key: row.resourceKey,
|
|
1939
|
+
resourceVersion: row.resourceVersion,
|
|
1940
|
+
generation: row.generation,
|
|
1941
|
+
...(row.deletionRequestedAt === null
|
|
1942
|
+
? {}
|
|
1943
|
+
: { deletionTimestamp: new Date(row.deletionRequestedAt).toISOString() }),
|
|
1944
|
+
finalizers: this.finalizers(row.controllerResourceId),
|
|
1945
|
+
},
|
|
1946
|
+
spec,
|
|
1947
|
+
status,
|
|
1948
|
+
};
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
private finalizers(controllerResourceId: string): string[] {
|
|
1952
|
+
const rows = this.state.connection
|
|
1953
|
+
.prepare(
|
|
1954
|
+
"SELECT finalizer FROM controller_finalizers WHERE controller_resource_id = ? ORDER BY position",
|
|
1955
|
+
)
|
|
1956
|
+
.all(controllerResourceId);
|
|
1957
|
+
return rows.flatMap((row) => (isFinalizerRow(row) ? [row.finalizer] : []));
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
private replaceFinalizers(controllerResourceId: string, finalizers: string[]): void {
|
|
1961
|
+
const unique = [...new Set(finalizers)];
|
|
1962
|
+
if (unique.length !== finalizers.length)
|
|
1963
|
+
throw new Error("Controller finalizers must be unique");
|
|
1964
|
+
this.state.connection
|
|
1965
|
+
.prepare("DELETE FROM controller_finalizers WHERE controller_resource_id = ?")
|
|
1966
|
+
.run(controllerResourceId);
|
|
1967
|
+
const insert = this.state.connection.prepare(
|
|
1968
|
+
"INSERT INTO controller_finalizers(controller_resource_id, finalizer, position) VALUES (?, ?, ?)",
|
|
1969
|
+
);
|
|
1970
|
+
unique.forEach((finalizer, index) => insert.run(controllerResourceId, finalizer, index));
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
private enqueueControllerRow(
|
|
1974
|
+
controllerResourceId: string,
|
|
1975
|
+
availableAt: number,
|
|
1976
|
+
now: number,
|
|
1977
|
+
): void {
|
|
1978
|
+
this.state.connection
|
|
1979
|
+
.prepare(
|
|
1980
|
+
`INSERT INTO controller_queue(
|
|
1981
|
+
controller_resource_id, available_at, queue_version,
|
|
1982
|
+
consecutive_errors, created_at, updated_at
|
|
1983
|
+
) VALUES (?, ?, 1, 0, ?, ?)
|
|
1984
|
+
ON CONFLICT(controller_resource_id) DO UPDATE SET
|
|
1985
|
+
available_at = MIN(controller_queue.available_at, excluded.available_at),
|
|
1986
|
+
queue_version = controller_queue.queue_version + 1,
|
|
1987
|
+
updated_at = excluded.updated_at`,
|
|
1988
|
+
)
|
|
1989
|
+
.run(controllerResourceId, availableAt, now, now);
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
private queueErrors(controllerResourceId: string): number {
|
|
1993
|
+
const row = this.state.connection
|
|
1994
|
+
.prepare(
|
|
1995
|
+
"SELECT consecutive_errors AS consecutiveErrors FROM controller_queue WHERE controller_resource_id = ?",
|
|
1996
|
+
)
|
|
1997
|
+
.get(controllerResourceId);
|
|
1998
|
+
return isErrorCountRow(row) ? row.consecutiveErrors : 0;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
private queueVersion(controllerResourceId: string): number {
|
|
2002
|
+
const row = this.state.connection
|
|
2003
|
+
.prepare(
|
|
2004
|
+
"SELECT queue_version AS queueVersion FROM controller_queue WHERE controller_resource_id = ?",
|
|
2005
|
+
)
|
|
2006
|
+
.get(controllerResourceId);
|
|
2007
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
2008
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
2009
|
+
if (!isQueueVersionRow(row)) throw new Error("Controller queue item is missing");
|
|
2010
|
+
return row.queueVersion;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
private settleControllerClaim(
|
|
2014
|
+
claim: ControllerQueueClaim,
|
|
2015
|
+
requeue: QueueRequeueOptions | undefined,
|
|
2016
|
+
nowValue?: string,
|
|
2017
|
+
): boolean {
|
|
2018
|
+
const now = epoch(validTimestamp(nowValue));
|
|
2019
|
+
return this.state.transaction(() => {
|
|
2020
|
+
const row = this.controllerRow({ controller: claim.controller, key: claim.key });
|
|
2021
|
+
if (row === undefined) return false;
|
|
2022
|
+
try {
|
|
2023
|
+
this.assertControllerClaim(row, claim, now, false);
|
|
2024
|
+
} catch {
|
|
2025
|
+
return false;
|
|
2026
|
+
}
|
|
2027
|
+
const currentQueueVersion = this.queueVersion(row.controllerResourceId);
|
|
2028
|
+
if (requeue === undefined) {
|
|
2029
|
+
if (currentQueueVersion === claim.queueVersion) {
|
|
2030
|
+
this.state.connection
|
|
2031
|
+
.prepare("DELETE FROM controller_queue WHERE controller_resource_id = ?")
|
|
2032
|
+
.run(row.controllerResourceId);
|
|
2033
|
+
}
|
|
2034
|
+
} else {
|
|
2035
|
+
const errorHash =
|
|
2036
|
+
requeue.error === undefined ? null : this.state.putText(requeue.error, now);
|
|
2037
|
+
this.state.connection
|
|
2038
|
+
.prepare(
|
|
2039
|
+
`UPDATE controller_queue
|
|
2040
|
+
SET available_at = ?, queue_version = queue_version + 1,
|
|
2041
|
+
consecutive_errors = consecutive_errors + 1,
|
|
2042
|
+
last_error_hash = ?, updated_at = ? WHERE controller_resource_id = ?`,
|
|
2043
|
+
)
|
|
2044
|
+
.run(epoch(requeue.availableAt), errorHash, now, row.controllerResourceId);
|
|
2045
|
+
}
|
|
2046
|
+
this.releaseLease(row.resourceId, claim, now);
|
|
2047
|
+
return true;
|
|
2048
|
+
});
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
private assertControllerClaim(
|
|
2052
|
+
row: ControllerRow,
|
|
2053
|
+
claim: ControllerQueueClaim,
|
|
2054
|
+
now: number,
|
|
2055
|
+
requireUnexpired = true,
|
|
2056
|
+
): void {
|
|
2057
|
+
if (row.controllerName !== claim.controller || row.resourceKey !== claim.key)
|
|
2058
|
+
throw new Error("Controller claim targets another resource");
|
|
2059
|
+
const lease = this.requireLease(row.resourceId);
|
|
2060
|
+
if (
|
|
2061
|
+
lease.ownerType !== "controller" ||
|
|
2062
|
+
lease.ownerId !== claim.ownerId ||
|
|
2063
|
+
lease.generation !== claim.generation ||
|
|
2064
|
+
lease.tokenHash === null ||
|
|
2065
|
+
!lease.tokenHash.equals(tokenHash(claim.token)) ||
|
|
2066
|
+
(requireUnexpired && (lease.expiresAt === null || lease.expiresAt <= now))
|
|
2067
|
+
) {
|
|
2068
|
+
throw new Error("Controller claim is stale");
|
|
2073
2069
|
}
|
|
2074
|
-
return record;
|
|
2075
2070
|
}
|
|
2076
2071
|
|
|
2077
|
-
private
|
|
2078
|
-
const row = this.
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2072
|
+
private requireLease(resourceId: string): LeaseRow {
|
|
2073
|
+
const row = this.state.connection
|
|
2074
|
+
.prepare(
|
|
2075
|
+
`SELECT generation, owner_type AS ownerType, owner_id AS ownerId,
|
|
2076
|
+
token_hash AS tokenHash, expires_at AS expiresAt
|
|
2077
|
+
FROM leases WHERE resource_id = ?`,
|
|
2078
|
+
)
|
|
2079
|
+
.get(resourceId);
|
|
2080
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
2081
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
2082
|
+
if (!isLeaseRow(row)) throw new Error(`Lease is missing: ${resourceId}`);
|
|
2083
|
+
return row;
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
private releaseLease(resourceId: string, claim: ControllerQueueClaim, now: number): void {
|
|
2087
|
+
const row = this.controllerRow({ controller: claim.controller, key: claim.key });
|
|
2088
|
+
if (row === undefined) return;
|
|
2089
|
+
const revision = this.resourceRevision(resourceId);
|
|
2090
|
+
const result = this.state.connection
|
|
2091
|
+
.prepare(
|
|
2092
|
+
`UPDATE leases SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
|
|
2093
|
+
acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
|
|
2094
|
+
WHERE resource_id = ? AND owner_id = ? AND token_hash = ? AND generation = ?`,
|
|
2095
|
+
)
|
|
2096
|
+
.run(resourceId, claim.ownerId, tokenHash(claim.token), claim.generation);
|
|
2097
|
+
if (result.changes === 1) {
|
|
2098
|
+
this.bumpResource(resourceId, revision, now);
|
|
2099
|
+
this.insertEvent(
|
|
2100
|
+
resourceId,
|
|
2101
|
+
revision + 1,
|
|
2102
|
+
"lease.released",
|
|
2103
|
+
"controller",
|
|
2104
|
+
claim.ownerId,
|
|
2105
|
+
{},
|
|
2106
|
+
now,
|
|
2107
|
+
claim.generation,
|
|
2108
|
+
);
|
|
2109
|
+
claim.resourceVersion = revision + 1;
|
|
2083
2110
|
}
|
|
2084
|
-
return eventFromRow(row);
|
|
2085
2111
|
}
|
|
2086
|
-
}
|
|
2087
2112
|
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
deletion_timestamp TEXT,
|
|
2098
|
-
finalizers_json TEXT NOT NULL,
|
|
2099
|
-
updated_at TEXT NOT NULL,
|
|
2100
|
-
PRIMARY KEY (controller, resource_key)
|
|
2101
|
-
);
|
|
2102
|
-
|
|
2103
|
-
CREATE TABLE IF NOT EXISTS queue (
|
|
2104
|
-
controller TEXT NOT NULL,
|
|
2105
|
-
resource_key TEXT NOT NULL,
|
|
2106
|
-
available_at INTEGER NOT NULL,
|
|
2107
|
-
version INTEGER NOT NULL CHECK (version > 0),
|
|
2108
|
-
consecutive_errors INTEGER NOT NULL CHECK (consecutive_errors >= 0),
|
|
2109
|
-
last_error TEXT,
|
|
2110
|
-
claim_token TEXT,
|
|
2111
|
-
claim_version INTEGER,
|
|
2112
|
-
claim_expires_at INTEGER,
|
|
2113
|
-
PRIMARY KEY (controller, resource_key),
|
|
2114
|
-
FOREIGN KEY (controller, resource_key)
|
|
2115
|
-
REFERENCES resources(controller, resource_key) ON DELETE CASCADE
|
|
2116
|
-
);
|
|
2117
|
-
CREATE INDEX IF NOT EXISTS queue_ready
|
|
2118
|
-
ON queue(available_at, controller, resource_key);
|
|
2119
|
-
|
|
2120
|
-
CREATE TABLE IF NOT EXISTS workflow_run_queue (
|
|
2121
|
-
run_id TEXT PRIMARY KEY,
|
|
2122
|
-
workflow_ref TEXT NOT NULL,
|
|
2123
|
-
workflow_path TEXT NOT NULL,
|
|
2124
|
-
workflow_source_json TEXT NOT NULL,
|
|
2125
|
-
definition_digest TEXT NOT NULL,
|
|
2126
|
-
input_json TEXT NOT NULL,
|
|
2127
|
-
launch_options_json TEXT NOT NULL,
|
|
2128
|
-
status TEXT NOT NULL CHECK (
|
|
2129
|
-
status IN ('queued', 'starting', 'running', 'parked', 'done', 'failed', 'cancelled')
|
|
2130
|
-
),
|
|
2131
|
-
runner_id TEXT,
|
|
2132
|
-
claim_token TEXT,
|
|
2133
|
-
claim_expires_at INTEGER,
|
|
2134
|
-
affinity_runner_id TEXT,
|
|
2135
|
-
origin_session_id TEXT,
|
|
2136
|
-
parent_run_id TEXT,
|
|
2137
|
-
error_code TEXT,
|
|
2138
|
-
error_message TEXT,
|
|
2139
|
-
created_at TEXT NOT NULL,
|
|
2140
|
-
updated_at TEXT NOT NULL,
|
|
2141
|
-
started_at TEXT,
|
|
2142
|
-
finished_at TEXT
|
|
2143
|
-
);
|
|
2144
|
-
CREATE INDEX IF NOT EXISTS workflow_run_queue_claimable
|
|
2145
|
-
ON workflow_run_queue(status, claim_expires_at);
|
|
2146
|
-
CREATE UNIQUE INDEX IF NOT EXISTS workflow_run_queue_session_reservation
|
|
2147
|
-
ON workflow_run_queue(origin_session_id)
|
|
2148
|
-
WHERE origin_session_id IS NOT NULL AND status IN ('queued', 'starting', 'running');
|
|
2149
|
-
-- A checkpointed parent admits exactly one continuation run, across
|
|
2150
|
-
-- sessions and processes.
|
|
2151
|
-
CREATE UNIQUE INDEX IF NOT EXISTS workflow_run_queue_parent
|
|
2152
|
-
ON workflow_run_queue(parent_run_id) WHERE parent_run_id IS NOT NULL;
|
|
2153
|
-
|
|
2154
|
-
CREATE TABLE IF NOT EXISTS run_events (
|
|
2155
|
-
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2156
|
-
recorded_at TEXT NOT NULL,
|
|
2157
|
-
run_id TEXT NOT NULL,
|
|
2158
|
-
workflow_ref TEXT NOT NULL,
|
|
2159
|
-
type TEXT NOT NULL,
|
|
2160
|
-
runner_id TEXT,
|
|
2161
|
-
payload_json TEXT NOT NULL
|
|
2162
|
-
);
|
|
2163
|
-
CREATE INDEX IF NOT EXISTS run_events_run ON run_events(run_id);
|
|
2164
|
-
|
|
2165
|
-
CREATE TABLE IF NOT EXISTS workflow_notifications (
|
|
2166
|
-
notification_id TEXT PRIMARY KEY,
|
|
2167
|
-
run_id TEXT NOT NULL,
|
|
2168
|
-
node_id TEXT NOT NULL,
|
|
2169
|
-
attempt_id TEXT NOT NULL,
|
|
2170
|
-
notification_index INTEGER NOT NULL CHECK (notification_index > 0),
|
|
2171
|
-
target_session_id TEXT NOT NULL,
|
|
2172
|
-
kind TEXT NOT NULL CHECK (kind IN ('progress', 'final')),
|
|
2173
|
-
content TEXT NOT NULL,
|
|
2174
|
-
created_at TEXT NOT NULL,
|
|
2175
|
-
delivery_claim_token TEXT,
|
|
2176
|
-
delivery_claim_expires_at INTEGER,
|
|
2177
|
-
delivered_at TEXT,
|
|
2178
|
-
UNIQUE (run_id, node_id, notification_index)
|
|
2179
|
-
);
|
|
2180
|
-
CREATE INDEX IF NOT EXISTS workflow_notifications_pending
|
|
2181
|
-
ON workflow_notifications(target_session_id, delivery_claim_expires_at, created_at)
|
|
2182
|
-
WHERE delivered_at IS NULL;
|
|
2183
|
-
|
|
2184
|
-
CREATE TABLE IF NOT EXISTS workflow_turn_intents (
|
|
2185
|
-
intent_id TEXT PRIMARY KEY,
|
|
2186
|
-
source_event_id TEXT NOT NULL,
|
|
2187
|
-
run_id TEXT NOT NULL,
|
|
2188
|
-
workflow_ref TEXT NOT NULL,
|
|
2189
|
-
target_session_id TEXT NOT NULL,
|
|
2190
|
-
cause TEXT NOT NULL CHECK (
|
|
2191
|
-
cause IN ('agentCancelled', 'timedOut', 'failed', 'launchFailed',
|
|
2192
|
-
'controllerInterrupted', 'claimLost')
|
|
2193
|
-
),
|
|
2194
|
-
node_id TEXT,
|
|
2195
|
-
attempt_id TEXT,
|
|
2196
|
-
fallback_facts_json TEXT NOT NULL,
|
|
2197
|
-
requested_at TEXT NOT NULL,
|
|
2198
|
-
eligible_at TEXT,
|
|
2199
|
-
resolved_at TEXT,
|
|
2200
|
-
resolution TEXT CHECK (resolution IN ('workflowPrompt', 'presentation', 'fallback')),
|
|
2201
|
-
resolution_message_id TEXT,
|
|
2202
|
-
delivery_claim_token TEXT,
|
|
2203
|
-
delivery_claim_expires_at INTEGER,
|
|
2204
|
-
CHECK (
|
|
2205
|
-
(resolved_at IS NULL AND resolution IS NULL AND resolution_message_id IS NULL) OR
|
|
2206
|
-
(resolved_at IS NOT NULL AND resolution IS NOT NULL AND resolution_message_id IS NOT NULL)
|
|
2207
|
-
)
|
|
2208
|
-
);
|
|
2209
|
-
CREATE UNIQUE INDEX IF NOT EXISTS workflow_turn_intents_one_pending_run
|
|
2210
|
-
ON workflow_turn_intents(run_id, target_session_id) WHERE resolved_at IS NULL;
|
|
2211
|
-
CREATE INDEX IF NOT EXISTS workflow_turn_intents_pending_run
|
|
2212
|
-
ON workflow_turn_intents(run_id, target_session_id, requested_at)
|
|
2213
|
-
WHERE resolved_at IS NULL;
|
|
2214
|
-
CREATE INDEX IF NOT EXISTS workflow_turn_intents_eligible_session
|
|
2215
|
-
ON workflow_turn_intents(target_session_id, eligible_at, intent_id)
|
|
2216
|
-
WHERE resolved_at IS NULL AND eligible_at IS NOT NULL;
|
|
2217
|
-
|
|
2218
|
-
CREATE TABLE IF NOT EXISTS effects (
|
|
2219
|
-
effect_key TEXT NOT NULL,
|
|
2220
|
-
resource_uid TEXT NOT NULL,
|
|
2221
|
-
generation INTEGER NOT NULL CHECK (generation > 0),
|
|
2222
|
-
kind TEXT NOT NULL,
|
|
2223
|
-
state TEXT NOT NULL CHECK (state IN ('pending', 'applied', 'rejected', 'indeterminate')),
|
|
2224
|
-
request_fingerprint TEXT NOT NULL,
|
|
2225
|
-
started_at TEXT NOT NULL,
|
|
2226
|
-
completed_at TEXT,
|
|
2227
|
-
external_ref TEXT,
|
|
2228
|
-
error TEXT,
|
|
2229
|
-
PRIMARY KEY (resource_uid, effect_key),
|
|
2230
|
-
FOREIGN KEY (resource_uid) REFERENCES resources(uid) ON DELETE CASCADE
|
|
2231
|
-
);
|
|
2232
|
-
|
|
2233
|
-
CREATE TABLE IF NOT EXISTS workflow_requests (
|
|
2234
|
-
request_id TEXT PRIMARY KEY,
|
|
2235
|
-
resource_uid TEXT NOT NULL,
|
|
2236
|
-
request_key TEXT NOT NULL,
|
|
2237
|
-
input_fingerprint TEXT NOT NULL,
|
|
2238
|
-
workflow TEXT NOT NULL,
|
|
2239
|
-
run_id TEXT,
|
|
2240
|
-
state TEXT NOT NULL CHECK (
|
|
2241
|
-
state IN ('pending', 'running', 'waiting', 'succeeded', 'failed', 'interrupted')
|
|
2242
|
-
),
|
|
2243
|
-
attempt INTEGER NOT NULL CHECK (attempt >= 0),
|
|
2244
|
-
error TEXT,
|
|
2245
|
-
UNIQUE (resource_uid, request_key),
|
|
2246
|
-
FOREIGN KEY (resource_uid) REFERENCES resources(uid) ON DELETE CASCADE
|
|
2247
|
-
);
|
|
2248
|
-
|
|
2249
|
-
CREATE TABLE IF NOT EXISTS events (
|
|
2250
|
-
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2251
|
-
recorded_at TEXT NOT NULL,
|
|
2252
|
-
controller TEXT NOT NULL,
|
|
2253
|
-
resource_key TEXT NOT NULL,
|
|
2254
|
-
type TEXT NOT NULL,
|
|
2255
|
-
payload_json TEXT NOT NULL
|
|
2256
|
-
);
|
|
2257
|
-
CREATE INDEX IF NOT EXISTS events_resource
|
|
2258
|
-
ON events(controller, resource_key, seq DESC);
|
|
2259
|
-
`;
|
|
2260
|
-
|
|
2261
|
-
function resourceFromRow<TSpec, TStatus>(row: ResourceRow): ControllerResource<TSpec, TStatus> {
|
|
2262
|
-
const finalizers = parseStoredJson<string[]>(row.finalizers_json, "resource finalizers");
|
|
2263
|
-
validateFinalizers(finalizers);
|
|
2264
|
-
return {
|
|
2265
|
-
metadata: {
|
|
2266
|
-
uid: row.uid,
|
|
2267
|
-
controller: row.controller,
|
|
2268
|
-
key: row.resource_key,
|
|
2269
|
-
resourceVersion: row.resource_version,
|
|
2270
|
-
generation: row.generation,
|
|
2271
|
-
...(row.deletion_timestamp === null ? {} : { deletionTimestamp: row.deletion_timestamp }),
|
|
2272
|
-
finalizers,
|
|
2273
|
-
},
|
|
2274
|
-
spec: parseStoredJson<TSpec>(row.spec_json, "resource spec"),
|
|
2275
|
-
status: parseStoredJson<ControllerResourceStatus<TStatus>>(row.status_json, "resource status"),
|
|
2276
|
-
};
|
|
2277
|
-
}
|
|
2113
|
+
private resourceRevision(resourceId: string): number {
|
|
2114
|
+
const row = this.state.connection
|
|
2115
|
+
.prepare("SELECT revision FROM resources WHERE resource_id = ?")
|
|
2116
|
+
.get(resourceId);
|
|
2117
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
2118
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
2119
|
+
if (!isRevisionRow(row)) throw new Error(`Resource is missing: ${resourceId}`);
|
|
2120
|
+
return row.revision;
|
|
2121
|
+
}
|
|
2278
2122
|
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2123
|
+
private bumpResource(resourceId: string, expectedRevision: number, now: number): void {
|
|
2124
|
+
const result = this.state.connection
|
|
2125
|
+
.prepare(
|
|
2126
|
+
"UPDATE resources SET revision = revision + 1, updated_at = ? WHERE resource_id = ? AND revision = ?",
|
|
2127
|
+
)
|
|
2128
|
+
.run(now, resourceId, expectedRevision);
|
|
2129
|
+
if (result.changes !== 1) throw new Error("Resource revision conflict");
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
private insertEvent(
|
|
2133
|
+
resourceId: string,
|
|
2134
|
+
revision: number,
|
|
2135
|
+
type: string,
|
|
2136
|
+
actorType: string,
|
|
2137
|
+
actorId: string | null,
|
|
2138
|
+
payload: unknown,
|
|
2139
|
+
now: number,
|
|
2140
|
+
leaseGeneration?: number,
|
|
2141
|
+
): string {
|
|
2142
|
+
const eventId = `event-${randomUUID()}`;
|
|
2143
|
+
const payloadHash = this.state.putJson(payload, now);
|
|
2144
|
+
this.state.connection
|
|
2145
|
+
.prepare(
|
|
2146
|
+
`INSERT INTO events(
|
|
2147
|
+
event_id, resource_id, resource_revision, event_type, actor_type,
|
|
2148
|
+
actor_id, lease_generation, payload_hash, recorded_at
|
|
2149
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
2150
|
+
)
|
|
2151
|
+
.run(
|
|
2152
|
+
eventId,
|
|
2153
|
+
resourceId,
|
|
2154
|
+
revision,
|
|
2155
|
+
type,
|
|
2156
|
+
actorType,
|
|
2157
|
+
actorId,
|
|
2158
|
+
leaseGeneration ?? null,
|
|
2159
|
+
payloadHash,
|
|
2160
|
+
now,
|
|
2161
|
+
);
|
|
2162
|
+
return eventId;
|
|
2163
|
+
}
|
|
2293
2164
|
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
workflow: row.workflow,
|
|
2301
|
-
state: row.state,
|
|
2302
|
-
attempt: row.attempt,
|
|
2303
|
-
...(row.run_id === null ? {} : { runId: row.run_id }),
|
|
2304
|
-
...(row.error === null ? {} : { error: row.error }),
|
|
2305
|
-
};
|
|
2306
|
-
}
|
|
2165
|
+
private effectRow(resourceUid: string, key: string): EffectRow | undefined {
|
|
2166
|
+
const row = this.state.connection
|
|
2167
|
+
.prepare(effectSelect("WHERE c.uid = ? AND e.idempotency_key = ?"))
|
|
2168
|
+
.get(resourceUid, key);
|
|
2169
|
+
return isEffectRow(row) ? row : undefined;
|
|
2170
|
+
}
|
|
2307
2171
|
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
row.fallback_facts_json,
|
|
2314
|
-
"workflow turn intent facts",
|
|
2315
|
-
);
|
|
2316
|
-
validateWorkflowTurnIntentFacts(fallbackFacts);
|
|
2317
|
-
validateKey(row.intent_id, "workflow turn intent id");
|
|
2318
|
-
validateKey(row.source_event_id, "workflow turn source event id");
|
|
2319
|
-
validateRunId(row.run_id);
|
|
2320
|
-
validateKey(row.workflow_ref, "workflow turn workflow ref");
|
|
2321
|
-
validateKey(row.target_session_id, "workflow turn target session id");
|
|
2322
|
-
validTimestamp(row.requested_at);
|
|
2323
|
-
if (row.eligible_at !== null) validTimestamp(row.eligible_at);
|
|
2324
|
-
if (row.resolved_at !== null) validTimestamp(row.resolved_at);
|
|
2325
|
-
const allResolutionFieldsNull =
|
|
2326
|
-
row.resolved_at === null && row.resolution === null && row.resolution_message_id === null;
|
|
2327
|
-
const allResolutionFieldsSet =
|
|
2328
|
-
row.resolved_at !== null && row.resolution !== null && row.resolution_message_id !== null;
|
|
2329
|
-
if (!allResolutionFieldsNull && !allResolutionFieldsSet) {
|
|
2330
|
-
throw new Error(`Workflow turn intent ${row.intent_id} has inconsistent resolution fields`);
|
|
2331
|
-
}
|
|
2332
|
-
return {
|
|
2333
|
-
intentId: row.intent_id,
|
|
2334
|
-
sourceEventId: row.source_event_id,
|
|
2335
|
-
runId: row.run_id,
|
|
2336
|
-
workflowRef: row.workflow_ref,
|
|
2337
|
-
targetSessionId: row.target_session_id,
|
|
2338
|
-
cause,
|
|
2339
|
-
nodeId: row.node_id,
|
|
2340
|
-
attemptId: row.attempt_id,
|
|
2341
|
-
fallbackFacts,
|
|
2342
|
-
requestedAt: row.requested_at,
|
|
2343
|
-
eligibleAt: row.eligible_at,
|
|
2344
|
-
resolvedAt: row.resolved_at,
|
|
2345
|
-
resolution,
|
|
2346
|
-
resolutionMessageId: row.resolution_message_id,
|
|
2347
|
-
deliveryClaimExpiresAt:
|
|
2348
|
-
row.delivery_claim_expires_at === null ? null : iso(row.delivery_claim_expires_at),
|
|
2349
|
-
};
|
|
2350
|
-
}
|
|
2172
|
+
private requireEffectRow(resourceUid: string, key: string): EffectRow {
|
|
2173
|
+
const row = this.effectRow(resourceUid, key);
|
|
2174
|
+
if (row === undefined) throw new Error(`Effect record not found: ${resourceUid}/${key}`);
|
|
2175
|
+
return row;
|
|
2176
|
+
}
|
|
2351
2177
|
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2178
|
+
/* istanbul ignore next -- pure projection covered by integration tests */
|
|
2179
|
+
private mapEffect(row: EffectRow): EffectRecord {
|
|
2180
|
+
return {
|
|
2181
|
+
key: this.effectKey(row.effectId),
|
|
2182
|
+
resourceUid: row.resourceUid,
|
|
2183
|
+
generation: row.generation,
|
|
2184
|
+
kind: row.kind,
|
|
2185
|
+
state: controllerEffectState(row.state),
|
|
2186
|
+
requestFingerprint: row.requestFingerprint,
|
|
2187
|
+
startedAt: new Date(row.startedAt).toISOString(),
|
|
2188
|
+
...(row.completedAt === null ? {} : { completedAt: new Date(row.completedAt).toISOString() }),
|
|
2189
|
+
...(row.externalRef === null ? {} : { externalRef: row.externalRef }),
|
|
2190
|
+
...(row.errorHash === null
|
|
2191
|
+
? {}
|
|
2192
|
+
: { error: this.state.readBlob(row.errorHash)?.content.toString("utf8") ?? "" }),
|
|
2193
|
+
};
|
|
2357
2194
|
}
|
|
2358
|
-
return {
|
|
2359
|
-
notificationId: row.notification_id,
|
|
2360
|
-
runId: row.run_id,
|
|
2361
|
-
nodeId: row.node_id,
|
|
2362
|
-
attemptId: row.attempt_id,
|
|
2363
|
-
notificationIndex: row.notification_index,
|
|
2364
|
-
targetSessionId: row.target_session_id,
|
|
2365
|
-
kind: row.kind,
|
|
2366
|
-
content: row.content,
|
|
2367
|
-
createdAt: row.created_at,
|
|
2368
|
-
deliveryClaimExpiresAt:
|
|
2369
|
-
row.delivery_claim_expires_at === null ? null : iso(row.delivery_claim_expires_at),
|
|
2370
|
-
deliveredAt: row.delivered_at,
|
|
2371
|
-
};
|
|
2372
|
-
}
|
|
2373
2195
|
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2196
|
+
private effectKey(effectId: string): string {
|
|
2197
|
+
const row = this.state.connection
|
|
2198
|
+
.prepare("SELECT idempotency_key AS key FROM effects WHERE effect_id = ?")
|
|
2199
|
+
.get(effectId);
|
|
2200
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
2201
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
2202
|
+
if (!isKeyRow(row)) throw new Error(`Effect is missing: ${effectId}`);
|
|
2203
|
+
return row.key;
|
|
2204
|
+
}
|
|
2380
2205
|
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2206
|
+
private workflowRow(resourceUid: string, requestKey: string): WorkflowRow | undefined {
|
|
2207
|
+
const row = this.state.connection
|
|
2208
|
+
.prepare(workflowSelect("WHERE c.uid = ? AND w.request_key = ?"))
|
|
2209
|
+
.get(resourceUid, requestKey);
|
|
2210
|
+
return isWorkflowRow(row) ? row : undefined;
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
private workflowRowById(requestId: string): WorkflowRow | undefined {
|
|
2214
|
+
const row = this.state.connection
|
|
2215
|
+
.prepare(workflowSelect("WHERE w.request_id = ?"))
|
|
2216
|
+
.get(requestId);
|
|
2217
|
+
return isWorkflowRow(row) ? row : undefined;
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
private requireWorkflowRow(requestId: string): WorkflowRow {
|
|
2221
|
+
const row = this.workflowRowById(requestId);
|
|
2222
|
+
if (row === undefined) throw new Error(`Workflow request not found: ${requestId}`);
|
|
2223
|
+
return row;
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
/* istanbul ignore next -- pure projection covered by integration tests */
|
|
2227
|
+
private mapWorkflow(row: WorkflowRow): ChildWorkflowRecord {
|
|
2228
|
+
return {
|
|
2229
|
+
requestId: row.requestId,
|
|
2230
|
+
resourceUid: row.resourceUid,
|
|
2231
|
+
requestKey: row.requestKey,
|
|
2232
|
+
inputFingerprint: row.inputFingerprint.toString("hex"),
|
|
2233
|
+
workflow: row.workflowName,
|
|
2234
|
+
...(row.runId === null ? {} : { runId: row.runId }),
|
|
2235
|
+
state: row.status,
|
|
2236
|
+
attempt: row.attemptCount,
|
|
2237
|
+
...(row.errorHash === null
|
|
2238
|
+
? {}
|
|
2239
|
+
: { error: this.state.readBlob(row.errorHash)?.content.toString("utf8") ?? "" }),
|
|
2240
|
+
};
|
|
2384
2241
|
}
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2242
|
+
|
|
2243
|
+
private workflowRunRow(runId: string): RunRow | undefined {
|
|
2244
|
+
const row = this.state.connection.prepare(workflowRunSelect("WHERE r.run_id = ?")).get(runId);
|
|
2245
|
+
return isRunRow(row) ? row : undefined;
|
|
2388
2246
|
}
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
"cause",
|
|
2395
|
-
"nodeId",
|
|
2396
|
-
"attemptId",
|
|
2397
|
-
"reason",
|
|
2398
|
-
"handoff",
|
|
2399
|
-
]);
|
|
2400
|
-
for (const key of Object.keys(candidate)) {
|
|
2401
|
-
if (!allowedKeys.has(key)) throw new Error(`Unknown workflow turn intent facts field: ${key}`);
|
|
2247
|
+
|
|
2248
|
+
private requireWorkflowRunRow(runId: string): RunRow {
|
|
2249
|
+
const row = this.workflowRunRow(runId);
|
|
2250
|
+
if (row === undefined) throw new Error(`Workflow run not found: ${runId}`);
|
|
2251
|
+
return row;
|
|
2402
2252
|
}
|
|
2403
|
-
|
|
2404
|
-
|
|
2253
|
+
|
|
2254
|
+
private requireWorkflowRun(runId: string): WorkflowRunQueueRecord {
|
|
2255
|
+
return this.mapWorkflowRun(this.requireWorkflowRunRow(runId));
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
/* istanbul ignore next -- pure projection covered by integration tests */
|
|
2259
|
+
private mapWorkflowRun(row: RunRow): WorkflowRunQueueRecord {
|
|
2260
|
+
return {
|
|
2261
|
+
runId: row.runId,
|
|
2262
|
+
workflowName: row.workflowName,
|
|
2263
|
+
workflowSourceRef: row.workflowRef,
|
|
2264
|
+
workflowSource: this.state.readJson(row.workflowSourceHash),
|
|
2265
|
+
initialized: row.runStatus !== "queued",
|
|
2266
|
+
definitionDigest: `sha256:${row.definitionDigest.toString("hex")}`,
|
|
2267
|
+
input: this.state.readJson(row.inputHash),
|
|
2268
|
+
launchOptions: this.state.readJson(row.launchOptionsHash),
|
|
2269
|
+
status: row.status,
|
|
2270
|
+
runnerId: row.ownerId,
|
|
2271
|
+
claimToken: null,
|
|
2272
|
+
claimGeneration: row.ownerId === null ? null : row.leaseGeneration,
|
|
2273
|
+
claimExpiresAt:
|
|
2274
|
+
row.claimExpiresAt === null ? null : new Date(row.claimExpiresAt).toISOString(),
|
|
2275
|
+
affinityRunnerId: row.affinityRunnerId,
|
|
2276
|
+
originSessionId: row.originSessionId,
|
|
2277
|
+
parentRunId: row.parentRunId,
|
|
2278
|
+
errorCode: row.errorCode,
|
|
2279
|
+
errorMessage:
|
|
2280
|
+
row.errorHash === null
|
|
2281
|
+
? null
|
|
2282
|
+
: (this.state.readBlob(row.errorHash)?.content.toString("utf8") ?? null),
|
|
2283
|
+
createdAt: new Date(row.createdAt).toISOString(),
|
|
2284
|
+
updatedAt: new Date(row.updatedAt).toISOString(),
|
|
2285
|
+
startedAt: row.startedAt === null ? null : new Date(row.startedAt).toISOString(),
|
|
2286
|
+
finishedAt: row.finishedAt === null ? null : new Date(row.finishedAt).toISOString(),
|
|
2287
|
+
};
|
|
2405
2288
|
}
|
|
2406
|
-
|
|
2407
|
-
|
|
2289
|
+
|
|
2290
|
+
private claimRun(
|
|
2291
|
+
runId: string,
|
|
2292
|
+
runnerId: string,
|
|
2293
|
+
claimToken: string,
|
|
2294
|
+
leaseMs: number,
|
|
2295
|
+
now: number,
|
|
2296
|
+
): WorkflowRunQueueRecord | undefined {
|
|
2297
|
+
return this.state.transaction(() => {
|
|
2298
|
+
const row = this.workflowRunRow(runId);
|
|
2299
|
+
if (row === undefined || ["done", "failed", "cancelled"].includes(row.status))
|
|
2300
|
+
return undefined;
|
|
2301
|
+
const lease = this.requireLease(row.resourceId);
|
|
2302
|
+
if (
|
|
2303
|
+
lease.ownerId !== null &&
|
|
2304
|
+
lease.expiresAt !== null &&
|
|
2305
|
+
lease.expiresAt > now &&
|
|
2306
|
+
lease.ownerId !== runnerId
|
|
2307
|
+
)
|
|
2308
|
+
return undefined;
|
|
2309
|
+
const generation = lease.generation + 1;
|
|
2310
|
+
const expiresAt = now + leaseMs;
|
|
2311
|
+
const result = this.state.connection
|
|
2312
|
+
.prepare(
|
|
2313
|
+
`UPDATE leases SET generation = ?, owner_type = ?, owner_id = ?, token_hash = ?,
|
|
2314
|
+
acquired_at = ?, heartbeat_at = ?, expires_at = ?
|
|
2315
|
+
WHERE resource_id = ? AND generation = ?`,
|
|
2316
|
+
)
|
|
2317
|
+
.run(
|
|
2318
|
+
generation,
|
|
2319
|
+
runnerId.startsWith("host-") ? "host" : "session",
|
|
2320
|
+
runnerId,
|
|
2321
|
+
tokenHash(claimToken),
|
|
2322
|
+
now,
|
|
2323
|
+
now,
|
|
2324
|
+
expiresAt,
|
|
2325
|
+
row.resourceId,
|
|
2326
|
+
lease.generation,
|
|
2327
|
+
);
|
|
2328
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
2329
|
+
if (result.changes !== 1) return undefined;
|
|
2330
|
+
this.state.connection
|
|
2331
|
+
.prepare("UPDATE run_queue SET status = 'starting', updated_at = ? WHERE run_id = ?")
|
|
2332
|
+
.run(now, runId);
|
|
2333
|
+
const revision = this.resourceRevision(row.resourceId);
|
|
2334
|
+
this.bumpResource(row.resourceId, revision, now);
|
|
2335
|
+
this.insertEvent(
|
|
2336
|
+
row.resourceId,
|
|
2337
|
+
revision + 1,
|
|
2338
|
+
"lease.claimed",
|
|
2339
|
+
runnerId.startsWith("host-") ? "host" : "session",
|
|
2340
|
+
runnerId,
|
|
2341
|
+
{ expiresAt },
|
|
2342
|
+
now,
|
|
2343
|
+
generation,
|
|
2344
|
+
);
|
|
2345
|
+
return { ...this.requireWorkflowRun(runId), claimToken };
|
|
2346
|
+
});
|
|
2408
2347
|
}
|
|
2409
|
-
|
|
2410
|
-
|
|
2348
|
+
|
|
2349
|
+
private updateClaimedRunStatus(
|
|
2350
|
+
runId: string,
|
|
2351
|
+
claimToken: string,
|
|
2352
|
+
status: WorkflowRunLaunchStatus,
|
|
2353
|
+
nowValue?: string,
|
|
2354
|
+
): boolean {
|
|
2355
|
+
const now = epoch(validTimestamp(nowValue));
|
|
2356
|
+
return this.state.transaction(() => {
|
|
2357
|
+
if (
|
|
2358
|
+
!this.verifyWorkflowRunClaim({
|
|
2359
|
+
runId,
|
|
2360
|
+
claimToken,
|
|
2361
|
+
now: new Date(now).toISOString(),
|
|
2362
|
+
})
|
|
2363
|
+
) {
|
|
2364
|
+
return false;
|
|
2365
|
+
}
|
|
2366
|
+
const row = this.requireWorkflowRunRow(runId);
|
|
2367
|
+
const changed =
|
|
2368
|
+
this.state.connection
|
|
2369
|
+
.prepare(
|
|
2370
|
+
"UPDATE run_queue SET status = ?, started_at = COALESCE(started_at, ?), updated_at = ? WHERE run_id = ?",
|
|
2371
|
+
)
|
|
2372
|
+
.run(status, now, now, runId).changes === 1;
|
|
2373
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
2374
|
+
if (!changed) return false;
|
|
2375
|
+
const revision = this.resourceRevision(row.resourceId);
|
|
2376
|
+
const lease = this.requireLease(row.resourceId);
|
|
2377
|
+
this.bumpResource(row.resourceId, revision, now);
|
|
2378
|
+
this.insertEvent(
|
|
2379
|
+
row.resourceId,
|
|
2380
|
+
revision + 1,
|
|
2381
|
+
`run.queue_${status}`,
|
|
2382
|
+
lease.ownerType ?? "system",
|
|
2383
|
+
lease.ownerId,
|
|
2384
|
+
{ status },
|
|
2385
|
+
now,
|
|
2386
|
+
lease.generation || undefined,
|
|
2387
|
+
);
|
|
2388
|
+
return true;
|
|
2389
|
+
});
|
|
2411
2390
|
}
|
|
2412
|
-
|
|
2413
|
-
|
|
2391
|
+
|
|
2392
|
+
private releaseRunClaim(
|
|
2393
|
+
runId: string,
|
|
2394
|
+
claimToken: string,
|
|
2395
|
+
status: WorkflowRunLaunchStatus,
|
|
2396
|
+
nowValue?: string,
|
|
2397
|
+
): boolean {
|
|
2398
|
+
const now = epoch(validTimestamp(nowValue));
|
|
2399
|
+
return this.state.transaction(() => {
|
|
2400
|
+
const row = this.workflowRunRow(runId);
|
|
2401
|
+
if (
|
|
2402
|
+
row === undefined ||
|
|
2403
|
+
!this.verifyWorkflowRunClaim({ runId, claimToken, now: new Date(now).toISOString() })
|
|
2404
|
+
)
|
|
2405
|
+
return false;
|
|
2406
|
+
this.state.connection
|
|
2407
|
+
.prepare(
|
|
2408
|
+
"UPDATE run_queue SET status = ?, updated_at = ?, finished_at = ? WHERE run_id = ?",
|
|
2409
|
+
)
|
|
2410
|
+
.run(status, now, status === "done" ? now : null, runId);
|
|
2411
|
+
const lease = this.requireLease(row.resourceId);
|
|
2412
|
+
const update = this.state.connection
|
|
2413
|
+
.prepare(
|
|
2414
|
+
`UPDATE leases SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
|
|
2415
|
+
acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
|
|
2416
|
+
WHERE resource_id = ? AND token_hash = ? AND generation = ?`,
|
|
2417
|
+
)
|
|
2418
|
+
.run(row.resourceId, tokenHash(claimToken), lease.generation);
|
|
2419
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
2420
|
+
if (update.changes !== 1) return false;
|
|
2421
|
+
const revision = this.resourceRevision(row.resourceId);
|
|
2422
|
+
this.bumpResource(row.resourceId, revision, now);
|
|
2423
|
+
this.insertEvent(
|
|
2424
|
+
row.resourceId,
|
|
2425
|
+
revision + 1,
|
|
2426
|
+
`run.queue_${status}`,
|
|
2427
|
+
lease.ownerType ?? "system",
|
|
2428
|
+
lease.ownerId,
|
|
2429
|
+
{ status },
|
|
2430
|
+
now,
|
|
2431
|
+
lease.generation || undefined,
|
|
2432
|
+
);
|
|
2433
|
+
return true;
|
|
2434
|
+
});
|
|
2414
2435
|
}
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2436
|
+
|
|
2437
|
+
private terminalRun(
|
|
2438
|
+
runId: string,
|
|
2439
|
+
claimToken: string | undefined,
|
|
2440
|
+
status: "failed" | "cancelled",
|
|
2441
|
+
code: string,
|
|
2442
|
+
error: string,
|
|
2443
|
+
nowValue?: string,
|
|
2444
|
+
): boolean {
|
|
2445
|
+
const now = epoch(validTimestamp(nowValue));
|
|
2446
|
+
return this.state.transaction(() => {
|
|
2447
|
+
const row = this.workflowRunRow(runId);
|
|
2448
|
+
if (row === undefined || ["done", "failed", "cancelled"].includes(row.status)) return false;
|
|
2449
|
+
const lease = this.requireLease(row.resourceId);
|
|
2450
|
+
if (claimToken === undefined) {
|
|
2451
|
+
if (!(["queued", "starting"] as WorkflowRunLaunchStatus[]).includes(row.status)) {
|
|
2452
|
+
return false;
|
|
2453
|
+
}
|
|
2454
|
+
if (lease.ownerId !== null) return false;
|
|
2455
|
+
} else if (
|
|
2456
|
+
lease.ownerId === null ||
|
|
2457
|
+
lease.tokenHash === null ||
|
|
2458
|
+
lease.expiresAt === null ||
|
|
2459
|
+
lease.expiresAt <= now ||
|
|
2460
|
+
!lease.tokenHash.equals(tokenHash(claimToken))
|
|
2461
|
+
) {
|
|
2462
|
+
return false;
|
|
2463
|
+
}
|
|
2464
|
+
if (row.outputHash === null) throw new Error(`Workflow run ${runId} has no state projection`);
|
|
2465
|
+
const projected = this.state.readJson(row.outputHash);
|
|
2466
|
+
if (!isRecord(projected)) throw new Error(`Workflow run ${runId} state is invalid`);
|
|
2467
|
+
const revision = this.resourceRevision(row.resourceId);
|
|
2468
|
+
const at = new Date(now).toISOString();
|
|
2469
|
+
const terminalState: Record<string, unknown> = {
|
|
2470
|
+
...projected,
|
|
2471
|
+
traceSeq: revision + 1,
|
|
2472
|
+
status,
|
|
2473
|
+
updatedAt: at,
|
|
2474
|
+
finishedAt: at,
|
|
2475
|
+
error,
|
|
2476
|
+
};
|
|
2477
|
+
delete terminalState.currentNode;
|
|
2478
|
+
delete terminalState.currentAttemptId;
|
|
2479
|
+
delete terminalState.currentNodeStartedAt;
|
|
2480
|
+
delete terminalState.statusDetail;
|
|
2481
|
+
delete terminalState.paused;
|
|
2482
|
+
delete terminalState.waitingOn;
|
|
2483
|
+
const errorHash = this.state.putText(error, now);
|
|
2484
|
+
const outputHash = this.state.putJson(terminalState, now);
|
|
2485
|
+
const queueUpdate = this.state.connection
|
|
2486
|
+
.prepare(
|
|
2487
|
+
`UPDATE run_queue
|
|
2488
|
+
SET status = ?, error_code = ?, error_hash = ?, updated_at = ?, finished_at = ?
|
|
2489
|
+
WHERE run_id = ? AND status NOT IN ('done', 'failed', 'cancelled')`,
|
|
2490
|
+
)
|
|
2491
|
+
.run(status, code, errorHash, now, now, runId);
|
|
2492
|
+
if (queueUpdate.changes !== 1) return false;
|
|
2493
|
+
if (claimToken !== undefined) {
|
|
2494
|
+
const leaseUpdate = this.state.connection
|
|
2495
|
+
.prepare(
|
|
2496
|
+
`UPDATE leases
|
|
2497
|
+
SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
|
|
2498
|
+
acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
|
|
2499
|
+
WHERE resource_id = ? AND token_hash = ? AND generation = ? AND expires_at > ?`,
|
|
2500
|
+
)
|
|
2501
|
+
.run(row.resourceId, tokenHash(claimToken), lease.generation, now);
|
|
2502
|
+
if (leaseUpdate.changes !== 1) {
|
|
2503
|
+
throw new Error(`Workflow run ${runId} claim changed during terminal transition`);
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
this.state.connection
|
|
2507
|
+
.prepare(
|
|
2508
|
+
`UPDATE runs
|
|
2509
|
+
SET status = ?, output_hash = ?, error_hash = ?, updated_at = ?, finished_at = ?
|
|
2510
|
+
WHERE run_id = ?`,
|
|
2511
|
+
)
|
|
2512
|
+
.run(status, outputHash, errorHash, now, now, runId);
|
|
2513
|
+
this.bumpResource(row.resourceId, revision, now);
|
|
2514
|
+
this.insertEvent(
|
|
2515
|
+
row.resourceId,
|
|
2516
|
+
revision + 1,
|
|
2517
|
+
`run.queue_${status}`,
|
|
2518
|
+
lease.ownerType ?? "system",
|
|
2519
|
+
lease.ownerId,
|
|
2520
|
+
{ status, code, error },
|
|
2521
|
+
now,
|
|
2522
|
+
lease.generation || undefined,
|
|
2523
|
+
);
|
|
2524
|
+
return true;
|
|
2525
|
+
});
|
|
2421
2526
|
}
|
|
2422
|
-
|
|
2423
|
-
|
|
2527
|
+
|
|
2528
|
+
private turnIntent(intentId: string): WorkflowTurnIntentRecord | undefined {
|
|
2529
|
+
const row = this.turnIntentRow(intentId);
|
|
2530
|
+
return row === undefined ? undefined : this.mapTurnIntent(row);
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
private requireTurnIntent(intentId: string): WorkflowTurnIntentRecord {
|
|
2534
|
+
const row = this.turnIntent(intentId);
|
|
2535
|
+
if (row === undefined) throw new Error(`Workflow turn intent not found: ${intentId}`);
|
|
2536
|
+
return row;
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
private turnIntentRow(intentId: string): TurnIntentRow | undefined {
|
|
2540
|
+
const row = this.state.connection
|
|
2541
|
+
.prepare(turnIntentSelect("WHERE t.turn_intent_id = ?"))
|
|
2542
|
+
.get(intentId);
|
|
2543
|
+
return isTurnIntentRow(row) ? row : undefined;
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
private turnIntentBySource(sourceEventId: string): WorkflowTurnIntentRecord | undefined {
|
|
2547
|
+
const row = this.state.connection
|
|
2548
|
+
.prepare(turnIntentSelect("WHERE t.source_event_id = ?"))
|
|
2549
|
+
.get(sourceEventId);
|
|
2550
|
+
return isTurnIntentRow(row) ? this.mapTurnIntent(row) : undefined;
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
/* istanbul ignore next -- pure projection covered by integration tests */
|
|
2554
|
+
private mapTurnIntent(row: TurnIntentRow): WorkflowTurnIntentRecord {
|
|
2555
|
+
return {
|
|
2556
|
+
intentId: row.intentId,
|
|
2557
|
+
sourceEventId: row.sourceEventId,
|
|
2558
|
+
runId: row.runId,
|
|
2559
|
+
workflowRef: row.workflowRef,
|
|
2560
|
+
targetSessionId: row.targetSessionId,
|
|
2561
|
+
cause: row.cause,
|
|
2562
|
+
nodeId: row.nodeId,
|
|
2563
|
+
attemptId: row.attemptId,
|
|
2564
|
+
fallbackFacts: this.state.readJson(row.factsHash) as WorkflowTurnIntentFacts,
|
|
2565
|
+
requestedAt: new Date(row.requestedAt).toISOString(),
|
|
2566
|
+
eligibleAt: row.eligibleAt === null ? null : new Date(row.eligibleAt).toISOString(),
|
|
2567
|
+
resolvedAt: row.resolvedAt === null ? null : new Date(row.resolvedAt).toISOString(),
|
|
2568
|
+
resolution: row.resolution,
|
|
2569
|
+
resolutionMessageId: row.resolutionMessageId,
|
|
2570
|
+
deliveryClaimExpiresAt:
|
|
2571
|
+
row.claimExpiresAt === null ? null : new Date(row.claimExpiresAt).toISOString(),
|
|
2572
|
+
};
|
|
2424
2573
|
}
|
|
2425
|
-
|
|
2426
|
-
|
|
2574
|
+
|
|
2575
|
+
private claimEffectForTurn(
|
|
2576
|
+
intentId: string,
|
|
2577
|
+
token: string,
|
|
2578
|
+
leaseMs: number,
|
|
2579
|
+
nowValue?: string,
|
|
2580
|
+
): boolean {
|
|
2581
|
+
const row = this.turnIntentRow(intentId);
|
|
2582
|
+
return (
|
|
2583
|
+
row !== undefined &&
|
|
2584
|
+
this.claimEffect(row.effectId, row.targetSessionId, token, leaseMs, nowValue)
|
|
2585
|
+
);
|
|
2427
2586
|
}
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2587
|
+
|
|
2588
|
+
private claimEffect(
|
|
2589
|
+
effectId: string,
|
|
2590
|
+
ownerId: string,
|
|
2591
|
+
token: string,
|
|
2592
|
+
leaseMs: number,
|
|
2593
|
+
nowValue?: string,
|
|
2594
|
+
): boolean {
|
|
2595
|
+
const now = epoch(validTimestamp(nowValue));
|
|
2596
|
+
return this.state.transaction(() => {
|
|
2597
|
+
const row = this.state.connection
|
|
2598
|
+
.prepare(
|
|
2599
|
+
"SELECT resource_id AS resourceId FROM effects WHERE effect_id = ? AND status = 'pending'",
|
|
2600
|
+
)
|
|
2601
|
+
.get(effectId);
|
|
2602
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
2603
|
+
if (!isResourceIdRow(row)) return false;
|
|
2604
|
+
const lease = this.requireLease(row.resourceId);
|
|
2605
|
+
if (lease.ownerId !== null && lease.expiresAt !== null && lease.expiresAt > now) return false;
|
|
2606
|
+
return (
|
|
2607
|
+
this.state.connection
|
|
2608
|
+
.prepare(
|
|
2609
|
+
`UPDATE leases SET generation = ?, owner_type = 'session', owner_id = ?, token_hash = ?,
|
|
2610
|
+
acquired_at = ?, heartbeat_at = ?, expires_at = ?
|
|
2611
|
+
WHERE resource_id = ? AND generation = ?`,
|
|
2612
|
+
)
|
|
2613
|
+
.run(
|
|
2614
|
+
lease.generation + 1,
|
|
2615
|
+
ownerId,
|
|
2616
|
+
tokenHash(token),
|
|
2617
|
+
now,
|
|
2618
|
+
now,
|
|
2619
|
+
now + leaseMs,
|
|
2620
|
+
row.resourceId,
|
|
2621
|
+
lease.generation,
|
|
2622
|
+
).changes === 1
|
|
2623
|
+
);
|
|
2624
|
+
});
|
|
2431
2625
|
}
|
|
2432
|
-
|
|
2433
|
-
|
|
2626
|
+
|
|
2627
|
+
private verifyEffectToken(effectId: string, token: string, nowValue?: string): boolean {
|
|
2628
|
+
const now = epoch(validTimestamp(nowValue));
|
|
2629
|
+
const row = this.state.connection
|
|
2630
|
+
.prepare(
|
|
2631
|
+
`SELECT l.token_hash AS tokenHash, l.expires_at AS expiresAt
|
|
2632
|
+
FROM effects e JOIN leases l ON l.resource_id = e.resource_id
|
|
2633
|
+
WHERE e.effect_id = ?`,
|
|
2634
|
+
)
|
|
2635
|
+
.get(effectId);
|
|
2636
|
+
return (
|
|
2637
|
+
isEffectTokenRow(row) &&
|
|
2638
|
+
row.tokenHash !== null &&
|
|
2639
|
+
row.tokenHash.equals(tokenHash(token)) &&
|
|
2640
|
+
row.expiresAt !== null &&
|
|
2641
|
+
row.expiresAt > now
|
|
2642
|
+
);
|
|
2434
2643
|
}
|
|
2435
|
-
|
|
2436
|
-
|
|
2644
|
+
|
|
2645
|
+
private releaseEffectLease(effectId: string, token: string): boolean {
|
|
2646
|
+
return (
|
|
2647
|
+
this.state.connection
|
|
2648
|
+
.prepare(
|
|
2649
|
+
`UPDATE leases SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
|
|
2650
|
+
acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
|
|
2651
|
+
WHERE resource_id = (SELECT resource_id FROM effects WHERE effect_id = ?)
|
|
2652
|
+
AND token_hash = ?`,
|
|
2653
|
+
)
|
|
2654
|
+
.run(effectId, tokenHash(token)).changes === 1
|
|
2655
|
+
);
|
|
2437
2656
|
}
|
|
2438
|
-
}
|
|
2439
2657
|
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2658
|
+
private completeEffect(
|
|
2659
|
+
effectId: string,
|
|
2660
|
+
status: "applied" | "rejected" | "ambiguous",
|
|
2661
|
+
now: number,
|
|
2662
|
+
): void {
|
|
2663
|
+
this.state.connection
|
|
2664
|
+
.prepare("UPDATE effects SET status = ?, updated_at = ?, settled_at = ? WHERE effect_id = ?")
|
|
2665
|
+
.run(status, now, now, effectId);
|
|
2666
|
+
this.state.connection
|
|
2667
|
+
.prepare(
|
|
2668
|
+
`UPDATE leases SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
|
|
2669
|
+
acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
|
|
2670
|
+
WHERE resource_id = (SELECT resource_id FROM effects WHERE effect_id = ?)`,
|
|
2671
|
+
)
|
|
2672
|
+
.run(effectId);
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
private insertEffectResource(
|
|
2676
|
+
effectId: string,
|
|
2677
|
+
resourceId: string,
|
|
2678
|
+
sourceResourceId: string,
|
|
2679
|
+
sourceRevision: number,
|
|
2680
|
+
type: string,
|
|
2681
|
+
key: string,
|
|
2682
|
+
payloadHash: Buffer,
|
|
2683
|
+
scope: "run" | "controller" | "channel" | "system",
|
|
2684
|
+
now: number,
|
|
2685
|
+
): void {
|
|
2686
|
+
this.state.connection
|
|
2687
|
+
.prepare(
|
|
2688
|
+
"INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at) VALUES (?, 'effect', ?, 1, ?, ?)",
|
|
2689
|
+
)
|
|
2690
|
+
.run(resourceId, effectId, now, now);
|
|
2691
|
+
this.state.connection
|
|
2692
|
+
.prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
|
|
2693
|
+
.run(resourceId);
|
|
2694
|
+
this.state.connection
|
|
2695
|
+
.prepare(
|
|
2696
|
+
`INSERT INTO effects(
|
|
2697
|
+
effect_id, resource_id, source_resource_id, source_revision, effect_type,
|
|
2698
|
+
idempotency_key, payload_hash, owner_scope, status, attempt_count,
|
|
2699
|
+
created_at, updated_at
|
|
2700
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)`,
|
|
2701
|
+
)
|
|
2702
|
+
.run(
|
|
2703
|
+
effectId,
|
|
2704
|
+
resourceId,
|
|
2705
|
+
sourceResourceId,
|
|
2706
|
+
sourceRevision,
|
|
2707
|
+
type,
|
|
2708
|
+
key,
|
|
2709
|
+
payloadHash,
|
|
2710
|
+
scope,
|
|
2711
|
+
now,
|
|
2712
|
+
now,
|
|
2713
|
+
);
|
|
2714
|
+
this.insertEvent(resourceId, 1, "effect.created", "system", null, {}, now);
|
|
2450
2715
|
}
|
|
2451
|
-
return value;
|
|
2452
|
-
}
|
|
2453
2716
|
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2717
|
+
private notification(
|
|
2718
|
+
runId: string,
|
|
2719
|
+
attemptId: string,
|
|
2720
|
+
index: number,
|
|
2721
|
+
): WorkflowNotificationRecord | undefined {
|
|
2722
|
+
const row = this.state.connection
|
|
2723
|
+
.prepare(
|
|
2724
|
+
notificationSelect("WHERE n.run_id = ? AND n.attempt_id = ? AND n.notification_index = ?"),
|
|
2725
|
+
)
|
|
2726
|
+
.get(runId, attemptId, index);
|
|
2727
|
+
return isNotificationRow(row) ? this.mapNotification(row) : undefined;
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
private requireNotification(
|
|
2731
|
+
runId: string,
|
|
2732
|
+
attemptId: string,
|
|
2733
|
+
index: number,
|
|
2734
|
+
): WorkflowNotificationRecord {
|
|
2735
|
+
const record = this.notification(runId, attemptId, index);
|
|
2736
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
2737
|
+
if (record === undefined) throw new Error("Workflow notification was not stored");
|
|
2738
|
+
return record;
|
|
2457
2739
|
}
|
|
2458
|
-
return value;
|
|
2459
|
-
}
|
|
2460
2740
|
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2741
|
+
private notificationRows(sessionId: string, limit: number): NotificationRow[] {
|
|
2742
|
+
return this.state.connection
|
|
2743
|
+
.prepare(
|
|
2744
|
+
notificationSelect(
|
|
2745
|
+
"WHERE n.target_session_id = ? AND e.status = 'pending' ORDER BY n.created_at LIMIT ?",
|
|
2746
|
+
),
|
|
2747
|
+
)
|
|
2748
|
+
.all(sessionId, limit)
|
|
2749
|
+
.filter(isNotificationRow);
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
private notificationRowById(id: string): NotificationRow | undefined {
|
|
2753
|
+
const row = this.state.connection
|
|
2754
|
+
.prepare(notificationSelect("WHERE n.notification_id = ?"))
|
|
2755
|
+
.get(id);
|
|
2756
|
+
return isNotificationRow(row) ? row : undefined;
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
/* istanbul ignore next -- pure projection covered by integration tests */
|
|
2760
|
+
private mapNotification(row: NotificationRow): WorkflowNotificationRecord {
|
|
2761
|
+
return {
|
|
2762
|
+
notificationId: row.notificationId,
|
|
2763
|
+
runId: row.runId,
|
|
2764
|
+
nodeId: row.nodeId,
|
|
2765
|
+
attemptId: row.attemptId,
|
|
2766
|
+
notificationIndex: row.notificationIndex,
|
|
2767
|
+
targetSessionId: row.targetSessionId,
|
|
2768
|
+
kind: row.kind,
|
|
2769
|
+
content: this.state.readBlob(row.contentHash)?.content.toString("utf8") ?? "",
|
|
2770
|
+
createdAt: new Date(row.createdAt).toISOString(),
|
|
2771
|
+
deliveryClaimExpiresAt:
|
|
2772
|
+
row.claimExpiresAt === null ? null : new Date(row.claimExpiresAt).toISOString(),
|
|
2773
|
+
deliveredAt: row.deliveredAt === null ? null : new Date(row.deliveredAt).toISOString(),
|
|
2774
|
+
};
|
|
2775
|
+
}
|
|
2485
2776
|
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
throw new Error(`Invalid workflow run id: ${JSON.stringify(runId)}`);
|
|
2777
|
+
private assertWritable(): void {
|
|
2778
|
+
if (this.state.mode === "read-only") throw new Error("Controller store is read-only");
|
|
2489
2779
|
}
|
|
2490
2780
|
}
|
|
2491
2781
|
|
|
2492
|
-
function
|
|
2493
|
-
return
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
payload: parseStoredJson<JsonObject>(row.payload_json, "controller event payload"),
|
|
2500
|
-
};
|
|
2782
|
+
function controllerSelect(clause: string): string {
|
|
2783
|
+
return `SELECT c.controller_resource_id AS controllerResourceId,
|
|
2784
|
+
c.resource_id AS resourceId, r.revision AS resourceVersion,
|
|
2785
|
+
c.controller_name AS controllerName, c.resource_key AS resourceKey,
|
|
2786
|
+
c.uid, c.generation, c.spec_hash AS specHash, c.status_hash AS statusHash,
|
|
2787
|
+
c.deletion_requested_at AS deletionRequestedAt
|
|
2788
|
+
FROM controller_resources c JOIN resources r ON r.resource_id = c.resource_id ${clause}`;
|
|
2501
2789
|
}
|
|
2502
2790
|
|
|
2503
|
-
function
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2791
|
+
function effectSelect(clause: string): string {
|
|
2792
|
+
return `SELECT e.effect_id AS effectId, c.uid AS resourceUid, c.generation,
|
|
2793
|
+
e.effect_type AS kind, e.status AS state,
|
|
2794
|
+
json_extract(CAST(b.content AS TEXT), '$.requestFingerprint') AS requestFingerprint,
|
|
2795
|
+
e.created_at AS startedAt, e.settled_at AS completedAt,
|
|
2796
|
+
e.external_ref AS externalRef, e.error_hash AS errorHash
|
|
2797
|
+
FROM effects e
|
|
2798
|
+
JOIN controller_resources c ON c.resource_id = e.source_resource_id
|
|
2799
|
+
JOIN blobs b ON b.blob_hash = e.payload_hash ${clause}`;
|
|
2509
2800
|
}
|
|
2510
2801
|
|
|
2511
|
-
function
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2802
|
+
function workflowSelect(clause: string): string {
|
|
2803
|
+
return `SELECT w.request_id AS requestId, c.uid AS resourceUid,
|
|
2804
|
+
w.request_key AS requestKey, w.input_fingerprint AS inputFingerprint,
|
|
2805
|
+
w.workflow_name AS workflowName, COALESCE(w.run_id, w.reserved_run_id) AS runId, w.status,
|
|
2806
|
+
w.attempt_count AS attemptCount, w.error_hash AS errorHash
|
|
2807
|
+
FROM controller_workflows w
|
|
2808
|
+
JOIN controller_resources c ON c.controller_resource_id = w.controller_resource_id ${clause}`;
|
|
2517
2809
|
}
|
|
2518
2810
|
|
|
2519
|
-
function
|
|
2520
|
-
return
|
|
2811
|
+
function workflowRunSelect(clause: string): string {
|
|
2812
|
+
return `SELECT r.run_id AS runId, r.resource_id AS resourceId,
|
|
2813
|
+
d.workflow_name AS workflowName, r.workflow_ref AS workflowRef, r.status AS runStatus,
|
|
2814
|
+
r.workflow_source_hash AS workflowSourceHash, r.definition_digest AS definitionDigest,
|
|
2815
|
+
r.input_hash AS inputHash, r.output_hash AS outputHash,
|
|
2816
|
+
r.launch_options_hash AS launchOptionsHash,
|
|
2817
|
+
q.status, q.available_at AS availableAt, q.affinity_runner_id AS affinityRunnerId,
|
|
2818
|
+
q.consecutive_errors AS consecutiveErrors, q.error_code AS errorCode,
|
|
2819
|
+
q.error_hash AS errorHash, b.origin_session_id AS originSessionId,
|
|
2820
|
+
r.parent_run_id AS parentRunId, q.created_at AS createdAt, q.updated_at AS updatedAt,
|
|
2821
|
+
q.started_at AS startedAt, q.finished_at AS finishedAt,
|
|
2822
|
+
l.generation AS leaseGeneration, l.owner_id AS ownerId, l.expires_at AS claimExpiresAt
|
|
2823
|
+
FROM runs r JOIN workflow_definitions d ON d.definition_digest = r.definition_digest
|
|
2824
|
+
JOIN run_queue q ON q.run_id = r.run_id
|
|
2825
|
+
LEFT JOIN run_bindings b ON b.run_id = r.run_id
|
|
2826
|
+
JOIN leases l ON l.resource_id = r.resource_id ${clause}`;
|
|
2521
2827
|
}
|
|
2522
2828
|
|
|
2523
|
-
function
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2829
|
+
function turnIntentSelect(clause: string): string {
|
|
2830
|
+
return `SELECT t.turn_intent_id AS intentId, t.source_event_id AS sourceEventId,
|
|
2831
|
+
t.run_id AS runId, t.workflow_ref AS workflowRef,
|
|
2832
|
+
t.target_session_id AS targetSessionId, t.cause,
|
|
2833
|
+
t.node_id AS nodeId, t.attempt_id AS attemptId, t.facts_hash AS factsHash,
|
|
2834
|
+
t.requested_at AS requestedAt, t.eligible_at AS eligibleAt,
|
|
2835
|
+
t.resolved_at AS resolvedAt, t.resolution_type AS resolution,
|
|
2836
|
+
t.resolution_message_id AS resolutionMessageId, l.expires_at AS claimExpiresAt,
|
|
2837
|
+
t.effect_id AS effectId
|
|
2838
|
+
FROM turn_intents t JOIN effects e ON e.effect_id = t.effect_id
|
|
2839
|
+
JOIN leases l ON l.resource_id = e.resource_id ${clause}`;
|
|
2527
2840
|
}
|
|
2528
2841
|
|
|
2529
|
-
function
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2842
|
+
function notificationSelect(clause: string): string {
|
|
2843
|
+
return `SELECT n.notification_id AS notificationId, n.effect_id AS effectId,
|
|
2844
|
+
n.run_id AS runId, a.node_id AS nodeId, n.attempt_id AS attemptId,
|
|
2845
|
+
n.notification_index AS notificationIndex, n.target_session_id AS targetSessionId,
|
|
2846
|
+
n.notification_type AS kind, n.content_hash AS contentHash,
|
|
2847
|
+
n.created_at AS createdAt, l.expires_at AS claimExpiresAt,
|
|
2848
|
+
e.settled_at AS deliveredAt
|
|
2849
|
+
FROM notifications n JOIN effects e ON e.effect_id = n.effect_id
|
|
2850
|
+
JOIN leases l ON l.resource_id = e.resource_id
|
|
2851
|
+
JOIN node_attempts a ON a.attempt_id = n.attempt_id ${clause}`;
|
|
2533
2852
|
}
|
|
2534
2853
|
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2854
|
+
type QueueListRow = {
|
|
2855
|
+
controller: string;
|
|
2856
|
+
resourceKey: string;
|
|
2857
|
+
availableAt: number;
|
|
2858
|
+
consecutiveErrors: number;
|
|
2859
|
+
claimExpiresAt: number | null;
|
|
2860
|
+
};
|
|
2861
|
+
type ControllerEventRow = {
|
|
2862
|
+
seq: number;
|
|
2863
|
+
recordedAt: number;
|
|
2864
|
+
controller: string;
|
|
2865
|
+
resourceKey: string;
|
|
2866
|
+
eventType: string;
|
|
2867
|
+
payloadHash: Buffer | null;
|
|
2868
|
+
};
|
|
2869
|
+
type RunEventRow = {
|
|
2870
|
+
seq: number;
|
|
2871
|
+
recordedAt: number;
|
|
2872
|
+
runId: string;
|
|
2873
|
+
workflowRef: string;
|
|
2874
|
+
eventType: string;
|
|
2875
|
+
runnerId: string | null;
|
|
2876
|
+
payloadHash: Buffer | null;
|
|
2877
|
+
};
|
|
2878
|
+
type TurnIntentRow = {
|
|
2879
|
+
intentId: string;
|
|
2880
|
+
sourceEventId: string;
|
|
2881
|
+
runId: string;
|
|
2882
|
+
workflowRef: string;
|
|
2883
|
+
targetSessionId: string;
|
|
2884
|
+
cause: WorkflowTurnIntentCause;
|
|
2885
|
+
nodeId: string | null;
|
|
2886
|
+
attemptId: string | null;
|
|
2887
|
+
factsHash: Buffer;
|
|
2888
|
+
requestedAt: number;
|
|
2889
|
+
eligibleAt: number | null;
|
|
2890
|
+
resolvedAt: number | null;
|
|
2891
|
+
resolution: WorkflowTurnIntentResolution | null;
|
|
2892
|
+
resolutionMessageId: string | null;
|
|
2893
|
+
claimExpiresAt: number | null;
|
|
2894
|
+
effectId: string;
|
|
2895
|
+
};
|
|
2896
|
+
type NotificationRow = {
|
|
2897
|
+
notificationId: string;
|
|
2898
|
+
effectId: string;
|
|
2899
|
+
runId: string;
|
|
2900
|
+
nodeId: string;
|
|
2901
|
+
attemptId: string;
|
|
2902
|
+
notificationIndex: number;
|
|
2903
|
+
targetSessionId: string;
|
|
2904
|
+
kind: "progress" | "final";
|
|
2905
|
+
contentHash: Buffer;
|
|
2906
|
+
createdAt: number;
|
|
2907
|
+
claimExpiresAt: number | null;
|
|
2908
|
+
deliveredAt: number | null;
|
|
2909
|
+
};
|
|
2910
|
+
|
|
2911
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
2912
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2913
|
+
}
|
|
2914
|
+
function isControllerRow(value: unknown): value is ControllerRow {
|
|
2915
|
+
return isRecord(value);
|
|
2916
|
+
}
|
|
2917
|
+
function isLeaseRow(value: unknown): value is LeaseRow {
|
|
2918
|
+
return isRecord(value);
|
|
2919
|
+
}
|
|
2920
|
+
function isRunRow(value: unknown): value is RunRow {
|
|
2921
|
+
return isRecord(value);
|
|
2922
|
+
}
|
|
2923
|
+
function isEffectRow(value: unknown): value is EffectRow {
|
|
2924
|
+
return isRecord(value);
|
|
2925
|
+
}
|
|
2926
|
+
function isWorkflowRow(value: unknown): value is WorkflowRow {
|
|
2927
|
+
return isRecord(value);
|
|
2928
|
+
}
|
|
2929
|
+
function isQueueListRow(value: unknown): value is QueueListRow {
|
|
2930
|
+
return isRecord(value);
|
|
2931
|
+
}
|
|
2932
|
+
function isControllerEventRow(value: unknown): value is ControllerEventRow {
|
|
2933
|
+
return isRecord(value);
|
|
2934
|
+
}
|
|
2935
|
+
function isRunEventRow(value: unknown): value is RunEventRow {
|
|
2936
|
+
return isRecord(value);
|
|
2937
|
+
}
|
|
2938
|
+
function isTurnIntentRow(value: unknown): value is TurnIntentRow {
|
|
2939
|
+
return isRecord(value);
|
|
2940
|
+
}
|
|
2941
|
+
function isNotificationRow(value: unknown): value is NotificationRow {
|
|
2942
|
+
return isRecord(value);
|
|
2943
|
+
}
|
|
2944
|
+
function isProjectRow(value: unknown): value is { projectId: string } {
|
|
2945
|
+
return isRecord(value);
|
|
2946
|
+
}
|
|
2947
|
+
function isFinalizerRow(value: unknown): value is { finalizer: string } {
|
|
2948
|
+
return isRecord(value);
|
|
2949
|
+
}
|
|
2950
|
+
function isErrorCountRow(value: unknown): value is { consecutiveErrors: number } {
|
|
2951
|
+
return isRecord(value);
|
|
2952
|
+
}
|
|
2953
|
+
function isQueueVersionRow(value: unknown): value is { queueVersion: number } {
|
|
2954
|
+
return isRecord(value);
|
|
2955
|
+
}
|
|
2956
|
+
function isRevisionRow(value: unknown): value is { revision: number } {
|
|
2957
|
+
return isRecord(value);
|
|
2958
|
+
}
|
|
2959
|
+
function isSequenceRow(value: unknown): value is { seq: number } {
|
|
2960
|
+
return isRecord(value);
|
|
2961
|
+
}
|
|
2962
|
+
function isKeyRow(value: unknown): value is { key: string } {
|
|
2963
|
+
return isRecord(value);
|
|
2964
|
+
}
|
|
2965
|
+
function isResourceIdRow(value: unknown): value is { resourceId: string } {
|
|
2966
|
+
return isRecord(value);
|
|
2967
|
+
}
|
|
2968
|
+
function isEffectTokenRow(
|
|
2969
|
+
value: unknown,
|
|
2970
|
+
): value is { tokenHash: Buffer | null; expiresAt: number | null } {
|
|
2971
|
+
return isRecord(value);
|
|
2972
|
+
}
|
|
2973
|
+
function isEffectIdentityRow(value: unknown): value is { effectId: string; resourceId: string } {
|
|
2974
|
+
return isRecord(value);
|
|
2543
2975
|
}
|
|
2544
2976
|
|
|
2545
|
-
function
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2977
|
+
function effectIdFor(sourceResourceId: string, key: string): string {
|
|
2978
|
+
return `effect-${createHash("sha256").update(`${sourceResourceId}\0${key}`).digest("hex").slice(0, 40)}`;
|
|
2979
|
+
}
|
|
2980
|
+
function projectIdFor(canonicalPath: string): string {
|
|
2981
|
+
return `project-${createHash("sha256").update(canonicalPath).digest("hex").slice(0, 40)}`;
|
|
2982
|
+
}
|
|
2983
|
+
function canonicalProjectPath(value: string): string {
|
|
2984
|
+
try {
|
|
2985
|
+
return fs.realpathSync.native(value);
|
|
2986
|
+
} catch {
|
|
2987
|
+
return path.resolve(value);
|
|
2553
2988
|
}
|
|
2554
2989
|
}
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
if (
|
|
2558
|
-
|
|
2990
|
+
function digestBuffer(value: string): Buffer {
|
|
2991
|
+
const hex = value.startsWith("sha256:") ? value.slice(7) : value;
|
|
2992
|
+
if (!/^[a-f0-9]{64}$/i.test(hex)) throw new Error("Expected a SHA-256 digest");
|
|
2993
|
+
return Buffer.from(hex, "hex");
|
|
2994
|
+
}
|
|
2995
|
+
function queuedWorkflowSource(value: unknown): { root: unknown; mounted: unknown[] } {
|
|
2996
|
+
if (isRecord(value) && (value.kind === "builtin" || value.kind === "file")) {
|
|
2997
|
+
return { root: value, mounted: [] };
|
|
2998
|
+
}
|
|
2999
|
+
if (isRecord(value) && isRecord(value.root) && Array.isArray(value.mounted)) {
|
|
3000
|
+
return { root: value.root, mounted: value.mounted };
|
|
2559
3001
|
}
|
|
3002
|
+
throw new Error("Stored workflow source identity is invalid");
|
|
2560
3003
|
}
|
|
2561
3004
|
|
|
2562
|
-
function
|
|
2563
|
-
|
|
3005
|
+
function sourceParts(
|
|
3006
|
+
ref: string,
|
|
3007
|
+
revision: string,
|
|
3008
|
+
): { type: "builtin" | "file"; ref: string; revision: string } {
|
|
3009
|
+
return ref.startsWith("builtin:")
|
|
3010
|
+
? { type: "builtin", ref: ref.slice(8), revision }
|
|
3011
|
+
: { type: "file", ref, revision };
|
|
3012
|
+
}
|
|
3013
|
+
function effectStatus(value: EffectRecord["state"]): string {
|
|
3014
|
+
return value === "indeterminate" ? "ambiguous" : value;
|
|
3015
|
+
}
|
|
3016
|
+
function controllerEffectState(value: string): EffectRecord["state"] {
|
|
3017
|
+
return value === "ambiguous" ? "indeterminate" : (value as EffectRecord["state"]);
|
|
3018
|
+
}
|
|
3019
|
+
function validTimestamp(value?: string): string {
|
|
3020
|
+
const date = value === undefined ? new Date() : new Date(value);
|
|
3021
|
+
if (!Number.isFinite(date.getTime())) throw new Error("Invalid timestamp");
|
|
3022
|
+
return date.toISOString();
|
|
3023
|
+
}
|
|
3024
|
+
function epoch(value: string): number {
|
|
3025
|
+
return Date.parse(value);
|
|
3026
|
+
}
|
|
3027
|
+
function validateName(value: string, label: string): void {
|
|
3028
|
+
validateKey(value, label);
|
|
3029
|
+
}
|
|
3030
|
+
function validateKey(value: string, label: string): void {
|
|
3031
|
+
if (value.length === 0 || value.length > 512) throw new Error(`${label} is invalid`);
|
|
3032
|
+
}
|
|
3033
|
+
function validateRunId(value: string): void {
|
|
3034
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(value))
|
|
3035
|
+
throw new Error(`Invalid workflow run id: ${value}`);
|
|
2564
3036
|
}
|