pi-workflow-engine 0.10.2 → 0.11.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 +199 -120
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +145 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +376 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +87 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +84 -529
- package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +413 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +43 -1
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +81 -0
- package/.pi/extensions/pi-workflow-engine/src/budget.ts +7 -0
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +17 -3
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +164 -61
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +117 -20
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +64 -15
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +14 -3
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +208 -74
- 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 +8 -3
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +148 -64
- package/.pi/extensions/pi-workflow-engine/src/options.ts +9 -6
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +2 -5
- package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +665 -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 +17 -22
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +143 -52
- 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 +295 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +153 -0
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +404 -0
- package/.pi/extensions/pi-workflow-engine/src/types.ts +38 -10
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +5 -69
- package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
- 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/workflows.ts +28 -3
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +282 -41
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +48 -26
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +8 -1
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +8 -2
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +8 -1
- package/README.md +170 -226
- package/USAGE.md +92 -12
- package/package.json +9 -9
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ResolvedWorkflowRunOptions } from "./options.ts";
|
|
3
|
+
import type { PerfAggregate, PerfSink, PerfSnapshot } from "./perf.ts";
|
|
4
|
+
import type { WorkflowProgressSnapshot } from "./progress.ts";
|
|
5
|
+
import type {
|
|
6
|
+
LoadedWorkflow,
|
|
7
|
+
WorkflowProgressSource,
|
|
8
|
+
WorkflowRef,
|
|
9
|
+
WorkflowRunMetadata,
|
|
10
|
+
} from "./types.ts";
|
|
11
|
+
import type { WorkflowUsageSnapshot } from "./usage.ts";
|
|
12
|
+
|
|
13
|
+
export interface WorkflowPerfDetails {
|
|
14
|
+
readonly enabled: boolean;
|
|
15
|
+
readonly startedAt: number;
|
|
16
|
+
readonly aggregates: readonly PerfAggregate[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface WorkflowResultEnvelope {
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly result: unknown;
|
|
22
|
+
readonly completedAt: number;
|
|
23
|
+
readonly usage?: WorkflowUsageSnapshot;
|
|
24
|
+
readonly perf?: WorkflowPerfDetails;
|
|
25
|
+
readonly runId?: string;
|
|
26
|
+
readonly resumedFromRunId?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type ResolvedWorkflowRunner = (
|
|
30
|
+
ctx: ExtensionContext,
|
|
31
|
+
mod: LoadedWorkflow,
|
|
32
|
+
args: string,
|
|
33
|
+
options: ResolvedWorkflowRunOptions,
|
|
34
|
+
) => Promise<unknown>;
|
|
35
|
+
|
|
36
|
+
export interface WorkflowExecutionInput {
|
|
37
|
+
readonly ctx: ExtensionContext;
|
|
38
|
+
readonly name: string;
|
|
39
|
+
readonly mod: LoadedWorkflow;
|
|
40
|
+
readonly args: string;
|
|
41
|
+
readonly options: ResolvedWorkflowRunOptions;
|
|
42
|
+
readonly perfRecorder?: PerfSink;
|
|
43
|
+
readonly runResolvedWorkflow: ResolvedWorkflowRunner;
|
|
44
|
+
readonly resolveWorkflow: (ref: WorkflowRef) => Promise<LoadedWorkflow>;
|
|
45
|
+
readonly onProgressSource?: (source: WorkflowProgressSource | undefined) => void | Promise<void>;
|
|
46
|
+
readonly onProgressSnapshot?: (snapshot: WorkflowProgressSnapshot) => void | Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface WorkflowExecution {
|
|
50
|
+
readonly metadata?: WorkflowRunMetadata;
|
|
51
|
+
readonly envelope: WorkflowResultEnvelope;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Shared command/tool execution path. The engine remains lazy-loaded by the caller. */
|
|
55
|
+
export async function executeWorkflowInvocation(input: WorkflowExecutionInput): Promise<WorkflowExecution> {
|
|
56
|
+
let perfSnapshot: PerfSnapshot | undefined;
|
|
57
|
+
let usageSnapshot: WorkflowUsageSnapshot | undefined;
|
|
58
|
+
let runMetadata: WorkflowRunMetadata | undefined;
|
|
59
|
+
const { options } = input;
|
|
60
|
+
const runOptions: ResolvedWorkflowRunOptions = {
|
|
61
|
+
...options,
|
|
62
|
+
perfRecorder: input.perfRecorder,
|
|
63
|
+
resolveWorkflow: input.resolveWorkflow,
|
|
64
|
+
onProgressSource(source) {
|
|
65
|
+
return notifyLifecycleObservers(
|
|
66
|
+
() => input.onProgressSource?.(source),
|
|
67
|
+
() => options.onProgressSource?.(source),
|
|
68
|
+
);
|
|
69
|
+
},
|
|
70
|
+
onPerfSnapshot(snapshot) {
|
|
71
|
+
perfSnapshot = snapshot;
|
|
72
|
+
return notifyLifecycleObservers(() => options.onPerfSnapshot?.(snapshot));
|
|
73
|
+
},
|
|
74
|
+
onUsageSnapshot(snapshot) {
|
|
75
|
+
usageSnapshot = snapshot;
|
|
76
|
+
return notifyLifecycleObservers(() => options.onUsageSnapshot?.(snapshot));
|
|
77
|
+
},
|
|
78
|
+
onRunMetadata(metadata) {
|
|
79
|
+
runMetadata = metadata;
|
|
80
|
+
return notifyLifecycleObservers(() => options.onRunMetadata?.(metadata));
|
|
81
|
+
},
|
|
82
|
+
onProgressSnapshot(snapshot) {
|
|
83
|
+
return notifyLifecycleObservers(
|
|
84
|
+
() => input.onProgressSnapshot?.(snapshot),
|
|
85
|
+
() => options.onProgressSnapshot?.(snapshot),
|
|
86
|
+
);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
const result = await input.runResolvedWorkflow(input.ctx, input.mod, input.args, runOptions);
|
|
90
|
+
const perf = compactPerfSnapshot(perfSnapshot);
|
|
91
|
+
return {
|
|
92
|
+
metadata: runMetadata,
|
|
93
|
+
envelope: {
|
|
94
|
+
name: input.name,
|
|
95
|
+
result,
|
|
96
|
+
completedAt: Date.now(),
|
|
97
|
+
usage: usageSnapshot,
|
|
98
|
+
perf,
|
|
99
|
+
runId: runMetadata?.runId,
|
|
100
|
+
resumedFromRunId: runMetadata?.resumedFromRunId,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function compactPerfSnapshot(snapshot: PerfSnapshot | undefined): WorkflowPerfDetails | undefined {
|
|
106
|
+
if (!snapshot?.enabled) return undefined;
|
|
107
|
+
return { enabled: true, startedAt: snapshot.startedAt, aggregates: snapshot.aggregates };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function notifyLifecycleObservers(...observers: ReadonlyArray<() => void | Promise<void>>): Promise<void> {
|
|
111
|
+
const failures: unknown[] = [];
|
|
112
|
+
for (const observer of observers) {
|
|
113
|
+
try {
|
|
114
|
+
await observer();
|
|
115
|
+
} catch (error) {
|
|
116
|
+
failures.push(error);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (failures.length === 1) throw failures[0];
|
|
120
|
+
if (failures.length > 1) throw new AggregateError(failures, "Workflow lifecycle observers failed");
|
|
121
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
LoadedWorkflow,
|
|
3
|
+
WorkflowMeta,
|
|
4
|
+
WorkflowModule,
|
|
5
|
+
WorkflowSourceIdentity,
|
|
6
|
+
} from "./types.ts";
|
|
7
|
+
import type { WorktreeBaseline } from "./worktree.ts";
|
|
2
8
|
|
|
3
9
|
type WorkflowMetaCandidate = {
|
|
4
10
|
readonly name?: unknown;
|
|
@@ -30,6 +36,18 @@ export function parseWorkflowModule(value: unknown): { module: WorkflowModule }
|
|
|
30
36
|
return { module: { meta: parsedMeta.meta, default: candidate.default } };
|
|
31
37
|
}
|
|
32
38
|
|
|
39
|
+
/** Attach engine-owned source provenance after an authored module has been validated. */
|
|
40
|
+
export function loadWorkflow(
|
|
41
|
+
module: WorkflowModule,
|
|
42
|
+
source: WorkflowSourceIdentity,
|
|
43
|
+
isolatedWorktreeBaseline?: WorktreeBaseline,
|
|
44
|
+
): LoadedWorkflow {
|
|
45
|
+
const loaded = { meta: module.meta, default: module.default, source };
|
|
46
|
+
return isolatedWorktreeBaseline === undefined
|
|
47
|
+
? loaded
|
|
48
|
+
: { ...loaded, isolatedWorktreeBaseline };
|
|
49
|
+
}
|
|
50
|
+
|
|
33
51
|
function parseWorkflowMetaObject(meta: WorkflowMetaCandidate): { meta: WorkflowMeta } | { reason: string } {
|
|
34
52
|
if (typeof meta.name !== "string") return { reason: "meta.name must be a string" };
|
|
35
53
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
2
|
import * as codeReview from "../workflows/code-review.ts";
|
|
3
3
|
import * as refactorScout from "../workflows/refactor-scout.ts";
|
|
4
4
|
import * as diagnose from "../workflows/diagnose.ts";
|
|
5
5
|
import * as perfReview from "../workflows/perf-review.ts";
|
|
6
|
+
import type { WorkflowModule } from "./types.ts";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Workflows statically imported by the extension. These are loaded through pi's own
|
|
@@ -18,6 +19,30 @@ import * as perfReview from "../workflows/perf-review.ts";
|
|
|
18
19
|
* dynamically imported built-ins. Startup optimizations should happen at the extension
|
|
19
20
|
* entrypoint/discovery boundary first.
|
|
20
21
|
*/
|
|
21
|
-
export const
|
|
22
|
-
|
|
22
|
+
export const BUILTIN_SOURCE_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
23
|
+
|
|
24
|
+
export interface BuiltinWorkflowDefinition {
|
|
25
|
+
readonly module: WorkflowModule;
|
|
26
|
+
readonly filename: string;
|
|
27
|
+
readonly path: string;
|
|
28
|
+
readonly root: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const BUILTIN_WORKFLOW_DEFINITIONS: readonly BuiltinWorkflowDefinition[] = [
|
|
32
|
+
defineBuiltinWorkflow(codeReview, "code-review.ts"),
|
|
33
|
+
defineBuiltinWorkflow(refactorScout, "refactor-scout.ts"),
|
|
34
|
+
defineBuiltinWorkflow(diagnose, "diagnose.ts"),
|
|
35
|
+
defineBuiltinWorkflow(perfReview, "perf-review.ts"),
|
|
36
|
+
];
|
|
37
|
+
export const BUILTIN_WORKFLOWS = BUILTIN_WORKFLOW_DEFINITIONS.map(({ module }) => module);
|
|
38
|
+
export const BUILTIN_WORKFLOW_FILES = new Set(BUILTIN_WORKFLOW_DEFINITIONS.map(({ filename }) => filename));
|
|
23
39
|
export const BUILTIN_WORKFLOW_NAMES = BUILTIN_WORKFLOWS.map((mod) => mod.meta.name);
|
|
40
|
+
|
|
41
|
+
function defineBuiltinWorkflow(module: WorkflowModule, filename: string): BuiltinWorkflowDefinition {
|
|
42
|
+
return {
|
|
43
|
+
module: { meta: module.meta, default: module.default },
|
|
44
|
+
filename,
|
|
45
|
+
path: fileURLToPath(new URL(`../workflows/${filename}`, import.meta.url)),
|
|
46
|
+
root: BUILTIN_SOURCE_ROOT,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -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,101 @@ 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
|
const snapshotError = await createUnbornRepoSnapshot({ repoCwd: options.repoCwd, path, timeoutMs: options.timeoutMs, signal: options.signal })
|
|
180
270
|
.then(() => undefined)
|
|
181
|
-
.catch((error: unknown) =>
|
|
271
|
+
.catch((error: unknown) => unknownErrorMessage(error));
|
|
182
272
|
if (snapshotError !== undefined) return { path, snapshot: true, error: `git worktree add failed (${message}); unborn-repo snapshot fallback failed: ${snapshotError}` };
|
|
183
|
-
return { path, snapshot: true };
|
|
273
|
+
return await finalizeWorktreeRef({ path, snapshot: true, runner, signal: options.signal, timeoutMs });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function finalizeWorktreeRef(options: {
|
|
277
|
+
readonly path: string;
|
|
278
|
+
readonly snapshot?: boolean;
|
|
279
|
+
readonly runner: WorktreeGitRunner;
|
|
280
|
+
readonly signal?: AbortSignal;
|
|
281
|
+
readonly timeoutMs: number;
|
|
282
|
+
}): Promise<WorktreeRef | WorktreeAddFailure> {
|
|
283
|
+
const resolved = await options.runner
|
|
284
|
+
.runGit({
|
|
285
|
+
cwd: options.path,
|
|
286
|
+
args: ["rev-parse", "--verify", "HEAD^{commit}"],
|
|
287
|
+
signal: options.signal,
|
|
288
|
+
timeoutMs: options.timeoutMs,
|
|
289
|
+
})
|
|
290
|
+
.catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
|
|
291
|
+
if (!resolved.ok) {
|
|
292
|
+
return {
|
|
293
|
+
path: options.path,
|
|
294
|
+
snapshot: options.snapshot,
|
|
295
|
+
error: resolved.error ?? (resolved.stderr.trim() || "failed to resolve isolated worktree baseline"),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
const baselineOid = resolved.stdout.trim();
|
|
299
|
+
if (!isGitObjectId(baselineOid)) {
|
|
300
|
+
return { path: options.path, snapshot: options.snapshot, error: "isolated worktree baseline is not a commit OID" };
|
|
301
|
+
}
|
|
302
|
+
return { path: options.path, baselineOid, snapshot: options.snapshot };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function prepareWorktreeBaseline(options: {
|
|
306
|
+
readonly path: string;
|
|
307
|
+
readonly patch: string;
|
|
308
|
+
readonly runner: WorktreeGitRunner;
|
|
309
|
+
readonly signal?: AbortSignal;
|
|
310
|
+
readonly timeoutMs: number;
|
|
311
|
+
}): Promise<WorktreeGitCommandResult> {
|
|
312
|
+
const applied = await options.runner.runGit({
|
|
313
|
+
cwd: options.path,
|
|
314
|
+
args: ["apply", "--index", "--binary", "--whitespace=nowarn", "-"],
|
|
315
|
+
stdin: options.patch,
|
|
316
|
+
signal: options.signal,
|
|
317
|
+
timeoutMs: options.timeoutMs,
|
|
318
|
+
});
|
|
319
|
+
if (!applied.ok) return applied;
|
|
320
|
+
const hooksPath = await mkdtemp(join(tmpdir(), "pi-workflow-empty-hooks-"));
|
|
321
|
+
try {
|
|
322
|
+
return await options.runner.runGit({
|
|
323
|
+
cwd: options.path,
|
|
324
|
+
args: syntheticCommitArgs(hooksPath, "pi workflow reviewed snapshot"),
|
|
325
|
+
env: SYNTHETIC_COMMIT_ENV,
|
|
326
|
+
signal: options.signal,
|
|
327
|
+
timeoutMs: options.timeoutMs,
|
|
328
|
+
});
|
|
329
|
+
} finally {
|
|
330
|
+
await rm(hooksPath, { recursive: true, force: true });
|
|
331
|
+
}
|
|
184
332
|
}
|
|
185
333
|
|
|
186
334
|
export async function removeWorktree(options: {
|
|
@@ -203,10 +351,12 @@ export async function removeWorktree(options: {
|
|
|
203
351
|
|
|
204
352
|
export async function captureWorktreePatch(options: {
|
|
205
353
|
readonly worktreePath: string;
|
|
354
|
+
readonly baselineOid: string;
|
|
206
355
|
readonly runner?: WorktreeGitRunner;
|
|
207
356
|
readonly signal?: AbortSignal;
|
|
208
357
|
readonly timeoutMs?: number;
|
|
209
358
|
}): Promise<WorktreePatch | { readonly error: string }> {
|
|
359
|
+
if (!isGitObjectId(options.baselineOid)) return { error: "worktree patch baseline is not a commit OID" };
|
|
210
360
|
const runner = options.runner ?? spawnGitRunner;
|
|
211
361
|
const timeoutMs = options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS;
|
|
212
362
|
const intentToAdd = await runner
|
|
@@ -216,54 +366,144 @@ export async function captureWorktreePatch(options: {
|
|
|
216
366
|
signal: options.signal,
|
|
217
367
|
timeoutMs,
|
|
218
368
|
})
|
|
219
|
-
.catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error:
|
|
369
|
+
.catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
|
|
220
370
|
if (!intentToAdd.ok) return { error: intentToAdd.error ?? (intentToAdd.stderr.trim() || "git add -N failed before patch capture") };
|
|
221
371
|
|
|
222
372
|
const diff = await runner
|
|
223
373
|
.runGit({
|
|
224
374
|
cwd: options.worktreePath,
|
|
225
|
-
args: ["diff", "--no-color", "
|
|
375
|
+
args: ["diff", "--binary", "--full-index", "--no-ext-diff", "--no-color", options.baselineOid, "--"],
|
|
226
376
|
signal: options.signal,
|
|
227
377
|
timeoutMs,
|
|
228
378
|
maxBufferBytes: WORKTREE_DIFF_MAX_BYTES,
|
|
229
379
|
})
|
|
230
|
-
.catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error:
|
|
380
|
+
.catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
|
|
231
381
|
if (!diff.ok) return { error: diff.error ?? (diff.stderr.trim() || "worktree diff capture failed") };
|
|
232
382
|
return { patch: diff.stdout, changed: diff.stdout.trim().length > 0 };
|
|
233
383
|
}
|
|
234
384
|
|
|
385
|
+
export async function validateWorktreePatch(options: {
|
|
386
|
+
readonly worktreePath: string;
|
|
387
|
+
readonly candidate: WorktreePatch;
|
|
388
|
+
readonly runner?: WorktreeGitRunner;
|
|
389
|
+
readonly signal?: AbortSignal;
|
|
390
|
+
readonly timeoutMs?: number;
|
|
391
|
+
}): Promise<WorktreeGitCommandResult> {
|
|
392
|
+
const hasPatch = options.candidate.patch.trim().length > 0;
|
|
393
|
+
if (options.candidate.changed !== hasPatch) {
|
|
394
|
+
return invalidPatchResult("cached worktree patch changed flag does not match patch content");
|
|
395
|
+
}
|
|
396
|
+
if (!hasPatch) return { ok: true, stdout: "", stderr: "" };
|
|
397
|
+
|
|
398
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS;
|
|
399
|
+
return await (options.runner ?? spawnGitRunner)
|
|
400
|
+
.runGit({
|
|
401
|
+
cwd: options.worktreePath,
|
|
402
|
+
args: ["apply", "--check", "--binary", "-"],
|
|
403
|
+
stdin: options.candidate.patch,
|
|
404
|
+
signal: options.signal,
|
|
405
|
+
timeoutMs,
|
|
406
|
+
})
|
|
407
|
+
.catch((error: unknown) => invalidPatchResult(unknownErrorMessage(error)));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function invalidPatchResult(error: string): WorktreeGitCommandResult {
|
|
411
|
+
return { ok: false, stdout: "", stderr: "", error };
|
|
412
|
+
}
|
|
413
|
+
|
|
235
414
|
async function createUnbornRepoSnapshot(options: {
|
|
236
415
|
readonly repoCwd: string;
|
|
237
416
|
readonly path: string;
|
|
238
417
|
readonly signal?: AbortSignal;
|
|
239
418
|
readonly timeoutMs?: number;
|
|
240
419
|
}): Promise<void> {
|
|
420
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS;
|
|
421
|
+
const rootProbe = await runGitCommand({
|
|
422
|
+
cwd: options.repoCwd,
|
|
423
|
+
args: ["rev-parse", "--show-toplevel"],
|
|
424
|
+
signal: options.signal,
|
|
425
|
+
timeoutMs,
|
|
426
|
+
});
|
|
427
|
+
if (!rootProbe.ok) {
|
|
428
|
+
throw new Error(rootProbe.error ?? (rootProbe.stderr.trim() || "failed to resolve unborn repository root"));
|
|
429
|
+
}
|
|
430
|
+
const sourceRoot = parseGitTopLevel(rootProbe.stdout, options.repoCwd);
|
|
431
|
+
if (!sourceRoot) throw new Error("unborn repository root probe returned an invalid path");
|
|
432
|
+
|
|
241
433
|
const targetRoot = resolve(options.path);
|
|
242
434
|
await rm(options.path, { recursive: true, force: true });
|
|
243
435
|
await mkdir(options.path, { recursive: true });
|
|
244
|
-
await cp(
|
|
436
|
+
await cp(sourceRoot, options.path, {
|
|
245
437
|
recursive: true,
|
|
246
438
|
force: true,
|
|
247
439
|
filter: (source) => {
|
|
248
440
|
const resolvedSource = resolve(source);
|
|
249
441
|
if (resolvedSource === targetRoot || resolvedSource.startsWith(`${targetRoot}${sep}`)) return false;
|
|
250
|
-
return !resolvedSource
|
|
442
|
+
return !isExcludedSnapshotPath(sourceRoot, resolvedSource);
|
|
251
443
|
},
|
|
252
444
|
});
|
|
253
|
-
await
|
|
254
|
-
await
|
|
255
|
-
await
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
445
|
+
await requireGitCommand({ cwd: options.path, args: ["init"], signal: options.signal, timeoutMs });
|
|
446
|
+
await requireGitCommand({ cwd: options.path, args: ["add", "-A"], signal: options.signal, timeoutMs });
|
|
447
|
+
const hooksPath = await mkdtemp(join(tmpdir(), "pi-workflow-empty-hooks-"));
|
|
448
|
+
try {
|
|
449
|
+
await requireGitCommand({
|
|
450
|
+
cwd: options.path,
|
|
451
|
+
args: syntheticCommitArgs(hooksPath, "pi workflow baseline"),
|
|
452
|
+
env: SYNTHETIC_COMMIT_ENV,
|
|
453
|
+
signal: options.signal,
|
|
454
|
+
timeoutMs,
|
|
455
|
+
});
|
|
456
|
+
} finally {
|
|
457
|
+
await rm(hooksPath, { recursive: true, force: true });
|
|
458
|
+
}
|
|
459
|
+
await requireGitCommand({ cwd: options.path, args: ["clean", "-ffdx"], signal: options.signal, timeoutMs });
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function syntheticCommitArgs(hooksPath: string, message: string): readonly string[] {
|
|
463
|
+
return [
|
|
464
|
+
"-c",
|
|
465
|
+
`core.hooksPath=${hooksPath}`,
|
|
466
|
+
"-c",
|
|
467
|
+
"commit.gpgSign=false",
|
|
468
|
+
"commit",
|
|
469
|
+
"--allow-empty",
|
|
470
|
+
"--no-verify",
|
|
471
|
+
"--no-gpg-sign",
|
|
472
|
+
"-m",
|
|
473
|
+
message,
|
|
474
|
+
];
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function requireGitCommand(options: WorktreeGitCommandOptions): Promise<void> {
|
|
478
|
+
const result = await runGitCommand(options);
|
|
479
|
+
if (!result.ok) throw new Error(result.error ?? (result.stderr.trim() || "git command failed"));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function parseGitTopLevel(output: string, cwd: string): string | undefined {
|
|
483
|
+
const withoutLf = output.endsWith("\n") ? output.slice(0, -1) : output;
|
|
484
|
+
const value = withoutLf.endsWith("\r") ? withoutLf.slice(0, -1) : withoutLf;
|
|
485
|
+
if (value.length === 0 || value.includes("\n") || value.includes("\0")) return undefined;
|
|
486
|
+
const root = resolve(cwd, value);
|
|
487
|
+
const cwdFromRoot = relative(root, resolve(cwd));
|
|
488
|
+
return isAbsolute(cwdFromRoot) || cwdFromRoot === ".." || cwdFromRoot.startsWith(`..${sep}`) ? undefined : root;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function isExcludedSnapshotPath(sourceRoot: string, source: string): boolean {
|
|
492
|
+
const path = relative(sourceRoot, source);
|
|
493
|
+
if (path.length === 0) return false;
|
|
494
|
+
const segments = path.split(sep);
|
|
495
|
+
if (segments.includes(".git")) return true;
|
|
496
|
+
return segments.some((segment, index) => segment === ".pi" && segments[index + 1] === ".workflow-runs");
|
|
261
497
|
}
|
|
262
498
|
|
|
263
499
|
function isInvalidHeadError(message: string): boolean {
|
|
264
500
|
return /invalid reference:\s*HEAD/i.test(message) || /ambiguous argument ['"]?HEAD/i.test(message) || /unknown revision or path.*HEAD/i.test(message);
|
|
265
501
|
}
|
|
266
502
|
|
|
503
|
+
function isGitObjectId(value: string): boolean {
|
|
504
|
+
return /^[0-9a-f]{40,64}$/i.test(value);
|
|
505
|
+
}
|
|
506
|
+
|
|
267
507
|
export const spawnGitRunner: WorktreeGitRunner = {
|
|
268
508
|
async runGit(options) {
|
|
269
509
|
return await runGitCommand(options);
|
|
@@ -271,7 +511,7 @@ export const spawnGitRunner: WorktreeGitRunner = {
|
|
|
271
511
|
};
|
|
272
512
|
|
|
273
513
|
async function runGitCommand(options: WorktreeGitCommandOptions): Promise<WorktreeGitCommandResult> {
|
|
274
|
-
const env = { ...process.env };
|
|
514
|
+
const env = { ...process.env, ...options.env };
|
|
275
515
|
delete env.GIT_EXTERNAL_DIFF;
|
|
276
516
|
delete env.GIT_DIFF_OPTS;
|
|
277
517
|
return await runBoundedProcess({
|
|
@@ -279,6 +519,7 @@ async function runGitCommand(options: WorktreeGitCommandOptions): Promise<Worktr
|
|
|
279
519
|
args: options.args,
|
|
280
520
|
cwd: options.cwd,
|
|
281
521
|
env,
|
|
522
|
+
stdin: options.stdin,
|
|
282
523
|
signal: options.signal,
|
|
283
524
|
timeoutMs: options.timeoutMs,
|
|
284
525
|
maxBufferBytes: options.maxBufferBytes,
|
|
@@ -289,6 +530,6 @@ async function runGitCommand(options: WorktreeGitCommandOptions): Promise<Worktr
|
|
|
289
530
|
});
|
|
290
531
|
}
|
|
291
532
|
|
|
292
|
-
function
|
|
293
|
-
return error
|
|
533
|
+
function worktreeRemovalError(outcome: WorktreeRemovalOutcome): string {
|
|
534
|
+
return outcome.error ?? (outcome.stderr.trim() || "unknown error");
|
|
294
535
|
}
|