@akagilnc/pi-workflow-roles 0.1.4062 → 0.1.4196
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/dist/acp-host/production-host.js +2075 -1088
- package/dist/archivist-record-entry.js +204 -20
- package/dist/archivist-record-topology.js +36 -16
- package/dist/auditor-dossier-tool.js +2 -1
- package/dist/diarist-contracts.js +96 -0
- package/dist/doctor-evidence.js +3 -2
- package/dist/headless-host/production-host.js +2215 -1228
- package/dist/migrate-book-topology.js +278917 -0
- package/dist/navigator-public-session.js +22 -25
- package/dist/notary-source-run.js +7 -4
- package/dist/pi/durable-principal.js +9 -6
- package/dist/public-cli/auto-resume.js +2 -5
- package/dist/public-cli/diarist-run.js +27 -25
- package/dist/public-cli/instruction-seat-run.js +8 -11
- package/dist/public-cli/invocation.js +127 -63
- package/dist/public-cli/main.js +1507 -645
- package/dist/public-cli/option-definitions.js +2 -2
- package/dist/public-cli/post-admission.js +47 -12
- package/dist/public-cli/run-lifecycle.js +206 -58
- package/dist/public-cli/seat-ticket-binding.js +3 -6
- package/dist/public-cli/settlement.js +5 -3
- package/dist/role-run-placement.js +84 -0
- package/dist/role-run-relocation.js +358 -0
- package/dist/run-terminal-artifacts.js +2 -1
- package/dist/run-ticket-number.js +51 -32
- package/dist/sitian-appender.js +11 -26
- package/dist/submission-ledger.js +78 -19
- package/dist/ticket-provenance.js +3 -1
- package/package.json +3 -2
- package/scripts/build-package.mjs +34 -2
- package/souls/countersign.md +2 -2
- package/souls/fixer.md +3 -2
- package/src/analyst-ledger.ts +14 -15
- package/src/archivist-record-entry.ts +285 -38
- package/src/archivist-record-topology.ts +43 -18
- package/src/auditor-dossier-tool.ts +2 -1
- package/src/book-topology-migration-placement.ts +493 -0
- package/src/book-topology-migration.ts +220 -0
- package/src/book-topology-migrators-t11.ts +856 -0
- package/src/book-topology-mixed-volume-migrators.ts +470 -0
- package/src/book-topology-partition-migrators.ts +25 -0
- package/src/book-topology-record-class-migrators.ts +779 -0
- package/src/book-topology-runs-migrator.ts +244 -0
- package/src/diarist-contracts.ts +116 -0
- package/src/diarist-role.ts +6 -0
- package/src/diarist.ts +2 -0
- package/src/doctor-evidence.ts +4 -1
- package/src/doctor-role.ts +1 -1
- package/src/navigator-public-session.ts +33 -30
- package/src/notary-source-run.ts +9 -4
- package/src/pi/durable-principal.ts +9 -13
- package/src/public-cli/auto-resume.ts +2 -6
- package/src/public-cli/countersign-run.ts +182 -32
- package/src/public-cli/diarist-run.ts +34 -26
- package/src/public-cli/instruction-seat-run.ts +7 -11
- package/src/public-cli/invocation.ts +192 -58
- package/src/public-cli/option-definitions.ts +2 -2
- package/src/public-cli/post-admission.ts +73 -13
- package/src/public-cli/run-lifecycle.ts +255 -65
- package/src/public-cli/seat-ticket-binding.ts +3 -6
- package/src/public-cli/settlement.ts +6 -4
- package/src/role-run-placement.ts +128 -0
- package/src/role-run-relocation.ts +491 -0
- package/src/role-runtime.ts +11 -5
- package/src/run-terminal-artifacts.ts +3 -1
- package/src/run-ticket-number.ts +80 -20
- package/src/sitian-appender.ts +25 -28
- package/src/submission-ledger.ts +102 -19
- package/src/ticket-provenance.ts +4 -1
- package/src/ticket-trajectory.ts +22 -35
- package/src/worker-role.ts +4 -4
- package/src/worker-submission-gates.ts +6 -6
|
@@ -0,0 +1,779 @@
|
|
|
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 + human view; live topology)
|
|
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 { createHash } from "node:crypto";
|
|
13
|
+
import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
14
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
bookHistoricalRoots,
|
|
18
|
+
findBookRunDirectory,
|
|
19
|
+
findPlacedMigratingRun,
|
|
20
|
+
findUniquePrincipalPlacedRun,
|
|
21
|
+
isMigrationEnoent,
|
|
22
|
+
isTicketNumberString,
|
|
23
|
+
listMigrationBookKeys,
|
|
24
|
+
listMigrationDirents,
|
|
25
|
+
runCoordsFromSessionParent,
|
|
26
|
+
runIdFromSubject,
|
|
27
|
+
runRefFromBoundPath,
|
|
28
|
+
ticketNumberFromSubject,
|
|
29
|
+
} from "./book-topology-migration-placement.ts";
|
|
30
|
+
import {
|
|
31
|
+
reconcileMigrationPartition,
|
|
32
|
+
type BookTopologyMigrationContext,
|
|
33
|
+
type BookTopologyPartitionMigrator,
|
|
34
|
+
type MigrationItemOutcome,
|
|
35
|
+
} from "./book-topology-migration.ts";
|
|
36
|
+
import { S4_SUBMISSION_LEDGER_KINDS } from "./sitian-appender.ts";
|
|
37
|
+
import {
|
|
38
|
+
projectTicketProvenanceEntry,
|
|
39
|
+
TICKET_PROVENANCE_HUMAN_VIEW,
|
|
40
|
+
} from "./ticket-provenance-contracts.ts";
|
|
41
|
+
import { renderTicketProvenanceMarkdown } from "./ticket-provenance.ts";
|
|
42
|
+
|
|
43
|
+
const TICKET_PROVENANCE = "ticket-provenance";
|
|
44
|
+
const SUBMISSION_LEDGER = "submission-ledger";
|
|
45
|
+
const ATTEMPT_HISTORY = "attempt-history";
|
|
46
|
+
const MISPLACED = "misplaced-record-class";
|
|
47
|
+
|
|
48
|
+
function noteTouchedTicket(
|
|
49
|
+
context: BookTopologyMigrationContext,
|
|
50
|
+
bookKey: string,
|
|
51
|
+
ticketNumber: number,
|
|
52
|
+
): void {
|
|
53
|
+
let set = context.touchedTicketsByBook.get(bookKey);
|
|
54
|
+
if (set === undefined) {
|
|
55
|
+
set = new Set<number>();
|
|
56
|
+
context.touchedTicketsByBook.set(bookKey, set);
|
|
57
|
+
}
|
|
58
|
+
set.add(ticketNumber);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type RecordClass = typeof TICKET_PROVENANCE | typeof SUBMISSION_LEDGER | typeof ATTEMPT_HISTORY;
|
|
62
|
+
|
|
63
|
+
type JsonLine =
|
|
64
|
+
| { readonly ok: true; readonly value: Record<string, unknown>; readonly raw: string }
|
|
65
|
+
| { readonly ok: false; readonly raw: string };
|
|
66
|
+
|
|
67
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
68
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseJsonlLine(raw: string): JsonLine {
|
|
72
|
+
try {
|
|
73
|
+
const value: unknown = JSON.parse(raw);
|
|
74
|
+
if (!isRecord(value)) return { ok: false, raw };
|
|
75
|
+
return { ok: true, value, raw };
|
|
76
|
+
} catch {
|
|
77
|
+
return { ok: false, raw };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sourceRelative(backupBooksDirectory: string, absolutePath: string): string {
|
|
82
|
+
return relative(backupBooksDirectory, absolutePath).split(sep).join("/");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function lineSource(backupBooksDirectory: string, filePath: string, index: number): string {
|
|
86
|
+
return `${sourceRelative(backupBooksDirectory, filePath)}#${index + 1}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function stableKey(material: string): string {
|
|
90
|
+
return createHash("sha256").update(material).digest("hex").slice(0, 32);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function readJsonlLines(filePath: string): Promise<readonly string[]> {
|
|
94
|
+
let text: string;
|
|
95
|
+
try {
|
|
96
|
+
text = await readFile(filePath, "utf8");
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (isMigrationEnoent(error)) return [];
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
if (text.length === 0) return [];
|
|
102
|
+
const lines = text.split("\n");
|
|
103
|
+
if (text.endsWith("\n")) lines.pop();
|
|
104
|
+
return lines;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function ensureDir(path: string): Promise<void> {
|
|
108
|
+
await mkdir(path, { recursive: true });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Per-destination identity cache — one read per file, O(1) subsequent checks. */
|
|
112
|
+
class RecordWriteCache {
|
|
113
|
+
private readonly identities = new Map<string, Set<string>>();
|
|
114
|
+
|
|
115
|
+
private async load(recordFile: string): Promise<Set<string>> {
|
|
116
|
+
const cached = this.identities.get(recordFile);
|
|
117
|
+
if (cached !== undefined) return cached;
|
|
118
|
+
const set = new Set<string>();
|
|
119
|
+
for (const line of await readJsonlLines(recordFile)) {
|
|
120
|
+
if (line.trim() === "") continue;
|
|
121
|
+
const parsed = parseJsonlLine(line);
|
|
122
|
+
if (parsed.ok && typeof parsed.value.identity === "string") set.add(parsed.value.identity);
|
|
123
|
+
}
|
|
124
|
+
this.identities.set(recordFile, set);
|
|
125
|
+
return set;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async append(recordFile: string, raw: string): Promise<void> {
|
|
129
|
+
await ensureDir(dirname(recordFile));
|
|
130
|
+
const line = raw.endsWith("\n") ? raw : `${raw}\n`;
|
|
131
|
+
const parsed = parseJsonlLine(raw);
|
|
132
|
+
if (parsed.ok && typeof parsed.value.identity === "string") {
|
|
133
|
+
const existing = await this.load(recordFile);
|
|
134
|
+
if (existing.has(parsed.value.identity)) return;
|
|
135
|
+
existing.add(parsed.value.identity);
|
|
136
|
+
} else if (!this.identities.has(recordFile)) {
|
|
137
|
+
// Ensure subsequent identity loads see a live map even when first rows lack identity.
|
|
138
|
+
this.identities.set(recordFile, await this.load(recordFile));
|
|
139
|
+
}
|
|
140
|
+
await appendFile(recordFile, line, "utf8");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function roleFromPayload(payload: unknown): string | undefined {
|
|
145
|
+
if (isRecord(payload) && typeof payload.role === "string" && payload.role.length > 0) {
|
|
146
|
+
return payload.role;
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function runIdFromPayload(payload: unknown): string | undefined {
|
|
152
|
+
return isRecord(payload) && typeof payload.runId === "string" && payload.runId.length > 0
|
|
153
|
+
? payload.runId
|
|
154
|
+
: undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function recordClassOfKind(kind: unknown): RecordClass | undefined {
|
|
158
|
+
if (kind === TICKET_PROVENANCE) return TICKET_PROVENANCE;
|
|
159
|
+
if (kind === ATTEMPT_HISTORY) return ATTEMPT_HISTORY;
|
|
160
|
+
if (typeof kind === "string" && S4_SUBMISSION_LEDGER_KINDS.has(kind)) return SUBMISSION_LEDGER;
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function listFilesRecursive(root: string, predicate: (name: string) => boolean): Promise<string[]> {
|
|
165
|
+
const out: string[] = [];
|
|
166
|
+
async function walk(directory: string): Promise<void> {
|
|
167
|
+
for (const entry of await listMigrationDirents(directory)) {
|
|
168
|
+
const path = join(directory, entry.name);
|
|
169
|
+
if (entry.isDirectory()) await walk(path);
|
|
170
|
+
else if (entry.isFile() && predicate(entry.name)) out.push(path);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
await walk(root);
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Home partition sources under one record-class root:
|
|
179
|
+
* - direct `<category>/records.jsonl` (legacy flat book-root volume)
|
|
180
|
+
* - hashed sub-volumes `<category>/<volumeId>/records.jsonl`
|
|
181
|
+
* Both enter the same line-closed placement path; ENOENT yields empty reads.
|
|
182
|
+
*/
|
|
183
|
+
async function listVolumeRecordFiles(partitionDir: string): Promise<string[]> {
|
|
184
|
+
const files: string[] = [join(partitionDir, "records.jsonl")];
|
|
185
|
+
for (const entry of await listMigrationDirents(partitionDir)) {
|
|
186
|
+
if (!entry.isDirectory()) continue;
|
|
187
|
+
files.push(join(partitionDir, entry.name, "records.jsonl"));
|
|
188
|
+
}
|
|
189
|
+
return files;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Paths already closed by the three home migrators — including the partition-root
|
|
194
|
+
* direct records.jsonl. Criterion (not a partition name list): root record-class
|
|
195
|
+
* trees and nested ticket-provenance. Run trees are NOT blanket-skipped — wrong-kind
|
|
196
|
+
* rows inside a run must still rehome by content (#866 / #852 §落错).
|
|
197
|
+
*/
|
|
198
|
+
function isHomeRecordClassRelativePath(relPosix: string): boolean {
|
|
199
|
+
const parts = relPosix.split("/");
|
|
200
|
+
if (parts[0] === TICKET_PROVENANCE || parts[0] === SUBMISSION_LEDGER || parts[0] === ATTEMPT_HISTORY) {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
if (parts.length >= 2 && isTicketNumberString(parts[0]!)) {
|
|
204
|
+
// Partial-nest era + live ticket-root volume (records.jsonl at ticket dir).
|
|
205
|
+
if (parts[1] === TICKET_PROVENANCE) return true;
|
|
206
|
+
if (parts.length === 2 && parts[1] === "records.jsonl") return true;
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Canonical nested path for a run-owned record class; ticket-provenance is never run-nested. */
|
|
212
|
+
function isCanonicalRunNestedRecordFile(relPosix: string, recordClass: RecordClass): boolean {
|
|
213
|
+
if (recordClass === TICKET_PROVENANCE) return false;
|
|
214
|
+
const needle = `/session/${recordClass}/records.jsonl`;
|
|
215
|
+
return relPosix.endsWith(needle) || relPosix.endsWith(needle.slice(1));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function runCoordsFromRelativePath(relPosix: string): { readonly runId: string; readonly role: string } | undefined {
|
|
219
|
+
const parts = relPosix.split("/");
|
|
220
|
+
const runsIndex = parts.indexOf("runs");
|
|
221
|
+
if (runsIndex < 0 || runsIndex + 1 >= parts.length) return undefined;
|
|
222
|
+
const leaf = parts[runsIndex + 1]!;
|
|
223
|
+
const at = leaf.indexOf("@");
|
|
224
|
+
if (at <= 0 || at === leaf.length - 1) return undefined;
|
|
225
|
+
return { runId: leaf.slice(0, at), role: leaf.slice(at + 1) };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Typed owning run for a record-class row (subject / payload / sessionParent). */
|
|
229
|
+
function owningRunIdFromRecord(value: Record<string, unknown>): string | undefined {
|
|
230
|
+
return (
|
|
231
|
+
runIdFromSubject(value.subject)
|
|
232
|
+
?? runIdFromPayload(value.payload)
|
|
233
|
+
?? runCoordsFromSessionParent(value.sessionParent)?.runId
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Row already sits at the correct run's canonical nest — #865 carries the file.
|
|
239
|
+
* Path-class alone is not enough: a submission-ledger row under run A that names
|
|
240
|
+
* run B must still rehome (#866 run-principal).
|
|
241
|
+
*/
|
|
242
|
+
function isAlreadyHomeUnderOwningRun(
|
|
243
|
+
withinBook: string,
|
|
244
|
+
recordClass: RecordClass,
|
|
245
|
+
value: Record<string, unknown>,
|
|
246
|
+
): boolean {
|
|
247
|
+
if (!isCanonicalRunNestedRecordFile(withinBook, recordClass)) return false;
|
|
248
|
+
const pathCoords = runCoordsFromRelativePath(withinBook);
|
|
249
|
+
if (pathCoords === undefined) return false;
|
|
250
|
+
const owningRunId = owningRunIdFromRecord(value);
|
|
251
|
+
return owningRunId !== undefined && owningRunId === pathCoords.runId;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function relativePathWithinRun(relPosix: string): string | undefined {
|
|
255
|
+
const parts = relPosix.split("/");
|
|
256
|
+
const runsIndex = parts.indexOf("runs");
|
|
257
|
+
if (runsIndex < 0 || runsIndex + 2 >= parts.length) return undefined;
|
|
258
|
+
return parts.slice(runsIndex + 2).join("/");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function unboundCategoryFile(
|
|
262
|
+
booksDirectory: string,
|
|
263
|
+
bookKey: string,
|
|
264
|
+
category: string,
|
|
265
|
+
key: string,
|
|
266
|
+
): string {
|
|
267
|
+
return join(booksDirectory, bookKey, "unbound", category, key, "records.jsonl");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function resolveRunDestination(
|
|
271
|
+
context: BookTopologyMigrationContext,
|
|
272
|
+
bookKey: string,
|
|
273
|
+
runId: string,
|
|
274
|
+
hints: { readonly role?: string; readonly sessionParent?: unknown },
|
|
275
|
+
): Promise<
|
|
276
|
+
| { readonly kind: "run"; readonly runDirectory: string; readonly disposition: "placed" | "unbound" }
|
|
277
|
+
| { readonly kind: "unbound-key"; readonly key: string }
|
|
278
|
+
> {
|
|
279
|
+
const hasSessionParent =
|
|
280
|
+
typeof hints.sessionParent === "string" && hints.sessionParent.length > 0;
|
|
281
|
+
const bound = hasSessionParent
|
|
282
|
+
? runRefFromBoundPath(
|
|
283
|
+
hints.sessionParent,
|
|
284
|
+
bookHistoricalRoots(context.booksDirectory, context.backupBooksDirectory, bookKey),
|
|
285
|
+
)
|
|
286
|
+
: undefined;
|
|
287
|
+
// Path present but not bound to this book: never steal role or unique-run-guess.
|
|
288
|
+
if (hasSessionParent && bound === undefined) {
|
|
289
|
+
return { kind: "unbound-key", key: stableKey(runId) };
|
|
290
|
+
}
|
|
291
|
+
if (bound !== undefined) {
|
|
292
|
+
const existingDest = await findPlacedMigratingRun(
|
|
293
|
+
context.booksDirectory,
|
|
294
|
+
bookKey,
|
|
295
|
+
bound.leaf,
|
|
296
|
+
bound.sourceRelative,
|
|
297
|
+
);
|
|
298
|
+
if (existingDest !== undefined) {
|
|
299
|
+
return {
|
|
300
|
+
kind: "run",
|
|
301
|
+
runDirectory: existingDest.runDirectory,
|
|
302
|
+
disposition: existingDest.disposition,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
return { kind: "unbound-key", key: stableKey(bound.leaf) };
|
|
306
|
+
}
|
|
307
|
+
if (hints.role !== undefined && hints.role.length > 0) {
|
|
308
|
+
const existingDest = await findPlacedMigratingRun(
|
|
309
|
+
context.booksDirectory,
|
|
310
|
+
bookKey,
|
|
311
|
+
`${runId}@${hints.role}`,
|
|
312
|
+
);
|
|
313
|
+
if (existingDest !== undefined) {
|
|
314
|
+
return {
|
|
315
|
+
kind: "run",
|
|
316
|
+
runDirectory: existingDest.runDirectory,
|
|
317
|
+
disposition: existingDest.disposition,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
return { kind: "unbound-key", key: stableKey(`${runId}@${hints.role}`) };
|
|
321
|
+
}
|
|
322
|
+
// No path, no role: follow only a uniquely matching principal in this book.
|
|
323
|
+
const uniquePrincipal = await findUniquePrincipalPlacedRun(
|
|
324
|
+
context.booksDirectory,
|
|
325
|
+
bookKey,
|
|
326
|
+
runId,
|
|
327
|
+
);
|
|
328
|
+
if (uniquePrincipal !== undefined) {
|
|
329
|
+
return {
|
|
330
|
+
kind: "run",
|
|
331
|
+
runDirectory: uniquePrincipal.runDirectory,
|
|
332
|
+
disposition: uniquePrincipal.disposition,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
return { kind: "unbound-key", key: stableKey(runId) };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function placeTicketProvenanceLine(
|
|
339
|
+
context: BookTopologyMigrationContext,
|
|
340
|
+
bookKey: string,
|
|
341
|
+
writes: RecordWriteCache,
|
|
342
|
+
source: string,
|
|
343
|
+
raw: string,
|
|
344
|
+
value: Record<string, unknown> | undefined,
|
|
345
|
+
): Promise<MigrationItemOutcome> {
|
|
346
|
+
if (value === undefined) {
|
|
347
|
+
await writes.append(unboundCategoryFile(context.booksDirectory, bookKey, TICKET_PROVENANCE, stableKey(raw)), raw);
|
|
348
|
+
return { disposition: "unbound", source, malformed: true, malformedRaw: raw };
|
|
349
|
+
}
|
|
350
|
+
const ticketNumber = ticketNumberFromSubject(value.subject);
|
|
351
|
+
if (ticketNumber === undefined) {
|
|
352
|
+
await writes.append(unboundCategoryFile(context.booksDirectory, bookKey, TICKET_PROVENANCE, stableKey(raw)), raw);
|
|
353
|
+
return { disposition: "unbound", source };
|
|
354
|
+
}
|
|
355
|
+
// Live topology (docs/dossier-topology.md): ticket root holds records.jsonl.
|
|
356
|
+
await writes.append(
|
|
357
|
+
join(context.booksDirectory, bookKey, String(ticketNumber), "records.jsonl"),
|
|
358
|
+
raw,
|
|
359
|
+
);
|
|
360
|
+
noteTouchedTicket(context, bookKey, ticketNumber);
|
|
361
|
+
return { disposition: "placed", source };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function placeRunOwnedLine(
|
|
365
|
+
context: BookTopologyMigrationContext,
|
|
366
|
+
bookKey: string,
|
|
367
|
+
writes: RecordWriteCache,
|
|
368
|
+
category: typeof SUBMISSION_LEDGER | typeof ATTEMPT_HISTORY,
|
|
369
|
+
source: string,
|
|
370
|
+
raw: string,
|
|
371
|
+
value: Record<string, unknown> | undefined,
|
|
372
|
+
): Promise<MigrationItemOutcome> {
|
|
373
|
+
if (value === undefined) {
|
|
374
|
+
await writes.append(unboundCategoryFile(context.booksDirectory, bookKey, category, stableKey(raw)), raw);
|
|
375
|
+
return { disposition: "unbound", source, malformed: true, malformedRaw: raw };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const fromParent = runCoordsFromSessionParent(value.sessionParent);
|
|
379
|
+
const runId =
|
|
380
|
+
runIdFromSubject(value.subject)
|
|
381
|
+
?? runIdFromPayload(value.payload)
|
|
382
|
+
?? fromParent?.runId;
|
|
383
|
+
if (runId === undefined) {
|
|
384
|
+
await writes.append(unboundCategoryFile(context.booksDirectory, bookKey, category, stableKey(raw)), raw);
|
|
385
|
+
return { disposition: "unbound", source };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const role = roleFromPayload(value.payload);
|
|
389
|
+
const target = await resolveRunDestination(context, bookKey, runId, {
|
|
390
|
+
...(role === undefined ? {} : { role }),
|
|
391
|
+
...(value.sessionParent === undefined ? {} : { sessionParent: value.sessionParent }),
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
if (target.kind === "unbound-key") {
|
|
395
|
+
await writes.append(unboundCategoryFile(context.booksDirectory, bookKey, category, target.key), raw);
|
|
396
|
+
return { disposition: "unbound", source };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
await writes.append(join(target.runDirectory, "session", category, "records.jsonl"), raw);
|
|
400
|
+
return { disposition: target.disposition, source };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Place one parsed row by its typed kind; unknown kinds fall back to the home category unbound. */
|
|
404
|
+
async function placeRecordClassLine(
|
|
405
|
+
context: BookTopologyMigrationContext,
|
|
406
|
+
bookKey: string,
|
|
407
|
+
writes: RecordWriteCache,
|
|
408
|
+
source: string,
|
|
409
|
+
raw: string,
|
|
410
|
+
value: Record<string, unknown>,
|
|
411
|
+
fallbackCategory: RecordClass,
|
|
412
|
+
): Promise<MigrationItemOutcome> {
|
|
413
|
+
const recordClass = recordClassOfKind(value.kind);
|
|
414
|
+
if (recordClass === TICKET_PROVENANCE) {
|
|
415
|
+
return placeTicketProvenanceLine(context, bookKey, writes, source, raw, value);
|
|
416
|
+
}
|
|
417
|
+
if (recordClass === SUBMISSION_LEDGER || recordClass === ATTEMPT_HISTORY) {
|
|
418
|
+
return placeRunOwnedLine(context, bookKey, writes, recordClass, source, raw, value);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Unknown kind inside a record-class home volume: preserve under that home's unbound.
|
|
422
|
+
await writes.append(
|
|
423
|
+
unboundCategoryFile(context.booksDirectory, bookKey, fallbackCategory, stableKey(raw)),
|
|
424
|
+
raw,
|
|
425
|
+
);
|
|
426
|
+
return { disposition: "unbound", source };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function migrateJsonlFileByKind(
|
|
430
|
+
context: BookTopologyMigrationContext,
|
|
431
|
+
bookKey: string,
|
|
432
|
+
writes: RecordWriteCache,
|
|
433
|
+
filePath: string,
|
|
434
|
+
fallbackCategory: RecordClass,
|
|
435
|
+
outcomes: MigrationItemOutcome[],
|
|
436
|
+
): Promise<void> {
|
|
437
|
+
const lines = await readJsonlLines(filePath);
|
|
438
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
439
|
+
const raw = lines[index]!;
|
|
440
|
+
if (raw.trim() === "") continue;
|
|
441
|
+
const source = lineSource(context.backupBooksDirectory, filePath, index);
|
|
442
|
+
const parsed = parseJsonlLine(raw);
|
|
443
|
+
if (!parsed.ok) {
|
|
444
|
+
// Malformed: preserve under the home partition's unbound bucket.
|
|
445
|
+
if (fallbackCategory === TICKET_PROVENANCE) {
|
|
446
|
+
outcomes.push(await placeTicketProvenanceLine(context, bookKey, writes, source, raw, undefined));
|
|
447
|
+
} else {
|
|
448
|
+
outcomes.push(await placeRunOwnedLine(context, bookKey, writes, fallbackCategory, source, raw, undefined));
|
|
449
|
+
}
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
outcomes.push(
|
|
453
|
+
await placeRecordClassLine(context, bookKey, writes, source, raw, parsed.value, fallbackCategory),
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const OFFERED_IDENTITIES = "offered-identities.jsonl" as const;
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Merge offered-identities from one source volume into the destination ticket
|
|
462
|
+
* dir by identity key. Never last-source-wins overwrite of the whole file.
|
|
463
|
+
*/
|
|
464
|
+
async function mergeOfferedIdentities(
|
|
465
|
+
destDir: string,
|
|
466
|
+
volumeDir: string,
|
|
467
|
+
writes: RecordWriteCache,
|
|
468
|
+
): Promise<void> {
|
|
469
|
+
const from = join(volumeDir, OFFERED_IDENTITIES);
|
|
470
|
+
let lines: readonly string[];
|
|
471
|
+
try {
|
|
472
|
+
await stat(from);
|
|
473
|
+
lines = await readJsonlLines(from);
|
|
474
|
+
} catch (error) {
|
|
475
|
+
if (isMigrationEnoent(error)) return;
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
const destFile = join(destDir, OFFERED_IDENTITIES);
|
|
479
|
+
for (const raw of lines) {
|
|
480
|
+
if (raw.trim() === "") continue;
|
|
481
|
+
await writes.append(destFile, raw);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Derive the human-read face once from the merged authoritative records.jsonl.
|
|
487
|
+
* Never copy any pre-existing md as if it were the complete view.
|
|
488
|
+
*/
|
|
489
|
+
async function writeMergedTicketHumanView(
|
|
490
|
+
destDir: string,
|
|
491
|
+
ticketNumber: number,
|
|
492
|
+
): Promise<void> {
|
|
493
|
+
const lines = await readJsonlLines(join(destDir, "records.jsonl"));
|
|
494
|
+
const entries = [];
|
|
495
|
+
const unprojected: unknown[] = [];
|
|
496
|
+
for (const raw of lines) {
|
|
497
|
+
if (raw.trim() === "") continue;
|
|
498
|
+
const parsed = parseJsonlLine(raw);
|
|
499
|
+
if (!parsed.ok) continue;
|
|
500
|
+
if (recordClassOfKind(parsed.value.kind) !== TICKET_PROVENANCE) continue;
|
|
501
|
+
const entry = projectTicketProvenanceEntry(parsed.value.payload);
|
|
502
|
+
if (entry === undefined) {
|
|
503
|
+
if (parsed.value.payload !== undefined) unprojected.push(parsed.value.payload);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
entries.push(entry);
|
|
507
|
+
}
|
|
508
|
+
await ensureDir(destDir);
|
|
509
|
+
await writeFile(
|
|
510
|
+
join(destDir, TICKET_PROVENANCE_HUMAN_VIEW),
|
|
511
|
+
renderTicketProvenanceMarkdown({
|
|
512
|
+
ticketNumber,
|
|
513
|
+
entries,
|
|
514
|
+
...(unprojected.length > 0 ? { unprojected } : {}),
|
|
515
|
+
}),
|
|
516
|
+
"utf8",
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* After all sources for a ticket have contributed records + offered-identities,
|
|
522
|
+
* emit the single derived human view from the merged JSONL.
|
|
523
|
+
*/
|
|
524
|
+
async function finalizeTicketCompanions(
|
|
525
|
+
context: BookTopologyMigrationContext,
|
|
526
|
+
bookKey: string,
|
|
527
|
+
ticketNumbers: ReadonlySet<number>,
|
|
528
|
+
): Promise<void> {
|
|
529
|
+
for (const ticketNumber of ticketNumbers) {
|
|
530
|
+
const destDir = join(context.booksDirectory, bookKey, String(ticketNumber));
|
|
531
|
+
try {
|
|
532
|
+
await stat(join(destDir, "records.jsonl"));
|
|
533
|
+
} catch (error) {
|
|
534
|
+
if (isMigrationEnoent(error)) continue;
|
|
535
|
+
throw error;
|
|
536
|
+
}
|
|
537
|
+
await writeMergedTicketHumanView(destDir, ticketNumber);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Collect typed ticket numbers present in a source volume's records.jsonl. */
|
|
542
|
+
async function ticketNumbersInVolume(volumeDir: string): Promise<number[]> {
|
|
543
|
+
const lines = await readJsonlLines(join(volumeDir, "records.jsonl"));
|
|
544
|
+
const out: number[] = [];
|
|
545
|
+
const seen = new Set<number>();
|
|
546
|
+
for (const raw of lines) {
|
|
547
|
+
if (raw.trim() === "") continue;
|
|
548
|
+
const parsed = parseJsonlLine(raw);
|
|
549
|
+
if (!parsed.ok) continue;
|
|
550
|
+
if (recordClassOfKind(parsed.value.kind) !== TICKET_PROVENANCE) continue;
|
|
551
|
+
const ticketNumber = ticketNumberFromSubject(parsed.value.subject);
|
|
552
|
+
if (ticketNumber === undefined || seen.has(ticketNumber)) continue;
|
|
553
|
+
seen.add(ticketNumber);
|
|
554
|
+
out.push(ticketNumber);
|
|
555
|
+
}
|
|
556
|
+
return out;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function mergeCompanionsFromVolume(
|
|
560
|
+
context: BookTopologyMigrationContext,
|
|
561
|
+
bookKey: string,
|
|
562
|
+
volumeDir: string,
|
|
563
|
+
writes: RecordWriteCache,
|
|
564
|
+
): Promise<void> {
|
|
565
|
+
for (const ticketNumber of await ticketNumbersInVolume(volumeDir)) {
|
|
566
|
+
noteTouchedTicket(context, bookKey, ticketNumber);
|
|
567
|
+
const destDir = join(context.booksDirectory, bookKey, String(ticketNumber));
|
|
568
|
+
await ensureDir(destDir);
|
|
569
|
+
await mergeOfferedIdentities(destDir, volumeDir, writes);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function migrateHomePartitionBook(
|
|
574
|
+
context: BookTopologyMigrationContext,
|
|
575
|
+
bookKey: string,
|
|
576
|
+
writes: RecordWriteCache,
|
|
577
|
+
category: RecordClass,
|
|
578
|
+
outcomes: MigrationItemOutcome[],
|
|
579
|
+
): Promise<void> {
|
|
580
|
+
const backupBook = join(context.backupBooksDirectory, bookKey);
|
|
581
|
+
// Touches accumulate via noteTouchedTicket — human view finalizes once after
|
|
582
|
+
// misplaced lines also land (not here).
|
|
583
|
+
// listVolumeRecordFiles covers partition-root records.jsonl + hashed sub-volumes.
|
|
584
|
+
const rootFiles = await listVolumeRecordFiles(join(backupBook, category));
|
|
585
|
+
for (const filePath of rootFiles) {
|
|
586
|
+
await migrateJsonlFileByKind(context, bookKey, writes, filePath, category, outcomes);
|
|
587
|
+
if (category === TICKET_PROVENANCE) {
|
|
588
|
+
await mergeCompanionsFromVolume(context, bookKey, dirname(filePath), writes);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (category !== TICKET_PROVENANCE) return;
|
|
593
|
+
|
|
594
|
+
// One ticket-dir walk: partial-nest <ticket>/ticket-provenance/ and live
|
|
595
|
+
// <ticket>/ root volumes share existence check, line placement, companions.
|
|
596
|
+
for (const entry of await listMigrationDirents(backupBook)) {
|
|
597
|
+
if (!entry.isDirectory() || !isTicketNumberString(entry.name)) continue;
|
|
598
|
+
const ticketDir = join(backupBook, entry.name);
|
|
599
|
+
for (const volumeDir of [
|
|
600
|
+
join(ticketDir, TICKET_PROVENANCE),
|
|
601
|
+
ticketDir,
|
|
602
|
+
] as const) {
|
|
603
|
+
const recordsFile = join(volumeDir, "records.jsonl");
|
|
604
|
+
try {
|
|
605
|
+
await stat(recordsFile);
|
|
606
|
+
} catch (error) {
|
|
607
|
+
if (isMigrationEnoent(error)) continue;
|
|
608
|
+
throw error;
|
|
609
|
+
}
|
|
610
|
+
await migrateJsonlFileByKind(context, bookKey, writes, recordsFile, TICKET_PROVENANCE, outcomes);
|
|
611
|
+
await mergeCompanionsFromVolume(context, bookKey, volumeDir, writes);
|
|
612
|
+
// Ticket-dir name itself is a typed ticket when the volume lives under it.
|
|
613
|
+
noteTouchedTicket(context, bookKey, Number(entry.name));
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function normalizeJsonlRaw(raw: string): string {
|
|
619
|
+
return raw.endsWith("\n") ? raw : `${raw}\n`;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** Rewrite dest file without the given exact raw lines (drop wrong-nest copies after rehome). */
|
|
623
|
+
async function scrubRawLinesFromFile(
|
|
624
|
+
recordFile: string,
|
|
625
|
+
rawLines: ReadonlySet<string>,
|
|
626
|
+
): Promise<void> {
|
|
627
|
+
if (rawLines.size === 0) return;
|
|
628
|
+
const lines = await readJsonlLines(recordFile);
|
|
629
|
+
if (lines.length === 0) return;
|
|
630
|
+
const kept: string[] = [];
|
|
631
|
+
let changed = false;
|
|
632
|
+
for (const raw of lines) {
|
|
633
|
+
if (raw.trim() === "") continue;
|
|
634
|
+
const normalized = normalizeJsonlRaw(raw);
|
|
635
|
+
if (rawLines.has(normalized)) {
|
|
636
|
+
changed = true;
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
kept.push(normalized);
|
|
640
|
+
}
|
|
641
|
+
if (!changed) return;
|
|
642
|
+
await ensureDir(dirname(recordFile));
|
|
643
|
+
await writeFile(recordFile, kept.join(""), "utf8");
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Foreign jsonl rows whose typed kind is one of the three record classes.
|
|
648
|
+
* Range = whole book minus home partitions (root records.jsonl, hashed volumes,
|
|
649
|
+
* nested ticket-provenance) — those close under the three home migrators so T8
|
|
650
|
+
* does not double-count. Inside runs/: skip only rows already under their typed
|
|
651
|
+
* owning run at the canonical nest (those travel with #865); path-class alone
|
|
652
|
+
* never excuses a cross-run wrong principal. Every other typed row rehomes by
|
|
653
|
+
* content; dest copies at the source nest are scrubbed so a recursive run cp
|
|
654
|
+
* cannot keep a lying duplicate.
|
|
655
|
+
*/
|
|
656
|
+
async function migrateMisplacedBook(
|
|
657
|
+
context: BookTopologyMigrationContext,
|
|
658
|
+
bookKey: string,
|
|
659
|
+
writes: RecordWriteCache,
|
|
660
|
+
outcomes: MigrationItemOutcome[],
|
|
661
|
+
): Promise<void> {
|
|
662
|
+
const backupBook = join(context.backupBooksDirectory, bookKey);
|
|
663
|
+
const files = await listFilesRecursive(backupBook, (name) => name.endsWith(".jsonl"));
|
|
664
|
+
// backupRelPath → exact raw lines rehomed out of a source-run nest (wrong path or wrong principal).
|
|
665
|
+
// Match by line bytes, not identity — rows without identity must still leave the lying source copy.
|
|
666
|
+
const scrubPlans = new Map<string, Set<string>>();
|
|
667
|
+
|
|
668
|
+
for (const filePath of files) {
|
|
669
|
+
const rel = sourceRelative(context.backupBooksDirectory, filePath);
|
|
670
|
+
const withinBook = rel.split("/").slice(1).join("/");
|
|
671
|
+
// Home partition roots (incl. direct records.jsonl) are owned by home migrators.
|
|
672
|
+
if (isHomeRecordClassRelativePath(withinBook)) continue;
|
|
673
|
+
|
|
674
|
+
const lines = await readJsonlLines(filePath);
|
|
675
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
676
|
+
const raw = lines[index]!;
|
|
677
|
+
if (raw.trim() === "") continue;
|
|
678
|
+
const parsed = parseJsonlLine(raw);
|
|
679
|
+
if (!parsed.ok) continue;
|
|
680
|
+
const recordClass = recordClassOfKind(parsed.value.kind);
|
|
681
|
+
if (recordClass === undefined) continue;
|
|
682
|
+
|
|
683
|
+
// Correct principal + canonical nest — #865 carries the file as-is.
|
|
684
|
+
if (isAlreadyHomeUnderOwningRun(withinBook, recordClass, parsed.value)) continue;
|
|
685
|
+
|
|
686
|
+
const source = lineSource(context.backupBooksDirectory, filePath, index);
|
|
687
|
+
if (recordClass === TICKET_PROVENANCE) {
|
|
688
|
+
outcomes.push(await placeTicketProvenanceLine(context, bookKey, writes, source, raw, parsed.value));
|
|
689
|
+
} else {
|
|
690
|
+
outcomes.push(
|
|
691
|
+
await placeRunOwnedLine(context, bookKey, writes, recordClass, source, raw, parsed.value),
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (withinBook.includes("/runs/") || withinBook.startsWith("runs/")) {
|
|
696
|
+
let set = scrubPlans.get(withinBook);
|
|
697
|
+
if (set === undefined) {
|
|
698
|
+
set = new Set<string>();
|
|
699
|
+
scrubPlans.set(withinBook, set);
|
|
700
|
+
}
|
|
701
|
+
set.add(normalizeJsonlRaw(raw));
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Drop rehomed lines from the source nest in dest (wrong path or wrong principal).
|
|
707
|
+
for (const [withinBook, rawLines] of scrubPlans) {
|
|
708
|
+
const coords = runCoordsFromRelativePath(withinBook);
|
|
709
|
+
const withinRun = relativePathWithinRun(withinBook);
|
|
710
|
+
if (coords === undefined || withinRun === undefined) continue;
|
|
711
|
+
// Scrub plan already carries runId+role from the source leaf — keep the full identity.
|
|
712
|
+
const destRun = await findBookRunDirectory(
|
|
713
|
+
join(context.booksDirectory, bookKey),
|
|
714
|
+
coords.runId,
|
|
715
|
+
coords.role,
|
|
716
|
+
);
|
|
717
|
+
if (destRun === undefined) continue;
|
|
718
|
+
await scrubRawLinesFromFile(join(destRun.runDirectory, withinRun), rawLines);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async function forEachBook(
|
|
723
|
+
context: BookTopologyMigrationContext,
|
|
724
|
+
body: (bookKey: string, writes: RecordWriteCache) => Promise<void>,
|
|
725
|
+
): Promise<void> {
|
|
726
|
+
for (const bookKey of await listMigrationBookKeys(context.backupBooksDirectory)) {
|
|
727
|
+
await body(bookKey, new RecordWriteCache());
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
export const ticketProvenancePartitionMigrator: BookTopologyPartitionMigrator = {
|
|
732
|
+
partition: TICKET_PROVENANCE,
|
|
733
|
+
async migrate(context) {
|
|
734
|
+
const outcomes: MigrationItemOutcome[] = [];
|
|
735
|
+
await forEachBook(context, (bookKey, writes) =>
|
|
736
|
+
migrateHomePartitionBook(context, bookKey, writes, TICKET_PROVENANCE, outcomes));
|
|
737
|
+
return reconcileMigrationPartition(TICKET_PROVENANCE, "lines", outcomes);
|
|
738
|
+
},
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
export const submissionLedgerPartitionMigrator: BookTopologyPartitionMigrator = {
|
|
742
|
+
partition: SUBMISSION_LEDGER,
|
|
743
|
+
async migrate(context) {
|
|
744
|
+
const outcomes: MigrationItemOutcome[] = [];
|
|
745
|
+
await forEachBook(context, (bookKey, writes) =>
|
|
746
|
+
migrateHomePartitionBook(context, bookKey, writes, SUBMISSION_LEDGER, outcomes));
|
|
747
|
+
return reconcileMigrationPartition(SUBMISSION_LEDGER, "lines", outcomes);
|
|
748
|
+
},
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
export const attemptHistoryPartitionMigrator: BookTopologyPartitionMigrator = {
|
|
752
|
+
partition: ATTEMPT_HISTORY,
|
|
753
|
+
async migrate(context) {
|
|
754
|
+
const outcomes: MigrationItemOutcome[] = [];
|
|
755
|
+
await forEachBook(context, (bookKey, writes) =>
|
|
756
|
+
migrateHomePartitionBook(context, bookKey, writes, ATTEMPT_HISTORY, outcomes));
|
|
757
|
+
return reconcileMigrationPartition(ATTEMPT_HISTORY, "lines", outcomes);
|
|
758
|
+
},
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
export const misplacedRecordClassPartitionMigrator: BookTopologyPartitionMigrator = {
|
|
762
|
+
partition: MISPLACED,
|
|
763
|
+
async migrate(context) {
|
|
764
|
+
const outcomes: MigrationItemOutcome[] = [];
|
|
765
|
+
await forEachBook(context, (bookKey, writes) => migrateMisplacedBook(context, bookKey, writes, outcomes));
|
|
766
|
+
// All record-class lines (home + misplaced) have landed — one human-view pass.
|
|
767
|
+
for (const [bookKey, tickets] of context.touchedTicketsByBook) {
|
|
768
|
+
await finalizeTicketCompanions(context, bookKey, tickets);
|
|
769
|
+
}
|
|
770
|
+
return reconcileMigrationPartition(MISPLACED, "lines", outcomes);
|
|
771
|
+
},
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
export const BOOK_TOPOLOGY_RECORD_CLASS_MIGRATORS: readonly BookTopologyPartitionMigrator[] = [
|
|
775
|
+
ticketProvenancePartitionMigrator,
|
|
776
|
+
submissionLedgerPartitionMigrator,
|
|
777
|
+
attemptHistoryPartitionMigrator,
|
|
778
|
+
misplacedRecordClassPartitionMigrator,
|
|
779
|
+
];
|