@akagilnc/pi-workflow-roles 0.1.5504 → 0.1.5575

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 (89) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/acp-host/production-host.js +5965 -5994
  4. package/dist/analyst-gate-cycles-read.js +23 -55
  5. package/dist/analyst-ledger.js +21 -35
  6. package/dist/analyst-metric-families/acceptance-success-rework.js +25 -1
  7. package/dist/analyst-metric-families/round-timeline.js +2 -5
  8. package/dist/archivist-record-pointer.js +38 -30
  9. package/dist/auditor-dossier-tool.js +4 -3
  10. package/dist/auditor-soul.js +3 -11
  11. package/dist/doctor-evidence.js +3 -3
  12. package/dist/engine-detour-usage.js +6 -14
  13. package/dist/headless-host/production-host.js +5316 -5324
  14. package/dist/host-descriptions.js +0 -4
  15. package/dist/host-session-record.js +266 -267
  16. package/dist/migrate-book-topology.js +1314 -696
  17. package/dist/notary-source-run.js +1 -1
  18. package/dist/packaged-role-registry.js +5 -44
  19. package/dist/pi/role-turn-host.js +53 -3
  20. package/dist/public-cli/auto-resume.js +8 -47
  21. package/dist/public-cli/instruction-seat-run.js +5 -3
  22. package/dist/public-cli/invocation.js +51 -56
  23. package/dist/public-cli/main.js +1916 -1748
  24. package/dist/public-cli/post-admission.js +96 -55
  25. package/dist/public-cli/run-lifecycle.js +27 -53
  26. package/dist/public-cli/settlement.js +157 -302
  27. package/dist/role-run-placement.js +1 -11
  28. package/dist/role-run-relocation.js +72 -50
  29. package/dist/run-dossier-files.js +30 -0
  30. package/dist/run-dossier.js +322 -0
  31. package/dist/run-terminal-artifacts.js +68 -197
  32. package/dist/run-ticket-number.js +16 -3
  33. package/dist/session-identity.js +49 -18
  34. package/dist/sitian-appender.js +13 -16
  35. package/dist/sitian-facade.js +11 -0
  36. package/dist/sitian-reader.js +44 -29
  37. package/dist/submission-gate.js +1 -1
  38. package/dist/ticket-provenance.js +22 -7
  39. package/package.json +1 -1
  40. package/scripts/build-package.mjs +2 -0
  41. package/src/acp-host/production-host.ts +1 -1
  42. package/src/acp-host/role-turn-host.ts +9 -2
  43. package/src/analyst-gate-cycles-read.ts +26 -60
  44. package/src/analyst-ledger.ts +27 -42
  45. package/src/analyst-metric-families/acceptance-success-rework.ts +24 -1
  46. package/src/analyst-metric-families/gate-cycles.ts +1 -1
  47. package/src/analyst-metric-families/round-timeline.ts +2 -5
  48. package/src/analyst-page.ts +3 -3
  49. package/src/archivist-record-entry.ts +2 -2
  50. package/src/archivist-record-pointer.ts +50 -36
  51. package/src/auditor-dossier-tool.ts +7 -5
  52. package/src/auditor-soul.ts +3 -9
  53. package/src/book-topology-migration-placement.ts +16 -3
  54. package/src/book-topology-record-class-migrators.ts +4 -2
  55. package/src/book-topology-runs-migrator.ts +6 -3
  56. package/src/doctor-contracts.ts +1 -1
  57. package/src/doctor-evidence.ts +2 -2
  58. package/src/engine-detour-tool.ts +1 -1
  59. package/src/engine-detour-usage.ts +6 -16
  60. package/src/external-host-turn-loop.ts +25 -0
  61. package/src/headless-host/production-host.ts +1 -4
  62. package/src/headless-host/role-turn-host.ts +54 -20
  63. package/src/host-descriptions.ts +0 -6
  64. package/src/host-session-record.ts +224 -140
  65. package/src/notary-source-run.ts +1 -1
  66. package/src/packaged-role-registry.ts +1 -50
  67. package/src/pi/adapter.ts +50 -7
  68. package/src/pi/role-turn-host.ts +51 -3
  69. package/src/public-cli/auto-resume.ts +7 -43
  70. package/src/public-cli/instruction-seat-run.ts +9 -2
  71. package/src/public-cli/invocation.ts +61 -87
  72. package/src/public-cli/post-admission.ts +111 -72
  73. package/src/public-cli/run-lifecycle.ts +27 -72
  74. package/src/public-cli/settlement.ts +193 -418
  75. package/src/public-cli/terminal.ts +1 -1
  76. package/src/role-run-placement.ts +2 -13
  77. package/src/role-run-relocation.ts +71 -68
  78. package/src/role-runtime.ts +6 -10
  79. package/src/run-dossier-files.ts +33 -0
  80. package/src/run-dossier.ts +357 -0
  81. package/src/run-terminal-artifacts.ts +83 -237
  82. package/src/run-ticket-number.ts +41 -11
  83. package/src/session-identity.ts +52 -20
  84. package/src/sitian-appender.ts +17 -18
  85. package/src/sitian-facade.ts +22 -0
  86. package/src/sitian-reader.ts +62 -32
  87. package/src/submission-gate.ts +1 -1
  88. package/src/ticket-provenance.ts +20 -7
  89. package/src/ticket-trajectory.ts +10 -10
