mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-17 01:25:26 +02:00
Keep requested branch attachment separate from ownership established by realization, and round-trip the optional intent through the shared request contract.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
(cherry picked from commit ab1e8acc68)
331 lines
13 KiB
TypeScript
331 lines
13 KiB
TypeScript
import type {
|
|
Environment,
|
|
EnvironmentLease,
|
|
ExecutionWorkspaceConfig,
|
|
WorkspaceRealizationRecord,
|
|
WorkspaceRealizationRequest,
|
|
} from "@paperclipai/shared";
|
|
import type { RealizedExecutionWorkspace } from "./workspace-runtime.js";
|
|
import { ENVIRONMENT_DRIVER_TRAITS, getEnvironmentDriverTraits } from "./environment-driver-traits.js";
|
|
|
|
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 readNumber(value: unknown): number | null {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
function readStringArray(value: unknown): string[] {
|
|
return Array.isArray(value)
|
|
? value.map(readString).filter((entry): entry is string => entry !== null)
|
|
: [];
|
|
}
|
|
|
|
function readPathAliases(value: unknown): Array<{ path: string; target: string }> {
|
|
if (!Array.isArray(value)) return [];
|
|
return value.flatMap((entry) => {
|
|
const parsed = parseObject(entry);
|
|
const aliasPath = readString(parsed.path);
|
|
const target = readString(parsed.target);
|
|
return aliasPath && target ? [{ path: aliasPath, target }] : [];
|
|
});
|
|
}
|
|
|
|
// Read the additional referenced (mentioned) project sources. Legacy payloads omit the field, so
|
|
// this defaults to an empty array. Each source needs a localPath; entries without one are dropped.
|
|
function readAdditionalSources(
|
|
value: unknown,
|
|
): NonNullable<WorkspaceRealizationRequest["additionalSources"]> {
|
|
if (!Array.isArray(value)) return [];
|
|
return value.flatMap((entry) => {
|
|
const parsed = parseObject(entry);
|
|
const localPath = readString(parsed.localPath);
|
|
if (!localPath) return [];
|
|
return [
|
|
{
|
|
localPath,
|
|
projectId: readString(parsed.projectId),
|
|
projectWorkspaceId: readString(parsed.projectWorkspaceId),
|
|
repoUrl: readString(parsed.repoUrl),
|
|
repoRef: readString(parsed.repoRef),
|
|
},
|
|
];
|
|
});
|
|
}
|
|
|
|
export function readWorkspaceRealizationRequest(value: unknown): WorkspaceRealizationRequest | null {
|
|
const parsed = parseObject(value);
|
|
if (parsed.version !== 1) return null;
|
|
const source = parseObject(parsed.source);
|
|
const runtimeOverlay = parseObject(parsed.runtimeOverlay);
|
|
const localPath = readString(source.localPath);
|
|
const companyId = readString(parsed.companyId);
|
|
const environmentId = readString(parsed.environmentId);
|
|
const heartbeatRunId = readString(parsed.heartbeatRunId);
|
|
const adapterType = readString(parsed.adapterType);
|
|
if (!localPath || !companyId || !environmentId || !heartbeatRunId || !adapterType) return null;
|
|
|
|
return {
|
|
version: 1,
|
|
adapterType,
|
|
companyId,
|
|
environmentId,
|
|
executionWorkspaceId: readString(parsed.executionWorkspaceId),
|
|
issueId: readString(parsed.issueId),
|
|
heartbeatRunId,
|
|
requestedMode: readString(parsed.requestedMode),
|
|
source: {
|
|
kind:
|
|
source.kind === "task_session" || source.kind === "agent_home"
|
|
? source.kind
|
|
: "project_primary",
|
|
localPath,
|
|
projectId: readString(source.projectId),
|
|
projectWorkspaceId: readString(source.projectWorkspaceId),
|
|
repoUrl: readString(source.repoUrl),
|
|
repoRef: readString(source.repoRef),
|
|
strategy: source.strategy === "git_worktree" ? "git_worktree" : "project_primary",
|
|
branchName: readString(source.branchName),
|
|
...(readString(source.existingBranch) ? { existingBranch: readString(source.existingBranch)! } : {}),
|
|
worktreePath: readString(source.worktreePath),
|
|
},
|
|
additionalSources: readAdditionalSources(parsed.additionalSources),
|
|
runtimeOverlay: {
|
|
provisionCommand: readString(runtimeOverlay.provisionCommand),
|
|
runtimeProvisionCommand: readString(runtimeOverlay.runtimeProvisionCommand),
|
|
teardownCommand: readString(runtimeOverlay.teardownCommand),
|
|
cleanupCommand: readString(runtimeOverlay.cleanupCommand),
|
|
workspaceRuntime: Object.keys(parseObject(runtimeOverlay.workspaceRuntime)).length > 0
|
|
? parseObject(runtimeOverlay.workspaceRuntime)
|
|
: null,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function buildWorkspaceRealizationRequest(input: {
|
|
adapterType: string;
|
|
companyId: string;
|
|
environmentId: string;
|
|
executionWorkspaceId: string | null;
|
|
issueId: string | null;
|
|
heartbeatRunId: string;
|
|
requestedMode: string | null;
|
|
workspace: RealizedExecutionWorkspace;
|
|
workspaceConfig: ExecutionWorkspaceConfig | null;
|
|
}): WorkspaceRealizationRequest {
|
|
return {
|
|
version: 1,
|
|
adapterType: input.adapterType,
|
|
companyId: input.companyId,
|
|
environmentId: input.environmentId,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
issueId: input.issueId,
|
|
heartbeatRunId: input.heartbeatRunId,
|
|
requestedMode: input.requestedMode,
|
|
source: {
|
|
kind: input.workspace.source,
|
|
localPath: input.workspace.cwd,
|
|
projectId: input.workspace.projectId,
|
|
projectWorkspaceId: input.workspace.workspaceId,
|
|
repoUrl: input.workspace.repoUrl,
|
|
repoRef: input.workspace.repoRef,
|
|
strategy: input.workspace.strategy,
|
|
branchName: input.workspace.branchName,
|
|
...(input.workspace.existingBranch ? { existingBranch: input.workspace.existingBranch } : {}),
|
|
worktreePath: input.workspace.worktreePath,
|
|
},
|
|
// The additional (referenced) sources carry the read-only referenced-project workspaces. Run
|
|
// preparation resolves them for a local execution target only and exposes each local path to
|
|
// the agent through the workspace-hints channel (`PAPERCLIP_WORKSPACES_JSON`). A remote target
|
|
// never receives a referenced source: run preparation skips referenced-project resolution on a
|
|
// remote target, so this array is empty there. The `sync` block below therefore realizes only
|
|
// the anchor source; a remote-transport sync of the referenced trees is not implemented yet.
|
|
additionalSources: (input.workspace.additionalWorkspaces ?? []).map((additional) => ({
|
|
localPath: additional.cwd,
|
|
projectId: additional.projectId,
|
|
projectWorkspaceId: additional.workspaceId,
|
|
repoUrl: additional.repoUrl,
|
|
repoRef: additional.repoRef,
|
|
})),
|
|
runtimeOverlay: {
|
|
provisionCommand: input.workspaceConfig?.provisionCommand ?? null,
|
|
runtimeProvisionCommand: input.workspaceConfig?.runtimeProvisionCommand ?? null,
|
|
teardownCommand: input.workspaceConfig?.teardownCommand ?? null,
|
|
cleanupCommand: input.workspaceConfig?.cleanupCommand ?? null,
|
|
workspaceRuntime: input.workspaceConfig?.workspaceRuntime ?? null,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function buildWorkspaceRealizationRecord(input: {
|
|
environment: Environment;
|
|
lease: EnvironmentLease;
|
|
request: WorkspaceRealizationRequest;
|
|
realizedCwd?: string | null;
|
|
providerMetadata?: Record<string, unknown> | null;
|
|
}): WorkspaceRealizationRecord {
|
|
const leaseMetadata = input.lease.metadata ?? {};
|
|
const providerMetadata = input.providerMetadata ?? {};
|
|
// An unknown or absent driver reads the "local" row, same as today's fallback.
|
|
const traits = getEnvironmentDriverTraits(input.environment.driver) ?? ENVIRONMENT_DRIVER_TRAITS.local;
|
|
const transport = traits.driver;
|
|
const remotePath =
|
|
readString(providerMetadata.remoteCwd) ??
|
|
readString(leaseMetadata.remoteCwd) ??
|
|
readString(providerMetadata.remotePath) ??
|
|
null;
|
|
const host = readString(leaseMetadata.host);
|
|
const port = readNumber(leaseMetadata.port);
|
|
const username = readString(leaseMetadata.username);
|
|
const sandboxId = readString(leaseMetadata.sandboxId) ?? readString(providerMetadata.sandboxId);
|
|
const realizationMetadata = {
|
|
...parseObject(leaseMetadata.workspaceRealization),
|
|
...parseObject(providerMetadata.workspaceRealization),
|
|
...providerMetadata,
|
|
};
|
|
const mode = realizationMetadata.mode === "in_place" || realizationMetadata.realizationMode === "in_place"
|
|
? "in_place" as const
|
|
: "copy" as const;
|
|
const authoritativeRoot =
|
|
readString(realizationMetadata.authoritativeRoot) ??
|
|
(mode === "in_place" ? remotePath : null) ??
|
|
input.request.source.localPath;
|
|
const pathAliases = readPathAliases(realizationMetadata.pathAliases ?? realizationMetadata.workspaceAliases);
|
|
const outboundRestorePaths = readStringArray(realizationMetadata.outboundRestorePaths);
|
|
|
|
const provider =
|
|
input.lease.provider ??
|
|
(transport === "ssh" ? "ssh" : transport === "local" ? "local" : null);
|
|
const localPath = input.request.source.localPath;
|
|
const summary =
|
|
transport === "local"
|
|
? `Local workspace realized at ${localPath}.`
|
|
: transport === "ssh"
|
|
? `SSH workspace realized at ${username ?? "user"}@${host ?? "host"}:${port ?? 22}:${remotePath ?? input.request.source.localPath}.`
|
|
: transport === "sandbox"
|
|
? `Sandbox workspace realized at ${remotePath ?? "/"}${sandboxId ? ` in ${sandboxId}` : ""}.`
|
|
: `Plugin workspace realized at ${input.realizedCwd ?? remotePath ?? localPath}.`;
|
|
|
|
return {
|
|
version: 1,
|
|
mode,
|
|
authoritativeRoot,
|
|
pathAliases,
|
|
outboundRestorePaths,
|
|
provider,
|
|
environmentId: input.environment.id,
|
|
leaseId: input.lease.id,
|
|
providerLeaseId: input.lease.providerLeaseId,
|
|
local: {
|
|
path: localPath,
|
|
source: input.request.source.kind,
|
|
strategy: input.request.source.strategy,
|
|
projectId: input.request.source.projectId,
|
|
projectWorkspaceId: input.request.source.projectWorkspaceId,
|
|
repoUrl: input.request.source.repoUrl,
|
|
repoRef: input.request.source.repoRef,
|
|
branchName: input.request.source.branchName,
|
|
worktreePath: input.request.source.worktreePath,
|
|
},
|
|
additional: (input.request.additionalSources ?? []).map((additional) => ({
|
|
path: additional.localPath,
|
|
projectId: additional.projectId,
|
|
projectWorkspaceId: additional.projectWorkspaceId,
|
|
repoUrl: additional.repoUrl,
|
|
repoRef: additional.repoRef,
|
|
})),
|
|
remote: {
|
|
path: remotePath,
|
|
...(host ? { host } : {}),
|
|
...(port ? { port } : {}),
|
|
...(username ? { username } : {}),
|
|
...(sandboxId ? { sandboxId } : {}),
|
|
},
|
|
bootstrap: {
|
|
command: input.request.runtimeOverlay.provisionCommand,
|
|
},
|
|
rebuild: {
|
|
executionWorkspaceId: input.request.executionWorkspaceId,
|
|
mode: input.request.requestedMode,
|
|
repoUrl: input.request.source.repoUrl,
|
|
repoRef: input.request.source.repoRef,
|
|
localPath,
|
|
remotePath,
|
|
providerLeaseId: input.lease.providerLeaseId,
|
|
metadata: {
|
|
source: input.request.source,
|
|
runtimeOverlay: input.request.runtimeOverlay,
|
|
environmentDriver: input.environment.driver,
|
|
provider,
|
|
providerMetadata,
|
|
},
|
|
},
|
|
summary,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build the workspace-realization record from the run request. The server owns the record;
|
|
* a driver realize handler (built-in or plugin) returns only a realized cwd and provider
|
|
* metadata. Every `realizeWorkspace` exit must route through this helper, so the record carries
|
|
* the referenced (mentioned) project sources in `additional`. The adapter reads `additional` to
|
|
* stage each referenced tree into the target; a realize exit that returns a raw provider result
|
|
* without this helper drops the mentioned projects.
|
|
*/
|
|
export function buildWorkspaceRealizationRecordFromDriverInput(input: {
|
|
environment: Environment;
|
|
lease: EnvironmentLease;
|
|
workspace: {
|
|
localPath?: string;
|
|
remotePath?: string;
|
|
mode?: string;
|
|
metadata?: Record<string, unknown>;
|
|
};
|
|
cwd?: string | null;
|
|
providerMetadata?: Record<string, unknown> | null;
|
|
}): WorkspaceRealizationRecord {
|
|
const request =
|
|
readWorkspaceRealizationRequest(input.workspace.metadata?.workspaceRealizationRequest) ??
|
|
readWorkspaceRealizationRequest(input.workspace.metadata?.request) ??
|
|
buildWorkspaceRealizationRequest({
|
|
adapterType: "unknown",
|
|
companyId: input.lease.companyId,
|
|
environmentId: input.environment.id,
|
|
executionWorkspaceId: input.lease.executionWorkspaceId,
|
|
issueId: input.lease.issueId,
|
|
heartbeatRunId: input.lease.heartbeatRunId ?? "unknown",
|
|
requestedMode: input.workspace.mode ?? null,
|
|
workspace: {
|
|
baseCwd: input.workspace.localPath ?? input.cwd ?? input.workspace.remotePath ?? "/",
|
|
source: "task_session",
|
|
projectId: null,
|
|
workspaceId: null,
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
strategy: "project_primary",
|
|
cwd: input.workspace.localPath ?? input.cwd ?? input.workspace.remotePath ?? "/",
|
|
branchName: null,
|
|
worktreePath: null,
|
|
warnings: [],
|
|
created: false,
|
|
branchCreatedByRuntime: false,
|
|
},
|
|
workspaceConfig: null,
|
|
});
|
|
|
|
return buildWorkspaceRealizationRecord({
|
|
environment: input.environment,
|
|
lease: input.lease,
|
|
request,
|
|
realizedCwd: input.cwd ?? null,
|
|
providerMetadata: input.providerMetadata,
|
|
});
|
|
}
|