ai-foreman 1.7.5 → 1.7.6
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/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 +42 -3
- package/dist/buildRuns.d.ts +41 -17
- package/dist/buildRuns.js +243 -22
- package/dist/cli/start.js +104 -24
- package/dist/cli/tickets.d.ts +10 -1
- package/dist/cli/tickets.js +418 -79
- package/dist/foreman.d.ts +4 -4
- package/dist/foreman.js +35 -14
- package/dist/qaReview.d.ts +1 -0
- package/dist/qaReview.js +11 -6
- 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/reset.d.ts +27 -0
- package/dist/tickets/reset.js +213 -0
- package/dist/tickets/reviewStandards.d.ts +7 -0
- package/dist/tickets/reviewStandards.js +53 -0
- package/dist/tickets/setupConfig.d.ts +22 -0
- package/dist/tickets/setupConfig.js +105 -1
- package/dist/tickets/stateDb.d.ts +8 -1
- package/dist/tickets/stateDb.js +54 -23
- package/dist/workflowDb.d.ts +3 -0
- package/dist/workflowDb.js +26 -0
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -112,7 +112,7 @@ What each part does:
|
|
|
112
112
|
| `--keep-worktrees` | flag | remove successful worktrees | Keeps successful ticket worktrees for inspection. Blocked worktrees are kept automatically. |
|
|
113
113
|
| `--ticket <id>` | `T001` | none | Selects a branch-mode ticket to continue. Repeatable with `--continue`. |
|
|
114
114
|
| `--base <ref>` | `main` / `origin/main` / `HEAD` | current branch or `HEAD` | Base ref for root ticket branches. |
|
|
115
|
-
| `--branch-prefix <prefix>` | `
|
|
115
|
+
| `--branch-prefix <prefix>` | `feature` / `rafi` / another valid Git prefix | `feature` | Prefix for generated ticket branches. Explicit prefixes are preserved verbatim. |
|
|
116
116
|
| `--max-branch-depth <n>` | positive integer | `5` | Maximum selected branch/PR stack depth (root PR is depth 1). |
|
|
117
117
|
|
|
118
118
|
Auto-detected tracker files:
|
package/dist/branch/github.js
CHANGED
|
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { branchNodeFooter } from "./presentation.js";
|
|
5
|
+
import { configuredReviewBody, configuredReviewTitle } from "../tickets/reviewStandards.js";
|
|
5
6
|
const DEFAULT_COMMAND_TIMEOUT_MS = 15_000;
|
|
6
7
|
const OUTPUT_LIMIT = 2_000;
|
|
7
8
|
function runCommand(cwd, bin, args, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS) {
|
|
@@ -134,7 +135,7 @@ export function createOrReusePr(cwd, opts) {
|
|
|
134
135
|
const bodyPath = join(cwd, ".foreman", "pr-bodies", opts.runId, `${opts.node.ticket.id}.md`);
|
|
135
136
|
try {
|
|
136
137
|
mkdirSync(join(cwd, ".foreman", "pr-bodies", opts.runId), { recursive: true });
|
|
137
|
-
writeFileSync(bodyPath,
|
|
138
|
+
writeFileSync(bodyPath, configuredReviewBody(cwd, opts.node, opts.qaEvidence, opts.commit), "utf8");
|
|
138
139
|
}
|
|
139
140
|
catch (err) {
|
|
140
141
|
return prFailure(prBodyWriteFailure(opts.runId, bodyPath, err));
|
|
@@ -147,7 +148,7 @@ export function createOrReusePr(cwd, opts) {
|
|
|
147
148
|
"--head",
|
|
148
149
|
opts.node.branch,
|
|
149
150
|
"--title",
|
|
150
|
-
|
|
151
|
+
configuredReviewTitle(cwd, opts.node.ticket),
|
|
151
152
|
"--body-file",
|
|
152
153
|
bodyPath,
|
|
153
154
|
];
|
package/dist/branch/gitlab.js
CHANGED
|
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { branchNodeFooter } from "./presentation.js";
|
|
5
|
+
import { configuredReviewBody, configuredReviewTitle } from "../tickets/reviewStandards.js";
|
|
5
6
|
const DEFAULT_COMMAND_TIMEOUT_MS = 15_000;
|
|
6
7
|
const OUTPUT_LIMIT = 2_000;
|
|
7
8
|
export function preflightGlab(cwd) {
|
|
@@ -55,7 +56,7 @@ export function createOrReuseMr(cwd, opts) {
|
|
|
55
56
|
const bodyPath = join(cwd, ".foreman", "mr-bodies", opts.runId, `${opts.node.ticket.id}.md`);
|
|
56
57
|
try {
|
|
57
58
|
mkdirSync(join(cwd, ".foreman", "mr-bodies", opts.runId), { recursive: true });
|
|
58
|
-
writeFileSync(bodyPath,
|
|
59
|
+
writeFileSync(bodyPath, configuredReviewBody(cwd, opts.node, opts.qaEvidence, opts.commit), "utf8");
|
|
59
60
|
}
|
|
60
61
|
catch (err) {
|
|
61
62
|
return prFailure(failure("mr_create_failed", `Failed to write GitLab MR body file at ${bodyPath}.`, [
|
|
@@ -70,7 +71,7 @@ export function createOrReuseMr(cwd, opts) {
|
|
|
70
71
|
"--target-branch",
|
|
71
72
|
opts.node.baseBranch,
|
|
72
73
|
"--title",
|
|
73
|
-
|
|
74
|
+
configuredReviewTitle(cwd, opts.node.ticket),
|
|
74
75
|
"--description",
|
|
75
76
|
readBodyArg(bodyPath),
|
|
76
77
|
"--yes",
|
package/dist/branch/planner.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { TicketDef } from "../tickets/ticketSchema.js";
|
|
2
2
|
import type { TicketState } from "../tickets/stateDb.js";
|
|
3
3
|
import type { BranchPlan } from "./types.js";
|
|
4
|
+
import type { DeliveryConfig } from "../tickets/delivery.js";
|
|
5
|
+
import type { TicketBuildSetupConfig } from "../tickets/setupConfig.js";
|
|
4
6
|
export interface AuditDependency {
|
|
5
7
|
ticket: string;
|
|
6
8
|
dependsOn: string;
|
|
@@ -18,5 +20,8 @@ export interface BuildBranchPlanOptions {
|
|
|
18
20
|
export declare function buildBranchPlan(tickets: TicketDef[], states: Map<string, TicketState>, opts: BuildBranchPlanOptions): BranchPlan;
|
|
19
21
|
export declare function selectTicketsForBranchRun(tickets: TicketDef[], states: Map<string, TicketState>, steps: number, ticketIds?: string[]): TicketDef[];
|
|
20
22
|
export declare function applySharedDeliveryBranch(plan: BranchPlan, unitId: string, allRemainingTicketIds: string[], branchPrefix?: string): BranchPlan;
|
|
23
|
+
/** Apply branch sharing only; this deliberately never creates delivery stacks or dependency edges. */
|
|
24
|
+
export declare function applySizeBranchPolicy(plan: BranchPlan, policy: TicketBuildSetupConfig["branch_policy"], delivery?: DeliveryConfig, branchPrefix?: string): BranchPlan;
|
|
21
25
|
export declare function parseAuditDependencies(text: string): AuditDependency[];
|
|
22
26
|
export declare function buildBranchAuditInstruction(tickets: TicketDef[]): string;
|
|
27
|
+
export declare function normalizeBranchPrefix(prefix: string | undefined): string;
|
package/dist/branch/planner.js
CHANGED
|
@@ -99,11 +99,11 @@ export function selectTicketsForBranchRun(tickets, states, steps, ticketIds) {
|
|
|
99
99
|
}
|
|
100
100
|
return selected;
|
|
101
101
|
}
|
|
102
|
-
export function applySharedDeliveryBranch(plan, unitId, allRemainingTicketIds, branchPrefix = "
|
|
102
|
+
export function applySharedDeliveryBranch(plan, unitId, allRemainingTicketIds, branchPrefix = "feature") {
|
|
103
103
|
if (plan.nodes.length === 0)
|
|
104
104
|
return plan;
|
|
105
105
|
const slug = unitId.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "group";
|
|
106
|
-
const branch = `${branchPrefix
|
|
106
|
+
const branch = `${normalizeBranchPrefix(branchPrefix)}/${slug}`;
|
|
107
107
|
const finalSelected = plan.nodes.at(-1)?.ticket.id;
|
|
108
108
|
const completesUnit = plan.nodes.length === allRemainingTicketIds.length
|
|
109
109
|
&& allRemainingTicketIds.every((id) => plan.nodes.some((node) => node.ticket.id === id));
|
|
@@ -118,6 +118,38 @@ export function applySharedDeliveryBranch(plan, unitId, allRemainingTicketIds, b
|
|
|
118
118
|
})),
|
|
119
119
|
};
|
|
120
120
|
}
|
|
121
|
+
/** Apply branch sharing only; this deliberately never creates delivery stacks or dependency edges. */
|
|
122
|
+
export function applySizeBranchPolicy(plan, policy, delivery, branchPrefix = "feature") {
|
|
123
|
+
if (policy.mode !== "size" || plan.nodes.length < 2)
|
|
124
|
+
return plan;
|
|
125
|
+
const explicitlyAssigned = new Set(delivery?.units.flatMap((unit) => unit.tickets) ?? []);
|
|
126
|
+
const candidates = plan.nodes.filter((node) => policy.by_size[node.ticket.size] === "shared" && !explicitlyAssigned.has(node.ticket.id));
|
|
127
|
+
const groups = new Map();
|
|
128
|
+
for (const node of candidates) {
|
|
129
|
+
// Tickets with selected dependency edges need their existing topology. Independent
|
|
130
|
+
// tickets sharing the same base are safe to execute serially in one worktree/review.
|
|
131
|
+
if (node.dependencies.length)
|
|
132
|
+
continue;
|
|
133
|
+
const key = node.baseBranch;
|
|
134
|
+
groups.set(key, [...(groups.get(key) ?? []), node]);
|
|
135
|
+
}
|
|
136
|
+
const replacement = new Map();
|
|
137
|
+
for (const [base, nodes] of groups) {
|
|
138
|
+
if (nodes.length < 2)
|
|
139
|
+
continue;
|
|
140
|
+
const ids = nodes.map((node) => node.ticket.id.toLowerCase()).join("-");
|
|
141
|
+
const groupId = `size-small-${ids}`;
|
|
142
|
+
const branch = `${normalizeBranchPrefix(branchPrefix)}/${groupId}`.slice(0, 120).replace(/-+$/, "");
|
|
143
|
+
nodes.forEach((node, index) => replacement.set(node.ticket.id, {
|
|
144
|
+
...node,
|
|
145
|
+
branch,
|
|
146
|
+
baseBranch: base,
|
|
147
|
+
deliveryUnitId: groupId,
|
|
148
|
+
deliveryUnitFinal: index === nodes.length - 1,
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
return { ...plan, nodes: plan.nodes.map((node) => replacement.get(node.ticket.id) ?? node) };
|
|
152
|
+
}
|
|
121
153
|
export function parseAuditDependencies(text) {
|
|
122
154
|
const deps = [];
|
|
123
155
|
for (const line of text.split(/\r?\n/)) {
|
|
@@ -145,7 +177,7 @@ Only name dependencies where both tickets are in this selected batch. If there a
|
|
|
145
177
|
${body}`;
|
|
146
178
|
}
|
|
147
179
|
function uniqueBranchName(prefix, ticket, used) {
|
|
148
|
-
const cleanPrefix = prefix
|
|
180
|
+
const cleanPrefix = normalizeBranchPrefix(prefix);
|
|
149
181
|
const baseSlug = `${ticket.id}-${ticket.title}`
|
|
150
182
|
.toLowerCase()
|
|
151
183
|
.replace(/[^a-z0-9]+/g, "-")
|
|
@@ -160,6 +192,13 @@ function uniqueBranchName(prefix, ticket, used) {
|
|
|
160
192
|
used.add(branch);
|
|
161
193
|
return branch;
|
|
162
194
|
}
|
|
195
|
+
export function normalizeBranchPrefix(prefix) {
|
|
196
|
+
const value = prefix === undefined || prefix === "" ? "feature" : prefix;
|
|
197
|
+
if (value.startsWith("/") || value.endsWith("/") || value.includes("..") || value.includes("//") || /[~^:?*\[\\\s]/.test(value)) {
|
|
198
|
+
throw new Error(`invalid Git branch prefix: ${value}`);
|
|
199
|
+
}
|
|
200
|
+
return value;
|
|
201
|
+
}
|
|
163
202
|
function detectSelectedCycles(tickets, deps) {
|
|
164
203
|
const selected = new Set(tickets.map((ticket) => ticket.id));
|
|
165
204
|
const visited = new Set();
|
package/dist/buildRuns.d.ts
CHANGED
|
@@ -1,42 +1,66 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export declare const BUILD_RUN_VERSION =
|
|
1
|
+
import type { BuildRunRecordV2, ResolvedAgentSettings } from "rafi-spec";
|
|
2
|
+
export declare const BUILD_RUN_VERSION = 2;
|
|
3
3
|
export declare const BUILD_RUN_DIRECTORY = ".foreman/runs";
|
|
4
4
|
export declare const BUILD_HEARTBEAT_MS = 10000;
|
|
5
5
|
export declare const BUILD_LEASE_STALE_MS = 45000;
|
|
6
6
|
export interface CreateBuildRunInput {
|
|
7
7
|
tickets: string[];
|
|
8
8
|
deliveryUnit?: string;
|
|
9
|
-
branchMode?:
|
|
9
|
+
branchMode?: BuildRunRecordV2["branchMode"];
|
|
10
10
|
repositoryRoot: string;
|
|
11
11
|
worktree?: string;
|
|
12
12
|
branch?: string;
|
|
13
13
|
baseHead?: string;
|
|
14
|
+
baseRef?: string;
|
|
14
15
|
startHead?: string;
|
|
15
16
|
builder?: LegacyResolvedAgentSettings;
|
|
16
17
|
qa?: LegacyResolvedAgentSettings;
|
|
17
18
|
now?: Date;
|
|
18
19
|
}
|
|
19
|
-
export declare function createBuildRun(input: CreateBuildRunInput):
|
|
20
|
+
export declare function createBuildRun(input: CreateBuildRunInput): BuildRunRecordV2;
|
|
20
21
|
export declare function resumeBuildRun(projectDir: string, runId: string, patch: {
|
|
21
22
|
builder?: LegacyResolvedAgentSettings;
|
|
22
23
|
qa?: LegacyResolvedAgentSettings;
|
|
23
24
|
builderSessionId?: string | null;
|
|
24
|
-
}, now?: Date):
|
|
25
|
+
}, now?: Date): BuildRunRecordV2;
|
|
25
26
|
type LegacyResolvedAgentSettings = Omit<ResolvedAgentSettings, "session_strategy" | "settings_revision"> & Partial<Pick<ResolvedAgentSettings, "session_strategy" | "settings_revision">>;
|
|
26
|
-
export declare function saveBuildRun(projectDir: string, run:
|
|
27
|
-
export declare function checkpointBuildRun(projectDir: string, run:
|
|
28
|
-
export declare function persistBuildSession(projectDir: string, run:
|
|
29
|
-
export declare function recordBuildReceipt(projectDir: string, run:
|
|
27
|
+
export declare function saveBuildRun(projectDir: string, run: BuildRunRecordV2, now?: Date): BuildRunRecordV2;
|
|
28
|
+
export declare function checkpointBuildRun(projectDir: string, run: BuildRunRecordV2, checkpoint: string, patch?: Partial<BuildRunRecordV2>): BuildRunRecordV2;
|
|
29
|
+
export declare function persistBuildSession(projectDir: string, run: BuildRunRecordV2, role: "builder" | "qa", sessionId: string): BuildRunRecordV2;
|
|
30
|
+
export declare function recordBuildReceipt(projectDir: string, run: BuildRunRecordV2, operationId: string, detail?: {
|
|
30
31
|
externalId?: string;
|
|
31
32
|
detail?: string;
|
|
32
|
-
}):
|
|
33
|
-
export declare function heartbeatBuildRun(projectDir: string, run:
|
|
34
|
-
export declare function releaseBuildLease(projectDir: string, run:
|
|
35
|
-
export declare function completeBuildRun(projectDir: string, run:
|
|
36
|
-
export declare function readBuildRuns(projectDir: string):
|
|
37
|
-
export declare function recoverableBuildRuns(projectDir: string, now?: Date): Array<
|
|
33
|
+
}): BuildRunRecordV2;
|
|
34
|
+
export declare function heartbeatBuildRun(projectDir: string, run: BuildRunRecordV2, now?: Date): BuildRunRecordV2;
|
|
35
|
+
export declare function releaseBuildLease(projectDir: string, run: BuildRunRecordV2, status?: BuildRunRecordV2["status"]): BuildRunRecordV2;
|
|
36
|
+
export declare function completeBuildRun(projectDir: string, run: BuildRunRecordV2, now?: Date): BuildRunRecordV2;
|
|
37
|
+
export declare function readBuildRuns(projectDir: string): BuildRunRecordV2[];
|
|
38
|
+
export declare function recoverableBuildRuns(projectDir: string, now?: Date): Array<BuildRunRecordV2 & {
|
|
38
39
|
active: boolean;
|
|
39
40
|
}>;
|
|
40
|
-
export declare function isLeaseActive(run:
|
|
41
|
-
export declare function buildRecoveryPreview(run:
|
|
41
|
+
export declare function isLeaseActive(run: BuildRunRecordV2, now?: Date): boolean;
|
|
42
|
+
export declare function buildRecoveryPreview(run: BuildRunRecordV2): string[];
|
|
43
|
+
export interface BuildRecoveryProjection {
|
|
44
|
+
run: BuildRunRecordV2;
|
|
45
|
+
ticketId?: string;
|
|
46
|
+
ticketTitle?: string;
|
|
47
|
+
compactLabel: string;
|
|
48
|
+
compactHint: string;
|
|
49
|
+
completed: string[];
|
|
50
|
+
remaining: string[];
|
|
51
|
+
lastSuccess: string;
|
|
52
|
+
interruption: string;
|
|
53
|
+
validation: string;
|
|
54
|
+
expectedChanges: string[];
|
|
55
|
+
unexpectedChanges: Array<{
|
|
56
|
+
path: string;
|
|
57
|
+
risk: string;
|
|
58
|
+
}>;
|
|
59
|
+
nextAction: string;
|
|
60
|
+
worktree: string;
|
|
61
|
+
branch?: string;
|
|
62
|
+
exactSessionId?: string;
|
|
63
|
+
}
|
|
64
|
+
export declare function projectBuildRecovery(projectDir: string, run: BuildRunRecordV2, now?: Date, ticketOverride?: string): BuildRecoveryProjection;
|
|
65
|
+
export declare function formatBuildRecoveryProjection(projection: BuildRecoveryProjection): string[];
|
|
42
66
|
export {};
|
package/dist/buildRuns.js
CHANGED
|
@@ -2,16 +2,22 @@ import { createHash, 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 { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
|
|
9
|
+
import { loadTickets } from "./tickets/ticketLoader.js";
|
|
10
|
+
export const BUILD_RUN_VERSION = 2;
|
|
7
11
|
export const BUILD_RUN_DIRECTORY = ".foreman/runs";
|
|
8
12
|
export const BUILD_HEARTBEAT_MS = 10_000;
|
|
9
13
|
export const BUILD_LEASE_STALE_MS = 45_000;
|
|
10
14
|
export function createBuildRun(input) {
|
|
11
15
|
const now = input.now ?? new Date();
|
|
12
16
|
const stamp = now.toISOString();
|
|
17
|
+
const worktree = resolve(input.worktree ?? input.repositoryRoot);
|
|
18
|
+
const snapshot = captureGitSnapshot(worktree, input);
|
|
13
19
|
const run = {
|
|
14
|
-
version:
|
|
20
|
+
version: 2,
|
|
15
21
|
runId: randomUUID(),
|
|
16
22
|
status: "running",
|
|
17
23
|
tickets: [...input.tickets],
|
|
@@ -23,12 +29,14 @@ export function createBuildRun(input) {
|
|
|
23
29
|
qa: input.qa ? { settings: normalizeCapturedSettings(input.qa) } : undefined,
|
|
24
30
|
repository: {
|
|
25
31
|
root: resolve(input.repositoryRoot),
|
|
26
|
-
worktree
|
|
27
|
-
branch: input.branch,
|
|
28
|
-
baseHead: input.baseHead,
|
|
29
|
-
startHead: input.startHead,
|
|
30
|
-
|
|
32
|
+
worktree,
|
|
33
|
+
branch: input.branch ?? snapshot.branch,
|
|
34
|
+
baseHead: input.baseHead ?? snapshot.baselineHead,
|
|
35
|
+
startHead: input.startHead ?? snapshot.startHead,
|
|
36
|
+
git: snapshot,
|
|
37
|
+
baselineComplete: Boolean(snapshot.baselineHead && snapshot.startHead && snapshot.branch),
|
|
31
38
|
},
|
|
39
|
+
progress: { completedTickets: [], completedOperations: [], remainingTickets: [...input.tickets], nextAction: input.tickets[0] ? `Start ${input.tickets[0]}` : "Plan next work" },
|
|
32
40
|
receipts: {},
|
|
33
41
|
lease: currentLease(now),
|
|
34
42
|
createdAt: stamp,
|
|
@@ -71,7 +79,12 @@ export function saveBuildRun(projectDir, run, now = new Date()) {
|
|
|
71
79
|
validateBuildRun(run);
|
|
72
80
|
const directory = join(resolve(projectDir), BUILD_RUN_DIRECTORY);
|
|
73
81
|
mkdirSync(directory, { recursive: true });
|
|
74
|
-
const
|
|
82
|
+
const completedOperations = Object.keys(run.receipts).sort();
|
|
83
|
+
const next = {
|
|
84
|
+
...run,
|
|
85
|
+
progress: { ...run.progress, completedOperations, remainingTickets: remainingTickets(run) },
|
|
86
|
+
updatedAt: now.toISOString(),
|
|
87
|
+
};
|
|
75
88
|
const target = join(directory, `${run.runId}.json`);
|
|
76
89
|
const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
77
90
|
writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
@@ -145,7 +158,7 @@ export function releaseBuildLease(projectDir, run, status = "interrupted") {
|
|
|
145
158
|
return saved;
|
|
146
159
|
}
|
|
147
160
|
export function completeBuildRun(projectDir, run, now = new Date()) {
|
|
148
|
-
const saved = saveBuildRun(projectDir, { ...run, status: "completed", checkpoint: "complete", lease: undefined, completedAt: now.toISOString() }, now);
|
|
161
|
+
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
162
|
releaseWorkflowLease(projectDir, run.runId, now);
|
|
150
163
|
return saved;
|
|
151
164
|
}
|
|
@@ -157,7 +170,7 @@ export function readBuildRuns(projectDir) {
|
|
|
157
170
|
try {
|
|
158
171
|
const run = JSON.parse(readFileSync(join(directory, name), "utf8"));
|
|
159
172
|
validateBuildRun(run);
|
|
160
|
-
return [run];
|
|
173
|
+
return [upgradeBuildRun(run)];
|
|
161
174
|
}
|
|
162
175
|
catch {
|
|
163
176
|
return [];
|
|
@@ -167,7 +180,42 @@ export function readBuildRuns(projectDir) {
|
|
|
167
180
|
export function recoverableBuildRuns(projectDir, now = new Date()) {
|
|
168
181
|
return readBuildRuns(projectDir)
|
|
169
182
|
.filter((run) => run.status !== "completed")
|
|
170
|
-
.map((run) => ({ ...run, active: isLeaseActive(run, now) }));
|
|
183
|
+
.map((run) => ({ ...inferLegacyRunTickets(projectDir, run), active: isLeaseActive(run, now) }));
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Current-branch runs created before ticket checkpointing did not retain their
|
|
187
|
+
* selected ticket. Recover it from Foreman's tracker events so --ticket can
|
|
188
|
+
* still address those durable runs without rewriting the legacy record.
|
|
189
|
+
*/
|
|
190
|
+
function inferLegacyRunTickets(projectDir, run) {
|
|
191
|
+
if (run.tickets.length > 0 || !isTicketsInitialized(projectDir))
|
|
192
|
+
return run;
|
|
193
|
+
let db;
|
|
194
|
+
try {
|
|
195
|
+
const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
|
|
196
|
+
if (!existsSync(paths.stateDb))
|
|
197
|
+
return run;
|
|
198
|
+
db = new Database(paths.stateDb, { readonly: true, fileMustExist: true });
|
|
199
|
+
const rows = db.prepare(`
|
|
200
|
+
SELECT ticket_id
|
|
201
|
+
FROM ticket_events
|
|
202
|
+
WHERE actor = 'foreman'
|
|
203
|
+
AND ticket_id IS NOT NULL
|
|
204
|
+
AND julianday(timestamp) >= julianday(?)
|
|
205
|
+
AND julianday(timestamp) <= julianday(?)
|
|
206
|
+
ORDER BY timestamp, id
|
|
207
|
+
`).all(run.createdAt, run.updatedAt);
|
|
208
|
+
const tickets = [...new Set(rows.map((row) => row.ticket_id).filter(Boolean))];
|
|
209
|
+
if (tickets.length === 0)
|
|
210
|
+
return run;
|
|
211
|
+
return { ...run, tickets, currentTicket: tickets.at(-1) };
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return run;
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
db?.close();
|
|
218
|
+
}
|
|
171
219
|
}
|
|
172
220
|
export function isLeaseActive(run, now = new Date()) {
|
|
173
221
|
if (!run.lease)
|
|
@@ -193,12 +241,106 @@ export function buildRecoveryPreview(run) {
|
|
|
193
241
|
`checkpoint ${run.checkpoint}`,
|
|
194
242
|
`worktree ${run.repository.worktree}`,
|
|
195
243
|
`branch ${run.repository.branch ?? "current"}`,
|
|
196
|
-
`
|
|
244
|
+
`baseline ${run.repository.baselineComplete ? `${run.repository.git.baseRef ?? "recorded"} @ ${run.repository.git.baselineHead}` : "incomplete legacy baseline"}`,
|
|
245
|
+
`preserved paths ${run.repository.git.runOwnedPaths.join(", ") || run.repository.git.statusPaths.join(", ") || "none recorded"}`,
|
|
197
246
|
`completed operations ${completed.length ? completed.join(", ") : "none"}`,
|
|
198
247
|
`session ${run.builder?.sessionId ? "exact Builder session available" : "fresh Builder session required"}`,
|
|
199
248
|
`QA session ${run.qa?.sessionId ? "exact QA session available" : "fresh QA session required"}`,
|
|
200
249
|
];
|
|
201
250
|
}
|
|
251
|
+
export function projectBuildRecovery(projectDir, run, now = new Date(), ticketOverride) {
|
|
252
|
+
let operationNames = [];
|
|
253
|
+
let issues = [];
|
|
254
|
+
let workflowState = {};
|
|
255
|
+
if (existsSync(join(resolve(projectDir), ".rafi", "recovery.sqlite3"))) {
|
|
256
|
+
const workflow = new WorkflowDb(projectDir);
|
|
257
|
+
try {
|
|
258
|
+
operationNames = workflow.operations(run.runId).filter((operation) => operation.status === "confirmed").map((operation) => operation.kind);
|
|
259
|
+
issues = workflow.issues(run.runId).map((issue) => issue.detail);
|
|
260
|
+
workflowState = workflow.getRun(run.runId)?.state ?? {};
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
workflow.close();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const stateTicket = typeof workflowState.currentTicket === "string" ? workflowState.currentTicket : undefined;
|
|
267
|
+
const ticketId = ticketOverride ?? stateTicket ?? run.currentTicket ?? run.progress.remainingTickets[0] ?? run.tickets[0];
|
|
268
|
+
let ticketTitle;
|
|
269
|
+
if (ticketId && isTicketsInitialized(projectDir)) {
|
|
270
|
+
try {
|
|
271
|
+
const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
|
|
272
|
+
ticketTitle = loadTickets(paths.tickets).find((ticket) => ticket.id === ticketId)?.title;
|
|
273
|
+
}
|
|
274
|
+
catch { /* durable run data remains enough for a legacy preview */ }
|
|
275
|
+
}
|
|
276
|
+
const stateWorktree = typeof workflowState.worktree === "string" ? workflowState.worktree : undefined;
|
|
277
|
+
const recoveryWorktree = stateWorktree && existsSync(stateWorktree) ? stateWorktree : run.repository.worktree;
|
|
278
|
+
const stateBranch = typeof workflowState.branch === "string" ? workflowState.branch : undefined;
|
|
279
|
+
const expectedChanges = gitStatusPaths(recoveryWorktree);
|
|
280
|
+
const baseChanges = resolve(run.repository.root) === resolve(recoveryWorktree) ? [] : gitStatusPaths(run.repository.root);
|
|
281
|
+
const identityChanged = recoveryWorktree === run.repository.worktree && run.repository.git.worktreeIdentity && worktreeIdentity(recoveryWorktree) !== run.repository.git.worktreeIdentity;
|
|
282
|
+
const branchNow = gitValue(recoveryWorktree, ["branch", "--show-current"]);
|
|
283
|
+
const expectedBranch = stateBranch ?? run.repository.git.branch;
|
|
284
|
+
const branchChanged = Boolean(expectedBranch && branchNow && branchNow !== expectedBranch);
|
|
285
|
+
const unexpectedChanges = baseChanges.map((path) => ({ path, risk: "base-worktree changes may conflict with recovery" }));
|
|
286
|
+
if (identityChanged)
|
|
287
|
+
unexpectedChanges.push({ path: recoveryWorktree, risk: "preserved worktree identity changed" });
|
|
288
|
+
if (branchChanged)
|
|
289
|
+
unexpectedChanges.push({ path: recoveryWorktree, risk: `branch changed from ${expectedBranch} to ${branchNow}` });
|
|
290
|
+
const completed = [...new Set([...run.progress.completedTickets, ...run.progress.completedOperations, ...operationNames])];
|
|
291
|
+
const remaining = run.progress.remainingTickets.length ? run.progress.remainingTickets : remainingTickets(run);
|
|
292
|
+
const interruption = run.interruption?.lastError ?? run.interruption?.summary ?? run.failure?.summary ?? issues.at(-1) ?? run.status;
|
|
293
|
+
const validation = run.progress.validation
|
|
294
|
+
? `${run.progress.validation.status}${run.progress.validation.qa ? `; QA ${run.progress.validation.qa}` : ""}`
|
|
295
|
+
: run.qa?.sessionId ? "QA session checkpointed" : "no completed QA/validation checkpoint recorded";
|
|
296
|
+
const nextAction = ticketOverride
|
|
297
|
+
? `Resume ${ticketOverride} from ${run.checkpoint}`
|
|
298
|
+
: run.progress.nextAction ?? (ticketId ? `Resume ${ticketId} from ${run.checkpoint}` : `Resume from ${run.checkpoint}`);
|
|
299
|
+
const primary = ticketId ? `${ticketId}${ticketTitle ? `: ${ticketTitle}` : ""}` : "legacy run (ticket unavailable)";
|
|
300
|
+
return {
|
|
301
|
+
run, ticketId, ticketTitle,
|
|
302
|
+
compactLabel: `${primary} — ${run.status}`,
|
|
303
|
+
compactHint: `${run.builder?.settings.make ?? "runtime unavailable"}; ${stateBranch ?? branchNow ?? run.repository.branch ?? "current branch"}; updated ${relativeTime(run.updatedAt, now)}`,
|
|
304
|
+
completed, remaining,
|
|
305
|
+
lastSuccess: run.progress.lastSuccessfulAction ?? run.checkpoint,
|
|
306
|
+
interruption,
|
|
307
|
+
validation,
|
|
308
|
+
expectedChanges,
|
|
309
|
+
unexpectedChanges,
|
|
310
|
+
nextAction,
|
|
311
|
+
worktree: recoveryWorktree,
|
|
312
|
+
branch: stateBranch ?? branchNow ?? run.repository.git.branch ?? run.repository.branch,
|
|
313
|
+
exactSessionId: typeof workflowState.sessionId === "string" ? workflowState.sessionId : run.builder?.sessionId,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
export function formatBuildRecoveryProjection(projection) {
|
|
317
|
+
const { run } = projection;
|
|
318
|
+
const lines = [
|
|
319
|
+
`run ${run.runId}`,
|
|
320
|
+
`ticket ${projection.ticketId ?? "unavailable"}${projection.ticketTitle ? `: ${projection.ticketTitle}` : ""}`,
|
|
321
|
+
`status ${run.status}; checkpoint ${run.checkpoint}`,
|
|
322
|
+
`completed ${projection.completed.join(", ") || "none recorded"}`,
|
|
323
|
+
`current ${run.progress.currentStep ?? projection.ticketId ?? "not recorded"}`,
|
|
324
|
+
`remaining ${projection.remaining.join(", ") || "none"}`,
|
|
325
|
+
`last successful action ${projection.lastSuccess}`,
|
|
326
|
+
`interruption/failure ${projection.interruption}`,
|
|
327
|
+
`validation/QA ${projection.validation}`,
|
|
328
|
+
`branch ${projection.branch ?? "current"}; worktree ${projection.worktree}`,
|
|
329
|
+
`baseline ${run.repository.baselineComplete ? `${run.repository.git.baseRef ?? "recorded"} @ ${run.repository.git.baselineHead}` : "incomplete; automatic rollback is unavailable"}`,
|
|
330
|
+
`session ${projection.exactSessionId ? "exact Builder session available" : "fresh Builder session required"}`,
|
|
331
|
+
`runtime ${run.builder?.settings.make ?? "unavailable"}; model ${run.builder?.settings.model ?? "unavailable"}`,
|
|
332
|
+
`updated ${run.updatedAt}`,
|
|
333
|
+
`preserved expected in-progress paths ${projection.expectedChanges.join(", ") || "none currently dirty"}`,
|
|
334
|
+
];
|
|
335
|
+
if (projection.unexpectedChanges.length) {
|
|
336
|
+
lines.push("WARNING: unexpected recovery state (recovery remains available):");
|
|
337
|
+
for (const item of projection.unexpectedChanges)
|
|
338
|
+
lines.push(` ${item.path}: ${item.risk}`);
|
|
339
|
+
lines.push("recommended action: preserve or move these changes before recovery if they overlap the interrupted work");
|
|
340
|
+
}
|
|
341
|
+
lines.push(`next action ${projection.nextAction}`);
|
|
342
|
+
return lines;
|
|
343
|
+
}
|
|
202
344
|
function currentLease(now) {
|
|
203
345
|
return { hostname: hostname(), pid: process.pid, processStart: processStartIdentity(process.pid), heartbeatAt: now.toISOString() };
|
|
204
346
|
}
|
|
@@ -210,17 +352,8 @@ function processStartIdentity(pid) {
|
|
|
210
352
|
return pid === process.pid ? `${Date.now() - Math.round(process.uptime() * 1000)}` : "unknown";
|
|
211
353
|
}
|
|
212
354
|
}
|
|
213
|
-
function fingerprintWorkingFiles(worktree) {
|
|
214
|
-
try {
|
|
215
|
-
const stat = statSync(resolve(worktree));
|
|
216
|
-
return createHash("sha256").update(`${stat.dev}:${stat.ino}:${stat.mtimeMs}`).digest("hex");
|
|
217
|
-
}
|
|
218
|
-
catch {
|
|
219
|
-
return undefined;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
355
|
function validateBuildRun(run) {
|
|
223
|
-
if (run.version
|
|
356
|
+
if (![1, 2].includes(run.version) || !run.runId || !run.repository?.root || !run.repository.worktree || !run.createdAt || !run.updatedAt) {
|
|
224
357
|
throw new Error("invalid build run record");
|
|
225
358
|
}
|
|
226
359
|
}
|
|
@@ -229,6 +362,8 @@ function workflowStatus(status) {
|
|
|
229
362
|
return "completed";
|
|
230
363
|
if (status === "failed")
|
|
231
364
|
return "failed";
|
|
365
|
+
if (status === "superseded")
|
|
366
|
+
return "superseded";
|
|
232
367
|
if (status === "blocked")
|
|
233
368
|
return "blocked";
|
|
234
369
|
if (status === "interrupted" || status === "recoverable")
|
|
@@ -239,6 +374,92 @@ function remainingTickets(run) {
|
|
|
239
374
|
const current = run.currentTicket ? run.tickets.indexOf(run.currentTicket) : 0;
|
|
240
375
|
return run.status === "completed" ? [] : run.tickets.slice(Math.max(0, current));
|
|
241
376
|
}
|
|
377
|
+
function captureGitSnapshot(worktree, input) {
|
|
378
|
+
const git = (args) => {
|
|
379
|
+
try {
|
|
380
|
+
return execFileSync("git", args, { cwd: worktree, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
const statusPaths = (git(["status", "--porcelain=v1", "-z"]) ?? "").split("\0").filter(Boolean).map((line) => line.slice(3)).sort();
|
|
387
|
+
const head = input.startHead ?? git(["rev-parse", "HEAD"]);
|
|
388
|
+
const branch = input.branch ?? git(["branch", "--show-current"]);
|
|
389
|
+
return {
|
|
390
|
+
baselineHead: input.baseHead ?? head,
|
|
391
|
+
baseRef: input.baseRef ?? branch,
|
|
392
|
+
branch,
|
|
393
|
+
startHead: head,
|
|
394
|
+
worktree,
|
|
395
|
+
worktreeIdentity: worktreeIdentity(worktree),
|
|
396
|
+
statusPaths,
|
|
397
|
+
initialStatusPaths: [...statusPaths],
|
|
398
|
+
runOwnedPaths: [],
|
|
399
|
+
createdBranch: false,
|
|
400
|
+
createdWorktree: worktree !== resolve(input.repositoryRoot),
|
|
401
|
+
upstream: git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
function worktreeIdentity(worktree) {
|
|
405
|
+
try {
|
|
406
|
+
const stat = statSync(resolve(worktree));
|
|
407
|
+
return createHash("sha256").update(`${stat.dev}:${stat.ino}`).digest("hex");
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
return undefined;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
function gitValue(cwd, args) {
|
|
414
|
+
try {
|
|
415
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
return undefined;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
function gitStatusPaths(cwd) {
|
|
422
|
+
return (gitValue(cwd, ["status", "--porcelain=v1", "-z"]) ?? "").split("\0").filter(Boolean).map((line) => line.slice(3)).sort();
|
|
423
|
+
}
|
|
424
|
+
function relativeTime(value, now) {
|
|
425
|
+
const seconds = Math.max(0, Math.floor((now.getTime() - new Date(value).getTime()) / 1000));
|
|
426
|
+
if (seconds < 60)
|
|
427
|
+
return `${seconds}s ago`;
|
|
428
|
+
if (seconds < 3600)
|
|
429
|
+
return `${Math.floor(seconds / 60)}m ago`;
|
|
430
|
+
if (seconds < 86_400)
|
|
431
|
+
return `${Math.floor(seconds / 3600)}h ago`;
|
|
432
|
+
return `${Math.floor(seconds / 86_400)}d ago`;
|
|
433
|
+
}
|
|
434
|
+
function upgradeBuildRun(run) {
|
|
435
|
+
if (run.version === 2)
|
|
436
|
+
return run;
|
|
437
|
+
const legacy = run;
|
|
438
|
+
const remaining = legacy.status === "completed" ? [] : legacy.tickets.slice(Math.max(0, legacy.currentTicket ? legacy.tickets.indexOf(legacy.currentTicket) : 0));
|
|
439
|
+
return {
|
|
440
|
+
...legacy,
|
|
441
|
+
version: 2,
|
|
442
|
+
legacy: true,
|
|
443
|
+
repository: {
|
|
444
|
+
...legacy.repository,
|
|
445
|
+
baselineComplete: false,
|
|
446
|
+
git: {
|
|
447
|
+
baselineHead: legacy.repository.baseHead,
|
|
448
|
+
branch: legacy.repository.branch,
|
|
449
|
+
startHead: legacy.repository.startHead,
|
|
450
|
+
worktree: legacy.repository.worktree,
|
|
451
|
+
statusPaths: [], initialStatusPaths: [], runOwnedPaths: [], createdBranch: false, createdWorktree: false,
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
progress: {
|
|
455
|
+
completedTickets: legacy.status === "completed" ? [...legacy.tickets] : [],
|
|
456
|
+
completedOperations: Object.keys(legacy.receipts).sort(),
|
|
457
|
+
remainingTickets: remaining,
|
|
458
|
+
lastSuccessfulAction: legacy.checkpoint,
|
|
459
|
+
nextAction: remaining[0] ? `Resume ${remaining[0]}` : "Inspect legacy run",
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
}
|
|
242
463
|
function releaseWorkflowLease(projectDir, runId, now = new Date()) {
|
|
243
464
|
const workflow = new WorkflowDb(projectDir);
|
|
244
465
|
try {
|