ai-foreman 1.7.12 → 1.7.13
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 +15 -0
- package/dist/activity.d.ts +5 -1
- package/dist/activity.js +1 -1
- package/dist/adapters/claude.d.ts +7 -0
- package/dist/adapters/claude.js +133 -7
- package/dist/adapters/codex.d.ts +5 -0
- package/dist/adapters/codex.js +121 -11
- package/dist/adapters/recovering.d.ts +2 -0
- package/dist/adapters/recovering.js +11 -1
- package/dist/adapters/types.d.ts +47 -1
- package/dist/agentRun.d.ts +2 -0
- package/dist/agentRun.js +7 -1
- package/dist/branch/resume.js +11 -1
- package/dist/branch/runner.d.ts +11 -3
- package/dist/branch/runner.js +114 -60
- package/dist/buildRuns.d.ts +6 -2
- package/dist/buildRuns.js +76 -32
- package/dist/cli/doctor.d.ts +7 -0
- package/dist/cli/doctor.js +84 -1
- package/dist/cli/manager.d.ts +17 -0
- package/dist/cli/manager.js +135 -0
- package/dist/cli/recovery.d.ts +4 -0
- package/dist/cli/recovery.js +80 -0
- package/dist/cli/start.js +289 -53
- package/dist/cli/status.js +13 -2
- package/dist/cli/tickets.js +26 -8
- package/dist/config.d.ts +2 -0
- package/dist/config.js +23 -0
- package/dist/continuity.js +14 -9
- package/dist/diagnostics.d.ts +25 -0
- package/dist/diagnostics.js +428 -0
- package/dist/foreman.d.ts +16 -6
- package/dist/foreman.js +70 -82
- package/dist/handoffs.d.ts +5 -1
- package/dist/handoffs.js +4 -1
- package/dist/index.js +6 -0
- package/dist/log.d.ts +2 -2
- package/dist/log.js +4 -1
- package/dist/managerPacket.d.ts +25 -0
- package/dist/managerPacket.js +83 -0
- package/dist/markers.d.ts +1 -1
- package/dist/markers.js +4 -1
- package/dist/observability.d.ts +248 -0
- package/dist/observability.js +1018 -0
- package/dist/planChanges.d.ts +4 -0
- package/dist/planChanges.js +16 -0
- package/dist/projectDiagnostics.d.ts +27 -0
- package/dist/projectDiagnostics.js +407 -0
- package/dist/qaRecovery.d.ts +140 -0
- package/dist/qaRecovery.js +595 -0
- package/dist/qaReview.d.ts +128 -14
- package/dist/qaReview.js +974 -57
- package/dist/qaSnapshot.d.ts +31 -5
- package/dist/qaSnapshot.js +209 -134
- package/dist/recoveryPolicy.d.ts +37 -0
- package/dist/recoveryPolicy.js +251 -0
- package/dist/roles.js +1 -0
- package/dist/supervisor.d.ts +40 -0
- package/dist/supervisor.js +102 -0
- package/dist/ticketHistoryReader.d.ts +19 -0
- package/dist/ticketHistoryReader.js +58 -0
- package/dist/tickets/details.d.ts +8 -0
- package/dist/tickets/details.js +59 -11
- package/dist/tickets/output.d.ts +5 -0
- package/dist/tickets/output.js +73 -0
- package/dist/tickets/stateDb.d.ts +1 -0
- package/dist/tickets/stateDb.js +5 -0
- package/dist/workflowDb.d.ts +31 -2
- package/dist/workflowDb.js +192 -23
- package/dist/workflowReader.d.ts +42 -0
- package/dist/workflowReader.js +171 -0
- package/foreman.yaml +9 -0
- package/package.json +51 -3
package/dist/qaSnapshot.d.ts
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
export interface QaUntrackedCapture {
|
|
2
|
+
path: string;
|
|
3
|
+
kind: "file" | "symlink";
|
|
4
|
+
mode: number;
|
|
5
|
+
digest: string;
|
|
6
|
+
bytes: Buffer;
|
|
7
|
+
}
|
|
8
|
+
/** Immutable host-observable product state from which QA is constructed. */
|
|
9
|
+
export interface FrozenQaSourceState {
|
|
10
|
+
head: string;
|
|
11
|
+
status: Buffer;
|
|
12
|
+
combinedDiff: Buffer;
|
|
13
|
+
stagedDiff: Buffer;
|
|
14
|
+
unstagedDiff: Buffer;
|
|
15
|
+
changeSummary: string;
|
|
16
|
+
untracked: QaUntrackedCapture[];
|
|
17
|
+
digest: string;
|
|
18
|
+
capturedAt: string;
|
|
19
|
+
}
|
|
1
20
|
export interface QaChangeManifest {
|
|
2
21
|
diffDigest: string;
|
|
3
22
|
untracked: Array<{
|
|
@@ -10,6 +29,7 @@ export interface QaChangeManifest {
|
|
|
10
29
|
export interface DisposableQaSnapshot {
|
|
11
30
|
path: string;
|
|
12
31
|
manifest: QaChangeManifest;
|
|
32
|
+
frozenState: FrozenQaSourceState;
|
|
13
33
|
verify(): void;
|
|
14
34
|
qaChanges(): string[];
|
|
15
35
|
remove(): void;
|
|
@@ -17,17 +37,23 @@ export interface DisposableQaSnapshot {
|
|
|
17
37
|
export interface AsyncDisposableQaSnapshot {
|
|
18
38
|
path: string;
|
|
19
39
|
manifest: QaChangeManifest;
|
|
40
|
+
frozenState: FrozenQaSourceState;
|
|
20
41
|
verify(): Promise<void>;
|
|
21
42
|
qaChanges(): Promise<string[]>;
|
|
22
43
|
remove(): Promise<void>;
|
|
23
44
|
}
|
|
24
45
|
export type QaSnapshotProgress = (state: string, detail?: string) => void;
|
|
25
|
-
|
|
46
|
+
export declare class QaSourceInstabilityError extends Error {
|
|
47
|
+
readonly attempts: number;
|
|
48
|
+
constructor(attempts: number);
|
|
49
|
+
}
|
|
50
|
+
/** Capture twice and accept only a byte-identical source state. */
|
|
51
|
+
export declare function captureFrozenQaSource(worktree: string): FrozenQaSourceState;
|
|
52
|
+
export declare function captureFrozenQaSourceAsync(worktree: string, progress?: QaSnapshotProgress): Promise<FrozenQaSourceState>;
|
|
26
53
|
export declare function createDisposableQaSnapshot(builderWorktree: string): DisposableQaSnapshot;
|
|
27
|
-
/**
|
|
28
|
-
* Async form used by the live CLI. Git and filesystem work yields to the event
|
|
29
|
-
* loop so the activity reporter can keep animating during large snapshots.
|
|
30
|
-
*/
|
|
31
54
|
export declare function createDisposableQaSnapshotAsync(builderWorktree: string, progress?: QaSnapshotProgress): Promise<AsyncDisposableQaSnapshot>;
|
|
55
|
+
export declare function frozenQaStateManifest(state: FrozenQaSourceState): QaChangeManifest;
|
|
32
56
|
export declare function changeManifest(worktree: string): QaChangeManifest;
|
|
33
57
|
export declare function changeManifestAsync(worktree: string, progress?: QaSnapshotProgress, state?: string): Promise<QaChangeManifest>;
|
|
58
|
+
export declare function deterministicChangeSummaryAsync(worktree: string): Promise<string>;
|
|
59
|
+
export declare function calculateFrozenQaStateDigest(state: Omit<FrozenQaSourceState, "digest" | "capturedAt">): string;
|
package/dist/qaSnapshot.js
CHANGED
|
@@ -1,99 +1,80 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { lstat, mkdir, mkdtemp, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises";
|
|
4
5
|
import { tmpdir } from "node:os";
|
|
5
6
|
import { dirname, join, resolve } from "node:path";
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
const PRODUCT_PATHSPEC = ["--", ".", ":(exclude).foreman/**", ":(exclude).rafi/cache/**"];
|
|
8
|
+
const MAX_CAPTURE_ATTEMPTS = 3; // initial attempt plus two bounded retries
|
|
9
|
+
export class QaSourceInstabilityError extends Error {
|
|
10
|
+
attempts;
|
|
11
|
+
constructor(attempts) {
|
|
12
|
+
super(`Builder source changed during frozen QA capture after ${attempts} attempts`);
|
|
13
|
+
this.attempts = attempts;
|
|
14
|
+
this.name = "QaSourceInstabilityError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Capture twice and accept only a byte-identical source state. */
|
|
18
|
+
export function captureFrozenQaSource(worktree) {
|
|
19
|
+
const cwd = resolve(worktree);
|
|
20
|
+
for (let attempt = 1; attempt <= MAX_CAPTURE_ATTEMPTS; attempt++) {
|
|
21
|
+
const first = captureOnce(cwd);
|
|
22
|
+
const second = captureOnce(cwd);
|
|
23
|
+
if (first.digest === second.digest)
|
|
24
|
+
return { ...first, capturedAt: new Date().toISOString() };
|
|
25
|
+
}
|
|
26
|
+
throw new QaSourceInstabilityError(MAX_CAPTURE_ATTEMPTS);
|
|
27
|
+
}
|
|
28
|
+
export async function captureFrozenQaSourceAsync(worktree, progress = () => { }) {
|
|
29
|
+
const cwd = resolve(worktree);
|
|
30
|
+
for (let attempt = 1; attempt <= MAX_CAPTURE_ATTEMPTS; attempt++) {
|
|
31
|
+
progress("freezing Builder source state", `integrity pass ${attempt}/3`);
|
|
32
|
+
const first = await captureOnceAsync(cwd, progress);
|
|
33
|
+
const second = await captureOnceAsync(cwd, progress);
|
|
34
|
+
if (first.digest === second.digest)
|
|
35
|
+
return { ...first, capturedAt: new Date().toISOString() };
|
|
36
|
+
}
|
|
37
|
+
throw new QaSourceInstabilityError(MAX_CAPTURE_ATTEMPTS);
|
|
38
|
+
}
|
|
8
39
|
export function createDisposableQaSnapshot(builderWorktree) {
|
|
9
40
|
const source = resolve(builderWorktree);
|
|
10
|
-
const root =
|
|
41
|
+
const root = gitText(source, ["rev-parse", "--show-toplevel"]);
|
|
42
|
+
const frozenState = captureFrozenQaSource(source);
|
|
11
43
|
const tempRoot = mkdtempSync(join(tmpdir(), "rafi-qa-"));
|
|
12
44
|
const review = join(tempRoot, "review");
|
|
13
|
-
|
|
14
|
-
const added = spawnSync("git", ["-C", root, "worktree", "add", "--detach", review, head], { encoding: "utf8" });
|
|
15
|
-
if (added.status !== 0) {
|
|
16
|
-
rmSync(tempRoot, { recursive: true, force: true });
|
|
17
|
-
throw new Error(`cannot create disposable QA worktree: ${added.stderr.trim()}`);
|
|
18
|
-
}
|
|
45
|
+
addWorktreeSync(root, review, frozenState.head);
|
|
19
46
|
try {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const applied = spawnSync("git", ["-C", review, "apply", "--index", "--binary", "--whitespace=nowarn", "-"], { input: diff, encoding: "buffer", maxBuffer: 128 * 1024 * 1024 });
|
|
23
|
-
if (applied.status !== 0)
|
|
24
|
-
throw new Error(`cannot apply Builder diff to QA worktree: ${Buffer.from(applied.stderr).toString().trim()}`);
|
|
25
|
-
}
|
|
26
|
-
for (const path of untrackedPaths(source))
|
|
27
|
-
copyUntracked(source, review, path);
|
|
28
|
-
const manifest = changeManifest(source);
|
|
47
|
+
applyFrozenSync(review, frozenState);
|
|
48
|
+
const manifest = manifestFromFrozen(frozenState);
|
|
29
49
|
const snapshot = {
|
|
30
|
-
path: review, manifest,
|
|
31
|
-
verify: () =>
|
|
32
|
-
const actual = changeManifest(review);
|
|
33
|
-
if (JSON.stringify(actual) !== JSON.stringify(manifest))
|
|
34
|
-
throw new Error(`disposable QA snapshot does not match Builder change manifest: expected ${JSON.stringify(manifest)}, got ${JSON.stringify(actual)}`);
|
|
35
|
-
},
|
|
50
|
+
path: review, manifest, frozenState,
|
|
51
|
+
verify: () => assertSnapshotMatches(review, manifest),
|
|
36
52
|
qaChanges: () => manifestDifference(manifest, changeManifest(review)),
|
|
37
|
-
remove: () =>
|
|
38
|
-
spawnSync("git", ["-C", root, "worktree", "remove", "--force", review], { encoding: "utf8" });
|
|
39
|
-
rmSync(tempRoot, { recursive: true, force: true });
|
|
40
|
-
},
|
|
53
|
+
remove: () => removeWorktreeSync(root, review, tempRoot),
|
|
41
54
|
};
|
|
42
55
|
snapshot.verify();
|
|
43
56
|
return snapshot;
|
|
44
57
|
}
|
|
45
58
|
catch (error) {
|
|
46
|
-
|
|
47
|
-
rmSync(tempRoot, { recursive: true, force: true });
|
|
59
|
+
removeWorktreeSync(root, review, tempRoot);
|
|
48
60
|
throw error;
|
|
49
61
|
}
|
|
50
62
|
}
|
|
51
|
-
/**
|
|
52
|
-
* Async form used by the live CLI. Git and filesystem work yields to the event
|
|
53
|
-
* loop so the activity reporter can keep animating during large snapshots.
|
|
54
|
-
*/
|
|
55
63
|
export async function createDisposableQaSnapshotAsync(builderWorktree, progress = () => { }) {
|
|
56
64
|
const source = resolve(builderWorktree);
|
|
57
65
|
progress("preparing disposable QA snapshot", "locating Builder worktree");
|
|
58
66
|
const root = (await runGit(source, ["rev-parse", "--show-toplevel"])).toString().trim();
|
|
67
|
+
const frozenState = await captureFrozenQaSourceAsync(source, progress);
|
|
59
68
|
const tempRoot = await mkdtemp(join(tmpdir(), "rafi-qa-"));
|
|
60
69
|
const review = join(tempRoot, "review");
|
|
61
|
-
const head = (await runGit(source, ["rev-parse", "HEAD"])).toString().trim();
|
|
62
|
-
progress("preparing disposable QA snapshot", "creating detached review worktree");
|
|
63
|
-
try {
|
|
64
|
-
await runGit(root, ["worktree", "add", "--detach", review, head]);
|
|
65
|
-
}
|
|
66
|
-
catch (error) {
|
|
67
|
-
await rm(tempRoot, { recursive: true, force: true });
|
|
68
|
-
throw new Error(`cannot create disposable QA worktree: ${errorMessage(error)}`);
|
|
69
|
-
}
|
|
70
70
|
try {
|
|
71
|
-
progress("preparing disposable QA snapshot", "
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
await runGit(review, ["apply", "--index", "--binary", "--whitespace=nowarn", "-"], diff);
|
|
76
|
-
}
|
|
77
|
-
catch (error) {
|
|
78
|
-
throw new Error(`cannot apply Builder diff to QA worktree: ${errorMessage(error)}`);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
const paths = await untrackedPathsAsync(source);
|
|
82
|
-
for (let index = 0; index < paths.length; index++) {
|
|
83
|
-
progress("preparing disposable QA snapshot", `copying untracked files ${index + 1}/${paths.length}`);
|
|
84
|
-
await copyUntrackedAsync(source, review, paths[index]);
|
|
85
|
-
}
|
|
86
|
-
progress("preparing disposable QA snapshot", "recording Builder change manifest");
|
|
87
|
-
const manifest = await changeManifestAsync(source, progress, "preparing disposable QA snapshot");
|
|
71
|
+
progress("preparing disposable QA snapshot", "creating detached review worktree");
|
|
72
|
+
await runGit(root, ["worktree", "add", "--detach", review, frozenState.head]);
|
|
73
|
+
await applyFrozenAsync(review, frozenState, progress);
|
|
74
|
+
const manifest = manifestFromFrozen(frozenState);
|
|
88
75
|
const snapshot = {
|
|
89
|
-
path: review,
|
|
90
|
-
manifest,
|
|
91
|
-
verify: async () => {
|
|
92
|
-
const actual = await changeManifestAsync(review, progress, "preparing disposable QA snapshot");
|
|
93
|
-
if (JSON.stringify(actual) !== JSON.stringify(manifest)) {
|
|
94
|
-
throw new Error(`disposable QA snapshot does not match Builder change manifest: expected ${JSON.stringify(manifest)}, got ${JSON.stringify(actual)}`);
|
|
95
|
-
}
|
|
96
|
-
},
|
|
76
|
+
path: review, manifest, frozenState,
|
|
77
|
+
verify: async () => assertSnapshotMatchesAsync(review, manifest, progress),
|
|
97
78
|
qaChanges: async () => manifestDifference(manifest, await changeManifestAsync(review, progress)),
|
|
98
79
|
remove: async () => {
|
|
99
80
|
progress("cleaning up disposable QA snapshot", "removing detached review worktree");
|
|
@@ -101,7 +82,7 @@ export async function createDisposableQaSnapshotAsync(builderWorktree, progress
|
|
|
101
82
|
await rm(tempRoot, { recursive: true, force: true });
|
|
102
83
|
},
|
|
103
84
|
};
|
|
104
|
-
progress("preparing disposable QA snapshot", "verifying review copy");
|
|
85
|
+
progress("preparing disposable QA snapshot", "verifying frozen review copy");
|
|
105
86
|
await snapshot.verify();
|
|
106
87
|
return snapshot;
|
|
107
88
|
}
|
|
@@ -111,85 +92,156 @@ export async function createDisposableQaSnapshotAsync(builderWorktree, progress
|
|
|
111
92
|
throw error;
|
|
112
93
|
}
|
|
113
94
|
}
|
|
95
|
+
export function frozenQaStateManifest(state) { return manifestFromFrozen(state); }
|
|
114
96
|
export function changeManifest(worktree) {
|
|
115
|
-
const diff =
|
|
116
|
-
|
|
117
|
-
const absolute = join(worktree, path);
|
|
118
|
-
const stat = lstatSync(absolute);
|
|
119
|
-
const kind = stat.isSymbolicLink() ? "symlink" : "file";
|
|
120
|
-
const bytes = kind === "symlink" ? Buffer.from(readlinkSync(absolute)) : readFileSync(absolute);
|
|
121
|
-
return { path, kind, mode: stat.mode & 0o7777, digest: createHash("sha256").update(bytes).digest("hex") };
|
|
122
|
-
});
|
|
123
|
-
return { diffDigest: createHash("sha256").update(diff).digest("hex"), untracked };
|
|
97
|
+
const diff = gitBuffer(resolve(worktree), ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]);
|
|
98
|
+
return { diffDigest: hash(diff), untracked: untrackedPaths(worktree).map((path) => describeUntrackedSync(worktree, path)) };
|
|
124
99
|
}
|
|
125
100
|
export async function changeManifestAsync(worktree, progress = () => { }, state = "checking QA file changes") {
|
|
126
|
-
const diff = await runGit(worktree, ["diff", "--binary", "HEAD"]);
|
|
101
|
+
const diff = await runGit(worktree, ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]);
|
|
127
102
|
const paths = await untrackedPathsAsync(worktree);
|
|
128
103
|
const untracked = [];
|
|
129
104
|
for (let index = 0; index < paths.length; index++) {
|
|
130
|
-
const path = paths[index];
|
|
131
105
|
progress(state, `hashing untracked files ${index + 1}/${paths.length}`);
|
|
132
|
-
const absolute = join(worktree,
|
|
106
|
+
const absolute = join(worktree, paths[index]);
|
|
133
107
|
const stat = await lstat(absolute);
|
|
134
108
|
const kind = stat.isSymbolicLink() ? "symlink" : "file";
|
|
135
109
|
const bytes = kind === "symlink" ? Buffer.from(await readlink(absolute)) : await readFile(absolute);
|
|
136
|
-
untracked.push({ path, kind, mode: stat.mode & 0o7777, digest:
|
|
110
|
+
untracked.push({ path: paths[index], kind, mode: stat.mode & 0o7777, digest: hash(bytes) });
|
|
137
111
|
}
|
|
138
|
-
return { diffDigest:
|
|
139
|
-
}
|
|
140
|
-
function
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
return
|
|
147
|
-
}
|
|
148
|
-
function
|
|
149
|
-
const
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
112
|
+
return { diffDigest: hash(diff), untracked };
|
|
113
|
+
}
|
|
114
|
+
export async function deterministicChangeSummaryAsync(worktree) {
|
|
115
|
+
const [staged, unstaged, untracked] = await Promise.all([
|
|
116
|
+
runGit(worktree, ["diff", "--cached", "--name-status", "-z", ...PRODUCT_PATHSPEC]),
|
|
117
|
+
runGit(worktree, ["diff", "--name-status", "-z", ...PRODUCT_PATHSPEC]),
|
|
118
|
+
runGit(worktree, ["ls-files", "--others", "--exclude-standard", "-z"]),
|
|
119
|
+
]);
|
|
120
|
+
return renderSummary(staged, unstaged, untracked);
|
|
121
|
+
}
|
|
122
|
+
function captureOnce(cwd) {
|
|
123
|
+
const head = gitText(cwd, ["rev-parse", "HEAD"]);
|
|
124
|
+
const status = gitBuffer(cwd, ["status", "--porcelain=v2", "-z", "--untracked-files=all", ...PRODUCT_PATHSPEC]);
|
|
125
|
+
const combinedDiff = gitBuffer(cwd, ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]);
|
|
126
|
+
const stagedDiff = gitBuffer(cwd, ["diff", "--cached", "--binary", ...PRODUCT_PATHSPEC]);
|
|
127
|
+
const unstagedDiff = gitBuffer(cwd, ["diff", "--binary", ...PRODUCT_PATHSPEC]);
|
|
128
|
+
const stagedNames = gitBuffer(cwd, ["diff", "--cached", "--name-status", "-z", ...PRODUCT_PATHSPEC]);
|
|
129
|
+
const unstagedNames = gitBuffer(cwd, ["diff", "--name-status", "-z", ...PRODUCT_PATHSPEC]);
|
|
130
|
+
const untrackedNames = gitBuffer(cwd, ["ls-files", "--others", "--exclude-standard", "-z"]);
|
|
131
|
+
const untracked = untrackedPathsFrom(untrackedNames).map((path) => captureUntrackedSync(cwd, path));
|
|
132
|
+
const base = { head, status, combinedDiff, stagedDiff, unstagedDiff, changeSummary: renderSummary(stagedNames, unstagedNames, untrackedNames), untracked };
|
|
133
|
+
return { ...base, digest: calculateFrozenQaStateDigest(base), capturedAt: "" };
|
|
134
|
+
}
|
|
135
|
+
async function captureOnceAsync(cwd, progress) {
|
|
136
|
+
const [headBytes, status, combinedDiff, stagedDiff, unstagedDiff, stagedNames, unstagedNames, untrackedNames] = await Promise.all([
|
|
137
|
+
runGit(cwd, ["rev-parse", "HEAD"]), runGit(cwd, ["status", "--porcelain=v2", "-z", "--untracked-files=all", ...PRODUCT_PATHSPEC]),
|
|
138
|
+
runGit(cwd, ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]), runGit(cwd, ["diff", "--cached", "--binary", ...PRODUCT_PATHSPEC]),
|
|
139
|
+
runGit(cwd, ["diff", "--binary", ...PRODUCT_PATHSPEC]), runGit(cwd, ["diff", "--cached", "--name-status", "-z", ...PRODUCT_PATHSPEC]),
|
|
140
|
+
runGit(cwd, ["diff", "--name-status", "-z", ...PRODUCT_PATHSPEC]), runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z"]),
|
|
141
|
+
]);
|
|
142
|
+
const paths = untrackedPathsFrom(untrackedNames);
|
|
143
|
+
const untracked = [];
|
|
144
|
+
for (let index = 0; index < paths.length; index++) {
|
|
145
|
+
progress("freezing Builder source state", `reading untracked file ${index + 1}/${paths.length}`);
|
|
146
|
+
untracked.push(await captureUntrackedAsync(cwd, paths[index]));
|
|
157
147
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
const stat = await lstat(from);
|
|
166
|
-
if (stat.isSymbolicLink()) {
|
|
167
|
-
await rm(to, { force: true });
|
|
168
|
-
await symlink(await readlink(from), to);
|
|
148
|
+
const base = { head: headBytes.toString().trim(), status, combinedDiff, stagedDiff, unstagedDiff, changeSummary: renderSummary(stagedNames, unstagedNames, untrackedNames), untracked };
|
|
149
|
+
return { ...base, digest: calculateFrozenQaStateDigest(base), capturedAt: "" };
|
|
150
|
+
}
|
|
151
|
+
export function calculateFrozenQaStateDigest(state) {
|
|
152
|
+
const h = createHash("sha256");
|
|
153
|
+
for (const [label, value] of [["head", Buffer.from(state.head)], ["status", state.status], ["combined", state.combinedDiff], ["staged", state.stagedDiff], ["unstaged", state.unstagedDiff], ["summary", Buffer.from(state.changeSummary)]]) {
|
|
154
|
+
h.update(label).update("\0").update(value).update("\0");
|
|
169
155
|
}
|
|
170
|
-
|
|
171
|
-
|
|
156
|
+
for (const item of state.untracked)
|
|
157
|
+
h.update(item.path).update("\0").update(item.kind).update("\0").update(String(item.mode)).update("\0").update(item.bytes).update("\0");
|
|
158
|
+
return h.digest("hex");
|
|
159
|
+
}
|
|
160
|
+
function manifestFromFrozen(state) {
|
|
161
|
+
return { diffDigest: hash(state.combinedDiff), untracked: state.untracked.map(({ path, kind, mode, digest }) => ({ path, kind, mode, digest })) };
|
|
162
|
+
}
|
|
163
|
+
function applyFrozenSync(review, state) {
|
|
164
|
+
applyPatchSync(review, state.stagedDiff, true);
|
|
165
|
+
applyPatchSync(review, state.unstagedDiff, false);
|
|
166
|
+
for (const item of state.untracked)
|
|
167
|
+
writeCapturedUntrackedSync(review, item);
|
|
168
|
+
}
|
|
169
|
+
async function applyFrozenAsync(review, state, progress) {
|
|
170
|
+
progress("preparing disposable QA snapshot", "applying captured staged changes");
|
|
171
|
+
await applyPatchAsync(review, state.stagedDiff, true);
|
|
172
|
+
progress("preparing disposable QA snapshot", "applying captured unstaged changes");
|
|
173
|
+
await applyPatchAsync(review, state.unstagedDiff, false);
|
|
174
|
+
for (let index = 0; index < state.untracked.length; index++) {
|
|
175
|
+
progress("preparing disposable QA snapshot", `materializing captured untracked files ${index + 1}/${state.untracked.length}`);
|
|
176
|
+
await writeCapturedUntrackedAsync(review, state.untracked[index]);
|
|
172
177
|
}
|
|
173
178
|
}
|
|
174
|
-
function
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
179
|
+
function applyPatchSync(review, patch, index) {
|
|
180
|
+
if (!patch.length)
|
|
181
|
+
return;
|
|
182
|
+
const applied = spawnSync("git", ["-C", review, "apply", ...(index ? ["--index"] : []), "--binary", "--whitespace=nowarn", "-"], { input: patch, encoding: "buffer", maxBuffer: 256 * 1024 * 1024 });
|
|
183
|
+
if (applied.status !== 0)
|
|
184
|
+
throw new Error(`cannot apply frozen Builder diff to QA worktree: ${Buffer.from(applied.stderr).toString().trim()}`);
|
|
185
|
+
}
|
|
186
|
+
async function applyPatchAsync(review, patch, index) {
|
|
187
|
+
if (patch.length)
|
|
188
|
+
await runGit(review, ["apply", ...(index ? ["--index"] : []), "--binary", "--whitespace=nowarn", "-"], patch);
|
|
189
|
+
}
|
|
190
|
+
function captureUntrackedSync(cwd, path) {
|
|
191
|
+
const absolute = join(cwd, path);
|
|
192
|
+
const stat = lstatSync(absolute);
|
|
193
|
+
if (!stat.isFile() && !stat.isSymbolicLink())
|
|
194
|
+
throw new Error(`unsupported untracked path type during QA capture: ${path}`);
|
|
195
|
+
const kind = stat.isSymbolicLink() ? "symlink" : "file";
|
|
196
|
+
const bytes = kind === "symlink" ? Buffer.from(readlinkSync(absolute)) : readFileSync(absolute);
|
|
197
|
+
return { path, kind, mode: stat.mode & 0o7777, digest: hash(bytes), bytes };
|
|
198
|
+
}
|
|
199
|
+
async function captureUntrackedAsync(cwd, path) {
|
|
200
|
+
const absolute = join(cwd, path);
|
|
201
|
+
const stat = await lstat(absolute);
|
|
202
|
+
if (!stat.isFile() && !stat.isSymbolicLink())
|
|
203
|
+
throw new Error(`unsupported untracked path type during QA capture: ${path}`);
|
|
204
|
+
const kind = stat.isSymbolicLink() ? "symlink" : "file";
|
|
205
|
+
const bytes = kind === "symlink" ? Buffer.from(await readlink(absolute)) : await readFile(absolute);
|
|
206
|
+
return { path, kind, mode: stat.mode & 0o7777, digest: hash(bytes), bytes };
|
|
207
|
+
}
|
|
208
|
+
function writeCapturedUntrackedSync(review, item) {
|
|
209
|
+
const target = join(review, item.path);
|
|
210
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
211
|
+
if (item.kind === "symlink")
|
|
212
|
+
symlinkSync(item.bytes.toString(), target);
|
|
213
|
+
else
|
|
214
|
+
writeFileSync(target, item.bytes, { mode: item.mode });
|
|
215
|
+
}
|
|
216
|
+
async function writeCapturedUntrackedAsync(review, item) {
|
|
217
|
+
const target = join(review, item.path);
|
|
218
|
+
await mkdir(dirname(target), { recursive: true });
|
|
219
|
+
if (item.kind === "symlink")
|
|
220
|
+
await symlink(item.bytes.toString(), target);
|
|
221
|
+
else
|
|
222
|
+
await writeFile(target, item.bytes, { mode: item.mode });
|
|
223
|
+
}
|
|
224
|
+
function describeUntrackedSync(worktree, path) {
|
|
225
|
+
const item = captureUntrackedSync(worktree, path);
|
|
226
|
+
return { path: item.path, kind: item.kind, mode: item.mode, digest: item.digest };
|
|
227
|
+
}
|
|
228
|
+
function untrackedPaths(worktree) { return untrackedPathsFrom(gitBuffer(resolve(worktree), ["ls-files", "--others", "--exclude-standard", "-z"])); }
|
|
229
|
+
async function untrackedPathsAsync(worktree) { return untrackedPathsFrom(await runGit(worktree, ["ls-files", "--others", "--exclude-standard", "-z"])); }
|
|
230
|
+
function untrackedPathsFrom(raw) { return raw.toString().split("\0").filter((path) => path && isQaProductPath(path)).sort(); }
|
|
231
|
+
function isQaProductPath(path) { return path !== ".foreman" && !path.startsWith(".foreman/") && path !== ".rafi/cache" && !path.startsWith(".rafi/cache/"); }
|
|
232
|
+
function renderSummary(staged, unstaged, untracked) {
|
|
233
|
+
const normalize = (value) => value.toString().split("\0").filter(Boolean).filter(isQaProductPath).sort().join("\n") || "(none)";
|
|
234
|
+
return `tracked/staged:\n${normalize(staged)}\ntracked/unstaged:\n${normalize(unstaged)}\nuntracked:\n${normalize(untracked)}`;
|
|
190
235
|
}
|
|
191
|
-
function
|
|
192
|
-
|
|
236
|
+
function assertSnapshotMatches(review, expected) {
|
|
237
|
+
const actual = changeManifest(review);
|
|
238
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected))
|
|
239
|
+
throw new Error(`disposable QA snapshot does not match frozen source manifest: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
240
|
+
}
|
|
241
|
+
async function assertSnapshotMatchesAsync(review, expected, progress) {
|
|
242
|
+
const actual = await changeManifestAsync(review, progress, "verifying frozen QA snapshot");
|
|
243
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected))
|
|
244
|
+
throw new Error(`disposable QA snapshot does not match frozen source manifest: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
193
245
|
}
|
|
194
246
|
function manifestDifference(before, after) {
|
|
195
247
|
const changes = [];
|
|
@@ -202,3 +254,26 @@ function manifestDifference(before, after) {
|
|
|
202
254
|
changes.push(path);
|
|
203
255
|
return changes;
|
|
204
256
|
}
|
|
257
|
+
function addWorktreeSync(root, review, head) {
|
|
258
|
+
const added = spawnSync("git", ["-C", root, "worktree", "add", "--detach", review, head], { encoding: "utf8" });
|
|
259
|
+
if (added.status !== 0) {
|
|
260
|
+
rmSync(dirname(review), { recursive: true, force: true });
|
|
261
|
+
throw new Error(`cannot create disposable QA worktree: ${added.stderr.trim()}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function removeWorktreeSync(root, review, tempRoot) { spawnSync("git", ["-C", root, "worktree", "remove", "--force", review], { encoding: "utf8" }); rmSync(tempRoot, { recursive: true, force: true }); }
|
|
265
|
+
function gitText(cwd, args) { return gitBuffer(cwd, args).toString().trim(); }
|
|
266
|
+
function gitBuffer(cwd, args) { return execFileSync("git", ["-C", cwd, ...args], { encoding: "buffer", maxBuffer: 256 * 1024 * 1024 }); }
|
|
267
|
+
function runGit(cwd, args, input) {
|
|
268
|
+
return new Promise((resolvePromise, reject) => {
|
|
269
|
+
const child = spawn("git", ["-C", cwd, ...args], { stdio: ["pipe", "pipe", "pipe"] });
|
|
270
|
+
const stdout = [];
|
|
271
|
+
const stderr = [];
|
|
272
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
273
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
274
|
+
child.on("error", reject);
|
|
275
|
+
child.on("close", (code) => code === 0 ? resolvePromise(Buffer.concat(stdout)) : reject(new Error(Buffer.concat(stderr).toString().trim() || `git exited with status ${code ?? "unknown"}`)));
|
|
276
|
+
child.stdin.end(input);
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
function hash(bytes) { return createHash("sha256").update(bytes).digest("hex"); }
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { AutonomyConfig, AutonomyProfile, InterruptionDomain, PendingHumanDecision, RecoveryDisposition, ResolvedAutonomyPolicy, StructuredInterruption, WorkflowIssueCode } from "rafi-spec";
|
|
2
|
+
import { WorkflowDb } from "./workflowDb.js";
|
|
3
|
+
export declare const DEFAULT_AUTONOMY_CONFIG: AutonomyConfig;
|
|
4
|
+
export declare function loadProjectAutonomyConfig(projectDir: string): AutonomyConfig | undefined;
|
|
5
|
+
/** QA precedence: CLI -> frozen run -> canonical project config -> legacy foreman.yaml. */
|
|
6
|
+
export declare function resolveQaEnablement(input: {
|
|
7
|
+
cli?: boolean;
|
|
8
|
+
frozen?: boolean;
|
|
9
|
+
projectDir: string;
|
|
10
|
+
legacyForeman?: boolean;
|
|
11
|
+
}): {
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
source: "cli" | "frozen" | "project" | "legacy-foreman" | "default";
|
|
14
|
+
deprecationWarning?: string;
|
|
15
|
+
};
|
|
16
|
+
export declare function validateAutonomyConfig(value: unknown): AutonomyConfig;
|
|
17
|
+
export declare function resolveAutonomyPolicy(config: Partial<AutonomyConfig> | undefined, profileOverride?: AutonomyProfile, now?: Date): ResolvedAutonomyPolicy;
|
|
18
|
+
export declare function mapWorkflowIssueDomain(code: WorkflowIssueCode): InterruptionDomain;
|
|
19
|
+
export declare function evaluateRecovery(policy: ResolvedAutonomyPolicy, interruption: StructuredInterruption, attemptsUsed: number): RecoveryDisposition;
|
|
20
|
+
export declare function qaDecisionChoices(): PendingHumanDecision["choices"];
|
|
21
|
+
export declare class RecoveryDispatcher {
|
|
22
|
+
private readonly db;
|
|
23
|
+
private readonly policy;
|
|
24
|
+
constructor(db: WorkflowDb, policy: ResolvedAutonomyPolicy);
|
|
25
|
+
dispatch(input: {
|
|
26
|
+
interruption: StructuredInterruption;
|
|
27
|
+
reconcile?: () => Promise<"confirmed" | "absent" | "uncertain">;
|
|
28
|
+
execute: (disposition: RecoveryDisposition) => Promise<{
|
|
29
|
+
ok: boolean;
|
|
30
|
+
detail?: string;
|
|
31
|
+
}>;
|
|
32
|
+
}): Promise<{
|
|
33
|
+
disposition: RecoveryDisposition;
|
|
34
|
+
decision?: PendingHumanDecision;
|
|
35
|
+
attemptId?: string;
|
|
36
|
+
}>;
|
|
37
|
+
}
|