mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Tasks can use isolated repository workspaces. > - Core currently prepares those repositories on its own filesystem. > - A storage provider may only expose repositories inside remote agent computers. > - Project storage and task workspace preparation must have one provider owner. > - This pull request adds that contract and keeps execution as a separate plugin interface. > - Core keeps authorization, durable task bindings, and lifecycle fences. ## Linked Issues or Issue Description **Subsystem affected** Plugin SDK, project workspace policy, heartbeat preparation, execution leases, and workspace lifecycle. **Problem or motivation** Core cannot prepare a task workspace when its repository storage is unavailable on the Core host. An execution lease must not be required just to prepare persistent storage. **Proposed solution** Let one plugin provide project resource lifecycle and workspace prepare, inspect, restore, and remove behavior. Core reserves a task workspace before it calls the plugin. The plugin returns an opaque durable reference. The matching execution plugin receives that reference on its task RPC. **Alternatives considered** Preparing repositories on Core would require a provider mount on Core. Preparing them only during task launch would tie persistent workspace lifecycle to execution leases and agent availability. **Roadmap alignment** This extends the remote provider model in ROADMAP.md. Related execution work is in #16978. This PR does not replace that task bridge. Native execution of managed storage requires the bridge; this change fails closed when it is absent. ## What Changed - Add one storage provider declaration with supported modes/strategies, a capability, a validated RPC contract, and a test harness. - Persist provider ownership, repository revision, and execution-workspace identity before external effects. Reconcile retries with a stable operation ID. - Prepare provider storage before environment acquisition. Keep Core filesystem preparation out of this path. - Pin the durable reference to the matching execution plugin and validate the lease binding before task dispatch. - Route inspection, reopen, and removal through the existing lifecycle process. Keep local data when remote recovery is uncertain. - Keep the application database pool free during lifecycle locks. Preserve pending workspace selections, validate source edits before retirement, and retry unfinished closed storage cleanup. - Protect provider metadata and live repository sources from caller edits. Storage edits retire only closed records with confirmed removal; retired records cannot reopen. ## Verification - Current head `8214b3ca5`: all 25 provider lifecycle tests pass with `maxConnections=1`. They cover preparation, inspection, reopening, removal, a paused provider with responsive ordinary SQL and a competing source edit, pending selection preservation, rejected source edits, cleanup retry scheduling, and providers that cache operation results, and committed cleanup notifications for storage intent and recovered state. - Final frozen-tree lifecycle run on `8214b3ca5`: all 318 tests passed in 10 files, covering workspace services, reopen, compaction, task binding, project sources, heartbeat preparation, display caching, and source locks. - Direct server `tsc --noEmit` passes. The SDK build and all 128 SDK tests passed during initial verification; later SDK changes are documentation only. - Full CI on the previous head `ec5c7209a` passed all 55 applicable checks; 2 conditional checks skipped. Standard Greptile reviewed current head `342e6d9c3` at 5/5 with no unresolved threads. All 55 applicable CI checks passed on `342e6d9c3`; 2 conditional checks skipped. The Slack browser and CLI startup jobs passed on their single unchanged-head rerun. Its Apex review returned 4/5 with one pending-restoration issue. Commit `1b58943b1` reconciles that durable intent before cleanup, advances the generation without reopening the Core row, and blocks direct removal or retirement while intent remains unresolved. Standard Greptile reviewed `1b58943b1` at 5/5 with no new findings. All 54 applicable CI checks passed on `1b58943b1`; 3 conditional checks skipped. The chat job passed on its single unchanged-head rerun. Apex then returned 4/5 for a missing cleanup wake after recovery. Commit `8214b3ca5` atomically registers cleanup intent before storage intent and recovery-result writes. Its provider test observes real committed notification hints and the visible advanced generation. Standard Greptile reviewed `8214b3ca5` at 5/5 with no new findings. All 55 applicable current-head CI checks passed on the first run; 2 conditional checks skipped. Final Apex verification completed at 5/5 on this exact head: 38 files reviewed, no new comments, and no unresolved threads. The PR is ready for review and mergeable. - The first Apex review found five issues after a standard 5/5: application-pool starvation, stale pending selections, retirement during rejected source edits, lost cleanup retries, and reused inspection identities. Commits `6d72e7fd4` and `342e6d9c3` fix each with regression coverage. Archive lock waits use dedicated connections, and source edits fail promptly if the lifecycle is busy. Retirement compares normalized values. The normalized-source regression initially failed with the raw-value comparison; after moving the check after normalization, a clean provider-suite rerun passed all 24 tests at that stage. Those threads are resolved; the earlier Apex 0/5 is not a passing acceptance result. - Earlier local integration attempts could not bind localhost inside the sandbox. The same suites passed with permission to start local PostgreSQL. - The earlier broad local `pnpm test:run` overlapped edits and reported stale-revision and checkout-path/Git-fixture failures. It was not a final-tree result. Full CI subsequently passed; the original local failure remains part of verification history. - Local repository-wide typecheck/build cannot complete because `cargo` is unavailable. Direct server TypeScript checks and affected tests pass; full Rust and build validation comes from CI. - An earlier browser run on `ff918c5a8` failed in the unchanged case-filter journey: expected “Done,” received “All statuses,” with an announcement handler recorded during the assertion. The cause is not confirmed. [The failed job](https://github.com/paperclipai/paperclip/actions/runs/39500611857/job/118896954508) remains verification history. The unchanged shard passed in the next full CI run on `ec5c7209a`; no UI or fixture change was made. - Current-head browser shard 2 failed waiting five seconds for the mocked Slack setup Success heading. This PR changes neither that UI nor its fixture. [The failed job](https://github.com/paperclipai/paperclip/actions/runs/39506070513/job/118916021232) remains verification evidence. The cause is not confirmed; the single unchanged-shard rerun passed; no UI or fixture change was made, and the original cause remains unconfirmed. - The workspace test shard passed its 3,978 general tests, then its CLI company-import/export fixture timed out after 30 seconds waiting for startup health before creating any company or workspace. [The failed job](https://github.com/paperclipai/paperclip/actions/runs/39506070513/job/118916021855) remains verification history. The child reached external-PostgreSQL startup without exiting or reporting an exception. Its cause is not confirmed; the single unchanged-job rerun passed, including the CLI fixture. No startup or fixture change was made, and the original cause remains unconfirmed. - The new cached-removal regression initially failed because its fake worker advertised availability for unrelated fixture plugins, so another workspace changed its fake checkout state. Worker availability now matches the fixture plugin; the complete 25-test provider suite passes. - On `1b58943b1`, the chat shard timed out polling for one conversation after an authorized GitHub description edit. [The failed job](https://github.com/paperclipai/paperclip/actions/runs/39510521657/job/118930193418) remains evidence. The exact case passes locally against PostgreSQL (1 passed, 1,380 unrelated cases skipped), and this PR changes neither that test nor its handler. The original cause is unconfirmed; the single unchanged-job CI rerun passed without a code or fixture change. The original cause remains unconfirmed. - The diff was checked for credentials, private references, and generated artifacts. ## Risks This is an additive provider contract with no database migration. Existing projects keep their local behavior unless their policy selects a storage plugin. A selected provider must be ready and enabled. Unknown or unsafe cleanup fails closed. Managed native execution depends on #16978. Core refuses a host Runner fallback. Providers must fence lifecycle generations, preserve ambiguous retries, and report remote recovery honestly. Provider-specific live storage and guest mount qualification remain separate from these Core contract tests. ## Model Used OpenAI Codex (GPT-6), with repository inspection, code execution, and tests. The exact model variant was not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either linked existing issues or described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] I have run tests locally and they pass (affected suites; broad-run limits are documented above) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
498 lines
21 KiB
TypeScript
498 lines
21 KiB
TypeScript
import type {
|
|
ExecutionWorkspaceMode,
|
|
ExecutionWorkspaceStrategy,
|
|
IssueExecutionWorkspaceSettings,
|
|
ProjectExecutionWorkspaceDefaultMode,
|
|
ProjectExecutionWorkspacePolicy,
|
|
SharedWorkspaceConcurrency,
|
|
} from "@paperclipai/shared";
|
|
import { asString, parseObject } from "../adapters/utils.js";
|
|
|
|
export type ParsedExecutionWorkspaceMode = Exclude<ExecutionWorkspaceMode, "inherit" | "reuse_existing">;
|
|
|
|
export const WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE = "workspace_worktree_requires_project";
|
|
export const WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION =
|
|
"Attach a project to the task, or bind a reusable execution workspace, then retry.";
|
|
export const WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE =
|
|
`This task is set to run in an isolated git worktree, but it has no project and no reusable execution workspace to create the worktree from. ${WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION}`;
|
|
|
|
type WorkspaceStrategyType = ExecutionWorkspaceStrategy["type"];
|
|
|
|
export type UnrunnableWorktreeIssueRef = {
|
|
projectId?: string | null;
|
|
projectWorkspaceId?: string | null;
|
|
executionWorkspaceId?: string | null;
|
|
executionWorkspacePreference?: string | null;
|
|
};
|
|
|
|
function cloneRecord(value: Record<string, unknown> | null | undefined): Record<string, unknown> | null {
|
|
if (!value) return null;
|
|
return { ...value };
|
|
}
|
|
|
|
function parseExecutionWorkspaceStrategy(raw: unknown): ExecutionWorkspaceStrategy | null {
|
|
const parsed = parseObject(raw);
|
|
const type = asString(parsed.type, "");
|
|
if (type !== "project_primary" && type !== "git_worktree" && type !== "adapter_managed" && type !== "cloud_sandbox") {
|
|
return null;
|
|
}
|
|
return {
|
|
type,
|
|
...(typeof parsed.baseRef === "string" || parsed.baseRef === null ? { baseRef: parsed.baseRef } : {}),
|
|
...(typeof parsed.branchTemplate === "string" || parsed.branchTemplate === null ? { branchTemplate: parsed.branchTemplate } : {}),
|
|
...(typeof parsed.existingBranch === "string" && parsed.existingBranch.trim().length > 0
|
|
? { existingBranch: parsed.existingBranch.trim() }
|
|
: {}),
|
|
...(typeof parsed.worktreeParentDir === "string" || parsed.worktreeParentDir === null ? { worktreeParentDir: parsed.worktreeParentDir } : {}),
|
|
...(typeof parsed.provisionCommand === "string" || parsed.provisionCommand === null ? { provisionCommand: parsed.provisionCommand } : {}),
|
|
...(typeof parsed.runtimeProvisionCommand === "string" || parsed.runtimeProvisionCommand === null
|
|
? { runtimeProvisionCommand: parsed.runtimeProvisionCommand }
|
|
: {}),
|
|
...(typeof parsed.teardownCommand === "string" || parsed.teardownCommand === null ? { teardownCommand: parsed.teardownCommand } : {}),
|
|
};
|
|
}
|
|
|
|
export function resolveEffectiveWorkspaceStrategyType(
|
|
mode: ParsedExecutionWorkspaceMode,
|
|
config: Record<string, unknown> | null | undefined,
|
|
): WorkspaceStrategyType {
|
|
const workspaceStrategy = parseObject(config?.workspaceStrategy);
|
|
const type = asString(workspaceStrategy.type, "");
|
|
if (type === "project_primary" || type === "git_worktree" || type === "adapter_managed" || type === "cloud_sandbox") {
|
|
return type;
|
|
}
|
|
// Default mirrors workspace-runtime.ts realizeExecutionWorkspace: missing type -> "project_primary".
|
|
// agent_default is a metadata-only mode that never creates a worktree, so it keeps "adapter_managed".
|
|
return mode === "agent_default" ? "adapter_managed" : "project_primary";
|
|
}
|
|
|
|
export function resolvePinnedIssueWorkspaceStrategyType(input: {
|
|
mode: ParsedExecutionWorkspaceMode;
|
|
issueSettings: IssueExecutionWorkspaceSettings | null;
|
|
}): WorkspaceStrategyType {
|
|
const strategyType = input.issueSettings?.workspaceStrategy?.type;
|
|
if (
|
|
strategyType === "project_primary" ||
|
|
strategyType === "git_worktree" ||
|
|
strategyType === "adapter_managed" ||
|
|
strategyType === "cloud_sandbox"
|
|
) {
|
|
return strategyType;
|
|
}
|
|
// When no explicit strategy type is set, mirror the runtime default (project_primary for most
|
|
// modes; adapter_managed for agent_default). Mode alone never implies git_worktree.
|
|
return input.mode === "agent_default" ? "adapter_managed" : "project_primary";
|
|
}
|
|
|
|
export function hasReusableExecutionWorkspaceBinding(issue: UnrunnableWorktreeIssueRef): boolean {
|
|
return Boolean(issue.executionWorkspaceId && issue.executionWorkspacePreference === "reuse_existing");
|
|
}
|
|
|
|
export function isUnrunnableWorktreeCombo(input: {
|
|
issue: UnrunnableWorktreeIssueRef;
|
|
resolvedMode: ParsedExecutionWorkspaceMode;
|
|
resolvedStrategy: string | null | undefined;
|
|
reusableExecutionWorkspaceAvailable?: boolean | null;
|
|
hasResolvablePriorSessionWorkspace?: boolean | null;
|
|
}): boolean {
|
|
if (input.resolvedMode !== "isolated_workspace" && input.resolvedMode !== "operator_branch") return false;
|
|
if (input.resolvedStrategy !== "git_worktree") return false;
|
|
if (input.issue.projectId || input.issue.projectWorkspaceId) return false;
|
|
const hasReusableWorkspace =
|
|
input.reusableExecutionWorkspaceAvailable ?? hasReusableExecutionWorkspaceBinding(input.issue);
|
|
if (hasReusableWorkspace) return false;
|
|
return input.hasResolvablePriorSessionWorkspace !== true;
|
|
}
|
|
|
|
export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecutionWorkspacePolicy | null {
|
|
const parsed = parseObject(raw);
|
|
if (Object.keys(parsed).length === 0) return null;
|
|
const enabled = typeof parsed.enabled === "boolean" ? parsed.enabled : false;
|
|
const workspaceStrategy = parseExecutionWorkspaceStrategy(parsed.workspaceStrategy);
|
|
const defaultMode = asString(parsed.defaultMode, "");
|
|
const defaultProjectWorkspaceId =
|
|
typeof parsed.defaultProjectWorkspaceId === "string" ? parsed.defaultProjectWorkspaceId : undefined;
|
|
const allowIssueOverride =
|
|
typeof parsed.allowIssueOverride === "boolean" ? parsed.allowIssueOverride : undefined;
|
|
const sharedWorkspaceConcurrency = parseSharedWorkspaceConcurrency(parsed.sharedWorkspaceConcurrency);
|
|
const normalizedDefaultMode = (() => {
|
|
if (
|
|
defaultMode === "shared_workspace" ||
|
|
defaultMode === "isolated_workspace" ||
|
|
defaultMode === "operator_branch" ||
|
|
defaultMode === "adapter_default"
|
|
) {
|
|
return defaultMode as ProjectExecutionWorkspaceDefaultMode;
|
|
}
|
|
if (defaultMode === "project_primary") return "shared_workspace";
|
|
if (defaultMode === "isolated") return "isolated_workspace";
|
|
return undefined;
|
|
})();
|
|
return {
|
|
enabled,
|
|
...(typeof parsed.storageProviderPluginId === "string" ? { storageProviderPluginId: parsed.storageProviderPluginId } : {}),
|
|
...(sharedWorkspaceConcurrency ? { sharedWorkspaceConcurrency } : {}),
|
|
...(normalizedDefaultMode ? { defaultMode: normalizedDefaultMode } : {}),
|
|
...(allowIssueOverride !== undefined ? { allowIssueOverride } : {}),
|
|
...(defaultProjectWorkspaceId ? { defaultProjectWorkspaceId } : {}),
|
|
...(workspaceStrategy ? { workspaceStrategy } : {}),
|
|
...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime)
|
|
? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record<string, unknown>) } }
|
|
: {}),
|
|
...(parsed.branchPolicy && typeof parsed.branchPolicy === "object" && !Array.isArray(parsed.branchPolicy)
|
|
? { branchPolicy: { ...(parsed.branchPolicy as Record<string, unknown>) } }
|
|
: {}),
|
|
...(parsed.pullRequestPolicy && typeof parsed.pullRequestPolicy === "object" && !Array.isArray(parsed.pullRequestPolicy)
|
|
? { pullRequestPolicy: { ...(parsed.pullRequestPolicy as Record<string, unknown>) } }
|
|
: {}),
|
|
...(parsed.runtimePolicy && typeof parsed.runtimePolicy === "object" && !Array.isArray(parsed.runtimePolicy)
|
|
? { runtimePolicy: { ...(parsed.runtimePolicy as Record<string, unknown>) } }
|
|
: {}),
|
|
...(parsed.cleanupPolicy && typeof parsed.cleanupPolicy === "object" && !Array.isArray(parsed.cleanupPolicy)
|
|
? { cleanupPolicy: { ...(parsed.cleanupPolicy as Record<string, unknown>) } }
|
|
: {}),
|
|
...(parsed.authorizationPolicy && typeof parsed.authorizationPolicy === "object" && !Array.isArray(parsed.authorizationPolicy)
|
|
? { authorizationPolicy: { ...(parsed.authorizationPolicy as Record<string, unknown>) } }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
export function gateProjectExecutionWorkspacePolicy(
|
|
projectPolicy: ProjectExecutionWorkspacePolicy | null,
|
|
isolatedWorkspacesEnabled: boolean,
|
|
): ProjectExecutionWorkspacePolicy | null {
|
|
if (!isolatedWorkspacesEnabled) return null;
|
|
return projectPolicy;
|
|
}
|
|
|
|
/**
|
|
* Operator default: a project with a configured workspace and no policy of its
|
|
* own runs its tasks in an isolated per-task worktree.
|
|
*
|
|
* This substitutes a policy rather than moving the terminal fallback in
|
|
* `resolveExecutionWorkspaceMode`, and the distinction is load-bearing:
|
|
*
|
|
* - A task with no project must keep its existing behavior. Isolation needs a
|
|
* repository to cut a worktree from, and `isUnrunnableWorktreeCombo` blocks
|
|
* an isolated + `git_worktree` task that has neither `projectId` nor
|
|
* `projectWorkspaceId`. Moving the terminal fallback would resolve isolated
|
|
* for project-less tasks (agent chat, for example) and strand them before
|
|
* dispatch. A project without a configured workspace also uses a plain
|
|
* managed directory, not a Git checkout. `hasProjectWorkspace` keeps both
|
|
* cases on their existing path; configured checkouts are still validated
|
|
* before a worktree is created.
|
|
* - `buildExecutionWorkspaceAdapterConfig` only supplies the default
|
|
* `git_worktree` strategy when some layer actually asserts workspace
|
|
* control. A moved fallback would leave `hasWorkspaceControl` false and
|
|
* produce isolated mode carrying a `project_primary` strategy — a
|
|
* combination no caller expects. Substituting a real policy makes
|
|
* `projectHasPolicy` true, so mode and strategy stay coherent.
|
|
*
|
|
* A stored project policy always wins, including one that is explicitly
|
|
* disabled: `parseProjectExecutionWorkspacePolicy` returns `enabled: false`
|
|
* for a blob that never opted in, and that is a tenant decision to stay on the
|
|
* shared checkout, not an absent one to fill in.
|
|
*/
|
|
export function applyDefaultIsolatedExecutionWorkspacePolicy(input: {
|
|
projectPolicy: ProjectExecutionWorkspacePolicy | null;
|
|
defaultIsolatedWorkspacesEnabled: boolean;
|
|
hasProjectWorkspace: boolean;
|
|
}): ProjectExecutionWorkspacePolicy | null {
|
|
if (!input.defaultIsolatedWorkspacesEnabled) return input.projectPolicy;
|
|
if (!input.hasProjectWorkspace) return input.projectPolicy;
|
|
if (input.projectPolicy) return input.projectPolicy;
|
|
return { enabled: true, defaultMode: "isolated_workspace" };
|
|
}
|
|
|
|
type ParseIssueExecutionWorkspaceSettingsOptions = {
|
|
includeEnvironmentId?: boolean;
|
|
};
|
|
|
|
export function parseIssueExecutionWorkspaceSettings(
|
|
raw: unknown,
|
|
options: ParseIssueExecutionWorkspaceSettingsOptions = {},
|
|
): IssueExecutionWorkspaceSettings | null {
|
|
const parsed = parseObject(raw);
|
|
if (Object.keys(parsed).length === 0) return null;
|
|
const workspaceStrategy = parseExecutionWorkspaceStrategy(parsed.workspaceStrategy);
|
|
const sharedWorkspaceConcurrency = parseSharedWorkspaceConcurrency(parsed.sharedWorkspaceConcurrency);
|
|
const mode = asString(parsed.mode, "");
|
|
const normalizedMode = (() => {
|
|
if (
|
|
mode === "inherit" ||
|
|
mode === "shared_workspace" ||
|
|
mode === "isolated_workspace" ||
|
|
mode === "operator_branch" ||
|
|
mode === "reuse_existing" ||
|
|
mode === "agent_default"
|
|
) {
|
|
return mode;
|
|
}
|
|
if (mode === "project_primary") return "shared_workspace";
|
|
if (mode === "isolated") return "isolated_workspace";
|
|
return "";
|
|
})();
|
|
const networkEgress = parseObject(parsed.networkEgress);
|
|
const allowFqdns = Array.isArray(networkEgress.allowFqdns)
|
|
? networkEgress.allowFqdns
|
|
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
|
.map((value) => value.trim().toLowerCase())
|
|
: [];
|
|
const allowCidrs = Array.isArray(networkEgress.allowCidrs)
|
|
? networkEgress.allowCidrs
|
|
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
|
.map((value) => value.trim())
|
|
: [];
|
|
return {
|
|
...(normalizedMode
|
|
? { mode: normalizedMode as IssueExecutionWorkspaceSettings["mode"] }
|
|
: {}),
|
|
...(sharedWorkspaceConcurrency ? { sharedWorkspaceConcurrency } : {}),
|
|
...(options.includeEnvironmentId && (typeof parsed.environmentId === "string" || parsed.environmentId === null)
|
|
? { environmentId: parsed.environmentId }
|
|
: {}),
|
|
...(workspaceStrategy ? { workspaceStrategy } : {}),
|
|
...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime)
|
|
? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record<string, unknown>) } }
|
|
: {}),
|
|
...(allowFqdns.length > 0 || allowCidrs.length > 0
|
|
? { networkEgress: { allowFqdns, allowCidrs } }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
export function selectEnvironmentExecutionWorkspaceSettings(
|
|
parsedSettings: IssueExecutionWorkspaceSettings | null,
|
|
isolatedWorkspacesEnabled: boolean,
|
|
): IssueExecutionWorkspaceSettings | null {
|
|
if (!parsedSettings) return null;
|
|
if (isolatedWorkspacesEnabled) return parsedSettings;
|
|
return parsedSettings.networkEgress
|
|
? { networkEgress: parsedSettings.networkEgress }
|
|
: null;
|
|
}
|
|
|
|
export type ExecutionWorkspaceEnvironmentSource =
|
|
| "issue"
|
|
| "agent"
|
|
| "instance"
|
|
| "default"
|
|
| "managed";
|
|
|
|
export type ExecutionWorkspaceEnvironmentResolution = {
|
|
environmentId: string;
|
|
source: ExecutionWorkspaceEnvironmentSource;
|
|
};
|
|
|
|
export class ManagedSandboxUnavailableError extends Error {
|
|
constructor() {
|
|
super(
|
|
"This instance runs agents only in its platform-managed sandbox environment " +
|
|
"(managed sandbox only), but no active managed sandbox environment exists — " +
|
|
"its provider plugin may be unavailable. Refusing to fall back to local execution.",
|
|
);
|
|
this.name = "ManagedSandboxUnavailableError";
|
|
}
|
|
}
|
|
|
|
export function resolveExecutionWorkspaceEnvironmentId(input: {
|
|
/** Explicit operator-selected sandbox for a task with a low-trust boundary. */
|
|
lowTrustIssueEnvironmentId?: string | null;
|
|
agentDefaultEnvironmentId: string | null;
|
|
instanceDefaultEnvironmentId: string | null;
|
|
localDefaultEnvironmentId: string;
|
|
/**
|
|
* Managed-sandbox-only policy (`enableManagedSandboxOnly`): any selection
|
|
* that lands on the local environment is redirected to the managed
|
|
* sandbox environment instead, and with no managed environment available
|
|
* the resolution fails closed — never local. Non-local selections (ssh,
|
|
* user-created sandboxes) are untouched: the policy hides local, it does
|
|
* not forbid other environments.
|
|
*/
|
|
managedSandboxOnly?: boolean;
|
|
managedSandboxEnvironmentId?: string | null;
|
|
}): ExecutionWorkspaceEnvironmentResolution {
|
|
const resolved = ((): ExecutionWorkspaceEnvironmentResolution => {
|
|
if (input.lowTrustIssueEnvironmentId) {
|
|
return {environmentId: input.lowTrustIssueEnvironmentId, source: "issue"};
|
|
}
|
|
if (input.agentDefaultEnvironmentId) {
|
|
return {
|
|
environmentId: input.agentDefaultEnvironmentId,
|
|
source: "agent",
|
|
};
|
|
}
|
|
if (input.instanceDefaultEnvironmentId) {
|
|
return {
|
|
environmentId: input.instanceDefaultEnvironmentId,
|
|
source: "instance",
|
|
};
|
|
}
|
|
return {
|
|
environmentId: input.localDefaultEnvironmentId,
|
|
source: "default",
|
|
};
|
|
})();
|
|
if (input.managedSandboxOnly !== true || resolved.environmentId !== input.localDefaultEnvironmentId) {
|
|
return resolved;
|
|
}
|
|
if (!input.managedSandboxEnvironmentId) {
|
|
throw new ManagedSandboxUnavailableError();
|
|
}
|
|
return { environmentId: input.managedSandboxEnvironmentId, source: "managed" };
|
|
}
|
|
|
|
export function defaultIssueExecutionWorkspaceSettingsForProject(
|
|
projectPolicy: ProjectExecutionWorkspacePolicy | null,
|
|
): IssueExecutionWorkspaceSettings | null {
|
|
if (!projectPolicy?.enabled) return null;
|
|
return {
|
|
mode:
|
|
projectPolicy.defaultMode === "isolated_workspace"
|
|
? "isolated_workspace"
|
|
: projectPolicy.defaultMode === "operator_branch"
|
|
? "operator_branch"
|
|
: projectPolicy.defaultMode === "adapter_default"
|
|
? "agent_default"
|
|
: "shared_workspace",
|
|
};
|
|
}
|
|
|
|
export function issueExecutionWorkspaceModeForPersistedWorkspace(
|
|
mode: string | null | undefined,
|
|
): IssueExecutionWorkspaceSettings["mode"] {
|
|
if (mode === null || mode === undefined) {
|
|
return "agent_default";
|
|
}
|
|
if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") {
|
|
return mode;
|
|
}
|
|
if (mode === "adapter_managed" || mode === "cloud_sandbox") {
|
|
return "agent_default";
|
|
}
|
|
return "shared_workspace";
|
|
}
|
|
|
|
export function resolveExecutionWorkspaceMode(input: {
|
|
projectPolicy: ProjectExecutionWorkspacePolicy | null;
|
|
issueSettings: IssueExecutionWorkspaceSettings | null;
|
|
legacyUseProjectWorkspace: boolean | null;
|
|
}): ParsedExecutionWorkspaceMode {
|
|
const issueMode = input.issueSettings?.mode;
|
|
if (issueMode && issueMode !== "inherit" && issueMode !== "reuse_existing") {
|
|
return issueMode;
|
|
}
|
|
if (input.projectPolicy?.enabled) {
|
|
if (input.projectPolicy.defaultMode === "isolated_workspace") return "isolated_workspace";
|
|
if (input.projectPolicy.defaultMode === "operator_branch") return "operator_branch";
|
|
if (input.projectPolicy.defaultMode === "adapter_default") return "agent_default";
|
|
return "shared_workspace";
|
|
}
|
|
if (input.legacyUseProjectWorkspace === false) {
|
|
return "agent_default";
|
|
}
|
|
return "shared_workspace";
|
|
}
|
|
|
|
interface ParsedIssueAssigneeAdapterOverrides {
|
|
adapterConfig: Record<string, unknown> | null;
|
|
useProjectWorkspace: boolean | null;
|
|
}
|
|
|
|
export function parseIssueAssigneeAdapterOverrides(
|
|
raw: unknown,
|
|
): ParsedIssueAssigneeAdapterOverrides | null {
|
|
const parsed = parseObject(raw);
|
|
const parsedAdapterConfig = Object.fromEntries(
|
|
Object.entries(parseObject(parsed.adapterConfig)).filter(([key]) => key !== "homeMode"),
|
|
);
|
|
const adapterConfig =
|
|
Object.keys(parsedAdapterConfig).length > 0 ? parsedAdapterConfig : null;
|
|
const useProjectWorkspace =
|
|
typeof parsed.useProjectWorkspace === "boolean"
|
|
? parsed.useProjectWorkspace
|
|
: null;
|
|
if (!adapterConfig && useProjectWorkspace === null) return null;
|
|
return {
|
|
adapterConfig,
|
|
useProjectWorkspace,
|
|
};
|
|
}
|
|
|
|
function parseSharedWorkspaceConcurrency(raw: unknown): SharedWorkspaceConcurrency | undefined {
|
|
return raw === "auto" || raw === "serialize" || raw === "allow" ? raw : undefined;
|
|
}
|
|
|
|
export function resolveSharedWorkspaceConcurrency(input: {
|
|
projectPolicy: ProjectExecutionWorkspacePolicy | null;
|
|
issueSettings: IssueExecutionWorkspaceSettings | null;
|
|
}): SharedWorkspaceConcurrency {
|
|
return input.issueSettings?.sharedWorkspaceConcurrency
|
|
?? (input.projectPolicy?.enabled ? input.projectPolicy.sharedWorkspaceConcurrency : undefined)
|
|
?? "auto";
|
|
}
|
|
|
|
export function buildExecutionWorkspaceAdapterConfig(input: {
|
|
agentConfig: Record<string, unknown>;
|
|
adapterConfigOverrides?: Record<string, unknown> | null;
|
|
projectPolicy: ProjectExecutionWorkspacePolicy | null;
|
|
issueSettings: IssueExecutionWorkspaceSettings | null;
|
|
mode: ParsedExecutionWorkspaceMode;
|
|
legacyUseProjectWorkspace: boolean | null;
|
|
}): Record<string, unknown> {
|
|
const nextConfig = { ...input.agentConfig };
|
|
const projectHasPolicy = Boolean(input.projectPolicy?.enabled);
|
|
const issueHasWorkspaceOverrides = Boolean(
|
|
input.issueSettings?.mode ||
|
|
input.issueSettings?.workspaceStrategy ||
|
|
input.issueSettings?.workspaceRuntime,
|
|
);
|
|
const hasWorkspaceControl = projectHasPolicy || issueHasWorkspaceOverrides || input.legacyUseProjectWorkspace === false;
|
|
|
|
if (hasWorkspaceControl) {
|
|
if (input.mode === "isolated_workspace") {
|
|
const projectStrategy = projectHasPolicy ? input.projectPolicy?.workspaceStrategy : undefined;
|
|
const issueStrategy = input.issueSettings?.workspaceStrategy;
|
|
// An issue that changes its branch still needs the project's setup hooks.
|
|
// Do not carry those defaults into a different execution strategy.
|
|
const strategy = issueStrategy && projectStrategy?.type === issueStrategy.type
|
|
? { ...projectStrategy, ...issueStrategy }
|
|
: issueStrategy ?? projectStrategy ??
|
|
parseExecutionWorkspaceStrategy(nextConfig.workspaceStrategy) ??
|
|
({ type: "git_worktree" } satisfies ExecutionWorkspaceStrategy);
|
|
if (issueStrategy?.existingBranch && issueStrategy.branchTemplate === undefined && strategy !== issueStrategy) {
|
|
delete strategy.branchTemplate;
|
|
}
|
|
nextConfig.workspaceStrategy = strategy as unknown as Record<string, unknown>;
|
|
} else {
|
|
delete nextConfig.workspaceStrategy;
|
|
}
|
|
|
|
if (input.mode === "agent_default") {
|
|
delete nextConfig.workspaceRuntime;
|
|
} else if (input.issueSettings?.workspaceRuntime) {
|
|
nextConfig.workspaceRuntime = cloneRecord(input.issueSettings.workspaceRuntime) ?? undefined;
|
|
} else if (input.projectPolicy?.workspaceRuntime) {
|
|
nextConfig.workspaceRuntime = cloneRecord(input.projectPolicy.workspaceRuntime) ?? undefined;
|
|
}
|
|
}
|
|
|
|
return { ...nextConfig, ...input.adapterConfigOverrides };
|
|
}
|
|
|
|
export function taskWorkspaceRuntimeSelectionEnabled(input: {
|
|
legacyUiEnabled: boolean;
|
|
hasTypedSelection: boolean;
|
|
hasBinding: boolean;
|
|
}): boolean {
|
|
return input.legacyUiEnabled || input.hasTypedSelection || input.hasBinding;
|
|
}
|
|
|
|
export function canApplyTaskWorkspaceSelectionAtAdmission(input: {
|
|
admittedInput: unknown;
|
|
restarting: boolean;
|
|
hasLeaseOwner: boolean;
|
|
}): boolean {
|
|
return input.admittedInput == null && !input.restarting && !input.hasLeaseOwner;
|
|
}
|