thunderjar 0.1.3-preview-eee7160 → 0.1.3-preview-e09ffa1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/cli.js +1934 -1721
  2. package/package.json +14 -8
  3. package/src/api/checkoutCommit.ts +35 -0
  4. package/src/api/claudeCode.ts +74 -0
  5. package/src/api/declarations.ts +176 -133
  6. package/src/api/index.ts +44 -0
  7. package/src/api/parameterNames.ts +19 -0
  8. package/src/AGENTS.md +0 -156
  9. package/src/adapters/docker/docker.ts +0 -117
  10. package/src/api/fragments.ts +0 -30
  11. package/src/api/parameter-names.d.ts +0 -16
  12. package/src/cli/commands/build/build.docker.test.ts +0 -173
  13. package/src/cli/commands/build/build.test.ts +0 -234
  14. package/src/cli/commands/build/build.ts +0 -115
  15. package/src/cli/commands/declarations.typecheck.test.ts +0 -173
  16. package/src/cli/commands/executions/executions.test.ts +0 -84
  17. package/src/cli/commands/executions/executions.ts +0 -31
  18. package/src/cli/commands/format.ts +0 -33
  19. package/src/cli/commands/generate/generate.test.ts +0 -101
  20. package/src/cli/commands/generate/generate.ts +0 -18
  21. package/src/cli/commands/generate/parameter-names.ts +0 -131
  22. package/src/cli/commands/init/init.test.ts +0 -123
  23. package/src/cli/commands/init/init.ts +0 -189
  24. package/src/cli/commands/options.ts +0 -17
  25. package/src/cli/commands/plan/plan.test.ts +0 -168
  26. package/src/cli/commands/plan/plan.ts +0 -72
  27. package/src/cli/commands/remeasure/remeasure.test.ts +0 -536
  28. package/src/cli/commands/remeasure/remeasure.ts +0 -194
  29. package/src/cli/commands/run/run.test.ts +0 -622
  30. package/src/cli/commands/run/run.ts +0 -203
  31. package/src/cli/commands/show/show.test.ts +0 -288
  32. package/src/cli/commands/show/show.ts +0 -252
  33. package/src/cli/commands/validation.test.ts +0 -200
  34. package/src/cli/index.test.ts +0 -47
  35. package/src/cli/index.ts +0 -9
  36. package/src/cli/program.ts +0 -29
  37. package/src/images/prerun-builds.ts +0 -108
  38. package/src/images/prerun.test.ts +0 -243
  39. package/src/images/prerun.ts +0 -272
  40. package/src/images/repository.ts +0 -72
  41. package/src/index.ts +0 -24
  42. package/src/instruments/instruments.test.ts +0 -240
  43. package/src/instruments/instruments.ts +0 -205
  44. package/src/matrix/execution.ts +0 -122
  45. package/src/matrix/hashing.test.ts +0 -298
  46. package/src/matrix/hashing.ts +0 -131
  47. package/src/matrix/permutations.test.ts +0 -58
  48. package/src/matrix/permutations.ts +0 -50
  49. package/src/matrix/resolve.ts +0 -63
  50. package/src/matrix/tags.test.ts +0 -15
  51. package/src/matrix/tags.ts +0 -22
  52. package/src/matrix/ulid.test.ts +0 -28
  53. package/src/matrix/ulid.ts +0 -22
  54. package/src/measuring/aggregate.test.ts +0 -188
  55. package/src/measuring/aggregate.ts +0 -154
  56. package/src/measuring/measure.test.ts +0 -105
  57. package/src/measuring/measure.ts +0 -62
  58. package/src/measuring/remeasure-templates/v1/isPrime.test.template.txt +0 -4
  59. package/src/measuring/remeasure-templates/v2/isPrime.test.template.txt +0 -5
  60. package/src/measuring/remeasure.test.ts +0 -352
  61. package/src/measuring/remeasure.ts +0 -113
  62. package/src/platform/fs.test.ts +0 -89
  63. package/src/platform/fs.ts +0 -123
  64. package/src/platform/path.test.ts +0 -38
  65. package/src/platform/path.ts +0 -72
  66. package/src/platform/signals.test.ts +0 -25
  67. package/src/platform/signals.ts +0 -68
  68. package/src/project/config.test.ts +0 -68
  69. package/src/project/config.ts +0 -297
  70. package/src/running/runner.test.ts +0 -73
  71. package/src/running/runner.ts +0 -252
  72. package/src/store/run-data-store.test.ts +0 -232
  73. package/src/store/run-data-store.ts +0 -254
  74. package/src/testing/cli.ts +0 -65
  75. package/src/testing/fake-docker.test.ts +0 -197
  76. package/src/testing/fake-docker.ts +0 -104
  77. package/src/testing/fixture-project.ts +0 -177
  78. package/src/testing/measurement-context.ts +0 -5
  79. package/src/testing/seed-repo.test.ts +0 -10
  80. package/src/testing/seed-repo.ts +0 -83
  81. package/src/testing/test-setup.ts +0 -10
package/dist/cli.js CHANGED
@@ -2047,7 +2047,7 @@ var program = new Command;
2047
2047
  // package.json
