pi-extensible-workflows 2.0.0 → 3.1.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 +18 -11
- package/dist/src/agent-execution.d.ts +4 -94
- package/dist/src/agent-execution.js +34 -208
- package/dist/src/budget.d.ts +38 -0
- package/dist/src/budget.js +160 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +60 -8
- package/dist/src/doctor-cleanup.d.ts +41 -0
- package/dist/src/doctor-cleanup.js +597 -0
- package/dist/src/doctor.d.ts +7 -2
- package/dist/src/doctor.js +127 -22
- package/dist/src/execution.d.ts +17 -0
- package/dist/src/execution.js +630 -0
- package/dist/src/host.d.ts +101 -0
- package/dist/src/host.js +3084 -0
- package/dist/src/index.d.ts +13 -634
- package/dist/src/index.js +10 -4432
- package/dist/src/persistence.d.ts +9 -19
- package/dist/src/persistence.js +175 -61
- package/dist/src/registry.d.ts +43 -0
- package/dist/src/registry.js +279 -0
- package/dist/src/session-inspector.js +5 -3
- package/dist/src/types.d.ts +692 -0
- package/dist/src/types.js +27 -0
- package/dist/src/utils.d.ts +32 -0
- package/dist/src/utils.js +168 -0
- package/dist/src/validation.d.ts +28 -0
- package/dist/src/validation.js +832 -0
- package/package.json +3 -2
- package/skills/pi-extensible-workflows/SKILL.md +69 -34
- package/src/agent-execution.ts +37 -189
- package/src/budget.ts +75 -0
- package/src/cli.ts +47 -7
- package/src/doctor-cleanup.ts +337 -0
- package/src/doctor.ts +117 -24
- package/src/execution.ts +540 -0
- package/src/host.ts +2598 -0
- package/src/index.ts +14 -3856
- package/src/persistence.ts +122 -37
- package/src/registry.ts +240 -0
- package/src/session-inspector.ts +5 -3
- package/src/types.ts +158 -0
- package/src/utils.ts +142 -0
- package/src/validation.ts +688 -0
package/src/budget.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { WorkflowError, type BudgetDimension, type BudgetEvent, type BudgetLimits, type WorkflowBudget, type WorkflowBudgetPatch, type WorkflowBudgetUsage, type AgentAccounting, type RunState } from "./types.js";
|
|
2
|
+
import { fail, object } from "./utils.js";
|
|
3
|
+
|
|
4
|
+
function nonNegativeInteger(value: unknown): value is number { return Number.isInteger(value) && (value as number) >= 0; }
|
|
5
|
+
function nonNegativeFinite(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; }
|
|
6
|
+
export function validateBudget(value: unknown): WorkflowBudget | undefined {
|
|
7
|
+
if (value === undefined) return undefined;
|
|
8
|
+
if (!object(value)) fail("INVALID_METADATA", "budget must be an object");
|
|
9
|
+
const result: WorkflowBudget = {};
|
|
10
|
+
for (const [dimension, raw] of Object.entries(value)) {
|
|
11
|
+
if (!["tokens", "costUsd", "durationMs", "agentLaunches"].includes(dimension)) fail("INVALID_METADATA", `Unknown budget dimension: ${dimension}`);
|
|
12
|
+
if (!object(raw)) fail("INVALID_METADATA", `${dimension} budget must be an object`);
|
|
13
|
+
if (Object.keys(raw).some((key) => key !== "soft" && key !== "hard")) fail("INVALID_METADATA", `${dimension} budget has an unknown limit`);
|
|
14
|
+
const isCost = dimension === "costUsd";
|
|
15
|
+
for (const key of ["soft", "hard"] as const) if (raw[key] !== undefined && !(isCost ? nonNegativeFinite(raw[key]) : nonNegativeInteger(raw[key]))) fail("INVALID_METADATA", `${dimension}.${key} must be a non-negative ${isCost ? "finite number" : "integer"}`);
|
|
16
|
+
if (raw.soft !== undefined && raw.soft !== null && raw.hard !== undefined && raw.hard !== null && raw.soft >= raw.hard) fail("INVALID_METADATA", `${dimension}.soft must be less than hard`);
|
|
17
|
+
const limits: BudgetLimits = {};
|
|
18
|
+
if (raw.soft !== undefined) limits.soft = raw.soft as number;
|
|
19
|
+
if (raw.hard !== undefined) limits.hard = raw.hard as number;
|
|
20
|
+
if (Object.keys(limits).length) (result as Record<string, BudgetLimits>)[dimension] = limits;
|
|
21
|
+
}
|
|
22
|
+
return Object.freeze(result);
|
|
23
|
+
}
|
|
24
|
+
export function validateBudgetPatch(value: unknown): WorkflowBudgetPatch {
|
|
25
|
+
if (!object(value)) fail("INVALID_METADATA", "budget patch must be an object");
|
|
26
|
+
const result: WorkflowBudgetPatch = {};
|
|
27
|
+
for (const [dimension, raw] of Object.entries(value)) {
|
|
28
|
+
if (!["tokens", "costUsd", "durationMs", "agentLaunches"].includes(dimension)) fail("INVALID_METADATA", `Unknown budget dimension: ${dimension}`);
|
|
29
|
+
if (raw === null) { (result as Record<string, null>)[dimension] = null; continue; }
|
|
30
|
+
if (!object(raw) || Object.keys(raw).some((key) => key !== "soft" && key !== "hard")) fail("INVALID_METADATA", `${dimension} budget patch must contain only soft and hard`);
|
|
31
|
+
const limits: { soft?: number | null; hard?: number | null } = {};
|
|
32
|
+
for (const key of ["soft", "hard"] as const) if (Object.prototype.hasOwnProperty.call(raw, key)) {
|
|
33
|
+
if (raw[key] === null) limits[key] = null;
|
|
34
|
+
else { const checked = validateBudget({ [dimension]: { [key]: raw[key] } })?.[dimension as BudgetDimension]; if (checked?.[key] !== undefined) limits[key] = checked[key]; }
|
|
35
|
+
}
|
|
36
|
+
if (limits.soft !== null && limits.hard !== null && limits.soft !== undefined && limits.hard !== undefined && limits.soft >= limits.hard) fail("INVALID_METADATA", `${dimension}.soft must be less than hard`);
|
|
37
|
+
(result as Record<string, { soft?: number | null; hard?: number | null }>)[dimension] = limits;
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
export function budgetUsage(value?: Partial<WorkflowBudgetUsage>): WorkflowBudgetUsage { return { tokens: value?.tokens ?? 0, costUsd: value?.costUsd ?? 0, durationMs: value?.durationMs ?? 0, agentLaunches: value?.agentLaunches ?? 0 }; }
|
|
42
|
+
export class WorkflowBudgetRuntime {
|
|
43
|
+
readonly #now: () => number;
|
|
44
|
+
readonly #onChange: (() => void) | undefined;
|
|
45
|
+
readonly #injected = new Set<string>();
|
|
46
|
+
readonly #seen = new Set<string>();
|
|
47
|
+
#active: boolean;
|
|
48
|
+
#activeSince: number | undefined;
|
|
49
|
+
#usage: WorkflowBudgetUsage;
|
|
50
|
+
#events: BudgetEvent[];
|
|
51
|
+
#turnAccounting?: { input: number; output: number; cost: number };
|
|
52
|
+
constructor(readonly budget: WorkflowBudget | undefined, readonly version = 1, usage?: Partial<WorkflowBudgetUsage>, events: readonly BudgetEvent[] = [], options: { now?: () => number; onChange?: () => void; active?: boolean } = {}) { this.#now = options.now ?? (() => Date.now()); this.#onChange = options.onChange; this.#active = options.active ?? true; this.#activeSince = this.#active ? this.#now() : undefined; this.#usage = budgetUsage(usage); this.#events = [...events]; for (const event of events) if (event.budgetVersion === version) this.#seen.add(event.type); }
|
|
53
|
+
get usage(): WorkflowBudgetUsage { this.#syncDuration(); return { ...this.#usage }; }
|
|
54
|
+
get events(): readonly BudgetEvent[] { return this.#events; }
|
|
55
|
+
get hardExhausted(): boolean { return this.#events.some((event) => event.type === "hard_exhausted" && event.budgetVersion === this.version); }
|
|
56
|
+
checkAgentLaunch(): void { this.#checkHard(["agentLaunches"]); }
|
|
57
|
+
beforeAttempt(): void { this.#checkHard(["agentLaunches"]); this.#usage.agentLaunches += 1; this.#evaluate(); }
|
|
58
|
+
beforeTurn(): void { this.#syncDuration(); this.#evaluate(); this.#checkHard(["tokens", "costUsd", "durationMs"]); }
|
|
59
|
+
afterTurn(accounting: AgentAccounting, final: boolean): void { this.#syncDuration(); this.#applyTurn(accounting, final, this.#turnAccounting); this.#turnAccounting = { input: accounting.input, output: accounting.output, cost: accounting.cost }; }
|
|
60
|
+
#applyTurn(accounting: AgentAccounting, final: boolean, previous = { input: 0, output: 0, cost: 0 }): void { this.#usage.tokens += Math.max(0, accounting.input - previous.input) + Math.max(0, accounting.output - previous.output); this.#usage.costUsd += Math.max(0, accounting.cost - previous.cost); this.#evaluate(); if (!final) this.#checkHard(["tokens", "costUsd", "durationMs"]); }
|
|
61
|
+
instruction(agentId = "agent"): string | undefined { if (!this.#hasSoftCrossed() || this.#injected.has(agentId)) return undefined; this.#injected.add(agentId); return `The workflow budget soft limit has been reached. Finish the requested output now, preserving any required output schema. Current usage: ${JSON.stringify(this.usage)}. Do not start additional model work unless it is required to produce the final requested result.`; }
|
|
62
|
+
forAgent(agentId: string) { let attempt = 0; let previous: { input: number; output: number; cost: number } | undefined; return { beforeAttempt: () => { attempt += 1; previous = undefined; this.beforeAttempt(); }, beforeTurn: () => { this.beforeTurn(); }, afterTurn: (accounting: AgentAccounting, final: boolean) => { this.#applyTurn(accounting, final, previous); previous = { input: accounting.input, output: accounting.output, cost: accounting.cost }; }, instruction: () => this.instruction(`${agentId}:${String(attempt + 1)}`) }; }
|
|
63
|
+
transition(state: RunState): void { const active = state === "running"; if (active === this.#active) return; if (active) { this.#active = true; this.#activeSince = this.#now(); } else { this.#syncDuration(); this.#evaluate(); this.#active = false; this.#activeSince = undefined; } this.#onChange?.(); }
|
|
64
|
+
#syncDuration(): void { if (this.#active && this.#activeSince !== undefined) { const now = this.#now(); this.#usage.durationMs += Math.max(0, now - this.#activeSince); this.#activeSince = now; } }
|
|
65
|
+
#hasSoftCrossed(): boolean { return !!this.budget && (Object.entries(this.budget) as [BudgetDimension, BudgetLimits][]).some(([dimension, limits]) => limits.soft !== undefined && this.#usage[dimension] >= limits.soft); }
|
|
66
|
+
#checkHard(dimensions: readonly BudgetDimension[]): void { const exhausted = dimensions.filter((dimension) => { const hard = this.budget?.[dimension]?.hard; return hard !== undefined && this.#usage[dimension] >= hard; }); if (!exhausted.length) return; this.#record("hard_exhausted", exhausted); const detail = exhausted.map((dimension) => `${dimension} usage=${String(this.#usage[dimension])} hard=${String(this.budget?.[dimension]?.hard)}`).join(", "); throw new WorkflowError("BUDGET_EXHAUSTED", `Budget version ${String(this.version)} exhausted: ${detail}`); }
|
|
67
|
+
#evaluate(): void { const budget = this.budget; if (!budget) return; const soft = (Object.keys(budget) as BudgetDimension[]).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.soft !== undefined && this.#usage[dimension] >= limits.soft; }); if (soft.length) this.#record("soft_crossed", soft); const overrun = (Object.keys(budget) as BudgetDimension[]).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.hard !== undefined && this.#usage[dimension] > limits.hard; }); if (overrun.length) this.#record("hard_overrun", overrun); }
|
|
68
|
+
#record(type: BudgetEvent["type"], dimensions: readonly BudgetDimension[]): void { if (this.#seen.has(type)) return; this.#seen.add(type); this.#events.push({ type, budgetVersion: this.version, dimensions: [...dimensions], usage: this.usage, limits: structuredClone(this.budget ?? {}), at: this.#now() }); this.#onChange?.(); }
|
|
69
|
+
recordEvent(event: BudgetEvent): void { this.#events.push(structuredClone(event)); }
|
|
70
|
+
snapshot(): { usage: WorkflowBudgetUsage; budgetEvents: readonly BudgetEvent[] } { return { usage: this.usage, budgetEvents: [...this.#events] }; }
|
|
71
|
+
}
|
|
72
|
+
export function mergeBudget(budget: WorkflowBudget | undefined, patch: WorkflowBudgetPatch): WorkflowBudget | undefined { const merged: WorkflowBudget = structuredClone(budget ?? {}); for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"] as const) if (Object.prototype.hasOwnProperty.call(patch, dimension)) { const value = patch[dimension]; if (value === null) { Reflect.deleteProperty(merged, dimension); continue; } const next: BudgetLimits = { ...(merged[dimension] ?? {}) }; for (const key of ["soft", "hard"] as const) if (value && Object.prototype.hasOwnProperty.call(value, key)) { const limit = value[key]; if (limit === null) Reflect.deleteProperty(next, key); else if (limit !== undefined) next[key] = limit; } if (Object.keys(next).length) (merged as Record<string, BudgetLimits>)[dimension] = next; else Reflect.deleteProperty(merged, dimension); } return validateBudget(merged); }
|
|
73
|
+
export function budgetRelaxed(previous: WorkflowBudget | undefined, next: WorkflowBudget | undefined): boolean { for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"] as const) { const oldLimit = previous?.[dimension]; const newLimit = next?.[dimension]; for (const key of ["soft", "hard"] as const) if ((oldLimit?.[key] !== undefined && newLimit?.[key] === undefined) || (oldLimit?.[key] !== undefined && newLimit?.[key] !== undefined && newLimit[key] > oldLimit[key])) return true; } return false; }
|
|
74
|
+
export function exhaustedBudgetDimensions(budget: WorkflowBudget | undefined, usage: WorkflowBudgetUsage): BudgetDimension[] { if (!budget) return []; return (Object.keys(budget) as BudgetDimension[]).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.hard !== undefined && usage[dimension] >= limits.hard; }); }
|
|
75
|
+
export function resumeBudgetAllowed(budget: WorkflowBudget | undefined, usage: WorkflowBudgetUsage): boolean { return exhaustedBudgetDimensions(budget, usage).length === 0; }
|
package/src/cli.ts
CHANGED
|
@@ -7,7 +7,8 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
7
7
|
import { ProjectTrustStore, SessionManager, SettingsManager, createAgentSessionFromServices, createAgentSessionServices, getAgentDir, hasTrustRequiringProjectResources, type LoadExtensionsResult } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { Value } from "typebox/value";
|
|
9
9
|
import { doctor, doctorExitCode, formatDoctorReport, type DoctorOptions } from "./doctor.js";
|
|
10
|
-
import
|
|
10
|
+
import { doctorCleanup, doctorCleanupExitCode, formatDoctorCleanupReport, type DoctorCleanupOptions } from "./doctor-cleanup.js";
|
|
11
|
+
import workflowExtension, { formatWorkflowProgress, truncateWorkflowProgress, workflowCatalog, workflowSettingsPath, type JsonSchema, type JsonValue, type WorkflowProgressStyles } from "./index.js";
|
|
11
12
|
import { runSessionInspector, transcriptFileLines } from "./session-inspector.js";
|
|
12
13
|
import type { PersistedRun } from "./persistence.js";
|
|
13
14
|
import type { WorkflowCatalogFunction } from "./index.js";
|
|
@@ -158,6 +159,26 @@ function launcherHelpLines(): string[] {
|
|
|
158
159
|
}
|
|
159
160
|
function workflowUsage(): string { return [`Usage: pi-extensible-workflows run <workflow-name> [workflow arguments] | export <workflow-name> [--name <command>] [--output <path>] [--force]`, "", "Launcher options:", ...launcherHelpLines()].join("\n") + "\n"; }
|
|
160
161
|
function exportUsage(): string { return [`Usage: pi-extensible-workflows export <workflow-name> [--name <command>] [--output <path>] [--force]`, "", "Launcher options:", ...launcherHelpLines()].join("\n") + "\n"; }
|
|
162
|
+
export function parseDoctorCleanupArgs(rawArgs: readonly string[]): Required<Pick<DoctorCleanupOptions, "olderThanDays" | "yes">> {
|
|
163
|
+
let olderThanDays = 90;
|
|
164
|
+
let yes = false;
|
|
165
|
+
let seenDays = false;
|
|
166
|
+
for (let index = 0; index < rawArgs.length; index += 1) {
|
|
167
|
+
const token = rawArgs[index] as string;
|
|
168
|
+
if (token === "--yes") { yes = true; continue; }
|
|
169
|
+
const inline = token.startsWith("--older-than-days=") ? token.slice("--older-than-days=".length) : undefined;
|
|
170
|
+
if (token === "--older-than-days" || inline !== undefined) {
|
|
171
|
+
if (seenDays) throw new Error("--older-than-days may only be provided once");
|
|
172
|
+
const raw = inline ?? rawArgs[++index];
|
|
173
|
+
if (raw === undefined || !/^[1-9]\d*$/.test(raw)) throw new Error("older-than-days must be a positive integer");
|
|
174
|
+
const parsed = Number(raw);
|
|
175
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error("older-than-days must be a positive integer");
|
|
176
|
+
olderThanDays = parsed; seenDays = true; continue;
|
|
177
|
+
}
|
|
178
|
+
throw new Error(`Unknown cleanup option: ${token}`);
|
|
179
|
+
}
|
|
180
|
+
return { olderThanDays, yes };
|
|
181
|
+
}
|
|
161
182
|
function stripTrustOptions(rawArgs: readonly string[]): { args: string[]; trustOverride?: boolean } {
|
|
162
183
|
const args: string[] = [];
|
|
163
184
|
let trustOverride: boolean | undefined;
|
|
@@ -233,7 +254,7 @@ async function createWorkflowRuntime(options: WorkflowIo, shutdownHandlers: Shut
|
|
|
233
254
|
workflowExtension(headlessPi as never, homedir(), undefined, undefined, agentDir);
|
|
234
255
|
const workflowTool = tools.find((tool) => object(tool) && tool.name === "workflow") as HeadlessWorkflowTool | undefined;
|
|
235
256
|
if (!workflowTool) throw new Error("The workflow runtime could not be initialized");
|
|
236
|
-
return { catalog: workflowCatalog(), services, workflowTool, shutdownHandlers };
|
|
257
|
+
return { catalog: workflowCatalog({ cwd, projectTrusted: settingsManager.isProjectTrusted(), globalSettingsPath: workflowSettingsPath(agentDir) }), services, workflowTool, shutdownHandlers };
|
|
237
258
|
}
|
|
238
259
|
|
|
239
260
|
function availableModelInfo(services: WorkflowRuntime["services"], available = false): { provider: string; id: string }[] {
|
|
@@ -274,16 +295,25 @@ function writeLauncher(destination: string, workflowName: string, force: boolean
|
|
|
274
295
|
}
|
|
275
296
|
|
|
276
297
|
|
|
298
|
+
function terminalProgressStyles(enabled: boolean): WorkflowProgressStyles {
|
|
299
|
+
const style = (code: number) => enabled ? (text: string) => `\x1b[${String(code)}m${text}\x1b[0m` : (text: string) => text;
|
|
300
|
+
return { accent: style(36), success: style(32), error: style(31), warning: style(33), muted: style(90), dim: style(2), bold: style(1) };
|
|
301
|
+
}
|
|
277
302
|
class CliProgress {
|
|
278
303
|
#lastStable = "";
|
|
279
304
|
#lines = 0;
|
|
280
305
|
#frame = 0;
|
|
281
306
|
#run: PersistedRun | undefined;
|
|
282
307
|
#timer: ReturnType<typeof setInterval> | undefined;
|
|
283
|
-
|
|
308
|
+
#interactive: boolean;
|
|
309
|
+
#styles: WorkflowProgressStyles;
|
|
310
|
+
constructor(private readonly stderr: (text: string) => void, tty: boolean) {
|
|
311
|
+
this.#interactive = tty && process.env.NO_COLOR === undefined && process.env.TERM !== "dumb";
|
|
312
|
+
this.#styles = terminalProgressStyles(this.#interactive);
|
|
313
|
+
}
|
|
284
314
|
update(run: PersistedRun): void {
|
|
285
|
-
const stable = formatWorkflowProgress(run, "◇");
|
|
286
|
-
if (!this
|
|
315
|
+
const stable = formatWorkflowProgress(run, "◇", this.#styles);
|
|
316
|
+
if (!this.#interactive) { if (stable !== this.#lastStable) { this.#lastStable = stable; this.stderr(`${stable}\n`); } return; }
|
|
287
317
|
this.#run = run;
|
|
288
318
|
this.#timer ??= setInterval(() => { this.render(); }, 80);
|
|
289
319
|
this.#timer.unref();
|
|
@@ -293,13 +323,13 @@ class CliProgress {
|
|
|
293
323
|
if (!this.#run) return;
|
|
294
324
|
const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"][this.#frame++ % 10] ?? "◇";
|
|
295
325
|
const width = process.stderr.columns || 80;
|
|
296
|
-
const text = formatWorkflowProgress(this.#run, spinner
|
|
326
|
+
const text = truncateWorkflowProgress(formatWorkflowProgress(this.#run, spinner, this.#styles), width).join("\n");
|
|
297
327
|
this.stderr(`${this.#lines ? `\x1b[${String(this.#lines)}A` : ""}${this.#lines ? "" : "\x1b[?25l"}\x1b[0J${text}\n`);
|
|
298
328
|
this.#lines = text.split("\n").length;
|
|
299
329
|
}
|
|
300
330
|
finish(): void {
|
|
301
331
|
if (this.#timer) { clearInterval(this.#timer); this.#timer = undefined; }
|
|
302
|
-
if (this
|
|
332
|
+
if (this.#interactive && this.#lines) { this.stderr(`\x1b[${String(this.#lines)}A\x1b[0J\x1b[?25h`); this.#lines = 0; }
|
|
303
333
|
this.#run = undefined;
|
|
304
334
|
}
|
|
305
335
|
}
|
|
@@ -406,6 +436,16 @@ export async function runCli(args: readonly string[], options: CliOptions = {},
|
|
|
406
436
|
write(formatDoctorReport(report));
|
|
407
437
|
return doctorExitCode(report);
|
|
408
438
|
}
|
|
439
|
+
if (args[0] === "doctor" && args[1] === "cleanup") {
|
|
440
|
+
if (args.slice(2).some((arg) => arg === "--help" || arg === "-h")) { write("Usage: pi-extensible-workflows doctor cleanup [--older-than-days <days>] [--yes]\n"); return 0; }
|
|
441
|
+
try {
|
|
442
|
+
const parsed = parseDoctorCleanupArgs(args.slice(2));
|
|
443
|
+
const cleanupOptions: DoctorCleanupOptions = { ...parsed, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}) };
|
|
444
|
+
const report = await doctorCleanup(cleanupOptions);
|
|
445
|
+
write(formatDoctorCleanupReport(report));
|
|
446
|
+
return doctorCleanupExitCode(report);
|
|
447
|
+
} catch (error) { stderr(`Error: ${error instanceof Error ? error.message : String(error)}\n`); return 1; }
|
|
448
|
+
}
|
|
409
449
|
if (args[0] === "inspect" && args.length <= 2) {
|
|
410
450
|
try { await (options.inspect ?? runSessionInspector)(args[1]); return 0; }
|
|
411
451
|
catch (error) { write(`Error: ${error instanceof Error ? error.message : String(error)}\n`); return 1; }
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, readdir, readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { validateBudget } from "./budget.js";
|
|
6
|
+
import { RUN_STATES, type RunState } from "./types.js";
|
|
7
|
+
import { jsonValue, validateModelAliases } from "./utils.js";
|
|
8
|
+
import { validateSchema } from "./validation.js";
|
|
9
|
+
import { acquireSessionLease, hasLiveSessionLease, projectSessionsDirectory, RunStore, type PersistedRun, type SessionLease } from "./persistence.js";
|
|
10
|
+
|
|
11
|
+
const TERMINAL_STATES = new Set<RunState>(["completed", "failed", "stopped"]);
|
|
12
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
const REQUIRED_RUN_FILES = ["workflow.js", "state.json", "snapshot.json", "journal.json", "ownership.json", "worktrees.json", "borrowed-worktrees.json", "system-prompts.json"] as const;
|
|
14
|
+
const OPTIONAL_RUN_FILES = new Set(["result.json"]);
|
|
15
|
+
const RUN_DIRECTORIES = new Set(["worktrees"]);
|
|
16
|
+
const RUN_FILES = new Set<string>([...REQUIRED_RUN_FILES, ...OPTIONAL_RUN_FILES]);
|
|
17
|
+
const AGENT_STATES = new Set(["queued", "running", "waiting_for_child", "paused", "retrying", "completed", "failed", "cancelled"]);
|
|
18
|
+
const SCHEDULER_STATES = new Set(["queued", "running", "waiting_for_child", "paused", "retrying", "completed", "failed", "cancelled"]);
|
|
19
|
+
const BUDGET_DIMENSIONS = new Set(["tokens", "costUsd", "durationMs", "agentLaunches"]);
|
|
20
|
+
const BUDGET_EVENT_TYPES = new Set(["soft_crossed", "hard_overrun", "hard_exhausted", "adjustment_requested", "adjustment_approved", "adjustment_rejected"]);
|
|
21
|
+
|
|
22
|
+
export interface DoctorCleanupOptions { cwd?: string; home?: string; olderThanDays?: number; yes?: boolean; now?: number }
|
|
23
|
+
export interface CleanupRunResult { sessionId: string; runId: string; action: "candidate" | "skipped" | "deleted" | "failed"; state: string; stateMtimeMs: number; path: string; reason?: string }
|
|
24
|
+
export interface CleanupFailure { sessionId: string; runId?: string; message: string }
|
|
25
|
+
export interface CleanupSessionReport { sessionId: string; path: string; status: "preview" | "cleaned" | "skipped" | "failed"; reason?: string }
|
|
26
|
+
export interface DoctorCleanupReport { cwd: string; cutoffMs: number; olderThanDays: number; yes: boolean; sessions: readonly CleanupSessionReport[]; candidates: readonly CleanupRunResult[]; skipped: readonly CleanupRunResult[]; deleted: readonly CleanupRunResult[]; failures: readonly CleanupFailure[] }
|
|
27
|
+
|
|
28
|
+
type StoredRun = { sessionId: string; runId: string; store: RunStore; run: PersistedRun; stateMtimeMs: number; dependencies: readonly string[] };
|
|
29
|
+
type SessionScan = { sessionId: string; path: string; runs: readonly StoredRun[]; liveLease: boolean };
|
|
30
|
+
type SessionPlan = { candidates: readonly StoredRun[]; skipped: readonly CleanupRunResult[] };
|
|
31
|
+
|
|
32
|
+
function object(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
33
|
+
function textError(error: unknown): string { return error instanceof Error ? error.message : String(error); }
|
|
34
|
+
function positiveDays(value: number): number { if (!Number.isSafeInteger(value) || value < 1 || !Number.isFinite(value * DAY_MS)) throw new Error("older-than-days must be a positive integer"); return value; }
|
|
35
|
+
function runItem(entry: StoredRun, action: CleanupRunResult["action"], reason?: string): CleanupRunResult { return { sessionId: entry.sessionId, runId: entry.runId, action, state: entry.run.state, stateMtimeMs: entry.stateMtimeMs, path: entry.store.directory, ...(reason ? { reason } : {}) }; }
|
|
36
|
+
function sameNames(left: readonly string[], right: readonly string[]): boolean { return left.length === right.length && left.every((value, index) => value === right[index]); }
|
|
37
|
+
async function jsonFile(path: string): Promise<unknown> { return JSON.parse(await readFile(path, "utf8")) as unknown; }
|
|
38
|
+
async function requiredFile(path: string): Promise<void> { const info = await lstat(path); if (!info.isFile()) throw new Error(`Required artifact is not a regular file: ${path}`); }
|
|
39
|
+
function stringList(value: unknown, label: string, nonEmpty = false): void { if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || (nonEmpty && !item))) throw new Error(`${label} is invalid`); }
|
|
40
|
+
function optionalString(value: unknown, label: string): void { if (value !== undefined && typeof value !== "string") throw new Error(`${label} is invalid`); }
|
|
41
|
+
function nonNegativeInteger(value: unknown, label: string): void { if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(`${label} is invalid`); }
|
|
42
|
+
function positiveInteger(value: unknown, label: string): void { if (!Number.isSafeInteger(value) || (value as number) < 1) throw new Error(`${label} is invalid`); }
|
|
43
|
+
function finiteNumber(value: unknown, label: string): void { if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error(`${label} is invalid`); }
|
|
44
|
+
function model(value: unknown, label: string): void { if (!object(value) || typeof value.provider !== "string" || !value.provider || typeof value.model !== "string" || !value.model || (value.thinking !== undefined && !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value.thinking as string))) throw new Error(`${label} is invalid`); }
|
|
45
|
+
function accounting(value: unknown, label: string): void { if (!object(value)) throw new Error(`${label} is invalid`); for (const key of ["input", "output", "cacheRead", "cacheWrite", "cost"]) finiteNumber(value[key], `${label}.${key}`); }
|
|
46
|
+
function resourceExclusions(value: unknown, label: string): void { if (value === undefined) return; if (!object(value)) throw new Error(`${label} is invalid`); if (value.skills !== undefined) stringList(value.skills, `${label}.skills`); if (value.extensions !== undefined) stringList(value.extensions, `${label}.extensions`); }
|
|
47
|
+
function agentDefinition(value: unknown, label: string): void { if (!object(value)) throw new Error(`${label} is invalid`); optionalString(value.prompt, `${label}.prompt`); optionalString(value.description, `${label}.description`); optionalString(value.model, `${label}.model`); if (value.thinking !== undefined && !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value.thinking as string)) throw new Error(`${label}.thinking is invalid`); if (value.tools !== undefined) stringList(value.tools, `${label}.tools`); resourceExclusions(value.disabledAgentResources, `${label}.disabledAgentResources`); }
|
|
48
|
+
function validateScheduledOptions(value: unknown, label: string): void { if (!object(value) || typeof value.label !== "string" || !value.label || typeof value.cwd !== "string" || !value.cwd) throw new Error(`${label} is invalid`); optionalString(value.requestedLabel, `${label}.requestedLabel`); optionalString(value.parentBreadcrumb, `${label}.parentBreadcrumb`); stringList(value.tools, `${label}.tools`); optionalString(value.worktreeOwner, `${label}.worktreeOwner`); optionalString(value.model, `${label}.model`); if (value.thinking !== undefined && !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value.thinking as string)) throw new Error(`${label}.thinking is invalid`); optionalString(value.role, `${label}.role`); if (value.schema !== undefined) validateSchema(value.schema, `${label}.schema`); if (value.retries !== undefined) nonNegativeInteger(value.retries, `${label}.retries`); if (value.timeoutMs !== undefined && value.timeoutMs !== null) positiveInteger(value.timeoutMs, `${label}.timeoutMs`); if (value.agentOptions !== undefined && (!object(value.agentOptions) || !jsonValue(value.agentOptions))) throw new Error(`${label}.agentOptions is invalid`); if (value.agentIdentity !== undefined) { if (!object(value.agentIdentity) || !Array.isArray(value.agentIdentity.structuralPath) || value.agentIdentity.structuralPath.some((part) => typeof part !== "string") || typeof value.agentIdentity.callSite !== "string") throw new Error(`${label}.agentIdentity is invalid`); positiveInteger(value.agentIdentity.occurrence, `${label}.agentIdentity.occurrence`); optionalString(value.agentIdentity.parentBreadcrumb, `${label}.agentIdentity.parentBreadcrumb`); optionalString(value.agentIdentity.worktreeOwner, `${label}.agentIdentity.worktreeOwner`); } }
|
|
49
|
+
function validateAgent(value: unknown, label: string): void { if (!object(value) || typeof value.id !== "string" || !value.id || typeof value.name !== "string" || !value.name || typeof value.path !== "string" || !value.path || typeof value.state !== "string" || !AGENT_STATES.has(value.state)) throw new Error(`${label} is invalid`); optionalString(value.label, `${label}.label`); optionalString(value.parentId, `${label}.parentId`); if (value.structuralPath !== undefined) stringList(value.structuralPath, `${label}.structuralPath`); optionalString(value.parentBreadcrumb, `${label}.parentBreadcrumb`); optionalString(value.worktreeOwner, `${label}.worktreeOwner`); optionalString(value.role, `${label}.role`); optionalString(value.requestedModel, `${label}.requestedModel`); model(value.model, `${label}.model`); stringList(value.tools, `${label}.tools`); nonNegativeInteger(value.attempts, `${label}.attempts`); if (value.attemptDetails !== undefined) { if (!Array.isArray(value.attemptDetails)) throw new Error(`${label}.attemptDetails is invalid`); for (const [index, attempt] of value.attemptDetails.entries()) { const at = `${label}.attemptDetails[${String(index)}]`; if (!object(attempt) || !Number.isSafeInteger(attempt.attempt) || Number(attempt.attempt) < 1 || typeof attempt.sessionId !== "string" || !attempt.sessionId || typeof attempt.sessionFile !== "string" || !attempt.sessionFile) throw new Error(`${at} is invalid`); accounting(attempt.accounting, `${at}.accounting`); if (attempt.error !== undefined && (!object(attempt.error) || typeof attempt.error.code !== "string" || typeof attempt.error.message !== "string")) throw new Error(`${at}.error is invalid`); if (attempt.setup !== undefined) { if (!object(attempt.setup) || !Array.isArray(attempt.setup.hookNames) || attempt.setup.hookNames.some((name) => typeof name !== "string") || typeof attempt.setup.cwd !== "string") throw new Error(`${at}.setup is invalid`); model(attempt.setup.model, `${at}.setup.model`); stringList(attempt.setup.tools, `${at}.setup.tools`); resourceExclusions(attempt.setup.disabledAgentResources, `${at}.setup.disabledAgentResources`); } } } if (value.accounting !== undefined) accounting(value.accounting, `${label}.accounting`); if (value.toolCalls !== undefined) { if (!Array.isArray(value.toolCalls)) throw new Error(`${label}.toolCalls is invalid`); for (const call of value.toolCalls) if (!object(call) || typeof call.id !== "string" || typeof call.name !== "string" || !["running", "completed", "failed"].includes(call.state as string)) throw new Error(`${label}.toolCalls is invalid`); } if (value.activity !== undefined && (!object(value.activity) || !["reasoning", "tool", "text"].includes(value.activity.kind as string) || typeof value.activity.text !== "string")) throw new Error(`${label}.activity is invalid`); }
|
|
50
|
+
function validateUsage(value: unknown, label: string): void { if (!object(value)) throw new Error(`${label} is invalid`); for (const key of ["tokens", "costUsd", "durationMs", "agentLaunches"]) finiteNumber(value[key], `${label}.${key}`); }
|
|
51
|
+
function validateBudgetEvents(value: unknown): void { if (value === undefined) return; if (!Array.isArray(value)) throw new Error("Persisted budget events are invalid"); for (const [index, event] of value.entries()) { const label = `budgetEvents[${String(index)}]`; if (!object(event) || typeof event.type !== "string" || !BUDGET_EVENT_TYPES.has(event.type) || !Number.isSafeInteger(event.budgetVersion) || Number(event.budgetVersion) < 1 || !Array.isArray(event.dimensions) || event.dimensions.some((dimension) => typeof dimension !== "string" || !BUDGET_DIMENSIONS.has(dimension)) || typeof event.at !== "number" || !Number.isFinite(event.at) || event.limits === undefined) throw new Error(`${label} is invalid`); validateUsage(event.usage, `${label}.usage`); validateBudget(event.limits); } }
|
|
52
|
+
function validateRunRecord(run: PersistedRun): void {
|
|
53
|
+
const value = run as unknown;
|
|
54
|
+
if (!object(value) || typeof value.id !== "string" || !value.id || typeof value.workflowName !== "string" || !value.workflowName || typeof value.cwd !== "string" || !value.cwd || typeof value.sessionId !== "string" || !value.sessionId || !RUN_STATES.includes(value.state as RunState) || !Array.isArray(value.agents) || !Array.isArray(value.nativeSessions)) throw new Error("Persisted run state is invalid");
|
|
55
|
+
const agents = value.agents as Record<string, unknown>[];
|
|
56
|
+
agents.forEach((agent, index) => { validateAgent(agent, `agents[${String(index)}]`); });
|
|
57
|
+
const agentIds = new Set<string>();
|
|
58
|
+
for (const agent of agents) {
|
|
59
|
+
const id = agent.id as string;
|
|
60
|
+
if (agentIds.has(id)) throw new Error(`Duplicate persisted agent ${id}`);
|
|
61
|
+
agentIds.add(id);
|
|
62
|
+
}
|
|
63
|
+
for (const agent of agents) {
|
|
64
|
+
const parentId = agent.parentId;
|
|
65
|
+
if (parentId !== undefined && (typeof parentId !== "string" || !agentIds.has(parentId))) throw new Error("Persisted agent has a missing parent");
|
|
66
|
+
const seen = new Set<string>();
|
|
67
|
+
let parent = typeof parentId === "string" ? parentId : undefined;
|
|
68
|
+
while (parent) {
|
|
69
|
+
if (seen.has(parent)) throw new Error("Persisted agent parent cycle");
|
|
70
|
+
seen.add(parent);
|
|
71
|
+
const parentAgent = agents.find((candidate) => candidate.id === parent);
|
|
72
|
+
parent = typeof parentAgent?.parentId === "string" ? parentAgent.parentId : undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const [index, session] of (value.nativeSessions as unknown[]).entries()) if (!object(session) || typeof session.sessionId !== "string" || !session.sessionId || typeof session.sessionFile !== "string" || !session.sessionFile) throw new Error(`nativeSessions[${String(index)}] is invalid`);
|
|
76
|
+
optionalString(value.parentRunId, "Persisted parent run");
|
|
77
|
+
if (value.retry !== undefined) {
|
|
78
|
+
if (!object(value.retry) || typeof value.retry.sourceRunId !== "string" || !value.retry.sourceRunId || typeof value.retry.lineageRootRunId !== "string" || !value.retry.lineageRootRunId) throw new Error("Persisted retry provenance is invalid");
|
|
79
|
+
const sourceRunId = value.retry.sourceRunId;
|
|
80
|
+
stringList(value.retry.completedPaths, "Persisted retry completed paths");
|
|
81
|
+
stringList(value.retry.incompletePaths, "Persisted retry incomplete paths");
|
|
82
|
+
stringList(value.retry.namedWorktrees, "Persisted retry named worktrees");
|
|
83
|
+
if (value.parentRunId !== sourceRunId) throw new Error("Persisted retry parent does not match its source");
|
|
84
|
+
}
|
|
85
|
+
optionalString(value.phase, "Persisted phase");
|
|
86
|
+
if (value.phaseHistory !== undefined) {
|
|
87
|
+
if (!Array.isArray(value.phaseHistory)) throw new Error("Persisted phase history is invalid");
|
|
88
|
+
for (const phase of value.phaseHistory) { if (!object(phase) || typeof phase.phase !== "string" || !phase.phase) throw new Error("Persisted phase history is invalid"); nonNegativeInteger(phase.afterAgent, "Persisted phase history afterAgent"); }
|
|
89
|
+
}
|
|
90
|
+
if (value.error !== undefined && (!object(value.error) || typeof value.error.code !== "string" || typeof value.error.message !== "string")) throw new Error("Persisted run error is invalid");
|
|
91
|
+
validateBudget(value.budget);
|
|
92
|
+
if (value.budgetVersion !== undefined) positiveInteger(value.budgetVersion, "Persisted budget version");
|
|
93
|
+
if (value.usage !== undefined) validateUsage(value.usage, "Persisted usage");
|
|
94
|
+
validateBudgetEvents(value.budgetEvents);
|
|
95
|
+
if (value.events !== undefined) { if (!Array.isArray(value.events)) throw new Error("Persisted run events are invalid"); for (const event of value.events) if (!object(event) || typeof event.type !== "string" || typeof event.message !== "string") throw new Error("Persisted run event is invalid"); }
|
|
96
|
+
}
|
|
97
|
+
function validateSnapshot(snapshot: unknown): void {
|
|
98
|
+
if (!object(snapshot) || typeof snapshot.script !== "string" || !snapshot.script || !jsonValue(snapshot.args) || !object(snapshot.metadata) || typeof snapshot.metadata.name !== "string" || !snapshot.metadata.name || (snapshot.metadata.description !== undefined && typeof snapshot.metadata.description !== "string") || !object(snapshot.settings) || !Number.isSafeInteger(snapshot.settings.concurrency) || Number(snapshot.settings.concurrency) < 1 || Number(snapshot.settings.concurrency) > 16 || !Array.isArray(snapshot.models) || snapshot.models.some((modelName) => typeof modelName !== "string") || !Array.isArray(snapshot.tools) || snapshot.tools.some((tool) => typeof tool !== "string") || !Array.isArray(snapshot.agentTypes) || snapshot.agentTypes.some((agentType) => typeof agentType !== "string") || !Array.isArray(snapshot.schemas)) throw new Error("Persisted launch snapshot is invalid");
|
|
99
|
+
if (snapshot.identityVersion !== undefined) positiveInteger(snapshot.identityVersion, "Persisted snapshot identity version");
|
|
100
|
+
if (snapshot.launchKind !== undefined && !["inline", "function"].includes(snapshot.launchKind as string)) throw new Error("Persisted snapshot launch kind is invalid");
|
|
101
|
+
if (snapshot.launchKind === "function" && (typeof snapshot.functionName !== "string" || !snapshot.functionName)) throw new Error("Persisted snapshot function name is invalid");
|
|
102
|
+
optionalString(snapshot.functionName, "Persisted snapshot function name");
|
|
103
|
+
optionalString(snapshot.settingsPath, "Persisted snapshot settings path");
|
|
104
|
+
if (snapshot.settingsSources !== undefined) { if (!object(snapshot.settingsSources) || typeof snapshot.settingsSources.concurrency !== "string" || typeof snapshot.settingsSources.modelAliases !== "string" || typeof snapshot.settingsSources.disabledAgentResources !== "string") throw new Error("Persisted snapshot settings sources are invalid"); }
|
|
105
|
+
validateBudget(snapshot.budget);
|
|
106
|
+
if (snapshot.modelAliases !== undefined) validateModelAliases(snapshot.modelAliases);
|
|
107
|
+
if (snapshot.settings.modelAliases !== undefined) validateModelAliases(snapshot.settings.modelAliases);
|
|
108
|
+
if (snapshot.phases !== undefined) stringList(snapshot.phases, "Persisted snapshot phases");
|
|
109
|
+
resourceExclusions(snapshot.settings.disabledAgentResources, "Persisted snapshot disabled resources");
|
|
110
|
+
if (snapshot.roles !== undefined) { if (!object(snapshot.roles)) throw new Error("Persisted snapshot roles are invalid"); for (const [name, definition] of Object.entries(snapshot.roles)) agentDefinition(definition, `Persisted snapshot role ${name}`); }
|
|
111
|
+
if (snapshot.projectRoles !== undefined) stringList(snapshot.projectRoles, "Persisted snapshot project roles");
|
|
112
|
+
for (const [index, schema] of snapshot.schemas.entries()) validateSchema(schema, `Persisted snapshot schema[${String(index)}]`);
|
|
113
|
+
}
|
|
114
|
+
function validateJournal(value: unknown): void { if (!object(value) || !object(value.completed) || (value.awaiting !== undefined && !object(value.awaiting)) || (value.decisions !== undefined && !object(value.decisions))) throw new Error("Persisted workflow journal is invalid"); for (const operation of Object.values(value.completed)) if (!object(operation) || typeof operation.path !== "string" || !operation.path || !jsonValue(operation.value)) throw new Error("Persisted completed operation is invalid"); for (const checkpoint of Object.values(value.awaiting ?? {})) if (!object(checkpoint) || typeof checkpoint.path !== "string" || !checkpoint.path || typeof checkpoint.name !== "string" || !checkpoint.name || typeof checkpoint.prompt !== "string" || !jsonValue(checkpoint.context)) throw new Error("Persisted awaiting checkpoint is invalid"); for (const decision of Object.values(value.decisions ?? {})) { if (!object(decision) || decision.kind !== "budget" || typeof decision.proposalId !== "string" || !decision.proposalId || typeof decision.runId !== "string" || !decision.runId || !object(decision.previous) || !object(decision.proposed) || !Number.isSafeInteger(decision.budgetVersion) || Number(decision.budgetVersion) < 1) throw new Error("Persisted budget decision is invalid"); validateUsage(decision.consumed, "Persisted budget decision usage"); validateBudget(decision.previous); validateBudget(decision.proposed); } }
|
|
115
|
+
function validateSystemPrompts(value: unknown): void {
|
|
116
|
+
if (!object(value) || value.version !== 1 || !Array.isArray(value.entries)) throw new Error("Persisted system prompts are invalid");
|
|
117
|
+
for (const [index, entry] of value.entries.entries()) {
|
|
118
|
+
const label = `system-prompts.entries[${String(index)}]`;
|
|
119
|
+
if (!object(entry) || typeof entry.sessionId !== "string" || !entry.sessionId || !Number.isSafeInteger(entry.attempt) || Number(entry.attempt) < 1 || !Number.isSafeInteger(entry.turn) || Number(entry.turn) < 1 || typeof entry.prompt !== "string" || typeof entry.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(entry.sha256) || createHash("sha256").update(entry.prompt).digest("hex") !== entry.sha256) throw new Error(`${label} is invalid`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function validateRunDirectory(store: RunStore): Promise<void> {
|
|
123
|
+
const entries = await readdir(store.directory, { withFileTypes: true });
|
|
124
|
+
for (const entry of entries) {
|
|
125
|
+
if (RUN_DIRECTORIES.has(entry.name)) { if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error(`Run artifact is not a regular directory: ${join(store.directory, entry.name)}`); continue; }
|
|
126
|
+
if (!RUN_FILES.has(entry.name)) throw new Error(`Run inventory contains an unrecognized artifact: ${join(store.directory, entry.name)}`);
|
|
127
|
+
if (!entry.isFile() || entry.isSymbolicLink()) throw new Error(`Run artifact is not a regular file: ${join(store.directory, entry.name)}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async function validateRunArtifacts(store: RunStore, workflowScript: string, state: RunState): Promise<readonly { sourceRunId: string }[]> {
|
|
131
|
+
await validateRunDirectory(store);
|
|
132
|
+
for (const name of REQUIRED_RUN_FILES) await requiredFile(join(store.directory, name));
|
|
133
|
+
if (await readFile(join(store.directory, "workflow.js"), "utf8") !== workflowScript) throw new Error("Persisted workflow source does not match its launch snapshot");
|
|
134
|
+
validateSystemPrompts(await jsonFile(join(store.directory, "system-prompts.json")));
|
|
135
|
+
const result = await jsonFile(join(store.directory, "result.json")).catch((error: unknown) => { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; });
|
|
136
|
+
if (result === undefined && state === "completed") throw new Error("Completed run result is missing");
|
|
137
|
+
if (result !== undefined && !jsonValue(result)) throw new Error("Persisted workflow result is invalid");
|
|
138
|
+
validateJournal(await jsonFile(join(store.directory, "journal.json")));
|
|
139
|
+
const rawOwnership = await jsonFile(join(store.directory, "ownership.json"));
|
|
140
|
+
if (!Array.isArray(rawOwnership)) throw new Error("Persisted ownership records are invalid");
|
|
141
|
+
const ownership = rawOwnership as unknown[];
|
|
142
|
+
const ownershipIds = new Set<string>();
|
|
143
|
+
for (const [index, record] of ownership.entries()) {
|
|
144
|
+
const label = `ownership[${String(index)}]`;
|
|
145
|
+
if (!object(record) || typeof record.id !== "string" || !record.id || ownershipIds.has(record.id) || typeof record.label !== "string" || !record.label || typeof record.state !== "string" || !SCHEDULER_STATES.has(record.state)) throw new Error(`${label} is invalid`);
|
|
146
|
+
ownershipIds.add(record.id);
|
|
147
|
+
optionalString(record.parentId, `${label}.parentId`);
|
|
148
|
+
validateScheduledOptions(record.options, `${label}.options`);
|
|
149
|
+
}
|
|
150
|
+
for (const record of ownership) if (object(record) && record.parentId !== undefined && (typeof record.parentId !== "string" || !ownershipIds.has(record.parentId))) throw new Error("Persisted ownership parent is missing");
|
|
151
|
+
await store.validateDeletionWorktrees();
|
|
152
|
+
const borrowed = await store.borrowedWorktrees();
|
|
153
|
+
await store.validateBorrowedWorktrees();
|
|
154
|
+
return borrowed.map(({ sourceRunId }) => ({ sourceRunId }));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function sessionEntries(path: string): Promise<readonly import("node:fs").Dirent[]> {
|
|
158
|
+
const info = await lstat(path);
|
|
159
|
+
if (!info.isDirectory()) throw new Error(`Session inventory is not a directory: ${path}`);
|
|
160
|
+
return readdir(path, { withFileTypes: true });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function scanSession(cwd: string, sessionId: string, home: string, expectedLease?: SessionLease): Promise<SessionScan> {
|
|
164
|
+
const path = join(projectSessionsDirectory(cwd, home), sessionId);
|
|
165
|
+
const rootEntries = await sessionEntries(path);
|
|
166
|
+
const runsEntry = rootEntries.find((entry) => entry.name === "runs");
|
|
167
|
+
if (!runsEntry || !runsEntry.isDirectory() || runsEntry.isSymbolicLink()) throw new Error(`Session inventory has no regular runs directory: ${path}`);
|
|
168
|
+
if (rootEntries.some((entry) => entry.name !== "runs")) throw new Error(`Session inventory contains an unrecognized entry: ${path}`);
|
|
169
|
+
const runsPath = join(path, "runs");
|
|
170
|
+
const before = await sessionEntries(runsPath);
|
|
171
|
+
const runEntries = before.filter((entry) => entry.name !== "owner.json");
|
|
172
|
+
if (before.some((entry) => entry.name === "owner.json" && entry.isSymbolicLink())) throw new Error(`Session ownership lease is not a regular file: ${runsPath}`);
|
|
173
|
+
for (const entry of runEntries) if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name.startsWith(".")) throw new Error(`Session inventory contains an unrecognized entry: ${join(runsPath, entry.name)}`);
|
|
174
|
+
const ownerPath = join(runsPath, "owner.json");
|
|
175
|
+
let liveLease = false;
|
|
176
|
+
if (expectedLease && expectedLease.path === ownerPath) {
|
|
177
|
+
const owned = await jsonFile(ownerPath);
|
|
178
|
+
if (!object(owned) || owned.token !== expectedLease.token) throw new Error("Session ownership lease changed before deletion");
|
|
179
|
+
} else liveLease = await hasLiveSessionLease(cwd, sessionId, home);
|
|
180
|
+
const runs: StoredRun[] = [];
|
|
181
|
+
for (const entry of runEntries) {
|
|
182
|
+
const runId = entry.name;
|
|
183
|
+
const store = new RunStore(cwd, sessionId, runId, home);
|
|
184
|
+
try {
|
|
185
|
+
const beforeState = await stat(join(store.directory, "state.json"));
|
|
186
|
+
const loaded = await store.load();
|
|
187
|
+
validateRunRecord(loaded.run);
|
|
188
|
+
validateSnapshot(loaded.snapshot);
|
|
189
|
+
if (loaded.run.parentRunId !== undefined) await store.validateParentRun(loaded.run.parentRunId);
|
|
190
|
+
if (loaded.run.retry) await store.validateRetrySource();
|
|
191
|
+
const borrowed = await validateRunArtifacts(store, loaded.snapshot.script, loaded.run.state);
|
|
192
|
+
const afterState = await stat(join(store.directory, "state.json"));
|
|
193
|
+
if (beforeState.mtimeMs !== afterState.mtimeMs) throw new Error("Persisted state changed while scanning");
|
|
194
|
+
const dependencies = new Set<string>();
|
|
195
|
+
if (loaded.run.parentRunId !== undefined) dependencies.add(loaded.run.parentRunId);
|
|
196
|
+
if (loaded.run.retry) { dependencies.add(loaded.run.retry.sourceRunId); dependencies.add(loaded.run.retry.lineageRootRunId); }
|
|
197
|
+
for (const binding of borrowed) dependencies.add(binding.sourceRunId);
|
|
198
|
+
if (dependencies.has(runId)) throw new Error("Persisted run depends on itself");
|
|
199
|
+
runs.push({ sessionId, runId, store, run: loaded.run, stateMtimeMs: afterState.mtimeMs, dependencies: [...dependencies] });
|
|
200
|
+
} catch (error) { throw new Error(`Run ${runId} is corrupt or incomplete: ${textError(error)}`, { cause: error }); }
|
|
201
|
+
}
|
|
202
|
+
const after = await sessionEntries(runsPath);
|
|
203
|
+
const beforeNames = before.map(({ name }) => name).sort();
|
|
204
|
+
const afterNames = after.map(({ name }) => name).sort();
|
|
205
|
+
if (!sameNames(beforeNames, afterNames)) throw new Error("Session inventory changed while scanning");
|
|
206
|
+
const known = new Set(runs.map(({ runId }) => runId));
|
|
207
|
+
for (const run of runs) for (const dependency of run.dependencies) if (!known.has(dependency)) throw new Error(`Run ${run.runId} depends on missing run ${dependency}`);
|
|
208
|
+
const visiting = new Set<string>();
|
|
209
|
+
const visited = new Set<string>();
|
|
210
|
+
const visit = (runId: string): void => {
|
|
211
|
+
if (visiting.has(runId)) throw new Error("Persisted run dependency cycle prevents safe cleanup");
|
|
212
|
+
if (visited.has(runId)) return;
|
|
213
|
+
visiting.add(runId);
|
|
214
|
+
const run = runs.find(({ runId: current }) => current === runId);
|
|
215
|
+
for (const dependency of run?.dependencies ?? []) visit(dependency);
|
|
216
|
+
visiting.delete(runId);
|
|
217
|
+
visited.add(runId);
|
|
218
|
+
};
|
|
219
|
+
for (const run of runs) visit(run.runId);
|
|
220
|
+
return { sessionId, path, runs, liveLease };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function recheckCandidate(entry: StoredRun, cutoffMs: number): Promise<string | undefined> {
|
|
224
|
+
const before = await stat(join(entry.store.directory, "state.json")).catch(() => undefined);
|
|
225
|
+
if (!before) return "State record disappeared before deletion";
|
|
226
|
+
const loaded = await entry.store.load().catch((error: unknown) => { throw new Error(`Candidate could not be reloaded: ${textError(error)}`); });
|
|
227
|
+
validateRunRecord(loaded.run);
|
|
228
|
+
if (loaded.run.id !== entry.runId || loaded.run.state !== entry.run.state || !TERMINAL_STATES.has(loaded.run.state)) return "Candidate state changed before deletion";
|
|
229
|
+
const after = await stat(join(entry.store.directory, "state.json"));
|
|
230
|
+
if (before.mtimeMs !== after.mtimeMs || after.mtimeMs !== entry.stateMtimeMs) return "Candidate state record changed before deletion";
|
|
231
|
+
if (after.mtimeMs >= cutoffMs) return "Candidate is no longer older than the cutoff";
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function planSession(scan: SessionScan, cutoffMs: number): SessionPlan {
|
|
236
|
+
if (scan.liveLease) return { candidates: [], skipped: scan.runs.map((entry) => runItem(entry, "skipped", "Session has a live ownership lease")) };
|
|
237
|
+
const oldTerminal = new Set(scan.runs.filter(({ run, stateMtimeMs }) => TERMINAL_STATES.has(run.state) && stateMtimeMs < cutoffMs).map(({ runId }) => runId));
|
|
238
|
+
const protectedRuns = new Set<string>();
|
|
239
|
+
const visit = (runId: string) => { if (protectedRuns.has(runId)) return; protectedRuns.add(runId); const entry = scan.runs.find(({ runId: current }) => current === runId); for (const dependency of entry?.dependencies ?? []) visit(dependency); };
|
|
240
|
+
for (const entry of scan.runs) if (!oldTerminal.has(entry.runId)) for (const dependency of entry.dependencies) visit(dependency);
|
|
241
|
+
const skipped: CleanupRunResult[] = [];
|
|
242
|
+
for (const entry of scan.runs) {
|
|
243
|
+
if (!TERMINAL_STATES.has(entry.run.state)) skipped.push(runItem(entry, "skipped", `Run state ${entry.run.state} is active or resumable`));
|
|
244
|
+
else if (entry.stateMtimeMs >= cutoffMs) skipped.push(runItem(entry, "skipped", "State record is not older than the cutoff"));
|
|
245
|
+
else if (protectedRuns.has(entry.runId)) skipped.push(runItem(entry, "skipped", "A retained run depends on this run"));
|
|
246
|
+
}
|
|
247
|
+
return { candidates: scan.runs.filter(({ runId }) => oldTerminal.has(runId) && !protectedRuns.has(runId)), skipped };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function deletionOrder(scan: SessionScan, candidates: readonly StoredRun[]): readonly StoredRun[] {
|
|
251
|
+
const candidateIds = new Set(candidates.map(({ runId }) => runId));
|
|
252
|
+
const remaining = new Set(candidateIds);
|
|
253
|
+
const ordered: StoredRun[] = [];
|
|
254
|
+
while (remaining.size) {
|
|
255
|
+
const next = candidates.find((entry) => remaining.has(entry.runId) && !candidates.some((child) => remaining.has(child.runId) && child.dependencies.includes(entry.runId)));
|
|
256
|
+
if (!next) throw new Error("Dependency cycle prevents safe cleanup");
|
|
257
|
+
ordered.push(next); remaining.delete(next.runId);
|
|
258
|
+
}
|
|
259
|
+
return ordered;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function storedSessionIds(cwd: string, home: string): Promise<readonly string[]> {
|
|
263
|
+
const path = projectSessionsDirectory(cwd, home);
|
|
264
|
+
let entries: readonly import("node:fs").Dirent[];
|
|
265
|
+
try { entries = await sessionEntries(path); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; }
|
|
266
|
+
if (entries.some((entry) => entry.isSymbolicLink())) throw new Error(`Project session inventory contains a symbolic link: ${path}`);
|
|
267
|
+
const invalid = entries.find((entry) => !entry.isDirectory());
|
|
268
|
+
if (invalid) throw new Error(`Project session inventory contains an unrecognized entry: ${join(path, invalid.name)}`);
|
|
269
|
+
return entries.filter((entry) => entry.isDirectory()).map(({ name }) => name).sort();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function addUnique(items: CleanupRunResult[], item: CleanupRunResult): void { if (!items.some((current) => current.sessionId === item.sessionId && current.runId === item.runId && current.action === item.action)) items.push(item); }
|
|
273
|
+
function addFailure(items: CleanupFailure[], sessionId: string, message: string, runId?: string): void { items.push({ sessionId, ...(runId ? { runId } : {}), message }); }
|
|
274
|
+
|
|
275
|
+
export async function doctorCleanup(options: DoctorCleanupOptions = {}): Promise<DoctorCleanupReport> {
|
|
276
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
277
|
+
const home = resolve(options.home ?? homedir());
|
|
278
|
+
const olderThanDays = positiveDays(options.olderThanDays ?? 90);
|
|
279
|
+
const yes = options.yes === true;
|
|
280
|
+
const now = options.now ?? Date.now();
|
|
281
|
+
if (!Number.isFinite(now)) throw new Error("Cleanup command start time is invalid");
|
|
282
|
+
const cutoffMs = now - olderThanDays * DAY_MS;
|
|
283
|
+
if (!Number.isFinite(cutoffMs) || !Number.isFinite(new Date(cutoffMs).getTime())) throw new Error("older-than-days produces an unrepresentable cutoff");
|
|
284
|
+
const sessions: CleanupSessionReport[] = [];
|
|
285
|
+
const candidates: CleanupRunResult[] = [];
|
|
286
|
+
const skipped: CleanupRunResult[] = [];
|
|
287
|
+
const deleted: CleanupRunResult[] = [];
|
|
288
|
+
const failures: CleanupFailure[] = [];
|
|
289
|
+
let sessionIds: readonly string[];
|
|
290
|
+
try { sessionIds = await storedSessionIds(cwd, home); } catch (error) { addFailure(failures, "(project)", textError(error)); return { cwd, cutoffMs, olderThanDays, yes, sessions, candidates, skipped, deleted, failures }; }
|
|
291
|
+
for (const sessionId of sessionIds) {
|
|
292
|
+
let initial: SessionScan;
|
|
293
|
+
try { initial = await scanSession(cwd, sessionId, home); } catch (error) { sessions.push({ sessionId, path: join(projectSessionsDirectory(cwd, home), sessionId), status: "failed", reason: textError(error) }); addFailure(failures, sessionId, textError(error)); continue; }
|
|
294
|
+
const initialPlan = planSession(initial, cutoffMs);
|
|
295
|
+
for (const item of initialPlan.candidates) addUnique(candidates, runItem(item, "candidate"));
|
|
296
|
+
for (const item of initialPlan.skipped) addUnique(skipped, item);
|
|
297
|
+
if (!yes || initial.liveLease) { sessions.push({ sessionId, path: initial.path, status: initial.liveLease ? "skipped" : "preview", ...(initial.liveLease ? { reason: "Session has a live ownership lease" } : {}) }); continue; }
|
|
298
|
+
let lease: SessionLease;
|
|
299
|
+
try { lease = await acquireSessionLease(cwd, sessionId, home); } catch (error) {
|
|
300
|
+
const message = textError(error);
|
|
301
|
+
if (/already owned|active ownership|RUN_OWNED/i.test(message)) { sessions.push({ sessionId, path: initial.path, status: "skipped", reason: "Session has a live ownership lease" }); continue; }
|
|
302
|
+
sessions.push({ sessionId, path: initial.path, status: "failed", reason: message }); addFailure(failures, sessionId, message); continue;
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
let current: SessionScan;
|
|
306
|
+
try { current = await scanSession(cwd, sessionId, home, lease); } catch (error) { const message = textError(error); sessions.push({ sessionId, path: initial.path, status: "failed", reason: message }); addFailure(failures, sessionId, message); continue; }
|
|
307
|
+
let freshPlan = planSession(current, cutoffMs);
|
|
308
|
+
for (const item of freshPlan.candidates) addUnique(candidates, runItem(item, "candidate"));
|
|
309
|
+
const freshIds = new Set(freshPlan.candidates.map(({ runId }) => runId));
|
|
310
|
+
for (const item of initialPlan.candidates) if (!freshIds.has(item.runId)) addUnique(skipped, runItem(item, "skipped", "Candidate changed or is no longer independently eligible"));
|
|
311
|
+
let clean = true;
|
|
312
|
+
while (freshPlan.candidates.length) {
|
|
313
|
+
try { current = await scanSession(cwd, sessionId, home, lease); freshPlan = planSession(current, cutoffMs); for (const item of freshPlan.skipped) addUnique(skipped, item); } catch (error) { const message = textError(error); addFailure(failures, sessionId, message); clean = false; break; }
|
|
314
|
+
if (!freshPlan.candidates.length) break;
|
|
315
|
+
let ordered: readonly StoredRun[];
|
|
316
|
+
try { ordered = deletionOrder(current, freshPlan.candidates); } catch (error) { const message = textError(error); addFailure(failures, sessionId, message); clean = false; break; }
|
|
317
|
+
const target = ordered[0];
|
|
318
|
+
if (!target) break;
|
|
319
|
+
let changed: string | undefined;
|
|
320
|
+
try { changed = await recheckCandidate(target, cutoffMs); } catch (error) { const message = textError(error); addFailure(failures, sessionId, message, target.runId); clean = false; break; }
|
|
321
|
+
if (changed) { addUnique(skipped, runItem(target, "skipped", changed)); clean = false; break; }
|
|
322
|
+
try { await target.store.delete(true); deleted.push(runItem(target, "deleted")); } catch (error) { const message = textError(error); addUnique(skipped, runItem(target, "failed", message)); addFailure(failures, sessionId, message, target.runId); clean = false; break; }
|
|
323
|
+
}
|
|
324
|
+
if (clean) sessions.push({ sessionId, path: initial.path, status: "cleaned" });
|
|
325
|
+
else if (!sessions.some(({ sessionId: currentId }) => currentId === sessionId)) sessions.push({ sessionId, path: initial.path, status: "failed", reason: "Cleanup stopped after a safety recheck or deletion failure" });
|
|
326
|
+
} finally { try { await lease.release(); } catch (error) { addFailure(failures, sessionId, textError(error)); } }
|
|
327
|
+
}
|
|
328
|
+
return { cwd, cutoffMs, olderThanDays, yes, sessions, candidates, skipped, deleted, failures };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function doctorCleanupExitCode(report: DoctorCleanupReport): 0 | 1 { return report.failures.length ? 1 : 0; }
|
|
332
|
+
function runLine(item: CleanupRunResult): string { return `- [${item.action}] session=${item.sessionId} run=${item.runId} state=${item.state} state-mtime=${new Date(item.stateMtimeMs).toISOString()} path=\`${item.path}\`${item.reason ? `: ${item.reason}` : ""}`; }
|
|
333
|
+
export function formatDoctorCleanupReport(report: DoctorCleanupReport): string {
|
|
334
|
+
const lines = ["# pi-extensible-workflows doctor cleanup", "", "## Cleanup", `- Project: \`${report.cwd}\``, `- Cutoff: \`${new Date(report.cutoffMs).toISOString()}\` (strictly older than ${String(report.olderThanDays)} day(s))`, `- Mode: ${report.yes ? "confirmed deletion" : "preview only"}`, "", "## Candidates", ...(report.candidates.length ? report.candidates.map(runLine) : ["- None"]), "", "## Skipped", ...(report.skipped.length ? report.skipped.map(runLine) : ["- None"]), "", "## Deleted", ...(report.deleted.length ? report.deleted.map(runLine) : ["- None"]), "", "## Session safety", ...(report.sessions.length ? report.sessions.map((session) => `- [${session.status}] session=${session.sessionId} path=\`${session.path}\`${session.reason ? `: ${session.reason}` : ""}`) : ["- None stored"]), "", "## Failures", ...(report.failures.length ? report.failures.map((failure) => `- session=${failure.sessionId}${failure.runId ? ` run=${failure.runId}` : ""}: ${failure.message}`) : ["- None"]), "", "## Summary", `- ${String(report.candidates.length)} candidate(s), ${String(report.deleted.length)} deleted, ${String(report.skipped.length)} skipped, ${String(report.failures.length)} failure(s)`];
|
|
335
|
+
if (!report.yes) lines.push("", "No files were changed. Re-run with --yes to confirm deletion.");
|
|
336
|
+
return `${lines.join("\n")}\n`;
|
|
337
|
+
}
|