Files
PaperClipAI/server/src/services/heartbeat/workspaces.ts
T
DottaandPaperclip 84aa989bf9 Preserve native sessions through legacy workspace enrollment (#18943)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A native session retains its workspace and provider session across
turns.
> - An ephemeral web server must restore those files without its
predecessor's disk.
> - Legacy enrollment changes the workspace authority and can change
both workspace addresses.
> - The retained provider backup checks those addresses as part of its
identity.
> - This pull request preserves the exact addresses in a verified
migration receipt.
> - The benefit is that the next turn can use the original session after
enrollment.

## Linked Issues or Issue Description

Refs #18932, merged as `612d9931a6`. This pull request builds on that
all-Runner durability change. Related: #18918 fixes concurrent workspace
preservation locks. This change covers identity preservation during
offline enrollment.

## What Changed

- The native workspace migration owner records the selected session
bindings in the successful workspace migration operation.
- Runtime selection retains the original logical address only for the
exact agent, task and session.
- Remote workspace preparation restores the original provider address.
It refuses conflicting targets, forged receipts and occupied unowned
roots.
- The environment owner includes the migration operation in the reusable
lease fingerprint. Warm policy stays warm and receives a fresh
allocation through normal lease cleanup.
- Exact migration evidence permits the existing verified provider backup
to restore into that fresh warm allocation.
- Preserve verified provider allocation files through a normalized,
lease-bound digest receipt. Unknown files and altered markers remain
refused; interrupted publication is retryable.
- Retain legacy managed-instruction session fingerprints using the exact
imported content hash and recorded paths, preserving all other
configuration and credential checks.
- Migrate existing local skill archive/manifest objects through upload
claims, readback verification and receipt compare-and-swap; retain
original objects and IDs.
- Add explicit two-turn browser qualification for every released native
Daytona profile, with warm and per-turn variants. The production
maintenance entry runs offline. The old home is deleted only after a
verified export and successor readiness. The oracle checks provider
identity, native session, addresses, reference bytes, question and final
document.

## Verification

- Full repository typecheck passed at `b12ecd823e`. The production
session fix passed 198 session/credential tests. Full repository build
passed at `94bb917947`. The final Rust-only provider fix passed 57 ACPX
provider tests and the exact Linux release build.
- Workspace and transport checks passed: 117 transport/workspace tests,
six final real PostgreSQL migration boundary cases, 178 environment
tests, 539 Daytona tests (eight skips), and the native warm-backup
admission control.
- Local skill migration passed eight PostgreSQL/S3 tests (one existing
opt-in skip). Failed readback retains the source. Successful readback
still works after deletion of the old home.
- Latest Product E2E typecheck and 38 focused harness tests pass.
Earlier catalog/admission/report checks passed. Twelve unrelated
support-test failures reproduce on unchanged base `1a39dea9aa`; these
are not reported as passing.
- Live Codex per-turn at `2d5bb9a6d1` retained the native and provider
sessions, both workspace paths and exact reference bytes after deletion
of the old home. It completed the task and delivered its saved document
and original reference. Its original failure was an output-count grading
defect. The corrected grader passes all 21 checks against retained,
hashed evidence; the original result remains unchanged.
- Live OpenCode per-turn and Codex warm at harness `94bb917947` pass all
21 product checks. Their launcher cleanup failed because a read-only
diagnostic observer kept its disposable database open. The observer now
closes before ordinary database cleanup; a no-provider reproduction
confirms the fix. All owned sandboxes were independently verified
absent, child keys revoked and buckets removed. Original failed campaign
dispositions remain unchanged.
- Claude at `94bb917947` kept its sessions and reference bytes but
omitted the final clickable document link. That user-delivery failure
remains recorded. The fixture now explicitly asks for the link and
records migration evidence before checking navigation. The fresh Claude
attempt at `b12ecd823e` passed both strict migration checks: exact
provider/native session, both paths, original file bytes and
independently downloaded attachment. Its full browser workflow failed
again: it requested unnecessary confirmation, used the answer sentence
as the reference, and omitted the clickable document link. This is
retained as a failed user-workflow result, not a full pass.
- Earlier live failures exposed and reproduced the skill-object,
fresh-allocation marker and managed-instruction fingerprint bugs. The
fixes retain original identity checks. No provider session reset or
accounting bypass was used.
- The real Grok continuation at `b12ecd823e` failed before provider
execution because its immutable-instruction attach policy rejected
relocated registered file copies. A no-provider regression reproduced
the exact rejection. `b05ba3728a` selects the existing strict
authenticated asset-path policy for Grok; content digests, unknown
context fields, session/workspace identity and same-run mutations remain
enforced. The regression and all 57 ACPX provider controls pass. The
Grok browser migration now passes all 21 checks with the b05 Linux
Runner, including provider identity, exact reference bytes and final
user output; the launcher also reports successful cleanup.
- `24fabf1d81` applies the same policy to released ACPX Codex after a
second no-provider reproduction. All 58 ACPX provider controls and the
final Linux release build pass. Its live browser migration passes all 21
checks on the final binary, including exact original session/file
preservation and completed user output.
- The full native-session executor suite passes all 1,121 tests after
updating its existing lease database fixture for the migration-receipt
join. The prior CI failure was this unsupported test-double method; the
production guard still rejects ordinary unproven recovery. CI and
Greptile 5/5 passed at `ca1e0d5fe6`. The first CI attempt lost several
workers and had one rotated-recovery transport failure; its seven
descriptor cases passed locally, and the single unchanged retry passed.
The original failure remains recorded. Staging enrollment has not
started. Initial offline enrollment and subsequent rolling availability
are separate checks.

- The final review follow-up adds real runtime-selection PostgreSQL
cases for exact, different-session and absent receipts, plus
executor-level warm restoration that copies actual backup files and
verifies provider identity and the new lease stamp. Both complete
affected suites pass (1,172 tests), and full repository typecheck
passes. Removing either production migration connection independently
makes its new positive case fail; both source files were restored
byte-for-byte after these negative controls.

- Final commit `0971d373a3` passes all 63 checks (57 successes, five
expected skips, one neutral); Greptile is 5/5 and every review thread is
resolved.

## Risks

- An identity conflict blocks enrollment and keeps the original files.
It does not silently reset the session.
- A new migration generation retires a reusable lease through its
existing owner. The original sandbox must finish normal cleanup before
reuse proceeds.
- Initial enrollment has an offline step. The separate rolling suites
prove uninterrupted deploy behavior after enrollment.
- This adds no schema migration and changes no provider credentials or
accounting gate.

## Model Used

OpenAI Codex, GPT-6 family. The exact serving model build and context
window are not exposed in this session. The agent used reasoning,
terminal tools, code edits, PostgreSQL/filesystem tests and isolated
live browser qualification.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-04 04:43:56 -06:00

3805 lines
151 KiB
TypeScript

import { effectiveProjectWorkspacePolicy } from "../project-workspace-provider.js";
import { canonicalRepositoryRoot } from "../execution-workspace-source-roots.js";
import { assertTaskWorkspaceExecutionAccess } from "../task-workspace-access.js";
import { selectRestoredProjectRepositories, type GitWorkspaceContents } from "../git-workspace-contents.js";
import { GitCacheRestorationFailure } from "../git-workspace-restoration.js";
import { deriveRepoNameFromRepoUrl, managedProjectCheckoutName, projectRepositoryIdentity } from "../managed-project-checkout-name.js";
import { forbidden } from "../../errors.js";
import { readGitConnectionFailure } from "../git-connection-failure.js";
import { materializeManagedProjectWorkspace, ensureManagedRepositoriesIgnored } from "../managed-repository-checkout.js";
import { addNestedRepositoryWorktree, isVerifiedNestedRepositoryBase, nestedRepositoryBaseCandidates, readLinkedNestedCheckout, repairMovedNestedRepositoryWorktree } from "../nested-repository-worktrees.js";
export { materializeManagedProjectWorkspace } from "../managed-repository-checkout.js";
import { sql } from "drizzle-orm";
import { materializeIsolatedTaskDirectory } from "../isolated-task-directory.js";
import { readConnectionFailure, type GitConnectionFailure } from "@paperclipai/adapter-utils/connection-failure";
import { managedAiSessionFingerprintConfig } from "../ai-connection-runtime.js";
import {
PROJECT_REPOSITORIES_DIR,
readGitWorkspaceSnapshot,
} from "@paperclipai/adapter-utils/git-workspace-sync";
import fs from "node:fs/promises";
import path from "node:path";
import { localRepositoryPath } from "../local-repository-path.js";
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
import {
createHash,
randomUUID,
} from "node:crypto";
import {
and,
asc,
eq,
} from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
type ExecutionWorkspace,
type ExecutionWorkspaceConfig,
} from "@paperclipai/shared";
import {
agents,
heartbeatRuns,
issues,
projects,
projectWorkspaces,
} from "@paperclipai/db";
import { logger } from "../../middleware/logger.js";
import {
createGitRemoteAuthProvider,
scrubGitCredentialText,
type GitRemoteAuthProvider,
} from "../git-credentials.js";
import {
parseObject,
asNumber,
} from "../../adapters/utils.js";
import {
resolveDefaultAgentWorkspaceDir,
resolveManagedProjectWorkspaceDir,
resolvePaperclipInstanceRoot,
} from "../../home-paths.js";
import {
formatManagedGitWorktreeBranchInspection,
inspectManagedGitWorktreeBranch,
type ExecutionWorkspaceInput,
type RealizedExecutionWorkspace,
} from "../workspace-runtime.js";
import { resolvePersistedGitWorkspaceSource } from "../persisted-workspace-source.js";
import { verifiedRecordedIsolatedRemoteSource } from "../isolated-remote-workspace-source.js";
import { issueService } from "../issues.js";
import { projectService } from "../projects.js";
import {
authorizationService,
type AuthorizationActor,
} from "../authorization.js";
import { mergeExecutionWorkspaceConfig } from "../execution-workspaces.js";
import {
type WorkspaceOperationRecorder,
} from "../workspace-operations.js";
import {
GIT_BRANCH_OWNERSHIP_METADATA_KEY,
GIT_BRANCH_OWNERSHIP_METADATA_VERSION,
} from "../execution-workspace-branch-ownership.js";
import {
resolveEffectiveWorkspaceStrategyType,
resolveExecutionWorkspaceMode,
} from "../execution-workspace-policy.js";
import { isUnsafeSessionWorkspaceCwd } from "../session-workspace-cwd.js";
import { assertLowTrustWorkspaceIsolation } from "../low-trust-runtime-containment.js";
import type { TrustPresetResolution } from "../trust-preset-resolver.js";
import {
createEffectiveRunConfigFingerprints,
createEffectiveRunConfigSubcategoryFingerprints,
EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION,
type EffectiveRunConfigFingerprints,
type EffectiveRunConfigSecretManifestEntry,
} from "../effective-run-config-fingerprints.js";
const execFile = promisify(execFileCallback);
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
export const WORKSPACE_VALIDATION_FAILURE_CODE = "workspace_validation_failed";
const GITHUB_PR_WORKFLOW_SKILL_KEY =
"paperclipai/bundled/software-development/github-pr-workflow";
const GITHUB_PR_WORKFLOW_SKILL_SLUG = "github-pr-workflow";
const PUSH_CAPABILITY_ENV_KEYS = ["GH_TOKEN", "GITHUB_TOKEN"] as const;
// Keep this in sync with local adapters that require a git workspace before launch.
const GIT_SENSITIVE_LOCAL_ADAPTER_TYPES = new Set([
"claude_local",
"codex_local",
"cursor",
"gemini_local",
"grok_local",
"hermes_local",
"kimi_local",
"opencode_local",
"pi_local",
]);
export class WorkspaceValidationFailure extends Error {
code = WORKSPACE_VALIDATION_FAILURE_CODE;
resultJson: Record<string, unknown>;
constructor(message: string, resultJson: Record<string, unknown>) {
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<string, unknown>;
workspaceConfig: ExecutionWorkspaceConfig | null;
mode: ReturnType<typeof resolveExecutionWorkspaceMode>;
}) {
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<string, unknown> | null | undefined;
source: string;
createdByRuntime: boolean;
strategyType: "project_primary" | "git_worktree";
configSnapshot: Record<string, unknown> | 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<string, unknown>;
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<string, unknown>,
) {
const nextConfig = { ...config };
delete nextConfig.workspaceRuntime;
return nextConfig;
}
export function buildExecutionWorkspaceConfigSnapshot(
config: Record<string, unknown>,
environmentId?: string | null,
): Partial<ExecutionWorkspaceConfig> | null {
const strategy = parseObject(config.workspaceStrategy);
const snapshot: Partial<ExecutionWorkspaceConfig> = {};
// Persist the resolved environment onto the workspace so reused sessions stay on the
// environment they were created against until the workspace itself is recreated/reset.
const hasExplicitEnvironmentSelection = environmentId !== undefined;
if (hasExplicitEnvironmentSelection) {
snapshot.environmentId = environmentId ?? null;
}
if ("workspaceStrategy" in config) {
snapshot.provisionCommand =
typeof strategy.provisionCommand === "string"
? strategy.provisionCommand
: null;
snapshot.runtimeProvisionCommand =
typeof strategy.runtimeProvisionCommand === "string"
? strategy.runtimeProvisionCommand
: null;
snapshot.teardownCommand =
typeof strategy.teardownCommand === "string"
? strategy.teardownCommand
: null;
}
if ("workspaceRuntime" in config) {
const workspaceRuntime = parseObject(config.workspaceRuntime);
snapshot.workspaceRuntime =
Object.keys(workspaceRuntime).length > 0 ? workspaceRuntime : null;
}
if ("desiredState" in config) {
snapshot.desiredState =
config.desiredState === "running" ||
config.desiredState === "stopped" ||
config.desiredState === "manual"
? config.desiredState
: null;
}
if ("serviceStates" in config) {
const serviceStates = parseObject(config.serviceStates);
snapshot.serviceStates =
Object.keys(serviceStates).length > 0
? (Object.fromEntries(
Object.entries(serviceStates).filter(
([, state]) =>
state === "running" ||
state === "stopped" ||
state === "manual",
),
) as ExecutionWorkspaceConfig["serviceStates"])
: null;
}
const hasSnapshot =
Object.values(snapshot).some((value) => {
if (value === null) return false;
if (typeof value === "object") return Object.keys(value).length > 0;
return true;
}) || hasExplicitEnvironmentSelection;
return hasSnapshot ? snapshot : null;
}
export function stripHostWorkspaceProvision(input: {
config: Record<string, unknown>;
skipHostProvision: boolean;
}): Record<string, unknown> {
if (!input.skipHostProvision) return input.config;
const workspaceStrategy = parseObject(input.config.workspaceStrategy);
if (
typeof workspaceStrategy.provisionCommand !== "string" &&
typeof workspaceStrategy.runtimeProvisionCommand !== "string" &&
typeof input.config.runtimeProvisionCommand !== "string"
)
return input.config;
const nextWorkspaceStrategy = { ...workspaceStrategy };
delete nextWorkspaceStrategy.provisionCommand;
delete nextWorkspaceStrategy.runtimeProvisionCommand;
const nextConfig: Record<string, unknown> = { ...input.config, workspaceStrategy: nextWorkspaceStrategy };
delete nextConfig.runtimeProvisionCommand;
return nextConfig;
}
export async function preflightLowTrustWorkspaceIsolation(input: {
db?: Db;
trustPreset: TrustPresetResolution;
isolatedWorkspacesEnabled: boolean;
effectiveExecutionWorkspaceMode: string | null | undefined;
issue: {
companyId: string;
id?: string | null;
projectId?: string | null;
} | null;
resolveSelectedEnvironmentDriver: () => Promise<string | null | undefined>;
}): Promise<string | null> {
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<string | null | undefined>;
resolveWorkspace: () => Promise<TWorkspace>;
}): Promise<{
selectedEnvironmentDriver: string | null;
workspace: TWorkspace;
}> {
const selectedEnvironmentDriver = await preflightLowTrustWorkspaceIsolation({
db: input.db,
trustPreset: input.trustPreset,
isolatedWorkspacesEnabled: input.isolatedWorkspacesEnabled,
effectiveExecutionWorkspaceMode: input.effectiveExecutionWorkspaceMode,
issue: input.issue,
resolveSelectedEnvironmentDriver: input.resolveSelectedEnvironmentDriver,
});
return {
selectedEnvironmentDriver,
workspace: await input.resolveWorkspace(),
};
}
type PrepareManagedLocalSource = (sourceCwd: string, targetCwd: string) => Promise<(targetCwd: string) => Promise<void>>;
/**
* In-flight managed-checkout materializations keyed by target cwd. Two issues on the same
* project can wake within seconds of each other; without this, both runs raced the same
* clone target — the loser saw "destination path already exists" and its failure cleanup
* deleted the winner's in-progress clone, so both runs failed every round.
*/
const managedCheckoutMaterializations = new Map<
string,
Promise<{ cwd: string; warning: string | null }>
>();
export async function ensureManagedProjectWorkspace(input: {
companyId: string;
projectId: string;
repoUrl: string | null;
prepareLocalSource?: PrepareManagedLocalSource;
/** Optional git credential source for cloning private repos; null/absent preserves ambient behavior. */
resolveGitAuth?: GitRemoteAuthProvider | null;
}): Promise<{ cwd: string; warning: string | null }> {
const defaultCwd = resolveManagedProjectWorkspaceDir({
companyId: input.companyId,
projectId: input.projectId,
repoName: deriveRepoNameFromRepoUrl(input.repoUrl),
});
let cwd = defaultCwd;
if (input.repoUrl && await fs.stat(path.join(cwd, ".git")).catch(() => null)) {
const origin = await execFile("git", ["-C", cwd, "remote", "get-url", "origin"], { timeout: 10_000 })
.then((result) => result.stdout.trim()).catch(() => null);
if (origin && origin !== input.repoUrl) {
cwd = `${cwd}-${createHash("sha256").update(input.repoUrl).digest("hex").slice(0, 12)}`;
}
}
const inFlight = managedCheckoutMaterializations.get(cwd);
if (inFlight) {
await inFlight;
// The winner may have cloned a different repository with the same basename.
return ensureManagedProjectWorkspace(input);
}
const localSource = input.repoUrl ? localRepositoryPath(input.repoUrl) : null;
const guardLocalSource = localSource ? await input.prepareLocalSource?.(localSource, cwd) : undefined;
await guardLocalSource?.(cwd);
const attempt = materializeManagedProjectWorkspace(cwd, {
...input,
beforeCopy: guardLocalSource,
beforePublish: guardLocalSource ? () => guardLocalSource(cwd) : undefined,
}).finally(() => {
managedCheckoutMaterializations.delete(cwd);
});
managedCheckoutMaterializations.set(cwd, attempt);
const result = await attempt;
await guardLocalSource?.(cwd);
if (input.repoUrl) {
// A different server process can publish a same-name checkout between the
// initial origin check and the atomic rename. Never adopt its other repo.
const origin = await execFile("git", ["-C", cwd, "remote", "get-url", "origin"], { timeout: 10_000 })
.then((value) => value.stdout.trim()).catch(() => null);
if (origin && origin !== input.repoUrl) {
if (cwd !== defaultCwd) throw new Error("Managed checkout origin does not match the requested repository");
return ensureManagedProjectWorkspace(input);
}
}
return result;
}
/** Keep every distinct project repository inside the task's writable/synced root. */
export async function prepareProjectRepositoryWorkspaces(input: {
cwd: string;
anchorRepoUrl: string | null;
workspaces: Array<Pick<typeof projectWorkspaces.$inferSelect, "id" | "repoUrl" | "repoRef"> & { cwd?: string | null }>;
resolveGitAuth?: GitRemoteAuthProvider | null;
beforePublish?: () => Promise<void>;
prepareLocalSource?: (sourceCwd: string, projectWorkspaceId: string) => Promise<() => Promise<void>>;
restoredContents?: GitWorkspaceContents;
sharedBase?: { companyId: string; projectId: string };
}): Promise<Array<{ workspaceId: string; cwd: string; repoUrl: string; repoRef: string | null }>> {
const identity = projectRepositoryIdentity;
const seen = new Set(input.anchorRepoUrl ? [identity(input.anchorRepoUrl)] : []);
const selected = selectRestoredProjectRepositories(input.workspaces, input.restoredContents).filter((workspace) => {
if (!workspace.repoUrl || seen.has(identity(workspace.repoUrl))) return false;
seen.add(identity(workspace.repoUrl));
return true;
});
if (input.restoredContents?.checkouts.some(checkout => !selected.some(source => source.id === checkout.source.id))) throw new GitCacheRestorationFailure();
const root = path.join(input.cwd, PROJECT_REPOSITORIES_DIR);
if (selected.length === 0 && !(await fs.lstat(root).catch(() => null))) return [];
await fs.mkdir(root, { recursive: true });
if (await fs.realpath(root) !== path.join(await fs.realpath(input.cwd), PROJECT_REPOSITORIES_DIR)) {
throw new Error("Project repositories directory escapes the task workspace");
}
await ensureManagedRepositoriesIgnored(input.cwd);
const results = [];
for (const workspace of selected) {
const repoUrl = workspace.repoUrl!;
const key = managedProjectCheckoutName({ id: workspace.id, repoUrl, repoRef: workspace.repoRef, cwd: workspace.cwd });
const cwd = path.join(root, key);
const existing = await fs.lstat(cwd).catch(() => null);
if (!existing && input.restoredContents?.checkouts.some(checkout => checkout.name === key)) throw new GitCacheRestorationFailure();
if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) throw new Error("Invalid project repository checkout path");
const baseCandidates = input.sharedBase ? nestedRepositoryBaseCandidates({ ...input.sharedBase, repoUrl }) : null;
const linked = existing ? await readLinkedNestedCheckout(cwd, baseCandidates ?? []).catch(() => {
throw new Error("Invalid project repository checkout path");
}) : null;
if (linked && !await isVerifiedNestedRepositoryBase(linked.base, baseCandidates!, repoUrl)) throw new Error("Invalid project repository checkout path");
const configuredLocalSource = workspace.cwd && workspace.cwd !== REPO_ONLY_CWD_SENTINEL ? workspace.cwd : null;
const localSource = configuredLocalSource
&& await fs.stat(configuredLocalSource).then((entry) => entry.isDirectory()).catch(() => false)
? configuredLocalSource : null;
const urlLocalSource = localRepositoryPath(repoUrl);
const sourceToAuthorize = localSource ?? urlLocalSource;
const recheckLocalSource = sourceToAuthorize
? await input.prepareLocalSource?.(sourceToAuthorize, workspace.id) : undefined;
const beforePublish = async () => {
await recheckLocalSource?.();
await input.beforePublish?.();
};
if (!linked) {
const shared = input.sharedBase && !existing && !localSource
? await ensureManagedProjectWorkspace({ ...input.sharedBase, repoUrl, resolveGitAuth: input.resolveGitAuth }) : null;
if (shared && !shared.warning && await isVerifiedNestedRepositoryBase(shared.cwd, baseCandidates!, repoUrl)) {
await addNestedRepositoryWorktree({ base: shared.cwd, cwd, repoUrl, repoRef: workspace.repoRef,
resolveGitAuth: input.resolveGitAuth, beforeAdd: beforePublish });
} else {
const result = await materializeManagedProjectWorkspace(cwd, { repoUrl, repoRef: workspace.repoRef, localSource, resolveGitAuth: input.resolveGitAuth, beforePublish });
if (result.warning) throw new Error(result.warning);
}
}
await beforePublish();
results.push({ workspaceId: workspace.id, cwd, repoUrl, repoRef: workspace.repoRef });
}
// Retain detached checkout work outside the synchronized repository set.
const active = new Set(results.map((repo) => path.basename(repo.cwd)));
for (const entry of await fs.readdir(root)) {
if (active.has(entry) || entry.startsWith("task-repo-") || entry.includes(".clone-")) continue;
const retained = path.join(input.cwd, ".paperclip-runtime", "detached-repositories", randomUUID());
const linked = await readLinkedNestedCheckout(path.join(root, entry)).catch(() => null);
await fs.mkdir(path.dirname(retained), { recursive: true });
await fs.rename(path.join(root, entry), retained);
if (linked) await repairMovedNestedRepositoryWorktree(linked.base, retained);
}
return results;
}
/**
* Resolve one project workspace row to a usable cwd. The anchor path and each additional
* referenced project share this step: use the configured cwd when present, otherwise clone or
* create the managed checkout directory for the project. It throws only when the managed
* checkout cannot be prepared (for example, a clone failure).
*/
async function resolveConfiguredOrManagedProjectCwd(input: {
companyId: string;
projectId: string;
cwd: string | null;
repoUrl: string | null;
resolveGitAuth?: GitRemoteAuthProvider | null;
prepareLocalSource?: PrepareManagedLocalSource;
}): Promise<{ cwd: string; warning: string | null }> {
const configuredCwd = readNonEmptyString(input.cwd);
if (configuredCwd && configuredCwd !== REPO_ONLY_CWD_SENTINEL) {
return { cwd: configuredCwd, warning: null };
}
return ensureManagedProjectWorkspace({
companyId: input.companyId,
projectId: input.projectId,
repoUrl: readNonEmptyString(input.repoUrl),
resolveGitAuth: input.resolveGitAuth ?? null,
prepareLocalSource: input.prepareLocalSource,
});
}
/**
* Side-effecting dependencies for {@link resolveAdditionalProjectWorkspace}. The caller injects
* the real database, filesystem, and managed-checkout helpers. A test injects fakes to exercise
* the resolution logic without a database or filesystem.
*/
export interface ResolveAdditionalProjectWorkspaceDeps {
loadProjectWorkspaceRows: (
companyId: string,
projectId: string,
) => Promise<Array<typeof projectWorkspaces.$inferSelect>>;
resolveConfiguredOrManagedProjectCwd: typeof resolveConfiguredOrManagedProjectCwd;
ensureManagedProjectWorkspace: typeof ensureManagedProjectWorkspace;
directoryHasContents: (cwd: string) => Promise<boolean>;
}
/** Build the real dependencies for {@link resolveAdditionalProjectWorkspace}. */
function defaultAdditionalProjectWorkspaceDeps(
db: Db,
resolveGitAuth?: GitRemoteAuthProvider,
prepareLocalSource?: PrepareManagedLocalSource,
): ResolveAdditionalProjectWorkspaceDeps {
return {
loadProjectWorkspaceRows: (companyId, projectId) =>
db
.select()
.from(projectWorkspaces)
.where(
and(
eq(projectWorkspaces.companyId, companyId),
eq(projectWorkspaces.projectId, projectId),
),
)
.orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)),
resolveConfiguredOrManagedProjectCwd: (input) =>
resolveConfiguredOrManagedProjectCwd({
...input,
prepareLocalSource: input.prepareLocalSource ?? prepareLocalSource,
resolveGitAuth:
input.resolveGitAuth ??
resolveGitAuth ??
createGitRemoteAuthProvider(db, input.companyId),
}),
ensureManagedProjectWorkspace: (input) =>
ensureManagedProjectWorkspace({
...input,
prepareLocalSource: input.prepareLocalSource ?? prepareLocalSource,
resolveGitAuth:
input.resolveGitAuth ??
resolveGitAuth ??
createGitRemoteAuthProvider(db, input.companyId),
}),
// A realized workspace must hold real content. An empty directory gives the agent an empty
// referenced workspace, so treat an empty directory the same as a missing one.
directoryHasContents: async (cwd) => {
const stats = await fs.stat(cwd).catch(() => null);
if (!stats || !stats.isDirectory()) {
return false;
}
const entries = await fs.readdir(cwd).catch(() => [] as string[]);
return entries.length > 0;
},
};
}
/**
* Resolve one authorized referenced project to its own workspace cwd. Each additional project
* lands in its own managed checkout directory, never nested inside the anchor's worktree (the
* directory isolation invariant lives in {@link resolveManagedProjectWorkspaceDir}).
*
* A referenced project must resolve to a directory with real content. The function uses a
* configured checkout directory that exists, or clones a managed checkout from a workspace row
* that supplies a repository URL. When no row offers either, the function throws instead of
* creating an empty managed directory. An empty directory gives the agent an empty referenced
* workspace and hides the real cause. The caller catches the error and drops only that project.
* The function also throws when the managed checkout cannot be prepared (for example, a clone
* failure), so the caller can drop only that project.
*/
export async function resolveAdditionalProjectWorkspace(
input: {
companyId: string;
project: RunReferencedProject;
},
deps: ResolveAdditionalProjectWorkspaceDeps,
): Promise<ResolvedAdditionalWorkspace> {
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<string, unknown>;
};
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<typeof heartbeatRuns.$inferSelect, "errorCode"> | null | undefined,
) {
return run?.errorCode === WORKSPACE_VALIDATION_FAILURE_CODE;
}
export function readWorkspaceValidationPayloadFromRun(
run: Pick<typeof heartbeatRuns.$inferSelect, "resultJson"> | 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<string, unknown>;
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<typeof formatManagedGitWorktreeBranchInspection>;
}) {
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<boolean> {
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<string, unknown>;
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<string, unknown> = {},
connectionFailure?: GitConnectionFailure,
) => {
throw new WorkspaceValidationFailure(message, {
...(connectionFailure ? { connectionFailure } : {}),
workspaceValidation: {
reason,
issueId: input.issue!.id,
issueIdentifier: input.issue!.identifier,
issueProjectId: input.issue!.projectId,
issueProjectWorkspaceId: input.issue!.projectWorkspaceId,
issueExecutionWorkspaceId: input.issue!.executionWorkspaceId ?? null,
issueExecutionWorkspacePreference:
input.issue!.executionWorkspacePreference ?? null,
requestedExecutionWorkspaceMode: input.requestedExecutionWorkspaceMode,
workspaceStrategyType: strategyType,
resolvedWorkspaceSource: input.base.source,
resolvedProjectId: input.base.projectId,
resolvedProjectWorkspaceId: input.base.workspaceId,
resolvedWorkspaceCwd: input.base.baseCwd,
...extra,
},
});
};
if (input.base.source === "agent_home") {
fail(
"git_worktree_base_agent_home",
`Issue ${issueLabel} requested ${input.requestedExecutionWorkspaceMode} with git_worktree, but no project or reusable execution workspace was resolved; refusing to create a git worktree from agent fallback cwd "${input.base.baseCwd}". ${remediation}`,
);
}
// Checked before isGitCheckout: when materialization failed and the base cwd is the
// agent-home fallback, a git checkout at that path would be an unrelated repository —
// proceeding would build worktrees off the wrong repo, and failing on the checkout probe
// would mask the real cause (for example a clone that could not authenticate). The reason
// is reserved for genuine materialization failures; a fallback with no failed attempt
// (a configured path that is simply unavailable) keeps its accurate reporting below.
const materializationFailures = input.anchor?.materializationFailures ?? [];
if (input.anchor?.baseCwdFallback && materializationFailures.length > 0) {
const failureDetail = `: ${materializationFailures[0].error.replace(/\s+/g, " ")}`;
const connections = materializationFailures.map((failure) => readConnectionFailure(failure.connectionFailure));
// Mixed connection/local failures must retain their application-error report.
const connectionFailure = connections.every((failure) => failure?.provider === "git")
? connections[0] as GitConnectionFailure : undefined;
fail(
"git_worktree_base_materialization_failed",
`Issue ${issueLabel} requested ${input.requestedExecutionWorkspaceMode} with git_worktree, but the project workspace checkout could not be prepared${failureDetail}. Repair the project workspace repository URL, clone access, or configured local cwd, then retry.`,
{ baseCwdFallback: true, materializationFailures },
connectionFailure,
);
}
const checkoutProbe = await probeGitWorktreeBase(input.base.baseCwd);
if (!checkoutProbe.isCheckout) {
const knownLocalPathMismatch = checkoutProbe.notRepository &&
input.anchor?.localPathOnlyWorkspace === true &&
input.anchor.baseCwdFallback === false && materializationFailures.length === 0 &&
input.base.source === "project_primary" && Boolean(input.base.projectId) &&
Boolean(input.base.workspaceId) && !readNonEmptyString(input.base.repoUrl);
fail(
"git_worktree_base_not_git_checkout",
`Issue ${issueLabel} requested ${input.requestedExecutionWorkspaceMode} with git_worktree, but base workspace "${input.base.baseCwd}" is not a git checkout. ${remediation}`,
knownLocalPathMismatch
? { configurationReason: "local_path_requires_git_checkout" }
: {},
);
}
// A fallback cwd that happens to be a git checkout is still not the configured project
// workspace — building worktrees there would target an unrelated repository. No
// materialization attempt failed here (that case failed above); the configured path is
// simply unavailable, so the message points at the path rather than clone access.
if (input.anchor?.baseCwdFallback) {
fail(
"git_worktree_base_fallback_not_project_workspace",
`Issue ${issueLabel} requested ${input.requestedExecutionWorkspaceMode} with git_worktree, but the configured project workspace path is not available and the fallback cwd "${input.base.baseCwd}" is not the project workspace checkout. Make the configured project workspace path available on this host, or repair the project workspace configuration, then retry.`,
{ baseCwdFallback: true, materializationFailures },
);
}
}
export async function assertPushCapabilityCheckoutValid(input: {
enabled: boolean;
issue: {
id: string;
identifier: string | null;
} | null;
cwd: string | null | undefined;
}) {
if (!input.enabled || !input.issue) return;
const cwd = readNonEmptyString(input.cwd);
if (!cwd) return;
if (await hasGitPushRemote(cwd)) return;
throw new WorkspaceValidationFailure(
`Issue ${input.issue.identifier ?? input.issue.id} requested the GitHub PR workflow, but checkout "${cwd}" has no configured push remote. Bind the run to a writable repo checkout before dispatching the agent.`,
{
workspaceValidation: {
reason: "missing_git_push_remote",
issueId: input.issue.id,
issueIdentifier: input.issue.identifier,
executionWorkspaceCwd: cwd,
requiredEnvKeys: [...PUSH_CAPABILITY_ENV_KEYS],
},
},
);
}
/**
* Reconcile the `projectWorkspaceId` for a reused execution workspace.
*
* A `reuse_existing` workspace can have been persisted with a null
* `projectWorkspaceId` (e.g. it was created before its project had a primary
* project workspace). When we later restore it for a run whose issue now
* expects a concrete project workspace, backfill the column so the launch
* guard (`persisted_workspace_missing_project_workspace_id`) stops rejecting
* it on every requeue — otherwise `reuse_existing` re-binds the same stale
* record forever and the run crash-loops. Prefer the existing binding when
* present so we never null out a good value or silently rebind a genuine
* mismatch (which the guard still surfaces).
*/
export function reconcileReusedExecutionWorkspaceProjectWorkspaceId(
existingProjectWorkspaceId: string | null | undefined,
resolvedProjectWorkspaceId: string | null | undefined,
): string | null {
return existingProjectWorkspaceId ?? resolvedProjectWorkspaceId ?? null;
}
export async function assertGitSensitiveAdapterWorkspaceValid(input: {
adapterType: string;
agentId: string;
issue: {
id: string;
identifier: string | null;
projectId: string | null;
projectWorkspaceId: string | null;
} | null;
resolvedWorkspace: ResolvedWorkspaceForRun;
executionWorkspace: RealizedExecutionWorkspace;
persistedExecutionWorkspace: ExecutionWorkspace | null;
executionTarget: unknown;
environmentDriver?: string | null;
leaseMetadata?: unknown;
}) {
if (!GIT_SENSITIVE_LOCAL_ADAPTER_TYPES.has(input.adapterType)) return;
const executionTargetKind =
readNonEmptyString(
(input.executionTarget as { kind?: unknown } | null)?.kind,
) ?? "local";
if (executionTargetKind !== "local") return;
const issue = input.issue;
if (!issue) return;
const environmentDriver =
readNonEmptyString(input.environmentDriver) ?? "local";
const leaseMetadata = parseObject(input.leaseMetadata);
const leaseProviderMetadata = parseObject(leaseMetadata.providerMetadata);
const leaseRemoteCwd =
readNonEmptyString(leaseMetadata.remoteCwd) ??
readNonEmptyString(leaseProviderMetadata.remoteCwd);
const effectiveCwd = readNonEmptyString(input.executionWorkspace.cwd);
const persistedCwd = readNonEmptyString(
input.persistedExecutionWorkspace?.cwd,
);
const agentFallbackCwd = resolveDefaultAgentWorkspaceDir(input.agentId);
const workspaceExpectation =
Boolean(issue.projectWorkspaceId) ||
Boolean(input.resolvedWorkspace.workspaceId) ||
input.executionWorkspace.strategy === "git_worktree";
const fail = (
reason: string,
message: string,
extra: Record<string, unknown> = {},
) => {
throw new WorkspaceValidationFailure(message, {
workspaceValidation: {
reason,
adapterType: input.adapterType,
issueId: issue.id,
issueIdentifier: issue.identifier,
issueProjectId: issue.projectId,
issueProjectWorkspaceId: issue.projectWorkspaceId,
resolvedWorkspaceSource: input.resolvedWorkspace.source,
resolvedProjectId: input.resolvedWorkspace.projectId,
resolvedProjectWorkspaceId: input.resolvedWorkspace.workspaceId,
resolvedWorkspaceCwd: input.resolvedWorkspace.cwd,
executionWorkspaceCwd: effectiveCwd,
executionWorkspaceStrategy: input.executionWorkspace.strategy,
executionWorkspaceProjectId: input.executionWorkspace.projectId,
executionWorkspaceProjectWorkspaceId:
input.executionWorkspace.workspaceId,
persistedExecutionWorkspaceId:
input.persistedExecutionWorkspace?.id ?? null,
persistedWorkspaceCwd: persistedCwd,
persistedWorkspaceStrategy:
input.persistedExecutionWorkspace?.strategyType ?? null,
persistedProjectId:
input.persistedExecutionWorkspace?.projectId ?? null,
persistedProjectWorkspaceId:
input.persistedExecutionWorkspace?.projectWorkspaceId ?? null,
persistedProviderRef:
input.persistedExecutionWorkspace?.providerRef ?? null,
...extra,
},
});
};
if (issue.projectWorkspaceId && !issue.projectId && !input.persistedExecutionWorkspace?.projectId) {
fail(
"missing_project_id",
`Issue ${issue.identifier ?? issue.id} is linked to a project workspace but has no project id; refusing to launch ${input.adapterType} from fallback cwd.`,
);
}
if (!input.executionTarget && environmentDriver !== "local" && leaseRemoteCwd)
return;
if (workspaceExpectation && !input.persistedExecutionWorkspace) {
fail(
"missing_persisted_execution_workspace",
`Issue ${issue.identifier ?? issue.id} requires a project execution workspace, but none was persisted before adapter launch.`,
);
}
if (workspaceExpectation && !effectiveCwd) {
fail(
"missing_effective_cwd",
`Issue ${issue.identifier ?? issue.id} expected a project workspace, but no adapter cwd was resolved before launch.`,
);
}
if (
input.persistedExecutionWorkspace &&
effectiveCwd &&
persistedCwd &&
!sameResolvedPath(effectiveCwd, persistedCwd)
) {
fail(
"persisted_cwd_mismatch",
`Issue ${issue.identifier ?? issue.id} resolved adapter cwd "${effectiveCwd}" but persisted execution workspace cwd is "${persistedCwd}".`,
);
}
const expectedProjectWorkspaceId =
issue.projectWorkspaceId ?? input.resolvedWorkspace.workspaceId ?? null;
if (
expectedProjectWorkspaceId &&
input.persistedExecutionWorkspace &&
!input.persistedExecutionWorkspace.projectWorkspaceId
) {
fail(
"persisted_workspace_missing_project_workspace_id",
`Issue ${issue.identifier ?? issue.id} expected project workspace "${expectedProjectWorkspaceId}" but persisted execution workspace has no project workspace id.`,
);
}
if (
expectedProjectWorkspaceId &&
input.persistedExecutionWorkspace?.projectWorkspaceId &&
input.persistedExecutionWorkspace.projectWorkspaceId !==
expectedProjectWorkspaceId
) {
fail(
"project_workspace_mismatch",
`Issue ${issue.identifier ?? issue.id} expected project workspace "${expectedProjectWorkspaceId}" but persisted execution workspace points at "${input.persistedExecutionWorkspace.projectWorkspaceId}".`,
);
}
if (
workspaceExpectation &&
effectiveCwd &&
sameResolvedPath(effectiveCwd, agentFallbackCwd)
) {
fail(
"fallback_agent_home_cwd",
`Issue ${issue.identifier ?? issue.id} expected a project workspace, but ${input.adapterType} would launch from agent fallback cwd "${effectiveCwd}".`,
);
}
if (
input.persistedExecutionWorkspace?.strategyType === "git_worktree" &&
input.persistedExecutionWorkspace.providerRef &&
effectiveCwd &&
!sameResolvedPath(
effectiveCwd,
input.persistedExecutionWorkspace.providerRef,
)
) {
fail(
"git_worktree_provider_ref_mismatch",
`Issue ${issue.identifier ?? issue.id} expected git worktree "${input.persistedExecutionWorkspace.providerRef}" but adapter cwd resolved to "${effectiveCwd}".`,
);
}
if (
workspaceExpectation &&
!(input.resolvedWorkspace.localPathOnlyWorkspace === true && input.executionWorkspace.strategy === "project_primary" && !input.executionWorkspace.repoUrl && !input.executionWorkspace.branchName) &&
effectiveCwd &&
!(await hasGitMetadata(effectiveCwd))
) {
fail(
"missing_git_metadata",
`Issue ${issue.identifier ?? issue.id} expected a git workspace for ${input.adapterType}, but "${effectiveCwd}" has no .git metadata.`,
);
}
const expectedManagedBranchName =
readNonEmptyString(input.executionWorkspace.branchName) ??
readNonEmptyString(input.persistedExecutionWorkspace?.branchName);
if (
input.persistedExecutionWorkspace?.strategyType === "git_worktree" &&
effectiveCwd &&
expectedManagedBranchName
) {
const inspection = await inspectManagedGitWorktreeBranch({
worktreePath: effectiveCwd,
expectedBranchName: expectedManagedBranchName,
});
if (!inspection.valid) {
fail(
"git_worktree_branch_mismatch",
`Issue ${issue.identifier ?? issue.id} expected git worktree branch "${expectedManagedBranchName}" at "${effectiveCwd}", but ${inspection.reason ?? "the checked-out branch could not be verified"}.`,
{
managedGitWorktreeBranch:
formatManagedGitWorktreeBranchInspection(inspection),
},
);
}
}
}
/**
* A single read-only referenced (mentioned) project workspace resolved for a run.
* The run materializes one entry per authorized additional project, each in its own
* managed checkout directory. See {@link resolveAdditionalRunWorkspaces}.
*/
export type ResolvedAdditionalWorkspace = {
cwd: string;
projectId: string;
workspaceId: string | null;
repoUrl: string | null;
repoRef: string | null;
};
/**
* One project-workspace materialization attempt that failed during anchor resolution — for
* example a managed `git clone` that could not authenticate against a private repository.
* Carried on {@link ResolvedWorkspaceForRun} so downstream validation can report the real
* cause instead of the fallback cwd's symptoms. `repoUrl` and `error` are scrubbed of URL
* userinfo credentials before they are stored.
*/
export type WorkspaceMaterializationFailure = {
projectWorkspaceId: string | null;
repoUrl: string | null;
error: string;
connectionFailure?: GitConnectionFailure;
};
export type ResolvedWorkspaceForRun = {
cwd: string;
source: "project_primary" | "task_session" | "agent_home";
projectId: string | null;
workspaceId: string | null;
repoUrl: string | null;
repoRef: string | null;
workspaceHints: Array<{
workspaceId: string;
cwd: string | null;
repoUrl: string | null;
repoRef: string | null;
}>;
warnings: string[];
/**
* True when project workspaces exist for the run but none could be used, so `cwd` is the
* agent-home fallback rather than a configured or materialized project workspace path. The
* `source` stays `project_primary` in that case (session migration depends on it), so this
* flag is the only reliable fallback signal.
*/
baseCwdFallback: boolean;
/** Failed materialization attempts behind {@link baseCwdFallback}; empty when every candidate resolved or none was attempted. */
materializationFailures: WorkspaceMaterializationFailure[];
/** True only for an explicitly configured local project path without a repository URL. */
localPathOnlyWorkspace?: boolean;
/** Current configuration for drift reporting; never an alternative restore source. */
freshnessSource?: { projectId: string | null; workspaceId: string | null; repoUrl: string | null; repoRef: string | null };
/**
* Read-only referenced (mentioned) project workspaces for this run, one per authorized
* additional project. The array is empty unless the multi-project workspace-sync flag is on
* ({@link isMultiProjectWorkspaceSyncEnabled}); with the flag off the run resolves the anchor
* workspace only, exactly as before.
*/
additionalWorkspaces: ResolvedAdditionalWorkspace[];
/** Authorized project IDs for plugin-owned storage; no Core filesystem staging. */
runnerTaskProjectIds?: string[];
/**
* Structured record of every referenced project that the run dropped or failed, paired with the
* layer that dropped it. Run preparation reads this to emit the requested-vs-synced observability
* log. The human-readable form of each drop already rides {@link ResolvedWorkspaceForRun.warnings}.
*/
referencedProjectFailures: ReferencedProjectFailure[];
};
/** The anchor workspace shape, before the additional referenced workspaces are attached. */
type ResolvedAnchorWorkspaceForRun = Omit<
ResolvedWorkspaceForRun,
"additionalWorkspaces" | "referencedProjectFailures" | "runnerTaskProjectIds"
>;
/**
* Assemble the run warnings for the agent-home fallback when a project has workspaces but none
* produced a usable cwd. Materialization failures (for example a failed managed clone) take
* priority over the generic "no local cwd configured" note, which previously masked them.
*/
export function buildAnchorFallbackWorkspaceNotes(input: {
fallbackCwd: string;
preferredWorkspaceWarning: string | null;
materializationFailures: WorkspaceMaterializationFailure[];
missingProjectCwds: string[];
hasConfiguredProjectCwd: boolean;
}): string[] {
const warnings: string[] = [];
if (input.preferredWorkspaceWarning) {
warnings.push(input.preferredWorkspaceWarning);
}
if (input.materializationFailures.length > 0) {
const first = input.materializationFailures[0];
const extraFailureCount = input.materializationFailures.length - 1;
warnings.push(
extraFailureCount > 0
? `Failed to prepare the project workspace checkout (${first.error}), and ${extraFailureCount} other candidate workspace(s) also failed. Using fallback workspace "${input.fallbackCwd}" for this run.`
: `Failed to prepare the project workspace checkout: ${first.error}. Using fallback workspace "${input.fallbackCwd}" for this run.`,
);
}
if (input.missingProjectCwds.length > 0) {
const firstMissing = input.missingProjectCwds[0];
const extraMissingCount = Math.max(0, input.missingProjectCwds.length - 1);
warnings.push(
extraMissingCount > 0
? `Project workspace path "${firstMissing}" and ${extraMissingCount} other configured path(s) are not available yet. Using fallback workspace "${input.fallbackCwd}" for this run.`
: `Project workspace path "${firstMissing}" is not available yet. Using fallback workspace "${input.fallbackCwd}" for this run.`,
);
} else if (
input.materializationFailures.length === 0 &&
!input.hasConfiguredProjectCwd
) {
warnings.push(
`Project workspace has no local cwd configured. Using fallback workspace "${input.fallbackCwd}" for this run.`,
);
}
return warnings;
}
/**
* Build the plural workspace list that a run exposes to the agent through the
* `PAPERCLIP_WORKSPACES_JSON` environment variable. The list joins the anchor
* project's alternative workspace rows with the read-only referenced (mentioned)
* project workspaces, so every execution target receives the referenced project
* paths through the same channel the run already uses for the anchor project.
*
* Each referenced entry carries its `projectId` so the agent can tell which
* mentioned project a path belongs to. The referenced set is empty unless the
* multi-project workspace-sync flag is on, so the exposed list is byte-for-byte
* unchanged in the production default.
*/
export function buildRunWorkspaceHints(
resolved: Pick<
ResolvedWorkspaceForRun,
"workspaceHints" | "additionalWorkspaces"
>,
): Array<Record<string, unknown>> {
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<string, string | undefined> = 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<string, string | undefined> = 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<ReturnType<typeof projectService>["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 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<ReturnType<typeof issueService>, "findMentionedProjectIds">;
projects: Pick<ReturnType<typeof projectService>, "listByIds">;
access: Pick<ReturnType<typeof authorizationService>, "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<ResolvedRunReferencedProjects> {
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<string>(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<ReturnType<typeof issueService>, "findMentionedProjectIds">;
projects: Pick<ReturnType<typeof projectService>, "listByIds">;
access: Pick<ReturnType<typeof authorizationService>, "decide">;
/** Resolve one authorized referenced project to its own workspace cwd (injectable for tests). */
resolveProjectWorkspace: (
project: RunReferencedProject,
) => Promise<ResolvedAdditionalWorkspace>;
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-<projectId>`
// 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<string, unknown> | 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<string, unknown> = {
...(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<EffectiveRunSessionConfigCategory, string>;
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<EffectiveRunWorkspaceConfigCategory, string>;
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<T | null>) | null;
realizeWorkspace: () => Promise<T>;
}): 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<EffectiveRunWorkspaceConfigCategory>([
"mode",
"projectWorkspace",
"strategy",
"repo",
"environment",
"realization",
]);
function parseStoredConfigCategoryFingerprints(value: unknown) {
const parsed = parseObject(value);
const out: Partial<Record<EffectiveRunSessionConfigCategory, string>> = {};
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<string, unknown> | 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<string, unknown> | 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<Record<EffectiveRunSessionConfigCategory, string>>;
next: Record<EffectiveRunSessionConfigCategory, string>;
}) {
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<Record<EffectiveRunWorkspaceConfigCategory, string>> = {};
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<string, unknown> | 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<Record<EffectiveRunWorkspaceConfigCategory, string>>;
next: Record<EffectiveRunWorkspaceConfigCategory, string>;
}) {
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<string, unknown>;
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<string, unknown>,
) {
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<string, unknown> = {
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<string, unknown>;
agentRuntimeConfig: unknown;
instructions: unknown;
issueOverrides: unknown;
workspaceConfig: unknown;
environment: unknown;
environmentEnv: unknown;
projectEnv: unknown;
routineEnv: unknown;
secretManifest: readonly EffectiveRunConfigSecretManifestEntry[];
runtimeSkills: unknown;
agentConfigRevision: unknown;
agentIdentityKeyId?: string;
}) {
const sanitizedSecretManifest = sanitizeSecretManifestForConfigFingerprint(
input.secretManifest,
);
const workspaceConfig = { ...parseObject(input.workspaceConfig) };
// issues.updatedAt also advances for comments and status changes. Those are
// wake deltas, not execution-workspace configuration changes, so including
// the timestamp here makes every comment invalidate an otherwise reusable
// task session.
delete workspaceConfig.issueConfigRevisionAt;
delete workspaceConfig.projectConfigRevisionAt;
// This row is runtime state, not requested configuration. It is absent
// before the first reusable run is realized and present on the next turn;
// fingerprinting that transition would rotate the native session exactly
// when the warm runner first becomes reusable. The requested/effective mode,
// project policy, and issue settings remain the configuration compatibility
// boundary; the reusable row and its evolving generation are state.
delete workspaceConfig.existingExecutionWorkspace;
delete workspaceConfig.reusableExecutionWorkspaceConfig;
const issueSettings = { ...parseObject(workspaceConfig.issueSettings) };
if (issueSettings.mode === workspaceConfig.requestedMode) delete issueSettings.mode;
workspaceConfig.issueSettings = Object.keys(issueSettings).length ? issueSettings : null;
return {
adapter: {
adapterType: input.adapterType,
...(input.agentIdentityKeyId ? { agentIdentityKeyId: input.agentIdentityKeyId } : {}),
agentConfigRevision: input.agentConfigRevision,
},
adapterConfig: input.effectiveAdapterConfig,
agentRuntimeConfig: input.agentRuntimeConfig,
instructions: input.instructions,
issueOverrides: input.issueOverrides,
workspaceConfig,
environment: input.environment,
envBindings: {
environment: { env: input.environmentEnv },
project: { env: input.projectEnv },
routine: { env: input.routineEnv },
},
secrets: sanitizedSecretManifest,
runtimeSkills: input.runtimeSkills,
} satisfies Record<EffectiveRunSessionConfigCategory, unknown>;
}
export async function buildEffectiveRunSessionConfigMetadata(input: {
adapterType: string;
effectiveAdapterConfig: Record<string, unknown>;
managedAiHome?: string;
managedInstructions?: { entryFile: string; contentHash: string | null } | null;
retainManagedInstructionPaths?: boolean;
agentRuntimeConfig: unknown;
issueOverrides: unknown;
workspaceConfig: unknown;
environment: unknown;
environmentEnv: unknown;
projectEnv: unknown;
routineEnv: unknown;
secretManifest?: readonly EffectiveRunConfigSecretManifestEntry[];
runtimeSkills: unknown;
agentConfigRevision?: unknown;
agentIdentityKeyId?: string;
}): Promise<EffectiveRunSessionConfigMetadata> {
const secretManifest = input.secretManifest ?? [];
const instructions = input.managedInstructions ? {
configured: true, bundleMode: "managed", entryFile: input.managedInstructions.entryFile,
pathKind: "absolute", readPolicy: "root_bound", readable: input.managedInstructions.contentHash !== null,
...(input.managedInstructions.contentHash !== null ? { contentHash: input.managedInstructions.contentHash } : {}),
} : await resolveInstructionsConfigFingerprintMetadata(input.effectiveAdapterConfig);
const effectiveAdapterConfig = { ...managedAiSessionFingerprintConfig(input.effectiveAdapterConfig, input.managedAiHome) };
if (input.managedInstructions && !input.retainManagedInstructionPaths) {
delete effectiveAdapterConfig.instructionsRootPath;
delete effectiveAdapterConfig.instructionsFilePath;
}
// A selected immutable skill version is its identity; source is merely its
// disposable projection. Unversioned/local skills retain their path boundary.
const versionedSkills = (value: unknown) => Array.isArray(value) ? value.map(entry => {
const skill = parseObject(entry);
return readNonEmptyString(skill.versionId)
? { key: skill.key, runtimeName: skill.runtimeName, versionId: skill.versionId }
: entry;
}) : value;
if (effectiveAdapterConfig.paperclipRuntimeSkills !== undefined) {
effectiveAdapterConfig.paperclipRuntimeSkills = versionedSkills(effectiveAdapterConfig.paperclipRuntimeSkills);
}
const categoryValues = buildSessionConfigCategoryValues({
adapterType: input.adapterType,
effectiveAdapterConfig,
agentRuntimeConfig: input.agentRuntimeConfig,
instructions,
issueOverrides: input.issueOverrides,
workspaceConfig: input.workspaceConfig,
environment: input.environment,
environmentEnv: input.environmentEnv,
projectEnv: input.projectEnv,
routineEnv: input.routineEnv,
secretManifest,
runtimeSkills: versionedSkills(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<ExecutionWorkspaceConfig> | 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<EffectiveRunWorkspaceConfigCategory, unknown>;
}
export function buildEffectiveRunWorkspaceConfigMetadata(input: {
mode: unknown;
projectId: unknown;
projectWorkspaceId: unknown;
strategyType: unknown;
workspaceStrategy: unknown;
repoUrl: unknown;
repoRef: unknown;
branchName?: unknown;
configSnapshot: Partial<ExecutionWorkspaceConfig> | 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<string, unknown> | 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<string, unknown> | null | undefined,
managedAiCredentialIdentity: string | undefined,
): boolean {
if (!managedAiCredentialIdentity) return true;
return storedSessionParams?.[SESSION_AI_CREDENTIAL_IDENTITY_KEY] === managedAiCredentialIdentity;
}
export function readConfiguredModelFromAdapterConfig(
adapterConfig: Record<string, unknown> | null | undefined,
) {
return readNonEmptyString(adapterConfig?.model);
}
export function attachPaperclipSessionMetadataToSessionParams(
sessionParams: Record<string, unknown> | 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<string, unknown> | null | undefined,
) {
return readNonEmptyString(sessionParams?.[SESSION_CONFIGURED_MODEL_KEY]);
}
export function shouldResetTaskSessionForModelChange(input: {
configuredModel: string | null;
taskSessionParams: Record<string, unknown> | null | undefined;
}) {
const { configuredModel, taskSessionParams } = input;
if (!configuredModel || !taskSessionParams) return false;
const sessionModel = readConfiguredModelFromSessionParams(taskSessionParams);
return !!sessionModel && sessionModel !== configuredModel;
}
export function stripConfiguredModelFromSessionParams(
sessionParams: Record<string, unknown> | null | undefined,
) {
if (!sessionParams) return null;
const next = { ...sessionParams };
delete next[SESSION_CONFIGURED_MODEL_KEY];
return next;
}
export function stripPaperclipSessionMetadataFromSessionParams(
sessionParams: Record<string, unknown> | 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<string, unknown> | null | undefined;
configMetadata: EffectiveRunSessionConfigMetadata | null;
compatibleConfigMetadata?: readonly EffectiveRunSessionConfigMetadata[];
wakeResetReason?: string | null;
preserveLegacySessionWithoutConfigMetadata?: boolean;
}): TaskSessionConfigFreshnessDecision {
if (!input.hasTaskSession) {
return {
reset: false,
reasons: [],
changedCategories: [],
storedFingerprint: null,
nextFingerprint: input.configMetadata?.fingerprint ?? null,
};
}
const reasons: string[] = [];
const storedConfig = readConfigFingerprintFromSessionParams(
input.taskSessionParams,
);
const taskSessionConfiguredModel = readConfiguredModelFromSessionParams(
input.taskSessionParams,
);
const modelChangedSinceTaskSession = shouldResetTaskSessionForModelChange({
configuredModel: input.configuredModel,
taskSessionParams: input.taskSessionParams,
});
if (modelChangedSinceTaskSession && taskSessionConfiguredModel) {
reasons.push(
`configured model changed from "${taskSessionConfiguredModel}" to "${input.configuredModel}"`,
);
}
let changedCategories: EffectiveRunSessionConfigCategory[] = [];
if (input.configMetadata) {
if (!storedConfig && !input.preserveLegacySessionWithoutConfigMetadata) {
changedCategories = [...input.configMetadata.categories];
reasons.push(
"effective run configuration fingerprint metadata is missing",
);
} else if (
storedConfig &&
storedConfig.version !== input.configMetadata.version
) {
changedCategories = [...input.configMetadata.categories];
reasons.push(
`effective run configuration fingerprint version changed from ${storedConfig.version} to ${input.configMetadata.version}`,
);
} else if (
storedConfig &&
storedConfig.fingerprint !== input.configMetadata.fingerprint &&
!input.compatibleConfigMetadata?.some(candidate => candidate.version === storedConfig.version && candidate.fingerprint === storedConfig.fingerprint)
) {
changedCategories = changedEffectiveRunSessionConfigCategories({
previous: storedConfig.categoryFingerprints,
next: input.configMetadata.categoryFingerprints,
});
reasons.push(
`effective run configuration changed: ${describeEffectiveRunConfigCategories(changedCategories)}`,
);
}
}
if (input.wakeResetReason) reasons.push(input.wakeResetReason);
return {
reset: reasons.length > 0,
reasons,
changedCategories,
storedFingerprint: storedConfig?.fingerprint ?? null,
nextFingerprint: input.configMetadata?.fingerprint ?? null,
};
}
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
/** Database-bound workspace resolution for a heartbeat service instance. */
export function createHeartbeatWorkspaceResolver(db: Db) {
function managedLocalSourceGuard(
agent: typeof agents.$inferSelect,
issueId: string | null,
runId: string | null,
): PrepareManagedLocalSource {
return async (sourceCwd, targetCwd) => {
const [run] = runId
? await db.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, runId),
eq(heartbeatRuns.companyId, agent.companyId),
eq(heartbeatRuns.agentId, agent.id),
))
: [];
const runIssueId = run?.nativeIssueId ?? run?.contextSnapshot?.issueId
?? run?.contextSnapshot?.taskId ?? run?.issueId ?? null;
if (!run || runIssueId !== issueId) {
throw forbidden("Local repository source requires an authoritative run identity");
}
const actor = {
type: "agent" as const,
source: "agent_jwt" as const,
companyId: agent.companyId,
agentId: agent.id,
runId: run.id,
onBehalfOfUserId: run.responsibleUserId === "local-board" ? null : run.responsibleUserId,
};
if (!issueId) throw forbidden("Local repository source requires a current task");
const sourceIdentity = await fs.realpath(sourceCwd);
const targetIdentity = await canonicalRepositoryRoot(targetCwd);
const selected = await assertTaskWorkspaceExecutionAccess(db, actor, agent.companyId, { issueId });
const [sourceConfiguration] = selected.task.projectWorkspaceId
? await db.select({ id: projectWorkspaces.id, cwd: projectWorkspaces.cwd,
repoUrl: projectWorkspaces.repoUrl, repoRef: projectWorkspaces.repoRef })
.from(projectWorkspaces).where(and(eq(projectWorkspaces.id, selected.task.projectWorkspaceId),
eq(projectWorkspaces.companyId, agent.companyId)))
: [];
const validate = async () => {
if (await fs.realpath(sourceCwd) !== sourceIdentity
|| await canonicalRepositoryRoot(targetCwd) !== targetIdentity) {
throw forbidden("Local repository copy identity changed during preparation");
}
const current = await assertTaskWorkspaceExecutionAccess(db, actor, agent.companyId, { issueId });
if (current.task.projectWorkspaceId !== selected.task.projectWorkspaceId
|| current.task.projectId !== selected.task.projectId) {
throw forbidden("Local repository source configuration changed during preparation");
}
if (sourceConfiguration) {
const [source] = await db.select({ id: projectWorkspaces.id, cwd: projectWorkspaces.cwd,
repoUrl: projectWorkspaces.repoUrl, repoRef: projectWorkspaces.repoRef }).from(projectWorkspaces)
.where(and(eq(projectWorkspaces.id, sourceConfiguration.id), eq(projectWorkspaces.companyId, agent.companyId)));
if (!source || source.cwd !== sourceConfiguration.cwd || source.repoUrl !== sourceConfiguration.repoUrl
|| source.repoRef !== sourceConfiguration.repoRef) {
throw forbidden("Local repository source configuration changed during preparation");
}
}
};
await validate();
return validate;
};
}
async function resolveReusedGitWorkspaceAnchor(input: {
agent: typeof agents.$inferSelect;
workspace: ExecutionWorkspace;
projectId: string | null;
explicitProjectWorkspaceId: string | null;
issueId: string | null;
runId: string;
responsibleUserId: string | null;
immutableNativeBinding: boolean;
}): Promise<ResolvedAnchorWorkspaceForRun> {
const { workspace, agent } = input;
if (workspace.providerType === "plugin") {
return { cwd: `/workspaces/${workspace.id}`, source: "task_session" as const,
projectId: workspace.projectId, workspaceId: workspace.projectWorkspaceId,
repoUrl: workspace.repoUrl, repoRef: workspace.baseRef, workspaceHints: [], warnings: [],
baseCwdFallback: false, materializationFailures: [] };
}
// Configuration preparation may have awaited credentials and skills since
// issueRef was read. Match the ordinary anchor resolver's fresh selection
// check; only an already-admitted native input owns immutable source scope.
const currentIssueSource = input.issueId && !input.immutableNativeBinding
? await db.select({ projectId: issues.projectId, projectWorkspaceId: issues.projectWorkspaceId }).from(issues)
.where(and(eq(issues.id, input.issueId), eq(issues.companyId, agent.companyId))).then(rows => rows[0] ?? null)
: null;
const sourceProjectId = workspace.projectId;
const explicitProjectWorkspaceId = currentIssueSource?.projectWorkspaceId ?? input.explicitProjectWorkspaceId;
const projectWorkspaceRows = await db.select().from(projectWorkspaces).where(and(
eq(projectWorkspaces.companyId, agent.companyId),
workspace.projectId ? eq(projectWorkspaces.projectId, workspace.projectId) : sql`false`,
)).orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
const boundProjectWorkspace = projectWorkspaceRows.find(row => row.id === workspace.projectWorkspaceId) ?? null;
const configuredSource = prioritizeProjectWorkspaceCandidatesForRun(projectWorkspaceRows, explicitProjectWorkspaceId)[0];
const managedBase = workspace.repoUrl && workspace.projectId ? resolveManagedProjectWorkspaceDir({
companyId: agent.companyId,
projectId: workspace.projectId,
repoName: deriveRepoNameFromRepoUrl(workspace.repoUrl),
}) : null;
const candidateBaseCwds = [
await verifiedRecordedIsolatedRemoteSource(workspace),
managedBase,
managedBase && workspace.repoUrl
? `${managedBase}-${createHash("sha256").update(workspace.repoUrl).digest("hex").slice(0, 12)}` : null,
].filter((value): value is string => Boolean(value));
const cwd = await resolvePersistedGitWorkspaceSource({
companyId: agent.companyId,
projectId: sourceProjectId,
explicitProjectWorkspaceId,
workspace,
boundProjectWorkspace,
candidateBaseCwds,
managedSourceRoot: resolvePaperclipInstanceRoot(),
materializeOriginalRepository: workspace.repoUrl && workspace.projectId ? async () => {
const original = await ensureManagedProjectWorkspace({
companyId: agent.companyId,
projectId: workspace.projectId!,
repoUrl: workspace.repoUrl,
prepareLocalSource: managedLocalSourceGuard(agent, input.issueId, input.runId),
resolveGitAuth: createGitRemoteAuthProvider(db, agent.companyId, {
issueId: input.issueId, heartbeatRunId: input.runId, agentId: agent.id,
responsibleUserId: input.responsibleUserId,
}),
});
return original.cwd;
} : undefined,
});
return {
cwd,
source: "task_session",
projectId: workspace.projectId,
workspaceId: workspace.projectWorkspaceId,
repoUrl: workspace.repoUrl,
repoRef: workspace.baseRef,
// Freshness must still expose drift in current project configuration.
// This snapshot authorizes no filesystem lookup or repository fallback.
freshnessSource: {
projectId: workspace.projectId,
workspaceId: configuredSource?.id ?? null,
repoUrl: configuredSource?.repoUrl ?? null,
repoRef: configuredSource?.repoRef ?? null,
},
workspaceHints: [],
warnings: [],
baseCwdFallback: false,
materializationFailures: [],
};
}
async function resolveAnchorWorkspaceForRun(
agent: typeof agents.$inferSelect,
context: Record<string, unknown>,
previousSessionParams: Record<string, unknown> | null,
opts?: { useProjectWorkspace?: boolean | null; configuredCwd?: string | null; executionEnvironmentDriver?: string | null; storageProviderPluginId?: string | null },
): Promise<ResolvedAnchorWorkspaceForRun> {
const issueId =
readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
const resolveGitAuth = createGitRemoteAuthProvider(db, agent.companyId, {
issueId,
heartbeatRunId: readNonEmptyString(context.executionIdentityRunId),
responsibleUserId:
readNonEmptyString(context.responsibleUserId) ??
readNonEmptyString(context.responsible_user_id),
agentId: agent.id,
});
const contextProjectId = readNonEmptyString(context.projectId);
const contextProjectWorkspaceId = readNonEmptyString(
context.projectWorkspaceId,
);
const issueProjectRef = issueId
? await db
.select({
projectId: issues.projectId,
projectWorkspaceId: issues.projectWorkspaceId,
workspaceSelection: issues.workspaceSelection,
})
.from(issues)
.where(
and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)),
)
.then((rows) => rows[0] ?? null)
: null;
const issueProjectId = issueProjectRef?.projectId ?? null;
const workspaceSelection = issueProjectRef?.workspaceSelection?.selection;
const configuredSourceId = workspaceSelection?.kind === "configured_source"
? workspaceSelection.projectWorkspaceId : null;
const preferredProjectWorkspaceId =
configuredSourceId ?? issueProjectRef?.projectWorkspaceId ?? contextProjectWorkspaceId ?? null;
const [selectedSource] = preferredProjectWorkspaceId ? await db.select({ projectId: projectWorkspaces.projectId }).from(projectWorkspaces)
.where(and(eq(projectWorkspaces.id, preferredProjectWorkspaceId), eq(projectWorkspaces.companyId, agent.companyId))) : [];
const resolvedProjectId = selectedSource?.projectId ?? issueProjectId ?? contextProjectId;
const forceTaskDirectory = workspaceSelection?.kind === "task_directory";
const useProjectWorkspace = opts?.useProjectWorkspace !== false;
const workspaceProjectId = useProjectWorkspace && !forceTaskDirectory ? resolvedProjectId : null;
const unorderedProjectWorkspaceRows = workspaceProjectId
? await db
.select()
.from(projectWorkspaces)
.where(
and(
eq(projectWorkspaces.companyId, agent.companyId),
eq(projectWorkspaces.projectId, workspaceProjectId),
),
)
.orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id))
: [];
const prioritizedWorkspaceRows = prioritizeProjectWorkspaceCandidatesForRun(
unorderedProjectWorkspaceRows,
preferredProjectWorkspaceId,
);
const projectWorkspaceRows = configuredSourceId
? prioritizedWorkspaceRows.filter((workspace) => workspace.id === configuredSourceId)
: prioritizedWorkspaceRows;
const workspaceHints = projectWorkspaceRows.map((workspace) => ({
workspaceId: workspace.id,
cwd: readNonEmptyString(workspace.cwd),
repoUrl: readNonEmptyString(workspace.repoUrl),
repoRef: readNonEmptyString(workspace.repoRef),
}));
if (opts?.executionEnvironmentDriver === "plugin" && agent.adapterType === "paperclip_runner") {
if (workspaceProjectId) {
const project = await db.select({ id: projects.id }).from(projects).where(and(
eq(projects.companyId, agent.companyId), eq(projects.id, workspaceProjectId),
)).then(rows => rows[0] ?? null);
if (!project) throw new WorkspaceValidationFailure("The selected plugin project is unavailable.", { reason: "plugin_project_unavailable" });
}
if (configuredSourceId && projectWorkspaceRows.length === 0) {
throw new WorkspaceValidationFailure("The selected project workspace is unavailable.", { reason: "configured_workspace_unavailable" });
}
const selected = projectWorkspaceRows[0];
// This is a logical scope. Preparation resolves the execution-host path.
return { cwd: `/workspaces/${selected?.id ?? issueId ?? agent.id}`, source: workspaceProjectId ? "project_primary" : issueId ? "task_session" : "agent_home",
projectId: workspaceProjectId ?? null, workspaceId: selected?.id ?? null,
repoUrl: selected?.repoUrl ?? null, repoRef: selected?.repoRef ?? null,
workspaceHints, warnings: [], baseCwdFallback: false, materializationFailures: [] };
}
if (projectWorkspaceRows.length > 0 || configuredSourceId) {
const preferredWorkspace = preferredProjectWorkspaceId
? (projectWorkspaceRows.find(
(workspace) => workspace.id === preferredProjectWorkspaceId,
) ?? null)
: null;
const missingProjectCwds: string[] = [];
const materializationFailures: WorkspaceMaterializationFailure[] = [];
let hasConfiguredProjectCwd = false;
let preferredWorkspaceWarning: string | null = null;
if (preferredProjectWorkspaceId && !preferredWorkspace) {
preferredWorkspaceWarning = `Selected project workspace "${preferredProjectWorkspaceId}" is not available on this project.`;
}
const [sourceProject] = projectWorkspaceRows.length ? await db.select({ policy: projects.executionWorkspacePolicy }).from(projects)
.where(and(eq(projects.id, workspaceProjectId!), eq(projects.companyId, agent.companyId))) : [];
const storageProviderPluginId = opts?.storageProviderPluginId !== undefined ? opts.storageProviderPluginId : sourceProject?.policy?.storageProviderPluginId ??
(sourceProject ? (await effectiveProjectWorkspacePolicy(db, agent.companyId, sourceProject.policy,
projectWorkspaceRows.some(workspace => Boolean(workspace.repoUrl))))?.storageProviderPluginId : null);
for (const workspace of projectWorkspaceRows) {
if (storageProviderPluginId) {
return { cwd: `/workspaces/${workspace.id}`, source: "project_primary" as const,
projectId: workspace.projectId, workspaceId: workspace.id, repoUrl: workspace.repoUrl,
repoRef: workspace.repoRef, workspaceHints, warnings: [], baseCwdFallback: false, materializationFailures: [] };
}
let projectCwd: string;
let managedWorkspaceWarning: string | null = null;
try {
const resolvedCwd = await resolveConfiguredOrManagedProjectCwd({
companyId: agent.companyId,
projectId:
workspaceProjectId ?? resolvedProjectId ?? workspace.projectId,
cwd: workspace.cwd,
repoUrl: workspace.repoUrl,
resolveGitAuth,
prepareLocalSource: managedLocalSourceGuard(agent, issueId, readNonEmptyString(context.executionIdentityRunId)),
});
projectCwd = resolvedCwd.cwd;
managedWorkspaceWarning = resolvedCwd.warning;
} catch (error) {
const scrubbedError = scrubGitCredentialText(
error instanceof Error ? error.message : String(error),
);
const workspaceRepoUrl = readNonEmptyString(workspace.repoUrl);
const connectionFailure = readGitConnectionFailure(error);
materializationFailures.push({
projectWorkspaceId: workspace.id,
repoUrl: workspaceRepoUrl
? scrubGitCredentialText(workspaceRepoUrl)
: null,
error: scrubbedError,
...(connectionFailure ? { connectionFailure } : {}),
});
if (preferredWorkspace?.id === workspace.id) {
preferredWorkspaceWarning = scrubbedError;
}
continue;
}
hasConfiguredProjectCwd = true;
const projectCwdExists = await fs
.stat(projectCwd)
.then((stats) => stats.isDirectory())
.catch(() => false);
if (projectCwdExists) {
return {
cwd: projectCwd,
source: "project_primary" as const,
projectId: resolvedProjectId,
workspaceId: workspace.id,
repoUrl: workspace.repoUrl,
repoRef: workspace.repoRef,
workspaceHints,
warnings: [
preferredWorkspaceWarning,
managedWorkspaceWarning,
].filter((value): value is string => Boolean(value)),
baseCwdFallback: false,
materializationFailures,
localPathOnlyWorkspace:
(workspace.sourceType === "local_path" || workspace.sourceType === "non_git_path") &&
Boolean(readNonEmptyString(workspace.cwd)) && workspace.cwd !== REPO_ONLY_CWD_SENTINEL &&
!readNonEmptyString(workspace.repoUrl),
};
}
if (preferredWorkspace?.id === workspace.id) {
preferredWorkspaceWarning = `Selected project workspace path "${projectCwd}" is not available yet.`;
}
missingProjectCwds.push(projectCwd);
}
const connections = materializationFailures.map((failure) => readConnectionFailure(failure.connectionFailure));
const connectionFailure = connections.length > 0 && connections.every((failure) => failure?.provider === "git")
? connections[0] as GitConnectionFailure : undefined;
throw new WorkspaceValidationFailure(
`Configured project workspace is unavailable: ${preferredWorkspaceWarning ?? materializationFailures[0]?.error ?? missingProjectCwds[0] ?? "source could not be prepared"}. Repair the configured path or repository access, then retry.`,
{
...(connectionFailure ? { connectionFailure } : {}),
workspaceValidation: {
reason: materializationFailures.length > 0 ? "git_worktree_base_materialization_failed" : "configured_workspace_unavailable",
issueId,
issueProjectId,
issueProjectWorkspaceId: preferredProjectWorkspaceId,
resolvedWorkspaceSource: "project_primary",
resolvedProjectId,
resolvedProjectWorkspaceId: preferredWorkspace?.id ?? null,
baseCwdFallback: false,
materializationFailures,
},
},
);
}
const sessionCwd = readNonEmptyString(previousSessionParams?.cwd);
const sessionCwdLooksUnsafe = isUnsafeSessionWorkspaceCwd(sessionCwd);
if (!forceTaskDirectory && sessionCwd && !sessionCwdLooksUnsafe) {
const sessionCwdExists = await fs
.stat(sessionCwd)
.then((stats) => stats.isDirectory())
.catch(() => false);
if (sessionCwdExists) {
return {
cwd: sessionCwd,
source: "task_session" as const,
projectId: resolvedProjectId,
workspaceId: readNonEmptyString(previousSessionParams?.workspaceId),
repoUrl: readNonEmptyString(previousSessionParams?.repoUrl),
repoRef: readNonEmptyString(previousSessionParams?.repoRef),
workspaceHints,
warnings: [],
baseCwdFallback: false,
materializationFailures: [],
};
}
}
const configuredCwd = forceTaskDirectory ? null
: opts?.configuredCwd !== undefined ? opts.configuredCwd
: readNonEmptyString(parseObject(agent.adapterConfig).cwd);
const cwd = configuredCwd ? path.resolve(configuredCwd)
: issueId ? await materializeIsolatedTaskDirectory({ companyId: agent.companyId, issueId })
: resolveDefaultAgentWorkspaceDir(agent.id);
await fs.mkdir(cwd, { recursive: true });
const warnings: string[] = [];
if (sessionCwd && sessionCwdLooksUnsafe) {
warnings.push(
`Saved session workspace "${sessionCwd}" points at a system temp root and was rejected as untrusted. Using fallback workspace "${cwd}" for this run.`,
);
} else if (sessionCwd) {
warnings.push(
`Saved session workspace "${sessionCwd}" is not available. Using fallback workspace "${cwd}" for this run.`,
);
} else if (resolvedProjectId) {
warnings.push(
`No project workspace directory is currently available for this issue. Using fallback workspace "${cwd}" for this run.`,
);
} else {
warnings.push(
`No project or prior session workspace was available. Using fallback workspace "${cwd}" for this run.`,
);
}
return {
cwd,
source: issueId || configuredCwd ? "task_session" as const : "agent_home" as const,
projectId: null,
workspaceId: null,
repoUrl: null,
repoRef: null,
workspaceHints,
warnings,
baseCwdFallback: false,
materializationFailures: [],
};
}
/**
* Resolve the run workspace: the anchor workspace plus, when the multi-project workspace-sync
* flag is on, the read-only referenced (mentioned) project workspaces. With the flag off (the
* production default) the anchor path is unchanged and `additionalWorkspaces` is empty.
*/
async function resolveWorkspaceForRun(
agent: typeof agents.$inferSelect,
context: Record<string, unknown>,
previousSessionParams: Record<string, unknown> | null,
opts?: {
useProjectWorkspace?: boolean | null;
configuredCwd?: string | null;
executionEnvironmentDriver?: string | null;
storageProviderPluginId?: string | null;
anchorWorkspace?: ResolvedAnchorWorkspaceForRun;
},
): Promise<ResolvedWorkspaceForRun> {
const anchor = opts?.anchorWorkspace ?? await resolveAnchorWorkspaceForRun(
agent,
context,
previousSessionParams,
opts,
);
if (!isMultiProjectWorkspaceSyncEnabled()) {
return {
...anchor,
additionalWorkspaces: [],
referencedProjectFailures: [],
};
}
// Derive the remote-transport facts from the selected environment driver. `executionTargetIsRemote`
// decides whether the referenced set needs the remote path at all; `targetStagesConfined` decides
// whether that remote target confines each staged tree (only the sandbox driver does). The remote
// flag is the targeted kill switch; with it off, a remote run fails closed.
const executionEnvironmentDriver = opts?.executionEnvironmentDriver ?? null;
const issueId =
readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
const responsibleUserId = readNonEmptyString(context.responsibleUserId)
?? readNonEmptyString(context.responsible_user_id);
const referencedOptions: ResolveRunReferencedProjectsOptions = {
companyId: agent.companyId,
actor: { type: "agent", agentId: agent.id, companyId: agent.companyId, source: "agent_key",
runId: readNonEmptyString(context.executionIdentityRunId),
onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId },
issues: issueService(db), projects: projectService(db), access: authorizationService(db),
};
if (executionEnvironmentDriver === "plugin" && agent.adapterType === "paperclip_runner" &&
issueId && isMultiProjectWorkspaceSyncRemoteEnabled()) {
const referenced = await resolveRunReferencedProjects(issueId, anchor.projectId, referencedOptions);
return { ...anchor, additionalWorkspaces: [],
runnerTaskProjectIds: [anchor.projectId, ...referenced.additional.map(project => project.projectId)]
.filter((id): id is string => id !== null),
referencedProjectFailures: referenced.failures, warnings: [...anchor.warnings, ...referenced.warnings] };
}
const resolveGitAuth = createGitRemoteAuthProvider(db, agent.companyId, {
issueId,
heartbeatRunId: readNonEmptyString(context.executionIdentityRunId),
responsibleUserId,
agentId: agent.id,
});
const { additionalWorkspaces, warnings, failures } =
await resolveAdditionalRunWorkspaces(issueId, anchor.projectId, {
enabled: true,
executionTargetIsRemote: isRemoteExecutionEnvironmentDriver(
executionEnvironmentDriver,
),
targetStagesConfined: isConfinedRemoteStagingDriver(
executionEnvironmentDriver,
),
remoteReferencedSyncEnabled: isMultiProjectWorkspaceSyncRemoteEnabled(),
...referencedOptions,
resolveProjectWorkspace: (project) =>
resolveAdditionalProjectWorkspace(
{ companyId: agent.companyId, project },
defaultAdditionalProjectWorkspaceDeps(db, resolveGitAuth,
managedLocalSourceGuard(agent, issueId, readNonEmptyString(context.executionIdentityRunId))),
),
});
return {
...anchor,
additionalWorkspaces,
referencedProjectFailures: failures,
warnings:
warnings.length > 0
? [...anchor.warnings, ...warnings]
: anchor.warnings,
};
}
return {
resolveReusedGitWorkspaceAnchor,
resolveWorkspaceForRun,
};
}