@akagilnc/pi-workflow-roles 0.1.5483 → 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 +6312 -6376
- package/dist/analyst-gate-cycles-read.js +28 -78
- package/dist/analyst-ledger.js +26 -42
- 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-entry.js +4 -3
- package/dist/archivist-record-pointer.js +71 -36
- package/dist/atomic-write.js +2 -2
- package/dist/auditor-dossier-tool.js +11 -6
- package/dist/auditor-soul.js +5 -12
- package/dist/collector-config.js +6 -5
- package/dist/doctor-evidence.js +8 -4
- package/dist/engine-detour-usage.js +8 -22
- package/dist/headless-host/production-host.js +5969 -6011
- package/dist/host-descriptions.js +0 -4
- package/dist/host-session-record.js +266 -267
- package/dist/ledger-session-read.js +5 -0
- package/dist/migrate-book-topology.js +1369 -722
- package/dist/navigator-public-session.js +13 -17
- package/dist/notary-source-run.js +8 -16
- package/dist/packaged-role-registry.js +5 -56
- package/dist/pi/durable-principal.js +2 -3
- package/dist/pi/role-turn-host.js +79 -15
- package/dist/public-cli/auto-resume.js +8 -47
- package/dist/public-cli/instruction-seat-run.js +9 -7
- package/dist/public-cli/invocation.js +67 -100
- package/dist/public-cli/main.js +2689 -2532
- package/dist/public-cli/post-admission.js +96 -55
- package/dist/public-cli/run-lifecycle.js +44 -100
- package/dist/public-cli/settlement.js +184 -429
- package/dist/public-cli/terminal.js +0 -12
- package/dist/role-run-placement.js +53 -16
- package/dist/role-run-relocation.js +111 -52
- package/dist/run-dossier-files.js +30 -0
- package/dist/run-dossier.js +322 -0
- package/dist/run-terminal-artifacts.js +69 -202
- package/dist/run-ticket-number.js +49 -7
- package/dist/session-assistant-usage.js +9 -18
- package/dist/session-identity.js +49 -19
- package/dist/sitian-appender.js +29 -18
- package/dist/sitian-facade.js +11 -0
- package/dist/sitian-reader.js +44 -29
- package/dist/submission-gate.js +1 -1
- package/dist/submission-ledger.js +4 -4
- package/dist/ticket-provenance-contracts.js +2 -8
- package/dist/ticket-provenance.js +25 -9
- 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 +11 -4
- package/src/analyst-gate-cycles-read.ts +31 -88
- package/src/analyst-ledger.ts +36 -51
- 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 +6 -5
- package/src/archivist-record-pointer.ts +92 -42
- package/src/atomic-write.ts +3 -1
- package/src/auditor-dossier-tool.ts +14 -8
- package/src/auditor-soul.ts +5 -10
- 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/collector-config.ts +5 -3
- package/src/doctor-contracts.ts +1 -1
- package/src/doctor-evidence.ts +7 -3
- package/src/engine-detour-tool.ts +1 -1
- package/src/engine-detour-usage.ts +12 -25
- package/src/external-host-turn-loop.ts +25 -0
- package/src/factory-board.ts +16 -81
- package/src/headless-host/production-host.ts +1 -4
- package/src/headless-host/role-turn-host.ts +56 -21
- package/src/host-descriptions.ts +0 -6
- package/src/host-session-record.ts +224 -140
- package/src/ledger-session-read.ts +6 -0
- package/src/navigator-public-session.ts +13 -18
- package/src/navigator-work-context.ts +3 -1
- package/src/notary-role.ts +2 -1
- package/src/notary-source-run.ts +8 -18
- package/src/packaged-role-registry.ts +1 -65
- package/src/pi/adapter.ts +50 -7
- package/src/pi/durable-principal.ts +2 -4
- package/src/pi/role-turn-host.ts +85 -14
- package/src/public-cli/auto-resume.ts +7 -43
- package/src/public-cli/instruction-seat-run.ts +13 -6
- package/src/public-cli/invocation.ts +90 -144
- package/src/public-cli/post-admission.ts +111 -72
- package/src/public-cli/run-lifecycle.ts +47 -115
- package/src/public-cli/settlement.ts +221 -589
- package/src/public-cli/terminal.ts +1 -61
- package/src/role-envelope.ts +4 -2
- package/src/role-run-placement.ts +65 -17
- package/src/role-run-relocation.ts +110 -70
- package/src/role-runtime.ts +46 -69
- package/src/run-dossier-files.ts +33 -0
- package/src/run-dossier.ts +357 -0
- package/src/run-terminal-artifacts.ts +84 -240
- package/src/run-ticket-number.ts +82 -14
- package/src/session-assistant-usage.ts +8 -20
- package/src/session-identity.ts +52 -21
- package/src/sitian-appender.ts +33 -26
- package/src/sitian-facade.ts +22 -0
- package/src/sitian-reader.ts +62 -32
- package/src/submission-gate.ts +1 -1
- package/src/submission-ledger.ts +4 -4
- package/src/ticket-provenance-contracts.ts +3 -6
- package/src/ticket-provenance.ts +23 -11
- package/src/ticket-snapshot.ts +3 -2
- package/src/ticket-trajectory.ts +95 -109
|
@@ -1,277 +1,121 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* The reader only checks presence and structural
|
|
2
|
+
* The leg's terminal: the `terminal` section of current.json (#1161), written
|
|
3
|
+
* whole by settlement, last write wins. `report` = an accepted / escalated
|
|
4
|
+
* receipt, `error` = a controlled failure, `no_receipt` = the leg ended with no
|
|
5
|
+
* accepted receipt (#836). The reader only checks presence and structural
|
|
6
6
|
* readability — it does not re-derive role outcomes or invent a second
|
|
7
7
|
* candidate algorithm.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
10
|
-
import { readdir, readFile } from "node:fs/promises";
|
|
11
|
-
import { basename, dirname, join } from "node:path";
|
|
9
|
+
import { basename } from "node:path";
|
|
12
10
|
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
11
|
+
import { parseRunLeaf } from "./role-run-placement.ts";
|
|
12
|
+
import { readHistoryRowsSync, readSectionSync, writeSectionSync } from "./run-dossier.ts";
|
|
15
13
|
|
|
16
|
-
|
|
17
|
-
export const RUN_TERMINAL_ERROR_FILE = "error.json" as const;
|
|
18
|
-
export const RUN_TERMINAL_EVIDENCE_FILE = "evidence.json" as const;
|
|
19
|
-
export const RUN_TERMINAL_ERROR_SETTLEMENT_FILE = "error.settlement.json" as const;
|
|
20
|
-
export const RUN_TERMINAL_ARTIFACT_FILES = [
|
|
21
|
-
RUN_TERMINAL_REPORT_FILE,
|
|
22
|
-
RUN_TERMINAL_ERROR_FILE,
|
|
23
|
-
"audit-incomplete.json",
|
|
24
|
-
] as const;
|
|
14
|
+
import { isRecord } from "./unknown-value.ts";
|
|
25
15
|
|
|
26
|
-
export type
|
|
16
|
+
export type RunTerminalFace = "report" | "error" | "no_receipt";
|
|
27
17
|
|
|
28
|
-
|
|
29
|
-
* Fixed durable failure paths publishFailureArtifacts may settle when the
|
|
30
|
-
* conventional artifacts/error.json name cannot be written. Shared face so the
|
|
31
|
-
* reader follows the publisher — not a parallel search algorithm.
|
|
32
|
-
* Relative to the run directory.
|
|
33
|
-
*/
|
|
34
|
-
export const RUN_TERMINAL_ERROR_FALLBACK_RELATIVE_PATHS = [
|
|
35
|
-
`artifacts/${RUN_TERMINAL_ERROR_SETTLEMENT_FILE}`,
|
|
36
|
-
RUN_TERMINAL_ERROR_SETTLEMENT_FILE,
|
|
37
|
-
] as const;
|
|
38
|
-
|
|
39
|
-
/** Publisher and reader share the open-ended error face stem. */
|
|
40
|
-
export const UNIQUE_ERROR_FALLBACK_STEM = "error";
|
|
41
|
-
export function uniqueErrorFallbackName(): string {
|
|
42
|
-
return `${UNIQUE_ERROR_FALLBACK_STEM}.${randomUUID()}.json`;
|
|
43
|
-
}
|
|
44
|
-
const UNIQUE_ERROR_FALLBACK_NAME = new RegExp(
|
|
45
|
-
`^${UNIQUE_ERROR_FALLBACK_STEM}\\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\.json$`, "i",
|
|
46
|
-
);
|
|
47
|
-
|
|
48
|
-
export type RunTerminalArtifactRead =
|
|
18
|
+
export type RunTerminalRead =
|
|
49
19
|
| { readonly status: "absent" }
|
|
50
20
|
| {
|
|
51
21
|
readonly status: "present";
|
|
52
|
-
readonly
|
|
53
|
-
readonly path: string;
|
|
22
|
+
readonly face: RunTerminalFace;
|
|
54
23
|
readonly body: Record<string, unknown>;
|
|
55
24
|
}
|
|
56
|
-
| {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
25
|
+
| { readonly status: "unreadable"; readonly reason: string };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Terminal body field naming the `attempt-history` row this settlement appended.
|
|
29
|
+
* Readers resolve submitted payloads by this identity (#1161 甲); they must not
|
|
30
|
+
* pick the last history row of the same role.
|
|
31
|
+
*/
|
|
32
|
+
export const ATTEMPT_HISTORY_IDENTITY_FIELD = "attemptHistoryIdentity" as const;
|
|
62
33
|
|
|
63
34
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
35
|
+
* Terminal body field naming the court that wrote this terminal (#1161 R2).
|
|
36
|
+
* Historical `state.jsonl` terminal rows keep it so a later re-projection of the
|
|
37
|
+
* same court can recover that court's `attemptHistoryIdentity` without reading
|
|
38
|
+
* the leg's latest terminal or guessing by payload.
|
|
68
39
|
*/
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
40
|
+
export const TERMINAL_COURT_ATTEMPT_FIELD = "courtAttemptId" as const;
|
|
41
|
+
|
|
42
|
+
/** Replace the leg's terminal. */
|
|
43
|
+
export function writeRunTerminal(
|
|
44
|
+
runDirectory: string,
|
|
45
|
+
face: RunTerminalFace,
|
|
46
|
+
body: Record<string, unknown>,
|
|
47
|
+
): void {
|
|
48
|
+
writeSectionSync(runDirectory, "terminal", { face, at: new Date().toISOString(), body });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Read the leg's terminal. Minimum producer-owned face: a typed object body
|
|
53
|
+
* with a nonblank `role` (ADR 0043); anything else is unreadable. Absence is a
|
|
54
|
+
* valid no-receipt state. A damaged current.json throws.
|
|
55
|
+
*/
|
|
56
|
+
export function readRunTerminal(runDirectory: string): RunTerminalRead {
|
|
57
|
+
const terminal = readSectionSync(runDirectory, "terminal");
|
|
58
|
+
if (terminal === undefined) return { status: "absent" };
|
|
59
|
+
const { face, body } = terminal;
|
|
60
|
+
if (face !== "report" && face !== "error" && face !== "no_receipt") {
|
|
61
|
+
return { status: "unreadable", reason: "terminal section has no known face" };
|
|
74
62
|
}
|
|
75
63
|
if (!isRecord(body)) {
|
|
76
|
-
return {
|
|
77
|
-
ok: false,
|
|
78
|
-
reason: `terminal artifact JSON value is not a typed object (${Array.isArray(body) ? "array" : typeof body})`,
|
|
79
|
-
};
|
|
64
|
+
return { status: "unreadable", reason: "terminal body is not a typed object" };
|
|
80
65
|
}
|
|
81
66
|
if (typeof body.role !== "string" || body.role.trim() === "") {
|
|
82
|
-
return {
|
|
83
|
-
ok: false,
|
|
84
|
-
reason: "terminal artifact missing nonblank producer-owned role field",
|
|
85
|
-
};
|
|
67
|
+
return { status: "unreadable", reason: "terminal body missing nonblank producer-owned role field" };
|
|
86
68
|
}
|
|
87
|
-
return {
|
|
69
|
+
if (face !== "report") return { status: "present", face, body };
|
|
70
|
+
const resolved = withSubmittedPayloads(runDirectory, body);
|
|
71
|
+
if (resolved.status === "unreadable") return resolved;
|
|
72
|
+
return { status: "present", face, body: resolved.body };
|
|
88
73
|
}
|
|
89
74
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
let parsed: unknown;
|
|
107
|
-
try {
|
|
108
|
-
parsed = JSON.parse(raw);
|
|
109
|
-
} catch (error) {
|
|
110
|
-
return {
|
|
111
|
-
status: "unreadable",
|
|
112
|
-
file,
|
|
113
|
-
path,
|
|
114
|
-
reason:
|
|
115
|
-
error instanceof Error
|
|
116
|
-
? error.message
|
|
117
|
-
: `terminal artifact JSON parse failed: ${String(error)}`,
|
|
118
|
-
};
|
|
75
|
+
/**
|
|
76
|
+
* The report's outcome carries the verdict facts only; the role's submitted payloads are not
|
|
77
|
+
* stored a second time. A reader that wants them takes them from the attempt-history row this
|
|
78
|
+
* settlement named on the terminal body (`attemptHistoryIdentity`). When payloads are absent
|
|
79
|
+
* and that identity is missing or cannot supply them, the gap is unreadable — never a silent
|
|
80
|
+
* present body (#1161 R2-gap).
|
|
81
|
+
*/
|
|
82
|
+
function withSubmittedPayloads(
|
|
83
|
+
runDirectory: string,
|
|
84
|
+
body: Record<string, unknown>,
|
|
85
|
+
):
|
|
86
|
+
| { readonly status: "present"; readonly body: Record<string, unknown> }
|
|
87
|
+
| { readonly status: "unreadable"; readonly reason: string } {
|
|
88
|
+
const outcome = body.outcome;
|
|
89
|
+
if (!isRecord(outcome) || outcome.payloads !== undefined) {
|
|
90
|
+
return { status: "present", body };
|
|
119
91
|
}
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
92
|
+
const identity = body[ATTEMPT_HISTORY_IDENTITY_FIELD];
|
|
93
|
+
if (typeof identity !== "string" || identity.trim() === "") {
|
|
122
94
|
return {
|
|
123
95
|
status: "unreadable",
|
|
124
|
-
|
|
125
|
-
path,
|
|
126
|
-
reason: usable.reason,
|
|
96
|
+
reason: "terminal report has no payloads and no attemptHistoryIdentity",
|
|
127
97
|
};
|
|
128
98
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
let names: string[];
|
|
138
|
-
try {
|
|
139
|
-
names = await readdir(dir);
|
|
140
|
-
} catch (error) {
|
|
141
|
-
if (isMissingPathError(error)) continue;
|
|
142
|
-
throw error;
|
|
143
|
-
}
|
|
144
|
-
for (const name of names.sort((a, b) => a.localeCompare(b))) {
|
|
145
|
-
if (!UNIQUE_ERROR_FALLBACK_NAME.test(name)) continue;
|
|
146
|
-
found.push(join(dir, name));
|
|
99
|
+
for (const row of readHistoryRowsSync(runDirectory)) {
|
|
100
|
+
if (row.kind !== "attempt-history" || row.identity !== identity || !isRecord(row.payload)) continue;
|
|
101
|
+
const attempt = row.payload.outcome;
|
|
102
|
+
if (isRecord(attempt) && Array.isArray(attempt.payloads)) {
|
|
103
|
+
return {
|
|
104
|
+
status: "present",
|
|
105
|
+
body: { ...body, outcome: { ...outcome, payloads: attempt.payloads } },
|
|
106
|
+
};
|
|
147
107
|
}
|
|
148
108
|
}
|
|
149
|
-
return
|
|
109
|
+
return {
|
|
110
|
+
status: "unreadable",
|
|
111
|
+
reason: `terminal attemptHistoryIdentity ${identity} has no readable attempt-history payloads`,
|
|
112
|
+
};
|
|
150
113
|
}
|
|
151
114
|
|
|
152
115
|
/**
|
|
153
|
-
*
|
|
154
|
-
* fallbacks are shared across sibling runs, so binding uses this runId only.
|
|
116
|
+
* Run-directory face is `<runId>@<role>`.
|
|
155
117
|
* Sole authority for runDirectory → runId (last `@` split).
|
|
156
118
|
*/
|
|
157
119
|
export function runIdFromRunDirectory(runDirectory: string): string | undefined {
|
|
158
|
-
|
|
159
|
-
const at = name.lastIndexOf("@");
|
|
160
|
-
if (at <= 0 || at === name.length - 1) return undefined;
|
|
161
|
-
return name.slice(0, at);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Shared parent-directory unique fallback may be adopted only when the
|
|
166
|
-
* publisher-owned body.runId equals this run directory's runId. Same-run
|
|
167
|
-
* artifactsDir / runDirectory candidates keep path ownership and skip this.
|
|
168
|
-
* expectedRunId undefined (unparseable run dir) → never bound.
|
|
169
|
-
*/
|
|
170
|
-
function presentUniqueFallbackBoundToRun(
|
|
171
|
-
body: Record<string, unknown>,
|
|
172
|
-
expectedRunId: string | undefined,
|
|
173
|
-
): boolean {
|
|
174
|
-
if (expectedRunId === undefined) return false;
|
|
175
|
-
return typeof body.runId === "string" && body.runId === expectedRunId;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Sole authority for seam-owned unique error.<uuid>.json candidates.
|
|
180
|
-
* Used by both clearOpposite (settlement) and readRunTerminalArtifact — do not
|
|
181
|
-
* re-enumerate the same-run / parent unique set elsewhere.
|
|
182
|
-
* Ownership:
|
|
183
|
-
* - same-run dirs (artifacts/, runDir): path ownership — all unique names
|
|
184
|
-
* - parent runs/: only body.runId-bound faces; unparseable runId or unreadable body → none
|
|
185
|
-
*/
|
|
186
|
-
export async function listSeamOwnedUniqueErrorFacePaths(
|
|
187
|
-
runDirectory: string,
|
|
188
|
-
): Promise<readonly string[]> {
|
|
189
|
-
const artifactsDir = roleRunArtifactsDirectory(runDirectory);
|
|
190
|
-
const owned: string[] = await listUniqueErrorFallbackPaths([
|
|
191
|
-
artifactsDir,
|
|
192
|
-
runDirectory,
|
|
193
|
-
]);
|
|
194
|
-
const expectedRunId = runIdFromRunDirectory(runDirectory);
|
|
195
|
-
for (const path of await listUniqueErrorFallbackPaths([dirname(runDirectory)])) {
|
|
196
|
-
const read = await readTerminalArtifactAtPath(path, "error.json");
|
|
197
|
-
if (read === undefined || read.status !== "present") continue;
|
|
198
|
-
if (!presentUniqueFallbackBoundToRun(read.body, expectedRunId)) continue;
|
|
199
|
-
owned.push(path);
|
|
200
|
-
}
|
|
201
|
-
return owned;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
type PresentOrUnreadable = Exclude<RunTerminalArtifactRead, { status: "absent" }>;
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Publish contract (#953): only failure publish continues after clearOpposite
|
|
208
|
-
* failure, so a multi-class residue (residual report/audit beside a new error
|
|
209
|
-
* face) means the current settlement is failure. Prefer failure-class faces
|
|
210
|
-
* over success/audit — never filesystem mtime (copy/restore/utimes can lie).
|
|
211
|
-
*/
|
|
212
|
-
function failureClassRank(file: RunTerminalArtifactFile): number {
|
|
213
|
-
return file === "error.json" ? 1 : 0;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Read the current typed terminal artifact for a run directory.
|
|
218
|
-
*
|
|
219
|
-
* Candidate set (publisher-owned faces only):
|
|
220
|
-
* 1) conventional artifacts/{report,error,audit-incomplete}.json
|
|
221
|
-
* 2) publisher fixed failure fallbacks (error.settlement.json faces)
|
|
222
|
-
* 3) seam-owned unique error.<uuid>.json via listSeamOwnedUniqueErrorFacePaths
|
|
223
|
-
* (same-run path ownership + parent body.runId binding — shared with clear)
|
|
224
|
-
*
|
|
225
|
-
* Invariant: when more than one present face remains (e.g. clearOpposite failed
|
|
226
|
-
* during failure publish and a fallback was settled beside a residual report),
|
|
227
|
-
* adopt failure-class over success/audit by the publish contract — not mtime.
|
|
228
|
-
* Same-class ties keep candidate enumeration order (conventional before
|
|
229
|
-
* fallbacks before unique).
|
|
230
|
-
*
|
|
231
|
-
* Unreadable faces never outrank a present face. Parent unique unreadable
|
|
232
|
-
* files never enter the shared enumerator (cannot prove run identity).
|
|
233
|
-
* Absence of every known durable face is a valid no-receipt state.
|
|
234
|
-
*/
|
|
235
|
-
export async function readRunTerminalArtifact(
|
|
236
|
-
runDirectory: string,
|
|
237
|
-
): Promise<RunTerminalArtifactRead> {
|
|
238
|
-
const artifactsDir = roleRunArtifactsDirectory(runDirectory);
|
|
239
|
-
const present: Array<Extract<PresentOrUnreadable, { status: "present" }>> = [];
|
|
240
|
-
const unreadable: Array<Extract<PresentOrUnreadable, { status: "unreadable" }>> =
|
|
241
|
-
[];
|
|
242
|
-
|
|
243
|
-
const consider = (read: RunTerminalArtifactRead | undefined): void => {
|
|
244
|
-
if (read === undefined || read.status === "absent") return;
|
|
245
|
-
if (read.status === "present") {
|
|
246
|
-
present.push(read);
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
unreadable.push(read);
|
|
250
|
-
};
|
|
251
|
-
|
|
252
|
-
for (const file of RUN_TERMINAL_ARTIFACT_FILES) {
|
|
253
|
-
consider(await readTerminalArtifactAtPath(join(artifactsDir, file), file));
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
for (const relative of RUN_TERMINAL_ERROR_FALLBACK_RELATIVE_PATHS) {
|
|
257
|
-
consider(
|
|
258
|
-
await readTerminalArtifactAtPath(join(runDirectory, relative), "error.json"),
|
|
259
|
-
);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// Unique same-run + parent-bound faces: one enumerator shared with clear.
|
|
263
|
-
for (const path of await listSeamOwnedUniqueErrorFacePaths(runDirectory)) {
|
|
264
|
-
consider(await readTerminalArtifactAtPath(path, "error.json"));
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
if (present.length > 0) {
|
|
268
|
-
present.sort(
|
|
269
|
-
(a, b) => failureClassRank(b.file) - failureClassRank(a.file),
|
|
270
|
-
);
|
|
271
|
-
return present[0]!;
|
|
272
|
-
}
|
|
273
|
-
if (unreadable.length > 0) {
|
|
274
|
-
return unreadable[0]!;
|
|
275
|
-
}
|
|
276
|
-
return { status: "absent" };
|
|
120
|
+
return parseRunLeaf(basename(runDirectory))?.runId;
|
|
277
121
|
}
|
package/src/run-ticket-number.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Typed ticketNumber reader for a retained run's durable pages.
|
|
3
|
-
*
|
|
4
|
-
* page
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* Production board reads use state.jsonl rows only (admitted, then invocation).
|
|
4
|
+
* Pre-#1161 page files are readable only via readMigrationBoardTicketNumber.
|
|
5
|
+
* Migration derivation page last so worktree-derived placement stays readable
|
|
6
|
+
* without forging a board assertion. Missing page (ENOENT) → try next / undefined;
|
|
7
|
+
* damage and non-ENOENT IO failures propagate.
|
|
7
8
|
*/
|
|
9
|
+
import { readPageSync } from "./run-dossier.ts";
|
|
8
10
|
import { readFile } from "node:fs/promises";
|
|
9
11
|
import { join } from "node:path";
|
|
10
12
|
|
|
@@ -14,9 +16,48 @@ import { isEnoent, isRecord } from "./unknown-value.ts";
|
|
|
14
16
|
export const MIGRATION_TICKET_DERIVATION_PAGE =
|
|
15
17
|
"migration-ticket-derivation.json" as const;
|
|
16
18
|
|
|
19
|
+
/** Positive-integer contract for display / snapshot inputs; binding uses the safe subset below. */
|
|
20
|
+
export function isPositiveTicketNumber(value: unknown): value is number {
|
|
21
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1;
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
/** Sole safe-positive ticket invariant (bind / admission / placement / readers). */
|
|
18
25
|
export function isSafePositiveTicketNumber(value: unknown): value is number {
|
|
19
|
-
return
|
|
26
|
+
return isPositiveTicketNumber(value) && Number.isSafeInteger(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Shared digit spelling, also used for consumer-specific rejection diagnostics. */
|
|
30
|
+
export function isTicketNumberString(value: unknown): value is string {
|
|
31
|
+
return typeof value === "string" && /^[1-9][0-9]*$/.test(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Ticket spelling shared by placement, migration, and sitian subject routing.
|
|
36
|
+
* A number, or a digit string with no sign, zero-pad, decimal, or `#`.
|
|
37
|
+
* Unsafe integers are unidentified. Leading `#N` stays on readDeclaredTicketNumber.
|
|
38
|
+
*/
|
|
39
|
+
export function parseTicketNumber(value: unknown): number | undefined {
|
|
40
|
+
if (isSafePositiveTicketNumber(value)) return value;
|
|
41
|
+
if (!isTicketNumberString(value)) return undefined;
|
|
42
|
+
const parsed = Number(value);
|
|
43
|
+
return isSafePositiveTicketNumber(parsed) ? parsed : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Ticket number carried by a sitian subject, when the subject is a ticket. */
|
|
47
|
+
export function sitianSubjectTicketNumber(subject: unknown): number | undefined {
|
|
48
|
+
if (typeof subject === "string" || typeof subject === "number") {
|
|
49
|
+
return parseTicketNumber(subject);
|
|
50
|
+
}
|
|
51
|
+
if (subject !== null && typeof subject === "object" && !Array.isArray(subject)) {
|
|
52
|
+
return parseTicketNumber((subject as { ticketNumber?: unknown }).ticketNumber);
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Ticket-provenance directory id from a sitian subject, when the subject is a ticket. */
|
|
58
|
+
export function ticketNumberFromSitianSubject(subject: unknown): string | undefined {
|
|
59
|
+
const parsed = sitianSubjectTicketNumber(subject);
|
|
60
|
+
return parsed === undefined ? undefined : String(parsed);
|
|
20
61
|
}
|
|
21
62
|
|
|
22
63
|
/**
|
|
@@ -35,8 +76,7 @@ export function readDeclaredTicketNumber(value: unknown): number | undefined {
|
|
|
35
76
|
// (word-boundary alone would truncate at the decimal point).
|
|
36
77
|
const match = /^#?([1-9]\d*)(?!\.\d)(?:\b|$)/.exec(trimmed);
|
|
37
78
|
if (match === null) return undefined;
|
|
38
|
-
|
|
39
|
-
return isSafePositiveTicketNumber(ticketNumber) ? ticketNumber : undefined;
|
|
79
|
+
return parseTicketNumber(match[1]);
|
|
40
80
|
}
|
|
41
81
|
|
|
42
82
|
/** Loud reject for non-safe-positive ticket numbers before placement or bind. */
|
|
@@ -73,25 +113,53 @@ async function readJsonObject(
|
|
|
73
113
|
}
|
|
74
114
|
}
|
|
75
115
|
|
|
76
|
-
|
|
116
|
+
/** Pre-#1161 separate board pages — migration input only, not a runtime fact path. */
|
|
117
|
+
const LEGACY_BOARD_PAGE: Readonly<Record<"admitted" | "invocation", string>> = {
|
|
118
|
+
admitted: "admitted-request.json",
|
|
119
|
+
invocation: "invocation.json",
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
function readBoardPageTicketNumberFromRows(
|
|
77
123
|
runDirectory: string,
|
|
78
|
-
|
|
124
|
+
section: "admitted" | "invocation",
|
|
125
|
+
): number | undefined {
|
|
126
|
+
const fromRows = readPageSync(runDirectory, section);
|
|
127
|
+
return fromRows === undefined ? undefined : ticketFromRecord(fromRows);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function readBoardPageTicketNumberFromLegacy(
|
|
131
|
+
runDirectory: string,
|
|
132
|
+
section: "admitted" | "invocation",
|
|
79
133
|
): Promise<number | undefined> {
|
|
80
|
-
const record = await readJsonObject(join(runDirectory,
|
|
134
|
+
const record = await readJsonObject(join(runDirectory, LEGACY_BOARD_PAGE[section]));
|
|
81
135
|
if (record === undefined) return undefined;
|
|
82
136
|
return ticketFromRecord(record);
|
|
83
137
|
}
|
|
84
138
|
|
|
85
139
|
/**
|
|
86
|
-
* Board-only ticketNumber (admitted → invocation).
|
|
87
|
-
*
|
|
140
|
+
* Board-only ticketNumber from fact rows (admitted → invocation).
|
|
141
|
+
* Public / production identity paths use this sole projection — never legacy pages.
|
|
88
142
|
*/
|
|
89
143
|
export async function readBoardTicketNumber(
|
|
90
144
|
runDirectory: string,
|
|
91
145
|
): Promise<number | undefined> {
|
|
92
146
|
return (
|
|
93
|
-
(
|
|
94
|
-
(
|
|
147
|
+
readBoardPageTicketNumberFromRows(runDirectory, "admitted") ??
|
|
148
|
+
readBoardPageTicketNumberFromRows(runDirectory, "invocation")
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Migration-boundary board ticket: fact rows first, then pre-#1161 page files.
|
|
154
|
+
* Only #852 migrators and placement may call this; runtime identity must not.
|
|
155
|
+
*/
|
|
156
|
+
export async function readMigrationBoardTicketNumber(
|
|
157
|
+
runDirectory: string,
|
|
158
|
+
): Promise<number | undefined> {
|
|
159
|
+
return (
|
|
160
|
+
(await readBoardTicketNumber(runDirectory)) ??
|
|
161
|
+
(await readBoardPageTicketNumberFromLegacy(runDirectory, "admitted")) ??
|
|
162
|
+
(await readBoardPageTicketNumberFromLegacy(runDirectory, "invocation"))
|
|
95
163
|
);
|
|
96
164
|
}
|
|
97
165
|
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* Accumulates real cost when rows carry it; never invents cost zeros (#675).
|
|
4
4
|
*/
|
|
5
5
|
import type { Usage } from "@earendil-works/pi-ai";
|
|
6
|
-
import {
|
|
6
|
+
import { ROLE_RUN_SESSION_FILENAME, sessionFileOf } from "./role-run-placement.ts";
|
|
7
|
+
import { readStrictPiSessionJsonl } from "./ledger-session-read.ts";
|
|
7
8
|
|
|
8
9
|
import type { PublicSummonResult } from "./public-role-summons.ts";
|
|
9
10
|
|
|
@@ -12,25 +13,12 @@ import { errorText } from "./unknown-value.ts";
|
|
|
12
13
|
export async function readAssistantUsageFromSessionFile(
|
|
13
14
|
sessionFile: string,
|
|
14
15
|
): Promise<Usage | undefined> {
|
|
15
|
-
const { readFile } = await import("node:fs/promises");
|
|
16
|
-
let text: string;
|
|
17
|
-
try {
|
|
18
|
-
text = await readFile(sessionFile, "utf8");
|
|
19
|
-
} catch (error) {
|
|
20
|
-
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return undefined;
|
|
21
|
-
throw error;
|
|
22
|
-
}
|
|
23
16
|
let rows: Array<{ type?: string; message?: { role?: string; usage?: Usage } }>;
|
|
24
17
|
try {
|
|
25
|
-
rows =
|
|
26
|
-
.trim()
|
|
27
|
-
.split("\n")
|
|
28
|
-
.filter(Boolean)
|
|
29
|
-
.map((line) => JSON.parse(line) as {
|
|
30
|
-
type?: string;
|
|
31
|
-
message?: { role?: string; usage?: Usage };
|
|
32
|
-
});
|
|
18
|
+
rows = await readStrictPiSessionJsonl(sessionFile) as typeof rows;
|
|
33
19
|
} catch (error) {
|
|
20
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return undefined;
|
|
21
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
34
22
|
throw new Error(
|
|
35
23
|
`session usage parse failed (${sessionFile}): ${errorText(error)}`,
|
|
36
24
|
{ cause: error },
|
|
@@ -95,18 +83,18 @@ export function sessionFileFromPublicSummon(
|
|
|
95
83
|
summoned: PublicSummonResult,
|
|
96
84
|
): string | undefined {
|
|
97
85
|
if (typeof summoned.runDirectory === "string" && summoned.runDirectory.trim() !== "") {
|
|
98
|
-
return
|
|
86
|
+
return sessionFileOf(summoned.runDirectory);
|
|
99
87
|
}
|
|
100
88
|
const fromArtifacts = summoned.terminal?.artifacts
|
|
101
89
|
?.map((a) => (a as { path?: string }).path)
|
|
102
|
-
.find((p): p is string => typeof p === "string" && p.endsWith(
|
|
90
|
+
.find((p): p is string => typeof p === "string" && p.endsWith(ROLE_RUN_SESSION_FILENAME));
|
|
103
91
|
if (fromArtifacts !== undefined) return fromArtifacts;
|
|
104
92
|
const outcome = summoned.terminal?.roleOutcome;
|
|
105
93
|
if (outcome === undefined) return undefined;
|
|
106
94
|
const facts = (outcome as { decisiveFacts?: Record<string, unknown> }).decisiveFacts;
|
|
107
95
|
const pointer = facts?.runPointer;
|
|
108
96
|
if (typeof pointer === "string" && pointer.trim() !== "") {
|
|
109
|
-
return
|
|
97
|
+
return sessionFileOf(pointer);
|
|
110
98
|
}
|
|
111
99
|
return undefined;
|
|
112
100
|
}
|
package/src/session-identity.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
1
|
import { dirname, join } from "node:path";
|
|
3
2
|
|
|
4
3
|
import type { DurablePrincipal, DurablePrincipalAuthority } from "./host-contracts.ts";
|
|
@@ -7,37 +6,72 @@ import {
|
|
|
7
6
|
lookupHeadlessHostDescription,
|
|
8
7
|
lookupHostDescription,
|
|
9
8
|
} from "./host-descriptions.ts";
|
|
9
|
+
import { renderCurrentSync, RUN_STATE_FILE } from "./run-dossier.ts";
|
|
10
|
+
import { readSitianRecords, reportRunRecord } from "./sitian-facade.ts";
|
|
11
|
+
import { errorText, isRecord } from "./unknown-value.ts";
|
|
10
12
|
import type { SessionIdentityAuthority } from "./prepared-role-turn.ts";
|
|
11
13
|
|
|
12
|
-
/**
|
|
14
|
+
/** Log record kind of one host-session binding (a host's native session id for this run). */
|
|
15
|
+
export const HOST_SESSION_ID_RECORD_KIND = "host-session-id" as const;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Durable session binding: one state row per bind, scoped by host name — a run
|
|
19
|
+
* that changes hosts on resume must never hand one host's native session id to
|
|
20
|
+
* another. The latest record for the host wins. The public call projects
|
|
21
|
+
* `current.json` at the bind so host.sessions appears before the host CLI
|
|
22
|
+
* starts; a refused projection does not undo the binding fact or become a
|
|
23
|
+
* host/session failure (facts stay in state.jsonl; settlement still renders).
|
|
24
|
+
*/
|
|
13
25
|
export function createSessionIdentityAuthority(
|
|
14
26
|
authority: DurablePrincipalAuthority,
|
|
15
|
-
|
|
27
|
+
hostName: string,
|
|
16
28
|
): SessionIdentityAuthority {
|
|
17
|
-
const
|
|
18
|
-
|
|
29
|
+
const runDirectoryOf = (principal: DurablePrincipal): string =>
|
|
30
|
+
dirname(authority.decode(principal).sessionDirectory);
|
|
19
31
|
return {
|
|
20
32
|
resolveSessionFile(principal) {
|
|
21
33
|
return authority.decode(principal).sessionFile;
|
|
22
34
|
},
|
|
23
35
|
async load(principal) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
36
|
+
const runDirectory = runDirectoryOf(principal);
|
|
37
|
+
const { records, diagnostics } = await readSitianRecords(join(runDirectory, RUN_STATE_FILE));
|
|
38
|
+
// Control path fails closed on damaged rows — same class as readPageSync.
|
|
39
|
+
// Rendering may keep reachable facts; dispatch must not reuse a prior binding
|
|
40
|
+
// after syntax damage (#1161 C3).
|
|
41
|
+
if (diagnostics.length > 0) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`${RUN_STATE_FILE} has ${diagnostics.length} malformed row(s); refusing stale session binding: ${runDirectory}`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
let bound: string | undefined;
|
|
47
|
+
for (const record of records) {
|
|
48
|
+
if (record.kind !== HOST_SESSION_ID_RECORD_KIND) continue;
|
|
49
|
+
// Non-object / cannot name a host: unattributable damage — refuse
|
|
50
|
+
// (settled C3-payload). Never treat as a skippable "other host" row.
|
|
51
|
+
if (!isRecord(record.payload) || typeof record.payload.host !== "string") {
|
|
27
52
|
throw new Error("durable session binding is invalid");
|
|
28
53
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
54
|
+
// Explicit other host only: that host's bad id must not block this one.
|
|
55
|
+
if (record.payload.host !== hostName) continue;
|
|
56
|
+
// This host's null / non-string sessionId: refuse — do not reuse a prior
|
|
57
|
+
// binding for the same host (#1161 C3).
|
|
58
|
+
if (typeof record.payload.sessionId !== "string") {
|
|
59
|
+
throw new Error("durable session binding is invalid");
|
|
60
|
+
}
|
|
61
|
+
bound = record.payload.sessionId;
|
|
33
62
|
}
|
|
63
|
+
return bound;
|
|
34
64
|
},
|
|
35
65
|
async bind(principal, sessionId) {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
66
|
+
const runDirectory = runDirectoryOf(principal);
|
|
67
|
+
reportRunRecord(runDirectory, HOST_SESSION_ID_RECORD_KIND, { host: hostName, sessionId }, "session-identity");
|
|
68
|
+
try {
|
|
69
|
+
renderCurrentSync(runDirectory);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
process.stderr.write(
|
|
72
|
+
`[session-identity] current.json render refused after host-session-id; binding fact kept: ${runDirectory}: ${errorText(error)}\n`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
41
75
|
},
|
|
42
76
|
};
|
|
43
77
|
}
|
|
@@ -55,10 +89,7 @@ export async function readStoredHostSessionId(
|
|
|
55
89
|
if (host === undefined || host === DEFAULT_ROLE_TURN_HOST) return undefined;
|
|
56
90
|
const description = lookupHostDescription(host) ?? lookupHeadlessHostDescription(host);
|
|
57
91
|
if (description === undefined) return undefined;
|
|
58
|
-
const sessionId = await createSessionIdentityAuthority(
|
|
59
|
-
authority,
|
|
60
|
-
description.sessionBindingFile,
|
|
61
|
-
).load(principal);
|
|
92
|
+
const sessionId = await createSessionIdentityAuthority(authority, host).load(principal);
|
|
62
93
|
if (sessionId === undefined || sessionId === "") return undefined;
|
|
63
94
|
return sessionId;
|
|
64
95
|
}
|