mirror of
https://github.com/joelhooks/joelclaw.git
synced 2026-09-19 01:24:04 +08:00
08338a6c9f
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)
208 lines
7.3 KiB
TypeScript
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);
|
|
});
|
|
});
|