diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 1809c2fc165..53ea15d19ac 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -1217,6 +1217,15 @@ stored event payloads, redacts and shortens log chunks, and caps stdout/stderr excerpts. `heartbeat.ts` keeps event writes, current-user redaction, and live event delivery. Existing public helpers remain available from `heartbeat.ts`. +Workspace preparation is in `server/src/services/heartbeat/workspaces.ts`. It +owns managed checkout materialization, workspace validation and reuse, referenced +project resolution, and session/workspace configuration freshness. Its +`createHeartbeatWorkspaceResolver(db)` factory binds the run workspace resolvers +to a service's database. The checkout single-flight map stays at module scope so +all service instances share in-flight materialization. Existing public helpers +and the workspace validation error class remain available from `heartbeat.ts`. +Keep workspace policy changes separate from scheduling and run execution changes. + ## Wake Context Delivery Built-in adapters deliver wake context through the run prompt, including structured diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index d89725adf5c..f4199bba621 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1,3 +1,98 @@ +import { + WORKSPACE_VALIDATION_FAILURE_CODE, + isWorkspaceValidationFailedRun, + readWorkspaceValidationPayloadFromRun, + resolveNativeRecoveryExecutionWorkspaceBinding, + resolveExecutionWorkspaceReuseRequestForIssue, + buildExecutionWorkspaceConfigSnapshot, + stripWorkspaceRuntimeFromExecutionRunConfig, + buildEffectiveRunSessionConfigMetadata, + readConfigFingerprintFromSessionParams, + readConfiguredModelFromAdapterConfig, + resolveTaskSessionConfigFreshness, + stripPaperclipSessionMetadataFromSessionParams, + resolveWorkspaceAfterLowTrustPreflight, + WorkspaceValidationFailure, + stripHostWorkspaceProvisionForLowTrustSandbox, + assertGitWorktreeBaseWorkspaceReady, + buildEffectiveRunWorkspaceConfigMetadata, + resolveExecutionWorkspaceConfigFreshness, + resolveExecutionWorkspaceReuseProvisioningPolicy, + provisionExecutionWorkspaceForFreshnessDecision, + mergeExecutionWorkspaceMetadataForPersistence, + resolveExecutionWorkspaceBranchOwnership, + reconcileReusedExecutionWorkspaceProjectWorkspaceId, + recordWorkspaceConfigFreshnessOperation, + prepareProjectRepositoryWorkspaces, + resolveRuntimeSessionParamsForWorkspace, + buildRunWorkspaceHints, + buildReferencedProjectRunObservability, + assertGitSensitiveAdapterWorkspaceValid, + isWorkspaceValidationFailure, + fingerprintFinalizeWorkspaceBranchValidation, + attachPaperclipSessionMetadataToSessionParams, + type EffectiveRunSessionConfigMetadata, + createHeartbeatWorkspaceResolver, +} from "./heartbeat/workspaces.js"; +export { + WorkspaceValidationFailure, + requiresPushCapabilityPreflight, + applyPersistedExecutionWorkspaceConfig, + mergeExecutionWorkspaceMetadataForPersistence, + resolveExecutionWorkspaceBranchOwnership, + stripWorkspaceRuntimeFromExecutionRunConfig, + stripHostWorkspaceProvisionForLowTrustSandbox, + preflightLowTrustWorkspaceIsolation, + resolveWorkspaceAfterLowTrustPreflight, + ensureManagedProjectWorkspace, + prepareProjectRepositoryWorkspaces, + type ResolveAdditionalProjectWorkspaceDeps, + resolveAdditionalProjectWorkspace, + assertGitWorktreeBaseWorkspaceReady, + assertPushCapabilityCheckoutValid, + reconcileReusedExecutionWorkspaceProjectWorkspaceId, + assertGitSensitiveAdapterWorkspaceValid, + type ResolvedAdditionalWorkspace, + type WorkspaceMaterializationFailure, + type ResolvedWorkspaceForRun, + buildAnchorFallbackWorkspaceNotes, + buildRunWorkspaceHints, + prioritizeProjectWorkspaceCandidatesForRun, + MULTI_PROJECT_WORKSPACE_SYNC_ENV, + isMultiProjectWorkspaceSyncEnabled, + isRemoteExecutionEnvironmentDriver, + MULTI_PROJECT_WORKSPACE_SYNC_REMOTE_ENV, + isMultiProjectWorkspaceSyncRemoteEnabled, + isConfinedRemoteStagingDriver, + MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS, + MAX_RUN_REFERENCED_CANDIDATE_EVALUATIONS, + type RunReferencedProject, + type ReferencedProjectFailureReason, + type ReferencedProjectFailure, + type ResolvedRunReferencedProjects, + type ResolveRunReferencedProjectsOptions, + resolveRunReferencedProjects, + type ResolveAdditionalRunWorkspacesOptions, + resolveAdditionalRunWorkspaces, + type ReferencedProjectRunObservability, + buildReferencedProjectRunObservability, + resolveRuntimeSessionParamsForWorkspace, + type EffectiveRunWorkspaceConfigMetadata, + type ExecutionWorkspaceReuseRequestForIssue, + resolveNativeRecoveryExecutionWorkspaceBinding, + resolveExecutionWorkspaceReuseRequestForIssue, + resolveExecutionWorkspaceReuseProvisioningPolicy, + provisionExecutionWorkspaceForFreshnessDecision, + buildWorkspaceConfigFreshnessOperation, + buildEffectiveRunSessionConfigMetadata, + buildEffectiveRunWorkspaceConfigMetadata, + resolveExecutionWorkspaceConfigFreshness, + isTaskSessionCredentialCompatible, + shouldResetTaskSessionForModelChange, + stripConfiguredModelFromSessionParams, + stripPaperclipSessionMetadataFromSessionParams, + resolveTaskSessionConfigFreshness, +} from "./heartbeat/workspaces.js"; import { appendExcerpt, boundHeartbeatRunEventPayloadForStorage, @@ -63,7 +158,13 @@ import { applyConnectorSkills, prepareConnectorSkillDelivery, resolveConnectorAs import { admitExplicitNativeContinuation, admitExplicitContinuationRetry, undeliveredLegacyUserCommentIds } from "./explicit-native-continuation.js"; import { canRetryStoppedRun, isCancelledNativeStartup } from "./cancelled-native-startup.js"; import { connectionIntentService } from "./connection-intents.js"; -import { managedAiSessionFingerprintConfig, prepareManagedAiRuntime, assertManagedAiProjectAuth, stripAiAuthBindings, isAiConnectionBusy, AI_AUTH_ENV_KEYS } from "./ai-connection-runtime.js"; +import { + prepareManagedAiRuntime, + assertManagedAiProjectAuth, + stripAiAuthBindings, + isAiConnectionBusy, + AI_AUTH_ENV_KEYS, +} from "./ai-connection-runtime.js"; import { aiConnectionBindingSchema, aiRuntimeConnectionBindingSchema, type AiConnectionRouterSelection } from "@paperclipai/shared"; import { aiConnectionRouterService, AiConnectionPoolExhausted, applyAiConnectionRouterTaskSettings } from "./ai-connection-router.js"; import { aiConnectionSessionCompatibilityInputs, managedAiSessionIdentityCompatible } from "./ai-connection-session.js"; @@ -90,9 +191,12 @@ import { buildHeartbeatRunStatusLiveEventPayload } from "./heartbeat-run-status- export { buildHeartbeatRunStatusLiveEventPayload } from "./heartbeat-run-status-payload.js"; import { buildExecutionContinuation, StaleExecutionContinuationError } from "./execution-continuation.js"; import { renderPaperclipWakePrompt } from "@paperclipai/adapter-utils/server-utils"; -import { PROJECT_REPOSITORIES_DIR, readGitWorkspaceSnapshot, disposeGitWorkspaceSnapshot } from "@paperclipai/adapter-utils/git-workspace-sync"; -import { isWorkspaceGitScanError, WorkspaceGitScanError, WORKSPACE_GIT_SCAN_ERROR_CODES } from "./workspace-git-operation-scheduler.js"; -import { captureDirectorySnapshot, disposeDirectorySnapshot, mergeDirectoryWithBaseline } from "@paperclipai/adapter-utils/workspace-restore-merge"; + +import { + isWorkspaceGitScanError, + WORKSPACE_GIT_SCAN_ERROR_CODES, +} from "./workspace-git-operation-scheduler.js"; + import { initializeRunIdentity, explicitOperatorRunIdentity } from "./run-identity.js"; import { assertDurableChatWakeupReceipt, @@ -152,7 +256,6 @@ import { type CostStatus, type EnvironmentLeaseStatus, type ExecutionWorkspace, - type ExecutionWorkspaceConfig, type HeartbeatRunStatusPhase, type IssueExecutionMonitorClearReason, type IssueExecutionMonitorPolicy, @@ -227,10 +330,8 @@ import { logger } from "../middleware/logger.js"; import { createGitRemoteAuthProvider, resolveManagedGitHubIdentitySelection, - describeGitAuthFailure, filterResolvedGitHubConnectionsForRun, scrubGitCredentialText, - type GitRemoteAuthProvider, } from "./git-credentials.js"; // Re-exported because heartbeat's workspace surface exposed the scrubber before the // git-credentials module became its canonical home; existing importers keep working. @@ -374,7 +475,6 @@ import { budgetService, withCurrentBudgetEnforcement, type BudgetEnforcementScop import { secretService, type MissingRuntimeBinding } from "./secrets.js"; import { resolveDefaultAgentWorkspaceDir, - resolveManagedProjectWorkspaceDir, } from "../home-paths.js"; import { buildHeartbeatRunIssueComment, @@ -441,9 +541,8 @@ import { type RealizedExecutionWorkspace, type RuntimeServiceRef, type UnresolvedWorkspaceBaseRefError, - sanitizeRuntimeServiceBaseEnv, } from "./workspace-runtime.js"; -import { resolvePersistedGitWorkspaceSource } from "./persisted-workspace-source.js"; + import { readManagedWorktreeInstanceOwnership, WORKTREE_INSTANCE_ROOT_METADATA_KEY, @@ -457,11 +556,7 @@ import { settleLiveRunnerGoalBeforeInterrupt, } from "./runner-goals.js"; import { resolveChatRunPresentationAuthorizationReason } from "./chat-run-publications.js"; -import { projectService } from "./projects.js"; -import { - authorizationService, - type AuthorizationActor, -} from "./authorization.js"; + import { createToolGatewayService } from "./tool-gateway.js"; import { toolAccessService } from "./tool-access.js"; import { visibleIssueCondition } from "./issue-visibility.js"; @@ -488,19 +583,15 @@ import { } from "./plan-review-context.js"; import { executionWorkspaceService, - mergeExecutionWorkspaceConfig, } from "./execution-workspaces.js"; import { workspaceOperationService, - type WorkspaceOperationRecorder, } from "./workspace-operations.js"; import { isProcessGroupAlive, terminateLocalService, } from "./local-service-supervisor.js"; import { - GIT_BRANCH_OWNERSHIP_METADATA_KEY, - GIT_BRANCH_OWNERSHIP_METADATA_VERSION, isRuntimeOwnedGitBranch, } from "./execution-workspace-branch-ownership.js"; import { @@ -664,17 +755,10 @@ import { } from "./hot-restart.js"; import { assertLowTrustRuntimeServicesAllowed, - assertLowTrustWorkspaceIsolation, } from "./low-trust-runtime-containment.js"; import type { TrustPresetResolution } from "./trust-preset-resolver.js"; import { resolveAndRetainRunTrustPreset } from "./run-trust-preset.js"; -import { - createEffectiveRunConfigFingerprints, - createEffectiveRunConfigSubcategoryFingerprints, - EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION, - type EffectiveRunConfigFingerprints, - type EffectiveRunConfigSecretManifestEntry, -} from "./effective-run-config-fingerprints.js"; + import type { PluginWorkerManager } from "./plugin-worker-manager.js"; import { serverVersion } from "../version.js"; import { computeTaskDrain, applyTaskDrain, startTaskDrain, stopTaskDrain, readTaskDrain } from "./task-admission.js"; @@ -804,8 +888,7 @@ function pendingCleanupCapWarnedSql() { false )`; } -const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; -const MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS = 10 * 60 * 1000; + const MAX_INLINE_WAKE_COMMENTS = 8; const MAX_INLINE_WAKE_ATTACHMENTS = 20; const MAX_INLINE_WAKE_COMMENT_BODY_CHARS = 4_000; @@ -860,7 +943,7 @@ export { INTERACTION_CONTINUATION_INFRA_WAKE_REASON, }; const INTERACTION_CONTINUATION_INFRA_MAX_ATTEMPTS = 2; -const WORKSPACE_VALIDATION_FAILURE_CODE = "workspace_validation_failed"; + const WORKSPACE_VALIDATION_RECOVERY_CAUSE = "workspace_validation_failed"; const CONFIGURATION_INCOMPLETE_FAILURE_CODE = "configuration_incomplete"; const CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE = "configuration_incomplete"; @@ -870,8 +953,7 @@ const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON = "execution_review_participant_recovery"; const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_CAUSE = "execution_review_participant_recovery"; -const GITHUB_PR_WORKFLOW_SKILL_KEY = - "paperclipai/bundled/software-development/github-pr-workflow"; + const NON_RETRYABLE_PREFLIGHT_FAILURE_CODES = new Set([ "low_trust_isolation_unavailable", "low_trust_requires_isolated_workspace", @@ -912,20 +994,7 @@ class ChatControlRecoveryUnresolvedError extends Error { ); } } -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 { MAX_TURN_CONTINUATION_RETRY_REASON }; export const MAX_TURN_CONTINUATION_WAKE_REASON = "max_turns_continuation_retry"; const MAX_TURN_CONTINUATION_DEFAULT_MAX_ATTEMPTS = 2; @@ -969,17 +1038,6 @@ interface MaxTurnContinuationPolicy { delayMs: number; } -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; - } -} - // Pre-dispatch gate outcome: required secret/env bindings are missing, so the // run must not be dispatched. Surfaced as a configuration-incomplete blocker // routed to a human owner instead of N opaque dispatched-then-failed runs. @@ -1422,29 +1480,6 @@ function isConfiguredEnvBindingValue(binding: unknown) { return true; } -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) - ); -} - const LOW_TRUST_SENSITIVE_ENV_KEY_RE = /(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i; @@ -2107,768 +2142,6 @@ export function resolveNativeSandboxLifecycle(input: { return resolveReusableSandboxLifecycle(input); } -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; -} - -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 stripHostWorkspaceProvisionForLowTrustSandbox(input: { - config: Record; - trustPreset: TrustPresetResolution; - selectedEnvironmentDriver: string | null | undefined; -}): Record { - if (input.trustPreset.kind !== "low_trust_review") return input.config; - if (input.selectedEnvironmentDriver !== "sandbox") return input.config; - - const workspaceStrategy = parseObject(input.config.workspaceStrategy); - if ( - typeof workspaceStrategy.provisionCommand !== "string" && - typeof workspaceStrategy.runtimeProvisionCommand !== "string" - ) - return input.config; - - const nextWorkspaceStrategy = { ...workspaceStrategy }; - delete nextWorkspaceStrategy.provisionCommand; - delete nextWorkspaceStrategy.runtimeProvisionCommand; - - return { - ...input.config, - workspaceStrategy: nextWorkspaceStrategy, - }; -} - -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(), - }; -} - -function deriveRepoNameFromRepoUrl(repoUrl: string | null): string | null { - const trimmed = repoUrl?.trim() ?? ""; - if (!trimmed) return null; - try { - const parsed = new URL(trimmed); - const cleanedPath = parsed.pathname.replace(/\/+$/, ""); - const repoName = - cleanedPath - .split("/") - .filter(Boolean) - .pop() - ?.replace(/\.git$/i, "") ?? ""; - return repoName || null; - } catch { - return null; - } -} - -/** - * 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; - /** 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 attempt = materializeManagedProjectWorkspace(cwd, input).finally(() => { - managedCheckoutMaterializations.delete(cwd); - }); - managedCheckoutMaterializations.set(cwd, attempt); - const result = await attempt; - 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; -} - -async function materializeManagedProjectWorkspace( - cwd: string, - input: { - repoUrl: string | null; - repoRef?: string | null; - localSource?: string | null; - resolveGitAuth?: GitRemoteAuthProvider | null; - }, -): Promise<{ cwd: string; warning: string | null }> { - await fs.mkdir(path.dirname(cwd), { recursive: true }); - const stats = await fs.stat(cwd).catch(() => null); - - if (!input.repoUrl) { - if (!stats) { - await fs.mkdir(cwd, { recursive: true }); - } - return { cwd, warning: null }; - } - - const hasAdoptableGitDir = () => - fs - .stat(path.resolve(cwd, ".git")) - .then((entry) => entry.isDirectory()) - .catch(() => false); - if (await hasAdoptableGitDir()) { - return { cwd, warning: null }; - } - - if (stats) { - const entries = await fs.readdir(cwd).catch(() => []); - if (entries.length > 0) { - return { - cwd, - warning: `Managed workspace path "${cwd}" already exists but is not a git checkout. Using it as-is.`, - }; - } - await fs.rm(cwd, { recursive: true, force: true }); - } - - // Clone into a temp sibling, then move into place atomically. The shared target directory - // is never created in a partial state and never removed on failure, so a concurrent - // materialization (another process, or a run racing this one) can neither adopt a broken - // checkout nor lose its own completed one. - const auth = input.resolveGitAuth && !input.localSource - ? await input.resolveGitAuth(input.repoUrl) - : null; - const cloneTmpDir = await fs.mkdtemp(`${cwd}.clone-`); - try { - await execFile( - "git", - [...(auth?.configArgs ?? []), "clone", "--no-hardlinks", "--", input.localSource ?? input.repoUrl, cloneTmpDir], - { - env: { - // Spread order matters: the sanitizer strips PAPERCLIP_*, which would remove the - // credential-helper token env if it came first. GIT_TERMINAL_PROMPT=0 fails a - // credential-less private clone immediately instead of hanging on a prompt until - // the clone timeout. - ...sanitizeRuntimeServiceBaseEnv(process.env), - GIT_TERMINAL_PROMPT: "0", - ...(auth?.env ?? {}), - }, - timeout: MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS, - }, - ); - if (input.localSource) { - const snapshot = await readGitWorkspaceSnapshot(input.localSource, false); - if (!snapshot) throw new Error("Configured repository folder is not a Git checkout"); - let baseline; - try { - baseline = await captureDirectorySnapshot(cloneTmpDir, { exclude: [".git", ".paperclip-runtime", PROJECT_REPOSITORIES_DIR], ignoredPaths: snapshot.ignoredPaths, diskBacked: true }); - await mergeDirectoryWithBaseline({ baseline, sourceDir: input.localSource, targetDir: cloneTmpDir }); - } finally { - if (baseline) await disposeDirectorySnapshot(baseline); - await disposeGitWorkspaceSnapshot(snapshot); - } - await execFile("git", ["-C", cloneTmpDir, "remote", "set-url", "origin", input.repoUrl], { timeout: 10_000 }); - } else if (input.repoRef) { - await execFile("git", ["-C", cloneTmpDir, "checkout", input.repoRef], { timeout: MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS }); - } - } catch (error) { - await fs - .rm(cloneTmpDir, { recursive: true, force: true }) - .catch(() => undefined); - const reason = error instanceof Error ? error.message : String(error); - const authNote = describeGitAuthFailure({ - error: reason, - used: auth ? { source: auth.source, secretName: auth.secretName } : null, - }); - const message = scrubGitCredentialText( - `Failed to prepare managed checkout for "${input.repoUrl}" at "${cwd}": ${reason}${authNote ? ` ${authNote}` : ""}`, - ); - // Preserve the closed failure code without copying subprocess output or - // credentials into the durable run. Setup recovery needs the actual cause. - if (isWorkspaceGitScanError(error)) throw new WorkspaceGitScanError(error.code, message); - throw new Error(message); - } - - try { - await fs.rename(cloneTmpDir, cwd); - } catch (renameError) { - await fs - .rm(cloneTmpDir, { recursive: true, force: true }) - .catch(() => undefined); - // The target appearing between the emptiness check and the rename means another - // materialization won the race; adopt its checkout instead of failing the run. - if (await hasAdoptableGitDir()) { - return { cwd, warning: null }; - } - const reason = - renameError instanceof Error ? renameError.message : String(renameError); - throw new Error( - `Failed to move managed checkout into place at "${cwd}": ${reason}`, - ); - } - return { cwd, warning: null }; -} - -/** 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; -}): Promise> { - const identity = (url: string) => url.trim().replace(/\.git\/?$/, "").replace(/\/$/, ""); - const seen = new Set(input.anchorRepoUrl ? [identity(input.anchorRepoUrl)] : []); - const selected = input.workspaces.filter((workspace) => { - if (!workspace.repoUrl || seen.has(identity(workspace.repoUrl))) return false; - seen.add(identity(workspace.repoUrl)); - return true; - }); - 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"); - } - const excludePath = await execFile("git", ["-C", input.cwd, "rev-parse", "--git-path", "info/exclude"], { timeout: 10_000 }) - .then((result) => path.resolve(input.cwd, result.stdout.trim())); - const exclude = await fs.readFile(excludePath, "utf8").catch(() => ""); - if (!exclude.split(/\r?\n/).includes(`/${PROJECT_REPOSITORIES_DIR}/`)) { - await fs.mkdir(path.dirname(excludePath), { recursive: true }); - await fs.appendFile(excludePath, `\n/${PROJECT_REPOSITORIES_DIR}/\n`); - } - const results = []; - for (const workspace of selected) { - const repoUrl = workspace.repoUrl!; - const name = (deriveRepoNameFromRepoUrl(repoUrl) ?? "repo").replace(/[^a-zA-Z0-9_-]/g, "-"); - const key = `${name}-${createHash("sha256").update(JSON.stringify([workspace.id, identity(repoUrl), workspace.repoRef, workspace.cwd ?? null])).digest("hex").slice(0, 12)}`; - const cwd = path.join(root, key); - const existing = await fs.lstat(cwd).catch(() => null); - if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) throw new Error("Invalid project repository checkout path"); - const localSource = workspace.cwd && workspace.cwd !== REPO_ONLY_CWD_SENTINEL - && await fs.stat(workspace.cwd).then((entry) => entry.isDirectory()).catch(() => false) - ? workspace.cwd : null; - const result = await materializeManagedProjectWorkspace(cwd, { repoUrl, repoRef: workspace.repoRef, localSource, resolveGitAuth: input.resolveGitAuth }); - if (result.warning) throw new Error(result.warning); - 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.includes(".clone-")) continue; - const retained = path.join(input.cwd, ".paperclip-runtime", "detached-repositories", randomUUID()); - await fs.mkdir(path.dirname(retained), { recursive: true }); - await fs.rename(path.join(root, entry), 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; -}): 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, - }); -} - -/** - * 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, -): 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, - resolveGitAuth: - input.resolveGitAuth ?? - resolveGitAuth ?? - createGitRemoteAuthProvider(db, input.companyId), - }), - ensureManagedProjectWorkspace: (input) => - ensureManagedProjectWorkspace({ - ...input, - 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; - }; - -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), - ); -} - -function isWorkspaceValidationFailedRun( - run: Pick | null | undefined, -) { - return run?.errorCode === WORKSPACE_VALIDATION_FAILURE_CODE; -} - -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); -} - -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}`; -} - function isConfigurationIncompleteFailure( error: unknown, ): error is ConfigurationIncompleteFailure { @@ -2884,477 +2157,6 @@ export function isConfigurationIncompleteFailedRun( ); } -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 = {}, - ) => { - throw new WorkspaceValidationFailure(message, { - 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, " ")}`; - 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 }, - ); - } - - 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) { - 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 && - 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), - }, - ); - } - } -} - const heartbeatRunProcessGroupIdColumn = heartbeatRuns.processGroupId ?? sql`NULL`.as("processGroupId"); @@ -3740,726 +2542,6 @@ interface ParsedIssueAssigneeAdapterOverrides { useProjectWorkspace: boolean | null; } -/** - * 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; -}; - -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[]; - /** - * 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" ->; - -/** - * 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 } : {}), - })), - }; -} - function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; } @@ -5538,81 +3620,6 @@ export function parseSessionCompactionPolicy( .policy; } -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}".`, - }; -} - function parseIssueAssigneeAdapterOverrides( raw: unknown, ): ParsedIssueAssigneeAdapterOverrides | null { @@ -5842,1159 +3849,6 @@ export function shouldDeferFollowupWakeForSameIssue(input: { return false; } -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]; - -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, - ), - ); -} - -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`, - }; -} - -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; - // 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; - 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; -} - -function readConfiguredModelFromAdapterConfig( - adapterConfig: Record | null | undefined, -) { - return readNonEmptyString(adapterConfig?.model); -} - -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, - }; -} - export function shouldAutoCheckoutIssueForWake(input: { contextSnapshot: Record | null | undefined; issueStatus: string | null; @@ -12132,405 +8986,10 @@ export function heartbeatService( }; } - 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; - // 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 = input.issueId && !input.immutableNativeBinding ? currentIssueSource?.projectId ?? null : input.projectId; - const explicitProjectWorkspaceId = currentIssueSource?.projectWorkspaceId ?? input.explicitProjectWorkspaceId; - const projectWorkspaceRows = await db.select().from(projectWorkspaces).where(and( - eq(projectWorkspaces.companyId, agent.companyId), - eq(projectWorkspaces.projectId, workspace.projectId), - )).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 ? resolveManagedProjectWorkspaceDir({ - companyId: agent.companyId, - projectId: workspace.projectId, - repoName: deriveRepoNameFromRepoUrl(workspace.repoUrl), - }) : null; - const candidateBaseCwds = [ - 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 ? async () => { - const original = await ensureManagedProjectWorkspace({ - companyId: agent.companyId, - projectId: workspace.projectId, - repoUrl: workspace.repoUrl, - 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 }, - ): 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, - }) - .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 preferredProjectWorkspaceId = - issueProjectRef?.projectWorkspaceId ?? contextProjectWorkspaceId ?? null; - const resolvedProjectId = issueProjectId ?? contextProjectId; - const useProjectWorkspace = opts?.useProjectWorkspace !== false; - const workspaceProjectId = useProjectWorkspace ? 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 projectWorkspaceRows = prioritizeProjectWorkspaceCandidatesForRun( - unorderedProjectWorkspaceRows, - preferredProjectWorkspaceId, - ); - - const workspaceHints = projectWorkspaceRows.map((workspace) => ({ - workspaceId: workspace.id, - cwd: readNonEmptyString(workspace.cwd), - repoUrl: readNonEmptyString(workspace.repoUrl), - repoRef: readNonEmptyString(workspace.repoRef), - })); - - if (projectWorkspaceRows.length > 0) { - 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.`; - } - for (const workspace of projectWorkspaceRows) { - 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, - }); - projectCwd = resolvedCwd.cwd; - managedWorkspaceWarning = resolvedCwd.warning; - } catch (error) { - const scrubbedError = scrubGitCredentialText( - error instanceof Error ? error.message : String(error), - ); - const workspaceRepoUrl = readNonEmptyString(workspace.repoUrl); - materializationFailures.push({ - projectWorkspaceId: workspace.id, - repoUrl: workspaceRepoUrl - ? scrubGitCredentialText(workspaceRepoUrl) - : null, - error: scrubbedError, - }); - 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 fallbackCwd = resolveDefaultAgentWorkspaceDir(agent.id); - await fs.mkdir(fallbackCwd, { recursive: true }); - const warnings = buildAnchorFallbackWorkspaceNotes({ - fallbackCwd, - preferredWorkspaceWarning, - materializationFailures, - missingProjectCwds, - hasConfiguredProjectCwd, - }); - return { - cwd: fallbackCwd, - source: "project_primary" as const, - projectId: resolvedProjectId, - workspaceId: projectWorkspaceRows[0]?.id ?? null, - repoUrl: projectWorkspaceRows[0]?.repoUrl ?? null, - repoRef: projectWorkspaceRows[0]?.repoRef ?? null, - workspaceHints, - warnings, - baseCwdFallback: true, - materializationFailures, - }; - } - - if (workspaceProjectId) { - const managedWorkspace = await ensureManagedProjectWorkspace({ - companyId: agent.companyId, - projectId: workspaceProjectId, - repoUrl: null, - }); - return { - cwd: managedWorkspace.cwd, - source: "project_primary" as const, - projectId: resolvedProjectId, - workspaceId: null, - repoUrl: null, - repoRef: null, - workspaceHints, - warnings: managedWorkspace.warning ? [managedWorkspace.warning] : [], - baseCwdFallback: false, - materializationFailures: [], - }; - } - - const sessionCwd = readNonEmptyString(previousSessionParams?.cwd); - const sessionCwdLooksUnsafe = isUnsafeSessionWorkspaceCwd(sessionCwd); - if (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 cwd = 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: "agent_home" as const, - projectId: resolvedProjectId, - 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; - executionEnvironmentDriver?: 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 resolveGitAuth = createGitRemoteAuthProvider(db, agent.companyId, { - issueId, - heartbeatRunId: readNonEmptyString(context.executionIdentityRunId), - responsibleUserId: - readNonEmptyString(context.responsibleUserId) ?? - readNonEmptyString(context.responsible_user_id), - agentId: agent.id, - }); - const { additionalWorkspaces, warnings, failures } = - await resolveAdditionalRunWorkspaces(issueId, anchor.projectId, { - enabled: true, - executionTargetIsRemote: isRemoteExecutionEnvironmentDriver( - executionEnvironmentDriver, - ), - targetStagesConfined: isConfinedRemoteStagingDriver( - executionEnvironmentDriver, - ), - remoteReferencedSyncEnabled: isMultiProjectWorkspaceSyncRemoteEnabled(), - companyId: agent.companyId, - actor: { - type: "agent", - agentId: agent.id, - companyId: agent.companyId, - source: "agent_key", - }, - issues: issueService(db), - projects: projectService(db), - access: authorizationService(db), - resolveProjectWorkspace: (project) => - resolveAdditionalProjectWorkspace( - { companyId: agent.companyId, project }, - defaultAdditionalProjectWorkspaceDeps(db, resolveGitAuth), - ), - }); - - return { - ...anchor, - additionalWorkspaces, - referencedProjectFailures: failures, - warnings: - warnings.length > 0 - ? [...anchor.warnings, ...warnings] - : anchor.warnings, - }; - } + const { + resolveReusedGitWorkspaceAnchor, + resolveWorkspaceForRun, + } = createHeartbeatWorkspaceResolver(db); async function upsertTaskSession(input: { companyId: string; diff --git a/server/src/services/heartbeat/workspaces.test.ts b/server/src/services/heartbeat/workspaces.test.ts new file mode 100644 index 00000000000..7dc7f09f827 --- /dev/null +++ b/server/src/services/heartbeat/workspaces.test.ts @@ -0,0 +1,181 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import type { agents, Db, projectWorkspaces } from "@paperclipai/db"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + ensureManagedProjectWorkspace as legacyEnsureManagedProjectWorkspace, + WorkspaceValidationFailure as LegacyWorkspaceValidationFailure, +} from "../heartbeat.js"; +import { resolveDefaultAgentWorkspaceDir } from "../../home-paths.js"; +import { + createHeartbeatWorkspaceResolver, + ensureManagedProjectWorkspace, + WorkspaceValidationFailure, +} from "./workspaces.js"; + +const runGit = promisify(execFile); +let testRoot: string; + +beforeEach(async () => { + testRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-module-")); + vi.stubEnv("PAPERCLIP_HOME", testRoot); + vi.stubEnv("PAPERCLIP_INSTANCE_ID", "workspace-test"); + vi.stubEnv("PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC", "false"); +}); + +afterEach(async () => { + vi.unstubAllEnvs(); + await fs.rm(testRoot, { recursive: true, force: true }); +}); + +function agent(id = "agent-one") { + return { id, companyId: "company-one" } as typeof agents.$inferSelect; +} + +function workspace(id: string, projectId: string, cwd: string) { + return { + id, projectId, companyId: "company-one", cwd, + repoUrl: null, repoRef: null, sourceType: "local_path", + } as typeof projectWorkspaces.$inferSelect; +} + +// The existing service integration suites cover the SQL and authorization rules. +// These query results exercise the new factory's database binding and call order. +function databaseWithResults(...results: unknown[][]) { + const select = vi.fn(() => { + const rows = results.shift() ?? []; + const query = { + from: () => query, + where: () => query, + orderBy: async () => rows, + then: (resolve: (value: unknown[]) => unknown) => Promise.resolve(rows).then(resolve), + }; + return query; + }); + return { db: { select } as unknown as Db, select }; +} + +function deferred() { + let resolve!: () => void; + const promise = new Promise(complete => { resolve = complete; }); + return { promise, resolve }; +} + +async function createRepository(parent: string, text: string) { + const cwd = path.join(testRoot, parent, "repo"); + await fs.mkdir(cwd, { recursive: true }); + await runGit("git", ["init", cwd]); + await runGit("git", ["-C", cwd, "config", "user.email", "workspace-test@example.com"]); + await runGit("git", ["-C", cwd, "config", "user.name", "Workspace Test"]); + await fs.writeFile(path.join(cwd, "README.md"), text); + await runGit("git", ["-C", cwd, "add", "README.md"]); + await runGit("git", ["-C", cwd, "commit", "-m", "initial"]); + return cwd; +} + +describe("heartbeat workspace module", () => { + it("preserves the legacy error class and checkout entry point", () => { + expect(LegacyWorkspaceValidationFailure).toBe(WorkspaceValidationFailure); + expect(legacyEnsureManagedProjectWorkspace).toBe(ensureManagedProjectWorkspace); + const failure = new WorkspaceValidationFailure("invalid checkout", { reason: "test" }); + expect(failure).toBeInstanceOf(LegacyWorkspaceValidationFailure); + expect(failure.code).toBe("workspace_validation_failed"); + }); + + it("binds each resolver to its own database", async () => { + const firstCwd = path.join(testRoot, "first"); + const secondCwd = path.join(testRoot, "second"); + await Promise.all([fs.mkdir(firstCwd), fs.mkdir(secondCwd)]); + const first = databaseWithResults([workspace("workspace-one", "project-one", firstCwd)]); + const second = databaseWithResults([workspace("workspace-two", "project-two", secondCwd)]); + const [firstResult, secondResult] = await Promise.all([ + createHeartbeatWorkspaceResolver(first.db).resolveWorkspaceForRun(agent(), { projectId: "project-one" }, null), + createHeartbeatWorkspaceResolver(second.db).resolveWorkspaceForRun(agent(), { projectId: "project-two" }, null), + ]); + expect(firstResult).toMatchObject({ cwd: firstCwd, projectId: "project-one", workspaceId: "workspace-one", source: "project_primary", additionalWorkspaces: [] }); + expect(secondResult).toMatchObject({ cwd: secondCwd, projectId: "project-two", workspaceId: "workspace-two", source: "project_primary", additionalWorkspaces: [] }); + expect(first.select).toHaveBeenCalledTimes(1); + expect(second.select).toHaveBeenCalledTimes(1); + }); + + it("uses the current issue's project and selected workspace over stale context", async () => { + const selectedCwd = path.join(testRoot, "selected"); + await fs.mkdir(selectedCwd); + const database = databaseWithResults( + [{ projectId: "current-project", projectWorkspaceId: "selected-workspace" }], + [workspace("default-workspace", "current-project", path.join(testRoot, "missing")), workspace("selected-workspace", "current-project", selectedCwd)], + ); + const result = await createHeartbeatWorkspaceResolver(database.db).resolveWorkspaceForRun( + agent(), + { issueId: "issue-one", projectId: "stale-project", projectWorkspaceId: "stale-workspace" }, + null, + ); + expect(result).toMatchObject({ cwd: selectedCwd, projectId: "current-project", workspaceId: "selected-workspace", warnings: [], baseCwdFallback: false }); + expect(database.select).toHaveBeenCalledTimes(2); + }); + + it("retains a valid prior session workspace and falls back when it disappears", async () => { + const priorCwd = path.join(testRoot, "prior-session"); + await fs.mkdir(priorCwd); + const database = databaseWithResults(); + const resolver = createHeartbeatWorkspaceResolver(database.db); + const previousSession = { cwd: priorCwd, workspaceId: "previous-workspace" }; + expect(await resolver.resolveWorkspaceForRun(agent(), {}, previousSession)).toMatchObject({ cwd: priorCwd, source: "task_session", workspaceId: "previous-workspace", warnings: [] }); + await fs.rm(priorCwd, { recursive: true }); + const fallback = await resolver.resolveWorkspaceForRun(agent(), {}, previousSession); + expect(fallback).toMatchObject({ cwd: resolveDefaultAgentWorkspaceDir(agent().id), source: "agent_home", workspaceId: null }); + expect(fallback.warnings).toEqual([expect.stringContaining("is not available")]); + expect((await fs.stat(fallback.cwd)).isDirectory()).toBe(true); + expect(database.select).not.toHaveBeenCalled(); + }); + + it("shares one in-flight checkout across legacy and direct module callers", async () => { + const repoUrl = await createRepository("source", "shared checkout\n"); + const started = deferred(); + const release = deferred(); + const resolveGitAuth = vi.fn(async () => { + started.resolve(); + await release.promise; + return null; + }); + const input = { companyId: "company-one", projectId: "shared-project", repoUrl, resolveGitAuth }; + const attempts = Array.from({ length: 8 }, (_, index) => + (index % 2 ? legacyEnsureManagedProjectWorkspace : ensureManagedProjectWorkspace)(input), + ); + await started.promise; + release.resolve(); + const results = await Promise.all(attempts); + expect(resolveGitAuth).toHaveBeenCalledTimes(1); + expect(new Set(results.map(result => result.cwd)).size).toBe(1); + expect(await fs.readFile(path.join(results[0]!.cwd, "README.md"), "utf8")).toBe("shared checkout\n"); + expect((await runGit("git", ["-C", results[0]!.cwd, "remote", "get-url", "origin"])).stdout.trim()).toBe(repoUrl); + }); + + it("keeps concurrent repositories with the same basename in separate checkouts", async () => { + const [firstRepo, secondRepo] = await Promise.all([ + createRepository("first-source", "first repository\n"), + createRepository("second-source", "second repository\n"), + ]); + const [first, second] = await Promise.all([firstRepo, secondRepo].map(repoUrl => + ensureManagedProjectWorkspace({ companyId: "company-one", projectId: "shared-project", repoUrl }), + )); + expect(first!.cwd).not.toBe(second!.cwd); + expect(await fs.readFile(path.join(first!.cwd, "README.md"), "utf8")).toBe("first repository\n"); + expect(await fs.readFile(path.join(second!.cwd, "README.md"), "utf8")).toBe("second repository\n"); + }); + + it("releases a failed checkout attempt so the next caller can retry", async () => { + const repoUrl = await createRepository("retry-source", "retry succeeded\n"); + const resolveGitAuth = vi.fn() + .mockRejectedValueOnce(new Error("credential lookup failed")) + .mockResolvedValue(null); + const input = { companyId: "company-one", projectId: "retry-project", repoUrl, resolveGitAuth }; + await expect(ensureManagedProjectWorkspace(input)).rejects.toThrow("credential lookup failed"); + const result = await legacyEnsureManagedProjectWorkspace(input); + expect(resolveGitAuth).toHaveBeenCalledTimes(2); + expect(await fs.readFile(path.join(result.cwd, "README.md"), "utf8")).toBe("retry succeeded\n"); + }); +}); diff --git a/server/src/services/heartbeat/workspaces.ts b/server/src/services/heartbeat/workspaces.ts new file mode 100644 index 00000000000..5893ca1e7c6 --- /dev/null +++ b/server/src/services/heartbeat/workspaces.ts @@ -0,0 +1,3759 @@ +import { managedAiSessionFingerprintConfig } from "../ai-connection-runtime.js"; +import { + PROJECT_REPOSITORIES_DIR, + readGitWorkspaceSnapshot, + disposeGitWorkspaceSnapshot, +} from "@paperclipai/adapter-utils/git-workspace-sync"; +import { + isWorkspaceGitScanError, + WorkspaceGitScanError, +} from "../workspace-git-operation-scheduler.js"; +import { + captureDirectorySnapshot, + disposeDirectorySnapshot, + mergeDirectoryWithBaseline, +} from "@paperclipai/adapter-utils/workspace-restore-merge"; +import fs from "node:fs/promises"; +import path from "node:path"; +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, + describeGitAuthFailure, + 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, + sanitizeRuntimeServiceBaseEnv, +} from "../workspace-runtime.js"; +import { resolvePersistedGitWorkspaceSource } from "../persisted-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__"; + +const MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS = 10 * 60 * 1000; + +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 stripHostWorkspaceProvisionForLowTrustSandbox(input: { + config: Record; + trustPreset: TrustPresetResolution; + selectedEnvironmentDriver: string | null | undefined; +}): Record { + if (input.trustPreset.kind !== "low_trust_review") return input.config; + if (input.selectedEnvironmentDriver !== "sandbox") return input.config; + + const workspaceStrategy = parseObject(input.config.workspaceStrategy); + if ( + typeof workspaceStrategy.provisionCommand !== "string" && + typeof workspaceStrategy.runtimeProvisionCommand !== "string" + ) + return input.config; + + const nextWorkspaceStrategy = { ...workspaceStrategy }; + delete nextWorkspaceStrategy.provisionCommand; + delete nextWorkspaceStrategy.runtimeProvisionCommand; + + return { + ...input.config, + workspaceStrategy: nextWorkspaceStrategy, + }; +} + +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(), + }; +} + +function deriveRepoNameFromRepoUrl(repoUrl: string | null): string | null { + const trimmed = repoUrl?.trim() ?? ""; + if (!trimmed) return null; + try { + const parsed = new URL(trimmed); + const cleanedPath = parsed.pathname.replace(/\/+$/, ""); + const repoName = + cleanedPath + .split("/") + .filter(Boolean) + .pop() + ?.replace(/\.git$/i, "") ?? ""; + return repoName || null; + } catch { + return null; + } +} + +/** + * 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; + /** 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 attempt = materializeManagedProjectWorkspace(cwd, input).finally(() => { + managedCheckoutMaterializations.delete(cwd); + }); + managedCheckoutMaterializations.set(cwd, attempt); + const result = await attempt; + 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; +} + +async function materializeManagedProjectWorkspace( + cwd: string, + input: { + repoUrl: string | null; + repoRef?: string | null; + localSource?: string | null; + resolveGitAuth?: GitRemoteAuthProvider | null; + }, +): Promise<{ cwd: string; warning: string | null }> { + await fs.mkdir(path.dirname(cwd), { recursive: true }); + const stats = await fs.stat(cwd).catch(() => null); + + if (!input.repoUrl) { + if (!stats) { + await fs.mkdir(cwd, { recursive: true }); + } + return { cwd, warning: null }; + } + + const hasAdoptableGitDir = () => + fs + .stat(path.resolve(cwd, ".git")) + .then((entry) => entry.isDirectory()) + .catch(() => false); + if (await hasAdoptableGitDir()) { + return { cwd, warning: null }; + } + + if (stats) { + const entries = await fs.readdir(cwd).catch(() => []); + if (entries.length > 0) { + return { + cwd, + warning: `Managed workspace path "${cwd}" already exists but is not a git checkout. Using it as-is.`, + }; + } + await fs.rm(cwd, { recursive: true, force: true }); + } + + // Clone into a temp sibling, then move into place atomically. The shared target directory + // is never created in a partial state and never removed on failure, so a concurrent + // materialization (another process, or a run racing this one) can neither adopt a broken + // checkout nor lose its own completed one. + const auth = input.resolveGitAuth && !input.localSource + ? await input.resolveGitAuth(input.repoUrl) + : null; + const cloneTmpDir = await fs.mkdtemp(`${cwd}.clone-`); + try { + await execFile( + "git", + [...(auth?.configArgs ?? []), "clone", "--no-hardlinks", "--", input.localSource ?? input.repoUrl, cloneTmpDir], + { + env: { + // Spread order matters: the sanitizer strips PAPERCLIP_*, which would remove the + // credential-helper token env if it came first. GIT_TERMINAL_PROMPT=0 fails a + // credential-less private clone immediately instead of hanging on a prompt until + // the clone timeout. + ...sanitizeRuntimeServiceBaseEnv(process.env), + GIT_TERMINAL_PROMPT: "0", + ...(auth?.env ?? {}), + }, + timeout: MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS, + }, + ); + if (input.localSource) { + const snapshot = await readGitWorkspaceSnapshot(input.localSource, false); + if (!snapshot) throw new Error("Configured repository folder is not a Git checkout"); + let baseline; + try { + baseline = await captureDirectorySnapshot(cloneTmpDir, { exclude: [".git", ".paperclip-runtime", PROJECT_REPOSITORIES_DIR], ignoredPaths: snapshot.ignoredPaths, diskBacked: true }); + await mergeDirectoryWithBaseline({ baseline, sourceDir: input.localSource, targetDir: cloneTmpDir }); + } finally { + if (baseline) await disposeDirectorySnapshot(baseline); + await disposeGitWorkspaceSnapshot(snapshot); + } + await execFile("git", ["-C", cloneTmpDir, "remote", "set-url", "origin", input.repoUrl], { timeout: 10_000 }); + } else if (input.repoRef) { + await execFile("git", ["-C", cloneTmpDir, "checkout", input.repoRef], { timeout: MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS }); + } + } catch (error) { + await fs + .rm(cloneTmpDir, { recursive: true, force: true }) + .catch(() => undefined); + const reason = error instanceof Error ? error.message : String(error); + const authNote = describeGitAuthFailure({ + error: reason, + used: auth ? { source: auth.source, secretName: auth.secretName } : null, + }); + const message = scrubGitCredentialText( + `Failed to prepare managed checkout for "${input.repoUrl}" at "${cwd}": ${reason}${authNote ? ` ${authNote}` : ""}`, + ); + // Preserve the closed failure code without copying subprocess output or + // credentials into the durable run. Setup recovery needs the actual cause. + if (isWorkspaceGitScanError(error)) throw new WorkspaceGitScanError(error.code, message); + throw new Error(message); + } + + try { + await fs.rename(cloneTmpDir, cwd); + } catch (renameError) { + await fs + .rm(cloneTmpDir, { recursive: true, force: true }) + .catch(() => undefined); + // The target appearing between the emptiness check and the rename means another + // materialization won the race; adopt its checkout instead of failing the run. + if (await hasAdoptableGitDir()) { + return { cwd, warning: null }; + } + const reason = + renameError instanceof Error ? renameError.message : String(renameError); + throw new Error( + `Failed to move managed checkout into place at "${cwd}": ${reason}`, + ); + } + return { cwd, warning: null }; +} + +/** 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; +}): Promise> { + const identity = (url: string) => url.trim().replace(/\.git\/?$/, "").replace(/\/$/, ""); + const seen = new Set(input.anchorRepoUrl ? [identity(input.anchorRepoUrl)] : []); + const selected = input.workspaces.filter((workspace) => { + if (!workspace.repoUrl || seen.has(identity(workspace.repoUrl))) return false; + seen.add(identity(workspace.repoUrl)); + return true; + }); + 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"); + } + const excludePath = await execFile("git", ["-C", input.cwd, "rev-parse", "--git-path", "info/exclude"], { timeout: 10_000 }) + .then((result) => path.resolve(input.cwd, result.stdout.trim())); + const exclude = await fs.readFile(excludePath, "utf8").catch(() => ""); + if (!exclude.split(/\r?\n/).includes(`/${PROJECT_REPOSITORIES_DIR}/`)) { + await fs.mkdir(path.dirname(excludePath), { recursive: true }); + await fs.appendFile(excludePath, `\n/${PROJECT_REPOSITORIES_DIR}/\n`); + } + const results = []; + for (const workspace of selected) { + const repoUrl = workspace.repoUrl!; + const name = (deriveRepoNameFromRepoUrl(repoUrl) ?? "repo").replace(/[^a-zA-Z0-9_-]/g, "-"); + const key = `${name}-${createHash("sha256").update(JSON.stringify([workspace.id, identity(repoUrl), workspace.repoRef, workspace.cwd ?? null])).digest("hex").slice(0, 12)}`; + const cwd = path.join(root, key); + const existing = await fs.lstat(cwd).catch(() => null); + if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) throw new Error("Invalid project repository checkout path"); + const localSource = workspace.cwd && workspace.cwd !== REPO_ONLY_CWD_SENTINEL + && await fs.stat(workspace.cwd).then((entry) => entry.isDirectory()).catch(() => false) + ? workspace.cwd : null; + const result = await materializeManagedProjectWorkspace(cwd, { repoUrl, repoRef: workspace.repoRef, localSource, resolveGitAuth: input.resolveGitAuth }); + if (result.warning) throw new Error(result.warning); + 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.includes(".clone-")) continue; + const retained = path.join(input.cwd, ".paperclip-runtime", "detached-repositories", randomUUID()); + await fs.mkdir(path.dirname(retained), { recursive: true }); + await fs.rename(path.join(root, entry), 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; +}): 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, + }); +} + +/** + * 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, +): 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, + resolveGitAuth: + input.resolveGitAuth ?? + resolveGitAuth ?? + createGitRemoteAuthProvider(db, input.companyId), + }), + ensureManagedProjectWorkspace: (input) => + ensureManagedProjectWorkspace({ + ...input, + 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 = {}, + ) => { + throw new WorkspaceValidationFailure(message, { + 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, " ")}`; + 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 }, + ); + } + + 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) { + 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 && + 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; +}; + +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[]; + /** + * 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" +>; + +/** + * 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; + // 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; + 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) { + 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; + // 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 = input.issueId && !input.immutableNativeBinding ? currentIssueSource?.projectId ?? null : input.projectId; + const explicitProjectWorkspaceId = currentIssueSource?.projectWorkspaceId ?? input.explicitProjectWorkspaceId; + const projectWorkspaceRows = await db.select().from(projectWorkspaces).where(and( + eq(projectWorkspaces.companyId, agent.companyId), + eq(projectWorkspaces.projectId, workspace.projectId), + )).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 ? resolveManagedProjectWorkspaceDir({ + companyId: agent.companyId, + projectId: workspace.projectId, + repoName: deriveRepoNameFromRepoUrl(workspace.repoUrl), + }) : null; + const candidateBaseCwds = [ + 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 ? async () => { + const original = await ensureManagedProjectWorkspace({ + companyId: agent.companyId, + projectId: workspace.projectId, + repoUrl: workspace.repoUrl, + 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 }, + ): 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, + }) + .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 preferredProjectWorkspaceId = + issueProjectRef?.projectWorkspaceId ?? contextProjectWorkspaceId ?? null; + const resolvedProjectId = issueProjectId ?? contextProjectId; + const useProjectWorkspace = opts?.useProjectWorkspace !== false; + const workspaceProjectId = useProjectWorkspace ? 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 projectWorkspaceRows = prioritizeProjectWorkspaceCandidatesForRun( + unorderedProjectWorkspaceRows, + preferredProjectWorkspaceId, + ); + + const workspaceHints = projectWorkspaceRows.map((workspace) => ({ + workspaceId: workspace.id, + cwd: readNonEmptyString(workspace.cwd), + repoUrl: readNonEmptyString(workspace.repoUrl), + repoRef: readNonEmptyString(workspace.repoRef), + })); + + if (projectWorkspaceRows.length > 0) { + 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.`; + } + for (const workspace of projectWorkspaceRows) { + 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, + }); + projectCwd = resolvedCwd.cwd; + managedWorkspaceWarning = resolvedCwd.warning; + } catch (error) { + const scrubbedError = scrubGitCredentialText( + error instanceof Error ? error.message : String(error), + ); + const workspaceRepoUrl = readNonEmptyString(workspace.repoUrl); + materializationFailures.push({ + projectWorkspaceId: workspace.id, + repoUrl: workspaceRepoUrl + ? scrubGitCredentialText(workspaceRepoUrl) + : null, + error: scrubbedError, + }); + 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 fallbackCwd = resolveDefaultAgentWorkspaceDir(agent.id); + await fs.mkdir(fallbackCwd, { recursive: true }); + const warnings = buildAnchorFallbackWorkspaceNotes({ + fallbackCwd, + preferredWorkspaceWarning, + materializationFailures, + missingProjectCwds, + hasConfiguredProjectCwd, + }); + return { + cwd: fallbackCwd, + source: "project_primary" as const, + projectId: resolvedProjectId, + workspaceId: projectWorkspaceRows[0]?.id ?? null, + repoUrl: projectWorkspaceRows[0]?.repoUrl ?? null, + repoRef: projectWorkspaceRows[0]?.repoRef ?? null, + workspaceHints, + warnings, + baseCwdFallback: true, + materializationFailures, + }; + } + + if (workspaceProjectId) { + const managedWorkspace = await ensureManagedProjectWorkspace({ + companyId: agent.companyId, + projectId: workspaceProjectId, + repoUrl: null, + }); + return { + cwd: managedWorkspace.cwd, + source: "project_primary" as const, + projectId: resolvedProjectId, + workspaceId: null, + repoUrl: null, + repoRef: null, + workspaceHints, + warnings: managedWorkspace.warning ? [managedWorkspace.warning] : [], + baseCwdFallback: false, + materializationFailures: [], + }; + } + + const sessionCwd = readNonEmptyString(previousSessionParams?.cwd); + const sessionCwdLooksUnsafe = isUnsafeSessionWorkspaceCwd(sessionCwd); + if (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 cwd = 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: "agent_home" as const, + projectId: resolvedProjectId, + 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; + executionEnvironmentDriver?: 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 resolveGitAuth = createGitRemoteAuthProvider(db, agent.companyId, { + issueId, + heartbeatRunId: readNonEmptyString(context.executionIdentityRunId), + responsibleUserId: + readNonEmptyString(context.responsibleUserId) ?? + readNonEmptyString(context.responsible_user_id), + agentId: agent.id, + }); + const { additionalWorkspaces, warnings, failures } = + await resolveAdditionalRunWorkspaces(issueId, anchor.projectId, { + enabled: true, + executionTargetIsRemote: isRemoteExecutionEnvironmentDriver( + executionEnvironmentDriver, + ), + targetStagesConfined: isConfinedRemoteStagingDriver( + executionEnvironmentDriver, + ), + remoteReferencedSyncEnabled: isMultiProjectWorkspaceSyncRemoteEnabled(), + companyId: agent.companyId, + actor: { + type: "agent", + agentId: agent.id, + companyId: agent.companyId, + source: "agent_key", + }, + issues: issueService(db), + projects: projectService(db), + access: authorizationService(db), + resolveProjectWorkspace: (project) => + resolveAdditionalProjectWorkspace( + { companyId: agent.companyId, project }, + defaultAdditionalProjectWorkspaceDeps(db, resolveGitAuth), + ), + }); + + return { + ...anchor, + additionalWorkspaces, + referencedProjectFailures: failures, + warnings: + warnings.length > 0 + ? [...anchor.warnings, ...warnings] + : anchor.warnings, + }; + } + + return { + resolveReusedGitWorkspaceAnchor, + resolveWorkspaceForRun, + }; +}