mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Isolated workspaces give each task a safe and reproducible checkout. > - The existing setup cloned the development database before an agent needed to run the app. > - This made worktree creation slower and heavier for tasks that never start a service. > - Runtime services already use one server start path for heartbeat, operator, and startup recovery flows. > - This pull request moves heavy setup to that start path and keeps worktree creation lean. > - The benefit is faster isolated workspace creation with the same reliable runtime setup when a service starts. ## Linked Issues or Issue Description Related pull request: #10652 covers the initial deferred database-seeding slice. This pull request supersedes it with end-to-end runtime provisioning and safe cleanup. **What existing behavior does this improve?** This improves isolated worktree creation, runtime service startup, and isolated instance cleanup. **Subsystem affected** Cross-cutting: CLI worktree setup, server runtime orchestration, shared workspace contracts, and development scripts. **Current behavior** Paperclip seeds an isolated development database during worktree creation. It can also leave an isolated instance directory after workspace teardown. This work happens even when no runtime service starts. **Proposed behavior** Paperclip creates the worktree with a lean eager setup. It runs an idempotent runtime provision command before the first managed service spawn. Concurrent starts share one provision attempt. Teardown removes the isolated instance safely. **Reason and benefit** Many agent tasks only edit and test code. They do not need a running Paperclip instance. Deferring the database seed reduces workspace startup cost while preserving automatic setup for tasks that start the app. **Breaking changes** None. The new runtime provision command is optional. Existing workspace behavior is unchanged when it is absent. ## What Changed - Split Paperclip worktree setup into a lean eager script and an idempotent runtime provision script. - Added `runtimeProvisionCommand` to project, issue, realized workspace, and persisted workspace contracts. - Added a per-workspace provision mutex before local service spawn for heartbeat, operator, and startup recovery flows. - Added a persisted `provisioning` service state and the `workspace_runtime_provision` operation phase. - Kept provision time outside the service readiness timeout and made failed attempts visible and retryable. - Reclaimed isolated instance data during safe workspace teardown. - Serialized deferred database seeding across processes and bound teardown to the instance root captured in persisted workspace metadata. - Added tests for config flow, concurrency, retry, no-op behavior, readiness timing, scripts, CLI commands, and cleanup. - Documented the eager and runtime provisioning contracts. ## Verification - `pnpm -r typecheck` - `pnpm build` - `pnpm test:run` (server: 3,201 passed; UI: 3,345 passed; the CLI phase exposed one environment-sensitive AWS doctor assertion because the agent runtime injects static AWS credentials) - `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY pnpm exec vitest run cli/src/__tests__/secrets.test.ts -t 'passes AWS doctor checks when non-secret provider config is present'` - Focused runtime tests cover serialized provisioning, retry after stderr failure, absent-command no-op behavior, operation logging, persisted state order, and readiness timeout exclusion. - Focused CLI and cleanup tests cover concurrent seed serialization, stale-lock fail-closed behavior, persisted instance ownership, and rewritten sibling pointers. ## Risks - A faulty runtime provision script blocks service startup. Paperclip records stderr, marks the service failed, and retries on the next start. - Concurrent service requests share an in-process provision attempt, while the seed command uses an atomic filesystem lock across processes. A stale lock fails closed and requires an operator to verify no seed is running before removing it. - Isolated instance cleanup is destructive. The cleanup service validates ownership and path containment before removal. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, `gpt-5.6-sol`, with agentic reasoning, tool use, and code execution. The service does not expose the context-window size. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
359 lines
14 KiB
TypeScript
359 lines
14 KiB
TypeScript
import type {
|
|
Environment,
|
|
EnvironmentLease,
|
|
ExecutionWorkspaceConfig,
|
|
WorkspaceRealizationRecord,
|
|
WorkspaceRealizationRequest,
|
|
} from "@paperclipai/shared";
|
|
import type { RealizedExecutionWorkspace } from "./workspace-runtime.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),
|
|
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,
|
|
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 ?? {};
|
|
const transport =
|
|
input.environment.driver === "ssh" || input.environment.driver === "sandbox" || input.environment.driver === "plugin"
|
|
? input.environment.driver
|
|
: "local";
|
|
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 sync = (() => {
|
|
if (mode === "in_place" || transport === "local") {
|
|
return {
|
|
strategy: "none" as const,
|
|
prepare: "Use the realized local execution workspace directly.",
|
|
syncBack: null,
|
|
};
|
|
}
|
|
if (transport === "ssh") {
|
|
return {
|
|
strategy: "ssh_git_import_export" as const,
|
|
prepare: "Import the local git workspace to the remote SSH workspace before adapter execution.",
|
|
syncBack: "Export remote SSH workspace changes back to the local execution workspace after adapter execution.",
|
|
};
|
|
}
|
|
if (transport === "sandbox") {
|
|
return {
|
|
strategy: "sandbox_archive_upload_download" as const,
|
|
prepare: "Upload a workspace archive into the sandbox filesystem before adapter execution.",
|
|
syncBack: "Download a workspace archive from the sandbox and mirror it back locally after adapter execution.",
|
|
};
|
|
}
|
|
return {
|
|
strategy: "provider_defined" as const,
|
|
prepare: "Delegate workspace materialization to the plugin environment driver.",
|
|
syncBack: "Delegate result synchronization to the plugin environment driver.",
|
|
};
|
|
})();
|
|
|
|
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,
|
|
transport,
|
|
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 } : {}),
|
|
},
|
|
sync,
|
|
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,
|
|
},
|
|
workspaceConfig: null,
|
|
});
|
|
|
|
return buildWorkspaceRealizationRecord({
|
|
environment: input.environment,
|
|
lease: input.lease,
|
|
request,
|
|
realizedCwd: input.cwd ?? null,
|
|
providerMetadata: input.providerMetadata,
|
|
});
|
|
}
|