@osolmaz/pi-workflows 0.13.4 → 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 +146 -160
- 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 +1 -2
- package/dist/controllers/index.js +0 -1
- package/dist/controllers/index.js.map +1 -1
- package/dist/controllers/sqlite.d.ts +85 -31
- package/dist/controllers/sqlite.js +541 -126
- 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 -2928
- 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/prune.js +36 -10
- package/dist/state/prune.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 -22
- package/dist/workflows/tool-input.js +1 -44
- package/dist/workflows/tool-input.js.map +1 -1
- package/dist/workflows/types.d.ts +36 -4
- package/docs/2026-08-25-workflow-follow-ups.md +8 -6
- 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 +61 -27
- package/docs/HUMAN_DECISIONS.md +12 -4
- package/docs/SQLITE_STATE.md +42 -8
- 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-19-human-decision-gates-plan.md +34 -8
- package/docs/plans/2026-08-27-workflow-terminal-restart-plan.md +399 -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 +164 -156
- 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/skills/autodoc/SKILL.md +1 -1
- package/skills/autoimplement/SKILL.md +1 -1
- package/skills/autoplan/SKILL.md +1 -1
- package/skills/pi-workflows/SKILL.md +2 -0
- 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 +3 -5
- package/src/controllers/sqlite.ts +929 -225
- package/src/controllers/store.ts +1 -0
- package/src/extension/controller-command.ts +45 -0
- package/src/extension/index.ts +948 -3506
- 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/prune.ts +35 -9
- 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 +2 -60
- 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/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/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/session-events.ts +0 -121
|
@@ -2,7 +2,9 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { StateDatabase, workflowStatePath } from "../state/database.js";
|
|
5
|
+
import { canonicalJson } from "../state/json.js";
|
|
5
6
|
import { resourceIdFor, tokenHash } from "../state/mutation.js";
|
|
7
|
+
import { initializeViewerRun, recordViewerDeltas } from "../state/viewer.js";
|
|
6
8
|
import { EffectRequestConflictError, ResourceConflictError, ResourceNotFoundError, WorkflowRequestConflictError, } from "./errors.js";
|
|
7
9
|
const TURN_INTENT_FACTS_SCHEMA = "pi-workflows.deferred-turn-facts.v1";
|
|
8
10
|
export class SqliteControllerStore {
|
|
@@ -21,9 +23,14 @@ export class SqliteControllerStore {
|
|
|
21
23
|
checkLegacyState: filePath === workflowStatePath(),
|
|
22
24
|
});
|
|
23
25
|
this.filePath = this.state.filePath;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
+
}
|
|
27
34
|
}
|
|
28
35
|
close() {
|
|
29
36
|
if (this.closed)
|
|
@@ -188,6 +195,11 @@ export class SqliteControllerStore {
|
|
|
188
195
|
return undefined;
|
|
189
196
|
const now = epoch(validTimestamp(options.now));
|
|
190
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]);
|
|
191
203
|
return this.state.transaction(() => {
|
|
192
204
|
const row = this.state.connection
|
|
193
205
|
.prepare(`${controllerSelect(`
|
|
@@ -195,11 +207,12 @@ export class SqliteControllerStore {
|
|
|
195
207
|
JOIN leases l ON l.resource_id = c.resource_id
|
|
196
208
|
WHERE c.project_id = ?
|
|
197
209
|
AND c.controller_name IN (${placeholders})
|
|
210
|
+
${exclusionSql}
|
|
198
211
|
AND q.available_at <= ?
|
|
199
212
|
AND (l.owner_id IS NULL OR l.expires_at <= ?)
|
|
200
213
|
ORDER BY q.available_at, c.controller_name, c.resource_key
|
|
201
214
|
LIMIT 1`)}`)
|
|
202
|
-
.get(this.requireProjectId(), ...options.controllers, now, now);
|
|
215
|
+
.get(this.requireProjectId(), ...options.controllers, ...exclusionParams, now, now);
|
|
203
216
|
if (!isControllerRow(row))
|
|
204
217
|
return undefined;
|
|
205
218
|
const lease = this.requireLease(row.resourceId);
|
|
@@ -501,74 +514,97 @@ export class SqliteControllerStore {
|
|
|
501
514
|
validateRunId(options.runId);
|
|
502
515
|
const now = epoch(validTimestamp(options.now));
|
|
503
516
|
const definitionDigest = digestBuffer(options.definitionDigest);
|
|
504
|
-
return this.state.transaction(() =>
|
|
505
|
-
|
|
506
|
-
|
|
517
|
+
return this.state.transaction(() => this.reserveWorkflowRunInTransaction(options, now, definitionDigest));
|
|
518
|
+
}
|
|
519
|
+
reserveWorkflowRunInTransaction(options, now, definitionDigest) {
|
|
520
|
+
if (this.getWorkflowRun(options.runId) !== undefined) {
|
|
521
|
+
throw new Error(`Workflow run already reserved: ${options.runId}`);
|
|
522
|
+
}
|
|
523
|
+
if (options.parentRunId !== undefined) {
|
|
524
|
+
const parent = this.requireWorkflowRunRow(options.parentRunId);
|
|
525
|
+
if (parent.originSessionId !== options.originSessionId) {
|
|
526
|
+
throw new Error("Continuation parent belongs to another Pi session");
|
|
507
527
|
}
|
|
508
|
-
if (
|
|
509
|
-
const
|
|
510
|
-
if (
|
|
511
|
-
throw new Error("Continuation parent
|
|
528
|
+
if (parent.status === "parked") {
|
|
529
|
+
const parentLease = this.requireLease(parent.resourceId);
|
|
530
|
+
if (parentLease.ownerId !== null) {
|
|
531
|
+
throw new Error("Continuation parent still has an active owner");
|
|
512
532
|
}
|
|
513
|
-
|
|
514
|
-
|
|
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
|
|
533
|
+
this.state.connection
|
|
534
|
+
.prepare(`UPDATE run_queue
|
|
520
535
|
SET status = 'done', updated_at = ?, finished_at = ?
|
|
521
536
|
WHERE run_id = ? AND status = 'parked'`)
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
}
|
|
537
|
+
.run(now, now, parent.runId);
|
|
538
|
+
const parentRevision = this.resourceRevision(parent.resourceId);
|
|
539
|
+
this.bumpResource(parent.resourceId, parentRevision, now);
|
|
540
|
+
this.insertEvent(parent.resourceId, parentRevision + 1, "run.queue_done_for_continuation", "session", options.originSessionId, { continuationRunId: options.runId }, now);
|
|
533
541
|
}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
542
|
+
else if (parent.status === "done") {
|
|
543
|
+
throw new Error("Continuation parent already has a reserved continuation");
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
throw new Error(`Continuation parent queue is ${parent.status}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const resourceId = resourceIdFor("run", options.runId);
|
|
550
|
+
const definitionHash = this.state.putJson(options.definitionSnapshot, now);
|
|
551
|
+
const queuedSource = queuedWorkflowSource(options.workflowSource);
|
|
552
|
+
const inputHash = this.state.putJson(options.input ?? null, now);
|
|
553
|
+
const launchHash = this.state.putJson(options.launchOptions ?? {}, now);
|
|
554
|
+
this.state.connection
|
|
555
|
+
.prepare(`INSERT INTO workflow_definitions(
|
|
541
556
|
definition_digest, workflow_name, definition_hash, created_at
|
|
542
557
|
) VALUES (?, ?, ?, ?)
|
|
543
558
|
ON CONFLICT(definition_digest) DO NOTHING`)
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
559
|
+
.run(definitionDigest, options.workflowName, definitionHash, now);
|
|
560
|
+
this.state.connection
|
|
561
|
+
.prepare(`INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at)
|
|
547
562
|
VALUES (?, 'run', ?, 1, ?, ?)`)
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
563
|
+
.run(resourceId, options.runId, now, now);
|
|
564
|
+
this.state.connection
|
|
565
|
+
.prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
|
|
566
|
+
.run(resourceId);
|
|
567
|
+
this.state.connection
|
|
568
|
+
.prepare(`INSERT INTO runs(
|
|
554
569
|
run_id, resource_id, project_id, parent_run_id, definition_digest,
|
|
555
570
|
workflow_ref, launch_options_hash, status, paused,
|
|
556
571
|
input_hash, created_at, updated_at
|
|
557
572
|
) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`)
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
573
|
+
.run(options.runId, resourceId, this.requireProjectId(), options.parentRunId ?? null, definitionDigest, options.workflowSourceRef, launchHash, inputHash, now, now);
|
|
574
|
+
initializeViewerRun(this.state, options.runId, now);
|
|
575
|
+
insertQueuedRunSources(this.state, options.runId, queuedSource);
|
|
576
|
+
this.state.connection
|
|
577
|
+
.prepare(`INSERT INTO run_bindings(run_id, origin_session_id, execution_mode, created_at)
|
|
578
|
+
VALUES (?, ?, ?, ?)`)
|
|
579
|
+
.run(options.runId, options.originSessionId, options.executionMode ?? "interactive", now);
|
|
580
|
+
this.state.connection
|
|
581
|
+
.prepare(`INSERT INTO run_queue(
|
|
566
582
|
run_id, status, available_at, affinity_runner_id, origin_session_id,
|
|
567
583
|
consecutive_errors, created_at, updated_at
|
|
568
584
|
) VALUES (?, 'queued', ?, ?, ?, 0, ?, ?)`)
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
585
|
+
.run(options.runId, now, options.runnerId, options.originSessionId, now, now);
|
|
586
|
+
this.insertEvent(resourceId, 1, "run.queued", "session", options.originSessionId, {}, now);
|
|
587
|
+
return this.requireWorkflowRun(options.runId);
|
|
588
|
+
}
|
|
589
|
+
prepareOrAdoptWorkflowRun(options) {
|
|
590
|
+
validateRunId(options.runId);
|
|
591
|
+
const now = epoch(validTimestamp(options.now));
|
|
592
|
+
const definitionDigest = digestBuffer(options.definitionDigest);
|
|
593
|
+
return this.state.transaction(() => {
|
|
594
|
+
const existing = this.workflowRunRow(options.runId);
|
|
595
|
+
if (existing !== undefined) {
|
|
596
|
+
this.assertWorkflowRunPreparationCompatible(existing, options, definitionDigest);
|
|
597
|
+
return { state: "adopted", run: this.mapWorkflowRun(existing) };
|
|
598
|
+
}
|
|
599
|
+
this.reserveWorkflowRunInTransaction(options, now, definitionDigest);
|
|
600
|
+
const claimed = this.claimRunInTransaction(options.runId, options.runnerId, options.claimToken, options.leaseMs, now);
|
|
601
|
+
if (claimed === undefined) {
|
|
602
|
+
throw new Error(`Workflow run could not be claimed: ${options.runId}`);
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
state: "claimed",
|
|
606
|
+
run: claimed,
|
|
607
|
+
};
|
|
572
608
|
});
|
|
573
609
|
}
|
|
574
610
|
enqueueWorkflowRun(options) {
|
|
@@ -604,9 +640,20 @@ export class SqliteControllerStore {
|
|
|
604
640
|
const row = this.workflowRunRow(runId);
|
|
605
641
|
return row === undefined ? undefined : this.mapWorkflowRun(row);
|
|
606
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
|
+
}
|
|
607
650
|
listWorkflowRuns(options = {}) {
|
|
608
|
-
const clauses = [
|
|
609
|
-
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
|
+
}
|
|
610
657
|
if (options.statuses !== undefined && options.statuses.length > 0) {
|
|
611
658
|
clauses.push(`q.status IN (${options.statuses.map(() => "?").join(", ")})`);
|
|
612
659
|
params.push(...options.statuses);
|
|
@@ -617,7 +664,7 @@ export class SqliteControllerStore {
|
|
|
617
664
|
}
|
|
618
665
|
params.push(options.limit ?? 100);
|
|
619
666
|
const rows = this.state.connection
|
|
620
|
-
.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 ?`))
|
|
621
668
|
.all(...params);
|
|
622
669
|
return rows.filter(isRunRow).map((row) => this.mapWorkflowRun(row));
|
|
623
670
|
}
|
|
@@ -631,25 +678,93 @@ export class SqliteControllerStore {
|
|
|
631
678
|
const now = epoch(validTimestamp(options.now));
|
|
632
679
|
return this.claimRun(options.runId, options.runnerId, options.claimToken, options.leaseMs, now);
|
|
633
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
|
+
}
|
|
634
749
|
markWorkflowRunRunning(options) {
|
|
635
750
|
return this.updateClaimedRunStatus(options.runId, options.claimToken, "running", options.now);
|
|
636
751
|
}
|
|
637
752
|
claimNextWorkflowRun(options) {
|
|
638
753
|
const now = epoch(validTimestamp(options.now));
|
|
639
754
|
const clauses = [
|
|
640
|
-
"r.project_id = ?",
|
|
641
755
|
"q.status IN ('queued', 'parked', 'starting', 'running')",
|
|
642
756
|
"q.available_at <= ?",
|
|
643
757
|
"(l.owner_id IS NULL OR l.expires_at <= ? OR l.owner_id = ?)",
|
|
644
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')",
|
|
645
762
|
];
|
|
646
|
-
const params = [
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
options.runnerId,
|
|
652
|
-
];
|
|
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
|
+
}
|
|
653
768
|
if (options.sessionId !== undefined) {
|
|
654
769
|
clauses.push("b.origin_session_id = ?");
|
|
655
770
|
params.push(options.sessionId);
|
|
@@ -670,14 +785,22 @@ export class SqliteControllerStore {
|
|
|
670
785
|
}
|
|
671
786
|
renewWorkflowRunClaim(options) {
|
|
672
787
|
const now = epoch(validTimestamp(options.now));
|
|
673
|
-
|
|
674
|
-
|
|
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
|
+
}
|
|
675
802
|
return false;
|
|
676
|
-
|
|
677
|
-
.prepare(`UPDATE leases SET heartbeat_at = ?, expires_at = ?
|
|
678
|
-
WHERE resource_id = ? AND token_hash = ? AND owner_id = ? AND expires_at > ?`)
|
|
679
|
-
.run(now, now + options.leaseMs, row.resourceId, tokenHash(options.claimToken), row.ownerId, now);
|
|
680
|
-
return result.changes === 1;
|
|
803
|
+
});
|
|
681
804
|
}
|
|
682
805
|
verifyWorkflowRunClaim(options) {
|
|
683
806
|
const now = epoch(validTimestamp(options.now));
|
|
@@ -691,10 +814,7 @@ export class SqliteControllerStore {
|
|
|
691
814
|
}
|
|
692
815
|
workflowRunAuthority(runId, claimToken) {
|
|
693
816
|
const row = this.workflowRunRow(runId);
|
|
694
|
-
if (row === undefined ||
|
|
695
|
-
row.ownerId === null ||
|
|
696
|
-
row.claimExpiresAt === null ||
|
|
697
|
-
row.claimExpiresAt <= Date.now())
|
|
817
|
+
if (row === undefined || row.ownerId === null || row.claimExpiresAt === null)
|
|
698
818
|
return undefined;
|
|
699
819
|
const lease = this.requireLease(row.resourceId);
|
|
700
820
|
if (lease.tokenHash === null || !lease.tokenHash.equals(tokenHash(claimToken)))
|
|
@@ -706,16 +826,180 @@ export class SqliteControllerStore {
|
|
|
706
826
|
ownerId: row.ownerId,
|
|
707
827
|
token: claimToken,
|
|
708
828
|
generation: row.leaseGeneration,
|
|
829
|
+
leaseMs: 30_000,
|
|
709
830
|
};
|
|
710
831
|
}
|
|
711
832
|
parkWorkflowRun(options) {
|
|
712
833
|
return this.releaseRunClaim(options.runId, options.claimToken, "parked", options.now);
|
|
713
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
|
+
}
|
|
714
930
|
failWorkflowRun(options) {
|
|
715
931
|
return this.terminalRun(options.runId, options.claimToken, "failed", options.errorCode, options.errorMessage, options.now);
|
|
716
932
|
}
|
|
717
933
|
cancelWorkflowRun(options) {
|
|
718
|
-
|
|
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
|
+
});
|
|
719
1003
|
}
|
|
720
1004
|
deleteWorkflowRun(options) {
|
|
721
1005
|
const now = Date.now();
|
|
@@ -746,26 +1030,28 @@ export class SqliteControllerStore {
|
|
|
746
1030
|
throw new Error("Legacy workflow source claims are not available after the SQLite cutover");
|
|
747
1031
|
}
|
|
748
1032
|
recordRunEvent(options) {
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
+
});
|
|
769
1055
|
}
|
|
770
1056
|
listRunEventsAfter(seq, options = {}) {
|
|
771
1057
|
const rows = this.state.connection
|
|
@@ -923,6 +1209,13 @@ export class SqliteControllerStore {
|
|
|
923
1209
|
});
|
|
924
1210
|
return this.requireNotification(options.runId, options.attemptId, options.notificationIndex);
|
|
925
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
|
+
}
|
|
926
1219
|
claimPendingWorkflowNotifications(options) {
|
|
927
1220
|
const rows = this.notificationRows(options.targetSessionId, options.limit ?? 20);
|
|
928
1221
|
const result = [];
|
|
@@ -1177,6 +1470,19 @@ export class SqliteControllerStore {
|
|
|
1177
1470
|
.run(now, resourceId, expectedRevision);
|
|
1178
1471
|
if (result.changes !== 1)
|
|
1179
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
|
+
}
|
|
1180
1486
|
}
|
|
1181
1487
|
insertEvent(resourceId, revision, type, actorType, actorId, payload, now, leaseGeneration) {
|
|
1182
1488
|
const eventId = `event-${randomUUID()}`;
|
|
@@ -1275,6 +1581,22 @@ export class SqliteControllerStore {
|
|
|
1275
1581
|
requireWorkflowRun(runId) {
|
|
1276
1582
|
return this.mapWorkflowRun(this.requireWorkflowRunRow(runId));
|
|
1277
1583
|
}
|
|
1584
|
+
assertWorkflowRunPreparationCompatible(row, options, definitionDigest) {
|
|
1585
|
+
const compatible = row.workflowName === options.workflowName &&
|
|
1586
|
+
row.workflowRef === options.workflowSourceRef &&
|
|
1587
|
+
row.definitionDigest.equals(definitionDigest) &&
|
|
1588
|
+
canonicalJson(readQueuedRunSources(this.state, row)) ===
|
|
1589
|
+
canonicalJson(queuedWorkflowSource(options.workflowSource)) &&
|
|
1590
|
+
canonicalJson(this.state.readJson(row.inputHash)) === canonicalJson(options.input ?? null) &&
|
|
1591
|
+
canonicalJson(this.state.readJson(row.launchOptionsHash)) ===
|
|
1592
|
+
canonicalJson(options.launchOptions ?? {}) &&
|
|
1593
|
+
row.originSessionId === options.originSessionId &&
|
|
1594
|
+
row.executionMode === (options.executionMode ?? "interactive") &&
|
|
1595
|
+
row.parentRunId === (options.parentRunId ?? null);
|
|
1596
|
+
if (!compatible) {
|
|
1597
|
+
throw new Error(`Workflow run preparation conflicts: ${options.runId}`);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1278
1600
|
/* istanbul ignore next -- pure projection covered by integration tests */
|
|
1279
1601
|
mapWorkflowRun(row) {
|
|
1280
1602
|
return {
|
|
@@ -1292,7 +1614,8 @@ export class SqliteControllerStore {
|
|
|
1292
1614
|
claimGeneration: row.ownerId === null ? null : row.leaseGeneration,
|
|
1293
1615
|
claimExpiresAt: row.claimExpiresAt === null ? null : new Date(row.claimExpiresAt).toISOString(),
|
|
1294
1616
|
affinityRunnerId: row.affinityRunnerId,
|
|
1295
|
-
originSessionId: row.originSessionId,
|
|
1617
|
+
originSessionId: row.executionMode === "headless" ? null : row.originSessionId,
|
|
1618
|
+
executionMode: row.executionMode,
|
|
1296
1619
|
parentRunId: row.parentRunId,
|
|
1297
1620
|
errorCode: row.errorCode,
|
|
1298
1621
|
errorMessage: row.errorHash === null
|
|
@@ -1304,35 +1627,47 @@ export class SqliteControllerStore {
|
|
|
1304
1627
|
finishedAt: row.finishedAt === null ? null : new Date(row.finishedAt).toISOString(),
|
|
1305
1628
|
};
|
|
1306
1629
|
}
|
|
1307
|
-
claimRun(runId, runnerId, claimToken, leaseMs, now) {
|
|
1308
|
-
return this.state.transaction(() =>
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1630
|
+
claimRun(runId, runnerId, claimToken, leaseMs, now, options = {}) {
|
|
1631
|
+
return this.state.transaction(() => this.claimRunInTransaction(runId, runnerId, claimToken, leaseMs, now, options));
|
|
1632
|
+
}
|
|
1633
|
+
claimRunInTransaction(runId, runnerId, claimToken, leaseMs, now, options = {}) {
|
|
1634
|
+
const row = this.workflowRunRow(runId);
|
|
1635
|
+
if (row === undefined || ["done", "failed", "cancelled"].includes(row.status))
|
|
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
|
+
}
|
|
1644
|
+
const lease = this.requireLease(row.resourceId);
|
|
1645
|
+
if (lease.ownerId !== null &&
|
|
1646
|
+
lease.expiresAt !== null &&
|
|
1647
|
+
lease.expiresAt > now &&
|
|
1648
|
+
lease.ownerId !== runnerId)
|
|
1649
|
+
return undefined;
|
|
1650
|
+
const generation = lease.generation + 1;
|
|
1651
|
+
const expiresAt = now + leaseMs;
|
|
1652
|
+
const result = this.state.connection
|
|
1653
|
+
.prepare(`UPDATE leases SET generation = ?, owner_type = ?, owner_id = ?, token_hash = ?,
|
|
1322
1654
|
acquired_at = ?, heartbeat_at = ?, expires_at = ?
|
|
1323
1655
|
WHERE resource_id = ? AND generation = ?`)
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1656
|
+
.run(generation, runnerId.startsWith("host-") ? "host" : "session", runnerId, tokenHash(claimToken), now, now, expiresAt, row.resourceId, lease.generation);
|
|
1657
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
1658
|
+
if (result.changes !== 1)
|
|
1659
|
+
return undefined;
|
|
1660
|
+
if (options.preserveQueueStatus !== true) {
|
|
1328
1661
|
this.state.connection
|
|
1329
|
-
.prepare(
|
|
1662
|
+
.prepare(`UPDATE run_queue
|
|
1663
|
+
SET status = 'starting', error_code = NULL, error_hash = NULL, updated_at = ?
|
|
1664
|
+
WHERE run_id = ?`)
|
|
1330
1665
|
.run(now, runId);
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
}
|
|
1666
|
+
}
|
|
1667
|
+
const revision = this.resourceRevision(row.resourceId);
|
|
1668
|
+
this.bumpResource(row.resourceId, revision, now);
|
|
1669
|
+
this.insertEvent(row.resourceId, revision + 1, "lease.claimed", runnerId.startsWith("host-") ? "host" : "session", runnerId, { expiresAt }, now, generation);
|
|
1670
|
+
return { ...this.requireWorkflowRun(runId), claimToken };
|
|
1336
1671
|
}
|
|
1337
1672
|
updateClaimedRunStatus(runId, claimToken, status, nowValue) {
|
|
1338
1673
|
const now = epoch(validTimestamp(nowValue));
|
|
@@ -1380,6 +1715,7 @@ export class SqliteControllerStore {
|
|
|
1380
1715
|
const revision = this.resourceRevision(row.resourceId);
|
|
1381
1716
|
this.bumpResource(row.resourceId, revision, now);
|
|
1382
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);
|
|
1383
1719
|
return true;
|
|
1384
1720
|
});
|
|
1385
1721
|
}
|
|
@@ -1430,11 +1766,76 @@ export class SqliteControllerStore {
|
|
|
1430
1766
|
updated_at = ?, finished_at = ?
|
|
1431
1767
|
WHERE run_id = ?`)
|
|
1432
1768
|
.run(status, errorHash, now, now, runId);
|
|
1769
|
+
if (status === "cancelled") {
|
|
1770
|
+
this.cancelWorkflowRunDependents(runId, lease.ownerId ?? "workflow-control", errorHash, now);
|
|
1771
|
+
}
|
|
1433
1772
|
this.bumpResource(row.resourceId, revision, now);
|
|
1434
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);
|
|
1435
1775
|
return true;
|
|
1436
1776
|
});
|
|
1437
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
|
+
}
|
|
1438
1839
|
turnIntent(intentId) {
|
|
1439
1840
|
const row = this.turnIntentRow(intentId);
|
|
1440
1841
|
return row === undefined ? undefined : this.mapTurnIntent(row);
|
|
@@ -1629,7 +2030,8 @@ function workflowRunSelect(clause) {
|
|
|
1629
2030
|
q.status, q.available_at AS availableAt, q.affinity_runner_id AS affinityRunnerId,
|
|
1630
2031
|
q.consecutive_errors AS consecutiveErrors, q.error_code AS errorCode,
|
|
1631
2032
|
q.error_hash AS errorHash, b.origin_session_id AS originSessionId,
|
|
1632
|
-
|
|
2033
|
+
b.execution_mode AS executionMode, r.parent_run_id AS parentRunId,
|
|
2034
|
+
q.created_at AS createdAt, q.updated_at AS updatedAt,
|
|
1633
2035
|
q.started_at AS startedAt, q.finished_at AS finishedAt,
|
|
1634
2036
|
l.generation AS leaseGeneration, l.owner_id AS ownerId, l.expires_at AS claimExpiresAt
|
|
1635
2037
|
FROM runs r JOIN workflow_definitions d ON d.definition_digest = r.definition_digest
|
|
@@ -1678,6 +2080,16 @@ function isRunSourceIdentityRow(value) {
|
|
|
1678
2080
|
function isEffectRow(value) {
|
|
1679
2081
|
return isRecord(value);
|
|
1680
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
|
+
}
|
|
1681
2093
|
function isWorkflowRow(value) {
|
|
1682
2094
|
return isRecord(value);
|
|
1683
2095
|
}
|
|
@@ -1699,6 +2111,9 @@ function isNotificationRow(value) {
|
|
|
1699
2111
|
function isProjectRow(value) {
|
|
1700
2112
|
return isRecord(value);
|
|
1701
2113
|
}
|
|
2114
|
+
function isCanonicalPathRow(value) {
|
|
2115
|
+
return isRecord(value) && typeof value.canonicalPath === "string";
|
|
2116
|
+
}
|
|
1702
2117
|
function isFinalizerRow(value) {
|
|
1703
2118
|
return isRecord(value);
|
|
1704
2119
|
}
|