mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
Keep setup tests aligned with saved configuration
Pass the measured Claude runner to connection tests, cancel stale detail reads before publishing mutation results, and retain environment-save errors across test resets. Move setup lifecycle cases into their own suite to keep the original file below the repository size threshold. Verified 133 UI tests, 61 lifecycle and policy tests, UI TypeScript, server TypeScript pinned to this branch shared declarations, token gates, map checks, and whitespace. The new UI regressions fail against the previous implementation. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
5 files changed
+206
-123
No files matched your search
@@ -99,7 +99,7 @@ For an agent-owned plugin environment, verify preparation before connecting,
|
||||
same-agent probes, account preservation after an environment change, failed
|
||||
selection-save retry, and switching back while the computer stops. Confirm the
|
||||
setup hold still blocks ordinary work and API key creation before finishing.
|
||||
The [lifecycle tests](../../server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts)
|
||||
The [setup lifecycle tests](../../server/src/modules/agent-lifecycle/__tests__/setup.test.ts)
|
||||
exercise durable transitions with synthetic plugin resources; they do not prove
|
||||
live VM provisioning.
|
||||
|
||||
|
||||
@@ -116,102 +116,8 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect(agent).not.toHaveProperty("lifecycleOperation");
|
||||
});
|
||||
|
||||
it("keeps early setup inactive and completes the same record without renaming it", async () => {
|
||||
const runHost = vi.fn(async () => "complete" as const);
|
||||
const work = worker(runHost);
|
||||
const lifecycle = createAgentLifecycle(db);
|
||||
const id = randomUUID();
|
||||
const input = { id, name: "Early agent", adapterType: "process" };
|
||||
const first = await lifecycle.requestHire(companyId, input, { setupOnly: true });
|
||||
const retry = await lifecycle.requestHire(companyId, input, { setupOnly: true });
|
||||
expect(first.id).toBe(id);
|
||||
expect(retry.id).toBe(id);
|
||||
await work.process(id);
|
||||
expect(runHost).not.toHaveBeenCalled();
|
||||
expect(await current(id)).toMatchObject({ status: "paused", lifecycleState: "paused", lifecycleHolds: ["setup"] });
|
||||
expect(await db.select().from(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, id))).toHaveLength(0);
|
||||
await expect(agentConfiguration(db).createApiKey(id, "Incomplete setup")).rejects.toThrow("Finish agent setup");
|
||||
await expect(lifecycle.resumeAgent(id)).rejects.toThrow("Finish agent setup");
|
||||
const finished = await lifecycle.requestHire(companyId, { id, name: "Early agent", adapterType: "process", status: "idle" }, { setupAgentId: id });
|
||||
expect(finished).toMatchObject({ id, name: "Early agent" });
|
||||
await work.process(id);
|
||||
expect(await current(id)).toMatchObject({ status: "idle", lifecycleState: "ready", lifecycleHolds: [] });
|
||||
expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(1);
|
||||
await expect(lifecycle.requestHire(companyId, { id, name: "Early agent" }, { setupAgentId: id })).rejects.toThrow("no longer available");
|
||||
});
|
||||
|
||||
it("prepares agent-dependent drafts, stays idle until finish, and invalidates changed environments", async () => {
|
||||
const pluginId = randomUUID(), environmentId = randomUUID();
|
||||
await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "agent-environment-test", 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: environmentId, name: "Agent-owned test computer", driver: "plugin", status: "active",
|
||||
config: { pluginKey: pluginId, driverKey: "tasks", driverConfig: {} } });
|
||||
const call = vi.fn(async (_id, _method, input) => ({ operationId: input.operationId, version: input.version, status: "complete" }));
|
||||
const driver = createLifecycleDriver(db, { call } as never);
|
||||
const runHost = vi.fn(async () => "complete" as const);
|
||||
const work = configureAgentLifecycle(db, { ...driver, requiredPluginIds: async () => [pluginId], runHost });
|
||||
workers.push(work);
|
||||
const lifecycle = createAgentLifecycle(db);
|
||||
const id = randomUUID();
|
||||
const input = { id, name: "Early computer", adapterType: "paperclip_runner", adapterConfig: { provider: "codex" }, defaultEnvironmentId: environmentId };
|
||||
const draft = await lifecycle.requestHire(companyId, input, { setupOnly: true, runnerResolved: true });
|
||||
expect(draft).toMatchObject({ lifecycleState: "preparing", status: "paused", pauseReason: "setup" });
|
||||
await work.process(id);
|
||||
expect(await current(id)).toMatchObject({ lifecycleState: "verifying", status: "paused", lifecycleHolds: ["setup"] });
|
||||
expect(call.mock.calls.map(([, , request]) => request.phase)).toEqual(["preparing"]);
|
||||
const store = createLifecycleStore(db);
|
||||
expect(await store.pending(100, new Date())).not.toContain(id);
|
||||
expect(await store.claim(id, "unexpected", new Date())).toBeNull();
|
||||
await work.process(id);
|
||||
expect(runHost).toHaveBeenCalledTimes(1);
|
||||
await expect(agentConfiguration(db).createApiKey(id, "Incomplete computer")).rejects.toThrow("Finish agent setup");
|
||||
await expect(lifecycle.resumeAgent(id)).rejects.toThrow("Finish agent setup");
|
||||
await lifecycle.pauseAgent(id, "budget"); await work.process(id);
|
||||
expect(await current(id)).toMatchObject({ lifecycleState: "paused", lifecycleHolds: ["setup", "budget"] });
|
||||
await lifecycle.updateConfiguration(id, { adapterConfig: { provider: "codex", model: "changed" } });
|
||||
expect((await current(id)).lifecycleState).toBe("paused");
|
||||
await store.change(id, "reconcile"); await work.process(id);
|
||||
expect(await current(id)).toMatchObject({ lifecycleState: "verifying", lifecycleHolds: ["setup"] });
|
||||
await lifecycle.updateConfiguration(id, { adapterConfig: { provider: "codex", model: "prepared" } });
|
||||
await work.process(id);
|
||||
expect((await current(id)).lifecycleState).toBe("verifying");
|
||||
const [local] = await db.select().from(environments).where(eq(environments.driver, "local"));
|
||||
await lifecycle.updateConfiguration(id, { defaultEnvironmentId: local.id }); await work.process(id);
|
||||
expect((await current(id)).lifecycleState).toBe("paused");
|
||||
await lifecycle.updateConfiguration(id, { defaultEnvironmentId: environmentId }); await work.process(id);
|
||||
expect((await current(id)).lifecycleState).toBe("verifying");
|
||||
await lifecycle.updateConfiguration(id, { defaultEnvironmentId: local.id });
|
||||
expect((await current(id)).lifecycleState).toBe("pausing");
|
||||
await lifecycle.updateConfiguration(id, { defaultEnvironmentId: environmentId });
|
||||
await work.process(id);
|
||||
expect((await current(id)).lifecycleState).toBe("verifying");
|
||||
const finished = await lifecycle.requestHire(companyId, input, { setupAgentId: id, runnerResolved: true });
|
||||
expect(finished.id).toBe(id);
|
||||
await work.process(id);
|
||||
expect(await current(id)).toMatchObject({ lifecycleState: "ready", lifecycleHolds: [] });
|
||||
expect(call.mock.calls.map(([, , request]) => request.phase).at(-1)).toBe("verifying");
|
||||
});
|
||||
|
||||
it("does not prepare agent-owned resources before required hire approval", async () => {
|
||||
const pluginId = randomUUID(), environmentId = randomUUID();
|
||||
await db.update(companies).set({ requireBoardApprovalForNewAgents: true }).where(eq(companies.id, companyId));
|
||||
await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "approval-computer-test", 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: environmentId, name: "Approval computer", driver: "plugin", status: "active",
|
||||
config: { pluginKey: pluginId, driverKey: "tasks", driverConfig: {} } });
|
||||
const runHost = vi.fn(async () => "complete" as const);
|
||||
const work = worker(runHost);
|
||||
const lifecycle = createAgentLifecycle(db);
|
||||
const draft = await lifecycle.requestHire(companyId, { name: "Unapproved computer", adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "codex" }, defaultEnvironmentId: environmentId }, { setupOnly: true, runnerResolved: true });
|
||||
await work.process(draft.id);
|
||||
await lifecycle.updateConfiguration(draft.id, { adapterConfig: { provider: "codex", model: "changed" } });
|
||||
await work.process(draft.id);
|
||||
expect(await current(draft.id)).toMatchObject({ lifecycleState: "paused", lifecycleHolds: ["setup"] });
|
||||
expect(runHost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("freezes the final setup configuration for hire approval", async () => {
|
||||
const lifecycle = createAgentLifecycle(db);
|
||||
@@ -233,29 +139,6 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect((await current(setup.id)).lifecycleHolds).toEqual(["setup"]);
|
||||
});
|
||||
|
||||
it("lets only the setup creator configure and retry a stopping draft with creation permission", async () => {
|
||||
await db.insert(companyMemberships).values(["setup-owner", "setup-other"].map(principalId => ({ companyId,
|
||||
principalType: "user", principalId, membershipRole: "member" })));
|
||||
await db.insert(principalPermissionGrants).values(["setup-owner", "setup-other"].map(principalId => ({ companyId,
|
||||
principalType: "user", principalId, permissionKey: "agents:create" })));
|
||||
const lifecycle = createAgentLifecycle(db);
|
||||
const draft = await lifecycle.requestHire(companyId, { name: "Stopping setup", adapterType: "process" },
|
||||
{ setupOnly: true, createdByUserId: "setup-owner" });
|
||||
await db.update(agents).set({ lifecycleState: "pausing" }).where(eq(agents.id, draft.id));
|
||||
function app(userId: string) {
|
||||
const value = express(); value.use(express.json());
|
||||
value.use((req, _res, next) => { req.actor = { type: "board", source: "session", userId,
|
||||
companyIds: [companyId], memberships: [{ companyId, membershipRole: "member", status: "active" }] }; next(); });
|
||||
value.use("/api", agentRoutes(db)); value.use(errorHandler); return value;
|
||||
}
|
||||
const updated = await request(app("setup-owner")).patch(`/api/agents/${draft.id}`).send({ runtimeConfig: { heartbeat: { enabled: false } } });
|
||||
expect(updated.status, JSON.stringify(updated.body)).toBe(200);
|
||||
await request(app("setup-owner")).post(`/api/agents/${draft.id}/lifecycle/retry`).send({}).expect(200);
|
||||
await request(app("setup-other")).patch(`/api/agents/${draft.id}`).send({ runtimeConfig: { heartbeat: { enabled: true } } }).expect(403);
|
||||
await request(app("setup-other")).post(`/api/agents/${draft.id}/lifecycle/retry`).send({}).expect(403);
|
||||
await expect(lifecycle.requestHire(companyId, { name: draft.name }, { setupAgentId: draft.id })).rejects.toThrow("no longer available");
|
||||
expect((await current(draft.id)).lifecycleState).toBe("pausing");
|
||||
});
|
||||
|
||||
it("creates a board-owned setup before requesting approval for its final configuration", async () => {
|
||||
await db.update(companies).set({ requireBoardApprovalForNewAgents: true }).where(eq(companies.id, companyId));
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, companies, companyMemberships, principalPermissionGrants, environments, 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 { createAgentLifecycle, configureAgentLifecycle, createAgentLifecycleEffects } from "../../../services/agent-lifecycle.js";
|
||||
import { agentService as agentConfiguration } from "../../../services/agents.js";
|
||||
import { createLifecycleDriver } from "../../../services/agent-lifecycle-driver.js";
|
||||
import { agentRoutes } from "../../../routes/agents.js";
|
||||
import { errorHandler } from "../../../middleware/error-handler.js";
|
||||
import { createLifecycleStore as createStore } from "../adapters/postgres.js";
|
||||
import type { LifecycleAgent } from "../application/ports.js";
|
||||
|
||||
const createLifecycleStore = (db: Db) => createStore(db, createAgentLifecycleEffects());
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("agent setup lifecycle", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: Db;
|
||||
let companyId: string;
|
||||
const workers: ReturnType<typeof configureAgentLifecycle>[] = [];
|
||||
beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("paperclip-agent-setup-"); 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) await work.stop();
|
||||
companyId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Setup test", issuePrefix: `S${companyId.slice(0, 7)}` });
|
||||
});
|
||||
function worker(runHost: (agent: LifecycleAgent) => Promise<"complete" | "pending"> = async () => "complete") {
|
||||
const work = configureAgentLifecycle(db, { requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost });
|
||||
workers.push(work); return work;
|
||||
}
|
||||
async function current(id: string) { return (await db.select().from(agents).where(eq(agents.id, id)))[0]!; }
|
||||
|
||||
it("keeps early setup inactive and completes the same record without renaming it", async () => {
|
||||
const runHost = vi.fn(async () => "complete" as const);
|
||||
const work = worker(runHost);
|
||||
const lifecycle = createAgentLifecycle(db);
|
||||
const id = randomUUID();
|
||||
const input = { id, name: "Early agent", adapterType: "process" };
|
||||
const first = await lifecycle.requestHire(companyId, input, { setupOnly: true });
|
||||
const retry = await lifecycle.requestHire(companyId, input, { setupOnly: true });
|
||||
expect(first.id).toBe(id);
|
||||
expect(retry.id).toBe(id);
|
||||
await work.process(id);
|
||||
expect(runHost).not.toHaveBeenCalled();
|
||||
expect(await current(id)).toMatchObject({ status: "paused", lifecycleState: "paused", lifecycleHolds: ["setup"] });
|
||||
expect(await db.select().from(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, id))).toHaveLength(0);
|
||||
await expect(agentConfiguration(db).createApiKey(id, "Incomplete setup")).rejects.toThrow("Finish agent setup");
|
||||
await expect(lifecycle.resumeAgent(id)).rejects.toThrow("Finish agent setup");
|
||||
const finished = await lifecycle.requestHire(companyId, { id, name: "Early agent", adapterType: "process", status: "idle" }, { setupAgentId: id });
|
||||
expect(finished).toMatchObject({ id, name: "Early agent" });
|
||||
await work.process(id);
|
||||
expect(await current(id)).toMatchObject({ status: "idle", lifecycleState: "ready", lifecycleHolds: [] });
|
||||
expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(1);
|
||||
await expect(lifecycle.requestHire(companyId, { id, name: "Early agent" }, { setupAgentId: id })).rejects.toThrow("no longer available");
|
||||
});
|
||||
|
||||
it("prepares agent-dependent drafts, stays idle until finish, and invalidates changed environments", async () => {
|
||||
const pluginId = randomUUID(), environmentId = randomUUID();
|
||||
await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "agent-environment-test", 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: environmentId, name: "Agent-owned test computer", driver: "plugin", status: "active",
|
||||
config: { pluginKey: pluginId, driverKey: "tasks", driverConfig: {} } });
|
||||
const call = vi.fn(async (_id, _method, input) => ({ operationId: input.operationId, version: input.version, status: "complete" }));
|
||||
const driver = createLifecycleDriver(db, { call } as never);
|
||||
const runHost = vi.fn(async () => "complete" as const);
|
||||
const work = configureAgentLifecycle(db, { ...driver, requiredPluginIds: async () => [pluginId], runHost });
|
||||
workers.push(work);
|
||||
const lifecycle = createAgentLifecycle(db);
|
||||
const id = randomUUID();
|
||||
const input = { id, name: "Early computer", adapterType: "paperclip_runner", adapterConfig: { provider: "codex" }, defaultEnvironmentId: environmentId };
|
||||
const draft = await lifecycle.requestHire(companyId, input, { setupOnly: true, runnerResolved: true });
|
||||
expect(draft).toMatchObject({ lifecycleState: "preparing", status: "paused", pauseReason: "setup" });
|
||||
await work.process(id);
|
||||
expect(await current(id)).toMatchObject({ lifecycleState: "verifying", status: "paused", lifecycleHolds: ["setup"] });
|
||||
expect(call.mock.calls.map(([, , request]) => request.phase)).toEqual(["preparing"]);
|
||||
const store = createLifecycleStore(db);
|
||||
expect(await store.pending(100, new Date())).not.toContain(id);
|
||||
expect(await store.claim(id, "unexpected", new Date())).toBeNull();
|
||||
await work.process(id);
|
||||
expect(runHost).toHaveBeenCalledTimes(1);
|
||||
await expect(agentConfiguration(db).createApiKey(id, "Incomplete computer")).rejects.toThrow("Finish agent setup");
|
||||
await expect(lifecycle.resumeAgent(id)).rejects.toThrow("Finish agent setup");
|
||||
await lifecycle.pauseAgent(id, "budget"); await work.process(id);
|
||||
expect(await current(id)).toMatchObject({ lifecycleState: "paused", lifecycleHolds: ["setup", "budget"] });
|
||||
await lifecycle.updateConfiguration(id, { adapterConfig: { provider: "codex", model: "changed" } });
|
||||
expect((await current(id)).lifecycleState).toBe("paused");
|
||||
await store.change(id, "reconcile"); await work.process(id);
|
||||
expect(await current(id)).toMatchObject({ lifecycleState: "verifying", lifecycleHolds: ["setup"] });
|
||||
await lifecycle.updateConfiguration(id, { adapterConfig: { provider: "codex", model: "prepared" } });
|
||||
await work.process(id);
|
||||
expect((await current(id)).lifecycleState).toBe("verifying");
|
||||
const [local] = await db.select().from(environments).where(eq(environments.driver, "local"));
|
||||
await lifecycle.updateConfiguration(id, { defaultEnvironmentId: local.id }); await work.process(id);
|
||||
expect((await current(id)).lifecycleState).toBe("paused");
|
||||
await lifecycle.updateConfiguration(id, { defaultEnvironmentId: environmentId }); await work.process(id);
|
||||
expect((await current(id)).lifecycleState).toBe("verifying");
|
||||
await lifecycle.updateConfiguration(id, { defaultEnvironmentId: local.id });
|
||||
expect((await current(id)).lifecycleState).toBe("pausing");
|
||||
await lifecycle.updateConfiguration(id, { defaultEnvironmentId: environmentId });
|
||||
await work.process(id);
|
||||
expect((await current(id)).lifecycleState).toBe("verifying");
|
||||
const finished = await lifecycle.requestHire(companyId, input, { setupAgentId: id, runnerResolved: true });
|
||||
expect(finished.id).toBe(id);
|
||||
await work.process(id);
|
||||
expect(await current(id)).toMatchObject({ lifecycleState: "ready", lifecycleHolds: [] });
|
||||
expect(call.mock.calls.map(([, , request]) => request.phase).at(-1)).toBe("verifying");
|
||||
});
|
||||
|
||||
it("does not prepare agent-owned resources before required hire approval", async () => {
|
||||
const pluginId = randomUUID(), environmentId = randomUUID();
|
||||
await db.update(companies).set({ requireBoardApprovalForNewAgents: true }).where(eq(companies.id, companyId));
|
||||
await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "approval-computer-test", 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: environmentId, name: "Approval computer", driver: "plugin", status: "active",
|
||||
config: { pluginKey: pluginId, driverKey: "tasks", driverConfig: {} } });
|
||||
const runHost = vi.fn(async () => "complete" as const);
|
||||
const work = worker(runHost);
|
||||
const lifecycle = createAgentLifecycle(db);
|
||||
const draft = await lifecycle.requestHire(companyId, { name: "Unapproved computer", adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "codex" }, defaultEnvironmentId: environmentId }, { setupOnly: true, runnerResolved: true });
|
||||
await work.process(draft.id);
|
||||
await lifecycle.updateConfiguration(draft.id, { adapterConfig: { provider: "codex", model: "changed" } });
|
||||
await work.process(draft.id);
|
||||
expect(await current(draft.id)).toMatchObject({ lifecycleState: "paused", lifecycleHolds: ["setup"] });
|
||||
expect(runHost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets only the setup creator configure and retry a stopping draft with creation permission", async () => {
|
||||
await db.insert(companyMemberships).values(["setup-owner", "setup-other"].map(principalId => ({ companyId,
|
||||
principalType: "user", principalId, membershipRole: "member" })));
|
||||
await db.insert(principalPermissionGrants).values(["setup-owner", "setup-other"].map(principalId => ({ companyId,
|
||||
principalType: "user", principalId, permissionKey: "agents:create" })));
|
||||
const lifecycle = createAgentLifecycle(db);
|
||||
const draft = await lifecycle.requestHire(companyId, { name: "Stopping setup", adapterType: "process" },
|
||||
{ setupOnly: true, createdByUserId: "setup-owner" });
|
||||
await db.update(agents).set({ lifecycleState: "pausing" }).where(eq(agents.id, draft.id));
|
||||
function app(userId: string) {
|
||||
const value = express(); value.use(express.json());
|
||||
value.use((req, _res, next) => { req.actor = { type: "board", source: "session", userId,
|
||||
companyIds: [companyId], memberships: [{ companyId, membershipRole: "member", status: "active" }] }; next(); });
|
||||
value.use("/api", agentRoutes(db)); value.use(errorHandler); return value;
|
||||
}
|
||||
const updated = await request(app("setup-owner")).patch(`/api/agents/${draft.id}`).send({ runtimeConfig: { heartbeat: { enabled: false } } });
|
||||
expect(updated.status, JSON.stringify(updated.body)).toBe(200);
|
||||
await request(app("setup-owner")).post(`/api/agents/${draft.id}/lifecycle/retry`).send({}).expect(200);
|
||||
await request(app("setup-other")).patch(`/api/agents/${draft.id}`).send({ runtimeConfig: { heartbeat: { enabled: true } } }).expect(403);
|
||||
await request(app("setup-other")).post(`/api/agents/${draft.id}/lifecycle/retry`).send({}).expect(403);
|
||||
await expect(lifecycle.requestHire(companyId, { name: draft.name }, { setupAgentId: draft.id })).rejects.toThrow("no longer available");
|
||||
expect((await current(draft.id)).lifecycleState).toBe("pausing");
|
||||
});
|
||||
});
|
||||
@@ -220,6 +220,7 @@ function Setup({
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [environmentSaving, setEnvironmentSaving] = useState(false);
|
||||
const [environmentSaveError, setEnvironmentSaveError] = useState<string | null>(null);
|
||||
const generation = useRef(0);
|
||||
const qualificationTransition = useRef<{ run: number; companyId: string; environmentId: string | null; runner: AgentRunner } | null>(null);
|
||||
const savingRef = useRef(false);
|
||||
@@ -571,7 +572,8 @@ function Setup({
|
||||
...(["claude", "grok", "copilot", "pi"].includes(runnerProvider) ? { acpxAgent: runnerProvider } : {}),
|
||||
} : {}, defaultEnvironmentId: environmentOverride || (forced.forced || managedOnly ? environmentId : null),
|
||||
runtimeConfig: buildNewAgentRuntimeConfig({ heartbeatEnabled: false }),
|
||||
}).then(agent => {
|
||||
}).then(async agent => {
|
||||
await cache.cancelQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||||
setCreated(agent);
|
||||
cache.setQueryData(queryKeys.agents.detail(agent.id), agent);
|
||||
void cache.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
|
||||
@@ -635,6 +637,7 @@ function Setup({
|
||||
...(nextConnection?.applyStoredClaudeLogin ? { applyStoredClaudeLogin: true } : {}),
|
||||
}, companyId);
|
||||
rememberCredentials();
|
||||
await cache.cancelQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||||
setCreated(agent);
|
||||
cache.setQueryData(queryKeys.agents.detail(agent.id), agent);
|
||||
if (exit) navigate("/agents/all");
|
||||
@@ -663,8 +666,9 @@ function Setup({
|
||||
setEnvironmentOverride(selected);
|
||||
resetTest();
|
||||
setEnvironmentSaving(true);
|
||||
setEnvironmentSaveError(null);
|
||||
try { await saveDraft(connection, false, undefined, selected); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : "Could not save the environment."); }
|
||||
catch (cause) { setEnvironmentSaveError(cause instanceof Error ? cause.message : "Could not save the environment."); }
|
||||
finally { setEnvironmentSaving(false); }
|
||||
}
|
||||
|
||||
@@ -695,7 +699,7 @@ function Setup({
|
||||
agentId: created?.id,
|
||||
adapterType: requestAdapterType,
|
||||
providerAdapter: brandType,
|
||||
...(["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
|
||||
...(["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && (qualified || runner !== "auto") ? { runner: qualified?.runner ?? runner } : {}),
|
||||
adapterConfig: config,
|
||||
testCredentials: pendingCredentials(nextConnection),
|
||||
aiConnection: runtimeAiBinding ?? nextConnection?.aiConnection,
|
||||
@@ -766,6 +770,7 @@ function Setup({
|
||||
});
|
||||
setApiKey("");
|
||||
setConnection(null);
|
||||
await cache.cancelQueries({ queryKey: queryKeys.agents.detail(response.agent.id) });
|
||||
setCreated(response.agent);
|
||||
appearanceDraft.clear();
|
||||
setScreen("saved");
|
||||
@@ -902,7 +907,8 @@ function Setup({
|
||||
<p role="alert" className="text-sm text-destructive">{created.lifecycleError}</p>
|
||||
<Button disabled={busy} onClick={() => {
|
||||
setEnvironmentSaving(true);
|
||||
void agentsApi.retryLifecycle(created.id, companyId).then(agent => {
|
||||
void agentsApi.retryLifecycle(created.id, companyId).then(async agent => {
|
||||
await cache.cancelQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||||
setCreated(agent);
|
||||
cache.setQueryData(queryKeys.agents.detail(agent.id), agent);
|
||||
}).catch(cause => setError(cause instanceof Error ? cause.message : "Could not retry computer preparation."))
|
||||
@@ -1432,7 +1438,8 @@ function Setup({
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{error && !environmentSelectionSaved && <Button disabled={busy} onClick={() => void changeEnvironment()}>Retry saving environment</Button>}
|
||||
{environmentSaveError && <p role="alert" className="text-sm text-destructive">{environmentSaveError}</p>}
|
||||
{environmentSaveError && !environmentSelectionSaved && <Button disabled={busy} onClick={() => void changeEnvironment()}>Retry saving environment</Button>}
|
||||
<div className="flex justify-between border-t border-border py-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="ghost" disabled={busy} onClick={() => void saveAndExit()}>Save & exit</Button>
|
||||
|
||||
@@ -41,12 +41,18 @@ describe("New agent setup", () => {
|
||||
await click("Connect");
|
||||
const originalBinding = api.testEnvironment.mock.calls[0][2].aiConnection;
|
||||
const probes = api.testEnvironment.mock.calls.length;
|
||||
let finishDiscovery!: (adapters: typeof state.adapters) => void;
|
||||
adapterApi.list.mockImplementationOnce(() => new Promise(resolve => { finishDiscovery = resolve; }));
|
||||
api.update.mockRejectedValueOnce(new Error("Environment save unavailable"));
|
||||
const select = container.querySelector<HTMLSelectElement>('[aria-label="Environment"]')!;
|
||||
await act(async () => { select.value = "computer"; select.dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
await settle();
|
||||
expect(container.textContent).toContain("Configure your agent");
|
||||
expect(container.textContent).toContain("Environment save unavailable");
|
||||
await act(async () => finishDiscovery(state.adapters.map((adapter: any) => adapter.type === "codex_local"
|
||||
? { ...adapter, defaultRunner: "paperclip", supportedRunners: ["paperclip", "legacy"] } : adapter)));
|
||||
await settle();
|
||||
expect(container.textContent).toContain("Environment save unavailable");
|
||||
await click("Run test");
|
||||
expect(api.testEnvironment).toHaveBeenCalledTimes(probes);
|
||||
await click("Retry saving environment");
|
||||
@@ -56,6 +62,36 @@ describe("New agent setup", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("ignores a detail read that completes after a newer environment save", async () => {
|
||||
envApi.list.mockResolvedValue([
|
||||
{ id: "local-1", name: "Local", status: "active", driver: "local", config: {} },
|
||||
{ id: "computer", name: "Agent computer", status: "active", driver: "plugin", config: {} },
|
||||
]);
|
||||
await render("codex_local");
|
||||
await click("OpenAIAPI key");
|
||||
await fill("API key", "setup-test-key");
|
||||
await click("Connect");
|
||||
const id = api.create.mock.calls[0][1].setupId;
|
||||
state.params.set("setupAgentId", id);
|
||||
await renderCurrentParams();
|
||||
const key = queryKeys.agents.detail(id);
|
||||
const oldDraft = cache.getQueryData(key);
|
||||
let finishRead!: (agent: unknown) => void;
|
||||
api.get.mockImplementationOnce(() => new Promise(resolve => { finishRead = resolve; }));
|
||||
await act(async () => { void cache.refetchQueries({ queryKey: key }); });
|
||||
expect(finishRead).toBeTypeOf("function");
|
||||
const select = container.querySelector<HTMLSelectElement>('[aria-label="Environment"]')!;
|
||||
await act(async () => { select.value = "computer"; select.dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
await settle();
|
||||
await act(async () => finishRead(oldDraft));
|
||||
await settle();
|
||||
expect(cache.getQueryData(key)).toMatchObject({ defaultEnvironmentId: "computer" });
|
||||
await click("Run test");
|
||||
expect(api.testEnvironment).toHaveBeenLastCalledWith("company-1", expect.any(String), expect.objectContaining({ agentId: id, environmentId: "computer" }));
|
||||
await click("Finish setup");
|
||||
expect(api.hire).toHaveBeenCalledWith("company-1", expect.objectContaining({ setupAgentId: id, defaultEnvironmentId: "computer" }));
|
||||
});
|
||||
|
||||
it("waits for background preparation and tests the same saved computer after it completes", async () => {
|
||||
const id = "00000000-0000-4000-8000-000000000092";
|
||||
const draft = { id, companyId: "company-1", name: "Atlas", adapterType: "paperclip_runner", adapterConfig: { provider: "codex" },
|
||||
@@ -262,6 +298,7 @@ describe("New agent setup", () => {
|
||||
expect(calls).toEqual(["discovery-before", "platform", "discovery-after", "adapter"]);
|
||||
expect(envApi.probe).toHaveBeenCalledExactlyOnceWith("sandbox-1", "company-1");
|
||||
expect(api.testEnvironment).toHaveBeenCalledTimes(1);
|
||||
expect(api.testEnvironment.mock.calls[0][2]).toMatchObject({ agentId: api.create.mock.calls[0][1].setupId, runner: measured });
|
||||
expect(api.hire).toHaveBeenCalledTimes(1);
|
||||
const tested = api.testEnvironment.mock.calls[0][2].adapterConfig;
|
||||
const saved = api.hire.mock.calls[0][1];
|
||||
|
||||
Reference in new issue
Block a user