@osolmaz/pi-workflows 0.11.1 → 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/dist/controllers/sqlite.d.ts +55 -7
- package/dist/controllers/sqlite.js +195 -48
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/extension/index.js +246 -54
- package/dist/extension/index.js.map +1 -1
- package/dist/host/runner.js +3 -0
- package/dist/host/runner.js.map +1 -1
- package/dist/workflows/migrate-sources.d.ts +1 -1
- package/dist/workflows/migrate-sources.js.map +1 -1
- package/dist/workflows/tool-input.d.ts +1 -0
- package/dist/workflows/tool-input.js +2 -2
- package/dist/workflows/tool-input.js.map +1 -1
- package/docs/2026-08-20-durable-workflow-launch-plan.md +449 -0
- package/docs/workflows.md +7 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/src/controllers/sqlite.ts +308 -54
- package/src/extension/index.ts +303 -58
- package/src/host/runner.ts +3 -0
- package/src/workflows/migrate-sources.ts +5 -1
- package/src/workflows/tool-input.ts +5 -2
package/README.md
CHANGED
|
@@ -129,6 +129,11 @@ Then, from any pi conversation:
|
|
|
129
129
|
/workflow echo summarize this repository
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
+
A model-started workflow is saved before the tool reports it as queued. The returned run ID works
|
|
133
|
+
with `workflow status` and `workflow cancel` before execution starts. Pi Workflows waits for the
|
|
134
|
+
current agent turn to settle before activation. If activation fails, it saves the failure and sends
|
|
135
|
+
one follow-up turn so the model can correct the cause and start a new run.
|
|
136
|
+
|
|
132
137
|
`/workflow` with no arguments lists discovered workflows. `/workflow pause`
|
|
133
138
|
lets the current step finish and then holds the run before the next node. This
|
|
134
139
|
is useful when you want to interject in the conversation mid-workflow.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ControllerStore, type EffectReservation, type QueueItem, type QueueRequeueOptions, type WorkflowRecordUpdate, type WorkflowReservation } from "./store.js";
|
|
2
2
|
import type { ChildWorkflowRecord, ControllerEvent, ControllerQueueClaim, ControllerResource, ControllerResourceRef, ControllerResourceStatus, EffectRecord, JsonObject } from "./types.js";
|
|
3
|
+
export type WorkflowRunLaunchStatus = "queued" | "starting" | "running" | "parked" | "done" | "failed" | "cancelled";
|
|
3
4
|
/** A user-started workflow run tracked by the durable run queue. */
|
|
4
5
|
export type WorkflowRunQueueRecord = {
|
|
5
6
|
runId: string;
|
|
@@ -7,8 +8,11 @@ export type WorkflowRunQueueRecord = {
|
|
|
7
8
|
workflowName: string;
|
|
8
9
|
/** Canonical source reference used to reopen the run. */
|
|
9
10
|
workflowSourceRef: string;
|
|
11
|
+
workflowSource: unknown;
|
|
12
|
+
definitionDigest: string;
|
|
10
13
|
input: unknown;
|
|
11
|
-
|
|
14
|
+
launchOptions: unknown;
|
|
15
|
+
status: WorkflowRunLaunchStatus;
|
|
12
16
|
runnerId: string | null;
|
|
13
17
|
claimToken: string | null;
|
|
14
18
|
claimExpiresAt: string | null;
|
|
@@ -16,8 +20,12 @@ export type WorkflowRunQueueRecord = {
|
|
|
16
20
|
/** Pi session that owns delivery and interactive execution, or null for detached runs. */
|
|
17
21
|
originSessionId: string | null;
|
|
18
22
|
parentRunId: string | null;
|
|
23
|
+
errorCode: string | null;
|
|
24
|
+
errorMessage: string | null;
|
|
19
25
|
createdAt: string;
|
|
20
26
|
updatedAt: string;
|
|
27
|
+
startedAt: string | null;
|
|
28
|
+
finishedAt: string | null;
|
|
21
29
|
};
|
|
22
30
|
export type WorkflowNotificationRecord = {
|
|
23
31
|
notificationId: string;
|
|
@@ -26,7 +34,7 @@ export type WorkflowNotificationRecord = {
|
|
|
26
34
|
attemptId: string;
|
|
27
35
|
notificationIndex: number;
|
|
28
36
|
targetSessionId: string;
|
|
29
|
-
kind: "progress" | "final";
|
|
37
|
+
kind: "progress" | "final" | "launch_failure";
|
|
30
38
|
content: string;
|
|
31
39
|
createdAt: string;
|
|
32
40
|
deliveryClaimExpiresAt: string | null;
|
|
@@ -129,6 +137,7 @@ export declare class SqliteControllerStore implements ControllerStore {
|
|
|
129
137
|
}): ControllerEvent[];
|
|
130
138
|
private configure;
|
|
131
139
|
private initializeSchema;
|
|
140
|
+
private assertAlphaSchemaLayout;
|
|
132
141
|
private transaction;
|
|
133
142
|
private resourceRow;
|
|
134
143
|
private requireResource;
|
|
@@ -139,15 +148,29 @@ export declare class SqliteControllerStore implements ControllerStore {
|
|
|
139
148
|
private requireEffect;
|
|
140
149
|
private workflowRow;
|
|
141
150
|
private requireWorkflow;
|
|
142
|
-
/**
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
151
|
+
/** Reserve a user-started run before the initiating agent turn settles. */
|
|
152
|
+
reserveWorkflowRun(options: {
|
|
153
|
+
runId: string;
|
|
154
|
+
workflowName: string;
|
|
155
|
+
workflowSourceRef: string;
|
|
156
|
+
workflowSource: unknown;
|
|
157
|
+
definitionDigest: string;
|
|
158
|
+
input: unknown;
|
|
159
|
+
launchOptions?: unknown;
|
|
160
|
+
runnerId: string;
|
|
161
|
+
originSessionId: string;
|
|
162
|
+
parentRunId?: string;
|
|
163
|
+
now?: string;
|
|
164
|
+
}): WorkflowRunQueueRecord;
|
|
165
|
+
/** Insert a run that will start immediately outside a live model turn. */
|
|
146
166
|
enqueueWorkflowRun(options: {
|
|
147
167
|
runId: string;
|
|
148
168
|
workflowName: string;
|
|
149
169
|
workflowSourceRef: string;
|
|
170
|
+
workflowSource?: unknown;
|
|
171
|
+
definitionDigest?: string;
|
|
150
172
|
input: unknown;
|
|
173
|
+
launchOptions?: unknown;
|
|
151
174
|
runnerId: string;
|
|
152
175
|
claimToken: string;
|
|
153
176
|
leaseMs: number;
|
|
@@ -160,6 +183,19 @@ export declare class SqliteControllerStore implements ControllerStore {
|
|
|
160
183
|
listWorkflowRuns(options?: {
|
|
161
184
|
status?: WorkflowRunQueueRecord["status"];
|
|
162
185
|
}): WorkflowRunQueueRecord[];
|
|
186
|
+
findSessionReservation(sessionId: string): WorkflowRunQueueRecord | undefined;
|
|
187
|
+
claimWorkflowRun(options: {
|
|
188
|
+
runId: string;
|
|
189
|
+
runnerId: string;
|
|
190
|
+
claimToken: string;
|
|
191
|
+
leaseMs: number;
|
|
192
|
+
now?: string;
|
|
193
|
+
}): WorkflowRunQueueRecord | undefined;
|
|
194
|
+
markWorkflowRunRunning(options: {
|
|
195
|
+
runId: string;
|
|
196
|
+
claimToken: string;
|
|
197
|
+
now?: string;
|
|
198
|
+
}): boolean;
|
|
163
199
|
/**
|
|
164
200
|
* Claim the oldest claimable run, preferring runs with affinity to this
|
|
165
201
|
* runner. Parked rows are claimable immediately; claimed rows become
|
|
@@ -194,6 +230,18 @@ export declare class SqliteControllerStore implements ControllerStore {
|
|
|
194
230
|
claimToken: string;
|
|
195
231
|
now?: string;
|
|
196
232
|
}): boolean;
|
|
233
|
+
failWorkflowRun(options: {
|
|
234
|
+
runId: string;
|
|
235
|
+
claimToken?: string;
|
|
236
|
+
errorCode: string;
|
|
237
|
+
errorMessage: string;
|
|
238
|
+
now?: string;
|
|
239
|
+
}): boolean;
|
|
240
|
+
cancelWorkflowRun(options: {
|
|
241
|
+
runId: string;
|
|
242
|
+
claimToken?: string;
|
|
243
|
+
now?: string;
|
|
244
|
+
}): boolean;
|
|
197
245
|
/**
|
|
198
246
|
* Delete a claimed row. Used when a continuation fails before its bundle
|
|
199
247
|
* exists, so the parent's one-continuation slot is not consumed by a run
|
|
@@ -249,7 +297,7 @@ export declare class SqliteControllerStore implements ControllerStore {
|
|
|
249
297
|
attemptId: string;
|
|
250
298
|
notificationIndex: number;
|
|
251
299
|
targetSessionId: string;
|
|
252
|
-
kind: "progress" | "final";
|
|
300
|
+
kind: "progress" | "final" | "launch_failure";
|
|
253
301
|
content: string;
|
|
254
302
|
notificationId?: string;
|
|
255
303
|
now?: string;
|
|
@@ -467,19 +467,50 @@ export class SqliteControllerStore {
|
|
|
467
467
|
if (row === undefined) {
|
|
468
468
|
throw new Error("Controller store has no schema identifier");
|
|
469
469
|
}
|
|
470
|
-
return;
|
|
471
470
|
}
|
|
472
|
-
|
|
473
|
-
this.database
|
|
474
|
-
.prepare("
|
|
475
|
-
.
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
this.database
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
471
|
+
else {
|
|
472
|
+
const existingQueue = this.database
|
|
473
|
+
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'workflow_run_queue'")
|
|
474
|
+
.get();
|
|
475
|
+
if (existingQueue !== undefined)
|
|
476
|
+
this.assertAlphaSchemaLayout();
|
|
477
|
+
this.transaction(() => {
|
|
478
|
+
this.database
|
|
479
|
+
.prepare("INSERT OR IGNORE INTO schema_info (singleton, schema_id) VALUES (1, ?)")
|
|
480
|
+
.run(CONTROLLER_STORE_SCHEMA);
|
|
481
|
+
this.database.exec(SCHEMA_SQL);
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
this.assertAlphaSchemaLayout();
|
|
485
|
+
}
|
|
486
|
+
assertAlphaSchemaLayout() {
|
|
487
|
+
const requiredQueueColumns = new Set([
|
|
488
|
+
"run_id",
|
|
489
|
+
"workflow_ref",
|
|
490
|
+
"workflow_path",
|
|
491
|
+
"workflow_source_json",
|
|
492
|
+
"definition_digest",
|
|
493
|
+
"input_json",
|
|
494
|
+
"launch_options_json",
|
|
495
|
+
"status",
|
|
496
|
+
"runner_id",
|
|
497
|
+
"claim_token",
|
|
498
|
+
"claim_expires_at",
|
|
499
|
+
"affinity_runner_id",
|
|
500
|
+
"origin_session_id",
|
|
501
|
+
"parent_run_id",
|
|
502
|
+
"error_code",
|
|
503
|
+
"error_message",
|
|
504
|
+
"created_at",
|
|
505
|
+
"updated_at",
|
|
506
|
+
"started_at",
|
|
507
|
+
"finished_at",
|
|
508
|
+
]);
|
|
509
|
+
const actual = new Set(this.database.pragma("table_info(workflow_run_queue)").map((column) => column.name));
|
|
510
|
+
const missing = [...requiredQueueColumns].filter((column) => !actual.has(column));
|
|
511
|
+
if (missing.length > 0) {
|
|
512
|
+
throw new Error("Controller store uses an incompatible alpha layout. Preserve needed run bundles, then reset the project controller store.");
|
|
513
|
+
}
|
|
483
514
|
}
|
|
484
515
|
transaction(task) {
|
|
485
516
|
this.database.exec("BEGIN IMMEDIATE");
|
|
@@ -551,10 +582,32 @@ export class SqliteControllerStore {
|
|
|
551
582
|
}
|
|
552
583
|
return workflow;
|
|
553
584
|
}
|
|
554
|
-
/**
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
585
|
+
/** Reserve a user-started run before the initiating agent turn settles. */
|
|
586
|
+
reserveWorkflowRun(options) {
|
|
587
|
+
validateRunId(options.runId);
|
|
588
|
+
validateKey(options.workflowName, "workflow name");
|
|
589
|
+
validateKey(options.workflowSourceRef, "workflow source ref");
|
|
590
|
+
validateKey(options.definitionDigest, "workflow definition digest");
|
|
591
|
+
validateKey(options.runnerId, "runner id");
|
|
592
|
+
validateKey(options.originSessionId, "origin session id");
|
|
593
|
+
const inputJson = canonicalJson(options.input ?? null, "workflow run input");
|
|
594
|
+
const sourceJson = canonicalJson(options.workflowSource, "workflow run source");
|
|
595
|
+
const launchJson = canonicalJson(options.launchOptions ?? {}, "workflow launch options");
|
|
596
|
+
validateJsonSize(inputJson, "Workflow run input", MAX_RESOURCE_VALUE_BYTES);
|
|
597
|
+
validateJsonSize(sourceJson, "Workflow run source", MAX_EVENT_BYTES);
|
|
598
|
+
validateJsonSize(launchJson, "Workflow launch options", MAX_EVENT_BYTES);
|
|
599
|
+
const now = validTimestamp(options.now);
|
|
600
|
+
this.database
|
|
601
|
+
.prepare(`INSERT INTO workflow_run_queue (
|
|
602
|
+
run_id, workflow_ref, workflow_path, workflow_source_json, definition_digest,
|
|
603
|
+
input_json, launch_options_json, status, runner_id, claim_token, claim_expires_at,
|
|
604
|
+
affinity_runner_id, origin_session_id, parent_run_id, error_code, error_message,
|
|
605
|
+
created_at, updated_at, started_at, finished_at
|
|
606
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', NULL, NULL, NULL, ?, ?, ?, NULL, NULL, ?, ?, NULL, NULL)`)
|
|
607
|
+
.run(options.runId, options.workflowName, options.workflowSourceRef, sourceJson, options.definitionDigest, inputJson, launchJson, options.runnerId, options.originSessionId, options.parentRunId ?? null, now, now);
|
|
608
|
+
return this.requireWorkflowRun(options.runId);
|
|
609
|
+
}
|
|
610
|
+
/** Insert a run that will start immediately outside a live model turn. */
|
|
558
611
|
enqueueWorkflowRun(options) {
|
|
559
612
|
validateRunId(options.runId);
|
|
560
613
|
validateKey(options.workflowName, "workflow name");
|
|
@@ -566,16 +619,19 @@ export class SqliteControllerStore {
|
|
|
566
619
|
validateKey(options.originSessionId, "origin session id");
|
|
567
620
|
}
|
|
568
621
|
const inputJson = canonicalJson(options.input ?? null, "workflow run input");
|
|
622
|
+
const sourceJson = canonicalJson(options.workflowSource ?? { ref: options.workflowSourceRef }, "workflow run source");
|
|
623
|
+
const launchJson = canonicalJson(options.launchOptions ?? {}, "workflow launch options");
|
|
569
624
|
validateJsonSize(inputJson, "Workflow run input", MAX_RESOURCE_VALUE_BYTES);
|
|
570
625
|
const now = validTimestamp(options.now);
|
|
571
626
|
const expiresAt = epoch(now) + options.leaseMs;
|
|
572
627
|
this.database
|
|
573
628
|
.prepare(`INSERT INTO workflow_run_queue (
|
|
574
|
-
run_id, workflow_ref, workflow_path,
|
|
575
|
-
runner_id, claim_token, claim_expires_at,
|
|
576
|
-
origin_session_id, parent_run_id,
|
|
577
|
-
|
|
578
|
-
|
|
629
|
+
run_id, workflow_ref, workflow_path, workflow_source_json, definition_digest,
|
|
630
|
+
input_json, launch_options_json, status, runner_id, claim_token, claim_expires_at,
|
|
631
|
+
affinity_runner_id, origin_session_id, parent_run_id, error_code, error_message,
|
|
632
|
+
created_at, updated_at, started_at, finished_at
|
|
633
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 'starting', ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, NULL)`)
|
|
634
|
+
.run(options.runId, options.workflowName, options.workflowSourceRef, sourceJson, options.definitionDigest ?? "unavailable", inputJson, launchJson, options.runnerId, options.claimToken, expiresAt, options.affinityRunnerId ?? options.runnerId, options.originSessionId ?? null, options.parentRunId ?? null, now, now, now);
|
|
579
635
|
return this.requireWorkflowRun(options.runId);
|
|
580
636
|
}
|
|
581
637
|
getWorkflowRun(runId) {
|
|
@@ -595,6 +651,43 @@ export class SqliteControllerStore {
|
|
|
595
651
|
.all(options.status);
|
|
596
652
|
return rows.map(workflowRunFromRow);
|
|
597
653
|
}
|
|
654
|
+
findSessionReservation(sessionId) {
|
|
655
|
+
validateKey(sessionId, "session id");
|
|
656
|
+
const row = this.database
|
|
657
|
+
.prepare(`SELECT * FROM workflow_run_queue
|
|
658
|
+
WHERE origin_session_id = ? AND status IN ('queued', 'starting', 'running')
|
|
659
|
+
ORDER BY created_at LIMIT 1`)
|
|
660
|
+
.get(sessionId);
|
|
661
|
+
return row === undefined ? undefined : workflowRunFromRow(row);
|
|
662
|
+
}
|
|
663
|
+
claimWorkflowRun(options) {
|
|
664
|
+
validateRunId(options.runId);
|
|
665
|
+
validateKey(options.runnerId, "runner id");
|
|
666
|
+
validateKey(options.claimToken, "claim token");
|
|
667
|
+
validateDuration(options.leaseMs, "leaseMs");
|
|
668
|
+
const now = validTimestamp(options.now);
|
|
669
|
+
const nowMs = epoch(now);
|
|
670
|
+
const expiresAt = nowMs + options.leaseMs;
|
|
671
|
+
const result = this.database
|
|
672
|
+
.prepare(`UPDATE workflow_run_queue
|
|
673
|
+
SET status = 'starting', runner_id = ?, claim_token = ?, claim_expires_at = ?,
|
|
674
|
+
started_at = COALESCE(started_at, ?), updated_at = ?
|
|
675
|
+
WHERE run_id = ? AND (
|
|
676
|
+
status IN ('queued', 'parked')
|
|
677
|
+
OR (status = 'starting' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
|
|
678
|
+
)`)
|
|
679
|
+
.run(options.runnerId, options.claimToken, expiresAt, now, now, options.runId, nowMs);
|
|
680
|
+
return result.changes === 1 ? this.requireWorkflowRun(options.runId) : undefined;
|
|
681
|
+
}
|
|
682
|
+
markWorkflowRunRunning(options) {
|
|
683
|
+
validateRunId(options.runId);
|
|
684
|
+
const now = validTimestamp(options.now);
|
|
685
|
+
const result = this.database
|
|
686
|
+
.prepare(`UPDATE workflow_run_queue SET status = 'running', updated_at = ?
|
|
687
|
+
WHERE run_id = ? AND claim_token = ? AND status = 'starting'`)
|
|
688
|
+
.run(now, options.runId, options.claimToken);
|
|
689
|
+
return result.changes === 1;
|
|
690
|
+
}
|
|
598
691
|
/**
|
|
599
692
|
* Claim the oldest claimable run, preferring runs with affinity to this
|
|
600
693
|
* runner. Parked rows are claimable immediately; claimed rows become
|
|
@@ -616,11 +709,11 @@ export class SqliteControllerStore {
|
|
|
616
709
|
}
|
|
617
710
|
const exclusion = excluded.length === 0 ? "" : `AND run_id NOT IN (${excluded.map(() => "?").join(", ")})`;
|
|
618
711
|
const sessionFilter = options.sessionId === undefined
|
|
619
|
-
? ""
|
|
712
|
+
? "AND status != 'queued' AND (status != 'starting' OR origin_session_id IS NULL)"
|
|
620
713
|
: "AND (origin_session_id IS NULL OR origin_session_id = ?)";
|
|
621
714
|
const claimable = `(
|
|
622
|
-
status
|
|
623
|
-
OR (status
|
|
715
|
+
status IN ('queued', 'parked')
|
|
716
|
+
OR (status IN ('starting', 'running') AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
|
|
624
717
|
)`;
|
|
625
718
|
return this.transaction(() => {
|
|
626
719
|
const candidate = this.database
|
|
@@ -636,13 +729,13 @@ export class SqliteControllerStore {
|
|
|
636
729
|
}
|
|
637
730
|
const result = this.database
|
|
638
731
|
.prepare(`UPDATE workflow_run_queue
|
|
639
|
-
SET status = '
|
|
640
|
-
claim_expires_at = ?, updated_at = ?
|
|
732
|
+
SET status = 'starting', runner_id = ?, claim_token = ?,
|
|
733
|
+
claim_expires_at = ?, started_at = COALESCE(started_at, ?), updated_at = ?
|
|
641
734
|
WHERE run_id = ? AND (
|
|
642
|
-
status
|
|
643
|
-
OR (status
|
|
735
|
+
status IN ('queued', 'parked')
|
|
736
|
+
OR (status IN ('starting', 'running') AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
|
|
644
737
|
)`)
|
|
645
|
-
.run(options.runnerId, options.claimToken, expiresAt, now, candidate.run_id, nowMs);
|
|
738
|
+
.run(options.runnerId, options.claimToken, expiresAt, now, now, candidate.run_id, nowMs);
|
|
646
739
|
return result.changes === 1 ? this.requireWorkflowRun(candidate.run_id) : undefined;
|
|
647
740
|
});
|
|
648
741
|
}
|
|
@@ -654,7 +747,7 @@ export class SqliteControllerStore {
|
|
|
654
747
|
const expiresAt = nowMs + options.leaseMs;
|
|
655
748
|
const result = this.database
|
|
656
749
|
.prepare(`UPDATE workflow_run_queue SET claim_expires_at = ?
|
|
657
|
-
WHERE run_id = ? AND claim_token = ? AND status
|
|
750
|
+
WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')
|
|
658
751
|
AND claim_expires_at > ?`)
|
|
659
752
|
.run(expiresAt, options.runId, options.claimToken, nowMs);
|
|
660
753
|
return result.changes === 1;
|
|
@@ -665,7 +758,7 @@ export class SqliteControllerStore {
|
|
|
665
758
|
const nowMs = epoch(validTimestamp(options.now));
|
|
666
759
|
const row = this.database
|
|
667
760
|
.prepare(`SELECT 1 AS live FROM workflow_run_queue
|
|
668
|
-
WHERE run_id = ? AND claim_token = ? AND status
|
|
761
|
+
WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')
|
|
669
762
|
AND claim_expires_at > ?`)
|
|
670
763
|
.get(options.runId, options.claimToken, nowMs);
|
|
671
764
|
return row !== undefined;
|
|
@@ -678,10 +771,42 @@ export class SqliteControllerStore {
|
|
|
678
771
|
.prepare(`UPDATE workflow_run_queue
|
|
679
772
|
SET status = 'parked', runner_id = NULL, claim_token = NULL,
|
|
680
773
|
claim_expires_at = NULL, updated_at = ?
|
|
681
|
-
WHERE run_id = ? AND claim_token = ? AND status
|
|
774
|
+
WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')`)
|
|
682
775
|
.run(now, options.runId, options.claimToken);
|
|
683
776
|
return result.changes === 1;
|
|
684
777
|
}
|
|
778
|
+
failWorkflowRun(options) {
|
|
779
|
+
validateRunId(options.runId);
|
|
780
|
+
validateKey(options.errorCode, "workflow launch error code");
|
|
781
|
+
const message = options.errorMessage.trim();
|
|
782
|
+
if (message.length === 0 || Buffer.byteLength(message) > 2_048) {
|
|
783
|
+
throw new Error("Workflow launch error message must be between 1 and 2048 bytes");
|
|
784
|
+
}
|
|
785
|
+
const now = validTimestamp(options.now);
|
|
786
|
+
const claimFilter = options.claimToken === undefined ? "" : "AND claim_token = ?";
|
|
787
|
+
const result = this.database
|
|
788
|
+
.prepare(`UPDATE workflow_run_queue
|
|
789
|
+
SET status = 'failed', runner_id = NULL, claim_token = NULL,
|
|
790
|
+
claim_expires_at = NULL, parent_run_id = NULL, input_json = 'null',
|
|
791
|
+
launch_options_json = '{}', error_code = ?, error_message = ?,
|
|
792
|
+
finished_at = ?, updated_at = ?
|
|
793
|
+
WHERE run_id = ? AND status IN ('queued', 'starting', 'running') ${claimFilter}`)
|
|
794
|
+
.run(options.errorCode, message, now, now, options.runId, ...(options.claimToken === undefined ? [] : [options.claimToken]));
|
|
795
|
+
return result.changes === 1;
|
|
796
|
+
}
|
|
797
|
+
cancelWorkflowRun(options) {
|
|
798
|
+
validateRunId(options.runId);
|
|
799
|
+
const now = validTimestamp(options.now);
|
|
800
|
+
const claimFilter = options.claimToken === undefined ? "" : "AND claim_token = ?";
|
|
801
|
+
const result = this.database
|
|
802
|
+
.prepare(`UPDATE workflow_run_queue
|
|
803
|
+
SET status = 'cancelled', runner_id = NULL, claim_token = NULL,
|
|
804
|
+
claim_expires_at = NULL, input_json = 'null', launch_options_json = '{}',
|
|
805
|
+
finished_at = ?, updated_at = ?
|
|
806
|
+
WHERE run_id = ? AND status IN ('queued', 'starting') ${claimFilter}`)
|
|
807
|
+
.run(now, now, options.runId, ...(options.claimToken === undefined ? [] : [options.claimToken]));
|
|
808
|
+
return result.changes === 1;
|
|
809
|
+
}
|
|
685
810
|
/**
|
|
686
811
|
* Delete a claimed row. Used when a continuation fails before its bundle
|
|
687
812
|
* exists, so the parent's one-continuation slot is not consumed by a run
|
|
@@ -701,9 +826,10 @@ export class SqliteControllerStore {
|
|
|
701
826
|
const result = this.database
|
|
702
827
|
.prepare(`UPDATE workflow_run_queue
|
|
703
828
|
SET status = 'done', runner_id = NULL, claim_token = NULL,
|
|
704
|
-
claim_expires_at = NULL,
|
|
705
|
-
|
|
706
|
-
|
|
829
|
+
claim_expires_at = NULL, input_json = 'null', launch_options_json = '{}',
|
|
830
|
+
finished_at = ?, updated_at = ?
|
|
831
|
+
WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')`)
|
|
832
|
+
.run(now, now, options.runId, options.claimToken);
|
|
707
833
|
return result.changes === 1;
|
|
708
834
|
}
|
|
709
835
|
/** Repair a canonical bundle's queue source and claim it only when needed. */
|
|
@@ -721,26 +847,27 @@ export class SqliteControllerStore {
|
|
|
721
847
|
const row = this.database
|
|
722
848
|
.prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
|
|
723
849
|
.get(options.runId);
|
|
724
|
-
if (row === undefined ||
|
|
850
|
+
if (row === undefined || ["done", "failed", "cancelled"].includes(row.status)) {
|
|
725
851
|
return false;
|
|
852
|
+
}
|
|
726
853
|
if (row.workflow_path === options.workflowSourceRef && row.status === "parked") {
|
|
727
854
|
return "unchanged";
|
|
728
855
|
}
|
|
729
856
|
const claimable = row.status === "parked" ||
|
|
730
|
-
(row.status === "
|
|
857
|
+
(row.status === "starting" &&
|
|
731
858
|
row.claim_token === options.claimToken &&
|
|
732
859
|
row.claim_expires_at !== null &&
|
|
733
860
|
row.claim_expires_at > nowMs) ||
|
|
734
|
-
(row.status === "
|
|
861
|
+
(row.status === "starting" &&
|
|
735
862
|
row.claim_expires_at !== null &&
|
|
736
863
|
row.claim_expires_at <= nowMs);
|
|
737
864
|
if (!claimable)
|
|
738
865
|
return false;
|
|
739
866
|
const result = this.database
|
|
740
867
|
.prepare(`UPDATE workflow_run_queue
|
|
741
|
-
SET workflow_ref = ?, workflow_path = ?, status = '
|
|
868
|
+
SET workflow_ref = ?, workflow_path = ?, status = 'starting', runner_id = ?,
|
|
742
869
|
claim_token = ?, claim_expires_at = ?, updated_at = ?
|
|
743
|
-
WHERE run_id = ? AND status
|
|
870
|
+
WHERE run_id = ? AND status NOT IN ('done', 'failed', 'cancelled')`)
|
|
744
871
|
.run(options.workflowName, options.workflowSourceRef, options.runnerId, options.claimToken, expiresAt, now, options.runId);
|
|
745
872
|
return result.changes === 1 ? "claimed" : false;
|
|
746
873
|
});
|
|
@@ -761,25 +888,26 @@ export class SqliteControllerStore {
|
|
|
761
888
|
const row = this.database
|
|
762
889
|
.prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
|
|
763
890
|
.get(options.runId);
|
|
764
|
-
if (row === undefined ||
|
|
891
|
+
if (row === undefined || ["done", "failed", "cancelled"].includes(row.status)) {
|
|
765
892
|
return false;
|
|
893
|
+
}
|
|
766
894
|
const sourceMatches = row.workflow_path === options.oldWorkflowPath ||
|
|
767
895
|
row.workflow_path === options.workflowSourceRef;
|
|
768
896
|
const claimable = row.status === "parked" ||
|
|
769
|
-
(row.status === "
|
|
897
|
+
(row.status === "starting" &&
|
|
770
898
|
row.claim_token === options.claimToken &&
|
|
771
899
|
row.claim_expires_at !== null &&
|
|
772
900
|
row.claim_expires_at > nowMs) ||
|
|
773
|
-
(row.status === "
|
|
901
|
+
(row.status === "starting" &&
|
|
774
902
|
row.claim_expires_at !== null &&
|
|
775
903
|
row.claim_expires_at <= nowMs);
|
|
776
904
|
if (!sourceMatches || !claimable)
|
|
777
905
|
return false;
|
|
778
906
|
const result = this.database
|
|
779
907
|
.prepare(`UPDATE workflow_run_queue
|
|
780
|
-
SET workflow_ref = ?, workflow_path = ?, status = '
|
|
908
|
+
SET workflow_ref = ?, workflow_path = ?, status = 'starting', runner_id = ?,
|
|
781
909
|
claim_token = ?, claim_expires_at = ?, updated_at = ?
|
|
782
|
-
WHERE run_id = ? AND status
|
|
910
|
+
WHERE run_id = ? AND status NOT IN ('done', 'failed', 'cancelled')`)
|
|
783
911
|
.run(options.workflowName, options.workflowSourceRef, options.runnerId, options.claimToken, expiresAt, now, options.runId);
|
|
784
912
|
return result.changes === 1;
|
|
785
913
|
});
|
|
@@ -945,19 +1073,31 @@ const SCHEMA_SQL = `
|
|
|
945
1073
|
run_id TEXT PRIMARY KEY,
|
|
946
1074
|
workflow_ref TEXT NOT NULL,
|
|
947
1075
|
workflow_path TEXT NOT NULL,
|
|
1076
|
+
workflow_source_json TEXT NOT NULL,
|
|
1077
|
+
definition_digest TEXT NOT NULL,
|
|
948
1078
|
input_json TEXT NOT NULL,
|
|
949
|
-
|
|
1079
|
+
launch_options_json TEXT NOT NULL,
|
|
1080
|
+
status TEXT NOT NULL CHECK (
|
|
1081
|
+
status IN ('queued', 'starting', 'running', 'parked', 'done', 'failed', 'cancelled')
|
|
1082
|
+
),
|
|
950
1083
|
runner_id TEXT,
|
|
951
1084
|
claim_token TEXT,
|
|
952
1085
|
claim_expires_at INTEGER,
|
|
953
1086
|
affinity_runner_id TEXT,
|
|
954
1087
|
origin_session_id TEXT,
|
|
955
1088
|
parent_run_id TEXT,
|
|
1089
|
+
error_code TEXT,
|
|
1090
|
+
error_message TEXT,
|
|
956
1091
|
created_at TEXT NOT NULL,
|
|
957
|
-
updated_at TEXT NOT NULL
|
|
1092
|
+
updated_at TEXT NOT NULL,
|
|
1093
|
+
started_at TEXT,
|
|
1094
|
+
finished_at TEXT
|
|
958
1095
|
);
|
|
959
1096
|
CREATE INDEX IF NOT EXISTS workflow_run_queue_claimable
|
|
960
1097
|
ON workflow_run_queue(status, claim_expires_at);
|
|
1098
|
+
CREATE UNIQUE INDEX IF NOT EXISTS workflow_run_queue_session_reservation
|
|
1099
|
+
ON workflow_run_queue(origin_session_id)
|
|
1100
|
+
WHERE origin_session_id IS NOT NULL AND status IN ('queued', 'starting', 'running');
|
|
961
1101
|
-- A checkpointed parent admits exactly one continuation run, across
|
|
962
1102
|
-- sessions and processes.
|
|
963
1103
|
CREATE UNIQUE INDEX IF NOT EXISTS workflow_run_queue_parent
|
|
@@ -981,7 +1121,7 @@ const SCHEMA_SQL = `
|
|
|
981
1121
|
attempt_id TEXT NOT NULL,
|
|
982
1122
|
notification_index INTEGER NOT NULL CHECK (notification_index > 0),
|
|
983
1123
|
target_session_id TEXT NOT NULL,
|
|
984
|
-
kind TEXT NOT NULL CHECK (kind IN ('progress', 'final')),
|
|
1124
|
+
kind TEXT NOT NULL CHECK (kind IN ('progress', 'final', 'launch_failure')),
|
|
985
1125
|
content TEXT NOT NULL,
|
|
986
1126
|
created_at TEXT NOT NULL,
|
|
987
1127
|
delivery_claim_token TEXT,
|
|
@@ -1099,7 +1239,10 @@ function workflowRunFromRow(row) {
|
|
|
1099
1239
|
runId: row.run_id,
|
|
1100
1240
|
workflowName: row.workflow_ref,
|
|
1101
1241
|
workflowSourceRef: row.workflow_path,
|
|
1242
|
+
workflowSource: parseStoredJson(row.workflow_source_json, "workflow run source"),
|
|
1243
|
+
definitionDigest: row.definition_digest,
|
|
1102
1244
|
input: parseStoredJson(row.input_json, "workflow run input"),
|
|
1245
|
+
launchOptions: parseStoredJson(row.launch_options_json, "workflow launch options"),
|
|
1103
1246
|
status: row.status,
|
|
1104
1247
|
runnerId: row.runner_id,
|
|
1105
1248
|
claimToken: row.claim_token,
|
|
@@ -1107,8 +1250,12 @@ function workflowRunFromRow(row) {
|
|
|
1107
1250
|
affinityRunnerId: row.affinity_runner_id,
|
|
1108
1251
|
originSessionId: row.origin_session_id,
|
|
1109
1252
|
parentRunId: row.parent_run_id,
|
|
1253
|
+
errorCode: row.error_code,
|
|
1254
|
+
errorMessage: row.error_message,
|
|
1110
1255
|
createdAt: row.created_at,
|
|
1111
1256
|
updatedAt: row.updated_at,
|
|
1257
|
+
startedAt: row.started_at,
|
|
1258
|
+
finishedAt: row.finished_at,
|
|
1112
1259
|
};
|
|
1113
1260
|
}
|
|
1114
1261
|
function validateRunId(runId) {
|