ai-foreman 1.7.29 → 2.0.1
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 +1 -1
- package/dist/adapters/claude.d.ts +23 -12
- package/dist/adapters/claude.js +231 -26
- package/dist/adapters/codex.d.ts +19 -7
- package/dist/adapters/codex.js +202 -19
- package/dist/adapters/recovering.d.ts +2 -0
- package/dist/adapters/recovering.js +5 -1
- package/dist/adapters/structured.d.ts +71 -0
- package/dist/adapters/structured.js +213 -0
- package/dist/adapters/types.d.ts +85 -9
- package/dist/agentRun.d.ts +2 -0
- package/dist/agentRun.js +2 -1
- package/dist/boundedTurnEvidence.d.ts +18 -0
- package/dist/boundedTurnEvidence.js +76 -0
- package/dist/branch/currentGuard.d.ts +2 -0
- package/dist/branch/currentGuard.js +2 -0
- package/dist/branch/git.js +3 -8
- package/dist/branch/planner.js +1 -4
- package/dist/branch/runner.d.ts +2 -1
- package/dist/branch/runner.js +10 -7
- package/dist/buildAdmission.js +4 -0
- package/dist/buildApproval.d.ts +15 -0
- package/dist/buildApproval.js +28 -9
- package/dist/buildAssignment.d.ts +1 -0
- package/dist/buildAssignment.js +82 -23
- package/dist/buildInterventions.d.ts +2 -2
- package/dist/buildInterventions.js +11 -4
- package/dist/buildLaunchGate.js +3 -2
- package/dist/buildRuns.d.ts +1 -0
- package/dist/buildRuns.js +6 -1
- package/dist/builderGuidanceFollowup.js +17 -6
- package/dist/cli/doctor.js +3 -1
- package/dist/cli/manager.js +22 -20
- package/dist/cli/start.js +85 -39
- package/dist/cli/tickets.d.ts +0 -8
- package/dist/cli/tickets.js +50 -60
- package/dist/continuity.d.ts +10 -16
- package/dist/continuity.js +223 -208
- package/dist/continuityOrigin.d.ts +13 -0
- package/dist/continuityOrigin.js +91 -0
- package/dist/dispatchNotSent.d.ts +4 -0
- package/dist/dispatchNotSent.js +10 -0
- package/dist/foreman.d.ts +15 -16
- package/dist/foreman.js +70 -278
- package/dist/gitExclude.d.ts +16 -0
- package/dist/gitExclude.js +46 -0
- package/dist/graph/boundary.d.ts +1 -1
- package/dist/graph/boundary.js +1 -1
- package/dist/graph/host.js +8 -19
- package/dist/graph/manager.d.ts +1 -1
- package/dist/graph/manager.js +1 -1
- package/dist/graph/preparation.d.ts +38 -0
- package/dist/graph/preparation.js +7 -6
- package/dist/graph/semantic.d.ts +1 -0
- package/dist/graph/semantic.js +4 -1
- package/dist/graph/session.js +7 -2
- package/dist/graph/turn.d.ts +3 -4
- package/dist/graph/turn.js +29 -41
- package/dist/handoffProtocol.js +2 -2
- package/dist/handoffs.d.ts +5 -3
- package/dist/handoffs.js +92 -88
- package/dist/hostGraphApplicability.d.ts +2 -1
- package/dist/hostGraphApplicability.js +17 -19
- package/dist/hostQaAugmentation.js +21 -4
- package/dist/hostQaComparison.d.ts +21 -2
- package/dist/hostQaComparison.js +43 -29
- package/dist/hostSourcePreservation.d.ts +38 -0
- package/dist/hostSourcePreservation.js +72 -0
- package/dist/humanDecision.d.ts +7 -0
- package/dist/humanDecision.js +11 -0
- package/dist/interviews.d.ts +7 -0
- package/dist/interviews.js +18 -1
- package/dist/legacyCommunicationDisplay.d.ts +2 -0
- package/dist/legacyCommunicationDisplay.js +2 -0
- package/dist/managerPacket.js +3 -3
- package/dist/markers.d.ts +4 -3
- package/dist/markers.js +4 -20
- package/dist/observability.js +3 -8
- package/dist/probeIpc.d.ts +14 -0
- package/dist/probeIpc.js +29 -0
- package/dist/qaBuildGate.d.ts +21 -0
- package/dist/qaBuildGate.js +60 -13
- package/dist/qaBuilderCoverage.js +22 -13
- package/dist/qaBuilderEvidence.d.ts +16 -1
- package/dist/qaBuilderEvidence.js +48 -8
- package/dist/qaContractCoverage.d.ts +2 -2
- package/dist/qaContractCoverage.js +4 -9
- package/dist/qaContractDelivery.js +34 -20
- package/dist/qaCoverageAssembly.d.ts +22 -0
- package/dist/qaCoverageAssembly.js +16 -0
- package/dist/qaDeliveryJournal.d.ts +2 -0
- package/dist/qaDeliveryJournal.js +19 -0
- package/dist/qaFailureDelivery.js +97 -85
- package/dist/qaHostTransformation.d.ts +26 -0
- package/dist/qaHostTransformation.js +100 -0
- package/dist/qaPreparation.d.ts +8 -20
- package/dist/qaPreparation.js +153 -254
- package/dist/qaPreparationInputs.js +19 -0
- package/dist/qaPreparationStore.d.ts +11 -0
- package/dist/qaPreparationStore.js +44 -4
- package/dist/qaPrerequisites.d.ts +2 -1
- package/dist/qaPrerequisites.js +21 -1
- package/dist/qaProtocolV2.d.ts +15 -0
- package/dist/qaRecovery.d.ts +0 -3
- package/dist/qaRecovery.js +21 -27
- package/dist/qaReview.js +241 -406
- package/dist/qaReviewEvidence.d.ts +7 -3
- package/dist/qaReviewEvidence.js +15 -4
- package/dist/qaRuntime.js +1 -1
- package/dist/qaSnapshot.js +76 -21
- package/dist/qaStructuredResponse.d.ts +18 -0
- package/dist/qaStructuredResponse.js +81 -0
- package/dist/qaTimeline.js +2 -2
- package/dist/qaVerificationContract.d.ts +1 -2
- package/dist/qaVerificationContract.js +24 -35
- package/dist/readinessGate.js +5 -4
- package/dist/recoveryOwners.d.ts +32 -0
- package/dist/recoveryOwners.js +87 -0
- package/dist/runtimePreflight.d.ts +11 -0
- package/dist/runtimePreflight.js +77 -0
- package/dist/runtimeReadiness.js +23 -15
- package/dist/sources/sourceRegistry.d.ts +0 -3
- package/dist/sources/sourceRegistry.js +0 -42
- package/dist/statusReporter.d.ts +2 -0
- package/dist/statusReporter.js +2 -0
- package/dist/structuredCutover.d.ts +31 -0
- package/dist/structuredCutover.js +510 -0
- package/dist/structuredCutoverFiles.d.ts +10 -0
- package/dist/structuredCutoverFiles.js +181 -0
- package/dist/structuredDispatchAuthority.d.ts +22 -0
- package/dist/structuredDispatchAuthority.js +110 -0
- package/dist/structuredDispatchRecord.d.ts +29 -0
- package/dist/structuredDispatchRecord.js +36 -0
- package/dist/structuredLiveBudget.d.ts +2 -0
- package/dist/structuredLiveBudget.js +23 -0
- package/dist/structuredOperation.d.ts +18 -0
- package/dist/structuredOperation.js +279 -0
- package/dist/supervisedStart.js +35 -21
- package/dist/ticketPlanning.d.ts +1 -376
- package/dist/ticketPlanning.js +2 -49
- package/dist/ticketPopulation.d.ts +0 -3
- package/dist/ticketPopulation.js +4 -21
- package/dist/ticketPopulationPageAuthority.d.ts +13 -0
- package/dist/ticketPopulationPageAuthority.js +30 -0
- package/dist/ticketPopulationPaging.d.ts +27 -0
- package/dist/ticketPopulationPaging.js +127 -0
- package/dist/tickets/renderMarkdown.d.ts +14 -0
- package/dist/tickets/renderMarkdown.js +39 -5
- package/dist/tickets/ticketLoader.js +10 -0
- package/dist/tickets/ticketSchema.d.ts +107 -0
- package/dist/tickets/ticketSchema.js +4 -1
- package/dist/workflowDb.d.ts +116 -3
- package/dist/workflowDb.js +852 -61
- package/dist/workflowReader.d.ts +1 -0
- package/dist/workflowReader.js +9 -0
- package/package.json +23 -7
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { RunSpanV1 } from "rafi-spec";
|
|
2
|
-
/**
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Bounded, adapter-redacted output excerpts are host-retained evidence and are
|
|
4
|
+
* delivered to independent QA by default. RAFI_QA_COMMAND_OUTPUT_EXCERPTS=0
|
|
5
|
+
* (or off/false) disables them; a ticket ID limits them to that ticket.
|
|
6
|
+
*/
|
|
7
|
+
export declare function qaCommandOutputExcerptsAllowed(workId: string, configured?: string | undefined): boolean;
|
|
8
|
+
/** Command text is never forwarded; excerpts are bounded and may be truncated. */
|
|
5
9
|
export declare function selectQaCommandObservations(spans: RunSpanV1[], runId: string, workId: string, includeOutputExcerpts?: boolean): unknown[];
|
|
6
10
|
export declare function retainedQaCommandObservations(projectDir: string, runId: string, workId: string, includeOutputExcerpts?: boolean): unknown[];
|
package/dist/qaReviewEvidence.js
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import { ObservabilityReader } from "./observability.js";
|
|
2
|
-
/**
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Bounded, adapter-redacted output excerpts are host-retained evidence and are
|
|
4
|
+
* delivered to independent QA by default. RAFI_QA_COMMAND_OUTPUT_EXCERPTS=0
|
|
5
|
+
* (or off/false) disables them; a ticket ID limits them to that ticket.
|
|
6
|
+
*/
|
|
7
|
+
export function qaCommandOutputExcerptsAllowed(workId, configured = process.env.RAFI_QA_COMMAND_OUTPUT_EXCERPTS) {
|
|
8
|
+
if (!workId)
|
|
9
|
+
return false;
|
|
10
|
+
const value = configured?.trim();
|
|
11
|
+
if (!value || ["1", "on", "true"].includes(value.toLowerCase()))
|
|
12
|
+
return true;
|
|
13
|
+
if (["0", "off", "false"].includes(value.toLowerCase()))
|
|
14
|
+
return false;
|
|
15
|
+
return value === workId;
|
|
5
16
|
}
|
|
6
|
-
/**
|
|
17
|
+
/** Command text is never forwarded; excerpts are bounded and may be truncated. */
|
|
7
18
|
export function selectQaCommandObservations(spans, runId, workId, includeOutputExcerpts = false) {
|
|
8
19
|
const turns = new Set(spans.filter(span => span.runId === runId && span.kind === "provider_turn" && span.role === "builder" && span.ticketId === workId).map(span => span.spanId));
|
|
9
20
|
return spans.filter(span => span.runId === runId && span.kind === "tool" && span.role === "builder" && span.parentSpanId && turns.has(span.parentSpanId) && span.completionKnown && span.endedAt)
|
package/dist/qaRuntime.js
CHANGED
|
@@ -13,7 +13,7 @@ export function describeQaRuntimeHandle(adapter, metadata, handoffReceipt) {
|
|
|
13
13
|
// need the machine owner's authentication settings; Codex does not receive
|
|
14
14
|
// that SDK option.
|
|
15
15
|
const settingsSources = adapter.agent === "claude" ? "user" : "none";
|
|
16
|
-
const confinementPolicy = { sandboxMode: "read-only", settingsSources, permissions: readOnlyPermissionConfig(), dependencyProjection: "read-only-symlink", scratch: "sibling-temp-directory" };
|
|
16
|
+
const confinementPolicy = { sandboxMode: "read-only", settingsSources, permissions: readOnlyPermissionConfig(), dependencyProjection: "read-only-symlink", scratch: adapter.agent === "claude" ? "adapter-owned-private-temp-directory" : "sibling-temp-directory" };
|
|
17
17
|
const confinementBase = {
|
|
18
18
|
version: 2, sourceMode: "read-only", scratchMode: "isolated",
|
|
19
19
|
settingsSources, networkMode: "provider-required",
|
package/dist/qaSnapshot.js
CHANGED
|
@@ -51,11 +51,14 @@ export function createDisposableQaSnapshot(builderWorktree, options = {}) {
|
|
|
51
51
|
const frozenState = captureFrozenQaSource(source);
|
|
52
52
|
const tempRoot = mkdtempSync(join(tmpdir(), "rafi-qa-"));
|
|
53
53
|
const review = join(tempRoot, "review");
|
|
54
|
-
|
|
54
|
+
if (frozenState.head === "unavailable")
|
|
55
|
+
initializeUncommittedReviewSync(review);
|
|
56
|
+
else
|
|
57
|
+
cloneRepositorySync(root, review, frozenState.head);
|
|
55
58
|
try {
|
|
56
59
|
applyFrozenSync(review, frozenState);
|
|
57
60
|
if (options.projectDependencies !== false)
|
|
58
|
-
projectDependencyTrees(root, review);
|
|
61
|
+
projectDependencyTrees(root, review, frozenState.head === "unavailable");
|
|
59
62
|
mkdirSync(join(tempRoot, "scratch"), { mode: 0o700 });
|
|
60
63
|
const manifest = manifestFromFrozen(frozenState);
|
|
61
64
|
const snapshot = {
|
|
@@ -81,9 +84,12 @@ export async function createDisposableQaSnapshotAsync(builderWorktree, progress
|
|
|
81
84
|
const review = join(tempRoot, "review");
|
|
82
85
|
try {
|
|
83
86
|
progress("preparing disposable QA snapshot", "creating independent review repository");
|
|
84
|
-
|
|
87
|
+
if (frozenState.head === "unavailable")
|
|
88
|
+
await initializeUncommittedReviewAsync(review);
|
|
89
|
+
else
|
|
90
|
+
await cloneRepositoryAsync(root, review, frozenState.head);
|
|
85
91
|
await applyFrozenAsync(review, frozenState, progress);
|
|
86
|
-
projectDependencyTrees(root, review);
|
|
92
|
+
projectDependencyTrees(root, review, frozenState.head === "unavailable");
|
|
87
93
|
await mkdir(join(tempRoot, "scratch"), { mode: 0o700 });
|
|
88
94
|
const manifest = manifestFromFrozen(frozenState);
|
|
89
95
|
const snapshot = {
|
|
@@ -106,11 +112,11 @@ export async function createDisposableQaSnapshotAsync(builderWorktree, progress
|
|
|
106
112
|
}
|
|
107
113
|
export function frozenQaStateManifest(state) { return manifestFromFrozen(state); }
|
|
108
114
|
export function changeManifest(worktree) {
|
|
109
|
-
const diff = gitBuffer(resolve(worktree), ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]);
|
|
115
|
+
const diff = hasHeadSync(worktree) ? gitBuffer(resolve(worktree), ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]) : Buffer.alloc(0);
|
|
110
116
|
return { diffDigest: hash(diff), untracked: untrackedPaths(worktree).map((path) => describeUntrackedSync(worktree, path)) };
|
|
111
117
|
}
|
|
112
118
|
export async function changeManifestAsync(worktree, progress = () => { }, state = "checking QA file changes") {
|
|
113
|
-
const diff = await runGit(worktree, ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]);
|
|
119
|
+
const diff = (await hasHeadAsync(worktree)) ? await runGit(worktree, ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]) : Buffer.alloc(0);
|
|
114
120
|
const paths = await untrackedPathsAsync(worktree);
|
|
115
121
|
const untracked = [];
|
|
116
122
|
for (let index = 0; index < paths.length; index++) {
|
|
@@ -162,14 +168,14 @@ export function prospectiveGitTreeMatches(worktree, expectedTree, allowedPaths =
|
|
|
162
168
|
throw new Error(`git diff could not compare QA finalization trees (exit ${result.status ?? "unknown"})`);
|
|
163
169
|
}
|
|
164
170
|
function captureOnce(cwd) {
|
|
165
|
-
const head = gitText(cwd, ["rev-parse", "HEAD"]);
|
|
171
|
+
const head = hasHeadSync(cwd) ? gitText(cwd, ["rev-parse", "HEAD"]) : "unavailable";
|
|
166
172
|
const repository = captureRepositoryMetadataSync(cwd);
|
|
167
173
|
const status = gitBuffer(cwd, ["status", "--porcelain=v2", "-z", "--untracked-files=all", ...PRODUCT_PATHSPEC]);
|
|
168
|
-
const combinedDiff = gitBuffer(cwd, ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]);
|
|
169
|
-
const stagedDiff = gitBuffer(cwd, ["diff", "--cached", "--binary", ...PRODUCT_PATHSPEC]);
|
|
170
|
-
const unstagedDiff = gitBuffer(cwd, ["diff", "--binary", ...PRODUCT_PATHSPEC]);
|
|
171
|
-
const stagedNames = gitBuffer(cwd, ["diff", "--cached", "--name-status", "-z", ...PRODUCT_PATHSPEC]);
|
|
172
|
-
const unstagedNames = gitBuffer(cwd, ["diff", "--name-status", "-z", ...PRODUCT_PATHSPEC]);
|
|
174
|
+
const combinedDiff = head === "unavailable" ? Buffer.alloc(0) : gitBuffer(cwd, ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]);
|
|
175
|
+
const stagedDiff = gitBuffer(cwd, ["diff", "--cached", "--binary", ...(head === "unavailable" ? ["--root"] : []), ...PRODUCT_PATHSPEC]);
|
|
176
|
+
const unstagedDiff = head === "unavailable" ? Buffer.alloc(0) : gitBuffer(cwd, ["diff", "--binary", ...PRODUCT_PATHSPEC]);
|
|
177
|
+
const stagedNames = gitBuffer(cwd, ["diff", "--cached", "--name-status", "-z", ...(head === "unavailable" ? ["--root"] : []), ...PRODUCT_PATHSPEC]);
|
|
178
|
+
const unstagedNames = head === "unavailable" ? Buffer.alloc(0) : gitBuffer(cwd, ["diff", "--name-status", "-z", ...PRODUCT_PATHSPEC]);
|
|
173
179
|
const untrackedNames = gitBuffer(cwd, ["ls-files", "--others", "--exclude-standard", "-z"]);
|
|
174
180
|
const untracked = untrackedPathsFrom(untrackedNames).map((path) => captureUntrackedSync(cwd, path));
|
|
175
181
|
const pathInventory = enrichTrackedPathInventorySync(cwd, buildPathInventory(stagedNames, unstagedNames, untracked));
|
|
@@ -183,14 +189,15 @@ async function captureOnceAsync(cwd, progress) {
|
|
|
183
189
|
// observe a different index/worktree generation and manufacture a state
|
|
184
190
|
// that never existed. The enclosing double-capture supplies the stability
|
|
185
191
|
// fence across the complete sequence.
|
|
186
|
-
const
|
|
192
|
+
const headAvailable = await hasHeadAsync(cwd);
|
|
193
|
+
const headBytes = headAvailable ? await runGit(cwd, ["rev-parse", "HEAD"]) : Buffer.from("unavailable");
|
|
187
194
|
const repository = await captureRepositoryMetadataAsync(cwd);
|
|
188
195
|
const status = await runGit(cwd, ["status", "--porcelain=v2", "-z", "--untracked-files=all", ...PRODUCT_PATHSPEC]);
|
|
189
|
-
const combinedDiff = await runGit(cwd, ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]);
|
|
190
|
-
const stagedDiff = await runGit(cwd, ["diff", "--cached", "--binary", ...PRODUCT_PATHSPEC]);
|
|
191
|
-
const unstagedDiff = await runGit(cwd, ["diff", "--binary", ...PRODUCT_PATHSPEC]);
|
|
192
|
-
const stagedNames = await runGit(cwd, ["diff", "--cached", "--name-status", "-z", ...PRODUCT_PATHSPEC]);
|
|
193
|
-
const unstagedNames = await runGit(cwd, ["diff", "--name-status", "-z", ...PRODUCT_PATHSPEC]);
|
|
196
|
+
const combinedDiff = headAvailable ? await runGit(cwd, ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]) : Buffer.alloc(0);
|
|
197
|
+
const stagedDiff = await runGit(cwd, ["diff", "--cached", "--binary", ...(headAvailable ? [] : ["--root"]), ...PRODUCT_PATHSPEC]);
|
|
198
|
+
const unstagedDiff = headAvailable ? await runGit(cwd, ["diff", "--binary", ...PRODUCT_PATHSPEC]) : Buffer.alloc(0);
|
|
199
|
+
const stagedNames = await runGit(cwd, ["diff", "--cached", "--name-status", "-z", ...(headAvailable ? [] : ["--root"]), ...PRODUCT_PATHSPEC]);
|
|
200
|
+
const unstagedNames = headAvailable ? await runGit(cwd, ["diff", "--name-status", "-z", ...PRODUCT_PATHSPEC]) : Buffer.alloc(0);
|
|
194
201
|
const untrackedNames = await runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z"]);
|
|
195
202
|
const paths = untrackedPathsFrom(untrackedNames);
|
|
196
203
|
const untracked = [];
|
|
@@ -303,7 +310,13 @@ function describeUntrackedSync(worktree, path) {
|
|
|
303
310
|
function untrackedPaths(worktree) { return untrackedPathsFrom(gitBuffer(resolve(worktree), ["ls-files", "--others", "--exclude-standard", "-z"])); }
|
|
304
311
|
async function untrackedPathsAsync(worktree) { return untrackedPathsFrom(await runGit(worktree, ["ls-files", "--others", "--exclude-standard", "-z"])); }
|
|
305
312
|
function untrackedPathsFrom(raw) { return raw.toString().split("\0").filter((path) => path && isQaProductPath(path)).sort(); }
|
|
306
|
-
function isQaProductPath(path) {
|
|
313
|
+
function isQaProductPath(path) {
|
|
314
|
+
return path !== ".git" && !path.startsWith(".git/")
|
|
315
|
+
&& path !== ".foreman" && !path.startsWith(".foreman/")
|
|
316
|
+
&& path !== ".rafi" && !path.startsWith(".rafi/")
|
|
317
|
+
&& path !== "node_modules" && !path.startsWith("node_modules/")
|
|
318
|
+
&& path !== "vendor" && !path.startsWith("vendor/");
|
|
319
|
+
}
|
|
307
320
|
function renderSummary(staged, unstaged, untracked) {
|
|
308
321
|
const normalize = (value) => value.toString().split("\0").filter(Boolean).filter(isQaProductPath).sort().join("\n") || "(none)";
|
|
309
322
|
return `tracked/staged:\n${normalize(staged)}\ntracked/unstaged:\n${normalize(unstaged)}\nuntracked:\n${normalize(untracked)}`;
|
|
@@ -454,6 +467,16 @@ function manifestDifference(before, after) {
|
|
|
454
467
|
changes.push(path);
|
|
455
468
|
return changes;
|
|
456
469
|
}
|
|
470
|
+
function initializeUncommittedReviewSync(review) {
|
|
471
|
+
mkdirSync(review, { recursive: true });
|
|
472
|
+
const initialized = spawnSync("git", ["init", "-q", review], { encoding: "utf8" });
|
|
473
|
+
if (initialized.status !== 0)
|
|
474
|
+
throw new Error(`cannot initialize independent QA repository: ${initialized.stderr.trim()}`);
|
|
475
|
+
}
|
|
476
|
+
async function initializeUncommittedReviewAsync(review) {
|
|
477
|
+
await mkdir(review, { recursive: true });
|
|
478
|
+
await runGit(dirname(review), ["init", "-q", review]);
|
|
479
|
+
}
|
|
457
480
|
function cloneRepositorySync(root, review, head) {
|
|
458
481
|
const cloned = spawnSync("git", ["clone", "--no-local", "--no-hardlinks", "--no-checkout", "--quiet", root, review], { encoding: "utf8" });
|
|
459
482
|
if (cloned.status !== 0) {
|
|
@@ -475,7 +498,26 @@ async function cloneRepositoryAsync(root, review, head) {
|
|
|
475
498
|
* Project the existing trees without copying or permitting writes so validation
|
|
476
499
|
* commands in the independent clone can resolve the same installed toolchain.
|
|
477
500
|
*/
|
|
478
|
-
function projectDependencyTrees(sourceRoot, reviewRoot) {
|
|
501
|
+
function projectDependencyTrees(sourceRoot, reviewRoot, uncommitted = false) {
|
|
502
|
+
// Ignored top-level directories (vendored checkouts, runtimes) are also
|
|
503
|
+
// environment. A committed clone links one only when the clone ignores it
|
|
504
|
+
// too, so the link never appears as an untracked QA edit. Link these first:
|
|
505
|
+
// projecting a nested node_modules would otherwise create the parent as a
|
|
506
|
+
// plain directory and hide the checkout (e.g. vendor/starnet/.git).
|
|
507
|
+
for (const entry of readdirSync(sourceRoot, { withFileTypes: true })) {
|
|
508
|
+
if (!entry.isDirectory() || [".git", ".rafi", ".foreman", "node_modules"].includes(entry.name))
|
|
509
|
+
continue;
|
|
510
|
+
const source = join(sourceRoot, entry.name);
|
|
511
|
+
const target = join(reviewRoot, entry.name);
|
|
512
|
+
if (existsSync(target))
|
|
513
|
+
continue;
|
|
514
|
+
const ignored = spawnSync("git", ["-C", sourceRoot, "check-ignore", "-q", "--", entry.name]).status === 0;
|
|
515
|
+
if (!ignored)
|
|
516
|
+
continue;
|
|
517
|
+
if (!uncommitted && spawnSync("git", ["-C", reviewRoot, "check-ignore", "-q", "--", entry.name]).status !== 0)
|
|
518
|
+
continue;
|
|
519
|
+
symlinkSync(source, target, "dir");
|
|
520
|
+
}
|
|
479
521
|
const parents = ["", ...readdirSync(sourceRoot, { withFileTypes: true })
|
|
480
522
|
.filter((entry) => entry.isDirectory() && entry.name !== ".git" && entry.name !== "node_modules")
|
|
481
523
|
.flatMap((entry) => {
|
|
@@ -491,12 +533,25 @@ function projectDependencyTrees(sourceRoot, reviewRoot) {
|
|
|
491
533
|
if (!existsSync(source) || existsSync(target) || !lstatSync(source).isDirectory())
|
|
492
534
|
continue;
|
|
493
535
|
const relativeTarget = parent ? `${parent.replaceAll("\\", "/")}/node_modules` : "node_modules";
|
|
494
|
-
if (spawnSync("git", ["-C", reviewRoot, "check-ignore", "-q", "--", relativeTarget]).status !== 0)
|
|
536
|
+
if (!uncommitted && spawnSync("git", ["-C", reviewRoot, "check-ignore", "-q", "--", relativeTarget]).status !== 0)
|
|
495
537
|
continue;
|
|
496
538
|
mkdirSync(dirname(target), { recursive: true });
|
|
497
539
|
symlinkSync(source, target, "dir");
|
|
498
540
|
}
|
|
499
541
|
}
|
|
542
|
+
function hasHeadSync(cwd) {
|
|
543
|
+
const result = spawnSync("git", ["-C", resolve(cwd), "rev-parse", "--verify", "HEAD"], { stdio: "ignore" });
|
|
544
|
+
return result.status === 0;
|
|
545
|
+
}
|
|
546
|
+
async function hasHeadAsync(cwd) {
|
|
547
|
+
try {
|
|
548
|
+
await runGit(cwd, ["rev-parse", "--verify", "HEAD"]);
|
|
549
|
+
return true;
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
500
555
|
function captureRepositoryMetadataSync(cwd) {
|
|
501
556
|
const topLevel = gitText(cwd, ["rev-parse", "--show-toplevel"]);
|
|
502
557
|
const gitDir = gitText(cwd, ["rev-parse", "--path-format=absolute", "--git-dir"]);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type QaResponseContract } from "rafi-spec";
|
|
2
|
+
import type { TurnResult } from "./adapters/types.js";
|
|
3
|
+
import type { StepStatus } from "./foreman.js";
|
|
4
|
+
import { type BuilderQaRemediationContract, type QaFindingRefV2 } from "rafi-spec";
|
|
5
|
+
export declare function qaStructuredResponse(turn: TurnResult): QaResponseContract;
|
|
6
|
+
export declare function qaStructuredStatus(turn: TurnResult): StepStatus;
|
|
7
|
+
export declare function remediationStructuredResponse(turn: TurnResult, expected: {
|
|
8
|
+
handoffId?: string;
|
|
9
|
+
findings?: QaFindingRefV2[];
|
|
10
|
+
}): BuilderQaRemediationContract;
|
|
11
|
+
/** Describe a retained native qa-verdict response for evidence and display.
|
|
12
|
+
* It never authorizes a result; workflow decisions use qaStructuredResponse. */
|
|
13
|
+
export declare function describeNativeQaResponse(text: string): {
|
|
14
|
+
status: "qa_pass" | "qa_fail" | "blocked" | "needs_input" | "unknown";
|
|
15
|
+
valid: boolean;
|
|
16
|
+
issues?: string;
|
|
17
|
+
reportJson?: string;
|
|
18
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { qaVerdictOutput, validateQaFailureReport, validateBuilderQaRemediationReport } from "rafi-spec";
|
|
2
|
+
import { operationResponse } from "./structuredOperation.js";
|
|
3
|
+
import { remediationOutput, validateBuilderFindingCoverage } from "rafi-spec";
|
|
4
|
+
export function qaStructuredResponse(turn) {
|
|
5
|
+
try {
|
|
6
|
+
const response = operationResponse(turn, qaVerdictOutput);
|
|
7
|
+
if (response.result.kind === "blocked")
|
|
8
|
+
return { valid: true, errors: [], status: "blocked", fields: { reason: response.result.reason } };
|
|
9
|
+
if (response.result.kind === "needs-input")
|
|
10
|
+
return { valid: true, errors: [], status: "needs_input", fields: { question: response.result.question, choices: response.result.choices.join("|") } };
|
|
11
|
+
if (response.result.kind !== "completed")
|
|
12
|
+
throw new Error("Unconsumed QA intermediate result");
|
|
13
|
+
const payload = response.result.payload;
|
|
14
|
+
if (payload.kind === "failed") {
|
|
15
|
+
const validation = validateQaFailureReport(payload.report);
|
|
16
|
+
if (!validation.valid)
|
|
17
|
+
throw new Error(validation.errors.join("; "));
|
|
18
|
+
}
|
|
19
|
+
return payload.kind === "passed" ? { valid: true, errors: [], status: "qa_pass", fields: { summary: response.result.summary } }
|
|
20
|
+
: { valid: true, errors: [], status: "qa_fail", fields: { issues: response.result.summary }, report: payload.report, rawReportJson: JSON.stringify(payload.report) };
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
return { valid: false, errors: [String(error)], status: "unknown", fields: {} };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function qaStructuredStatus(turn) {
|
|
27
|
+
const contract = qaStructuredResponse(turn);
|
|
28
|
+
return { kind: contract.status, ...contract.fields, choices: contract.fields.choices?.split("|"), error: contract.errors.join("; ") || undefined };
|
|
29
|
+
}
|
|
30
|
+
export function remediationStructuredResponse(turn, expected) {
|
|
31
|
+
try {
|
|
32
|
+
const { result } = operationResponse(turn, remediationOutput);
|
|
33
|
+
if (result.kind === "blocked")
|
|
34
|
+
return { valid: true, errors: [], status: "blocked", fields: { reason: result.reason } };
|
|
35
|
+
if (result.kind === "needs-input")
|
|
36
|
+
return { valid: true, errors: [], status: "needs_input", fields: { question: result.question } };
|
|
37
|
+
if (result.kind !== "completed")
|
|
38
|
+
throw new Error("Unconsumed remediation intermediate result");
|
|
39
|
+
const { report, status, reason } = result.payload;
|
|
40
|
+
const validation = validateBuilderQaRemediationReport(report);
|
|
41
|
+
if (!validation.valid)
|
|
42
|
+
throw new Error(validation.errors.join("; "));
|
|
43
|
+
const errors = expected.findings ? validateBuilderFindingCoverage(report, expected.findings) : [];
|
|
44
|
+
if (expected.handoffId && report.handoff_id !== expected.handoffId)
|
|
45
|
+
errors.push("Remediation handoff identity mismatch");
|
|
46
|
+
const blocked = report.findings.some(finding => finding.disposition === "blocked");
|
|
47
|
+
if (blocked !== (status === "blocked") || status === "blocked" && !reason || status === "done" && reason !== null)
|
|
48
|
+
errors.push("Contradictory remediation outcome");
|
|
49
|
+
for (const finding of report.findings)
|
|
50
|
+
if (Boolean(finding.blocker) !== (finding.disposition === "blocked"))
|
|
51
|
+
errors.push(`Invalid blocker for ${finding.finding_key}`);
|
|
52
|
+
return { valid: !errors.length, errors, status, fields: status === "blocked" ? { reason: reason } : { summary: result.summary }, report, rawReportJson: JSON.stringify(report) };
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
return { valid: false, errors: [String(error)], status: "unknown", fields: {} };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Describe a retained native qa-verdict response for evidence and display.
|
|
59
|
+
* It never authorizes a result; workflow decisions use qaStructuredResponse. */
|
|
60
|
+
export function describeNativeQaResponse(text) {
|
|
61
|
+
let value;
|
|
62
|
+
try {
|
|
63
|
+
value = JSON.parse(text);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return { status: "unknown", valid: false, issues: "Response is not native JSON" };
|
|
67
|
+
}
|
|
68
|
+
const result = value?.result;
|
|
69
|
+
if (value?.operation !== "qa-verdict" || !result || typeof result !== "object")
|
|
70
|
+
return { status: "unknown", valid: false, issues: "Response is not a qa-verdict result" };
|
|
71
|
+
if (result.kind === "blocked")
|
|
72
|
+
return { status: "blocked", valid: true, issues: typeof result.reason === "string" ? result.reason : undefined };
|
|
73
|
+
if (result.kind === "needs-input")
|
|
74
|
+
return { status: "needs_input", valid: true, issues: typeof result.question === "string" ? result.question : undefined };
|
|
75
|
+
const summary = typeof result.summary === "string" ? result.summary : undefined;
|
|
76
|
+
if (result.kind === "completed" && result.payload?.kind === "passed")
|
|
77
|
+
return { status: "qa_pass", valid: true, issues: summary };
|
|
78
|
+
if (result.kind === "completed" && result.payload?.kind === "failed")
|
|
79
|
+
return { status: "qa_fail", valid: true, issues: summary, reportJson: JSON.stringify(result.payload.report ?? {}) };
|
|
80
|
+
return { status: "unknown", valid: false, issues: summary };
|
|
81
|
+
}
|
package/dist/qaTimeline.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { decisionBelongsToWork } from "./buildInterventions.js";
|
|
2
2
|
import { evidenceRecord } from "./qaEvidenceReader.js";
|
|
3
|
-
import {
|
|
3
|
+
import { describeNativeQaResponse } from "./qaStructuredResponse.js";
|
|
4
4
|
/** All counters are scoped rows. A changed digest is never a code-change explanation. */
|
|
5
5
|
export function buildQaTimeline(snapshot, workId) {
|
|
6
6
|
const scoped = (table) => (snapshot.rows[table] ?? []).filter(row => row.ticket_id === workId);
|
|
@@ -25,7 +25,7 @@ export function buildQaTimeline(snapshot, workId) {
|
|
|
25
25
|
if (/correction|repair/.test(slot))
|
|
26
26
|
counters.corrections++;
|
|
27
27
|
const bytes = typeof receipt?.cleanedResponseDigest === "string" ? snapshot.blobs.get(receipt.cleanedResponseDigest) : undefined;
|
|
28
|
-
const contract = bytes ?
|
|
28
|
+
const contract = bytes ? describeNativeQaResponse(bytes.toString("utf8")) : undefined;
|
|
29
29
|
const classification = contract ? contract.valid ? contract.status : "malformed" : "unavailable";
|
|
30
30
|
if (slot === "initial" && classification === "malformed")
|
|
31
31
|
counters.malformedResponses++;
|
|
@@ -20,9 +20,8 @@ export declare function ensureHostScopeCheck(candidate: QaContractCandidateV1, b
|
|
|
20
20
|
*/
|
|
21
21
|
export declare function rewriteProviderHostScopeChecks(candidate: QaContractCandidateV1, baselineId: string): QaContractCandidateV1;
|
|
22
22
|
export declare function authoritativeInventory(ticket: TicketDef, checklist: string[], inputs: ContractInputRef[]): RequirementRef[];
|
|
23
|
-
export declare function parsePreparationArtifact<T>(text: string, tag: string, maximum?: number): T;
|
|
24
23
|
/** Capture safe absolute snapshot paths before the disposable snapshot is removed. */
|
|
25
|
-
export declare function mapSnapshotWorkingDirectories(text:
|
|
24
|
+
export declare function mapSnapshotWorkingDirectories(text: Partial<QaContractCandidateV1>, snapshotPath: string): Record<string, string>;
|
|
26
25
|
/** Providers sometimes repeat an absolute disposable-snapshot path from an earlier
|
|
27
26
|
* conversation. That path is host-owned and must not enter the durable contract;
|
|
28
27
|
* normalize only the bounded QA snapshot naming convention to stable relative
|
|
@@ -129,8 +129,7 @@ export function bindUniqueBaselineCheckIds(candidate) {
|
|
|
129
129
|
/** Ensure the host-owned scope invariant survives providers that omit or rename it. */
|
|
130
130
|
export function ensureHostScopeCheck(candidate, baselineId) {
|
|
131
131
|
const copy = structuredClone(candidate);
|
|
132
|
-
const hasHostScope = copy.checks.some(check => check.
|
|
133
|
-
&& /scope comparison|product paths|preimplementation baseline/i.test(`${check.expectedBehavior} ${check.expectedEvidence.join(" ")} ${check.applicability.requiredEvidence.join(" ")}`));
|
|
132
|
+
const hasHostScope = copy.checks.some(check => check.hostKind === "source-scope");
|
|
134
133
|
if (hasHostScope)
|
|
135
134
|
return copy;
|
|
136
135
|
// Every contract check must trace to at least one authoritative requirement.
|
|
@@ -141,8 +140,11 @@ export function ensureHostScopeCheck(candidate, baselineId) {
|
|
|
141
140
|
const scopeRequirement = copy.requirements[0]?.id;
|
|
142
141
|
if (!scopeRequirement)
|
|
143
142
|
throw new Error("Cannot add host scope check without an authoritative requirement");
|
|
143
|
+
let hostId = "host-scope";
|
|
144
|
+
while (copy.checks.some(check => check.id === hostId))
|
|
145
|
+
hostId += "-host";
|
|
144
146
|
copy.checks.push({
|
|
145
|
-
id: "
|
|
147
|
+
id: hostId, hostKind: "source-scope",
|
|
146
148
|
requirementRefs: [scopeRequirement],
|
|
147
149
|
origin: "invariant",
|
|
148
150
|
expectedBehavior: "Rafi compares the final product paths with the host-captured preimplementation baseline and rejects unauthorized product changes.",
|
|
@@ -214,42 +216,13 @@ export function authoritativeInventory(ticket, checklist, inputs) {
|
|
|
214
216
|
}
|
|
215
217
|
return result;
|
|
216
218
|
}
|
|
217
|
-
export function parsePreparationArtifact(text, tag, maximum = 2 * 1024 * 1024) {
|
|
218
|
-
if (Buffer.byteLength(text) > maximum)
|
|
219
|
-
throw new Error("QA preparation response exceeds size limit");
|
|
220
|
-
const start = `${tag}_START`, end = `${tag}_END`;
|
|
221
|
-
// Marker names can appear inline in provider prose or in a quoted repair
|
|
222
|
-
// instruction. Only a standalone marker line is an envelope boundary.
|
|
223
|
-
// This keeps the parser strict about malformed envelopes without treating
|
|
224
|
-
// explanatory text as a second/truncated artifact.
|
|
225
|
-
const markerOffsets = (marker) => {
|
|
226
|
-
const offsets = [];
|
|
227
|
-
const pattern = new RegExp(`^[ \\t]*${marker}[ \\t]*$`, "gm");
|
|
228
|
-
for (let match = pattern.exec(text); match; match = pattern.exec(text))
|
|
229
|
-
offsets.push(match.index);
|
|
230
|
-
return offsets;
|
|
231
|
-
};
|
|
232
|
-
const starts = markerOffsets(start), ends = markerOffsets(end);
|
|
233
|
-
if (!starts.length || starts.length !== ends.length)
|
|
234
|
-
throw new Error(`Expected exactly one ${tag} artifact`);
|
|
235
|
-
const artifacts = starts.map((at, index) => {
|
|
236
|
-
const from = at + start.length, to = ends[index];
|
|
237
|
-
if (to < from)
|
|
238
|
-
throw new Error("Malformed preparation artifact envelope");
|
|
239
|
-
return text.slice(from, to).trim();
|
|
240
|
-
});
|
|
241
|
-
if (artifacts.length > 1) {
|
|
242
|
-
const normalized = artifacts.map(artifact => canonicalContractJson(JSON.parse(artifact)));
|
|
243
|
-
if (normalized.some(artifact => artifact !== normalized[0]))
|
|
244
|
-
throw new Error(`Expected exactly one unambiguous ${tag} artifact`);
|
|
245
|
-
}
|
|
246
|
-
return JSON.parse(artifacts[0]);
|
|
247
|
-
}
|
|
248
219
|
/** Capture safe absolute snapshot paths before the disposable snapshot is removed. */
|
|
249
220
|
export function mapSnapshotWorkingDirectories(text, snapshotPath) {
|
|
250
221
|
const result = {};
|
|
251
222
|
try {
|
|
252
|
-
|
|
223
|
+
if (typeof text === "string")
|
|
224
|
+
throw new Error("Snapshot mappings require validated native draft fields");
|
|
225
|
+
const draft = text;
|
|
253
226
|
const root = realpathSync(snapshotPath);
|
|
254
227
|
for (const check of draft.checks ?? [])
|
|
255
228
|
for (const method of check.verification ?? []) {
|
|
@@ -263,6 +236,17 @@ export function mapSnapshotWorkingDirectories(text, snapshotPath) {
|
|
|
263
236
|
}
|
|
264
237
|
catch { /* Missing or inaccessible paths remain invalid. */ }
|
|
265
238
|
}
|
|
239
|
+
for (const evidence of draft.preparationEvidence ?? [])
|
|
240
|
+
for (const reference of evidence.references ?? []) {
|
|
241
|
+
if (!isAbsolute(reference.path))
|
|
242
|
+
continue;
|
|
243
|
+
try {
|
|
244
|
+
const target = realpathSync(reference.path), path = relative(root, target);
|
|
245
|
+
if (path && path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path) && statSync(target).isFile())
|
|
246
|
+
result[reference.path] = path;
|
|
247
|
+
}
|
|
248
|
+
catch { /* Unreadable/outside references are rejected during binding. */ }
|
|
249
|
+
}
|
|
266
250
|
}
|
|
267
251
|
catch { /* Parsing and validation report malformed provider responses. */ }
|
|
268
252
|
return result;
|
|
@@ -310,6 +294,11 @@ export function validateCandidate(candidate, inventory, resolveEquivalent) {
|
|
|
310
294
|
for (const req of candidate.requirements) {
|
|
311
295
|
if (!inputs.has(req.inputRef) || !req.statement?.trim() || req.digest !== contractDigest("statement", req.statement))
|
|
312
296
|
issues.push(`Invalid source/statement ${req.id}`);
|
|
297
|
+
if (req.verificationIntent !== undefined && req.verificationIntentDigest !== contractDigest("verification-intent-v2", req.verificationIntent)) {
|
|
298
|
+
issues.push(`Invalid V2 verification intent ${req.id}`);
|
|
299
|
+
}
|
|
300
|
+
if (req.verificationIntent === undefined && req.verificationIntentDigest !== undefined)
|
|
301
|
+
issues.push(`Unexpected V2 verification intent digest ${req.id}`);
|
|
313
302
|
if (req.conflict)
|
|
314
303
|
issues.push(`Unresolved requirement conflict ${req.id}`);
|
|
315
304
|
if (req.authority === "proposed" && req.obligation === "mandatory")
|
package/dist/readinessGate.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseHostIpc } from "rafi-spec";
|
|
1
2
|
/** Trusted bootstrap: registration and durable authorization precede all provider work. */
|
|
2
3
|
import { WorkflowDb } from "./workflowDb.js";
|
|
3
4
|
import { spawn } from "node:child_process";
|
|
@@ -21,7 +22,7 @@ try {
|
|
|
21
22
|
await new Promise((resolve, reject) => {
|
|
22
23
|
const timer = setTimeout(() => finish(new Error("Readiness authorization timed out")), 10_000);
|
|
23
24
|
const disconnect = () => finish(new Error("Readiness owner disconnected"));
|
|
24
|
-
const message = (value) => {
|
|
25
|
+
const message = (value) => { const ack = parseHostIpc(value); if (ack?.kind === "rafi-probe-authorized" && ack.id === id && ack.pid === process.pid)
|
|
25
26
|
finish(); };
|
|
26
27
|
function finish(error) {
|
|
27
28
|
clearTimeout(timer);
|
|
@@ -34,7 +35,7 @@ try {
|
|
|
34
35
|
}
|
|
35
36
|
process.on("message", message);
|
|
36
37
|
process.once("disconnect", disconnect);
|
|
37
|
-
process.send({ kind: "rafi-probe-registered", id });
|
|
38
|
+
process.send({ version: 1, kind: "rafi-probe-registered", id, pid: process.pid });
|
|
38
39
|
if (disconnected)
|
|
39
40
|
disconnect();
|
|
40
41
|
});
|
|
@@ -47,11 +48,11 @@ try {
|
|
|
47
48
|
stdio: [windows ? "pipe" : "ignore", "inherit", "inherit"],
|
|
48
49
|
});
|
|
49
50
|
child.on("error", (error) => {
|
|
50
|
-
process.send?.({ kind: "rafi-probe-spawn-error", code: error.code, message: error.message });
|
|
51
|
+
process.send?.({ version: 1, kind: "rafi-probe-spawn-error", id, pid: process.pid, code: error.code ?? null, message: error.message.slice(0, 8192) });
|
|
51
52
|
console.error(error.message);
|
|
52
53
|
process.exitCode = 127;
|
|
53
54
|
});
|
|
54
|
-
const done = new Promise(resolve => { child.once("exit", (code, signal) => { process.send?.({ kind: "rafi-probe-result", exitCode: code, signal }); process.exitCode = code ?? 1; resolve(); }); child.once("error", () => resolve()); });
|
|
55
|
+
const done = new Promise(resolve => { child.once("exit", (code, signal) => { process.send?.({ version: 1, kind: "rafi-probe-result", id, pid: process.pid, exitCode: code, signal }); process.exitCode = code ?? 1; resolve(); }); child.once("error", () => resolve()); });
|
|
55
56
|
if (windows) {
|
|
56
57
|
try {
|
|
57
58
|
if (!child.pid)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface RecoveryOwner {
|
|
2
|
+
source: "build-admission" | "project-lease" | "supervisor" | "owned-process";
|
|
3
|
+
runId: string;
|
|
4
|
+
pid: number | null;
|
|
5
|
+
processStart?: string;
|
|
6
|
+
host?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface CompletedPlanApproval {
|
|
9
|
+
runId: string;
|
|
10
|
+
originalWork: unknown;
|
|
11
|
+
state: {
|
|
12
|
+
planId?: string;
|
|
13
|
+
revision?: number;
|
|
14
|
+
digest?: string;
|
|
15
|
+
decisionReceipt?: unknown;
|
|
16
|
+
} & Record<string, unknown>;
|
|
17
|
+
updatedAt: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Reads completed `rafi plan` approval runs without migrating. They are
|
|
21
|
+
* planning state that predates any build, so a build reset must carry them
|
|
22
|
+
* into the fresh database; build recovery refuses to resume without one.
|
|
23
|
+
*/
|
|
24
|
+
export declare function readCompletedPlanApprovals(dbPath: string): CompletedPlanApproval[];
|
|
25
|
+
/**
|
|
26
|
+
* Lists every process that may still own a recovery database, without
|
|
27
|
+
* migrating it. Archiving tools need this because opening a database from an
|
|
28
|
+
* older Rafi through WorkflowDb runs the structured-communication cutover, and
|
|
29
|
+
* a stale build admission left by a dead process makes that cutover fail.
|
|
30
|
+
* Unknown or malformed tables are skipped; callers decide liveness.
|
|
31
|
+
*/
|
|
32
|
+
export declare function readRecoveryOwners(dbPath: string): RecoveryOwner[];
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
/**
|
|
4
|
+
* Reads completed `rafi plan` approval runs without migrating. They are
|
|
5
|
+
* planning state that predates any build, so a build reset must carry them
|
|
6
|
+
* into the fresh database; build recovery refuses to resume without one.
|
|
7
|
+
*/
|
|
8
|
+
export function readCompletedPlanApprovals(dbPath) {
|
|
9
|
+
if (!existsSync(dbPath))
|
|
10
|
+
return [];
|
|
11
|
+
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
12
|
+
try {
|
|
13
|
+
db.pragma("query_only = ON");
|
|
14
|
+
if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='workflow_runs'").get())
|
|
15
|
+
return [];
|
|
16
|
+
const rows = db.prepare("SELECT run_id,original_work_json,state_json,updated_at FROM workflow_runs WHERE kind='plan' AND status='completed' ORDER BY updated_at")
|
|
17
|
+
.all();
|
|
18
|
+
const approvals = [];
|
|
19
|
+
for (const row of rows) {
|
|
20
|
+
try {
|
|
21
|
+
approvals.push({ runId: row.run_id, originalWork: JSON.parse(row.original_work_json), state: JSON.parse(row.state_json), updatedAt: row.updated_at });
|
|
22
|
+
}
|
|
23
|
+
catch { /* a malformed historical row is not an approval */ }
|
|
24
|
+
}
|
|
25
|
+
return approvals;
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
db.close();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Lists every process that may still own a recovery database, without
|
|
33
|
+
* migrating it. Archiving tools need this because opening a database from an
|
|
34
|
+
* older Rafi through WorkflowDb runs the structured-communication cutover, and
|
|
35
|
+
* a stale build admission left by a dead process makes that cutover fail.
|
|
36
|
+
* Unknown or malformed tables are skipped; callers decide liveness.
|
|
37
|
+
*/
|
|
38
|
+
export function readRecoveryOwners(dbPath) {
|
|
39
|
+
if (!existsSync(dbPath))
|
|
40
|
+
return [];
|
|
41
|
+
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
42
|
+
try {
|
|
43
|
+
db.pragma("query_only = ON");
|
|
44
|
+
const has = (table) => Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(table));
|
|
45
|
+
const owners = [];
|
|
46
|
+
if (has("build_admission")) {
|
|
47
|
+
const row = db.prepare("SELECT record_json FROM build_admission WHERE singleton=1").get();
|
|
48
|
+
if (row) {
|
|
49
|
+
let record;
|
|
50
|
+
try {
|
|
51
|
+
record = JSON.parse(row.record_json);
|
|
52
|
+
}
|
|
53
|
+
catch { /* malformed: report as unverifiable */ }
|
|
54
|
+
owners.push({ source: "build-admission", runId: String(record?.runId ?? ""), pid: typeof record?.pid === "number" ? record.pid : null, processStart: record?.processStart, host: record?.host });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (has("project_lease")) {
|
|
58
|
+
for (const row of db.prepare("SELECT * FROM project_lease").all()) {
|
|
59
|
+
owners.push({ source: "project-lease", runId: String(row.run_id ?? ""), pid: typeof row.pid === "number" ? row.pid : null, processStart: row.process_start ?? undefined, host: row.host ?? undefined });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (has("supervisor_leases") && has("workflow_runs")) {
|
|
63
|
+
const rows = db.prepare(`SELECT s.run_id,s.state_json FROM supervisor_leases s JOIN workflow_runs r USING(run_id)
|
|
64
|
+
WHERE r.kind='build' AND r.status NOT IN ('completed','cancelled','superseded')`).all();
|
|
65
|
+
for (const row of rows) {
|
|
66
|
+
let state;
|
|
67
|
+
try {
|
|
68
|
+
state = JSON.parse(row.state_json);
|
|
69
|
+
}
|
|
70
|
+
catch { /* malformed: report as unverifiable */ }
|
|
71
|
+
// Matches the prior reset rule: only a supervisor recorded as running owns the run.
|
|
72
|
+
if (state && state.status !== "running")
|
|
73
|
+
continue;
|
|
74
|
+
owners.push({ source: "supervisor", runId: row.run_id, pid: typeof state?.pid === "number" ? state.pid : null, processStart: state?.processStart });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (has("build_owned_processes")) {
|
|
78
|
+
for (const row of db.prepare("SELECT run_id,pid,process_start,host FROM build_owned_processes WHERE state<>'quiescent'").all()) {
|
|
79
|
+
owners.push({ source: "owned-process", runId: String(row.run_id), pid: typeof row.pid === "number" ? row.pid : null, processStart: row.process_start ?? undefined, host: row.host ?? undefined });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return owners;
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
db.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface RuntimeCheck {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
label: string;
|
|
4
|
+
detail: string;
|
|
5
|
+
fix?: string;
|
|
6
|
+
}
|
|
7
|
+
/** Minimal semver check for the range forms Rafi declares: "24.x", "^13.0.3", "13.0.3". */
|
|
8
|
+
export declare function satisfiesRange(version: string, range: string): boolean;
|
|
9
|
+
export declare function checkRuntimeEnvironment(): RuntimeCheck[];
|
|
10
|
+
/** Text for a failed preflight, or undefined when every check passed. */
|
|
11
|
+
export declare function runtimePreflightFailure(checks?: RuntimeCheck[]): string | undefined;
|