mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 21:05:21 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Environment runtime drivers provide workspace, lease, and custom image behavior > - Runtime code used driver identity checks and several capability-specific members > - These checks spread capability rules across the runtime and made new drivers harder to verify > - This pull request adds one general capability classifier and one static driver support table > - The benefit is one fail-closed capability model that keeps current behavior and supports future drivers ## Linked Issues or Issue Description **What existing behavior does this improve?** Environment runtime capability checks for workspace realization, custom images, lease capabilities, and duplex authorization. **Subsystem affected** Cross-cutting (multiple of the above) **Current behavior** The runtime selects several capability paths from driver identity and separate capability members. Custom image gates also trust provider declarations without checking every matching live worker method. **Proposed behavior** The runtime uses one general capability classifier and one static support table. Custom image gates require both the provider declaration and every matching live worker method. The public capability names remain unchanged. **Reason and benefit** The change keeps capability rules in one place. It removes identity conditions from runtime consumers and makes unsupported drivers fail closed. **Breaking changes** None. The public names sandboxCapabilities, sandboxProviders, and EffectiveSandboxCapabilities remain available. ## What Changed - Add classifyEnvironmentCapabilities and static support definitions for all four driver families. - Add resolveCapabilities to every environment runtime driver. - Move driver traits into environment-driver-traits.ts and migrate runtime consumers. - Require provider declarations and matching live worker methods for all custom image gates. - Migrate duplex authorization to the general resolver and remove the dead sandbox-only member. - Delete the unused resolveEffectiveSandboxCapabilities wrapper and update its test. ## Verification - pnpm --filter @paperclipai/server typecheck - pnpm exec vitest run server/src/__tests__/environment-capability-contract.test.ts server/src/__tests__/environment-runtime.test.ts — 92 tests pass - pnpm exec vitest run server/src/__tests__/environment-driver-traits.test.ts server/src/__tests__/general-capability-classifier.test.ts — 12 tests pass - pnpm exec vitest run server/src/__tests__/environment-custom-images-service.test.ts server/src/__tests__/environment-execution-target-capabilities.test.ts server/src/__tests__/environment-execution-target-duplex.test.ts server/src/__tests__/environment-execution-target-duplex-kill-switch.test.ts — 66 tests pass ## Risks The main risk is a capability gate that denies a valid driver or permits an invalid driver. The static support matrix, live worker method checks, and regression tests reduce this risk. No database, public API, or published type name changes. ## Model Used OpenAI Codex, GPT-5, with tool use and code execution. The deployment does not provide a separate context-window value. ## 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 (for example, docs/... or fix/...) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
664 lines
24 KiB
TypeScript
664 lines
24 KiB
TypeScript
import type { Db } from "@paperclipai/db";
|
|
import type {
|
|
EnvironmentProbeResult,
|
|
PluginEnvironmentConfig,
|
|
PluginEnvironmentDriverDeclaration,
|
|
} from "@paperclipai/shared";
|
|
import type {
|
|
PluginEnvironmentExecuteParams,
|
|
PluginEnvironmentExecuteResult,
|
|
PluginEnvironmentInteractiveSetupSession,
|
|
PluginEnvironmentStartInteractiveSetupParams,
|
|
PluginEnvironmentGetInteractiveSetupParams,
|
|
PluginEnvironmentCaptureTemplateParams,
|
|
PluginEnvironmentCaptureTemplateResult,
|
|
PluginEnvironmentCancelInteractiveSetupParams,
|
|
PluginEnvironmentCancelInteractiveSetupResult,
|
|
PluginEnvironmentDeleteTemplateParams,
|
|
PluginEnvironmentDeleteTemplateResult,
|
|
PluginEnvironmentLease,
|
|
PluginEnvironmentRealizeWorkspaceParams,
|
|
PluginEnvironmentRealizeWorkspaceResult,
|
|
} from "@paperclipai/plugin-sdk";
|
|
import { unprocessable } from "../errors.js";
|
|
import {
|
|
collectSecretRefPaths,
|
|
parseSecretRefBindingObject,
|
|
readConfigValueAtPath,
|
|
writeConfigValueAtPath,
|
|
} from "./json-schema-secret-refs.js";
|
|
import { pluginRegistryService } from "./plugin-registry.js";
|
|
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
|
|
|
/**
|
|
* The worker methods a sandbox provider must advertise before the host reuses
|
|
* a provider lease across runs. The host resumes a lease through
|
|
* `environmentResumeLease`, ends it through `environmentReleaseLease`, and tears
|
|
* down a stale lease through `environmentDestroyLease`. The reuse path destroys
|
|
* the stale lease when a resume fails and then acquires a fresh lease, so a
|
|
* provider that omits any of the three methods can strand the stale lease and
|
|
* can never complete the reuse path.
|
|
*
|
|
* The runtime capability normalizer maps `reusableLeases` to these same methods,
|
|
* so the acquisition guard, the effective-capability snapshot, and the published
|
|
* provider-capabilities value all read one source and cannot drift.
|
|
*/
|
|
export const REUSABLE_LEASE_WORKER_METHODS = [
|
|
"environmentResumeLease",
|
|
"environmentReleaseLease",
|
|
"environmentDestroyLease",
|
|
] as const;
|
|
|
|
/**
|
|
* The worker methods a sandbox provider must advertise before the customImage
|
|
* setup gate in `environment-custom-images.ts` allows an interactive setup
|
|
* session. The setup lifecycle starts a session through
|
|
* `environmentStartInteractiveSetup`, polls it through
|
|
* `environmentGetInteractiveSetup`, and cancels it through
|
|
* `environmentCancelInteractiveSetup`. A provider that omits any of the three
|
|
* can strand a setup session partway through the lifecycle.
|
|
*/
|
|
export const INTERACTIVE_SETUP_WORKER_METHODS = [
|
|
"environmentStartInteractiveSetup",
|
|
"environmentGetInteractiveSetup",
|
|
"environmentCancelInteractiveSetup",
|
|
] as const;
|
|
|
|
/** The worker method the customImage template-capture gate in `environment-custom-images.ts` requires. */
|
|
export const TEMPLATE_CAPTURE_WORKER_METHODS = [
|
|
"environmentCaptureTemplate",
|
|
] as const;
|
|
|
|
/** The worker method the customImage template-delete gate in `environment-custom-images.ts` requires. */
|
|
export const TEMPLATE_DELETE_WORKER_METHODS = [
|
|
"environmentDeleteTemplate",
|
|
] as const;
|
|
|
|
export interface ReadyPluginWorkerRecovery {
|
|
pluginKeys: readonly string[];
|
|
startWorker(plugin: { id: string; pluginKey: string }): Promise<boolean>;
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
export interface ReadyPluginEnvironmentDriver {
|
|
pluginId: string;
|
|
pluginKey: string;
|
|
driverKey: string;
|
|
displayName: string;
|
|
description?: string;
|
|
configSchema: PluginEnvironmentDriverDeclaration["configSchema"];
|
|
supportsReusableLeases?: PluginEnvironmentDriverDeclaration["supportsReusableLeases"];
|
|
sandboxCapabilities?: PluginEnvironmentDriverDeclaration["sandboxCapabilities"];
|
|
/**
|
|
* The running worker for this exact plugin advertises ALL reusable-lease
|
|
* lifecycle methods (`REUSABLE_LEASE_WORKER_METHODS`: resume, release, and
|
|
* destroy). The published provider-capabilities value grants reusable leases
|
|
* only when the declaration allows them AND this flag is true, so presentation
|
|
* matches the acquisition guard, which also verifies the same methods live.
|
|
*/
|
|
reusableLeaseMethodsVerified: boolean;
|
|
supportsInteractiveSetup?: PluginEnvironmentDriverDeclaration["supportsInteractiveSetup"];
|
|
interactiveSetupConnectionTypes?: PluginEnvironmentDriverDeclaration["interactiveSetupConnectionTypes"];
|
|
supportsTemplateCapture?: PluginEnvironmentDriverDeclaration["supportsTemplateCapture"];
|
|
templateRefKind?: PluginEnvironmentDriverDeclaration["templateRefKind"];
|
|
templateConfigBinding?: PluginEnvironmentDriverDeclaration["templateConfigBinding"];
|
|
supportsTemplateDelete?: PluginEnvironmentDriverDeclaration["supportsTemplateDelete"];
|
|
supportsLoginPty?: PluginEnvironmentDriverDeclaration["supportsLoginPty"];
|
|
}
|
|
|
|
export function pluginDriverProviderKey(config: Pick<PluginEnvironmentConfig, "pluginKey" | "driverKey">): string {
|
|
return `${config.pluginKey}:${config.driverKey}`;
|
|
}
|
|
|
|
const DEFAULT_READY_PLUGIN_WORKER_RECOVERY_TIMEOUT_MS = 2_000;
|
|
|
|
async function resolveWithTimeout<T>(promise: Promise<T>, timeoutMs: number, timeoutValue: T): Promise<T> {
|
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return await promise;
|
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
try {
|
|
return await Promise.race([
|
|
promise,
|
|
new Promise<T>((resolve) => {
|
|
timeout = setTimeout(() => resolve(timeoutValue), timeoutMs);
|
|
}),
|
|
]);
|
|
} finally {
|
|
if (timeout) clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
export async function resolvePluginEnvironmentDriver(input: {
|
|
db: Db;
|
|
workerManager: PluginWorkerManager;
|
|
config: PluginEnvironmentConfig;
|
|
}) {
|
|
const pluginRegistry = pluginRegistryService(input.db);
|
|
const plugin = await pluginRegistry.getByKey(input.config.pluginKey);
|
|
if (!plugin || plugin.status !== "ready") {
|
|
throw new Error(`Plugin environment driver "${pluginDriverProviderKey(input.config)}" is not ready.`);
|
|
}
|
|
const driver = plugin.manifestJson.environmentDrivers?.find(
|
|
(candidate) => candidate.driverKey === input.config.driverKey,
|
|
);
|
|
if (!driver) {
|
|
throw new Error(`Plugin "${input.config.pluginKey}" does not declare environment driver "${input.config.driverKey}".`);
|
|
}
|
|
if (!input.workerManager.isRunning(plugin.id)) {
|
|
throw new Error(`Plugin environment driver "${pluginDriverProviderKey(input.config)}" has no running worker.`);
|
|
}
|
|
return { plugin, driver };
|
|
}
|
|
|
|
export async function resolvePluginEnvironmentDriverByKey(input: {
|
|
db: Db;
|
|
workerManager: PluginWorkerManager;
|
|
driverKey: string;
|
|
}) {
|
|
return await resolvePluginSandboxProviderDriverByKey({
|
|
db: input.db,
|
|
driverKey: input.driverKey,
|
|
workerManager: input.workerManager,
|
|
requireRunning: true,
|
|
});
|
|
}
|
|
|
|
export async function resolvePluginSandboxProviderDriverByKey(input: {
|
|
db: Db;
|
|
driverKey: string;
|
|
workerManager?: PluginWorkerManager;
|
|
requireRunning?: boolean;
|
|
}): Promise<{ plugin: Awaited<ReturnType<ReturnType<typeof pluginRegistryService>["list"]>>[number]; driver: PluginEnvironmentDriverDeclaration } | null> {
|
|
const pluginRegistry = pluginRegistryService(input.db);
|
|
const plugins = await pluginRegistry.list();
|
|
for (const plugin of plugins) {
|
|
const driver = plugin.manifestJson.environmentDrivers?.find(
|
|
(candidate) => candidate.driverKey === input.driverKey && candidate.kind === "sandbox_provider",
|
|
) as PluginEnvironmentDriverDeclaration | undefined;
|
|
if (!driver) continue;
|
|
if (input.requireRunning) {
|
|
if (plugin.status !== "ready") continue;
|
|
if (!input.workerManager?.isRunning(plugin.id)) continue;
|
|
}
|
|
return { plugin, driver };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Resolve the sandbox-provider driver declaration from one exact plugin id.
|
|
*
|
|
* A driver key is only unique inside a single manifest. Two installed plugins
|
|
* can declare the same driver key. A lease pins the plugin that acquired it
|
|
* through `metadata.pluginId`. Use this resolver, not the by-key resolver, when
|
|
* the caller must read the declaration from that exact plugin. The by-key
|
|
* resolver returns the first installed plugin with the key, which can be a
|
|
* different, even disabled, plugin.
|
|
*
|
|
* This resolver fails closed. It returns `null` when the plugin id is unknown,
|
|
* or when that plugin no longer declares a `sandbox_provider` driver with the
|
|
* given key.
|
|
*/
|
|
export async function resolvePluginSandboxProviderDriverById(input: {
|
|
db: Db;
|
|
pluginId: string;
|
|
driverKey: string;
|
|
}): Promise<{ plugin: Awaited<ReturnType<ReturnType<typeof pluginRegistryService>["getById"]>>; driver: PluginEnvironmentDriverDeclaration } | null> {
|
|
const pluginRegistry = pluginRegistryService(input.db);
|
|
const plugin = await pluginRegistry.getById(input.pluginId);
|
|
if (!plugin) return null;
|
|
const driver = plugin.manifestJson.environmentDrivers?.find(
|
|
(candidate) => candidate.driverKey === input.driverKey && candidate.kind === "sandbox_provider",
|
|
) as PluginEnvironmentDriverDeclaration | undefined;
|
|
if (!driver) return null;
|
|
return { plugin, driver };
|
|
}
|
|
|
|
export async function listReadyPluginEnvironmentDrivers(input: {
|
|
db: Db;
|
|
workerManager?: PluginWorkerManager;
|
|
recoverMissingWorker?: ReadyPluginWorkerRecovery;
|
|
}) {
|
|
if (!input.workerManager) return [];
|
|
const pluginRegistry = pluginRegistryService(input.db);
|
|
const plugins = await pluginRegistry.list();
|
|
const recoverablePluginKeys = new Set(input.recoverMissingWorker?.pluginKeys ?? []);
|
|
const readyPlugins = plugins.filter((plugin) => plugin.status === "ready");
|
|
const recoveryAttempts: Promise<boolean>[] = [];
|
|
|
|
for (const plugin of readyPlugins) {
|
|
const hasSandboxProviderDriver = plugin.manifestJson.environmentDrivers?.some(
|
|
(driver) => driver.kind === "sandbox_provider",
|
|
) ?? false;
|
|
const canRecover =
|
|
hasSandboxProviderDriver
|
|
&& !input.workerManager.isRunning(plugin.id)
|
|
&& recoverablePluginKeys.has(plugin.pluginKey)
|
|
&& !input.workerManager.getWorker(plugin.id);
|
|
if (!canRecover || !input.recoverMissingWorker) continue;
|
|
const timeoutMs =
|
|
input.recoverMissingWorker.timeoutMs ?? DEFAULT_READY_PLUGIN_WORKER_RECOVERY_TIMEOUT_MS;
|
|
recoveryAttempts.push(resolveWithTimeout(
|
|
input.recoverMissingWorker.startWorker({
|
|
id: plugin.id,
|
|
pluginKey: plugin.pluginKey,
|
|
}).catch(() => false),
|
|
timeoutMs,
|
|
false,
|
|
));
|
|
}
|
|
|
|
if (recoveryAttempts.length > 0) {
|
|
await Promise.all(recoveryAttempts);
|
|
}
|
|
|
|
const rows: ReadyPluginEnvironmentDriver[] = [];
|
|
for (const plugin of readyPlugins) {
|
|
if (!input.workerManager.isRunning(plugin.id)) {
|
|
continue;
|
|
}
|
|
// The plugin is running, so read the live worker's verified methods once per
|
|
// plugin. A provider advertises reusable leases only when its worker carries
|
|
// all reuse lifecycle methods; the declaration alone never grants them.
|
|
const workerMethods = new Set(input.workerManager.getWorker(plugin.id)?.supportedMethods ?? []);
|
|
const reusableLeaseMethodsVerified = REUSABLE_LEASE_WORKER_METHODS.every(
|
|
(method) => workerMethods.has(method),
|
|
);
|
|
rows.push(
|
|
...(plugin.manifestJson.environmentDrivers ?? [])
|
|
.filter((driver) => driver.kind === "sandbox_provider")
|
|
.map((driver) => ({
|
|
pluginId: plugin.id,
|
|
pluginKey: plugin.pluginKey,
|
|
driverKey: driver.driverKey,
|
|
displayName: driver.displayName,
|
|
description: driver.description,
|
|
configSchema: driver.configSchema,
|
|
supportsReusableLeases: driver.supportsReusableLeases,
|
|
sandboxCapabilities: driver.sandboxCapabilities,
|
|
reusableLeaseMethodsVerified,
|
|
supportsInteractiveSetup: driver.supportsInteractiveSetup,
|
|
interactiveSetupConnectionTypes: driver.interactiveSetupConnectionTypes,
|
|
supportsTemplateCapture: driver.supportsTemplateCapture,
|
|
templateRefKind: driver.templateRefKind,
|
|
templateConfigBinding: driver.templateConfigBinding,
|
|
supportsTemplateDelete: driver.supportsTemplateDelete,
|
|
supportsLoginPty: driver.supportsLoginPty,
|
|
})),
|
|
);
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
export async function validatePluginSandboxProviderConfig(input: {
|
|
db: Db;
|
|
workerManager: PluginWorkerManager;
|
|
provider: string;
|
|
config: Record<string, unknown>;
|
|
}): Promise<{
|
|
normalizedConfig: Record<string, unknown>;
|
|
pluginId: string;
|
|
pluginKey: string;
|
|
driver: PluginEnvironmentDriverDeclaration;
|
|
}> {
|
|
const resolved = await resolvePluginSandboxProviderDriverByKey({
|
|
db: input.db,
|
|
driverKey: input.provider,
|
|
workerManager: input.workerManager,
|
|
requireRunning: true,
|
|
});
|
|
if (!resolved) {
|
|
throw unprocessable(`Sandbox provider "${input.provider}" is not installed or its plugin worker is not running.`);
|
|
}
|
|
|
|
// Secret pickers submit `{ type: "secret_ref", secretId, version }` binding
|
|
// objects for `format: "secret-ref"` fields. Plugins only understand string
|
|
// config values, so canonicalize bindings to the bare secret id (the
|
|
// persisted shape) before the plugin validates.
|
|
const configSchema =
|
|
resolved.driver.configSchema && typeof resolved.driver.configSchema === "object" && !Array.isArray(resolved.driver.configSchema)
|
|
? resolved.driver.configSchema as Record<string, unknown>
|
|
: null;
|
|
let config = input.config;
|
|
for (const path of collectSecretRefPaths(configSchema)) {
|
|
const binding = parseSecretRefBindingObject(readConfigValueAtPath(config, path));
|
|
if (!binding) continue;
|
|
if (binding.version !== "latest") {
|
|
throw unprocessable(
|
|
`Secret binding at ${path} pins version ${binding.version}; sandbox provider secret references always resolve the latest version.`,
|
|
);
|
|
}
|
|
config = writeConfigValueAtPath(config, path, binding.secretId);
|
|
}
|
|
|
|
const result = await input.workerManager.call(resolved.plugin.id, "environmentValidateConfig", {
|
|
driverKey: input.provider,
|
|
config,
|
|
});
|
|
|
|
if (!result.ok) {
|
|
throw unprocessable(
|
|
result.errors?.[0] ?? `Sandbox provider "${input.provider}" rejected its config.`,
|
|
{
|
|
errors: result.errors ?? [],
|
|
warnings: result.warnings ?? [],
|
|
},
|
|
);
|
|
}
|
|
|
|
return {
|
|
normalizedConfig: result.normalizedConfig ?? config,
|
|
pluginId: resolved.plugin.id,
|
|
pluginKey: resolved.plugin.pluginKey,
|
|
driver: resolved.driver,
|
|
};
|
|
}
|
|
|
|
export async function validatePluginEnvironmentDriverConfig(input: {
|
|
db: Db;
|
|
workerManager: PluginWorkerManager;
|
|
config: PluginEnvironmentConfig;
|
|
}): Promise<PluginEnvironmentConfig> {
|
|
const { plugin } = await resolvePluginEnvironmentDriver(input);
|
|
const result = await input.workerManager.call(plugin.id, "environmentValidateConfig", {
|
|
driverKey: input.config.driverKey,
|
|
config: input.config.driverConfig,
|
|
});
|
|
|
|
if (!result.ok) {
|
|
throw unprocessable(
|
|
result.errors?.[0] ?? `Plugin environment driver "${pluginDriverProviderKey(input.config)}" rejected its config.`,
|
|
{
|
|
errors: result.errors ?? [],
|
|
warnings: result.warnings ?? [],
|
|
},
|
|
);
|
|
}
|
|
|
|
return {
|
|
...input.config,
|
|
driverConfig: result.normalizedConfig ?? input.config.driverConfig,
|
|
};
|
|
}
|
|
|
|
export async function probePluginEnvironmentDriver(input: {
|
|
db: Db;
|
|
workerManager: PluginWorkerManager;
|
|
companyId: string;
|
|
environmentId: string;
|
|
config: PluginEnvironmentConfig;
|
|
}): Promise<EnvironmentProbeResult> {
|
|
const { plugin } = await resolvePluginEnvironmentDriver(input);
|
|
const result = await input.workerManager.call(plugin.id, "environmentProbe", {
|
|
driverKey: input.config.driverKey,
|
|
companyId: input.companyId,
|
|
environmentId: input.environmentId,
|
|
config: input.config.driverConfig,
|
|
}, 120_000);
|
|
|
|
return {
|
|
ok: result.ok,
|
|
driver: "plugin",
|
|
summary: result.summary ?? `Plugin environment driver "${pluginDriverProviderKey(input.config)}" probe ${result.ok ? "passed" : "failed"}.`,
|
|
details: {
|
|
pluginKey: input.config.pluginKey,
|
|
driverKey: input.config.driverKey,
|
|
diagnostics: result.diagnostics ?? [],
|
|
metadata: result.metadata ?? {},
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function probePluginSandboxProviderDriver(input: {
|
|
db: Db;
|
|
workerManager: PluginWorkerManager;
|
|
companyId: string;
|
|
environmentId: string;
|
|
provider: string;
|
|
config: Record<string, unknown>;
|
|
}): Promise<EnvironmentProbeResult> {
|
|
const resolved = await resolvePluginEnvironmentDriverByKey({
|
|
db: input.db,
|
|
workerManager: input.workerManager,
|
|
driverKey: input.provider,
|
|
});
|
|
if (!resolved) {
|
|
return {
|
|
ok: false,
|
|
driver: "sandbox",
|
|
summary: `Sandbox provider "${input.provider}" is not installed or its plugin worker is not running.`,
|
|
details: {
|
|
provider: input.provider,
|
|
},
|
|
};
|
|
}
|
|
|
|
const { provider: _provider, ...driverConfig } = input.config;
|
|
const result = await input.workerManager.call(resolved.plugin.id, "environmentProbe", {
|
|
driverKey: input.provider,
|
|
companyId: input.companyId,
|
|
environmentId: input.environmentId,
|
|
config: driverConfig,
|
|
}, 120_000);
|
|
|
|
return {
|
|
ok: result.ok,
|
|
driver: "sandbox",
|
|
summary: result.summary ?? `Sandbox provider "${input.provider}" probe ${result.ok ? "passed" : "failed"}.`,
|
|
details: {
|
|
provider: input.provider,
|
|
pluginKey: resolved.plugin.pluginKey,
|
|
diagnostics: result.diagnostics ?? [],
|
|
metadata: result.metadata ?? {},
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function resumePluginEnvironmentLease(input: {
|
|
db: Db;
|
|
workerManager: PluginWorkerManager;
|
|
companyId: string;
|
|
environmentId: string;
|
|
issueId?: string | null;
|
|
config: PluginEnvironmentConfig;
|
|
providerLeaseId: string;
|
|
leaseMetadata?: Record<string, unknown>;
|
|
}): Promise<PluginEnvironmentLease> {
|
|
const { plugin } = await resolvePluginEnvironmentDriver(input);
|
|
return await input.workerManager.call(plugin.id, "environmentResumeLease", {
|
|
driverKey: input.config.driverKey,
|
|
companyId: input.companyId,
|
|
environmentId: input.environmentId,
|
|
issueId: input.issueId ?? null,
|
|
config: input.config.driverConfig,
|
|
providerLeaseId: input.providerLeaseId,
|
|
leaseMetadata: input.leaseMetadata,
|
|
});
|
|
}
|
|
|
|
export async function destroyPluginEnvironmentLease(input: {
|
|
db: Db;
|
|
workerManager: PluginWorkerManager;
|
|
companyId: string;
|
|
environmentId: string;
|
|
issueId?: string | null;
|
|
config: PluginEnvironmentConfig;
|
|
providerLeaseId: string | null;
|
|
leaseMetadata?: Record<string, unknown>;
|
|
}): Promise<void> {
|
|
const { plugin } = await resolvePluginEnvironmentDriver(input);
|
|
await input.workerManager.call(plugin.id, "environmentDestroyLease", {
|
|
driverKey: input.config.driverKey,
|
|
companyId: input.companyId,
|
|
environmentId: input.environmentId,
|
|
issueId: input.issueId ?? null,
|
|
config: input.config.driverConfig,
|
|
providerLeaseId: input.providerLeaseId,
|
|
leaseMetadata: input.leaseMetadata,
|
|
});
|
|
}
|
|
|
|
export async function realizePluginEnvironmentWorkspace(input: {
|
|
db: Db;
|
|
workerManager: PluginWorkerManager;
|
|
pluginId?: string | null;
|
|
params: PluginEnvironmentRealizeWorkspaceParams;
|
|
config: PluginEnvironmentConfig;
|
|
}): Promise<PluginEnvironmentRealizeWorkspaceResult> {
|
|
const { plugin } = input.pluginId
|
|
? { plugin: { id: input.pluginId } }
|
|
: await resolvePluginEnvironmentDriver({
|
|
db: input.db,
|
|
workerManager: input.workerManager,
|
|
config: input.config,
|
|
});
|
|
return await input.workerManager.call(plugin.id, "environmentRealizeWorkspace", input.params);
|
|
}
|
|
|
|
export async function executePluginEnvironmentCommand(input: {
|
|
db: Db;
|
|
workerManager: PluginWorkerManager;
|
|
pluginId?: string | null;
|
|
params: PluginEnvironmentExecuteParams;
|
|
config: PluginEnvironmentConfig;
|
|
}): Promise<PluginEnvironmentExecuteResult> {
|
|
const { plugin } = input.pluginId
|
|
? { plugin: { id: input.pluginId } }
|
|
: await resolvePluginEnvironmentDriver({
|
|
db: input.db,
|
|
workerManager: input.workerManager,
|
|
config: input.config,
|
|
});
|
|
return await input.workerManager.call(
|
|
plugin.id,
|
|
"environmentExecute",
|
|
input.params,
|
|
resolvePluginExecuteRpcTimeoutMs({
|
|
requestedTimeoutMs: input.params.timeoutMs,
|
|
config: input.config.driverConfig,
|
|
}),
|
|
);
|
|
}
|
|
|
|
export async function startPluginEnvironmentInteractiveSetup(input: {
|
|
db: Db;
|
|
workerManager: PluginWorkerManager;
|
|
config: PluginEnvironmentConfig;
|
|
params: Omit<PluginEnvironmentStartInteractiveSetupParams, "driverKey" | "config">;
|
|
}): Promise<PluginEnvironmentInteractiveSetupSession> {
|
|
const { plugin } = await resolvePluginEnvironmentDriver({
|
|
db: input.db,
|
|
workerManager: input.workerManager,
|
|
config: input.config,
|
|
});
|
|
return await input.workerManager.call(plugin.id, "environmentStartInteractiveSetup", {
|
|
...input.params,
|
|
driverKey: input.config.driverKey,
|
|
config: input.config.driverConfig,
|
|
}, resolvePluginExecuteRpcTimeoutMs({
|
|
requestedTimeoutMs: undefined,
|
|
config: input.config.driverConfig,
|
|
}));
|
|
}
|
|
|
|
export async function getPluginEnvironmentInteractiveSetup(input: {
|
|
db: Db;
|
|
workerManager: PluginWorkerManager;
|
|
config: PluginEnvironmentConfig;
|
|
params: Omit<PluginEnvironmentGetInteractiveSetupParams, "driverKey" | "config">;
|
|
}): Promise<PluginEnvironmentInteractiveSetupSession> {
|
|
const { plugin } = await resolvePluginEnvironmentDriver({
|
|
db: input.db,
|
|
workerManager: input.workerManager,
|
|
config: input.config,
|
|
});
|
|
return await input.workerManager.call(plugin.id, "environmentGetInteractiveSetup", {
|
|
...input.params,
|
|
driverKey: input.config.driverKey,
|
|
config: input.config.driverConfig,
|
|
}, resolvePluginExecuteRpcTimeoutMs({
|
|
requestedTimeoutMs: undefined,
|
|
config: input.config.driverConfig,
|
|
}));
|
|
}
|
|
|
|
export async function capturePluginEnvironmentTemplate(input: {
|
|
db: Db;
|
|
workerManager: PluginWorkerManager;
|
|
config: PluginEnvironmentConfig;
|
|
params: Omit<PluginEnvironmentCaptureTemplateParams, "driverKey" | "config">;
|
|
}): Promise<PluginEnvironmentCaptureTemplateResult> {
|
|
const { plugin } = await resolvePluginEnvironmentDriver({
|
|
db: input.db,
|
|
workerManager: input.workerManager,
|
|
config: input.config,
|
|
});
|
|
return await input.workerManager.call(plugin.id, "environmentCaptureTemplate", {
|
|
...input.params,
|
|
driverKey: input.config.driverKey,
|
|
config: input.config.driverConfig,
|
|
}, resolvePluginExecuteRpcTimeoutMs({
|
|
requestedTimeoutMs: input.params.timeoutMs ?? undefined,
|
|
config: input.config.driverConfig,
|
|
}));
|
|
}
|
|
|
|
export async function cancelPluginEnvironmentInteractiveSetup(input: {
|
|
db: Db;
|
|
workerManager: PluginWorkerManager;
|
|
config: PluginEnvironmentConfig;
|
|
params: Omit<PluginEnvironmentCancelInteractiveSetupParams, "driverKey" | "config">;
|
|
}): Promise<PluginEnvironmentCancelInteractiveSetupResult> {
|
|
const { plugin } = await resolvePluginEnvironmentDriver({
|
|
db: input.db,
|
|
workerManager: input.workerManager,
|
|
config: input.config,
|
|
});
|
|
return await input.workerManager.call(plugin.id, "environmentCancelInteractiveSetup", {
|
|
...input.params,
|
|
driverKey: input.config.driverKey,
|
|
config: input.config.driverConfig,
|
|
}, resolvePluginExecuteRpcTimeoutMs({
|
|
requestedTimeoutMs: undefined,
|
|
config: input.config.driverConfig,
|
|
}));
|
|
}
|
|
|
|
export async function deletePluginEnvironmentTemplate(input: {
|
|
db: Db;
|
|
workerManager: PluginWorkerManager;
|
|
config: PluginEnvironmentConfig;
|
|
params: Omit<PluginEnvironmentDeleteTemplateParams, "driverKey" | "config">;
|
|
}): Promise<PluginEnvironmentDeleteTemplateResult> {
|
|
const { plugin } = await resolvePluginEnvironmentDriver({
|
|
db: input.db,
|
|
workerManager: input.workerManager,
|
|
config: input.config,
|
|
});
|
|
return await input.workerManager.call(plugin.id, "environmentDeleteTemplate", {
|
|
...input.params,
|
|
driverKey: input.config.driverKey,
|
|
config: input.config.driverConfig,
|
|
}, resolvePluginExecuteRpcTimeoutMs({
|
|
requestedTimeoutMs: undefined,
|
|
config: input.config.driverConfig,
|
|
}));
|
|
}
|
|
|
|
const RPC_OVERHEAD_BUFFER_MS = 30_000;
|
|
|
|
export function resolvePluginExecuteRpcTimeoutMs(input: {
|
|
requestedTimeoutMs?: number;
|
|
config: Record<string, unknown>;
|
|
}): number | undefined {
|
|
let baseMs: number | undefined;
|
|
if (Number.isFinite(input.requestedTimeoutMs) && (input.requestedTimeoutMs ?? 0) > 0) {
|
|
baseMs = Math.trunc(input.requestedTimeoutMs!);
|
|
} else {
|
|
const configTimeoutMs = typeof input.config.timeoutMs === "number" ? input.config.timeoutMs : null;
|
|
if (configTimeoutMs && Number.isFinite(configTimeoutMs) && configTimeoutMs > 0) {
|
|
baseMs = Math.trunc(configTimeoutMs);
|
|
}
|
|
}
|
|
return baseMs != null ? baseMs + RPC_OVERHEAD_BUFFER_MS : undefined;
|
|
}
|