mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 18:10:39 +09:00
400 lines
12 KiB
TypeScript
400 lines
12 KiB
TypeScript
|
|
import path from "node:path";
|
||
|
|
import type { SshRemoteExecutionSpec } from "./ssh.js";
|
||
|
|
import {
|
||
|
|
buildRemoteExecutionSessionIdentity,
|
||
|
|
prepareRemoteManagedRuntime,
|
||
|
|
remoteExecutionSessionMatches,
|
||
|
|
type RemoteManagedRuntimeAsset,
|
||
|
|
} from "./remote-managed-runtime.js";
|
||
|
|
import { parseSshRemoteExecutionSpec, runSshCommand, shellQuote } from "./ssh.js";
|
||
|
|
import {
|
||
|
|
ensureCommandResolvable,
|
||
|
|
resolveCommandForLogs,
|
||
|
|
runChildProcess,
|
||
|
|
type RunProcessResult,
|
||
|
|
type TerminalResultCleanupOptions,
|
||
|
|
} from "./server-utils.js";
|
||
|
|
|
||
|
|
export interface AdapterLocalExecutionTarget {
|
||
|
|
kind: "local";
|
||
|
|
environmentId?: string | null;
|
||
|
|
leaseId?: string | null;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface AdapterSshExecutionTarget {
|
||
|
|
kind: "remote";
|
||
|
|
transport: "ssh";
|
||
|
|
environmentId?: string | null;
|
||
|
|
leaseId?: string | null;
|
||
|
|
remoteCwd: string;
|
||
|
|
paperclipApiUrl?: string | null;
|
||
|
|
spec: SshRemoteExecutionSpec;
|
||
|
|
}
|
||
|
|
|
||
|
|
export type AdapterExecutionTarget =
|
||
|
|
| AdapterLocalExecutionTarget
|
||
|
|
| AdapterSshExecutionTarget;
|
||
|
|
|
||
|
|
export type AdapterRemoteExecutionSpec = SshRemoteExecutionSpec;
|
||
|
|
|
||
|
|
export type AdapterManagedRuntimeAsset = RemoteManagedRuntimeAsset;
|
||
|
|
|
||
|
|
export interface PreparedAdapterExecutionTargetRuntime {
|
||
|
|
target: AdapterExecutionTarget;
|
||
|
|
runtimeRootDir: string | null;
|
||
|
|
assetDirs: Record<string, string>;
|
||
|
|
restoreWorkspace(): Promise<void>;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface AdapterExecutionTargetProcessOptions {
|
||
|
|
cwd: string;
|
||
|
|
env: Record<string, string>;
|
||
|
|
stdin?: string;
|
||
|
|
timeoutSec: number;
|
||
|
|
graceSec: number;
|
||
|
|
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||
|
|
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
|
||
|
|
terminalResultCleanup?: TerminalResultCleanupOptions;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface AdapterExecutionTargetShellOptions {
|
||
|
|
cwd: string;
|
||
|
|
env: Record<string, string>;
|
||
|
|
timeoutSec?: number;
|
||
|
|
graceSec?: number;
|
||
|
|
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseObject(value: unknown): Record<string, unknown> {
|
||
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
||
|
|
? (value as Record<string, unknown>)
|
||
|
|
: {};
|
||
|
|
}
|
||
|
|
|
||
|
|
function readString(value: unknown): string | null {
|
||
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function readStringMeta(parsed: Record<string, unknown>, key: string): string | null {
|
||
|
|
return readString(parsed[key]);
|
||
|
|
}
|
||
|
|
|
||
|
|
function isAdapterExecutionTargetInstance(value: unknown): value is AdapterExecutionTarget {
|
||
|
|
const parsed = parseObject(value);
|
||
|
|
if (parsed.kind === "local") return true;
|
||
|
|
if (parsed.kind !== "remote") return false;
|
||
|
|
if (parsed.transport === "ssh") return parseSshRemoteExecutionSpec(parseObject(parsed.spec)) !== null;
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function adapterExecutionTargetToRemoteSpec(
|
||
|
|
target: AdapterExecutionTarget | null | undefined,
|
||
|
|
): AdapterRemoteExecutionSpec | null {
|
||
|
|
return target?.kind === "remote" && target.transport === "ssh" ? target.spec : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function adapterExecutionTargetIsRemote(
|
||
|
|
target: AdapterExecutionTarget | null | undefined,
|
||
|
|
): boolean {
|
||
|
|
return target?.kind === "remote";
|
||
|
|
}
|
||
|
|
|
||
|
|
export function adapterExecutionTargetUsesManagedHome(
|
||
|
|
target: AdapterExecutionTarget | null | undefined,
|
||
|
|
): boolean {
|
||
|
|
// SSH execution targets sync the runtime assets they need into the remote cwd today,
|
||
|
|
// so neither local nor remote targets provision a separate managed adapter home.
|
||
|
|
void target;
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function adapterExecutionTargetRemoteCwd(
|
||
|
|
target: AdapterExecutionTarget | null | undefined,
|
||
|
|
localCwd: string,
|
||
|
|
): string {
|
||
|
|
return target?.kind === "remote" ? target.remoteCwd : localCwd;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function adapterExecutionTargetPaperclipApiUrl(
|
||
|
|
target: AdapterExecutionTarget | null | undefined,
|
||
|
|
): string | null {
|
||
|
|
if (target?.kind !== "remote") return null;
|
||
|
|
return target.paperclipApiUrl ?? target.spec.paperclipApiUrl ?? null;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function describeAdapterExecutionTarget(
|
||
|
|
target: AdapterExecutionTarget | null | undefined,
|
||
|
|
): string {
|
||
|
|
if (!target || target.kind === "local") return "local environment";
|
||
|
|
return `SSH environment ${target.spec.username}@${target.spec.host}:${target.spec.port}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function ensureAdapterExecutionTargetCommandResolvable(
|
||
|
|
command: string,
|
||
|
|
target: AdapterExecutionTarget | null | undefined,
|
||
|
|
cwd: string,
|
||
|
|
env: NodeJS.ProcessEnv,
|
||
|
|
) {
|
||
|
|
await ensureCommandResolvable(command, cwd, env, {
|
||
|
|
remoteExecution: adapterExecutionTargetToRemoteSpec(target),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function resolveAdapterExecutionTargetCommandForLogs(
|
||
|
|
command: string,
|
||
|
|
target: AdapterExecutionTarget | null | undefined,
|
||
|
|
cwd: string,
|
||
|
|
env: NodeJS.ProcessEnv,
|
||
|
|
): Promise<string> {
|
||
|
|
return await resolveCommandForLogs(command, cwd, env, {
|
||
|
|
remoteExecution: adapterExecutionTargetToRemoteSpec(target),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function runAdapterExecutionTargetProcess(
|
||
|
|
runId: string,
|
||
|
|
target: AdapterExecutionTarget | null | undefined,
|
||
|
|
command: string,
|
||
|
|
args: string[],
|
||
|
|
options: AdapterExecutionTargetProcessOptions,
|
||
|
|
): Promise<RunProcessResult> {
|
||
|
|
return await runChildProcess(runId, command, args, {
|
||
|
|
cwd: options.cwd,
|
||
|
|
env: options.env,
|
||
|
|
stdin: options.stdin,
|
||
|
|
timeoutSec: options.timeoutSec,
|
||
|
|
graceSec: options.graceSec,
|
||
|
|
onLog: options.onLog,
|
||
|
|
onSpawn: options.onSpawn,
|
||
|
|
terminalResultCleanup: options.terminalResultCleanup,
|
||
|
|
remoteExecution: adapterExecutionTargetToRemoteSpec(target),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function runAdapterExecutionTargetShellCommand(
|
||
|
|
runId: string,
|
||
|
|
target: AdapterExecutionTarget | null | undefined,
|
||
|
|
command: string,
|
||
|
|
options: AdapterExecutionTargetShellOptions,
|
||
|
|
): Promise<RunProcessResult> {
|
||
|
|
const onLog = options.onLog ?? (async () => {});
|
||
|
|
if (target?.kind === "remote") {
|
||
|
|
const startedAt = new Date().toISOString();
|
||
|
|
try {
|
||
|
|
const result = await runSshCommand(target.spec, `sh -lc ${shellQuote(command)}`, {
|
||
|
|
timeoutMs: (options.timeoutSec ?? 15) * 1000,
|
||
|
|
});
|
||
|
|
if (result.stdout) await onLog("stdout", result.stdout);
|
||
|
|
if (result.stderr) await onLog("stderr", result.stderr);
|
||
|
|
return {
|
||
|
|
exitCode: 0,
|
||
|
|
signal: null,
|
||
|
|
timedOut: false,
|
||
|
|
stdout: result.stdout,
|
||
|
|
stderr: result.stderr,
|
||
|
|
pid: null,
|
||
|
|
startedAt,
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
const timedOutError = error as NodeJS.ErrnoException & {
|
||
|
|
stdout?: string;
|
||
|
|
stderr?: string;
|
||
|
|
signal?: string | null;
|
||
|
|
};
|
||
|
|
const stdout = timedOutError.stdout ?? "";
|
||
|
|
const stderr = timedOutError.stderr ?? "";
|
||
|
|
if (typeof timedOutError.code === "number") {
|
||
|
|
if (stdout) await onLog("stdout", stdout);
|
||
|
|
if (stderr) await onLog("stderr", stderr);
|
||
|
|
return {
|
||
|
|
exitCode: timedOutError.code,
|
||
|
|
signal: timedOutError.signal ?? null,
|
||
|
|
timedOut: false,
|
||
|
|
stdout,
|
||
|
|
stderr,
|
||
|
|
pid: null,
|
||
|
|
startedAt,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
if (timedOutError.code !== "ETIMEDOUT") {
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
if (stdout) await onLog("stdout", stdout);
|
||
|
|
if (stderr) await onLog("stderr", stderr);
|
||
|
|
return {
|
||
|
|
exitCode: null,
|
||
|
|
signal: timedOutError.signal ?? null,
|
||
|
|
timedOut: true,
|
||
|
|
stdout,
|
||
|
|
stderr,
|
||
|
|
pid: null,
|
||
|
|
startedAt,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return await runAdapterExecutionTargetProcess(
|
||
|
|
runId,
|
||
|
|
target,
|
||
|
|
"sh",
|
||
|
|
["-lc", command],
|
||
|
|
{
|
||
|
|
cwd: options.cwd,
|
||
|
|
env: options.env,
|
||
|
|
timeoutSec: options.timeoutSec ?? 15,
|
||
|
|
graceSec: options.graceSec ?? 5,
|
||
|
|
onLog,
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function readAdapterExecutionTargetHomeDir(
|
||
|
|
runId: string,
|
||
|
|
target: AdapterExecutionTarget | null | undefined,
|
||
|
|
options: AdapterExecutionTargetShellOptions,
|
||
|
|
): Promise<string | null> {
|
||
|
|
const result = await runAdapterExecutionTargetShellCommand(
|
||
|
|
runId,
|
||
|
|
target,
|
||
|
|
'printf %s "$HOME"',
|
||
|
|
options,
|
||
|
|
);
|
||
|
|
const homeDir = result.stdout.trim();
|
||
|
|
return homeDir.length > 0 ? homeDir : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function ensureAdapterExecutionTargetFile(
|
||
|
|
runId: string,
|
||
|
|
target: AdapterExecutionTarget | null | undefined,
|
||
|
|
filePath: string,
|
||
|
|
options: AdapterExecutionTargetShellOptions,
|
||
|
|
): Promise<void> {
|
||
|
|
await runAdapterExecutionTargetShellCommand(
|
||
|
|
runId,
|
||
|
|
target,
|
||
|
|
`mkdir -p ${shellQuote(path.posix.dirname(filePath))} && : > ${shellQuote(filePath)}`,
|
||
|
|
options,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function adapterExecutionTargetSessionIdentity(
|
||
|
|
target: AdapterExecutionTarget | null | undefined,
|
||
|
|
): Record<string, unknown> | null {
|
||
|
|
if (!target || target.kind === "local") return null;
|
||
|
|
return buildRemoteExecutionSessionIdentity(target.spec);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function adapterExecutionTargetSessionMatches(
|
||
|
|
saved: unknown,
|
||
|
|
target: AdapterExecutionTarget | null | undefined,
|
||
|
|
): boolean {
|
||
|
|
if (!target || target.kind === "local") {
|
||
|
|
return Object.keys(parseObject(saved)).length === 0;
|
||
|
|
}
|
||
|
|
return remoteExecutionSessionMatches(saved, target.spec);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTarget | null {
|
||
|
|
const parsed = parseObject(value);
|
||
|
|
const kind = readStringMeta(parsed, "kind");
|
||
|
|
|
||
|
|
if (kind === "local") {
|
||
|
|
return {
|
||
|
|
kind: "local",
|
||
|
|
environmentId: readStringMeta(parsed, "environmentId"),
|
||
|
|
leaseId: readStringMeta(parsed, "leaseId"),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
if (kind === "remote" && readStringMeta(parsed, "transport") === "ssh") {
|
||
|
|
const spec = parseSshRemoteExecutionSpec(parseObject(parsed.spec));
|
||
|
|
if (!spec) return null;
|
||
|
|
return {
|
||
|
|
kind: "remote",
|
||
|
|
transport: "ssh",
|
||
|
|
environmentId: readStringMeta(parsed, "environmentId"),
|
||
|
|
leaseId: readStringMeta(parsed, "leaseId"),
|
||
|
|
remoteCwd: spec.remoteCwd,
|
||
|
|
paperclipApiUrl: readStringMeta(parsed, "paperclipApiUrl") ?? spec.paperclipApiUrl ?? null,
|
||
|
|
spec,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function adapterExecutionTargetFromRemoteExecution(
|
||
|
|
remoteExecution: unknown,
|
||
|
|
metadata: Pick<AdapterLocalExecutionTarget, "environmentId" | "leaseId"> = {},
|
||
|
|
): AdapterExecutionTarget | null {
|
||
|
|
const parsed = parseObject(remoteExecution);
|
||
|
|
const ssh = parseSshRemoteExecutionSpec(parsed);
|
||
|
|
if (ssh) {
|
||
|
|
return {
|
||
|
|
kind: "remote",
|
||
|
|
transport: "ssh",
|
||
|
|
environmentId: metadata.environmentId ?? null,
|
||
|
|
leaseId: metadata.leaseId ?? null,
|
||
|
|
remoteCwd: ssh.remoteCwd,
|
||
|
|
paperclipApiUrl: ssh.paperclipApiUrl ?? null,
|
||
|
|
spec: ssh,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function readAdapterExecutionTarget(input: {
|
||
|
|
executionTarget?: unknown;
|
||
|
|
legacyRemoteExecution?: unknown;
|
||
|
|
}): AdapterExecutionTarget | null {
|
||
|
|
if (isAdapterExecutionTargetInstance(input.executionTarget)) {
|
||
|
|
return input.executionTarget;
|
||
|
|
}
|
||
|
|
return (
|
||
|
|
parseAdapterExecutionTarget(input.executionTarget) ??
|
||
|
|
adapterExecutionTargetFromRemoteExecution(input.legacyRemoteExecution)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function prepareAdapterExecutionTargetRuntime(input: {
|
||
|
|
target: AdapterExecutionTarget | null | undefined;
|
||
|
|
adapterKey: string;
|
||
|
|
workspaceLocalDir: string;
|
||
|
|
workspaceExclude?: string[];
|
||
|
|
preserveAbsentOnRestore?: string[];
|
||
|
|
assets?: AdapterManagedRuntimeAsset[];
|
||
|
|
installCommand?: string | null;
|
||
|
|
}): Promise<PreparedAdapterExecutionTargetRuntime> {
|
||
|
|
const target = input.target ?? { kind: "local" as const };
|
||
|
|
if (target.kind === "local") {
|
||
|
|
return {
|
||
|
|
target,
|
||
|
|
runtimeRootDir: null,
|
||
|
|
assetDirs: {},
|
||
|
|
restoreWorkspace: async () => {},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const prepared = await prepareRemoteManagedRuntime({
|
||
|
|
spec: target.spec,
|
||
|
|
adapterKey: input.adapterKey,
|
||
|
|
workspaceLocalDir: input.workspaceLocalDir,
|
||
|
|
assets: input.assets,
|
||
|
|
});
|
||
|
|
return {
|
||
|
|
target,
|
||
|
|
runtimeRootDir: prepared.runtimeRootDir,
|
||
|
|
assetDirs: prepared.assetDirs,
|
||
|
|
restoreWorkspace: prepared.restoreWorkspace,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export function runtimeAssetDir(
|
||
|
|
prepared: Pick<PreparedAdapterExecutionTargetRuntime, "assetDirs">,
|
||
|
|
key: string,
|
||
|
|
fallbackRemoteCwd: string,
|
||
|
|
): string {
|
||
|
|
return prepared.assetDirs[key] ?? path.posix.join(fallbackRemoteCwd, ".paperclip-runtime", key);
|
||
|
|
}
|