pi-workflow-engine 1.1.0 → 1.3.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 +199 -85
- package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +5 -2
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +8 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +4 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +3 -1
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +18 -4
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +34 -7
- package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +9 -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/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 +112 -31
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +75 -18
- package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +5 -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/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 +4 -4
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/types.ts +54 -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/workflow-advisory-utils.ts +82 -23
- 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 +59 -22
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +7 -4
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +7 -4
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +7 -4
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +12 -4
- package/README.md +11 -3
- package/USAGE.md +124 -23
- package/package.json +13 -4
- package/skills/workflow-authoring/SKILL.md +162 -0
|
@@ -15,11 +15,12 @@ import {
|
|
|
15
15
|
resolveWorkflowRunOptions,
|
|
16
16
|
type ResolvedWorkflowRunOptions,
|
|
17
17
|
} from "./options.ts";
|
|
18
|
-
import type { AgentOptions, IsolatedAgentResult, LoadedWorkflow, WorkflowApi, WorkflowProgressEvent, WorkflowRef, WorkflowRunOptions } from "./types.ts";
|
|
18
|
+
import type { AgentOptions, IsolatedAgentResult, LoadedWorkflow, WorkflowApi, WorkflowMeta, WorkflowProgressEvent, WorkflowRef, WorkflowRunOptions } from "./types.ts";
|
|
19
19
|
import { showWorkflowInspector } from "./ui/workflow-inspector.ts";
|
|
20
20
|
import { createWorkflowJournal, createWorkflowRunId, pruneWorkflowJournals, workflowJournalPath } from "./journal.ts";
|
|
21
21
|
import { WorktreeRegistry } from "./worktree.ts";
|
|
22
22
|
import { runFinalizers } from "./finalizers.ts";
|
|
23
|
+
import { workflowArgsText, workflowInputPresent } from "./workflow-input.ts";
|
|
23
24
|
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
24
25
|
import {
|
|
25
26
|
captureWorkflowResumeContext,
|
|
@@ -27,11 +28,14 @@ import {
|
|
|
27
28
|
} from "./resume-context.ts";
|
|
28
29
|
import {
|
|
29
30
|
DurableWorkflowRun,
|
|
31
|
+
persistWorkflowRunScript,
|
|
30
32
|
ProjectWorkflowRunStore,
|
|
31
33
|
workflowRunRecordPath,
|
|
32
34
|
type WorkflowRunStore,
|
|
33
35
|
} from "./workflow-run-store.ts";
|
|
34
36
|
import { createWorkflowRunRecord } from "./workflow-run-record.ts";
|
|
37
|
+
import { canRelaunchWorkflowRun } from "./workflow-run-history.ts";
|
|
38
|
+
import { sealWorkflowRunScript } from "./workflow-run-seal.ts";
|
|
35
39
|
import {
|
|
36
40
|
createProviderUsageLimitPauseRecord,
|
|
37
41
|
WorkflowProviderUsageLimitError,
|
|
@@ -76,22 +80,23 @@ export interface WorkflowEngineDependencies {
|
|
|
76
80
|
/**
|
|
77
81
|
* Run a workflow module: build the per-run primitives (binding agent/parallel/pipeline
|
|
78
82
|
* to a shared semaphore + progress tracker), invoke the workflow, return its result.
|
|
83
|
+
* `input` is a slash command's argument string or a tool call's JSON value.
|
|
79
84
|
*/
|
|
80
85
|
export async function runWorkflow(
|
|
81
86
|
ctx: ExtensionContext,
|
|
82
87
|
mod: LoadedWorkflow,
|
|
83
|
-
|
|
88
|
+
input: unknown,
|
|
84
89
|
options: WorkflowRunOptions = {},
|
|
85
90
|
dependencies: WorkflowEngineDependencies = {},
|
|
86
91
|
): Promise<unknown> {
|
|
87
|
-
return await runResolvedWorkflow(ctx, mod,
|
|
92
|
+
return await runResolvedWorkflow(ctx, mod, input, resolveWorkflowRunOptions(options), dependencies);
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
/** Execute an options-normalized workflow without re-reading environment defaults. */
|
|
91
96
|
export async function runResolvedWorkflow(
|
|
92
97
|
ctx: ExtensionContext,
|
|
93
98
|
mod: LoadedWorkflow,
|
|
94
|
-
|
|
99
|
+
input: unknown,
|
|
95
100
|
resolvedOptions: ResolvedWorkflowRunOptions,
|
|
96
101
|
dependencies: WorkflowEngineDependencies = {},
|
|
97
102
|
): Promise<unknown> {
|
|
@@ -107,16 +112,20 @@ export async function runResolvedWorkflow(
|
|
|
107
112
|
const journalPath = workflowJournalPath(ctx.cwd, runId);
|
|
108
113
|
const resumePath = resolvedOptions.resumeFromRunId ? workflowJournalPath(ctx.cwd, resolvedOptions.resumeFromRunId) : undefined;
|
|
109
114
|
const runStore = new ProjectWorkflowRunStore(ctx.cwd);
|
|
115
|
+
const script = await sealRunScript(ctx.cwd, runId, mod);
|
|
110
116
|
let persistenceFailureReported = false;
|
|
117
|
+
const initialRecord = createWorkflowRunRecord({
|
|
118
|
+
runId,
|
|
119
|
+
workflow: mod,
|
|
120
|
+
options: resolvedOptions,
|
|
121
|
+
progress: progress.snapshot(),
|
|
122
|
+
argumentsPresent: workflowInputPresent(input),
|
|
123
|
+
scriptPersisted: script.path !== undefined,
|
|
124
|
+
scriptSeal: script.seal,
|
|
125
|
+
});
|
|
111
126
|
const durableRun = new DurableWorkflowRun(
|
|
112
127
|
runStore,
|
|
113
|
-
|
|
114
|
-
runId,
|
|
115
|
-
workflow: mod,
|
|
116
|
-
options: resolvedOptions,
|
|
117
|
-
progress: progress.snapshot(),
|
|
118
|
-
argumentsPresent: args.length > 0,
|
|
119
|
-
}),
|
|
128
|
+
initialRecord,
|
|
120
129
|
(error) => {
|
|
121
130
|
if (persistenceFailureReported) return;
|
|
122
131
|
persistenceFailureReported = true;
|
|
@@ -124,7 +133,11 @@ export async function runResolvedWorkflow(
|
|
|
124
133
|
},
|
|
125
134
|
);
|
|
126
135
|
durableProgress = durableRun;
|
|
127
|
-
const progressSource = {
|
|
136
|
+
const progressSource = {
|
|
137
|
+
snapshot: () => progress.snapshot(),
|
|
138
|
+
state: () => durableRun.state,
|
|
139
|
+
skipAgent: (agentId: number) => progress.skipAgent(agentId),
|
|
140
|
+
};
|
|
128
141
|
const worktrees = dependencies.worktrees ?? new WorktreeRegistry(ctx.cwd);
|
|
129
142
|
const runAbortController = new AbortController();
|
|
130
143
|
const unlinkContextAbortSignal = linkAbortSignal(ctx.signal, runAbortController);
|
|
@@ -132,7 +145,7 @@ export async function runResolvedWorkflow(
|
|
|
132
145
|
const workflowOutcome = await captureOutcome(async () => {
|
|
133
146
|
await notifyLifecycleObserver(progress, "progress source callback", () => resolvedOptions.onProgressSource?.(progressSource));
|
|
134
147
|
if (resolvedOptions.inspect && ctx.hasUI && ctx.mode === "tui") {
|
|
135
|
-
void showWorkflowInspector(ctx.ui, progressSource.snapshot, () => ({ state: progressSource.state() }))
|
|
148
|
+
void showWorkflowInspector(ctx.ui, progressSource.snapshot, () => ({ state: progressSource.state() }), progressSource)
|
|
136
149
|
.catch((error: unknown) => {
|
|
137
150
|
try {
|
|
138
151
|
progress.log(`inspector failed: ${unknownErrorMessage(error)}`);
|
|
@@ -151,6 +164,7 @@ export async function runResolvedWorkflow(
|
|
|
151
164
|
resumedFromRunId: resolvedOptions.resumeFromRunId,
|
|
152
165
|
journalPath,
|
|
153
166
|
recordPath: workflowRunRecordPath(ctx.cwd, runId),
|
|
167
|
+
...(script.path === undefined ? {} : { scriptPath: script.path }),
|
|
154
168
|
}),
|
|
155
169
|
);
|
|
156
170
|
progress.log(resolvedOptions.resumeFromRunId ? `run id: ${runId} (resuming from ${resolvedOptions.resumeFromRunId})` : `run id: ${runId}`);
|
|
@@ -184,7 +198,7 @@ export async function runResolvedWorkflow(
|
|
|
184
198
|
|
|
185
199
|
// perf.total_ms wraps the whole tree: sub-workflows run inside this span via api.workflow().
|
|
186
200
|
return perf.time("workflow.total_ms", () =>
|
|
187
|
-
runWorkflowWithContext(rc, progress, mod,
|
|
201
|
+
runWorkflowWithContext(rc, progress, mod, input, {
|
|
188
202
|
abortController: runAbortController,
|
|
189
203
|
submissionLimit: resolvedOptions.parallelSubmissionLimit ?? resolvedOptions.concurrency * 2,
|
|
190
204
|
resolveWorkflow: resolvedOptions.resolveWorkflow,
|
|
@@ -225,7 +239,7 @@ export async function runResolvedWorkflow(
|
|
|
225
239
|
durableRun.transition({
|
|
226
240
|
state: "paused",
|
|
227
241
|
progress: progress.snapshot(),
|
|
228
|
-
...createProviderUsageLimitPauseRecord(pauseError, resolvedOptions,
|
|
242
|
+
...createProviderUsageLimitPauseRecord(pauseError, resolvedOptions, canRelaunchWorkflowRun(initialRecord)),
|
|
229
243
|
});
|
|
230
244
|
} else if (pauseError) {
|
|
231
245
|
durableRun.transition({
|
|
@@ -247,6 +261,33 @@ export async function runResolvedWorkflow(
|
|
|
247
261
|
throw outcome.error;
|
|
248
262
|
}
|
|
249
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Seal a run whose code came from a script for this install, so a repository cannot ship a run record
|
|
266
|
+
* whose relaunch runs its own code. An inline run's script is also written beside its record; a saved
|
|
267
|
+
* script resolves again by name, and the seal pins the content that ran. A failed write or seal only
|
|
268
|
+
* costs the run its relaunch from `/workflow:runs`.
|
|
269
|
+
*/
|
|
270
|
+
async function sealRunScript(cwd: string, runId: string, mod: LoadedWorkflow): Promise<{ readonly path?: string; readonly seal?: string }> {
|
|
271
|
+
const { source } = mod;
|
|
272
|
+
const savedScript = source.kind === "file" && source.selfContained === true;
|
|
273
|
+
if (!savedScript && (source.kind !== "fingerprint" || mod.inlineScript === undefined)) return {};
|
|
274
|
+
let path: string | undefined;
|
|
275
|
+
if (mod.inlineScript !== undefined && source.kind === "fingerprint") {
|
|
276
|
+
try {
|
|
277
|
+
path = await persistWorkflowRunScript(cwd, runId, mod.inlineScript);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
process.stderr.write(`[${mod.meta.name}] script persistence failed: ${unknownErrorMessage(error)}\n`);
|
|
280
|
+
return {};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
return { path, seal: await sealWorkflowRunScript(runId, source.fingerprint) };
|
|
285
|
+
} catch (error) {
|
|
286
|
+
process.stderr.write(`[${mod.meta.name}] script seal failed, so /workflow:runs cannot relaunch this run: ${unknownErrorMessage(error)}\n`);
|
|
287
|
+
return { path };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
250
291
|
function backgroundPauseError(error: unknown, ...signals: Array<AbortSignal | undefined>): unknown {
|
|
251
292
|
if (isWorkflowPauseError(error)) return error;
|
|
252
293
|
for (const signal of signals) {
|
|
@@ -345,7 +386,7 @@ function createWorkflowAgent(
|
|
|
345
386
|
function agent<S extends TSchema>(prompt: string, opts: AgentOptions<S> & { schema: S }): Promise<Static<S>>;
|
|
346
387
|
function agent(prompt: string, opts?: AgentOptions): Promise<string>;
|
|
347
388
|
function agent(prompt: string, agentOpts?: AgentOptions): Promise<unknown> {
|
|
348
|
-
const scopedOptions = scope.agentOptions(agentOpts);
|
|
389
|
+
const scopedOptions = scope.agentOptions(withPhaseProfile(mod.meta, agentOpts));
|
|
349
390
|
const executionOptions: AgentExecutionOptions =
|
|
350
391
|
scopedOptions.isolation === "worktree" && mod.isolatedWorktreeBaseline !== undefined
|
|
351
392
|
? { ...scopedOptions, worktreeBaseline: mod.isolatedWorktreeBaseline }
|
|
@@ -355,6 +396,17 @@ function createWorkflowAgent(
|
|
|
355
396
|
return agent;
|
|
356
397
|
}
|
|
357
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Routing order is the agent's `model`, then its `profile`, then its declared phase's `profile`, then
|
|
401
|
+
* the host. Only an explicit `opts.phase` selects a phase profile: the global `phase()` is shared
|
|
402
|
+
* state that concurrent `parallel`/`pipeline` branches race on.
|
|
403
|
+
*/
|
|
404
|
+
function withPhaseProfile(meta: WorkflowMeta, opts: AgentOptions | undefined): AgentOptions | undefined {
|
|
405
|
+
if (opts?.phase === undefined || opts.model !== undefined || opts.profile !== undefined) return opts;
|
|
406
|
+
const profile = meta.phases?.find((phase) => phase.title === opts.phase)?.profile;
|
|
407
|
+
return profile === undefined ? opts : { ...opts, profile };
|
|
408
|
+
}
|
|
409
|
+
|
|
358
410
|
/**
|
|
359
411
|
* Build the `WorkflowApi` from an existing run context and invoke the module. Reused for both
|
|
360
412
|
* the top-level run and every `api.workflow()` sub-step, so children share the parent's semaphore,
|
|
@@ -364,10 +416,12 @@ export async function runWorkflowWithContext(
|
|
|
364
416
|
rc: WorkflowRunContext,
|
|
365
417
|
progress: WorkflowProgress,
|
|
366
418
|
mod: LoadedWorkflow,
|
|
367
|
-
|
|
419
|
+
input: unknown,
|
|
368
420
|
opts: WorkflowContextOptions,
|
|
369
421
|
): Promise<unknown> {
|
|
370
422
|
const depth = opts.depth ?? 0;
|
|
423
|
+
// Converted before any agent runs, so an argument with no JSON form rejects the call up front.
|
|
424
|
+
const args = workflowArgsText(input);
|
|
371
425
|
const scope = createWorkflowScope(progress, opts.progressPrefix ?? "", opts.progressNamespace);
|
|
372
426
|
const resumeContext: AgentResumeBaseContext = {
|
|
373
427
|
workflow: await captureWorkflowResumeContext(mod, rc.signal),
|
|
@@ -388,7 +442,8 @@ export async function runWorkflowWithContext(
|
|
|
388
442
|
const childController = new AbortController();
|
|
389
443
|
const unlink = linkAbortSignal(rc.signal, childController);
|
|
390
444
|
try {
|
|
391
|
-
|
|
445
|
+
const childInput = childArgs === undefined ? "" : childArgs;
|
|
446
|
+
return await runWorkflowWithContext({ ...rc, signal: childController.signal }, progress, childMod, childInput, {
|
|
392
447
|
abortController: childController,
|
|
393
448
|
submissionLimit: opts.submissionLimit,
|
|
394
449
|
resolveWorkflow: opts.resolveWorkflow,
|
|
@@ -416,8 +471,10 @@ export async function runWorkflowWithContext(
|
|
|
416
471
|
log: scope.log,
|
|
417
472
|
progress: scope.event,
|
|
418
473
|
args,
|
|
474
|
+
input,
|
|
419
475
|
cwd: rc.cwd,
|
|
420
476
|
budget: rc.budget,
|
|
477
|
+
agents: { max: rc.agentLimiter.maxAgents, remaining: () => rc.agentLimiter.remaining() },
|
|
421
478
|
signal: rc.signal,
|
|
422
479
|
};
|
|
423
480
|
|
|
@@ -2,3 +2,8 @@
|
|
|
2
2
|
export function isMissingPathError(error: unknown): boolean {
|
|
3
3
|
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
4
4
|
}
|
|
5
|
+
|
|
6
|
+
/** Identify a path that already exists, as reported for an exclusive (`wx`) create. */
|
|
7
|
+
export function isExistingPathError(error: unknown): boolean {
|
|
8
|
+
return error instanceof Error && "code" in error && error.code === "EEXIST";
|
|
9
|
+
}
|
|
@@ -36,10 +36,16 @@ export function compileInlineWorkflow(source: string): LoadedWorkflow {
|
|
|
36
36
|
return {
|
|
37
37
|
meta: parsedMeta.meta,
|
|
38
38
|
default: (api) => executor(api, Type),
|
|
39
|
-
source: { kind: "fingerprint", fingerprint:
|
|
39
|
+
source: { kind: "fingerprint", fingerprint: inlineScriptFingerprint(source) },
|
|
40
|
+
inlineScript: source,
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/** A compiled script can import nothing, so the hash of its source is its whole identity. */
|
|
45
|
+
export function inlineScriptFingerprint(source: string): string {
|
|
46
|
+
return createHash("sha256").update(source).digest("hex");
|
|
47
|
+
}
|
|
48
|
+
|
|
43
49
|
/** The script's pure-literal meta, read without compiling or running anything; undefined when it does not parse. */
|
|
44
50
|
export function readInlineWorkflowMeta(source: string): WorkflowMeta | undefined {
|
|
45
51
|
try {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdir, readdir, readFile, rm, stat, appendFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readdir, readFile, rm, stat, appendFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import type { AgentOptions } from "./types.ts";
|
|
5
5
|
import type { WorktreeBaseline } from "./worktree.ts";
|
|
6
6
|
import { canonicalizeIdentity } from "./identity-canonicalization.ts";
|
|
7
|
+
import { isExistingPathError } from "./filesystem-error.ts";
|
|
7
8
|
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
8
9
|
import {
|
|
9
10
|
isAgentResumeContext,
|
|
@@ -15,6 +16,9 @@ import {
|
|
|
15
16
|
export const WORKFLOW_RUNS_DIR = join(".pi", ".workflow-runs");
|
|
16
17
|
export const WORKFLOW_JOURNAL_KEEP = 50;
|
|
17
18
|
|
|
19
|
+
/** Ignores everything in the runs directory, including this file, so user projects never commit run data. */
|
|
20
|
+
const WORKFLOW_RUNS_GITIGNORE = "*\n";
|
|
21
|
+
|
|
18
22
|
export interface JournalEntryV2 {
|
|
19
23
|
readonly version: 2;
|
|
20
24
|
readonly key: string;
|
|
@@ -56,6 +60,20 @@ export class WorkflowJournalLoadError extends Error {
|
|
|
56
60
|
override readonly name = "WorkflowJournalLoadError";
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Create the directory that holds run files, with a `.gitignore` that ignores all of them: records,
|
|
65
|
+
* journals, and scripts can carry prompts and results, and user projects do not list this directory in
|
|
66
|
+
* their own ignore files. An existing `.gitignore` is never overwritten.
|
|
67
|
+
*/
|
|
68
|
+
export async function ensureWorkflowRunsDir(dir: string): Promise<void> {
|
|
69
|
+
await mkdir(dir, { recursive: true });
|
|
70
|
+
try {
|
|
71
|
+
await writeFile(join(dir, ".gitignore"), WORKFLOW_RUNS_GITIGNORE, { encoding: "utf8", flag: "wx" });
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (!isExistingPathError(error)) throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
59
77
|
export function createWorkflowRunId(): string {
|
|
60
78
|
return randomUUID();
|
|
61
79
|
}
|
|
@@ -161,7 +179,7 @@ export async function createWorkflowJournal(options: {
|
|
|
161
179
|
readonly writePath: string;
|
|
162
180
|
}): Promise<WorkflowJournal> {
|
|
163
181
|
const priorEntries = options.resumePath ? await loadJournalEntries(options.resumePath, { required: true }) : [];
|
|
164
|
-
await
|
|
182
|
+
await ensureWorkflowRunsDir(dirname(options.writePath));
|
|
165
183
|
await appendFile(options.writePath, "", "utf8");
|
|
166
184
|
return createMemoryBackedJournal(priorEntries, options.writePath);
|
|
167
185
|
}
|
|
@@ -201,10 +219,10 @@ export function createMemoryBackedJournal(priorEntries: readonly JournalEntry[]
|
|
|
201
219
|
const entry: JournalEntryV2 = { version: 2, key, result, identity };
|
|
202
220
|
if (writePath) {
|
|
203
221
|
try {
|
|
204
|
-
await
|
|
222
|
+
await ensureWorkflowRunsDir(dirname(writePath));
|
|
205
223
|
await appendFile(writePath, `${JSON.stringify(entry)}\n`, "utf8");
|
|
206
224
|
} catch (error) {
|
|
207
|
-
|
|
225
|
+
return { ok: false, error: unknownErrorMessage(error) };
|
|
208
226
|
}
|
|
209
227
|
}
|
|
210
228
|
return { ok: true };
|
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
1
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
5
2
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
3
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { configFilePaths, readJsonConfigFile, writeJsonConfigFile, type ConfigFilePaths } from "./config-file.ts";
|
|
8
5
|
import { isRecord } from "./guards.ts";
|
|
9
|
-
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
10
6
|
|
|
11
7
|
export const WORKFLOW_MODEL_PROFILE_NAMES = ["small", "medium", "big"] as const;
|
|
12
8
|
export type WorkflowModelProfileName = (typeof WORKFLOW_MODEL_PROFILE_NAMES)[number];
|
|
@@ -20,10 +16,7 @@ export interface WorkflowModelProfileFile {
|
|
|
20
16
|
readonly profiles: Partial<Record<WorkflowModelProfileName, WorkflowModelProfileConfig>>;
|
|
21
17
|
}
|
|
22
18
|
|
|
23
|
-
export
|
|
24
|
-
readonly user: string;
|
|
25
|
-
readonly project: string;
|
|
26
|
-
}
|
|
19
|
+
export type WorkflowModelProfilePaths = ConfigFilePaths;
|
|
27
20
|
|
|
28
21
|
interface ResolvedWorkflowModelProfileBase {
|
|
29
22
|
readonly name: WorkflowModelProfileName;
|
|
@@ -52,15 +45,10 @@ const HOST_FALLBACK_THINKING: Record<WorkflowModelProfileName, ThinkingLevel> =
|
|
|
52
45
|
big: "high",
|
|
53
46
|
};
|
|
54
47
|
const CONFIG_FILE_NAME = "workflow-models.json";
|
|
48
|
+
const CONFIG_LABEL = "workflow model profiles";
|
|
55
49
|
|
|
56
|
-
export function workflowModelProfilePaths(
|
|
57
|
-
cwd
|
|
58
|
-
agentDir: string = getAgentDir(),
|
|
59
|
-
): WorkflowModelProfilePaths {
|
|
60
|
-
return {
|
|
61
|
-
user: join(agentDir, CONFIG_FILE_NAME),
|
|
62
|
-
project: join(cwd, ".pi", CONFIG_FILE_NAME),
|
|
63
|
-
};
|
|
50
|
+
export function workflowModelProfilePaths(cwd: string, agentDir?: string): WorkflowModelProfilePaths {
|
|
51
|
+
return configFilePaths(CONFIG_FILE_NAME, cwd, agentDir);
|
|
64
52
|
}
|
|
65
53
|
|
|
66
54
|
export function resolveWorkflowModelProfiles(input: {
|
|
@@ -123,22 +111,8 @@ export function resolveAgentModelProfile(
|
|
|
123
111
|
}
|
|
124
112
|
|
|
125
113
|
export function readWorkflowModelProfileFile(configPath: string): WorkflowModelProfileFile {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
contents = readFileSync(configPath, "utf8");
|
|
129
|
-
} catch (error) {
|
|
130
|
-
if (isMissingPathError(error)) return { profiles: {} };
|
|
131
|
-
throw new Error(
|
|
132
|
-
`Could not read workflow model profiles at ${configPath}: ${unknownErrorMessage(error)}. Fix the file permissions or remove the file.`,
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
let parsed: unknown;
|
|
136
|
-
try {
|
|
137
|
-
parsed = JSON.parse(contents);
|
|
138
|
-
} catch (error) {
|
|
139
|
-
throw new Error(`Invalid JSON in workflow model profiles at ${configPath}: ${unknownErrorMessage(error)}. Fix or remove the file.`);
|
|
140
|
-
}
|
|
141
|
-
return parseWorkflowModelProfileFile(parsed, configPath);
|
|
114
|
+
const parsed = readJsonConfigFile(configPath, CONFIG_LABEL);
|
|
115
|
+
return parsed === undefined ? { profiles: {} } : parseWorkflowModelProfileFile(parsed, configPath);
|
|
142
116
|
}
|
|
143
117
|
|
|
144
118
|
export function setWorkflowModelProfile(input: {
|
|
@@ -239,19 +213,7 @@ function assertOnlyKeys(value: Record<string, unknown>, allowed: readonly string
|
|
|
239
213
|
}
|
|
240
214
|
|
|
241
215
|
function writeWorkflowModelProfileFile(configPath: string, config: WorkflowModelProfileFile): void {
|
|
242
|
-
|
|
243
|
-
try {
|
|
244
|
-
mkdirSync(dirname(configPath), { recursive: true });
|
|
245
|
-
writeFileSync(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
246
|
-
renameSync(temporaryPath, configPath);
|
|
247
|
-
} catch (error) {
|
|
248
|
-
try {
|
|
249
|
-
unlinkSync(temporaryPath);
|
|
250
|
-
} catch {
|
|
251
|
-
// Best effort: preserve the original write error.
|
|
252
|
-
}
|
|
253
|
-
throw new Error(`Could not write workflow model profiles at ${configPath}: ${unknownErrorMessage(error)}.`);
|
|
254
|
-
}
|
|
216
|
+
writeJsonConfigFile(configPath, config, CONFIG_LABEL);
|
|
255
217
|
}
|
|
256
218
|
|
|
257
219
|
function invalidConfig(configPath: string, reason: string): Error {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { WorkflowUsageSnapshot } from "./usage.ts";
|
|
2
2
|
|
|
3
|
-
export type AgentRowStatus = "queued" | "running" | "done" | "failed";
|
|
3
|
+
export type AgentRowStatus = "queued" | "running" | "done" | "failed" | "skipped";
|
|
4
4
|
export type WorkflowLaneItemStatus = "pending" | "running" | "success" | "warning" | "error";
|
|
5
5
|
|
|
6
6
|
export interface AgentRowSnapshot {
|
|
@@ -69,7 +69,9 @@ export class ProgressTracker {
|
|
|
69
69
|
private readonly lanes = new Map<string, WorkflowLaneItem[]>();
|
|
70
70
|
private readonly laneOverflow = new Map<string, number>();
|
|
71
71
|
private readonly rowsById = new Map<number, AgentRow>();
|
|
72
|
-
|
|
72
|
+
/** How to abort each running row's agent; never part of a snapshot, so it is never persisted. */
|
|
73
|
+
private readonly skipHandlers = new Map<number, () => void>();
|
|
74
|
+
private readonly agentCounts: Record<AgentRowStatus, number> = { queued: 0, running: 0, done: 0, failed: 0, skipped: 0 };
|
|
73
75
|
private readonly startedAt = Date.now();
|
|
74
76
|
private readonly laneItemLimit = laneItemLimitFromEnv();
|
|
75
77
|
private doneAt: number | undefined;
|
|
@@ -168,12 +170,13 @@ export class ProgressTracker {
|
|
|
168
170
|
return id;
|
|
169
171
|
}
|
|
170
172
|
|
|
171
|
-
agentStart(id: number): void {
|
|
173
|
+
agentStart(id: number, skip?: () => void): void {
|
|
172
174
|
const row = this.rowsById.get(id);
|
|
173
175
|
if (row) {
|
|
174
176
|
this.transitionAgentStatus(row, "running");
|
|
175
177
|
row.startedAt = Date.now();
|
|
176
178
|
row.error = undefined;
|
|
179
|
+
if (skip) this.skipHandlers.set(id, skip);
|
|
177
180
|
}
|
|
178
181
|
this.publish();
|
|
179
182
|
}
|
|
@@ -188,8 +191,9 @@ export class ProgressTracker {
|
|
|
188
191
|
}
|
|
189
192
|
|
|
190
193
|
agentDone(id: number): void {
|
|
194
|
+
this.skipHandlers.delete(id);
|
|
191
195
|
const row = this.rowsById.get(id);
|
|
192
|
-
if (row && row.status !== "failed") {
|
|
196
|
+
if (row && row.status !== "failed" && row.status !== "skipped") {
|
|
193
197
|
this.transitionAgentStatus(row, "done");
|
|
194
198
|
row.doneAt = Date.now();
|
|
195
199
|
}
|
|
@@ -197,6 +201,7 @@ export class ProgressTracker {
|
|
|
197
201
|
}
|
|
198
202
|
|
|
199
203
|
agentFailed(id: number, error: unknown): void {
|
|
204
|
+
this.skipHandlers.delete(id);
|
|
200
205
|
const row = this.rowsById.get(id);
|
|
201
206
|
if (row) {
|
|
202
207
|
this.transitionAgentStatus(row, "failed");
|
|
@@ -206,6 +211,28 @@ export class ProgressTracker {
|
|
|
206
211
|
this.publish();
|
|
207
212
|
}
|
|
208
213
|
|
|
214
|
+
agentSkipped(id: number): void {
|
|
215
|
+
this.skipHandlers.delete(id);
|
|
216
|
+
const row = this.rowsById.get(id);
|
|
217
|
+
if (row) {
|
|
218
|
+
this.transitionAgentStatus(row, "skipped");
|
|
219
|
+
row.doneAt = Date.now();
|
|
220
|
+
}
|
|
221
|
+
this.publish();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Abort one running agent at the user's request (the inspector's skip key); false when the row is
|
|
226
|
+
* no longer running. The row turns `skipped` once the agent's session has wound down.
|
|
227
|
+
*/
|
|
228
|
+
skipAgent(id: number): boolean {
|
|
229
|
+
const skip = this.skipHandlers.get(id);
|
|
230
|
+
if (!skip || this.rowsById.get(id)?.status !== "running") return false;
|
|
231
|
+
this.skipHandlers.delete(id);
|
|
232
|
+
skip();
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
|
|
209
236
|
updateUsage(snapshot: WorkflowUsageSnapshot): void {
|
|
210
237
|
this.usageSnapshot = snapshot;
|
|
211
238
|
this.publish();
|
|
@@ -238,7 +265,8 @@ export class ProgressTracker {
|
|
|
238
265
|
running: this.agentCounts.running,
|
|
239
266
|
done: this.agentCounts.done,
|
|
240
267
|
failed: this.agentCounts.failed,
|
|
241
|
-
|
|
268
|
+
skipped: this.agentCounts.skipped,
|
|
269
|
+
total: this.agentCounts.queued + this.agentCounts.running + this.agentCounts.done + this.agentCounts.failed + this.agentCounts.skipped,
|
|
242
270
|
};
|
|
243
271
|
}
|
|
244
272
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { BeforeAgentStartEventResult, NormalizedBuildSystemPromptOptions } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
type PromptSectionOptions = Pick<NormalizedBuildSystemPromptOptions, "sections" | "forceSystemPrompt">;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Finish adding the `name` section from a `before_agent_start` handler. Normally the section in
|
|
7
|
+
* `options.sections` is enough and pi appends it as a patch. An earlier handler that forced the whole
|
|
8
|
+
* prompt makes pi render no sections, so the section is appended to that text instead; pi hands the
|
|
9
|
+
* returned prompt to later handlers as their `forceSystemPrompt`, so several sections chain.
|
|
10
|
+
*/
|
|
11
|
+
export function appendSectionToForcedPrompt(options: PromptSectionOptions, name: string): BeforeAgentStartEventResult | undefined {
|
|
12
|
+
const text = options.sections[name];
|
|
13
|
+
if (options.forceSystemPrompt === undefined || text === undefined) return undefined;
|
|
14
|
+
return { systemPrompt: `${options.forceSystemPrompt}\n\n<${name}>\n${text}\n</${name}>` };
|
|
15
|
+
}
|
|
@@ -29,7 +29,8 @@ export function normalizeResearchLanes(plan: ResearchPlan): ResearchLane[] {
|
|
|
29
29
|
return lanes;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
/** Distinct claims, most important first, bounded to {@link MAX_VERIFICATION_CLAIMS}; `overCap` counts the claims cut. */
|
|
33
|
+
export function buildClaimCandidates(results: readonly ResearchLaneResult[]): { candidates: ResearchClaimCandidate[]; overCap: number } {
|
|
33
34
|
const byClaim = new Map<string, { claim: string; importance: ResearchEvidence["importance"]; evidence: ResearchEvidence[] }>();
|
|
34
35
|
for (const item of results.flatMap((result) => result.evidence.map(sanitizeEvidence).filter(isDefined))) {
|
|
35
36
|
const key = item.claim.toLowerCase();
|
|
@@ -41,9 +42,8 @@ export function buildClaimCandidates(results: readonly ResearchLaneResult[]): Re
|
|
|
41
42
|
if (IMPORTANCE_RANK[item.importance] < IMPORTANCE_RANK[current.importance]) current.importance = item.importance;
|
|
42
43
|
current.evidence = dedupeEvidence([...current.evidence, item]);
|
|
43
44
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
.slice(0, MAX_VERIFICATION_CLAIMS);
|
|
45
|
+
const ranked = [...byClaim.values()].sort((left, right) => IMPORTANCE_RANK[left.importance] - IMPORTANCE_RANK[right.importance]);
|
|
46
|
+
return { candidates: ranked.slice(0, MAX_VERIFICATION_CLAIMS), overCap: Math.max(0, ranked.length - MAX_VERIFICATION_CLAIMS) };
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
export function sanitizeVerification(
|
|
@@ -438,7 +438,7 @@ export async function captureWorkflowResumeContext(
|
|
|
438
438
|
if (mod.source.kind === "unverifiable") {
|
|
439
439
|
return unverifiableWorkflowResumeContext(mod.meta.name, mod.source.reason);
|
|
440
440
|
}
|
|
441
|
-
if (mod.source.kind === "fingerprint") {
|
|
441
|
+
if (mod.source.kind === "fingerprint" || mod.source.selfContained) {
|
|
442
442
|
return mod.source.fingerprint.length > 0
|
|
443
443
|
? { kind: "verified", name: mod.meta.name, sourceFingerprint: mod.source.fingerprint }
|
|
444
444
|
: unverifiableWorkflowResumeContext(mod.meta.name, "workflow source fingerprint is empty");
|