Files
joelhooks__joelclaw/packages/agent-execution/__tests__/artifacts.test.ts
Joel Hooks 08338a6c9f Add repo materialization and patch artifact export to @joelclaw/agent-execution
Implement sandbox runtime PRD Story 3: clean repo materialization and patch-artifact export so sandbox runs mutate only their own checkout and return auditable output instead of touching the host worktree.

New capabilities:
- materializeRepo(): Clone or checkout repo at exact SHA in sandbox-local workspace. Fresh clone if target doesn't exist, fetch+checkout otherwise. SHA verification with automatic unshallow. Isolated from host worktree.
- generatePatchArtifact(): Export auditable patch from baseSha..headSha with touched-file inventory, verification summary, and log references. Uses git format-patch for commits, git diff for uncommitted changes.
- getTouchedFiles(): Capture modified/untracked files via git status --porcelain.
- verifyRepoState(): Validate repo is at expected SHA.
- writeArtifactBundle()/readArtifactBundle(): Serialize ExecutionArtifacts to/from JSON.

Promotion boundary: Phase 1 output is patch bundle + metadata. Runtime does NOT merge to main or push to remote. Operator reviews patch + verification, then applies to host repo or discards.

Tests: Full coverage for repo materialization, artifact export, touched-file inventory, and bundle serialization. All 84 tests pass.

Docs: Updated deploy.md, architecture.md, and system-architecture skill with new contract details and Phase 1 promotion boundary explanation.

Files:
- packages/agent-execution/src/repo.ts (new)
- packages/agent-execution/src/artifacts.ts (new)
- packages/agent-execution/src/index.ts (exports)
- packages/agent-execution/__tests__/repo.test.ts (new)
- packages/agent-execution/__tests__/artifacts.test.ts (new)
- docs/deploy.md (updated)
- docs/architecture.md (updated)
- skills/system-architecture/SKILL.md (updated)

Verification: bunx tsc --noEmit ✓, pnpm biome check ✓, bun test packages/agent-execution ✓ (84/84 pass)
2026-03-06 17:20:54 -08:00

208 lines
7.3 KiB
TypeScript

