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