import path from "node:path"; import { GIT_ARCHIVE_EXCLUDES } from "./git-workspace-sync.js"; import { type SshRemoteExecutionSpec, prepareWorkspaceForSshExecution, runSshCommand, restoreWorkspaceFromSshExecution, syncDirectoryToSsh, } from "./ssh.js"; import type { SandboxManagedRuntimeAssetRestoreContext } from "./sandbox-managed-runtime.js"; import { captureDirectorySnapshot } from "./workspace-restore-merge.js"; import type { RuntimeProgressSink } from "./runtime-progress.js"; export interface RemoteManagedRuntimeAsset { key: string; localDir: string; followSymlinks?: boolean; exclude?: string[]; restore?: (ctx: SandboxManagedRuntimeAssetRestoreContext) => Promise; } export interface PreparedRemoteManagedRuntime { spec: SshRemoteExecutionSpec; workspaceLocalDir: string; workspaceRemoteDir: string; runtimeRootDir: string; assetDirs: Record; restoreWorkspace(onProgress?: RuntimeProgressSink): Promise; } function asObject(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } function asString(value: unknown): string { return typeof value === "string" ? value : ""; } function asNumber(value: unknown): number { return typeof value === "number" ? value : Number(value); } function shellQuote(value: string): string { return `'${value.replace(/'/g, `'"'"'`)}'`; } async function readRemoteFile(spec: SshRemoteExecutionSpec, remotePath: string): Promise { const result = await runSshCommand(spec, `base64 < ${shellQuote(remotePath)}`, { maxBuffer: 1024 * 1024, }); return Buffer.from(result.stdout.replace(/\s+/g, ""), "base64"); } export function buildRemoteExecutionSessionIdentity(spec: SshRemoteExecutionSpec | null) { if (!spec) return null; return { transport: "ssh", host: spec.host, port: spec.port, username: spec.username, remoteCwd: spec.remoteCwd, } as const; } export function remoteExecutionSessionMatches(saved: unknown, current: SshRemoteExecutionSpec | null): boolean { const currentIdentity = buildRemoteExecutionSessionIdentity(current); if (!currentIdentity) return false; const parsedSaved = asObject(saved); return ( asString(parsedSaved.transport) === currentIdentity.transport && asString(parsedSaved.host) === currentIdentity.host && asNumber(parsedSaved.port) === currentIdentity.port && asString(parsedSaved.username) === currentIdentity.username && asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd ); } export async function prepareRemoteManagedRuntime(input: { spec: SshRemoteExecutionSpec; runId: string; adapterKey: string; workspaceLocalDir: string; workspaceRemoteDir?: string; syncWorkspace?: boolean; assets?: RemoteManagedRuntimeAsset[]; // Upload progress sink. Threaded for the byte-counting transport rewrite; the // child task wires it into the workspace/asset transfers. onProgress?: RuntimeProgressSink; }): Promise { const baseWorkspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd; const syncWorkspace = input.syncWorkspace !== false; const workspaceRemoteDir = syncWorkspace ? path.posix.join( baseWorkspaceRemoteDir, ".paperclip-runtime", "runs", input.runId, "workspace", ) : baseWorkspaceRemoteDir; const runtimeRootDir = path.posix.join(workspaceRemoteDir, ".paperclip-runtime", input.adapterKey); const preparedWorkspace = syncWorkspace ? await prepareWorkspaceForSshExecution({ spec: input.spec, localDir: input.workspaceLocalDir, remoteDir: workspaceRemoteDir, onProgress: input.onProgress, }) : null; const baselineSnapshot = preparedWorkspace ? await captureDirectorySnapshot(input.workspaceLocalDir, { exclude: preparedWorkspace.gitBacked ? [...GIT_ARCHIVE_EXCLUDES, ".paperclip-runtime"] : [".paperclip-runtime"], }) : null; const assetDirs: Record = {}; try { for (const asset of input.assets ?? []) { const remoteDir = path.posix.join(runtimeRootDir, asset.key); assetDirs[asset.key] = remoteDir; await syncDirectoryToSsh({ spec: input.spec, localDir: asset.localDir, remoteDir, followSymlinks: asset.followSymlinks, exclude: asset.exclude, onProgress: input.onProgress, progressLabel: asset.key, }); } } catch (error) { if (preparedWorkspace && baselineSnapshot) { await restoreWorkspaceFromSshExecution({ spec: input.spec, localDir: input.workspaceLocalDir, remoteDir: workspaceRemoteDir, baselineSnapshot, restoreGitHistory: preparedWorkspace.gitBacked, onProgress: input.onProgress, }); } throw error; } return { spec: input.spec, workspaceLocalDir: input.workspaceLocalDir, workspaceRemoteDir, runtimeRootDir, assetDirs, restoreWorkspace: async (onProgress?: RuntimeProgressSink) => { if (preparedWorkspace && baselineSnapshot) { await restoreWorkspaceFromSshExecution({ spec: input.spec, localDir: input.workspaceLocalDir, remoteDir: workspaceRemoteDir, baselineSnapshot, restoreGitHistory: preparedWorkspace.gitBacked, onProgress, }); } for (const asset of input.assets ?? []) { if (!asset.restore) continue; await asset.restore({ assetDir: path.posix.join(runtimeRootDir, asset.key), readFile: (remotePath) => readRemoteFile(input.spec, remotePath), }); } }, }; }