Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/process/feature-captures.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ crops retain their source dimensions and fail if the canvas cannot contain them.
Component-stage CSS measurements round to their declared image pixels; phone
border boxes are exact. Runtime transcripts use the full document height.

Rendering binds the stable checkout directory `tmp/capture-share/` and selects a
fresh staging child by name. Docker Desktop therefore sees the same output bind
source on repeated runs. Verified files are copied into staging on the requested
output filesystem before the existing atomic publication renames.

Docker and the installed Bun binary are required. Builds and Chromium run in
the image declared by the shared visual runner, with the frozen installed
packages. The manifest records the image ID/digests, browser and Bun binaries,
Expand Down
23 changes: 23 additions & 0 deletions docs/process/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,29 @@ in the [CI and merge reference](../../.agents/skills/work-issue/references/ci-an
navigation; see [the assertion/domain mapping](proof-domains.md). Dynamic test
counts and passing assertions come from actual suite reports, not AST counts.

## Stable Docker proof inputs

Docker Desktop on WSL2 retains a host mount for each distinct bind source, even
when a container exits. Local proofs bind stable checkout directories and select
fresh run directories inside the container. The first use of a checkout can add
mounts; repeating a proof must not add sources solely because its run ID changed.
Restarting Docker Desktop or `wsl --shutdown` clears older leaked mounts.

| Caller | Stable host source | Run selection |
| --- | --- | --- |
| Native hosted admission | `tmp/native-host-proof/` | Container-only bind of the selected run to `/proof` |
| Worker identity | `tmp/worker-identity-proof/` | Same `/proof` projection, with read-only helper and variant projections inside the container |
| Editorial capture | `tmp/capture-share/` | A staging directory name selects outputs; verified files are copied to destination-local staging before atomic publication |
| Native diagram export | Private tar-filled named volumes and copied captured Bun bytes | Only source and filtered installation volumes are mounted read-only; output is copied out and owned containers/volumes are removed |
| Pinned workspace lease test | `tmp/workspace-lease-container/` | Container working directory selects the fresh fixture |

The stable source checkout, installed Bun binary, font cache and lease directory
retain their existing mounts. Browser, raster and audio callers already use stable source paths. Images, isolation flags and proof assertions remain the
same. Container-only projections create no new Docker host bind source. The private
diagram copy transport additionally requires host `tar` for its input archives. A new
checkout still introduces its own stable sources; this does not clear old mounts
or promise zero growth across distinct checkouts.

## GitHub Actions CI

CI and contract checks run from `.github/workflows/ci.yml` and `contract.yml`.
Expand Down
32 changes: 22 additions & 10 deletions scripts/capture.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
import { copyFile, mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { relative, resolve } from "node:path";
import { basename, dirname, relative, resolve } from "node:path";
import { readCatalogue, type Viewport } from "./captures/catalogue.ts";
import { readFontLock, verifyFontCache } from "./captures/fonts.ts";
import { prepareFontCache } from "./captures/prepare-fonts.ts";
Expand Down Expand Up @@ -50,6 +50,12 @@ async function command(argv: string[]): Promise<void> {
if (await process.exited !== 0) throw new Error(`Capture command failed: ${argv.slice(0, 3).join(" ")}`);
}

export function captureRenderArgs(root: string, cache: string, staging: string, bun: string, image: string, runnerDirectory: string): string[] {
return ["docker", "run", "--rm", "--network=none", "--read-only", "--ipc=host", "--user", `${process.getuid?.() ?? 0}:${process.getgid?.() ?? 0}`,
"--tmpfs", "/tmp:rw,exec,nosuid,mode=1777", "-v", `${root}:/repo:ro`, "-v", `${cache}:/fonts:ro`,
"-v", `${dirname(staging)}:/captures`, "--env", `BRAIN_CAPTURE_RUN=${basename(staging)}`, "-v", `${bun}:/capture-bun:ro`, "-v", `${bun}:/usr/local/bin/bun:ro`, "-v", `${bun}:/usr/local/bin/bunx:ro`, "-w", "/repo", image, "node", `/repo/${relative(root, runnerDirectory)}/inside.mjs`];
}

async function run(): Promise<void> {
const options = argumentsFor(process.argv.slice(2));
const catalogue = await readCatalogue(root);
Expand All @@ -73,10 +79,13 @@ async function run(): Promise<void> {
const lockDirectory = resolve(root,"tmp/.feature-capture-lock");
try { await mkdir(lockDirectory); }
catch { throw new Error("Another capture owns this checkout’s build; finish it before capturing again. If it was interrupted, remove tmp/.feature-capture-lock after verifying no capture is running."); }
let staging: string | undefined, runnerDirectory: string | undefined;
let staging: string | undefined, publication: string | undefined, runnerDirectory: string | undefined;
try {
await writeFile(resolve(lockDirectory,"owner.json"),JSON.stringify({pid:process.pid})+"\n");
staging = await mkdtemp(resolve(output, ".capture-"));
const share = resolve(root, "tmp", "capture-share");
await mkdir(share, { recursive: true });
staging = await mkdtemp(resolve(share, ".capture-"));
publication = await mkdtemp(resolve(output, ".capture-publication-"));
runnerDirectory = await mkdtemp(resolve(root, "tmp/.capture-runner-"));
let image: Array<{ Id: string; RepoDigests?: string[] }>;
try { image = JSON.parse(execFileSync("docker", ["image", "inspect", pin.image], { encoding: "utf8" })); }
Expand Down Expand Up @@ -106,9 +115,7 @@ async function run(): Promise<void> {
reduced_motion: catalogue.environment.reduced_motion },
};
await writeFile(resolve(staging, "capture-job.json"), JSON.stringify(job) + "\n");
await command(["docker", "run", "--rm", "--network=none", "--read-only", "--ipc=host", "--user", `${process.getuid?.() ?? 0}:${process.getgid?.() ?? 0}`,
"--tmpfs", "/tmp:rw,exec,nosuid,mode=1777", "-v", `${root}:/repo:ro`, "-v", `${cache}:/fonts:ro`,
"-v", `${staging}:/captures`, "-v", `${bun}:/capture-bun:ro`, "-v", `${bun}:/usr/local/bin/bun:ro`, "-v", `${bun}:/usr/local/bin/bunx:ro`, "-w", "/repo", pin.image, "node", `/repo/${relative(root, runnerDirectory)}/inside.mjs`]);
await command(captureRenderArgs(root, cache, staging, bun, pin.image, runnerDirectory));
if ((await sourceProvenance(root)).inputs_sha256 !== source.inputs_sha256) throw new Error("Capture source changed while rendering; outputs were not accepted");
const result = JSON.parse(await readFile(resolve(staging, "capture-result.json"), "utf8")) as { artifacts: Array<{ id: string; file: string; files: Array<{file:string}>; theme: string; viewport: Viewport }> };
if (result.artifacts.length !== ids.length || new Set(result.artifacts.map((entry)=>entry.id)).size!==ids.length || result.artifacts.some((entry) => !ids.includes(entry.id) || !entry.files.length)) throw new Error("Capture runner did not return every selected recipe");
Expand All @@ -120,14 +127,19 @@ async function run(): Promise<void> {
const retained = previous.artifacts.filter((entry) => typeof entry === "object" && entry !== null && !replaced.has((entry as { file: string }).file));
const manifest = { purpose: previous.purpose, selected_ids: ids, artifacts: [...retained, ...result.artifacts] };
await writeFile(resolve(staging, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
for (const entry of result.artifacts) for (const file of entry.files) await rename(resolve(staging, file.file), resolve(output, file.file));
await rename(resolve(staging, "manifest.json"), resolve(output, "manifest.json"));
// Copy to the destination filesystem before the existing atomic publication
// renames. Explicit output directories may live on a different filesystem.
for (const entry of result.artifacts) for (const file of entry.files) await copyFile(resolve(staging, file.file), resolve(publication, file.file));
await copyFile(resolve(staging, "manifest.json"), resolve(publication, "manifest.json"));
for (const entry of result.artifacts) for (const file of entry.files) await rename(resolve(publication, file.file), resolve(output, file.file));
await rename(resolve(publication, "manifest.json"), resolve(output, "manifest.json"));
console.log(`Captured ${ids.length} selected recipes; each artifact records its own source/environment provenance.`);
} finally {
if (staging) await rm(staging, { recursive: true, force: true });
if (publication) await rm(publication, { recursive: true, force: true });
if (runnerDirectory) await rm(runnerDirectory, { recursive: true, force: true });
await rm(lockDirectory, {recursive:true,force:true});
}
}

run().catch((error) => { console.error((error as Error).message); process.exitCode = 1; });
if (import.meta.main) run().catch((error) => { console.error((error as Error).message); process.exitCode = 1; });
4 changes: 3 additions & 1 deletion scripts/captures/inside.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ export interface CaptureJob {
}

async function run(): Promise<void> {
const root = "/repo", output = "/captures", cache = "/fonts";
const run = process.env.BRAIN_CAPTURE_RUN;
if (!run || !/^\.capture-[a-zA-Z0-9_-]+$/.test(run)) throw new Error("Capture runner requires its owned staging directory name");
const root = "/repo", output = resolve("/captures", run), cache = "/fonts";
const job: CaptureJob = JSON.parse(await readFile(resolve(output, "capture-job.json"), "utf8"));
const catalogue = await readCatalogue(root);
const index = JSON.parse(await readFile(resolve(root, "packages/ui-kit/storybook-static/index.json"), "utf8"));
Expand Down
2 changes: 2 additions & 0 deletions scripts/diagram-export-producer.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ export const diagramProducerImage:string;
export const diagramProducerImageIdentity:string;
export function diagramProducerRuntime():{image:string;bun:string;executable:string;bytes:Uint8Array;sha256:string};
export function prepareDiagramNativeArtifacts(repo:string,projects:string[],expected?:{source:string;installation:string;runtime?:string}):string|undefined;

export function diagramNativeDockerArgs(repo: string, source: string, runtime: { image: string }, volumes: { source: string; modules: string; output: string; runtime: string }, container: string, program: string): string[];
Loading
Loading