@akagilnc/pi-workflow-roles 0.1.4489 → 0.1.4503

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.
@@ -13,6 +13,7 @@ import {
13
13
  bindAdmittedTicketNumber,
14
14
  buildInstructionTransportPrompt,
15
15
  relocateAdmittedRunToTicket,
16
+ recordAdmittedCorrelation,
16
17
  type AdmittedDiaristInvocation,
17
18
  type ParseDiaristArgvResult,
18
19
  } from "./invocation.ts";
@@ -94,6 +95,9 @@ function diaristAdapters(
94
95
  shouldPresentSettled: () => true,
95
96
  // Resume interrupted state: board ticket already on pages, run still under unbound/.
96
97
  beforeDispatch: async (admitted, lease) => {
98
+ if (env.correlationId !== undefined && env.correlationId.trim() !== "") {
99
+ await recordAdmittedCorrelation(admitted, env.correlationId);
100
+ }
97
101
  await bindAndRelocateDiaristIfBoardBound(admitted, env.principalAuthority, lease);
98
102
  },
99
103
  // First post-turn assert: accept hook wrote board pages; relocate before lease release.
@@ -6,11 +6,14 @@ import { execFileSync } from "node:child_process";
6
6
  import { existsSync } from "node:fs";
7
7
  import {
8
8
  lstat,
9
+ mkdtemp,
9
10
  readFile,
10
11
  realpath,
11
12
  rename,
13
+ rm,
12
14
  writeFile,
13
15
  } from "node:fs/promises";
16
+ import { tmpdir } from "node:os";
14
17
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
15
18
 
16
19
  import {
@@ -123,6 +126,8 @@ export type AdmittedRoleInvocationBase = {
123
126
  * (ADR 0049 host channel). Admission does not mint ticket-binding ids.
124
127
  */
125
128
  readonly correlationId?: string;
129
+ /** Direct callers retained across same-run resumes, in first-observed order. */
130
+ readonly correlationIds?: readonly string[];
126
131
  /**
127
132
  * Typed ticketNumber after known-identity reuse or notary source-run inheritance
128
133
  * (#635 / #709). Admission does not bind from CLI flag or attachment frontmatter.
@@ -315,6 +320,8 @@ export function issueAdmissionPlacement(
315
320
  /** Identity already asserted by 起居郎; never derive this from CLI parameters. */
316
321
  readonly subject: RoleRunSubject;
317
322
  readonly home?: string;
323
+ /** Placement may be computed before a same-ticket lookup without touching disk. */
324
+ readonly materialize?: boolean;
318
325
  },
319
326
  ): AdmissionPlacement {
320
327
  const ledgerHome = resolveActivationLedgerHome(request.home);
@@ -325,7 +332,7 @@ export function issueAdmissionPlacement(
325
332
  runId: request.runId,
326
333
  role: request.role,
327
334
  });
328
- ensureRoleRunPlacement(ledgerHome, placement);
335
+ if (request.materialize !== false) ensureRoleRunPlacement(ledgerHome, placement);
329
336
  return {
330
337
  principal: authority.seal(placement),
331
338
  ...placement,
@@ -530,6 +537,42 @@ export async function bindAdmittedTicketNumber(
530
537
  (admitted as { ticketNumber?: number }).ticketNumber = ticketNumber;
531
538
  }
532
539
 
540
+ /** Persist each direct caller observed while a retained run is resumed. */
541
+ export async function recordAdmittedCorrelation(
542
+ admitted: AdmittedRoleInvocation,
543
+ correlationId: string,
544
+ ): Promise<void> {
545
+ const current = JSON.parse(
546
+ await readFile(admitted.admittedRequestPath, "utf8"),
547
+ ) as Record<string, unknown>;
548
+ const prior = [
549
+ ...(Array.isArray(current.correlationIds)
550
+ ? current.correlationIds.filter((value): value is string =>
551
+ typeof value === "string" && value.trim() !== ""
552
+ )
553
+ : []),
554
+ ...(typeof current.correlationId === "string" && current.correlationId.trim() !== ""
555
+ ? [current.correlationId]
556
+ : []),
557
+ ];
558
+ const correlationIds = [...new Set([...prior, correlationId])];
559
+ await writeFile(
560
+ admitted.admittedRequestPath,
561
+ `${JSON.stringify({ ...current, correlationId, correlationIds }, null, 2)}\n`,
562
+ "utf8",
563
+ );
564
+ await mergeInvocationIdentityPage(admitted.runDirectory, {
565
+ correlationId,
566
+ correlationIds,
567
+ });
568
+ const mutable = admitted as {
569
+ correlationId?: string;
570
+ correlationIds?: readonly string[];
571
+ };
572
+ mutable.correlationId = correlationId;
573
+ mutable.correlationIds = correlationIds;
574
+ }
575
+
533
576
  /**
534
577
  * #871: persist the typed co-review set as a countersign run fact (admitted-request +
535
578
  * invocation.json). Whole-set replace — never union with a prior set. Main ticket
@@ -1200,11 +1243,9 @@ export function parseFixerArgv(args: readonly string[]): ParseFixerArgvResult {
1200
1243
  };
1201
1244
  }
1202
1245
 
1203
- async function freezeRegularFileAttachment(
1246
+ async function readRegularFileAttachment(
1204
1247
  sourcePath: string,
1205
- destinationDir: string,
1206
- index: number,
1207
- ): Promise<{ attachment: FrozenAttachment; body: Buffer }> {
1248
+ ): Promise<{ absolute: string; bytes: Buffer }> {
1208
1249
  const absolute = isAbsolute(sourcePath) ? sourcePath : resolve(sourcePath);
1209
1250
  let st;
1210
1251
  try {
@@ -1220,18 +1261,103 @@ async function freezeRegularFileAttachment(
1220
1261
  `attachment must be a regular file (not a directory or symlink): ${sourcePath}`,
1221
1262
  );
1222
1263
  }
1223
- const bytes = await readFile(absolute);
1224
- const name = `${String(index).padStart(2, "0")}-${basename(absolute)}`;
1225
- const frozenPath = join(destinationDir, name);
1264
+ try {
1265
+ return { absolute, bytes: await readFile(absolute) };
1266
+ } catch (error) {
1267
+ throw new CliUsageError(
1268
+ `attachment is not a readable regular file: ${sourcePath}`,
1269
+ { cause: error },
1270
+ );
1271
+ }
1272
+ }
1273
+
1274
+ export type PreparedAttachment = {
1275
+ readonly absolute: string;
1276
+ readonly snapshotPath: string;
1277
+ };
1278
+
1279
+ /**
1280
+ * Snapshot deferred inputs sequentially before identity side effects. The staging
1281
+ * files keep aggregate attachment bytes off heap until their final run is known.
1282
+ */
1283
+ async function removePreparedAttachmentDirectory(stagingDirectory: string): Promise<void> {
1284
+ await rm(stagingDirectory, { recursive: true, force: true });
1285
+ }
1286
+
1287
+ /** Own the complete deferred-snapshot lifetime without masking either failure. */
1288
+ export async function withPreparedAttachments<T>(
1289
+ attachmentPaths: readonly string[],
1290
+ use: (prepared: readonly PreparedAttachment[]) => Promise<T>,
1291
+ ): Promise<T> {
1292
+ if (attachmentPaths.length === 0) return await use([]);
1293
+ const stagingDirectory = await mkdtemp(join(tmpdir(), "ak-role-attachments-"));
1294
+ const prepared: PreparedAttachment[] = [];
1295
+ let result: T;
1296
+ try {
1297
+ for (let index = 0; index < attachmentPaths.length; index += 1) {
1298
+ const { absolute, bytes } = await readRegularFileAttachment(attachmentPaths[index]!);
1299
+ const snapshotPath = join(stagingDirectory, String(index).padStart(6, "0"));
1300
+ await writeFile(snapshotPath, bytes);
1301
+ prepared.push({ absolute, snapshotPath });
1302
+ }
1303
+ result = await use(prepared);
1304
+ } catch (primary) {
1305
+ try {
1306
+ await removePreparedAttachmentDirectory(stagingDirectory);
1307
+ } catch (cleanup) {
1308
+ throw new AggregateError(
1309
+ [primary, cleanup],
1310
+ "attachment snapshot operation failed and cleanup also failed",
1311
+ { cause: primary },
1312
+ );
1313
+ }
1314
+ throw primary;
1315
+ }
1316
+ await removePreparedAttachmentDirectory(stagingDirectory);
1317
+ return result;
1318
+ }
1319
+
1320
+ async function freezeAttachmentBytes(
1321
+ provenancePath: string,
1322
+ bytes: Buffer,
1323
+ destinationDir: string,
1324
+ index: number,
1325
+ ): Promise<FrozenAttachment> {
1326
+ const frozenPath = join(
1327
+ destinationDir,
1328
+ `${String(index).padStart(2, "0")}-${basename(provenancePath)}`,
1329
+ );
1226
1330
  await writeFile(frozenPath, bytes);
1227
1331
  return {
1228
- attachment: {
1229
- provenancePath: absolute,
1230
- frozenPath,
1231
- byteLength: bytes.byteLength,
1232
- sha256: sha256Hex(bytes),
1233
- mediaKind: "regular-file",
1234
- },
1332
+ provenancePath,
1333
+ frozenPath,
1334
+ byteLength: bytes.byteLength,
1335
+ sha256: sha256Hex(bytes),
1336
+ mediaKind: "regular-file",
1337
+ };
1338
+ }
1339
+
1340
+ async function freezePreparedAttachment(
1341
+ prepared: PreparedAttachment,
1342
+ destinationDir: string,
1343
+ index: number,
1344
+ ): Promise<FrozenAttachment> {
1345
+ return freezeAttachmentBytes(
1346
+ prepared.absolute,
1347
+ await readFile(prepared.snapshotPath),
1348
+ destinationDir,
1349
+ index,
1350
+ );
1351
+ }
1352
+
1353
+ async function freezeRegularFileAttachment(
1354
+ sourcePath: string,
1355
+ destinationDir: string,
1356
+ index: number,
1357
+ ): Promise<{ attachment: FrozenAttachment; body: Buffer }> {
1358
+ const { absolute, bytes } = await readRegularFileAttachment(sourcePath);
1359
+ return {
1360
+ attachment: await freezeAttachmentBytes(absolute, bytes, destinationDir, index),
1235
1361
  body: bytes,
1236
1362
  };
1237
1363
  }
@@ -1270,6 +1396,25 @@ export async function freezeAttachmentsIntoRun(
1270
1396
  return freezeAttachments(attachmentPaths, attachmentsDirectory);
1271
1397
  }
1272
1398
 
1399
+ /** Freeze prepared bytes and metadata through one path for birth or retained runs. */
1400
+ export async function freezePreparedAttachmentsIntoRun(
1401
+ prepared: readonly PreparedAttachment[],
1402
+ runDirectory: string,
1403
+ summonsKey?: string,
1404
+ ): Promise<readonly FrozenAttachment[]> {
1405
+ if (prepared.length === 0) return [];
1406
+ const ledgerHome = resolveActivationLedgerHome(homeFromRunDirectory(runDirectory));
1407
+ const attachmentsDirectory = summonsKey === undefined
1408
+ ? join(runDirectory, "attachments")
1409
+ : join(runDirectory, "attachments", summonsKey);
1410
+ ensureRealDirectoryTree(ledgerHome, attachmentsDirectory);
1411
+ const attachments: FrozenAttachment[] = [];
1412
+ for (let index = 0; index < prepared.length; index += 1) {
1413
+ attachments.push(await freezePreparedAttachment(prepared[index]!, attachmentsDirectory, index));
1414
+ }
1415
+ return attachments;
1416
+ }
1417
+
1273
1418
  function ticketAdmissionFields(
1274
1419
  ticketNumber: number | undefined,
1275
1420
  ): { ticketNumber?: number } {
@@ -1515,11 +1660,13 @@ export type AdmitCountersignInvocationOptions = {
1515
1660
  /** Effective model for this invocation — written onto invocation.json. */
1516
1661
  model?: InvocationEffectiveModel;
1517
1662
  correlationId?: string;
1663
+ /** Same-ticket lookup may select an existing run before a new run is persisted. */
1664
+ deferPersistence?: boolean;
1518
1665
  };
1519
1666
 
1520
1667
  /**
1521
- * Atomically admit a Countersign Role run: freeze Attachments, persist the
1522
- * request, write the invocation ledger (#572 / ADR 0074).
1668
+ * Admit a Countersign run immediately, or reserve its coordinates for deferred
1669
+ * materialization after identity lookup (#572 / ADR 0074 / #863).
1523
1670
  */
1524
1671
  export async function admitCountersignInvocation(
1525
1672
  options: AdmitCountersignInvocationOptions,
@@ -1545,9 +1692,12 @@ export async function admitCountersignInvocation(
1545
1692
  role: "countersign",
1546
1693
  subject: { unbound: true },
1547
1694
  home: options.home,
1695
+ materialize: options.deferPersistence !== true,
1548
1696
  });
1549
1697
 
1550
- const attachments = await freezeAttachments(options.attachmentPaths, attachmentsDirectory);
1698
+ const attachments = options.deferPersistence === true
1699
+ ? []
1700
+ : await freezeAttachments(options.attachmentPaths, attachmentsDirectory);
1551
1701
  // Ticket binding is LLM-only post-admission (#635); admission stays unbound.
1552
1702
 
1553
1703
  const instruction = options.instruction;
@@ -1571,11 +1721,13 @@ export async function admitCountersignInvocation(
1571
1721
  })),
1572
1722
  };
