@catheadowl/dsh-eval 0.1.0 → 0.2.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/src/runner.mjs CHANGED
@@ -1,373 +1,216 @@
1
- /**
2
- * The eval run driver. One eval case = one `dsh --profile <p> --patch
3
- * <generated-overlay> "<task>"` headless run in an isolated `DSH_HOME`, so
4
- * the session JSONL trace lands alone under a per-run persistence root and
5
- * needs no teardown of shared state. The overlay is generated per run:
6
- *
7
- * - always: `session-persistence-jsonl` re-rooted to the run dir, plaintext
8
- * one-event-per-line layout (config override is whole-replace, so every
9
- * field the backend needs is restated);
10
- * - optional case persona: `system-prompt` persona override;
11
- * - optional `disableRows: ['<row-id>', ...]` case declaration: the listed
12
- * loader rows are disabled, so e.g. a turn-close blocking gate plugin
13
- * cannot splice feedback steps past the script's terminal step (the
14
- * disableRows × turn-close gate boundary contract);
15
- * - mock mode: `agent-default-model` re-pointed at the `eval-mock` provider
16
- * plus an insert mounting the scripted adapter plugin by `file://` URL
17
- * (relative plugin names resolve against the PROFILE dir, not the overlay
18
- * file, so an absolute URL is the portable reference).
19
- */
20
-
21
- import { spawn } from 'node:child_process'
22
- import {
23
- chmodSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, unlinkSync, writeFileSync,
24
- } from 'node:fs'
25
- import { tmpdir, homedir } from 'node:os'
26
- import { isAbsolute, join, resolve } from 'node:path'
27
- import { fileURLToPath, pathToFileURL } from 'node:url'
28
- import { loadTraceDir } from './trace.mjs'
29
-
30
- /** This framework's root directory (the eval package dir). */
31
- const FRAMEWORK_ROOT = fileURLToPath(new URL('..', import.meta.url))
32
-
33
- /** The scripted mock adapter plugin, referenced from generated overlays. */
34
- const MOCK_ADAPTER_PATH = join(FRAMEWORK_ROOT, 'src', 'mock', 'mock-adapter.mjs')
35
-
36
- /** The profile-local module-fallback directory, rebuilt fresh by boot and never staged. */
37
- const MODULE_FALLBACK_DIR = '.dsh-module-fallback'
38
-
39
- /**
40
- * Stage the profile store into a temporary DSH_HOME. The BOOTED profile's
41
- * directory is COPIED (sans its own `node_modules` and `.dsh-module-fallback`):
42
- * `prepareProfile` unconditionally rewrites the profile's root cordis.yml on
43
- * every boot, and copying keeps that write inside the temporary home instead
44
- * of leaking through a junction into the real store. Only the profile's own
45
- * `node_modules` stays junctioned — out-of-tree plugin resolution needs it,
46
- * and a task boot never writes it. The module-fallback directories (the
47
- * store-level shared `profiles/node_modules` and the profile-local
48
- * `.dsh-module-fallback`) are deliberately NOT staged: `healProfilesModuleFallback`
49
- * rebuilds both fresh in the temporary home on every boot, and `.dsh-module-fallback`
50
- * in particular is full of junctions that a naive recursive copy would follow
51
- * into the real store. The copy is junction-aware — links are recreated as
52
- * links, never descended (see `copyProfileEntry`). An absent profile copies
53
- * nothing: boot initializes shipped templates inside the temporary home.
54
- * @param {string} realHome - the real Harness home holding `profiles/`.
55
- * @param {string} tmpHome - the temporary home (created up to `profiles/`).
56
- * @param {string} profileName - the profile this run boots.
57
- * @returns {string[]} every created junction path (unlink before rmSync).
58
- */
59
- export function stageProfileStore(realHome, tmpHome, profileName) {
60
- const junctions = []
61
- const tmpProfiles = join(tmpHome, 'profiles')
62
- mkdirSync(tmpProfiles, { recursive: true })
63
- const realProfiles = join(realHome, 'profiles')
64
- if (!existsSync(realProfiles)) return junctions
65
- const realProfileDir = join(realProfiles, profileName)
66
- if (!existsSync(join(realProfileDir, 'package.json'))) return junctions
67
- const tmpProfileDir = join(tmpProfiles, profileName)
68
- mkdirSync(tmpProfileDir, { recursive: true })
69
- for (const entry of readdirSync(realProfileDir, { withFileTypes: true })) {
70
- if (entry.name === 'node_modules' || entry.name === MODULE_FALLBACK_DIR) continue
71
- copyProfileEntry(join(realProfileDir, entry.name), join(tmpProfileDir, entry.name), junctions)
72
- }
73
- const profileModules = join(realProfileDir, 'node_modules')
74
- if (existsSync(profileModules)) {
75
- const target = join(tmpProfileDir, 'node_modules')
76
- symlinkSync(profileModules, target, 'junction')
77
- junctions.push(target)
78
- }
79
- return junctions
80
- }
81
-
82
- /**
83
- * Copy one profile entry (file, directory, or link) into the staged profile.
84
- * A link is recreated as a link (`'junction'` on Windows) instead of being
85
- * followed: Node's `cpSync` dereferences Windows junctions in recursive mode
86
- * (no cycle guard), so a junction-bearing tree would recurse into the real
87
- * store and overflow the native stack. Recreated junctions are appended to
88
- * `junctions` so callers can unlink them before `rmSync` (which would
89
- * otherwise descend through them into the real store).
90
- */
91
- function copyProfileEntry(from, to, junctions) {
92
- const stat = lstatSync(from)
93
- if (stat.isSymbolicLink()) {
94
- const target = readlinkSync(from)
95
- symlinkSync(target, to, 'junction')
96
- junctions.push(to)
97
- return
98
- }
99
- if (stat.isDirectory()) {
100
- mkdirSync(to, { recursive: true })
101
- for (const child of readdirSync(from)) {
102
- copyProfileEntry(join(from, child), join(to, child), junctions)
103
- }
104
- return
105
- }
106
- copyFileSync(from, to)
107
- }
108
-
109
- /** JSON double-quoted strings are valid YAML scalars — enough for this emitter. */
110
- function yamlScalar(value) {
111
- if (typeof value === 'boolean' || typeof value === 'number') return String(value)
112
- return JSON.stringify(String(value))
113
- }
114
-
115
- /**
116
- * Serialize the per-run overlay patch list to YAML.
117
- * @param {object} parts - overlay ingredients (see runEvalCase).
118
- * @returns {string} the overlay file text.
119
- */
120
- export function buildOverlayYaml(parts) {
121
- const lines = []
122
- lines.push('- id: session-persistence-jsonl')
123
- lines.push(' config:')
124
- lines.push(` root: ${yamlScalar(parts.sessionsRoot)}`)
125
- lines.push(' packChunks: false')
126
- lines.push(' compression: none')
127
- if (parts.persona !== undefined) {
128
- lines.push('- id: system-prompt')
129
- lines.push(' config:')
130
- lines.push(` persona: ${yamlScalar(parts.persona)}`)
131
- }
132
- for (const rowId of parts.disableRows ?? []) {
133
- // Per-row disable uses the same cross-layer overlay mechanism as
134
- // `session-title-llm`: an `- id: <row> / disabled: true` patch targets
135
- // the row the plugin bundle itself inserts.
136
- lines.push(`- id: ${yamlScalar(rowId)}`)
137
- lines.push(' disabled: true')
138
- }
139
- if (parts.mock) {
140
- lines.push('- id: agent-default-model')
141
- lines.push(' config:')
142
- lines.push(' provider: eval-mock')
143
- lines.push(' model: eval-mock')
144
- // The title generator also calls the default provider and would consume
145
- // script steps; deterministic runs own every model call themselves.
146
- lines.push('- id: session-title-llm')
147
- lines.push(' disabled: true')
148
- lines.push('- insert:')
149
- lines.push(' - id: eval-mock-llm')
150
- lines.push(` name: ${yamlScalar(pathToFileURL(MOCK_ADAPTER_PATH).href)}`)
151
- }
152
- return `${lines.join('\n')}\n`
153
- }
154
-
155
- /**
156
- * Run one eval case end to end.
157
- *
158
- * Case shape: `{ id, task, mode?: 'real' | 'mock', expect: Matcher[],
159
- * script?: { steps: ChunkStep[] }, persona?: string, disableRows?: string[],
160
- * prepare?: (workspace: string) => void | Promise<void>,
161
- * inspect?: (workspace: string, helpers: { trace }) => void | Promise<void>,
162
- * timeoutMs?: number }`
163
- *
164
- * @param {object} evalCase - the case under test.
165
- * @param {object} options
166
- * @param {string} options.profile - the dsh profile booting the run (plugin installed there).
167
- * @param {string} [options.dshRepoDir] - the deepseek-harness checkout (legacy CLI
168
- * location; ignored when cliPath is given).
169
- * @param {string} [options.cliPath] - explicit compiled CLI entry (C6 chain result;
170
- * takes precedence over dshRepoDir).
171
- * @param {'real' | 'mock'} [options.mode] - force a mode over the case's own.
172
- * @param {string} [options.artifactsDir] - copy stdout/stderr/trace/session logs here (created).
173
- * @returns {Promise<EvalRunResult>}
174
- */
175
- export async function runEvalCase(evalCase, options) {
176
- const mode = options.mode ?? evalCase.mode ?? 'real'
177
- const binPath = options.cliPath !== undefined
178
- ? resolve(options.cliPath)
179
- : join(resolve(options.dshRepoDir), 'apps', 'cli', 'lib', 'bin.js')
180
- const timeoutMs = evalCase.timeoutMs ?? 180_000
181
-
182
- const runDir = mkdtempSync(join(tmpdir(), 'dsh-eval-'))
183
- // Everything past this point is wrapped in try/finally so the temp dir
184
- // (and any junctions) are cleaned up even when `prepare`, profile
185
- // staging, or spawn throw. Previously only the normal exit path
186
- // cleaned up — a `prepare` failure leaked the entire runDir.
187
- try {
188
- const dshHome = join(runDir, 'dsh-home')
189
- const workspace = join(runDir, 'workspace')
190
- const sessionsRoot = join(runDir, 'sessions')
191
- mkdirSync(workspace, { recursive: true })
192
- await evalCase.prepare?.(workspace)
193
-
194
- // Profiles resolve under $DSH_HOME/profiles, and eval overwrites DSH_HOME
195
- // for session/settings isolation: stage the profile store (see
196
- // stageProfileStore the booted profile is copied, so boot's unconditional
197
- // cordis.yml rewrite stays inside the temporary home; only the profile's
198
- // read-only node_modules stays linked, and the shared fallback is rebuilt
199
- // by boot inside the temporary home). The managed credential
200
- // document is copied in because `dsh-credentials-local` resolves it per
201
- // request. Falls back to the default `~/.dsh` when the ambient environment
202
- // sets no home of its own.
203
- const realHome = (process.env.DSH_HOME ?? '').trim() !== '' ? process.env.DSH_HOME : join(homedir(), '.dsh')
204
- mkdirSync(dshHome, { recursive: true })
205
- stageProfileStore(realHome, dshHome, options.profile)
206
- const realCredentials = join(realHome, '.credentials.yaml')
207
- if (existsSync(realCredentials)) {
208
- const credentialsCopy = join(dshHome, '.credentials.yaml')
209
- copyFileSync(realCredentials, credentialsCopy)
210
- try {
211
- // Best-effort owner-only on POSIX (the harness's own e2e uses 0o600);
212
- // a no-op beyond the read-only bit on Windows.
213
- chmodSync(credentialsCopy, 0o600)
214
- } catch { /* permission tightening is best-effort */ }
215
- }
216
-
217
- const env = {
218
- ...process.env,
219
- DSH_HOME: dshHome,
220
- DSH_TELEMETRY_DISABLED: '1',
221
- }
222
- if (mode === 'mock') {
223
- if (evalCase.script === undefined) {
224
- throw new Error(`case '${evalCase.id}': mock mode requires a script`)
225
- }
226
- const scriptPath = join(runDir, 'mock-script.json')
227
- writeFileSync(scriptPath, JSON.stringify(evalCase.script))
228
- env.DSH_EVAL_MOCK_SCRIPT = scriptPath
229
- }
230
-
231
- const overlayPath = join(runDir, 'eval-overlay.yml')
232
- if (evalCase.disableRows !== undefined
233
- && (!Array.isArray(evalCase.disableRows)
234
- || evalCase.disableRows.some(row => typeof row !== 'string' || row === ''))) {
235
- throw new Error(`case '${evalCase.id}': disableRows must be a string[] of loader row ids`)
236
- }
237
- writeFileSync(overlayPath, buildOverlayYaml({
238
- sessionsRoot,
239
- persona: evalCase.persona,
240
- disableRows: evalCase.disableRows,
241
- mock: mode === 'mock',
242
- }))
243
-
244
- const cliArgs = [
245
- binPath,
246
- '--profile', options.profile,
247
- '--patch', overlayPath,
248
- evalCase.task,
249
- ]
250
- const child = spawn(process.execPath, cliArgs, { cwd: workspace, env })
251
-
252
- let stdout = ''
253
- let stderr = ''
254
- child.stdout.on('data', chunk => { stdout += chunk })
255
- child.stderr.on('data', chunk => { stderr += chunk })
256
-
257
- let timedOut = false
258
- const timer = setTimeout(() => {
259
- timedOut = true
260
- child.kill('SIGTERM')
261
- }, timeoutMs)
262
-
263
- const exitCode = await new Promise(resolveExit => {
264
- child.on('error', error => { stderr += `\ndsh-eval: failed to spawn dsh CLI: ${error.message}\n`; resolveExit(127) })
265
- child.on('exit', code => resolveExit(code ?? 1))
266
- })
267
- clearTimeout(timer)
268
-
269
- const trace = loadTraceDir(sessionsRoot)
270
- const sessionLogs = collectSessionLogTexts(sessionsRoot)
271
-
272
- // Workspace assertions live HERE, before the run dir cleanup: a case's
273
- // `inspect(workspace, { trace })` may throw; the failure text rides the
274
- // result instead of leaking past cleanup.
275
- let inspectError
276
- if (typeof evalCase.inspect === 'function') {
277
- try {
278
- await evalCase.inspect(workspace, { trace })
279
- } catch (error) {
280
- inspectError = error instanceof Error ? error.message : String(error)
281
- }
282
- }
283
-
284
- if (options.artifactsDir !== undefined) {
285
- mkdirSync(options.artifactsDir, { recursive: true })
286
- writeFileSync(join(options.artifactsDir, 'stdout.txt'), stdout)
287
- writeFileSync(join(options.artifactsDir, 'stderr.txt'), stderr)
288
- writeFileSync(join(options.artifactsDir, 'trace.json'), JSON.stringify({
289
- caseId: evalCase.id,
290
- mode,
291
- task: evalCase.task,
292
- exitCode,
293
- timedOut,
294
- trace,
295
- }, undefined, 2))
296
- try {
297
- cpSync(sessionsRoot, join(options.artifactsDir, 'sessions'), { recursive: true })
298
- } catch { /* no session materialized — nothing to copy */ }
299
- }
300
-
301
- return {
302
- caseId: evalCase.id, mode, task: evalCase.task, exitCode, timedOut,
303
- stdout, stderr, trace, sessionLogs, inspectError, runDir,
304
- }
305
- } finally {
306
- if (process.env.DSH_EVAL_KEEP_TMP !== '1') {
307
- // Drop every junction first so cleanup can never descend into the
308
- // real profile store. Junctions may not exist when the error
309
- // happened before stageProfileStore ran — readdirSync catches that.
310
- try {
311
- const profileJunctions = []
312
- const walk = (dir) => {
313
- let entries
314
- try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
315
- for (const entry of entries) {
316
- const full = join(dir, entry.name)
317
- if (entry.isSymbolicLink()) profileJunctions.push(full)
318
- else if (entry.isDirectory()) walk(full)
319
- }
320
- }
321
- walk(join(runDir, 'dsh-home'))
322
- for (const junction of profileJunctions) {
323
- try { unlinkSync(junction) } catch { /* junction absent — nothing to drop */ }
324
- }
325
- } catch { /* dsh-home not created yet */ }
326
- rmSync(runDir, { recursive: true, force: true })
327
- }
328
- }
329
- }
330
-
331
- /** Read every session artifact under the root as text (best-effort, pre-cleanup). */
332
- function collectSessionLogTexts(sessionsRoot) {
333
- const texts = []
334
- const walk = (dir) => {
335
- let entries
336
- try {
337
- entries = readdirSync(dir, { withFileTypes: true })
338
- } catch {
339
- return
340
- }
341
- for (const entry of entries) {
342
- const path = join(dir, entry.name)
343
- if (entry.isDirectory()) walk(path)
344
- else if (entry.name === 'session.jsonl') texts.push(readFileSync(path, 'utf8'))
345
- }
346
- }
347
- walk(sessionsRoot)
348
- return texts
349
- }
350
-
351
- /**
352
- * @typedef {object} EvalRunResult
353
- * @property {string} caseId
354
- * @property {'real' | 'mock'} mode
355
- * @property {string} task
356
- * @property {number} exitCode - the headless CLI's exit code (0 = turn completed).
357
- * @property {boolean} timedOut
358
- * @property {string} stdout - printed final assistant text (plus any startup chatter).
359
- * @property {string} stderr
360
- * @property {import('./trace.mjs').EvalTrace | undefined} trace
361
- * @property {string[]} sessionLogs - raw session artifact texts, pre-cleanup.
362
- * @property {string | undefined} inspectError - the case's `inspect` failure text, when it threw.
363
- * @property {string} runDir - removed unless DSH_EVAL_KEEP_TMP=1.
364
- */
365
-
366
- /** Re-exported so bin can resolve the framework without guessing paths. */
367
- export { FRAMEWORK_ROOT }
368
-
369
- /** Whether a candidate dsh repo dir looks like one (the CLI artifact exists). */
370
- export function looksLikeDshRepo(dir) {
371
- if (!isAbsolute(dir)) return false
372
- return existsSync(join(dir, 'apps', 'cli', 'lib', 'bin.js'))
373
- }
1
+ /**
2
+ * The eval run driver. One eval case = one `dsh --profile <p> --patch
3
+ * <generated-overlay> "<task>"` headless run in an isolated `DSH_HOME`, so
4
+ * the session JSONL trace lands alone under a per-run persistence root and
5
+ * needs no teardown of shared state. The overlay is generated per run
6
+ * (see overlay.mjs):
7
+ *
8
+ * - always: `session-persistence-jsonl` re-rooted to the run dir, plaintext
9
+ * one-event-per-line layout (config override is whole-replace, so every
10
+ * field the backend needs is restated);
11
+ * - optional case persona: `system-prompt` persona override;
12
+ * - optional `disableRows: ['<row-id>', ...]` case declaration: the listed
13
+ * loader rows are disabled, so e.g. a turn-close blocking gate plugin
14
+ * cannot splice feedback steps past the script's terminal step (the
15
+ * disableRows × turn-close gate boundary contract);
16
+ * - optional `rowConfig: { '<row-id>': { key: value } }` case declaration:
17
+ * per-row config overrides in this run's overlay. The overlay REPLACES
18
+ * the row's whole config (cordis patch semantics), so restate any keys
19
+ * the row still needs — the arm-style A/B use case disables one
20
+ * provider via `disabledProviders` while restating the row's other
21
+ * config keys explicitly;
22
+ * - mock mode: `agent-default-model` re-pointed at the `eval-mock` provider
23
+ * plus an insert mounting the scripted adapter plugin by `file://` URL
24
+ * (relative plugin names resolve against the PROFILE dir, not the overlay
25
+ * file, so an absolute URL is the portable reference).
26
+ *
27
+ * Isolation mechanics (home staging, junction-safe teardown, spawn/timeout)
28
+ * live in sandbox.mjs; overlay serialization lives in overlay.mjs. This
29
+ * module is the orchestration only.
30
+ */
31
+
32
+ import { existsSync, mkdirSync, mkdtempSync, writeFileSync, cpSync, readdirSync, readFileSync } from 'node:fs'
33
+ import { tmpdir } from 'node:os'
34
+ import { isAbsolute, join, resolve } from 'node:path'
35
+ import { fileURLToPath } from 'node:url'
36
+ import { loadTraceDir } from './trace.mjs'
37
+ import { validateRowConfig, validateDisableRows } from './discovery.mjs'
38
+ import { CLI_RELATIVE_PATH } from './cli.mjs'
39
+ import { buildOverlayYaml } from './overlay.mjs'
40
+ import { resolveRealDshHome, stageSandboxHome, teardownSandbox, spawnHeadlessDsh } from './sandbox.mjs'
41
+
42
+ /** This framework's root directory (the eval package dir). */
43
+ const FRAMEWORK_ROOT = fileURLToPath(new URL('..', import.meta.url))
44
+
45
+ /**
46
+ * Run one eval case end to end.
47
+ *
48
+ * Case shape: `{ id, task, mode?: 'real' | 'mock', expect: Matcher[],
49
+ * script?: { steps: ChunkStep[] }, persona?: string, disableRows?: string[],
50
+ * rowConfig?: Record<string, Record<string, unknown>>,
51
+ * prepare?: (workspace: string) => void | Promise<void>,
52
+ * inspect?: (workspace: string, helpers: { trace }) => void | Promise<void>,
53
+ * timeoutMs?: number }`
54
+ *
55
+ * @param {object} evalCase - the case under test.
56
+ * @param {object} options
57
+ * @param {string} options.profile - the dsh profile booting the run (plugin installed there).
58
+ * @param {string} [options.dshRepoDir] - the deepseek-harness checkout (legacy CLI
59
+ * location; ignored when cliPath is given).
60
+ * @deprecated options.dshRepoDir — pass the C6 chain result via cliPath
61
+ * instead; this legacy option is removed in the next minor release.
62
+ * @param {string} [options.cliPath] - explicit compiled CLI entry (C6 chain result;
63
+ * takes precedence over dshRepoDir).
64
+ * @param {'real' | 'mock'} [options.mode] - force a mode over the case's own.
65
+ * @param {string} [options.artifactsDir] - copy stdout/stderr/trace/session logs here (created).
66
+ * @returns {Promise<EvalRunResult>}
67
+ */
68
+ export async function runEvalCase(evalCase, options) {
69
+ const mode = options.mode ?? evalCase.mode ?? 'real'
70
+ const binPath = options.cliPath !== undefined
71
+ ? resolve(options.cliPath)
72
+ : join(resolve(options.dshRepoDir), ...CLI_RELATIVE_PATH.split(/[\\/]/))
73
+ const timeoutMs = evalCase.timeoutMs ?? 180_000
74
+
75
+ const runDir = mkdtempSync(join(tmpdir(), 'dsh-eval-'))
76
+ // Everything past this point is wrapped in try/finally so the temp dir
77
+ // (and any junctions) are cleaned up even when `prepare`, profile
78
+ // staging, or spawn throw. Previously only the normal exit path
79
+ // cleaned up — a `prepare` failure leaked the entire runDir.
80
+ try {
81
+ const dshHome = join(runDir, 'dsh-home')
82
+ const workspace = join(runDir, 'workspace')
83
+ const sessionsRoot = join(runDir, 'sessions')
84
+ mkdirSync(workspace, { recursive: true })
85
+ await evalCase.prepare?.(workspace)
86
+
87
+ // Profiles resolve under $DSH_HOME/profiles, and eval overwrites DSH_HOME
88
+ // for session/settings isolation (staging + teardown mechanics in
89
+ // sandbox.mjs the booted profile is copied so boot's unconditional
90
+ // cordis.yml rewrite stays inside the temporary home; only the profile's
91
+ // read-only node_modules stays linked, and the shared fallback is rebuilt
92
+ // by boot inside the temporary home).
93
+ const realHome = resolveRealDshHome()
94
+ stageSandboxHome(realHome, dshHome, options.profile)
95
+
96
+ const env = {
97
+ ...process.env,
98
+ DSH_HOME: dshHome,
99
+ DSH_TELEMETRY_DISABLED: '1',
100
+ }
101
+ if (mode === 'mock') {
102
+ if (evalCase.script === undefined) {
103
+ throw new Error(`case '${evalCase.id}': mock mode requires a script`)
104
+ }
105
+ const scriptPath = join(runDir, 'mock-script.json')
106
+ writeFileSync(scriptPath, JSON.stringify(evalCase.script))
107
+ env.DSH_EVAL_MOCK_SCRIPT = scriptPath
108
+ }
109
+
110
+ if (evalCase.disableRows !== undefined) {
111
+ validateDisableRows(evalCase.disableRows, `case '${evalCase.id}'`)
112
+ }
113
+ if (evalCase.rowConfig !== undefined) {
114
+ validateRowConfig(evalCase.rowConfig, `case '${evalCase.id}'`)
115
+ }
116
+ const overlayPath = join(runDir, 'eval-overlay.yml')
117
+ writeFileSync(overlayPath, buildOverlayYaml({
118
+ sessionsRoot,
119
+ persona: evalCase.persona,
120
+ disableRows: evalCase.disableRows,
121
+ rowConfig: evalCase.rowConfig,
122
+ mock: mode === 'mock',
123
+ }))
124
+
125
+ const { stdout, stderr, exitCode, timedOut } = await spawnHeadlessDsh({
126
+ cli: binPath,
127
+ cliArgs: ['--profile', options.profile, '--patch', overlayPath, evalCase.task],
128
+ cwd: workspace,
129
+ env,
130
+ timeoutMs,
131
+ })
132
+
133
+ const trace = loadTraceDir(sessionsRoot)
134
+ const sessionLogs = collectSessionLogTexts(sessionsRoot)
135
+
136
+ // Workspace assertions live HERE, before the run dir cleanup: a case's
137
+ // `inspect(workspace, { trace })` may throw; the failure text rides the
138
+ // result instead of leaking past cleanup.
139
+ let inspectError
140
+ if (typeof evalCase.inspect === 'function') {
141
+ try {
142
+ await evalCase.inspect(workspace, { trace })
143
+ } catch (error) {
144
+ inspectError = error instanceof Error ? error.message : String(error)
145
+ }
146
+ }
147
+
148
+ if (options.artifactsDir !== undefined) {
149
+ mkdirSync(options.artifactsDir, { recursive: true })
150
+ writeFileSync(join(options.artifactsDir, 'stdout.txt'), stdout)
151
+ writeFileSync(join(options.artifactsDir, 'stderr.txt'), stderr)
152
+ writeFileSync(join(options.artifactsDir, 'trace.json'), JSON.stringify({
153
+ caseId: evalCase.id,
154
+ mode,
155
+ task: evalCase.task,
156
+ exitCode,
157
+ timedOut,
158
+ trace,
159
+ }, undefined, 2))
160
+ try {
161
+ cpSync(sessionsRoot, join(options.artifactsDir, 'sessions'), { recursive: true })
162
+ } catch { /* no session materialized — nothing to copy */ }
163
+ }
164
+
165
+ return {
166
+ caseId: evalCase.id, mode, task: evalCase.task, exitCode, timedOut,
167
+ stdout, stderr, trace, sessionLogs, inspectError, runDir,
168
+ }
169
+ } finally {
170
+ teardownSandbox(runDir, { keep: process.env.DSH_EVAL_KEEP_TMP === '1' })
171
+ }
172
+ }
173
+
174
+ /** Read every session artifact under the root as text (best-effort, pre-cleanup). */
175
+ function collectSessionLogTexts(sessionsRoot) {
176
+ const texts = []
177
+ const walk = (dir) => {
178
+ let entries
179
+ try {
180
+ entries = readdirSync(dir, { withFileTypes: true })
181
+ } catch {
182
+ return
183
+ }
184
+ for (const entry of entries) {
185
+ const path = join(dir, entry.name)
186
+ if (entry.isDirectory()) walk(path)
187
+ else if (entry.name === 'session.jsonl') texts.push(readFileSync(path, 'utf8'))
188
+ }
189
+ }
190
+ walk(sessionsRoot)
191
+ return texts
192
+ }
193
+
194
+ /**
195
+ * @typedef {object} EvalRunResult
196
+ * @property {string} caseId
197
+ * @property {'real' | 'mock'} mode
198
+ * @property {string} task
199
+ * @property {number} exitCode - the headless CLI's exit code (0 = turn completed).
200
+ * @property {boolean} timedOut
201
+ * @property {string} stdout - printed final assistant text (plus any startup chatter).
202
+ * @property {string} stderr
203
+ * @property {import('./trace.mjs').EvalTrace | undefined} trace
204
+ * @property {string[]} sessionLogs - raw session artifact texts, pre-cleanup.
205
+ * @property {string | undefined} inspectError - the case's `inspect` failure text, when it threw.
206
+ * @property {string} runDir - removed unless DSH_EVAL_KEEP_TMP=1.
207
+ */
208
+
209
+ /** Re-exported so bin can resolve the framework without guessing paths. */
210
+ export { FRAMEWORK_ROOT }
211
+
212
+ /** Whether a candidate dsh repo dir looks like one (the CLI artifact exists). */
213
+ export function looksLikeDshRepo(dir) {
214
+ if (!isAbsolute(dir)) return false
215
+ return existsSync(join(dir, ...CLI_RELATIVE_PATH.split(/[\\/]/)))
216
+ }