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.
Files changed (44) hide show
  1. package/README.md +18 -11
  2. package/dist/src/agent-execution.d.ts +4 -94
  3. package/dist/src/agent-execution.js +34 -208
  4. package/dist/src/budget.d.ts +38 -0
  5. package/dist/src/budget.js +160 -0
  6. package/dist/src/cli.d.ts +2 -0
  7. package/dist/src/cli.js +60 -8
  8. package/dist/src/doctor-cleanup.d.ts +41 -0
  9. package/dist/src/doctor-cleanup.js +597 -0
  10. package/dist/src/doctor.d.ts +7 -2
  11. package/dist/src/doctor.js +127 -22
  12. package/dist/src/execution.d.ts +17 -0
  13. package/dist/src/execution.js +630 -0
  14. package/dist/src/host.d.ts +101 -0
  15. package/dist/src/host.js +3084 -0
  16. package/dist/src/index.d.ts +13 -634
  17. package/dist/src/index.js +10 -4432
  18. package/dist/src/persistence.d.ts +9 -19
  19. package/dist/src/persistence.js +175 -61
  20. package/dist/src/registry.d.ts +43 -0
  21. package/dist/src/registry.js +279 -0
  22. package/dist/src/session-inspector.js +5 -3
  23. package/dist/src/types.d.ts +692 -0
  24. package/dist/src/types.js +27 -0
  25. package/dist/src/utils.d.ts +32 -0
  26. package/dist/src/utils.js +168 -0
  27. package/dist/src/validation.d.ts +28 -0
  28. package/dist/src/validation.js +832 -0
  29. package/package.json +3 -2
  30. package/skills/pi-extensible-workflows/SKILL.md +69 -34
  31. package/src/agent-execution.ts +37 -189
  32. package/src/budget.ts +75 -0
  33. package/src/cli.ts +47 -7
  34. package/src/doctor-cleanup.ts +337 -0
  35. package/src/doctor.ts +117 -24
  36. package/src/execution.ts +540 -0
  37. package/src/host.ts +2598 -0
  38. package/src/index.ts +14 -3856
  39. package/src/persistence.ts +122 -37
  40. package/src/registry.ts +240 -0
  41. package/src/session-inspector.ts +5 -3
  42. package/src/types.ts +158 -0
  43. package/src/utils.ts +142 -0
  44. package/src/validation.ts +688 -0
