pi-extensible-workflows 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -0
- package/dist/src/agent-execution.d.ts +213 -0
- package/dist/src/agent-execution.js +537 -0
- package/dist/src/ambient-workflow-evals.d.ts +112 -0
- package/dist/src/ambient-workflow-evals.js +375 -0
- package/dist/src/cli.d.ts +6 -0
- package/dist/src/cli.js +26 -0
- package/dist/src/doctor.d.ts +59 -0
- package/dist/src/doctor.js +269 -0
- package/dist/src/eval-capture-extension.d.ts +5 -0
- package/dist/src/eval-capture-extension.js +48 -0
- package/dist/src/index.d.ts +362 -0
- package/dist/src/index.js +2839 -0
- package/dist/src/persistence.d.ts +90 -0
- package/dist/src/persistence.js +530 -0
- package/dist/src/session-inspector.d.ts +59 -0
- package/dist/src/session-inspector.js +396 -0
- package/dist/src/workflow-evals-child.d.ts +1 -0
- package/dist/src/workflow-evals-child.js +13 -0
- package/dist/src/workflow-evals.d.ts +290 -0
- package/dist/src/workflow-evals.js +1221 -0
- package/evals/cases/custom-model-read.yaml +15 -0
- package/evals/cases/direct-answer.yaml +6 -0
- package/evals/cases/mixed-parallel-pipeline.yaml +18 -0
- package/evals/cases/output-schema.yaml +22 -0
- package/evals/cases/parallel.yaml +15 -0
- package/evals/cases/pipeline.yaml +10 -0
- package/evals/cases/ready-for-agent-parallel-merge.yaml +32 -0
- package/evals/cases/required-role.yaml +14 -0
- package/evals/cases/role-model-mixed.yaml +18 -0
- package/evals/cases/two-agents.yaml +10 -0
- package/package.json +65 -0
- package/skills/pi-extensible-workflows/SKILL.md +109 -0
- package/src/agent-execution.ts +529 -0
- package/src/ambient-workflow-evals.ts +452 -0
- package/src/cli.ts +23 -0
- package/src/doctor.ts +285 -0
- package/src/eval-capture-extension.ts +54 -0
- package/src/index.ts +2519 -0
- package/src/persistence.ts +470 -0
- package/src/session-inspector.ts +370 -0
- package/src/workflow-evals-child.ts +15 -0
- package/src/workflow-evals.ts +876 -0
- package/test/fixtures/ready-for-agent-tasks.md +9 -0
- package/test/fixtures/workflow-eval-roles/developer.md +18 -0
- package/test/fixtures/workflow-eval-roles/reviewer.md +18 -0
- package/test/fixtures/workflow-eval-roles/scout.md +17 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { access, link, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import type { JsonValue, LaunchSnapshot, RunRecord } from "./index.js";
|
|
8
|
+
import type { OwnershipRecord } from "./agent-execution.js";
|
|
9
|
+
import { loadLaunchSnapshot, WorkflowError } from "./index.js";
|
|
10
|
+
|
|
11
|
+
export interface NativeSessionReference { sessionId: string; sessionFile: string }
|
|
12
|
+
export interface EffectiveSystemPrompt { sessionId: string; attempt: number; turn: number; sha256: string; prompt: string }
|
|
13
|
+
export interface PersistedRun extends RunRecord { nativeSessions: readonly NativeSessionReference[] }
|
|
14
|
+
export interface CompletedOperation { path: string; value: JsonValue }
|
|
15
|
+
export interface AwaitingCheckpoint { path: string; name: string; prompt: string; context: JsonValue }
|
|
16
|
+
export type PersistedOwnershipNode = OwnershipRecord
|
|
17
|
+
type Journal = { completed: Record<string, CompletedOperation>; awaiting?: Record<string, AwaitingCheckpoint> };
|
|
18
|
+
export interface WorktreeReference { owner: string; path: string; branch: string; cwd: string; base: string }
|
|
19
|
+
|
|
20
|
+
const execute = promisify(execFile);
|
|
21
|
+
const gitIdentity = {
|
|
22
|
+
GIT_AUTHOR_NAME: "pi-extensible-workflows", GIT_AUTHOR_EMAIL: "pi-extensible-workflows@localhost", GIT_COMMITTER_NAME: "pi-extensible-workflows", GIT_COMMITTER_EMAIL: "pi-extensible-workflows@localhost",
|
|
23
|
+
GIT_AUTHOR_DATE: "2000-01-01T00:00:00Z", GIT_COMMITTER_DATE: "2000-01-01T00:00:00Z",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function safePart(value: string): string { return value.replace(/[^a-zA-Z0-9._-]/g, "_"); }
|
|
27
|
+
|
|
28
|
+
export function projectStorageKey(cwd: string): string {
|
|
29
|
+
const exact = resolve(cwd);
|
|
30
|
+
const slug = safePart(basename(exact)) || "root";
|
|
31
|
+
return `${slug}-${createHash("sha256").update(exact).digest("hex").slice(0, 12)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function runsDirectory(cwd: string, sessionId: string, home = homedir()): string {
|
|
35
|
+
return join(home, ".pi", "workflows", "projects", projectStorageKey(cwd), "sessions", safePart(sessionId), "runs");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SESSION_OWNER_FILE = "owner.json";
|
|
39
|
+
const SESSION_OWNER_WRITE_GRACE_MS = 30_000;
|
|
40
|
+
const RUN_CREATE_TEMP = /^\.([a-zA-Z0-9._-]+)\.(\d+)\.[0-9a-f-]+\.tmp$/;
|
|
41
|
+
type SessionOwner = { pid: number; token: string; startedAt: number };
|
|
42
|
+
|
|
43
|
+
async function processAlive(pid: number, startedAt?: number): Promise<boolean> {
|
|
44
|
+
try { process.kill(pid, 0); } catch (error) { return (error as NodeJS.ErrnoException).code !== "ESRCH"; }
|
|
45
|
+
if (startedAt !== undefined && process.platform === "linux") {
|
|
46
|
+
try { if ((await stat(`/proc/${String(pid)}`)).ctimeMs > startedAt) return false; }
|
|
47
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; }
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sameOwner(left: unknown, right: unknown): boolean {
|
|
53
|
+
if (!left || typeof left !== "object" || !right || typeof right !== "object") return false;
|
|
54
|
+
const first = left as Partial<SessionOwner>;
|
|
55
|
+
const second = right as Partial<SessionOwner>;
|
|
56
|
+
return first.pid === second.pid && first.token === second.token;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function restoreLease(path: string, stale: string): Promise<void> {
|
|
60
|
+
try { await link(stale, path); }
|
|
61
|
+
catch (error) {
|
|
62
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
63
|
+
if (code !== "EEXIST" && code !== "ENOENT") throw error;
|
|
64
|
+
}
|
|
65
|
+
await rm(stale, { force: true });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function cleanupRunTemps(directory: string, entries: readonly { name: string; isDirectory(): boolean }[]): Promise<void> {
|
|
69
|
+
await Promise.all(entries.map(async (entry) => {
|
|
70
|
+
const match = entry.isDirectory() ? RUN_CREATE_TEMP.exec(entry.name) : undefined;
|
|
71
|
+
const pid = match?.[2] ? Number(match[2]) : undefined;
|
|
72
|
+
if (pid && !await processAlive(pid)) await rm(join(directory, entry.name), { recursive: true, force: true });
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class SessionLease {
|
|
77
|
+
#released = false;
|
|
78
|
+
constructor(readonly path: string, readonly token: string) {}
|
|
79
|
+
get active(): boolean { return !this.#released; }
|
|
80
|
+
async release(): Promise<void> {
|
|
81
|
+
if (this.#released) return;
|
|
82
|
+
this.#released = true;
|
|
83
|
+
try {
|
|
84
|
+
const owner = JSON.parse(await readFile(this.path, "utf8")) as Partial<SessionOwner>;
|
|
85
|
+
if (owner.token === this.token) await rm(this.path, { force: true });
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function acquireSessionLease(cwd: string, sessionId: string, home = homedir()): Promise<SessionLease> {
|
|
93
|
+
const directory = runsDirectory(cwd, sessionId, home);
|
|
94
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
95
|
+
const path = join(directory, SESSION_OWNER_FILE);
|
|
96
|
+
for (;;) {
|
|
97
|
+
const token = randomUUID();
|
|
98
|
+
const owner: SessionOwner = { pid: process.pid, token, startedAt: Date.now() };
|
|
99
|
+
try {
|
|
100
|
+
const handle = await open(path, "wx", 0o600);
|
|
101
|
+
try { await handle.writeFile(`${JSON.stringify(owner)}\n`, "utf8"); } finally { await handle.close(); }
|
|
102
|
+
return new SessionLease(path, token);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
105
|
+
let existing: Partial<SessionOwner>;
|
|
106
|
+
let existingText = "";
|
|
107
|
+
try {
|
|
108
|
+
existingText = await readFile(path, "utf8");
|
|
109
|
+
existing = JSON.parse(existingText) as Partial<SessionOwner>;
|
|
110
|
+
if (typeof existing.pid === "number" && existing.pid > 0 && await processAlive(existing.pid, existing.startedAt)) throw new WorkflowError("RUN_OWNED", `Pi session ${sessionId} is already owned by process ${String(existing.pid)}`);
|
|
111
|
+
} catch (readError) {
|
|
112
|
+
if (readError instanceof WorkflowError) throw readError;
|
|
113
|
+
if ((readError as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
114
|
+
const age = await stat(path).then((value) => Date.now() - value.mtimeMs).catch(() => 0);
|
|
115
|
+
if (age < SESSION_OWNER_WRITE_GRACE_MS) throw new WorkflowError("RUN_OWNED", `Pi session ${sessionId} has an active ownership lease`);
|
|
116
|
+
existing = {};
|
|
117
|
+
}
|
|
118
|
+
const stale = `${path}.${randomUUID()}.stale`;
|
|
119
|
+
try {
|
|
120
|
+
await rename(path, stale);
|
|
121
|
+
const movedText = await readFile(stale, "utf8");
|
|
122
|
+
let moved: unknown;
|
|
123
|
+
try { moved = JSON.parse(movedText); }
|
|
124
|
+
catch {
|
|
125
|
+
if (movedText === existingText) await rm(stale, { force: true });
|
|
126
|
+
else await restoreLease(path, stale);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!sameOwner(existing, moved)) { await restoreLease(path, stale); continue; }
|
|
130
|
+
await rm(stale, { force: true });
|
|
131
|
+
}
|
|
132
|
+
catch (reclaimError) { if ((reclaimError as NodeJS.ErrnoException).code === "ENOENT") continue; throw reclaimError; }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function listRunIds(cwd: string, sessionId: string, home = homedir()): Promise<string[]> {
|
|
138
|
+
const directory = runsDirectory(cwd, sessionId, home);
|
|
139
|
+
try {
|
|
140
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
141
|
+
await cleanupRunTemps(directory, entries);
|
|
142
|
+
return entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map(({ name }) => name);
|
|
143
|
+
}
|
|
144
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function structuralPath(...names: string[]): string {
|
|
148
|
+
if (names.length === 0 || names.some((name) => name.trim() === "")) throw new WorkflowError("INVALID_METADATA", "Structural paths require non-empty explicit names");
|
|
149
|
+
return names.map((name) => encodeURIComponent(name)).join("/");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function atomicJson(path: string, value: unknown): Promise<void> {
|
|
153
|
+
const temporary = `${path}.${String(process.pid)}.${randomUUID()}.tmp`;
|
|
154
|
+
await writeFile(temporary, `${JSON.stringify(value)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
155
|
+
await rename(temporary, path);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function json<T>(path: string): Promise<T> { return JSON.parse(await readFile(path, "utf8")) as T; }
|
|
159
|
+
|
|
160
|
+
export class RunStore {
|
|
161
|
+
readonly directory: string;
|
|
162
|
+
private journalWrite: Promise<void> = Promise.resolve();
|
|
163
|
+
// ponytail: serializes one RunStore instance; cross-process run sharing remains unsupported.
|
|
164
|
+
private stateWrite: Promise<void> = Promise.resolve();
|
|
165
|
+
private worktreeWrite: Promise<void> = Promise.resolve();
|
|
166
|
+
private snapshotWrite: Promise<void> = Promise.resolve();
|
|
167
|
+
// ponytail: the session lease prevents concurrent RunStore writers for one run.
|
|
168
|
+
private systemPromptWrite: Promise<void> = Promise.resolve();
|
|
169
|
+
constructor(readonly cwd: string, readonly sessionId: string, readonly runId: string, home = homedir()) {
|
|
170
|
+
this.cwd = resolve(cwd);
|
|
171
|
+
this.directory = join(runsDirectory(this.cwd, sessionId, home), safePart(runId));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async create(run: PersistedRun, snapshot: Readonly<LaunchSnapshot>): Promise<void> {
|
|
175
|
+
if (resolve(run.cwd) !== this.cwd || run.sessionId !== this.sessionId || run.id !== this.runId) throw new WorkflowError("INTERNAL_ERROR", "Run identity does not match its session-scoped store");
|
|
176
|
+
const temporary = join(dirname(this.directory), `.${safePart(this.runId)}.${String(process.pid)}.${randomUUID()}.tmp`);
|
|
177
|
+
await mkdir(dirname(this.directory), { recursive: true, mode: 0o700 });
|
|
178
|
+
await mkdir(temporary, { mode: 0o700 });
|
|
179
|
+
try {
|
|
180
|
+
await atomicJson(join(temporary, "snapshot.json"), snapshot);
|
|
181
|
+
await atomicJson(join(temporary, "journal.json"), { completed: {}, awaiting: {} });
|
|
182
|
+
await atomicJson(join(temporary, "ownership.json"), []);
|
|
183
|
+
await atomicJson(join(temporary, "worktrees.json"), []);
|
|
184
|
+
await atomicJson(join(temporary, "state.json"), run);
|
|
185
|
+
await atomicJson(join(temporary, "system-prompts.json"), { version: 1, entries: [] });
|
|
186
|
+
await rename(temporary, this.directory);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
await rm(temporary, { recursive: true, force: true });
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async isComplete(): Promise<boolean> {
|
|
194
|
+
try { await Promise.all([access(join(this.directory, "snapshot.json")), access(join(this.directory, "journal.json")), access(join(this.directory, "ownership.json")), access(join(this.directory, "state.json"))]); return true; }
|
|
195
|
+
catch { return false; }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async load(): Promise<{ run: PersistedRun; snapshot: Readonly<LaunchSnapshot> }> {
|
|
199
|
+
await this.stateWrite;
|
|
200
|
+
const run = await json<PersistedRun>(join(this.directory, "state.json"));
|
|
201
|
+
if (resolve(run.cwd) !== this.cwd || run.sessionId !== this.sessionId || run.id !== this.runId) throw new WorkflowError("RESUME_INCOMPATIBLE", "Persisted run belongs to another cwd or Pi session");
|
|
202
|
+
return { run, snapshot: loadLaunchSnapshot(await json<LaunchSnapshot>(join(this.directory, "snapshot.json"))) };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async saveState(run: PersistedRun): Promise<void> {
|
|
206
|
+
const write = this.stateWrite.then(async () => {
|
|
207
|
+
if (resolve(run.cwd) !== this.cwd || run.sessionId !== this.sessionId || run.id !== this.runId) throw new WorkflowError("INTERNAL_ERROR", "Run identity does not match its session-scoped store");
|
|
208
|
+
await atomicJson(join(this.directory, "state.json"), run);
|
|
209
|
+
});
|
|
210
|
+
this.stateWrite = write.catch(() => undefined);
|
|
211
|
+
await write;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async updateState(update: (run: PersistedRun) => PersistedRun | Promise<PersistedRun>): Promise<PersistedRun> {
|
|
215
|
+
let result!: PersistedRun;
|
|
216
|
+
const write = this.stateWrite.then(async () => {
|
|
217
|
+
const current = await json<PersistedRun>(join(this.directory, "state.json"));
|
|
218
|
+
if (resolve(current.cwd) !== this.cwd || current.sessionId !== this.sessionId || current.id !== this.runId) throw new WorkflowError("RESUME_INCOMPATIBLE", "Persisted run belongs to another cwd or Pi session");
|
|
219
|
+
result = await update(current);
|
|
220
|
+
if (resolve(result.cwd) !== this.cwd || result.sessionId !== this.sessionId || result.id !== this.runId) throw new WorkflowError("INTERNAL_ERROR", "Run identity does not match its session-scoped store");
|
|
221
|
+
await atomicJson(join(this.directory, "state.json"), result);
|
|
222
|
+
});
|
|
223
|
+
this.stateWrite = write.catch(() => undefined);
|
|
224
|
+
await write;
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async saveOwnership(nodes: readonly PersistedOwnershipNode[]): Promise<void> {
|
|
229
|
+
await atomicJson(join(this.directory, "ownership.json"), nodes);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async loadOwnership(): Promise<readonly PersistedOwnershipNode[]> {
|
|
233
|
+
return json<readonly PersistedOwnershipNode[]>(join(this.directory, "ownership.json"));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
systemPromptPath(): string { return join(this.directory, "system-prompts.json"); }
|
|
237
|
+
|
|
238
|
+
async recordSystemPrompt(entry: Omit<EffectiveSystemPrompt, "sha256">): Promise<void> {
|
|
239
|
+
const write = this.systemPromptWrite.then(async () => {
|
|
240
|
+
const path = this.systemPromptPath();
|
|
241
|
+
const artifact = await json<{ version: 1; entries: EffectiveSystemPrompt[] }>(path).catch((error: unknown) => { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { version: 1 as const, entries: [] as EffectiveSystemPrompt[] }; throw error; });
|
|
242
|
+
artifact.entries.push({ ...entry, sha256: createHash("sha256").update(entry.prompt).digest("hex") });
|
|
243
|
+
await atomicJson(path, artifact);
|
|
244
|
+
});
|
|
245
|
+
this.systemPromptWrite = write.catch(() => undefined);
|
|
246
|
+
await write;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async systemPrompts(): Promise<readonly EffectiveSystemPrompt[]> {
|
|
250
|
+
await this.systemPromptWrite;
|
|
251
|
+
return (await json<{ version: 1; entries: EffectiveSystemPrompt[] }>(this.systemPromptPath()).catch((error: unknown) => { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { version: 1 as const, entries: [] }; throw error; })).entries;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private async updateJournal<T>(update: (journal: Journal) => T | Promise<T>): Promise<T> {
|
|
255
|
+
let result!: T;
|
|
256
|
+
const write = this.journalWrite.then(async () => {
|
|
257
|
+
const journalPath = join(this.directory, "journal.json");
|
|
258
|
+
const journal = await json<Journal>(journalPath);
|
|
259
|
+
journal.awaiting ??= {};
|
|
260
|
+
result = await update(journal);
|
|
261
|
+
await atomicJson(journalPath, journal);
|
|
262
|
+
});
|
|
263
|
+
this.journalWrite = write.catch(() => undefined);
|
|
264
|
+
await write;
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async complete(path: string, value: JsonValue): Promise<void> {
|
|
269
|
+
await this.updateJournal((journal) => {
|
|
270
|
+
if (journal.completed[path]) throw new WorkflowError("DUPLICATE_NAME", `Completed structural path already exists: ${path}`);
|
|
271
|
+
journal.completed[path] = { path, value };
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async replay(path: string): Promise<CompletedOperation | undefined> {
|
|
276
|
+
await this.journalWrite;
|
|
277
|
+
return (await json<Journal>(join(this.directory, "journal.json"))).completed[path];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async awaitCheckpoint(checkpoint: AwaitingCheckpoint): Promise<boolean | undefined> {
|
|
281
|
+
return this.updateJournal((journal) => {
|
|
282
|
+
const completed = journal.completed[checkpoint.path];
|
|
283
|
+
if (completed) return completed.value as boolean;
|
|
284
|
+
(journal.awaiting as Record<string, AwaitingCheckpoint>)[checkpoint.path] = checkpoint;
|
|
285
|
+
return undefined;
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async awaitingCheckpoints(): Promise<readonly AwaitingCheckpoint[]> {
|
|
290
|
+
await this.journalWrite;
|
|
291
|
+
const journal = await json<Journal>(join(this.directory, "journal.json"));
|
|
292
|
+
return Object.values(journal.awaiting ?? {});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async answerCheckpoint(name: string, approved: boolean): Promise<AwaitingCheckpoint | undefined> {
|
|
296
|
+
return this.updateJournal((journal) => {
|
|
297
|
+
const checkpoint = Object.values(journal.awaiting as Record<string, AwaitingCheckpoint>).find((item) => item.name === name);
|
|
298
|
+
if (!checkpoint || journal.completed[checkpoint.path]) return undefined;
|
|
299
|
+
journal.completed[checkpoint.path] = { path: checkpoint.path, value: approved };
|
|
300
|
+
journal.awaiting = Object.fromEntries(Object.entries(journal.awaiting as Record<string, AwaitingCheckpoint>).filter(([path]) => path !== checkpoint.path));
|
|
301
|
+
return checkpoint;
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private expectedWorktree(owner: string): Pick<WorktreeReference, "path" | "branch"> {
|
|
306
|
+
const key = createHash("sha256").update(`${this.sessionId}\0${this.runId}\0${owner}`).digest("hex").slice(0, 16);
|
|
307
|
+
return { path: join(this.directory, "worktrees", key), branch: `pi-extensible-workflows/${safePart(this.runId)}/${key}` };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private markerPath(owner: string): string {
|
|
311
|
+
const key = createHash("sha256").update(`${this.sessionId}\0${this.runId}\0${owner}`).digest("hex").slice(0, 16);
|
|
312
|
+
return join(this.directory, `worktree-${key}.creating`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private structuralWorktree(owner: string, record: unknown): WorktreeReference {
|
|
316
|
+
if (!record || typeof record !== "object") throw new Error(`Invalid worktree record for ${owner}`);
|
|
317
|
+
const candidate = record as Partial<WorktreeReference>;
|
|
318
|
+
const expected = this.expectedWorktree(owner);
|
|
319
|
+
const relativePath = typeof candidate.path === "string" ? relative(this.directory, candidate.path) : "..";
|
|
320
|
+
const relativeCwd = typeof candidate.path === "string" && typeof candidate.cwd === "string" ? relative(candidate.path, candidate.cwd) : "..";
|
|
321
|
+
if (candidate.owner !== owner || typeof candidate.path !== "string" || typeof candidate.branch !== "string" || typeof candidate.cwd !== "string" || typeof candidate.base !== "string" || resolve(candidate.path) !== expected.path || candidate.branch !== expected.branch || relativePath === ".." || relativePath.startsWith(`..${sep}`) || relativeCwd === ".." || relativeCwd.startsWith(`..${sep}`)) throw new Error(`Invalid worktree record for ${owner}`);
|
|
322
|
+
return candidate as WorktreeReference;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private async cleanupMarker(markerPath: string): Promise<void> {
|
|
326
|
+
let marker: Partial<{ owner: string; path: string; branch: string; base: string }>;
|
|
327
|
+
try { marker = await json(markerPath); } catch { return; }
|
|
328
|
+
if (typeof marker.owner !== "string" || typeof marker.base !== "string") return;
|
|
329
|
+
const expected = this.expectedWorktree(marker.owner);
|
|
330
|
+
if (marker.path !== expected.path || marker.branch !== expected.branch) return;
|
|
331
|
+
const root = await git(this.cwd, ["rev-parse", "--show-toplevel"]).then((value) => value.trim()).catch(() => "");
|
|
332
|
+
if (!root) return;
|
|
333
|
+
const branchBase = await git(root, ["rev-parse", "--verify", `${expected.branch}^{commit}`]).then((value) => value.trim()).catch(() => "");
|
|
334
|
+
if (branchBase !== marker.base) return;
|
|
335
|
+
await git(root, ["worktree", "remove", "--force", expected.path]).catch(() => undefined);
|
|
336
|
+
await git(root, ["branch", "-D", expected.branch]).catch(() => undefined);
|
|
337
|
+
await rm(expected.path, { recursive: true, force: true });
|
|
338
|
+
await rm(markerPath, { force: true });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private async cleanupOrphanWorktrees(): Promise<void> {
|
|
342
|
+
const entries = await readdir(this.directory).catch(() => [] as string[]);
|
|
343
|
+
for (const entry of entries.filter((name) => name.endsWith(".creating"))) await this.cleanupMarker(join(this.directory, entry));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async validateWorktree(owner: string, cwd?: string): Promise<WorktreeReference> {
|
|
347
|
+
try {
|
|
348
|
+
await this.load();
|
|
349
|
+
const records = await json<unknown[]>(join(this.directory, "worktrees.json"));
|
|
350
|
+
const matches = records.filter((candidate) => candidate && typeof candidate === "object" && (candidate as Partial<WorktreeReference>).owner === owner);
|
|
351
|
+
if (matches.length !== 1) throw new Error(`Missing or duplicate worktree record for ${owner}`);
|
|
352
|
+
const record = this.structuralWorktree(owner, matches[0]);
|
|
353
|
+
if (cwd !== undefined && resolve(cwd) !== resolve(record.cwd)) throw new Error(`Invalid worktree record for ${owner}`);
|
|
354
|
+
await access(record.cwd);
|
|
355
|
+
return record;
|
|
356
|
+
} catch (error) {
|
|
357
|
+
throw error instanceof WorkflowError && error.code === "WORKTREE_FAILED" ? error : new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async worktree(owner: string): Promise<WorktreeReference> {
|
|
362
|
+
const write = this.worktreeWrite.then(async () => {
|
|
363
|
+
await this.load();
|
|
364
|
+
const recordsPath = join(this.directory, "worktrees.json");
|
|
365
|
+
let records = await json<WorktreeReference[]>(recordsPath).catch((error: unknown) => { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; });
|
|
366
|
+
const existing = records.find((record) => record.owner === owner);
|
|
367
|
+
if (existing) return this.validateWorktree(owner);
|
|
368
|
+
const { path, branch } = this.expectedWorktree(owner);
|
|
369
|
+
const index = join(this.directory, `index-${basename(path)}`);
|
|
370
|
+
const markerPath = this.markerPath(owner);
|
|
371
|
+
let branchCreated = false;
|
|
372
|
+
let worktreeCreated = false;
|
|
373
|
+
try {
|
|
374
|
+
const root = (await git(this.cwd, ["rev-parse", "--show-toplevel"])).trim();
|
|
375
|
+
const launchRelative = relative(root, this.cwd);
|
|
376
|
+
if (launchRelative.startsWith("..")) throw new Error("launch cwd is outside the repository");
|
|
377
|
+
await this.cleanupMarker(markerPath);
|
|
378
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
379
|
+
await git(root, ["read-tree", "HEAD"], { GIT_INDEX_FILE: index });
|
|
380
|
+
await git(root, ["add", "-A"], { GIT_INDEX_FILE: index });
|
|
381
|
+
const tree = (await git(root, ["write-tree"], { GIT_INDEX_FILE: index })).trim();
|
|
382
|
+
const commit = (await git(root, ["commit-tree", tree, "-p", "HEAD", "-m", "pi-extensible-workflows runtime snapshot"], { GIT_INDEX_FILE: index, ...gitIdentity })).trim();
|
|
383
|
+
const record = { owner, path, branch, cwd: join(path, launchRelative), base: commit };
|
|
384
|
+
await atomicJson(markerPath, { owner, path, branch, base: commit });
|
|
385
|
+
await git(root, ["branch", branch, commit]);
|
|
386
|
+
branchCreated = true;
|
|
387
|
+
await git(root, ["worktree", "add", "--no-checkout", path, branch]);
|
|
388
|
+
worktreeCreated = true;
|
|
389
|
+
await git(path, ["checkout", "--force", branch]);
|
|
390
|
+
await rm(index, { force: true });
|
|
391
|
+
await atomicJson(recordsPath, [...records, record]);
|
|
392
|
+
await rm(markerPath, { force: true });
|
|
393
|
+
return record;
|
|
394
|
+
} catch (error) {
|
|
395
|
+
await rm(index, { force: true });
|
|
396
|
+
if (worktreeCreated) await git(this.cwd, ["worktree", "remove", "--force", path]).catch(() => undefined);
|
|
397
|
+
if (branchCreated) await git(this.cwd, ["branch", "-D", branch]).catch(() => undefined);
|
|
398
|
+
await rm(markerPath, { force: true });
|
|
399
|
+
try {
|
|
400
|
+
const persisted = await json<unknown[]>(recordsPath);
|
|
401
|
+
const matches = persisted.filter((candidate) => candidate && typeof candidate === "object" && (candidate as Partial<WorktreeReference>).owner === owner);
|
|
402
|
+
if (matches.length === 1) { this.structuralWorktree(owner, matches[0]); records = persisted.filter((candidate) => candidate !== matches[0]) as WorktreeReference[]; await atomicJson(recordsPath, records); }
|
|
403
|
+
} catch { /* Ownership changed or disappeared: do not delete anything. */ }
|
|
404
|
+
throw new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
this.worktreeWrite = write.then(() => undefined, () => undefined);
|
|
408
|
+
return write;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async snapshotWorktree(owner: string): Promise<string> {
|
|
412
|
+
try {
|
|
413
|
+
const write = this.snapshotWrite.then(async () => {
|
|
414
|
+
const record = await this.worktree(owner);
|
|
415
|
+
await git(record.path, ["add", "-A"]);
|
|
416
|
+
if ((await git(record.path, ["status", "--porcelain"])).trim()) await git(record.path, ["commit", "-m", "pi-extensible-workflows runtime snapshot"], gitIdentity);
|
|
417
|
+
return (await git(record.path, ["rev-parse", "HEAD"])).trim();
|
|
418
|
+
});
|
|
419
|
+
this.snapshotWrite = write.then(() => undefined, () => undefined);
|
|
420
|
+
return await write;
|
|
421
|
+
} catch (error) {
|
|
422
|
+
throw error instanceof WorkflowError && error.code === "WORKTREE_FAILED" ? error : new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async worktrees(): Promise<readonly WorktreeReference[]> {
|
|
427
|
+
const records = await json<WorktreeReference[]>(join(this.directory, "worktrees.json")).catch((error: unknown) => { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; });
|
|
428
|
+
const validated = await Promise.all(records.map(async (record) => { try { return await this.validateWorktree(record.owner); } catch { return undefined; } }));
|
|
429
|
+
return validated.filter((record): record is WorktreeReference => record !== undefined);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async changedWorktrees(): Promise<readonly WorktreeReference[]> {
|
|
433
|
+
const changed: WorktreeReference[] = [];
|
|
434
|
+
for (const valid of await this.worktrees()) {
|
|
435
|
+
try { await git(valid.path, ["diff", "--quiet", valid.base, "HEAD"]); }
|
|
436
|
+
catch { changed.push(valid); }
|
|
437
|
+
}
|
|
438
|
+
return changed;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async saveResult(value: JsonValue): Promise<string> {
|
|
442
|
+
const path = join(this.directory, "result.json");
|
|
443
|
+
await atomicJson(path, value);
|
|
444
|
+
return path;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async delete(confirmed: boolean): Promise<void> {
|
|
448
|
+
if (!confirmed) throw new WorkflowError("CANCELLED", "Run deletion requires confirmation");
|
|
449
|
+
const records = await json<unknown[]>(join(this.directory, "worktrees.json")).catch((error: unknown) => { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; });
|
|
450
|
+
const validated = records.map((record) => {
|
|
451
|
+
try {
|
|
452
|
+
if (!record || typeof record !== "object" || typeof (record as Partial<WorktreeReference>).owner !== "string") throw new Error("Invalid worktree record");
|
|
453
|
+
return this.structuralWorktree((record as Partial<WorktreeReference>).owner as string, record);
|
|
454
|
+
} catch (error) {
|
|
455
|
+
throw new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
await this.cleanupOrphanWorktrees();
|
|
459
|
+
for (const record of validated) {
|
|
460
|
+
await git(this.cwd, ["worktree", "remove", "--force", record.path]).catch(() => undefined);
|
|
461
|
+
await git(this.cwd, ["branch", "-D", record.branch]).catch(() => undefined);
|
|
462
|
+
}
|
|
463
|
+
await rm(this.directory, { recursive: true, force: true });
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function git(cwd: string, args: readonly string[], extraEnv: NodeJS.ProcessEnv = {}): Promise<string> {
|
|
468
|
+
const { stdout } = await execute("git", ["-c", "core.hooksPath=/dev/null", "-c", "commit.gpgSign=false", ...args], { cwd, env: { ...process.env, ...extraEnv }, encoding: "utf8" });
|
|
469
|
+
return stdout;
|
|
470
|
+
}
|