fix: bound durable workspace seed cache publication

Enforce workspace and company generation, byte, and directory caps under one company lock. Cache saturation and free-space limits preserve per-run seeds and skip optional publication without deleting existing recovery evidence.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-10 07:56:02 -05:00
1 parent 0f8cea002a
commit fff60782d2
8 files changed
+223 -25

No files matched your search

+1 -1
View File
@@ -47,7 +47,7 @@ Concurrent remote sharing requires proven common physical placement, separate se
Sparse filesystem checkpoints use disk-backed full manifests, changed bytes/deletions, safe-link and existing ignore semantics, and separate nested Git history. Capture still enumerates and hashes files with stable-read validation; a metadata hash cache is deferred. No-change sends zero file-content payload where capability is supported; manifest metadata still transfers; unsupported transport uses explicit confined full fallback. Reuse immutable durable seed generations, not only warm sandbox contents; reuse still verifies archive digests. Existing run references pin seeds/deltas until all admitted runs/recovery obligations release them. Retain the final host rescan because the run ownership fence does not exclude external writers to shared folders; do not reuse a stale post-merge manifest.
Seed recovery deliberately uses **no delta chain** in this implementation. An unchanged generation reuses its verified content-addressed immutable full seed; a changed generation creates a fresh full seed. Sparse output transfer is separate from seed recovery. This revises the sketch's 16-delta compaction proposal: composing recovery generations would add a new failure surface before the simpler cache has been qualified. Existing references retain old seeds; no automatic retention/deletion policy is introduced. Changed input generations still pay full snapshot creation. New cache entries are reconstructed and checked against the admitted baseline before publication, so concurrent writes cannot label an archive as the wrong generation; this adds cold verification work. A corrupted cache entry remains a miss and falls back to a fresh seed; automatic cache repair and garbage collection are deferred. The existing Git seed contract captures the selected branch/HEAD, not unrelated refs.
Seed recovery deliberately uses **no delta chain** in this implementation. An unchanged generation reuses its verified content-addressed immutable full seed; a changed generation creates a fresh full seed. Sparse output transfer is separate from seed recovery. This revises the sketch's 16-delta compaction proposal: composing recovery generations would add a new failure surface before the simpler cache has been qualified. Existing references retain old seeds; no automatic eviction/deletion policy is introduced. Optional cache publication is capped at 16 generations/1 GiB per workspace and 256 generations/256 workspace directories/8 GiB per company. A company-scoped filesystem lock serializes quota checks and publication across workspace roots. Archive and receipt bytes, corrupt entries and abandoned pending generations count against capacity; publication also preserves the manifest storage free-space reserve (256 MiB by default), accounting for a full-copy fallback. Saturation or cache failure skips publication and keeps the already-written per-run recovery seed. Existing over-capacity caches stop growing without deleting admitted evidence. This deliberately trades cache hit rate after saturation for bounded additional storage and unchanged recovery semantics; no global cross-company quota is added. Changed input generations still pay full snapshot creation. New cache entries are reconstructed and checked against the admitted baseline before publication, so concurrent writes cannot label an archive as the wrong generation; this adds cold verification work. A corrupted cache entry remains a miss and falls back to a fresh seed; automatic cache repair and garbage collection are deferred. The existing Git seed contract captures the selected branch/HEAD, not unrelated refs.
Ordinary settlement remains host-durable before successful completion. Preserve export-only recovery and exact-source lease retention. Preserve the specific unsafe-export omission exception and its run-log-only diagnostic. Do not change global merge conflict semantics as a side effect.
@@ -547,6 +547,7 @@ export async function prepareCommandManagedRuntime(input: {
workspaceCheckpoint?: boolean;
onWorkspaceCheckpoint?: (metrics: WorkspaceCheckpointMetrics) => Promise<void>;
workspaceSeedCacheDirectory?: string;
workspaceSeedCacheCompanyDirectory?: string;
workspaceBaseline?: DirectorySnapshot;
workspaceGitSnapshot?: GitWorkspaceSnapshot | null;
workspaceRepositories?: NonNullable<GitWorkspaceSnapshot["repositories"]>;
@@ -619,6 +620,7 @@ export async function prepareCommandManagedRuntime(input: {
workspaceCheckpoint: input.workspaceCheckpoint,
onWorkspaceCheckpoint: input.onWorkspaceCheckpoint,
workspaceSeedCacheDirectory: input.workspaceSeedCacheDirectory,
workspaceSeedCacheCompanyDirectory: input.workspaceSeedCacheCompanyDirectory,
workspaceBaseline: input.workspaceBaseline,
workspaceGitSnapshot: input.workspaceGitSnapshot,
workspaceRepositories: input.workspaceRepositories,
@@ -668,6 +670,7 @@ export async function prepareCommandManagedRuntime(input: {
workspaceCheckpoint: input.workspaceCheckpoint,
onWorkspaceCheckpoint: input.onWorkspaceCheckpoint,
workspaceSeedCacheDirectory: input.workspaceSeedCacheDirectory,
workspaceSeedCacheCompanyDirectory: input.workspaceSeedCacheCompanyDirectory,
workspaceBaseline: input.workspaceBaseline,
workspaceGitSnapshot: input.workspaceGitSnapshot,
workspaceRepositories: input.workspaceRepositories,
@@ -1439,6 +1439,7 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
workspaceCheckpoint?: boolean;
onWorkspaceCheckpoint?: (metrics: WorkspaceCheckpointMetrics) => Promise<void>;
workspaceSeedCacheDirectory?: string;
workspaceSeedCacheCompanyDirectory?: string;
workspaceBaseline?: DirectorySnapshot;
workspaceGitSnapshot?: GitWorkspaceSnapshot | null;
workspaceRepositories?: NonNullable<GitWorkspaceSnapshot["repositories"]>;
@@ -1528,6 +1529,7 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
workspaceCheckpoint: input.workspaceCheckpoint,
onWorkspaceCheckpoint: input.onWorkspaceCheckpoint,
workspaceSeedCacheDirectory: input.workspaceSeedCacheDirectory,
workspaceSeedCacheCompanyDirectory: input.workspaceSeedCacheCompanyDirectory,
workspaceBaseline: input.workspaceBaseline,
workspaceGitSnapshot: input.workspaceGitSnapshot,
workspaceRepositories: input.workspaceRepositories,
@@ -1115,6 +1115,7 @@ export async function prepareSandboxManagedRuntime(input: {
workspaceCheckpoint?: boolean;
onWorkspaceCheckpoint?: (metrics: WorkspaceCheckpointMetrics) => Promise<void>;
workspaceSeedCacheDirectory?: string;
workspaceSeedCacheCompanyDirectory?: string;
/** Durable snapshots supplied when reconstructing an interrupted restore. */
workspaceBaseline?: DirectorySnapshot;
workspaceGitSnapshot?: GitWorkspaceSnapshot | null;
@@ -1568,7 +1569,7 @@ export async function prepareSandboxManagedRuntime(input: {
const verified = await captureDirectorySnapshot(verifiedRoot, { exclude: baselineSnapshot!.exclude, ignoredPaths: baselineSnapshot!.ignoredPaths, diskBacked: true });
try {
if (directorySnapshotSha256(verified) === directorySnapshotSha256(baselineSnapshot!)) {
await publishWorkspaceSeedGeneration(input.workspaceSeedCacheDirectory!, seedGeneration, { ...seed, gitArchivePath: gitSnapshot || repositories.length ? seed.gitArchivePath : null });
await publishWorkspaceSeedGeneration(input.workspaceSeedCacheDirectory!, seedGeneration, { ...seed, gitArchivePath: gitSnapshot || repositories.length ? seed.gitArchivePath : null }, { companyDirectory: input.workspaceSeedCacheCompanyDirectory });
}
} finally { await disposeDirectorySnapshot(verified); }
});
@@ -143,13 +143,13 @@ it("uses sparse native transfer end-to-end, including replay and unsupported-run
writeFile: (file: string, data: ArrayBuffer) => fs.writeFile(file, Buffer.from(data)), readFile: (file: string) => fs.readFile(file),
listFiles: (dir: string) => fs.readdir(dir), remove: (file: string) => fs.rm(file, { force: true, recursive: true }), run,
syncIn: sync, syncOut: sync };
const prepare = () => prepareSandboxManagedRuntime({ spec: { provider: "test", sandboxId: "test", remoteCwd: remote, timeoutMs: 30000, apiKey: null, transport: "sandbox" },
const prepare = (overrides: Partial<Parameters<typeof prepareSandboxManagedRuntime>[0]> = {}) => prepareSandboxManagedRuntime({ spec: { provider: "test", sandboxId: "test", remoteCwd: remote, timeoutMs: 30000, apiKey: null, transport: "sandbox" },
adapterKey: "test", client, workspaceLocalDir: f.root, workspaceCheckpoint: true,
runtimeSpan: async (name, work) => {
if (mutateDuringPack && name === "pack") await fs.writeFile(path.join(f.root, "unchanged"), "concurrent change");
return work();
},
workspaceSeedCacheDirectory: cache, workspaceDurableSeed: { workspaceArchivePath: path.join(f.temp, "run-seed.tar") } });
workspaceSeedCacheDirectory: cache, workspaceDurableSeed: { workspaceArchivePath: path.join(f.temp, "run-seed.tar") }, ...overrides });
const first = await prepare(); await first.restoreWorkspace(); expect(payloadBytes).toBe(0);
const generation = (await fs.readdir(cache))[0]!;
await fs.writeFile(path.join(cache, generation, "workspace.tar"), "corrupt cache");
@@ -176,4 +176,22 @@ it("uses sparse native transfer end-to-end, including replay and unsupported-run
expect(await fs.readdir(cache)).toEqual(existingGenerations);
expect((await fs.stat(path.join(f.temp, "run-seed.tar"))).size).toBeGreaterThan(0);
await raced.cleanupWorkspaceSnapshot();
// A saturated optional cache must still admit and recover from its per-run seed.
mutateDuringPack = false;
for (let count = (await fs.readdir(cache)).length; count < 16; count++) {
await fs.mkdir(path.join(cache, `.pending-retained-${count}`));
}
const saturated = await fs.readdir(cache);
await fs.writeFile(path.join(f.root, "new"), "quota recovery");
const uncached = await prepare();
expect(await fs.readdir(cache)).toEqual(saturated);
const snapshot = uncached.workspaceSyncSnapshot!;
await fs.writeFile(path.join(f.root, "new"), "later host edit");
await fs.rm(remote, { recursive: true, force: true });
const recovered = await prepare({ workspaceInboundMode: "durable_seed", workspaceBaseline: snapshot.baseline,
workspaceGitSnapshot: snapshot.gitSnapshot, workspaceRepositories: snapshot.repositories });
expect(await fs.readFile(path.join(remote, "new"), "utf8")).toBe("quota recovery");
expect(await fs.readFile(path.join(f.root, "new"), "utf8")).toBe("later host edit");
expect(await fs.readdir(cache)).toEqual(saturated);
await recovered.cleanupWorkspaceSnapshot();
}, 30000);
@@ -0,0 +1,96 @@
import fs from "node:fs/promises";
import nodeFs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { publishWorkspaceSeedGeneration, readWorkspaceSeedGeneration } from "./workspace-seed-cache.js";
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");
});
@@ -2,7 +2,8 @@ import fs from "node:fs/promises";
import path from "node:path";
import { createHash, randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { directorySnapshotSha256, type DirectorySnapshot } from "./workspace-restore-merge.js";
import { directorySnapshotSha256, withDirectoryMergeLock, type DirectorySnapshot } from "./workspace-restore-merge.js";
import { assertWorkspaceManifestDiskSpace } from "./workspace-manifest.js";
import type { GitWorkspaceSnapshot } from "./git-workspace-sync.js";
import type { WorkspaceDurableSeedPaths } from "./sandbox-managed-runtime.js";
@@ -20,7 +21,7 @@ async function digest(file: string): Promise<string> {
}
/** Cache is durable but never authority: every archive is checked before reuse.
* Admitted runs retain their own hard link, independent of cache cleanup. */
* Admitted runs retain independent per-run archives; cache entries are never evicted. */
export async function readWorkspaceSeedGeneration(root: string, generation: string): Promise<WorkspaceDurableSeedPaths | null> {
const directory = path.join(root, generation);
try {
@@ -42,25 +43,99 @@ export async function readWorkspaceSeedGeneration(root: string, generation: stri
gitArchivePath, gitArchiveSha256: metadata.gitSha256 };
} catch { return null; }
}
export async function publishWorkspaceSeedGeneration(root: string, generation: string, seed: WorkspaceDurableSeedPaths): Promise<void> {
await fs.mkdir(root, { recursive: true, mode: 0o700 });
const rootStat = await fs.lstat(root);
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error("workspace_seed_cache_root_invalid");
const temporary = path.join(root, `.pending-${randomUUID()}`);
await fs.mkdir(temporary, { mode: 0o700 });
export const WORKSPACE_SEED_CACHE_LIMITS = Object.freeze({
workspaceGenerations: 16,
workspaceBytes: 1024 ** 3,
companyGenerations: 256,
companyWorkspaces: 256,
companyBytes: 8 * 1024 ** 3,
});
type SeedCacheLimits = { [Key in keyof typeof WORKSPACE_SEED_CACHE_LIMITS]: number };
/** Count even abandoned pending entries and corrupt archives against capacity.
* Never follow links or recurse into an unexpected cache layout. */
async function cacheUsage(root: string, limits: SeedCacheLimits): Promise<{ generations: number; bytes: number } | null> {
let generations = 0, bytes = 0;
for await (const entry of await fs.opendir(root)) {
if (++generations > limits.companyGenerations) return null;
const directory = path.join(root, entry.name);
const stat = await fs.lstat(directory);
if (!stat.isDirectory() || stat.isSymbolicLink()) return null;
let files = 0;
for await (const child of await fs.opendir(directory)) {
if (++files > 3) return null;
const file = await fs.lstat(path.join(directory, child.name));
if (!file.isFile() || file.isSymbolicLink()) return null;
bytes += file.size;
if (bytes > limits.companyBytes) return null;
}
}
return { generations, bytes };
}
/** Optional optimization: saturation or cache failure leaves per-run recovery
* untouched. No eviction: readers may still be using a returned cache path. */
export async function publishWorkspaceSeedGeneration(
root: string, generation: string, seed: WorkspaceDurableSeedPaths,
options: { companyDirectory?: string; limits?: Partial<SeedCacheLimits> } = {},
): Promise<boolean> {
const limits = { ...WORKSPACE_SEED_CACHE_LIMITS, ...options.limits };
if (!/^[a-f0-9]{64}$/.test(generation)
|| Object.values(limits).some(value => !Number.isSafeInteger(value) || value < 0)) return false;
root = path.resolve(root);
const company = path.resolve(options.companyDirectory ?? root);
if (root !== company && path.dirname(root) !== company) return false;
try {
const copy = async (source: string, name: string) => {
const target = path.join(temporary, name);
await fs.link(source, target).catch(() => fs.copyFile(source, target));
const file = await fs.open(target, "r");
try { await file.sync(); } finally { await file.close(); }
return digest(target);
};
const workspaceSha256 = await copy(seed.workspaceArchivePath, "workspace.tar");
const gitSha256 = seed.gitArchivePath ? await copy(seed.gitArchivePath, "git.tar") : null;
await fs.writeFile(path.join(temporary, "receipt.json"), JSON.stringify({ version: 1, generation, workspaceSha256, gitSha256 }));
await fs.rename(temporary, path.join(root, generation)).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "EEXIST" && error.code !== "ENOTEMPTY") throw error;
await fs.mkdir(company, { recursive: true, mode: 0o700 });
const companyStat = await fs.lstat(company);
if (!companyStat.isDirectory() || companyStat.isSymbolicLink()) return false;
return await withDirectoryMergeLock(company, async () => {
let totalGenerations = 0, totalBytes = 0, workspaces = 0;
let workspace = { generations: 0, bytes: 0 };
let workspaceExists = false;
const directories = root === company ? [{ name: "" }] : await fs.opendir(company);
for await (const { name } of directories) {
if (++workspaces > limits.companyWorkspaces) return false;
const directory = root === company ? company : path.join(company, name);
const stat = await fs.lstat(directory);
if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
const usage = await cacheUsage(directory, limits);
if (!usage) return false;
totalGenerations += usage.generations; totalBytes += usage.bytes;
if (totalGenerations > limits.companyGenerations || totalBytes > limits.companyBytes) return false;
if (directory === root) { workspace = usage; workspaceExists = true; }
}
// An existing immutable entry adds no storage, including when saturated.
if (workspaceExists && await fs.lstat(path.join(root, generation)).then(() => true, () => false)) return true;
let incomingBytes = 4096; // conservative receipt allowance
for (const archive of [seed.workspaceArchivePath, seed.gitArchivePath]) {
if (!archive) continue;
const stat = await fs.lstat(archive);
if (!stat.isFile() || stat.isSymbolicLink()) return false;
incomingBytes += stat.size;
}
if (workspace.generations + 1 > limits.workspaceGenerations || workspace.bytes + incomingBytes > limits.workspaceBytes
|| totalGenerations + 1 > limits.companyGenerations || totalBytes + incomingBytes > limits.companyBytes
|| workspaces + (workspaceExists ? 0 : 1) > limits.companyWorkspaces) return false;
// Reserve enough for a full copy even when hard links are unavailable.
assertWorkspaceManifestDiskSpace(company, incomingBytes);
await fs.mkdir(root, { recursive: true, mode: 0o700 });
const temporary = path.join(root, `.pending-${randomUUID()}`);
await fs.mkdir(temporary, { mode: 0o700 });
try {
const copy = async (source: string, name: string) => {
const target = path.join(temporary, name);
await fs.link(source, target).catch(() => fs.copyFile(source, target));
const file = await fs.open(target, "r");
try { await file.sync(); } finally { await file.close(); }
return digest(target);
};
const workspaceSha256 = await copy(seed.workspaceArchivePath, "workspace.tar");
const gitSha256 = seed.gitArchivePath ? await copy(seed.gitArchivePath, "git.tar") : null;
await fs.writeFile(path.join(temporary, "receipt.json"), JSON.stringify({ version: 1, generation, workspaceSha256, gitSha256 }));
await fs.rename(temporary, path.join(root, generation));
return true;
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
});
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
} catch { return false; }
}
@@ -790,6 +790,7 @@ async function prepareRuntime(input: {
repositories?: NonNullable<GitWorkspaceSnapshot["repositories"]>;
durableSeed?: WorkspaceDurableSeedPaths;
seedCacheDirectory?: string;
seedCacheCompanyDirectory?: string;
onPhase?: RuntimeSpanRunner;
onCheckpoint?: (metrics: WorkspaceCheckpointMetrics) => Promise<void>;
}): Promise<PreparedAdapterExecutionTargetRuntime> {
@@ -807,6 +808,7 @@ async function prepareRuntime(input: {
workspaceCheckpoint: true,
onWorkspaceCheckpoint: input.onCheckpoint,
workspaceSeedCacheDirectory: input.seedCacheDirectory,
workspaceSeedCacheCompanyDirectory: input.seedCacheCompanyDirectory,
runtimeSpan: input.onPhase,
});
}
@@ -976,6 +978,7 @@ export async function prepareNativeWorkspaceSync(input: {
onCheckpoint: input.onCheckpoint,
workspaceLocalDir: input.workspaceLocalDir,
mode,
seedCacheCompanyDirectory: path.join(resolvePaperclipInstanceRoot(), STATE_ROOT_NAME, "generations", requireSafeSegment(input.companyId, "company_id")),
seedCacheDirectory: path.join(resolvePaperclipInstanceRoot(), STATE_ROOT_NAME, "generations", requireSafeSegment(input.companyId, "company_id"), requireSafeSegment(input.workspaceId, "workspace_id")),
durableSeed: {
workspaceArchivePath: seedPaths.workspaceArchivePath,