diff --git a/.gitignore b/.gitignore index fbf4e4a..a72145c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ lib/ coverage/ *.tsbuildinfo .DS_Store -.wireit/ \ No newline at end of file +.wireit/ +.codegraph/ diff --git a/README.md b/README.md index 785c6a2..8baf8b1 100644 --- a/README.md +++ b/README.md @@ -241,40 +241,45 @@ Docker Desktop applies this to both `docker build` and `docker run`, so nothing ### Extending with toolchains -Install packages in a child image, then point tests at that tag: +Install packages in a child image that derives from the agent image, register a named **variant** in `globalSetup`, then select it from `agent()`: ```dockerfile # docker/agent.Dockerfile -FROM agent-gwt/cursor-cli:local -# or: FROM agent-gwt/claude-code:local +ARG AGENT_IMAGE=agent-gwt/cursor-cli:local +FROM ${AGENT_IMAGE} +# or default: agent-gwt/claude-code:local -# Official Arch packages (as root) -RUN pacman -Sy --noconfirm --needed nodejs npm python rust \ - && pacman -Scc --noconfirm - -# AUR packages (build-time only — yay refuses root) +# Prefer yay for everything (it wraps pacman) so official + AUR deps share one layer. +# yay refuses root — switch to the aur user for the install. USER aur -RUN yay -S --noconfirm --needed some-aur-package +RUN yay -S --noconfirm --needed nodejs npm python rust some-aur-package USER root ``` ```ts // vitest.global-setup.ts -import { buildAgentImage, buildDockerImage } from "agent-gwt"; +import { buildToolchainImage } from "agent-gwt"; export default async function setup() { - await buildAgentImage("cursor"); - await buildDockerImage("my-app/agent:local", { + await buildToolchainImage("node18", { + agent: "cursor", dockerfileRelative: "docker/agent.Dockerfile", - packageRoot: process.cwd(), }); } ``` ```ts -agent({ name: "cursor", image: "my-app/agent:local", model: "auto" }); +agent({ name: "cursor", variant: "node18", model: "auto" }); +// omit variant → stock agent-gwt/cursor-cli:local ``` +`buildToolchainImage` builds the agent image first, passes `--build-arg AGENT_IMAGE=…`, tags a per-repo image (`agent-gwt/toolchain--:`), and registers the variant under `/tmp/.agents-gwt/toolchains//` (one file per variant, so parallel registration is safe). The digest covers **Dockerfile bytes + parent image ID**; `docker build` is always run (daemon cache applies) so `COPY`/`ADD` context changes are picked up on the next `buildToolchainImage` call. `packageRoot` defaults to `process.cwd()` and must match the cwd used when resolving `agent({ variant })`. `image` remains a low-level override and is mutually exclusive with `variant`. + +Caveats: + +- Vitest watch does not re-run `globalSetup` — restart after Dockerfile or parent-image changes, or the variant still points at the previous tag until you rebuild. +- Prefer `FROM ${AGENT_IMAGE}` (or a literal `FROM` matching the agent) so `agent: "claude"` cannot silently wrap a Cursor base. Validation checks the **first** `FROM` only (single-stage Dockerfiles). + The base uses Arch/`pacman` (glibc). Alpine will not run the Cursor CLI. ## What `agent` does @@ -282,8 +287,8 @@ The base uses Arch/`pacman` (glibc). Alpine will not run the Cursor CLI. Suite-level `withAspect` **before** hook that: 1. Resolves `name` via the agents registry and sets `this.agent` -2. Sets `this.model` when provided; sets `this.image` from `options.image` or the resolved agent -3. Asserts that Docker image already exists (`docker image inspect`) — it does **not** build. Build once in `globalSetup` with `buildAgentImage(...)` so parallel test files do not race +2. Sets `this.model` when provided; sets `this.image` from `options.image`, a registered `options.variant`, or the resolved agent +3. Asserts that Docker image already exists (`docker image inspect`) — it does **not** build. Build once in `globalSetup` with `buildAgentImage(...)` / `buildToolchainImage(...)` so parallel test files do not race Pair workspace lifecycle separately: `withAspect(a_workspace, cleanup_workspace)`. @@ -300,10 +305,11 @@ Pair workspace lifecycle separately: `withAspect(a_workspace, cleanup_workspace) | Export | Role | | -------------------------------------------------- | ------------------------------------------------------------------------------------------- | | `AgentContext` | Extensible context type (`workspace`, `prompt`, `agent`, `image`, …) | -| `agent(opts)` | `withAspect` before — `{ name: "cursor" \| "claude", model?, image? }` | +| `agent(opts)` | `withAspect` before — `{ name: "cursor" \| "claude", model?, variant?, image? }` | | `buildAgentImage(name)` | Suite setup — builds base + agent image (use in vitest `globalSetup`) | +| `buildToolchainImage(variant, opts)` | Suite setup — builds a per-repo toolchain layer and registers `variant` for `agent()` | | `buildBaseImage()` | Builds `agent-gwt/base:local` only | -| `buildDockerImage(...)` | Builds an arbitrary Dockerfile (e.g. toolchain overlay) | +| `buildDockerImage(...)` | Builds an arbitrary Dockerfile (low-level; prefer `buildToolchainImage` for toolchains) | | `a_workspace` | Creates `/tmp/.agents-gwt/ws-*` (use in `withAspect` before, or in `given`) | | `copy_to_workspace(workspace, globs, options?)` | Copy glob-matched files into `workspace` from the current spec directory (`from`, `base`) | | `cleanup_workspace` | Remove the temp workspace (use in `withAspect` after) | diff --git a/src/agents/build-agent-image.spec.ts b/src/agents/build-agent-image.spec.ts index 86ea2cc..1bd6818 100644 --- a/src/agents/build-agent-image.spec.ts +++ b/src/agents/build-agent-image.spec.ts @@ -102,6 +102,38 @@ describe("buildDockerImage", () => { error_mentions_failed_build, }, }); + + test("force rebuilds even when the image already exists", { + given: { + reset_memo, + image_name, + package_with_dockerfile, + inspect_succeeds_but_force_still_builds, + }, + when: { + building_image_with_force, + }, + then: { + build_was_called, + build_uses_plain_progress_and_streams_output, + }, + }); + + test("passes build args through to docker build", { + given: { + reset_memo, + image_name, + package_with_dockerfile, + inspect_fails_then_build_succeeds, + }, + when: { + building_image_with_build_args, + }, + then: { + build_was_called, + build_includes_build_args, + }, + }); }); describe("buildBaseImage", () => { @@ -238,6 +270,24 @@ function inspect_fails_then_build_fails(this: BuildContext) { }; } +function inspect_succeeds_but_force_still_builds(this: BuildContext) { + this.dockerRunner = async (args, options) => { + if (args[0] === "image" && args[1] === "inspect") { + this.inspectCalls += 1; + return { exitCode: 0, stdout: "[]", stderr: "" }; + } + if (args[0] === "build") { + this.buildCalls += 1; + this.lastBuildArgs = args; + if (options !== undefined) { + this.lastBuildOptions = options; + } + return { exitCode: 0, stdout: "done", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; +} + async function building_image(this: BuildContext) { await buildDockerImage(this.image, { dockerfileRelative: this.dockerfileRelative, @@ -246,6 +296,24 @@ async function building_image(this: BuildContext) { }); } +async function building_image_with_force(this: BuildContext) { + await buildDockerImage(this.image, { + dockerfileRelative: this.dockerfileRelative, + packageRoot: this.packageRoot, + dockerRunner: this.dockerRunner, + force: true, + }); +} + +async function building_image_with_build_args(this: BuildContext) { + await buildDockerImage(this.image, { + dockerfileRelative: this.dockerfileRelative, + packageRoot: this.packageRoot, + dockerRunner: this.dockerRunner, + buildArgs: { AGENT_IMAGE: "agent-gwt/cursor-cli:local" }, + }); +} + async function building_image_twice(this: BuildContext) { await building_image.call(this); await building_image.call(this); @@ -280,6 +348,20 @@ function build_uses_plain_progress_and_streams_output(this: BuildContext) { expect(this.lastBuildOptions).toEqual({ inheritOutput: true }); } +function build_includes_build_args(this: BuildContext) { + expect(this.lastBuildArgs).toEqual([ + "build", + "--progress=plain", + "-t", + this.image, + "--build-arg", + "AGENT_IMAGE=agent-gwt/cursor-cli:local", + "-f", + join(this.packageRoot, this.dockerfileRelative), + this.packageRoot, + ]); +} + function error_mentions_failed_build(this: BuildContext) { expect(this.error?.message).toContain("Failed to build image"); expect(this.error?.message).toContain("build boom"); diff --git a/src/agents/build-agent-image.ts b/src/agents/build-agent-image.ts index cf2fbc1..fb64388 100644 --- a/src/agents/build-agent-image.ts +++ b/src/agents/build-agent-image.ts @@ -20,19 +20,22 @@ export async function buildDockerImage( ): Promise { const dockerRunner = options.dockerRunner ?? runDocker; const { packageRoot } = options; - const memoKey = `${image}::${options.dockerfileRelative}::${packageRoot}`; + const force = options.force === true; + const buildArgs = options.buildArgs ?? {}; + const memoKey = `${image}::${options.dockerfileRelative}::${packageRoot}::${force}::${JSON.stringify(buildArgs)}`; const existing = builtImages.get(memoKey); if (existing !== undefined) { return existing; } - const pending = doBuild(image, options.dockerfileRelative, dockerRunner, packageRoot).catch( - (error: unknown) => { - builtImages.delete(memoKey); - throw error; - }, - ); + const pending = doBuild(image, options.dockerfileRelative, dockerRunner, packageRoot, { + force, + buildArgs, + }).catch((error: unknown) => { + builtImages.delete(memoKey); + throw error; + }); builtImages.set(memoKey, pending); return pending; @@ -60,11 +63,14 @@ async function doBuild( dockerfileRelative: string, dockerRunner: DockerRunner, packageRoot: string, + options: { force: boolean; buildArgs: Record }, ): Promise { - const inspect = await dockerRunner(["image", "inspect", image]); - if (inspect.exitCode === 0) { - process.stderr.write(`[agent-gwt] Docker image ${image} already present\n`); - return; + if (!options.force) { + const inspect = await dockerRunner(["image", "inspect", image]); + if (inspect.exitCode === 0) { + process.stderr.write(`[agent-gwt] Docker image ${image} already present\n`); + return; + } } const dockerfile = join(packageRoot, dockerfileRelative); @@ -74,8 +80,13 @@ async function doBuild( process.stderr.write(`[agent-gwt] Building Docker image ${image}...\n`); + const buildArgs: string[] = []; + for (const [key, value] of Object.entries(options.buildArgs)) { + buildArgs.push("--build-arg", `${key}=${value}`); + } + const build = await dockerRunner( - ["build", "--progress=plain", "-t", image, "-f", dockerfile, packageRoot], + ["build", "--progress=plain", "-t", image, ...buildArgs, "-f", dockerfile, packageRoot], { inheritOutput: true }, ); diff --git a/src/agents/build-toolchain-image.spec.ts b/src/agents/build-toolchain-image.spec.ts new file mode 100644 index 0000000..abbc477 --- /dev/null +++ b/src/agents/build-toolchain-image.spec.ts @@ -0,0 +1,536 @@ +import { afterEach, describe, expect, vi } from "vitest"; +import test, { withAspect } from "vitest-gwt"; +import { createHash } from "node:crypto"; +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; + +import * as buildAgentImageModule from "./build-agent-image.js"; +import { + buildToolchainImage, + clearToolchainImageMemory, + resetToolchainImages, + resolveToolchainImage, + type BuildToolchainImageOptions, +} from "./build-toolchain-image.js"; +import { resetBuiltImages } from "./build-agent-image.js"; +import type { DockerRunOptions, DockerRunner } from "./types.js"; + +type Context = { + variant: string; + packageRoot: string; + dockerfileRelative: string; + dockerfileContents: string; + parentImageId: string; + dockerRunner: DockerRunner | undefined; + inspectCalls: number; + buildCalls: number; + lastInspectArgs: string[] | undefined; + lastBuildArgs: string[] | undefined; + lastBuildOptions: DockerRunOptions | undefined; + error: Error | undefined; + agentBuildCalls: number; + firstImage: string | undefined; + secondImage: string | undefined; + tempRoots: string[]; +}; + +describe("buildToolchainImage", () => { + withAspect(reset_toolchain_state, cleanup_temp_roots); + + test("builds a content-hashed tag and registers the variant", { + given: { + variant_name, + package_with_dockerfile, + stub_agent_build, + inspect_parent_then_force_build, + }, + when: { + building_toolchain, + }, + then: { + agent_image_was_built, + parent_image_was_inspected, + build_was_called, + build_targeted_hashed_image_with_agent_arg, + variant_is_registered, + }, + }); + + test("force-rebuilds even when the hashed image already exists", { + given: { + variant_name, + package_with_dockerfile, + stub_agent_build, + parent_present_and_target_present_still_builds, + }, + when: { + building_toolchain, + }, + then: { + build_was_called, + variant_is_registered, + }, + }); + + test("memoizes the docker build so a second call does not rebuild", { + given: { + variant_name, + package_with_dockerfile, + stub_agent_build, + inspect_parent_then_force_build, + }, + when: { + building_toolchain_twice, + }, + then: { + build_called_once, + }, + }); + + test("uses a new tag when the Dockerfile content changes", { + given: { + variant_name, + package_with_dockerfile, + stub_agent_build, + inspect_parent_then_force_build, + }, + when: { + building_then_changing_dockerfile_and_rebuilding, + }, + then: { + rebuilt_with_new_content_digest, + }, + }); + + test("uses a new tag when the parent image id changes", { + given: { + variant_name, + package_with_dockerfile, + stub_agent_build, + inspect_parent_then_force_build, + }, + when: { + building_then_changing_parent_id_and_rebuilding, + }, + then: { + rebuilt_with_new_parent_digest, + }, + }); + + test("scopes tags by package root so repos do not collide", { + given: { + variant_name, + package_with_dockerfile, + stub_agent_build, + inspect_parent_then_force_build, + }, + when: { + building_same_dockerfile_in_two_roots, + }, + then: { + tags_differ_by_repo_digest, + }, + }); + + test("surfaces a clear error when the Dockerfile is missing", { + given: { + variant_name, + package_without_dockerfile, + stub_agent_build, + }, + when: { + building_toolchain_catching_error, + }, + then: { + error_mentions_missing_dockerfile, + }, + }); + + test("rejects a Dockerfile FROM that does not match the agent image", { + given: { + variant_name, + package_with_mismatched_from, + stub_agent_build, + }, + when: { + building_toolchain_catching_error, + }, + then: { + error_mentions_from_mismatch, + }, + }); + + test("resolves a variant from the persisted registry after memory is cleared", { + given: { + variant_name, + package_with_dockerfile, + stub_agent_build, + inspect_parent_then_force_build, + }, + when: { + building_then_clearing_memory_and_resolving, + }, + then: { + variant_resolved_from_disk, + }, + }); + + test("keeps distinct registry entries for variants that sanitize to the same name", { + given: { + variant_name, + package_with_dockerfile, + stub_agent_build, + inspect_parent_then_force_build, + }, + when: { + building_colliding_sanitized_variant_names, + }, + then: { + colliding_variants_resolve_independently, + }, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function reset_toolchain_state(this: Context) { + const priorRoots = this.tempRoots ?? []; + resetToolchainImages(); + for (const root of priorRoots) { + resetToolchainImages({ packageRoot: root }); + } + resetBuiltImages(); + this.tempRoots = []; + this.inspectCalls = 0; + this.buildCalls = 0; + this.agentBuildCalls = 0; + this.error = undefined; + this.firstImage = undefined; + this.secondImage = undefined; + this.lastInspectArgs = undefined; + this.lastBuildArgs = undefined; + this.lastBuildOptions = undefined; + this.parentImageId = "sha256:parent-image-id-1"; +} + +async function cleanup_temp_roots(this: Context) { + const roots = [...(this.tempRoots ?? [])]; + resetToolchainImages(); + for (const root of roots) { + resetToolchainImages({ packageRoot: root }); + } + resetBuiltImages(); + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))); + this.tempRoots = []; +} + +function variant_name(this: Context) { + this.variant = "node18"; + this.dockerfileRelative = join("docker", "agent.Dockerfile"); + this.dockerfileContents = "FROM agent-gwt/cursor-cli:local\n"; +} + +async function package_with_dockerfile(this: Context) { + this.packageRoot = await mkdtemp(join(tmpdir(), "agent-gwt-tc-")); + this.tempRoots.push(this.packageRoot); + await mkdir(join(this.packageRoot, "docker"), { recursive: true }); + await writeFile(join(this.packageRoot, this.dockerfileRelative), this.dockerfileContents); +} + +async function package_with_mismatched_from(this: Context) { + this.dockerfileContents = "FROM agent-gwt/claude-code:local\n"; + await package_with_dockerfile.call(this); +} + +async function package_without_dockerfile(this: Context) { + this.packageRoot = await mkdtemp(join(tmpdir(), "agent-gwt-tc-missing-")); + this.tempRoots.push(this.packageRoot); + this.dockerfileRelative = join("docker", "missing.Dockerfile"); +} + +function stub_agent_build(this: Context) { + vi.spyOn(buildAgentImageModule, "buildAgentImage").mockImplementation(async () => { + this.agentBuildCalls += 1; + }); +} + +function inspect_parent_then_force_build(this: Context) { + this.dockerRunner = async (args, options) => { + if (args[0] === "image" && args[1] === "inspect") { + this.inspectCalls += 1; + this.lastInspectArgs = args; + if (args.includes("--format")) { + return { exitCode: 0, stdout: `${this.parentImageId}\n`, stderr: "" }; + } + return { exitCode: 1, stdout: "", stderr: "No such image" }; + } + if (args[0] === "build") { + this.buildCalls += 1; + this.lastBuildArgs = args; + if (options !== undefined) { + this.lastBuildOptions = options; + } + return { exitCode: 0, stdout: "done", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; +} + +function parent_present_and_target_present_still_builds(this: Context) { + this.dockerRunner = async (args, options) => { + if (args[0] === "image" && args[1] === "inspect") { + this.inspectCalls += 1; + this.lastInspectArgs = args; + if (args.includes("--format")) { + return { exitCode: 0, stdout: `${this.parentImageId}\n`, stderr: "" }; + } + // Target tag exists — force should still build. + return { exitCode: 0, stdout: "[]", stderr: "" }; + } + if (args[0] === "build") { + this.buildCalls += 1; + this.lastBuildArgs = args; + if (options !== undefined) { + this.lastBuildOptions = options; + } + return { exitCode: 0, stdout: "done", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; +} + +async function building_toolchain(this: Context) { + const options: BuildToolchainImageOptions = { + agent: "cursor", + dockerfileRelative: this.dockerfileRelative, + packageRoot: this.packageRoot, + }; + if (this.dockerRunner !== undefined) { + options.dockerRunner = this.dockerRunner; + } + await buildToolchainImage(this.variant, options); +} + +async function building_toolchain_twice(this: Context) { + await building_toolchain.call(this); + await building_toolchain.call(this); +} + +async function building_toolchain_catching_error(this: Context) { + try { + const options: BuildToolchainImageOptions = { + agent: "cursor", + dockerfileRelative: this.dockerfileRelative, + packageRoot: this.packageRoot, + }; + if (this.dockerRunner !== undefined) { + options.dockerRunner = this.dockerRunner; + } + await buildToolchainImage(this.variant, options); + } catch (error) { + this.error = error as Error; + } +} + +async function building_then_changing_dockerfile_and_rebuilding(this: Context) { + await building_toolchain.call(this); + this.firstImage = resolveToolchainImage("cursor", this.variant, { + packageRoot: this.packageRoot, + }); + + this.dockerfileContents = "FROM agent-gwt/cursor-cli:local\nRUN echo changed\n"; + await writeFile(join(this.packageRoot, this.dockerfileRelative), this.dockerfileContents); + resetBuiltImages(); + this.buildCalls = 0; + this.inspectCalls = 0; + + await building_toolchain.call(this); + this.secondImage = resolveToolchainImage("cursor", this.variant, { + packageRoot: this.packageRoot, + }); +} + +async function building_then_changing_parent_id_and_rebuilding(this: Context) { + await building_toolchain.call(this); + this.firstImage = resolveToolchainImage("cursor", this.variant, { + packageRoot: this.packageRoot, + }); + + this.parentImageId = "sha256:parent-image-id-2"; + resetBuiltImages(); + this.buildCalls = 0; + this.inspectCalls = 0; + + await building_toolchain.call(this); + this.secondImage = resolveToolchainImage("cursor", this.variant, { + packageRoot: this.packageRoot, + }); +} + +async function building_same_dockerfile_in_two_roots(this: Context) { + await building_toolchain.call(this); + this.firstImage = resolveToolchainImage("cursor", this.variant, { + packageRoot: this.packageRoot, + }); + + const secondRoot = await mkdtemp(join(tmpdir(), "agent-gwt-tc-other-")); + this.tempRoots.push(secondRoot); + await mkdir(join(secondRoot, "docker"), { recursive: true }); + await writeFile(join(secondRoot, this.dockerfileRelative), this.dockerfileContents); + + resetBuiltImages(); + this.packageRoot = secondRoot; + await building_toolchain.call(this); + this.secondImage = resolveToolchainImage("cursor", this.variant, { + packageRoot: this.packageRoot, + }); +} + +async function building_then_clearing_memory_and_resolving(this: Context) { + await building_toolchain.call(this); + this.firstImage = resolveToolchainImage("cursor", this.variant, { + packageRoot: this.packageRoot, + }); + clearToolchainImageMemory(); + this.secondImage = resolveToolchainImage("cursor", this.variant, { + packageRoot: this.packageRoot, + }); +} + +async function building_colliding_sanitized_variant_names(this: Context) { + this.variant = "node/18"; + await building_toolchain.call(this); + this.firstImage = resolveToolchainImage("cursor", "node/18", { + packageRoot: this.packageRoot, + }); + + this.variant = "node_18"; + await building_toolchain.call(this); + this.secondImage = resolveToolchainImage("cursor", "node_18", { + packageRoot: this.packageRoot, + }); +} + +function agent_image_was_built(this: Context) { + expect(this.agentBuildCalls).toBe(1); + expect(buildAgentImageModule.buildAgentImage).toHaveBeenCalledWith("cursor"); +} + +function parent_image_was_inspected(this: Context) { + expect(this.inspectCalls).toBeGreaterThanOrEqual(1); + expect(this.lastInspectArgs).toEqual([ + "image", + "inspect", + "--format", + "{{.Id}}", + "agent-gwt/cursor-cli:local", + ]); +} + +function build_was_called(this: Context) { + expect(this.buildCalls).toBe(1); +} + +function build_called_once(this: Context) { + expect(this.buildCalls).toBe(1); +} + +function build_targeted_hashed_image_with_agent_arg(this: Context) { + const expected = expectedImage(this.packageRoot, this.dockerfileContents, this.parentImageId); + expect(this.lastBuildArgs).toEqual([ + "build", + "--progress=plain", + "-t", + expected, + "--build-arg", + "AGENT_IMAGE=agent-gwt/cursor-cli:local", + "-f", + join(this.packageRoot, this.dockerfileRelative), + this.packageRoot, + ]); + expect(this.lastBuildOptions).toEqual({ inheritOutput: true }); +} + +function variant_is_registered(this: Context) { + expect(resolveToolchainImage("cursor", this.variant, { packageRoot: this.packageRoot })).toBe( + expectedImage(this.packageRoot, this.dockerfileContents, this.parentImageId), + ); +} + +function rebuilt_with_new_content_digest(this: Context) { + expect(this.firstImage).toBeDefined(); + expect(this.secondImage).toBeDefined(); + expect(this.firstImage).not.toBe(this.secondImage); + expect(tagOf(this.secondImage!)).toBe( + contentDigestOf(this.dockerfileContents, this.parentImageId), + ); + expect(this.buildCalls).toBe(1); +} + +function rebuilt_with_new_parent_digest(this: Context) { + expect(this.firstImage).toBeDefined(); + expect(this.secondImage).toBeDefined(); + expect(this.firstImage).not.toBe(this.secondImage); + expect(tagOf(this.secondImage!)).toBe( + contentDigestOf(this.dockerfileContents, this.parentImageId), + ); + expect(this.buildCalls).toBe(1); +} + +function tags_differ_by_repo_digest(this: Context) { + expect(this.firstImage).toBeDefined(); + expect(this.secondImage).toBeDefined(); + expect(this.firstImage).not.toBe(this.secondImage); + expect(repoDigestOf(this.firstImage!)).not.toBe(repoDigestOf(this.secondImage!)); + expect(tagOf(this.firstImage!)).toBe(tagOf(this.secondImage!)); +} + +function variant_resolved_from_disk(this: Context) { + expect(this.firstImage).toBeDefined(); + expect(this.secondImage).toBe(this.firstImage); +} + +function colliding_variants_resolve_independently(this: Context) { + expect(this.firstImage).toBeDefined(); + expect(this.secondImage).toBeDefined(); + // Same Dockerfile + parent → same image tag, but both keys must still resolve (no lost update). + expect(this.firstImage).toBe(this.secondImage); + expect(resolveToolchainImage("cursor", "node/18", { packageRoot: this.packageRoot })).toBe( + this.firstImage, + ); + expect(resolveToolchainImage("cursor", "node_18", { packageRoot: this.packageRoot })).toBe( + this.secondImage, + ); +} + +function error_mentions_missing_dockerfile(this: Context) { + expect(this.error?.message).toContain("Dockerfile not found"); + expect(this.error?.message).toContain(join(this.packageRoot, this.dockerfileRelative)); +} + +function error_mentions_from_mismatch(this: Context) { + expect(this.error?.message).toContain("Dockerfile FROM must resolve to"); + expect(this.error?.message).toContain("agent-gwt/cursor-cli:local"); +} + +function expectedImage(packageRoot: string, contents: string, parentId: string): string { + const repoDigest = createHash("sha256").update(resolve(packageRoot)).digest("hex").slice(0, 12); + const contentDigest = contentDigestOf(contents, parentId); + return `agent-gwt/toolchain-cursor-${repoDigest}:${contentDigest}`; +} + +function contentDigestOf(contents: string, parentId: string): string { + return createHash("sha256").update(`${contents}\n${parentId}`).digest("hex").slice(0, 12); +} + +function tagOf(image: string): string { + return image.slice(image.lastIndexOf(":") + 1); +} + +function repoDigestOf(image: string): string { + const name = image.slice(0, image.lastIndexOf(":")); + return name.slice(name.lastIndexOf("-") + 1); +} diff --git a/src/agents/build-toolchain-image.ts b/src/agents/build-toolchain-image.ts new file mode 100644 index 0000000..dbbfe1a --- /dev/null +++ b/src/agents/build-toolchain-image.ts @@ -0,0 +1,188 @@ +import { createHash, randomBytes } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { buildAgentImage, buildDockerImage } from "./build-agent-image.js"; +import { runDocker } from "./docker.js"; +import type { AgentName } from "./registry.js"; +import { resolveAgent } from "./registry.js"; +import type { DockerRunner } from "./types.js"; + +const DIGEST_LENGTH = 12; +const AGENT_IMAGE_BUILD_ARG = "AGENT_IMAGE"; + +const toolchainImages = new Map(); + +export type BuildToolchainImageOptions = { + agent: AgentName; + dockerfileRelative: string; + /** + * Repo root that owns the Dockerfile. Defaults to `process.cwd()`. + * Must match the cwd used when resolving variants via `agent({ variant })` + * (registry paths are keyed by this digest). + */ + packageRoot?: string; + dockerRunner?: DockerRunner; +}; + +export type ResolveToolchainImageOptions = { + /** Defaults to `process.cwd()` — must match the `packageRoot` used at build time. */ + packageRoot?: string; +}; + +export function resetToolchainImages(options: ResolveToolchainImageOptions = {}): void { + toolchainImages.clear(); + const dir = registryDir(options.packageRoot ?? process.cwd()); + if (existsSync(dir)) { + rmSync(dir, { recursive: true, force: true }); + } +} + +/** Clears the in-process cache without deleting persisted registry files. */ +export function clearToolchainImageMemory(): void { + toolchainImages.clear(); +} + +/** + * Resolve a variant registered by `buildToolchainImage`. + * Checks in-process memory first, then the packageRoot-scoped registry file + * (so vitest `globalSetup` registrations are visible to test workers). + */ +export function resolveToolchainImage( + agent: AgentName, + variant: string, + options: ResolveToolchainImageOptions = {}, +): string | undefined { + const packageRoot = resolve(options.packageRoot ?? process.cwd()); + const key = registryKey(agent, variant); + const cacheKey = `${digest(packageRoot)}::${key}`; + const cached = toolchainImages.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + const file = registryEntryPath(packageRoot, agent, variant); + if (!existsSync(file)) { + return undefined; + } + + const image = readFileSync(file, "utf8").trim(); + if (image.length === 0) { + return undefined; + } + + toolchainImages.set(cacheKey, image); + return image; +} + +export async function buildToolchainImage( + variant: string, + options: BuildToolchainImageOptions, +): Promise { + const packageRoot = resolve(options.packageRoot ?? process.cwd()); + const dockerfile = join(packageRoot, options.dockerfileRelative); + const dockerRunner = options.dockerRunner ?? runDocker; + const agentImage = resolveAgent(options.agent).image; + + if (!existsSync(dockerfile)) { + throw new Error(`Dockerfile not found at ${dockerfile}`); + } + + const dockerfileContents = readFileSync(dockerfile, "utf8"); + assertDockerfileUsesAgentImage(dockerfileContents, agentImage); + + await buildAgentImage(options.agent); + + const parentId = await inspectImageId(agentImage, dockerRunner); + const repoDigest = digest(packageRoot); + const contentDigest = digest(`${dockerfileContents}\n${parentId}`); + const image = `agent-gwt/toolchain-${options.agent}-${repoDigest}:${contentDigest}`; + + await buildDockerImage(image, { + dockerfileRelative: options.dockerfileRelative, + packageRoot, + force: true, + buildArgs: { [AGENT_IMAGE_BUILD_ARG]: agentImage }, + ...(options.dockerRunner !== undefined ? { dockerRunner: options.dockerRunner } : {}), + }); + + registerToolchainImage(packageRoot, options.agent, variant, image); +} + +function registerToolchainImage( + packageRoot: string, + agent: AgentName, + variant: string, + image: string, +): void { + const key = registryKey(agent, variant); + const cacheKey = `${digest(packageRoot)}::${key}`; + toolchainImages.set(cacheKey, image); + + const file = registryEntryPath(packageRoot, agent, variant); + mkdirSync(registryDir(packageRoot), { recursive: true }); + const temp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`; + writeFileSync(temp, `${image}\n`); + renameSync(temp, file); +} + +function assertDockerfileUsesAgentImage(contents: string, agentImage: string): void { + // Single-stage only: first FROM must be the agent (multi-stage final-FROM layouts are out of scope). + const fromLine = contents + .split("\n") + .map((line) => line.trim()) + .find((line) => /^FROM\s+/i.test(line)); + + if (fromLine === undefined) { + throw new Error( + `Dockerfile must start FROM ${agentImage} or FROM \${${AGENT_IMAGE_BUILD_ARG}}`, + ); + } + + const usesBuildArg = + fromLine.includes(`\${${AGENT_IMAGE_BUILD_ARG}}`) || + fromLine.includes(`$${AGENT_IMAGE_BUILD_ARG}`); + const usesLiteral = fromLine.includes(agentImage); + + if (!usesBuildArg && !usesLiteral) { + throw new Error( + `Dockerfile FROM must resolve to ${agentImage} ` + + `(use FROM ${agentImage} or ARG ${AGENT_IMAGE_BUILD_ARG} / FROM \${${AGENT_IMAGE_BUILD_ARG}}). ` + + `Got: ${fromLine}`, + ); + } +} + +async function inspectImageId(image: string, dockerRunner: DockerRunner): Promise { + const inspect = await dockerRunner(["image", "inspect", "--format", "{{.Id}}", image]); + if (inspect.exitCode !== 0) { + throw new Error( + `Docker image ${image} not found after buildAgentImage. stderr:\n${inspect.stderr}`, + ); + } + + const id = inspect.stdout.trim(); + if (id.length === 0) { + throw new Error(`Docker image inspect returned an empty Id for ${image}`); + } + + return id; +} + +function registryKey(agent: AgentName, variant: string): string { + return `${agent}::${variant}`; +} + +function registryDir(packageRoot: string): string { + return join(tmpdir(), ".agents-gwt", "toolchains", digest(resolve(packageRoot))); +} + +function registryEntryPath(packageRoot: string, agent: AgentName, variant: string): string { + // Hash the raw key so distinct variants never collide on disk (e.g. node/18 vs node_18). + return join(registryDir(packageRoot), digest(registryKey(agent, variant))); +} + +function digest(value: string | Buffer): string { + return createHash("sha256").update(value).digest("hex").slice(0, DIGEST_LENGTH); +} diff --git a/src/agents/ensure-image.spec.ts b/src/agents/ensure-image.spec.ts index 426e5a1..2425215 100644 --- a/src/agents/ensure-image.spec.ts +++ b/src/agents/ensure-image.spec.ts @@ -97,4 +97,5 @@ function build_was_not_called(this: Context) { function error_mentions_missing_image(this: Context) { expect(this.error?.message).toContain("agent-gwt/test:local"); expect(this.error?.message).toContain("buildAgentImage"); + expect(this.error?.message).toContain("buildToolchainImage"); } diff --git a/src/agents/ensure-image.ts b/src/agents/ensure-image.ts index f87b5eb..38bf844 100644 --- a/src/agents/ensure-image.ts +++ b/src/agents/ensure-image.ts @@ -12,6 +12,6 @@ export async function ensureDockerImage( } throw new Error( - `Docker image ${image} not found. Call buildAgentImage(...) from vitest globalSetup (or build the image manually) before running agent tests.`, + `Docker image ${image} not found. Call buildAgentImage(...) or buildToolchainImage(...) from vitest globalSetup (or build the image manually) before running agent tests.`, ); } diff --git a/src/agents/run-error.spec.ts b/src/agents/run-error.spec.ts index fe39496..47cd484 100644 --- a/src/agents/run-error.spec.ts +++ b/src/agents/run-error.spec.ts @@ -28,6 +28,15 @@ describe("agentRunError", () => { }, }); + test("hints buildToolchainImage when a toolchain tag is missing", { + when: { + building_error_for_missing_toolchain_image, + }, + then: { + message_has_toolchain_build_hint, + }, + }); + test("puts the agent-reported detail in the headline", { when: { building_error_with_detail, @@ -60,6 +69,19 @@ function building_error_for_missing_image(this: Context) { }); } +function building_error_for_missing_toolchain_image(this: Context) { + this.error = agentRunError({ + agent: "Cursor", + name: "cursor", + image: "agent-gwt/toolchain-cursor-abcd1234ef00:fedcba987654", + result: { + exitCode: 125, + stdout: "", + stderr: "Unable to find image 'agent-gwt/toolchain-cursor-abcd1234ef00:fedcba987654' locally", + }, + }); +} + function building_error_with_detail(this: Context) { this.error = agentRunError({ agent: "Claude", @@ -88,6 +110,12 @@ function message_has_build_hint(this: Context) { expect(this.error.message).toContain("agent-gwt/claude-code:local"); } +function message_has_toolchain_build_hint(this: Context) { + expect(this.error.message).toContain("buildToolchainImage"); + expect(this.error.message).toContain("agent-gwt/toolchain-cursor-abcd1234ef00:fedcba987654"); + expect(this.error.message.includes("buildAgentImage")).toBe(false); +} + function message_has_detail_headline(this: Context) { expect(this.error.message).toContain("Claude agent exited with code 1: api_error: Not logged in"); } diff --git a/src/agents/run-error.ts b/src/agents/run-error.ts index 1dd96b5..9629fc4 100644 --- a/src/agents/run-error.ts +++ b/src/agents/run-error.ts @@ -21,8 +21,16 @@ export function agentRunError(options: AgentRunErrorOptions): Error { : `${options.agent} agent exited with code ${exitCode}: ${options.detail}`; const hint = stderr.includes("Unable to find image") || stderr.includes("not found") - ? `\nDocker image ${options.image} not found; build it with buildAgentImage("${options.name}") in vitest globalSetup.` + ? missingImageHint(options) : ""; return new Error(`${headline}${hint}\nstderr:\n${stderr}\nstdout:\n${stdout}`); } + +function missingImageHint(options: AgentRunErrorOptions): string { + if (options.image.includes("/toolchain-")) { + return `\nDocker image ${options.image} not found; build it with buildToolchainImage(...) in vitest globalSetup.`; + } + + return `\nDocker image ${options.image} not found; build it with buildAgentImage("${options.name}") in vitest globalSetup.`; +} diff --git a/src/agents/types.ts b/src/agents/types.ts index 8569b2b..82c8ee7 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -21,6 +21,11 @@ export type Agent = { export type AgentOptions = { model?: string; + /** + * Named toolchain registered via `buildToolchainImage(variant, ...)`. + * Mutually exclusive with `image`. + */ + variant?: string; /** Docker image tag to ensure and run (defaults to the resolved agent's image). */ image?: string; }; @@ -71,4 +76,8 @@ export type BuildDockerImageOptions = { dockerfileRelative: string; packageRoot: string; dockerRunner?: DockerRunner; + /** When true, always run `docker build` even if the tag already exists. */ + force?: boolean; + /** Extra `--build-arg KEY=VALUE` pairs passed to `docker build`. */ + buildArgs?: Record; }; diff --git a/src/given/agent.spec.ts b/src/given/agent.spec.ts index f3d8798..bfce72e 100644 --- a/src/given/agent.spec.ts +++ b/src/given/agent.spec.ts @@ -1,6 +1,7 @@ -import { afterEach, describe, expect, vi } from "vitest"; -import test from "vitest-gwt"; +import { describe, expect, vi } from "vitest"; +import test, { withAspect } from "vitest-gwt"; +import * as toolchainModule from "../agents/build-toolchain-image.js"; import * as ensureImageModule from "../agents/ensure-image.js"; import { agentRegistry, type AgentName } from "../agents/registry.js"; import { CLAUDE_IMAGE } from "../agents/claude/constants.js"; @@ -10,14 +11,15 @@ import type { AgentContext } from "../types.js"; type Context = AgentContext & { ensureCalls: number; - ensuredImage?: string; + ensuredImage: string | undefined; + error: Error | undefined; + variant: string; + toolchainImage: string; }; -afterEach(() => { - vi.restoreAllMocks(); -}); - describe("agent", () => { + withAspect(reset_agent_test_state, undefined); + test("sets agent, model, and image from the resolved agent", { given: { stub_ensure_docker_image, @@ -47,6 +49,45 @@ describe("agent", () => { }, }); + test("resolves a registered toolchain variant", { + given: { + stub_ensure_docker_image, + registered_toolchain_variant, + }, + when: { + applying_agent_with_variant, + }, + then: { + agent_is: agent_is("cursor"), + image_is_toolchain_variant, + ensure_was_called_with_toolchain, + }, + }); + + test("throws when the toolchain variant is unknown", { + given: { + stub_ensure_docker_image, + }, + when: { + applying_agent_with_unknown_variant, + }, + then: { + error_mentions_unknown_variant, + }, + }); + + test("throws when both image and variant are set", { + given: { + stub_ensure_docker_image, + }, + when: { + applying_agent_with_image_and_variant, + }, + then: { + error_mentions_mutual_exclusion, + }, + }); + test("resolves the claude agent by name", { given: { stub_ensure_docker_image, @@ -63,6 +104,13 @@ describe("agent", () => { }); }); +function reset_agent_test_state(this: Context) { + vi.restoreAllMocks(); + this.ensureCalls = 0; + this.ensuredImage = undefined; + this.error = undefined; +} + function stub_ensure_docker_image(this: Context) { this.ensureCalls = 0; vi.spyOn(ensureImageModule, "ensureDockerImage").mockImplementation(async (image) => { @@ -71,6 +119,41 @@ function stub_ensure_docker_image(this: Context) { }); } +function registered_toolchain_variant(this: Context) { + this.variant = "node18"; + this.toolchainImage = "agent-gwt/toolchain-cursor-abcd1234ef00:fedcba987654"; + vi.spyOn(toolchainModule, "resolveToolchainImage").mockImplementation((agentName, variant) => { + if (agentName === "cursor" && variant === this.variant) { + return this.toolchainImage; + } + return undefined; + }); +} + +async function applying_agent_with_variant(this: Context) { + await agent({ name: "cursor", variant: this.variant, model: "auto" }).call(this); +} + +async function applying_agent_with_unknown_variant(this: Context) { + try { + await agent({ name: "cursor", variant: "missing" }).call(this); + } catch (error) { + this.error = error as Error; + } +} + +async function applying_agent_with_image_and_variant(this: Context) { + try { + await agent({ + name: "cursor", + image: "my-app/agent:local", + variant: "node18", + }).call(this); + } catch (error) { + this.error = error as Error; + } +} + function agent_is(name: AgentName) { return function (this: Context) { expect(this.agent).toBe(agentRegistry[name]); @@ -89,9 +172,27 @@ function image_is(image: string) { }; } +function image_is_toolchain_variant(this: Context) { + expect(this.image).toBe(this.toolchainImage); +} + function ensure_was_called_with(image: string) { return function (this: Context) { expect(this.ensureCalls).toBe(1); expect(this.ensuredImage).toBe(image); }; } + +function ensure_was_called_with_toolchain(this: Context) { + expect(this.ensureCalls).toBe(1); + expect(this.ensuredImage).toBe(this.toolchainImage); +} + +function error_mentions_unknown_variant(this: Context) { + expect(this.error?.message).toContain('Unknown toolchain variant "missing"'); + expect(this.error?.message).toContain("buildToolchainImage"); +} + +function error_mentions_mutual_exclusion(this: Context) { + expect(this.error?.message).toContain("cannot set both image and variant"); +} diff --git a/src/given/agent.ts b/src/given/agent.ts index dd24925..501a4af 100644 --- a/src/given/agent.ts +++ b/src/given/agent.ts @@ -1,3 +1,4 @@ +import { resolveToolchainImage } from "../agents/build-toolchain-image.js"; import type { AgentName } from "../agents/registry.js"; import { resolveAgent } from "../agents/registry.js"; import { ensureDockerImage } from "../agents/ensure-image.js"; @@ -12,7 +13,7 @@ export function agent(options: ConfigureAgentOptions) { return async function (this: AgentContext): Promise { this.agent = resolved; - this.image = options.image ?? resolved.image; + this.image = resolveAgentImage(options, resolved.image); if (options.model !== undefined) { this.model = options.model; @@ -21,3 +22,29 @@ export function agent(options: ConfigureAgentOptions) { await ensureDockerImage(this.image); }; } + +function resolveAgentImage(options: ConfigureAgentOptions, defaultImage: string): string { + if (options.image !== undefined && options.variant !== undefined) { + throw new Error( + `agent({ name: "${options.name}" }) cannot set both image and variant; pick one.`, + ); + } + + if (options.image !== undefined) { + return options.image; + } + + if (options.variant !== undefined) { + const image = resolveToolchainImage(options.name, options.variant); + if (image === undefined) { + throw new Error( + `Unknown toolchain variant "${options.variant}" for agent "${options.name}". ` + + `Call buildToolchainImage("${options.variant}", { agent: "${options.name}", ... }) ` + + `from vitest globalSetup before running tests.`, + ); + } + return image; + } + + return defaultImage; +} diff --git a/src/index.ts b/src/index.ts index c762376..58591a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,6 +49,12 @@ export { resetBuiltImages, type BuildBaseImageOptions, } from "./agents/build-agent-image.js"; +export { + buildToolchainImage, + resetToolchainImages, + resolveToolchainImage, + type BuildToolchainImageOptions, +} from "./agents/build-toolchain-image.js"; export { BASE_IMAGE, BASE_DOCKERFILE_RELATIVE,