@@ -6,17 +6,17 @@
6
6
  * frozen attachment/artifact bytes. Nested walk is confined to package-owned
7
7
  * `session/` seams.
8
8
  */
9
- import { existsSync } from "node:fs";
10
9
  import { readdir, readFile, writeFile } from "node:fs/promises";
11
10
  import { dirname, join, sep } from "node:path";
12
11
  import { activationBookDirectory, physicalPathIdentity } from "./activation-ledger-topology.js";
13
12
  import { readRoleRunIdentity } from "./public-cli/run-lifecycle.js";
14
13
  import { findRoleRunDirectory, roleRunPlacement } from "./role-run-placement.js";
15
14
  import { AUDITOR_PARENT_ATTEMPT_BINDING_ENTRY_TYPE } from "./compliance-transport.js";
15
+ import { readPageSync, updateSectionSync } from "./run-dossier.js";
16
16
  import { isRecord, isEnoent } from "./unknown-value.js";
17
17
  const ADMITTED_PAGE_FIELDS = [
18
18
  "runDirectory",
19
- "admittedRequestPath",
19
+ "admittedRequestPath", // pre-#1161 pages only
20
20
  "sessionDirectory",
21
21
  "sessionFile",
22
22
  "taskPath",
@@ -33,7 +33,7 @@ const INVOCATION_PAGE_FIELDS = [
33
33
  ];
34
34
  const RUN_STATE_PAGE_FIELDS = [
35
35
  "runDirectory",
36
- "admittedRequestPath",
36
+ "admittedRequestPath", // pre-#1161 pages only
37
37
  "sessionDirectory",
38
38
  "sessionFile",
39
39
  ];
@@ -176,25 +176,26 @@ export function rewriteAdmittedRoleRunPage(page, rewrites) {
176
176
  rewriteRunDirectoryPathFieldsAgainstRewrites(page.principal, ["sessionDirectory", "sessionFile"], rewrites);
177
177
  }
178
178
  }
