fix: preserve restored setup and bind native sync to sandbox home
Record checkpoint intent before mutations and permit CI-owned lock resolution when building a staging migrator. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
b11cb8b5f8
commit
5f2b9742eb
|
|
@ -17,12 +17,19 @@ export function previewIdentity(sha, date, artifactBaseUrl) {
|
|||
baseUrl: `${base.href.replace(/\/$/, "")}/${sha}` };
|
||||
}
|
||||
|
||||
export function assertPreviewSourceClean(repo) {
|
||||
// Repository policy regenerates the lock in CI for manifest-only branches.
|
||||
// That generated input is allowed; every tracked source input must still
|
||||
// match the commit identifying both the app image and migrator artifact.
|
||||
execFileSync("git", ["diff", "--quiet", "HEAD", "--", ".", ":(exclude)pnpm-lock.yaml"], { cwd: repo });
|
||||
}
|
||||
|
||||
export function buildPreviewMigrator(outputDirectory, artifactBaseUrl) {
|
||||
const repo = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const git = (...args) => execFileSync("git", args, { cwd: repo, encoding: "utf8" }).trim();
|
||||
const sha = git("rev-parse", "HEAD");
|
||||
if (process.env.GITHUB_SHA && process.env.GITHUB_SHA !== sha) throw new Error("Preview checkout differs from the workflow commit");
|
||||
git("diff", "--quiet", "HEAD");
|
||||
assertPreviewSourceClean(repo);
|
||||
const identity = previewIdentity(sha, new Date(git("show", "-s", "--format=%cI", "HEAD")), artifactBaseUrl);
|
||||
execFileSync("pnpm", ["--filter", "@paperclipai/db...", "build"], { cwd: repo, stdio: "inherit" });
|
||||
const output = path.resolve(outputDirectory);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,28 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { previewIdentity } from "./build-preview-migrator.mjs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { assertPreviewSourceClean, previewIdentity } from "./build-preview-migrator.mjs";
|
||||
|
||||
test("preview source identity permits CI lock resolution but rejects staged and unstaged source drift", () => {
|
||||
const repo = mkdtempSync(path.join(os.tmpdir(), "preview-source-"));
|
||||
const git = (...args) => execFileSync("git", args, { cwd: repo, stdio: "pipe" });
|
||||
try {
|
||||
git("init");
|
||||
for (const file of ["pnpm-lock.yaml", "package.json"]) writeFileSync(path.join(repo, file), "initial\n");
|
||||
git("add", ".");
|
||||
git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "initial");
|
||||
assertPreviewSourceClean(repo);
|
||||
writeFileSync(path.join(repo, "pnpm-lock.yaml"), "CI resolved\n");
|
||||
assertPreviewSourceClean(repo);
|
||||
writeFileSync(path.join(repo, "package.json"), "source drift\n");
|
||||
assert.throws(() => assertPreviewSourceClean(repo));
|
||||
git("add", "package.json");
|
||||
assert.throws(() => assertPreviewSourceClean(repo));
|
||||
} finally { rmSync(repo, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("preview artifact identity is immutable, namespaced, and ordered by commit time", () => {
|
||||
const sha = "2f42a4968d5761fd62172e35ecf8188195b8d431";
|
||||
|
|
|
|||
|
|
@ -83,14 +83,14 @@ async function buildSandboxTarget(input: {
|
|||
environment: { id: "env-1", driver: "sandbox", config: { provider: "daytona" } },
|
||||
leaseId: "lease-1",
|
||||
leaseMetadata: { remoteCwd: "/work" },
|
||||
lease: { id: "lease-1", leasePolicy: "reuse_by_environment" } as never,
|
||||
lease: { id: "lease-1", leasePolicy: "reuse_by_environment", metadata: { remoteCwd: "/work", marker: "preserved" } } as never,
|
||||
environmentRuntime,
|
||||
});
|
||||
|
||||
if (target?.kind !== "remote" || target.transport !== "sandbox") {
|
||||
throw new Error("expected a sandbox target");
|
||||
}
|
||||
return { target, execute };
|
||||
return { target, execute, environmentRuntime };
|
||||
}
|
||||
|
||||
describe("resolveEnvironmentExecutionTarget effective capability snapshot", () => {
|
||||
|
|
@ -268,6 +268,23 @@ describe("effective snapshot gates the sync decision", () => {
|
|||
expect(target.runner?.syncOut).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("uses the host-bound home for sync after work folders are prepared without changing the primary workspace", async () => {
|
||||
const { target, environmentRuntime } = await buildSandboxTarget({ snapshot: FULL_GRANT, supportsSync: true });
|
||||
await target.runner!.syncOut!([]);
|
||||
expect(environmentRuntime.syncOut).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
lease: expect.objectContaining({ metadata: { remoteCwd: "/work", marker: "preserved" } }),
|
||||
}));
|
||||
target.workFolderHome = "/home/daytona";
|
||||
await target.runner!.syncIn!([]);
|
||||
await target.runner!.syncOut!([]);
|
||||
for (const sync of [environmentRuntime.syncIn, environmentRuntime.syncOut]) {
|
||||
expect(sync).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
lease: expect.objectContaining({ metadata: { remoteCwd: "/home/daytona", marker: "preserved" } }),
|
||||
}));
|
||||
}
|
||||
expect(target.remoteCwd).toBe("/work");
|
||||
});
|
||||
|
||||
it("omits the native sync hooks when the snapshot removes a sync verb", async () => {
|
||||
// The snapshot verified inbound sync but not outbound sync. The runner
|
||||
// exposes the sync hooks both-or-neither, so it keeps the base64 fallback.
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@ describe("shared sandbox work-folder lifecycle", () => {
|
|||
await fs.symlink("tracked", path.join(source, "link"));
|
||||
await exec("git", ["-C", source, "add", "."]);
|
||||
await exec("git", ["-C", source, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "initial"]);
|
||||
await db.insert(projectWorkspaces).values({ companyId, projectId, name, repoUrl: source, sourceType: "git_repo", isPrimary: name === "repo-one" });
|
||||
await db.insert(projectWorkspaces).values({ companyId, projectId, name, repoUrl: source, sourceType: "git_repo", isPrimary: name === "repo-one",
|
||||
setupCommand: "printf 'initialized\\n' >> .setup-count" });
|
||||
}
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
|
|
@ -62,18 +63,27 @@ describe("shared sandbox work-folder lifecycle", () => {
|
|||
runner: { execute: (input) => localTestWorkFolderRunner.execute({ ...input, env: { ...input.env, HOME: home } }) } } });
|
||||
active.push(run); return run;
|
||||
}
|
||||
it("keeps successful checkpoints saved when activity logging fails", async () => {
|
||||
it("retains unaudited edits and retries without misreporting a completed checkpoint", async () => {
|
||||
const run = await prepare(path.join(root, "activity-failure"), randomUUID());
|
||||
await run.flush();
|
||||
const [before] = await db.select().from(workFolderRuns).where(eq(workFolderRuns.runId, run.manifest.runId));
|
||||
await fs.writeFile(path.join(run.home, "task/activity-proof.txt"), "saved after logging recovers");
|
||||
const activity = vi.spyOn(activityLog, "logActivity").mockRejectedValue(new Error("activity unavailable"));
|
||||
try {
|
||||
const run = await prepare(path.join(root, "activity-failure"), randomUUID());
|
||||
await fs.writeFile(path.join(run.home, "task/activity-proof.txt"), "saved despite logging failure");
|
||||
await run.stop(); active.splice(active.indexOf(run), 1);
|
||||
await expect(run.stop()).rejects.toThrow("activity unavailable");
|
||||
const [state] = await db.select().from(workFolderRuns).where(eq(workFolderRuns.runId, run.manifest.runId));
|
||||
expect(state?.state).toBe("saved");
|
||||
expect(state?.lastSavedAt).not.toBeNull();
|
||||
expect(state?.state).toBe("failed");
|
||||
expect(state?.lastSavedAt).toEqual(before?.lastSavedAt);
|
||||
const folder = await workFolderService(db, storage).ensure({ companyId, scope: "task", ownerId: taskId });
|
||||
expect((await workFolderService(db, storage).list(folder)).files.some((file) => file.path === "activity-proof.txt")).toBe(true);
|
||||
expect((await workFolderService(db, storage).list(folder)).files.some((file) => file.path === "activity-proof.txt")).toBe(false);
|
||||
expect(await fs.readFile(path.join(run.home, "task/activity-proof.txt"), "utf8")).toBe("saved after logging recovers");
|
||||
} finally { activity.mockRestore(); }
|
||||
await run.stop(); active.splice(active.indexOf(run), 1);
|
||||
const [saved] = await db.select().from(workFolderRuns).where(eq(workFolderRuns.runId, run.manifest.runId));
|
||||
expect(saved?.state).toBe("saved");
|
||||
expect(saved!.lastSavedAt!.getTime()).toBeGreaterThan(before!.lastSavedAt!.getTime());
|
||||
const folder = await workFolderService(db, storage).ensure({ companyId, scope: "task", ownerId: taskId });
|
||||
expect((await workFolderService(db, storage).list(folder)).files.some((file) => file.path === "activity-proof.txt")).toBe(true);
|
||||
}, 120_000);
|
||||
|
||||
it("reuses clones and restores saved unpushed work, staged changes, and task files after losing the sandbox", async () => {
|
||||
|
|
@ -85,6 +95,7 @@ describe("shared sandbox work-folder lifecycle", () => {
|
|||
expect(first.primaryRepo).toBe(path.join(home, "repos/repo-one"));
|
||||
await fs.writeFile(path.join(home, "task/report.md"), "durable task file");
|
||||
const repo = first.primaryRepo;
|
||||
expect(await fs.readFile(path.join(repo, ".setup-count"), "utf8")).toBe("initialized\n");
|
||||
await fs.writeFile(path.join(repo, "tracked"), "committed\n");
|
||||
await exec("git", ["-C", repo, "add", "."]);
|
||||
await exec("git", ["-C", repo, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "unpushed"]);
|
||||
|
|
@ -95,10 +106,12 @@ describe("shared sandbox work-folder lifecycle", () => {
|
|||
await fs.writeFile(path.join(repo, "untracked"), "untracked\n");
|
||||
await first.stop(); active.splice(active.indexOf(first), 1);
|
||||
const warm = await prepare(home, randomUUID(), leaseId);
|
||||
expect(await fs.readFile(path.join(repo, ".setup-count"), "utf8")).toBe("initialized\n");
|
||||
expect(await fs.readFile(path.join(repo, "tracked"), "utf8")).toBe("unstaged\n");
|
||||
await warm.stop(); active.splice(active.indexOf(warm), 1);
|
||||
await fs.rm(home, { recursive: true });
|
||||
const restored = await prepare(path.join(root, "replacement"), randomUUID());
|
||||
expect(await fs.readFile(path.join(restored.primaryRepo, ".setup-count"), "utf8")).toBe("initialized\n");
|
||||
expect(await fs.readFile(path.join(restored.home, "task/report.md"), "utf8")).toBe("durable task file");
|
||||
expect((await exec("git", ["-C", restored.primaryRepo, "rev-parse", "HEAD"])).stdout.trim()).toBe(expectedHead);
|
||||
expect((await exec("git", ["-C", restored.primaryRepo, "show", ":tracked"])).stdout).toBe("staged\n");
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { adapterSupportsRemoteManagedEnvironments } from "@paperclipai/shared";
|
|||
import {
|
||||
adapterExecutionTargetToRemoteSpec,
|
||||
type AdapterExecutionTarget,
|
||||
type AdapterSandboxExecutionTarget,
|
||||
type SandboxLeaseAcquisition,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
import type { DuplexObservabilityRecorder } from "@paperclipai/adapter-utils/duplex-observability";
|
||||
|
|
@ -359,7 +360,13 @@ export async function resolveEnvironmentExecutionTarget(input: {
|
|||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// The coordinator binds the natural home after acquiring this target. Read
|
||||
// that host-owned binding at sync time instead of capturing the old cwd.
|
||||
// Provider path/symlink confinement still applies to every transfer.
|
||||
const syncLease = () => target.workFolderHome
|
||||
? { ...input.lease!, metadata: { ...input.lease!.metadata, remoteCwd: target.workFolderHome } }
|
||||
: input.lease!;
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: parsed.config.provider,
|
||||
|
|
@ -595,13 +602,13 @@ export async function resolveEnvironmentExecutionTarget(input: {
|
|||
syncIn: (operations) =>
|
||||
input.environmentRuntime!.syncIn({
|
||||
environment: input.environment as Environment,
|
||||
lease: input.lease!,
|
||||
lease: syncLease(),
|
||||
operations,
|
||||
}),
|
||||
syncOut: (operations) =>
|
||||
input.environmentRuntime!.syncOut({
|
||||
environment: input.environment as Environment,
|
||||
lease: input.lease!,
|
||||
lease: syncLease(),
|
||||
operations,
|
||||
}),
|
||||
}
|
||||
|
|
@ -625,6 +632,7 @@ export async function resolveEnvironmentExecutionTarget(input: {
|
|||
}
|
||||
: undefined,
|
||||
};
|
||||
return target;
|
||||
}
|
||||
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import { workFolderRepositoryService } from "./work-folder-repositories.js";
|
|||
import { startWorkFolderCheckpointer } from "./work-folder-checkpointer.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { assertWorkFolderAccess } from "./work-folder-access.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
function signature(entry: WorkTreeEntry | undefined) {
|
||||
return entry ? JSON.stringify([entry.kind, entry.sha256, entry.executable]) : "missing";
|
||||
|
|
@ -228,12 +227,14 @@ export async function prepareSandboxWorkFolders(input: {
|
|||
const root = path.posix.join(paths.repos!, binding.name);
|
||||
const probe = await target.runner!.execute({ command: "git", args: ["-C", root, "rev-parse", "--git-dir"], bypassSession: true, timeoutMs: 10_000 });
|
||||
const freshCheckout = probe.exitCode !== 0;
|
||||
let restoredCheckout = false;
|
||||
if (freshCheckout) {
|
||||
// Publish the checkout directory only after every restore object or
|
||||
// clone step completes. An interrupted attempt cannot masquerade as a
|
||||
// reusable checkout merely because it contains a .git directory.
|
||||
const temporary = path.posix.join(staging, `repo-${binding.id}-${randomUUID()}`);
|
||||
const restored = await repositories.restore(binding, temporary, staging);
|
||||
restoredCheckout = restored;
|
||||
if (!restored) {
|
||||
const auth = await resolveGitAuth(workspace.repoUrl!);
|
||||
const result = await target.runner!.execute({ command: "git", args: [...(auth?.configArgs ?? []), "clone", "--no-hardlinks",
|
||||
|
|
@ -264,7 +265,9 @@ export async function prepareSandboxWorkFolders(input: {
|
|||
}
|
||||
await transport.moveRoot(temporary, root);
|
||||
}
|
||||
if ((!binding.setupComplete || freshCheckout) && workspace.setupCommand) {
|
||||
// A complete checkpoint already contains the setup's durable outputs.
|
||||
// Replacing the sandbox must not repeat completed project setup.
|
||||
if ((!binding.setupComplete || (freshCheckout && !restoredCheckout)) && workspace.setupCommand) {
|
||||
const setup = await target.runner!.execute({ command: "sh", args: ["-c", workspace.setupCommand], cwd: root, bypassSession: true, timeoutMs: 300_000 });
|
||||
if (setup.exitCode !== 0 || setup.timedOut) throw new Error(`Repository ${binding.name} setup failed`);
|
||||
}
|
||||
|
|
@ -285,17 +288,19 @@ export async function prepareSandboxWorkFolders(input: {
|
|||
await logActivity(db, { companyId: input.companyId, actorType: "agent", actorId: input.agentId,
|
||||
agentId: input.agentId, runId: input.runId, issueId: input.taskId,
|
||||
responsibleUserIdOverride: input.responsibleUserId, action, entityType: "heartbeat_run", entityId: input.runId,
|
||||
details: { scopes: WORK_FOLDER_SCOPES.filter((scope) => Boolean(folders[scope])), repositories: bindings.length } })
|
||||
.catch((error) => logger.warn({ err: error, runId: input.runId }, "Work-folder activity could not be recorded"));
|
||||
details: { phase: "started", scopes: WORK_FOLDER_SCOPES.filter((scope) => Boolean(folders[scope])), repositories: bindings.length } });
|
||||
}
|
||||
try {
|
||||
// Record intent before mutations. An unavailable audit store blocks new
|
||||
// work instead of turning an already completed save into a false failure.
|
||||
// The run's persisted state/lastSavedAt records checkpoint completion.
|
||||
await recordCheckpoint("work_folder.prepared");
|
||||
await seedAttachments();
|
||||
await importAgentFiles();
|
||||
// A resumed sandbox can hold edits newer than its last completed checkpoint.
|
||||
if (previous) for (const scope of WORK_FOLDER_SCOPES) await outgoing(scope);
|
||||
for (const scope of WORK_FOLDER_SCOPES) await incoming(scope);
|
||||
await prepareRepositories();
|
||||
await recordCheckpoint("work_folder.prepared");
|
||||
await saveState("starting");
|
||||
if (previous?.refreshRequested) await db.update(workFolderRuns).set({ refreshRequested: false })
|
||||
.where(eq(workFolderRuns.runId, previous.runId));
|
||||
|
|
@ -306,10 +311,10 @@ export async function prepareSandboxWorkFolders(input: {
|
|||
const checkpointer = startWorkFolderCheckpointer({
|
||||
async checkpoint() {
|
||||
await assertBindings();
|
||||
await recordCheckpoint("work_folder.checkpoint");
|
||||
await saveState("saving");
|
||||
for (const scope of WORK_FOLDER_SCOPES) await outgoing(scope);
|
||||
for (const { binding, root } of bindings) await repositories.checkpoint(binding, root);
|
||||
await recordCheckpoint("work_folder.checkpoint");
|
||||
await saveState("saved");
|
||||
},
|
||||
async onError() { await saveState("failed", "Files could not be saved; the sandbox must be retained for recovery"); },
|
||||
|
|
|
|||
Loading…
Reference in New Issue