@osolmaz/pi-workflows 0.13.3 → 0.14.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 +136 -118
- package/dist/builtins/autoimplement.workflow.d.ts +12 -12
- package/dist/builtins/autoplan.workflow.d.ts +3 -3
- package/dist/builtins/autoplan.workflow.js +44 -35
- package/dist/builtins/autoplan.workflow.js.map +1 -1
- package/dist/builtins/catalog.js +1 -1
- package/dist/builtins/plan-change.workflow.d.ts +6 -6
- package/dist/controllers/index.d.ts +1 -1
- package/dist/controllers/index.js.map +1 -1
- package/dist/controllers/sqlite.d.ts +34 -31
- package/dist/controllers/sqlite.js +116 -77
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/extension/index.js +721 -202
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/restart-policy.d.ts +38 -0
- package/dist/extension/restart-policy.js +116 -0
- package/dist/extension/restart-policy.js.map +1 -0
- package/dist/extension/terminal-decision.d.ts +51 -0
- package/dist/extension/terminal-decision.js +110 -0
- package/dist/extension/terminal-decision.js.map +1 -0
- package/dist/state/prune.js +36 -10
- package/dist/state/prune.js.map +1 -1
- package/dist/workflows/tool-input.d.ts +4 -0
- package/dist/workflows/tool-input.js +6 -1
- package/dist/workflows/tool-input.js.map +1 -1
- package/docs/2026-08-25-workflow-follow-ups.md +8 -6
- package/docs/DEFERRED_TURNS.md +39 -26
- package/docs/HUMAN_DECISIONS.md +12 -4
- package/docs/SQLITE_STATE.md +24 -0
- package/docs/WORKFLOW_COMPOSITION.md +1 -1
- package/docs/plans/2026-08-19-human-decision-gates-plan.md +34 -8
- package/docs/plans/2026-08-27-workflow-terminal-restart-plan.md +357 -0
- package/docs/workflows.md +85 -29
- 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 +6 -6
- package/skills/pi-workflows/SKILL.md +2 -0
- package/src/builtins/autoplan.workflow.ts +45 -37
- package/src/builtins/catalog.ts +1 -1
- package/src/controllers/index.ts +3 -0
- package/src/controllers/sqlite.ts +226 -155
- package/src/extension/index.ts +881 -220
- package/src/extension/restart-policy.ts +163 -0
- package/src/extension/terminal-decision.ts +172 -0
- package/src/state/prune.ts +35 -9
- package/src/workflows/tool-input.ts +9 -1
|
@@ -96,9 +96,11 @@ function originalUserInstructions(outputs: Record<string, unknown>): string {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
function originalInstructionsPrompt(outputs: Record<string, unknown>): string {
|
|
99
|
-
return [
|
|
100
|
-
"
|
|
101
|
-
|
|
99
|
+
return [
|
|
100
|
+
"Continue in this Pi session. Do not delegate this workflow step or start another session.",
|
|
101
|
+
"Original user instructions (authoritative):",
|
|
102
|
+
originalUserInstructions(outputs),
|
|
103
|
+
].join("\n");
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
function requireString(value: unknown, label: string): string {
|
|
@@ -286,7 +288,7 @@ function candidateSummary(
|
|
|
286
288
|
ideal: AutoplanIdeal,
|
|
287
289
|
id: string,
|
|
288
290
|
): { id: string; title: string; gist: string } {
|
|
289
|
-
if (id === "ideal") return { id, title: "
|
|
291
|
+
if (id === "ideal") return { id, title: "Holy grail", gist: ideal.ideal };
|
|
290
292
|
const candidate = proposal.candidates.find((item) => item.id === id);
|
|
291
293
|
if (candidate === undefined)
|
|
292
294
|
throw new Error(`autoplan candidate ${JSON.stringify(id)} is missing`);
|
|
@@ -298,9 +300,9 @@ function summaryInput(
|
|
|
298
300
|
blocked: boolean,
|
|
299
301
|
input: AutoplanInput,
|
|
300
302
|
): PlainSummaryInput {
|
|
301
|
-
const proposal = outputs.
|
|
302
|
-
const selection = outputs.
|
|
303
|
-
const ideal = outputs.
|
|
303
|
+
const proposal = outputs.solutions as AutoplanProposal;
|
|
304
|
+
const selection = outputs.select as AutoplanSelection;
|
|
305
|
+
const ideal = outputs.holyGrail as AutoplanIdeal;
|
|
304
306
|
const selected = candidateSummary(proposal, ideal, selection.selectedId);
|
|
305
307
|
return {
|
|
306
308
|
source: {
|
|
@@ -368,6 +370,7 @@ export const autoplanWorkflow = defineWorkflow({
|
|
|
368
370
|
statusDetail: "capturing the user's instructions",
|
|
369
371
|
prompt: () =>
|
|
370
372
|
[
|
|
373
|
+
"Continue in this Pi session. Do not delegate this workflow step or start another session.",
|
|
371
374
|
"Read the conversation that came before this workflow step.",
|
|
372
375
|
"Return one text string named originalUserInstructions.",
|
|
373
376
|
"Include everything that the user has instructed for the intended purpose in the given context.",
|
|
@@ -398,12 +401,13 @@ export const autoplanWorkflow = defineWorkflow({
|
|
|
398
401
|
expectedOutput: `{ "problem": "concise statement", "success": ["criterion"], "inScope": ["change"], "outOfScope": ["change"], "constraints": ["constraint"], "controlBoundary": "what can change" }`,
|
|
399
402
|
validate: (value) => requireRecord(value, "autoplan frame"),
|
|
400
403
|
}),
|
|
401
|
-
|
|
402
|
-
statusDetail: "devising
|
|
404
|
+
solutions: agent({
|
|
405
|
+
statusDetail: "devising long-term solutions",
|
|
403
406
|
prompt: ({ outputs, input }) =>
|
|
404
407
|
[
|
|
405
408
|
originalInstructionsPrompt(outputs),
|
|
406
409
|
"Give two to four practical options that fit the allowed scope.",
|
|
410
|
+
"For each option, ask: Is this a Long term elegant and production ready solution?",
|
|
407
411
|
"For each option return a stable lowercase id and a short title. Add a plain gist and full solution, explain the reason and trade-offs, and list the parts.",
|
|
408
412
|
"Favor a few reusable parts with clear owners and use interfaces that already exist.",
|
|
409
413
|
"Reject one-off machinery and infrastructure that the task does not need.",
|
|
@@ -416,42 +420,46 @@ export const autoplanWorkflow = defineWorkflow({
|
|
|
416
420
|
validate: (value, { input }) =>
|
|
417
421
|
parseProposal(value, (input as AutoplanInput).previousPlan !== undefined),
|
|
418
422
|
}),
|
|
419
|
-
|
|
420
|
-
statusDetail: "describing the
|
|
423
|
+
holyGrail: agent({
|
|
424
|
+
statusDetail: "describing the Holy grail",
|
|
421
425
|
prompt: ({ outputs, input }) =>
|
|
422
426
|
[
|
|
423
427
|
originalInstructionsPrompt(outputs),
|
|
424
|
-
"Describe the
|
|
425
|
-
"
|
|
428
|
+
"Describe the Holy grail separately from the practical options.",
|
|
429
|
+
"Ask: Is this the Holy grail for the problem?",
|
|
430
|
+
"The Holy grail may match one option or go beyond the current scope.",
|
|
431
|
+
"Explain what makes it more Long term elegant and production ready than the practical options.",
|
|
426
432
|
"List each dependency we do not control. Do not assume that it can change.",
|
|
427
|
-
"State what
|
|
433
|
+
"State what the Holy grail would improve beyond the practical options.",
|
|
428
434
|
`Problem frame: ${JSON.stringify(outputs.frame)}`,
|
|
429
|
-
`Candidates: ${JSON.stringify(outputs.
|
|
435
|
+
`Candidates: ${JSON.stringify(outputs.solutions)}`,
|
|
430
436
|
`New evidence: ${JSON.stringify((input as AutoplanInput).newEvidence ?? null)}`,
|
|
431
437
|
].join("\n"),
|
|
432
|
-
expectedOutput: `{ "ideal": "
|
|
438
|
+
expectedOutput: `{ "ideal": "Holy grail end state", "outsideDependencies": ["dependency"], "additionalValue": ["benefit"] }`,
|
|
433
439
|
validate: parseIdeal,
|
|
434
440
|
}),
|
|
435
|
-
|
|
436
|
-
statusDetail: "
|
|
441
|
+
select: agent({
|
|
442
|
+
statusDetail: "selecting the solution",
|
|
437
443
|
prompt: ({ outputs }) =>
|
|
438
444
|
[
|
|
439
445
|
originalInstructionsPrompt(outputs),
|
|
440
446
|
"Select one option. Do not ask the user to choose.",
|
|
441
|
-
"Select the
|
|
442
|
-
"
|
|
447
|
+
"Select the most Long term elegant and production ready option that is proportionate, in scope, and implementable through interfaces we control.",
|
|
448
|
+
"Select the Holy grail when it meets those conditions and its value justifies the added complexity.",
|
|
449
|
+
"Otherwise select the strongest practical option we can build now with a clear path toward the Holy grail.",
|
|
443
450
|
"Work outside our control does not by itself make the plan blocked.",
|
|
444
451
|
"Do not require changes to an upstream project or an unrelated repository. Do not require a new service or resource without approval.",
|
|
445
452
|
"When two options solve the problem equally well, choose the simpler one.",
|
|
446
|
-
"Give one specific rejection reason for every option you do not select, including the
|
|
453
|
+
"Give one specific rejection reason for every option you do not select, including the Holy grail.",
|
|
447
454
|
"Return blocked only if no option inside the allowed scope can meet the success criteria.",
|
|
448
455
|
`Frame: ${JSON.stringify(outputs.frame)}`,
|
|
449
|
-
`Candidates: ${JSON.stringify(outputs.
|
|
450
|
-
`
|
|
451
|
-
`
|
|
456
|
+
`Candidates: ${JSON.stringify(outputs.solutions)}`,
|
|
457
|
+
`Holy grail candidate id: ideal`,
|
|
458
|
+
`Holy grail: ${JSON.stringify(outputs.holyGrail)}`,
|
|
452
459
|
].join("\n"),
|
|
453
460
|
expectedOutput: `{ "status": "ready" | "blocked", "selectedId": "candidate-id-or-ideal", "why": "reason", "relationshipToIdeal": "relationship", "rejected": [{ "id": "other-id", "reason": "why it lost" }], "compromises": ["compromise"], "blocker": "required only when blocked" }`,
|
|
454
|
-
validate: (value, { outputs }) =>
|
|
461
|
+
validate: (value, { outputs }) =>
|
|
462
|
+
parseSelection(value, outputs.solutions as AutoplanProposal),
|
|
455
463
|
}),
|
|
456
464
|
plan: agent({
|
|
457
465
|
timeoutMs: 30 * 60_000,
|
|
@@ -466,8 +474,8 @@ export const autoplanWorkflow = defineWorkflow({
|
|
|
466
474
|
"Correct the earlier plan when the new evidence proves it wrong.",
|
|
467
475
|
"Do not change files.",
|
|
468
476
|
`Frame: ${JSON.stringify(outputs.frame)}`,
|
|
469
|
-
`Selection: ${JSON.stringify(outputs.
|
|
470
|
-
`Candidates: ${JSON.stringify(outputs.
|
|
477
|
+
`Selection: ${JSON.stringify(outputs.select)}`,
|
|
478
|
+
`Candidates: ${JSON.stringify(outputs.solutions)}`,
|
|
471
479
|
`Previous plan: ${JSON.stringify((input as AutoplanInput).previousPlan ?? null)}`,
|
|
472
480
|
`New evidence: ${JSON.stringify((input as AutoplanInput).newEvidence ?? null)}`,
|
|
473
481
|
].join("\n"),
|
|
@@ -476,13 +484,13 @@ export const autoplanWorkflow = defineWorkflow({
|
|
|
476
484
|
}),
|
|
477
485
|
blocked: compute({
|
|
478
486
|
run: ({ outputs }) => {
|
|
479
|
-
const selection = outputs.
|
|
487
|
+
const selection = outputs.select as AutoplanSelection;
|
|
480
488
|
return {
|
|
481
489
|
status: "blocked",
|
|
482
490
|
originalUserInstructions: originalUserInstructions(outputs),
|
|
483
491
|
frame: outputs.frame,
|
|
484
|
-
proposal: outputs.
|
|
485
|
-
ideal: outputs.
|
|
492
|
+
proposal: outputs.solutions as AutoplanProposal,
|
|
493
|
+
ideal: outputs.holyGrail as AutoplanIdeal,
|
|
486
494
|
selection,
|
|
487
495
|
plainSummary: plainSummaryResult(outputs.blockedSummary, "autoplan blocked summary"),
|
|
488
496
|
reason: requireString(selection.blocker, "autoplan blocker"),
|
|
@@ -499,9 +507,9 @@ export const autoplanWorkflow = defineWorkflow({
|
|
|
499
507
|
status: "ready",
|
|
500
508
|
originalUserInstructions: originalUserInstructions(outputs),
|
|
501
509
|
frame: outputs.frame,
|
|
502
|
-
proposal: outputs.
|
|
503
|
-
ideal: outputs.
|
|
504
|
-
selection: outputs.
|
|
510
|
+
proposal: outputs.solutions as AutoplanProposal,
|
|
511
|
+
ideal: outputs.holyGrail as AutoplanIdeal,
|
|
512
|
+
selection: outputs.select as AutoplanSelection,
|
|
505
513
|
plan: outputs.plan,
|
|
506
514
|
plainSummary: plainSummaryResult(outputs.readySummary, "autoplan ready summary"),
|
|
507
515
|
planDigest,
|
|
@@ -513,11 +521,11 @@ export const autoplanWorkflow = defineWorkflow({
|
|
|
513
521
|
},
|
|
514
522
|
edges: [
|
|
515
523
|
{ from: "captureIntent", to: "frame" },
|
|
516
|
-
{ from: "frame", to: "
|
|
517
|
-
{ from: "
|
|
518
|
-
{ from: "
|
|
524
|
+
{ from: "frame", to: "solutions" },
|
|
525
|
+
{ from: "solutions", to: "holyGrail" },
|
|
526
|
+
{ from: "holyGrail", to: "select" },
|
|
519
527
|
{
|
|
520
|
-
from: "
|
|
528
|
+
from: "select",
|
|
521
529
|
switch: { on: "$.status", cases: { ready: "plan", blocked: "blockedSummary" } },
|
|
522
530
|
},
|
|
523
531
|
{ from: "plan", to: "readySummary" },
|
package/src/builtins/catalog.ts
CHANGED
|
@@ -9,7 +9,7 @@ import sanityCheckWorkflow from "./sanity-check.workflow.js";
|
|
|
9
9
|
|
|
10
10
|
export const builtinWorkflowCatalog = new BuiltinWorkflowCatalog([
|
|
11
11
|
{ id: "plain-summary", revision: "3", definition: plainSummaryWorkflow },
|
|
12
|
-
{ id: "autoplan", revision: "
|
|
12
|
+
{ id: "autoplan", revision: "6", definition: autoplanWorkflow },
|
|
13
13
|
{ id: "autodoc", revision: "2", definition: autodocWorkflow },
|
|
14
14
|
{ id: "autoimplement", revision: "11", definition: autoimplementWorkflow },
|
|
15
15
|
{ id: "plan-approval", revision: "4", definition: planApprovalWorkflow },
|
package/src/controllers/index.ts
CHANGED
|
@@ -24,7 +24,10 @@ export {
|
|
|
24
24
|
SqliteControllerStore,
|
|
25
25
|
type RunEventRecord,
|
|
26
26
|
type WorkflowNotificationRecord,
|
|
27
|
+
type WorkflowRunClaimOptions,
|
|
28
|
+
type WorkflowRunPreparationResult,
|
|
27
29
|
type WorkflowRunQueueRecord,
|
|
30
|
+
type WorkflowRunReservationOptions,
|
|
28
31
|
} from "./sqlite.js";
|
|
29
32
|
export {
|
|
30
33
|
type ControllerStore,
|
|
@@ -2,6 +2,7 @@ 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";
|
|
6
7
|
import {
|
|
7
8
|
EffectRequestConflictError,
|
|
@@ -39,6 +40,26 @@ export type WorkflowRunLaunchStatus =
|
|
|
39
40
|
| "failed"
|
|
40
41
|
| "cancelled";
|
|
41
42
|
|
|
43
|
+
export type WorkflowRunReservationOptions = {
|
|
44
|
+
runId: string;
|
|
45
|
+
workflowName: string;
|
|
46
|
+
workflowSourceRef: string;
|
|
47
|
+
workflowSource: unknown;
|
|
48
|
+
definitionDigest: string;
|
|
49
|
+
definitionSnapshot: unknown;
|
|
50
|
+
input: unknown;
|
|
51
|
+
launchOptions?: unknown;
|
|
52
|
+
runnerId: string;
|
|
53
|
+
originSessionId: string;
|
|
54
|
+
parentRunId?: string;
|
|
55
|
+
now?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type WorkflowRunClaimOptions = WorkflowRunReservationOptions & {
|
|
59
|
+
claimToken: string;
|
|
60
|
+
leaseMs: number;
|
|
61
|
+
};
|
|
62
|
+
|
|
42
63
|
export type WorkflowRunQueueRecord = {
|
|
43
64
|
runId: string;
|
|
44
65
|
workflowName: string;
|
|
@@ -64,6 +85,16 @@ export type WorkflowRunQueueRecord = {
|
|
|
64
85
|
finishedAt: string | null;
|
|
65
86
|
};
|
|
66
87
|
|
|
88
|
+
export type WorkflowRunPreparationResult =
|
|
89
|
+
| {
|
|
90
|
+
state: "claimed";
|
|
91
|
+
run: WorkflowRunQueueRecord & { claimToken: string };
|
|
92
|
+
}
|
|
93
|
+
| {
|
|
94
|
+
state: "adopted";
|
|
95
|
+
run: WorkflowRunQueueRecord;
|
|
96
|
+
};
|
|
97
|
+
|
|
67
98
|
export type WorkflowNotificationRecord = {
|
|
68
99
|
notificationId: string;
|
|
69
100
|
runId: string;
|
|
@@ -84,7 +115,9 @@ export type WorkflowTurnIntentCause =
|
|
|
84
115
|
| "failed"
|
|
85
116
|
| "launchFailed"
|
|
86
117
|
| "controllerInterrupted"
|
|
87
|
-
| "claimLost"
|
|
118
|
+
| "claimLost"
|
|
119
|
+
| "terminal"
|
|
120
|
+
| "cancelled";
|
|
88
121
|
|
|
89
122
|
export type WorkflowTurnIntentResolution = "workflowPrompt" | "presentation" | "fallback";
|
|
90
123
|
|
|
@@ -996,139 +1029,147 @@ export class SqliteControllerStore implements ControllerStore {
|
|
|
996
1029
|
}));
|
|
997
1030
|
}
|
|
998
1031
|
|
|
999
|
-
reserveWorkflowRun(options: {
|
|
1000
|
-
runId: string;
|
|
1001
|
-
workflowName: string;
|
|
1002
|
-
workflowSourceRef: string;
|
|
1003
|
-
workflowSource: unknown;
|
|
1004
|
-
definitionDigest: string;
|
|
1005
|
-
definitionSnapshot: unknown;
|
|
1006
|
-
input: unknown;
|
|
1007
|
-
launchOptions?: unknown;
|
|
1008
|
-
runnerId: string;
|
|
1009
|
-
originSessionId: string;
|
|
1010
|
-
parentRunId?: string;
|
|
1011
|
-
now?: string;
|
|
1012
|
-
}): WorkflowRunQueueRecord {
|
|
1032
|
+
reserveWorkflowRun(options: WorkflowRunReservationOptions): WorkflowRunQueueRecord {
|
|
1013
1033
|
validateRunId(options.runId);
|
|
1014
1034
|
const now = epoch(validTimestamp(options.now));
|
|
1015
1035
|
const definitionDigest = digestBuffer(options.definitionDigest);
|
|
1016
|
-
return this.state.transaction(() =>
|
|
1017
|
-
|
|
1018
|
-
|
|
1036
|
+
return this.state.transaction(() =>
|
|
1037
|
+
this.reserveWorkflowRunInTransaction(options, now, definitionDigest),
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
private reserveWorkflowRunInTransaction(
|
|
1042
|
+
options: WorkflowRunReservationOptions,
|
|
1043
|
+
now: number,
|
|
1044
|
+
definitionDigest: Buffer,
|
|
1045
|
+
): WorkflowRunQueueRecord {
|
|
1046
|
+
if (this.getWorkflowRun(options.runId) !== undefined) {
|
|
1047
|
+
throw new Error(`Workflow run already reserved: ${options.runId}`);
|
|
1048
|
+
}
|
|
1049
|
+
if (options.parentRunId !== undefined) {
|
|
1050
|
+
const parent = this.requireWorkflowRunRow(options.parentRunId);
|
|
1051
|
+
if (parent.originSessionId !== options.originSessionId) {
|
|
1052
|
+
throw new Error("Continuation parent belongs to another Pi session");
|
|
1019
1053
|
}
|
|
1020
|
-
if (
|
|
1021
|
-
const
|
|
1022
|
-
if (
|
|
1023
|
-
throw new Error("Continuation parent
|
|
1054
|
+
if (parent.status === "parked") {
|
|
1055
|
+
const parentLease = this.requireLease(parent.resourceId);
|
|
1056
|
+
if (parentLease.ownerId !== null) {
|
|
1057
|
+
throw new Error("Continuation parent still has an active owner");
|
|
1024
1058
|
}
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
throw new Error("Continuation parent still has an active owner");
|
|
1029
|
-
}
|
|
1030
|
-
this.state.connection
|
|
1031
|
-
.prepare(
|
|
1032
|
-
`UPDATE run_queue
|
|
1059
|
+
this.state.connection
|
|
1060
|
+
.prepare(
|
|
1061
|
+
`UPDATE run_queue
|
|
1033
1062
|
SET status = 'done', updated_at = ?, finished_at = ?
|
|
1034
1063
|
WHERE run_id = ? AND status = 'parked'`,
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
}
|
|
1064
|
+
)
|
|
1065
|
+
.run(now, now, parent.runId);
|
|
1066
|
+
const parentRevision = this.resourceRevision(parent.resourceId);
|
|
1067
|
+
this.bumpResource(parent.resourceId, parentRevision, now);
|
|
1068
|
+
this.insertEvent(
|
|
1069
|
+
parent.resourceId,
|
|
1070
|
+
parentRevision + 1,
|
|
1071
|
+
"run.queue_done_for_continuation",
|
|
1072
|
+
"session",
|
|
1073
|
+
options.originSessionId,
|
|
1074
|
+
{ continuationRunId: options.runId },
|
|
1075
|
+
now,
|
|
1076
|
+
);
|
|
1077
|
+
} else if (parent.status === "done") {
|
|
1078
|
+
throw new Error("Continuation parent already has a reserved continuation");
|
|
1079
|
+
} else {
|
|
1080
|
+
throw new Error(`Continuation parent queue is ${parent.status}`);
|
|
1053
1081
|
}
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1082
|
+
}
|
|
1083
|
+
const resourceId = resourceIdFor("run", options.runId);
|
|
1084
|
+
const definitionHash = this.state.putJson(options.definitionSnapshot, now);
|
|
1085
|
+
const queuedSource = queuedWorkflowSource(options.workflowSource);
|
|
1086
|
+
const inputHash = this.state.putJson(options.input ?? null, now);
|
|
1087
|
+
const launchHash = this.state.putJson(options.launchOptions ?? {}, now);
|
|
1088
|
+
this.state.connection
|
|
1089
|
+
.prepare(
|
|
1090
|
+
`INSERT INTO workflow_definitions(
|
|
1062
1091
|
definition_digest, workflow_name, definition_hash, created_at
|
|
1063
1092
|
) VALUES (?, ?, ?, ?)
|
|
1064
1093
|
ON CONFLICT(definition_digest) DO NOTHING`,
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1094
|
+
)
|
|
1095
|
+
.run(definitionDigest, options.workflowName, definitionHash, now);
|
|
1096
|
+
this.state.connection
|
|
1097
|
+
.prepare(
|
|
1098
|
+
`INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at)
|
|
1070
1099
|
VALUES (?, 'run', ?, 1, ?, ?)`,
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1100
|
+
)
|
|
1101
|
+
.run(resourceId, options.runId, now, now);
|
|
1102
|
+
this.state.connection
|
|
1103
|
+
.prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
|
|
1104
|
+
.run(resourceId);
|
|
1105
|
+
this.state.connection
|
|
1106
|
+
.prepare(
|
|
1107
|
+
`INSERT INTO runs(
|
|
1079
1108
|
run_id, resource_id, project_id, parent_run_id, definition_digest,
|
|
1080
1109
|
workflow_ref, launch_options_hash, status, paused,
|
|
1081
1110
|
input_hash, created_at, updated_at
|
|
1082
1111
|
) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`,
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1112
|
+
)
|
|
1113
|
+
.run(
|
|
1114
|
+
options.runId,
|
|
1115
|
+
resourceId,
|
|
1116
|
+
this.requireProjectId(),
|
|
1117
|
+
options.parentRunId ?? null,
|
|
1118
|
+
definitionDigest,
|
|
1119
|
+
options.workflowSourceRef,
|
|
1120
|
+
launchHash,
|
|
1121
|
+
inputHash,
|
|
1122
|
+
now,
|
|
1123
|
+
now,
|
|
1124
|
+
);
|
|
1125
|
+
insertQueuedRunSources(this.state, options.runId, queuedSource);
|
|
1126
|
+
this.state.connection
|
|
1127
|
+
.prepare(
|
|
1128
|
+
`INSERT INTO run_bindings(run_id, origin_session_id, execution_mode, created_at)
|
|
1100
1129
|
VALUES (?, ?, 'interactive', ?)`,
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1130
|
+
)
|
|
1131
|
+
.run(options.runId, options.originSessionId, now);
|
|
1132
|
+
this.state.connection
|
|
1133
|
+
.prepare(
|
|
1134
|
+
`INSERT INTO run_queue(
|
|
1106
1135
|
run_id, status, available_at, affinity_runner_id, origin_session_id,
|
|
1107
1136
|
consecutive_errors, created_at, updated_at
|
|
1108
1137
|
) VALUES (?, 'queued', ?, ?, ?, 0, ?, ?)`,
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1138
|
+
)
|
|
1139
|
+
.run(options.runId, now, options.runnerId, options.originSessionId, now, now);
|
|
1140
|
+
this.insertEvent(resourceId, 1, "run.queued", "session", options.originSessionId, {}, now);
|
|
1141
|
+
return this.requireWorkflowRun(options.runId);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
prepareOrAdoptWorkflowRun(options: WorkflowRunClaimOptions): WorkflowRunPreparationResult {
|
|
1145
|
+
validateRunId(options.runId);
|
|
1146
|
+
const now = epoch(validTimestamp(options.now));
|
|
1147
|
+
const definitionDigest = digestBuffer(options.definitionDigest);
|
|
1148
|
+
return this.state.transaction(() => {
|
|
1149
|
+
const existing = this.workflowRunRow(options.runId);
|
|
1150
|
+
if (existing !== undefined) {
|
|
1151
|
+
this.assertWorkflowRunPreparationCompatible(existing, options, definitionDigest);
|
|
1152
|
+
return { state: "adopted", run: this.mapWorkflowRun(existing) };
|
|
1153
|
+
}
|
|
1154
|
+
this.reserveWorkflowRunInTransaction(options, now, definitionDigest);
|
|
1155
|
+
const claimed = this.claimRunInTransaction(
|
|
1156
|
+
options.runId,
|
|
1157
|
+
options.runnerId,
|
|
1158
|
+
options.claimToken,
|
|
1159
|
+
options.leaseMs,
|
|
1160
|
+
now,
|
|
1161
|
+
);
|
|
1162
|
+
if (claimed === undefined) {
|
|
1163
|
+
throw new Error(`Workflow run could not be claimed: ${options.runId}`);
|
|
1164
|
+
}
|
|
1165
|
+
return {
|
|
1166
|
+
state: "claimed",
|
|
1167
|
+
run: claimed as WorkflowRunQueueRecord & { claimToken: string },
|
|
1168
|
+
};
|
|
1113
1169
|
});
|
|
1114
1170
|
}
|
|
1115
1171
|
|
|
1116
|
-
enqueueWorkflowRun(options: {
|
|
1117
|
-
runId: string;
|
|
1118
|
-
workflowName: string;
|
|
1119
|
-
workflowSourceRef: string;
|
|
1120
|
-
workflowSource: unknown;
|
|
1121
|
-
definitionDigest: string;
|
|
1122
|
-
definitionSnapshot: unknown;
|
|
1123
|
-
input: unknown;
|
|
1124
|
-
launchOptions?: unknown;
|
|
1125
|
-
runnerId: string;
|
|
1126
|
-
claimToken: string;
|
|
1127
|
-
leaseMs: number;
|
|
1128
|
-
originSessionId: string;
|
|
1129
|
-
parentRunId?: string;
|
|
1130
|
-
now?: string;
|
|
1131
|
-
}): WorkflowRunQueueRecord {
|
|
1172
|
+
enqueueWorkflowRun(options: WorkflowRunClaimOptions): WorkflowRunQueueRecord {
|
|
1132
1173
|
if (this.getWorkflowRun(options.runId) === undefined) {
|
|
1133
1174
|
this.reserveWorkflowRun({
|
|
1134
1175
|
runId: options.runId,
|
|
@@ -2228,6 +2269,27 @@ export class SqliteControllerStore implements ControllerStore {
|
|
|
2228
2269
|
return this.mapWorkflowRun(this.requireWorkflowRunRow(runId));
|
|
2229
2270
|
}
|
|
2230
2271
|
|
|
2272
|
+
private assertWorkflowRunPreparationCompatible(
|
|
2273
|
+
row: RunRow,
|
|
2274
|
+
options: WorkflowRunReservationOptions,
|
|
2275
|
+
definitionDigest: Buffer,
|
|
2276
|
+
): void {
|
|
2277
|
+
const compatible =
|
|
2278
|
+
row.workflowName === options.workflowName &&
|
|
2279
|
+
row.workflowRef === options.workflowSourceRef &&
|
|
2280
|
+
row.definitionDigest.equals(definitionDigest) &&
|
|
2281
|
+
canonicalJson(readQueuedRunSources(this.state, row)) ===
|
|
2282
|
+
canonicalJson(queuedWorkflowSource(options.workflowSource)) &&
|
|
2283
|
+
canonicalJson(this.state.readJson(row.inputHash)) === canonicalJson(options.input ?? null) &&
|
|
2284
|
+
canonicalJson(this.state.readJson(row.launchOptionsHash)) ===
|
|
2285
|
+
canonicalJson(options.launchOptions ?? {}) &&
|
|
2286
|
+
row.originSessionId === options.originSessionId &&
|
|
2287
|
+
row.parentRunId === (options.parentRunId ?? null);
|
|
2288
|
+
if (!compatible) {
|
|
2289
|
+
throw new Error(`Workflow run preparation conflicts: ${options.runId}`);
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2231
2293
|
/* istanbul ignore next -- pure projection covered by integration tests */
|
|
2232
2294
|
private mapWorkflowRun(row: RunRow): WorkflowRunQueueRecord {
|
|
2233
2295
|
return {
|
|
@@ -2267,56 +2329,65 @@ export class SqliteControllerStore implements ControllerStore {
|
|
|
2267
2329
|
leaseMs: number,
|
|
2268
2330
|
now: number,
|
|
2269
2331
|
): WorkflowRunQueueRecord | undefined {
|
|
2270
|
-
return this.state.transaction(() =>
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2332
|
+
return this.state.transaction(() =>
|
|
2333
|
+
this.claimRunInTransaction(runId, runnerId, claimToken, leaseMs, now),
|
|
2334
|
+
);
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
private claimRunInTransaction(
|
|
2338
|
+
runId: string,
|
|
2339
|
+
runnerId: string,
|
|
2340
|
+
claimToken: string,
|
|
2341
|
+
leaseMs: number,
|
|
2342
|
+
now: number,
|
|
2343
|
+
): WorkflowRunQueueRecord | undefined {
|
|
2344
|
+
const row = this.workflowRunRow(runId);
|
|
2345
|
+
if (row === undefined || ["done", "failed", "cancelled"].includes(row.status)) return undefined;
|
|
2346
|
+
const lease = this.requireLease(row.resourceId);
|
|
2347
|
+
if (
|
|
2348
|
+
lease.ownerId !== null &&
|
|
2349
|
+
lease.expiresAt !== null &&
|
|
2350
|
+
lease.expiresAt > now &&
|
|
2351
|
+
lease.ownerId !== runnerId
|
|
2352
|
+
)
|
|
2353
|
+
return undefined;
|
|
2354
|
+
const generation = lease.generation + 1;
|
|
2355
|
+
const expiresAt = now + leaseMs;
|
|
2356
|
+
const result = this.state.connection
|
|
2357
|
+
.prepare(
|
|
2358
|
+
`UPDATE leases SET generation = ?, owner_type = ?, owner_id = ?, token_hash = ?,
|
|
2287
2359
|
acquired_at = ?, heartbeat_at = ?, expires_at = ?
|
|
2288
2360
|
WHERE resource_id = ? AND generation = ?`,
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
runnerId.startsWith("host-") ? "host" : "session",
|
|
2293
|
-
runnerId,
|
|
2294
|
-
tokenHash(claimToken),
|
|
2295
|
-
now,
|
|
2296
|
-
now,
|
|
2297
|
-
expiresAt,
|
|
2298
|
-
row.resourceId,
|
|
2299
|
-
lease.generation,
|
|
2300
|
-
);
|
|
2301
|
-
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
2302
|
-
if (result.changes !== 1) return undefined;
|
|
2303
|
-
this.state.connection
|
|
2304
|
-
.prepare("UPDATE run_queue SET status = 'starting', updated_at = ? WHERE run_id = ?")
|
|
2305
|
-
.run(now, runId);
|
|
2306
|
-
const revision = this.resourceRevision(row.resourceId);
|
|
2307
|
-
this.bumpResource(row.resourceId, revision, now);
|
|
2308
|
-
this.insertEvent(
|
|
2309
|
-
row.resourceId,
|
|
2310
|
-
revision + 1,
|
|
2311
|
-
"lease.claimed",
|
|
2361
|
+
)
|
|
2362
|
+
.run(
|
|
2363
|
+
generation,
|
|
2312
2364
|
runnerId.startsWith("host-") ? "host" : "session",
|
|
2313
2365
|
runnerId,
|
|
2314
|
-
|
|
2366
|
+
tokenHash(claimToken),
|
|
2315
2367
|
now,
|
|
2316
|
-
|
|
2368
|
+
now,
|
|
2369
|
+
expiresAt,
|
|
2370
|
+
row.resourceId,
|
|
2371
|
+
lease.generation,
|
|
2317
2372
|
);
|
|
2318
|
-
|
|
2319
|
-
|
|
2373
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
2374
|
+
if (result.changes !== 1) return undefined;
|
|
2375
|
+
this.state.connection
|
|
2376
|
+
.prepare("UPDATE run_queue SET status = 'starting', updated_at = ? WHERE run_id = ?")
|
|
2377
|
+
.run(now, runId);
|
|
2378
|
+
const revision = this.resourceRevision(row.resourceId);
|
|
2379
|
+
this.bumpResource(row.resourceId, revision, now);
|
|
2380
|
+
this.insertEvent(
|
|
2381
|
+
row.resourceId,
|
|
2382
|
+
revision + 1,
|
|
2383
|
+
"lease.claimed",
|
|
2384
|
+
runnerId.startsWith("host-") ? "host" : "session",
|
|
2385
|
+
runnerId,
|
|
2386
|
+
{ expiresAt },
|
|
2387
|
+
now,
|
|
2388
|
+
generation,
|
|
2389
|
+
);
|
|
2390
|
+
return { ...this.requireWorkflowRun(runId), claimToken };
|
|
2320
2391
|
}
|
|
2321
2392
|
|
|
2322
2393
|
private updateClaimedRunStatus(
|