@akagilnc/pi-workflow-roles 0.1.5797 → 0.1.5820

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.
Files changed (80) hide show
  1. package/dist/acp-host/production-host.js +464 -355
  2. package/dist/acp-host/role-turn-host.js +484 -0
  3. package/dist/activation-ledger-session.js +105 -0
  4. package/dist/activation-ledger.js +8 -0
  5. package/dist/activation-trace.js +33 -0
  6. package/dist/audit-escalation.js +7 -0
  7. package/dist/audited-run-identity.js +21 -0
  8. package/dist/auditor-role.js +7 -0
  9. package/dist/book-topology-migration-placement.js +404 -0
  10. package/dist/book-topology-migration.js +125 -0
  11. package/dist/book-topology-migrators-t11.js +677 -0
  12. package/dist/book-topology-mixed-volume-migrators.js +322 -0
  13. package/dist/book-topology-partition-migrators.js +23 -0
  14. package/dist/book-topology-record-class-migrators.js +671 -0
  15. package/dist/book-topology-runs-migrator.js +272 -0
  16. package/dist/collector-role.js +109 -0
  17. package/dist/compliance-transport.js +5 -3
  18. package/dist/countersign-role.js +17 -0
  19. package/dist/diarist-role.js +8 -0
  20. package/dist/diarist.js +15 -0
  21. package/dist/doctor-role.js +80 -0
  22. package/dist/engine-detour-tool.js +51 -37
  23. package/dist/engine-detour.js +22 -11
  24. package/dist/factory-board.js +1478 -0
  25. package/dist/fixer-bash-seatbelt.js +13 -0
  26. package/dist/gatekeeper-role.js +1 -2
  27. package/dist/gh-api-runner.js +81 -0
  28. package/dist/gleaner-left-role.js +8 -0
  29. package/dist/headless-host/production-host.js +464 -355
  30. package/dist/headless-host/role-turn-host.js +691 -0
  31. package/dist/human-format.js +67 -0
  32. package/dist/inspector-role.js +12 -0
  33. package/dist/judge-recording-anti-forge.js +39 -0
  34. package/dist/judge-role.js +21 -3
  35. package/dist/merger-role.js +57 -0
  36. package/dist/migrate-book-topology.js +0 -2
  37. package/dist/navigator-role.js +6 -0
  38. package/dist/navigator-work-context.js +72 -0
  39. package/dist/notary-role.js +113 -0
  40. package/dist/oauth-keepalive.js +149 -0
  41. package/dist/package-contracts/terminating-infrastructure.js +0 -35
  42. package/dist/pi/adapter.js +272 -0
  43. package/dist/public-cli/cli.js +9 -3
  44. package/dist/public-cli/instruction-seat-run.js +163 -44
  45. package/dist/public-cli/main.js +241 -123
  46. package/dist/report-ticket-tool.js +24 -13
  47. package/dist/reviewer-role.js +33 -0
  48. package/dist/role-envelope.js +707 -0
  49. package/dist/role-runtime-dependencies.js +57 -0
  50. package/dist/role-runtime.js +1476 -0
  51. package/dist/secretariat-role.js +5 -0
  52. package/dist/stderr-jsonl.js +26 -0
  53. package/dist/submission-correctable-error.js +2 -6
  54. package/dist/submission-errors.js +0 -30
  55. package/dist/submission-gate.js +61 -54
  56. package/dist/submission-ledger.js +105 -98
  57. package/dist/ticket-snapshot.js +486 -0
  58. package/dist/ticket-trajectory.js +843 -0
  59. package/dist/worker-role.js +184 -0
  60. package/dist/worker-submission-gates.js +333 -0
  61. package/package.json +1 -1
  62. package/resources/engine-dispatch.md +6 -6
  63. package/resources/engines/agy.md +1 -1
  64. package/src/compliance-transport.ts +7 -3
  65. package/src/engine-detour-tool.ts +88 -57
  66. package/src/engine-detour.ts +31 -14
  67. package/src/gatekeeper-role.ts +1 -15
  68. package/src/host-contracts.ts +9 -12
  69. package/src/judge-role.ts +30 -7
  70. package/src/package-contracts/terminating-infrastructure.ts +8 -81
  71. package/src/pi/adapter.ts +0 -1
  72. package/src/public-cli/cli.ts +21 -14
  73. package/src/public-cli/instruction-seat-run.ts +227 -44
  74. package/src/report-ticket-tool.ts +27 -13
  75. package/src/role-envelope.ts +35 -35
  76. package/src/role-runtime.ts +63 -56
  77. package/src/submission-correctable-error.ts +1 -6
  78. package/src/submission-errors.ts +0 -34
  79. package/src/submission-gate.ts +71 -56
  80. package/src/submission-ledger.ts +109 -104
