ai-foreman 1.7.5 → 1.7.7
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 +17 -2
- package/dist/activity.d.ts +3 -0
- package/dist/activity.js +29 -4
- package/dist/adapters/claude.d.ts +33 -2
- package/dist/adapters/claude.js +208 -17
- package/dist/adapters/codex.d.ts +11 -1
- package/dist/adapters/codex.js +195 -20
- package/dist/adapters/recovering.d.ts +8 -2
- package/dist/adapters/recovering.js +23 -3
- package/dist/adapters/sessionFailure.d.ts +18 -0
- package/dist/adapters/sessionFailure.js +51 -0
- package/dist/adapters/types.d.ts +52 -9
- package/dist/agentRun.d.ts +4 -1
- package/dist/agentRun.js +96 -9
- package/dist/branch/currentGuard.d.ts +34 -0
- package/dist/branch/currentGuard.js +65 -0
- package/dist/branch/github.js +3 -2
- package/dist/branch/gitlab.js +3 -2
- package/dist/branch/planner.d.ts +5 -0
- package/dist/branch/planner.js +37 -3
- package/dist/branch/prefix.d.ts +8 -0
- package/dist/branch/prefix.js +26 -0
- package/dist/branch/resume.d.ts +2 -0
- package/dist/branch/resume.js +8 -0
- package/dist/branch/runner.d.ts +14 -2
- package/dist/branch/runner.js +77 -17
- package/dist/buildRuns.d.ts +51 -18
- package/dist/buildRuns.js +318 -31
- package/dist/cli/handoffs.d.ts +2 -0
- package/dist/cli/handoffs.js +74 -0
- package/dist/cli/start.js +1040 -62
- package/dist/cli/tickets.d.ts +10 -1
- package/dist/cli/tickets.js +615 -80
- package/dist/continuity.d.ts +86 -0
- package/dist/continuity.js +365 -0
- package/dist/foreman.d.ts +9 -5
- package/dist/foreman.js +76 -20
- package/dist/handoffs.d.ts +69 -0
- package/dist/handoffs.js +326 -0
- package/dist/log.d.ts +1 -1
- package/dist/permissions/policy.d.ts +5 -1
- package/dist/permissions/policy.js +48 -1
- package/dist/qaReview.d.ts +7 -6
- package/dist/qaReview.js +31 -20
- package/dist/sessionAvailability.d.ts +15 -0
- package/dist/sessionAvailability.js +57 -0
- package/dist/sessionIdentity.d.ts +33 -0
- package/dist/sessionIdentity.js +132 -0
- package/dist/sessionLifecycle.d.ts +102 -3
- package/dist/sessionLifecycle.js +578 -20
- package/dist/statusReporter.d.ts +58 -8
- package/dist/statusReporter.js +210 -14
- package/dist/ticketPlanning.js +8 -0
- package/dist/ticketPopulation.js +88 -12
- package/dist/tickets/commands.js +20 -12
- package/dist/tickets/details.d.ts +17 -0
- package/dist/tickets/details.js +94 -0
- package/dist/tickets/eligibility.d.ts +28 -0
- package/dist/tickets/eligibility.js +71 -0
- package/dist/tickets/groupReset.d.ts +25 -0
- package/dist/tickets/groupReset.js +279 -0
- package/dist/tickets/groups.d.ts +36 -0
- package/dist/tickets/groups.js +68 -0
- package/dist/tickets/importer.js +27 -3
- package/dist/tickets/recommendations.js +31 -7
- package/dist/tickets/reset.d.ts +28 -0
- package/dist/tickets/reset.js +214 -0
- package/dist/tickets/reviewStandards.d.ts +7 -0
- package/dist/tickets/reviewStandards.js +53 -0
- package/dist/tickets/setupConfig.d.ts +23 -0
- package/dist/tickets/setupConfig.js +115 -3
- package/dist/tickets/stateDb.d.ts +42 -1
- package/dist/tickets/stateDb.js +261 -23
- package/dist/tickets/ticketLoader.js +5 -2
- package/dist/tickets/validate.js +4 -0
- package/dist/workflowDb.d.ts +102 -2
- package/dist/workflowDb.js +460 -8
- package/package.json +35 -3
package/dist/buildRuns.js
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { hostname } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import Database from "better-sqlite3";
|
|
5
7
|
import { WorkflowDb } from "./workflowDb.js";
|
|
6
|
-
|
|
8
|
+
import { canonicalSessionPath, captureWorkspaceIdentity, createProviderSessionRef, latestSessionBinding, upsertSessionBinding } from "./sessionIdentity.js";
|
|
9
|
+
import { resolveProviderSessionAvailability } from "./sessionAvailability.js";
|
|
10
|
+
import { captureCurrentWorkflowSessionIdentity, currentWorkflowIdentityKey } from "./branch/currentGuard.js";
|
|
11
|
+
import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
|
|
12
|
+
import { loadTickets } from "./tickets/ticketLoader.js";
|
|
13
|
+
export const BUILD_RUN_VERSION = 2;
|
|
7
14
|
export const BUILD_RUN_DIRECTORY = ".foreman/runs";
|
|
8
15
|
export const BUILD_HEARTBEAT_MS = 10_000;
|
|
9
16
|
export const BUILD_LEASE_STALE_MS = 45_000;
|
|
10
17
|
export function createBuildRun(input) {
|
|
11
18
|
const now = input.now ?? new Date();
|
|
12
19
|
const stamp = now.toISOString();
|
|
20
|
+
const worktree = resolve(input.worktree ?? input.repositoryRoot);
|
|
21
|
+
const snapshot = captureGitSnapshot(worktree, input);
|
|
13
22
|
const run = {
|
|
14
|
-
version:
|
|
23
|
+
version: 2,
|
|
15
24
|
runId: randomUUID(),
|
|
16
25
|
status: "running",
|
|
17
26
|
tickets: [...input.tickets],
|
|
@@ -23,12 +32,15 @@ export function createBuildRun(input) {
|
|
|
23
32
|
qa: input.qa ? { settings: normalizeCapturedSettings(input.qa) } : undefined,
|
|
24
33
|
repository: {
|
|
25
34
|
root: resolve(input.repositoryRoot),
|
|
26
|
-
worktree
|
|
27
|
-
branch: input.branch,
|
|
28
|
-
baseHead: input.baseHead,
|
|
29
|
-
startHead: input.startHead,
|
|
30
|
-
|
|
35
|
+
worktree,
|
|
36
|
+
branch: input.branch ?? snapshot.branch,
|
|
37
|
+
baseHead: input.baseHead ?? snapshot.baselineHead,
|
|
38
|
+
startHead: input.startHead ?? snapshot.startHead,
|
|
39
|
+
git: snapshot,
|
|
40
|
+
baselineComplete: Boolean(snapshot.baselineHead && snapshot.startHead && snapshot.branch),
|
|
31
41
|
},
|
|
42
|
+
progress: { completedTickets: [], completedOperations: [], remainingTickets: [...input.tickets], nextAction: input.tickets[0] ? `Start ${input.tickets[0]}` : "Plan next work" },
|
|
43
|
+
runDecisions: input.runDecisions,
|
|
32
44
|
receipts: {},
|
|
33
45
|
lease: currentLease(now),
|
|
34
46
|
createdAt: stamp,
|
|
@@ -57,21 +69,38 @@ export function resumeBuildRun(projectDir, runId, patch, now = new Date()) {
|
|
|
57
69
|
finally {
|
|
58
70
|
workflow.close();
|
|
59
71
|
}
|
|
72
|
+
const clearBuilderSession = patch.builderSessionId === null && patch.builderSessionRef === null;
|
|
73
|
+
const builderSessionId = clearBuilderSession
|
|
74
|
+
? undefined
|
|
75
|
+
: patch.builderSessionRef?.sessionId ?? patch.builderSessionId ?? existing.builder?.sessionId;
|
|
60
76
|
return saveBuildRun(projectDir, {
|
|
61
77
|
...existing, status: "running", checkpoint: "recovery-resumed", completedAt: undefined,
|
|
62
|
-
builder: patch.builder ? { settings: normalizeCapturedSettings(patch.builder), ...(
|
|
78
|
+
builder: patch.builder ? { settings: normalizeCapturedSettings(patch.builder), ...(builderSessionId ? { sessionId: builderSessionId } : {}) } : existing.builder,
|
|
63
79
|
qa: patch.qa ? { settings: normalizeCapturedSettings(patch.qa), sessionId: existing.qa?.sessionId } : existing.qa,
|
|
80
|
+
sessionBindings: patch.builderSessionRef ? upsertSessionBinding(existing.sessionBindings, patch.builderSessionRef) : existing.sessionBindings,
|
|
64
81
|
lease: currentLease(now),
|
|
65
82
|
}, now);
|
|
66
83
|
}
|
|
67
84
|
function normalizeCapturedSettings(settings) {
|
|
68
|
-
return {
|
|
85
|
+
return {
|
|
86
|
+
...settings,
|
|
87
|
+
session_strategy: settings.session_strategy ?? (["builder", "qa", "ticket-maker"].includes(settings.role) ? "compact" : "fresh"),
|
|
88
|
+
display_session_cost: settings.display_session_cost ?? false,
|
|
89
|
+
auto_compact_threshold_percent: settings.auto_compact_threshold_percent ?? 50,
|
|
90
|
+
compact_maximum: settings.compact_maximum ?? 10,
|
|
91
|
+
settings_revision: settings.settings_revision ?? 0,
|
|
92
|
+
};
|
|
69
93
|
}
|
|
70
94
|
export function saveBuildRun(projectDir, run, now = new Date()) {
|
|
71
95
|
validateBuildRun(run);
|
|
72
96
|
const directory = join(resolve(projectDir), BUILD_RUN_DIRECTORY);
|
|
73
97
|
mkdirSync(directory, { recursive: true });
|
|
74
|
-
const
|
|
98
|
+
const completedOperations = Object.keys(run.receipts).sort();
|
|
99
|
+
const next = {
|
|
100
|
+
...run,
|
|
101
|
+
progress: { ...run.progress, completedOperations, remainingTickets: remainingTickets(run) },
|
|
102
|
+
updatedAt: now.toISOString(),
|
|
103
|
+
};
|
|
75
104
|
const target = join(directory, `${run.runId}.json`);
|
|
76
105
|
const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
77
106
|
writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
@@ -91,9 +120,9 @@ export function saveBuildRun(projectDir, run, now = new Date()) {
|
|
|
91
120
|
remainingWork: { tickets: remainingTickets(next) }, state: next, event: "build_snapshot",
|
|
92
121
|
}, now);
|
|
93
122
|
if (next.builder?.sessionId)
|
|
94
|
-
workflow.recordSession(next.runId, "builder", "builder", next.builder.sessionId, "checkpoint", next.builder.settings, now);
|
|
123
|
+
workflow.recordSession(next.runId, "builder", "builder", latestSessionBinding(next.sessionBindings, "builder", next.builder.sessionId) ?? next.builder.sessionId, "checkpoint", next.builder.settings, now);
|
|
95
124
|
if (next.qa?.sessionId)
|
|
96
|
-
workflow.recordSession(next.runId, "qa", "qa", next.qa.sessionId, "checkpoint", next.qa.settings, now);
|
|
125
|
+
workflow.recordSession(next.runId, "qa", "qa", latestSessionBinding(next.sessionBindings, "qa", next.qa.sessionId) ?? next.qa.sessionId, "checkpoint", next.qa.settings, now);
|
|
97
126
|
}
|
|
98
127
|
finally {
|
|
99
128
|
workflow.close();
|
|
@@ -103,11 +132,40 @@ export function saveBuildRun(projectDir, run, now = new Date()) {
|
|
|
103
132
|
export function checkpointBuildRun(projectDir, run, checkpoint, patch = {}) {
|
|
104
133
|
return saveBuildRun(projectDir, { ...run, ...patch, checkpoint, receipts: patch.receipts ?? run.receipts });
|
|
105
134
|
}
|
|
106
|
-
export function persistBuildSession(projectDir, run, role,
|
|
135
|
+
export function persistBuildSession(projectDir, run, role, session) {
|
|
107
136
|
const current = run[role];
|
|
108
137
|
if (!current)
|
|
109
138
|
throw new Error(`${role} settings were not captured for run ${run.runId}`);
|
|
110
|
-
|
|
139
|
+
const sessionId = typeof session === "string" ? session : session.sessionId;
|
|
140
|
+
const sessionBindings = typeof session === "string" ? run.sessionBindings : upsertSessionBinding(run.sessionBindings, session);
|
|
141
|
+
return checkpointBuildRun(projectDir, { ...run, sessionBindings, [role]: { ...current, sessionId } }, `${role}-session-ready`);
|
|
142
|
+
}
|
|
143
|
+
/** Latest scoped binding, with the narrow legacy Builder inference allowed by the recovery protocol. */
|
|
144
|
+
export function buildRunSessionBinding(run, role, sessionId) {
|
|
145
|
+
const bound = latestSessionBinding(run.sessionBindings, role, sessionId);
|
|
146
|
+
if (bound)
|
|
147
|
+
return bound;
|
|
148
|
+
if (role === "qa")
|
|
149
|
+
return undefined;
|
|
150
|
+
const raw = sessionId ?? run.builder?.sessionId;
|
|
151
|
+
if (!raw || !run.builder || !existsSync(run.repository.worktree))
|
|
152
|
+
return undefined;
|
|
153
|
+
return createProviderSessionRef({
|
|
154
|
+
provider: run.builder.settings.make,
|
|
155
|
+
sessionId: raw,
|
|
156
|
+
role: "builder",
|
|
157
|
+
stream: "builder",
|
|
158
|
+
generation: 0,
|
|
159
|
+
cwd: run.repository.worktree,
|
|
160
|
+
configRoot: run.repository.root,
|
|
161
|
+
workspaceIdentity: run.branchMode === "current" && run.repository.git.branch
|
|
162
|
+
? currentWorkflowIdentityKey({ worktree: canonicalSessionPath(run.repository.worktree), ref: `branch:${run.repository.git.branch}` })
|
|
163
|
+
: run.repository.git.worktreeIdentity ?? captureWorkspaceIdentity(run.repository.worktree),
|
|
164
|
+
ticketId: run.currentTicket,
|
|
165
|
+
deliveryUnitId: run.deliveryUnit,
|
|
166
|
+
source: "legacy-inferred",
|
|
167
|
+
createdAt: run.createdAt,
|
|
168
|
+
});
|
|
111
169
|
}
|
|
112
170
|
export function recordBuildReceipt(projectDir, run, operationId, detail) {
|
|
113
171
|
if (run.receipts[operationId])
|
|
@@ -145,7 +203,7 @@ export function releaseBuildLease(projectDir, run, status = "interrupted") {
|
|
|
145
203
|
return saved;
|
|
146
204
|
}
|
|
147
205
|
export function completeBuildRun(projectDir, run, now = new Date()) {
|
|
148
|
-
const saved = saveBuildRun(projectDir, { ...run, status: "completed", checkpoint: "complete", lease: undefined, completedAt: now.toISOString() }, now);
|
|
206
|
+
const saved = saveBuildRun(projectDir, { ...run, status: "completed", checkpoint: "complete", lease: undefined, completedAt: now.toISOString(), progress: { ...run.progress, completedTickets: [...run.tickets], remainingTickets: [], nextAction: "None; build complete" } }, now);
|
|
149
207
|
releaseWorkflowLease(projectDir, run.runId, now);
|
|
150
208
|
return saved;
|
|
151
209
|
}
|
|
@@ -157,7 +215,7 @@ export function readBuildRuns(projectDir) {
|
|
|
157
215
|
try {
|
|
158
216
|
const run = JSON.parse(readFileSync(join(directory, name), "utf8"));
|
|
159
217
|
validateBuildRun(run);
|
|
160
|
-
return [run];
|
|
218
|
+
return [upgradeBuildRun(run)];
|
|
161
219
|
}
|
|
162
220
|
catch {
|
|
163
221
|
return [];
|
|
@@ -167,7 +225,42 @@ export function readBuildRuns(projectDir) {
|
|
|
167
225
|
export function recoverableBuildRuns(projectDir, now = new Date()) {
|
|
168
226
|
return readBuildRuns(projectDir)
|
|
169
227
|
.filter((run) => run.status !== "completed")
|
|
170
|
-
.map((run) => ({ ...run, active: isLeaseActive(run, now) }));
|
|
228
|
+
.map((run) => ({ ...inferLegacyRunTickets(projectDir, run), active: isLeaseActive(run, now) }));
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Current-branch runs created before ticket checkpointing did not retain their
|
|
232
|
+
* selected ticket. Recover it from Foreman's tracker events so --ticket can
|
|
233
|
+
* still address those durable runs without rewriting the legacy record.
|
|
234
|
+
*/
|
|
235
|
+
function inferLegacyRunTickets(projectDir, run) {
|
|
236
|
+
if (run.tickets.length > 0 || !isTicketsInitialized(projectDir))
|
|
237
|
+
return run;
|
|
238
|
+
let db;
|
|
239
|
+
try {
|
|
240
|
+
const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
|
|
241
|
+
if (!existsSync(paths.stateDb))
|
|
242
|
+
return run;
|
|
243
|
+
db = new Database(paths.stateDb, { readonly: true, fileMustExist: true });
|
|
244
|
+
const rows = db.prepare(`
|
|
245
|
+
SELECT ticket_id
|
|
246
|
+
FROM ticket_events
|
|
247
|
+
WHERE actor = 'foreman'
|
|
248
|
+
AND ticket_id IS NOT NULL
|
|
249
|
+
AND julianday(timestamp) >= julianday(?)
|
|
250
|
+
AND julianday(timestamp) <= julianday(?)
|
|
251
|
+
ORDER BY timestamp, id
|
|
252
|
+
`).all(run.createdAt, run.updatedAt);
|
|
253
|
+
const tickets = [...new Set(rows.map((row) => row.ticket_id).filter(Boolean))];
|
|
254
|
+
if (tickets.length === 0)
|
|
255
|
+
return run;
|
|
256
|
+
return { ...run, tickets, currentTicket: tickets.at(-1) };
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return run;
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
db?.close();
|
|
263
|
+
}
|
|
171
264
|
}
|
|
172
265
|
export function isLeaseActive(run, now = new Date()) {
|
|
173
266
|
if (!run.lease)
|
|
@@ -193,34 +286,146 @@ export function buildRecoveryPreview(run) {
|
|
|
193
286
|
`checkpoint ${run.checkpoint}`,
|
|
194
287
|
`worktree ${run.repository.worktree}`,
|
|
195
288
|
`branch ${run.repository.branch ?? "current"}`,
|
|
196
|
-
`
|
|
289
|
+
`baseline ${run.repository.baselineComplete ? `${run.repository.git.baseRef ?? "recorded"} @ ${run.repository.git.baselineHead}` : "incomplete legacy baseline"}`,
|
|
290
|
+
`preserved paths ${run.repository.git.runOwnedPaths.join(", ") || run.repository.git.statusPaths.join(", ") || "none recorded"}`,
|
|
197
291
|
`completed operations ${completed.length ? completed.join(", ") : "none"}`,
|
|
198
|
-
`session ${run
|
|
199
|
-
`QA session ${run
|
|
292
|
+
`session ${buildRunSessionBinding(run, "builder") ? "Builder session candidate requires validation" : "fresh Builder session required"}`,
|
|
293
|
+
`QA session ${buildRunSessionBinding(run, "qa") ? "QA session candidate requires validation" : "fresh QA session required"}`,
|
|
200
294
|
];
|
|
201
295
|
}
|
|
202
|
-
function
|
|
203
|
-
|
|
296
|
+
export function projectBuildRecovery(projectDir, run, now = new Date(), ticketOverride, sessionAvailability) {
|
|
297
|
+
let operationNames = [];
|
|
298
|
+
let issues = [];
|
|
299
|
+
let workflowState = {};
|
|
300
|
+
if (existsSync(join(resolve(projectDir), ".rafi", "recovery.sqlite3"))) {
|
|
301
|
+
const workflow = new WorkflowDb(projectDir);
|
|
302
|
+
try {
|
|
303
|
+
operationNames = workflow.operations(run.runId).filter((operation) => operation.status === "confirmed").map((operation) => operation.kind);
|
|
304
|
+
issues = workflow.issues(run.runId).map((issue) => issue.detail);
|
|
305
|
+
workflowState = workflow.getRun(run.runId)?.state ?? {};
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
workflow.close();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const stateTicket = typeof workflowState.currentTicket === "string" ? workflowState.currentTicket : undefined;
|
|
312
|
+
const ticketId = ticketOverride ?? stateTicket ?? run.currentTicket ?? run.progress.remainingTickets[0] ?? run.tickets[0];
|
|
313
|
+
let ticketTitle;
|
|
314
|
+
if (ticketId && isTicketsInitialized(projectDir)) {
|
|
315
|
+
try {
|
|
316
|
+
const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
|
|
317
|
+
ticketTitle = loadTickets(paths.tickets).find((ticket) => ticket.id === ticketId)?.title;
|
|
318
|
+
}
|
|
319
|
+
catch { /* durable run data remains enough for a legacy preview */ }
|
|
320
|
+
}
|
|
321
|
+
const stateWorktree = typeof workflowState.worktree === "string" ? workflowState.worktree : undefined;
|
|
322
|
+
const recoveryWorktree = stateWorktree && existsSync(stateWorktree) ? stateWorktree : run.repository.worktree;
|
|
323
|
+
const stateBranch = typeof workflowState.branch === "string" ? workflowState.branch : undefined;
|
|
324
|
+
const expectedChanges = gitStatusPaths(recoveryWorktree);
|
|
325
|
+
const baseChanges = resolve(run.repository.root) === resolve(recoveryWorktree) ? [] : gitStatusPaths(run.repository.root);
|
|
326
|
+
const identityChanged = recoveryWorktree === run.repository.worktree && run.repository.git.worktreeIdentity && worktreeIdentity(recoveryWorktree) !== run.repository.git.worktreeIdentity;
|
|
327
|
+
const branchNow = gitValue(recoveryWorktree, ["branch", "--show-current"]);
|
|
328
|
+
const expectedBranch = stateBranch ?? run.repository.git.branch;
|
|
329
|
+
const branchChanged = Boolean(expectedBranch && branchNow && branchNow !== expectedBranch);
|
|
330
|
+
const unexpectedChanges = baseChanges.map((path) => ({ path, risk: "base-worktree changes may conflict with recovery" }));
|
|
331
|
+
if (identityChanged)
|
|
332
|
+
unexpectedChanges.push({ path: recoveryWorktree, risk: "preserved worktree identity changed" });
|
|
333
|
+
if (branchChanged)
|
|
334
|
+
unexpectedChanges.push({ path: recoveryWorktree, risk: `branch changed from ${expectedBranch} to ${branchNow}` });
|
|
335
|
+
const completed = [...new Set([...run.progress.completedTickets, ...run.progress.completedOperations, ...operationNames])];
|
|
336
|
+
const remaining = run.progress.remainingTickets.length ? run.progress.remainingTickets : remainingTickets(run);
|
|
337
|
+
const interruption = run.interruption?.lastError ?? run.interruption?.summary ?? run.failure?.summary ?? issues.at(-1) ?? run.status;
|
|
338
|
+
const validation = run.progress.validation
|
|
339
|
+
? `${run.progress.validation.status}${run.progress.validation.qa ? `; QA ${run.progress.validation.qa}` : ""}`
|
|
340
|
+
: run.qa?.sessionId ? "QA session checkpointed" : "no completed QA/validation checkpoint recorded";
|
|
341
|
+
const nextAction = ticketOverride
|
|
342
|
+
? `Resume ${ticketOverride} from ${run.checkpoint}`
|
|
343
|
+
: run.progress.nextAction ?? (ticketId ? `Resume ${ticketId} from ${run.checkpoint}` : `Resume from ${run.checkpoint}`);
|
|
344
|
+
const primary = ticketId ? `${ticketId}${ticketTitle ? `: ${ticketTitle}` : ""}` : "legacy run (ticket unavailable)";
|
|
345
|
+
const stateSessionId = typeof workflowState.sessionId === "string" ? workflowState.sessionId : undefined;
|
|
346
|
+
const sessionCandidateRef = buildRunSessionBinding(run, "builder", stateSessionId ?? run.builder?.sessionId);
|
|
347
|
+
const exactSessionRef = sessionAvailability?.status === "available" ? sessionAvailability.sessionRef ?? sessionCandidateRef : undefined;
|
|
348
|
+
return {
|
|
349
|
+
run, ticketId, ticketTitle,
|
|
350
|
+
compactLabel: `${primary} — ${run.status}`,
|
|
351
|
+
compactHint: `${run.builder?.settings.make ?? "runtime unavailable"}; ${stateBranch ?? branchNow ?? run.repository.branch ?? "current branch"}; updated ${relativeTime(run.updatedAt, now)}`,
|
|
352
|
+
completed, remaining,
|
|
353
|
+
lastSuccess: run.progress.lastSuccessfulAction ?? run.checkpoint,
|
|
354
|
+
interruption,
|
|
355
|
+
validation,
|
|
356
|
+
expectedChanges,
|
|
357
|
+
unexpectedChanges,
|
|
358
|
+
nextAction,
|
|
359
|
+
worktree: recoveryWorktree,
|
|
360
|
+
branch: stateBranch ?? branchNow ?? run.repository.git.branch ?? run.repository.branch,
|
|
361
|
+
...(sessionCandidateRef ? { sessionCandidateRef } : {}),
|
|
362
|
+
...(sessionAvailability ? { sessionAvailability } : {}),
|
|
363
|
+
...(exactSessionRef ? { exactSessionRef, exactSessionId: exactSessionRef.sessionId } : {}),
|
|
364
|
+
};
|
|
204
365
|
}
|
|
205
|
-
function
|
|
366
|
+
export async function resolveBuildRecoveryProjection(projectDir, run, now = new Date(), ticketOverride, probe = {}) {
|
|
367
|
+
const frozen = projectBuildRecovery(projectDir, run, now, ticketOverride);
|
|
368
|
+
const candidate = frozen.sessionCandidateRef;
|
|
369
|
+
if (!candidate)
|
|
370
|
+
return frozen;
|
|
371
|
+
const availability = await resolveProviderSessionAvailability(candidate, {
|
|
372
|
+
...probe,
|
|
373
|
+
cwd: frozen.worktree,
|
|
374
|
+
configRoot: run.repository.root,
|
|
375
|
+
workspaceIdentity: run.branchMode === "current" ? safeCurrentIdentity(frozen.worktree) : captureWorkspaceIdentity(frozen.worktree),
|
|
376
|
+
now,
|
|
377
|
+
});
|
|
378
|
+
return projectBuildRecovery(projectDir, run, now, ticketOverride, availability);
|
|
379
|
+
}
|
|
380
|
+
function safeCurrentIdentity(worktree) {
|
|
206
381
|
try {
|
|
207
|
-
return
|
|
382
|
+
return captureCurrentWorkflowSessionIdentity(worktree);
|
|
208
383
|
}
|
|
209
384
|
catch {
|
|
210
|
-
return
|
|
385
|
+
return undefined;
|
|
211
386
|
}
|
|
212
387
|
}
|
|
213
|
-
function
|
|
388
|
+
export function formatBuildRecoveryProjection(projection) {
|
|
389
|
+
const { run } = projection;
|
|
390
|
+
const lines = [
|
|
391
|
+
`run ${run.runId}`,
|
|
392
|
+
`ticket ${projection.ticketId ?? "unavailable"}${projection.ticketTitle ? `: ${projection.ticketTitle}` : ""}`,
|
|
393
|
+
`status ${run.status}; checkpoint ${run.checkpoint}`,
|
|
394
|
+
`completed ${projection.completed.join(", ") || "none recorded"}`,
|
|
395
|
+
`current ${run.progress.currentStep ?? projection.ticketId ?? "not recorded"}`,
|
|
396
|
+
`remaining ${projection.remaining.join(", ") || "none"}`,
|
|
397
|
+
`last successful action ${projection.lastSuccess}`,
|
|
398
|
+
`interruption/failure ${projection.interruption}`,
|
|
399
|
+
`validation/QA ${projection.validation}`,
|
|
400
|
+
`branch ${projection.branch ?? "current"}; worktree ${projection.worktree}`,
|
|
401
|
+
`baseline ${run.repository.baselineComplete ? `${run.repository.git.baseRef ?? "recorded"} @ ${run.repository.git.baselineHead}` : "incomplete; automatic rollback is unavailable"}`,
|
|
402
|
+
`session ${projection.exactSessionId ? "exact Builder session available (validated)" : projection.sessionCandidateRef ? `exact Builder session unavailable (${projection.sessionAvailability?.reason ?? "validation pending"})` : "fresh Builder session required"}`,
|
|
403
|
+
`runtime ${run.builder?.settings.make ?? "unavailable"}; model ${run.builder?.settings.model ?? "unavailable"}`,
|
|
404
|
+
`updated ${run.updatedAt}`,
|
|
405
|
+
`preserved expected in-progress paths ${projection.expectedChanges.join(", ") || "none currently dirty"}`,
|
|
406
|
+
];
|
|
407
|
+
if (projection.unexpectedChanges.length) {
|
|
408
|
+
lines.push("WARNING: unexpected recovery state (recovery remains available):");
|
|
409
|
+
for (const item of projection.unexpectedChanges)
|
|
410
|
+
lines.push(` ${item.path}: ${item.risk}`);
|
|
411
|
+
lines.push("recommended action: preserve or move these changes before recovery if they overlap the interrupted work");
|
|
412
|
+
}
|
|
413
|
+
lines.push(`next action ${projection.nextAction}`);
|
|
414
|
+
return lines;
|
|
415
|
+
}
|
|
416
|
+
function currentLease(now) {
|
|
417
|
+
return { hostname: hostname(), pid: process.pid, processStart: processStartIdentity(process.pid), heartbeatAt: now.toISOString() };
|
|
418
|
+
}
|
|
419
|
+
function processStartIdentity(pid) {
|
|
214
420
|
try {
|
|
215
|
-
|
|
216
|
-
return createHash("sha256").update(`${stat.dev}:${stat.ino}:${stat.mtimeMs}`).digest("hex");
|
|
421
|
+
return statSync(`/proc/${pid}`).birthtimeMs.toString();
|
|
217
422
|
}
|
|
218
423
|
catch {
|
|
219
|
-
return
|
|
424
|
+
return pid === process.pid ? `${Date.now() - Math.round(process.uptime() * 1000)}` : "unknown";
|
|
220
425
|
}
|
|
221
426
|
}
|
|
222
427
|
function validateBuildRun(run) {
|
|
223
|
-
if (run.version
|
|
428
|
+
if (![1, 2].includes(run.version) || !run.runId || !run.repository?.root || !run.repository.worktree || !run.createdAt || !run.updatedAt) {
|
|
224
429
|
throw new Error("invalid build run record");
|
|
225
430
|
}
|
|
226
431
|
}
|
|
@@ -229,6 +434,8 @@ function workflowStatus(status) {
|
|
|
229
434
|
return "completed";
|
|
230
435
|
if (status === "failed")
|
|
231
436
|
return "failed";
|
|
437
|
+
if (status === "superseded")
|
|
438
|
+
return "superseded";
|
|
232
439
|
if (status === "blocked")
|
|
233
440
|
return "blocked";
|
|
234
441
|
if (status === "interrupted" || status === "recoverable")
|
|
@@ -239,6 +446,86 @@ function remainingTickets(run) {
|
|
|
239
446
|
const current = run.currentTicket ? run.tickets.indexOf(run.currentTicket) : 0;
|
|
240
447
|
return run.status === "completed" ? [] : run.tickets.slice(Math.max(0, current));
|
|
241
448
|
}
|
|
449
|
+
function captureGitSnapshot(worktree, input) {
|
|
450
|
+
const git = (args) => {
|
|
451
|
+
try {
|
|
452
|
+
return execFileSync("git", args, { cwd: worktree, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
return undefined;
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
const statusPaths = (git(["status", "--porcelain=v1", "-z"]) ?? "").split("\0").filter(Boolean).map((line) => line.slice(3)).sort();
|
|
459
|
+
const head = input.startHead ?? git(["rev-parse", "HEAD"]);
|
|
460
|
+
const branch = input.branch ?? git(["branch", "--show-current"]);
|
|
461
|
+
return {
|
|
462
|
+
baselineHead: input.baseHead ?? head,
|
|
463
|
+
baseRef: input.baseRef ?? branch,
|
|
464
|
+
branch,
|
|
465
|
+
startHead: head,
|
|
466
|
+
worktree,
|
|
467
|
+
worktreeIdentity: worktreeIdentity(worktree),
|
|
468
|
+
statusPaths,
|
|
469
|
+
initialStatusPaths: [...statusPaths],
|
|
470
|
+
runOwnedPaths: [],
|
|
471
|
+
createdBranch: false,
|
|
472
|
+
createdWorktree: worktree !== resolve(input.repositoryRoot),
|
|
473
|
+
upstream: git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]),
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
function worktreeIdentity(worktree) {
|
|
477
|
+
return captureWorkspaceIdentity(worktree);
|
|
478
|
+
}
|
|
479
|
+
function gitValue(cwd, args) {
|
|
480
|
+
try {
|
|
481
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
return undefined;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
function gitStatusPaths(cwd) {
|
|
488
|
+
return (gitValue(cwd, ["status", "--porcelain=v1", "-z"]) ?? "").split("\0").filter(Boolean).map((line) => line.slice(3)).sort();
|
|
489
|
+
}
|
|
490
|
+
function relativeTime(value, now) {
|
|
491
|
+
const seconds = Math.max(0, Math.floor((now.getTime() - new Date(value).getTime()) / 1000));
|
|
492
|
+
if (seconds < 60)
|
|
493
|
+
return `${seconds}s ago`;
|
|
494
|
+
if (seconds < 3600)
|
|
495
|
+
return `${Math.floor(seconds / 60)}m ago`;
|
|
496
|
+
if (seconds < 86_400)
|
|
497
|
+
return `${Math.floor(seconds / 3600)}h ago`;
|
|
498
|
+
return `${Math.floor(seconds / 86_400)}d ago`;
|
|
499
|
+
}
|
|
500
|
+
function upgradeBuildRun(run) {
|
|
501
|
+
if (run.version === 2)
|
|
502
|
+
return run;
|
|
503
|
+
const legacy = run;
|
|
504
|
+
const remaining = legacy.status === "completed" ? [] : legacy.tickets.slice(Math.max(0, legacy.currentTicket ? legacy.tickets.indexOf(legacy.currentTicket) : 0));
|
|
505
|
+
return {
|
|
506
|
+
...legacy,
|
|
507
|
+
version: 2,
|
|
508
|
+
legacy: true,
|
|
509
|
+
repository: {
|
|
510
|
+
...legacy.repository,
|
|
511
|
+
baselineComplete: false,
|
|
512
|
+
git: {
|
|
513
|
+
baselineHead: legacy.repository.baseHead,
|
|
514
|
+
branch: legacy.repository.branch,
|
|
515
|
+
startHead: legacy.repository.startHead,
|
|
516
|
+
worktree: legacy.repository.worktree,
|
|
517
|
+
statusPaths: [], initialStatusPaths: [], runOwnedPaths: [], createdBranch: false, createdWorktree: false,
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
progress: {
|
|
521
|
+
completedTickets: legacy.status === "completed" ? [...legacy.tickets] : [],
|
|
522
|
+
completedOperations: Object.keys(legacy.receipts).sort(),
|
|
523
|
+
remainingTickets: remaining,
|
|
524
|
+
lastSuccessfulAction: legacy.checkpoint,
|
|
525
|
+
nextAction: remaining[0] ? `Resume ${remaining[0]}` : "Inspect legacy run",
|
|
526
|
+
},
|
|
527
|
+
};
|
|
528
|
+
}
|
|
242
529
|
function releaseWorkflowLease(projectDir, runId, now = new Date()) {
|
|
243
530
|
const workflow = new WorkflowDb(projectDir);
|
|
244
531
|
try {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { recoverableBuildRuns } from "../buildRuns.js";
|
|
4
|
+
import { HandoffService, writeHandoffInspection } from "../handoffs.js";
|
|
5
|
+
import { WorkflowDb } from "../workflowDb.js";
|
|
6
|
+
export function buildHandoffsCommand() {
|
|
7
|
+
const command = new Command("handoffs").description("Inspect and explicitly manage validated cumulative handoffs.");
|
|
8
|
+
command.command("inspect")
|
|
9
|
+
.requiredOption("--run <id>", "build run ID")
|
|
10
|
+
.option("--generation <n>", "handoff generation")
|
|
11
|
+
.option("--format <format>", "markdown or json", "markdown")
|
|
12
|
+
.option("--output <path>", "write inspection to a file")
|
|
13
|
+
.option("--project <dir>", "project directory", ".")
|
|
14
|
+
.action((opts) => {
|
|
15
|
+
const format = String(opts.format);
|
|
16
|
+
if (!["markdown", "json"].includes(format))
|
|
17
|
+
throw new Error("--format must be markdown or json");
|
|
18
|
+
const generation = opts.generation === undefined ? undefined : positiveInteger(opts.generation, "--generation");
|
|
19
|
+
const service = new HandoffService(resolve(String(opts.project)));
|
|
20
|
+
const value = service.inspect(String(opts.run), generation);
|
|
21
|
+
const content = format === "json"
|
|
22
|
+
? `${JSON.stringify({ version: 1, lineage: value.lineage, manifest: value.manifest }, null, 2)}\n`
|
|
23
|
+
: value.markdown;
|
|
24
|
+
if (opts.output) {
|
|
25
|
+
writeHandoffInspection(String(opts.output), content);
|
|
26
|
+
console.log(`rafi handoffs: wrote ${format} inspection to ${resolve(String(opts.output))}`);
|
|
27
|
+
}
|
|
28
|
+
else
|
|
29
|
+
process.stdout.write(content.endsWith("\n") ? content : `${content}\n`);
|
|
30
|
+
});
|
|
31
|
+
command.command("prune-cache")
|
|
32
|
+
.requiredOption("--run <id>", "build run ID")
|
|
33
|
+
.option("--keep-latest <n>", "number of newest cache generations to keep", "1")
|
|
34
|
+
.option("--project <dir>", "project directory", ".")
|
|
35
|
+
.action((opts) => {
|
|
36
|
+
const keep = nonNegativeInteger(opts.keepLatest, "--keep-latest");
|
|
37
|
+
const removed = new HandoffService(resolve(String(opts.project))).pruneCache(String(opts.run), keep);
|
|
38
|
+
console.log(`rafi handoffs: pruned ${removed.length} disposable cache generation(s); durable history was preserved`);
|
|
39
|
+
});
|
|
40
|
+
command.command("delete-history")
|
|
41
|
+
.requiredOption("--run <id>", "build run ID")
|
|
42
|
+
.requiredOption("--yes", "confirm irreversible durable history deletion")
|
|
43
|
+
.option("--project <dir>", "project directory", ".")
|
|
44
|
+
.action((opts) => {
|
|
45
|
+
const projectDir = resolve(String(opts.project));
|
|
46
|
+
const runId = String(opts.run);
|
|
47
|
+
const recoverable = recoverableBuildRuns(projectDir).find((run) => run.runId === runId);
|
|
48
|
+
if (recoverable)
|
|
49
|
+
throw new Error(`run ${runId} is active or recoverable; finish or explicitly clean up the run before deleting durable handoff history`);
|
|
50
|
+
const db = new WorkflowDb(projectDir);
|
|
51
|
+
try {
|
|
52
|
+
const deleted = db.deleteHandoffHistory(runId);
|
|
53
|
+
console.log(`rafi handoffs: deleted ${deleted} durable handoff generation(s) for run ${runId}`);
|
|
54
|
+
console.log(`rafi handoffs: disposable copies remain until pruned; run: rafi handoffs prune-cache --run ${shellQuote(runId)} --keep-latest 1`);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
db.close();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
return command;
|
|
61
|
+
}
|
|
62
|
+
function positiveInteger(value, label) {
|
|
63
|
+
const result = Number(value);
|
|
64
|
+
if (!Number.isSafeInteger(result) || result < 1)
|
|
65
|
+
throw new Error(`${label} must be a positive safe integer`);
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
function nonNegativeInteger(value, label) {
|
|
69
|
+
const result = Number(value);
|
|
70
|
+
if (!Number.isSafeInteger(result) || result < 0)
|
|
71
|
+
throw new Error(`${label} must be a non-negative safe integer`);
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
function shellQuote(value) { return `'${value.replace(/'/g, `'\\''`)}'`; }
|