@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.
- package/dist/acp-host/production-host.js +464 -355
- package/dist/acp-host/role-turn-host.js +484 -0
- package/dist/activation-ledger-session.js +105 -0
- package/dist/activation-ledger.js +8 -0
- package/dist/activation-trace.js +33 -0
- package/dist/audit-escalation.js +7 -0
- package/dist/audited-run-identity.js +21 -0
- package/dist/auditor-role.js +7 -0
- package/dist/book-topology-migration-placement.js +404 -0
- package/dist/book-topology-migration.js +125 -0
- package/dist/book-topology-migrators-t11.js +677 -0
- package/dist/book-topology-mixed-volume-migrators.js +322 -0
- package/dist/book-topology-partition-migrators.js +23 -0
- package/dist/book-topology-record-class-migrators.js +671 -0
- package/dist/book-topology-runs-migrator.js +272 -0
- package/dist/collector-role.js +109 -0
- package/dist/compliance-transport.js +5 -3
- package/dist/countersign-role.js +17 -0
- package/dist/diarist-role.js +8 -0
- package/dist/diarist.js +15 -0
- package/dist/doctor-role.js +80 -0
- package/dist/engine-detour-tool.js +51 -37
- package/dist/engine-detour.js +22 -11
- package/dist/factory-board.js +1478 -0
- package/dist/fixer-bash-seatbelt.js +13 -0
- package/dist/gatekeeper-role.js +1 -2
- package/dist/gh-api-runner.js +81 -0
- package/dist/gleaner-left-role.js +8 -0
- package/dist/headless-host/production-host.js +464 -355
- package/dist/headless-host/role-turn-host.js +691 -0
- package/dist/human-format.js +67 -0
- package/dist/inspector-role.js +12 -0
- package/dist/judge-recording-anti-forge.js +39 -0
- package/dist/judge-role.js +21 -3
- package/dist/merger-role.js +57 -0
- package/dist/migrate-book-topology.js +0 -2
- package/dist/navigator-role.js +6 -0
- package/dist/navigator-work-context.js +72 -0
- package/dist/notary-role.js +113 -0
- package/dist/oauth-keepalive.js +149 -0
- package/dist/package-contracts/terminating-infrastructure.js +0 -35
- package/dist/pi/adapter.js +272 -0
- package/dist/public-cli/cli.js +9 -3
- package/dist/public-cli/instruction-seat-run.js +163 -44
- package/dist/public-cli/main.js +241 -123
- package/dist/report-ticket-tool.js +24 -13
- package/dist/reviewer-role.js +33 -0
- package/dist/role-envelope.js +707 -0
- package/dist/role-runtime-dependencies.js +57 -0
- package/dist/role-runtime.js +1476 -0
- package/dist/secretariat-role.js +5 -0
- package/dist/stderr-jsonl.js +26 -0
- package/dist/submission-correctable-error.js +2 -6
- package/dist/submission-errors.js +0 -30
- package/dist/submission-gate.js +61 -54
- package/dist/submission-ledger.js +105 -98
- package/dist/ticket-snapshot.js +486 -0
- package/dist/ticket-trajectory.js +843 -0
- package/dist/worker-role.js +184 -0
- package/dist/worker-submission-gates.js +333 -0
- package/package.json +1 -1
- package/resources/engine-dispatch.md +6 -6
- package/resources/engines/agy.md +1 -1
- package/src/compliance-transport.ts +7 -3
- package/src/engine-detour-tool.ts +88 -57
- package/src/engine-detour.ts +31 -14
- package/src/gatekeeper-role.ts +1 -15
- package/src/host-contracts.ts +9 -12
- package/src/judge-role.ts +30 -7
- package/src/package-contracts/terminating-infrastructure.ts +8 -81
- package/src/pi/adapter.ts +0 -1
- package/src/public-cli/cli.ts +21 -14
- package/src/public-cli/instruction-seat-run.ts +227 -44
- package/src/report-ticket-tool.ts +27 -13
- package/src/role-envelope.ts +35 -35
- package/src/role-runtime.ts +63 -56
- package/src/submission-correctable-error.ts +1 -6
- package/src/submission-errors.ts +0 -34
- package/src/submission-gate.ts +71 -56
- package/src/submission-ledger.ts +109 -104
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #867 T11: migrate auditor-roles and issues/, discard retired kinds and run
|
|
3
|
+
* pages, keep navigator / collector-handbook at book top-level, and move
|
|
4
|
+
* manual archives out of the books home.
|
|
5
|
+
*
|
|
6
|
+
* Partition order below is the registration order: auditor-roles, issues,
|
|
7
|
+
* deprecated-kinds, deprecated-run-pages, navigator, collector-handbook,
|
|
8
|
+
* manual-archives.
|
|
9
|
+
*/
|
|
10
|
+
import { cp, lstat, mkdir, readFile, stat, unlink, utimes, writeFile } from "node:fs/promises";
|
|
11
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
12
|
+
import { pathContainedIn, physicallyContainedIn, } from "./activation-ledger-topology.js";
|
|
13
|
+
import { reconcileMigrationPartition, } from "./book-topology-migration.js";
|
|
14
|
+
import { findPlacedMigratingRun, isFlatRunsRelative, isTicketNumberString, listBackupRunLeaves, listMigrationBookKeys, listMigrationDirents, uniqueRunLeafExistsInBook, } from "./book-topology-migration-placement.js";
|
|
15
|
+
import { rewriteRoleRunDurablePages, } from "./role-run-relocation.js";
|
|
16
|
+
import { isRecord, errorText, isEnoent } from "./unknown-value.js";
|
|
17
|
+
const AUDITOR_ROLES_PARTITION = "auditor-roles";
|
|
18
|
+
const ISSUES_PARTITION = "issues";
|
|
19
|
+
const DEPRECATED_KINDS_PARTITION = "deprecated-kinds";
|
|
20
|
+
const DEPRECATED_RUN_PAGES_PARTITION = "deprecated-run-pages";
|
|
21
|
+
const NAVIGATOR_PARTITION = "navigator";
|
|
22
|
+
const COLLECTOR_HANDBOOK_PARTITION = "collector-handbook";
|
|
23
|
+
const MANUAL_ARCHIVES_PARTITION = "manual-archives";
|
|
24
|
+
const TICKET_NUMBER_NAME = /^[1-9][0-9]*$/;
|
|
25
|
+
const RUN_LEAF = /^([A-Za-z0-9][A-Za-z0-9._-]*)@([A-Za-z][A-Za-z0-9_-]*)$/;
|
|
26
|
+
const DEPRECATED_KIND_DIRECTORIES = [
|
|
27
|
+
"submission-candidate",
|
|
28
|
+
"submission-outcome",
|
|
29
|
+
"submission-sealed",
|
|
30
|
+
"submission-post-seal-anomaly",
|
|
31
|
+
];
|
|
32
|
+
const MANUAL_ARCHIVE_DIRECTORIES = ["root-loose", "snapshots", "scratchpad"];
|
|
33
|
+
const T10_MISFILED_NAME = "records.jsonl";
|
|
34
|
+
const CURRENT_SESSION_LEDGER = "current-session.json";
|
|
35
|
+
/** Frozen run-page names T9 must skip when copying whole runs. */
|
|
36
|
+
export const DEPRECATED_RUN_PAGE_NAMES = Object.freeze([
|
|
37
|
+
"institutional-resolution.json",
|
|
38
|
+
"capabilities.json",
|
|
39
|
+
"grok-capabilities.json",
|
|
40
|
+
]);
|
|
41
|
+
const DEPRECATED_RUN_PAGE_NAME_SET = new Set(DEPRECATED_RUN_PAGE_NAMES);
|
|
42
|
+
export function isDeprecatedRunPage(name) {
|
|
43
|
+
return DEPRECATED_RUN_PAGE_NAME_SET.has(name);
|
|
44
|
+
}
|
|
45
|
+
function parseRunLeaf(name) {
|
|
46
|
+
const match = RUN_LEAF.exec(name);
|
|
47
|
+
if (match === null)
|
|
48
|
+
return undefined;
|
|
49
|
+
const runId = match[1];
|
|
50
|
+
const role = match[2];
|
|
51
|
+
if (runId === undefined || role === undefined)
|
|
52
|
+
return undefined;
|
|
53
|
+
return { runId, role };
|
|
54
|
+
}
|
|
55
|
+
function sourceIdentity(backupBooksDirectory, path) {
|
|
56
|
+
return relative(backupBooksDirectory, path).split(sep).join("/");
|
|
57
|
+
}
|
|
58
|
+
async function directoryExists(path) {
|
|
59
|
+
try {
|
|
60
|
+
const info = await stat(path);
|
|
61
|
+
return info.isDirectory();
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (isEnoent(error))
|
|
65
|
+
return false;
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function hasAnyFileRecursive(directory) {
|
|
70
|
+
for (const entry of await listMigrationDirents(directory)) {
|
|
71
|
+
const path = join(directory, entry.name);
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
if (await hasAnyFileRecursive(path))
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
/** Map a path still written against the pre-rename books root into this backup. */
|
|
83
|
+
function mapBooksPathToBackup(booksDirectory, backupBooksDirectory, absolutePath) {
|
|
84
|
+
const booksRoot = resolve(booksDirectory);
|
|
85
|
+
const resolved = resolve(absolutePath);
|
|
86
|
+
if (resolved !== booksRoot && !pathContainedIn(booksRoot, resolved))
|
|
87
|
+
return undefined;
|
|
88
|
+
return join(backupBooksDirectory, relative(booksRoot, resolved));
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Live session for this volume: either already under the volume, or the books
|
|
92
|
+
* original path that maps 1:1 onto a file inside this backup volume. Basename
|
|
93
|
+
* fallback is not a binding. Physical containment rejects symlink escape.
|
|
94
|
+
*/
|
|
95
|
+
function resolveVolumeSessionFile(booksDirectory, backupBooksDirectory, volumeDirectory, sessionFileField) {
|
|
96
|
+
const resolvedVolume = resolve(volumeDirectory);
|
|
97
|
+
const resolvedSession = resolve(sessionFileField);
|
|
98
|
+
if (physicallyContainedIn(resolvedVolume, resolvedSession))
|
|
99
|
+
return resolvedSession;
|
|
100
|
+
const mapped = mapBooksPathToBackup(booksDirectory, backupBooksDirectory, sessionFileField);
|
|
101
|
+
if (mapped === undefined)
|
|
102
|
+
return undefined;
|
|
103
|
+
const resolvedMapped = resolve(mapped);
|
|
104
|
+
return physicallyContainedIn(resolvedVolume, resolvedMapped)
|
|
105
|
+
? resolvedMapped
|
|
106
|
+
: undefined;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Parent run bound to this migration's books or backup root. Any other
|
|
110
|
+
* `.ak-roles/books/.../runs/...` spelling (temp/fixture spill) is rejected.
|
|
111
|
+
*/
|
|
112
|
+
function parseParentRunBoundToMigration(booksDirectory, backupBooksDirectory, parentSession) {
|
|
113
|
+
const resolved = resolve(parentSession);
|
|
114
|
+
const booksRoot = resolve(booksDirectory);
|
|
115
|
+
const backupRoot = resolve(backupBooksDirectory);
|
|
116
|
+
let rel;
|
|
117
|
+
if (resolved === booksRoot || pathContainedIn(booksRoot, resolved)) {
|
|
118
|
+
rel = relative(booksRoot, resolved).split(sep).join("/");
|
|
119
|
+
}
|
|
120
|
+
else if (resolved === backupRoot || pathContainedIn(backupRoot, resolved)) {
|
|
121
|
+
rel = relative(backupRoot, resolved).split(sep).join("/");
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
const parts = rel.split("/").filter((part) => part.length > 0);
|
|
127
|
+
const runsIndex = parts.indexOf("runs");
|
|
128
|
+
if (runsIndex < 1 || runsIndex + 1 >= parts.length)
|
|
129
|
+
return undefined;
|
|
130
|
+
const leafName = parts[runsIndex + 1];
|
|
131
|
+
const before = parts.slice(0, runsIndex);
|
|
132
|
+
if (leafName === undefined)
|
|
133
|
+
return undefined;
|
|
134
|
+
let bookKey;
|
|
135
|
+
if (before.length === 1) {
|
|
136
|
+
bookKey = before[0];
|
|
137
|
+
}
|
|
138
|
+
else if (before.length === 2
|
|
139
|
+
&& (before[1] === "unbound" || (before[1] !== undefined && isTicketNumberString(before[1])))) {
|
|
140
|
+
bookKey = before[0];
|
|
141
|
+
}
|
|
142
|
+
else if (before.length === 3
|
|
143
|
+
&& before[1] === "issues"
|
|
144
|
+
&& before[2] !== undefined
|
|
145
|
+
&& isTicketNumberString(before[2])) {
|
|
146
|
+
// Legacy issues/<ticket>/runs/<leaf> bound to this book.
|
|
147
|
+
bookKey = before[0];
|
|
148
|
+
}
|
|
149
|
+
if (bookKey === undefined)
|
|
150
|
+
return undefined;
|
|
151
|
+
const leaf = parseRunLeaf(leafName);
|
|
152
|
+
if (leaf === undefined)
|
|
153
|
+
return undefined;
|
|
154
|
+
return {
|
|
155
|
+
bookKey,
|
|
156
|
+
leafName,
|
|
157
|
+
sourceRelative: parts.slice(1, runsIndex + 2).join("/"),
|
|
158
|
+
runId: leaf.runId,
|
|
159
|
+
role: leaf.role,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
async function readJsonlParentSession(path) {
|
|
163
|
+
let raw;
|
|
164
|
+
try {
|
|
165
|
+
raw = await readFile(path, "utf8");
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
if (isEnoent(error))
|
|
169
|
+
return undefined;
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
const newline = raw.indexOf("\n");
|
|
173
|
+
const line = newline === -1 ? raw : raw.slice(0, newline);
|
|
174
|
+
if (line.length === 0)
|
|
175
|
+
return undefined;
|
|
176
|
+
try {
|
|
177
|
+
const header = JSON.parse(line);
|
|
178
|
+
if (!isRecord(header)) {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
const parentSession = header.parentSession;
|
|
182
|
+
return typeof parentSession === "string" && parentSession.length > 0
|
|
183
|
+
? parentSession
|
|
184
|
+
: undefined;
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async function backupParentRunExists(backupBooksDirectory, parent) {
|
|
191
|
+
const backupBook = join(backupBooksDirectory, parent.bookKey);
|
|
192
|
+
if (await directoryExists(join(backupBook, parent.sourceRelative)))
|
|
193
|
+
return true;
|
|
194
|
+
// Unique-leaf fallback only for the cut historical flat alias, never missing nested paths.
|
|
195
|
+
if (!isFlatRunsRelative(parent.sourceRelative, parent.leafName))
|
|
196
|
+
return false;
|
|
197
|
+
return uniqueRunLeafExistsInBook(backupBook, parent.leafName);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* True volume (#867 three-way conjunction):
|
|
201
|
+
* 1. continuation pointer (current-session.sessionFile)
|
|
202
|
+
* 2. that pointer binds a live session under this volume (direct, or books→backup map only)
|
|
203
|
+
* 3. session content names a parent bound to this migration's real backup run
|
|
204
|
+
*
|
|
205
|
+
* Outside pointers, basename-only coincidence, temp/fixture parents, or missing
|
|
206
|
+
* backup parents all disqualify.
|
|
207
|
+
*/
|
|
208
|
+
async function findTrueVolumeParent(backupBooksDirectory, booksDirectory, volumeDirectory) {
|
|
209
|
+
let ledgerRaw;
|
|
210
|
+
try {
|
|
211
|
+
ledgerRaw = await readFile(join(volumeDirectory, CURRENT_SESSION_LEDGER), "utf8");
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
if (isEnoent(error))
|
|
215
|
+
return undefined;
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
let sessionFileField;
|
|
219
|
+
try {
|
|
220
|
+
const ledger = JSON.parse(ledgerRaw);
|
|
221
|
+
if (!isRecord(ledger)) {
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
const sessionFile = ledger.sessionFile;
|
|
225
|
+
if (typeof sessionFile !== "string" || sessionFile.length === 0)
|
|
226
|
+
return undefined;
|
|
227
|
+
sessionFileField = sessionFile;
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
const liveSession = resolveVolumeSessionFile(booksDirectory, backupBooksDirectory, volumeDirectory, sessionFileField);
|
|
233
|
+
if (liveSession === undefined)
|
|
234
|
+
return undefined;
|
|
235
|
+
// lstat: only a regular file is later materialized by copyDirSkippingCurrentSession
|
|
236
|
+
// (Dirent.isFile skips symlinks). Accepting a symlink would report placed with a
|
|
237
|
+
// broken ledger target after copy.
|
|
238
|
+
try {
|
|
239
|
+
const info = await lstat(liveSession);
|
|
240
|
+
if (!info.isFile())
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
if (isEnoent(error))
|
|
245
|
+
return undefined;
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
const parentSession = await readJsonlParentSession(liveSession);
|
|
249
|
+
if (parentSession === undefined)
|
|
250
|
+
return undefined;
|
|
251
|
+
const parent = parseParentRunBoundToMigration(booksDirectory, backupBooksDirectory, parentSession);
|
|
252
|
+
if (parent === undefined)
|
|
253
|
+
return undefined;
|
|
254
|
+
if (!(await backupParentRunExists(backupBooksDirectory, parent)))
|
|
255
|
+
return undefined;
|
|
256
|
+
return parent;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Full historical→final run map for relocation. Covers every run already placed
|
|
260
|
+
* under books/ (T9 ticket/unbound nests) so cross-run parent pointers rewrite to
|
|
261
|
+
* each peer's final path, not only the current parent.
|
|
262
|
+
*/
|
|
263
|
+
async function collectPlacedRunRewrites(booksDirectory, backupBooksDirectory) {
|
|
264
|
+
const rewrites = [];
|
|
265
|
+
const seen = new Set();
|
|
266
|
+
const push = (oldRunDirectory, newRunDirectory) => {
|
|
267
|
+
if (seen.has(oldRunDirectory))
|
|
268
|
+
return;
|
|
269
|
+
seen.add(oldRunDirectory);
|
|
270
|
+
rewrites.push({ oldRunDirectory, newRunDirectory });
|
|
271
|
+
};
|
|
272
|
+
for (const bookKey of await listMigrationBookKeys(booksDirectory)) {
|
|
273
|
+
for (const subject of await listMigrationDirents(join(booksDirectory, bookKey))) {
|
|
274
|
+
if (!subject.isDirectory())
|
|
275
|
+
continue;
|
|
276
|
+
const runsRoot = join(booksDirectory, bookKey, subject.name, "runs");
|
|
277
|
+
for (const run of await listMigrationDirents(runsRoot)) {
|
|
278
|
+
if (!run.isDirectory())
|
|
279
|
+
continue;
|
|
280
|
+
const finalPath = join(runsRoot, run.name);
|
|
281
|
+
push(join(booksDirectory, bookKey, "runs", run.name), finalPath);
|
|
282
|
+
push(join(backupBooksDirectory, bookKey, "runs", run.name), finalPath);
|
|
283
|
+
push(join(backupBooksDirectory, bookKey, subject.name, "runs", run.name), finalPath);
|
|
284
|
+
if (isTicketNumberString(subject.name)) {
|
|
285
|
+
push(join(booksDirectory, bookKey, "issues", subject.name, "runs", run.name), finalPath);
|
|
286
|
+
push(join(backupBooksDirectory, bookKey, "issues", subject.name, "runs", run.name), finalPath);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return rewrites;
|
|
292
|
+
}
|
|
293
|
+
function historicalRunDirectoriesFor(booksDirectory, backupBooksDirectory, parent) {
|
|
294
|
+
const out = [
|
|
295
|
+
join(booksDirectory, parent.bookKey, parent.sourceRelative),
|
|
296
|
+
join(backupBooksDirectory, parent.bookKey, parent.sourceRelative),
|
|
297
|
+
];
|
|
298
|
+
// Flat alias when the source nest is already ticket/unbound/issues.
|
|
299
|
+
if (!parent.sourceRelative.startsWith("runs/")) {
|
|
300
|
+
out.push(join(booksDirectory, parent.bookKey, "runs", parent.leafName));
|
|
301
|
+
out.push(join(backupBooksDirectory, parent.bookKey, "runs", parent.leafName));
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
async function resolveDestinationRun(context, parent) {
|
|
306
|
+
return findPlacedMigratingRun(context.booksDirectory, parent.bookKey, parent.leafName, parent.sourceRelative);
|
|
307
|
+
}
|
|
308
|
+
function rewritePathIntoDestination(sessionFile, sourceRoots, destRoot) {
|
|
309
|
+
const resolved = resolve(sessionFile);
|
|
310
|
+
for (const root of sourceRoots) {
|
|
311
|
+
const resolvedRoot = resolve(root);
|
|
312
|
+
if (pathContainedIn(resolvedRoot, resolved)) {
|
|
313
|
+
return join(destRoot, relative(resolvedRoot, resolved));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return sessionFile;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Destination current-session.sessionFile for mtime-tie comparison.
|
|
320
|
+
* Missing file (ENOENT) → undefined; non-ENOENT I/O, JSON damage, or non-object
|
|
321
|
+
* / non-string sessionFile keep identity and throw — never pretend "no pointer".
|
|
322
|
+
*/
|
|
323
|
+
async function readSessionFileField(path) {
|
|
324
|
+
let raw;
|
|
325
|
+
try {
|
|
326
|
+
raw = await readFile(path, "utf8");
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
if (isEnoent(error))
|
|
330
|
+
return undefined;
|
|
331
|
+
throw error;
|
|
332
|
+
}
|
|
333
|
+
let parsed;
|
|
334
|
+
try {
|
|
335
|
+
parsed = JSON.parse(raw);
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
throw new Error(`book topology migration cannot read current-session pointer at ${path}: invalid JSON (${errorText(error)})`, { cause: error });
|
|
339
|
+
}
|
|
340
|
+
if (!isRecord(parsed)) {
|
|
341
|
+
throw new Error(`book topology migration cannot read current-session pointer at ${path}: expected a JSON object`);
|
|
342
|
+
}
|
|
343
|
+
const sessionFile = parsed.sessionFile;
|
|
344
|
+
if (typeof sessionFile !== "string") {
|
|
345
|
+
throw new Error(`book topology migration cannot read current-session pointer at ${path}: sessionFile must be a string`);
|
|
346
|
+
}
|
|
347
|
+
return sessionFile;
|
|
348
|
+
}
|
|
349
|
+
async function writeWinningCurrentSession(destPath, srcStat, destStat, nextRaw, nextSessionFile) {
|
|
350
|
+
if (destStat !== undefined) {
|
|
351
|
+
if (destStat.mtimeMs > srcStat.mtimeMs)
|
|
352
|
+
return;
|
|
353
|
+
if (destStat.mtimeMs === srcStat.mtimeMs) {
|
|
354
|
+
// Only a successfully projected dest pointer enters the basename race.
|
|
355
|
+
// Damage must throw above — never compare as empty and overwrite.
|
|
356
|
+
const destSessionFile = await readSessionFileField(destPath);
|
|
357
|
+
if (destSessionFile === undefined) {
|
|
358
|
+
throw new Error(`book topology migration cannot compare current-session pointer at ${destPath}: file disappeared during tie-break`);
|
|
359
|
+
}
|
|
360
|
+
const srcName = basename(nextSessionFile ?? "");
|
|
361
|
+
const destName = basename(destSessionFile);
|
|
362
|
+
if (destName >= srcName)
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
await writeFile(destPath, nextRaw, "utf8");
|
|
367
|
+
await utimes(destPath, srcStat.atime, srcStat.mtime);
|
|
368
|
+
}
|
|
369
|
+
async function mergeRewrittenCurrentSession(volumeDirectory, historicalVolume, destNest) {
|
|
370
|
+
const srcPath = join(volumeDirectory, CURRENT_SESSION_LEDGER);
|
|
371
|
+
let srcRaw;
|
|
372
|
+
let srcStat;
|
|
373
|
+
try {
|
|
374
|
+
srcStat = await stat(srcPath);
|
|
375
|
+
srcRaw = await readFile(srcPath, "utf8");
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
if (isEnoent(error))
|
|
379
|
+
return;
|
|
380
|
+
throw error;
|
|
381
|
+
}
|
|
382
|
+
let nextRaw = srcRaw.endsWith("\n") ? srcRaw : `${srcRaw}\n`;
|
|
383
|
+
let nextSessionFile;
|
|
384
|
+
try {
|
|
385
|
+
const parsed = JSON.parse(srcRaw);
|
|
386
|
+
if (isRecord(parsed)) {
|
|
387
|
+
const record = parsed;
|
|
388
|
+
if (typeof record.sessionFile === "string") {
|
|
389
|
+
const rewritten = rewritePathIntoDestination(record.sessionFile, [volumeDirectory, historicalVolume], destNest);
|
|
390
|
+
record.sessionFile = rewritten;
|
|
391
|
+
nextSessionFile = rewritten;
|
|
392
|
+
}
|
|
393
|
+
nextRaw = `${JSON.stringify(record)}\n`;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
nextSessionFile = undefined;
|
|
398
|
+
}
|
|
399
|
+
const destPath = join(destNest, CURRENT_SESSION_LEDGER);
|
|
400
|
+
let destStat;
|
|
401
|
+
try {
|
|
402
|
+
destStat = await stat(destPath);
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
if (!isEnoent(error))
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
await writeWinningCurrentSession(destPath, srcStat, destStat, nextRaw, nextSessionFile);
|
|
409
|
+
}
|
|
410
|
+
async function copyDirSkippingCurrentSession(from, to) {
|
|
411
|
+
await mkdir(to, { recursive: true });
|
|
412
|
+
for (const entry of await listMigrationDirents(from)) {
|
|
413
|
+
if (entry.name === CURRENT_SESSION_LEDGER && entry.isFile())
|
|
414
|
+
continue;
|
|
415
|
+
const src = join(from, entry.name);
|
|
416
|
+
const dest = join(to, entry.name);
|
|
417
|
+
if (entry.isDirectory()) {
|
|
418
|
+
await copyDirSkippingCurrentSession(src, dest);
|
|
419
|
+
}
|
|
420
|
+
else if (entry.isFile()) {
|
|
421
|
+
await cp(src, dest, { preserveTimestamps: true });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
async function copyVolumeIntoNest(volumeDirectory, historicalVolume, destNest) {
|
|
426
|
+
await copyDirSkippingCurrentSession(volumeDirectory, destNest);
|
|
427
|
+
await mergeRewrittenCurrentSession(volumeDirectory, historicalVolume, destNest);
|
|
428
|
+
}
|
|
429
|
+
async function copyTree(source, destination) {
|
|
430
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
431
|
+
await cp(source, destination, { recursive: true, preserveTimestamps: true });
|
|
432
|
+
}
|
|
433
|
+
export const bookTopologyAuditorRolesMigrator = {
|
|
434
|
+
partition: AUDITOR_ROLES_PARTITION,
|
|
435
|
+
async migrate(context) {
|
|
436
|
+
const outcomes = [];
|
|
437
|
+
const { backupBooksDirectory, booksDirectory } = context;
|
|
438
|
+
// T9 already rewrote pages present in flat runs; volumes land after that and
|
|
439
|
+
// must reuse the same relocation authority with the full historical→final map.
|
|
440
|
+
const crossRunRewrites = await collectPlacedRunRewrites(booksDirectory, backupBooksDirectory);
|
|
441
|
+
const rewriteSeen = new Set(crossRunRewrites.map((r) => r.oldRunDirectory));
|
|
442
|
+
for (const bookKey of await listMigrationBookKeys(backupBooksDirectory)) {
|
|
443
|
+
const auditorRoot = join(backupBooksDirectory, bookKey, AUDITOR_ROLES_PARTITION);
|
|
444
|
+
for (const entry of await listMigrationDirents(auditorRoot)) {
|
|
445
|
+
const sourcePath = join(auditorRoot, entry.name);
|
|
446
|
+
if (entry.isFile() && entry.name === T10_MISFILED_NAME) {
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
const source = sourceIdentity(backupBooksDirectory, sourcePath);
|
|
450
|
+
if (!entry.isDirectory()) {
|
|
451
|
+
outcomes.push({ disposition: "discarded", source });
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
const parent = await findTrueVolumeParent(backupBooksDirectory, booksDirectory, sourcePath);
|
|
455
|
+
if (parent === undefined) {
|
|
456
|
+
outcomes.push({ disposition: "discarded", source });
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
const destination = await resolveDestinationRun(context, parent);
|
|
460
|
+
const historicalVolume = join(booksDirectory, bookKey, AUDITOR_ROLES_PARTITION, entry.name);
|
|
461
|
+
if (destination === undefined) {
|
|
462
|
+
const destNest = join(booksDirectory, bookKey, "unbound", AUDITOR_ROLES_PARTITION, entry.name);
|
|
463
|
+
await copyVolumeIntoNest(sourcePath, historicalVolume, destNest);
|
|
464
|
+
outcomes.push({ disposition: "unbound", source });
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const destNest = join(destination.runDirectory, "session", AUDITOR_ROLES_PARTITION);
|
|
468
|
+
await copyVolumeIntoNest(sourcePath, historicalVolume, destNest);
|
|
469
|
+
const historicalParents = historicalRunDirectoriesFor(booksDirectory, backupBooksDirectory, parent);
|
|
470
|
+
for (const oldRunDirectory of historicalParents) {
|
|
471
|
+
if (rewriteSeen.has(oldRunDirectory))
|
|
472
|
+
continue;
|
|
473
|
+
rewriteSeen.add(oldRunDirectory);
|
|
474
|
+
crossRunRewrites.push({
|
|
475
|
+
oldRunDirectory,
|
|
476
|
+
newRunDirectory: destination.runDirectory,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
// Own pair = this parent; cross map covers every other final placement.
|
|
480
|
+
await rewriteRoleRunDurablePages({
|
|
481
|
+
pagesDirectory: destination.runDirectory,
|
|
482
|
+
oldRunDirectory: historicalParents[0],
|
|
483
|
+
newRunDirectory: destination.runDirectory,
|
|
484
|
+
crossRunRewrites,
|
|
485
|
+
});
|
|
486
|
+
outcomes.push({ disposition: destination.disposition, source });
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return reconcileMigrationPartition(AUDITOR_ROLES_PARTITION, "entries", outcomes);
|
|
490
|
+
},
|
|
491
|
+
};
|
|
492
|
+
/**
|
|
493
|
+
* Copy ticket-level issues material only. Runs under issues/<ticket>/runs are
|
|
494
|
+
* owned by the unified T9 inventory (listBackupRunLeaves layout "issues") and
|
|
495
|
+
* must not be force-copied over already-placed destinations.
|
|
496
|
+
*/
|
|
497
|
+
async function copyIssuesNonRunTree(source, destination) {
|
|
498
|
+
await mkdir(destination, { recursive: true });
|
|
499
|
+
for (const entry of await listMigrationDirents(source)) {
|
|
500
|
+
if (entry.name === "runs")
|
|
501
|
+
continue;
|
|
502
|
+
const from = join(source, entry.name);
|
|
503
|
+
const to = join(destination, entry.name);
|
|
504
|
+
if (entry.isDirectory()) {
|
|
505
|
+
await copyIssuesNonRunTree(from, to);
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
if (!entry.isFile())
|
|
509
|
+
continue;
|
|
510
|
+
try {
|
|
511
|
+
await stat(to);
|
|
512
|
+
throw new Error(`book topology migration refuses to overwrite ${to} with ${from}`);
|
|
513
|
+
}
|
|
514
|
+
catch (error) {
|
|
515
|
+
if (!isEnoent(error))
|
|
516
|
+
throw error;
|
|
517
|
+
}
|
|
518
|
+
await cp(from, to, { preserveTimestamps: true });
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
export const bookTopologyIssuesMigrator = {
|
|
522
|
+
partition: ISSUES_PARTITION,
|
|
523
|
+
async migrate(context) {
|
|
524
|
+
const outcomes = [];
|
|
525
|
+
const { backupBooksDirectory, booksDirectory } = context;
|
|
526
|
+
for (const bookKey of await listMigrationBookKeys(backupBooksDirectory)) {
|
|
527
|
+
const issuesRoot = join(backupBooksDirectory, bookKey, ISSUES_PARTITION);
|
|
528
|
+
for (const entry of await listMigrationDirents(issuesRoot)) {
|
|
529
|
+
const sourcePath = join(issuesRoot, entry.name);
|
|
530
|
+
const source = sourceIdentity(backupBooksDirectory, sourcePath);
|
|
531
|
+
const isTicketDir = entry.isDirectory() && TICKET_NUMBER_NAME.test(entry.name);
|
|
532
|
+
if (!isTicketDir || !(await hasAnyFileRecursive(sourcePath))) {
|
|
533
|
+
outcomes.push({ disposition: "discarded", source });
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
// Non-run companions only; runs closed under the runs partition.
|
|
537
|
+
// Ticket entry is still placed when it only held runs already migrated by T9.
|
|
538
|
+
await copyIssuesNonRunTree(sourcePath, join(booksDirectory, bookKey, entry.name));
|
|
539
|
+
outcomes.push({ disposition: "placed", source });
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return reconcileMigrationPartition(ISSUES_PARTITION, "entries", outcomes);
|
|
543
|
+
},
|
|
544
|
+
};
|
|
545
|
+
export const bookTopologyDeprecatedKindsMigrator = {
|
|
546
|
+
partition: DEPRECATED_KINDS_PARTITION,
|
|
547
|
+
async migrate(context) {
|
|
548
|
+
const outcomes = [];
|
|
549
|
+
const { backupBooksDirectory } = context;
|
|
550
|
+
for (const bookKey of await listMigrationBookKeys(backupBooksDirectory)) {
|
|
551
|
+
for (const kind of DEPRECATED_KIND_DIRECTORIES) {
|
|
552
|
+
const kindRoot = join(backupBooksDirectory, bookKey, kind);
|
|
553
|
+
for (const entry of await listMigrationDirents(kindRoot)) {
|
|
554
|
+
outcomes.push({
|
|
555
|
+
disposition: "discarded",
|
|
556
|
+
source: sourceIdentity(backupBooksDirectory, join(kindRoot, entry.name)),
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return reconcileMigrationPartition(DEPRECATED_KINDS_PARTITION, "entries", outcomes);
|
|
562
|
+
},
|
|
563
|
+
};
|
|
564
|
+
async function unlinkIfPresent(path) {
|
|
565
|
+
try {
|
|
566
|
+
await unlink(path);
|
|
567
|
+
}
|
|
568
|
+
catch (error) {
|
|
569
|
+
if (!isEnoent(error))
|
|
570
|
+
throw error;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Source-correlated discard for one run directory: count each deprecated page
|
|
575
|
+
* and delete it from the matching final run when that run was already copied.
|
|
576
|
+
*/
|
|
577
|
+
async function discardDeprecatedPagesFromRunSource(input) {
|
|
578
|
+
for (const page of await listMigrationDirents(input.sourceRunDirectory)) {
|
|
579
|
+
if (!page.isFile() || !isDeprecatedRunPage(page.name))
|
|
580
|
+
continue;
|
|
581
|
+
input.outcomes.push({
|
|
582
|
+
disposition: "discarded",
|
|
583
|
+
source: sourceIdentity(input.backupBooksDirectory, join(input.sourceRunDirectory, page.name)),
|
|
584
|
+
});
|
|
585
|
+
if (input.destinationRunDirectory !== undefined) {
|
|
586
|
+
await unlinkIfPresent(join(input.destinationRunDirectory, page.name));
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Count each backup deprecated run page as discarded, and delete the same page
|
|
592
|
+
* from the destination run when it has already been placed. One rule, one place:
|
|
593
|
+
* isDeprecatedRunPage names the pages; listBackupRunLeaves is the sole inventory
|
|
594
|
+
* (flat, ticket, unbound, issues).
|
|
595
|
+
*/
|
|
596
|
+
export const bookTopologyDeprecatedRunPagesMigrator = {
|
|
597
|
+
partition: DEPRECATED_RUN_PAGES_PARTITION,
|
|
598
|
+
async migrate(context) {
|
|
599
|
+
const outcomes = [];
|
|
600
|
+
const { backupBooksDirectory, booksDirectory } = context;
|
|
601
|
+
for (const bookKey of await listMigrationBookKeys(backupBooksDirectory)) {
|
|
602
|
+
for (const leaf of await listBackupRunLeaves(join(backupBooksDirectory, bookKey))) {
|
|
603
|
+
if (!leaf.isDirectory)
|
|
604
|
+
continue;
|
|
605
|
+
const placed = await findPlacedMigratingRun(booksDirectory, bookKey, leaf.leafName, leaf.relativePath);
|
|
606
|
+
await discardDeprecatedPagesFromRunSource({
|
|
607
|
+
backupBooksDirectory,
|
|
608
|
+
sourceRunDirectory: leaf.sourcePath,
|
|
609
|
+
destinationRunDirectory: placed?.runDirectory,
|
|
610
|
+
outcomes,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return reconcileMigrationPartition(DEPRECATED_RUN_PAGES_PARTITION, "entries", outcomes);
|
|
615
|
+
},
|
|
616
|
+
};
|
|
617
|
+
async function copyBookTopLevelPartition(context, partition, outcomes) {
|
|
618
|
+
for (const bookKey of await listMigrationBookKeys(context.backupBooksDirectory)) {
|
|
619
|
+
const sourceDir = join(context.backupBooksDirectory, bookKey, partition);
|
|
620
|
+
if (!(await directoryExists(sourceDir)))
|
|
621
|
+
continue;
|
|
622
|
+
await copyTree(sourceDir, join(context.booksDirectory, bookKey, partition));
|
|
623
|
+
for (const entry of await listMigrationDirents(sourceDir)) {
|
|
624
|
+
outcomes.push({
|
|
625
|
+
disposition: "placed",
|
|
626
|
+
source: sourceIdentity(context.backupBooksDirectory, join(sourceDir, entry.name)),
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
export const bookTopologyNavigatorMigrator = {
|
|
632
|
+
partition: NAVIGATOR_PARTITION,
|
|
633
|
+
async migrate(context) {
|
|
634
|
+
const outcomes = [];
|
|
635
|
+
await copyBookTopLevelPartition(context, NAVIGATOR_PARTITION, outcomes);
|
|
636
|
+
return reconcileMigrationPartition(NAVIGATOR_PARTITION, "entries", outcomes);
|
|
637
|
+
},
|
|
638
|
+
};
|
|
639
|
+
export const bookTopologyCollectorHandbookMigrator = {
|
|
640
|
+
partition: COLLECTOR_HANDBOOK_PARTITION,
|
|
641
|
+
async migrate(context) {
|
|
642
|
+
const outcomes = [];
|
|
643
|
+
await copyBookTopLevelPartition(context, COLLECTOR_HANDBOOK_PARTITION, outcomes);
|
|
644
|
+
return reconcileMigrationPartition(COLLECTOR_HANDBOOK_PARTITION, "entries", outcomes);
|
|
645
|
+
},
|
|
646
|
+
};
|
|
647
|
+
export const bookTopologyManualArchivesMigrator = {
|
|
648
|
+
partition: MANUAL_ARCHIVES_PARTITION,
|
|
649
|
+
async migrate(context) {
|
|
650
|
+
const outcomes = [];
|
|
651
|
+
const { backupBooksDirectory, booksDirectory } = context;
|
|
652
|
+
const archivesRoot = join(dirname(booksDirectory), MANUAL_ARCHIVES_PARTITION);
|
|
653
|
+
for (const bookKey of await listMigrationBookKeys(backupBooksDirectory)) {
|
|
654
|
+
for (const dirName of MANUAL_ARCHIVE_DIRECTORIES) {
|
|
655
|
+
const sourceDir = join(backupBooksDirectory, bookKey, dirName);
|
|
656
|
+
if (!(await directoryExists(sourceDir)))
|
|
657
|
+
continue;
|
|
658
|
+
await copyTree(sourceDir, join(archivesRoot, bookKey, dirName));
|
|
659
|
+
outcomes.push({
|
|
660
|
+
disposition: "placed",
|
|
661
|
+
source: sourceIdentity(backupBooksDirectory, sourceDir),
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return reconcileMigrationPartition(MANUAL_ARCHIVES_PARTITION, "entries", outcomes);
|
|
666
|
+
},
|
|
667
|
+
};
|
|
668
|
+
/** T11 partitions in the documented registration order. */
|
|
669
|
+
export const BOOK_TOPOLOGY_T11_MIGRATORS = [
|
|
670
|
+
bookTopologyAuditorRolesMigrator,
|
|
671
|
+
bookTopologyIssuesMigrator,
|
|
672
|
+
bookTopologyDeprecatedKindsMigrator,
|
|
673
|
+
bookTopologyDeprecatedRunPagesMigrator,
|
|
674
|
+
bookTopologyNavigatorMigrator,
|
|
675
|
+
bookTopologyCollectorHandbookMigrator,
|
|
676
|
+
bookTopologyManualArchivesMigrator,
|
|
677
|
+
];
|