fix(agents): select environment plugins only for agents on that environment (#19173)

This commit is contained in:
Nicky Leach authored and GitHub committed 2026-11-04 20:59:02 -08:00
1 parent dc46b575ac
commit 53937be283
14 files changed
+313 -19

No files matched your search

+16
View File
@@ -191,6 +191,22 @@ A disabled or removed plugin cannot silently release an existing requirement.
Restore that plugin to complete the operation.
A later plugin installation does not change an existing selection.
A plugin with an environment driver that declares `requiresAgent: true` is
selected only for an agent on that environment. The host uses the agent's default
environment, or the instance default when the agent has none. Other lifecycle
plugins are selected for all agents, including a sandbox provider that declares
`requiresAgent: true`.
When an agent moves to such an environment, the host adds the plugin at the next
`preparing` or `resuming` step. An agent in `ready` cannot move to it until it is
paused, because the plugin must finish its work before the agent uses that
environment. An agent in `verifying` returns to `preparing`. An agent that moves away keeps the selection until it is terminated,
so the plugin keeps its resources until then.
Saving an environment or the instance default that gives such a plugin to `ready`
or `verifying` agents without that selection returns a conflict. Pause those agents,
save the change, and then resume them.
Each request contains these fields:
- `companyId` and `agentId` identify the agent.
+3 -1
View File
@@ -2800,7 +2800,9 @@ Legacy runner; incompatible settings return remediation instead of being ignored
Saved OpenCode agents keep their runner and runtime policies.
Plugin environment drivers can declare `requiresAgent: true` when their leases
need an agent-owned resource. Setup saves the draft and environment, runs its
need an agent-owned resource. The host selects that plugin's lifecycle work only for
agents on that environment. See [Required plugin work](./AGENT-LIFECYCLE.md#required-plugin-work).
Setup saves the draft and environment, runs its
existing lifecycle preparation, and waits in `verifying` with the `setup` hold.
The worker becomes idle there until the user finishes hiring. Connection tests
use that draft's identity and a bounded Core-issued probe; ordinary tasks remain
@@ -2,7 +2,7 @@ import type { Server } from "node:http";
import express from "express";
import request from "supertest";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { unprocessable } from "../errors.js";
import { conflict, unprocessable } from "../errors.js";
import { environmentRoutes } from "../routes/environments.js";
import { errorHandler } from "../middleware/index.js";
@@ -33,6 +33,7 @@ const mockCloseWarmNativeSessionsForEnvironment = vi.hoisted(() =>
vi.fn(async () => ({ closed: 0, busy: 0, failed: 0 })),
);
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(async () => ({ defaultEnvironmentId: null })),
listCompanyIds: vi.fn(),
getGeneral: vi.fn(),
getExperimental: vi.fn(),
@@ -123,10 +124,13 @@ vi.mock("../services/execution-workspaces.js", () => ({
executionWorkspaceService: () => mockExecutionWorkspaceService,
}));
const mockAssertAgentsReadyForEnvironmentChange = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("../services/plugin-environment-driver.js", () => ({
// The runtime reads this published constant at import time. Mirror the real
// value so the mocked module keeps the same reusable-lease method contract.
REUSABLE_LEASE_WORKER_METHODS: ["environmentResumeLease", "environmentReleaseLease", "environmentDestroyLease"],
assertAgentsReadyForEnvironmentChange: mockAssertAgentsReadyForEnvironmentChange,
listReadyPluginEnvironmentDrivers: mockListReadyPluginEnvironmentDrivers,
resolvePluginSandboxProviderDriverByKey: mockResolvePluginSandboxProviderDriverByKey,
resolvePluginSandboxProviderConfigSchema: async (input: { db: unknown; driverKey: string }) =>
@@ -2452,6 +2456,19 @@ describe("environment routes", () => {
expect(mockEnvironmentService.list).not.toHaveBeenCalled();
});
it("refuses a config change that gives active agents an unprepared plugin", async () => {
const environment = createEnvironment();
mockEnvironmentService.getById.mockResolvedValue(environment);
mockAssertAgentsReadyForEnvironmentChange.mockRejectedValueOnce(conflict("need setup first"));
const res = await request(createApp(localBoardActor()))
.patch(`/api/environments/${environment.id}?companyId=company-1`)
.send({ driver: "local", config: {} });
expect(res.status).toBe(409);
expect(mockAssertAgentsReadyForEnvironmentChange).toHaveBeenCalledWith(expect.anything(), environment,
expect.objectContaining({ driver: "local" }), expect.objectContaining({ environmentId: environment.id }));
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
});
it("logs a redacted update summary instead of raw config or metadata", async () => {
const environment = createEnvironment();
mockEnvironmentService.getById.mockResolvedValue(environment);
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { hoistModuleGraph } from "./helpers/hoist-module-graph.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
const mockAssertAgentsReadyForEnvironmentChange = vi.hoisted(() => vi.fn(async () => {}));
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(),
getGeneral: vi.fn(),
@@ -47,6 +48,10 @@ function registerModuleMocks() {
vi.doMock("../services/environments.js", () => ({
environmentService: () => mockEnvironmentService,
}));
vi.doMock("../services/plugin-environment-driver.js", async (importOriginal) => ({
...await importOriginal<typeof import("../services/plugin-environment-driver.js")>(),
assertAgentsReadyForEnvironmentChange: mockAssertAgentsReadyForEnvironmentChange,
}));
}
// Identity object the mocked db.transaction hands to writers; tests assert
@@ -359,6 +364,18 @@ describe("instance settings routes", () => {
expect(mockLogActivity).toHaveBeenCalledTimes(2);
});
it("refuses an instance default that gives inheriting agents an unprepared plugin", async () => {
const app = await createApp(localAdminActor());
const { conflict } = await import("../errors.js");
mockAssertAgentsReadyForEnvironmentChange.mockRejectedValueOnce(conflict("need setup first"));
const res = await request(app)
.patch("/api/instance/settings")
.send({ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" });
expect(res.status).toBe(409);
expect(mockAssertAgentsReadyForEnvironmentChange.mock.lastCall?.at(3)).toEqual({ inheritsDefault: true });
expect(mockInstanceSettingsService.update).not.toHaveBeenCalled();
});
it("clears the managed-default stamp marker on an explicit tenant default write", async () => {
// A tenant write of defaultEnvironmentId reclassifies the default as
// tenant-chosen: the reconciliation stamp marker on the managed
@@ -0,0 +1,163 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { agents, companies, environments, instanceSettings, plugins, createDb, type Db } from "@paperclipai/db";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../../__tests__/helpers/embedded-postgres.js";
import { configureAgentLifecycle, createAgentLifecycle, createAgentLifecycleEffects } from "../../../services/agent-lifecycle.js";
import { createLifecycleDriver } from "../../../services/agent-lifecycle-driver.js";
import { agentService as agentConfiguration } from "../../../services/agents.js";
import { assertAgentsReadyForEnvironmentChange } from "../../../services/plugin-environment-driver.js";
import { createLifecycleStore } from "../adapters/postgres.js";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("environment-bound lifecycle plugins", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: Db;
let companyId: string;
let boundPluginId: string;
let boundEnvironmentId: string;
let otherEnvironmentId: string;
const workers: ReturnType<typeof configureAgentLifecycle>[] = [];
const call = vi.fn(async (_id, _method, input) => ({ operationId: input.operationId, version: input.version, status: "complete" }));
beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("paperclip-environment-plugins-"); db = createDb(database.connectionString); }, 90_000);
afterAll(async () => { for (const work of workers) await work.stop(); await database?.cleanup(); });
beforeEach(async () => {
for (const work of workers.splice(0)) await work.stop();
call.mockClear();
await db.delete(plugins);
await db.update(instanceSettings).set({ defaultEnvironmentId: null });
companyId = randomUUID(); boundPluginId = randomUUID(); boundEnvironmentId = randomUUID(); otherEnvironmentId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Environment plugins", issuePrefix: `E${companyId.slice(0, 7)}` });
await db.insert(plugins).values({ id: boundPluginId, pluginKey: boundPluginId, packageName: "bound", version: "1.0.0", status: "ready",
manifestJson: { agentLifecycle: true, capabilities: ["agents.lifecycle.manage", "environment.drivers.register"],
environmentDrivers: [{ driverKey: "tasks", requiresAgent: true, supportsTasks: true }] } as never });
await db.insert(environments).values([
{ id: boundEnvironmentId, name: `Bound ${boundEnvironmentId}`, driver: "plugin", status: "active", config: { pluginKey: boundPluginId, driverKey: "tasks", driverConfig: {} } },
{ id: otherEnvironmentId, name: `Other ${otherEnvironmentId}`, driver: "sandbox", status: "active", config: { provider: "fixture" } },
]);
});
function worker() {
const work = configureAgentLifecycle(db, { ...createLifecycleDriver(db, { call } as never), runHost: async () => "complete" as const });
workers.push(work);
return work;
}
async function agent(defaultEnvironmentId: string | null, fields: Partial<typeof agents.$inferInsert> = {}) {
const id = randomUUID();
await db.insert(agents).values({ id, companyId, name: `Agent ${id}`, adapterType: "process", defaultEnvironmentId, ...fields });
return id;
}
async function current(id: string) { return (await db.select().from(agents).where(eq(agents.id, id)))[0]!; }
async function selection(id: string) {
return createLifecycleDriver(db, { call } as never).requiredPluginIds((await createLifecycleStore(db, createAgentLifecycleEffects()).get(id))!);
}
it("selects an environment-bound plugin only for agents on its environment", async () => {
const genericPluginId = randomUUID();
await db.insert(plugins).values({ id: genericPluginId, pluginKey: genericPluginId, packageName: "generic", version: "1.0.0", status: "ready",
manifestJson: { agentLifecycle: true, capabilities: ["agents.lifecycle.manage"] } as never });
expect(await selection(await agent(boundEnvironmentId))).toEqual(expect.arrayContaining([boundPluginId, genericPluginId]));
expect(await selection(await agent(otherEnvironmentId))).toEqual([genericPluginId]);
expect(await selection(await agent(null))).toEqual([genericPluginId]);
});
it("keeps selecting a sandbox provider that requires an agent for all agents", async () => {
const providerId = randomUUID(), sandboxId = randomUUID(), providerKey = `provider-${providerId.slice(0, 8)}`;
await db.insert(plugins).values({ id: providerId, pluginKey: providerId, packageName: "provider", version: "1.0.0", status: "ready",
manifestJson: { agentLifecycle: true, capabilities: ["agents.lifecycle.manage", "environment.drivers.register"],
environmentDrivers: [{ driverKey: providerKey, kind: "sandbox_provider", requiresAgent: true }] } as never });
await db.insert(environments).values({ id: sandboxId, name: `Sandbox ${sandboxId}`, driver: "sandbox", status: "active", config: { provider: providerKey } });
expect(await selection(await agent(sandboxId))).toEqual([providerId]);
expect(await selection(await agent(otherEnvironmentId))).toEqual([providerId]);
const id = await agent(otherEnvironmentId, { lifecycleRequiredPluginIds: [] });
await agentConfiguration(db).update(id, { defaultEnvironmentId: sandboxId });
expect((await current(id)).defaultEnvironmentId).toBe(sandboxId);
});
it("refuses an environment change that gives active agents an unprepared plugin", async () => {
const other = { driver: "sandbox", config: { provider: "fixture" } };
const bound = { driver: "plugin", config: { pluginKey: boundPluginId, driverKey: "tasks" } };
const users = { environmentId: otherEnvironmentId, inheritsDefault: false };
const id = await agent(otherEnvironmentId, { lifecycleRequiredPluginIds: [] });
await expect(assertAgentsReadyForEnvironmentChange(db, other, bound, users)).rejects.toThrow("need setup first");
await db.update(agents).set({ lifecycleState: "paused", status: "paused" }).where(eq(agents.id, id));
await expect(assertAgentsReadyForEnvironmentChange(db, other, bound, users)).resolves.toBeUndefined();
await db.update(agents).set({ lifecycleState: "ready", status: "idle", lifecycleRequiredPluginIds: [boundPluginId] }).where(eq(agents.id, id));
await expect(assertAgentsReadyForEnvironmentChange(db, other, bound, users)).resolves.toBeUndefined();
await db.update(agents).set({ lifecycleRequiredPluginIds: [] }).where(eq(agents.id, id));
await expect(assertAgentsReadyForEnvironmentChange(db, bound, bound, users)).resolves.toBeUndefined();
await expect(assertAgentsReadyForEnvironmentChange(db, bound, other, users)).resolves.toBeUndefined();
});
it("refuses an instance default that gives inheriting agents an unprepared plugin", async () => {
const other = { driver: "sandbox", config: { provider: "fixture" } };
const bound = { driver: "plugin", config: { pluginKey: boundPluginId, driverKey: "tasks" } };
await agent(null, { lifecycleRequiredPluginIds: [] });
await expect(assertAgentsReadyForEnvironmentChange(db, other, bound, { inheritsDefault: true })).rejects.toThrow("need setup first");
await expect(assertAgentsReadyForEnvironmentChange(db, other, bound, { environmentId: boundEnvironmentId, inheritsDefault: false })).resolves.toBeUndefined();
});
it("uses the instance default for an agent with no environment", async () => {
await db.update(instanceSettings).set({ defaultEnvironmentId: boundEnvironmentId });
expect(await selection(await agent(null))).toEqual([boundPluginId]);
expect(await selection(await agent(otherEnvironmentId))).toEqual([]);
});
it("does not call the plugin while an agent on another environment prepares", async () => {
const id = await agent(otherEnvironmentId, { lifecycleState: "resuming", status: "paused",
lifecycleOperation: { id: randomUUID(), hostComplete: false, completedPluginIds: [], attempts: 0 } });
await worker().process(id);
expect(call).not.toHaveBeenCalled();
expect((await current(id)).lifecycleRequiredPluginIds).toEqual([]);
});
it("adds the plugin when a paused agent moves to its environment and resumes", async () => {
const id = await agent(otherEnvironmentId, { lifecycleState: "paused", status: "paused", lifecycleHolds: ["manual"], pauseReason: "manual", lifecycleRequiredPluginIds: [] });
await agentConfiguration(db).update(id, { defaultEnvironmentId: boundEnvironmentId });
await createAgentLifecycle(db).resumeAgent(id);
await worker().process(id);
expect((await current(id)).lifecycleRequiredPluginIds).toEqual([boundPluginId]);
expect(call.mock.calls.map(args => args[2].phase)).toContain("resuming");
});
it("returns a verifying agent to preparing when it moves to the plugin's environment", async () => {
const id = await agent(otherEnvironmentId, { lifecycleState: "verifying", status: "paused", lifecycleRequiredPluginIds: [],
lifecycleOperation: { id: randomUUID(), hostComplete: false, completedPluginIds: [], attempts: 0 } });
await agentConfiguration(db).update(id, { defaultEnvironmentId: boundEnvironmentId });
expect((await current(id)).lifecycleState).toBe("preparing");
await worker().process(id);
expect((await current(id)).lifecycleRequiredPluginIds).toEqual([boundPluginId]);
expect(call.mock.calls.map(args => args[2].phase)).toContain("preparing");
});
it("adds the plugin when an agent moves to its environment while preparing", async () => {
const id = await agent(otherEnvironmentId, { lifecycleState: "preparing", status: "paused", lifecycleRequiredPluginIds: [],
lifecycleOperation: { id: randomUUID(), hostComplete: true, completedPluginIds: [], attempts: 0 } });
await agentConfiguration(db).update(id, { defaultEnvironmentId: boundEnvironmentId });
await worker().process(id);
expect((await current(id)).lifecycleRequiredPluginIds).toEqual([boundPluginId]);
expect(call.mock.calls.map(args => args[2].phase)).toContain("preparing");
});
it("keeps a verifying agent in verifying when the new environment needs no new plugin", async () => {
const id = await agent(boundEnvironmentId, { lifecycleState: "verifying", status: "paused", lifecycleRequiredPluginIds: [boundPluginId],
lifecycleOperation: { id: randomUUID(), hostComplete: false, completedPluginIds: [], attempts: 0 } });
await agentConfiguration(db).update(id, { defaultEnvironmentId: otherEnvironmentId });
expect((await current(id)).lifecycleState).toBe("verifying");
});
it("refuses to move a ready agent to an environment whose plugin it has not selected", async () => {
const id = await agent(otherEnvironmentId, { lifecycleRequiredPluginIds: [] });
await expect(agentConfiguration(db).update(id, { defaultEnvironmentId: boundEnvironmentId })).rejects.toThrow("Pause the agent");
expect((await current(id)).defaultEnvironmentId).toBe(otherEnvironmentId);
});
it("lets a ready agent move when the plugin is selected, and keeps the selection when it leaves", async () => {
const id = await agent(boundEnvironmentId, { lifecycleRequiredPluginIds: [boundPluginId] });
await agentConfiguration(db).update(id, { defaultEnvironmentId: otherEnvironmentId });
expect(await current(id)).toMatchObject({ defaultEnvironmentId: otherEnvironmentId, lifecycleRequiredPluginIds: [boundPluginId] });
await agentConfiguration(db).update(id, { defaultEnvironmentId: boundEnvironmentId });
expect((await current(id)).defaultEnvironmentId).toBe(boundEnvironmentId);
});
});
@@ -25,6 +25,14 @@ export async function updateAgentConfiguration(tx: Db, effects: LifecycleEffects
assertConfigurationFields(patch);
const changed = ["adapterType", "adapterConfig", "runtimeConfig", "defaultEnvironmentId"].some(key =>
Object.prototype.hasOwnProperty.call(patch, key) && !jsonEqual(patch[key as keyof typeof patch], current[key as keyof typeof current]));
const needsNewPlugin = ["ready", "verifying"].includes(current.lifecycleState)
&& Object.prototype.hasOwnProperty.call(patch, "defaultEnvironmentId")
&& patch.defaultEnvironmentId !== current.defaultEnvironmentId
&& await effects.environmentRequiresNewPlugin(tx, patch.defaultEnvironmentId, current.lifecycleRequiredPluginIds);
if (needsNewPlugin && current.lifecycleState === "ready") {
throw new AgentLifecycleConflict("This environment needs setup for the agent. Pause the agent, change its environment, and then resume it.");
}
const prepareEnvironment = needsNewPlugin && !current.lifecycleHolds.includes("setup");
const prepareSetup = changed && current.lifecycleHolds.length === 1 && current.lifecycleHolds[0] === "setup"
&& ["paused", "preparing", "verifying"].includes(current.lifecycleState)
&& await effects.setupRequiresPreparation(tx, current.companyId,
@@ -35,7 +43,7 @@ export async function updateAgentConfiguration(tx: Db, effects: LifecycleEffects
if (invalidate) await effects.notifyWork(tx);
const [updated] = await tx.update(agents).set({ ...patch, updatedAt: new Date(), ...(invalidate ? {
lifecycleVersion: current.lifecycleVersion + 1, lifecycleError: null,
...(prepareSetup ? { lifecycleState: "preparing" as const } : {}),
...(prepareSetup || prepareEnvironment ? { lifecycleState: "preparing" as const } : {}),
...(!prepareSetup && current.lifecycleHolds.includes("setup") && ["preparing", "verifying"].includes(current.lifecycleState)
? { lifecycleState: "pausing" as const } : {}),
lifecycleOperation: { ...current.lifecycleOperation!, id: randomUUID(), hostComplete: false,
@@ -8,6 +8,7 @@ import type { ActivityPublication } from "../../../types/activity-publication.js
export interface LifecycleEffects {
setupRequiresPreparation(db: Db, companyId: string, environmentId: string | null | undefined,
profile: Pick<CreateAgentData, "adapterType" | "adapterConfig">): Promise<boolean>;
environmentRequiresNewPlugin(db: Db, environmentId: string | null | undefined, selectedPluginIds: string[] | null): Promise<boolean>;
notifyWork(db: Db): Promise<void>;
beforeStatusChange(db: Db, companyId: string, agentId: string, before: string, after: string): Promise<void>;
reportFailure(context: LifecycleFailure, error: unknown): void;
@@ -158,7 +158,8 @@ export function createLifecycleStore(db: Db, effects: LifecycleEffects): Lifecyc
async setRequiredPlugins(agent, owner, pluginIds) {
const rows = await db.update(agents).set({ lifecycleRequiredPluginIds: pluginIds })
.where(and(eq(agents.id, agent.id), eq(agents.lifecycleVersion, agent.lifecycleVersion),
sql`${agents.lifecycleRequiredPluginIds} is null`,
agent.lifecycleRequiredPluginIds === null ? sql`${agents.lifecycleRequiredPluginIds} is null`
: sql`${agents.lifecycleRequiredPluginIds} = ${JSON.stringify(agent.lifecycleRequiredPluginIds)}::jsonb`,
sql`${agents.lifecycleOperation}->>'leaseOwner' = ${owner}`)).returning({ id: agents.id });
return rows.length > 0;
},
@@ -33,8 +33,12 @@ export function createLifecycleWorker(store: LifecycleStore, driver: LifecycleDr
renewal.unref?.();
try {
const operation = agent.lifecycleOperation!;
const pluginIds = agent.lifecycleRequiredPluginIds ?? await runStep("select_plugins", () => driver.requiredPluginIds(agent));
if (agent.lifecycleRequiredPluginIds === null && !await runStep("save_plugins", () => store.setRequiredPlugins(agent, owner, pluginIds))) continue;
const saved = agent.lifecycleRequiredPluginIds;
const selecting = saved === null || (["preparing", "resuming"].includes(agent.lifecycleState)
&& !operation.hostComplete && operation.completedPluginIds.length === 0);
const pluginIds = !selecting ? saved
: [...new Set([...(saved ?? []), ...await runStep("select_plugins", () => driver.requiredPluginIds(agent))])];
if ((saved === null || pluginIds.length !== saved.length) && !await runStep("save_plugins", () => store.setRequiredPlugins(agent, owner, pluginIds))) continue;
if (!canRun()) {
await runStep("defer", () => store.defer(agent, owner, null, new Date()));
return;
+6
View File
@@ -52,6 +52,7 @@ import { secretService } from "../services/secrets.js";
import {
listReadyPluginEnvironmentDrivers,
type ReadyPluginWorkerRecovery,
assertAgentsReadyForEnvironmentChange,
} from "../services/plugin-environment-driver.js";
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
import { assertBoardOrgAccess, getActorInfo } from "./authz.js";
@@ -1216,6 +1217,11 @@ export function environmentRoutes(
// Persist the config change and its binding rows atomically: a binding
// ref that fails validation (e.g. a deleted secret) must roll the whole
// save back instead of leaving the config re-pointed with stale bindings.
if (patch.config !== undefined || patch.driver !== undefined) {
const instanceDefault = (await instanceSettingsService(db).get()).defaultEnvironmentId;
await assertAgentsReadyForEnvironmentChange(db, existing, { driver: nextDriver, config: patch.config ?? existing.config },
{ environmentId: existing.id, inheritsDefault: instanceDefault === existing.id });
}
const environment = await db.transaction(async (tx) => {
const updated = await svc.update(existing.id, patch, { db: tx });
if (!updated) return null;
+5
View File
@@ -9,6 +9,7 @@ import {
startTaskDrainRequestSchema,
} from "@paperclipai/shared";
import { conflict, forbidden } from "../errors.js";
import { assertAgentsReadyForEnvironmentChange } from "../services/plugin-environment-driver.js";
import {
cloudTenantPrimaryCompanyId,
getCloudStackContext,
@@ -155,6 +156,10 @@ export function instanceSettingsRoutes(db: Db, pluginWorkers?: PluginWorkerManag
"instance",
typeof req.body.defaultEnvironmentId === "string" ? req.body.defaultEnvironmentId : null,
);
const previousId = (await svc.get()).defaultEnvironmentId;
const environmentById = async (id: unknown) => typeof id === "string" ? environments.getById(id) : null;
await assertAgentsReadyForEnvironmentChange(db, await environmentById(previousId), await environmentById(req.body.defaultEnvironmentId),
{ inheritsDefault: true });
}
// An explicit tenant write of the instance default reclassifies its
// attribution: whatever the default becomes — including a deliberate
+14 -4
View File
@@ -1,5 +1,7 @@
import { and, eq, inArray } from "drizzle-orm";
import { agents, plugins, pluginCompanySettings, type Db } from "@paperclipai/db";
import { agents, environments, plugins, pluginCompanySettings, type Db } from "@paperclipai/db";
import { instanceSettingsService } from "./instance-settings.js";
import { pluginHasAgentEnvironments, pluginServesAgentEnvironment } from "./plugin-environment-driver.js";
import { heartbeatService } from "./heartbeat.js";
import { listInvalidOrgChainDescendantIds } from "./agent-invokability.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
@@ -15,12 +17,20 @@ export function createLifecycleDriver(db: Db, manager: PluginWorkerManager): Lif
await heartbeat.resumeQueuedRunsForAgent(agent.companyId, agent.id);
},
async requiredPluginIds(agent) {
if (agent.lifecycleRequiredPluginIds !== null) return agent.lifecycleRequiredPluginIds;
const selected = agent.lifecycleRequiredPluginIds;
const installed = await db.select().from(plugins).where(inArray(plugins.status, ["ready", "error", "installed", "upgrade_pending"]));
const disabled = await db.select({ pluginId: pluginCompanySettings.pluginId }).from(pluginCompanySettings)
.where(and(eq(pluginCompanySettings.companyId, agent.companyId), eq(pluginCompanySettings.enabled, false)));
return installed.filter(plugin => !disabled.some(row => row.pluginId === plugin.id) &&
plugin.manifestJson.agentLifecycle && plugin.manifestJson.capabilities.includes("agents.lifecycle.manage")).map(plugin => plugin.id);
const candidates = installed.filter(plugin => !disabled.some(row => row.pluginId === plugin.id) &&
plugin.manifestJson.agentLifecycle && plugin.manifestJson.capabilities.includes("agents.lifecycle.manage"));
const [row] = await db.select({ environmentId: agents.defaultEnvironmentId }).from(agents).where(eq(agents.id, agent.id));
const environmentId = row?.environmentId ?? (await instanceSettingsService(db).get()).defaultEnvironmentId;
const [environment] = environmentId ? await db.select().from(environments).where(eq(environments.id, environmentId)) : [];
if (selected !== null) {
const added = candidates.filter(plugin => pluginServesAgentEnvironment(plugin, environment) && !selected.includes(plugin.id));
return [...selected, ...added.map(plugin => plugin.id)];
}
return candidates.filter(plugin => !pluginHasAgentEnvironments(plugin) || pluginServesAgentEnvironment(plugin, environment)).map(plugin => plugin.id);
},
async runPlugin(agent, pluginId) {
const [plugin] = await db.select().from(plugins).where(eq(plugins.id, pluginId));
+7 -1
View File
@@ -23,7 +23,7 @@ import { applyAgentPolicyHoldsInTransaction } from "../modules/agent-lifecycle/i
import { trackIdleWork } from "./task-admission.js";
import { environmentService } from "./environments.js";
import { instanceSettingsService } from "./instance-settings.js";
import { pluginEnvironmentRequiresAgent } from "./plugin-environment-driver.js";
import { pluginEnvironmentRequiresAgent, pluginForAgentEnvironment } from "./plugin-environment-driver.js";
import { isProviderApiSetupDraft } from "./native-runtime/provider-api-placement.js";
export { scheduleAgentLifecycle, processAgentLifecycle } from "../modules/agent-lifecycle/index.js";
@@ -42,6 +42,12 @@ export function createAgentLifecycleEffects(_hooks: BudgetServiceHooks = {}): li
if (bound.length && !bound.includes(companyId)) throw new lifecycle.AgentLifecycleConflict("The environment belongs to another company");
return true;
},
async environmentRequiresNewPlugin(db, environmentId, selectedPluginIds) {
const id = environmentId ?? (await instanceSettingsService(db).get()).defaultEnvironmentId;
const environment = id ? await environmentService(db).getById(id) : null;
const plugin = environment ? await pluginForAgentEnvironment(db, environment) : null;
return plugin !== null && !selectedPluginIds?.includes(plugin.id);
},
notifyWork: tx => notifyDeliveryWork(tx, DELIVERY_QUEUES.agentLifecycle),
async beforeStatusChange(tx, companyId, agentId, before, after) {
await notifyAttentionRetentionWork(tx, { companyId });
@@ -1,5 +1,6 @@
import { plugins, type Db } from "@paperclipai/db";
import { sql } from "drizzle-orm";
import { agents, plugins, type Db } from "@paperclipai/db";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { conflict } from "../errors.js";
import type {
EnvironmentProbeResult,
PluginEnvironmentConfig,
@@ -31,12 +32,49 @@ import {
import { pluginRegistryService } from "./plugin-registry.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
export async function pluginEnvironmentRequiresAgent(db: Db, environment: { driver: string; config: Record<string, unknown> | null }) {
if (environment.driver !== "plugin") return false;
const config = environment.config;
if (typeof config?.pluginKey !== "string" || typeof config.driverKey !== "string") return false;
const plugin = await pluginRegistryService(db).getByKey(config.pluginKey);
return plugin?.manifestJson.environmentDrivers?.some(driver => driver.driverKey === config.driverKey && driver.requiresAgent === true) ?? false;
type AgentEnvironmentRef = { driver: string; config: Record<string, unknown> | null };
type AgentEnvironmentPlugin = {
pluginKey: string;
manifestJson: { environmentDrivers?: Array<{ driverKey: string; kind?: string; requiresAgent?: boolean }> };
};
export function pluginHasAgentEnvironments(plugin: AgentEnvironmentPlugin) {
const drivers = plugin.manifestJson.environmentDrivers?.filter(driver => driver.requiresAgent === true) ?? [];
return drivers.length > 0 && drivers.every(driver => driver.kind !== "sandbox_provider");
}
export function pluginServesAgentEnvironment(plugin: AgentEnvironmentPlugin, environment: AgentEnvironmentRef | null | undefined) {
if (environment?.driver !== "plugin" || environment.config?.pluginKey !== plugin.pluginKey) return false;
return plugin.manifestJson.environmentDrivers?.some(driver => driver.requiresAgent === true && driver.kind !== "sandbox_provider"
&& driver.driverKey === environment.config?.driverKey) ?? false;
}
export async function pluginForAgentEnvironment(db: Db, environment: AgentEnvironmentRef) {
const pluginKey = environment.config?.pluginKey;
if (environment.driver !== "plugin" || typeof pluginKey !== "string") return null;
const plugin = await pluginRegistryService(db).getByKey(pluginKey);
return plugin && pluginServesAgentEnvironment(plugin, environment) ? plugin : null;
}
/** Throws when a saved environment would give active agents a plugin that has not prepared them. */
export async function assertAgentsReadyForEnvironmentChange(db: Db, before: AgentEnvironmentRef | null | undefined,
after: AgentEnvironmentRef | null | undefined, users: { environmentId?: string; inheritsDefault: boolean }) {
const plugin = after ? await pluginForAgentEnvironment(db, after) : null;
if (!plugin || (before && (await pluginForAgentEnvironment(db, before))?.id === plugin.id)) return;
const usesEnvironment = or(users.environmentId ? eq(agents.defaultEnvironmentId, users.environmentId) : undefined,
users.inheritsDefault ? isNull(agents.defaultEnvironmentId) : undefined);
if (!usesEnvironment) return;
const [waiting] = await db.select({ id: agents.id }).from(agents).where(and(usesEnvironment,
inArray(agents.lifecycleState, ["ready", "verifying"]),
sql`not coalesce(${agents.lifecycleRequiredPluginIds}, '[]'::jsonb) @> ${JSON.stringify([plugin.id])}::jsonb`)).limit(1);
if (waiting) {
throw conflict("Agents that use this environment need setup first. Pause those agents, save this change, and then resume them.",
{ code: "environment_requires_agent_setup" });
}
}
export async function pluginEnvironmentRequiresAgent(db: Db, environment: AgentEnvironmentRef) {
return await pluginForAgentEnvironment(db, environment) !== null;
}
/**