pi-workflow-engine 0.10.3 → 0.12.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/.pi/extensions/pi-workflow-engine/index.ts +418 -213
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -4
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +147 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +399 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +91 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +154 -530
- package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +368 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +87 -39
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +89 -0
- package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
- package/.pi/extensions/pi-workflow-engine/src/budget.ts +10 -3
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +55 -3
- package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +230 -61
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +38 -55
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +70 -17
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -63
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +357 -75
- package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
- package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +10 -5
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +156 -66
- package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
- package/.pi/extensions/pi-workflow-engine/src/options.ts +96 -6
- package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +74 -122
- package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
- package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
- package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +701 -0
- package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +49 -0
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
- package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
- package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +18 -22
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +128 -56
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +294 -0
- package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
- package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
- package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +150 -0
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +401 -0
- package/.pi/extensions/pi-workflow-engine/src/types.ts +87 -25
- package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +7 -70
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
- package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +23 -3
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +30 -3
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +321 -43
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +62 -64
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +17 -17
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +15 -16
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +15 -15
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
- package/README.md +82 -207
- package/USAGE.md +295 -21
- package/package.json +9 -9
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
|
-
import { cp, mkdir, rm } from "node:fs/promises";
|
|
4
|
-
import { join, resolve, sep } from "node:path";
|
|
3
|
+
import { cp, mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { runBoundedProcess } from "./process-runner.ts";
|
|
6
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
6
7
|
|
|
7
8
|
export interface WorktreeGitCommandOptions {
|
|
8
9
|
readonly cwd: string;
|
|
9
10
|
readonly args: readonly string[];
|
|
11
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
12
|
+
readonly stdin?: string;
|
|
10
13
|
readonly signal?: AbortSignal;
|
|
11
14
|
readonly timeoutMs: number;
|
|
12
15
|
readonly maxBufferBytes?: number;
|
|
@@ -25,9 +28,17 @@ export interface WorktreeGitRunner {
|
|
|
25
28
|
|
|
26
29
|
export interface WorktreeRef {
|
|
27
30
|
readonly path: string;
|
|
31
|
+
/** Exact post-setup commit used as the immutable base for every result patch. */
|
|
32
|
+
readonly baselineOid: string;
|
|
28
33
|
readonly snapshot?: boolean;
|
|
29
34
|
}
|
|
30
35
|
|
|
36
|
+
/** Immutable commit plus an optional patch used to reconstruct a reviewed snapshot. */
|
|
37
|
+
export interface WorktreeBaseline {
|
|
38
|
+
readonly ref: string;
|
|
39
|
+
readonly patch?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
export interface WorktreeAddFailure {
|
|
32
43
|
readonly path: string;
|
|
33
44
|
readonly error: string;
|
|
@@ -45,6 +56,21 @@ export interface WorktreeRemovalOutcome extends WorktreeGitCommandResult {
|
|
|
45
56
|
readonly path: string;
|
|
46
57
|
}
|
|
47
58
|
|
|
59
|
+
export class WorktreeCleanupError extends AggregateError {
|
|
60
|
+
readonly outcomes: readonly WorktreeRemovalOutcome[];
|
|
61
|
+
|
|
62
|
+
constructor(outcomes: readonly WorktreeRemovalOutcome[]) {
|
|
63
|
+
const failures = outcomes.filter((outcome) => !outcome.ok);
|
|
64
|
+
const details = failures.map((failure) => `${failure.path} (${worktreeRemovalError(failure)})`);
|
|
65
|
+
super(
|
|
66
|
+
failures.map((failure) => new Error(`Failed to remove isolated worktree ${failure.path}: ${worktreeRemovalError(failure)}`)),
|
|
67
|
+
`Failed to remove ${failures.length} isolated worktree${failures.length === 1 ? "" : "s"}: ${details.join(", ")}`,
|
|
68
|
+
);
|
|
69
|
+
this.name = "WorktreeCleanupError";
|
|
70
|
+
this.outcomes = outcomes;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
48
74
|
export interface WorktreePatch {
|
|
49
75
|
readonly patch: string;
|
|
50
76
|
readonly changed: boolean;
|
|
@@ -59,6 +85,7 @@ export interface WorktreeRegistryOptions {
|
|
|
59
85
|
|
|
60
86
|
export type WorktreePatchCapture = (options: {
|
|
61
87
|
readonly worktreePath: string;
|
|
88
|
+
readonly baselineOid: string;
|
|
62
89
|
readonly runner?: WorktreeGitRunner;
|
|
63
90
|
readonly signal?: AbortSignal;
|
|
64
91
|
readonly timeoutMs?: number;
|
|
@@ -66,11 +93,20 @@ export type WorktreePatchCapture = (options: {
|
|
|
66
93
|
|
|
67
94
|
const DEFAULT_WORKTREE_TIMEOUT_MS = 30_000;
|
|
68
95
|
const WORKTREE_DIFF_MAX_BYTES = 16 << 20;
|
|
96
|
+
const SYNTHETIC_COMMIT_ENV: NodeJS.ProcessEnv = {
|
|
97
|
+
GIT_AUTHOR_NAME: "pi-workflow",
|
|
98
|
+
GIT_AUTHOR_EMAIL: "pi-workflow@example.invalid",
|
|
99
|
+
GIT_AUTHOR_DATE: "2000-01-01T00:00:00Z",
|
|
100
|
+
GIT_COMMITTER_NAME: "pi-workflow",
|
|
101
|
+
GIT_COMMITTER_EMAIL: "pi-workflow@example.invalid",
|
|
102
|
+
GIT_COMMITTER_DATE: "2000-01-01T00:00:00Z",
|
|
103
|
+
};
|
|
69
104
|
let pathCounter = 0;
|
|
70
105
|
|
|
71
106
|
export class WorktreeRegistry {
|
|
72
107
|
private readonly paths = new Set<string>();
|
|
73
108
|
private readonly snapshots = new Set<string>();
|
|
109
|
+
private readonly removals = new Map<string, Promise<WorktreeGitCommandResult>>();
|
|
74
110
|
private readonly runner: WorktreeGitRunner;
|
|
75
111
|
private readonly patchCapture: WorktreePatchCapture;
|
|
76
112
|
private readonly timeoutMs: number;
|
|
@@ -93,8 +129,8 @@ export class WorktreeRegistry {
|
|
|
93
129
|
return await isGitWorktree({ repoCwd: this.repoCwd, runner: this.runner, signal, timeoutMs: this.timeoutMs });
|
|
94
130
|
}
|
|
95
131
|
|
|
96
|
-
async add(signal?: AbortSignal): Promise<WorktreeRef | WorktreeAddFailure> {
|
|
97
|
-
const added = await addWorktree({ repoCwd: this.repoCwd, runner: this.runner, signal, timeoutMs: this.timeoutMs });
|
|
132
|
+
async add(signal?: AbortSignal, baseline?: WorktreeBaseline): Promise<WorktreeRef | WorktreeAddFailure> {
|
|
133
|
+
const added = await addWorktree({ repoCwd: this.repoCwd, runner: this.runner, signal, timeoutMs: this.timeoutMs, baseline });
|
|
98
134
|
this.register(added.path);
|
|
99
135
|
if (added.snapshot === true) this.snapshots.add(added.path);
|
|
100
136
|
if ("error" in added) {
|
|
@@ -104,16 +140,62 @@ export class WorktreeRegistry {
|
|
|
104
140
|
return added;
|
|
105
141
|
}
|
|
106
142
|
|
|
107
|
-
async capturePatch(
|
|
108
|
-
|
|
143
|
+
async capturePatch(
|
|
144
|
+
path: string,
|
|
145
|
+
baselineOid: string,
|
|
146
|
+
signal?: AbortSignal,
|
|
147
|
+
): Promise<WorktreePatch | { readonly error: string }> {
|
|
148
|
+
return await this.patchCapture({
|
|
149
|
+
worktreePath: path,
|
|
150
|
+
baselineOid,
|
|
151
|
+
runner: this.runner,
|
|
152
|
+
signal,
|
|
153
|
+
timeoutMs: this.timeoutMs,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async validatePatch(path: string, candidate: WorktreePatch, signal?: AbortSignal): Promise<WorktreeGitCommandResult> {
|
|
158
|
+
return await validateWorktreePatch({
|
|
159
|
+
worktreePath: path,
|
|
160
|
+
candidate,
|
|
161
|
+
runner: this.runner,
|
|
162
|
+
signal,
|
|
163
|
+
timeoutMs: this.timeoutMs,
|
|
164
|
+
});
|
|
109
165
|
}
|
|
110
166
|
|
|
111
167
|
async remove(path: string): Promise<WorktreeGitCommandResult> {
|
|
112
|
-
const
|
|
168
|
+
const pending = this.removals.get(path);
|
|
169
|
+
if (pending) return await pending;
|
|
170
|
+
|
|
171
|
+
const removal = this.removeOnce(path).finally(() => this.removals.delete(path));
|
|
172
|
+
this.removals.set(path, removal);
|
|
173
|
+
return await removal;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async removeAll(): Promise<readonly WorktreeRemovalOutcome[]> {
|
|
177
|
+
const outcomes = await Promise.all(
|
|
178
|
+
[...this.paths].map(async (path) => ({
|
|
179
|
+
path,
|
|
180
|
+
...(await this.remove(path)),
|
|
181
|
+
})),
|
|
182
|
+
);
|
|
183
|
+
if (outcomes.some((outcome) => !outcome.ok)) throw new WorktreeCleanupError(outcomes);
|
|
184
|
+
return outcomes;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private async removeOnce(path: string): Promise<WorktreeGitCommandResult> {
|
|
188
|
+
const result = await removeWorktree({
|
|
189
|
+
repoCwd: this.repoCwd,
|
|
190
|
+
path,
|
|
191
|
+
runner: this.runner,
|
|
192
|
+
timeoutMs: this.timeoutMs,
|
|
193
|
+
snapshot: this.snapshots.has(path),
|
|
194
|
+
}).catch((error: unknown) => ({
|
|
113
195
|
ok: false,
|
|
114
196
|
stdout: "",
|
|
115
197
|
stderr: "",
|
|
116
|
-
error:
|
|
198
|
+
error: unknownErrorMessage(error),
|
|
117
199
|
}));
|
|
118
200
|
if (result.ok) {
|
|
119
201
|
this.paths.delete(path);
|
|
@@ -121,15 +203,6 @@ export class WorktreeRegistry {
|
|
|
121
203
|
}
|
|
122
204
|
return result;
|
|
123
205
|
}
|
|
124
|
-
|
|
125
|
-
async removeAll(): Promise<readonly WorktreeRemovalOutcome[]> {
|
|
126
|
-
return await Promise.all(
|
|
127
|
-
[...this.paths].map(async (path) => ({
|
|
128
|
-
path,
|
|
129
|
-
...(await this.remove(path)),
|
|
130
|
-
})),
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
206
|
}
|
|
134
207
|
|
|
135
208
|
export function createWorktreePath(baseDir = tmpdir()): string {
|
|
@@ -150,7 +223,7 @@ export async function isGitWorktree(options: {
|
|
|
150
223
|
signal: options.signal,
|
|
151
224
|
timeoutMs: options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS,
|
|
152
225
|
})
|
|
153
|
-
.catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error:
|
|
226
|
+
.catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
|
|
154
227
|
if (!result.ok) return { ok: false, inside: false, error: result.error ?? (result.stderr.trim() || "git worktree probe failed") };
|
|
155
228
|
return { ok: true, inside: result.stdout.trim() === "true" };
|
|
156
229
|
}
|
|
@@ -161,26 +234,138 @@ export async function addWorktree(options: {
|
|
|
161
234
|
readonly signal?: AbortSignal;
|
|
162
235
|
readonly timeoutMs?: number;
|
|
163
236
|
readonly baseDir?: string;
|
|
237
|
+
readonly baseline?: WorktreeBaseline;
|
|
164
238
|
}): Promise<WorktreeRef | WorktreeAddFailure> {
|
|
165
239
|
const path = createWorktreePath(options.baseDir);
|
|
166
|
-
const
|
|
240
|
+
const ref = options.baseline?.ref ?? "HEAD";
|
|
241
|
+
const runner = options.runner ?? spawnGitRunner;
|
|
242
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS;
|
|
243
|
+
const result = await runner
|
|
167
244
|
.runGit({
|
|
168
245
|
cwd: options.repoCwd,
|
|
169
|
-
args: ["worktree", "add", "--detach", path,
|
|
246
|
+
args: ["worktree", "add", "--detach", path, ref],
|
|
170
247
|
signal: options.signal,
|
|
171
|
-
timeoutMs
|
|
248
|
+
timeoutMs,
|
|
172
249
|
})
|
|
173
|
-
.catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error:
|
|
174
|
-
if (result.ok)
|
|
250
|
+
.catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
|
|
251
|
+
if (result.ok) {
|
|
252
|
+
const patch = options.baseline?.patch;
|
|
253
|
+
if (patch !== undefined && patch.trim().length > 0) {
|
|
254
|
+
const prepared = await prepareWorktreeBaseline({
|
|
255
|
+
path,
|
|
256
|
+
patch,
|
|
257
|
+
runner,
|
|
258
|
+
signal: options.signal,
|
|
259
|
+
timeoutMs,
|
|
260
|
+
}).catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
|
|
261
|
+
if (!prepared.ok) return { path, error: prepared.error ?? (prepared.stderr.trim() || "failed to prepare worktree baseline") };
|
|
262
|
+
}
|
|
263
|
+
return await finalizeWorktreeRef({ path, runner, signal: options.signal, timeoutMs });
|
|
264
|
+
}
|
|
175
265
|
|
|
176
266
|
const message = result.error ?? (result.stderr.trim() || "git worktree add failed");
|
|
177
267
|
if (!isInvalidHeadError(message)) return { path, error: message };
|
|
178
268
|
|
|
179
|
-
|
|
269
|
+
return await addUnbornRepositoryWorktree({
|
|
270
|
+
repoCwd: options.repoCwd,
|
|
271
|
+
path,
|
|
272
|
+
addError: message,
|
|
273
|
+
runner,
|
|
274
|
+
signal: options.signal,
|
|
275
|
+
snapshotTimeoutMs: options.timeoutMs,
|
|
276
|
+
timeoutMs,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function addUnbornRepositoryWorktree(options: {
|
|
281
|
+
readonly repoCwd: string;
|
|
282
|
+
readonly path: string;
|
|
283
|
+
readonly addError: string;
|
|
284
|
+
readonly runner: WorktreeGitRunner;
|
|
285
|
+
readonly signal?: AbortSignal;
|
|
286
|
+
readonly snapshotTimeoutMs?: number;
|
|
287
|
+
readonly timeoutMs: number;
|
|
288
|
+
}): Promise<WorktreeRef | WorktreeAddFailure> {
|
|
289
|
+
const snapshotError = await createUnbornRepoSnapshot({
|
|
290
|
+
repoCwd: options.repoCwd,
|
|
291
|
+
path: options.path,
|
|
292
|
+
timeoutMs: options.snapshotTimeoutMs,
|
|
293
|
+
signal: options.signal,
|
|
294
|
+
})
|
|
180
295
|
.then(() => undefined)
|
|
181
|
-
.catch((error: unknown) =>
|
|
182
|
-
if (snapshotError !== undefined)
|
|
183
|
-
|
|
296
|
+
.catch((error: unknown) => unknownErrorMessage(error));
|
|
297
|
+
if (snapshotError !== undefined) {
|
|
298
|
+
return {
|
|
299
|
+
path: options.path,
|
|
300
|
+
snapshot: true,
|
|
301
|
+
error: `git worktree add failed (${options.addError}); unborn-repo snapshot fallback failed: ${snapshotError}`,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
return await finalizeWorktreeRef({
|
|
305
|
+
path: options.path,
|
|
306
|
+
snapshot: true,
|
|
307
|
+
runner: options.runner,
|
|
308
|
+
signal: options.signal,
|
|
309
|
+
timeoutMs: options.timeoutMs,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function finalizeWorktreeRef(options: {
|
|
314
|
+
readonly path: string;
|
|
315
|
+
readonly snapshot?: boolean;
|
|
316
|
+
readonly runner: WorktreeGitRunner;
|
|
317
|
+
readonly signal?: AbortSignal;
|
|
318
|
+
readonly timeoutMs: number;
|
|
319
|
+
}): Promise<WorktreeRef | WorktreeAddFailure> {
|
|
320
|
+
const resolved = await options.runner
|
|
321
|
+
.runGit({
|
|
322
|
+
cwd: options.path,
|
|
323
|
+
args: ["rev-parse", "--verify", "HEAD^{commit}"],
|
|
324
|
+
signal: options.signal,
|
|
325
|
+
timeoutMs: options.timeoutMs,
|
|
326
|
+
})
|
|
327
|
+
.catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
|
|
328
|
+
if (!resolved.ok) {
|
|
329
|
+
return {
|
|
330
|
+
path: options.path,
|
|
331
|
+
snapshot: options.snapshot,
|
|
332
|
+
error: resolved.error ?? (resolved.stderr.trim() || "failed to resolve isolated worktree baseline"),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
const baselineOid = resolved.stdout.trim();
|
|
336
|
+
if (!isGitObjectId(baselineOid)) {
|
|
337
|
+
return { path: options.path, snapshot: options.snapshot, error: "isolated worktree baseline is not a commit OID" };
|
|
338
|
+
}
|
|
339
|
+
return { path: options.path, baselineOid, snapshot: options.snapshot };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function prepareWorktreeBaseline(options: {
|
|
343
|
+
readonly path: string;
|
|
344
|
+
readonly patch: string;
|
|
345
|
+
readonly runner: WorktreeGitRunner;
|
|
346
|
+
readonly signal?: AbortSignal;
|
|
347
|
+
readonly timeoutMs: number;
|
|
348
|
+
}): Promise<WorktreeGitCommandResult> {
|
|
349
|
+
const applied = await options.runner.runGit({
|
|
350
|
+
cwd: options.path,
|
|
351
|
+
args: ["apply", "--index", "--binary", "--whitespace=nowarn", "-"],
|
|
352
|
+
stdin: options.patch,
|
|
353
|
+
signal: options.signal,
|
|
354
|
+
timeoutMs: options.timeoutMs,
|
|
355
|
+
});
|
|
356
|
+
if (!applied.ok) return applied;
|
|
357
|
+
const hooksPath = await mkdtemp(join(tmpdir(), "pi-workflow-empty-hooks-"));
|
|
358
|
+
try {
|
|
359
|
+
return await options.runner.runGit({
|
|
360
|
+
cwd: options.path,
|
|
361
|
+
args: syntheticCommitArgs(hooksPath, "pi workflow reviewed snapshot"),
|
|
362
|
+
env: SYNTHETIC_COMMIT_ENV,
|
|
363
|
+
signal: options.signal,
|
|
364
|
+
timeoutMs: options.timeoutMs,
|
|
365
|
+
});
|
|
366
|
+
} finally {
|
|
367
|
+
await rm(hooksPath, { recursive: true, force: true });
|
|
368
|
+
}
|
|
184
369
|
}
|
|
185
370
|
|
|
186
371
|
export async function removeWorktree(options: {
|
|
@@ -203,10 +388,12 @@ export async function removeWorktree(options: {
|
|
|
203
388
|
|
|
204
389
|
export async function captureWorktreePatch(options: {
|
|
205
390
|
readonly worktreePath: string;
|
|
391
|
+
readonly baselineOid: string;
|
|
206
392
|
readonly runner?: WorktreeGitRunner;
|
|
207
393
|
readonly signal?: AbortSignal;
|
|
208
394
|
readonly timeoutMs?: number;
|
|
209
395
|
}): Promise<WorktreePatch | { readonly error: string }> {
|
|
396
|
+
if (!isGitObjectId(options.baselineOid)) return { error: "worktree patch baseline is not a commit OID" };
|
|
210
397
|
const runner = options.runner ?? spawnGitRunner;
|
|
211
398
|
const timeoutMs = options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS;
|
|
212
399
|
const intentToAdd = await runner
|
|
@@ -216,54 +403,144 @@ export async function captureWorktreePatch(options: {
|
|
|
216
403
|
signal: options.signal,
|
|
217
404
|
timeoutMs,
|
|
218
405
|
})
|
|
219
|
-
.catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error:
|
|
406
|
+
.catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
|
|
220
407
|
if (!intentToAdd.ok) return { error: intentToAdd.error ?? (intentToAdd.stderr.trim() || "git add -N failed before patch capture") };
|
|
221
408
|
|
|
222
409
|
const diff = await runner
|
|
223
410
|
.runGit({
|
|
224
411
|
cwd: options.worktreePath,
|
|
225
|
-
args: ["diff", "--no-color", "
|
|
412
|
+
args: ["diff", "--binary", "--full-index", "--no-ext-diff", "--no-color", options.baselineOid, "--"],
|
|
226
413
|
signal: options.signal,
|
|
227
414
|
timeoutMs,
|
|
228
415
|
maxBufferBytes: WORKTREE_DIFF_MAX_BYTES,
|
|
229
416
|
})
|
|
230
|
-
.catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error:
|
|
417
|
+
.catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
|
|
231
418
|
if (!diff.ok) return { error: diff.error ?? (diff.stderr.trim() || "worktree diff capture failed") };
|
|
232
419
|
return { patch: diff.stdout, changed: diff.stdout.trim().length > 0 };
|
|
233
420
|
}
|
|
234
421
|
|
|
422
|
+
export async function validateWorktreePatch(options: {
|
|
423
|
+
readonly worktreePath: string;
|
|
424
|
+
readonly candidate: WorktreePatch;
|
|
425
|
+
readonly runner?: WorktreeGitRunner;
|
|
426
|
+
readonly signal?: AbortSignal;
|
|
427
|
+
readonly timeoutMs?: number;
|
|
428
|
+
}): Promise<WorktreeGitCommandResult> {
|
|
429
|
+
const hasPatch = options.candidate.patch.trim().length > 0;
|
|
430
|
+
if (options.candidate.changed !== hasPatch) {
|
|
431
|
+
return invalidPatchResult("cached worktree patch changed flag does not match patch content");
|
|
432
|
+
}
|
|
433
|
+
if (!hasPatch) return { ok: true, stdout: "", stderr: "" };
|
|
434
|
+
|
|
435
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS;
|
|
436
|
+
return await (options.runner ?? spawnGitRunner)
|
|
437
|
+
.runGit({
|
|
438
|
+
cwd: options.worktreePath,
|
|
439
|
+
args: ["apply", "--check", "--binary", "-"],
|
|
440
|
+
stdin: options.candidate.patch,
|
|
441
|
+
signal: options.signal,
|
|
442
|
+
timeoutMs,
|
|
443
|
+
})
|
|
444
|
+
.catch((error: unknown) => invalidPatchResult(unknownErrorMessage(error)));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function invalidPatchResult(error: string): WorktreeGitCommandResult {
|
|
448
|
+
return { ok: false, stdout: "", stderr: "", error };
|
|
449
|
+
}
|
|
450
|
+
|
|
235
451
|
async function createUnbornRepoSnapshot(options: {
|
|
236
452
|
readonly repoCwd: string;
|
|
237
453
|
readonly path: string;
|
|
238
454
|
readonly signal?: AbortSignal;
|
|
239
455
|
readonly timeoutMs?: number;
|
|
240
456
|
}): Promise<void> {
|
|
457
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS;
|
|
458
|
+
const rootProbe = await runGitCommand({
|
|
459
|
+
cwd: options.repoCwd,
|
|
460
|
+
args: ["rev-parse", "--show-toplevel"],
|
|
461
|
+
signal: options.signal,
|
|
462
|
+
timeoutMs,
|
|
463
|
+
});
|
|
464
|
+
if (!rootProbe.ok) {
|
|
465
|
+
throw new Error(rootProbe.error ?? (rootProbe.stderr.trim() || "failed to resolve unborn repository root"));
|
|
466
|
+
}
|
|
467
|
+
const sourceRoot = parseGitTopLevel(rootProbe.stdout, options.repoCwd);
|
|
468
|
+
if (!sourceRoot) throw new Error("unborn repository root probe returned an invalid path");
|
|
469
|
+
|
|
241
470
|
const targetRoot = resolve(options.path);
|
|
242
471
|
await rm(options.path, { recursive: true, force: true });
|
|
243
472
|
await mkdir(options.path, { recursive: true });
|
|
244
|
-
await cp(
|
|
473
|
+
await cp(sourceRoot, options.path, {
|
|
245
474
|
recursive: true,
|
|
246
475
|
force: true,
|
|
247
476
|
filter: (source) => {
|
|
248
477
|
const resolvedSource = resolve(source);
|
|
249
478
|
if (resolvedSource === targetRoot || resolvedSource.startsWith(`${targetRoot}${sep}`)) return false;
|
|
250
|
-
return !resolvedSource
|
|
479
|
+
return !isExcludedSnapshotPath(sourceRoot, resolvedSource);
|
|
251
480
|
},
|
|
252
481
|
});
|
|
253
|
-
await
|
|
254
|
-
await
|
|
255
|
-
await
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
482
|
+
await requireGitCommand({ cwd: options.path, args: ["init"], signal: options.signal, timeoutMs });
|
|
483
|
+
await requireGitCommand({ cwd: options.path, args: ["add", "-A"], signal: options.signal, timeoutMs });
|
|
484
|
+
const hooksPath = await mkdtemp(join(tmpdir(), "pi-workflow-empty-hooks-"));
|
|
485
|
+
try {
|
|
486
|
+
await requireGitCommand({
|
|
487
|
+
cwd: options.path,
|
|
488
|
+
args: syntheticCommitArgs(hooksPath, "pi workflow baseline"),
|
|
489
|
+
env: SYNTHETIC_COMMIT_ENV,
|
|
490
|
+
signal: options.signal,
|
|
491
|
+
timeoutMs,
|
|
492
|
+
});
|
|
493
|
+
} finally {
|
|
494
|
+
await rm(hooksPath, { recursive: true, force: true });
|
|
495
|
+
}
|
|
496
|
+
await requireGitCommand({ cwd: options.path, args: ["clean", "-ffdx"], signal: options.signal, timeoutMs });
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function syntheticCommitArgs(hooksPath: string, message: string): readonly string[] {
|
|
500
|
+
return [
|
|
501
|
+
"-c",
|
|
502
|
+
`core.hooksPath=${hooksPath}`,
|
|
503
|
+
"-c",
|
|
504
|
+
"commit.gpgSign=false",
|
|
505
|
+
"commit",
|
|
506
|
+
"--allow-empty",
|
|
507
|
+
"--no-verify",
|
|
508
|
+
"--no-gpg-sign",
|
|
509
|
+
"-m",
|
|
510
|
+
message,
|
|
511
|
+
];
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function requireGitCommand(options: WorktreeGitCommandOptions): Promise<void> {
|
|
515
|
+
const result = await runGitCommand(options);
|
|
516
|
+
if (!result.ok) throw new Error(result.error ?? (result.stderr.trim() || "git command failed"));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function parseGitTopLevel(output: string, cwd: string): string | undefined {
|
|
520
|
+
const withoutLf = output.endsWith("\n") ? output.slice(0, -1) : output;
|
|
521
|
+
const value = withoutLf.endsWith("\r") ? withoutLf.slice(0, -1) : withoutLf;
|
|
522
|
+
if (value.length === 0 || value.includes("\n") || value.includes("\0")) return undefined;
|
|
523
|
+
const root = resolve(cwd, value);
|
|
524
|
+
const cwdFromRoot = relative(root, resolve(cwd));
|
|
525
|
+
return isAbsolute(cwdFromRoot) || cwdFromRoot === ".." || cwdFromRoot.startsWith(`..${sep}`) ? undefined : root;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function isExcludedSnapshotPath(sourceRoot: string, source: string): boolean {
|
|
529
|
+
const path = relative(sourceRoot, source);
|
|
530
|
+
if (path.length === 0) return false;
|
|
531
|
+
const segments = path.split(sep);
|
|
532
|
+
if (segments.includes(".git")) return true;
|
|
533
|
+
return segments.some((segment, index) => segment === ".pi" && segments[index + 1] === ".workflow-runs");
|
|
261
534
|
}
|
|
262
535
|
|
|
263
536
|
function isInvalidHeadError(message: string): boolean {
|
|
264
537
|
return /invalid reference:\s*HEAD/i.test(message) || /ambiguous argument ['"]?HEAD/i.test(message) || /unknown revision or path.*HEAD/i.test(message);
|
|
265
538
|
}
|
|
266
539
|
|
|
540
|
+
function isGitObjectId(value: string): boolean {
|
|
541
|
+
return /^[0-9a-f]{40,64}$/i.test(value);
|
|
542
|
+
}
|
|
543
|
+
|
|
267
544
|
export const spawnGitRunner: WorktreeGitRunner = {
|
|
268
545
|
async runGit(options) {
|
|
269
546
|
return await runGitCommand(options);
|
|
@@ -271,7 +548,7 @@ export const spawnGitRunner: WorktreeGitRunner = {
|
|
|
271
548
|
};
|
|
272
549
|
|
|
273
550
|
async function runGitCommand(options: WorktreeGitCommandOptions): Promise<WorktreeGitCommandResult> {
|
|
274
|
-
const env = { ...process.env };
|
|
551
|
+
const env = { ...process.env, ...options.env };
|
|
275
552
|
delete env.GIT_EXTERNAL_DIFF;
|
|
276
553
|
delete env.GIT_DIFF_OPTS;
|
|
277
554
|
return await runBoundedProcess({
|
|
@@ -279,6 +556,7 @@ async function runGitCommand(options: WorktreeGitCommandOptions): Promise<Worktr
|
|
|
279
556
|
args: options.args,
|
|
280
557
|
cwd: options.cwd,
|
|
281
558
|
env,
|
|
559
|
+
stdin: options.stdin,
|
|
282
560
|
signal: options.signal,
|
|
283
561
|
timeoutMs: options.timeoutMs,
|
|
284
562
|
maxBufferBytes: options.maxBufferBytes,
|
|
@@ -289,6 +567,6 @@ async function runGitCommand(options: WorktreeGitCommandOptions): Promise<Worktr
|
|
|
289
567
|
});
|
|
290
568
|
}
|
|
291
569
|
|
|
292
|
-
function
|
|
293
|
-
return error
|
|
570
|
+
function worktreeRemovalError(outcome: WorktreeRemovalOutcome): string {
|
|
571
|
+
return outcome.error ?? (outcome.stderr.trim() || "unknown error");
|
|
294
572
|
}
|