@akagilnc/pi-workflow-roles 0.1.5504 → 0.1.5571
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/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/dist/acp-host/production-host.js +5965 -5994
- package/dist/analyst-gate-cycles-read.js +23 -55
- package/dist/analyst-ledger.js +21 -35
- package/dist/analyst-metric-families/acceptance-success-rework.js +25 -1
- package/dist/analyst-metric-families/round-timeline.js +2 -5
- package/dist/archivist-record-pointer.js +38 -30
- package/dist/auditor-dossier-tool.js +4 -3
- package/dist/auditor-soul.js +3 -11
- package/dist/doctor-evidence.js +3 -3
- package/dist/engine-detour-usage.js +6 -14
- package/dist/headless-host/production-host.js +5316 -5324
- package/dist/host-descriptions.js +0 -4
- package/dist/host-session-record.js +266 -267
- package/dist/migrate-book-topology.js +1314 -696
- package/dist/notary-source-run.js +1 -1
- package/dist/packaged-role-registry.js +5 -44
- package/dist/pi/role-turn-host.js +53 -3
- package/dist/public-cli/auto-resume.js +8 -47
- package/dist/public-cli/instruction-seat-run.js +5 -3
- package/dist/public-cli/invocation.js +51 -56
- package/dist/public-cli/main.js +1916 -1748
- package/dist/public-cli/post-admission.js +96 -55
- package/dist/public-cli/run-lifecycle.js +27 -53
- package/dist/public-cli/settlement.js +157 -302
- package/dist/role-run-placement.js +1 -11
- package/dist/role-run-relocation.js +72 -50
- package/dist/run-dossier-files.js +30 -0
- package/dist/run-dossier.js +322 -0
- package/dist/run-terminal-artifacts.js +68 -197
- package/dist/run-ticket-number.js +16 -3
- package/dist/session-identity.js +49 -18
- package/dist/sitian-appender.js +13 -16
- package/dist/sitian-facade.js +11 -0
- package/dist/sitian-reader.js +44 -29
- package/dist/submission-gate.js +1 -1
- package/dist/ticket-provenance.js +22 -7
- package/package.json +1 -1
- package/scripts/build-package.mjs +2 -0
- package/src/acp-host/production-host.ts +1 -1
- package/src/acp-host/role-turn-host.ts +9 -2
- package/src/analyst-gate-cycles-read.ts +26 -60
- package/src/analyst-ledger.ts +27 -42
- package/src/analyst-metric-families/acceptance-success-rework.ts +24 -1
- package/src/analyst-metric-families/gate-cycles.ts +1 -1
- package/src/analyst-metric-families/round-timeline.ts +2 -5
- package/src/analyst-page.ts +3 -3
- package/src/archivist-record-entry.ts +2 -2
- package/src/archivist-record-pointer.ts +50 -36
- package/src/auditor-dossier-tool.ts +7 -5
- package/src/auditor-soul.ts +3 -9
- package/src/book-topology-migration-placement.ts +16 -3
- package/src/book-topology-record-class-migrators.ts +4 -2
- package/src/book-topology-runs-migrator.ts +6 -3
- package/src/doctor-contracts.ts +1 -1
- package/src/doctor-evidence.ts +2 -2
- package/src/engine-detour-tool.ts +1 -1
- package/src/engine-detour-usage.ts +6 -16
- package/src/external-host-turn-loop.ts +25 -0
- package/src/headless-host/production-host.ts +1 -4
- package/src/headless-host/role-turn-host.ts +54 -20
- package/src/host-descriptions.ts +0 -6
- package/src/host-session-record.ts +224 -140
- package/src/notary-source-run.ts +1 -1
- package/src/packaged-role-registry.ts +1 -50
- package/src/pi/adapter.ts +50 -7
- package/src/pi/role-turn-host.ts +51 -3
- package/src/public-cli/auto-resume.ts +7 -43
- package/src/public-cli/instruction-seat-run.ts +9 -2
- package/src/public-cli/invocation.ts +61 -87
- package/src/public-cli/post-admission.ts +111 -72
- package/src/public-cli/run-lifecycle.ts +27 -72
- package/src/public-cli/settlement.ts +193 -418
- package/src/public-cli/terminal.ts +1 -1
- package/src/role-run-placement.ts +2 -13
- package/src/role-run-relocation.ts +71 -68
- package/src/role-runtime.ts +6 -10
- package/src/run-dossier-files.ts +33 -0
- package/src/run-dossier.ts +357 -0
- package/src/run-terminal-artifacts.ts +83 -237
- package/src/run-ticket-number.ts +41 -11
- package/src/session-identity.ts +52 -20
- package/src/sitian-appender.ts +17 -18
- package/src/sitian-facade.ts +22 -0
- package/src/sitian-reader.ts +62 -32
- package/src/submission-gate.ts +1 -1
- package/src/ticket-provenance.ts +20 -7
- 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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
*
|
|
307
|
-
* session transcript typed parent bindings. Never walks attachments
|
|
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
|
|
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
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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(
|
|
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
|
+
}
|