@@ -0,0 +1,597 @@
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 } from "./types.js";
7
+ import { jsonValue, validateModelAliases } from "./utils.js";
8
+ import { validateSchema } from "./validation.js";
9
+ import { acquireSessionLease, hasLiveSessionLease, projectSessionsDirectory, RunStore } from "./persistence.js";
10
+ const TERMINAL_STATES = new Set(["completed", "failed", "stopped"]);
11
+ const DAY_MS = 24 * 60 * 60 * 1000;
12
+ const REQUIRED_RUN_FILES = ["workflow.js", "state.json", "snapshot.json", "journal.json", "ownership.json", "worktrees.json", "borrowed-worktrees.json", "system-prompts.json"];
13
+ const OPTIONAL_RUN_FILES = new Set(["result.json"]);
14
+ const RUN_DIRECTORIES = new Set(["worktrees"]);
15
+ const RUN_FILES = new Set([...REQUIRED_RUN_FILES, ...OPTIONAL_RUN_FILES]);
16
+ const AGENT_STATES = new Set(["queued", "running", "waiting_for_child", "paused", "retrying", "completed", "failed", "cancelled"]);
17
+ const SCHEDULER_STATES = new Set(["queued", "running", "waiting_for_child", "paused", "retrying", "completed", "failed", "cancelled"]);
18
+ const BUDGET_DIMENSIONS = new Set(["tokens", "costUsd", "durationMs", "agentLaunches"]);
19
+ const BUDGET_EVENT_TYPES = new Set(["soft_crossed", "hard_overrun", "hard_exhausted", "adjustment_requested", "adjustment_approved", "adjustment_rejected"]);
20
+ function object(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
21
+ function textError(error) { return error instanceof Error ? error.message : String(error); }
22
+ function positiveDays(value) { if (!Number.isSafeInteger(value) || value < 1 || !Number.isFinite(value * DAY_MS))
23
+ throw new Error("older-than-days must be a positive integer"); return value; }
24
+ function runItem(entry, action, reason) { return { sessionId: entry.sessionId, runId: entry.runId, action, state: entry.run.state, stateMtimeMs: entry.stateMtimeMs, path: entry.store.directory, ...(reason ? { reason } : {}) }; }
25
+ function sameNames(left, right) { return left.length === right.length && left.every((value, index) => value === right[index]); }
26
+ async function jsonFile(path) { return JSON.parse(await readFile(path, "utf8")); }
27
+ async function requiredFile(path) { const info = await lstat(path); if (!info.isFile())
28
+ throw new Error(`Required artifact is not a regular file: ${path}`); }
29
+ function stringList(value, label, nonEmpty = false) { if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || (nonEmpty && !item)))
30
+ throw new Error(`${label} is invalid`); }
31
+ function optionalString(value, label) { if (value !== undefined && typeof value !== "string")
32
+ throw new Error(`${label} is invalid`); }
33
+ function nonNegativeInteger(value, label) { if (!Number.isSafeInteger(value) || value < 0)
34
+ throw new Error(`${label} is invalid`); }
35
+ function positiveInteger(value, label) { if (!Number.isSafeInteger(value) || value < 1)
36
+ throw new Error(`${label} is invalid`); }
37
+ function finiteNumber(value, label) { if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
38
+ throw new Error(`${label} is invalid`); }
39
+ function model(value, label) { 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)))
40
+ throw new Error(`${label} is invalid`); }
41
+ function accounting(value, label) { if (!object(value))
42
+ throw new Error(`${label} is invalid`); for (const key of ["input", "output", "cacheRead", "cacheWrite", "cost"])
43
+ finiteNumber(value[key], `${label}.${key}`); }
44
+ function resourceExclusions(value, label) { if (value === undefined)
45
+ return; if (!object(value))
46
+ throw new Error(`${label} is invalid`); if (value.skills !== undefined)
47
+ stringList(value.skills, `${label}.skills`); if (value.extensions !== undefined)
48
+ stringList(value.extensions, `${label}.extensions`); }
49
+ function agentDefinition(value, label) { if (!object(value))
50
+ 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))
51
+ throw new Error(`${label}.thinking is invalid`); if (value.tools !== undefined)
52
+ stringList(value.tools, `${label}.tools`); resourceExclusions(value.disabledAgentResources, `${label}.disabledAgentResources`); }
53
+ function validateScheduledOptions(value, label) { if (!object(value) || typeof value.label !== "string" || !value.label || typeof value.cwd !== "string" || !value.cwd)
54
+ 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))
55
+ throw new Error(`${label}.thinking is invalid`); optionalString(value.role, `${label}.role`); if (value.schema !== undefined)
56
+ validateSchema(value.schema, `${label}.schema`); if (value.retries !== undefined)
57
+ nonNegativeInteger(value.retries, `${label}.retries`); if (value.timeoutMs !== undefined && value.timeoutMs !== null)
58
+ positiveInteger(value.timeoutMs, `${label}.timeoutMs`); if (value.agentOptions !== undefined && (!object(value.agentOptions) || !jsonValue(value.agentOptions)))
59
+ throw new Error(`${label}.agentOptions is invalid`); if (value.agentIdentity !== undefined) {
60
+ if (!object(value.agentIdentity) || !Array.isArray(value.agentIdentity.structuralPath) || value.agentIdentity.structuralPath.some((part) => typeof part !== "string") || typeof value.agentIdentity.callSite !== "string")
61
+ throw new Error(`${label}.agentIdentity is invalid`);
62
+ positiveInteger(value.agentIdentity.occurrence, `${label}.agentIdentity.occurrence`);
63
+ optionalString(value.agentIdentity.parentBreadcrumb, `${label}.agentIdentity.parentBreadcrumb`);
64
+ optionalString(value.agentIdentity.worktreeOwner, `${label}.agentIdentity.worktreeOwner`);
65
+ } }
66
+ function validateAgent(value, label) { 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))
67
+ throw new Error(`${label} is invalid`); optionalString(value.label, `${label}.label`); optionalString(value.parentId, `${label}.parentId`); if (value.structuralPath !== undefined)
68
+ 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) {
69
+ if (!Array.isArray(value.attemptDetails))
70
+ throw new Error(`${label}.attemptDetails is invalid`);
71
+ for (const [index, attempt] of value.attemptDetails.entries()) {
72
+ const at = `${label}.attemptDetails[${String(index)}]`;
73
+ if (!object(attempt) || !Number.isSafeInteger(attempt.attempt) || Number(attempt.attempt) < 1 || typeof attempt.sessionId !== "string" || !attempt.sessionId || typeof attempt.sessionFile !== "string" || !attempt.sessionFile)
74
+ throw new Error(`${at} is invalid`);
75
+ accounting(attempt.accounting, `${at}.accounting`);
76
+ if (attempt.error !== undefined && (!object(attempt.error) || typeof attempt.error.code !== "string" || typeof attempt.error.message !== "string"))
77
+ throw new Error(`${at}.error is invalid`);
78
+ if (attempt.setup !== undefined) {
79
+ if (!object(attempt.setup) || !Array.isArray(attempt.setup.hookNames) || attempt.setup.hookNames.some((name) => typeof name !== "string") || typeof attempt.setup.cwd !== "string")
80
+ throw new Error(`${at}.setup is invalid`);
81
+ model(attempt.setup.model, `${at}.setup.model`);
82
+ stringList(attempt.setup.tools, `${at}.setup.tools`);
83
+ resourceExclusions(attempt.setup.disabledAgentResources, `${at}.setup.disabledAgentResources`);
84
+ }
85
+ }
86
+ } if (value.accounting !== undefined)
87
+ accounting(value.accounting, `${label}.accounting`); if (value.toolCalls !== undefined) {
88
+ if (!Array.isArray(value.toolCalls))
89
+ throw new Error(`${label}.toolCalls is invalid`);
90
+ for (const call of value.toolCalls)
91
+ if (!object(call) || typeof call.id !== "string" || typeof call.name !== "string" || !["running", "completed", "failed"].includes(call.state))
92
+ throw new Error(`${label}.toolCalls is invalid`);
93
+ } if (value.activity !== undefined && (!object(value.activity) || !["reasoning", "tool", "text"].includes(value.activity.kind) || typeof value.activity.text !== "string"))
94
+ throw new Error(`${label}.activity is invalid`); }
95
+ function validateUsage(value, label) { if (!object(value))
96
+ throw new Error(`${label} is invalid`); for (const key of ["tokens", "costUsd", "durationMs", "agentLaunches"])
97
+ finiteNumber(value[key], `${label}.${key}`); }
98
+ function validateBudgetEvents(value) { if (value === undefined)
99
+ return; if (!Array.isArray(value))
100
+ throw new Error("Persisted budget events are invalid"); for (const [index, event] of value.entries()) {
101
+ const label = `budgetEvents[${String(index)}]`;
102
+ 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)
103
+ throw new Error(`${label} is invalid`);
104
+ validateUsage(event.usage, `${label}.usage`);
105
+ validateBudget(event.limits);
106
+ } }
107
+ function validateRunRecord(run) {
108
+ const value = run;
109
+ 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) || !Array.isArray(value.agents) || !Array.isArray(value.nativeSessions))
110
+ throw new Error("Persisted run state is invalid");
111
+ const agents = value.agents;
112
+ agents.forEach((agent, index) => { validateAgent(agent, `agents[${String(index)}]`); });
113
+ const agentIds = new Set();
114
+ for (const agent of agents) {
115
+ const id = agent.id;
116
+ if (agentIds.has(id))
117
+ throw new Error(`Duplicate persisted agent ${id}`);
118
+ agentIds.add(id);
119
+ }
120
+ for (const agent of agents) {
121
+ const parentId = agent.parentId;
122
+ if (parentId !== undefined && (typeof parentId !== "string" || !agentIds.has(parentId)))
123
+ throw new Error("Persisted agent has a missing parent");
124
+ const seen = new Set();
125
+ let parent = typeof parentId === "string" ? parentId : undefined;
126
+ while (parent) {
127
+ if (seen.has(parent))
128
+ throw new Error("Persisted agent parent cycle");
129
+ seen.add(parent);
130
+ const parentAgent = agents.find((candidate) => candidate.id === parent);
131
+ parent = typeof parentAgent?.parentId === "string" ? parentAgent.parentId : undefined;
132
+ }
133
+ }
134
+ for (const [index, session] of value.nativeSessions.entries())
135
+ if (!object(session) || typeof session.sessionId !== "string" || !session.sessionId || typeof session.sessionFile !== "string" || !session.sessionFile)
136
+ throw new Error(`nativeSessions[${String(index)}] is invalid`);
137
+ optionalString(value.parentRunId, "Persisted parent run");
138
+ if (value.retry !== undefined) {
139
+ if (!object(value.retry) || typeof value.retry.sourceRunId !== "string" || !value.retry.sourceRunId || typeof value.retry.lineageRootRunId !== "string" || !value.retry.lineageRootRunId)
140
+ throw new Error("Persisted retry provenance is invalid");
141
+ const sourceRunId = value.retry.sourceRunId;
142
+ stringList(value.retry.completedPaths, "Persisted retry completed paths");
143
+ stringList(value.retry.incompletePaths, "Persisted retry incomplete paths");
144
+ stringList(value.retry.namedWorktrees, "Persisted retry named worktrees");
145
+ if (value.parentRunId !== sourceRunId)
146
+ throw new Error("Persisted retry parent does not match its source");
147
+ }
148
+ optionalString(value.phase, "Persisted phase");
149
+ if (value.phaseHistory !== undefined) {
150
+ if (!Array.isArray(value.phaseHistory))
151
+ throw new Error("Persisted phase history is invalid");
152
+ for (const phase of value.phaseHistory) {
153
+ if (!object(phase) || typeof phase.phase !== "string" || !phase.phase)
154
+ throw new Error("Persisted phase history is invalid");
155
+ nonNegativeInteger(phase.afterAgent, "Persisted phase history afterAgent");
156
+ }
157
+ }
158
+ if (value.error !== undefined && (!object(value.error) || typeof value.error.code !== "string" || typeof value.error.message !== "string"))
159
+ throw new Error("Persisted run error is invalid");
160
+ validateBudget(value.budget);
161
+ if (value.budgetVersion !== undefined)
162
+ positiveInteger(value.budgetVersion, "Persisted budget version");
163
+ if (value.usage !== undefined)
164
+ validateUsage(value.usage, "Persisted usage");
165
+ validateBudgetEvents(value.budgetEvents);
166
+ if (value.events !== undefined) {
167
+ if (!Array.isArray(value.events))
168
+ throw new Error("Persisted run events are invalid");
169
+ for (const event of value.events)
170
+ if (!object(event) || typeof event.type !== "string" || typeof event.message !== "string")
171
+ throw new Error("Persisted run event is invalid");
172
+ }
173
+ }
174
+ function validateSnapshot(snapshot) {
175
+ 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))
176
+ throw new Error("Persisted launch snapshot is invalid");
177
+ if (snapshot.identityVersion !== undefined)
178
+ positiveInteger(snapshot.identityVersion, "Persisted snapshot identity version");
179
+ if (snapshot.launchKind !== undefined && !["inline", "function"].includes(snapshot.launchKind))
180
+ throw new Error("Persisted snapshot launch kind is invalid");
181
+ if (snapshot.launchKind === "function" && (typeof snapshot.functionName !== "string" || !snapshot.functionName))
182
+ throw new Error("Persisted snapshot function name is invalid");
183
+ optionalString(snapshot.functionName, "Persisted snapshot function name");
184
+ optionalString(snapshot.settingsPath, "Persisted snapshot settings path");
185
+ if (snapshot.settingsSources !== undefined) {
186
+ if (!object(snapshot.settingsSources) || typeof snapshot.settingsSources.concurrency !== "string" || typeof snapshot.settingsSources.modelAliases !== "string" || typeof snapshot.settingsSources.disabledAgentResources !== "string")
187
+ throw new Error("Persisted snapshot settings sources are invalid");
188
+ }
189
+ validateBudget(snapshot.budget);
190
+ if (snapshot.modelAliases !== undefined)
191
+ validateModelAliases(snapshot.modelAliases);
192
+ if (snapshot.settings.modelAliases !== undefined)
193
+ validateModelAliases(snapshot.settings.modelAliases);
194
+ if (snapshot.phases !== undefined)
195
+ stringList(snapshot.phases, "Persisted snapshot phases");
196
+ resourceExclusions(snapshot.settings.disabledAgentResources, "Persisted snapshot disabled resources");
197
+ if (snapshot.roles !== undefined) {
198
+ if (!object(snapshot.roles))
199
+ throw new Error("Persisted snapshot roles are invalid");
200
+ for (const [name, definition] of Object.entries(snapshot.roles))
201
+ agentDefinition(definition, `Persisted snapshot role ${name}`);
202
+ }
203
+ if (snapshot.projectRoles !== undefined)
204
+ stringList(snapshot.projectRoles, "Persisted snapshot project roles");
205
+ for (const [index, schema] of snapshot.schemas.entries())
206
+ validateSchema(schema, `Persisted snapshot schema[${String(index)}]`);
207
+ }
208
+ function validateJournal(value) { if (!object(value) || !object(value.completed) || (value.awaiting !== undefined && !object(value.awaiting)) || (value.decisions !== undefined && !object(value.decisions)))
209
+ throw new Error("Persisted workflow journal is invalid"); for (const operation of Object.values(value.completed))
210
+ if (!object(operation) || typeof operation.path !== "string" || !operation.path || !jsonValue(operation.value))
211
+ throw new Error("Persisted completed operation is invalid"); for (const checkpoint of Object.values(value.awaiting ?? {}))
212
+ if (!object(checkpoint) || typeof checkpoint.path !== "string" || !checkpoint.path || typeof checkpoint.name !== "string" || !checkpoint.name || typeof checkpoint.prompt !== "string" || !jsonValue(checkpoint.context))
213
+ throw new Error("Persisted awaiting checkpoint is invalid"); for (const decision of Object.values(value.decisions ?? {})) {
214
+ 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)
215
+ throw new Error("Persisted budget decision is invalid");
216
+ validateUsage(decision.consumed, "Persisted budget decision usage");
217
+ validateBudget(decision.previous);
218
+ validateBudget(decision.proposed);
219
+ } }
220
+ function validateSystemPrompts(value) {
221
+ if (!object(value) || value.version !== 1 || !Array.isArray(value.entries))
222
+ throw new Error("Persisted system prompts are invalid");
223
+ for (const [index, entry] of value.entries.entries()) {
224
+ const label = `system-prompts.entries[${String(index)}]`;
225
+ 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)
226
+ throw new Error(`${label} is invalid`);
227
+ }
228
+ }
229
+ async function validateRunDirectory(store) {
230
+ const entries = await readdir(store.directory, { withFileTypes: true });
231
+ for (const entry of entries) {
232
+ if (RUN_DIRECTORIES.has(entry.name)) {
233
+ if (!entry.isDirectory() || entry.isSymbolicLink())
234
+ throw new Error(`Run artifact is not a regular directory: ${join(store.directory, entry.name)}`);
235
+ continue;
236
+ }
237
+ if (!RUN_FILES.has(entry.name))
238
+ throw new Error(`Run inventory contains an unrecognized artifact: ${join(store.directory, entry.name)}`);
239
+ if (!entry.isFile() || entry.isSymbolicLink())
240
+ throw new Error(`Run artifact is not a regular file: ${join(store.directory, entry.name)}`);
241
+ }
242
+ }
243
+ async function validateRunArtifacts(store, workflowScript, state) {
244
+ await validateRunDirectory(store);
245
+ for (const name of REQUIRED_RUN_FILES)
246
+ await requiredFile(join(store.directory, name));
247
+ if (await readFile(join(store.directory, "workflow.js"), "utf8") !== workflowScript)
248
+ throw new Error("Persisted workflow source does not match its launch snapshot");
249
+ validateSystemPrompts(await jsonFile(join(store.directory, "system-prompts.json")));
250
+ const result = await jsonFile(join(store.directory, "result.json")).catch((error) => { if (error.code === "ENOENT")
251
+ return undefined; throw error; });
252
+ if (result === undefined && state === "completed")
253
+ throw new Error("Completed run result is missing");
254
+ if (result !== undefined && !jsonValue(result))
255
+ throw new Error("Persisted workflow result is invalid");
256
+ validateJournal(await jsonFile(join(store.directory, "journal.json")));
257
+ const rawOwnership = await jsonFile(join(store.directory, "ownership.json"));
258
+ if (!Array.isArray(rawOwnership))
259
+ throw new Error("Persisted ownership records are invalid");
260
+ const ownership = rawOwnership;
261
+ const ownershipIds = new Set();
262
+ for (const [index, record] of ownership.entries()) {
263
+ const label = `ownership[${String(index)}]`;
264
+ 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))
265
+ throw new Error(`${label} is invalid`);
266
+ ownershipIds.add(record.id);
267
+ optionalString(record.parentId, `${label}.parentId`);
268
+ validateScheduledOptions(record.options, `${label}.options`);
269
+ }
270
+ for (const record of ownership)
271
+ if (object(record) && record.parentId !== undefined && (typeof record.parentId !== "string" || !ownershipIds.has(record.parentId)))
272
+ throw new Error("Persisted ownership parent is missing");
273
+ await store.validateDeletionWorktrees();
274
+ const borrowed = await store.borrowedWorktrees();
275
+ await store.validateBorrowedWorktrees();
276
+ return borrowed.map(({ sourceRunId }) => ({ sourceRunId }));
277
+ }
278
+ async function sessionEntries(path) {
279
+ const info = await lstat(path);
280
+ if (!info.isDirectory())
281
+ throw new Error(`Session inventory is not a directory: ${path}`);
282
+ return readdir(path, { withFileTypes: true });
283
+ }
284
+ async function scanSession(cwd, sessionId, home, expectedLease) {
285
+ const path = join(projectSessionsDirectory(cwd, home), sessionId);
286
+ const rootEntries = await sessionEntries(path);
287
+ const runsEntry = rootEntries.find((entry) => entry.name === "runs");
288
+ if (!runsEntry || !runsEntry.isDirectory() || runsEntry.isSymbolicLink())
289
+ throw new Error(`Session inventory has no regular runs directory: ${path}`);
290
+ if (rootEntries.some((entry) => entry.name !== "runs"))
291
+ throw new Error(`Session inventory contains an unrecognized entry: ${path}`);
292
+ const runsPath = join(path, "runs");
293
+ const before = await sessionEntries(runsPath);
294
+ const runEntries = before.filter((entry) => entry.name !== "owner.json");
295
+ if (before.some((entry) => entry.name === "owner.json" && entry.isSymbolicLink()))
296
+ throw new Error(`Session ownership lease is not a regular file: ${runsPath}`);
297
+ for (const entry of runEntries)
298
+ if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name.startsWith("."))
299
+ throw new Error(`Session inventory contains an unrecognized entry: ${join(runsPath, entry.name)}`);
300
+ const ownerPath = join(runsPath, "owner.json");
301
+ let liveLease = false;
302
+ if (expectedLease && expectedLease.path === ownerPath) {
303
+ const owned = await jsonFile(ownerPath);
304
+ if (!object(owned) || owned.token !== expectedLease.token)
305
+ throw new Error("Session ownership lease changed before deletion");
306
+ }
307
+ else
308
+ liveLease = await hasLiveSessionLease(cwd, sessionId, home);
309
+ const runs = [];
310
+ for (const entry of runEntries) {
311
+ const runId = entry.name;
312
+ const store = new RunStore(cwd, sessionId, runId, home);
313
+ try {
314
+ const beforeState = await stat(join(store.directory, "state.json"));
315
+ const loaded = await store.load();
316
+ validateRunRecord(loaded.run);
317
+ validateSnapshot(loaded.snapshot);
318
+ if (loaded.run.parentRunId !== undefined)
319
+ await store.validateParentRun(loaded.run.parentRunId);
320
+ if (loaded.run.retry)
321
+ await store.validateRetrySource();
322
+ const borrowed = await validateRunArtifacts(store, loaded.snapshot.script, loaded.run.state);
323
+ const afterState = await stat(join(store.directory, "state.json"));
324
+ if (beforeState.mtimeMs !== afterState.mtimeMs)
325
+ throw new Error("Persisted state changed while scanning");
326
+ const dependencies = new Set();
327
+ if (loaded.run.parentRunId !== undefined)
328
+ dependencies.add(loaded.run.parentRunId);
329
+ if (loaded.run.retry) {
330
+ dependencies.add(loaded.run.retry.sourceRunId);
331
+ dependencies.add(loaded.run.retry.lineageRootRunId);
332
+ }
333
+ for (const binding of borrowed)
334
+ dependencies.add(binding.sourceRunId);
335
+ if (dependencies.has(runId))
336
+ throw new Error("Persisted run depends on itself");
337
+ runs.push({ sessionId, runId, store, run: loaded.run, stateMtimeMs: afterState.mtimeMs, dependencies: [...dependencies] });
338
+ }
339
+ catch (error) {
340
+ throw new Error(`Run ${runId} is corrupt or incomplete: ${textError(error)}`, { cause: error });
341
+ }
342
+ }
343
+ const after = await sessionEntries(runsPath);
344
+ const beforeNames = before.map(({ name }) => name).sort();
345
+ const afterNames = after.map(({ name }) => name).sort();
346
+ if (!sameNames(beforeNames, afterNames))
347
+ throw new Error("Session inventory changed while scanning");
348
+ const known = new Set(runs.map(({ runId }) => runId));
349
+ for (const run of runs)
350
+ for (const dependency of run.dependencies)
351
+ if (!known.has(dependency))
352
+ throw new Error(`Run ${run.runId} depends on missing run ${dependency}`);
353
+ const visiting = new Set();
354
+ const visited = new Set();
355
+ const visit = (runId) => {
356
+ if (visiting.has(runId))
357
+ throw new Error("Persisted run dependency cycle prevents safe cleanup");
358
+ if (visited.has(runId))
359
+ return;
360
+ visiting.add(runId);
361
+ const run = runs.find(({ runId: current }) => current === runId);
362
+ for (const dependency of run?.dependencies ?? [])
363
+ visit(dependency);
364
+ visiting.delete(runId);
365
+ visited.add(runId);
366
+ };
367
+ for (const run of runs)
368
+ visit(run.runId);
369
+ return { sessionId, path, runs, liveLease };
370
+ }
371
+ async function recheckCandidate(entry, cutoffMs) {
372
+ const before = await stat(join(entry.store.directory, "state.json")).catch(() => undefined);
373
+ if (!before)
374
+ return "State record disappeared before deletion";
375
+ const loaded = await entry.store.load().catch((error) => { throw new Error(`Candidate could not be reloaded: ${textError(error)}`); });
376
+ validateRunRecord(loaded.run);
377
+ if (loaded.run.id !== entry.runId || loaded.run.state !== entry.run.state || !TERMINAL_STATES.has(loaded.run.state))
378
+ return "Candidate state changed before deletion";
379
+ const after = await stat(join(entry.store.directory, "state.json"));
380
+ if (before.mtimeMs !== after.mtimeMs || after.mtimeMs !== entry.stateMtimeMs)
381
+ return "Candidate state record changed before deletion";
382
+ if (after.mtimeMs >= cutoffMs)
383
+ return "Candidate is no longer older than the cutoff";
384
+ return undefined;
385
+ }
386
+ function planSession(scan, cutoffMs) {
387
+ if (scan.liveLease)
388
+ return { candidates: [], skipped: scan.runs.map((entry) => runItem(entry, "skipped", "Session has a live ownership lease")) };
389
+ const oldTerminal = new Set(scan.runs.filter(({ run, stateMtimeMs }) => TERMINAL_STATES.has(run.state) && stateMtimeMs < cutoffMs).map(({ runId }) => runId));
390
+ const protectedRuns = new Set();
391
+ const visit = (runId) => { if (protectedRuns.has(runId))
392
+ return; protectedRuns.add(runId); const entry = scan.runs.find(({ runId: current }) => current === runId); for (const dependency of entry?.dependencies ?? [])
393
+ visit(dependency); };
394
+ for (const entry of scan.runs)
395
+ if (!oldTerminal.has(entry.runId))
396
+ for (const dependency of entry.dependencies)
397
+ visit(dependency);
398
+ const skipped = [];
399
+ for (const entry of scan.runs) {
400
+ if (!TERMINAL_STATES.has(entry.run.state))
401
+ skipped.push(runItem(entry, "skipped", `Run state ${entry.run.state} is active or resumable`));
402
+ else if (entry.stateMtimeMs >= cutoffMs)
403
+ skipped.push(runItem(entry, "skipped", "State record is not older than the cutoff"));
404
+ else if (protectedRuns.has(entry.runId))
405
+ skipped.push(runItem(entry, "skipped", "A retained run depends on this run"));
406
+ }
407
+ return { candidates: scan.runs.filter(({ runId }) => oldTerminal.has(runId) && !protectedRuns.has(runId)), skipped };
408
+ }
409
+ function deletionOrder(scan, candidates) {
410
+ const candidateIds = new Set(candidates.map(({ runId }) => runId));
411
+ const remaining = new Set(candidateIds);
412
+ const ordered = [];
413
+ while (remaining.size) {
414
+ const next = candidates.find((entry) => remaining.has(entry.runId) && !candidates.some((child) => remaining.has(child.runId) && child.dependencies.includes(entry.runId)));
415
+ if (!next)
416
+ throw new Error("Dependency cycle prevents safe cleanup");
417
+ ordered.push(next);
418
+ remaining.delete(next.runId);
419
+ }
420
+ return ordered;
421
+ }
422
+ async function storedSessionIds(cwd, home) {
423
+ const path = projectSessionsDirectory(cwd, home);
424
+ let entries;
425
+ try {
426
+ entries = await sessionEntries(path);
427
+ }
428
+ catch (error) {
429
+ if (error.code === "ENOENT")
430
+ return [];
431
+ throw error;
432
+ }
433
+ if (entries.some((entry) => entry.isSymbolicLink()))
434
+ throw new Error(`Project session inventory contains a symbolic link: ${path}`);
435
+ const invalid = entries.find((entry) => !entry.isDirectory());
436
+ if (invalid)
437
+ throw new Error(`Project session inventory contains an unrecognized entry: ${join(path, invalid.name)}`);
438
+ return entries.filter((entry) => entry.isDirectory()).map(({ name }) => name).sort();
439
+ }
440
+ function addUnique(items, item) { if (!items.some((current) => current.sessionId === item.sessionId && current.runId === item.runId && current.action === item.action))
441
+ items.push(item); }
442
+ function addFailure(items, sessionId, message, runId) { items.push({ sessionId, ...(runId ? { runId } : {}), message }); }
443
+ export async function doctorCleanup(options = {}) {
444
+ const cwd = resolve(options.cwd ?? process.cwd());
445
+ const home = resolve(options.home ?? homedir());
446
+ const olderThanDays = positiveDays(options.olderThanDays ?? 90);
447
+ const yes = options.yes === true;
448
+ const now = options.now ?? Date.now();
449
+ if (!Number.isFinite(now))
450
+ throw new Error("Cleanup command start time is invalid");
451
+ const cutoffMs = now - olderThanDays * DAY_MS;
452
+ if (!Number.isFinite(cutoffMs) || !Number.isFinite(new Date(cutoffMs).getTime()))
453
+ throw new Error("older-than-days produces an unrepresentable cutoff");
454
+ const sessions = [];
455
+ const candidates = [];
456
+ const skipped = [];
457
+ const deleted = [];
458
+ const failures = [];
459
+ let sessionIds;
460
+ try {
461
+ sessionIds = await storedSessionIds(cwd, home);
462
+ }
463
+ catch (error) {
464
+ addFailure(failures, "(project)", textError(error));
465
+ return { cwd, cutoffMs, olderThanDays, yes, sessions, candidates, skipped, deleted, failures };
466
+ }
467
+ for (const sessionId of sessionIds) {
468
+ let initial;
469
+ try {
470
+ initial = await scanSession(cwd, sessionId, home);
471
+ }
472
+ catch (error) {
473
+ sessions.push({ sessionId, path: join(projectSessionsDirectory(cwd, home), sessionId), status: "failed", reason: textError(error) });
474
+ addFailure(failures, sessionId, textError(error));
475
+ continue;
476
+ }
477
+ const initialPlan = planSession(initial, cutoffMs);
478
+ for (const item of initialPlan.candidates)
479
+ addUnique(candidates, runItem(item, "candidate"));
480
+ for (const item of initialPlan.skipped)
481
+ addUnique(skipped, item);
482
+ if (!yes || initial.liveLease) {
483
+ sessions.push({ sessionId, path: initial.path, status: initial.liveLease ? "skipped" : "preview", ...(initial.liveLease ? { reason: "Session has a live ownership lease" } : {}) });
484
+ continue;
485
+ }
486
+ let lease;
487
+ try {
488
+ lease = await acquireSessionLease(cwd, sessionId, home);
489
+ }
490
+ catch (error) {
491
+ const message = textError(error);
492
+ if (/already owned|active ownership|RUN_OWNED/i.test(message)) {
493
+ sessions.push({ sessionId, path: initial.path, status: "skipped", reason: "Session has a live ownership lease" });
494
+ continue;
495
+ }
496
+ sessions.push({ sessionId, path: initial.path, status: "failed", reason: message });
497
+ addFailure(failures, sessionId, message);
498
+ continue;
499
+ }
500
+ try {
501
+ let current;
502
+ try {
503
+ current = await scanSession(cwd, sessionId, home, lease);
504
+ }
505
+ catch (error) {
506
+ const message = textError(error);
507
+ sessions.push({ sessionId, path: initial.path, status: "failed", reason: message });
508
+ addFailure(failures, sessionId, message);
509
+ continue;
510
+ }
511
+ let freshPlan = planSession(current, cutoffMs);
512
+ for (const item of freshPlan.candidates)
513
+ addUnique(candidates, runItem(item, "candidate"));
514
+ const freshIds = new Set(freshPlan.candidates.map(({ runId }) => runId));
515
+ for (const item of initialPlan.candidates)
516
+ if (!freshIds.has(item.runId))
517
+ addUnique(skipped, runItem(item, "skipped", "Candidate changed or is no longer independently eligible"));
518
+ let clean = true;
519
+ while (freshPlan.candidates.length) {
520
+ try {
521
+ current = await scanSession(cwd, sessionId, home, lease);
522
+ freshPlan = planSession(current, cutoffMs);
523
+ for (const item of freshPlan.skipped)
524
+ addUnique(skipped, item);
525
+ }
526
+ catch (error) {
527
+ const message = textError(error);
528
+ addFailure(failures, sessionId, message);
529
+ clean = false;
530
+ break;
531
+ }
532
+ if (!freshPlan.candidates.length)
533
+ break;
534
+ let ordered;
535
+ try {
536
+ ordered = deletionOrder(current, freshPlan.candidates);
537
+ }
538
+ catch (error) {
539
+ const message = textError(error);
540
+ addFailure(failures, sessionId, message);
541
+ clean = false;
542
+ break;
543
+ }
544
+ const target = ordered[0];
545
+ if (!target)
546
+ break;
547
+ let changed;
548
+ try {
549
+ changed = await recheckCandidate(target, cutoffMs);
550
+ }
551
+ catch (error) {
552
+ const message = textError(error);
553
+ addFailure(failures, sessionId, message, target.runId);
554
+ clean = false;
555
+ break;
556
+ }
557
+ if (changed) {
558
+ addUnique(skipped, runItem(target, "skipped", changed));
559
+ clean = false;
560
+ break;
561
+ }
562
+ try {
563
+ await target.store.delete(true);
564
+ deleted.push(runItem(target, "deleted"));
565
+ }
566
+ catch (error) {
567
+ const message = textError(error);
568
+ addUnique(skipped, runItem(target, "failed", message));
569
+ addFailure(failures, sessionId, message, target.runId);
570
+ clean = false;
571
+ break;
572
+ }
573
+ }
574
+ if (clean)
575
+ sessions.push({ sessionId, path: initial.path, status: "cleaned" });
576
+ else if (!sessions.some(({ sessionId: currentId }) => currentId === sessionId))
577
+ sessions.push({ sessionId, path: initial.path, status: "failed", reason: "Cleanup stopped after a safety recheck or deletion failure" });
578
+ }
579
+ finally {
580
+ try {
581
+ await lease.release();
582
+ }
583
+ catch (error) {
584
+ addFailure(failures, sessionId, textError(error));
585
+ }
586
+ }
587
+ }
588
+ return { cwd, cutoffMs, olderThanDays, yes, sessions, candidates, skipped, deleted, failures };
589
+ }
590
+ export function doctorCleanupExitCode(report) { return report.failures.length ? 1 : 0; }
591
+ function runLine(item) { 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}` : ""}`; }
592
+ export function formatDoctorCleanupReport(report) {
593
+ 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)`];
594
+ if (!report.yes)
595
+ lines.push("", "No files were changed. Re-run with --yes to confirm deletion.");
596
+ return `${lines.join("\n")}\n`;
597
+ }
@@ -1,4 +1,5 @@
1
- import { type AgentResourcePolicy, type WorkflowFunction, type WorkflowSettings } from "./index.js";
1
+ import { type AgentResourcePolicy, type WorkflowCatalogModelAlias, type WorkflowExtensionMetadata, type WorkflowFunction, type WorkflowSettings, type WorkflowSettingsSources } from "./index.js";
2
+ import { type WorkflowRegistryApi } from "./registry.js";
2
3
  export type DoctorSeverity = "error" | "warning";
3
4
  export interface DoctorDiagnostic {
4
5
  severity: DoctorSeverity;
@@ -10,10 +11,11 @@ export interface DoctorDiagnostic {
10
11
  export interface DoctorRole {
11
12
  name: string;
12
13
  path: string;
13
- scope: "global" | "project";
14
+ scope: "extension" | "global" | "project";
14
15
  active: boolean;
15
16
  overrides?: string;
16
17
  overriddenBy?: string;
18
+ extension?: WorkflowExtensionMetadata;
17
19
  }
18
20
  export interface DoctorFunction {
19
21
  name: string;
@@ -43,11 +45,13 @@ export interface DoctorReport {
43
45
  agentDir: string;
44
46
  settingsPath: string;
45
47
  settings: Readonly<WorkflowSettings>;
48
+ settingsSources: WorkflowSettingsSources;
46
49
  trust: DoctorTrust;
47
50
  activeTools: readonly string[];
48
51
  roles: readonly DoctorRole[];
49
52
  functions: readonly DoctorFunction[];
50
53
  resourcePolicy: AgentResourcePolicy;
54
+ modelAliases: readonly WorkflowCatalogModelAlias[];
51
55
  diagnostics: readonly DoctorDiagnostic[];
52
56
  }
53
57
  export interface DoctorOptions {
@@ -56,6 +60,7 @@ export interface DoctorOptions {
56
60
  settingsPath?: string;
57
61
  discoverPi?: (cwd: string, agentDir: string) => Promise<DoctorPiState>;
58
62
  activeTools?: readonly string[];
63
+ registry?: WorkflowRegistryApi;
59
64
  }
60
65
  export declare function doctor(options?: DoctorOptions): Promise<DoctorReport>;
61
66
  export declare function doctorExitCode(report: DoctorReport): 0 | 1;