@marcoscale98/piewf-cli 5.14.1-fork.1

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