@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.
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
@@ -0,0 +1,357 @@
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
+
23
+ import { sessionFileOf } from "./role-run-placement.ts";
24
+ import { RUN_CURRENT_FILE, RUN_HISTORY_FILE, RUN_LOG_FILE, RUN_STATE_FILE, OFFICER_POINTER_RECORD_KIND } from "./run-dossier-files.ts";
25
+ import { appendSitianRecord } from "./sitian-appender.ts";
26
+ import { parseSitianRecordText } from "./sitian-reader.ts";
27
+ import { isMissingPathError, isRecord } from "./unknown-value.ts";
28
+
29
+ export { RUN_CURRENT_FILE, RUN_HISTORY_FILE, RUN_LOG_FILE, RUN_STATE_FILE };
30
+
31
+ /** Sections of `current.json` that are a whole page appended as one row. */
32
+ export type CurrentSection = "invocation" | "admitted" | "runState" | "terminal";
33
+ /** Sections rendered from rows other seams append. */
34
+ export type DerivedSection = "host" | "submission" | "officers";
35
+
36
+ /** The row kind of each whole-page section: the name of the file it replaced. */
37
+ const PAGE_ROW_KIND: Readonly<Record<CurrentSection, string>> = {
38
+ invocation: "invocation",
39
+ admitted: "admitted-request",
40
+ runState: "run-state",
41
+ terminal: "terminal",
42
+ };
43
+
44
+ export function runCurrentPath(runDirectory: string): string {
45
+ return join(runDirectory, RUN_CURRENT_FILE);
46
+ }
47
+
48
+ /** The whole `current.json`, or undefined when absent (ENOENT only). */
49
+ export function readCurrentSync(runDirectory: string): Record<string, unknown> | undefined {
50
+ let raw: string;
51
+ try {
52
+ raw = readFileSync(runCurrentPath(runDirectory), "utf8");
53
+ } catch (error) {
54
+ if (isMissingPathError(error)) return undefined;
55
+ throw error;
56
+ }
57
+ const parsed: unknown = JSON.parse(raw);
58
+ if (!isRecord(parsed)) throw new TypeError(`${RUN_CURRENT_FILE} is not an object: ${runCurrentPath(runDirectory)}`);
59
+ return parsed;
60
+ }
61
+
62
+ /** One section of `current.json` (the rendering); undefined when the file or the section is absent. */
63
+ export function readSectionSync(
64
+ runDirectory: string,
65
+ section: CurrentSection | DerivedSection,
66
+ ): Record<string, unknown> | undefined {
67
+ const value = readCurrentSync(runDirectory)?.[section];
68
+ if (value === undefined) return undefined;
69
+ if (!isRecord(value)) {
70
+ throw new TypeError(`${RUN_CURRENT_FILE} ${section} section is not an object: ${runCurrentPath(runDirectory)}`);
71
+ }
72
+ return value;
73
+ }
74
+
75
+ type RowFile = {
76
+ readonly rows: Record<string, unknown>[];
77
+ /** Physical on-disk byte length of this read — never a re-encoded string length. */
78
+ readonly bytes: number;
79
+ /** The error code of a read that failed for a reason other than the file being absent. */
80
+ readonly fault?: string;
81
+ /** Canonical malformed-line diagnostics from the sole Sitian decoder. */
82
+ readonly diagnostics?: readonly { readonly line: number; readonly error: string }[];
83
+ };
84
+
85
+ /**
86
+ * The rows of one run file as of this read, with the physical byte length they came from.
87
+ * Missing file → empty. IO fault → no rows + fault code. Damaged lines keep the sole
88
+ * Sitian decoder's diagnostics and do not invent a second skip parser.
89
+ */
90
+ function readRowFile(path: string): RowFile {
91
+ let buffer: Buffer;
92
+ try {
93
+ buffer = readFileSync(path);
94
+ } catch (error) {
95
+ if (isMissingPathError(error)) return { rows: [], bytes: 0 };
96
+ return { rows: [], bytes: 0, fault: String((error as NodeJS.ErrnoException).code ?? (error as Error).name) };
97
+ }
98
+ const { records, diagnostics } = parseSitianRecordText(buffer.toString("utf8"));
99
+ return {
100
+ rows: [...records] as Record<string, unknown>[],
101
+ bytes: buffer.length,
102
+ ...(diagnostics.length > 0
103
+ ? { diagnostics: diagnostics.map((d) => ({ line: d.line, error: d.error })) }
104
+ : {}),
105
+ };
106
+ }
107
+
108
+ function fileBytes(path: string): number | undefined {
109
+ try {
110
+ return statSync(path).size;
111
+ } catch (error) {
112
+ return isMissingPathError(error) ? 0 : undefined;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Payload of the last row of `kind`, or undefined when no such row exists.
118
+ * Non-object payloads are returned as-is: a later illegal whole page must not
119
+ * be washed into an older valid page or into absence.
120
+ */
121
+ function lastPayloadOfKind(rows: readonly Record<string, unknown>[], kind: string): unknown {
122
+ for (let index = rows.length - 1; index >= 0; index -= 1) {
123
+ const row = rows[index]!;
124
+ if (row.kind === kind) return row.payload;
125
+ }
126
+ return undefined;
127
+ }
128
+
129
+ /** Last whole-page payload as an object, or undefined when absent; illegal values throw. */
130
+ function lastPagePayload(
131
+ rows: readonly Record<string, unknown>[],
132
+ kind: string,
133
+ runDirectory: string,
134
+ ): Record<string, unknown> | undefined {
135
+ const payload = lastPayloadOfKind(rows, kind);
136
+ if (payload === undefined) return undefined;
137
+ if (!isRecord(payload)) {
138
+ throw new TypeError(`${RUN_STATE_FILE} ${kind} payload is not an object: ${runDirectory}`);
139
+ }
140
+ return payload;
141
+ }
142
+
143
+ /**
144
+ * Latest officer-pointer payload per officer from already-decoded history rows.
145
+ * Sole reducer for the "last booking wins per officer" rule (#753 / #1161 officers).
146
+ * A booked officer-pointer row with a non-object payload or non-string officer is
147
+ * damaged topology — never silently omitted into lawful zero rounds (#1161 O1).
148
+ * Absence of any officer-pointer row remains lawful empty.
149
+ * Invalid input is a TypeError (same class as other dossier illegal payloads).
150
+ * Control readers (`readBookedOfficerPointers`) let this throw into the existing
151
+ * auditor-roles unreadable seam; `render` catches only that TypeError and marks
152
+ * `history.jsonl` unreadable so other reachable facts stay in current.json.
153
+ */
154
+ export function latestOfficerPointersFromRecords(
155
+ records: readonly { readonly kind?: unknown; readonly payload?: unknown }[],
156
+ ): Readonly<Record<string, unknown>> {
157
+ const latest: Record<string, unknown> = {};
158
+ for (const record of records) {
159
+ if (record.kind !== OFFICER_POINTER_RECORD_KIND) continue;
160
+ if (!isRecord(record.payload) || typeof record.payload.officer !== "string") {
161
+ throw new TypeError("officer-pointer payload is not a record with string officer");
162
+ }
163
+ latest[record.payload.officer] = record.payload;
164
+ }
165
+ return latest;
166
+ }
167
+
168
+ /** `current.json` as the rows say right now. */
169
+ function render(
170
+ runDirectory: string,
171
+ history: readonly Record<string, unknown>[],
172
+ state: readonly Record<string, unknown>[],
173
+ log: readonly Record<string, unknown>[],
174
+ unreadable: Record<string, string>,
175
+ ): Record<string, unknown> {
176
+ const whole: Record<string, unknown> = {};
177
+ for (const section of ["invocation", "admitted", "runState", "terminal"] as const) {
178
+ const page = lastPayloadOfKind(state, PAGE_ROW_KIND[section]);
179
+ if (page !== undefined) whole[section] = page;
180
+ }
181
+ let latest: Record<string, unknown> | undefined;
182
+ for (const row of history) {
183
+ const payload = isRecord(row.payload) ? row.payload : undefined;
184
+ if (row.kind === "sealed" && payload !== undefined) {
185
+ latest = { toolCallId: payload.toolCallId, role: payload.role, accepted: payload.accepted, at: row.timestamp };
186
+ }
187
+ }
188
+ // Control readers still fail closed via latestOfficerPointersFromRecords.
189
+ // Render must keep other reachable facts and surface damage on the existing
190
+ // file unreadable seam — never abort the whole current.json (#1161 O1).
191
+ // Catch only the reducer's known TypeError; unknown throws stay unknown
192
+ // (never wash into malformed by free-text message).
193
+ let officers: Readonly<Record<string, unknown>> = {};
194
+ try {
195
+ officers = latestOfficerPointersFromRecords(history);
196
+ } catch (error) {
197
+ if (!(error instanceof TypeError)) throw error;
198
+ if (unreadable[RUN_HISTORY_FILE] === undefined) unreadable[RUN_HISTORY_FILE] = "malformed";
199
+ }
200
+ const sessions: Record<string, unknown> = {};
201
+ for (const row of state) {
202
+ const payload = isRecord(row.payload) ? row.payload : undefined;
203
+ if (row.kind === "host-session-id" && payload !== undefined && typeof payload.host === "string") {
204
+ sessions[payload.host] = payload.sessionId;
205
+ }
206
+ }
207
+ let original: unknown;
208
+ for (const row of log) {
209
+ const payload = isRecord(row.payload) ? row.payload : undefined;
210
+ if (row.kind === "host-session" && payload?.type === "native-session-copy" && typeof payload.landingPath === "string") {
211
+ original = payload.landingPath;
212
+ }
213
+ }
214
+ // The copy row keeps the absolute path it was written to; a run that was filed under its ticket
215
+ // since lives elsewhere. The original is projected from where this run directory is now.
216
+ if (typeof original === "string") original = join(runDirectory, "session", basename(original));
217
+ whole.host = { sessions, ...(original === undefined ? {} : { original }) };
218
+ whole.submission = latest === undefined ? {} : { latest };
219
+ whole.officers = officers;
220
+ if (Object.keys(unreadable).length > 0) whole.unreadable = unreadable;
221
+ return whole;
222
+ }
223
+
224
+ function writeWhole(runDirectory: string, whole: Record<string, unknown>): void {
225
+ const destination = runCurrentPath(runDirectory);
226
+ const body = `${JSON.stringify(whole, null, 2)}\n`;
227
+ // Same-directory temp + rename: a concurrent reader (the analyst scans live
228
+ // runs) never sees a torn file. A run directory that cannot take a new entry
229
+ // (settlement after a chmod) still accepts an in-place overwrite of the
230
+ // writable file, so that case degrades instead of failing.
231
+ const temporary = join(runDirectory, `.${RUN_CURRENT_FILE}-${randomUUID()}.tmp`);
232
+ try {
233
+ writeFileSync(temporary, body, "utf8");
234
+ renameSync(temporary, destination);
235
+ } catch (error) {
236
+ rmSync(temporary, { force: true });
237
+ const code = (error as NodeJS.ErrnoException).code;
238
+ if (code !== "EACCES" && code !== "EPERM" && code !== "EROFS") throw error;
239
+ writeFileSync(destination, body, "utf8");
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Process-local once-declare for unreadable row files (Node.js emitWarning
245
+ * "Avoiding duplicate warnings": once per process, not keyed off the durable
246
+ * rendering). Key = runDirectory + file name; value = last declared code.
247
+ */
248
+ const declaredUnreadable = new Map<string, string>();
249
+
250
+ /**
251
+ * Write `current.json` from the rows as read now; then, if a row file grew since that read,
252
+ * render again. The last appender renders after its own append, so the loop ends when the
253
+ * appends stop. A row file that cannot be read is rendered as having no rows and named in
254
+ * the rendering's `unreadable` marker (file to error code), so a stale or partial rendering
255
+ * is never silent. Declaration does not read the previous rendering: a damaged
256
+ * `current.json` must not block fact append, re-render, or public resume.
257
+ */
258
+ export function renderCurrentSync(runDirectory: string): void {
259
+ const paths = [RUN_HISTORY_FILE, RUN_STATE_FILE, RUN_LOG_FILE].map((name) => join(runDirectory, name));
260
+ for (;;) {
261
+ const [history, state, log] = paths.map(readRowFile) as [RowFile, RowFile, RowFile];
262
+ const unreadable: Record<string, string> = {};
263
+ for (const [name, file] of [[RUN_HISTORY_FILE, history], [RUN_STATE_FILE, state], [RUN_LOG_FILE, log]] as const) {
264
+ if (file.fault !== undefined) unreadable[name] = file.fault;
265
+ else if (file.diagnostics !== undefined && file.diagnostics.length > 0) unreadable[name] = "malformed";
266
+ }
267
+ // Render may add officer-pointer TypeError damage onto the same unreadable map;
268
+ // declare after render so that cause enters the existing diagnostics/unreadable seam.
269
+ const whole = render(runDirectory, history.rows, state.rows, log.rows, unreadable);
270
+ for (const [name, code] of Object.entries(unreadable)) {
271
+ const key = `${runDirectory}\0${name}`;
272
+ if (declaredUnreadable.get(key) === code) continue;
273
+ const detail = code === "malformed"
274
+ ? `${name} has malformed row(s); current.json keeps reachable rows and retains the diagnostic`
275
+ : `${name} cannot be read (${code}); current.json is rendered without it`;
276
+ process.stderr.write(`[run-dossier] ${detail}: ${runDirectory}\n`);
277
+ declaredUnreadable.set(key, code);
278
+ }
279
+ writeWhole(runDirectory, whole);
280
+ // Growth uses physical bytes; a malformed diagnostic must not skip the check.
281
+ const grew = [history, state, log].some((file, index) => file.fault === undefined && fileBytes(paths[index]!) !== file.bytes);
282
+ if (!grew) return;
283
+ }
284
+ }
285
+
286
+ function appendPageRow(runDirectory: string, section: CurrentSection, page: Record<string, unknown>): void {
287
+ appendSitianRecord({
288
+ level: "event",
289
+ kind: PAGE_ROW_KIND[section],
290
+ sessionParent: sessionFileOf(runDirectory),
291
+ source: "run-dossier",
292
+ payload: page,
293
+ });
294
+ }
295
+
296
+ /**
297
+ * The fact itself: a whole-page section as its last row says. The public call
298
+ * reads its own facts here, not from the rendering, so a rendering that is stale
299
+ * or refused changes nothing. Absence of a row is absence — never a `current.json`
300
+ * fallback. A row file that cannot be read fails closed. An illegal (non-object)
301
+ * latest payload keeps its failure identity; it is not filtered into absence.
302
+ */
303
+ export function readPageSync(runDirectory: string, section: CurrentSection): Record<string, unknown> | undefined {
304
+ const state = readRowFile(join(runDirectory, RUN_STATE_FILE));
305
+ if (state.fault !== undefined) throw new Error(`${RUN_STATE_FILE} is unreadable (${state.fault}): ${runDirectory}`);
306
+ // Control plane fails closed on damaged rows: reachable older pages must not
307
+ // silently drive lifecycle (#1161 C3). Rendering still keeps reachable facts.
308
+ if (state.diagnostics !== undefined && state.diagnostics.length > 0) {
309
+ throw new Error(
310
+ `${RUN_STATE_FILE} has malformed row(s); refusing stale control-plane page: ${runDirectory}`,
311
+ );
312
+ }
313
+ return lastPagePayload(state.rows, PAGE_ROW_KIND[section], runDirectory);
314
+ }
315
+
316
+ /** Every row of history.jsonl, in order; a file that cannot be read throws. */
317
+ export function readHistoryRowsSync(runDirectory: string): readonly Record<string, unknown>[] {
318
+ const history = readRowFile(join(runDirectory, RUN_HISTORY_FILE));
319
+ if (history.fault !== undefined) throw new Error(`${RUN_HISTORY_FILE} is unreadable (${history.fault}): ${runDirectory}`);
320
+ return history.rows;
321
+ }
322
+
323
+ /** Every row of state.jsonl, in order; a file that cannot be read throws. */
324
+ export function readStateRowsSync(runDirectory: string): readonly Record<string, unknown>[] {
325
+ const state = readRowFile(join(runDirectory, RUN_STATE_FILE));
326
+ if (state.fault !== undefined) throw new Error(`${RUN_STATE_FILE} is unreadable (${state.fault}): ${runDirectory}`);
327
+ return state.rows;
328
+ }
329
+
330
+ /** Rewrite one section whole: append its row, then render `current.json`. */
331
+ export function writeSectionSync(
332
+ runDirectory: string,
333
+ section: CurrentSection,
334
+ value: Record<string, unknown>,
335
+ ): void {
336
+ appendPageRow(runDirectory, section, value);
337
+ renderCurrentSync(runDirectory);
338
+ }
339
+
340
+ /**
341
+ * Let the caller keep its field rules for one existing section: the update
342
+ * receives the last row's page. `undefined` means no change. A missing section
343
+ * is an error: admission writes it first.
344
+ */
345
+ export function updateSectionSync(
346
+ runDirectory: string,
347
+ section: CurrentSection,
348
+ update: (current: Record<string, unknown>) => Record<string, unknown> | undefined,
349
+ ): void {
350
+ const current = readPageSync(runDirectory, section);
351
+ if (current === undefined) {
352
+ throw new Error(`${RUN_STATE_FILE} has no ${PAGE_ROW_KIND[section]} row: ${runDirectory}`);
353
+ }
354
+ const next = update(current);
355
+ if (next === undefined) return;
356
+ writeSectionSync(runDirectory, section, next);
357
+ }