@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,7 @@
1
+ /**
2
+ * Public Auditor (审刑院) role — filed-officer envelope (#675).
3
+ */
4
+ import { AUDITOR_OUTPUT_TOOL_NAME, } from "./package-contracts/auditor-output.js";
5
+ import { roleSubmissionDeclaration } from "./role-submission-declarations.js";
6
+ export { AUDITOR_OUTPUT_TOOL_NAME };
7
+ export const AUDITOR_TOOL_SPEC = roleSubmissionDeclaration("auditor");
@@ -0,0 +1,404 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { pathContainedIn } from "./activation-ledger-topology.js";
4
+ import { isUnboundRunDirectory, roleRunPlacement } from "./role-run-placement.js";
5
+ export { isUnboundRunDirectory };
6
+ import { MIGRATION_TICKET_DERIVATION_PAGE, readMigrationBoardTicketNumber, readMigrationDerivedTicketNumber, } from "./run-ticket-number.js";
7
+ import { readPageSync } from "./run-dossier.js";
8
+ import { isRecord, isEnoent } from "./unknown-value.js";
9
+ const TICKET_NUMBER_RE = /^[1-9][0-9]*$/;
10
+ const RUN_DIR_NAME_RE = /^([^@]+)@([^@]+)$/;
11
+ /** Book-key directories under a books root; missing root → []. */
12
+ export async function listMigrationBookKeys(booksDirectory) {
13
+ try {
14
+ const entries = await readdir(booksDirectory, { withFileTypes: true });
15
+ return entries
16
+ .filter((entry) => entry.isDirectory())
17
+ .map((entry) => entry.name)
18
+ .sort((a, b) => a.localeCompare(b));
19
+ }
20
+ catch (error) {
21
+ if (isEnoent(error))
22
+ return [];
23
+ throw error;
24
+ }
25
+ }
26
+ /** Directory entries sorted by name; missing directory → []. */
27
+ export async function listMigrationDirents(directory) {
28
+ try {
29
+ const entries = await readdir(directory, { withFileTypes: true });
30
+ return [...entries].sort((a, b) => a.name.localeCompare(b.name));
31
+ }
32
+ catch (error) {
33
+ if (isEnoent(error))
34
+ return [];
35
+ throw error;
36
+ }
37
+ }
38
+ export function ticketNumberFromUnknown(value) {
39
+ if (typeof value === "number" && Number.isSafeInteger(value) && value > 0)
40
+ return value;
41
+ if (typeof value === "string" && TICKET_NUMBER_RE.test(value))
42
+ return Number(value);
43
+ return undefined;
44
+ }
45
+ export function isTicketNumberString(value) {
46
+ return TICKET_NUMBER_RE.test(value);
47
+ }
48
+ /**
49
+ * Ticket number contained in a worktree path's final segment.
50
+ * #852: 「工作树路径末段所含票号」— shared by #865 and #866.
51
+ * Exactly one digit run that is itself a safe positive ticket spelling;
52
+ * multiple or zero digit runs are undecidable → undefined (unbound).
53
+ */
54
+ export function ticketNumberFromWorktreeBasename(pathBasename) {
55
+ const matches = pathBasename.match(/\d+/g);
56
+ if (matches === null || matches.length !== 1)
57
+ return undefined;
58
+ const spelling = matches[0];
59
+ if (!TICKET_NUMBER_RE.test(spelling))
60
+ return undefined;
61
+ const ticketNumber = Number(spelling);
62
+ if (!Number.isSafeInteger(ticketNumber) || ticketNumber < 1)
63
+ return undefined;
64
+ return ticketNumber;
65
+ }
66
+ /**
67
+ * projectRoot from a retained run's durable pages.
68
+ * New落点 (state rows) first, then pre-#1161 page files at the migration boundary.
69
+ * ENOENT → try next / undefined; bad JSON and non-ENOENT IO propagate.
70
+ */
71
+ async function readProjectRootFromRun(runDirectory) {
72
+ for (const section of ["admitted", "invocation", "runState"]) {
73
+ try {
74
+ const page = readPageSync(runDirectory, section);
75
+ const projectRoot = page?.projectRoot;
76
+ if (typeof projectRoot === "string" && projectRoot.length > 0) {
77
+ return { projectRoot, sourcePage: `state.jsonl:${section}` };
78
+ }
79
+ }
80
+ catch (error) {
81
+ if (!isEnoent(error))
82
+ throw error;
83
+ }
84
+ }
85
+ for (const page of ["admitted-request.json", "invocation.json", "run-state.json"]) {
86
+ const path = join(runDirectory, page);
87
+ try {
88
+ const raw = JSON.parse(await readFile(path, "utf8"));
89
+ if (!isRecord(raw))
90
+ continue;
91
+ const projectRoot = raw.projectRoot;
92
+ if (typeof projectRoot === "string" && projectRoot.length > 0) {
93
+ return { projectRoot, sourcePage: page };
94
+ }
95
+ }
96
+ catch (error) {
97
+ if (isEnoent(error))
98
+ continue;
99
+ throw error;
100
+ }
101
+ }
102
+ return undefined;
103
+ }
104
+ /**
105
+ * Ticket binding for a retained run directory (#852 / #865 / #866):
106
+ * effective placement ticket (board pages, else migration-derived page) first,
107
+ * then worktree basename containment when the leaf holds a ticket number.
108
+ * Migration placement may use derived facts; LLM/board identity writers must not.
109
+ */
110
+ export async function resolveMigratingRunTicket(runDirectory) {
111
+ const boardTicket = await readMigrationBoardTicketNumber(runDirectory);
112
+ if (boardTicket !== undefined) {
113
+ return {
114
+ ticketNumber: boardTicket,
115
+ derivation: { method: "board", source: runDirectory },
116
+ };
117
+ }
118
+ const derivedTicket = await readMigrationDerivedTicketNumber(runDirectory);
119
+ if (derivedTicket !== undefined) {
120
+ return {
121
+ ticketNumber: derivedTicket,
122
+ derivation: {
123
+ method: "project-root-basename",
124
+ source: runDirectory,
125
+ sourcePage: MIGRATION_TICKET_DERIVATION_PAGE,
126
+ },
127
+ };
128
+ }
129
+ const project = await readProjectRootFromRun(runDirectory);
130
+ if (project === undefined)
131
+ return { ticketNumber: undefined, derivation: undefined };
132
+ const leaf = basename(project.projectRoot.replace(/\\/g, "/"));
133
+ const ticketNumber = ticketNumberFromWorktreeBasename(leaf);
134
+ if (ticketNumber === undefined)
135
+ return { ticketNumber: undefined, derivation: undefined };
136
+ return {
137
+ ticketNumber,
138
+ derivation: {
139
+ method: "project-root-basename",
140
+ source: project.projectRoot,
141
+ sourcePage: project.sourcePage,
142
+ },
143
+ };
144
+ }
145
+ export function bookHistoricalRoots(booksDirectory, backupBooksDirectory, bookKey) {
146
+ return [join(booksDirectory, bookKey), join(backupBooksDirectory, bookKey)];
147
+ }
148
+ function runLeafFromRelative(path) {
149
+ const segments = path.replaceAll("\\", "/").split("/").filter((segment) => segment.length > 0);
150
+ const runsIndex = segments.lastIndexOf("runs");
151
+ if (runsIndex < 0 || runsIndex + 1 >= segments.length)
152
+ return undefined;
153
+ const leaf = segments[runsIndex + 1];
154
+ if (leaf === undefined || leaf === "." || leaf === "..")
155
+ return undefined;
156
+ return leaf;
157
+ }
158
+ /** Bind a path to this book's historical roots, then take complete leaf + source-relative run dir. */
159
+ export function runRefFromBoundPath(path, bookRoots) {
160
+ for (const root of bookRoots) {
161
+ const rootResolved = resolve(root);
162
+ const candidate = isAbsolute(path) ? resolve(path) : resolve(rootResolved, path);
163
+ if (candidate === rootResolved || !pathContainedIn(rootResolved, candidate))
164
+ continue;
165
+ const rel = relative(rootResolved, candidate).split(sep).join("/");
166
+ const leaf = runLeafFromRelative(rel);
167
+ if (leaf === undefined)
168
+ continue;
169
+ const parts = rel.split("/").filter((part) => part.length > 0);
170
+ const runsIndex = parts.indexOf("runs");
171
+ if (runsIndex < 0 || runsIndex + 1 >= parts.length)
172
+ continue;
173
+ return { leaf, sourceRelative: parts.slice(0, runsIndex + 2).join("/") };
174
+ }
175
+ return undefined;
176
+ }
177
+ async function listRunLeafEntries(runsDirectory) {
178
+ const entries = await listMigrationDirents(runsDirectory);
179
+ return entries.map((entry) => ({
180
+ name: entry.name,
181
+ isDirectory: entry.isDirectory(),
182
+ }));
183
+ }
184
+ /**
185
+ * Every retained run tree under one backup book: legacy flat `runs/`,
186
+ * already-canonical `<ticket>/runs/`, `unbound/runs/`, and legacy
187
+ * `issues/<ticket>/runs/`.
188
+ */
189
+ export async function listBackupRunLeaves(backupBookDirectory) {
190
+ const leaves = [];
191
+ const collect = async (relativeDir, layout) => {
192
+ const runsDirectory = join(backupBookDirectory, ...relativeDir.split("/"));
193
+ for (const entry of await listRunLeafEntries(runsDirectory)) {
194
+ leaves.push({
195
+ relativePath: `${relativeDir}/${entry.name}`,
196
+ sourcePath: join(runsDirectory, entry.name),
197
+ leafName: entry.name,
198
+ isDirectory: entry.isDirectory,
199
+ layout,
200
+ });
201
+ }
202
+ };
203
+ await collect("runs", "flat");
204
+ await collect("unbound/runs", "unbound");
205
+ for (const subject of await listMigrationDirents(backupBookDirectory)) {
206
+ if (!subject.isDirectory() || !isTicketNumberString(subject.name))
207
+ continue;
208
+ await collect(`${subject.name}/runs`, "ticket");
209
+ }
210
+ for (const issue of await listMigrationDirents(join(backupBookDirectory, "issues"))) {
211
+ if (!issue.isDirectory() || !isTicketNumberString(issue.name))
212
+ continue;
213
+ await collect(`issues/${issue.name}/runs`, "issues");
214
+ }
215
+ return leaves;
216
+ }
217
+ async function listExactPlacedRunPaths(bookDir, leafName) {
218
+ const matches = [];
219
+ const subjectEntries = await listMigrationDirents(bookDir);
220
+ const runsDirs = [
221
+ join(bookDir, "runs"),
222
+ ...subjectEntries.filter((entry) => entry.isDirectory()).map((entry) => join(bookDir, entry.name, "runs")),
223
+ ];
224
+ for (const runsDir of runsDirs) {
225
+ const entries = await listMigrationDirents(runsDir);
226
+ if (entries.some((entry) => entry.name === leafName)) {
227
+ matches.push(join(runsDir, leafName));
228
+ }
229
+ }
230
+ return matches;
231
+ }
232
+ function destPathFromSourceRelative(booksDirectory, bookKey, sourceRelative) {
233
+ const parts = sourceRelative.replaceAll("\\", "/").split("/").filter((part) => part.length > 0);
234
+ const runsIndex = parts.indexOf("runs");
235
+ if (runsIndex < 0 || runsIndex + 1 >= parts.length)
236
+ return undefined;
237
+ const leaf = parts[runsIndex + 1];
238
+ const before = parts.slice(0, runsIndex);
239
+ if (leaf === undefined)
240
+ return undefined;
241
+ // Legacy issues/<ticket>/runs/<leaf> → canonical <ticket>/runs/<leaf>.
242
+ if (before.length === 2
243
+ && before[0] === "issues"
244
+ && before[1] !== undefined
245
+ && isTicketNumberString(before[1])) {
246
+ return join(booksDirectory, bookKey, before[1], "runs", leaf);
247
+ }
248
+ if (before.length !== 1)
249
+ return undefined;
250
+ const subject = before[0];
251
+ if (subject === undefined)
252
+ return undefined;
253
+ if (subject !== "unbound" && !isTicketNumberString(subject))
254
+ return undefined;
255
+ return join(booksDirectory, bookKey, subject, "runs", leaf);
256
+ }
257
+ function placedRunFromPath(runDirectory) {
258
+ return {
259
+ runDirectory,
260
+ disposition: isUnboundRunDirectory(runDirectory) ? "unbound" : "placed",
261
+ };
262
+ }
263
+ async function listPrincipalPlacedRunPaths(bookDir, runId) {
264
+ const matches = [...await listExactPlacedRunPaths(bookDir, runId)];
265
+ const subjectEntries = await listMigrationDirents(bookDir);
266
+ const runsDirs = [
267
+ join(bookDir, "runs"),
268
+ ...subjectEntries.filter((entry) => entry.isDirectory()).map((entry) => join(bookDir, entry.name, "runs")),
269
+ ];
270
+ const prefix = `${runId}@`;
271
+ for (const runsDir of runsDirs) {
272
+ for (const entry of await listMigrationDirents(runsDir)) {
273
+ const name = entry.name;
274
+ if (!name.startsWith(prefix) || name.slice(runId.length + 1).includes("@"))
275
+ continue;
276
+ const path = join(runsDir, name);
277
+ if (!matches.includes(path))
278
+ matches.push(path);
279
+ }
280
+ }
281
+ return matches;
282
+ }
283
+ function uniquePlacedRun(leafName, matches) {
284
+ if (matches.length === 0)
285
+ return undefined;
286
+ if (matches.length === 1)
287
+ return placedRunFromPath(matches[0]);
288
+ throw new Error(`book topology migration cannot uniquely place run ${leafName}: ${matches.join(", ")}`);
289
+ }
290
+ /** Locate an exact run leaf under bookDir/runs, bookDir/<subject>/runs, bookDir/unbound/runs. */
291
+ export async function findBookRunDirectory(bookDir, runId, role) {
292
+ if (runId.trim() === "")
293
+ return undefined;
294
+ const leafName = role !== undefined && role.length > 0 ? `${runId}@${role}` : runId;
295
+ const matches = role !== undefined && role.length > 0
296
+ ? await listExactPlacedRunPaths(bookDir, leafName)
297
+ : await listPrincipalPlacedRunPaths(bookDir, runId);
298
+ const unique = uniquePlacedRun(leafName, matches);
299
+ if (unique === undefined)
300
+ return undefined;
301
+ const foundRole = unique.runDirectory.split(/[/\\]/).pop()?.split("@")[1] ?? role ?? "";
302
+ return { runDirectory: unique.runDirectory, role: foundRole };
303
+ }
304
+ /** Historical flat `runs/<leaf>` alias under a book root (not ticket/unbound). */
305
+ export function isFlatRunsRelative(sourceRelative, leafName) {
306
+ const parts = sourceRelative.replaceAll("\\", "/").split("/").filter((part) => part.length > 0);
307
+ return parts.length === 2 && parts[0] === "runs" && parts[1] === leafName;
308
+ }
309
+ /** Destination run already placed by T9. Follow complete leaf; bind source path when given. */
310
+ export async function findPlacedMigratingRun(booksDirectory, bookKey, leafName, sourceRelative) {
311
+ if (leafName.trim() === "")
312
+ return undefined;
313
+ const bookDir = join(booksDirectory, bookKey);
314
+ const matches = await listExactPlacedRunPaths(bookDir, leafName);
315
+ if (sourceRelative !== undefined && sourceRelative.length > 0) {
316
+ const preferred = destPathFromSourceRelative(booksDirectory, bookKey, sourceRelative);
317
+ if (preferred !== undefined) {
318
+ const hit = matches.find((path) => path === preferred);
319
+ if (hit !== undefined)
320
+ return placedRunFromPath(hit);
321
+ // T9 may have used the board ticket to move an historical unbound leaf
322
+ // under `<ticket>/runs`. The complete leaf is the placement identity;
323
+ // when it remains unique, downstream record/mixed/T11 consumers must
324
+ // follow that authoritative T9 result rather than re-derive unbound.
325
+ if (sourceRelative.replaceAll("\\", "/").startsWith("unbound/runs/")) {
326
+ return uniquePlacedRun(leafName, matches);
327
+ }
328
+ return undefined;
329
+ }
330
+ // Historical flat `runs/<leaf>` alias after T9 nested the unique complete leaf.
331
+ if (isFlatRunsRelative(sourceRelative, leafName)) {
332
+ return uniquePlacedRun(leafName, matches);
333
+ }
334
+ return undefined;
335
+ }
336
+ return uniquePlacedRun(leafName, matches);
337
+ }
338
+ /** True when this book retains exactly one directory named leafName across layouts. */
339
+ export async function uniqueRunLeafExistsInBook(bookDir, leafName) {
340
+ if (leafName.trim() === "")
341
+ return false;
342
+ const matches = await listExactPlacedRunPaths(bookDir, leafName);
343
+ return matches.length === 1;
344
+ }
345
+ /**
346
+ * No path / role evidence: follow runId only when exactly one `runId@role`
347
+ * (or bare runId) exists across this book's layouts. Zero or multi → undefined.
348
+ */
349
+ export async function findUniquePrincipalPlacedRun(booksDirectory, bookKey, runId) {
350
+ if (runId.trim() === "")
351
+ return undefined;
352
+ const matches = await listPrincipalPlacedRunPaths(join(booksDirectory, bookKey), runId);
353
+ if (matches.length !== 1)
354
+ return undefined;
355
+ return placedRunFromPath(matches[0]);
356
+ }
357
+ /**
358
+ * Destination run directory via the sole live placement authority (roleRunPlacement).
359
+ * booksDirectory is the books/ root; ledger home is its parent.
360
+ */
361
+ export function destinationRunDirectory(booksDirectory, bookKey, ticketNumber, runId, role) {
362
+ const ledgerHome = dirname(booksDirectory);
363
+ const subject = ticketNumber !== undefined
364
+ ? { ticketNumber }
365
+ : { unbound: true };
366
+ return roleRunPlacement(ledgerHome, {
367
+ bookKey,
368
+ subject,
369
+ runId,
370
+ role,
371
+ }).runDirectory;
372
+ }
373
+ /** Extract runId@role from a sessionParent path when it points at a run session. */
374
+ export function runCoordsFromSessionParent(sessionParent) {
375
+ if (typeof sessionParent !== "string" || sessionParent.length === 0)
376
+ return undefined;
377
+ const normalized = sessionParent.replace(/\\/g, "/");
378
+ const marker = "/runs/";
379
+ const index = normalized.lastIndexOf(marker);
380
+ if (index < 0)
381
+ return undefined;
382
+ const after = normalized.slice(index + marker.length);
383
+ const leaf = after.split("/")[0] ?? "";
384
+ const match = RUN_DIR_NAME_RE.exec(leaf);
385
+ if (match === null)
386
+ return undefined;
387
+ return { runId: match[1], role: match[2] };
388
+ }
389
+ export function runIdFromSubject(subject) {
390
+ if (typeof subject === "string" && subject.length > 0)
391
+ return subject;
392
+ if (isRecord(subject) && typeof subject.runId === "string" && subject.runId.length > 0) {
393
+ return subject.runId;
394
+ }
395
+ return undefined;
396
+ }
397
+ export function ticketNumberFromSubject(subject) {
398
+ if (typeof subject === "string" || typeof subject === "number") {
399
+ return ticketNumberFromUnknown(subject);
400
+ }
401
+ if (isRecord(subject))
402
+ return ticketNumberFromUnknown(subject.ticketNumber);
403
+ return undefined;
404
+ }
@@ -0,0 +1,125 @@
1
+ import { mkdir, readdir, rename } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { physicalPathIdentity, physicallyContainedIn, } from "./activation-ledger-topology.js";
5
+ import { autopsyWriterLock, describeErrorIdentity, } from "./public-cli/run-lifecycle.js";
6
+ /** Shared reconciliation projection. Counting outcomes makes the closure true by construction. */
7
+ export function reconcileMigrationPartition(partition, unit, outcomes) {
8
+ let placed = 0;
9
+ let unbound = 0;
10
+ let discarded = 0;
11
+ const malformedRows = [];
12
+ for (const outcome of outcomes) {
13
+ if (outcome.disposition === "placed")
14
+ placed += 1;
15
+ else if (outcome.disposition === "unbound")
16
+ unbound += 1;
17
+ else
18
+ discarded += 1;
19
+ if ("malformed" in outcome) {
20
+ malformedRows.push({ source: outcome.source, raw: outcome.malformedRaw });
21
+ }
22
+ }
23
+ return {
24
+ partition,
25
+ unit,
26
+ before: outcomes.length,
27
+ placed,
28
+ unbound,
29
+ discarded,
30
+ malformedRows,
31
+ };
32
+ }
33
+ async function findWriterLocks(root) {
34
+ const locks = [];
35
+ async function walk(directory) {
36
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
37
+ const path = join(directory, entry.name);
38
+ if (entry.name === "writer.lock") {
39
+ const kind = entry.isFile() ? "file"
40
+ : entry.isDirectory() ? "directory"
41
+ : entry.isSymbolicLink() ? "symbolic link"
42
+ : entry.isFIFO() ? "FIFO"
43
+ : entry.isSocket() ? "socket"
44
+ : entry.isCharacterDevice() ? "character device"
45
+ : entry.isBlockDevice() ? "block device"
46
+ : "unknown filesystem object";
47
+ locks.push({ path, regularFile: entry.isFile(), kind });
48
+ }
49
+ else if (entry.isDirectory()) {
50
+ await walk(path);
51
+ }
52
+ }
53
+ }
54
+ await walk(root);
55
+ return locks;
56
+ }
57
+ /**
58
+ * Refuse migration from a role whose canonical dossier is inside books/, and
59
+ * while a writer lease proves a run is currently active. Retained lifecycle
60
+ * state is history, not holder liveness.
61
+ */
62
+ export async function assertBookTopologyMigrationPrerequisites(booksDirectory, env = process.env) {
63
+ const runDirectory = env.AK_ROLE_RUN_DIR;
64
+ if (runDirectory !== undefined
65
+ && (physicalPathIdentity(booksDirectory) === physicalPathIdentity(runDirectory)
66
+ || physicallyContainedIn(booksDirectory, runDirectory))) {
67
+ throw new Error(`book topology migration cannot run from a role dossier inside books/: ${runDirectory}`);
68
+ }
69
+ const active = [];
70
+ for (const candidate of await findWriterLocks(booksDirectory)) {
71
+ const lockPath = candidate.path;
72
+ if (!candidate.regularFile) {
73
+ throw new Error(`cannot establish zero in-flight runs from ${lockPath}: writer lock is a ${candidate.kind}, holder liveness unverifiable`);
74
+ }
75
+ const holder = await autopsyWriterLock(lockPath);
76
+ if (holder.verdict === "alive") {
77
+ active.push(`${lockPath} (live pid ${holder.pid})`);
78
+ }
79
+ else if (holder.verdict === "unknown") {
80
+ const cause = holder.reason === "unreadable"
81
+ ? `unreadable: ${describeErrorIdentity(holder.readFailure)}`
82
+ : `unparseable holder: ${JSON.stringify(holder.content)}`;
83
+ throw new Error(`cannot establish zero in-flight runs from ${lockPath}: ${cause}`);
84
+ }
85
+ }
86
+ if (active.length > 0) {
87
+ throw new Error(`book topology migration requires zero in-flight runs:\n${active.join("\n")}`);
88
+ }
89
+ }
90
+ export function datedBooksBackupDirectory(booksDirectory, now = new Date()) {
91
+ const date = now.toISOString().slice(0, 10);
92
+ return join(dirname(booksDirectory), `${basename(booksDirectory)}-${date}`);
93
+ }
94
+ /** Atomic same-parent rename: the readable pre-migration tree is never packed. */
95
+ export async function renameBooksToDatedBackup(booksDirectory, now = new Date()) {
96
+ const backup = datedBooksBackupDirectory(booksDirectory, now);
97
+ await rename(booksDirectory, backup);
98
+ return backup;
99
+ }
100
+ export async function migrateBookTopology(input) {
101
+ if (input.migrators.length === 0) {
102
+ throw new Error("book topology migration has no partition migrators");
103
+ }
104
+ const ledgerHome = resolve(input.ledgerHome ?? join(homedir(), ".ak-roles"));
105
+ const booksDirectory = join(ledgerHome, "books");
106
+ await assertBookTopologyMigrationPrerequisites(booksDirectory, input.env);
107
+ const backupBooksDirectory = await renameBooksToDatedBackup(booksDirectory, input.now);
108
+ await mkdir(booksDirectory);
109
+ const context = {
110
+ backupBooksDirectory,
111
+ booksDirectory,
112
+ };
113
+ const partitions = [];
114
+ for (const migrator of input.migrators) {
115
+ const report = await migrator.migrate(context);
116
+ if (report.partition !== migrator.partition) {
117
+ throw new Error(`partition report mismatch: expected ${migrator.partition}, received ${report.partition}`);
118
+ }
119
+ if (report.before !== report.placed + report.unbound + report.discarded) {
120
+ throw new Error(`partition reconciliation did not close: ${report.partition}`);
121
+ }
122
+ partitions.push(report);
123
+ }
124
+ return { backupBooksDirectory, booksDirectory, partitions };
125
+ }