mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +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 > - That document cannot declare a plugin environment yet, so a managed instance cannot get a plugin-provided environment without manual setup > - The full change is split into three pull requests. This first one adds the plugin environment rows and their write floor, and nothing calls them yet > - The second pull request lets the managed config declare plugin entries and provisions them at boot. The third sets the instance default once and advertises the new managed-config capability > - The benefit is a smaller review for each step, and no instance changes behavior until the last step lands ## Linked Issues or Issue Description **Subsystem affected** Server environments (managed-config environment provisioning, environment routes) and the board UI's adapter Test target. **Problem or motivation** A managed instance cannot get a plugin environment from its managed config. Company settings cannot create one either. So each managed instance that needs one requires manual setup. **Proposed solution** Over three pull requests, let an `environments` entry in the managed config declare `driver: "plugin"`. Boot then provisions one locked row for each plugin entry, and makes one of them the instance default once on new instances. This pull request adds only the row layer: create or adopt a marked row, archive it, find it, and keep tenants from editing it. **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 - New `server/src/services/managed-plugin-environments.ts`, used through new `environmentService` methods (`ensureManagedPluginEnvironment`, `archiveManagedPluginEnvironment`, `findManagedPluginEnvironment`): - The ensure validates the driver config through `normalizeEnvironmentConfig` and `validatePluginEnvironmentDriverConfig`, the same path an operator-created plugin environment takes. - It marks the row `managedByPaperclip: true` and `managedPluginEnvironment: "<pluginKey>:<driverKey>"`. - It adopts an existing unmarked row for the same plugin and driver instead of adding a second one, so a hand-made row keeps its agents. - A row that already matches gets no write. Changes write activity log entries. - Archive sets an active marked row to `archived`. The next ensure reactivates it. - Migration `0437`: unique index `environments_managed_plugin_environment_idx` on `metadata ->> 'managedPluginEnvironment'`. This is the only schema change of the three pull requests. Snapshot `0432` is pruned to keep the five newest. - `server/src/routes/environments.ts`: `isPlatformProvisionedEnvironment` also covers the new marker (write floor and redaction). `managedPluginEnvironment` is a reserved marker key, so a client cannot stamp it on its own row. Nothing provisions marked rows yet, so the marker-clear escape hatch stays open for them. The next pull request refuses it while the managed config declares the row's entry. - `ui/src/lib/adapter-test-environment.ts`: `resolveManagedSandboxEnvironmentId` requires `driver === "sandbox"`. The plugin row also carries `managedByPaperclip`, so the old "not local" check could take it for the managed sandbox. ## Verification - `cd server && npx vitest run src/__tests__/managed-plugin-environments.test.ts` (embedded Postgres): - The ensure creates the marked row through the plugin validator. A second ensure writes nothing and logs nothing. - A hand-made row for the same plugin and driver is adopted. It keeps its id and its agents. - Archive, then ensure, reactivates the row. - The ensure refuses while the plugin has no running worker, and refuses a name that another environment holds. - The unique index allows one row per marker. - `npx vitest run src/__tests__/environment-routes.test.ts`: on a cloud-managed instance, edit, env-var edit, status change and delete of a marked plugin row are refused, including for instance admins. The marker alone keeps the floor. A client cannot stamp the marker. The marker clear is allowed. - `npx vitest run src/__tests__/environment-service.test.ts`: passes. - `cd ui && npx vitest run src/lib/adapter-test-environment.test.ts`: the plugin row is never taken for the managed sandbox. Under managed-sandbox-only, an agent on the sandbox tests in the sandbox, and an agent on the plugin environment tests there. - `pnpm --filter @paperclipai/server typecheck`, `pnpm --filter @paperclipai/ui typecheck` and `pnpm --filter @paperclipai/db check:migrations` pass. - I did not run the full `pnpm test:run`, `pnpm -r typecheck` or `pnpm build`. CI covers them. `pnpm verify` asks for broader verification because of the migration. ## Risks - Migration: one new partial unique index on a small table. No row carries the marker before this change, so the index cannot fail on existing data. - No caller uses the new ensure yet, so no instance changes behavior. - The UI change affects only lists that hold a non-sandbox row with `managedByPaperclip`. Today that is only the local row, which the old check already excluded. > 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>
288 lines
11 KiB
TypeScript
288 lines
11 KiB
TypeScript
/**
|
|
* Instance-level plugin environment rows declared by the managed-config
|
|
* `environments` section (`driver: "plugin"` entries, see `managed-config.ts`).
|
|
*
|
|
* Each declared entry owns exactly one row, identified by the marker
|
|
* `metadata.managedPluginEnvironment = "<pluginKey>:<driverKey>"` (unique
|
|
* index `environments_managed_plugin_environment_idx`) next to
|
|
* `managedByPaperclip: true`, which puts the row behind the environments-route
|
|
* write floor on cloud-managed instances.
|
|
*
|
|
* - The first ensure adopts an existing unmarked row for the same plugin and
|
|
* driver instead of adding a second one, so a hand-made row keeps the agents
|
|
* that point at it.
|
|
* - Later ensures converge the row to the declaration and write nothing when
|
|
* it already matches.
|
|
* - Archiving is only for a declared plugin that is not running; the next
|
|
* successful ensure reactivates the row. Nothing here archives a row
|
|
* because its entry left the document.
|
|
*
|
|
* The driver config always goes through the plugin environment validator
|
|
* (`normalizeEnvironmentConfig` + `validatePluginEnvironmentDriverConfig`),
|
|
* the same path an operator-created plugin environment takes.
|
|
*/
|
|
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { companies, environments } from "@paperclipai/db";
|
|
import type { PluginEnvironmentConfig } from "@paperclipai/shared";
|
|
import { logActivity, publishActivity, type ActivityPublication } from "./activity-log.js";
|
|
import { normalizeEnvironmentConfig } from "./environment-config.js";
|
|
import { validatePluginEnvironmentDriverConfig } from "./plugin-environment-driver.js";
|
|
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
|
|
|
export const MANAGED_PLUGIN_ENVIRONMENT_MARKER_KEY = "managedPluginEnvironment";
|
|
|
|
export function managedPluginEnvironmentMarker(input: { pluginKey: string; driverKey: string }): string {
|
|
return `${input.pluginKey}:${input.driverKey}`;
|
|
}
|
|
|
|
export interface ManagedPluginEnvironmentInput {
|
|
name: string;
|
|
description?: string;
|
|
pluginKey: string;
|
|
driverKey: string;
|
|
driverConfig?: Record<string, unknown>;
|
|
/** Runs the plugin's own config validation (`environmentValidateConfig`). */
|
|
workerManager: Pick<PluginWorkerManager, "call" | "isRunning">;
|
|
}
|
|
|
|
export type ManagedPluginEnvironmentReconcileAction = "added" | "adopted" | "updated" | "unchanged";
|
|
|
|
export type EnvironmentRow = typeof environments.$inferSelect;
|
|
|
|
export interface ManagedPluginEnvironmentRowResult {
|
|
row: EnvironmentRow;
|
|
action: ManagedPluginEnvironmentReconcileAction;
|
|
}
|
|
|
|
function metadataOf(row: Pick<EnvironmentRow, "metadata">): Record<string, unknown> {
|
|
return (row.metadata ?? {}) as Record<string, unknown>;
|
|
}
|
|
|
|
function stableJson(value: unknown): string {
|
|
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
if (value && typeof value === "object") {
|
|
return `{${Object.keys(value as Record<string, unknown>)
|
|
.sort()
|
|
.map((key) => `${JSON.stringify(key)}:${stableJson((value as Record<string, unknown>)[key])}`)
|
|
.join(",")}}`;
|
|
}
|
|
return JSON.stringify(value ?? null);
|
|
}
|
|
|
|
function servesPair(row: EnvironmentRow, input: { pluginKey: string; driverKey: string }): boolean {
|
|
const config = (row.config ?? {}) as Record<string, unknown>;
|
|
return config.pluginKey === input.pluginKey && config.driverKey === input.driverKey;
|
|
}
|
|
|
|
async function validatedDriverConfig(db: Db, input: ManagedPluginEnvironmentInput): Promise<PluginEnvironmentConfig> {
|
|
const normalized = normalizeEnvironmentConfig({
|
|
driver: "plugin",
|
|
config: { pluginKey: input.pluginKey, driverKey: input.driverKey, driverConfig: input.driverConfig ?? {} },
|
|
}) as unknown as PluginEnvironmentConfig;
|
|
return await validatePluginEnvironmentDriverConfig({
|
|
db,
|
|
workerManager: input.workerManager as PluginWorkerManager,
|
|
config: normalized,
|
|
});
|
|
}
|
|
|
|
/** Marker value of a row, or null when it has none (absent, or cleared to `null` / `false`). */
|
|
function markerOf(row: Pick<EnvironmentRow, "metadata">): string | null {
|
|
const value = metadataOf(row)[MANAGED_PLUGIN_ENVIRONMENT_MARKER_KEY];
|
|
return typeof value === "string" ? value : null;
|
|
}
|
|
|
|
/**
|
|
* Record activity for every company inside the caller's transaction, so the
|
|
* entries commit (or roll back) with the row change. Live events go to
|
|
* `publications` and are published by the caller after commit.
|
|
*/
|
|
async function logForEveryCompany(
|
|
tx: Db,
|
|
publications: ActivityPublication[],
|
|
input: { action: string; environmentId: string; details: Record<string, unknown> },
|
|
): Promise<void> {
|
|
const companyIds = await tx.select({ id: companies.id }).from(companies).then((rows) => rows.map((row) => row.id));
|
|
for (const companyId of companyIds) {
|
|
await logActivity(tx, {
|
|
companyId,
|
|
actorType: "system",
|
|
actorId: "managed-environment-reconciler",
|
|
action: input.action,
|
|
entityType: "environment",
|
|
entityId: input.environmentId,
|
|
details: input.details,
|
|
}, publications);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure the row for one declared plugin entry. Idempotent: a row that
|
|
* already matches the declaration is returned without a write.
|
|
*/
|
|
export async function ensureManagedPluginEnvironment(
|
|
db: Db,
|
|
input: ManagedPluginEnvironmentInput,
|
|
): Promise<ManagedPluginEnvironmentRowResult> {
|
|
const marker = managedPluginEnvironmentMarker(input);
|
|
const config = await validatedDriverConfig(db, input);
|
|
const desiredConfig = { ...config } as Record<string, unknown>;
|
|
const description = input.description ?? null;
|
|
|
|
const publications: ActivityPublication[] = [];
|
|
const result = await db.transaction(async (tx): Promise<ManagedPluginEnvironmentRowResult> => {
|
|
const reconciled = await reconcile(tx);
|
|
if (reconciled.action !== "unchanged") {
|
|
await logForEveryCompany(tx as unknown as Db, publications, {
|
|
action: `environment.managed_plugin_${reconciled.action}`,
|
|
environmentId: reconciled.row.id,
|
|
details: { pluginKey: input.pluginKey, driverKey: input.driverKey, reconciliationAction: reconciled.action },
|
|
});
|
|
}
|
|
return reconciled;
|
|
});
|
|
publications.forEach(publishActivity);
|
|
return result;
|
|
|
|
async function reconcile(tx: Parameters<Parameters<Db["transaction"]>[0]>[0]): Promise<ManagedPluginEnvironmentRowResult> {
|
|
// Lock every plugin row: concurrent ensures (the boot pass and a worker
|
|
// recovery, or two builds in a rolling deploy) serialize here, so only one
|
|
// of them can adopt or insert.
|
|
const pluginRows = await tx.select().from(environments).where(eq(environments.driver, "plugin")).for("update");
|
|
let row = pluginRows.find((candidate) => markerOf(candidate) === marker) ?? null;
|
|
let adopted = false;
|
|
if (!row) {
|
|
const candidates = pluginRows
|
|
// A cleared marker (`null` / `false`, from the marker-clear request)
|
|
// counts as unmarked, so a cleared row is adopted again.
|
|
.filter((candidate) => servesPair(candidate, input) && markerOf(candidate) === null)
|
|
.sort((left, right) =>
|
|
Number(right.name === input.name) - Number(left.name === input.name)
|
|
|| Number(right.status === "active") - Number(left.status === "active")
|
|
|| left.createdAt.getTime() - right.createdAt.getTime());
|
|
row = candidates[0] ?? null;
|
|
adopted = row !== null;
|
|
}
|
|
|
|
const nameOwner = await tx
|
|
.select({ id: environments.id, driver: environments.driver })
|
|
.from(environments)
|
|
.where(eq(environments.name, input.name))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (!row) {
|
|
if (nameOwner) {
|
|
throw new Error(
|
|
`Failed to ensure managed plugin environment: environment "${input.name}" already exists with driver "${nameOwner.driver}"`,
|
|
);
|
|
}
|
|
const now = new Date();
|
|
const inserted = await tx
|
|
.insert(environments)
|
|
.values({
|
|
name: input.name,
|
|
description,
|
|
driver: "plugin",
|
|
status: "active",
|
|
config: desiredConfig,
|
|
envVars: {},
|
|
metadata: { managedByPaperclip: true, [MANAGED_PLUGIN_ENVIRONMENT_MARKER_KEY]: marker },
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.onConflictDoNothing()
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
if (inserted) return { row: inserted, action: "added" };
|
|
// A concurrent pass won the marker index; converge on its row.
|
|
row = await tx
|
|
.select()
|
|
.from(environments)
|
|
.where(sql`${environments.metadata} ->> ${MANAGED_PLUGIN_ENVIRONMENT_MARKER_KEY} = ${marker}`)
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!row) throw new Error("Failed to ensure managed plugin environment");
|
|
}
|
|
|
|
// Keep the row's own name when another row already holds the declared one.
|
|
const desiredName = nameOwner && nameOwner.id !== row.id ? row.name : input.name;
|
|
const currentMetadata = metadataOf(row);
|
|
const desiredMetadata = {
|
|
...currentMetadata,
|
|
managedByPaperclip: true,
|
|
[MANAGED_PLUGIN_ENVIRONMENT_MARKER_KEY]: marker,
|
|
};
|
|
const matches =
|
|
row.name === desiredName &&
|
|
(row.description ?? null) === description &&
|
|
row.status === "active" &&
|
|
stableJson(row.config) === stableJson(desiredConfig) &&
|
|
stableJson(currentMetadata) === stableJson(desiredMetadata);
|
|
if (matches) return { row, action: "unchanged" };
|
|
|
|
const updated = await tx
|
|
.update(environments)
|
|
.set({
|
|
name: desiredName,
|
|
description,
|
|
status: "active",
|
|
config: desiredConfig,
|
|
metadata: desiredMetadata,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(environments.id, row.id))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!updated) throw new Error("Managed plugin environment changed during reconciliation");
|
|
return { row: updated, action: adopted ? "adopted" : "updated" };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Archive the marked row of a declared plugin that is not ready or has no
|
|
* running worker, so run scheduling stops selecting it. Returns null when
|
|
* there is no active marked row.
|
|
*/
|
|
export async function archiveManagedPluginEnvironment(
|
|
db: Db,
|
|
input: { pluginKey: string; driverKey: string },
|
|
): Promise<EnvironmentRow | null> {
|
|
const marker = managedPluginEnvironmentMarker(input);
|
|
const publications: ActivityPublication[] = [];
|
|
const archived = await db.transaction(async (tx) => {
|
|
const row = await tx
|
|
.update(environments)
|
|
.set({ status: "archived", updatedAt: new Date() })
|
|
.where(and(
|
|
eq(environments.driver, "plugin"),
|
|
eq(environments.status, "active"),
|
|
sql`${environments.metadata} ->> ${MANAGED_PLUGIN_ENVIRONMENT_MARKER_KEY} = ${marker}`,
|
|
))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!row) return null;
|
|
await logForEveryCompany(tx as unknown as Db, publications, {
|
|
action: "environment.managed_provider_unavailable_archived",
|
|
environmentId: row.id,
|
|
details: { pluginKey: input.pluginKey, driverKey: input.driverKey },
|
|
});
|
|
return row;
|
|
});
|
|
publications.forEach(publishActivity);
|
|
return archived;
|
|
}
|
|
|
|
/** The marked row for one plugin entry, archived or not. */
|
|
export async function findManagedPluginEnvironment(
|
|
db: Db,
|
|
input: { pluginKey: string; driverKey: string },
|
|
): Promise<EnvironmentRow | null> {
|
|
return await db
|
|
.select()
|
|
.from(environments)
|
|
.where(sql`${environments.metadata} ->> ${MANAGED_PLUGIN_ENVIRONMENT_MARKER_KEY} = ${managedPluginEnvironmentMarker(input)}`)
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|