/**
* Tests for artifact generation helpers.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { $ } from "bun";
import {
ArtifactGenerationError,
generatePatchArtifact,
readArtifactBundle,
writeArtifactBundle,
} from "../src/artifacts.js";
describe("artifact generation", () => {
let testDir: string;
beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), "agent-execution-test-"));
});
afterEach(async () => {
if (testDir) {
await rm(testDir, { recursive: true, force: true });
}
});
test("generatePatchArtifact creates artifact with patch for committed changes", async () => {
// Create a simple git repo
const repoPath = join(testDir, "test-repo");
await $`git init ${repoPath}`.quiet();
await $`git -C ${repoPath} config user.email "test@example.com"`.quiet();
await $`git -C ${repoPath} config user.name "Test User"`.quiet();
await $`echo "initial" > ${repoPath}/test.txt`.quiet();
await $`git -C ${repoPath} add test.txt`.quiet();
await $`git -C ${repoPath} commit -m "Initial commit"`.quiet();
const baseSha = await $`git -C ${repoPath} rev-parse HEAD`.text();
const baseShaClean = baseSha.trim();
// Make a change and commit
await $`echo "modified" > ${repoPath}/test.txt`.quiet();
await $`git -C ${repoPath} add test.txt`.quiet();
await $`git -C ${repoPath} commit -m "Modify file"`.quiet();
const headSha = await $`git -C ${repoPath} rev-parse HEAD`.text();
const headShaClean = headSha.trim();
const artifact = await generatePatchArtifact({
repoPath,
baseSha: baseShaClean,
headSha: headShaClean,
});
expect(artifact.headSha).toBe(headShaClean);
expect(artifact.touchedFiles).toEqual([]);
expect(artifact.patch).toBeTruthy();
expect(artifact.patch).toContain("test.txt");
expect(artifact.patch).toContain("modified");
});
test("generatePatchArtifact creates artifact with empty patch when no changes", async () => {
// Create a simple git repo
const repoPath = join(testDir, "test-repo");
await $`git init ${repoPath}`.quiet();
await $`git -C ${repoPath} config user.email "test@example.com"`.quiet();
await $`git -C ${repoPath} config user.name "Test User"`.quiet();
await $`echo "initial" > ${repoPath}/test.txt`.quiet();
await $`git -C ${repoPath} add test.txt`.quiet();
await $`git -C ${repoPath} commit -m "Initial commit"`.quiet();
const baseSha = await $`git -C ${repoPath} rev-parse HEAD`.text();
const baseShaClean = baseSha.trim();
const artifact = await generatePatchArtifact({
repoPath,
baseSha: baseShaClean,
headSha: baseShaClean,
});
expect(artifact.headSha).toBe(baseShaClean);
expect(artifact.touchedFiles).toEqual([]);
expect(artifact.patch).toBe("");
});
test("generatePatchArtifact includes touched files for uncommitted changes", async () => {
// Create a simple git repo
const repoPath = join(testDir, "test-repo");
await $`git init ${repoPath}`.quiet();
await $`git -C ${repoPath} config user.email "test@example.com"`.quiet();
await $`git -C ${repoPath} config user.name "Test User"`.quiet();
await $`echo "initial" > ${repoPath}/test.txt`.quiet();
await $`git -C ${repoPath} add test.txt`.quiet();
await $`git -C ${repoPath} commit -m "Initial commit"`.quiet();
const baseSha = await $`git -C ${repoPath} rev-parse HEAD`.text();
const baseShaClean = baseSha.trim();
// Make a change without committing
await $`echo "modified" > ${repoPath}/test.txt`.quiet();
const artifact = await generatePatchArtifact({
repoPath,
baseSha: baseShaClean,
});
expect(artifact.headSha).toBe(baseShaClean);
expect(artifact.touchedFiles).toContain("test.txt");
expect(artifact.patch).toBeTruthy();
});
test("generatePatchArtifact includes verification data when provided", async () => {
// Create a simple git repo
const repoPath = join(testDir, "test-repo");
await $`git init ${repoPath}`.quiet();
await $`git -C ${repoPath} config user.email "test@example.com"`.quiet();
await $`git -C ${repoPath} config user.name "Test User"`.quiet();
await $`echo "initial" > ${repoPath}/test.txt`.quiet();
await $`git -C ${repoPath} add test.txt`.quiet();
await $`git -C ${repoPath} commit -m "Initial commit"`.quiet();
const baseSha = await $`git -C ${repoPath} rev-parse HEAD`.text();
const baseShaClean = baseSha.trim();
const artifact = await generatePatchArtifact({
repoPath,
baseSha: baseShaClean,
verificationCommands: ["bun test", "bunx tsc --noEmit"],
verificationSuccess: true,
verificationOutput: "All tests passed",
});
expect(artifact.verification).toBeDefined();
expect(artifact.verification?.commands).toEqual(["bun test", "bunx tsc --noEmit"]);
expect(artifact.verification?.success).toBe(true);
expect(artifact.verification?.output).toBe("All tests passed");
});
test("generatePatchArtifact includes log references when provided", async () => {
// Create a simple git repo
const repoPath = join(testDir, "test-repo");
await $`git init ${repoPath}`.quiet();
await $`git -C ${repoPath} config user.email "test@example.com"`.quiet();
await $`git -C ${repoPath} config user.name "Test User"`.quiet();
await $`echo "initial" > ${repoPath}/test.txt`.quiet();
await $`git -C ${repoPath} add test.txt`.quiet();
await $`git -C ${repoPath} commit -m "Initial commit"`.quiet();
const baseSha = await $`git -C ${repoPath} rev-parse HEAD`.text();
const baseShaClean = baseSha.trim();
const artifact = await generatePatchArtifact({
repoPath,
baseSha: baseShaClean,
executionLogPath: "/tmp/execution.log",
verificationLogPath: "/tmp/verification.log",
});
expect(artifact.logs).toBeDefined();
expect(artifact.logs?.executionLog).toBe("/tmp/execution.log");
expect(artifact.logs?.verificationLog).toBe("/tmp/verification.log");
});
test("writeArtifactBundle writes JSON to disk", async () => {
const artifact = {
headSha: "abc123",
touchedFiles: ["file1.ts", "file2.ts"],
patch: "diff content",
};
const outputPath = join(testDir, "artifacts.json");
const written = await writeArtifactBundle(artifact, outputPath);
expect(written).toBe(outputPath);
// Verify file exists and contains correct data
const file = Bun.file(outputPath);
const content = await file.text();
const parsed = JSON.parse(content);
expect(parsed).toEqual(artifact);
});
test("readArtifactBundle reads JSON from disk", async () => {
const artifact = {
headSha: "abc123",
touchedFiles: ["file1.ts", "file2.ts"],
patch: "diff content",
};
const filePath = join(testDir, "artifacts.json");
await Bun.write(filePath, JSON.stringify(artifact, null, 2));
const read = await readArtifactBundle(filePath);
expect(read).toEqual(artifact);
});
test("readArtifactBundle throws on invalid JSON", async () => {
const filePath = join(testDir, "invalid.json");
await Bun.write(filePath, "not valid json");
await expect(readArtifactBundle(filePath)).rejects.toThrow(ArtifactGenerationError);
});
});