2048
2048
  var package_default = {
2049
2049
  name: "thunderjar",
2050
- version: "0.1.3-preview-eee7160",
2050
+ version: "0.1.3-preview-e09ffa1",
2051
2051
  type: "module",
2052
2052
  description: "Thunderjar",
2053
2053
  license: "MIT",
@@ -2058,24 +2058,30 @@ var package_default = {
2058
2058
  bin: {
2059
2059
  thunderjar: "./dist/cli.js"
2060
2060
  },
2061
- main: "./src/index.ts",
2062
- types: "./src/index.ts",
2061
+ main: "./src/api/index.ts",
2062
+ types: "./src/api/index.ts",
2063
+ exports: {
2064
+ ".": "./src/api/index.ts"
2065
+ },
2063
2066
  files: [
2064
2067
  "dist",
2065
- "src"
2068
+ "src/api",
2069
+ "!src/**/*.test.ts",
2070
+ "!src/**/*.result.json"
2066
2071
  ],
2067
2072
  publishConfig: {
2068
2073
  access: "public"
2069
2074
  },
2070
2075
  scripts: {
2071
2076
  build: "bun build src/cli/index.ts --target bun --outfile dist/cli.js",
2072
- lint: "bun scripts/sync-oxlint-layout.ts && bun scripts/sync-oxlint-layout.ts --check && oxlint",
2077
+ lint: "bun scripts/lint-layers.ts --check && oxlint",
2078
+ "lint:generate": "bun scripts/lint-layers.ts",
2073
2079
  typecheck: "tsc --noEmit",
2074
2080
  test: "bun test",
2075
- "test:prepare": "bun scripts/prepare-tests.ts",
2076
- "test:docker": "THUNDERJAR_TEST_DOCKER=1 bun test docker",
2081
+ "test:docker": "THUNDERJAR_DOCKER_TESTS=1 bun test --timeout 600000 .docker.test",
2082
+ "test:agent": "THUNDERJAR_AGENT_TESTS=1 bun test --timeout 900000 .agent.test",
2083
+ "test:package": "bun scripts/package-smoke-test.ts",
2077
2084
  fixture: "bun scripts/fixture.ts",
2078
- "dev:link": "bun scripts/dev-link.ts",
2079
2085
  "docs:dev": "vitepress dev docs",
2080
2086
  "docs:build": "vitepress build docs",
2081
2087
  "docs:build:versions": "bun scripts/build-versioned-docs.ts",
@@ -2100,242 +2106,213 @@ var package_default = {
2100
2106
  }
2101
2107
  };
2102
2108
 
2103
- // src/platform/path.ts
2104
- function isAbsolute(path2) {
2105
- return path2.startsWith("/");
2106
- }
2107
- function normalize(path2) {
2108
- if (path2 === "")
2109
- return ".";
2110
- const absolute = isAbsolute(path2);
2111
- const trailing = path2.endsWith("/");
2112
- const out = [];
2113
- for (const part of path2.split("/")) {
2114
- if (part === "" || part === ".")
2115
- continue;
2116
- if (part === "..") {
2117
- if (out.length > 0 && out.at(-1) !== "..")
2118
- out.pop();
2119
- else if (!absolute)
2120
- out.push("..");
2121
- } else
2122
- out.push(part);
2123
- }
2124
- let result = (absolute ? "/" : "") + out.join("/");
2125
- if (result === "")
2126
- result = absolute ? "/" : ".";
2127
- if (trailing && !result.endsWith("/"))
2128
- result += "/";
2129
- return result;
2130
- }
2131
- function join(...parts) {
2132
- const joined = parts.filter((part) => part !== "").join("/");
2133
- return joined === "" ? "." : normalize(joined);
2134
- }
2135
- function resolve(...parts) {
2136
- let path2 = "";
2137
- for (let i = parts.length - 1;i >= 0 && !isAbsolute(path2); i--) {
2138
- const part = parts[i];
2139
- if (part !== "")
2140
- path2 = path2 === "" ? part : `${part}/${path2}`;
2141
- }
2142
- if (!isAbsolute(path2))
2143
- path2 = `${process.cwd()}/${path2}`;
2144
- const normal = normalize(path2);
2145
- return normal.length > 1 && normal.endsWith("/") ? normal.slice(0, -1) : normal;
2146
- }
2147
- function relative(from, to) {
2148
- const a = resolve(from).split("/").filter(Boolean);
2149
- const b = resolve(to).split("/").filter(Boolean);
2150
- let common = 0;
2151
- while (common < a.length && common < b.length && a[common] === b[common])
2152
- common++;
2153
- return [...Array(a.length - common).fill(".."), ...b.slice(common)].join("/");
2154
- }
2155
- function dirname(path2) {
2156
- if (path2 === "")
2157
- return ".";
2158
- const trimmed = path2.length > 1 ? path2.replace(/\/+$/, "") : path2;
2159
- const at = trimmed.lastIndexOf("/");
2160
- if (at === -1)
2161
- return ".";
2162
- if (at === 0)
2163
- return "/";
2164
- return trimmed.slice(0, at);
2165
- }
2166
- function basename(path2, ext) {
2167
- const trimmed = path2.length > 1 ? path2.replace(/\/+$/, "") : path2;
2168
- const base = trimmed.slice(trimmed.lastIndexOf("/") + 1);
2169
- return ext && base !== ext && base.endsWith(ext) ? base.slice(0, -ext.length) : base;
2170
- }
2171
- function extname(path2) {
2172
- const base = basename(path2);
2173
- const at = base.lastIndexOf(".");
2174
- return at <= 0 ? "" : base.slice(at);
2109
+ // src/api/declarations.ts
2110
+ var DECLARED_BY = Symbol.for("thunderjar.declaredBy");
2111
+ function declaredBy(value) {
2112
+ if ((typeof value !== "object" || value === null) && typeof value !== "function")
2113
+ return;
2114
+ const mark = value[DECLARED_BY];
2115
+ return typeof mark === "string" ? mark : undefined;
2175
2116
  }
2176
2117
 
2177
- // src/adapters/docker/docker.ts
2178
- class DockerBuildError extends Error {
2179
- ref;
2180
- exitCode;
2181
- constructor(ref, exitCode) {
2182
- super(`docker build of ${ref} exited ${exitCode}`);
2183
- this.ref = ref;
2184
- this.exitCode = exitCode;
2185
- }
2186
- }
2118
+ // src/config/loader.ts
2119
+ var FOLDER = ".thunderjar";
2187
2120
 
2188
- class DockerCommandError extends Error {
2189
- args;
2190
- exitCode;
2191
- stderr;
2192
- constructor(args, exitCode, stderr) {
2193
- super(`docker ${args[0]} exited ${exitCode}${stderr.trim() ? `: ${stderr.trim().split(`
2194
- `).at(-1)}` : ""}`);
2195
- this.args = args;
2196
- this.exitCode = exitCode;
2197
- this.stderr = stderr;
2121
+ class ConfigError extends Error {
2122
+ problems;
2123
+ constructor(...problems) {
2124
+ super(problems.join(`
2125
+ `));
2126
+ this.problems = problems;
2198
2127
  }
2199
2128
  }
2200
- async function docker(binary, args, output) {
2201
- const stdio = {
2202
- collect: { stdout: "ignore", stderr: "pipe" },
2203
- stderr: { stdout: 2, stderr: 2 },
2204
- stdout: { stdout: "pipe", stderr: 2 }
2205
- };
2206
- const child = Bun.spawn([binary, ...args], { stdin: "ignore", ...stdio[output] });
2207
- const text = (stream) => stream instanceof ReadableStream ? new Response(stream).text() : Promise.resolve("");
2208
- const [stdout, stderr] = await Promise.all([text(child.stdout), text(child.stderr), child.exited]);
2209
- return { exitCode: child.exitCode ?? 1, stdout, stderr };
2210
- }
2211
- function dockerCli(binary = process.env.THUNDERJAR_DOCKER || "docker") {
2212
- const must = async (args) => {
2213
- const { exitCode, stderr } = await docker(binary, args, "collect");
2214
- if (exitCode !== 0)
2215
- throw new DockerCommandError(args, exitCode, stderr);
2216
- };
2217
- return {
2218
- async unreachable() {
2219
- try {
2220
- const { exitCode, stderr } = await docker(binary, ["version", "--format", "{{.Server.Version}}"], "collect");
2221
- if (exitCode === 0)
2222
- return;
2223
- return stderr.trim().split(`
2224
- `).at(-1) || `\`${binary} version\` exited ${exitCode}`;
2225
- } catch (error) {
2226
- if (error.code === "ENOENT")
2227
- return `\`${binary}\` was not found`;
2228
- throw error;
2229
- }
2230
- },
2231
- async imageExists(ref) {
2232
- return (await docker(binary, ["image", "inspect", "--format", "{{.Id}}", ref], "collect")).exitCode === 0;
2233
- },
2234
- async build(contextDir, ref) {
2235
- const { exitCode } = await docker(binary, ["build", "--tag", ref, contextDir], "stderr");
2236
- if (exitCode !== 0)
2237
- throw new DockerBuildError(ref, exitCode);
2238
- },
2239
- async run(ref, name, script) {
2240
- const { exitCode, stdout } = await docker(binary, ["run", "--name", name, "--entrypoint", "sh", ref, "-c", script], "stdout");
2241
- return { exitCode, stdout };
2242
- },
2243
- async commit(name, ref, like) {
2244
- const format = "{{json .Config.Entrypoint}}\t{{json .Config.Cmd}}";
2245
- const inspected = await docker(binary, ["image", "inspect", "--format", format, like], "stdout");
2246
- if (inspected.exitCode !== 0)
2247
- throw new DockerCommandError(["image", "inspect", like], inspected.exitCode, "");
2248
- const [entrypoint, cmd] = inspected.stdout.trim().split("\t");
2249
- const changes = [`ENTRYPOINT ${entrypoint === "null" ? '[""]' : entrypoint}`, `CMD ${cmd === "null" ? '["sh"]' : cmd}`];
2250
- await must(["commit", ...changes.flatMap((change) => ["--change", change]), name, ref]);
2251
- },
2252
- create: (ref, name) => must(["create", "--name", name, "--entrypoint", "sh", ref]),
2253
- copyFrom: (name, path2, dest) => must(["cp", `${name}:${path2}`, dest]),
2254
- removeContainer: (name) => must(["rm", "--force", name])
2255
- };
2256
- }
2257
-
2258
- // src/platform/fs.ts
2259
- var {$ } = globalThis.Bun;
2260
- function tmpdir() {
2261
- return (Bun.env.TMPDIR ?? "/tmp").replace(/\/+$/, "") || "/";
2129
+ function problemsError(problems) {
2130
+ return new ConfigError(...problems.map(({ file, field, message }) => `${FOLDER}/${file}: ${field ? `${field}: ` : ""}${message}`));
2262
2131
  }
2263
- async function makeTempDir(prefix) {
2264
- return (await $`mktemp -d ${join(tmpdir(), `${prefix}XXXXXX`)}`.quiet().text()).trim();
2132
+ async function isDirectory(path2) {
2133
+ try {
2134
+ return (await Bun.file(path2).stat()).isDirectory();
2135
+ } catch {
2136
+ return false;
2137
+ }
2265
2138
  }
2266
- async function makeDir(dir) {
2267
- await $`mkdir -p ${dir}`.quiet();
2139
+ async function existsIn(root, path2) {
2140
+ return path2.endsWith("/") ? isDirectory(`${root}/${path2}`) : Bun.file(`${root}/${path2}`).exists();
2268
2141
  }
2269
- async function remove(path2) {
2270
- await $`rm -rf ${path2}`.quiet();
2142
+ var parentOf = (dir) => dir.slice(0, dir.lastIndexOf("/")) || "/";
2143
+ async function findThunderjarRoot(startDir) {
2144
+ let dir = startDir.replace(/(.)\/+$/, "$1");
2145
+ for (;; ) {
2146
+ const candidate = dir === "/" ? `/${FOLDER}` : `${dir}/${FOLDER}`;
2147
+ if (await isDirectory(candidate))
2148
+ return candidate;
2149
+ if (dir === "/")
2150
+ break;
2151
+ dir = parentOf(dir);
2152
+ }
2153
+ throw new ConfigError(`No ${FOLDER}/ folder in ${startDir} or any folder above it. Run \`thunderjar init\` first.`);
2271
2154
  }
2272
- async function copy(from, to) {
2273
- if (await isDirectory(from)) {
2274
- await makeDir(to);
2275
- await $`cp -RP ${from}/. ${to}`.quiet();
2276
- } else {
2277
- await $`cp -P ${from} ${to}`.quiet();
2155
+ async function loadDeclaration(root, file, declareFn, problems) {
2156
+ if (!await existsIn(root, file)) {
2157
+ problems.push({ file, message: `is missing. Export \`${declareFn}(...)\` from it as the default.` });
2158
+ return;
2159
+ }
2160
+ let module;
2161
+ try {
2162
+ module = await import(`${root}/${file}`);
2163
+ } catch (error) {
2164
+ problems.push({ file, message: `could not be loaded: ${error instanceof Error ? error.message : String(error)}` });
2165
+ return;
2166
+ }
2167
+ if (module.default === undefined) {
2168
+ problems.push({ file, message: `has no default export. Export \`${declareFn}(...)\` as the default.` });
2169
+ return;
2170
+ }
2171
+ const by = declaredBy(module.default);
2172
+ if (by !== declareFn) {
2173
+ problems.push({
2174
+ file,
2175
+ message: by ? `default export is wrapped in ${by}, expected ${declareFn}.` : `default export is not wrapped in ${declareFn}(...).`
2176
+ });
2177
+ return;
2278
2178
  }
2179
+ return module.default;
2279
2180
  }
2280
- async function move(from, to) {
2281
- await $`mv -f ${from} ${to}`.quiet();
2181
+
2182
+ // src/config/permutations.ts
2183
+ var {CryptoHasher, Glob: Glob2 } = globalThis.Bun;
2184
+
2185
+ // src/config/folders.ts
2186
+ var {Glob } = globalThis.Bun;
2187
+ var PARAMETERS = "test-case-parameters";
2188
+ var sorted = (names) => [...new Set(names)].toSorted();
2189
+ async function scan(root, dir, pattern, onlyFiles) {
2190
+ if (!await existsIn(root, `${dir}/`))
2191
+ return [];
2192
+ try {
2193
+ return sorted(new Glob(pattern).scanSync({ cwd: `${root}/${dir}`, onlyFiles }));
2194
+ } catch (error) {
2195
+ throw new ConfigError(`${FOLDER}/${dir}/ could not be read: ${error instanceof Error ? error.message : String(error)}`);
2196
+ }
2282
2197
  }
2283
- async function exists(path2) {
2284
- return (await $`test -e ${path2}`.quiet().nothrow()).exitCode === 0;
2198
+ async function foldersIn(root, dir) {
2199
+ const folders = [];
2200
+ for (const name of await scan(root, dir, "*", false)) {
2201
+ if (await existsIn(root, `${dir}/${name}/`))
2202
+ folders.push(name);
2203
+ }
2204
+ return folders;
2285
2205
  }
2286
- async function isDirectory(path2) {
2287
- return (await $`test -d ${path2}`.quiet().nothrow()).exitCode === 0;
2288
- }
2289
- function existsSync(path2) {
2290
- return Bun.spawnSync(["test", "-e", path2]).exitCode === 0;
2291
- }
2292
- function isDirectorySync(path2) {
2293
- return Bun.spawnSync(["test", "-d", path2]).exitCode === 0;
2294
- }
2295
- function readBytesSync(path2) {
2296
- const result = Bun.spawnSync(["cat", "--", path2], { stderr: "pipe" });
2297
- if (result.exitCode !== 0)
2298
- throw new Error(`can't read ${path2}: ${result.stderr.toString().trim()}`);
2299
- return new Uint8Array(result.stdout);
2300
- }
2301
- function listFilesSync(dir) {
2302
- if (!isDirectorySync(dir))
2303
- throw new Error(`not a directory: ${dir}`);
2304
- return [...new Bun.Glob("**").scanSync({ cwd: dir, dot: true, onlyFiles: true, followSymlinks: false })].toSorted();
2305
- }
2306
- function listEntries(dir, { recursive = false } = {}) {
2307
- if (!isDirectorySync(dir))
2308
- throw new Error(`not a directory: ${dir}`);
2309
- const pattern = new Bun.Glob(recursive ? "**" : "*");
2310
- const scan = (onlyFiles) => [...pattern.scanSync({ cwd: dir, dot: true, onlyFiles, followSymlinks: false })];
2311
- const files = new Set(scan(true));
2312
- const isRealDirectory = (path2) => Bun.spawnSync(["sh", "-c", 'test -d "$1" && test ! -L "$1"', "sh", join(dir, path2)]).exitCode === 0;
2313
- return scan(false).toSorted().map((path2) => ({ path: path2, isDirectory: !files.has(path2) && isRealDirectory(path2) }));
2206
+ async function tsFilesIn(root, dir) {
2207
+ return (await scan(root, dir, "*.ts", true)).map((path2) => path2.slice(0, -".ts".length));
2314
2208
  }
2315
2209
 
2316
- // src/api/declarations.ts
2317
- var DECLARED_BY = Symbol.for("thunderjar.declaredBy");
2318
- function declaredBy(value) {
2319
- if ((typeof value !== "object" || value === null) && typeof value !== "function")
2320
- return;
2321
- const mark = value[DECLARED_BY];
2322
- return typeof mark === "string" ? mark : undefined;
2210
+ // src/config/permutations.ts
2211
+ var AXES = ["baseImage", "codeState", "promptSet", "harnessModel", "initialPrompt"];
2212
+ function matrixShape(testCase) {
2213
+ return AXES.map((axis) => testCase[axis].length);
2214
+ }
2215
+ function expandPermutations(testCase) {
2216
+ return AXES.reduce((permutations, axis) => permutations.flatMap((partial) => testCase[axis].map((name) => ({ ...partial, [axis]: name }))), [{}]);
2217
+ }
2218
+ var abbreviateHash = (hash) => hash.slice(0, 8);
2219
+ var sha256 = (data) => new CryptoHasher("sha256").update(data).digest("hex");
2220
+ var HARNESS_MAPS = new Set(["models.ts", "versions.ts"]);
2221
+ async function walk(dir, prefix = "", ancestors = new Set) {
2222
+ const { dev, ino } = await Bun.file(dir).stat();
2223
+ const id = `${dev}:${ino}`;
2224
+ if (ancestors.has(id))
2225
+ return [];
2226
+ const within = new Set([...ancestors, id]);
2227
+ const entries = [...new Glob2("*").scanSync({ cwd: dir, onlyFiles: false, dot: true })];
2228
+ const nested = await Promise.all(entries.map(async (entry) => {
2229
+ const stat = await Bun.file(`${dir}/${entry}`).stat().catch(() => {
2230
+ return;
2231
+ });
2232
+ if (!stat)
2233
+ return [{ file: `${prefix}${entry}`, dangling: true }];
2234
+ if (stat.isDirectory())
2235
+ return walk(`${dir}/${entry}`, `${prefix}${entry}/`, within);
2236
+ return [{ file: `${prefix}${entry}` }];
2237
+ }));
2238
+ return nested.flat();
2239
+ }
2240
+ async function contentOf(root, path2, exclude = new Set) {
2241
+ if (!path2.endsWith("/"))
2242
+ return [["", sha256(await Bun.file(`${root}/${path2}`).arrayBuffer())]];
2243
+ const files = (await walk(`${root}/${path2}`)).filter(({ file }) => !exclude.has(file)).toSorted((a, b) => a.file < b.file ? -1 : a.file > b.file ? 1 : 0);
2244
+ return Promise.all(files.map(async ({ file, dangling }) => [
2245
+ file,
2246
+ dangling ? null : sha256(await Bun.file(`${root}/${path2}${file}`).arrayBuffer())
2247
+ ]));
2248
+ }
2249
+ function parsePair(pair) {
2250
+ const at = pair.indexOf("@");
2251
+ const slash = pair.indexOf("/", at);
2252
+ return { harness: pair.slice(0, at), version: pair.slice(at + 1, slash), model: pair.slice(slash + 1) };
2253
+ }
2254
+ var FOLDERS = {
2255
+ baseImage: "baseImages",
2256
+ codeState: "codeStates",
2257
+ promptSet: "promptSets"
2258
+ };
2259
+ async function hashPermutations(root, testCase) {
2260
+ const cache = new Map;
2261
+ const cached = (key, compute) => {
2262
+ if (!cache.has(key))
2263
+ cache.set(key, compute());
2264
+ return cache.get(key);
2265
+ };
2266
+ const problems = [];
2267
+ const folderHash = (axis, name) => cached(`${axis}\x00${name}`, async () => sha256(JSON.stringify(await contentOf(root, `${PARAMETERS}/${FOLDERS[axis]}/${name}/`))));
2268
+ const initialPromptHash = (name) => cached(`initialPrompt\x00${name}`, async () => sha256(JSON.stringify(await contentOf(root, `${PARAMETERS}/tasks/${testCase.task}/initial-prompts/${name}.ts`))));
2269
+ const harnessMap = async (harness, file, declareFn) => await loadDeclaration(root, `${PARAMETERS}/harnessModels/${harness}/${file}`, declareFn, problems);
2270
+ const harnessHash = (pair) => {
2271
+ const { harness, version } = parsePair(pair);
2272
+ return cached(`harness\x00${harness}@${version}`, async () => {
2273
+ const files = await contentOf(root, `${PARAMETERS}/harnessModels/${harness}/`, HARNESS_MAPS);
2274
+ const versions = await harnessMap(harness, "versions.ts", "declareHarnessVersions");
2275
+ return sha256(JSON.stringify({ files, version: versions?.[version] }));
2276
+ });
2277
+ };
2278
+ const modelId = (pair) => {
2279
+ const { harness, model } = parsePair(pair);
2280
+ return cached(`model\x00${harness}/${model}`, async () => String((await harnessMap(harness, "models.ts", "declareHarnessModels"))?.[model]));
2281
+ };
2282
+ const permutations = await Promise.all(expandPermutations(testCase).map(async (permutation) => {
2283
+ const parts = await Promise.all([
2284
+ folderHash("baseImage", permutation.baseImage),
2285
+ folderHash("codeState", permutation.codeState),
2286
+ folderHash("promptSet", permutation.promptSet),
2287
+ harnessHash(permutation.harnessModel),
2288
+ initialPromptHash(permutation.initialPrompt),
2289
+ modelId(permutation.harnessModel)
2290
+ ]);
2291
+ const [baseImage, codeState, promptSet, harnessModel, initialPrompt, model] = parts;
2292
+ return {
2293
+ ...permutation,
2294
+ hash: sha256(JSON.stringify(parts)),
2295
+ parameterHashes: { baseImage, codeState, promptSet, harnessModel, initialPrompt },
2296
+ modelId: model
2297
+ };
2298
+ }));
2299
+ if (problems.length > 0)
2300
+ throw problemsError(problems);
2301
+ return permutations;
2323
2302
  }
2324
2303
 
2304
+ // src/config/measurements.ts
2305
+ var {CryptoHasher: CryptoHasher2 } = globalThis.Bun;
2306
+
2325
2307
  // src/instruments/instruments.ts
2326
- var aggregateMethods = {
2327
- fileCreated: ["rate", "count"],
2328
- grep: ["mean", "median", "min", "max", "sum"],
2329
- templateTest: ["sum", "mean"]
2330
- };
2331
2308
  var show = (value) => JSON.stringify(value) ?? String(value);
2332
- var checkPath = (field, value, what) => typeof value === "string" && value !== "" ? [] : [[field, `must be ${what}, got ${show(value)}`]];
2309
+ var checkString = (field, value, what) => typeof value === "string" && value !== "" ? [] : [[field, `must be ${what}, got ${show(value)}`]];
2333
2310
  var fileCreated = {
2334
2311
  async evaluate({ glob }, ctx) {
2335
2312
  return { outcome: "measured", data: (await ctx.glob(glob)).length > 0 };
2336
2313
  },
2337
2314
  checkConfig(config) {
2338
- return checkPath("config.glob", config?.glob, "a glob pattern");
2315
+ return checkString("config.glob", config?.glob, "a glob pattern");
2339
2316
  }
2340
2317
  };
2341
2318
  var lines = (text) => text === "" ? [] : text.replace(/\r?\n$/, "").split(/\r?\n/);
@@ -2355,27 +2332,45 @@ var grep = {
2355
2332
  },
2356
2333
  checkConfig(config) {
2357
2334
  const { glob, pattern } = config ?? {};
2358
- const problems = checkPath("config.glob", glob, "a glob pattern");
2359
- if (typeof pattern !== "string") {
2360
- problems.push(["config.pattern", `must be a JavaScript regular expression, got ${show(pattern)}`]);
2361
- } else {
2362
- try {
2363
- new RegExp(pattern);
2364
- } catch (error) {
2365
- problems.push(["config.pattern", `${show(pattern)} is not a valid regular expression: ${error.message}`]);
2366
- }
2335
+ return [...checkString("config.glob", glob, "a glob pattern"), ...checkPattern(pattern)];
2336
+ }
2337
+ };
2338
+ function checkPattern(pattern) {
2339
+ if (typeof pattern !== "string")
2340
+ return [["config.pattern", `must be a JavaScript regular expression, got ${show(pattern)}`]];
2341
+ try {
2342
+ new RegExp(pattern);
2343
+ return [];
2344
+ } catch (error) {
2345
+ const reason = error.message.replace(/^Invalid regular expression: /, "");
2346
+ return [["config.pattern", `${show(pattern)} is not a valid regular expression: ${reason}`]];
2347
+ }
2348
+ }
2349
+ var replyMatches = {
2350
+ async evaluate({ pattern, turn }, ctx) {
2351
+ const reply = turn === undefined ? ctx.reply : ctx.replies[turn - 1];
2352
+ if (!reply)
2353
+ return { outcome: "skipped", reason: `no turn ${turn}: the initial prompt has ${ctx.replies.length}` };
2354
+ if (reply.outcome !== "collected")
2355
+ return { outcome: "skipped", reason: `no reply: ${reply.reason}` };
2356
+ return { outcome: "measured", data: new RegExp(pattern).test(reply.text) };
2357
+ },
2358
+ checkConfig(config) {
2359
+ const { pattern, turn } = config ?? {};
2360
+ const problems = checkPattern(pattern);
2361
+ if (turn !== undefined && (typeof turn !== "number" || !Number.isInteger(turn) || turn < 1)) {
2362
+ problems.push(["config.turn", `must be a turn, counting from 1, got ${show(turn)}`]);
2367
2363
  }
2368
2364
  return problems;
2369
- }
2365
+ },
2366
+ usesContainer: false
2370
2367
  };
2371
2368
  var shellWord = (text) => `'${text.replaceAll("'", `'\\''`)}'`;
2372
- var LOADERS = {
2373
- ".js": "js",
2374
- ".mjs": "js",
2375
- ".cjs": "js",
2376
- ".jsx": "jsx",
2377
- ".tsx": "tsx"
2378
- };
2369
+ var dirname = (path2) => path2.includes("/") ? path2.slice(0, path2.lastIndexOf("/")) : ".";
2370
+ var basename = (path2) => path2.slice(path2.lastIndexOf("/") + 1);
2371
+ var extname = (file) => file.lastIndexOf(".") > 0 ? file.slice(file.lastIndexOf(".")) : "";
2372
+ var join = (dir, file) => dir === "." ? file : `${dir}/${file}`;
2373
+ var LOADERS = { ".js": "js", ".mjs": "js", ".cjs": "js", ".jsx": "jsx", ".tsx": "tsx" };
2379
2374
  var identifier = (stem) => {
2380
2375
  const name = stem.replace(/[^\w$]/g, "_");
2381
2376
  return /^[0-9]/.test(name) ? `_${name}` : name || "subject";
@@ -2386,11 +2381,10 @@ class CannotBind extends Error {
2386
2381
  async function render(template, subject, ctx) {
2387
2382
  const dir = dirname(subject);
2388
2383
  const file = basename(subject);
2389
- const stem = basename(file, extname(file));
2390
- const loader = LOADERS[extname(file)] ?? "ts";
2384
+ const stem = file.slice(0, file.length - extname(file).length);
2391
2385
  let exports;
2392
2386
  try {
2393
- exports = new Bun.Transpiler({ loader }).scan(await ctx.readFile(subject)).exports;
2387
+ exports = new Bun.Transpiler({ loader: LOADERS[extname(file)] ?? "ts" }).scan(await ctx.readFile(subject)).exports;
2394
2388
  } catch (error) {
2395
2389
  throw new CannotBind(`can't read the exports of ${subject}: ${error.message}`);
2396
2390
  }
@@ -2403,8 +2397,9 @@ async function render(template, subject, ctx) {
2403
2397
  let subjectPath = `./${file}`;
2404
2398
  if (defaultExport === "default") {
2405
2399
  defaultExport = identifier(stem);
2406
- subjectPath = `./${stem}.templateTest.bind.ts`;
2407
- await ctx.writeFile(join(dir, subjectPath), `export { default as ${defaultExport} } from "./${file}";
2400
+ const binding = `${stem}.templateTest.bind.ts`;
2401
+ subjectPath = `./${binding}`;
2402
+ await ctx.writeFile(join(dir, binding), `export { default as ${defaultExport} } from "./${file}";
2408
2403
  `);
2409
2404
  }
2410
2405
  const values = { defaultExport, subjectPath };
@@ -2423,11 +2418,14 @@ async function runTest(testPath, ctx) {
2423
2418
  ${stderr}`;
2424
2419
  const count = (label) => Number(new RegExp(`^\\s*(\\d+) ${label}$`, "m").exec(output)?.[1] ?? "0");
2425
2420
  const ran = /^Ran (\d+) tests? /m.exec(output);
2426
- if (!ran || ran[1] === "0")
2427
- throw new CannotBind(`bun test ${testPath} reported no results`);
2421
+ if (!ran || ran[1] === "0") {
2422
+ const said = output.trim().split(`
2423
+ `).at(-1);
2424
+ throw new CannotBind(`bun test ./${testPath} reported no results${said ? `: ${said}` : ""}`);
2425
+ }
2428
2426
  if (count("errors?") > 0) {
2429
2427
  const error = /^\w*error: .*$/im.exec(output)?.[0];
2430
- throw new CannotBind(`bun test ${testPath} failed outside any test${error ? `: ${error}` : ""}`);
2428
+ throw new CannotBind(`bun test ./${testPath} failed outside any test${error ? `: ${error}` : ""}`);
2431
2429
  }
2432
2430
  return { passed: count("pass"), failed: count("fail") };
2433
2431
  }
@@ -2454,1061 +2452,1012 @@ var templateTest = {
2454
2452
  checkConfig(config) {
2455
2453
  const { subject, template } = config ?? {};
2456
2454
  return [
2457
- ...checkPath("config.subject", subject, "a glob pattern"),
2458
- ...checkPath("config.template", template, "a path relative to the measurement file")
2455
+ ...checkString("config.subject", subject, "a glob pattern"),
2456
+ ...checkString("config.template", template, "a path relative to the measurement's file")
2459
2457
  ];
2460
2458
  },
2461
2459
  bundledFiles: ["template"]
2462
2460
  };
2463
- var instruments = { fileCreated, grep, templateTest };
2461
+ var instruments = { fileCreated, grep, templateTest, replyMatches };
2462
+ var aggregateMethods = {
2463
+ fileCreated: ["rate", "count"],
2464
+ grep: ["mean", "median", "min", "max", "sum"],
2465
+ templateTest: ["sum", "mean"],
2466
+ replyMatches: ["rate", "count"]
2467
+ };
2464
2468
  var isInstrumentName = (name) => typeof name === "string" && Object.hasOwn(instruments, name);
2465
2469
 
2466
- // src/project/config.ts
2467
- class ConfigError extends Error {
2468
- }
2469
-
2470
- class ThunderjarRootNotFoundError extends ConfigError {
2471
- constructor(startDir) {
2472
- super(`error: no .thunderjar/ folder found in ${startDir} or any parent directory.
2473
- Run \`thunderjar init\` first.`);
2474
- }
2475
- }
2476
-
2477
- class TestCaseNotFoundError extends ConfigError {
2478
- constructor(name, path2) {
2479
- super(`error: no test case named "${name}" (expected ${path2})`);
2480
- }
2481
- }
2482
-
2483
- class InvalidConfigError extends ConfigError {
2484
- problems;
2485
- constructor(problems) {
2486
- super(problems.map(({ file, field, message }) => `error: .thunderjar/${file}: ${field ? `${field}: ` : ""}${message}`).join(`
2487
- `));
2488
- this.problems = problems;
2489
- }
2490
- }
2491
- function findThunderjarRoot(startDir = process.cwd()) {
2492
- let dir = resolve(startDir);
2493
- for (;; ) {
2494
- const candidate = join(dir, ".thunderjar");
2495
- if (existsSync(candidate))
2496
- return candidate;
2497
- const parent = dirname(dir);
2498
- if (parent === dir)
2499
- throw new ThunderjarRootNotFoundError(startDir);
2500
- dir = parent;
2501
- }
2502
- }
2503
- async function loadDeclaration(root, file, declareFn, problems) {
2504
- let module;
2505
- try {
2506
- module = await import(join(root, file));
2507
- } catch (error) {
2508
- problems.push({ file, message: `could not be loaded: ${error instanceof Error ? error.message : String(error)}` });
2509
- return;
2510
- }
2511
- if (!("default" in module) || module.default === undefined) {
2512
- problems.push({ file, message: `has no default export. Export \`${declareFn}(...)\` as the default.` });
2513
- return;
2514
- }
2515
- const by = declaredBy(module.default);
2516
- if (by !== declareFn) {
2517
- problems.push({
2518
- file,
2519
- message: by ? `default export is wrapped in ${by}, expected ${declareFn}.` : `default export is not wrapped in ${declareFn}(...).`
2520
- });
2521
- return;
2522
- }
2523
- return module.default;
2524
- }
2470
+ // src/config/measurements.ts
2525
2471
  var show2 = (value) => JSON.stringify(value) ?? String(value);
2526
- function requireFile(root, testCaseFile, field, name, file, problems) {
2527
- if (existsSync(join(root, file)))
2528
- return true;
2529
- problems.push({ file: testCaseFile, field, message: `${show2(name)} has no matching ${file}` });
2530
- return false;
2531
- }
2532
- var FOLDER_PARAMETERS = [
2533
- ["baseImage", "baseImages", "declareBaseImage"],
2534
- ["codeState", "codeStates", "declareCodeState"],
2535
- ["promptSet", "promptSets", "declarePromptSet"]
2536
- ];
2537
- var ARRAY_FIELDS = ["baseImage", "codeState", "promptSet", "harnessModel", "initialPrompt"];
2538
- var PAIR = /^([^@/]+)@([^/]+)\/(.+)$/;
2539
- function parseHarnessModelPair(pair) {
2540
- const match = PAIR.exec(pair);
2541
- if (!match)
2542
- return;
2543
- const [, family, version, model] = match;
2544
- return { family, version, model };
2472
+ var sha2562 = (data) => new CryptoHasher2("sha256").update(data).digest("hex");
2473
+ function normalize(path2) {
2474
+ const segments = [];
2475
+ for (const segment of path2.split("/")) {
2476
+ if (segment === "..")
2477
+ segments.pop();
2478
+ else if (segment !== "." && segment !== "")
2479
+ segments.push(segment);
2480
+ }
2481
+ return `${path2.startsWith("/") ? "/" : ""}${segments.join("/")}`;
2545
2482
  }
2546
- async function collectMeasurements(root, task, problems) {
2547
- const dir = `test-case-parameters/tasks/${task}/measurements`;
2548
- if (!existsSync(join(root, dir)))
2549
- return [];
2550
- const files = [...new Bun.Glob("*.ts").scanSync({ cwd: join(root, dir) })].filter((name) => !basename(name, ".ts").includes(".")).toSorted();
2483
+ async function checkMeasurements(root, task, problems) {
2484
+ const dir = `${PARAMETERS}/tasks/${task}/measurements`;
2551
2485
  const measurements = [];
2552
- for (const name of files) {
2553
- const file = `${dir}/${name}`;
2486
+ for (const name of (await tsFilesIn(root, dir)).filter((name2) => !name2.includes("."))) {
2487
+ const file = `${dir}/${name}.ts`;
2488
+ const problem = (field, message) => problems.push({ file, field, message });
2554
2489
  const declaration = await loadDeclaration(root, file, "declareMeasurement", problems);
2555
2490
  if (!declaration)
2556
2491
  continue;
2557
2492
  const { instrument, config, aggregate } = declaration;
2558
2493
  if (!isInstrumentName(instrument)) {
2559
- problems.push({
2560
- file,
2561
- field: "instrument",
2562
- message: `${show2(instrument)} is not a known instrument. Known instruments: ${Object.keys(instruments).join(", ")}`
2563
- });
2494
+ problem("instrument", `${show2(instrument)} is not a known instrument. Known instruments: ${Object.keys(instruments).join(", ")}`);
2564
2495
  continue;
2565
2496
  }
2566
2497
  const before = problems.length;
2567
2498
  const { checkConfig, bundledFiles = [] } = instruments[instrument];
2568
2499
  const configProblems = checkConfig(config);
2569
2500
  for (const [field, message] of configProblems)
2570
- problems.push({ file, field, message });
2501
+ problem(field, message);
2502
+ const fields = { ...config };
2503
+ const files = {};
2571
2504
  if (configProblems.length === 0) {
2572
2505
  for (const key of bundledFiles) {
2573
- const path2 = config[key];
2574
- requireFile(root, file, `config.${key}`, path2, join(dir, path2), problems);
2506
+ const path2 = normalize(`${dir}/${fields[key]}`);
2507
+ if (!await existsIn(root, path2)) {
2508
+ problem(`config.${key}`, `${show2(fields[key])} has no matching ${FOLDER}/${path2}`);
2509
+ continue;
2510
+ }
2511
+ fields[key] = `${root}/${path2}`;
2512
+ files[key] = sha2562(await Bun.file(fields[key]).arrayBuffer());
2575
2513
  }
2576
2514
  }
2577
2515
  if (!Array.isArray(aggregate)) {
2578
- problems.push({ file, field: "aggregate", message: `must be an array of aggregation methods, got ${show2(aggregate)}` });
2516
+ problem("aggregate", `must be an array of aggregation methods, got ${show2(aggregate)}`);
2579
2517
  } else {
2580
- const menu = aggregateMethods[instrument];
2581
- for (const method of aggregate) {
2582
- if (menu.includes(method))
2583
- continue;
2584
- problems.push({
2585
- file,
2586
- field: "aggregate",
2587
- message: `${show2(method)} is not a way ${instrument}'s values combine. Use one of: ${menu.join(", ")}`
2588
- });
2518
+ const methods = aggregateMethods[instrument];
2519
+ for (const method of aggregate.filter((method2) => !methods.includes(method2))) {
2520
+ problem("aggregate", `${show2(method)} is not a way ${instrument}'s values combine. Use one of: ${methods.join(", ")}`);
2589
2521
  }
2590
2522
  }
2591
2523
  if (problems.length === before) {
2592
- measurements.push({ ...declaration, name: basename(name, ".ts"), dir: join(root, dir) });
2524
+ measurements.push({
2525
+ name,
2526
+ declaration,
2527
+ config: fields,
2528
+ contentHash: sha2562(JSON.stringify({ instrument, config, files }))
2529
+ });
2593
2530
  }
2594
2531
  }
2595
2532
  return measurements;
2596
2533
  }
2597
2534
  async function loadMeasurements(root, task) {
2598
2535
  const problems = [];
2599
- const measurements = await collectMeasurements(root, task, problems);
2536
+ const measurements = await checkMeasurements(root, task, problems);
2600
2537
  if (problems.length > 0)
2601
- throw new InvalidConfigError(problems);
2538
+ throw problemsError(problems);
2602
2539
  return measurements;
2603
2540
  }
2604
- async function checkTestCase(root, file, testCase, problems) {
2605
- const parameters = "test-case-parameters";
2606
- const names = {};
2607
- for (const field of ARRAY_FIELDS) {
2608
- const value = testCase[field];
2609
- if (!Array.isArray(value)) {
2610
- problems.push({ file, field, message: `must be an array of names, got ${show2(value)}` });
2611
- } else if (value.length === 0) {
2612
- problems.push({ file, field, message: "must name at least one value, got []" });
2613
- } else if (value.some((v) => typeof v !== "string")) {
2614
- problems.push({ file, field, message: `must be an array of names, got ${show2(value)}` });
2615
- } else {
2616
- names[field] = value;
2541
+
2542
+ // src/config/requiredEnv.ts
2543
+ var NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
2544
+ var show3 = (value) => JSON.stringify(value) ?? String(value);
2545
+ function requiredEnvProblem(requiredEnv) {
2546
+ if (requiredEnv === undefined)
2547
+ return;
2548
+ if (!Array.isArray(requiredEnv))
2549
+ return `must be an array of environment variable names, got ${show3(requiredEnv)}`;
2550
+ const bad = requiredEnv.filter((name) => typeof name !== "string" || !NAME.test(name));
2551
+ if (bad.length > 0)
2552
+ return `${bad.map(show3).join(", ")} ${bad.length === 1 ? "is not an environment variable name" : "are not environment variable names"}`;
2553
+ return;
2554
+ }
2555
+ var requiredEnvOf = (harness) => harness.requiredEnv ?? [];
2556
+ async function checkRequiredEnv(root, testCase, env = Bun.env) {
2557
+ const harnesses = [...new Set(testCase.harnessModel.map((pair) => parsePair(pair).harness))];
2558
+ const problems = [];
2559
+ const missing = [];
2560
+ for (const name of harnesses) {
2561
+ const harness = await loadDeclaration(root, `${PARAMETERS}/harnessModels/${name}/index.ts`, "declareHarness", problems);
2562
+ for (const variable of harness ? requiredEnvOf(harness) : []) {
2563
+ if (!env[variable])
2564
+ missing.push(`${name} needs ${variable}, which is not set.`);
2565
+ }
2566
+ }
2567
+ if (problems.length > 0)
2568
+ throw problemsError(problems);
2569
+ if (missing.length === 0)
2570
+ return;
2571
+ const hint = `Export ${missing.length === 1 ? "it" : "them"} in the shell that runs thunderjar.`;
2572
+ throw new ConfigError(...missing.slice(0, -1), `${missing.at(-1)}
2573
+ ${hint}`);
2574
+ }
2575
+
2576
+ // src/config/runOptions.ts
2577
+ var show4 = (value) => JSON.stringify(value) ?? String(value);
2578
+ var RESERVED = ["--name", "--pull", "--entrypoint", "--rm", "--detach", "-d"];
2579
+ function runOptionsProblem(runOptions) {
2580
+ if (runOptions === undefined)
2581
+ return;
2582
+ if (!Array.isArray(runOptions) || runOptions.some((option) => typeof option !== "string")) {
2583
+ return `must be an array of docker run options and their values, each a string, got ${show4(runOptions)}`;
2584
+ }
2585
+ const reserved = runOptions.filter((option) => RESERVED.includes(option.split("=")[0]));
2586
+ if (reserved.length > 0)
2587
+ return `${reserved.map(show4).join(", ")} can't be given: Thunderjar sets ${reserved.length === 1 ? "it" : "them"} itself`;
2588
+ return;
2589
+ }
2590
+
2591
+ // src/config/testCase.ts
2592
+ var show5 = (value) => JSON.stringify(value) ?? String(value);
2593
+ var PARAMETER_FOLDERS = [
2594
+ ["baseImages", "base images"],
2595
+ ["codeStates", "code states"],
2596
+ ["promptSets", "prompt sets"],
2597
+ ["harnessModels", "harnesses"],
2598
+ ["tasks", "tasks"]
2599
+ ];
2600
+ async function checkParameterFolders(root) {
2601
+ const problems = [];
2602
+ for (const [folder, what] of PARAMETER_FOLDERS) {
2603
+ if ((await foldersIn(root, `${PARAMETERS}/${folder}`)).length === 0) {
2604
+ problems.push(`${FOLDER}/${PARAMETERS}/${folder}/ has no ${what}`);
2617
2605
  }
2618
2606
  }
2619
- const iterations = testCase.iterations;
2620
- if (typeof iterations !== "number" || !Number.isInteger(iterations) || iterations < 1) {
2621
- problems.push({ file, field: "iterations", message: `must be a positive integer, got ${show2(iterations)}` });
2607
+ if (problems.length > 0)
2608
+ throw new ConfigError(...problems);
2609
+ }
2610
+ var FOLDER_FIELDS = [
2611
+ ["baseImage", "baseImages", "declareBaseImage"],
2612
+ ["codeState", "codeStates", "declareCodeState"],
2613
+ ["promptSet", "promptSets", "declarePromptSet"]
2614
+ ];
2615
+ var ARRAY_FIELDS = ["baseImage", "codeState", "promptSet", "harnessModel", "initialPrompt"];
2616
+ var HARNESS_MODEL = /^([^@/]+)@([^/]+)\/(.+)$/;
2617
+
2618
+ class TestCaseChecker {
2619
+ root;
2620
+ file;
2621
+ problems = [];
2622
+ harnesses = new Map;
2623
+ constructor(root, file) {
2624
+ this.root = root;
2625
+ this.file = file;
2626
+ }
2627
+ problem(field, message) {
2628
+ this.problems.push({ file: this.file, field, message });
2629
+ }
2630
+ async requires(field, name, path2) {
2631
+ if (await existsIn(this.root, path2))
2632
+ return true;
2633
+ this.problem(field, `${show5(name)} has no matching ${FOLDER}/${path2}`);
2634
+ return false;
2622
2635
  }
2623
- for (const [field, folder, declareFn] of FOLDER_PARAMETERS) {
2624
- for (const name of names[field] ?? []) {
2625
- const index = `${parameters}/${folder}/${name}/index.ts`;
2626
- if (requireFile(root, file, field, name, index, problems)) {
2627
- await loadDeclaration(root, index, declareFn, problems);
2636
+ arrays(testCase) {
2637
+ const names = {};
2638
+ for (const field of ARRAY_FIELDS) {
2639
+ const value = testCase[field];
2640
+ if (!Array.isArray(value) || value.some((name) => typeof name !== "string")) {
2641
+ this.problem(field, `must be an array of names, got ${show5(value)}`);
2642
+ } else if (value.length === 0) {
2643
+ this.problem(field, "must name at least one value, got []");
2644
+ } else {
2645
+ names[field] = value;
2628
2646
  }
2629
2647
  }
2648
+ return names;
2630
2649
  }
2631
- const task = testCase.task;
2632
- let taskExists = false;
2633
- if (typeof task !== "string") {
2634
- problems.push({ file, field: "task", message: `must be a task name, got ${show2(task)}` });
2635
- } else {
2636
- taskExists = requireFile(root, file, "task", task, `${parameters}/tasks/${task}/`, problems);
2637
- }
2638
- if (taskExists) {
2639
- await collectMeasurements(root, task, problems);
2640
- for (const name of names.initialPrompt ?? []) {
2641
- const prompt = `${parameters}/tasks/${task}/initial-prompts/${name}.ts`;
2642
- if (existsSync(join(root, prompt))) {
2643
- await loadDeclaration(root, prompt, "declareInitialPrompt", problems);
2644
- continue;
2650
+ checkIterations(iterations) {
2651
+ if (typeof iterations !== "number" || !Number.isInteger(iterations) || iterations < 1) {
2652
+ this.problem("iterations", `must be a positive integer, got ${show5(iterations)}`);
2653
+ }
2654
+ }
2655
+ async checkFolderFields(names) {
2656
+ for (const [field, folder, declareFn] of FOLDER_FIELDS) {
2657
+ for (const name of names[field] ?? []) {
2658
+ const index = `${PARAMETERS}/${folder}/${name}/index.ts`;
2659
+ if (await this.requires(field, name, index)) {
2660
+ const declaration = await loadDeclaration(this.root, index, declareFn, this.problems);
2661
+ const optionsProblem = field === "baseImage" && declaration && runOptionsProblem(declaration.runOptions);
2662
+ if (optionsProblem)
2663
+ this.problems.push({ file: index, field: "runOptions", message: optionsProblem });
2664
+ }
2645
2665
  }
2646
- const owner = new Bun.Glob(`${parameters}/tasks/*/initial-prompts/${name}.ts`).scanSync({ cwd: root }).next().value;
2647
- problems.push({
2648
- file,
2649
- field: "initialPrompt",
2650
- message: owner ? `${show2(name)} belongs to task ${show2(owner.split("/")[2])}, not ${show2(task)}` : `${show2(name)} has no matching ${prompt}`
2651
- });
2652
2666
  }
2653
2667
  }
2654
- for (const pair of names.harnessModel ?? []) {
2655
- const parsed = parseHarnessModelPair(pair);
2656
- if (!parsed) {
2657
- problems.push({ file, field: "harnessModel", message: `${show2(pair)} is not of the form family@version/model` });
2658
- continue;
2668
+ async checkTask(task, initialPrompts) {
2669
+ if (typeof task !== "string") {
2670
+ this.problem("task", `must be a task name, got ${show5(task)}`);
2671
+ return;
2659
2672
  }
2660
- const { family, version, model } = parsed;
2661
- const familyDir = `${parameters}/harnessModels/${family}`;
2662
- if (!requireFile(root, file, "harnessModel", pair, `${familyDir}/`, problems))
2663
- continue;
2664
- const familyProblems = [];
2665
- await loadDeclaration(root, `${familyDir}/index.ts`, "declareHarness", familyProblems);
2666
- await loadDeclaration(root, `${familyDir}/collectTokenCosts.ts`, "declareTokenCostCollector", familyProblems);
2667
- const versions = await loadDeclaration(root, `${familyDir}/versions.ts`, "declareHarnessVersions", familyProblems);
2668
- const models = await loadDeclaration(root, `${familyDir}/models.ts`, "declareHarnessModels", familyProblems);
2669
- for (const p of familyProblems) {
2670
- if (!problems.some((q) => q.file === p.file && q.message === p.message))
2671
- problems.push(p);
2672
- }
2673
- if (versions && !Object.hasOwn(versions, version)) {
2674
- problems.push({
2675
- file,
2676
- field: "harnessModel",
2677
- message: `${show2(pair)}: version ${show2(version)} is not in ${familyDir}/versions.ts`
2678
- });
2673
+ if (!await this.requires("task", task, `${PARAMETERS}/tasks/${task}/`))
2674
+ return;
2675
+ await checkMeasurements(this.root, task, this.problems);
2676
+ for (const name of initialPrompts) {
2677
+ const prompt = `${PARAMETERS}/tasks/${task}/initial-prompts/${name}.ts`;
2678
+ if (await existsIn(this.root, prompt)) {
2679
+ const declaration = await loadDeclaration(this.root, prompt, "declareInitialPrompt", this.problems);
2680
+ const timeout = declaration?.timeoutSeconds;
2681
+ if (declaration && (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0)) {
2682
+ this.problems.push({ file: prompt, field: "timeoutSeconds", message: `must be a number of seconds above 0, got ${show5(timeout)}` });
2683
+ }
2684
+ if (declaration)
2685
+ this.checkPrompt(prompt, declaration);
2686
+ continue;
2687
+ }
2688
+ const owner = new Bun.Glob(`${PARAMETERS}/tasks/*/initial-prompts/${name}.ts`).scanSync({ cwd: this.root }).next().value;
2689
+ this.problem("initialPrompt", owner ? `${show5(name)} belongs to task ${show5(owner.split("/")[2])}, not ${show5(task)}` : `${show5(name)} has no matching ${FOLDER}/${prompt}`);
2679
2690
  }
2680
- if (models && !Object.hasOwn(models, model)) {
2681
- problems.push({
2682
- file,
2683
- field: "harnessModel",
2684
- message: `${show2(pair)}: model ${show2(model)} is not in ${familyDir}/models.ts`
2685
- });
2691
+ }
2692
+ checkPrompt(file, { prompt, turns }) {
2693
+ const problem = (field, message) => this.problems.push({ file, field, message });
2694
+ if (prompt !== undefined && turns !== undefined) {
2695
+ problem("turns", "can't be given beside prompt. Give a single prompt, or a list of turns.");
2696
+ } else if (turns !== undefined) {
2697
+ if (!Array.isArray(turns) || turns.length === 0 || turns.some((turn) => typeof turn !== "string")) {
2698
+ problem("turns", `must be a list of at least one prompt, got ${show5(turns)}`);
2699
+ }
2700
+ } else if (typeof prompt !== "string") {
2701
+ problem("prompt", `must be the prompt's text, or give a list of turns instead, got ${show5(prompt)}`);
2702
+ }
2703
+ }
2704
+ async harness(name) {
2705
+ const loaded = this.harnesses.get(name);
2706
+ if (loaded)
2707
+ return loaded;
2708
+ const dir = `${PARAMETERS}/harnessModels/${name}`;
2709
+ const declaration = await loadDeclaration(this.root, `${dir}/index.ts`, "declareHarness", this.problems);
2710
+ const envProblem = declaration && requiredEnvProblem(declaration.requiredEnv);
2711
+ if (envProblem)
2712
+ this.problems.push({ file: `${dir}/index.ts`, field: "requiredEnv", message: envProblem });
2713
+ if (declaration && declaration.collectReply !== undefined && typeof declaration.collectReply !== "function") {
2714
+ this.problems.push({ file: `${dir}/index.ts`, field: "collectReply", message: `must be a function of the result file's text, got ${show5(declaration.collectReply)}` });
2715
+ }
2716
+ await loadDeclaration(this.root, `${dir}/collectTokenCosts.ts`, "declareTokenCostCollector", this.problems);
2717
+ const harness = {
2718
+ versions: await loadDeclaration(this.root, `${dir}/versions.ts`, "declareHarnessVersions", this.problems),
2719
+ models: await loadDeclaration(this.root, `${dir}/models.ts`, "declareHarnessModels", this.problems)
2720
+ };
2721
+ this.harnesses.set(name, harness);
2722
+ return harness;
2723
+ }
2724
+ async checkHarnessModels(pairs) {
2725
+ for (const pair of pairs) {
2726
+ const match = HARNESS_MODEL.exec(pair);
2727
+ if (!match) {
2728
+ this.problem("harnessModel", `${show5(pair)} is not of the form harness@version/model`);
2729
+ continue;
2730
+ }
2731
+ const [, name, version, model] = match;
2732
+ const dir = `${PARAMETERS}/harnessModels/${name}`;
2733
+ if (!await this.requires("harnessModel", pair, `${dir}/`))
2734
+ continue;
2735
+ const { versions, models } = await this.harness(name);
2736
+ if (versions && !Object.hasOwn(versions, version)) {
2737
+ this.problem("harnessModel", `${show5(pair)}: version ${show5(version)} is not in ${FOLDER}/${dir}/versions.ts`);
2738
+ }
2739
+ if (models && !Object.hasOwn(models, model)) {
2740
+ this.problem("harnessModel", `${show5(pair)}: model ${show5(model)} is not in ${FOLDER}/${dir}/models.ts`);
2741
+ }
2686
2742
  }
2687
2743
  }
2744
+ async check(testCase) {
2745
+ const names = this.arrays(testCase);
2746
+ this.checkIterations(testCase.iterations);
2747
+ await this.checkFolderFields(names);
2748
+ await this.checkTask(testCase.task, names.initialPrompt ?? []);
2749
+ await this.checkHarnessModels(names.harnessModel ?? []);
2750
+ }
2688
2751
  }
2689
2752
  async function loadTestCase(root, name) {
2753
+ await checkParameterFolders(root);
2690
2754
  const file = `test-cases/${name}.ts`;
2691
- const path2 = join(root, file);
2692
- if (!existsSync(path2))
2693
- throw new TestCaseNotFoundError(name, path2);
2694
- const problems = [];
2695
- const testCase = await loadDeclaration(root, file, "declareTestCase", problems);
2755
+ if (!await existsIn(root, file)) {
2756
+ throw new ConfigError(`No test case named "${name}": expected ${FOLDER}/${file}.`);
2757
+ }
2758
+ const checker = new TestCaseChecker(root, file);
2759
+ const testCase = await loadDeclaration(root, file, "declareTestCase", checker.problems);
2696
2760
  if (testCase)
2697
- await checkTestCase(root, file, testCase, problems);
2698
- if (problems.length > 0)
2699
- throw new InvalidConfigError(problems);
2761
+ await checker.check(testCase);
2762
+ if (checker.problems.length > 0)
2763
+ throw problemsError(checker.problems);
2700
2764
  return testCase;
2701
2765
  }
2702
2766
 
2703
- // src/matrix/permutations.ts
2704
- var AXES = ["baseImage", "codeState", "promptSet", "harnessModel", "initialPrompt"];
2705
- function matrixShape(testCase) {
2706
- return AXES.map((axis) => testCase[axis].length);
2707
- }
2708
- function expandPermutations(testCase) {
2709
- const permutations = [];
2710
- for (const baseImage of testCase.baseImage)
2711
- for (const codeState of testCase.codeState)
2712
- for (const promptSet of testCase.promptSet)
2713
- for (const harnessModel of testCase.harnessModel)
2714
- for (const initialPrompt of testCase.initialPrompt)
2715
- permutations.push({ baseImage, codeState, promptSet, harnessModel, initialPrompt });
2716
- return permutations;
2717
- }
2718
- function permutationRows(permutations) {
2719
- const cells = permutations.map((p) => AXES.map((axis) => p[axis]));
2720
- const widths = AXES.map((_, i) => Math.max(...cells.map((row) => row[i].length)));
2721
- return cells.map((row) => row.map((cell, i) => i === AXES.length - 1 ? cell : cell.padEnd(widths[i])).join(" | "));
2722
- }
2767
+ // src/docker/docker.ts
2768
+ var {$ } = globalThis.Bun;
2723
2769
 
2724
- // src/matrix/hashing.ts
2725
- var sha256 = (data) => new Bun.CryptoHasher("sha256").update(data).digest("hex");
2726
- function canonical(value) {
2727
- if (typeof value === "function")
2728
- return JSON.stringify(value.toString());
2729
- if (Array.isArray(value))
2730
- return `[${value.map(canonical).join(",")}]`;
2731
- if (typeof value === "object" && value !== null) {
2732
- const keys = Object.keys(value).toSorted();
2733
- return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(",")}}`;
2734
- }
2735
- return JSON.stringify(value) ?? "null";
2736
- }
2737
- var hashValue = (value) => sha256(canonical(value));
2738
- var abbreviateHash = (hash) => hash.slice(0, 8);
2739
- function hashFolder(dir, exclude = []) {
2740
- const files = listFilesSync(dir).filter((file) => !exclude.includes(file));
2741
- return hashValue(files.map((file) => [file, sha256(readBytesSync(join(dir, file)))]));
2742
- }
2743
- var hashBaseImage = (path2) => hashFolder(path2);
2744
- var hashCodeState = (path2) => hashFolder(path2);
2745
- var hashPromptSet = (path2) => hashFolder(path2);
2746
- var hashInitialPrompt = (path2) => sha256(readBytesSync(path2));
2747
- var FAMILY_MAP_FILES = ["models.ts", "versions.ts", "collectTokenCosts.ts"];
2748
- var EXECUTION_OVERRIDES = ["applyParameter", "cli", "requiredEnv"];
2749
- async function loadMap(file) {
2750
- const module = await import(file);
2751
- return module.default ?? {};
2752
- }
2753
- async function hashHarnessModelPair(family, version, model) {
2754
- const versions = await loadMap(join(family, "versions.ts"));
2755
- const models = await loadMap(join(family, "models.ts"));
2756
- if (!Object.hasOwn(versions, version))
2757
- throw new Error(`version "${version}" is not in ${family}/versions.ts`);
2758
- if (!Object.hasOwn(models, model))
2759
- throw new Error(`model "${model}" is not in ${family}/models.ts`);
2760
- const entry = versions[version];
2761
- if (typeof entry.version !== "string") {
2762
- throw new Error(`version "${version}" in ${family}/versions.ts has no pinned version; floating versions are not supported yet`);
2763
- }
2764
- const overrides = Object.fromEntries(EXECUTION_OVERRIDES.filter((k) => (k in entry)).map((k) => [k, entry[k]]));
2765
- return hashValue({
2766
- shared: hashFolder(family, FAMILY_MAP_FILES),
2767
- version: entry.version,
2768
- overrides,
2769
- modelId: models[model]
2770
- });
2771
- }
2772
- var hashPermutation = (hashes) => hashValue(hashes);
2773
- function hashMeasurement({ instrument, config, dir }) {
2774
- const keys = isInstrumentName(instrument) ? instruments[instrument].bundledFiles ?? [] : [];
2775
- if (keys.length === 0)
2776
- return hashValue({ instrument, config });
2777
- if (dir === undefined)
2778
- throw new Error(`a ${instrument} measurement's hash needs the folder its bundled files are in`);
2779
- const paths = config;
2780
- const files = Object.fromEntries(keys.map((key) => [key, sha256(readBytesSync(resolve(dir, paths[key])))]));
2781
- return hashValue({ instrument, config, files });
2770
+ class DockerUnavailableError extends Error {
2782
2771
  }
2783
2772
 
2784
- // src/matrix/tags.ts
2785
- var prerunTag = (permutationHash) => `prerun-h${abbreviateHash(permutationHash)}`;
2786
- var postrunTag = (executionId, permutationHash, iterationIndex) => `postrun-e${executionId}-h${abbreviateHash(permutationHash)}-i${String(iterationIndex).padStart(2, "0")}`;
2787
- var imageRef = (repository, tag) => `${repository}:${tag}`;
2773
+ class DockerBuildError extends Error {
2774
+ }
2788
2775
 
2789
- // src/images/prerun.ts
2790
- var LOCAL_REPOSITORY = "thunderjar";
2791
- var prerunImageRef = (repository, permutationHash) => imageRef(repository, prerunTag(permutationHash));
2792
- var PARAMETER_DIRS = {
2793
- baseImage: "parameters/baseImage",
2794
- harness: "parameters/harnessModel",
2795
- codeState: "parameters/codeState",
2796
- promptSet: "parameters/promptSet"
2797
- };
2798
- var importDefault = async (path2) => (await import(path2)).default;
2799
- async function loadPrerunParameters(root, task, permutation) {
2800
- const parameters = join(root, "test-case-parameters");
2801
- const folder = async (dir, owner) => {
2802
- const { config, applyParameter } = await importDefault(join(dir, "index.ts"));
2803
- return { owner, dir, config, applyParameter };
2804
- };
2805
- const { family, version } = parseHarnessModelPair(permutation.harnessModel);
2806
- const familyDir = join(parameters, "harnessModels", family);
2807
- const harness = await importDefault(join(familyDir, "index.ts"));
2808
- const entry = (await importDefault(join(familyDir, "versions.ts")))[version];
2809
- if (!("version" in entry))
2810
- throw new Error(`${permutation.harnessModel}: floating versions are not supported yet`);
2811
- const initialPrompt = await importDefault(join(parameters, "tasks", task, "initial-prompts", `${permutation.initialPrompt}.ts`));
2812
- return {
2813
- baseImage: await folder(join(parameters, "baseImages", permutation.baseImage), `base image "${permutation.baseImage}"`),
2814
- harness: {
2815
- owner: `harness/model pair "${permutation.harnessModel}"`,
2816
- dir: familyDir,
2817
- exclude: FAMILY_MAP_FILES,
2818
- config: harness.config,
2819
- applyParameter: entry.applyParameter ?? harness.applyParameter,
2820
- version: entry.version
2821
- },
2822
- codeState: await folder(join(parameters, "codeStates", permutation.codeState), `code state "${permutation.codeState}"`),
2823
- promptSet: await folder(join(parameters, "promptSets", permutation.promptSet), `prompt set "${permutation.promptSet}"`),
2824
- initialPrompt: {
2825
- owner: `initial prompt "${permutation.initialPrompt}"`,
2826
- config: initialPrompt.config,
2827
- applyParameter: initialPrompt.applyParameter
2776
+ class DockerContainerError extends Error {
2777
+ }
2778
+ var dockerBinary = () => Bun.env.THUNDERJAR_DOCKER || "docker";
2779
+ var reasonOf = (stderr) => stderr.toString().trim().split(`
2780
+ `).at(-1);
2781
+ async function buildImage(contextDir, image) {
2782
+ const binary = dockerBinary();
2783
+ const result = await $`${binary} build -t ${image} ${contextDir}`.quiet().nothrow();
2784
+ if (result.exitCode !== 0) {
2785
+ throw new DockerBuildError(reasonOf(result.stderr) || `\`${binary} build\` exited ${result.exitCode}`);
2786
+ }
2787
+ }
2788
+ async function imagesIn(repository) {
2789
+ const binary = dockerBinary();
2790
+ const result = await $`${binary} image ls --format ${"{{.Repository}}:{{.Tag}}"} ${repository}`.quiet().nothrow();
2791
+ if (result.exitCode !== 0) {
2792
+ throw new DockerUnavailableError(reasonOf(result.stderr) || `\`${binary} image ls\` exited ${result.exitCode}`);
2793
+ }
2794
+ return new Set(result.stdout.toString().split(`
2795
+ `).filter(Boolean));
2796
+ }
2797
+ async function mustRun(args) {
2798
+ const binary = dockerBinary();
2799
+ const result = await $`${binary} ${args}`.quiet().nothrow();
2800
+ if (result.exitCode !== 0) {
2801
+ throw new DockerContainerError(reasonOf(result.stderr) || `\`${binary} ${args[0]}\` exited ${result.exitCode}`);
2802
+ }
2803
+ return result.stdout.toString();
2804
+ }
2805
+ async function runContainer(image, name, script, { env = [], runOptions = [], timeoutSeconds } = {}) {
2806
+ const binary = dockerBinary();
2807
+ const passed = env.flatMap((variable) => ["-e", variable]);
2808
+ const args = ["run", "--name", name, "--pull", "never", ...passed, ...runOptions, "--entrypoint", "sh", image, "-c", script];
2809
+ const child = Bun.spawn([binary, ...args], { env: { ...Bun.env }, stdin: "ignore", stdout: "ignore", stderr: "pipe" });
2810
+ let killing;
2811
+ const timer = timeoutSeconds === undefined ? undefined : setTimeout(() => {
2812
+ const killedAt2 = Date.now();
2813
+ killing = mustRun(["kill", name]).then(() => killedAt2, () => {
2814
+ return;
2815
+ });
2816
+ }, timeoutSeconds * 1000);
2817
+ const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
2818
+ clearTimeout(timer);
2819
+ const killedAt = await killing;
2820
+ if (killedAt !== undefined)
2821
+ return { timedOut: true, killedAt };
2822
+ if (exitCode !== 0) {
2823
+ throw new DockerContainerError(reasonOf(stderr) || `\`${binary} run\` exited ${exitCode}`);
2824
+ }
2825
+ return { timedOut: false };
2826
+ }
2827
+ async function readContainerFile(name, path2) {
2828
+ const dir = `${Bun.env.TMPDIR || "/tmp"}/thunderjar-cp-${crypto.randomUUID()}`;
2829
+ await $`mkdir -p ${dir}`.quiet();
2830
+ try {
2831
+ const binary = dockerBinary();
2832
+ const result = await $`${binary} cp ${`${name}:${path2}`} ${dir}/file`.quiet().nothrow();
2833
+ if (result.exitCode !== 0) {
2834
+ const reason = reasonOf(result.stderr);
2835
+ if (reason?.includes("Could not find the file"))
2836
+ return;
2837
+ throw new DockerContainerError(reason || `\`${binary} cp\` exited ${result.exitCode}`);
2828
2838
  }
2829
- };
2839
+ return await Bun.file(`${dir}/file`).text();
2840
+ } finally {
2841
+ await $`rm -rf ${dir}`.quiet();
2842
+ }
2830
2843
  }
2831
- var COMMIT = /^[0-9a-f]{4,64}$/i;
2832
- function recordingGit(owner, needs) {
2833
- const checkCommit = (method, commit) => {
2834
- if (!COMMIT.test(commit)) {
2835
- throw new Error(`error: ${owner}: ctx.git.${method} needs a commit hash, got ${JSON.stringify(commit)}.`);
2836
- }
2837
- };
2844
+ async function commitContainer(name, image, like) {
2845
+ const inspected = await mustRun(["image", "inspect", "--format", "{{json .Config.Entrypoint}}\t{{json .Config.Cmd}}", like]);
2846
+ const [entrypoint, cmd] = inspected.trim().split("\t");
2847
+ const changes = [`ENTRYPOINT ${entrypoint === "null" ? '[""]' : entrypoint}`, `CMD ${cmd === "null" ? '["sh"]' : cmd}`];
2848
+ await mustRun(["commit", ...changes.flatMap((change) => ["--change", change]), name, image]);
2849
+ }
2850
+ async function removeContainer(name) {
2851
+ await mustRun(["rm", "--force", name]);
2852
+ }
2853
+ async function startContainer(image, name) {
2854
+ await mustRun(["run", "--detach", "--name", name, "--pull", "never", "--entrypoint", "sh", image, "-c", "exec sleep 2147483647"]);
2855
+ }
2856
+ async function execInContainer(name, workdir, command, stdin) {
2857
+ const binary = dockerBinary();
2858
+ const args = ["exec", ...stdin === undefined ? [] : ["--interactive"], "--workdir", workdir, name, ...command];
2859
+ const result = stdin === undefined ? await $`${binary} ${args}`.quiet().nothrow() : await $`${binary} ${args} < ${new Response(stdin)}`.quiet().nothrow();
2860
+ if (result.exitCode !== 0 && /^(Error response from daemon|OCI runtime exec failed|Cannot connect to the Docker daemon)/.test(result.stderr.toString())) {
2861
+ throw new DockerContainerError(reasonOf(result.stderr) || `\`${binary} exec\` exited ${result.exitCode}`);
2862
+ }
2863
+ return { stdout: result.stdout.toString(), stderr: result.stderr.toString(), exitCode: result.exitCode };
2864
+ }
2865
+
2866
+ // src/execution/images.ts
2867
+ var DEFAULT_IMAGE_PREFIX = "thunderjar";
2868
+ var NAME_COMPONENT = /^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*$/;
2869
+ function imagePrefixProblem(prefix) {
2870
+ if (NAME_COMPONENT.test(prefix))
2871
+ return;
2872
+ return "must be lowercase letters and digits, joined by single separators (., _, __ or -), as a Docker repository name is.";
2873
+ }
2874
+ var prerunRepository = (prefix = DEFAULT_IMAGE_PREFIX) => `${prefix}-prerun`;
2875
+ var prerunImage = (hash, prefix = DEFAULT_IMAGE_PREFIX) => `${prerunRepository(prefix)}:${abbreviateHash(hash)}`;
2876
+ var postrunImage = (executionId, permutation, iteration, prefix = DEFAULT_IMAGE_PREFIX) => `${prefix}-postrun:${executionId}-${permutation}-${iteration}`;
2877
+ var containerName = (executionId, permutation, iteration, prefix = DEFAULT_IMAGE_PREFIX) => `${prefix}-run-${executionId}-${permutation}-${iteration}`;
2878
+
2879
+ // src/execution/prerunBuilds.ts
2880
+ var {$: $3 } = globalThis.Bun;
2881
+
2882
+ // src/git/git.ts
2883
+ var {$: $2 } = globalThis.Bun;
2884
+ var BUNDLE_BRANCH = "code-state";
2885
+ async function findRepository(dir) {
2886
+ const common = await $2`git -C ${dir} rev-parse --path-format=absolute --git-common-dir`.quiet().nothrow();
2887
+ if (common.exitCode !== 0)
2888
+ return;
2889
+ const objects = `${common.stdout.toString().trim()}/objects`;
2838
2890
  return {
2839
- bundle(commit) {
2840
- checkCommit("bundle", commit);
2841
- const path2 = `git/${commit}.bundle`;
2842
- needs.push({ kind: "bundle", owner, commit, path: path2 });
2843
- return path2;
2891
+ async resolveCommit(commit) {
2892
+ const result = await $2`git -C ${dir} rev-parse --verify --quiet ${`${commit}^{commit}`}`.quiet().nothrow();
2893
+ return result.exitCode === 0 ? result.stdout.toString().trim() : undefined;
2844
2894
  },
2845
- show(commit, file) {
2846
- checkCommit("show", commit);
2847
- const normal = normalize(file);
2848
- if (file === "" || isAbsolute(file) || normal === "." || normal.split("/").includes("..")) {
2849
- throw new Error(`error: ${owner}: ctx.git.show needs a path inside the repository, got ${JSON.stringify(file)}.`);
2895
+ async readFile(commit, path2) {
2896
+ const result = await $2`git -C ${dir} show ${`${commit}:${path2}`}`.quiet().nothrow();
2897
+ return result.exitCode === 0 ? new Uint8Array(result.stdout) : undefined;
2898
+ },
2899
+ async bundle(commit, dest) {
2900
+ const scratch = `${Bun.env.TMPDIR || "/tmp"}/thunderjar-bundle-${crypto.randomUUID()}`;
2901
+ try {
2902
+ await $2`git init --quiet --bare ${scratch}`.quiet();
2903
+ await Bun.write(`${scratch}/objects/info/alternates`, `${objects}
2904
+ `);
2905
+ await $2`git --git-dir=${scratch} update-ref ${`refs/heads/${BUNDLE_BRANCH}`} ${commit}`.quiet();
2906
+ await $2`git --git-dir=${scratch} symbolic-ref HEAD ${`refs/heads/${BUNDLE_BRANCH}`}`.quiet();
2907
+ await $2`git --git-dir=${scratch} bundle create --quiet ${dest} HEAD ${BUNDLE_BRANCH}`.quiet();
2908
+ } finally {
2909
+ await $2`rm -rf ${scratch}`.quiet();
2850
2910
  }
2851
- const path2 = `git/${commit}/${normal}`;
2852
- needs.push({ kind: "file", owner, commit, file: normal, path: path2 });
2853
- return path2;
2854
2911
  }
2855
2912
  };
2856
2913
  }
2857
- function renderPrerun(parameters) {
2858
- const git = [];
2859
- const ctx = (role) => ({ git: recordingGit(parameters[role].owner, git), dir: PARAMETER_DIRS[role] });
2860
- const { baseImage, harness, codeState, promptSet, initialPrompt } = parameters;
2861
- const steps = [
2862
- baseImage.applyParameter(baseImage.config, ctx("baseImage")),
2863
- harness.applyParameter(harness.config, { ...ctx("harness"), version: harness.version }),
2864
- "WORKDIR /workspace",
2865
- codeState.applyParameter(codeState.config, ctx("codeState")),
2866
- promptSet.applyParameter(promptSet.config, ctx("promptSet")),
2867
- initialPrompt.applyParameter(initialPrompt.config, { git: recordingGit(initialPrompt.owner, git) })
2868
- ];
2869
- return { dockerfile: `${steps.filter((step) => step.trim() !== "").join(`
2870
- `)}
2871
- `, git };
2872
- }
2873
2914
 
2874
- class MissingCommitsError extends Error {
2875
- }
2876
- async function checkCommits(needs, repository) {
2915
+ // src/execution/gitNeeds.ts
2916
+ async function checkGitNeeds(root, needs) {
2917
+ if (needs.length === 0)
2918
+ return;
2919
+ const repository = await findRepository(root);
2877
2920
  const problems = new Set;
2878
2921
  const present = new Map;
2879
2922
  for (const need of needs) {
2923
+ if (!repository) {
2924
+ problems.add(`commit ${need.commit} (${need.owner}) can't be read: the project isn't in a git repository.`);
2925
+ continue;
2926
+ }
2880
2927
  if (!present.has(need.commit))
2881
2928
  present.set(need.commit, await repository.resolveCommit(need.commit) !== undefined);
2882
2929
  if (!present.get(need.commit)) {
2883
- problems.add(`error: commit ${need.commit} (${need.owner}) is not in this repository.
2930
+ problems.add(`commit ${need.commit} (${need.owner}) is not in this repository.
2884
2931
  Fetch it, or check that it has been pushed.`);
2885
2932
  } else if (need.kind === "file" && await repository.readFile(need.commit, need.file) === undefined) {
2886
- problems.add(`error: ${need.file} is not in commit ${need.commit} (${need.owner}).`);
2933
+ problems.add(`${need.file} is not in commit ${need.commit} (${need.owner}).`);
2887
2934
  }
2888
2935
  }
2889
2936
  if (problems.size > 0)
2890
- throw new MissingCommitsError([...problems].join(`
2891
- `));
2937
+ throw new ConfigError(...problems);
2938
+ return repository;
2892
2939
  }
2893
- async function assembleBuildContext(parameters, repository, dir) {
2894
- const { dockerfile, git } = renderPrerun(parameters);
2895
- await remove(dir);
2896
- await makeDir(dir);
2897
- await Bun.write(join(dir, "Dockerfile"), dockerfile);
2898
- for (const role of Object.keys(PARAMETER_DIRS)) {
2899
- const { dir: source, exclude = [] } = parameters[role];
2900
- const target = join(dir, PARAMETER_DIRS[role]);
2901
- await copy(source, target);
2902
- for (const path2 of exclude)
2903
- await remove(join(target, path2));
2904
- }
2905
- const written = new Set;
2906
- for (const need of git) {
2907
- if (written.has(need.path))
2908
- continue;
2909
- written.add(need.path);
2940
+ async function writeGitNeeds(repository, needs, contextDir) {
2941
+ for (const need of needs) {
2910
2942
  if (!repository)
2911
- throw new Error(`${need.owner} reads commit ${need.commit}, but there is no repository to read it from`);
2912
- const dest = join(dir, need.path);
2913
- await makeDir(dirname(dest));
2943
+ throw new Error(`${need.owner} reads commit ${need.commit}, but there is no repository to read it from.`);
2944
+ const dest = `${contextDir}/${need.path}`;
2914
2945
  if (need.kind === "bundle") {
2915
- const full = await repository.resolveCommit(need.commit);
2916
- if (!full)
2917
- throw new Error(`commit ${need.commit} is not in the repository`);
2918
- await repository.bundle(full, dest);
2946
+ await repository.bundle(await repository.resolveCommit(need.commit), dest);
2919
2947
  } else {
2920
- const contents = await repository.readFile(need.commit, need.file);
2921
- if (contents === undefined)
2922
- throw new Error(`${need.file} is not in commit ${need.commit}`);
2923
- await Bun.write(dest, contents);
2948
+ await Bun.write(dest, await repository.readFile(need.commit, need.file));
2924
2949
  }
2925
2950
  }
2926
2951
  }
2927
- async function prerunStatuses(docker2, repository, permutations) {
2928
- const unreachable = await docker2.unreachable();
2929
- if (unreachable !== undefined)
2930
- return { unreachable };
2931
- const statuses = new Map;
2932
- for (const { hash } of permutations) {
2933
- if (statuses.has(hash))
2934
- continue;
2935
- statuses.set(hash, await docker2.imageExists(prerunImageRef(repository, hash)) ? "exists" : "missing");
2936
- }
2937
- return { statuses };
2938
- }
2939
- var showPrerunStatus = (status) => status === "exists" ? " (exists, reused)" : status === "missing" ? " (will build)" : "";
2940
- var unreachableNote = (reason) => `Docker can't be reached (${reason}), so whether each prerun image exists is unknown.`;
2941
2952
 
2942
- // src/images/repository.ts
2943
- async function git(cwd, args) {
2944
- const child = Bun.spawn(["git", ...args], { cwd, stdio: ["ignore", "pipe", "pipe"] });
2945
- const [stdout, stderr] = await Promise.all([
2946
- new Response(child.stdout).arrayBuffer(),
2947
- new Response(child.stderr).text(),
2948
- child.exited
2949
- ]);
2950
- return { exitCode: child.exitCode ?? 1, stdout: Buffer.from(stdout), stderr };
2953
+ // src/api/checkoutCommit.ts
2954
+ var CHECKOUT_COMMIT = Symbol.for("thunderjar.checkoutCommit");
2955
+
2956
+ // src/execution/prerunDockerfile.ts
2957
+ var PARAMETER_DIRS = {
2958
+ baseImage: "parameters/baseImage",
2959
+ codeState: "parameters/codeState",
2960
+ promptSet: "parameters/promptSet",
2961
+ harness: "parameters/harness"
2962
+ };
2963
+ var BUNDLE_PATH = "code.bundle";
2964
+ var ROLE_NAMES = {
2965
+ baseImage: "base image",
2966
+ codeState: "code state",
2967
+ promptSet: "prompt set",
2968
+ harness: "harness",
2969
+ initialPrompt: "initial prompt"
2970
+ };
2971
+ var ownerOf = (role, file) => {
2972
+ const segments = file.split("/");
2973
+ const name = role === "initialPrompt" ? segments.at(-1).replace(/\.ts$/, "") : segments.at(-2);
2974
+ return `${ROLE_NAMES[role]} ${JSON.stringify(name)}`;
2975
+ };
2976
+ var COMMIT = /^[0-9a-f]{4,64}$/i;
2977
+ function checkCommitHash(method, commit) {
2978
+ if (typeof commit !== "string" || !COMMIT.test(commit)) {
2979
+ throw new Error(`${method} needs a commit hash, got ${JSON.stringify(commit)}.`);
2980
+ }
2951
2981
  }
2952
- async function gitOrThrow(cwd, args) {
2953
- const result = await git(cwd, args);
2954
- if (result.exitCode !== 0)
2955
- throw new Error(`git ${args.join(" ")} failed in ${cwd}:
2956
- ${result.stderr.trim()}`);
2957
- return result.stdout;
2982
+ function repositoryPath(path2) {
2983
+ const segments = typeof path2 === "string" ? path2.split("/").filter((segment) => segment !== "" && segment !== ".") : [];
2984
+ if (typeof path2 !== "string" || path2.startsWith("/") || segments.length === 0 || segments.includes("..")) {
2985
+ throw new Error(`ctx.git.show needs a path inside the repository, got ${JSON.stringify(path2)}.`);
2986
+ }
2987
+ return segments.join("/");
2958
2988
  }
2959
- async function findRepository(dir) {
2960
- const { exitCode, stdout, stderr } = await git(dir, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
2961
- if (exitCode !== 0 && /not a git repository/i.test(stderr))
2962
- return;
2963
- if (exitCode !== 0)
2964
- throw new Error(`error: git can't read the repository ${dir} is in:
2965
- ${stderr.trim()}`);
2966
- const objects = join(stdout.toString("utf8").trim(), "objects");
2989
+ function recordingGit(owner, needs) {
2967
2990
  return {
2968
- async resolveCommit(commit) {
2969
- const { exitCode: exitCode2, stdout: stdout2 } = await git(dir, ["rev-parse", "--verify", "--quiet", `${commit}^{commit}`]);
2970
- return exitCode2 === 0 ? stdout2.toString("utf8").trim() : undefined;
2971
- },
2972
- async readFile(commit, path2) {
2973
- const { exitCode: exitCode2, stdout: stdout2 } = await git(dir, ["show", `${commit}:${path2}`]);
2974
- return exitCode2 === 0 ? stdout2 : undefined;
2975
- },
2976
- async bundle(commit, dest) {
2977
- const scratch = await makeTempDir("thunderjar-bundle-");
2978
- try {
2979
- await gitOrThrow(scratch, ["init", "--quiet", "--bare"]);
2980
- await Bun.write(join(scratch, "objects", "info", "alternates"), `${objects}
2981
- `);
2982
- await gitOrThrow(scratch, ["update-ref", "refs/heads/code-state", commit]);
2983
- await gitOrThrow(scratch, ["symbolic-ref", "HEAD", "refs/heads/code-state"]);
2984
- await gitOrThrow(scratch, ["bundle", "create", "--quiet", dest, "HEAD", "code-state"]);
2985
- } finally {
2986
- await remove(scratch);
2987
- }
2991
+ show(commit, path2) {
2992
+ checkCommitHash("ctx.git.show", commit);
2993
+ const file = repositoryPath(path2);
2994
+ const need = { kind: "file", owner, commit, file, path: `git/${commit}/${file}` };
2995
+ needs.push(need);
2996
+ return need.path;
2988
2997
  }
2989
2998
  };
2990
2999
  }
2991
-
2992
- // src/images/prerun-builds.ts
2993
- var buildContextDir = (rootDir, permutationHash) => join(rootDir, "builds", prerunTag(permutationHash));
2994
- async function preparePrerunBuilds(options) {
2995
- const { root, task, permutations, docker: docker2, repository, rootDir, purpose } = options;
2996
- const found = await prerunStatuses(docker2, repository, permutations);
2997
- if ("unreachable" in found) {
2998
- throw new Error(`error: Docker can't be reached (${found.unreachable}). ${purpose} needs Docker.`);
2999
- }
3000
- const parameters = new Map;
3001
- for (const permutation of permutations) {
3002
- if (found.statuses.get(permutation.hash) !== "missing")
3003
- continue;
3004
- parameters.set(permutation.hash, await loadPrerunParameters(root, task, permutation));
3005
- }
3006
- const gitNeeds = [...parameters.values()].flatMap((p) => renderPrerun(p).git);
3007
- let repo;
3008
- if (gitNeeds.length > 0) {
3009
- const projectDir = dirname(root);
3010
- repo = await findRepository(projectDir);
3011
- if (!repo) {
3012
- const [{ owner, commit }] = gitNeeds;
3013
- throw new Error(`error: ${projectDir} is not in a git repository, but ${owner} reads commit ${commit} from it.`);
3000
+ var checkoutLines = (commit) => [
3001
+ `COPY ${BUNDLE_PATH} /tmp/code.bundle`,
3002
+ [
3003
+ "RUN git clone --quiet /tmp/code.bundle /workspace",
3004
+ `git -C /workspace checkout --quiet --detach ${commit}`,
3005
+ `git -C /workspace branch --quiet --delete --force ${BUNDLE_BRANCH}`,
3006
+ "git -C /workspace remote remove origin",
3007
+ 'git -C /workspace config user.name "Thunderjar agent"',
3008
+ "git -C /workspace config user.email agent@thunderjar.invalid",
3009
+ "rm /tmp/code.bundle"
3010
+ ].join(" \\\n && ")
3011
+ ].join(`
3012
+ `);
3013
+ function renderPrerun(parameters) {
3014
+ const problems = [];
3015
+ const git = [];
3016
+ const gitOf = (role) => recordingGit(ownerOf(role, parameters[role].file), git);
3017
+ let checkout;
3018
+ const codeStateCtx = {
3019
+ dir: PARAMETER_DIRS.codeState,
3020
+ git: gitOf("codeState"),
3021
+ [CHECKOUT_COMMIT]: (commit) => {
3022
+ checkCommitHash("checkoutCommit", commit);
3023
+ if (checkout !== undefined)
3024
+ throw new Error(`checkoutCommit was already called, with ${checkout}: a code state checks out one commit.`);
3025
+ checkout = commit;
3014
3026
  }
3015
- await checkCommits(gitNeeds, repo);
3016
- }
3017
- const missing = new Set(parameters.keys());
3018
- return {
3019
- missing,
3020
- async build(permutationHash) {
3021
- const declared = parameters.get(permutationHash);
3022
- if (!declared || !missing.has(permutationHash))
3023
- throw new Error(`prerun image ${permutationHash} isn't missing`);
3024
- const ref = prerunImageRef(repository, permutationHash);
3025
- const contextDir = buildContextDir(rootDir, permutationHash);
3026
- await assembleBuildContext(declared, repo, contextDir);
3027
- try {
3028
- await docker2.build(contextDir, ref);
3029
- } catch (error) {
3030
- if (!(error instanceof DockerBuildError))
3031
- throw error;
3032
- throw new Error(`error: building ${ref} failed (docker build exited ${error.exitCode}).
3033
- Its build context is kept in ${contextDir}`);
3034
- }
3035
- missing.delete(permutationHash);
3027
+ };
3028
+ const fragment = (role, ctx) => {
3029
+ const { file, declaration } = parameters[role];
3030
+ const problem = (message) => problems.push({ file, field: "applyParameter", message });
3031
+ const applyParameter = declaration.applyParameter;
3032
+ if (!applyParameter) {
3033
+ if (role !== "baseImage")
3034
+ return "";
3035
+ problem("is missing. A base image's fragment provides the FROM.");
3036
+ return;
3037
+ }
3038
+ let result;
3039
+ try {
3040
+ result = applyParameter(declaration.config, ctx);
3041
+ } catch (error) {
3042
+ problem(`threw: ${error instanceof Error ? error.message : String(error)}`);
3043
+ return;
3044
+ }
3045
+ if (typeof result !== "string") {
3046
+ problem(`must return a Dockerfile fragment, a string, got ${typeof result}.`);
3047
+ return;
3048
+ }
3049
+ if (role === "baseImage" && !/^FROM\s/i.test(result.trim())) {
3050
+ problem(`must return a fragment that starts with FROM, got ${JSON.stringify(result.trim().split(`
3051
+ `)[0])}.`);
3052
+ return;
3036
3053
  }
3054
+ return result.trim();
3037
3055
  };
3056
+ const baseImage = fragment("baseImage", { dir: PARAMETER_DIRS.baseImage, git: gitOf("baseImage") });
3057
+ const codeState = fragment("codeState", codeStateCtx);
3058
+ const steps = [
3059
+ baseImage,
3060
+ checkout === undefined ? "" : checkoutLines(checkout),
3061
+ "WORKDIR /workspace",
3062
+ codeState,
3063
+ fragment("promptSet", { dir: PARAMETER_DIRS.promptSet, git: gitOf("promptSet") }),
3064
+ fragment("harness", { dir: PARAMETER_DIRS.harness, git: gitOf("harness"), version: parameters.harness.version }),
3065
+ fragment("initialPrompt", { git: gitOf("initialPrompt") })
3066
+ ];
3067
+ if (problems.length > 0)
3068
+ throw problemsError(problems);
3069
+ if (checkout !== undefined) {
3070
+ git.unshift({ kind: "bundle", owner: ownerOf("codeState", parameters.codeState.file), commit: checkout, path: BUNDLE_PATH });
3071
+ }
3072
+ return { dockerfile: `${steps.filter((step) => step !== "").join(`
3073
+ `)}
3074
+ `, git };
3038
3075
  }
3039
-
3040
- // src/matrix/resolve.ts
3041
- async function resolveTestCase(root, testCase) {
3042
- const parameters = join(root, "test-case-parameters");
3043
- const hashParameter = {
3044
- baseImage: (n) => hashBaseImage(join(parameters, "baseImages", n)),
3045
- codeState: (n) => hashCodeState(join(parameters, "codeStates", n)),
3046
- promptSet: (n) => hashPromptSet(join(parameters, "promptSets", n)),
3047
- harnessModel: (n) => {
3048
- const { family, version, model } = parseHarnessModelPair(n);
3049
- return hashHarnessModelPair(join(parameters, "harnessModels", family), version, model);
3050
- },
3051
- initialPrompt: (n) => hashInitialPrompt(join(parameters, "tasks", testCase.task, "initial-prompts", `${n}.ts`))
3076
+ async function loadPrerunParameters(root, task, permutation) {
3077
+ const problems = [];
3078
+ const load = async (file, declareFn) => ({
3079
+ file,
3080
+ declaration: await loadDeclaration(root, file, declareFn, problems)
3081
+ });
3082
+ const { harness, version } = parsePair(permutation.harnessModel);
3083
+ const harnessDir = `${PARAMETERS}/harnessModels/${harness}`;
3084
+ const [baseImage, codeState, promptSet, harnessDeclaration, versions, initialPrompt] = await Promise.all([
3085
+ load(`${PARAMETERS}/baseImages/${permutation.baseImage}/index.ts`, "declareBaseImage"),
3086
+ load(`${PARAMETERS}/codeStates/${permutation.codeState}/index.ts`, "declareCodeState"),
3087
+ load(`${PARAMETERS}/promptSets/${permutation.promptSet}/index.ts`, "declarePromptSet"),
3088
+ load(`${harnessDir}/index.ts`, "declareHarness"),
3089
+ load(`${harnessDir}/versions.ts`, "declareHarnessVersions"),
3090
+ load(`${PARAMETERS}/tasks/${task}/initial-prompts/${permutation.initialPrompt}.ts`, "declareInitialPrompt")
3091
+ ]);
3092
+ if (problems.length > 0)
3093
+ throw problemsError(problems);
3094
+ return {
3095
+ baseImage,
3096
+ codeState,
3097
+ promptSet,
3098
+ harness: { ...harnessDeclaration, version: versions.declaration[version].version },
3099
+ initialPrompt
3052
3100
  };
3053
- const hashes = {};
3054
- for (const axis of AXES) {
3055
- hashes[axis] = new Map;
3056
- for (const n of testCase[axis])
3057
- hashes[axis].set(n, await hashParameter[axis](n));
3058
- }
3059
- const permutations = expandPermutations(testCase).map((permutation) => ({
3060
- ...permutation,
3061
- hash: hashPermutation({
3062
- baseImage: hashes.baseImage.get(permutation.baseImage),
3063
- codeState: hashes.codeState.get(permutation.codeState),
3064
- promptSet: hashes.promptSet.get(permutation.promptSet),
3065
- harnessModel: hashes.harnessModel.get(permutation.harnessModel),
3066
- initialPrompt: hashes.initialPrompt.get(permutation.initialPrompt)
3067
- })
3068
- }));
3069
- return { hashes, permutations };
3070
3101
  }
3071
3102
 
3072
- // src/platform/signals.ts
3073
- var COMMON = {
3074
- SIGHUP: 1,
3075
- SIGINT: 2,
3076
- SIGQUIT: 3,
3077
- SIGILL: 4,
3078
- SIGTRAP: 5,
3079
- SIGABRT: 6,
3080
- SIGIOT: 6,
3081
- SIGFPE: 8,
3082
- SIGKILL: 9,
3083
- SIGSEGV: 11,
3084
- SIGPIPE: 13,
3085
- SIGALRM: 14,
3086
- SIGTERM: 15,
3087
- SIGXCPU: 24,
3088
- SIGXFSZ: 25,
3089
- SIGVTALRM: 26,
3090
- SIGPROF: 27,
3091
- SIGWINCH: 28
3092
- };
3093
- var LINUX = {
3094
- SIGBUS: 7,
3095
- SIGUSR1: 10,
3096
- SIGUSR2: 12,
3097
- SIGSTKFLT: 16,
3098
- SIGCHLD: 17,
3099
- SIGCONT: 18,
3100
- SIGSTOP: 19,
3101
- SIGTSTP: 20,
3102
- SIGTTIN: 21,
3103
- SIGTTOU: 22,
3104
- SIGURG: 23,
3105
- SIGIO: 29,
3106
- SIGPOLL: 29,
3107
- SIGPWR: 30,
3108
- SIGSYS: 31
3109
- };
3110
- var BSD = {
3111
- SIGEMT: 7,
3112
- SIGBUS: 10,
3113
- SIGSYS: 12,
3114
- SIGURG: 16,
3115
- SIGSTOP: 17,
3116
- SIGTSTP: 18,
3117
- SIGCONT: 19,
3118
- SIGCHLD: 20,
3119
- SIGTTIN: 21,
3120
- SIGTTOU: 22,
3121
- SIGIO: 23,
3122
- SIGINFO: 29,
3123
- SIGUSR1: 30,
3124
- SIGUSR2: 31
3125
- };
3126
- var SIGNALS = { ...COMMON, ...process.platform === "linux" ? LINUX : BSD };
3127
- function signalNumber(signal) {
3128
- if (typeof signal === "number")
3129
- return signal;
3130
- const number = SIGNALS[signal];
3131
- if (number === undefined)
3132
- throw new Error(`unknown signal: ${signal}`);
3133
- return number;
3103
+ // src/execution/prerunBuilds.ts
3104
+ var parameterFolders = (permutation) => ({
3105
+ baseImage: `${PARAMETERS}/baseImages/${permutation.baseImage}`,
3106
+ codeState: `${PARAMETERS}/codeStates/${permutation.codeState}`,
3107
+ promptSet: `${PARAMETERS}/promptSets/${permutation.promptSet}`,
3108
+ harness: `${PARAMETERS}/harnessModels/${parsePair(permutation.harnessModel).harness}`
3109
+ });
3110
+ async function temporaryFolder() {
3111
+ const dir = `${Bun.env.TMPDIR || "/tmp"}/thunderjar-build-${crypto.randomUUID()}`;
3112
+ await $3`mkdir -p ${dir}`.quiet();
3113
+ return dir;
3134
3114
  }
3135
-
3136
- // src/running/runner.ts
3137
- var defaultRootDir = () => join(tmpdir(), "thunderjar");
3138
- var exitCodeOf = (proc) => proc.exitCode ?? 128 + signalNumber(proc.signalCode);
3139
- var shellQuote = (text) => `'${text.replaceAll("'", `'\\''`)}'`;
3140
- var containerWrapper = (command, timeoutSeconds) => [
3141
- "mkdir -p /thunderjar",
3142
- "start=$(date +%s%3N)",
3143
- `timeout -k 5 ${timeoutSeconds} sh -c ${shellQuote(command)} >&2`,
3144
- "code=$?",
3145
- "end=$(date +%s%3N)",
3146
- `echo "{\\"startedAt\\":$start,\\"finishedAt\\":$end,\\"exitCode\\":$code}" > /thunderjar/run.json`,
3147
- "cat /thunderjar/run.json"
3148
- ].join(`
3149
- `);
3150
- function createDockerRunner(docker2, repository) {
3151
- return {
3152
- async runContainer(run) {
3153
- const prerun = imageRef(repository, run.prerunTag);
3154
- const name = `thunderjar-${run.postrunTag}-${crypto.randomUUID().slice(0, 8)}`;
3155
- try {
3156
- const { exitCode, stdout } = await docker2.run(prerun, name, containerWrapper(run.command, run.timeoutSeconds));
3157
- const runJson = stdout.trim().split(`
3158
- `).at(-1) ?? "";
3159
- if (exitCode !== 0 || !runJson.startsWith("{")) {
3160
- throw new Error(`error: running a container from ${prerun} failed (docker run exited ${exitCode}).`);
3161
- }
3162
- await docker2.commit(name, imageRef(repository, run.postrunTag), prerun);
3163
- return JSON.parse(runJson);
3164
- } finally {
3165
- await docker2.removeContainer(name).catch(() => {});
3166
- }
3115
+ async function copyParameters(root, permutation, contextDir) {
3116
+ const folders = parameterFolders(permutation);
3117
+ for (const role of Object.keys(PARAMETER_DIRS)) {
3118
+ const dest = `${contextDir}/${PARAMETER_DIRS[role]}`;
3119
+ await $3`mkdir -p ${dest}`.quiet();
3120
+ await $3`cp -RL ${root}/${folders[role]}/. ${dest}`.quiet();
3121
+ }
3122
+ }
3123
+ async function buildPrerunImages(root, task, permutations, imagePrefix, hooks = {}) {
3124
+ const unique = [...new Map(permutations.map((permutation) => [permutation.hash, permutation])).values()];
3125
+ const existing = await imagesIn(prerunRepository(imagePrefix));
3126
+ const images = unique.map((permutation) => ({ permutation, image: prerunImage(permutation.hash, imagePrefix) }));
3127
+ const missing = images.filter(({ image }) => !existing.has(image));
3128
+ const builds = await Promise.all(missing.map(async ({ permutation, image }) => ({
3129
+ permutation,
3130
+ image,
3131
+ ...renderPrerun(await loadPrerunParameters(root, task, permutation))
3132
+ })));
3133
+ const repository = await checkGitNeeds(root, builds.flatMap(({ git }) => git));
3134
+ for (const { permutation, image, dockerfile, git } of builds) {
3135
+ hooks.onBuilding?.(image);
3136
+ const contextDir = await temporaryFolder();
3137
+ try {
3138
+ await copyParameters(root, permutation, contextDir);
3139
+ await writeGitNeeds(repository, git, contextDir);
3140
+ await Bun.write(`${contextDir}/Dockerfile`, dockerfile);
3141
+ await buildImage(contextDir, image);
3142
+ } finally {
3143
+ await $3`rm -rf ${contextDir}`.quiet();
3167
3144
  }
3145
+ hooks.onBuilt?.(image);
3146
+ }
3147
+ return {
3148
+ built: builds.map(({ image }) => image),
3149
+ present: images.filter(({ image }) => existing.has(image)).map(({ image }) => image)
3168
3150
  };
3169
3151
  }
3170
- async function exec(command, cwd) {
3171
- const child = Bun.spawn(["sh", "-c", command], { cwd, stdio: ["ignore", "pipe", "pipe"] });
3172
- const [stdout, stderr] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
3173
- return { stdout, stderr, exitCode: exitCodeOf(child) };
3174
- }
3175
- function createDockerMeasurementOpener(docker2, repository) {
3176
- return {
3177
- async openMeasurementContext(postrunTag2) {
3178
- const scratch = await makeTempDir("thunderjar-measure-");
3179
- const workdir = join(scratch, "workspace");
3180
- const name = `thunderjar-measure-${crypto.randomUUID().slice(0, 8)}`;
3181
- try {
3182
- await makeDir(workdir);
3183
- await docker2.create(imageRef(repository, postrunTag2), name);
3184
- await docker2.copyFrom(name, "/workspace/.", workdir);
3185
- const resultPath = join(scratch, "result.json");
3186
- await docker2.copyFrom(name, "/thunderjar/result.json", resultPath).catch(() => {});
3187
- const harnessResult = await exists(resultPath) ? await Bun.file(resultPath).text() : "";
3188
- return scratchContext(workdir, harnessResult, scratch);
3189
- } catch (error) {
3190
- await remove(scratch);
3152
+
3153
+ // src/cli/configErrors.ts
3154
+ function reportingConfigErrors(action) {
3155
+ return async (...args) => {
3156
+ try {
3157
+ await action(...args);
3158
+ } catch (error) {
3159
+ if (!(error instanceof ConfigError))
3191
3160
  throw error;
3192
- } finally {
3193
- await docker2.removeContainer(name).catch(() => {});
3194
- }
3161
+ for (const problem of error.problems)
3162
+ console.error(`Error: ${problem}`);
3163
+ process.exitCode = 1;
3195
3164
  }
3196
3165
  };
3197
3166
  }
3198
- function scratchContext(workdir, harnessResult, scratch) {
3199
- return {
3200
- workdir,
3201
- harnessResult,
3202
- exec: (command) => exec(command, workdir),
3203
- readFile: (path2) => Bun.file(resolve(workdir, path2)).text(),
3204
- async writeFile(path2, contents) {
3205
- await Bun.write(resolve(workdir, path2), contents);
3206
- },
3207
- glob: async (pattern) => (await Array.fromAsync(new Bun.Glob(pattern).scan({ cwd: workdir, dot: true }))).toSorted(),
3208
- close: () => remove(scratch)
3209
- };
3210
- }
3211
3167
 
3212
- // src/cli/commands/format.ts
3213
- var count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
3214
- var showValue = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? `{${Object.entries(value).map(([key, v]) => `${key}: ${showValue(v)}`).join(", ")}}` : JSON.stringify(value) ?? String(value);
3215
- var showOutcome = (outcome) => outcome.outcome === "measured" ? showValue(outcome.data) : outcome.outcome === "skipped" ? "skipped" : `erroredWhileMeasuring: ${outcome.error.message}`;
3216
- var seconds = (ms) => `${(ms / 1000).toFixed(2)}s`;
3217
- var duration = ({ startedAt, finishedAt }) => seconds(finishedAt - startedAt);
3218
- var minute = (iso) => iso.slice(0, 16).replace("T", " ");
3219
-
3220
- // src/cli/commands/options.ts
3221
- function valueOption(flags, description) {
3222
- return new Option(flags, description).argParser((value, previous) => {
3223
- if (previous !== undefined)
3224
- throw new InvalidArgumentError("It may only be given once.");
3225
- if (value.startsWith("-"))
3226
- throw new InvalidArgumentError("It needs a value, not another flag.");
3227
- return value;
3168
+ // src/cli/imagePrefix.ts
3169
+ function imagePrefixOption() {
3170
+ return new Option("--image-prefix <name>", "start every image name with <name>, e.g. <name>-prerun:<hash>, instead of thunderjar").default(DEFAULT_IMAGE_PREFIX).argParser((prefix) => {
3171
+ const problem = imagePrefixProblem(prefix);
3172
+ if (problem)
3173
+ throw new InvalidArgumentError(`It ${problem}`);
3174
+ return prefix;
3228
3175
  });
3229
3176
  }
3230
- var imagePrefixOption = (description) => valueOption("--image-prefix <name>", description);
3231
- var dataDirOption = () => valueOption("--data-dir <dir>", "keep the run data store in this folder, in place of .thunderjar/.data/");
3232
3177
 
3233
3178
  // src/cli/commands/build/build.ts
3234
- async function build(name, options = {}) {
3235
- const {
3236
- cwd = process.cwd(),
3237
- log = (text) => console.log(text),
3238
- docker: docker2 = dockerCli(),
3239
- dryRun = false,
3240
- repository = LOCAL_REPOSITORY,
3241
- rootDir = defaultRootDir(),
3242
- now = Date.now
3243
- } = options;
3244
- const root = findThunderjarRoot(cwd);
3179
+ async function dryRun(cwd, name, imagePrefix) {
3180
+ const root = await findThunderjarRoot(cwd);
3245
3181
  const testCase = await loadTestCase(root, name);
3246
- const { permutations } = await resolveTestCase(root, testCase);
3247
- const rows = permutationRows(permutations);
3248
- const numberWidth = Math.max(2, String(permutations.length).length);
3249
- const pad = " ".repeat(numberWidth + 2);
3250
- if (dryRun) {
3251
- log(`Dockerfiles for ${name} (${count(permutations.length, "permutation")}). Nothing is built.`);
3252
- for (const [n, permutation] of permutations.entries()) {
3253
- const { dockerfile } = renderPrerun(await loadPrerunParameters(root, testCase.task, permutation));
3254
- log("");
3255
- log(`${String(n + 1).padStart(numberWidth)} ${rows[n]}`);
3256
- log(`${pad}${prerunImageRef(repository, permutation.hash)}`);
3257
- log("");
3258
- log(dockerfile.trimEnd().replace(/^(?=.)/gm, pad));
3259
- }
3260
- return;
3261
- }
3262
- const builds = await preparePrerunBuilds({
3263
- root,
3264
- task: testCase.task,
3265
- permutations,
3266
- docker: docker2,
3267
- repository,
3268
- rootDir,
3269
- purpose: "Building prerun images"
3270
- });
3271
- const toBuild = builds.missing.size;
3272
- if (toBuild > 0) {
3273
- log(`Building ${count(toBuild, "prerun image")} for ${name} (${count(permutations.length, "permutation")})`);
3274
- log(`Build contexts in ${join(rootDir, "builds")}`);
3275
- } else {
3276
- log(`Every prerun image for ${name} exists (${count(permutations.length, "permutation")})`);
3277
- }
3278
- for (const [n, permutation] of permutations.entries()) {
3279
- const ref = prerunImageRef(repository, permutation.hash);
3280
- log("");
3281
- log(`${String(n + 1).padStart(numberWidth)} ${rows[n]}`);
3282
- if (!builds.missing.has(permutation.hash)) {
3283
- log(`${pad}${ref} reused`);
3284
- continue;
3182
+ const permutations = await hashPermutations(root, testCase);
3183
+ const unique = [...new Map(permutations.map((permutation) => [permutation.hash, permutation])).values()];
3184
+ const rendered = await Promise.all(unique.map(async (permutation) => ({
3185
+ image: prerunImage(permutation.hash, imagePrefix),
3186
+ ...renderPrerun(await loadPrerunParameters(root, testCase.task, permutation))
3187
+ })));
3188
+ await checkGitNeeds(root, rendered.flatMap(({ git }) => git));
3189
+ return rendered.map(({ image, dockerfile }) => `# ${image}
3190
+ ${dockerfile.trimEnd()}`).join(`
3191
+
3192
+ `);
3193
+ }
3194
+ async function build(cwd, name, imagePrefix) {
3195
+ const root = await findThunderjarRoot(cwd);
3196
+ const testCase = await loadTestCase(root, name);
3197
+ const permutations = await hashPermutations(root, testCase);
3198
+ let building;
3199
+ try {
3200
+ const { built, present } = await buildPrerunImages(root, testCase.task, permutations, imagePrefix, {
3201
+ onBuilding: (image) => {
3202
+ building = image;
3203
+ process.stdout.write(`Building ${image} ... `);
3204
+ },
3205
+ onBuilt: () => {
3206
+ building = undefined;
3207
+ console.log("done");
3208
+ }
3209
+ });
3210
+ console.log(`${built.length} built, ${present.length} already present`);
3211
+ } catch (error) {
3212
+ if (error instanceof DockerUnavailableError) {
3213
+ console.error(`Error: Docker can't be reached, so no image can be built: ${error.message}`);
3214
+ } else if (error instanceof DockerBuildError) {
3215
+ console.log("failed");
3216
+ console.error(`Error: Building ${building} failed: ${error.message}`);
3217
+ } else {
3218
+ throw error;
3285
3219
  }
3286
- const started = now();
3287
- await builds.build(permutation.hash);
3288
- log(`${pad}${ref} built (${seconds(now() - started)})`);
3220
+ process.exitCode = 1;
3289
3221
  }
3290
- log("");
3291
- log(`${count(permutations.length, "prerun image")}: ${toBuild} built, ${permutations.length - toBuild} reused`);
3292
3222
  }
3293
3223
  function createBuildCommand() {
3294
- return new Command("build").description("Build a test case's prerun images.").argument("<test case>", "the test case whose prerun images to build").option("--dry-run", "print each permutation's Dockerfile instead of building").addOption(imagePrefixOption("the image repository prerun images are tagged in")).action(async (name, options) => {
3295
- await build(name, { dryRun: options.dryRun ?? false, repository: options.imagePrefix });
3296
- });
3224
+ return new Command("build").description("Build the prerun Docker image of each permutation of a test case, unless it already exists. Permutations with the same hash share one image.").argument("<test case>", `the name of a test case, declared in ${FOLDER}/test-cases/<test case>.ts`).option("--dry-run", "print each prerun image's name and Dockerfile, without building anything").addOption(imagePrefixOption()).action(reportingConfigErrors(async (name, options) => {
3225
+ if (options.dryRun)
3226
+ console.log(await dryRun(process.cwd(), name, options.imagePrefix));
3227
+ else
3228
+ await build(process.cwd(), name, options.imagePrefix);
3229
+ }));
3230
+ }
3231
+
3232
+ // src/store/runDataStore.ts
3233
+ var {$: $4, Glob: Glob3 } = globalThis.Bun;
3234
+ var executionFolder = (dataDir, executionId) => `${dataDir}/runs/${executionId}`;
3235
+ async function writeJson(path2, value) {
3236
+ const temporary = `${path2}.${crypto.randomUUID()}.tmp`;
3237
+ await Bun.write(temporary, `${JSON.stringify(value, null, 2)}
3238
+ `);
3239
+ await $4`mv ${temporary} ${path2}`.quiet();
3297
3240
  }
3298
3241
 
3299
- // src/store/run-data-store.ts
3300
- var runDataStorePath = (cwd, dataDir) => join(dataDir === undefined ? join(findThunderjarRoot(cwd), ".data") : resolve(cwd, dataDir), "runs.json");
3301
- function summarizeExecutions(records) {
3302
- const byId = new Map;
3303
- for (const { executionId, testCase, ranAt } of records) {
3304
- const summary = byId.get(executionId);
3305
- if (!summary)
3306
- byId.set(executionId, { executionId, testCase, ranAt, containerRuns: 1 });
3307
- else {
3308
- summary.containerRuns++;
3309
- if (ranAt < summary.ranAt)
3310
- summary.ranAt = ranAt;
3311
- }
3312
- }
3313
- const newestFirst = (a, b) => a < b ? 1 : a > b ? -1 : 0;
3314
- return [...byId.values()].toSorted((a, b) => newestFirst(a.ranAt, b.ranAt) || newestFirst(a.executionId, b.executionId));
3315
- }
3316
- function inExecutionOrder(records) {
3317
- const place = matrixPlaces(records);
3318
- return records.toSorted((a, b) => place.get(a.permutationHash) - place.get(b.permutationHash) || a.iteration - b.iteration);
3319
- }
3320
- function matrixPlaces(records) {
3321
- const place = new Map;
3322
- for (const { permutationHash } of records)
3323
- if (!place.has(permutationHash))
3324
- place.set(permutationHash, place.size);
3325
- return place;
3326
- }
3327
- function jsonFileStore(path2) {
3328
- const read = async () => {
3329
- let text;
3242
+ class RunDataStoreError extends Error {
3243
+ path;
3244
+ constructor(path2, message) {
3245
+ super(message);
3246
+ this.path = path2;
3247
+ }
3248
+ }
3249
+ async function readJsonFiles(dir, pattern) {
3250
+ const isDirectory2 = await Bun.file(dir).stat().then((stat) => stat.isDirectory()).catch(() => false);
3251
+ if (!isDirectory2)
3252
+ return [];
3253
+ const files = await Array.fromAsync(new Glob3(pattern).scan({ cwd: dir }));
3254
+ return Promise.all(files.map(async (file) => {
3255
+ const path2 = `${dir}/${file}`;
3330
3256
  try {
3331
- text = await Bun.file(path2).text();
3257
+ return await Bun.file(path2).json();
3332
3258
  } catch (error) {
3333
- if (error.code === "ENOENT")
3334
- return { containerRuns: [] };
3335
- throw error;
3259
+ throw new RunDataStoreError(path2, error instanceof Error ? error.message : String(error));
3336
3260
  }
3337
- return JSON.parse(text);
3338
- };
3339
- const write = async (data) => {
3340
- await makeDir(dirname(path2));
3341
- const temp = `${path2}.${crypto.randomUUID()}.tmp`;
3342
- await Bun.write(temp, `${JSON.stringify(data, null, 2)}
3343
- `);
3344
- await move(temp, path2);
3345
- };
3261
+ }));
3262
+ }
3263
+ function fileStore(dataDir) {
3346
3264
  return {
3347
- async recordContainerRun(record) {
3348
- const data = await read();
3349
- data.containerRuns.push(record);
3350
- await write(data);
3351
- },
3352
- async recordMeasures(postrunImage, measures) {
3353
- const data = await read();
3354
- const record = data.containerRuns.find((r) => r.postrunImage === postrunImage);
3355
- if (!record)
3356
- throw new Error(`no container run recorded for ${postrunImage}`);
3357
- record.result = { ...record.result, ...measures };
3358
- await write(data);
3359
- },
3360
- async listExecutions(testCase) {
3361
- const { containerRuns } = await read();
3362
- return summarizeExecutions(testCase === undefined ? containerRuns : containerRuns.filter((r) => r.testCase === testCase));
3265
+ async recordExecution(record) {
3266
+ await writeJson(`${executionFolder(dataDir, record.executionId)}/execution.json`, record);
3363
3267
  },
3364
- async getExecution(executionId) {
3365
- const { containerRuns } = await read();
3366
- return inExecutionOrder(containerRuns.filter((r) => r.executionId === executionId));
3367
- },
3368
- async getContainerRun(postrunImage) {
3369
- const { containerRuns } = await read();
3370
- return containerRuns.find((r) => r.postrunImage === postrunImage);
3268
+ async recordContainerRun(record) {
3269
+ const dir = `${executionFolder(dataDir, record.executionId)}/container-runs`;
3270
+ await writeJson(`${dir}/${record.permutation}-${record.iteration}.json`, record);
3371
3271
  },
3372
- async recordAggregationResult(result) {
3373
- const data = await read();
3374
- const results = data.aggregationResults ??= [];
3375
- const same = results.findIndex((r) => r.executionId === result.executionId && r.permutationHash === result.permutationHash);
3376
- if (same === -1)
3377
- results.push(result);
3378
- else
3379
- results[same] = result;
3380
- await write(data);
3272
+ async listExecutions() {
3273
+ const records = await readJsonFiles(`${dataDir}/runs`, "*/execution.json");
3274
+ return records.toSorted((a, b) => a.executionId < b.executionId ? 1 : a.executionId > b.executionId ? -1 : 0);
3381
3275
  },
3382
- async getAggregationResults(executionId) {
3383
- const { containerRuns, aggregationResults = [] } = await read();
3384
- const place = matrixPlaces(containerRuns.filter((r) => r.executionId === executionId));
3385
- const placeOf = ({ permutationHash }) => place.get(permutationHash) ?? place.size;
3386
- return aggregationResults.filter((r) => r.executionId === executionId).toSorted((a, b) => placeOf(a) - placeOf(b));
3276
+ async containerRunsOf(executionId) {
3277
+ const records = await readJsonFiles(`${executionFolder(dataDir, executionId)}/container-runs`, "*.json");
3278
+ return records.toSorted((a, b) => a.permutation - b.permutation || a.iteration - b.iteration);
3387
3279
  }
3388
3280
  };
3389
3281
  }
3390
3282
 
3391
- // src/cli/commands/executions/executions.ts
3392
- async function executions(testCase, { cwd = process.cwd(), dataDir } = {}) {
3393
- const store = jsonFileStore(runDataStorePath(cwd, dataDir));
3394
- const listed = await store.listExecutions(testCase);
3395
- if (listed.length === 0) {
3396
- return testCase === undefined ? "No executions recorded." : `No executions recorded for ${testCase}.`;
3283
+ // src/cli/dataDir.ts
3284
+ function dataDirOption() {
3285
+ return new Option("--data-dir <dir>", `the folder holding the run data store, instead of ${FOLDER}/.data/`);
3286
+ }
3287
+ function resolvePath(cwd, path2) {
3288
+ const segments = [];
3289
+ for (const segment of (path2.startsWith("/") ? path2 : `${cwd}/${path2}`).split("/")) {
3290
+ if (segment === "" || segment === ".")
3291
+ continue;
3292
+ if (segment === "..")
3293
+ segments.pop();
3294
+ else
3295
+ segments.push(segment);
3397
3296
  }
3398
- const width = Math.max(...listed.map((e) => e.testCase.length));
3399
- return listed.map((e) => `${e.executionId} ${e.testCase.padEnd(width)} ${minute(e.ranAt)}`).join(`
3400
- `);
3297
+ return `/${segments.join("/")}`;
3401
3298
  }
3402
- function createExecutionsCommand() {
3403
- return new Command("executions").description("List recorded executions.").argument("[test case]", "list only this test case's executions").addOption(dataDirOption()).action(async (testCase, options) => {
3404
- console.log(await executions(testCase, { dataDir: options.dataDir }));
3405
- });
3299
+ var dataDirOf = (cwd, root, dataDir) => dataDir === undefined ? `${root}/.data` : resolvePath(cwd, dataDir);
3300
+ function displayPath(cwd, path2) {
3301
+ const base = resolvePath(cwd, ".");
3302
+ return path2.startsWith(`${base}/`) ? path2.slice(base.length + 1) : path2;
3406
3303
  }
3407
3304
 
3408
- // src/cli/commands/generate/parameter-names.ts
3409
- async function listDirNames(dir) {
3410
- try {
3411
- return listEntries(dir).filter((entry) => entry.isDirectory).map((entry) => entry.path).sort();
3412
- } catch {
3413
- return [];
3414
- }
3305
+ // src/cli/format.ts
3306
+ function agentEnd(execution, harnessResult) {
3307
+ if (execution.timedOut)
3308
+ return `timed out after ${execution.timeoutSeconds}s`;
3309
+ const ended = execution.exitCode === 0 ? "done" : `exited ${execution.exitCode}`;
3310
+ return harnessResult === null ? `${ended}, no result file` : ended;
3311
+ }
3312
+ var endedCleanly = (execution, harnessResult) => !execution.timedOut && execution.exitCode === 0 && harnessResult !== null;
3313
+ var thousands = (n) => Math.round(n).toLocaleString("en-US");
3314
+ var tokensOf = ({ inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens }) => inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens;
3315
+ var totalTokens = (costs) => costs?.outcome === "collected" ? costs.models.reduce((sum, model) => sum + tokensOf(model), 0) : undefined;
3316
+ function knownCost(costs) {
3317
+ if (costs?.outcome !== "collected" || costs.models.length === 0)
3318
+ return;
3319
+ if (costs.models.some(({ costUsd, costBasis }) => costUsd === undefined || costBasis === "unknown"))
3320
+ return;
3321
+ return costs.models.reduce((sum, { costUsd }) => sum + costUsd, 0);
3415
3322
  }
3416
- async function listFileStems(dir) {
3323
+ var dollars = (usd) => usd > 0 && usd < 0.0005 ? "<$0.001" : `$${usd.toFixed(3)}`;
3324
+ var plural = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
3325
+ var minute = (iso) => new Date(iso).toISOString().slice(0, 16).replace("T", " ");
3326
+
3327
+ // src/cli/commands/executions/executions.ts
3328
+ function renderExecutions(executions) {
3329
+ const testCaseWidth = Math.max(...executions.map(({ testCase }) => testCase.length));
3330
+ const matrices = executions.map(({ matrix }) => `${plural(matrix.permutations, "permutation")} \xD7 ${plural(matrix.iterations, "iteration")}`);
3331
+ const width = Math.max(...matrices.map((matrix) => matrix.length));
3332
+ return executions.map((execution, i) => `${execution.executionId} ${execution.testCase.padEnd(testCaseWidth)} ${minute(execution.startedAt)} ${matrices[i].padEnd(width)} ${execution.status}`).join(`
3333
+ `);
3334
+ }
3335
+ async function executions(cwd, testCase, dataDir) {
3336
+ const store = dataDir === undefined ? dataDirOf(cwd, await findThunderjarRoot(cwd)) : resolvePath(cwd, dataDir);
3337
+ let listed;
3417
3338
  try {
3418
- return listEntries(dir).filter((entry) => !entry.isDirectory && entry.path.endsWith(".ts")).map((entry) => entry.path.slice(0, -".ts".length)).sort();
3419
- } catch {
3420
- return [];
3339
+ listed = await fileStore(store).listExecutions();
3340
+ } catch (error) {
3341
+ if (!(error instanceof RunDataStoreError))
3342
+ throw error;
3343
+ console.error(`Error: Can't read ${displayPath(cwd, error.path)}: ${error.message}`);
3344
+ process.exitCode = 1;
3345
+ return;
3346
+ }
3347
+ if (testCase !== undefined)
3348
+ listed = listed.filter((execution) => execution.testCase === testCase);
3349
+ if (listed.length === 0) {
3350
+ console.log(testCase === undefined ? "No executions recorded." : `No executions of ${testCase} recorded.`);
3351
+ } else {
3352
+ console.log(renderExecutions(listed));
3421
3353
  }
3422
3354
  }
3423
- async function loadFamilyNames(root, family, problems) {
3424
- const familyDir = `test-case-parameters/harnessModels/${family}`;
3425
- const models = await loadDeclaration(root, `${familyDir}/models.ts`, "declareHarnessModels", problems);
3426
- const versions = await loadDeclaration(root, `${familyDir}/versions.ts`, "declareHarnessVersions", problems);
3427
- return {
3428
- models: Object.keys(models ?? {}).sort(),
3429
- versions: Object.keys(versions ?? {}).sort()
3430
- };
3355
+ function createExecutionsCommand() {
3356
+ return new Command("executions").description("List past executions from the run data store, newest first: each one's ID, its test case, when it started (in UTC), its permutations and iterations, and whether it's running, complete or failed.").argument("[test case]", `list only the executions of this test case, declared in ${FOLDER}/test-cases/<test case>.ts`).addOption(dataDirOption()).action(reportingConfigErrors(async (testCase, options) => {
3357
+ await executions(process.cwd(), testCase, options.dataDir);
3358
+ }));
3359
+ }
3360
+
3361
+ // src/cli/commands/generate/parameterNames.ts
3362
+ var GENERATED_PATH = "_generated/parameterNames.d.ts";
3363
+ async function keysOfMap(root, file, declareFn, problems) {
3364
+ if (!await existsIn(root, file))
3365
+ return [];
3366
+ return Object.keys(await loadDeclaration(root, file, declareFn, problems) ?? {}).toSorted();
3431
3367
  }
3432
3368
  async function collectParameterNames(root) {
3433
- const parameters = join(root, "test-case-parameters");
3434
- const [baseImages, codeStates, promptSets, taskNames, familyNames] = await Promise.all([
3435
- listDirNames(join(parameters, "baseImages")),
3436
- listDirNames(join(parameters, "codeStates")),
3437
- listDirNames(join(parameters, "promptSets")),
3438
- listDirNames(join(parameters, "tasks")),
3439
- listDirNames(join(parameters, "harnessModels"))
3440
- ]);
3441
3369
  const tasks = {};
3442
- for (const task of taskNames) {
3443
- tasks[task] = await listFileStems(join(parameters, "tasks", task, "initial-prompts"));
3370
+ for (const task of await foldersIn(root, `${PARAMETERS}/tasks`)) {
3371
+ tasks[task] = await tsFilesIn(root, `${PARAMETERS}/tasks/${task}/initial-prompts`);
3444
3372
  }
3445
- const harnessFamilies = {};
3446
3373
  const problems = [];
3447
- for (const family of familyNames) {
3448
- harnessFamilies[family] = await loadFamilyNames(root, family, problems);
3374
+ const harnesses = {};
3375
+ for (const harness of await foldersIn(root, `${PARAMETERS}/harnessModels`)) {
3376
+ const dir = `${PARAMETERS}/harnessModels/${harness}`;
3377
+ harnesses[harness] = {
3378
+ versions: await keysOfMap(root, `${dir}/versions.ts`, "declareHarnessVersions", problems),
3379
+ models: await keysOfMap(root, `${dir}/models.ts`, "declareHarnessModels", problems)
3380
+ };
3449
3381
  }
3450
3382
  if (problems.length > 0)
3451
- throw new InvalidConfigError(problems);
3452
- return { baseImages, codeStates, promptSets, tasks, harnessFamilies };
3383
+ throw problemsError(problems);
3384
+ return {
3385
+ baseImages: await foldersIn(root, `${PARAMETERS}/baseImages`),
3386
+ codeStates: await foldersIn(root, `${PARAMETERS}/codeStates`),
3387
+ promptSets: await foldersIn(root, `${PARAMETERS}/promptSets`),
3388
+ tasks,
3389
+ harnesses
3390
+ };
3453
3391
  }
3454
- function literalUnion(names) {
3455
- return names.length === 0 ? "never" : names.map((name) => JSON.stringify(name)).join(" | ");
3392
+ var union = (names) => names.length === 0 ? "never" : names.map((name) => JSON.stringify(name)).join(" | ");
3393
+ function harnessModelType(harnesses) {
3394
+ const pairs = Object.entries(harnesses).filter(([, { versions, models }]) => versions.length > 0 && models.length > 0).map(([harness, { versions, models }]) => `\`${harness}@\${${union(versions)}}/\${${union(models)}}\``);
3395
+ if (pairs.length <= 1)
3396
+ return ` ${pairs[0] ?? "never"}`;
3397
+ return pairs.map((pair) => `
3398
+ | ${pair}`).join("");
3399
+ }
3400
+ function initialPromptType(tasks) {
3401
+ const entries = Object.entries(tasks);
3402
+ if (entries.length === 0)
3403
+ return "{}";
3404
+ return `{
3405
+ ${entries.map(([task, prompts]) => ` ${JSON.stringify(task)}: ${union(prompts)};
3406
+ `).join("")}}`;
3456
3407
  }
3457
3408
  function renderParameterNames(names) {
3458
- const lines2 = [
3459
- "// _generated/parameter-names.d.ts \u2014 generated by `thunderjar generate`, not edited",
3460
- "",
3461
- `type BaseImageName = ${literalUnion(names.baseImages)};`,
3462
- `type CodeStateName = ${literalUnion(names.codeStates)};`,
3463
- `type PromptSetName = ${literalUnion(names.promptSets)};`,
3464
- `type TaskName = ${literalUnion(Object.keys(names.tasks))};`
3465
- ];
3466
- const tasks = Object.entries(names.tasks);
3467
- if (tasks.length === 0) {
3468
- lines2.push("type InitialPromptNames = Record<string, never>;");
3469
- } else {
3470
- lines2.push("type InitialPromptNames = {");
3471
- for (const [task, prompts] of tasks) {
3472
- lines2.push(` ${JSON.stringify(task)}: ${literalUnion(prompts)};`);
3473
- }
3474
- lines2.push("};");
3475
- }
3476
- const pairs = Object.entries(names.harnessFamilies).filter(([, { versions, models }]) => versions.length > 0 && models.length > 0).map(([family, { versions, models }]) => "`" + family + "@${" + literalUnion(versions) + "}/${" + literalUnion(models) + "}`");
3477
- if (pairs.length === 0) {
3478
- lines2.push("type HarnessModelName = never;");
3479
- } else if (pairs.length === 1) {
3480
- lines2.push(`type HarnessModelName = ${pairs[0]};`);
3481
- } else {
3482
- lines2.push("type HarnessModelName =");
3483
- lines2.push(...pairs.map((pair) => ` | ${pair}`));
3484
- lines2[lines2.length - 1] += ";";
3485
- }
3486
- lines2.push("");
3487
- return lines2.join(`
3488
- `);
3409
+ return `// Generated by \`thunderjar generate\` from .thunderjar/${PARAMETERS}/. Don't edit it: run that again.
3410
+
3411
+ type BaseImageName = ${union(names.baseImages)};
3412
+ type CodeStateName = ${union(names.codeStates)};
3413
+ type PromptSetName = ${union(names.promptSets)};
3414
+ type TaskName = ${union(Object.keys(names.tasks))};
3415
+ type InitialPromptNames = ${initialPromptType(names.tasks)};
3416
+ type HarnessModelName =${harnessModelType(names.harnesses)};
3417
+
3418
+ interface ThunderjarParameterNames {
3419
+ baseImage: BaseImageName;
3420
+ codeState: CodeStateName;
3421
+ promptSet: PromptSetName;
3422
+ task: TaskName;
3423
+ initialPrompt: InitialPromptNames;
3424
+ harnessModel: HarnessModelName;
3425
+ }
3426
+ `;
3489
3427
  }
3490
- async function writeGeneratedTypes(root) {
3491
- const names = await collectParameterNames(root);
3492
- const content = renderParameterNames(names);
3493
- const path2 = join(root, "_generated", "parameter-names.d.ts");
3494
- await Bun.write(path2, content);
3495
- return { path: path2, content };
3428
+ async function writeParameterNames(root) {
3429
+ await Bun.write(`${root}/${GENERATED_PATH}`, renderParameterNames(await collectParameterNames(root)));
3496
3430
  }
3497
3431
 
3498
3432
  // src/cli/commands/generate/generate.ts
3499
- async function generate(cwd = process.cwd()) {
3500
- const root = findThunderjarRoot(cwd);
3501
- const { path: path2 } = await writeGeneratedTypes(root);
3502
- return { path: path2 };
3433
+ function relativeTo(dir, path2) {
3434
+ let up = "";
3435
+ let base = dir.replace(/\/+$/, "");
3436
+ while (!path2.startsWith(`${base}/`)) {
3437
+ up += "../";
3438
+ base = base.slice(0, base.lastIndexOf("/"));
3439
+ }
3440
+ return up + path2.slice(base.length + 1);
3441
+ }
3442
+ async function generate(cwd) {
3443
+ const root = await findThunderjarRoot(cwd);
3444
+ await writeParameterNames(root);
3445
+ return `${root}/${GENERATED_PATH}`;
3503
3446
  }
3504
3447
  function createGenerateCommand() {
3505
- return new Command("generate").description("Write the parameter-name types for this project.").action(async () => {
3506
- const { path: path2 } = await generate();
3507
- console.log(`generated ${path2}`);
3508
- });
3448
+ return new Command("generate").description(`Write the names declared under ${FOLDER}/test-case-parameters/ to ${FOLDER}/${GENERATED_PATH}, so a test case that names an undeclared parameter is a type error.`).action(reportingConfigErrors(async () => {
3449
+ const path2 = await generate(process.cwd());
3450
+ console.log(`Wrote ${relativeTo(process.cwd(), path2)}`);
3451
+ }));
3509
3452
  }
3510
3453
 
3511
- // src/cli/commands/init/init.ts
3454
+ // src/cli/commands/init/scaffold.ts
3455
+ var declaration = (fn, body) => `import { ${fn} } from "thunderjar";
3456
+
3457
+ export default ${fn}(${body});
3458
+ `;
3459
+ var json = (value) => JSON.stringify(value, null, 2) + `
3460
+ `;
3512
3461
  var config = `import { declareConfig, sqliteStore } from "thunderjar";
3513
3462
 
3514
3463
  export default declareConfig({
@@ -3516,58 +3465,7 @@ export default declareConfig({
3516
3465
  runDataStore: sqliteStore({ path: "./.thunderjar/.data/runs.sqlite" }),
3517
3466
  });
3518
3467
  `;
3519
- var stub = (fn, body) => `import { ${fn} } from "thunderjar";
3520
-
3521
- export default ${fn}(${body});
3522
- `;
3523
- var baseImage = stub("declareBaseImage", `{
3524
- applyParameter: () => \`FROM node:20-bookworm\`,
3525
- }`);
3526
- var codeState = `import { checkoutCommit, declareCodeState } from "thunderjar";
3527
-
3528
- export default declareCodeState({
3529
- config: { commit: "a1b2c3" },
3530
- applyParameter: (config, ctx) => \`\${checkoutCommit(ctx, config.commit)}\\nRUN npm ci\`,
3531
- });
3532
- `;
3533
- var promptSet = stub("declarePromptSet", `{
3534
- config: { commit: "e4f5a6" },
3535
- applyParameter: (config, ctx) => \`COPY \${ctx.git.show(config.commit, "prompts/snerk.md")} /workspace/CLAUDE.md\`,
3536
- }`);
3537
- var harnessIndex = stub("declareHarness", `{
3538
- applyParameter: (_config, ctx) => \`RUN npm install -g @anthropic-ai/claude-code@\${ctx.version}\`,
3539
- cli: (ctx) => \`claude -p "\${ctx.initialPrompt}" --model \${ctx.modelId} > \${ctx.resultPath}\`,
3540
- requiredEnv: ["ANTHROPIC_API_KEY"],
3541
- }`);
3542
- var harnessModels = stub("declareHarnessModels", `{
3543
- "haiku-4-5": "claude-haiku-4-5-20251001",
3544
- }`);
3545
- var harnessVersions = stub("declareHarnessVersions", `{
3546
- "2.1.283": { version: "2.1.283" },
3547
- }`);
3548
- var collectTokenCosts = stub("declareTokenCostCollector", `(_raw, _ctx) => {
3549
- return { outcome: "unavailable", reason: "notReported" };
3550
- }`);
3551
- var initialPrompt = stub("declareInitialPrompt", `{
3552
- prompt: "Create a file isPrime.ts exporting a function that tests for primality.",
3553
- timeoutSeconds: 600,
3554
- applyParameter: () => \`\`,
3555
- }`);
3556
- var measurement = stub("declareMeasurement", `{
3557
- instrument: "fileCreated",
3558
- config: { glob: "**/isPrime.ts" },
3559
- aggregate: ["rate"],
3560
- }`);
3561
- var testCase = stub("declareTestCase", `{
3562
- baseImage: ["node20"],
3563
- codeState: ["baseline"],
3564
- promptSet: ["snerk"],
3565
- harnessModel: ["claude-code@2.1.283/haiku-4-5"],
3566
- task: "is-prime",
3567
- initialPrompt: ["plain"],
3568
- iterations: 1,
3569
- }`);
3570
- var tsconfig = JSON.stringify({
3468
+ var tsconfig = json({
3571
3469
  compilerOptions: {
3572
3470
  target: "ESNext",
3573
3471
  module: "ESNext",
@@ -3579,18 +3477,71 @@ var tsconfig = JSON.stringify({
3579
3477
  },
3580
3478
  include: ["**/*.ts"],
3581
3479
  exclude: ["node_modules", ".data", "**/*.template.ts"]
3582
- }, null, 2) + `
3480
+ });
3481
+ var testCase = declaration("declareTestCase", `{
3482
+ baseImage: ["node20"],
3483
+ codeState: ["baseline"],
3484
+ promptSet: ["snerk"],
3485
+ harnessModel: ["claude-code@2.1.283/haiku-4-5"],
3486
+ task: "is-prime",
3487
+ initialPrompt: ["plain"],
3488
+ iterations: 1,
3489
+ }`);
3490
+ var baseImage = declaration("declareBaseImage", `{
3491
+ applyParameter: () => \`FROM node:20-bookworm\`,
3492
+ }`);
3493
+ var harness = `import { claudeCodeReply, declareHarness } from "thunderjar";
3494
+
3495
+ // Claude Code, installed as root, then run as the image's non-root \`node\` user: it won't skip its
3496
+ // permission prompts as root, and nobody is there to answer them.
3497
+ export default declareHarness({
3498
+ applyParameter: (_config, ctx) => \`RUN npm install -g @anthropic-ai/claude-code@\${ctx.version}
3499
+ RUN mkdir -p /thunderjar && chown -R node:node /workspace /thunderjar
3500
+ USER node\`,
3501
+ // The prompt is read from its file, so quotes and newlines reach the agent intact. Turn 0 starts
3502
+ // the session, and any later turn resumes it.
3503
+ cli: (ctx) =>
3504
+ \`claude -p "$(cat \${ctx.promptPath})" --model \${ctx.modelId} --output-format json --dangerously-skip-permissions\` +
3505
+ \` \${ctx.turn === 0 ? "--session-id" : "--resume"} \${ctx.sessionId} > \${ctx.resultPath}\`,
3506
+ collectReply: claudeCodeReply,
3507
+ requiredEnv: ["ANTHROPIC_API_KEY"],
3508
+ });
3509
+ `;
3510
+ var harnessModels = declaration("declareHarnessModels", `{
3511
+ "haiku-4-5": "claude-haiku-4-5-20251001",
3512
+ }`);
3513
+ var harnessVersions = declaration("declareHarnessVersions", `{
3514
+ "2.1.283": { version: "2.1.283" },
3515
+ }`);
3516
+ var collectTokenCosts = `import { claudeCodeTokenCosts, declareTokenCostCollector } from "thunderjar";
3517
+
3518
+ export default declareTokenCostCollector((raw) => claudeCodeTokenCosts(raw));
3583
3519
  `;
3520
+ var initialPrompt = declaration("declareInitialPrompt", `{
3521
+ prompt: "Create a file isPrime.ts exporting a function that tests for primality.",
3522
+ timeoutSeconds: 600,
3523
+ }`);
3524
+ var measurement = declaration("declareMeasurement", `{
3525
+ instrument: "fileCreated",
3526
+ config: { glob: "**/isPrime.ts" },
3527
+ aggregate: ["rate"],
3528
+ }`);
3584
3529
  var P = "test-case-parameters";
3585
- function scaffold() {
3530
+ function scaffold({ nestedPackage }) {
3586
3531
  return {
3587
- "thunderjar.config.ts": config,
3532
+ ...nestedPackage && {
3533
+ "package.json": json({ name: "thunderjar-config", private: true, type: "module", dependencies: { thunderjar: "latest" } })
3534
+ },
3588
3535
  "tsconfig.json": tsconfig,
3536
+ ".gitignore": ["_generated/", ".data/", ...nestedPackage ? ["node_modules/"] : []].join(`
3537
+ `) + `
3538
+ `,
3539
+ "thunderjar.config.ts": config,
3589
3540
  "test-cases/example.ts": testCase,
3590
3541
  [`${P}/baseImages/node20/index.ts`]: baseImage,
3591
- [`${P}/codeStates/baseline/index.ts`]: codeState,
3592
- [`${P}/promptSets/snerk/index.ts`]: promptSet,
3593
- [`${P}/harnessModels/claude-code/index.ts`]: harnessIndex,
3542
+ [`${P}/codeStates/baseline/index.ts`]: declaration("declareCodeState", "{}"),
3543
+ [`${P}/promptSets/snerk/index.ts`]: declaration("declarePromptSet", "{}"),
3544
+ [`${P}/harnessModels/claude-code/index.ts`]: harness,
3594
3545
  [`${P}/harnessModels/claude-code/models.ts`]: harnessModels,
3595
3546
  [`${P}/harnessModels/claude-code/versions.ts`]: harnessVersions,
3596
3547
  [`${P}/harnessModels/claude-code/collectTokenCosts.ts`]: collectTokenCosts,
@@ -3598,371 +3549,484 @@ function scaffold() {
3598
3549
  [`${P}/tasks/is-prime/measurements/isPrimeTsExists.ts`]: measurement
3599
3550
  };
3600
3551
  }
3601
- async function init(projectRoot) {
3602
- const root = join(projectRoot, ".thunderjar");
3603
- const nested = !existsSync(join(projectRoot, "package.json"));
3604
- const files = scaffold();
3605
- if (nested) {
3606
- files["package.json"] = JSON.stringify({ name: "thunderjar-config", private: true, type: "module", dependencies: { thunderjar: "latest" } }, null, 2) + `
3607
- `;
3552
+
3553
+ // src/cli/commands/init/init.ts
3554
+ var FOLDER2 = ".thunderjar";
3555
+ async function isDirectory2(path2) {
3556
+ try {
3557
+ return (await Bun.file(path2).stat()).isDirectory();
3558
+ } catch {
3559
+ return false;
3608
3560
  }
3609
- files[".gitignore"] = ["_generated/", ".data/", ...nested ? ["node_modules/"] : []].join(`
3610
- `) + `
3611
- `;
3612
- const result = { created: [], skipped: [] };
3613
- for (const [rel, content] of Object.entries(files)) {
3614
- const path2 = join(root, rel);
3615
- if (existsSync(path2)) {
3616
- result.skipped.push(rel);
3561
+ }
3562
+ async function init(projectDir) {
3563
+ const root = `${projectDir}/${FOLDER2}`;
3564
+ const createdFolder = !await isDirectory2(root);
3565
+ const nestedPackage = !await Bun.file(`${projectDir}/package.json`).exists();
3566
+ const result = { createdFolder, created: [], existing: [] };
3567
+ for (const [path2, content] of Object.entries(scaffold({ nestedPackage }))) {
3568
+ const file = Bun.file(`${root}/${path2}`);
3569
+ if (await file.exists()) {
3570
+ result.existing.push(path2);
3617
3571
  continue;
3618
3572
  }
3619
- await Bun.write(path2, content);
3620
- result.created.push(rel);
3573
+ await Bun.write(file, content);
3574
+ result.created.push(path2);
3621
3575
  }
3622
3576
  return result;
3623
3577
  }
3624
3578
  function createInitCommand() {
3625
- return new Command("init").description("Create .thunderjar/ in this project.").action(async () => {
3626
- const { created, skipped } = await init(process.cwd());
3627
- for (const f of created)
3628
- console.log(`created .thunderjar/${f}`);
3629
- for (const f of skipped)
3630
- console.log(`exists .thunderjar/${f}`);
3579
+ return new Command("init").description("Scaffold the .thunderjar/ configuration folder in the current directory, with an example test case. Existing files are left as they are.").action(async () => {
3580
+ const { createdFolder, created, existing } = await init(process.cwd());
3581
+ if (createdFolder)
3582
+ console.log(`Created ${FOLDER2}/`);
3583
+ for (const path2 of created)
3584
+ console.log(`Created ${FOLDER2}/${path2}`);
3585
+ for (const path2 of existing)
3586
+ console.log(`Exists ${FOLDER2}/${path2}`);
3631
3587
  });
3632
3588
  }
3633
3589
 
3634
3590
  // src/cli/commands/plan/plan.ts
3635
- var LABELS = ["baseImage", "codeState", "promptSet", "harnessModel", "task", "initialPrompt"];
3636
- var LABEL_WIDTH = Math.max(...LABELS.map((label) => label.length)) + 2;
3637
- async function plan(name, cwd = process.cwd(), options = {}) {
3638
- const { docker: docker2 = dockerCli(), repository = LOCAL_REPOSITORY } = options;
3639
- const root = findThunderjarRoot(cwd);
3640
- const testCase2 = await loadTestCase(root, name);
3641
- const { hashes, permutations } = await resolveTestCase(root, testCase2);
3642
- const found = await prerunStatuses(docker2, repository, permutations);
3643
- const status = (hash) => ("statuses" in found) ? showPrerunStatus(found.statuses.get(hash)) : "";
3644
- const parameterLines = LABELS.map((label) => {
3645
- const value = label === "task" ? testCase2.task : testCase2[label].map((n) => `${n} (${abbreviateHash(hashes[label].get(n))})`).join(", ");
3646
- return `${(label + ":").padEnd(LABEL_WIDTH)}${value}`;
3647
- });
3648
- const runs = permutations.length * testCase2.iterations;
3649
- const summary = `Matrix shape: ${matrixShape(testCase2).join("/")} ` + `(${count(permutations.length, "permutation")} \xD7 ${count(testCase2.iterations, "iteration")} = ${count(runs, "container run")})`;
3650
- const rows = permutationRows(permutations);
3651
- const numberWidth = Math.max(2, String(permutations.length).length);
3652
- const entries = permutations.map((permutation, n) => `${String(n + 1).padStart(numberWidth)} ${rows[n]}
3653
- ` + `${" ".repeat(numberWidth + 2)}${prerunTag(permutation.hash)}${status(permutation.hash)}`);
3654
- const note = "unreachable" in found ? ["", unreachableNote(found.unreachable)] : [];
3655
- return [...parameterLines, "", summary, "", entries.join(`
3656
-
3657
- `), ...note].join(`
3591
+ var ROWS = [
3592
+ ["baseImage", (t) => t.baseImage.join(", ")],
3593
+ ["codeState", (t) => t.codeState.join(", ")],
3594
+ ["promptSet", (t) => t.promptSet.join(", ")],
3595
+ ["harnessModel", (t) => t.harnessModel.join(", ")],
3596
+ ["task", (t) => t.task],
3597
+ ["initialPrompt", (t) => t.initialPrompt.join(", ")]
3598
+ ];
3599
+ var LABEL_WIDTH = Math.max(...ROWS.map(([label]) => `${label}: `.length));
3600
+ function renderSummary(testCase2) {
3601
+ return ROWS.map(([label, values]) => `${`${label}:`.padEnd(LABEL_WIDTH)}${values(testCase2)}`).join(`
3658
3602
  `);
3659
3603
  }
3660
- function createPlanCommand() {
3661
- return new Command("plan").description("Print what a run of a test case would do.").argument("<test case>", "the test case to plan").addOption(imagePrefixOption("the image repository prerun images are looked up in")).action(async (name, options) => {
3662
- console.log(await plan(name, process.cwd(), { repository: options.imagePrefix }));
3604
+ function renderMatrix(testCase2, permutations, statuses) {
3605
+ const count = permutations.length;
3606
+ const shape = `Matrix shape: ${matrixShape(testCase2).join("/")} (${count} ${count === 1 ? "permutation" : "permutations"})`;
3607
+ const indexWidth = Math.max(2, String(count).length);
3608
+ const widths = AXES.map((axis) => Math.max(...permutations.map((permutation) => permutation[axis].length)));
3609
+ const rows = permutations.map((permutation, i) => {
3610
+ const columns = AXES.map((axis, a) => permutation[axis].padEnd(widths[a])).join(" | ");
3611
+ return `${String(i + 1).padStart(indexWidth)} ${columns} ${abbreviateHash(permutation.hash)} ${statuses[i]}`;
3663
3612
  });
3613
+ return [shape, "", ...rows].join(`
3614
+ `);
3615
+ }
3616
+ async function imageStatuses(permutations, imagePrefix) {
3617
+ try {
3618
+ const images = await imagesIn(prerunRepository(imagePrefix));
3619
+ return { statuses: permutations.map((p) => images.has(prerunImage(p.hash, imagePrefix)) ? "built" : "missing") };
3620
+ } catch (error) {
3621
+ if (!(error instanceof DockerUnavailableError))
3622
+ throw error;
3623
+ return { statuses: permutations.map(() => "unknown"), unavailable: error.message };
3624
+ }
3664
3625
  }
3626
+ async function plan(cwd, name, imagePrefix) {
3627
+ const root = await findThunderjarRoot(cwd);
3628
+ const testCase2 = await loadTestCase(root, name);
3629
+ await checkRequiredEnv(root, testCase2);
3630
+ const permutations = await hashPermutations(root, testCase2);
3631
+ const { statuses, unavailable } = await imageStatuses(permutations, imagePrefix);
3632
+ return {
3633
+ output: `${renderSummary(testCase2)}
3665
3634
 
3666
- // src/measuring/aggregate.ts
3667
- var sum = (xs) => xs.reduce((a, b) => a + b, 0);
3668
- var mean = (xs) => sum(xs) / xs.length;
3669
- var median = (xs) => {
3670
- const sorted = xs.toSorted((a, b) => a - b);
3671
- const mid = sorted.length >> 1;
3672
- return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
3673
- };
3674
- var byKey = (records, combine) => {
3675
- const keys = [...new Set(records.flatMap((r) => Object.keys(r)))];
3676
- return Object.fromEntries(keys.map((key) => [key, combine(records.map((r) => r[key] ?? 0))]));
3677
- };
3678
- var ofBooleans = {
3679
- rate: (bs) => bs.filter(Boolean).length / bs.length,
3680
- count: (bs) => bs.filter(Boolean).length
3681
- };
3682
- var ofNumbers = {
3683
- mean,
3684
- median,
3685
- min: (xs) => Math.min(...xs),
3686
- max: (xs) => Math.max(...xs),
3687
- sum
3688
- };
3689
- var ofRecords = {
3690
- sum: (rs) => byKey(rs, sum),
3691
- mean: (rs) => byKey(rs, mean)
3692
- };
3693
- var isRecordOfNumbers = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((v) => typeof v === "number");
3694
- function menuFor(values) {
3695
- if (values.every((v) => typeof v === "boolean"))
3696
- return ["booleans", ofBooleans];
3697
- if (values.every((v) => typeof v === "number"))
3698
- return ["numbers", ofNumbers];
3699
- if (values.every(isRecordOfNumbers))
3700
- return ["records of numbers", ofRecords];
3701
- return;
3635
+ ${renderMatrix(testCase2, permutations, statuses)}`,
3636
+ warning: unavailable && `Warning: Docker can't be reached, so whether each prerun image is built is unknown: ${unavailable}`
3637
+ };
3702
3638
  }
3703
- function combine(values, methods) {
3704
- if (values.length === 0)
3705
- return {};
3706
- const menu = menuFor(values);
3707
- if (!menu)
3708
- throw new Error(`can't combine ${JSON.stringify(values)}: not all booleans, numbers or records of numbers`);
3709
- const [kind, menuMethods] = menu;
3710
- return Object.fromEntries(methods.map((method) => {
3711
- const apply = menuMethods[method];
3712
- if (!apply)
3713
- throw new Error(`"${method}" doesn't combine ${kind}`);
3714
- return [method, apply(values)];
3639
+ function createPlanCommand() {
3640
+ return new Command("plan").description("Show what a test case would run, without running it: the parameters it names, each permutation of them with its hash, and whether its prerun image is built.").argument("<test case>", `the name of a test case, declared in ${FOLDER}/test-cases/<test case>.ts`).addOption(imagePrefixOption()).action(reportingConfigErrors(async (name, options) => {
3641
+ const { output, warning } = await plan(process.cwd(), name, options.imagePrefix);
3642
+ console.log(output);
3643
+ if (warning)
3644
+ console.error(warning);
3715
3645
  }));
3716
3646
  }
3717
- function aggregatePermutation(records, measurements) {
3718
- const [first] = records;
3719
- if (!first)
3720
- throw new Error("no container runs to aggregate");
3721
- const { executionId, permutationHash } = first;
3722
- if (records.some((r) => r.executionId !== executionId || r.permutationHash !== permutationHash)) {
3723
- throw new Error("container runs to aggregate must be of one permutation of one execution");
3724
- }
3725
- const measures = {};
3726
- for (const { name, aggregate } of measurements) {
3727
- const values = records.flatMap(({ result }) => {
3728
- const outcome = result[name]?.outcome;
3729
- return outcome?.outcome === "measured" ? [outcome.data] : [];
3730
- });
3731
- measures[name] = { measured: values.length, ...combine(values, aggregate) };
3732
- }
3733
- const durations = records.map(({ execution }) => execution.finishedAt - execution.startedAt);
3647
+
3648
+ // src/execution/planExecution.ts
3649
+ var RESULT_PATH = "/thunderjar/result.json";
3650
+ var PROMPT_PATH = "/thunderjar/prompt.txt";
3651
+ function turnPaths(turn, count) {
3652
+ if (count === 1)
3653
+ return { promptPath: PROMPT_PATH, resultPath: RESULT_PATH };
3654
+ return { promptPath: `/thunderjar/turns/${turn + 1}/prompt.txt`, resultPath: `/thunderjar/turns/${turn + 1}/result.json` };
3655
+ }
3656
+ var promptsOf = (initialPrompt2) => initialPrompt2.turns ?? [initialPrompt2.prompt];
3657
+ async function loadHarness(root, task, permutation, problems) {
3658
+ const { harness: name, version, model } = parsePair(permutation.harnessModel);
3659
+ const dir = `${PARAMETERS}/harnessModels/${name}`;
3660
+ const [baseImage2, harness2, collectTokenCosts2, versions, models, initialPrompt2] = await Promise.all([
3661
+ loadDeclaration(root, `${PARAMETERS}/baseImages/${permutation.baseImage}/index.ts`, "declareBaseImage", problems),
3662
+ loadDeclaration(root, `${dir}/index.ts`, "declareHarness", problems),
3663
+ loadDeclaration(root, `${dir}/collectTokenCosts.ts`, "declareTokenCostCollector", problems),
3664
+ loadDeclaration(root, `${dir}/versions.ts`, "declareHarnessVersions", problems),
3665
+ loadDeclaration(root, `${dir}/models.ts`, "declareHarnessModels", problems),
3666
+ loadDeclaration(root, `${PARAMETERS}/tasks/${task}/initial-prompts/${permutation.initialPrompt}.ts`, "declareInitialPrompt", problems)
3667
+ ]);
3668
+ if (!baseImage2 || !harness2 || !collectTokenCosts2 || !versions || !models || !initialPrompt2)
3669
+ return;
3670
+ const pinned = versions[version].version;
3671
+ return { baseImage: baseImage2, harness: harness2, collectTokenCosts: collectTokenCosts2, version: pinned, modelId: models[model], initialPrompt: initialPrompt2 };
3672
+ }
3673
+ function planTurns({ harness: harness2, version, modelId, initialPrompt: initialPrompt2 }, sessionId) {
3674
+ const prompts = promptsOf(initialPrompt2);
3675
+ return prompts.map((prompt, turn) => {
3676
+ const paths = turnPaths(turn, prompts.length);
3677
+ const command = harness2.cli({ version, modelId, initialPrompt: prompt, ...paths, turn, sessionId });
3678
+ return { prompt, ...paths, command };
3679
+ });
3680
+ }
3681
+ async function planExecution(root, testCase2, executionId, imagePrefix = DEFAULT_IMAGE_PREFIX) {
3682
+ const hashed = await hashPermutations(root, testCase2);
3683
+ const problems = [];
3684
+ const loaded = await Promise.all(hashed.map((permutation) => loadHarness(root, testCase2.task, permutation, problems)));
3685
+ if (problems.length > 0)
3686
+ throw problemsError(problems);
3734
3687
  return {
3735
3688
  executionId,
3736
- permutationHash,
3737
- iterations: records.length,
3738
- measures,
3739
- duration: { mean: mean(durations), max: Math.max(...durations) }
3689
+ permutations: hashed.map((permutation, p) => {
3690
+ const harnessed = loaded[p];
3691
+ return {
3692
+ number: p + 1,
3693
+ permutation,
3694
+ prerunImage: prerunImage(permutation.hash, imagePrefix),
3695
+ env: requiredEnvOf(harnessed.harness),
3696
+ runOptions: harnessed.baseImage.runOptions ?? [],
3697
+ timeoutSeconds: harnessed.initialPrompt.timeoutSeconds,
3698
+ harness: harnessed.harness,
3699
+ collectTokenCosts: harnessed.collectTokenCosts,
3700
+ version: harnessed.version,
3701
+ modelId: harnessed.modelId,
3702
+ iterations: Array.from({ length: testCase2.iterations }, (_, i) => {
3703
+ const sessionId = crypto.randomUUID();
3704
+ return {
3705
+ number: i + 1,
3706
+ postrunImage: postrunImage(executionId, p + 1, i + 1, imagePrefix),
3707
+ sessionId,
3708
+ turns: planTurns(harnessed, sessionId)
3709
+ };
3710
+ })
3711
+ };
3712
+ })
3740
3713
  };
3741
3714
  }
3742
- async function recomputeAggregationResult(store, executionId, permutationHash, measurements) {
3743
- const records = (await store.getExecution(executionId)).filter((r) => r.permutationHash === permutationHash);
3744
- const result = aggregatePermutation(records, measurements);
3745
- await store.recordAggregationResult(result);
3746
- return result;
3747
- }
3748
3715
 
3749
- // src/measuring/measure.ts
3750
- var message = (error) => error instanceof Error ? error.message : String(error);
3751
- var recordable = (result) => result.outcome === "erroredWhileMeasuring" ? { outcome: "erroredWhileMeasuring", error: { message: message(result.error) } } : result;
3752
- async function evaluate({ instrument, config: config2, dir }, ctx) {
3716
+ // src/execution/containerRun.ts
3717
+ var RUN_PATH = "/thunderjar/run.json";
3718
+ var shellQuote = (text) => `'${text.replaceAll("'", `'\\''`)}'`;
3719
+ var NOW_MS_FUNCTION = [
3720
+ "now_ms() {",
3721
+ " ms=$(date +%s%3N)",
3722
+ " case $ms in",
3723
+ ` ${"[0-9]".repeat(13)}) echo "$ms" ;;`,
3724
+ " *) echo $(( $(date +%s) * 1000 )) ;;",
3725
+ " esac",
3726
+ "}"
3727
+ ].join(`
3728
+ `);
3729
+ function executionWrapper(turns) {
3730
+ const folders = [...new Set(["/thunderjar", ...turns.map(({ promptPath }) => promptPath.slice(0, promptPath.lastIndexOf("/")))])];
3731
+ const many = turns.length > 1;
3732
+ const runTurn = ({ command }, i) => {
3733
+ const run = [
3734
+ "turn_start=$(now_ms)",
3735
+ `sh -c ${shellQuote(command)}`,
3736
+ "code=$?",
3737
+ ...many ? [`turns="$turns${i === 0 ? "" : ","}{\\"startedAt\\":$turn_start,\\"finishedAt\\":$(now_ms),\\"exitCode\\":$code}"`] : []
3738
+ ];
3739
+ return i === 0 ? run : ["if [ $code -eq 0 ]; then", ...run.map((line) => ` ${line}`), "fi"];
3740
+ };
3741
+ return [
3742
+ `mkdir -p ${folders.join(" ")}`,
3743
+ ...turns.map(({ prompt, promptPath }) => `printf '%s' ${shellQuote(prompt)} > ${promptPath}`),
3744
+ NOW_MS_FUNCTION,
3745
+ "start=$(now_ms)",
3746
+ `echo "{\\"startedAt\\":$start}" > ${RUN_PATH}`,
3747
+ ...many ? ['turns=""'] : [],
3748
+ ...turns.flatMap(runTurn),
3749
+ "end=$(now_ms)",
3750
+ `echo "{\\"startedAt\\":$start,\\"finishedAt\\":$end,\\"exitCode\\":$code${many ? ",\\\"turns\\\":[$turns]" : ""}}" > ${RUN_PATH}`
3751
+ ].join(`
3752
+ `);
3753
+ }
3754
+ var isEpochMs = (value) => Number.isInteger(value) && value >= 1000000000000 && value < 10000000000000;
3755
+ var isTurn = (value) => {
3756
+ const { startedAt, finishedAt, exitCode } = value ?? {};
3757
+ return isEpochMs(startedAt) && isEpochMs(finishedAt) && typeof exitCode === "number";
3758
+ };
3759
+ function parseExecution(text, timeoutSeconds, killedAt) {
3760
+ let parsed;
3753
3761
  try {
3754
- if (!isInstrumentName(instrument))
3755
- throw new Error(`unknown instrument ${JSON.stringify(instrument)}`);
3756
- const chosen = instruments[instrument];
3757
- const fields = config2;
3758
- const bundled = Object.fromEntries((chosen.bundledFiles ?? []).map((key) => [key, resolve(dir, fields[key])]));
3759
- return recordable(await chosen.evaluate({ ...fields, ...bundled }, ctx));
3760
- } catch (error) {
3761
- return { outcome: "erroredWhileMeasuring", error: { message: message(error) } };
3762
+ parsed = text === undefined ? undefined : JSON.parse(text);
3763
+ } catch {
3764
+ parsed = undefined;
3765
+ }
3766
+ const { startedAt, finishedAt, exitCode, turns } = parsed ?? {};
3767
+ const invalid = () => new DockerContainerError(`The execution wrapper left no valid ${RUN_PATH}: ${text === undefined ? "there is none" : text.trim()}`);
3768
+ if (!isEpochMs(startedAt))
3769
+ throw invalid();
3770
+ if (killedAt !== undefined)
3771
+ return { startedAt, finishedAt: killedAt, exitCode: null, timedOut: true, timeoutSeconds };
3772
+ if (!isEpochMs(finishedAt) || typeof exitCode !== "number")
3773
+ throw invalid();
3774
+ if (turns !== undefined && (!Array.isArray(turns) || !turns.every(isTurn)))
3775
+ throw invalid();
3776
+ return { startedAt, finishedAt, exitCode, timedOut: false, timeoutSeconds, ...turns && { turns } };
3777
+ }
3778
+ async function runInContainer(run) {
3779
+ try {
3780
+ const stop = await runContainer(run.prerunImage, run.container, executionWrapper(run.turns), {
3781
+ env: run.env,
3782
+ runOptions: run.runOptions,
3783
+ timeoutSeconds: run.timeoutSeconds
3784
+ });
3785
+ const killedAt = stop.timedOut ? stop.killedAt : undefined;
3786
+ const execution = parseExecution(await readContainerFile(run.container, RUN_PATH), run.timeoutSeconds, killedAt);
3787
+ const harnessResults = [];
3788
+ for (const { resultPath } of run.turns)
3789
+ harnessResults.push(await readContainerFile(run.container, resultPath) ?? null);
3790
+ await commitContainer(run.container, run.postrunImage, run.prerunImage);
3791
+ return { execution, harnessResults };
3792
+ } finally {
3793
+ await removeContainer(run.container).catch(() => {});
3762
3794
  }
3763
3795
  }
3764
- async function measureContainerRun(measurements, open, now = Date.now) {
3796
+
3797
+ // src/execution/measure.ts
3798
+ var message = (error) => error instanceof Error ? error.message : String(error);
3799
+ var recordable = (result) => result.outcome === "erroredWhileMeasuring" ? { outcome: "erroredWhileMeasuring", error: { message: message(result.error) } } : result;
3800
+ async function measureContainerRun(measurements, open, now = () => new Date) {
3765
3801
  const result = {};
3766
- for (const measurement2 of measurements) {
3767
- const ctx = await open();
3802
+ for (const { name, declaration: declaration2, config: config2, contentHash } of measurements) {
3803
+ const instrument = instruments[declaration2.instrument];
3804
+ const ctx = await open(instrument.usesContainer !== false);
3768
3805
  let outcome;
3769
3806
  try {
3770
- outcome = await evaluate(measurement2, ctx);
3807
+ outcome = recordable(await instrument.evaluate(config2, ctx));
3808
+ } catch (error) {
3809
+ outcome = { outcome: "erroredWhileMeasuring", error: { message: message(error) } };
3771
3810
  } finally {
3772
3811
  await ctx.close();
3773
3812
  }
3774
- result[measurement2.name] = {
3775
- instrument: measurement2.instrument,
3776
- contentHash: hashMeasurement(measurement2),
3777
- measuredAt: new Date(now()).toISOString(),
3778
- outcome
3779
- };
3813
+ result[name] = { instrument: declaration2.instrument, contentHash, measuredAt: now().toISOString(), outcome };
3780
3814
  }
3781
3815
  return result;
3782
3816
  }
3783
3817
 
3784
- // src/measuring/remeasure.ts
3785
- function measureStatus(recorded, measurement2) {
3786
- if (recorded === undefined || recorded.outcome.outcome === "erroredWhileMeasuring")
3787
- return "missing";
3788
- return recorded.contentHash === hashMeasurement(measurement2) ? "current" : "stale";
3789
- }
3790
- function planRemeasure(records, measurements) {
3791
- const runs = records.flatMap(({ executionId, postrunImage, result }) => {
3792
- const measurementNames = measurements.filter((measurement2) => measureStatus(result[measurement2.name], measurement2) !== "current").map(({ name }) => name).toSorted();
3793
- return measurementNames.length === 0 ? [] : [{ executionId, postrunImage, measurementNames }];
3794
- });
3795
- return { runs, executions: summarizeExecutions2(records, measurements) };
3796
- }
3797
- function summarizeExecutions2(records, measurements) {
3798
- const resultsByExecution = new Map;
3799
- for (const { executionId, result } of records) {
3800
- resultsByExecution.set(executionId, [...resultsByExecution.get(executionId) ?? [], result]);
3801
- }
3802
- const sorted = measurements.toSorted((a, b) => a.name < b.name ? -1 : 1);
3803
- const declared = new Set(measurements.map(({ name }) => name));
3804
- return [...resultsByExecution].map(([executionId, results]) => {
3805
- const summaries = sorted.map((measurement2) => {
3806
- const statuses = results.map((result) => measureStatus(result[measurement2.name], measurement2));
3807
- const status = statuses.includes("missing") ? "missing" : statuses.includes("stale") ? "stale" : "current";
3808
- return { name: measurement2.name, contentHash: hashMeasurement(measurement2), status };
3809
- });
3810
- const storedMeasures = new Map;
3811
- for (const name of results.flatMap((result) => Object.keys(result)).filter((name2) => !declared.has(name2))) {
3812
- storedMeasures.set(name, (storedMeasures.get(name) ?? 0) + 1);
3813
- }
3814
- const deleted = [...storedMeasures].toSorted(([a], [b]) => a < b ? -1 : 1).map(([name, count2]) => ({ name, storedMeasures: count2 }));
3815
- return { executionId, measurements: summaries, deleted };
3816
- });
3818
+ // src/execution/measurementContext.ts
3819
+ var WORKDIR = "/workspace";
3820
+ function recordedContext({ harnessResult, reply, replies }) {
3821
+ const noContainer = async () => {
3822
+ throw new Error("this instrument has no measurement container: it reads only what was recorded of the run");
3823
+ };
3824
+ return {
3825
+ workdir: WORKDIR,
3826
+ harnessResult,
3827
+ reply,
3828
+ replies,
3829
+ exec: noContainer,
3830
+ readFile: noContainer,
3831
+ writeFile: noContainer,
3832
+ glob: noContainer,
3833
+ close: async () => {}
3834
+ };
3817
3835
  }
3818
-
3819
- // src/cli/commands/remeasure/remeasure.ts
3820
- var footer = (updated, recomputed) => [
3821
- `Updated ${count(updated, "container run")}. Recomputed ${count(recomputed, "aggregation result")}.`,
3822
- "The agent was not rerun."
3823
- ];
3824
- var NOTHING_TO_DO = "Already current, nothing to do.";
3825
- function measuredLines(measured) {
3826
- const taken = measured.flatMap(({ record, result }) => Object.entries(result).map((entry) => ({ record, entry })));
3827
- const width = Math.max(...taken.map(({ entry: [name] }) => name.length));
3828
- return taken.map(({ record: { permutationHash, iteration }, entry: [name, { outcome }] }) => ` ${abbreviateHash(permutationHash)} i${String(iteration).padStart(2, "0")} ${name.padEnd(width)} ${showOutcome(outcome)}`);
3829
- }
3830
- function executionReport(task, containerRuns, { executionId, measurements, deleted }, measured, recomputed) {
3831
- const rows = [
3832
- ...measurements.map(({ name, contentHash, status }) => [
3833
- name,
3834
- status === "current" ? "up to date" : `new / changed (${abbreviateHash(contentHash)})`
3835
- ]),
3836
- ...deleted.map(({ name, storedMeasures }) => [
3837
- name,
3838
- `deleted from task; ${count(storedMeasures, "stored measure")} kept, excluded from aggregates`
3839
- ])
3840
- ];
3841
- const width = Math.max(...rows.map(([name]) => name.length));
3842
- return [
3843
- `Execution ${executionId} (${count(containerRuns, "container run")})`,
3844
- `Current measurements for task ${task}:`,
3845
- ...rows.map(([name, status]) => ` ${name.padEnd(width)} ${status}`),
3846
- "",
3847
- ...measured.length === 0 ? [NOTHING_TO_DO] : ["Measuring from each postrun image:", ...measuredLines(measured), ""],
3848
- ...footer(measured.length, recomputed)
3849
- ].join(`
3850
- `);
3836
+ async function openMeasurementContext(postrunImage2, container, { harnessResult, reply, replies }) {
3837
+ try {
3838
+ await startContainer(postrunImage2, container);
3839
+ } catch (error) {
3840
+ await removeContainer(container).catch(() => {});
3841
+ throw error;
3842
+ }
3843
+ const run = (command, stdin) => execInContainer(container, WORKDIR, command, stdin);
3844
+ const mustRun2 = async (what, command, stdin) => {
3845
+ const result = await run(command, stdin);
3846
+ if (result.exitCode !== 0)
3847
+ throw new Error(`${what}: ${result.stderr.trim() || `exited ${result.exitCode}`}`);
3848
+ return result.stdout;
3849
+ };
3850
+ return {
3851
+ workdir: WORKDIR,
3852
+ harnessResult,
3853
+ reply,
3854
+ replies,
3855
+ exec: (command) => run(["sh", "-c", command]),
3856
+ readFile: (path2) => mustRun2(`can't read ${path2}`, ["cat", "--", path2]),
3857
+ async writeFile(path2, contents) {
3858
+ await mustRun2(`can't write ${path2}`, ["sh", "-c", 'mkdir -p -- "$(dirname -- "$1")" && cat > "$1"', "sh", path2], contents);
3859
+ },
3860
+ async glob(pattern) {
3861
+ const found = await mustRun2(`can't list the files in ${WORKDIR}`, ["find", ".", "(", "-type", "f", "-o", "-type", "l", ")"]);
3862
+ const glob = new Bun.Glob(pattern);
3863
+ return found.split(`
3864
+ `).filter(Boolean).map((path2) => path2.replace(/^\.\//, "")).filter((path2) => glob.match(path2)).toSorted();
3865
+ },
3866
+ close: () => removeContainer(container)
3867
+ };
3851
3868
  }
3852
- function testCaseReport(testCase2, executions2, updated, recomputed) {
3853
- const rows = executions2.map(({ executionId, containerRuns, ranAt, plan: plan2 }) => ({
3854
- executionId,
3855
- runs: count(containerRuns, "run"),
3856
- day: ranAt.slice(0, 10),
3857
- current: plan2.runs.length === 0
3858
- }));
3859
- const width = Math.max(...rows.map(({ runs }) => runs.length));
3860
- const total = executions2.reduce((sum2, { containerRuns }) => sum2 + containerRuns, 0);
3861
- return [
3862
- `${testCase2} has ${count(executions2.length, "execution")} (${count(total, "container run")})`,
3863
- ...rows.map(({ executionId, runs, day, current }) => ` ${executionId} ${runs.padEnd(width)} ${day}${current ? " (already current, nothing to do)" : ""}`),
3864
- "",
3865
- updated === 0 ? NOTHING_TO_DO : `Measuring ${count(updated, "container run")}... done`,
3866
- ...footer(updated, recomputed)
3867
- ].join(`
3868
- `);
3869
+
3870
+ // src/execution/reply.ts
3871
+ var message2 = (error) => error instanceof Error ? error.message : String(error);
3872
+ function collectReply(collect, harnessResult) {
3873
+ if (harnessResult === null)
3874
+ return { outcome: "unavailable", reason: "the agent left no result file" };
3875
+ if (!collect)
3876
+ return { outcome: "unavailable", reason: "its harness has no collectReply" };
3877
+ let text;
3878
+ try {
3879
+ text = collect(harnessResult);
3880
+ } catch (error) {
3881
+ return { outcome: "unavailable", reason: `collectReply threw: ${message2(error)}` };
3882
+ }
3883
+ if (typeof text !== "string")
3884
+ return { outcome: "unavailable", reason: `collectReply returned ${typeof text}, not a string` };
3885
+ return { outcome: "collected", text };
3869
3886
  }
3870
- async function remeasureExecution(cwd, store, opener, executionId) {
3871
- const records = await store.getExecution(executionId);
3872
- const measurements = await loadMeasurements(findThunderjarRoot(cwd), records[0].task);
3873
- const plan2 = planRemeasure(records, measurements);
3874
- const measured = [];
3875
- for (const { postrunImage, measurementNames } of plan2.runs) {
3876
- const record = records.find((r) => r.postrunImage === postrunImage);
3877
- const planned = measurementNames.map((name) => measurements.find((m) => m.name === name));
3878
- const result = await measureContainerRun(planned, () => opener.openMeasurementContext(record.postrunImage));
3879
- await store.recordMeasures(record.postrunImage, result);
3880
- measured.push({ record, result });
3881
- }
3882
- const permutationHashes = new Set(records.map(({ permutationHash }) => permutationHash));
3883
- for (const permutationHash of permutationHashes) {
3884
- await recomputeAggregationResult(store, executionId, permutationHash, measurements);
3885
- }
3886
- return { records, plan: plan2, measured, recomputed: permutationHashes.size };
3887
- }
3888
- async function remeasure(target, { cwd = process.cwd(), dataDir } = {}) {
3889
- const store = jsonFileStore(runDataStorePath(cwd, dataDir));
3890
- const opener = createDockerMeasurementOpener(dockerCli(), LOCAL_REPOSITORY);
3891
- if ((await store.getExecution(target)).length > 0) {
3892
- const { records, plan: plan2, measured, recomputed } = await remeasureExecution(cwd, store, opener, target);
3893
- return executionReport(records[0].task, records.length, plan2.executions[0], measured, recomputed);
3894
- }
3895
- const executions2 = await store.listExecutions(target);
3896
- if (executions2.length > 0) {
3897
- const planned = [];
3898
- let updated = 0;
3899
- let recomputed = 0;
3900
- for (const execution of executions2) {
3901
- const { plan: plan2, measured, recomputed: ofExecution } = await remeasureExecution(cwd, store, opener, execution.executionId);
3902
- planned.push({ ...execution, plan: plan2 });
3903
- updated += measured.length;
3904
- recomputed += ofExecution;
3905
- }
3906
- return testCaseReport(target, planned, updated, recomputed);
3907
- }
3908
- throw new Error(`${target} is not a recorded execution or test case`);
3909
- }
3910
- function createRemeasureCommand() {
3911
- return new Command("remeasure").description("Apply the current measurements to past runs.").argument("<target>", "an execution ID, or a test case, meaning every execution of it").addOption(dataDirOption()).action(async (target, options) => {
3912
- console.log(await remeasure(target, { dataDir: options.dataDir }));
3887
+
3888
+ // src/execution/tokenCosts.ts
3889
+ var isCount = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0;
3890
+ function isTokenCosts(value) {
3891
+ const costs = value;
3892
+ if (typeof costs !== "object" || costs === null)
3893
+ return false;
3894
+ if (costs.outcome === "unavailable")
3895
+ return ["absent", "unparseable", "notReported"].includes(costs.reason);
3896
+ if (costs.outcome !== "collected" || !Array.isArray(costs.models))
3897
+ return false;
3898
+ if (costs.apiDurationMs !== undefined && !isCount(costs.apiDurationMs))
3899
+ return false;
3900
+ return costs.models.every((model) => {
3901
+ const counts = [model.inputTokens, model.outputTokens, model.cacheCreationTokens, model.cacheReadTokens];
3902
+ return typeof model.model === "string" && counts.every(isCount) && (model.costUsd === undefined || isCount(model.costUsd));
3913
3903
  });
3914
3904
  }
3905
+ function collectOne(collect, raw, ctx) {
3906
+ if (raw === null)
3907
+ return { outcome: "unavailable", reason: "absent" };
3908
+ try {
3909
+ const costs = collect(raw, ctx);
3910
+ return isTokenCosts(costs) ? costs : { outcome: "unavailable", reason: "unparseable" };
3911
+ } catch {
3912
+ return { outcome: "unavailable", reason: "unparseable" };
3913
+ }
3914
+ }
3915
+ function add(a, b) {
3916
+ const costUsd = a.costUsd !== undefined && b.costUsd !== undefined ? a.costUsd + b.costUsd : undefined;
3917
+ const costBasis = a.costBasis === b.costBasis ? a.costBasis : "unknown";
3918
+ return {
3919
+ model: a.model,
3920
+ inputTokens: a.inputTokens + b.inputTokens,
3921
+ outputTokens: a.outputTokens + b.outputTokens,
3922
+ cacheCreationTokens: a.cacheCreationTokens + b.cacheCreationTokens,
3923
+ cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens,
3924
+ ...costUsd !== undefined && { costUsd },
3925
+ ...costBasis !== undefined && { costBasis }
3926
+ };
3927
+ }
3928
+ function collectTokenCosts2(collect, harnessResults, ctx) {
3929
+ const all = harnessResults.map((raw) => collectOne(collect, raw, ctx));
3930
+ const collected = all.filter((costs) => costs.outcome === "collected");
3931
+ if (collected.length === 0)
3932
+ return all[0] ?? { outcome: "unavailable", reason: "absent" };
3933
+ const byModel = new Map;
3934
+ for (const model of collected.flatMap((costs) => costs.models)) {
3935
+ const before = byModel.get(model.model);
3936
+ byModel.set(model.model, before ? add(before, model) : model);
3937
+ }
3938
+ const durations = collected.map((costs) => costs.apiDurationMs);
3939
+ const apiDurationMs = durations.every((ms) => ms !== undefined) ? durations.reduce((a, b) => a + b, 0) : undefined;
3940
+ return { outcome: "collected", models: [...byModel.values()], ...apiDurationMs !== undefined && { apiDurationMs } };
3941
+ }
3915
3942
 
3916
- // src/matrix/execution.ts
3917
- var RESULT_PATH = "/thunderjar/result.json";
3918
- var importDefault2 = async (path2) => (await import(path2)).default;
3919
- async function renderCommand(root, task, permutation) {
3920
- const parameters = join(root, "test-case-parameters");
3921
- const { family, version, model } = parseHarnessModelPair(permutation.harnessModel);
3922
- const familyDir = join(parameters, "harnessModels", family);
3923
- const harness = await importDefault2(join(familyDir, "index.ts"));
3924
- const versions = await importDefault2(join(familyDir, "versions.ts"));
3925
- const models = await importDefault2(join(familyDir, "models.ts"));
3926
- const initialPrompt2 = await importDefault2(join(parameters, "tasks", task, "initial-prompts", `${permutation.initialPrompt}.ts`));
3927
- const entry = versions[version];
3928
- if (!("version" in entry)) {
3929
- throw new Error(`${permutation.harnessModel}: floating versions are not supported yet`);
3930
- }
3931
- const cli = entry.cli ?? harness.cli;
3932
- const ctx = { version: entry.version, modelId: models[model], initialPrompt: initialPrompt2.prompt };
3943
+ // src/execution/runExecution.ts
3944
+ function containerRunRecord({ plan: plan2, name, testCase: testCase2 }, { number: permutationNumber, permutation }, iteration, execution, turns, tokenCosts) {
3945
+ const last = turns.at(-1);
3946
+ const { harness: harness2, version, model } = parsePair(permutation.harnessModel);
3933
3947
  return {
3934
- renderCommand: (resultPath) => cli({ ...ctx, resultPath }),
3935
- timeoutSeconds: initialPrompt2.timeoutSeconds,
3936
- harnessModel: { family, version: ctx.version, modelId: ctx.modelId }
3948
+ executionId: plan2.executionId,
3949
+ testCase: name,
3950
+ task: testCase2.task,
3951
+ permutation: permutationNumber,
3952
+ permutationHash: permutation.hash,
3953
+ iteration: iteration.number,
3954
+ postrunImage: iteration.postrunImage,
3955
+ ranAt: new Date(execution.startedAt).toISOString(),
3956
+ parameters: Object.fromEntries(AXES.map((axis) => [axis, { name: permutation[axis], parameterHash: permutation.parameterHashes[axis] }])),
3957
+ harnessModel: { harness: harness2, version, model, modelId: permutation.modelId },
3958
+ execution,
3959
+ harnessResult: last.harnessResult,
3960
+ reply: last.reply,
3961
+ tokenCosts,
3962
+ sessionId: iteration.sessionId,
3963
+ ...turns.length > 1 && { turns },
3964
+ result: {}
3937
3965
  };
3938
3966
  }
3939
- async function planExecution(root, testCase2, executionId) {
3940
- const { hashes, permutations } = await resolveTestCase(root, testCase2);
3941
- const planned = [];
3942
- for (const permutation of permutations) {
3943
- const { renderCommand: render2, timeoutSeconds, harnessModel } = await renderCommand(root, testCase2.task, permutation);
3944
- const command = render2(RESULT_PATH);
3945
- const parameters = Object.fromEntries(AXES.map((axis) => [axis, { name: permutation[axis], parameterHash: hashes[axis].get(permutation[axis]) }]));
3946
- planned.push({
3947
- permutation,
3948
- parameters,
3949
- harnessModel,
3950
- prerunTag: prerunTag(permutation.hash),
3951
- runs: Array.from({ length: testCase2.iterations }, (_, iterationIndex) => ({
3952
- executionId,
3953
- iterationIndex,
3954
- prerunTag: prerunTag(permutation.hash),
3955
- postrunTag: postrunTag(executionId, permutation.hash, iterationIndex),
3956
- command,
3957
- renderCommand: render2,
3958
- timeoutSeconds
3959
- }))
3960
- });
3967
+ async function runExecution(run, store, hooks = {}) {
3968
+ const { root, testCase: testCase2, plan: plan2, imagePrefix } = run;
3969
+ const measurements = await loadMeasurements(root, testCase2.task);
3970
+ await buildPrerunImages(root, testCase2.task, plan2.permutations.map(({ permutation }) => permutation), imagePrefix, hooks);
3971
+ const startedAt = new Date().toISOString();
3972
+ const executionRecord = (status2, finishedAt) => ({
3973
+ executionId: plan2.executionId,
3974
+ testCase: run.name,
3975
+ task: testCase2.task,
3976
+ startedAt,
3977
+ finishedAt,
3978
+ status: status2,
3979
+ matrix: { shape: matrixShape(testCase2), permutations: plan2.permutations.length, iterations: testCase2.iterations }
3980
+ });
3981
+ await store.recordExecution(executionRecord("running"));
3982
+ let status = "complete";
3983
+ let finished;
3984
+ try {
3985
+ for (const permutation of plan2.permutations) {
3986
+ for (const iteration of permutation.iterations) {
3987
+ hooks.onRunning?.(permutation, iteration);
3988
+ const container = containerName(plan2.executionId, permutation.number, iteration.number, imagePrefix);
3989
+ const { execution, harnessResults } = await runInContainer({
3990
+ prerunImage: permutation.prerunImage,
3991
+ postrunImage: iteration.postrunImage,
3992
+ turns: iteration.turns,
3993
+ env: permutation.env,
3994
+ runOptions: permutation.runOptions,
3995
+ timeoutSeconds: permutation.timeoutSeconds,
3996
+ container
3997
+ });
3998
+ const turns = harnessResults.map((harnessResult2) => ({ harnessResult: harnessResult2, reply: collectReply(permutation.harness.collectReply, harnessResult2) }));
3999
+ const tokenCosts = collectTokenCosts2(permutation.collectTokenCosts, harnessResults, {
4000
+ version: permutation.version,
4001
+ modelId: permutation.modelId
4002
+ });
4003
+ const record = containerRunRecord(run, permutation, iteration, execution, turns, tokenCosts);
4004
+ const { harnessResult, reply } = record;
4005
+ await store.recordContainerRun(record);
4006
+ if (execution.exitCode !== 0)
4007
+ status = "failed";
4008
+ hooks.onRan?.(permutation, iteration, record);
4009
+ if (measurements.length === 0)
4010
+ continue;
4011
+ let opened = 0;
4012
+ const recorded = { harnessResult: harnessResult ?? "", reply, replies: turns.map((turn) => turn.reply) };
4013
+ const open = async (usesContainer) => usesContainer ? openMeasurementContext(iteration.postrunImage, `${container}-measure-${++opened}`, recorded) : recordedContext(recorded);
4014
+ const result = await measureContainerRun(measurements, open);
4015
+ await store.recordContainerRun({ ...record, result });
4016
+ hooks.onMeasured?.(permutation, iteration, result);
4017
+ }
4018
+ }
4019
+ } catch (error) {
4020
+ status = "failed";
4021
+ throw error;
4022
+ } finally {
4023
+ finished = executionRecord(status, new Date().toISOString());
4024
+ await store.recordExecution(finished);
3961
4025
  }
3962
- return { executionId, iterations: testCase2.iterations, permutations: planned };
4026
+ return finished;
3963
4027
  }
3964
4028
 
3965
- // src/matrix/ulid.ts
4029
+ // src/execution/ulid.ts
3966
4030
  var ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
3967
4031
  function ulid(now = Date.now(), random = crypto.getRandomValues(new Uint8Array(10))) {
3968
4032
  if (!Number.isInteger(now) || now < 0 || now >= 2 ** 48)
@@ -3982,260 +4046,409 @@ function ulid(now = Date.now(), random = crypto.getRandomValues(new Uint8Array(1
3982
4046
  }
3983
4047
 
3984
4048
  // src/cli/commands/run/run.ts
3985
- var indent = (text, spaces) => text.split(`
3986
- `).map((line) => " ".repeat(spaces) + line).join(`
4049
+ var FIELD_INDENT = " ";
4050
+ var LABEL_WIDTH2 = 10;
4051
+ var field = (label, value) => `${FIELD_INDENT}${`${label}:`.padEnd(LABEL_WIDTH2)}${value.split(`
4052
+ `).join(`
4053
+ ${FIELD_INDENT}${" ".repeat(LABEL_WIDTH2)}`)}`;
4054
+ function renderExecution({ executionId, permutations }) {
4055
+ const blocks = permutations.map((permutation) => [
4056
+ `Permutation ${permutation.number}: prerun image ${permutation.prerunImage}`,
4057
+ ...permutation.iterations.flatMap((iteration) => [
4058
+ ` Iteration ${iteration.number}: postrun image ${iteration.postrunImage}`,
4059
+ ...permutation.env.length > 0 ? [field("env", permutation.env.join(", "))] : [],
4060
+ ...permutation.runOptions.length > 0 ? [field("docker", permutation.runOptions.join(" "))] : [],
4061
+ ...iteration.turns.length === 1 ? [field("prompt", iteration.turns[0].prompt), field("command", iteration.turns[0].command)] : [
4062
+ field("session", iteration.sessionId),
4063
+ ...iteration.turns.flatMap(({ prompt, command }, t) => [field(`prompt ${t + 1}`, prompt), field(`turn ${t + 1}`, command)])
4064
+ ]
4065
+ ])
4066
+ ].join(`
4067
+ `));
4068
+ return [`Execution ${executionId}`, ...blocks].join(`
4069
+
3987
4070
  `);
4071
+ }
4072
+ async function dryRun2(cwd, name, imagePrefix) {
4073
+ const root = await findThunderjarRoot(cwd);
4074
+ const executionId = ulid();
4075
+ const testCase2 = await loadTestCase(root, name);
4076
+ await checkRequiredEnv(root, testCase2);
4077
+ return renderExecution(await planExecution(root, testCase2, executionId, imagePrefix));
4078
+ }
3988
4079
  var ordinal = (n, total) => `${String(n).padStart(String(total).length)}/${total}`;
3989
- var shape = ({ permutations, iterations }) => `${count(permutations.length, "permutation")} \xD7 ${count(iterations, "iteration")}`;
3990
- async function run(name, options) {
3991
- const {
3992
- dryRun,
3993
- cwd = process.cwd(),
3994
- log = (text) => console.log(text),
3995
- now = Date.now,
3996
- newExecutionId = () => ulid(now()),
3997
- rootDir = defaultRootDir(),
3998
- docker: docker2 = dockerCli(),
3999
- repository = LOCAL_REPOSITORY,
4000
- runner = createDockerRunner(docker2, repository),
4001
- measurementOpener = createDockerMeasurementOpener(docker2, repository),
4002
- dataDir
4003
- } = options;
4004
- const root = findThunderjarRoot(cwd);
4080
+ var duration = (ms) => ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
4081
+ var showValue = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? `{${Object.entries(value).map(([key, v]) => `${key}: ${showValue(v)}`).join(", ")}}` : JSON.stringify(value) ?? String(value);
4082
+ var showOutcome = (outcome) => outcome.outcome === "measured" ? showValue(outcome.data) : outcome.outcome === "skipped" ? `skipped${outcome.reason ? ` (${outcome.reason})` : ""}` : `erroredWhileMeasuring (${outcome.error.message})`;
4083
+ var showMeasures = (result) => Object.entries(result).map(([name, { outcome }]) => `${name}: ${showOutcome(outcome)}`).join(" ");
4084
+ function label(testCase2, { permutation }) {
4085
+ const varying = AXES.filter((axis) => axis !== "harnessModel" && axis !== "initialPrompt" && testCase2[axis].length > 1);
4086
+ const harnesses = new Set(testCase2.harnessModel.map((pair) => parsePair(pair).harness));
4087
+ const harness2 = harnesses.size === testCase2.harnessModel.length ? parsePair(permutation.harnessModel).harness : permutation.harnessModel;
4088
+ return [...varying.map((axis) => permutation[axis]), harness2, permutation.initialPrompt].join(" | ");
4089
+ }
4090
+ async function run(cwd, name, imagePrefix, dataDir) {
4091
+ const root = await findThunderjarRoot(cwd);
4005
4092
  const testCase2 = await loadTestCase(root, name);
4006
- const execution = await planExecution(root, testCase2, newExecutionId());
4007
- const { executionId, permutations, iterations } = execution;
4008
- const rows = permutationRows(permutations.map((p) => p.permutation));
4009
- const permutationLine = (n) => `Permutation ${ordinal(n + 1, permutations.length)} ${rows[n]}`;
4010
- if (dryRun) {
4011
- const found = await prerunStatuses(docker2, repository, permutations.map((p) => p.permutation));
4012
- log(`Execution ${executionId} (dry run, ${shape(execution)})`);
4013
- if ("unreachable" in found)
4014
- log(unreachableNote(found.unreachable));
4015
- permutations.forEach(({ permutation, prerunTag: prerunTag2, runs }, n) => {
4016
- log("");
4017
- log([
4018
- permutationLine(n),
4019
- ` ${prerunTag2}${"statuses" in found ? showPrerunStatus(found.statuses.get(permutation.hash)) : ""}`,
4020
- ...runs.map(({ iterationIndex, postrunTag: postrunTag2, command }) => ` iteration ${ordinal(iterationIndex + 1, iterations)} ${postrunTag2}
4021
- ${indent(command, 4)}`)
4022
- ].join(`
4023
- `));
4093
+ await checkRequiredEnv(root, testCase2);
4094
+ const plan2 = await planExecution(root, testCase2, ulid(), imagePrefix);
4095
+ const store = dataDirOf(cwd, root, dataDir);
4096
+ const permutations = plan2.permutations.length;
4097
+ console.log(`Execution ${plan2.executionId}`);
4098
+ let building;
4099
+ let lineOpen = false;
4100
+ try {
4101
+ const record = await runExecution({ root, name, testCase: testCase2, plan: plan2, imagePrefix }, fileStore(store), {
4102
+ onBuilding: (image) => {
4103
+ building = image;
4104
+ lineOpen = true;
4105
+ process.stdout.write(`Building ${image} ... `);
4106
+ },
4107
+ onBuilt: () => {
4108
+ building = undefined;
4109
+ lineOpen = false;
4110
+ console.log("done");
4111
+ },
4112
+ onRunning: (permutation, iteration) => {
4113
+ lineOpen = true;
4114
+ process.stdout.write(` ${ordinal(permutation.number, permutations)} iteration ${ordinal(iteration.number, testCase2.iterations)} ${label(testCase2, permutation)} ... `);
4115
+ },
4116
+ onRan: (_permutation, _iteration, { execution, harnessResult, tokenCosts }) => {
4117
+ lineOpen = false;
4118
+ const ended = agentEnd(execution, harnessResult);
4119
+ const cost = knownCost(tokenCosts);
4120
+ const tokens = totalTokens(tokenCosts);
4121
+ const spent = cost !== undefined ? `, ${dollars(cost)}` : tokens !== undefined ? `, ${thousands(tokens)} tokens` : "";
4122
+ console.log(execution.timedOut ? ended : `${ended} (${duration(execution.finishedAt - execution.startedAt)}${spent})`);
4123
+ },
4124
+ onMeasured: (permutation, _iteration, result) => {
4125
+ console.log(`${" ".repeat(ordinal(permutation.number, permutations).length + 3)}${showMeasures(result)}`);
4126
+ }
4024
4127
  });
4025
- return;
4026
- }
4027
- const measurements = await loadMeasurements(root, testCase2.task);
4028
- const nameWidth = Math.max(0, ...measurements.map((m) => m.name.length));
4029
- const storePath = runDataStorePath(cwd, dataDir);
4030
- const store = jsonFileStore(storePath);
4031
- const builds = await preparePrerunBuilds({
4032
- root,
4033
- task: testCase2.task,
4034
- permutations: permutations.map((p) => p.permutation),
4035
- docker: docker2,
4036
- repository,
4037
- rootDir,
4038
- purpose: "Running a test case"
4039
- });
4040
- log(`Execution ${executionId} (${shape(execution)})`);
4041
- log(`Images in ${repository}`);
4042
- if (builds.missing.size > 0)
4043
- log(`Build contexts in ${join(rootDir, "builds")}`);
4044
- const exitCodes = new Map;
4045
- for (const [n, { permutation, parameters, harnessModel, prerunTag: prerunTag2, runs }] of permutations.entries()) {
4046
- log("");
4047
- log(permutationLine(n));
4048
- if (builds.missing.has(permutation.hash)) {
4049
- const started = now();
4050
- await builds.build(permutation.hash);
4051
- log(` ${prerunTag2} built (${seconds(now() - started)})`);
4128
+ console.log(`Wrote ${displayPath(cwd, executionFolder(store, plan2.executionId))}/`);
4129
+ if (record.status === "failed")
4130
+ process.exitCode = 1;
4131
+ } catch (error) {
4132
+ if (error instanceof DockerUnavailableError) {
4133
+ console.error(`Error: Docker can't be reached, so nothing can be run: ${error.message}`);
4134
+ } else if (error instanceof DockerBuildError) {
4135
+ console.log("failed");
4136
+ console.error(`Error: Building ${building} failed: ${error.message}`);
4137
+ } else if (error instanceof DockerContainerError && lineOpen) {
4138
+ console.log("failed");
4139
+ console.error(`Error: Running a container failed: ${error.message}`);
4140
+ } else if (error instanceof DockerContainerError) {
4141
+ console.error(`Error: Measuring a container run failed: ${error.message}`);
4052
4142
  } else {
4053
- log(` ${prerunTag2} reused`);
4054
- }
4055
- for (const containerRun of runs) {
4056
- const { startedAt, finishedAt, exitCode } = await runner.runContainer(containerRun);
4057
- await store.recordContainerRun({
4058
- executionId,
4059
- testCase: name,
4060
- task: testCase2.task,
4061
- permutationHash: permutation.hash,
4062
- iteration: containerRun.iterationIndex,
4063
- postrunImage: containerRun.postrunTag,
4064
- ranAt: new Date(startedAt).toISOString(),
4065
- parameters,
4066
- harnessModel,
4067
- result: {},
4068
- execution: { startedAt, finishedAt, exitCode }
4069
- });
4070
- exitCodes.set(exitCode, (exitCodes.get(exitCode) ?? 0) + 1);
4071
- log(` iteration ${ordinal(containerRun.iterationIndex + 1, iterations)} ${containerRun.postrunTag}` + ` exit ${exitCode} ${duration({ startedAt, finishedAt })}`);
4072
- if (measurements.length > 0) {
4073
- const open = () => measurementOpener.openMeasurementContext(containerRun.postrunTag);
4074
- const result = await measureContainerRun(measurements, open, now);
4075
- await store.recordMeasures(containerRun.postrunTag, result);
4076
- log(Object.entries(result).map(([name2, { outcome }]) => ` ${name2.padEnd(nameWidth)} ${showOutcome(outcome)}`).join(`
4077
- `));
4078
- }
4079
- await recomputeAggregationResult(store, executionId, permutation.hash, measurements);
4143
+ throw error;
4080
4144
  }
4145
+ process.exitCode = 1;
4081
4146
  }
4082
- const total = permutations.length * iterations;
4083
- const byCode = [...exitCodes].toSorted(([a], [b]) => a - b).map(([code, n]) => `${n} exited ${code}`);
4084
- log("");
4085
- log(`${count(total, "container run")}: ${byCode.join(", ")}`);
4086
- log(`Recorded in ${relative(cwd, storePath)}`);
4087
4147
  }
4088
4148
  function createRunCommand() {
4089
- return new Command("run").description("Run a test case.").argument("<test case>", "the test case to run").option("--dry-run", "print each container run instead of building or running anything").addOption(imagePrefixOption("the image repository prerun and postrun images are tagged in")).addOption(dataDirOption()).action(async (name, options) => {
4090
- await run(name, { dryRun: options.dryRun ?? false, repository: options.imagePrefix, dataDir: options.dataDir });
4091
- });
4149
+ return new Command("run").description("Run a test case: one execution, with a new ID. Builds any missing prerun image, then runs each iteration of each permutation in a container from its prerun image, commits it into its postrun image, takes the task's measurements of it, each in a container from its postrun image, and records it in the run data store.").argument("<test case>", `the name of a test case, declared in ${FOLDER}/test-cases/<test case>.ts`).option("--dry-run", "print the images, environment variables, prompts and harness commands the execution would use, without running anything").addOption(imagePrefixOption()).addOption(dataDirOption()).action(reportingConfigErrors(async (name, options) => {
4150
+ if (options.dryRun)
4151
+ console.log(await dryRun2(process.cwd(), name, options.imagePrefix));
4152
+ else
4153
+ await run(process.cwd(), name, options.imagePrefix, options.dataDir);
4154
+ }));
4155
+ }
4156
+
4157
+ // src/execution/aggregate.ts
4158
+ var sum = (xs) => xs.reduce((a, b) => a + b, 0);
4159
+ var mean = (xs) => sum(xs) / xs.length;
4160
+ var median = (xs) => {
4161
+ const sorted2 = xs.toSorted((a, b) => a - b);
4162
+ const mid = sorted2.length >> 1;
4163
+ return sorted2.length % 2 === 1 ? sorted2[mid] : (sorted2[mid - 1] + sorted2[mid]) / 2;
4164
+ };
4165
+ var byKey = (records, combine) => {
4166
+ const keys = [...new Set(records.flatMap((record) => Object.keys(record)))];
4167
+ return Object.fromEntries(keys.map((key) => [key, combine(records.map((record) => record[key] ?? 0))]));
4168
+ };
4169
+ var ofBooleans = {
4170
+ rate: (bs) => bs.filter(Boolean).length / bs.length,
4171
+ count: (bs) => bs.filter(Boolean).length
4172
+ };
4173
+ var ofNumbers = { mean, median, min: (xs) => Math.min(...xs), max: (xs) => Math.max(...xs), sum };
4174
+ var ofRecords = {
4175
+ sum: (records) => byKey(records, sum),
4176
+ mean: (records) => byKey(records, mean)
4177
+ };
4178
+ var isRecordOfNumbers = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((v) => typeof v === "number");
4179
+ function menuFor(values) {
4180
+ if (values.every((v) => typeof v === "boolean"))
4181
+ return ["booleans", ofBooleans];
4182
+ if (values.every((v) => typeof v === "number"))
4183
+ return ["numbers", ofNumbers];
4184
+ if (values.every(isRecordOfNumbers))
4185
+ return ["records of numbers", ofRecords];
4186
+ return;
4187
+ }
4188
+ function aggregateValues(values, methods) {
4189
+ if (values.length === 0)
4190
+ return { values: {} };
4191
+ const menu = menuFor([...values]);
4192
+ if (!menu)
4193
+ return { incompatible: "recorded values aren't all booleans, numbers or records of numbers" };
4194
+ const [kind, menuMethods] = menu;
4195
+ const unfit = methods.filter((method) => !menuMethods[method]);
4196
+ if (unfit.length > 0) {
4197
+ const list = unfit.map((method) => `"${method}"`).join(", ");
4198
+ return { incompatible: `recorded values are ${kind}; ${list} ${unfit.length === 1 ? "doesn't" : "don't"} combine them` };
4199
+ }
4200
+ return {
4201
+ values: Object.fromEntries(methods.map((method) => [method, menuMethods[method]([...values])]))
4202
+ };
4203
+ }
4204
+ function aggregatePermutation(runs, measurements) {
4205
+ return Object.fromEntries(measurements.map(({ name, declaration: declaration2 }) => {
4206
+ const values = runs.flatMap(({ result }) => {
4207
+ const outcome = result[name]?.outcome;
4208
+ return outcome?.outcome === "measured" ? [outcome.data] : [];
4209
+ });
4210
+ const combined = aggregateValues(values, declaration2.aggregate);
4211
+ const measure = "incompatible" in combined ? { measured: values.length, values: {}, incompatible: combined.incompatible } : { measured: values.length, values: combined.values };
4212
+ return [name, measure];
4213
+ }));
4092
4214
  }
4093
4215
 
4094
4216
  // src/cli/commands/show/show.ts
4217
+ var GAP = " ";
4095
4218
  var labelled = (lines2) => {
4096
- const width = Math.max(...lines2.map(([label]) => label.length));
4097
- return lines2.map(([label, value]) => `${label.padEnd(width)} ${value}`);
4219
+ const width = Math.max(...lines2.map(([label2]) => label2.length));
4220
+ return lines2.map(([label2, value]) => `${label2.padEnd(width)} ${value}`);
4098
4221
  };
4099
- function table(rows) {
4222
+ function columns(rows) {
4100
4223
  const widths = rows[0].map((_, i) => Math.max(...rows.map((row) => row[i]?.length ?? 0)));
4101
- return rows.map((row) => row.length === 0 ? "" : ` ${row.map((cell, i) => i === row.length - 1 ? cell : cell.padEnd(widths[i])).join(" ")}`);
4224
+ return rows.map((row) => ` ${row.map((cell, i) => cell.padEnd(widths[i])).join(" ")}`.trimEnd());
4102
4225
  }
4103
- var permutationOf = ({ parameters }) => Object.fromEntries(AXES.map((axis) => [axis, parameters[axis].name]));
4104
- var PLACES = { rate: 2, mean: 1, median: 1 };
4105
- function aggregatedValue(method, value) {
4106
- const places = PLACES[method];
4107
- const number = (n) => places === undefined ? showValue(n) : n.toFixed(places);
4226
+ var decimal = (n, places) => places === undefined ? String(n) : n.toFixed(places);
4227
+ function showValue2(value, places) {
4108
4228
  if (typeof value === "number")
4109
- return number(value);
4110
- return `{${Object.entries(value).map(([key, n]) => `${key}: ${number(n)}`).join(", ")}}`;
4111
- }
4112
- function aggregateCell({ measured, ...methods }) {
4113
- if (measured === 0)
4114
- return "\u2014";
4115
- return Object.entries(methods).map(([method, value]) => `${method} ${aggregatedValue(method, value)}`).join(", ");
4116
- }
4117
- function exits(runs) {
4118
- const byCode = new Map;
4119
- for (const { execution } of runs)
4120
- byCode.set(execution.exitCode, (byCode.get(execution.exitCode) ?? 0) + 1);
4121
- return [...byCode].toSorted(([a], [b]) => a - b).map(([code, n]) => `${n}/${runs.length} exited ${code}`).join(", ");
4122
- }
4123
- var summary = (label, value) => ` ${label.padEnd(11)}${value}`;
4124
- function staleness(measurements) {
4125
- const currentHashes = new Map(measurements.map((measurement2) => [measurement2.name, hashMeasurement(measurement2)]));
4126
- return (name, { contentHash }) => {
4127
- const current = currentHashes.get(name);
4128
- return current !== undefined && current !== contentHash;
4129
- };
4229
+ return decimal(value, places);
4230
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
4231
+ const entries = Object.entries(value).map(([key, v]) => `${key}: ${typeof v === "number" ? decimal(v, places) : JSON.stringify(v)}`);
4232
+ return `{${entries.join(", ")}}`;
4233
+ }
4234
+ return JSON.stringify(value) ?? String(value);
4235
+ }
4236
+ function showAggregate(method, value, measured) {
4237
+ if (method === "rate" && typeof value === "number")
4238
+ return `${Math.round(value * measured)}/${measured}`;
4239
+ return showValue2(value, method === "mean" || method === "median" ? 1 : undefined);
4240
+ }
4241
+ function showOutcome2(outcome) {
4242
+ if (outcome === undefined)
4243
+ return "not measured";
4244
+ if (outcome.outcome === "skipped")
4245
+ return `skipped${outcome.reason === undefined ? "" : ` (${outcome.reason})`}`;
4246
+ if (outcome.outcome === "erroredWhileMeasuring")
4247
+ return `errored while measuring (${outcome.error.message})`;
4248
+ return showValue2(outcome.data);
4249
+ }
4250
+ function duration2(ms) {
4251
+ const seconds = Math.round(ms / 1000);
4252
+ return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
4253
+ }
4254
+ var runName = ({ permutation, iteration }) => `${permutation}-${iteration}`;
4255
+ var notCollected = (costs) => costs === undefined ? "not recorded" : costs.outcome === "unavailable" ? `not reported (${costs.reason})` : "";
4256
+ function costColumns(runs) {
4257
+ const mean2 = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length;
4258
+ const tokens = runs.flatMap(({ tokenCosts }) => totalTokens(tokenCosts) ?? []);
4259
+ const costs = runs.flatMap(({ tokenCosts }) => knownCost(tokenCosts) ?? []);
4260
+ const star = (n) => n < runs.length ? "*" : "";
4261
+ return [
4262
+ tokens.length === 0 ? "not reported" : `${thousands(mean2(tokens))}${star(tokens.length)}`,
4263
+ costs.length === 0 ? "\u2014" : `${dollars(mean2(costs))}${star(costs.length)}`
4264
+ ];
4130
4265
  }
4131
- function aggregationTable({ iterations, measures }, runs, isStale) {
4132
- const names = Object.keys(measures).toSorted();
4133
- if (names.length === 0)
4134
- return [" no measures"];
4135
- const staleRow = (name) => runs.some(({ result }) => {
4136
- const measure = result[name];
4137
- return measure?.outcome.outcome === "measured" && isStale(name, measure);
4138
- });
4139
- return table([
4140
- ["MEASURE", "MEASURED", "AGGREGATE"],
4141
- ...names.map((name) => [
4142
- name,
4143
- `${measures[name].measured}/${iterations}`,
4144
- `${aggregateCell(measures[name])}${staleRow(name) ? " stale, run `remeasure`" : ""}`
4266
+ function tokenCostLines(costs) {
4267
+ if (costs.outcome === "unavailable")
4268
+ return [` none (${costs.reason})`];
4269
+ const cost = ({ costUsd, costBasis }) => costUsd === undefined ? "not reported" : costBasis === "unknown" ? `not reported (its price table doesn't know the model)` : dollars(costUsd);
4270
+ return columns([
4271
+ ["model", "input", "output", "cache write", "cache read", "cost"],
4272
+ ...costs.models.map((model) => [
4273
+ model.model,
4274
+ thousands(model.inputTokens),
4275
+ thousands(model.outputTokens),
4276
+ thousands(model.cacheCreationTokens),
4277
+ thousands(model.cacheReadTokens),
4278
+ cost(model)
4145
4279
  ])
4146
4280
  ]);
4147
4281
  }
4148
- var detailed = (outcome) => outcome.outcome === "measured" ? showValue(outcome.data) : outcome.outcome === "skipped" ? `skipped${outcome.reason === undefined ? "" : ` (${outcome.reason})`}` : `error (${outcome.error.message})`;
4149
- function byPermutation(records) {
4150
- const groups = new Map;
4151
- for (const record of records) {
4152
- const group = groups.get(record.permutationHash);
4153
- if (group)
4154
- group.push(record);
4155
- else
4156
- groups.set(record.permutationHash, [record]);
4157
- }
4158
- return [...groups.values()];
4159
- }
4160
- function showExecution(records, aggregations, measurements, latestOf) {
4161
- const [{ executionId, testCase: testCase2, task }] = records;
4162
- const ranAt = records.map((r) => r.ranAt).toSorted()[0];
4163
- const lines2 = labelled([
4164
- ["Execution", `${executionId}${latestOf === undefined ? "" : ` (latest of ${latestOf})`}`],
4282
+ function varyingAxes(runs) {
4283
+ const varying = AXES.filter((axis) => new Set(runs.map(({ parameters }) => parameters[axis].name)).size > 1);
4284
+ return varying.length > 0 ? varying : [...AXES];
4285
+ }
4286
+ function permutationTable(execution, runs, measurements) {
4287
+ const axes = varyingAxes(runs);
4288
+ const methodColumns = measurements.flatMap(({ name, declaration: declaration2 }) => declaration2.aggregate.map((method, i) => ({ name, method, first: i === 0 })));
4289
+ const numberWidth = String(execution.matrix.permutations).length;
4290
+ const withCosts = runs.some(({ tokenCosts }) => tokenCosts !== undefined);
4291
+ const rows = [];
4292
+ const leftOut = [];
4293
+ const incompatible = new Map;
4294
+ for (let permutation = 1;permutation <= execution.matrix.permutations; permutation++) {
4295
+ const number = String(permutation).padStart(numberWidth);
4296
+ const own = runs.filter((run2) => run2.permutation === permutation);
4297
+ const [first] = own;
4298
+ if (!first) {
4299
+ rows.push([number, "no container runs"]);
4300
+ continue;
4301
+ }
4302
+ const measures = aggregatePermutation(own, measurements);
4303
+ for (const [name, measure] of Object.entries(measures)) {
4304
+ if (measure.incompatible !== undefined)
4305
+ incompatible.set(name, (incompatible.get(name) ?? new Set).add(measure.incompatible));
4306
+ }
4307
+ for (const { name } of measurements) {
4308
+ for (const run2 of own) {
4309
+ const outcome = run2.result[name]?.outcome;
4310
+ if (outcome?.outcome !== "measured")
4311
+ leftOut.push([runName(run2), name, showOutcome2(outcome)]);
4312
+ }
4313
+ }
4314
+ const costs = withCosts ? costColumns(own) : [];
4315
+ if (withCosts) {
4316
+ for (const run2 of own) {
4317
+ if (run2.tokenCosts?.outcome !== "collected")
4318
+ leftOut.push([runName(run2), "tokens", notCollected(run2.tokenCosts)]);
4319
+ }
4320
+ }
4321
+ rows.push([
4322
+ number,
4323
+ axes.map((axis) => first.parameters[axis].name).join(" | "),
4324
+ `${own.length}/${execution.matrix.iterations}`,
4325
+ ...methodColumns.map(({ name, method }) => {
4326
+ const { measured, values, incompatible: incompatible2 } = measures[name];
4327
+ if (incompatible2 !== undefined)
4328
+ return "?";
4329
+ const value = values[method];
4330
+ if (value === undefined)
4331
+ return "\u2014";
4332
+ return `${showAggregate(method, value, measured)}${measured < own.length ? "*" : ""}`;
4333
+ }),
4334
+ ...costs
4335
+ ]);
4336
+ }
4337
+ const header = ["", axes.join(" | "), "runs", ...methodColumns.map(({ method }) => method), ...withCosts ? ["tokens (mean)", "cost (mean)"] : []];
4338
+ const widths = header.map((cell, i) => Math.max(cell.length, ...rows.map((row) => row[i]?.length ?? 0)));
4339
+ for (const { name, declaration: declaration2 } of measurements) {
4340
+ const start = 3 + methodColumns.findIndex((column2) => column2.name === name);
4341
+ const end = start + declaration2.aggregate.length - 1;
4342
+ const span = widths.slice(start, end + 1).reduce((a, b) => a + b, 0) + GAP.length * (end - start);
4343
+ if (span < name.length)
4344
+ widths[end] += name.length - span;
4345
+ }
4346
+ const line = (cells) => ` ${cells.map((cell, i) => cell.padEnd(widths[i])).join(GAP)}`.trimEnd();
4347
+ const column = (i) => 1 + widths.slice(0, i).reduce((a, b) => a + b + GAP.length, 0);
4348
+ const names = methodColumns.reduce((text, { name, first }, i) => first ? `${text.padEnd(column(3 + i))}${name}` : text, "");
4349
+ const lines2 = [...measurements.length > 0 ? [names] : [], line(header), ...rows.map(line)];
4350
+ const unclean = runs.filter(({ execution: execution2, harnessResult }) => !endedCleanly(execution2, harnessResult));
4351
+ if (unclean.length > 0) {
4352
+ lines2.push("", "! Agents that didn't end cleanly:", ...columns(unclean.map((run2) => [runName(run2), agentEnd(run2.execution, run2.harnessResult)])));
4353
+ }
4354
+ if (leftOut.length > 0)
4355
+ lines2.push("", "* Left out of the aggregate:", ...columns(leftOut));
4356
+ if (incompatible.size > 0) {
4357
+ const reasons = [...incompatible].flatMap(([name, why]) => [...why].map((reason) => [`${name}:`, `${reason} (remeasure)`]));
4358
+ lines2.push("", "? Can't aggregate as declared today:", ...columns(reasons));
4359
+ }
4360
+ return lines2;
4361
+ }
4362
+ function renderExecution2(execution, runs, measurements) {
4363
+ const { executionId, testCase: testCase2, task, startedAt, status, matrix } = execution;
4364
+ const header = labelled([
4365
+ ["Execution", executionId],
4165
4366
  ["Test case", testCase2],
4166
4367
  ["Task", task],
4167
- ["Date", minute(ranAt)]
4368
+ ["Started", minute(startedAt)],
4369
+ ["Status", status],
4370
+ ["Matrix", `${plural(matrix.permutations, "permutation")} \xD7 ${plural(matrix.iterations, "iteration")}`]
4168
4371
  ]);
4169
- const permutations = byPermutation(records);
4170
- const rows = permutationRows(permutations.map(([first]) => permutationOf(first)));
4171
- const byHash = new Map(aggregations.map((a) => [a.permutationHash, a]));
4172
- const isStale = staleness(measurements);
4173
- permutations.forEach((runs, n) => {
4174
- const aggregation = byHash.get(runs[0].permutationHash);
4175
- lines2.push("", `Permutation ${abbreviateHash(runs[0].permutationHash)} (${count(runs.length, "iteration")})`, ` ${rows[n]}`, "", ...aggregation ? aggregationTable(aggregation, runs, isStale) : [" no aggregation result recorded"], "", summary("exit", exits(runs)), ...aggregation ? [summary("duration", `mean ${seconds(aggregation.duration.mean)}, max ${seconds(aggregation.duration.max)}`)] : []);
4176
- });
4177
- return lines2.join(`
4372
+ const body = runs.length === 0 ? ["No container runs recorded."] : permutationTable(execution, runs, measurements);
4373
+ return [...header, "", ...body].join(`
4178
4374
  `);
4179
4375
  }
4180
- function showContainerRun(record, measurements) {
4181
- const { postrunImage, executionId, testCase: testCase2, permutationHash, iteration, ranAt, parameters, result, execution } = record;
4376
+ var showReply = (reply, label2 = "") => {
4377
+ const lines2 = reply.outcome === "collected" ? reply.text.split(`
4378
+ `) : [`none (${reply.reason})`];
4379
+ return lines2.map((line, i) => ` ${i === 0 ? label2 : " ".repeat(label2.length)}${line}`.trimEnd());
4380
+ };
4381
+ var showReplies = ({ reply, turns }) => turns ? turns.flatMap((turn, i) => showReply(turn.reply, `Turn ${i + 1}: `)) : showReply(reply);
4382
+ function renderContainerRun(record) {
4383
+ const { executionId, testCase: testCase2, permutation, permutationHash, iteration, postrunImage: postrunImage2, ranAt, parameters, harnessModel, execution, harnessResult, reply, result } = record;
4384
+ const turnsRan = execution.turns === undefined ? [] : [["Turns", `${execution.turns.length} of ${record.turns?.length ?? "?"} ran`]];
4182
4385
  const names = Object.keys(result).toSorted();
4183
- const isStale = staleness(measurements);
4184
- const cell = (name) => `${detailed(result[name].outcome)}${isStale(name, result[name]) ? " (stale)" : ""}`;
4185
4386
  return [
4186
4387
  ...labelled([
4187
- ["Container run", postrunImage],
4388
+ ["Container run", runName(record)],
4188
4389
  ["Execution", `${executionId} (${testCase2})`],
4189
- ["Permutation", `${abbreviateHash(permutationHash)}, iteration ${iteration + 1}`],
4390
+ ["Permutation", `${permutation} (${abbreviateHash(permutationHash)}), iteration ${iteration}`],
4391
+ ["Postrun image", postrunImage2],
4190
4392
  ["Ran at", minute(ranAt)]
4191
4393
  ]),
4192
4394
  "",
4193
4395
  "Parameters",
4194
- ...table(AXES.map((axis) => [axis, parameters[axis].name, parameters[axis].parameterHash.slice(0, 4)])),
4396
+ ...columns(AXES.map((axis) => [axis, parameters[axis].name, abbreviateHash(parameters[axis].parameterHash)])),
4195
4397
  "",
4196
- "Measures",
4197
- ...names.length === 0 ? [" none"] : table(names.map((name) => [name, cell(name)])),
4398
+ ...labelled([
4399
+ ["Harness", `${harnessModel.harness} ${harnessModel.version}`],
4400
+ ["Model", `${harnessModel.model} (${harnessModel.modelId})`],
4401
+ ["Duration", duration2(execution.finishedAt - execution.startedAt)],
4402
+ ["Exit", execution.timedOut ? `timed out after ${execution.timeoutSeconds}s` : String(execution.exitCode)],
4403
+ ...turnsRan,
4404
+ ["Result file", harnessResult === null ? "none" : plural(new TextEncoder().encode(harnessResult).length, "byte")]
4405
+ ]),
4198
4406
  "",
4199
- `Duration ${duration(execution)} exit code ${execution.exitCode}`
4407
+ ...reply === undefined ? [] : ["Reply", ...showReplies(record), ""],
4408
+ ...record.tokenCosts === undefined ? [] : ["Token costs", ...tokenCostLines(record.tokenCosts), ""],
4409
+ "Measures",
4410
+ ...names.length === 0 ? [" none"] : columns(names.map((name) => [name, showOutcome2(result[name].outcome)]))
4200
4411
  ].join(`
4201
4412
  `);
4202
4413
  }
4203
- async function show3(target, { cwd = process.cwd(), dataDir } = {}) {
4204
- const store = jsonFileStore(runDataStorePath(cwd, dataDir));
4205
- const todays = (task) => loadMeasurements(findThunderjarRoot(cwd), task);
4206
- const containerRun = await store.getContainerRun(target);
4207
- if (containerRun)
4208
- return showContainerRun(containerRun, await todays(containerRun.task));
4209
- const execution = await store.getExecution(target);
4210
- if (execution.length > 0) {
4211
- return showExecution(execution, await store.getAggregationResults(target), await todays(execution[0].task));
4414
+ function fail(message3) {
4415
+ console.error(`Error: ${message3}`);
4416
+ process.exitCode = 1;
4417
+ }
4418
+ async function show6(cwd, executionId, containerRun, dataDir) {
4419
+ const root = containerRun === undefined || dataDir === undefined ? await findThunderjarRoot(cwd) : undefined;
4420
+ const store = fileStore(dataDir === undefined ? dataDirOf(cwd, root) : resolvePath(cwd, dataDir));
4421
+ let execution;
4422
+ let runs;
4423
+ try {
4424
+ execution = (await store.listExecutions()).find((record2) => record2.executionId === executionId);
4425
+ if (!execution)
4426
+ return fail(`No execution ${executionId} recorded. List them with \`thunderjar executions\`.`);
4427
+ runs = await store.containerRunsOf(executionId);
4428
+ } catch (error) {
4429
+ if (!(error instanceof RunDataStoreError))
4430
+ throw error;
4431
+ return fail(`Can't read ${displayPath(cwd, error.path)}: ${error.message}`);
4212
4432
  }
4213
- const [latest] = await store.listExecutions(target);
4214
- if (latest) {
4215
- const { executionId } = latest;
4216
- const records = await store.getExecution(executionId);
4217
- return showExecution(records, await store.getAggregationResults(executionId), await todays(records[0].task), target);
4433
+ if (containerRun === undefined) {
4434
+ console.log(renderExecution2(execution, runs, await loadMeasurements(root, execution.task)));
4435
+ return;
4218
4436
  }
4219
- throw new Error(`${target} is not a recorded container run, execution or test case`);
4437
+ const record = runs.find((run2) => runName(run2) === containerRun || run2.postrunImage === containerRun);
4438
+ if (!record)
4439
+ return fail(`Execution ${executionId} has no container run ${containerRun}.`);
4440
+ console.log(renderContainerRun(record));
4220
4441
  }
4221
4442
  function createShowCommand() {
4222
- return new Command("show").description("Show a test case, an execution or a container run.").argument("<target>", "a container run's postrun tag, an execution ID, or a test case, meaning its latest execution").addOption(dataDirOption()).action(async (target, options) => {
4223
- console.log(await show3(target, { dataDir: options.dataDir }));
4224
- });
4443
+ return new Command("show").description("Show an execution's recorded results: a row per permutation, with each measure aggregated across its iterations by its measurement's methods. Given a container run, show that one iteration instead: its parameters, how the agent ran, and each measure.").argument("<execution>", "the execution's ID, as listed by `thunderjar executions`").argument("[container run]", "one of its container runs, as `<permutation>-<iteration>`, e.g. `1-1`, or its postrun image").addOption(dataDirOption()).action(reportingConfigErrors(async (executionId, containerRun, options) => {
4444
+ await show6(process.cwd(), executionId, containerRun, options.dataDir);
4445
+ }));
4225
4446
  }
4226
4447
 
4227
4448
  // src/cli/program.ts
4228
4449
  function createProgram() {
4229
- const program2 = new Command().name("thunderjar").description(package_default.description).version(package_default.version).showHelpAfterError().addCommand(createInitCommand()).addCommand(createGenerateCommand()).addCommand(createPlanCommand()).addCommand(createBuildCommand()).addCommand(createRunCommand()).addCommand(createExecutionsCommand()).addCommand(createShowCommand()).addCommand(createRemeasureCommand());
4230
- for (const command of program2.commands)
4231
- command.showHelpAfterError();
4232
- return program2;
4450
+ return new Command().name("thunderjar").description(package_default.description).version(package_default.version).addCommand(createInitCommand()).addCommand(createGenerateCommand()).addCommand(createPlanCommand()).addCommand(createBuildCommand()).addCommand(createRunCommand()).addCommand(createExecutionsCommand()).addCommand(createShowCommand());
4233
4451
  }
4234
4452
 
4235
4453
  // src/cli/index.ts
4236
- try {
4237
- await createProgram().parseAsync();
4238
- } catch (error) {
4239
- console.error(error instanceof Error ? error.message : error);
4240
- process.exitCode = 1;
4241
- }
4454
+ await createProgram().parseAsync();