@osolmaz/pi-workflows 0.14.0 → 0.15.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 +78 -110
- package/dist/builtins/autoimplement.workflow.js +3 -1
- package/dist/builtins/autoimplement.workflow.js.map +1 -1
- package/dist/builtins/change-verification.workflow.js +7 -2
- package/dist/builtins/change-verification.workflow.js.map +1 -1
- package/dist/builtins/metadata.d.ts +22 -0
- package/dist/builtins/metadata.js +10 -0
- package/dist/builtins/metadata.js.map +1 -0
- package/dist/builtins/monitor.workflow.js +4 -1
- package/dist/builtins/monitor.workflow.js.map +1 -1
- package/dist/builtins/sanity-check.workflow.js +4 -1
- package/dist/builtins/sanity-check.workflow.js.map +1 -1
- package/dist/builtins/workspace-preparation.workflow.js +3 -1
- package/dist/builtins/workspace-preparation.workflow.js.map +1 -1
- package/dist/controllers/index.d.ts +0 -1
- package/dist/controllers/index.js +0 -1
- package/dist/controllers/index.js.map +1 -1
- package/dist/controllers/sqlite.d.ts +51 -0
- package/dist/controllers/sqlite.js +433 -57
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/controllers/store.d.ts +1 -0
- package/dist/extension/controller-command.d.ts +22 -0
- package/dist/extension/controller-command.js +32 -0
- package/dist/extension/controller-command.js.map +1 -0
- package/dist/extension/index.d.ts +2 -9
- package/dist/extension/index.js +876 -3447
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/message-card.d.ts +13 -0
- package/dist/extension/message-card.js +46 -0
- package/dist/extension/message-card.js.map +1 -0
- package/dist/extension/step-message.d.ts +5 -6
- package/dist/extension/step-message.js +41 -52
- package/dist/extension/step-message.js.map +1 -1
- package/dist/host/child-worker-supervisor.d.ts +50 -0
- package/dist/host/child-worker-supervisor.js +159 -0
- package/dist/host/child-worker-supervisor.js.map +1 -0
- package/dist/host/client.d.ts +48 -0
- package/dist/host/client.js +216 -0
- package/dist/host/client.js.map +1 -0
- package/dist/host/controller-worker-entry.d.ts +2 -0
- package/dist/host/controller-worker-entry.js +244 -0
- package/dist/host/controller-worker-entry.js.map +1 -0
- package/dist/host/controller-worker-protocol.d.ts +36 -0
- package/dist/host/controller-worker-protocol.js +49 -0
- package/dist/host/controller-worker-protocol.js.map +1 -0
- package/dist/host/controller-worker-supervisor.d.ts +21 -0
- package/dist/host/controller-worker-supervisor.js +54 -0
- package/dist/host/controller-worker-supervisor.js.map +1 -0
- package/dist/host/host-entry.d.ts +2 -0
- package/dist/host/host-entry.js +23 -0
- package/dist/host/host-entry.js.map +1 -0
- package/dist/host/processes.d.ts +17 -12
- package/dist/host/processes.js +154 -50
- package/dist/host/processes.js.map +1 -1
- package/dist/host/protocol.d.ts +38 -0
- package/dist/host/protocol.js +156 -0
- package/dist/host/protocol.js.map +1 -0
- package/dist/host/resolver-entry.d.ts +49 -0
- package/dist/host/resolver-entry.js +149 -0
- package/dist/host/resolver-entry.js.map +1 -0
- package/dist/host/rpc-executor.d.ts +14 -3
- package/dist/host/rpc-executor.js +63 -30
- package/dist/host/rpc-executor.js.map +1 -1
- package/dist/host/runner.d.ts +95 -29
- package/dist/host/runner.js +2311 -348
- package/dist/host/runner.js.map +1 -1
- package/dist/host/state.d.ts +174 -0
- package/dist/host/state.js +689 -0
- package/dist/host/state.js.map +1 -0
- package/dist/host/worker-entry.d.ts +10 -0
- package/dist/host/worker-entry.js +446 -0
- package/dist/host/worker-entry.js.map +1 -0
- package/dist/host/worker-protocol.d.ts +31 -0
- package/dist/host/worker-protocol.js +122 -0
- package/dist/host/worker-protocol.js.map +1 -0
- package/dist/host/worker-store.d.ts +80 -0
- package/dist/host/worker-store.js +91 -0
- package/dist/host/worker-store.js.map +1 -0
- package/dist/host/worker-supervisor.d.ts +22 -0
- package/dist/host/worker-supervisor.js +54 -0
- package/dist/host/worker-supervisor.js.map +1 -0
- package/dist/render/canvas.d.ts +2 -1
- package/dist/render/canvas.js +40 -15
- package/dist/render/canvas.js.map +1 -1
- package/dist/render/graph-render.d.ts +7 -3
- package/dist/render/graph-render.js +125 -74
- package/dist/render/graph-render.js.map +1 -1
- package/dist/state/database.js +3 -1
- package/dist/state/database.js.map +1 -1
- package/dist/state/index.d.ts +1 -0
- package/dist/state/index.js +1 -0
- package/dist/state/index.js.map +1 -1
- package/dist/state/schema.d.ts +1 -1
- package/dist/state/schema.js +171 -3
- package/dist/state/schema.js.map +1 -1
- package/dist/state/viewer.d.ts +46 -0
- package/dist/state/viewer.js +249 -0
- package/dist/state/viewer.js.map +1 -0
- package/dist/viewer/cli.d.ts +1 -1
- package/dist/viewer/cli.js +47 -18
- package/dist/viewer/cli.js.map +1 -1
- package/dist/workflows/composition.js +25 -2
- package/dist/workflows/composition.js.map +1 -1
- package/dist/workflows/definition.d.ts +3 -1
- package/dist/workflows/definition.js +25 -0
- package/dist/workflows/definition.js.map +1 -1
- package/dist/workflows/engine.d.ts +1 -0
- package/dist/workflows/engine.js +157 -42
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/errors.d.ts +3 -1
- package/dist/workflows/errors.js +4 -7
- package/dist/workflows/errors.js.map +1 -1
- package/dist/workflows/human-decision.d.ts +3 -0
- package/dist/workflows/human-decision.js +31 -0
- package/dist/workflows/human-decision.js.map +1 -1
- package/dist/workflows/index.d.ts +1 -1
- package/dist/workflows/index.js +1 -1
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/schema.js +17 -6
- package/dist/workflows/schema.js.map +1 -1
- package/dist/{viewer → workflows}/session-reducer.d.ts +3 -1
- package/dist/{viewer → workflows}/session-reducer.js +4 -0
- package/dist/workflows/session-reducer.js.map +1 -0
- package/dist/workflows/store.d.ts +87 -1
- package/dist/workflows/store.js +892 -93
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/tool-input.d.ts +0 -26
- package/dist/workflows/tool-input.js +2 -50
- package/dist/workflows/tool-input.js.map +1 -1
- package/dist/workflows/types.d.ts +36 -4
- package/docs/2026-08-30-out-of-process-workflow-host-plan.md +353 -0
- package/docs/CONTROLLERS.md +11 -11
- package/docs/DEFERRED_TURNS.md +23 -2
- package/docs/SQLITE_STATE.md +41 -31
- package/docs/WORKFLOW_HOST.md +452 -0
- package/docs/development.md +46 -30
- package/docs/live-replay-protocol.md +129 -100
- package/docs/plans/2026-08-27-workflow-terminal-restart-plan.md +42 -0
- package/docs/plans/2026-08-28-piw-incremental-viewer-plan.md +293 -0
- package/docs/plans/piw-viewer-experience-implementation-plan.md +11 -1
- package/docs/plans/tui-viewer-implementation-plan.md +5 -0
- package/docs/tui-viewer.md +18 -3
- package/docs/workflows.md +162 -210
- package/examples/workflows/command-batch.workflow.ts +2 -0
- package/examples/workflows/shell.workflow.ts +2 -1
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/src/builtins/autoimplement.workflow.ts +3 -0
- package/src/builtins/change-verification.workflow.ts +7 -2
- package/src/builtins/metadata.ts +9 -0
- package/src/builtins/monitor.workflow.ts +4 -0
- package/src/builtins/sanity-check.workflow.ts +4 -0
- package/src/builtins/workspace-preparation.workflow.ts +3 -1
- package/src/controllers/index.ts +0 -5
- package/src/controllers/sqlite.ts +709 -76
- package/src/controllers/store.ts +1 -0
- package/src/extension/controller-command.ts +45 -0
- package/src/extension/index.ts +944 -4163
- package/src/extension/message-card.ts +61 -0
- package/src/extension/step-message.ts +58 -63
- package/src/host/child-worker-supervisor.ts +183 -0
- package/src/host/client.ts +293 -0
- package/src/host/controller-worker-entry.ts +311 -0
- package/src/host/controller-worker-protocol.ts +104 -0
- package/src/host/controller-worker-supervisor.ts +79 -0
- package/src/host/host-entry.ts +23 -0
- package/src/host/processes.ts +171 -54
- package/src/host/protocol.ts +196 -0
- package/src/host/resolver-entry.ts +241 -0
- package/src/host/rpc-executor.ts +76 -34
- package/src/host/runner.ts +2813 -422
- package/src/host/state.ts +1160 -0
- package/src/host/worker-entry.ts +533 -0
- package/src/host/worker-protocol.ts +165 -0
- package/src/host/worker-store.ts +229 -0
- package/src/host/worker-supervisor.ts +74 -0
- package/src/render/canvas.ts +44 -10
- package/src/render/graph-render.ts +145 -90
- package/src/state/database.ts +2 -1
- package/src/state/index.ts +14 -0
- package/src/state/schema.ts +171 -3
- package/src/state/viewer.ts +356 -0
- package/src/viewer/cli.ts +49 -17
- package/src/workflows/composition.ts +36 -2
- package/src/workflows/definition.ts +32 -0
- package/src/workflows/engine.ts +157 -54
- package/src/workflows/errors.ts +11 -2
- package/src/workflows/human-decision.ts +49 -0
- package/src/workflows/index.ts +2 -0
- package/src/workflows/schema.ts +19 -6
- package/src/{viewer → workflows}/session-reducer.ts +11 -1
- package/src/workflows/store.ts +1316 -108
- package/src/workflows/tool-input.ts +3 -69
- package/src/workflows/types.ts +32 -4
- package/dist/controllers/workflow-engine-scheduler.d.ts +0 -29
- package/dist/controllers/workflow-engine-scheduler.js +0 -175
- package/dist/controllers/workflow-engine-scheduler.js.map +0 -1
- package/dist/extension/controller-host.d.ts +0 -48
- package/dist/extension/controller-host.js +0 -110
- package/dist/extension/controller-host.js.map +0 -1
- package/dist/extension/deferred-turn-coordinator.d.ts +0 -32
- package/dist/extension/deferred-turn-coordinator.js +0 -143
- package/dist/extension/deferred-turn-coordinator.js.map +0 -1
- package/dist/extension/deferred-turn.d.ts +0 -44
- package/dist/extension/deferred-turn.js +0 -110
- package/dist/extension/deferred-turn.js.map +0 -1
- package/dist/extension/executor.d.ts +0 -86
- package/dist/extension/executor.js +0 -311
- package/dist/extension/executor.js.map +0 -1
- package/dist/extension/follow-up-coordinator.d.ts +0 -27
- package/dist/extension/follow-up-coordinator.js +0 -131
- package/dist/extension/follow-up-coordinator.js.map +0 -1
- package/dist/extension/recorder.d.ts +0 -84
- package/dist/extension/recorder.js +0 -528
- package/dist/extension/recorder.js.map +0 -1
- package/dist/extension/restart-policy.d.ts +0 -38
- package/dist/extension/restart-policy.js +0 -116
- package/dist/extension/restart-policy.js.map +0 -1
- package/dist/extension/session-events.d.ts +0 -133
- package/dist/extension/session-events.js +0 -61
- package/dist/extension/session-events.js.map +0 -1
- package/dist/extension/terminal-decision.d.ts +0 -51
- package/dist/extension/terminal-decision.js +0 -110
- package/dist/extension/terminal-decision.js.map +0 -1
- package/dist/viewer/session-reducer.js.map +0 -1
- package/src/controllers/workflow-engine-scheduler.ts +0 -246
- package/src/extension/controller-host.ts +0 -167
- package/src/extension/deferred-turn-coordinator.ts +0 -171
- package/src/extension/deferred-turn.ts +0 -166
- package/src/extension/executor.ts +0 -411
- package/src/extension/follow-up-coordinator.ts +0 -151
- package/src/extension/recorder.ts +0 -655
- package/src/extension/restart-policy.ts +0 -163
- package/src/extension/session-events.ts +0 -121
- package/src/extension/terminal-decision.ts +0 -172
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { StateDatabase, workflowStatePath } from "../state/database.js";
|
|
5
5
|
import { canonicalJson } from "../state/json.js";
|
|
6
6
|
import { resourceIdFor, tokenHash } from "../state/mutation.js";
|
|
7
|
+
import { initializeViewerRun, recordViewerDeltas } from "../state/viewer.js";
|
|
7
8
|
import { EffectRequestConflictError, ResourceConflictError, ResourceNotFoundError, WorkflowRequestConflictError, } from "./errors.js";
|
|
8
9
|
const TURN_INTENT_FACTS_SCHEMA = "pi-workflows.deferred-turn-facts.v1";
|
|
9
10
|
export class SqliteControllerStore {
|
|
@@ -22,9 +23,14 @@ export class SqliteControllerStore {
|
|
|
22
23
|
checkLegacyState: filePath === workflowStatePath(),
|
|
23
24
|
});
|
|
24
25
|
this.filePath = this.state.filePath;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
if (options.global === true) {
|
|
27
|
+
this.projectId = null;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
const projectPath = options.projectPath === undefined ? process.cwd() : options.projectPath;
|
|
31
|
+
this.projectId =
|
|
32
|
+
options.readOnly === true ? this.findProject(projectPath) : this.ensureProject(projectPath);
|
|
33
|
+
}
|
|
28
34
|
}
|
|
29
35
|
close() {
|
|
30
36
|
if (this.closed)
|
|
@@ -189,6 +195,11 @@ export class SqliteControllerStore {
|
|
|
189
195
|
return undefined;
|
|
190
196
|
const now = epoch(validTimestamp(options.now));
|
|
191
197
|
const placeholders = options.controllers.map(() => "?").join(", ");
|
|
198
|
+
const exclusions = options.exclude ?? [];
|
|
199
|
+
const exclusionSql = exclusions
|
|
200
|
+
.map(() => "AND NOT (c.controller_name = ? AND c.resource_key = ?)")
|
|
201
|
+
.join("\n ");
|
|
202
|
+
const exclusionParams = exclusions.flatMap((ref) => [ref.controller, ref.key]);
|
|
192
203
|
return this.state.transaction(() => {
|
|
193
204
|
const row = this.state.connection
|
|
194
205
|
.prepare(`${controllerSelect(`
|
|
@@ -196,11 +207,12 @@ export class SqliteControllerStore {
|
|
|
196
207
|
JOIN leases l ON l.resource_id = c.resource_id
|
|
197
208
|
WHERE c.project_id = ?
|
|
198
209
|
AND c.controller_name IN (${placeholders})
|
|
210
|
+
${exclusionSql}
|
|
199
211
|
AND q.available_at <= ?
|
|
200
212
|
AND (l.owner_id IS NULL OR l.expires_at <= ?)
|
|
201
213
|
ORDER BY q.available_at, c.controller_name, c.resource_key
|
|
202
214
|
LIMIT 1`)}`)
|
|
203
|
-
.get(this.requireProjectId(), ...options.controllers, now, now);
|
|
215
|
+
.get(this.requireProjectId(), ...options.controllers, ...exclusionParams, now, now);
|
|
204
216
|
if (!isControllerRow(row))
|
|
205
217
|
return undefined;
|
|
206
218
|
const lease = this.requireLease(row.resourceId);
|
|
@@ -559,11 +571,12 @@ export class SqliteControllerStore {
|
|
|
559
571
|
input_hash, created_at, updated_at
|
|
560
572
|
) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`)
|
|
561
573
|
.run(options.runId, resourceId, this.requireProjectId(), options.parentRunId ?? null, definitionDigest, options.workflowSourceRef, launchHash, inputHash, now, now);
|
|
574
|
+
initializeViewerRun(this.state, options.runId, now);
|
|
562
575
|
insertQueuedRunSources(this.state, options.runId, queuedSource);
|
|
563
576
|
this.state.connection
|
|
564
577
|
.prepare(`INSERT INTO run_bindings(run_id, origin_session_id, execution_mode, created_at)
|
|
565
|
-
VALUES (?, ?,
|
|
566
|
-
.run(options.runId, options.originSessionId, now);
|
|
578
|
+
VALUES (?, ?, ?, ?)`)
|
|
579
|
+
.run(options.runId, options.originSessionId, options.executionMode ?? "interactive", now);
|
|
567
580
|
this.state.connection
|
|
568
581
|
.prepare(`INSERT INTO run_queue(
|
|
569
582
|
run_id, status, available_at, affinity_runner_id, origin_session_id,
|
|
@@ -627,9 +640,20 @@ export class SqliteControllerStore {
|
|
|
627
640
|
const row = this.workflowRunRow(runId);
|
|
628
641
|
return row === undefined ? undefined : this.mapWorkflowRun(row);
|
|
629
642
|
}
|
|
643
|
+
workflowRunProjectPath(runId) {
|
|
644
|
+
const row = this.state.connection
|
|
645
|
+
.prepare(`SELECT p.canonical_path AS canonicalPath
|
|
646
|
+
FROM runs r JOIN projects p ON p.project_id = r.project_id WHERE r.run_id = ?`)
|
|
647
|
+
.get(runId);
|
|
648
|
+
return isCanonicalPathRow(row) ? row.canonicalPath : undefined;
|
|
649
|
+
}
|
|
630
650
|
listWorkflowRuns(options = {}) {
|
|
631
|
-
const clauses = [
|
|
632
|
-
const params = [
|
|
651
|
+
const clauses = [];
|
|
652
|
+
const params = [];
|
|
653
|
+
if (this.projectId !== null) {
|
|
654
|
+
clauses.push("r.project_id = ?");
|
|
655
|
+
params.push(this.projectId);
|
|
656
|
+
}
|
|
633
657
|
if (options.statuses !== undefined && options.statuses.length > 0) {
|
|
634
658
|
clauses.push(`q.status IN (${options.statuses.map(() => "?").join(", ")})`);
|
|
635
659
|
params.push(...options.statuses);
|
|
@@ -640,7 +664,7 @@ export class SqliteControllerStore {
|
|
|
640
664
|
}
|
|
641
665
|
params.push(options.limit ?? 100);
|
|
642
666
|
const rows = this.state.connection
|
|
643
|
-
.prepare(workflowRunSelect(`WHERE ${clauses.join(" AND ")} ORDER BY q.created_at DESC LIMIT ?`))
|
|
667
|
+
.prepare(workflowRunSelect(`${clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`} ORDER BY q.created_at DESC LIMIT ?`))
|
|
644
668
|
.all(...params);
|
|
645
669
|
return rows.filter(isRunRow).map((row) => this.mapWorkflowRun(row));
|
|
646
670
|
}
|
|
@@ -654,25 +678,93 @@ export class SqliteControllerStore {
|
|
|
654
678
|
const now = epoch(validTimestamp(options.now));
|
|
655
679
|
return this.claimRun(options.runId, options.runnerId, options.claimToken, options.leaseMs, now);
|
|
656
680
|
}
|
|
681
|
+
/** Schedule a worker that validates one pending interactive submission. */
|
|
682
|
+
claimWorkflowRunForInteractionValidation(options) {
|
|
683
|
+
const now = epoch(validTimestamp(options.now));
|
|
684
|
+
return this.claimRun(options.runId, options.runnerId, options.claimToken, options.leaseMs, now, {
|
|
685
|
+
allowPendingInteraction: true,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
/** Take a short host claim without scheduling a parked interactive run. */
|
|
689
|
+
claimWorkflowRunForControl(options) {
|
|
690
|
+
const now = epoch(validTimestamp(options.now));
|
|
691
|
+
return this.claimRun(options.runId, options.runnerId, options.claimToken, options.leaseMs, now, { allowPendingInteraction: true, preserveQueueStatus: true });
|
|
692
|
+
}
|
|
693
|
+
beginWorkflowRunInteractionTimeout(options) {
|
|
694
|
+
const now = epoch(validTimestamp(options.now));
|
|
695
|
+
return this.state.transaction(() => {
|
|
696
|
+
if (!this.verifyWorkflowRunClaim({
|
|
697
|
+
runId: options.runId,
|
|
698
|
+
claimToken: options.claimToken,
|
|
699
|
+
now: new Date(now).toISOString(),
|
|
700
|
+
})) {
|
|
701
|
+
return false;
|
|
702
|
+
}
|
|
703
|
+
const row = this.requireWorkflowRunRow(options.runId);
|
|
704
|
+
const interaction = this.state.connection
|
|
705
|
+
.prepare(`SELECT i.request_id AS requestId, i.attempt_id AS attemptId
|
|
706
|
+
FROM interactive_requests i
|
|
707
|
+
JOIN node_attempts a ON a.attempt_id = i.attempt_id
|
|
708
|
+
WHERE i.run_id = ? AND i.status IN ('pending', 'presenting')
|
|
709
|
+
AND a.deadline_at IS NOT NULL AND a.deadline_at <= ?
|
|
710
|
+
ORDER BY a.deadline_at, i.request_id LIMIT 1`)
|
|
711
|
+
.get(options.runId, now);
|
|
712
|
+
if (!isExpiredInteractionRow(interaction))
|
|
713
|
+
return false;
|
|
714
|
+
const request = this.state.connection
|
|
715
|
+
.prepare(`UPDATE interactive_requests
|
|
716
|
+
SET status = 'cancelled', presenter_id = NULL,
|
|
717
|
+
presentation_claim_expires_at = NULL, revision = revision + 1, updated_at = ?
|
|
718
|
+
WHERE request_id = ? AND run_id = ? AND status IN ('pending', 'presenting')`)
|
|
719
|
+
.run(now, interaction.requestId, options.runId);
|
|
720
|
+
const run = this.state.connection
|
|
721
|
+
.prepare(`UPDATE runs
|
|
722
|
+
SET status = 'running', status_detail = 'finishing expired interaction deadline',
|
|
723
|
+
updated_at = ?, finished_at = NULL
|
|
724
|
+
WHERE run_id = ? AND status = 'waiting'`)
|
|
725
|
+
.run(now, options.runId);
|
|
726
|
+
const queue = this.state.connection
|
|
727
|
+
.prepare(`UPDATE run_queue SET status = 'starting', error_code = NULL, error_hash = NULL,
|
|
728
|
+
updated_at = ?, finished_at = NULL
|
|
729
|
+
WHERE run_id = ? AND status = 'parked'`)
|
|
730
|
+
.run(now, options.runId);
|
|
731
|
+
/* istanbul ignore if -- the expired parked interaction was selected above */
|
|
732
|
+
if (request.changes !== 1 || run.changes !== 1 || queue.changes !== 1) {
|
|
733
|
+
throw new Error(`Workflow run ${options.runId} changed during interaction timeout`);
|
|
734
|
+
}
|
|
735
|
+
const error = "Workflow node deadline expired before origin-session input arrived";
|
|
736
|
+
const receiptHash = this.state.putJson({ status: "rejected", error }, now);
|
|
737
|
+
this.state.connection
|
|
738
|
+
.prepare(`UPDATE interactive_submissions SET outcome = 'rejected', receipt_hash = ?
|
|
739
|
+
WHERE request_id = ? AND outcome = 'validating'`)
|
|
740
|
+
.run(receiptHash, interaction.requestId);
|
|
741
|
+
const revision = this.resourceRevision(row.resourceId);
|
|
742
|
+
const lease = this.requireLease(row.resourceId);
|
|
743
|
+
this.bumpResource(row.resourceId, revision, now);
|
|
744
|
+
this.insertEvent(row.resourceId, revision + 1, "run.interaction_timeout_started", lease.ownerType ?? "system", lease.ownerId, { requestId: interaction.requestId, attemptId: interaction.attemptId }, now, lease.generation || undefined);
|
|
745
|
+
recordViewerDeltas(this.state, options.runId, [{ targetType: "summary" }, { targetType: "replay" }, { targetType: "conversation" }], now);
|
|
746
|
+
return true;
|
|
747
|
+
});
|
|
748
|
+
}
|
|
657
749
|
markWorkflowRunRunning(options) {
|
|
658
750
|
return this.updateClaimedRunStatus(options.runId, options.claimToken, "running", options.now);
|
|
659
751
|
}
|
|
660
752
|
claimNextWorkflowRun(options) {
|
|
661
753
|
const now = epoch(validTimestamp(options.now));
|
|
662
754
|
const clauses = [
|
|
663
|
-
"r.project_id = ?",
|
|
664
755
|
"q.status IN ('queued', 'parked', 'starting', 'running')",
|
|
665
756
|
"q.available_at <= ?",
|
|
666
757
|
"(l.owner_id IS NULL OR l.expires_at <= ? OR l.owner_id = ?)",
|
|
667
758
|
"(q.affinity_runner_id IS NULL OR q.affinity_runner_id = ? OR q.status IN ('parked', 'starting', 'running'))",
|
|
759
|
+
"NOT (q.status = 'parked' AND (r.status = 'waiting' OR r.paused = 1))",
|
|
760
|
+
"NOT EXISTS (SELECT 1 FROM interactive_requests i WHERE i.run_id = r.run_id AND i.status IN ('pending', 'presenting'))",
|
|
761
|
+
"(q.error_code IS NULL OR q.error_code <> 'workflowSourceChanged')",
|
|
668
762
|
];
|
|
669
|
-
const params = [
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
options.runnerId,
|
|
675
|
-
];
|
|
763
|
+
const params = [now, now, options.runnerId, options.runnerId];
|
|
764
|
+
if (this.projectId !== null) {
|
|
765
|
+
clauses.unshift("r.project_id = ?");
|
|
766
|
+
params.unshift(this.projectId);
|
|
767
|
+
}
|
|
676
768
|
if (options.sessionId !== undefined) {
|
|
677
769
|
clauses.push("b.origin_session_id = ?");
|
|
678
770
|
params.push(options.sessionId);
|
|
@@ -693,14 +785,22 @@ export class SqliteControllerStore {
|
|
|
693
785
|
}
|
|
694
786
|
renewWorkflowRunClaim(options) {
|
|
695
787
|
const now = epoch(validTimestamp(options.now));
|
|
696
|
-
|
|
697
|
-
|
|
788
|
+
return this.state.transaction(() => {
|
|
789
|
+
const row = this.workflowRunRow(options.runId);
|
|
790
|
+
if (row === undefined || row.ownerId === null)
|
|
791
|
+
return false;
|
|
792
|
+
const lease = this.requireLease(row.resourceId);
|
|
793
|
+
const result = this.state.connection
|
|
794
|
+
.prepare(`UPDATE leases SET heartbeat_at = ?, expires_at = ?
|
|
795
|
+
WHERE resource_id = ? AND owner_type = ? AND owner_id = ?
|
|
796
|
+
AND token_hash = ? AND generation = ? AND expires_at > ?`)
|
|
797
|
+
.run(now, now + options.leaseMs, row.resourceId, lease.ownerType, row.ownerId, tokenHash(options.claimToken), lease.generation, now);
|
|
798
|
+
if (result.changes === 1) {
|
|
799
|
+
recordViewerDeltas(this.state, options.runId, [{ targetType: "summary" }, { targetType: "replay" }], now);
|
|
800
|
+
return true;
|
|
801
|
+
}
|
|
698
802
|
return false;
|
|
699
|
-
|
|
700
|
-
.prepare(`UPDATE leases SET heartbeat_at = ?, expires_at = ?
|
|
701
|
-
WHERE resource_id = ? AND token_hash = ? AND owner_id = ? AND expires_at > ?`)
|
|
702
|
-
.run(now, now + options.leaseMs, row.resourceId, tokenHash(options.claimToken), row.ownerId, now);
|
|
703
|
-
return result.changes === 1;
|
|
803
|
+
});
|
|
704
804
|
}
|
|
705
805
|
verifyWorkflowRunClaim(options) {
|
|
706
806
|
const now = epoch(validTimestamp(options.now));
|
|
@@ -714,10 +814,7 @@ export class SqliteControllerStore {
|
|
|
714
814
|
}
|
|
715
815
|
workflowRunAuthority(runId, claimToken) {
|
|
716
816
|
const row = this.workflowRunRow(runId);
|
|
717
|
-
if (row === undefined ||
|
|
718
|
-
row.ownerId === null ||
|
|
719
|
-
row.claimExpiresAt === null ||
|
|
720
|
-
row.claimExpiresAt <= Date.now())
|
|
817
|
+
if (row === undefined || row.ownerId === null || row.claimExpiresAt === null)
|
|
721
818
|
return undefined;
|
|
722
819
|
const lease = this.requireLease(row.resourceId);
|
|
723
820
|
if (lease.tokenHash === null || !lease.tokenHash.equals(tokenHash(claimToken)))
|
|
@@ -729,16 +826,180 @@ export class SqliteControllerStore {
|
|
|
729
826
|
ownerId: row.ownerId,
|
|
730
827
|
token: claimToken,
|
|
731
828
|
generation: row.leaseGeneration,
|
|
829
|
+
leaseMs: 30_000,
|
|
732
830
|
};
|
|
733
831
|
}
|
|
734
832
|
parkWorkflowRun(options) {
|
|
735
833
|
return this.releaseRunClaim(options.runId, options.claimToken, "parked", options.now);
|
|
736
834
|
}
|
|
835
|
+
parkWorkflowRunForSourceChange(options) {
|
|
836
|
+
const now = epoch(validTimestamp(options.now));
|
|
837
|
+
return this.state.transaction(() => {
|
|
838
|
+
const row = this.workflowRunRow(options.runId);
|
|
839
|
+
if (row === undefined ||
|
|
840
|
+
!this.verifyWorkflowRunClaim({
|
|
841
|
+
runId: options.runId,
|
|
842
|
+
claimToken: options.claimToken,
|
|
843
|
+
now: new Date(now).toISOString(),
|
|
844
|
+
})) {
|
|
845
|
+
return false;
|
|
846
|
+
}
|
|
847
|
+
const lease = this.requireLease(row.resourceId);
|
|
848
|
+
const detail = options.detail.slice(0, 8_192);
|
|
849
|
+
const errorHash = this.state.putText(detail, now);
|
|
850
|
+
const run = this.state.connection
|
|
851
|
+
.prepare(`UPDATE runs SET status_detail = ?, updated_at = ?, finished_at = NULL
|
|
852
|
+
WHERE run_id = ? AND status NOT IN ('completed', 'failed', 'timed_out', 'cancelled')`)
|
|
853
|
+
.run(detail, now, options.runId);
|
|
854
|
+
const queue = this.state.connection
|
|
855
|
+
.prepare(`UPDATE run_queue
|
|
856
|
+
SET status = 'parked', error_code = 'workflowSourceChanged', error_hash = ?,
|
|
857
|
+
available_at = ?, updated_at = ?, finished_at = NULL
|
|
858
|
+
WHERE run_id = ? AND status IN ('queued', 'starting', 'running', 'parked')`)
|
|
859
|
+
.run(errorHash, now, now, options.runId);
|
|
860
|
+
/* istanbul ignore if -- exact live claim and nonterminal checks make both updates mandatory */
|
|
861
|
+
if (run.changes !== 1 || queue.changes !== 1) {
|
|
862
|
+
throw new Error(`Workflow run ${options.runId} has inconsistent source-change state`);
|
|
863
|
+
}
|
|
864
|
+
const released = this.state.connection
|
|
865
|
+
.prepare(`UPDATE leases
|
|
866
|
+
SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
|
|
867
|
+
acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
|
|
868
|
+
WHERE resource_id = ? AND token_hash = ? AND generation = ? AND expires_at > ?`)
|
|
869
|
+
.run(row.resourceId, tokenHash(options.claimToken), lease.generation, now);
|
|
870
|
+
/* istanbul ignore if -- the exact live claim is stable in this transaction */
|
|
871
|
+
if (released.changes !== 1) {
|
|
872
|
+
throw new Error(`Workflow run ${options.runId} claim changed during source recovery`);
|
|
873
|
+
}
|
|
874
|
+
const revision = this.resourceRevision(row.resourceId);
|
|
875
|
+
this.bumpResource(row.resourceId, revision, now);
|
|
876
|
+
this.insertEvent(row.resourceId, revision + 1, "run.source_changed", lease.ownerType ?? "system", lease.ownerId, { status: "parked", code: "workflowSourceChanged" }, now, lease.generation || undefined);
|
|
877
|
+
recordViewerDeltas(this.state, options.runId, [{ targetType: "summary" }, { targetType: "replay" }], now);
|
|
878
|
+
return true;
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
parkWorkflowRunForAmbiguousEffect(options) {
|
|
882
|
+
const now = epoch(validTimestamp(options.now));
|
|
883
|
+
return this.state.transaction(() => {
|
|
884
|
+
const row = this.workflowRunRow(options.runId);
|
|
885
|
+
if (row === undefined ||
|
|
886
|
+
!this.verifyWorkflowRunClaim({
|
|
887
|
+
runId: options.runId,
|
|
888
|
+
claimToken: options.claimToken,
|
|
889
|
+
now: new Date(now).toISOString(),
|
|
890
|
+
})) {
|
|
891
|
+
return false;
|
|
892
|
+
}
|
|
893
|
+
const lease = this.requireLease(row.resourceId);
|
|
894
|
+
const detail = "effect outcome is ambiguous; explicit recovery is required";
|
|
895
|
+
const errorHash = this.state.putText(detail, now);
|
|
896
|
+
this.state.connection
|
|
897
|
+
.prepare(`UPDATE node_attempts SET status = 'waiting', updated_at = ?
|
|
898
|
+
WHERE run_id = ? AND status IN ('pending', 'running')`)
|
|
899
|
+
.run(now, options.runId);
|
|
900
|
+
const run = this.state.connection
|
|
901
|
+
.prepare(`UPDATE runs SET status = 'waiting', status_detail = ?, updated_at = ?, finished_at = ?
|
|
902
|
+
WHERE run_id = ? AND status = 'running'`)
|
|
903
|
+
.run(detail, now, now, options.runId);
|
|
904
|
+
const queue = this.state.connection
|
|
905
|
+
.prepare(`UPDATE run_queue
|
|
906
|
+
SET status = 'parked', error_code = 'effectAmbiguous', error_hash = ?, updated_at = ?
|
|
907
|
+
WHERE run_id = ? AND status IN ('starting', 'running', 'parked')`)
|
|
908
|
+
.run(errorHash, now, options.runId);
|
|
909
|
+
/* istanbul ignore if -- exact live claim and run checks make both updates mandatory */
|
|
910
|
+
if (run.changes !== 1 || queue.changes !== 1) {
|
|
911
|
+
throw new Error(`Workflow run ${options.runId} has inconsistent ambiguous-effect state`);
|
|
912
|
+
}
|
|
913
|
+
const released = this.state.connection
|
|
914
|
+
.prepare(`UPDATE leases
|
|
915
|
+
SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
|
|
916
|
+
acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
|
|
917
|
+
WHERE resource_id = ? AND token_hash = ? AND generation = ? AND expires_at > ?`)
|
|
918
|
+
.run(row.resourceId, tokenHash(options.claimToken), lease.generation, now);
|
|
919
|
+
/* istanbul ignore if -- the exact live claim is stable in this transaction */
|
|
920
|
+
if (released.changes !== 1) {
|
|
921
|
+
throw new Error(`Workflow run ${options.runId} claim changed during effect recovery`);
|
|
922
|
+
}
|
|
923
|
+
const revision = this.resourceRevision(row.resourceId);
|
|
924
|
+
this.bumpResource(row.resourceId, revision, now);
|
|
925
|
+
this.insertEvent(row.resourceId, revision + 1, "run.effect_ambiguous", lease.ownerType ?? "system", lease.ownerId, { status: "waiting", code: "effectAmbiguous" }, now, lease.generation || undefined);
|
|
926
|
+
recordViewerDeltas(this.state, options.runId, [{ targetType: "summary" }, { targetType: "replay" }], now);
|
|
927
|
+
return true;
|
|
928
|
+
});
|
|
929
|
+
}
|
|
737
930
|
failWorkflowRun(options) {
|
|
738
931
|
return this.terminalRun(options.runId, options.claimToken, "failed", options.errorCode, options.errorMessage, options.now);
|
|
739
932
|
}
|
|
740
933
|
cancelWorkflowRun(options) {
|
|
741
|
-
|
|
934
|
+
const cancelled = this.terminalRun(options.runId, options.claimToken, "cancelled", "cancelled", "Workflow run cancelled", options.now);
|
|
935
|
+
if (cancelled || options.claimToken !== undefined)
|
|
936
|
+
return cancelled;
|
|
937
|
+
return this.cancelStaleWorkflowRun({
|
|
938
|
+
runId: options.runId,
|
|
939
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
/** Cancel a nonterminal run only when its claim is absent or expired. */
|
|
943
|
+
cancelStaleWorkflowRun(options) {
|
|
944
|
+
const now = epoch(validTimestamp(options.now));
|
|
945
|
+
const controlId = options.controlId ?? "workflow-control";
|
|
946
|
+
const claimToken = options.claimToken ?? randomUUID();
|
|
947
|
+
return this.state.transaction(() => {
|
|
948
|
+
const row = this.workflowRunRow(options.runId);
|
|
949
|
+
if (row === undefined || ["done", "failed", "cancelled"].includes(row.status))
|
|
950
|
+
return false;
|
|
951
|
+
const lease = this.requireLease(row.resourceId);
|
|
952
|
+
if (lease.ownerId !== null && lease.expiresAt !== null && lease.expiresAt > now)
|
|
953
|
+
return false;
|
|
954
|
+
const generation = lease.generation + 1;
|
|
955
|
+
const claimed = this.state.connection
|
|
956
|
+
.prepare(`UPDATE leases
|
|
957
|
+
SET generation = ?, owner_type = 'system', owner_id = ?, token_hash = ?,
|
|
958
|
+
acquired_at = ?, heartbeat_at = ?, expires_at = ?
|
|
959
|
+
WHERE resource_id = ? AND generation = ?
|
|
960
|
+
AND (owner_id IS NULL OR expires_at IS NULL OR expires_at <= ?)`)
|
|
961
|
+
.run(generation, controlId, tokenHash(claimToken), now, now, now + 30_000, row.resourceId, lease.generation, now);
|
|
962
|
+
if (claimed.changes !== 1)
|
|
963
|
+
return false;
|
|
964
|
+
const errorHash = this.state.putText("Workflow run cancelled", now);
|
|
965
|
+
const queue = this.state.connection
|
|
966
|
+
.prepare(`UPDATE run_queue
|
|
967
|
+
SET status = 'cancelled', error_code = 'cancelled', error_hash = ?,
|
|
968
|
+
updated_at = ?, finished_at = ?
|
|
969
|
+
WHERE run_id = ? AND status NOT IN ('done', 'failed', 'cancelled')`)
|
|
970
|
+
.run(errorHash, now, now, options.runId);
|
|
971
|
+
/* istanbul ignore if -- the control claim selects one nonterminal queue row */
|
|
972
|
+
if (queue.changes !== 1) {
|
|
973
|
+
throw new Error(`Workflow run ${options.runId} has inconsistent queue state`);
|
|
974
|
+
}
|
|
975
|
+
const run = this.state.connection
|
|
976
|
+
.prepare(`UPDATE runs
|
|
977
|
+
SET status = 'cancelled', paused = 0, status_detail = NULL, error_hash = ?,
|
|
978
|
+
updated_at = ?, finished_at = ?
|
|
979
|
+
WHERE run_id = ? AND status NOT IN ('completed', 'failed', 'timed_out', 'cancelled')`)
|
|
980
|
+
.run(errorHash, now, now, options.runId);
|
|
981
|
+
/* istanbul ignore if -- the control claim selects one nonterminal durable run */
|
|
982
|
+
if (run.changes !== 1) {
|
|
983
|
+
throw new Error(`Workflow run ${options.runId} has inconsistent durable state`);
|
|
984
|
+
}
|
|
985
|
+
this.cancelWorkflowRunDependents(options.runId, controlId, errorHash, now);
|
|
986
|
+
const released = this.state.connection
|
|
987
|
+
.prepare(`UPDATE leases
|
|
988
|
+
SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
|
|
989
|
+
acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
|
|
990
|
+
WHERE resource_id = ? AND owner_type = 'system' AND owner_id = ?
|
|
991
|
+
AND token_hash = ? AND generation = ? AND expires_at > ?`)
|
|
992
|
+
.run(row.resourceId, controlId, tokenHash(claimToken), generation, now);
|
|
993
|
+
/* istanbul ignore if -- the exact control claim was written in this transaction */
|
|
994
|
+
if (released.changes !== 1) {
|
|
995
|
+
throw new Error(`Workflow run ${options.runId} control claim changed during cancellation`);
|
|
996
|
+
}
|
|
997
|
+
const revision = this.resourceRevision(row.resourceId);
|
|
998
|
+
this.bumpResource(row.resourceId, revision, now);
|
|
999
|
+
this.insertEvent(row.resourceId, revision + 1, "run.queue_cancelled", "control", controlId, { status: "cancelled", code: "cancelled", staleControl: true }, now, generation);
|
|
1000
|
+
recordViewerDeltas(this.state, options.runId, [{ targetType: "summary" }, { targetType: "replay" }], now);
|
|
1001
|
+
return true;
|
|
1002
|
+
});
|
|
742
1003
|
}
|
|
743
1004
|
deleteWorkflowRun(options) {
|
|
744
1005
|
const now = Date.now();
|
|
@@ -769,26 +1030,28 @@ export class SqliteControllerStore {
|
|
|
769
1030
|
throw new Error("Legacy workflow source claims are not available after the SQLite cutover");
|
|
770
1031
|
}
|
|
771
1032
|
recordRunEvent(options) {
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
1033
|
+
return this.state.transaction(() => {
|
|
1034
|
+
const row = this.requireWorkflowRunRow(options.runId);
|
|
1035
|
+
const now = epoch(validTimestamp(options.now));
|
|
1036
|
+
const revision = this.resourceRevision(row.resourceId) + 1;
|
|
1037
|
+
this.bumpResource(row.resourceId, revision - 1, now);
|
|
1038
|
+
const eventId = this.insertEvent(row.resourceId, revision, options.type, options.runnerId?.startsWith("host-") ? "host" : "session", options.runnerId ?? null, options.payload ?? {}, now, row.leaseGeneration || undefined);
|
|
1039
|
+
const seq = this.state.connection
|
|
1040
|
+
.prepare("SELECT event_seq AS seq FROM events WHERE event_id = ?")
|
|
1041
|
+
.get(eventId);
|
|
1042
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
1043
|
+
if (!isSequenceRow(seq))
|
|
1044
|
+
throw new Error("Run event was not recorded");
|
|
1045
|
+
return {
|
|
1046
|
+
seq: seq.seq,
|
|
1047
|
+
recordedAt: new Date(now).toISOString(),
|
|
1048
|
+
runId: options.runId,
|
|
1049
|
+
workflowRef: options.workflowRef,
|
|
1050
|
+
type: options.type,
|
|
1051
|
+
runnerId: options.runnerId ?? null,
|
|
1052
|
+
payload: options.payload ?? {},
|
|
1053
|
+
};
|
|
1054
|
+
});
|
|
792
1055
|
}
|
|
793
1056
|
listRunEventsAfter(seq, options = {}) {
|
|
794
1057
|
const rows = this.state.connection
|
|
@@ -946,6 +1209,13 @@ export class SqliteControllerStore {
|
|
|
946
1209
|
});
|
|
947
1210
|
return this.requireNotification(options.runId, options.attemptId, options.notificationIndex);
|
|
948
1211
|
}
|
|
1212
|
+
listPendingWorkflowNotifications(options) {
|
|
1213
|
+
const now = Date.now();
|
|
1214
|
+
return this.notificationRows(options.targetSessionId, options.limit ?? 20)
|
|
1215
|
+
.map((row) => this.mapNotification(row))
|
|
1216
|
+
.filter((notification) => notification.deliveryClaimExpiresAt === null ||
|
|
1217
|
+
Date.parse(notification.deliveryClaimExpiresAt) <= now);
|
|
1218
|
+
}
|
|
949
1219
|
claimPendingWorkflowNotifications(options) {
|
|
950
1220
|
const rows = this.notificationRows(options.targetSessionId, options.limit ?? 20);
|
|
951
1221
|
const result = [];
|
|
@@ -1200,6 +1470,19 @@ export class SqliteControllerStore {
|
|
|
1200
1470
|
.run(now, resourceId, expectedRevision);
|
|
1201
1471
|
if (result.changes !== 1)
|
|
1202
1472
|
throw new Error("Resource revision conflict");
|
|
1473
|
+
const run = this.state.connection
|
|
1474
|
+
.prepare(`SELECT r.run_id AS runId
|
|
1475
|
+
FROM runs r JOIN viewer_runs v ON v.run_id = r.run_id
|
|
1476
|
+
WHERE r.resource_id = ?`)
|
|
1477
|
+
.get(resourceId);
|
|
1478
|
+
if (isRecord(run) && typeof run.runId === "string") {
|
|
1479
|
+
recordViewerDeltas(this.state, run.runId, [
|
|
1480
|
+
{ targetType: "summary" },
|
|
1481
|
+
{ targetType: "graph" },
|
|
1482
|
+
{ targetType: "replay" },
|
|
1483
|
+
{ targetType: "inspector", targetKey: "run" },
|
|
1484
|
+
], now);
|
|
1485
|
+
}
|
|
1203
1486
|
}
|
|
1204
1487
|
insertEvent(resourceId, revision, type, actorType, actorId, payload, now, leaseGeneration) {
|
|
1205
1488
|
const eventId = `event-${randomUUID()}`;
|
|
@@ -1308,6 +1591,7 @@ export class SqliteControllerStore {
|
|
|
1308
1591
|
canonicalJson(this.state.readJson(row.launchOptionsHash)) ===
|
|
1309
1592
|
canonicalJson(options.launchOptions ?? {}) &&
|
|
1310
1593
|
row.originSessionId === options.originSessionId &&
|
|
1594
|
+
row.executionMode === (options.executionMode ?? "interactive") &&
|
|
1311
1595
|
row.parentRunId === (options.parentRunId ?? null);
|
|
1312
1596
|
if (!compatible) {
|
|
1313
1597
|
throw new Error(`Workflow run preparation conflicts: ${options.runId}`);
|
|
@@ -1330,7 +1614,8 @@ export class SqliteControllerStore {
|
|
|
1330
1614
|
claimGeneration: row.ownerId === null ? null : row.leaseGeneration,
|
|
1331
1615
|
claimExpiresAt: row.claimExpiresAt === null ? null : new Date(row.claimExpiresAt).toISOString(),
|
|
1332
1616
|
affinityRunnerId: row.affinityRunnerId,
|
|
1333
|
-
originSessionId: row.originSessionId,
|
|
1617
|
+
originSessionId: row.executionMode === "headless" ? null : row.originSessionId,
|
|
1618
|
+
executionMode: row.executionMode,
|
|
1334
1619
|
parentRunId: row.parentRunId,
|
|
1335
1620
|
errorCode: row.errorCode,
|
|
1336
1621
|
errorMessage: row.errorHash === null
|
|
@@ -1342,13 +1627,20 @@ export class SqliteControllerStore {
|
|
|
1342
1627
|
finishedAt: row.finishedAt === null ? null : new Date(row.finishedAt).toISOString(),
|
|
1343
1628
|
};
|
|
1344
1629
|
}
|
|
1345
|
-
claimRun(runId, runnerId, claimToken, leaseMs, now) {
|
|
1346
|
-
return this.state.transaction(() => this.claimRunInTransaction(runId, runnerId, claimToken, leaseMs, now));
|
|
1630
|
+
claimRun(runId, runnerId, claimToken, leaseMs, now, options = {}) {
|
|
1631
|
+
return this.state.transaction(() => this.claimRunInTransaction(runId, runnerId, claimToken, leaseMs, now, options));
|
|
1347
1632
|
}
|
|
1348
|
-
claimRunInTransaction(runId, runnerId, claimToken, leaseMs, now) {
|
|
1633
|
+
claimRunInTransaction(runId, runnerId, claimToken, leaseMs, now, options = {}) {
|
|
1349
1634
|
const row = this.workflowRunRow(runId);
|
|
1350
1635
|
if (row === undefined || ["done", "failed", "cancelled"].includes(row.status))
|
|
1351
1636
|
return undefined;
|
|
1637
|
+
if (options.allowPendingInteraction !== true &&
|
|
1638
|
+
this.state.connection
|
|
1639
|
+
.prepare(`SELECT 1 FROM interactive_requests
|
|
1640
|
+
WHERE run_id = ? AND status IN ('pending', 'presenting') LIMIT 1`)
|
|
1641
|
+
.get(runId) !== undefined) {
|
|
1642
|
+
return undefined;
|
|
1643
|
+
}
|
|
1352
1644
|
const lease = this.requireLease(row.resourceId);
|
|
1353
1645
|
if (lease.ownerId !== null &&
|
|
1354
1646
|
lease.expiresAt !== null &&
|
|
@@ -1365,9 +1657,13 @@ export class SqliteControllerStore {
|
|
|
1365
1657
|
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
1366
1658
|
if (result.changes !== 1)
|
|
1367
1659
|
return undefined;
|
|
1368
|
-
|
|
1369
|
-
.
|
|
1370
|
-
|
|
1660
|
+
if (options.preserveQueueStatus !== true) {
|
|
1661
|
+
this.state.connection
|
|
1662
|
+
.prepare(`UPDATE run_queue
|
|
1663
|
+
SET status = 'starting', error_code = NULL, error_hash = NULL, updated_at = ?
|
|
1664
|
+
WHERE run_id = ?`)
|
|
1665
|
+
.run(now, runId);
|
|
1666
|
+
}
|
|
1371
1667
|
const revision = this.resourceRevision(row.resourceId);
|
|
1372
1668
|
this.bumpResource(row.resourceId, revision, now);
|
|
1373
1669
|
this.insertEvent(row.resourceId, revision + 1, "lease.claimed", runnerId.startsWith("host-") ? "host" : "session", runnerId, { expiresAt }, now, generation);
|
|
@@ -1419,6 +1715,7 @@ export class SqliteControllerStore {
|
|
|
1419
1715
|
const revision = this.resourceRevision(row.resourceId);
|
|
1420
1716
|
this.bumpResource(row.resourceId, revision, now);
|
|
1421
1717
|
this.insertEvent(row.resourceId, revision + 1, `run.queue_${status}`, lease.ownerType ?? "system", lease.ownerId, { status }, now, lease.generation || undefined);
|
|
1718
|
+
recordViewerDeltas(this.state, runId, [{ targetType: "summary" }, { targetType: "replay" }], now);
|
|
1422
1719
|
return true;
|
|
1423
1720
|
});
|
|
1424
1721
|
}
|
|
@@ -1469,11 +1766,76 @@ export class SqliteControllerStore {
|
|
|
1469
1766
|
updated_at = ?, finished_at = ?
|
|
1470
1767
|
WHERE run_id = ?`)
|
|
1471
1768
|
.run(status, errorHash, now, now, runId);
|
|
1769
|
+
if (status === "cancelled") {
|
|
1770
|
+
this.cancelWorkflowRunDependents(runId, lease.ownerId ?? "workflow-control", errorHash, now);
|
|
1771
|
+
}
|
|
1472
1772
|
this.bumpResource(row.resourceId, revision, now);
|
|
1473
1773
|
this.insertEvent(row.resourceId, revision + 1, `run.queue_${status}`, lease.ownerType ?? "system", lease.ownerId, { status, code, error }, now, lease.generation || undefined);
|
|
1774
|
+
recordViewerDeltas(this.state, runId, [{ targetType: "summary" }, { targetType: "replay" }], now);
|
|
1474
1775
|
return true;
|
|
1475
1776
|
});
|
|
1476
1777
|
}
|
|
1778
|
+
cancelWorkflowRunDependents(runId, actorId, errorHash, now) {
|
|
1779
|
+
this.state.connection
|
|
1780
|
+
.prepare(`UPDATE node_attempts
|
|
1781
|
+
SET status = 'cancelled', error_hash = COALESCE(error_hash, ?),
|
|
1782
|
+
updated_at = ?, finished_at = COALESCE(finished_at, ?)
|
|
1783
|
+
WHERE run_id = ? AND status IN ('pending', 'running', 'waiting', 'interrupted')`)
|
|
1784
|
+
.run(errorHash, now, now, runId);
|
|
1785
|
+
const effects = this.state.connection
|
|
1786
|
+
.prepare(`SELECT e.effect_id AS effectId, e.resource_id AS resourceId,
|
|
1787
|
+
e.status, e.attempt_count AS attemptCount
|
|
1788
|
+
FROM effects e JOIN runs r ON r.resource_id = e.source_resource_id
|
|
1789
|
+
WHERE r.run_id = ? AND e.owner_scope = 'run' AND e.status IN ('pending', 'applying')
|
|
1790
|
+
ORDER BY e.effect_id`)
|
|
1791
|
+
.all(runId)
|
|
1792
|
+
.filter(isCancelledRunEffectRow);
|
|
1793
|
+
for (const effect of effects) {
|
|
1794
|
+
const status = effect.status === "applying" ? "ambiguous" : "cancelled";
|
|
1795
|
+
const revision = this.resourceRevision(effect.resourceId);
|
|
1796
|
+
const changed = this.state.connection
|
|
1797
|
+
.prepare(`UPDATE effects
|
|
1798
|
+
SET status = ?, next_attempt_at = NULL, error_hash = ?, updated_at = ?, settled_at = ?
|
|
1799
|
+
WHERE effect_id = ? AND status = ? AND attempt_count = ?`)
|
|
1800
|
+
.run(status, errorHash, now, now, effect.effectId, effect.status, effect.attemptCount);
|
|
1801
|
+
/* istanbul ignore if -- cancellation serializes the selected effect transition */
|
|
1802
|
+
if (changed.changes !== 1) {
|
|
1803
|
+
throw new Error(`Workflow effect ${effect.effectId} changed during cancellation`);
|
|
1804
|
+
}
|
|
1805
|
+
if (effect.status === "applying") {
|
|
1806
|
+
this.state.connection
|
|
1807
|
+
.prepare(`UPDATE effect_attempts
|
|
1808
|
+
SET finished_at = ?, outcome = 'interrupted', error_hash = ?
|
|
1809
|
+
WHERE effect_id = ? AND attempt_number = ? AND finished_at IS NULL`)
|
|
1810
|
+
.run(now, errorHash, effect.effectId, effect.attemptCount);
|
|
1811
|
+
}
|
|
1812
|
+
this.bumpResource(effect.resourceId, revision, now);
|
|
1813
|
+
this.insertEvent(effect.resourceId, revision + 1, `effect.${status}`, "control", actorId, { runId, reason: "workflowCancelled" }, now);
|
|
1814
|
+
}
|
|
1815
|
+
this.state.connection
|
|
1816
|
+
.prepare(`INSERT INTO human_decision_resolutions(
|
|
1817
|
+
decision_id, outcome, provenance, response_hash, reason, channel,
|
|
1818
|
+
actor_id, request_digest, resolved_at
|
|
1819
|
+
)
|
|
1820
|
+
SELECT d.decision_id, 'cancelled', 'explicit_cancel', NULL,
|
|
1821
|
+
'Workflow run cancelled', NULL, ?, d.request_digest, ?
|
|
1822
|
+
FROM human_decisions d
|
|
1823
|
+
LEFT JOIN human_decision_resolutions r ON r.decision_id = d.decision_id
|
|
1824
|
+
WHERE d.run_id = ? AND r.decision_id IS NULL`)
|
|
1825
|
+
.run(actorId, now, runId);
|
|
1826
|
+
const receiptHash = this.state.putJson({ status: "rejected", error: "Workflow run cancelled" }, now);
|
|
1827
|
+
this.state.connection
|
|
1828
|
+
.prepare(`UPDATE interactive_submissions SET outcome = 'rejected', receipt_hash = ?
|
|
1829
|
+
WHERE outcome = 'validating'
|
|
1830
|
+
AND request_id IN (SELECT request_id FROM interactive_requests WHERE run_id = ?)`)
|
|
1831
|
+
.run(receiptHash, runId);
|
|
1832
|
+
this.state.connection
|
|
1833
|
+
.prepare(`UPDATE interactive_requests
|
|
1834
|
+
SET status = 'cancelled', presenter_id = NULL,
|
|
1835
|
+
presentation_claim_expires_at = NULL, revision = revision + 1, updated_at = ?
|
|
1836
|
+
WHERE run_id = ? AND status IN ('pending', 'presenting')`)
|
|
1837
|
+
.run(now, runId);
|
|
1838
|
+
}
|
|
1477
1839
|
turnIntent(intentId) {
|
|
1478
1840
|
const row = this.turnIntentRow(intentId);
|
|
1479
1841
|
return row === undefined ? undefined : this.mapTurnIntent(row);
|
|
@@ -1668,7 +2030,8 @@ function workflowRunSelect(clause) {
|
|
|
1668
2030
|
q.status, q.available_at AS availableAt, q.affinity_runner_id AS affinityRunnerId,
|
|
1669
2031
|
q.consecutive_errors AS consecutiveErrors, q.error_code AS errorCode,
|
|
1670
2032
|
q.error_hash AS errorHash, b.origin_session_id AS originSessionId,
|
|
1671
|
-
|
|
2033
|
+
b.execution_mode AS executionMode, r.parent_run_id AS parentRunId,
|
|
2034
|
+
q.created_at AS createdAt, q.updated_at AS updatedAt,
|
|
1672
2035
|
q.started_at AS startedAt, q.finished_at AS finishedAt,
|
|
1673
2036
|
l.generation AS leaseGeneration, l.owner_id AS ownerId, l.expires_at AS claimExpiresAt
|
|
1674
2037
|
FROM runs r JOIN workflow_definitions d ON d.definition_digest = r.definition_digest
|
|
@@ -1717,6 +2080,16 @@ function isRunSourceIdentityRow(value) {
|
|
|
1717
2080
|
function isEffectRow(value) {
|
|
1718
2081
|
return isRecord(value);
|
|
1719
2082
|
}
|
|
2083
|
+
function isCancelledRunEffectRow(value) {
|
|
2084
|
+
return (isRecord(value) &&
|
|
2085
|
+
typeof value.effectId === "string" &&
|
|
2086
|
+
typeof value.resourceId === "string" &&
|
|
2087
|
+
(value.status === "pending" || value.status === "applying") &&
|
|
2088
|
+
typeof value.attemptCount === "number");
|
|
2089
|
+
}
|
|
2090
|
+
function isExpiredInteractionRow(value) {
|
|
2091
|
+
return (isRecord(value) && typeof value.requestId === "string" && typeof value.attemptId === "string");
|
|
2092
|
+
}
|
|
1720
2093
|
function isWorkflowRow(value) {
|
|
1721
2094
|
return isRecord(value);
|
|
1722
2095
|
}
|
|
@@ -1738,6 +2111,9 @@ function isNotificationRow(value) {
|
|
|
1738
2111
|
function isProjectRow(value) {
|
|
1739
2112
|
return isRecord(value);
|
|
1740
2113
|
}
|
|
2114
|
+
function isCanonicalPathRow(value) {
|
|
2115
|
+
return isRecord(value) && typeof value.canonicalPath === "string";
|
|
2116
|
+
}
|
|
1741
2117
|
function isFinalizerRow(value) {
|
|
1742
2118
|
return isRecord(value);
|
|
1743
2119
|
}
|