mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 06:16:06 -10:00
fix(agents): select environment plugins only for agents on that environment (#19173)
This commit is contained in:
1 parent
dc46b575ac
commit
53937be283
14 files changed
+313
-19
No files matched your search
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user