import { effectiveProjectWorkspacePolicy } from "../project-workspace-provider.js"; import { canonicalRepositoryRoot } from "../execution-workspace-source-roots.js"; import { assertTaskWorkspaceExecutionAccess } from "../task-workspace-access.js"; import { selectRestoredProjectRepositories, type GitWorkspaceContents } from "../git-workspace-contents.js"; import { GitCacheRestorationFailure } from "../git-workspace-restoration.js"; import { deriveRepoNameFromRepoUrl, managedProjectCheckoutName, projectRepositoryIdentity } from "../managed-project-checkout-name.js"; import { forbidden } from "../../errors.js"; import { readGitConnectionFailure } from "../git-connection-failure.js"; import { materializeManagedProjectWorkspace, ensureManagedRepositoriesIgnored } from "../managed-repository-checkout.js"; import { addNestedRepositoryWorktree, isVerifiedNestedRepositoryBase, nestedRepositoryBaseCandidates, readLinkedNestedCheckout, repairMovedNestedRepositoryWorktree } from "../nested-repository-worktrees.js"; export { materializeManagedProjectWorkspace } from "../managed-repository-checkout.js"; import { sql } from "drizzle-orm"; import { materializeIsolatedTaskDirectory } from "../isolated-task-directory.js"; import { readConnectionFailure, type GitConnectionFailure } from "@paperclipai/adapter-utils/connection-failure"; import { managedAiSessionFingerprintConfig } from "../ai-connection-runtime.js"; import { PROJECT_REPOSITORIES_DIR, readGitWorkspaceSnapshot, } from "@paperclipai/adapter-utils/git-workspace-sync"; import fs from "node:fs/promises"; import path from "node:path"; import { localRepositoryPath } from "../local-repository-path.js"; import { execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; import { createHash, randomUUID, } from "node:crypto"; import { and, asc, eq, } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { type ExecutionWorkspace, type ExecutionWorkspaceConfig, } from "@paperclipai/shared"; import { agents, heartbeatRuns, issues, projects, projectWorkspaces, } from "@paperclipai/db"; import { logger } from "../../middleware/logger.js"; import { createGitRemoteAuthProvider, scrubGitCredentialText, type GitRemoteAuthProvider, } from "../git-credentials.js"; import { parseObject, asNumber, } from "../../adapters/utils.js"; import { resolveDefaultAgentWorkspaceDir, resolveManagedProjectWorkspaceDir, resolvePaperclipInstanceRoot, } from "../../home-paths.js"; import { formatManagedGitWorktreeBranchInspection, inspectManagedGitWorktreeBranch, type ExecutionWorkspaceInput, type RealizedExecutionWorkspace, } from "../workspace-runtime.js"; import { resolvePersistedGitWorkspaceSource } from "../persisted-workspace-source.js"; import { verifiedRecordedIsolatedRemoteSource } from "../isolated-remote-workspace-source.js"; import { issueService } from "../issues.js"; import { projectService } from "../projects.js"; import { authorizationService, type AuthorizationActor, } from "../authorization.js"; import { mergeExecutionWorkspaceConfig } from "../execution-workspaces.js"; import { type WorkspaceOperationRecorder, } from "../workspace-operations.js"; import { GIT_BRANCH_OWNERSHIP_METADATA_KEY, GIT_BRANCH_OWNERSHIP_METADATA_VERSION, } from "../execution-workspace-branch-ownership.js"; import { resolveEffectiveWorkspaceStrategyType, resolveExecutionWorkspaceMode, } from "../execution-workspace-policy.js"; import { isUnsafeSessionWorkspaceCwd } from "../session-workspace-cwd.js"; import { assertLowTrustWorkspaceIsolation } from "../low-trust-runtime-containment.js"; import type { TrustPresetResolution } from "../trust-preset-resolver.js"; import { createEffectiveRunConfigFingerprints, createEffectiveRunConfigSubcategoryFingerprints, EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION, type EffectiveRunConfigFingerprints, type EffectiveRunConfigSecretManifestEntry, } from "../effective-run-config-fingerprints.js"; const execFile = promisify(execFileCallback); const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; export const WORKSPACE_VALIDATION_FAILURE_CODE = "workspace_validation_failed"; const GITHUB_PR_WORKFLOW_SKILL_KEY = "paperclipai/bundled/software-development/github-pr-workflow"; const GITHUB_PR_WORKFLOW_SKILL_SLUG = "github-pr-workflow"; const PUSH_CAPABILITY_ENV_KEYS = ["GH_TOKEN", "GITHUB_TOKEN"] as const; // Keep this in sync with local adapters that require a git workspace before launch. const GIT_SENSITIVE_LOCAL_ADAPTER_TYPES = new Set([ "claude_local", "codex_local", "cursor", "gemini_local", "grok_local", "hermes_local", "kimi_local", "opencode_local", "pi_local", ]); export class WorkspaceValidationFailure extends Error { code = WORKSPACE_VALIDATION_FAILURE_CODE; resultJson: Record; constructor(message: string, resultJson: Record) { super(message); this.name = "WorkspaceValidationFailure"; this.resultJson = resultJson; } } function hasGithubPrWorkflowSkill(desiredSkills: string[]) { return desiredSkills.some((skill) => { const normalized = skill.trim(); return ( normalized === GITHUB_PR_WORKFLOW_SKILL_KEY || normalized === GITHUB_PR_WORKFLOW_SKILL_SLUG || normalized.endsWith(`/${GITHUB_PR_WORKFLOW_SKILL_SLUG}`) ); }); } export function requiresPushCapabilityPreflight(input: { adapterType: string; issueId: string | null | undefined; explicitRunScopedSkillKeys: string[]; }) { return ( Boolean(input.issueId) && GIT_SENSITIVE_LOCAL_ADAPTER_TYPES.has(input.adapterType) && hasGithubPrWorkflowSkill(input.explicitRunScopedSkillKeys) ); } export function applyPersistedExecutionWorkspaceConfig(input: { config: Record; workspaceConfig: ExecutionWorkspaceConfig | null; mode: ReturnType; }) { const nextConfig = { ...input.config }; if (input.mode !== "agent_default") { if (input.workspaceConfig?.workspaceRuntime === null) { delete nextConfig.workspaceRuntime; } else if (input.workspaceConfig?.workspaceRuntime) { nextConfig.workspaceRuntime = { ...input.workspaceConfig.workspaceRuntime, }; } if (input.workspaceConfig?.desiredState === null) { delete nextConfig.desiredState; } else if (input.workspaceConfig?.desiredState) { nextConfig.desiredState = input.workspaceConfig.desiredState; } if (input.workspaceConfig?.serviceStates === null) { delete nextConfig.serviceStates; } else if (input.workspaceConfig?.serviceStates) { nextConfig.serviceStates = { ...input.workspaceConfig.serviceStates }; } } if (input.workspaceConfig && input.mode === "isolated_workspace") { const nextStrategy = parseObject(nextConfig.workspaceStrategy); if (input.workspaceConfig.provisionCommand === null) delete nextStrategy.provisionCommand; else nextStrategy.provisionCommand = input.workspaceConfig.provisionCommand; if (input.workspaceConfig.runtimeProvisionCommand === null) delete nextStrategy.runtimeProvisionCommand; else nextStrategy.runtimeProvisionCommand = input.workspaceConfig.runtimeProvisionCommand; if (input.workspaceConfig.teardownCommand === null) delete nextStrategy.teardownCommand; else nextStrategy.teardownCommand = input.workspaceConfig.teardownCommand; nextConfig.workspaceStrategy = nextStrategy; } return nextConfig; } export function mergeExecutionWorkspaceMetadataForPersistence(input: { existingMetadata: Record | null | undefined; source: string; createdByRuntime: boolean; strategyType: "project_primary" | "git_worktree"; configSnapshot: Record | null; shouldReuseExisting: boolean; shouldRefreshConfigSnapshot?: boolean; workspaceConfigMetadata?: EffectiveRunWorkspaceConfigMetadata | null; baseRef: string | null | undefined; baseRefSha: string | null | undefined; }) { const base = { ...(input.existingMetadata ?? {}), source: input.source, createdByRuntime: input.createdByRuntime, } as Record; if (input.strategyType === "git_worktree") { base[GIT_BRANCH_OWNERSHIP_METADATA_KEY] = GIT_BRANCH_OWNERSHIP_METADATA_VERSION; } else { delete base[GIT_BRANCH_OWNERSHIP_METADATA_KEY]; } const existingSnapshot = parseObject(base.baseRefSnapshot); if (typeof existingSnapshot.resolvedSha !== "string" && input.baseRefSha) { base.baseRefSnapshot = { baseRef: input.baseRef ?? null, resolvedSha: input.baseRefSha, }; } if (input.workspaceConfigMetadata) { base[WORKSPACE_CONFIG_FINGERPRINT_METADATA_KEY] = { version: input.workspaceConfigMetadata.version, workspaceHash: input.workspaceConfigMetadata.fingerprint, categories: input.workspaceConfigMetadata.categories, categoryFingerprints: input.workspaceConfigMetadata.categoryFingerprints, lastEvaluatedAt: input.workspaceConfigMetadata.evaluatedAt, }; } if ( (input.shouldReuseExisting && !input.shouldRefreshConfigSnapshot) || !input.configSnapshot ) { return base; } return mergeExecutionWorkspaceConfig(base, input.configSnapshot); } export function resolveExecutionWorkspaceBranchOwnership( executionWorkspace: Pick< RealizedExecutionWorkspace, "created" | "branchCreatedByRuntime" >, ) { return executionWorkspace.branchCreatedByRuntime; } export function stripWorkspaceRuntimeFromExecutionRunConfig( config: Record, ) { const nextConfig = { ...config }; delete nextConfig.workspaceRuntime; return nextConfig; } export function buildExecutionWorkspaceConfigSnapshot( config: Record, environmentId?: string | null, ): Partial | null { const strategy = parseObject(config.workspaceStrategy); const snapshot: Partial = {}; // Persist the resolved environment onto the workspace so reused sessions stay on the // environment they were created against until the workspace itself is recreated/reset. const hasExplicitEnvironmentSelection = environmentId !== undefined; if (hasExplicitEnvironmentSelection) { snapshot.environmentId = environmentId ?? null; } if ("workspaceStrategy" in config) { snapshot.provisionCommand = typeof strategy.provisionCommand === "string" ? strategy.provisionCommand : null; snapshot.runtimeProvisionCommand = typeof strategy.runtimeProvisionCommand === "string" ? strategy.runtimeProvisionCommand : null; snapshot.teardownCommand = typeof strategy.teardownCommand === "string" ? strategy.teardownCommand : null; } if ("workspaceRuntime" in config) { const workspaceRuntime = parseObject(config.workspaceRuntime); snapshot.workspaceRuntime = Object.keys(workspaceRuntime).length > 0 ? workspaceRuntime : null; } if ("desiredState" in config) { snapshot.desiredState = config.desiredState === "running" || config.desiredState === "stopped" || config.desiredState === "manual" ? config.desiredState : null; } if ("serviceStates" in config) { const serviceStates = parseObject(config.serviceStates); snapshot.serviceStates = Object.keys(serviceStates).length > 0 ? (Object.fromEntries( Object.entries(serviceStates).filter( ([, state]) => state === "running" || state === "stopped" || state === "manual", ), ) as ExecutionWorkspaceConfig["serviceStates"]) : null; } const hasSnapshot = Object.values(snapshot).some((value) => { if (value === null) return false; if (typeof value === "object") return Object.keys(value).length > 0; return true; }) || hasExplicitEnvironmentSelection; return hasSnapshot ? snapshot : null; } export function stripHostWorkspaceProvision(input: { config: Record; skipHostProvision: boolean; }): Record { if (!input.skipHostProvision) return input.config; const workspaceStrategy = parseObject(input.config.workspaceStrategy); if ( typeof workspaceStrategy.provisionCommand !== "string" && typeof workspaceStrategy.runtimeProvisionCommand !== "string" && typeof input.config.runtimeProvisionCommand !== "string" ) return input.config; const nextWorkspaceStrategy = { ...workspaceStrategy }; delete nextWorkspaceStrategy.provisionCommand; delete nextWorkspaceStrategy.runtimeProvisionCommand; const nextConfig: Record = { ...input.config, workspaceStrategy: nextWorkspaceStrategy }; delete nextConfig.runtimeProvisionCommand; return nextConfig; } export async function preflightLowTrustWorkspaceIsolation(input: { db?: Db; trustPreset: TrustPresetResolution; isolatedWorkspacesEnabled: boolean; effectiveExecutionWorkspaceMode: string | null | undefined; issue: { companyId: string; id?: string | null; projectId?: string | null; } | null; resolveSelectedEnvironmentDriver: () => Promise; }): Promise { if ( input.trustPreset.kind !== "denied" && input.trustPreset.kind !== "low_trust_review" ) { return null; } const selectedEnvironmentDriver = input.trustPreset.kind === "low_trust_review" ? await input.resolveSelectedEnvironmentDriver() : null; await assertLowTrustWorkspaceIsolation({ db: input.db, resolution: input.trustPreset, isolatedWorkspacesEnabled: input.isolatedWorkspacesEnabled, effectiveExecutionWorkspaceMode: input.effectiveExecutionWorkspaceMode, selectedEnvironmentDriver, issue: input.issue, }); return selectedEnvironmentDriver ?? null; } export async function resolveWorkspaceAfterLowTrustPreflight< TWorkspace, >(input: { db?: Db; trustPreset: TrustPresetResolution; isolatedWorkspacesEnabled: boolean; effectiveExecutionWorkspaceMode: string | null | undefined; issue: { companyId: string; id?: string | null; projectId?: string | null; } | null; resolveSelectedEnvironmentDriver: () => Promise; resolveWorkspace: () => Promise; }): Promise<{ selectedEnvironmentDriver: string | null; workspace: TWorkspace; }> { const selectedEnvironmentDriver = await preflightLowTrustWorkspaceIsolation({ db: input.db, trustPreset: input.trustPreset, isolatedWorkspacesEnabled: input.isolatedWorkspacesEnabled, effectiveExecutionWorkspaceMode: input.effectiveExecutionWorkspaceMode, issue: input.issue, resolveSelectedEnvironmentDriver: input.resolveSelectedEnvironmentDriver, }); return { selectedEnvironmentDriver, workspace: await input.resolveWorkspace(), }; } type PrepareManagedLocalSource = (sourceCwd: string, targetCwd: string) => Promise<(targetCwd: string) => Promise>; /** * In-flight managed-checkout materializations keyed by target cwd. Two issues on the same * project can wake within seconds of each other; without this, both runs raced the same * clone target — the loser saw "destination path already exists" and its failure cleanup * deleted the winner's in-progress clone, so both runs failed every round. */ const managedCheckoutMaterializations = new Map< string, Promise<{ cwd: string; warning: string | null }> >(); export async function ensureManagedProjectWorkspace(input: { companyId: string; projectId: string; repoUrl: string | null; prepareLocalSource?: PrepareManagedLocalSource; /** Optional git credential source for cloning private repos; null/absent preserves ambient behavior. */ resolveGitAuth?: GitRemoteAuthProvider | null; }): Promise<{ cwd: string; warning: string | null }> { const defaultCwd = resolveManagedProjectWorkspaceDir({ companyId: input.companyId, projectId: input.projectId, repoName: deriveRepoNameFromRepoUrl(input.repoUrl), }); let cwd = defaultCwd; if (input.repoUrl && await fs.stat(path.join(cwd, ".git")).catch(() => null)) { const origin = await execFile("git", ["-C", cwd, "remote", "get-url", "origin"], { timeout: 10_000 }) .then((result) => result.stdout.trim()).catch(() => null); if (origin && origin !== input.repoUrl) { cwd = `${cwd}-${createHash("sha256").update(input.repoUrl).digest("hex").slice(0, 12)}`; } } const inFlight = managedCheckoutMaterializations.get(cwd); if (inFlight) { await inFlight; // The winner may have cloned a different repository with the same basename. return ensureManagedProjectWorkspace(input); } const localSource = input.repoUrl ? localRepositoryPath(input.repoUrl) : null; const guardLocalSource = localSource ? await input.prepareLocalSource?.(localSource, cwd) : undefined; await guardLocalSource?.(cwd); const attempt = materializeManagedProjectWorkspace(cwd, { ...input, beforeCopy: guardLocalSource, beforePublish: guardLocalSource ? () => guardLocalSource(cwd) : undefined, }).finally(() => { managedCheckoutMaterializations.delete(cwd); }); managedCheckoutMaterializations.set(cwd, attempt); const result = await attempt; await guardLocalSource?.(cwd); if (input.repoUrl) { // A different server process can publish a same-name checkout between the // initial origin check and the atomic rename. Never adopt its other repo. const origin = await execFile("git", ["-C", cwd, "remote", "get-url", "origin"], { timeout: 10_000 }) .then((value) => value.stdout.trim()).catch(() => null); if (origin && origin !== input.repoUrl) { if (cwd !== defaultCwd) throw new Error("Managed checkout origin does not match the requested repository"); return ensureManagedProjectWorkspace(input); } } return result; } /** Keep every distinct project repository inside the task's writable/synced root. */ export async function prepareProjectRepositoryWorkspaces(input: { cwd: string; anchorRepoUrl: string | null; workspaces: Array & { cwd?: string | null }>; resolveGitAuth?: GitRemoteAuthProvider | null; beforePublish?: () => Promise; prepareLocalSource?: (sourceCwd: string, projectWorkspaceId: string) => Promise<() => Promise>; restoredContents?: GitWorkspaceContents; sharedBase?: { companyId: string; projectId: string }; }): Promise> { const identity = projectRepositoryIdentity; const seen = new Set(input.anchorRepoUrl ? [identity(input.anchorRepoUrl)] : []); const selected = selectRestoredProjectRepositories(input.workspaces, input.restoredContents).filter((workspace) => { if (!workspace.repoUrl || seen.has(identity(workspace.repoUrl))) return false; seen.add(identity(workspace.repoUrl)); return true; }); if (input.restoredContents?.checkouts.some(checkout => !selected.some(source => source.id === checkout.source.id))) throw new GitCacheRestorationFailure(); const root = path.join(input.cwd, PROJECT_REPOSITORIES_DIR); if (selected.length === 0 && !(await fs.lstat(root).catch(() => null))) return []; await fs.mkdir(root, { recursive: true }); if (await fs.realpath(root) !== path.join(await fs.realpath(input.cwd), PROJECT_REPOSITORIES_DIR)) { throw new Error("Project repositories directory escapes the task workspace"); } await ensureManagedRepositoriesIgnored(input.cwd); const results = []; for (const workspace of selected) { const repoUrl = workspace.repoUrl!; const key = managedProjectCheckoutName({ id: workspace.id, repoUrl, repoRef: workspace.repoRef, cwd: workspace.cwd }); const cwd = path.join(root, key); const existing = await fs.lstat(cwd).catch(() => null); if (!existing && input.restoredContents?.checkouts.some(checkout => checkout.name === key)) throw new GitCacheRestorationFailure(); if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) throw new Error("Invalid project repository checkout path"); const baseCandidates = input.sharedBase ? nestedRepositoryBaseCandidates({ ...input.sharedBase, repoUrl }) : null; const linked = existing ? await readLinkedNestedCheckout(cwd, baseCandidates ?? []).catch(() => { throw new Error("Invalid project repository checkout path"); }) : null; if (linked && !await isVerifiedNestedRepositoryBase(linked.base, baseCandidates!, repoUrl)) throw new Error("Invalid project repository checkout path"); const configuredLocalSource = workspace.cwd && workspace.cwd !== REPO_ONLY_CWD_SENTINEL ? workspace.cwd : null; const localSource = configuredLocalSource && await fs.stat(configuredLocalSource).then((entry) => entry.isDirectory()).catch(() => false) ? configuredLocalSource : null; const urlLocalSource = localRepositoryPath(repoUrl); const sourceToAuthorize = localSource ?? urlLocalSource; const recheckLocalSource = sourceToAuthorize ? await input.prepareLocalSource?.(sourceToAuthorize, workspace.id) : undefined; const beforePublish = async () => { await recheckLocalSource?.(); await input.beforePublish?.(); }; if (!linked) { const shared = input.sharedBase && !existing && !localSource ? await ensureManagedProjectWorkspace({ ...input.sharedBase, repoUrl, resolveGitAuth: input.resolveGitAuth }) : null; if (shared && !shared.warning && await isVerifiedNestedRepositoryBase(shared.cwd, baseCandidates!, repoUrl)) { await addNestedRepositoryWorktree({ base: shared.cwd, cwd, repoUrl, repoRef: workspace.repoRef, resolveGitAuth: input.resolveGitAuth, beforeAdd: beforePublish }); } else { const result = await materializeManagedProjectWorkspace(cwd, { repoUrl, repoRef: workspace.repoRef, localSource, resolveGitAuth: input.resolveGitAuth, beforePublish }); if (result.warning) throw new Error(result.warning); } } await beforePublish(); results.push({ workspaceId: workspace.id, cwd, repoUrl, repoRef: workspace.repoRef }); } // Retain detached checkout work outside the synchronized repository set. const active = new Set(results.map((repo) => path.basename(repo.cwd))); for (const entry of await fs.readdir(root)) { if (active.has(entry) || entry.startsWith("task-repo-") || entry.includes(".clone-")) continue; const retained = path.join(input.cwd, ".paperclip-runtime", "detached-repositories", randomUUID()); const linked = await readLinkedNestedCheckout(path.join(root, entry)).catch(() => null); await fs.mkdir(path.dirname(retained), { recursive: true }); await fs.rename(path.join(root, entry), retained); if (linked) await repairMovedNestedRepositoryWorktree(linked.base, retained); } return results; } /** * Resolve one project workspace row to a usable cwd. The anchor path and each additional * referenced project share this step: use the configured cwd when present, otherwise clone or * create the managed checkout directory for the project. It throws only when the managed * checkout cannot be prepared (for example, a clone failure). */ async function resolveConfiguredOrManagedProjectCwd(input: { companyId: string; projectId: string; cwd: string | null; repoUrl: string | null; resolveGitAuth?: GitRemoteAuthProvider | null; prepareLocalSource?: PrepareManagedLocalSource; }): Promise<{ cwd: string; warning: string | null }> { const configuredCwd = readNonEmptyString(input.cwd); if (configuredCwd && configuredCwd !== REPO_ONLY_CWD_SENTINEL) { return { cwd: configuredCwd, warning: null }; } return ensureManagedProjectWorkspace({ companyId: input.companyId, projectId: input.projectId, repoUrl: readNonEmptyString(input.repoUrl), resolveGitAuth: input.resolveGitAuth ?? null, prepareLocalSource: input.prepareLocalSource, }); } /** * Side-effecting dependencies for {@link resolveAdditionalProjectWorkspace}. The caller injects * the real database, filesystem, and managed-checkout helpers. A test injects fakes to exercise * the resolution logic without a database or filesystem. */ export interface ResolveAdditionalProjectWorkspaceDeps { loadProjectWorkspaceRows: ( companyId: string, projectId: string, ) => Promise>; resolveConfiguredOrManagedProjectCwd: typeof resolveConfiguredOrManagedProjectCwd; ensureManagedProjectWorkspace: typeof ensureManagedProjectWorkspace; directoryHasContents: (cwd: string) => Promise; } /** Build the real dependencies for {@link resolveAdditionalProjectWorkspace}. */ function defaultAdditionalProjectWorkspaceDeps( db: Db, resolveGitAuth?: GitRemoteAuthProvider, prepareLocalSource?: PrepareManagedLocalSource, ): ResolveAdditionalProjectWorkspaceDeps { return { loadProjectWorkspaceRows: (companyId, projectId) => db .select() .from(projectWorkspaces) .where( and( eq(projectWorkspaces.companyId, companyId), eq(projectWorkspaces.projectId, projectId), ), ) .orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)), resolveConfiguredOrManagedProjectCwd: (input) => resolveConfiguredOrManagedProjectCwd({ ...input, prepareLocalSource: input.prepareLocalSource ?? prepareLocalSource, resolveGitAuth: input.resolveGitAuth ?? resolveGitAuth ?? createGitRemoteAuthProvider(db, input.companyId), }), ensureManagedProjectWorkspace: (input) => ensureManagedProjectWorkspace({ ...input, prepareLocalSource: input.prepareLocalSource ?? prepareLocalSource, resolveGitAuth: input.resolveGitAuth ?? resolveGitAuth ?? createGitRemoteAuthProvider(db, input.companyId), }), // A realized workspace must hold real content. An empty directory gives the agent an empty // referenced workspace, so treat an empty directory the same as a missing one. directoryHasContents: async (cwd) => { const stats = await fs.stat(cwd).catch(() => null); if (!stats || !stats.isDirectory()) { return false; } const entries = await fs.readdir(cwd).catch(() => [] as string[]); return entries.length > 0; }, }; } /** * Resolve one authorized referenced project to its own workspace cwd. Each additional project * lands in its own managed checkout directory, never nested inside the anchor's worktree (the * directory isolation invariant lives in {@link resolveManagedProjectWorkspaceDir}). * * A referenced project must resolve to a directory with real content. The function uses a * configured checkout directory that exists, or clones a managed checkout from a workspace row * that supplies a repository URL. When no row offers either, the function throws instead of * creating an empty managed directory. An empty directory gives the agent an empty referenced * workspace and hides the real cause. The caller catches the error and drops only that project. * The function also throws when the managed checkout cannot be prepared (for example, a clone * failure), so the caller can drop only that project. */ export async function resolveAdditionalProjectWorkspace( input: { companyId: string; project: RunReferencedProject; }, deps: ResolveAdditionalProjectWorkspaceDeps, ): Promise { const { companyId } = input; const projectId = input.project.projectId; const workspaceRows = await deps.loadProjectWorkspaceRows( companyId, projectId, ); for (const workspace of workspaceRows) { // A row realizes real content only through a configured checkout directory or a repository URL // to clone. A row with neither can produce only an empty managed directory, so skip it here. const configuredCwd = readNonEmptyString(workspace.cwd); const hasConfiguredCwd = Boolean(configuredCwd) && configuredCwd !== REPO_ONLY_CWD_SENTINEL; if (!hasConfiguredCwd && !readNonEmptyString(workspace.repoUrl)) { continue; } const { cwd } = await deps.resolveConfiguredOrManagedProjectCwd({ companyId, projectId, cwd: workspace.cwd, repoUrl: workspace.repoUrl, }); // A directory that exists but holds no content is not a realized workspace. Accept the row only // when the resolved directory has real content, so an empty directory never masks a missing one. if (await deps.directoryHasContents(cwd)) { return { cwd, projectId, workspaceId: workspace.id, repoUrl: workspace.repoUrl, repoRef: workspace.repoRef, }; } } // No configured checkout resolved to a directory with content. Clone a managed checkout only from a // real source: the first workspace row that supplies a repository URL. Without a real source, do // not fabricate an empty managed directory and report success. Throw instead, so the caller drops // only this referenced project and adds a clear warning. const fallbackRow = workspaceRows.find((row) => readNonEmptyString(row.repoUrl)) ?? null; const fallbackRepoUrl = fallbackRow ? readNonEmptyString(fallbackRow.repoUrl) : null; if (!fallbackRow || !fallbackRepoUrl) { throw new Error( `Referenced project ${projectId} has no workspace checkout or repository URL to realize.`, ); } const managed = await deps.ensureManagedProjectWorkspace({ companyId, projectId, repoUrl: fallbackRepoUrl, }); return { cwd: managed.cwd, projectId, workspaceId: fallbackRow.id, repoUrl: fallbackRow.repoUrl, repoRef: fallbackRow.repoRef, }; } type WorkspaceValidationFailureLike = | WorkspaceValidationFailure | { code: typeof WORKSPACE_VALIDATION_FAILURE_CODE; resultJson: Record; }; export function isWorkspaceValidationFailure( error: unknown, ): error is WorkspaceValidationFailureLike { if (error instanceof WorkspaceValidationFailure) return true; const maybe = error as { code?: unknown; resultJson?: unknown } | null; return Boolean( maybe && maybe.code === WORKSPACE_VALIDATION_FAILURE_CODE && maybe.resultJson && typeof maybe.resultJson === "object" && !Array.isArray(maybe.resultJson), ); } export function isWorkspaceValidationFailedRun( run: Pick | null | undefined, ) { return run?.errorCode === WORKSPACE_VALIDATION_FAILURE_CODE; } export function readWorkspaceValidationPayloadFromRun( run: Pick | null | undefined, ) { return parseObject(parseObject(run?.resultJson).workspaceValidation); } function stableStringifyForFingerprint(value: unknown): string { if (Array.isArray(value)) { return `[${value.map((entry) => stableStringifyForFingerprint(entry)).join(",")}]`; } if (value && typeof value === "object") { const rec = value as Record; return `{${Object.keys(rec) .sort() .map( (key) => `${JSON.stringify(key)}:${stableStringifyForFingerprint(rec[key])}`, ) .join(",")}}`; } return JSON.stringify(value); } export function fingerprintFinalizeWorkspaceBranchValidation(input: { issueId: string | null; executionWorkspaceId: string; inspection: ReturnType; }) { const digest = createHash("sha256") .update( stableStringifyForFingerprint({ version: 1, reason: "git_worktree_branch_incoherence", issueId: input.issueId, executionWorkspaceId: input.executionWorkspaceId, worktreePath: input.inspection.worktreePath ? path.resolve(input.inspection.worktreePath) : null, repoRoot: input.inspection.repoRoot ? path.resolve(input.inspection.repoRoot) : null, expectedBranchName: input.inspection.expectedBranchName, actualBranchName: input.inspection.actualBranchName, reasonCode: input.inspection.reasonCode, }), ) .digest("hex"); return `workspace_finalize_branch_mismatch:v1:sha256:${digest}`; } async function hasGitMetadata(cwd: string | null | undefined) { const normalized = readNonEmptyString(cwd); if (!normalized) return false; return fs .lstat(path.resolve(normalized, ".git")) .then((entry) => entry.isDirectory() || entry.isFile()) .catch(() => false); } async function isGitCheckout(cwd: string | null | undefined) { const normalized = readNonEmptyString(cwd); if (!normalized) return false; return execFile("git", ["rev-parse", "--show-toplevel"], { cwd: normalized }) .then((result) => Boolean(readNonEmptyString(result.stdout))) .catch(() => false); } async function probeGitWorktreeBase(cwd: string) { try { const result = await execFile("git", ["rev-parse", "--show-toplevel"], { cwd, env: { ...process.env, LC_ALL: "C" }, }); return { isCheckout: Boolean(readNonEmptyString(result.stdout)), notRepository: false }; } catch (error) { const failure = error as { code?: unknown; signal?: unknown; stderr?: unknown }; // Git's absence result is only a candidate: damaged metadata can produce // the same message. Verify its absence too. Missing Git, permission errors, // corruption and I/O failures must remain reportable in Sentry. const notRepository = failure.code === 128 && failure.signal == null && typeof failure.stderr === "string" && /^fatal: not a git repository \(or any of the parent directories\): \.git\r?\n?$/.test(failure.stderr) && !process.env.GIT_DIR && !process.env.GIT_WORK_TREE && !process.env.GIT_COMMON_DIR && await hasNoGitMetadataInWorkspaceAncestors(cwd); return { isCheckout: false, notRepository }; } } async function hasNoGitMetadataInWorkspaceAncestors(cwd: string): Promise { try { let directory = await fs.realpath(cwd); if (!(await fs.stat(directory)).isDirectory()) return false; for (;;) { // Damaged repositories can produce the same "not a git repository" // message as ordinary directories. Retain those failures in Sentry. // lstat deliberately recognizes dangling .git links as metadata too. for (const name of [".git", "HEAD", "objects", "refs"]) { try { await fs.lstat(path.join(directory, name)); return false; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false; } } const parent = path.dirname(directory); if (parent === directory) return true; directory = parent; } } catch { return false; } } function sameResolvedPath( left: string | null | undefined, right: string | null | undefined, ) { const leftPath = readNonEmptyString(left); const rightPath = readNonEmptyString(right); if (!leftPath || !rightPath) return false; return path.resolve(leftPath) === path.resolve(rightPath); } async function hasGitPushRemote(cwd: string | null | undefined) { const normalized = readNonEmptyString(cwd); if (!normalized) return false; const remoteNames = await execFile("git", ["remote"], { cwd: normalized }) .then((result) => result.stdout .split(/\r?\n/) .map((value) => value.trim()) .filter((value) => value.length > 0), ) .catch(() => []); for (const remoteName of remoteNames) { const pushUrl = await execFile( "git", ["remote", "get-url", "--push", remoteName], { cwd: normalized }, ) .then((result) => readNonEmptyString(result.stdout)) .catch(() => null); if (pushUrl) return true; } return false; } export async function assertGitWorktreeBaseWorkspaceReady(input: { requestedExecutionWorkspaceMode: ReturnType< typeof resolveExecutionWorkspaceMode >; config: Record; issue: { id: string; identifier: string | null; projectId: string | null; projectWorkspaceId: string | null; executionWorkspaceId?: string | null; executionWorkspacePreference?: string | null; } | null; base: ExecutionWorkspaceInput; /** * Anchor-resolution facts that `base` alone cannot express: whether the base cwd is the * agent-home fallback despite the project having workspaces, and which materialization * attempts failed on the way there. Absent means "not a fallback" (legacy callers). */ anchor?: { baseCwdFallback?: boolean; materializationFailures?: WorkspaceMaterializationFailure[]; /** Selected database workspace is an explicit local path with no repository URL. */ localPathOnlyWorkspace?: boolean; } | null; }) { if (!input.issue) return; if ( input.requestedExecutionWorkspaceMode !== "isolated_workspace" && input.requestedExecutionWorkspaceMode !== "operator_branch" ) { return; } const strategyType = resolveEffectiveWorkspaceStrategyType( input.requestedExecutionWorkspaceMode, input.config, ); if (strategyType !== "git_worktree") return; const issueLabel = input.issue.identifier ?? input.issue.id; const remediation = "This task needs a project / project workspace or a reusable execution workspace before it can run."; const fail = ( reason: string, message: string, extra: Record = {}, connectionFailure?: GitConnectionFailure, ) => { throw new WorkspaceValidationFailure(message, { ...(connectionFailure ? { connectionFailure } : {}), workspaceValidation: { reason, issueId: input.issue!.id, issueIdentifier: input.issue!.identifier, issueProjectId: input.issue!.projectId, issueProjectWorkspaceId: input.issue!.projectWorkspaceId, issueExecutionWorkspaceId: input.issue!.executionWorkspaceId ?? null, issueExecutionWorkspacePreference: input.issue!.executionWorkspacePreference ?? null, requestedExecutionWorkspaceMode: input.requestedExecutionWorkspaceMode, workspaceStrategyType: strategyType, resolvedWorkspaceSource: input.base.source, resolvedProjectId: input.base.projectId, resolvedProjectWorkspaceId: input.base.workspaceId, resolvedWorkspaceCwd: input.base.baseCwd, ...extra, }, }); }; if (input.base.source === "agent_home") { fail( "git_worktree_base_agent_home", `Issue ${issueLabel} requested ${input.requestedExecutionWorkspaceMode} with git_worktree, but no project or reusable execution workspace was resolved; refusing to create a git worktree from agent fallback cwd "${input.base.baseCwd}". ${remediation}`, ); } // Checked before isGitCheckout: when materialization failed and the base cwd is the // agent-home fallback, a git checkout at that path would be an unrelated repository — // proceeding would build worktrees off the wrong repo, and failing on the checkout probe // would mask the real cause (for example a clone that could not authenticate). The reason // is reserved for genuine materialization failures; a fallback with no failed attempt // (a configured path that is simply unavailable) keeps its accurate reporting below. const materializationFailures = input.anchor?.materializationFailures ?? []; if (input.anchor?.baseCwdFallback && materializationFailures.length > 0) { const failureDetail = `: ${materializationFailures[0].error.replace(/\s+/g, " ")}`; const connections = materializationFailures.map((failure) => readConnectionFailure(failure.connectionFailure)); // Mixed connection/local failures must retain their application-error report. const connectionFailure = connections.every((failure) => failure?.provider === "git") ? connections[0] as GitConnectionFailure : undefined; fail( "git_worktree_base_materialization_failed", `Issue ${issueLabel} requested ${input.requestedExecutionWorkspaceMode} with git_worktree, but the project workspace checkout could not be prepared${failureDetail}. Repair the project workspace repository URL, clone access, or configured local cwd, then retry.`, { baseCwdFallback: true, materializationFailures }, connectionFailure, ); } const checkoutProbe = await probeGitWorktreeBase(input.base.baseCwd); if (!checkoutProbe.isCheckout) { const knownLocalPathMismatch = checkoutProbe.notRepository && input.anchor?.localPathOnlyWorkspace === true && input.anchor.baseCwdFallback === false && materializationFailures.length === 0 && input.base.source === "project_primary" && Boolean(input.base.projectId) && Boolean(input.base.workspaceId) && !readNonEmptyString(input.base.repoUrl); fail( "git_worktree_base_not_git_checkout", `Issue ${issueLabel} requested ${input.requestedExecutionWorkspaceMode} with git_worktree, but base workspace "${input.base.baseCwd}" is not a git checkout. ${remediation}`, knownLocalPathMismatch ? { configurationReason: "local_path_requires_git_checkout" } : {}, ); } // A fallback cwd that happens to be a git checkout is still not the configured project // workspace — building worktrees there would target an unrelated repository. No // materialization attempt failed here (that case failed above); the configured path is // simply unavailable, so the message points at the path rather than clone access. if (input.anchor?.baseCwdFallback) { fail( "git_worktree_base_fallback_not_project_workspace", `Issue ${issueLabel} requested ${input.requestedExecutionWorkspaceMode} with git_worktree, but the configured project workspace path is not available and the fallback cwd "${input.base.baseCwd}" is not the project workspace checkout. Make the configured project workspace path available on this host, or repair the project workspace configuration, then retry.`, { baseCwdFallback: true, materializationFailures }, ); } } export async function assertPushCapabilityCheckoutValid(input: { enabled: boolean; issue: { id: string; identifier: string | null; } | null; cwd: string | null | undefined; }) { if (!input.enabled || !input.issue) return; const cwd = readNonEmptyString(input.cwd); if (!cwd) return; if (await hasGitPushRemote(cwd)) return; throw new WorkspaceValidationFailure( `Issue ${input.issue.identifier ?? input.issue.id} requested the GitHub PR workflow, but checkout "${cwd}" has no configured push remote. Bind the run to a writable repo checkout before dispatching the agent.`, { workspaceValidation: { reason: "missing_git_push_remote", issueId: input.issue.id, issueIdentifier: input.issue.identifier, executionWorkspaceCwd: cwd, requiredEnvKeys: [...PUSH_CAPABILITY_ENV_KEYS], }, }, ); } /** * Reconcile the `projectWorkspaceId` for a reused execution workspace. * * A `reuse_existing` workspace can have been persisted with a null * `projectWorkspaceId` (e.g. it was created before its project had a primary * project workspace). When we later restore it for a run whose issue now * expects a concrete project workspace, backfill the column so the launch * guard (`persisted_workspace_missing_project_workspace_id`) stops rejecting * it on every requeue — otherwise `reuse_existing` re-binds the same stale * record forever and the run crash-loops. Prefer the existing binding when * present so we never null out a good value or silently rebind a genuine * mismatch (which the guard still surfaces). */ export function reconcileReusedExecutionWorkspaceProjectWorkspaceId( existingProjectWorkspaceId: string | null | undefined, resolvedProjectWorkspaceId: string | null | undefined, ): string | null { return existingProjectWorkspaceId ?? resolvedProjectWorkspaceId ?? null; } export async function assertGitSensitiveAdapterWorkspaceValid(input: { adapterType: string; agentId: string; issue: { id: string; identifier: string | null; projectId: string | null; projectWorkspaceId: string | null; } | null; resolvedWorkspace: ResolvedWorkspaceForRun; executionWorkspace: RealizedExecutionWorkspace; persistedExecutionWorkspace: ExecutionWorkspace | null; executionTarget: unknown; environmentDriver?: string | null; leaseMetadata?: unknown; }) { if (!GIT_SENSITIVE_LOCAL_ADAPTER_TYPES.has(input.adapterType)) return; const executionTargetKind = readNonEmptyString( (input.executionTarget as { kind?: unknown } | null)?.kind, ) ?? "local"; if (executionTargetKind !== "local") return; const issue = input.issue; if (!issue) return; const environmentDriver = readNonEmptyString(input.environmentDriver) ?? "local"; const leaseMetadata = parseObject(input.leaseMetadata); const leaseProviderMetadata = parseObject(leaseMetadata.providerMetadata); const leaseRemoteCwd = readNonEmptyString(leaseMetadata.remoteCwd) ?? readNonEmptyString(leaseProviderMetadata.remoteCwd); const effectiveCwd = readNonEmptyString(input.executionWorkspace.cwd); const persistedCwd = readNonEmptyString( input.persistedExecutionWorkspace?.cwd, ); const agentFallbackCwd = resolveDefaultAgentWorkspaceDir(input.agentId); const workspaceExpectation = Boolean(issue.projectWorkspaceId) || Boolean(input.resolvedWorkspace.workspaceId) || input.executionWorkspace.strategy === "git_worktree"; const fail = ( reason: string, message: string, extra: Record = {}, ) => { throw new WorkspaceValidationFailure(message, { workspaceValidation: { reason, adapterType: input.adapterType, issueId: issue.id, issueIdentifier: issue.identifier, issueProjectId: issue.projectId, issueProjectWorkspaceId: issue.projectWorkspaceId, resolvedWorkspaceSource: input.resolvedWorkspace.source, resolvedProjectId: input.resolvedWorkspace.projectId, resolvedProjectWorkspaceId: input.resolvedWorkspace.workspaceId, resolvedWorkspaceCwd: input.resolvedWorkspace.cwd, executionWorkspaceCwd: effectiveCwd, executionWorkspaceStrategy: input.executionWorkspace.strategy, executionWorkspaceProjectId: input.executionWorkspace.projectId, executionWorkspaceProjectWorkspaceId: input.executionWorkspace.workspaceId, persistedExecutionWorkspaceId: input.persistedExecutionWorkspace?.id ?? null, persistedWorkspaceCwd: persistedCwd, persistedWorkspaceStrategy: input.persistedExecutionWorkspace?.strategyType ?? null, persistedProjectId: input.persistedExecutionWorkspace?.projectId ?? null, persistedProjectWorkspaceId: input.persistedExecutionWorkspace?.projectWorkspaceId ?? null, persistedProviderRef: input.persistedExecutionWorkspace?.providerRef ?? null, ...extra, }, }); }; if (issue.projectWorkspaceId && !issue.projectId && !input.persistedExecutionWorkspace?.projectId) { fail( "missing_project_id", `Issue ${issue.identifier ?? issue.id} is linked to a project workspace but has no project id; refusing to launch ${input.adapterType} from fallback cwd.`, ); } if (!input.executionTarget && environmentDriver !== "local" && leaseRemoteCwd) return; if (workspaceExpectation && !input.persistedExecutionWorkspace) { fail( "missing_persisted_execution_workspace", `Issue ${issue.identifier ?? issue.id} requires a project execution workspace, but none was persisted before adapter launch.`, ); } if (workspaceExpectation && !effectiveCwd) { fail( "missing_effective_cwd", `Issue ${issue.identifier ?? issue.id} expected a project workspace, but no adapter cwd was resolved before launch.`, ); } if ( input.persistedExecutionWorkspace && effectiveCwd && persistedCwd && !sameResolvedPath(effectiveCwd, persistedCwd) ) { fail( "persisted_cwd_mismatch", `Issue ${issue.identifier ?? issue.id} resolved adapter cwd "${effectiveCwd}" but persisted execution workspace cwd is "${persistedCwd}".`, ); } const expectedProjectWorkspaceId = issue.projectWorkspaceId ?? input.resolvedWorkspace.workspaceId ?? null; if ( expectedProjectWorkspaceId && input.persistedExecutionWorkspace && !input.persistedExecutionWorkspace.projectWorkspaceId ) { fail( "persisted_workspace_missing_project_workspace_id", `Issue ${issue.identifier ?? issue.id} expected project workspace "${expectedProjectWorkspaceId}" but persisted execution workspace has no project workspace id.`, ); } if ( expectedProjectWorkspaceId && input.persistedExecutionWorkspace?.projectWorkspaceId && input.persistedExecutionWorkspace.projectWorkspaceId !== expectedProjectWorkspaceId ) { fail( "project_workspace_mismatch", `Issue ${issue.identifier ?? issue.id} expected project workspace "${expectedProjectWorkspaceId}" but persisted execution workspace points at "${input.persistedExecutionWorkspace.projectWorkspaceId}".`, ); } if ( workspaceExpectation && effectiveCwd && sameResolvedPath(effectiveCwd, agentFallbackCwd) ) { fail( "fallback_agent_home_cwd", `Issue ${issue.identifier ?? issue.id} expected a project workspace, but ${input.adapterType} would launch from agent fallback cwd "${effectiveCwd}".`, ); } if ( input.persistedExecutionWorkspace?.strategyType === "git_worktree" && input.persistedExecutionWorkspace.providerRef && effectiveCwd && !sameResolvedPath( effectiveCwd, input.persistedExecutionWorkspace.providerRef, ) ) { fail( "git_worktree_provider_ref_mismatch", `Issue ${issue.identifier ?? issue.id} expected git worktree "${input.persistedExecutionWorkspace.providerRef}" but adapter cwd resolved to "${effectiveCwd}".`, ); } if ( workspaceExpectation && !(input.resolvedWorkspace.localPathOnlyWorkspace === true && input.executionWorkspace.strategy === "project_primary" && !input.executionWorkspace.repoUrl && !input.executionWorkspace.branchName) && effectiveCwd && !(await hasGitMetadata(effectiveCwd)) ) { fail( "missing_git_metadata", `Issue ${issue.identifier ?? issue.id} expected a git workspace for ${input.adapterType}, but "${effectiveCwd}" has no .git metadata.`, ); } const expectedManagedBranchName = readNonEmptyString(input.executionWorkspace.branchName) ?? readNonEmptyString(input.persistedExecutionWorkspace?.branchName); if ( input.persistedExecutionWorkspace?.strategyType === "git_worktree" && effectiveCwd && expectedManagedBranchName ) { const inspection = await inspectManagedGitWorktreeBranch({ worktreePath: effectiveCwd, expectedBranchName: expectedManagedBranchName, }); if (!inspection.valid) { fail( "git_worktree_branch_mismatch", `Issue ${issue.identifier ?? issue.id} expected git worktree branch "${expectedManagedBranchName}" at "${effectiveCwd}", but ${inspection.reason ?? "the checked-out branch could not be verified"}.`, { managedGitWorktreeBranch: formatManagedGitWorktreeBranchInspection(inspection), }, ); } } } /** * A single read-only referenced (mentioned) project workspace resolved for a run. * The run materializes one entry per authorized additional project, each in its own * managed checkout directory. See {@link resolveAdditionalRunWorkspaces}. */ export type ResolvedAdditionalWorkspace = { cwd: string; projectId: string; workspaceId: string | null; repoUrl: string | null; repoRef: string | null; }; /** * One project-workspace materialization attempt that failed during anchor resolution — for * example a managed `git clone` that could not authenticate against a private repository. * Carried on {@link ResolvedWorkspaceForRun} so downstream validation can report the real * cause instead of the fallback cwd's symptoms. `repoUrl` and `error` are scrubbed of URL * userinfo credentials before they are stored. */ export type WorkspaceMaterializationFailure = { projectWorkspaceId: string | null; repoUrl: string | null; error: string; connectionFailure?: GitConnectionFailure; }; export type ResolvedWorkspaceForRun = { cwd: string; source: "project_primary" | "task_session" | "agent_home"; projectId: string | null; workspaceId: string | null; repoUrl: string | null; repoRef: string | null; workspaceHints: Array<{ workspaceId: string; cwd: string | null; repoUrl: string | null; repoRef: string | null; }>; warnings: string[]; /** * True when project workspaces exist for the run but none could be used, so `cwd` is the * agent-home fallback rather than a configured or materialized project workspace path. The * `source` stays `project_primary` in that case (session migration depends on it), so this * flag is the only reliable fallback signal. */ baseCwdFallback: boolean; /** Failed materialization attempts behind {@link baseCwdFallback}; empty when every candidate resolved or none was attempted. */ materializationFailures: WorkspaceMaterializationFailure[]; /** True only for an explicitly configured local project path without a repository URL. */ localPathOnlyWorkspace?: boolean; /** Current configuration for drift reporting; never an alternative restore source. */ freshnessSource?: { projectId: string | null; workspaceId: string | null; repoUrl: string | null; repoRef: string | null }; /** * Read-only referenced (mentioned) project workspaces for this run, one per authorized * additional project. The array is empty unless the multi-project workspace-sync flag is on * ({@link isMultiProjectWorkspaceSyncEnabled}); with the flag off the run resolves the anchor * workspace only, exactly as before. */ additionalWorkspaces: ResolvedAdditionalWorkspace[]; /** Authorized project IDs for plugin-owned storage; no Core filesystem staging. */ runnerTaskProjectIds?: string[]; /** * Structured record of every referenced project that the run dropped or failed, paired with the * layer that dropped it. Run preparation reads this to emit the requested-vs-synced observability * log. The human-readable form of each drop already rides {@link ResolvedWorkspaceForRun.warnings}. */ referencedProjectFailures: ReferencedProjectFailure[]; }; /** The anchor workspace shape, before the additional referenced workspaces are attached. */ type ResolvedAnchorWorkspaceForRun = Omit< ResolvedWorkspaceForRun, "additionalWorkspaces" | "referencedProjectFailures" | "runnerTaskProjectIds" >; /** * Assemble the run warnings for the agent-home fallback when a project has workspaces but none * produced a usable cwd. Materialization failures (for example a failed managed clone) take * priority over the generic "no local cwd configured" note, which previously masked them. */ export function buildAnchorFallbackWorkspaceNotes(input: { fallbackCwd: string; preferredWorkspaceWarning: string | null; materializationFailures: WorkspaceMaterializationFailure[]; missingProjectCwds: string[]; hasConfiguredProjectCwd: boolean; }): string[] { const warnings: string[] = []; if (input.preferredWorkspaceWarning) { warnings.push(input.preferredWorkspaceWarning); } if (input.materializationFailures.length > 0) { const first = input.materializationFailures[0]; const extraFailureCount = input.materializationFailures.length - 1; warnings.push( extraFailureCount > 0 ? `Failed to prepare the project workspace checkout (${first.error}), and ${extraFailureCount} other candidate workspace(s) also failed. Using fallback workspace "${input.fallbackCwd}" for this run.` : `Failed to prepare the project workspace checkout: ${first.error}. Using fallback workspace "${input.fallbackCwd}" for this run.`, ); } if (input.missingProjectCwds.length > 0) { const firstMissing = input.missingProjectCwds[0]; const extraMissingCount = Math.max(0, input.missingProjectCwds.length - 1); warnings.push( extraMissingCount > 0 ? `Project workspace path "${firstMissing}" and ${extraMissingCount} other configured path(s) are not available yet. Using fallback workspace "${input.fallbackCwd}" for this run.` : `Project workspace path "${firstMissing}" is not available yet. Using fallback workspace "${input.fallbackCwd}" for this run.`, ); } else if ( input.materializationFailures.length === 0 && !input.hasConfiguredProjectCwd ) { warnings.push( `Project workspace has no local cwd configured. Using fallback workspace "${input.fallbackCwd}" for this run.`, ); } return warnings; } /** * Build the plural workspace list that a run exposes to the agent through the * `PAPERCLIP_WORKSPACES_JSON` environment variable. The list joins the anchor * project's alternative workspace rows with the read-only referenced (mentioned) * project workspaces, so every execution target receives the referenced project * paths through the same channel the run already uses for the anchor project. * * Each referenced entry carries its `projectId` so the agent can tell which * mentioned project a path belongs to. The referenced set is empty unless the * multi-project workspace-sync flag is on, so the exposed list is byte-for-byte * unchanged in the production default. */ export function buildRunWorkspaceHints( resolved: Pick< ResolvedWorkspaceForRun, "workspaceHints" | "additionalWorkspaces" >, ): Array> { return [ ...resolved.workspaceHints, ...resolved.additionalWorkspaces.map((additional) => ({ workspaceId: additional.workspaceId, cwd: additional.cwd, repoUrl: additional.repoUrl, repoRef: additional.repoRef, projectId: additional.projectId, })), ]; } type ProjectWorkspaceCandidate = { id: string; }; export function prioritizeProjectWorkspaceCandidatesForRun< T extends ProjectWorkspaceCandidate, >(rows: T[], preferredWorkspaceId: string | null | undefined): T[] { if (!preferredWorkspaceId) return rows; const preferredIndex = rows.findIndex( (row) => row.id === preferredWorkspaceId, ); if (preferredIndex <= 0) return rows; return [ rows[preferredIndex]!, ...rows.slice(0, preferredIndex), ...rows.slice(preferredIndex + 1), ]; } /** * Environment flag (kill-switch, default ON) that gates whether run preparation * consumes the multi-project referenced-project set produced by * {@link resolveRunReferencedProjects}. The feature is live by default: an unset * value resolves ON. An operator disables the feature with an explicit false * value (`"false"`, `"0"`, `"off"`, or `""`). While off, a run materializes only * the anchor project's workspace exactly as before — the referenced set is inert. */ export const MULTI_PROJECT_WORKSPACE_SYNC_ENV = "PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC"; /** * True when an environment value explicitly turns a flag off. An unset value is * not false — the caller decides the unset default. This is the inverse of * {@link isTruthyRuntimeEnvValue} for the kill-switch words plus the empty string. */ function isFalsyRuntimeEnvValue(value: string | undefined): boolean { if (value === undefined) { return false; } const normalized = value.trim().toLowerCase(); return ( normalized === "" || normalized === "false" || normalized === "0" || normalized === "off" || normalized === "no" ); } export function isMultiProjectWorkspaceSyncEnabled( env: Record = process.env, ): boolean { // Default ON: an unset value is not false, so the feature is live unless an // operator sets an explicit false value as the kill switch (rollback path). return !isFalsyRuntimeEnvValue(env[MULTI_PROJECT_WORKSPACE_SYNC_ENV]); } /** * True when an environment driver runs the workspace on a non-local target. The `ssh`, `sandbox`, * and `plugin` drivers each realize the workspace off the host, so a host-local directory path is * not present on the target. This mirrors the remote-transport classification in * {@link buildWorkspaceRealizationRecord}. The `local` driver (and an unknown/absent driver) is * treated as local. */ export function isRemoteExecutionEnvironmentDriver( driver: string | null | undefined, ): boolean { return driver === "ssh" || driver === "sandbox" || driver === "plugin"; } /** * Environment flag (kill-switch, default ON) that gates whether a *remote* run stages the * referenced (mentioned) project set into the sandbox. This is a targeted rollback lever: it * disables only the remote referenced-project path and never regresses the working local path. * The master flag {@link MULTI_PROJECT_WORKSPACE_SYNC_ENV} is the blunt switch that kills both * local and remote. The remote path runs when both the master flag and this remote flag are ON — * the default state. An unset value resolves ON; an operator disables it with an explicit false * value (`"false"`, `"0"`, `"off"`, `"no"`, or `""`). The OFF state fails closed: a remote run * runs no referenced-project authorization or staging and reverts to the remote drop path. */ export const MULTI_PROJECT_WORKSPACE_SYNC_REMOTE_ENV = "PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC_REMOTE"; export function isMultiProjectWorkspaceSyncRemoteEnabled( env: Record = process.env, ): boolean { // Default ON: an unset value is not false, so the remote path is live unless an operator sets // an explicit false value as the targeted kill switch (rollback path). return !isFalsyRuntimeEnvValue(env[MULTI_PROJECT_WORKSPACE_SYNC_REMOTE_ENV]); } /** * True when an environment driver stages a multi-source remote workspace through the confined * sandbox/command runtime. Only the `sandbox` driver asserts per-project confinement on the * staging path (`assertSyncOperationsConfined` in `sandbox-managed-runtime`). The `ssh` driver * stages without that guard, and the `plugin` driver does not route through the confined command * runtime in the workspace-realization step, so both keep dropping referenced projects. A `local` * (or unknown) driver is not remote and never reaches this check. This gate is intentionally * narrower than {@link isRemoteExecutionEnvironmentDriver}: it names the one transport that * confines each staged referenced tree. */ export function isConfinedRemoteStagingDriver( driver: string | null | undefined, ): boolean { return driver === "sandbox"; } /** * Upper bound on how many additional (mentioned) projects a single run may materialize * beyond the anchor. Bounds the fan-out of per-project authorization and workspace prep. */ export const MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS = 10; /** * Upper bound on how many *available* (same-company, hydrated) candidate projects a single run will * *authorize* before the admitted-project cap is applied. * * This is a fan-out guard distinct from {@link MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS}: * the admitted cap counts only projects that were successfully authorized, so on its own it * does not bound how many `project:read` decisions a run performs — an adversarial same-company * mention flood in which every candidate is denied would authorize every candidate before the * admitted cap is ever reached. This limit caps the number of authorization decisions regardless of * how many candidates are admitted, so denied mentions cannot force unbounded authorization work. * Only available candidates count against it — unavailable mentions are filtered by the company-scoped * hydration first and never consume an evaluation slot. It is always at least the admitted cap so the * admitted cap remains reachable in the normal (non-flood) case. */ export const MAX_RUN_REFERENCED_CANDIDATE_EVALUATIONS = 50; type RunReferencedProjectRecord = Awaited< ReturnType["listByIds"]> >[number]; export interface RunReferencedProject { projectId: string; project: RunReferencedProjectRecord; } /** * The layer that dropped or failed a referenced project. The run surfacing and the * observability log both use these values as the per-failure reason: * - `authorization`: the run actor is not authorized to read the project. * - `resolution`: the project could not be brought into the run locally (unknown or * unavailable project, cap exceeded, or a workspace clone/prepare failure). * - `staging`: the project resolved but failed to stage into the run sandbox (the * downstream remote path; see `sandbox-managed-runtime`). */ export type ReferencedProjectFailureReason = "authorization" | "resolution" | "staging"; /** One referenced project that a run dropped or failed, with the layer that caused it. */ export interface ReferencedProjectFailure { projectId: string; reason: ReferencedProjectFailureReason; /** * The failure message, when the layer that dropped the project produced one. A `staging` failure * carries the remote extract or sync error here, so a reader of the run log learns why the project * dropped. An `authorization` or `resolution` drop omits this field. */ error?: string; } export interface ResolvedRunReferencedProjects { /** The anchor (primary) project — retains the existing git-worktree run path; never re-authorized here. */ anchor: RunReferencedProject | null; /** Additional read-only referenced projects that each passed per-project `project:read` authorization. */ additional: RunReferencedProject[]; /** Human-readable warnings for every referenced project that was dropped (unavailable, unauthorized, or capped). */ warnings: string[]; /** Structured record of every dropped referenced project, paired with the layer that dropped it. */ failures: ReferencedProjectFailure[]; } export interface ResolveRunReferencedProjectsOptions { companyId: string; /** The run actor; every additional project is authorized against this actor. */ actor: AuthorizationActor; issues: Pick, "findMentionedProjectIds">; projects: Pick, "listByIds">; access: Pick, "decide">; /** Override the additional-project cap (defaults to {@link MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS}). */ maxAdditionalProjects?: number; /** * Override the candidate authorization fan-out cap — the maximum number of *available* candidates * that are authorized (defaults to {@link MAX_RUN_REFERENCED_CANDIDATE_EVALUATIONS}). Always * effectively raised to at least the admitted-project cap so the admitted cap stays reachable. */ maxCandidateEvaluations?: number; } /** * Produce the deduped, company-scoped, per-project-authorized referenced-project set * `[anchor, ...additional]` for a run. * * The anchor keeps its existing issue/run authorization path and is never re-authorized or * inherited by the additional projects. Every additional (mentioned) project must independently * pass a fail-closed `project:read` authorization check against the run actor before it is * admitted — any non-`allowed` decision, company mismatch, missing/unknown project, or thrown * authorization error drops the project and appends a warning (the run always continues). * * Candidate evaluation is bounded twice, independently: at most * {@link ResolveRunReferencedProjectsOptions.maxCandidateEvaluations} *available* candidates are ever * hydrated and authorized (a fan-out guard against an adversarial same-company mention flood of denied * projects), and at most {@link ResolveRunReferencedProjectsOptions.maxAdditionalProjects} of those are * admitted. The evaluation cap bounds hydration as well as authorization: candidates are hydrated and * availability-filtered in mention order in bounded batches, and hydration stops as soon as the * evaluation window is filled with available candidates (or the mention set is exhausted), so hydration * never processes the complete mention set — its cost is bounded by the window, not by mention volume. * Availability filtering still runs before a candidate consumes an evaluation slot, so an unavailable * mention (foreign-company, deleted, or malformed id) never occupies a slot or displaces a later * authorized project. Available candidates beyond the evaluation window are left un-hydrated and dropped * with a warning, never triggering an authorization decision. */ export async function resolveRunReferencedProjects( issueId: string, anchorProjectId: string | null, opts: ResolveRunReferencedProjectsOptions, ): Promise { const { companyId, actor, issues, projects, access } = opts; const warnings: string[] = []; const failures: ReferencedProjectFailure[] = []; const cap = Math.max( 0, opts.maxAdditionalProjects ?? MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS, ); // The evaluation cap bounds candidate hydration + authorization fan-out. It is always at least the // admitted cap so the admitted cap stays reachable in the normal (non-flood) case. const evaluationCap = Math.max( cap, opts.maxCandidateEvaluations ?? MAX_RUN_REFERENCED_CANDIDATE_EVALUATIONS, ); // Company-scoped, deduped, order-preserving mention set (title + description + comment bodies). // Run prep counts mentions in comments, so comment bodies are always included. const mentionedIds = await issues.findMentionedProjectIds(issueId, { includeCommentBodies: true, }); // Anchor wins: it keeps the full git-worktree path and is never re-authorized here, so drop it // from the mention set. Preserve mention order while deduping the remaining candidates. const allCandidateIds: string[] = []; const seen = new Set(anchorProjectId ? [anchorProjectId] : []); for (const projectId of mentionedIds) { if (seen.has(projectId)) continue; seen.add(projectId); allCandidateIds.push(projectId); } // Hydrate + availability-filter candidates in mention order, but never process more of the mention // set than the evaluation window needs. Candidates are pulled in bounded batches sized to what the // window still needs, and hydration stops as soon as `evaluationCap` *available* candidates are // collected (or the mention set is exhausted). This bounds hydration by the evaluation window rather // than by mention volume: an adversarial same-company mention flood can neither force an unbounded // hydration query nor displace a later authorized project out of the window. `listByIds` filters by // company, so each batch both fetches the records and performs availability filtering — a mention that // did not resolve inside this company (foreign-company, deleted, or malformed id) is dropped here with // a warning and never occupies an evaluation slot. The anchor is co-hydrated with the first batch (it // was excluded from `allCandidateIds` above, so it never double-counts) and is never re-authorized. const availableCandidates: RunReferencedProject[] = []; let hydrationCursor = 0; let anchorRecord: RunReferencedProjectRecord | null = null; let anchorHydrated = false; while ( availableCandidates.length < evaluationCap && hydrationCursor < allCandidateIds.length ) { const need = evaluationCap - availableCandidates.length; const batchCandidateIds = allCandidateIds.slice( hydrationCursor, hydrationCursor + need, ); hydrationCursor += batchCandidateIds.length; const hydrateIds = !anchorHydrated && anchorProjectId ? [anchorProjectId, ...batchCandidateIds] : batchCandidateIds; const hydrated = await projects.listByIds(companyId, hydrateIds); const byId = new Map(hydrated.map((project) => [project.id, project])); if (!anchorHydrated && anchorProjectId) { anchorRecord = byId.get(anchorProjectId) ?? null; anchorHydrated = true; } for (const projectId of batchCandidateIds) { const project = byId.get(projectId); if (!project) { warnings.push( `Referenced project ${projectId} was skipped because it is not available in this company.`, ); failures.push({ projectId, reason: "resolution" }); continue; } availableCandidates.push({ projectId, project }); } } // Hydrate the anchor on its own if the candidate loop never ran (no mentions to co-hydrate it with). if (!anchorHydrated && anchorProjectId) { const hydrated = await projects.listByIds(companyId, [anchorProjectId]); anchorRecord = hydrated.find((project) => project.id === anchorProjectId) ?? null; anchorHydrated = true; } const anchor: RunReferencedProject | null = anchorRecord && anchorProjectId ? { projectId: anchorProjectId, project: anchorRecord } : null; // The loop already bounds `availableCandidates` to at most `evaluationCap` entries. Any mentions left // un-hydrated past the window (the fan-out cap dropped them before hydration/authorization) are // surfaced as a warning after the admit loop below. Denied candidates still consume this window (each // costs exactly one authorization decision, which is what the cap bounds); unavailable mentions, // filtered above, do not. const candidates = availableCandidates; const unevaluatedCandidateCount = allCandidateIds.length - hydrationCursor; // Admit candidates in mention order until the cap of successfully-authorized projects is reached. // The cap bounds how many additional projects a run *materializes*, so it is counted against // admitted projects only; denied mentions never use a slot. const additional: RunReferencedProject[] = []; let capReachedAtIndex: number | null = null; for (let index = 0; index < candidates.length; index++) { if (additional.length >= cap) { capReachedAtIndex = index; break; } const { projectId, project } = candidates[index]!; let allowed = false; try { const decision = await access.decide({ actor, action: "project:read", resource: { type: "project", companyId, projectId }, scope: { projectId }, }); allowed = decision.allowed === true; } catch { // Fail-closed: an authorization error never admits a project. allowed = false; } if (!allowed) { warnings.push( `Referenced project ${projectId} was skipped because it is not authorized for this run.`, ); failures.push({ projectId, reason: "authorization" }); continue; } additional.push({ projectId, project }); } // Warn once if the admitted cap stopped us before every available candidate was considered. The // skipped count includes both the still-unconsidered evaluated candidates and any available // candidates that were dropped before evaluation by the fan-out cap above. if (capReachedAtIndex !== null) { const skipped = candidates.length - capReachedAtIndex + unevaluatedCandidateCount; warnings.push( `Only the first ${cap} referenced project(s) will be synced for this run; ${skipped} additional referenced project(s) were skipped.`, ); } else if (unevaluatedCandidateCount > 0) { // The admitted cap was never reached (e.g. a flood of denied mentions), but the evaluation // fan-out cap dropped available candidates before they could be authorized. warnings.push( `Only the first ${evaluationCap} referenced mention(s) were evaluated for this run; ${unevaluatedCandidateCount} additional referenced mention(s) were skipped without evaluation.`, ); } // Record every capped or unevaluated candidate as a per-project resolution failure so the run // surfacing and the observability log can reconcile requested against synced. The evaluated // candidates past the admitted cap carry a known projectId; the candidates the fan-out cap // dropped before hydration carry their id from the ordered mention set. if (capReachedAtIndex !== null) { for (let index = capReachedAtIndex; index < candidates.length; index++) { failures.push({ projectId: candidates[index]!.projectId, reason: "resolution", }); } } for (const projectId of allCandidateIds.slice(hydrationCursor)) { failures.push({ projectId, reason: "resolution" }); } return { anchor, additional, warnings, failures }; } export interface ResolveAdditionalRunWorkspacesOptions { /** Gate that mirrors {@link isMultiProjectWorkspaceSyncEnabled}. When false, the result is empty. */ enabled: boolean; companyId: string; /** The run actor; every additional project is authorized against this actor. */ actor: AuthorizationActor; issues: Pick, "findMentionedProjectIds">; projects: Pick, "listByIds">; access: Pick, "decide">; /** Resolve one authorized referenced project to its own workspace cwd (injectable for tests). */ resolveProjectWorkspace: ( project: RunReferencedProject, ) => Promise; maxAdditionalProjects?: number; maxCandidateEvaluations?: number; /** * True when the run executes on a non-local target (ssh, sandbox, or plugin). A referenced * project realizes as a local directory first. On a remote target that local tree reaches the * agent only when a confined transport stages it into the sandbox and the remote flag is on * (see `targetStagesConfined` and `remoteReferencedSyncEnabled`). Otherwise the run drops the * whole referenced set and records it at the staging layer. */ executionTargetIsRemote?: boolean; /** * True when the remote target stages each referenced tree through the confined sandbox/command * runtime (the `sandbox` driver; see {@link isConfinedRemoteStagingDriver}). The gate opens the * referenced-project path on a remote target only when this is true. The SSH transport and any * unconfined transport keep dropping referenced projects. Ignored on a local target. */ targetStagesConfined?: boolean; /** * The remote-only kill switch (default ON; see {@link isMultiProjectWorkspaceSyncRemoteEnabled}). * When true, a confined remote target stages the referenced set. When false, a remote target * fails closed: it runs no referenced-project authorization or staging and reverts to the remote * drop path. Ignored on a local target. */ remoteReferencedSyncEnabled?: boolean; } /** * Resolve the read-only referenced (mentioned) project workspaces for a run. * * The function is inert until the multi-project workspace-sync flag is on: when `enabled` is * false (the production default) or there is no issue, it returns an empty result and performs * no authorization or workspace work. When enabled, it authorizes the referenced set through * {@link resolveRunReferencedProjects} and resolves each admitted project to its own cwd. Each * project resolves in isolation: a per-project failure drops only that project and appends a * warning, so one bad clone never aborts the run. */ export async function resolveAdditionalRunWorkspaces( issueId: string | null, anchorProjectId: string | null, opts: ResolveAdditionalRunWorkspacesOptions, ): Promise<{ additionalWorkspaces: ResolvedAdditionalWorkspace[]; warnings: string[]; failures: ReferencedProjectFailure[]; }> { if (!opts.enabled || !issueId) { return { additionalWorkspaces: [], warnings: [], failures: [] }; } // A referenced project realizes as a local directory first. On a remote target the run carries // that tree to the agent only when a confined transport stages it into the sandbox and the // remote flag is on. The confined sandbox transport asserts per-project confinement on each // staged tree (`assertSyncOperationsConfined` in `sandbox-managed-runtime`). The SSH transport // does not, so it stays out of scope. When the remote flag is off the run fails closed. In every // one of those drop cases the run neither does authorization or clone work it must discard nor // exposes an inaccessible referenced path to the agent. if (opts.executionTargetIsRemote) { const remoteReferencedSyncOpen = (opts.remoteReferencedSyncEnabled ?? false) && (opts.targetStagesConfined ?? false); if (!remoteReferencedSyncOpen) { const mentionedIds = await opts.issues.findMentionedProjectIds(issueId, { includeCommentBodies: true, }); // Each distinct non-anchor mention is a referenced project this remote run drops. An SSH // target (or the remote flag off) has no confined path to receive the referenced tree, so // the run drops the whole set at the staging layer. Record one failure per dropped project // so the requested-vs-synced accounting counts the whole referenced set and the run still // emits its structured sync log. Warn only when the issue actually mentions a project, so a // remote run without any referenced mention stays silent. const droppedProjectIds = [ ...new Set( mentionedIds.filter((projectId) => projectId !== anchorProjectId), ), ]; return { additionalWorkspaces: [], warnings: droppedProjectIds.length > 0 ? [ "Referenced-project workspaces are available only on a local execution target or a confined sandbox target. This run uses a different remote execution target, so no referenced-project workspace was attached.", ] : [], failures: droppedProjectIds.map((projectId) => ({ projectId, reason: "staging" as const, })), }; } // Fall through: a confined sandbox target with the remote flag on resolves and authorizes the // referenced set exactly like a local target. The resolver is driver-agnostic; the confined // sandbox transport downstream stages each admitted tree into its own `project-` // directory. The per-project `project:read` check below still runs against the run actor. } const referenced = await resolveRunReferencedProjects( issueId, anchorProjectId, { companyId: opts.companyId, actor: opts.actor, issues: opts.issues, projects: opts.projects, access: opts.access, maxAdditionalProjects: opts.maxAdditionalProjects, maxCandidateEvaluations: opts.maxCandidateEvaluations, }, ); const additionalWorkspaces: ResolvedAdditionalWorkspace[] = []; const warnings = [...referenced.warnings]; const failures = [...referenced.failures]; for (const project of referenced.additional) { try { additionalWorkspaces.push(await opts.resolveProjectWorkspace(project)); } catch (error) { const reason = error instanceof Error ? error.message : String(error); warnings.push( `Referenced project ${project.projectId} was skipped because its workspace could not be prepared: ${reason}`, ); failures.push({ projectId: project.projectId, reason: "resolution" }); } } return { additionalWorkspaces, warnings, failures }; } /** Structured fields for the one requested-vs-synced observability log a run emits at run prep. */ export interface ReferencedProjectRunObservability { referenced_projects_requested: number; referenced_projects_synced: number; referenced_project_failures: Array<{ project_id: string; reason: ReferencedProjectFailureReason; /** The failure message for a `staging` drop; absent for an `authorization` or `resolution` drop. */ error?: string; }>; } /** * Build the requested-vs-synced observability fields for a run's referenced-project set. * * A run requests one referenced project per authorized mention and syncs the projects that resolve. * The requested count is the synced count plus every dropped project, so the two counts and the * per-failure reasons together account for the whole referenced set. The human-readable warning for * each drop rides the run's surfaced warnings channel; this function produces only the structured * log fields, so a run emits exactly one line with a stable field shape. */ export function buildReferencedProjectRunObservability(input: { syncedProjectIds: readonly string[]; failures: readonly ReferencedProjectFailure[]; }): ReferencedProjectRunObservability { return { referenced_projects_requested: input.syncedProjectIds.length + input.failures.length, referenced_projects_synced: input.syncedProjectIds.length, referenced_project_failures: input.failures.map((failure) => ({ project_id: failure.projectId, reason: failure.reason, // Carry the error only when the layer produced one, so an authorization or resolution drop // stays a two-field entry and a staging drop names its reason. ...(failure.error !== undefined ? { error: failure.error } : {}), })), }; } export function resolveRuntimeSessionParamsForWorkspace(input: { agentId: string; previousSessionParams: Record | null; resolvedWorkspace: ResolvedWorkspaceForRun; }) { const { agentId, previousSessionParams, resolvedWorkspace } = input; const previousSessionId = readNonEmptyString( previousSessionParams?.sessionId, ); const previousCwd = readNonEmptyString(previousSessionParams?.cwd); if (!previousSessionId || !previousCwd) { return { sessionParams: previousSessionParams, warning: null as string | null, }; } if (resolvedWorkspace.source !== "project_primary") { return { sessionParams: previousSessionParams, warning: null as string | null, }; } const projectCwd = readNonEmptyString(resolvedWorkspace.cwd); if (!projectCwd) { return { sessionParams: previousSessionParams, warning: null as string | null, }; } const fallbackAgentHomeCwd = resolveDefaultAgentWorkspaceDir(agentId); if (path.resolve(previousCwd) !== path.resolve(fallbackAgentHomeCwd)) { return { sessionParams: previousSessionParams, warning: null as string | null, }; } if (path.resolve(projectCwd) === path.resolve(previousCwd)) { return { sessionParams: previousSessionParams, warning: null as string | null, }; } const previousWorkspaceId = readNonEmptyString( previousSessionParams?.workspaceId, ); if ( previousWorkspaceId && resolvedWorkspace.workspaceId && previousWorkspaceId !== resolvedWorkspace.workspaceId ) { return { sessionParams: previousSessionParams, warning: null as string | null, }; } const migratedSessionParams: Record = { ...(previousSessionParams ?? {}), cwd: projectCwd, }; if (resolvedWorkspace.workspaceId) migratedSessionParams.workspaceId = resolvedWorkspace.workspaceId; if (resolvedWorkspace.repoUrl) migratedSessionParams.repoUrl = resolvedWorkspace.repoUrl; if (resolvedWorkspace.repoRef) migratedSessionParams.repoRef = resolvedWorkspace.repoRef; return { sessionParams: migratedSessionParams, warning: `Project workspace "${projectCwd}" is now available. ` + `Attempting to resume session "${previousSessionId}" that was previously saved in fallback workspace "${previousCwd}".`, }; } const SESSION_AI_CREDENTIAL_IDENTITY_KEY = "paperclipAiCredentialIdentity"; const SESSION_CONFIGURED_MODEL_KEY = "__paperclipConfiguredModel"; const SESSION_CONFIG_FINGERPRINT_KEY = "__paperclipConfigFingerprint"; const SESSION_CONFIG_FINGERPRINT_VERSION_KEY = "__paperclipConfigFingerprintVersion"; const SESSION_CONFIG_CATEGORIES_KEY = "__paperclipConfigCategories"; const SESSION_CONFIG_CATEGORY_FINGERPRINTS_KEY = "__paperclipConfigCategoryFingerprints"; const PAPERCLIP_SESSION_METADATA_KEYS = new Set([ SESSION_AI_CREDENTIAL_IDENTITY_KEY, SESSION_CONFIGURED_MODEL_KEY, SESSION_CONFIG_FINGERPRINT_KEY, SESSION_CONFIG_FINGERPRINT_VERSION_KEY, SESSION_CONFIG_CATEGORIES_KEY, SESSION_CONFIG_CATEGORY_FINGERPRINTS_KEY, ]); const WORKSPACE_CONFIG_FINGERPRINT_METADATA_KEY = "configFingerprint"; const EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES = [ "adapter", "adapterConfig", "agentRuntimeConfig", "instructions", "issueOverrides", "workspaceConfig", "environment", "envBindings", "secrets", "runtimeSkills", ] as const; const EFFECTIVE_RUN_WORKSPACE_CONFIG_CATEGORIES = [ "mode", "projectWorkspace", "strategy", "repo", "lifecycleCommands", "runtimeServices", "environment", "realization", ] as const; type EffectiveRunSessionConfigCategory = (typeof EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES)[number]; type EffectiveRunWorkspaceConfigCategory = (typeof EFFECTIVE_RUN_WORKSPACE_CONFIG_CATEGORIES)[number]; export type EffectiveRunSessionConfigMetadata = { aiCredentialIdentity?: string; version: typeof EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION; fingerprint: string; categories: EffectiveRunSessionConfigCategory[]; categoryFingerprints: Record; fingerprints: EffectiveRunConfigFingerprints; }; type TaskSessionConfigFreshnessDecision = { reset: boolean; reasons: string[]; changedCategories: EffectiveRunSessionConfigCategory[]; storedFingerprint: string | null; nextFingerprint: string | null; }; export type EffectiveRunWorkspaceConfigMetadata = { version: typeof EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION; fingerprint: string; categories: EffectiveRunWorkspaceConfigCategory[]; categoryFingerprints: Record; fingerprints: EffectiveRunConfigFingerprints; evaluatedAt: string; }; type WorkspaceConfigFreshnessDecisionAction = "create" | "reuse" | "refresh" | "replace"; type ExecutionWorkspaceConfigFreshnessDecision = { action: WorkspaceConfigFreshnessDecisionAction; shouldReuseExisting: boolean; shouldRefreshConfigSnapshot: boolean; reasons: string[]; changedCategories: EffectiveRunWorkspaceConfigCategory[]; storedFingerprint: string | null; inferredFingerprint: string | null; nextFingerprint: string | null; storedFingerprintPresent: boolean; }; type WorkspaceConfigFreshnessOperationInput = { decision: ExecutionWorkspaceConfigFreshnessDecision; hasExistingWorkspace: boolean; reuseRequested: boolean; workspaceReused: boolean; configSnapshotRefreshed: boolean; previousWorkspaceId: string | null; activeWorkspaceId: string | null; }; type ExecutionWorkspaceReuseProvisioningPolicy = { shouldRestoreExistingWorkspace: boolean; shouldRefreshWorkspaceConfigSnapshot: boolean; shouldPersistLatestWorkspaceConfigMetadata: boolean; }; type WorkspaceReuseIssueRef = | { id?: string | null; identifier?: string | null; } | null | undefined; export type ExecutionWorkspaceReuseRequestForIssue = { requestedExecutionWorkspaceId: string | null; requestedShouldReuseExisting: boolean; existingExecutionWorkspaceAvailable: boolean; }; /** * Projectless native runs bind their immutable envelope to the run id even * though no project-scoped execution-workspace row exists. Treat that value * as a reuse request only when it resolves to a persisted workspace row. */ export function resolveNativeRecoveryExecutionWorkspaceBinding(input: { bindingId: string | null | undefined; persistedWorkspaceFound: boolean; }): string | null { const bindingId = readNonEmptyString(input.bindingId); return bindingId && input.persistedWorkspaceFound ? bindingId : null; } export function resolveExecutionWorkspaceReuseRequestForIssue(input: { issueExecutionWorkspaceId?: string | null; issueExecutionWorkspacePreference?: string | null; existingExecutionWorkspaceStatus?: string | null; requestedExistingBranch?: string | null; existingExecutionWorkspaceBranchName?: string | null; }): ExecutionWorkspaceReuseRequestForIssue { const requestedExecutionWorkspaceId = readNonEmptyString( input.issueExecutionWorkspaceId, ); // An explicitly pinned existing branch outranks an inherited reuse_existing // binding: a persisted workspace on any other branch (or with no recorded // branch) is stale for this issue, so dispatch realizes the pinned branch. const requestedExistingBranch = readNonEmptyString( input.requestedExistingBranch, ); const existingWorkspaceMatchesRequestedBranch = requestedExistingBranch === null || readNonEmptyString(input.existingExecutionWorkspaceBranchName) === requestedExistingBranch; const requestedShouldReuseExisting = input.issueExecutionWorkspacePreference === "reuse_existing" && requestedExecutionWorkspaceId !== null && existingWorkspaceMatchesRequestedBranch; return { requestedExecutionWorkspaceId, requestedShouldReuseExisting, existingExecutionWorkspaceAvailable: requestedShouldReuseExisting && input.existingExecutionWorkspaceStatus !== null && input.existingExecutionWorkspaceStatus !== undefined && input.existingExecutionWorkspaceStatus !== "archived", }; } export function resolveExecutionWorkspaceReuseProvisioningPolicy(input: { requestedShouldReuseExisting: boolean; workspaceConfigFreshness: ExecutionWorkspaceConfigFreshnessDecision; }): ExecutionWorkspaceReuseProvisioningPolicy { const shouldRestoreExistingWorkspace = input.requestedShouldReuseExisting; const replacementClassDrift = input.requestedShouldReuseExisting && input.workspaceConfigFreshness.action === "replace"; return { shouldRestoreExistingWorkspace, shouldRefreshWorkspaceConfigSnapshot: shouldRestoreExistingWorkspace && !replacementClassDrift && input.workspaceConfigFreshness.shouldRefreshConfigSnapshot, shouldPersistLatestWorkspaceConfigMetadata: !replacementClassDrift, }; } function formatInheritedExecutionWorkspaceReuseFailure(input: { reason: | "inherited_workspace_reuse_failed" | "inherited_workspace_reuse_unavailable"; issueRef: WorkspaceReuseIssueRef; runId: string; executionWorkspaceId: string | null | undefined; workspaceConfigFreshness: ExecutionWorkspaceConfigFreshnessDecision; cause?: unknown; }) { const issueLabel = input.issueRef?.identifier ?? input.issueRef?.id ?? input.runId; const workspaceLabel = input.executionWorkspaceId ?? "unknown workspace"; const causeMessage = input.cause instanceof Error ? input.cause.message : input.cause != null ? String(input.cause) : null; const remediation = input.reason === "inherited_workspace_reuse_failed" ? "Inspect the referenced execution workspace restore/provision logs, repair or unarchive the workspace, or intentionally clear the issue's reuse_existing workspace binding before retrying." : "Repair or unarchive the referenced execution workspace, or intentionally clear the issue's reuse_existing workspace binding before retrying."; const message = causeMessage ? `Issue ${issueLabel} requested inherited execution workspace reuse for ${workspaceLabel}, but the workspace could not be restored because ${causeMessage}.` : `Issue ${issueLabel} requested inherited execution workspace reuse for ${workspaceLabel}, but the workspace could not be restored.`; return `${message} ${remediation}`; } export async function provisionExecutionWorkspaceForFreshnessDecision< T extends { warnings?: string[] }, >(input: { requestedShouldReuseExisting: boolean; existingExecutionWorkspaceId?: string | null; issueRef: WorkspaceReuseIssueRef; runId: string; workspaceConfigFreshness: ExecutionWorkspaceConfigFreshnessDecision; restoreExistingWorkspace?: (() => Promise) | null; realizeWorkspace: () => Promise; }): Promise<{ executionWorkspace: T; reusedExecutionWorkspace: T | null; policy: ExecutionWorkspaceReuseProvisioningPolicy; }> { const policy = resolveExecutionWorkspaceReuseProvisioningPolicy({ requestedShouldReuseExisting: input.requestedShouldReuseExisting, workspaceConfigFreshness: input.workspaceConfigFreshness, }); if (!policy.shouldRestoreExistingWorkspace) { const executionWorkspace = await input.realizeWorkspace(); return { executionWorkspace, reusedExecutionWorkspace: null, policy, }; } let restored: T | null = null; let reuseFailure: string | null = null; try { restored = (await input.restoreExistingWorkspace?.()) ?? null; } catch (error) { if (isWorkspaceValidationFailure(error)) { throw error; } reuseFailure = formatInheritedExecutionWorkspaceReuseFailure({ reason: "inherited_workspace_reuse_failed", issueRef: input.issueRef, runId: input.runId, executionWorkspaceId: input.existingExecutionWorkspaceId, workspaceConfigFreshness: input.workspaceConfigFreshness, cause: error, }); } if (!restored) { reuseFailure = reuseFailure ?? formatInheritedExecutionWorkspaceReuseFailure({ reason: "inherited_workspace_reuse_unavailable", issueRef: input.issueRef, runId: input.runId, executionWorkspaceId: input.existingExecutionWorkspaceId, workspaceConfigFreshness: input.workspaceConfigFreshness, }); } if (reuseFailure) throw new Error(reuseFailure); if (!restored) { throw new Error( "Expected restored execution workspace after reuse fallback handling", ); } return { executionWorkspace: restored, reusedExecutionWorkspace: restored, policy, }; } const EFFECTIVE_RUN_SESSION_CONFIG_CATEGORY_LABELS: Record< EffectiveRunSessionConfigCategory, string > = { adapter: "adapter", adapterConfig: "adapter config", agentRuntimeConfig: "agent runtime config", instructions: "instructions", issueOverrides: "issue overrides", workspaceConfig: "workspace config", environment: "environment", envBindings: "env bindings", secrets: "secrets", runtimeSkills: "runtime skills", }; const EFFECTIVE_RUN_WORKSPACE_CONFIG_CATEGORY_LABELS: Record< EffectiveRunWorkspaceConfigCategory, string > = { mode: "workspace mode", projectWorkspace: "project workspace", strategy: "workspace strategy", repo: "repo/base ref", lifecycleCommands: "workspace lifecycle commands", runtimeServices: "runtime services", environment: "environment", realization: "workspace realization", }; const WORKSPACE_REPLACEMENT_CONFIG_CATEGORIES = new Set([ "mode", "projectWorkspace", "strategy", "repo", "environment", "realization", ]); function parseStoredConfigCategoryFingerprints(value: unknown) { const parsed = parseObject(value); const out: Partial> = {}; for (const category of EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES) { const fingerprint = readNonEmptyString(parsed[category]); if (fingerprint) out[category] = fingerprint; } return out; } function readConfigCategoriesFromSessionParams( sessionParams: Record | null | undefined, ) { const rawCategories = Array.isArray( sessionParams?.[SESSION_CONFIG_CATEGORIES_KEY], ) ? sessionParams?.[SESSION_CONFIG_CATEGORIES_KEY] : []; return rawCategories.filter( (category): category is EffectiveRunSessionConfigCategory => typeof category === "string" && (EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES as readonly string[]).includes( category, ), ); } export function readConfigFingerprintFromSessionParams( sessionParams: Record | null | undefined, ) { if (!sessionParams) return null; const fingerprint = readNonEmptyString( sessionParams[SESSION_CONFIG_FINGERPRINT_KEY], ); const version = asNumber( sessionParams[SESSION_CONFIG_FINGERPRINT_VERSION_KEY], 0, ); if (!fingerprint || version <= 0) return null; return { fingerprint, version, categories: readConfigCategoriesFromSessionParams(sessionParams), categoryFingerprints: parseStoredConfigCategoryFingerprints( sessionParams[SESSION_CONFIG_CATEGORY_FINGERPRINTS_KEY], ), }; } function describeEffectiveRunConfigCategories( categories: readonly EffectiveRunSessionConfigCategory[], ) { return categories .map((category) => EFFECTIVE_RUN_SESSION_CONFIG_CATEGORY_LABELS[category]) .join(", "); } function changedEffectiveRunSessionConfigCategories(input: { previous: Partial>; next: Record; }) { const changed = EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES.filter( (category) => input.previous[category] !== input.next[category], ); return changed.length > 0 ? changed : [...EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES]; } function parseStoredWorkspaceConfigCategoryFingerprints(value: unknown) { const parsed = parseObject(value); const out: Partial> = {}; for (const category of EFFECTIVE_RUN_WORKSPACE_CONFIG_CATEGORIES) { const fingerprint = readNonEmptyString(parsed[category]); if (fingerprint) out[category] = fingerprint; } return out; } function readWorkspaceConfigCategoriesFromMetadata(value: unknown) { const rawCategories = Array.isArray(value) ? value : []; return rawCategories.filter( (category): category is EffectiveRunWorkspaceConfigCategory => typeof category === "string" && (EFFECTIVE_RUN_WORKSPACE_CONFIG_CATEGORIES as readonly string[]).includes( category, ), ); } function readWorkspaceConfigFingerprintFromMetadata( metadata: Record | null | undefined, ) { const raw = parseObject( metadata?.[WORKSPACE_CONFIG_FINGERPRINT_METADATA_KEY], ); const fingerprint = readNonEmptyString(raw.workspaceHash) ?? readNonEmptyString(raw.fingerprint); const version = asNumber(raw.version, 0); if (!fingerprint || version <= 0) return null; return { fingerprint, version, categories: readWorkspaceConfigCategoriesFromMetadata(raw.categories), categoryFingerprints: parseStoredWorkspaceConfigCategoryFingerprints( raw.categoryFingerprints, ), }; } function describeEffectiveRunWorkspaceConfigCategories( categories: readonly EffectiveRunWorkspaceConfigCategory[], ) { return categories .map((category) => EFFECTIVE_RUN_WORKSPACE_CONFIG_CATEGORY_LABELS[category]) .join(", "); } function changedEffectiveRunWorkspaceConfigCategories(input: { previous: Partial>; next: Record; }) { const changed = EFFECTIVE_RUN_WORKSPACE_CONFIG_CATEGORIES.filter( (category) => input.previous[category] !== input.next[category], ); return changed.length > 0 ? changed : [...EFFECTIVE_RUN_WORKSPACE_CONFIG_CATEGORIES]; } function workspaceConfigFreshnessActionLabel( action: WorkspaceConfigFreshnessDecisionAction, ) { switch (action) { case "refresh": return "refreshed execution workspace config"; case "replace": return "replaced execution workspace"; case "reuse": return "updated execution workspace freshness metadata"; case "create": return "created execution workspace"; } } export function buildWorkspaceConfigFreshnessOperation( input: WorkspaceConfigFreshnessOperationInput, ) { if ( !input.reuseRequested || !input.hasExistingWorkspace || input.decision.reasons.length === 0 ) { return null; } const changedCategoryLabels = input.decision.changedCategories.map( (category) => EFFECTIVE_RUN_WORKSPACE_CONFIG_CATEGORY_LABELS[category], ); const categorySummary = changedCategoryLabels.length > 0 ? ` (${changedCategoryLabels.join(", ")})` : ""; const reasonSummary = input.decision.reasons.join("; "); return { metadata: { kind: "config_freshness", action: input.decision.action, changedCategories: input.decision.changedCategories, changedCategoryLabels, reasons: input.decision.reasons, reuseRequested: input.reuseRequested, workspaceReused: input.workspaceReused, configSnapshotRefreshed: input.configSnapshotRefreshed, storedFingerprintPresent: input.decision.storedFingerprintPresent, previousWorkspaceId: input.previousWorkspaceId, activeWorkspaceId: input.activeWorkspaceId, }, system: `[paperclip] ${workspaceConfigFreshnessActionLabel(input.decision.action)} after config freshness check${categorySummary}: ${reasonSummary}\n`, }; } export async function recordWorkspaceConfigFreshnessOperation( input: WorkspaceConfigFreshnessOperationInput & { recorder: WorkspaceOperationRecorder; runId: string; }, ) { const operation = buildWorkspaceConfigFreshnessOperation(input); if (!operation) return; try { await input.recorder.recordOperation({ phase: "workspace_config_freshness", metadata: operation.metadata, run: async () => ({ status: "succeeded", system: operation.system, }), }); } catch (error) { logger.warn( { err: error instanceof Error ? error.message : String(error), runId: input.runId, previousWorkspaceId: input.previousWorkspaceId, activeWorkspaceId: input.activeWorkspaceId, action: input.decision.action, }, "failed to record workspace config freshness operation", ); } } function sanitizeSecretManifestForConfigFingerprint( manifest: readonly EffectiveRunConfigSecretManifestEntry[], ) { return manifest.map((entry) => { const record = entry as Record; return { configPath: readNonEmptyString(record.configPath) ?? "", envKey: readNonEmptyString(record.envKey), secretId: readNonEmptyString(record.secretId) ?? "", bindingId: readNonEmptyString(record.bindingId), version: typeof record.version === "number" && Number.isFinite(record.version) ? record.version : readNonEmptyString(record.version), provider: readNonEmptyString(record.provider), providerVersionRef: readNonEmptyString(record.providerVersionRef), outcome: record.outcome === "success" || record.outcome === "failure" ? record.outcome : null, }; }); } async function hashFileContentsForConfigFingerprint(filePath: string) { const contents = await fs.readFile(filePath); return `sha256:${createHash("sha256").update(contents).digest("hex")}`; } function isPathInsideRoot(input: { rootPath: string; filePath: string }) { const relative = path.relative(input.rootPath, input.filePath); return ( relative === "" || (relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative)) ); } function resolveRootBoundInstructionsFingerprintPath(input: { instructionsFilePath: string | null; instructionsRootPath: string | null; instructionsEntryFile: string | null; }): | { filePath: string; skippedReason: null } | { filePath: null; skippedReason: string | null } { if ( !input.instructionsRootPath || !path.isAbsolute(input.instructionsRootPath) ) { return { filePath: null, skippedReason: input.instructionsFilePath ? "missing_absolute_root" : null, }; } const rootPath = path.resolve(input.instructionsRootPath); const candidatePath = input.instructionsEntryFile ?? input.instructionsFilePath; if (!candidatePath) return { filePath: null, skippedReason: "missing_entry_file" }; const resolvedPath = path.isAbsolute(candidatePath) ? path.resolve(candidatePath) : path.resolve(rootPath, candidatePath); if (!isPathInsideRoot({ rootPath, filePath: resolvedPath })) { return { filePath: null, skippedReason: "outside_root" }; } return { filePath: resolvedPath, skippedReason: null }; } async function resolveInstructionsConfigFingerprintMetadata( config: Record, ) { const instructionsFilePath = readNonEmptyString(config.instructionsFilePath); const instructionsRootPath = readNonEmptyString(config.instructionsRootPath); const instructionsEntryFile = readNonEmptyString( config.instructionsEntryFile, ); const resolved = resolveRootBoundInstructionsFingerprintPath({ instructionsFilePath, instructionsRootPath, instructionsEntryFile, }); const configuredPath = resolved.filePath ?? instructionsFilePath ?? (instructionsRootPath && instructionsEntryFile ? path.resolve(instructionsRootPath, instructionsEntryFile) : null); if (!configuredPath && !instructionsRootPath && !instructionsEntryFile) return null; const metadata: Record = { configured: true, bundleMode: readNonEmptyString(config.instructionsBundleMode), entryFile: instructionsEntryFile, pathKind: configuredPath ? path.isAbsolute(configuredPath) ? "absolute" : "relative" : null, readPolicy: "root_bound", }; if (resolved.skippedReason) metadata.readSkippedReason = resolved.skippedReason; if (resolved.filePath) { try { metadata.contentHash = await hashFileContentsForConfigFingerprint( resolved.filePath, ); metadata.readable = true; } catch { metadata.readable = false; } } return metadata; } function buildSessionConfigCategoryValues(input: { adapterType: string; effectiveAdapterConfig: Record; agentRuntimeConfig: unknown; instructions: unknown; issueOverrides: unknown; workspaceConfig: unknown; environment: unknown; environmentEnv: unknown; projectEnv: unknown; routineEnv: unknown; secretManifest: readonly EffectiveRunConfigSecretManifestEntry[]; runtimeSkills: unknown; agentConfigRevision: unknown; agentIdentityKeyId?: string; }) { const sanitizedSecretManifest = sanitizeSecretManifestForConfigFingerprint( input.secretManifest, ); const workspaceConfig = { ...parseObject(input.workspaceConfig) }; // issues.updatedAt also advances for comments and status changes. Those are // wake deltas, not execution-workspace configuration changes, so including // the timestamp here makes every comment invalidate an otherwise reusable // task session. delete workspaceConfig.issueConfigRevisionAt; delete workspaceConfig.projectConfigRevisionAt; // This row is runtime state, not requested configuration. It is absent // before the first reusable run is realized and present on the next turn; // fingerprinting that transition would rotate the native session exactly // when the warm runner first becomes reusable. The requested/effective mode, // project policy, and issue settings remain the configuration compatibility // boundary; the reusable row and its evolving generation are state. delete workspaceConfig.existingExecutionWorkspace; delete workspaceConfig.reusableExecutionWorkspaceConfig; const issueSettings = { ...parseObject(workspaceConfig.issueSettings) }; if (issueSettings.mode === workspaceConfig.requestedMode) delete issueSettings.mode; workspaceConfig.issueSettings = Object.keys(issueSettings).length ? issueSettings : null; return { adapter: { adapterType: input.adapterType, ...(input.agentIdentityKeyId ? { agentIdentityKeyId: input.agentIdentityKeyId } : {}), agentConfigRevision: input.agentConfigRevision, }, adapterConfig: input.effectiveAdapterConfig, agentRuntimeConfig: input.agentRuntimeConfig, instructions: input.instructions, issueOverrides: input.issueOverrides, workspaceConfig, environment: input.environment, envBindings: { environment: { env: input.environmentEnv }, project: { env: input.projectEnv }, routine: { env: input.routineEnv }, }, secrets: sanitizedSecretManifest, runtimeSkills: input.runtimeSkills, } satisfies Record; } export async function buildEffectiveRunSessionConfigMetadata(input: { adapterType: string; effectiveAdapterConfig: Record; managedAiHome?: string; agentRuntimeConfig: unknown; issueOverrides: unknown; workspaceConfig: unknown; environment: unknown; environmentEnv: unknown; projectEnv: unknown; routineEnv: unknown; secretManifest?: readonly EffectiveRunConfigSecretManifestEntry[]; runtimeSkills: unknown; agentConfigRevision?: unknown; agentIdentityKeyId?: string; }): Promise { const secretManifest = input.secretManifest ?? []; const instructions = await resolveInstructionsConfigFingerprintMetadata( input.effectiveAdapterConfig, ); const categoryValues = buildSessionConfigCategoryValues({ adapterType: input.adapterType, effectiveAdapterConfig: managedAiSessionFingerprintConfig(input.effectiveAdapterConfig, input.managedAiHome), agentRuntimeConfig: input.agentRuntimeConfig, instructions, issueOverrides: input.issueOverrides, workspaceConfig: input.workspaceConfig, environment: input.environment, environmentEnv: input.environmentEnv, projectEnv: input.projectEnv, routineEnv: input.routineEnv, secretManifest, runtimeSkills: input.runtimeSkills, agentConfigRevision: input.agentConfigRevision ?? null, agentIdentityKeyId: input.agentIdentityKeyId, }); const fingerprints = createEffectiveRunConfigFingerprints({ session: categoryValues, secretManifest, }); const categoryFingerprints = createEffectiveRunConfigSubcategoryFingerprints({ category: "session", value: categoryValues, subcategories: EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES, secretManifest, }); return { version: EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION, fingerprint: fingerprints.sessionFingerprint.fingerprint, categories: [...EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES], categoryFingerprints, fingerprints, }; } function buildWorkspaceConfigCategoryValues(input: { mode: unknown; projectId: unknown; projectWorkspaceId: unknown; strategyType: unknown; workspaceStrategy: unknown; repoUrl: unknown; repoRef: unknown; branchName: unknown; configSnapshot: Partial | null; environment: unknown; realization: unknown; }) { const snapshot = input.configSnapshot ?? {}; return { mode: { mode: input.mode ?? null, }, projectWorkspace: { projectId: input.projectId ?? null, projectWorkspaceId: input.projectWorkspaceId ?? null, }, strategy: { strategyType: input.strategyType ?? null, workspaceStrategy: input.workspaceStrategy ?? null, }, repo: { repoUrl: input.repoUrl ?? null, repoRef: input.repoRef ?? null, branchName: input.branchName ?? null, }, lifecycleCommands: { provisionCommand: snapshot.provisionCommand ?? null, runtimeProvisionCommand: snapshot.runtimeProvisionCommand ?? null, teardownCommand: snapshot.teardownCommand ?? null, cleanupCommand: snapshot.cleanupCommand ?? null, }, runtimeServices: { workspaceRuntime: snapshot.workspaceRuntime ?? null, desiredState: snapshot.desiredState ?? null, serviceStates: snapshot.serviceStates ?? null, }, environment: input.environment ?? null, realization: input.realization ?? null, } satisfies Record; } export function buildEffectiveRunWorkspaceConfigMetadata(input: { mode: unknown; projectId: unknown; projectWorkspaceId: unknown; strategyType: unknown; workspaceStrategy: unknown; repoUrl: unknown; repoRef: unknown; branchName?: unknown; configSnapshot: Partial | null; environment: unknown; realization: unknown; secretManifest?: readonly EffectiveRunConfigSecretManifestEntry[]; evaluatedAt?: string | Date | null; }): EffectiveRunWorkspaceConfigMetadata { const secretManifest = input.secretManifest ?? []; const categoryValues = buildWorkspaceConfigCategoryValues({ mode: input.mode, projectId: input.projectId, projectWorkspaceId: input.projectWorkspaceId, strategyType: input.strategyType, workspaceStrategy: input.workspaceStrategy, repoUrl: input.repoUrl, repoRef: input.repoRef, branchName: input.branchName ?? null, configSnapshot: input.configSnapshot, environment: input.environment, realization: input.realization, }); const fingerprints = createEffectiveRunConfigFingerprints({ workspace: categoryValues, secretManifest, }); const categoryFingerprints = createEffectiveRunConfigSubcategoryFingerprints({ category: "workspace", value: categoryValues, subcategories: EFFECTIVE_RUN_WORKSPACE_CONFIG_CATEGORIES, secretManifest, }); const evaluatedAt = input.evaluatedAt instanceof Date ? input.evaluatedAt.toISOString() : (readNonEmptyString(input.evaluatedAt) ?? new Date().toISOString()); return { version: EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION, fingerprint: fingerprints.workspaceFingerprint.fingerprint, categories: [...EFFECTIVE_RUN_WORKSPACE_CONFIG_CATEGORIES], categoryFingerprints, fingerprints, evaluatedAt, }; } export function resolveExecutionWorkspaceConfigFreshness(input: { hasExistingWorkspace: boolean; existingWorkspaceMetadata: Record | null | undefined; inferredMetadata?: EffectiveRunWorkspaceConfigMetadata | null; nextMetadata: EffectiveRunWorkspaceConfigMetadata | null; }): ExecutionWorkspaceConfigFreshnessDecision { if (!input.hasExistingWorkspace) { return { action: "create", shouldReuseExisting: false, shouldRefreshConfigSnapshot: false, reasons: [], changedCategories: [], storedFingerprint: null, inferredFingerprint: null, nextFingerprint: input.nextMetadata?.fingerprint ?? null, storedFingerprintPresent: false, }; } const stored = readWorkspaceConfigFingerprintFromMetadata( input.existingWorkspaceMetadata, ); const previous = stored ? { version: stored.version, fingerprint: stored.fingerprint, categoryFingerprints: stored.categoryFingerprints, } : input.inferredMetadata ? { version: input.inferredMetadata.version, fingerprint: input.inferredMetadata.fingerprint, categoryFingerprints: input.inferredMetadata.categoryFingerprints, } : null; if (!input.nextMetadata) { return { action: "reuse", shouldReuseExisting: true, shouldRefreshConfigSnapshot: false, reasons: [], changedCategories: [], storedFingerprint: stored?.fingerprint ?? null, inferredFingerprint: stored ? null : (input.inferredMetadata?.fingerprint ?? null), nextFingerprint: null, storedFingerprintPresent: Boolean(stored), }; } if (!previous) { return { action: "replace", shouldReuseExisting: false, shouldRefreshConfigSnapshot: false, reasons: [ "execution workspace configuration fingerprint metadata is missing", ], changedCategories: [...input.nextMetadata.categories], storedFingerprint: null, inferredFingerprint: null, nextFingerprint: input.nextMetadata.fingerprint, storedFingerprintPresent: false, }; } if (previous.version !== input.nextMetadata.version) { return { action: "replace", shouldReuseExisting: false, shouldRefreshConfigSnapshot: false, reasons: [ `execution workspace configuration fingerprint version changed from ${previous.version} to ${input.nextMetadata.version}`, ], changedCategories: [...input.nextMetadata.categories], storedFingerprint: stored?.fingerprint ?? null, inferredFingerprint: stored ? null : (input.inferredMetadata?.fingerprint ?? null), nextFingerprint: input.nextMetadata.fingerprint, storedFingerprintPresent: Boolean(stored), }; } if (previous.fingerprint === input.nextMetadata.fingerprint) { return { action: "reuse", shouldReuseExisting: true, shouldRefreshConfigSnapshot: !stored, reasons: stored ? [] : ["execution workspace configuration fingerprint metadata is missing"], changedCategories: [], storedFingerprint: stored?.fingerprint ?? null, inferredFingerprint: stored ? null : (input.inferredMetadata?.fingerprint ?? null), nextFingerprint: input.nextMetadata.fingerprint, storedFingerprintPresent: Boolean(stored), }; } const changedCategories = changedEffectiveRunWorkspaceConfigCategories({ previous: previous.categoryFingerprints, next: input.nextMetadata.categoryFingerprints, }); const replacementRequired = changedCategories.some((category) => WORKSPACE_REPLACEMENT_CONFIG_CATEGORIES.has(category), ); const action: WorkspaceConfigFreshnessDecisionAction = replacementRequired ? "replace" : "refresh"; return { action, shouldReuseExisting: action !== "replace", shouldRefreshConfigSnapshot: action === "refresh", reasons: [ `execution workspace configuration changed: ${describeEffectiveRunWorkspaceConfigCategories(changedCategories)}`, ], changedCategories, storedFingerprint: stored?.fingerprint ?? null, inferredFingerprint: stored ? null : (input.inferredMetadata?.fingerprint ?? null), nextFingerprint: input.nextMetadata.fingerprint, storedFingerprintPresent: Boolean(stored), }; } /** Read server-owned identity before adapter codecs discard unknown metadata. */ export function isTaskSessionCredentialCompatible( storedSessionParams: Record | null | undefined, managedAiCredentialIdentity: string | undefined, ): boolean { if (!managedAiCredentialIdentity) return true; return storedSessionParams?.[SESSION_AI_CREDENTIAL_IDENTITY_KEY] === managedAiCredentialIdentity; } export function readConfiguredModelFromAdapterConfig( adapterConfig: Record | null | undefined, ) { return readNonEmptyString(adapterConfig?.model); } export function attachPaperclipSessionMetadataToSessionParams( sessionParams: Record | null | undefined, configuredModel: string | null, configMetadata?: EffectiveRunSessionConfigMetadata | null, ) { if (!configuredModel && !configMetadata) return sessionParams ?? null; const next = { ...(sessionParams ?? {}) }; if (configuredModel) next[SESSION_CONFIGURED_MODEL_KEY] = configuredModel; if (configMetadata) { if (configMetadata.aiCredentialIdentity) next[SESSION_AI_CREDENTIAL_IDENTITY_KEY] = configMetadata.aiCredentialIdentity; next[SESSION_CONFIG_FINGERPRINT_KEY] = configMetadata.fingerprint; next[SESSION_CONFIG_FINGERPRINT_VERSION_KEY] = configMetadata.version; next[SESSION_CONFIG_CATEGORIES_KEY] = configMetadata.categories; next[SESSION_CONFIG_CATEGORY_FINGERPRINTS_KEY] = configMetadata.categoryFingerprints; } return next; } function readConfiguredModelFromSessionParams( sessionParams: Record | null | undefined, ) { return readNonEmptyString(sessionParams?.[SESSION_CONFIGURED_MODEL_KEY]); } export function shouldResetTaskSessionForModelChange(input: { configuredModel: string | null; taskSessionParams: Record | null | undefined; }) { const { configuredModel, taskSessionParams } = input; if (!configuredModel || !taskSessionParams) return false; const sessionModel = readConfiguredModelFromSessionParams(taskSessionParams); return !!sessionModel && sessionModel !== configuredModel; } export function stripConfiguredModelFromSessionParams( sessionParams: Record | null | undefined, ) { if (!sessionParams) return null; const next = { ...sessionParams }; delete next[SESSION_CONFIGURED_MODEL_KEY]; return next; } export function stripPaperclipSessionMetadataFromSessionParams( sessionParams: Record | null | undefined, ) { if (!sessionParams) return null; const next = { ...sessionParams }; for (const key of PAPERCLIP_SESSION_METADATA_KEYS) { delete next[key]; } return next; } export function resolveTaskSessionConfigFreshness(input: { hasTaskSession: boolean; configuredModel: string | null; taskSessionParams: Record | null | undefined; configMetadata: EffectiveRunSessionConfigMetadata | null; compatibleConfigMetadata?: readonly EffectiveRunSessionConfigMetadata[]; wakeResetReason?: string | null; preserveLegacySessionWithoutConfigMetadata?: boolean; }): TaskSessionConfigFreshnessDecision { if (!input.hasTaskSession) { return { reset: false, reasons: [], changedCategories: [], storedFingerprint: null, nextFingerprint: input.configMetadata?.fingerprint ?? null, }; } const reasons: string[] = []; const storedConfig = readConfigFingerprintFromSessionParams( input.taskSessionParams, ); const taskSessionConfiguredModel = readConfiguredModelFromSessionParams( input.taskSessionParams, ); const modelChangedSinceTaskSession = shouldResetTaskSessionForModelChange({ configuredModel: input.configuredModel, taskSessionParams: input.taskSessionParams, }); if (modelChangedSinceTaskSession && taskSessionConfiguredModel) { reasons.push( `configured model changed from "${taskSessionConfiguredModel}" to "${input.configuredModel}"`, ); } let changedCategories: EffectiveRunSessionConfigCategory[] = []; if (input.configMetadata) { if (!storedConfig && !input.preserveLegacySessionWithoutConfigMetadata) { changedCategories = [...input.configMetadata.categories]; reasons.push( "effective run configuration fingerprint metadata is missing", ); } else if ( storedConfig && storedConfig.version !== input.configMetadata.version ) { changedCategories = [...input.configMetadata.categories]; reasons.push( `effective run configuration fingerprint version changed from ${storedConfig.version} to ${input.configMetadata.version}`, ); } else if ( storedConfig && storedConfig.fingerprint !== input.configMetadata.fingerprint && !input.compatibleConfigMetadata?.some(candidate => candidate.version === storedConfig.version && candidate.fingerprint === storedConfig.fingerprint) ) { changedCategories = changedEffectiveRunSessionConfigCategories({ previous: storedConfig.categoryFingerprints, next: input.configMetadata.categoryFingerprints, }); reasons.push( `effective run configuration changed: ${describeEffectiveRunConfigCategories(changedCategories)}`, ); } } if (input.wakeResetReason) reasons.push(input.wakeResetReason); return { reset: reasons.length > 0, reasons, changedCategories, storedFingerprint: storedConfig?.fingerprint ?? null, nextFingerprint: input.configMetadata?.fingerprint ?? null, }; } function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; } /** Database-bound workspace resolution for a heartbeat service instance. */ export function createHeartbeatWorkspaceResolver(db: Db) { function managedLocalSourceGuard( agent: typeof agents.$inferSelect, issueId: string | null, runId: string | null, ): PrepareManagedLocalSource { return async (sourceCwd, targetCwd) => { const [run] = runId ? await db.select().from(heartbeatRuns).where(and( eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, agent.companyId), eq(heartbeatRuns.agentId, agent.id), )) : []; const runIssueId = run?.nativeIssueId ?? run?.contextSnapshot?.issueId ?? run?.contextSnapshot?.taskId ?? run?.issueId ?? null; if (!run || runIssueId !== issueId) { throw forbidden("Local repository source requires an authoritative run identity"); } const actor = { type: "agent" as const, source: "agent_jwt" as const, companyId: agent.companyId, agentId: agent.id, runId: run.id, onBehalfOfUserId: run.responsibleUserId === "local-board" ? null : run.responsibleUserId, }; if (!issueId) throw forbidden("Local repository source requires a current task"); const sourceIdentity = await fs.realpath(sourceCwd); const targetIdentity = await canonicalRepositoryRoot(targetCwd); const selected = await assertTaskWorkspaceExecutionAccess(db, actor, agent.companyId, { issueId }); const [sourceConfiguration] = selected.task.projectWorkspaceId ? await db.select({ id: projectWorkspaces.id, cwd: projectWorkspaces.cwd, repoUrl: projectWorkspaces.repoUrl, repoRef: projectWorkspaces.repoRef }) .from(projectWorkspaces).where(and(eq(projectWorkspaces.id, selected.task.projectWorkspaceId), eq(projectWorkspaces.companyId, agent.companyId))) : []; const validate = async () => { if (await fs.realpath(sourceCwd) !== sourceIdentity || await canonicalRepositoryRoot(targetCwd) !== targetIdentity) { throw forbidden("Local repository copy identity changed during preparation"); } const current = await assertTaskWorkspaceExecutionAccess(db, actor, agent.companyId, { issueId }); if (current.task.projectWorkspaceId !== selected.task.projectWorkspaceId || current.task.projectId !== selected.task.projectId) { throw forbidden("Local repository source configuration changed during preparation"); } if (sourceConfiguration) { const [source] = await db.select({ id: projectWorkspaces.id, cwd: projectWorkspaces.cwd, repoUrl: projectWorkspaces.repoUrl, repoRef: projectWorkspaces.repoRef }).from(projectWorkspaces) .where(and(eq(projectWorkspaces.id, sourceConfiguration.id), eq(projectWorkspaces.companyId, agent.companyId))); if (!source || source.cwd !== sourceConfiguration.cwd || source.repoUrl !== sourceConfiguration.repoUrl || source.repoRef !== sourceConfiguration.repoRef) { throw forbidden("Local repository source configuration changed during preparation"); } } }; await validate(); return validate; }; } async function resolveReusedGitWorkspaceAnchor(input: { agent: typeof agents.$inferSelect; workspace: ExecutionWorkspace; projectId: string | null; explicitProjectWorkspaceId: string | null; issueId: string | null; runId: string; responsibleUserId: string | null; immutableNativeBinding: boolean; }): Promise { const { workspace, agent } = input; if (workspace.providerType === "plugin") { return { cwd: `/workspaces/${workspace.id}`, source: "task_session" as const, projectId: workspace.projectId, workspaceId: workspace.projectWorkspaceId, repoUrl: workspace.repoUrl, repoRef: workspace.baseRef, workspaceHints: [], warnings: [], baseCwdFallback: false, materializationFailures: [] }; } // Configuration preparation may have awaited credentials and skills since // issueRef was read. Match the ordinary anchor resolver's fresh selection // check; only an already-admitted native input owns immutable source scope. const currentIssueSource = input.issueId && !input.immutableNativeBinding ? await db.select({ projectId: issues.projectId, projectWorkspaceId: issues.projectWorkspaceId }).from(issues) .where(and(eq(issues.id, input.issueId), eq(issues.companyId, agent.companyId))).then(rows => rows[0] ?? null) : null; const sourceProjectId = workspace.projectId; const explicitProjectWorkspaceId = currentIssueSource?.projectWorkspaceId ?? input.explicitProjectWorkspaceId; const projectWorkspaceRows = await db.select().from(projectWorkspaces).where(and( eq(projectWorkspaces.companyId, agent.companyId), workspace.projectId ? eq(projectWorkspaces.projectId, workspace.projectId) : sql`false`, )).orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)); const boundProjectWorkspace = projectWorkspaceRows.find(row => row.id === workspace.projectWorkspaceId) ?? null; const configuredSource = prioritizeProjectWorkspaceCandidatesForRun(projectWorkspaceRows, explicitProjectWorkspaceId)[0]; const managedBase = workspace.repoUrl && workspace.projectId ? resolveManagedProjectWorkspaceDir({ companyId: agent.companyId, projectId: workspace.projectId, repoName: deriveRepoNameFromRepoUrl(workspace.repoUrl), }) : null; const candidateBaseCwds = [ await verifiedRecordedIsolatedRemoteSource(workspace), managedBase, managedBase && workspace.repoUrl ? `${managedBase}-${createHash("sha256").update(workspace.repoUrl).digest("hex").slice(0, 12)}` : null, ].filter((value): value is string => Boolean(value)); const cwd = await resolvePersistedGitWorkspaceSource({ companyId: agent.companyId, projectId: sourceProjectId, explicitProjectWorkspaceId, workspace, boundProjectWorkspace, candidateBaseCwds, managedSourceRoot: resolvePaperclipInstanceRoot(), materializeOriginalRepository: workspace.repoUrl && workspace.projectId ? async () => { const original = await ensureManagedProjectWorkspace({ companyId: agent.companyId, projectId: workspace.projectId!, repoUrl: workspace.repoUrl, prepareLocalSource: managedLocalSourceGuard(agent, input.issueId, input.runId), resolveGitAuth: createGitRemoteAuthProvider(db, agent.companyId, { issueId: input.issueId, heartbeatRunId: input.runId, agentId: agent.id, responsibleUserId: input.responsibleUserId, }), }); return original.cwd; } : undefined, }); return { cwd, source: "task_session", projectId: workspace.projectId, workspaceId: workspace.projectWorkspaceId, repoUrl: workspace.repoUrl, repoRef: workspace.baseRef, // Freshness must still expose drift in current project configuration. // This snapshot authorizes no filesystem lookup or repository fallback. freshnessSource: { projectId: workspace.projectId, workspaceId: configuredSource?.id ?? null, repoUrl: configuredSource?.repoUrl ?? null, repoRef: configuredSource?.repoRef ?? null, }, workspaceHints: [], warnings: [], baseCwdFallback: false, materializationFailures: [], }; } async function resolveAnchorWorkspaceForRun( agent: typeof agents.$inferSelect, context: Record, previousSessionParams: Record | null, opts?: { useProjectWorkspace?: boolean | null; configuredCwd?: string | null; executionEnvironmentDriver?: string | null; storageProviderPluginId?: string | null }, ): Promise { const issueId = readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId); const resolveGitAuth = createGitRemoteAuthProvider(db, agent.companyId, { issueId, heartbeatRunId: readNonEmptyString(context.executionIdentityRunId), responsibleUserId: readNonEmptyString(context.responsibleUserId) ?? readNonEmptyString(context.responsible_user_id), agentId: agent.id, }); const contextProjectId = readNonEmptyString(context.projectId); const contextProjectWorkspaceId = readNonEmptyString( context.projectWorkspaceId, ); const issueProjectRef = issueId ? await db .select({ projectId: issues.projectId, projectWorkspaceId: issues.projectWorkspaceId, workspaceSelection: issues.workspaceSelection, }) .from(issues) .where( and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)), ) .then((rows) => rows[0] ?? null) : null; const issueProjectId = issueProjectRef?.projectId ?? null; const workspaceSelection = issueProjectRef?.workspaceSelection?.selection; const configuredSourceId = workspaceSelection?.kind === "configured_source" ? workspaceSelection.projectWorkspaceId : null; const preferredProjectWorkspaceId = configuredSourceId ?? issueProjectRef?.projectWorkspaceId ?? contextProjectWorkspaceId ?? null; const [selectedSource] = preferredProjectWorkspaceId ? await db.select({ projectId: projectWorkspaces.projectId }).from(projectWorkspaces) .where(and(eq(projectWorkspaces.id, preferredProjectWorkspaceId), eq(projectWorkspaces.companyId, agent.companyId))) : []; const resolvedProjectId = selectedSource?.projectId ?? issueProjectId ?? contextProjectId; const forceTaskDirectory = workspaceSelection?.kind === "task_directory"; const useProjectWorkspace = opts?.useProjectWorkspace !== false; const workspaceProjectId = useProjectWorkspace && !forceTaskDirectory ? resolvedProjectId : null; const unorderedProjectWorkspaceRows = workspaceProjectId ? await db .select() .from(projectWorkspaces) .where( and( eq(projectWorkspaces.companyId, agent.companyId), eq(projectWorkspaces.projectId, workspaceProjectId), ), ) .orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)) : []; const prioritizedWorkspaceRows = prioritizeProjectWorkspaceCandidatesForRun( unorderedProjectWorkspaceRows, preferredProjectWorkspaceId, ); const projectWorkspaceRows = configuredSourceId ? prioritizedWorkspaceRows.filter((workspace) => workspace.id === configuredSourceId) : prioritizedWorkspaceRows; const workspaceHints = projectWorkspaceRows.map((workspace) => ({ workspaceId: workspace.id, cwd: readNonEmptyString(workspace.cwd), repoUrl: readNonEmptyString(workspace.repoUrl), repoRef: readNonEmptyString(workspace.repoRef), })); if (opts?.executionEnvironmentDriver === "plugin" && agent.adapterType === "paperclip_runner") { if (workspaceProjectId) { const project = await db.select({ id: projects.id }).from(projects).where(and( eq(projects.companyId, agent.companyId), eq(projects.id, workspaceProjectId), )).then(rows => rows[0] ?? null); if (!project) throw new WorkspaceValidationFailure("The selected plugin project is unavailable.", { reason: "plugin_project_unavailable" }); } if (configuredSourceId && projectWorkspaceRows.length === 0) { throw new WorkspaceValidationFailure("The selected project workspace is unavailable.", { reason: "configured_workspace_unavailable" }); } const selected = projectWorkspaceRows[0]; // This is a logical scope. Preparation resolves the execution-host path. return { cwd: `/workspaces/${selected?.id ?? issueId ?? agent.id}`, source: workspaceProjectId ? "project_primary" : issueId ? "task_session" : "agent_home", projectId: workspaceProjectId ?? null, workspaceId: selected?.id ?? null, repoUrl: selected?.repoUrl ?? null, repoRef: selected?.repoRef ?? null, workspaceHints, warnings: [], baseCwdFallback: false, materializationFailures: [] }; } if (projectWorkspaceRows.length > 0 || configuredSourceId) { const preferredWorkspace = preferredProjectWorkspaceId ? (projectWorkspaceRows.find( (workspace) => workspace.id === preferredProjectWorkspaceId, ) ?? null) : null; const missingProjectCwds: string[] = []; const materializationFailures: WorkspaceMaterializationFailure[] = []; let hasConfiguredProjectCwd = false; let preferredWorkspaceWarning: string | null = null; if (preferredProjectWorkspaceId && !preferredWorkspace) { preferredWorkspaceWarning = `Selected project workspace "${preferredProjectWorkspaceId}" is not available on this project.`; } const [sourceProject] = projectWorkspaceRows.length ? await db.select({ policy: projects.executionWorkspacePolicy }).from(projects) .where(and(eq(projects.id, workspaceProjectId!), eq(projects.companyId, agent.companyId))) : []; const storageProviderPluginId = opts?.storageProviderPluginId !== undefined ? opts.storageProviderPluginId : sourceProject?.policy?.storageProviderPluginId ?? (sourceProject ? (await effectiveProjectWorkspacePolicy(db, agent.companyId, sourceProject.policy, projectWorkspaceRows.some(workspace => Boolean(workspace.repoUrl))))?.storageProviderPluginId : null); for (const workspace of projectWorkspaceRows) { if (storageProviderPluginId) { return { cwd: `/workspaces/${workspace.id}`, source: "project_primary" as const, projectId: workspace.projectId, workspaceId: workspace.id, repoUrl: workspace.repoUrl, repoRef: workspace.repoRef, workspaceHints, warnings: [], baseCwdFallback: false, materializationFailures: [] }; } let projectCwd: string; let managedWorkspaceWarning: string | null = null; try { const resolvedCwd = await resolveConfiguredOrManagedProjectCwd({ companyId: agent.companyId, projectId: workspaceProjectId ?? resolvedProjectId ?? workspace.projectId, cwd: workspace.cwd, repoUrl: workspace.repoUrl, resolveGitAuth, prepareLocalSource: managedLocalSourceGuard(agent, issueId, readNonEmptyString(context.executionIdentityRunId)), }); projectCwd = resolvedCwd.cwd; managedWorkspaceWarning = resolvedCwd.warning; } catch (error) { const scrubbedError = scrubGitCredentialText( error instanceof Error ? error.message : String(error), ); const workspaceRepoUrl = readNonEmptyString(workspace.repoUrl); const connectionFailure = readGitConnectionFailure(error); materializationFailures.push({ projectWorkspaceId: workspace.id, repoUrl: workspaceRepoUrl ? scrubGitCredentialText(workspaceRepoUrl) : null, error: scrubbedError, ...(connectionFailure ? { connectionFailure } : {}), }); if (preferredWorkspace?.id === workspace.id) { preferredWorkspaceWarning = scrubbedError; } continue; } hasConfiguredProjectCwd = true; const projectCwdExists = await fs .stat(projectCwd) .then((stats) => stats.isDirectory()) .catch(() => false); if (projectCwdExists) { return { cwd: projectCwd, source: "project_primary" as const, projectId: resolvedProjectId, workspaceId: workspace.id, repoUrl: workspace.repoUrl, repoRef: workspace.repoRef, workspaceHints, warnings: [ preferredWorkspaceWarning, managedWorkspaceWarning, ].filter((value): value is string => Boolean(value)), baseCwdFallback: false, materializationFailures, localPathOnlyWorkspace: (workspace.sourceType === "local_path" || workspace.sourceType === "non_git_path") && Boolean(readNonEmptyString(workspace.cwd)) && workspace.cwd !== REPO_ONLY_CWD_SENTINEL && !readNonEmptyString(workspace.repoUrl), }; } if (preferredWorkspace?.id === workspace.id) { preferredWorkspaceWarning = `Selected project workspace path "${projectCwd}" is not available yet.`; } missingProjectCwds.push(projectCwd); } const connections = materializationFailures.map((failure) => readConnectionFailure(failure.connectionFailure)); const connectionFailure = connections.length > 0 && connections.every((failure) => failure?.provider === "git") ? connections[0] as GitConnectionFailure : undefined; throw new WorkspaceValidationFailure( `Configured project workspace is unavailable: ${preferredWorkspaceWarning ?? materializationFailures[0]?.error ?? missingProjectCwds[0] ?? "source could not be prepared"}. Repair the configured path or repository access, then retry.`, { ...(connectionFailure ? { connectionFailure } : {}), workspaceValidation: { reason: materializationFailures.length > 0 ? "git_worktree_base_materialization_failed" : "configured_workspace_unavailable", issueId, issueProjectId, issueProjectWorkspaceId: preferredProjectWorkspaceId, resolvedWorkspaceSource: "project_primary", resolvedProjectId, resolvedProjectWorkspaceId: preferredWorkspace?.id ?? null, baseCwdFallback: false, materializationFailures, }, }, ); } const sessionCwd = readNonEmptyString(previousSessionParams?.cwd); const sessionCwdLooksUnsafe = isUnsafeSessionWorkspaceCwd(sessionCwd); if (!forceTaskDirectory && sessionCwd && !sessionCwdLooksUnsafe) { const sessionCwdExists = await fs .stat(sessionCwd) .then((stats) => stats.isDirectory()) .catch(() => false); if (sessionCwdExists) { return { cwd: sessionCwd, source: "task_session" as const, projectId: resolvedProjectId, workspaceId: readNonEmptyString(previousSessionParams?.workspaceId), repoUrl: readNonEmptyString(previousSessionParams?.repoUrl), repoRef: readNonEmptyString(previousSessionParams?.repoRef), workspaceHints, warnings: [], baseCwdFallback: false, materializationFailures: [], }; } } const configuredCwd = forceTaskDirectory ? null : opts?.configuredCwd !== undefined ? opts.configuredCwd : readNonEmptyString(parseObject(agent.adapterConfig).cwd); const cwd = configuredCwd ? path.resolve(configuredCwd) : issueId ? await materializeIsolatedTaskDirectory({ companyId: agent.companyId, issueId }) : resolveDefaultAgentWorkspaceDir(agent.id); await fs.mkdir(cwd, { recursive: true }); const warnings: string[] = []; if (sessionCwd && sessionCwdLooksUnsafe) { warnings.push( `Saved session workspace "${sessionCwd}" points at a system temp root and was rejected as untrusted. Using fallback workspace "${cwd}" for this run.`, ); } else if (sessionCwd) { warnings.push( `Saved session workspace "${sessionCwd}" is not available. Using fallback workspace "${cwd}" for this run.`, ); } else if (resolvedProjectId) { warnings.push( `No project workspace directory is currently available for this issue. Using fallback workspace "${cwd}" for this run.`, ); } else { warnings.push( `No project or prior session workspace was available. Using fallback workspace "${cwd}" for this run.`, ); } return { cwd, source: issueId || configuredCwd ? "task_session" as const : "agent_home" as const, projectId: null, workspaceId: null, repoUrl: null, repoRef: null, workspaceHints, warnings, baseCwdFallback: false, materializationFailures: [], }; } /** * Resolve the run workspace: the anchor workspace plus, when the multi-project workspace-sync * flag is on, the read-only referenced (mentioned) project workspaces. With the flag off (the * production default) the anchor path is unchanged and `additionalWorkspaces` is empty. */ async function resolveWorkspaceForRun( agent: typeof agents.$inferSelect, context: Record, previousSessionParams: Record | null, opts?: { useProjectWorkspace?: boolean | null; configuredCwd?: string | null; executionEnvironmentDriver?: string | null; storageProviderPluginId?: string | null; anchorWorkspace?: ResolvedAnchorWorkspaceForRun; }, ): Promise { const anchor = opts?.anchorWorkspace ?? await resolveAnchorWorkspaceForRun( agent, context, previousSessionParams, opts, ); if (!isMultiProjectWorkspaceSyncEnabled()) { return { ...anchor, additionalWorkspaces: [], referencedProjectFailures: [], }; } // Derive the remote-transport facts from the selected environment driver. `executionTargetIsRemote` // decides whether the referenced set needs the remote path at all; `targetStagesConfined` decides // whether that remote target confines each staged tree (only the sandbox driver does). The remote // flag is the targeted kill switch; with it off, a remote run fails closed. const executionEnvironmentDriver = opts?.executionEnvironmentDriver ?? null; const issueId = readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId); const responsibleUserId = readNonEmptyString(context.responsibleUserId) ?? readNonEmptyString(context.responsible_user_id); const referencedOptions: ResolveRunReferencedProjectsOptions = { companyId: agent.companyId, actor: { type: "agent", agentId: agent.id, companyId: agent.companyId, source: "agent_key", runId: readNonEmptyString(context.executionIdentityRunId), onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId }, issues: issueService(db), projects: projectService(db), access: authorizationService(db), }; if (executionEnvironmentDriver === "plugin" && agent.adapterType === "paperclip_runner" && issueId && isMultiProjectWorkspaceSyncRemoteEnabled()) { const referenced = await resolveRunReferencedProjects(issueId, anchor.projectId, referencedOptions); return { ...anchor, additionalWorkspaces: [], runnerTaskProjectIds: [anchor.projectId, ...referenced.additional.map(project => project.projectId)] .filter((id): id is string => id !== null), referencedProjectFailures: referenced.failures, warnings: [...anchor.warnings, ...referenced.warnings] }; } const resolveGitAuth = createGitRemoteAuthProvider(db, agent.companyId, { issueId, heartbeatRunId: readNonEmptyString(context.executionIdentityRunId), responsibleUserId, agentId: agent.id, }); const { additionalWorkspaces, warnings, failures } = await resolveAdditionalRunWorkspaces(issueId, anchor.projectId, { enabled: true, executionTargetIsRemote: isRemoteExecutionEnvironmentDriver( executionEnvironmentDriver, ), targetStagesConfined: isConfinedRemoteStagingDriver( executionEnvironmentDriver, ), remoteReferencedSyncEnabled: isMultiProjectWorkspaceSyncRemoteEnabled(), ...referencedOptions, resolveProjectWorkspace: (project) => resolveAdditionalProjectWorkspace( { companyId: agent.companyId, project }, defaultAdditionalProjectWorkspaceDeps(db, resolveGitAuth, managedLocalSourceGuard(agent, issueId, readNonEmptyString(context.executionIdentityRunId))), ), }); return { ...anchor, additionalWorkspaces, referencedProjectFailures: failures, warnings: warnings.length > 0 ? [...anchor.warnings, ...warnings] : anchor.warnings, }; } return { resolveReusedGitWorkspaceAnchor, resolveWorkspaceForRun, }; }