immune-brain 3.6.8 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -4
- package/README.zh-CN.md +10 -3
- package/package.json +3 -2
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +18 -2
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +76 -121
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +106 -600
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +1 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +3 -3
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +17 -43
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +7581 -5042
- package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +11 -12
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +1 -1
- package/plugins/immune-brain/dist/imm-loop.md +27 -25
- package/plugins/immune-brain/dist/imm-planner.md +53 -32
- package/plugins/immune-brain/dist/imm-review-retro.md +123 -0
- package/plugins/immune-brain/dist/registry.yaml +9 -0
- package/plugins/immune-brain/dist/role-prompts/code-review.md +3 -1
- package/plugins/immune-brain/dist/role-prompts/executor.md +4 -4
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +183 -40
- package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +240 -0
- package/plugins/immune-brain/runtime/assurance/qa.ts +132 -58
- package/plugins/immune-brain/runtime/assurance/review_evidence.ts +15 -7
- package/plugins/immune-brain/runtime/assurance/verification.ts +246 -206
- package/plugins/immune-brain/runtime/authorization_operation.ts +20 -0
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +288 -721
- package/plugins/immune-brain/runtime/commands/kernel.ts +158 -67
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +254 -29
- package/plugins/immune-brain/runtime/kernel/actor_identity.ts +33 -0
- package/plugins/immune-brain/runtime/kernel/application.ts +22 -6
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +94 -5
- package/plugins/immune-brain/runtime/kernel/authority_port.ts +27 -6
- package/plugins/immune-brain/runtime/kernel/backend_claim.ts +43 -16
- package/plugins/immune-brain/runtime/kernel/batch_authority.ts +10 -6
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +50 -63
- package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +13 -4
- package/plugins/immune-brain/runtime/kernel/completion.ts +5 -14
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +124 -34
- package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +13 -5
- package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
- package/plugins/immune-brain/runtime/kernel/intent.ts +7 -11
- package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +4 -1
- package/plugins/immune-brain/runtime/kernel/legacy_task_record.ts +323 -0
- package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +10 -1
- package/plugins/immune-brain/runtime/kernel/reducer.ts +32 -31
- package/plugins/immune-brain/runtime/kernel/run_identity.ts +121 -0
- package/plugins/immune-brain/runtime/kernel/spec_binding.ts +100 -0
- package/plugins/immune-brain/runtime/kernel/sqlite_migration.ts +950 -0
- package/plugins/immune-brain/runtime/kernel/sqlite_store.ts +1193 -0
- package/plugins/immune-brain/runtime/kernel/storage.ts +1254 -1206
- package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +129 -755
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +419 -46
- package/plugins/immune-brain/runtime/kernel/types.ts +12 -43
- package/plugins/immune-brain/runtime/kernel/validation.ts +60 -274
- package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +0 -1
- package/plugins/immune-brain/runtime/plan_core.ts +27 -65
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +3 -1
- package/plugins/immune-brain/runtime/prompts/executor.md +4 -4
- package/plugins/immune-brain/runtime/staged_intent.ts +58 -0
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +37 -7
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +42 -2
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +771 -0
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +189 -0
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +35 -0
- package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +33 -0
- package/plugins/immune-brain/runtime/unattended/types.ts +14 -1
- package/plugins/immune-brain/runtime/v4_runtime.ts +19 -23
- package/plugins/immune-brain/runtime/verification_descriptor.ts +92 -136
- package/plugins/immune-brain/runtime/workspace_scope.ts +98 -13
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -3
- package/plugins/immune-brain/skills/imm-review-retro/SKILL.md +23 -0
- package/plugins/immune-brain/skills/imm-review-retro/scripts/review_retro.ts +355 -0
- package/plugins/immune-brain/skills/registry.yaml +9 -0
- package/plugins/immune-brain/bin/imm-retire-stale-wrapper +0 -4
- package/plugins/immune-brain/bin/imm-retired +0 -4
- package/plugins/immune-brain/runtime/authority_commit_receipts.ts +0 -716
- package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +0 -451
- package/plugins/immune-brain/runtime/kernel/legacy.ts +0 -299
- package/plugins/immune-brain/runtime/kernel/observation.ts +0 -397
- package/plugins/immune-brain/runtime/kernel/readiness.ts +0 -282
- package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +0 -132
|
@@ -0,0 +1,950 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit claimless import of the retired file store into the SQLite authority.
|
|
3
|
+
*
|
|
4
|
+
* The legacy layout is authority for at most terminal tasks: an owner-free,
|
|
5
|
+
* committed worktree whose tasks all settled. The importer therefore refuses
|
|
6
|
+
* any layout that still carries live authority, copies each legacy record's raw
|
|
7
|
+
* bytes into tracked audit evidence first, builds a candidate store at
|
|
8
|
+
* `.imm/state/kernel.sqlite.importing`, verifies it row by row, and only then
|
|
9
|
+
* publishes it with one rename. A crash before the rename leaves the canonical
|
|
10
|
+
* store absent; a crash after it leaves a complete store, so no reader observes
|
|
11
|
+
* a half-imported database. Retry is idempotent through the recorded receipt.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
14
|
+
import {
|
|
15
|
+
closeSync,
|
|
16
|
+
constants,
|
|
17
|
+
existsSync,
|
|
18
|
+
fsyncSync,
|
|
19
|
+
lstatSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
readFileSync,
|
|
22
|
+
readdirSync,
|
|
23
|
+
realpathSync,
|
|
24
|
+
openSync,
|
|
25
|
+
rmSync,
|
|
26
|
+
statSync,
|
|
27
|
+
writeFileSync,
|
|
28
|
+
} from "node:fs";
|
|
29
|
+
import { spawnSync } from "node:child_process";
|
|
30
|
+
import { dirname, join, resolve } from "node:path";
|
|
31
|
+
import { DatabaseSync } from "node:sqlite";
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
FILE_STORE_CLAIM_RELATIVE,
|
|
35
|
+
FILE_STORE_TASKS_RELATIVE,
|
|
36
|
+
FILE_STORE_WORKSPACE_RELATIVE,
|
|
37
|
+
KERNEL_DB_RELATIVE,
|
|
38
|
+
KERNEL_TRANSACTION_MARKERS,
|
|
39
|
+
LEGACY_ARTIFACT_RETIREMENT,
|
|
40
|
+
LEGACY_MEMORY_RELATIVE,
|
|
41
|
+
LEGACY_RETIRED_DIRECTORIES,
|
|
42
|
+
LEGACY_TASKS_RELATIVE,
|
|
43
|
+
LEGACY_TRANSACTION_MARKERS,
|
|
44
|
+
LEGACY_WORKSPACE_RELATIVE,
|
|
45
|
+
STATE_RELATIVE,
|
|
46
|
+
auditTaskRecordPath,
|
|
47
|
+
auditTerminalProofPath,
|
|
48
|
+
} from "./storage_paths";
|
|
49
|
+
import {
|
|
50
|
+
classifyStoreFile,
|
|
51
|
+
createMigrationStoreFile,
|
|
52
|
+
ensureStoreIdentity,
|
|
53
|
+
insertRunRow,
|
|
54
|
+
markAuditExported,
|
|
55
|
+
publishMigrationStoreFile,
|
|
56
|
+
updateRunTerminal,
|
|
57
|
+
verifyStoreFile,
|
|
58
|
+
} from "./sqlite_store";
|
|
59
|
+
import { isTerminalBatchState, type BatchRunState } from "../unattended/batch_state";
|
|
60
|
+
import { parseTaskRecord } from "./validation";
|
|
61
|
+
import { parseTaskRecordV3 } from "./legacy_task_record";
|
|
62
|
+
import { canonicalRecordHash } from "./reducer";
|
|
63
|
+
import type { TaskRecord, TaskRecordV3 } from "./types";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The retired file-store layouts may still hold genuine historical v3-contract
|
|
67
|
+
* records (the live parser only accepts v4). A record that satisfies neither
|
|
68
|
+
* parser is unreadable, not a legacy v3 record, and refuses the import.
|
|
69
|
+
*/
|
|
70
|
+
function parseLegacyTaskRecord(raw: unknown): TaskRecord | TaskRecordV3 {
|
|
71
|
+
try {
|
|
72
|
+
return parseTaskRecord(raw);
|
|
73
|
+
} catch {
|
|
74
|
+
return parseTaskRecordV3(raw);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `canonicalRecordHash` only serializes its input; the v4-only parameter type
|
|
80
|
+
* exists for live call sites, not because the hash itself is v4-specific. A
|
|
81
|
+
* legacy v3 record canonicalizes the same way.
|
|
82
|
+
*/
|
|
83
|
+
function canonicalLegacyRecordHash(record: TaskRecord | TaskRecordV3): string {
|
|
84
|
+
return canonicalRecordHash(record as TaskRecord);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Raised when another process already holds the migration lock. */
|
|
88
|
+
export class MigrationBusyError extends Error {
|
|
89
|
+
constructor(message: string) {
|
|
90
|
+
super(message);
|
|
91
|
+
this.name = "MigrationBusyError";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Exclusive cross-process lock for one migration of this worktree. */
|
|
96
|
+
export const MIGRATION_LOCK_RELATIVE = `${STATE_RELATIVE}/migration.lock`;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Serialize the whole migration — check, rebuild, verify, publish, retire — across
|
|
100
|
+
* processes. Without it two concurrent runs can publish a candidate that the
|
|
101
|
+
* other process is still rebuilding, and a half-imported database would replace
|
|
102
|
+
* the authority while the legacy sources are deleted.
|
|
103
|
+
*
|
|
104
|
+
* The lock is never stolen: reclaiming a lock by path is itself a race (two
|
|
105
|
+
* reclaimers can each delete the other's fresh lock), so an existing lock is
|
|
106
|
+
* reported with the exact recovery action instead. Release removes the lock only
|
|
107
|
+
* while it still carries this process's own token.
|
|
108
|
+
*/
|
|
109
|
+
function withImportLock<T>(root: string, operation: () => T): T {
|
|
110
|
+
const lockPath = join(root, MIGRATION_LOCK_RELATIVE);
|
|
111
|
+
// The lock lives inside the worktree, so its own path is validated before
|
|
112
|
+
// anything is created: a symlinked .imm/state must not receive the lock.
|
|
113
|
+
assertNoSymlinkSegments(root, MIGRATION_LOCK_RELATIVE);
|
|
114
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
115
|
+
const token = `${process.pid} ${randomUUID()}`;
|
|
116
|
+
let fd: number;
|
|
117
|
+
try {
|
|
118
|
+
fd = openSync(lockPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST")
|
|
121
|
+
throw new MigrationBusyError(
|
|
122
|
+
`another storage layout migration is running; if no migration is running, delete ${MIGRATION_LOCK_RELATIVE} and retry`,
|
|
123
|
+
);
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
writeFileSync(fd, `${token}\n`);
|
|
128
|
+
} finally {
|
|
129
|
+
closeSync(fd);
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
return operation();
|
|
133
|
+
} finally {
|
|
134
|
+
let held = "";
|
|
135
|
+
try {
|
|
136
|
+
held = readFileSync(lockPath, "utf8").trim();
|
|
137
|
+
} catch {
|
|
138
|
+
held = "";
|
|
139
|
+
}
|
|
140
|
+
if (held === token) rmSync(lockPath, { force: true });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The candidate store the importer builds before publication. *//** The candidate store the importer builds before publication. */
|
|
145
|
+
export const MIGRATION_STORE_RELATIVE = `${KERNEL_DB_RELATIVE}.importing`;
|
|
146
|
+
/** Import identity of a published migration, used to make retry idempotent. */
|
|
147
|
+
export const MIGRATION_RECEIPT_RELATIVE = `${STATE_RELATIVE}/migration-receipt.json`;
|
|
148
|
+
|
|
149
|
+
export interface SqliteImportOutcome {
|
|
150
|
+
contract: "assurance_kernel/sqlite_import_result/v1";
|
|
151
|
+
outcome: "imported" | "already_imported" | "refused" | "failed";
|
|
152
|
+
reason: string | null;
|
|
153
|
+
imported_task_ids: string[];
|
|
154
|
+
/** Paths that must be committed before the source may be retired. */
|
|
155
|
+
uncommitted_evidence: string[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface LegacySource {
|
|
159
|
+
directory: string;
|
|
160
|
+
recordFile: string;
|
|
161
|
+
/** Beside the record in the oldest layout; null when the proof is tracked audit evidence. */
|
|
162
|
+
proofFile: string | null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
interface LegacyTask {
|
|
166
|
+
taskId: string;
|
|
167
|
+
sources: LegacySource[];
|
|
168
|
+
recordBytes: Buffer;
|
|
169
|
+
/** Canonical text, used only for comparison. */
|
|
170
|
+
recordJson: string;
|
|
171
|
+
/** The exact historical bytes the terminal proof binds. */
|
|
172
|
+
recordText: string;
|
|
173
|
+
proofBytes: Buffer;
|
|
174
|
+
proofJson: string;
|
|
175
|
+
recordHash: string;
|
|
176
|
+
state: "done" | "stopped";
|
|
177
|
+
importedAt: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function refusal(reason: string, uncommitted: string[] = []): SqliteImportOutcome {
|
|
181
|
+
return {
|
|
182
|
+
contract: "assurance_kernel/sqlite_import_result/v1",
|
|
183
|
+
outcome: "refused",
|
|
184
|
+
reason,
|
|
185
|
+
imported_task_ids: [],
|
|
186
|
+
uncommitted_evidence: uncommitted,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Historical bytes are only safe to retire once the audit copies that preserve
|
|
192
|
+
* them are committed, so the import stops after writing evidence and the
|
|
193
|
+
* operator commits those paths before rerunning it.
|
|
194
|
+
*
|
|
195
|
+
* The committed copy is verified against `HEAD` byte-for-byte rather than
|
|
196
|
+
* through `git status`: a status check cannot see a copy that is untracked
|
|
197
|
+
* because a broad ignore rule covers it, and a file that is not in `HEAD` is
|
|
198
|
+
* not protected by anything.
|
|
199
|
+
*/
|
|
200
|
+
function uncommittedEvidencePaths(root: string, paths: string[]): string[] {
|
|
201
|
+
const dirty: string[] = [];
|
|
202
|
+
for (const relative of paths) {
|
|
203
|
+
const committed = spawnSync("git", ["-C", root, "cat-file", "blob", `HEAD:${relative}`]);
|
|
204
|
+
if (committed.status !== 0) {
|
|
205
|
+
dirty.push(relative);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
let working: Buffer;
|
|
209
|
+
try {
|
|
210
|
+
working = readFileSync(join(root, relative));
|
|
211
|
+
} catch {
|
|
212
|
+
dirty.push(relative);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const head = Buffer.isBuffer(committed.stdout) ? committed.stdout : Buffer.from(String(committed.stdout ?? ""));
|
|
216
|
+
if (!head.equals(working)) dirty.push(relative);
|
|
217
|
+
}
|
|
218
|
+
return dirty;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function readRegularFileOrNull(path: string): Buffer | null {
|
|
222
|
+
try {
|
|
223
|
+
if (!statSync(path).isFile()) return null;
|
|
224
|
+
return readFileSync(path);
|
|
225
|
+
} catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function sha256Hex(bytes: Buffer | string): string {
|
|
231
|
+
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Durably write the import receipt: the file is flushed and its directory is
|
|
236
|
+
* fsynced before the database is published, so a power loss can never leave a
|
|
237
|
+
* published store whose import identity was never recorded.
|
|
238
|
+
*/
|
|
239
|
+
function writeReceiptDurably(root: string, receipt: string): void {
|
|
240
|
+
const relative = MIGRATION_RECEIPT_RELATIVE;
|
|
241
|
+
const target = join(root, relative);
|
|
242
|
+
assertNoSymlinkSegments(root, relative);
|
|
243
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
244
|
+
const fd = openSync(target, "w");
|
|
245
|
+
try {
|
|
246
|
+
writeFileSync(fd, receipt);
|
|
247
|
+
fsyncSync(fd);
|
|
248
|
+
} finally {
|
|
249
|
+
closeSync(fd);
|
|
250
|
+
}
|
|
251
|
+
const directory = openSync(dirname(target), constants.O_RDONLY);
|
|
252
|
+
try {
|
|
253
|
+
fsyncSync(directory);
|
|
254
|
+
} finally {
|
|
255
|
+
closeSync(directory);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function readReceipt(root: string): { identity: string; task_ids: string[] } | null {
|
|
260
|
+
const path = join(root, MIGRATION_RECEIPT_RELATIVE);
|
|
261
|
+
if (!existsSync(path)) return null;
|
|
262
|
+
try {
|
|
263
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
264
|
+
if (parsed.contract !== "assurance_kernel/sqlite_import_receipt/v1") return null;
|
|
265
|
+
if (typeof parsed.identity !== "string" || !Array.isArray(parsed.task_ids)) return null;
|
|
266
|
+
return { identity: parsed.identity, task_ids: parsed.task_ids.map(String) };
|
|
267
|
+
} catch {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Any batch state that is not terminal still owns work, so the layout is live.
|
|
274
|
+
* Terminality is decided by the batch state owner's own predicate rather than a
|
|
275
|
+
* local list, so a state added by that module cannot silently block migration.
|
|
276
|
+
*/
|
|
277
|
+
function recoverableBatch(root: string): string | null {
|
|
278
|
+
const directory = join(root, STATE_RELATIVE, "batches");
|
|
279
|
+
if (!existsSync(directory)) return null;
|
|
280
|
+
for (const entry of readdirSync(directory).sort()) {
|
|
281
|
+
// Reports describe a state that the state file already carries.
|
|
282
|
+
if (!entry.endsWith(".json") || entry.endsWith(".report.json")) continue;
|
|
283
|
+
try {
|
|
284
|
+
const parsed = JSON.parse(readFileSync(join(directory, entry), "utf8")) as Record<string, unknown>;
|
|
285
|
+
const state = typeof parsed.batch_state === "string" ? parsed.batch_state : null;
|
|
286
|
+
if (state !== null && isTerminalBatchState(state as BatchRunState)) continue;
|
|
287
|
+
return `batch ${entry} is not terminal (${state ?? "unknown"})`;
|
|
288
|
+
} catch {
|
|
289
|
+
return `batch ${entry} is unreadable`;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Read every legacy terminal task from both retired layouts: `.imm/tasks`
|
|
296
|
+
* (pre-cutover owner files) and `.imm/state/tasks` (the v4 file store this
|
|
297
|
+
* release retires). A record that cannot be parsed, is not terminal, or has no
|
|
298
|
+
* matching terminal proof refuses the whole import: a partial authority import
|
|
299
|
+
* is worse than none.
|
|
300
|
+
*/
|
|
301
|
+
function readLegacyTasks(root: string): { tasks: LegacyTask[]; reason: string | null } {
|
|
302
|
+
const tasks: LegacyTask[] = [];
|
|
303
|
+
const byId = new Map<string, LegacyTask>();
|
|
304
|
+
const seenFolded = new Map<string, string>();
|
|
305
|
+
for (const relative of [LEGACY_TASKS_RELATIVE, FILE_STORE_TASKS_RELATIVE]) {
|
|
306
|
+
const directory = join(root, relative);
|
|
307
|
+
if (!existsSync(directory)) continue;
|
|
308
|
+
if (lstatSync(directory).isSymbolicLink()) return { tasks: [], reason: `${relative} is a symlink` };
|
|
309
|
+
for (const entry of readdirSync(directory).sort()) {
|
|
310
|
+
if (!entry.endsWith(".json") || entry.endsWith(".backend-claim.json") || entry.startsWith(".")) continue;
|
|
311
|
+
const taskId = entry.slice(0, -".json".length);
|
|
312
|
+
const recordPath = join(directory, entry);
|
|
313
|
+
// The v4 file store kept its records under .imm/state/tasks and its
|
|
314
|
+
// terminal proofs as tracked audit evidence; the older layout kept the
|
|
315
|
+
// proof beside the record.
|
|
316
|
+
const besideRecord = relative === LEGACY_TASKS_RELATIVE;
|
|
317
|
+
const proofRelative = besideRecord ? null : auditTerminalProofPath(taskId);
|
|
318
|
+
const proofPath = besideRecord ? join(directory, `${taskId}.backend-claim.json`) : join(root, proofRelative!);
|
|
319
|
+
if (!statSync(recordPath).isFile()) return { tasks: [], reason: `${relative}/${entry} is not a regular file` };
|
|
320
|
+
if (!existsSync(proofPath))
|
|
321
|
+
return {
|
|
322
|
+
tasks: [],
|
|
323
|
+
reason: besideRecord
|
|
324
|
+
? `task ${taskId} has no terminal proof`
|
|
325
|
+
: `task ${taskId} has no tracked terminal proof at ${proofRelative}`,
|
|
326
|
+
};
|
|
327
|
+
const recordBytes = readFileSync(recordPath);
|
|
328
|
+
const proofBytes = readFileSync(proofPath);
|
|
329
|
+
let record: TaskRecord | TaskRecordV3;
|
|
330
|
+
try {
|
|
331
|
+
record = parseLegacyTaskRecord(JSON.parse(recordBytes.toString("utf8")));
|
|
332
|
+
} catch (error) {
|
|
333
|
+
return { tasks: [], reason: `task ${taskId} is not a readable legacy record: ${error instanceof Error ? error.message : String(error)}` };
|
|
334
|
+
}
|
|
335
|
+
// The file name is the task identity the rest of the runtime uses, so a
|
|
336
|
+
// record that disagrees with it would publish a self-contradictory run.
|
|
337
|
+
if (record.task_id !== taskId || record.intent_snapshot.task_id !== taskId)
|
|
338
|
+
return { tasks: [], reason: `task ${taskId} record declares a different task identity` };
|
|
339
|
+
if (record.lifecycle !== "done" && record.lifecycle !== "stopped")
|
|
340
|
+
return { tasks: [], reason: `task ${taskId} is ${record.lifecycle}; a live legacy task must settle on the prior runtime` };
|
|
341
|
+
let proof: Record<string, unknown>;
|
|
342
|
+
try {
|
|
343
|
+
proof = JSON.parse(proofBytes.toString("utf8")) as Record<string, unknown>;
|
|
344
|
+
} catch {
|
|
345
|
+
return { tasks: [], reason: `task ${taskId} has an unreadable terminal proof` };
|
|
346
|
+
}
|
|
347
|
+
if (proof.contract !== "assurance_kernel/task_tombstone/v2" || proof.task_id !== taskId)
|
|
348
|
+
return { tasks: [], reason: `task ${taskId} terminal proof does not match its record` };
|
|
349
|
+
// The record hash is the historical identity: the raw bytes the previous
|
|
350
|
+
// runtime committed, not a re-serialization of the parsed value.
|
|
351
|
+
const recordHash = sha256Hex(recordBytes);
|
|
352
|
+
if (proof.final_record_hash !== recordHash)
|
|
353
|
+
return { tasks: [], reason: `task ${taskId} terminal proof does not bind its record bytes` };
|
|
354
|
+
if (proof.terminal_lifecycle !== undefined && proof.terminal_lifecycle !== record.lifecycle)
|
|
355
|
+
return { tasks: [], reason: `task ${taskId} terminal proof lifecycle does not match its record` };
|
|
356
|
+
// Two task ids that differ only by case cannot keep distinct evidence on
|
|
357
|
+
// the default case-insensitive filesystem, and the same id in both
|
|
358
|
+
// layouts is an ambiguous authority, so the import refuses both.
|
|
359
|
+
const folded = taskId.toLowerCase();
|
|
360
|
+
const foldedOwner = seenFolded.get(folded);
|
|
361
|
+
if (foldedOwner !== undefined && foldedOwner !== taskId)
|
|
362
|
+
return { tasks: [], reason: `case-fold source collision between ${foldedOwner} and ${taskId}` };
|
|
363
|
+
seenFolded.set(folded, taskId);
|
|
364
|
+
if (byId.has(taskId)) return { tasks: [], reason: `task ${taskId} exists in more than one legacy layout` };
|
|
365
|
+
const task: LegacyTask = {
|
|
366
|
+
taskId,
|
|
367
|
+
sources: [{ directory: relative, recordFile: entry, proofFile: besideRecord ? `${taskId}.backend-claim.json` : null }],
|
|
368
|
+
recordBytes,
|
|
369
|
+
recordJson: `${JSON.stringify(record, null, 2)}\n`,
|
|
370
|
+
recordText: recordBytes.toString("utf8"),
|
|
371
|
+
proofBytes,
|
|
372
|
+
proofJson: `${JSON.stringify(proof, null, 2)}\n`,
|
|
373
|
+
recordHash,
|
|
374
|
+
state: record.lifecycle,
|
|
375
|
+
importedAt: typeof proof.terminalized_at === "string" ? proof.terminalized_at : new Date(0).toISOString(),
|
|
376
|
+
};
|
|
377
|
+
byId.set(taskId, task);
|
|
378
|
+
tasks.push(task);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return { tasks, reason: null };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
/** Deterministic run identity: the same legacy facts always import to one run. */
|
|
386
|
+
function migratedRunId(task: LegacyTask): string {
|
|
387
|
+
return `run-migrated-${createHash("sha256").update(`${task.taskId}:${task.recordHash}`).digest("hex").slice(0, 24)}`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Refuse to write through any symlinked segment of an evidence path: the audit
|
|
392
|
+
* directory is inside the worktree, and a linked subdirectory would let a
|
|
393
|
+
* legitimate-looking import write outside it.
|
|
394
|
+
*/
|
|
395
|
+
function assertNoSymlinkSegments(root: string, relativePath: string): void {
|
|
396
|
+
const segments = relativePath.split("/").filter(Boolean);
|
|
397
|
+
let current = root;
|
|
398
|
+
for (const segment of segments) {
|
|
399
|
+
current = join(current, segment);
|
|
400
|
+
let stat: ReturnType<typeof lstatSync>;
|
|
401
|
+
try {
|
|
402
|
+
stat = lstatSync(current);
|
|
403
|
+
} catch (error) {
|
|
404
|
+
// Only a genuinely missing segment ends the walk. `existsSync` would
|
|
405
|
+
// also report a dangling symlink as absent, and the write that follows
|
|
406
|
+
// would then create its target outside the worktree.
|
|
407
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
408
|
+
if (code === "ENOENT" || code === "ENOTDIR") break;
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
if (stat.isSymbolicLink())
|
|
412
|
+
throw new Error(`symlinked evidence path is forbidden during import: ${relativePath}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Create an evidence file without ever following a link: the exclusive,
|
|
418
|
+
* no-follow open is what makes the containment check race-free.
|
|
419
|
+
*/
|
|
420
|
+
function createEvidenceFile(path: string, bytes: Buffer): void {
|
|
421
|
+
const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0));
|
|
422
|
+
try {
|
|
423
|
+
writeFileSync(fd, bytes);
|
|
424
|
+
} finally {
|
|
425
|
+
closeSync(fd);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Whether this worktree already recorded a completed import identity. */
|
|
430
|
+
export function hasMigrationReceipt(rootInput: string): boolean {
|
|
431
|
+
return readReceipt(realpathSync(rootInput)) !== null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Copy one historical file into tracked evidence, byte for byte, and refuse a
|
|
436
|
+
* path that already exists with different content.
|
|
437
|
+
*/
|
|
438
|
+
function writeEvidenceCopy(root: string, relativeSource: string, relativeEvidence: string, bytes: Buffer): void {
|
|
439
|
+
assertNoSymlinkSegments(root, relativeEvidence);
|
|
440
|
+
const target = join(root, relativeEvidence);
|
|
441
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
442
|
+
if (existsSync(target)) {
|
|
443
|
+
if (!readFileSync(target).equals(bytes))
|
|
444
|
+
throw new Error(`audit evidence already exists with different bytes: ${relativeEvidence} (from ${relativeSource})`);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
createEvidenceFile(target, bytes);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** The retired artifacts this worktree still holds, with their evidence paths. */
|
|
451
|
+
function collectRetiredArtifacts(root: string): Array<{ source: string; evidence: string }> {
|
|
452
|
+
const present: Array<{ source: string; evidence: string }> = [];
|
|
453
|
+
for (const entry of LEGACY_ARTIFACT_RETIREMENT) {
|
|
454
|
+
const absolute = join(root, entry.source);
|
|
455
|
+
if (!existsSync(absolute)) continue;
|
|
456
|
+
// A symlinked parent would let retirement delete a file outside the
|
|
457
|
+
// worktree, so every source path is checked segment by segment first.
|
|
458
|
+
assertNoSymlinkSegments(root, entry.source);
|
|
459
|
+
if (!statSync(absolute).isFile()) throw new Error(`${entry.source} is not a regular file`);
|
|
460
|
+
present.push(entry);
|
|
461
|
+
}
|
|
462
|
+
return present;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Verify surviving historical sources still match their committed evidence
|
|
467
|
+
* before anything is retired. Bytes added or changed after the import committed
|
|
468
|
+
* its evidence would otherwise be deleted without ever being preserved.
|
|
469
|
+
*/
|
|
470
|
+
function verifyArtifactEvidence(root: string, artifacts: Array<{ source: string; evidence: string }>): void {
|
|
471
|
+
for (const artifact of artifacts) {
|
|
472
|
+
const evidence = readRegularFileOrNull(join(root, artifact.evidence));
|
|
473
|
+
if (!evidence) throw new Error(`the committed evidence for ${artifact.source} is missing; refusing to retire it`);
|
|
474
|
+
if (!evidence.equals(readFileSync(join(root, artifact.source))))
|
|
475
|
+
throw new Error(`the surviving legacy artifact ${artifact.source} differs from its committed evidence; refusing to retire it`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Preserve the recognized retired artifacts as historical evidence. Their bytes
|
|
481
|
+
* are copied before anything is deleted, and the copies must be committed
|
|
482
|
+
* before publication removes the sources.
|
|
483
|
+
*/
|
|
484
|
+
function preserveRetiredArtifacts(root: string, artifacts: Array<{ source: string; evidence: string }>): void {
|
|
485
|
+
for (const artifact of artifacts) {
|
|
486
|
+
assertNoSymlinkSegments(root, artifact.source);
|
|
487
|
+
writeEvidenceCopy(root, artifact.source, artifact.evidence, readFileSync(join(root, artifact.source)));
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Retire the artifacts whose bytes are already preserved as evidence. */
|
|
492
|
+
function removeRetiredArtifacts(root: string, artifacts: Array<{ source: string; evidence: string }>): void {
|
|
493
|
+
for (const artifact of artifacts) {
|
|
494
|
+
assertNoSymlinkSegments(root, artifact.source);
|
|
495
|
+
rmSync(join(root, artifact.source), { force: true });
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Remove the directories that only ever held retired artifacts. */
|
|
500
|
+
function removeEmptyRetiredDirectories(root: string): void {
|
|
501
|
+
for (const relative of [...LEGACY_RETIRED_DIRECTORIES, LEGACY_TASKS_RELATIVE, FILE_STORE_TASKS_RELATIVE]) {
|
|
502
|
+
const directory = join(root, relative);
|
|
503
|
+
if (!existsSync(directory)) continue;
|
|
504
|
+
// A linked directory is left alone: removing it would act on a path the
|
|
505
|
+
// layout never owned.
|
|
506
|
+
if (lstatSync(directory).isSymbolicLink()) continue;
|
|
507
|
+
if (readdirSync(directory).length === 0) rmSync(directory, { recursive: true, force: true });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Copy the historical bytes into tracked audit evidence. This is the only place
|
|
513
|
+
* the importer writes evidence, and it never rewrites a file that already
|
|
514
|
+
* exists with different bytes.
|
|
515
|
+
*/
|
|
516
|
+
function writeAuditEvidence(root: string, task: LegacyTask): void {
|
|
517
|
+
const recordPath = join(root, auditTaskRecordPath(task.taskId));
|
|
518
|
+
const proofPath = join(root, auditTerminalProofPath(task.taskId));
|
|
519
|
+
for (const relative of [auditTaskRecordPath(task.taskId), auditTerminalProofPath(task.taskId)])
|
|
520
|
+
assertNoSymlinkSegments(root, relative);
|
|
521
|
+
mkdirSync(dirname(recordPath), { recursive: true });
|
|
522
|
+
for (const [path, bytes] of [[recordPath, task.recordBytes], [proofPath, task.proofBytes]] as const) {
|
|
523
|
+
if (existsSync(path)) {
|
|
524
|
+
if (!readFileSync(path).equals(bytes))
|
|
525
|
+
throw new Error(`audit evidence already exists with different bytes: ${path}`);
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
writeFileSync(path, bytes);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function buildCandidateStore(root: string, tasks: LegacyTask[], now: string): void {
|
|
533
|
+
const target = join(root, MIGRATION_STORE_RELATIVE);
|
|
534
|
+
createMigrationStoreFile(root, target, now);
|
|
535
|
+
const db = new DatabaseSync(target);
|
|
536
|
+
try {
|
|
537
|
+
db.exec("BEGIN IMMEDIATE");
|
|
538
|
+
try {
|
|
539
|
+
for (const task of tasks) {
|
|
540
|
+
insertRunRow(db, {
|
|
541
|
+
run_id: migratedRunId(task),
|
|
542
|
+
task_id: task.taskId,
|
|
543
|
+
record_json: task.recordText,
|
|
544
|
+
intent_revision: 1,
|
|
545
|
+
intent_content_hash: task.recordHash,
|
|
546
|
+
enrollment_event_id: `migrated:${task.taskId}`,
|
|
547
|
+
claim_status: "active",
|
|
548
|
+
created_at: task.importedAt,
|
|
549
|
+
updated_at: task.importedAt,
|
|
550
|
+
});
|
|
551
|
+
updateRunTerminal(db, migratedRunId(task), task.state, task.recordText, task.proofJson, task.importedAt);
|
|
552
|
+
// The historical audit already holds exactly these bytes: the task's
|
|
553
|
+
// own tracked evidence is the export, so a later follow-up must not
|
|
554
|
+
// write a second copy that could drift from the bound proof.
|
|
555
|
+
markAuditExported(db, migratedRunId(task), task.importedAt);
|
|
556
|
+
}
|
|
557
|
+
db.exec("COMMIT");
|
|
558
|
+
// Fold the WAL back into the main file so publication moves exactly
|
|
559
|
+
// one self-contained database.
|
|
560
|
+
db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
561
|
+
db.exec("PRAGMA journal_mode=DELETE");
|
|
562
|
+
} catch (error) {
|
|
563
|
+
db.exec("ROLLBACK");
|
|
564
|
+
throw error;
|
|
565
|
+
}
|
|
566
|
+
} finally {
|
|
567
|
+
db.close();
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Discard an unfinished candidate and build it again from the source facts. */
|
|
572
|
+
function rebuildCandidateStore(root: string, tasks: LegacyTask[], now: string): void {
|
|
573
|
+
const target = join(root, MIGRATION_STORE_RELATIVE);
|
|
574
|
+
for (const suffix of ["", "-wal", "-shm"]) rmSync(`${target}${suffix}`, { force: true });
|
|
575
|
+
buildCandidateStore(root, tasks, now);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Verify the candidate store row by row before anything is published. */
|
|
579
|
+
function verifyCandidateStore(root: string, tasks: LegacyTask[]): void {
|
|
580
|
+
const target = join(root, MIGRATION_STORE_RELATIVE);
|
|
581
|
+
// Schema version and worktree binding are verified before any row compare, so
|
|
582
|
+
// a candidate built for another worktree can never be published here.
|
|
583
|
+
verifyStoreFile(root, target);
|
|
584
|
+
const db = new DatabaseSync(target, { readOnly: true });
|
|
585
|
+
try {
|
|
586
|
+
const rows = db
|
|
587
|
+
.prepare("SELECT run_id, task_id, state, record_json, terminal_proof_json, intent_content_hash, claim_status, audit_exported_at FROM runs")
|
|
588
|
+
.all() as Array<Record<string, unknown>>;
|
|
589
|
+
if (rows.length !== tasks.length) throw new Error(`candidate store holds ${rows.length} run(s), expected ${tasks.length}`);
|
|
590
|
+
const byTask = new Map(rows.map((row) => [String(row.task_id), row]));
|
|
591
|
+
for (const task of tasks) {
|
|
592
|
+
const row = byTask.get(task.taskId);
|
|
593
|
+
if (!row) throw new Error(`candidate store is missing ${task.taskId}`);
|
|
594
|
+
if (row.run_id !== migratedRunId(task)) throw new Error(`candidate store run identity differs for ${task.taskId}`);
|
|
595
|
+
if (row.state !== task.state) throw new Error(`candidate store state differs for ${task.taskId}`);
|
|
596
|
+
if (row.claim_status !== null) throw new Error(`candidate store kept a live claim for ${task.taskId}`);
|
|
597
|
+
if (String(row.record_json) !== task.recordText) throw new Error(`candidate store record bytes differ for ${task.taskId}`);
|
|
598
|
+
if (!row.audit_exported_at) throw new Error(`candidate store run ${task.taskId} would re-export its audit`);
|
|
599
|
+
if (String(row.terminal_proof_json) !== task.proofJson) throw new Error(`candidate store terminal proof differs for ${task.taskId}`);
|
|
600
|
+
if (row.intent_content_hash !== task.recordHash) throw new Error(`candidate store record hash differs for ${task.taskId}`);
|
|
601
|
+
if (
|
|
602
|
+
canonicalLegacyRecordHash(parseLegacyTaskRecord(JSON.parse(String(row.record_json)))) !==
|
|
603
|
+
canonicalLegacyRecordHash(parseLegacyTaskRecord(JSON.parse(task.recordJson)))
|
|
604
|
+
)
|
|
605
|
+
throw new Error(`candidate store record is not canonical for ${task.taskId}`);
|
|
606
|
+
}
|
|
607
|
+
const workspace = db.prepare("SELECT current_run_id FROM workspace WHERE id = 1").get() as Record<string, unknown> | undefined;
|
|
608
|
+
if (!workspace) throw new Error("candidate store has no workspace row");
|
|
609
|
+
if (workspace.current_run_id !== null) throw new Error("candidate store claims a current run");
|
|
610
|
+
} finally {
|
|
611
|
+
db.close();
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Remove the retired authority once the published store owns it. The raw bytes
|
|
617
|
+
* are already preserved as audit evidence, so this deletes a second authority
|
|
618
|
+
* source rather than history.
|
|
619
|
+
*/
|
|
620
|
+
function removeRetiredAuthority(root: string, tasks: LegacyTask[]): void {
|
|
621
|
+
for (const task of tasks) {
|
|
622
|
+
for (const source of task.sources) {
|
|
623
|
+
assertNoSymlinkSegments(root, `${source.directory}/${source.recordFile}`);
|
|
624
|
+
rmSync(join(root, source.directory, source.recordFile), { force: true });
|
|
625
|
+
// A proof that lives in the audit tree is preserved evidence, not
|
|
626
|
+
// retired authority: only a beside-the-record proof is removed.
|
|
627
|
+
if (source.proofFile) rmSync(join(root, source.directory, source.proofFile), { force: true });
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
for (const relative of [FILE_STORE_CLAIM_RELATIVE, FILE_STORE_WORKSPACE_RELATIVE, LEGACY_WORKSPACE_RELATIVE]) {
|
|
631
|
+
if (!existsSync(join(root, relative))) continue;
|
|
632
|
+
assertNoSymlinkSegments(root, relative);
|
|
633
|
+
rmSync(join(root, relative), { force: true });
|
|
634
|
+
}
|
|
635
|
+
for (const relative of [LEGACY_TASKS_RELATIVE, FILE_STORE_TASKS_RELATIVE]) {
|
|
636
|
+
const directory = join(root, relative);
|
|
637
|
+
if (existsSync(directory) && readdirSync(directory).length === 0) rmSync(directory, { recursive: true, force: true });
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function importIdentity(tasks: LegacyTask[]): string {
|
|
642
|
+
return sha256Hex(tasks.map((task) => `${task.taskId}:${task.recordHash}`).sort().join("\n"));
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Reasons a retired layout still owns live work. Retirement deletes the owner
|
|
647
|
+
* signals, so this runs before any deletion — including on the recovery path,
|
|
648
|
+
* where the layout is reported as a store conflict rather than an active owner.
|
|
649
|
+
*/
|
|
650
|
+
function liveLegacyOwnerReason(root: string): string | null {
|
|
651
|
+
for (const claim of [FILE_STORE_CLAIM_RELATIVE, `${LEGACY_TASKS_RELATIVE}/.backend-claim.json`]) {
|
|
652
|
+
if (existsSync(join(root, claim))) return `a legacy owner claim is present at ${claim}`;
|
|
653
|
+
}
|
|
654
|
+
// A transaction marker means the prior runtime has an unfinished transaction
|
|
655
|
+
// to settle: its recovery state is not ours to delete.
|
|
656
|
+
for (const marker of LEGACY_TRANSACTION_MARKERS) {
|
|
657
|
+
if (existsSync(join(root, marker))) return `a legacy transaction marker is present at ${marker}`;
|
|
658
|
+
}
|
|
659
|
+
// A ledger that is not idle describes work the prior runtime still owns.
|
|
660
|
+
const ledger = `${LEGACY_MEMORY_RELATIVE}/current_iteration.json`;
|
|
661
|
+
if (existsSync(join(root, ledger))) {
|
|
662
|
+
assertNoSymlinkSegments(root, ledger);
|
|
663
|
+
try {
|
|
664
|
+
const status = (JSON.parse(readFileSync(join(root, ledger), "utf8")) as Record<string, unknown>).runtime_status;
|
|
665
|
+
if (typeof status === "string" && status !== "idle") return `the legacy ledger is ${status}`;
|
|
666
|
+
} catch {
|
|
667
|
+
return `${ledger} is unreadable`;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
// The retired SQLite file store kept its own transaction markers.
|
|
671
|
+
for (const marker of KERNEL_TRANSACTION_MARKERS) {
|
|
672
|
+
const relative = `${STATE_RELATIVE}/transactions/${marker}`;
|
|
673
|
+
if (existsSync(join(root, relative))) return `a legacy transaction marker is present at ${relative}`;
|
|
674
|
+
}
|
|
675
|
+
for (const workspace of [LEGACY_WORKSPACE_RELATIVE, FILE_STORE_WORKSPACE_RELATIVE]) {
|
|
676
|
+
const path = join(root, workspace);
|
|
677
|
+
if (!existsSync(path)) continue;
|
|
678
|
+
assertNoSymlinkSegments(root, workspace);
|
|
679
|
+
try {
|
|
680
|
+
const owner = (JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>).current_working;
|
|
681
|
+
if (typeof owner === "string" && owner.length > 0) return `the legacy workspace owner is ${owner}`;
|
|
682
|
+
} catch {
|
|
683
|
+
return `${workspace} is unreadable`;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return recoverableBatch(root);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Open the published store read-only for a recovery check. */
|
|
690
|
+
function openPublishedStore(root: string, canonical: string): DatabaseSync {
|
|
691
|
+
return new DatabaseSync(canonical, { readOnly: true });
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function publishedRow(db: DatabaseSync, taskId: string): Record<string, unknown> | null {
|
|
695
|
+
const row = db
|
|
696
|
+
.prepare("SELECT run_id, state, record_json, terminal_proof_json, intent_content_hash FROM runs WHERE task_id = ?")
|
|
697
|
+
.get(taskId) as Record<string, unknown> | undefined;
|
|
698
|
+
return row ?? null;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Verify the surviving legacy sources against the published store instead of
|
|
703
|
+
* against the original import digest: an interrupted cleanup has already
|
|
704
|
+
* removed part of them, and every survivor must still be exactly the authority
|
|
705
|
+
* the store published.
|
|
706
|
+
*/
|
|
707
|
+
function verifyPublishedRows(root: string, canonical: string, tasks: LegacyTask[]): void {
|
|
708
|
+
const db = openPublishedStore(root, canonical);
|
|
709
|
+
try {
|
|
710
|
+
for (const task of tasks) {
|
|
711
|
+
const row = publishedRow(db, task.taskId);
|
|
712
|
+
if (!row) throw new Error(`the surviving legacy task ${task.taskId} is not part of the published import`);
|
|
713
|
+
if (row.run_id !== migratedRunId(task)) throw new Error(`the surviving legacy task ${task.taskId} does not match its published run`);
|
|
714
|
+
if (row.state !== task.state) throw new Error(`the surviving legacy task ${task.taskId} does not match its published state`);
|
|
715
|
+
if (String(row.record_json) !== task.recordText) throw new Error(`the surviving legacy task ${task.taskId} differs from the published record`);
|
|
716
|
+
if (row.intent_content_hash !== task.recordHash) throw new Error(`the surviving legacy task ${task.taskId} differs from the published record hash`);
|
|
717
|
+
if (String(row.terminal_proof_json) !== task.proofJson) throw new Error(`the surviving legacy task ${task.taskId} differs from the published proof`);
|
|
718
|
+
}
|
|
719
|
+
} finally {
|
|
720
|
+
db.close();
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
interface OrphanProof {
|
|
725
|
+
path: string;
|
|
726
|
+
taskId: string;
|
|
727
|
+
bytes: Buffer;
|
|
728
|
+
proofJson: string;
|
|
729
|
+
evidence: string;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Collect proofs whose record was already retired by an interrupted cleanup.
|
|
734
|
+
* The proof is deleted below, so its path must stay inside the worktree.
|
|
735
|
+
*/
|
|
736
|
+
function collectOrphanProofs(root: string): OrphanProof[] {
|
|
737
|
+
const orphans: OrphanProof[] = [];
|
|
738
|
+
for (const relative of [LEGACY_TASKS_RELATIVE, FILE_STORE_TASKS_RELATIVE]) {
|
|
739
|
+
const directory = join(root, relative);
|
|
740
|
+
if (!existsSync(directory)) continue;
|
|
741
|
+
for (const entry of readdirSync(directory)) {
|
|
742
|
+
if (!entry.endsWith(".backend-claim.json")) continue;
|
|
743
|
+
const taskId = entry.slice(0, -".backend-claim.json".length);
|
|
744
|
+
if (existsSync(join(directory, `${taskId}.json`))) continue;
|
|
745
|
+
assertNoSymlinkSegments(root, `${relative}/${entry}`);
|
|
746
|
+
const bytes = readRegularFileOrNull(join(directory, entry));
|
|
747
|
+
if (!bytes) continue;
|
|
748
|
+
let proof: Record<string, unknown>;
|
|
749
|
+
try {
|
|
750
|
+
proof = JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
|
|
751
|
+
} catch {
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
orphans.push({
|
|
755
|
+
path: join(directory, entry),
|
|
756
|
+
taskId,
|
|
757
|
+
bytes,
|
|
758
|
+
proofJson: `${JSON.stringify(proof, null, 2)}\n`,
|
|
759
|
+
evidence: auditTerminalProofPath(taskId),
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return orphans;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Retire an orphan proof only when the published store carries the identical one
|
|
768
|
+
* and the committed evidence still holds its exact bytes.
|
|
769
|
+
*/
|
|
770
|
+
function retireOrphanProofs(root: string, canonical: string, orphans: OrphanProof[]): void {
|
|
771
|
+
if (orphans.length === 0) return;
|
|
772
|
+
const db = openPublishedStore(root, canonical);
|
|
773
|
+
try {
|
|
774
|
+
for (const orphan of orphans) {
|
|
775
|
+
const row = publishedRow(db, orphan.taskId);
|
|
776
|
+
if (!row || String(row.terminal_proof_json) !== orphan.proofJson) continue;
|
|
777
|
+
const evidence = readRegularFileOrNull(join(root, orphan.evidence));
|
|
778
|
+
if (!evidence || !evidence.equals(orphan.bytes))
|
|
779
|
+
throw new Error(`${orphan.evidence} does not hold the committed bytes of the orphan proof; refusing to retire it`);
|
|
780
|
+
rmSync(orphan.path, { force: true });
|
|
781
|
+
}
|
|
782
|
+
} finally {
|
|
783
|
+
db.close();
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Import the retired file store into the SQLite authority. `now` stamps the
|
|
789
|
+
* store's creation metadata; it never rewrites historical evidence.
|
|
790
|
+
*/
|
|
791
|
+
export function importLegacyWorkspace(rootInput: string, now = new Date().toISOString()): SqliteImportOutcome {
|
|
792
|
+
const canonicalInput = realpathSync(rootInput);
|
|
793
|
+
try {
|
|
794
|
+
return withImportLock(canonicalInput, () => importLegacyWorkspaceLocked(canonicalInput, now));
|
|
795
|
+
} catch (error) {
|
|
796
|
+
return {
|
|
797
|
+
contract: "assurance_kernel/sqlite_import_result/v1",
|
|
798
|
+
outcome: error instanceof MigrationBusyError ? "refused" : "failed",
|
|
799
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
800
|
+
imported_task_ids: [],
|
|
801
|
+
uncommitted_evidence: [],
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function importLegacyWorkspaceLocked(rootInput: string, now: string): SqliteImportOutcome {
|
|
807
|
+
// Bind one canonical root: a temp-directory root on macOS reaches the same
|
|
808
|
+
// files through /var and /private/var, and mixing them would look like a
|
|
809
|
+
// traversal attempt to the store's path safety checks.
|
|
810
|
+
const root = realpathSync(rootInput);
|
|
811
|
+
const canonical = join(root, KERNEL_DB_RELATIVE);
|
|
812
|
+
const receipt = readReceipt(root);
|
|
813
|
+
if (existsSync(canonical)) {
|
|
814
|
+
if (!receipt) return refusal("a kernel store already exists; the importer never overwrites one");
|
|
815
|
+
// The publication rename already happened. Verify the published store and
|
|
816
|
+
// finish any cleanup the crash interrupted instead of refusing the retry.
|
|
817
|
+
try {
|
|
818
|
+
verifyStoreFile(root, canonical);
|
|
819
|
+
// A crash between the publication rename and the identity marker would
|
|
820
|
+
// otherwise leave a store that a later truncation cannot be detected for.
|
|
821
|
+
ensureStoreIdentity(root);
|
|
822
|
+
} catch (error) {
|
|
823
|
+
return refusal(`the published store failed verification: ${error instanceof Error ? error.message : String(error)}`);
|
|
824
|
+
}
|
|
825
|
+
try {
|
|
826
|
+
// The published branch owns deletions too, so a live legacy owner that
|
|
827
|
+
// reappeared after the import refuses the cleanup instead of losing it.
|
|
828
|
+
const live = liveLegacyOwnerReason(root);
|
|
829
|
+
if (live) return refusal(`${live}; settle or stop the live legacy owner before retiring it`);
|
|
830
|
+
const pending = readLegacyTasks(root);
|
|
831
|
+
if (pending.reason) return refusal(pending.reason);
|
|
832
|
+
// A partially finished cleanup leaves only the tasks whose files were not
|
|
833
|
+
// removed yet, so the remaining subset never matches the full receipt
|
|
834
|
+
// identity. Each survivor is verified against its published row instead,
|
|
835
|
+
// which also refuses a source file the import never published.
|
|
836
|
+
if (pending.tasks.length > 0) verifyPublishedRows(root, canonical, pending.tasks);
|
|
837
|
+
// Retiring a source is only safe while the committed evidence holds its
|
|
838
|
+
// exact bytes, so this re-runs the same preservation check the import
|
|
839
|
+
// performed and repairs a missing copy instead of deleting the only one.
|
|
840
|
+
for (const task of pending.tasks) writeAuditEvidence(root, task);
|
|
841
|
+
// The evidence that justifies retiring the survivors must still be in
|
|
842
|
+
// HEAD: a branch switch between publication and cleanup would otherwise
|
|
843
|
+
// delete the only copy of the historical bytes.
|
|
844
|
+
const orphans = collectOrphanProofs(root);
|
|
845
|
+
const survivingEvidence = [
|
|
846
|
+
...pending.tasks.flatMap((task) => [auditTaskRecordPath(task.taskId), auditTerminalProofPath(task.taskId)]),
|
|
847
|
+
...collectRetiredArtifacts(root).map((artifact) => artifact.evidence),
|
|
848
|
+
...orphans.map((orphan) => orphan.evidence),
|
|
849
|
+
];
|
|
850
|
+
const missingEvidence = uncommittedEvidencePaths(root, survivingEvidence);
|
|
851
|
+
if (missingEvidence.length > 0)
|
|
852
|
+
return refusal("the committed audit evidence for the remaining legacy bytes is missing", missingEvidence);
|
|
853
|
+
removeRetiredAuthority(root, pending.tasks);
|
|
854
|
+
// The cleanup always runs to the end: an interrupted deletion can leave
|
|
855
|
+
// an orphan proof with no record, and that survivor keeps the layout
|
|
856
|
+
// invalid even though every task is already published.
|
|
857
|
+
retireOrphanProofs(root, canonical, orphans);
|
|
858
|
+
const surviving = collectRetiredArtifacts(root);
|
|
859
|
+
verifyArtifactEvidence(root, surviving);
|
|
860
|
+
removeRetiredArtifacts(root, surviving);
|
|
861
|
+
removeEmptyRetiredDirectories(root);
|
|
862
|
+
} catch (error) {
|
|
863
|
+
return refusal(error instanceof Error ? error.message : String(error));
|
|
864
|
+
}
|
|
865
|
+
return {
|
|
866
|
+
contract: "assurance_kernel/sqlite_import_result/v1",
|
|
867
|
+
outcome: "already_imported",
|
|
868
|
+
reason: null,
|
|
869
|
+
imported_task_ids: receipt.task_ids,
|
|
870
|
+
uncommitted_evidence: [],
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
const live = liveLegacyOwnerReason(root);
|
|
874
|
+
if (live) return refusal(`${live}; a live legacy owner or recoverable batch must be settled or stopped before import`);
|
|
875
|
+
const { tasks, reason } = readLegacyTasks(root);
|
|
876
|
+
if (reason) return refusal(reason);
|
|
877
|
+
// A workspace can be on the retired layout with no task at all — only an
|
|
878
|
+
// owner-free workspace file or an idle Ledger. Migrating it still publishes
|
|
879
|
+
// the store, preserves the historical evidence, and retires the old paths,
|
|
880
|
+
// so a zero-run import is a normal outcome rather than a refusal.
|
|
881
|
+
const identity = importIdentity(tasks);
|
|
882
|
+
if (receipt && receipt.identity !== identity)
|
|
883
|
+
return refusal("the recorded import identity does not match the legacy facts");
|
|
884
|
+
try {
|
|
885
|
+
for (const task of tasks) writeAuditEvidence(root, task);
|
|
886
|
+
const artifacts = collectRetiredArtifacts(root);
|
|
887
|
+
preserveRetiredArtifacts(root, artifacts);
|
|
888
|
+
const evidencePaths = [
|
|
889
|
+
...tasks.flatMap((task) => [auditTaskRecordPath(task.taskId), auditTerminalProofPath(task.taskId)]),
|
|
890
|
+
...artifacts.map((artifact) => artifact.evidence),
|
|
891
|
+
];
|
|
892
|
+
const dirty = uncommittedEvidencePaths(root, evidencePaths);
|
|
893
|
+
const candidateReady = existsSync(join(root, MIGRATION_STORE_RELATIVE));
|
|
894
|
+
// The commit check is never waived: publication deletes the sources, so
|
|
895
|
+
// the committed evidence must exist even when a candidate is already
|
|
896
|
+
// built.
|
|
897
|
+
if (dirty.length > 0) return refusal("the preserved audit evidence is not committed yet", dirty);
|
|
898
|
+
if (!candidateReady) buildCandidateStore(root, tasks, now);
|
|
899
|
+
try {
|
|
900
|
+
verifyCandidateStore(root, tasks);
|
|
901
|
+
} catch (error) {
|
|
902
|
+
// A candidate this worktree built but never finished — the process died
|
|
903
|
+
// before the schema committed, or before its rows were written — is
|
|
904
|
+
// rebuilt from the source facts. A candidate bound to another worktree
|
|
905
|
+
// stays refused: that identity is never ours to overwrite.
|
|
906
|
+
if (classifyStoreFile(root, join(root, MIGRATION_STORE_RELATIVE)) === "foreign") throw error;
|
|
907
|
+
rebuildCandidateStore(root, tasks, now);
|
|
908
|
+
verifyCandidateStore(root, tasks);
|
|
909
|
+
}
|
|
910
|
+
// The receipt is written before publication so a crash between the rename
|
|
911
|
+
// and the cleanup still leaves a retry that converges: the next run finds
|
|
912
|
+
// the store, verifies it, and finishes removing the retired files.
|
|
913
|
+
writeReceiptDurably(
|
|
914
|
+
root,
|
|
915
|
+
`${JSON.stringify({
|
|
916
|
+
contract: "assurance_kernel/sqlite_import_receipt/v1",
|
|
917
|
+
imported_at: now,
|
|
918
|
+
identity,
|
|
919
|
+
task_ids: tasks.map((task) => task.taskId).sort(),
|
|
920
|
+
}, null, 2)}\n`,
|
|
921
|
+
);
|
|
922
|
+
publishMigrationStoreFile(root, join(root, MIGRATION_STORE_RELATIVE));
|
|
923
|
+
removeRetiredAuthority(root, tasks);
|
|
924
|
+
removeRetiredArtifacts(root, artifacts);
|
|
925
|
+
removeEmptyRetiredDirectories(root);
|
|
926
|
+
return {
|
|
927
|
+
contract: "assurance_kernel/sqlite_import_result/v1",
|
|
928
|
+
outcome: "imported",
|
|
929
|
+
reason: null,
|
|
930
|
+
imported_task_ids: tasks.map((task) => task.taskId).sort(),
|
|
931
|
+
uncommitted_evidence: [],
|
|
932
|
+
};
|
|
933
|
+
} catch (error) {
|
|
934
|
+
return {
|
|
935
|
+
contract: "assurance_kernel/sqlite_import_result/v1",
|
|
936
|
+
outcome: "failed",
|
|
937
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
938
|
+
imported_task_ids: [],
|
|
939
|
+
uncommitted_evidence: [],
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** Read-only diagnosis of an interrupted import, for operators and tests. */
|
|
945
|
+
export function inspectImportState(root: string): { candidate: boolean; receipt: boolean } {
|
|
946
|
+
return {
|
|
947
|
+
candidate: existsSync(resolve(root, MIGRATION_STORE_RELATIVE)),
|
|
948
|
+
receipt: existsSync(resolve(root, MIGRATION_RECEIPT_RELATIVE)),
|
|
949
|
+
};
|
|
950
|
+
}
|