@osolmaz/pi-workflows 0.12.0 → 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 +56 -31
- package/dist/builtins/autodoc.workflow.d.ts +4 -4
- package/dist/builtins/autoimplement.workflow.d.ts +797 -142
- package/dist/builtins/autoimplement.workflow.js +17 -105
- package/dist/builtins/autoimplement.workflow.js.map +1 -1
- 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 +7 -5
- package/dist/builtins/catalog.js.map +1 -1
- package/dist/builtins/index.d.ts +4 -2
- package/dist/builtins/index.js +3 -1
- package/dist/builtins/index.js.map +1 -1
- package/dist/builtins/monitor.workflow.d.ts +56 -23
- package/dist/builtins/monitor.workflow.js +508 -329
- package/dist/builtins/monitor.workflow.js.map +1 -1
- package/dist/builtins/pi-agent-group.d.ts +72 -0
- package/dist/builtins/pi-agent-group.js +1087 -0
- package/dist/builtins/pi-agent-group.js.map +1 -0
- 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-approval.workflow.d.ts +39 -5
- package/dist/builtins/plan-approval.workflow.js +92 -14
- package/dist/builtins/plan-approval.workflow.js.map +1 -1
- package/dist/builtins/plan-change.workflow.d.ts +449 -0
- package/dist/builtins/plan-change.workflow.js +256 -0
- package/dist/builtins/plan-change.workflow.js.map +1 -0
- package/dist/builtins/plan-presentation.js +2 -2
- package/dist/builtins/plan-presentation.js.map +1 -1
- package/dist/builtins/sanity-check.workflow.d.ts +50 -6
- package/dist/builtins/sanity-check.workflow.js +150 -28
- 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 +4 -2
- package/dist/extension/decision-channels.js +333 -146
- 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 +209 -127
- 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/session-events.d.ts +2 -2
- 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 +29 -3
- 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 +15 -10
- 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 +9 -23
- 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/catalog.js +7 -2
- package/dist/workflows/catalog.js.map +1 -1
- package/dist/workflows/composition.js +20 -2
- package/dist/workflows/composition.js.map +1 -1
- package/dist/workflows/decision-presentation.d.ts +1 -1
- package/dist/workflows/decision-presentation.js +51 -38
- package/dist/workflows/decision-presentation.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 +7 -7
- package/dist/workflows/engine.js +196 -115
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/errors.d.ts +16 -3
- package/dist/workflows/errors.js +18 -3
- package/dist/workflows/errors.js.map +1 -1
- package/dist/workflows/human-decision.d.ts +45 -8
- package/dist/workflows/human-decision.js +641 -274
- package/dist/workflows/human-decision.js.map +1 -1
- package/dist/workflows/index.d.ts +5 -6
- package/dist/workflows/index.js +4 -5
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/progress.d.ts +1 -0
- package/dist/workflows/progress.js +15 -3
- package/dist/workflows/progress.js.map +1 -1
- package/dist/workflows/schema.js +27 -1
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.d.ts +83 -103
- package/dist/workflows/store.js +907 -951
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +88 -126
- 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 +36 -47
- package/docs/HUMAN_DECISION_PRESENTATIONS.md +14 -24
- package/docs/MONITOR.md +179 -237
- package/docs/SQLITE_STATE.md +219 -0
- package/docs/WORKFLOW_COMPOSITION.md +27 -26
- 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 +322 -0
- package/docs/plans/2026-08-21-sanity-check-plan.md +217 -95
- 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 +127 -53
- package/examples/workflows/approved-plan.workflow.ts +19 -46
- package/examples/workflows/plain-summary.workflow.ts +1 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +7 -7
- package/plugins/herdr/viewer.mjs +1 -11
- package/schemas/human-decision-accepted-v1.schema.json +15 -3
- package/schemas/human-decision-continuation-v1.schema.json +10 -1
- package/schemas/human-decision-delivery-v1.schema.json +8 -0
- package/schemas/human-decision-receipt-v1.schema.json +8 -0
- package/schemas/human-decision-request-v1.schema.json +24 -4
- package/skills/autoimplement/SKILL.md +27 -0
- package/skills/autoplan/SKILL.md +5 -4
- package/skills/monitor/SKILL.md +91 -135
- package/skills/pi-workflows/SKILL.md +9 -8
- package/skills/sanity-check/SKILL.md +44 -0
- package/src/builtins/autoimplement.workflow.ts +19 -118
- package/src/builtins/autoplan.workflow.ts +290 -40
- package/src/builtins/catalog.ts +7 -5
- package/src/builtins/index.ts +18 -1
- package/src/builtins/monitor.workflow.ts +673 -382
- package/src/builtins/pi-agent-group.ts +1407 -0
- package/src/builtins/plain-summary.workflow.ts +185 -0
- package/src/builtins/plan-approval.workflow.ts +157 -24
- package/src/builtins/plan-change.workflow.ts +321 -0
- package/src/builtins/plan-presentation.ts +2 -2
- package/src/builtins/sanity-check.workflow.ts +248 -48
- 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 +456 -203
- package/src/extension/executor.ts +105 -2
- package/src/extension/herdr-viewer.ts +0 -5
- package/src/extension/index.ts +251 -151
- package/src/extension/recorder.ts +13 -22
- package/src/extension/session-events.ts +2 -2
- package/src/extension/step-message.ts +106 -4
- package/src/extension/widget.ts +32 -5
- package/src/host/rpc-executor.ts +4 -0
- package/src/host/runner.ts +93 -41
- package/src/render/graph-render.ts +12 -6
- 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 +19 -30
- 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/catalog.ts +7 -2
- package/src/workflows/composition.ts +21 -3
- package/src/workflows/decision-presentation.ts +56 -43
- package/src/workflows/definition.ts +36 -3
- package/src/workflows/engine.ts +228 -130
- package/src/workflows/errors.ts +27 -3
- package/src/workflows/human-decision.ts +921 -349
- package/src/workflows/index.ts +20 -30
- package/src/workflows/progress.ts +18 -3
- package/src/workflows/schema.ts +36 -1
- package/src/workflows/store.ts +1300 -1163
- package/src/workflows/types.ts +100 -137
- package/dist/builtins/sanity-check-session.d.ts +0 -17
- package/dist/builtins/sanity-check-session.js +0 -168
- package/dist/builtins/sanity-check-session.js.map +0 -1
- 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/schemas/human-decision-accepted-v2.schema.json +0 -50
- package/schemas/human-decision-delivery-v2.schema.json +0 -36
- package/schemas/human-decision-receipt-v2.schema.json +0 -39
- package/schemas/human-decision-request-v2.schema.json +0 -69
- package/schemas/human-decision-resolution-v2.schema.json +0 -27
- package/src/builtins/sanity-check-session.ts +0 -205
- package/src/workflows/artifacts.ts +0 -188
- package/src/workflows/migrate-sources.ts +0 -178
|
@@ -1,76 +1,82 @@
|
|
|
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 { EffectRequestConflictError, ResourceConflictError, ResourceNotFoundError, WorkflowRequestConflictError, } from "./errors.js";
|
|
6
|
-
import { canonicalJson, parseStoredJson } from "./json.js";
|
|
7
|
-
import { CONTROLLER_STORE_SCHEMA, controllerStorePath, } from "./store.js";
|
|
8
|
-
const MAX_KEY_LENGTH = 512;
|
|
9
|
-
const MAX_ERROR_LENGTH = 8_192;
|
|
10
|
-
const MAX_EVENT_BYTES = 64 * 1024;
|
|
11
|
-
const MAX_RESOURCE_VALUE_BYTES = 1024 * 1024;
|
|
12
7
|
const TURN_INTENT_FACTS_SCHEMA = "pi-workflows.deferred-turn-facts.v1";
|
|
13
8
|
export class SqliteControllerStore {
|
|
14
9
|
filePath;
|
|
15
|
-
|
|
10
|
+
state;
|
|
11
|
+
ownsState;
|
|
12
|
+
projectId;
|
|
16
13
|
closed = false;
|
|
17
|
-
constructor(filePath =
|
|
18
|
-
this.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
this.configure(readOnly);
|
|
32
|
-
this.initializeSchema(readOnly);
|
|
14
|
+
constructor(filePath = workflowStatePath(), options = {}) {
|
|
15
|
+
this.ownsState = options.state === undefined;
|
|
16
|
+
this.state =
|
|
17
|
+
options.state ??
|
|
18
|
+
new StateDatabase({
|
|
19
|
+
filePath,
|
|
20
|
+
mode: options.readOnly === true ? "read-only" : "read-write",
|
|
21
|
+
checkLegacyState: filePath === workflowStatePath(),
|
|
22
|
+
});
|
|
23
|
+
this.filePath = this.state.filePath;
|
|
24
|
+
const projectPath = options.projectPath === undefined ? process.cwd() : options.projectPath;
|
|
25
|
+
this.projectId =
|
|
26
|
+
options.readOnly === true ? this.findProject(projectPath) : this.ensureProject(projectPath);
|
|
33
27
|
}
|
|
34
28
|
close() {
|
|
35
|
-
if (this.closed)
|
|
29
|
+
if (this.closed)
|
|
36
30
|
return;
|
|
37
|
-
}
|
|
38
31
|
this.closed = true;
|
|
39
|
-
this.
|
|
32
|
+
if (this.ownsState)
|
|
33
|
+
this.state.close();
|
|
40
34
|
}
|
|
41
35
|
putResource(options) {
|
|
36
|
+
this.assertWritable();
|
|
42
37
|
validateName(options.controller, "controller");
|
|
43
38
|
validateKey(options.key, "resource key");
|
|
44
|
-
const now = validTimestamp(options.now);
|
|
45
|
-
const
|
|
46
|
-
validateJsonSize(specJson, "Controller resource spec", MAX_RESOURCE_VALUE_BYTES);
|
|
39
|
+
const now = epoch(validTimestamp(options.now));
|
|
40
|
+
const specHash = this.state.putJson(options.spec, now);
|
|
47
41
|
const initialStatus = {
|
|
48
42
|
observedGeneration: 0,
|
|
49
43
|
conditions: [],
|
|
50
44
|
controllerStatus: options.initialStatus,
|
|
51
45
|
};
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const existing = this.resourceRow({ controller: options.controller, key: options.key });
|
|
46
|
+
const statusHash = this.state.putJson(initialStatus, now);
|
|
47
|
+
return this.state.transaction(() => {
|
|
48
|
+
const existing = this.controllerRow({ controller: options.controller, key: options.key });
|
|
56
49
|
if (existing === undefined) {
|
|
57
50
|
const uid = randomUUID();
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
51
|
+
const controllerResourceId = `controller-resource-${randomUUID()}`;
|
|
52
|
+
const resourceId = resourceIdFor("controller", `${this.requireProjectId()}:${options.controller}:${options.key}`);
|
|
53
|
+
this.state.connection
|
|
54
|
+
.prepare(`INSERT INTO resources(
|
|
55
|
+
resource_id, resource_type, aggregate_key, revision, created_at, updated_at
|
|
56
|
+
) VALUES (?, 'controller', ?, 1, ?, ?)`)
|
|
57
|
+
.run(resourceId, controllerResourceId, now, now);
|
|
58
|
+
this.state.connection
|
|
59
|
+
.prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
|
|
60
|
+
.run(resourceId);
|
|
61
|
+
this.state.connection
|
|
62
|
+
.prepare(`INSERT INTO controller_resources(
|
|
63
|
+
controller_resource_id, resource_id, project_id, controller_name,
|
|
64
|
+
resource_key, uid, generation, spec_hash, status_hash, created_at, updated_at
|
|
65
|
+
) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)`)
|
|
66
|
+
.run(controllerResourceId, resourceId, this.requireProjectId(), options.controller, options.key, uid, specHash, statusHash, now, now);
|
|
67
|
+
this.enqueueControllerRow(controllerResourceId, now, now);
|
|
68
|
+
this.insertEvent(resourceId, 1, "resource.created", "control", null, { generation: 1 }, now);
|
|
65
69
|
}
|
|
66
|
-
else if (existing.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
WHERE
|
|
72
|
-
.run(
|
|
73
|
-
this.
|
|
70
|
+
else if (!existing.specHash.equals(specHash)) {
|
|
71
|
+
const revision = existing.resourceVersion + 1;
|
|
72
|
+
this.state.connection
|
|
73
|
+
.prepare(`UPDATE controller_resources
|
|
74
|
+
SET spec_hash = ?, generation = generation + 1, updated_at = ?
|
|
75
|
+
WHERE controller_resource_id = ?`)
|
|
76
|
+
.run(specHash, now, existing.controllerResourceId);
|
|
77
|
+
this.bumpResource(existing.resourceId, existing.resourceVersion, now);
|
|
78
|
+
this.enqueueControllerRow(existing.controllerResourceId, now, now);
|
|
79
|
+
this.insertEvent(existing.resourceId, revision, "resource.spec_updated", "control", null, { generation: existing.generation + 1 }, now);
|
|
74
80
|
}
|
|
75
81
|
return this.requireResource({
|
|
76
82
|
controller: options.controller,
|
|
@@ -79,1627 +85,1746 @@ export class SqliteControllerStore {
|
|
|
79
85
|
});
|
|
80
86
|
}
|
|
81
87
|
getResource(ref) {
|
|
82
|
-
const row = this.
|
|
83
|
-
return row === undefined
|
|
88
|
+
const row = this.controllerRow(ref);
|
|
89
|
+
return row === undefined
|
|
90
|
+
? undefined
|
|
91
|
+
: this.mapControllerResource(row);
|
|
84
92
|
}
|
|
85
93
|
getResourceByUid(uid) {
|
|
86
|
-
const row = this.
|
|
87
|
-
return row
|
|
94
|
+
const row = this.state.connection.prepare(controllerSelect("WHERE c.uid = ?")).get(uid);
|
|
95
|
+
return isControllerRow(row) ? this.mapControllerResource(row) : undefined;
|
|
88
96
|
}
|
|
89
97
|
listResources(options = {}) {
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
+
const clauses = ["1 = 1"];
|
|
99
|
+
const params = [];
|
|
100
|
+
if (this.projectId !== null) {
|
|
101
|
+
clauses.push("c.project_id = ?");
|
|
102
|
+
params.push(this.projectId);
|
|
103
|
+
}
|
|
104
|
+
if (options.controller !== undefined) {
|
|
105
|
+
clauses.push("c.controller_name = ?");
|
|
106
|
+
params.push(options.controller);
|
|
107
|
+
}
|
|
108
|
+
const rows = this.state.connection
|
|
109
|
+
.prepare(controllerSelect(`WHERE ${clauses.join(" AND ")} ORDER BY c.controller_name, c.resource_key`))
|
|
110
|
+
.all(...params);
|
|
111
|
+
return rows
|
|
112
|
+
.filter(isControllerRow)
|
|
113
|
+
.map((row) => this.mapControllerResource(row));
|
|
98
114
|
}
|
|
99
115
|
updateStatus(options) {
|
|
100
|
-
const now = validTimestamp(options.now);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
return this.transaction(() => {
|
|
107
|
-
const current = this.resourceRow(options.ref);
|
|
108
|
-
if (current === undefined) {
|
|
109
|
-
throw new ResourceNotFoundError(options.ref.controller, options.ref.key);
|
|
110
|
-
}
|
|
111
|
-
if (current.resource_version !== options.expectedResourceVersion) {
|
|
116
|
+
const now = epoch(validTimestamp(options.now));
|
|
117
|
+
return this.state.transaction(() => {
|
|
118
|
+
const row = this.requireControllerRow(options.ref);
|
|
119
|
+
this.assertControllerClaim(row, options.claim, now);
|
|
120
|
+
if (row.resourceVersion !== options.expectedResourceVersion) {
|
|
112
121
|
throw new ResourceConflictError(options.ref.controller, options.ref.key);
|
|
113
122
|
}
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
this.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
resource_version = resource_version + 1, updated_at = ?
|
|
124
|
-
WHERE controller = ? AND resource_key = ? AND resource_version = ?`)
|
|
125
|
-
.run(statusJson, finalizersJson, now, options.ref.controller, options.ref.key, options.expectedResourceVersion);
|
|
123
|
+
const statusHash = this.state.putJson(options.status, now);
|
|
124
|
+
this.state.connection
|
|
125
|
+
.prepare("UPDATE controller_resources SET status_hash = ?, updated_at = ? WHERE controller_resource_id = ?")
|
|
126
|
+
.run(statusHash, now, row.controllerResourceId);
|
|
127
|
+
if (options.finalizers !== undefined)
|
|
128
|
+
this.replaceFinalizers(row.controllerResourceId, options.finalizers);
|
|
129
|
+
this.bumpResource(row.resourceId, row.resourceVersion, now);
|
|
130
|
+
this.insertEvent(row.resourceId, row.resourceVersion + 1, "resource.status_updated", "controller", options.claim.ownerId, {}, now, options.claim.generation);
|
|
131
|
+
options.claim.resourceVersion = row.resourceVersion + 1;
|
|
126
132
|
return this.requireResource(options.ref);
|
|
127
133
|
});
|
|
128
134
|
}
|
|
129
|
-
requestDeletion(ref,
|
|
130
|
-
const
|
|
131
|
-
return this.transaction(() => {
|
|
132
|
-
const row = this.
|
|
133
|
-
if (row ===
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
this.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
WHERE controller = ? AND resource_key = ?`)
|
|
141
|
-
.run(timestamp, timestamp, ref.controller, ref.key);
|
|
135
|
+
requestDeletion(ref, nowValue) {
|
|
136
|
+
const now = epoch(validTimestamp(nowValue));
|
|
137
|
+
return this.state.transaction(() => {
|
|
138
|
+
const row = this.requireControllerRow(ref);
|
|
139
|
+
if (row.deletionRequestedAt === null) {
|
|
140
|
+
this.state.connection
|
|
141
|
+
.prepare("UPDATE controller_resources SET deletion_requested_at = ?, updated_at = ? WHERE controller_resource_id = ?")
|
|
142
|
+
.run(now, now, row.controllerResourceId);
|
|
143
|
+
this.bumpResource(row.resourceId, row.resourceVersion, now);
|
|
144
|
+
this.enqueueControllerRow(row.controllerResourceId, now, now);
|
|
145
|
+
this.insertEvent(row.resourceId, row.resourceVersion + 1, "resource.deletion_requested", "control", null, {}, now);
|
|
142
146
|
}
|
|
143
|
-
this.enqueueRow(ref, epoch(timestamp));
|
|
144
147
|
return this.requireResource(ref);
|
|
145
148
|
});
|
|
146
149
|
}
|
|
147
150
|
updateFinalizers(options) {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
151
|
+
const now = epoch(validTimestamp(options.now));
|
|
152
|
+
return this.state.transaction(() => {
|
|
153
|
+
const row = this.requireControllerRow(options.ref);
|
|
154
|
+
if (row.resourceVersion !== options.expectedResourceVersion) {
|
|
155
|
+
throw new ResourceConflictError(options.ref.controller, options.ref.key);
|
|
156
|
+
}
|
|
157
|
+
this.replaceFinalizers(row.controllerResourceId, options.finalizers);
|
|
158
|
+
this.state.connection
|
|
159
|
+
.prepare("UPDATE controller_resources SET updated_at = ? WHERE controller_resource_id = ?")
|
|
160
|
+
.run(now, row.controllerResourceId);
|
|
161
|
+
this.bumpResource(row.resourceId, row.resourceVersion, now);
|
|
162
|
+
this.insertEvent(row.resourceId, row.resourceVersion + 1, "resource.finalizers_updated", "control", null, {}, now);
|
|
163
|
+
return this.requireResource(options.ref);
|
|
164
|
+
});
|
|
159
165
|
}
|
|
160
|
-
deleteResource(ref, expectedResourceVersion) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
throw new ResourceConflictError(ref.controller, ref.key);
|
|
175
|
-
}
|
|
176
|
-
return true;
|
|
166
|
+
deleteResource(ref, expectedResourceVersion, claim) {
|
|
167
|
+
return this.state.transaction(() => {
|
|
168
|
+
const row = this.requireControllerRow(ref);
|
|
169
|
+
this.assertControllerClaim(row, claim, Date.now());
|
|
170
|
+
if (row.resourceVersion !== expectedResourceVersion) {
|
|
171
|
+
throw new ResourceConflictError(ref.controller, ref.key);
|
|
172
|
+
}
|
|
173
|
+
if (row.deletionRequestedAt === null || this.finalizers(row.controllerResourceId).length > 0)
|
|
174
|
+
return false;
|
|
175
|
+
this.state.connection
|
|
176
|
+
.prepare("DELETE FROM controller_resources WHERE controller_resource_id = ?")
|
|
177
|
+
.run(row.controllerResourceId);
|
|
178
|
+
return true;
|
|
179
|
+
});
|
|
177
180
|
}
|
|
178
181
|
enqueue(ref, availableAt) {
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
this.enqueueRow(ref, epoch(at));
|
|
182
|
+
const row = this.requireControllerRow(ref);
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
this.enqueueControllerRow(row.controllerResourceId, epoch(validTimestamp(availableAt)), now);
|
|
184
185
|
}
|
|
185
186
|
claimNext(options) {
|
|
186
|
-
|
|
187
|
-
if (options.controllers.length === 0) {
|
|
187
|
+
if (options.controllers.length === 0)
|
|
188
188
|
return undefined;
|
|
189
|
-
|
|
190
|
-
const now = validTimestamp(options.now);
|
|
191
|
-
const nowMs = epoch(now);
|
|
189
|
+
const now = epoch(validTimestamp(options.now));
|
|
192
190
|
const placeholders = options.controllers.map(() => "?").join(", ");
|
|
193
|
-
return this.transaction(() => {
|
|
194
|
-
const row = this.
|
|
195
|
-
.prepare(`
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
191
|
+
return this.state.transaction(() => {
|
|
192
|
+
const row = this.state.connection
|
|
193
|
+
.prepare(`${controllerSelect(`
|
|
194
|
+
JOIN controller_queue q ON q.controller_resource_id = c.controller_resource_id
|
|
195
|
+
JOIN leases l ON l.resource_id = c.resource_id
|
|
196
|
+
WHERE c.project_id = ?
|
|
197
|
+
AND c.controller_name IN (${placeholders})
|
|
198
|
+
AND q.available_at <= ?
|
|
199
|
+
AND (l.owner_id IS NULL OR l.expires_at <= ?)
|
|
200
|
+
ORDER BY q.available_at, c.controller_name, c.resource_key
|
|
201
|
+
LIMIT 1`)}`)
|
|
202
|
+
.get(this.requireProjectId(), ...options.controllers, now, now);
|
|
203
|
+
if (!isControllerRow(row))
|
|
203
204
|
return undefined;
|
|
204
|
-
|
|
205
|
-
const token =
|
|
206
|
-
const
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
205
|
+
const lease = this.requireLease(row.resourceId);
|
|
206
|
+
const token = randomBytes(32).toString("base64url");
|
|
207
|
+
const generation = lease.generation + 1;
|
|
208
|
+
const expiresAt = now + options.leaseMs;
|
|
209
|
+
const result = this.state.connection
|
|
210
|
+
.prepare(`UPDATE leases
|
|
211
|
+
SET generation = ?, owner_type = 'controller', owner_id = ?, token_hash = ?,
|
|
212
|
+
acquired_at = ?, heartbeat_at = ?, expires_at = ?
|
|
213
|
+
WHERE resource_id = ? AND generation = ?
|
|
214
|
+
AND (owner_id IS NULL OR expires_at <= ?)`)
|
|
215
|
+
.run(generation, options.ownerId, tokenHash(token), now, now, expiresAt, row.resourceId, lease.generation, now);
|
|
216
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
217
|
+
if (result.changes !== 1)
|
|
214
218
|
return undefined;
|
|
215
|
-
|
|
219
|
+
this.bumpResource(row.resourceId, row.resourceVersion, now);
|
|
220
|
+
this.insertEvent(row.resourceId, row.resourceVersion + 1, "lease.claimed", "controller", options.ownerId, { expiresAt }, now, generation);
|
|
216
221
|
return {
|
|
217
|
-
controller: row.
|
|
218
|
-
key: row.
|
|
222
|
+
controller: row.controllerName,
|
|
223
|
+
key: row.resourceKey,
|
|
224
|
+
ownerId: options.ownerId,
|
|
219
225
|
token,
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
226
|
+
generation,
|
|
227
|
+
queueVersion: this.queueVersion(row.controllerResourceId),
|
|
228
|
+
resourceVersion: row.resourceVersion + 1,
|
|
229
|
+
consecutiveErrors: this.queueErrors(row.controllerResourceId),
|
|
230
|
+
expiresAt: new Date(expiresAt).toISOString(),
|
|
223
231
|
};
|
|
224
232
|
});
|
|
225
233
|
}
|
|
226
|
-
renewClaim(claim, leaseMs,
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
234
|
+
renewClaim(claim, leaseMs, nowValue) {
|
|
235
|
+
const now = epoch(validTimestamp(nowValue));
|
|
236
|
+
const expiresAt = now + leaseMs;
|
|
237
|
+
const row = this.controllerRow({ controller: claim.controller, key: claim.key });
|
|
238
|
+
if (row === undefined)
|
|
239
|
+
return false;
|
|
240
|
+
const result = this.state.connection
|
|
241
|
+
.prepare(`UPDATE leases SET heartbeat_at = ?, expires_at = ?
|
|
242
|
+
WHERE resource_id = ? AND owner_type = 'controller' AND owner_id = ?
|
|
243
|
+
AND token_hash = ? AND generation = ? AND expires_at > ?`)
|
|
244
|
+
.run(now, expiresAt, row.resourceId, claim.ownerId, tokenHash(claim.token), claim.generation, now);
|
|
245
|
+
if (result.changes === 1)
|
|
246
|
+
claim.expiresAt = new Date(expiresAt).toISOString();
|
|
235
247
|
return result.changes === 1;
|
|
236
248
|
}
|
|
237
|
-
settleClaim(claim,
|
|
238
|
-
|
|
239
|
-
return this.transaction(() => {
|
|
240
|
-
const row = this.claimedRow(claim);
|
|
241
|
-
if (row === undefined) {
|
|
242
|
-
return false;
|
|
243
|
-
}
|
|
244
|
-
if (row.version === row.claim_version) {
|
|
245
|
-
this.database
|
|
246
|
-
.prepare("DELETE FROM queue WHERE controller = ? AND resource_key = ? AND claim_token = ?")
|
|
247
|
-
.run(claim.controller, claim.key, claim.token);
|
|
248
|
-
}
|
|
249
|
-
else {
|
|
250
|
-
this.database
|
|
251
|
-
.prepare(`UPDATE queue
|
|
252
|
-
SET claim_token = NULL, claim_version = NULL, claim_expires_at = NULL,
|
|
253
|
-
available_at = MIN(available_at, ?), consecutive_errors = 0, last_error = NULL
|
|
254
|
-
WHERE controller = ? AND resource_key = ? AND claim_token = ?`)
|
|
255
|
-
.run(nowMs, claim.controller, claim.key, claim.token);
|
|
256
|
-
}
|
|
257
|
-
return true;
|
|
258
|
-
});
|
|
249
|
+
settleClaim(claim, nowValue) {
|
|
250
|
+
return this.settleControllerClaim(claim, undefined, nowValue);
|
|
259
251
|
}
|
|
260
|
-
requeueClaim(claim, options,
|
|
261
|
-
|
|
262
|
-
const error = options.error === undefined ? null : truncateError(options.error);
|
|
263
|
-
return this.transaction(() => {
|
|
264
|
-
const row = this.claimedRow(claim);
|
|
265
|
-
if (row === undefined) {
|
|
266
|
-
return false;
|
|
267
|
-
}
|
|
268
|
-
const eventArrived = row.version !== row.claim_version;
|
|
269
|
-
const nextAvailableAt = eventArrived ? Math.min(row.available_at, availableAt) : availableAt;
|
|
270
|
-
const nextErrors = options.error === undefined ? 0 : row.consecutive_errors + 1;
|
|
271
|
-
this.database
|
|
272
|
-
.prepare(`UPDATE queue
|
|
273
|
-
SET claim_token = NULL, claim_version = NULL, claim_expires_at = NULL,
|
|
274
|
-
available_at = ?, consecutive_errors = ?, last_error = ?
|
|
275
|
-
WHERE controller = ? AND resource_key = ? AND claim_token = ?`)
|
|
276
|
-
.run(nextAvailableAt, nextErrors, error, claim.controller, claim.key, claim.token);
|
|
277
|
-
return true;
|
|
278
|
-
});
|
|
252
|
+
requeueClaim(claim, options, nowValue) {
|
|
253
|
+
return this.settleControllerClaim(claim, options, nowValue);
|
|
279
254
|
}
|
|
280
255
|
listQueue() {
|
|
281
|
-
const rows = this.
|
|
282
|
-
.prepare(
|
|
283
|
-
|
|
284
|
-
|
|
256
|
+
const rows = this.state.connection
|
|
257
|
+
.prepare(`SELECT c.controller_name AS controller, c.resource_key AS resourceKey,
|
|
258
|
+
q.available_at AS availableAt, q.consecutive_errors AS consecutiveErrors,
|
|
259
|
+
l.expires_at AS claimExpiresAt
|
|
260
|
+
FROM controller_queue q
|
|
261
|
+
JOIN controller_resources c ON c.controller_resource_id = q.controller_resource_id
|
|
262
|
+
JOIN leases l ON l.resource_id = c.resource_id
|
|
263
|
+
WHERE c.project_id = ? ORDER BY q.available_at, c.controller_name, c.resource_key`)
|
|
264
|
+
.all(this.requireProjectId());
|
|
265
|
+
return rows.filter(isQueueListRow).map((row) => ({
|
|
285
266
|
controller: row.controller,
|
|
286
|
-
key: row.
|
|
287
|
-
availableAt:
|
|
288
|
-
consecutiveErrors: row.
|
|
289
|
-
...(row.
|
|
267
|
+
key: row.resourceKey,
|
|
268
|
+
availableAt: new Date(row.availableAt).toISOString(),
|
|
269
|
+
consecutiveErrors: row.consecutiveErrors,
|
|
270
|
+
...(row.claimExpiresAt === null
|
|
271
|
+
? {}
|
|
272
|
+
: { claimExpiresAt: new Date(row.claimExpiresAt).toISOString() }),
|
|
290
273
|
}));
|
|
291
274
|
}
|
|
292
275
|
reserveEffect(options) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
276
|
+
const now = epoch(validTimestamp(options.now));
|
|
277
|
+
return this.state.transaction(() => {
|
|
278
|
+
const source = this.requireControllerRowByUid(options.resourceUid);
|
|
279
|
+
this.assertControllerClaim(source, options.claim, now);
|
|
280
|
+
const effectId = effectIdFor(source.resourceId, options.key);
|
|
297
281
|
const existing = this.effectRow(options.resourceUid, options.key);
|
|
298
282
|
if (existing !== undefined) {
|
|
299
|
-
if (existing.
|
|
300
|
-
existing.
|
|
301
|
-
existing.
|
|
283
|
+
if (existing.generation !== options.generation ||
|
|
284
|
+
existing.kind !== options.kind ||
|
|
285
|
+
existing.requestFingerprint !== options.requestFingerprint) {
|
|
302
286
|
throw new EffectRequestConflictError(options.key);
|
|
303
287
|
}
|
|
304
|
-
return { record:
|
|
288
|
+
return { record: this.mapEffect(existing), created: false };
|
|
305
289
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
290
|
+
const effectResourceId = resourceIdFor("effect", effectId);
|
|
291
|
+
const payloadHash = this.state.putJson({
|
|
292
|
+
key: options.key,
|
|
293
|
+
resourceUid: options.resourceUid,
|
|
294
|
+
generation: options.generation,
|
|
295
|
+
kind: options.kind,
|
|
296
|
+
requestFingerprint: options.requestFingerprint,
|
|
297
|
+
}, now);
|
|
298
|
+
this.state.connection
|
|
299
|
+
.prepare(`INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at)
|
|
300
|
+
VALUES (?, 'effect', ?, 1, ?, ?)`)
|
|
301
|
+
.run(effectResourceId, effectId, now, now);
|
|
302
|
+
this.state.connection
|
|
303
|
+
.prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
|
|
304
|
+
.run(effectResourceId);
|
|
305
|
+
this.state.connection
|
|
306
|
+
.prepare(`INSERT INTO effects(
|
|
307
|
+
effect_id, resource_id, source_resource_id, source_revision, effect_type,
|
|
308
|
+
idempotency_key, payload_hash, owner_scope, status, attempt_count,
|
|
309
|
+
created_at, updated_at
|
|
310
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 'controller', 'pending', 0, ?, ?)`)
|
|
311
|
+
.run(effectId, effectResourceId, source.resourceId, source.resourceVersion, options.kind, options.key, payloadHash, now, now);
|
|
312
|
+
this.insertEvent(effectResourceId, 1, "effect.reserved", "controller", options.claim.ownerId, {}, now, options.claim.generation);
|
|
312
313
|
return {
|
|
313
|
-
record: this.
|
|
314
|
+
record: this.mapEffect(this.requireEffectRow(options.resourceUid, options.key)),
|
|
314
315
|
created: true,
|
|
315
316
|
};
|
|
316
317
|
});
|
|
317
318
|
}
|
|
318
319
|
getEffect(resourceUid, key) {
|
|
319
320
|
const row = this.effectRow(resourceUid, key);
|
|
320
|
-
return row === undefined ? undefined :
|
|
321
|
+
return row === undefined ? undefined : this.mapEffect(row);
|
|
321
322
|
}
|
|
322
323
|
updateEffect(options) {
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
.
|
|
338
|
-
|
|
324
|
+
const now = epoch(validTimestamp(options.now));
|
|
325
|
+
return this.state.transaction(() => {
|
|
326
|
+
const source = this.requireControllerRowByUid(options.resourceUid);
|
|
327
|
+
this.assertControllerClaim(source, options.claim, now);
|
|
328
|
+
const row = this.requireEffectRow(options.resourceUid, options.key);
|
|
329
|
+
const effectResourceId = resourceIdFor("effect", row.effectId);
|
|
330
|
+
const revision = this.resourceRevision(effectResourceId);
|
|
331
|
+
const errorHash = options.error === undefined ? null : this.state.putText(options.error, now);
|
|
332
|
+
this.state.connection
|
|
333
|
+
.prepare(`UPDATE effects
|
|
334
|
+
SET status = ?, external_ref = ?, error_hash = ?, updated_at = ?, settled_at = ?
|
|
335
|
+
WHERE effect_id = ?`)
|
|
336
|
+
.run(effectStatus(options.state), options.externalRef ?? null, errorHash, now, options.state === "pending" ? null : now, row.effectId);
|
|
337
|
+
this.bumpResource(effectResourceId, revision, now);
|
|
338
|
+
this.insertEvent(effectResourceId, revision + 1, `effect.${options.state}`, "controller", options.claim.ownerId, {}, now, options.claim.generation);
|
|
339
|
+
return this.mapEffect(this.requireEffectRow(options.resourceUid, options.key));
|
|
340
|
+
});
|
|
339
341
|
}
|
|
340
342
|
listEffects(resourceUid) {
|
|
341
|
-
const rows = this.
|
|
342
|
-
.prepare("
|
|
343
|
+
const rows = this.state.connection
|
|
344
|
+
.prepare(effectSelect("WHERE c.uid = ? ORDER BY e.created_at, e.effect_id"))
|
|
343
345
|
.all(resourceUid);
|
|
344
|
-
return rows.map(
|
|
346
|
+
return rows.filter(isEffectRow).map((row) => this.mapEffect(row));
|
|
345
347
|
}
|
|
346
348
|
reserveWorkflow(options) {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
349
|
+
const now = Date.now();
|
|
350
|
+
return this.state.transaction(() => {
|
|
351
|
+
const source = this.requireControllerRowByUid(options.resourceUid);
|
|
352
|
+
this.assertControllerClaim(source, options.claim, now);
|
|
350
353
|
const existing = this.workflowRow(options.resourceUid, options.requestKey);
|
|
351
354
|
if (existing !== undefined) {
|
|
352
|
-
if (existing.
|
|
353
|
-
existing.
|
|
355
|
+
if (existing.workflowName !== options.workflow ||
|
|
356
|
+
existing.inputFingerprint.toString("hex") !== options.inputFingerprint) {
|
|
354
357
|
throw new WorkflowRequestConflictError(options.requestKey);
|
|
355
358
|
}
|
|
356
|
-
return { record:
|
|
359
|
+
return { record: this.mapWorkflow(existing), created: false };
|
|
357
360
|
}
|
|
358
361
|
const requestId = randomUUID();
|
|
359
|
-
this.
|
|
360
|
-
.prepare(`INSERT INTO
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
.run(requestId,
|
|
365
|
-
return {
|
|
366
|
-
record: this.requireWorkflow(requestId),
|
|
367
|
-
created: true,
|
|
368
|
-
};
|
|
362
|
+
this.state.connection
|
|
363
|
+
.prepare(`INSERT INTO controller_workflows(
|
|
364
|
+
request_id, controller_resource_id, request_key, workflow_name,
|
|
365
|
+
input_fingerprint, status, attempt_count, created_at, updated_at
|
|
366
|
+
) VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?)`)
|
|
367
|
+
.run(requestId, source.controllerResourceId, options.requestKey, options.workflow, Buffer.from(options.inputFingerprint, "hex"), now, now);
|
|
368
|
+
return { record: this.mapWorkflow(this.requireWorkflowRow(requestId)), created: true };
|
|
369
369
|
});
|
|
370
370
|
}
|
|
371
371
|
getWorkflow(resourceUid, requestKey) {
|
|
372
372
|
const row = this.workflowRow(resourceUid, requestKey);
|
|
373
|
-
return row === undefined ? undefined :
|
|
373
|
+
return row === undefined ? undefined : this.mapWorkflow(row);
|
|
374
374
|
}
|
|
375
375
|
getWorkflowByRequestId(requestId) {
|
|
376
|
-
const row = this.
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
376
|
+
const row = this.workflowRowById(requestId);
|
|
377
|
+
return row === undefined ? undefined : this.mapWorkflow(row);
|
|
378
|
+
}
|
|
379
|
+
updateWorkflow(requestId, update, claim) {
|
|
380
|
+
const now = Date.now();
|
|
381
|
+
return this.state.transaction(() => {
|
|
382
|
+
const row = this.requireWorkflowRow(requestId);
|
|
383
|
+
const source = this.requireControllerRowByUid(row.resourceUid);
|
|
384
|
+
this.assertControllerClaim(source, claim, now);
|
|
385
|
+
const errorHash = update.error === undefined || update.error === null
|
|
386
|
+
? null
|
|
387
|
+
: this.state.putText(update.error, now);
|
|
388
|
+
this.state.connection
|
|
389
|
+
.prepare(`UPDATE controller_workflows
|
|
390
|
+
SET reserved_run_id = COALESCE(?, reserved_run_id),
|
|
391
|
+
run_id = CASE
|
|
392
|
+
WHEN ? IS NOT NULL AND EXISTS(SELECT 1 FROM runs WHERE run_id = ?) THEN ?
|
|
393
|
+
ELSE run_id
|
|
394
|
+
END,
|
|
395
|
+
status = ?, attempt_count = COALESCE(?, attempt_count),
|
|
396
|
+
error_hash = ?, updated_at = ?
|
|
397
|
+
WHERE request_id = ?`)
|
|
398
|
+
.run(update.runId ?? null, update.runId ?? null, update.runId ?? null, update.runId ?? null, update.state, update.attempt ?? null, errorHash, now, requestId);
|
|
399
|
+
return this.mapWorkflow(this.requireWorkflowRow(requestId));
|
|
400
|
+
});
|
|
380
401
|
}
|
|
381
|
-
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
if (existing === undefined) {
|
|
386
|
-
throw new Error(`Workflow request not found: ${requestId}`);
|
|
402
|
+
completeWorkflow(requestId, update) {
|
|
403
|
+
const row = this.requireWorkflowRow(requestId);
|
|
404
|
+
if (update.state === "pending" || update.state === "running") {
|
|
405
|
+
throw new Error("Scheduler completion must report a settled child state");
|
|
387
406
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
407
|
+
if (update.runId !== undefined && row.runId !== null && update.runId !== row.runId) {
|
|
408
|
+
throw new Error("Scheduler completion run ID does not match the reserved child run");
|
|
409
|
+
}
|
|
410
|
+
const source = this.requireControllerRowByUid(row.resourceUid);
|
|
411
|
+
const now = Date.now();
|
|
412
|
+
return this.state.transaction(() => {
|
|
413
|
+
const errorHash = update.error === undefined || update.error === null
|
|
393
414
|
? null
|
|
394
|
-
:
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
415
|
+
: this.state.putText(update.error, now);
|
|
416
|
+
this.state.connection
|
|
417
|
+
.prepare(`UPDATE controller_workflows
|
|
418
|
+
SET run_id = CASE
|
|
419
|
+
WHEN ? IS NOT NULL AND EXISTS(SELECT 1 FROM runs WHERE run_id = ?) THEN ?
|
|
420
|
+
ELSE run_id
|
|
421
|
+
END,
|
|
422
|
+
status = ?, attempt_count = COALESCE(?, attempt_count),
|
|
423
|
+
error_hash = ?, updated_at = ?
|
|
424
|
+
WHERE request_id = ?`)
|
|
425
|
+
.run(update.runId ?? null, update.runId ?? null, update.runId ?? null, update.state, update.attempt ?? null, errorHash, now, requestId);
|
|
426
|
+
const revision = this.resourceRevision(source.resourceId);
|
|
427
|
+
this.bumpResource(source.resourceId, revision, now);
|
|
428
|
+
this.insertEvent(source.resourceId, revision + 1, "workflow_state_changed", "system", null, { requestId, state: update.state, runId: update.runId ?? row.runId }, now);
|
|
429
|
+
return this.mapWorkflow(this.requireWorkflowRow(requestId));
|
|
430
|
+
});
|
|
401
431
|
}
|
|
402
432
|
listWorkflows(resourceUid) {
|
|
403
|
-
const rows = this.
|
|
404
|
-
.prepare("
|
|
433
|
+
const rows = this.state.connection
|
|
434
|
+
.prepare(workflowSelect("WHERE c.uid = ? ORDER BY w.created_at, w.request_id"))
|
|
405
435
|
.all(resourceUid);
|
|
406
|
-
return rows.map(
|
|
436
|
+
return rows.filter(isWorkflowRow).map((row) => this.mapWorkflow(row));
|
|
407
437
|
}
|
|
408
438
|
recordEvent(options) {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
439
|
+
const now = epoch(validTimestamp(options.now));
|
|
440
|
+
return this.state.transaction(() => {
|
|
441
|
+
const row = this.requireControllerRow({ controller: options.controller, key: options.key });
|
|
442
|
+
if (options.claim !== undefined)
|
|
443
|
+
this.assertControllerClaim(row, options.claim, now);
|
|
444
|
+
const revision = this.resourceRevision(row.resourceId) + 1;
|
|
445
|
+
this.bumpResource(row.resourceId, revision - 1, now);
|
|
446
|
+
const eventId = this.insertEvent(row.resourceId, revision, options.type, options.claim === undefined ? "control" : "controller", options.claim?.ownerId ?? null, options.payload ?? {}, now, options.claim?.generation);
|
|
447
|
+
if (options.claim !== undefined)
|
|
448
|
+
options.claim.resourceVersion = revision;
|
|
449
|
+
const event = this.state.connection
|
|
450
|
+
.prepare("SELECT event_seq AS seq FROM events WHERE event_id = ?")
|
|
451
|
+
.get(eventId);
|
|
452
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
453
|
+
if (!isSequenceRow(event))
|
|
454
|
+
throw new Error("Controller event was not recorded");
|
|
455
|
+
return {
|
|
456
|
+
seq: event.seq,
|
|
457
|
+
recordedAt: new Date(now).toISOString(),
|
|
458
|
+
controller: options.controller,
|
|
459
|
+
key: options.key,
|
|
460
|
+
type: options.type,
|
|
461
|
+
payload: options.payload ?? {},
|
|
462
|
+
};
|
|
463
|
+
});
|
|
422
464
|
}
|
|
423
465
|
listEvents(options = {}) {
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
466
|
+
const clauses = ["r.resource_type = 'controller'"];
|
|
467
|
+
const params = [];
|
|
468
|
+
if (this.projectId !== null) {
|
|
469
|
+
clauses.push("c.project_id = ?");
|
|
470
|
+
params.push(this.projectId);
|
|
427
471
|
}
|
|
428
|
-
const clauses = [];
|
|
429
|
-
const values = [];
|
|
430
472
|
if (options.controller !== undefined) {
|
|
431
|
-
clauses.push("
|
|
432
|
-
|
|
473
|
+
clauses.push("c.controller_name = ?");
|
|
474
|
+
params.push(options.controller);
|
|
433
475
|
}
|
|
434
476
|
if (options.key !== undefined) {
|
|
435
|
-
clauses.push("resource_key = ?");
|
|
436
|
-
|
|
437
|
-
}
|
|
438
|
-
const where = clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`;
|
|
439
|
-
const rows = this.database
|
|
440
|
-
.prepare(`SELECT * FROM events ${where} ORDER BY seq DESC LIMIT ?`)
|
|
441
|
-
.all(...values, limit);
|
|
442
|
-
return rows.map(eventFromRow);
|
|
443
|
-
}
|
|
444
|
-
configure(readOnly) {
|
|
445
|
-
if (!readOnly) {
|
|
446
|
-
this.database.pragma("journal_mode = WAL");
|
|
447
|
-
this.database.pragma("synchronous = FULL");
|
|
448
|
-
this.database.pragma("foreign_keys = ON");
|
|
449
|
-
}
|
|
450
|
-
this.database.pragma("busy_timeout = 5000");
|
|
451
|
-
}
|
|
452
|
-
initializeSchema(readOnly) {
|
|
453
|
-
if (!readOnly) {
|
|
454
|
-
this.database.exec(`
|
|
455
|
-
CREATE TABLE IF NOT EXISTS schema_info (
|
|
456
|
-
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
457
|
-
schema_id TEXT NOT NULL
|
|
458
|
-
);
|
|
459
|
-
`);
|
|
460
|
-
}
|
|
461
|
-
const row = this.database
|
|
462
|
-
.prepare("SELECT schema_id FROM schema_info WHERE singleton = 1")
|
|
463
|
-
.get();
|
|
464
|
-
if (row !== undefined && row.schema_id !== CONTROLLER_STORE_SCHEMA) {
|
|
465
|
-
throw new Error(`Unsupported controller store schema: ${row.schema_id}`);
|
|
466
|
-
}
|
|
467
|
-
if (readOnly) {
|
|
468
|
-
if (row === undefined) {
|
|
469
|
-
throw new Error("Controller store has no schema identifier");
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
else {
|
|
473
|
-
const existingQueue = this.database
|
|
474
|
-
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'workflow_run_queue'")
|
|
475
|
-
.get();
|
|
476
|
-
if (existingQueue !== undefined) {
|
|
477
|
-
this.assertAlphaSchemaLayout(false);
|
|
478
|
-
this.assertNoLegacyPendingLaunchNotifications();
|
|
479
|
-
}
|
|
480
|
-
this.transaction(() => {
|
|
481
|
-
this.database
|
|
482
|
-
.prepare("INSERT OR IGNORE INTO schema_info (singleton, schema_id) VALUES (1, ?)")
|
|
483
|
-
.run(CONTROLLER_STORE_SCHEMA);
|
|
484
|
-
this.database.exec(SCHEMA_SQL);
|
|
485
|
-
});
|
|
486
|
-
}
|
|
487
|
-
this.assertAlphaSchemaLayout(true);
|
|
488
|
-
this.assertNoLegacyPendingLaunchNotifications();
|
|
489
|
-
}
|
|
490
|
-
assertAlphaSchemaLayout(requireTurnIntents) {
|
|
491
|
-
const requiredQueueColumns = new Set([
|
|
492
|
-
"run_id",
|
|
493
|
-
"workflow_ref",
|
|
494
|
-
"workflow_path",
|
|
495
|
-
"workflow_source_json",
|
|
496
|
-
"definition_digest",
|
|
497
|
-
"input_json",
|
|
498
|
-
"launch_options_json",
|
|
499
|
-
"status",
|
|
500
|
-
"runner_id",
|
|
501
|
-
"claim_token",
|
|
502
|
-
"claim_expires_at",
|
|
503
|
-
"affinity_runner_id",
|
|
504
|
-
"origin_session_id",
|
|
505
|
-
"parent_run_id",
|
|
506
|
-
"error_code",
|
|
507
|
-
"error_message",
|
|
508
|
-
"created_at",
|
|
509
|
-
"updated_at",
|
|
510
|
-
"started_at",
|
|
511
|
-
"finished_at",
|
|
512
|
-
]);
|
|
513
|
-
const actual = new Set(this.database.pragma("table_info(workflow_run_queue)").map((column) => column.name));
|
|
514
|
-
const missing = [...requiredQueueColumns].filter((column) => !actual.has(column));
|
|
515
|
-
if (missing.length > 0) {
|
|
516
|
-
throw new Error("Controller store uses an incompatible alpha layout. Preserve needed run bundles, then reset the project controller store.");
|
|
517
|
-
}
|
|
518
|
-
if (!requireTurnIntents)
|
|
519
|
-
return;
|
|
520
|
-
const requiredIntentColumns = new Set([
|
|
521
|
-
"intent_id",
|
|
522
|
-
"source_event_id",
|
|
523
|
-
"run_id",
|
|
524
|
-
"workflow_ref",
|
|
525
|
-
"target_session_id",
|
|
526
|
-
"cause",
|
|
527
|
-
"node_id",
|
|
528
|
-
"attempt_id",
|
|
529
|
-
"fallback_facts_json",
|
|
530
|
-
"requested_at",
|
|
531
|
-
"eligible_at",
|
|
532
|
-
"resolved_at",
|
|
533
|
-
"resolution",
|
|
534
|
-
"resolution_message_id",
|
|
535
|
-
"delivery_claim_token",
|
|
536
|
-
"delivery_claim_expires_at",
|
|
537
|
-
]);
|
|
538
|
-
const intentColumns = new Set(this.database.pragma("table_info(workflow_turn_intents)").map((column) => column.name));
|
|
539
|
-
const missingIntentColumns = [...requiredIntentColumns].filter((column) => !intentColumns.has(column));
|
|
540
|
-
if (missingIntentColumns.length > 0) {
|
|
541
|
-
throw new Error("Controller store uses an incompatible alpha turn-intent layout. Preserve needed run bundles, then reset the project controller store.");
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
assertNoLegacyPendingLaunchNotifications() {
|
|
545
|
-
const notificationTable = this.database
|
|
546
|
-
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'workflow_notifications'")
|
|
547
|
-
.get();
|
|
548
|
-
if (notificationTable === undefined) {
|
|
549
|
-
throw new Error("Controller store uses an incompatible alpha notification layout. Preserve needed run bundles, then reset the project controller store.");
|
|
550
|
-
}
|
|
551
|
-
const legacy = this.database
|
|
552
|
-
.prepare(`SELECT 1 FROM workflow_notifications
|
|
553
|
-
WHERE kind = 'launch_failure' AND delivered_at IS NULL LIMIT 1`)
|
|
554
|
-
.get();
|
|
555
|
-
if (legacy !== undefined) {
|
|
556
|
-
throw new Error("Controller store contains pending alpha launch-failure notifications. Preserve needed run bundles, then reset the project controller store.");
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
transaction(task) {
|
|
560
|
-
this.database.exec("BEGIN IMMEDIATE");
|
|
561
|
-
try {
|
|
562
|
-
const value = task();
|
|
563
|
-
this.database.exec("COMMIT");
|
|
564
|
-
return value;
|
|
565
|
-
}
|
|
566
|
-
catch (error) {
|
|
567
|
-
this.database.exec("ROLLBACK");
|
|
568
|
-
throw error;
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
resourceRow(ref) {
|
|
572
|
-
return this.database
|
|
573
|
-
.prepare("SELECT * FROM resources WHERE controller = ? AND resource_key = ?")
|
|
574
|
-
.get(ref.controller, ref.key);
|
|
575
|
-
}
|
|
576
|
-
requireResource(ref) {
|
|
577
|
-
const resource = this.getResource(ref);
|
|
578
|
-
if (resource === undefined) {
|
|
579
|
-
throw new ResourceNotFoundError(ref.controller, ref.key);
|
|
477
|
+
clauses.push("c.resource_key = ?");
|
|
478
|
+
params.push(options.key);
|
|
580
479
|
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
.
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
return this.database
|
|
601
|
-
.prepare(`SELECT * FROM queue
|
|
602
|
-
WHERE controller = ? AND resource_key = ? AND claim_token = ?`)
|
|
603
|
-
.get(claim.controller, claim.key, claim.token);
|
|
604
|
-
}
|
|
605
|
-
effectRow(resourceUid, key) {
|
|
606
|
-
return this.database
|
|
607
|
-
.prepare("SELECT * FROM effects WHERE resource_uid = ? AND effect_key = ?")
|
|
608
|
-
.get(resourceUid, key);
|
|
609
|
-
}
|
|
610
|
-
requireEffect(resourceUid, key) {
|
|
611
|
-
const effect = this.getEffect(resourceUid, key);
|
|
612
|
-
if (effect === undefined) {
|
|
613
|
-
throw new Error(`Effect record not found: ${resourceUid}/${key}`);
|
|
614
|
-
}
|
|
615
|
-
return effect;
|
|
616
|
-
}
|
|
617
|
-
workflowRow(resourceUid, requestKey) {
|
|
618
|
-
return this.database
|
|
619
|
-
.prepare("SELECT * FROM workflow_requests WHERE resource_uid = ? AND request_key = ?")
|
|
620
|
-
.get(resourceUid, requestKey);
|
|
621
|
-
}
|
|
622
|
-
requireWorkflow(requestId) {
|
|
623
|
-
const workflow = this.getWorkflowByRequestId(requestId);
|
|
624
|
-
if (workflow === undefined) {
|
|
625
|
-
throw new Error(`Workflow request not found: ${requestId}`);
|
|
626
|
-
}
|
|
627
|
-
return workflow;
|
|
480
|
+
params.push(options.limit ?? 100);
|
|
481
|
+
const rows = this.state.connection
|
|
482
|
+
.prepare(`SELECT e.event_seq AS seq, e.recorded_at AS recordedAt,
|
|
483
|
+
c.controller_name AS controller, c.resource_key AS resourceKey,
|
|
484
|
+
e.event_type AS eventType, e.payload_hash AS payloadHash
|
|
485
|
+
FROM events e
|
|
486
|
+
JOIN resources r ON r.resource_id = e.resource_id
|
|
487
|
+
JOIN controller_resources c ON c.resource_id = r.resource_id
|
|
488
|
+
WHERE ${clauses.join(" AND ")}
|
|
489
|
+
ORDER BY e.event_seq DESC LIMIT ?`)
|
|
490
|
+
.all(...params);
|
|
491
|
+
return rows.filter(isControllerEventRow).map((row) => ({
|
|
492
|
+
seq: row.seq,
|
|
493
|
+
recordedAt: new Date(row.recordedAt).toISOString(),
|
|
494
|
+
controller: row.controller,
|
|
495
|
+
key: row.resourceKey,
|
|
496
|
+
type: row.eventType,
|
|
497
|
+
payload: row.payloadHash === null ? {} : this.state.readJson(row.payloadHash),
|
|
498
|
+
}));
|
|
628
499
|
}
|
|
629
|
-
/** Reserve a user-started run before the initiating agent turn settles. */
|
|
630
500
|
reserveWorkflowRun(options) {
|
|
631
501
|
validateRunId(options.runId);
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
502
|
+
const now = epoch(validTimestamp(options.now));
|
|
503
|
+
const definitionDigest = digestBuffer(options.definitionDigest);
|
|
504
|
+
return this.state.transaction(() => {
|
|
505
|
+
if (this.getWorkflowRun(options.runId) !== undefined) {
|
|
506
|
+
throw new Error(`Workflow run already reserved: ${options.runId}`);
|
|
507
|
+
}
|
|
508
|
+
if (options.parentRunId !== undefined) {
|
|
509
|
+
const parent = this.requireWorkflowRunRow(options.parentRunId);
|
|
510
|
+
if (parent.originSessionId !== options.originSessionId) {
|
|
511
|
+
throw new Error("Continuation parent belongs to another Pi session");
|
|
512
|
+
}
|
|
513
|
+
if (parent.status === "parked") {
|
|
514
|
+
const parentLease = this.requireLease(parent.resourceId);
|
|
515
|
+
if (parentLease.ownerId !== null) {
|
|
516
|
+
throw new Error("Continuation parent still has an active owner");
|
|
517
|
+
}
|
|
518
|
+
this.state.connection
|
|
519
|
+
.prepare(`UPDATE run_queue
|
|
520
|
+
SET status = 'done', updated_at = ?, finished_at = ?
|
|
521
|
+
WHERE run_id = ? AND status = 'parked'`)
|
|
522
|
+
.run(now, now, parent.runId);
|
|
523
|
+
const parentRevision = this.resourceRevision(parent.resourceId);
|
|
524
|
+
this.bumpResource(parent.resourceId, parentRevision, now);
|
|
525
|
+
this.insertEvent(parent.resourceId, parentRevision + 1, "run.queue_done_for_continuation", "session", options.originSessionId, { continuationRunId: options.runId }, now);
|
|
526
|
+
}
|
|
527
|
+
else if (parent.status === "done") {
|
|
528
|
+
throw new Error("Continuation parent already has a reserved continuation");
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
throw new Error(`Continuation parent queue is ${parent.status}`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
const resourceId = resourceIdFor("run", options.runId);
|
|
535
|
+
const definitionHash = this.state.putJson(options.definitionSnapshot, now);
|
|
536
|
+
const queuedSource = queuedWorkflowSource(options.workflowSource);
|
|
537
|
+
const sourceHash = this.state.putJson(options.workflowSource, now);
|
|
538
|
+
const inputHash = this.state.putJson(options.input ?? null, now);
|
|
539
|
+
const launchHash = this.state.putJson(options.launchOptions ?? {}, now);
|
|
540
|
+
const queuedStateHash = this.state.putJson({
|
|
541
|
+
schema: "pi-workflows.run-state.v1",
|
|
542
|
+
traceSeq: 1,
|
|
543
|
+
runId: options.runId,
|
|
544
|
+
workflowName: options.workflowName,
|
|
545
|
+
workflowSource: queuedSource.root,
|
|
546
|
+
...(queuedSource.mounted.length === 0
|
|
547
|
+
? {}
|
|
548
|
+
: {
|
|
549
|
+
workflowSources: queuedSource.mounted,
|
|
550
|
+
definitionDigest: options.definitionDigest,
|
|
551
|
+
}),
|
|
552
|
+
...(options.parentRunId === undefined ? {} : { parentRunId: options.parentRunId }),
|
|
553
|
+
startedAt: new Date(now).toISOString(),
|
|
554
|
+
updatedAt: new Date(now).toISOString(),
|
|
555
|
+
status: "running",
|
|
556
|
+
input: options.input ?? null,
|
|
557
|
+
outputs: {},
|
|
558
|
+
results: {},
|
|
559
|
+
steps: [],
|
|
560
|
+
updates: [],
|
|
561
|
+
}, now);
|
|
562
|
+
this.state.connection
|
|
563
|
+
.prepare(`INSERT INTO workflow_definitions(
|
|
564
|
+
definition_digest, workflow_name, definition_hash, created_at
|
|
565
|
+
) VALUES (?, ?, ?, ?)
|
|
566
|
+
ON CONFLICT(definition_digest) DO NOTHING`)
|
|
567
|
+
.run(definitionDigest, options.workflowName, definitionHash, now);
|
|
568
|
+
this.state.connection
|
|
569
|
+
.prepare(`INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at)
|
|
570
|
+
VALUES (?, 'run', ?, 1, ?, ?)`)
|
|
571
|
+
.run(resourceId, options.runId, now, now);
|
|
572
|
+
this.state.connection
|
|
573
|
+
.prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
|
|
574
|
+
.run(resourceId);
|
|
575
|
+
const source = sourceParts(options.workflowSourceRef, options.definitionDigest);
|
|
576
|
+
this.state.connection
|
|
577
|
+
.prepare(`INSERT INTO runs(
|
|
578
|
+
run_id, resource_id, project_id, parent_run_id, definition_digest,
|
|
579
|
+
workflow_ref, workflow_source_hash, launch_options_hash,
|
|
580
|
+
source_type, source_ref, source_revision, status, paused,
|
|
581
|
+
input_hash, output_hash, created_at, updated_at
|
|
582
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?, ?)`)
|
|
583
|
+
.run(options.runId, resourceId, this.requireProjectId(), options.parentRunId ?? null, definitionDigest, options.workflowSourceRef, sourceHash, launchHash, source.type, source.ref, source.revision, inputHash, queuedStateHash, now, now);
|
|
584
|
+
this.state.connection
|
|
585
|
+
.prepare(`INSERT INTO run_bindings(run_id, origin_session_id, execution_mode, created_at)
|
|
586
|
+
VALUES (?, ?, 'interactive', ?)`)
|
|
587
|
+
.run(options.runId, options.originSessionId, now);
|
|
588
|
+
this.state.connection
|
|
589
|
+
.prepare(`INSERT INTO run_queue(
|
|
590
|
+
run_id, status, available_at, affinity_runner_id, origin_session_id,
|
|
591
|
+
consecutive_errors, created_at, updated_at
|
|
592
|
+
) VALUES (?, 'queued', ?, ?, ?, 0, ?, ?)`)
|
|
593
|
+
.run(options.runId, now, options.runnerId, options.originSessionId, now, now);
|
|
594
|
+
this.insertEvent(resourceId, 1, "run.queued", "session", options.originSessionId, {}, now);
|
|
595
|
+
return this.requireWorkflowRun(options.runId);
|
|
596
|
+
});
|
|
597
|
+
}
|
|
655
598
|
enqueueWorkflowRun(options) {
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
599
|
+
if (this.getWorkflowRun(options.runId) === undefined) {
|
|
600
|
+
this.reserveWorkflowRun({
|
|
601
|
+
runId: options.runId,
|
|
602
|
+
workflowName: options.workflowName,
|
|
603
|
+
workflowSourceRef: options.workflowSourceRef,
|
|
604
|
+
workflowSource: options.workflowSource,
|
|
605
|
+
definitionDigest: options.definitionDigest,
|
|
606
|
+
definitionSnapshot: options.definitionSnapshot,
|
|
607
|
+
input: options.input,
|
|
608
|
+
launchOptions: options.launchOptions ?? {},
|
|
609
|
+
runnerId: options.runnerId,
|
|
610
|
+
originSessionId: options.originSessionId,
|
|
611
|
+
...(options.parentRunId === undefined ? {} : { parentRunId: options.parentRunId }),
|
|
612
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
613
|
+
});
|
|
664
614
|
}
|
|
665
|
-
const
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
created_at, updated_at, started_at, finished_at
|
|
677
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, 'starting', ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, NULL)`)
|
|
678
|
-
.run(options.runId, options.workflowName, options.workflowSourceRef, sourceJson, options.definitionDigest ?? "unavailable", inputJson, launchJson, options.runnerId, options.claimToken, expiresAt, options.affinityRunnerId ?? options.runnerId, options.originSessionId ?? null, options.parentRunId ?? null, now, now, now);
|
|
679
|
-
return this.requireWorkflowRun(options.runId);
|
|
615
|
+
const claimed = this.claimWorkflowRun({
|
|
616
|
+
runId: options.runId,
|
|
617
|
+
runnerId: options.runnerId,
|
|
618
|
+
claimToken: options.claimToken,
|
|
619
|
+
leaseMs: options.leaseMs,
|
|
620
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
621
|
+
});
|
|
622
|
+
if (claimed === undefined) {
|
|
623
|
+
throw new Error(`Workflow run could not be claimed: ${options.runId}`);
|
|
624
|
+
}
|
|
625
|
+
return claimed;
|
|
680
626
|
}
|
|
681
627
|
getWorkflowRun(runId) {
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
.prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
|
|
685
|
-
.get(runId);
|
|
686
|
-
return row === undefined ? undefined : workflowRunFromRow(row);
|
|
628
|
+
const row = this.workflowRunRow(runId);
|
|
629
|
+
return row === undefined ? undefined : this.mapWorkflowRun(row);
|
|
687
630
|
}
|
|
688
631
|
listWorkflowRuns(options = {}) {
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
632
|
+
const clauses = ["r.project_id = ?"];
|
|
633
|
+
const params = [this.requireProjectId()];
|
|
634
|
+
if (options.statuses !== undefined && options.statuses.length > 0) {
|
|
635
|
+
clauses.push(`q.status IN (${options.statuses.map(() => "?").join(", ")})`);
|
|
636
|
+
params.push(...options.statuses);
|
|
637
|
+
}
|
|
638
|
+
if (options.excludeRunIds !== undefined && options.excludeRunIds.length > 0) {
|
|
639
|
+
clauses.push(`r.run_id NOT IN (${options.excludeRunIds.map(() => "?").join(", ")})`);
|
|
640
|
+
params.push(...options.excludeRunIds);
|
|
641
|
+
}
|
|
642
|
+
params.push(options.limit ?? 100);
|
|
643
|
+
const rows = this.state.connection
|
|
644
|
+
.prepare(workflowRunSelect(`WHERE ${clauses.join(" AND ")} ORDER BY q.created_at DESC LIMIT ?`))
|
|
645
|
+
.all(...params);
|
|
646
|
+
return rows.filter(isRunRow).map((row) => this.mapWorkflowRun(row));
|
|
697
647
|
}
|
|
698
648
|
findSessionReservation(sessionId) {
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
.prepare(`SELECT * FROM workflow_run_queue
|
|
702
|
-
WHERE origin_session_id = ? AND status IN ('queued', 'starting', 'running')
|
|
703
|
-
ORDER BY created_at LIMIT 1`)
|
|
649
|
+
const row = this.state.connection
|
|
650
|
+
.prepare(workflowRunSelect("WHERE b.origin_session_id = ? AND q.status NOT IN ('done', 'failed', 'cancelled') ORDER BY q.created_at DESC LIMIT 1"))
|
|
704
651
|
.get(sessionId);
|
|
705
|
-
return row
|
|
652
|
+
return isRunRow(row) ? this.mapWorkflowRun(row) : undefined;
|
|
706
653
|
}
|
|
707
654
|
claimWorkflowRun(options) {
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
validateKey(options.claimToken, "claim token");
|
|
711
|
-
validateDuration(options.leaseMs, "leaseMs");
|
|
712
|
-
const now = validTimestamp(options.now);
|
|
713
|
-
const nowMs = epoch(now);
|
|
714
|
-
const expiresAt = nowMs + options.leaseMs;
|
|
715
|
-
const result = this.database
|
|
716
|
-
.prepare(`UPDATE workflow_run_queue
|
|
717
|
-
SET status = 'starting', runner_id = ?, claim_token = ?, claim_expires_at = ?,
|
|
718
|
-
started_at = COALESCE(started_at, ?), updated_at = ?
|
|
719
|
-
WHERE run_id = ? AND (
|
|
720
|
-
status IN ('queued', 'parked')
|
|
721
|
-
OR (status = 'starting' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
|
|
722
|
-
)`)
|
|
723
|
-
.run(options.runnerId, options.claimToken, expiresAt, now, now, options.runId, nowMs);
|
|
724
|
-
return result.changes === 1 ? this.requireWorkflowRun(options.runId) : undefined;
|
|
655
|
+
const now = epoch(validTimestamp(options.now));
|
|
656
|
+
return this.claimRun(options.runId, options.runnerId, options.claimToken, options.leaseMs, now);
|
|
725
657
|
}
|
|
726
658
|
markWorkflowRunRunning(options) {
|
|
727
|
-
|
|
728
|
-
const now = validTimestamp(options.now);
|
|
729
|
-
const result = this.database
|
|
730
|
-
.prepare(`UPDATE workflow_run_queue SET status = 'running', updated_at = ?
|
|
731
|
-
WHERE run_id = ? AND claim_token = ? AND status = 'starting'`)
|
|
732
|
-
.run(now, options.runId, options.claimToken);
|
|
733
|
-
return result.changes === 1;
|
|
659
|
+
return this.updateClaimedRunStatus(options.runId, options.claimToken, "running", options.now);
|
|
734
660
|
}
|
|
735
|
-
/**
|
|
736
|
-
* Claim the oldest claimable run, preferring runs with affinity to this
|
|
737
|
-
* runner. Parked rows are claimable immediately; claimed rows become
|
|
738
|
-
* claimable once their lease expires, so a dead or stalled runner never
|
|
739
|
-
* strands a run. Returns undefined when nothing is claimable.
|
|
740
|
-
*/
|
|
741
661
|
claimNextWorkflowRun(options) {
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
const
|
|
751
|
-
|
|
752
|
-
|
|
662
|
+
const now = epoch(validTimestamp(options.now));
|
|
663
|
+
const clauses = [
|
|
664
|
+
"r.project_id = ?",
|
|
665
|
+
"q.status IN ('queued', 'parked', 'starting', 'running')",
|
|
666
|
+
"q.available_at <= ?",
|
|
667
|
+
"(l.owner_id IS NULL OR l.expires_at <= ? OR l.owner_id = ?)",
|
|
668
|
+
"(q.affinity_runner_id IS NULL OR q.affinity_runner_id = ? OR q.status IN ('parked', 'starting', 'running'))",
|
|
669
|
+
];
|
|
670
|
+
const params = [
|
|
671
|
+
this.requireProjectId(),
|
|
672
|
+
now,
|
|
673
|
+
now,
|
|
674
|
+
options.runnerId,
|
|
675
|
+
options.runnerId,
|
|
676
|
+
];
|
|
677
|
+
if (options.sessionId !== undefined) {
|
|
678
|
+
clauses.push("b.origin_session_id = ?");
|
|
679
|
+
params.push(options.sessionId);
|
|
753
680
|
}
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
CASE WHEN affinity_runner_id = ? THEN 0 ELSE 1 END,
|
|
768
|
-
created_at ASC
|
|
769
|
-
LIMIT 1`)
|
|
770
|
-
.get(nowMs, ...excluded, ...(options.sessionId === undefined ? [] : [options.sessionId]), options.runnerId);
|
|
771
|
-
if (candidate === undefined) {
|
|
772
|
-
return undefined;
|
|
773
|
-
}
|
|
774
|
-
const result = this.database
|
|
775
|
-
.prepare(`UPDATE workflow_run_queue
|
|
776
|
-
SET status = 'starting', runner_id = ?, claim_token = ?,
|
|
777
|
-
claim_expires_at = ?, started_at = COALESCE(started_at, ?), updated_at = ?
|
|
778
|
-
WHERE run_id = ? AND (
|
|
779
|
-
status IN ('queued', 'parked')
|
|
780
|
-
OR (status IN ('starting', 'running') AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
|
|
781
|
-
)`)
|
|
782
|
-
.run(options.runnerId, options.claimToken, expiresAt, now, now, candidate.run_id, nowMs);
|
|
783
|
-
return result.changes === 1 ? this.requireWorkflowRun(candidate.run_id) : undefined;
|
|
784
|
-
});
|
|
681
|
+
else {
|
|
682
|
+
clauses.push("(b.execution_mode = 'headless' OR q.status <> 'queued')");
|
|
683
|
+
}
|
|
684
|
+
if (options.excludeRunIds !== undefined && options.excludeRunIds.length > 0) {
|
|
685
|
+
clauses.push(`r.run_id NOT IN (${options.excludeRunIds.map(() => "?").join(", ")})`);
|
|
686
|
+
params.push(...options.excludeRunIds);
|
|
687
|
+
}
|
|
688
|
+
const row = this.state.connection
|
|
689
|
+
.prepare(workflowRunSelect(`WHERE ${clauses.join(" AND ")} ORDER BY q.available_at, q.created_at LIMIT 1`))
|
|
690
|
+
.get(...params);
|
|
691
|
+
if (!isRunRow(row))
|
|
692
|
+
return undefined;
|
|
693
|
+
return this.claimRun(row.runId, options.runnerId, options.claimToken, options.leaseMs, now);
|
|
785
694
|
}
|
|
786
|
-
/** Extend a live claim. Rejects expired or foreign claims. */
|
|
787
695
|
renewWorkflowRunClaim(options) {
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
const result = this.
|
|
793
|
-
.prepare(`UPDATE
|
|
794
|
-
WHERE
|
|
795
|
-
|
|
796
|
-
.run(expiresAt, options.runId, options.claimToken, nowMs);
|
|
696
|
+
const now = epoch(validTimestamp(options.now));
|
|
697
|
+
const row = this.workflowRunRow(options.runId);
|
|
698
|
+
if (row === undefined || row.ownerId === null)
|
|
699
|
+
return false;
|
|
700
|
+
const result = this.state.connection
|
|
701
|
+
.prepare(`UPDATE leases SET heartbeat_at = ?, expires_at = ?
|
|
702
|
+
WHERE resource_id = ? AND token_hash = ? AND owner_id = ? AND expires_at > ?`)
|
|
703
|
+
.run(now, now + options.leaseMs, row.resourceId, tokenHash(options.claimToken), row.ownerId, now);
|
|
797
704
|
return result.changes === 1;
|
|
798
705
|
}
|
|
799
|
-
/** The fence check: true only while this exact claim is live. */
|
|
800
706
|
verifyWorkflowRunClaim(options) {
|
|
801
|
-
|
|
802
|
-
const
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
.
|
|
808
|
-
|
|
809
|
-
}
|
|
810
|
-
|
|
707
|
+
const now = epoch(validTimestamp(options.now));
|
|
708
|
+
const row = this.workflowRunRow(options.runId);
|
|
709
|
+
if (row === undefined || row.ownerId === null || row.claimExpiresAt === null)
|
|
710
|
+
return false;
|
|
711
|
+
const lease = this.requireLease(row.resourceId);
|
|
712
|
+
return (lease.tokenHash !== null &&
|
|
713
|
+
lease.tokenHash.equals(tokenHash(options.claimToken)) &&
|
|
714
|
+
row.claimExpiresAt > now);
|
|
715
|
+
}
|
|
716
|
+
workflowRunAuthority(runId, claimToken) {
|
|
717
|
+
const row = this.workflowRunRow(runId);
|
|
718
|
+
if (row === undefined ||
|
|
719
|
+
row.ownerId === null ||
|
|
720
|
+
row.claimExpiresAt === null ||
|
|
721
|
+
row.claimExpiresAt <= Date.now())
|
|
722
|
+
return undefined;
|
|
723
|
+
const lease = this.requireLease(row.resourceId);
|
|
724
|
+
if (lease.tokenHash === null || !lease.tokenHash.equals(tokenHash(claimToken)))
|
|
725
|
+
return undefined;
|
|
726
|
+
const ownerType = row.ownerId.startsWith("host-") ? "host" : "session";
|
|
727
|
+
return {
|
|
728
|
+
actor: { type: ownerType, id: row.ownerId },
|
|
729
|
+
ownerType,
|
|
730
|
+
ownerId: row.ownerId,
|
|
731
|
+
token: claimToken,
|
|
732
|
+
generation: row.leaseGeneration,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
811
735
|
parkWorkflowRun(options) {
|
|
812
|
-
|
|
813
|
-
const now = validTimestamp(options.now);
|
|
814
|
-
const result = this.database
|
|
815
|
-
.prepare(`UPDATE workflow_run_queue
|
|
816
|
-
SET status = 'parked', runner_id = NULL, claim_token = NULL,
|
|
817
|
-
claim_expires_at = NULL, updated_at = ?
|
|
818
|
-
WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')`)
|
|
819
|
-
.run(now, options.runId, options.claimToken);
|
|
820
|
-
return result.changes === 1;
|
|
736
|
+
return this.releaseRunClaim(options.runId, options.claimToken, "parked", options.now);
|
|
821
737
|
}
|
|
822
738
|
failWorkflowRun(options) {
|
|
823
|
-
|
|
824
|
-
validateKey(options.errorCode, "workflow launch error code");
|
|
825
|
-
const message = options.errorMessage.trim();
|
|
826
|
-
if (message.length === 0 || Buffer.byteLength(message) > 2_048) {
|
|
827
|
-
throw new Error("Workflow launch error message must be between 1 and 2048 bytes");
|
|
828
|
-
}
|
|
829
|
-
const now = validTimestamp(options.now);
|
|
830
|
-
const claimFilter = options.claimToken === undefined ? "" : "AND claim_token = ?";
|
|
831
|
-
const result = this.database
|
|
832
|
-
.prepare(`UPDATE workflow_run_queue
|
|
833
|
-
SET status = 'failed', runner_id = NULL, claim_token = NULL,
|
|
834
|
-
claim_expires_at = NULL, parent_run_id = NULL, input_json = 'null',
|
|
835
|
-
launch_options_json = '{}', error_code = ?, error_message = ?,
|
|
836
|
-
finished_at = ?, updated_at = ?
|
|
837
|
-
WHERE run_id = ? AND status IN ('queued', 'starting', 'running') ${claimFilter}`)
|
|
838
|
-
.run(options.errorCode, message, now, now, options.runId, ...(options.claimToken === undefined ? [] : [options.claimToken]));
|
|
839
|
-
return result.changes === 1;
|
|
739
|
+
return this.terminalRun(options.runId, options.claimToken, "failed", options.errorCode, options.errorMessage, options.now);
|
|
840
740
|
}
|
|
841
741
|
cancelWorkflowRun(options) {
|
|
842
|
-
|
|
843
|
-
const now = validTimestamp(options.now);
|
|
844
|
-
const claimFilter = options.claimToken === undefined ? "" : "AND claim_token = ?";
|
|
845
|
-
const result = this.database
|
|
846
|
-
.prepare(`UPDATE workflow_run_queue
|
|
847
|
-
SET status = 'cancelled', runner_id = NULL, claim_token = NULL,
|
|
848
|
-
claim_expires_at = NULL, input_json = 'null', launch_options_json = '{}',
|
|
849
|
-
finished_at = ?, updated_at = ?
|
|
850
|
-
WHERE run_id = ? AND status IN ('queued', 'starting') ${claimFilter}`)
|
|
851
|
-
.run(now, now, options.runId, ...(options.claimToken === undefined ? [] : [options.claimToken]));
|
|
852
|
-
return result.changes === 1;
|
|
742
|
+
return this.terminalRun(options.runId, options.claimToken, "cancelled", "cancelled", "Workflow run cancelled", options.now);
|
|
853
743
|
}
|
|
854
|
-
/**
|
|
855
|
-
* Delete a claimed row. Used when a continuation fails before its bundle
|
|
856
|
-
* exists, so the parent's one-continuation slot is not consumed by a run
|
|
857
|
-
* that never happened.
|
|
858
|
-
*/
|
|
859
744
|
deleteWorkflowRun(options) {
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
return result.changes === 1;
|
|
865
|
-
}
|
|
866
|
-
/** Release a claim and mark the run terminal in the queue. */
|
|
867
|
-
completeWorkflowRun(options) {
|
|
868
|
-
validateRunId(options.runId);
|
|
869
|
-
const now = validTimestamp(options.now);
|
|
870
|
-
const result = this.database
|
|
871
|
-
.prepare(`UPDATE workflow_run_queue
|
|
872
|
-
SET status = 'done', runner_id = NULL, claim_token = NULL,
|
|
873
|
-
claim_expires_at = NULL, input_json = 'null', launch_options_json = '{}',
|
|
874
|
-
finished_at = ?, updated_at = ?
|
|
875
|
-
WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')`)
|
|
876
|
-
.run(now, now, options.runId, options.claimToken);
|
|
877
|
-
return result.changes === 1;
|
|
878
|
-
}
|
|
879
|
-
/** Repair a canonical bundle's queue source and claim it only when needed. */
|
|
880
|
-
repairCanonicalWorkflowSourceRun(options) {
|
|
881
|
-
validateRunId(options.runId);
|
|
882
|
-
validateKey(options.workflowName, "workflow name");
|
|
883
|
-
validateKey(options.workflowSourceRef, "workflow source ref");
|
|
884
|
-
validateKey(options.runnerId, "runner id");
|
|
885
|
-
validateKey(options.claimToken, "claim token");
|
|
886
|
-
validateDuration(options.leaseMs, "leaseMs");
|
|
887
|
-
const now = validTimestamp(options.now);
|
|
888
|
-
const nowMs = epoch(now);
|
|
889
|
-
const expiresAt = nowMs + options.leaseMs;
|
|
890
|
-
return this.transaction(() => {
|
|
891
|
-
const row = this.database
|
|
892
|
-
.prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
|
|
893
|
-
.get(options.runId);
|
|
894
|
-
if (row === undefined || ["done", "failed", "cancelled"].includes(row.status)) {
|
|
745
|
+
const now = Date.now();
|
|
746
|
+
return this.state.transaction(() => {
|
|
747
|
+
const row = this.workflowRunRow(options.runId);
|
|
748
|
+
if (row === undefined)
|
|
895
749
|
return false;
|
|
896
|
-
|
|
897
|
-
if (
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
(
|
|
902
|
-
row.claim_token === options.claimToken &&
|
|
903
|
-
row.claim_expires_at !== null &&
|
|
904
|
-
row.claim_expires_at > nowMs) ||
|
|
905
|
-
(row.status === "starting" &&
|
|
906
|
-
row.claim_expires_at !== null &&
|
|
907
|
-
row.claim_expires_at <= nowMs);
|
|
908
|
-
if (!claimable)
|
|
909
|
-
return false;
|
|
910
|
-
const result = this.database
|
|
911
|
-
.prepare(`UPDATE workflow_run_queue
|
|
912
|
-
SET workflow_ref = ?, workflow_path = ?, status = 'starting', runner_id = ?,
|
|
913
|
-
claim_token = ?, claim_expires_at = ?, updated_at = ?
|
|
914
|
-
WHERE run_id = ? AND status NOT IN ('done', 'failed', 'cancelled')`)
|
|
915
|
-
.run(options.workflowName, options.workflowSourceRef, options.runnerId, options.claimToken, expiresAt, now, options.runId);
|
|
916
|
-
return result.changes === 1 ? "claimed" : false;
|
|
917
|
-
});
|
|
918
|
-
}
|
|
919
|
-
/** Claim and rewrite one proved legacy workflow source queue row atomically. */
|
|
920
|
-
claimLegacyWorkflowSourceRun(options) {
|
|
921
|
-
validateRunId(options.runId);
|
|
922
|
-
validateKey(options.workflowName, "workflow name");
|
|
923
|
-
validateKey(options.oldWorkflowPath, "legacy workflow path");
|
|
924
|
-
validateKey(options.workflowSourceRef, "workflow source ref");
|
|
925
|
-
validateKey(options.runnerId, "runner id");
|
|
926
|
-
validateKey(options.claimToken, "claim token");
|
|
927
|
-
validateDuration(options.leaseMs, "leaseMs");
|
|
928
|
-
const now = validTimestamp(options.now);
|
|
929
|
-
const nowMs = epoch(now);
|
|
930
|
-
const expiresAt = nowMs + options.leaseMs;
|
|
931
|
-
return this.transaction(() => {
|
|
932
|
-
const row = this.database
|
|
933
|
-
.prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
|
|
934
|
-
.get(options.runId);
|
|
935
|
-
if (row === undefined || ["done", "failed", "cancelled"].includes(row.status)) {
|
|
750
|
+
const lease = this.requireLease(row.resourceId);
|
|
751
|
+
if (lease.ownerId === null ||
|
|
752
|
+
lease.tokenHash === null ||
|
|
753
|
+
lease.expiresAt === null ||
|
|
754
|
+
lease.expiresAt <= now ||
|
|
755
|
+
!lease.tokenHash.equals(tokenHash(options.claimToken))) {
|
|
936
756
|
return false;
|
|
937
757
|
}
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
(row.status === "starting" &&
|
|
942
|
-
row.claim_token === options.claimToken &&
|
|
943
|
-
row.claim_expires_at !== null &&
|
|
944
|
-
row.claim_expires_at > nowMs) ||
|
|
945
|
-
(row.status === "starting" &&
|
|
946
|
-
row.claim_expires_at !== null &&
|
|
947
|
-
row.claim_expires_at <= nowMs);
|
|
948
|
-
if (!sourceMatches || !claimable)
|
|
949
|
-
return false;
|
|
950
|
-
const result = this.database
|
|
951
|
-
.prepare(`UPDATE workflow_run_queue
|
|
952
|
-
SET workflow_ref = ?, workflow_path = ?, status = 'starting', runner_id = ?,
|
|
953
|
-
claim_token = ?, claim_expires_at = ?, updated_at = ?
|
|
954
|
-
WHERE run_id = ? AND status NOT IN ('done', 'failed', 'cancelled')`)
|
|
955
|
-
.run(options.workflowName, options.workflowSourceRef, options.runnerId, options.claimToken, expiresAt, now, options.runId);
|
|
956
|
-
return result.changes === 1;
|
|
758
|
+
return (this.state.connection
|
|
759
|
+
.prepare("DELETE FROM resources WHERE resource_id = ?")
|
|
760
|
+
.run(row.resourceId).changes === 1);
|
|
957
761
|
});
|
|
958
762
|
}
|
|
959
|
-
|
|
763
|
+
completeWorkflowRun(options) {
|
|
764
|
+
return this.releaseRunClaim(options.runId, options.claimToken, "done", options.now);
|
|
765
|
+
}
|
|
766
|
+
repairCanonicalWorkflowSourceRun() {
|
|
767
|
+
throw new Error("Legacy workflow source repair is not available after the SQLite cutover");
|
|
768
|
+
}
|
|
769
|
+
claimLegacyWorkflowSourceRun() {
|
|
770
|
+
throw new Error("Legacy workflow source claims are not available after the SQLite cutover");
|
|
771
|
+
}
|
|
960
772
|
recordRunEvent(options) {
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
const
|
|
964
|
-
|
|
965
|
-
const
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
.
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
773
|
+
const row = this.requireWorkflowRunRow(options.runId);
|
|
774
|
+
const now = epoch(validTimestamp(options.now));
|
|
775
|
+
const revision = this.resourceRevision(row.resourceId) + 1;
|
|
776
|
+
this.bumpResource(row.resourceId, revision - 1, now);
|
|
777
|
+
const eventId = this.insertEvent(row.resourceId, revision, options.type, options.runnerId?.startsWith("host-") ? "host" : "session", options.runnerId ?? null, options.payload ?? {}, now, row.leaseGeneration || undefined);
|
|
778
|
+
const seq = this.state.connection
|
|
779
|
+
.prepare("SELECT event_seq AS seq FROM events WHERE event_id = ?")
|
|
780
|
+
.get(eventId);
|
|
781
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
782
|
+
if (!isSequenceRow(seq))
|
|
783
|
+
throw new Error("Run event was not recorded");
|
|
784
|
+
return {
|
|
785
|
+
seq: seq.seq,
|
|
786
|
+
recordedAt: new Date(now).toISOString(),
|
|
787
|
+
runId: options.runId,
|
|
788
|
+
workflowRef: options.workflowRef,
|
|
789
|
+
type: options.type,
|
|
790
|
+
runnerId: options.runnerId ?? null,
|
|
791
|
+
payload: options.payload ?? {},
|
|
792
|
+
};
|
|
793
|
+
}
|
|
972
794
|
listRunEventsAfter(seq, options = {}) {
|
|
973
|
-
const
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
.all(seq, limit);
|
|
980
|
-
return rows.map((row) => ({
|
|
795
|
+
const rows = this.state.connection
|
|
796
|
+
.prepare(`SELECT e.event_seq AS seq, e.recorded_at AS recordedAt, r.run_id AS runId,
|
|
797
|
+
r.workflow_ref AS workflowRef, e.event_type AS eventType,
|
|
798
|
+
e.actor_id AS runnerId, e.payload_hash AS payloadHash
|
|
799
|
+
FROM events e JOIN runs r ON r.resource_id = e.resource_id
|
|
800
|
+
WHERE e.event_seq > ? ORDER BY e.event_seq LIMIT ?`)
|
|
801
|
+
.all(seq, options.limit ?? 100);
|
|
802
|
+
return rows.filter(isRunEventRow).map((row) => ({
|
|
981
803
|
seq: row.seq,
|
|
982
|
-
recordedAt: row.
|
|
983
|
-
runId: row.
|
|
984
|
-
workflowRef: row.
|
|
985
|
-
type: row.
|
|
986
|
-
runnerId: row.
|
|
987
|
-
payload:
|
|
804
|
+
recordedAt: new Date(row.recordedAt).toISOString(),
|
|
805
|
+
runId: row.runId,
|
|
806
|
+
workflowRef: row.workflowRef,
|
|
807
|
+
type: row.eventType,
|
|
808
|
+
runnerId: row.runnerId,
|
|
809
|
+
payload: row.payloadHash === null ? {} : this.state.readJson(row.payloadHash),
|
|
988
810
|
}));
|
|
989
811
|
}
|
|
990
812
|
ensureWorkflowTurnIntent(options) {
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
const
|
|
998
|
-
const
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
}
|
|
1019
|
-
if (existing.sourceEventId !== options.sourceEventId ||
|
|
1020
|
-
existing.runId !== options.runId ||
|
|
1021
|
-
existing.workflowRef !== options.workflowRef ||
|
|
1022
|
-
existing.targetSessionId !== options.targetSessionId ||
|
|
1023
|
-
existing.cause !== options.cause ||
|
|
1024
|
-
existing.nodeId !== nodeId ||
|
|
1025
|
-
existing.attemptId !== attemptId) {
|
|
1026
|
-
throw new Error(`Workflow turn intent identity conflict: ${options.intentId}`);
|
|
1027
|
-
}
|
|
1028
|
-
return existing;
|
|
813
|
+
const existing = this.turnIntentBySource(options.sourceEventId);
|
|
814
|
+
if (existing !== undefined)
|
|
815
|
+
return existing;
|
|
816
|
+
const now = epoch(validTimestamp(options.requestedAt));
|
|
817
|
+
const run = this.requireWorkflowRunRow(options.runId);
|
|
818
|
+
const intentId = options.intentId;
|
|
819
|
+
const resourceId = resourceIdFor("turn_intent", intentId);
|
|
820
|
+
const effectId = `effect-${randomUUID()}`;
|
|
821
|
+
this.state.transaction(() => {
|
|
822
|
+
const factsHash = this.state.putJson(options.fallbackFacts, now);
|
|
823
|
+
this.insertEffectResource(effectId, resourceIdFor("effect", effectId), run.resourceId, this.resourceRevision(run.resourceId), "turn.deliver", intentId, factsHash, "run", now);
|
|
824
|
+
this.state.connection
|
|
825
|
+
.prepare("INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at) VALUES (?, 'turn_intent', ?, 1, ?, ?)")
|
|
826
|
+
.run(resourceId, intentId, now, now);
|
|
827
|
+
this.state.connection
|
|
828
|
+
.prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
|
|
829
|
+
.run(resourceId);
|
|
830
|
+
this.state.connection
|
|
831
|
+
.prepare(`INSERT INTO turn_intents(
|
|
832
|
+
turn_intent_id, resource_id, effect_id, source_event_id, run_id,
|
|
833
|
+
workflow_ref, target_session_id, cause, node_id, attempt_id,
|
|
834
|
+
facts_hash, requested_at, eligible_at, created_at
|
|
835
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
836
|
+
.run(intentId, resourceId, effectId, options.sourceEventId, options.runId, options.workflowRef, options.targetSessionId, options.cause, options.nodeId ?? null, options.attemptId ?? null, factsHash, now, options.eligible ? now : null, now);
|
|
837
|
+
this.insertEvent(resourceId, 1, "turn.requested", "session", options.targetSessionId, {}, now);
|
|
838
|
+
});
|
|
839
|
+
return this.requireTurnIntent(intentId);
|
|
1029
840
|
}
|
|
1030
841
|
getWorkflowTurnIntent(intentId) {
|
|
1031
|
-
|
|
1032
|
-
const row = this.database
|
|
1033
|
-
.prepare("SELECT * FROM workflow_turn_intents WHERE intent_id = ?")
|
|
1034
|
-
.get(intentId);
|
|
1035
|
-
return row === undefined ? undefined : workflowTurnIntentFromRow(row);
|
|
842
|
+
return this.turnIntent(intentId);
|
|
1036
843
|
}
|
|
1037
844
|
findPendingWorkflowTurnIntent(options) {
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
const row = this.database
|
|
1041
|
-
.prepare(`SELECT * FROM workflow_turn_intents
|
|
1042
|
-
WHERE run_id = ? AND target_session_id = ? AND resolved_at IS NULL
|
|
1043
|
-
ORDER BY requested_at DESC, intent_id DESC LIMIT 1`)
|
|
845
|
+
const row = this.state.connection
|
|
846
|
+
.prepare(turnIntentSelect("WHERE t.run_id = ? AND t.target_session_id = ? AND t.resolved_at IS NULL ORDER BY t.requested_at DESC LIMIT 1"))
|
|
1044
847
|
.get(options.runId, options.targetSessionId);
|
|
1045
|
-
return row
|
|
848
|
+
return isTurnIntentRow(row) ? this.mapTurnIntent(row) : undefined;
|
|
1046
849
|
}
|
|
1047
850
|
claimWorkflowTurnIntent(options) {
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
validateKey(options.claimToken, "workflow turn claim token");
|
|
1051
|
-
validateDuration(options.leaseMs, "leaseMs");
|
|
1052
|
-
const nowMs = epoch(validTimestamp(options.now));
|
|
1053
|
-
const result = this.database
|
|
1054
|
-
.prepare(`UPDATE workflow_turn_intents
|
|
1055
|
-
SET delivery_claim_token = ?, delivery_claim_expires_at = ?
|
|
1056
|
-
WHERE intent_id = ? AND target_session_id = ? AND resolved_at IS NULL
|
|
1057
|
-
AND (delivery_claim_expires_at IS NULL OR delivery_claim_expires_at <= ?)`)
|
|
1058
|
-
.run(options.claimToken, nowMs + options.leaseMs, options.intentId, options.targetSessionId, nowMs);
|
|
1059
|
-
if (result.changes !== 1)
|
|
851
|
+
const intent = this.turnIntent(options.intentId);
|
|
852
|
+
if (intent?.targetSessionId !== options.targetSessionId)
|
|
1060
853
|
return undefined;
|
|
1061
|
-
|
|
1062
|
-
.
|
|
1063
|
-
|
|
1064
|
-
return workflowTurnIntentFromRow(row);
|
|
854
|
+
return this.claimEffectForTurn(options.intentId, options.claimToken, options.leaseMs, options.now)
|
|
855
|
+
? this.requireTurnIntent(options.intentId)
|
|
856
|
+
: undefined;
|
|
1065
857
|
}
|
|
1066
858
|
claimEligibleWorkflowTurnIntents(options) {
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
859
|
+
const now = epoch(validTimestamp(options.now));
|
|
860
|
+
const rows = this.state.connection
|
|
861
|
+
.prepare(turnIntentSelect("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 ?"))
|
|
862
|
+
.all(options.targetSessionId, now, options.limit ?? 10);
|
|
863
|
+
const claimed = [];
|
|
864
|
+
for (const row of rows) {
|
|
865
|
+
if (isTurnIntentRow(row) &&
|
|
866
|
+
this.claimEffectForTurn(row.intentId, options.claimToken, options.leaseMs, options.now)) {
|
|
867
|
+
claimed.push(this.requireTurnIntent(row.intentId));
|
|
868
|
+
}
|
|
1073
869
|
}
|
|
1074
|
-
|
|
1075
|
-
const nowMs = epoch(now);
|
|
1076
|
-
return this.transaction(() => {
|
|
1077
|
-
const ids = this.database
|
|
1078
|
-
.prepare(`SELECT intent_id FROM workflow_turn_intents
|
|
1079
|
-
WHERE target_session_id = ? AND resolved_at IS NULL AND eligible_at IS NOT NULL
|
|
1080
|
-
AND eligible_at <= ?
|
|
1081
|
-
AND (delivery_claim_expires_at IS NULL OR delivery_claim_expires_at <= ?)
|
|
1082
|
-
ORDER BY eligible_at, intent_id LIMIT ?`)
|
|
1083
|
-
.all(options.targetSessionId, now, nowMs, limit);
|
|
1084
|
-
if (ids.length === 0)
|
|
1085
|
-
return [];
|
|
1086
|
-
const placeholders = ids.map(() => "?").join(", ");
|
|
1087
|
-
this.database
|
|
1088
|
-
.prepare(`UPDATE workflow_turn_intents
|
|
1089
|
-
SET delivery_claim_token = ?, delivery_claim_expires_at = ?
|
|
1090
|
-
WHERE intent_id IN (${placeholders}) AND resolved_at IS NULL
|
|
1091
|
-
AND (delivery_claim_expires_at IS NULL OR delivery_claim_expires_at <= ?)`)
|
|
1092
|
-
.run(options.claimToken, nowMs + options.leaseMs, ...ids.map((row) => row.intent_id), nowMs);
|
|
1093
|
-
const rows = this.database
|
|
1094
|
-
.prepare(`SELECT * FROM workflow_turn_intents WHERE delivery_claim_token = ?
|
|
1095
|
-
ORDER BY eligible_at, intent_id`)
|
|
1096
|
-
.all(options.claimToken);
|
|
1097
|
-
return rows.map(workflowTurnIntentFromRow);
|
|
1098
|
-
});
|
|
870
|
+
return claimed;
|
|
1099
871
|
}
|
|
1100
872
|
makeWorkflowTurnIntentEligible(options) {
|
|
1101
|
-
|
|
1102
|
-
const
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
.run(workflowTurnIntentFactsJson(options.fallbackFacts), validTimestamp(options.now), options.intentId);
|
|
1107
|
-
return result.changes === 1;
|
|
873
|
+
const eligibleAt = epoch(validTimestamp(options.eligibleAt ?? options.now));
|
|
874
|
+
const factsHash = this.state.putJson(options.fallbackFacts, eligibleAt);
|
|
875
|
+
return (this.state.connection
|
|
876
|
+
.prepare("UPDATE turn_intents SET eligible_at = ?, facts_hash = ? WHERE turn_intent_id = ? AND resolved_at IS NULL")
|
|
877
|
+
.run(eligibleAt, factsHash, options.intentId).changes === 1);
|
|
1108
878
|
}
|
|
1109
879
|
resolveWorkflowTurnIntent(options) {
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
880
|
+
const now = epoch(validTimestamp(options.now));
|
|
881
|
+
return this.state.transaction(() => {
|
|
882
|
+
const row = this.turnIntentRow(options.intentId);
|
|
883
|
+
if (row === undefined ||
|
|
884
|
+
row.targetSessionId !== options.targetSessionId ||
|
|
885
|
+
!this.verifyEffectToken(row.effectId, options.claimToken, options.now)) {
|
|
886
|
+
return false;
|
|
887
|
+
}
|
|
888
|
+
const result = this.state.connection
|
|
889
|
+
.prepare(`UPDATE turn_intents SET resolved_at = ?, resolution_type = ?, resolution_message_id = ?
|
|
890
|
+
WHERE turn_intent_id = ? AND resolved_at IS NULL`)
|
|
891
|
+
.run(now, options.resolution, options.messageId ?? null, options.intentId);
|
|
892
|
+
if (result.changes === 1)
|
|
893
|
+
this.completeEffect(row.effectId, "applied", now);
|
|
894
|
+
return result.changes === 1;
|
|
895
|
+
});
|
|
1123
896
|
}
|
|
1124
897
|
releaseWorkflowTurnIntentClaim(options) {
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
.prepare(`UPDATE workflow_turn_intents
|
|
1130
|
-
SET delivery_claim_token = NULL, delivery_claim_expires_at = NULL
|
|
1131
|
-
WHERE intent_id = ? AND target_session_id = ? AND resolved_at IS NULL
|
|
1132
|
-
AND delivery_claim_token = ?`)
|
|
1133
|
-
.run(options.intentId, options.targetSessionId, options.claimToken);
|
|
1134
|
-
return result.changes === 1;
|
|
898
|
+
const row = this.turnIntentRow(options.intentId);
|
|
899
|
+
return (row !== undefined &&
|
|
900
|
+
row.targetSessionId === options.targetSessionId &&
|
|
901
|
+
this.releaseEffectLease(row.effectId, options.claimToken));
|
|
1135
902
|
}
|
|
1136
903
|
listWorkflowTurnIntents(options = {}) {
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
const limit = options.limit ?? 20;
|
|
1143
|
-
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 100) {
|
|
1144
|
-
throw new Error("Workflow turn intent limit must be an integer from 1 through 100");
|
|
904
|
+
const clauses = ["1 = 1"];
|
|
905
|
+
const params = [];
|
|
906
|
+
if (options.runId !== undefined) {
|
|
907
|
+
clauses.push("t.run_id = ?");
|
|
908
|
+
params.push(options.runId);
|
|
1145
909
|
}
|
|
1146
|
-
const clauses = [];
|
|
1147
|
-
const values = [];
|
|
1148
910
|
if (options.targetSessionId !== undefined) {
|
|
1149
|
-
clauses.push("target_session_id = ?");
|
|
1150
|
-
|
|
1151
|
-
}
|
|
1152
|
-
if (options.runId !== undefined) {
|
|
1153
|
-
clauses.push("run_id = ?");
|
|
1154
|
-
values.push(options.runId);
|
|
911
|
+
clauses.push("t.target_session_id = ?");
|
|
912
|
+
params.push(options.targetSessionId);
|
|
1155
913
|
}
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
.
|
|
1161
|
-
|
|
914
|
+
if (options.unresolvedOnly === true)
|
|
915
|
+
clauses.push("t.resolved_at IS NULL");
|
|
916
|
+
params.push(options.limit ?? 100);
|
|
917
|
+
const rows = this.state.connection
|
|
918
|
+
.prepare(turnIntentSelect(`WHERE ${clauses.join(" AND ")} ORDER BY t.requested_at DESC LIMIT ?`))
|
|
919
|
+
.all(...params);
|
|
920
|
+
return rows.filter(isTurnIntentRow).map((row) => this.mapTurnIntent(row));
|
|
1162
921
|
}
|
|
1163
922
|
enqueueWorkflowNotification(options) {
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
923
|
+
const existing = this.notification(options.runId, options.attemptId, options.notificationIndex);
|
|
924
|
+
if (existing !== undefined)
|
|
925
|
+
return existing;
|
|
926
|
+
const run = this.requireWorkflowRunRow(options.runId);
|
|
927
|
+
const now = epoch(validTimestamp(options.now));
|
|
928
|
+
const notificationId = `notification-${randomUUID()}`;
|
|
929
|
+
const resourceId = resourceIdFor("notification", notificationId);
|
|
930
|
+
const effectId = `effect-${randomUUID()}`;
|
|
931
|
+
this.state.transaction(() => {
|
|
932
|
+
const contentHash = this.state.putText(options.content, now);
|
|
933
|
+
this.insertEffectResource(effectId, resourceIdFor("effect", effectId), run.resourceId, this.resourceRevision(run.resourceId), "notification.deliver", notificationId, contentHash, "run", now);
|
|
934
|
+
this.state.connection
|
|
935
|
+
.prepare("INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at) VALUES (?, 'notification', ?, 1, ?, ?)")
|
|
936
|
+
.run(resourceId, notificationId, now, now);
|
|
937
|
+
this.state.connection
|
|
938
|
+
.prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
|
|
939
|
+
.run(resourceId);
|
|
940
|
+
this.state.connection
|
|
941
|
+
.prepare(`INSERT INTO notifications(
|
|
942
|
+
notification_id, effect_id, run_id, attempt_id, notification_index,
|
|
943
|
+
target_session_id, notification_type, content_hash, created_at
|
|
944
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
945
|
+
.run(notificationId, effectId, options.runId, options.attemptId, options.notificationIndex, options.targetSessionId, options.kind, contentHash, now);
|
|
946
|
+
this.insertEvent(resourceId, 1, "notification.queued", "session", options.targetSessionId, {}, now);
|
|
947
|
+
});
|
|
948
|
+
return this.requireNotification(options.runId, options.attemptId, options.notificationIndex);
|
|
1187
949
|
}
|
|
1188
950
|
claimPendingWorkflowNotifications(options) {
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
951
|
+
const rows = this.notificationRows(options.targetSessionId, options.limit ?? 20);
|
|
952
|
+
const result = [];
|
|
953
|
+
for (const row of rows) {
|
|
954
|
+
if (this.claimEffect(row.effectId, options.targetSessionId, options.claimToken, options.leaseMs, options.now)) {
|
|
955
|
+
result.push(this.mapNotification(row));
|
|
956
|
+
}
|
|
1195
957
|
}
|
|
1196
|
-
|
|
1197
|
-
const nowMs = epoch(now);
|
|
1198
|
-
return this.transaction(() => {
|
|
1199
|
-
const ids = this.database
|
|
1200
|
-
.prepare(`SELECT notification_id FROM workflow_notifications
|
|
1201
|
-
WHERE target_session_id = ? AND delivered_at IS NULL
|
|
1202
|
-
AND (delivery_claim_expires_at IS NULL OR delivery_claim_expires_at <= ?)
|
|
1203
|
-
ORDER BY created_at, notification_id LIMIT ?`)
|
|
1204
|
-
.all(options.targetSessionId, nowMs, limit);
|
|
1205
|
-
if (ids.length === 0)
|
|
1206
|
-
return [];
|
|
1207
|
-
const placeholders = ids.map(() => "?").join(", ");
|
|
1208
|
-
this.database
|
|
1209
|
-
.prepare(`UPDATE workflow_notifications
|
|
1210
|
-
SET delivery_claim_token = ?, delivery_claim_expires_at = ?
|
|
1211
|
-
WHERE notification_id IN (${placeholders}) AND delivered_at IS NULL
|
|
1212
|
-
AND (delivery_claim_expires_at IS NULL OR delivery_claim_expires_at <= ?)`)
|
|
1213
|
-
.run(options.claimToken, nowMs + options.leaseMs, ...ids.map((row) => row.notification_id), nowMs);
|
|
1214
|
-
const rows = this.database
|
|
1215
|
-
.prepare(`SELECT * FROM workflow_notifications WHERE delivery_claim_token = ?
|
|
1216
|
-
ORDER BY created_at, notification_id`)
|
|
1217
|
-
.all(options.claimToken);
|
|
1218
|
-
return rows.map(workflowNotificationFromRow);
|
|
1219
|
-
});
|
|
958
|
+
return result;
|
|
1220
959
|
}
|
|
1221
960
|
markWorkflowNotificationDelivered(options) {
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
.
|
|
1231
|
-
|
|
961
|
+
const now = epoch(validTimestamp(options.now));
|
|
962
|
+
return this.state.transaction(() => {
|
|
963
|
+
const row = this.notificationRowById(options.notificationId);
|
|
964
|
+
if (row === undefined ||
|
|
965
|
+
row.targetSessionId !== options.targetSessionId ||
|
|
966
|
+
!this.verifyEffectToken(row.effectId, options.claimToken, options.now)) {
|
|
967
|
+
return false;
|
|
968
|
+
}
|
|
969
|
+
this.completeEffect(row.effectId, "applied", now);
|
|
970
|
+
return true;
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
settleRunEffect(runId, effectType) {
|
|
974
|
+
const rows = this.state.connection
|
|
975
|
+
.prepare(`SELECT e.effect_id AS effectId, e.resource_id AS resourceId
|
|
976
|
+
FROM effects e JOIN runs r ON r.resource_id = e.source_resource_id
|
|
977
|
+
WHERE r.run_id = ? AND e.effect_type = ? AND e.status = 'pending'`)
|
|
978
|
+
.all(runId, effectType);
|
|
979
|
+
for (const row of rows) {
|
|
980
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
981
|
+
if (!isEffectIdentityRow(row))
|
|
982
|
+
continue;
|
|
983
|
+
this.state.transaction(() => {
|
|
984
|
+
const now = Date.now();
|
|
985
|
+
const revision = this.resourceRevision(row.resourceId);
|
|
986
|
+
this.state.connection
|
|
987
|
+
.prepare(`UPDATE effects SET status = 'applied', updated_at = ?, settled_at = ?
|
|
988
|
+
WHERE effect_id = ? AND status = 'pending'`)
|
|
989
|
+
.run(now, now, row.effectId);
|
|
990
|
+
this.bumpResource(row.resourceId, revision, now);
|
|
991
|
+
this.insertEvent(row.resourceId, revision + 1, "effect.applied", "system", null, { runId, effectType }, now);
|
|
992
|
+
});
|
|
993
|
+
}
|
|
1232
994
|
}
|
|
1233
995
|
setWorkflowRunOriginSession(runId, originSessionId) {
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
.run(originSessionId,
|
|
1240
|
-
|
|
996
|
+
const now = Date.now();
|
|
997
|
+
return (this.state.connection
|
|
998
|
+
.prepare(`INSERT INTO run_bindings(run_id, origin_session_id, execution_mode, created_at)
|
|
999
|
+
VALUES (?, ?, 'interactive', ?)
|
|
1000
|
+
ON CONFLICT(run_id) DO UPDATE SET origin_session_id = excluded.origin_session_id`)
|
|
1001
|
+
.run(runId, originSessionId, now).changes === 1);
|
|
1002
|
+
}
|
|
1003
|
+
ensureProject(projectPath) {
|
|
1004
|
+
const canonicalPath = canonicalProjectPath(projectPath);
|
|
1005
|
+
const projectId = projectIdFor(canonicalPath);
|
|
1006
|
+
this.state.connection
|
|
1007
|
+
.prepare(`INSERT INTO projects(project_id, canonical_path, created_at)
|
|
1008
|
+
VALUES (?, ?, ?) ON CONFLICT(project_id) DO NOTHING`)
|
|
1009
|
+
.run(projectId, canonicalPath, Date.now());
|
|
1010
|
+
return projectId;
|
|
1011
|
+
}
|
|
1012
|
+
findProject(projectPath) {
|
|
1013
|
+
const row = this.state.connection
|
|
1014
|
+
.prepare("SELECT project_id AS projectId FROM projects WHERE canonical_path = ?")
|
|
1015
|
+
.get(canonicalProjectPath(projectPath));
|
|
1016
|
+
return isProjectRow(row) ? row.projectId : null;
|
|
1017
|
+
}
|
|
1018
|
+
requireProjectId() {
|
|
1019
|
+
if (this.projectId === null)
|
|
1020
|
+
throw new Error("Controller project is not registered in the state database");
|
|
1021
|
+
return this.projectId;
|
|
1022
|
+
}
|
|
1023
|
+
controllerRow(ref) {
|
|
1024
|
+
if (this.projectId === null)
|
|
1025
|
+
return undefined;
|
|
1026
|
+
const row = this.state.connection
|
|
1027
|
+
.prepare(controllerSelect("WHERE c.project_id = ? AND c.controller_name = ? AND c.resource_key = ?"))
|
|
1028
|
+
.get(this.projectId, ref.controller, ref.key);
|
|
1029
|
+
return isControllerRow(row) ? row : undefined;
|
|
1030
|
+
}
|
|
1031
|
+
requireControllerRow(ref) {
|
|
1032
|
+
const row = this.controllerRow(ref);
|
|
1033
|
+
if (row === undefined)
|
|
1034
|
+
throw new ResourceNotFoundError(ref.controller, ref.key);
|
|
1035
|
+
return row;
|
|
1241
1036
|
}
|
|
1242
|
-
|
|
1243
|
-
const
|
|
1244
|
-
if
|
|
1245
|
-
|
|
1037
|
+
requireControllerRowByUid(uid) {
|
|
1038
|
+
const row = this.state.connection.prepare(controllerSelect("WHERE c.uid = ?")).get(uid);
|
|
1039
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
1040
|
+
if (!isControllerRow(row))
|
|
1041
|
+
throw new Error(`Controller resource not found: ${uid}`);
|
|
1042
|
+
return row;
|
|
1043
|
+
}
|
|
1044
|
+
requireResource(ref) {
|
|
1045
|
+
const resource = this.getResource(ref);
|
|
1046
|
+
if (resource === undefined)
|
|
1047
|
+
throw new ResourceNotFoundError(ref.controller, ref.key);
|
|
1048
|
+
return resource;
|
|
1049
|
+
}
|
|
1050
|
+
/* istanbul ignore next -- pure projection covered by integration tests */
|
|
1051
|
+
mapControllerResource(row) {
|
|
1052
|
+
const spec = this.state.readJson(row.specHash);
|
|
1053
|
+
const status = this.state.readJson(row.statusHash);
|
|
1054
|
+
return {
|
|
1055
|
+
metadata: {
|
|
1056
|
+
uid: row.uid,
|
|
1057
|
+
controller: row.controllerName,
|
|
1058
|
+
key: row.resourceKey,
|
|
1059
|
+
resourceVersion: row.resourceVersion,
|
|
1060
|
+
generation: row.generation,
|
|
1061
|
+
...(row.deletionRequestedAt === null
|
|
1062
|
+
? {}
|
|
1063
|
+
: { deletionTimestamp: new Date(row.deletionRequestedAt).toISOString() }),
|
|
1064
|
+
finalizers: this.finalizers(row.controllerResourceId),
|
|
1065
|
+
},
|
|
1066
|
+
spec,
|
|
1067
|
+
status,
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
finalizers(controllerResourceId) {
|
|
1071
|
+
const rows = this.state.connection
|
|
1072
|
+
.prepare("SELECT finalizer FROM controller_finalizers WHERE controller_resource_id = ? ORDER BY position")
|
|
1073
|
+
.all(controllerResourceId);
|
|
1074
|
+
return rows.flatMap((row) => (isFinalizerRow(row) ? [row.finalizer] : []));
|
|
1075
|
+
}
|
|
1076
|
+
replaceFinalizers(controllerResourceId, finalizers) {
|
|
1077
|
+
const unique = [...new Set(finalizers)];
|
|
1078
|
+
if (unique.length !== finalizers.length)
|
|
1079
|
+
throw new Error("Controller finalizers must be unique");
|
|
1080
|
+
this.state.connection
|
|
1081
|
+
.prepare("DELETE FROM controller_finalizers WHERE controller_resource_id = ?")
|
|
1082
|
+
.run(controllerResourceId);
|
|
1083
|
+
const insert = this.state.connection.prepare("INSERT INTO controller_finalizers(controller_resource_id, finalizer, position) VALUES (?, ?, ?)");
|
|
1084
|
+
unique.forEach((finalizer, index) => insert.run(controllerResourceId, finalizer, index));
|
|
1085
|
+
}
|
|
1086
|
+
enqueueControllerRow(controllerResourceId, availableAt, now) {
|
|
1087
|
+
this.state.connection
|
|
1088
|
+
.prepare(`INSERT INTO controller_queue(
|
|
1089
|
+
controller_resource_id, available_at, queue_version,
|
|
1090
|
+
consecutive_errors, created_at, updated_at
|
|
1091
|
+
) VALUES (?, ?, 1, 0, ?, ?)
|
|
1092
|
+
ON CONFLICT(controller_resource_id) DO UPDATE SET
|
|
1093
|
+
available_at = MIN(controller_queue.available_at, excluded.available_at),
|
|
1094
|
+
queue_version = controller_queue.queue_version + 1,
|
|
1095
|
+
updated_at = excluded.updated_at`)
|
|
1096
|
+
.run(controllerResourceId, availableAt, now, now);
|
|
1097
|
+
}
|
|
1098
|
+
queueErrors(controllerResourceId) {
|
|
1099
|
+
const row = this.state.connection
|
|
1100
|
+
.prepare("SELECT consecutive_errors AS consecutiveErrors FROM controller_queue WHERE controller_resource_id = ?")
|
|
1101
|
+
.get(controllerResourceId);
|
|
1102
|
+
return isErrorCountRow(row) ? row.consecutiveErrors : 0;
|
|
1103
|
+
}
|
|
1104
|
+
queueVersion(controllerResourceId) {
|
|
1105
|
+
const row = this.state.connection
|
|
1106
|
+
.prepare("SELECT queue_version AS queueVersion FROM controller_queue WHERE controller_resource_id = ?")
|
|
1107
|
+
.get(controllerResourceId);
|
|
1108
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
1109
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
1110
|
+
if (!isQueueVersionRow(row))
|
|
1111
|
+
throw new Error("Controller queue item is missing");
|
|
1112
|
+
return row.queueVersion;
|
|
1113
|
+
}
|
|
1114
|
+
settleControllerClaim(claim, requeue, nowValue) {
|
|
1115
|
+
const now = epoch(validTimestamp(nowValue));
|
|
1116
|
+
return this.state.transaction(() => {
|
|
1117
|
+
const row = this.controllerRow({ controller: claim.controller, key: claim.key });
|
|
1118
|
+
if (row === undefined)
|
|
1119
|
+
return false;
|
|
1120
|
+
try {
|
|
1121
|
+
this.assertControllerClaim(row, claim, now, false);
|
|
1122
|
+
}
|
|
1123
|
+
catch {
|
|
1124
|
+
return false;
|
|
1125
|
+
}
|
|
1126
|
+
const currentQueueVersion = this.queueVersion(row.controllerResourceId);
|
|
1127
|
+
if (requeue === undefined) {
|
|
1128
|
+
if (currentQueueVersion === claim.queueVersion) {
|
|
1129
|
+
this.state.connection
|
|
1130
|
+
.prepare("DELETE FROM controller_queue WHERE controller_resource_id = ?")
|
|
1131
|
+
.run(row.controllerResourceId);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
else {
|
|
1135
|
+
const errorHash = requeue.error === undefined ? null : this.state.putText(requeue.error, now);
|
|
1136
|
+
this.state.connection
|
|
1137
|
+
.prepare(`UPDATE controller_queue
|
|
1138
|
+
SET available_at = ?, queue_version = queue_version + 1,
|
|
1139
|
+
consecutive_errors = consecutive_errors + 1,
|
|
1140
|
+
last_error_hash = ?, updated_at = ? WHERE controller_resource_id = ?`)
|
|
1141
|
+
.run(epoch(requeue.availableAt), errorHash, now, row.controllerResourceId);
|
|
1142
|
+
}
|
|
1143
|
+
this.releaseLease(row.resourceId, claim, now);
|
|
1144
|
+
return true;
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
assertControllerClaim(row, claim, now, requireUnexpired = true) {
|
|
1148
|
+
if (row.controllerName !== claim.controller || row.resourceKey !== claim.key)
|
|
1149
|
+
throw new Error("Controller claim targets another resource");
|
|
1150
|
+
const lease = this.requireLease(row.resourceId);
|
|
1151
|
+
if (lease.ownerType !== "controller" ||
|
|
1152
|
+
lease.ownerId !== claim.ownerId ||
|
|
1153
|
+
lease.generation !== claim.generation ||
|
|
1154
|
+
lease.tokenHash === null ||
|
|
1155
|
+
!lease.tokenHash.equals(tokenHash(claim.token)) ||
|
|
1156
|
+
(requireUnexpired && (lease.expiresAt === null || lease.expiresAt <= now))) {
|
|
1157
|
+
throw new Error("Controller claim is stale");
|
|
1246
1158
|
}
|
|
1247
|
-
return record;
|
|
1248
1159
|
}
|
|
1249
|
-
|
|
1250
|
-
const row = this.
|
|
1251
|
-
|
|
1252
|
-
|
|
1160
|
+
requireLease(resourceId) {
|
|
1161
|
+
const row = this.state.connection
|
|
1162
|
+
.prepare(`SELECT generation, owner_type AS ownerType, owner_id AS ownerId,
|
|
1163
|
+
token_hash AS tokenHash, expires_at AS expiresAt
|
|
1164
|
+
FROM leases WHERE resource_id = ?`)
|
|
1165
|
+
.get(resourceId);
|
|
1166
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
1167
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
1168
|
+
if (!isLeaseRow(row))
|
|
1169
|
+
throw new Error(`Lease is missing: ${resourceId}`);
|
|
1170
|
+
return row;
|
|
1171
|
+
}
|
|
1172
|
+
releaseLease(resourceId, claim, now) {
|
|
1173
|
+
const row = this.controllerRow({ controller: claim.controller, key: claim.key });
|
|
1174
|
+
if (row === undefined)
|
|
1175
|
+
return;
|
|
1176
|
+
const revision = this.resourceRevision(resourceId);
|
|
1177
|
+
const result = this.state.connection
|
|
1178
|
+
.prepare(`UPDATE leases SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
|
|
1179
|
+
acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
|
|
1180
|
+
WHERE resource_id = ? AND owner_id = ? AND token_hash = ? AND generation = ?`)
|
|
1181
|
+
.run(resourceId, claim.ownerId, tokenHash(claim.token), claim.generation);
|
|
1182
|
+
if (result.changes === 1) {
|
|
1183
|
+
this.bumpResource(resourceId, revision, now);
|
|
1184
|
+
this.insertEvent(resourceId, revision + 1, "lease.released", "controller", claim.ownerId, {}, now, claim.generation);
|
|
1185
|
+
claim.resourceVersion = revision + 1;
|
|
1253
1186
|
}
|
|
1254
|
-
return eventFromRow(row);
|
|
1255
1187
|
}
|
|
1256
|
-
|
|
1257
|
-
const
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
claim_token TEXT,
|
|
1302
|
-
claim_expires_at INTEGER,
|
|
1303
|
-
affinity_runner_id TEXT,
|
|
1304
|
-
origin_session_id TEXT,
|
|
1305
|
-
parent_run_id TEXT,
|
|
1306
|
-
error_code TEXT,
|
|
1307
|
-
error_message TEXT,
|
|
1308
|
-
created_at TEXT NOT NULL,
|
|
1309
|
-
updated_at TEXT NOT NULL,
|
|
1310
|
-
started_at TEXT,
|
|
1311
|
-
finished_at TEXT
|
|
1312
|
-
);
|
|
1313
|
-
CREATE INDEX IF NOT EXISTS workflow_run_queue_claimable
|
|
1314
|
-
ON workflow_run_queue(status, claim_expires_at);
|
|
1315
|
-
CREATE UNIQUE INDEX IF NOT EXISTS workflow_run_queue_session_reservation
|
|
1316
|
-
ON workflow_run_queue(origin_session_id)
|
|
1317
|
-
WHERE origin_session_id IS NOT NULL AND status IN ('queued', 'starting', 'running');
|
|
1318
|
-
-- A checkpointed parent admits exactly one continuation run, across
|
|
1319
|
-
-- sessions and processes.
|
|
1320
|
-
CREATE UNIQUE INDEX IF NOT EXISTS workflow_run_queue_parent
|
|
1321
|
-
ON workflow_run_queue(parent_run_id) WHERE parent_run_id IS NOT NULL;
|
|
1322
|
-
|
|
1323
|
-
CREATE TABLE IF NOT EXISTS run_events (
|
|
1324
|
-
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1325
|
-
recorded_at TEXT NOT NULL,
|
|
1326
|
-
run_id TEXT NOT NULL,
|
|
1327
|
-
workflow_ref TEXT NOT NULL,
|
|
1328
|
-
type TEXT NOT NULL,
|
|
1329
|
-
runner_id TEXT,
|
|
1330
|
-
payload_json TEXT NOT NULL
|
|
1331
|
-
);
|
|
1332
|
-
CREATE INDEX IF NOT EXISTS run_events_run ON run_events(run_id);
|
|
1333
|
-
|
|
1334
|
-
CREATE TABLE IF NOT EXISTS workflow_notifications (
|
|
1335
|
-
notification_id TEXT PRIMARY KEY,
|
|
1336
|
-
run_id TEXT NOT NULL,
|
|
1337
|
-
node_id TEXT NOT NULL,
|
|
1338
|
-
attempt_id TEXT NOT NULL,
|
|
1339
|
-
notification_index INTEGER NOT NULL CHECK (notification_index > 0),
|
|
1340
|
-
target_session_id TEXT NOT NULL,
|
|
1341
|
-
kind TEXT NOT NULL CHECK (kind IN ('progress', 'final')),
|
|
1342
|
-
content TEXT NOT NULL,
|
|
1343
|
-
created_at TEXT NOT NULL,
|
|
1344
|
-
delivery_claim_token TEXT,
|
|
1345
|
-
delivery_claim_expires_at INTEGER,
|
|
1346
|
-
delivered_at TEXT,
|
|
1347
|
-
UNIQUE (run_id, node_id, notification_index)
|
|
1348
|
-
);
|
|
1349
|
-
CREATE INDEX IF NOT EXISTS workflow_notifications_pending
|
|
1350
|
-
ON workflow_notifications(target_session_id, delivery_claim_expires_at, created_at)
|
|
1351
|
-
WHERE delivered_at IS NULL;
|
|
1352
|
-
|
|
1353
|
-
CREATE TABLE IF NOT EXISTS workflow_turn_intents (
|
|
1354
|
-
intent_id TEXT PRIMARY KEY,
|
|
1355
|
-
source_event_id TEXT NOT NULL,
|
|
1356
|
-
run_id TEXT NOT NULL,
|
|
1357
|
-
workflow_ref TEXT NOT NULL,
|
|
1358
|
-
target_session_id TEXT NOT NULL,
|
|
1359
|
-
cause TEXT NOT NULL CHECK (
|
|
1360
|
-
cause IN ('agentCancelled', 'timedOut', 'failed', 'launchFailed',
|
|
1361
|
-
'controllerInterrupted', 'claimLost')
|
|
1362
|
-
),
|
|
1363
|
-
node_id TEXT,
|
|
1364
|
-
attempt_id TEXT,
|
|
1365
|
-
fallback_facts_json TEXT NOT NULL,
|
|
1366
|
-
requested_at TEXT NOT NULL,
|
|
1367
|
-
eligible_at TEXT,
|
|
1368
|
-
resolved_at TEXT,
|
|
1369
|
-
resolution TEXT CHECK (resolution IN ('workflowPrompt', 'presentation', 'fallback')),
|
|
1370
|
-
resolution_message_id TEXT,
|
|
1371
|
-
delivery_claim_token TEXT,
|
|
1372
|
-
delivery_claim_expires_at INTEGER,
|
|
1373
|
-
CHECK (
|
|
1374
|
-
(resolved_at IS NULL AND resolution IS NULL AND resolution_message_id IS NULL) OR
|
|
1375
|
-
(resolved_at IS NOT NULL AND resolution IS NOT NULL AND resolution_message_id IS NOT NULL)
|
|
1376
|
-
)
|
|
1377
|
-
);
|
|
1378
|
-
CREATE UNIQUE INDEX IF NOT EXISTS workflow_turn_intents_one_pending_run
|
|
1379
|
-
ON workflow_turn_intents(run_id, target_session_id) WHERE resolved_at IS NULL;
|
|
1380
|
-
CREATE INDEX IF NOT EXISTS workflow_turn_intents_pending_run
|
|
1381
|
-
ON workflow_turn_intents(run_id, target_session_id, requested_at)
|
|
1382
|
-
WHERE resolved_at IS NULL;
|
|
1383
|
-
CREATE INDEX IF NOT EXISTS workflow_turn_intents_eligible_session
|
|
1384
|
-
ON workflow_turn_intents(target_session_id, eligible_at, intent_id)
|
|
1385
|
-
WHERE resolved_at IS NULL AND eligible_at IS NOT NULL;
|
|
1386
|
-
|
|
1387
|
-
CREATE TABLE IF NOT EXISTS effects (
|
|
1388
|
-
effect_key TEXT NOT NULL,
|
|
1389
|
-
resource_uid TEXT NOT NULL,
|
|
1390
|
-
generation INTEGER NOT NULL CHECK (generation > 0),
|
|
1391
|
-
kind TEXT NOT NULL,
|
|
1392
|
-
state TEXT NOT NULL CHECK (state IN ('pending', 'applied', 'rejected', 'indeterminate')),
|
|
1393
|
-
request_fingerprint TEXT NOT NULL,
|
|
1394
|
-
started_at TEXT NOT NULL,
|
|
1395
|
-
completed_at TEXT,
|
|
1396
|
-
external_ref TEXT,
|
|
1397
|
-
error TEXT,
|
|
1398
|
-
PRIMARY KEY (resource_uid, effect_key),
|
|
1399
|
-
FOREIGN KEY (resource_uid) REFERENCES resources(uid) ON DELETE CASCADE
|
|
1400
|
-
);
|
|
1401
|
-
|
|
1402
|
-
CREATE TABLE IF NOT EXISTS workflow_requests (
|
|
1403
|
-
request_id TEXT PRIMARY KEY,
|
|
1404
|
-
resource_uid TEXT NOT NULL,
|
|
1405
|
-
request_key TEXT NOT NULL,
|
|
1406
|
-
input_fingerprint TEXT NOT NULL,
|
|
1407
|
-
workflow TEXT NOT NULL,
|
|
1408
|
-
run_id TEXT,
|
|
1409
|
-
state TEXT NOT NULL CHECK (
|
|
1410
|
-
state IN ('pending', 'running', 'waiting', 'succeeded', 'failed', 'interrupted')
|
|
1411
|
-
),
|
|
1412
|
-
attempt INTEGER NOT NULL CHECK (attempt >= 0),
|
|
1413
|
-
error TEXT,
|
|
1414
|
-
UNIQUE (resource_uid, request_key),
|
|
1415
|
-
FOREIGN KEY (resource_uid) REFERENCES resources(uid) ON DELETE CASCADE
|
|
1416
|
-
);
|
|
1417
|
-
|
|
1418
|
-
CREATE TABLE IF NOT EXISTS events (
|
|
1419
|
-
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1420
|
-
recorded_at TEXT NOT NULL,
|
|
1421
|
-
controller TEXT NOT NULL,
|
|
1422
|
-
resource_key TEXT NOT NULL,
|
|
1423
|
-
type TEXT NOT NULL,
|
|
1424
|
-
payload_json TEXT NOT NULL
|
|
1425
|
-
);
|
|
1426
|
-
CREATE INDEX IF NOT EXISTS events_resource
|
|
1427
|
-
ON events(controller, resource_key, seq DESC);
|
|
1428
|
-
`;
|
|
1429
|
-
function resourceFromRow(row) {
|
|
1430
|
-
const finalizers = parseStoredJson(row.finalizers_json, "resource finalizers");
|
|
1431
|
-
validateFinalizers(finalizers);
|
|
1432
|
-
return {
|
|
1433
|
-
metadata: {
|
|
1434
|
-
uid: row.uid,
|
|
1435
|
-
controller: row.controller,
|
|
1436
|
-
key: row.resource_key,
|
|
1437
|
-
resourceVersion: row.resource_version,
|
|
1188
|
+
resourceRevision(resourceId) {
|
|
1189
|
+
const row = this.state.connection
|
|
1190
|
+
.prepare("SELECT revision FROM resources WHERE resource_id = ?")
|
|
1191
|
+
.get(resourceId);
|
|
1192
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
1193
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
1194
|
+
if (!isRevisionRow(row))
|
|
1195
|
+
throw new Error(`Resource is missing: ${resourceId}`);
|
|
1196
|
+
return row.revision;
|
|
1197
|
+
}
|
|
1198
|
+
bumpResource(resourceId, expectedRevision, now) {
|
|
1199
|
+
const result = this.state.connection
|
|
1200
|
+
.prepare("UPDATE resources SET revision = revision + 1, updated_at = ? WHERE resource_id = ? AND revision = ?")
|
|
1201
|
+
.run(now, resourceId, expectedRevision);
|
|
1202
|
+
if (result.changes !== 1)
|
|
1203
|
+
throw new Error("Resource revision conflict");
|
|
1204
|
+
}
|
|
1205
|
+
insertEvent(resourceId, revision, type, actorType, actorId, payload, now, leaseGeneration) {
|
|
1206
|
+
const eventId = `event-${randomUUID()}`;
|
|
1207
|
+
const payloadHash = this.state.putJson(payload, now);
|
|
1208
|
+
this.state.connection
|
|
1209
|
+
.prepare(`INSERT INTO events(
|
|
1210
|
+
event_id, resource_id, resource_revision, event_type, actor_type,
|
|
1211
|
+
actor_id, lease_generation, payload_hash, recorded_at
|
|
1212
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1213
|
+
.run(eventId, resourceId, revision, type, actorType, actorId, leaseGeneration ?? null, payloadHash, now);
|
|
1214
|
+
return eventId;
|
|
1215
|
+
}
|
|
1216
|
+
effectRow(resourceUid, key) {
|
|
1217
|
+
const row = this.state.connection
|
|
1218
|
+
.prepare(effectSelect("WHERE c.uid = ? AND e.idempotency_key = ?"))
|
|
1219
|
+
.get(resourceUid, key);
|
|
1220
|
+
return isEffectRow(row) ? row : undefined;
|
|
1221
|
+
}
|
|
1222
|
+
requireEffectRow(resourceUid, key) {
|
|
1223
|
+
const row = this.effectRow(resourceUid, key);
|
|
1224
|
+
if (row === undefined)
|
|
1225
|
+
throw new Error(`Effect record not found: ${resourceUid}/${key}`);
|
|
1226
|
+
return row;
|
|
1227
|
+
}
|
|
1228
|
+
/* istanbul ignore next -- pure projection covered by integration tests */
|
|
1229
|
+
mapEffect(row) {
|
|
1230
|
+
return {
|
|
1231
|
+
key: this.effectKey(row.effectId),
|
|
1232
|
+
resourceUid: row.resourceUid,
|
|
1438
1233
|
generation: row.generation,
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1234
|
+
kind: row.kind,
|
|
1235
|
+
state: controllerEffectState(row.state),
|
|
1236
|
+
requestFingerprint: row.requestFingerprint,
|
|
1237
|
+
startedAt: new Date(row.startedAt).toISOString(),
|
|
1238
|
+
...(row.completedAt === null ? {} : { completedAt: new Date(row.completedAt).toISOString() }),
|
|
1239
|
+
...(row.externalRef === null ? {} : { externalRef: row.externalRef }),
|
|
1240
|
+
...(row.errorHash === null
|
|
1241
|
+
? {}
|
|
1242
|
+
: { error: this.state.readBlob(row.errorHash)?.content.toString("utf8") ?? "" }),
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
effectKey(effectId) {
|
|
1246
|
+
const row = this.state.connection
|
|
1247
|
+
.prepare("SELECT idempotency_key AS key FROM effects WHERE effect_id = ?")
|
|
1248
|
+
.get(effectId);
|
|
1249
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
1250
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
1251
|
+
if (!isKeyRow(row))
|
|
1252
|
+
throw new Error(`Effect is missing: ${effectId}`);
|
|
1253
|
+
return row.key;
|
|
1254
|
+
}
|
|
1255
|
+
workflowRow(resourceUid, requestKey) {
|
|
1256
|
+
const row = this.state.connection
|
|
1257
|
+
.prepare(workflowSelect("WHERE c.uid = ? AND w.request_key = ?"))
|
|
1258
|
+
.get(resourceUid, requestKey);
|
|
1259
|
+
return isWorkflowRow(row) ? row : undefined;
|
|
1260
|
+
}
|
|
1261
|
+
workflowRowById(requestId) {
|
|
1262
|
+
const row = this.state.connection
|
|
1263
|
+
.prepare(workflowSelect("WHERE w.request_id = ?"))
|
|
1264
|
+
.get(requestId);
|
|
1265
|
+
return isWorkflowRow(row) ? row : undefined;
|
|
1266
|
+
}
|
|
1267
|
+
requireWorkflowRow(requestId) {
|
|
1268
|
+
const row = this.workflowRowById(requestId);
|
|
1269
|
+
if (row === undefined)
|
|
1270
|
+
throw new Error(`Workflow request not found: ${requestId}`);
|
|
1271
|
+
return row;
|
|
1272
|
+
}
|
|
1273
|
+
/* istanbul ignore next -- pure projection covered by integration tests */
|
|
1274
|
+
mapWorkflow(row) {
|
|
1275
|
+
return {
|
|
1276
|
+
requestId: row.requestId,
|
|
1277
|
+
resourceUid: row.resourceUid,
|
|
1278
|
+
requestKey: row.requestKey,
|
|
1279
|
+
inputFingerprint: row.inputFingerprint.toString("hex"),
|
|
1280
|
+
workflow: row.workflowName,
|
|
1281
|
+
...(row.runId === null ? {} : { runId: row.runId }),
|
|
1282
|
+
state: row.status,
|
|
1283
|
+
attempt: row.attemptCount,
|
|
1284
|
+
...(row.errorHash === null
|
|
1285
|
+
? {}
|
|
1286
|
+
: { error: this.state.readBlob(row.errorHash)?.content.toString("utf8") ?? "" }),
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
workflowRunRow(runId) {
|
|
1290
|
+
const row = this.state.connection.prepare(workflowRunSelect("WHERE r.run_id = ?")).get(runId);
|
|
1291
|
+
return isRunRow(row) ? row : undefined;
|
|
1292
|
+
}
|
|
1293
|
+
requireWorkflowRunRow(runId) {
|
|
1294
|
+
const row = this.workflowRunRow(runId);
|
|
1295
|
+
if (row === undefined)
|
|
1296
|
+
throw new Error(`Workflow run not found: ${runId}`);
|
|
1297
|
+
return row;
|
|
1298
|
+
}
|
|
1299
|
+
requireWorkflowRun(runId) {
|
|
1300
|
+
return this.mapWorkflowRun(this.requireWorkflowRunRow(runId));
|
|
1301
|
+
}
|
|
1302
|
+
/* istanbul ignore next -- pure projection covered by integration tests */
|
|
1303
|
+
mapWorkflowRun(row) {
|
|
1304
|
+
return {
|
|
1305
|
+
runId: row.runId,
|
|
1306
|
+
workflowName: row.workflowName,
|
|
1307
|
+
workflowSourceRef: row.workflowRef,
|
|
1308
|
+
workflowSource: this.state.readJson(row.workflowSourceHash),
|
|
1309
|
+
initialized: row.runStatus !== "queued",
|
|
1310
|
+
definitionDigest: `sha256:${row.definitionDigest.toString("hex")}`,
|
|
1311
|
+
input: this.state.readJson(row.inputHash),
|
|
1312
|
+
launchOptions: this.state.readJson(row.launchOptionsHash),
|
|
1313
|
+
status: row.status,
|
|
1314
|
+
runnerId: row.ownerId,
|
|
1315
|
+
claimToken: null,
|
|
1316
|
+
claimGeneration: row.ownerId === null ? null : row.leaseGeneration,
|
|
1317
|
+
claimExpiresAt: row.claimExpiresAt === null ? null : new Date(row.claimExpiresAt).toISOString(),
|
|
1318
|
+
affinityRunnerId: row.affinityRunnerId,
|
|
1319
|
+
originSessionId: row.originSessionId,
|
|
1320
|
+
parentRunId: row.parentRunId,
|
|
1321
|
+
errorCode: row.errorCode,
|
|
1322
|
+
errorMessage: row.errorHash === null
|
|
1323
|
+
? null
|
|
1324
|
+
: (this.state.readBlob(row.errorHash)?.content.toString("utf8") ?? null),
|
|
1325
|
+
createdAt: new Date(row.createdAt).toISOString(),
|
|
1326
|
+
updatedAt: new Date(row.updatedAt).toISOString(),
|
|
1327
|
+
startedAt: row.startedAt === null ? null : new Date(row.startedAt).toISOString(),
|
|
1328
|
+
finishedAt: row.finishedAt === null ? null : new Date(row.finishedAt).toISOString(),
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
claimRun(runId, runnerId, claimToken, leaseMs, now) {
|
|
1332
|
+
return this.state.transaction(() => {
|
|
1333
|
+
const row = this.workflowRunRow(runId);
|
|
1334
|
+
if (row === undefined || ["done", "failed", "cancelled"].includes(row.status))
|
|
1335
|
+
return undefined;
|
|
1336
|
+
const lease = this.requireLease(row.resourceId);
|
|
1337
|
+
if (lease.ownerId !== null &&
|
|
1338
|
+
lease.expiresAt !== null &&
|
|
1339
|
+
lease.expiresAt > now &&
|
|
1340
|
+
lease.ownerId !== runnerId)
|
|
1341
|
+
return undefined;
|
|
1342
|
+
const generation = lease.generation + 1;
|
|
1343
|
+
const expiresAt = now + leaseMs;
|
|
1344
|
+
const result = this.state.connection
|
|
1345
|
+
.prepare(`UPDATE leases SET generation = ?, owner_type = ?, owner_id = ?, token_hash = ?,
|
|
1346
|
+
acquired_at = ?, heartbeat_at = ?, expires_at = ?
|
|
1347
|
+
WHERE resource_id = ? AND generation = ?`)
|
|
1348
|
+
.run(generation, runnerId.startsWith("host-") ? "host" : "session", runnerId, tokenHash(claimToken), now, now, expiresAt, row.resourceId, lease.generation);
|
|
1349
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
1350
|
+
if (result.changes !== 1)
|
|
1351
|
+
return undefined;
|
|
1352
|
+
this.state.connection
|
|
1353
|
+
.prepare("UPDATE run_queue SET status = 'starting', updated_at = ? WHERE run_id = ?")
|
|
1354
|
+
.run(now, runId);
|
|
1355
|
+
const revision = this.resourceRevision(row.resourceId);
|
|
1356
|
+
this.bumpResource(row.resourceId, revision, now);
|
|
1357
|
+
this.insertEvent(row.resourceId, revision + 1, "lease.claimed", runnerId.startsWith("host-") ? "host" : "session", runnerId, { expiresAt }, now, generation);
|
|
1358
|
+
return { ...this.requireWorkflowRun(runId), claimToken };
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
updateClaimedRunStatus(runId, claimToken, status, nowValue) {
|
|
1362
|
+
const now = epoch(validTimestamp(nowValue));
|
|
1363
|
+
return this.state.transaction(() => {
|
|
1364
|
+
if (!this.verifyWorkflowRunClaim({
|
|
1365
|
+
runId,
|
|
1366
|
+
claimToken,
|
|
1367
|
+
now: new Date(now).toISOString(),
|
|
1368
|
+
})) {
|
|
1369
|
+
return false;
|
|
1370
|
+
}
|
|
1371
|
+
const row = this.requireWorkflowRunRow(runId);
|
|
1372
|
+
const changed = this.state.connection
|
|
1373
|
+
.prepare("UPDATE run_queue SET status = ?, started_at = COALESCE(started_at, ?), updated_at = ? WHERE run_id = ?")
|
|
1374
|
+
.run(status, now, now, runId).changes === 1;
|
|
1375
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
1376
|
+
if (!changed)
|
|
1377
|
+
return false;
|
|
1378
|
+
const revision = this.resourceRevision(row.resourceId);
|
|
1379
|
+
const lease = this.requireLease(row.resourceId);
|
|
1380
|
+
this.bumpResource(row.resourceId, revision, now);
|
|
1381
|
+
this.insertEvent(row.resourceId, revision + 1, `run.queue_${status}`, lease.ownerType ?? "system", lease.ownerId, { status }, now, lease.generation || undefined);
|
|
1382
|
+
return true;
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
releaseRunClaim(runId, claimToken, status, nowValue) {
|
|
1386
|
+
const now = epoch(validTimestamp(nowValue));
|
|
1387
|
+
return this.state.transaction(() => {
|
|
1388
|
+
const row = this.workflowRunRow(runId);
|
|
1389
|
+
if (row === undefined ||
|
|
1390
|
+
!this.verifyWorkflowRunClaim({ runId, claimToken, now: new Date(now).toISOString() }))
|
|
1391
|
+
return false;
|
|
1392
|
+
this.state.connection
|
|
1393
|
+
.prepare("UPDATE run_queue SET status = ?, updated_at = ?, finished_at = ? WHERE run_id = ?")
|
|
1394
|
+
.run(status, now, status === "done" ? now : null, runId);
|
|
1395
|
+
const lease = this.requireLease(row.resourceId);
|
|
1396
|
+
const update = this.state.connection
|
|
1397
|
+
.prepare(`UPDATE leases SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
|
|
1398
|
+
acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
|
|
1399
|
+
WHERE resource_id = ? AND token_hash = ? AND generation = ?`)
|
|
1400
|
+
.run(row.resourceId, tokenHash(claimToken), lease.generation);
|
|
1401
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
1402
|
+
if (update.changes !== 1)
|
|
1403
|
+
return false;
|
|
1404
|
+
const revision = this.resourceRevision(row.resourceId);
|
|
1405
|
+
this.bumpResource(row.resourceId, revision, now);
|
|
1406
|
+
this.insertEvent(row.resourceId, revision + 1, `run.queue_${status}`, lease.ownerType ?? "system", lease.ownerId, { status }, now, lease.generation || undefined);
|
|
1407
|
+
return true;
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
terminalRun(runId, claimToken, status, code, error, nowValue) {
|
|
1411
|
+
const now = epoch(validTimestamp(nowValue));
|
|
1412
|
+
return this.state.transaction(() => {
|
|
1413
|
+
const row = this.workflowRunRow(runId);
|
|
1414
|
+
if (row === undefined || ["done", "failed", "cancelled"].includes(row.status))
|
|
1415
|
+
return false;
|
|
1416
|
+
const lease = this.requireLease(row.resourceId);
|
|
1417
|
+
if (claimToken === undefined) {
|
|
1418
|
+
if (!["queued", "starting"].includes(row.status)) {
|
|
1419
|
+
return false;
|
|
1420
|
+
}
|
|
1421
|
+
if (lease.ownerId !== null)
|
|
1422
|
+
return false;
|
|
1423
|
+
}
|
|
1424
|
+
else if (lease.ownerId === null ||
|
|
1425
|
+
lease.tokenHash === null ||
|
|
1426
|
+
lease.expiresAt === null ||
|
|
1427
|
+
lease.expiresAt <= now ||
|
|
1428
|
+
!lease.tokenHash.equals(tokenHash(claimToken))) {
|
|
1429
|
+
return false;
|
|
1430
|
+
}
|
|
1431
|
+
if (row.outputHash === null)
|
|
1432
|
+
throw new Error(`Workflow run ${runId} has no state projection`);
|
|
1433
|
+
const projected = this.state.readJson(row.outputHash);
|
|
1434
|
+
if (!isRecord(projected))
|
|
1435
|
+
throw new Error(`Workflow run ${runId} state is invalid`);
|
|
1436
|
+
const revision = this.resourceRevision(row.resourceId);
|
|
1437
|
+
const at = new Date(now).toISOString();
|
|
1438
|
+
const terminalState = {
|
|
1439
|
+
...projected,
|
|
1440
|
+
traceSeq: revision + 1,
|
|
1441
|
+
status,
|
|
1442
|
+
updatedAt: at,
|
|
1443
|
+
finishedAt: at,
|
|
1444
|
+
error,
|
|
1445
|
+
};
|
|
1446
|
+
delete terminalState.currentNode;
|
|
1447
|
+
delete terminalState.currentAttemptId;
|
|
1448
|
+
delete terminalState.currentNodeStartedAt;
|
|
1449
|
+
delete terminalState.statusDetail;
|
|
1450
|
+
delete terminalState.paused;
|
|
1451
|
+
delete terminalState.waitingOn;
|
|
1452
|
+
const errorHash = this.state.putText(error, now);
|
|
1453
|
+
const outputHash = this.state.putJson(terminalState, now);
|
|
1454
|
+
const queueUpdate = this.state.connection
|
|
1455
|
+
.prepare(`UPDATE run_queue
|
|
1456
|
+
SET status = ?, error_code = ?, error_hash = ?, updated_at = ?, finished_at = ?
|
|
1457
|
+
WHERE run_id = ? AND status NOT IN ('done', 'failed', 'cancelled')`)
|
|
1458
|
+
.run(status, code, errorHash, now, now, runId);
|
|
1459
|
+
if (queueUpdate.changes !== 1)
|
|
1460
|
+
return false;
|
|
1461
|
+
if (claimToken !== undefined) {
|
|
1462
|
+
const leaseUpdate = this.state.connection
|
|
1463
|
+
.prepare(`UPDATE leases
|
|
1464
|
+
SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
|
|
1465
|
+
acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
|
|
1466
|
+
WHERE resource_id = ? AND token_hash = ? AND generation = ? AND expires_at > ?`)
|
|
1467
|
+
.run(row.resourceId, tokenHash(claimToken), lease.generation, now);
|
|
1468
|
+
if (leaseUpdate.changes !== 1) {
|
|
1469
|
+
throw new Error(`Workflow run ${runId} claim changed during terminal transition`);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
this.state.connection
|
|
1473
|
+
.prepare(`UPDATE runs
|
|
1474
|
+
SET status = ?, output_hash = ?, error_hash = ?, updated_at = ?, finished_at = ?
|
|
1475
|
+
WHERE run_id = ?`)
|
|
1476
|
+
.run(status, outputHash, errorHash, now, now, runId);
|
|
1477
|
+
this.bumpResource(row.resourceId, revision, now);
|
|
1478
|
+
this.insertEvent(row.resourceId, revision + 1, `run.queue_${status}`, lease.ownerType ?? "system", lease.ownerId, { status, code, error }, now, lease.generation || undefined);
|
|
1479
|
+
return true;
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
turnIntent(intentId) {
|
|
1483
|
+
const row = this.turnIntentRow(intentId);
|
|
1484
|
+
return row === undefined ? undefined : this.mapTurnIntent(row);
|
|
1485
|
+
}
|
|
1486
|
+
requireTurnIntent(intentId) {
|
|
1487
|
+
const row = this.turnIntent(intentId);
|
|
1488
|
+
if (row === undefined)
|
|
1489
|
+
throw new Error(`Workflow turn intent not found: ${intentId}`);
|
|
1490
|
+
return row;
|
|
1491
|
+
}
|
|
1492
|
+
turnIntentRow(intentId) {
|
|
1493
|
+
const row = this.state.connection
|
|
1494
|
+
.prepare(turnIntentSelect("WHERE t.turn_intent_id = ?"))
|
|
1495
|
+
.get(intentId);
|
|
1496
|
+
return isTurnIntentRow(row) ? row : undefined;
|
|
1497
|
+
}
|
|
1498
|
+
turnIntentBySource(sourceEventId) {
|
|
1499
|
+
const row = this.state.connection
|
|
1500
|
+
.prepare(turnIntentSelect("WHERE t.source_event_id = ?"))
|
|
1501
|
+
.get(sourceEventId);
|
|
1502
|
+
return isTurnIntentRow(row) ? this.mapTurnIntent(row) : undefined;
|
|
1503
|
+
}
|
|
1504
|
+
/* istanbul ignore next -- pure projection covered by integration tests */
|
|
1505
|
+
mapTurnIntent(row) {
|
|
1506
|
+
return {
|
|
1507
|
+
intentId: row.intentId,
|
|
1508
|
+
sourceEventId: row.sourceEventId,
|
|
1509
|
+
runId: row.runId,
|
|
1510
|
+
workflowRef: row.workflowRef,
|
|
1511
|
+
targetSessionId: row.targetSessionId,
|
|
1512
|
+
cause: row.cause,
|
|
1513
|
+
nodeId: row.nodeId,
|
|
1514
|
+
attemptId: row.attemptId,
|
|
1515
|
+
fallbackFacts: this.state.readJson(row.factsHash),
|
|
1516
|
+
requestedAt: new Date(row.requestedAt).toISOString(),
|
|
1517
|
+
eligibleAt: row.eligibleAt === null ? null : new Date(row.eligibleAt).toISOString(),
|
|
1518
|
+
resolvedAt: row.resolvedAt === null ? null : new Date(row.resolvedAt).toISOString(),
|
|
1519
|
+
resolution: row.resolution,
|
|
1520
|
+
resolutionMessageId: row.resolutionMessageId,
|
|
1521
|
+
deliveryClaimExpiresAt: row.claimExpiresAt === null ? null : new Date(row.claimExpiresAt).toISOString(),
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
claimEffectForTurn(intentId, token, leaseMs, nowValue) {
|
|
1525
|
+
const row = this.turnIntentRow(intentId);
|
|
1526
|
+
return (row !== undefined &&
|
|
1527
|
+
this.claimEffect(row.effectId, row.targetSessionId, token, leaseMs, nowValue));
|
|
1528
|
+
}
|
|
1529
|
+
claimEffect(effectId, ownerId, token, leaseMs, nowValue) {
|
|
1530
|
+
const now = epoch(validTimestamp(nowValue));
|
|
1531
|
+
return this.state.transaction(() => {
|
|
1532
|
+
const row = this.state.connection
|
|
1533
|
+
.prepare("SELECT resource_id AS resourceId FROM effects WHERE effect_id = ? AND status = 'pending'")
|
|
1534
|
+
.get(effectId);
|
|
1535
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
1536
|
+
if (!isResourceIdRow(row))
|
|
1537
|
+
return false;
|
|
1538
|
+
const lease = this.requireLease(row.resourceId);
|
|
1539
|
+
if (lease.ownerId !== null && lease.expiresAt !== null && lease.expiresAt > now)
|
|
1540
|
+
return false;
|
|
1541
|
+
return (this.state.connection
|
|
1542
|
+
.prepare(`UPDATE leases SET generation = ?, owner_type = 'session', owner_id = ?, token_hash = ?,
|
|
1543
|
+
acquired_at = ?, heartbeat_at = ?, expires_at = ?
|
|
1544
|
+
WHERE resource_id = ? AND generation = ?`)
|
|
1545
|
+
.run(lease.generation + 1, ownerId, tokenHash(token), now, now, now + leaseMs, row.resourceId, lease.generation).changes === 1);
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
verifyEffectToken(effectId, token, nowValue) {
|
|
1549
|
+
const now = epoch(validTimestamp(nowValue));
|
|
1550
|
+
const row = this.state.connection
|
|
1551
|
+
.prepare(`SELECT l.token_hash AS tokenHash, l.expires_at AS expiresAt
|
|
1552
|
+
FROM effects e JOIN leases l ON l.resource_id = e.resource_id
|
|
1553
|
+
WHERE e.effect_id = ?`)
|
|
1554
|
+
.get(effectId);
|
|
1555
|
+
return (isEffectTokenRow(row) &&
|
|
1556
|
+
row.tokenHash !== null &&
|
|
1557
|
+
row.tokenHash.equals(tokenHash(token)) &&
|
|
1558
|
+
row.expiresAt !== null &&
|
|
1559
|
+
row.expiresAt > now);
|
|
1560
|
+
}
|
|
1561
|
+
releaseEffectLease(effectId, token) {
|
|
1562
|
+
return (this.state.connection
|
|
1563
|
+
.prepare(`UPDATE leases SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
|
|
1564
|
+
acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
|
|
1565
|
+
WHERE resource_id = (SELECT resource_id FROM effects WHERE effect_id = ?)
|
|
1566
|
+
AND token_hash = ?`)
|
|
1567
|
+
.run(effectId, tokenHash(token)).changes === 1);
|
|
1568
|
+
}
|
|
1569
|
+
completeEffect(effectId, status, now) {
|
|
1570
|
+
this.state.connection
|
|
1571
|
+
.prepare("UPDATE effects SET status = ?, updated_at = ?, settled_at = ? WHERE effect_id = ?")
|
|
1572
|
+
.run(status, now, now, effectId);
|
|
1573
|
+
this.state.connection
|
|
1574
|
+
.prepare(`UPDATE leases SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
|
|
1575
|
+
acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
|
|
1576
|
+
WHERE resource_id = (SELECT resource_id FROM effects WHERE effect_id = ?)`)
|
|
1577
|
+
.run(effectId);
|
|
1578
|
+
}
|
|
1579
|
+
insertEffectResource(effectId, resourceId, sourceResourceId, sourceRevision, type, key, payloadHash, scope, now) {
|
|
1580
|
+
this.state.connection
|
|
1581
|
+
.prepare("INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at) VALUES (?, 'effect', ?, 1, ?, ?)")
|
|
1582
|
+
.run(resourceId, effectId, now, now);
|
|
1583
|
+
this.state.connection
|
|
1584
|
+
.prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
|
|
1585
|
+
.run(resourceId);
|
|
1586
|
+
this.state.connection
|
|
1587
|
+
.prepare(`INSERT INTO effects(
|
|
1588
|
+
effect_id, resource_id, source_resource_id, source_revision, effect_type,
|
|
1589
|
+
idempotency_key, payload_hash, owner_scope, status, attempt_count,
|
|
1590
|
+
created_at, updated_at
|
|
1591
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)`)
|
|
1592
|
+
.run(effectId, resourceId, sourceResourceId, sourceRevision, type, key, payloadHash, scope, now, now);
|
|
1593
|
+
this.insertEvent(resourceId, 1, "effect.created", "system", null, {}, now);
|
|
1594
|
+
}
|
|
1595
|
+
notification(runId, attemptId, index) {
|
|
1596
|
+
const row = this.state.connection
|
|
1597
|
+
.prepare(notificationSelect("WHERE n.run_id = ? AND n.attempt_id = ? AND n.notification_index = ?"))
|
|
1598
|
+
.get(runId, attemptId, index);
|
|
1599
|
+
return isNotificationRow(row) ? this.mapNotification(row) : undefined;
|
|
1600
|
+
}
|
|
1601
|
+
requireNotification(runId, attemptId, index) {
|
|
1602
|
+
const record = this.notification(runId, attemptId, index);
|
|
1603
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
1604
|
+
if (record === undefined)
|
|
1605
|
+
throw new Error("Workflow notification was not stored");
|
|
1606
|
+
return record;
|
|
1607
|
+
}
|
|
1608
|
+
notificationRows(sessionId, limit) {
|
|
1609
|
+
return this.state.connection
|
|
1610
|
+
.prepare(notificationSelect("WHERE n.target_session_id = ? AND e.status = 'pending' ORDER BY n.created_at LIMIT ?"))
|
|
1611
|
+
.all(sessionId, limit)
|
|
1612
|
+
.filter(isNotificationRow);
|
|
1613
|
+
}
|
|
1614
|
+
notificationRowById(id) {
|
|
1615
|
+
const row = this.state.connection
|
|
1616
|
+
.prepare(notificationSelect("WHERE n.notification_id = ?"))
|
|
1617
|
+
.get(id);
|
|
1618
|
+
return isNotificationRow(row) ? row : undefined;
|
|
1619
|
+
}
|
|
1620
|
+
/* istanbul ignore next -- pure projection covered by integration tests */
|
|
1621
|
+
mapNotification(row) {
|
|
1622
|
+
return {
|
|
1623
|
+
notificationId: row.notificationId,
|
|
1624
|
+
runId: row.runId,
|
|
1625
|
+
nodeId: row.nodeId,
|
|
1626
|
+
attemptId: row.attemptId,
|
|
1627
|
+
notificationIndex: row.notificationIndex,
|
|
1628
|
+
targetSessionId: row.targetSessionId,
|
|
1629
|
+
kind: row.kind,
|
|
1630
|
+
content: this.state.readBlob(row.contentHash)?.content.toString("utf8") ?? "",
|
|
1631
|
+
createdAt: new Date(row.createdAt).toISOString(),
|
|
1632
|
+
deliveryClaimExpiresAt: row.claimExpiresAt === null ? null : new Date(row.claimExpiresAt).toISOString(),
|
|
1633
|
+
deliveredAt: row.deliveredAt === null ? null : new Date(row.deliveredAt).toISOString(),
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
assertWritable() {
|
|
1637
|
+
if (this.state.mode === "read-only")
|
|
1638
|
+
throw new Error("Controller store is read-only");
|
|
1639
|
+
}
|
|
1445
1640
|
}
|
|
1446
|
-
function
|
|
1447
|
-
return
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
requestFingerprint: row.request_fingerprint,
|
|
1454
|
-
startedAt: row.started_at,
|
|
1455
|
-
...(row.completed_at === null ? {} : { completedAt: row.completed_at }),
|
|
1456
|
-
...(row.external_ref === null ? {} : { externalRef: row.external_ref }),
|
|
1457
|
-
...(row.error === null ? {} : { error: row.error }),
|
|
1458
|
-
};
|
|
1641
|
+
function controllerSelect(clause) {
|
|
1642
|
+
return `SELECT c.controller_resource_id AS controllerResourceId,
|
|
1643
|
+
c.resource_id AS resourceId, r.revision AS resourceVersion,
|
|
1644
|
+
c.controller_name AS controllerName, c.resource_key AS resourceKey,
|
|
1645
|
+
c.uid, c.generation, c.spec_hash AS specHash, c.status_hash AS statusHash,
|
|
1646
|
+
c.deletion_requested_at AS deletionRequestedAt
|
|
1647
|
+
FROM controller_resources c JOIN resources r ON r.resource_id = c.resource_id ${clause}`;
|
|
1459
1648
|
}
|
|
1460
|
-
function
|
|
1461
|
-
return
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
...(row.run_id === null ? {} : { runId: row.run_id }),
|
|
1470
|
-
...(row.error === null ? {} : { error: row.error }),
|
|
1471
|
-
};
|
|
1649
|
+
function effectSelect(clause) {
|
|
1650
|
+
return `SELECT e.effect_id AS effectId, c.uid AS resourceUid, c.generation,
|
|
1651
|
+
e.effect_type AS kind, e.status AS state,
|
|
1652
|
+
json_extract(CAST(b.content AS TEXT), '$.requestFingerprint') AS requestFingerprint,
|
|
1653
|
+
e.created_at AS startedAt, e.settled_at AS completedAt,
|
|
1654
|
+
e.external_ref AS externalRef, e.error_hash AS errorHash
|
|
1655
|
+
FROM effects e
|
|
1656
|
+
JOIN controller_resources c ON c.resource_id = e.source_resource_id
|
|
1657
|
+
JOIN blobs b ON b.blob_hash = e.payload_hash ${clause}`;
|
|
1472
1658
|
}
|
|
1473
|
-
function
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
validateRunId(row.run_id);
|
|
1481
|
-
validateKey(row.workflow_ref, "workflow turn workflow ref");
|
|
1482
|
-
validateKey(row.target_session_id, "workflow turn target session id");
|
|
1483
|
-
validTimestamp(row.requested_at);
|
|
1484
|
-
if (row.eligible_at !== null)
|
|
1485
|
-
validTimestamp(row.eligible_at);
|
|
1486
|
-
if (row.resolved_at !== null)
|
|
1487
|
-
validTimestamp(row.resolved_at);
|
|
1488
|
-
const allResolutionFieldsNull = row.resolved_at === null && row.resolution === null && row.resolution_message_id === null;
|
|
1489
|
-
const allResolutionFieldsSet = row.resolved_at !== null && row.resolution !== null && row.resolution_message_id !== null;
|
|
1490
|
-
if (!allResolutionFieldsNull && !allResolutionFieldsSet) {
|
|
1491
|
-
throw new Error(`Workflow turn intent ${row.intent_id} has inconsistent resolution fields`);
|
|
1492
|
-
}
|
|
1493
|
-
return {
|
|
1494
|
-
intentId: row.intent_id,
|
|
1495
|
-
sourceEventId: row.source_event_id,
|
|
1496
|
-
runId: row.run_id,
|
|
1497
|
-
workflowRef: row.workflow_ref,
|
|
1498
|
-
targetSessionId: row.target_session_id,
|
|
1499
|
-
cause,
|
|
1500
|
-
nodeId: row.node_id,
|
|
1501
|
-
attemptId: row.attempt_id,
|
|
1502
|
-
fallbackFacts,
|
|
1503
|
-
requestedAt: row.requested_at,
|
|
1504
|
-
eligibleAt: row.eligible_at,
|
|
1505
|
-
resolvedAt: row.resolved_at,
|
|
1506
|
-
resolution,
|
|
1507
|
-
resolutionMessageId: row.resolution_message_id,
|
|
1508
|
-
deliveryClaimExpiresAt: row.delivery_claim_expires_at === null ? null : iso(row.delivery_claim_expires_at),
|
|
1509
|
-
};
|
|
1659
|
+
function workflowSelect(clause) {
|
|
1660
|
+
return `SELECT w.request_id AS requestId, c.uid AS resourceUid,
|
|
1661
|
+
w.request_key AS requestKey, w.input_fingerprint AS inputFingerprint,
|
|
1662
|
+
w.workflow_name AS workflowName, COALESCE(w.run_id, w.reserved_run_id) AS runId, w.status,
|
|
1663
|
+
w.attempt_count AS attemptCount, w.error_hash AS errorHash
|
|
1664
|
+
FROM controller_workflows w
|
|
1665
|
+
JOIN controller_resources c ON c.controller_resource_id = w.controller_resource_id ${clause}`;
|
|
1510
1666
|
}
|
|
1511
|
-
function
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
};
|
|
1667
|
+
function workflowRunSelect(clause) {
|
|
1668
|
+
return `SELECT r.run_id AS runId, r.resource_id AS resourceId,
|
|
1669
|
+
d.workflow_name AS workflowName, r.workflow_ref AS workflowRef, r.status AS runStatus,
|
|
1670
|
+
r.workflow_source_hash AS workflowSourceHash, r.definition_digest AS definitionDigest,
|
|
1671
|
+
r.input_hash AS inputHash, r.output_hash AS outputHash,
|
|
1672
|
+
r.launch_options_hash AS launchOptionsHash,
|
|
1673
|
+
q.status, q.available_at AS availableAt, q.affinity_runner_id AS affinityRunnerId,
|
|
1674
|
+
q.consecutive_errors AS consecutiveErrors, q.error_code AS errorCode,
|
|
1675
|
+
q.error_hash AS errorHash, b.origin_session_id AS originSessionId,
|
|
1676
|
+
r.parent_run_id AS parentRunId, q.created_at AS createdAt, q.updated_at AS updatedAt,
|
|
1677
|
+
q.started_at AS startedAt, q.finished_at AS finishedAt,
|
|
1678
|
+
l.generation AS leaseGeneration, l.owner_id AS ownerId, l.expires_at AS claimExpiresAt
|
|
1679
|
+
FROM runs r JOIN workflow_definitions d ON d.definition_digest = r.definition_digest
|
|
1680
|
+
JOIN run_queue q ON q.run_id = r.run_id
|
|
1681
|
+
LEFT JOIN run_bindings b ON b.run_id = r.run_id
|
|
1682
|
+
JOIN leases l ON l.resource_id = r.resource_id ${clause}`;
|
|
1528
1683
|
}
|
|
1529
|
-
function
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1684
|
+
function turnIntentSelect(clause) {
|
|
1685
|
+
return `SELECT t.turn_intent_id AS intentId, t.source_event_id AS sourceEventId,
|
|
1686
|
+
t.run_id AS runId, t.workflow_ref AS workflowRef,
|
|
1687
|
+
t.target_session_id AS targetSessionId, t.cause,
|
|
1688
|
+
t.node_id AS nodeId, t.attempt_id AS attemptId, t.facts_hash AS factsHash,
|
|
1689
|
+
t.requested_at AS requestedAt, t.eligible_at AS eligibleAt,
|
|
1690
|
+
t.resolved_at AS resolvedAt, t.resolution_type AS resolution,
|
|
1691
|
+
t.resolution_message_id AS resolutionMessageId, l.expires_at AS claimExpiresAt,
|
|
1692
|
+
t.effect_id AS effectId
|
|
1693
|
+
FROM turn_intents t JOIN effects e ON e.effect_id = t.effect_id
|
|
1694
|
+
JOIN leases l ON l.resource_id = e.resource_id ${clause}`;
|
|
1534
1695
|
}
|
|
1535
|
-
function
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
"workflowName",
|
|
1546
|
-
"runId",
|
|
1547
|
-
"observedState",
|
|
1548
|
-
"cause",
|
|
1549
|
-
"nodeId",
|
|
1550
|
-
"attemptId",
|
|
1551
|
-
"reason",
|
|
1552
|
-
"handoff",
|
|
1553
|
-
]);
|
|
1554
|
-
for (const key of Object.keys(candidate)) {
|
|
1555
|
-
if (!allowedKeys.has(key))
|
|
1556
|
-
throw new Error(`Unknown workflow turn intent facts field: ${key}`);
|
|
1557
|
-
}
|
|
1558
|
-
if (typeof candidate.workflowName !== "string") {
|
|
1559
|
-
throw new Error("Workflow turn intent workflowName must be a string");
|
|
1560
|
-
}
|
|
1561
|
-
if (typeof candidate.runId !== "string") {
|
|
1562
|
-
throw new Error("Workflow turn intent runId must be a string");
|
|
1563
|
-
}
|
|
1564
|
-
if (typeof candidate.observedState !== "string") {
|
|
1565
|
-
throw new Error("Workflow turn intent observedState must be a string");
|
|
1566
|
-
}
|
|
1567
|
-
if (typeof candidate.cause !== "string") {
|
|
1568
|
-
throw new Error("Workflow turn intent cause must be a string");
|
|
1569
|
-
}
|
|
1570
|
-
validateKey(candidate.workflowName, "workflow turn facts workflow name");
|
|
1571
|
-
validateRunId(candidate.runId);
|
|
1572
|
-
validateKey(candidate.observedState, "workflow turn facts observed state");
|
|
1573
|
-
validateWorkflowTurnIntentCause(candidate.cause);
|
|
1574
|
-
if (candidate.nodeId !== null && typeof candidate.nodeId !== "string") {
|
|
1575
|
-
throw new Error("Workflow turn intent nodeId must be a string or null");
|
|
1576
|
-
}
|
|
1577
|
-
if (candidate.attemptId !== null && typeof candidate.attemptId !== "string") {
|
|
1578
|
-
throw new Error("Workflow turn intent attemptId must be a string or null");
|
|
1579
|
-
}
|
|
1580
|
-
if (candidate.reason !== null && typeof candidate.reason !== "string") {
|
|
1581
|
-
throw new Error("Workflow turn intent reason must be a string or null");
|
|
1582
|
-
}
|
|
1583
|
-
if (candidate.nodeId !== null)
|
|
1584
|
-
validateKey(candidate.nodeId, "workflow turn facts node id");
|
|
1585
|
-
if (candidate.attemptId !== null) {
|
|
1586
|
-
validateKey(candidate.attemptId, "workflow turn facts attempt id");
|
|
1587
|
-
}
|
|
1588
|
-
if (candidate.reason !== null && candidate.reason.length > MAX_ERROR_LENGTH) {
|
|
1589
|
-
throw new Error(`Workflow turn intent reason must be at most ${MAX_ERROR_LENGTH} characters`);
|
|
1590
|
-
}
|
|
1591
|
-
if (typeof candidate.handoff !== "boolean") {
|
|
1592
|
-
throw new Error("Workflow turn intent handoff must be a boolean");
|
|
1593
|
-
}
|
|
1696
|
+
function notificationSelect(clause) {
|
|
1697
|
+
return `SELECT n.notification_id AS notificationId, n.effect_id AS effectId,
|
|
1698
|
+
n.run_id AS runId, a.node_id AS nodeId, n.attempt_id AS attemptId,
|
|
1699
|
+
n.notification_index AS notificationIndex, n.target_session_id AS targetSessionId,
|
|
1700
|
+
n.notification_type AS kind, n.content_hash AS contentHash,
|
|
1701
|
+
n.created_at AS createdAt, l.expires_at AS claimExpiresAt,
|
|
1702
|
+
e.settled_at AS deliveredAt
|
|
1703
|
+
FROM notifications n JOIN effects e ON e.effect_id = n.effect_id
|
|
1704
|
+
JOIN leases l ON l.resource_id = e.resource_id
|
|
1705
|
+
JOIN node_attempts a ON a.attempt_id = n.attempt_id ${clause}`;
|
|
1594
1706
|
}
|
|
1595
|
-
function
|
|
1596
|
-
|
|
1597
|
-
value !== "timedOut" &&
|
|
1598
|
-
value !== "failed" &&
|
|
1599
|
-
value !== "launchFailed" &&
|
|
1600
|
-
value !== "controllerInterrupted" &&
|
|
1601
|
-
value !== "claimLost") {
|
|
1602
|
-
throw new Error(`Invalid workflow turn intent cause: ${value}`);
|
|
1603
|
-
}
|
|
1604
|
-
return value;
|
|
1707
|
+
function isRecord(value) {
|
|
1708
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1605
1709
|
}
|
|
1606
|
-
function
|
|
1607
|
-
|
|
1608
|
-
throw new Error(`Invalid workflow turn intent resolution: ${value}`);
|
|
1609
|
-
}
|
|
1610
|
-
return value;
|
|
1710
|
+
function isControllerRow(value) {
|
|
1711
|
+
return isRecord(value);
|
|
1611
1712
|
}
|
|
1612
|
-
function
|
|
1613
|
-
return
|
|
1614
|
-
runId: row.run_id,
|
|
1615
|
-
workflowName: row.workflow_ref,
|
|
1616
|
-
workflowSourceRef: row.workflow_path,
|
|
1617
|
-
workflowSource: parseStoredJson(row.workflow_source_json, "workflow run source"),
|
|
1618
|
-
definitionDigest: row.definition_digest,
|
|
1619
|
-
input: parseStoredJson(row.input_json, "workflow run input"),
|
|
1620
|
-
launchOptions: parseStoredJson(row.launch_options_json, "workflow launch options"),
|
|
1621
|
-
status: row.status,
|
|
1622
|
-
runnerId: row.runner_id,
|
|
1623
|
-
claimToken: row.claim_token,
|
|
1624
|
-
claimExpiresAt: row.claim_expires_at === null ? null : iso(row.claim_expires_at),
|
|
1625
|
-
affinityRunnerId: row.affinity_runner_id,
|
|
1626
|
-
originSessionId: row.origin_session_id,
|
|
1627
|
-
parentRunId: row.parent_run_id,
|
|
1628
|
-
errorCode: row.error_code,
|
|
1629
|
-
errorMessage: row.error_message,
|
|
1630
|
-
createdAt: row.created_at,
|
|
1631
|
-
updatedAt: row.updated_at,
|
|
1632
|
-
startedAt: row.started_at,
|
|
1633
|
-
finishedAt: row.finished_at,
|
|
1634
|
-
};
|
|
1713
|
+
function isLeaseRow(value) {
|
|
1714
|
+
return isRecord(value);
|
|
1635
1715
|
}
|
|
1636
|
-
function
|
|
1637
|
-
|
|
1638
|
-
throw new Error(`Invalid workflow run id: ${JSON.stringify(runId)}`);
|
|
1639
|
-
}
|
|
1716
|
+
function isRunRow(value) {
|
|
1717
|
+
return isRecord(value);
|
|
1640
1718
|
}
|
|
1641
|
-
function
|
|
1642
|
-
return
|
|
1643
|
-
seq: row.seq,
|
|
1644
|
-
recordedAt: row.recorded_at,
|
|
1645
|
-
controller: row.controller,
|
|
1646
|
-
key: row.resource_key,
|
|
1647
|
-
type: row.type,
|
|
1648
|
-
payload: parseStoredJson(row.payload_json, "controller event payload"),
|
|
1649
|
-
};
|
|
1719
|
+
function isEffectRow(value) {
|
|
1720
|
+
return isRecord(value);
|
|
1650
1721
|
}
|
|
1651
|
-
function
|
|
1652
|
-
|
|
1653
|
-
if (!Number.isFinite(Date.parse(timestamp))) {
|
|
1654
|
-
throw new Error(`Invalid timestamp: ${JSON.stringify(timestamp)}`);
|
|
1655
|
-
}
|
|
1656
|
-
return timestamp;
|
|
1722
|
+
function isWorkflowRow(value) {
|
|
1723
|
+
return isRecord(value);
|
|
1657
1724
|
}
|
|
1658
|
-
function
|
|
1659
|
-
|
|
1660
|
-
if (!Number.isSafeInteger(parsed)) {
|
|
1661
|
-
throw new Error(`Timestamp is outside the supported range: ${value}`);
|
|
1662
|
-
}
|
|
1663
|
-
return parsed;
|
|
1725
|
+
function isQueueListRow(value) {
|
|
1726
|
+
return isRecord(value);
|
|
1664
1727
|
}
|
|
1665
|
-
function
|
|
1666
|
-
return
|
|
1728
|
+
function isControllerEventRow(value) {
|
|
1729
|
+
return isRecord(value);
|
|
1667
1730
|
}
|
|
1668
|
-
function
|
|
1669
|
-
|
|
1670
|
-
throw new Error(`${name} must be a positive safe integer`);
|
|
1671
|
-
}
|
|
1731
|
+
function isRunEventRow(value) {
|
|
1732
|
+
return isRecord(value);
|
|
1672
1733
|
}
|
|
1673
|
-
function
|
|
1674
|
-
|
|
1675
|
-
throw new Error(`${description} contains unsupported characters: ${JSON.stringify(value)}`);
|
|
1676
|
-
}
|
|
1734
|
+
function isTurnIntentRow(value) {
|
|
1735
|
+
return isRecord(value);
|
|
1677
1736
|
}
|
|
1678
|
-
function
|
|
1679
|
-
|
|
1680
|
-
const code = character.codePointAt(0);
|
|
1681
|
-
return code < 32 || code === 127;
|
|
1682
|
-
});
|
|
1683
|
-
if (value.length === 0 || value.length > MAX_KEY_LENGTH || hasControl) {
|
|
1684
|
-
throw new Error(`${description} must be 1-${MAX_KEY_LENGTH} printable characters`);
|
|
1685
|
-
}
|
|
1737
|
+
function isNotificationRow(value) {
|
|
1738
|
+
return isRecord(value);
|
|
1686
1739
|
}
|
|
1687
|
-
function
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1740
|
+
function isProjectRow(value) {
|
|
1741
|
+
return isRecord(value);
|
|
1742
|
+
}
|
|
1743
|
+
function isFinalizerRow(value) {
|
|
1744
|
+
return isRecord(value);
|
|
1745
|
+
}
|
|
1746
|
+
function isErrorCountRow(value) {
|
|
1747
|
+
return isRecord(value);
|
|
1748
|
+
}
|
|
1749
|
+
function isQueueVersionRow(value) {
|
|
1750
|
+
return isRecord(value);
|
|
1751
|
+
}
|
|
1752
|
+
function isRevisionRow(value) {
|
|
1753
|
+
return isRecord(value);
|
|
1754
|
+
}
|
|
1755
|
+
function isSequenceRow(value) {
|
|
1756
|
+
return isRecord(value);
|
|
1757
|
+
}
|
|
1758
|
+
function isKeyRow(value) {
|
|
1759
|
+
return isRecord(value);
|
|
1760
|
+
}
|
|
1761
|
+
function isResourceIdRow(value) {
|
|
1762
|
+
return isRecord(value);
|
|
1763
|
+
}
|
|
1764
|
+
function isEffectTokenRow(value) {
|
|
1765
|
+
return isRecord(value);
|
|
1766
|
+
}
|
|
1767
|
+
function isEffectIdentityRow(value) {
|
|
1768
|
+
return isRecord(value);
|
|
1769
|
+
}
|
|
1770
|
+
function effectIdFor(sourceResourceId, key) {
|
|
1771
|
+
return `effect-${createHash("sha256").update(`${sourceResourceId}\0${key}`).digest("hex").slice(0, 40)}`;
|
|
1772
|
+
}
|
|
1773
|
+
function projectIdFor(canonicalPath) {
|
|
1774
|
+
return `project-${createHash("sha256").update(canonicalPath).digest("hex").slice(0, 40)}`;
|
|
1775
|
+
}
|
|
1776
|
+
function canonicalProjectPath(value) {
|
|
1777
|
+
try {
|
|
1778
|
+
return fs.realpathSync.native(value);
|
|
1695
1779
|
}
|
|
1780
|
+
catch {
|
|
1781
|
+
return path.resolve(value);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
function digestBuffer(value) {
|
|
1785
|
+
const hex = value.startsWith("sha256:") ? value.slice(7) : value;
|
|
1786
|
+
if (!/^[a-f0-9]{64}$/i.test(hex))
|
|
1787
|
+
throw new Error("Expected a SHA-256 digest");
|
|
1788
|
+
return Buffer.from(hex, "hex");
|
|
1696
1789
|
}
|
|
1697
|
-
function
|
|
1698
|
-
if (
|
|
1699
|
-
|
|
1790
|
+
function queuedWorkflowSource(value) {
|
|
1791
|
+
if (isRecord(value) && (value.kind === "builtin" || value.kind === "file")) {
|
|
1792
|
+
return { root: value, mounted: [] };
|
|
1700
1793
|
}
|
|
1794
|
+
if (isRecord(value) && isRecord(value.root) && Array.isArray(value.mounted)) {
|
|
1795
|
+
return { root: value.root, mounted: value.mounted };
|
|
1796
|
+
}
|
|
1797
|
+
throw new Error("Stored workflow source identity is invalid");
|
|
1798
|
+
}
|
|
1799
|
+
function sourceParts(ref, revision) {
|
|
1800
|
+
return ref.startsWith("builtin:")
|
|
1801
|
+
? { type: "builtin", ref: ref.slice(8), revision }
|
|
1802
|
+
: { type: "file", ref, revision };
|
|
1803
|
+
}
|
|
1804
|
+
function effectStatus(value) {
|
|
1805
|
+
return value === "indeterminate" ? "ambiguous" : value;
|
|
1806
|
+
}
|
|
1807
|
+
function controllerEffectState(value) {
|
|
1808
|
+
return value === "ambiguous" ? "indeterminate" : value;
|
|
1809
|
+
}
|
|
1810
|
+
function validTimestamp(value) {
|
|
1811
|
+
const date = value === undefined ? new Date() : new Date(value);
|
|
1812
|
+
if (!Number.isFinite(date.getTime()))
|
|
1813
|
+
throw new Error("Invalid timestamp");
|
|
1814
|
+
return date.toISOString();
|
|
1815
|
+
}
|
|
1816
|
+
function epoch(value) {
|
|
1817
|
+
return Date.parse(value);
|
|
1818
|
+
}
|
|
1819
|
+
function validateName(value, label) {
|
|
1820
|
+
validateKey(value, label);
|
|
1821
|
+
}
|
|
1822
|
+
function validateKey(value, label) {
|
|
1823
|
+
if (value.length === 0 || value.length > 512)
|
|
1824
|
+
throw new Error(`${label} is invalid`);
|
|
1701
1825
|
}
|
|
1702
|
-
function
|
|
1703
|
-
|
|
1826
|
+
function validateRunId(value) {
|
|
1827
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(value))
|
|
1828
|
+
throw new Error(`Invalid workflow run id: ${value}`);
|
|
1704
1829
|
}
|
|
1705
1830
|
//# sourceMappingURL=sqlite.js.map
|