@akagilnc/pi-workflow-roles 0.1.4745 → 0.1.4772

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.
@@ -2,9 +2,10 @@
2
2
  * #865 T9: migrate retained runs into ticket-scoped or unbound placement.
3
3
  * Inventory covers legacy flat `runs/`, existing `<ticket>/runs/`, and
4
4
  * `unbound/runs/`. Attribution reuses the shared migrating-run helper;
5
- * already-canonical trees copy as-is.
5
+ * already-canonical ticket trees copy as-is. Unbound leaves that already
6
+ * hold a board typed ticketNumber place under that ticket (#863 stock).
6
7
  */
7
- import { cp, mkdir, stat, writeFile } from "node:fs/promises";
8
+ import { cp, mkdir, rename, readdir, stat, writeFile } from "node:fs/promises";
8
9
  import { basename, dirname, join, relative, sep } from "node:path";
9
10
 
10
11
  import {
@@ -16,16 +17,21 @@ import {
16
17
  resolveMigratingRunTicket,
17
18
  } from "./book-topology-migration-placement.ts";
18
19
  import {
20
+ assertBookTopologyMigrationPrerequisites,
19
21
  reconcileMigrationPartition,
20
22
  type BookTopologyMigrationContext,
21
23
  type BookTopologyPartitionMigrator,
22
24
  type MigrationItemOutcome,
23
25
  } from "./book-topology-migration.ts";
26
+ import { listBookRunDirectories } from "./role-run-placement.ts";
24
27
  import {
25
28
  rewriteRoleRunDurablePages,
26
29
  type RunDirectoryPathRewrite,
27
30
  } from "./role-run-relocation.ts";
28
- import { MIGRATION_TICKET_DERIVATION_PAGE } from "./run-ticket-number.ts";
31
+ import {
32
+ MIGRATION_TICKET_DERIVATION_PAGE,
33
+ readBoardTicketNumber,
34
+ } from "./run-ticket-number.ts";
29
35
 
30
36
  const RUNS_PARTITION = "runs";
31
37
 
@@ -141,9 +147,38 @@ async function planBookMoves(
141
147
  historicalRunDirectory = join(booksDirectory, bookKey, leaf.relativePath);
142
148
  }
143
149
  derivation = undefined;
150
+ } else if (leaf.layout === "unbound") {
151
+ // #863: board typed ticket on an unbound leaf → place under that ticket.
152
+ // No board ticket → stay unbound. Never invent from prose or derivation.
153
+ const boardTicket = leaf.isDirectory
154
+ ? await readBoardTicketNumber(leaf.sourcePath)
155
+ : undefined;
156
+ if (parsed !== undefined && boardTicket !== undefined) {
157
+ targetPath = destinationRunDirectory(
158
+ booksDirectory,
159
+ bookKey,
160
+ boardTicket,
161
+ parsed.runId,
162
+ parsed.role,
163
+ );
164
+ disposition = "placed";
165
+ historicalRunDirectory = join(
166
+ booksDirectory,
167
+ bookKey,
168
+ "unbound",
169
+ "runs",
170
+ leaf.leafName,
171
+ );
172
+ } else {
173
+ targetPath = join(booksDirectory, bookKey, leaf.relativePath);
174
+ disposition = "unbound";
175
+ historicalRunDirectory = undefined;
176
+ }
177
+ derivation = undefined;
144
178
  } else if (leaf.layout !== "flat") {
179
+ // Already-canonical `<ticket>/runs/` — copy as-is.
145
180
  targetPath = join(booksDirectory, bookKey, leaf.relativePath);
146
- disposition = leaf.layout === "unbound" ? "unbound" : "placed";
181
+ disposition = "placed";
147
182
  historicalRunDirectory = undefined;
148
183
  derivation = undefined;
149
184
  } else if (parsed === undefined) {
@@ -242,3 +277,120 @@ export const bookTopologyRunsMigrator: BookTopologyPartitionMigrator = {
242
277
  return reconcileMigrationPartition(RUNS_PARTITION, "entries", outcomes);
243
278
  },
244
279
  };
280
+
281
+ export type BoardBoundUnboundRelocation = {
282
+ readonly from: string;
283
+ readonly to: string;
284
+ readonly ticketNumber: number;
285
+ };
286
+
287
+ type PlannedBoardBoundMove = {
288
+ readonly sourcePath: string;
289
+ readonly targetPath: string;
290
+ readonly ticketNumber: number;
291
+ };
292
+
293
+ /**
294
+ * Live in-place repair for already-migrated trees (#863 stock): plan the full
295
+ * board-bound unbound→ticket batch first, refuse any overwrite before mutation,
296
+ * rewrite durable pages (including batch `crossRunRewrites`) while sources still
297
+ * sit at unbound, then rename. Parse/write failures therefore leave sources in
298
+ * place so a retry can finish the same closure; rename-only retries still find
299
+ * remaining unbound sources. Leaves without a board ticket stay unbound.
300
+ */
301
+ export async function relocateBoardBoundUnboundRunsInBook(
302
+ bookDirectory: string,
303
+ ): Promise<readonly BoardBoundUnboundRelocation[]> {
304
+ const bookKey = basename(bookDirectory);
305
+ const booksDirectory = dirname(bookDirectory);
306
+ const unboundRuns = join(bookDirectory, "unbound", "runs");
307
+ let entries;
308
+ try {
309
+ entries = await readdir(unboundRuns, { withFileTypes: true });
310
+ } catch (error) {
311
+ if (isMigrationEnoent(error)) return [];
312
+ throw error;
313
+ }
314
+
315
+ const planned: PlannedBoardBoundMove[] = [];
316
+ for (const entry of entries) {
317
+ if (!entry.isDirectory()) continue;
318
+ const parsed = parseRunLeaf(entry.name);
319
+ if (parsed === undefined) continue;
320
+ const sourcePath = join(unboundRuns, entry.name);
321
+ const boardTicket = await readBoardTicketNumber(sourcePath);
322
+ if (boardTicket === undefined) continue;
323
+ const targetPath = destinationRunDirectory(
324
+ booksDirectory,
325
+ bookKey,
326
+ boardTicket,
327
+ parsed.runId,
328
+ parsed.role,
329
+ );
330
+ planned.push({
331
+ sourcePath,
332
+ targetPath,
333
+ ticketNumber: boardTicket,
334
+ });
335
+ }
336
+
337
+ if (planned.length === 0) return [];
338
+
339
+ for (const move of planned) {
340
+ if (await pathExists(move.targetPath)) {
341
+ throw new Error(
342
+ `board-bound unbound relocate refuses to overwrite ${move.targetPath} with ${move.sourcePath}`,
343
+ );
344
+ }
345
+ }
346
+
347
+ const crossRunRewrites: RunDirectoryPathRewrite[] = planned.map((move) => ({
348
+ oldRunDirectory: move.sourcePath,
349
+ newRunDirectory: move.targetPath,
350
+ }));
351
+ const relocatedBySource = new Map(
352
+ planned.map((move) => [move.sourcePath, move] as const),
353
+ );
354
+
355
+ // Rewrite before any rename so durable-page failures remain retryable from
356
+ // the same unbound sources (failure-honesty: no relocatedCount=0 whitewash).
357
+ for (const runDir of await listBookRunDirectories(bookDirectory)) {
358
+ const move = relocatedBySource.get(runDir);
359
+ await rewriteRoleRunDurablePages({
360
+ pagesDirectory: runDir,
361
+ oldRunDirectory: move?.sourcePath ?? runDir,
362
+ newRunDirectory: move?.targetPath ?? runDir,
363
+ crossRunRewrites,
364
+ });
365
+ }
366
+
367
+ for (const move of planned) {
368
+ await mkdir(dirname(move.targetPath), { recursive: true });
369
+ await rename(move.sourcePath, move.targetPath);
370
+ }
371
+
372
+ return planned.map((move) => ({
373
+ from: move.sourcePath,
374
+ to: move.targetPath,
375
+ ticketNumber: move.ticketNumber,
376
+ }));
377
+ }
378
+
379
+ /**
380
+ * Walk every book under `booksDirectory` and relocate board-bound unbound
381
+ * runs in place. Requires zero live writer locks (same gate as topology migrate).
382
+ */
383
+ export async function relocateBoardBoundUnboundRunsInBooks(
384
+ booksDirectory: string,
385
+ env: NodeJS.ProcessEnv = process.env,
386
+ ): Promise<readonly BoardBoundUnboundRelocation[]> {
387
+ await assertBookTopologyMigrationPrerequisites(booksDirectory, env);
388
+ const relocated: BoardBoundUnboundRelocation[] = [];
389
+ for (const bookKey of await listMigrationBookKeys(booksDirectory)) {
390
+ const batch = await relocateBoardBoundUnboundRunsInBook(
391
+ join(booksDirectory, bookKey),
392
+ );
393
+ relocated.push(...batch);
394
+ }
395
+ return relocated;
396
+ }
@@ -87,7 +87,8 @@ export async function resolveNotarySourceRunLocator(options: {
87
87
 
88
88
  const ledgerHome = resolveActivationLedgerHome(options.home);
89
89
  const bookKey = resolveBookKeyFromGit(options.projectRoot);
90
- const bookRunsRoot = join(activationBookDirectory(ledgerHome, bookKey), "runs");
90
+ const bookDirectory = activationBookDirectory(ledgerHome, bookKey);
91
+ const bookRunsRoot = join(bookDirectory, "runs");
91
92
 
92
93
  let candidate: string;
93
94
  const bare = parseRunDirectoryName(raw);
@@ -96,12 +97,27 @@ export async function resolveNotarySourceRunLocator(options: {
96
97
  ?? join(bookRunsRoot, `${bare.runId}@${bare.role}`);
97
98
  } else {
98
99
  candidate = isAbsolute(raw) ? raw : resolve(options.projectRoot, raw);
100
+ const identity = parseRunDirectoryName(basename(candidate));
101
+ const subjectDirectory = dirname(dirname(candidate));
102
+ const isLegacyUnboundLocator =
103
+ identity !== undefined &&
104
+ basename(dirname(candidate)) === "runs" &&
105
+ basename(subjectDirectory) === "unbound" &&
106
+ resolve(dirname(subjectDirectory)) === resolve(bookDirectory);
107
+ if (isLegacyUnboundLocator) {
108
+ candidate = (await findRunDirectoryById(
109
+ options.home,
110
+ identity.runId,
111
+ bookKey,
112
+ identity.role,
113
+ )) ?? candidate;
114
+ }
99
115
  }
100
116
 
101
117
  const real = await requireRunDirectory(candidate, raw);
102
118
  const identity = parseRunDirectoryName(basename(real))!;
103
119
 
104
- const bookIdentity = physicalPathIdentity(activationBookDirectory(ledgerHome, bookKey));
120
+ const bookIdentity = physicalPathIdentity(bookDirectory);
105
121
  const parentIdentity = physicalPathIdentity(dirname(real));
106
122
  const subjectBookIdentity = physicalPathIdentity(dirname(dirname(dirname(real))));
107
123
  if (
@@ -124,7 +124,6 @@ const RESUME_SEAT_DISPATCH: Record<
124
124
  ) => Promise<{
125
125
  exitCode: number;
126
126
  terminal?: TerminalResult;
127
- staleWriterLeaseReclaimed?: true;
128
127
  }>;
129
128
  }
130
129
  > = {
@@ -473,19 +472,15 @@ export type CliResult = {
473
472
  /** Settled Terminal when an admitted Role run produced one (programmatic/tests). */
474
473
  terminal?: TerminalResult;
475
474
  hostFailure?: HostSelectionFailure;
476
- /** Typed #556 fact from public resume/dispatch when a dead holder lock was unlinked. */
477
- staleWriterLeaseReclaimed?: true;
478
475
  };
479
476
 
480
477
  function cliResultFromRoleRun(result: {
481
478
  exitCode: number;
482
479
  terminal?: TerminalResult;
483
- staleWriterLeaseReclaimed?: true;
484
480
  }): CliResult {
485
481
  return {
486
482
  exitCode: result.exitCode,
487
483
  ...(result.terminal === undefined ? {} : { terminal: result.terminal }),
488
- ...(result.staleWriterLeaseReclaimed === true ? { staleWriterLeaseReclaimed: true as const } : {}),
489
484
  };
490
485
  }
491
486
 
@@ -813,7 +813,6 @@ export function listRolesForDisplay(
813
813
 
814
814
  /**
815
815
  * Read configured credential presence from a Pi auth.json document.
816
- * Presence drives the selected-provider fail-closed seam (public-run-credentials).
817
816
  */
818
817
  export function credentialProvidersFromAuthData(
819
818
  data: unknown,
@@ -785,7 +785,7 @@ export async function runPublicCountersign(
785
785
  function countersignAdapters(options?: {
786
786
  beforeDispatch?: (
787
787
  admitted: AdmittedCountersignInvocation,
788
- lease: RunWriterLease,
788
+ lease?: RunWriterLease,
789
789
  ) => void | Promise<void>;
790
790
  }) {
791
791
  return {
@@ -76,7 +76,7 @@ export function buildDiaristTurnRequest(
76
76
  async function bindAndRelocateDiaristIfBoardBound(
77
77
  admitted: AdmittedDiaristInvocation,
78
78
  authority: DiaristRunEnv["principalAuthority"],
79
- lease: RunWriterLease,
79
+ lease?: RunWriterLease,
80
80
  ): Promise<void> {
81
81
  const boardTicket = await readBoardTicketNumber(admitted.runDirectory);
82
82
  if (boardTicket === undefined) return;
@@ -41,7 +41,6 @@ import {
41
41
  requireSafePositiveTicketNumber,
42
42
  } from "../run-ticket-number.ts";
43
43
  import {
44
- rewriteRoleRunDurablePages,
45
44
  rewriteRunDirectoryPathFields,
46
45
  rewriteRunDirectoryPathValue,
47
46
  } from "../role-run-relocation.ts";
@@ -628,8 +627,8 @@ export async function relocateAdmittedRunToTicket(
628
627
  admitted: AdmittedRoleInvocation,
629
628
  authority: DurablePrincipalAuthority,
630
629
  heldLease?: { relocate(runDirectory: string): void },
631
- ): Promise<void> {
632
- if (admitted.ticketNumber === undefined || !admitted.runDirectory.includes(`${sep}unbound${sep}runs${sep}`)) return;
630
+ ): Promise<{ oldRunDirectory: string; newRunDirectory: string } | undefined> {
631
+ if (admitted.ticketNumber === undefined || !admitted.runDirectory.includes(`${sep}unbound${sep}runs${sep}`)) return undefined;
633
632
  const oldRunDirectory = admitted.runDirectory;
634
633
  const ledgerHome = resolveActivationLedgerHome(homeFromRunDirectory(oldRunDirectory));
635
634
  const target = roleRunPlacement(ledgerHome, {
@@ -639,7 +638,17 @@ export async function relocateAdmittedRunToTicket(
639
638
  role: admitted.role,
640
639
  });
641
640
  ensureRoleRunDirectory(ledgerHome, dirname(target.runDirectory));
641
+ // Host sealing is pure identity projection, but may reject the coordinates.
642
+ // Keep that failure before the filesystem commit point.
643
+ const principal = authority.seal(target);
644
+
645
+ // Rename is the only durable commit. Persisted paths are resolved from typed
646
+ // run identity on read, so online relocation never writes unleased peers or
647
+ // pretends a directory rename plus page rewrites form one transaction.
642
648
  await rename(oldRunDirectory, target.runDirectory);
649
+
650
+ // rename moved the open lock inode with the directory. Transfer cleanup
651
+ // ownership immediately after the commit.
643
652
  heldLease?.relocate(target.runDirectory);
644
653
 
645
654
  const admittedRecord = admitted as unknown as Record<string, unknown>;
@@ -664,14 +673,9 @@ export async function relocateAdmittedRunToTicket(
664
673
  target.runDirectory,
665
674
  ) as string;
666
675
  }
667
- const principal = authority.seal(target);
668
676
  (admitted as { principal: DurablePrincipal }).principal = principal;
669
677
 
670
- await rewriteRoleRunDurablePages({
671
- pagesDirectory: target.runDirectory,
672
- oldRunDirectory,
673
- newRunDirectory: target.runDirectory,
674
- });
678
+ return { oldRunDirectory, newRunDirectory: target.runDirectory };
675
679
  }
676
680
 
677
681
  /**