pi-workflow-engine 1.0.0 → 1.2.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/.pi/extensions/pi-workflow-engine/index.ts +236 -94
- package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +9 -6
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +10 -3
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +18 -4
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +71 -23
- package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +12 -3
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +51 -8
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +14 -0
- package/.pi/extensions/pi-workflow-engine/src/classify.ts +107 -0
- package/.pi/extensions/pi-workflow-engine/src/codemode-slots.ts +73 -0
- package/.pi/extensions/pi-workflow-engine/src/config-file.ts +52 -0
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +127 -58
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -40
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +78 -18
- package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +5 -0
- package/.pi/extensions/pi-workflow-engine/src/host-tools.ts +76 -0
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +7 -1
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +22 -4
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +9 -47
- package/.pi/extensions/pi-workflow-engine/src/model-ref.ts +19 -0
- package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +2 -2
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +32 -4
- package/.pi/extensions/pi-workflow-engine/src/prompt-sections.ts +15 -0
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +8 -6
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +15 -3
- package/.pi/extensions/pi-workflow-engine/src/types.ts +68 -9
- package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +9 -3
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +69 -18
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +17 -6
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +8 -3
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +16 -8
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +20 -2
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +123 -9
- package/.pi/extensions/pi-workflow-engine/src/workflow-catalog.ts +79 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +6 -2
- package/.pi/extensions/pi-workflow-engine/src/workflow-input.ts +13 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +21 -4
- package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +26 -6
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +2 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +69 -13
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +23 -4
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +27 -1
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-seal.ts +83 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +30 -4
- package/.pi/extensions/pi-workflow-engine/src/workflow-script-file.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-settings.ts +147 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-tool-request.ts +61 -0
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +12 -4
- package/README.md +12 -4
- package/USAGE.md +167 -23
- package/package.json +13 -4
- package/skills/workflow-authoring/SKILL.md +160 -0
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
7
7
|
import { BackgroundWorkflowCoordinator } from "./background-workflows.ts";
|
|
8
8
|
import { backgroundUnavailableResult, startBackgroundWorkflowTool } from "./background-workflow-tool.ts";
|
|
9
|
-
import { validateWorkflowRunId } from "./journal.ts";
|
|
9
|
+
import { validateWorkflowRunId, WORKFLOW_RUNS_DIR } from "./journal.ts";
|
|
10
10
|
import { resolveWorkflowRunOptions, type ResolvedWorkflowRunOptions } from "./options.ts";
|
|
11
11
|
import type { LoadedWorkflow } from "./types.ts";
|
|
12
12
|
import {
|
|
@@ -17,15 +17,19 @@ import {
|
|
|
17
17
|
formatWorkflowRunSummary,
|
|
18
18
|
isWorkflowRunLifecycleAction,
|
|
19
19
|
parseWorkflowRunsCommand,
|
|
20
|
+
isInlineWorkflowRun,
|
|
20
21
|
retainedWorkflowRunOutcome,
|
|
21
22
|
supersedingRuns,
|
|
22
23
|
WORKFLOW_RUN_ACTIONS,
|
|
23
24
|
WORKFLOW_RUN_HISTORY_LIMIT,
|
|
25
|
+
workflowRunDisplayName,
|
|
24
26
|
type WorkflowRunActionContext,
|
|
25
27
|
type WorkflowRunLifecycleAction,
|
|
26
28
|
} from "./workflow-run-history.ts";
|
|
27
|
-
import { stopWorkflowRunRecord, type WorkflowRunRecord } from "./workflow-run-record.ts";
|
|
28
|
-
import {
|
|
29
|
+
import { stopWorkflowRunRecord, workflowRunScriptFile, type WorkflowRunRecord } from "./workflow-run-record.ts";
|
|
30
|
+
import { verifyWorkflowRunScriptSeal } from "./workflow-run-seal.ts";
|
|
31
|
+
import { ProjectWorkflowRunStore, readWorkflowRunScript, type WorkflowRunStore } from "./workflow-run-store.ts";
|
|
32
|
+
import { compileInlineWorkflow, inlineScriptFingerprint } from "./inline-workflow.ts";
|
|
29
33
|
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
30
34
|
import {
|
|
31
35
|
WorkflowUsageLimitScheduler,
|
|
@@ -39,7 +43,8 @@ import { completeCurrentArgument, splitArgumentPrefix } from "./command-completi
|
|
|
39
43
|
type WorkflowRunCompletionContext = Pick<ExtensionContext, "cwd" | "sessionManager">;
|
|
40
44
|
|
|
41
45
|
interface WorkflowRunControllerDependencies {
|
|
42
|
-
|
|
46
|
+
/** The registered workflow a relaunch runs; `ctx` decides which project's saved workflows are visible. */
|
|
47
|
+
readonly resolveWorkflow: (name: string, ctx: ExtensionContext) => Promise<LoadedWorkflow | undefined>;
|
|
43
48
|
readonly execute: (
|
|
44
49
|
ctx: ExtensionContext,
|
|
45
50
|
name: string,
|
|
@@ -160,7 +165,7 @@ export class WorkflowRunController {
|
|
|
160
165
|
}
|
|
161
166
|
const actions = availableWorkflowRunActions(record, runs);
|
|
162
167
|
const selectedAction = await ctx.ui.select(
|
|
163
|
-
`${record
|
|
168
|
+
`${workflowRunDisplayName(record)} · ${record.runId}`,
|
|
164
169
|
[...actions],
|
|
165
170
|
);
|
|
166
171
|
const action = actions.find((candidate) => candidate === selectedAction);
|
|
@@ -232,14 +237,10 @@ export class WorkflowRunController {
|
|
|
232
237
|
): Promise<string> {
|
|
233
238
|
const unavailable = backgroundUnavailableResult(ctx.mode);
|
|
234
239
|
if (unavailable) throw new Error(unavailable.content[0].text);
|
|
235
|
-
const workflow = await this.
|
|
236
|
-
if (!workflow) throw new Error(`registered workflow ${record.workflow.name} is unavailable`);
|
|
237
|
-
if (workflow.source.kind !== "file") {
|
|
238
|
-
throw new Error(`registered workflow ${record.workflow.name} no longer has verifiable file provenance`);
|
|
239
|
-
}
|
|
240
|
+
const { workflow, fingerprint } = await this.relaunchWorkflow(ctx, record);
|
|
240
241
|
if (
|
|
241
242
|
action === "resume"
|
|
242
|
-
&&
|
|
243
|
+
&& fingerprint !== record.workflow.sourceFingerprint
|
|
243
244
|
) {
|
|
244
245
|
throw new Error("workflow source changed, so journal replay cannot resume safely");
|
|
245
246
|
}
|
|
@@ -264,6 +265,8 @@ export class WorkflowRunController {
|
|
|
264
265
|
ctx,
|
|
265
266
|
name: workflow.meta.name,
|
|
266
267
|
options,
|
|
268
|
+
// A relaunch continues the original run, so it delivers the way that run would have.
|
|
269
|
+
wakeOnComplete: record.background?.origin.wakeOnComplete === true,
|
|
267
270
|
execute: (backgroundCtx, backgroundOptions) =>
|
|
268
271
|
this.dependencies.execute(backgroundCtx, workflow.meta.name, workflow, backgroundOptions),
|
|
269
272
|
});
|
|
@@ -272,6 +275,35 @@ export class WorkflowRunController {
|
|
|
272
275
|
return message;
|
|
273
276
|
}
|
|
274
277
|
|
|
278
|
+
/** The module a relaunch runs: an inline run's persisted script, else the registered workflow of the same name. */
|
|
279
|
+
private async relaunchWorkflow(
|
|
280
|
+
ctx: ExtensionContext,
|
|
281
|
+
record: WorkflowRunRecord,
|
|
282
|
+
): Promise<{ readonly workflow: LoadedWorkflow; readonly fingerprint: string }> {
|
|
283
|
+
if (record.workflow.sourceKind === "fingerprint") {
|
|
284
|
+
if (record.scriptFile === undefined) throw new Error(`inline workflow ${record.workflow.name} did not retain its script`);
|
|
285
|
+
const fingerprint = await sealedFingerprint(record);
|
|
286
|
+
let source: string;
|
|
287
|
+
try {
|
|
288
|
+
source = await readWorkflowRunScript(ctx.cwd, record.runId);
|
|
289
|
+
} catch (error) {
|
|
290
|
+
throw new Error(`inline workflow script ${record.scriptFile} is unavailable: ${unknownErrorMessage(error)}`);
|
|
291
|
+
}
|
|
292
|
+
if (inlineScriptFingerprint(source) !== fingerprint) throw new Error(unsealedRunMessage(record));
|
|
293
|
+
return { workflow: compileInlineWorkflow(source), fingerprint };
|
|
294
|
+
}
|
|
295
|
+
const workflow = await this.dependencies.resolveWorkflow(record.workflow.name, ctx);
|
|
296
|
+
if (!workflow) throw new Error(`registered workflow ${record.workflow.name} is unavailable`);
|
|
297
|
+
if (workflow.source.kind !== "file") {
|
|
298
|
+
throw new Error(`registered workflow ${record.workflow.name} no longer has verifiable file provenance`);
|
|
299
|
+
}
|
|
300
|
+
// A repository can also ship a saved script under the name a forged record claims.
|
|
301
|
+
if (workflow.source.selfContained === true && (await sealedFingerprint(record)) !== workflow.source.fingerprint) {
|
|
302
|
+
throw new Error(unsealedRunMessage(record));
|
|
303
|
+
}
|
|
304
|
+
return { workflow, fingerprint: workflow.source.fingerprint };
|
|
305
|
+
}
|
|
306
|
+
|
|
275
307
|
private async autoResume(ctx: ExtensionContext, runId: string, attempt: number): Promise<void> {
|
|
276
308
|
const record = await this.loadRecord(ctx.cwd, runId);
|
|
277
309
|
if (
|
|
@@ -326,7 +358,7 @@ export class WorkflowRunController {
|
|
|
326
358
|
.filter((record) => availableWorkflowRunActions(record, runs).includes(action))
|
|
327
359
|
.map((record) => ({
|
|
328
360
|
value: record.runId,
|
|
329
|
-
description: `${record.state} · ${record
|
|
361
|
+
description: `${record.state} · ${workflowRunDisplayName(record)}`,
|
|
330
362
|
})),
|
|
331
363
|
);
|
|
332
364
|
}
|
|
@@ -340,12 +372,36 @@ export class WorkflowRunController {
|
|
|
340
372
|
{ value: "last", description: "Inspect the current or most recent in-session workflow" },
|
|
341
373
|
...records.map((record) => ({
|
|
342
374
|
value: record.runId,
|
|
343
|
-
description: `${record.state} · ${record
|
|
375
|
+
description: `${record.state} · ${workflowRunDisplayName(record)}`,
|
|
344
376
|
})),
|
|
345
377
|
]);
|
|
346
378
|
}
|
|
347
379
|
}
|
|
348
380
|
|
|
381
|
+
/**
|
|
382
|
+
* The fingerprint a script-sourced record's seal covers. A repository can ship a record of its own, so
|
|
383
|
+
* the seal is checked before any script is read; it is this install's, over the script as recorded.
|
|
384
|
+
*/
|
|
385
|
+
async function sealedFingerprint(record: WorkflowRunRecord): Promise<string> {
|
|
386
|
+
const fingerprint = record.workflow.sourceFingerprint;
|
|
387
|
+
if (
|
|
388
|
+
fingerprint === undefined
|
|
389
|
+
|| record.scriptSeal === undefined
|
|
390
|
+
|| !(await verifyWorkflowRunScriptSeal(record.runId, fingerprint, record.scriptSeal))
|
|
391
|
+
) {
|
|
392
|
+
throw new Error(unsealedRunMessage(record));
|
|
393
|
+
}
|
|
394
|
+
return fingerprint;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Why a script-sourced record will not run, and how to run the script anyway. */
|
|
398
|
+
function unsealedRunMessage(record: WorkflowRunRecord): string {
|
|
399
|
+
const rerun = isInlineWorkflowRun(record)
|
|
400
|
+
? `rerun it with the workflow tool's scriptPath (${WORKFLOW_RUNS_DIR}/${workflowRunScriptFile(record.runId)})`
|
|
401
|
+
: `run ${record.workflow.name} again by name`;
|
|
402
|
+
return `${isInlineWorkflowRun(record) ? "inline run" : "run"} ${record.runId} was not recorded by this pi install, or its script changed since; ${rerun}`;
|
|
403
|
+
}
|
|
404
|
+
|
|
349
405
|
/**
|
|
350
406
|
* Runs that any stored run resumed, whatever that resume's outcome. A resumed run keeps its paused
|
|
351
407
|
* record and pause attempt, so automatic resume treats the pause as spent: re-arming it after a
|
|
@@ -86,9 +86,28 @@ export function availableWorkflowRunActions(
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
export function canRelaunchWorkflowRun(record: WorkflowRunRecord): boolean {
|
|
89
|
+
return hasRelaunchableWorkflowSource(record) && record.options.argumentsPresent === false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A registered workflow resolves again by name; an inline one relaunches from the script persisted beside
|
|
94
|
+
* its record, and only when the record carries a seal. This check is only the seal's presence: relaunch
|
|
95
|
+
* verifies it, and the script's fingerprint, before running anything.
|
|
96
|
+
*/
|
|
97
|
+
export function hasRelaunchableWorkflowSource(record: WorkflowRunRecord): boolean {
|
|
98
|
+
if (typeof record.workflow.sourceFingerprint !== "string") return false;
|
|
89
99
|
return record.workflow.sourceKind === "file"
|
|
90
|
-
&&
|
|
91
|
-
|
|
100
|
+
|| (isInlineWorkflowRun(record) && record.scriptFile !== undefined && record.scriptSeal !== undefined);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A run of an inline script (a tool `script` or `scriptPath`), not of a registered workflow. */
|
|
104
|
+
export function isInlineWorkflowRun(record: WorkflowRunRecord): boolean {
|
|
105
|
+
return record.workflow.sourceKind === "fingerprint";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The run's workflow name, marked when it ran an inline script, so such a record cannot pose as a registered workflow's run. */
|
|
109
|
+
export function workflowRunDisplayName(record: WorkflowRunRecord): string {
|
|
110
|
+
return isInlineWorkflowRun(record) ? `${record.workflow.name} (inline)` : record.workflow.name;
|
|
92
111
|
}
|
|
93
112
|
|
|
94
113
|
export function formatWorkflowRunHistory(
|
|
@@ -112,7 +131,7 @@ export function formatWorkflowRunSummary(
|
|
|
112
131
|
const usage = formatWorkflowUsageLine(record.usage);
|
|
113
132
|
const resumedAs = runs.resumedAs.get(record.runId);
|
|
114
133
|
const actions = availableWorkflowRunActions(record, runs).filter((action) => action !== "inspect");
|
|
115
|
-
return `${record.state.toUpperCase()} ${record
|
|
134
|
+
return `${record.state.toUpperCase()} ${workflowRunDisplayName(record)} · age ${formatDuration(Math.max(0, now - record.createdAt))} · duration ${formatWorkflowRunDuration(record, now)}${usage ? ` · ${usage}` : ""} · ${record.runId}${resumedAs ? ` · resumed as ${resumedAs}` : ""}${actions.length > 0 ? ` · actions ${actions.join(", ")}` : ""}`;
|
|
116
135
|
}
|
|
117
136
|
|
|
118
137
|
export function formatWorkflowRunDetails(
|
|
@@ -127,7 +146,7 @@ export function formatWorkflowRunDetails(
|
|
|
127
146
|
const usage = formatWorkflowUsageLine(record.usage);
|
|
128
147
|
const lines = [
|
|
129
148
|
`Workflow run ${record.runId}`,
|
|
130
|
-
`Workflow: ${record
|
|
149
|
+
`Workflow: ${workflowRunDisplayName(record)}`,
|
|
131
150
|
`State: ${record.state.toUpperCase()}`,
|
|
132
151
|
`Age: ${formatDuration(Math.max(0, now - record.createdAt))}`,
|
|
133
152
|
`Duration: ${formatWorkflowRunDuration(record, now)}`,
|
|
@@ -17,8 +17,10 @@ import {
|
|
|
17
17
|
isPersistedWorkflowBackground,
|
|
18
18
|
type PersistedWorkflowBackground,
|
|
19
19
|
} from "./workflow-run-background.ts";
|
|
20
|
+
import { isWorkflowRunScriptSeal } from "./workflow-run-seal.ts";
|
|
20
21
|
|
|
21
22
|
export const WORKFLOW_RUN_RECORD_VERSION = 1;
|
|
23
|
+
export const WORKFLOW_RUN_SCRIPT_SUFFIX = ".script.js";
|
|
22
24
|
|
|
23
25
|
const MAX_RESULT_BYTES = 1 << 20;
|
|
24
26
|
const MAX_RESULT_DEPTH = 64;
|
|
@@ -97,6 +99,14 @@ interface WorkflowRunRecordBase {
|
|
|
97
99
|
readonly runId: string;
|
|
98
100
|
readonly workflow: PersistedWorkflowIdentity;
|
|
99
101
|
readonly journalFile: string;
|
|
102
|
+
/** An inline run's script, persisted beside the record; prompts in it never enter the record itself. */
|
|
103
|
+
readonly scriptFile?: string;
|
|
104
|
+
/**
|
|
105
|
+
* This install's HMAC over the run id and source fingerprint, proving the engine recorded the script
|
|
106
|
+
* rather than a repository shipping it. A run of an inline or saved script relaunches from
|
|
107
|
+
* `/workflow:runs` only with one.
|
|
108
|
+
*/
|
|
109
|
+
readonly scriptSeal?: string;
|
|
100
110
|
readonly options: PersistedWorkflowRunOptions;
|
|
101
111
|
readonly createdAt: number;
|
|
102
112
|
readonly updatedAt: number;
|
|
@@ -125,6 +135,10 @@ export function createWorkflowRunRecord(input: {
|
|
|
125
135
|
readonly options: ResolvedWorkflowRunOptions;
|
|
126
136
|
readonly progress: WorkflowProgressSnapshot;
|
|
127
137
|
readonly argumentsPresent?: boolean;
|
|
138
|
+
/** Whether the run's inline script was persisted beside the record. */
|
|
139
|
+
readonly scriptPersisted?: boolean;
|
|
140
|
+
/** The run's script seal; see {@link WorkflowRunRecordBase.scriptSeal}. */
|
|
141
|
+
readonly scriptSeal?: string;
|
|
128
142
|
}): WorkflowRunRecord {
|
|
129
143
|
const runId = validateWorkflowRunId(input.runId);
|
|
130
144
|
assertMatchingRun(runId, input.progress.runId);
|
|
@@ -134,6 +148,8 @@ export function createWorkflowRunRecord(input: {
|
|
|
134
148
|
state: "queued",
|
|
135
149
|
workflow: persistedWorkflowIdentity(input.workflow),
|
|
136
150
|
journalFile: `${runId}.jsonl`,
|
|
151
|
+
scriptFile: input.scriptPersisted ? workflowRunScriptFile(runId) : undefined,
|
|
152
|
+
scriptSeal: input.scriptSeal,
|
|
137
153
|
options: persistedWorkflowRunOptions(input.options, input.argumentsPresent ?? false),
|
|
138
154
|
createdAt: input.progress.startedAt,
|
|
139
155
|
updatedAt: input.progress.startedAt,
|
|
@@ -142,6 +158,11 @@ export function createWorkflowRunRecord(input: {
|
|
|
142
158
|
};
|
|
143
159
|
}
|
|
144
160
|
|
|
161
|
+
/** The persisted script's file name in the runs directory. */
|
|
162
|
+
export function workflowRunScriptFile(runId: string): string {
|
|
163
|
+
return `${runId}${WORKFLOW_RUN_SCRIPT_SUFFIX}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
145
166
|
export function updateWorkflowRunProgress(
|
|
146
167
|
record: WorkflowRunRecord,
|
|
147
168
|
progress: WorkflowProgressSnapshot,
|
|
@@ -169,6 +190,8 @@ export function transitionWorkflowRun(record: WorkflowRunRecord, transition: Wor
|
|
|
169
190
|
runId: record.runId,
|
|
170
191
|
workflow: record.workflow,
|
|
171
192
|
journalFile: record.journalFile,
|
|
193
|
+
scriptFile: record.scriptFile,
|
|
194
|
+
scriptSeal: record.scriptSeal,
|
|
172
195
|
options: record.options,
|
|
173
196
|
createdAt: record.createdAt,
|
|
174
197
|
updatedAt: at,
|
|
@@ -475,6 +498,9 @@ export function isWorkflowRunRecord(value: unknown): value is WorkflowRunRecord
|
|
|
475
498
|
if (!isValidRunId(value.runId) || !isWorkflowRunState(value.state)) return false;
|
|
476
499
|
if (!isPersistedWorkflowIdentity(value.workflow) || !isPersistedWorkflowRunOptions(value.options)) return false;
|
|
477
500
|
if (value.journalFile !== `${value.runId}.jsonl`) return false;
|
|
501
|
+
if (value.scriptFile !== undefined && value.scriptFile !== workflowRunScriptFile(value.runId)) return false;
|
|
502
|
+
// A seal covers an inline run's persisted script or a saved script, which resolves again by name.
|
|
503
|
+
if (value.scriptSeal !== undefined && (!isWorkflowRunScriptSeal(value.scriptSeal) || (value.scriptFile === undefined && value.workflow.sourceKind !== "file"))) return false;
|
|
478
504
|
if (!isFiniteNumber(value.createdAt) || !isFiniteNumber(value.updatedAt)) return false;
|
|
479
505
|
if (value.startedAt !== undefined && !isFiniteNumber(value.startedAt)) return false;
|
|
480
506
|
if (value.endedAt !== undefined && !isFiniteNumber(value.endedAt)) return false;
|
|
@@ -568,7 +594,7 @@ function isPhaseSnapshot(value: unknown): boolean {
|
|
|
568
594
|
|
|
569
595
|
function isAgentSnapshot(value: unknown): boolean {
|
|
570
596
|
if (!isRecord(value) || !isFiniteNumber(value.id) || typeof value.label !== "string") return false;
|
|
571
|
-
if (value.status !== "queued" && value.status !== "running" && value.status !== "done" && value.status !== "failed") return false;
|
|
597
|
+
if (value.status !== "queued" && value.status !== "running" && value.status !== "done" && value.status !== "failed" && value.status !== "skipped") return false;
|
|
572
598
|
if (!isFiniteNumber(value.toolUses)) return false;
|
|
573
599
|
if (value.startedAt !== undefined && !isFiniteNumber(value.startedAt)) return false;
|
|
574
600
|
if (value.doneAt !== undefined && !isFiniteNumber(value.doneAt)) return false;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { link, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { isExistingPathError, isMissingPathError } from "./filesystem-error.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The per-install key that seals inline run scripts, in pi's agent directory. It lives outside every
|
|
9
|
+
* project, so a repository can ship run records and scripts but never a seal this install accepts.
|
|
10
|
+
*/
|
|
11
|
+
export const WORKFLOW_RUN_SEAL_KEY_FILE = "workflow-run-seal.key";
|
|
12
|
+
|
|
13
|
+
const KEY_BYTES = 32;
|
|
14
|
+
const HEX_256 = /^[0-9a-f]{64}$/;
|
|
15
|
+
|
|
16
|
+
/** Whether `value` has a seal's shape; only {@link verifyWorkflowRunScriptSeal} says whether it is genuine. */
|
|
17
|
+
export function isWorkflowRunScriptSeal(value: unknown): value is string {
|
|
18
|
+
return typeof value === "string" && HEX_256.test(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The seal a run record carries for its persisted inline script, creating this install's key on first use. */
|
|
22
|
+
export async function sealWorkflowRunScript(
|
|
23
|
+
runId: string,
|
|
24
|
+
sourceFingerprint: string,
|
|
25
|
+
agentDir: string = getAgentDir(),
|
|
26
|
+
): Promise<string> {
|
|
27
|
+
const path = join(agentDir, WORKFLOW_RUN_SEAL_KEY_FILE);
|
|
28
|
+
return runSeal((await readSealKey(path)) ?? (await createSealKey(agentDir, path)), runId, sourceFingerprint);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Whether this install sealed `sourceFingerprint` for `runId`. Never creates a key: without one, nothing was sealed here. */
|
|
32
|
+
export async function verifyWorkflowRunScriptSeal(
|
|
33
|
+
runId: string,
|
|
34
|
+
sourceFingerprint: string,
|
|
35
|
+
seal: string,
|
|
36
|
+
agentDir: string = getAgentDir(),
|
|
37
|
+
): Promise<boolean> {
|
|
38
|
+
if (!isWorkflowRunScriptSeal(seal)) return false;
|
|
39
|
+
const key = await readSealKey(join(agentDir, WORKFLOW_RUN_SEAL_KEY_FILE));
|
|
40
|
+
if (key === undefined) return false;
|
|
41
|
+
return timingSafeEqual(Buffer.from(runSeal(key, runId, sourceFingerprint), "hex"), Buffer.from(seal, "hex"));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function runSeal(key: Buffer, runId: string, sourceFingerprint: string): string {
|
|
45
|
+
return createHmac("sha256", key).update(`${runId}\0${sourceFingerprint}`).digest("hex");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function readSealKey(path: string): Promise<Buffer | undefined> {
|
|
49
|
+
let content: string;
|
|
50
|
+
try {
|
|
51
|
+
content = await readFile(path, "utf8");
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (isMissingPathError(error)) return undefined;
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
const hex = content.trim();
|
|
57
|
+
if (!HEX_256.test(hex)) {
|
|
58
|
+
throw new Error(`workflow run seal key ${path} is malformed; delete it to create a new one, which retires every existing seal`);
|
|
59
|
+
}
|
|
60
|
+
return Buffer.from(hex, "hex");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Publish a new key with an exclusive hard link from a complete temporary file, so a concurrent reader
|
|
65
|
+
* never sees a partial key. When another process published one first, its key wins and is read back.
|
|
66
|
+
*/
|
|
67
|
+
async function createSealKey(agentDir: string, path: string): Promise<Buffer> {
|
|
68
|
+
await mkdir(agentDir, { recursive: true });
|
|
69
|
+
const key = randomBytes(KEY_BYTES);
|
|
70
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
71
|
+
try {
|
|
72
|
+
await writeFile(temporaryPath, `${key.toString("hex")}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
73
|
+
await link(temporaryPath, path);
|
|
74
|
+
return key;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (!isExistingPathError(error)) throw error;
|
|
77
|
+
const existing = await readSealKey(path);
|
|
78
|
+
if (existing === undefined) throw error;
|
|
79
|
+
return existing;
|
|
80
|
+
} finally {
|
|
81
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, join } from "node:path";
|
|
4
|
-
import { validateWorkflowRunId, WORKFLOW_RUNS_DIR } from "./journal.ts";
|
|
4
|
+
import { ensureWorkflowRunsDir, validateWorkflowRunId, WORKFLOW_RUNS_DIR } from "./journal.ts";
|
|
5
5
|
import type { WorkflowProgressSnapshot } from "./progress-types.ts";
|
|
6
6
|
import {
|
|
7
7
|
isWorkflowRunRecord,
|
|
8
8
|
transitionWorkflowRun,
|
|
9
9
|
updateWorkflowRunProgress,
|
|
10
|
+
WORKFLOW_RUN_SCRIPT_SUFFIX,
|
|
11
|
+
workflowRunScriptFile,
|
|
10
12
|
type WorkflowRunRecord,
|
|
11
13
|
type WorkflowRunState,
|
|
12
14
|
type WorkflowRunTransition,
|
|
@@ -34,7 +36,7 @@ export class ProjectWorkflowRunStore implements WorkflowRunStore {
|
|
|
34
36
|
if (Buffer.byteLength(content) > MAX_RECORD_BYTES) {
|
|
35
37
|
throw new Error(`Workflow run record exceeded ${MAX_RECORD_BYTES} bytes.`);
|
|
36
38
|
}
|
|
37
|
-
await
|
|
39
|
+
await ensureWorkflowRunsDir(dirname(path));
|
|
38
40
|
try {
|
|
39
41
|
await writeFile(temporaryPath, content, { encoding: "utf8", mode: 0o600 });
|
|
40
42
|
await rename(temporaryPath, path);
|
|
@@ -70,7 +72,12 @@ export class ProjectWorkflowRunStore implements WorkflowRunStore {
|
|
|
70
72
|
.filter((entry): entry is { readonly path: string; readonly mtimeMs: number } => entry !== undefined)
|
|
71
73
|
.sort((left, right) => right.mtimeMs - left.mtimeMs)
|
|
72
74
|
.slice(Math.max(0, keep));
|
|
73
|
-
|
|
75
|
+
// A pruned record takes its persisted script with it: scripts are only ever reached through their record.
|
|
76
|
+
const paths = stale.flatMap((entry) => [
|
|
77
|
+
entry.path,
|
|
78
|
+
`${entry.path.slice(0, -WORKFLOW_RUN_RECORD_SUFFIX.length)}${WORKFLOW_RUN_SCRIPT_SUFFIX}`,
|
|
79
|
+
]);
|
|
80
|
+
await Promise.all(paths.map((path) => rm(path, { force: true }).catch(() => undefined)));
|
|
74
81
|
}
|
|
75
82
|
|
|
76
83
|
/** Record file paths in the runs directory; empty when the directory cannot be read. */
|
|
@@ -153,6 +160,25 @@ export function workflowRunRecordPath(cwd: string, runId: string): string {
|
|
|
153
160
|
return join(cwd, WORKFLOW_RUNS_DIR, `${validateWorkflowRunId(runId)}${WORKFLOW_RUN_RECORD_SUFFIX}`);
|
|
154
161
|
}
|
|
155
162
|
|
|
163
|
+
export function workflowRunScriptPath(cwd: string, runId: string): string {
|
|
164
|
+
return join(cwd, WORKFLOW_RUNS_DIR, workflowRunScriptFile(validateWorkflowRunId(runId)));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Persist an inline run's script beside its record, which redacts prompts and so cannot hold it. This
|
|
169
|
+
* adds no new exposure: pi's session transcript already holds the script as the tool call's arguments.
|
|
170
|
+
*/
|
|
171
|
+
export async function persistWorkflowRunScript(cwd: string, runId: string, source: string): Promise<string> {
|
|
172
|
+
const path = workflowRunScriptPath(cwd, runId);
|
|
173
|
+
await ensureWorkflowRunsDir(dirname(path));
|
|
174
|
+
await writeFile(path, source, { encoding: "utf8", mode: 0o600 });
|
|
175
|
+
return path;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function readWorkflowRunScript(cwd: string, runId: string): Promise<string> {
|
|
179
|
+
return await readFile(workflowRunScriptPath(cwd, runId), "utf8");
|
|
180
|
+
}
|
|
181
|
+
|
|
156
182
|
async function loadWorkflowRunRecord(path: string): Promise<WorkflowRunRecord | undefined> {
|
|
157
183
|
try {
|
|
158
184
|
const content = await readFile(path, "utf8");
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Stats } from "node:fs";
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { compileInlineWorkflow } from "./inline-workflow.ts";
|
|
5
|
+
import type { LoadedWorkflow } from "./types.ts";
|
|
6
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
7
|
+
|
|
8
|
+
/** The largest script file a `scriptPath` may name; real workflow scripts are a few kilobytes. */
|
|
9
|
+
export const MAX_WORKFLOW_SCRIPT_FILE_BYTES = 1 << 20;
|
|
10
|
+
|
|
11
|
+
/** A `scriptPath` that cannot be read. */
|
|
12
|
+
export class WorkflowScriptPathError extends Error {
|
|
13
|
+
override readonly name = "WorkflowScriptPathError";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Read an inline-format script named relative to `cwd`. No containment: the script runs with the
|
|
18
|
+
* extension's access either way, as an inline `script` does.
|
|
19
|
+
*/
|
|
20
|
+
export async function readWorkflowScriptFile(cwd: string, scriptPath: string): Promise<string> {
|
|
21
|
+
return await readBoundedScriptFile(resolve(cwd, scriptPath), `scriptPath ${scriptPath}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read a workflow script only if it is a regular file within the size limit: a FIFO or device would
|
|
26
|
+
* block the read or never end. `label` names the file in errors.
|
|
27
|
+
*/
|
|
28
|
+
export async function readBoundedScriptFile(file: string, label: string): Promise<string> {
|
|
29
|
+
const unreadable = (error: unknown) => new WorkflowScriptPathError(`${label} could not be read: ${unknownErrorMessage(error)}`);
|
|
30
|
+
let info: Stats;
|
|
31
|
+
try {
|
|
32
|
+
info = await stat(file);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
throw unreadable(error);
|
|
35
|
+
}
|
|
36
|
+
if (!info.isFile()) throw new WorkflowScriptPathError(`${label} is not a regular file`);
|
|
37
|
+
if (info.size > MAX_WORKFLOW_SCRIPT_FILE_BYTES) {
|
|
38
|
+
throw new WorkflowScriptPathError(`${label} is ${info.size} bytes, over the ${MAX_WORKFLOW_SCRIPT_FILE_BYTES}-byte limit`);
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
return await readFile(file, "utf8");
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw unreadable(error);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Compile a script file exactly like an inline `script`: same contract, injected `Type`, content fingerprint. */
|
|
48
|
+
export async function compileWorkflowScriptFile(cwd: string, scriptPath: string): Promise<LoadedWorkflow> {
|
|
49
|
+
return compileInlineWorkflow(await readWorkflowScriptFile(cwd, scriptPath));
|
|
50
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
3
|
+
import { completeCurrentArgument, splitArgumentPrefix } from "./command-completions.ts";
|
|
4
|
+
import { configFilePaths, readJsonConfigFile, writeJsonConfigFile, type ConfigFilePaths } from "./config-file.ts";
|
|
5
|
+
import { isRecord } from "./guards.ts";
|
|
6
|
+
import { DEFAULT_WORKFLOW_MAX_AGENTS } from "./options.ts";
|
|
7
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* How large the host should make each workflow it authors. A prompt guideline only: the run's
|
|
11
|
+
* `maxAgents` limit stays the separate hard cap.
|
|
12
|
+
*/
|
|
13
|
+
export const WORKFLOW_SIZE_GUIDELINES = ["small", "medium", "large", "off"] as const;
|
|
14
|
+
export type WorkflowSizeGuideline = (typeof WORKFLOW_SIZE_GUIDELINES)[number];
|
|
15
|
+
export const DEFAULT_WORKFLOW_SIZE_GUIDELINE: WorkflowSizeGuideline = "medium";
|
|
16
|
+
|
|
17
|
+
const SIZE_AGENT_LIMITS: Readonly<Record<Exclude<WorkflowSizeGuideline, "off">, number>> = { small: 5, medium: 10, large: 25 };
|
|
18
|
+
const SETTINGS_FILE_NAME = "workflow-settings.json";
|
|
19
|
+
const SETTINGS_LABEL = "workflow settings";
|
|
20
|
+
|
|
21
|
+
export interface WorkflowSettingsFile {
|
|
22
|
+
readonly size?: WorkflowSizeGuideline;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ResolvedWorkflowSize =
|
|
26
|
+
| { readonly size: WorkflowSizeGuideline; readonly source: "default" }
|
|
27
|
+
| { readonly size: WorkflowSizeGuideline; readonly source: "user" | "project"; readonly configPath: string };
|
|
28
|
+
|
|
29
|
+
export function workflowSettingsPaths(cwd: string, agentDir?: string): ConfigFilePaths {
|
|
30
|
+
return configFilePaths(SETTINGS_FILE_NAME, cwd, agentDir);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isWorkflowSizeGuideline(value: string): value is WorkflowSizeGuideline {
|
|
34
|
+
return WORKFLOW_SIZE_GUIDELINES.includes(value as WorkflowSizeGuideline);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function readWorkflowSettingsFile(configPath: string): WorkflowSettingsFile {
|
|
38
|
+
const parsed = readJsonConfigFile(configPath, SETTINGS_LABEL);
|
|
39
|
+
if (parsed === undefined) return {};
|
|
40
|
+
if (!isRecord(parsed)) throw invalidSettings(configPath, "the root must be an object");
|
|
41
|
+
const unexpected = Object.keys(parsed).find((key) => key !== "size");
|
|
42
|
+
if (unexpected) throw invalidSettings(configPath, `unexpected key "${unexpected}"`);
|
|
43
|
+
const { size } = parsed;
|
|
44
|
+
if (size === undefined) return {};
|
|
45
|
+
if (typeof size !== "string" || !isWorkflowSizeGuideline(size)) {
|
|
46
|
+
throw invalidSettings(configPath, '"size" must be small, medium, large, or off');
|
|
47
|
+
}
|
|
48
|
+
return { size };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The project file wins over the user file, which wins over the default. */
|
|
52
|
+
export function resolveWorkflowSize(paths: ConfigFilePaths): ResolvedWorkflowSize {
|
|
53
|
+
for (const source of ["project", "user"] as const) {
|
|
54
|
+
const { size } = readWorkflowSettingsFile(paths[source]);
|
|
55
|
+
if (size !== undefined) return { size, source, configPath: paths[source] };
|
|
56
|
+
}
|
|
57
|
+
return { size: DEFAULT_WORKFLOW_SIZE_GUIDELINE, source: "default" };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function setWorkflowSize(configPath: string, size: WorkflowSizeGuideline): void {
|
|
61
|
+
const current = readWorkflowSettingsFile(configPath);
|
|
62
|
+
writeJsonConfigFile(configPath, { ...current, size }, SETTINGS_LABEL);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The catalog line for a size, or undefined when the guideline is off. */
|
|
66
|
+
export function workflowSizeGuidelineLine(size: WorkflowSizeGuideline): string | undefined {
|
|
67
|
+
if (size === "off") return undefined;
|
|
68
|
+
return `Workflow size guideline: ${size} — keep each workflow under ${SIZE_AGENT_LIMITS[size]} agents unless the user asks for more; the user changes this with /workflow:size.`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type WorkflowSizeCommand =
|
|
72
|
+
| { readonly kind: "status" }
|
|
73
|
+
| { readonly kind: "set"; readonly size: WorkflowSizeGuideline; readonly scope: keyof ConfigFilePaths }
|
|
74
|
+
| { readonly kind: "error"; readonly message: string };
|
|
75
|
+
|
|
76
|
+
const USAGE = "Usage: /workflow:size [small|medium|large|off] [--user|--project]";
|
|
77
|
+
const SIZE_COMPLETIONS = WORKFLOW_SIZE_GUIDELINES.map((value) => ({
|
|
78
|
+
value,
|
|
79
|
+
description: value === "off" ? "No size guideline in the prompt" : `Keep each workflow under ${SIZE_AGENT_LIMITS[value]} agents`,
|
|
80
|
+
}));
|
|
81
|
+
const SCOPE_COMPLETIONS = [
|
|
82
|
+
{ value: "--user", description: "Write the user-level workflow settings" },
|
|
83
|
+
{ value: "--project", description: "Write the project-level workflow settings" },
|
|
84
|
+
] as const;
|
|
85
|
+
|
|
86
|
+
export function parseWorkflowSizeCommand(args: string): WorkflowSizeCommand {
|
|
87
|
+
const tokens = args.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
88
|
+
const userScope = tokens.includes("--user");
|
|
89
|
+
const projectScope = tokens.includes("--project");
|
|
90
|
+
if (userScope && projectScope) return { kind: "error", message: "Choose either --user or --project, not both." };
|
|
91
|
+
const positional = tokens.filter((token) => token !== "--user" && token !== "--project");
|
|
92
|
+
if (positional.length === 0) {
|
|
93
|
+
return userScope || projectScope ? { kind: "error", message: "Name a size to write." } : { kind: "status" };
|
|
94
|
+
}
|
|
95
|
+
const [size, ...extra] = positional;
|
|
96
|
+
if (extra.length > 0 || !size || !isWorkflowSizeGuideline(size)) {
|
|
97
|
+
return { kind: "error", message: "Size must be small, medium, large, or off." };
|
|
98
|
+
}
|
|
99
|
+
return { kind: "set", size, scope: projectScope ? "project" : "user" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function workflowSizeArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
|
|
103
|
+
const { completed } = splitArgumentPrefix(argumentPrefix);
|
|
104
|
+
if (completed.length === 0) return completeCurrentArgument(argumentPrefix, SIZE_COMPLETIONS);
|
|
105
|
+
if (completed.length === 1 && isWorkflowSizeGuideline(completed[0] ?? "")) return completeCurrentArgument(argumentPrefix, SCOPE_COMPLETIONS);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function formatWorkflowSize(resolved: ResolvedWorkflowSize): string {
|
|
110
|
+
const source = resolved.source === "default" ? "default" : `${resolved.source} config ${resolved.configPath}`;
|
|
111
|
+
const rule = resolved.size === "off"
|
|
112
|
+
? "no size guideline is added to the prompt"
|
|
113
|
+
: `keep each workflow under ${SIZE_AGENT_LIMITS[resolved.size]} agents unless the user asks for more`;
|
|
114
|
+
return [
|
|
115
|
+
`Workflow size guideline: ${resolved.size} (${source}) — ${rule}.`,
|
|
116
|
+
`The maxAgents run limit (default ${DEFAULT_WORKFLOW_MAX_AGENTS}) stays the hard cap on live agents per run.`,
|
|
117
|
+
].join("\n");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Register `/workflow:size`; `onChange` lets the session's prompt pick the new guideline up. */
|
|
121
|
+
export function registerWorkflowSizeCommand(pi: Pick<ExtensionAPI, "registerCommand">, onChange: (ctx: ExtensionCommandContext) => void): void {
|
|
122
|
+
pi.registerCommand("workflow:size", {
|
|
123
|
+
description: "Show or set the workflow size guideline: /workflow:size [small|medium|large|off] [--user|--project]",
|
|
124
|
+
getArgumentCompletions: workflowSizeArgumentCompletions,
|
|
125
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
126
|
+
const command = parseWorkflowSizeCommand(args);
|
|
127
|
+
if (command.kind === "error") {
|
|
128
|
+
ctx.ui.notify(`${command.message}\n${USAGE}`, "warning");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const paths = workflowSettingsPaths(ctx.cwd);
|
|
133
|
+
if (command.kind === "set") {
|
|
134
|
+
setWorkflowSize(paths[command.scope], command.size);
|
|
135
|
+
onChange(ctx);
|
|
136
|
+
}
|
|
137
|
+
ctx.ui.notify(formatWorkflowSize(resolveWorkflowSize(paths)), "info");
|
|
138
|
+
} catch (error) {
|
|
139
|
+
ctx.ui.notify(unknownErrorMessage(error), "error");
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function invalidSettings(configPath: string, reason: string): Error {
|
|
146
|
+
return new Error(`Invalid workflow settings at ${configPath}: ${reason}. Fix or remove the file.`);
|
|
147
|
+
}
|