mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 08:28:48 +01:00
3785 lines
150 KiB
TypeScript
3785 lines
150 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 read-only referenced projects that each passed per-project `project:read` authorization. */
|
|
additional: RunReferencedProject[];
|
|
/** Human-readable warnings for every referenced project that was dropped (unavailable, unauthorized, or capped). */
|
|
warnings: string[];
|
|
/** Structured record of every dropped referenced project, paired with the layer that dropped it. */
|
|
failures: ReferencedProjectFailure[];
|
|
}
|
|
|
|
export interface ResolveRunReferencedProjectsOptions {
|
|
companyId: string;
|
|
/** The run actor; every additional project is authorized against this actor. */
|
|
actor: AuthorizationActor;
|
|
issues: Pick<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;
|
|
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 = await resolveInstructionsConfigFingerprintMetadata(
|
|
input.effectiveAdapterConfig,
|
|
);
|
|
const categoryValues = buildSessionConfigCategoryValues({
|
|
adapterType: input.adapterType,
|
|
effectiveAdapterConfig: managedAiSessionFingerprintConfig(input.effectiveAdapterConfig, input.managedAiHome),
|
|
agentRuntimeConfig: input.agentRuntimeConfig,
|
|
instructions,
|
|
issueOverrides: input.issueOverrides,
|
|
workspaceConfig: input.workspaceConfig,
|
|
environment: input.environment,
|
|
environmentEnv: input.environmentEnv,
|
|
projectEnv: input.projectEnv,
|
|
routineEnv: input.routineEnv,
|
|
secretManifest,
|
|
runtimeSkills: input.runtimeSkills,
|
|
agentConfigRevision: input.agentConfigRevision ?? null,
|
|
agentIdentityKeyId: input.agentIdentityKeyId,
|
|
});
|
|
const fingerprints = createEffectiveRunConfigFingerprints({
|
|
session: categoryValues,
|
|
secretManifest,
|
|
});
|
|
const categoryFingerprints = createEffectiveRunConfigSubcategoryFingerprints({
|
|
category: "session",
|
|
value: categoryValues,
|
|
subcategories: EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES,
|
|
secretManifest,
|
|
});
|
|
return {
|
|
version: EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION,
|
|
fingerprint: fingerprints.sessionFingerprint.fingerprint,
|
|
categories: [...EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES],
|
|
categoryFingerprints,
|
|
fingerprints,
|
|
};
|
|
}
|
|
|
|
function buildWorkspaceConfigCategoryValues(input: {
|
|
mode: unknown;
|
|
projectId: unknown;
|
|
projectWorkspaceId: unknown;
|
|
strategyType: unknown;
|
|
workspaceStrategy: unknown;
|
|
repoUrl: unknown;
|
|
repoRef: unknown;
|
|
branchName: unknown;
|
|
configSnapshot: Partial<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,
|
|
};
|
|
}
|