@@ -0,0 +1,671 @@
1
+ /**
2
+ * #866 (T10): record-class partition migrators (build only; execution is #861).
3
+ *
4
+ * Line-closed placement by each row's typed kind (never by source directory alone):
5
+ * - ticket-provenance → <ticket>/records.jsonl (live topology; human view cancelled #900)
6
+ * - submission-ledger kinds → owning run session/submission-ledger/
7
+ * - attempt-history → owning run session/attempt-history/
8
+ * - unknown ownership → unbound/ (never silent discard)
9
+ * - malformed → unbound with exact bytes + malformedRows entry
10
+ * - misplaced: any foreign jsonl whose kind is one of the three classes
11
+ */
12
+ import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
13
+ import { dirname, join, relative, sep } from "node:path";
14
+ import { bookHistoricalRoots, findBookRunDirectory, findPlacedMigratingRun, findUniquePrincipalPlacedRun, isTicketNumberString, listMigrationBookKeys, listMigrationDirents, runCoordsFromSessionParent, runIdFromSubject, runRefFromBoundPath, ticketNumberFromSubject, } from "./book-topology-migration-placement.js";
15
+ import { reconcileMigrationPartition, } from "./book-topology-migration.js";
16
+ import { projectTicketProvenanceHeader } from "./ticket-provenance-contracts.js";
17
+ import { sha256Hex } from "./sha256.js";
18
+ import { isRecord, isEnoent } from "./unknown-value.js";
19
+ /** Kinds the retired #852-era submission-ledger volume held (migration of stored runs only). */
20
+ const LEGACY_SUBMISSION_LEDGER_KINDS = new Set(["candidate", "roundContext", "outcome", "sealed", "post-seal-anomaly"]);
21
+ const TICKET_PROVENANCE = "ticket-provenance";
22
+ const SUBMISSION_LEDGER = "submission-ledger";
23
+ const ATTEMPT_HISTORY = "attempt-history";
24
+ const MISPLACED = "misplaced-record-class";
25
+ function parseJsonlLine(raw) {
26
+ try {
27
+ const value = JSON.parse(raw);
28
+ if (!isRecord(value))
29
+ return { ok: false, raw };
30
+ return { ok: true, value, raw };
31
+ }
32
+ catch {
33
+ return { ok: false, raw };
34
+ }
35
+ }
36
+ function sourceRelative(backupBooksDirectory, absolutePath) {
37
+ return relative(backupBooksDirectory, absolutePath).split(sep).join("/");
38
+ }
39
+ function lineSource(backupBooksDirectory, filePath, index) {
40
+ return `${sourceRelative(backupBooksDirectory, filePath)}#${index + 1}`;
41
+ }
42
+ function stableKey(material) {
43
+ return sha256Hex(material).slice(0, 32);
44
+ }
45
+ async function readJsonlLines(filePath) {
46
+ let text;
47
+ try {
48
+ text = await readFile(filePath, "utf8");
49
+ }
50
+ catch (error) {
51
+ if (isEnoent(error))
52
+ return [];
53
+ throw error;
54
+ }
55
+ if (text.length === 0)
56
+ return [];
57
+ const lines = text.split("\n");
58
+ if (text.endsWith("\n"))
59
+ lines.pop();
60
+ return lines;
61
+ }
62
+ async function ensureDir(path) {
63
+ await mkdir(path, { recursive: true });
64
+ }
65
+ /** Per-destination identity cache — one read per file, O(1) subsequent checks. */
66
+ class RecordWriteCache {
67
+ identities = new Map();
68
+ /** Dest paths written as whole #901 bare volumes — legacy must not append under them. */
69
+ bareVolumeFiles = new Set();
70
+ /** Lines currently on a dest that are still claimed `placed` in outcomes. */
71
+ destPlacements = new Map();
72
+ async load(recordFile) {
73
+ const cached = this.identities.get(recordFile);
74
+ if (cached !== undefined)
75
+ return cached;
76
+ const set = new Set();
77
+ for (const line of await readJsonlLines(recordFile)) {
78
+ if (line.trim() === "")
79
+ continue;
80
+ const parsed = parseJsonlLine(line);
81
+ if (parsed.ok && typeof parsed.value.identity === "string")
82
+ set.add(parsed.value.identity);
83
+ }
84
+ this.identities.set(recordFile, set);
85
+ return set;
86
+ }
87
+ markBareVolume(recordFile) {
88
+ this.bareVolumeFiles.add(recordFile);
89
+ this.identities.set(recordFile, new Set());
90
+ }
91
+ isBareVolume(recordFile) {
92
+ return this.bareVolumeFiles.has(recordFile);
93
+ }
94
+ /**
95
+ * After a bare whole-file write, register its lines so a later bare for the
96
+ * same ticket can rehome them and flip dispositions (multi-bare honesty).
97
+ */
98
+ setDestPlacements(recordFile, placements) {
99
+ this.destPlacements.set(recordFile, [...placements]);
100
+ }
101
+ /**
102
+ * Install a bare volume as the sole ticket file. Any lines already placed on
103
+ * this dest (legacy appends or a prior bare) are moved to unbound and their
104
+ * outcomes flipped — report stays honest.
105
+ */
106
+ async replaceWholeBare(recordFile, body, outcomes, unboundFileForRaw) {
107
+ const prior = this.destPlacements.get(recordFile) ?? [];
108
+ for (const placement of prior) {
109
+ await this.append(unboundFileForRaw(placement.raw), placement.raw);
110
+ const previous = outcomes[placement.outcomeIndex];
111
+ if (previous !== undefined) {
112
+ outcomes[placement.outcomeIndex] = {
113
+ disposition: "unbound",
114
+ source: previous.source,
115
+ };
116
+ }
117
+ }
118
+ this.destPlacements.delete(recordFile);
119
+ await ensureDir(dirname(recordFile));
120
+ const text = body.endsWith("\n") ? body : `${body}\n`;
121
+ await writeFile(recordFile, text, "utf8");
122
+ this.markBareVolume(recordFile);
123
+ }
124
+ async append(recordFile, raw, options) {
125
+ await ensureDir(dirname(recordFile));
126
+ const line = raw.endsWith("\n") ? raw : `${raw}\n`;
127
+ const parsed = parseJsonlLine(raw);
128
+ if (parsed.ok && typeof parsed.value.identity === "string") {
129
+ const existing = await this.load(recordFile);
130
+ if (!existing.has(parsed.value.identity)) {
131
+ existing.add(parsed.value.identity);
132
+ await appendFile(recordFile, line, "utf8");
133
+ }
134
+ // Identity hit skips a second physical copy, but the source row still claims
135
+ // this dest — register outcome below so bare replace flips every claim.
136
+ }
137
+ else {
138
+ if (!this.identities.has(recordFile)) {
139
+ // Ensure subsequent identity loads see a live map even when first rows lack identity.
140
+ this.identities.set(recordFile, await this.load(recordFile));
141
+ }
142
+ await appendFile(recordFile, line, "utf8");
143
+ }
144
+ // Register outcome claims on dest even when identity skipped a write.
145
+ if (options?.outcomeIndex !== undefined && !this.bareVolumeFiles.has(recordFile)) {
146
+ const list = this.destPlacements.get(recordFile) ?? [];
147
+ list.push({ raw, outcomeIndex: options.outcomeIndex });
148
+ this.destPlacements.set(recordFile, list);
149
+ }
150
+ }
151
+ }
152
+ function roleFromPayload(payload) {
153
+ if (isRecord(payload) && typeof payload.role === "string" && payload.role.length > 0) {
154
+ return payload.role;
155
+ }
156
+ return undefined;
157
+ }
158
+ function runIdFromPayload(payload) {
159
+ return isRecord(payload) && typeof payload.runId === "string" && payload.runId.length > 0
160
+ ? payload.runId
161
+ : undefined;
162
+ }
163
+ function recordClassOfKind(kind) {
164
+ if (kind === TICKET_PROVENANCE)
165
+ return TICKET_PROVENANCE;
166
+ if (kind === ATTEMPT_HISTORY)
167
+ return ATTEMPT_HISTORY;
168
+ if (typeof kind === "string" && LEGACY_SUBMISSION_LEDGER_KINDS.has(kind))
169
+ return SUBMISSION_LEDGER;
170
+ return undefined;
171
+ }
172
+ async function listFilesRecursive(root, predicate) {
173
+ const out = [];
174
+ async function walk(directory) {
175
+ for (const entry of await listMigrationDirents(directory)) {
176
+ const path = join(directory, entry.name);
177
+ if (entry.isDirectory())
178
+ await walk(path);
179
+ else if (entry.isFile() && predicate(entry.name))
180
+ out.push(path);
181
+ }
182
+ }
183
+ await walk(root);
184
+ return out;
185
+ }
186
+ /**
187
+ * Home partition sources under one record-class root:
188
+ * - direct `<category>/records.jsonl` (legacy flat book-root volume)
189
+ * - hashed sub-volumes `<category>/<volumeId>/records.jsonl`
190
+ * Both enter the same line-closed placement path; ENOENT yields empty reads.
191
+ */
192
+ async function listVolumeRecordFiles(partitionDir) {
193
+ const files = [join(partitionDir, "records.jsonl")];
194
+ for (const entry of await listMigrationDirents(partitionDir)) {
195
+ if (!entry.isDirectory())
196
+ continue;
197
+ files.push(join(partitionDir, entry.name, "records.jsonl"));
198
+ }
199
+ return files;
200
+ }
201
+ /**
202
+ * Paths already closed by the three home migrators — including the partition-root
203
+ * direct records.jsonl. Criterion (not a partition name list): root record-class
204
+ * trees and nested ticket-provenance. Run trees are NOT blanket-skipped — wrong-kind
205
+ * rows inside a run must still rehome by content (#866 / #852 §落错).
206
+ */
207
+ function isHomeRecordClassRelativePath(relPosix) {
208
+ const parts = relPosix.split("/");
209
+ if (parts[0] === TICKET_PROVENANCE || parts[0] === SUBMISSION_LEDGER || parts[0] === ATTEMPT_HISTORY) {
210
+ return true;
211
+ }
212
+ if (parts.length >= 2 && isTicketNumberString(parts[0])) {
213
+ // Partial-nest era + live ticket-root volume (records.jsonl at ticket dir).
214
+ if (parts[1] === TICKET_PROVENANCE)
215
+ return true;
216
+ if (parts.length === 2 && parts[1] === "records.jsonl")
217
+ return true;
218
+ }
219
+ return false;
220
+ }
221
+ /** Canonical nested path for a run-owned record class; ticket-provenance is never run-nested. */
222
+ function isCanonicalRunNestedRecordFile(relPosix, recordClass) {
223
+ if (recordClass === TICKET_PROVENANCE)
224
+ return false;
225
+ const needle = `/session/${recordClass}/records.jsonl`;
226
+ return relPosix.endsWith(needle) || relPosix.endsWith(needle.slice(1));
227
+ }
228
+ function runCoordsFromRelativePath(relPosix) {
229
+ const parts = relPosix.split("/");
230
+ const runsIndex = parts.indexOf("runs");
231
+ if (runsIndex < 0 || runsIndex + 1 >= parts.length)
232
+ return undefined;
233
+ const leaf = parts[runsIndex + 1];
234
+ const at = leaf.indexOf("@");
235
+ if (at <= 0 || at === leaf.length - 1)
236
+ return undefined;
237
+ return { runId: leaf.slice(0, at), role: leaf.slice(at + 1) };
238
+ }
239
+ /** Typed owning run for a record-class row (subject / payload / sessionParent). */
240
+ function owningRunIdFromRecord(value) {
241
+ return (runIdFromSubject(value.subject)
242
+ ?? runIdFromPayload(value.payload)
243
+ ?? runCoordsFromSessionParent(value.sessionParent)?.runId);
244
+ }
245
+ /**
246
+ * Row already sits at the correct run's canonical nest — #865 carries the file.
247
+ * Path-class alone is not enough: a submission-ledger row under run A that names
248
+ * run B must still rehome (#866 run-principal).
249
+ */
250
+ function isAlreadyHomeUnderOwningRun(withinBook, recordClass, value) {
251
+ if (!isCanonicalRunNestedRecordFile(withinBook, recordClass))
252
+ return false;
253
+ const pathCoords = runCoordsFromRelativePath(withinBook);
254
+ if (pathCoords === undefined)
255
+ return false;
256
+ const owningRunId = owningRunIdFromRecord(value);
257
+ return owningRunId !== undefined && owningRunId === pathCoords.runId;
258
+ }
259
+ function relativePathWithinRun(relPosix) {
260
+ const parts = relPosix.split("/");
261
+ const runsIndex = parts.indexOf("runs");
262
+ if (runsIndex < 0 || runsIndex + 2 >= parts.length)
263
+ return undefined;
264
+ return parts.slice(runsIndex + 2).join("/");
265
+ }
266
+ function unboundCategoryFile(booksDirectory, bookKey, category, key) {
267
+ return join(booksDirectory, bookKey, "unbound", category, key, "records.jsonl");
268
+ }
269
+ async function resolveRunDestination(context, bookKey, runId, hints) {
270
+ const hasSessionParent = typeof hints.sessionParent === "string" && hints.sessionParent.length > 0;
271
+ const bound = hasSessionParent
272
+ ? runRefFromBoundPath(hints.sessionParent, bookHistoricalRoots(context.booksDirectory, context.backupBooksDirectory, bookKey))
273
+ : undefined;
274
+ // Path present but not bound to this book: never steal role or unique-run-guess.
275
+ if (hasSessionParent && bound === undefined) {
276
+ return { kind: "unbound-key", key: stableKey(runId) };
277
+ }
278
+ if (bound !== undefined) {
279
+ const existingDest = await findPlacedMigratingRun(context.booksDirectory, bookKey, bound.leaf, bound.sourceRelative);
280
+ if (existingDest !== undefined) {
281
+ return {
282
+ kind: "run",
283
+ runDirectory: existingDest.runDirectory,
284
+ disposition: existingDest.disposition,
285
+ };
286
+ }
287
+ return { kind: "unbound-key", key: stableKey(bound.leaf) };
288
+ }
289
+ if (hints.role !== undefined && hints.role.length > 0) {
290
+ const existingDest = await findPlacedMigratingRun(context.booksDirectory, bookKey, `${runId}@${hints.role}`);
291
+ if (existingDest !== undefined) {
292
+ return {
293
+ kind: "run",
294
+ runDirectory: existingDest.runDirectory,
295
+ disposition: existingDest.disposition,
296
+ };
297
+ }
298
+ return { kind: "unbound-key", key: stableKey(`${runId}@${hints.role}`) };
299
+ }
300
+ // No path, no role: follow only a uniquely matching principal in this book.
301
+ const uniquePrincipal = await findUniquePrincipalPlacedRun(context.booksDirectory, bookKey, runId);
302
+ if (uniquePrincipal !== undefined) {
303
+ return {
304
+ kind: "run",
305
+ runDirectory: uniquePrincipal.runDirectory,
306
+ disposition: uniquePrincipal.disposition,
307
+ };
308
+ }
309
+ return { kind: "unbound-key", key: stableKey(runId) };
310
+ }
311
+ async function placeTicketProvenanceLine(context, bookKey, writes, source, raw, value,
312
+ /** Outcomes length at call time = index of the outcome the caller is about to push. */
313
+ outcomeIndex) {
314
+ if (value === undefined) {
315
+ await writes.append(unboundCategoryFile(context.booksDirectory, bookKey, TICKET_PROVENANCE, stableKey(raw)), raw);
316
+ return { disposition: "unbound", source, malformed: true, malformedRaw: raw };
317
+ }
318
+ const ticketNumber = ticketNumberFromSubject(value.subject);
319
+ if (ticketNumber === undefined) {
320
+ await writes.append(unboundCategoryFile(context.booksDirectory, bookKey, TICKET_PROVENANCE, stableKey(raw)), raw);
321
+ return { disposition: "unbound", source };
322
+ }
323
+ // Live topology (docs/dossier-topology.md): ticket root holds records.jsonl.
324
+ const dest = join(context.booksDirectory, bookKey, String(ticketNumber), "records.jsonl");
325
+ // Never append legacy SitianRecord under a bare #901 volume (header must stay first).
326
+ if (writes.isBareVolume(dest)) {
327
+ await writes.append(unboundCategoryFile(context.booksDirectory, bookKey, TICKET_PROVENANCE, stableKey(raw)), raw);
328
+ return { disposition: "unbound", source };
329
+ }
330
+ const existing = await readJsonlLines(dest);
331
+ if (tryBareTicketProvenanceVolume(existing) !== undefined) {
332
+ writes.markBareVolume(dest);
333
+ await writes.append(unboundCategoryFile(context.booksDirectory, bookKey, TICKET_PROVENANCE, stableKey(raw)), raw);
334
+ return { disposition: "unbound", source };
335
+ }
336
+ await writes.append(dest, raw, { outcomeIndex });
337
+ return { disposition: "placed", source };
338
+ }
339
+ async function placeRunOwnedLine(context, bookKey, writes, category, source, raw, value) {
340
+ if (value === undefined) {
341
+ await writes.append(unboundCategoryFile(context.booksDirectory, bookKey, category, stableKey(raw)), raw);
342
+ return { disposition: "unbound", source, malformed: true, malformedRaw: raw };
343
+ }
344
+ const fromParent = runCoordsFromSessionParent(value.sessionParent);
345
+ const runId = runIdFromSubject(value.subject)
346
+ ?? runIdFromPayload(value.payload)
347
+ ?? fromParent?.runId;
348
+ if (runId === undefined) {
349
+ await writes.append(unboundCategoryFile(context.booksDirectory, bookKey, category, stableKey(raw)), raw);
350
+ return { disposition: "unbound", source };
351
+ }
352
+ const role = roleFromPayload(value.payload);
353
+ const target = await resolveRunDestination(context, bookKey, runId, {
354
+ ...(role === undefined ? {} : { role }),
355
+ ...(value.sessionParent === undefined ? {} : { sessionParent: value.sessionParent }),
356
+ });
357
+ if (target.kind === "unbound-key") {
358
+ await writes.append(unboundCategoryFile(context.booksDirectory, bookKey, category, target.key), raw);
359
+ return { disposition: "unbound", source };
360
+ }
361
+ await writes.append(join(target.runDirectory, "session", category, "records.jsonl"), raw);
362
+ return { disposition: target.disposition, source };
363
+ }
364
+ /** Place one parsed row by its typed kind; unknown kinds fall back to the home category unbound. */
365
+ async function placeRecordClassLine(context, bookKey, writes, source, raw, value, fallbackCategory, outcomeIndex) {
366
+ const recordClass = recordClassOfKind(value.kind);
367
+ if (recordClass === TICKET_PROVENANCE) {
368
+ return placeTicketProvenanceLine(context, bookKey, writes, source, raw, value, outcomeIndex);
369
+ }
370
+ if (recordClass === SUBMISSION_LEDGER || recordClass === ATTEMPT_HISTORY) {
371
+ return placeRunOwnedLine(context, bookKey, writes, recordClass, source, raw, value);
372
+ }
373
+ // Unknown kind inside a record-class home volume: preserve under that home's unbound.
374
+ await writes.append(unboundCategoryFile(context.booksDirectory, bookKey, fallbackCategory, stableKey(raw)), raw);
375
+ return { disposition: "unbound", source };
376
+ }
377
+ /**
378
+ * #901 bare diary volume: first non-empty line is a header without SitianRecord
379
+ * `kind`. Recognise the whole file as one ticket volume — do not split rows into unbound.
380
+ */
381
+ function tryBareTicketProvenanceVolume(lines) {
382
+ const body = [];
383
+ for (const raw of lines) {
384
+ if (raw.trim() === "")
385
+ continue;
386
+ body.push(raw);
387
+ }
388
+ if (body.length === 0)
389
+ return undefined;
390
+ const first = parseJsonlLine(body[0]);
391
+ if (!first.ok)
392
+ return undefined;
393
+ // Legacy SitianRecord rows carry a typed kind — leave them to line placement.
394
+ if (recordClassOfKind(first.value.kind) !== undefined)
395
+ return undefined;
396
+ const header = projectTicketProvenanceHeader(first.value);
397
+ if (header === undefined || header.ticket === null)
398
+ return undefined;
399
+ return { ticket: header.ticket, body };
400
+ }
401
+ async function placeBareTicketProvenanceVolume(context, bookKey, writes, filePath, bare, outcomes) {
402
+ const dest = join(context.booksDirectory, bookKey, String(bare.ticket), "records.jsonl");
403
+ // Whole-file bare install. Prior dest lines (legacy or earlier bare) rehome to
404
+ // unbound with flipped dispositions — header stays first; no silent erase.
405
+ const body = bare.body.map((raw) => (raw.endsWith("\n") ? raw : `${raw}\n`)).join("");
406
+ await writes.replaceWholeBare(dest, body, outcomes, (raw) => unboundCategoryFile(context.booksDirectory, bookKey, TICKET_PROVENANCE, stableKey(raw)));
407
+ const barePlacements = [];
408
+ for (let index = 0; index < bare.body.length; index += 1) {
409
+ const source = lineSource(context.backupBooksDirectory, filePath, index);
410
+ const outcomeIndex = outcomes.length;
411
+ outcomes.push({ disposition: "placed", source });
412
+ barePlacements.push({ raw: bare.body[index], outcomeIndex });
413
+ }
414
+ // Register so a later bare for the same ticket can supersede honestly.
415
+ writes.setDestPlacements(dest, barePlacements);
416
+ }
417
+ async function migrateJsonlFileByKind(context, bookKey, writes, filePath, fallbackCategory, outcomes) {
418
+ const lines = await readJsonlLines(filePath);
419
+ if (fallbackCategory === TICKET_PROVENANCE) {
420
+ const bare = tryBareTicketProvenanceVolume(lines);
421
+ if (bare !== undefined) {
422
+ await placeBareTicketProvenanceVolume(context, bookKey, writes, filePath, bare, outcomes);
423
+ return;
424
+ }
425
+ }
426
+ for (let index = 0; index < lines.length; index += 1) {
427
+ const raw = lines[index];
428
+ if (raw.trim() === "")
429
+ continue;
430
+ const source = lineSource(context.backupBooksDirectory, filePath, index);
431
+ const parsed = parseJsonlLine(raw);
432
+ if (!parsed.ok) {
433
+ // Malformed: preserve under the home partition's unbound bucket.
434
+ if (fallbackCategory === TICKET_PROVENANCE) {
435
+ outcomes.push(await placeTicketProvenanceLine(context, bookKey, writes, source, raw, undefined, outcomes.length));
436
+ }
437
+ else {
438
+ outcomes.push(await placeRunOwnedLine(context, bookKey, writes, fallbackCategory, source, raw, undefined));
439
+ }
440
+ continue;
441
+ }
442
+ outcomes.push(await placeRecordClassLine(context, bookKey, writes, source, raw, parsed.value, fallbackCategory, outcomes.length));
443
+ }
444
+ }
445
+ const OFFERED_IDENTITIES = "offered-identities.jsonl";
446
+ /**
447
+ * Merge offered-identities from one source volume into the destination ticket
448
+ * dir by identity key. Never last-source-wins overwrite of the whole file.
449
+ */
450
+ async function mergeOfferedIdentities(destDir, volumeDir, writes) {
451
+ const from = join(volumeDir, OFFERED_IDENTITIES);
452
+ let lines;
453
+ try {
454
+ await stat(from);
455
+ lines = await readJsonlLines(from);
456
+ }
457
+ catch (error) {
458
+ if (isEnoent(error))
459
+ return;
460
+ throw error;
461
+ }
462
+ const destFile = join(destDir, OFFERED_IDENTITIES);
463
+ for (const raw of lines) {
464
+ if (raw.trim() === "")
465
+ continue;
466
+ await writes.append(destFile, raw);
467
+ }
468
+ }
469
+ /** Collect typed ticket numbers present in a source volume's records.jsonl. */
470
+ async function ticketNumbersInVolume(volumeDir) {
471
+ const lines = await readJsonlLines(join(volumeDir, "records.jsonl"));
472
+ const bare = tryBareTicketProvenanceVolume(lines);
473
+ if (bare !== undefined)
474
+ return [bare.ticket];
475
+ const out = [];
476
+ const seen = new Set();
477
+ for (const raw of lines) {
478
+ if (raw.trim() === "")
479
+ continue;
480
+ const parsed = parseJsonlLine(raw);
481
+ if (!parsed.ok)
482
+ continue;
483
+ if (recordClassOfKind(parsed.value.kind) !== TICKET_PROVENANCE)
484
+ continue;
485
+ const ticketNumber = ticketNumberFromSubject(parsed.value.subject);
486
+ if (ticketNumber === undefined || seen.has(ticketNumber))
487
+ continue;
488
+ seen.add(ticketNumber);
489
+ out.push(ticketNumber);
490
+ }
491
+ return out;
492
+ }
493
+ async function mergeCompanionsFromVolume(context, bookKey, volumeDir, writes) {
494
+ for (const ticketNumber of await ticketNumbersInVolume(volumeDir)) {
495
+ const destDir = join(context.booksDirectory, bookKey, String(ticketNumber));
496
+ await ensureDir(destDir);
497
+ await mergeOfferedIdentities(destDir, volumeDir, writes);
498
+ }
499
+ }
500
+ async function migrateHomePartitionBook(context, bookKey, writes, category, outcomes) {
501
+ const backupBook = join(context.backupBooksDirectory, bookKey);
502
+ // listVolumeRecordFiles covers partition-root records.jsonl + hashed sub-volumes.
503
+ const rootFiles = await listVolumeRecordFiles(join(backupBook, category));
504
+ for (const filePath of rootFiles) {
505
+ await migrateJsonlFileByKind(context, bookKey, writes, filePath, category, outcomes);
506
+ if (category === TICKET_PROVENANCE) {
507
+ await mergeCompanionsFromVolume(context, bookKey, dirname(filePath), writes);
508
+ }
509
+ }
510
+ if (category !== TICKET_PROVENANCE)
511
+ return;
512
+ // One ticket-dir walk: partial-nest <ticket>/ticket-provenance/ and live
513
+ // <ticket>/ root volumes share existence check, line placement, companions.
514
+ for (const entry of await listMigrationDirents(backupBook)) {
515
+ if (!entry.isDirectory() || !isTicketNumberString(entry.name))
516
+ continue;
517
+ const ticketDir = join(backupBook, entry.name);
518
+ for (const volumeDir of [
519
+ join(ticketDir, TICKET_PROVENANCE),
520
+ ticketDir,
521
+ ]) {
522
+ const recordsFile = join(volumeDir, "records.jsonl");
523
+ try {
524
+ await stat(recordsFile);
525
+ }
526
+ catch (error) {
527
+ if (isEnoent(error))
528
+ continue;
529
+ throw error;
530
+ }
531
+ await migrateJsonlFileByKind(context, bookKey, writes, recordsFile, TICKET_PROVENANCE, outcomes);
532
+ await mergeCompanionsFromVolume(context, bookKey, volumeDir, writes);
533
+ }
534
+ }
535
+ }
536
+ function normalizeJsonlRaw(raw) {
537
+ return raw.endsWith("\n") ? raw : `${raw}\n`;
538
+ }
539
+ /** Rewrite dest file without the given exact raw lines (drop wrong-nest copies after rehome). */
540
+ async function scrubRawLinesFromFile(recordFile, rawLines) {
541
+ if (rawLines.size === 0)
542
+ return;
543
+ const lines = await readJsonlLines(recordFile);
544
+ if (lines.length === 0)
545
+ return;
546
+ const kept = [];
547
+ let changed = false;
548
+ for (const raw of lines) {
549
+ if (raw.trim() === "")
550
+ continue;
551
+ const normalized = normalizeJsonlRaw(raw);
552
+ if (rawLines.has(normalized)) {
553
+ changed = true;
554
+ continue;
555
+ }
556
+ kept.push(normalized);
557
+ }
558
+ if (!changed)
559
+ return;
560
+ await ensureDir(dirname(recordFile));
561
+ await writeFile(recordFile, kept.join(""), "utf8");
562
+ }
563
+ /**
564
+ * Foreign jsonl rows whose typed kind is one of the three record classes.
565
+ * Range = whole book minus home partitions (root records.jsonl, hashed volumes,
566
+ * nested ticket-provenance) — those close under the three home migrators so T8
567
+ * does not double-count. Inside runs/: skip only rows already under their typed
568
+ * owning run at the canonical nest (those travel with #865); path-class alone
569
+ * never excuses a cross-run wrong principal. Every other typed row rehomes by
570
+ * content; dest copies at the source nest are scrubbed so a recursive run cp
571
+ * cannot keep a lying duplicate.
572
+ */
573
+ async function migrateMisplacedBook(context, bookKey, writes, outcomes) {
574
+ const backupBook = join(context.backupBooksDirectory, bookKey);
575
+ const files = await listFilesRecursive(backupBook, (name) => name.endsWith(".jsonl"));
576
+ // backupRelPath → exact raw lines rehomed out of a source-run nest (wrong path or wrong principal).
577
+ // Match by line bytes, not identity — rows without identity must still leave the lying source copy.
578
+ const scrubPlans = new Map();
579
+ for (const filePath of files) {
580
+ const rel = sourceRelative(context.backupBooksDirectory, filePath);
581
+ const withinBook = rel.split("/").slice(1).join("/");
582
+ // Home partition roots (incl. direct records.jsonl) are owned by home migrators.
583
+ if (isHomeRecordClassRelativePath(withinBook))
584
+ continue;
585
+ const lines = await readJsonlLines(filePath);
586
+ for (let index = 0; index < lines.length; index += 1) {
587
+ const raw = lines[index];
588
+ if (raw.trim() === "")
589
+ continue;
590
+ const parsed = parseJsonlLine(raw);
591
+ if (!parsed.ok)
592
+ continue;
593
+ const recordClass = recordClassOfKind(parsed.value.kind);
594
+ if (recordClass === undefined)
595
+ continue;
596
+ // Correct principal + canonical nest — #865 carries the file as-is.
597
+ if (isAlreadyHomeUnderOwningRun(withinBook, recordClass, parsed.value))
598
+ continue;
599
+ const source = lineSource(context.backupBooksDirectory, filePath, index);
600
+ if (recordClass === TICKET_PROVENANCE) {
601
+ outcomes.push(await placeTicketProvenanceLine(context, bookKey, writes, source, raw, parsed.value, outcomes.length));
602
+ }
603
+ else {
604
+ outcomes.push(await placeRunOwnedLine(context, bookKey, writes, recordClass, source, raw, parsed.value));
605
+ }
606
+ if (withinBook.includes("/runs/") || withinBook.startsWith("runs/")) {
607
+ let set = scrubPlans.get(withinBook);
608
+ if (set === undefined) {
609
+ set = new Set();
610
+ scrubPlans.set(withinBook, set);
611
+ }
612
+ set.add(normalizeJsonlRaw(raw));
613
+ }
614
+ }
615
+ }
616
+ // Drop rehomed lines from the source nest in dest (wrong path or wrong principal).
617
+ for (const [withinBook, rawLines] of scrubPlans) {
618
+ const coords = runCoordsFromRelativePath(withinBook);
619
+ const withinRun = relativePathWithinRun(withinBook);
620
+ if (coords === undefined || withinRun === undefined)
621
+ continue;
622
+ // Scrub plan already carries runId+role from the source leaf — keep the full identity.
623
+ const destRun = await findBookRunDirectory(join(context.booksDirectory, bookKey), coords.runId, coords.role);
624
+ if (destRun === undefined)
625
+ continue;
626
+ await scrubRawLinesFromFile(join(destRun.runDirectory, withinRun), rawLines);
627
+ }
628
+ }
629
+ async function forEachBook(context, body) {
630
+ for (const bookKey of await listMigrationBookKeys(context.backupBooksDirectory)) {
631
+ await body(bookKey, new RecordWriteCache());
632
+ }
633
+ }
634
+ export const ticketProvenancePartitionMigrator = {
635
+ partition: TICKET_PROVENANCE,
636
+ async migrate(context) {
637
+ const outcomes = [];
638
+ await forEachBook(context, (bookKey, writes) => migrateHomePartitionBook(context, bookKey, writes, TICKET_PROVENANCE, outcomes));
639
+ return reconcileMigrationPartition(TICKET_PROVENANCE, "lines", outcomes);
640
+ },
641
+ };
642
+ export const submissionLedgerPartitionMigrator = {
643
+ partition: SUBMISSION_LEDGER,
644
+ async migrate(context) {
645
+ const outcomes = [];
646
+ await forEachBook(context, (bookKey, writes) => migrateHomePartitionBook(context, bookKey, writes, SUBMISSION_LEDGER, outcomes));
647
+ return reconcileMigrationPartition(SUBMISSION_LEDGER, "lines", outcomes);
648
+ },
649
+ };
650
+ export const attemptHistoryPartitionMigrator = {
651
+ partition: ATTEMPT_HISTORY,
652
+ async migrate(context) {
653
+ const outcomes = [];
654
+ await forEachBook(context, (bookKey, writes) => migrateHomePartitionBook(context, bookKey, writes, ATTEMPT_HISTORY, outcomes));
655
+ return reconcileMigrationPartition(ATTEMPT_HISTORY, "lines", outcomes);
656
+ },
657
+ };
658
+ export const misplacedRecordClassPartitionMigrator = {
659
+ partition: MISPLACED,
660
+ async migrate(context) {
661
+ const outcomes = [];
662
+ await forEachBook(context, (bookKey, writes) => migrateMisplacedBook(context, bookKey, writes, outcomes));
663
+ return reconcileMigrationPartition(MISPLACED, "lines", outcomes);
664
+ },
665
+ };
666
+ export const BOOK_TOPOLOGY_RECORD_CLASS_MIGRATORS = [
667
+ ticketProvenancePartitionMigrator,
668
+ submissionLedgerPartitionMigrator,
669
+ attemptHistoryPartitionMigrator,
670
+ misplacedRecordClassPartitionMigrator,
671
+ ];