mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs in remote sandboxes upload the workspace as seed
archives, and a per-company cache keeps recent seeds so a later run with
the same workspace state can reuse them
> - The cache only ever added seeds; at its limits it stopped accepting
new ones and never removed old ones
> - On one instance it had filled to its 8 GiB cap with week-old seeds
and froze, so it held disk without helping new runs
> - This pull request expires seeds unused for 7 days and evicts the
least recently used seeds when a new one needs room
> - The benefit is a cache that stays useful within the same limits and
gives back space held by stale seeds
## Linked Issues or Issue Description
Bug report described in-PR (bug report template).
**What happened?**
On one instance, the company's seed cache
(`native-workspace-sync/generations/<company>/`) sat at its 8 GiB cap:
209 generations across 169 workspaces. 107 generations (8.2 GB) had not
been touched for 7 days or more. Because publishing stops at the limits,
new workspace states were no longer cached, while seeds for old states
and deleted workspaces stayed forever.
**Expected behavior**
Stale seeds are removed, and a new seed can replace the least recently
used ones when the cache is full.
**Steps to reproduce**
1. Run enough native sandbox runs across workspace states to reach a
seed cache limit.
2. New seeds are no longer published, and nothing is ever removed.
**Paperclip version or commit:** `master` at 837add0caa.
**Deployment mode:** any with native remote-sandbox runs.
**Agent adapter(s) involved:** native sandbox runtimes
(`packages/adapter-utils` seed cache).
## What Changed
`packages/adapter-utils/src/workspace-seed-cache.ts`:
- **Use tracking:** on a verified hit, `readWorkspaceSeedGeneration`
takes the company lock, confirms the receipt is still the one it
verified, and touches it, so its mtime records the last use. Eviction
holds the same lock from reading timestamps to removing, so a hit is
either recorded first and protected, or already gone and reported as a
miss. The reader accepts a `companyDirectory` option, which the sandbox
runtime now passes as it already does for publishing.
- **Idle expiry:** `publishWorkspaceSeedGeneration`, under the existing
company lock, first removes generations unused for
`WORKSPACE_SEED_CACHE_EVICTION.idleMs` (7 days).
- **LRU eviction:** if the new seed still does not fit, it evicts the
least recently used generations until it does: within the workspace for
workspace limits, across the company for company limits.
- **What is never evicted:**
- a generation used within `minIdleMs` (1 hour), since a run copies a
cache hit shortly after the lookup;
- the generation being published;
- abandoned `.pending-*` directories, which still count toward the
limits as before.
- **Removal:** renames to `.evicted-<uuid>` before deleting, and removes
leftovers of an interrupted eviction. All empty workspace directories
are removed, so they no longer hold workspace slots.
- **Identity check:** publication rechecks the company directory's
identity under the lock before evicting, so a directory replaced during
verification is never touched.
- **Verification:** optional verification still runs only when the seed
can fit after eviction. A dry eviction plan decides this before
verification, and the real eviction happens under the lock.
- `publishWorkspaceSeedGeneration` accepts `eviction` and `now` options
for tests; the limits are unchanged.
No code comments were added.
## Verification
- `pnpm exec vitest run src/workspace-seed-cache.test.ts
src/workspace-checkpoint.test.ts` (in `packages/adapter-utils`): 58
passed. With `sandbox-managed-runtime.test.ts`: 168 passed. New cases:
- **Idle expiry:** generations unused for 7 days are removed on publish,
emptied workspaces are removed, newer ones stay.
- **Use tracking:** a cache hit protects an otherwise idle generation.
- **Workspace LRU:** the least recently used generation of a full
workspace is evicted.
- **Company LRU:** company pressure evicts across workspaces, but never
a generation used within the last hour.
- **Verification:** it runs only when eviction can make room.
- **Pending directories:** abandoned ones are never evicted, and
verification is skipped when they fill the cache.
- **Leftovers:** remnants of an interrupted eviction are removed.
- **Concurrent read:** an eviction paused just before removing a seed
makes a concurrent read report a miss, never a hit whose files vanish.
- **Replaced directory:** a nonempty company directory swapped in during
verification keeps its seeds.
- **Empty workspace:** an abandoned empty workspace directory no longer
holds the only workspace slot.
- **Mutation checks:** removing any of these fails at least one case:
- the touch on read;
- idle expiry;
- the recent-use guard;
- the plan before verification;
- leftover cleanup;
- the pending-directory guard;
- the read lock (the concurrent-read case then returns a hit whose files
are gone);
- the identity recheck;
- removing only workspaces that had generations.
The existing `workspace-checkpoint` end-to-end test caught an earlier
version that ignored pending directories when deciding whether to
verify.
- `pnpm exec vitest run src` (in `packages/adapter-utils`): all seed
cache and checkpoint tests pass. Two runs on a heavily loaded machine
each had unrelated failures (`github-launcher.test.ts`,
`acpx-engine/execute.test.ts`). One run also failed a seed cache test
once; I could not reproduce that in 21 further runs, including 6 in
parallel.
- `pnpm exec vitest run native-workspace-sync` (in `server/`): 23
passed.
- `pnpm run typecheck` (in `server/`, after building
`packages/adapter-utils`): pass.
- Not run: server build and boot smoke.
## Risks
- **First publish after deploy:** on an instance with a full cache of
old seeds, it removes them, about 8 GB on the instance above. The
removal is a rename plus a recursive delete under the company lock.
- **Eviction during use:** a run that looked up a seed more than an hour
earlier and has not yet copied it could see it evicted. It would fail
that preparation and retry. In practice the copy follows the lookup
within the same preparation.
- **No new settings:** the 7-day and 1-hour thresholds are constants
exported as `WORKSPACE_SEED_CACHE_EVICTION`.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), 1M context, through Claude Code
with tool use (shell, file edits, tests, read-only measurements on a
live instance).
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
296 lines
17 KiB
TypeScript
296 lines
17 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import nodeFs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { publishWorkspaceSeedGeneration, readWorkspaceSeedGeneration } from "./workspace-seed-cache.js";
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
const promise = new Promise<T>(done => { resolve = done; });
|
|
return { promise, resolve };
|
|
}
|
|
|
|
const roots: string[] = [];
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks(); vi.unstubAllEnvs();
|
|
await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true })));
|
|
});
|
|
async function fixture() {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "seed-cache-quota-")); roots.push(root);
|
|
vi.stubEnv("PAPERCLIP_HOME", path.join(root, "home"));
|
|
const company = path.join(root, "company"), workspace = path.join(company, "workspace");
|
|
const archive = path.join(root, "run-seed.tar"); await fs.writeFile(archive, "run recovery bytes");
|
|
const seed = { workspaceArchivePath: archive };
|
|
return { root, company, workspace, archive, seed };
|
|
}
|
|
const generation = (index: number) => index.toString(16).padStart(64, "0");
|
|
|
|
it("stops new workspace generations at capacity while retaining reusable and per-run seeds", async () => {
|
|
const f = await fixture(); const options = { companyDirectory: f.company, limits: { workspaceGenerations: 1 } };
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, options)).toBe(true);
|
|
const prior = await readWorkspaceSeedGeneration(f.workspace, generation(1));
|
|
expect(prior).not.toBeNull();
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(2), f.seed, options)).toBe(false);
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, options)).toBe(true);
|
|
expect(await fs.readdir(f.workspace)).toEqual([generation(1)]);
|
|
expect(await fs.readFile(f.archive, "utf8")).toBe("run recovery bytes");
|
|
expect(await fs.readFile(prior!.workspaceArchivePath, "utf8")).toBe("run recovery bytes");
|
|
});
|
|
|
|
it.each(["workspaceBytes", "companyBytes"] as const)("counts archive and receipt bytes against %s without creating an empty workspace", async (limit) => {
|
|
const f = await fixture();
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, {
|
|
companyDirectory: f.company, limits: { [limit]: 4096 },
|
|
})).toBe(false);
|
|
expect(await fs.readdir(f.company)).toEqual([]);
|
|
expect(await fs.readFile(f.archive, "utf8")).toBe("run recovery bytes");
|
|
});
|
|
|
|
it.each(["companyGenerations", "companyWorkspaces"] as const)("serializes distinct-workspace publication against %s", async (limit) => {
|
|
const f = await fixture(); const options = { companyDirectory: f.company, limits: { [limit]: 1 } };
|
|
const results = await Promise.all(["first", "second"].map((name, index) =>
|
|
publishWorkspaceSeedGeneration(path.join(f.company, name), generation(index), f.seed, options)));
|
|
expect(results.sort()).toEqual([false, true]);
|
|
expect(await fs.readdir(f.company)).toHaveLength(1);
|
|
expect(await fs.readFile(f.archive, "utf8")).toBe("run recovery bytes");
|
|
});
|
|
|
|
it("applies the company byte cap across workspace roots", async () => {
|
|
const f = await fixture();
|
|
const options = { companyDirectory: f.company, limits: { companyBytes: 4500 } };
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, options)).toBe(true);
|
|
const secondArchive = path.join(f.root, "second-seed.tar"); await fs.writeFile(secondArchive, "x".repeat(1000));
|
|
expect(await publishWorkspaceSeedGeneration(path.join(f.company, "second"), generation(2), { workspaceArchivePath: secondArchive }, options)).toBe(false);
|
|
expect(await fs.readdir(f.company)).toEqual(["workspace"]);
|
|
});
|
|
|
|
it("serializes concurrent archive publication against the aggregate byte cap", async () => {
|
|
const f = await fixture(); await fs.writeFile(f.archive, "x".repeat(1024));
|
|
const options = { companyDirectory: f.company, limits: { companyBytes: 5500 } };
|
|
const results = await Promise.all(["first", "second"].map((name, index) =>
|
|
publishWorkspaceSeedGeneration(path.join(f.company, name), generation(index), f.seed, options)));
|
|
expect(results.sort()).toEqual([false, true]);
|
|
expect(await fs.readdir(f.company)).toHaveLength(1);
|
|
});
|
|
|
|
it("counts abandoned pending and pre-existing over-quota directories without deleting them", async () => {
|
|
const f = await fixture(); await fs.mkdir(path.join(f.workspace, ".pending-old"), { recursive: true });
|
|
await fs.writeFile(path.join(f.workspace, ".pending-old", "workspace.tar"), "retained");
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(2), f.seed, {
|
|
companyDirectory: f.company, limits: { workspaceGenerations: 0 },
|
|
})).toBe(false);
|
|
expect(await fs.readFile(path.join(f.workspace, ".pending-old", "workspace.tar"), "utf8")).toBe("retained");
|
|
expect(await fs.readdir(f.workspace)).toEqual([".pending-old"]);
|
|
});
|
|
|
|
it("reserves full-copy disk capacity and skips a cache write without losing recovery", async () => {
|
|
const f = await fixture();
|
|
vi.spyOn(nodeFs, "statfsSync").mockReturnValue({ bavail: 256n * 1024n * 1024n, bsize: 1n } as ReturnType<typeof nodeFs.statfsSync>);
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, { companyDirectory: f.company })).toBe(false);
|
|
expect(await fs.readdir(f.company)).toEqual([]);
|
|
expect(await fs.readFile(f.archive, "utf8")).toBe("run recovery bytes");
|
|
});
|
|
|
|
it("supports cross-device archive copies independently of the per-run seed", async () => {
|
|
const f = await fixture();
|
|
vi.spyOn(fs, "link").mockRejectedValue(Object.assign(new Error("cross-device"), { code: "EXDEV" }));
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, { companyDirectory: f.company })).toBe(true);
|
|
await fs.unlink(f.archive);
|
|
const cached = await readWorkspaceSeedGeneration(f.workspace, generation(1));
|
|
expect(await fs.readFile(cached!.workspaceArchivePath, "utf8")).toBe("run recovery bytes");
|
|
});
|
|
|
|
it("skips optional verification when capacity is exhausted", async () => {
|
|
const f = await fixture(); const verify = vi.fn(async () => true);
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, {
|
|
companyDirectory: f.company, limits: { workspaceGenerations: 0 }, verify,
|
|
})).toBe(false);
|
|
expect(verify).not.toHaveBeenCalled();
|
|
expect(await fs.readFile(f.archive, "utf8")).toBe("run recovery bytes");
|
|
});
|
|
|
|
it("treats optional verification storage failure as a cache miss", async () => {
|
|
const f = await fixture();
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, {
|
|
companyDirectory: f.company, verify: async () => { throw Object.assign(new Error("disk full"), { code: "ENOSPC" }); },
|
|
})).toBe(false);
|
|
expect(await fs.readdir(f.company)).toEqual([]);
|
|
expect(await fs.readFile(f.archive, "utf8")).toBe("run recovery bytes");
|
|
});
|
|
|
|
it("does not hold the company publication lock during slow verification", async () => {
|
|
const f = await fixture();
|
|
const entered = deferred<void>();
|
|
const release = deferred<boolean>();
|
|
const slow = publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, {
|
|
companyDirectory: f.company, verify: () => { entered.resolve(); return release.promise; },
|
|
});
|
|
try {
|
|
await entered.promise;
|
|
expect(await publishWorkspaceSeedGeneration(path.join(f.company, "second"), generation(2), f.seed,
|
|
{ companyDirectory: f.company })).toBe(true);
|
|
expect(await fs.readdir(f.company)).toEqual(["second"]);
|
|
} finally { release.resolve(true); await slow; }
|
|
expect(await readWorkspaceSeedGeneration(f.workspace, generation(1))).not.toBeNull();
|
|
});
|
|
|
|
it("rechecks aggregate capacity after concurrent verifications finish", async () => {
|
|
const f = await fixture();
|
|
const release = deferred<boolean>();
|
|
const entered = vi.fn(() => release.promise);
|
|
const publications = ["first", "second"].map((name, index) => publishWorkspaceSeedGeneration(
|
|
path.join(f.company, name), generation(index), f.seed,
|
|
{ companyDirectory: f.company, limits: { companyGenerations: 1 }, verify: entered },
|
|
));
|
|
try { await vi.waitFor(() => expect(entered).toHaveBeenCalledTimes(2)); }
|
|
finally { release.resolve(true); }
|
|
expect((await Promise.all(publications)).sort()).toEqual([false, true]);
|
|
expect(await fs.readdir(f.company)).toHaveLength(1);
|
|
expect(await fs.readFile(f.archive, "utf8")).toBe("run recovery bytes");
|
|
});
|
|
|
|
it("rejects a company directory replaced during verification", async () => {
|
|
const f = await fixture();
|
|
const replacement = path.join(f.root, "other-company"); await fs.mkdir(replacement);
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, {
|
|
companyDirectory: f.company, verify: async () => {
|
|
await fs.rename(f.company, `${f.company}-retained`);
|
|
await fs.symlink(replacement, f.company);
|
|
return true;
|
|
},
|
|
})).toBe(false);
|
|
expect(await fs.readdir(replacement)).toEqual([]);
|
|
expect(await fs.readFile(f.archive, "utf8")).toBe("run recovery bytes");
|
|
});
|
|
|
|
describe("eviction", () => {
|
|
const DAY = 24 * 60 * 60 * 1000;
|
|
async function lastUsed(directory: string, ms: number) {
|
|
await fs.utimes(path.join(directory, "receipt.json"), new Date(ms), new Date(ms));
|
|
}
|
|
|
|
it("expires generations unused for the idle period when publishing, and removes emptied workspaces", async () => {
|
|
const f = await fixture(); const now = Date.now(); const options = { companyDirectory: f.company, now: () => now };
|
|
const other = path.join(f.company, "other");
|
|
expect(await publishWorkspaceSeedGeneration(other, generation(1), f.seed, options)).toBe(true);
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(2), f.seed, options)).toBe(true);
|
|
await lastUsed(path.join(other, generation(1)), now - 8 * DAY);
|
|
await lastUsed(path.join(f.workspace, generation(2)), now - 6 * DAY);
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(3), f.seed, options)).toBe(true);
|
|
expect((await fs.readdir(f.company)).sort()).toEqual(["workspace"]);
|
|
expect((await fs.readdir(f.workspace)).sort()).toEqual([generation(2), generation(3)]);
|
|
expect(await fs.readFile(f.archive, "utf8")).toBe("run recovery bytes");
|
|
});
|
|
|
|
it("counts a cache hit as use, so a recently read generation is not expired", async () => {
|
|
const f = await fixture(); const options = { companyDirectory: f.company };
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, options)).toBe(true);
|
|
await lastUsed(path.join(f.workspace, generation(1)), Date.now() - 8 * DAY);
|
|
expect(await readWorkspaceSeedGeneration(f.workspace, generation(1))).not.toBeNull();
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(2), f.seed, options)).toBe(true);
|
|
expect((await fs.readdir(f.workspace)).sort()).toEqual([generation(1), generation(2)]);
|
|
});
|
|
|
|
it("evicts the least recently used generation of the workspace when it is full", async () => {
|
|
const f = await fixture(); const now = Date.now();
|
|
const options = { companyDirectory: f.company, limits: { workspaceGenerations: 2 }, now: () => now };
|
|
for (const index of [1, 2]) expect(await publishWorkspaceSeedGeneration(f.workspace, generation(index), f.seed, options)).toBe(true);
|
|
await lastUsed(path.join(f.workspace, generation(1)), now - 3 * 60 * 60 * 1000);
|
|
await lastUsed(path.join(f.workspace, generation(2)), now - 2 * 60 * 60 * 1000);
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(3), f.seed, options)).toBe(true);
|
|
expect((await fs.readdir(f.workspace)).sort()).toEqual([generation(2), generation(3)]);
|
|
});
|
|
|
|
it("evicts across workspaces under company pressure but never a generation used within the last hour", async () => {
|
|
const f = await fixture(); const now = Date.now();
|
|
const options = { companyDirectory: f.company, limits: { companyGenerations: 2 }, now: () => now };
|
|
const other = path.join(f.company, "other");
|
|
expect(await publishWorkspaceSeedGeneration(other, generation(1), f.seed, options)).toBe(true);
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(2), f.seed, options)).toBe(true);
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(3), f.seed, options)).toBe(false);
|
|
await lastUsed(path.join(other, generation(1)), now - 2 * 60 * 60 * 1000);
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(3), f.seed, options)).toBe(true);
|
|
expect((await fs.readdir(f.company)).sort()).toEqual(["workspace"]);
|
|
expect((await fs.readdir(f.workspace)).sort()).toEqual([generation(2), generation(3)]);
|
|
});
|
|
|
|
it("runs optional verification only when eviction can make room", async () => {
|
|
const f = await fixture(); const now = Date.now(); const verify = vi.fn(async () => true);
|
|
const options = { companyDirectory: f.company, limits: { workspaceGenerations: 1 }, now: () => now, verify };
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, options)).toBe(true);
|
|
verify.mockClear();
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(2), f.seed, options)).toBe(false);
|
|
expect(verify).not.toHaveBeenCalled();
|
|
await lastUsed(path.join(f.workspace, generation(1)), now - 2 * 60 * 60 * 1000);
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(2), f.seed, options)).toBe(true);
|
|
expect(verify).toHaveBeenCalledTimes(1);
|
|
expect(await fs.readdir(f.workspace)).toEqual([generation(2)]);
|
|
});
|
|
|
|
it("never evicts abandoned pending directories and skips verification when only they fill the cache", async () => {
|
|
const f = await fixture(); const now = Date.now(); const verify = vi.fn(async () => true);
|
|
await fs.mkdir(path.join(f.workspace, ".pending-old"), { recursive: true });
|
|
await fs.utimes(path.join(f.workspace, ".pending-old"), new Date(now - 30 * DAY), new Date(now - 30 * DAY));
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, {
|
|
companyDirectory: f.company, limits: { workspaceGenerations: 1 }, now: () => now, verify,
|
|
})).toBe(false);
|
|
expect(verify).not.toHaveBeenCalled();
|
|
expect(await fs.readdir(f.workspace)).toEqual([".pending-old"]);
|
|
});
|
|
|
|
it("never returns a cache hit that a concurrent eviction removes", async () => {
|
|
const f = await fixture(); const now = Date.now();
|
|
const options = { companyDirectory: f.company, limits: { workspaceGenerations: 1 }, now: () => now };
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, options)).toBe(true);
|
|
await lastUsed(path.join(f.workspace, generation(1)), now - 2 * 60 * 60 * 1000);
|
|
const paused = deferred<void>(), release = deferred<void>();
|
|
const rename = fs.rename.bind(fs);
|
|
vi.spyOn(fs, "rename").mockImplementation(async (from, to) => {
|
|
if (String(to).includes(".evicted-")) { paused.resolve(); await release.promise; }
|
|
return rename(from, to);
|
|
});
|
|
const publishing = publishWorkspaceSeedGeneration(f.workspace, generation(2), f.seed, options);
|
|
await paused.promise;
|
|
const reading = readWorkspaceSeedGeneration(f.workspace, generation(1), { companyDirectory: f.company });
|
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
release.resolve();
|
|
const [hit, published] = await Promise.all([reading, publishing]);
|
|
expect(published).toBe(true);
|
|
if (hit) expect(await fs.readFile(hit.workspaceArchivePath, "utf8")).toBe("run recovery bytes");
|
|
else await expect(fs.access(path.join(f.workspace, generation(1)))).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
|
|
it("does not evict from a company directory replaced during verification", async () => {
|
|
const f = await fixture(); const now = Date.now();
|
|
const options = { companyDirectory: f.company, limits: { workspaceGenerations: 1 }, now: () => now };
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, options)).toBe(true);
|
|
await lastUsed(path.join(f.workspace, generation(1)), now - 8 * DAY);
|
|
const replacement = path.join(f.root, "other-company");
|
|
await fs.cp(f.company, replacement, { recursive: true, preserveTimestamps: true });
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(2), f.seed, { ...options, verify: async () => {
|
|
await fs.rename(f.company, `${f.company}-retained`);
|
|
await fs.symlink(replacement, f.company);
|
|
return true;
|
|
} })).toBe(false);
|
|
expect(await fs.readdir(path.join(replacement, "workspace"))).toEqual([generation(1)]);
|
|
});
|
|
|
|
it("removes empty workspace directories so they do not hold workspace slots", async () => {
|
|
const f = await fixture(); await fs.mkdir(path.join(f.company, "abandoned"), { recursive: true });
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, {
|
|
companyDirectory: f.company, limits: { companyWorkspaces: 1 },
|
|
})).toBe(true);
|
|
expect(await fs.readdir(f.company)).toEqual(["workspace"]);
|
|
});
|
|
|
|
it("removes leftovers of an interrupted eviction", async () => {
|
|
const f = await fixture(); const options = { companyDirectory: f.company };
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, options)).toBe(true);
|
|
await fs.mkdir(path.join(f.workspace, ".evicted-leftover"));
|
|
await fs.writeFile(path.join(f.workspace, ".evicted-leftover", "workspace.tar"), "stale");
|
|
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(2), f.seed, options)).toBe(true);
|
|
expect((await fs.readdir(f.workspace)).sort()).toEqual([generation(1), generation(2)]);
|
|
});
|
|
});
|