mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
Prepare agent-owned environments during hiring (#17761)
<!-- entire-trail-link-start --> https://entire.com/gh/paperclipai/-/trails/1/changes/paperclip/1 <!-- entire-trail-link-end --> ## Thinking Path > - Paperclip manages agents and their execution environments. > - New agent setup saves an inactive agent before model connection tests. > - Some plugins need that agent to prepare its execution resource. > - The wizard tested without the agent ID, while the setup hold prevented preparation. > - Environment changes also discarded the selected model account. > - This change prepares the saved draft during setup and keeps normal execution held. > - Users can test the prepared resource before they finish hiring. ## Linked Issues or Issue Description **What happened?** An agent-owned plugin environment could not acquire a test lease during hiring. Changing environments also returned the user to Connect and cleared the selected account. **Expected behavior** Keep the selected account, invalidate its test, and prepare the saved agent resource in the background. Test that same agent before finishing setup. **Steps to reproduce** 1. Start a new Claude or Codex agent and select a saved account. 2. Change to an agent-owned plugin environment. 3. Try to finish setup or reconnect the account. Related: #17750 defers probes until after hire submission. This change implements preparation during the wizard instead. The related PR search and ROADMAP review found no other implementation of this setup preparation path. ## What Changed - Add the optional `requiresAgent` driver declaration. Require agent lifecycle participation when it is set. - Prepare saved drafts on creation or environment changes. Keep the setup hold and wait in `verifying` without background verification scans. - Bind tests to the saved agent. Return a warning without allocating a lease when the required agent is absent or still preparing. - Preserve the account and configuration step when the environment changes. Save the selection and invalidate the old test. - Show active preparation and support authorized retries. Keep approval, company, budget, and normal task admission controls. - Document the contract and cover lifecycle, lease, manifest, and wizard behavior. ## Verification - Server and UI TypeScript checks passed. - 112 wizard and setup helper tests passed. The shared manifest suite passed. - 123 lifecycle, policy, lease, and environment-test API cases passed after the approval-gate fix. The diagnostic and driver suites and all 80 agent adapter route tests also passed. - Token gates, module boundaries, map validation, and whitespace checks passed. - All 66 lifecycle/policy cases passed after the stopping-draft permission fix. The new real-DB route case verifies creator success, other-creator denial, and refusal to complete a hire while stopping. - Full workspace typecheck and build were attempted. Both stop at the unchanged Rust Runner step because Cargo is not installed on this host. - CI on43be3c93dpassed all automated checks, including full test shards, typecheck, build, Rust checks, browser suites, and canary checks: https://github.com/paperclipai/paperclip/actions/runs/40368174530. - The full local repository test command was started. Two unchanged workspace tests assume Git's initial branch is master; this host defaults to main. Both fail in isolation under the host default and pass with process-scoped init.defaultBranch=master. The broad run was stopped after those confirmed fixture failures; CI supplies the full clean check. - Early new-test failures were fixture collisions and an unscoped sweep assertion; the fixtures and assertion were corrected. One route-suite load raced with SDK output regeneration; rebuilding first and running the suite afterward passed. - Initial CI exposed a route mock missing the new prerequisite lookup. The fixture was updated and a real HTTP no-lease case was added; all 54 cases in that route suite pass. Security review identified allocation before required approval; the new approval gate and its tests address it. - CI on50b5a08afreported 32 of 33 expected calls in the unchanged cleanup-history scheduling test. Its isolated case passed locally without cleanup-code changes; the original failure remains recorded and the final CI run passes on the current head. - Standard Greptile reviewed43be3c93dat 5/5. A subsequent deeper review identified runner selection, stale detail reads, error retention, and test file size issues; 70343387b addresses all four with resolved threads. Standard review returned 5/5 on that commit and again on rebased headfda42c5ab. The rebase preserves master's new managed environment skill declarations alongside requiresAgent. Full CI onfda42c5abpasses: 55 successful checks, two intentional skips, no failures. Run: https://github.com/paperclipai/paperclip/actions/runs/40405121641. - After those fixes, 133 UI tests and 61 lifecycle/policy cases pass; all eight focused UI regressions fail against the previous implementation and pass with the fix. Token, map, and whitespace gates pass. The stale-read fixture initially omitted the setup URL because navigation is mocked; following the URL activates the real detail query and reproduces the race. - UI TypeScript passes. This host's server dependency links now point to a separate scratch checkout lacking requiresAgent, so the ordinary server check reports that missing property. A no-emit check pinned to this branch's shared declarations passes; it disables declaration generation and supplies explicit local type roots. The first temporary configuration lacked those type roots; the corrected configuration passes. CI remains the full workspace verification. - After rebasing onto8373d7867, all 133 UI, 61 lifecycle/policy, and 44 manifest cases pass again, as do UI TypeScript, token, map, and whitespace checks. The server no-emit check now hits three mismatched SDK/Runner declarations from the scratch dependency links; these are outside this change. Full CI builds the complete dependency set. - Review cycle: the first Apex review scored 3/5 with four findings. All were fixed together, the branch was rebased to resolve a schema conflict, full CI and standard Greptile passed, and a second Apex review returned 5/5 onfda42c5abwith zero new comments. All six review threads are resolved, and the PR is conflict-free. Review: https://github.com/paperclipai/paperclip/pull/17761#issuecomment-6422635718. - No live provider resource was created and no deployment was performed. ## Risks Plugins must opt in and ship a compatible declaration. Companies requiring hire approval keep drafts paused and defer allocation and verification until approval. Other companies prepare drafts during the wizard under their existing agent-creation authority; the setup hold still blocks ordinary work. Preparation uses existing lifecycle ownership, retry, and cleanup paths. The open wizard polls only while preparation or stopping is active. No database migration is required. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, tool use, and code execution. The exact serving model ID and context window size are not exposed. ## 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 described the issue in-PR using the bug report fields - [x] I have not referenced internal or instance-local issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] I have run the targeted tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation - [x] I have considered and documented the risks - [x] All Paperclip gates are green — 55 successful checks and two intentional skips onfda42c5ab- [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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
c7c12dbc79
commit
835966dec8
25 files changed
+509
-68
No files matched your search
@@ -46,6 +46,11 @@ const mockEnvironmentRuntime = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const mockResolveEnvironmentExecutionTarget = vi.hoisted(() => vi.fn());
|
||||
const mockPluginEnvironmentRequiresAgent = vi.hoisted(() => vi.fn(async () => false));
|
||||
vi.mock("../services/plugin-environment-driver.js", async importOriginal => ({
|
||||
...(await importOriginal<typeof import("../services/plugin-environment-driver.js")>()),
|
||||
pluginEnvironmentRequiresAgent: mockPluginEnvironmentRequiresAgent,
|
||||
}));
|
||||
const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
get: vi.fn(async () => ({ defaultEnvironmentId: null as string | null })),
|
||||
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false, executionMode: "local" })),
|
||||
@@ -1053,6 +1058,19 @@ describe("agent test-environment route", () => {
|
||||
mockResolveNewAgentRunnerForCompany.mockClear();
|
||||
});
|
||||
|
||||
it("reports the missing agent prerequisite without acquiring a lease or probing the host", async () => {
|
||||
mockPluginEnvironmentRequiresAgent.mockResolvedValueOnce(true);
|
||||
mockResolveNewAgentRunnerForCompany.mockResolvedValueOnce({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } });
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/adapters/codex_local/test-environment")
|
||||
.send({ adapterConfig: {}, environmentId: pluginEnvironment.id });
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body).toMatchObject({ status: "warn", checks: [{ code: "environment_agent_required", level: "warn" }] });
|
||||
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
|
||||
expect(testEnvironmentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks the plugin driver against the resolved Runner adapter, not the requested harness", async () => {
|
||||
mockResolveNewAgentRunnerForCompany.mockResolvedValueOnce({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude" } });
|
||||
const app = await createApp();
|
||||
|
||||
@@ -64,6 +64,30 @@ 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" }]);
|
||||
await db.update(companies).set({ requireBoardApprovalForNewAgents: true }).where(eq(companies.id, companyId));
|
||||
await db.update(agents).set({ lifecycleState: "paused", pauseReason: "setup" }).where(eq(agents.id, agentId));
|
||||
expect((await setup.resolveAdapterTestExecutionContext({ ...input, agentId })).fallbackChecks).toMatchObject([{ code: "environment_agent_approval_required", 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";
|
||||
@@ -116,29 +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("freezes the final setup configuration for hire approval", async () => {
|
||||
const lifecycle = createAgentLifecycle(db);
|
||||
@@ -160,6 +139,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect((await current(setup.id)).lifecycleHolds).toEqual(["setup"]);
|
||||
});
|
||||
|
||||
|
||||
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));
|
||||
await db.insert(companyMemberships).values(["board", "other-creator"].map(principalId => ({ companyId, principalType: "user", principalId, membershipRole: "member" })));
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,14 +53,17 @@ 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);
|
||||
if (restartPause && next === "paused") next = "pausing";
|
||||
if (command === "complete" && next === "ready" && holds.length) next = "pausing";
|
||||
if (command === "complete" && next === "paused" && !holds.length) next = "resuming";
|
||||
if (["complete", "reconcile"].includes(command) && next === "paused" && holds.length === 1 && holds[0] === "setup"
|
||||
&& await effects.setupRequiresPreparation(tx, agent.companyId, agent.defaultEnvironmentId)) next = "preparing";
|
||||
if (next === agent.lifecycleState) {
|
||||
if (JSON.stringify(holds) !== JSON.stringify(agent.lifecycleHolds) || agent.pauseReason !== (holds[0] ?? null)) {
|
||||
const [updated] = await tx.update(agents).set({ lifecycleHolds: holds, pauseReason: holds[0] ?? null }).where(eq(agents.id, agent.id)).returning();
|
||||
@@ -131,7 +140,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 +188,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.lifecycleState === "pausing" || !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", "pausing"].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, companies, 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,25 @@ 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, pauseReason: agents.pauseReason,
|
||||
requiresApproval: companies.requireBoardApprovalForNewAgents }).from(agents)
|
||||
.innerJoin(companies, eq(companies.id, agents.companyId))
|
||||
.where(and(eq(agents.id, input.agentId), eq(agents.companyId, input.companyId))) : [];
|
||||
if (agent?.state === "paused" && agent.pauseReason === "setup" && agent.requiresApproval) return {
|
||||
executionTarget: null, environmentName: environment.name, fallbackChecks: [{
|
||||
code: "environment_agent_approval_required", level: "warn",
|
||||
message: "The selected account will be verified on the agent computer after hire approval.",
|
||||
}], release: noopRelease,
|
||||
};
|
||||
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,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companies, type Db } from "@paperclipai/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as lifecycle from "../modules/agent-lifecycle/index.js";
|
||||
import { withAccountingTransaction } from "./accounting-transaction.js";
|
||||
import { budgetServiceInTransaction, policyBlocks, type BudgetServiceHooks } from "./budgets.js";
|
||||
@@ -12,12 +13,25 @@ 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 [company] = await db.select({ requiresApproval: companies.requireBoardApprovalForNewAgents }).from(companies).where(eq(companies.id, companyId));
|
||||
if (!company || company.requiresApproval) 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
|
||||
|
||||
Reference in new issue
Block a user