feat(agents): bulk edit connections and default models (#17215)

Add harness-scoped bulk connection and default-model edits to the agent roster, with compatible choices, change previews, per-agent save results, and retries.

Use an icon-based harness filter, keep agent rows readable, remove the status selector and selection hint, and redirect legacy status URLs to the full roster.

Preserve authorization, audit history, unrelated settings, and optimistic concurrency checks through the existing agent update route.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-22 18:47:06 -05:00
1 parent 46760cc588
commit 9bf7dc0c8f
28 files changed
+1245 -223

No files matched your search

@@ -48,6 +48,15 @@ const support = await getEmbeddedPostgresTestSupport();
async function hire(status = "idle") { return createAgentLifecycle(db).requestHire(companyId, { name: "Agent", status, adapterType: "process" }); }
async function current(id: string) { return (await db.select().from(agents).where(eq(agents.id, id)))[0]!; }
it("rejects an outdated configuration snapshot under the write lock", async () => {
const agent = await hire();
const snapshot = await current(agent.id);
const expectedConfiguration = { adapterType: snapshot.adapterType, adapterConfig: snapshot.adapterConfig, runtimeConfig: snapshot.runtimeConfig, defaultEnvironmentId: snapshot.defaultEnvironmentId };
await createAgentLifecycle(db).updateConfiguration(agent.id, { runtimeConfig: { heartbeat: { intervalSec: 42 } } });
await expect(createAgentLifecycle(db).updateConfiguration(agent.id, { runtimeConfig: { aiConnection: {} } }, { expectedConfiguration })).rejects.toThrow("configuration changed while saving");
expect((await current(agent.id)).runtimeConfig).toEqual({ heartbeat: { intervalSec: 42 } });
});
it("maps existing states without starting setup or clearing pause reasons", async () => {
const rows = await db.insert(agents).values(["idle", "running", "error", "paused", "pending_approval", "terminated"].map(status => ({
companyId, name: status, status, pauseReason: status === "paused" ? "budget" : null,
@@ -1,3 +1,5 @@
import { isDeepStrictEqual } from "node:util";
import { conflict } from "../../../errors.js";
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { agents, type Db } from "@paperclipai/db";
@@ -11,6 +13,10 @@ export async function updateAgentConfiguration(tx: Db, effects: LifecycleEffects
assertConfigurationFields(data);
const [current] = await tx.select().from(agents).where(eq(agents.id, id)).for("no key update");
if (!current) return null;
if (options?.expectedConfiguration && Object.entries(options.expectedConfiguration).some(([key, value]) =>
!isDeepStrictEqual(current[key as keyof typeof current], value))) {
throw conflict("Agent configuration changed while saving. Refresh the roster and review the changes again.");
}
const patch = await effects.prepareConfiguration(tx, current, data, options);
assertConfigurationFields(patch);
const changed = ["adapterType", "adapterConfig", "runtimeConfig", "defaultEnvironmentId"].some(key =>