create-principles-disciple 1.146.8 → 1.147.0
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/_release/product-identity.json +1 -1
- package/codex-adapter/dist/ingestion/explicit-owner-statement.d.ts +6 -0
- package/codex-adapter/dist/ingestion/explicit-owner-statement.js +6 -0
- package/codex-adapter/dist/pd-hook.js +5 -2
- package/codex-adapter/dist/worker/workspace-worker.d.ts +13 -1
- package/codex-adapter/dist/worker/workspace-worker.js +26 -3
- package/codex-adapter/package.json +1 -1
- package/console/dist/server/models/ExaminerEvidenceModel.d.ts +159 -0
- package/console/dist/server/models/ExaminerEvidenceModel.js +274 -0
- package/console/dist/server/models/ReceiptsConsoleModel.js +15 -1
- package/console/dist/server/routes/examiner-executor.d.ts +40 -4
- package/console/dist/server/routes/examiner-executor.js +54 -45
- package/console/dist/server/routes/examiner-runs.d.ts +196 -0
- package/console/dist/server/routes/examiner-runs.js +938 -0
- package/console/dist/server/routes/examiner-study-inputs.d.ts +147 -0
- package/console/dist/server/routes/examiner-study-inputs.js +263 -0
- package/console/dist/server/routes/examiner.d.ts +26 -17
- package/console/dist/server/routes/examiner.js +261 -12
- package/console/dist/types.d.ts +1 -0
- package/console/dist/web/assets/app.css +92 -0
- package/console/dist/web/assets/app.js +38 -32
- package/console/lab/lib/challenge-openclaw-host.mjs +17 -0
- package/console/lab/lib/challenge-runner.mjs +9 -0
- package/console/lab/lib/examiner-study.mjs +55 -0
- package/core/dist/runtime-v2/index.d.ts +2 -2
- package/core/dist/runtime-v2/index.d.ts.map +1 -1
- package/core/dist/runtime-v2/index.js +1 -1
- package/core/dist/runtime-v2/index.js.map +1 -1
- package/core/dist/runtime-v2/pain-ingress-payload.d.ts +13 -1
- package/core/dist/runtime-v2/pain-ingress-payload.d.ts.map +1 -1
- package/core/dist/runtime-v2/pain-ingress-payload.js +21 -2
- package/core/dist/runtime-v2/pain-ingress-payload.js.map +1 -1
- package/core/dist/runtime-v2/pain-ingress.d.ts.map +1 -1
- package/core/dist/runtime-v2/pain-ingress.js +3 -0
- package/core/dist/runtime-v2/pain-ingress.js.map +1 -1
- package/core/dist/runtime-v2/signal-collector/confidence-gate.d.ts +35 -0
- package/core/dist/runtime-v2/signal-collector/confidence-gate.d.ts.map +1 -0
- package/core/dist/runtime-v2/signal-collector/confidence-gate.js +48 -0
- package/core/dist/runtime-v2/signal-collector/confidence-gate.js.map +1 -0
- package/core/dist/runtime-v2/signal-collector/index.d.ts +1 -0
- package/core/dist/runtime-v2/signal-collector/index.d.ts.map +1 -1
- package/core/dist/runtime-v2/signal-collector/index.js +1 -0
- package/core/dist/runtime-v2/signal-collector/index.js.map +1 -1
- package/core/dist/runtime-v2/signal-collector/keyword-stage.d.ts +4 -6
- package/core/dist/runtime-v2/signal-collector/keyword-stage.d.ts.map +1 -1
- package/core/dist/runtime-v2/signal-collector/keyword-stage.js +100 -9
- package/core/dist/runtime-v2/signal-collector/keyword-stage.js.map +1 -1
- package/core/dist/runtime-v2/store/context/sqlite-context-assembler.d.ts.map +1 -1
- package/core/dist/runtime-v2/store/context/sqlite-context-assembler.js +8 -4
- package/core/dist/runtime-v2/store/context/sqlite-context-assembler.js.map +1 -1
- package/core/package.json +1 -1
- package/dist/installer.d.ts.map +1 -1
- package/dist/installer.js +52 -1
- package/dist/installer.js.map +1 -1
- package/dist/installers/mcp-registration-conflict.d.ts +35 -0
- package/dist/installers/mcp-registration-conflict.d.ts.map +1 -0
- package/dist/installers/mcp-registration-conflict.js +559 -0
- package/dist/installers/mcp-registration-conflict.js.map +1 -0
- package/dist/installers/openclaw-host-installer.d.ts.map +1 -1
- package/dist/installers/openclaw-host-installer.js +20 -0
- package/dist/installers/openclaw-host-installer.js.map +1 -1
- package/dist/installers/workbuddy-host-installer.d.ts.map +1 -1
- package/dist/installers/workbuddy-host-installer.js +124 -2
- package/dist/installers/workbuddy-host-installer.js.map +1 -1
- package/dist/mcp-server-layout-compat.d.ts +18 -0
- package/dist/mcp-server-layout-compat.d.ts.map +1 -0
- package/dist/mcp-server-layout-compat.js +22 -0
- package/dist/mcp-server-layout-compat.js.map +1 -0
- package/host-runtime/dist/governance-signal-admission.d.ts +147 -4
- package/host-runtime/dist/governance-signal-admission.js +1065 -60
- package/host-runtime/dist/index.d.ts +2 -0
- package/host-runtime/dist/index.js +2 -0
- package/host-runtime/dist/signal-classifier.d.ts +46 -0
- package/host-runtime/dist/signal-classifier.js +167 -0
- package/host-runtime/dist/signal-stage2-cycle.d.ts +42 -0
- package/host-runtime/dist/signal-stage2-cycle.js +184 -0
- package/host-runtime/package.json +1 -1
- package/install-layout/dist/index.d.ts +15 -0
- package/install-layout/dist/index.js +10 -0
- package/install-layout/package.json +1 -1
- package/package.json +9 -7
- package/pd-cli/dist/commands/health-codex.d.ts.map +1 -1
- package/pd-cli/dist/commands/health-codex.js +12 -0
- package/pd-cli/dist/commands/health-codex.js.map +1 -1
- package/pd-cli/dist/commands/health-mcp.d.ts +74 -0
- package/pd-cli/dist/commands/health-mcp.d.ts.map +1 -0
- package/pd-cli/dist/commands/health-mcp.js +723 -0
- package/pd-cli/dist/commands/health-mcp.js.map +1 -0
- package/pd-cli/dist/commands/health.d.ts +2 -0
- package/pd-cli/dist/commands/health.d.ts.map +1 -1
- package/pd-cli/dist/commands/health.js +11 -6
- package/pd-cli/dist/commands/health.js.map +1 -1
- package/pd-mcp-server/dist/budget.d.ts +15 -0
- package/pd-mcp-server/dist/budget.js +19 -0
- package/pd-mcp-server/dist/deadline.d.ts +75 -0
- package/pd-mcp-server/dist/deadline.js +136 -0
- package/pd-mcp-server/dist/envelope.d.ts +52 -0
- package/pd-mcp-server/dist/envelope.js +63 -0
- package/pd-mcp-server/dist/index.d.ts +10 -0
- package/pd-mcp-server/dist/index.js +76 -0
- package/pd-mcp-server/dist/logging.d.ts +12 -0
- package/pd-mcp-server/dist/logging.js +42 -0
- package/pd-mcp-server/dist/server.d.ts +90 -0
- package/pd-mcp-server/dist/server.js +178 -0
- package/pd-mcp-server/dist/version.d.ts +13 -0
- package/pd-mcp-server/dist/version.js +13 -0
- package/pd-mcp-server/dist/workspace.d.ts +23 -0
- package/pd-mcp-server/dist/workspace.js +77 -0
- package/pd-mcp-server/package.json +49 -0
- package/plugin/dist/bundle.js +414 -412
- package/plugin/dist/core/mcp-binding.d.ts +41 -0
- package/plugin/dist/index.d.ts +33 -0
- package/plugin/dist/openclaw.plugin.json +10 -1
- package/plugin/dist/utils/workspace-resolver.d.ts +17 -0
- package/plugin/openclaw.plugin.json +10 -1
- package/plugin/package.json +2 -1
- package/release-manager/dist/installer.js +52 -1
- package/release-manager/dist/installers/mcp-registration-conflict.js +559 -0
- package/release-manager/dist/installers/openclaw-host-installer.js +20 -0
- package/release-manager/dist/installers/workbuddy-host-installer.js +124 -2
- package/release-manager/dist/mcp-server-layout-compat.js +22 -0
- package/release-manager/package.json +7 -5
- package/workbuddy-adapter/dist/pd-hook.js +28 -5
- package/workbuddy-adapter/dist/worker/workspace-worker.d.ts +6 -1
- package/workbuddy-adapter/dist/worker/workspace-worker.js +24 -3
- package/workbuddy-adapter/package.json +1 -1
- package/workbuddy-adapter/plugin/.codebuddy-plugin/plugin.json +2 -2
- package/workbuddy-adapter/plugin/.mcp.json +10 -0
- package/workbuddy-adapter/plugin/mcp/launcher.cjs +347 -0
|
@@ -0,0 +1,938 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPEC-PD-EXAMINER-001 v0.10.1 §12.3 — the minimal ASYNC execution contract
|
|
3
|
+
* for console-originated runs, plus the F2 readiness surface.
|
|
4
|
+
*
|
|
5
|
+
* Contract (all state derives from the study's EXISTING append-only ledger —
|
|
6
|
+
* `events.jsonl` — never a second execution ledger):
|
|
7
|
+
*
|
|
8
|
+
* POST /studies/:id/run {mode:'host-dry-run'|'f2-real-host'}
|
|
9
|
+
* → gate → append CONSOLE_RUN_ACCEPTED {runId, mode, pid}
|
|
10
|
+
* → 202 Accepted {runId, studyId, status:'ACCEPTED'}
|
|
11
|
+
* → detached work appends CONSOLE_RUN_FINISHED | CONSOLE_RUN_FAILED
|
|
12
|
+
*
|
|
13
|
+
* - one unfinished console run per study: a second POST answers 409
|
|
14
|
+
* run_in_progress (no new event, no work);
|
|
15
|
+
* - duplicate/repeat requests are therefore explicitly refused, never
|
|
16
|
+
* silently duplicated (idempotency by refusal);
|
|
17
|
+
* - a server restart leaves an ACCEPTED without a terminal event; startup
|
|
18
|
+
* recovery appends CONSOLE_RUN_ABANDONED {reason:'server_restart'} for
|
|
19
|
+
* runs whose accepting process is gone — an abandoned attempt is
|
|
20
|
+
* RESULT_UNKNOWN and is NEVER auto-retried;
|
|
21
|
+
* - f2-real-host additionally requires an APPROVED Stage R authorization
|
|
22
|
+
* bound to the frozen study BEFORE acceptance (403 otherwise) — and the
|
|
23
|
+
* runner re-verifies the same authority under the study lock per unit
|
|
24
|
+
* (this gate is a necessary condition, the runner is the authority).
|
|
25
|
+
*
|
|
26
|
+
* Zero-model-call guarantee: host dry-run only probes the local OpenClaw CLI
|
|
27
|
+
* (`--version`), reads the frozen study and the authorization record, and
|
|
28
|
+
* creates+removes a probe directory. No provider is contacted. The CLI probe
|
|
29
|
+
* delegates to the lab's read-only `probeOpenClawCli()` (the same local
|
|
30
|
+
* `--version` process transport creation uses) — this console module itself
|
|
31
|
+
* never spawns a process and never passes an executable path derived from
|
|
32
|
+
* study data.
|
|
33
|
+
*/
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
37
|
+
import { pathToFileURL } from 'node:url';
|
|
38
|
+
import { canonicalPathSync, isJsonObject, isPathInside, isSamePath, preflightCheck, readStudyJson, } from './examiner-executor.js';
|
|
39
|
+
export const CONSOLE_RUN_SCHEMA = 'examiner-console-run.v1';
|
|
40
|
+
export const CONSOLE_RUN_EVENT_TYPES = ['CONSOLE_RUN_ACCEPTED', 'CONSOLE_RUN_FINISHED', 'CONSOLE_RUN_FAILED', 'CONSOLE_RUN_ABANDONED'];
|
|
41
|
+
export const CONSOLE_RUN_MODES = ['host-dry-run', 'f2-real-host'];
|
|
42
|
+
/** rc-2: real runtime guard for an untrusted mode value (no `as` narrowing). */
|
|
43
|
+
function isConsoleRunMode(value) {
|
|
44
|
+
return CONSOLE_RUN_MODES.some((mode) => mode === value);
|
|
45
|
+
}
|
|
46
|
+
/** Directory-name allowlist for the recovery scan (same charset as study handles). */
|
|
47
|
+
const STUDY_DIR_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
48
|
+
// ── Secure events.jsonl access (single-fd, identity-pinned) ─────────────────
|
|
49
|
+
/**
|
|
50
|
+
* P1-4: the study's event ledger is UNREADABLE for a reason the Owner must
|
|
51
|
+
* see — a corrupt or permission-blocked ledger is never silently treated as
|
|
52
|
+
* "no runs". Only a genuinely absent file (first-run condition) yields [].
|
|
53
|
+
*/
|
|
54
|
+
export class ConsoleLedgerUnreadableError extends Error {
|
|
55
|
+
code;
|
|
56
|
+
constructor(code, message) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.name = 'ConsoleLedgerUnreadableError';
|
|
59
|
+
this.code = code;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* SECURITY CONTRACT (P1 TOCTOU):
|
|
64
|
+
*
|
|
65
|
+
* `events.jsonl` is the single source of truth for console runs. Every
|
|
66
|
+
* read/write goes through one file-descriptor session:
|
|
67
|
+
*
|
|
68
|
+
* 1. the study directory is resolved canonically and proven to live inside
|
|
69
|
+
* the WRITABLE root and outside the HISTORICAL root;
|
|
70
|
+
* 2. its directory identity (dev+ino) is captured BEFORE the open;
|
|
71
|
+
* 3. on Linux the directory itself is opened (O_DIRECTORY|O_NOFOLLOW) and the
|
|
72
|
+
* ledger is opened RELATIVE to that held fd via `/proc/self/fd/<n>`, so the
|
|
73
|
+
* kernel resolves the parent atomically — a path swap cannot redirect it;
|
|
74
|
+
* 4. the ledger is opened with O_NOFOLLOW (no final-component link) and
|
|
75
|
+
* created exclusively (O_CREAT|O_EXCL) when absent;
|
|
76
|
+
* 5. the FILE is fstat-ed and must be a regular file, and the DIRECTORY
|
|
77
|
+
* identity is re-verified through the held fd;
|
|
78
|
+
* 6. the same fd performs the read/write (no path re-open), and writes loop
|
|
79
|
+
* until the whole payload is stored;
|
|
80
|
+
* 7. on platforms without `/proc/self/fd` (Windows/macOS) the directory
|
|
81
|
+
* identity is re-stat-ed after the open and the file's canonical path must
|
|
82
|
+
* still be exactly `<studyReal>/events.jsonl` BEFORE any read/write —
|
|
83
|
+
* a swap is refused, never followed. This is the documented weaker
|
|
84
|
+
* mechanism; see the class comment on residual windows.
|
|
85
|
+
*
|
|
86
|
+
* Failure classes stay distinct and fail-closed: absent (only legitimate
|
|
87
|
+
* first-run), corrupt, permission denied, symlink/junction, identity change.
|
|
88
|
+
*/
|
|
89
|
+
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
|
|
90
|
+
const O_DIRECTORY = typeof fs.constants.O_DIRECTORY === 'number' ? fs.constants.O_DIRECTORY : 0;
|
|
91
|
+
const LEDGER_READ_CHUNK = 64 * 1024;
|
|
92
|
+
function errorCode(error) {
|
|
93
|
+
return error.code ?? 'unknown';
|
|
94
|
+
}
|
|
95
|
+
function identityOf(target) {
|
|
96
|
+
try {
|
|
97
|
+
const stat = fs.statSync(target, { bigint: true });
|
|
98
|
+
return { dev: stat.dev, ino: stat.ino };
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function sameIdentity(a, b) {
|
|
105
|
+
return a !== null && b !== null && a.dev === b.dev && a.ino === b.ino;
|
|
106
|
+
}
|
|
107
|
+
function ledgerUnreadable(message) {
|
|
108
|
+
return new ConsoleLedgerUnreadableError('ledger_unreadable', message);
|
|
109
|
+
}
|
|
110
|
+
/** Resolve + prove the study directory boundary (fail-closed, never string-only). */
|
|
111
|
+
function resolveLedgerStudyDir(roots, studyDir, mode) {
|
|
112
|
+
const studyReal = canonicalPathSync(studyDir);
|
|
113
|
+
if (!studyReal)
|
|
114
|
+
throw ledgerUnreadable('the study directory cannot be resolved to a canonical location (fail-closed)');
|
|
115
|
+
// Identity, not just prefix: a directory swapped for a SIBLING inside the
|
|
116
|
+
// writable root still resolves to a different basename. The console only
|
|
117
|
+
// ever addresses `<root>/<studyId>`, so any mismatch is a swap, not a study.
|
|
118
|
+
if (path.basename(studyReal) !== path.basename(path.resolve(studyDir))) {
|
|
119
|
+
throw ledgerUnreadable('the study directory resolved to a different entry name — path swap refused (fail-closed)');
|
|
120
|
+
}
|
|
121
|
+
const historicalReal = canonicalPathSync(roots.historical) ?? roots.historical;
|
|
122
|
+
// Historical research assets are READ-ONLY, never unwritable-unknown: the
|
|
123
|
+
// status projection must still show their recorded run history. Reads are
|
|
124
|
+
// allowed under the same identity/fstat contract; every append is refused.
|
|
125
|
+
if (isSamePath(historicalReal, studyReal) || isPathInside(historicalReal, studyReal)) {
|
|
126
|
+
if (mode === 'append')
|
|
127
|
+
throw ledgerUnreadable('the study directory resolves inside the historical research area — writes are refused (fail-closed)');
|
|
128
|
+
return studyReal;
|
|
129
|
+
}
|
|
130
|
+
const writableReal = canonicalPathSync(roots.writable) ?? roots.writable;
|
|
131
|
+
if (!isPathInside(writableReal, studyReal)) {
|
|
132
|
+
throw ledgerUnreadable('the study directory resolves outside the writable console root (fail-closed)');
|
|
133
|
+
}
|
|
134
|
+
return studyReal;
|
|
135
|
+
}
|
|
136
|
+
/** Linux-only atomic parent binding; null means "use the documented fallback". */
|
|
137
|
+
function openPinnedStudyDir(studyReal) {
|
|
138
|
+
if (process.platform !== 'linux')
|
|
139
|
+
return null;
|
|
140
|
+
try {
|
|
141
|
+
return fs.openSync(studyReal, fs.constants.O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
throw ledgerUnreadable(`the study directory cannot be opened safely (${errorCode(error)})`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function closeQuietly(fd) {
|
|
148
|
+
if (fd === null)
|
|
149
|
+
return;
|
|
150
|
+
try {
|
|
151
|
+
fs.closeSync(fd);
|
|
152
|
+
}
|
|
153
|
+
catch { /* already closed */ }
|
|
154
|
+
}
|
|
155
|
+
/** Remove ONLY a ledger this call created, and only through a verified parent. */
|
|
156
|
+
function discardCreatedLedger(dirFd, studyReal) {
|
|
157
|
+
try {
|
|
158
|
+
const expected = path.join(studyReal, 'events.jsonl');
|
|
159
|
+
if (dirFd !== null) {
|
|
160
|
+
fs.unlinkSync(`/proc/self/fd/${dirFd}/events.jsonl`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const real = canonicalPathSync(expected);
|
|
164
|
+
if (real && isSamePath(real, expected))
|
|
165
|
+
fs.unlinkSync(real);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// Leftover empty file is reported by the caller's fail-closed error.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Open the ledger for `read` or `append` under the identity contract above.
|
|
173
|
+
* Returns null ONLY when the ledger is absent in read mode (first-run case).
|
|
174
|
+
*/
|
|
175
|
+
function openLedger(roots, studyDir, mode) {
|
|
176
|
+
const studyReal = resolveLedgerStudyDir(roots, studyDir, mode);
|
|
177
|
+
const dirBefore = identityOf(studyReal);
|
|
178
|
+
if (!dirBefore)
|
|
179
|
+
throw ledgerUnreadable('the study directory identity cannot be established (fail-closed)');
|
|
180
|
+
const dirFd = openPinnedStudyDir(studyReal);
|
|
181
|
+
const target = dirFd !== null ? `/proc/self/fd/${dirFd}/events.jsonl` : path.join(studyReal, 'events.jsonl');
|
|
182
|
+
let fd = null;
|
|
183
|
+
let created = false;
|
|
184
|
+
try {
|
|
185
|
+
if (mode === 'read') {
|
|
186
|
+
try {
|
|
187
|
+
fd = fs.openSync(target, fs.constants.O_RDONLY | O_NOFOLLOW);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
if (errorCode(error) === 'ENOENT') {
|
|
191
|
+
closeQuietly(dirFd);
|
|
192
|
+
return null; // genuinely absent ledger — the only empty case
|
|
193
|
+
}
|
|
194
|
+
throw ledgerUnreadable(`the study event ledger cannot be opened (${errorCode(error)})`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
try {
|
|
199
|
+
fd = fs.openSync(target, fs.constants.O_WRONLY | fs.constants.O_APPEND | O_NOFOLLOW);
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
if (errorCode(error) !== 'ENOENT') {
|
|
203
|
+
throw ledgerUnreadable(`the study event ledger cannot be opened for append (${errorCode(error)})`);
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
fd = fs.openSync(target, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_APPEND | O_NOFOLLOW);
|
|
207
|
+
created = true;
|
|
208
|
+
}
|
|
209
|
+
catch (createError) {
|
|
210
|
+
throw ledgerUnreadable(`the study event ledger cannot be created exclusively (${errorCode(createError)})`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const fileStat = fs.fstatSync(fd, { bigint: true });
|
|
215
|
+
if (!fileStat.isFile()) {
|
|
216
|
+
throw ledgerUnreadable('the study event ledger is not a regular file (symlink or special file refused)');
|
|
217
|
+
}
|
|
218
|
+
if (dirFd !== null) {
|
|
219
|
+
const dirStat = fs.fstatSync(dirFd, { bigint: true });
|
|
220
|
+
if (!dirStat.isDirectory() || !sameIdentity({ dev: dirStat.dev, ino: dirStat.ino }, dirBefore)) {
|
|
221
|
+
throw ledgerUnreadable('the study directory identity changed while the ledger was opened (fail-closed)');
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
// Documented fallback (Windows/macOS): identity re-check + exact canonical
|
|
226
|
+
// path of the ledger, evaluated AFTER the open and BEFORE any read/write.
|
|
227
|
+
if (!sameIdentity(identityOf(studyReal), dirBefore)) {
|
|
228
|
+
throw ledgerUnreadable('the study directory identity changed while the ledger was opened (fail-closed)');
|
|
229
|
+
}
|
|
230
|
+
const fileReal = canonicalPathSync(path.join(studyReal, 'events.jsonl'));
|
|
231
|
+
if (!fileReal || !isSamePath(fileReal, path.join(studyReal, 'events.jsonl'))) {
|
|
232
|
+
throw ledgerUnreadable('the study event ledger path resolves elsewhere (symlink or junction refused)');
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { fd, dirFd, created };
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
if (created)
|
|
239
|
+
discardCreatedLedger(dirFd, studyReal);
|
|
240
|
+
closeQuietly(fd);
|
|
241
|
+
closeQuietly(dirFd);
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/** Read the WHOLE ledger through the verified fd (no path re-open). */
|
|
246
|
+
function readAllFromFd(fd) {
|
|
247
|
+
const chunks = [];
|
|
248
|
+
const buffer = Buffer.alloc(LEDGER_READ_CHUNK);
|
|
249
|
+
let position = 0;
|
|
250
|
+
for (;;) {
|
|
251
|
+
const readBytes = fs.readSync(fd, buffer, 0, buffer.length, position);
|
|
252
|
+
if (readBytes === 0)
|
|
253
|
+
break;
|
|
254
|
+
chunks.push(Buffer.from(buffer.subarray(0, readBytes)));
|
|
255
|
+
position += readBytes;
|
|
256
|
+
}
|
|
257
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
258
|
+
}
|
|
259
|
+
/** Write the WHOLE payload; a short write is retried, zero progress fails closed. */
|
|
260
|
+
function writeAllToFd(fd, text) {
|
|
261
|
+
const payload = Buffer.from(text, 'utf8');
|
|
262
|
+
let written = 0;
|
|
263
|
+
while (written < payload.length) {
|
|
264
|
+
const chunk = fs.writeSync(fd, payload, written, payload.length - written);
|
|
265
|
+
if (chunk <= 0)
|
|
266
|
+
throw new Error('the study event ledger write made no progress (fail-closed)');
|
|
267
|
+
written += chunk;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Remove the dry-run probe directory ONLY when it is still the exact plain
|
|
272
|
+
* directory this run created (identity dev+ino), still resolves inside the
|
|
273
|
+
* same study directory, and the study directory itself has not been swapped.
|
|
274
|
+
* Any unprovable case keeps the residue and reports why — an unknown path is
|
|
275
|
+
* never deleted.
|
|
276
|
+
*/
|
|
277
|
+
export function removeProbeDirIfStillOwned(probe, study) {
|
|
278
|
+
let stat;
|
|
279
|
+
try {
|
|
280
|
+
stat = fs.lstatSync(probe.path);
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
return { removed: false, reason: `the probe path cannot be stat-ed (${errorCode(error)})` };
|
|
284
|
+
}
|
|
285
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
286
|
+
return { removed: false, reason: 'the probe path is no longer a plain directory' };
|
|
287
|
+
}
|
|
288
|
+
if (!sameIdentity(identityOf(probe.path), { dev: probe.dev, ino: probe.ino })) {
|
|
289
|
+
return { removed: false, reason: 'the probe directory identity changed' };
|
|
290
|
+
}
|
|
291
|
+
if (!sameIdentity(identityOf(study.real), { dev: study.dev, ino: study.ino })) {
|
|
292
|
+
return { removed: false, reason: 'the study directory identity changed' };
|
|
293
|
+
}
|
|
294
|
+
const probeReal = canonicalPathSync(probe.path);
|
|
295
|
+
if (!probeReal || !isPathInside(study.real, probeReal)) {
|
|
296
|
+
return { removed: false, reason: 'the probe directory no longer resolves inside the study' };
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
fs.rmSync(probe.path, { recursive: true, force: true });
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
return { removed: false, reason: `removal failed (${errorCode(error)})` };
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
fs.lstatSync(probe.path);
|
|
306
|
+
return { removed: false, reason: 'the probe path still exists after removal' };
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
if (errorCode(error) === 'ENOENT')
|
|
310
|
+
return { removed: true };
|
|
311
|
+
return { removed: false, reason: `post-removal verification failed (${errorCode(error)})` };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/** Safe ledger text reader: null ONLY when the ledger is legitimately absent. */
|
|
315
|
+
function readLedgerText(roots, studyDir) {
|
|
316
|
+
const opened = openLedger(roots, studyDir, 'read');
|
|
317
|
+
if (!opened)
|
|
318
|
+
return null;
|
|
319
|
+
try {
|
|
320
|
+
return readAllFromFd(opened.fd);
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
throw new ConsoleLedgerUnreadableError('ledger_unreadable', `the study event ledger could not be read (${error.message})`);
|
|
324
|
+
}
|
|
325
|
+
finally {
|
|
326
|
+
closeQuietly(opened.fd);
|
|
327
|
+
closeQuietly(opened.dirFd);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/** Append one console-run lifecycle event to the study's events ledger. */
|
|
331
|
+
export function appendConsoleRunEvent(roots, studyDir, event) {
|
|
332
|
+
const opened = openLedger(roots, studyDir, 'append');
|
|
333
|
+
if (!opened)
|
|
334
|
+
throw ledgerUnreadable('the study event ledger could not be opened for append (fail-closed)');
|
|
335
|
+
try {
|
|
336
|
+
writeAllToFd(opened.fd, `${JSON.stringify(event)}\n`);
|
|
337
|
+
}
|
|
338
|
+
finally {
|
|
339
|
+
closeQuietly(opened.fd);
|
|
340
|
+
closeQuietly(opened.dirFd);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/** True when the LATEST RUN_STARTED has no following RUN_FINISHED. */
|
|
344
|
+
function offlineExecutionInFlight(roots, studyDir) {
|
|
345
|
+
const text = readLedgerText(roots, studyDir);
|
|
346
|
+
if (text === null)
|
|
347
|
+
return false;
|
|
348
|
+
let started = false;
|
|
349
|
+
let finishedAfterStart = false;
|
|
350
|
+
const lines = text.split('\n').filter(Boolean);
|
|
351
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
352
|
+
const line = lines[index] ?? '';
|
|
353
|
+
let row;
|
|
354
|
+
try {
|
|
355
|
+
row = JSON.parse(line);
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
// A corrupt ledger is NEVER "no offline execution": refuse it here too.
|
|
359
|
+
throw new ConsoleLedgerUnreadableError('ledger_corrupt', `the study event ledger is corrupt at line ${index + 1} (JSON parse failed: ${error.message}) — refuse rather than treat as idle`);
|
|
360
|
+
}
|
|
361
|
+
if (!isJsonObject(row) || typeof row.type !== 'string')
|
|
362
|
+
continue;
|
|
363
|
+
// A finish from an EARLIER run must not mask a later in-flight run: only
|
|
364
|
+
// a RUN_FINISHED AFTER the latest RUN_STARTED settles the current one.
|
|
365
|
+
if (row.type === 'RUN_STARTED') {
|
|
366
|
+
started = true;
|
|
367
|
+
finishedAfterStart = false;
|
|
368
|
+
}
|
|
369
|
+
else if (row.type === 'RUN_FINISHED' && started) {
|
|
370
|
+
finishedAfterStart = true;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return started && !finishedAfterStart;
|
|
374
|
+
}
|
|
375
|
+
export class OfflineRunInFlightError extends Error {
|
|
376
|
+
constructor() {
|
|
377
|
+
super('an offline execution is currently in progress for this study (RUN_STARTED without RUN_FINISHED)');
|
|
378
|
+
this.name = 'OfflineRunInFlightError';
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
export function mintRunId() {
|
|
382
|
+
return `RUN-C-${randomUUID().toUpperCase()}`;
|
|
383
|
+
}
|
|
384
|
+
/** Derive the console-run states from the study ledger (never stored separately). */
|
|
385
|
+
export function readConsoleRuns(roots, studyDir) {
|
|
386
|
+
const text = readLedgerText(roots, studyDir);
|
|
387
|
+
if (text === null)
|
|
388
|
+
return [];
|
|
389
|
+
const lines = text.split('\n').filter(Boolean);
|
|
390
|
+
const runs = new Map();
|
|
391
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
392
|
+
const line = lines[index] ?? '';
|
|
393
|
+
let parsed;
|
|
394
|
+
try {
|
|
395
|
+
parsed = JSON.parse(line);
|
|
396
|
+
}
|
|
397
|
+
catch (error) {
|
|
398
|
+
throw new ConsoleLedgerUnreadableError('ledger_corrupt', `the study event ledger is corrupt at line ${index + 1} (JSON parse failed: ${error.message}) — refuse rather than pretend it is empty`);
|
|
399
|
+
}
|
|
400
|
+
if (!isJsonObject(parsed) || typeof parsed.type !== 'string' || !CONSOLE_RUN_EVENT_TYPES.includes(parsed.type))
|
|
401
|
+
continue;
|
|
402
|
+
if (parsed.schema !== CONSOLE_RUN_SCHEMA || typeof parsed.runId !== 'string' || typeof parsed.mode !== 'string')
|
|
403
|
+
continue;
|
|
404
|
+
// rc-2: an untrusted mode string is narrowed by a real guard, never `as`.
|
|
405
|
+
// A value outside the two supported modes is not a console-run event.
|
|
406
|
+
const mode = isConsoleRunMode(parsed.mode) ? parsed.mode : null;
|
|
407
|
+
if (mode === null)
|
|
408
|
+
continue;
|
|
409
|
+
const acceptedAt = typeof parsed.acceptedAt === 'string' ? parsed.acceptedAt : '';
|
|
410
|
+
const checks = Array.isArray(parsed.checks) ? parsed.checks : undefined;
|
|
411
|
+
const existing = runs.get(parsed.runId) ?? { runId: parsed.runId, mode, status: 'RUNNING', acceptedAt, finishedAt: null, outcome: null, reason: null };
|
|
412
|
+
if (parsed.type === 'CONSOLE_RUN_ACCEPTED') {
|
|
413
|
+
runs.set(parsed.runId, { ...existing, status: 'RUNNING', acceptedAt, finishedAt: null, outcome: null, reason: null });
|
|
414
|
+
}
|
|
415
|
+
else if (parsed.type === 'CONSOLE_RUN_FINISHED') {
|
|
416
|
+
runs.set(parsed.runId, { ...existing, status: 'FINISHED', finishedAt: typeof parsed.finishedAt === 'string' ? parsed.finishedAt : null, outcome: typeof parsed.outcome === 'string' ? parsed.outcome : null, reason: typeof parsed.reason === 'string' ? parsed.reason : null, ...(checks !== undefined ? { checks } : {}) });
|
|
417
|
+
}
|
|
418
|
+
else if (parsed.type === 'CONSOLE_RUN_FAILED') {
|
|
419
|
+
runs.set(parsed.runId, { ...existing, status: 'FAILED', finishedAt: typeof parsed.finishedAt === 'string' ? parsed.finishedAt : null, outcome: typeof parsed.outcome === 'string' ? parsed.outcome : null, reason: typeof parsed.reason === 'string' ? parsed.reason : null, ...(checks !== undefined ? { checks } : {}) });
|
|
420
|
+
}
|
|
421
|
+
else if (parsed.type === 'CONSOLE_RUN_ABANDONED') {
|
|
422
|
+
runs.set(parsed.runId, { ...existing, status: 'ABANDONED', finishedAt: typeof parsed.finishedAt === 'string' ? parsed.finishedAt : null, outcome: 'RESULT_UNKNOWN', reason: typeof parsed.reason === 'string' ? parsed.reason : 'unknown' });
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return [...runs.values()].sort((a, b) => (a.acceptedAt < b.acceptedAt ? -1 : 1));
|
|
426
|
+
}
|
|
427
|
+
export function currentConsoleRun(runs) {
|
|
428
|
+
return runs.find((run) => run.status === 'RUNNING') ?? null;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Study status projection (manifest + ledger settlement counts + report status).
|
|
432
|
+
* The DISPLAY projection tolerates single corrupt lines — the authoritative
|
|
433
|
+
* console-run reader refuses them and the route reports `ledgerProblem` — but
|
|
434
|
+
* it reads the ledger through the SAME secure fd boundary (never by path).
|
|
435
|
+
*/
|
|
436
|
+
export function studyStatusPayload(roots, studyDir) {
|
|
437
|
+
const manifest = readStudyJson(studyDir, 'examiner-manifest.json');
|
|
438
|
+
let events = [];
|
|
439
|
+
try {
|
|
440
|
+
const text = readLedgerText(roots, studyDir);
|
|
441
|
+
if (text !== null) {
|
|
442
|
+
events = text.trim().split('\n').filter(Boolean)
|
|
443
|
+
.map((line) => {
|
|
444
|
+
try {
|
|
445
|
+
const parsed = JSON.parse(line);
|
|
446
|
+
return isJsonObject(parsed) ? parsed : null;
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
})
|
|
452
|
+
.filter((row) => row !== null);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
catch (error) {
|
|
456
|
+
if (!(error instanceof ConsoleLedgerUnreadableError))
|
|
457
|
+
throw error;
|
|
458
|
+
// The status route surfaces the structured ledgerProblem; this projection
|
|
459
|
+
// must not turn "unreadable" into fabricated clean zeroes.
|
|
460
|
+
events = [];
|
|
461
|
+
}
|
|
462
|
+
const counts = {};
|
|
463
|
+
for (const event of events) {
|
|
464
|
+
if (typeof event.type !== 'string' || !event.type.startsWith('UNIT_FINISHED'))
|
|
465
|
+
continue;
|
|
466
|
+
const result = String(event.result ?? 'UNKNOWN');
|
|
467
|
+
counts[result] = (counts[result] ?? 0) + 1;
|
|
468
|
+
}
|
|
469
|
+
const target = isJsonObject(manifest.targetPrinciple) && typeof manifest.targetPrinciple.id === 'string'
|
|
470
|
+
? manifest.targetPrinciple.id
|
|
471
|
+
: null;
|
|
472
|
+
// A run is "finished" only when the LATEST RUN_STARTED has a following
|
|
473
|
+
// RUN_FINISHED. A finish from an earlier run must never make a later
|
|
474
|
+
// in-flight run look complete (the Owner would see "finished" and the page
|
|
475
|
+
// would stop polling while the run is still executing).
|
|
476
|
+
let runStarted = false;
|
|
477
|
+
let runFinished = false;
|
|
478
|
+
for (const event of events) {
|
|
479
|
+
if (event.type === 'RUN_STARTED') {
|
|
480
|
+
runStarted = true;
|
|
481
|
+
runFinished = false;
|
|
482
|
+
}
|
|
483
|
+
else if (event.type === 'RUN_FINISHED' && runStarted) {
|
|
484
|
+
runFinished = true;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
const reportExists = fs.existsSync(path.join(studyDir, 'behavior-report-v2.json'));
|
|
488
|
+
const report = reportExists ? readStudyJson(studyDir, 'behavior-report-v2.json') : null;
|
|
489
|
+
return {
|
|
490
|
+
studyId: path.basename(studyDir),
|
|
491
|
+
experimentMode: manifest.experimentMode,
|
|
492
|
+
interventionType: manifest.interventionType,
|
|
493
|
+
targetPrincipleId: target,
|
|
494
|
+
plannedPairs: manifest.plannedPairs,
|
|
495
|
+
runStarted,
|
|
496
|
+
runFinished,
|
|
497
|
+
unitStates: counts,
|
|
498
|
+
reportStatus: report ? report.status : null,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* P1-4: the whole acceptance transaction — read current runs, check the
|
|
503
|
+
* single-live constraint (async AND offline in-flight), verify the study is
|
|
504
|
+
* still writable, append CONSOLE_RUN_ACCEPTED — happens under the EXISTING
|
|
505
|
+
* lab study write lock, so two concurrent requests can never both pass the
|
|
506
|
+
* check. The lock guards ONLY the acceptance; the async execution itself
|
|
507
|
+
* runs unlocked (settlement appends race-free through the same lock the
|
|
508
|
+
* runner already takes for real executions).
|
|
509
|
+
*/
|
|
510
|
+
export class ConsoleRunInProgressError extends Error {
|
|
511
|
+
runId;
|
|
512
|
+
mode;
|
|
513
|
+
acceptedAt;
|
|
514
|
+
constructor(running) {
|
|
515
|
+
super(`console run ${running.runId} (${running.mode}) is still in progress`);
|
|
516
|
+
this.name = 'ConsoleRunInProgressError';
|
|
517
|
+
this.runId = running.runId;
|
|
518
|
+
this.mode = running.mode;
|
|
519
|
+
this.acceptedAt = running.acceptedAt;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
/** Load the lab's authoritative single-writer lock (dynamic — no package edge). */
|
|
523
|
+
async function loadStudyLock(labRoot) {
|
|
524
|
+
const ledgerUrl = pathToFileURL(path.join(labRoot, 'lib', 'challenge-ledger.mjs')).href;
|
|
525
|
+
const mod = (await import(ledgerUrl));
|
|
526
|
+
if (typeof mod.acquireStudyWriteLock !== 'function' || typeof mod.releaseStudyWriteLock !== 'function') {
|
|
527
|
+
throw new Error('the lab payload does not export the study write lock — refusing an unguarded ledger settlement');
|
|
528
|
+
}
|
|
529
|
+
return { acquireStudyWriteLock: mod.acquireStudyWriteLock, releaseStudyWriteLock: mod.releaseStudyWriteLock };
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Run `operation` under the SAME study write lock the offline runner holds.
|
|
533
|
+
* Acceptance, recovery and async settlement all use this one lock, so a
|
|
534
|
+
* terminal event can never land between another writer's check and append.
|
|
535
|
+
*/
|
|
536
|
+
async function withStudyWriteLock(input, operation) {
|
|
537
|
+
const lock = await loadStudyLock(input.labRoot);
|
|
538
|
+
lock.acquireStudyWriteLock(input.studyDir, { owner: input.owner });
|
|
539
|
+
try {
|
|
540
|
+
return await operation();
|
|
541
|
+
}
|
|
542
|
+
finally {
|
|
543
|
+
try {
|
|
544
|
+
lock.releaseStudyWriteLock(input.studyDir, { owner: input.owner });
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
// An unreleaseable lock stays on disk (crash-holder semantics); the
|
|
548
|
+
// lab's lock discipline governs recovery.
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
export async function acceptConsoleRun(roots, studyDir, inputs) {
|
|
553
|
+
const owner = `pd-console-accept-${process.pid}`;
|
|
554
|
+
return withStudyWriteLock({ labRoot: inputs.labRoot, studyDir, owner }, () => {
|
|
555
|
+
// Everything below is INSIDE the lock. A ledger that cannot be honestly
|
|
556
|
+
// read refuses the acceptance (P1-4B) — never an empty-arrays guess.
|
|
557
|
+
const runs = readConsoleRuns(roots, studyDir);
|
|
558
|
+
const running = currentConsoleRun(runs);
|
|
559
|
+
if (running)
|
|
560
|
+
throw new ConsoleRunInProgressError(running);
|
|
561
|
+
// A synchronous offline run in flight (RUN_STARTED without RUN_FINISHED)
|
|
562
|
+
// also refuses: one execution at a time per study.
|
|
563
|
+
if (offlineExecutionInFlight(roots, studyDir))
|
|
564
|
+
throw new OfflineRunInFlightError();
|
|
565
|
+
const runId = mintRunId();
|
|
566
|
+
const acceptedAt = new Date().toISOString();
|
|
567
|
+
appendConsoleRunEvent(roots, studyDir, {
|
|
568
|
+
type: 'CONSOLE_RUN_ACCEPTED', schema: CONSOLE_RUN_SCHEMA, runId, studyId: inputs.studyId,
|
|
569
|
+
mode: inputs.mode, acceptedAt, pid: process.pid, owner: 'pd-console',
|
|
570
|
+
});
|
|
571
|
+
return { runId, acceptedAt };
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Append an async run's TERMINAL event under the study write lock. Recovery
|
|
576
|
+
* (which also holds the lock) therefore either sees the terminal event and
|
|
577
|
+
* skips, or appends ABANDONED first and the later terminal event wins — an
|
|
578
|
+
* already-settled run can never be reported as ABANDONED.
|
|
579
|
+
*/
|
|
580
|
+
export async function appendConsoleRunEventLocked(input, event) {
|
|
581
|
+
await withStudyWriteLock({ labRoot: input.labRoot, studyDir: input.studyDir, owner: `pd-console-settle-${process.pid}` }, () => appendConsoleRunEvent(input.roots, input.studyDir, event));
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Offline→async half of the single-active contract (P1-3).
|
|
585
|
+
*
|
|
586
|
+
* The lab runner calls this UNDER the study write lock, BEFORE RUN_STARTED,
|
|
587
|
+
* attempt reservation or actor creation. The caller (route layer) supplies it
|
|
588
|
+
* so `examiner-runs` and `examiner-executor` never import each other.
|
|
589
|
+
*/
|
|
590
|
+
export function assertOfflineAdmissionAllowed(roots, studyDir) {
|
|
591
|
+
const running = currentConsoleRun(readConsoleRuns(roots, studyDir));
|
|
592
|
+
if (running)
|
|
593
|
+
throw new ConsoleRunInProgressError(running);
|
|
594
|
+
}
|
|
595
|
+
/** Best-effort liveness probe for the process that accepted a run. */
|
|
596
|
+
export function processAlive(pid) {
|
|
597
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
598
|
+
return false;
|
|
599
|
+
try {
|
|
600
|
+
process.kill(pid, 0);
|
|
601
|
+
return true;
|
|
602
|
+
}
|
|
603
|
+
catch (error) {
|
|
604
|
+
const { code } = error;
|
|
605
|
+
return code === 'EPERM';
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Startup recovery: walk the WRITABLE root only (historical assets are never
|
|
610
|
+
* console-run targets) and abandon every unfinished console run whose
|
|
611
|
+
* accepting process is gone. An accepting process that is still alive keeps
|
|
612
|
+
* its run (another console server may legitimately be serving the workspace).
|
|
613
|
+
*
|
|
614
|
+
* Path safety: only names the server itself enumerated that pass the
|
|
615
|
+
* study-handle charset allowlist, each directory must resolve canonically
|
|
616
|
+
* INSIDE the writable root, and every ledger access re-proves containment at
|
|
617
|
+
* the point of use — a symlinked/junctioned entry is skipped, never followed.
|
|
618
|
+
*/
|
|
619
|
+
export async function recoverUnfinishedConsoleRuns(roots, labRoot) {
|
|
620
|
+
const recovered = [];
|
|
621
|
+
const writableReal = canonicalPathSync(roots.writable);
|
|
622
|
+
if (!writableReal || !fs.existsSync(roots.writable))
|
|
623
|
+
return recovered;
|
|
624
|
+
const rootPrefix = writableReal.endsWith(path.sep) ? writableReal : `${writableReal}${path.sep}`;
|
|
625
|
+
const entries = fs.readdirSync(roots.writable, { withFileTypes: true })
|
|
626
|
+
.filter((entry) => entry.isDirectory() && STUDY_DIR_NAME_PATTERN.test(entry.name));
|
|
627
|
+
for (const entry of entries) {
|
|
628
|
+
const dirReal = canonicalPathSync(path.join(roots.writable, entry.name));
|
|
629
|
+
if (!dirReal || !dirReal.startsWith(rootPrefix))
|
|
630
|
+
continue;
|
|
631
|
+
// P1-4B: an unreadable ledger in ONE study must not hide (or be mistaken
|
|
632
|
+
// for "no runs" in) the others — skip it loudly and keep scanning.
|
|
633
|
+
let runs;
|
|
634
|
+
try {
|
|
635
|
+
runs = readConsoleRuns(roots, dirReal);
|
|
636
|
+
}
|
|
637
|
+
catch (error) {
|
|
638
|
+
if (error instanceof ConsoleLedgerUnreadableError) {
|
|
639
|
+
console.error(`[pd-console] examiner run recovery: study ${entry.name} ledger ${error.code} — skipped (not treated as no-runs): ${error.message}`);
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
throw error;
|
|
643
|
+
}
|
|
644
|
+
const running = currentConsoleRun(runs);
|
|
645
|
+
if (!running)
|
|
646
|
+
continue;
|
|
647
|
+
// The accepting pid is only recoverable from the raw ACCEPTED event.
|
|
648
|
+
try {
|
|
649
|
+
const text = readLedgerText(roots, dirReal);
|
|
650
|
+
if (text === null)
|
|
651
|
+
continue;
|
|
652
|
+
const accepted = text.split('\n')
|
|
653
|
+
.filter(Boolean)
|
|
654
|
+
.map((line) => JSON.parse(line))
|
|
655
|
+
.filter((row) => isJsonObject(row) && row.type === 'CONSOLE_RUN_ACCEPTED' && row.runId === running.runId)
|
|
656
|
+
.pop();
|
|
657
|
+
const pid = typeof accepted?.pid === 'number' ? accepted.pid : 0;
|
|
658
|
+
if (processAlive(pid))
|
|
659
|
+
continue;
|
|
660
|
+
// The check-and-append below runs under the SAME lock the async
|
|
661
|
+
// settlement takes. Re-reading inside the lock proves the run is still
|
|
662
|
+
// unfinished: a terminal event that landed in between wins, and two
|
|
663
|
+
// concurrent recoveries cannot both append ABANDONED.
|
|
664
|
+
await withStudyWriteLock({ labRoot, studyDir: dirReal, owner: `pd-console-recover-${process.pid}` }, () => {
|
|
665
|
+
const fresh = currentConsoleRun(readConsoleRuns(roots, dirReal));
|
|
666
|
+
if (!fresh || fresh.runId !== running.runId)
|
|
667
|
+
return;
|
|
668
|
+
appendConsoleRunEvent(roots, dirReal, {
|
|
669
|
+
type: 'CONSOLE_RUN_ABANDONED',
|
|
670
|
+
schema: CONSOLE_RUN_SCHEMA,
|
|
671
|
+
runId: running.runId,
|
|
672
|
+
studyId: entry.name,
|
|
673
|
+
mode: running.mode,
|
|
674
|
+
acceptedAt: running.acceptedAt,
|
|
675
|
+
pid,
|
|
676
|
+
owner: 'pd-console',
|
|
677
|
+
finishedAt: new Date().toISOString(),
|
|
678
|
+
outcome: 'RESULT_UNKNOWN',
|
|
679
|
+
reason: 'server_restart — the accepting process is gone; the attempt stays RESULT_UNKNOWN and is never auto-retried',
|
|
680
|
+
});
|
|
681
|
+
recovered.push({ studyId: entry.name, runId: running.runId });
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
catch (error) {
|
|
685
|
+
if (error.name === 'StudyLockError') {
|
|
686
|
+
console.error(`[pd-console] examiner run recovery: study ${entry.name} is locked by another writer — skipped (that writer settles it)`);
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
// An unreadable ledger is reported by the status route; recovery skips it.
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return recovered;
|
|
693
|
+
}
|
|
694
|
+
async function readStageR(labRoot, studyDir) {
|
|
695
|
+
try {
|
|
696
|
+
const url = pathToFileURL(path.join(labRoot, 'lib', 'challenge-stage-r-gate.mjs')).href;
|
|
697
|
+
const mod = (await import(url));
|
|
698
|
+
const record = mod.readStageRAuthorization(studyDir);
|
|
699
|
+
return record ? { present: true, record, problem: null } : { present: false, record: null, problem: 'run-authorization.json missing or unreadable' };
|
|
700
|
+
}
|
|
701
|
+
catch (error) {
|
|
702
|
+
return { present: false, record: null, problem: `the Stage R gate module could not be loaded (${error.message})` };
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
/** Closed extraction of the fields the dry-run/gate surface needs (rc-2). */
|
|
706
|
+
function stageRFacts(record) {
|
|
707
|
+
if (!record)
|
|
708
|
+
return { status: null, executionMode: null, revoked: false, halted: false, model: null, validUntil: null };
|
|
709
|
+
const identity = isJsonObject(record.requestedActorIdentity) ? record.requestedActorIdentity : {};
|
|
710
|
+
return {
|
|
711
|
+
status: typeof record.status === 'string' ? record.status : null,
|
|
712
|
+
executionMode: typeof record.executionMode === 'string' ? record.executionMode : null,
|
|
713
|
+
revoked: record.revoked === true,
|
|
714
|
+
halted: record.halted === true,
|
|
715
|
+
model: typeof identity.model === 'string' ? identity.model : null,
|
|
716
|
+
validUntil: typeof record.validUntil === 'string' ? record.validUntil : null,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* The host dry-run: everything Gate 2 can check WITHOUT a model call. It is
|
|
721
|
+
* deliberately a checklist, not a pass/fail black box — the Owner sees exactly
|
|
722
|
+
* which prerequisite is missing before real execution can be requested.
|
|
723
|
+
*/
|
|
724
|
+
export async function hostDryRunPass(input) {
|
|
725
|
+
const { modules, labRoot, studyDir, context } = input;
|
|
726
|
+
const checks = [];
|
|
727
|
+
const check = (entry) => {
|
|
728
|
+
checks.push({ id: entry.id, severity: entry.severity ?? 'blocker', ok: entry.ok, detail: entry.detail });
|
|
729
|
+
};
|
|
730
|
+
// 1. The frozen offline contract must still verify.
|
|
731
|
+
try {
|
|
732
|
+
const preflight = preflightCheck(modules, studyDir, context);
|
|
733
|
+
check({ id: 'offline_contract_preflight', ok: preflight.ok, detail: preflight.ok
|
|
734
|
+
? 'the frozen study contract verified (manifest/HEPS/RuleCode/pairs/bindings)'
|
|
735
|
+
: `preflight blockers: ${preflight.blockers.map((b) => String(b.id ?? 'unknown')).join(', ')}` });
|
|
736
|
+
}
|
|
737
|
+
catch (error) {
|
|
738
|
+
check({ id: 'offline_contract_preflight', ok: false, detail: `preflight could not run: ${error.message}` });
|
|
739
|
+
}
|
|
740
|
+
// 2. Stage R authorization — the SAME authoritative verifier the F2 request
|
|
741
|
+
// uses (schema, stage/status, the full study binding, the validity
|
|
742
|
+
// window, the retirement registry, revocation/halted and probe evidence).
|
|
743
|
+
// A dry-run must never report readiness on a weaker subset than the gate.
|
|
744
|
+
let gate;
|
|
745
|
+
try {
|
|
746
|
+
// eslint-disable-next-line @typescript-eslint/no-use-before-define -- hoisted declaration (the gate section follows the dry-run section)
|
|
747
|
+
gate = await f2AuthorizationGate(labRoot, studyDir);
|
|
748
|
+
}
|
|
749
|
+
catch (error) {
|
|
750
|
+
gate = { authorized: false, reasons: [`the Stage R gate could not run (${error.message})`], record: null };
|
|
751
|
+
}
|
|
752
|
+
const facts = stageRFacts(gate.record);
|
|
753
|
+
const authOk = gate.authorized;
|
|
754
|
+
check({ id: 'stage_r_authorization', ok: authOk, detail: authOk
|
|
755
|
+
? `APPROVED F2-real-host authorization verified by the lab Stage R gate (model ${facts.model ?? 'unspecified'}, validUntil ${facts.validUntil ?? 'unbounded'})`
|
|
756
|
+
: gate.reasons.length > 0
|
|
757
|
+
? `Stage R gate refused: ${gate.reasons.join('; ')}`
|
|
758
|
+
: 'Stage R gate refused the authorization record' });
|
|
759
|
+
// 3. OpenClaw CLI probe — a LOCAL `--version` process, never a model call,
|
|
760
|
+
// delegated to the lab's read-only probe with NO executable argument
|
|
761
|
+
// (fixed default 'openclaw'; untrusted study data never picks binaries).
|
|
762
|
+
let cliVersion = null;
|
|
763
|
+
let hostOk = false;
|
|
764
|
+
let hostNote;
|
|
765
|
+
try {
|
|
766
|
+
const hostUrl = pathToFileURL(path.join(labRoot, 'lib', 'challenge-openclaw-host.mjs')).href;
|
|
767
|
+
const hostMod = (await import(hostUrl));
|
|
768
|
+
if (typeof hostMod.probeOpenClawCli !== 'function') {
|
|
769
|
+
hostNote = 'the OpenClaw host module in this lab payload does not export the read-only CLI probe';
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
const { ok, cliVersion: probedVersion, reason } = hostMod.probeOpenClawCli();
|
|
773
|
+
hostOk = ok === true;
|
|
774
|
+
cliVersion = probedVersion;
|
|
775
|
+
hostNote = hostOk ? `OpenClaw CLI reachable (${cliVersion})` : `OpenClaw CLI not reachable: ${reason ?? 'unknown reason'}`;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
catch (error) {
|
|
779
|
+
hostNote = `the OpenClaw CLI probe could not run (${error.message})`;
|
|
780
|
+
}
|
|
781
|
+
check({ id: 'host_cli_probe', ok: hostOk, detail: hostNote });
|
|
782
|
+
// 4. Isolation workspace probe: per-arm round dirs must be creatable inside
|
|
783
|
+
// the writable study root. P1-2: EXCLUSIVE mkdtemp (random suffix, never a
|
|
784
|
+
// fixed name), the study directory is re-verified through the ledger
|
|
785
|
+
// boundary first, the created directory's identity is captured, the marker
|
|
786
|
+
// is created exclusively with O_NOFOLLOW, and cleanup removes ONLY the
|
|
787
|
+
// directory this run created while its identity and containment still
|
|
788
|
+
// verify. If identity cannot be proven the residue is KEPT and reported.
|
|
789
|
+
let isolationOk = false;
|
|
790
|
+
let isolationNote;
|
|
791
|
+
let probeDir = null;
|
|
792
|
+
try {
|
|
793
|
+
const studyReal = context ? resolveLedgerStudyDir(context.roots, studyDir, 'append') : canonicalPathSync(studyDir);
|
|
794
|
+
const studyIdentity = studyReal ? identityOf(studyReal) : null;
|
|
795
|
+
if (!studyReal || !studyIdentity)
|
|
796
|
+
throw new Error('the study directory identity cannot be established (fail-closed)');
|
|
797
|
+
probeDir = fs.mkdtempSync(path.join(studyReal, '.console-host-dry-run-probe-'));
|
|
798
|
+
const probeStat = fs.lstatSync(probeDir);
|
|
799
|
+
if (probeStat.isSymbolicLink() || !probeStat.isDirectory())
|
|
800
|
+
throw new Error('the probe path is not a plain directory (fail-closed)');
|
|
801
|
+
const probeIdentity = identityOf(probeDir);
|
|
802
|
+
if (!probeIdentity)
|
|
803
|
+
throw new Error('the probe directory identity cannot be established (fail-closed)');
|
|
804
|
+
const probeReal = canonicalPathSync(probeDir);
|
|
805
|
+
if (!probeReal || !isPathInside(studyReal, probeReal)) {
|
|
806
|
+
throw new Error('the probe directory resolved outside the study — refusal, never cleanup of an unknown path');
|
|
807
|
+
}
|
|
808
|
+
const marker = path.join(probeDir, 'round-write.txt');
|
|
809
|
+
const markerFd = fs.openSync(marker, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW);
|
|
810
|
+
try {
|
|
811
|
+
const markerStat = fs.fstatSync(markerFd, { bigint: true });
|
|
812
|
+
if (!markerStat.isFile())
|
|
813
|
+
throw new Error('the probe marker is not a regular file (fail-closed)');
|
|
814
|
+
writeAllToFd(markerFd, 'probe');
|
|
815
|
+
}
|
|
816
|
+
finally {
|
|
817
|
+
closeQuietly(markerFd);
|
|
818
|
+
}
|
|
819
|
+
const removal = removeProbeDirIfStillOwned({ path: probeDir, dev: probeIdentity.dev, ino: probeIdentity.ino }, { real: studyReal, dev: studyIdentity.dev, ino: studyIdentity.ino });
|
|
820
|
+
if (!removal.removed)
|
|
821
|
+
throw new Error(`the probe directory was NOT removed (${removal.reason ?? 'unknown reason'}) — residue kept, never an unknown-path delete`);
|
|
822
|
+
isolationOk = !fs.existsSync(probeDir);
|
|
823
|
+
isolationNote = isolationOk ? 'round-directory creation verified inside the writable study root (exclusive temp dir created + removed, no residue)' : 'the probe directory could not be removed — residue left behind';
|
|
824
|
+
}
|
|
825
|
+
catch (error) {
|
|
826
|
+
const residue = probeDir !== null && fs.existsSync(probeDir) ? ` (residue kept: ${probeDir})` : '';
|
|
827
|
+
isolationNote = `round-directory creation failed: ${error.message}${residue}`;
|
|
828
|
+
}
|
|
829
|
+
check({ id: 'isolation_workspace_probe', ok: isolationOk, detail: isolationNote });
|
|
830
|
+
// 5. Evidence ceiling (informational — the capability table is frozen).
|
|
831
|
+
check({ id: 'injection_capability_ceiling', ok: true, severity: 'info', detail: 'OpenClaw prompt-delivery evidence ceiling is SUBMITTED (HOST_MAX_PROVABLE_LEVEL) — full-conclusion minimum CONTEXT_PRESENT can never be claimed from an OpenClaw prompt delivery' });
|
|
832
|
+
// 6. The dry-run's own zero-model-call guarantee.
|
|
833
|
+
check({ id: 'model_calls_zero', ok: true, severity: 'info', detail: 'this dry-run performs zero model/provider calls by construction (local CLI version probe + filesystem checks only)' });
|
|
834
|
+
return {
|
|
835
|
+
ok: checks.every((entry) => entry.severity === 'info' || entry.ok),
|
|
836
|
+
checks,
|
|
837
|
+
authorization: {
|
|
838
|
+
present: gate.record !== null,
|
|
839
|
+
status: facts.status,
|
|
840
|
+
executionMode: facts.executionMode,
|
|
841
|
+
model: facts.model,
|
|
842
|
+
note: authOk ? 'authorized for F2 real-host execution' : 'NOT authorized — see the stage_r_authorization check',
|
|
843
|
+
},
|
|
844
|
+
hostProbe: { cliVersion, note: hostNote },
|
|
845
|
+
modelCalls: 0,
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
// ── F2 real-host authorization gate ─────────────────────────────────────────
|
|
849
|
+
/** Read a JSON-object file or throw a clean refusal (rc-1/rc-3). */
|
|
850
|
+
function readJsonObjectFileStrict(file) {
|
|
851
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
852
|
+
if (!isJsonObject(parsed))
|
|
853
|
+
throw new Error(`${path.basename(file)} is not a JSON object`);
|
|
854
|
+
return parsed;
|
|
855
|
+
}
|
|
856
|
+
function hashOfCanonicalJson(value) {
|
|
857
|
+
// Canonical JSON hashing identical to the lab's hashOfCanonical (sorted
|
|
858
|
+
// keys, no whitespace). Kept local so the gate does not import freeze
|
|
859
|
+
// machinery beyond the gate module itself.
|
|
860
|
+
const canonical = (input) => {
|
|
861
|
+
if (Array.isArray(input))
|
|
862
|
+
return input.map(canonical);
|
|
863
|
+
if (isJsonObject(input)) {
|
|
864
|
+
const entries = Object.entries(input).filter(([, v]) => v !== undefined);
|
|
865
|
+
return Object.fromEntries(entries.sort(([a], [b]) => (a < b ? -1 : 1)).map(([k, v]) => [k, canonical(v)]));
|
|
866
|
+
}
|
|
867
|
+
return input;
|
|
868
|
+
};
|
|
869
|
+
return createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex');
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* P1-5: the console gate REUSES the lab's authoritative Stage R verifier
|
|
873
|
+
* (verifyStageRAuthorization) instead of a hand-rolled subset — schema,
|
|
874
|
+
* stage/status, the full study binding (studyId/suiteHash/mappingHash/
|
|
875
|
+
* graderHash/studyPlanHash), arms/cases/repetitions coverage, attemptCap,
|
|
876
|
+
* requestCap, the validity window, the retirement registry, revocation/
|
|
877
|
+
* halted, requestedActorIdentity and the F2 real-host probe evidence are ALL
|
|
878
|
+
* enforced. A record that is merely status=APPROVED fails every missing
|
|
879
|
+
* binding it does not carry. The runner re-verifies the same authority per
|
|
880
|
+
* unit under the study lock — this gate is a necessary condition, the runner
|
|
881
|
+
* is the authority.
|
|
882
|
+
*/
|
|
883
|
+
export async function f2AuthorizationGate(labRoot, studyDir) {
|
|
884
|
+
const stageR = await readStageR(labRoot, studyDir);
|
|
885
|
+
if (!stageR.present) {
|
|
886
|
+
return { authorized: false, reasons: ['no run-authorization.json exists in this study — the Owner must issue an APPROVED F2-real-host record bound to the frozen study'], record: null };
|
|
887
|
+
}
|
|
888
|
+
// The frozen facts the bindings are derived from (read fail-closed — a
|
|
889
|
+
// study whose frozen files are unreadable cannot be authorized).
|
|
890
|
+
const frozen = (() => {
|
|
891
|
+
try {
|
|
892
|
+
const manifest = readJsonObjectFileStrict(path.join(studyDir, 'examiner-manifest.json'));
|
|
893
|
+
const suite = readJsonObjectFileStrict(path.join(studyDir, 'suite-manifest.json'));
|
|
894
|
+
const mapping = readJsonObjectFileStrict(path.join(studyDir, 'pain-mechanism-mapping.json'));
|
|
895
|
+
const scoring = readJsonObjectFileStrict(path.join(studyDir, 'scoring-contract.json'));
|
|
896
|
+
return { ok: true, manifest, suite, mapping, scoring };
|
|
897
|
+
}
|
|
898
|
+
catch (error) {
|
|
899
|
+
return { ok: false, reason: error.message };
|
|
900
|
+
}
|
|
901
|
+
})();
|
|
902
|
+
if (!frozen.ok) {
|
|
903
|
+
return { authorized: false, reasons: [`the frozen study files could not be read for binding verification: ${frozen.reason}`], record: stageR.record };
|
|
904
|
+
}
|
|
905
|
+
const gateUrl = pathToFileURL(path.join(labRoot, 'lib', 'challenge-stage-r-gate.mjs')).href;
|
|
906
|
+
const gateMod = (await import(gateUrl));
|
|
907
|
+
if (typeof gateMod.verifyStageRAuthorization !== 'function' || typeof gateMod.readStudyPlanHash !== 'function') {
|
|
908
|
+
return { authorized: false, reasons: ['the lab payload does not export the authoritative Stage R verifier — fail-closed'], record: stageR.record };
|
|
909
|
+
}
|
|
910
|
+
const suiteCases = Array.isArray(frozen.suite.cases) ? frozen.suite.cases : [];
|
|
911
|
+
const caseIds = suiteCases.map((entry) => (isJsonObject(entry) && typeof entry.caseId === 'string' ? entry.caseId : ''));
|
|
912
|
+
const repetitionsPerCase = Number.isInteger(frozen.manifest.repetitionsPerCase) ? Number(frozen.manifest.repetitionsPerCase) : 0;
|
|
913
|
+
const repetitionIds = Array.from({ length: repetitionsPerCase }, (_, index) => String(index + 1));
|
|
914
|
+
const record = stageR.record ?? {};
|
|
915
|
+
const attemptCap = Number.isSafeInteger(record.attemptCap) ? record.attemptCap : null;
|
|
916
|
+
const verdict = gateMod.verifyStageRAuthorization({
|
|
917
|
+
authorizationRecord: record,
|
|
918
|
+
bindings: {
|
|
919
|
+
studyId: frozen.manifest.studyId ?? null,
|
|
920
|
+
suiteHash: frozen.suite.suiteHash ?? null,
|
|
921
|
+
mappingHash: frozen.mapping.mappingHash ?? null,
|
|
922
|
+
graderHash: hashOfCanonicalJson(frozen.scoring),
|
|
923
|
+
studyPlanHash: gateMod.readStudyPlanHash(studyDir),
|
|
924
|
+
},
|
|
925
|
+
plan: { arms: ['A', 'B'], cases: caseIds, repetitions: repetitionIds },
|
|
926
|
+
maxAttemptsTotal: attemptCap,
|
|
927
|
+
now: Date.now(),
|
|
928
|
+
declaredExecutionMode: 'F2-real-host',
|
|
929
|
+
// The console declares NOTHING of its own: a bound timeout/cap is echoed
|
|
930
|
+
// back verbatim (the real runner must declare the same values), and an
|
|
931
|
+
// unbound one is left undeclared — so this gate can never widen the
|
|
932
|
+
// Owner-approved budget; it can only fail the record's own integrity.
|
|
933
|
+
...(Number.isSafeInteger(record.requestTimeoutMs) ? { declaredRequestTimeoutMs: record.requestTimeoutMs } : {}),
|
|
934
|
+
...(Number.isSafeInteger(record.maxRequestsPerAttempt) ? { declaredMaxRequestsPerAttempt: record.maxRequestsPerAttempt } : {}),
|
|
935
|
+
retiredAuthorizations: typeof gateMod.readAuthorizationRevocations === 'function' ? gateMod.readAuthorizationRevocations() : null,
|
|
936
|
+
});
|
|
937
|
+
return { authorized: verdict.ok, reasons: verdict.reasons, record };
|
|
938
|
+
}
|