faberun 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- package/src/web/server.mjs +552 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { appendJsonl } from "../run/store.mjs";
|
|
4
|
+
import {
|
|
5
|
+
boundedGitSync,
|
|
6
|
+
candidateRefName,
|
|
7
|
+
cleanupCandidate,
|
|
8
|
+
createCandidateWorktree,
|
|
9
|
+
deleteRef,
|
|
10
|
+
git,
|
|
11
|
+
gitDiffEmpty,
|
|
12
|
+
gitHead,
|
|
13
|
+
removeWorktree,
|
|
14
|
+
updateRefConditional,
|
|
15
|
+
candidateWorktreePath,
|
|
16
|
+
runRefName,
|
|
17
|
+
worktreeCheckedOutAt,
|
|
18
|
+
} from "./worktree.mjs";
|
|
19
|
+
|
|
20
|
+
const JOURNAL = "integration.jsonl";
|
|
21
|
+
const TERMINAL = new Set(["accepted", "failed", "conflict"]);
|
|
22
|
+
|
|
23
|
+
/** @typedef {"prepared"|"verified"|"accepted"|"failed"|"conflict"} IntegrationStatus */
|
|
24
|
+
/** @typedef {{schemaVersion: number, transactionId: string, runId: string, node: string, attempt: number, attemptSha: string, branch: string|null, empty: boolean, previousRunRefTip: string, candidateSha: string|null, conflictingPaths: string[], at: string, status?: IntegrationStatus, verificationEvidence?: unknown, acceptedAt?: string}} IntegrationRecord */
|
|
25
|
+
/** @typedef {{passed?: boolean, error?: string, [key: string]: unknown}} CandidateEvidence */
|
|
26
|
+
/** @typedef {(record: IntegrationRecord) => void|Promise<void>} AcceptedCallback */
|
|
27
|
+
/** @typedef {(record: IntegrationRecord & {candidateEvidence: CandidateEvidence}) => void|Promise<void>} VerificationFailureCallback */
|
|
28
|
+
/** @typedef {(record: IntegrationRecord & {currentRunRefTip: string|null, error?: unknown}) => void|Promise<void>} ConcurrentMoveCallback */
|
|
29
|
+
/** @typedef {(workspace: string, transaction: IntegrationRecord) => Promise<CandidateEvidence>} CandidateVerifier */
|
|
30
|
+
/** @typedef {{repo: string, runDir: string, runId: string, nodeId: string, attempt: number, attemptSha: string, branch?: string|null, verificationEvidence?: unknown, verifyCandidate: CandidateVerifier, onAccepted?: AcceptedCallback, onVerificationFailure?: VerificationFailureCallback, onConflict?: AcceptedCallback, onConcurrentMove?: ConcurrentMoveCallback, interrupt?: (stage: string) => void}} IntegrationArgs */
|
|
31
|
+
/** @typedef {{repo: string, runDir: string, runId: string, verifyCandidate: CandidateVerifier, onAccepted?: AcceptedCallback, onVerificationFailure?: VerificationFailureCallback, onConflict?: AcceptedCallback, onConcurrentMove?: ConcurrentMoveCallback}} RecoveryArgs */
|
|
32
|
+
/** @typedef {{status: string, candidateSha?: string|null, conflictingPaths?: string[], candidateEvidence?: CandidateEvidence}} IntegrationResult */
|
|
33
|
+
/** @typedef {{runId: string, status: "promoted"|"already_promoted", branch: string, sha: string, previousSha: string|null, at: string}} PromoteRecord */
|
|
34
|
+
|
|
35
|
+
/** @param {string} runDir @returns {string} */
|
|
36
|
+
function integrationJournalPath(runDir) {
|
|
37
|
+
return join(runDir, JOURNAL);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @param {string} nodeId @param {number} attempt @returns {string} */
|
|
41
|
+
function integrationKey(nodeId, attempt) {
|
|
42
|
+
return `${nodeId}:${attempt}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @param {string} runDir @returns {IntegrationRecord[]} */
|
|
46
|
+
export function readIntegrationJournal(runDir) {
|
|
47
|
+
const path = integrationJournalPath(runDir);
|
|
48
|
+
let text;
|
|
49
|
+
try {
|
|
50
|
+
text = readTextFile(path);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error instanceof Error && /** @type {{code?: string}} */ (error).code === "ENOENT") return [];
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
return text.split("\n").filter(Boolean).map((line, index) => {
|
|
56
|
+
try {
|
|
57
|
+
return /** @type {IntegrationRecord} */ (JSON.parse(line));
|
|
58
|
+
} catch (error) {
|
|
59
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
60
|
+
throw new Error(`integration journal line ${index + 1} is invalid: ${message}`);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @param {string} runDir @returns {IntegrationRecord[]} */
|
|
66
|
+
function latestIntegrationTransactions(runDir) {
|
|
67
|
+
/** @type {Map<string, IntegrationRecord>} */
|
|
68
|
+
const latest = new Map();
|
|
69
|
+
for (const record of readIntegrationJournal(runDir)) {
|
|
70
|
+
if (!record || typeof record !== "object") continue;
|
|
71
|
+
const key = integrationKey(record.node, record.attempt);
|
|
72
|
+
latest.set(key, record);
|
|
73
|
+
}
|
|
74
|
+
return [...latest.values()];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @param {IntegrationArgs} args @returns {Promise<IntegrationResult|null>} */
|
|
78
|
+
export async function integrateAttempt({
|
|
79
|
+
repo,
|
|
80
|
+
runDir,
|
|
81
|
+
runId,
|
|
82
|
+
nodeId,
|
|
83
|
+
attempt,
|
|
84
|
+
attemptSha,
|
|
85
|
+
branch,
|
|
86
|
+
verificationEvidence,
|
|
87
|
+
verifyCandidate,
|
|
88
|
+
onAccepted,
|
|
89
|
+
onVerificationFailure,
|
|
90
|
+
onConflict,
|
|
91
|
+
onConcurrentMove,
|
|
92
|
+
interrupt,
|
|
93
|
+
}) {
|
|
94
|
+
const existing = latestIntegrationTransactions(runDir).find((record) => (
|
|
95
|
+
record.node === nodeId && record.attempt === attempt
|
|
96
|
+
));
|
|
97
|
+
if (existing?.status === "accepted") {
|
|
98
|
+
await finishAccepted(repo, runDir, runId, existing, onAccepted);
|
|
99
|
+
return { status: "accepted", candidateSha: existing.candidateSha };
|
|
100
|
+
}
|
|
101
|
+
if (existing && !TERMINAL.has(existing.status ?? "")) {
|
|
102
|
+
return recoverIntegrations({
|
|
103
|
+
repo,
|
|
104
|
+
runDir,
|
|
105
|
+
runId,
|
|
106
|
+
verifyCandidate,
|
|
107
|
+
onAccepted,
|
|
108
|
+
onVerificationFailure,
|
|
109
|
+
onConflict,
|
|
110
|
+
onConcurrentMove,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const previousRunRefTip = gitHead(repo, runRefName(runId));
|
|
114
|
+
if (!previousRunRefTip) throw new Error(`integration ref is unavailable for ${runId}`);
|
|
115
|
+
const transaction = prepareTransaction({
|
|
116
|
+
repo,
|
|
117
|
+
runId,
|
|
118
|
+
nodeId,
|
|
119
|
+
attempt,
|
|
120
|
+
attemptSha,
|
|
121
|
+
branch,
|
|
122
|
+
previousRunRefTip,
|
|
123
|
+
});
|
|
124
|
+
appendRecord(runDir, { ...transaction, status: "prepared", verificationEvidence: verificationEvidence ?? null });
|
|
125
|
+
interruptStage(interrupt, "prepared");
|
|
126
|
+
if (transaction.conflictingPaths.length) {
|
|
127
|
+
appendRecord(runDir, { ...transaction, status: "conflict", conflictingPaths: transaction.conflictingPaths, verificationEvidence: verificationEvidence ?? null });
|
|
128
|
+
await onConflict?.(transaction);
|
|
129
|
+
cleanupCandidate(repo, runDir, runId);
|
|
130
|
+
return { status: "conflict", conflictingPaths: transaction.conflictingPaths };
|
|
131
|
+
}
|
|
132
|
+
return verifyAndAdvance({
|
|
133
|
+
repo,
|
|
134
|
+
runDir,
|
|
135
|
+
runId,
|
|
136
|
+
transaction,
|
|
137
|
+
verificationEvidence,
|
|
138
|
+
verifyCandidate,
|
|
139
|
+
onAccepted,
|
|
140
|
+
onVerificationFailure,
|
|
141
|
+
onConcurrentMove,
|
|
142
|
+
interrupt,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** @param {RecoveryArgs} args @returns {Promise<IntegrationResult|null>} */
|
|
147
|
+
export async function recoverIntegrations({
|
|
148
|
+
repo,
|
|
149
|
+
runDir,
|
|
150
|
+
runId,
|
|
151
|
+
verifyCandidate,
|
|
152
|
+
onAccepted,
|
|
153
|
+
onVerificationFailure,
|
|
154
|
+
onConflict,
|
|
155
|
+
onConcurrentMove,
|
|
156
|
+
}) {
|
|
157
|
+
for (const record of latestIntegrationTransactions(runDir)) {
|
|
158
|
+
if (record.status === "accepted") {
|
|
159
|
+
await finishAccepted(repo, runDir, runId, record, onAccepted);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (record.status === "failed" || record.status === "conflict") {
|
|
163
|
+
cleanupCandidate(repo, runDir, runId);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
/** @type {IntegrationRecord} */
|
|
167
|
+
const transaction = {
|
|
168
|
+
...record,
|
|
169
|
+
conflictingPaths: Array.isArray(record.conflictingPaths) ? record.conflictingPaths : [],
|
|
170
|
+
};
|
|
171
|
+
if (!transaction.candidateSha) {
|
|
172
|
+
cleanupCandidate(repo, runDir, runId);
|
|
173
|
+
if (transaction.conflictingPaths.length) {
|
|
174
|
+
const conflict = appendConflict(runDir, transaction);
|
|
175
|
+
await onConflict?.(conflict);
|
|
176
|
+
}
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const current = gitHead(repo, runRefName(runId));
|
|
180
|
+
// A prepared record has no candidate verification evidence. Even if an
|
|
181
|
+
// external actor moved the run ref to the candidate or beyond it, that
|
|
182
|
+
// ancestry is not proof that this transaction passed verification.
|
|
183
|
+
if (record.status === "prepared") {
|
|
184
|
+
if (current && current !== transaction.previousRunRefTip) {
|
|
185
|
+
cleanupCandidate(repo, runDir, runId);
|
|
186
|
+
await onConcurrentMove?.({ ...transaction, currentRunRefTip: current });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
cleanupCandidate(repo, runDir, runId);
|
|
190
|
+
const rebuilt = candidateObjectExists(repo, transaction.candidateSha)
|
|
191
|
+
? { ...transaction, conflictingPaths: [] }
|
|
192
|
+
: prepareTransaction({
|
|
193
|
+
repo,
|
|
194
|
+
runId,
|
|
195
|
+
nodeId: transaction.node,
|
|
196
|
+
attempt: transaction.attempt,
|
|
197
|
+
attemptSha: transaction.attemptSha,
|
|
198
|
+
branch: transaction.branch,
|
|
199
|
+
previousRunRefTip: transaction.previousRunRefTip,
|
|
200
|
+
});
|
|
201
|
+
if (rebuilt.candidateSha !== transaction.candidateSha || rebuilt.conflictingPaths.length) {
|
|
202
|
+
const conflict = appendConflict(runDir, {
|
|
203
|
+
...transaction,
|
|
204
|
+
conflictingPaths: rebuilt.conflictingPaths.length ? rebuilt.conflictingPaths : ["candidate object unavailable"],
|
|
205
|
+
});
|
|
206
|
+
await onConflict?.(conflict);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
return verifyAndAdvance({
|
|
210
|
+
repo,
|
|
211
|
+
runDir,
|
|
212
|
+
runId,
|
|
213
|
+
transaction: rebuilt,
|
|
214
|
+
verificationEvidence: transaction.verificationEvidence,
|
|
215
|
+
verifyCandidate,
|
|
216
|
+
onAccepted,
|
|
217
|
+
onVerificationFailure,
|
|
218
|
+
onConcurrentMove,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
if (current === transaction.candidateSha && current !== transaction.previousRunRefTip) {
|
|
222
|
+
const accepted = appendAccepted(runDir, transaction);
|
|
223
|
+
await finishAccepted(repo, runDir, runId, accepted, onAccepted);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (current && current !== transaction.previousRunRefTip && isAncestor(repo, transaction.candidateSha, current)) {
|
|
227
|
+
const accepted = appendAccepted(runDir, transaction);
|
|
228
|
+
await finishAccepted(repo, runDir, runId, accepted, onAccepted);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (current && current !== transaction.previousRunRefTip) {
|
|
232
|
+
cleanupCandidate(repo, runDir, runId);
|
|
233
|
+
await onConcurrentMove?.({ ...transaction, currentRunRefTip: current });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
cleanupCandidate(repo, runDir, runId);
|
|
237
|
+
return advanceVerified({
|
|
238
|
+
repo,
|
|
239
|
+
runDir,
|
|
240
|
+
runId,
|
|
241
|
+
transaction,
|
|
242
|
+
onAccepted,
|
|
243
|
+
onConcurrentMove,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** @param {{repo: string, runId: string, nodeId: string, attempt: number, attemptSha: string, branch?: string|null, previousRunRefTip: string}} args @returns {IntegrationRecord} */
|
|
250
|
+
function prepareTransaction({ repo, runId, nodeId, attempt, attemptSha, branch, previousRunRefTip }) {
|
|
251
|
+
/** @type {IntegrationRecord} */
|
|
252
|
+
const transaction = {
|
|
253
|
+
schemaVersion: 1,
|
|
254
|
+
transactionId: `${runId}:${nodeId}:${attempt}`,
|
|
255
|
+
runId,
|
|
256
|
+
node: nodeId,
|
|
257
|
+
attempt,
|
|
258
|
+
attemptSha,
|
|
259
|
+
branch: branch ?? null,
|
|
260
|
+
empty: false,
|
|
261
|
+
previousRunRefTip,
|
|
262
|
+
candidateSha: null,
|
|
263
|
+
conflictingPaths: [],
|
|
264
|
+
at: new Date().toISOString(),
|
|
265
|
+
};
|
|
266
|
+
transaction.empty = Boolean(gitDiffEmpty(repo, previousRunRefTip, attemptSha));
|
|
267
|
+
if (transaction.empty) {
|
|
268
|
+
transaction.candidateSha = previousRunRefTip;
|
|
269
|
+
return transaction;
|
|
270
|
+
}
|
|
271
|
+
if (attemptSha === previousRunRefTip || isAncestor(repo, previousRunRefTip, attemptSha)) {
|
|
272
|
+
transaction.candidateSha = attemptSha;
|
|
273
|
+
return transaction;
|
|
274
|
+
}
|
|
275
|
+
const merge = boundedGitSync(["-C", repo, "merge-tree", "--write-tree", previousRunRefTip, attemptSha], {
|
|
276
|
+
encoding: "utf8",
|
|
277
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
278
|
+
});
|
|
279
|
+
if (merge.error) throw merge.error;
|
|
280
|
+
if (merge.status !== 0) {
|
|
281
|
+
transaction.conflictingPaths = conflictPaths(repo, previousRunRefTip, attemptSha, `${merge.stdout ?? ""}\n${merge.stderr ?? ""}`);
|
|
282
|
+
return transaction;
|
|
283
|
+
}
|
|
284
|
+
const tree = String(merge.stdout ?? "").split("\n")[0].trim();
|
|
285
|
+
if (!/^[0-9a-f]{40,64}$/.test(tree)) throw new Error("git merge-tree did not return a candidate tree");
|
|
286
|
+
transaction.candidateSha = gitCommitTree(repo, tree, previousRunRefTip, attemptSha, runId, nodeId, attempt);
|
|
287
|
+
return transaction;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** @param {{repo: string, runDir: string, runId: string, transaction: IntegrationRecord, verificationEvidence?: unknown, verifyCandidate: CandidateVerifier, onAccepted?: AcceptedCallback, onVerificationFailure?: VerificationFailureCallback, onConcurrentMove?: ConcurrentMoveCallback, interrupt?: (stage: string) => void}} args @returns {Promise<IntegrationResult>} */
|
|
291
|
+
async function verifyAndAdvance({
|
|
292
|
+
repo,
|
|
293
|
+
runDir,
|
|
294
|
+
runId,
|
|
295
|
+
transaction,
|
|
296
|
+
verificationEvidence,
|
|
297
|
+
verifyCandidate,
|
|
298
|
+
onAccepted,
|
|
299
|
+
onVerificationFailure,
|
|
300
|
+
onConcurrentMove,
|
|
301
|
+
interrupt,
|
|
302
|
+
}) {
|
|
303
|
+
deleteRef(repo, candidateRefName(runId));
|
|
304
|
+
deleteCandidateWorktree(repo, runDir, runId);
|
|
305
|
+
gitSetRef(repo, candidateRefName(runId), transaction.candidateSha);
|
|
306
|
+
const workspace = createCandidateWorktree({ repo, runDir, runId });
|
|
307
|
+
/** @type {CandidateEvidence} */
|
|
308
|
+
let candidateEvidence;
|
|
309
|
+
try {
|
|
310
|
+
candidateEvidence = await verifyCandidate(workspace, transaction);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
candidateEvidence = { passed: false, error: error instanceof Error ? error.message : String(error) };
|
|
313
|
+
}
|
|
314
|
+
if (!candidateEvidence?.passed) {
|
|
315
|
+
appendRecord(runDir, { ...transaction, status: "failed", verificationEvidence: { attempt: verificationEvidence ?? null, candidate: candidateEvidence } });
|
|
316
|
+
cleanupCandidate(repo, runDir, runId);
|
|
317
|
+
await onVerificationFailure?.({ ...transaction, candidateEvidence });
|
|
318
|
+
return { status: "verification_failed", candidateEvidence };
|
|
319
|
+
}
|
|
320
|
+
/** @type {IntegrationRecord} */
|
|
321
|
+
const verified = { ...transaction, status: "verified", verificationEvidence: { attempt: verificationEvidence ?? null, candidate: candidateEvidence } };
|
|
322
|
+
appendRecord(runDir, verified);
|
|
323
|
+
interruptStage(interrupt, "before-ref");
|
|
324
|
+
return advanceVerified({ repo, runDir, runId, transaction: verified, onAccepted, onConcurrentMove, interrupt });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** @param {{repo: string, runDir: string, runId: string, transaction: IntegrationRecord, onAccepted?: AcceptedCallback, onConcurrentMove?: ConcurrentMoveCallback, interrupt?: (stage: string) => void}} args @returns {Promise<IntegrationResult>} */
|
|
328
|
+
async function advanceVerified({ repo, runDir, runId, transaction, onAccepted, onConcurrentMove, interrupt }) {
|
|
329
|
+
const current = gitHead(repo, runRefName(runId));
|
|
330
|
+
if (!current) throw new Error(`integration ref is unavailable for ${runId}`);
|
|
331
|
+
if (current === transaction.candidateSha && current !== transaction.previousRunRefTip) {
|
|
332
|
+
const accepted = appendAccepted(runDir, transaction);
|
|
333
|
+
await finishAccepted(repo, runDir, runId, accepted, onAccepted);
|
|
334
|
+
return { status: "accepted", candidateSha: transaction.candidateSha };
|
|
335
|
+
}
|
|
336
|
+
if (current !== transaction.previousRunRefTip && !(isAncestor(repo, transaction.candidateSha, current))) {
|
|
337
|
+
cleanupCandidate(repo, runDir, runId);
|
|
338
|
+
await onConcurrentMove?.({ ...transaction, currentRunRefTip: current });
|
|
339
|
+
return { status: "concurrent_move" };
|
|
340
|
+
}
|
|
341
|
+
if (current !== transaction.previousRunRefTip && isAncestor(repo, transaction.candidateSha, current)) {
|
|
342
|
+
const accepted = appendAccepted(runDir, transaction);
|
|
343
|
+
await finishAccepted(repo, runDir, runId, accepted, onAccepted);
|
|
344
|
+
return { status: "accepted", candidateSha: transaction.candidateSha };
|
|
345
|
+
}
|
|
346
|
+
if (!transaction.candidateSha) throw new Error(`verified integration transaction is missing a candidate for ${transaction.node}`);
|
|
347
|
+
try {
|
|
348
|
+
updateRefConditional(repo, runRefName(runId), transaction.candidateSha, transaction.previousRunRefTip);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
const now = gitHead(repo, runRefName(runId));
|
|
351
|
+
cleanupCandidate(repo, runDir, runId);
|
|
352
|
+
await onConcurrentMove?.({ ...transaction, currentRunRefTip: now, error });
|
|
353
|
+
return { status: "concurrent_move" };
|
|
354
|
+
}
|
|
355
|
+
interruptStage(interrupt, "after-ref");
|
|
356
|
+
const accepted = appendAccepted(runDir, transaction);
|
|
357
|
+
await onAccepted?.(accepted);
|
|
358
|
+
interruptStage(interrupt, "after-state");
|
|
359
|
+
cleanupCandidate(repo, runDir, runId);
|
|
360
|
+
return { status: "accepted", candidateSha: transaction.candidateSha };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** @param {string} repo @param {string} runDir @param {string} runId @param {IntegrationRecord} record @param {AcceptedCallback|undefined} onAccepted @returns {Promise<void>} */
|
|
364
|
+
async function finishAccepted(repo, runDir, runId, record, onAccepted) {
|
|
365
|
+
await onAccepted?.(record);
|
|
366
|
+
cleanupCandidate(repo, runDir, runId);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** @param {string} runDir @param {IntegrationRecord} transaction @returns {IntegrationRecord} */
|
|
370
|
+
function appendAccepted(runDir, transaction) {
|
|
371
|
+
/** @type {IntegrationRecord} */
|
|
372
|
+
const accepted = { ...transaction, status: "accepted", acceptedAt: new Date().toISOString() };
|
|
373
|
+
const existing = latestIntegrationTransactions(runDir).find((record) => (
|
|
374
|
+
record.node === transaction.node && record.attempt === transaction.attempt && record.status === "accepted"
|
|
375
|
+
));
|
|
376
|
+
if (existing) return existing;
|
|
377
|
+
appendRecord(runDir, accepted);
|
|
378
|
+
return accepted;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** @param {string} runDir @param {IntegrationRecord} transaction @returns {IntegrationRecord} */
|
|
382
|
+
function appendConflict(runDir, transaction) {
|
|
383
|
+
/** @type {IntegrationRecord} */
|
|
384
|
+
const conflict = { ...transaction, status: "conflict" };
|
|
385
|
+
const existing = latestIntegrationTransactions(runDir).find((record) => (
|
|
386
|
+
record.node === transaction.node && record.attempt === transaction.attempt && record.status === "conflict"
|
|
387
|
+
));
|
|
388
|
+
if (existing) return existing;
|
|
389
|
+
appendRecord(runDir, conflict);
|
|
390
|
+
return conflict;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** @param {string} runDir @param {IntegrationRecord} record @returns {void} */
|
|
394
|
+
function appendRecord(runDir, record) {
|
|
395
|
+
appendJsonl(integrationJournalPath(runDir), record);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** @param {string} repo @param {string|null} ancestor @param {string|null} descendant @returns {boolean} */
|
|
399
|
+
function isAncestor(repo, ancestor, descendant) {
|
|
400
|
+
if (!ancestor || !descendant) return false;
|
|
401
|
+
try {
|
|
402
|
+
git(repo, ["merge-base", "--is-ancestor", ancestor, descendant]);
|
|
403
|
+
return true;
|
|
404
|
+
} catch {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** @param {string} repo @param {string|null} sha @returns {boolean} */
|
|
410
|
+
function candidateObjectExists(repo, sha) {
|
|
411
|
+
if (!sha) return false;
|
|
412
|
+
try {
|
|
413
|
+
git(repo, ["cat-file", "-e", `${sha}^{commit}`]);
|
|
414
|
+
return true;
|
|
415
|
+
} catch {
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** @param {string} repo @param {string} ref @param {string|null} sha @returns {void} */
|
|
421
|
+
function gitSetRef(repo, ref, sha) {
|
|
422
|
+
if (!sha) throw new Error(`cannot set ${ref} without a candidate sha`);
|
|
423
|
+
git(repo, ["update-ref", ref, sha]);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** @param {string} repo @param {string} tree @param {string} firstParent @param {string} secondParent @param {string} runId @param {string} nodeId @param {number} attempt @returns {string} */
|
|
427
|
+
function gitCommitTree(repo, tree, firstParent, secondParent, runId, nodeId, attempt) {
|
|
428
|
+
const result = boundedGitSync(["-C", repo, "commit-tree", tree, "-p", firstParent, "-p", secondParent, "-m", `faberun candidate ${runId} ${nodeId} attempt ${attempt}`], {
|
|
429
|
+
encoding: "utf8",
|
|
430
|
+
env: {
|
|
431
|
+
...process.env,
|
|
432
|
+
GIT_AUTHOR_NAME: "faberun",
|
|
433
|
+
GIT_AUTHOR_EMAIL: "runner@example.test",
|
|
434
|
+
GIT_COMMITTER_NAME: "faberun",
|
|
435
|
+
GIT_COMMITTER_EMAIL: "runner@example.test",
|
|
436
|
+
},
|
|
437
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
438
|
+
});
|
|
439
|
+
if (result.error) throw result.error;
|
|
440
|
+
if (result.status !== 0) throw new Error(`git commit-tree failed: ${String(result.stderr ?? "").trim()}`);
|
|
441
|
+
return String(result.stdout).trim();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** @param {string} repo @param {string} first @param {string} second @param {string} output @returns {string[]} */
|
|
445
|
+
function conflictPaths(repo, first, second, output) {
|
|
446
|
+
const paths = new Set();
|
|
447
|
+
for (const match of output.matchAll(/(?:CONFLICT[^\n]*|Merge conflict in)\s+([^\n]+)/gi)) paths.add(match[1].trim());
|
|
448
|
+
try {
|
|
449
|
+
const base = git(repo, ["merge-base", first, second]);
|
|
450
|
+
const left = new Set(git(repo, ["diff", "--name-only", base, first]).split("\n").filter(Boolean));
|
|
451
|
+
for (const path of git(repo, ["diff", "--name-only", base, second]).split("\n").filter(Boolean)) {
|
|
452
|
+
if (left.has(path)) paths.add(path);
|
|
453
|
+
}
|
|
454
|
+
} catch (error) {
|
|
455
|
+
// Two candidates with no common ancestor cannot be diffed against a base;
|
|
456
|
+
// the paths already parsed from the merge output above are still
|
|
457
|
+
// reported. Anything else is a git defect worth surfacing.
|
|
458
|
+
const stderr = /** @type {{stderr?: unknown}} */ (error).stderr;
|
|
459
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
460
|
+
if (!/no merge base|unrelated histories/iu.test(String(stderr ?? message))) throw error;
|
|
461
|
+
}
|
|
462
|
+
return [...paths].sort();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** @param {string} repo @param {string} runDir @param {string} runId @returns {void} */
|
|
466
|
+
function deleteCandidateWorktree(repo, runDir, runId) {
|
|
467
|
+
const path = candidateWorktreePath(runDir, runId);
|
|
468
|
+
removeWorktree(repo, path);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** @param {((stage: string) => void)|undefined} interrupt @param {string} stage @returns {void} */
|
|
472
|
+
function interruptStage(interrupt, stage) {
|
|
473
|
+
interrupt?.(stage);
|
|
474
|
+
if (process.env.FABERUN_INTEGRATION_INTERRUPT === stage) {
|
|
475
|
+
throw new Error(`integration interrupted at ${stage}`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Fast-forward a campaign's landing branch onto a run's integrated ref. This
|
|
481
|
+
* is the only place a branch other than the per-run ref moves, and it is
|
|
482
|
+
* deliberately narrow: it never force-updates, never moves a branch that a
|
|
483
|
+
* live worktree has checked out, and refuses `main` unless the operator said
|
|
484
|
+
* so. It is idempotent and durable: when the branch already contains the run
|
|
485
|
+
* head it reports `already_promoted` and still re-emits the record, so a crash
|
|
486
|
+
* between the ref move and the campaign write is repaired on re-invocation
|
|
487
|
+
* rather than repeated.
|
|
488
|
+
*
|
|
489
|
+
* @param {{repo: string, runId: string, landBranch: string, runHead?: string|null, baseSha?: string|null, finalVerificationPassed: boolean, allowMain?: boolean, onPromoted?: (record: PromoteRecord) => void}} args
|
|
490
|
+
* @returns {PromoteRecord}
|
|
491
|
+
*/
|
|
492
|
+
export function promoteRun({ repo, runId, landBranch, runHead, baseSha, finalVerificationPassed, allowMain = false, onPromoted }) {
|
|
493
|
+
if (finalVerificationPassed !== true) {
|
|
494
|
+
throw refusal(`refusing to promote ${runId}: final verification is not green`, "final_verification_not_green");
|
|
495
|
+
}
|
|
496
|
+
if (typeof landBranch !== "string" || !landBranch.trim()) {
|
|
497
|
+
throw refusal(`refusing to promote ${runId}: no landing branch is configured`, "land_branch_missing");
|
|
498
|
+
}
|
|
499
|
+
if (landBranch === "main" && !allowMain) {
|
|
500
|
+
throw refusal(`refusing to promote ${runId} onto main without the explicit operator authorization (--allow-main)`, "land_branch_main_requires_flag");
|
|
501
|
+
}
|
|
502
|
+
const head = runHead ?? gitHead(repo, runRefName(runId));
|
|
503
|
+
if (!head) throw refusal(`refusing to promote ${runId}: the run ref is unavailable`, "run_ref_missing");
|
|
504
|
+
const checkedOutAt = worktreeCheckedOutAt(repo, landBranch);
|
|
505
|
+
if (checkedOutAt) {
|
|
506
|
+
throw refusal(`refusing to promote ${landBranch}: it is checked out in ${checkedOutAt}`, "land_branch_checked_out");
|
|
507
|
+
}
|
|
508
|
+
const branchRef = `refs/heads/${landBranch}`;
|
|
509
|
+
const current = gitHead(repo, branchRef);
|
|
510
|
+
const at = new Date().toISOString();
|
|
511
|
+
if (current !== null && (current === head || isAncestor(repo, head, current))) {
|
|
512
|
+
/** @type {PromoteRecord} */
|
|
513
|
+
const record = { runId, status: "already_promoted", branch: landBranch, sha: current, previousSha: current, at };
|
|
514
|
+
onPromoted?.(record);
|
|
515
|
+
return record;
|
|
516
|
+
}
|
|
517
|
+
if (current !== null && !isAncestor(repo, current, head)) {
|
|
518
|
+
throw refusal(`refusing to promote ${landBranch}: ${head} is not a fast-forward of ${current}; the chain never force-updates`, "land_branch_not_fast_forward");
|
|
519
|
+
}
|
|
520
|
+
let previousSha = current;
|
|
521
|
+
if (current === null) {
|
|
522
|
+
if (!baseSha) {
|
|
523
|
+
throw refusal(`refusing to promote ${landBranch}: the branch does not exist and the run has no recorded gitHead to create it at`, "land_branch_base_missing");
|
|
524
|
+
}
|
|
525
|
+
if (baseSha !== head && !isAncestor(repo, baseSha, head)) {
|
|
526
|
+
throw refusal(`refusing to promote ${landBranch}: the first run's gitHead ${baseSha} is not an ancestor of ${head}`, "land_branch_not_fast_forward");
|
|
527
|
+
}
|
|
528
|
+
git(repo, ["update-ref", branchRef, baseSha]);
|
|
529
|
+
previousSha = baseSha;
|
|
530
|
+
}
|
|
531
|
+
updateRefConditional(repo, branchRef, head, /** @type {string} */ (previousSha));
|
|
532
|
+
/** @type {PromoteRecord} */
|
|
533
|
+
const record = { runId, status: "promoted", branch: landBranch, sha: head, previousSha, at };
|
|
534
|
+
onPromoted?.(record);
|
|
535
|
+
return record;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** @param {string} message @param {string} code @returns {Error} */
|
|
539
|
+
function refusal(message, code) {
|
|
540
|
+
return Object.assign(new Error(message), { code });
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** @param {string} path @returns {string} */
|
|
544
|
+
function readTextFile(path) {
|
|
545
|
+
return readFileSync(path, "utf8");
|
|
546
|
+
}
|