mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
Preserve empty-inventory admission and immutable recovery. Reserve managed or anonymous roots before acquisition and fence legacy recovery using its verified host binding. Co-Authored-By: Paperclip <noreply@paperclip.ing>
382 lines
15 KiB
TypeScript
382 lines
15 KiB
TypeScript
import { mkdtemp, readdir, rm, mkdir, writeFile, readFile, appendFile, symlink, stat } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
directorySnapshotSha256,
|
|
serializeDirectorySnapshot,
|
|
captureDirectorySnapshot,
|
|
disposeDirectorySnapshot,
|
|
} from "@paperclipai/adapter-utils/workspace-restore-merge";
|
|
|
|
import {
|
|
classifyNativeWorkspaceInbound,
|
|
nativeWorkspaceSyncInternals,
|
|
readNativeWorkspaceSyncReference,
|
|
readNativeWorkspaceSyncLocalCwd,
|
|
resumeNativeWorkspaceSync,
|
|
prepareNativeWorkspaceSync,
|
|
cleanupNativeWorkspaceSync,
|
|
} from "../services/native-runtime/native-workspace-sync.js";
|
|
|
|
const digest = "a".repeat(64);
|
|
|
|
describe("native workspace sync durable metadata", () => {
|
|
const originalPaperclipHome = process.env.PAPERCLIP_HOME;
|
|
const originalPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
|
|
const cleanupDirs: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
if (originalPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
|
|
else process.env.PAPERCLIP_HOME = originalPaperclipHome;
|
|
if (originalPaperclipInstanceId === undefined)
|
|
delete process.env.PAPERCLIP_INSTANCE_ID;
|
|
else process.env.PAPERCLIP_INSTANCE_ID = originalPaperclipInstanceId;
|
|
await Promise.all(
|
|
cleanupDirs
|
|
.splice(0)
|
|
.map((directory) => rm(directory, { recursive: true, force: true })),
|
|
);
|
|
});
|
|
|
|
it("classifies fresh, warm, replacement, and same-run recovery inputs", () => {
|
|
expect(
|
|
classifyNativeWorkspaceInbound({
|
|
kind: "new_run",
|
|
acquisition: "created",
|
|
hasPriorStamp: false,
|
|
}),
|
|
).toBe("host_current");
|
|
expect(
|
|
classifyNativeWorkspaceInbound({
|
|
kind: "new_run",
|
|
acquisition: "resumed",
|
|
hasPriorStamp: true,
|
|
}),
|
|
).toBe("adopt_remote");
|
|
expect(
|
|
classifyNativeWorkspaceInbound({
|
|
kind: "new_run",
|
|
acquisition: "resumed",
|
|
hasPriorStamp: false,
|
|
}),
|
|
).toBe("host_current");
|
|
expect(
|
|
classifyNativeWorkspaceInbound({
|
|
kind: "new_run",
|
|
acquisition: "replacement",
|
|
hasPriorStamp: true,
|
|
}),
|
|
).toBe("host_current");
|
|
expect(
|
|
classifyNativeWorkspaceInbound({
|
|
kind: "existing_run",
|
|
restartRecovery: true,
|
|
sameProviderLease: true,
|
|
}),
|
|
).toBe("adopt_remote");
|
|
expect(
|
|
classifyNativeWorkspaceInbound({
|
|
kind: "existing_run",
|
|
restartRecovery: true,
|
|
sameProviderLease: false,
|
|
}),
|
|
).toBe("durable_seed");
|
|
expect(
|
|
classifyNativeWorkspaceInbound({
|
|
kind: "existing_run",
|
|
restartRecovery: false,
|
|
sameRunRecovery: true,
|
|
sameProviderLease: true,
|
|
}),
|
|
).toBe("adopt_remote");
|
|
expect(() =>
|
|
classifyNativeWorkspaceInbound({
|
|
kind: "existing_run",
|
|
restartRecovery: false,
|
|
sameProviderLease: true,
|
|
}),
|
|
).toThrow("native_workspace_sync_unexpected_existing_descriptor");
|
|
});
|
|
|
|
it("refuses a different sandbox before preparing restart workspace sync", async () => {
|
|
const select = vi.fn();
|
|
await expect(prepareNativeWorkspaceSync({
|
|
db: { select } as never, runId: "run", companyId: "company", workspaceId: "workspace",
|
|
workspaceLocalDir: "/tmp/local-workspace", lease: { id: "lease", providerLeaseId: "replacement" } as never,
|
|
target: { kind: "remote", transport: "sandbox", remoteCwd: "/workspace", providerKey: "daytona",
|
|
sandboxLeaseAcquisition: { outcome: "replacement", providerLeaseId: "replacement" } } as never,
|
|
restartRecovery: { kind: "reattach_remote_runner", runId: "run", leaseOwner: "controller", controllerGeneration: 2,
|
|
providerAttempt: 1, restartKind: "graceful", recoveryRequestId: null,
|
|
remote: { providerLeaseId: "original", remoteCwd: "/workspace" } },
|
|
})).rejects.toThrow("native_remote_recovery_lease_mismatch");
|
|
expect(select).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reads backward-compatible references and the resource disposition", () => {
|
|
const base = {
|
|
schema: "paperclip.native-workspace-sync/v1",
|
|
state: "prepared",
|
|
descriptorSha256: digest,
|
|
baselineSha256: digest,
|
|
finalHostSha256: null,
|
|
workspaceId: "workspace-1",
|
|
leaseId: "lease-1",
|
|
providerLeaseId: "sandbox-1",
|
|
remoteCwd: "/workspace",
|
|
};
|
|
|
|
expect(readNativeWorkspaceSyncReference(base)).toEqual({
|
|
...base,
|
|
resourceDisposition: null,
|
|
});
|
|
expect(
|
|
readNativeWorkspaceSyncReference({
|
|
...base,
|
|
resourceDisposition: "keep_running",
|
|
}),
|
|
).toEqual({ ...base, resourceDisposition: "keep_running" });
|
|
expect(
|
|
readNativeWorkspaceSyncReference({
|
|
...base,
|
|
resourceDisposition: "delete_everything",
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("rejects traversal before constructing a durable state path", () => {
|
|
expect(() =>
|
|
nativeWorkspaceSyncInternals.descriptorPath("../run", digest),
|
|
).toThrow("native_workspace_sync_invalid_run_id");
|
|
expect(() =>
|
|
nativeWorkspaceSyncInternals.descriptorPath("run-1", "../descriptor"),
|
|
).toThrow("native_workspace_sync_descriptor_digest_invalid");
|
|
});
|
|
|
|
it.each(["corrupt", "symlink", "foreign"] as const)("persists compact v2 manifests and rejects %s recovery storage", async (tamper) => {
|
|
const paperclipHome = await mkdtemp(path.join(os.tmpdir(), "paperclip-native-manifest-"));
|
|
cleanupDirs.push(paperclipHome);
|
|
process.env.PAPERCLIP_HOME = paperclipHome;
|
|
process.env.PAPERCLIP_INSTANCE_ID = "manifest-test";
|
|
const workspace = path.join(paperclipHome, "workspace");
|
|
await mkdir(workspace);
|
|
await writeFile(path.join(workspace, "private-filename-雪"), "baseline");
|
|
const baseline = await captureDirectorySnapshot(workspace, { diskBacked: true });
|
|
const initial = serializeDirectorySnapshot(baseline);
|
|
if (initial.version !== 2) throw new Error("Expected a disk baseline");
|
|
const scratch = initial.entries.filePath;
|
|
const runId = "manifest-run";
|
|
const stateDir = path.dirname(nativeWorkspaceSyncInternals.durableSeedPaths(runId).workspaceArchivePath);
|
|
await mkdir(stateDir, { recursive: true });
|
|
await nativeWorkspaceSyncInternals.persistSnapshotManifests(runId, { baseline, gitSnapshot: null });
|
|
await expect(stat(scratch)).rejects.toMatchObject({ code: "ENOENT" });
|
|
const serialized = serializeDirectorySnapshot(baseline);
|
|
if (serialized.version !== 2) throw new Error("Expected v2");
|
|
const descriptor = {
|
|
schema: "paperclip.native-workspace-sync/v2" as const,
|
|
binding: { runId, companyId: "company", workspaceId: "workspace", leaseId: "lease", providerLeaseId: "sandbox", localCwd: workspace, remoteCwd: "/workspace" },
|
|
state: "prepared" as const, baselineSha256: directorySnapshotSha256(baseline),
|
|
baseline: serialized, gitSnapshot: null, seed: null,
|
|
createdAt: new Date().toISOString(), finalizedAt: null, finalHostSha256: null, resourceDisposition: null,
|
|
};
|
|
let reference = await nativeWorkspaceSyncInternals.writeDescriptor(descriptor);
|
|
const raw = await readFile(nativeWorkspaceSyncInternals.descriptorPath(runId, reference.descriptorSha256), "utf8");
|
|
expect(raw.length).toBeLessThan(4096);
|
|
expect(raw).not.toContain("private-filename");
|
|
const recovered = await nativeWorkspaceSyncInternals.readDescriptor({ runId, reference });
|
|
expect(recovered.baseline.entries.get("private-filename-雪")).toMatchObject({ kind: "file" });
|
|
await disposeDirectorySnapshot(recovered.baseline);
|
|
await expect(readNativeWorkspaceSyncLocalCwd({ runId, companyId: "company", workspaceId: "workspace", reference })).resolves.toBe(workspace);
|
|
await expect(readNativeWorkspaceSyncLocalCwd({ runId, companyId: "other-company", workspaceId: "workspace", reference })).rejects.toThrow(/binding_mismatch/);
|
|
await expect(readNativeWorkspaceSyncLocalCwd({ runId, companyId: "company", workspaceId: "other-workspace", reference })).rejects.toThrow(/binding_mismatch/);
|
|
if (tamper === "corrupt") {
|
|
await appendFile(serialized.entries.filePath, "corrupt");
|
|
} else if (tamper === "symlink") {
|
|
const copy = path.join(paperclipHome, "copied.sqlite");
|
|
await writeFile(copy, await readFile(serialized.entries.filePath));
|
|
await rm(serialized.entries.filePath);
|
|
await symlink(copy, serialized.entries.filePath);
|
|
} else {
|
|
descriptor.baseline.entries.filePath = path.join(paperclipHome, "foreign.sqlite");
|
|
reference = await nativeWorkspaceSyncInternals.writeDescriptor(descriptor);
|
|
}
|
|
await expect(nativeWorkspaceSyncInternals.readDescriptor({ runId, reference })).rejects.toThrow();
|
|
await cleanupNativeWorkspaceSync(runId);
|
|
await expect(stat(stateDir)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
|
|
it.each([false, true])("writes one immutable descriptor when the same state is replayed (multiple repositories: %s)", async (multipleRepositories) => {
|
|
const paperclipHome = await mkdtemp(
|
|
path.join(os.tmpdir(), "paperclip-native-workspace-sync-"),
|
|
);
|
|
cleanupDirs.push(paperclipHome);
|
|
process.env.PAPERCLIP_HOME = paperclipHome;
|
|
process.env.PAPERCLIP_INSTANCE_ID = "descriptor-test";
|
|
const baseline = {
|
|
exclude: [".paperclip-runtime"],
|
|
entries: new Map([
|
|
[
|
|
"continuity.txt",
|
|
{ kind: "file" as const, mode: 0o644, hash: digest },
|
|
],
|
|
]),
|
|
};
|
|
const descriptor = {
|
|
schema: "paperclip.native-workspace-sync/v1" as const,
|
|
binding: {
|
|
runId: "run-idempotent",
|
|
companyId: "company-1",
|
|
workspaceId: "workspace-1",
|
|
leaseId: "lease-1",
|
|
providerLeaseId: "sandbox-1",
|
|
localCwd: path.join(paperclipHome, "workspace"),
|
|
remoteCwd: "/workspace",
|
|
},
|
|
state: "prepared" as const,
|
|
baselineSha256: directorySnapshotSha256(baseline),
|
|
baseline: serializeDirectorySnapshot(baseline),
|
|
gitSnapshot: multipleRepositories ? {
|
|
headCommit: "a".repeat(40), branchName: "main", overlayPaths: [], deletedPaths: [], ignoredPaths: [],
|
|
repositories: [{ path: ".paperclip-repositories/backend", snapshot: {
|
|
headCommit: "b".repeat(40), branchName: "backend-work", overlayPaths: ["dirty.txt"], deletedPaths: [], ignoredPaths: ["secret.txt"],
|
|
} }],
|
|
} : null,
|
|
seed: null,
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
finalizedAt: null,
|
|
finalHostSha256: null,
|
|
resourceDisposition: "keep_running" as const,
|
|
};
|
|
|
|
const first =
|
|
await nativeWorkspaceSyncInternals.writeDescriptor(descriptor);
|
|
const second =
|
|
await nativeWorkspaceSyncInternals.writeDescriptor(descriptor);
|
|
|
|
expect(second).toEqual(first);
|
|
const files = await readdir(
|
|
path.dirname(
|
|
nativeWorkspaceSyncInternals.descriptorPath(
|
|
descriptor.binding.runId,
|
|
first.descriptorSha256,
|
|
),
|
|
),
|
|
);
|
|
expect(files.filter((file) => file.endsWith(".json"))).toEqual([
|
|
`descriptor.${first.descriptorSha256}.json`,
|
|
]);
|
|
await expect(
|
|
nativeWorkspaceSyncInternals.readDescriptor({
|
|
runId: descriptor.binding.runId,
|
|
reference: first,
|
|
}),
|
|
).resolves.toMatchObject({ descriptor });
|
|
});
|
|
|
|
it("repairs finalized remote and lease stamps after an interrupted commit", async () => {
|
|
const paperclipHome = await mkdtemp(
|
|
path.join(os.tmpdir(), "paperclip-native-workspace-sync-repair-"),
|
|
);
|
|
cleanupDirs.push(paperclipHome);
|
|
process.env.PAPERCLIP_HOME = paperclipHome;
|
|
process.env.PAPERCLIP_INSTANCE_ID = "descriptor-repair-test";
|
|
const baseline = {
|
|
exclude: [".paperclip-runtime"],
|
|
entries: new Map([
|
|
[
|
|
"continuity.txt",
|
|
{ kind: "file" as const, mode: 0o644, hash: digest },
|
|
],
|
|
]),
|
|
};
|
|
const finalHostSha256 = "b".repeat(64);
|
|
const descriptor = {
|
|
schema: "paperclip.native-workspace-sync/v1" as const,
|
|
binding: {
|
|
runId: "run-finalized-repair",
|
|
companyId: "company-1",
|
|
workspaceId: "workspace-1",
|
|
leaseId: "lease-1",
|
|
providerLeaseId: "sandbox-1",
|
|
localCwd: path.join(paperclipHome, "workspace"),
|
|
remoteCwd: "/workspace",
|
|
},
|
|
state: "finalized" as const,
|
|
baselineSha256: directorySnapshotSha256(baseline),
|
|
baseline: serializeDirectorySnapshot(baseline),
|
|
gitSnapshot: null,
|
|
seed: null,
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
finalizedAt: "2026-01-01T00:01:00.000Z",
|
|
finalHostSha256,
|
|
resourceDisposition: "keep_running" as const,
|
|
};
|
|
const reference =
|
|
await nativeWorkspaceSyncInternals.writeDescriptor(descriptor);
|
|
const rows = (values: unknown[]) => {
|
|
const query = {
|
|
from: () => query,
|
|
where: () => query,
|
|
for: () => query,
|
|
limit: () => query,
|
|
then: <TResult1 = unknown, TResult2 = never>(
|
|
onfulfilled?:
|
|
((value: unknown[]) => TResult1 | PromiseLike<TResult1>) | null,
|
|
onrejected?:
|
|
((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
|
) => Promise.resolve(values).then(onfulfilled, onrejected),
|
|
};
|
|
return query;
|
|
};
|
|
let persistedLeaseMetadata: Record<string, unknown> | null = null;
|
|
const db = {
|
|
select: () =>
|
|
rows([{ runnerProfileJson: { nativeWorkspaceSync: reference } }]),
|
|
transaction: async (callback: (tx: unknown) => Promise<unknown>) =>
|
|
callback({
|
|
select: () => rows([{ metadata: { retained: true } }]),
|
|
update: () => ({
|
|
set: (value: { metadata: Record<string, unknown> }) => ({
|
|
where: async () => {
|
|
persistedLeaseMetadata = value.metadata;
|
|
},
|
|
}),
|
|
}),
|
|
}),
|
|
};
|
|
const execute = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ timedOut: false, exitCode: 1, stdout: "" })
|
|
.mockResolvedValueOnce({ timedOut: false, exitCode: 0, stdout: "" });
|
|
|
|
await expect(
|
|
resumeNativeWorkspaceSync({
|
|
db: db as never,
|
|
runId: descriptor.binding.runId,
|
|
target: {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
remoteCwd: descriptor.binding.remoteCwd,
|
|
sandboxLeaseAcquisition: {
|
|
providerLeaseId: descriptor.binding.providerLeaseId,
|
|
},
|
|
runner: { execute },
|
|
} as never,
|
|
}),
|
|
).resolves.toBe(true);
|
|
|
|
expect(execute).toHaveBeenCalledTimes(2);
|
|
expect(persistedLeaseMetadata).toMatchObject({
|
|
retained: true,
|
|
nativeWorkspaceSync: {
|
|
schema: "paperclip.native-workspace-stamp/v1",
|
|
workspaceId: descriptor.binding.workspaceId,
|
|
providerLeaseId: descriptor.binding.providerLeaseId,
|
|
remoteCwd: descriptor.binding.remoteCwd,
|
|
hostSha256: finalHostSha256,
|
|
finalizedRunId: descriptor.binding.runId,
|
|
},
|
|
});
|
|
});
|
|
});
|