Harden work-folder recovery and add deployed staging acceptance tools

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-07 12:28:17 -05:00
1 parent a13bb6b395
commit 6c7d1bbbf0
23 files changed
+656 -38

No files matched your search

@@ -101,6 +101,17 @@ describe.sequential("cli auth routes", () => {
vi.resetAllMocks();
});
it.each(["session", "cloud_tenant"])("recognizes an authenticated %s user for board CLI approval", async (source) => {
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({ id: "challenge-1", requestedAccess: "board", status: "pending" });
const app = await createApp({ type: "board", source, userId: "user-1", companyIds: ["company-1"], isInstanceAdmin: false });
const result = await request(app).get("/api/cli-auth/challenges/challenge-1?token=pcp_cli_auth_secret");
expect(result.status).toBe(200);
expect(result.body).toMatchObject({ requiresSignIn: false, canApprove: true, currentUserId: "user-1" });
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({ id: "challenge-1", requestedAccess: "instance_admin_required", status: "pending" });
const admin = await request(app).get("/api/cli-auth/challenges/challenge-1?token=pcp_cli_auth_secret");
expect(admin.body.canApprove).toBe(false);
});
it.sequential("creates a CLI auth challenge with approval metadata", async () => {
mockBoardAuthService.createCliAuthChallenge.mockResolvedValue({
challenge: {
@@ -1,11 +1,12 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { agents, companies, createDb, heartbeatRuns, issues, environments, environmentLeases, projects, projectWorkspaces, startEmbeddedPostgresTestDatabase, type Db } from "@paperclipai/db";
import { agents, assets, companyMemberships, issueAttachments, companies, createDb, heartbeatRuns, issues, environments, environmentLeases, projects, projectWorkspaces, taskRepositoryBindings, startEmbeddedPostgresTestDatabase, type Db } from "@paperclipai/db";
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
import { prepareSandboxWorkFolders } from "../services/sandbox-work-folders.js";
import { retainUnsavedWorkFolderLease, workFolderSandboxKey } from "../services/work-folder-retention.js";
@@ -44,14 +45,14 @@ describe("shared sandbox work-folder lifecycle", () => {
for (const run of active) await run.stop().catch(() => {});
await database?.cleanup(); if (root) await fs.rm(root, { recursive: true, force: true });
});
async function prepare(home: string, leaseId: string, physicalId = leaseId) {
async function prepare(home: string, leaseId: string, physicalId = leaseId, responsibleUserId: string | null = null) {
await fs.mkdir(home, { recursive: true });
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running" });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, responsibleUserId, status: "running" });
const lease = { id: leaseId, companyId, environmentId, provider: "test", providerLeaseId: physicalId };
await db.insert(environmentLeases).values({ ...lease, heartbeatRunId: runId }).onConflictDoUpdate({ target: environmentLeases.id, set: { heartbeatRunId: runId } });
const run = await prepareSandboxWorkFolders({ db, companyId, agentId, projectId, taskId, runId,
responsibleUserId: null, storage, sandboxKey: workFolderSandboxKey(lease), target: { kind: "remote", transport: "sandbox", leaseId, remoteCwd: home,
responsibleUserId, storage, sandboxKey: workFolderSandboxKey(lease), target: { kind: "remote", transport: "sandbox", leaseId, remoteCwd: home,
runner: { execute: (input) => localTestWorkFolderRunner.execute({ ...input, env: { ...input.env, HOME: home } }) } } });
active.push(run); return run;
}
@@ -123,4 +124,78 @@ describe("shared sandbox work-folder lifecycle", () => {
await resumed.stop(); active.splice(active.indexOf(resumed), 1);
}, 120_000);
it("stops private-file synchronization after responsible-user membership is revoked", async () => {
const userId = randomUUID();
const [membership] = await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, membershipRole: "member" }).returning();
const leaseId = randomUUID();
const run = await prepare(path.join(root, "revoked-user"), leaseId, leaseId, userId);
await fs.writeFile(path.join(run.home, "user/private"), "pending private edit");
await db.update(companyMemberships).set({ status: "inactive" }).where(eq(companyMemberships.id, membership!.id));
await expect(run.stop()).rejects.toThrow("no longer authorized");
expect(await retainUnsavedWorkFolderLease(db, { id: leaseId, companyId })).toBe(true);
const svc = workFolderService(db, storage);
const folder = await svc.ensure({ companyId, scope: "user", ownerId: userId });
expect((await svc.list(folder)).files).toHaveLength(0);
expect(await fs.readFile(path.join(run.home, "user/private"), "utf8")).toBe("pending private edit");
await db.update(companyMemberships).set({ status: "active" }).where(eq(companyMemberships.id, membership!.id));
await run.stop(); active.splice(active.indexOf(run), 1);
}, 120_000);
it("does not publish a partial repository checkpoint and retries a failed final save", async () => {
const leaseId = randomUUID();
const run = await prepare(path.join(root, "interrupted-checkpoint"), leaseId);
await run.flush();
const bindingId = run.manifest.repositories[0]!.bindingId;
const [before] = await db.select().from(taskRepositoryBindings).where(eq(taskRepositoryBindings.id, bindingId));
await fs.writeFile(path.join(run.primaryRepo, "new-unsaved-file"), "must survive a failed save");
const put = storage.putObject.bind(storage);
const fail = vi.spyOn(storage, "putObject").mockImplementation(async (input) => {
if (input.objectKey.includes("/checkpoints/")) throw new Error("Injected storage outage");
return put(input);
});
try {
await expect(run.stop()).rejects.toThrow("Injected storage outage");
const [after] = await db.select().from(taskRepositoryBindings).where(eq(taskRepositoryBindings.id, bindingId));
expect(after!.checkpointKey).toBe(before!.checkpointKey);
expect(await retainUnsavedWorkFolderLease(db, { id: leaseId, companyId })).toBe(true);
} finally { fail.mockRestore(); }
await run.stop(); active.splice(active.indexOf(run), 1);
await fs.rm(run.home, { recursive: true });
const recovered = await prepare(path.join(root, "interrupted-recovered"), randomUUID());
expect(await fs.readFile(path.join(recovered.primaryRepo, "new-unsaved-file"), "utf8")).toBe("must survive a failed save");
await recovered.stop(); active.splice(active.indexOf(recovered), 1);
}, 120_000);
it("seeds duplicate and reserved attachment names idempotently without changing original uploads", async () => {
const attachmentIds: string[] = [];
const originalKeys: string[] = [];
const content = Buffer.from("original upload");
for (const originalFilename of ["same.txt", "same.txt", ".", ".paperclip-runtime"]) {
const id = randomUUID();
const objectKey = `${companyId}/attachments/${id}`;
originalKeys.push(objectKey);
await storage.putObject({ objectKey, body: content, contentType: "text/plain", contentLength: content.length });
await db.insert(assets).values({ id, companyId, provider: storage.id, objectKey, contentType: "text/plain", byteSize: content.length,
sha256: createHash("sha256").update(content).digest("hex"), originalFilename });
const attachmentId = randomUUID(); attachmentIds.push(attachmentId);
await db.insert(issueAttachments).values({ id: attachmentId, companyId, issueId: taskId, assetId: id });
}
const leaseId = randomUUID();
const home = path.join(root, "attachment-seeding");
const first = await prepare(home, leaseId);
const files = (await fs.readdir(path.join(home, "task"))).filter((file) => attachmentIds.some((id) => file.includes(id)));
expect(files).toHaveLength(4);
await fs.writeFile(path.join(home, "task", files[0]!), "edited working copy");
await first.stop(); active.splice(active.indexOf(first), 1);
const warm = await prepare(home, randomUUID(), leaseId);
expect(await fs.readFile(path.join(home, "task", files[0]!), "utf8")).toBe("edited working copy");
expect((await fs.readdir(path.join(home, "task"))).filter((file) => attachmentIds.some((id) => file.includes(id)))).toEqual(files);
await warm.stop(); active.splice(active.indexOf(warm), 1);
for (const objectKey of originalKeys) {
const original = await storage.getObject({ objectKey });
const chunks: Buffer[] = [];
for await (const chunk of original.stream) chunks.push(Buffer.from(chunk));
expect(Buffer.concat(chunks)).toEqual(content);
}
}, 120_000);
});
+1 -1
View File
@@ -2797,7 +2797,7 @@ export function accessRoutes(
const isSignedInBoardUser =
req.actor.type === "board" &&
(req.actor.source === "session" || isLocalImplicit(req)) &&
(req.actor.source === "session" || req.actor.source === "cloud_tenant" || isLocalImplicit(req)) &&
Boolean(req.actor.userId);
const canApprove =
isSignedInBoardUser &&
+17 -10
View File
@@ -1794,6 +1794,10 @@ function createSandboxEnvironmentDriver(
}
const workerConfig = stripSandboxProviderEnvelope(parsed.config);
// A provider reaper must not delete the only copy after a failed final
// checkpoint. Normal release still deletes ephemeral sandboxes after
// the host confirms durability; idle stop/archive remains enabled.
if (boundRun && parsed.config.provider === "daytona") workerConfig.autoDeleteInterval = -1;
const storedConfig = storedParsed.config;
const providerConfigForLease = sandboxConfigForLeaseMetadata(storedConfig);
// Require the reusable-lease capability AND a worker that verifies the
@@ -1816,7 +1820,6 @@ function createSandboxEnvironmentDriver(
declaredReusableLeases && capabilityIsVerified("reusableLeases", pluginVerifiedMethods);
const leaseFingerprint =
supportsReusableLeases &&
parsed.config.reuseLease &&
input.heartbeatRunId !== null &&
input.executionWorkspaceId !== null &&
input.agentId !== null
@@ -1845,13 +1848,12 @@ function createSandboxEnvironmentDriver(
// or terminal rows cannot be matched.
const reusableCandidateLeases =
supportsReusableLeases &&
parsed.config.reuseLease &&
input.heartbeatRunId !== null &&
input.executionWorkspaceId !== null &&
input.agentId !== null
? (await environmentsSvc.listLeases(input.environment.id))
.filter((lease) =>
lease.leasePolicy === "reuse_by_environment" &&
((parsed.config.reuseLease && lease.leasePolicy === "reuse_by_environment") || lease.metadata?.workFolderRecoveryRequired === true) &&
reusableLeaseCanBeResumed({ lease, heartbeatRunId: input.heartbeatRunId }) &&
lease.executionWorkspaceId === input.executionWorkspaceId &&
lease.metadata?.agentId === input.agentId,
@@ -1876,6 +1878,9 @@ function createSandboxEnvironmentDriver(
lease.heartbeatRunId === input.heartbeatRunId,
}),
);
if (reusableCandidateLeases.some((lease) => lease.metadata?.workFolderRecoveryRequired === true && !reusableExistingLeases.includes(lease))) {
throw new Error("Unsaved sandbox work requires recovery with its original run identity and configuration");
}
if (reusableCandidateLeases.length > reusableExistingLeases.length) {
await cleanupObsoleteReusableSandboxLeases({
environment: input.environment,
@@ -1885,7 +1890,6 @@ function createSandboxEnvironmentDriver(
}
const reusableProviderLeaseId =
supportsReusableLeases &&
parsed.config.reuseLease &&
input.heartbeatRunId !== null &&
input.executionWorkspaceId !== null &&
input.agentId !== null
@@ -1982,6 +1986,9 @@ function createSandboxEnvironmentDriver(
});
}
if (!providerLease) {
if (await retainUnsavedWorkFolderLease(db, reusableLease)) {
throw new Error("Saved sandbox could not be resumed; unsaved work was retained for recovery");
}
if (
input.adapterType === "paperclip_runner" &&
!verifyNativeHarnessBackupStamp(
@@ -2046,7 +2053,7 @@ function createSandboxEnvironmentDriver(
metadata: acquiredLease.metadata,
schema: pluginProvider.resolved.driver.configSchema as Record<string, unknown> | null | undefined,
});
const reusableScope = resolvedLeasePolicy === "reuse_by_environment"
const reusableScope = supportsReusableLeases && input.heartbeatRunId !== null
? buildReusableSandboxLeaseScope({
responsibleUserId,
issueId: input.issueId,
@@ -2186,7 +2193,6 @@ function createSandboxEnvironmentDriver(
const providerConfigForLease = sandboxConfigForLeaseMetadata(parsed.config);
const leaseFingerprint =
supportsReusableLeases &&
parsed.config.reuseLease &&
input.heartbeatRunId !== null &&
input.executionWorkspaceId !== null &&
input.agentId !== null
@@ -2203,13 +2209,12 @@ function createSandboxEnvironmentDriver(
: null;
const reusableCandidateLeases =
supportsReusableLeases &&
parsed.config.reuseLease &&
input.heartbeatRunId !== null &&
input.executionWorkspaceId !== null &&
input.agentId !== null
? (await environmentsSvc.listLeases(input.environment.id))
.filter((lease) =>
lease.leasePolicy === "reuse_by_environment" &&
((parsed.config.reuseLease && lease.leasePolicy === "reuse_by_environment") || lease.metadata?.workFolderRecoveryRequired === true) &&
reusableLeaseCanBeResumed({ lease, heartbeatRunId: input.heartbeatRunId }) &&
lease.executionWorkspaceId === input.executionWorkspaceId &&
lease.metadata?.agentId === input.agentId,
@@ -2234,6 +2239,9 @@ function createSandboxEnvironmentDriver(
lease.heartbeatRunId === input.heartbeatRunId,
}),
);
if (reusableCandidateLeases.some((lease) => lease.metadata?.workFolderRecoveryRequired === true && !reusableExistingLeases.includes(lease))) {
throw new Error("Unsaved sandbox work requires recovery with its original run identity and configuration");
}
if (reusableCandidateLeases.length > reusableExistingLeases.length) {
await cleanupObsoleteReusableSandboxLeases({
environment: input.environment,
@@ -2243,7 +2251,6 @@ function createSandboxEnvironmentDriver(
}
const reusableProviderLeaseId =
supportsReusableLeases &&
parsed.config.reuseLease &&
input.heartbeatRunId !== null &&
input.executionWorkspaceId !== null &&
input.agentId !== null
@@ -2290,7 +2297,7 @@ function createSandboxEnvironmentDriver(
const resolvedLeasePolicy = supportsReusableLeases && parsed.config.reuseLease && input.heartbeatRunId !== null
? "reuse_by_environment"
: "ephemeral";
const reusableScope = resolvedLeasePolicy === "reuse_by_environment"
const reusableScope = supportsReusableLeases && input.heartbeatRunId !== null
? buildReusableSandboxLeaseScope({
responsibleUserId,
issueId: input.issueId,
+1
View File
@@ -19743,6 +19743,7 @@ export function heartbeatService(
sandboxWorkFolders = await prepareSandboxWorkFolders({ db, companyId: run.companyId, runId: run.id,
agentId: agent.id, responsibleUserId: run.responsibleUserId ?? null,
taskId: issueRef?.id ?? null, projectId: issueRef?.projectId ?? null, target: executionTarget,
primaryWorkspaceId: executionWorkspace.workspaceId, primaryBranchName: executionWorkspace.branchName,
sandboxKey: workFolderSandboxKey(activeEnvironmentLease.lease) });
if (sandboxWorkFolders.identityChanged) { taskSessionForRun = null; previousSessionParams = null; }
executionTarget.workFolderHome = sandboxWorkFolders.home;
+57 -8
View File
@@ -3,7 +3,7 @@ import { createReadStream } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { and, asc, desc, eq, sql } from "drizzle-orm";
import { assets, issueAttachments, projectWorkspaces, taskRepositoryBindings, workFileOperations, workFolderRuns, workFolders, type Db } from "@paperclipai/db";
import { agents, assets, companyMemberships, heartbeatRuns, issues, projects, issueAttachments, projectWorkspaces, taskRepositoryBindings, workFileOperations, workFolderRuns, workFolders, type Db } from "@paperclipai/db";
import { WORK_FOLDER_SCOPES, type SandboxWorkFolderManifest, type WorkFolderScope } from "@paperclipai/shared";
import type { AdapterSandboxExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import type { StorageProvider } from "../storage/types.js";
@@ -15,6 +15,7 @@ import { workFolderService } from "./work-folders.js";
import { workFolderPaths, workFolderTransport, type WorkTreeEntry } from "./work-folder-transport.js";
import { workFolderRepositoryService } from "./work-folder-repositories.js";
import { startWorkFolderCheckpointer } from "./work-folder-checkpointer.js";
import { assertWorkFolderAccess } from "./work-folder-access.js";
function signature(entry: WorkTreeEntry | undefined) {
return entry ? JSON.stringify([entry.kind, entry.sha256, entry.executable]) : "missing";
@@ -28,10 +29,43 @@ function repoName(value: string, id: string) {
export async function prepareSandboxWorkFolders(input: {
db: Db; companyId: string; runId: string; agentId: string; responsibleUserId: string | null;
taskId: string | null; projectId: string | null; target: AdapterSandboxExecutionTarget;
primaryWorkspaceId?: string | null; primaryBranchName?: string | null;
storage?: StorageProvider; sandboxKey?: string;
}) {
const { db, target } = input;
if (!target.runner || !target.leaseId) throw new Error("Sandbox file transport is unavailable");
async function assertBindings() {
const memberships: Array<{ companyId: string; membershipRole: string | null; status: string }> = [];
const deny = () => { throw new Error("Sandbox work-folder access is no longer authorized; working files were retained"); };
const [run] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId)));
if (!run || run.agentId !== input.agentId || run.responsibleUserId !== input.responsibleUserId) deny();
const [agent] = await db.select({ id: agents.id }).from(agents).where(and(eq(agents.id, input.agentId), eq(agents.companyId, input.companyId)));
if (!agent) deny();
if (input.taskId) {
const [task] = await db.select({ id: issues.id }).from(issues).where(and(eq(issues.id, input.taskId), eq(issues.companyId, input.companyId)));
if (!task) deny();
}
if (input.projectId) {
const [project] = await db.select({ id: projects.id }).from(projects).where(and(eq(projects.id, input.projectId), eq(projects.companyId, input.companyId)));
if (!project) deny();
}
if (input.responsibleUserId) {
const [membership] = await db.select().from(companyMemberships).where(and(eq(companyMemberships.companyId, input.companyId),
eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, input.responsibleUserId), eq(companyMemberships.status, "active")));
if (!membership || membership.membershipRole === "viewer") deny();
if (membership) memberships.push({ companyId: membership.companyId, membershipRole: membership.membershipRole, status: membership.status });
}
for (const [scope, ownerId] of [["task", input.taskId], ["agent", input.agentId], ["project", input.projectId]] as const) {
if (!ownerId) continue;
await assertWorkFolderAccess(db, { type: "agent", source: "agent_jwt", companyId: input.companyId,
agentId: input.agentId, runId: input.runId, onBehalfOfUserId: input.responsibleUserId, onBehalfOfMemberships: memberships },
{ companyId: input.companyId, scope, ownerId }, true);
}
}
// Host-side transfers do not go through HTTP authorization middleware. Check
// the authoritative bindings here too, including after membership revocation.
// An ended heartbeat may still flush; its immutable identity must still match.
await assertBindings();
const storage = input.storage ?? createStorageProviderFromConfig(loadConfig());
const svc = workFolderService(db, storage);
const transport = workFolderTransport(target.runner);
@@ -77,11 +111,11 @@ export async function prepareSandboxWorkFolders(input: {
const [seeded] = await db.select().from(workFileOperations).where(and(eq(workFileOperations.folderId, folders.task.id),
eq(workFileOperations.operationId, operationId)));
if (seeded) continue;
const original = (asset.originalFilename ?? attachment.id).split(/[\\/]/).at(-1)!.replace(/[\x00-\x1f\x7f]/g, "_") || attachment.id;
let filename = original;
try { await svc.get(folders.task, filename); filename = `${original}-${attachment.id}`; } catch (error) {
if ((error as { status?: number }).status !== 404) throw error;
}
const original = (asset.originalFilename ?? "attachment").split(/[\\/]/).at(-1)!.replace(/[\x00-\x1f\x7f]/g, "_").slice(0, 180) || "attachment";
// The ID makes the destination independent of concurrent uploads and
// earlier seeding attempts. Even dot/reserved filenames become safe.
const extension = path.posix.extname(original);
const filename = `${original.slice(0, original.length - extension.length)}-${attachment.id}${extension}`;
const result = await storage.getObject({ objectKey: asset.objectKey });
try { await svc.write(folders.task, { path: filename, body: result.stream, contentType: asset.contentType, operationId, onlyIfMissing: true }); }
finally { result.stream.destroy(); }
@@ -103,7 +137,7 @@ export async function prepareSandboxWorkFolders(input: {
if (stat.isDirectory()) {
if (relative) await svc.write(folder, { path: relative, kind: "directory", operationId: `import:${relative}`, onlyIfMissing: true });
for (const name of (await fs.readdir(path.join(root, relative))).sort()) {
if ([".codex", ".claude", ".cache", ".config", ".local", ".git", ".paperclip-runtime"].includes(name)) continue;
if ([".codex", ".claude", ".cache", ".config", ".local", ".git", ".paperclip-runtime", ".ssh", ".aws", ".azure", ".netrc", ".git-credentials", ".npmrc", ".npm", "node_modules", ".venv"].includes(name)) continue;
await visit(relative ? `${relative}/${name}` : name);
}
} else if (stat.isFile()) {
@@ -192,6 +226,7 @@ export async function prepareSandboxWorkFolders(input: {
const names = new Set(existing.map((binding) => binding.name));
const resolveGitAuth = createGitRemoteAuthProvider(db, input.companyId, { responsibleUserId: input.responsibleUserId, agentId: input.agentId, issueId: input.taskId, heartbeatRunId: input.runId });
for (const workspace of workspaces.filter((entry) => entry.repoUrl)) {
const primary = input.primaryWorkspaceId ? workspace.id === input.primaryWorkspaceId : workspace.isPrimary;
let binding = existing.find((entry) => entry.workspaceId === workspace.id);
if (!binding) {
const baseName = repoName(workspace.repoUrl!.split(/[/:]/).at(-1) ?? workspace.name, workspace.id);
@@ -222,6 +257,18 @@ export async function prepareSandboxWorkFolders(input: {
const checkout = await target.runner!.execute({ command: "git", args: ["-C", temporary, "checkout", binding.repoRef, "--"], bypassSession: true, timeoutMs: 60_000 });
if (checkout.exitCode !== 0 || checkout.timedOut) throw new Error(`Required repository ${binding.name} ref could not be checked out`);
}
if (primary && input.primaryBranchName) {
const branch = input.primaryBranchName;
const valid = await target.runner!.execute({ command: "git", args: ["check-ref-format", "--branch", branch], bypassSession: true, timeoutMs: 10_000 });
if (valid.exitCode !== 0 || valid.stdout.trim() !== branch) throw new Error(`Required repository ${binding.name} branch is invalid`);
// Honor the task's existing branch policy on the initial clone.
// Restores and warm starts keep the saved HEAD and index untouched.
const checkout = await target.runner!.execute({ command: "git", args: ["-C", temporary, "checkout", branch, "--"], bypassSession: true, timeoutMs: 60_000 });
if (checkout.exitCode !== 0) {
const create = await target.runner!.execute({ command: "git", args: ["-C", temporary, "checkout", "-b", branch], bypassSession: true, timeoutMs: 60_000 });
if (create.exitCode !== 0 || create.timedOut) throw new Error(`Required repository ${binding.name} task branch could not be created`);
}
}
} else {
const init = await target.runner!.execute({ command: "git", args: ["-C", temporary, "init"], bypassSession: true, timeoutMs: 10_000 });
if (init.exitCode !== 0) throw new Error(`Repository ${binding.name} could not be restored`);
@@ -236,7 +283,7 @@ export async function prepareSandboxWorkFolders(input: {
}
await db.update(taskRepositoryBindings).set({ setupComplete: true, retiredAt: null }).where(eq(taskRepositoryBindings.id, binding.id));
bindings.push({ binding, root });
manifest.repositories.push({ bindingId: binding.id, workspaceId: workspace.id, name: binding.name, primary: workspace.isPrimary });
manifest.repositories.push({ bindingId: binding.id, workspaceId: workspace.id, name: binding.name, primary });
await saveState("starting");
}
for (const old of existing) if (!workspaces.some((workspace) => workspace.id === old.workspaceId)) {
@@ -263,6 +310,7 @@ export async function prepareSandboxWorkFolders(input: {
}
const checkpointer = startWorkFolderCheckpointer({
async checkpoint() {
await assertBindings();
await saveState("saving");
for (const scope of WORK_FOLDER_SCOPES) await outgoing(scope);
for (const { binding, root } of bindings) await repositories.checkpoint(binding, root);
@@ -280,6 +328,7 @@ export async function prepareSandboxWorkFolders(input: {
if (run?.refreshRequested) {
// The agent has stopped. The successful final flush above protects its
// edits before accepting incoming shared files at this safe boundary.
await assertBindings();
for (const scope of WORK_FOLDER_SCOPES) await incoming(scope);
await db.update(workFolderRuns).set({ refreshRequested: false, baselines, updatedAt: new Date() })
.where(eq(workFolderRuns.runId, input.runId));
@@ -56,8 +56,9 @@ export function workFolderRepositoryService(db: Db, storage: StorageProvider, tr
for (let offset = 0; offset < published.length; offset += 1000) {
await tx.update(workFolderObjects).set({ deleteAfter: null }).where(inArray(workFolderObjects.objectKey, published.slice(offset, offset + 1000)));
}
await tx.update(taskRepositoryBindings).set({ checkpointKey, checkpointSha256: digest, checkpointAt: new Date() })
.where(and(eq(taskRepositoryBindings.id, binding.id), eq(taskRepositoryBindings.companyId, binding.companyId)));
const updated = await tx.update(taskRepositoryBindings).set({ checkpointKey, checkpointSha256: digest, checkpointAt: new Date() })
.where(and(eq(taskRepositoryBindings.id, binding.id), eq(taskRepositoryBindings.companyId, binding.companyId))).returning({ id: taskRepositoryBindings.id });
if (!updated.length) throw new Error("Repository owner was deleted during checkpoint");
});
knownByBinding.set(binding.id, new Set(files.flatMap((file) => file.objectKey ? [file.objectKey] : [])));
binding.checkpointKey = checkpointKey;