ai-foreman 1.7.13 → 1.7.14
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 +33 -3
- package/dist/adapters/claude.d.ts +5 -0
- package/dist/adapters/claude.js +122 -16
- package/dist/adapters/codex.d.ts +2 -0
- package/dist/adapters/codex.js +30 -8
- package/dist/adapters/recovering.d.ts +2 -0
- package/dist/adapters/recovering.js +6 -0
- package/dist/adapters/types.d.ts +22 -0
- package/dist/agentRun.js +21 -3
- package/dist/branch/currentGuard.d.ts +2 -0
- package/dist/branch/currentGuard.js +9 -0
- package/dist/branch/finalization.d.ts +22 -0
- package/dist/branch/finalization.js +120 -0
- package/dist/branch/git.js +7 -2
- package/dist/branch/runner.d.ts +3 -2
- package/dist/branch/runner.js +277 -75
- package/dist/buildRuns.d.ts +1 -0
- package/dist/buildRuns.js +33 -3
- package/dist/cli/start.js +314 -86
- package/dist/cli/state.d.ts +4 -0
- package/dist/cli/state.js +69 -0
- package/dist/continuity.d.ts +4 -0
- package/dist/continuity.js +26 -3
- package/dist/foreman.d.ts +22 -4
- package/dist/foreman.js +105 -19
- package/dist/handoffs.d.ts +5 -2
- package/dist/handoffs.js +93 -12
- package/dist/index.js +2 -0
- package/dist/qaFailureDelivery.d.ts +100 -0
- package/dist/qaFailureDelivery.js +570 -0
- package/dist/qaProtocolV2.d.ts +233 -0
- package/dist/qaProtocolV2.js +114 -0
- package/dist/qaRecovery.d.ts +15 -3
- package/dist/qaRecovery.js +477 -83
- package/dist/qaReview.d.ts +42 -17
- package/dist/qaReview.js +1216 -457
- package/dist/qaRuntime.d.ts +14 -0
- package/dist/qaRuntime.js +44 -0
- package/dist/qaSnapshot.d.ts +34 -0
- package/dist/qaSnapshot.js +302 -34
- package/dist/stateTransfer.d.ts +57 -0
- package/dist/stateTransfer.js +644 -0
- package/dist/statusReporter.d.ts +2 -0
- package/dist/statusReporter.js +6 -0
- package/dist/workflowDb.d.ts +245 -2
- package/dist/workflowDb.js +647 -6
- package/package.json +15 -3
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { gzipSync, gunzipSync } from "node:zlib";
|
|
8
|
+
import { StateDb } from "./tickets/stateDb.js";
|
|
9
|
+
import { DEFAULT_TICKETS_CONFIG } from "./tickets/config.js";
|
|
10
|
+
import { WorkflowDb, readCurrentWorkflowLease, WORKFLOW_DB_FILE } from "./workflowDb.js";
|
|
11
|
+
import { OBSERVABILITY_DB_FILE } from "./observability.js";
|
|
12
|
+
export const STATE_BUNDLE_FORMAT = "rafi-state-bundle.v1";
|
|
13
|
+
export const STATE_MANIFEST_PATH = "rafi-state-manifest.v1.json";
|
|
14
|
+
export const STATE_LINEAGE_PATH = ".rafi/state-transfer.json";
|
|
15
|
+
const TRANSFER_ROOTS = [
|
|
16
|
+
".tickets/tickets.yaml",
|
|
17
|
+
".tickets/config.yaml",
|
|
18
|
+
".tickets/delivery.yaml",
|
|
19
|
+
".tickets/tracker-rules.md",
|
|
20
|
+
"docs/ticket-progress.md",
|
|
21
|
+
"docs/ticket-archive.md",
|
|
22
|
+
".foreman/runs",
|
|
23
|
+
".foreman/delivery-sessions",
|
|
24
|
+
".foreman/qa-report-recovery",
|
|
25
|
+
".foreman/qa-recovery-context",
|
|
26
|
+
".rafi/compiled",
|
|
27
|
+
".rafi/source-cache",
|
|
28
|
+
".rafi/interviews",
|
|
29
|
+
DEFAULT_TICKETS_CONFIG.paths.stateDb,
|
|
30
|
+
WORKFLOW_DB_FILE,
|
|
31
|
+
OBSERVABILITY_DB_FILE,
|
|
32
|
+
];
|
|
33
|
+
const STATE_PATHS = [
|
|
34
|
+
...TRANSFER_ROOTS.filter((path) => !isKnownSqlitePath(path)),
|
|
35
|
+
];
|
|
36
|
+
const DIAGNOSTIC_TRANSFER_DIRS = [
|
|
37
|
+
".foreman/runs",
|
|
38
|
+
".foreman/delivery-sessions",
|
|
39
|
+
".foreman/qa-report-recovery",
|
|
40
|
+
".foreman/qa-recovery-context",
|
|
41
|
+
];
|
|
42
|
+
export async function exportStateBundle(projectDir, outputFile, opts = {}) {
|
|
43
|
+
const root = resolve(projectDir);
|
|
44
|
+
assertDbBackedTickets(root);
|
|
45
|
+
assertNoLiveLease(root);
|
|
46
|
+
runCurrentMigrations(root);
|
|
47
|
+
assertCleanGit(root, "export");
|
|
48
|
+
const work = mkdtempSync(join(tmpdir(), "rafi-state-export-"));
|
|
49
|
+
try {
|
|
50
|
+
const staged = join(work, "state");
|
|
51
|
+
mkdirSync(staged, { recursive: true });
|
|
52
|
+
stageRegularStateFiles(root, staged);
|
|
53
|
+
await stageSqliteBackup(root, DEFAULT_TICKETS_CONFIG.paths.stateDb, join(staged, DEFAULT_TICKETS_CONFIG.paths.stateDb));
|
|
54
|
+
if (existsSync(join(root, WORKFLOW_DB_FILE)))
|
|
55
|
+
await stageSqliteBackup(root, WORKFLOW_DB_FILE, join(staged, WORKFLOW_DB_FILE));
|
|
56
|
+
if (existsSync(join(root, OBSERVABILITY_DB_FILE)))
|
|
57
|
+
await stageSqliteBackup(root, OBSERVABILITY_DB_FILE, join(staged, OBSERVABILITY_DB_FILE));
|
|
58
|
+
const filePaths = listFiles(staged).sort();
|
|
59
|
+
const currentFingerprint = fingerprintState(root);
|
|
60
|
+
const priorLineage = readLineage(root);
|
|
61
|
+
const baseFingerprint = priorLineage?.currentFingerprint ?? currentFingerprint;
|
|
62
|
+
const manifest = {
|
|
63
|
+
format: STATE_BUNDLE_FORMAT,
|
|
64
|
+
bundleId: randomUUID(),
|
|
65
|
+
createdAt: (opts.now ?? new Date()).toISOString(),
|
|
66
|
+
lineage: {
|
|
67
|
+
...(priorLineage?.lastImportedBundleId || priorLineage?.lastExportedBundleId ? { baseBundleId: priorLineage.lastImportedBundleId ?? priorLineage.lastExportedBundleId } : {}),
|
|
68
|
+
baseFingerprint,
|
|
69
|
+
sourceCurrentFingerprint: currentFingerprint,
|
|
70
|
+
},
|
|
71
|
+
source: {
|
|
72
|
+
root,
|
|
73
|
+
git: gitSummary(root),
|
|
74
|
+
fingerprint: currentFingerprint,
|
|
75
|
+
},
|
|
76
|
+
packages: {
|
|
77
|
+
aiForemanVersion: opts.packageVersion ?? packageVersion(),
|
|
78
|
+
},
|
|
79
|
+
compatibility: {
|
|
80
|
+
dbBackedTicketsRequired: true,
|
|
81
|
+
providerSessionsPortable: false,
|
|
82
|
+
diagnosticsIncluded: true,
|
|
83
|
+
},
|
|
84
|
+
files: filePaths.map((path) => {
|
|
85
|
+
const abs = join(staged, path);
|
|
86
|
+
return { path, kind: isSqlitePath(path) ? "sqlite" : "file", size: statSync(abs).size, sha256: sha256(readFileSync(abs)) };
|
|
87
|
+
}),
|
|
88
|
+
};
|
|
89
|
+
writeFileSync(join(staged, STATE_MANIFEST_PATH), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
90
|
+
const entries = listFiles(staged).sort().map((path) => ({ path, data: readFileSync(join(staged, path)).toString("base64") }));
|
|
91
|
+
const payload = { format: STATE_BUNDLE_FORMAT, entries };
|
|
92
|
+
mkdirSync(dirname(resolve(outputFile)), { recursive: true });
|
|
93
|
+
writeFileSync(resolve(outputFile), gzipSync(Buffer.from(JSON.stringify(payload), "utf8")));
|
|
94
|
+
writeLineage(root, {
|
|
95
|
+
version: 1,
|
|
96
|
+
lastExportedBundleId: manifest.bundleId,
|
|
97
|
+
sourceRoot: root,
|
|
98
|
+
sourceFingerprint: manifest.source.fingerprint,
|
|
99
|
+
currentFingerprint: manifest.source.fingerprint,
|
|
100
|
+
updatedAt: manifest.createdAt,
|
|
101
|
+
});
|
|
102
|
+
return manifest;
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
rmSync(work, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export function inspectStateBundle(bundleFile) {
|
|
109
|
+
const archive = readArchive(bundleFile);
|
|
110
|
+
const manifestEntry = archive.entries.find((entry) => entry.path === STATE_MANIFEST_PATH);
|
|
111
|
+
if (!manifestEntry)
|
|
112
|
+
throw new Error(`bundle is missing ${STATE_MANIFEST_PATH}`);
|
|
113
|
+
const manifest = JSON.parse(Buffer.from(manifestEntry.data, "base64").toString("utf8"));
|
|
114
|
+
validateManifest(manifest);
|
|
115
|
+
validateArchiveAgainstManifest(archive, manifest);
|
|
116
|
+
const byPath = new Map(archive.entries.map((entry) => [entry.path, entry]));
|
|
117
|
+
for (const file of manifest.files) {
|
|
118
|
+
const entry = byPath.get(file.path);
|
|
119
|
+
if (!entry)
|
|
120
|
+
throw new Error(`bundle is missing file entry ${file.path}`);
|
|
121
|
+
const data = Buffer.from(entry.data, "base64");
|
|
122
|
+
if (data.length !== file.size)
|
|
123
|
+
throw new Error(`bundle file size mismatch for ${file.path}`);
|
|
124
|
+
if (sha256(data) !== file.sha256)
|
|
125
|
+
throw new Error(`bundle digest mismatch for ${file.path}`);
|
|
126
|
+
}
|
|
127
|
+
return { manifest, valid: true };
|
|
128
|
+
}
|
|
129
|
+
export async function importStateBundle(projectDir, bundleFile, opts = {}) {
|
|
130
|
+
const root = resolve(projectDir);
|
|
131
|
+
const archive = readArchive(bundleFile);
|
|
132
|
+
const inspected = inspectStateBundle(bundleFile);
|
|
133
|
+
const manifest = inspected.manifest;
|
|
134
|
+
assertNoLiveLease(root, "import");
|
|
135
|
+
assertCleanGit(root, "import");
|
|
136
|
+
assertCompatibleGit(root, manifest);
|
|
137
|
+
assertLineageAllowsImport(root, manifest);
|
|
138
|
+
if (hasTransferableState(root) && !opts.yes)
|
|
139
|
+
throw new Error("refusing to replace existing local Rafi state without confirmation; rerun with --yes or approve the interactive prompt");
|
|
140
|
+
const work = mkdtempSync(join(tmpdir(), "rafi-state-import-"));
|
|
141
|
+
let backupDir;
|
|
142
|
+
let rollbackDir;
|
|
143
|
+
try {
|
|
144
|
+
const staged = join(work, "state");
|
|
145
|
+
mkdirSync(staged, { recursive: true });
|
|
146
|
+
for (const entry of archive.entries) {
|
|
147
|
+
if (entry.path === STATE_MANIFEST_PATH)
|
|
148
|
+
continue;
|
|
149
|
+
assertSafeRelativePath(entry.path);
|
|
150
|
+
if (!isAllowedStateTransferPath(entry.path))
|
|
151
|
+
throw new Error(`bundle file is outside the Rafi state transfer allowlist: ${entry.path}`);
|
|
152
|
+
const out = join(staged, entry.path);
|
|
153
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
154
|
+
writeFileSync(out, Buffer.from(entry.data, "base64"));
|
|
155
|
+
}
|
|
156
|
+
const backup = backupExistingState(root, opts.now ?? new Date());
|
|
157
|
+
backupDir = backup?.durableBackupDir;
|
|
158
|
+
rollbackDir = backup?.snapshotDir;
|
|
159
|
+
replaceTransferableState(root, staged);
|
|
160
|
+
rewriteImportedPaths(root, manifest.source.root);
|
|
161
|
+
assertDbBackedTickets(root);
|
|
162
|
+
runCurrentMigrations(root);
|
|
163
|
+
const currentFingerprint = fingerprintState(root);
|
|
164
|
+
writeLineage(root, {
|
|
165
|
+
version: 1,
|
|
166
|
+
lastImportedBundleId: manifest.bundleId,
|
|
167
|
+
sourceRoot: manifest.source.root,
|
|
168
|
+
sourceFingerprint: manifest.source.fingerprint,
|
|
169
|
+
currentFingerprint,
|
|
170
|
+
updatedAt: (opts.now ?? new Date()).toISOString(),
|
|
171
|
+
});
|
|
172
|
+
return { manifest, backupDir };
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
restoreBackup(root, rollbackDir);
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
if (rollbackDir && existsSync(rollbackDir))
|
|
180
|
+
rmSync(rollbackDir, { recursive: true, force: true });
|
|
181
|
+
rmSync(work, { recursive: true, force: true });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
export function fingerprintState(projectDir) {
|
|
185
|
+
const root = resolve(projectDir);
|
|
186
|
+
const hash = createHash("sha256");
|
|
187
|
+
for (const rel of [...new Set(TRANSFER_ROOTS)].sort()) {
|
|
188
|
+
const abs = join(root, rel);
|
|
189
|
+
if (!existsSync(abs))
|
|
190
|
+
continue;
|
|
191
|
+
if (rel === STATE_LINEAGE_PATH || rel.includes(".rafi/state-transfer-backups"))
|
|
192
|
+
continue;
|
|
193
|
+
for (const file of (statSync(abs).isDirectory() ? listFiles(abs).map((child) => join(rel, child)) : [rel]).sort()) {
|
|
194
|
+
if (file.endsWith("-wal") || file.endsWith("-shm") || file === STATE_LINEAGE_PATH || file.includes(".rafi/state-transfer-backups"))
|
|
195
|
+
continue;
|
|
196
|
+
const full = join(root, file);
|
|
197
|
+
if (!existsSync(full) || statSync(full).isDirectory())
|
|
198
|
+
continue;
|
|
199
|
+
hash.update(file);
|
|
200
|
+
hash.update("\0");
|
|
201
|
+
hash.update(readFileSync(full));
|
|
202
|
+
hash.update("\0");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
for (const file of rootDiagnosticFiles(root).sort()) {
|
|
206
|
+
if (file === STATE_LINEAGE_PATH || file.includes(".rafi/state-transfer-backups"))
|
|
207
|
+
continue;
|
|
208
|
+
const full = join(root, file);
|
|
209
|
+
if (!existsSync(full) || statSync(full).isDirectory())
|
|
210
|
+
continue;
|
|
211
|
+
hash.update(file);
|
|
212
|
+
hash.update("\0");
|
|
213
|
+
hash.update(readFileSync(full));
|
|
214
|
+
hash.update("\0");
|
|
215
|
+
}
|
|
216
|
+
return hash.digest("hex");
|
|
217
|
+
}
|
|
218
|
+
function assertDbBackedTickets(root) {
|
|
219
|
+
const indicators = [
|
|
220
|
+
DEFAULT_TICKETS_CONFIG.paths.tickets,
|
|
221
|
+
".tickets/delivery.yaml",
|
|
222
|
+
".tickets/config.yaml",
|
|
223
|
+
"docs/ticket-progress.md",
|
|
224
|
+
"docs/ticket-archive.md",
|
|
225
|
+
".foreman/delivery-sessions",
|
|
226
|
+
".tickets/delivery-sessions",
|
|
227
|
+
".tickets/history.jsonl",
|
|
228
|
+
".tickets/history",
|
|
229
|
+
".tickets/events",
|
|
230
|
+
".tickets/imports",
|
|
231
|
+
".tickets/backups",
|
|
232
|
+
];
|
|
233
|
+
const found = indicators.find((rel) => existsSync(join(root, rel)));
|
|
234
|
+
if (found && !existsSync(join(root, DEFAULT_TICKETS_CONFIG.paths.stateDb))) {
|
|
235
|
+
throw new Error(`DB-backed ticket state is required: found ${found} but missing ${DEFAULT_TICKETS_CONFIG.paths.stateDb}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function assertNoLiveLease(root, operation = "export") {
|
|
239
|
+
const lease = readCurrentWorkflowLease(root);
|
|
240
|
+
if (!lease)
|
|
241
|
+
return;
|
|
242
|
+
const ageMs = Date.now() - new Date(lease.heartbeatAt).getTime();
|
|
243
|
+
if (ageMs <= 45_000)
|
|
244
|
+
throw new Error(`refusing to ${operation} while live workflow lease exists for run ${lease.runId}; stop or pause the active run first, then retry the state ${operation}`);
|
|
245
|
+
}
|
|
246
|
+
function runCurrentMigrations(root) {
|
|
247
|
+
if (existsSync(join(root, DEFAULT_TICKETS_CONFIG.paths.stateDb))) {
|
|
248
|
+
const db = new StateDb(join(root, DEFAULT_TICKETS_CONFIG.paths.stateDb));
|
|
249
|
+
db.close();
|
|
250
|
+
}
|
|
251
|
+
if (existsSync(join(root, WORKFLOW_DB_FILE)) || existsSync(join(root, ".foreman", "runs")) || existsSync(join(root, ".rafi", "interviews")) || existsSync(join(root, ".tickets", "delivery-sessions"))) {
|
|
252
|
+
const db = new WorkflowDb(root);
|
|
253
|
+
db.close();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function stageRegularStateFiles(root, staged) {
|
|
257
|
+
const seen = new Set();
|
|
258
|
+
for (const rel of STATE_PATHS) {
|
|
259
|
+
const abs = join(root, rel);
|
|
260
|
+
if (!existsSync(abs))
|
|
261
|
+
continue;
|
|
262
|
+
for (const file of statSync(abs).isDirectory() ? listFiles(abs).map((child) => join(rel, child)) : [rel]) {
|
|
263
|
+
if (seen.has(file) || shouldSkipRuntimeFile(file) || isSqlitePath(file) || !isAllowedStateTransferPath(file))
|
|
264
|
+
continue;
|
|
265
|
+
seen.add(file);
|
|
266
|
+
const source = join(root, file);
|
|
267
|
+
const dest = join(staged, file);
|
|
268
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
269
|
+
copyFileSync(source, dest);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
for (const file of rootDiagnosticFiles(root)) {
|
|
273
|
+
if (seen.has(file) || shouldSkipRuntimeFile(file) || !isAllowedStateTransferPath(file))
|
|
274
|
+
continue;
|
|
275
|
+
seen.add(file);
|
|
276
|
+
const source = join(root, file);
|
|
277
|
+
const dest = join(staged, file);
|
|
278
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
279
|
+
copyFileSync(source, dest);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async function stageSqliteBackup(root, rel, dest) {
|
|
283
|
+
const source = join(root, rel);
|
|
284
|
+
if (!existsSync(source))
|
|
285
|
+
return;
|
|
286
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
287
|
+
const db = new Database(source, { readonly: true, fileMustExist: true });
|
|
288
|
+
try {
|
|
289
|
+
await db.backup(dest);
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
db.close();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function backupExistingState(root, now) {
|
|
296
|
+
const existing = [...new Set([...transferRootsWithLineage(), ...rootDiagnosticFiles(root)])].filter((rel) => existsSync(join(root, rel)));
|
|
297
|
+
const snapshotDir = mkdtempSync(join(tmpdir(), "rafi-state-import-rollback-"));
|
|
298
|
+
for (const rel of existing) {
|
|
299
|
+
const source = join(root, rel);
|
|
300
|
+
const dest = join(snapshotDir, rel);
|
|
301
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
302
|
+
cpSync(source, dest, { recursive: true, force: true });
|
|
303
|
+
}
|
|
304
|
+
if (!existing.length)
|
|
305
|
+
return { snapshotDir };
|
|
306
|
+
const backupDir = join(root, ".rafi", "state-transfer-backups", now.toISOString().replace(/[:.]/g, "-"));
|
|
307
|
+
for (const rel of existing) {
|
|
308
|
+
const source = join(root, rel);
|
|
309
|
+
const dest = join(backupDir, rel);
|
|
310
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
311
|
+
cpSync(source, dest, { recursive: true, force: true });
|
|
312
|
+
}
|
|
313
|
+
return { durableBackupDir: backupDir, snapshotDir };
|
|
314
|
+
}
|
|
315
|
+
function replaceTransferableState(root, staged) {
|
|
316
|
+
removeTransferableState(root);
|
|
317
|
+
for (const file of listFiles(staged)) {
|
|
318
|
+
if (!isAllowedStateTransferPath(file))
|
|
319
|
+
throw new Error(`staged file is outside the Rafi state transfer allowlist: ${file}`);
|
|
320
|
+
const source = join(staged, file);
|
|
321
|
+
const dest = join(root, file);
|
|
322
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
323
|
+
copyFileSync(source, dest);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
function removeTransferableState(root) {
|
|
327
|
+
for (const rel of [...new Set([...transferRootsWithLineage(), ...rootDiagnosticFiles(root)])]) {
|
|
328
|
+
if (rel === ".rafi" || rel === ".foreman" || rel === ".tickets" || rel === "docs")
|
|
329
|
+
continue;
|
|
330
|
+
const target = join(root, rel);
|
|
331
|
+
if (existsSync(target))
|
|
332
|
+
rmSync(target, { recursive: true, force: true });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
function restoreBackup(root, backupDir) {
|
|
336
|
+
removeTransferableState(root);
|
|
337
|
+
if (!backupDir)
|
|
338
|
+
return;
|
|
339
|
+
for (const file of listFiles(backupDir)) {
|
|
340
|
+
const source = join(backupDir, file);
|
|
341
|
+
const dest = join(root, file);
|
|
342
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
343
|
+
copyFileSync(source, dest);
|
|
344
|
+
}
|
|
345
|
+
rmSync(backupDir, { recursive: true, force: true });
|
|
346
|
+
}
|
|
347
|
+
function assertLineageAllowsImport(root, manifest) {
|
|
348
|
+
const hasState = hasTransferableState(root);
|
|
349
|
+
if (!hasState)
|
|
350
|
+
return;
|
|
351
|
+
const lineage = readLineage(root);
|
|
352
|
+
if (!lineage) {
|
|
353
|
+
if (!manifest.lineage)
|
|
354
|
+
return;
|
|
355
|
+
throw new Error(`target already has local Rafi state but no ${STATE_LINEAGE_PATH}; refusing to overwrite without state-transfer lineage. Export from the advanced machine first or start from a clean target state.`);
|
|
356
|
+
}
|
|
357
|
+
const current = fingerprintState(root);
|
|
358
|
+
if (lineage.currentFingerprint && current !== lineage.currentFingerprint) {
|
|
359
|
+
throw new Error("target Rafi state has advanced since the last state transfer; export from this machine first or start with a clean target state");
|
|
360
|
+
}
|
|
361
|
+
if (!manifest.lineage) {
|
|
362
|
+
throw new Error("incoming state bundle has no lineage metadata; import it only into a clean target or export a new bundle with current Rafi first");
|
|
363
|
+
}
|
|
364
|
+
const expectedBundleId = lineage.lastImportedBundleId ?? lineage.lastExportedBundleId;
|
|
365
|
+
const bundleMatches = Boolean(expectedBundleId && manifest.lineage.baseBundleId === expectedBundleId);
|
|
366
|
+
const fingerprintMatches = Boolean(lineage.currentFingerprint && manifest.lineage.baseFingerprint === lineage.currentFingerprint);
|
|
367
|
+
if (!bundleMatches && !fingerprintMatches) {
|
|
368
|
+
throw new Error("incoming state bundle diverges from the target state lineage; export from the advanced machine, switch branches or check out the expected Git HEAD, or start from a clean target state");
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
function readLineage(root) {
|
|
372
|
+
const path = join(root, STATE_LINEAGE_PATH);
|
|
373
|
+
if (!existsSync(path))
|
|
374
|
+
return undefined;
|
|
375
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
376
|
+
}
|
|
377
|
+
function writeLineage(root, lineage) {
|
|
378
|
+
const path = join(root, STATE_LINEAGE_PATH);
|
|
379
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
380
|
+
writeFileSync(path, `${JSON.stringify(lineage, null, 2)}\n`, "utf8");
|
|
381
|
+
}
|
|
382
|
+
function rewriteImportedPaths(root, sourceRoot) {
|
|
383
|
+
for (const rel of listFiles(join(root, ".foreman", "runs"), true).map((child) => join(".foreman", "runs", child))) {
|
|
384
|
+
rewriteJsonFile(root, rel, (value) => rewriteBuildRunJson(value, sourceRoot, root));
|
|
385
|
+
}
|
|
386
|
+
for (const rel of listFiles(join(root, ".foreman", "delivery-sessions"), true).map((child) => join(".foreman", "delivery-sessions", child))) {
|
|
387
|
+
rewriteJsonFile(root, rel, (value) => rewriteKnownPathKeys(value, sourceRoot, root, new Set(["worktreePath"])));
|
|
388
|
+
}
|
|
389
|
+
for (const rel of listFiles(join(root, ".foreman", "qa-report-recovery"), true).map((child) => join(".foreman", "qa-report-recovery", child))) {
|
|
390
|
+
if (rel.endsWith(".json"))
|
|
391
|
+
rewriteJsonFile(root, rel, (value) => rewriteKnownPathKeys(value, sourceRoot, root, new Set(["packetPath", "worktreePath", "builderWorktree"])));
|
|
392
|
+
}
|
|
393
|
+
rewriteWorkflowDb(root, sourceRoot);
|
|
394
|
+
}
|
|
395
|
+
function rewriteWorkflowDb(root, sourceRoot) {
|
|
396
|
+
const path = join(root, WORKFLOW_DB_FILE);
|
|
397
|
+
if (!existsSync(path))
|
|
398
|
+
return;
|
|
399
|
+
const db = new Database(path);
|
|
400
|
+
try {
|
|
401
|
+
rewriteSqlJsonColumn(db, "workflow_runs", "run_id", ["original_work_json", "remaining_work_json", "state_json"], sourceRoot, root);
|
|
402
|
+
rewriteSqlJsonColumn(db, "branch_resume_sessions", "rowid", ["session_json"], sourceRoot, root);
|
|
403
|
+
if (tableExists(db, "qa_recovery_heads")) {
|
|
404
|
+
const rows = db.prepare("SELECT rowid,packet_path FROM qa_recovery_heads").all();
|
|
405
|
+
for (const row of rows)
|
|
406
|
+
db.prepare("UPDATE qa_recovery_heads SET packet_path=? WHERE rowid=?").run(rewriteProjectPath(row.packet_path, sourceRoot, root), row.rowid);
|
|
407
|
+
}
|
|
408
|
+
if (tableExists(db, "qa_packet_projections")) {
|
|
409
|
+
const rows = db.prepare("SELECT rowid,path,manifest_json FROM qa_packet_projections").all();
|
|
410
|
+
for (const row of rows) {
|
|
411
|
+
const manifest = row.manifest_json ? JSON.stringify(rewriteKnownPathKeys(JSON.parse(row.manifest_json), sourceRoot, root, new Set(["path", "packetPath", "worktreePath"]))) : null;
|
|
412
|
+
db.prepare("UPDATE qa_packet_projections SET path=?,manifest_json=? WHERE rowid=?").run(rewriteProjectPath(row.path, sourceRoot, root), manifest, row.rowid);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
finally {
|
|
417
|
+
db.close();
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function rewriteSqlJsonColumn(db, table, idColumn, columns, sourceRoot, root) {
|
|
421
|
+
if (!tableExists(db, table))
|
|
422
|
+
return;
|
|
423
|
+
const quotedColumns = columns.map((column) => `"${column}"`).join(",");
|
|
424
|
+
const rows = db.prepare(`SELECT ${idColumn} AS id,${quotedColumns} FROM ${table}`).all();
|
|
425
|
+
for (const row of rows) {
|
|
426
|
+
const sets = [];
|
|
427
|
+
const values = [];
|
|
428
|
+
for (const column of columns) {
|
|
429
|
+
const raw = row[column];
|
|
430
|
+
if (typeof raw !== "string")
|
|
431
|
+
continue;
|
|
432
|
+
sets.push(`"${column}"=?`);
|
|
433
|
+
values.push(JSON.stringify(rewriteKnownPathKeys(JSON.parse(raw), sourceRoot, root, new Set(["root", "worktree", "worktreePath", "packetPath"]))));
|
|
434
|
+
}
|
|
435
|
+
if (!sets.length)
|
|
436
|
+
continue;
|
|
437
|
+
values.push(row.id);
|
|
438
|
+
db.prepare(`UPDATE ${table} SET ${sets.join(",")} WHERE ${idColumn}=?`).run(...values);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
function rewriteBuildRunJson(value, sourceRoot, root) {
|
|
442
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
443
|
+
return value;
|
|
444
|
+
const copy = { ...value };
|
|
445
|
+
const repository = copy.repository;
|
|
446
|
+
if (repository && typeof repository === "object" && !Array.isArray(repository)) {
|
|
447
|
+
copy.repository = rewriteKnownPathKeys(repository, sourceRoot, root, new Set(["root", "worktree"]));
|
|
448
|
+
}
|
|
449
|
+
return rewriteKnownPathKeys(copy, sourceRoot, root, new Set(["worktreePath"]));
|
|
450
|
+
}
|
|
451
|
+
function rewriteKnownPathKeys(value, sourceRoot, root, keys, currentKey) {
|
|
452
|
+
if (typeof value === "string")
|
|
453
|
+
return currentKey && keys.has(currentKey) ? rewriteProjectPath(value, sourceRoot, root) : value;
|
|
454
|
+
if (Array.isArray(value))
|
|
455
|
+
return value.map((item) => rewriteKnownPathKeys(item, sourceRoot, root, keys));
|
|
456
|
+
if (!value || typeof value !== "object")
|
|
457
|
+
return value;
|
|
458
|
+
const out = {};
|
|
459
|
+
for (const [key, child] of Object.entries(value))
|
|
460
|
+
out[key] = rewriteKnownPathKeys(child, sourceRoot, root, keys, key);
|
|
461
|
+
return out;
|
|
462
|
+
}
|
|
463
|
+
function rewriteProjectPath(value, sourceRoot, root) {
|
|
464
|
+
const source = resolve(sourceRoot);
|
|
465
|
+
const candidate = isAbsolute(value) ? resolve(value) : value;
|
|
466
|
+
if (typeof candidate === "string" && isAbsolute(candidate)) {
|
|
467
|
+
const rel = relative(source, candidate);
|
|
468
|
+
if (!rel || (!rel.startsWith("..") && !isAbsolute(rel)))
|
|
469
|
+
return join(root, rel);
|
|
470
|
+
}
|
|
471
|
+
return value;
|
|
472
|
+
}
|
|
473
|
+
function rewriteJsonFile(root, rel, rewrite) {
|
|
474
|
+
const path = join(root, rel);
|
|
475
|
+
if (!existsSync(path) || statSync(path).isDirectory())
|
|
476
|
+
return;
|
|
477
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
478
|
+
writeFileSync(path, `${JSON.stringify(rewrite(value), null, 2)}\n`, "utf8");
|
|
479
|
+
}
|
|
480
|
+
function readArchive(bundleFile) {
|
|
481
|
+
const payload = JSON.parse(gunzipSync(readFileSync(resolve(bundleFile))).toString("utf8"));
|
|
482
|
+
if (payload.format !== STATE_BUNDLE_FORMAT || !Array.isArray(payload.entries))
|
|
483
|
+
throw new Error("unsupported Rafi state bundle format; upgrade Rafi to read this bundle");
|
|
484
|
+
const seen = new Set();
|
|
485
|
+
for (const entry of payload.entries) {
|
|
486
|
+
assertSafeRelativePath(entry.path);
|
|
487
|
+
if (seen.has(entry.path))
|
|
488
|
+
throw new Error(`duplicate bundle archive entry: ${entry.path}`);
|
|
489
|
+
seen.add(entry.path);
|
|
490
|
+
}
|
|
491
|
+
return payload;
|
|
492
|
+
}
|
|
493
|
+
function validateManifest(manifest) {
|
|
494
|
+
if (manifest.format !== STATE_BUNDLE_FORMAT)
|
|
495
|
+
throw new Error("unsupported Rafi state bundle manifest; upgrade Rafi to read this bundle");
|
|
496
|
+
if (!manifest.compatibility?.dbBackedTicketsRequired)
|
|
497
|
+
throw new Error("unsupported state bundle compatibility flags");
|
|
498
|
+
const seen = new Set();
|
|
499
|
+
for (const file of manifest.files ?? []) {
|
|
500
|
+
assertSafeRelativePath(file.path);
|
|
501
|
+
if (seen.has(file.path))
|
|
502
|
+
throw new Error(`duplicate manifest file path: ${file.path}`);
|
|
503
|
+
seen.add(file.path);
|
|
504
|
+
if (!isAllowedStateTransferPath(file.path))
|
|
505
|
+
throw new Error(`manifest file is outside the Rafi state transfer allowlist: ${file.path}`);
|
|
506
|
+
if (file.kind === "sqlite" && !isKnownSqlitePath(file.path))
|
|
507
|
+
throw new Error(`manifest sqlite file is outside the Rafi sqlite allowlist: ${file.path}`);
|
|
508
|
+
if (file.kind === "file" && isKnownSqlitePath(file.path))
|
|
509
|
+
throw new Error(`manifest sqlite database must use sqlite kind: ${file.path}`);
|
|
510
|
+
}
|
|
511
|
+
if (manifest.lineage && manifest.lineage.sourceCurrentFingerprint !== manifest.source.fingerprint)
|
|
512
|
+
throw new Error("manifest lineage source fingerprint does not match source fingerprint");
|
|
513
|
+
}
|
|
514
|
+
function validateArchiveAgainstManifest(archive, manifest) {
|
|
515
|
+
const manifestPaths = new Set(manifest.files.map((file) => file.path));
|
|
516
|
+
for (const entry of archive.entries) {
|
|
517
|
+
if (entry.path === STATE_MANIFEST_PATH)
|
|
518
|
+
continue;
|
|
519
|
+
if (!manifestPaths.has(entry.path))
|
|
520
|
+
throw new Error(`bundle contains unmanifested file entry: ${entry.path}`);
|
|
521
|
+
}
|
|
522
|
+
const archivePaths = new Set(archive.entries.map((entry) => entry.path));
|
|
523
|
+
if (!archivePaths.has(STATE_MANIFEST_PATH))
|
|
524
|
+
throw new Error(`bundle is missing ${STATE_MANIFEST_PATH}`);
|
|
525
|
+
for (const file of manifest.files) {
|
|
526
|
+
if (!archivePaths.has(file.path))
|
|
527
|
+
throw new Error(`bundle is missing file entry ${file.path}`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
function assertCompatibleGit(root, manifest) {
|
|
531
|
+
const target = gitSummary(root);
|
|
532
|
+
const source = manifest.source.git;
|
|
533
|
+
if (source.available && !target.available)
|
|
534
|
+
throw new Error("target repository must have Git available before importing this state bundle");
|
|
535
|
+
if (source.branch && target.branch && source.branch !== target.branch)
|
|
536
|
+
throw new Error(`target Git branch mismatch: expected ${source.branch}, found ${target.branch}; switch branch before importing Rafi state`);
|
|
537
|
+
if (source.head && target.head && source.head !== target.head)
|
|
538
|
+
throw new Error(`target Git HEAD mismatch: expected ${source.head}, found ${target.head}; sync the source tree or check out the expected HEAD before importing Rafi state`);
|
|
539
|
+
}
|
|
540
|
+
function gitSummary(root) {
|
|
541
|
+
const head = spawnSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
542
|
+
if (head.status !== 0)
|
|
543
|
+
return { available: false };
|
|
544
|
+
const branch = spawnSync("git", ["branch", "--show-current"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
545
|
+
const dirty = spawnSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
546
|
+
return {
|
|
547
|
+
available: true,
|
|
548
|
+
head: head.stdout.trim() || undefined,
|
|
549
|
+
branch: branch.status === 0 ? branch.stdout.trim() || undefined : undefined,
|
|
550
|
+
dirty: dirty.status === 0 ? dirty.stdout.trim().length > 0 : undefined,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
function listFiles(root, missingOk = false) {
|
|
554
|
+
if (!existsSync(root))
|
|
555
|
+
return missingOk ? [] : [];
|
|
556
|
+
const out = [];
|
|
557
|
+
const walk = (dir, prefix = "") => {
|
|
558
|
+
for (const name of readdirSync(dir).sort()) {
|
|
559
|
+
const abs = join(dir, name);
|
|
560
|
+
const rel = prefix ? `${prefix}/${name}` : name;
|
|
561
|
+
const stat = statSync(abs);
|
|
562
|
+
if (stat.isDirectory())
|
|
563
|
+
walk(abs, rel);
|
|
564
|
+
else
|
|
565
|
+
out.push(rel);
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
if (statSync(root).isDirectory())
|
|
569
|
+
walk(root);
|
|
570
|
+
else
|
|
571
|
+
out.push(basename(root));
|
|
572
|
+
return out;
|
|
573
|
+
}
|
|
574
|
+
function shouldSkipRuntimeFile(path) {
|
|
575
|
+
return path.endsWith("-wal") || path.endsWith("-shm") || path === STATE_LINEAGE_PATH || path.includes(".rafi/state-transfer-backups");
|
|
576
|
+
}
|
|
577
|
+
function isSqlitePath(path) {
|
|
578
|
+
return path.endsWith(".sqlite") || path.endsWith(".sqlite3");
|
|
579
|
+
}
|
|
580
|
+
function isKnownSqlitePath(path) {
|
|
581
|
+
return path === DEFAULT_TICKETS_CONFIG.paths.stateDb || path === WORKFLOW_DB_FILE || path === OBSERVABILITY_DB_FILE;
|
|
582
|
+
}
|
|
583
|
+
function transferRootsWithLineage() {
|
|
584
|
+
return [...new Set([...TRANSFER_ROOTS, STATE_LINEAGE_PATH])].sort();
|
|
585
|
+
}
|
|
586
|
+
export function hasTransferableState(projectDir) {
|
|
587
|
+
const root = resolve(projectDir);
|
|
588
|
+
return transferRootsWithLineage().some((rel) => existsSync(join(root, rel))) || rootDiagnosticFiles(root).length > 0;
|
|
589
|
+
}
|
|
590
|
+
function rootDiagnosticFiles(root) {
|
|
591
|
+
const dir = join(root, ".foreman");
|
|
592
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory())
|
|
593
|
+
return [];
|
|
594
|
+
return readdirSync(dir)
|
|
595
|
+
.filter((name) => name.endsWith(".json") || name.endsWith(".jsonl"))
|
|
596
|
+
.map((name) => `.foreman/${name}`)
|
|
597
|
+
.filter((path) => isAllowedStateTransferPath(path));
|
|
598
|
+
}
|
|
599
|
+
function isAllowedStateTransferPath(path) {
|
|
600
|
+
if (path === STATE_MANIFEST_PATH)
|
|
601
|
+
return true;
|
|
602
|
+
if (path.endsWith("-wal") || path.endsWith("-shm"))
|
|
603
|
+
return false;
|
|
604
|
+
if (path.startsWith(".foreman/worktrees/"))
|
|
605
|
+
return false;
|
|
606
|
+
if (isKnownSqlitePath(path))
|
|
607
|
+
return true;
|
|
608
|
+
for (const root of STATE_PATHS) {
|
|
609
|
+
if (path === root || path.startsWith(`${root}/`))
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
612
|
+
for (const root of DIAGNOSTIC_TRANSFER_DIRS) {
|
|
613
|
+
if (path === root || path.startsWith(`${root}/`))
|
|
614
|
+
return true;
|
|
615
|
+
}
|
|
616
|
+
if (/^\.foreman\/[^/]+\.jsonl?$/.test(path))
|
|
617
|
+
return true;
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
function assertCleanGit(root, operation) {
|
|
621
|
+
const git = gitSummary(root);
|
|
622
|
+
if (!git.available || git.dirty === false)
|
|
623
|
+
return;
|
|
624
|
+
throw new Error(`refusing to ${operation} Rafi state from a dirty Git checkout because source code is not bundled; commit or stash local changes, sync the source tree, then retry`);
|
|
625
|
+
}
|
|
626
|
+
function assertSafeRelativePath(path) {
|
|
627
|
+
if (!path || isAbsolute(path) || path.split(/[\\/]/).includes("..") || path.includes(`.${sep}`))
|
|
628
|
+
throw new Error(`unsafe bundle path: ${path}`);
|
|
629
|
+
}
|
|
630
|
+
function sha256(data) {
|
|
631
|
+
return createHash("sha256").update(data).digest("hex");
|
|
632
|
+
}
|
|
633
|
+
function tableExists(db, table) {
|
|
634
|
+
return Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(table));
|
|
635
|
+
}
|
|
636
|
+
function packageVersion() {
|
|
637
|
+
try {
|
|
638
|
+
const text = readFileSync(new URL("../package.json", import.meta.url), "utf8");
|
|
639
|
+
return String(JSON.parse(text).version ?? "unknown");
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
return "unknown";
|
|
643
|
+
}
|
|
644
|
+
}
|
package/dist/statusReporter.d.ts
CHANGED
|
@@ -79,10 +79,12 @@ export declare class RoleStatusAdapter implements BuilderAdapter {
|
|
|
79
79
|
sendTurn(text: string): Promise<TurnResult>;
|
|
80
80
|
sessionId(): string | undefined;
|
|
81
81
|
sessionRef(): import("rafi-spec").ProviderSessionRefV1 | undefined;
|
|
82
|
+
prepareSession(): Promise<import("rafi-spec").ProviderSessionRefV1>;
|
|
82
83
|
adoptSessionRef(ref: import("rafi-spec").ProviderSessionRefV1): void;
|
|
83
84
|
validateSession(): Promise<import("rafi-spec").SessionAvailabilityV1>;
|
|
84
85
|
compact(): Promise<CompactResult>;
|
|
85
86
|
prepareAutoCompaction(thresholdPercent?: number): Promise<NativeAutoCompactionPolicy | void>;
|
|
87
|
+
requiresAutoCompactionSetupTurn(): boolean;
|
|
86
88
|
autoCompactionPolicy(): NativeAutoCompactionPolicy | undefined;
|
|
87
89
|
drainNativeCompactions(): import("./adapters/types.js").NativeCompaction[];
|
|
88
90
|
restoreNativeCompactions(compactions: NativeCompaction[]): void;
|
package/dist/statusReporter.js
CHANGED
|
@@ -174,10 +174,16 @@ export class RoleStatusAdapter {
|
|
|
174
174
|
async sendTurn(text) { this.onActive(this.adapter); return this.adapter.sendTurn(text); }
|
|
175
175
|
sessionId() { return this.adapter.sessionId(); }
|
|
176
176
|
sessionRef() { return this.adapter.sessionRef?.(); }
|
|
177
|
+
prepareSession() {
|
|
178
|
+
if (!this.adapter.prepareSession)
|
|
179
|
+
return Promise.reject(new Error("wrapped adapter cannot establish a provider session without a work turn"));
|
|
180
|
+
return this.adapter.prepareSession();
|
|
181
|
+
}
|
|
177
182
|
adoptSessionRef(ref) { this.adapter.adoptSessionRef?.(ref); }
|
|
178
183
|
validateSession() { return this.adapter.validateSession?.() ?? Promise.resolve({ version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped" }); }
|
|
179
184
|
compact() { this.onActive(this.adapter); return this.adapter.compact?.() ?? Promise.resolve({ ok: false, error: "native compaction unavailable" }); }
|
|
180
185
|
prepareAutoCompaction(thresholdPercent) { return this.adapter.prepareAutoCompaction?.(thresholdPercent) ?? Promise.resolve(); }
|
|
186
|
+
requiresAutoCompactionSetupTurn() { return this.adapter.requiresAutoCompactionSetupTurn?.() ?? false; }
|
|
181
187
|
autoCompactionPolicy() { return this.adapter.autoCompactionPolicy?.(); }
|
|
182
188
|
drainNativeCompactions() { return this.adapter.drainNativeCompactions?.() ?? []; }
|
|
183
189
|
restoreNativeCompactions(compactions) { this.adapter.restoreNativeCompactions?.(compactions); }
|