forked from farhoodlabs/paperclip
12cb7b40fd
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - When an agent runs against a remote target, Paperclip syncs the
workspace out to the remote at run start and restores changes back to
the local workspace at run end
> - The previous restore flow naïvely overwrote local files with
whatever the remote returned, so files that the remote run never touched
but had timestamp/mode drift could be needlessly rewritten — and a
single static `refs/paperclip/ssh-sync/imported` ref made concurrent SSH
workspace exports race on the same git ref
> - This pull request adds a `workspace-restore-merge` module that diffs
a pre-run snapshot against the post-run remote state and only writes
back files the remote actually changed; SSH workspace exports now use a
per-import unique ref so concurrent runs can't trample each other
> - Every adapter's execute path threads the snapshot through
`prepareAdapterExecutionTargetRuntime` so the merge has the baseline it
needs
> - The benefit is workspace restores no longer churn untouched files,
and concurrent SSH runs no longer collide on the import ref
## What Changed
- `packages/adapter-utils/src/workspace-restore-merge.{ts,test.ts}`: new
module — directory snapshot (kind/mode/sha256/symlink target) plus
snapshot-aware merge that writes only the files the remote changed
- `packages/adapter-utils/src/ssh.ts`: SSH workspace export uses a
per-import unique ref (`refs/paperclip/ssh-sync/imported/<uuid>`);
restore goes through the new merge helper; `ssh-fixture.test.ts` covers
the unique-ref + merge paths
- `packages/adapter-utils/src/sandbox-managed-runtime.ts` +
`remote-managed-runtime.ts`: thread the snapshot/merge through the
sandbox and SSH paths
- `packages/adapter-utils/src/server-utils.{ts,test.ts}` +
`execution-target.ts`: helpers for capturing the pre-run snapshot;
`prepareAdapterExecutionTargetRuntime` gains required `runId` and
optional `workspaceRemoteDir`, and returns the realized
`workspaceRemoteDir`
- Each adapter's `execute.ts` (acpx, claude, codex, cursor, gemini,
opencode, pi) takes the snapshot at run start and passes it through to
the runtime restore
- Remote execute test mocks updated to match the new
`prepareWorkspaceForSshExecution` return shape and the per-run
`${managedRemoteWorkspace}` cwd subdirectory
## Verification
- `pnpm vitest run --no-coverage --project @paperclipai/adapter-utils
--project @paperclipai/adapter-acpx-local --project
@paperclipai/adapter-claude-local --project
@paperclipai/adapter-codex-local --project
@paperclipai/adapter-cursor-local --project
@paperclipai/adapter-gemini-local --project
@paperclipai/adapter-opencode-local --project
@paperclipai/adapter-pi-local` — 196/196 passing
- `pnpm typecheck` clean across the workspace
## Risks
Medium. The restore path now writes a strict subset of what it
previously did — files the remote did not touch are no longer rewritten.
If any flow was relying on a touch-without-content-change being copied
back (timestamp or permission propagation only), that behavior is now
skipped. Snapshot capture adds an O(N-files-in-workspace) hash pass at
run start; the cost is bounded by the existing exclude list. The `runId`
parameter on `prepareAdapterExecutionTargetRuntime` is now required —
every in-tree caller is updated; out-of-tree adapter authors need to
pass it.
## Model Used
Claude Opus 4.7 (1M context)
## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable — new module +
every adapter execute path covered
- [x] If this change affects the UI, I have included before/after
screenshots — N/A (no UI)
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
134 lines
3.9 KiB
TypeScript
134 lines
3.9 KiB
TypeScript
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<string, string>;
|
|
restoreWorkspace(): Promise<void>;
|
|
}
|
|
|
|
function asObject(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: {};
|
|
}
|
|
|
|
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<PreparedRemoteManagedRuntime> {
|
|
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<string, string> = {};
|
|
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,
|
|
});
|
|
},
|
|
};
|
|
}
|