thunderjar 0.1.2 → 0.1.3-preview-eee7160
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +2161 -34
- package/package.json +13 -4
- package/src/AGENTS.md +156 -0
- package/src/adapters/docker/docker.ts +117 -0
- package/src/api/declarations.ts +279 -0
- package/src/api/fragments.ts +30 -0
- package/src/api/parameter-names.d.ts +16 -0
- package/src/cli/commands/build/build.docker.test.ts +173 -0
- package/src/cli/commands/build/build.test.ts +234 -0
- package/src/cli/commands/build/build.ts +115 -0
- package/src/cli/commands/declarations.typecheck.test.ts +173 -0
- package/src/cli/commands/executions/executions.test.ts +84 -0
- package/src/cli/commands/executions/executions.ts +31 -0
- package/src/cli/commands/format.ts +33 -0
- package/src/cli/commands/generate/generate.test.ts +101 -0
- package/src/cli/commands/generate/generate.ts +18 -0
- package/src/cli/commands/generate/parameter-names.ts +131 -0
- package/src/cli/commands/init/init.test.ts +123 -0
- package/src/cli/commands/init/init.ts +189 -0
- package/src/cli/commands/options.ts +17 -0
- package/src/cli/commands/plan/plan.test.ts +168 -0
- package/src/cli/commands/plan/plan.ts +72 -0
- package/src/cli/commands/remeasure/remeasure.test.ts +536 -0
- package/src/cli/commands/remeasure/remeasure.ts +194 -0
- package/src/cli/commands/run/run.test.ts +622 -0
- package/src/cli/commands/run/run.ts +203 -0
- package/src/cli/commands/show/show.test.ts +288 -0
- package/src/cli/commands/show/show.ts +252 -0
- package/src/cli/commands/validation.test.ts +200 -0
- package/src/cli/index.test.ts +47 -0
- package/src/cli/index.ts +9 -0
- package/src/cli/program.ts +29 -0
- package/src/images/prerun-builds.ts +108 -0
- package/src/images/prerun.test.ts +243 -0
- package/src/images/prerun.ts +272 -0
- package/src/images/repository.ts +72 -0
- package/src/index.ts +24 -0
- package/src/instruments/instruments.test.ts +240 -0
- package/src/instruments/instruments.ts +205 -0
- package/src/matrix/execution.ts +122 -0
- package/src/matrix/hashing.test.ts +298 -0
- package/src/matrix/hashing.ts +131 -0
- package/src/matrix/permutations.test.ts +58 -0
- package/src/matrix/permutations.ts +50 -0
- package/src/matrix/resolve.ts +63 -0
- package/src/matrix/tags.test.ts +15 -0
- package/src/matrix/tags.ts +22 -0
- package/src/matrix/ulid.test.ts +28 -0
- package/src/matrix/ulid.ts +22 -0
- package/src/measuring/aggregate.test.ts +188 -0
- package/src/measuring/aggregate.ts +154 -0
- package/src/measuring/measure.test.ts +105 -0
- package/src/measuring/measure.ts +62 -0
- package/src/measuring/remeasure-templates/v1/isPrime.test.template.txt +4 -0
- package/src/measuring/remeasure-templates/v2/isPrime.test.template.txt +5 -0
- package/src/measuring/remeasure.test.ts +352 -0
- package/src/measuring/remeasure.ts +113 -0
- package/src/platform/fs.test.ts +89 -0
- package/src/platform/fs.ts +123 -0
- package/src/platform/path.test.ts +38 -0
- package/src/platform/path.ts +72 -0
- package/src/platform/signals.test.ts +25 -0
- package/src/platform/signals.ts +68 -0
- package/src/project/config.test.ts +68 -0
- package/src/project/config.ts +297 -0
- package/src/running/runner.test.ts +73 -0
- package/src/running/runner.ts +252 -0
- package/src/store/run-data-store.test.ts +232 -0
- package/src/store/run-data-store.ts +254 -0
- package/src/testing/cli.ts +65 -0
- package/src/testing/fake-docker.test.ts +197 -0
- package/src/testing/fake-docker.ts +104 -0
- package/src/testing/fixture-project.ts +177 -0
- package/src/testing/measurement-context.ts +5 -0
- package/src/testing/seed-repo.test.ts +10 -0
- package/src/testing/seed-repo.ts +83 -0
- package/src/testing/test-setup.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thunderjar",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3-preview-eee7160",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Thunderjar",
|
|
6
6
|
"license": "MIT",
|
|
@@ -11,19 +11,27 @@
|
|
|
11
11
|
"bin": {
|
|
12
12
|
"thunderjar": "./dist/cli.js"
|
|
13
13
|
},
|
|
14
|
+
"main": "./src/index.ts",
|
|
15
|
+
"types": "./src/index.ts",
|
|
14
16
|
"files": [
|
|
15
|
-
"dist"
|
|
17
|
+
"dist",
|
|
18
|
+
"src"
|
|
16
19
|
],
|
|
17
20
|
"publishConfig": {
|
|
18
21
|
"access": "public"
|
|
19
22
|
},
|
|
20
23
|
"scripts": {
|
|
21
24
|
"build": "bun build src/cli/index.ts --target bun --outfile dist/cli.js",
|
|
22
|
-
"lint": "oxlint",
|
|
25
|
+
"lint": "bun scripts/sync-oxlint-layout.ts && bun scripts/sync-oxlint-layout.ts --check && oxlint",
|
|
23
26
|
"typecheck": "tsc --noEmit",
|
|
24
27
|
"test": "bun test",
|
|
28
|
+
"test:prepare": "bun scripts/prepare-tests.ts",
|
|
29
|
+
"test:docker": "THUNDERJAR_TEST_DOCKER=1 bun test docker",
|
|
30
|
+
"fixture": "bun scripts/fixture.ts",
|
|
31
|
+
"dev:link": "bun scripts/dev-link.ts",
|
|
25
32
|
"docs:dev": "vitepress dev docs",
|
|
26
33
|
"docs:build": "vitepress build docs",
|
|
34
|
+
"docs:build:versions": "bun scripts/build-versioned-docs.ts",
|
|
27
35
|
"docs:preview": "vitepress preview docs",
|
|
28
36
|
"changeset": "changeset",
|
|
29
37
|
"version": "changeset version",
|
|
@@ -37,9 +45,10 @@
|
|
|
37
45
|
},
|
|
38
46
|
"devDependencies": {
|
|
39
47
|
"@changesets/cli": "^2.31.0",
|
|
48
|
+
"@changesets/get-release-plan": "^4.0.16",
|
|
40
49
|
"@types/bun": "^1.4.3",
|
|
41
50
|
"oxlint": "^1.88.0",
|
|
42
51
|
"typescript": "^7.0.2",
|
|
43
52
|
"vitepress": "^2.0.0-alpha.18"
|
|
44
53
|
}
|
|
45
|
-
}
|
|
54
|
+
}
|
package/src/AGENTS.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# src/
|
|
2
|
+
|
|
3
|
+
Thunderjar's source, in layers. Each layer imports only from the layers below it, and the lint enforces it: `scripts/layout.ts` lists the layers in order, and `bun run lint` fails an import that points upward or a file in a folder the layout doesn't know.
|
|
4
|
+
|
|
5
|
+
## Layers
|
|
6
|
+
|
|
7
|
+
```mermaid
|
|
8
|
+
flowchart TD
|
|
9
|
+
cli --> commands["cli/commands"]
|
|
10
|
+
commands --> images & running & measuring & store
|
|
11
|
+
images --> matrix
|
|
12
|
+
running --> matrix
|
|
13
|
+
measuring --> running & store & matrix
|
|
14
|
+
store --> matrix
|
|
15
|
+
matrix --> project & instruments
|
|
16
|
+
project --> instruments
|
|
17
|
+
instruments --> api
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The diagram shows the imports that exist today. What the lint allows is any layer lower in this order, lowest first:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
api → instruments → project → matrix → store → running → images → measuring → cli/commands → cli
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Every layer may also import `platform`, `adapters/docker` and `testing`.
|
|
27
|
+
|
|
28
|
+
## Folders
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
src/
|
|
32
|
+
index.ts the `thunderjar` package: re-exports api/
|
|
33
|
+
api/
|
|
34
|
+
instruments/
|
|
35
|
+
project/
|
|
36
|
+
matrix/
|
|
37
|
+
store/
|
|
38
|
+
running/
|
|
39
|
+
images/
|
|
40
|
+
measuring/
|
|
41
|
+
cli/
|
|
42
|
+
index.ts
|
|
43
|
+
program.ts
|
|
44
|
+
commands/
|
|
45
|
+
format.ts
|
|
46
|
+
<command>/
|
|
47
|
+
platform/
|
|
48
|
+
adapters/docker/
|
|
49
|
+
testing/
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- `index.ts`
|
|
53
|
+
- The package entry users import `thunderjar` from. It only re-exports `api/`.
|
|
54
|
+
- `api/`
|
|
55
|
+
- What users' configuration files import.
|
|
56
|
+
- `declarations.ts`: the `declareX()` functions. At runtime each returns its argument; the types give `config` and `ctx` their shape.
|
|
57
|
+
- `fragments.ts`: ready-made Dockerfile fragments for `applyParameter`, such as `checkoutCommit`.
|
|
58
|
+
- `parameter-names.d.ts`: fallback types for parameter names. A real project sees its `_generated/` file instead.
|
|
59
|
+
- `instruments/`
|
|
60
|
+
- The built-in measuring instruments (`fileCreated`, `grep`, `templateTest`), and the aggregate methods that fit each one's values.
|
|
61
|
+
- `project/`
|
|
62
|
+
- `config.ts`: finds `.thunderjar/`, and loads and checks a test case and its measurements against the files on disk. Configuration errors come from here.
|
|
63
|
+
- `matrix/`
|
|
64
|
+
- Working out what to run. Nothing here runs anything.
|
|
65
|
+
- `permutations.ts`: a test case's cross product, in a stable order.
|
|
66
|
+
- `hashing.ts`: parameter and permutation hashes, from content, never names.
|
|
67
|
+
- `resolve.ts`: every permutation of a test case, with its hash.
|
|
68
|
+
- `execution.ts`: one execution laid out, with each container run's tags and harness command.
|
|
69
|
+
- `tags.ts`: prerun and postrun image tags. `ulid.ts`: execution IDs.
|
|
70
|
+
- `store/`
|
|
71
|
+
- `run-data-store.ts`: where executions, container runs and aggregation results are kept. It's an interface; for now the only store is a JSON file.
|
|
72
|
+
- `running/`
|
|
73
|
+
- `runner.ts`: runs one container run in a container from its prerun image, committing it into its postrun image, and opens the measurement context that instruments read a run through. A host runner stands in for Docker in tests of `run`.
|
|
74
|
+
- `images/`
|
|
75
|
+
- `prerun.ts`: renders a permutation's prerun Dockerfile from its parameters' fragments, checks the commits it reads, and writes its build context.
|
|
76
|
+
- `prerun-builds.ts`: builds a test case's missing prerun images, for both `build` and `run`, after checking everything the builds need.
|
|
77
|
+
- `repository.ts`: read-only access to the user's git repository.
|
|
78
|
+
- `measuring/`
|
|
79
|
+
- `measure.ts`: applies a task's measurements to one container run.
|
|
80
|
+
- `aggregate.ts`: combines a permutation's iterations into its aggregation result.
|
|
81
|
+
- `remeasure.ts`: for a backfill, whether a stored measure is missing, stale or current, which measurements each container run needs, and each execution's measurement statuses and deleted names.
|
|
82
|
+
- `remeasure-templates/`: two versions of a bundled template, static test data for `remeasure.test.ts`.
|
|
83
|
+
- `cli/commands/`
|
|
84
|
+
- One folder per command, holding the command and its tests, e.g.
|
|
85
|
+
```
|
|
86
|
+
cli/commands/build/
|
|
87
|
+
build.ts
|
|
88
|
+
build.test.ts
|
|
89
|
+
build.docker.test.ts
|
|
90
|
+
```
|
|
91
|
+
- `<command>.ts` exports the command's function, e.g. `build()`, and `createBuildCommand()`, which declares its arguments and options as a Commander `Command` and calls that function.
|
|
92
|
+
- A command's tests drive the CLI against a checked-in `fixtureTests/` scenario, unchanged, and assert only on what the command produced. Every expected result lives in the test. See "Fixture tests" in the root `AGENTS.md`.
|
|
93
|
+
- `format.ts`: how commands print what they share, such as counts and durations.
|
|
94
|
+
- Tests that cut across commands sit directly in `cli/commands/`: configuration errors (`validation.test.ts`) and typed parameter names (`declarations.typecheck.test.ts`).
|
|
95
|
+
- `cli/`
|
|
96
|
+
- `program.ts`: `createProgram()`, the Commander program, which adds every command's `createXCommand()`.
|
|
97
|
+
- `index.ts`: the `thunderjar` entry point. It parses the arguments with `createProgram()`.
|
|
98
|
+
- `platform/`
|
|
99
|
+
- Stands in for `node:path`, `node:fs` and `node:os`, which this repository doesn't use (see "Use Bun" in the root `AGENTS.md`). It imports nothing else from `src/`.
|
|
100
|
+
- `path.ts`: POSIX path functions, such as `join`, `resolve` and `relative`.
|
|
101
|
+
- `fs.ts`: file helpers on Bun's APIs and shell, such as `makeTempDir`, `remove` and `listEntries`. To read or write a file, use `Bun.file` and `Bun.write` directly.
|
|
102
|
+
- `adapters/docker/`
|
|
103
|
+
- The real Docker, behind the seam tests swap out. It imports nothing else from `src/` but `platform/`.
|
|
104
|
+
- `testing/`
|
|
105
|
+
- Test helpers, shared by tests in every layer.
|
|
106
|
+
- `cli.ts`: runs the CLI in a `fixtureTests/` scenario, the way `bun run fixture` does. `scenario()` names a scenario's project; `copyOfFixtures()` copies it for `init` only, the one command that writes into the project; a real `run` passes `--data-dir` instead.
|
|
107
|
+
- `fake-docker.ts`: the recording fake Docker, so a test can say which images exist, and which postrun images' saved files `docker create` and `docker cp` read. It never runs a container.
|
|
108
|
+
- `measurement-context.ts`: the host measurement context, for instrument tests.
|
|
109
|
+
- `test-setup.ts`, which every `bun test` preloads.
|
|
110
|
+
- NOT ENDORSED: `fixture-project.ts` and `seed-repo.ts` generate throwaway projects. Older tests still use them while they move to scenarios; new tests don't.
|
|
111
|
+
|
|
112
|
+
## Rules
|
|
113
|
+
|
|
114
|
+
### Never import upward
|
|
115
|
+
|
|
116
|
+
✅ **Do** import only from a layer below, or from `platform`, `adapters/docker` or `testing`.
|
|
117
|
+
|
|
118
|
+
❌ **Don't** import from a layer above, even for one function. Move the function down instead.
|
|
119
|
+
|
|
120
|
+
**Why:** the layers are what keep each part testable on its own, and readable without the parts above it. One upward import makes two layers depend on each other.
|
|
121
|
+
|
|
122
|
+
e.g. `config.ts` checks a measurement's aggregate methods. It used to import them from `measuring/aggregate.ts`, above it, so the list moved down into `instruments/`, which both use.
|
|
123
|
+
|
|
124
|
+
### Put a new module in the lowest layer that can hold it
|
|
125
|
+
|
|
126
|
+
✅ **Do** pick the layer by what the module needs to import: the lowest layer whose imports suffice.
|
|
127
|
+
|
|
128
|
+
❌ **Don't** put it in `cli/commands/` because a command is its first user.
|
|
129
|
+
|
|
130
|
+
**Why:** a module placed too high can't be used by the layers that will later need it, and the next person moves it.
|
|
131
|
+
|
|
132
|
+
### Keep tests beside their code
|
|
133
|
+
|
|
134
|
+
✅ **Do** put `<module>.test.ts` in the same folder as `<module>.ts`, and real-Docker tests in `<module>.docker.test.ts`.
|
|
135
|
+
|
|
136
|
+
❌ **Don't** gather tests in a separate tree.
|
|
137
|
+
|
|
138
|
+
**Why:** a module and its tests change together, and the folder shows at a glance what is tested.
|
|
139
|
+
|
|
140
|
+
A test of a lower layer that needs something from a higher one gets it through `testing/`, e.g. `instruments.test.ts` gets the host measurement context from `testing/measurement-context.ts`. That keeps `testing/` the one place where tests reach across layers.
|
|
141
|
+
|
|
142
|
+
### Share between commands through format.ts
|
|
143
|
+
|
|
144
|
+
✅ **Do** put anything two commands print alike in `cli/commands/format.ts`, or lower if it isn't about printing.
|
|
145
|
+
|
|
146
|
+
❌ **Don't** import one command from another in a command's own code.
|
|
147
|
+
|
|
148
|
+
**Why:** commands are siblings. If one imports another, changing how `run` prints breaks `show`.
|
|
149
|
+
|
|
150
|
+
The lint can't tell a command's code from its tests, so it allows command folders to import each other. A test shouldn't need to: one that needs, say, a recorded execution to `show` starts from a scenario with a checked-in run data store instead of running `run`.
|
|
151
|
+
|
|
152
|
+
### Adding a layer or a command
|
|
153
|
+
|
|
154
|
+
- A command: add a folder under `cli/commands/` whose `<command>.ts` exports `createXCommand()`, add its name to `COMMANDS` in `scripts/layout.ts`, and add the command to `createProgram()` in `cli/program.ts`.
|
|
155
|
+
- A layer: add it to `LAYERS` in `scripts/layout.ts`, in order, then update the diagram and this file.
|
|
156
|
+
- Then run `bun run lint`, which regenerates the rules in `.oxlintrc.json`.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// The Docker seam — spec/120-test-boundaries.md#principles. Everything Thunderjar asks of
|
|
2
|
+
// Docker goes through a `Docker`; the real one runs the `docker` CLI. Tests swap the binary,
|
|
3
|
+
// not the code path: `THUNDERJAR_DOCKER` names the executable to run in place of `docker`, such
|
|
4
|
+
// as fixtureTests/fake-docker.ts, which records the commands it is given
|
|
5
|
+
// (spec/120-test-boundaries.md#2-cli-with-recording-adapters).
|
|
6
|
+
//
|
|
7
|
+
// Thunderjar builds prerun images, runs a container run in a container from one and commits it
|
|
8
|
+
// into a postrun image, and copies files out of a postrun image to measure it.
|
|
9
|
+
|
|
10
|
+
export interface Docker {
|
|
11
|
+
/** Why the Docker daemon can't be reached, or undefined when it can. */
|
|
12
|
+
unreachable(): Promise<string | undefined>;
|
|
13
|
+
imageExists(ref: string): Promise<boolean>;
|
|
14
|
+
/**
|
|
15
|
+
* Builds the Dockerfile in `contextDir` and tags the image `ref`. Docker's own output goes to
|
|
16
|
+
* stderr. Throws if the build fails.
|
|
17
|
+
*/
|
|
18
|
+
build(contextDir: string, ref: string): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Runs `script` with `sh -c` in a new container named `name` from the image `ref`, and
|
|
21
|
+
* leaves the container once it stops. Returns its stdout; its stderr goes to stderr.
|
|
22
|
+
*/
|
|
23
|
+
run(ref: string, name: string, script: string): Promise<{ exitCode: number; stdout: string }>;
|
|
24
|
+
/**
|
|
25
|
+
* Commits the stopped container `name` into the image `ref`, with the entrypoint and command
|
|
26
|
+
* of the image `like` (the one it ran from), so starting it doesn't run `run`'s script again.
|
|
27
|
+
*/
|
|
28
|
+
commit(name: string, ref: string, like: string): Promise<void>;
|
|
29
|
+
/** Creates, without starting it, a container named `name` from the image `ref`. */
|
|
30
|
+
create(ref: string, name: string): Promise<void>;
|
|
31
|
+
/** Copies `path` out of the container `name` to `dest` on the host, as `docker cp` does. */
|
|
32
|
+
copyFrom(name: string, path: string, dest: string): Promise<void>;
|
|
33
|
+
/** Removes the container `name`, stopped or not. */
|
|
34
|
+
removeContainer(name: string): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class DockerBuildError extends Error {
|
|
38
|
+
constructor(
|
|
39
|
+
readonly ref: string,
|
|
40
|
+
readonly exitCode: number,
|
|
41
|
+
) {
|
|
42
|
+
super(`docker build of ${ref} exited ${exitCode}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A docker command that exited non-zero, with what it wrote to stderr. */
|
|
47
|
+
export class DockerCommandError extends Error {
|
|
48
|
+
constructor(
|
|
49
|
+
readonly args: string[],
|
|
50
|
+
readonly exitCode: number,
|
|
51
|
+
readonly stderr: string,
|
|
52
|
+
) {
|
|
53
|
+
super(`docker ${args[0]} exited ${exitCode}${stderr.trim() ? `: ${stderr.trim().split("\n").at(-1)}` : ""}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Runs `binary` with `args`. "collect" discards stdout and collects stderr; "stderr" passes all
|
|
59
|
+
* of its output to our stderr, keeping stdout for Thunderjar's own; "stdout" collects stdout and
|
|
60
|
+
* passes stderr to ours.
|
|
61
|
+
*/
|
|
62
|
+
async function docker(binary: string, args: string[], output: "collect" | "stderr" | "stdout") {
|
|
63
|
+
const stdio: Record<typeof output, { stdout: "ignore" | "pipe" | number; stderr: "pipe" | number }> = {
|
|
64
|
+
collect: { stdout: "ignore", stderr: "pipe" },
|
|
65
|
+
stderr: { stdout: 2, stderr: 2 },
|
|
66
|
+
stdout: { stdout: "pipe", stderr: 2 },
|
|
67
|
+
};
|
|
68
|
+
const child = Bun.spawn([binary, ...args], { stdin: "ignore", ...stdio[output] });
|
|
69
|
+
const text = (stream: unknown) => (stream instanceof ReadableStream ? new Response(stream).text() : Promise.resolve(""));
|
|
70
|
+
const [stdout, stderr] = await Promise.all([text(child.stdout), text(child.stderr), child.exited]);
|
|
71
|
+
return { exitCode: child.exitCode ?? 1, stdout, stderr };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A Docker that runs the `docker` CLI, or `binary` in its place. */
|
|
75
|
+
export function dockerCli(binary: string = process.env.THUNDERJAR_DOCKER || "docker"): Docker {
|
|
76
|
+
/** Runs a command whose output isn't needed, throwing if it fails. */
|
|
77
|
+
const must = async (args: string[]) => {
|
|
78
|
+
const { exitCode, stderr } = await docker(binary, args, "collect");
|
|
79
|
+
if (exitCode !== 0) throw new DockerCommandError(args, exitCode, stderr);
|
|
80
|
+
};
|
|
81
|
+
return {
|
|
82
|
+
async unreachable() {
|
|
83
|
+
try {
|
|
84
|
+
const { exitCode, stderr } = await docker(binary, ["version", "--format", "{{.Server.Version}}"], "collect");
|
|
85
|
+
if (exitCode === 0) return undefined;
|
|
86
|
+
return stderr.trim().split("\n").at(-1) || `\`${binary} version\` exited ${exitCode}`;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return `\`${binary}\` was not found`;
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
async imageExists(ref) {
|
|
93
|
+
return (await docker(binary, ["image", "inspect", "--format", "{{.Id}}", ref], "collect")).exitCode === 0;
|
|
94
|
+
},
|
|
95
|
+
async build(contextDir, ref) {
|
|
96
|
+
const { exitCode } = await docker(binary, ["build", "--tag", ref, contextDir], "stderr");
|
|
97
|
+
if (exitCode !== 0) throw new DockerBuildError(ref, exitCode);
|
|
98
|
+
},
|
|
99
|
+
async run(ref, name, script) {
|
|
100
|
+
// The image's own entrypoint isn't run: the script is all that runs.
|
|
101
|
+
const { exitCode, stdout } = await docker(binary, ["run", "--name", name, "--entrypoint", "sh", ref, "-c", script], "stdout");
|
|
102
|
+
return { exitCode, stdout };
|
|
103
|
+
},
|
|
104
|
+
async commit(name, ref, like) {
|
|
105
|
+
const format = "{{json .Config.Entrypoint}}\t{{json .Config.Cmd}}";
|
|
106
|
+
const inspected = await docker(binary, ["image", "inspect", "--format", format, like], "stdout");
|
|
107
|
+
if (inspected.exitCode !== 0) throw new DockerCommandError(["image", "inspect", like], inspected.exitCode, "");
|
|
108
|
+
const [entrypoint, cmd] = inspected.stdout.trim().split("\t");
|
|
109
|
+
// `ENTRYPOINT []` is ignored; `[""]` clears it.
|
|
110
|
+
const changes = [`ENTRYPOINT ${entrypoint === "null" ? '[""]' : entrypoint}`, `CMD ${cmd === "null" ? '["sh"]' : cmd}`];
|
|
111
|
+
await must(["commit", ...changes.flatMap((change) => ["--change", change]), name, ref]);
|
|
112
|
+
},
|
|
113
|
+
create: (ref, name) => must(["create", "--name", name, "--entrypoint", "sh", ref]),
|
|
114
|
+
copyFrom: (name, path, dest) => must(["cp", `${name}:${path}`, dest]),
|
|
115
|
+
removeContainer: (name) => must(["rm", "--force", name]),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// The `declareX()` functions a Thunderjar configuration file's default export is
|
|
2
|
+
// wrapped in — see spec/022-coding-conventions.md#declarex-functions-not-bare-exports.
|
|
3
|
+
// Each is the identity function on its argument; the point is the parameter type, which
|
|
4
|
+
// gives every `ctx` its shape with nothing written at the call site.
|
|
5
|
+
//
|
|
6
|
+
// `BaseImageName`, `CodeStateName`, `PromptSetName`, `TaskName`, `InitialPromptNames` and
|
|
7
|
+
// `HarnessModelName` are ambient globals with no import — see src/api/parameter-names.d.ts.
|
|
8
|
+
|
|
9
|
+
// Each declareX() marks what it returns with its own name, so the config loader can tell
|
|
10
|
+
// a wrapped default export from a bare one. Symbol.for, so the mark is recognised even
|
|
11
|
+
// when the user's project resolves a different copy of this module.
|
|
12
|
+
const DECLARED_BY = Symbol.for("thunderjar.declaredBy");
|
|
13
|
+
|
|
14
|
+
function declared<T extends object>(declareFn: string, value: T): T {
|
|
15
|
+
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
|
16
|
+
Object.defineProperty(value, DECLARED_BY, { value: declareFn, configurable: true });
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The name of the declareX() function `value` was returned from, if any. */
|
|
22
|
+
export function declaredBy(value: unknown): string | undefined {
|
|
23
|
+
if ((typeof value !== "object" || value === null) && typeof value !== "function") return undefined;
|
|
24
|
+
const mark = (value as { [DECLARED_BY]?: unknown })[DECLARED_BY];
|
|
25
|
+
return typeof mark === "string" ? mark : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// --- applyParameter: spec/060-test-case-parameters.md#declarations-and-applyparameter ---
|
|
29
|
+
//
|
|
30
|
+
// Each parameter's `applyParameter(config, ctx)` returns the Dockerfile fragment that applies
|
|
31
|
+
// it. `config` is the declaration's own `config`, as written (undefined if it has none); the
|
|
32
|
+
// declaration file is hashed, so it is part of the parameter hash.
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Puts things from the repository Thunderjar is run in into the build context, and returns
|
|
36
|
+
* the path to COPY them from. A commit named here must be in that repository: it is checked
|
|
37
|
+
* before anything is built.
|
|
38
|
+
*/
|
|
39
|
+
export interface GitBuildContext {
|
|
40
|
+
/** A git bundle holding `commit` and its ancestors, and nothing else, with one branch, `code-state`. */
|
|
41
|
+
bundle(commit: string): string;
|
|
42
|
+
/** The file at `path` in `commit`. */
|
|
43
|
+
show(commit: string, path: string): string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What every fragment gets. */
|
|
47
|
+
interface FragmentCtx {
|
|
48
|
+
resolvedHash?: string;
|
|
49
|
+
git: GitBuildContext;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What a fragment gets when its parameter is a folder: `dir` is that folder's path in the
|
|
54
|
+
* build context, so files bundled beside the declaration can be copied in —
|
|
55
|
+
* `COPY ${ctx.dir}/files/ /workspace/`. It names the parameter's role, not its folder, so
|
|
56
|
+
* renaming the folder changes nothing.
|
|
57
|
+
*/
|
|
58
|
+
export interface ApplyParameterCtx extends FragmentCtx {
|
|
59
|
+
dir: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// spec/060-test-case-parameters.md#controlling-the-parameter-hash
|
|
63
|
+
interface HashOverrides {
|
|
64
|
+
additionalHash?: () => string;
|
|
65
|
+
determineHash?: () => string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A parameter declaration's `config`, and the `applyParameter` that receives it. */
|
|
69
|
+
interface Applies<C, Ctx> {
|
|
70
|
+
config?: C;
|
|
71
|
+
applyParameter: (config: C, ctx: Ctx) => string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- Base image: spec/061-base-image.md ---
|
|
75
|
+
|
|
76
|
+
interface BaseImageDeclaration<C> extends HashOverrides, Applies<C, ApplyParameterCtx> {}
|
|
77
|
+
|
|
78
|
+
export function declareBaseImage<C = undefined>(declaration: BaseImageDeclaration<C>): BaseImageDeclaration<C> {
|
|
79
|
+
return declared("declareBaseImage", declaration);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- Code state: spec/062-code-state.md ---
|
|
83
|
+
|
|
84
|
+
interface CodeStateDeclaration<C> extends HashOverrides, Applies<C, ApplyParameterCtx> {}
|
|
85
|
+
|
|
86
|
+
export function declareCodeState<C = undefined>(declaration: CodeStateDeclaration<C>): CodeStateDeclaration<C> {
|
|
87
|
+
return declared("declareCodeState", declaration);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- Prompt set: spec/063-prompt-set.md ---
|
|
91
|
+
|
|
92
|
+
interface PromptSetDeclaration<C> extends HashOverrides, Applies<C, ApplyParameterCtx> {}
|
|
93
|
+
|
|
94
|
+
export function declarePromptSet<C = undefined>(declaration: PromptSetDeclaration<C>): PromptSetDeclaration<C> {
|
|
95
|
+
return declared("declarePromptSet", declaration);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- Harness/model pair: spec/064-harness.md ---
|
|
99
|
+
|
|
100
|
+
/** `dir` is the family folder, less models.ts, versions.ts and collectTokenCosts.ts. */
|
|
101
|
+
export interface HarnessApplyCtx extends ApplyParameterCtx {
|
|
102
|
+
version: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface HarnessCliCtx {
|
|
106
|
+
version: string;
|
|
107
|
+
modelId: string;
|
|
108
|
+
initialPrompt: string;
|
|
109
|
+
resultPath: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface HarnessExecutionOverrides {
|
|
113
|
+
/** Replaces the family's for this version. Gets the family's `config`, which versions.ts can't type. */
|
|
114
|
+
applyParameter?: (config: unknown, ctx: HarnessApplyCtx) => string;
|
|
115
|
+
cli?: (ctx: HarnessCliCtx) => string;
|
|
116
|
+
requiredEnv?: string[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface HarnessDeclaration<C = unknown> extends Applies<C, HarnessApplyCtx> {
|
|
120
|
+
cli: (ctx: HarnessCliCtx) => string;
|
|
121
|
+
requiredEnv?: string[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function declareHarness<C = undefined>(declaration: HarnessDeclaration<C>): HarnessDeclaration<C> {
|
|
125
|
+
return declared("declareHarness", declaration);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function declareHarnessModels(models: Record<string, string>): Record<string, string> {
|
|
129
|
+
return declared("declareHarnessModels", models);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type HarnessVersionEntry = ({ version: string } | { determineHash: () => string }) & HarnessExecutionOverrides;
|
|
133
|
+
|
|
134
|
+
export function declareHarnessVersions(
|
|
135
|
+
versions: Record<string, HarnessVersionEntry>,
|
|
136
|
+
): Record<string, HarnessVersionEntry> {
|
|
137
|
+
return declared("declareHarnessVersions", versions);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// spec/087-collecting-token-costs.md#normalising-collecttokencosts
|
|
141
|
+
interface CollectorContext {
|
|
142
|
+
version: string;
|
|
143
|
+
modelId: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface ModelTokenCosts {
|
|
147
|
+
model: string;
|
|
148
|
+
inputTokens: number;
|
|
149
|
+
outputTokens: number;
|
|
150
|
+
cacheCreationTokens: number;
|
|
151
|
+
cacheReadTokens: number;
|
|
152
|
+
costUsd?: number;
|
|
153
|
+
costBasis?: "list" | "managed" | "unknown";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
type TokenCosts =
|
|
157
|
+
| { outcome: "collected"; models: ModelTokenCosts[]; apiDurationMs?: number }
|
|
158
|
+
| { outcome: "unavailable"; reason: "absent" | "unparseable" | "notReported" };
|
|
159
|
+
|
|
160
|
+
type TokenCostCollector = (raw: string, ctx: CollectorContext) => TokenCosts;
|
|
161
|
+
|
|
162
|
+
export function declareTokenCostCollector(collector: TokenCostCollector): TokenCostCollector {
|
|
163
|
+
return declared("declareTokenCostCollector", collector);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --- Initial prompt: spec/066-task.md#initial-prompts ---
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* An initial prompt is one file, not a folder, so its fragment has no `ctx.dir`: nothing beside
|
|
170
|
+
* it is part of its hash.
|
|
171
|
+
*/
|
|
172
|
+
export interface InitialPromptDeclaration<C = unknown> extends HashOverrides, Applies<C, FragmentCtx> {
|
|
173
|
+
prompt: string;
|
|
174
|
+
timeoutSeconds: number;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function declareInitialPrompt<C = undefined>(declaration: InitialPromptDeclaration<C>): InitialPromptDeclaration<C> {
|
|
178
|
+
return declared("declareInitialPrompt", declaration);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- Measurement: spec/060-test-case-parameters.md#measuring-instruments--measurements ---
|
|
182
|
+
|
|
183
|
+
/** What an instrument's `evaluate` gives for one measurement of one container run. */
|
|
184
|
+
export type MeasurementResult<T> =
|
|
185
|
+
| { outcome: "measured"; data: T }
|
|
186
|
+
| { outcome: "skipped"; reason?: string } // e.g. no output existed to check; `show` prints the reason
|
|
187
|
+
| { outcome: "erroredWhileMeasuring"; error: Error }; // the instrument itself threw
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* What an instrument runs against: the postrun state of one container run —
|
|
191
|
+
* spec/060-test-case-parameters.md#measurement-context. Relative paths and glob patterns
|
|
192
|
+
* resolve in `workdir`.
|
|
193
|
+
*/
|
|
194
|
+
export interface MeasurementContext {
|
|
195
|
+
exec(command: string): Promise<{ stdout: string; stderr: string; exitCode: number }>;
|
|
196
|
+
readFile(path: string): Promise<string>;
|
|
197
|
+
writeFile(path: string, contents: string): Promise<void>;
|
|
198
|
+
/** Files matching `pattern`, relative to `workdir`, sorted. */
|
|
199
|
+
glob(pattern: string): Promise<string[]>;
|
|
200
|
+
/** Raw contents of the file the harness's `cli` wrote. */
|
|
201
|
+
harnessResult: string;
|
|
202
|
+
workdir: string;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The built-in instruments' config and value types, by name. src/instruments/instruments.ts implements
|
|
207
|
+
* each against exactly these types.
|
|
208
|
+
*/
|
|
209
|
+
export interface InstrumentTypes {
|
|
210
|
+
/** Did the agent leave a file matching `glob`? */
|
|
211
|
+
fileCreated: { config: { glob: string }; value: boolean };
|
|
212
|
+
/** How many lines, across the files matching `glob`, match `pattern`, a JavaScript regex? */
|
|
213
|
+
grep: { config: { glob: string; pattern: string }; value: number };
|
|
214
|
+
/**
|
|
215
|
+
* How many tests pass and fail when `template`, a path relative to the measurement file, is
|
|
216
|
+
* rendered against each file matching `subject` and run with `bun test`?
|
|
217
|
+
*/
|
|
218
|
+
templateTest: { config: { subject: string; template: string }; value: { passed: number; failed: number } };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export type InstrumentName = keyof InstrumentTypes;
|
|
222
|
+
|
|
223
|
+
/** The ways a value type's measures can combine — spec/085-test-case-results.md#how-a-measures-values-combine. */
|
|
224
|
+
export type AggregateFor<T> = [T] extends [boolean]
|
|
225
|
+
? "rate" | "count"
|
|
226
|
+
: [T] extends [number]
|
|
227
|
+
? "mean" | "median" | "min" | "max" | "sum"
|
|
228
|
+
: [T] extends [Record<string, number>]
|
|
229
|
+
? "sum" | "mean"
|
|
230
|
+
: never;
|
|
231
|
+
|
|
232
|
+
/** An instrument plus its config, typed by the instrument: a `config` that doesn't fit it is a type error. */
|
|
233
|
+
export type MeasurementDeclaration = {
|
|
234
|
+
[I in InstrumentName]: {
|
|
235
|
+
instrument: I;
|
|
236
|
+
config: InstrumentTypes[I]["config"];
|
|
237
|
+
/** How this measurement's values combine across iterations — see src/measuring/aggregate.ts. */
|
|
238
|
+
aggregate: AggregateFor<InstrumentTypes[I]["value"]>[];
|
|
239
|
+
};
|
|
240
|
+
}[InstrumentName];
|
|
241
|
+
|
|
242
|
+
export function declareMeasurement(declaration: MeasurementDeclaration): MeasurementDeclaration {
|
|
243
|
+
return declared("declareMeasurement", declaration);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// --- Test case: spec/055-declaring-test-cases.md ---
|
|
247
|
+
|
|
248
|
+
export interface TestCaseDeclaration<T extends TaskName = TaskName> {
|
|
249
|
+
baseImage: BaseImageName[];
|
|
250
|
+
codeState: CodeStateName[];
|
|
251
|
+
promptSet: PromptSetName[];
|
|
252
|
+
harnessModel: HarnessModelName[];
|
|
253
|
+
task: T;
|
|
254
|
+
initialPrompt: Array<T extends keyof InitialPromptNames ? InitialPromptNames[T] : never>;
|
|
255
|
+
iterations: number;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function declareTestCase<T extends TaskName>(declaration: TestCaseDeclaration<T>): TestCaseDeclaration<T> {
|
|
259
|
+
return declared("declareTestCase", declaration);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// --- Global config: spec/050-setup-and-configure.md#thunderjarconfigts ---
|
|
263
|
+
|
|
264
|
+
interface SqliteStoreOptions {
|
|
265
|
+
path: string;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function sqliteStore(options: SqliteStoreOptions): { kind: "sqlite" } & SqliteStoreOptions {
|
|
269
|
+
return { kind: "sqlite", ...options };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
interface ThunderjarConfigDeclaration {
|
|
273
|
+
imageStore: { repository: string };
|
|
274
|
+
runDataStore: ReturnType<typeof sqliteStore>;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function declareConfig(config: ThunderjarConfigDeclaration): ThunderjarConfigDeclaration {
|
|
278
|
+
return declared("declareConfig", config);
|
|
279
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Dockerfile fragments a parameter's `applyParameter` can use, exported from `thunderjar`.
|
|
2
|
+
|
|
3
|
+
import type { GitBuildContext } from "./declarations.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Checks `commit` out into /workspace as a real repository — spec/062-code-state.md#how-the-code-gets-into-the-image:
|
|
7
|
+
* cloned from a bundle of the commit and its ancestors only (so `git log --all` shows the agent
|
|
8
|
+
* nothing after it), detached, with no remote, and a git identity so the agent can commit.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* export default declareCodeState({
|
|
12
|
+
* config: { commit: "a1b2c3" },
|
|
13
|
+
* applyParameter: (config, ctx) => `${checkoutCommit(ctx, config.commit)}\nRUN npm ci`,
|
|
14
|
+
* });
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export function checkoutCommit(ctx: { git: GitBuildContext }, commit: string): string {
|
|
18
|
+
return [
|
|
19
|
+
`COPY ${ctx.git.bundle(commit)} /tmp/code.bundle`,
|
|
20
|
+
[
|
|
21
|
+
"RUN git clone --quiet /tmp/code.bundle /workspace",
|
|
22
|
+
`git -C /workspace checkout --quiet --detach ${commit}`,
|
|
23
|
+
"git -C /workspace branch --quiet --delete --force code-state",
|
|
24
|
+
"git -C /workspace remote remove origin",
|
|
25
|
+
`git -C /workspace config user.name "Thunderjar agent"`,
|
|
26
|
+
"git -C /workspace config user.email agent@thunderjar.invalid",
|
|
27
|
+
"rm /tmp/code.bundle",
|
|
28
|
+
].join(" \\\n && "),
|
|
29
|
+
].join("\n");
|
|
30
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Fallback ambient types for the parameter names a test case selects.
|
|
2
|
+
//
|
|
3
|
+
// `thunderjar generate` writes a project-specific `_generated/parameter-names.d.ts`
|
|
4
|
+
// declaring these same global names as literal unions (see
|
|
5
|
+
// spec/055-declaring-test-cases.md#typed-names). A real project's own TypeScript
|
|
6
|
+
// program sees only that generated file, never this one — this file exists solely so
|
|
7
|
+
// that this package's own declarations (which reference these names directly, with no
|
|
8
|
+
// import) type-check on their own, and so a program that hasn't run `generate` yet
|
|
9
|
+
// still compiles, permissively.
|
|
10
|
+
|
|
11
|
+
type BaseImageName = string;
|
|
12
|
+
type CodeStateName = string;
|
|
13
|
+
type PromptSetName = string;
|
|
14
|
+
type TaskName = string;
|
|
15
|
+
type InitialPromptNames = Record<string, string>;
|
|
16
|
+
type HarnessModelName = string;
|