mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A managed instance gets its instance-level environments from the `PAPERCLIP_MANAGED_CONFIG` document > - #19490 added the plugin environment rows, their marker index and write floor, but nothing creates them yet > - The managed config still accepts only one sandbox entry, so a managed instance cannot get a plugin-provided environment without manual setup > - This pull request lets the managed config declare plugin entries beside the sandbox entry, and provisions one locked row for each at boot > - The benefit is that a managed instance gets its plugin environments with no manual setup, and the rows recover by themselves when their plugin restarts ## Linked Issues or Issue Description Refs #19490 (the plugin environment rows this pull request provisions) **Subsystem affected** Server environments: the managed-config parser, the managed environment bootstrap, and the environment routes' write floor. **Problem or motivation** A managed instance cannot get a plugin environment from its managed config. The `environments` section accepts one entry, and that entry always provisions the managed sandbox. Company settings cannot create a plugin environment either. So each managed instance that needs one requires manual setup. **Proposed solution** Let an `environments` entry declare `driver: "plugin"`. Boot provisions one locked row for each plugin entry through the row layer from #19490. A later pull request adds a one-time instance default for one plugin entry, and advertises the new managed-config capability. **Alternatives considered** Manual setup on each instance, and a new control-plane endpoint. Both need a step outside the deploy. The managed config already delivers the sandbox entry. **Roadmap alignment** This extends the existing managed-config `environments` section, which already provisions the managed sandbox. It adds no new product surface. ## What Changed - `server/src/services/managed-config.ts`: an `environments` entry may set `driver` to `"sandbox"` or `"plugin"`. An entry without `driver` is still the sandbox entry, so today's documents parse unchanged. - A plugin entry has `name`, optional `description`, `pluginKey` (the plugin's manifest id), `driverKey` and optional `driverConfig`. - At most one sandbox entry. Names are unique across the section, and `pluginKey`–`driverKey` pairs are unique. Secret-like keys in `driverConfig` refuse startup, as for sandbox `config`. An unknown key or driver still refuses startup, and that includes `instanceDefault` until the follow-up pull request. - Plugin entries parse into a new `pluginEnvironments` list. The `environments` list keeps only the sandbox entry, so its consumers do not change. - `MANAGED_ENVIRONMENT_DRIVERS` lists the accepted drivers. - `server/src/services/managed-environments.ts` (`applyManagedEnvironments`): - Boot refuses to start unless an auto-installed catalog entry installs each plugin entry's `pluginKey`. `plugins.autoInstall` holds catalog keys, not manifest ids, so the parser cannot check this. Boot uses the manifest ids `createApp` already resolves from the bundled and distribution catalogs (`app.locals.managedBundledPluginKeys`, passed in by `server/src/index.ts`). Without that list it resolves the catalog itself. - Plugin entries run after the sandbox entry and reuse its readiness handling. Boot awaits `pluginsReady`, then requires the plugin `ready`, declaring the entry's driver, and running a worker. Otherwise boot archives the row and re-ensures it on the worker's `ready`. - Recovery re-ensures on every worker `ready` until one ensure succeeds or the plugin rejects the config (HTTP 422). - If the ensure fails because the worker died, boot archives the row and waits for `ready`. If the worker is still running (for example the call timed out), boot keeps the row as it is, retries once at once and on the next `ready`, and otherwise at the next boot. No timer is added. - Removing an entry never archives its row. - A plugin-only document does not conflict with `PAPERCLIP_EXECUTION_MODE`, because plugin rows do not use the sandbox slot. - Plugin entries never change the instance default. The sandbox stamp still fills only an unset or local default, so it never replaces a plugin default. - `server/src/routes/environments.ts`: `isPlatformSlotEnvironment` treats a marked plugin row as a live slot while the document declares its entry. So the marker-clear escape hatch is refused then, and allowed again after the entry leaves the document. - `doc/DEVELOPING.md`: new "Managed environments" section. ## Verification - `cd server && npx vitest run src/__tests__/managed-config.test.ts`: old documents parse unchanged. Sandbox plus plugin parses. Each of these refuses startup: two sandbox entries, `driver: null`, duplicate names, a duplicate pair, a secret-like key, an unknown key (including `instanceDefault`), an unknown driver, a malformed `driverKey` or `driverConfig`. - `npx vitest run src/services/managed-environments-plugin-entries.test.ts` (the sandbox-entry tests stay in `managed-environments.test.ts`; shared fixtures are in `managed-environments.test-support.ts`): - A plugin entry is ensured with the validator's worker manager, after the sandbox entry. - Boot refuses a plugin entry whose `pluginKey` no auto-installed catalog entry installs, both from the passed list and from its own catalog lookup. - A plugin that is down archives the row, and worker recovery re-ensures it. A missing plugin, a plugin without the driver, or a worker manager without `call` counts as unavailable. - Recovery stays armed after a failed attempt and stops after one success or a permanent config rejection (422). A `ready` during an attempt queues one more. - A worker that dies during the boot ensure archives the row and arms recovery. A failure while the worker keeps running leaves the row as it is and retries. - Removing an entry does not archive. Plugin entries and the sandbox stamp never change a plugin default. - An ensure failure is counted, not thrown. - `npx vitest run src/__tests__/managed-plugin-environments.test.ts` (embedded Postgres, real `applyManagedEnvironments`): the first boot creates the row and the second changes nothing. A plugin that is down archives the row, and recovery brings it back. Removing the entry does not archive the row. The instance default stays unset. The row tests from #19490 still pass. - `npx vitest run src/__tests__/environment-routes.test.ts`: the marker clear is refused while the document declares the entry, and allowed after it leaves. - `npx vitest run src/__tests__/instance-settings-service.test.ts src/__tests__/instance-settings-cloud-defaults.test.ts`: pass with the new `pluginEnvironments` field. - `pnpm --filter @paperclipai/server typecheck` passes. - I did not run the full `pnpm test:run`, `pnpm -r typecheck` or `pnpm build`. CI covers them. ## Risks - No schema change. - A managed-config document with a plugin entry now provisions a row at boot. A document without one behaves as before. - Adoption renames an unmarked row for the same plugin and driver to the declared name and resets its driver config to the declared one. The row keeps its id, agents and other metadata, and it is then locked like the managed sandbox. - Boot now refuses to start when a plugin entry names a plugin that no auto-installed catalog entry provides. This is the same fail-closed posture as an unknown sandbox provider. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code with tool use (file edits, shell, test runs). ## 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 - [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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
678 lines
30 KiB
TypeScript
678 lines
30 KiB
TypeScript
/**
|
|
* Managed-environment bootstrap (harness → app contract).
|
|
*
|
|
* Managed-cloud instances may declare sandbox environments in the
|
|
* `environments` section of `PAPERCLIP_MANAGED_CONFIG` (parsed fail-closed in
|
|
* `managed-config.ts`). On boot, each declared environment is idempotently
|
|
* ensured as the instance-level Paperclip-managed sandbox row via the
|
|
* provider-agnostic `ensureManagedSandboxEnvironment` — the control plane
|
|
* provisions, tenants use, for any bundled sandbox provider plugin.
|
|
*
|
|
* The failure posture mirrors bundled plugin provisioning
|
|
* (`bundled-plugins.ts`), deliberately split:
|
|
*
|
|
* 1. **Validation fails closed at parse time** (`managed-config.ts`): a
|
|
* malformed section refuses startup with a precise error.
|
|
* 2. **The DB ensure step is fail-safe per entry**: an ensure failure is
|
|
* logged and boot continues degraded (environment unavailable) rather
|
|
* than crash-looping a fleet.
|
|
*
|
|
* Ensuring is additionally synchronized with provider availability: the
|
|
* caller's `pluginsReady` promise (the bundled-plugin install/load pass) is
|
|
* awaited first, and an entry whose provider plugin is not installed, `ready`,
|
|
* AND running a live worker (`workerManager.isRunning`; a `ready` record whose
|
|
* activation failed has no worker and cannot serve leases) afterwards is
|
|
* skipped (counted failed) instead of being written as an active row —
|
|
* otherwise the heartbeat would resume queued runs against an environment
|
|
* whose lease acquisition cannot succeed yet. When such an entry was
|
|
* provisioned by an earlier boot, its still-active row is archived
|
|
* (`archiveManagedSandboxEnvironment`) for the same reason. Re-activation
|
|
* happens on the next healthy boot's ensure, or earlier: when the plugin
|
|
* record is `ready` and only the worker is down (a crash in restart-backoff),
|
|
* a one-shot `ready` listener on the worker handle re-runs the ensure as soon
|
|
* as the worker manager respawns the worker, so a transient crash does not
|
|
* leave the environment archived until someone restarts the server.
|
|
*
|
|
* Removing an entry from the document stops future refreshes but never
|
|
* deletes or archives the row — there is intentionally no unprovision path
|
|
* here, matching `plugins.autoInstall` semantics (leases may still reference
|
|
* the row; withdrawal is an explicit operator action). Archiving above is
|
|
* scoped to a declared-but-unavailable provider, not to document removal.
|
|
*
|
|
* Provider credentials are never part of the declared config: every bundled
|
|
* sandbox provider falls back to its documented process environment variable
|
|
* (e.g. `DAYTONA_API_KEY`) when `config` omits the key, so the deployment
|
|
* delivers secrets as env vars and the managed document stays secret-free.
|
|
*
|
|
* `driver: "plugin"` entries take the same readiness path after the sandbox
|
|
* entry: await `pluginsReady`, require the plugin `ready`, declaring the
|
|
* entry's driver, and running a worker; else archive the entry's row and
|
|
* re-ensure it on the worker's `ready`. Each entry owns one row (see
|
|
* `managed-plugin-environments.ts`). Plugin entries never touch the instance
|
|
* default.
|
|
*/
|
|
|
|
import type { Db } from "@paperclipai/db";
|
|
import { HttpError } from "../errors.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
import { environmentService } from "./environments.js";
|
|
import type { ManagedSandboxEnvironmentReconcileAction } from "./environments.js";
|
|
import { instanceSettingsService } from "./instance-settings.js";
|
|
import type { ManagedInstanceConfig } from "./managed-config.js";
|
|
import type { ManagedResourceStockStatus } from "./managed-resource-drift.js";
|
|
import { parseExecutionPolicyBootstrapEnv } from "./execution-policy-bootstrap.js";
|
|
import { resolvePluginSandboxProviderDriverByKey } from "./plugin-environment-driver.js";
|
|
import { pluginRegistryService } from "./plugin-registry.js";
|
|
import { resolveBundledCatalogRoot, resolveBundledPluginInstalls } from "./bundled-plugins.js";
|
|
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
|
import type { ManagedPluginEnvironmentReconcileAction } from "./managed-plugin-environments.js";
|
|
|
|
export interface ApplyManagedEnvironmentsOptions {
|
|
env?: Record<string, string | undefined>;
|
|
/**
|
|
* Resolves when the bundled-plugin startup pass (install + load; the
|
|
* `ensureBundledPlugins` chain in `createApp`) has finished. Awaited before
|
|
* any environment is ensured so an active row never precedes its provider
|
|
* driver. The promise never rejects (the chain catches internally).
|
|
*/
|
|
pluginsReady?: Promise<unknown>;
|
|
/**
|
|
* The app's plugin worker manager, consulted per entry so an environment is
|
|
* only ensured while its provider plugin has a live worker (a `ready`
|
|
* registry record whose activation failed cannot serve leases). Fails
|
|
* closed: without a worker manager every entry is treated as unavailable.
|
|
*
|
|
* `getWorker` is `PluginWorkerManager.getWorker` narrowed to the `ready`
|
|
* subscription used for post-archive recovery: a worker in crash-restart
|
|
* backoff re-emits `ready` when the manager respawns it, and the listener
|
|
* re-ensures the archived environment (see `applyManagedEnvironments`).
|
|
*
|
|
* Plugin entries also need `call`, to run the plugin's own config
|
|
* validation; without it every plugin entry is treated as unavailable.
|
|
*/
|
|
workerManager?: Pick<PluginWorkerManager, "isRunning"> & Partial<Pick<PluginWorkerManager, "call">> & {
|
|
getWorker(pluginId: string):
|
|
| {
|
|
on(event: "ready", listener: (payload: { pluginId: string }) => void): void;
|
|
off(event: "ready", listener: (payload: { pluginId: string }) => void): void;
|
|
}
|
|
| undefined;
|
|
};
|
|
/** Test seam: overrides the environment service built from `db`. */
|
|
environments?: Pick<
|
|
ReturnType<typeof environmentService>,
|
|
| "ensureManagedSandboxEnvironment"
|
|
| "archiveManagedSandboxEnvironment"
|
|
| "getById"
|
|
| "update"
|
|
| "findManagedSandboxEnvironment"
|
|
| "ensureManagedPluginEnvironment"
|
|
| "archiveManagedPluginEnvironment"
|
|
>;
|
|
/** Test seam: overrides the instance-settings service built from `db`. */
|
|
instanceSettings?: Pick<ReturnType<typeof instanceSettingsService>, "get" | "update">;
|
|
/** Test seam: overrides the sandbox-provider plugin driver lookup. */
|
|
resolveSandboxProviderDriver?: (input: {
|
|
db: Db;
|
|
driverKey: string;
|
|
}) => Promise<{ plugin: { id: string; pluginKey: string; status: string } } | null>;
|
|
/**
|
|
* Test seam: overrides the lookup of a plugin entry's plugin. Returns null
|
|
* when the plugin is not installed; `declaresDriver` is false when its
|
|
* manifest does not declare the entry's environment driver.
|
|
*/
|
|
resolveEnvironmentPlugin?: (input: {
|
|
db: Db;
|
|
pluginKey: string;
|
|
driverKey: string;
|
|
}) => Promise<{ plugin: { id: string; pluginKey: string; status: string }; declaresDriver: boolean } | null>;
|
|
/**
|
|
* Manifest ids (`pluginKey`) that the `plugins.autoInstall` catalog keys
|
|
* resolve to: `app.locals.managedBundledPluginKeys`, from
|
|
* `resolveBundledPluginInstalls` over the bundled and distribution
|
|
* catalogs. A plugin entry whose `pluginKey` is not in this list refuses
|
|
* startup. When omitted, the list is resolved here the same way.
|
|
*/
|
|
autoInstalledPluginKeys?: readonly string[];
|
|
}
|
|
|
|
export interface ManagedPluginEnvironmentReconciliationOutcome {
|
|
environmentId: string;
|
|
name: string;
|
|
pluginKey: string;
|
|
driverKey: string;
|
|
action: ManagedPluginEnvironmentReconcileAction;
|
|
}
|
|
|
|
export interface ManagedEnvironmentReconciliationOutcome {
|
|
environmentId: string;
|
|
name: string;
|
|
provider: string;
|
|
action: ManagedSandboxEnvironmentReconcileAction;
|
|
stockStatus: ManagedResourceStockStatus;
|
|
updateAvailable: boolean;
|
|
}
|
|
|
|
export interface ApplyManagedEnvironmentsResult {
|
|
ensured: number;
|
|
failed: number;
|
|
added: number;
|
|
updated: number;
|
|
unchanged: number;
|
|
skipped: number;
|
|
/** Managed reconciliation never removes rows in preserve-only mode. */
|
|
removed: number;
|
|
/** Preserve-only reconciliation never creates replacement backups. */
|
|
backedUp: number;
|
|
outcomes: ManagedEnvironmentReconciliationOutcome[];
|
|
pluginOutcomes: ManagedPluginEnvironmentReconciliationOutcome[];
|
|
}
|
|
|
|
/**
|
|
* The plugin refused the entry's config (`HttpError` 422 from
|
|
* `normalizeEnvironmentConfig` or `validatePluginEnvironmentDriverConfig`).
|
|
* Retrying cannot help until the config changes. Every other failure, such
|
|
* as a worker that crashed mid-call, is treated as transient.
|
|
*/
|
|
function isPermanentConfigRejection(err: unknown): boolean {
|
|
return err instanceof HttpError && err.status === 422;
|
|
}
|
|
|
|
async function resolveEnvironmentPluginByKey(input: { db: Db; pluginKey: string; driverKey: string }) {
|
|
const plugin = await pluginRegistryService(input.db).getByKey(input.pluginKey);
|
|
if (!plugin) return null;
|
|
return {
|
|
plugin: { id: plugin.id, pluginKey: plugin.pluginKey, status: plugin.status },
|
|
declaresDriver: plugin.manifestJson.environmentDrivers?.some(
|
|
(driver) => driver.driverKey === input.driverKey && driver.kind !== "sandbox_provider",
|
|
) ?? false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Ensure every environment declared in the managed-config document. Returns
|
|
* null when there is nothing to do (self-hosted, or no `environments`
|
|
* section); otherwise the ensured/failed counts. Idempotent; safe to call on
|
|
* every boot.
|
|
*/
|
|
export async function applyManagedEnvironments(
|
|
db: Db,
|
|
managedConfig: ManagedInstanceConfig | null,
|
|
opts: ApplyManagedEnvironmentsOptions = {},
|
|
): Promise<ApplyManagedEnvironmentsResult | null> {
|
|
if (!managedConfig) return null;
|
|
|
|
const settings = opts.instanceSettings ?? instanceSettingsService(db);
|
|
const environments = opts.environments ?? environmentService(db);
|
|
const managedSandboxOnlyDeclared = managedConfig.features.enableManagedSandboxOnly === true;
|
|
|
|
// Mode-off default cleanup runs BEFORE any ensure, and regardless of
|
|
// whether the document still declares environments or the provider is
|
|
// available: a reconciliation-stamped default must not outlive the mode
|
|
// through the paths that skip per-entry reconciliation entirely — the
|
|
// declaration was removed, the provider is down, or the row was
|
|
// archived. Only a default that points at the managed row AND carries
|
|
// the `managedDefaultStamped` marker reverts; tenant-chosen defaults
|
|
// (no marker) are untouched.
|
|
if (!managedSandboxOnlyDeclared) {
|
|
try {
|
|
const managedRow = await environments.findManagedSandboxEnvironment(undefined, {
|
|
includeArchived: true,
|
|
});
|
|
if (
|
|
managedRow &&
|
|
managedRow.metadata?.managedDefaultStamped === true &&
|
|
((await settings.get()).defaultEnvironmentId ?? null) === managedRow.id
|
|
) {
|
|
await settings.update({ defaultEnvironmentId: null });
|
|
const { managedDefaultStamped: _cleared, ...remainingMetadata } = managedRow.metadata ?? {};
|
|
await environments.update(managedRow.id, { metadata: remainingMetadata });
|
|
logger.info(
|
|
{ environmentId: managedRow.id },
|
|
"instance default environment reverted from the managed sandbox environment (managed-sandbox-only is not declared)",
|
|
);
|
|
}
|
|
} catch (err) {
|
|
logger.error(
|
|
{ err },
|
|
"failed to revert the stamped managed sandbox instance default (degraded: stale default until the next boot)",
|
|
);
|
|
}
|
|
}
|
|
|
|
const pluginSpecs = managedConfig.pluginEnvironments;
|
|
if (managedConfig.environments.length === 0 && pluginSpecs.length === 0) return null;
|
|
|
|
// The forced-execution-mode bootstrap (`PAPERCLIP_EXECUTION_MODE=kubernetes`)
|
|
// and this one both own the single Paperclip-managed sandbox row
|
|
// (`environments_managed_sandbox_idx`). Configuring both is contradictory;
|
|
// refuse startup rather than let bootstrap ordering pick a winner. Plugin
|
|
// entries do not touch that slot.
|
|
const env = opts.env ?? process.env;
|
|
if (managedConfig.environments.length > 0 && parseExecutionPolicyBootstrapEnv(env)) {
|
|
throw new Error(
|
|
`PAPERCLIP_EXECUTION_MODE and the PAPERCLIP_MANAGED_CONFIG "environments" section are mutually exclusive: both manage the single instance sandbox environment`,
|
|
);
|
|
}
|
|
|
|
// Coherence, fail closed like the parser: a plugin entry names its plugin by
|
|
// manifest id, and only `plugins.autoInstall` provisions plugins on a
|
|
// managed instance, so some auto-installed catalog entry must install it.
|
|
if (pluginSpecs.length > 0) {
|
|
const autoInstalledPluginKeys = opts.autoInstalledPluginKeys ?? resolveBundledPluginInstalls(
|
|
managedConfig.plugins.autoInstall,
|
|
{ catalogRoot: resolveBundledCatalogRoot(env), env, enforceCatalogRoot: true },
|
|
).map((install) => install.pluginKey);
|
|
for (const spec of pluginSpecs) {
|
|
if (!autoInstalledPluginKeys.includes(spec.pluginKey)) {
|
|
throw new Error(
|
|
`PAPERCLIP_MANAGED_CONFIG "environments" entry "${spec.name}" uses plugin "${spec.pluginKey}", which no "plugins.autoInstall" catalog entry installs; a managed plugin environment requires its plugin to be provisioned`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// The heartbeat resumes queued runs right after this bootstrap step, and
|
|
// lease acquisition fails hard on a provider whose plugin is missing or not
|
|
// ready. Wait for the bundled-plugin startup pass to finish, then refuse to
|
|
// ensure (and in particular to re-activate) a row whose provider driver did
|
|
// not come up — a degraded boot without the row beats an active environment
|
|
// that fails every lease until the plugin recovers.
|
|
await opts.pluginsReady;
|
|
|
|
const resolveDriver = opts.resolveSandboxProviderDriver ?? resolvePluginSandboxProviderDriverByKey;
|
|
|
|
/**
|
|
* Point the instance default at the managed sandbox row when no
|
|
* deliberate choice stands in the way: an unset default, a default on
|
|
* the (hidden-under-this-mode) local row, or a dangling reference all
|
|
* move to the managed environment, so pickers and run selection agree
|
|
* that "the default" is the platform sandbox. A tenant-chosen custom
|
|
* environment (ssh, their own sandbox) is never overridden.
|
|
*
|
|
* Gated symmetrically on the document declaring
|
|
* `enableManagedSandboxOnly`:
|
|
*
|
|
* - Declared: stamp the managed row as the default, so pickers and run
|
|
* selection agree the platform sandbox is "the default". The stamp is
|
|
* recorded as `managedDefaultStamped` in the row's platform-owned
|
|
* metadata (which boot reconciliation preserves and the client write
|
|
* floor protects), so a stamped default is distinguishable from one
|
|
* the tenant chose deliberately.
|
|
* - Not declared: local execution is a legitimate default again, so a
|
|
* default THIS reconciliation stamped earlier (points at the managed
|
|
* row AND carries the stamp marker) reverts to unset — otherwise
|
|
* turning the mode off would keep routing default-following runs
|
|
* into the sandbox off a stale stamp. A default the tenant selected
|
|
* themselves — the managed row without the marker, or any custom
|
|
* environment — is untouched in both directions.
|
|
*
|
|
* Idempotent and best-effort — a failure degrades to the run-time
|
|
* policy, which refuses local under managed-sandbox-only regardless.
|
|
*/
|
|
const ensureManagedInstanceDefault = async (managedEnvironment: {
|
|
id: string;
|
|
metadata: Record<string, unknown> | null;
|
|
}): Promise<void> => {
|
|
if (!managedSandboxOnlyDeclared) return;
|
|
try {
|
|
const current = (await settings.get()).defaultEnvironmentId ?? null;
|
|
if (current === managedEnvironment.id) return;
|
|
if (current !== null) {
|
|
const currentEnvironment = await environments.getById(current);
|
|
if (currentEnvironment && currentEnvironment.driver !== "local") return;
|
|
}
|
|
// Marker first: a crash between the two writes must never leave a
|
|
// stamped default that the revert path cannot attribute.
|
|
await environments.update(managedEnvironment.id, {
|
|
metadata: { ...(managedEnvironment.metadata ?? {}), managedDefaultStamped: true },
|
|
});
|
|
await settings.update({ defaultEnvironmentId: managedEnvironment.id });
|
|
logger.info(
|
|
{ environmentId: managedEnvironment.id, previousDefaultEnvironmentId: current },
|
|
"instance default environment set to the managed sandbox environment",
|
|
);
|
|
} catch (err) {
|
|
logger.error(
|
|
{ err, environmentId: managedEnvironment.id },
|
|
"failed to reconcile the instance default environment with the managed sandbox environment",
|
|
);
|
|
}
|
|
};
|
|
|
|
// Recovery path for a `ready` plugin whose worker was down at check time:
|
|
// that shape is usually a crash in restart-backoff, and the manager's
|
|
// respawn re-emits `ready` on the same handle. A one-shot listener re-runs
|
|
// the idempotent ensure so the recovered provider becomes selectable again
|
|
// without waiting for the next boot (this pass has already archived the
|
|
// row). The post-subscribe `isRunning` re-check closes the race where the
|
|
// worker recovered between the gate check and the subscription. No handle
|
|
// means no respawn is coming (activation never started a worker), so the
|
|
// degraded-until-next-boot posture stands.
|
|
const scheduleRecoveryReactivation = (pluginId: string, reactivateEntry: () => Promise<void>): void => {
|
|
const manager = opts.workerManager;
|
|
const handle = manager?.getWorker(pluginId);
|
|
if (!handle) return;
|
|
let reactivated = false;
|
|
const reactivate = (): void => {
|
|
if (reactivated) return;
|
|
reactivated = true;
|
|
handle.off("ready", reactivate);
|
|
void reactivateEntry();
|
|
};
|
|
handle.on("ready", reactivate);
|
|
if (manager?.isRunning(pluginId)) reactivate();
|
|
};
|
|
|
|
const reactivateSandboxEntry = async (spec: ManagedInstanceConfig["environments"][number]): Promise<void> => {
|
|
try {
|
|
const result = await environments.ensureManagedSandboxEnvironment({
|
|
name: spec.name,
|
|
description: spec.description,
|
|
provider: spec.provider,
|
|
config: { ...spec.config },
|
|
stockVersion: managedConfig.catalogVersion,
|
|
platformFullyManaged: true,
|
|
});
|
|
logger.info(
|
|
{
|
|
environmentId: result.environment.id,
|
|
name: spec.name,
|
|
provider: spec.provider,
|
|
action: result.action,
|
|
stockStatus: result.stockStatus,
|
|
updateAvailable: result.updateAvailable,
|
|
},
|
|
"managed sandbox environment reactivated after provider worker recovery",
|
|
);
|
|
} catch (err) {
|
|
logger.error(
|
|
{ err, name: spec.name, provider: spec.provider },
|
|
"failed to reactivate managed sandbox environment after provider worker recovery (degraded: environment unavailable until next boot)",
|
|
);
|
|
}
|
|
};
|
|
|
|
let ensured = 0;
|
|
let failed = 0;
|
|
let added = 0;
|
|
let updated = 0;
|
|
let unchanged = 0;
|
|
let skipped = 0;
|
|
const outcomes: ManagedEnvironmentReconciliationOutcome[] = [];
|
|
for (const spec of managedConfig.environments) {
|
|
try {
|
|
const resolved = await resolveDriver({ db, driverKey: spec.provider });
|
|
// `ready` in the registry is necessary but not sufficient: activation
|
|
// can fail after install leaves the record `ready`, in which case no
|
|
// worker is running and the driver cannot serve leases.
|
|
const workerRunning =
|
|
resolved != null && (opts.workerManager?.isRunning(resolved.plugin.id) ?? false);
|
|
if (!resolved || resolved.plugin.status !== "ready" || !workerRunning) {
|
|
failed += 1;
|
|
// A row provisioned by an earlier boot must not stay active either —
|
|
// archive it so run scheduling stops selecting it. Best-effort: an
|
|
// archive failure is logged, and the entry is already counted failed.
|
|
const archived = await environments
|
|
.archiveManagedSandboxEnvironment({ provider: spec.provider })
|
|
.catch((archiveErr: unknown) => {
|
|
logger.error(
|
|
{ err: archiveErr, name: spec.name, provider: spec.provider },
|
|
"failed to archive the managed sandbox environment of an unavailable provider",
|
|
);
|
|
return null;
|
|
});
|
|
logger.error(
|
|
{
|
|
name: spec.name,
|
|
provider: spec.provider,
|
|
pluginKey: resolved?.plugin.pluginKey ?? null,
|
|
pluginStatus: resolved?.plugin.status ?? null,
|
|
workerRunning,
|
|
archivedEnvironmentId: archived?.id ?? null,
|
|
},
|
|
"managed sandbox environment provider plugin is not installed, ready, and running; skipping ensure and archiving any previously provisioned row (degraded: environment unavailable)",
|
|
);
|
|
// Only a `ready` record can recover without another boot: the worker
|
|
// manager restarts crashed workers, but nothing (re)installs a missing
|
|
// or non-ready plugin at runtime.
|
|
if (resolved && resolved.plugin.status === "ready") {
|
|
scheduleRecoveryReactivation(resolved.plugin.id, () => reactivateSandboxEntry(spec));
|
|
}
|
|
continue;
|
|
}
|
|
const reconciliation = await environments.ensureManagedSandboxEnvironment({
|
|
name: spec.name,
|
|
description: spec.description,
|
|
provider: spec.provider,
|
|
config: { ...spec.config },
|
|
stockVersion: managedConfig.catalogVersion,
|
|
platformFullyManaged: true,
|
|
});
|
|
if (reconciliation.action === "skipped") skipped += 1;
|
|
else {
|
|
ensured += 1;
|
|
if (reconciliation.action === "added") added += 1;
|
|
else if (reconciliation.action === "updated") updated += 1;
|
|
else unchanged += 1;
|
|
}
|
|
outcomes.push({
|
|
environmentId: reconciliation.environment.id,
|
|
name: reconciliation.environment.name,
|
|
provider: spec.provider,
|
|
action: reconciliation.action,
|
|
stockStatus: reconciliation.stockStatus,
|
|
updateAvailable: reconciliation.updateAvailable,
|
|
});
|
|
await ensureManagedInstanceDefault(reconciliation.environment);
|
|
const logContext = {
|
|
environmentId: reconciliation.environment.id,
|
|
name: reconciliation.environment.name,
|
|
provider: spec.provider,
|
|
action: reconciliation.action,
|
|
stockStatus: reconciliation.stockStatus,
|
|
updateAvailable: reconciliation.updateAvailable,
|
|
};
|
|
if (reconciliation.action === "skipped") {
|
|
logger.warn(
|
|
logContext,
|
|
"managed sandbox environment has operator modifications; preserving row and skipping stock update",
|
|
);
|
|
} else {
|
|
logger.info(logContext, "managed sandbox environment reconciled");
|
|
}
|
|
} catch (err) {
|
|
failed += 1;
|
|
logger.error(
|
|
{ err, name: spec.name, provider: spec.provider },
|
|
"failed to ensure managed sandbox environment; continuing boot (degraded: environment unavailable)",
|
|
);
|
|
}
|
|
}
|
|
const resolvePlugin = opts.resolveEnvironmentPlugin ?? resolveEnvironmentPluginByKey;
|
|
const pluginOutcomes: ManagedPluginEnvironmentReconciliationOutcome[] = [];
|
|
|
|
const ensurePluginEntry = (
|
|
spec: ManagedInstanceConfig["pluginEnvironments"][number],
|
|
workerManager: Pick<PluginWorkerManager, "isRunning" | "call">,
|
|
) =>
|
|
environments.ensureManagedPluginEnvironment({
|
|
name: spec.name,
|
|
description: spec.description,
|
|
pluginKey: spec.pluginKey,
|
|
driverKey: spec.driverKey,
|
|
driverConfig: { ...spec.driverConfig },
|
|
workerManager,
|
|
});
|
|
|
|
type PluginSpec = ManagedInstanceConfig["pluginEnvironments"][number];
|
|
type PluginWorkerAccess = Pick<PluginWorkerManager, "isRunning" | "call">;
|
|
|
|
const archivePluginEntry = (spec: PluginSpec) =>
|
|
environments
|
|
.archiveManagedPluginEnvironment({ pluginKey: spec.pluginKey, driverKey: spec.driverKey })
|
|
.catch((archiveErr: unknown) => {
|
|
logger.error(
|
|
{ err: archiveErr, name: spec.name, pluginKey: spec.pluginKey, driverKey: spec.driverKey },
|
|
"failed to archive the managed plugin environment of an unavailable plugin",
|
|
);
|
|
return null;
|
|
});
|
|
|
|
/**
|
|
* Re-ensure a plugin entry each time its worker reports `ready`, until one
|
|
* ensure succeeds or the plugin permanently rejects the entry's config. A
|
|
* worker that crashes during an attempt (for example inside
|
|
* `environmentValidateConfig`) leaves the listener armed, so its next
|
|
* respawn retries. A `ready` that arrives during an attempt queues one more
|
|
* attempt. No handle means no respawn is coming (activation never started a
|
|
* worker), so the degraded-until-next-boot posture stands.
|
|
*/
|
|
const schedulePluginRecovery = (spec: PluginSpec, pluginId: string, workerAccess: PluginWorkerAccess): void => {
|
|
const manager = opts.workerManager;
|
|
const handle = manager?.getWorker(pluginId);
|
|
if (!handle) return;
|
|
const logBase = { name: spec.name, pluginKey: spec.pluginKey, driverKey: spec.driverKey };
|
|
let settled = false;
|
|
let inFlight = false;
|
|
let queued = false;
|
|
const settle = (): void => {
|
|
settled = true;
|
|
handle.off("ready", attempt);
|
|
};
|
|
function attempt(): void {
|
|
if (settled) return;
|
|
if (inFlight) {
|
|
queued = true;
|
|
return;
|
|
}
|
|
inFlight = true;
|
|
void ensurePluginEntry(spec, workerAccess)
|
|
.then((result) => {
|
|
settle();
|
|
logger.info(
|
|
{ ...logBase, environmentId: result.environment.id, action: result.action },
|
|
"managed plugin environment reactivated after plugin worker recovery",
|
|
);
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (isPermanentConfigRejection(err)) {
|
|
settle();
|
|
logger.error(
|
|
{ err, ...logBase },
|
|
"the plugin rejected the managed plugin environment's config; not retrying (degraded: environment unavailable until the config changes)",
|
|
);
|
|
} else {
|
|
logger.warn(
|
|
{ err, ...logBase },
|
|
"failed to reactivate managed plugin environment; retrying when the plugin worker is ready again",
|
|
);
|
|
}
|
|
})
|
|
.finally(() => {
|
|
inFlight = false;
|
|
if (queued && !settled) {
|
|
queued = false;
|
|
attempt();
|
|
}
|
|
});
|
|
}
|
|
handle.on("ready", attempt);
|
|
if (manager?.isRunning(pluginId)) attempt();
|
|
};
|
|
|
|
for (const spec of pluginSpecs) {
|
|
const logBase = { name: spec.name, pluginKey: spec.pluginKey, driverKey: spec.driverKey };
|
|
let recoverable: { pluginId: string; workerAccess: PluginWorkerAccess } | null = null;
|
|
try {
|
|
const resolved = await resolvePlugin({ db, pluginKey: spec.pluginKey, driverKey: spec.driverKey });
|
|
const manager = opts.workerManager;
|
|
const call = manager?.call;
|
|
// Only a `ready` record that declares the driver can recover without
|
|
// another boot: the worker manager restarts crashed workers, but nothing
|
|
// (re)installs a missing or non-ready plugin at runtime.
|
|
if (resolved && resolved.plugin.status === "ready" && resolved.declaresDriver && manager && call) {
|
|
recoverable = {
|
|
pluginId: resolved.plugin.id,
|
|
workerAccess: { isRunning: manager.isRunning.bind(manager), call: call.bind(manager) },
|
|
};
|
|
}
|
|
const workerRunning = resolved != null && (manager?.isRunning(resolved.plugin.id) ?? false);
|
|
if (!recoverable || !workerRunning) {
|
|
failed += 1;
|
|
const archived = await archivePluginEntry(spec);
|
|
logger.error(
|
|
{
|
|
...logBase,
|
|
pluginStatus: resolved?.plugin.status ?? null,
|
|
declaresDriver: resolved?.declaresDriver ?? null,
|
|
workerRunning,
|
|
archivedEnvironmentId: archived?.id ?? null,
|
|
},
|
|
"managed plugin environment's plugin is not installed, ready, declaring its driver, and running; skipping ensure and archiving any previously provisioned row (degraded: environment unavailable)",
|
|
);
|
|
if (recoverable) schedulePluginRecovery(spec, recoverable.pluginId, recoverable.workerAccess);
|
|
continue;
|
|
}
|
|
const result = await ensurePluginEntry(spec, recoverable.workerAccess);
|
|
ensured += 1;
|
|
if (result.action === "added") added += 1;
|
|
else if (result.action === "unchanged") unchanged += 1;
|
|
else updated += 1;
|
|
pluginOutcomes.push({
|
|
environmentId: result.environment.id,
|
|
name: result.environment.name,
|
|
pluginKey: spec.pluginKey,
|
|
driverKey: spec.driverKey,
|
|
action: result.action,
|
|
});
|
|
logger.info(
|
|
{ ...logBase, environmentId: result.environment.id, action: result.action },
|
|
"managed plugin environment reconciled",
|
|
);
|
|
} catch (err) {
|
|
failed += 1;
|
|
if (recoverable && !isPermanentConfigRejection(err)) {
|
|
if (!recoverable.workerAccess.isRunning(recoverable.pluginId)) {
|
|
// The worker died after the readiness check (for example inside
|
|
// `environmentValidateConfig`): treat it like a plugin that is
|
|
// down, so the row is not left active, and retry on its `ready`.
|
|
const archived = await archivePluginEntry(spec);
|
|
logger.error(
|
|
{ err, ...logBase, archivedEnvironmentId: archived?.id ?? null },
|
|
"managed plugin environment's worker stopped during ensure; archived any previously provisioned row and retrying when the plugin worker is ready again (degraded: environment unavailable)",
|
|
);
|
|
} else {
|
|
// The worker is still running (for example the call timed out).
|
|
// Keep any previously provisioned row as it is, so agents keep a
|
|
// working environment; retry once now and on the worker's next
|
|
// `ready`, and otherwise at the next boot.
|
|
logger.error(
|
|
{ err, ...logBase },
|
|
"failed to ensure managed plugin environment while its worker is running; keeping any previously provisioned row and retrying (degraded: declaration not applied)",
|
|
);
|
|
}
|
|
schedulePluginRecovery(spec, recoverable.pluginId, recoverable.workerAccess);
|
|
} else {
|
|
logger.error(
|
|
{ err, ...logBase },
|
|
"failed to ensure managed plugin environment; continuing boot (degraded: environment unavailable)",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
ensured,
|
|
failed,
|
|
added,
|
|
updated,
|
|
unchanged,
|
|
skipped,
|
|
removed: 0,
|
|
backedUp: 0,
|
|
outcomes,
|
|
pluginOutcomes,
|
|
};
|
|
}
|