1573
1723
  const admittedRequestPath = join(runDirectory, "admitted-request.json");
1574
- await writeAdmittedRequestPersistence(admittedRequestPath, admitted, {
1575
- sessionDirectory,
1576
- sessionFile,
1577
- });
1578
- await writeRoleInvocationLedger({ ...admitted, sessionDirectory, sessionFile }, admitted.role, options.model);
1724
+ if (options.deferPersistence !== true) {
1725
+ await writeAdmittedRequestPersistence(admittedRequestPath, admitted, {
1726
+ sessionDirectory,
1727
+ sessionFile,
1728
+ });
1729
+ await writeRoleInvocationLedger({ ...admitted, sessionDirectory, sessionFile }, admitted.role, options.model);
1730
+ }
1579
1731
 
1580
1732
  return {
1581
1733
  role: "countersign",
@@ -1592,6 +1744,36 @@ export async function admitCountersignInvocation(
1592
1744
  };
1593
1745
  }
1594
1746
 
1747
+ /** Persist a deferred Countersign admission after same-ticket lookup found no retained run. */
1748
+ export async function materializeCountersignInvocation(
1749
+ admitted: AdmittedCountersignInvocation,
1750
+ options: Pick<AdmitCountersignInvocationOptions, "home" | "principalAuthority" | "model"> & {
1751
+ preparedAttachments: readonly PreparedAttachment[];
1752
+ },
1753
+ ): Promise<void> {
1754
+ const placement = issueAdmissionPlacement(options.principalAuthority, {
1755
+ cwd: admitted.projectRoot,
1756
+ runId: admitted.runId,
1757
+ role: "countersign",
1758
+ subject: { unbound: true },
1759
+ home: options.home,
1760
+ });
1761
+ const attachments = await freezePreparedAttachmentsIntoRun(
1762
+ options.preparedAttachments,
1763
+ admitted.runDirectory,
1764
+ );
1765
+ (admitted as { attachments: readonly FrozenAttachment[] }).attachments = attachments;
1766
+ await writeAdmittedRequestPersistence(admitted.admittedRequestPath, admitted, {
1767
+ sessionDirectory: placement.sessionDirectory,
1768
+ sessionFile: placement.sessionFile,
1769
+ });
1770
+ await writeRoleInvocationLedger(
1771
+ { ...admitted, sessionDirectory: placement.sessionDirectory, sessionFile: placement.sessionFile },
1772
+ admitted.role,
1773
+ options.model,
1774
+ );
1775
+ }
1776
+
1595
1777
  /** Build the Pi prompt transport for an admitted Countersign request. */
1596
1778
  export function buildCountersignTransportPrompt(
1597
1779
  admitted: AdmittedCountersignInvocation,