mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 10:30:37 +09:00
Add SSH environment support (#4358)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The environments subsystem already models execution environments, but before this branch there was no end-to-end SSH-backed runtime path for agents to actually run work against a remote box > - That meant agents could be configured around environment concepts without a reliable way to execute adapter sessions remotely, sync workspace state, and preserve run context across supported adapters > - We also need environment selection to participate in normal Paperclip control-plane behavior: agent defaults, project/issue selection, route validation, and environment probing > - Because this capability is still experimental, the UI surface should be easy to hide and easy to remove later without undoing the underlying implementation > - This pull request adds SSH environment execution support across the runtime, adapters, routes, schema, and tests, then puts the visible environment-management UI behind an experimental flag > - The benefit is that we can validate real SSH-backed agent execution now while keeping the user-facing controls safely gated until the feature is ready to come out of experimentation ## What Changed - Added SSH-backed execution target support in the shared adapter runtime, including remote workspace preparation, skill/runtime asset sync, remote session handling, and workspace restore behavior after runs. - Added SSH execution coverage for supported local adapters, plus remote execution tests across Claude, Codex, Cursor, Gemini, OpenCode, and Pi. - Added environment selection and environment-management backend support needed for SSH execution, including route/service work, validation, probing, and agent default environment persistence. - Added CLI support for SSH environment lab verification and updated related docs/tests. - Added the `enableEnvironments` experimental flag and gated the environment UI behind it on company settings, agent configuration, and project configuration surfaces. ## Verification - `pnpm exec vitest run packages/adapters/claude-local/src/server/execute.remote.test.ts packages/adapters/cursor-local/src/server/execute.remote.test.ts packages/adapters/gemini-local/src/server/execute.remote.test.ts packages/adapters/opencode-local/src/server/execute.remote.test.ts packages/adapters/pi-local/src/server/execute.remote.test.ts` - `pnpm exec vitest run server/src/__tests__/environment-routes.test.ts` - `pnpm exec vitest run server/src/__tests__/instance-settings-routes.test.ts` - `pnpm exec vitest run ui/src/lib/new-agent-hire-payload.test.ts ui/src/lib/new-agent-runtime-config.test.ts` - `pnpm -r typecheck` - `pnpm build` - Manual verification on a branch-local dev server: - enabled the experimental flag - created an SSH environment - created a Linux Claude agent using that environment - confirmed a run executed on the Linux box and synced workspace changes back ## Risks - Medium: this touches runtime execution flow across multiple adapters, so regressions would likely show up in remote session setup, workspace sync, or environment selection precedence. - The UI flag reduces exposure, but the underlying runtime and route changes are still substantial and rely on migration correctness. - The change set is broad across adapters, control-plane services, migrations, and UI gating, so review should pay close attention to environment-selection precedence and remote workspace lifecycle behavior. ## Model Used - OpenAI Codex via Paperclip's local Codex adapter, GPT-5-class coding model with tool use and code execution in the local repo workspace. The local adapter does not surface a more specific public model version string in this branch workflow. ## 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 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 - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
parent
f98c348e2b
commit
e4995bbb1c
95 changed files with 10162 additions and 315 deletions
|
|
@ -38,6 +38,7 @@ const CONFIG_REVISION_FIELDS = [
|
|||
"adapterType",
|
||||
"adapterConfig",
|
||||
"runtimeConfig",
|
||||
"defaultEnvironmentId",
|
||||
"budgetMonthlyCents",
|
||||
"metadata",
|
||||
] as const;
|
||||
|
|
@ -98,6 +99,7 @@ function buildConfigSnapshot(
|
|||
adapterType: row.adapterType,
|
||||
adapterConfig,
|
||||
runtimeConfig,
|
||||
defaultEnvironmentId: row.defaultEnvironmentId,
|
||||
budgetMonthlyCents: row.budgetMonthlyCents,
|
||||
metadata,
|
||||
};
|
||||
|
|
@ -169,6 +171,10 @@ function configPatchFromSnapshot(snapshot: unknown): Partial<typeof agents.$infe
|
|||
adapterType: snapshot.adapterType,
|
||||
adapterConfig: isPlainRecord(snapshot.adapterConfig) ? snapshot.adapterConfig : {},
|
||||
runtimeConfig: isPlainRecord(snapshot.runtimeConfig) ? snapshot.runtimeConfig : {},
|
||||
defaultEnvironmentId:
|
||||
typeof snapshot.defaultEnvironmentId === "string" || snapshot.defaultEnvironmentId === null
|
||||
? snapshot.defaultEnvironmentId
|
||||
: null,
|
||||
budgetMonthlyCents: Math.max(0, Math.floor(snapshot.budgetMonthlyCents)),
|
||||
metadata: isPlainRecord(snapshot.metadata) || snapshot.metadata === null ? snapshot.metadata : null,
|
||||
};
|
||||
|
|
|
|||
237
server/src/services/environment-config.ts
Normal file
237
server/src/services/environment-config.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type {
|
||||
Environment,
|
||||
EnvironmentDriver,
|
||||
LocalEnvironmentConfig,
|
||||
SshEnvironmentConfig,
|
||||
} from "@paperclipai/shared";
|
||||
import { unprocessable } from "../errors.js";
|
||||
import { parseObject } from "../adapters/utils.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
|
||||
const secretRefSchema = z.object({
|
||||
type: z.literal("secret_ref"),
|
||||
secretId: z.string().uuid(),
|
||||
version: z.union([z.literal("latest"), z.number().int().positive()]).optional().default("latest"),
|
||||
}).strict();
|
||||
|
||||
const sshEnvironmentConfigSchema = z.object({
|
||||
host: z.string({ required_error: "SSH environments require a host." }).trim().min(1, "SSH environments require a host."),
|
||||
port: z.coerce.number().int().min(1).max(65535).default(22),
|
||||
username: z.string({ required_error: "SSH environments require a username." }).trim().min(1, "SSH environments require a username."),
|
||||
remoteWorkspacePath: z
|
||||
.string({ required_error: "SSH environments require a remote workspace path." })
|
||||
.trim()
|
||||
.min(1, "SSH environments require a remote workspace path.")
|
||||
.refine((value) => value.startsWith("/"), "SSH remote workspace path must be absolute."),
|
||||
privateKey: z.null().optional().default(null),
|
||||
privateKeySecretRef: secretRefSchema.optional().nullable().default(null),
|
||||
knownHosts: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((value) => (value && value.length > 0 ? value : null)),
|
||||
strictHostKeyChecking: z.boolean().optional().default(true),
|
||||
}).strict();
|
||||
|
||||
const sshEnvironmentConfigProbeSchema = sshEnvironmentConfigSchema.extend({
|
||||
privateKey: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((value) => (value && value.length > 0 ? value : null)),
|
||||
}).strict();
|
||||
|
||||
const sshEnvironmentConfigPersistenceSchema = sshEnvironmentConfigProbeSchema;
|
||||
|
||||
export type ParsedEnvironmentConfig =
|
||||
| { driver: "local"; config: LocalEnvironmentConfig }
|
||||
| { driver: "ssh"; config: SshEnvironmentConfig };
|
||||
|
||||
function toErrorMessage(error: z.ZodError) {
|
||||
const first = error.issues[0];
|
||||
if (!first) return "Invalid environment config.";
|
||||
return first.message;
|
||||
}
|
||||
|
||||
function secretName(input: {
|
||||
environmentName: string;
|
||||
driver: EnvironmentDriver;
|
||||
field: string;
|
||||
}) {
|
||||
const slug = input.environmentName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48) || "environment";
|
||||
return `environment-${input.driver}-${slug}-${input.field}-${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
|
||||
async function createEnvironmentSecret(input: {
|
||||
db: Db;
|
||||
companyId: string;
|
||||
environmentName: string;
|
||||
driver: EnvironmentDriver;
|
||||
field: string;
|
||||
value: string;
|
||||
actor?: { userId?: string | null; agentId?: string | null };
|
||||
}) {
|
||||
const created = await secretService(input.db).create(
|
||||
input.companyId,
|
||||
{
|
||||
name: secretName(input),
|
||||
provider: "local_encrypted",
|
||||
value: input.value,
|
||||
description: `Secret for ${input.environmentName} ${input.field}.`,
|
||||
},
|
||||
input.actor,
|
||||
);
|
||||
return {
|
||||
type: "secret_ref" as const,
|
||||
secretId: created.id,
|
||||
version: "latest" as const,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeEnvironmentConfig(input: {
|
||||
driver: EnvironmentDriver;
|
||||
config: Record<string, unknown> | null | undefined;
|
||||
}): Record<string, unknown> {
|
||||
if (input.driver === "local") {
|
||||
return { ...parseObject(input.config) };
|
||||
}
|
||||
|
||||
if (input.driver === "ssh") {
|
||||
const parsed = sshEnvironmentConfigSchema.safeParse(parseObject(input.config));
|
||||
if (!parsed.success) {
|
||||
throw unprocessable(toErrorMessage(parsed.error), {
|
||||
issues: parsed.error.issues,
|
||||
});
|
||||
}
|
||||
return parsed.data satisfies SshEnvironmentConfig;
|
||||
}
|
||||
|
||||
throw unprocessable(`Unsupported environment driver "${input.driver}".`);
|
||||
}
|
||||
|
||||
export function normalizeEnvironmentConfigForProbe(input: {
|
||||
driver: EnvironmentDriver;
|
||||
config: Record<string, unknown> | null | undefined;
|
||||
}): Record<string, unknown> {
|
||||
if (input.driver === "ssh") {
|
||||
const parsed = sshEnvironmentConfigProbeSchema.safeParse(parseObject(input.config));
|
||||
if (!parsed.success) {
|
||||
throw unprocessable(toErrorMessage(parsed.error), {
|
||||
issues: parsed.error.issues,
|
||||
});
|
||||
}
|
||||
return parsed.data satisfies SshEnvironmentConfig;
|
||||
}
|
||||
|
||||
return normalizeEnvironmentConfig(input);
|
||||
}
|
||||
|
||||
export async function normalizeEnvironmentConfigForPersistence(input: {
|
||||
db: Db;
|
||||
companyId: string;
|
||||
environmentName: string;
|
||||
driver: EnvironmentDriver;
|
||||
config: Record<string, unknown> | null | undefined;
|
||||
actor?: { userId?: string | null; agentId?: string | null };
|
||||
}): Promise<Record<string, unknown>> {
|
||||
if (input.driver === "ssh") {
|
||||
const parsed = sshEnvironmentConfigPersistenceSchema.safeParse(parseObject(input.config));
|
||||
if (!parsed.success) {
|
||||
throw unprocessable(toErrorMessage(parsed.error), {
|
||||
issues: parsed.error.issues,
|
||||
});
|
||||
}
|
||||
const secrets = secretService(input.db);
|
||||
const { privateKey, ...stored } = parsed.data;
|
||||
let nextPrivateKeySecretRef = stored.privateKeySecretRef;
|
||||
if (privateKey) {
|
||||
nextPrivateKeySecretRef = await createEnvironmentSecret({
|
||||
db: input.db,
|
||||
companyId: input.companyId,
|
||||
environmentName: input.environmentName,
|
||||
driver: input.driver,
|
||||
field: "private-key",
|
||||
value: privateKey,
|
||||
actor: input.actor,
|
||||
});
|
||||
if (
|
||||
stored.privateKeySecretRef &&
|
||||
stored.privateKeySecretRef.secretId !== nextPrivateKeySecretRef.secretId
|
||||
) {
|
||||
await secrets.remove(stored.privateKeySecretRef.secretId);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...stored,
|
||||
privateKey: null,
|
||||
privateKeySecretRef: nextPrivateKeySecretRef,
|
||||
} satisfies SshEnvironmentConfig;
|
||||
}
|
||||
|
||||
return normalizeEnvironmentConfig({
|
||||
driver: input.driver,
|
||||
config: input.config,
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveEnvironmentDriverConfigForRuntime(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
environment: Pick<Environment, "driver" | "config">,
|
||||
): Promise<ParsedEnvironmentConfig> {
|
||||
const parsed = parseEnvironmentDriverConfig(environment);
|
||||
if (parsed.driver === "ssh" && parsed.config.privateKeySecretRef) {
|
||||
return {
|
||||
driver: "ssh",
|
||||
config: {
|
||||
...parsed.config,
|
||||
privateKey: await secretService(db).resolveSecretValue(
|
||||
companyId,
|
||||
parsed.config.privateKeySecretRef.secretId,
|
||||
parsed.config.privateKeySecretRef.version ?? "latest",
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function readSshEnvironmentPrivateKeySecretId(
|
||||
environment: Pick<Environment, "driver" | "config">,
|
||||
): string | null {
|
||||
if (environment.driver !== "ssh") return null;
|
||||
const parsed = sshEnvironmentConfigSchema.safeParse(parseObject(environment.config));
|
||||
if (!parsed.success) return null;
|
||||
return parsed.data.privateKeySecretRef?.secretId ?? null;
|
||||
}
|
||||
|
||||
export function parseEnvironmentDriverConfig(
|
||||
environment: Pick<Environment, "driver" | "config">,
|
||||
): ParsedEnvironmentConfig {
|
||||
if (environment.driver === "local") {
|
||||
return {
|
||||
driver: "local",
|
||||
config: { ...parseObject(environment.config) },
|
||||
};
|
||||
}
|
||||
|
||||
if (environment.driver === "ssh") {
|
||||
const parsed = sshEnvironmentConfigSchema.parse(parseObject(environment.config));
|
||||
return {
|
||||
driver: "ssh",
|
||||
config: parsed,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported environment driver "${environment.driver}".`);
|
||||
}
|
||||
77
server/src/services/environment-probe.ts
Normal file
77
server/src/services/environment-probe.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import type { Environment, EnvironmentProbeResult } from "@paperclipai/shared";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { ensureSshWorkspaceReady } from "@paperclipai/adapter-utils/ssh";
|
||||
import {
|
||||
resolveEnvironmentDriverConfigForRuntime,
|
||||
type ParsedEnvironmentConfig,
|
||||
} from "./environment-config.js";
|
||||
import os from "node:os";
|
||||
|
||||
export async function probeEnvironment(
|
||||
db: Db,
|
||||
environment: Environment,
|
||||
options: { resolvedConfig?: ParsedEnvironmentConfig } = {},
|
||||
): Promise<EnvironmentProbeResult> {
|
||||
const parsed = options.resolvedConfig ?? await resolveEnvironmentDriverConfigForRuntime(db, environment.companyId, environment);
|
||||
|
||||
if (parsed.driver === "local") {
|
||||
return {
|
||||
ok: true,
|
||||
driver: "local",
|
||||
summary: "Local environment is available on this Paperclip host.",
|
||||
details: {
|
||||
hostname: os.hostname(),
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const { remoteCwd } = await ensureSshWorkspaceReady(parsed.config);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
driver: "ssh",
|
||||
summary: `Connected to ${parsed.config.username}@${parsed.config.host} and verified the remote workspace path.`,
|
||||
details: {
|
||||
host: parsed.config.host,
|
||||
port: parsed.config.port,
|
||||
username: parsed.config.username,
|
||||
remoteWorkspacePath: parsed.config.remoteWorkspacePath,
|
||||
remoteCwd,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const stderr =
|
||||
error && typeof error === "object" && "stderr" in error && typeof error.stderr === "string"
|
||||
? error.stderr.trim()
|
||||
: "";
|
||||
const stdout =
|
||||
error && typeof error === "object" && "stdout" in error && typeof error.stdout === "string"
|
||||
? error.stdout.trim()
|
||||
: "";
|
||||
const code =
|
||||
error && typeof error === "object" && "code" in error
|
||||
? (error as { code?: unknown }).code
|
||||
: null;
|
||||
const message =
|
||||
stderr ||
|
||||
stdout ||
|
||||
(error instanceof Error ? error.message : String(error)) ||
|
||||
"SSH probe failed.";
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
driver: "ssh",
|
||||
summary: `SSH probe failed for ${parsed.config.username}@${parsed.config.host}.`,
|
||||
details: {
|
||||
host: parsed.config.host,
|
||||
port: parsed.config.port,
|
||||
username: parsed.config.username,
|
||||
remoteWorkspacePath: parsed.config.remoteWorkspacePath,
|
||||
error: message,
|
||||
code,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { environmentLeases, environments } from "@paperclipai/db";
|
||||
import {
|
||||
|
|
@ -130,6 +130,7 @@ export function environmentService(db: Db) {
|
|||
})
|
||||
.onConflictDoNothing({
|
||||
target: [environments.companyId, environments.driver],
|
||||
where: sql`${environments.driver} = 'local'`,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
|
@ -189,6 +190,15 @@ export function environmentService(db: Db) {
|
|||
return row ? toEnvironment(row) : null;
|
||||
},
|
||||
|
||||
remove: async (id: string): Promise<Environment | null> => {
|
||||
const row = await db
|
||||
.delete(environments)
|
||||
.where(eq(environments.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? toEnvironment(row) : null;
|
||||
},
|
||||
|
||||
listLeases: async (
|
||||
environmentId: string,
|
||||
filters: {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ 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 = (() => {
|
||||
|
|
@ -58,6 +59,7 @@ 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>) } }
|
||||
|
|
@ -109,6 +111,7 @@ 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>) } }
|
||||
|
|
@ -116,6 +119,28 @@ export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecuti
|
|||
};
|
||||
}
|
||||
|
||||
export function resolveExecutionWorkspaceEnvironmentId(input: {
|
||||
projectPolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
issueSettings: IssueExecutionWorkspaceSettings | null;
|
||||
workspaceConfig: { environmentId?: string | null } | null;
|
||||
agentDefaultEnvironmentId: string | null;
|
||||
defaultEnvironmentId: string;
|
||||
}) {
|
||||
if (input.workspaceConfig?.environmentId !== undefined) {
|
||||
return input.workspaceConfig.environmentId ?? input.defaultEnvironmentId;
|
||||
}
|
||||
if (input.issueSettings?.environmentId !== undefined) {
|
||||
return input.issueSettings.environmentId ?? input.defaultEnvironmentId;
|
||||
}
|
||||
if (input.projectPolicy?.environmentId !== undefined) {
|
||||
return input.projectPolicy.environmentId ?? input.defaultEnvironmentId;
|
||||
}
|
||||
if (input.agentDefaultEnvironmentId !== null) {
|
||||
return input.agentDefaultEnvironmentId;
|
||||
}
|
||||
return input.defaultEnvironmentId;
|
||||
}
|
||||
|
||||
export function defaultIssueExecutionWorkspaceSettingsForProject(
|
||||
projectPolicy: ProjectExecutionWorkspacePolicy | null,
|
||||
): IssueExecutionWorkspaceSettings | null {
|
||||
|
|
|
|||
|
|
@ -203,6 +203,7 @@ export function readExecutionWorkspaceConfig(metadata: Record<string, unknown> |
|
|||
if (!raw) return null;
|
||||
|
||||
const config: ExecutionWorkspaceConfig = {
|
||||
environmentId: readNullableString(raw.environmentId),
|
||||
provisionCommand: readNullableString(raw.provisionCommand),
|
||||
teardownCommand: readNullableString(raw.teardownCommand),
|
||||
cleanupCommand: readNullableString(raw.cleanupCommand),
|
||||
|
|
@ -226,6 +227,7 @@ export function mergeExecutionWorkspaceConfig(
|
|||
): Record<string, unknown> | null {
|
||||
const nextMetadata = isRecord(metadata) ? { ...metadata } : {};
|
||||
const current = readExecutionWorkspaceConfig(metadata) ?? {
|
||||
environmentId: null,
|
||||
provisionCommand: null,
|
||||
teardownCommand: null,
|
||||
cleanupCommand: null,
|
||||
|
|
@ -240,6 +242,7 @@ export function mergeExecutionWorkspaceConfig(
|
|||
}
|
||||
|
||||
const nextConfig: ExecutionWorkspaceConfig = {
|
||||
environmentId: patch.environmentId !== undefined ? readNullableString(patch.environmentId) : current.environmentId,
|
||||
provisionCommand: patch.provisionCommand !== undefined ? readNullableString(patch.provisionCommand) : current.provisionCommand,
|
||||
teardownCommand: patch.teardownCommand !== undefined ? readNullableString(patch.teardownCommand) : current.teardownCommand,
|
||||
cleanupCommand: patch.cleanupCommand !== undefined ? readNullableString(patch.cleanupCommand) : current.cleanupCommand,
|
||||
|
|
@ -260,6 +263,7 @@ export function mergeExecutionWorkspaceConfig(
|
|||
|
||||
if (hasConfig) {
|
||||
nextMetadata.config = {
|
||||
environmentId: nextConfig.environmentId,
|
||||
provisionCommand: nextConfig.provisionCommand,
|
||||
teardownCommand: nextConfig.teardownCommand,
|
||||
cleanupCommand: nextConfig.cleanupCommand,
|
||||
|
|
@ -739,6 +743,37 @@ export function executionWorkspaceService(db: Db) {
|
|||
.then((rows) => rows[0] ?? null);
|
||||
return row ? toExecutionWorkspace(row) : null;
|
||||
},
|
||||
|
||||
clearEnvironmentSelection: async (companyId: string, environmentId: string) => {
|
||||
return db.transaction(async (tx) => {
|
||||
const rows = await tx
|
||||
.select({
|
||||
id: executionWorkspaces.id,
|
||||
metadata: executionWorkspaces.metadata,
|
||||
})
|
||||
.from(executionWorkspaces)
|
||||
.where(eq(executionWorkspaces.companyId, companyId));
|
||||
|
||||
let cleared = 0;
|
||||
const updatedAt = new Date();
|
||||
for (const row of rows) {
|
||||
const metadata = (row.metadata as Record<string, unknown> | null) ?? null;
|
||||
const config = readExecutionWorkspaceConfig(metadata);
|
||||
if (config?.environmentId !== environmentId) continue;
|
||||
|
||||
await tx
|
||||
.update(executionWorkspaces)
|
||||
.set({
|
||||
metadata: mergeExecutionWorkspaceConfig(metadata, { environmentId: null }),
|
||||
updatedAt,
|
||||
})
|
||||
.where(eq(executionWorkspaces.id, row.id));
|
||||
cleared += 1;
|
||||
}
|
||||
|
||||
return cleared;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,12 +8,19 @@ import type { Db } from "@paperclipai/db";
|
|||
import {
|
||||
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
|
||||
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
||||
isEnvironmentDriverSupportedForAdapter,
|
||||
type BillingType,
|
||||
type EnvironmentLeaseStatus,
|
||||
type ExecutionWorkspace,
|
||||
type ExecutionWorkspaceConfig,
|
||||
type RunLivenessState,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
ensureSshWorkspaceReady,
|
||||
findReachablePaperclipApiUrlOverSsh,
|
||||
type SshRemoteExecutionSpec,
|
||||
} from "@paperclipai/adapter-utils/ssh";
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
import {
|
||||
agents,
|
||||
agentRuntimeState,
|
||||
|
|
@ -98,8 +105,10 @@ import {
|
|||
issueExecutionWorkspaceModeForPersistedWorkspace,
|
||||
parseIssueExecutionWorkspaceSettings,
|
||||
parseProjectExecutionWorkspacePolicy,
|
||||
resolveExecutionWorkspaceEnvironmentId,
|
||||
resolveExecutionWorkspaceMode,
|
||||
} from "./execution-workspace-policy.js";
|
||||
import { resolveEnvironmentDriverConfigForRuntime } from "./environment-config.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import {
|
||||
RUN_LIVENESS_CONTINUATION_REASON,
|
||||
|
|
@ -322,6 +331,27 @@ function leaseReleaseStatusForRunStatus(
|
|||
return status === "failed" || status === "timed_out" ? "failed" : "released";
|
||||
}
|
||||
|
||||
function runtimeApiUrlCandidates() {
|
||||
const candidates = [
|
||||
process.env.PAPERCLIP_RUNTIME_API_URL,
|
||||
process.env.PAPERCLIP_API_URL,
|
||||
process.env.PUBLIC_BASE_URL,
|
||||
].filter((value): value is string => typeof value === "string" && value.trim().length > 0);
|
||||
const encoded = process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON;
|
||||
if (!encoded) return candidates;
|
||||
try {
|
||||
const parsed = JSON.parse(encoded);
|
||||
if (Array.isArray(parsed)) {
|
||||
candidates.push(
|
||||
...parsed.filter((value): value is string => typeof value === "string" && value.trim().length > 0),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
logger.warn("Ignoring invalid PAPERCLIP_RUNTIME_API_CANDIDATES_JSON");
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function applyPersistedExecutionWorkspaceConfig(input: {
|
||||
config: Record<string, unknown>;
|
||||
workspaceConfig: ExecutionWorkspaceConfig | null;
|
||||
|
|
@ -391,9 +421,19 @@ export function buildRealizedExecutionWorkspaceFromPersisted(input: {
|
|||
};
|
||||
}
|
||||
|
||||
function buildExecutionWorkspaceConfigSnapshot(config: Record<string, unknown>): Partial<ExecutionWorkspaceConfig> | null {
|
||||
function buildExecutionWorkspaceConfigSnapshot(
|
||||
config: Record<string, unknown>,
|
||||
environmentId?: string | null,
|
||||
): Partial<ExecutionWorkspaceConfig> | null {
|
||||
const strategy = parseObject(config.workspaceStrategy);
|
||||
const snapshot: Partial<ExecutionWorkspaceConfig> = {};
|
||||
// Persist the resolved environment onto the workspace so reused sessions stay on the
|
||||
// environment they were created against until the workspace itself is recreated/reset.
|
||||
const hasExplicitEnvironmentSelection = environmentId !== undefined;
|
||||
|
||||
if (hasExplicitEnvironmentSelection) {
|
||||
snapshot.environmentId = environmentId ?? null;
|
||||
}
|
||||
|
||||
if ("workspaceStrategy" in config) {
|
||||
snapshot.provisionCommand = typeof strategy.provisionCommand === "string" ? strategy.provisionCommand : null;
|
||||
|
|
@ -426,7 +466,7 @@ function buildExecutionWorkspaceConfigSnapshot(config: Record<string, unknown>):
|
|||
if (typeof value === "object") return Object.keys(value).length > 0;
|
||||
return true;
|
||||
});
|
||||
return hasSnapshot ? snapshot : null;
|
||||
return hasSnapshot || hasExplicitEnvironmentSelection ? snapshot : null;
|
||||
}
|
||||
|
||||
function deriveRepoNameFromRepoUrl(repoUrl: string | null): string | null {
|
||||
|
|
@ -5061,7 +5101,15 @@ export function heartbeatService(db: Db) {
|
|||
const mergedConfig = issueAssigneeOverrides?.adapterConfig
|
||||
? { ...persistedWorkspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig }
|
||||
: persistedWorkspaceManagedConfig;
|
||||
const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig);
|
||||
const defaultEnvironment = await environmentsSvc.ensureLocalEnvironment(agent.companyId);
|
||||
const selectedEnvironmentId = resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: projectExecutionWorkspacePolicy,
|
||||
issueSettings: issueExecutionWorkspaceSettings,
|
||||
workspaceConfig: existingExecutionWorkspace?.config ?? null,
|
||||
agentDefaultEnvironmentId: agent.defaultEnvironmentId,
|
||||
defaultEnvironmentId: defaultEnvironment.id,
|
||||
});
|
||||
const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig, selectedEnvironmentId);
|
||||
const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig);
|
||||
const { resolvedConfig, secretKeys } = await resolveExecutionRunAdapterConfig({
|
||||
companyId: agent.companyId,
|
||||
|
|
@ -5294,26 +5342,105 @@ export function heartbeatService(db: Db) {
|
|||
})(),
|
||||
};
|
||||
context.paperclipWorkspaces = resolvedWorkspace.workspaceHints;
|
||||
const localEnvironment = await environmentsSvc.ensureLocalEnvironment(agent.companyId);
|
||||
const selectedEnvironment =
|
||||
selectedEnvironmentId === defaultEnvironment.id
|
||||
? defaultEnvironment
|
||||
: await environmentsSvc.getById(selectedEnvironmentId);
|
||||
if (!selectedEnvironment || selectedEnvironment.companyId !== agent.companyId) {
|
||||
throw notFound(`Environment "${selectedEnvironmentId}" not found.`);
|
||||
}
|
||||
if (selectedEnvironment.status !== "active") {
|
||||
throw conflict(`Environment "${selectedEnvironment.name}" is not active.`);
|
||||
}
|
||||
if (!isEnvironmentDriverSupportedForAdapter(agent.adapterType, selectedEnvironment.driver)) {
|
||||
throw conflict(
|
||||
`Adapter "${agent.adapterType}" does not support "${selectedEnvironment.driver}" environments.`,
|
||||
);
|
||||
}
|
||||
|
||||
const selectedEnvironmentRuntimeConfig = await resolveEnvironmentDriverConfigForRuntime(
|
||||
db,
|
||||
agent.companyId,
|
||||
selectedEnvironment,
|
||||
);
|
||||
let environmentProvider = selectedEnvironment.driver;
|
||||
let environmentProviderLeaseId: string | null = null;
|
||||
let environmentLeaseMetadata: Record<string, unknown> = {
|
||||
driver: selectedEnvironment.driver,
|
||||
executionWorkspaceMode: persistedExecutionWorkspace?.mode ?? effectiveExecutionWorkspaceMode,
|
||||
cwd: executionWorkspace.cwd,
|
||||
};
|
||||
let executionTarget: AdapterExecutionTarget | null = null;
|
||||
let remoteExecution: SshRemoteExecutionSpec | null = null;
|
||||
|
||||
if (selectedEnvironmentRuntimeConfig.driver === "ssh") {
|
||||
const { remoteCwd } = await ensureSshWorkspaceReady(selectedEnvironmentRuntimeConfig.config);
|
||||
const paperclipApiUrl = await findReachablePaperclipApiUrlOverSsh({
|
||||
config: selectedEnvironmentRuntimeConfig.config,
|
||||
candidates: runtimeApiUrlCandidates(),
|
||||
});
|
||||
remoteExecution = {
|
||||
...selectedEnvironmentRuntimeConfig.config,
|
||||
remoteCwd,
|
||||
paperclipApiUrl,
|
||||
};
|
||||
environmentProvider = "ssh";
|
||||
environmentProviderLeaseId = `ssh://${selectedEnvironmentRuntimeConfig.config.username}@${selectedEnvironmentRuntimeConfig.config.host}:${selectedEnvironmentRuntimeConfig.config.port}${remoteCwd}`;
|
||||
environmentLeaseMetadata = {
|
||||
...environmentLeaseMetadata,
|
||||
host: selectedEnvironmentRuntimeConfig.config.host,
|
||||
port: selectedEnvironmentRuntimeConfig.config.port,
|
||||
username: selectedEnvironmentRuntimeConfig.config.username,
|
||||
remoteWorkspacePath: selectedEnvironmentRuntimeConfig.config.remoteWorkspacePath,
|
||||
remoteCwd,
|
||||
paperclipApiUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const environmentLease = await environmentsSvc.acquireLease({
|
||||
companyId: agent.companyId,
|
||||
environmentId: localEnvironment.id,
|
||||
environmentId: selectedEnvironment.id,
|
||||
executionWorkspaceId: persistedExecutionWorkspace?.id ?? null,
|
||||
issueId: issueId ?? null,
|
||||
heartbeatRunId: run.id,
|
||||
leasePolicy: "ephemeral",
|
||||
provider: "local",
|
||||
metadata: {
|
||||
driver: "local",
|
||||
executionWorkspaceMode: persistedExecutionWorkspace?.mode ?? effectiveExecutionWorkspaceMode,
|
||||
cwd: executionWorkspace.cwd,
|
||||
},
|
||||
provider: environmentProvider,
|
||||
providerLeaseId: environmentProviderLeaseId,
|
||||
metadata: environmentLeaseMetadata,
|
||||
});
|
||||
if (remoteExecution) {
|
||||
executionTarget = {
|
||||
kind: "remote",
|
||||
transport: "ssh",
|
||||
environmentId: selectedEnvironment.id,
|
||||
leaseId: environmentLease.id,
|
||||
remoteCwd: remoteExecution.remoteCwd,
|
||||
paperclipApiUrl: remoteExecution.paperclipApiUrl,
|
||||
spec: remoteExecution,
|
||||
};
|
||||
}
|
||||
context.paperclipEnvironment = {
|
||||
id: localEnvironment.id,
|
||||
name: localEnvironment.name,
|
||||
driver: localEnvironment.driver,
|
||||
id: selectedEnvironment.id,
|
||||
name: selectedEnvironment.name,
|
||||
driver: selectedEnvironment.driver,
|
||||
leaseId: environmentLease.id,
|
||||
...(typeof environmentLease.metadata?.remoteCwd === "string"
|
||||
? {
|
||||
remoteCwd: environmentLease.metadata.remoteCwd,
|
||||
host:
|
||||
typeof environmentLease.metadata?.host === "string"
|
||||
? environmentLease.metadata.host
|
||||
: undefined,
|
||||
port:
|
||||
typeof environmentLease.metadata?.port === "number"
|
||||
? environmentLease.metadata.port
|
||||
: undefined,
|
||||
username:
|
||||
typeof environmentLease.metadata?.username === "string"
|
||||
? environmentLease.metadata.username
|
||||
: undefined,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
await logActivity(db, {
|
||||
companyId: agent.companyId,
|
||||
|
|
@ -5325,8 +5452,8 @@ export function heartbeatService(db: Db) {
|
|||
entityType: "environment_lease",
|
||||
entityId: environmentLease.id,
|
||||
details: {
|
||||
environmentId: localEnvironment.id,
|
||||
driver: localEnvironment.driver,
|
||||
environmentId: selectedEnvironment.id,
|
||||
driver: selectedEnvironment.driver,
|
||||
leasePolicy: environmentLease.leasePolicy,
|
||||
provider: environmentLease.provider,
|
||||
executionWorkspaceId: environmentLease.executionWorkspaceId,
|
||||
|
|
@ -5592,6 +5719,10 @@ export function heartbeatService(db: Db) {
|
|||
runtime: runtimeForAdapter,
|
||||
config: runtimeConfig,
|
||||
context,
|
||||
executionTarget,
|
||||
executionTransport: remoteExecution
|
||||
? { remoteExecution: remoteExecution as unknown as Record<string, unknown> }
|
||||
: undefined,
|
||||
onLog,
|
||||
onMeta: onAdapterMeta,
|
||||
onSpawn: async (meta) => {
|
||||
|
|
|
|||
|
|
@ -38,11 +38,13 @@ function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettin
|
|||
const parsed = instanceExperimentalSettingsSchema.safeParse(raw ?? {});
|
||||
if (parsed.success) {
|
||||
return {
|
||||
enableEnvironments: parsed.data.enableEnvironments ?? false,
|
||||
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
|
||||
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
enableEnvironments: false,
|
||||
enableIsolatedWorkspaces: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
defaultIssueExecutionWorkspaceSettingsForProject,
|
||||
gateProjectExecutionWorkspacePolicy,
|
||||
issueExecutionWorkspaceModeForPersistedWorkspace,
|
||||
parseIssueExecutionWorkspaceSettings,
|
||||
parseProjectExecutionWorkspacePolicy,
|
||||
} from "./execution-workspace-policy.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
|
|
@ -2191,6 +2192,36 @@ export function issueService(db: Db) {
|
|||
return dbOrTx === db ? db.transaction(runUpdate) : runUpdate(dbOrTx);
|
||||
},
|
||||
|
||||
clearExecutionWorkspaceEnvironmentSelection: async (companyId: string, environmentId: string) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
executionWorkspaceSettings: issues.executionWorkspaceSettings,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.companyId, companyId));
|
||||
|
||||
let cleared = 0;
|
||||
for (const row of rows) {
|
||||
const settings = parseIssueExecutionWorkspaceSettings(row.executionWorkspaceSettings);
|
||||
if (settings?.environmentId !== environmentId) continue;
|
||||
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
executionWorkspaceSettings: {
|
||||
...settings,
|
||||
environmentId: null,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, row.id));
|
||||
cleared += 1;
|
||||
}
|
||||
|
||||
return cleared;
|
||||
},
|
||||
|
||||
remove: (id: string) =>
|
||||
db.transaction(async (tx) => {
|
||||
const attachmentAssetIds = await tx
|
||||
|
|
|
|||
|
|
@ -523,6 +523,36 @@ export function projectService(db: Db) {
|
|||
return enriched ?? null;
|
||||
},
|
||||
|
||||
clearExecutionWorkspaceEnvironmentSelection: async (companyId: string, environmentId: string) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: projects.id,
|
||||
executionWorkspacePolicy: projects.executionWorkspacePolicy,
|
||||
})
|
||||
.from(projects)
|
||||
.where(eq(projects.companyId, companyId));
|
||||
|
||||
let cleared = 0;
|
||||
for (const row of rows) {
|
||||
const policy = parseProjectExecutionWorkspacePolicy(row.executionWorkspacePolicy);
|
||||
if (policy?.environmentId !== environmentId) continue;
|
||||
|
||||
await db
|
||||
.update(projects)
|
||||
.set({
|
||||
executionWorkspacePolicy: {
|
||||
...policy,
|
||||
environmentId: null,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(projects.id, row.id));
|
||||
cleared += 1;
|
||||
}
|
||||
|
||||
return cleared;
|
||||
},
|
||||
|
||||
remove: (id: string) =>
|
||||
db
|
||||
.delete(projects)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue