mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 12:33:05 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A run that finds its task workspace held by another run waits as a `workspace_busy` scheduled retry. #19143 keeps that wait as one parked retry while the holder is live. > - A remote task workspace stays held by its last writer run until the writer's save and sandbox release settle. This hold continues after the writer run ends, for example when a reassignment stops the writer. > - The parked-wait check counted only a `running` holder. For an ended writer whose save had not settled, it released the waiter at each due check. Preparation deferred again, cancelled the run, and created a new retry run. > - After a mid-run reassignment, the new owner got five cancelled `workspace_busy` runs in eleven minutes, and the task did no work. > - This pull request makes the parked wait use the same writer-hold rule as workspace reservation, so the waiter stays parked until the hold releases. > - The benefit is one durable wait, one start when the hold releases, and no churn of cancelled runs. ## Linked Issues or Issue Description Refs #19143 (parks a workspace-busy wait). Refs #19184 (releases a recovered hold for a new assignee after reassignment). **What happened?** A board user reassigned a task in the middle of a run. The old run was cancelled with `issue_reassigned`. The new assignee's runs were each created, started and cancelled with `workspace_busy`: "Shared execution workspace is busy: run … ended, but its workspace save has not settled". This repeated every 1–2 minutes (five cancelled runs in 11 minutes). Every cancellation named the stopped run of the previous assignee as the holder. The new assignee's other tasks were not the holder. **Expected behavior** The reassigned task waits as one parked retry. It starts once when the stopped writer's save and sandbox release settle. The wait stops at the one-hour holder cutoff. **Steps to reproduce** 1. Give a task a remote execution workspace whose writer run has ended, but whose save has not settled (`writerRunId` set, `dirty: true`). 2. Wake the task's assignee. The run defers with `workspace_busy` and schedules a retry. 3. Make the retry due and promote scheduled retries. Before this fix, the retry is promoted and dispatched, defers again, and creates another run. ## What Changed - New `server/src/services/remote-workspace-writer-hold.ts`: `readRemoteWorkspaceWriterHold` holds the rule for who holds a remote workspace (a running writer, or an ended writer whose save or sandbox release has not settled). The code is moved from `reserveRemoteWorkspace` without a behavior change. - `reserveRemoteWorkspace` uses that function. It throws the same errors as before. - `parkWorkspaceBusyRetryInTx` (from #19143): when the recorded holder is not running, it reads the same hold for the waited execution workspace. If the recorded holder is an ended writer that still holds it, the retry stays parked. The cutoff clock starts at the writer's end, the same value that reservation measures. Earlier waiters keep their arrival order. - New `WORKSPACE_BUSY_SETTLING_RECHECK_MS` (60 s). Settling has no single completion point that can wake the waiter, so a wait behind a settling writer re-checks every minute. Each check creates no run. In the promotion transaction it reads the workspace, the writer run, its task, and the writer's leases (one row per sandbox lease of that run). The checks last only while the wait exists, up to the one-hour cutoff. - `doc/execution-semantics.md`: documents the parked wait behind a settling writer. ## Verification - Red before the fix: `cd server && npx vitest run src/__tests__/heartbeat-remote-workspace-busy.test.ts`. The two new tests failed: the due retry was promoted (`expected [ … ] to not include '<retry id>'`). - Green after the fix, with: - `parks one waiter while an ended writer's save settles, then admits it once the hold releases`: three due checks keep one parked run, each re-check is bounded by the writer's end plus one hour, there is one `workspace_busy` cancellation in total and no new retry run. After the hold releases, the next check promotes the same run. - `stops parking behind an ended writer once its unsettled save reaches the holder cutoff`: parked before the cutoff, promoted after it (preparation then fails with the existing unfinished-writer error). - Existing suites pass: `heartbeat-workspace-busy.test.ts` (live-holder parking, arrival order, stop, workspace move), `modules/run-dispatch`, `remote-workspace-reservation.test.ts`, `remote-workspaces.integration.test.ts`, `isolated-remote-workspace-source.test.ts`, `heartbeat/workspace-preparation.test.ts` (476 tests in all). - `tsc --noEmit` for `server` shows no errors in the changed files. - Not done: a live run on staging. The fix is not deployed. ## Risks - Low. Parking applies only to a retry that recorded a `workspace_busy` wait on an execution workspace whose recorded writer is that holder. If the hold settles, moves to another run, or the task changes workspace, the retry is released to normal dispatch as before. - The wait stays bounded by the existing one-hour holder cutoff from the writer's end. After that, preparation fails as before. A user can still stop the parked run. - One writer per workspace is unchanged. Reservation still decides admission with the same rule. - The one-minute re-check runs only while a wait exists. It is not idle polling. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude Code with tool use (shell, file edits, browser reads of staging diagnostics). ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
912 lines
68 KiB
TypeScript
912 lines
68 KiB
TypeScript
import { workspaceAllocationFiles } from "./remote-workspace-allocation.js";
|
|
import { readNativeWorkspaceMigration } from "./native-runtime/native-workspace-migration.js";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import { existsSync } from "node:fs";
|
|
import { mkdtemp, rm, statfs } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
|
import {
|
|
companies, environmentLeases, executionWorkspaces, executionWorkspaceSnapshots, heartbeatRuns, issues, workspaceOperations, type Db,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
readRemoteWorkspaceAuthority, remoteWorkspaceSourceSchema,
|
|
type RemoteWorkspaceAuthority, type RemoteWorkspaceRunReference, type RemoteWorkspaceSource,
|
|
type EnvironmentLease,
|
|
} from "@paperclipai/shared";
|
|
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
|
import { getStorageService, type StorageService } from "../storage/index.js";
|
|
import { loadConfig } from "../config.js";
|
|
import { conflict, forbidden, HttpError, unprocessable } from "../errors.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
import { createGitRemoteAuthProvider } from "./git-credentials.js";
|
|
import { notifyCancelledWorkspacePreservation } from "./cancelled-workspace-preservation-notifications.js";
|
|
import { assertExecutionWorkspaceRecordAccess, assertTaskWorkspaceExecutionAccess } from "./task-workspace-access.js";
|
|
import { readRemoteWorkspaceWriterHold, type RemoteWorkspaceWriter } from "./remote-workspace-writer-hold.js";
|
|
import { persistActivity } from "./activity-log.js";
|
|
import {
|
|
captureRemoteWorkspaceArchive, cleanupRemoteWorkspaceArchive, prepareRemoteWorkspaceDirectory, prepareRemoteRepository, remoteWorkspaceRepositoryPath,
|
|
RemoteWorkspaceOperationTimeoutError, RemoteWorkspacePreparationTimeoutError, remoteWorkspaceFailureCause,
|
|
requireRemoteWorkspaceTarget, restoreRemoteWorkspaceDirectory, RemoteWorkspaceRestoreError, type RemoteWorkspaceRestoreStep,
|
|
} from "./remote-workspace-transport.js";
|
|
import {
|
|
beginWorkspaceSnapshot, getWorkspaceSnapshot, loadWorkspaceSnapshotArchive, uploadWorkspaceSnapshot,
|
|
} from "./remote-workspace-snapshot-storage.js";
|
|
import { restoreRemoteWorkspaceArchive } from "./remote-workspace-snapshot-archive.js";
|
|
import { removeTaskDirectoryTree } from "./isolated-task-directory-snapshot.js";
|
|
import { attachPreservedWorkspaceSnapshots } from "./workspace-recovery-snapshot.js";
|
|
import { notifyRemoteWorkspaceSnapshots } from "./remote-workspace-snapshot-retention.js";
|
|
import { notifyTerminalWorkspaceWork } from "./terminal-workspace-work.js";
|
|
import { preserveRemoteWorkspacePreparationFailure } from "./native-runtime/native-workspace-finalization-state.js";
|
|
|
|
export { readRemoteWorkspaceAuthority, type RemoteWorkspaceAuthority, type RemoteWorkspaceSource } from "@paperclipai/shared";
|
|
export { remoteWorkspaceRepositoryPath } from "./remote-workspace-transport.js";
|
|
export { clearRemoteWorkspaceLocationForReleasedLease } from "./remote-workspace-snapshot-retention.js";
|
|
type Workspace = Pick<typeof executionWorkspaces.$inferSelect, "id" | "companyId" | "metadata" | "sourceIssueId" | "projectId" | "projectWorkspaceId">;
|
|
type Transaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
|
const object = (value: unknown): Record<string, unknown> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
const SAVE_WORK_SCHEMA = "paperclip.remote-workspace-save-work.v1";
|
|
const RETIRED_SAVE_SCHEMA = "paperclip.remote-workspace-retired-save.v1";
|
|
type SaveWork = { schema: typeof SAVE_WORK_SCHEMA; activeIds: string[]; unknown: boolean; quiescedAt: string | null };
|
|
function readSaveWork(metadata: Record<string, unknown> | null | undefined): SaveWork | null {
|
|
const work = object(metadata?.saveWork);
|
|
return work.schema === SAVE_WORK_SCHEMA && Array.isArray(work.activeIds)
|
|
&& work.activeIds.every(id => typeof id === "string") && new Set(work.activeIds).size === work.activeIds.length
|
|
&& typeof work.unknown === "boolean" && (work.quiescedAt === null
|
|
|| typeof work.quiescedAt === "string" && Number.isFinite(Date.parse(work.quiescedAt)))
|
|
? work as SaveWork : null;
|
|
}
|
|
function sameExportLocation(value: unknown, location: NonNullable<RemoteWorkspaceAuthority["location"]>) {
|
|
const recorded = object(value);
|
|
return (["environmentId", "leaseId", "providerLeaseId", "remoteCwd"] as const).every(key => recorded[key] === location[key]);
|
|
}
|
|
|
|
export function shouldUseRemoteWorkspaces(input: {
|
|
adapterType: string; environmentDriver: string | null; experimentalEnabled: boolean; existingWorkspace?: { metadata?: Record<string, unknown> | null } | null;
|
|
}) {
|
|
const enrolled = readRemoteWorkspaceAuthority(input.existingWorkspace?.metadata) !== null;
|
|
if (enrolled && (input.adapterType !== "paperclip_runner" || input.environmentDriver !== "sandbox")) {
|
|
throw conflict("This workspace requires its native sandbox execution environment.", { code: "remote_workspace_execution_required" });
|
|
}
|
|
if (enrolled) return true;
|
|
// Existing host authority changes only through the explicit import workflow.
|
|
if (input.existingWorkspace || !input.experimentalEnabled || input.adapterType !== "paperclip_runner"
|
|
|| input.environmentDriver !== "sandbox") return false;
|
|
return configuredRemoteWorkspacePersistence() !== null;
|
|
}
|
|
|
|
export function assertEphemeralWorkspaceExecution(input: {
|
|
externalConversation: boolean; adapterType: string;
|
|
pluginTaskWorkspace: boolean; remoteAuthority: boolean;
|
|
}) {
|
|
if (process.env.PAPERCLIP_EPHEMERAL_SERVER !== "true" || input.externalConversation) return;
|
|
// Runtime selection has not promoted a fresh run yet. Validate storage here;
|
|
if (input.adapterType !== "paperclip_runner"
|
|
|| (!input.pluginTaskWorkspace && !input.remoteAuthority)) {
|
|
throw unprocessable("Volume-free servers require native Paperclip Runner execution with remote workspace storage.",
|
|
{ code: "ephemeral_execution_configuration_required" });
|
|
}
|
|
}
|
|
|
|
export function assertEphemeralNativeRuntime(native: boolean) {
|
|
if (process.env.PAPERCLIP_EPHEMERAL_SERVER === "true" && !native) {
|
|
throw unprocessable("Volume-free servers require native Paperclip Runner execution with remote workspace storage.",
|
|
{ code: "ephemeral_execution_configuration_required" });
|
|
}
|
|
}
|
|
|
|
function configuredRemoteWorkspacePersistence(storage?: StorageService) {
|
|
const config = loadConfig();
|
|
const databaseUrl = config.databaseUrl?.trim();
|
|
const keyFilePath = process.env.PAPERCLIP_SECRETS_MASTER_KEY?.trim() ? null : config.secretsMasterKeyFilePath;
|
|
if ((storage?.provider ?? config.storageProvider) !== "s3" || !databaseUrl || !/^postgres(?:ql)?:\/\//.test(databaseUrl)
|
|
|| (keyFilePath !== null && !existsSync(keyFilePath))) {
|
|
return null;
|
|
}
|
|
return { databaseUrl, keyFilePath };
|
|
}
|
|
|
|
export function assertRemoteWorkspacePersistence(storage: StorageService = getStorageService()) {
|
|
const persistence = configuredRemoteWorkspacePersistence(storage);
|
|
if (!persistence) throw conflict("Remote workspace storage requires external Postgres, object storage, and a stable configured encryption key.",
|
|
{ code: "remote_workspace_persistence_unavailable" });
|
|
return persistence;
|
|
}
|
|
|
|
function reference(workspaceId: string, authority: RemoteWorkspaceAuthority, state: "prepared" | "saved"): RemoteWorkspaceRunReference {
|
|
if (!authority.location) throw new Error("remote_workspace_location_missing");
|
|
return { schema: "paperclip.remote-workspace-run.v1", workspaceId, epoch: authority.epoch,
|
|
leaseId: authority.location.leaseId, providerLeaseId: authority.location.providerLeaseId,
|
|
remoteCwd: authority.location.remoteCwd, state, snapshotId: authority.snapshotId };
|
|
}
|
|
|
|
async function currentWorkspace(tx: Transaction, companyId: string, workspaceId: string) {
|
|
const [workspace] = await tx.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, workspaceId),
|
|
eq(executionWorkspaces.companyId, companyId))).for("update");
|
|
if (!workspace) throw new Error("remote_workspace_missing");
|
|
return workspace;
|
|
}
|
|
|
|
async function lockWorkspaceRunParents(tx: Transaction, companyId: string, runId: string, expectedIssueId?: string | null) {
|
|
const fields = { nativeIssueId: heartbeatRuns.nativeIssueId, issueId: heartbeatRuns.issueId,
|
|
contextIssueId: sql<unknown>`${heartbeatRuns.contextSnapshot}->'issueId'` };
|
|
const [selected] = await tx.select(fields).from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)));
|
|
if (!selected) throw new Error("remote_workspace_run_missing");
|
|
const issueId = selected.nativeIssueId ?? selected.issueId ?? (typeof selected.contextIssueId === "string" ? selected.contextIssueId : null);
|
|
if (expectedIssueId !== undefined && issueId !== expectedIssueId) throw new Error("remote_workspace_run_binding_changed");
|
|
const [company] = await tx.select({ id: companies.id }).from(companies).where(eq(companies.id, companyId)).for("key share");
|
|
if (!company) throw new Error("remote_workspace_run_missing");
|
|
if (issueId) {
|
|
const [issue] = await tx.select({ id: issues.id }).from(issues).where(and(
|
|
eq(issues.companyId, companyId), eq(issues.id, issueId))).for("update");
|
|
if (!issue) throw new Error("remote_workspace_run_binding_changed");
|
|
}
|
|
const [current] = await tx.select(fields).from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId))).for("update");
|
|
const currentIssueId = current?.nativeIssueId ?? current?.issueId ?? (typeof current?.contextIssueId === "string" ? current.contextIssueId : null);
|
|
if (!current || currentIssueId !== issueId) throw new Error("remote_workspace_run_binding_changed");
|
|
return issueId;
|
|
}
|
|
|
|
export class RemoteWorkspaceWriterActiveError extends HttpError {
|
|
constructor(
|
|
readonly workspaceId: string,
|
|
readonly writer: RemoteWorkspaceWriter,
|
|
) {
|
|
super(409, "The workspace still has an unfinished writer or save operation.", { code: "remote_workspace_writer_pending" });
|
|
}
|
|
}
|
|
|
|
export async function reserveRemoteWorkspace(input: {
|
|
db: Db; companyId: string; issueId: string; runId: string; agentId: string; environmentId: string;
|
|
projectId: string | null; projectWorkspaceId: string | null; existingWorkspaceId: string | null;
|
|
source: RemoteWorkspaceSource; storage?: StorageService;
|
|
mode?: string;
|
|
fresh?: { attemptId: string; previousWorkspaceId: string | null; expectedBindingRevision: number };
|
|
recoverySourceRunIds?: string[];
|
|
}) {
|
|
assertRemoteWorkspacePersistence(input.storage ?? getStorageService());
|
|
const source = remoteWorkspaceSourceSchema.parse(input.source);
|
|
return input.db.transaction(async tx => {
|
|
await tx.select({ id: companies.id }).from(companies).where(eq(companies.id, input.companyId)).for("key share");
|
|
const [issue] = await tx.select().from(issues).where(and(eq(issues.companyId, input.companyId), eq(issues.id, input.issueId))).for("update");
|
|
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.id, input.runId))).for("update");
|
|
if (!issue || !run || run.agentId !== input.agentId || run.status !== "running" ||
|
|
![run.nativeIssueId, run.issueId, object(run.contextSnapshot).issueId].includes(input.issueId)) throw forbidden("Workspace preparation requires its current task execution.");
|
|
if (input.fresh && (issue.executionWorkspaceId !== input.fresh.previousWorkspaceId
|
|
|| issue.workspaceBindingRevision !== input.fresh.expectedBindingRevision)) throw conflict("The task workspace selection changed.");
|
|
let selected = input.fresh ? input.fresh.attemptId : input.existingWorkspaceId ?? issue.executionWorkspaceId;
|
|
let selectedSharedSource = false;
|
|
if (!selected && input.mode === "shared_workspace" && input.projectWorkspaceId) {
|
|
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`remote-shared-workspace:${input.companyId}:${input.projectWorkspaceId}`}, 0))`);
|
|
const [shared] = await tx.select({ id: executionWorkspaces.id }).from(executionWorkspaces).where(and(
|
|
eq(executionWorkspaces.companyId, input.companyId), eq(executionWorkspaces.projectWorkspaceId, input.projectWorkspaceId),
|
|
eq(executionWorkspaces.mode, "shared_workspace"), eq(executionWorkspaces.status, "active"),
|
|
sql`${executionWorkspaces.metadata}->'remoteWorkspace'->>'schema' = 'paperclip.remote-workspace.v1'`,
|
|
)).limit(1);
|
|
selected = shared?.id ?? null;
|
|
selectedSharedSource = selected !== null;
|
|
}
|
|
const [selectedRow] = selected ? await tx.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.companyId, input.companyId), eq(executionWorkspaces.id, selected))).for("update") : [];
|
|
const existing = selectedRow ?? null;
|
|
if (selected && !existing && !input.fresh) throw new Error("remote_workspace_missing");
|
|
const authority = readRemoteWorkspaceAuthority(existing?.metadata);
|
|
if (existing && !authority) throw conflict("Import this existing workspace before enabling remote storage.", { code: "remote_workspace_migration_required" });
|
|
if (existing && authority) {
|
|
if (["archived", "cleanup_failed"].includes(existing.status)) throw conflict("Workspace ownership changed.");
|
|
if (existing.sourceIssueId !== input.issueId) {
|
|
const actor = { type: "agent", source: "agent_jwt", companyId: input.companyId,
|
|
agentId: input.agentId, runId: input.runId,
|
|
onBehalfOfUserId: run.responsibleUserId === "local-board" ? null : run.responsibleUserId } as const;
|
|
await assertTaskWorkspaceExecutionAccess(tx, actor, input.companyId, selectedSharedSource
|
|
? { issueId: input.issueId, projectWorkspaceId: input.projectWorkspaceId }
|
|
: { issueId: input.issueId, workspaceId: existing.id });
|
|
await assertExecutionWorkspaceRecordAccess(tx, actor, input.companyId, existing.id);
|
|
}
|
|
const [uncertain] = await tx.select({ id: workspaceOperations.id }).from(workspaceOperations).where(and(
|
|
eq(workspaceOperations.companyId, input.companyId), eq(workspaceOperations.executionWorkspaceId, existing.id),
|
|
eq(workspaceOperations.phase, "remote_workspace_provision"), sql`${workspaceOperations.metadata}->>'uncertain' = 'true'`,
|
|
sql`${workspaceOperations.heartbeatRunId} is distinct from ${input.runId}::uuid`,
|
|
)).limit(1);
|
|
if (uncertain) throw conflict("The prior provisioning operation needs verification before this workspace can run again.", { code: "remote_workspace_provision_unverified" });
|
|
const hold = await readRemoteWorkspaceWriterHold(tx as unknown as Db, { companyId: input.companyId, authority, runId: input.runId });
|
|
if (hold.kind === "held") throw new RemoteWorkspaceWriterActiveError(existing.id, hold.writer);
|
|
if (hold.kind === "unattributed") throw conflict("The workspace still has an unfinished writer or save operation.", { code: "remote_workspace_writer_pending" });
|
|
if (authority.writerRunId === input.runId) return existing;
|
|
const next: RemoteWorkspaceAuthority = { ...authority, source, epoch: authority.epoch + 1, writerRunId: input.runId };
|
|
const [saved] = await tx.update(executionWorkspaces).set({ metadata: { ...existing.metadata, remoteWorkspace: next,
|
|
...(authority.snapshotId && existing.metadata?.remoteWorkspaceSnapshotRepositories === undefined
|
|
? { remoteWorkspaceSnapshotRepositories: { snapshotId: authority.snapshotId, repositories: authority.source.repositories ?? [] } } : {}),
|
|
}, updatedAt: new Date() })
|
|
.where(eq(executionWorkspaces.id, existing.id)).returning();
|
|
if (issue.executionWorkspaceId !== existing.id) await tx.update(issues).set({ executionWorkspaceId: existing.id,
|
|
executionWorkspacePreference: "reuse_existing", workspaceBindingRevision: issue.workspaceBindingRevision + 1,
|
|
updatedAt: new Date() }).where(eq(issues.id, issue.id));
|
|
return saved!;
|
|
}
|
|
const id = input.fresh?.attemptId ?? randomUUID();
|
|
const initial: RemoteWorkspaceAuthority = { schema: "paperclip.remote-workspace.v1", epoch: 1,
|
|
snapshotId: null, source, location: null, writerRunId: input.runId, dirty: false };
|
|
const [workspace] = await tx.insert(executionWorkspaces).values({ id, companyId: input.companyId, sourceIssueId: input.issueId,
|
|
projectId: input.projectId, projectWorkspaceId: input.projectWorkspaceId, mode: input.mode ?? "isolated_workspace",
|
|
strategyType: source.kind === "git" ? "git_worktree" : "project_primary", name: "Task workspace", status: "active",
|
|
providerType: "cloud_sandbox", cwd: `/workspaces/${id}`, repoUrl: source.kind === "git" ? source.repoUrl : null,
|
|
baseRef: source.kind === "git" ? source.ref : null, branchName: source.kind === "git" ? source.branchName ?? null : null,
|
|
metadata: { remoteWorkspace: initial, ...(input.recoverySourceRunIds?.length
|
|
? { remoteWorkspaceRecoverySourceRunIds: input.recoverySourceRunIds } : {}) },
|
|
}).returning();
|
|
await tx.update(issues).set({ executionWorkspaceId: id, executionWorkspacePreference: "reuse_existing",
|
|
workspaceBindingRevision: issue.workspaceBindingRevision + 1, updatedAt: new Date() }).where(eq(issues.id, issue.id));
|
|
await persistActivity(tx as unknown as Db, { companyId: input.companyId, actorType: "system", actorId: "remote-workspaces",
|
|
action: "workspace.remote_created", entityType: "execution_workspace", entityId: id, runId: input.runId,
|
|
issueId: input.issueId, details: { authority: "sandbox", epoch: initial.epoch } });
|
|
return workspace!;
|
|
});
|
|
}
|
|
|
|
async function persistRunReference(tx: Transaction, companyId: string, runId: string, value: RemoteWorkspaceRunReference) {
|
|
const [run] = await tx.select({ profile: heartbeatRuns.runnerProfileJson, nativeIssueId: heartbeatRuns.nativeIssueId,
|
|
issueId: heartbeatRuns.issueId, contextIssueId: sql<unknown>`${heartbeatRuns.contextSnapshot}->'issueId'` }).from(heartbeatRuns)
|
|
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId))).for("update");
|
|
if (!run) throw new Error("remote_workspace_run_missing");
|
|
await notifyCancelledWorkspacePreservation(tx, { companyId, runId });
|
|
await tx.update(heartbeatRuns).set({ runnerProfileJson: { ...run.profile, remoteWorkspace: value }, updatedAt: new Date() })
|
|
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)));
|
|
return run.nativeIssueId ?? run.issueId ?? (typeof run.contextIssueId === "string" ? run.contextIssueId : null);
|
|
}
|
|
|
|
/** Retry acknowledged or explicitly retired exports, never active captures. */
|
|
async function cleanupSavedExports(input: { db: Db; companyId: string; workspaceId: string;
|
|
location: NonNullable<RemoteWorkspaceAuthority["location"]>; target: ReturnType<typeof requireRemoteWorkspaceTarget>;
|
|
assertOwned: () => Promise<void> }) {
|
|
for (;;) {
|
|
const rows = await input.db.select({ id: workspaceOperations.id, metadata: workspaceOperations.metadata,
|
|
phase: workspaceOperations.phase }).from(workspaceOperations).where(and(
|
|
eq(workspaceOperations.companyId, input.companyId), eq(workspaceOperations.executionWorkspaceId, input.workspaceId),
|
|
inArray(workspaceOperations.phase, ["remote_workspace_snapshot", "remote_workspace_restore"]),
|
|
sql`(${workspaceOperations.status} = 'succeeded' or (${workspaceOperations.status} = 'failed'
|
|
and ${workspaceOperations.metadata}->'retiredSave'->>'schema' = ${RETIRED_SAVE_SCHEMA}))`,
|
|
sql`(${workspaceOperations.metadata}->'saveWork'->>'schema' is distinct from ${SAVE_WORK_SCHEMA}
|
|
or (${workspaceOperations.metadata}#>'{saveWork,unknown}' = 'false'::jsonb and case when jsonb_typeof(${workspaceOperations.metadata}#>'{saveWork,activeIds}') = 'array'
|
|
then jsonb_array_length(${workspaceOperations.metadata}#>'{saveWork,activeIds}') = 0 else false end))`,
|
|
sql`${workspaceOperations.metadata}->'exportCleanup'->>'state' = 'pending'`,
|
|
sql`${workspaceOperations.metadata}->'exportCleanup'->>'environmentId' = ${input.location.environmentId}`,
|
|
sql`${workspaceOperations.metadata}->'exportCleanup'->>'providerLeaseId' = ${input.location.providerLeaseId}`,
|
|
sql`${workspaceOperations.metadata}->'exportCleanup'->>'remoteCwd' = ${input.location.remoteCwd}`,
|
|
)).limit(20);
|
|
if (!rows.length) return true;
|
|
for (const row of rows) {
|
|
await input.assertOwned();
|
|
let cleaned = false;
|
|
let lastError: string | null = null;
|
|
try {
|
|
await cleanupRemoteWorkspaceArchive({ target: input.target,
|
|
remoteCwd: row.phase === "remote_workspace_restore" ? path.posix.dirname(input.location.remoteCwd) : input.location.remoteCwd,
|
|
operationId: row.id });
|
|
cleaned = true;
|
|
} catch (error) {
|
|
lastError = error instanceof RemoteWorkspaceOperationTimeoutError ? error.message : "remote_workspace_export_cleanup_failed";
|
|
}
|
|
await input.assertOwned();
|
|
await input.db.update(workspaceOperations).set({ metadata: sql`jsonb_set(coalesce(${workspaceOperations.metadata}, '{}'::jsonb),
|
|
'{exportCleanup}', coalesce(${workspaceOperations.metadata}->'exportCleanup', '{}'::jsonb) ||
|
|
${JSON.stringify({ state: cleaned ? "cleaned" : "pending", lastError })}::jsonb)`,
|
|
updatedAt: new Date() })
|
|
.where(and(eq(workspaceOperations.id, row.id), eq(workspaceOperations.companyId, input.companyId),
|
|
inArray(workspaceOperations.status, ["succeeded", "failed"])));
|
|
if (!cleaned) return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** The caller retains execution admission and provider ownership; this owns file authority. */
|
|
function isSavedWorkspaceRepository(workspace: Workspace, authority: RemoteWorkspaceAuthority,
|
|
repository: NonNullable<RemoteWorkspaceSource["repositories"]>[number]) {
|
|
if (authority.snapshotId === null) return false;
|
|
const receipt = workspace.metadata?.remoteWorkspaceSnapshotRepositories;
|
|
let repositories = authority.source.repositories ?? [];
|
|
if (receipt !== undefined) {
|
|
const saved = object(receipt);
|
|
if (saved.snapshotId !== authority.snapshotId || !Array.isArray(saved.repositories)) throw new Error("remote_workspace_snapshot_source_mismatch");
|
|
repositories = remoteWorkspaceSourceSchema.parse({ kind: "empty", repositories: saved.repositories }).repositories ?? [];
|
|
}
|
|
return repositories.some(saved =>
|
|
saved.id === repository.id && saved.relativePath === repository.relativePath && saved.repoUrl === repository.repoUrl
|
|
&& (saved.ref ?? "HEAD") === (repository.ref ?? "HEAD") && saved.pinnedCommit === repository.pinnedCommit
|
|
&& (saved.readOnly ?? false) === (repository.readOnly ?? false));
|
|
}
|
|
|
|
function publishedWorkspaceDirectory(workspace: Workspace & Pick<typeof executionWorkspaces.$inferSelect, "providerRef">,
|
|
authority: RemoteWorkspaceAuthority) {
|
|
const publishedRoot = authority.snapshotId && (authority.location?.remoteCwd ?? workspace.providerRef);
|
|
if (!publishedRoot) return null;
|
|
const directory = path.posix.basename(publishedRoot);
|
|
const suffix = directory.slice(workspace.id.length);
|
|
if (!path.posix.isAbsolute(publishedRoot) || path.posix.basename(path.posix.dirname(publishedRoot)) !== ".paperclip-workspaces"
|
|
|| !directory.startsWith(workspace.id) || (suffix !== "" && !/^-[1-9][0-9]*-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(suffix))) {
|
|
throw new Error("remote_workspace_published_path_invalid");
|
|
}
|
|
return directory;
|
|
}
|
|
|
|
/**
|
|
* Whether the root at `remoteRoot` on this lease's sandbox is this workspace's
|
|
* own earlier allocation whose last use ended in a completed final save. A
|
|
* retained sandbox keeps that root after the workspace moves on, for example
|
|
* when child tasks inherit the workspace and save newer work elsewhere. Its
|
|
* files are then already part of the saved lineage being restored.
|
|
*/
|
|
async function isRetiredOwnAllocation(db: Db, workspace: Workspace, lease: EnvironmentLease, remoteRoot: string, epoch: number) {
|
|
if (!lease.environmentId || !lease.providerLeaseId) return false;
|
|
const rows = await db.select({ id: workspaceOperations.id, phase: workspaceOperations.phase, status: workspaceOperations.status,
|
|
metadata: workspaceOperations.metadata }).from(workspaceOperations).where(and(
|
|
eq(workspaceOperations.companyId, workspace.companyId), eq(workspaceOperations.executionWorkspaceId, workspace.id),
|
|
inArray(workspaceOperations.phase, ["remote_workspace_snapshot", "remote_workspace_restore"]),
|
|
sql`${workspaceOperations.metadata}->'exportCleanup'->>'environmentId' = ${lease.environmentId}`,
|
|
sql`${workspaceOperations.metadata}->'exportCleanup'->>'providerLeaseId' = ${lease.providerLeaseId}`,
|
|
sql`${workspaceOperations.metadata}->'exportCleanup'->>'remoteCwd' = ${remoteRoot}`));
|
|
const epochOf = (metadata: Record<string, unknown> | null) => Number(metadata?.epoch);
|
|
if (!rows.length || rows.some(row => !Number.isSafeInteger(epochOf(row.metadata)))) return false;
|
|
// Every later writer must have used another allocation.
|
|
const last = Math.max(...rows.map(row => epochOf(row.metadata)));
|
|
if (last >= epoch) return false;
|
|
const lastUse = rows.filter(row => epochOf(row.metadata) === last);
|
|
const saves = lastUse.filter(row => row.phase === "remote_workspace_snapshot" && object(row.metadata?.retiredSave).schema !== RETIRED_SAVE_SCHEMA);
|
|
if (saves.some(row => row.status !== "succeeded" && row.status !== "failed")) return false;
|
|
const generation = (row: typeof rows[number]) => Number(row.metadata?.generation);
|
|
const final = saves.filter(row => row.status === "succeeded").sort((a, b) => generation(b) - generation(a))[0];
|
|
const work = readSaveWork(final?.metadata);
|
|
if (!final || final.metadata?.boundary !== "final" || !work || work.unknown || work.activeIds.length
|
|
|| saves.some(row => row !== final && !(generation(row) < generation(final)))) return false;
|
|
const [snapshot] = await db.select({ status: executionWorkspaceSnapshots.status }).from(executionWorkspaceSnapshots).where(and(
|
|
eq(executionWorkspaceSnapshots.companyId, workspace.companyId), eq(executionWorkspaceSnapshots.executionWorkspaceId, workspace.id),
|
|
eq(executionWorkspaceSnapshots.sourceOperationId, final.id)));
|
|
return Boolean(snapshot && !["pending", "uploaded"].includes(snapshot.status));
|
|
}
|
|
|
|
export async function prepareRemoteWorkspace(input: {
|
|
db: Db; workspace: Workspace & Pick<typeof executionWorkspaces.$inferSelect, "providerRef">;
|
|
runId: string; lease: EnvironmentLease; target: AdapterExecutionTarget | null;
|
|
source?: RemoteWorkspaceSource; assertOwned?: () => Promise<void>;
|
|
provisionCommand?: string | null; runtimeProvisionCommand?: string | null; storage?: StorageService;
|
|
}) {
|
|
const target = requireRemoteWorkspaceTarget(input.target);
|
|
const initial = readRemoteWorkspaceAuthority(input.workspace.metadata);
|
|
if ((input.source ?? initial?.source)?.repositories?.some(repository => repository.readOnly)) {
|
|
throw conflict("The selected native sandbox cannot enforce read-only referenced project roots.",
|
|
{ code: "remote_workspace_readonly_sources_unsupported" });
|
|
}
|
|
if (!initial || initial.writerRunId !== input.runId || !input.lease.environmentId || !input.lease.providerLeaseId
|
|
|| input.lease.companyId !== input.workspace.companyId || input.lease.heartbeatRunId !== input.runId) throw new Error("remote_workspace_binding_mismatch");
|
|
await input.assertOwned?.();
|
|
const sameAllocation = initial.location?.providerLeaseId === input.lease.providerLeaseId
|
|
&& initial.location?.environmentId === input.lease.environmentId;
|
|
if (initial.location && !sameAllocation && initial.dirty) throw conflict("The prior workspace has unsaved work; recover it before replacing its allocation.");
|
|
const migration = input.workspace.metadata?.nativeWorkspaceMigration
|
|
? await readNativeWorkspaceMigration(input.db, input.workspace) : null;
|
|
if (migration && migration.remoteCwd !== target.remoteCwd) throw new Error("native_workspace_migration_target_changed");
|
|
const remoteRoot = sameAllocation ? initial.location!.remoteCwd : migration?.remoteCwd ?? path.posix.join(target.remoteCwd, ".paperclip-workspaces",
|
|
publishedWorkspaceDirectory(input.workspace, initial) ?? `${input.workspace.id}-${initial.epoch}-${input.lease.id}`);
|
|
const preparationIdentity = createHash("sha256").update(JSON.stringify([input.workspace.companyId, input.workspace.id,
|
|
input.runId, initial.epoch, input.lease.id, input.lease.providerLeaseId, remoteRoot, initial.source])).digest("hex");
|
|
let restoredExport: Awaited<ReturnType<typeof restoreRemoteWorkspaceDirectory>> | null = null;
|
|
if (sameAllocation && !await cleanupSavedExports({ db: input.db, companyId: input.workspace.companyId,
|
|
workspaceId: input.workspace.id, location: initial.location!, target,
|
|
assertOwned: input.assertOwned ?? (async () => undefined) })) throw new Error("remote_workspace_export_cleanup_pending");
|
|
if (!sameAllocation) {
|
|
if (initial.snapshotId) {
|
|
let step: RemoteWorkspaceRestoreStep = "snapshot_lookup";
|
|
try {
|
|
const snapshot = await getWorkspaceSnapshot(input.db, { companyId: input.workspace.companyId, workspaceId: input.workspace.id, snapshotId: initial.snapshotId });
|
|
if (!snapshot) throw new Error("remote_workspace_snapshot_missing");
|
|
const replaceRetiredRoot = migration === null && !initial.dirty
|
|
&& await isRetiredOwnAllocation(input.db, input.workspace, input.lease, remoteRoot, initial.epoch);
|
|
const scratch = await mkdtemp(path.join(os.tmpdir(), "pc-workspace-restore-"));
|
|
try {
|
|
const archivePath = path.join(scratch, "workspace.pcws");
|
|
step = "snapshot_download";
|
|
await loadWorkspaceSnapshotArchive({ storage: input.storage ?? getStorageService(), reference: snapshot, targetPath: archivePath });
|
|
step = "sandbox_restore";
|
|
restoredExport = await restoreRemoteWorkspaceDirectory({ target, remoteCwd: remoteRoot, archivePath, manifestDigest: snapshot.manifestDigest,
|
|
restorationIdentity: preparationIdentity, allowEmptyRoot: migration !== null,
|
|
allocationFiles: migration ? workspaceAllocationFiles(input.lease, remoteRoot) : [],
|
|
replaceRetiredRoot });
|
|
} finally { await rm(scratch, { recursive: true, force: true }); }
|
|
} catch (error) {
|
|
// Cancellation keeps its own handling, as do lookup refusals.
|
|
if ((error instanceof Error && error.name === "AbortError") || (error instanceof HttpError && step === "snapshot_lookup")) throw error;
|
|
// Storage reports a missing saved object as 404. Retrying cannot restore it.
|
|
const objectMissing = error instanceof HttpError && step === "snapshot_download" && error.status === 404;
|
|
if (objectMissing) throw new RemoteWorkspaceRestoreError(step, false, new Error("remote_workspace_snapshot_object_missing"));
|
|
// A missing saved version cannot be fixed by retrying. Otherwise a retry
|
|
// is offered only while this run still owns the same unchanged, clean
|
|
// saved version; a failed restore never publishes over existing files.
|
|
// A sandbox refusal that names its reason (an occupied folder, a changed
|
|
// owner, a rejected archive) repeats on retry, so it goes to the board.
|
|
let retryable = false;
|
|
const refused = error instanceof Error && / (?:reason|archive)=[a-z_]+/.test(error.message);
|
|
if (step !== "snapshot_lookup" && !refused) {
|
|
const [latest] = await input.db.select({ metadata: executionWorkspaces.metadata }).from(executionWorkspaces)
|
|
.where(and(eq(executionWorkspaces.companyId, input.workspace.companyId), eq(executionWorkspaces.id, input.workspace.id)));
|
|
const current = readRemoteWorkspaceAuthority(latest?.metadata);
|
|
retryable = Boolean(current && current.epoch === initial.epoch && current.writerRunId === input.runId
|
|
&& !current.dirty && current.snapshotId === initial.snapshotId);
|
|
}
|
|
throw new RemoteWorkspaceRestoreError(step, retryable, error);
|
|
}
|
|
} else {
|
|
try {
|
|
await prepareRemoteWorkspaceDirectory({ target, remoteCwd: remoteRoot, source: initial.source,
|
|
preparationIdentity,
|
|
resolveGitAuth: await createRemoteWorkspaceGitAuth(input.db, input.workspace.companyId, input.runId) });
|
|
} catch (error) {
|
|
if (error instanceof RemoteWorkspaceOperationTimeoutError && error.operation === "prepare"
|
|
&& !initial.dirty && !initial.location) {
|
|
const [latest] = await input.db.select({ metadata: executionWorkspaces.metadata }).from(executionWorkspaces)
|
|
.where(and(eq(executionWorkspaces.companyId, input.workspace.companyId), eq(executionWorkspaces.id, input.workspace.id)));
|
|
const current = readRemoteWorkspaceAuthority(latest?.metadata);
|
|
if (current && current.epoch === initial.epoch && current.writerRunId === input.runId
|
|
&& !current.dirty && !current.location && !current.snapshotId) {
|
|
throw new RemoteWorkspacePreparationTimeoutError(error);
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
for (const repository of (input.source ?? initial.source).repositories ?? []) {
|
|
await input.assertOwned?.();
|
|
// Only repositories already owned by the saved authority may rely on the
|
|
// archive when their old local origin was intentionally omitted. New source
|
|
// descriptors still follow normal acquisition and origin verification.
|
|
await prepareRemoteRepository({ target, remoteCwd: remoteWorkspaceRepositoryPath(remoteRoot, repository),
|
|
repoUrl: repository.repoUrl, ref: repository.ref, pinnedCommit: repository.pinnedCommit,
|
|
savedArchive: isSavedWorkspaceRepository(input.workspace, initial, repository),
|
|
resolveGitAuth: await createRemoteWorkspaceGitAuth(input.db, input.workspace.companyId, input.runId) });
|
|
}
|
|
const recoveredFiles: Array<{ operationId: string; sourceRunId: string; path: string }> = [];
|
|
const recoverySourceRunIds = input.workspace.metadata?.remoteWorkspaceRecoverySourceRunIds;
|
|
if (!sameAllocation && Array.isArray(recoverySourceRunIds) && recoverySourceRunIds.length) {
|
|
// Shared and inherited workspaces retain their original source task. The
|
|
// current writer's task owns its recovery attachments, including a child's
|
|
// preserved files recorded during migration of an ancestor's workspace.
|
|
const [run] = await input.db.select({ nativeIssueId: heartbeatRuns.nativeIssueId, issueId: heartbeatRuns.issueId,
|
|
contextIssueId: sql<unknown>`${heartbeatRuns.contextSnapshot}->'issueId'` }).from(heartbeatRuns)
|
|
.where(and(eq(heartbeatRuns.companyId, input.workspace.companyId), eq(heartbeatRuns.id, input.runId)));
|
|
const issueId = run?.nativeIssueId ?? run?.issueId ?? run?.contextIssueId;
|
|
if (typeof issueId !== "string") throw new Error("remote_workspace_recovery_task_missing");
|
|
const scratch = await mkdtemp(path.join(os.tmpdir(), "pc-workspace-recovery-import-"));
|
|
try {
|
|
const recovered = await attachPreservedWorkspaceSnapshots({ db: input.db, companyId: input.workspace.companyId,
|
|
issueId, sourceRunIds: recoverySourceRunIds as string[], cwd: scratch });
|
|
for (const item of recovered) {
|
|
const relative = `.paperclip-recovered/${item.operationId}`;
|
|
await target.runner!.syncIn!([{ operationId: randomUUID(), files: [{
|
|
kind: "directory", sourcePath: item.path, targetPath: path.posix.join(remoteRoot, relative),
|
|
}] }]);
|
|
recoveredFiles.push({ operationId: item.operationId, sourceRunId: item.sourceRunId, path: relative });
|
|
}
|
|
} finally { await rm(scratch, { recursive: true, force: true }); }
|
|
}
|
|
await input.assertOwned?.();
|
|
const prepared = await input.db.transaction(async tx => {
|
|
await lockWorkspaceRunParents(tx, input.workspace.companyId, input.runId);
|
|
const workspace = await currentWorkspace(tx, input.workspace.companyId, input.workspace.id);
|
|
const current = readRemoteWorkspaceAuthority(workspace.metadata);
|
|
if (!current || current.epoch !== initial.epoch || current.writerRunId !== input.runId) throw new Error("remote_workspace_authority_changed");
|
|
// The same run can retry with a different allocation and stale preparation
|
|
// input. Its controller lease alone cannot replace already-published dirty files.
|
|
if (current.dirty && current.location && (current.location.environmentId !== input.lease.environmentId
|
|
|| current.location.providerLeaseId !== input.lease.providerLeaseId || current.location.leaseId !== input.lease.id
|
|
|| current.location.remoteCwd !== remoteRoot)) throw new Error("remote_workspace_authority_changed");
|
|
const authority: RemoteWorkspaceAuthority = { ...current, dirty: true, location: { environmentId: input.lease.environmentId!,
|
|
leaseId: input.lease.id, providerLeaseId: input.lease.providerLeaseId!, remoteCwd: remoteRoot } };
|
|
const [lease] = await tx.select().from(environmentLeases).where(and(eq(environmentLeases.id, input.lease.id),
|
|
eq(environmentLeases.companyId, workspace.companyId))).for("update");
|
|
if (!lease || lease.status !== "active" || lease.environmentId !== input.lease.environmentId
|
|
|| lease.providerLeaseId !== authority.location!.providerLeaseId || lease.heartbeatRunId !== input.runId
|
|
|| (lease.executionWorkspaceId && lease.executionWorkspaceId !== workspace.id)) throw new Error("remote_workspace_lease_changed");
|
|
const [savedLease] = await tx.update(environmentLeases).set({ metadata: { ...lease.metadata, remoteCwd: remoteRoot, remoteWorkspaceId: workspace.id }, updatedAt: new Date() })
|
|
.where(eq(environmentLeases.id, lease.id)).returning();
|
|
const [savedWorkspace] = await tx.update(executionWorkspaces).set({ cwd: remoteRoot, providerRef: remoteRoot,
|
|
metadata: { ...workspace.metadata, remoteWorkspace: authority,
|
|
...(recoveredFiles.length ? { remoteWorkspaceRecoveredFiles: recoveredFiles } : {}) }, updatedAt: new Date() }).where(eq(executionWorkspaces.id, workspace.id)).returning();
|
|
const runReference = reference(workspace.id, authority, "prepared");
|
|
const issueId = await persistRunReference(tx, workspace.companyId, input.runId, runReference);
|
|
if (restoredExport) {
|
|
const [existing] = await tx.select().from(workspaceOperations).where(eq(workspaceOperations.id, restoredExport.operationId)).for("update");
|
|
if (existing && (existing.companyId !== workspace.companyId || existing.executionWorkspaceId !== workspace.id
|
|
|| existing.heartbeatRunId !== input.runId || existing.phase !== "remote_workspace_restore")) throw new Error("remote_workspace_restore_receipt_changed");
|
|
const metadata = { epoch: authority.epoch, snapshotId: initial.snapshotId,
|
|
exportCleanup: { ...authority.location, stagingRoot: restoredExport.stagingRoot,
|
|
state: restoredExport.cleanupPending ? "pending" : "cleaned" } };
|
|
if (existing) await tx.update(workspaceOperations).set({ status: "succeeded", metadata, finishedAt: new Date(), updatedAt: new Date() })
|
|
.where(eq(workspaceOperations.id, existing.id));
|
|
else await tx.insert(workspaceOperations).values({ id: restoredExport.operationId, companyId: workspace.companyId,
|
|
executionWorkspaceId: workspace.id, heartbeatRunId: input.runId, issueId,
|
|
phase: "remote_workspace_restore", status: "succeeded", metadata, finishedAt: new Date() });
|
|
}
|
|
return { workspace: savedWorkspace!, lease: savedLease! as EnvironmentLease, remoteRoot, reference: runReference };
|
|
});
|
|
target.remoteCwd = remoteRoot;
|
|
if (restoredExport?.cleanupPending) await cleanupSavedExports({ db: input.db, companyId: input.workspace.companyId,
|
|
workspaceId: input.workspace.id, location: readRemoteWorkspaceAuthority(prepared.workspace.metadata)!.location!, target,
|
|
assertOwned: input.assertOwned ?? (async () => undefined) });
|
|
try {
|
|
await provisionRemoteWorkspace({ db: input.db, companyId: input.workspace.companyId, workspaceId: input.workspace.id,
|
|
runId: input.runId, epoch: initial.epoch, remoteCwd: remoteRoot, target, assertOwned: input.assertOwned,
|
|
commands: [!initial.snapshotId ? input.provisionCommand : null, input.runtimeProvisionCommand] });
|
|
if (!initial.snapshotId) {
|
|
await saveRemoteWorkspace({ ...input, workspace: prepared.workspace, lease: prepared.lease, target,
|
|
assertOwned: input.assertOwned ?? (async () => undefined), boundary: "initial" });
|
|
const [workspace] = await input.db.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, input.workspace.id), eq(executionWorkspaces.companyId, input.workspace.companyId)));
|
|
prepared.workspace = workspace!;
|
|
prepared.reference = reference(workspace!.id, readRemoteWorkspaceAuthority(workspace!.metadata)!, "prepared");
|
|
}
|
|
} catch (error) {
|
|
// A first save can fail before runtime selection. Its already-published
|
|
// authority is still a file obligation, independent of native admission.
|
|
await preserveRemoteWorkspacePreparationFailure(input.db, prepared.lease, { assertOwned: input.assertOwned })
|
|
.catch(preservationError => logger.error({ err: preservationError, runId: input.runId }, "Remote workspace preparation recovery was deferred; source remains protected"));
|
|
throw error;
|
|
}
|
|
return prepared;
|
|
}
|
|
|
|
/** The workspace lock fences new invocations while completed final authority
|
|
* retires only attempts whose every registered invocation has settled. */
|
|
async function retireQuiescedSaves(tx: Transaction, workspace: Workspace, runId: string, epoch: number,
|
|
location: NonNullable<RemoteWorkspaceAuthority["location"]>) {
|
|
const authority = readRemoteWorkspaceAuthority(workspace.metadata);
|
|
if (!authority || authority.epoch !== epoch || authority.dirty || authority.writerRunId !== null
|
|
|| !authority.snapshotId || !sameExportLocation(authority.location, location)) return;
|
|
const [final] = await tx.select({ id: executionWorkspaceSnapshots.id, generation: executionWorkspaceSnapshots.generation,
|
|
operationId: workspaceOperations.id, metadata: workspaceOperations.metadata }).from(executionWorkspaceSnapshots)
|
|
.innerJoin(workspaceOperations, and(eq(workspaceOperations.id, executionWorkspaceSnapshots.sourceOperationId),
|
|
eq(workspaceOperations.companyId, workspace.companyId), eq(workspaceOperations.executionWorkspaceId, workspace.id),
|
|
eq(workspaceOperations.heartbeatRunId, runId), eq(workspaceOperations.phase, "remote_workspace_snapshot"),
|
|
eq(workspaceOperations.status, "succeeded"), sql`${workspaceOperations.metadata}->>'boundary' = 'final'`,
|
|
sql`${workspaceOperations.metadata}->>'epoch' = ${String(epoch)}`))
|
|
.where(and(eq(executionWorkspaceSnapshots.companyId, workspace.companyId), eq(executionWorkspaceSnapshots.executionWorkspaceId, workspace.id),
|
|
eq(executionWorkspaceSnapshots.id, authority.snapshotId), eq(executionWorkspaceSnapshots.sourceRunId, runId),
|
|
eq(executionWorkspaceSnapshots.epoch, epoch), eq(executionWorkspaceSnapshots.status, "committed"))).limit(1);
|
|
if (!final || final.metadata?.snapshotId !== final.id || !sameExportLocation(final.metadata?.exportCleanup, location)) return;
|
|
const candidates = await tx.select().from(workspaceOperations).where(and(
|
|
eq(workspaceOperations.companyId, workspace.companyId), eq(workspaceOperations.executionWorkspaceId, workspace.id),
|
|
eq(workspaceOperations.heartbeatRunId, runId), eq(workspaceOperations.phase, "remote_workspace_snapshot"),
|
|
// A save that already ended as failed is retired the same way, so its
|
|
// export staging is still cleaned once the final save commits.
|
|
inArray(workspaceOperations.status, ["running", "pending", "failed"]), sql`${workspaceOperations.metadata}->>'epoch' = ${String(epoch)}`)).for("update");
|
|
for (const candidate of candidates) {
|
|
if (candidate.status === "failed" && (object(candidate.metadata?.retiredSave).schema === RETIRED_SAVE_SCHEMA
|
|
|| !candidate.metadata?.lastFailure)) continue;
|
|
const work = readSaveWork(candidate.metadata), generation = Number(candidate.metadata?.generation);
|
|
if (!work || work.unknown || work.activeIds.length || !work.quiescedAt || candidate.metadata?.boundary === "final"
|
|
|| !Number.isSafeInteger(generation) || generation >= final.generation
|
|
|| !sameExportLocation(candidate.metadata?.exportCleanup, location)) continue;
|
|
const [snapshot] = await tx.select({ status: executionWorkspaceSnapshots.status, generation: executionWorkspaceSnapshots.generation,
|
|
epoch: executionWorkspaceSnapshots.epoch, runId: executionWorkspaceSnapshots.sourceRunId }).from(executionWorkspaceSnapshots).where(and(
|
|
eq(executionWorkspaceSnapshots.companyId, workspace.companyId), eq(executionWorkspaceSnapshots.executionWorkspaceId, workspace.id),
|
|
eq(executionWorkspaceSnapshots.sourceOperationId, candidate.id))).for("update");
|
|
if (snapshot && (!["pending", "uploaded"].includes(snapshot.status) || snapshot.generation !== generation
|
|
|| snapshot.epoch !== epoch || snapshot.runId !== runId)) continue;
|
|
const at = new Date();
|
|
await notifyRemoteWorkspaceSnapshots(tx, `workspace:${workspace.id}`);
|
|
await tx.update(workspaceOperations).set({ status: "failed", finishedAt: at, updatedAt: at,
|
|
metadata: { ...candidate.metadata, retiredSave: { schema: RETIRED_SAVE_SCHEMA, snapshotId: final.id,
|
|
operationId: final.operationId, retiredAt: at.toISOString() } } }).where(eq(workspaceOperations.id, candidate.id));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The redacted reason a save invocation failed: a server-built remote cause, an
|
|
* internal snake_case code, or a fixed fallback. Provider text is never kept.
|
|
*/
|
|
export function remoteWorkspaceSaveFailure(error: unknown): { code: string; cause?: string } {
|
|
const cause = remoteWorkspaceFailureCause(error);
|
|
const message = error instanceof Error ? error.message.trim() : "";
|
|
const code = cause ? cause.split(":")[0]! : /^[a-z][a-z0-9_]{2,80}$/.test(message) ? message : "remote_workspace_save_failed";
|
|
return cause ? { code, cause } : { code };
|
|
}
|
|
|
|
async function settleSaveInvocation(input: { db: Db; workspace: Workspace; runId: string; epoch: number;
|
|
location: NonNullable<RemoteWorkspaceAuthority["location"]>; operationId: string; invocationId: string;
|
|
failure?: { code: string; cause?: string } }) {
|
|
return input.db.transaction(async tx => {
|
|
const workspace = await currentWorkspace(tx, input.workspace.companyId, input.workspace.id);
|
|
const [operation] = await tx.select().from(workspaceOperations).where(and(eq(workspaceOperations.id, input.operationId),
|
|
eq(workspaceOperations.companyId, workspace.companyId), eq(workspaceOperations.executionWorkspaceId, workspace.id),
|
|
eq(workspaceOperations.heartbeatRunId, input.runId), sql`${workspaceOperations.metadata}->>'epoch' = ${String(input.epoch)}`)).for("update");
|
|
const work = readSaveWork(operation?.metadata);
|
|
if (!operation || !work || !work.activeIds.includes(input.invocationId)) return false;
|
|
const activeIds = work.activeIds.filter(id => id !== input.invocationId);
|
|
if (!activeIds.length && !work.unknown) await notifyRemoteWorkspaceSnapshots(tx, `workspace:${workspace.id}`);
|
|
const now = new Date();
|
|
// The last settled invocation of a failed save ends the operation. A later
|
|
// retry of the same boundary reopens it; a success still records "succeeded".
|
|
const failure = input.failure;
|
|
const failed = Boolean(failure) && operation.status === "running" && !activeIds.length && !work.unknown;
|
|
await tx.update(workspaceOperations).set({ metadata: { ...operation.metadata,
|
|
saveWork: { ...work, activeIds, quiescedAt: activeIds.length ? null : now.toISOString() },
|
|
...(failed && failure ? { lastFailure: { ...failure, at: now.toISOString() } } : {}) },
|
|
...(failed && failure ? { status: "failed", exitCode: 1, finishedAt: now,
|
|
stderrExcerpt: `${[failure.code, failure.cause].filter(Boolean).join("\n")}\n` } : {}),
|
|
updatedAt: now })
|
|
.where(eq(workspaceOperations.id, operation.id));
|
|
await retireQuiescedSaves(tx, workspace, input.runId, input.epoch, input.location);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
export async function saveRemoteWorkspace(input: {
|
|
db: Db; workspace: Workspace; runId: string; lease: EnvironmentLease; target: AdapterExecutionTarget | null;
|
|
assertOwned: () => Promise<void>; storage?: StorageService; boundary?: string;
|
|
}) {
|
|
const target = requireRemoteWorkspaceTarget(input.target), boundary = input.boundary ?? "final";
|
|
const expected = readRemoteWorkspaceAuthority(input.workspace.metadata);
|
|
if (!expected?.location || expected.location.providerLeaseId !== input.lease.providerLeaseId || expected.location.leaseId !== input.lease.id) throw new Error("remote_workspace_save_binding_mismatch");
|
|
await input.assertOwned();
|
|
const invocationId = randomUUID();
|
|
const operation = await input.db.transaction(async tx => {
|
|
const issueId = await lockWorkspaceRunParents(tx, input.workspace.companyId, input.runId);
|
|
const workspace = await currentWorkspace(tx, input.workspace.companyId, input.workspace.id);
|
|
const current = readRemoteWorkspaceAuthority(workspace.metadata);
|
|
if (!current || current.epoch !== expected.epoch || (current.writerRunId !== input.runId && current.dirty)) throw new Error("remote_workspace_authority_changed");
|
|
const [previous] = await tx.select().from(workspaceOperations).where(and(eq(workspaceOperations.companyId, workspace.companyId),
|
|
eq(workspaceOperations.executionWorkspaceId, workspace.id), eq(workspaceOperations.heartbeatRunId, input.runId),
|
|
eq(workspaceOperations.phase, "remote_workspace_snapshot"), sql`${workspaceOperations.metadata}->>'boundary' = ${boundary}`,
|
|
sql`${workspaceOperations.metadata}->>'epoch' = ${String(current.epoch)}`)).limit(1).for("update");
|
|
if (previous) {
|
|
if (previous.issueId !== issueId) throw new Error("remote_workspace_run_binding_changed");
|
|
if (object(previous.metadata?.retiredSave).schema === RETIRED_SAVE_SCHEMA) throw new Error("remote_workspace_snapshot_retired");
|
|
const work = readSaveWork(previous.metadata);
|
|
// A retry of a failed save reopens that boundary's operation.
|
|
const reopened = previous.status === "failed" ? { status: "running", finishedAt: null, exitCode: null } : {};
|
|
const [registered] = await tx.update(workspaceOperations).set({ ...reopened, metadata: { ...previous.metadata,
|
|
saveWork: { schema: SAVE_WORK_SCHEMA, activeIds: [...(work?.activeIds ?? []), invocationId],
|
|
unknown: work?.unknown ?? true, quiescedAt: null } }, updatedAt: new Date() }).where(eq(workspaceOperations.id, previous.id)).returning();
|
|
return registered!;
|
|
}
|
|
// The workspace lock serializes generation reservations. Failed/deleted
|
|
// snapshots still own their generation, and an operation can be committed
|
|
// before beginWorkspaceSnapshot creates its ledger row. Count both journals
|
|
// across epochs while retaining the last committed snapshot as the parent.
|
|
const [reservation] = await tx.execute(sql`select greatest(
|
|
coalesce((select max(${executionWorkspaceSnapshots.generation}) from ${executionWorkspaceSnapshots}
|
|
where ${executionWorkspaceSnapshots.companyId} = ${workspace.companyId}
|
|
and ${executionWorkspaceSnapshots.executionWorkspaceId} = ${workspace.id}), 0),
|
|
coalesce((select max(case when ${workspaceOperations.metadata}->>'generation' ~ '^[0-9]+$'
|
|
then (${workspaceOperations.metadata}->>'generation')::numeric else 0 end) from ${workspaceOperations}
|
|
where ${workspaceOperations.companyId} = ${workspace.companyId}
|
|
and ${workspaceOperations.executionWorkspaceId} = ${workspace.id}
|
|
and ${workspaceOperations.phase} = 'remote_workspace_snapshot'), 0)
|
|
) as generation`);
|
|
const generation = Number(reservation?.generation) + 1;
|
|
if (!Number.isSafeInteger(generation) || generation < 1) throw new Error("remote_workspace_snapshot_generation_invalid");
|
|
const [created] = await tx.insert(workspaceOperations).values({ companyId: workspace.companyId, executionWorkspaceId: workspace.id,
|
|
heartbeatRunId: input.runId, issueId, phase: "remote_workspace_snapshot", status: "running",
|
|
metadata: { boundary, epoch: current.epoch, parentSnapshotId: current.snapshotId, generation,
|
|
saveWork: { schema: SAVE_WORK_SCHEMA, activeIds: [invocationId], unknown: false, quiescedAt: null },
|
|
exportCleanup: { ...current.location, state: "pending" } },
|
|
}).returning();
|
|
return created!;
|
|
});
|
|
let captured: Awaited<ReturnType<typeof captureRemoteWorkspaceArchive>> | null = null;
|
|
let failure: { code: string; cause?: string } | undefined;
|
|
try {
|
|
const snapshot = await beginWorkspaceSnapshot(input.db, { companyId: input.workspace.companyId, workspaceId: input.workspace.id,
|
|
operationId: operation.id, runId: input.runId, parentSnapshotId: operation.metadata?.parentSnapshotId as string | null,
|
|
generation: operation.metadata?.generation as number, epoch: expected.epoch });
|
|
if (operation.status === "succeeded" && ["committed", "superseded"].includes(snapshot.status)) {
|
|
return { snapshotId: snapshot.id, generation: snapshot.generation, sha256: snapshot.sha256!, savedAt: snapshot.updatedAt.toISOString() };
|
|
}
|
|
const existingUpload = snapshot.status === "uploaded" ? await getWorkspaceSnapshot(input.db, { companyId: input.workspace.companyId,
|
|
workspaceId: input.workspace.id, snapshotId: snapshot.id }) : null;
|
|
captured = existingUpload ? null : await captureRemoteWorkspaceArchive({ target, remoteCwd: expected.location.remoteCwd, operationId: operation.id });
|
|
await input.assertOwned();
|
|
const saved = await uploadWorkspaceSnapshot({ db: input.db, storage: input.storage ?? getStorageService(), snapshotId: snapshot.id,
|
|
companyId: input.workspace.companyId, workspaceId: input.workspace.id, archivePath: captured?.archivePath ?? "",
|
|
manifestDigest: existingUpload?.manifestDigest ?? captured!.manifestDigest });
|
|
await input.assertOwned();
|
|
const at = new Date();
|
|
await input.db.transaction(async tx => {
|
|
await lockWorkspaceRunParents(tx, input.workspace.companyId, input.runId, operation.issueId);
|
|
const workspace = await currentWorkspace(tx, input.workspace.companyId, input.workspace.id);
|
|
const current = readRemoteWorkspaceAuthority(workspace.metadata);
|
|
if (!current || current.epoch !== expected.epoch || current.writerRunId !== input.runId || current.snapshotId !== snapshot.parentSnapshotId) {
|
|
if (current?.epoch === expected.epoch && current.snapshotId === saved.id) return;
|
|
throw new Error("remote_workspace_publication_fenced");
|
|
}
|
|
const authority: RemoteWorkspaceAuthority = { ...current, snapshotId: saved.id,
|
|
dirty: boundary !== "final", writerRunId: boundary !== "final" ? input.runId : null };
|
|
const [committed] = await tx.update(executionWorkspaceSnapshots).set({ status: "committed", updatedAt: at }).where(and(
|
|
eq(executionWorkspaceSnapshots.id, saved.id), eq(executionWorkspaceSnapshots.companyId, workspace.companyId),
|
|
eq(executionWorkspaceSnapshots.executionWorkspaceId, workspace.id), eq(executionWorkspaceSnapshots.status, "uploaded"))).returning({ id: executionWorkspaceSnapshots.id });
|
|
if (!committed) throw new Error("remote_workspace_snapshot_publication_changed");
|
|
await notifyRemoteWorkspaceSnapshots(tx, `workspace:${workspace.id}`);
|
|
if (boundary === "final") await notifyTerminalWorkspaceWork(tx, `workspace:${workspace.id}`);
|
|
await tx.update(executionWorkspaces).set({ metadata: { ...workspace.metadata, remoteWorkspace: authority,
|
|
remoteWorkspaceSnapshotRepositories: { snapshotId: saved.id, repositories: expected.source.repositories ?? [] },
|
|
}, updatedAt: at }).where(eq(executionWorkspaces.id, workspace.id));
|
|
await tx.update(workspaceOperations).set({ status: "succeeded", finishedAt: at, updatedAt: at,
|
|
metadata: sql`coalesce(${workspaceOperations.metadata}, '{}'::jsonb) || ${JSON.stringify({ snapshotId: saved.id, sha256: saved.sha256 })}::jsonb` })
|
|
.where(eq(workspaceOperations.id, operation.id));
|
|
await persistRunReference(tx, workspace.companyId, input.runId, reference(workspace.id, authority, boundary === "final" ? "saved" : "prepared"));
|
|
await persistActivity(tx as unknown as Db, { companyId: workspace.companyId, actorType: "system", actorId: "remote-workspaces",
|
|
action: "workspace.snapshot_saved", entityType: "execution_workspace", entityId: workspace.id, runId: input.runId,
|
|
details: { snapshotId: saved.id, generation: saved.generation, epoch: current.epoch, boundary } });
|
|
if (snapshot.parentSnapshotId) await tx.update(executionWorkspaceSnapshots).set({ status: "superseded",
|
|
deleteAfter: new Date(at.getTime() + 7 * 86400_000), updatedAt: at }).where(and(eq(executionWorkspaceSnapshots.id, snapshot.parentSnapshotId),
|
|
eq(executionWorkspaceSnapshots.companyId, workspace.companyId), eq(executionWorkspaceSnapshots.executionWorkspaceId, workspace.id)));
|
|
});
|
|
return { snapshotId: saved.id, generation: saved.generation, sha256: saved.sha256, savedAt: at.toISOString() };
|
|
} catch (error) {
|
|
failure = remoteWorkspaceSaveFailure(error);
|
|
throw error;
|
|
} finally {
|
|
// Every upload/capture promise has settled before its durable token clears.
|
|
// Missing completion evidence remains pinned; cleanup never masks the save error.
|
|
try { await captured?.cleanup(); }
|
|
catch { logger.warn({ runId: input.runId, operationId: operation.id }, "Remote workspace local archive cleanup failed"); }
|
|
try {
|
|
if (await settleSaveInvocation({ db: input.db, workspace: input.workspace, runId: input.runId, epoch: expected.epoch,
|
|
location: expected.location, operationId: operation.id, invocationId, failure })) {
|
|
await cleanupSavedExports({ db: input.db, companyId: input.workspace.companyId, workspaceId: input.workspace.id,
|
|
location: expected.location, target, assertOwned: input.assertOwned });
|
|
}
|
|
} catch { logger.warn({ runId: input.runId, operationId: operation.id }, "Remote workspace save settlement or export cleanup remains pending"); }
|
|
}
|
|
}
|
|
|
|
export async function prepareRemoteWorkspaceRepository(input: {
|
|
db: Db; workspace: Workspace; runId: string; lease: EnvironmentLease; target: AdapterExecutionTarget | null;
|
|
repository: { id: string; relativePath: string; repoUrl: string; ref: string | null; pinnedCommit?: string; readOnly?: boolean };
|
|
assertOwned?: () => Promise<void>; resolveGitAuth?: ReturnType<typeof createGitRemoteAuthProvider>; verifyOnly?: boolean; storage?: StorageService;
|
|
}) {
|
|
const authority = readRemoteWorkspaceAuthority(input.workspace.metadata);
|
|
if (!authority?.location || authority.writerRunId !== input.runId || authority.location.leaseId !== input.lease.id
|
|
|| authority.location.providerLeaseId !== input.lease.providerLeaseId) throw new Error("remote_workspace_repository_binding_mismatch");
|
|
await input.assertOwned?.();
|
|
const target = requireRemoteWorkspaceTarget(input.target);
|
|
const result = await prepareRemoteRepository({ target, remoteCwd: remoteWorkspaceRepositoryPath(authority.location.remoteCwd, input.repository),
|
|
...input.repository, verifyOnly: input.verifyOnly, savedArchive: isSavedWorkspaceRepository(input.workspace, authority, input.repository),
|
|
resolveGitAuth: input.resolveGitAuth ?? await createRemoteWorkspaceGitAuth(input.db, input.workspace.companyId, input.runId) });
|
|
await input.assertOwned?.();
|
|
await saveRemoteWorkspace({ ...input, target, boundary: `repository:${input.repository.id}:${result.pinnedCommit}`,
|
|
assertOwned: input.assertOwned ?? (async () => undefined) });
|
|
return result;
|
|
}
|
|
|
|
type SavedWorkspaceFileLimits = {
|
|
maxOpen: number; maxArchiveBytes: number; maxScratchBytes: number; maxEntries: number; maxManifestBytes: number; freeSpaceMarginBytes: number;
|
|
};
|
|
const SAVED_WORKSPACE_FILE_LIMITS: SavedWorkspaceFileLimits = {
|
|
maxOpen: 2, maxArchiveBytes: 512 * 1024 * 1024, maxScratchBytes: 2 * 1024 * 1024 * 1024,
|
|
maxEntries: 10_000, maxManifestBytes: 4 * 1024 * 1024, freeSpaceMarginBytes: 256 * 1024 * 1024,
|
|
};
|
|
let savedWorkspaceFileOpen = 0, savedWorkspaceFileReservedBytes = 0;
|
|
|
|
export function createSavedWorkspaceLoader(db: Db, storage: StorageService = getStorageService(), options: {
|
|
limits?: Partial<SavedWorkspaceFileLimits>; availableBytes?: () => Promise<bigint>;
|
|
} = {}) {
|
|
const limits = { ...SAVED_WORKSPACE_FILE_LIMITS, ...options.limits };
|
|
for (const key of Object.keys(limits) as Array<keyof SavedWorkspaceFileLimits>) {
|
|
if (!Number.isSafeInteger(limits[key]) || limits[key] < (key === "freeSpaceMarginBytes" ? 0 : 1)
|
|
|| limits[key] > SAVED_WORKSPACE_FILE_LIMITS[key]) throw new Error("invalid_saved_workspace_file_limits");
|
|
}
|
|
const availableBytes = options.availableBytes ?? (async () => {
|
|
const disk = await statfs(os.tmpdir(), { bigint: true });
|
|
return disk.bavail * disk.bsize;
|
|
});
|
|
return async (input: { companyId: string; workspaceId: string; snapshotId: string; signal?: AbortSignal }) => {
|
|
input.signal?.throwIfAborted();
|
|
const [row] = await db.select().from(executionWorkspaceSnapshots).where(and(eq(executionWorkspaceSnapshots.companyId, input.companyId),
|
|
eq(executionWorkspaceSnapshots.executionWorkspaceId, input.workspaceId), eq(executionWorkspaceSnapshots.id, input.snapshotId),
|
|
eq(executionWorkspaceSnapshots.status, "committed")));
|
|
const saved = row ? await getWorkspaceSnapshot(db, input) : null;
|
|
input.signal?.throwIfAborted();
|
|
if (!saved) throw new Error("remote_workspace_snapshot_unavailable");
|
|
if (saved.byteSize > limits.maxArchiveBytes) throw unprocessable("Saved workspace is too large for Files", { code: "too_large" });
|
|
// Decryption can hold ciphertext, plaintext, and its atomic publication at
|
|
// once. Bound filesystem allocation overhead even for many tiny entries.
|
|
const reservation = 3 * saved.byteSize + limits.maxEntries * 16 * 1024;
|
|
if (savedWorkspaceFileOpen >= limits.maxOpen || reservation > limits.maxScratchBytes - savedWorkspaceFileReservedBytes) {
|
|
throw unprocessable("Saved workspace file extraction capacity is unavailable", { code: "workspace_unavailable" });
|
|
}
|
|
savedWorkspaceFileOpen++; savedWorkspaceFileReservedBytes += reservation;
|
|
let scratch: string | undefined, released = false, cleaning: Promise<void> | undefined;
|
|
// Source modes stay intact while reading. Cleanup changes only private directories and skips symlinks.
|
|
const cleanup = async () => {
|
|
if (released) return;
|
|
if (!cleaning) cleaning = (async () => {
|
|
if (scratch) await removeTaskDirectoryTree(scratch);
|
|
released = true; savedWorkspaceFileOpen--; savedWorkspaceFileReservedBytes -= reservation;
|
|
})();
|
|
try { await cleaning; } catch (error) { cleaning = undefined; throw error; }
|
|
};
|
|
try {
|
|
// Existing reservations may not have written their bytes yet. Keep them
|
|
// deducted from free space until their owned directories are removed.
|
|
if (await availableBytes() < BigInt(savedWorkspaceFileReservedBytes + limits.freeSpaceMarginBytes)) {
|
|
throw unprocessable("Insufficient temporary disk space for saved workspace Files", { code: "workspace_unavailable" });
|
|
}
|
|
input.signal?.throwIfAborted();
|
|
scratch = await mkdtemp(path.join(os.tmpdir(), "pc-workspace-files-"));
|
|
const archive = path.join(scratch, "archive.pcws"), root = path.join(scratch, "files");
|
|
await loadWorkspaceSnapshotArchive({ storage, reference: saved, targetPath: archive, signal: input.signal, temporaryParent: scratch });
|
|
await restoreRemoteWorkspaceArchive({ archivePath: archive, targetDir: root, expectedManifestDigest: saved.manifestDigest,
|
|
signal: input.signal, maxEntries: limits.maxEntries, maxManifestBytes: limits.maxManifestBytes });
|
|
return { root, savedAt: row!.updatedAt.toISOString(), cleanup };
|
|
} catch (error) {
|
|
try { await cleanup(); } catch {
|
|
logger.warn({ workspaceId: input.workspaceId }, "Failed to remove saved workspace extraction; capacity remains reserved");
|
|
}
|
|
if (error instanceof Error && ["remote_workspace_archive_entry_limit", "remote_workspace_archive_manifest_too_large"].includes(error.message)) {
|
|
throw unprocessable("Saved workspace manifest exceeds Files extraction limits", { code: "too_large" });
|
|
}
|
|
throw error;
|
|
}
|
|
};
|
|
}
|
|
|
|
/** Bind credentials to the current run's identity, including shared workspace users. */
|
|
export async function createRemoteWorkspaceGitAuth(db: Db, companyId: string, runId: string,
|
|
create: typeof createGitRemoteAuthProvider = createGitRemoteAuthProvider) {
|
|
const [run] = await db.select({ agentId: heartbeatRuns.agentId, responsibleUserId: heartbeatRuns.responsibleUserId,
|
|
nativeIssueId: heartbeatRuns.nativeIssueId, issueId: heartbeatRuns.issueId, status: heartbeatRuns.status,
|
|
contextIssueId: sql<unknown>`${heartbeatRuns.contextSnapshot}->'issueId'`,
|
|
}).from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)));
|
|
const issueId = run?.nativeIssueId ?? run?.issueId ?? run?.contextIssueId;
|
|
if (!run || run.status !== "running" || typeof issueId !== "string"
|
|
|| !/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/i.test(issueId)) throw new Error("remote_workspace_git_run_unavailable");
|
|
return create(db, companyId, { issueId, heartbeatRunId: runId, agentId: run.agentId,
|
|
responsibleUserId: run.responsibleUserId === "local-board" ? null : run.responsibleUserId });
|
|
}
|
|
|
|
/** Arbitrary provisioning commands cannot be replayed after an ambiguous reply. */
|
|
export async function provisionRemoteWorkspace(input: {
|
|
db: Db; companyId: string; workspaceId: string; runId: string; epoch: number;
|
|
remoteCwd: string; target: ReturnType<typeof requireRemoteWorkspaceTarget>; commands: Array<string | null | undefined>;
|
|
assertOwned?: () => Promise<void>;
|
|
}) {
|
|
for (const [index, command] of input.commands.entries()) {
|
|
if (!command?.trim()) continue;
|
|
await input.assertOwned?.();
|
|
const digest = createHash("sha256").update(command).digest("hex");
|
|
const operation = await input.db.transaction(async tx => {
|
|
const issueId = await lockWorkspaceRunParents(tx, input.companyId, input.runId);
|
|
const [workspace] = await tx.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.companyId, input.companyId), eq(executionWorkspaces.id, input.workspaceId))).for("update");
|
|
const authority = readRemoteWorkspaceAuthority(workspace?.metadata);
|
|
const [run] = await tx.select({ status: heartbeatRuns.status }).from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.id, input.runId)));
|
|
if (!authority || authority.epoch !== input.epoch || authority.writerRunId !== input.runId || run?.status !== "running") throw new Error("remote_workspace_provision_fenced");
|
|
const [existing] = await tx.select().from(workspaceOperations).where(and(eq(workspaceOperations.companyId, input.companyId),
|
|
eq(workspaceOperations.executionWorkspaceId, input.workspaceId), eq(workspaceOperations.heartbeatRunId, input.runId),
|
|
eq(workspaceOperations.phase, "remote_workspace_provision"), sql`${workspaceOperations.metadata}->>'index' = ${String(index)}`,
|
|
sql`${workspaceOperations.metadata}->>'epoch' = ${String(input.epoch)}`)).limit(1);
|
|
if (existing) {
|
|
if (existing.metadata?.commandSha256 !== digest || existing.status !== "succeeded") throw new Error("remote_workspace_provision_unverified");
|
|
return null;
|
|
}
|
|
const [created] = await tx.insert(workspaceOperations).values({ companyId: input.companyId, executionWorkspaceId: input.workspaceId,
|
|
heartbeatRunId: input.runId, issueId, phase: "remote_workspace_provision", status: "running",
|
|
metadata: { epoch: input.epoch, index, commandSha256: digest, uncertain: true } }).returning();
|
|
return created!;
|
|
});
|
|
if (!operation) continue;
|
|
await input.assertOwned?.();
|
|
const result = await input.target.runner!.execute({ command: "bash", args: ["-lc", command], cwd: input.remoteCwd,
|
|
timeoutMs: 300_000, bypassSession: true });
|
|
if (result.exitCode !== 0 || result.timedOut) throw new Error("remote_workspace_provision_unverified");
|
|
await input.assertOwned?.();
|
|
await input.db.transaction(async tx => {
|
|
const [workspace] = await tx.select({ metadata: executionWorkspaces.metadata }).from(executionWorkspaces)
|
|
.where(and(eq(executionWorkspaces.companyId, input.companyId), eq(executionWorkspaces.id, input.workspaceId))).for("update");
|
|
const authority = readRemoteWorkspaceAuthority(workspace?.metadata);
|
|
if (authority?.epoch !== input.epoch || authority.writerRunId !== input.runId) throw new Error("remote_workspace_provision_fenced");
|
|
await tx.update(workspaceOperations).set({ status: "succeeded", finishedAt: new Date(), updatedAt: new Date(),
|
|
metadata: { ...operation.metadata, uncertain: false } }).where(eq(workspaceOperations.id, operation.id));
|
|
});
|
|
}
|
|
}
|