mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
feat(environments): fill an unset instance default with a managed plugin environment (#19551)
## 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, which can declare plugin environments next to the managed sandbox > - A platform that runs managed instances wants a new instance to start with one of those plugin environments as its default, without changing the default of any instance that already has one > - #19632 made boot fill the instance default only when it is unset, local or deleted, in one locked transaction, and removed the revert, so every existing managed instance keeps a set default > - This pull request adds an `instanceDefault` flag to a plugin entry. That entry fills an unset default through the same locked fill, before the managed sandbox does > - The fill follows the same readiness rule as an admin's default write, so it never moves ready agents onto a plugin that has not prepared them > - The platform also needs a way to know whether a release understands plugin entries, so the release now ships a small capabilities file > - The benefit: new instances get the plugin environment as their default, and existing instances and admin choices are never changed ## Linked Issues or Issue Description Refs #19632 (the locked fill-if-unset rule this builds on), #19508 (plugin entries in the managed config) **Subsystem affected** Server environments: the managed config parser and the managed environment bootstrap. **Problem or motivation** The managed config can declare plugin environments, but it can't make one the instance default on a new instance. An external platform also has no way to tell whether a given release supports plugin entries before it sends one. **Proposed solution** - Add `instanceDefault: true` to at most one plugin entry. Right after that entry's boot ensure succeeds, it fills the instance default through `fillUnsetInstanceDefault`. The managed sandbox fill runs after it. - On a new instance the plugin environment wins. On an existing instance the default is already set, so neither fill changes it. - The fill declines while an active agent that follows the default hasn't been prepared by that plugin. This is the rule the instance-settings route enforces. When the plugin fill declines, the managed sandbox fills as usual. - Ship `server/src/services/managed-config-capabilities.json`, which lists `pluginEnvironments`. **Alternatives considered** - A recorded one-time decision with a retry worker. It needed pending states and extra checks for many writer and trigger combinations. Filling only an unset default, under the existing lock, needs none of that. - Rejecting the fill whenever agents exist. That is stricter than the admin route and blocks harmless cases, such as agents that are paused or already prepared. **Roadmap alignment** This extends existing managed-environment behaviour. It adds no UI. ## What Changed - `server/src/services/managed-config.ts`: - `ManagedPluginEnvironmentSpec.instanceDefault` (boolean, default `false`). - The parser refuses `instanceDefault` on more than one plugin entry, on the sandbox entry (even `false`), or as a non-boolean. - `MANAGED_CONFIG_CAPABILITIES` and `MANAGED_CONFIG_CAPABILITIES_SCHEMA_VERSION` constants. A plugin entry is refused unless `pluginEnvironments` is listed. - `server/src/services/managed-config-capabilities.json`: `{ "schemaVersion": 1, "capabilities": ["pluginEnvironments"] }`. A test pins it to the constants and to this literal contract. - `server/src/services/managed-environments.ts`: - New `fillPluginInstanceDefault`. It runs right after a successful boot ensure of the flagged entry and logs a fill or a decline. Recovery after boot never fills. - The managed sandbox fill now runs once, after the plugin entries. - `server/src/services/managed-instance-default.ts`: `fillUnsetInstanceDefault` runs `agentsNeedSetupForEnvironmentChange` inside its locked transaction and returns `declined: "agents_need_setup"`. A sandbox target never needs agent setup, so the managed sandbox fill behaves as before. - `server/src/services/plugin-environment-driver.ts`: the readiness query is factored into `agentsNeedSetupForEnvironmentChange`. `assertAgentsReadyForEnvironmentChange` wraps it and still throws the same conflict. - `doc/DEVELOPING.md`: documents `instanceDefault`, the fill order, the readiness decline, and the capabilities file. ## Verification - `cd server && npx vitest run src/__tests__/managed-plugin-environments.test.ts` (embedded Postgres), under `instanceDefault (fill only an unset default)`: - A new instance gets the plugin row as its default. - An existing sandbox default is kept, and so is an admin's choice. - If the plugin was down at the first boot and the sandbox filled, a later recovery and boot keep the sandbox default. - An admin-cleared or local default is declined while a ready or verifying agent follows the default without the plugin's setup. The managed sandbox then fills it. - It fills when the agents that follow the default are paused or already prepared, or are pinned to another environment. - Without the flag, nothing happens. - A new agent that follows the default gets the plugin in `requiredPluginIds`. - `npx vitest run src/__tests__/managed-instance-default.test.ts` (embedded Postgres): the earlier fill, lock and overlap cases pass. A sandbox fill also succeeds while active agents follow the default. - `npx vitest run src/services/managed-environments-plugin-entries.test.ts`: - The fill order is plugin then sandbox. - When the plugin is down there is no fill, and recovery never fills. - A failed ensure gives only the sandbox fill, and a failed fill keeps the ensure counted. - When the plugin fill declines, the sandbox still fills. - `npx vitest run src/__tests__/managed-config.test.ts src/services/managed-config-capabilities.test.ts`: parser refusals and the capabilities contract. - `npx vitest run src/services/managed-environments.test.ts src/__tests__/instance-settings-routes.test.ts src/__tests__/environment-routes.test.ts`: pass. The route still throws the same readiness conflict. - `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 - An instance whose default is unset at the first boot after this release gets the plugin environment, if its agents are ready for it. That covers a new instance, and an instance whose admin cleared the default or chose the local environment. This is the intended behaviour. - The readiness check doesn't lock agent rows. If an agent becomes ready during the boot fill, it can miss the check, which is the same race the admin route has. Agents created after the fill are prepared for the plugin, because setup reads the current default. - If the plugin is down at a new instance's first boot, the instance keeps the managed sandbox default from then on. - No schema change. > 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fb3cf1a072
commit
0b73c805e8
11 files changed
+433
-35
No files matched your search
+22
-7
@@ -2098,12 +2098,14 @@ sandbox entry (no `driver`, or `driver: "sandbox"`) and any number of
|
||||
"driver": "plugin",
|
||||
"pluginKey": "example.remote-host-plugin",
|
||||
"driverKey": "tasks",
|
||||
"driverConfig": {}
|
||||
"driverConfig": {},
|
||||
"instanceDefault": true
|
||||
}
|
||||
```
|
||||
|
||||
Names and `pluginKey`–`driverKey` pairs must be unique, and `driverConfig` must
|
||||
not contain secret-like keys. Any other key refuses startup. `plugins.autoInstall`
|
||||
Names and `pluginKey`–`driverKey` pairs must be unique, `driverConfig` must not
|
||||
contain secret-like keys, and `instanceDefault` is allowed on at most one plugin
|
||||
entry and never on the sandbox entry. Any other key refuses startup. `plugins.autoInstall`
|
||||
holds catalog keys, not manifest ids, so boot also refuses to start unless an
|
||||
auto-installed bundled or distribution catalog entry installs the entry's
|
||||
`pluginKey`.
|
||||
@@ -2112,15 +2114,28 @@ Boot validates the driver config through the plugin, marks the row
|
||||
`managedPluginEnvironment: "<pluginKey>:<driverKey>"`, and adopts an existing
|
||||
unmarked row for the same plugin and driver. While the plugin is down, the row is
|
||||
archived; it comes back when the worker is ready. Removing an entry never archives
|
||||
its row. Plugin entries do not change the instance default.
|
||||
its row.
|
||||
|
||||
Under `enableManagedSandboxOnly`, boot fills the instance default with the
|
||||
managed sandbox only when the default is unset, names the local environment, or
|
||||
names a deleted one. The fill locks the default row and rechecks it, so a
|
||||
Boot fills the instance default only when it is unset, names the local
|
||||
environment, or names a deleted one. A plugin entry with `instanceDefault` fills
|
||||
first, right after its ensure; then, under `enableManagedSandboxOnly`, the
|
||||
managed sandbox fills. So a new instance gets the plugin environment, and an
|
||||
existing instance keeps the default it already has. If the plugin is down at a
|
||||
new instance's first boot, the managed sandbox fills the default instead, and the
|
||||
plugin environment does not replace it later. Recovery after boot never fills.
|
||||
Like an admin's default write, the plugin fill declines while an active agent
|
||||
that follows the default has not been prepared by that plugin; the managed
|
||||
sandbox then fills as usual. The fill locks the default row and rechecks it, so a
|
||||
concurrent boot or admin write is never overwritten. A filled default is an
|
||||
ordinary set value: nothing reverts it when the mode is later turned off, and an
|
||||
admin can change it in instance settings.
|
||||
|
||||
`server/src/services/managed-config-capabilities.json` lists the managed-config
|
||||
capabilities a release supports, for the control plane to read at a release
|
||||
commit. It must equal `MANAGED_CONFIG_CAPABILITIES` in `managed-config.ts`
|
||||
(`managed-config-capabilities.test.ts` checks this), and the parser accepts plugin
|
||||
entries only while that list includes `pluginEnvironments`.
|
||||
|
||||
|
||||
### Experimental native remote workspace storage
|
||||
|
||||
|
||||
@@ -428,6 +428,7 @@ describe("parseManagedConfigEnv plugin environment entries", () => {
|
||||
pluginKey: PLUGIN_KEY,
|
||||
driverKey: "tasks",
|
||||
driverConfig: {},
|
||||
instanceDefault: true,
|
||||
...overrides,
|
||||
});
|
||||
const sandbox = { name: "Managed Sandbox", provider: "daytona", config: { target: "us" } };
|
||||
@@ -456,6 +457,7 @@ describe("parseManagedConfigEnv plugin environment entries", () => {
|
||||
pluginKey: PLUGIN_KEY,
|
||||
driverKey: "tasks",
|
||||
driverConfig: {},
|
||||
instanceDefault: true,
|
||||
}]);
|
||||
expect(Object.isFrozen(config?.pluginEnvironments)).toBe(true);
|
||||
expect(Object.isFrozen(config?.pluginEnvironments[0])).toBe(true);
|
||||
@@ -464,7 +466,7 @@ describe("parseManagedConfigEnv plugin environment entries", () => {
|
||||
it("parses a plugin entry alone, with optional fields omitted", () => {
|
||||
const config = parseManagedConfigEnv(docWith([{ name: "Remote", driver: "plugin", pluginKey: PLUGIN_KEY, driverKey: "tasks" }]));
|
||||
expect(config?.environments).toEqual([]);
|
||||
expect(config?.pluginEnvironments[0]).toMatchObject({ driverConfig: {} });
|
||||
expect(config?.pluginEnvironments[0]).toMatchObject({ driverConfig: {}, instanceDefault: false });
|
||||
expect(config?.pluginEnvironments[0]?.description).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -478,10 +480,27 @@ describe("parseManagedConfigEnv plugin environment entries", () => {
|
||||
});
|
||||
|
||||
it("refuses a duplicate pluginKey and driverKey pair", () => {
|
||||
expect(() => parseManagedConfigEnv(docWith([remoteHost(), remoteHost({ name: "Second" })])))
|
||||
expect(() => parseManagedConfigEnv(docWith([remoteHost(), remoteHost({ name: "Second", instanceDefault: false })])))
|
||||
.toThrow(/duplicates another plugin entry's pluginKey and driverKey/);
|
||||
});
|
||||
|
||||
it("refuses instanceDefault on two entries", () => {
|
||||
expect(() => parseManagedConfigEnv(docWith([remoteHost(), remoteHost({ name: "Second", driverKey: "other" })])))
|
||||
.toThrow(/at most one entry may become the instance default/);
|
||||
});
|
||||
|
||||
it("refuses instanceDefault on the sandbox entry, even false", () => {
|
||||
expect(() => parseManagedConfigEnv(docWith([{ ...sandbox, instanceDefault: true }])))
|
||||
.toThrow(/"environments\[0\].instanceDefault" is not allowed on the sandbox entry/);
|
||||
expect(() => parseManagedConfigEnv(docWith([{ ...sandbox, instanceDefault: false }])))
|
||||
.toThrow(/not allowed on the sandbox entry/);
|
||||
});
|
||||
|
||||
it("refuses a non-boolean instanceDefault", () => {
|
||||
expect(() => parseManagedConfigEnv(docWith([remoteHost({ instanceDefault: "yes" })])))
|
||||
.toThrow(/"environments\[0\].instanceDefault" must be a boolean/);
|
||||
});
|
||||
|
||||
it("refuses secret-like driverConfig keys at any depth", () => {
|
||||
expect(() => parseManagedConfigEnv(docWith([remoteHost({ driverConfig: { apiToken: "t" } })])))
|
||||
.toThrow(/"environments\[0\].driverConfig" key "apiToken" looks secret-bearing/);
|
||||
@@ -495,9 +514,6 @@ describe("parseManagedConfigEnv plugin environment entries", () => {
|
||||
expect(() => parseManagedConfigEnv(docWith([remoteHost({ envVars: {} })]))).toThrow(/unknown key "envVars"/);
|
||||
expect(() => parseManagedConfigEnv(docWith([remoteHost({ provider: "daytona" })]))).toThrow(/unknown key "provider"/);
|
||||
expect(() => parseManagedConfigEnv(docWith([{ ...sandbox, pluginKey: PLUGIN_KEY }]))).toThrow(/unknown key "pluginKey"/);
|
||||
// Not supported by this build on either entry.
|
||||
expect(() => parseManagedConfigEnv(docWith([remoteHost({ instanceDefault: true })]))).toThrow(/unknown key "instanceDefault"/);
|
||||
expect(() => parseManagedConfigEnv(docWith([{ ...sandbox, instanceDefault: false }]))).toThrow(/unknown key "instanceDefault"/);
|
||||
});
|
||||
|
||||
it("refuses an unknown driver and malformed plugin fields", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDb, environments, instanceSettings, type Db } from "@paperclipai/db";
|
||||
import { agents, companies, createDb, environments, instanceSettings, type Db } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { fillUnsetInstanceDefault } from "../services/managed-instance-default.js";
|
||||
import { applyManagedEnvironments } from "../services/managed-environments.js";
|
||||
@@ -18,6 +19,8 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
}, 90_000);
|
||||
afterAll(async () => { await database?.cleanup(); });
|
||||
beforeEach(async () => {
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
await db.delete(instanceSettings);
|
||||
await db.delete(environments);
|
||||
const [sandbox] = await db.insert(environments).values({
|
||||
@@ -47,6 +50,13 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect(await instanceDefault()).toBe(sandboxId);
|
||||
});
|
||||
|
||||
it("fills with the managed sandbox while active agents follow the default (a sandbox needs no agent setup)", async () => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Managed", issuePrefix: `M${companyId.slice(0, 7)}` });
|
||||
await db.insert(agents).values({ companyId, name: "Ready agent", adapterType: "process", defaultEnvironmentId: null });
|
||||
expect(await fillUnsetInstanceDefault(db, sandboxId)).toEqual({ filled: true, previousDefaultEnvironmentId: null });
|
||||
});
|
||||
|
||||
it("fills a default that names the local environment", async () => {
|
||||
const localId = await environment("Local", "local");
|
||||
await setInstanceDefault(localId);
|
||||
|
||||
@@ -15,6 +15,10 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } fro
|
||||
import { environmentService } from "../services/environments.js";
|
||||
import { MANAGED_CONFIG_ENV_KEY, parseManagedConfigEnv } from "../services/managed-config.js";
|
||||
import { applyManagedEnvironments } from "../services/managed-environments.js";
|
||||
import { fillUnsetInstanceDefault } from "../services/managed-instance-default.js";
|
||||
import { createLifecycleDriver } from "../services/agent-lifecycle-driver.js";
|
||||
import { createAgentLifecycleEffects } from "../services/agent-lifecycle.js";
|
||||
import { createLifecycleStore } from "../modules/agent-lifecycle/adapters/postgres.js";
|
||||
|
||||
const PLUGIN_KEY = "example.remote-host-plugin";
|
||||
const MARKER = `${PLUGIN_KEY}:tasks`;
|
||||
@@ -86,7 +90,8 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
version: "1.0.0",
|
||||
status: "ready",
|
||||
manifestJson: {
|
||||
capabilities: ["environment.drivers.register"],
|
||||
agentLifecycle: true,
|
||||
capabilities: ["agents.lifecycle.manage", "environment.drivers.register"],
|
||||
environmentDrivers: [{ driverKey: "tasks", requiresAgent: true, supportsTasks: true }],
|
||||
} as never,
|
||||
});
|
||||
@@ -252,4 +257,115 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect((await pluginRows())[0]!.status).toBe("active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("instanceDefault (fill only an unset default)", () => {
|
||||
const defaultEntry = { ...remoteHostEntry, instanceDefault: true };
|
||||
const boot = (entries: unknown[] = [defaultEntry]) =>
|
||||
applyManagedEnvironments(db, managedConfig(entries), { env: {}, workerManager, autoInstalledPluginKeys: [PLUGIN_KEY] });
|
||||
const instanceDefault = async () =>
|
||||
(await db.select().from(instanceSettings).where(eq(instanceSettings.singletonKey, "default")))[0]?.defaultEnvironmentId ?? null;
|
||||
const setInstanceDefault = async (environmentId: string | null) => {
|
||||
const [row] = await db.select().from(instanceSettings).where(eq(instanceSettings.singletonKey, "default"));
|
||||
if (row) await db.update(instanceSettings).set({ defaultEnvironmentId: environmentId }).where(eq(instanceSettings.id, row.id));
|
||||
else await db.insert(instanceSettings).values({ singletonKey: "default", defaultEnvironmentId: environmentId });
|
||||
};
|
||||
const sandboxRow = async () => {
|
||||
const [row] = await db.insert(environments).values({
|
||||
name: "Managed Sandbox", driver: "sandbox", status: "active", config: { provider: "daytona" },
|
||||
metadata: { managedByPaperclip: true, managedSandboxProvider: "daytona" },
|
||||
}).returning();
|
||||
return row!.id;
|
||||
};
|
||||
const agent = async (companyId: string, defaultEnvironmentId: string | null,
|
||||
lifecycle: Partial<Pick<typeof agents.$inferInsert, "lifecycleState" | "lifecycleRequiredPluginIds">> = {}) => {
|
||||
const id = randomUUID();
|
||||
await db.insert(agents).values({ id, companyId, name: `Agent ${id}`, adapterType: "process", defaultEnvironmentId, ...lifecycle });
|
||||
return id;
|
||||
};
|
||||
|
||||
it("makes the plugin row the default on a new instance (unset default)", async () => {
|
||||
await boot();
|
||||
const [row] = await pluginRows();
|
||||
expect(await instanceDefault()).toBe(row!.id);
|
||||
});
|
||||
|
||||
it("keeps an existing instance's default, which the managed sandbox already filled", async () => {
|
||||
const sandboxId = await sandboxRow();
|
||||
await setInstanceDefault(sandboxId);
|
||||
await agent(await company(), null);
|
||||
await boot();
|
||||
await boot();
|
||||
expect(await instanceDefault()).toBe(sandboxId);
|
||||
});
|
||||
|
||||
it("keeps an admin's choice", async () => {
|
||||
const [chosen] = await db.insert(environments).values({ name: "Admin choice", driver: "ssh", status: "active", config: {} }).returning();
|
||||
await setInstanceDefault(chosen!.id);
|
||||
await boot();
|
||||
expect(await instanceDefault()).toBe(chosen!.id);
|
||||
});
|
||||
|
||||
it("never takes over later when the plugin was down at the first boot and the sandbox filled", async () => {
|
||||
running = false;
|
||||
await boot();
|
||||
expect(await instanceDefault()).toBeNull();
|
||||
// The same boot's managed sandbox fill (under managed-sandbox-only).
|
||||
const sandboxId = await sandboxRow();
|
||||
await fillUnsetInstanceDefault(db, sandboxId);
|
||||
|
||||
// The plugin recovers mid-process, then on a later boot: no fill either way.
|
||||
running = true;
|
||||
for (const listener of [...readyListeners]) listener({ pluginId });
|
||||
await vi.waitFor(async () => expect((await pluginRows())[0]?.status).toBe("active"));
|
||||
await boot();
|
||||
expect(await instanceDefault()).toBe(sandboxId);
|
||||
});
|
||||
|
||||
it("declines an admin-cleared default while an active agent that follows it lacks the plugin's setup", async () => {
|
||||
await setInstanceDefault(null);
|
||||
await agent(await company(), null);
|
||||
await boot();
|
||||
expect(await instanceDefault()).toBeNull();
|
||||
// The same boot's managed sandbox fill still fills it.
|
||||
const sandboxId = await sandboxRow();
|
||||
expect(await fillUnsetInstanceDefault(db, sandboxId)).toMatchObject({ filled: true });
|
||||
expect(await instanceDefault()).toBe(sandboxId);
|
||||
});
|
||||
|
||||
it("declines a local default the same way", async () => {
|
||||
const [local] = await db.insert(environments).values({ name: "Local", driver: "local", status: "active", config: {} }).returning();
|
||||
await setInstanceDefault(local!.id);
|
||||
await agent(await company(), null, { lifecycleState: "verifying" });
|
||||
await boot();
|
||||
expect(await instanceDefault()).toBe(local!.id);
|
||||
});
|
||||
|
||||
it("fills when no active agent following the default needs the plugin's setup", async () => {
|
||||
const companyId = await company();
|
||||
const sandboxId = await sandboxRow();
|
||||
await agent(companyId, null, { lifecycleState: "paused" });
|
||||
await agent(companyId, null, { lifecycleRequiredPluginIds: [pluginId] });
|
||||
await agent(companyId, sandboxId);
|
||||
await setInstanceDefault(null);
|
||||
await boot();
|
||||
const [row] = await pluginRows();
|
||||
expect(await instanceDefault()).toBe(row!.id);
|
||||
});
|
||||
|
||||
it("does nothing without the flag", async () => {
|
||||
await boot([remoteHostEntry]);
|
||||
expect(await instanceDefault()).toBeNull();
|
||||
});
|
||||
|
||||
it("prepares a new agent that follows the default with the plugin, and one set to the sandbox without it", async () => {
|
||||
await boot();
|
||||
const sandboxId = await sandboxRow();
|
||||
const companyId = await company();
|
||||
const selection = async (id: string) =>
|
||||
createLifecycleDriver(db, { call } as never)
|
||||
.requiredPluginIds((await createLifecycleStore(db, createAgentLifecycleEffects()).get(id))!);
|
||||
expect(await selection(await agent(companyId, null))).toEqual([pluginId]);
|
||||
expect(await selection(await agent(companyId, sandboxId))).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{ "schemaVersion": 1, "capabilities": ["pluginEnvironments"] }
|
||||
@@ -0,0 +1,27 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MANAGED_CONFIG_CAPABILITIES,
|
||||
MANAGED_CONFIG_CAPABILITIES_SCHEMA_VERSION,
|
||||
} from "./managed-config.js";
|
||||
|
||||
// The control plane fetches this file at a release commit to learn which
|
||||
// managed-config sections the release accepts. It must match the parser's
|
||||
// own list exactly.
|
||||
const capabilitiesFile = new URL("./managed-config-capabilities.json", import.meta.url);
|
||||
|
||||
describe("managed-config-capabilities.json", () => {
|
||||
it("matches the capabilities the parser supports", () => {
|
||||
expect(JSON.parse(readFileSync(capabilitiesFile, "utf8"))).toEqual({
|
||||
schemaVersion: MANAGED_CONFIG_CAPABILITIES_SCHEMA_VERSION,
|
||||
capabilities: [...MANAGED_CONFIG_CAPABILITIES],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the published contract", () => {
|
||||
expect(JSON.parse(readFileSync(capabilitiesFile, "utf8"))).toEqual({
|
||||
schemaVersion: 1,
|
||||
capabilities: ["pluginEnvironments"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,8 @@
|
||||
* "driver": "plugin",
|
||||
* "pluginKey": "example.remote-host-plugin",
|
||||
* "driverKey": "tasks",
|
||||
* "driverConfig": {}
|
||||
* "driverConfig": {},
|
||||
* "instanceDefault": true
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
@@ -81,6 +82,25 @@ export interface ManagedInstanceConfig {
|
||||
* before plugin entries existed parse unchanged.
|
||||
*/
|
||||
export const MANAGED_ENVIRONMENT_DRIVERS = ["sandbox", "plugin"] as const;
|
||||
|
||||
/**
|
||||
* The managed-config capabilities this build supports. The control plane
|
||||
* reads them from `managed-config-capabilities.json` (next to this file) at
|
||||
* a release commit, and sends a section only to a release that lists its
|
||||
* capability. `managed-config-capabilities.test.ts` keeps that file equal to
|
||||
* this list, and the parser accepts each section only while its capability
|
||||
* is listed here, so the two cannot drift.
|
||||
*
|
||||
* - `pluginEnvironments`: `driver: "plugin"` entries in `environments`,
|
||||
* including `instanceDefault`.
|
||||
*/
|
||||
export const MANAGED_CONFIG_CAPABILITIES_SCHEMA_VERSION = 1;
|
||||
export const MANAGED_CONFIG_CAPABILITIES = ["pluginEnvironments"] as const;
|
||||
export type ManagedConfigCapability = (typeof MANAGED_CONFIG_CAPABILITIES)[number];
|
||||
|
||||
function supportsCapability(capability: ManagedConfigCapability): boolean {
|
||||
return (MANAGED_CONFIG_CAPABILITIES as readonly string[]).includes(capability);
|
||||
}
|
||||
export type ManagedEnvironmentDriver = (typeof MANAGED_ENVIRONMENT_DRIVERS)[number];
|
||||
|
||||
/**
|
||||
@@ -106,6 +126,13 @@ export interface ManagedPluginEnvironmentSpec {
|
||||
driverKey: string;
|
||||
/** Driver config, validated by the plugin at ensure time; never carries secrets. */
|
||||
driverConfig: Readonly<Record<string, unknown>>;
|
||||
/**
|
||||
* Fill the instance default with this environment when the default is
|
||||
* unset (or names the local environment or a deleted one), before the
|
||||
* managed sandbox fills it. A set default is never changed. See
|
||||
* `fillUnsetInstanceDefault`.
|
||||
*/
|
||||
instanceDefault: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,6 +230,7 @@ const PLUGIN_ENTRY_KEYS = [
|
||||
"pluginKey",
|
||||
"driverKey",
|
||||
"driverConfig",
|
||||
"instanceDefault",
|
||||
] as const;
|
||||
/** Same rule as the plugin environment config schema in `environment-config.ts`. */
|
||||
const PLUGIN_DRIVER_KEY_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
||||
@@ -254,6 +282,7 @@ function parseManagedEnvironmentsSection(
|
||||
}
|
||||
const names = new Set<string>();
|
||||
const pluginDrivers = new Set<string>();
|
||||
let instanceDefaultIndex: number | null = null;
|
||||
for (const [index, entry] of raw.entries()) {
|
||||
const at = `environments[${index}]`;
|
||||
if (!isPlainObject(entry)) {
|
||||
@@ -266,6 +295,14 @@ function parseManagedEnvironmentsSection(
|
||||
`"${at}.driver" must be one of ${MANAGED_ENVIRONMENT_DRIVERS.map((value) => JSON.stringify(value)).join(", ")} when present (got ${describeJsonValue(entry.driver)})`,
|
||||
);
|
||||
}
|
||||
if (driver === "plugin" && !supportsCapability("pluginEnvironments")) {
|
||||
fail(`"${at}.driver" is "plugin", but this build does not list the "pluginEnvironments" managed-config capability`);
|
||||
}
|
||||
if (driver === "sandbox" && entry.instanceDefault !== undefined) {
|
||||
fail(
|
||||
`"${at}.instanceDefault" is not allowed on the sandbox entry; only a plugin entry may become the instance default (the sandbox entry's default is governed by enableManagedSandboxOnly)`,
|
||||
);
|
||||
}
|
||||
const allowedKeys: readonly string[] = driver === "plugin" ? PLUGIN_ENTRY_KEYS : SANDBOX_ENTRY_KEYS;
|
||||
for (const key of Object.keys(entry)) {
|
||||
if (!allowedKeys.includes(key)) {
|
||||
@@ -330,6 +367,18 @@ function parseManagedEnvironmentsSection(
|
||||
}
|
||||
pluginDrivers.add(pair);
|
||||
const driverConfig = readSecretFreeObject(entry.driverConfig, `${at}.driverConfig`);
|
||||
if (entry.instanceDefault !== undefined && typeof entry.instanceDefault !== "boolean") {
|
||||
fail(`"${at}.instanceDefault" must be a boolean when present (got ${describeJsonValue(entry.instanceDefault)})`);
|
||||
}
|
||||
const instanceDefault = entry.instanceDefault === true;
|
||||
if (instanceDefault) {
|
||||
if (instanceDefaultIndex !== null) {
|
||||
fail(
|
||||
`"${at}.instanceDefault" is also set on environments[${instanceDefaultIndex}]; at most one entry may become the instance default`,
|
||||
);
|
||||
}
|
||||
instanceDefaultIndex = index;
|
||||
}
|
||||
plugin.push(
|
||||
Object.freeze({
|
||||
driver: "plugin",
|
||||
@@ -338,6 +387,7 @@ function parseManagedEnvironmentsSection(
|
||||
pluginKey,
|
||||
driverKey,
|
||||
driverConfig: Object.freeze(driverConfig),
|
||||
instanceDefault,
|
||||
}) as ManagedPluginEnvironmentSpec,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -404,9 +404,8 @@ describe("applyManagedEnvironments plugin entries", () => {
|
||||
expect(environments.archiveManagedPluginEnvironment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never fills the instance default with a plugin environment", async () => {
|
||||
// Only the managed sandbox fills an unset default, through the locked
|
||||
// fill, which keeps any set default (a plugin environment included).
|
||||
it("never fills the instance default with a plugin entry that does not declare instanceDefault", async () => {
|
||||
// Without the flag, only the managed sandbox fills an unset default.
|
||||
const fillInstanceDefault = vi.fn(async () => ({ filled: false, previousDefaultEnvironmentId: "plugin-env" }));
|
||||
await applyManagedEnvironments(noDb, configWith(
|
||||
[{ name: "Daytona", provider: "daytona" }, pluginEntry()],
|
||||
@@ -424,6 +423,99 @@ describe("applyManagedEnvironments plugin entries", () => {
|
||||
expect(fillInstanceDefault).toHaveBeenCalledWith("env-1");
|
||||
});
|
||||
|
||||
describe("instanceDefault", () => {
|
||||
const sandboxAndComputer = () => configWith(
|
||||
[{ name: "Daytona", provider: "daytona" }, pluginEntry({ instanceDefault: true })],
|
||||
{ enableManagedSandboxOnly: true },
|
||||
);
|
||||
|
||||
it("fills an unset default with the plugin row first, then lets the sandbox fill", async () => {
|
||||
// The locked fill decides; here it reports a fill each time it is asked.
|
||||
const fillInstanceDefault = vi.fn(async (_environmentId: string) => ({ filled: true, previousDefaultEnvironmentId: null }));
|
||||
await applyManagedEnvironments(noDb, sandboxAndComputer(), {
|
||||
env: {},
|
||||
workerManager: workerManager(),
|
||||
fillInstanceDefault,
|
||||
environments: environmentsSeam(),
|
||||
resolveSandboxProviderDriver: readyDriverResolver(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
// The plugin row goes first, even though the sandbox entry is ensured
|
||||
// first; on a new instance the plugin fill wins and the sandbox fill
|
||||
// then finds the default set.
|
||||
expect(fillInstanceDefault.mock.calls.map(([environmentId]) => environmentId)).toEqual(["plugin-env", "env-1"]);
|
||||
});
|
||||
|
||||
it("lets the sandbox fill when the plugin fill declines because agents need setup", async () => {
|
||||
const fillInstanceDefault = vi.fn(async (environmentId: string) => environmentId === "plugin-env"
|
||||
? { filled: false, previousDefaultEnvironmentId: null, declined: "agents_need_setup" as const }
|
||||
: { filled: true, previousDefaultEnvironmentId: null });
|
||||
await applyManagedEnvironments(noDb, sandboxAndComputer(), {
|
||||
env: {},
|
||||
workerManager: workerManager(),
|
||||
fillInstanceDefault,
|
||||
environments: environmentsSeam(),
|
||||
resolveSandboxProviderDriver: readyDriverResolver(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
expect(fillInstanceDefault.mock.calls.map(([environmentId]) => environmentId)).toEqual(["plugin-env", "env-1"]);
|
||||
expect(await fillInstanceDefault.mock.results[1]!.value).toMatchObject({ filled: true });
|
||||
});
|
||||
|
||||
it("does not fill with the plugin row when its plugin is down, and recovery never fills", async () => {
|
||||
const { handle, fireReady } = recoveryHandle();
|
||||
const fillInstanceDefault = vi.fn(async () => ({ filled: true, previousDefaultEnvironmentId: null }));
|
||||
const environments = environmentsSeam();
|
||||
const manager = workerManager(false, handle);
|
||||
await applyManagedEnvironments(noDb, configWith([pluginEntry({ instanceDefault: true })]), {
|
||||
env: {},
|
||||
workerManager: manager,
|
||||
fillInstanceDefault,
|
||||
environments,
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
expect(fillInstanceDefault).not.toHaveBeenCalled();
|
||||
|
||||
manager.isRunning.mockReturnValue(true);
|
||||
fireReady();
|
||||
await tick();
|
||||
expect(environments.ensureManagedPluginEnvironment).toHaveBeenCalledTimes(1);
|
||||
expect(fillInstanceDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fill with the plugin row when its ensure fails", async () => {
|
||||
const fillInstanceDefault = vi.fn(async () => ({ filled: true, previousDefaultEnvironmentId: null }));
|
||||
await applyManagedEnvironments(noDb, sandboxAndComputer(), {
|
||||
env: {},
|
||||
workerManager: workerManager(),
|
||||
fillInstanceDefault,
|
||||
environments: environmentsSeam({
|
||||
ensureManagedPluginEnvironment: vi.fn().mockRejectedValue(unprocessable("driverConfig.region is not supported")),
|
||||
}),
|
||||
resolveSandboxProviderDriver: readyDriverResolver(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
// Only the sandbox fill runs.
|
||||
expect(fillInstanceDefault.mock.calls).toEqual([["env-1"]]);
|
||||
});
|
||||
|
||||
it("keeps the plugin ensure counted when its fill fails", async () => {
|
||||
const result = await applyManagedEnvironments(noDb, configWith([pluginEntry({ instanceDefault: true })]), {
|
||||
env: {},
|
||||
workerManager: workerManager(),
|
||||
fillInstanceDefault: vi.fn().mockRejectedValue(new Error("db exploded")),
|
||||
environments: environmentsSeam(),
|
||||
resolveEnvironmentPlugin: readyPlugin(),
|
||||
autoInstalledPluginKeys: [PLUGIN_KEY],
|
||||
});
|
||||
expect(result).toMatchObject({ ensured: 1, failed: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
it("is fail-safe: an ensure failure is logged and counted, not thrown", async () => {
|
||||
const result = await applyManagedEnvironments(noDb, configWith([pluginEntry()]), {
|
||||
env: {},
|
||||
|
||||
@@ -48,8 +48,14 @@
|
||||
* 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.
|
||||
* `managed-plugin-environments.ts`).
|
||||
*
|
||||
* Instance default: boot fills an unset, local or deleted default once, under
|
||||
* a lock (`fillUnsetInstanceDefault`), and never changes a set one. A plugin
|
||||
* entry with `instanceDefault` fills first, right after its ensure; the
|
||||
* managed sandbox (under `enableManagedSandboxOnly`) fills after all plugin
|
||||
* entries. So a new instance gets the plugin environment, and an existing
|
||||
* instance, whose default is already set, keeps its default.
|
||||
*/
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
@@ -302,6 +308,11 @@ export async function applyManagedEnvironments(
|
||||
* on. The write is one locked transaction (`fillUnsetInstanceDefault`), so
|
||||
* overlapping boots and admin writes cannot overwrite each other.
|
||||
*
|
||||
* Runs once, after the plugin entries: a plugin entry with
|
||||
* `instanceDefault` fills an unset default first (see
|
||||
* `fillPluginInstanceDefault`), so on a new instance it wins, and on an
|
||||
* existing one (whose default is already set) neither fill changes it.
|
||||
*
|
||||
* Best-effort: a failure degrades to the run-time policy, which refuses
|
||||
* local under managed-sandbox-only regardless.
|
||||
*/
|
||||
@@ -322,6 +333,41 @@ export async function applyManagedEnvironments(
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A plugin entry with `instanceDefault` fills the instance default with its
|
||||
* row right after a successful boot ensure, through the same locked fill:
|
||||
* only an unset, local or deleted default is replaced, and a set default
|
||||
* (an existing instance's managed sandbox, an admin's choice) never is.
|
||||
* Like an admin's write, the fill declines while active agents following
|
||||
* the default lack the plugin's setup; the sandbox fill then runs as usual.
|
||||
* Recovery after boot never fills. Best-effort, like the sandbox fill.
|
||||
*/
|
||||
const fillPluginInstanceDefault = async (
|
||||
spec: ManagedInstanceConfig["pluginEnvironments"][number],
|
||||
environmentId: string,
|
||||
): Promise<void> => {
|
||||
if (!spec.instanceDefault) return;
|
||||
try {
|
||||
const { filled, previousDefaultEnvironmentId, declined } = await fillInstanceDefault(environmentId);
|
||||
if (declined) {
|
||||
logger.warn(
|
||||
{ environmentId, name: spec.name, previousDefaultEnvironmentId, declined },
|
||||
"kept the instance default: agents that follow it need setup for the managed plugin environment first",
|
||||
);
|
||||
}
|
||||
if (!filled) return;
|
||||
logger.info(
|
||||
{ environmentId, name: spec.name, previousDefaultEnvironmentId },
|
||||
"instance default environment set to the managed plugin environment",
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, environmentId, name: spec.name },
|
||||
"failed to fill the instance default with the managed plugin 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
|
||||
// re-emits `ready` when it respawns or replaces the worker. A one-shot
|
||||
@@ -405,6 +451,7 @@ export async function applyManagedEnvironments(
|
||||
let unchanged = 0;
|
||||
let skipped = 0;
|
||||
const outcomes: ManagedEnvironmentReconciliationOutcome[] = [];
|
||||
let sandboxDefaultCandidate: string | null = null;
|
||||
for (const spec of managedConfig.environments) {
|
||||
try {
|
||||
const resolved = await resolveDriver({ db, driverKey: spec.provider });
|
||||
@@ -469,7 +516,8 @@ export async function applyManagedEnvironments(
|
||||
stockStatus: reconciliation.stockStatus,
|
||||
updateAvailable: reconciliation.updateAvailable,
|
||||
});
|
||||
await ensureManagedInstanceDefault(reconciliation.environment);
|
||||
// Filled after the plugin entries (see `ensureManagedInstanceDefault`).
|
||||
sandboxDefaultCandidate = reconciliation.environment.id;
|
||||
const logContext = {
|
||||
environmentId: reconciliation.environment.id,
|
||||
name: reconciliation.environment.name,
|
||||
@@ -635,6 +683,7 @@ export async function applyManagedEnvironments(
|
||||
{ ...logBase, environmentId: result.environment.id, action: result.action },
|
||||
"managed plugin environment reconciled",
|
||||
);
|
||||
await fillPluginInstanceDefault(spec, result.environment.id);
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
if (recoverable && !isPermanentConfigRejection(err)) {
|
||||
@@ -667,6 +716,9 @@ export async function applyManagedEnvironments(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (sandboxDefaultCandidate) await ensureManagedInstanceDefault({ id: sandboxDefaultCandidate });
|
||||
|
||||
return {
|
||||
ensured,
|
||||
failed,
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
* default write, can never overwrite a value that another transaction just
|
||||
* committed: whichever commits first wins, and the other sees a set default.
|
||||
*
|
||||
* The fill obeys the same readiness rule as an admin's default write
|
||||
* (`assertAgentsReadyForEnvironmentChange`): it declines a target that would
|
||||
* give active agents following the default a plugin that has not prepared
|
||||
* them. A managed sandbox target never needs agent setup, so this only ever
|
||||
* declines a plugin environment.
|
||||
*
|
||||
* Once filled, the default is an ordinary set value. Nothing reverts it
|
||||
* automatically; an admin can change it in instance settings.
|
||||
*/
|
||||
@@ -19,6 +25,7 @@ import { eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { environments, instanceSettings } from "@paperclipai/db";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import { agentsNeedSetupForEnvironmentChange } from "./plugin-environment-driver.js";
|
||||
|
||||
/** Singleton key of the instance settings row that holds `defaultEnvironmentId`. */
|
||||
const INSTANCE_DEFAULT_SETTINGS_KEY = "default";
|
||||
@@ -26,11 +33,14 @@ const INSTANCE_DEFAULT_SETTINGS_KEY = "default";
|
||||
export interface FillInstanceDefaultResult {
|
||||
filled: boolean;
|
||||
previousDefaultEnvironmentId: string | null;
|
||||
/** Set when the default was fillable but active agents following it need setup for the target first. */
|
||||
declined?: "agents_need_setup";
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the instance default at `environmentId` only if the current default
|
||||
* is unset, the local environment, or an environment that no longer exists.
|
||||
* is unset, the local environment, or an environment that no longer exists,
|
||||
* and no active agent following the default would need setup for it first.
|
||||
*/
|
||||
export async function fillUnsetInstanceDefault(db: Db, environmentId: string): Promise<FillInstanceDefaultResult> {
|
||||
return await db.transaction(async (tx) => {
|
||||
@@ -45,14 +55,17 @@ export async function fillUnsetInstanceDefault(db: Db, environmentId: string): P
|
||||
.for("update");
|
||||
const current = defaultRow?.defaultEnvironmentId ?? null;
|
||||
if (current === environmentId) return { filled: false, previousDefaultEnvironmentId: current };
|
||||
if (current !== null) {
|
||||
const [currentEnvironment] = await tx
|
||||
.select({ driver: environments.driver })
|
||||
.from(environments)
|
||||
.where(eq(environments.id, current));
|
||||
if (currentEnvironment && currentEnvironment.driver !== "local") {
|
||||
return { filled: false, previousDefaultEnvironmentId: current };
|
||||
}
|
||||
const environmentRef = async (id: string) => (await tx
|
||||
.select({ driver: environments.driver, config: environments.config })
|
||||
.from(environments)
|
||||
.where(eq(environments.id, id)))[0] ?? null;
|
||||
const currentEnvironment = current === null ? null : await environmentRef(current);
|
||||
if (currentEnvironment && currentEnvironment.driver !== "local") {
|
||||
return { filled: false, previousDefaultEnvironmentId: current };
|
||||
}
|
||||
if (await agentsNeedSetupForEnvironmentChange(tx as unknown as Db, currentEnvironment, await environmentRef(environmentId),
|
||||
{ inheritsDefault: true })) {
|
||||
return { filled: false, previousDefaultEnvironmentId: current, declined: "agents_need_setup" };
|
||||
}
|
||||
await settings.update({ defaultEnvironmentId: environmentId }, { db: tx });
|
||||
return { filled: true, previousDefaultEnvironmentId: current };
|
||||
|
||||
@@ -56,18 +56,24 @@ export async function pluginForAgentEnvironment(db: Db, environment: AgentEnviro
|
||||
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,
|
||||
/** Whether a saved environment change would give active agents a plugin that has not prepared them. */
|
||||
export async function agentsNeedSetupForEnvironmentChange(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;
|
||||
if (!plugin || (before && (await pluginForAgentEnvironment(db, before))?.id === plugin.id)) return false;
|
||||
const usesEnvironment = or(users.environmentId ? eq(agents.defaultEnvironmentId, users.environmentId) : undefined,
|
||||
users.inheritsDefault ? isNull(agents.defaultEnvironmentId) : undefined);
|
||||
if (!usesEnvironment) return;
|
||||
if (!usesEnvironment) return false;
|
||||
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) {
|
||||
return Boolean(waiting);
|
||||
}
|
||||
|
||||
/** 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 }) {
|
||||
if (await agentsNeedSetupForEnvironmentChange(db, before, after, users)) {
|
||||
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" });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user