179
- async function rewriteOfficerPointerFile(path, rewrites) {
180
- if (!existsSync(path))
181
- return;
179
+ /** Pre-#1161 `session/auditor-roles/<officer>.pointer.json` (migrator input only). */
180
+ async function rewriteLegacyOfficerPointerFile(path, rewrites) {
182
181
  const page = JSON.parse(await readFile(path, "utf8"));
183
- if (!isRecord(page))
184
- return;
185
- // Only the typed direct-officer pointer shape — never arbitrary .pointer.json.
186
- if (page.kind !== "direct-officer-run-pointer")
182
+ if (!isRecord(page) || page.kind !== "direct-officer-run-pointer")
187
183
  return;
188
184
  const before = JSON.stringify(page);
189
185
  rewriteRunDirectoryPathFieldsAgainstRewrites(page, OFFICER_POINTER_FIELDS, rewrites);
190
- if (JSON.stringify(page) !== before) {
186
+ if (JSON.stringify(page) !== before)
191
187
  await writeFile(path, `${JSON.stringify(page)}\n`, "utf8");
192
- }
193
188
  }
194
189
  async function rewriteSitianRecordsJsonl(path, rewrites) {
195
- if (!existsSync(path))
196
- return;
197
- const raw = await readFile(path, "utf8");
190
+ let raw;
191
+ try {
192
+ raw = await readFile(path, "utf8");
193
+ }
194
+ catch (error) {
195
+ if (isEnoent(error))
196
+ return;
197
+ throw error;
198
+ }
198
199
  if (raw.length === 0)
199
200
  return;
200
201
  const endsWithNewline = raw.endsWith("\n");
@@ -244,9 +245,15 @@ async function rewriteSitianRecordsJsonl(path, rewrites) {
244
245
  * ak_auditor_parent_attempt_binding data.parent.sessionFile. Never message text.
245
246
  */
246
247
  async function rewriteSessionTranscriptBindings(path, rewrites) {
247
- if (!existsSync(path))
248
- return;
249
- const raw = await readFile(path, "utf8");
248
+ let raw;
249
+ try {
250
+ raw = await readFile(path, "utf8");
251
+ }
252
+ catch (error) {
253
+ if (isEnoent(error))
254
+ return;
255
+ throw error;
256
+ }
250
257
  if (raw.length === 0)
251
258
  return;
252
259
  const endsWithNewline = raw.endsWith("\n");
@@ -303,8 +310,8 @@ async function rewriteSessionTranscriptBindings(path, rewrites) {
303
310
  }
304
311
  /**
305
312
  * Package-owned nested seams under session/ only:
306
- * direct-officer *.pointer.json, sitian records.jsonl,
307
- * session transcript typed parent bindings. Never walks attachments/ or artifacts/.
313
+ * sitian records.jsonl,
314
+ * session transcript typed parent bindings. Never walks attachments/.
308
315
  */
309
316
  async function rewriteNestedMachinePathPages(pagesDirectory, rewrites) {
310
317
  const sessionRoot = join(pagesDirectory, "session");
@@ -327,7 +334,7 @@ async function rewriteNestedMachinePathPages(pagesDirectory, rewrites) {
327
334
  if (!entry.isFile())
328
335
  continue;
329
336
  if (entry.name.endsWith(".pointer.json")) {
330
- await rewriteOfficerPointerFile(path, rewrites);
337
+ await rewriteLegacyOfficerPointerFile(path, rewrites);
331
338
  }
332
339
  else if (entry.name === "records.jsonl") {
333
340
  await rewriteSitianRecordsJsonl(path, rewrites);
@@ -340,7 +347,7 @@ async function rewriteNestedMachinePathPages(pagesDirectory, rewrites) {
340
347
  await walk(sessionRoot);
341
348
  }
342
349
  /**
343
- * Rewrite admitted-request / invocation / run-state path fields (attachment
350
+ * Rewrite the admitted / invocation / runState fact rows (path fields: attachment
344
351
  * frozenPath / summons.attachmentPaths pointers only), then nested package-owned
345
352
  * session seams. Callers may rewrite before or after the filesystem move/copy:
346
353
  * `pagesDirectory` is where the pages currently live on disk; path strings that
@@ -350,38 +357,53 @@ async function rewriteNestedMachinePathPages(pagesDirectory, rewrites) {
350
357
  export async function rewriteRoleRunDurablePages(input) {
351
358
  const { pagesDirectory } = input;
352
359
  const rewrites = collectRewrites(input);
353
- const admittedPath = join(pagesDirectory, "admitted-request.json");
354
- if (existsSync(admittedPath)) {
355
- const page = JSON.parse(await readFile(admittedPath, "utf8"));
356
- const before = JSON.stringify(page);
357
- rewriteAdmittedRoleRunPage(page, rewrites);
358
- if (JSON.stringify(page) !== before) {
359
- await writeFile(admittedPath, `${JSON.stringify(page, null, 2)}\n`, "utf8");
360
- }
361
- }
362
- const invocationPath = join(pagesDirectory, "invocation.json");
363
- if (existsSync(invocationPath)) {
364
- const page = JSON.parse(await readFile(invocationPath, "utf8"));
365
- const before = JSON.stringify(page);
366
- rewriteRunDirectoryPathFieldsAgainstRewrites(page, INVOCATION_PAGE_FIELDS, rewrites);
367
- if (JSON.stringify(page) !== before) {
368
- await writeFile(invocationPath, `${JSON.stringify(page, null, 2)}\n`, "utf8");
369
- }
360
+ // One rewriter per page kind; each keeps its own field list.
361
+ const pageRewriters = {
362
+ admitted: (page) => rewriteAdmittedRoleRunPage(page, rewrites),
363
+ invocation: (page) => rewriteRunDirectoryPathFieldsAgainstRewrites(page, INVOCATION_PAGE_FIELDS, rewrites),
364
+ runState: (page) => {
365
+ rewriteRunDirectoryPathFieldsAgainstRewrites(page, RUN_STATE_PAGE_FIELDS, rewrites);
366
+ if (isRecord(page.principal)) {
367
+ rewriteRunDirectoryPathFieldsAgainstRewrites(page.principal, ["sessionDirectory", "sessionFile"], rewrites);
368
+ }
369
+ // Open court summons: source-run locators + frozen attachment path pointers.
370
+ if (isRecord(page.currentCourt)) {
371
+ rewriteSummonsMaterials(page.currentCourt.summons, rewrites);
372
+ }
373
+ },
374
+ };
375
+ // A page is rewritten only when its fact row exists — never gate on the rendering.
376
+ for (const [section, rewrite] of Object.entries(pageRewriters)) {
377
+ if (readPageSync(pagesDirectory, section) === undefined)
378
+ continue;
379
+ updateSectionSync(pagesDirectory, section, (current) => {
380
+ const page = structuredClone(current);
381
+ rewrite(page);
382
+ return JSON.stringify(page) === JSON.stringify(current) ? undefined : page;
383
+ });
370
384
  }
371
- const statePath = join(pagesDirectory, "run-state.json");
372
- if (existsSync(statePath)) {
373
- const page = JSON.parse(await readFile(statePath, "utf8"));
374
- const before = JSON.stringify(page);
375
- rewriteRunDirectoryPathFieldsAgainstRewrites(page, RUN_STATE_PAGE_FIELDS, rewrites);
376
- if (isRecord(page.principal)) {
377
- rewriteRunDirectoryPathFieldsAgainstRewrites(page.principal, ["sessionDirectory", "sessionFile"], rewrites);
385
+ // Runs from before #1161 (the one-shot book-topology migrator's input) keep these
386
+ // as separate page files; the same rewriters apply. Nothing live writes them.
387
+ for (const [file, rewrite] of [
388
+ ["admitted-request.json", pageRewriters.admitted],
389
+ ["invocation.json", pageRewriters.invocation],
390
+ ["run-state.json", pageRewriters.runState],
391
+ ]) {
392
+ const path = join(pagesDirectory, file);
393
+ let raw;
394
+ try {
395
+ raw = await readFile(path, "utf8");
378
396
  }
379
- // Open court summons: source-run locators + frozen attachment path pointers.
380
- if (isRecord(page.currentCourt)) {
381
- rewriteSummonsMaterials(page.currentCourt.summons, rewrites);
397
+ catch (error) {
398
+ if (isEnoent(error))
399
+ continue;
400
+ throw error;
382
401
  }
402
+ const page = JSON.parse(raw);
403
+ const before = JSON.stringify(page);
404
+ rewrite(page);
383
405
  if (JSON.stringify(page) !== before) {
384
- await writeFile(statePath, `${JSON.stringify(page, null, 2)}\n`, "utf8");
406
+ await writeFile(path, `${JSON.stringify(page, null, 2)}\n`, "utf8");
385
407
  }
386
408
  }
387
409
  await rewriteNestedMachinePathPages(pagesDirectory, rewrites);
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Names in a run directory (docs/dossier-topology.md, #1161). Constants only, so
3
+ * the appender and the `current.json` renderer can both import them.
4
+ */
5
+ export const RUN_CURRENT_FILE = "current.json";
6
+ export const RUN_HISTORY_FILE = "history.jsonl";
7
+ export const RUN_LOG_FILE = "log.jsonl";
8
+ export const RUN_STATE_FILE = "state.jsonl";
9
+ /**
10
+ * Record kinds that make a leg's history: they land in `history.jsonl`. The
11
+ * original submission ledger and attempt history, the prompt/schema each turn was
12
+ * started with, and officer pointers.
13
+ */
14
+ export const RUN_HISTORY_KINDS = new Set([
15
+ "candidate", "roundContext", "outcome", "sealed", "post-seal-anomaly",
16
+ "attempt-history", "turn-delivery", "officer-pointer",
17
+ ]);
18
+ /** History record kind of a booked officer pointer (appender routes it to history.jsonl). */
19
+ export const OFFICER_POINTER_RECORD_KIND = "officer-pointer";
20
+ /**
21
+ * The facts a run cannot go on without land in `state.jsonl`: the four whole pages only the
22
+ * public call knows, and the native session id a host binds (a resume needs it). A
23
+ * different volume than the submission ledger and than the auxiliary stream, as the
24
+ * files they replaced were (a page write failure throws; a ledger volume that cannot
25
+ * be written does not stop a resume; a stream line that cannot be written is noted).
26
+ * Every other kind lands in `log.jsonl`.
27
+ */
28
+ export const RUN_STATE_KINDS = new Set([
29
+ "invocation", "admitted-request", "run-state", "terminal", "host-session-id",
30
+ ]);
@@ -0,0 +1,322 @@
1
+ /**
2
+ * The per-leg dossier files (docs/dossier-topology.md, #1161): `current.json`,
3
+ * `history.jsonl` (the submission ledger and attempt history), `state.jsonl` (the four
4
+ * whole pages) and `log.jsonl` (the rest), all append-only and written through the sitian appender, and the one host original.
5
+ *
6
+ * Facts are rows first. The four pages only the public call knows — identity
7
+ * (`invocation`), admission (`admitted-request`), lifecycle (`run-state`) and the
8
+ * terminal — are each rewritten whole by appending one `state.jsonl` row whose
9
+ * payload is that page. `current.json` is a rendering: the last row of each kind
10
+ * in the three row files, read at the moment of writing, never a value
11
+ * carried from an earlier snapshot or from the previous `current.json`.
12
+ *
13
+ * Two public calls may exist on one leg (the live leg and a manual resume) and
14
+ * both write `current.json`; there is no lock. After writing, a writer checks that
15
+ * the row files are still the size it rendered from and, if rows were appended
16
+ * meanwhile, renders again — so whichever writer finishes last has rendered from
17
+ * every row, and the file ends equal to a fresh rendering.
18
+ */
19
+ import { randomUUID } from "node:crypto";
20
+ import { readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
21
+ import { basename, join } from "node:path";
22
+ import { sessionFileOf } from "./role-run-placement.js";
23
+ import { RUN_CURRENT_FILE, RUN_HISTORY_FILE, RUN_LOG_FILE, RUN_STATE_FILE, OFFICER_POINTER_RECORD_KIND } from "./run-dossier-files.js";
24
+ import { appendSitianRecord } from "./sitian-appender.js";
25
+ import { parseSitianRecordText } from "./sitian-reader.js";
26
+ import { isMissingPathError, isRecord } from "./unknown-value.js";
27
+ export { RUN_CURRENT_FILE, RUN_HISTORY_FILE, RUN_LOG_FILE, RUN_STATE_FILE };
28
+ /** The row kind of each whole-page section: the name of the file it replaced. */
29
+ const PAGE_ROW_KIND = {
30
+ invocation: "invocation",
31
+ admitted: "admitted-request",
32
+ runState: "run-state",
33
+ terminal: "terminal",
34
+ };
35
+ export function runCurrentPath(runDirectory) {
36
+ return join(runDirectory, RUN_CURRENT_FILE);
37
+ }
38
+ /** The whole `current.json`, or undefined when absent (ENOENT only). */
39
+ export function readCurrentSync(runDirectory) {
40
+ let raw;
41
+ try {
42
+ raw = readFileSync(runCurrentPath(runDirectory), "utf8");
43
+ }
44
+ catch (error) {
45
+ if (isMissingPathError(error))
46
+ return undefined;
47
+ throw error;
48
+ }
49
+ const parsed = JSON.parse(raw);
50
+ if (!isRecord(parsed))
51
+ throw new TypeError(`${RUN_CURRENT_FILE} is not an object: ${runCurrentPath(runDirectory)}`);
52
+ return parsed;
53
+ }
54
+ /** One section of `current.json` (the rendering); undefined when the file or the section is absent. */
55
+ export function readSectionSync(runDirectory, section) {
56
+ const value = readCurrentSync(runDirectory)?.[section];
57
+ if (value === undefined)
58
+ return undefined;
59
+ if (!isRecord(value)) {
60
+ throw new TypeError(`${RUN_CURRENT_FILE} ${section} section is not an object: ${runCurrentPath(runDirectory)}`);
61
+ }
62
+ return value;
63
+ }
64
+ /**
65
+ * The rows of one run file as of this read, with the physical byte length they came from.
66
+ * Missing file → empty. IO fault → no rows + fault code. Damaged lines keep the sole
67
+ * Sitian decoder's diagnostics and do not invent a second skip parser.
68
+ */
69
+ function readRowFile(path) {
70
+ let buffer;
71
+ try {
72
+ buffer = readFileSync(path);
73
+ }
74
+ catch (error) {
75
+ if (isMissingPathError(error))
76
+ return { rows: [], bytes: 0 };
77
+ return { rows: [], bytes: 0, fault: String(error.code ?? error.name) };
78
+ }
79
+ const { records, diagnostics } = parseSitianRecordText(buffer.toString("utf8"));
80
+ return {
81
+ rows: [...records],
82
+ bytes: buffer.length,
83
+ ...(diagnostics.length > 0
84
+ ? { diagnostics: diagnostics.map((d) => ({ line: d.line, error: d.error })) }
85
+ : {}),
86
+ };
87
+ }
88
+ function fileBytes(path) {
89
+ try {
90
+ return statSync(path).size;
91
+ }
92
+ catch (error) {
93
+ return isMissingPathError(error) ? 0 : undefined;
94
+ }
95
+ }
96
+ /**
97
+ * Payload of the last row of `kind`, or undefined when no such row exists.
98
+ * Non-object payloads are returned as-is: a later illegal whole page must not
99
+ * be washed into an older valid page or into absence.
100
+ */
101
+ function lastPayloadOfKind(rows, kind) {
102
+ for (let index = rows.length - 1; index >= 0; index -= 1) {
103
+ const row = rows[index];
104
+ if (row.kind === kind)
105
+ return row.payload;
106
+ }
107
+ return undefined;
108
+ }
109
+ /** Last whole-page payload as an object, or undefined when absent; illegal values throw. */
110
+ function lastPagePayload(rows, kind, runDirectory) {
111
+ const payload = lastPayloadOfKind(rows, kind);
112
+ if (payload === undefined)
113
+ return undefined;
114
+ if (!isRecord(payload)) {
115
+ throw new TypeError(`${RUN_STATE_FILE} ${kind} payload is not an object: ${runDirectory}`);
116
+ }
117
+ return payload;
118
+ }
119
+ /**
120
+ * Latest officer-pointer payload per officer from already-decoded history rows.
121
+ * Sole reducer for the "last booking wins per officer" rule (#753 / #1161 officers).
122
+ * A booked officer-pointer row with a non-object payload or non-string officer is
123
+ * damaged topology — never silently omitted into lawful zero rounds (#1161 O1).
124
+ * Absence of any officer-pointer row remains lawful empty.
125
+ * Invalid input is a TypeError (same class as other dossier illegal payloads).
126
+ * Control readers (`readBookedOfficerPointers`) let this throw into the existing
127
+ * auditor-roles unreadable seam; `render` catches only that TypeError and marks
128
+ * `history.jsonl` unreadable so other reachable facts stay in current.json.
129
+ */
130
+ export function latestOfficerPointersFromRecords(records) {
131
+ const latest = {};
132
+ for (const record of records) {
133
+ if (record.kind !== OFFICER_POINTER_RECORD_KIND)
134
+ continue;
135
+ if (!isRecord(record.payload) || typeof record.payload.officer !== "string") {
136
+ throw new TypeError("officer-pointer payload is not a record with string officer");
137
+ }
138
+ latest[record.payload.officer] = record.payload;
139
+ }
140
+ return latest;
141
+ }
142
+ /** `current.json` as the rows say right now. */
143
+ function render(runDirectory, history, state, log, unreadable) {
144
+ const whole = {};
145
+ for (const section of ["invocation", "admitted", "runState", "terminal"]) {
146
+ const page = lastPayloadOfKind(state, PAGE_ROW_KIND[section]);
147
+ if (page !== undefined)
148
+ whole[section] = page;
149
+ }
150
+ let latest;
151
+ for (const row of history) {
152
+ const payload = isRecord(row.payload) ? row.payload : undefined;
153
+ if (row.kind === "sealed" && payload !== undefined) {
154
+ latest = { toolCallId: payload.toolCallId, role: payload.role, accepted: payload.accepted, at: row.timestamp };
155
+ }
156
+ }
157
+ // Control readers still fail closed via latestOfficerPointersFromRecords.
158
+ // Render must keep other reachable facts and surface damage on the existing
159
+ // file unreadable seam — never abort the whole current.json (#1161 O1).
160
+ // Catch only the reducer's known TypeError; unknown throws stay unknown
161
+ // (never wash into malformed by free-text message).
162
+ let officers = {};
163
+ try {
164
+ officers = latestOfficerPointersFromRecords(history);
165
+ }
166
+ catch (error) {
167
+ if (!(error instanceof TypeError))
168
+ throw error;
169
+ if (unreadable[RUN_HISTORY_FILE] === undefined)
170
+ unreadable[RUN_HISTORY_FILE] = "malformed";
171
+ }
172
+ const sessions = {};
173
+ for (const row of state) {
174
+ const payload = isRecord(row.payload) ? row.payload : undefined;
175
+ if (row.kind === "host-session-id" && payload !== undefined && typeof payload.host === "string") {
176
+ sessions[payload.host] = payload.sessionId;
177
+ }
178
+ }
179
+ let original;
180
+ for (const row of log) {
181
+ const payload = isRecord(row.payload) ? row.payload : undefined;
182
+ if (row.kind === "host-session" && payload?.type === "native-session-copy" && typeof payload.landingPath === "string") {
183
+ original = payload.landingPath;
184
+ }
185
+ }
186
+ // The copy row keeps the absolute path it was written to; a run that was filed under its ticket
187
+ // since lives elsewhere. The original is projected from where this run directory is now.
188
+ if (typeof original === "string")
189
+ original = join(runDirectory, "session", basename(original));
190
+ whole.host = { sessions, ...(original === undefined ? {} : { original }) };
191
+ whole.submission = latest === undefined ? {} : { latest };
192
+ whole.officers = officers;
193
+ if (Object.keys(unreadable).length > 0)
194
+ whole.unreadable = unreadable;
195
+ return whole;
196
+ }
197
+ function writeWhole(runDirectory, whole) {
198
+ const destination = runCurrentPath(runDirectory);
199
+ const body = `${JSON.stringify(whole, null, 2)}\n`;
200
+ // Same-directory temp + rename: a concurrent reader (the analyst scans live
201
+ // runs) never sees a torn file. A run directory that cannot take a new entry
202
+ // (settlement after a chmod) still accepts an in-place overwrite of the
203
+ // writable file, so that case degrades instead of failing.
204
+ const temporary = join(runDirectory, `.${RUN_CURRENT_FILE}-${randomUUID()}.tmp`);
205
+ try {
206
+ writeFileSync(temporary, body, "utf8");
207
+ renameSync(temporary, destination);
208
+ }
209
+ catch (error) {
210
+ rmSync(temporary, { force: true });
211
+ const code = error.code;
212
+ if (code !== "EACCES" && code !== "EPERM" && code !== "EROFS")
213
+ throw error;
214
+ writeFileSync(destination, body, "utf8");
215
+ }
216
+ }
217
+ /**
218
+ * Process-local once-declare for unreadable row files (Node.js emitWarning
219
+ * "Avoiding duplicate warnings": once per process, not keyed off the durable
220
+ * rendering). Key = runDirectory + file name; value = last declared code.
221
+ */
222
+ const declaredUnreadable = new Map();
223
+ /**
224
+ * Write `current.json` from the rows as read now; then, if a row file grew since that read,
225
+ * render again. The last appender renders after its own append, so the loop ends when the
226
+ * appends stop. A row file that cannot be read is rendered as having no rows and named in
227
+ * the rendering's `unreadable` marker (file to error code), so a stale or partial rendering
228
+ * is never silent. Declaration does not read the previous rendering: a damaged
229
+ * `current.json` must not block fact append, re-render, or public resume.
230
+ */
231
+ export function renderCurrentSync(runDirectory) {
232
+ const paths = [RUN_HISTORY_FILE, RUN_STATE_FILE, RUN_LOG_FILE].map((name) => join(runDirectory, name));
233
+ for (;;) {
234
+ const [history, state, log] = paths.map(readRowFile);
235
+ const unreadable = {};
236
+ for (const [name, file] of [[RUN_HISTORY_FILE, history], [RUN_STATE_FILE, state], [RUN_LOG_FILE, log]]) {
237
+ if (file.fault !== undefined)
238
+ unreadable[name] = file.fault;
239
+ else if (file.diagnostics !== undefined && file.diagnostics.length > 0)
240
+ unreadable[name] = "malformed";
241
+ }
242
+ // Render may add officer-pointer TypeError damage onto the same unreadable map;
243
+ // declare after render so that cause enters the existing diagnostics/unreadable seam.
244
+ const whole = render(runDirectory, history.rows, state.rows, log.rows, unreadable);
245
+ for (const [name, code] of Object.entries(unreadable)) {
246
+ const key = `${runDirectory}\0${name}`;
247
+ if (declaredUnreadable.get(key) === code)
248
+ continue;
249
+ const detail = code === "malformed"
250
+ ? `${name} has malformed row(s); current.json keeps reachable rows and retains the diagnostic`
251
+ : `${name} cannot be read (${code}); current.json is rendered without it`;
252
+ process.stderr.write(`[run-dossier] ${detail}: ${runDirectory}\n`);
253
+ declaredUnreadable.set(key, code);
254
+ }
255
+ writeWhole(runDirectory, whole);
256
+ // Growth uses physical bytes; a malformed diagnostic must not skip the check.
257
+ const grew = [history, state, log].some((file, index) => file.fault === undefined && fileBytes(paths[index]) !== file.bytes);
258
+ if (!grew)
259
+ return;
260
+ }
261
+ }
262
+ function appendPageRow(runDirectory, section, page) {
263
+ appendSitianRecord({
264
+ level: "event",
265
+ kind: PAGE_ROW_KIND[section],
266
+ sessionParent: sessionFileOf(runDirectory),
267
+ source: "run-dossier",
268
+ payload: page,
269
+ });
270
+ }
271
+ /**
272
+ * The fact itself: a whole-page section as its last row says. The public call
273
+ * reads its own facts here, not from the rendering, so a rendering that is stale
274
+ * or refused changes nothing. Absence of a row is absence — never a `current.json`
275
+ * fallback. A row file that cannot be read fails closed. An illegal (non-object)
276
+ * latest payload keeps its failure identity; it is not filtered into absence.
277
+ */
278
+ export function readPageSync(runDirectory, section) {
279
+ const state = readRowFile(join(runDirectory, RUN_STATE_FILE));
280
+ if (state.fault !== undefined)
281
+ throw new Error(`${RUN_STATE_FILE} is unreadable (${state.fault}): ${runDirectory}`);
282
+ // Control plane fails closed on damaged rows: reachable older pages must not
283
+ // silently drive lifecycle (#1161 C3). Rendering still keeps reachable facts.
284
+ if (state.diagnostics !== undefined && state.diagnostics.length > 0) {
285
+ throw new Error(`${RUN_STATE_FILE} has malformed row(s); refusing stale control-plane page: ${runDirectory}`);
286
+ }
287
+ return lastPagePayload(state.rows, PAGE_ROW_KIND[section], runDirectory);
288
+ }
289
+ /** Every row of history.jsonl, in order; a file that cannot be read throws. */
290
+ export function readHistoryRowsSync(runDirectory) {
291
+ const history = readRowFile(join(runDirectory, RUN_HISTORY_FILE));
292
+ if (history.fault !== undefined)
293
+ throw new Error(`${RUN_HISTORY_FILE} is unreadable (${history.fault}): ${runDirectory}`);
294
+ return history.rows;
295
+ }
296
+ /** Every row of state.jsonl, in order; a file that cannot be read throws. */
297
+ export function readStateRowsSync(runDirectory) {
298
+ const state = readRowFile(join(runDirectory, RUN_STATE_FILE));
299
+ if (state.fault !== undefined)
300
+ throw new Error(`${RUN_STATE_FILE} is unreadable (${state.fault}): ${runDirectory}`);
301
+ return state.rows;
302
+ }
303
+ /** Rewrite one section whole: append its row, then render `current.json`. */
304
+ export function writeSectionSync(runDirectory, section, value) {
305
+ appendPageRow(runDirectory, section, value);
306
+ renderCurrentSync(runDirectory);
307
+ }
308
+ /**
309
+ * Let the caller keep its field rules for one existing section: the update
310
+ * receives the last row's page. `undefined` means no change. A missing section
311
+ * is an error: admission writes it first.
312
+ */
313
+ export function updateSectionSync(runDirectory, section, update) {
314
+ const current = readPageSync(runDirectory, section);
315
+ if (current === undefined) {
316
+ throw new Error(`${RUN_STATE_FILE} has no ${PAGE_ROW_KIND[section]} row: ${runDirectory}`);
317
+ }
318
+ const next = update(current);
319
+ if (next === undefined)
320
+ return;
321
+ writeSectionSync(runDirectory, section, next);
322
+ }