mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01:00
Prepare agent-owned environments during hiring
Save environment changes without discarding the selected account. Prepare agent-dependent drafts through lifecycle hooks, keep setup probes bound to the saved agent, and leave the worker idle until setup finishes. Ordinary execution remains held. Verified server and UI typechecks, token gates, 109 wizard/helper tests, 93 lifecycle/task tests, and the shared manifest validation suite. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
21 files changed
+231
-38
No files matched your search
@@ -2002,6 +2002,15 @@ extra arguments, model variants, and inherited permission rules require the
|
||||
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
|
||||
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
|
||||
blocked. Environment changes save immediately, preserve the selected account,
|
||||
and invalidate the previous test. Tests without an agent report that prerequisite
|
||||
without allocating a lease. Finishing setup still verifies the saved configuration.
|
||||
|
||||
Pi also requires an explicit `provider/model` before testing or hiring; inactive
|
||||
setup drafts may precede model selection. Install its pinned runtime on the
|
||||
execution host with `paperclipai runtime setup pi` before testing the connection.
|
||||
|
||||
@@ -193,6 +193,8 @@ export interface SandboxProviderCapabilities {
|
||||
export interface PluginEnvironmentDriverDeclaration {
|
||||
/** Plugin-managed skill keys automatically included when this environment is selected. */
|
||||
skillKeys?: string[];
|
||||
/** Leases require a saved agent and its lifecycle preparation, including during setup. */
|
||||
requiresAgent?: boolean;
|
||||
/** Provides Runner execution, resource preparation, and PRP connections via environmentTask. */
|
||||
supportsTasks?: boolean;
|
||||
freshWorkspaceVersion?: 1;
|
||||
|
||||
@@ -44,6 +44,13 @@ describe("plugin manifest validators", () => {
|
||||
}
|
||||
expect(pluginManifestV1Schema.safeParse({ ...manifest, capabilities: ["environment.drivers.register"] }).success).toBe(false);
|
||||
});
|
||||
it("retains the agent prerequisite on environment drivers", () => {
|
||||
const manifest = { ...buildSandboxProviderManifest({ requiresAgent: true }), agentLifecycle: true,
|
||||
capabilities: ["environment.drivers.register", "agents.lifecycle.manage"] };
|
||||
expect(pluginManifestV1Schema.parse(manifest).environmentDrivers?.[0]?.requiresAgent).toBe(true);
|
||||
expect(pluginManifestV1Schema.safeParse(buildSandboxProviderManifest({ requiresAgent: "true" })).success).toBe(false);
|
||||
expect(pluginManifestV1Schema.safeParse({ ...manifest, agentLifecycle: undefined }).success).toBe(false);
|
||||
});
|
||||
it("requires authority for required agent lifecycle participants", () => {
|
||||
const manifest = { id: "example.lifecycle", apiVersion: 1, version: "0.1.0", displayName: "Lifecycle", description: "Lifecycle tests", author: "Tests", categories: ["automation"], entrypoints: { worker: "worker.js" }, agentLifecycle: true };
|
||||
expect(pluginManifestV1Schema.safeParse({ ...manifest, capabilities: ["agents.read"] }).success).toBe(false);
|
||||
|
||||
@@ -178,6 +178,7 @@ export type SandboxProviderCapabilitiesInput = z.infer<typeof sandboxProviderCap
|
||||
// (for example `supportsLoginPTY`) fails validation instead of dropping.
|
||||
export const pluginEnvironmentDriverDeclarationSchema = z.object({
|
||||
skillKeys: z.array(z.string().min(1).max(100)).max(100).optional(),
|
||||
requiresAgent: z.boolean().optional(),
|
||||
supportsTasks: z.boolean().optional(),
|
||||
freshWorkspaceVersion: z.literal(1).optional(),
|
||||
warmTaskVersion: z.literal(1).optional(),
|
||||
@@ -828,6 +829,9 @@ export const pluginManifestV1Schema = z.object({
|
||||
launchers: z.array(pluginLauncherDeclarationSchema).optional(),
|
||||
}).optional(),
|
||||
}).superRefine((manifest, ctx) => {
|
||||
if (manifest.environmentDrivers?.some(driver => driver.requiresAgent) && !manifest.agentLifecycle) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Agent-dependent environments require agent lifecycle participation", path: ["agentLifecycle"] });
|
||||
}
|
||||
if (manifest.agentLifecycle && !manifest.capabilities.includes("agents.lifecycle.manage")) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Agent lifecycle participation requires agents.lifecycle.manage", path: ["capabilities"] });
|
||||
}
|
||||
|
||||
@@ -64,6 +64,27 @@ databaseSuite("Core plugin Runner task lifecycle", () => {
|
||||
});
|
||||
afterAll(async () => { await stopDb?.(); });
|
||||
|
||||
it("does not allocate agent-dependent setup leases before the agent is prepared", async () => {
|
||||
const companyId = randomUUID(), pluginId = randomUUID(), environmentId = randomUUID(), agentId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Setup lease fixture", issuePrefix: "SETUP" });
|
||||
await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "@test/agent-owned-tasks", version: "1.0.0", status: "ready",
|
||||
manifestJson: { agentLifecycle: true, capabilities: ["agents.lifecycle.manage", "environment.drivers.register"],
|
||||
environmentDrivers: [{ driverKey: "tasks", supportsTasks: true, requiresAgent: true }] } as never });
|
||||
await db.insert(environments).values({ id: environmentId, name: "Agent computer", driver: "plugin", status: "active",
|
||||
config: { pluginKey: pluginId, driverKey: "tasks", driverConfig: {} } });
|
||||
const call = vi.fn();
|
||||
const setup = agentEnvironmentTestService(db, { call } as never);
|
||||
const input = { companyId, environmentId, adapterType: "paperclip_runner" };
|
||||
expect((await setup.resolveAdapterTestExecutionContext(input)).fallbackChecks).toMatchObject([{ code: "environment_agent_required", level: "warn" }]);
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Draft", lifecycleState: "preparing", lifecycleHolds: ["setup"], status: "paused" });
|
||||
expect((await setup.resolveAdapterTestExecutionContext({ ...input, agentId })).fallbackChecks).toMatchObject([{ code: "environment_agent_preparing", level: "warn" }]);
|
||||
const foreign = randomUUID();
|
||||
await db.insert(companies).values({ id: foreign, name: "Other company", issuePrefix: "OTHER" });
|
||||
expect((await setup.resolveAdapterTestExecutionContext({ ...input, companyId: foreign, agentId })).fallbackChecks).toMatchObject([{ code: "environment_agent_preparing" }]);
|
||||
expect(call).not.toHaveBeenCalled();
|
||||
expect(await db.select().from(environmentLeases).where(eq(environmentLeases.environmentId, environmentId))).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses an acquired lease through SDK RPC, native dispatch, connection, stop proof and release", async () => {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), runId = randomUUID();
|
||||
const environmentId = randomUUID(), pluginId = randomUUID(), projectId = randomUUID();
|
||||
|
||||
@@ -16,7 +16,7 @@ import { errorHandler } from "../../../middleware/error-handler.js";
|
||||
import { createLifecycleDriver } from "../../../services/agent-lifecycle-driver.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { agents, approvals, projects, issues, heartbeatRuns, nativeRunFinalizations, budgetPolicies, environmentLeases, agentApiKeys, activityLog, agentConfigRevisions, userCompanyPreferences, companies, companyMemberships, principalPermissionGrants, plugins, pluginCompanySettings, createDb, type Db } from "@paperclipai/db";
|
||||
import { agents, approvals, projects, issues, heartbeatRuns, nativeRunFinalizations, budgetPolicies, environmentLeases, environments, agentApiKeys, activityLog, agentConfigRevisions, userCompanyPreferences, companies, companyMemberships, principalPermissionGrants, plugins, pluginCompanySettings, 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, startAgentLifecycle } from "../../../services/agent-lifecycle.js";
|
||||
@@ -140,6 +140,54 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
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");
|
||||
// Restore only the setup hold to exercise environment changes under the wizard's authority.
|
||||
await db.update(agents).set({ lifecycleHolds: ["setup"], pauseReason: "setup" }).where(eq(agents.id, id));
|
||||
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");
|
||||
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("freezes the final setup configuration for hire approval", async () => {
|
||||
const lifecycle = createAgentLifecycle(db);
|
||||
const id = randomUUID();
|
||||
|
||||
@@ -21,10 +21,17 @@ 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 invalidate = changed && ["preparing", "verifying", "resuming"].includes(current.lifecycleState);
|
||||
const prepareSetup = changed && current.lifecycleHolds.length === 1 && current.lifecycleHolds[0] === "setup"
|
||||
&& ["paused", "preparing", "verifying"].includes(current.lifecycleState)
|
||||
&& await effects.setupRequiresPreparation(tx, current.companyId,
|
||||
Object.prototype.hasOwnProperty.call(patch, "defaultEnvironmentId") ? patch.defaultEnvironmentId : current.defaultEnvironmentId);
|
||||
const invalidate = changed && (["preparing", "verifying", "resuming"].includes(current.lifecycleState) || prepareSetup);
|
||||
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 && current.lifecycleHolds.includes("setup") && ["preparing", "verifying"].includes(current.lifecycleState)
|
||||
? { lifecycleState: "pausing" as const } : {}),
|
||||
lifecycleOperation: { ...current.lifecycleOperation!, id: randomUUID(), hostComplete: false,
|
||||
completedPluginIds: [], leaseOwner: undefined, leaseUntil: undefined, retryAt: undefined },
|
||||
} : {}) }).where(eq(agents.id, id)).returning();
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ActivityPublication } from "../../../types/activity-publication.js
|
||||
|
||||
/** Services supply integrations; lifecycle owns the transaction and state writes. */
|
||||
export interface LifecycleEffects {
|
||||
setupRequiresPreparation(db: Db, companyId: string, environmentId?: string | null): Promise<boolean>;
|
||||
notifyWork(db: Db): Promise<void>;
|
||||
reportFailure(context: LifecycleFailure, error: unknown): void;
|
||||
deleteDependencies(db: Db, companyId: string, agentId: string): Promise<void>;
|
||||
|
||||
@@ -13,6 +13,12 @@ export function assertRootDatabase(db: Db) {
|
||||
type LifecycleRow = typeof agents.$inferSelect;
|
||||
type ChangeOptions = { version?: number; owner?: string; reason?: string };
|
||||
|
||||
// Prepared drafts wait for the wizard; they have no background verification obligation.
|
||||
export const pendingLifecycleWork = and(
|
||||
inArray(agents.lifecycleState, ["preparing", "verifying", "pausing", "resuming", "terminating", "cleaning_up"]),
|
||||
sql`not (${agents.lifecycleState} = 'verifying' and ${agents.lifecycleHolds} @> '["setup"]'::jsonb)`,
|
||||
);
|
||||
|
||||
/** The caller owns the accounting transaction and has locked this agent. */
|
||||
async function changeLockedAgent(tx: Db, effects: LifecycleEffects, agent: LifecycleRow,
|
||||
command: LifecycleCommand, options: ChangeOptions = {}, policyHolds?: string[], restartPause = false) {
|
||||
@@ -47,8 +53,9 @@ async function changeLockedAgent(tx: Db, effects: LifecycleEffects, agent: Lifec
|
||||
}
|
||||
if (command === "pause") holds = [...new Set([...holds, reason])];
|
||||
if (command === "resume") holds = holds.filter(hold => reason === "user" ? ["budget", "company_archived", "company_paused"].includes(hold) : hold !== reason);
|
||||
const blockingHolds = holds.filter(hold => hold !== "setup" || !["preparing", "verifying"].includes(agent.lifecycleState));
|
||||
let next = command === "reconcile"
|
||||
? holds.length && !["pausing", "paused"].includes(agent.lifecycleState) ? "pausing" as const
|
||||
? blockingHolds.length && !["pausing", "paused"].includes(agent.lifecycleState) ? "pausing" as const
|
||||
: !holds.length && agent.lifecycleState === "paused" ? "resuming" as const : agent.lifecycleState
|
||||
: command === "resume" && (holds.length || agent.lifecycleState === "pausing") ? agent.lifecycleState
|
||||
: transition(agent.lifecycleState, command, agent.lifecycleOperation?.resumeState);
|
||||
@@ -131,7 +138,7 @@ export function createLifecycleStore(db: Db, effects: LifecycleEffects): Lifecyc
|
||||
lifecycleOperation: sql`coalesce(${agents.lifecycleOperation}, '{"id":"", "hostComplete":false, "completedPluginIds":[], "attempts":0}'::jsonb)
|
||||
|| jsonb_build_object('leaseOwner', ${owner}::text, 'leaseUntil', ${(new Date(now.getTime() + 120_000)).toISOString()}::text,
|
||||
'attempts', coalesce((${agents.lifecycleOperation}->>'attempts')::integer, 0) + 1)`,
|
||||
}).where(and(eq(agents.id, id), inArray(agents.lifecycleState, ["preparing", "verifying", "pausing", "resuming", "terminating", "cleaning_up"]),
|
||||
}).where(and(eq(agents.id, id), pendingLifecycleWork,
|
||||
sql`(${agents.lifecycleState} in ('pausing', 'terminating', 'cleaning_up') or exists (
|
||||
select 1 from ${companies} where ${companies.id} = ${agents.companyId} and ${companies.status} = 'active'))`,
|
||||
sql`coalesce((${agents.lifecycleOperation}->>'leaseUntil')::timestamptz, '-infinity') <= ${now.toISOString()}::timestamptz`,
|
||||
@@ -179,7 +186,7 @@ export function createLifecycleStore(db: Db, effects: LifecycleEffects): Lifecyc
|
||||
},
|
||||
async pending(limit, now) {
|
||||
return db.select({ id: agents.id }).from(agents).where(and(
|
||||
inArray(agents.lifecycleState, ["preparing", "verifying", "pausing", "resuming", "terminating", "cleaning_up"]),
|
||||
pendingLifecycleWork,
|
||||
sql`coalesce((${agents.lifecycleOperation}->>'leaseUntil')::timestamptz, '-infinity') <= ${now.toISOString()}::timestamptz`,
|
||||
sql`coalesce((${agents.lifecycleOperation}->>'retryAt')::timestamptz, '-infinity') <= ${now.toISOString()}::timestamptz`,
|
||||
)).orderBy(asc(agents.updatedAt), asc(agents.id)).limit(limit).then(rows => rows.map(row => row.id));
|
||||
|
||||
@@ -4,6 +4,7 @@ import { and, eq, sql } from "drizzle-orm";
|
||||
import { agents, agentApiKeys, companies, type Db } from "@paperclipai/db";
|
||||
import { conflict, notFound } from "../../../errors.js";
|
||||
import { agentRecordQueries, type CreateAgentOptions, type CreateAgentData } from "../../../lib/agent-records.js";
|
||||
import { isAgentSetupDraft } from "../domain/policy.js";
|
||||
|
||||
export function agentRecords(db: Db, effects: LifecycleEffects) {
|
||||
const { getById } = agentRecordQueries(db);
|
||||
@@ -25,7 +26,7 @@ export function agentRecords(db: Db, effects: LifecycleEffects) {
|
||||
if (!existing) throw notFound("Agent setup not found");
|
||||
return existing;
|
||||
}
|
||||
if (setup && (setup.lifecycleState !== "paused" || !setup.lifecycleHolds.includes("setup"))) {
|
||||
if (setup && (!isAgentSetupDraft(setup) || !setup.lifecycleHolds.includes("setup"))) {
|
||||
throw conflict("Agent setup is no longer available");
|
||||
}
|
||||
if (options?.setupAgentId && !setup) throw notFound("Agent setup not found");
|
||||
@@ -35,8 +36,9 @@ export function agentRecords(db: Db, effects: LifecycleEffects) {
|
||||
const [company] = await tx.select({ status: companies.status }).from(companies).where(eq(companies.id, companyId));
|
||||
const companyHolds = company?.status === "paused" ? ["company_paused"] : company?.status === "archived" ? ["company_archived"] : [];
|
||||
const holds = [...(status === "paused" ? [pauseReason ?? "manual"] : []), ...companyHolds];
|
||||
const prepareSetup = options?.setupOnly && !companyHolds.length && await effects.setupRequiresPreparation(txDb, companyId, prepared.defaultEnvironmentId);
|
||||
const lifecycleState = status === "pending_approval" ? "pending_approval" : status === "terminated" ? "terminated"
|
||||
: status === "paused" ? "paused" : companyHolds.length ? "pausing" : "preparing";
|
||||
: prepareSetup ? "preparing" : status === "paused" ? "paused" : companyHolds.length ? "pausing" : "preparing";
|
||||
if (lifecycleState === "preparing" || lifecycleState === "pausing") await effects.notifyWork(tx);
|
||||
const values: typeof agents.$inferInsert = {
|
||||
...prepared,
|
||||
|
||||
@@ -5,6 +5,10 @@ export class AgentLifecycleConflict extends Error {
|
||||
constructor(message: string) { super(message); this.name = "AgentLifecycleConflict"; }
|
||||
}
|
||||
|
||||
export function isAgentSetupDraft(agent: { lifecycleState?: string; pauseReason?: string | null }) {
|
||||
return agent.pauseReason === "setup" && ["paused", "preparing", "verifying"].includes(agent.lifecycleState ?? "");
|
||||
}
|
||||
|
||||
export function canConfigureAgentConnection(agent: { lifecycleState: string; status: string }): boolean {
|
||||
return agent.lifecycleState === "preparing" || agent.lifecycleState === "verifying"
|
||||
|| (agent.lifecycleState === "ready" && !["paused", "terminated", "pending_approval"].includes(agent.status));
|
||||
|
||||
@@ -8,7 +8,7 @@ import { updateAgentConfiguration } from "./adapters/configuration.js";
|
||||
import type { AgentConfigurationPatch, UpdateAgentOptions } from "../../lib/agent-records.js";
|
||||
import { purgeAgent } from "./adapters/deletion.js";
|
||||
import { agentRecords } from "./adapters/records.js";
|
||||
import { assertRootDatabase, createLifecycleStore } from "./adapters/postgres.js";
|
||||
import { assertRootDatabase, createLifecycleStore, pendingLifecycleWork } from "./adapters/postgres.js";
|
||||
import { hasCompanyHoldMismatch, projectCompanyHolds } from "./adapters/company-policy.js";
|
||||
import { createLifecycleWorker } from "./application/worker.js";
|
||||
import type { LifecycleDriver } from "./application/ports.js";
|
||||
@@ -16,12 +16,11 @@ import type { LifecycleDriver } from "./application/ports.js";
|
||||
export { applyAgentPolicyHoldsInTransaction } from "./adapters/postgres.js";
|
||||
export { companyHoldMismatch, fenceActiveCompanyAgentStopsInTransaction } from "./adapters/company-policy.js";
|
||||
|
||||
export { AgentLifecycleConflict, canConfigureAgentConnection, isAgentAwaitingSetup } from "./domain/policy.js";
|
||||
export { AgentLifecycleConflict, canConfigureAgentConnection, isAgentAwaitingSetup, isAgentSetupDraft } from "./domain/policy.js";
|
||||
|
||||
export type { HireDecisionTarget } from "./adapters/approvals.js";
|
||||
export type { LifecycleDriver, LifecycleAgent, LifecycleFailure } from "./application/ports.js";
|
||||
|
||||
const pendingStates = ["preparing", "verifying", "pausing", "resuming", "terminating", "cleaning_up"] as const;
|
||||
const wakes = new WeakMap<Db, () => void>();
|
||||
|
||||
const workers = new WeakMap<Db, ReturnType<typeof createLifecycleWorker>>();
|
||||
@@ -116,7 +115,7 @@ export function startAgentLifecycle(db: Db, effects: LifecycleEffects, driver: L
|
||||
const [pending] = await db.select({ due: sql<number | null>`min(greatest(
|
||||
coalesce(extract(epoch from (${agents.lifecycleOperation}->>'leaseUntil')::timestamptz) * 1000, 0),
|
||||
coalesce(extract(epoch from (${agents.lifecycleOperation}->>'retryAt')::timestamptz) * 1000, 0)))::double precision` })
|
||||
.from(agents).where(inArray(agents.lifecycleState, pendingStates));
|
||||
.from(agents).where(pendingLifecycleWork);
|
||||
const due = pending?.due == null ? null : Number(pending.due);
|
||||
const now = Date.now();
|
||||
// Already-due work uses the coordinator's bounded retry. A zero-delay
|
||||
|
||||
@@ -7,6 +7,7 @@ import { resolveNewAgentRunnerForCompany } from "../services/agent-runner-select
|
||||
import { agentEnvironmentTestService } from "../services/agent-environment-test.js";
|
||||
import { qualifyAgentRunner } from "../services/agent-runner-qualification.js";
|
||||
import { createAgentLifecycle } from "../services/agent-lifecycle.js";
|
||||
import { isAgentSetupDraft } from "../modules/agent-lifecycle/index.js";
|
||||
|
||||
import { agentIdentityService } from "../services/agent-identity.js";
|
||||
import { aiConnectionRouterService, poolMemberRuntimeConfig } from "../services/ai-connection-router.js";
|
||||
@@ -1797,7 +1798,7 @@ export function agentRoutes(
|
||||
id: string; companyId: string; createdByUserId?: string | null; lifecycleState?: string; pauseReason?: string | null;
|
||||
}, allowCompleted = false) {
|
||||
if (req.actor.type === "board" && req.actor.userId && targetAgent.createdByUserId === req.actor.userId
|
||||
&& (allowCompleted || (targetAgent.lifecycleState === "paused" && targetAgent.pauseReason === "setup"))) {
|
||||
&& (allowCompleted || isAgentSetupDraft(targetAgent))) {
|
||||
await assertCanCreateAgentsForCompany(req, targetAgent.companyId);
|
||||
return;
|
||||
}
|
||||
@@ -3181,7 +3182,7 @@ export function agentRoutes(
|
||||
const testEnvironmentId = await resolveAdapterTestEnvironmentId(companyId, environmentId, binding.provider === "github");
|
||||
if (testEnvironmentId) await assertAdapterTestEnvironmentForCompany(companyId, testEnvironmentId, adapterType);
|
||||
const hasSavedAgent = !newAgent || (isUuidLike(agentId) && Boolean(await svc.getById(agentId)));
|
||||
if (!hasSavedAgent && !test && testEnvironmentId && (await environmentsSvc.getById(testEnvironmentId))?.driver === "plugin") {
|
||||
if (newAgent && !test && testEnvironmentId && (await environmentsSvc.getById(testEnvironmentId))?.driver === "plugin") {
|
||||
// The lifecycle verifies the saved agent after its plugin can create the agent-owned computer.
|
||||
return { connectionId: selection.connection.id };
|
||||
}
|
||||
@@ -3229,7 +3230,7 @@ export function agentRoutes(
|
||||
if (savedAgent.companyId !== companyId) throw notFound("Agent not found");
|
||||
await assertCanConfigureAgentSetup(req, savedAgent);
|
||||
}
|
||||
const testingSetup = savedAgent?.lifecycleState === "paused" && savedAgent.pauseReason === "setup";
|
||||
const testingSetup = savedAgent ? isAgentSetupDraft(savedAgent) : false;
|
||||
const poolBinding = requestedBinding ?? (savedAgent?.runtimeConfig?.aiConnection ? aiRuntimeConnectionBindingSchema.parse(savedAgent.runtimeConfig.aiConnection) : undefined);
|
||||
const requestedEnvironmentId = await resolveAdapterTestEnvironmentId(
|
||||
companyId,
|
||||
@@ -5546,7 +5547,7 @@ export function agentRoutes(
|
||||
&& (["openai_dot", "muse"].includes(String(rawEffectiveAdapterConfig.provider)) || ["openai_dot", "muse"].includes(String(existingRunnerProvider)))) {
|
||||
await assertSelectableAdapterType(requestedAdapterType, rawEffectiveAdapterConfig);
|
||||
}
|
||||
if (!(existing.lifecycleState === "paused" && existing.pauseReason === "setup") && (
|
||||
if (!isAgentSetupDraft(existing) && (
|
||||
changingAdapterType ||
|
||||
(requestedAdapterType === "paperclip_runner" &&
|
||||
(requestedAdapterConfig !== null ||
|
||||
@@ -5581,7 +5582,7 @@ export function agentRoutes(
|
||||
}
|
||||
const nextAiConfig = (patchData.adapterConfig ?? existing.adapterConfig) as Record<string, unknown>;
|
||||
if (existing.runtimeConfig.aiConnection && requestedRuntimeConfig && !requestedRuntimeConfig.aiConnection
|
||||
&& !(existing.lifecycleState === "paused" && existing.pauseReason === "setup")) {
|
||||
&& !isAgentSetupDraft(existing)) {
|
||||
const existingBinding = aiRuntimeConnectionBindingSchema.safeParse(existing.runtimeConfig.aiConnection).data;
|
||||
if (existingBinding && agentHarnessType(existing.adapterType, existing.adapterConfig) === agentHarnessType(requestedAdapterType, nextAiConfig)
|
||||
&& (existingBinding.mode === "router" || isAiConnectionCompatible(existingBinding,
|
||||
@@ -5590,7 +5591,7 @@ export function agentRoutes(
|
||||
}
|
||||
}
|
||||
if (!requestedRuntimeConfig?.aiConnection && !existing.runtimeConfig.aiConnection
|
||||
&& !(existing.lifecycleState === "paused" && existing.pauseReason === "setup")
|
||||
&& !isAgentSetupDraft(existing)
|
||||
&& requestedAdapterType === "paperclip_runner" && nextAiConfig.provider === "acpx"
|
||||
&& nextAiConfig.acpxAgent === "copilot"
|
||||
&& (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId"))) {
|
||||
@@ -5606,7 +5607,7 @@ export function agentRoutes(
|
||||
const copilotConfigurationChanged = requestedAdapterType === "paperclip_runner" && aiConfig.provider === "acpx" && aiConfig.acpxAgent === "copilot"
|
||||
&& (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId"));
|
||||
if (runtimeSettings || changed || copilotConfigurationChanged || (nextAiBinding.mode === "router" && requestedAdapterType !== existing.adapterType)) {
|
||||
const configuringSetup = existing.lifecycleState === "paused" && existing.pauseReason === "setup";
|
||||
const configuringSetup = isAgentSetupDraft(existing);
|
||||
await validateManagedAgentBinding(req, existing.companyId, existing.id, requestedAdapterType, aiConfig, nextAiBinding,
|
||||
(patchData.defaultEnvironmentId !== undefined ? patchData.defaultEnvironmentId : existing.defaultEnvironmentId) as string | null,
|
||||
!configuringSetup && (!runtimeSettings || changed), configuringSetup, Boolean(runtimeSettings));
|
||||
@@ -5684,7 +5685,7 @@ export function agentRoutes(
|
||||
const id = req.params.id as string;
|
||||
const existing = await getAccessibleAgent(req, res, id);
|
||||
if (!existing) return;
|
||||
await assertCanUpdateAgent(req, existing);
|
||||
await assertCanConfigureAgentSetup(req, existing);
|
||||
const agent = await svcLifecycle.retry(id);
|
||||
if (!agent) { res.status(404).json({ error: "Agent not found" }); return; }
|
||||
const actor = getActorInfo(req);
|
||||
|
||||
@@ -2,7 +2,8 @@ import { join } from "node:path";
|
||||
import { probePluginRunnerEnvironment } from "./native-runtime/plugin-runner-probe.js";
|
||||
import { registerPluginTaskCredentialHandoff } from "./plugin-task-credential-writeback.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, type Db } from "@paperclipai/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
import type { AdapterEnvironmentCheck } from "@paperclipai/adapter-utils";
|
||||
import { parseObject } from "@paperclipai/adapter-utils/server-utils";
|
||||
@@ -19,6 +20,7 @@ import { validateAiApiKey } from "./ai-api-key-test.js";
|
||||
import { probeCopilotExecutionTarget } from "./copilot-connection-probe.js";
|
||||
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
import { assertEnvironmentSelectionForCompany } from "./environment-selection.js";
|
||||
import { pluginEnvironmentRequiresAgent } from "./plugin-environment-driver.js";
|
||||
const asRecord = (value: unknown): Record<string, unknown> | null => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||||
export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: PluginWorkerManager) {
|
||||
const instanceSettings = instanceSettingsService(db);
|
||||
@@ -212,6 +214,17 @@ export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: Plugin
|
||||
// agent lease) and archive it on release instead of deleting it, so the
|
||||
// operator can inspect the exact sandbox from the provider dashboard while
|
||||
// provider-side expiry reaps it later.
|
||||
if (environment.driver === "plugin" && await pluginEnvironmentRequiresAgent(db, environment)) {
|
||||
const [agent] = input.agentId ? await db.select({ state: agents.lifecycleState }).from(agents)
|
||||
.where(and(eq(agents.id, input.agentId), eq(agents.companyId, input.companyId))) : [];
|
||||
if (!agent || ["paused", "preparing"].includes(agent.state)) return {
|
||||
executionTarget: null, environmentName: environment.name, fallbackChecks: [{
|
||||
code: !input.agentId ? "environment_agent_required" : "environment_agent_preparing", level: "warn",
|
||||
message: !input.agentId ? "This environment needs a saved agent before its connection can be tested."
|
||||
: "The agent computer is being prepared. Test the connection when preparation finishes.",
|
||||
}], release: noopRelease,
|
||||
};
|
||||
}
|
||||
const testEnvironment = environment.driver === "sandbox"
|
||||
? {
|
||||
...environment,
|
||||
|
||||
@@ -12,12 +12,23 @@ import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notificatio
|
||||
import { fenceActiveCompanyAgentStopsInTransaction as fenceActiveCompanyStops } from "../modules/agent-lifecycle/index.js";
|
||||
import { applyAgentPolicyHoldsInTransaction } from "../modules/agent-lifecycle/index.js";
|
||||
import { trackIdleWork } from "./task-admission.js";
|
||||
import { environmentService } from "./environments.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import { pluginEnvironmentRequiresAgent } from "./plugin-environment-driver.js";
|
||||
|
||||
export { scheduleAgentLifecycle, processAgentLifecycle } from "../modules/agent-lifecycle/index.js";
|
||||
export type { LifecycleDriver, LifecycleAgent } from "../modules/agent-lifecycle/index.js";
|
||||
|
||||
export function createAgentLifecycleEffects(_hooks: BudgetServiceHooks = {}): lifecycle.LifecycleEffects {
|
||||
return {
|
||||
async setupRequiresPreparation(db, companyId, environmentId) {
|
||||
const id = environmentId ?? (await instanceSettingsService(db).get()).defaultEnvironmentId;
|
||||
const environment = id ? await environmentService(db).getById(id) : null;
|
||||
if (!environment || !await pluginEnvironmentRequiresAgent(db, environment)) return false;
|
||||
const bound = await environmentService(db).listBoundCompanyIds(environment.id);
|
||||
if (bound.length && !bound.includes(companyId)) throw new lifecycle.AgentLifecycleConflict("The environment belongs to another company");
|
||||
return true;
|
||||
},
|
||||
notifyWork: tx => notifyDeliveryWork(tx, DELIVERY_QUEUES.agentLifecycle),
|
||||
reportFailure(context, error) {
|
||||
const code = lifecycle.lifecycleFailureCode(error);
|
||||
|
||||
@@ -31,6 +31,14 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The worker methods a sandbox provider must advertise before the host reuses
|
||||
* a provider lease across runs. The host resumes a lease through
|
||||
|
||||
@@ -194,12 +194,13 @@ function Setup({
|
||||
const savedAgent = useQuery({
|
||||
queryKey: queryKeys.agents.detail(recordId ?? "new"),
|
||||
queryFn: () => agentsApi.get(recordId!, companyId),
|
||||
enabled: Boolean(recordId) && !currentCreated && !creating.current,
|
||||
enabled: Boolean(recordId) && !creating.current,
|
||||
refetchInterval: query => query.state.data?.pauseReason === "setup"
|
||||
&& ["preparing", "pausing"].includes(query.state.data.lifecycleState ?? "") && !query.state.data.lifecycleError ? 1000 : false,
|
||||
retry: false,
|
||||
});
|
||||
const created =
|
||||
currentCreated ??
|
||||
(savedAgent.data?.companyId === companyId ? savedAgent.data : null);
|
||||
(savedAgent.data?.companyId === companyId ? savedAgent.data : null) ?? currentCreated;
|
||||
const completed = Boolean(created && created.pauseReason !== "setup");
|
||||
useEffect(() => {
|
||||
if (brandType !== "pi_local" || created?.adapterType !== "paperclip_runner"
|
||||
@@ -218,6 +219,7 @@ function Setup({
|
||||
null,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [environmentSaving, setEnvironmentSaving] = useState(false);
|
||||
const generation = useRef(0);
|
||||
const qualificationTransition = useRef<{ run: number; companyId: string; environmentId: string | null; runner: AgentRunner } | null>(null);
|
||||
const savingRef = useRef(false);
|
||||
@@ -410,14 +412,15 @@ function Setup({
|
||||
(!(managedOnly || forced.forced) || environmentId),
|
||||
);
|
||||
const needsCurrentSetup = ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType);
|
||||
const ready = Boolean(created) && connectionSettingsReady && (!needsCurrentSetup || (
|
||||
const preparingComputer = created?.pauseReason === "setup" && ["preparing", "pausing"].includes(created.lifecycleState ?? "");
|
||||
const ready = Boolean(created) && !environmentSaving && !preparingComputer && connectionSettingsReady && (!needsCurrentSetup || (
|
||||
envs.isSuccess && !envs.isFetching
|
||||
&& settings.isSuccess && !settings.isFetching
|
||||
&& experimental.isSuccess && !experimental.isFetching
|
||||
&& general.isSuccess && !general.isFetching
|
||||
&& (runner !== "auto" || (adapters.isSuccess && !adapters.isFetching))
|
||||
));
|
||||
const busy = testState === "running" || saving;
|
||||
const busy = testState === "running" || saving || environmentSaving;
|
||||
|
||||
function buildConfig(
|
||||
nextConnection = connection,
|
||||
@@ -604,7 +607,7 @@ function Setup({
|
||||
setScreen(binding || !connectionAdapter ? "runtime" : "connect");
|
||||
}, [savedAgent.data, companyId, connectionAdapter, brandType]);
|
||||
|
||||
async function saveDraft(nextConnection = connection, exit = false, testedConfig?: Record<string, unknown>) {
|
||||
async function saveDraft(nextConnection = connection, exit = false, testedConfig?: Record<string, unknown>, selectedEnvironment = environmentOverride) {
|
||||
if (!created || completed) return;
|
||||
const binding = runtimeAiBinding ?? nextConnection?.aiConnection;
|
||||
const config = testedConfig ?? buildConfig(nextConnection);
|
||||
@@ -625,7 +628,7 @@ function Setup({
|
||||
submitted = true;
|
||||
const agent = await agentsApi.update(created.id, {
|
||||
adapterType: requestAdapterType, runner, adapterConfig: config,
|
||||
defaultEnvironmentId: environmentOverride || (forced.forced || managedOnly ? environmentId : null),
|
||||
defaultEnvironmentId: selectedEnvironment || (forced.forced || managedOnly ? environmentId : null),
|
||||
runtimeConfig: { ...created.runtimeConfig, ...(binding ? { aiConnection: binding } : {}) },
|
||||
...(nextConnection?.applyStoredClaudeLogin ? { applyStoredClaudeLogin: true } : {}),
|
||||
}, companyId);
|
||||
@@ -678,6 +681,7 @@ function Setup({
|
||||
const config = preparedConfig(nextConnection, qualified?.runner);
|
||||
const tested = await testAgentSetup({
|
||||
companyId,
|
||||
agentId: created?.id,
|
||||
adapterType: requestAdapterType,
|
||||
providerAdapter: brandType,
|
||||
...(["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
|
||||
@@ -686,7 +690,7 @@ function Setup({
|
||||
aiConnection: runtimeAiBinding ?? nextConnection?.aiConnection,
|
||||
environmentId,
|
||||
});
|
||||
if (run !== generation.current) return null;
|
||||
if (run !== generation.current) return null;
|
||||
setResult(tested);
|
||||
setTestState(blocking(tested) ? "fail" : tested.status);
|
||||
return (
|
||||
@@ -882,6 +886,18 @@ function Setup({
|
||||
{environmentError}
|
||||
</p>
|
||||
)}
|
||||
{preparingComputer && <p role="status" className="text-sm text-muted-foreground">Preparing your agent computer…</p>}
|
||||
{created?.lifecycleError && <div className="space-y-2">
|
||||
<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 => {
|
||||
setCreated(agent);
|
||||
cache.setQueryData(queryKeys.agents.detail(agent.id), agent);
|
||||
}).catch(cause => setError(cause instanceof Error ? cause.message : "Could not retry computer preparation."))
|
||||
.finally(() => setEnvironmentSaving(false));
|
||||
}}>Retry preparation</Button>
|
||||
</div>}
|
||||
{setupError && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{setupError.message}
|
||||
@@ -1374,10 +1390,13 @@ function Setup({
|
||||
value={environmentOverride}
|
||||
disabled={forced.forced}
|
||||
onChange={(event) => {
|
||||
setEnvironmentOverride(event.target.value);
|
||||
setConnection(null);
|
||||
const selected = event.target.value;
|
||||
setEnvironmentOverride(selected);
|
||||
resetTest();
|
||||
if (connectionAdapter) setScreen("connect");
|
||||
setEnvironmentSaving(true);
|
||||
void saveDraft(connection, false, undefined, selected)
|
||||
.catch(cause => setError(cause instanceof Error ? cause.message : "Could not save the environment."))
|
||||
.finally(() => setEnvironmentSaving(false));
|
||||
}}
|
||||
>
|
||||
<option value="">
|
||||
|
||||
@@ -31,6 +31,12 @@ const ready = {
|
||||
};
|
||||
beforeEach(() => testEnvironment.mockReset());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
it.each(["environment_agent_required", "environment_agent_preparing"])("does not start a supplementary provider probe while %s", async code => {
|
||||
const pending = { ...ready, status: "warn", checks: [{ code, level: "warn", message: "Waiting for the agent computer" }] };
|
||||
testEnvironment.mockResolvedValue(pending);
|
||||
expect(await testAgentSetup(input)).toEqual(pending);
|
||||
expect(testEnvironment).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("does not launch an ambient provider hello test for a pool preview", async () => {
|
||||
testEnvironment.mockResolvedValue({ ...ready, status: "warn", checks: [{ code: "ai_connection_pool_task_test_required", level: "warn", message: "Run a task" }] });
|
||||
await testAgentSetup({ ...input, aiConnection: { mode: "router", connectionId: "pool-id" } });
|
||||
|
||||
@@ -30,6 +30,7 @@ export async function testAgentSetup(input: {
|
||||
);
|
||||
if (
|
||||
input.aiConnection?.mode === "router" ||
|
||||
runtime.checks.some(check => ["environment_agent_required", "environment_agent_preparing"].includes(check.code)) ||
|
||||
runtime.status === "fail" ||
|
||||
runtime.checks.some(
|
||||
(check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE,
|
||||
|
||||
@@ -15,6 +15,7 @@ const api = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
hire: vi.fn(),
|
||||
testEnvironment: vi.fn(),
|
||||
retryLifecycle: vi.fn(),
|
||||
getAdapterAuthSignal: vi.fn(),
|
||||
getClaudeOAuthTokenStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -10,6 +10,29 @@ import {
|
||||
} from "./NewAgent.test-support";
|
||||
|
||||
describe("New agent setup", () => {
|
||||
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" },
|
||||
runtimeConfig: {}, lifecycleState: "preparing", pauseReason: "setup", defaultEnvironmentId: "computer", status: "paused" };
|
||||
state.params = new URLSearchParams({ name: "Atlas", adapterType: "codex_local", setupAgentId: id });
|
||||
api.get.mockResolvedValue(draft);
|
||||
settings.get.mockResolvedValue({ defaultEnvironmentId: "computer" });
|
||||
envApi.list.mockResolvedValue([{ id: "computer", name: "Agent computer", status: "active", driver: "plugin", config: {} }]);
|
||||
await renderCurrentParams();
|
||||
expect(container.textContent).toContain("Preparing your agent computer");
|
||||
await click("OpenAIAPI key");
|
||||
expect(api.testEnvironment).not.toHaveBeenCalled();
|
||||
api.get.mockResolvedValue({ ...draft, lifecycleState: "verifying" });
|
||||
await act(async () => { await cache.invalidateQueries({ queryKey: queryKeys.agents.detail(id) }); });
|
||||
await settle();
|
||||
expect(container.textContent).not.toContain("Preparing your agent computer");
|
||||
await click("OpenAIAPI key");
|
||||
await fill("API key", "setup-test-key");
|
||||
await click("Connect");
|
||||
expect(api.testEnvironment).toHaveBeenCalledWith("company-1", expect.any(String), expect.objectContaining({ agentId: id, environmentId: "computer" }));
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates an inactive record before connecting and finishes that same agent", async () => {
|
||||
await render("codex_local");
|
||||
expect(api.create).toHaveBeenCalledTimes(1);
|
||||
@@ -21,6 +44,7 @@ describe("New agent setup", () => {
|
||||
await click("OpenAIAPI key");
|
||||
await fill("API key", "setup-test-key");
|
||||
await click("Connect");
|
||||
expect(api.testEnvironment.mock.calls[0][2].agentId).toBe(setupId);
|
||||
expect(api.update).toHaveBeenCalledWith(setupId, expect.objectContaining({ runtimeConfig: expect.objectContaining({ aiConnection: expect.anything() }) }), "company-1");
|
||||
await click("Finish setup");
|
||||
expect(api.hire.mock.calls[0][1].setupAgentId).toBe(setupId);
|
||||
@@ -349,7 +373,7 @@ describe("New agent setup", () => {
|
||||
const environment = container.querySelector<HTMLSelectElement>('select[aria-label="Environment"]')!;
|
||||
await act(async () => { environment.value = "linux-ssh"; environment.dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
await settle();
|
||||
await connect("OpenAI");
|
||||
expect(container.textContent).toContain("Configure your agent");
|
||||
await click("Finish setup");
|
||||
expect(api.testEnvironment).toHaveBeenLastCalledWith("company-1", "codex_local", expect.objectContaining({ environmentId: "linux-ssh", runner: "legacy" }));
|
||||
expect(api.hire.mock.calls[0][1]).toMatchObject({ adapterType: "codex_local", runner: "legacy" });
|
||||
@@ -392,7 +416,6 @@ describe("New agent setup", () => {
|
||||
await act(async () => (container.querySelector("summary") as HTMLElement).click());
|
||||
expect(container.querySelector('[aria-label="Runner"]')?.textContent).toBe("Legacy runner (default)");
|
||||
expect(adapterApi.list).toHaveBeenCalledWith({ companyId: "company-1", environmentId: "local-1" });
|
||||
await connect("OpenAI");
|
||||
await click("Finish setup");
|
||||
expect(api.testEnvironment).toHaveBeenCalledTimes(2);
|
||||
expect(api.testEnvironment).toHaveBeenLastCalledWith("company-1", "codex_local", expect.objectContaining({ environmentId: "local-1" }));
|
||||
@@ -557,7 +580,8 @@ describe("New agent setup", () => {
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await settle();
|
||||
expect(container.querySelector('select[aria-label="Environment"]')).toBeNull();
|
||||
expect(container.querySelector('select[aria-label="Environment"]')).not.toBeNull();
|
||||
await click("Connect model");
|
||||
await click("Complete subscription login");
|
||||
await click("Finish setup");
|
||||
expect(api.hire.mock.calls[0][1]).toMatchObject({ defaultEnvironmentId: "login-env", runtimeConfig: { aiConnection: { method: "subscription" } } });
|
||||
@@ -597,10 +621,8 @@ describe("New agent setup", () => {
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await settle();
|
||||
if (container.querySelector('[role="radiogroup"][aria-label="Connection type"]')) {
|
||||
await click("GrokAPI key");
|
||||
await click("Use saved API key");
|
||||
}
|
||||
expect(container.textContent).toContain("Configure your agent");
|
||||
await click("Run test");
|
||||
expect(api.testEnvironment).toHaveBeenCalledWith("company-1", "paperclip_runner", expect.objectContaining({ environmentId: "grok-sandbox" }));
|
||||
await click("Finish setup");
|
||||
expect(api.hire.mock.calls[0][1].defaultEnvironmentId).toBe("grok-sandbox");
|
||||
|
||||
Reference in new issue
Block a user