openclaw / src /agents /worktrees /empty-source.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
e249c6d verified
Raw History Blame Contribute Delete
10.5 kB
import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import * as commandExec from "../../process/exec.js";
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
import * as stateLease from "../../state/openclaw-state-lease.js";
import { requireGit, runGit } from "./git.js";
import { getRegistryWorktree } from "./registry.js";
import { ManagedWorktreeService, SNAPSHOT_RETENTION_MS } from "./service.js";
const identity = {
GIT_AUTHOR_NAME: "OpenClaw Test",
GIT_AUTHOR_EMAIL: "openclaw-test@example.invalid",
GIT_COMMITTER_NAME: "OpenClaw Test",
GIT_COMMITTER_EMAIL: "openclaw-test@example.invalid",
};
describe("empty managed workspaces", () => {
const tempDirs = useAutoCleanupTempDirTracker((cleanup) =>
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
closeOpenClawStateDatabaseForTest();
cleanup();
}),
);
let root: string;
let env: NodeJS.ProcessEnv;
let now: number;
let service: ManagedWorktreeService;
function create(name: string) {
return service.createEmpty({ name, ownerKind: "session", ownerId: `agent:main:${name}` });
}
beforeEach(() => {
root = tempDirs.make("openclaw-empty-workspace-");
vi.stubEnv("GIT_CONFIG_NOSYSTEM", "1");
vi.stubEnv("GIT_CONFIG_GLOBAL", os.devNull);
env = { ...process.env, OPENCLAW_STATE_DIR: path.join(root, "state") };
now = 1_700_000_000_000;
service = new ManagedWorktreeService({
env,
now: () => now,
getConfig: () => ({ worktreeAcceleration: false }),
});
});
it("concurrently reuses one session workspace without operator Git configuration", async () => {
const config = path.join(root, "operator.gitconfig");
await fs.writeFile(
config,
"[init]\n\ttemplateDir = /missing/template\n[commit]\n\tgpgSign = true\n",
);
const bootstrapEnv = {
...env,
GIT_CONFIG_GLOBAL: config,
GIT_DIR: path.join(root, "unrelated.git"),
GIT_INDEX_FILE: path.join(root, "unrelated.index"),
GIT_DEFAULT_HASH: "sha256",
};
const records = await Promise.all(
Array.from({ length: 3 }, () =>
new ManagedWorktreeService({ env: bootstrapEnv }).createEmpty({
ownerKind: "session",
ownerId: "agent:main:same-session",
name: "same-session",
}),
),
);
expect(new Set(records.map((record) => record.id)).size).toBe(1);
const { repoRoot: source, path: workspace } = records[0]!;
expect(path.basename(source)).toBe("workspace");
expect(await requireGit(source, ["branch", "--show-current"])).toBe("main");
expect(await requireGit(source, ["rev-list", "--count", "main"])).toBe("1");
expect(await requireGit(source, ["ls-tree", "-r", "main"])).toBe("");
expect(await fs.readdir(workspace)).toEqual([".git"]);
expect(await fs.readdir(path.dirname(source))).toEqual([path.basename(source)]);
expect(fsSync.existsSync(bootstrapEnv.GIT_DIR)).toBe(false);
expect(fsSync.existsSync(bootstrapEnv.GIT_INDEX_FILE)).toBe(false);
});
it("isolates files, history, remotes, and config while retaining snapshot recovery", async () => {
const first = await create("first");
await fs.writeFile(path.join(first.path, "notes.md"), "Committed task content\n");
await requireGit(first.path, ["add", "notes.md"]);
await requireGit(first.path, ["commit", "-m", "Task progress"], { env: identity });
await requireGit(first.path, ["remote", "add", "origin", "https://example.invalid/first.git"]);
await requireGit(first.path, ["config", "openclaw.taskMarker", "first"]);
const attachment = Buffer.from([0, 1, 128, 255]);
await fs.writeFile(path.join(first.path, "attachment.bin"), attachment);
const removed = await service.remove({ id: first.id, reason: "archive" });
expect(removed).toMatchObject({ removed: true, snapshotRef: expect.any(String) });
expect(fsSync.existsSync(first.path)).toBe(false);
const restored = await service.restore({ id: first.id });
expect(await fs.readFile(path.join(restored.path, "notes.md"), "utf8")).toBe(
"Committed task content\n",
);
expect(await fs.readFile(path.join(restored.path, "attachment.bin"))).toEqual(attachment);
const second = await create("second");
expect(second.repoRoot).not.toBe(first.repoRoot);
expect(await fs.readdir(second.path)).toEqual([".git"]);
expect(await requireGit(second.path, ["remote"])).toBe("");
expect((await runGit(second.path, ["config", "openclaw.taskMarker"])).code).toBe(1);
expect(await requireGit(second.path, ["rev-list", "--all", "--count"])).toBe("1");
expect(await requireGit(first.repoRoot, ["rev-list", "--count", "main"])).toBe("1");
expect(await create("first")).toMatchObject({ id: first.id, path: first.path });
const records = service.listRegistryRecords();
await fs.rename(first.repoRoot, `${first.repoRoot}-saved`);
await expect(create("first")).rejects.toThrow("Empty workspace source is missing");
expect(fsSync.existsSync(first.repoRoot)).toBe(false);
expect(service.listRegistryRecords()).toEqual(records);
expect(await fs.readFile(path.join(restored.path, "attachment.bin"))).toEqual(attachment);
});
it.each(["files", "head", "commit", "metadata"])(
"preserves and refuses a source with modified %s",
async (modified) => {
const { repoRoot: source } = await create("modified");
const marker = path.join(source, "keep.txt");
if (modified === "files") {
await fs.writeFile(marker, "keep this content\n");
} else if (modified === "head") {
await requireGit(source, ["checkout", "--detach"]);
} else if (modified === "commit") {
await requireGit(source, ["commit", "--allow-empty", "-m", "Changed source"], {
env: identity,
});
} else {
await fs.rename(path.join(source, ".git"), path.join(source, "saved-metadata"));
}
await expect(create("modified")).rejects.toThrow(
"Empty workspace source is unavailable or modified",
);
if (modified === "files") {
expect(await fs.readFile(marker, "utf8")).toBe("keep this content\n");
} else if (modified === "head") {
expect(await requireGit(source, ["branch", "--show-current"])).toBe("");
} else if (modified === "commit") {
expect(await requireGit(source, ["rev-list", "--count", "main"])).toBe("2");
} else {
expect(fsSync.existsSync(path.join(source, "saved-metadata", "HEAD"))).toBe(true);
expect(fsSync.existsSync(path.join(source, ".git"))).toBe(false);
}
},
);
it("cleans unexposed bootstrap state when authority is revoked after Git initialization", async () => {
const revoked = new Error("session creation no longer owns this request");
let current = true;
const execute = commandExec.runCommandWithTimeout;
vi.spyOn(commandExec, "runCommandWithTimeout").mockImplementation(async (argv, options) => {
const result = await execute(argv, options);
if (argv.includes("update-ref")) {
current = false;
}
return result;
});
await expect(
service.createEmpty({
ownerKind: "session",
ownerId: "agent:main:revoked",
commitGuard: () => {
if (!current) {
throw revoked;
}
},
}),
).rejects.toBe(revoked);
expect(
await fs.readdir(path.join(env.OPENCLAW_STATE_DIR!, "worktree-sources", "empty")),
).toEqual([]);
});
it("expires source metadata only with its final retained snapshot record", async () => {
const created = await create("retained");
await fs.writeFile(path.join(created.path, "draft.txt"), "Restorable work\n");
await service.remove({ id: created.id, reason: "archive" });
now += SNAPSHOT_RETENTION_MS;
expect((await service.gc()).snapshotsPruned).toBe(0);
expect(fsSync.existsSync(created.repoRoot)).toBe(true);
const restored = await service.restore({ id: created.id });
expect(await fs.readFile(path.join(restored.path, "draft.txt"), "utf8")).toBe(
"Restorable work\n",
);
await service.remove({ id: created.id, reason: "archive-again" });
now += SNAPSHOT_RETENTION_MS + 1;
const unavailableLease = vi
.spyOn(stateLease, "withOpenClawStateLease")
.mockRejectedValue(new Error("allocation lease unavailable"));
expect((await service.gc()).snapshotsPruned).toBe(0);
expect(fsSync.existsSync(created.repoRoot)).toBe(true);
const retained = getRegistryWorktree(env, created.id);
expect(retained?.snapshotRef).toBeDefined();
expect(await requireGit(created.repoRoot, ["show", `${retained!.snapshotRef}:draft.txt`])).toBe(
"Restorable work",
);
unavailableLease.mockRestore();
expect((await service.gc()).snapshotsPruned).toBe(1);
expect(fsSync.existsSync(created.repoRoot)).toBe(false);
expect(fsSync.existsSync(path.dirname(created.repoRoot))).toBe(false);
expect(getRegistryWorktree(env, created.id)).toBeUndefined();
});
it("removes an unbound source when checkout allocation fails", async () => {
await expect(create("invalid name")).rejects.toThrow("worktree name must match");
expect(service.listRegistryRecords()).toEqual([]);
expect(
await fs.readdir(path.join(env.OPENCLAW_STATE_DIR!, "worktree-sources", "empty")),
).toEqual([]);
});
it("preserves an expired source with extra files and retries after they are moved", async () => {
const created = await create("source-files");
await service.remove({ id: created.id, reason: "archive" });
const marker = path.join(created.repoRoot, "keep.txt");
await fs.writeFile(marker, "Keep this unowned source file\n");
now += SNAPSHOT_RETENTION_MS + 1;
expect((await service.gc()).snapshotsPruned).toBe(0);
expect(getRegistryWorktree(env, created.id)?.snapshotRef).toBeDefined();
expect(await fs.readFile(marker, "utf8")).toBe("Keep this unowned source file\n");
await fs.rename(marker, path.join(root, "saved.txt"));
expect((await service.gc()).snapshotsPruned).toBe(1);
expect(fsSync.existsSync(created.repoRoot)).toBe(false);
});
});