@osolmaz/pi-workflows 0.3.0 → 0.4.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 +3 -2
- package/dist/builtins/catalog.d.ts +2 -0
- package/dist/builtins/catalog.js +22 -0
- package/dist/builtins/catalog.js.map +1 -0
- package/dist/builtins/monitor.workflow.js +17 -1
- package/dist/builtins/monitor.workflow.js.map +1 -1
- package/dist/controllers/sqlite.d.ts +27 -4
- package/dist/controllers/sqlite.js +83 -5
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/controllers/workflow-engine-scheduler.d.ts +2 -2
- package/dist/controllers/workflow-engine-scheduler.js +3 -1
- package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
- package/dist/extension/executor.d.ts +3 -0
- package/dist/extension/executor.js +11 -1
- package/dist/extension/executor.js.map +1 -1
- package/dist/extension/index.js +84 -29
- package/dist/extension/index.js.map +1 -1
- package/dist/host/runner.d.ts +1 -0
- package/dist/host/runner.js +48 -19
- package/dist/host/runner.js.map +1 -1
- package/dist/workflows/catalog.d.ts +43 -0
- package/dist/workflows/catalog.js +79 -0
- package/dist/workflows/catalog.js.map +1 -0
- package/dist/workflows/engine.d.ts +5 -6
- package/dist/workflows/engine.js +71 -33
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/index.d.ts +2 -2
- package/dist/workflows/index.js +1 -1
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/loader.d.ts +18 -16
- package/dist/workflows/loader.js +58 -23
- package/dist/workflows/loader.js.map +1 -1
- package/dist/workflows/migrate-sources.d.ts +41 -0
- package/dist/workflows/migrate-sources.js +129 -0
- package/dist/workflows/migrate-sources.js.map +1 -0
- package/dist/workflows/schema.js +2 -1
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.js +2 -2
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +18 -4
- package/docs/development.md +5 -3
- package/docs/plans/2026-08-12-coordinated-workflow-timeouts-plan.md +74 -0
- package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +97 -0
- package/docs/run-bundles.md +22 -4
- package/docs/workflows.md +34 -10
- package/package.json +1 -1
- package/src/builtins/catalog.ts +22 -0
- package/src/builtins/monitor.workflow.ts +25 -1
- package/src/controllers/sqlite.ts +128 -10
- package/src/controllers/workflow-engine-scheduler.ts +5 -2
- package/src/extension/executor.ts +12 -1
- package/src/extension/index.ts +106 -37
- package/src/host/runner.ts +58 -19
- package/src/workflows/catalog.ts +135 -0
- package/src/workflows/engine.ts +102 -53
- package/src/workflows/index.ts +2 -0
- package/src/workflows/loader.ts +70 -26
- package/src/workflows/migrate-sources.ts +167 -0
- package/src/workflows/schema.ts +2 -1
- package/src/workflows/store.ts +2 -2
- package/src/workflows/types.ts +13 -4
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { agent, compute, defineWorkflow, shell } from "../workflows/
|
|
1
|
+
import { agent, compute, defineWorkflow, shell } from "../workflows/definition.js";
|
|
2
2
|
import type { WorkflowNodeContext } from "../workflows/types.js";
|
|
3
3
|
|
|
4
4
|
const MIN_INTERVAL_MINUTES = 1;
|
|
5
5
|
const MAX_INTERVAL_MINUTES = 24 * 60;
|
|
6
|
+
const MIN_CHECK_TIMEOUT_MINUTES = 5;
|
|
7
|
+
const MAX_CHECK_TIMEOUT_MINUTES = 24 * 60;
|
|
8
|
+
const DEFAULT_MIN_CHECK_TIMEOUT_MINUTES = 60;
|
|
6
9
|
const DEFAULT_MAX_CHECKS = 1_000;
|
|
7
10
|
const MAX_CHECKS = 1_000;
|
|
8
11
|
const MAX_OBSERVATION_CHARS = 8_000;
|
|
@@ -17,6 +20,7 @@ type MonitorInput = {
|
|
|
17
20
|
reportWhen?: string;
|
|
18
21
|
stopWhen?: string;
|
|
19
22
|
maxChecks?: number;
|
|
23
|
+
checkTimeoutMinutes?: number;
|
|
20
24
|
};
|
|
21
25
|
|
|
22
26
|
type MonitorConfig = {
|
|
@@ -25,6 +29,7 @@ type MonitorConfig = {
|
|
|
25
29
|
reportWhen: string;
|
|
26
30
|
stopWhen: string;
|
|
27
31
|
maxChecks: number;
|
|
32
|
+
checkTimeoutMinutes: number;
|
|
28
33
|
};
|
|
29
34
|
|
|
30
35
|
type MonitorRoute = "continue_quiet" | "continue_report" | "stop_quiet" | "stop_report";
|
|
@@ -81,6 +86,17 @@ function prepareInput(input: unknown): MonitorConfig {
|
|
|
81
86
|
if (!Number.isInteger(maxChecks) || maxChecks <= 0 || maxChecks > MAX_CHECKS) {
|
|
82
87
|
throw new Error(`maxChecks must be an integer from 1 through ${MAX_CHECKS}`);
|
|
83
88
|
}
|
|
89
|
+
const checkTimeoutMinutes =
|
|
90
|
+
value.checkTimeoutMinutes ?? Math.max(DEFAULT_MIN_CHECK_TIMEOUT_MINUTES, value.everyMinutes);
|
|
91
|
+
if (
|
|
92
|
+
!Number.isInteger(checkTimeoutMinutes) ||
|
|
93
|
+
checkTimeoutMinutes < MIN_CHECK_TIMEOUT_MINUTES ||
|
|
94
|
+
checkTimeoutMinutes > MAX_CHECK_TIMEOUT_MINUTES
|
|
95
|
+
) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`checkTimeoutMinutes must be an integer from ${MIN_CHECK_TIMEOUT_MINUTES} through ${MAX_CHECK_TIMEOUT_MINUTES}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
84
100
|
return {
|
|
85
101
|
task,
|
|
86
102
|
everyMinutes: value.everyMinutes,
|
|
@@ -93,6 +109,7 @@ function prepareInput(input: unknown): MonitorConfig {
|
|
|
93
109
|
? "The user cancels the monitor or it reaches its maximum check count."
|
|
94
110
|
: requireBoundedString(value.stopWhen, "stopWhen", 4_000),
|
|
95
111
|
maxChecks,
|
|
112
|
+
checkTimeoutMinutes,
|
|
96
113
|
};
|
|
97
114
|
}
|
|
98
115
|
|
|
@@ -100,6 +117,10 @@ function configFrom(outputs: Record<string, unknown>): MonitorConfig {
|
|
|
100
117
|
return outputs.prepare as MonitorConfig;
|
|
101
118
|
}
|
|
102
119
|
|
|
120
|
+
function agentTimeoutMs({ outputs }: WorkflowNodeContext): number {
|
|
121
|
+
return configFrom(outputs).checkTimeoutMinutes * 60_000;
|
|
122
|
+
}
|
|
123
|
+
|
|
103
124
|
function completedChecks(context: WorkflowNodeContext): number {
|
|
104
125
|
return context.state.steps.filter((step) => step.nodeId === "check" && step.outcome === "ok")
|
|
105
126
|
.length;
|
|
@@ -190,6 +211,7 @@ export default defineWorkflow({
|
|
|
190
211
|
}),
|
|
191
212
|
check: agent({
|
|
192
213
|
statusDetail: "checking monitored target",
|
|
214
|
+
timeoutMs: agentTimeoutMs,
|
|
193
215
|
prompt: (context) => {
|
|
194
216
|
const config = configFrom(context.outputs);
|
|
195
217
|
const previous = context.outputs.check as MonitorCheck | undefined;
|
|
@@ -212,12 +234,14 @@ export default defineWorkflow({
|
|
|
212
234
|
}),
|
|
213
235
|
report_continue: agent({
|
|
214
236
|
statusDetail: "reporting monitor update",
|
|
237
|
+
timeoutMs: agentTimeoutMs,
|
|
215
238
|
prompt: ({ outputs }) => reportPrompt(outputs),
|
|
216
239
|
expectedOutput: '{ "reported": true }',
|
|
217
240
|
validate: (output) => validateReportAck(output),
|
|
218
241
|
}),
|
|
219
242
|
report_stop: agent({
|
|
220
243
|
statusDetail: "reporting final monitor update",
|
|
244
|
+
timeoutMs: agentTimeoutMs,
|
|
221
245
|
prompt: ({ outputs }) => reportPrompt(outputs),
|
|
222
246
|
expectedOutput: '{ "reported": true }',
|
|
223
247
|
validate: (output) => validateReportAck(output),
|
|
@@ -101,8 +101,10 @@ type WorkflowRunQueueRow = {
|
|
|
101
101
|
/** A user-started workflow run tracked by the durable run queue. */
|
|
102
102
|
export type WorkflowRunQueueRecord = {
|
|
103
103
|
runId: string;
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
/** Human-readable workflow name used in status and event output. */
|
|
105
|
+
workflowName: string;
|
|
106
|
+
/** Canonical source reference used to reopen the run. */
|
|
107
|
+
workflowSourceRef: string;
|
|
106
108
|
input: unknown;
|
|
107
109
|
status: "claimed" | "parked" | "done";
|
|
108
110
|
runnerId: string | null;
|
|
@@ -866,8 +868,8 @@ export class SqliteControllerStore implements ControllerStore {
|
|
|
866
868
|
*/
|
|
867
869
|
enqueueWorkflowRun(options: {
|
|
868
870
|
runId: string;
|
|
869
|
-
|
|
870
|
-
|
|
871
|
+
workflowName: string;
|
|
872
|
+
workflowSourceRef: string;
|
|
871
873
|
input: unknown;
|
|
872
874
|
runnerId: string;
|
|
873
875
|
claimToken: string;
|
|
@@ -877,8 +879,8 @@ export class SqliteControllerStore implements ControllerStore {
|
|
|
877
879
|
now?: string;
|
|
878
880
|
}): WorkflowRunQueueRecord {
|
|
879
881
|
validateRunId(options.runId);
|
|
880
|
-
validateKey(options.
|
|
881
|
-
validateKey(options.
|
|
882
|
+
validateKey(options.workflowName, "workflow name");
|
|
883
|
+
validateKey(options.workflowSourceRef, "workflow source ref");
|
|
882
884
|
validateKey(options.runnerId, "runner id");
|
|
883
885
|
validateKey(options.claimToken, "claim token");
|
|
884
886
|
validateDuration(options.leaseMs, "leaseMs");
|
|
@@ -896,8 +898,8 @@ export class SqliteControllerStore implements ControllerStore {
|
|
|
896
898
|
)
|
|
897
899
|
.run(
|
|
898
900
|
options.runId,
|
|
899
|
-
options.
|
|
900
|
-
options.
|
|
901
|
+
options.workflowName,
|
|
902
|
+
options.workflowSourceRef,
|
|
901
903
|
inputJson,
|
|
902
904
|
options.runnerId,
|
|
903
905
|
options.claimToken,
|
|
@@ -1068,6 +1070,122 @@ export class SqliteControllerStore implements ControllerStore {
|
|
|
1068
1070
|
return result.changes === 1;
|
|
1069
1071
|
}
|
|
1070
1072
|
|
|
1073
|
+
/** Repair a canonical bundle's queue source and claim it only when needed. */
|
|
1074
|
+
repairCanonicalWorkflowSourceRun(options: {
|
|
1075
|
+
runId: string;
|
|
1076
|
+
workflowName: string;
|
|
1077
|
+
workflowSourceRef: string;
|
|
1078
|
+
runnerId: string;
|
|
1079
|
+
claimToken: string;
|
|
1080
|
+
leaseMs: number;
|
|
1081
|
+
now?: string;
|
|
1082
|
+
}): "unchanged" | "claimed" | false {
|
|
1083
|
+
validateRunId(options.runId);
|
|
1084
|
+
validateKey(options.workflowName, "workflow name");
|
|
1085
|
+
validateKey(options.workflowSourceRef, "workflow source ref");
|
|
1086
|
+
validateKey(options.runnerId, "runner id");
|
|
1087
|
+
validateKey(options.claimToken, "claim token");
|
|
1088
|
+
validateDuration(options.leaseMs, "leaseMs");
|
|
1089
|
+
const now = validTimestamp(options.now);
|
|
1090
|
+
const nowMs = epoch(now);
|
|
1091
|
+
const expiresAt = nowMs + options.leaseMs;
|
|
1092
|
+
return this.transaction(() => {
|
|
1093
|
+
const row = this.database
|
|
1094
|
+
.prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
|
|
1095
|
+
.get(options.runId) as WorkflowRunQueueRow | undefined;
|
|
1096
|
+
if (row === undefined || row.status === "done") return false;
|
|
1097
|
+
if (row.workflow_path === options.workflowSourceRef && row.status === "parked") {
|
|
1098
|
+
return "unchanged";
|
|
1099
|
+
}
|
|
1100
|
+
const claimable =
|
|
1101
|
+
row.status === "parked" ||
|
|
1102
|
+
(row.status === "claimed" &&
|
|
1103
|
+
row.claim_token === options.claimToken &&
|
|
1104
|
+
row.claim_expires_at !== null &&
|
|
1105
|
+
row.claim_expires_at > nowMs) ||
|
|
1106
|
+
(row.status === "claimed" &&
|
|
1107
|
+
row.claim_expires_at !== null &&
|
|
1108
|
+
row.claim_expires_at <= nowMs);
|
|
1109
|
+
if (!claimable) return false;
|
|
1110
|
+
const result = this.database
|
|
1111
|
+
.prepare(
|
|
1112
|
+
`UPDATE workflow_run_queue
|
|
1113
|
+
SET workflow_ref = ?, workflow_path = ?, status = 'claimed', runner_id = ?,
|
|
1114
|
+
claim_token = ?, claim_expires_at = ?, updated_at = ?
|
|
1115
|
+
WHERE run_id = ? AND status != 'done'`,
|
|
1116
|
+
)
|
|
1117
|
+
.run(
|
|
1118
|
+
options.workflowName,
|
|
1119
|
+
options.workflowSourceRef,
|
|
1120
|
+
options.runnerId,
|
|
1121
|
+
options.claimToken,
|
|
1122
|
+
expiresAt,
|
|
1123
|
+
now,
|
|
1124
|
+
options.runId,
|
|
1125
|
+
);
|
|
1126
|
+
return result.changes === 1 ? "claimed" : false;
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/** Claim and rewrite one proved legacy workflow source queue row atomically. */
|
|
1131
|
+
claimLegacyWorkflowSourceRun(options: {
|
|
1132
|
+
runId: string;
|
|
1133
|
+
workflowName: string;
|
|
1134
|
+
oldWorkflowPath: string;
|
|
1135
|
+
workflowSourceRef: string;
|
|
1136
|
+
runnerId: string;
|
|
1137
|
+
claimToken: string;
|
|
1138
|
+
leaseMs: number;
|
|
1139
|
+
now?: string;
|
|
1140
|
+
}): boolean {
|
|
1141
|
+
validateRunId(options.runId);
|
|
1142
|
+
validateKey(options.workflowName, "workflow name");
|
|
1143
|
+
validateKey(options.oldWorkflowPath, "legacy workflow path");
|
|
1144
|
+
validateKey(options.workflowSourceRef, "workflow source ref");
|
|
1145
|
+
validateKey(options.runnerId, "runner id");
|
|
1146
|
+
validateKey(options.claimToken, "claim token");
|
|
1147
|
+
validateDuration(options.leaseMs, "leaseMs");
|
|
1148
|
+
const now = validTimestamp(options.now);
|
|
1149
|
+
const nowMs = epoch(now);
|
|
1150
|
+
const expiresAt = nowMs + options.leaseMs;
|
|
1151
|
+
return this.transaction(() => {
|
|
1152
|
+
const row = this.database
|
|
1153
|
+
.prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
|
|
1154
|
+
.get(options.runId) as WorkflowRunQueueRow | undefined;
|
|
1155
|
+
if (row === undefined || row.status === "done") return false;
|
|
1156
|
+
const sourceMatches =
|
|
1157
|
+
row.workflow_path === options.oldWorkflowPath ||
|
|
1158
|
+
row.workflow_path === options.workflowSourceRef;
|
|
1159
|
+
const claimable =
|
|
1160
|
+
row.status === "parked" ||
|
|
1161
|
+
(row.status === "claimed" &&
|
|
1162
|
+
row.claim_token === options.claimToken &&
|
|
1163
|
+
row.claim_expires_at !== null &&
|
|
1164
|
+
row.claim_expires_at > nowMs) ||
|
|
1165
|
+
(row.status === "claimed" &&
|
|
1166
|
+
row.claim_expires_at !== null &&
|
|
1167
|
+
row.claim_expires_at <= nowMs);
|
|
1168
|
+
if (!sourceMatches || !claimable) return false;
|
|
1169
|
+
const result = this.database
|
|
1170
|
+
.prepare(
|
|
1171
|
+
`UPDATE workflow_run_queue
|
|
1172
|
+
SET workflow_ref = ?, workflow_path = ?, status = 'claimed', runner_id = ?,
|
|
1173
|
+
claim_token = ?, claim_expires_at = ?, updated_at = ?
|
|
1174
|
+
WHERE run_id = ? AND status != 'done'`,
|
|
1175
|
+
)
|
|
1176
|
+
.run(
|
|
1177
|
+
options.workflowName,
|
|
1178
|
+
options.workflowSourceRef,
|
|
1179
|
+
options.runnerId,
|
|
1180
|
+
options.claimToken,
|
|
1181
|
+
expiresAt,
|
|
1182
|
+
now,
|
|
1183
|
+
options.runId,
|
|
1184
|
+
);
|
|
1185
|
+
return result.changes === 1;
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1071
1189
|
/** Append a run lifecycle transition to the event feed. */
|
|
1072
1190
|
recordRunEvent(options: {
|
|
1073
1191
|
runId: string;
|
|
@@ -1332,8 +1450,8 @@ function workflowFromRow(row: WorkflowRow): ChildWorkflowRecord {
|
|
|
1332
1450
|
function workflowRunFromRow(row: WorkflowRunQueueRow): WorkflowRunQueueRecord {
|
|
1333
1451
|
return {
|
|
1334
1452
|
runId: row.run_id,
|
|
1335
|
-
|
|
1336
|
-
|
|
1453
|
+
workflowName: row.workflow_ref,
|
|
1454
|
+
workflowSourceRef: row.workflow_path,
|
|
1337
1455
|
input: parseStoredJson(row.input_json, "workflow run input"),
|
|
1338
1456
|
status: row.status,
|
|
1339
1457
|
runnerId: row.runner_id,
|
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
WorkflowDefinition,
|
|
5
5
|
WorkflowRunResult,
|
|
6
6
|
WorkflowRunStatus,
|
|
7
|
+
WorkflowSource,
|
|
7
8
|
} from "../workflows/types.js";
|
|
8
9
|
import type {
|
|
9
10
|
ControllerWorkflowScheduler,
|
|
@@ -13,7 +14,7 @@ import type {
|
|
|
13
14
|
|
|
14
15
|
export type ResolvedChildWorkflow = {
|
|
15
16
|
workflow: WorkflowDefinition;
|
|
16
|
-
|
|
17
|
+
workflowSource?: WorkflowSource;
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
export type WorkflowEngineSchedulerOptions = {
|
|
@@ -77,7 +78,9 @@ export class WorkflowEngineScheduler implements ControllerWorkflowScheduler {
|
|
|
77
78
|
const promise = engine
|
|
78
79
|
.run(resolved.workflow, request.input, {
|
|
79
80
|
runId,
|
|
80
|
-
...(resolved.
|
|
81
|
+
...(resolved.workflowSource !== undefined
|
|
82
|
+
? { workflowSource: resolved.workflowSource }
|
|
83
|
+
: {}),
|
|
81
84
|
})
|
|
82
85
|
.then((result) => {
|
|
83
86
|
callCompletion(onComplete, resultFromRun(result));
|
|
@@ -33,6 +33,8 @@ export type ConversationStepExecutorOptions = {
|
|
|
33
33
|
maxNudges?: number;
|
|
34
34
|
/** Conversation linkage hooks, wired to the session recorder. */
|
|
35
35
|
conversation?: ConversationHooks;
|
|
36
|
+
/** Called when the engine aborts a pending agent step. */
|
|
37
|
+
onAbort?: (contract: AgentStepRequest["contract"], reason: unknown) => void;
|
|
36
38
|
};
|
|
37
39
|
|
|
38
40
|
type PendingStep = {
|
|
@@ -61,6 +63,7 @@ export class ConversationStepExecutor implements AgentStepExecutor {
|
|
|
61
63
|
private readonly sendPrompt: (delivery: PromptDelivery) => void;
|
|
62
64
|
private readonly maxNudges: number;
|
|
63
65
|
private readonly conversation: ConversationHooks | undefined;
|
|
66
|
+
private readonly onAbort: ConversationStepExecutorOptions["onAbort"];
|
|
64
67
|
private pending: PendingStep | null = null;
|
|
65
68
|
private streaming = false;
|
|
66
69
|
private heldByUser = false;
|
|
@@ -69,6 +72,7 @@ export class ConversationStepExecutor implements AgentStepExecutor {
|
|
|
69
72
|
this.sendPrompt = options.sendPrompt;
|
|
70
73
|
this.maxNudges = options.maxNudges ?? DEFAULT_MAX_NUDGES;
|
|
71
74
|
this.conversation = options.conversation;
|
|
75
|
+
this.onAbort = options.onAbort;
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
/** Track agent streaming state (wire to agent_start / agent_settled). */
|
|
@@ -121,8 +125,15 @@ export class ConversationStepExecutor implements AgentStepExecutor {
|
|
|
121
125
|
return await new Promise<AgentStepSubmission>((resolve, reject) => {
|
|
122
126
|
const onAbort = () => {
|
|
123
127
|
const reason: unknown = signal.reason ?? new Error("Workflow step aborted");
|
|
128
|
+
if (this.pending?.request !== request) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
124
131
|
this.clearPending();
|
|
125
|
-
|
|
132
|
+
try {
|
|
133
|
+
this.onAbort?.(request.contract, reason);
|
|
134
|
+
} finally {
|
|
135
|
+
reject(reason);
|
|
136
|
+
}
|
|
126
137
|
};
|
|
127
138
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
128
139
|
let markCleared!: () => void;
|
package/src/extension/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
3
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { builtinWorkflowCatalog } from "../builtins/catalog.js";
|
|
3
5
|
import {
|
|
4
6
|
projectControllerStorePath,
|
|
5
7
|
type RunEventRecord,
|
|
@@ -8,13 +10,14 @@ import {
|
|
|
8
10
|
import type { JsonObject } from "../controllers/types.js";
|
|
9
11
|
import type { WorkflowSchedulerResult } from "../controllers/workflows.js";
|
|
10
12
|
import { WorkflowEngine } from "../workflows/engine.js";
|
|
11
|
-
import { ClaimLostError, errorMessage, isClaimLostError } from "../workflows/errors.js";
|
|
12
13
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
} from "../workflows/
|
|
14
|
+
ClaimLostError,
|
|
15
|
+
errorMessage,
|
|
16
|
+
isClaimLostError,
|
|
17
|
+
TimeoutError,
|
|
18
|
+
} from "../workflows/errors.js";
|
|
19
|
+
import { discoverWorkflows, resolveWorkflowRef } from "../workflows/loader.js";
|
|
20
|
+
import { migrateLegacyWorkflowSources } from "../workflows/migrate-sources.js";
|
|
18
21
|
import {
|
|
19
22
|
createRunId,
|
|
20
23
|
listRunBundles,
|
|
@@ -24,6 +27,7 @@ import {
|
|
|
24
27
|
createDefinitionSnapshot,
|
|
25
28
|
} from "../workflows/store.js";
|
|
26
29
|
import type {
|
|
30
|
+
AgentStepContract,
|
|
27
31
|
WorkflowDefinition,
|
|
28
32
|
WorkflowDefinitionSnapshot,
|
|
29
33
|
WorkflowRunResult,
|
|
@@ -207,6 +211,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
207
211
|
// One runner identity per session; it names this session in run claims.
|
|
208
212
|
const runnerId = randomUUID();
|
|
209
213
|
let runQueueStore: SqliteControllerStore | null = null;
|
|
214
|
+
const migrationBlockedRuns = new Set<string>();
|
|
210
215
|
const ensureRunQueueStore = (cwd: string): SqliteControllerStore => {
|
|
211
216
|
runQueueStore ??= new SqliteControllerStore(projectControllerStorePath(cwd));
|
|
212
217
|
return runQueueStore;
|
|
@@ -308,7 +313,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
308
313
|
const rows = runQueueStore.listWorkflowRuns();
|
|
309
314
|
for (const row of rows) {
|
|
310
315
|
if (row.status === "parked") {
|
|
311
|
-
lines.push(`${row.
|
|
316
|
+
lines.push(`${row.workflowName} run ${row.runId} is parked and will resume`);
|
|
312
317
|
}
|
|
313
318
|
}
|
|
314
319
|
const known = new Set(rows.map((row) => row.runId));
|
|
@@ -348,6 +353,8 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
348
353
|
runSyncTimer.unref?.();
|
|
349
354
|
};
|
|
350
355
|
let activeRun: ActiveRun | null = null;
|
|
356
|
+
let systemTurnAbort: AgentStepContract | null = null;
|
|
357
|
+
let lastExpiredAttempt: { contract: AgentStepContract; reason: string } | null = null;
|
|
351
358
|
let pendingToolLaunch: {
|
|
352
359
|
ctx: ExtensionContext;
|
|
353
360
|
ref: string;
|
|
@@ -506,7 +513,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
506
513
|
if (
|
|
507
514
|
sessionClosed ||
|
|
508
515
|
run.generation !== runGeneration ||
|
|
509
|
-
state.status
|
|
516
|
+
(state.status !== "completed" && state.status !== "waiting") ||
|
|
510
517
|
run.presentationPrompt === undefined
|
|
511
518
|
) {
|
|
512
519
|
return;
|
|
@@ -639,7 +646,9 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
639
646
|
const summary =
|
|
640
647
|
state.status === "waiting" && state.waitingOn
|
|
641
648
|
? `Workflow ${state.workflowName} parked at checkpoint ${state.waitingOn} — answer with /workflow answer <json> (run ${state.runId})`
|
|
642
|
-
: `Workflow ${state.workflowName} ${state.status} (run ${state.runId})
|
|
649
|
+
: `Workflow ${state.workflowName} ${state.status} (run ${state.runId})${
|
|
650
|
+
state.error !== undefined ? `: ${state.error.slice(0, MAX_STATUS_ERROR_CHARS)}` : ""
|
|
651
|
+
}`;
|
|
643
652
|
notify(ctx, summary, state.status === "completed" ? "info" : "warning");
|
|
644
653
|
try {
|
|
645
654
|
const childResult =
|
|
@@ -689,13 +698,13 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
689
698
|
}
|
|
690
699
|
supersedePresentation();
|
|
691
700
|
const generation = runGeneration;
|
|
692
|
-
const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd });
|
|
693
|
-
const workflow =
|
|
701
|
+
const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd }, builtinWorkflowCatalog);
|
|
702
|
+
const workflow = resolved.definition;
|
|
694
703
|
if (options.signal?.aborted) {
|
|
695
704
|
throw options.signal.reason ?? new Error("Workflow startup aborted");
|
|
696
705
|
}
|
|
697
706
|
const snapshot = createDefinitionSnapshot(workflow);
|
|
698
|
-
const
|
|
707
|
+
const workflowSource = resolved.source;
|
|
699
708
|
const runId = options.runId ?? createRunId(workflow.name);
|
|
700
709
|
|
|
701
710
|
// Continuations validate the parent before touching the queue: a
|
|
@@ -706,7 +715,10 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
706
715
|
if (parent === null || parent.state.status !== "waiting") {
|
|
707
716
|
throw new Error(`Workflow run ${options.parentRunId} is not waiting at a checkpoint`);
|
|
708
717
|
}
|
|
709
|
-
if (
|
|
718
|
+
if (
|
|
719
|
+
parent.state.workflowSource !== undefined &&
|
|
720
|
+
!isDeepStrictEqual(parent.state.workflowSource, workflowSource)
|
|
721
|
+
) {
|
|
710
722
|
throw new Error(
|
|
711
723
|
`Workflow source changed since run ${options.parentRunId} started; revert the edit to answer its checkpoint`,
|
|
712
724
|
);
|
|
@@ -725,8 +737,11 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
725
737
|
const token = randomUUID();
|
|
726
738
|
queueStore.enqueueWorkflowRun({
|
|
727
739
|
runId,
|
|
728
|
-
|
|
729
|
-
|
|
740
|
+
workflowName: workflow.name,
|
|
741
|
+
workflowSourceRef:
|
|
742
|
+
workflowSource.kind === "builtin"
|
|
743
|
+
? `builtin:${workflowSource.id}`
|
|
744
|
+
: workflowSource.path,
|
|
730
745
|
input,
|
|
731
746
|
runnerId,
|
|
732
747
|
claimToken: token,
|
|
@@ -757,6 +772,16 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
757
772
|
sendPrompt: ({ prompt, streaming }) => {
|
|
758
773
|
pi.sendUserMessage(prompt, streaming ? { deliverAs: "steer" } : undefined);
|
|
759
774
|
},
|
|
775
|
+
onAbort: (contract, reason) => {
|
|
776
|
+
lastExpiredAttempt = {
|
|
777
|
+
contract,
|
|
778
|
+
reason: reason instanceof TimeoutError ? "timed out" : `ended: ${errorMessage(reason)}`,
|
|
779
|
+
};
|
|
780
|
+
if (!executor.held && !ctx.isIdle()) {
|
|
781
|
+
systemTurnAbort = contract;
|
|
782
|
+
ctx.abort();
|
|
783
|
+
}
|
|
784
|
+
},
|
|
760
785
|
conversation: {
|
|
761
786
|
beginAttempt: (contract) => run.recorder?.beginAttempt(contract),
|
|
762
787
|
mark: () => run.recorder?.mark() ?? 0,
|
|
@@ -840,12 +865,11 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
840
865
|
|
|
841
866
|
run.completion = (
|
|
842
867
|
options.resume === true
|
|
843
|
-
? engine.resumeRun(workflow, runId, {
|
|
868
|
+
? engine.resumeRun(workflow, runId, { workflowSource })
|
|
844
869
|
: options.parentRunId === undefined
|
|
845
|
-
? engine.run(workflow, input, {
|
|
870
|
+
? engine.run(workflow, input, { workflowSource, runId })
|
|
846
871
|
: engine.continueRun(workflow, options.parentRunId, input, {
|
|
847
|
-
|
|
848
|
-
workflowHash,
|
|
872
|
+
workflowSource,
|
|
849
873
|
runId,
|
|
850
874
|
})
|
|
851
875
|
)
|
|
@@ -955,13 +979,21 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
955
979
|
runnerId,
|
|
956
980
|
claimToken,
|
|
957
981
|
leaseMs: RUN_CLAIM_LEASE_MS,
|
|
982
|
+
excludeRunIds: [...migrationBlockedRuns],
|
|
958
983
|
});
|
|
959
984
|
if (claimed === undefined) {
|
|
960
985
|
return;
|
|
961
986
|
}
|
|
962
987
|
let started: string | undefined;
|
|
963
988
|
try {
|
|
964
|
-
|
|
989
|
+
const bundle = await readRunBundle(new WorkflowRunStore().runDirFor(claimed.runId));
|
|
990
|
+
const sourceRef =
|
|
991
|
+
bundle?.state.workflowSource === undefined
|
|
992
|
+
? claimed.workflowSourceRef
|
|
993
|
+
: bundle.state.workflowSource.kind === "builtin"
|
|
994
|
+
? `builtin:${bundle.state.workflowSource.id}`
|
|
995
|
+
: bundle.state.workflowSource.path;
|
|
996
|
+
started = await startRun(ctx, sourceRef, claimed.input, {
|
|
965
997
|
resume: true,
|
|
966
998
|
runId: claimed.runId,
|
|
967
999
|
claimToken,
|
|
@@ -971,7 +1003,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
971
1003
|
throw error;
|
|
972
1004
|
}
|
|
973
1005
|
if (started !== undefined) {
|
|
974
|
-
notify(ctx, `Resumed workflow run ${claimed.runId} (${claimed.
|
|
1006
|
+
notify(ctx, `Resumed workflow run ${claimed.runId} (${claimed.workflowName}).`);
|
|
975
1007
|
} else {
|
|
976
1008
|
queueStore.parkWorkflowRun({ runId: claimed.runId, claimToken });
|
|
977
1009
|
}
|
|
@@ -1051,7 +1083,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1051
1083
|
ctx: ExtensionContext,
|
|
1052
1084
|
offset = 0,
|
|
1053
1085
|
): Promise<WorkflowControlResult> => {
|
|
1054
|
-
const discovered = await discoverWorkflows({ cwd: ctx.cwd });
|
|
1086
|
+
const discovered = await discoverWorkflows({ cwd: ctx.cwd }, builtinWorkflowCatalog);
|
|
1055
1087
|
if (discovered.length === 0) {
|
|
1056
1088
|
return {
|
|
1057
1089
|
message:
|
|
@@ -1255,7 +1287,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1255
1287
|
const resolveWaitingWorkflow = async (
|
|
1256
1288
|
ctx: ExtensionContext,
|
|
1257
1289
|
requestedRunId?: string,
|
|
1258
|
-
): Promise<{ parentRunId: string;
|
|
1290
|
+
): Promise<{ parentRunId: string; workflowRef: string }> => {
|
|
1259
1291
|
let parentRunId = requestedRunId ?? lastWaitingRunId;
|
|
1260
1292
|
if (parentRunId === null) {
|
|
1261
1293
|
const rows = ensureRunQueueStore(ctx.cwd).listWorkflowRuns();
|
|
@@ -1279,14 +1311,20 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1279
1311
|
if (
|
|
1280
1312
|
parent === null ||
|
|
1281
1313
|
parent.state.status !== "waiting" ||
|
|
1282
|
-
parent.state.
|
|
1314
|
+
parent.state.workflowSource === undefined
|
|
1283
1315
|
) {
|
|
1284
1316
|
if (parentRunId === lastWaitingRunId) {
|
|
1285
1317
|
lastWaitingRunId = null;
|
|
1286
1318
|
}
|
|
1287
1319
|
throw new Error(`Workflow run ${parentRunId} is no longer waiting.`);
|
|
1288
1320
|
}
|
|
1289
|
-
return {
|
|
1321
|
+
return {
|
|
1322
|
+
parentRunId,
|
|
1323
|
+
workflowRef:
|
|
1324
|
+
parent.state.workflowSource.kind === "builtin"
|
|
1325
|
+
? `builtin:${parent.state.workflowSource.id}`
|
|
1326
|
+
: parent.state.workflowSource.path,
|
|
1327
|
+
};
|
|
1290
1328
|
};
|
|
1291
1329
|
|
|
1292
1330
|
const answerWorkflowControl = async (
|
|
@@ -1295,7 +1333,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1295
1333
|
requestedRunId?: string,
|
|
1296
1334
|
): Promise<WorkflowControlResult> => {
|
|
1297
1335
|
const waiting = await resolveWaitingWorkflow(ctx, requestedRunId);
|
|
1298
|
-
const continued = await startRun(ctx, waiting.
|
|
1336
|
+
const continued = await startRun(ctx, waiting.workflowRef, input, {
|
|
1299
1337
|
parentRunId: waiting.parentRunId,
|
|
1300
1338
|
});
|
|
1301
1339
|
if (continued === undefined) {
|
|
@@ -1356,8 +1394,8 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1356
1394
|
const reservation = { ctx, ref, input, options };
|
|
1357
1395
|
pendingToolLaunch = reservation;
|
|
1358
1396
|
try {
|
|
1359
|
-
const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd });
|
|
1360
|
-
const workflow =
|
|
1397
|
+
const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd }, builtinWorkflowCatalog);
|
|
1398
|
+
const workflow = resolved.definition;
|
|
1361
1399
|
if (pendingToolLaunch !== reservation) {
|
|
1362
1400
|
throw new Error("The queued workflow launch was cancelled before validation finished.");
|
|
1363
1401
|
}
|
|
@@ -1382,7 +1420,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1382
1420
|
description:
|
|
1383
1421
|
"Run or manage a workflow: /workflow <name-or-path> [task | --input-json {…}]; also: status, pause, resume, cancel, answer",
|
|
1384
1422
|
getArgumentCompletions: async (prefix: string) => {
|
|
1385
|
-
const discovered = await discoverWorkflows({ cwd: process.cwd() });
|
|
1423
|
+
const discovered = await discoverWorkflows({ cwd: process.cwd() }, builtinWorkflowCatalog);
|
|
1386
1424
|
const items = [
|
|
1387
1425
|
...discovered.map((workflow) => ({ value: workflow.name, label: workflow.name })),
|
|
1388
1426
|
{ value: "status", label: "status" },
|
|
@@ -1556,13 +1594,21 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1556
1594
|
break;
|
|
1557
1595
|
case "answer": {
|
|
1558
1596
|
const waiting = await resolveWaitingWorkflow(ctx, params.runId);
|
|
1559
|
-
control = await queueToolLaunch(ctx, waiting.
|
|
1597
|
+
control = await queueToolLaunch(ctx, waiting.workflowRef, params.input, {
|
|
1560
1598
|
parentRunId: waiting.parentRunId,
|
|
1561
1599
|
});
|
|
1562
1600
|
break;
|
|
1563
1601
|
}
|
|
1564
1602
|
case "submit": {
|
|
1565
1603
|
if (!activeRun) {
|
|
1604
|
+
if (
|
|
1605
|
+
lastExpiredAttempt?.contract.attemptId === params.attempt &&
|
|
1606
|
+
lastExpiredAttempt.contract.nodeId === params.step
|
|
1607
|
+
) {
|
|
1608
|
+
throw new Error(
|
|
1609
|
+
`Workflow step ${JSON.stringify(params.step)} attempt ${JSON.stringify(params.attempt)} ${lastExpiredAttempt.reason}; its output is no longer accepted.`,
|
|
1610
|
+
);
|
|
1611
|
+
}
|
|
1566
1612
|
throw new Error("No workflow step is waiting for output.");
|
|
1567
1613
|
}
|
|
1568
1614
|
// Flush the conversation into the bundle before accepting, so the
|
|
@@ -1603,6 +1649,24 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1603
1649
|
pi.on("session_start", async (_event, ctx) => {
|
|
1604
1650
|
sessionClosed = false;
|
|
1605
1651
|
controllerContext = ctx;
|
|
1652
|
+
try {
|
|
1653
|
+
const queue = ensureRunQueueStore(ctx.cwd);
|
|
1654
|
+
const migration = await migrateLegacyWorkflowSources({
|
|
1655
|
+
catalog: builtinWorkflowCatalog,
|
|
1656
|
+
queue,
|
|
1657
|
+
});
|
|
1658
|
+
migrationBlockedRuns.clear();
|
|
1659
|
+
for (const blocked of migration.blocked) migrationBlockedRuns.add(blocked.runId);
|
|
1660
|
+
if (migration.blocked.length > 0) {
|
|
1661
|
+
notify(
|
|
1662
|
+
ctx,
|
|
1663
|
+
`Could not migrate ${migration.blocked.length} legacy workflow source(s).`,
|
|
1664
|
+
"warning",
|
|
1665
|
+
);
|
|
1666
|
+
}
|
|
1667
|
+
} catch (error) {
|
|
1668
|
+
notify(ctx, `Could not migrate legacy workflow sources: ${errorMessage(error)}`, "warning");
|
|
1669
|
+
}
|
|
1606
1670
|
try {
|
|
1607
1671
|
syncArmed = true;
|
|
1608
1672
|
startRunSync(ctx);
|
|
@@ -1632,13 +1696,6 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1632
1696
|
});
|
|
1633
1697
|
|
|
1634
1698
|
pi.on("agent_end", (event, ctx) => {
|
|
1635
|
-
const run = activeRun;
|
|
1636
|
-
if (!run) {
|
|
1637
|
-
return;
|
|
1638
|
-
}
|
|
1639
|
-
// An aborted turn means the user hit escape to take the conversation
|
|
1640
|
-
// back. Nudging or dispatching the next step would immediately steal it
|
|
1641
|
-
// again, so hold the run until an explicit /workflow resume.
|
|
1642
1699
|
const aborted = event.messages.some(
|
|
1643
1700
|
(message) =>
|
|
1644
1701
|
typeof message === "object" &&
|
|
@@ -1646,6 +1703,17 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1646
1703
|
"stopReason" in message &&
|
|
1647
1704
|
(message as { stopReason?: string }).stopReason === "aborted",
|
|
1648
1705
|
);
|
|
1706
|
+
if (aborted && systemTurnAbort !== null) {
|
|
1707
|
+
systemTurnAbort = null;
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
const run = activeRun;
|
|
1711
|
+
if (!run) {
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
// An aborted turn means the user hit escape to take the conversation
|
|
1715
|
+
// back. Nudging or dispatching the next step would immediately steal it
|
|
1716
|
+
// again, so hold the run until an explicit /workflow resume.
|
|
1649
1717
|
if (!aborted || runHeld()) {
|
|
1650
1718
|
return;
|
|
1651
1719
|
}
|
|
@@ -1719,6 +1787,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1719
1787
|
|
|
1720
1788
|
pi.on("session_shutdown", async () => {
|
|
1721
1789
|
sessionClosed = true;
|
|
1790
|
+
systemTurnAbort = null;
|
|
1722
1791
|
supersedePresentation();
|
|
1723
1792
|
const run = activeRun;
|
|
1724
1793
|
if (run !== null && run.claimToken !== undefined) {
|