refactor(environments): make execution environments instance-scoped (#8375)
## Thinking Path > - Paperclip is the control plane for AI-agent companies, so execution environment selection has to stay inspectable and predictable across companies, agents, and runs. > - The environment subsystem decides where an agent heartbeat actually runs and how remote sandbox state is realized and restored. > - That subsystem previously mixed company-scoped environment catalogs with issue-level environment stamping, so a reassigned issue could keep executing in the previous assignee's sandbox. > - That behavior breaks the control-plane contract: changing the assignee should change the executing agent/environment path unless there is an explicit current override. > - Fixing it cleanly required more than a narrow patch; the environment model had to move to instance scope with a single inherited default and per-agent override semantics. > - This pull request rewires the schema, server/API surface, runtime resolution, and UI around that model, then adds regression coverage for cross-company inheritance and per-agent isolation. > - The benefit is that environment choice now follows the approved instance/agent configuration path instead of stale issue state, while shared environments only need to be configured once per instance. ## Linked Issues or Issue Description - No directly matching public GitHub issue or PR was found while searching for this refactor. ### What happened? Reassigning work between agents with different execution environments could keep running in the previous sandbox because environment choice was stamped onto the issue and outranked the current assignee. The same subsystem also forced environment catalogs to be duplicated per company even though the underlying execution environments were instance-wide resources. ### Expected behavior Execution should resolve through the current instance and agent configuration path, with one instance-scoped environment catalog, one instance default, optional per-agent override, and no stale issue-level environment authority surviving reassignment. ### Steps to reproduce 1. Configure two agents to use different execution environments. 2. Assign an issue to the first agent so the issue records execution state in that environment. 3. Reassign the same issue to the second agent and run another heartbeat. 4. Observe that the pre-fix runtime can still sync or execute in the original sandbox instead of the second agent's environment. ### Paperclip version or commit Current `master` before this PR. ### Deployment mode Self-hosted server. ### Installation method Built from source (`pnpm dev` / `pnpm build`). ### Agent adapter(s) involved - Claude Code - Not adapter-specific (core bug in environment authority / resolution) ### Database mode External Postgres. ### Access context Both board reassignment and agent heartbeats were involved. ## What Changed - Moved environments and their default selection contract to instance scope in DB/shared types, including the migration that dedupes legacy per-company environments and seeds the instance local default. - Reworked environment CRUD/auth flows to use instance-scoped APIs and added route/service coverage for instance-level environment management. - Changed runtime resolution to prefer `agent default -> instance default -> built-in local`, removed issue-level environment stamping from the active execution path, and isolated sandbox/plugin leases by `(executionWorkspaceId, agentId)`. - Added environment env-var runtime precedence so environment-provided values act as the baseline for agent execution. - Moved the environment UI into instance settings and updated agent configuration surfaces to reflect inherit/override behavior. - Added regression coverage for instance-default inheritance across companies and for the new runtime resolution behavior. - Fixed a rebase-only duplicate `enableTaskWatchdogs` flag regression in instance settings types/validators/services so the branch typechecks cleanly on current `master`. - Updated stale server tests so CI matches the shipped instance-scoped environment contract. ## Verification - `git diff --check` - `pnpm --filter @paperclipai/shared typecheck` - `pnpm --filter @paperclipai/db typecheck` - `pnpm exec vitest run server/src/__tests__/environment-runtime-driver-contract.test.ts server/src/__tests__/agent-permissions-routes.test.ts server/src/__tests__/environment-routes.test.ts server/src/__tests__/environment-instance-routes.test.ts server/src/__tests__/execution-workspace-policy.test.ts server/src/__tests__/heartbeat-plugin-environment.test.ts server/src/__tests__/instance-settings-routes.test.ts` ## Risks - The migration changes environment scope and dedupes existing rows, so installs with unusual legacy environment combinations should be reviewed carefully during upgrade. - Remote execution behavior now depends on instance-default inheritance semantics instead of issue-level stamping, so any remaining code paths that still assume issue-scoped environment authority would surface as follow-up bugs. - This PR includes both server/runtime behavior and UI relocation, so reviewers should watch for authorization edge cases around instance settings and environment management. > I checked [`ROADMAP.md`](ROADMAP.md). This work fits the existing Cloud / Sandbox agents direction as a bug-fix/refactor to current behavior, not a new parallel product surface. ## Model Used - OpenAI Codex coding agent in this Paperclip/Codex session; GPT-5-class tool-using model with code execution and shell access. The exact backend model ID is not exposed to the session runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
@@ -2,6 +2,7 @@ import type { Environment, EnvironmentProbeResult } from "@paperclipai/shared";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { ensureSshWorkspaceReady } from "@paperclipai/adapter-utils/ssh";
|
||||
import {
|
||||
parseEnvironmentDriverConfig,
|
||||
resolveEnvironmentDriverConfigForRuntime,
|
||||
type ParsedEnvironmentConfig,
|
||||
} from "./environment-config.js";
|
||||
@@ -13,9 +14,18 @@ import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
export async function probeEnvironment(
|
||||
db: Db,
|
||||
environment: Environment,
|
||||
options: { pluginWorkerManager?: PluginWorkerManager; resolvedConfig?: ParsedEnvironmentConfig } = {},
|
||||
options: {
|
||||
companyId?: string | null;
|
||||
pluginWorkerManager?: PluginWorkerManager;
|
||||
resolvedConfig?: ParsedEnvironmentConfig;
|
||||
} = {},
|
||||
): Promise<EnvironmentProbeResult> {
|
||||
const parsed = options.resolvedConfig ?? await resolveEnvironmentDriverConfigForRuntime(db, environment.companyId, environment);
|
||||
const resolvedCompanyId = options.companyId ?? null;
|
||||
const parsed = options.resolvedConfig ?? (
|
||||
resolvedCompanyId
|
||||
? await resolveEnvironmentDriverConfigForRuntime(db, resolvedCompanyId, environment)
|
||||
: parseEnvironmentDriverConfig(environment)
|
||||
);
|
||||
|
||||
if (parsed.driver === "local") {
|
||||
return {
|
||||
@@ -44,7 +54,7 @@ export async function probeEnvironment(
|
||||
return await probePluginSandboxProviderDriver({
|
||||
db,
|
||||
workerManager: options.pluginWorkerManager,
|
||||
companyId: environment.companyId,
|
||||
companyId: resolvedCompanyId ?? "instance",
|
||||
environmentId: environment.id,
|
||||
provider: parsed.config.provider,
|
||||
config: parsed.config as unknown as Record<string, unknown>,
|
||||
@@ -68,7 +78,7 @@ export async function probeEnvironment(
|
||||
return await probePluginEnvironmentDriver({
|
||||
db,
|
||||
workerManager: options.pluginWorkerManager,
|
||||
companyId: environment.companyId,
|
||||
companyId: resolvedCompanyId ?? "instance",
|
||||
environmentId: environment.id,
|
||||
config: parsed.config,
|
||||
});
|
||||
|
||||
@@ -159,20 +159,19 @@ export function environmentRunOrchestrator(
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve the selected environment for a run. Ensures a local default
|
||||
* exists and resolves the priority chain:
|
||||
* execution workspace config > issue settings > project policy > agent default > company default
|
||||
* Resolve the selected environment for a run. The caller passes the concrete
|
||||
* selected environment id plus the built-in local fallback id used to lazily
|
||||
* ensure the local environment row exists.
|
||||
*/
|
||||
async function resolveEnvironment(input: {
|
||||
companyId: string;
|
||||
selectedEnvironmentId: string;
|
||||
defaultEnvironmentId: string;
|
||||
localEnvironmentId: string;
|
||||
}): Promise<Environment> {
|
||||
const environmentId =
|
||||
input.selectedEnvironmentId || input.defaultEnvironmentId;
|
||||
const environmentId = input.selectedEnvironmentId || input.localEnvironmentId;
|
||||
|
||||
const environment =
|
||||
environmentId === input.defaultEnvironmentId
|
||||
environmentId === input.localEnvironmentId
|
||||
? await environmentsSvc.ensureLocalEnvironment(input.companyId)
|
||||
: await environmentsSvc.getById(environmentId);
|
||||
|
||||
@@ -182,12 +181,6 @@ export function environmentRunOrchestrator(
|
||||
});
|
||||
}
|
||||
|
||||
if (environment.companyId !== input.companyId) {
|
||||
throw new EnvironmentRunError("environment_not_found", `Environment "${environmentId}" does not belong to this company.`, {
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
|
||||
if (environment.status !== "active") {
|
||||
throw new EnvironmentRunError("environment_inactive", `Environment "${environment.name}" is not active (status: ${environment.status}).`, {
|
||||
environmentId: environment.id,
|
||||
@@ -206,6 +199,7 @@ export function environmentRunOrchestrator(
|
||||
companyId: string;
|
||||
environment: Environment;
|
||||
issueId: string | null;
|
||||
agentId: string;
|
||||
heartbeatRunId: string;
|
||||
persistedExecutionWorkspace: Pick<ExecutionWorkspace, "id" | "mode"> | null;
|
||||
adapterType: string | null;
|
||||
@@ -262,7 +256,7 @@ export function environmentRunOrchestrator(
|
||||
async function acquireForRun(input: {
|
||||
companyId: string;
|
||||
selectedEnvironmentId: string;
|
||||
defaultEnvironmentId: string;
|
||||
localEnvironmentId: string;
|
||||
adapterType: string;
|
||||
issueId: string | null;
|
||||
heartbeatRunId: string;
|
||||
@@ -273,7 +267,7 @@ export function environmentRunOrchestrator(
|
||||
const environment = await resolveEnvironment({
|
||||
companyId: input.companyId,
|
||||
selectedEnvironmentId: input.selectedEnvironmentId,
|
||||
defaultEnvironmentId: input.defaultEnvironmentId,
|
||||
localEnvironmentId: input.localEnvironmentId,
|
||||
});
|
||||
|
||||
// Step 2: Acquire lease
|
||||
@@ -281,6 +275,7 @@ export function environmentRunOrchestrator(
|
||||
companyId: input.companyId,
|
||||
environment,
|
||||
issueId: input.issueId,
|
||||
agentId: input.agentId,
|
||||
heartbeatRunId: input.heartbeatRunId,
|
||||
persistedExecutionWorkspace: input.persistedExecutionWorkspace,
|
||||
adapterType: input.adapterType ?? null,
|
||||
|
||||
@@ -103,6 +103,7 @@ export interface EnvironmentDriverAcquireInput {
|
||||
companyId: string;
|
||||
environment: Environment;
|
||||
issueId: string | null;
|
||||
agentId: string | null;
|
||||
/**
|
||||
* UUID of the owning heartbeat run, or null for ad-hoc invocations
|
||||
* (e.g. operator-initiated `Test` probes) that are not tied to a run.
|
||||
@@ -219,6 +220,7 @@ function createLocalEnvironmentDriver(db: Db): EnvironmentRuntimeDriver {
|
||||
leasePolicy: "ephemeral",
|
||||
provider: "local",
|
||||
metadata: {
|
||||
...(input.agentId ? { agentId: input.agentId } : {}),
|
||||
driver: input.environment.driver,
|
||||
executionWorkspaceMode: input.executionWorkspaceMode,
|
||||
},
|
||||
@@ -272,6 +274,7 @@ function createSshEnvironmentDriver(db: Db): EnvironmentRuntimeDriver {
|
||||
provider: "ssh",
|
||||
providerLeaseId: `ssh://${parsed.config.username}@${parsed.config.host}:${parsed.config.port}${remoteCwd}`,
|
||||
metadata: {
|
||||
...(input.agentId ? { agentId: input.agentId } : {}),
|
||||
driver: input.environment.driver,
|
||||
executionWorkspaceMode: input.executionWorkspaceMode,
|
||||
host: parsed.config.host,
|
||||
@@ -452,11 +455,23 @@ function createSandboxEnvironmentDriver(
|
||||
// We also filter out leases whose policy is not reuse_by_environment
|
||||
// so any non-reusable lease (including ad-hoc test leases that
|
||||
// landed in the table from older code paths) cannot be matched.
|
||||
const reusableExistingLeases = parsed.config.reuseLease && input.heartbeatRunId !== null
|
||||
const reusableExistingLeases =
|
||||
parsed.config.reuseLease &&
|
||||
input.heartbeatRunId !== null &&
|
||||
input.executionWorkspaceId !== null &&
|
||||
input.agentId !== null
|
||||
? (await environmentsSvc.listLeases(input.environment.id))
|
||||
.filter((lease) => lease.leasePolicy === "reuse_by_environment")
|
||||
.filter((lease) =>
|
||||
lease.leasePolicy === "reuse_by_environment" &&
|
||||
lease.executionWorkspaceId === input.executionWorkspaceId &&
|
||||
lease.metadata?.agentId === input.agentId,
|
||||
)
|
||||
: [];
|
||||
const reusableProviderLeaseId = parsed.config.reuseLease && input.heartbeatRunId !== null
|
||||
const reusableProviderLeaseId =
|
||||
parsed.config.reuseLease &&
|
||||
input.heartbeatRunId !== null &&
|
||||
input.executionWorkspaceId !== null &&
|
||||
input.agentId !== null
|
||||
? findReusableSandboxLeaseId({ config: storedConfig, leases: reusableExistingLeases })
|
||||
: null;
|
||||
const reusableLease = reusableProviderLeaseId
|
||||
@@ -497,6 +512,8 @@ function createSandboxEnvironmentDriver(
|
||||
// a well-formed identifier.
|
||||
runId: input.heartbeatRunId ?? randomUUID(),
|
||||
workspaceMode: input.executionWorkspaceMode ?? undefined,
|
||||
agentId: input.agentId ?? undefined,
|
||||
executionWorkspaceId: input.executionWorkspaceId ?? undefined,
|
||||
// The agent's harness for THIS run, so the plugin picks the matching
|
||||
// runtime image (per-run adapter, mixed-harness environments).
|
||||
// NOTE: environment-runtime.ts has TWO drivers calling
|
||||
@@ -527,6 +544,7 @@ function createSandboxEnvironmentDriver(
|
||||
providerLeaseId: acquiredLease.providerLeaseId,
|
||||
expiresAt: acquiredLease.expiresAt ? new Date(acquiredLease.expiresAt) : undefined,
|
||||
metadata: {
|
||||
...(input.agentId ? { agentId: input.agentId } : {}),
|
||||
driver: input.environment.driver,
|
||||
executionWorkspaceMode: input.executionWorkspaceMode,
|
||||
pluginId: pluginProvider.resolved.plugin.id,
|
||||
@@ -546,13 +564,21 @@ function createSandboxEnvironmentDriver(
|
||||
// provider lease, or releasing the test lease will terminate the live
|
||||
// heartbeat run that shares it. Filter to leases whose policy is
|
||||
// reuse_by_environment so non-reusable rows can never be matched.
|
||||
const reusableProviderLeaseId = parsed.config.reuseLease && input.heartbeatRunId !== null
|
||||
const reusableProviderLeaseId =
|
||||
parsed.config.reuseLease &&
|
||||
input.heartbeatRunId !== null &&
|
||||
input.executionWorkspaceId !== null &&
|
||||
input.agentId !== null
|
||||
? (await environmentsSvc
|
||||
.listLeases(input.environment.id)
|
||||
.then((leases) =>
|
||||
findReusableSandboxLeaseId({
|
||||
config: parsed.config,
|
||||
leases: leases.filter((lease) => lease.leasePolicy === "reuse_by_environment"),
|
||||
leases: leases.filter((lease) =>
|
||||
lease.leasePolicy === "reuse_by_environment" &&
|
||||
lease.executionWorkspaceId === input.executionWorkspaceId &&
|
||||
lease.metadata?.agentId === input.agentId,
|
||||
),
|
||||
}),
|
||||
))
|
||||
: null;
|
||||
@@ -562,6 +588,8 @@ function createSandboxEnvironmentDriver(
|
||||
environmentId: input.environment.id,
|
||||
heartbeatRunId: input.heartbeatRunId ?? randomUUID(),
|
||||
issueId: input.issueId,
|
||||
agentId: input.agentId,
|
||||
executionWorkspaceId: input.executionWorkspaceId,
|
||||
reusableProviderLeaseId,
|
||||
});
|
||||
|
||||
@@ -581,6 +609,7 @@ function createSandboxEnvironmentDriver(
|
||||
provider: parsed.config.provider,
|
||||
providerLeaseId: providerLease.providerLeaseId,
|
||||
metadata: {
|
||||
...(input.agentId ? { agentId: input.agentId } : {}),
|
||||
driver: input.environment.driver,
|
||||
executionWorkspaceMode: input.executionWorkspaceMode,
|
||||
...providerLease.metadata,
|
||||
@@ -913,6 +942,8 @@ function createPluginEnvironmentDriver(
|
||||
config: parsed.config.driverConfig,
|
||||
runId: input.heartbeatRunId ?? randomUUID(),
|
||||
workspaceMode: input.executionWorkspaceMode ?? undefined,
|
||||
agentId: input.agentId ?? undefined,
|
||||
executionWorkspaceId: input.executionWorkspaceId ?? undefined,
|
||||
adapterType: input.adapterType ?? undefined,
|
||||
});
|
||||
|
||||
@@ -927,6 +958,7 @@ function createPluginEnvironmentDriver(
|
||||
providerLeaseId: providerLease.providerLeaseId,
|
||||
expiresAt: parseExpiresAt(providerLease.expiresAt),
|
||||
metadata: {
|
||||
...(input.agentId ? { agentId: input.agentId } : {}),
|
||||
providerMetadata: providerLease.metadata ?? {},
|
||||
driver: input.environment.driver,
|
||||
executionWorkspaceMode: input.executionWorkspaceMode,
|
||||
@@ -1126,6 +1158,7 @@ export function environmentRuntimeService(
|
||||
companyId: string;
|
||||
environment: Environment;
|
||||
issueId: string | null;
|
||||
agentId?: string | null;
|
||||
/** Null for ad-hoc invocations (e.g. operator-initiated `Test` probes). */
|
||||
heartbeatRunId: string | null;
|
||||
persistedExecutionWorkspace: Pick<ExecutionWorkspace, "id" | "mode"> | null;
|
||||
@@ -1144,6 +1177,7 @@ export function environmentRuntimeService(
|
||||
companyId: input.companyId,
|
||||
environment: input.environment,
|
||||
issueId: input.issueId,
|
||||
agentId: input.agentId ?? null,
|
||||
heartbeatRunId: input.heartbeatRunId,
|
||||
executionWorkspaceId: leaseContext.executionWorkspaceId,
|
||||
executionWorkspaceMode: leaseContext.executionWorkspaceMode,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type EnvironmentLeaseStatus,
|
||||
type UpdateEnvironment,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict } from "../errors.js";
|
||||
|
||||
type EnvironmentRow = typeof environments.$inferSelect;
|
||||
type EnvironmentLeaseRow = typeof environmentLeases.$inferSelect;
|
||||
@@ -70,19 +71,68 @@ function readEnum<T extends string>(value: string | null, allowed: readonly T[],
|
||||
throw new Error(`Unexpected ${fieldName} value: ${value}`);
|
||||
}
|
||||
|
||||
function hasConstraintName(error: unknown, constraintName: string): boolean {
|
||||
if (typeof error !== "object" || error === null) return false;
|
||||
const candidate = error as {
|
||||
constraint?: unknown;
|
||||
constraint_name?: unknown;
|
||||
cause?: unknown;
|
||||
};
|
||||
return candidate.constraint === constraintName
|
||||
|| candidate.constraint_name === constraintName
|
||||
|| hasConstraintName(candidate.cause, constraintName);
|
||||
}
|
||||
|
||||
function toEnvironment(row: EnvironmentRow): Environment {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
name: row.name,
|
||||
description: row.description ?? null,
|
||||
driver: readEnum(row.driver, ENVIRONMENT_DRIVERS, "environment driver") ?? "local",
|
||||
status: readEnum(row.status, ENVIRONMENT_STATUSES, "environment status") ?? "active",
|
||||
config: cloneRecord(row.config, {}) ?? {},
|
||||
envVars: cloneRecord(row.envVars, {}) ?? {},
|
||||
metadata: cloneRecord(row.metadata),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
} as Environment;
|
||||
}
|
||||
|
||||
type EnvironmentListFilters = {
|
||||
status?: string;
|
||||
driver?: string;
|
||||
};
|
||||
|
||||
function resolveListFilters(
|
||||
companyIdOrFilters?: string | EnvironmentListFilters,
|
||||
maybeFilters?: EnvironmentListFilters,
|
||||
): EnvironmentListFilters {
|
||||
if (typeof companyIdOrFilters === "string") {
|
||||
return maybeFilters ?? {};
|
||||
}
|
||||
return companyIdOrFilters ?? {};
|
||||
}
|
||||
|
||||
function resolveCreateInput(
|
||||
companyIdOrInput: string | CreateEnvironment,
|
||||
maybeInput?: CreateEnvironment,
|
||||
): CreateEnvironment {
|
||||
if (typeof companyIdOrInput === "string") {
|
||||
if (!maybeInput) throw new Error("Create environment input is required");
|
||||
return maybeInput;
|
||||
}
|
||||
return companyIdOrInput;
|
||||
}
|
||||
|
||||
function resolveKubernetesConfig(
|
||||
companyIdOrConfig: string | KubernetesEnvironmentConfigInput,
|
||||
maybeConfig?: KubernetesEnvironmentConfigInput,
|
||||
): KubernetesEnvironmentConfigInput {
|
||||
if (typeof companyIdOrConfig === "string") {
|
||||
if (!maybeConfig) throw new Error("Kubernetes environment config is required");
|
||||
return maybeConfig;
|
||||
}
|
||||
return companyIdOrConfig;
|
||||
}
|
||||
|
||||
function toEnvironmentLease(row: EnvironmentLeaseRow): EnvironmentLease {
|
||||
@@ -116,19 +166,17 @@ function toEnvironmentLease(row: EnvironmentLeaseRow): EnvironmentLease {
|
||||
export function environmentService(db: Db) {
|
||||
return {
|
||||
list: async (
|
||||
companyId: string,
|
||||
filters: {
|
||||
status?: string;
|
||||
driver?: string;
|
||||
} = {},
|
||||
companyIdOrFilters?: string | EnvironmentListFilters,
|
||||
maybeFilters?: EnvironmentListFilters,
|
||||
): Promise<Environment[]> => {
|
||||
const conditions = [eq(environments.companyId, companyId)];
|
||||
const filters = resolveListFilters(companyIdOrFilters, maybeFilters);
|
||||
const conditions = [];
|
||||
if (filters.status) conditions.push(eq(environments.status, filters.status));
|
||||
if (filters.driver) conditions.push(eq(environments.driver, filters.driver));
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(environments)
|
||||
.where(and(...conditions))
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(desc(environments.updatedAt), desc(environments.createdAt));
|
||||
return rows.map(toEnvironment);
|
||||
},
|
||||
@@ -147,26 +195,26 @@ export function environmentService(db: Db) {
|
||||
return row ? toEnvironmentLease(row) : null;
|
||||
},
|
||||
|
||||
ensureLocalEnvironment: async (companyId: string): Promise<Environment> => {
|
||||
ensureLocalEnvironment: async (_companyId?: string): Promise<Environment> => {
|
||||
const now = new Date();
|
||||
const row = await db
|
||||
.insert(environments)
|
||||
.values({
|
||||
companyId,
|
||||
name: DEFAULT_LOCAL_ENVIRONMENT_NAME,
|
||||
description: DEFAULT_LOCAL_ENVIRONMENT_DESCRIPTION,
|
||||
driver: "local",
|
||||
status: "active",
|
||||
config: {},
|
||||
envVars: {},
|
||||
metadata: {
|
||||
managedByPaperclip: true,
|
||||
defaultForCompany: true,
|
||||
defaultForInstance: true,
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoNothing({
|
||||
target: [environments.companyId, environments.driver],
|
||||
target: [environments.driver],
|
||||
where: sql`${environments.driver} = 'local'`,
|
||||
})
|
||||
.returning()
|
||||
@@ -176,7 +224,7 @@ export function environmentService(db: Db) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(environments)
|
||||
.where(and(eq(environments.companyId, companyId), eq(environments.driver, "local")))
|
||||
.where(eq(environments.driver, "local"))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) {
|
||||
throw new Error("Failed to ensure local environment");
|
||||
@@ -186,7 +234,7 @@ export function environmentService(db: Db) {
|
||||
|
||||
/**
|
||||
* Idempotently ensure a managed Kubernetes sandbox environment exists for a
|
||||
* company, configured from instance/operator-supplied config. Mirrors
|
||||
* instance, configured from instance/operator-supplied config. Mirrors
|
||||
* `ensureLocalEnvironment`, but there is no DB unique index for sandbox
|
||||
* drivers, so idempotency is by metadata marker + driver lookup.
|
||||
*
|
||||
@@ -196,9 +244,10 @@ export function environmentService(db: Db) {
|
||||
* update egress/runtimeClass via gitops without recreating the row).
|
||||
*/
|
||||
ensureKubernetesEnvironment: async (
|
||||
companyId: string,
|
||||
config: KubernetesEnvironmentConfigInput,
|
||||
companyIdOrConfig: string | KubernetesEnvironmentConfigInput,
|
||||
maybeConfig?: KubernetesEnvironmentConfigInput,
|
||||
): Promise<Environment> => {
|
||||
const config = resolveKubernetesConfig(companyIdOrConfig, maybeConfig);
|
||||
const desiredConfig: Record<string, unknown> = {
|
||||
...config,
|
||||
provider: KUBERNETES_PROVIDER_KEY,
|
||||
@@ -211,7 +260,7 @@ export function environmentService(db: Db) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(environments)
|
||||
.where(and(eq(environments.companyId, companyId), eq(environments.driver, "sandbox")))
|
||||
.where(eq(environments.driver, "sandbox"))
|
||||
.then((rows) =>
|
||||
rows.find(
|
||||
(row) =>
|
||||
@@ -235,36 +284,45 @@ export function environmentService(db: Db) {
|
||||
return toEnvironment(updated);
|
||||
}
|
||||
|
||||
// The partial unique index `environments_company_managed_sandbox_idx`
|
||||
// (added in migration 0102) enforces "at most one Paperclip-managed
|
||||
// sandbox row per company" at the DB level. Use ON CONFLICT DO NOTHING
|
||||
// keyed on that index so concurrent callers can race the INSERT — only
|
||||
// one succeeds; losers re-read the surviving row.
|
||||
// The partial unique index `environments_managed_sandbox_idx` enforces
|
||||
// "at most one Paperclip-managed sandbox row per instance" at the DB
|
||||
// level. Use ON CONFLICT DO NOTHING keyed on that index so concurrent
|
||||
// callers can race the INSERT; losers re-read the surviving row.
|
||||
const inserted = await db
|
||||
.insert(environments)
|
||||
.values({
|
||||
companyId,
|
||||
name: DEFAULT_KUBERNETES_ENVIRONMENT_NAME,
|
||||
description: DEFAULT_KUBERNETES_ENVIRONMENT_DESCRIPTION,
|
||||
driver: "sandbox",
|
||||
status: "active",
|
||||
config: desiredConfig,
|
||||
envVars: {},
|
||||
metadata: desiredMetadata,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoNothing({
|
||||
target: [environments.companyId],
|
||||
where: sql`${environments.driver} = 'sandbox' AND (${environments.metadata} ->> 'managedByPaperclip')::boolean = true`,
|
||||
target: [environments.driver],
|
||||
where:
|
||||
sql`${environments.driver} = 'sandbox' AND (${environments.metadata} ->> 'managedByPaperclip')::boolean = true`,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
.then((rows) => rows[0] ?? null)
|
||||
.catch((error) => {
|
||||
if (
|
||||
hasConstraintName(error, "environments_name_idx")
|
||||
|| hasConstraintName(error, "environments_managed_sandbox_idx")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (inserted) return toEnvironment(inserted);
|
||||
|
||||
const winner = await db
|
||||
.select()
|
||||
.from(environments)
|
||||
.where(and(eq(environments.companyId, companyId), eq(environments.driver, "sandbox")))
|
||||
.where(eq(environments.driver, "sandbox"))
|
||||
.then(
|
||||
(rows) =>
|
||||
rows.find(
|
||||
@@ -281,17 +339,16 @@ export function environmentService(db: Db) {
|
||||
},
|
||||
|
||||
/**
|
||||
* Find an active Kubernetes sandbox environment for a company, if one
|
||||
* Find the active managed Kubernetes sandbox environment, if one
|
||||
* exists. Read-only counterpart to `ensureKubernetesEnvironment` used by the
|
||||
* per-run execution guard (which must not silently create config-less envs).
|
||||
*/
|
||||
findKubernetesEnvironment: async (companyId: string): Promise<Environment | null> => {
|
||||
findKubernetesEnvironment: async (_companyId?: string): Promise<Environment | null> => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(environments)
|
||||
.where(
|
||||
and(
|
||||
eq(environments.companyId, companyId),
|
||||
eq(environments.driver, "sandbox"),
|
||||
eq(environments.status, "active"),
|
||||
),
|
||||
@@ -304,23 +361,36 @@ export function environmentService(db: Db) {
|
||||
return match ? toEnvironment(match) : null;
|
||||
},
|
||||
|
||||
create: async (companyId: string, input: CreateEnvironment): Promise<Environment> => {
|
||||
create: async (
|
||||
companyIdOrInput: string | CreateEnvironment,
|
||||
maybeInput?: CreateEnvironment,
|
||||
): Promise<Environment> => {
|
||||
const input = resolveCreateInput(companyIdOrInput, maybeInput);
|
||||
const now = new Date();
|
||||
const row = await db
|
||||
.insert(environments)
|
||||
.values({
|
||||
companyId,
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
driver: input.driver,
|
||||
status: input.status ?? "active",
|
||||
config: input.config ?? {},
|
||||
envVars: (input as CreateEnvironment & { envVars?: Record<string, unknown> }).envVars ?? {},
|
||||
metadata: input.metadata ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
.then((rows) => rows[0] ?? null)
|
||||
.catch((error) => {
|
||||
if (hasConstraintName(error, "environments_name_idx")) {
|
||||
throw conflict(`An environment named "${input.name}" already exists for this instance.`);
|
||||
}
|
||||
if (hasConstraintName(error, "environments_local_driver_idx")) {
|
||||
throw conflict("A local environment already exists for this instance.");
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (!row) {
|
||||
throw new Error("Failed to create environment");
|
||||
}
|
||||
@@ -336,6 +406,9 @@ export function environmentService(db: Db) {
|
||||
if (patch.driver !== undefined) values.driver = patch.driver;
|
||||
if (patch.status !== undefined) values.status = patch.status;
|
||||
if (patch.config !== undefined) values.config = patch.config;
|
||||
if ("envVars" in patch && patch.envVars !== undefined) {
|
||||
values.envVars = (patch.envVars ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
if (patch.metadata !== undefined) values.metadata = patch.metadata ?? null;
|
||||
|
||||
const row = await db
|
||||
@@ -343,7 +416,16 @@ export function environmentService(db: Db) {
|
||||
.set(values)
|
||||
.where(eq(environments.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
.then((rows) => rows[0] ?? null)
|
||||
.catch((error) => {
|
||||
if (hasConstraintName(error, "environments_name_idx")) {
|
||||
throw conflict(`An environment named "${patch.name}" already exists for this instance.`);
|
||||
}
|
||||
if (hasConstraintName(error, "environments_local_driver_idx")) {
|
||||
throw conflict("A local environment already exists for this instance.");
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
return row ? toEnvironment(row) : null;
|
||||
},
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecu
|
||||
const defaultMode = asString(parsed.defaultMode, "");
|
||||
const defaultProjectWorkspaceId =
|
||||
typeof parsed.defaultProjectWorkspaceId === "string" ? parsed.defaultProjectWorkspaceId : undefined;
|
||||
const environmentId = typeof parsed.environmentId === "string" ? parsed.environmentId : undefined;
|
||||
const allowIssueOverride =
|
||||
typeof parsed.allowIssueOverride === "boolean" ? parsed.allowIssueOverride : undefined;
|
||||
const normalizedDefaultMode = (() => {
|
||||
@@ -59,7 +58,6 @@ export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecu
|
||||
...(normalizedDefaultMode ? { defaultMode: normalizedDefaultMode } : {}),
|
||||
...(allowIssueOverride !== undefined ? { allowIssueOverride } : {}),
|
||||
...(defaultProjectWorkspaceId ? { defaultProjectWorkspaceId } : {}),
|
||||
...(environmentId !== undefined ? { environmentId } : {}),
|
||||
...(workspaceStrategy ? { workspaceStrategy } : {}),
|
||||
...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime)
|
||||
? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record<string, unknown>) } }
|
||||
@@ -114,7 +112,6 @@ export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecuti
|
||||
...(normalizedMode
|
||||
? { mode: normalizedMode as IssueExecutionWorkspaceSettings["mode"] }
|
||||
: {}),
|
||||
...(typeof parsed.environmentId === "string" ? { environmentId: parsed.environmentId } : {}),
|
||||
...(workspaceStrategy ? { workspaceStrategy } : {}),
|
||||
...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime)
|
||||
? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record<string, unknown>) } }
|
||||
@@ -123,125 +120,36 @@ export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecuti
|
||||
}
|
||||
|
||||
export type ExecutionWorkspaceEnvironmentSource =
|
||||
| "workspace"
|
||||
| "issue"
|
||||
| "project"
|
||||
| "agent"
|
||||
| "instance"
|
||||
| "default";
|
||||
|
||||
export type ExecutionWorkspaceEnvironmentConflict = {
|
||||
reason: "reused_workspace_environment_mismatch";
|
||||
workspaceEnvironmentId: string;
|
||||
assigneeIntendedEnvironmentId: string;
|
||||
assigneeIntendedSource: Exclude<ExecutionWorkspaceEnvironmentSource, "workspace">;
|
||||
};
|
||||
|
||||
export type ExecutionWorkspaceEnvironmentResolution = {
|
||||
environmentId: string;
|
||||
source: ExecutionWorkspaceEnvironmentSource;
|
||||
conflict: ExecutionWorkspaceEnvironmentConflict | null;
|
||||
};
|
||||
|
||||
function resolveAssigneeIntendedExecutionWorkspaceEnvironment(input: {
|
||||
projectPolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
issueSettings: IssueExecutionWorkspaceSettings | null;
|
||||
agentDefaultEnvironmentId: string | null;
|
||||
defaultEnvironmentId: string;
|
||||
}): {
|
||||
environmentId: string;
|
||||
source: Exclude<ExecutionWorkspaceEnvironmentSource, "workspace">;
|
||||
} {
|
||||
// Explicit issue-level env override always wins, even for null-default
|
||||
// (local-only) agents. An operator who deliberately set
|
||||
// `executionWorkspaceSettings.environmentId` on this specific issue (see the
|
||||
// issues-service contract preserved in issues.ts:4243) chose that env for
|
||||
// this assignment and should not be silently downgraded to the local default
|
||||
// (PAPA-430 review fix). Inherited issue envs from
|
||||
// `inheritExecutionWorkspaceFromIssueId` are stripped before this point in
|
||||
// `resolveExecutionWorkspaceEnvironmentId`.
|
||||
if (input.issueSettings?.environmentId !== undefined) {
|
||||
return {
|
||||
environmentId: input.issueSettings.environmentId ?? input.defaultEnvironmentId,
|
||||
source: "issue",
|
||||
};
|
||||
}
|
||||
// A null defaultEnvironmentId on the agent means it is deliberately scoped to
|
||||
// the local default (e.g. Manual QA today). Project policy must not promote
|
||||
// such an agent off of local — only an explicit issue-level override above
|
||||
// can move the assignee away from the local default.
|
||||
if (input.agentDefaultEnvironmentId === null) {
|
||||
return { environmentId: input.defaultEnvironmentId, source: "default" };
|
||||
}
|
||||
if (input.projectPolicy?.environmentId !== undefined) {
|
||||
return {
|
||||
environmentId: input.projectPolicy.environmentId ?? input.defaultEnvironmentId,
|
||||
source: "project",
|
||||
};
|
||||
}
|
||||
return { environmentId: input.agentDefaultEnvironmentId, source: "agent" };
|
||||
}
|
||||
|
||||
export function resolveExecutionWorkspaceEnvironmentId(input: {
|
||||
projectPolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
issueSettings: IssueExecutionWorkspaceSettings | null;
|
||||
workspaceConfig: { environmentId?: string | null } | null;
|
||||
agentDefaultEnvironmentId: string | null;
|
||||
defaultEnvironmentId: string;
|
||||
instanceDefaultEnvironmentId: string | null;
|
||||
localDefaultEnvironmentId: string;
|
||||
}): ExecutionWorkspaceEnvironmentResolution {
|
||||
// PAPA-431 companion: when the assignee has no explicit defaultEnvironmentId
|
||||
// (deliberately local-only, e.g. Manual QA) AND the issue settings env exactly
|
||||
// matches the reused workspace env, treat the issue env as a promoted artifact
|
||||
// from `inheritExecutionWorkspaceFromIssueId` rather than a deliberate
|
||||
// operator choice. Strip it so the resolver falls back to the local default
|
||||
// and the workspace-vs-intended conflict check forces a fresh realization.
|
||||
// A genuine operator override (via PATCH on the issue) reaches this code path
|
||||
// either with no reused workspace (workspaceConfig === null) or against a
|
||||
// workspace whose persisted env does not match the new override; both keep
|
||||
// the issue setting in place.
|
||||
const inheritedIssueEnvOnNullDefaultAssignee =
|
||||
input.agentDefaultEnvironmentId === null &&
|
||||
input.workspaceConfig?.environmentId !== undefined &&
|
||||
input.workspaceConfig?.environmentId !== null &&
|
||||
input.issueSettings?.environmentId !== undefined &&
|
||||
input.issueSettings.environmentId === input.workspaceConfig.environmentId;
|
||||
let issueSettingsForResolution = input.issueSettings;
|
||||
if (inheritedIssueEnvOnNullDefaultAssignee && input.issueSettings) {
|
||||
const { environmentId: _droppedInheritedEnv, ...rest } = input.issueSettings;
|
||||
void _droppedInheritedEnv;
|
||||
issueSettingsForResolution = rest as IssueExecutionWorkspaceSettings;
|
||||
if (input.agentDefaultEnvironmentId) {
|
||||
return {
|
||||
environmentId: input.agentDefaultEnvironmentId,
|
||||
source: "agent",
|
||||
};
|
||||
}
|
||||
|
||||
const assigneeIntended = resolveAssigneeIntendedExecutionWorkspaceEnvironment({
|
||||
projectPolicy: input.projectPolicy,
|
||||
issueSettings: issueSettingsForResolution,
|
||||
agentDefaultEnvironmentId: input.agentDefaultEnvironmentId,
|
||||
defaultEnvironmentId: input.defaultEnvironmentId,
|
||||
});
|
||||
|
||||
if (input.workspaceConfig?.environmentId !== undefined) {
|
||||
const workspaceEnvironmentId =
|
||||
input.workspaceConfig.environmentId ?? input.defaultEnvironmentId;
|
||||
// PAPA-380 / PAPA-431: a reused workspace's persisted environmentId must
|
||||
// never silently shadow the current assignee's environment identity.
|
||||
// When they disagree, refuse the silent reuse: return the assignee's
|
||||
// intended env and surface a conflict signal so the caller forces a fresh
|
||||
// workspace realization (or otherwise alerts the operator) instead of
|
||||
// running the agent on someone else's environment.
|
||||
if (workspaceEnvironmentId !== assigneeIntended.environmentId) {
|
||||
return {
|
||||
environmentId: assigneeIntended.environmentId,
|
||||
source: assigneeIntended.source,
|
||||
conflict: {
|
||||
reason: "reused_workspace_environment_mismatch",
|
||||
workspaceEnvironmentId,
|
||||
assigneeIntendedEnvironmentId: assigneeIntended.environmentId,
|
||||
assigneeIntendedSource: assigneeIntended.source,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { environmentId: workspaceEnvironmentId, source: "workspace", conflict: null };
|
||||
if (input.instanceDefaultEnvironmentId) {
|
||||
return {
|
||||
environmentId: input.instanceDefaultEnvironmentId,
|
||||
source: "instance",
|
||||
};
|
||||
}
|
||||
return { environmentId: assigneeIntended.environmentId, source: assigneeIntended.source, conflict: null };
|
||||
return {
|
||||
environmentId: input.localDefaultEnvironmentId,
|
||||
source: "default",
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultIssueExecutionWorkspaceSettingsForProject(
|
||||
|
||||
@@ -457,6 +457,8 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
agentId?: string | null;
|
||||
issueId?: string | null;
|
||||
heartbeatRunId?: string | null;
|
||||
environmentId?: string | null;
|
||||
environmentEnv?: unknown;
|
||||
projectId?: string | null;
|
||||
routineId?: string | null;
|
||||
executionRunConfig: Record<string, unknown>;
|
||||
@@ -466,12 +468,14 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
trustPreset?: TrustPresetResolution;
|
||||
}) {
|
||||
const executionRunConfig = stripPaperclipRuntimeEnvFromAdapterConfig(input.executionRunConfig);
|
||||
const environmentEnv = stripPaperclipRuntimeEnvBindings(input.environmentEnv);
|
||||
const projectEnv = stripPaperclipRuntimeEnvBindings(input.projectEnv);
|
||||
const routineEnv = stripPaperclipRuntimeEnvBindings(input.routineEnv);
|
||||
const lowTrustAllowedBindingIds = input.trustPreset?.kind === "low_trust_review"
|
||||
? input.trustPreset.boundary.allowedSecretBindingIds ?? []
|
||||
: undefined;
|
||||
if (input.trustPreset?.kind === "low_trust_review") {
|
||||
assertLowTrustEnvConfigAllowed(environmentEnv, "environment.env");
|
||||
assertLowTrustEnvConfigAllowed(executionRunConfig.env, "agent.env");
|
||||
assertLowTrustEnvConfigAllowed(projectEnv, "project.env");
|
||||
assertLowTrustEnvConfigAllowed(routineEnv, "routine.env");
|
||||
@@ -482,6 +486,15 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
// dispatched-then-failed run (which previously surfaced as opaque setup_failed).
|
||||
if (typeof input.secretsSvc.collectMissingRuntimeBindings === "function") {
|
||||
const missingBindings: MissingRuntimeBinding[] = [];
|
||||
if (environmentEnv && input.environmentId) {
|
||||
missingBindings.push(
|
||||
...(await input.secretsSvc.collectMissingRuntimeBindings(
|
||||
input.companyId,
|
||||
environmentEnv,
|
||||
{ consumerType: "environment", consumerId: input.environmentId },
|
||||
)),
|
||||
);
|
||||
}
|
||||
if (input.agentId) {
|
||||
missingBindings.push(
|
||||
...(await input.secretsSvc.collectMissingRuntimeBindings(
|
||||
@@ -524,6 +537,23 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
});
|
||||
}
|
||||
}
|
||||
const environmentEnvResolution = environmentEnv
|
||||
? await input.secretsSvc.resolveEnvBindings(
|
||||
input.companyId,
|
||||
environmentEnv,
|
||||
input.environmentId
|
||||
? {
|
||||
consumerType: "environment",
|
||||
consumerId: input.environmentId,
|
||||
actorType: "agent",
|
||||
actorId: input.agentId ?? null,
|
||||
issueId: input.issueId ?? null,
|
||||
heartbeatRunId: input.heartbeatRunId ?? null,
|
||||
...(lowTrustAllowedBindingIds !== undefined ? { allowedBindingIds: lowTrustAllowedBindingIds } : {}),
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
: { env: {}, secretKeys: new Set<string>(), manifest: [] };
|
||||
const { config: resolvedConfig, secretKeys, manifest } = await input.secretsSvc.resolveAdapterConfigForRuntime(
|
||||
input.companyId,
|
||||
executionRunConfig,
|
||||
@@ -539,6 +569,15 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
if (Object.keys(environmentEnvResolution.env).length > 0) {
|
||||
resolvedConfig.env = {
|
||||
...environmentEnvResolution.env,
|
||||
...parseObject(resolvedConfig.env),
|
||||
};
|
||||
for (const key of environmentEnvResolution.secretKeys) {
|
||||
secretKeys.add(key);
|
||||
}
|
||||
}
|
||||
const projectEnvResolution = projectEnv
|
||||
? await input.secretsSvc.resolveEnvBindings(
|
||||
input.companyId,
|
||||
@@ -595,6 +634,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
resolvedConfig,
|
||||
secretKeys,
|
||||
secretManifest: [
|
||||
...(environmentEnvResolution.manifest ?? []),
|
||||
...(manifest ?? []),
|
||||
...(projectEnvResolution.manifest ?? []),
|
||||
...(routineEnvResolution.manifest ?? []),
|
||||
@@ -8417,37 +8457,14 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
const requestedReusableExecutionWorkspaceConfig = requestedShouldReuseExisting
|
||||
? existingExecutionWorkspace?.config ?? null
|
||||
: null;
|
||||
const defaultEnvironment = await environmentsSvc.ensureLocalEnvironment(agent.companyId);
|
||||
const localEnvironment = await environmentsSvc.ensureLocalEnvironment(agent.companyId);
|
||||
const resolvedInstanceSettings = await instanceSettings.get();
|
||||
const environmentResolution = resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: projectExecutionWorkspacePolicy,
|
||||
issueSettings: issueExecutionWorkspaceSettings,
|
||||
workspaceConfig: requestedReusableExecutionWorkspaceConfig,
|
||||
agentDefaultEnvironmentId: agent.defaultEnvironmentId,
|
||||
defaultEnvironmentId: defaultEnvironment.id,
|
||||
instanceDefaultEnvironmentId: resolvedInstanceSettings.defaultEnvironmentId ?? null,
|
||||
localDefaultEnvironmentId: localEnvironment.id,
|
||||
});
|
||||
// PAPA-380 / PAPA-431: when the resolver refuses silent reuse of the
|
||||
// persisted workspace environment, also force a fresh workspace
|
||||
// realization on the assignee's intended env. Reusing the on-disk
|
||||
// workspace while swapping the env underneath it would mismatch the cwd's
|
||||
// runtime expectations (e.g. an SSH-targeted worktree running on the
|
||||
// local default driver).
|
||||
if (environmentResolution.conflict) {
|
||||
logger.warn(
|
||||
{
|
||||
runId: run.id,
|
||||
issueId,
|
||||
agentId: agent.id,
|
||||
adapterType: agent.adapterType,
|
||||
existingExecutionWorkspaceId: existingExecutionWorkspace?.id ?? null,
|
||||
workspaceEnvironmentId: environmentResolution.conflict.workspaceEnvironmentId,
|
||||
assigneeIntendedEnvironmentId:
|
||||
environmentResolution.conflict.assigneeIntendedEnvironmentId,
|
||||
assigneeIntendedSource: environmentResolution.conflict.assigneeIntendedSource,
|
||||
},
|
||||
"Refusing silent reuse of execution workspace whose environment does not match the assignee's intended environment; forcing fresh realization",
|
||||
);
|
||||
}
|
||||
const shouldReuseExisting = requestedShouldReuseExisting && !environmentResolution.conflict;
|
||||
const shouldReuseExisting = requestedShouldReuseExisting;
|
||||
const reusableExecutionWorkspaceConfig = shouldReuseExisting
|
||||
? requestedReusableExecutionWorkspaceConfig
|
||||
: null;
|
||||
@@ -8545,7 +8562,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
const preflightEnvironment = await envOrchestrator.resolveEnvironment({
|
||||
companyId: agent.companyId,
|
||||
selectedEnvironmentId,
|
||||
defaultEnvironmentId: defaultEnvironment.id,
|
||||
localEnvironmentId: localEnvironment.id,
|
||||
});
|
||||
return preflightEnvironment.driver;
|
||||
},
|
||||
@@ -8609,11 +8626,18 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
});
|
||||
const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig, selectedEnvironmentId);
|
||||
const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig);
|
||||
const selectedEnvironmentForConfig = selectedEnvironmentId === localEnvironment.id
|
||||
? localEnvironment
|
||||
: selectedEnvironmentId
|
||||
? await environmentsSvc.getById(selectedEnvironmentId)
|
||||
: null;
|
||||
const { resolvedConfig, secretKeys, secretManifest } = await resolveExecutionRunAdapterConfig({
|
||||
companyId: agent.companyId,
|
||||
agentId: agent.id,
|
||||
issueId,
|
||||
heartbeatRunId: run.id,
|
||||
environmentId: selectedEnvironmentForConfig?.id ?? null,
|
||||
environmentEnv: selectedEnvironmentForConfig?.envVars ?? null,
|
||||
projectId: projectContext?.id ?? null,
|
||||
routineId: routineEnvContext.routineId,
|
||||
executionRunConfig,
|
||||
@@ -8849,17 +8873,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, run.id));
|
||||
}
|
||||
// When execution is forced to Kubernetes, `selectedEnvironmentId` is already
|
||||
// pinned to the managed k8s environment above; ignore any persisted workspace
|
||||
// environmentId (which could point at a stale local/ssh env) so a reused
|
||||
// workspace can never downgrade us off the sandbox.
|
||||
const persistedEnvironmentId = isExecutionForcedToKubernetes(executionPolicy)
|
||||
? selectedEnvironmentId
|
||||
: persistedExecutionWorkspace?.config?.environmentId ?? selectedEnvironmentId;
|
||||
const acquiredEnvironment = await envOrchestrator.acquireForRun({
|
||||
companyId: agent.companyId,
|
||||
selectedEnvironmentId: persistedEnvironmentId,
|
||||
defaultEnvironmentId: defaultEnvironment.id,
|
||||
selectedEnvironmentId,
|
||||
localEnvironmentId: localEnvironment.id,
|
||||
adapterType: agent.adapterType,
|
||||
issueId: issueId ?? null,
|
||||
heartbeatRunId: run.id,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type InstanceExperimentalSettings,
|
||||
type PatchInstanceGeneralSettings,
|
||||
type InstanceSettings,
|
||||
type PatchInstanceSettings,
|
||||
type PatchInstanceExperimentalSettings,
|
||||
} from "@paperclipai/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
@@ -63,9 +64,9 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableStreamlinedLeftNavigation: false,
|
||||
enableConferenceRoomChat: false,
|
||||
enableTaskWatchdogs: false,
|
||||
enableIssuePlanDecompositions: false,
|
||||
enableExperimentalFileViewer: false,
|
||||
enableTaskWatchdogs: false,
|
||||
enableCloudSync: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: false,
|
||||
@@ -77,11 +78,12 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
||||
function toInstanceSettings(row: typeof instanceSettings.$inferSelect): InstanceSettings {
|
||||
return {
|
||||
id: row.id,
|
||||
defaultEnvironmentId: row.defaultEnvironmentId ?? null,
|
||||
general: normalizeGeneralSettings(row.general),
|
||||
experimental: normalizeExperimentalSettings(row.experimental),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
} as InstanceSettings;
|
||||
}
|
||||
|
||||
export function instanceSettingsService(db: Db) {
|
||||
@@ -126,6 +128,22 @@ export function instanceSettingsService(db: Db) {
|
||||
return {
|
||||
get: async (): Promise<InstanceSettings> => toInstanceSettings(await getOrCreateRow()),
|
||||
|
||||
update: async (patch: PatchInstanceSettings): Promise<InstanceSettings> => {
|
||||
const current = await getOrCreateRow();
|
||||
const now = new Date();
|
||||
const [updated] = await db
|
||||
.update(instanceSettings)
|
||||
.set({
|
||||
...(Object.prototype.hasOwnProperty.call(patch, "defaultEnvironmentId")
|
||||
? { defaultEnvironmentId: patch.defaultEnvironmentId ?? null }
|
||||
: {}),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(instanceSettings.id, current.id))
|
||||
.returning();
|
||||
return toInstanceSettings(updated ?? current);
|
||||
},
|
||||
|
||||
getGeneral: async (): Promise<InstanceGeneralSettings> => {
|
||||
const row = await getOrCreateRow();
|
||||
return normalizeGeneralSettings(row.general);
|
||||
|
||||
@@ -4975,9 +4975,8 @@ export function issueService(db: Db) {
|
||||
issueData.projectId = workspace.projectId;
|
||||
}
|
||||
const projectGoalId = await getProjectDefaultGoalId(tx, companyId, issueData.projectId);
|
||||
// Cache the project policy lookup for this insert. Both the
|
||||
// default-settings block and the assignee-environment-promotion block
|
||||
// need the same row; without caching they'd issue two round-trips.
|
||||
// Cache the project policy lookup for this insert so the default
|
||||
// workspace-settings block does not re-query the project row.
|
||||
let projectPolicyCached: ReturnType<typeof parseProjectExecutionWorkspacePolicy> | null = null;
|
||||
let projectPolicyLoaded = false;
|
||||
const loadProjectPolicyOnce = async () => {
|
||||
@@ -5006,37 +5005,6 @@ export function issueService(db: Db) {
|
||||
),
|
||||
) as Record<string, unknown> | null;
|
||||
}
|
||||
if (data.assigneeAgentId && isolatedWorkspacesEnabled) {
|
||||
const currentWorkspaceSettings = executionWorkspaceSettings == null
|
||||
? {}
|
||||
: parseObject(executionWorkspaceSettings);
|
||||
const issueHasEnvironmentSelection =
|
||||
Object.prototype.hasOwnProperty.call(currentWorkspaceSettings, "environmentId");
|
||||
// Don't promote the assignee agent's defaultEnvironmentId if either
|
||||
// the issue or the project policy already specifies an environment.
|
||||
// resolveExecutionWorkspaceEnvironmentId treats issue settings as
|
||||
// higher priority than project policy, so promoting the agent's
|
||||
// default to issue settings would invert the documented priority
|
||||
// (project policy must win over agent default when explicitly set).
|
||||
let projectHasEnvironmentSelection = false;
|
||||
if (!issueHasEnvironmentSelection && issueData.projectId) {
|
||||
const projectPolicy = await loadProjectPolicyOnce();
|
||||
projectHasEnvironmentSelection = projectPolicy?.environmentId !== undefined;
|
||||
}
|
||||
if (!issueHasEnvironmentSelection && !projectHasEnvironmentSelection) {
|
||||
const assigneeAgent = await tx
|
||||
.select({ defaultEnvironmentId: agents.defaultEnvironmentId })
|
||||
.from(agents)
|
||||
.where(and(eq(agents.id, data.assigneeAgentId), eq(agents.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (typeof assigneeAgent?.defaultEnvironmentId === "string" && assigneeAgent.defaultEnvironmentId.length > 0) {
|
||||
executionWorkspaceSettings = {
|
||||
...currentWorkspaceSettings,
|
||||
environmentId: assigneeAgent.defaultEnvironmentId,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!projectWorkspaceId && issueData.projectId) {
|
||||
const project = await tx
|
||||
.select({
|
||||
@@ -5236,6 +5204,11 @@ export function issueService(db: Db) {
|
||||
issueData.executionWorkspaceSettings !== undefined
|
||||
? parseIssueExecutionWorkspaceSettings(issueData.executionWorkspaceSettings)
|
||||
: parseIssueExecutionWorkspaceSettings(existing.executionWorkspaceSettings);
|
||||
if (issueData.executionWorkspaceSettings !== undefined) {
|
||||
patch.executionWorkspaceSettings = nextExecutionWorkspaceSettings
|
||||
? { ...nextExecutionWorkspaceSettings }
|
||||
: null;
|
||||
}
|
||||
let validatedProjectWorkspace: { projectId: string } | null = null;
|
||||
let validatedExecutionWorkspace: { projectId: string } | null = null;
|
||||
if (!nextProjectId && nextProjectWorkspaceId) {
|
||||
@@ -5295,93 +5268,6 @@ export function issueService(db: Db) {
|
||||
),
|
||||
]);
|
||||
|
||||
// Mirror the create() path: when the assignee changes to a non-null
|
||||
// agent, default the issue's executionWorkspaceSettings.environmentId
|
||||
// to the new agent's defaultEnvironmentId. Skip when:
|
||||
// - this update explicitly sets executionWorkspaceSettings.environmentId
|
||||
// (caller is making a deliberate override; respect it), OR
|
||||
// - the project policy already specifies an environmentId (project
|
||||
// policy must win over agent default per the documented priority
|
||||
// order in resolveExecutionWorkspaceEnvironmentId), OR
|
||||
// - the issue already has an environmentId that was *not* the prior
|
||||
// assignee's default (i.e., the operator set it explicitly in an
|
||||
// earlier update; preserve their choice). When the existing
|
||||
// environmentId matches the prior assignee's default, treat it as
|
||||
// auto-promoted and refresh it to the new assignee's default.
|
||||
const assigneeChanged =
|
||||
issueData.assigneeAgentId !== undefined &&
|
||||
issueData.assigneeAgentId !== null &&
|
||||
issueData.assigneeAgentId !== existing.assigneeAgentId;
|
||||
const explicitEnvInThisUpdate =
|
||||
issueData.executionWorkspaceSettings !== undefined &&
|
||||
Object.prototype.hasOwnProperty.call(
|
||||
parseObject(issueData.executionWorkspaceSettings),
|
||||
"environmentId",
|
||||
);
|
||||
if (assigneeChanged && isolatedWorkspacesEnabled && !explicitEnvInThisUpdate) {
|
||||
let projectHasEnvironmentSelection = false;
|
||||
if (nextProjectId) {
|
||||
const projectRow = await tx
|
||||
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, nextProjectId), eq(projects.companyId, existing.companyId)))
|
||||
.then((rows: Array<{ executionWorkspacePolicy: unknown }>) => rows[0] ?? null);
|
||||
const projectPolicy = parseProjectExecutionWorkspacePolicy(projectRow?.executionWorkspacePolicy);
|
||||
projectHasEnvironmentSelection = projectPolicy?.environmentId !== undefined;
|
||||
}
|
||||
if (!projectHasEnvironmentSelection) {
|
||||
const baseSettings = nextExecutionWorkspaceSettings == null
|
||||
? {}
|
||||
: parseObject(nextExecutionWorkspaceSettings);
|
||||
const existingEnvId = typeof baseSettings.environmentId === "string"
|
||||
? baseSettings.environmentId
|
||||
: null;
|
||||
|
||||
// Look up both the prior assignee (to detect auto-promoted env)
|
||||
// and the new assignee in a single query.
|
||||
type AgentRow = { id: string; defaultEnvironmentId: string | null };
|
||||
const agentRows: AgentRow[] = await tx
|
||||
.select({ id: agents.id, defaultEnvironmentId: agents.defaultEnvironmentId })
|
||||
.from(agents)
|
||||
.where(
|
||||
and(
|
||||
eq(agents.companyId, existing.companyId),
|
||||
inArray(
|
||||
agents.id,
|
||||
[issueData.assigneeAgentId!, existing.assigneeAgentId].filter(
|
||||
(value): value is string => typeof value === "string",
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const newAssignee = agentRows.find((row: AgentRow) => row.id === issueData.assigneeAgentId);
|
||||
const previousAssignee = existing.assigneeAgentId
|
||||
? agentRows.find((row: AgentRow) => row.id === existing.assigneeAgentId)
|
||||
: null;
|
||||
|
||||
const newDefaultEnvId =
|
||||
typeof newAssignee?.defaultEnvironmentId === "string" && newAssignee.defaultEnvironmentId.length > 0
|
||||
? newAssignee.defaultEnvironmentId
|
||||
: null;
|
||||
const previousDefaultEnvId =
|
||||
typeof previousAssignee?.defaultEnvironmentId === "string" && previousAssignee.defaultEnvironmentId.length > 0
|
||||
? previousAssignee.defaultEnvironmentId
|
||||
: null;
|
||||
|
||||
const existingEnvWasAutoPromoted =
|
||||
existingEnvId === null ||
|
||||
(previousDefaultEnvId !== null && existingEnvId === previousDefaultEnvId);
|
||||
|
||||
if (newDefaultEnvId && existingEnvWasAutoPromoted) {
|
||||
patch.executionWorkspaceSettings = {
|
||||
...baseSettings,
|
||||
environmentId: newDefaultEnvId,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
patch.goalId = resolveNextIssueGoalId({
|
||||
currentProjectId: existing.projectId,
|
||||
currentGoalId: existing.goalId,
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface AcquireSandboxLeaseInput {
|
||||
environmentId: string;
|
||||
heartbeatRunId: string;
|
||||
issueId: string | null;
|
||||
agentId?: string | null;
|
||||
executionWorkspaceId?: string | null;
|
||||
}
|
||||
|
||||
export interface ResumeSandboxLeaseInput {
|
||||
@@ -137,7 +139,7 @@ class FakeSandboxProvider implements SandboxProvider {
|
||||
async acquireLease(input: AcquireSandboxLeaseInput): Promise<SandboxLeaseHandle> {
|
||||
assertProviderConfig<FakeSandboxEnvironmentConfig>(this.provider, input.config);
|
||||
const providerLeaseId = input.config.reuseLease
|
||||
? `sandbox://fake/${input.environmentId}`
|
||||
? `sandbox://fake/${input.environmentId}/${input.executionWorkspaceId ?? "workspace"}/${input.agentId ?? "agent"}`
|
||||
: `sandbox://fake/${input.heartbeatRunId}/${randomUUID()}`;
|
||||
|
||||
return {
|
||||
@@ -329,6 +331,8 @@ export async function acquireSandboxProviderLease(input: {
|
||||
environmentId: string;
|
||||
heartbeatRunId: string;
|
||||
issueId: string | null;
|
||||
agentId?: string | null;
|
||||
executionWorkspaceId?: string | null;
|
||||
reusableProviderLeaseId?: string | null;
|
||||
}): Promise<SandboxLeaseHandle> {
|
||||
const provider = requireSandboxProvider(input.config.provider);
|
||||
@@ -347,6 +351,8 @@ export async function acquireSandboxProviderLease(input: {
|
||||
environmentId: input.environmentId,
|
||||
heartbeatRunId: input.heartbeatRunId,
|
||||
issueId: input.issueId,
|
||||
agentId: input.agentId,
|
||||
executionWorkspaceId: input.executionWorkspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -865,13 +865,13 @@ export function secretService(db: Db) {
|
||||
status: environments.status,
|
||||
})
|
||||
.from(environments)
|
||||
.where(and(eq(environments.companyId, companyId), inArray(environments.id, environmentIds)));
|
||||
.where(inArray(environments.id, environmentIds));
|
||||
for (const row of rows) {
|
||||
setTarget({
|
||||
type: "environment",
|
||||
id: row.id,
|
||||
label: row.name,
|
||||
href: "/company/settings/environments",
|
||||
href: "/company/settings/instance/environments",
|
||||
status: row.status,
|
||||
});
|
||||
}
|
||||
@@ -2181,6 +2181,20 @@ export function secretService(db: Db) {
|
||||
return normalizedRefs;
|
||||
},
|
||||
|
||||
listBindingCompanyIdsForTarget: async (
|
||||
target: { targetType: SecretBindingTargetType; targetId: string },
|
||||
): Promise<string[]> =>
|
||||
db
|
||||
.select({ companyId: companySecretBindings.companyId })
|
||||
.from(companySecretBindings)
|
||||
.where(
|
||||
and(
|
||||
eq(companySecretBindings.targetType, target.targetType),
|
||||
eq(companySecretBindings.targetId, target.targetId),
|
||||
),
|
||||
)
|
||||
.then((rows) => [...new Set(rows.map((row) => row.companyId))]),
|
||||
|
||||
syncEnvBindingsForTarget: async (
|
||||
companyId: string,
|
||||
target: { targetType: SecretBindingTargetType; targetId: string; pathPrefix?: string },
|
||||
|
||||
Reference in New Issue
Block a user