File size: 10,458 Bytes
e249c6d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
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);
  });
});