import path from "node:path"; import { type SshRemoteExecutionSpec, prepareWorkspaceForSshExecution, restoreWorkspaceFromSshExecution, syncDirectoryToSsh, } from "./ssh.js"; import { captureDirectorySnapshot } from "./workspace-restore-merge.js"; export interface RemoteManagedRuntimeAsset { key: string; localDir: string; followSymlinks?: boolean; exclude?: string[]; } export interface PreparedRemoteManagedRuntime { spec: SshRemoteExecutionSpec; workspaceLocalDir: string; workspaceRemoteDir: string; runtimeRootDir: string; assetDirs: Record; restoreWorkspace(): 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); } 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; assets?: RemoteManagedRuntimeAsset[]; }): Promise { const baseWorkspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd; const workspaceRemoteDir = path.posix.join( baseWorkspaceRemoteDir, ".paperclip-runtime", "runs", input.runId, "workspace", ); const runtimeRootDir = path.posix.join(workspaceRemoteDir, ".paperclip-runtime", input.adapterKey); const preparedWorkspace = await prepareWorkspaceForSshExecution({ spec: input.spec, localDir: input.workspaceLocalDir, remoteDir: workspaceRemoteDir, }); const restoreExclude = preparedWorkspace.gitBacked ? [".git", ".paperclip-runtime"] : [".paperclip-runtime"]; const baselineSnapshot = await captureDirectorySnapshot(input.workspaceLocalDir, { exclude: restoreExclude, }); 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, }); } } catch (error) { await restoreWorkspaceFromSshExecution({ spec: input.spec, localDir: input.workspaceLocalDir, remoteDir: workspaceRemoteDir, baselineSnapshot, restoreGitHistory: preparedWorkspace.gitBacked, }); throw error; } return { spec: input.spec, workspaceLocalDir: input.workspaceLocalDir, workspaceRemoteDir, runtimeRootDir, assetDirs, restoreWorkspace: async () => { await restoreWorkspaceFromSshExecution({ spec: input.spec, localDir: input.workspaceLocalDir, remoteDir: workspaceRemoteDir, baselineSnapshot, restoreGitHistory: preparedWorkspace.gitBacked, }); }, }; }