mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
merge: integrate agent lifecycle ownership from master
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
ad5d8826ad
159 files changed
+7173
-2805
No files matched your search
@@ -413,3 +413,9 @@ describe("adapter auth-signal route", () => {
|
||||
expect(mockResolveEnvironmentExecutionTarget).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("../services/agent-lifecycle.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../services/agent-lifecycle.js")>();
|
||||
return { ...actual, createAgentLifecycle: () => ({
|
||||
}) };
|
||||
});
|
||||
@@ -1225,3 +1225,10 @@ describe("agent routes adapter validation", () => {
|
||||
expect(mockAgentService.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("../services/agent-lifecycle.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../services/agent-lifecycle.js")>();
|
||||
return { ...actual, createAgentLifecycle: () => ({
|
||||
requestHire: (...args: unknown[]) => mockAgentService.create(...args),
|
||||
}) };
|
||||
});
|
||||
@@ -212,7 +212,10 @@ describe("internal agent commentary through both transports", () => {
|
||||
await server.db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId, status: "running", invocationSource: "on_demand" });
|
||||
await submitAgentCommentary(server.db, { companyId: f.companyId, agentId, runId }, { ...input, kind: "complaint" });
|
||||
expect((await rows({ ...f, runId }))[0].issueId).toBeNull();
|
||||
if (owner === "agent") await agentService(server.db).remove(agentId);
|
||||
if (owner === "agent") {
|
||||
await server.db.update(agents).set({ lifecycleState: "terminated", status: "terminated" }).where(eq(agents.id, agentId));
|
||||
await agentService(server.db).remove(agentId);
|
||||
}
|
||||
else await companyService(server.db).remove(f.companyId);
|
||||
expect(await rows({ ...f, runId })).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -755,3 +755,13 @@ describe("agent cross-tenant route authorization", () => {
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("../services/agent-lifecycle.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../services/agent-lifecycle.js")>();
|
||||
return { ...actual, createAgentLifecycle: () => ({
|
||||
requestHire: (...args: unknown[]) => mockAgentService.create(...args),
|
||||
pauseAgent: (...args: unknown[]) => mockAgentService.pause(...args),
|
||||
resumeAgent: (...args: unknown[]) => mockAgentService.resume(...args),
|
||||
terminateAgent: (...args: unknown[]) => mockAgentService.terminate(...args),
|
||||
}) };
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAgentLifecycle } from "../services/agent-lifecycle.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
@@ -50,7 +51,7 @@ describeDatabase("new agent configuration defaults", () => {
|
||||
issuePrefix: `PD${companyId.slice(0, 6).toUpperCase()}`,
|
||||
});
|
||||
const create = (name: string, permissions: Record<string, unknown> = {}, metadata?: Record<string, unknown>) =>
|
||||
agentService(db).create(companyId, {
|
||||
createAgentLifecycle(db).requestHire(companyId, {
|
||||
name,
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
@@ -130,6 +131,7 @@ describeDatabase("new agent configuration defaults", () => {
|
||||
expect(await db.select().from(principalPermissionGrants)
|
||||
.where(eq(principalPermissionGrants.principalId, standard.id))).toEqual([]);
|
||||
|
||||
await db.update(agents).set({ status: "terminated", lifecycleState: "terminated" }).where(eq(agents.id, peer.id));
|
||||
await agentService(db).remove(peer.id);
|
||||
expect(await db.select().from(principalPermissionGrants)
|
||||
.where(eq(principalPermissionGrants.principalId, peer.id))).toEqual([]);
|
||||
@@ -190,7 +192,7 @@ describeDatabase("new agent configuration defaults", () => {
|
||||
name: "Pending permission default",
|
||||
issuePrefix: `PP${companyId.slice(0, 6).toUpperCase()}`,
|
||||
});
|
||||
const pending = await agentService(db).create(companyId, {
|
||||
const pending = await createAgentLifecycle(db).requestHire(companyId, {
|
||||
name: "Pending hire",
|
||||
status: "pending_approval",
|
||||
role: "engineer",
|
||||
@@ -201,7 +203,7 @@ describeDatabase("new agent configuration defaults", () => {
|
||||
expect(await db.select().from(principalPermissionGrants)
|
||||
.where(eq(principalPermissionGrants.companyId, companyId))).toEqual([]);
|
||||
|
||||
const result = await agentService(db).activatePendingApproval(pending.id);
|
||||
const result = await createAgentLifecycle(db).approveHire(pending.id);
|
||||
expect(result?.activated).toBe(true);
|
||||
expect((await db.select().from(principalPermissionGrants)
|
||||
.where(eq(principalPermissionGrants.companyId, companyId)))
|
||||
@@ -220,7 +222,7 @@ describeDatabase("new agent configuration defaults", () => {
|
||||
name: "Invited permission default",
|
||||
issuePrefix: `PI${companyId.slice(0, 6).toUpperCase()}`,
|
||||
});
|
||||
const invited = await agentService(db).create(companyId, {
|
||||
const invited = await createAgentLifecycle(db).requestHire(companyId, {
|
||||
name: "Invited agent",
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
|
||||
@@ -1709,3 +1709,10 @@ describe("adapter device-login routes", () => {
|
||||
expect(loggedText).not.toContain("provider-lease-");
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("../services/agent-lifecycle.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../services/agent-lifecycle.js")>();
|
||||
return { ...actual, createAgentLifecycle: () => ({
|
||||
requestHire: (...args: unknown[]) => mockAgentService.create(...args),
|
||||
}) };
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AdapterEnvironmentTestResult } from "@paperclipai/adapter-utils";
|
||||
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared";
|
||||
import { assertHarnessTestPassed } from "../services/agent-harness-verification.js";
|
||||
|
||||
describe("saved harness verification", () => {
|
||||
const result = (status: AdapterEnvironmentTestResult["status"], codes: string[]): AdapterEnvironmentTestResult => ({
|
||||
adapterType: "test", status, testedAt: new Date().toISOString(),
|
||||
checks: codes.map(code => ({ code, level: "info", message: code })),
|
||||
});
|
||||
it.each([
|
||||
["fail", []], ["warn", ["claude_hello_probe_auth_required"]],
|
||||
["warn", ["gemini_hello_probe_timed_out"]], ["pass", ["claude_hello_probe_skipped_custom_command"]],
|
||||
["warn", ["claude_acp_anthropic_api_key_detected", "claude_acp_login_probe_unavailable"]],
|
||||
["warn", ["cli_version_probe_mismatch"]], ["pass", [ADAPTER_AUTH_MISSING_CHECK_CODE]],
|
||||
["warn", ["claude_hello_probe_passed", "claude_hello_probe_auth_required"]],
|
||||
] as const)("rejects %s with %j", (status, codes) => {
|
||||
expect(() => assertHarnessTestPassed(result(status, [...codes]))).toThrow("The harness test failed");
|
||||
});
|
||||
it.each([
|
||||
["pass", ["process_command_available"]], ["warn", ["claude_hello_probe_passed", "optional_environment_warning"]],
|
||||
["warn", ["claude_acp_anthropic_api_key_detected", "claude_hello_probe_passed"]],
|
||||
["pass", ["codex_hello_probe_succeeded"]], ["warn", ["ai_connection_api_key_reverified"]],
|
||||
] as const)("accepts %s with %j", (status, codes) => {
|
||||
expect(() => assertHarnessTestPassed(result(status, [...codes]))).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import { aiConnectionRouterService } from "../services/ai-connection-router.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { toolAccessService } from "../services/tool-access.js";
|
||||
import { waitForPendingRunFailureReports } from "../services/run-failure-report.js";
|
||||
import { configureAgentLifecycle } from "../services/agent-lifecycle.js";
|
||||
|
||||
const captureRunFailure = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../sentry.js", async (importOriginal) => ({
|
||||
@@ -374,7 +375,7 @@ describe("agent-created hires use managed AI connections", () => {
|
||||
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Other provider", role: "engineer", adapterType }));
|
||||
expect(agent.runtimeConfig.aiConnection).toMatchObject({ provider: otherProvider, mode: "responsible_user" });
|
||||
await expect(prepareManagedAiRuntime(db, { companyId: f.companyId, agentId: agent.id, responsibleUserId: f.userId, adapterType, binding: agent.runtimeConfig.aiConnection, config: agent.adapterConfig })).rejects.toMatchObject({ details: { code: "ai_connection_default_missing" } });
|
||||
expect(agent.status).toBe("idle");
|
||||
expect(agent).toMatchObject({ status: "paused", lifecycleState: "preparing" });
|
||||
});
|
||||
|
||||
for (const endpoint of ["agent-hires", "agents"]) {
|
||||
@@ -486,11 +487,17 @@ describe("agent-created hires use managed AI connections", () => {
|
||||
});
|
||||
|
||||
describe("hired agents sharing a subscription", () => {
|
||||
async function finishSetup(agentId: string) {
|
||||
// These tests cover task credentials after setup; lifecycle tests cover verification.
|
||||
const worker = configureAgentLifecycle(db, { requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost: async () => "complete" });
|
||||
try { await worker.process(agentId); } finally { await worker.stop(); }
|
||||
}
|
||||
it("keeps a credential-lock timeout on automatic retry without blocking the task or starting a provider", async () => {
|
||||
const f = await fixture("openai", "subscription");
|
||||
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Waiting teammate", role: "engineer", adapterType: f.adapterType, runner: "legacy", reportsTo: f.agentId, adapterConfig: { engine: "cli" }, runtimeConfig: { heartbeat: { enabled: false } } }));
|
||||
// Only an operator may set host execution paths after the agent is hired.
|
||||
await db.update(agents).set({ adapterConfig: { ...agent.adapterConfig, cwd: home } }).where(eq(agents.id, agent.id));
|
||||
await finishSetup(agent.id);
|
||||
const [issue] = await db.insert(issues).values({ companyId: f.companyId, title: "Wait for credential rotation", status: "todo", assigneeAgentId: agent.id, responsibleUserId: f.userId, createdByUserId: f.userId }).returning();
|
||||
const execute = vi.fn(async () => ({ exitCode: 0, signal: null, timedOut: false, resultJson: {} }));
|
||||
registerServerAdapter({ ...getServerAdapter(f.adapterType), execute });
|
||||
@@ -534,6 +541,7 @@ describe("hired agents sharing a subscription", () => {
|
||||
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Concurrent teammate", role: "engineer", adapterType: f.adapterType, runner: "legacy", reportsTo: f.agentId, adapterConfig: { engine: "cli" }, runtimeConfig: { heartbeat: { enabled: false } } }));
|
||||
// Host working directories are configured by an operator, not an agent key.
|
||||
await db.update(agents).set({ adapterConfig: { ...agent.adapterConfig, cwd: home } }).where(eq(agents.id, agent.id));
|
||||
await finishSetup(agent.id);
|
||||
const [issue] = await db.insert(issues).values({ companyId: f.companyId, title: "Subscription child task", status: "todo", assigneeAgentId: agent.id, responsibleUserId: f.userId, createdByUserId: f.userId }).returning();
|
||||
const parentRuntime = await prepareManagedAiRuntime(db, { companyId: f.companyId, agentId: f.agentId, responsibleUserId: f.userId, adapterType: f.adapterType, binding: f.binding, config: { cwd: home } });
|
||||
const execute = vi.fn(async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAgentLifecycle } from "../services/agent-lifecycle.js";
|
||||
import { randomBytes, randomUUID, verify, generateKeyPairSync } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -74,7 +75,7 @@ describe("agent cryptographic identity", () => {
|
||||
});
|
||||
|
||||
it("creates identities in the agent creation transaction and rolls both writes back on failure", async () => {
|
||||
const created = await agentService(db).create(companyId, { name: "New identity", role: "engineer", status: "pending_approval" });
|
||||
const created = await createAgentLifecycle(db).requestHire(companyId, { name: "New identity", role: "engineer", status: "pending_approval" });
|
||||
expect(await agentIdentityService(db).getPublicIdentity(companyId, created.id)).toMatchObject({ algorithm: "Ed25519" });
|
||||
const id = randomUUID();
|
||||
await expect(db.transaction(async tx => {
|
||||
@@ -85,7 +86,7 @@ describe("agent cryptographic identity", () => {
|
||||
expect(await db.select().from(agents).where(eq(agents.id, id))).toHaveLength(0);
|
||||
expect(await agentIdentityService(db).getPublicIdentity(companyId, id)).toBeNull();
|
||||
const fail = vi.spyOn(localEncryptedProvider, "createSecret").mockRejectedValueOnce(new Error("encryption unavailable"));
|
||||
await expect(agentService(db).create(companyId, { name: "Must roll back", role: "engineer", status: "pending_approval" })).rejects.toThrow("encryption unavailable");
|
||||
await expect(createAgentLifecycle(db).requestHire(companyId, { name: "Must roll back", role: "engineer", status: "pending_approval" })).rejects.toThrow("encryption unavailable");
|
||||
fail.mockRestore();
|
||||
expect(await db.select().from(agents).where(eq(agents.name, "Must roll back"))).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -844,3 +844,10 @@ describe("agent instructions bundle routes", () => {
|
||||
expect(res.body.adapterConfig.instructionsFilePath).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("../services/agent-lifecycle.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../services/agent-lifecycle.js")>();
|
||||
return { ...actual, createAgentLifecycle: () => ({
|
||||
requestHire: (...args: unknown[]) => mockAgentService.create(...args),
|
||||
}) };
|
||||
});
|
||||
@@ -1940,3 +1940,9 @@ describe("agent live run routes", () => {
|
||||
expect(mockWorkspaceDiffReprojection.persist).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("../services/agent-lifecycle.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../services/agent-lifecycle.js")>();
|
||||
return { ...actual, createAgentLifecycle: () => ({
|
||||
}) };
|
||||
});
|
||||
@@ -73,8 +73,8 @@ const mockApprovalService = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
findOpenHireApprovalForAgent: vi.fn(),
|
||||
approve: vi.fn(),
|
||||
reject: vi.fn(),
|
||||
approveHire: vi.fn(),
|
||||
rejectHire: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockBudgetService = vi.hoisted(() => ({
|
||||
@@ -326,8 +326,8 @@ describe("agent permission routes", () => {
|
||||
mockApprovalService.create.mockReset();
|
||||
mockApprovalService.getById.mockReset();
|
||||
mockApprovalService.findOpenHireApprovalForAgent.mockReset();
|
||||
mockApprovalService.approve.mockReset();
|
||||
mockApprovalService.reject.mockReset();
|
||||
mockApprovalService.approveHire.mockReset();
|
||||
mockApprovalService.rejectHire.mockReset();
|
||||
mockBudgetService.upsertPolicy.mockReset();
|
||||
mockHeartbeatService.listTaskSessions.mockReset();
|
||||
mockHeartbeatService.resetRuntimeSession.mockReset();
|
||||
@@ -692,6 +692,16 @@ describe("agent permission routes", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([{ spentMonthlyCents: 0 }, { name: "Renamed", spentMonthlyCents: 0 }])(
|
||||
"rejects accounting fields before applying an agent update: %j", async payload => {
|
||||
const app = await createApp({ type: "board", userId: "board-user", source: "session", isInstanceAdmin: true, companyIds: [companyId] });
|
||||
const res = await requestApp(app, baseUrl => request(baseUrl).patch(`/api/agents/${agentId}`).send(payload));
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.details).toEqual(expect.arrayContaining([expect.objectContaining({ path: ["spentMonthlyCents"] })]));
|
||||
expect(mockAgentService.update).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("blocks agent updates for authenticated company members without agent admin permission", async () => {
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
|
||||
@@ -1297,7 +1307,7 @@ describe("agent permission routes", () => {
|
||||
expect.objectContaining({
|
||||
status: "idle",
|
||||
}),
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "agent-admin-user", claudeLogin: { storedSessionId: null, ownerUserId: "agent-admin-user", applyExistingWithoutClaim: false } },
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "agent-admin-user", responsibleUserId: "agent-admin-user", claudeLogin: { storedSessionId: null, ownerUserId: "agent-admin-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
@@ -1427,7 +1437,7 @@ describe("agent permission routes", () => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1463,7 +1473,7 @@ describe("agent permission routes", () => {
|
||||
model: DEFAULT_OPENCODE_LOCAL_MODEL,
|
||||
}),
|
||||
}),
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1501,7 +1511,7 @@ describe("agent permission routes", () => {
|
||||
model: "anthropic/claude-sonnet-4-5",
|
||||
}),
|
||||
}),
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1543,7 +1553,7 @@ describe("agent permission routes", () => {
|
||||
{
|
||||
runnerResolved: true,
|
||||
aiConnectionInstall: undefined,
|
||||
createdByUserId: "board-user",
|
||||
createdByUserId: "board-user", responsibleUserId: "board-user",
|
||||
claudeLogin: {
|
||||
storedSessionId: null,
|
||||
ownerUserId: "board-user",
|
||||
@@ -1564,9 +1574,10 @@ describe("agent permission routes", () => {
|
||||
status: "idle",
|
||||
};
|
||||
mockAgentService.getById.mockResolvedValue(pendingAgent);
|
||||
mockAgentService.activatePendingApproval.mockResolvedValue({
|
||||
mockApprovalService.approveHire.mockResolvedValue({
|
||||
agent: approvedAgent,
|
||||
activated: true,
|
||||
applied: true,
|
||||
approval: null,
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
@@ -1582,8 +1593,7 @@ describe("agent permission routes", () => {
|
||||
.send({}));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockAgentService.activatePendingApproval).toHaveBeenCalledWith(agentId);
|
||||
expect(mockApprovalService.approve).not.toHaveBeenCalled();
|
||||
expect(mockApprovalService.approveHire).toHaveBeenCalledWith(agentId, "board-user");
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
companyId,
|
||||
actorType: "user",
|
||||
@@ -1604,19 +1614,9 @@ describe("agent permission routes", () => {
|
||||
...baseAgent,
|
||||
status: "idle",
|
||||
};
|
||||
// First getById (getAccessibleAgent) sees the pending agent; the second
|
||||
// (after the approval resolves) sees the activated agent.
|
||||
mockAgentService.getById
|
||||
.mockResolvedValueOnce(pendingAgent)
|
||||
.mockResolvedValue(approvedAgent);
|
||||
mockApprovalService.findOpenHireApprovalForAgent.mockResolvedValue({
|
||||
id: "approval-1",
|
||||
companyId,
|
||||
type: "hire_agent",
|
||||
status: "pending",
|
||||
payload: { agentId },
|
||||
});
|
||||
mockApprovalService.approve.mockResolvedValue({
|
||||
mockAgentService.getById.mockResolvedValue(pendingAgent);
|
||||
mockApprovalService.approveHire.mockResolvedValue({
|
||||
agent: approvedAgent,
|
||||
approval: { id: "approval-1", status: "approved" },
|
||||
applied: true,
|
||||
});
|
||||
@@ -1635,7 +1635,7 @@ describe("agent permission routes", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// The shared approval flow handles activation; we must not double-activate.
|
||||
expect(mockApprovalService.approve).toHaveBeenCalledWith("approval-1", "board-user");
|
||||
expect(mockApprovalService.approveHire).toHaveBeenCalledWith(agentId, "board-user");
|
||||
expect(mockAgentService.activatePendingApproval).not.toHaveBeenCalled();
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "agent.approved",
|
||||
@@ -1652,19 +1652,9 @@ describe("agent permission routes", () => {
|
||||
...baseAgent,
|
||||
status: "terminated",
|
||||
};
|
||||
// getAccessibleAgent sees the pending agent; after the rejection resolves
|
||||
// (which terminates internally) the route re-reads the terminated agent.
|
||||
mockAgentService.getById
|
||||
.mockResolvedValueOnce(pendingAgent)
|
||||
.mockResolvedValue(terminatedAgent);
|
||||
mockApprovalService.findOpenHireApprovalForAgent.mockResolvedValue({
|
||||
id: "approval-1",
|
||||
companyId,
|
||||
type: "hire_agent",
|
||||
status: "pending",
|
||||
payload: { agentId },
|
||||
});
|
||||
mockApprovalService.reject.mockResolvedValue({
|
||||
mockAgentService.getById.mockResolvedValue(pendingAgent);
|
||||
mockApprovalService.rejectHire.mockResolvedValue({
|
||||
agent: terminatedAgent,
|
||||
approval: { id: "approval-1", status: "rejected" },
|
||||
applied: true,
|
||||
});
|
||||
@@ -1687,8 +1677,8 @@ describe("agent permission routes", () => {
|
||||
.send({}));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockApprovalService.reject).toHaveBeenCalledWith("approval-1", "board-user");
|
||||
// reject() terminates the agent internally; the route must not terminate again.
|
||||
expect(mockApprovalService.rejectHire).toHaveBeenCalledWith(agentId, "board-user");
|
||||
// The hire decision rejects the pending agent; the route must not terminate again.
|
||||
expect(mockAgentService.terminate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1718,7 +1708,7 @@ describe("agent permission routes", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockAgentService.terminate).toHaveBeenCalledWith(agentId);
|
||||
expect(mockApprovalService.findOpenHireApprovalForAgent).not.toHaveBeenCalled();
|
||||
expect(mockApprovalService.reject).not.toHaveBeenCalled();
|
||||
expect(mockApprovalService.rejectHire).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects direct approval for agents that are not pending approval", async () => {
|
||||
@@ -1735,7 +1725,7 @@ describe("agent permission routes", () => {
|
||||
.send({}));
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(mockAgentService.activatePendingApproval).not.toHaveBeenCalled();
|
||||
expect(mockApprovalService.approveHire).not.toHaveBeenCalled();
|
||||
expect(mockLogActivity).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "agent.approved",
|
||||
}));
|
||||
@@ -1774,7 +1764,7 @@ describe("agent permission routes", () => {
|
||||
expect.objectContaining({
|
||||
defaultEnvironmentId: environmentId,
|
||||
}),
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1861,7 +1851,7 @@ describe("agent permission routes", () => {
|
||||
adapterType: expectedAdapterType,
|
||||
defaultEnvironmentId: environmentId,
|
||||
}),
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
if (adapterCase.adapterType === "codex_local") {
|
||||
expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "codex" });
|
||||
@@ -2346,3 +2336,12 @@ describe("agent permission routes", () => {
|
||||
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("../services/agent-lifecycle.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../services/agent-lifecycle.js")>();
|
||||
return { ...actual, createAgentLifecycle: () => ({
|
||||
requestHire: (...args: unknown[]) => mockAgentService.create(...args),
|
||||
approveHire: (...args: unknown[]) => mockAgentService.activatePendingApproval(...args),
|
||||
terminateAgent: (...args: unknown[]) => mockAgentService.terminate(...args),
|
||||
}) };
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
defaultAgentPermissions,
|
||||
normalizeAgentPermissions,
|
||||
permissionsImplyLowTrust,
|
||||
} from "../services/agent-permissions.js";
|
||||
} from "../lib/agent-permissions.js";
|
||||
|
||||
describe("agent permissions service", () => {
|
||||
it("grants agent-creation authority to new agents by default", () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAgentLifecycle } from "../services/agent-lifecycle.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
@@ -20,15 +21,17 @@ describe("persisted agent personas", () => {
|
||||
afterAll(async () => { await database?.cleanup(); });
|
||||
it("randomizes once, accepts a saved draft and preserves identity across edits and revision rollback", async () => {
|
||||
const service = agentService(db);
|
||||
const random = await service.create(companyId, { name: "Random", role: "engineer", adapterType: "process" });
|
||||
const serviceLifecycle = createAgentLifecycle(db);
|
||||
const random = await serviceLifecycle.requestHire(companyId, { name: "Random", role: "engineer", adapterType: "process" });
|
||||
expect(agentAppearanceSchema.safeParse(random.appearance).success).toBe(true);
|
||||
const appearance = appearanceForPalette("arctic-blue");
|
||||
const original = await service.create(companyId, { name: "Draft", role: "engineer", adapterType: "process", appearance });
|
||||
const original = await serviceLifecycle.requestHire(companyId, { name: "Draft", role: "engineer", adapterType: "process", appearance });
|
||||
expect(original.appearance).toEqual(appearance);
|
||||
const updated = await service.update(original.id, { name: "Renamed" }, { recordRevision: { source: "test" } });
|
||||
expect(updated?.appearance).toEqual(appearance);
|
||||
const [revision] = await service.listConfigRevisions(original.id);
|
||||
await service.update(original.id, { name: "Changed again", status: "paused" });
|
||||
await service.update(original.id, { name: "Changed again" });
|
||||
await serviceLifecycle.pauseAgent(original.id);
|
||||
const restored = await service.rollbackConfigRevision(original.id, revision.id, {});
|
||||
expect(restored?.appearance).toEqual(appearance);
|
||||
expect((await agentService(db).getById(original.id))?.avatarUrl).toContain("/arctic-blue/rest.png?size=512");
|
||||
|
||||
@@ -1020,7 +1020,7 @@ describe("agent skill routes", () => {
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } },
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "local-board", responsibleUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
expect(mockTrackAgentCreated).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
@@ -1070,7 +1070,7 @@ describe("agent skill routes", () => {
|
||||
expect.objectContaining({
|
||||
role: "security",
|
||||
}),
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } },
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "local-board", responsibleUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
expect(mockTrackAgentCreated).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
@@ -1532,7 +1532,7 @@ describe("agent skill routes", () => {
|
||||
{
|
||||
runnerResolved: true,
|
||||
aiConnectionInstall: undefined,
|
||||
createdByUserId: "local-board",
|
||||
createdByUserId: "local-board", responsibleUserId: "local-board",
|
||||
claudeLogin: {
|
||||
storedSessionId: null,
|
||||
ownerUserId: "local-board",
|
||||
@@ -1616,3 +1616,10 @@ describe("agent skill routes", () => {
|
||||
expect(mockAgentInstructionsService.materializeManagedBundle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("../services/agent-lifecycle.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../services/agent-lifecycle.js")>();
|
||||
return { ...actual, createAgentLifecycle: () => ({
|
||||
requestHire: (...args: unknown[]) => mockAgentService.create(...args),
|
||||
}) };
|
||||
});
|
||||
@@ -116,10 +116,7 @@ vi.mock("../services/ai-connection-runtime.js", async (importOriginal) => ({
|
||||
},
|
||||
}));
|
||||
const mockValidateAiApiKey = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
vi.mock("../routes/ai-connections.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../routes/ai-connections.js")>()),
|
||||
validateAiApiKey: mockValidateAiApiKey,
|
||||
}));
|
||||
vi.mock("../services/ai-api-key-test.js", () => ({ validateAiApiKey: mockValidateAiApiKey }));
|
||||
const mockMarkAuthenticationFailed = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
vi.mock("../services/ai-connections.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../services/ai-connections.js")>()),
|
||||
@@ -1030,3 +1027,9 @@ describe("agent test-environment route", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("../services/agent-lifecycle.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../services/agent-lifecycle.js")>();
|
||||
return { ...actual, createAgentLifecycle: () => ({
|
||||
}) };
|
||||
});
|
||||
@@ -416,3 +416,9 @@ describeEmbeddedPostgres("agents adapter-config user-secret resolution routes",
|
||||
function beforeEachActor(actor: TestActor) {
|
||||
currentActor = actor;
|
||||
}
|
||||
|
||||
vi.mock("../services/agent-lifecycle.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../services/agent-lifecycle.js")>();
|
||||
return { ...actual, createAgentLifecycle: () => ({
|
||||
}) };
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAgentLifecycle } from "../services/agent-lifecycle.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
@@ -362,7 +363,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
const scope = await seedScope();
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
|
||||
|
||||
const created = await agentService(db).create(scope.companyId, createInput(scope), {
|
||||
const created = await createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
|
||||
});
|
||||
|
||||
@@ -376,7 +377,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
|
||||
// A replay of the same claim inserts no second agent.
|
||||
await expect(
|
||||
agentService(db).create(scope.companyId, createInput(scope), {
|
||||
createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
|
||||
}),
|
||||
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
|
||||
@@ -386,7 +387,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
it("rejects a missing claim and inserts no binding", async () => {
|
||||
const scope = await seedScope();
|
||||
await expect(
|
||||
agentService(db).create(scope.companyId, createInput(scope), {
|
||||
createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: undefined, ownerUserId: scope.ownerUserId },
|
||||
}),
|
||||
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
|
||||
@@ -397,7 +398,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
const scope = await seedScope();
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
|
||||
await expect(
|
||||
agentService(db).create(scope.companyId, createInput(scope), {
|
||||
createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: "intruder" },
|
||||
}),
|
||||
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
|
||||
@@ -409,7 +410,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
const scope = await seedScope();
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() - 1_000);
|
||||
await expect(
|
||||
agentService(db).create(scope.companyId, createInput(scope), {
|
||||
createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
|
||||
}),
|
||||
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
|
||||
@@ -420,7 +421,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
const scope = await seedScope();
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000, "awaiting_code");
|
||||
await expect(
|
||||
agentService(db).create(scope.companyId, createInput(scope), {
|
||||
createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
|
||||
}),
|
||||
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
|
||||
@@ -432,8 +433,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
// Missing claim.
|
||||
const missingScope = await seedScope();
|
||||
messages.push(
|
||||
await agentService(db)
|
||||
.create(missingScope.companyId, createInput(missingScope), {
|
||||
await createAgentLifecycle(db).requestHire(missingScope.companyId, createInput(missingScope), {
|
||||
claudeLogin: { storedSessionId: undefined, ownerUserId: missingScope.ownerUserId },
|
||||
})
|
||||
.then(() => "no-error")
|
||||
@@ -443,8 +443,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
const foreignScope = await seedScope();
|
||||
const foreignSession = await seedStoredClaim(foreignScope, Date.now() + 60_000);
|
||||
messages.push(
|
||||
await agentService(db)
|
||||
.create(foreignScope.companyId, createInput(foreignScope), {
|
||||
await createAgentLifecycle(db).requestHire(foreignScope.companyId, createInput(foreignScope), {
|
||||
claudeLogin: { storedSessionId: foreignSession, ownerUserId: "intruder" },
|
||||
})
|
||||
.then(() => "no-error")
|
||||
@@ -454,8 +453,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
const expiredScope = await seedScope();
|
||||
const expiredSession = await seedStoredClaim(expiredScope, Date.now() - 1_000);
|
||||
messages.push(
|
||||
await agentService(db)
|
||||
.create(expiredScope.companyId, createInput(expiredScope), {
|
||||
await createAgentLifecycle(db).requestHire(expiredScope.companyId, createInput(expiredScope), {
|
||||
claudeLogin: { storedSessionId: expiredSession, ownerUserId: expiredScope.ownerUserId },
|
||||
})
|
||||
.then(() => "no-error")
|
||||
@@ -465,8 +463,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
const nonStoredScope = await seedScope();
|
||||
const nonStoredSession = await seedStoredClaim(nonStoredScope, Date.now() + 60_000, "submitting");
|
||||
messages.push(
|
||||
await agentService(db)
|
||||
.create(nonStoredScope.companyId, createInput(nonStoredScope), {
|
||||
await createAgentLifecycle(db).requestHire(nonStoredScope.companyId, createInput(nonStoredScope), {
|
||||
claudeLogin: { storedSessionId: nonStoredSession, ownerUserId: nonStoredScope.ownerUserId },
|
||||
})
|
||||
.then(() => "no-error")
|
||||
@@ -475,12 +472,11 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
// Already consumed.
|
||||
const consumedScope = await seedScope();
|
||||
const consumedSession = await seedStoredClaim(consumedScope, Date.now() + 60_000);
|
||||
await agentService(db).create(consumedScope.companyId, createInput(consumedScope), {
|
||||
await createAgentLifecycle(db).requestHire(consumedScope.companyId, createInput(consumedScope), {
|
||||
claudeLogin: { storedSessionId: consumedSession, ownerUserId: consumedScope.ownerUserId },
|
||||
});
|
||||
messages.push(
|
||||
await agentService(db)
|
||||
.create(consumedScope.companyId, createInput(consumedScope), {
|
||||
await createAgentLifecycle(db).requestHire(consumedScope.companyId, createInput(consumedScope), {
|
||||
claudeLogin: { storedSessionId: consumedSession, ownerUserId: consumedScope.ownerUserId },
|
||||
})
|
||||
.then(() => "no-error")
|
||||
@@ -495,10 +491,10 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
agentService(db).create(scope.companyId, createInput(scope), {
|
||||
createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
|
||||
}),
|
||||
agentService(db).create(scope.companyId, createInput(scope), {
|
||||
createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
|
||||
}),
|
||||
]);
|
||||
@@ -536,8 +532,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
});
|
||||
|
||||
await locked;
|
||||
const createPromise = agentService(db)
|
||||
.create(scope.companyId, createInput(scope), {
|
||||
const createPromise = createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
|
||||
})
|
||||
.then(() => "created")
|
||||
@@ -555,7 +550,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
it("removes the fixed binding on a normal update", async () => {
|
||||
const scope = await seedScope();
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
|
||||
const created = await agentService(db).create(scope.companyId, createInput(scope), {
|
||||
const created = await createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
|
||||
});
|
||||
|
||||
@@ -568,7 +563,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
it("re-points the binding to a plain value on a normal update", async () => {
|
||||
const scope = await seedScope();
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
|
||||
const created = await agentService(db).create(scope.companyId, createInput(scope), {
|
||||
const created = await createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
|
||||
});
|
||||
|
||||
@@ -583,7 +578,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
it("removes the binding by a move to another adapter type on a normal update", async () => {
|
||||
const scope = await seedScope();
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
|
||||
const created = await agentService(db).create(scope.companyId, createInput(scope), {
|
||||
const created = await createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
|
||||
});
|
||||
|
||||
@@ -603,7 +598,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
it("keeps an existing binding when the update changes another field", async () => {
|
||||
const scope = await seedScope();
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
|
||||
const created = await agentService(db).create(scope.companyId, createInput(scope), {
|
||||
const created = await createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
|
||||
});
|
||||
|
||||
@@ -616,7 +611,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
|
||||
it("rejects a newly introduced binding on update, because it carries no claim", async () => {
|
||||
const scope = await seedScope();
|
||||
const created = await agentService(db).create(scope.companyId, {
|
||||
const created = await createAgentLifecycle(db).requestHire(scope.companyId, {
|
||||
name: "Api Key Agent",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
@@ -645,7 +640,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
const scope = await seedScope();
|
||||
await seedStoredOwnerValue(scope);
|
||||
|
||||
const created = await agentService(db).create(scope.companyId, createInput(scope), {
|
||||
const created = await createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { ownerUserId: scope.ownerUserId, applyExistingWithoutClaim: true },
|
||||
});
|
||||
|
||||
@@ -666,9 +661,9 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
const scope = await seedScope();
|
||||
await seedStoredOwnerValue(scope);
|
||||
|
||||
const created = await agentService(
|
||||
const created = await createAgentLifecycle(
|
||||
db,
|
||||
).create(scope.companyId, { ...createInput(scope), status: "pending_approval" }, {
|
||||
).requestHire(scope.companyId, { ...createInput(scope), status: "pending_approval" }, {
|
||||
claudeLogin: { ownerUserId: scope.ownerUserId, applyExistingWithoutClaim: true },
|
||||
});
|
||||
|
||||
@@ -680,7 +675,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
it("binds the fixed reference on an update from a stored owner value with no claim", async () => {
|
||||
const scope = await seedScope();
|
||||
await seedStoredOwnerValue(scope);
|
||||
const created = await agentService(db).create(scope.companyId, {
|
||||
const created = await createAgentLifecycle(db).requestHire(scope.companyId, {
|
||||
name: "Claude Agent",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
@@ -706,7 +701,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
const scope = await seedScope();
|
||||
|
||||
await expect(
|
||||
agentService(db).create(scope.companyId, createInput(scope), {
|
||||
createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { ownerUserId: scope.ownerUserId, applyExistingWithoutClaim: true },
|
||||
}),
|
||||
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
|
||||
@@ -723,7 +718,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
// The route sets no owner for an agent actor. The gate rejects the no-claim
|
||||
// bind, so an agent actor never binds the fixed reference.
|
||||
await expect(
|
||||
agentService(db).create(scope.companyId, createInput(scope), {
|
||||
createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { ownerUserId: null, applyExistingWithoutClaim: true },
|
||||
}),
|
||||
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
|
||||
@@ -739,7 +734,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
foreignScope.ownerUserId = ownerScope.ownerUserId;
|
||||
|
||||
await expect(
|
||||
agentService(db).create(foreignScope.companyId, createInput(foreignScope), {
|
||||
createAgentLifecycle(db).requestHire(foreignScope.companyId, createInput(foreignScope), {
|
||||
claudeLogin: { ownerUserId: foreignScope.ownerUserId, applyExistingWithoutClaim: true },
|
||||
}),
|
||||
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
|
||||
@@ -753,7 +748,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
// The apply-existing flag is set, but the config carries no fixed binding. The
|
||||
// gate mints the fixed reference only when the client presents the exact fixed
|
||||
// binding, so the flag alone binds nothing and creates no fixed definition.
|
||||
const created = await agentService(db).create(
|
||||
const created = await createAgentLifecycle(db).requestHire(
|
||||
scope.companyId,
|
||||
{
|
||||
name: "Plain Agent",
|
||||
@@ -781,7 +776,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
// A client adds a secret id and a foreign owner to the binding. The persist
|
||||
// normalization keeps only the fixed reference fields, so the extra selectors
|
||||
// never reach the stored binding. The runtime derives the owner from context.
|
||||
const created = await agentService(db).create(
|
||||
const created = await createAgentLifecycle(db).requestHire(
|
||||
scope.companyId,
|
||||
createInput(scope, {
|
||||
CLAUDE_CODE_OAUTH_TOKEN: {
|
||||
@@ -804,7 +799,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
it("resolves the bound reference to the stored value without leaking it in a log", async () => {
|
||||
const scope = await seedScope();
|
||||
await seedStoredOwnerValue(scope, "sk-secret-resolve");
|
||||
const created = await agentService(db).create(scope.companyId, createInput(scope), {
|
||||
const created = await createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { ownerUserId: scope.ownerUserId, applyExistingWithoutClaim: true },
|
||||
});
|
||||
|
||||
@@ -858,7 +853,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
const scope = await seedScope();
|
||||
const parent = await seedParentAgent(scope);
|
||||
|
||||
const created = await agentService(db).create(scope.companyId, createInput(scope), {
|
||||
const created = await createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { inheritedFromAgentId: parent.id },
|
||||
});
|
||||
|
||||
@@ -871,7 +866,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
const scope = await seedScope();
|
||||
const parent = await seedParentAgent(scope, { version: 5 });
|
||||
|
||||
const created = await agentService(db).create(
|
||||
const created = await createAgentLifecycle(db).requestHire(
|
||||
scope.companyId,
|
||||
createInput(scope, { CLAUDE_CODE_OAUTH_TOKEN: { ...FIXED_BINDING, version: 5 } }),
|
||||
{ claudeLogin: { inheritedFromAgentId: parent.id } },
|
||||
@@ -890,7 +885,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
const parent = await seedParentAgent(scope, { version: 5 });
|
||||
|
||||
await expect(
|
||||
agentService(db).create(
|
||||
createAgentLifecycle(db).requestHire(
|
||||
scope.companyId,
|
||||
createInput(scope, { CLAUDE_CODE_OAUTH_TOKEN: { ...FIXED_BINDING, version: 2 } }),
|
||||
{ claudeLogin: { inheritedFromAgentId: parent.id } },
|
||||
@@ -906,7 +901,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
const parent = await seedParentAgent(scope, { holdsFixedBinding: false });
|
||||
|
||||
await expect(
|
||||
agentService(db).create(scope.companyId, createInput(scope), {
|
||||
createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { inheritedFromAgentId: parent.id },
|
||||
}),
|
||||
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
|
||||
@@ -921,7 +916,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
const parent = await seedParentAgent(foreignScope);
|
||||
|
||||
await expect(
|
||||
agentService(db).create(scope.companyId, createInput(scope), {
|
||||
createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { inheritedFromAgentId: parent.id },
|
||||
}),
|
||||
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
|
||||
@@ -934,7 +929,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
const scope = await seedScope();
|
||||
|
||||
await expect(
|
||||
agentService(db).create(scope.companyId, createInput(scope), {
|
||||
createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { inheritedFromAgentId: randomUUID() },
|
||||
}),
|
||||
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
|
||||
@@ -950,7 +945,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
const parent = await seedParentAgent(scope, { adapterType: "codex_local" });
|
||||
|
||||
await expect(
|
||||
agentService(db).create(scope.companyId, createInput(scope), {
|
||||
createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { inheritedFromAgentId: parent.id },
|
||||
}),
|
||||
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
|
||||
@@ -988,8 +983,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
// The child's copied reference still names version 5, the version the
|
||||
// route read before the rotation started. The create call must wait for
|
||||
// the parent row lock, so it can only proceed once the rotation commits.
|
||||
const createPromise = agentService(db)
|
||||
.create(
|
||||
const createPromise = createAgentLifecycle(db).requestHire(
|
||||
scope.companyId,
|
||||
createInput(scope, { CLAUDE_CODE_OAUTH_TOKEN: { ...FIXED_BINDING, version: 5 } }),
|
||||
{ claudeLogin: { inheritedFromAgentId: parent.id } },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAgentLifecycle } from "../services/agent-lifecycle.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
@@ -66,9 +67,10 @@ describeEmbeddedPostgres("pending approval agent config integrity", () => {
|
||||
it("resolves internal Codex creation once and preserves legacy agents through ordinary edits", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const service = agentService(db);
|
||||
const native = await service.create(companyId, { name: "New Codex", adapterType: "codex_local", adapterConfig: { modelReasoningEffort: "high" }, status: "pending_approval" });
|
||||
const legacy = await service.create(companyId, { name: "Legacy Codex", adapterType: "codex_local", runner: "legacy", adapterConfig: { extraArgs: ["--search"] }, status: "pending_approval" });
|
||||
const reviewedLegacy = await service.create(companyId, { name: "Historical approved hire", adapterType: "codex_local", adapterConfig: {}, status: "pending_approval" }, { runnerResolved: true });
|
||||
const lifecycle = createAgentLifecycle(db);
|
||||
const native = await lifecycle.requestHire(companyId, { name: "New Codex", adapterType: "codex_local", adapterConfig: { modelReasoningEffort: "high" }, status: "pending_approval" });
|
||||
const legacy = await lifecycle.requestHire(companyId, { name: "Legacy Codex", adapterType: "codex_local", runner: "legacy", adapterConfig: { extraArgs: ["--search"] }, status: "pending_approval" });
|
||||
const reviewedLegacy = await lifecycle.requestHire(companyId, { name: "Historical approved hire", adapterType: "codex_local", adapterConfig: {}, status: "pending_approval" }, { runnerResolved: true });
|
||||
// Only public Linux x64 artifacts qualify an automatic native default;
|
||||
// source-built macOS/Windows agents keep legacy until their packaging is qualified.
|
||||
if (process.platform === "linux" && process.arch === "x64") {
|
||||
@@ -79,8 +81,9 @@ describeEmbeddedPostgres("pending approval agent config integrity", () => {
|
||||
expect(legacy.adapterType).toBe("codex_local");
|
||||
expect(reviewedLegacy.adapterType).toBe("codex_local");
|
||||
for (const agent of [native, legacy, reviewedLegacy]) {
|
||||
expect(agent).not.toHaveProperty("runner");
|
||||
// Ordinary title changes on an activated agent must not revisit creation defaults.
|
||||
await service.activatePendingApproval(agent.id);
|
||||
await lifecycle.approveHire(agent.id);
|
||||
await service.update(agent.id, { title: "Updated title" });
|
||||
const saved = await service.getById(agent.id);
|
||||
expect(saved?.adapterType).toBe(agent.adapterType);
|
||||
@@ -91,8 +94,9 @@ describeEmbeddedPostgres("pending approval agent config integrity", () => {
|
||||
it("freezes generic pending hire config and reapplies the approval snapshot on activation", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const agentSvc = agentService(db);
|
||||
const agentSvcLifecycle = createAgentLifecycle(db);
|
||||
const approvalSvc = approvalService(db);
|
||||
const pending = await agentSvc.create(companyId, {
|
||||
const pending = await agentSvcLifecycle.requestHire(companyId, {
|
||||
name: "Pending Coder",
|
||||
role: "engineer",
|
||||
title: "Software Engineer",
|
||||
@@ -170,7 +174,8 @@ describeEmbeddedPostgres("pending approval agent config integrity", () => {
|
||||
await approvalSvc.approve(approval.id, "board-user", "Approved generic hire");
|
||||
|
||||
await expect(agentSvc.getById(pending.id)).resolves.toMatchObject({
|
||||
status: "idle",
|
||||
status: "paused",
|
||||
lifecycleState: "preparing",
|
||||
appearance: pending.appearance,
|
||||
name: "Pending Coder",
|
||||
role: "engineer",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAgentLifecycle } from "../services/agent-lifecycle.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
@@ -86,7 +87,7 @@ describeEmbeddedPostgres("agent service secret binding sync", () => {
|
||||
value: "sk-ant-123",
|
||||
});
|
||||
|
||||
const created = await agentService(db).create(companyId, {
|
||||
const created = await createAgentLifecycle(db).requestHire(companyId, {
|
||||
name: "Claude Novita",
|
||||
role: "engineer",
|
||||
status: "pending_approval",
|
||||
@@ -137,7 +138,7 @@ describeEmbeddedPostgres("agent service secret binding sync", () => {
|
||||
value: "slack-test-token",
|
||||
});
|
||||
|
||||
const created = await agentService(db).create(companyId, {
|
||||
const created = await createAgentLifecycle(db).requestHire(companyId, {
|
||||
name: "Slack Briefing",
|
||||
role: "briefing",
|
||||
adapterType: "codex_local",
|
||||
@@ -185,7 +186,7 @@ describeEmbeddedPostgres("agent service secret binding sync", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
agentService(db).create(companyId, {
|
||||
createAgentLifecycle(db).requestHire(companyId, {
|
||||
name: "Unlisted Static Lease",
|
||||
role: "engineer",
|
||||
adapterType: "codex_local",
|
||||
@@ -220,7 +221,7 @@ describeEmbeddedPostgres("agent service secret binding sync", () => {
|
||||
const companyId = await seedCompany();
|
||||
const literalApiKey = `hermes-key-${randomUUID()}`;
|
||||
|
||||
const created = await agentService(db).create(companyId, {
|
||||
const created = await createAgentLifecycle(db).requestHire(companyId, {
|
||||
name: "Hermes Gateway",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
@@ -289,7 +290,7 @@ describeEmbeddedPostgres("agent service secret binding sync", () => {
|
||||
value: "next-value",
|
||||
});
|
||||
|
||||
const created = await agentService(db).create(companyId, {
|
||||
const created = await createAgentLifecycle(db).requestHire(companyId, {
|
||||
name: "Binding Swapper",
|
||||
role: "engineer",
|
||||
adapterType: "codex_local",
|
||||
@@ -359,13 +360,14 @@ describeEmbeddedPostgres("agent service secret binding sync", () => {
|
||||
.where(eq(companySecretBindings.targetId, agentId));
|
||||
expect(beforeBindings).toHaveLength(0);
|
||||
|
||||
const approved = await agentService(db).activatePendingApproval(agentId);
|
||||
const approved = await createAgentLifecycle(db).approveHire(agentId);
|
||||
|
||||
expect(approved).toMatchObject({
|
||||
activated: true,
|
||||
agent: {
|
||||
id: agentId,
|
||||
status: "idle",
|
||||
status: "paused",
|
||||
lifecycleState: "preparing",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -390,7 +392,7 @@ describeEmbeddedPostgres("agent service secret binding sync", () => {
|
||||
const missingSecretId = randomUUID();
|
||||
|
||||
await expect(
|
||||
agentService(db).create(companyId, {
|
||||
createAgentLifecycle(db).requestHire(companyId, {
|
||||
name: "Broken Create",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
@@ -420,7 +422,7 @@ describeEmbeddedPostgres("agent service secret binding sync", () => {
|
||||
provider: "local_encrypted",
|
||||
value: "valid-value",
|
||||
});
|
||||
const created = await agentService(db).create(companyId, {
|
||||
const created = await createAgentLifecycle(db).requestHire(companyId, {
|
||||
name: "Transactional Update",
|
||||
role: "engineer",
|
||||
adapterType: "codex_local",
|
||||
@@ -483,7 +485,7 @@ describeEmbeddedPostgres("agent service secret binding sync", () => {
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await expect(agentService(db).activatePendingApproval(agentId)).rejects.toBeTruthy();
|
||||
await expect(createAgentLifecycle(db).approveHire(agentId)).rejects.toBeTruthy();
|
||||
|
||||
const reloaded = await agentService(db).getById(agentId);
|
||||
expect(reloaded?.status).toBe("pending_approval");
|
||||
|
||||
@@ -2,21 +2,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { approvalService } from "../services/approvals.ts";
|
||||
import { companies } from "@paperclipai/db";
|
||||
|
||||
vi.mock("../services/budgets.js", () => ({
|
||||
vi.mock("../services/budgets.js", async importOriginal => ({
|
||||
...await importOriginal<typeof import("../services/budgets.js")>(),
|
||||
budgetService: () => ({ deliverPendingEnforcement: vi.fn(async () => {}) }),
|
||||
budgetServiceInTransaction: () => ({ upsertPolicy: vi.fn(async () => {}) }),
|
||||
}));
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
activatePendingApproval: vi.fn(),
|
||||
getById: vi.fn(async () => ({ id: "agent-1" })),
|
||||
create: vi.fn(),
|
||||
terminate: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockNotifyHireApproved = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/agents.js", () => ({
|
||||
agentService: vi.fn(() => mockAgentService),
|
||||
vi.mock("../modules/agent-lifecycle/adapters/records.js", () => ({
|
||||
agentRecords: vi.fn(() => ({ ...mockAgentService, rejectPendingHire: mockAgentService.terminate })),
|
||||
}));
|
||||
|
||||
vi.mock("../services/hire-hook.js", () => ({
|
||||
@@ -114,7 +116,7 @@ describe("approvalService resolution idempotency", () => {
|
||||
const result = await svc.approve("approval-1", "board", "ship it");
|
||||
|
||||
expect(result.applied).toBe(true);
|
||||
expect(mockAgentService.activatePendingApproval).toHaveBeenCalledWith("agent-1", approved.payload);
|
||||
expect(mockAgentService.activatePendingApproval).toHaveBeenCalledWith("agent-1", approved.payload, approved.requestedByUserId);
|
||||
expect(mockNotifyHireApproved).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -163,7 +165,7 @@ describe("approvalService resolution idempotency", () => {
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: approved.payload.adapterConfig,
|
||||
}),
|
||||
{ runnerResolved: true, createdByUserId: expectedCreator },
|
||||
{ runnerResolved: true, createdByUserId: expectedCreator, responsibleUserId: requestedByUserId },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAgentLifecycle } from "../services/agent-lifecycle.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
@@ -795,8 +796,8 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
await agentService(db).pause(pausedReviewerId);
|
||||
await agentService(db).terminate(terminatedReviewerId);
|
||||
await createAgentLifecycle(db).pauseAgent(pausedReviewerId);
|
||||
await createAgentLifecycle(db).terminateAgent(terminatedReviewerId);
|
||||
|
||||
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
|
||||
const interactionTitles = feed.items
|
||||
@@ -886,7 +887,7 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
effectiveResolverPolicy: "human_only",
|
||||
payload: { version: 1, questions: [] },
|
||||
});
|
||||
await agentService(db).pause(reviewerId);
|
||||
await createAgentLifecycle(db).pauseAgent(reviewerId);
|
||||
|
||||
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
|
||||
const otherUserFeed = await attentionService(db).list(companyId, { userId: "other-user" });
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAgentLifecycle, configureAgentLifecycle } from "../services/agent-lifecycle.js";
|
||||
import fs from "node:fs/promises";
|
||||
import { fileHash } from "../services/agent-file-store.js";
|
||||
import { stockHash } from "../services/managed-resource-drift.js";
|
||||
@@ -9,6 +10,9 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import {
|
||||
activityLog,
|
||||
heartbeatRuns,
|
||||
agentWakeupRequests,
|
||||
routineRuns,
|
||||
agentConfigRevisions,
|
||||
agents,
|
||||
approvals,
|
||||
@@ -35,14 +39,14 @@ import { agentInstructionsService } from "../services/agent-instructions.ts";
|
||||
import { agentService } from "../services/agents.ts";
|
||||
import { approvalService } from "../services/approvals.ts";
|
||||
import {
|
||||
builtInAgentService,
|
||||
builtInAgentService as createBuiltInAgentService,
|
||||
deriveBuiltInAgentStatus,
|
||||
listBuiltInAgentDefinitions,
|
||||
readBuiltInTextWithFallback,
|
||||
reconcileBuiltInAgentsOnStartup,
|
||||
validateBuiltInAgentDefinitions,
|
||||
} from "../services/built-in-agents.ts";
|
||||
import { readBuiltInAgentMarker, withBuiltInAgentMarker } from "../services/built-in-agent-metadata.ts";
|
||||
import { readBuiltInAgentMarker, withBuiltInAgentMarker } from "../lib/built-in-agent-metadata.ts";
|
||||
import { issueThreadInteractionService } from "../services/issue-thread-interactions.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
@@ -119,6 +123,22 @@ describe("built-in agent asset loading", () => {
|
||||
|
||||
describeEmbeddedPostgres("built-in agents", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let lifecycleWorker: ReturnType<typeof configureAgentLifecycle>;
|
||||
function builtInAgentService(database: typeof db) {
|
||||
const service = createBuiltInAgentService(database);
|
||||
return { ...service,
|
||||
async ensure(...args: Parameters<typeof service.ensure>) {
|
||||
const result = await service.ensure(...args);
|
||||
if (result.agentId) await lifecycleWorker.process(result.agentId);
|
||||
return service.get(args[0], args[1]);
|
||||
},
|
||||
async get(...args: Parameters<typeof service.get>) {
|
||||
const result = await service.get(...args);
|
||||
if (result.agentId) await lifecycleWorker.process(result.agentId);
|
||||
return service.get(...args);
|
||||
},
|
||||
};
|
||||
}
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
let runnerSelectionSpy: { mockRestore(): void } | undefined;
|
||||
const runnerTarget = { driver: "local", platform: "linux", architecture: "x64" };
|
||||
@@ -136,6 +156,7 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-built-in-agents-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
lifecycleWorker = configureAgentLifecycle(db, { requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost: async () => "complete" });
|
||||
}, 20_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -149,6 +170,7 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
runnerSelectionSpy?.mockRestore();
|
||||
await lifecycleWorker.sweep();
|
||||
await db.delete(routineTriggers);
|
||||
await db.delete(routines);
|
||||
await db.delete(issueThreadInteractions);
|
||||
@@ -170,6 +192,7 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await lifecycleWorker.stop();
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
@@ -551,7 +574,7 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
});
|
||||
expect(ready.status).toBe("ready");
|
||||
|
||||
await agentService(db).pause(ready.agentId!, "manual");
|
||||
await createAgentLifecycle(db).pauseAgent(ready.agentId!, "manual");
|
||||
await expect(builtIns.get(companyId, "learning")).resolves.toMatchObject({
|
||||
status: "paused",
|
||||
agentId: ready.agentId,
|
||||
@@ -593,7 +616,7 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
warning: null,
|
||||
});
|
||||
|
||||
await agentService(db).pause(ready.agentId!, "maintenance");
|
||||
await createAgentLifecycle(db).pauseAgent(ready.agentId!, "maintenance");
|
||||
await expect(builtIns.requireBuiltInAgent(companyId, "briefs")).resolves.toMatchObject({
|
||||
agent: { id: ready.agentId },
|
||||
warning: {
|
||||
@@ -638,7 +661,7 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
|
||||
it("reconciles an enabled Reflection Coach bundle with skill sync and a disabled routine", async () => {
|
||||
const companyId = await seedCompany({ requireApproval: false });
|
||||
const root = await agentService(db).create(companyId, {
|
||||
const root = await createAgentLifecycle(db).requestHire(companyId, {
|
||||
name: "CEO",
|
||||
role: "ceo",
|
||||
status: "idle",
|
||||
@@ -730,7 +753,7 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
|
||||
it("recreates missing managed resource bindings idempotently during concurrent reconcile", async () => {
|
||||
const companyId = await seedCompany({ requireApproval: false });
|
||||
await agentService(db).create(companyId, {
|
||||
await createAgentLifecycle(db).requestHire(companyId, {
|
||||
name: "CEO",
|
||||
role: "ceo",
|
||||
status: "idle",
|
||||
@@ -768,7 +791,7 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
|
||||
it("preserves new-agent approval gates during on-demand Reflection Coach provisioning", async () => {
|
||||
const companyId = await seedCompany({ requireApproval: true });
|
||||
const root = await agentService(db).create(companyId, {
|
||||
const root = await createAgentLifecycle(db).requestHire(companyId, {
|
||||
name: "CEO",
|
||||
role: "ceo",
|
||||
status: "idle",
|
||||
@@ -1012,7 +1035,7 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
it("prevents direct marker add, remove, or mutation", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const builtIn = await builtInAgentService(db).ensure(companyId, "briefs");
|
||||
const normal = await agentService(db).create(companyId, {
|
||||
const normal = await createAgentLifecycle(db).requestHire(companyId, {
|
||||
name: "Normal",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
@@ -1022,7 +1045,7 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await expect(agentService(db).create(companyId, {
|
||||
await expect(createAgentLifecycle(db).requestHire(companyId, {
|
||||
name: "Spoof",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
@@ -1275,7 +1298,7 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
|
||||
it("automatically materializes the Reflection Coach bundle without enabling background work", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const root = await agentService(db).create(companyId, {
|
||||
const root = await createAgentLifecycle(db).requestHire(companyId, {
|
||||
name: "CEO",
|
||||
role: "ceo",
|
||||
status: "idle",
|
||||
@@ -1350,7 +1373,7 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
|
||||
it("materializes the Summarizer bundle paused on Claude Haiku with a disabled routine", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const root = await agentService(db).create(companyId, {
|
||||
const root = await createAgentLifecycle(db).requestHire(companyId, {
|
||||
name: "CEO",
|
||||
role: "ceo",
|
||||
status: "idle",
|
||||
@@ -1462,9 +1485,35 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
expect(resetFile.content).not.toContain("Operator edit.");
|
||||
});
|
||||
|
||||
it("keeps a built-in routine task while the agent prepares", async () => {
|
||||
const companyId = await seedCompany({ requireApproval: false });
|
||||
const builtIns = createBuiltInAgentService(db);
|
||||
const created = await builtIns.ensure(companyId, "reflection-coach");
|
||||
await lifecycleWorker.stop();
|
||||
lifecycleWorker = configureAgentLifecycle(db, { requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost: async () => "pending" });
|
||||
try {
|
||||
const result = await builtIns.runRoutine(companyId, "reflection-coach", "recent-agent-reflection", { userId: "responsible-user" });
|
||||
expect(result.status).toBe("issue_created");
|
||||
const tasks = await db.select().from(issues).where(eq(issues.companyId, companyId));
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0]!.assigneeAgentId).toBe(created.agentId);
|
||||
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId)))
|
||||
.toEqual([expect.objectContaining({ status: "queued", agentId: created.agentId })]);
|
||||
// A second request during setup must not try to resume an already preparing agent.
|
||||
await expect(builtIns.runRoutine(companyId, "reflection-coach", "recent-agent-reflection", { userId: "responsible-user" }))
|
||||
.resolves.toHaveProperty("id");
|
||||
} finally {
|
||||
await lifecycleWorker.stop();
|
||||
await db.delete(routineRuns).where(eq(routineRuns.companyId, companyId));
|
||||
await db.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId));
|
||||
await db.delete(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, companyId));
|
||||
lifecycleWorker = configureAgentLifecycle(db, { requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost: async () => "complete" });
|
||||
}
|
||||
});
|
||||
|
||||
it("controls the Reflection Coach routine schedule without enabling it by default", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await agentService(db).create(companyId, {
|
||||
await createAgentLifecycle(db).requestHire(companyId, {
|
||||
name: "CEO",
|
||||
role: "ceo",
|
||||
status: "idle",
|
||||
@@ -1487,7 +1536,8 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
"recent-agent-reflection",
|
||||
{ userId: "board-user" },
|
||||
);
|
||||
expect(enabled.status).toBe("needs_setup");
|
||||
await lifecycleWorker.process(enabled.agentId!);
|
||||
expect((await builtIns.get(companyId, "reflection-coach")).status).toBe("needs_setup");
|
||||
expect(enabled.resources.find((resource) => resource.resourceKind === "routine")).toMatchObject({
|
||||
stockStatus: "stock_current",
|
||||
scheduleEnabled: true,
|
||||
@@ -1515,7 +1565,7 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
|
||||
it("surfaces pending Reflection Coach proposal interactions on the routine resource", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await agentService(db).create(companyId, {
|
||||
await createAgentLifecycle(db).requestHire(companyId, {
|
||||
name: "CEO",
|
||||
role: "ceo",
|
||||
status: "idle",
|
||||
@@ -1568,7 +1618,8 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
it("gates Reflection Coach proposal mutations until an accepted follow-up apply step", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const agentsSvc = agentService(db);
|
||||
await agentsSvc.create(companyId, {
|
||||
const agentsSvcLifecycle = createAgentLifecycle(db);
|
||||
await agentsSvcLifecycle.requestHire(companyId, {
|
||||
name: "CEO",
|
||||
role: "ceo",
|
||||
status: "idle",
|
||||
@@ -1577,7 +1628,7 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
const target = await agentsSvc.create(companyId, {
|
||||
const target = await agentsSvcLifecycle.requestHire(companyId, {
|
||||
name: "Target Coder",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAgentLifecycle, configureAgentLifecycle } from "../services/agent-lifecycle.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
@@ -41,6 +42,7 @@ describeEmbeddedPostgres("cleanup removal services", () => {
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-cleanup-removal-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
configureAgentLifecycle(db, { requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost: async () => "complete" });
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -146,6 +148,7 @@ describeEmbeddedPostgres("cleanup removal services", () => {
|
||||
createdByRunId: runId,
|
||||
});
|
||||
|
||||
await db.update(agents).set({ status: "terminated", lifecycleState: "terminated" }).where(eq(agents.id, agentId));
|
||||
const removed = await agentService(db).remove(agentId);
|
||||
|
||||
expect(removed?.id).toBe(agentId);
|
||||
|
||||
@@ -33,9 +33,10 @@ vi.mock("../services/cloud-lifecycle-sync.js", () => ({
|
||||
notifyCloudOfPrimaryCompanyLifecycleChange: notifyCloudSpy,
|
||||
}));
|
||||
|
||||
import { configureAgentLifecycle } from "../services/agent-lifecycle.js";
|
||||
import { companyService } from "../services/companies.js";
|
||||
import { deriveIssuePrefixBase } from "../services/issue-prefix.js";
|
||||
import { readBuiltInAgentMarker } from "../services/built-in-agent-metadata.js";
|
||||
import { readBuiltInAgentMarker } from "../lib/built-in-agent-metadata.js";
|
||||
import { builtInAgentService, reconcileBuiltInAgentsOnStartup } from "../services/built-in-agents.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
@@ -49,6 +50,16 @@ if (!embeddedPostgresSupport.supported) {
|
||||
|
||||
describeEmbeddedPostgres("companyService", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let lifecycleWorker: ReturnType<typeof configureAgentLifecycle>;
|
||||
beforeEach(() => { lifecycleWorker = configureAgentLifecycle(db, { requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost: async () => "complete" }); });
|
||||
async function insertAgents(input: typeof agents.$inferInsert | Array<typeof agents.$inferInsert>) {
|
||||
const rows = Array.isArray(input) ? input : [input];
|
||||
await db.insert(agents).values(rows.map(row => ({ ...row,
|
||||
lifecycleState: ["paused", "pending_approval", "terminated"].includes(row.status ?? "") ? row.status! : "ready",
|
||||
lifecycleHolds: row.status === "paused" ? [row.pauseReason ?? "manual"] : [],
|
||||
})));
|
||||
}
|
||||
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -57,6 +68,7 @@ describeEmbeddedPostgres("companyService", () => {
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await lifecycleWorker.stop();
|
||||
await db.delete(routineTriggers);
|
||||
await db.delete(routines);
|
||||
await db.delete(builtInManagedResources);
|
||||
@@ -165,7 +177,7 @@ describeEmbeddedPostgres("companyService", () => {
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values([
|
||||
await insertAgents([
|
||||
{
|
||||
id: runningAgentId,
|
||||
companyId,
|
||||
@@ -335,7 +347,7 @@ describeEmbeddedPostgres("companyService", () => {
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values([
|
||||
await insertAgents([
|
||||
{
|
||||
id: archivedPausedAgentId,
|
||||
companyId,
|
||||
@@ -382,6 +394,7 @@ describeEmbeddedPostgres("companyService", () => {
|
||||
);
|
||||
|
||||
expect(reactivated?.status).toBe("active");
|
||||
await lifecycleWorker.process(archivedPausedAgentId);
|
||||
|
||||
const reactivateActivity = await db
|
||||
.select({
|
||||
@@ -441,7 +454,7 @@ describeEmbeddedPostgres("companyService", () => {
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values([
|
||||
await insertAgents([
|
||||
{
|
||||
id: runningAgentId,
|
||||
companyId,
|
||||
@@ -551,7 +564,7 @@ describeEmbeddedPostgres("companyService", () => {
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values([
|
||||
await insertAgents([
|
||||
{
|
||||
id: archivedPausedAgentId,
|
||||
companyId,
|
||||
@@ -587,6 +600,7 @@ describeEmbeddedPostgres("companyService", () => {
|
||||
);
|
||||
|
||||
expect(reactivated?.status).toBe("active");
|
||||
await lifecycleWorker.process(archivedPausedAgentId);
|
||||
|
||||
const rows = await db
|
||||
.select({ id: agents.id, status: agents.status, pauseReason: agents.pauseReason })
|
||||
@@ -618,7 +632,7 @@ describeEmbeddedPostgres("companyService", () => {
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
await insertAgents({
|
||||
id: terminatedAgentId,
|
||||
companyId,
|
||||
name: "Terminated Agent",
|
||||
@@ -661,7 +675,7 @@ describeEmbeddedPostgres("companyService", () => {
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
await insertAgents({
|
||||
id: manualPausedAgentId,
|
||||
companyId,
|
||||
name: "Manual Paused Agent",
|
||||
@@ -713,7 +727,7 @@ describeEmbeddedPostgres("companyService", () => {
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
await insertAgents({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Idle Agent",
|
||||
@@ -788,7 +802,7 @@ describeEmbeddedPostgres("companyService", () => {
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
await insertAgents({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Idle Agent",
|
||||
@@ -863,7 +877,7 @@ describeEmbeddedPostgres("companyService", () => {
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
await insertAgents({
|
||||
id: idleAgentId,
|
||||
companyId,
|
||||
name: "Idle Agent",
|
||||
|
||||
@@ -134,6 +134,13 @@ vi.mock("../services/agents.js", () => ({
|
||||
agentService: () => agentSvc,
|
||||
}));
|
||||
|
||||
vi.mock("../services/agent-lifecycle.js", () => ({
|
||||
createAgentLifecycle: () => ({
|
||||
requestHire: (...args: unknown[]) => agentSvc.create(...args),
|
||||
pauseAgent: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../services/access.js", () => ({
|
||||
accessService: () => accessSvc,
|
||||
}));
|
||||
|
||||
@@ -189,12 +189,16 @@ describe("company decision service", () => {
|
||||
const context: DecisionContext = { companyId: f.companyId, feature: settingsDecisionTest,
|
||||
actor: { type: "agent", agentId: f.agentId, runId, companyId: f.companyId, source: "agent_jwt" } };
|
||||
const remove = () => agentService(db).remove(f.agentId);
|
||||
let inFlightDeletionError: unknown;
|
||||
f.provider.mockImplementationOnce(async () => {
|
||||
await expect(remove()).rejects.toMatchObject({ status: 409, details: { code: "agent_decision_accounting_pending" } });
|
||||
// Exercise the accounting guard after lifecycle termination.
|
||||
await db.update(agents).set({ status: "terminated", lifecycleState: "terminated" }).where(eq(agents.id, f.agentId));
|
||||
inFlightDeletionError = await remove().catch(error => error);
|
||||
expect(await f.service.decide(f.context, DECISION_TEST_REQUEST)).toMatchObject({ status: "unavailable", reason: "budget_blocked" });
|
||||
return { errorCode: "timeout", receipt: decisionReceipt("openai", null) };
|
||||
});
|
||||
expect((await f.service.decide(context, DECISION_TEST_REQUEST)).status).toBe("failed");
|
||||
expect(inFlightDeletionError).toMatchObject({ status: 409, details: { code: "agent_decision_accounting_pending" } });
|
||||
await expect(remove()).rejects.toMatchObject({ status: 409, details: { code: "agent_decision_accounting_pending" } });
|
||||
expect((await db.select().from(budgetReservations).where(eq(budgetReservations.companyId, f.companyId)))[0]).toMatchObject({ state: "held", amountCents: "1.0000000" });
|
||||
});
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { logActivity } from "../services/activity-log.js";
|
||||
vi.mock("../services/activity-log.js", async importOriginal => {
|
||||
const actual = await importOriginal<typeof import("../services/activity-log.js")>();
|
||||
return { ...actual, logActivity: vi.fn(actual.logActivity) };
|
||||
});
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -38,7 +43,8 @@ it("atomically resumes one pending invite across retries and never persists a pa
|
||||
const service = dotInvitationService(db);
|
||||
const [a, b] = await Promise.all([service.create(f.companyId, f.userId), service.create(f.companyId, f.userId)]);
|
||||
expect(a.agent.id).toBe(b.agent.id);
|
||||
expect(a.agent.status).toBe("idle");
|
||||
expect(a.agent.status).toBe("paused");
|
||||
expect((await db.select().from(agents).where(eq(agents.id, a.agent.id)))[0]!.lifecycleState).toBe("preparing");
|
||||
expect(await service.resume(f.companyId, f.userId)).toMatchObject({ agent: { id: a.agent.id } });
|
||||
expect(await service.resume(f.companyId, randomUUID())).toBeNull();
|
||||
expect(await db.select().from(agents).where(eq(agents.companyId, f.companyId))).toHaveLength(1);
|
||||
@@ -47,6 +53,26 @@ it("atomically resumes one pending invite across retries and never persists a pa
|
||||
expect(JSON.stringify(logs)).not.toContain(code.pairingCode);
|
||||
expect(JSON.stringify(await service.resume(f.companyId, f.userId))).not.toContain(code.pairingCode);
|
||||
});
|
||||
it("reuses a committed hire when invitation completion must retry", async () => {
|
||||
const f = await fixture(true);
|
||||
const actual = await vi.importActual<typeof import("../services/activity-log.js")>("../services/activity-log.js");
|
||||
vi.mocked(logActivity).mockImplementation(async (...args) => {
|
||||
if (args[1].action === "agent.hire_created") {
|
||||
vi.mocked(logActivity).mockImplementation(actual.logActivity);
|
||||
throw new Error("Invitation audit unavailable");
|
||||
}
|
||||
return actual.logActivity(...args);
|
||||
});
|
||||
const service = dotInvitationService(db);
|
||||
await expect(service.create(f.companyId, f.userId)).rejects.toThrow("Invitation audit unavailable");
|
||||
const before = await db.select().from(agents).where(eq(agents.companyId, f.companyId));
|
||||
expect(before).toHaveLength(1);
|
||||
expect(await service.resume(f.companyId, f.userId)).toBeNull();
|
||||
const retried = await service.create(f.companyId, f.userId);
|
||||
expect(retried.agent.id).toBe(before[0]!.id);
|
||||
expect(await db.select().from(approvals).where(eq(approvals.companyId, f.companyId))).toHaveLength(1);
|
||||
expect(await db.select().from(agents).where(eq(agents.companyId, f.companyId))).toHaveLength(1);
|
||||
});
|
||||
it("honors hiring approval before issuing a pairing capability", async () => {
|
||||
const f = await fixture(true);
|
||||
const invite = await dotInvitationService(db).create(f.companyId, f.userId);
|
||||
@@ -91,7 +117,7 @@ it("keeps invite routes company-scoped and denies viewers and agent actors", asy
|
||||
expect(result.status, JSON.stringify(result.body)).toBe(200);
|
||||
expect(result.body.agent.id).toBeTruthy();
|
||||
expect((await request(app).get(path)).body.agent.id).toBe(result.body.agent.id);
|
||||
expect(await db.select().from(agents).where(and(eq(agents.companyId, f.companyId), eq(agents.status, "idle")))).toHaveLength(1);
|
||||
expect(await db.select().from(agents).where(and(eq(agents.companyId, f.companyId), eq(agents.lifecycleState, "preparing")))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not trust editable agent metadata as an invitation ownership receipt", async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import express from "express";
|
||||
import request from "supertest";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDb, authUsers, companies, companyMemberships, agents, dotAgentBindings, mcpOauthGrants, mcpOauthRequests, mcpOauthTokens } from "@paperclipai/db";
|
||||
import { createDb, authUsers, companies, companyMemberships, agents, dotAgentBindings, dotMailboxItems, mcpEventSubscriptions, mcpOauthGrants, mcpOauthRequests, mcpOauthTokens } from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { createPublicMcpOAuth, publicMcpConfig, hashMcpSecret, DEVICE_GRANT } from "../services/public-mcp/oauth.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
@@ -22,12 +22,12 @@ describe("Dot onboarding with an operator-issued pairing capability", () => {
|
||||
}, 120000);
|
||||
beforeEach(async () => { await instanceSettingsService(db).updateExperimental({ enablePublicMcp: true, enableOpenAiDot: true, enableNativeRunner: false }); });
|
||||
afterAll(async () => { await temp?.cleanup(); });
|
||||
async function fixture(authorizationOrigin?: string) {
|
||||
async function fixture(authorizationOrigin?: string, lifecycleState = "ready") {
|
||||
const userId = randomUUID();
|
||||
await db.insert(authUsers).values({ id: userId, name: "Operator", email: userId + "@example.test", createdAt: new Date(), updatedAt: new Date() });
|
||||
const [company] = await db.insert(companies).values({ name: "Dot onboarding", issuePrefix: "DO" + randomBytes(3).toString("hex") }).returning();
|
||||
await db.insert(companyMemberships).values({ companyId: company!.id, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" });
|
||||
const [agent] = await db.insert(agents).values({ companyId: company!.id, name: "Dot", adapterType: "paperclip_runner", adapterConfig: { provider: "openai_dot" }, status: "active" }).returning();
|
||||
const [agent] = await db.insert(agents).values({ companyId: company!.id, name: "Dot", adapterType: "paperclip_runner", adapterConfig: { provider: "openai_dot" }, lifecycleState, status: lifecycleState === "ready" ? "active" : "paused" }).returning();
|
||||
const oauth = createPublicMcpOAuth(db, { ...config, ...(authorizationOrigin ? { authorizationOrigin } : {}) });
|
||||
const client = await oauth.register({ client_name: "Dot", redirect_uris: [callback], grant_types: ["authorization_code", "refresh_token", DEVICE_GRANT] }, randomUUID());
|
||||
const verifier = randomBytes(32).toString("base64url");
|
||||
@@ -43,6 +43,49 @@ describe("Dot onboarding with an operator-issued pairing capability", () => {
|
||||
return f.oauth.token({ grant_type: "authorization_code", client_id: f.client.client_id,
|
||||
redirect_uri: callback, resource: config.resource, code: new URL(consent.redirectUrl).searchParams.get("code"), code_verifier: f.verifier });
|
||||
}
|
||||
|
||||
it("invalidates setup when a broker pairing is applied or revoked", async () => {
|
||||
const f = await fixture(undefined, "preparing");
|
||||
const consent = await f.oauth.consent(f.id, { type: "board", source: "session", userId: f.userId },
|
||||
{ decision: "approve", companyId: f.company.id, allowWrites: false });
|
||||
const tokens = await f.oauth.token({ grant_type: "authorization_code", client_id: f.client.client_id,
|
||||
redirect_uri: callback, resource: config.resource, code: new URL(consent.redirectUrl).searchParams.get("code"), code_verifier: f.verifier });
|
||||
const principal = await f.oauth.authenticate(tokens.access_token);
|
||||
const broker = dotRunnerBroker(db);
|
||||
await broker.pair(principal, f.pairing.pairingCode);
|
||||
const [paired] = await db.select().from(agents).where(eq(agents.id, f.agent.id));
|
||||
expect(paired.adapterConfig.dotBindingId).toBe(f.pairing.bindingId);
|
||||
expect(paired.lifecycleVersion).toBe(f.agent.lifecycleVersion + 1);
|
||||
await expect(broker.pair(principal, f.pairing.pairingCode)).rejects.toThrow("Pairing code expired or was consumed");
|
||||
|
||||
await broker.revoke(f.company.id, f.agent.id, f.userId);
|
||||
const [revoked] = await db.select().from(agents).where(eq(agents.id, f.agent.id));
|
||||
expect(revoked.adapterConfig).not.toHaveProperty("dotBindingId");
|
||||
expect(revoked.lifecycleVersion).toBe(paired.lifecycleVersion + 1);
|
||||
await expect(f.oauth.authenticate(tokens.access_token)).rejects.toThrow();
|
||||
});
|
||||
it.each(["preparing", "verifying"])("allows Dot setup during %s without allowing task access", async (state) => {
|
||||
const f = await fixture(undefined, state);
|
||||
const tokens = await connect(f);
|
||||
const principal = await f.oauth.authenticate(tokens.access_token);
|
||||
const broker = dotRunnerBroker(db);
|
||||
await db.insert(mcpEventSubscriptions).values({ id: randomUUID(), companyId: f.company.id,
|
||||
grantId: principal.grant.id, name: "paperclip.dot.mailbox_updated", bindingId: f.pairing.bindingId,
|
||||
arguments: {}, deliveryMaterial: {}, verifiedAt: new Date(), expiresAt: new Date(Date.now() + 60_000) });
|
||||
await db.insert(dotMailboxItems).values({ companyId: f.company.id, bindingId: f.pairing.bindingId,
|
||||
bindingGeneration: 1, kind: "assignment", sourceEventId: randomUUID(), references: { task: "hidden" } });
|
||||
await broker.challenge(f.company.id, f.agent.id);
|
||||
const inbox = await broker.mailbox(principal);
|
||||
expect(inbox.items.map(item => item.kind)).toEqual(["readiness_challenge"]);
|
||||
expect(inbox.nextCursor).toBe(0);
|
||||
await expect(broker.confirmChallenge(principal, String(inbox.items[0]!.references.challenge))).resolves.toEqual({ status: "ready" });
|
||||
expect(await broker.capabilities(principal)).toMatchObject({ ready: false });
|
||||
await expect(broker.tasks(principal)).rejects.toThrow("authority");
|
||||
await expect(broker.snapshot(f.company.id, f.agent.id, f.pairing.bindingId)).rejects.toThrow("authority");
|
||||
await db.update(agents).set({ lifecycleState: "paused" }).where(eq(agents.id, f.agent.id));
|
||||
expect((await broker.mailbox(principal)).items).toEqual([]);
|
||||
await expect(broker.confirmChallenge(principal, "already-used")).rejects.toThrow("authority");
|
||||
});
|
||||
it("refreshes a Dot connection after years of inactivity, still rotates and revokes on replay", async () => {
|
||||
const f = await fixture();
|
||||
const tokens = await connect(f);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
stopSshEnvLabFixture,
|
||||
} from "@paperclipai/adapter-utils/ssh";
|
||||
import {
|
||||
activityLog,
|
||||
costEvents,
|
||||
agents,
|
||||
builtInManagedResources,
|
||||
@@ -219,6 +220,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
await stopSshEnvLabFixture(path.join(root, "state.json")).catch(() => undefined);
|
||||
await rm(root, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
await db.delete(activityLog);
|
||||
await db.delete(environmentLeases);
|
||||
await db.delete(issues);
|
||||
await db.delete(costEvents);
|
||||
@@ -5780,6 +5782,21 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("destroys a failed retained lease when the agent stops", async () => {
|
||||
const { lease, workerManager, runtimeWithPlugin, runId } = await seedStaleLifecycleReusableLease("retain_on_failure");
|
||||
await db.update(heartbeatRuns).set({ status: "failed", finishedAt: new Date() }).where(eq(heartbeatRuns.id, runId));
|
||||
await runtimeWithPlugin.releaseRunLeases(runId, "failed");
|
||||
await expect(environmentService(db).getLeaseById(lease.id)).resolves.toMatchObject({ status: "retained", cleanupStatus: "failed" });
|
||||
vi.mocked(workerManager.getWorker).mockReturnValue({ supportedMethods: ["environmentDestroyLease"] } as never);
|
||||
vi.mocked(workerManager.call).mockResolvedValue({ providerLeaseId: lease.providerLeaseId, state: "destroyed" });
|
||||
const { heartbeatService } = await import("../services/heartbeat.js");
|
||||
const stopped = await heartbeatService(db, { pluginWorkerManager: workerManager })
|
||||
.stopInvocationsForAgents([lease.metadata!.agentId as string], "Agent lifecycle stop requested");
|
||||
expect(stopped).toBe(true);
|
||||
expect(workerManager.call).toHaveBeenCalledWith(expect.any(String), "environmentDestroyLease", expect.objectContaining({ providerLeaseId: lease.providerLeaseId }), expect.any(Number));
|
||||
await expect(environmentService(db).getLeaseById(lease.id)).resolves.toMatchObject({ status: "expired", cleanupStatus: "success" });
|
||||
});
|
||||
|
||||
it("fails closed on expiry destruction when the worker no longer advertises the destroy lifecycle method", async () => {
|
||||
const { pluginId, lease, workerManager, runtimeWithPlugin } = await seedStaleLifecycleReusableLease();
|
||||
|
||||
|
||||
@@ -9046,7 +9046,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
|
||||
expect(mockTerminateLocalService).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pid: 81_501, processGroupId: 81_502 }),
|
||||
{ forceAfterMs: 3000 },
|
||||
{ forceAfterMs: 3000, signal: "SIGINT" },
|
||||
);
|
||||
expect(runningProcesses.has(runId)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -216,6 +216,26 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
||||
return { companyId, agentId };
|
||||
}
|
||||
|
||||
it.each(["preparing", "verifying", "resuming"] as const)("keeps assigned work queued through %s, including recovery with timers disabled", async lifecycleState => {
|
||||
const { companyId, agentId } = await seedCompanyAndAgent({ heartbeatConfig: { enabled: false } });
|
||||
await db.update(agents).set({ status: "paused", lifecycleState }).where(eq(agents.id, agentId));
|
||||
const [issue] = await db.insert(issues).values({ companyId, title: "Work accepted during setup", status: "todo", assigneeAgentId: agentId }).returning();
|
||||
const run = await heartbeat.wakeup(agentId, { source: "assignment", triggerDetail: "system", reason: "issue_assigned",
|
||||
requestedByActorType: "system", payload: { issueId: issue.id } });
|
||||
expect(run?.status).toBe("queued");
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
expect((await heartbeat.getRun(run!.id))?.status).toBe("queued");
|
||||
expect(countExecuteCallsForRun(run!.id)).toBe(0);
|
||||
expect((await heartbeat.reconcileStrandedAssignedIssues()).escalated).toBe(0);
|
||||
expect((await db.select().from(issues).where(eq(issues.id, issue.id)))[0]).toMatchObject({ status: "todo", assigneeAgentId: agentId });
|
||||
await db.update(agents).set({ status: "idle", lifecycleState: "ready" }).where(eq(agents.id, agentId));
|
||||
const recovered = heartbeatService(db, { runtimeEnv: { ...process.env, PAPERCLIP_IN_WORKTREE: "false" } });
|
||||
await recovered.resumeQueuedRuns();
|
||||
expect(await waitForCondition(async () => countExecuteCallsForRun(run!.id) === 1)).toBe(true);
|
||||
await recovered.waitForRunExecutionDrain(run!.id);
|
||||
expect(countExecuteCallsForRun(run!.id)).toBe(1);
|
||||
});
|
||||
|
||||
async function seedQueuedRun(input: {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
|
||||
@@ -7,6 +7,10 @@ const canUserMock = vi.fn();
|
||||
const insertMock = vi.fn();
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/agent-lifecycle.js", () => ({
|
||||
createAgentLifecycle: () => ({}),
|
||||
AgentLifecycleConflict: class extends Error {},
|
||||
}));
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
isInstanceAdmin: vi.fn(),
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
normalizeHumanRole,
|
||||
resolveHumanInviteRole,
|
||||
} from "../services/company-member-roles.js";
|
||||
import { NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS, newStandardAgentGrantScope } from "../services/agent-permissions.js";
|
||||
import { NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS, newStandardAgentGrantScope } from "../lib/agent-permissions.js";
|
||||
|
||||
const invitedAgentId = "agent-1";
|
||||
const defaultAgentGrants = NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS.map((permissionKey) => ({
|
||||
|
||||
@@ -1031,6 +1031,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
{ agentId: creatorAgentId },
|
||||
);
|
||||
|
||||
await db.update(agents).set({ lifecycleState: "terminated", status: "terminated" }).where(eq(agents.id, addresseeAgentId));
|
||||
await agentService(db).remove(addresseeAgentId);
|
||||
|
||||
const cancelled = await interactionsSvc.getById(created.id);
|
||||
|
||||
@@ -236,7 +236,9 @@ describeEmbeddedPostgres("POST /api/companies/:companyId/onboarding-seed", () =>
|
||||
expect(companyGoals[0]?.title).toBe("Make robotics boring");
|
||||
expect(companyGoals[0]?.description).toBe("Boring enough that hospitals buy it.");
|
||||
// No agent and no task were sent, so none were invented.
|
||||
expect(await ctx.db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(0);
|
||||
const committedAgents = await ctx.db.select().from(agents).where(eq(agents.companyId, companyId));
|
||||
expect(committedAgents).toHaveLength(0);
|
||||
expect(await ctx.db.select().from(projects).where(eq(projects.companyId, companyId))).toHaveLength(0);
|
||||
expect(await ctx.db.select().from(issues).where(eq(issues.companyId, companyId))).toHaveLength(0);
|
||||
expect(response.body.agentId).toBeNull();
|
||||
expect(response.body.issueId).toBeNull();
|
||||
@@ -273,12 +275,7 @@ describeEmbeddedPostgres("POST /api/companies/:companyId/onboarding-seed", () =>
|
||||
expect(entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rolls the whole seed back when the audit entry cannot be written", async () => {
|
||||
// The audit entry shares the seed's transaction, so a failure to write it
|
||||
// must leave nothing behind. The alternative — commit the seed and lose the
|
||||
// entry — is unrecoverable: Cloud stops retrying on a 2xx, and a later
|
||||
// replay reports `changed: false` and never logs, so the entry would be
|
||||
// permanently absent.
|
||||
it("reuses the committed hire after the content transaction fails", async () => {
|
||||
const { companyId, app } = await seedCompany();
|
||||
|
||||
// Injected at the module boundary, not on `ctx.db`: the audit write goes
|
||||
@@ -289,18 +286,20 @@ describeEmbeddedPostgres("POST /api/companies/:companyId/onboarding-seed", () =>
|
||||
const failed = await post(app, companyId, SEED);
|
||||
expect(failed.status).toBeGreaterThanOrEqual(500);
|
||||
|
||||
// Nothing committed: no seed record, so Cloud has no acknowledged revision
|
||||
// and keeps retrying, and no orphaned agent from the rolled-back attempt.
|
||||
// The failed content transaction must not acknowledge the revision.
|
||||
expect(
|
||||
await ctx.db
|
||||
.select()
|
||||
.from(companyOnboardingSeeds)
|
||||
.where(eq(companyOnboardingSeeds.companyId, companyId)),
|
||||
).toHaveLength(0);
|
||||
expect(await ctx.db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(0);
|
||||
const committedAgents = await ctx.db.select().from(agents).where(eq(agents.companyId, companyId));
|
||||
expect(committedAgents).toHaveLength(1);
|
||||
expect(await ctx.db.select().from(projects).where(eq(projects.companyId, companyId))).toHaveLength(0);
|
||||
|
||||
// And the retry recovers completely — seed applied, entry present.
|
||||
await post(app, companyId, SEED).expect(200);
|
||||
expect((await ctx.db.select().from(agents).where(eq(agents.companyId, companyId))).map(agent => agent.id))
|
||||
.toEqual(committedAgents.map(agent => agent.id));
|
||||
expect(
|
||||
await ctx.db
|
||||
.select()
|
||||
@@ -327,7 +326,9 @@ describeEmbeddedPostgres("POST /api/companies/:companyId/onboarding-seed", () =>
|
||||
const response = await post(strangerApp, companyId, SEED);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await ctx.db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(0);
|
||||
const committedAgents = await ctx.db.select().from(agents).where(eq(agents.companyId, companyId));
|
||||
expect(committedAgents).toHaveLength(0);
|
||||
expect(await ctx.db.select().from(projects).where(eq(projects.companyId, companyId))).toHaveLength(0);
|
||||
expect(await ctx.db.select().from(companyOnboardingSeeds)).toHaveLength(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -279,6 +279,15 @@ function loadSpecRoutes() {
|
||||
}
|
||||
|
||||
describe("openapi routes", () => {
|
||||
it("documents only writable agent update fields and lifecycle status requests", () => {
|
||||
const document = buildOpenApiSpec() as any;
|
||||
const properties = document.paths["/api/agents/{id}"].patch.requestBody.content["application/json"].schema.properties;
|
||||
expect(properties).not.toHaveProperty("spentMonthlyCents");
|
||||
expect(properties.status.enum).toEqual(["paused", "idle", "terminated"]);
|
||||
expect(properties).toHaveProperty("name");
|
||||
expect(properties).toHaveProperty("budgetMonthlyCents");
|
||||
});
|
||||
|
||||
it("documents manager-only task privacy hints without protected scope identity", () => {
|
||||
const spec = buildOpenApiSpec() as any;
|
||||
const operation = spec.paths["/api/issues/{id}/privacy-constraints"].get;
|
||||
|
||||
@@ -21,6 +21,18 @@ function fixture(definition: PluginDefinition, rpcTimeoutMs = 30_000) {
|
||||
}
|
||||
|
||||
describe("plugin idle RPC", () => {
|
||||
it("advertises and dispatches the agent lifecycle method", async () => {
|
||||
const request = { companyId: "company", agentId: "agent", operationId: "operation", version: 7, phase: "preparing" };
|
||||
const f = fixture({ async setup() {}, async onAgentLifecycle(input) {
|
||||
expect(input).toEqual(request);
|
||||
return { operationId: input.operationId, version: input.version, status: "pending" };
|
||||
} });
|
||||
try {
|
||||
expect((await f.initialize()).result.supportedMethods).toContain("agentLifecycle");
|
||||
expect((await f.call("agentLifecycle", request)).result).toEqual({ operationId: "operation", version: 7, status: "pending" });
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
const hold = () => ({ ownerId: "idle-owner", expiresAt: Date.now() + 30_000 });
|
||||
|
||||
it("seals an idle worker, fences stale releases, and restores normal calls", async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAgentLifecycle, configureAgentLifecycle } from "../services/agent-lifecycle.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
@@ -268,11 +269,15 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
|
||||
it("keeps an explicit resume durable across managed-agent reconcile", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin({ manifest: pausedManifest() });
|
||||
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
await agentService(db).resume(created.agentId!);
|
||||
await createAgentLifecycle(db).resumeAgent(created.agentId!);
|
||||
|
||||
const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
|
||||
expect(reconciled.agent).toMatchObject({
|
||||
expect(reconciled.agent).toMatchObject({ lifecycleState: "resuming", pauseReason: null });
|
||||
const worker = configureAgentLifecycle(db, { requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost: async () => "complete" });
|
||||
await worker.process(created.agentId!);
|
||||
await worker.stop();
|
||||
expect((await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" })).agent).toMatchObject({
|
||||
status: "idle",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAgentLifecycle } from "../services/agent-lifecycle.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import express from "express";
|
||||
@@ -42,7 +43,7 @@ if (!support.supported) console.warn(`Primary agent database tests unavailable:
|
||||
await db.insert(companyMemberships).values(["user-a", "user-b", "explicit"].map(principalId => ({ companyId: id, principalType: "user", principalId, status: "active", membershipRole: "admin" })));
|
||||
return id;
|
||||
}
|
||||
const create = (companyId: string, name: string, userId?: string) => agentService(db).create(companyId, {
|
||||
const create = (companyId: string, name: string, userId?: string) => createAgentLifecycle(db).requestHire(companyId, {
|
||||
name, adapterType: "process", status: "idle",
|
||||
}, { createdByUserId: userId });
|
||||
const get = (companyId: string, userId = "user-a") => primaryAgentService(db).get(companyId, userId);
|
||||
@@ -123,9 +124,9 @@ if (!support.supported) console.warn(`Primary agent database tests unavailable:
|
||||
|
||||
it("retains paused/error primaries, clears on leave, rejoins when chosen, and preserves stars", async () => {
|
||||
const c = await company(); const a = await create(c, "Maia", "user-a");
|
||||
await agentService(db).pause(a.id);
|
||||
await createAgentLifecycle(db).pauseAgent(a.id);
|
||||
expect((await get(c)).primaryAgentId).toBe(a.id);
|
||||
await agentService(db).update(a.id, { status: "error" });
|
||||
await db.update(agents).set({ status: "error", lifecycleState: "ready", lifecycleHolds: [] }).where(eq(agents.id, a.id));
|
||||
expect((await get(c)).primaryAgentId).toBe(a.id);
|
||||
const memberships = resourceMembershipService(db);
|
||||
await memberships.updateAgent({ companyId: c, userId: "user-a", agentId: a.id, state: "left", actor: actor(c) });
|
||||
@@ -153,23 +154,24 @@ if (!support.supported) console.warn(`Primary agent database tests unavailable:
|
||||
|
||||
it("clears terminated/deleted primaries and rejects terminated targets and explicit removal", async () => {
|
||||
const c = await company(); const a = await create(c, "Maia", "user-a");
|
||||
await agentService(db).terminate(a.id);
|
||||
await createAgentLifecycle(db).terminateAgent(a.id);
|
||||
expect(await get(c)).toMatchObject({ primaryAgentId: null, initialized: true });
|
||||
await request(app(actor(c))).put(url(c)).send({ primaryAgentId: a.id }).expect(422);
|
||||
await request(app(actor(c))).put(url(c)).send({ primaryAgentId: null }).expect(400);
|
||||
const b = await create(c, "Alex", "user-a");
|
||||
expect((await get(c)).primaryAgentId).toBeNull();
|
||||
await request(app(actor(c))).put(url(c)).send({ primaryAgentId: b.id }).expect(200);
|
||||
await db.update(agents).set({ status: "terminated", lifecycleState: "terminated" }).where(eq(agents.id, b.id));
|
||||
await agentService(db).remove(b.id);
|
||||
expect(await get(c)).toMatchObject({ primaryAgentId: null, initialized: true });
|
||||
});
|
||||
|
||||
it("rolls creation and initialization back together", async () => {
|
||||
it("rejects creation inside a caller transaction", async () => {
|
||||
const c = await company();
|
||||
await expect(db.transaction(async tx => {
|
||||
await agentService(tx as unknown as typeof db).create(c, { name: "Rolled back", adapterType: "process" }, { createdByUserId: "user-a" });
|
||||
await createAgentLifecycle(tx as unknown as typeof db).requestHire(c, { name: "Rolled back", adapterType: "process" }, { createdByUserId: "user-a" });
|
||||
throw new Error("rollback");
|
||||
})).rejects.toThrow("rollback");
|
||||
})).rejects.toThrow("root database");
|
||||
expect(await get(c)).toMatchObject({ primaryAgentId: null, initialized: false });
|
||||
expect(await db.select().from(agents).where(eq(agents.companyId, c))).toHaveLength(0);
|
||||
});
|
||||
@@ -177,7 +179,7 @@ if (!support.supported) console.warn(`Primary agent database tests unavailable:
|
||||
it("backfills the earliest human event, leaves gone/terminated originals empty, and preserves explicit preferences", async () => {
|
||||
const c = await company(); const first = await create(c, "First"), later = await create(c, "Later");
|
||||
const terminated = await create(c, "Terminated");
|
||||
await agentService(db).terminate(terminated.id);
|
||||
await createAgentLifecycle(db).terminateAgent(terminated.id);
|
||||
const events = [
|
||||
["human", first.id, "user", 1], ["human", later.id, "user", 2],
|
||||
["gone", randomUUID(), "user", 1], ["gone", later.id, "user", 2],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAgentLifecycle, configureAgentLifecycle } from "../services/agent-lifecycle.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
@@ -22,23 +23,29 @@ describePostgres("Resource lifecycle events", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: Db;
|
||||
let companyId: string;
|
||||
let worker: ReturnType<typeof configureAgentLifecycle>;
|
||||
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-resource-events-");
|
||||
db = createDb(database.connectionString);
|
||||
}, 90_000);
|
||||
afterAll(async () => { await database?.cleanup(); });
|
||||
afterAll(async () => { await worker?.stop(); await database?.cleanup(); });
|
||||
beforeEach(async () => {
|
||||
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", undefined);
|
||||
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", undefined);
|
||||
await worker?.stop();
|
||||
worker = configureAgentLifecycle(db, { requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost: async () => "complete" });
|
||||
companyId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Lifecycle fixture", issuePrefix: `L${companyId.replaceAll("-", "").slice(0, 6)}` });
|
||||
});
|
||||
afterEach(() => { vi.unstubAllEnvs(); });
|
||||
|
||||
const events = () => db.select().from(resourceLifecycleEvents).where(eq(resourceLifecycleEvents.companyId, companyId));
|
||||
const createAgent = (status: "idle" | "pending_approval" | "terminated" = "idle", database: Db = db) =>
|
||||
agentService(database).create(companyId, { name: "Lifecycle agent", adapterType: "process", adapterConfig: {}, status });
|
||||
const createAgent = async (status: "idle" | "pending_approval" | "terminated" = "idle", database: Db = db) => {
|
||||
const agent = await createAgentLifecycle(database).requestHire(companyId, { name: "Lifecycle agent", adapterType: "process", adapterConfig: {}, status });
|
||||
await worker.process(agent.id);
|
||||
return (await agentService(db).getById(agent.id))!;
|
||||
};
|
||||
const createPlugin = async () => {
|
||||
const [plugin] = await db.insert(plugins).values({ pluginKey: randomUUID(), packageName: "lifecycle-fixture", version: "1.0.0", status: "ready", manifestJson: {} as never }).returning();
|
||||
return plugin;
|
||||
@@ -56,7 +63,7 @@ describePostgres("Resource lifecycle events", () => {
|
||||
const approval = await approvalService(db).create(companyId, { type: "hire_agent", status: "pending", payload: { agentId: agent.id } });
|
||||
await approvalService(db).approve(approval.id, "fixture-board");
|
||||
await approvalService(db).approve(approval.id, "fixture-board");
|
||||
await agentService(db).activatePendingApproval(agent.id);
|
||||
await createAgentLifecycle(db).approveHire(agent.id);
|
||||
expect(await events()).toEqual([expect.objectContaining({ resourceType: "agent", resourceId: agent.id })]);
|
||||
});
|
||||
|
||||
@@ -64,6 +71,7 @@ describePostgres("Resource lifecycle events", () => {
|
||||
const approval = await approvalService(db).create(companyId, { type: "hire_agent", status: "pending", payload: { name: "Legacy hire", adapterType: "process" } });
|
||||
await approvalService(db).approve(approval.id, "fixture-board");
|
||||
const [intent] = await events();
|
||||
if (intent) await worker.process(intent.resourceId);
|
||||
expect(intent?.resourceType).toBe("agent");
|
||||
expect(await agentService(db).getById(intent!.resourceId)).toMatchObject({ status: "idle", companyId });
|
||||
});
|
||||
@@ -111,25 +119,32 @@ describePostgres("Resource lifecycle events", () => {
|
||||
it("records every pause/resume cycle and termination in resource order, without duplicate hooks", async () => {
|
||||
const agent = await createAgent();
|
||||
const service = agentService(db);
|
||||
const serviceLifecycle = createAgentLifecycle(db);
|
||||
await db.insert(agentApiKeys).values({ agentId: agent.id, companyId, name: "Lifecycle key", keyHash: "fixture-hash" });
|
||||
await Promise.all(Array.from({ length: 4 }, () => service.pause(agent.id)));
|
||||
await Promise.all(Array.from({ length: 4 }, () => service.resume(agent.id)));
|
||||
await service.update(agent.id, { status: "paused" });
|
||||
await service.update(agent.id, { status: "idle" });
|
||||
await Promise.all(Array.from({ length: 4 }, () => service.terminate(agent.id)));
|
||||
await Promise.all(Array.from({ length: 4 }, () => serviceLifecycle.pauseAgent(agent.id)));
|
||||
await worker.process(agent.id);
|
||||
await Promise.all(Array.from({ length: 4 }, () => serviceLifecycle.resumeAgent(agent.id)));
|
||||
await worker.process(agent.id);
|
||||
await serviceLifecycle.pauseAgent(agent.id);
|
||||
await worker.process(agent.id);
|
||||
await worker.process(agent.id);
|
||||
await serviceLifecycle.resumeAgent(agent.id);
|
||||
await worker.process(agent.id);
|
||||
await worker.process(agent.id);
|
||||
await Promise.all(Array.from({ length: 4 }, () => serviceLifecycle.terminateAgent(agent.id)));
|
||||
const rows = (await events()).sort((a, b) => a.id - b.id);
|
||||
expect(rows.map(row => row.action)).toEqual(["create", "pause", "resume", "pause", "resume", "terminate"]);
|
||||
expect(rows.every(row => row.resourceId === agent.id)).toBe(true);
|
||||
const [key] = await db.select().from(agentApiKeys).where(eq(agentApiKeys.agentId, agent.id));
|
||||
expect(key.revokedAt).not.toBeNull();
|
||||
await expect(service.pause(agent.id)).rejects.toMatchObject({ status: 409 });
|
||||
await expect(service.resume(agent.id)).rejects.toMatchObject({ status: 409 });
|
||||
await expect(serviceLifecycle.pauseAgent(agent.id)).rejects.toMatchObject({ name: "AgentLifecycleConflict" });
|
||||
await expect(serviceLifecycle.resumeAgent(agent.id)).rejects.toMatchObject({ name: "AgentLifecycleConflict" });
|
||||
});
|
||||
|
||||
it("does not bypass pending hire approval with pause or resume", async () => {
|
||||
const agent = await createAgent("pending_approval");
|
||||
await expect(agentService(db).pause(agent.id)).rejects.toMatchObject({ status: 409 });
|
||||
await expect(agentService(db).resume(agent.id)).rejects.toMatchObject({ status: 409 });
|
||||
await expect(createAgentLifecycle(db).pauseAgent(agent.id)).rejects.toMatchObject({ name: "AgentLifecycleConflict" });
|
||||
await expect(createAgentLifecycle(db).resumeAgent(agent.id)).rejects.toMatchObject({ name: "AgentLifecycleConflict" });
|
||||
expect(await events()).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -142,6 +157,7 @@ describePostgres("Resource lifecycle events", () => {
|
||||
await service.evaluateCostEvent(event);
|
||||
expect(await agentService(db).getById(agent.id)).toMatchObject({ status: "paused", pauseReason: "budget" });
|
||||
await service.upsertPolicy(companyId, { scopeType: "agent", scopeId: agent.id, amount: 200 }, "fixture-board");
|
||||
await worker.process(agent.id);
|
||||
expect(await agentService(db).getById(agent.id)).toMatchObject({ status: "idle", pauseReason: null });
|
||||
expect((await events()).sort((a, b) => a.id - b.id).map(row => row.action)).toEqual(["create", "pause", "resume"]);
|
||||
});
|
||||
@@ -149,14 +165,15 @@ describePostgres("Resource lifecycle events", () => {
|
||||
it("rolls back pause, resume, termination, and key revocation if a hook write fails", async () => {
|
||||
const agent = await createAgent();
|
||||
const paused = await createAgent();
|
||||
await agentService(db).pause(paused.id);
|
||||
await createAgentLifecycle(db).pauseAgent(paused.id);
|
||||
await worker.process(paused.id);
|
||||
await db.insert(agentApiKeys).values({ agentId: agent.id, companyId, name: "Retained key", keyHash: "fixture-retained-hash" });
|
||||
const before = await events();
|
||||
await db.execute(sql`ALTER TABLE resource_lifecycle_events ADD CONSTRAINT fixture_reject_hook CHECK (false) NOT VALID`);
|
||||
try {
|
||||
await expect(agentService(db).pause(agent.id)).rejects.toThrow();
|
||||
await expect(agentService(db).resume(paused.id)).rejects.toThrow();
|
||||
await expect(agentService(db).terminate(agent.id)).rejects.toThrow();
|
||||
await expect(createAgentLifecycle(db).pauseAgent(agent.id)).rejects.toThrow();
|
||||
await expect(createAgentLifecycle(db).resumeAgent(paused.id)).rejects.toThrow();
|
||||
await expect(createAgentLifecycle(db).terminateAgent(agent.id)).rejects.toThrow();
|
||||
expect(await agentService(db).getById(agent.id)).toMatchObject({ status: "idle" });
|
||||
expect(await agentService(db).getById(paused.id)).toMatchObject({ status: "paused" });
|
||||
const [key] = await db.select().from(agentApiKeys).where(eq(agentApiKeys.agentId, agent.id));
|
||||
@@ -172,7 +189,8 @@ describePostgres("Resource lifecycle events", () => {
|
||||
const projectId = randomUUID();
|
||||
await expect(db.transaction(async tx => {
|
||||
const txDb = tx as unknown as Db;
|
||||
await agentService(txDb).create(companyId, { id: agentId, name: "Rolled back agent" });
|
||||
await tx.insert(agents).values({ id: agentId, companyId, name: "Rolled back agent" });
|
||||
await recordResourceCreationEvent(txDb, companyId, "agent", agentId);
|
||||
await projectService(txDb).createWithRepositories(companyId, { id: projectId, name: "Rolled back project" }, []);
|
||||
throw new Error("rollback fixture");
|
||||
})).rejects.toThrow("rollback fixture");
|
||||
@@ -387,8 +405,10 @@ describePostgres("Resource lifecycle events", () => {
|
||||
const first = await createPlugin();
|
||||
const second = await createPlugin();
|
||||
const agent = await createAgent();
|
||||
await agentService(db).pause(agent.id);
|
||||
await agentService(db).resume(agent.id);
|
||||
await createAgentLifecycle(db).pauseAgent(agent.id);
|
||||
await worker.process(agent.id);
|
||||
await createAgentLifecycle(db).resumeAgent(agent.id);
|
||||
await worker.process(agent.id);
|
||||
const journal = (await events()).sort((a, b) => a.id - b.id);
|
||||
const inbox = pluginLifecycleInbox(db, first.id);
|
||||
const [event] = await inbox.list(companyId);
|
||||
@@ -426,7 +446,8 @@ describePostgres("Resource lifecycle events", () => {
|
||||
const plugin = await createPlugin();
|
||||
const inbox = pluginLifecycleInbox(db, plugin.id);
|
||||
await db.transaction(async tx => {
|
||||
const low = await agentService(tx as unknown as Db).create(companyId, { name: "Delayed commit" });
|
||||
const [low] = await tx.insert(agents).values({ companyId, name: "Delayed commit" }).returning();
|
||||
await recordResourceCreationEvent(tx as unknown as Db, companyId, "agent", low.id);
|
||||
const high = await createAgent();
|
||||
const [visible] = await inbox.list(companyId);
|
||||
expect(visible.resourceId).toBe(high.id);
|
||||
|
||||
@@ -335,7 +335,7 @@ describeEmbeddedPostgres("secret proposal routes", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires agent JWT and atomically cascade-approves a secret plus binding with dual audits", async () => {
|
||||
it("requires agent JWT and approves a secret before its binding with dual audits", async () => {
|
||||
const fixture = await seedRun();
|
||||
const denied = await request(createAgentApp(fixture, "agent_key"))
|
||||
.post("/api/agents/me/secret-proposals")
|
||||
@@ -475,6 +475,46 @@ describeEmbeddedPostgres("secret proposal routes", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps an approved secret after binding fails and reuses it on retry", async () => {
|
||||
const fixture = await seedRun();
|
||||
const agentApp = createAgentApp(fixture);
|
||||
const boardApp = createBoardApp(fixture);
|
||||
const secret = await request(agentApp).post("/api/agents/me/secret-proposals").send({
|
||||
kind: "secret", name: "dev/retry/token", value: "retry-fixture", justification: "Needed by task",
|
||||
});
|
||||
const binding = await request(agentApp).post("/api/agents/me/secret-proposals").send({
|
||||
kind: "binding", secretProposalId: secret.body.id, configPath: "env.RETRY_TOKEN", justification: "Use saved secret",
|
||||
});
|
||||
expect(secret.status).toBe(201);
|
||||
expect(binding.status).toBe(201);
|
||||
await db.update(agents).set({ adapterConfig: { env: { RETRY_TOKEN: "existing-value" } } })
|
||||
.where(eq(agents.id, fixture.agentId));
|
||||
const approve = () => request(boardApp)
|
||||
.post(`/api/companies/${fixture.companyId}/secret-proposals/${binding.body.id}/approve`).send({ cascade: true });
|
||||
|
||||
expect((await approve()).status).toBe(409);
|
||||
const [saved] = await db.select().from(companySecrets);
|
||||
expect(saved).toMatchObject({ name: "dev/retry/token", status: "active" });
|
||||
expect(await db.select().from(companySecretProposals)).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: secret.body.id, status: "approved", createdSecretId: saved.id, valueCiphertext: null }),
|
||||
expect.objectContaining({ id: binding.body.id, status: "pending" }),
|
||||
]));
|
||||
expect(await db.select().from(companySecretBindings)).toHaveLength(0);
|
||||
expect((await db.select().from(agents))[0].adapterConfig).toEqual({ env: { RETRY_TOKEN: "existing-value" } });
|
||||
expect((await db.select().from(issueThreadInteractions))[0].status).toBe("pending");
|
||||
|
||||
await db.update(agents).set({ adapterConfig: {} }).where(eq(agents.id, fixture.agentId));
|
||||
expect((await approve()).status).toBe(200);
|
||||
expect(await db.select().from(companySecrets)).toHaveLength(1);
|
||||
expect(await db.select().from(companySecretVersions)).toHaveLength(1);
|
||||
expect(await db.select().from(companySecretBindings)).toEqual([
|
||||
expect.objectContaining({ secretId: saved.id, targetId: fixture.agentId, configPath: "env.RETRY_TOKEN" }),
|
||||
]);
|
||||
const history = await db.select().from(activityLog);
|
||||
expect(history.filter(row => row.action === "secret.created")).toHaveLength(1);
|
||||
expect(history.filter(row => row.action === "secret.proposal.approved")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("approves a binding without cascade after its secret proposal was approved separately", async () => {
|
||||
const fixture = await seedRun();
|
||||
const agentApp = createAgentApp(fixture);
|
||||
@@ -1446,7 +1486,7 @@ describeEmbeddedPostgres("secret proposal routes", () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it("holds the org graph stable until binding approval commits", async () => {
|
||||
it("rechecks the org graph after secret approval commits", async () => {
|
||||
const fixture = await seedRun();
|
||||
const targetAgentId = randomUUID();
|
||||
await db.insert(agents).values({
|
||||
@@ -1532,8 +1572,9 @@ describeEmbeddedPostgres("secret proposal routes", () => {
|
||||
expect(reorgFinished).toBe(false);
|
||||
|
||||
const approval = await approvalPromise;
|
||||
expect(approval.status).toBe(200);
|
||||
await reorgPromise;
|
||||
expect(approval.status).toBe(409);
|
||||
expect(approval.body.error).toContain("chain-of-command policy");
|
||||
} finally {
|
||||
await db.execute(sql.raw(`
|
||||
DROP TRIGGER IF EXISTS paperclip_test_pause_secret_approval ON company_secrets;
|
||||
@@ -1541,9 +1582,12 @@ describeEmbeddedPostgres("secret proposal routes", () => {
|
||||
`));
|
||||
}
|
||||
|
||||
expect(await db.select().from(companySecretBindings)).toEqual([
|
||||
expect.objectContaining({ targetId: targetAgentId, configPath: "access.CONCURRENT_REORG_TOKEN" }),
|
||||
]);
|
||||
expect(await db.select().from(companySecretBindings)).toHaveLength(0);
|
||||
expect(await db.select().from(companySecrets)).toHaveLength(1);
|
||||
expect(await db.select().from(companySecretProposals)).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: secretProposal.body.id, status: "approved", valueCiphertext: null }),
|
||||
expect.objectContaining({ id: bindingProposal.body.id, status: "pending" }),
|
||||
]));
|
||||
expect(await db.select({ reportsTo: agents.reportsTo }).from(agents).where(eq(agents.id, targetAgentId)))
|
||||
.toEqual([{ reportsTo: null }]);
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from "@paperclipai/shared";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { statusCardRoutes } from "../routes/status-cards.js";
|
||||
import { withBuiltInAgentMarker } from "../services/built-in-agent-metadata.js";
|
||||
import { withBuiltInAgentMarker } from "../lib/built-in-agent-metadata.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import type { IssueAssignmentWakeupDeps } from "../services/issue-assignment-wakeup.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { summarySlotService } from "../services/summary-slots.ts";
|
||||
import { withBuiltInAgentMarker } from "../services/built-in-agent-metadata.ts";
|
||||
import { withBuiltInAgentMarker } from "../lib/built-in-agent-metadata.ts";
|
||||
import { issueService } from "../services/issues.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createDeliveryWorkCoordinator } from "./services/delivery-work-coordinator.js";
|
||||
import { DELIVERY_QUEUES } from "./services/delivery-work-notifications.js";
|
||||
import { createLifecycleDriver } from "./services/agent-lifecycle-driver.js";
|
||||
import { startAgentLifecycle } from "./services/agent-lifecycle.js";
|
||||
import { idleAdmissionMiddleware, trackIdleRequestHandlers } from "./middleware/idle-admission.js";
|
||||
import { isIdleTaskDrainActive, trackIdleWork } from "./services/task-admission.js";
|
||||
import { customerSuccessRoutes } from "./routes/customer-success.js";
|
||||
@@ -623,6 +625,8 @@ export async function createApp(
|
||||
|
||||
const hostServicesDisposers = new Map<string, () => void>();
|
||||
const workerManager = opts.pluginWorkerManager ?? createPluginWorkerManager();
|
||||
let lifecyclePluginsReady = false;
|
||||
const agentLifecycle = startAgentLifecycle(db, createLifecycleDriver(db, workerManager), () => lifecyclePluginsReady && !isWarmStandby() && !isIdleTaskDrainActive());
|
||||
const connectionIntentHeartbeat = heartbeatService(db, {
|
||||
pluginWorkerManager: workerManager,
|
||||
});
|
||||
@@ -1360,6 +1364,7 @@ export async function createApp(
|
||||
logger.error({ err }, "Failed to load ready plugins on startup");
|
||||
});
|
||||
app.locals.bundledPluginsStartup = trackIdleWork(bundledPluginsStartup);
|
||||
void bundledPluginsStartup.then(() => { lifecyclePluginsReady = true; return agentLifecycle.sweep(); }).catch(() => logger.warn("Agent lifecycle recovery failed; retrying."));
|
||||
// The shutdown hook runs at most once. It caches the in-flight promise, so a
|
||||
// second caller (for example the `exit` handler) awaits the same completion
|
||||
// instead of starting a second teardown.
|
||||
@@ -1370,6 +1375,7 @@ export async function createApp(
|
||||
// The scheduler tick queries the database. Stop it here, inside the
|
||||
// awaited teardown, so no tick runs after the caller ends the pool.
|
||||
scheduler.stop();
|
||||
await agentLifecycle.stop();
|
||||
await publicMcpEvents?.stop();
|
||||
await dotMcpEvents?.stop();
|
||||
jobCoordinator.stop();
|
||||
|
||||
File renamed without changes.
@@ -0,0 +1,475 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { and, eq, gte, inArray, lt, sql } from "drizzle-orm";
|
||||
import { agents, costEvents, type Db } from "@paperclipai/db";
|
||||
import {
|
||||
agentAppearanceSchema, resolveAgentAppearance, agentAvatarUrl,
|
||||
AGENT_DEFAULT_MAX_CONCURRENT_RUNS, agentRuntimeConfigSchema, getAgentWorkEligibility,
|
||||
normalizeAgentUrlKey, type AgentEligibilityAgent, type AgentRunnerChoice,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js";
|
||||
import { normalizeAgentPermissions } from "./agent-permissions.js";
|
||||
import { builtInAgentMarkersEqual, readBuiltInAgentMarker } from "./built-in-agent-metadata.js";
|
||||
|
||||
export function hashToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function createToken() {
|
||||
return `pcp_${randomBytes(24).toString("hex")}`;
|
||||
}
|
||||
|
||||
const CONFIG_REVISION_FIELDS = [
|
||||
"name",
|
||||
"role",
|
||||
"title",
|
||||
"icon",
|
||||
"appearance",
|
||||
"reportsTo",
|
||||
"capabilities",
|
||||
"adapterType",
|
||||
"adapterConfig",
|
||||
"runtimeConfig",
|
||||
"defaultEnvironmentId",
|
||||
"budgetMonthlyCents",
|
||||
"metadata",
|
||||
] as const;
|
||||
|
||||
type ConfigRevisionField = (typeof CONFIG_REVISION_FIELDS)[number];
|
||||
type AgentConfigSnapshot = Pick<typeof agents.$inferSelect, ConfigRevisionField>;
|
||||
|
||||
export interface RevisionMetadata {
|
||||
createdByAgentId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
source?: string;
|
||||
rolledBackFromRevisionId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Claude login context for an agent write. The route derives the owner user
|
||||
* from the authenticated actor, not from the request body, and forwards the
|
||||
* non-secret `storedSessionId` claim from a completed Claude login session. A
|
||||
* controlled internal override permits a migration or an administrator repair to
|
||||
* bind or unbind the fixed OAuth token without a claim.
|
||||
*
|
||||
* The `applyExistingWithoutClaim` field is the user-actor apply-existing path.
|
||||
* The route sets it only for an authenticated user actor and derives the owner
|
||||
* from that actor. The path binds the fixed reference to the owner stored value
|
||||
* with no login round trip. It is distinct from `allowInternalBindingOverride`,
|
||||
* which does no ownership check.
|
||||
*
|
||||
* The `inheritedFromAgentId` field is the hire-inheritance path. The route
|
||||
* sets it only for an authenticated agent actor whose hire request inherited
|
||||
* the fixed reference from that named parent. The service re-reads the parent
|
||||
* agent inside the write transaction and binds the fixed reference only when
|
||||
* the parent exists, is in the same company, is a `claude_local` agent, and
|
||||
* already holds the exact fixed binding.
|
||||
*/
|
||||
export interface ClaudeLoginContext {
|
||||
storedSessionId?: string | null;
|
||||
ownerUserId?: string | null;
|
||||
allowInternalBindingOverride?: boolean;
|
||||
applyExistingWithoutClaim?: boolean;
|
||||
inheritedFromAgentId?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateAgentOptions {
|
||||
recordRevision?: RevisionMetadata;
|
||||
allowBuiltInAgentMetadata?: boolean;
|
||||
allowPendingApprovalConfigUpdate?: boolean;
|
||||
claudeLogin?: ClaudeLoginContext;
|
||||
}
|
||||
|
||||
export type CreateAgentData = Omit<typeof agents.$inferInsert, "companyId" | "lifecycleState" | "lifecycleRequiredPluginIds" | "lifecycleHolds" | "lifecycleVersion" | "lifecycleError" | "lifecycleOperation"> & { runner?: AgentRunnerChoice };
|
||||
export type AgentConfigurationRecord = typeof agents.$inferSelect;
|
||||
export const AGENT_CONFIGURATION_FIELDS = [...CONFIG_REVISION_FIELDS, "permissions"] as const;
|
||||
export type AgentConfigurationPatch = Partial<Pick<typeof agents.$inferInsert, typeof AGENT_CONFIGURATION_FIELDS[number]>>;
|
||||
export type AgentHireRecord = Pick<typeof agents.$inferSelect, "id" | "companyId" | "adapterType" | "adapterConfig" | "permissions" | "metadata" | "status">;
|
||||
|
||||
export interface CreateAgentOptions {
|
||||
/** Reviewed/historical execution already resolved by a governed internal caller. */
|
||||
runnerResolved?: boolean;
|
||||
responsibleUserId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
aiConnectionInstall?: { connectionId: string; memberConnectionIds?: string[]; createdByUserId: string | null };
|
||||
allowBuiltInAgentMetadata?: boolean;
|
||||
claudeLogin?: ClaudeLoginContext;
|
||||
}
|
||||
|
||||
interface AgentShortnameRow {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface AgentShortnameCollisionOptions {
|
||||
excludeAgentId?: string | null;
|
||||
}
|
||||
|
||||
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function jsonEqual(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
export function buildConfigSnapshot(
|
||||
row: Pick<typeof agents.$inferSelect, ConfigRevisionField>,
|
||||
): AgentConfigSnapshot {
|
||||
const adapterConfig =
|
||||
typeof row.adapterConfig === "object" && row.adapterConfig !== null && !Array.isArray(row.adapterConfig)
|
||||
? sanitizeRecord(row.adapterConfig as Record<string, unknown>)
|
||||
: {};
|
||||
const runtimeConfig =
|
||||
typeof row.runtimeConfig === "object" && row.runtimeConfig !== null && !Array.isArray(row.runtimeConfig)
|
||||
? sanitizeRecord(row.runtimeConfig as Record<string, unknown>)
|
||||
: {};
|
||||
const metadata =
|
||||
typeof row.metadata === "object" && row.metadata !== null && !Array.isArray(row.metadata)
|
||||
? sanitizeRecord(row.metadata as Record<string, unknown>)
|
||||
: row.metadata ?? null;
|
||||
return {
|
||||
name: row.name,
|
||||
role: row.role,
|
||||
title: row.title,
|
||||
icon: row.icon,
|
||||
appearance: row.appearance,
|
||||
reportsTo: row.reportsTo,
|
||||
capabilities: row.capabilities,
|
||||
adapterType: row.adapterType,
|
||||
adapterConfig,
|
||||
runtimeConfig,
|
||||
defaultEnvironmentId: row.defaultEnvironmentId,
|
||||
budgetMonthlyCents: row.budgetMonthlyCents,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
export function containsRedactedMarker(value: unknown): boolean {
|
||||
if (value === REDACTED_EVENT_VALUE) return true;
|
||||
if (Array.isArray(value)) return value.some((item) => containsRedactedMarker(item));
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
return Object.values(value as Record<string, unknown>).some((entry) => containsRedactedMarker(entry));
|
||||
}
|
||||
|
||||
export function hasConfigPatchFields(data: Partial<typeof agents.$inferInsert>) {
|
||||
return CONFIG_REVISION_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(data, field));
|
||||
}
|
||||
|
||||
export function changedPendingApprovalConfigFields(
|
||||
existing: Omit<typeof agents.$inferSelect, "lifecycleOperation" | "lifecycleRequiredPluginIds">,
|
||||
data: Partial<typeof agents.$inferInsert>,
|
||||
) {
|
||||
return CONFIG_REVISION_FIELDS.filter((field) =>
|
||||
Object.prototype.hasOwnProperty.call(data, field) && !jsonEqual(data[field], existing[field]),
|
||||
);
|
||||
}
|
||||
|
||||
export function configPatchFromApprovalPayload(payload: Record<string, unknown>) {
|
||||
const patch: Partial<typeof agents.$inferInsert> = {};
|
||||
if (typeof payload.name === "string") patch.name = payload.name;
|
||||
if (typeof payload.role === "string") patch.role = payload.role;
|
||||
if (payload.appearance != null) patch.appearance = agentAppearanceSchema.parse(payload.appearance);
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "title")) {
|
||||
patch.title = typeof payload.title === "string" ? payload.title : null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "icon")) {
|
||||
patch.icon = typeof payload.icon === "string" ? payload.icon : null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "reportsTo")) {
|
||||
patch.reportsTo = typeof payload.reportsTo === "string" ? payload.reportsTo : null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "capabilities")) {
|
||||
patch.capabilities = typeof payload.capabilities === "string" ? payload.capabilities : null;
|
||||
}
|
||||
if (typeof payload.adapterType === "string") patch.adapterType = payload.adapterType;
|
||||
if (isPlainRecord(payload.adapterConfig)) patch.adapterConfig = payload.adapterConfig;
|
||||
if (isPlainRecord(payload.runtimeConfig)) patch.runtimeConfig = payload.runtimeConfig;
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "defaultEnvironmentId")) {
|
||||
patch.defaultEnvironmentId =
|
||||
typeof payload.defaultEnvironmentId === "string" ? payload.defaultEnvironmentId : null;
|
||||
}
|
||||
if (typeof payload.budgetMonthlyCents === "number") {
|
||||
patch.budgetMonthlyCents = payload.budgetMonthlyCents;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "metadata")) {
|
||||
patch.metadata = isPlainRecord(payload.metadata) ? payload.metadata : null;
|
||||
}
|
||||
if (isPlainRecord(payload.permissions)) {
|
||||
patch.permissions = payload.permissions;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
function parseFiniteNumberLike(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value !== "string") return null;
|
||||
const parsed = Number(value.trim());
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function normalizeRuntimeConfigForNewAgent(runtimeConfig: unknown): Record<string, unknown> {
|
||||
const normalizedRuntimeConfig = isPlainRecord(runtimeConfig) ? { ...runtimeConfig } : {};
|
||||
const heartbeat = isPlainRecord(normalizedRuntimeConfig.heartbeat)
|
||||
? { ...normalizedRuntimeConfig.heartbeat }
|
||||
: {};
|
||||
if (parseFiniteNumberLike(heartbeat.maxConcurrentRuns) == null) {
|
||||
heartbeat.maxConcurrentRuns = AGENT_DEFAULT_MAX_CONCURRENT_RUNS;
|
||||
}
|
||||
normalizedRuntimeConfig.heartbeat = heartbeat;
|
||||
return normalizedRuntimeConfig;
|
||||
}
|
||||
|
||||
export function diffConfigSnapshot(
|
||||
before: AgentConfigSnapshot,
|
||||
after: AgentConfigSnapshot,
|
||||
): string[] {
|
||||
return CONFIG_REVISION_FIELDS.filter((field) => !jsonEqual(before[field], after[field]));
|
||||
}
|
||||
|
||||
export function configPatchFromSnapshot(snapshot: unknown): Partial<typeof agents.$inferInsert> {
|
||||
if (!isPlainRecord(snapshot)) throw unprocessable("Invalid revision snapshot");
|
||||
|
||||
if (typeof snapshot.name !== "string" || snapshot.name.length === 0) {
|
||||
throw unprocessable("Invalid revision snapshot: name");
|
||||
}
|
||||
if (typeof snapshot.role !== "string" || snapshot.role.length === 0) {
|
||||
throw unprocessable("Invalid revision snapshot: role");
|
||||
}
|
||||
if (typeof snapshot.adapterType !== "string" || snapshot.adapterType.length === 0) {
|
||||
throw unprocessable("Invalid revision snapshot: adapterType");
|
||||
}
|
||||
if (typeof snapshot.budgetMonthlyCents !== "number" || !Number.isFinite(snapshot.budgetMonthlyCents)) {
|
||||
throw unprocessable("Invalid revision snapshot: budgetMonthlyCents");
|
||||
}
|
||||
const runtimeConfig = agentRuntimeConfigSchema.safeParse(
|
||||
isPlainRecord(snapshot.runtimeConfig) ? snapshot.runtimeConfig : {},
|
||||
);
|
||||
if (!runtimeConfig.success) {
|
||||
throw unprocessable("Invalid revision snapshot: runtimeConfig");
|
||||
}
|
||||
|
||||
return {
|
||||
name: snapshot.name,
|
||||
role: snapshot.role,
|
||||
title: typeof snapshot.title === "string" || snapshot.title === null ? snapshot.title : null,
|
||||
reportsTo:
|
||||
typeof snapshot.reportsTo === "string" || snapshot.reportsTo === null ? snapshot.reportsTo : null,
|
||||
capabilities:
|
||||
typeof snapshot.capabilities === "string" || snapshot.capabilities === null
|
||||
? snapshot.capabilities
|
||||
: null,
|
||||
adapterType: snapshot.adapterType,
|
||||
adapterConfig: isPlainRecord(snapshot.adapterConfig) ? snapshot.adapterConfig : {},
|
||||
runtimeConfig: runtimeConfig.data,
|
||||
defaultEnvironmentId:
|
||||
typeof snapshot.defaultEnvironmentId === "string" || snapshot.defaultEnvironmentId === null
|
||||
? snapshot.defaultEnvironmentId
|
||||
: null,
|
||||
budgetMonthlyCents: Math.max(0, Math.floor(snapshot.budgetMonthlyCents)),
|
||||
metadata: isPlainRecord(snapshot.metadata) || snapshot.metadata === null ? snapshot.metadata : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasAgentShortnameCollision(
|
||||
candidateName: string,
|
||||
existingAgents: AgentShortnameRow[],
|
||||
options?: AgentShortnameCollisionOptions,
|
||||
): boolean {
|
||||
const candidateShortname = normalizeAgentUrlKey(candidateName);
|
||||
if (!candidateShortname) return false;
|
||||
|
||||
return existingAgents.some((agent) => {
|
||||
if (agent.status === "terminated") return false;
|
||||
if (options?.excludeAgentId && agent.id === options.excludeAgentId) return false;
|
||||
return normalizeAgentUrlKey(agent.name) === candidateShortname;
|
||||
});
|
||||
}
|
||||
|
||||
export function deduplicateAgentName(
|
||||
candidateName: string,
|
||||
existingAgents: AgentShortnameRow[],
|
||||
): string {
|
||||
if (!hasAgentShortnameCollision(candidateName, existingAgents)) {
|
||||
return candidateName;
|
||||
}
|
||||
for (let i = 2; i <= 100; i++) {
|
||||
const suffixed = `${candidateName} ${i}`;
|
||||
if (!hasAgentShortnameCollision(suffixed, existingAgents)) {
|
||||
return suffixed;
|
||||
}
|
||||
}
|
||||
return `${candidateName} ${Date.now()}`;
|
||||
}
|
||||
|
||||
export function agentRecordQueries(db: Db) {
|
||||
function currentUtcMonthWindow(now = new Date()) {
|
||||
const year = now.getUTCFullYear();
|
||||
const month = now.getUTCMonth();
|
||||
return {
|
||||
start: new Date(Date.UTC(year, month, 1, 0, 0, 0, 0)),
|
||||
end: new Date(Date.UTC(year, month + 1, 1, 0, 0, 0, 0)),
|
||||
};
|
||||
}
|
||||
|
||||
function withUrlKey<T extends { id: string; name: string }>(row: T) {
|
||||
return {
|
||||
...row,
|
||||
urlKey: normalizeAgentUrlKey(row.name) ?? row.id,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAgentBaseRow(row: typeof agents.$inferSelect) {
|
||||
const { lifecycleOperation, lifecycleRequiredPluginIds, ...publicRow } = row;
|
||||
return withUrlKey({
|
||||
...publicRow,
|
||||
permissions: normalizeAgentPermissions(row.permissions),
|
||||
});
|
||||
}
|
||||
|
||||
function toEligibilityAgent(row: Pick<typeof agents.$inferSelect, "id" | "companyId" | "name" | "status" | "reportsTo">): AgentEligibilityAgent {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
name: row.name,
|
||||
status: row.status,
|
||||
reportsTo: row.reportsTo,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAgentRows(rows: (typeof agents.$inferSelect)[], allCompanyRows = rows) {
|
||||
const eligibilityAgents = allCompanyRows.map(toEligibilityAgent);
|
||||
return rows.map((row) => {
|
||||
const base = normalizeAgentBaseRow(row);
|
||||
const appearance = resolveAgentAppearance(row.appearance, row.id);
|
||||
return {
|
||||
...base,
|
||||
appearance,
|
||||
avatarUrl: agentAvatarUrl(appearance),
|
||||
orgChainHealth: getAgentWorkEligibility({
|
||||
agent: toEligibilityAgent(row),
|
||||
agents: eligibilityAgents,
|
||||
}).orgChainHealth,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAgentRow(row: typeof agents.$inferSelect, allCompanyRows?: (typeof agents.$inferSelect)[]) {
|
||||
return normalizeAgentRows([row], allCompanyRows)[0]!;
|
||||
}
|
||||
|
||||
async function listCompanyAgentRows(companyId: string) {
|
||||
return db.select().from(agents).where(eq(agents.companyId, companyId));
|
||||
}
|
||||
|
||||
async function getMonthlySpendByAgentIds(companyId: string, agentIds: string[]) {
|
||||
if (agentIds.length === 0) return new Map<string, number>();
|
||||
const { start, end } = currentUtcMonthWindow();
|
||||
const rows = await db
|
||||
.select({
|
||||
agentId: costEvents.agentId,
|
||||
spentMonthlyCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::double precision`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(costEvents.companyId, companyId),
|
||||
inArray(costEvents.agentId, agentIds),
|
||||
gte(costEvents.occurredAt, start),
|
||||
lt(costEvents.occurredAt, end),
|
||||
),
|
||||
)
|
||||
.groupBy(costEvents.agentId);
|
||||
return new Map(rows.map((row) => [row.agentId, Number(row.spentMonthlyCents ?? 0)]));
|
||||
}
|
||||
|
||||
async function hydrateAgentSpend<T extends { id: string; companyId: string; spentMonthlyCents: number }>(rows: T[]) {
|
||||
const agentIds = rows.map((row) => row.id);
|
||||
const companyId = rows[0]?.companyId;
|
||||
if (!companyId || agentIds.length === 0) return rows;
|
||||
const spendByAgentId = await getMonthlySpendByAgentIds(companyId, agentIds);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
spentMonthlyCents: spendByAgentId.get(row.id) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async function getById(id: string) {
|
||||
const row = await db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) return null;
|
||||
const [companyRows, hydrated] = await Promise.all([
|
||||
listCompanyAgentRows(row.companyId),
|
||||
hydrateAgentSpend([row]).then((rows) => rows[0]!),
|
||||
]);
|
||||
return normalizeAgentRow(hydrated, companyRows);
|
||||
}
|
||||
|
||||
async function ensureManager(companyId: string, managerId: string) {
|
||||
const manager = await getById(managerId);
|
||||
if (!manager) throw notFound("Manager not found");
|
||||
if (manager.companyId !== companyId) {
|
||||
throw unprocessable("Manager must belong to same company");
|
||||
}
|
||||
return manager;
|
||||
}
|
||||
|
||||
async function assertNoCycle(agentId: string, reportsTo: string | null | undefined) {
|
||||
if (!reportsTo) return;
|
||||
if (reportsTo === agentId) throw unprocessable("Agent cannot report to itself");
|
||||
|
||||
let cursor: string | null = reportsTo;
|
||||
while (cursor) {
|
||||
if (cursor === agentId) throw unprocessable("Reporting relationship would create cycle");
|
||||
const next = await getById(cursor);
|
||||
cursor = next?.reportsTo ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertCompanyShortnameAvailable(
|
||||
companyId: string,
|
||||
candidateName: string,
|
||||
options?: AgentShortnameCollisionOptions,
|
||||
) {
|
||||
const candidateShortname = normalizeAgentUrlKey(candidateName);
|
||||
if (!candidateShortname) return;
|
||||
|
||||
const existingAgents = await db
|
||||
.select({
|
||||
id: agents.id,
|
||||
name: agents.name,
|
||||
status: agents.status,
|
||||
})
|
||||
.from(agents)
|
||||
.where(eq(agents.companyId, companyId));
|
||||
|
||||
const hasCollision = hasAgentShortnameCollision(candidateName, existingAgents, options);
|
||||
if (hasCollision) {
|
||||
throw conflict(
|
||||
`Agent shortname '${candidateShortname}' is already in use in this company`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertBuiltInAgentMetadataMutationAllowed(
|
||||
beforeMetadata: unknown,
|
||||
afterMetadata: unknown,
|
||||
options?: { allowBuiltInAgentMetadata?: boolean },
|
||||
) {
|
||||
if (options?.allowBuiltInAgentMetadata) return;
|
||||
const beforeMarker = readBuiltInAgentMarker(beforeMetadata);
|
||||
const afterMarker = readBuiltInAgentMarker(afterMetadata);
|
||||
if (builtInAgentMarkersEqual(beforeMarker, afterMarker)) return;
|
||||
throw conflict("Built-in agent marker is managed by Paperclip and cannot be edited directly", {
|
||||
code: "built_in_agent_marker_readonly",
|
||||
key: beforeMarker?.key ?? afterMarker?.key ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return { normalizeAgentRows, normalizeAgentRow, listCompanyAgentRows, hydrateAgentSpend,
|
||||
getById, ensureManager, assertNoCycle, assertCompanyShortnameAvailable, assertBuiltInAgentMetadataMutationAllowed };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { approvals, type Db } from "@paperclipai/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
|
||||
export const resolvableApprovalStatuses = ["pending", "revision_requested"];
|
||||
|
||||
export function approvalResolutionRecords(db: Db) {
|
||||
const canResolveStatuses = new Set(resolvableApprovalStatuses);
|
||||
const resolvableStatuses = resolvableApprovalStatuses;
|
||||
type ApprovalRecord = typeof approvals.$inferSelect;
|
||||
type ResolutionResult = { approval: ApprovalRecord; applied: boolean };
|
||||
async function getExistingApproval(id: string, database: Db = db) {
|
||||
const existing = await database
|
||||
.select()
|
||||
.from(approvals)
|
||||
.where(eq(approvals.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) throw notFound("Approval not found");
|
||||
return existing;
|
||||
}
|
||||
|
||||
async function resolveApproval(
|
||||
id: string,
|
||||
targetStatus: "approved" | "rejected",
|
||||
decidedByUserId: string,
|
||||
decisionNote: string | null | undefined,
|
||||
database: Db = db,
|
||||
): Promise<ResolutionResult> {
|
||||
const existing = await getExistingApproval(id, database);
|
||||
if (!canResolveStatuses.has(existing.status)) {
|
||||
if (existing.status === targetStatus) {
|
||||
return { approval: existing, applied: false };
|
||||
}
|
||||
throw unprocessable(
|
||||
`Only pending or revision requested approvals can be ${targetStatus === "approved" ? "approved" : "rejected"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updated = await database
|
||||
.update(approvals)
|
||||
.set({
|
||||
status: targetStatus,
|
||||
decidedByUserId,
|
||||
decisionNote: decisionNote ?? null,
|
||||
decidedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(approvals.id, id), inArray(approvals.status, resolvableStatuses)))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (updated) {
|
||||
return { approval: updated, applied: true };
|
||||
}
|
||||
|
||||
const latest = await getExistingApproval(id, database);
|
||||
if (latest.status === targetStatus) {
|
||||
return { approval: latest, applied: false };
|
||||
}
|
||||
|
||||
throw unprocessable(
|
||||
`Only pending or revision requested approvals can be ${targetStatus === "approved" ? "approved" : "rejected"}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { getExistingApproval, resolveApproval };
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,9 @@
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
type CompanyDeletionTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
|
||||
export interface CompanyDeletionParticipant {
|
||||
// The caller holds the company lock and removes dependent records first.
|
||||
// Use this transaction only. Throw on failure. Do not call external systems.
|
||||
deleteCompanyData(tx: CompanyDeletionTransaction, companyId: string): Promise<void>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentLifecycleConflict } from "../modules/agent-lifecycle/index.js";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { ZodError } from "zod";
|
||||
@@ -129,6 +130,7 @@ export function errorHandler(
|
||||
res: Response,
|
||||
_next: NextFunction,
|
||||
) {
|
||||
if (err instanceof AgentLifecycleConflict) { res.status(409).json({ error: err.message }); return; }
|
||||
if (err instanceof HttpError) {
|
||||
const details =
|
||||
err.details &&
|
||||
|
||||
@@ -22,10 +22,37 @@ adapters → application → domain
|
||||
Postgres queries, transactions, and process control. An adapter file may
|
||||
import `drizzle-orm`, `@paperclipai/db`, and Node.js I/O modules.
|
||||
|
||||
A module exposes one entry point, `index.ts`, which composes the adapters
|
||||
A module exposes its commands through `index.ts`, which composes the adapters
|
||||
and the use cases behind a factory function. Code outside the module imports
|
||||
only that entry point, never a file inside `domain/`, `application/`, or
|
||||
that entry point, never a file inside `domain/`, `application/`, or
|
||||
`adapters/` directly.
|
||||
|
||||
`pnpm check:module-boundaries` enforces these rules for production source
|
||||
files. It also rejects imports that bypass another module's `index.ts`.
|
||||
files. It also checks access to the company deletion entry point below.
|
||||
|
||||
## Company deletion
|
||||
|
||||
The existing company deletion service owns the database transaction and deletion
|
||||
order. A module can expose a separate `company-deletion.ts` entry point.
|
||||
Only `services/company-deletion.ts` can import this entry point.
|
||||
The entry point exports an implementation of `CompanyDeletionParticipant` from
|
||||
`lib/company-deletion.ts`. Do not export it through the normal module index.
|
||||
|
||||
The service must complete external cleanup before it starts the transaction.
|
||||
It must lock the company before it deletes dependent records or calls a module.
|
||||
Creation commands must use the same company lock.
|
||||
The service removes dependent records before it calls the module.
|
||||
The module checks its deletion conditions and deletes the records it owns.
|
||||
Use the supplied transaction. Do not start another transaction or call external
|
||||
systems. Throw an error if deletion cannot proceed.
|
||||
An error must roll back all database changes in the deletion transaction.
|
||||
|
||||
Call each module explicitly in dependency order. No module registry is required.
|
||||
|
||||
The agent lifecycle module also has a restricted `configuration.ts` entry point.
|
||||
It saves configuration and invalidates verification in the caller's transaction.
|
||||
The [lifecycle guide](../../../doc/AGENT-LIFECYCLE.md) lists its permitted callers.
|
||||
Ordinary configuration updates use the root lifecycle command.
|
||||
|
||||
The [agent lifecycle module](../../../doc/AGENT-LIFECYCLE.md) owns agent creation,
|
||||
pause, resume, and termination. Use its commands for these changes.
|
||||
@@ -456,6 +456,7 @@ export function createPostgresWatchdogAdapter(db: Db): WatchdogRunReader & Watch
|
||||
eq(agents.id, input.run.agentId),
|
||||
eq(agents.companyId, companyId),
|
||||
notInArray(agents.status, ["paused", "terminated"]),
|
||||
eq(agents.lifecycleState, "ready"),
|
||||
));
|
||||
|
||||
return updatedRun;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { createLifecycleWorker } from "../application/worker.js";
|
||||
import type { LifecycleAgent, LifecycleStore, LifecycleDriver } from "../application/ports.js";
|
||||
import { startAgentLifecycle } from "../index.js";
|
||||
import { createAgentLifecycleEffects } from "../../../services/agent-lifecycle.js";
|
||||
import { logger } from "../../../middleware/logger.js";
|
||||
|
||||
const agent: LifecycleAgent = { id: "agent", companyId: "company", lifecycleState: "verifying", lifecycleVersion: 3,
|
||||
lifecycleRequiredPluginIds: null, lifecycleError: null,
|
||||
lifecycleOperation: { id: "operation", hostComplete: false, completedPluginIds: [], attempts: 1 } };
|
||||
function fixture() {
|
||||
const store = { get: vi.fn(), claim: vi.fn().mockResolvedValue(agent), renew: vi.fn().mockResolvedValue(true),
|
||||
setRequiredPlugins: vi.fn().mockResolvedValue(true), completeHost: vi.fn().mockResolvedValue(true),
|
||||
completePlugin: vi.fn().mockResolvedValue(true), defer: vi.fn().mockResolvedValue(true),
|
||||
change: vi.fn().mockResolvedValue({ ...agent, lifecycleState: "ready" }), pending: vi.fn().mockResolvedValue([agent.id]) } satisfies LifecycleStore;
|
||||
const driver = { requiredPluginIds: vi.fn().mockResolvedValue(["plugin"]), runHost: vi.fn().mockResolvedValue("complete"),
|
||||
runPlugin: vi.fn().mockResolvedValue("complete") } satisfies LifecycleDriver;
|
||||
const report = vi.fn();
|
||||
return { store, driver, report, worker: createLifecycleWorker(store, driver, report) };
|
||||
}
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); });
|
||||
|
||||
describe("lifecycle failure diagnostics", () => {
|
||||
it.each(["host", "plugin", "claim", "renew", "scan", "defer"] as const)("reports %s failures with the available context", async stage => {
|
||||
const { store, driver, report, worker } = fixture();
|
||||
const error = new Error("private provider detail");
|
||||
if (stage === "host") driver.runHost.mockRejectedValue(error);
|
||||
else if (stage === "plugin") driver.runPlugin.mockRejectedValue(error);
|
||||
else if (stage === "scan") store.pending.mockRejectedValue(error);
|
||||
else if (stage === "defer") { driver.runHost.mockResolvedValue("pending"); store.defer.mockRejectedValue(error); }
|
||||
else store[stage].mockRejectedValue(error);
|
||||
const work = stage === "scan" ? worker.sweep() : worker.process(agent.id);
|
||||
if (["claim", "scan", "defer"].includes(stage)) await expect(work).rejects.toThrow(error);
|
||||
else await work;
|
||||
expect(report).toHaveBeenCalledWith(expect.objectContaining({ stage }), error);
|
||||
if (!["claim", "scan"].includes(stage)) expect(report).toHaveBeenCalledWith(expect.objectContaining({
|
||||
agentId: agent.id, companyId: agent.companyId, phase: "verifying", operationId: "operation", version: 3,
|
||||
...(stage === "plugin" ? { pluginId: "plugin" } : {}),
|
||||
}), error);
|
||||
await worker.stop();
|
||||
});
|
||||
|
||||
it("reports periodic policy scan failures", async () => {
|
||||
vi.useFakeTimers();
|
||||
const error = new Error("private database detail");
|
||||
const db = { select: () => { throw error; } } as unknown as Db;
|
||||
const effects = { ...createAgentLifecycleEffects(), reportFailure: vi.fn() };
|
||||
const work = startAgentLifecycle(db, effects, fixture().driver, () => true);
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(effects.reportFailure).toHaveBeenCalledWith({ stage: "policy_scan" }, error);
|
||||
} finally { await work.stop(); }
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ code: "required_plugin_unavailable" }, "required_plugin_unavailable"],
|
||||
[{ code: "harness_test_failed" }, "harness_test_failed"],
|
||||
[{ cause: { code: "40P01", message: "private query" } }, "40P01"],
|
||||
[{ code: "private credential value" }, "unclassified"],
|
||||
])("logs known codes without provider error contents", (details, failureCode) => {
|
||||
const log = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
||||
const context = { stage: "host" as const, agentId: "agent", phase: "verifying" as const, operationId: "operation", version: 3 };
|
||||
createAgentLifecycleEffects().reportFailure(context, Object.assign(new Error("private provider detail"), details));
|
||||
expect(log).toHaveBeenCalledExactlyOnceWith({ ...context, failureCode }, "Agent lifecycle operation failed");
|
||||
expect(JSON.stringify(log.mock.calls)).not.toContain("private");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,673 @@
|
||||
import { createAgentLifecycle as createLifecycleCommands } from "../index.js";
|
||||
import { createAgentLifecycleEffects } from "../../../services/agent-lifecycle.js";
|
||||
import { deleteCompany } from "../../../services/company-deletion.js";
|
||||
import { agentLifecycleCompanyDeletion } from "../company-deletion.js";
|
||||
import { requireServerAdapter } from "../../../adapters/index.js";
|
||||
import { agentExecutionsHaveStopped } from "../../../services/agent-execution-stop.js";
|
||||
import { remoteTerminationReceipt } from "../../../services/remote-execution-termination.js";
|
||||
import { approvalService } from "../../../services/approvals.js";
|
||||
import { updateAgentConfigurationInTransaction } from "../../../services/agent-configuration-transaction.js";
|
||||
import { agentService as agentConfiguration } from "../../../services/agents.js";
|
||||
import { readFileSync } from "node:fs";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { agentRoutes } from "../../../routes/agents.js";
|
||||
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 { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../../__tests__/helpers/embedded-postgres.js";
|
||||
import { createAgentLifecycle, configureAgentLifecycle } from "../../../services/agent-lifecycle.js";
|
||||
import { createLifecycleStore as createStore } from "../adapters/postgres.js";
|
||||
import { transition } from "../domain/policy.js";
|
||||
import type { LifecycleAgent } from "../application/ports.js";
|
||||
import { budgetService } from "../../../services/budgets.js";
|
||||
|
||||
const createLifecycleStore = (db: Db) => createStore(db, createAgentLifecycleEffects());
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("agent lifecycle commands", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: Db;
|
||||
let companyId: string;
|
||||
const workers: ReturnType<typeof configureAgentLifecycle>[] = [];
|
||||
beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("paperclip-agent-lifecycle-"); db = createDb(database.connectionString); }, 90_000);
|
||||
afterAll(async () => { for (const worker of workers) await worker.stop(); await database?.cleanup(); });
|
||||
beforeEach(async () => {
|
||||
for (const work of workers) await work.stop();
|
||||
companyId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Lifecycle test", issuePrefix: `L${companyId.slice(0, 7)}` });
|
||||
});
|
||||
function worker(runHost: (agent: LifecycleAgent) => Promise<"complete" | "pending"> = async () => "complete") {
|
||||
const value = configureAgentLifecycle(db, { requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost });
|
||||
workers.push(value); return value;
|
||||
}
|
||||
async function hire(status = "idle") { return createAgentLifecycle(db).requestHire(companyId, { name: "Agent", status, adapterType: "process" }); }
|
||||
async function current(id: string) { return (await db.select().from(agents).where(eq(agents.id, id)))[0]!; }
|
||||
|
||||
it("maps existing states without starting setup or clearing pause reasons", async () => {
|
||||
const rows = await db.insert(agents).values(["idle", "running", "error", "paused", "pending_approval", "terminated"].map(status => ({
|
||||
companyId, name: status, status, pauseReason: status === "paused" ? "budget" : null,
|
||||
}))).returning();
|
||||
const migration = readFileSync(new URL("../../../../../packages/db/src/migrations/0320_daily_onslaught.sql", import.meta.url), "utf8");
|
||||
await db.execute(sql.raw(migration.slice(migration.indexOf('UPDATE "agents"'))));
|
||||
for (const row of rows) {
|
||||
const mapped = await current(row.id);
|
||||
expect(mapped.lifecycleState).toBe(["paused", "pending_approval", "terminated"].includes(row.status) ? row.status : "ready");
|
||||
expect(mapped.lifecycleOperation).toBeNull();
|
||||
expect(mapped.lifecycleRequiredPluginIds).toBeNull();
|
||||
expect(mapped.lifecycleHolds).toEqual(row.status === "paused" ? ["budget"] : []);
|
||||
}
|
||||
});
|
||||
|
||||
it("commits the record before preparation and verifies before admission", async () => {
|
||||
const observed: string[] = [];
|
||||
const work = worker(async agent => {
|
||||
expect((await current(agent.id)).lifecycleState).toBe(agent.lifecycleState);
|
||||
observed.push(agent.lifecycleState); return "complete";
|
||||
});
|
||||
const agent = await hire();
|
||||
expect(agent).toMatchObject({ lifecycleState: "preparing", status: "paused" });
|
||||
await work.process(agent.id);
|
||||
expect(observed).toEqual(["preparing", "verifying"]);
|
||||
expect(await current(agent.id)).toMatchObject({ lifecycleState: "ready", status: "idle" });
|
||||
expect(agent).not.toHaveProperty("lifecycleOperation");
|
||||
});
|
||||
|
||||
it("rolls back a hire and its grants when hire initialization fails", async () => {
|
||||
const effects = createAgentLifecycleEffects();
|
||||
const lifecycle = createLifecycleCommands(db, { ...effects,
|
||||
initializeHire: async (tx, agent, options) => {
|
||||
await effects.initializeHire(tx, agent, options);
|
||||
throw new Error("Hire initialization failed");
|
||||
},
|
||||
});
|
||||
await expect(lifecycle.requestHire(companyId, { name: "Rollback hire", adapterType: "process" }))
|
||||
.rejects.toThrow("Hire initialization failed");
|
||||
expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(0);
|
||||
expect(await db.select().from(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("runs the saved harness test and keeps a failed configuration out of ready", async () => {
|
||||
const driver = createLifecycleDriver(db, {} as never);
|
||||
const work = worker(driver.runHost);
|
||||
const agent = await hire();
|
||||
await work.process(agent.id);
|
||||
expect(await current(agent.id)).toMatchObject({ lifecycleState: "verifying", status: "paused" });
|
||||
await agentConfiguration(db).update(agent.id, { adapterConfig: { command: process.execPath } });
|
||||
await work.process(agent.id);
|
||||
expect(await current(agent.id)).toMatchObject({ lifecycleState: "ready", status: "idle", lifecycleError: null });
|
||||
});
|
||||
|
||||
it.each(["claude_local", "gemini_local"])("keeps %s authentication warnings out of ready", async adapterType => {
|
||||
const adapter = requireServerAdapter(adapterType);
|
||||
const probe = vi.spyOn(adapter, "testEnvironment").mockResolvedValue({ adapterType, status: "warn",
|
||||
testedAt: new Date().toISOString(), checks: [{ code: `${adapterType.split("_")[0]}_hello_probe_auth_required`, level: "warn", message: "Login required" }] });
|
||||
try {
|
||||
const agent = await createAgentLifecycle(db).requestHire(companyId, { name: "Login test", adapterType });
|
||||
const work = worker(createLifecycleDriver(db, {} as never).runHost);
|
||||
await work.process(agent.id);
|
||||
expect(probe).toHaveBeenCalled();
|
||||
expect(await current(agent.id)).toMatchObject({ lifecycleState: "verifying", lifecycleError: "The lifecycle step failed. Retry the operation." });
|
||||
} finally { probe.mockRestore(); }
|
||||
});
|
||||
|
||||
it("recovers an expired native owner only after the cancelled run and controller stop", async () => {
|
||||
const agent = await hire();
|
||||
const [issue] = await db.insert(issues).values({ companyId, title: "Cancelled native run" }).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId: agent.id, nativeIssueId: issue.id,
|
||||
status: "cancelled", invocationSource: "on_demand", runtimeMode: "native",
|
||||
startedAt: new Date(), finishedAt: new Date(), processPid: process.pid }).returning();
|
||||
await db.insert(nativeRunFinalizations).values({ companyId, issueId: issue.id, runId: run.id,
|
||||
phase: "executing", leaseOwner: "stopped-server", leaseExpiresAt: new Date(0), controllerPid: process.pid });
|
||||
expect(await agentExecutionsHaveStopped(db, [agent.id])).toBe(false);
|
||||
await db.update(nativeRunFinalizations).set({ controllerPid: null }).where(eq(nativeRunFinalizations.runId, run.id));
|
||||
expect(await agentExecutionsHaveStopped(db, [agent.id])).toBe(false);
|
||||
await db.update(heartbeatRuns).set({ processPid: 2_000_000_000 }).where(eq(heartbeatRuns.id, run.id));
|
||||
await db.update(nativeRunFinalizations).set({ leaseExpiresAt: new Date(Date.now() + 60_000) }).where(eq(nativeRunFinalizations.runId, run.id));
|
||||
expect(await agentExecutionsHaveStopped(db, [agent.id])).toBe(false);
|
||||
await db.update(nativeRunFinalizations).set({ leaseExpiresAt: new Date(0) }).where(eq(nativeRunFinalizations.runId, run.id));
|
||||
expect(await agentExecutionsHaveStopped(db, [agent.id])).toBe(true);
|
||||
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, run.id)))[0]).toMatchObject({ leaseOwner: null, leaseExpiresAt: null });
|
||||
});
|
||||
|
||||
it("does not advance termination while a cancelled process still runs", async () => {
|
||||
const agent = await hire();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId: agent.id,
|
||||
status: "cancelled", invocationSource: "on_demand", runtimeMode: "legacy",
|
||||
startedAt: new Date(), finishedAt: new Date(), processPid: process.pid }).returning();
|
||||
const driver = createLifecycleDriver(db, {} as never);
|
||||
const work = worker(driver.runHost);
|
||||
await createAgentLifecycle(db).terminateAgent(agent.id);
|
||||
await work.process(agent.id);
|
||||
expect((await current(agent.id)).lifecycleState).toBe("terminating");
|
||||
await db.update(heartbeatRuns).set({ processPid: null,
|
||||
resultJson: { executionCancellation: { state: "acknowledged" } } }).where(eq(heartbeatRuns.id, run.id));
|
||||
await createAgentLifecycle(db).retry(agent.id);
|
||||
await work.process(agent.id);
|
||||
expect((await current(agent.id)).lifecycleState).toBe("terminated");
|
||||
});
|
||||
|
||||
it("does not cancel a reporting agent when its manager pauses", async () => {
|
||||
const parent = await hire();
|
||||
const [child] = await db.insert(agents).values({ companyId, name: "Report", reportsTo: parent.id,
|
||||
status: "idle", lifecycleState: "ready" }).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId: child.id,
|
||||
status: "queued", invocationSource: "on_demand" }).returning();
|
||||
const work = worker(createLifecycleDriver(db, {} as never).runHost);
|
||||
await createAgentLifecycle(db).pauseAgent(parent.id);
|
||||
await work.process(parent.id);
|
||||
expect((await current(parent.id)).lifecycleState).toBe("paused");
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0]!.status).toBe("queued");
|
||||
});
|
||||
|
||||
it("retains company data until every agent finishes termination", async () => {
|
||||
const agent = await hire();
|
||||
const [project] = await db.insert(projects).values({ companyId, name: "Keep until cleanup", leadAgentId: agent.id }).returning();
|
||||
await db.insert(issues).values({ companyId, projectId: project.id, title: "Keep this task", assigneeAgentId: agent.id });
|
||||
const pending = worker(async () => "pending");
|
||||
await expect(deleteCompany(db, companyId)).rejects.toThrow("Complete termination");
|
||||
expect(await db.select().from(projects).where(eq(projects.id, project.id))).toHaveLength(1);
|
||||
expect(await db.select().from(issues).where(eq(issues.companyId, companyId))).toHaveLength(1);
|
||||
await pending.stop();
|
||||
const complete = worker();
|
||||
await createAgentLifecycle(db).retry(agent.id);
|
||||
await complete.process(agent.id);
|
||||
await deleteCompany(db, companyId);
|
||||
expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(0);
|
||||
expect(await db.select().from(companies).where(eq(companies.id, companyId))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("requires a transaction for the company deletion integration", async () => {
|
||||
await expect(agentLifecycleCompanyDeletion.deleteCompanyData(db as never, companyId))
|
||||
.rejects.toThrow("Company deletion requires a database transaction");
|
||||
});
|
||||
|
||||
it("rejects individual deletion before cleanup and rolls back failed cleanup", async () => {
|
||||
const agent = await hire();
|
||||
const effects = createAgentLifecycleEffects();
|
||||
const cleanup = vi.fn(effects.deleteDependencies);
|
||||
const lifecycle = createLifecycleCommands(db, { ...effects, deleteDependencies: cleanup });
|
||||
await expect(lifecycle.purgeAgent(agent.id)).rejects.toThrow("Complete termination");
|
||||
expect(cleanup).not.toHaveBeenCalled();
|
||||
|
||||
await db.update(agents).set({ lifecycleState: "terminated", status: "terminated" }).where(eq(agents.id, agent.id));
|
||||
const [key] = await db.insert(agentApiKeys).values({ companyId, agentId: agent.id, name: "Keep on rollback", keyHash: randomUUID() }).returning();
|
||||
cleanup.mockImplementation(async (tx, companyId, id) => {
|
||||
await effects.deleteDependencies(tx, companyId, id);
|
||||
throw new Error("Dependency cleanup failed");
|
||||
});
|
||||
await expect(lifecycle.purgeAgent(agent.id)).rejects.toThrow("Dependency cleanup failed");
|
||||
expect(await current(agent.id)).toMatchObject({ lifecycleState: "terminated" });
|
||||
expect(await db.select().from(agentApiKeys).where(eq(agentApiKeys.id, key.id))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("deletes terminated and rejected agents only in the requested company", async () => {
|
||||
await db.insert(agents).values((["terminated", "rejected"] as const).map(lifecycleState => ({
|
||||
companyId, name: lifecycleState, status: "terminated", lifecycleState,
|
||||
})));
|
||||
const [otherCompany] = await db.insert(companies).values({ name: "Keep this company", issuePrefix: `K${companyId.slice(0, 7)}` }).returning();
|
||||
const [otherAgent] = await db.insert(agents).values({ companyId: otherCompany.id, name: "Keep this agent",
|
||||
status: "terminated", lifecycleState: "terminated" }).returning();
|
||||
|
||||
await deleteCompany(db, companyId);
|
||||
|
||||
expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(0);
|
||||
expect(await current(otherAgent.id)).toMatchObject({ companyId: otherCompany.id });
|
||||
expect(await db.select().from(companies).where(eq(companies.id, otherCompany.id))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rolls back agent and company data deletion when a later operation fails", async () => {
|
||||
const agent = await hire("terminated");
|
||||
const [project] = await db.insert(projects).values({ companyId, name: "Keep on rollback" }).returning();
|
||||
const removeAgents = agentLifecycleCompanyDeletion.deleteCompanyData;
|
||||
const failure = vi.spyOn(agentLifecycleCompanyDeletion, "deleteCompanyData").mockImplementation(async (tx, id) => {
|
||||
await removeAgents(tx, id);
|
||||
expect(await tx.select().from(agents).where(eq(agents.companyId, id))).toHaveLength(0);
|
||||
throw new Error("Later deletion failed");
|
||||
});
|
||||
try {
|
||||
await expect(deleteCompany(db, companyId)).rejects.toThrow("Later deletion failed");
|
||||
expect(await current(agent.id)).toMatchObject({ lifecycleState: "terminated" });
|
||||
expect(await db.select().from(projects).where(eq(projects.id, project.id))).toHaveLength(1);
|
||||
expect(await db.select().from(companies).where(eq(companies.id, companyId))).toHaveLength(1);
|
||||
} finally { failure.mockRestore(); }
|
||||
});
|
||||
|
||||
it("blocks a concurrent hire until company deletion commits", async () => {
|
||||
let reportLock!: (pid: number) => void;
|
||||
let rejectLock!: (error: unknown) => void;
|
||||
const locked = new Promise<number>((resolve, reject) => { reportLock = resolve; rejectLock = reject; });
|
||||
let release!: () => void;
|
||||
const released = new Promise<void>(resolve => { release = resolve; });
|
||||
const removeAgents = agentLifecycleCompanyDeletion.deleteCompanyData;
|
||||
const pause = vi.spyOn(agentLifecycleCompanyDeletion, "deleteCompanyData").mockImplementation(async (tx, id) => {
|
||||
const [backend] = await tx.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`);
|
||||
reportLock(backend!.pid);
|
||||
await released;
|
||||
await removeAgents(tx, id);
|
||||
});
|
||||
const deletion = deleteCompany(db, companyId);
|
||||
void deletion.catch(rejectLock);
|
||||
let hiring: Promise<unknown> | undefined;
|
||||
try {
|
||||
const pid = await locked;
|
||||
hiring = hire().then(() => "created", error => error.message);
|
||||
await vi.waitFor(async () => {
|
||||
const blocked = await db.execute(sql`select 1 from pg_stat_activity
|
||||
where datname = current_database() and ${pid} = any(pg_blocking_pids(pid))`);
|
||||
expect(blocked).toHaveLength(1);
|
||||
});
|
||||
release();
|
||||
await deletion;
|
||||
expect(await hiring).toBe("Company not found");
|
||||
expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(0);
|
||||
} finally {
|
||||
release();
|
||||
await Promise.allSettled([deletion, hiring]);
|
||||
pause.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("requires cleanup proof after a remote run is already cancelled", async () => {
|
||||
const agent = await hire();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId: agent.id,
|
||||
status: "cancelled", invocationSource: "on_demand", runtimeMode: "native",
|
||||
startedAt: new Date(), finishedAt: new Date() }).returning();
|
||||
const [lease] = await db.insert(environmentLeases).values({ companyId, heartbeatRunId: run.id,
|
||||
provider: "test", providerLeaseId: "test-lease", status: "pending_cleanup",
|
||||
cleanupStatus: "failed", releasedAt: new Date() }).returning();
|
||||
expect(await agentExecutionsHaveStopped(db, [agent.id])).toBe(false);
|
||||
await db.update(environmentLeases).set({ status: "released", cleanupStatus: "success" })
|
||||
.where(eq(environmentLeases.id, lease.id));
|
||||
expect(await agentExecutionsHaveStopped(db, [agent.id])).toBe(false);
|
||||
await db.update(environmentLeases).set({ metadata: {
|
||||
remoteExecutionTermination: remoteTerminationReceipt(lease, { providerLeaseId: lease.providerLeaseId, state: "destroyed" }),
|
||||
} }).where(eq(environmentLeases.id, lease.id));
|
||||
expect(await agentExecutionsHaveStopped(db, [agent.id])).toBe(true);
|
||||
});
|
||||
|
||||
it("does not prepare a proposed hire until approval", async () => {
|
||||
const observed: string[] = [];
|
||||
const work = worker(async agent => { observed.push(agent.lifecycleState); return "complete"; });
|
||||
const agent = await hire("pending_approval");
|
||||
await work.process(agent.id);
|
||||
expect(observed).toEqual([]);
|
||||
const approval = await approvalService(db).create(companyId, { type: "hire_agent", payload: { agentId: agent.id }, status: "pending" });
|
||||
await approvalService(db).approve(approval.id, "board");
|
||||
await vi.waitFor(async () => expect((await current(agent.id)).lifecycleState).toBe("ready"));
|
||||
expect(observed).toEqual(["preparing", "verifying"]);
|
||||
});
|
||||
|
||||
it.each(["approved", "rejected"] as const)("uses the same %s hire decision for an agent and its approval", async decision => {
|
||||
const agent = await hire("pending_approval");
|
||||
const approval = await approvalService(db).create(companyId, { type: "hire_agent",
|
||||
requestedByUserId: "requester", payload: { agentId: agent.id }, status: "pending" });
|
||||
const lifecycle = createAgentLifecycle(db);
|
||||
const decide = decision === "approved" ? lifecycle.approveHire : lifecycle.rejectHire;
|
||||
const first = await decide(agent.id, "board");
|
||||
expect(first).toMatchObject({ applied: true, approval: { id: approval.id, status: decision },
|
||||
agent: { lifecycleState: decision === "approved" ? "preparing" : "rejected" } });
|
||||
const version = (await current(agent.id)).lifecycleVersion;
|
||||
expect(await decide({ approvalId: approval.id }, "board")).toMatchObject({ applied: false, hireApprovedAgentId: null });
|
||||
expect((await current(agent.id)).lifecycleVersion).toBe(version);
|
||||
});
|
||||
|
||||
it("rolls back the approval and agent together when activation fails", async () => {
|
||||
const agent = await hire("pending_approval");
|
||||
const approval = await approvalService(db).create(companyId, { type: "hire_agent",
|
||||
payload: { agentId: agent.id }, status: "pending" });
|
||||
const lifecycle = createLifecycleCommands(db, { ...createAgentLifecycleEffects(),
|
||||
recordCreation: async () => { throw new Error("Creation event failed"); } });
|
||||
await expect(lifecycle.approveHire(agent.id, "board")).rejects.toThrow("Creation event failed");
|
||||
expect((await current(agent.id)).lifecycleState).toBe("pending_approval");
|
||||
expect((await db.select().from(approvals).where(eq(approvals.id, approval.id)))[0].status).toBe("pending");
|
||||
});
|
||||
|
||||
it("keeps the hire credential owner and recovers it for migrated approvals", async () => {
|
||||
for (const savedOwner of [null, "original-owner"]) {
|
||||
const [agent] = await db.insert(agents).values({ companyId, name: "Migrated hire", status: "pending_approval",
|
||||
lifecycleState: "pending_approval", lifecycleOperation: savedOwner ? {
|
||||
id: randomUUID(), hostComplete: false, completedPluginIds: [], attempts: 0, responsibleUserId: savedOwner,
|
||||
} : null }).returning();
|
||||
const approval = await approvalService(db).create(companyId, { type: "hire_agent",
|
||||
requestedByUserId: "requesting-member", payload: { agentId: agent.id }, status: "pending" });
|
||||
await approvalService(db).approve(approval.id, "approving-member");
|
||||
expect((await current(agent.id)).lifecycleOperation?.responsibleUserId).toBe(savedOwner ?? "requesting-member");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects transaction injection and generic state writes", async () => {
|
||||
const agent = await hire("pending_approval");
|
||||
await expect(db.transaction(async tx => createAgentLifecycle(tx as unknown as Db))).rejects.toThrow("root database");
|
||||
await expect(agentConfiguration(db).update(agent.id, { status: "idle" } as never)).rejects.toThrow("lifecycle command");
|
||||
await expect(createAgentLifecycle(db).requestHire(companyId, { name: "Bypass", lifecycleRequiredPluginIds: [] } as never)).rejects.toThrow("Lifecycle fields");
|
||||
});
|
||||
|
||||
it("clears a rejected hire from the user's primary selection", async () => {
|
||||
const agent = await createAgentLifecycle(db).requestHire(companyId, { name: "Proposed", status: "pending_approval" }, { createdByUserId: "board" });
|
||||
const preference = () => db.select().from(userCompanyPreferences).where(eq(userCompanyPreferences.companyId, companyId));
|
||||
expect((await preference())[0]!.primaryAgentId).toBe(agent.id);
|
||||
const approval = await approvalService(db).create(companyId, { type: "hire_agent", payload: { agentId: agent.id }, status: "pending" });
|
||||
await approvalService(db).reject(approval.id, "board");
|
||||
expect((await preference())[0]!.primaryAgentId).toBeNull();
|
||||
expect((await current(agent.id)).lifecycleState).toBe("rejected");
|
||||
});
|
||||
|
||||
it("rolls back configuration and its revision when the requested transition fails", async () => {
|
||||
const lifecycle = createAgentLifecycle(db);
|
||||
const agent = await hire("terminated");
|
||||
await expect(lifecycle.updateAndTransition(agent.id, "resume", { name: "Must not persist" },
|
||||
{ recordRevision: { createdByUserId: "board", source: "patch" } })).rejects.toThrow("Cannot resume");
|
||||
expect((await current(agent.id)).name).toBe("Agent");
|
||||
expect(await db.select().from(agentConfigRevisions).where(eq(agentConfigRevisions.agentId, agent.id))).toEqual([]);
|
||||
const active = await hire();
|
||||
expect(await lifecycle.updateAndTransition(active.id, "pause", { name: "Paused agent" })).toMatchObject({ name: "Paused agent", lifecycleState: "pausing" });
|
||||
});
|
||||
|
||||
it("preserves a disabled positive budget at approval and applies explicit configuration changes", async () => {
|
||||
const agent = await hire("pending_approval");
|
||||
const budgets = budgetService(db);
|
||||
await budgets.upsertPolicy(companyId, { scopeType: "agent", scopeId: agent.id, amount: 1000, isActive: false }, null);
|
||||
const approval = await approvalService(db).create(companyId, { type: "hire_agent",
|
||||
payload: { agentId: agent.id, budgetMonthlyCents: 1000 }, status: "pending" });
|
||||
const policy = async () => (await db.select().from(budgetPolicies).where(eq(budgetPolicies.scopeId, agent.id)))[0]!;
|
||||
await approvalService(db).approve(approval.id, "board");
|
||||
expect(await policy()).toMatchObject({ amount: 1000, isActive: false });
|
||||
await agentConfiguration(db).update(agent.id, { budgetMonthlyCents: 2000 });
|
||||
expect(await policy()).toMatchObject({ amount: 2000, isActive: true });
|
||||
await agentConfiguration(db).update(agent.id, { budgetMonthlyCents: 0 });
|
||||
expect(await policy()).toMatchObject({ amount: 0, isActive: false });
|
||||
});
|
||||
|
||||
it.each(["updateConfiguration", "updateAndTransition"] as const)("limits %s budget reconciliation to the edited agent", async command => {
|
||||
const lifecycle = createAgentLifecycle(db);
|
||||
const one = await hire();
|
||||
const two = await lifecycle.requestHire(companyId, { name: "Other budget target" });
|
||||
await db.update(companies).set({ status: "paused" }).where(eq(companies.id, companyId));
|
||||
if (command === "updateConfiguration") await lifecycle.updateConfiguration(one.id, { budgetMonthlyCents: 1000 });
|
||||
else await lifecycle.updateAndTransition(one.id, "pause", { budgetMonthlyCents: 1000 });
|
||||
expect((await current(one.id)).lifecycleHolds).toContain("company_paused");
|
||||
expect((await current(two.id)).lifecycleHolds).toEqual([]);
|
||||
});
|
||||
|
||||
it("limits invocation policy checks to the requested agent", async () => {
|
||||
const one = await hire();
|
||||
const two = await createAgentLifecycle(db).requestHire(companyId, { name: "Another agent" });
|
||||
await db.update(companies).set({ status: "paused" }).where(eq(companies.id, companyId));
|
||||
await budgetService(db).getInvocationBlock(companyId, one.id);
|
||||
expect((await current(one.id)).lifecycleHolds).toContain("company_paused");
|
||||
expect((await current(two.id)).lifecycleHolds).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a manual hold when company policy changes", async () => {
|
||||
const work = worker(); const lifecycle = createAgentLifecycle(db); const agent = await hire(); await work.process(agent.id);
|
||||
await lifecycle.pauseAgent(agent.id); await work.process(agent.id);
|
||||
await db.update(companies).set({ status: "archived" }).where(eq(companies.id, companyId));
|
||||
await createAgentLifecycle(db).reconcilePolicyHolds(companyId); await work.process(agent.id);
|
||||
await db.update(companies).set({ status: "active" }).where(eq(companies.id, companyId));
|
||||
await createAgentLifecycle(db).reconcilePolicyHolds(companyId); await work.process(agent.id);
|
||||
expect(await current(agent.id)).toMatchObject({ lifecycleState: "paused", lifecycleHolds: ["manual"] });
|
||||
await lifecycle.resumeAgent(agent.id); await work.process(agent.id);
|
||||
expect((await current(agent.id)).lifecycleState).toBe("ready");
|
||||
});
|
||||
|
||||
it("defers automatic policy changes while a task holds the agent lock", async () => {
|
||||
const agent = await hire("paused");
|
||||
await db.transaction(async tx => {
|
||||
await tx.select().from(agents).where(eq(agents.id, agent.id)).for("update");
|
||||
expect(await createLifecycleStore(db).change(agent.id, "reconcile")).toBeNull();
|
||||
});
|
||||
expect(await createLifecycleStore(db).change(agent.id, "reconcile")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("retains the failed phase and the same operation for retry", async () => {
|
||||
let fail = true;
|
||||
const work = worker(async agent => { if (agent.lifecycleState === "verifying" && fail) throw new Error("private detail"); return "complete"; });
|
||||
const agent = await hire(); await work.process(agent.id);
|
||||
const failed = await current(agent.id);
|
||||
expect(failed.lifecycleState).toBe("verifying");
|
||||
expect(failed.lifecycleError).not.toContain("private detail");
|
||||
fail = false;
|
||||
await createAgentLifecycle(db).retry(agent.id); await work.process(agent.id);
|
||||
expect((await current(agent.id)).lifecycleState).toBe("ready");
|
||||
});
|
||||
|
||||
it("fences a late completion when termination replaces preparation", async () => {
|
||||
const agent = await hire("paused");
|
||||
const store = createLifecycleStore(db);
|
||||
await createAgentLifecycle(db).resumeAgent(agent.id);
|
||||
const claimed = await store.claim(agent.id, "old", new Date());
|
||||
expect(claimed).not.toBeNull();
|
||||
await store.setRequiredPlugins(claimed!, "old", []);
|
||||
await createAgentLifecycle(db).terminateAgent(agent.id);
|
||||
expect(await store.completeHost(claimed!, "old")).toBe(false);
|
||||
expect(await store.change(agent.id, "complete", { owner: "old", version: claimed!.lifecycleVersion })).toBeNull();
|
||||
expect((await current(agent.id)).lifecycleState).toBe("terminating");
|
||||
});
|
||||
|
||||
it("revokes keys at termination and waits for cleanup before deletion", async () => {
|
||||
let pending = true;
|
||||
const phases: string[] = [];
|
||||
const work = worker(async agent => { phases.push(agent.lifecycleState); return agent.lifecycleState === "cleaning_up" && pending ? "pending" : "complete"; });
|
||||
const lifecycle = createAgentLifecycle(db); const agent = await hire(); await work.process(agent.id);
|
||||
await db.insert(agentApiKeys).values({ agentId: agent.id, companyId, name: "Test", keyHash: randomUUID() });
|
||||
await lifecycle.terminateAgent(agent.id); await work.process(agent.id);
|
||||
expect((await current(agent.id)).lifecycleState).toBe("cleaning_up");
|
||||
expect((await db.select().from(agentApiKeys).where(eq(agentApiKeys.agentId, agent.id)))[0].revokedAt).not.toBeNull();
|
||||
await expect(agentConfiguration(db).remove(agent.id)).rejects.toThrow("Complete termination");
|
||||
pending = false; await lifecycle.retry(agent.id); await work.process(agent.id);
|
||||
expect(phases).toContain("terminating");
|
||||
expect((await current(agent.id)).lifecycleState).toBe("terminated");
|
||||
await agentConfiguration(db).remove(agent.id);
|
||||
expect(await current(agent.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("retains required plugin IDs and checks the operation and company on each reply", async () => {
|
||||
const agent = await hire("paused");
|
||||
const pluginId = randomUUID();
|
||||
await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "lifecycle-test", version: "1.0.0", status: "ready",
|
||||
manifestJson: { agentLifecycle: true, capabilities: ["agents.lifecycle.manage"] } as never });
|
||||
const call = vi.fn(async (_id, _method, input) => ({ operationId: input.operationId, version: input.version, status: "complete" }));
|
||||
const driver = createLifecycleDriver(db, { call } as never);
|
||||
const snapshot = (await createLifecycleStore(db).get(agent.id))!;
|
||||
expect(await driver.requiredPluginIds(snapshot)).toContain(pluginId);
|
||||
await db.insert(pluginCompanySettings).values({ companyId, pluginId, enabled: false });
|
||||
expect(await driver.requiredPluginIds(snapshot)).not.toContain(pluginId);
|
||||
expect(await driver.requiredPluginIds({ ...snapshot, lifecycleRequiredPluginIds: [pluginId] })).toContain(pluginId);
|
||||
await expect(driver.runPlugin(snapshot, pluginId)).rejects.toThrow("unavailable");
|
||||
await db.update(pluginCompanySettings).set({ enabled: true }).where(eq(pluginCompanySettings.pluginId, pluginId));
|
||||
expect(await driver.runPlugin(snapshot, pluginId)).toBe("complete");
|
||||
expect(call).toHaveBeenCalledWith(pluginId, "agentLifecycle", { companyId, agentId: agent.id,
|
||||
operationId: snapshot.lifecycleOperation!.id, version: snapshot.lifecycleVersion, phase: "paused" }, 30_000);
|
||||
call.mockResolvedValueOnce({ operationId: "stale", version: snapshot.lifecycleVersion, status: "complete" });
|
||||
await expect(driver.runPlugin(snapshot, pluginId)).rejects.toThrow("Invalid lifecycle result");
|
||||
});
|
||||
|
||||
it("keeps host completion and required plugin results across a worker restart", async () => {
|
||||
const pluginIds = [randomUUID(), randomUUID()];
|
||||
const runHost = vi.fn(async () => "complete" as const);
|
||||
const firstPlugin = vi.fn(async (_agent: LifecycleAgent, pluginId: string) =>
|
||||
pluginId === pluginIds[0] ? "complete" as const : "pending" as const);
|
||||
const first = configureAgentLifecycle(db, { requiredPluginIds: async () => pluginIds, runHost, runPlugin: firstPlugin });
|
||||
workers.push(first);
|
||||
const agent = await hire();
|
||||
await first.process(agent.id);
|
||||
expect(await current(agent.id)).toMatchObject({ lifecycleState: "preparing", lifecycleRequiredPluginIds: pluginIds,
|
||||
lifecycleOperation: { hostComplete: true, completedPluginIds: [pluginIds[0]] } });
|
||||
await first.stop();
|
||||
|
||||
const discover = vi.fn(async () => []);
|
||||
const resumedPlugin = vi.fn(async (_agent: LifecycleAgent, _pluginId: string) => "complete" as const);
|
||||
const resumed = configureAgentLifecycle(db, { requiredPluginIds: discover, runHost, runPlugin: resumedPlugin });
|
||||
workers.push(resumed);
|
||||
await createAgentLifecycle(db).retry(agent.id);
|
||||
await resumed.process(agent.id);
|
||||
expect((await current(agent.id)).lifecycleState).toBe("ready");
|
||||
expect(discover).not.toHaveBeenCalled();
|
||||
expect(runHost).toHaveBeenCalledTimes(2);
|
||||
expect(resumedPlugin.mock.calls.map(call => call[1])).toEqual([pluginIds[1], ...pluginIds]);
|
||||
});
|
||||
|
||||
it("requires host and plugin completion and freezes the selected plugin IDs", async () => {
|
||||
const agent = await hire();
|
||||
const store = createLifecycleStore(db);
|
||||
const claimed = (await store.claim(agent.id, "test", new Date()))!;
|
||||
const pluginId = randomUUID();
|
||||
expect(await store.setRequiredPlugins(claimed, "test", [pluginId])).toBe(true);
|
||||
expect(await store.setRequiredPlugins(claimed, "test", [])).toBe(false);
|
||||
expect(await store.completePlugin(claimed, "test", randomUUID())).toBe(false);
|
||||
const complete = () => store.change(agent.id, "complete", { owner: "test", version: claimed.lifecycleVersion });
|
||||
expect(await complete()).toBeNull();
|
||||
expect(await store.completePlugin(claimed, "test", pluginId)).toBe(true);
|
||||
expect(await complete()).toBeNull();
|
||||
expect(await store.completeHost(claimed, "test")).toBe(true);
|
||||
expect(await complete()).toMatchObject({ lifecycleState: "verifying" });
|
||||
expect(await store.completePlugin(claimed, "test", pluginId)).toBe(false);
|
||||
});
|
||||
|
||||
it("fences verification when the saved configuration changes", async () => {
|
||||
const lifecycle = createAgentLifecycle(db); const store = createLifecycleStore(db);
|
||||
const agent = await hire();
|
||||
const claimed = (await store.claim(agent.id, "configuration-test", new Date()))!;
|
||||
await store.setRequiredPlugins(claimed, "configuration-test", []);
|
||||
await agentConfiguration(db).update(agent.id, { adapterConfig: { command: "echo" } });
|
||||
expect(await store.completeHost(claimed, "configuration-test")).toBe(false);
|
||||
expect((await lifecycle.get(agent.id))!.lifecycleVersion).toBeGreaterThan(claimed.lifecycleVersion);
|
||||
});
|
||||
|
||||
it("invalidates only changed execution configuration during setup", async () => {
|
||||
const lifecycle = createAgentLifecycle(db);
|
||||
const agent = await hire();
|
||||
const before = await current(agent.id);
|
||||
await lifecycle.updateConfiguration(agent.id, { name: "Profile only" });
|
||||
await lifecycle.updateConfiguration(agent.id, { adapterConfig: before.adapterConfig });
|
||||
expect((await current(agent.id)).lifecycleVersion).toBe(before.lifecycleVersion);
|
||||
await lifecycle.updateConfiguration(agent.id, { runtimeConfig: { heartbeat: { enabled: false } } });
|
||||
expect((await current(agent.id)).lifecycleVersion).toBe(before.lifecycleVersion + 1);
|
||||
const work = worker();
|
||||
await work.process(agent.id);
|
||||
const ready = await current(agent.id);
|
||||
expect(ready.lifecycleState).toBe("ready");
|
||||
await lifecycle.updateConfiguration(agent.id, { adapterConfig: { command: "echo" } });
|
||||
expect(await current(agent.id)).toMatchObject({ lifecycleState: "ready", lifecycleVersion: ready.lifecycleVersion });
|
||||
});
|
||||
|
||||
it("rejects identity, accounting, and unknown fields through every configuration entry point", async () => {
|
||||
const agent = await hire();
|
||||
const lifecycle = createAgentLifecycle(db);
|
||||
const before = await current(agent.id);
|
||||
for (const patch of [{ id: randomUUID() }, { companyId: randomUUID() }, { spentMonthlyCents: 0 },
|
||||
{ spendMonthUtc: "2099-01" }, { lastHeartbeatAt: new Date() }, { errorReason: null },
|
||||
{ createdAt: new Date() }, { updatedAt: new Date() }, { lifecycleState: "ready" }, { futureColumn: true }]) {
|
||||
await expect(lifecycle.updateConfiguration(agent.id, patch as never)).rejects.toThrow("Unsupported configuration field");
|
||||
await expect(lifecycle.updateAndTransition(agent.id, "pause", patch as never)).rejects.toThrow("Unsupported configuration field");
|
||||
await expect(db.transaction(tx => updateAgentConfigurationInTransaction(tx as unknown as Db, agent.id, patch as never)))
|
||||
.rejects.toThrow("Unsupported configuration field");
|
||||
}
|
||||
expect(await current(agent.id)).toEqual(before);
|
||||
const effects = createAgentLifecycleEffects();
|
||||
const malformed = createLifecycleCommands(db, { ...effects, prepareConfiguration: async () => ({ id: randomUUID() }) as never });
|
||||
await expect(malformed.updateConfiguration(agent.id, { name: "Changed" })).rejects.toThrow("Unsupported configuration field");
|
||||
expect(await current(agent.id)).toEqual(before);
|
||||
});
|
||||
|
||||
it("records only the changed configuration fields for a legacy appearance", async () => {
|
||||
const [agent] = await db.insert(agents).values({ companyId, name: "Legacy", appearance: null }).returning();
|
||||
await createAgentLifecycle(db).updateConfiguration(agent.id, { name: "Renamed" },
|
||||
{ recordRevision: { source: "test", createdByUserId: "board" } });
|
||||
const revisions = await db.select().from(agentConfigRevisions).where(eq(agentConfigRevisions.agentId, agent.id));
|
||||
expect(revisions).toHaveLength(1);
|
||||
expect(revisions[0].changedKeys).toEqual(["name"]);
|
||||
expect(revisions[0].beforeConfig.appearance).toEqual(revisions[0].afterConfig.appearance);
|
||||
});
|
||||
|
||||
it("accepts the unchanged resolved appearance of a pending hire", async () => {
|
||||
const [agent] = await db.insert(agents).values({ companyId, name: "Pending legacy", status: "pending_approval", appearance: null }).returning();
|
||||
const resolved = await agentConfiguration(db).getById(agent.id);
|
||||
await expect(createAgentLifecycle(db).updateConfiguration(agent.id, { appearance: resolved!.appearance }))
|
||||
.resolves.toMatchObject({ id: agent.id });
|
||||
await expect(createAgentLifecycle(db).updateConfiguration(agent.id, { name: "Changed" }))
|
||||
.rejects.toThrow("Pending approval agent configuration cannot be changed");
|
||||
});
|
||||
|
||||
it("rolls back a root configuration command when dependent records fail", async () => {
|
||||
const agent = await hire();
|
||||
const before = await current(agent.id);
|
||||
const effects = createAgentLifecycleEffects();
|
||||
const lifecycle = createLifecycleCommands(db, { ...effects, completeConfiguration: async (...args) => {
|
||||
await effects.completeConfiguration(...args);
|
||||
throw new Error("Configuration completion failed");
|
||||
} });
|
||||
await expect(lifecycle.updateConfiguration(agent.id, { adapterConfig: { command: "echo" } },
|
||||
{ recordRevision: { source: "test", createdByUserId: "board" } })).rejects.toThrow("Configuration completion failed");
|
||||
expect(await current(agent.id)).toMatchObject({ adapterConfig: before.adapterConfig,
|
||||
lifecycleVersion: before.lifecycleVersion, lifecycleOperation: before.lifecycleOperation });
|
||||
expect(await db.select().from(agentConfigRevisions).where(eq(agentConfigRevisions.agentId, agent.id))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rolls back configuration and verification invalidation in the binding transaction", async () => {
|
||||
const agent = await hire();
|
||||
const before = await current(agent.id);
|
||||
await expect(updateAgentConfigurationInTransaction(db, agent.id, { name: "Uncommitted" }))
|
||||
.rejects.toThrow("requires a database transaction");
|
||||
expect(() => updateAgentConfigurationInTransaction(db, agent.id, { budgetMonthlyCents: 100 } as never))
|
||||
.toThrow("Budget changes require a root lifecycle command");
|
||||
await expect(db.transaction(async tx => agentConfiguration(tx as unknown as Db).update(agent.id, { name: "Uncommitted" })))
|
||||
.rejects.toThrow("root database");
|
||||
await expect(db.transaction(async tx => {
|
||||
await updateAgentConfigurationInTransaction(tx as unknown as Db, agent.id, { adapterConfig: { command: "echo" } });
|
||||
expect((await tx.select().from(agents).where(eq(agents.id, agent.id)))[0].lifecycleVersion).toBe(before.lifecycleVersion + 1);
|
||||
throw new Error("Binding change failed");
|
||||
})).rejects.toThrow("Binding change failed");
|
||||
expect(await current(agent.id)).toMatchObject({ adapterConfig: before.adapterConfig,
|
||||
lifecycleVersion: before.lifecycleVersion, lifecycleOperation: before.lifecycleOperation });
|
||||
});
|
||||
|
||||
it("permits a board retry and rejects an agent or a different company", async () => {
|
||||
const agent = await hire();
|
||||
function app(actor: Express.Request["actor"]) {
|
||||
const app = express(); app.use(express.json());
|
||||
app.use((req, _res, next) => { req.actor = actor; next(); });
|
||||
app.use("/api", agentRoutes(db)); app.use(errorHandler); return app;
|
||||
}
|
||||
const board: Express.Request["actor"] = { type: "board", source: "session", userId: "board", companyIds: [companyId],
|
||||
memberships: [{ companyId, membershipRole: "admin", status: "active" }] };
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "board", membershipRole: "admin" });
|
||||
await db.insert(principalPermissionGrants).values(["agents:create", "agents:configure"].map(permissionKey => ({ companyId, principalType: "user", principalId: "board", permissionKey })));
|
||||
const url = `/api/agents/${agent.id}/lifecycle/retry`;
|
||||
const response = await request(app(board)).post(url).send({});
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
||||
expect(response.body.lifecycleState).toBe("preparing");
|
||||
expect(response.body).not.toHaveProperty("lifecycleOperation");
|
||||
expect(await db.select().from(activityLog).where(and(eq(activityLog.entityId, agent.id), eq(activityLog.action, "agent.lifecycle_retried"))))
|
||||
.toEqual([expect.objectContaining({ actorType: "user", actorId: "board" })]);
|
||||
await request(app({ type: "agent", source: "agent_key", companyId, agentId: agent.id })).post(url).send({}).expect(403);
|
||||
const denied = await request(app({ ...board, companyIds: [], memberships: [] })).post(url).send({});
|
||||
expect([403, 404]).toContain(denied.status);
|
||||
});
|
||||
|
||||
it("recovers an expired lease and never accepts an expired completion", async () => {
|
||||
const agent = await hire("paused"); const store = createLifecycleStore(db);
|
||||
await createAgentLifecycle(db).resumeAgent(agent.id);
|
||||
const claim = await store.claim(agent.id, "first", new Date(Date.now() - 180_000));
|
||||
expect(claim).not.toBeNull();
|
||||
await store.setRequiredPlugins(claim!, "first", []);
|
||||
await store.completeHost(claim!, "first");
|
||||
expect(await store.change(agent.id, "complete", { owner: "first", version: claim!.lifecycleVersion })).toBeNull();
|
||||
expect(await store.claim(agent.id, "second", new Date())).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("lifecycle transition rules", () => {
|
||||
it("does not bypass authorization, verification, or cleanup", () => {
|
||||
expect(() => transition("pending_approval", "resume")).toThrow();
|
||||
expect(transition("preparing", "complete")).toBe("verifying");
|
||||
expect(transition("terminating", "complete")).toBe("cleaning_up");
|
||||
expect(transition("cleaning_up", "complete")).toBe("terminated");
|
||||
expect(() => transition("terminated", "resume")).toThrow();
|
||||
expect(transition("terminating", "terminate")).toBe("terminating");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { agents, companies, createDb, type Db } from "@paperclipai/db";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { startAgentLifecycle } from "../index.js";
|
||||
import { createAgentLifecycleEffects } from "../../../services/agent-lifecycle.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../../__tests__/helpers/embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("lifecycle policy sweep", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: Db;
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-lifecycle-policy-");
|
||||
db = createDb(database.connectionString);
|
||||
}, 90_000);
|
||||
afterAll(async () => { await database?.cleanup(); });
|
||||
|
||||
it("caps each policy page, skips ineligible agents, and returns to the first page", async () => {
|
||||
const [company] = await db.insert(companies).values({ name: "Policy pages", issuePrefix: "POL" }).returning();
|
||||
const eligible = await db.insert(agents).values(Array.from({ length: 101 }, (_, index) => ({
|
||||
id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`,
|
||||
companyId: company.id, name: `Agent ${index}`, status: "idle", lifecycleState: "ready" as const,
|
||||
lifecycleHolds: ["company_paused"], pauseReason: "company_paused",
|
||||
}))).returning();
|
||||
const excluded = await db.insert(agents).values((["pending_approval", "terminating", "cleaning_up", "terminated", "rejected"] as const).map(lifecycleState => ({
|
||||
companyId: company.id, name: lifecycleState, status: "terminated", lifecycleState,
|
||||
lifecycleHolds: ["company_paused"], pauseReason: "company_paused",
|
||||
}))).returning();
|
||||
const effects = createAgentLifecycleEffects();
|
||||
const transaction = vi.spyOn(effects, "transaction");
|
||||
const now = Date.now();
|
||||
const clock = vi.spyOn(Date, "now").mockReturnValue(now);
|
||||
const timer = vi.spyOn(globalThis, "setInterval").mockImplementation(() => ({ unref() {} }) as ReturnType<typeof setInterval>);
|
||||
const clearTimer = vi.spyOn(globalThis, "clearInterval").mockImplementation(() => {});
|
||||
const work = startAgentLifecycle(db, effects, {
|
||||
requiredPluginIds: async () => [], runHost: async () => "pending", runPlugin: async () => "pending",
|
||||
}, () => true);
|
||||
const rows = () => db.select().from(agents).where(eq(agents.companyId, company.id)).orderBy(asc(agents.id));
|
||||
try {
|
||||
await work.sweep();
|
||||
expect(transaction).toHaveBeenCalledTimes(100);
|
||||
expect((await rows()).filter(row => row.lifecycleHolds.length === 0)).toHaveLength(100);
|
||||
await work.sweep();
|
||||
expect(transaction).toHaveBeenCalledTimes(100);
|
||||
clock.mockReturnValue(now + 60_001);
|
||||
await work.sweep();
|
||||
expect(transaction).toHaveBeenCalledTimes(101);
|
||||
expect((await rows()).filter(row => row.lifecycleHolds.length > 0).map(row => row.id).sort())
|
||||
.toEqual(excluded.map(row => row.id).sort());
|
||||
await db.update(agents).set({ lifecycleHolds: ["company_paused"] }).where(eq(agents.id, eligible[0].id));
|
||||
clock.mockReturnValue(now + 120_002);
|
||||
await work.sweep();
|
||||
expect(transaction).toHaveBeenCalledTimes(201);
|
||||
expect((await rows())[0].lifecycleHolds).toEqual([]);
|
||||
} finally {
|
||||
await work.stop();
|
||||
transaction.mockRestore();
|
||||
clock.mockRestore();
|
||||
timer.mockRestore();
|
||||
clearTimer.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { agentAppearanceSchema } from "@paperclipai/shared";
|
||||
import { approvals, type Db } from "@paperclipai/db";
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { unprocessable } from "../../../errors.js";
|
||||
import { approvalResolutionRecords, resolvableApprovalStatuses } from "../../../lib/approval-records.js";
|
||||
import { agentRecords } from "./records.js";
|
||||
import { createLifecycleStore } from "./postgres.js";
|
||||
import type { LifecycleEffects } from "./effects.js";
|
||||
|
||||
export type HireDecisionTarget = string | { approvalId: string };
|
||||
type HireDecisionResult = {
|
||||
approval: typeof approvals.$inferSelect | null;
|
||||
agent: Awaited<ReturnType<ReturnType<typeof agentRecords>["getById"]>>;
|
||||
applied: boolean;
|
||||
activated: boolean;
|
||||
hireApprovedAgentId: string | null;
|
||||
};
|
||||
|
||||
export function hireApprovalService(db: Db, effects: LifecycleEffects) {
|
||||
const { getExistingApproval, resolveApproval } = approvalResolutionRecords(db);
|
||||
async function decide(target: HireDecisionTarget, status: "approved" | "rejected", userId: string, note?: string | null): Promise<HireDecisionResult | null> {
|
||||
const initial = typeof target === "string"
|
||||
? await agentRecords(db, effects).getById(target)
|
||||
: await getExistingApproval(target.approvalId);
|
||||
if (!initial) return null;
|
||||
if (typeof target !== "string" && (!("type" in initial) || initial.type !== "hire_agent")) throw unprocessable("Expected a hire approval");
|
||||
const companyId = initial.companyId;
|
||||
// Hire decisions acquire the company lock before changing an approval or agent.
|
||||
return effects.transaction(db, companyId, async (tx, publications) => {
|
||||
const records = agentRecords(tx, effects);
|
||||
let approvalId = typeof target === "string" ? null : target.approvalId;
|
||||
if (typeof target === "string") {
|
||||
const [open] = await tx.select({ id: approvals.id }).from(approvals).where(and(
|
||||
eq(approvals.companyId, companyId), eq(approvals.type, "hire_agent"),
|
||||
inArray(approvals.status, resolvableApprovalStatuses),
|
||||
sql`${approvals.payload}->>'agentId' = ${target}`,
|
||||
)).orderBy(desc(approvals.createdAt)).limit(1);
|
||||
approvalId = open?.id ?? null;
|
||||
}
|
||||
let approval: typeof approvals.$inferSelect | null = null;
|
||||
let applied = true;
|
||||
if (approvalId) {
|
||||
const result = await resolveApproval(approvalId, status, userId, note, tx);
|
||||
approval = result.approval;
|
||||
applied = result.applied;
|
||||
}
|
||||
const payload = approval?.payload ?? {};
|
||||
let agentId = typeof target === "string" ? target : typeof payload.agentId === "string" ? payload.agentId : null;
|
||||
if (applied && status === "approved") {
|
||||
if (agentId) {
|
||||
const activated = await records.activatePendingApproval(agentId, approval ? payload : null, approval ? approval.requestedByUserId : userId);
|
||||
if (!approval) applied = activated?.activated ?? false;
|
||||
} else {
|
||||
const created = await records.create(companyId, {
|
||||
name: String(payload.name ?? "New Agent"),
|
||||
appearance: payload.appearance == null ? undefined : agentAppearanceSchema.parse(payload.appearance),
|
||||
role: String(payload.role ?? "general"),
|
||||
title: typeof payload.title === "string" ? payload.title : null,
|
||||
reportsTo: typeof payload.reportsTo === "string" ? payload.reportsTo : null,
|
||||
capabilities: typeof payload.capabilities === "string" ? payload.capabilities : null,
|
||||
adapterType: String(payload.adapterType ?? "process"),
|
||||
adapterConfig:
|
||||
typeof payload.adapterConfig === "object" && payload.adapterConfig !== null
|
||||
? (payload.adapterConfig as Record<string, unknown>)
|
||||
: {},
|
||||
budgetMonthlyCents:
|
||||
typeof payload.budgetMonthlyCents === "number" ? payload.budgetMonthlyCents : 0,
|
||||
metadata:
|
||||
typeof payload.metadata === "object" && payload.metadata !== null
|
||||
? (payload.metadata as Record<string, unknown>)
|
||||
: null,
|
||||
status: "idle",
|
||||
spentMonthlyCents: 0,
|
||||
permissions: undefined,
|
||||
lastHeartbeatAt: null,
|
||||
}, { runnerResolved: true, createdByUserId: approval!.requestedByAgentId ? null : approval!.requestedByUserId, responsibleUserId: approval!.requestedByUserId });
|
||||
agentId = created.id;
|
||||
}
|
||||
if (approval && agentId && typeof payload.budgetMonthlyCents === "number" && payload.budgetMonthlyCents > 0) {
|
||||
await effects.setAgentBudget(tx, publications, companyId, agentId, payload.budgetMonthlyCents, userId);
|
||||
}
|
||||
} else if (applied && agentId) {
|
||||
if (approval) await records.rejectPendingHire(agentId);
|
||||
else {
|
||||
const before = await records.getById(agentId);
|
||||
await createLifecycleStore(tx, effects).change(agentId, "reject");
|
||||
applied = before?.lifecycleState !== "rejected";
|
||||
}
|
||||
}
|
||||
return { approval, agent: agentId ? await records.getById(agentId) : null, applied,
|
||||
activated: status === "approved" && applied,
|
||||
hireApprovedAgentId: status === "approved" && applied ? agentId : null };
|
||||
});
|
||||
}
|
||||
return { decide };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { agents } from "@paperclipai/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import type { CompanyDeletionParticipant } from "../../../lib/company-deletion.js";
|
||||
import { AgentLifecycleConflict, assertAgentPurgeAllowed } from "../domain/policy.js";
|
||||
|
||||
export const agentLifecycleCompanyDeletion: CompanyDeletionParticipant = {
|
||||
async deleteCompanyData(tx, companyId) {
|
||||
if (!("nestedIndex" in tx)) {
|
||||
throw new AgentLifecycleConflict("Company deletion requires a database transaction");
|
||||
}
|
||||
const rows = await tx.select({ lifecycleState: agents.lifecycleState }).from(agents)
|
||||
.where(eq(agents.companyId, companyId)).for("update");
|
||||
for (const agent of rows) assertAgentPurgeAllowed(agent);
|
||||
await tx.delete(agents).where(and(
|
||||
eq(agents.companyId, companyId),
|
||||
inArray(agents.lifecycleState, ["terminated", "rejected"]),
|
||||
));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, type Db } from "@paperclipai/db";
|
||||
import { agentRecordQueries, AGENT_CONFIGURATION_FIELDS, jsonEqual, type AgentConfigurationPatch, type UpdateAgentOptions } from "../../../lib/agent-records.js";
|
||||
import type { ActivityPublication } from "../../../types/activity-publication.js";
|
||||
import { AgentLifecycleConflict } from "../domain/policy.js";
|
||||
import type { LifecycleEffects } from "./effects.js";
|
||||
|
||||
export async function updateAgentConfiguration(tx: Db, effects: LifecycleEffects, id: string, data: AgentConfigurationPatch, options?: UpdateAgentOptions, publications: ActivityPublication[] = []) {
|
||||
if (!("nestedIndex" in tx)) throw new AgentLifecycleConflict("Configuration integration requires a database transaction");
|
||||
assertConfigurationFields(data);
|
||||
const [current] = await tx.select().from(agents).where(eq(agents.id, id)).for("update");
|
||||
if (!current) return null;
|
||||
const patch = await effects.prepareConfiguration(tx, current, data, options);
|
||||
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 [updated] = await tx.update(agents).set({ ...patch, updatedAt: new Date(), ...(invalidate ? {
|
||||
lifecycleVersion: current.lifecycleVersion + 1, lifecycleError: null,
|
||||
lifecycleOperation: { ...current.lifecycleOperation!, id: randomUUID(), hostComplete: false,
|
||||
completedPluginIds: [], leaseOwner: undefined, leaseUntil: undefined, retryAt: undefined },
|
||||
} : {}) }).where(eq(agents.id, id)).returning();
|
||||
await effects.completeConfiguration(tx, current, updated, patch, options, publications);
|
||||
return agentRecordQueries(tx).getById(id);
|
||||
}
|
||||
|
||||
function assertConfigurationFields(data: AgentConfigurationPatch) {
|
||||
if (Object.keys(data).some(field => !AGENT_CONFIGURATION_FIELDS.some(allowed => allowed === field))) {
|
||||
throw new AgentLifecycleConflict("Unsupported configuration field; use the command that owns this field, such as a lifecycle command");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { agents, type Db } from "@paperclipai/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { conflict } from "../../../errors.js";
|
||||
import { agentRecordQueries } from "../../../lib/agent-records.js";
|
||||
import { readBuiltInAgentMarker } from "../../../lib/built-in-agent-metadata.js";
|
||||
import { assertAgentPurgeAllowed } from "../domain/policy.js";
|
||||
import type { LifecycleEffects } from "./effects.js";
|
||||
|
||||
export async function purgeAgent(db: Db, effects: LifecycleEffects, id: string) {
|
||||
const { getById, normalizeAgentRow } = agentRecordQueries(db);
|
||||
const existing = await getById(id);
|
||||
if (!existing) return null;
|
||||
return effects.transaction(db, existing.companyId, async tx => {
|
||||
const [agent] = await tx.select().from(agents).where(eq(agents.id, id)).for("update");
|
||||
if (!agent) return null;
|
||||
const marker = readBuiltInAgentMarker(agent.metadata);
|
||||
if (marker) throw conflict("Built-in agents cannot be deleted; pause them instead", {
|
||||
code: "built_in_agent_undeletable", key: marker.key, featureKeys: marker.featureKeys,
|
||||
});
|
||||
assertAgentPurgeAllowed(agent);
|
||||
await effects.deleteDependencies(tx, agent.companyId, id);
|
||||
const [deleted] = await tx.delete(agents).where(and(
|
||||
eq(agents.id, id), inArray(agents.lifecycleState, ["terminated", "rejected"]),
|
||||
)).returning();
|
||||
return deleted ? normalizeAgentRow(deleted) : null;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { LifecycleFailure } from "../application/ports.js";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type { budgetPolicies } from "@paperclipai/db";
|
||||
import type { CreateAgentData, CreateAgentOptions, AgentConfigurationPatch, AgentConfigurationRecord, AgentHireRecord, UpdateAgentOptions } from "../../../lib/agent-records.js";
|
||||
import type { ActivityPublication } from "../../../types/activity-publication.js";
|
||||
|
||||
/** Services supply integrations; lifecycle owns the transaction and state writes. */
|
||||
export interface LifecycleEffects {
|
||||
reportFailure(context: LifecycleFailure, error: unknown): void;
|
||||
deleteDependencies(db: Db, companyId: string, agentId: string): Promise<void>;
|
||||
prepareConfiguration(db: Db, current: AgentConfigurationRecord, data: AgentConfigurationPatch, options?: UpdateAgentOptions): Promise<AgentConfigurationPatch>;
|
||||
completeConfiguration(db: Db, before: AgentConfigurationRecord, after: AgentConfigurationRecord, patch: AgentConfigurationPatch, options: UpdateAgentOptions | undefined, publications: ActivityPublication[]): Promise<void>;
|
||||
transaction<T>(db: Db, companyId: string, work: (tx: Db, publications: ActivityPublication[]) => Promise<T>): Promise<T>;
|
||||
policyBlocks(db: Db, policy: typeof budgetPolicies.$inferSelect): Promise<boolean>;
|
||||
setAgentBudget(db: Db, publications: ActivityPublication[], companyId: string, agentId: string, amount: number, userId: string | null, isActive?: boolean): Promise<unknown>;
|
||||
enforceBudget(db: Db, companyId: string, agentId: string): Promise<void>;
|
||||
recordCreation(db: Db, companyId: string, agentId: string): Promise<void>;
|
||||
recordStatus(db: Db, companyId: string, agentId: string, before: string, after: string): Promise<void>;
|
||||
clearPrimary(db: Db, companyId: string, agentId: string): Promise<unknown>;
|
||||
prepareHire(db: Db, companyId: string, data: CreateAgentData, options?: CreateAgentOptions): Promise<CreateAgentData>;
|
||||
initializeHire(db: Db, agent: AgentHireRecord, options?: CreateAgentOptions): Promise<void>;
|
||||
prepareHireApproval(db: Db, agent: AgentHireRecord, payload?: Record<string, unknown> | null): Promise<AgentConfigurationPatch>;
|
||||
completeHireApproval(db: Db, agent: AgentHireRecord, previous: AgentHireRecord): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, asc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { agents, agentApiKeys, companies, budgetPolicies, type Db } from "@paperclipai/db";
|
||||
import type { AgentLifecycleOperation } from "@paperclipai/shared";
|
||||
import { AgentLifecycleConflict, compatibilityStatus, transition } from "../domain/policy.js";
|
||||
import type { LifecycleEffects } from "./effects.js";
|
||||
import type { LifecycleStore } from "../application/ports.js";
|
||||
|
||||
export function assertRootDatabase(db: Db) {
|
||||
if ("nestedIndex" in db) throw new AgentLifecycleConflict("Lifecycle commands require the root database connection");
|
||||
}
|
||||
|
||||
export function createLifecycleStore(db: Db, effects: LifecycleEffects): LifecycleStore {
|
||||
const get = (id: string) => db.select().from(agents).where(eq(agents.id, id)).then(rows => rows[0] ?? null);
|
||||
return {
|
||||
get,
|
||||
async change(id, command, options = {}) {
|
||||
const existing = await get(id);
|
||||
if (!existing) return null;
|
||||
return effects.transaction(db, existing.companyId, async tx => {
|
||||
const [agent] = await tx.select().from(agents).where(eq(agents.id, id)).for("update", command === "reconcile" ? { skipLocked: true } : undefined);
|
||||
if (!agent) return null;
|
||||
if (options.version !== undefined && options.version !== agent.lifecycleVersion) return null;
|
||||
if (command === "complete" && (!options.owner || !agent.lifecycleOperation?.leaseUntil || agent.lifecycleOperation?.leaseOwner !== options.owner ||
|
||||
Date.parse(agent.lifecycleOperation?.leaseUntil ?? "") <= Date.now() ||
|
||||
!agent.lifecycleOperation.hostComplete || agent.lifecycleRequiredPluginIds === null ||
|
||||
agent.lifecycleRequiredPluginIds.some(id => !agent.lifecycleOperation!.completedPluginIds.includes(id)))) return null;
|
||||
if (command === "retry") {
|
||||
transition(agent.lifecycleState, command);
|
||||
if (agent.lifecycleOperation?.leaseUntil && Date.parse(agent.lifecycleOperation.leaseUntil) > Date.now()) return agent;
|
||||
const [updated] = await tx.update(agents).set({ lifecycleError: null,
|
||||
lifecycleOperation: sql`${agents.lifecycleOperation} - 'retryAt' - 'leaseOwner' - 'leaseUntil'`, updatedAt: new Date() }).where(eq(agents.id, id)).returning();
|
||||
return updated;
|
||||
}
|
||||
if (command === "resume" && !["ready", "paused", "pausing", "resuming"].includes(agent.lifecycleState)) transition(agent.lifecycleState, command);
|
||||
let holds = agent.lifecycleHolds;
|
||||
const reason = options.reason ?? "manual";
|
||||
if (command === "reconcile") {
|
||||
if (["pending_approval", "terminating", "cleaning_up", "terminated", "rejected"].includes(agent.lifecycleState)) return agent;
|
||||
const [company] = await tx.select().from(companies).where(eq(companies.id, agent.companyId));
|
||||
const policies = await tx.select().from(budgetPolicies).where(and(eq(budgetPolicies.companyId, agent.companyId),
|
||||
eq(budgetPolicies.scopeType, "agent"), eq(budgetPolicies.scopeId, id)));
|
||||
let budgetBlocked = false;
|
||||
for (const policy of policies) if (await effects.policyBlocks(tx, policy)) budgetBlocked = true;
|
||||
holds = holds.filter(hold => hold !== "budget" && hold !== "company_archived" && hold !== "company_paused");
|
||||
if (budgetBlocked) holds.push("budget");
|
||||
if (company.status === "archived") holds.push("company_archived");
|
||||
if (company.status === "paused") holds.push("company_paused");
|
||||
}
|
||||
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);
|
||||
let next = command === "reconcile"
|
||||
? holds.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 (command === "complete" && next === "ready" && holds.length) next = "pausing";
|
||||
if (command === "complete" && next === "paused" && !holds.length) next = "resuming";
|
||||
if (next === agent.lifecycleState) {
|
||||
if (JSON.stringify(holds) !== JSON.stringify(agent.lifecycleHolds) || agent.pauseReason !== (holds[0] ?? null)) {
|
||||
await tx.update(agents).set({ lifecycleHolds: holds, pauseReason: holds[0] ?? null }).where(eq(agents.id, id));
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
const operation: AgentLifecycleOperation = {
|
||||
responsibleUserId: agent.lifecycleOperation?.responsibleUserId,
|
||||
id: randomUUID(), hostComplete: false, completedPluginIds: [], attempts: 0,
|
||||
resumeState: next === "pausing"
|
||||
? agent.lifecycleState === "preparing" || agent.lifecycleState === "verifying" ? agent.lifecycleState : agent.lifecycleOperation?.resumeState ?? "ready"
|
||||
: agent.lifecycleOperation?.resumeState,
|
||||
};
|
||||
if (next === "ready") operation.resumeState = "ready";
|
||||
const status = compatibilityStatus(next, agent.status);
|
||||
const [updated] = await tx.update(agents).set({
|
||||
lifecycleHolds: holds, lifecycleState: next, lifecycleVersion: agent.lifecycleVersion + 1, lifecycleError: null,
|
||||
lifecycleOperation: operation, status,
|
||||
pauseReason: holds[0] ?? null,
|
||||
pausedAt: next === "pausing" ? new Date() : next === "ready" ? null : agent.pausedAt,
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(agents.id, id)).returning();
|
||||
if (next === "terminating" || next === "rejected") {
|
||||
await tx.update(agentApiKeys).set({ revokedAt: new Date() }).where(eq(agentApiKeys.agentId, id));
|
||||
await effects.clearPrimary(tx, agent.companyId, id);
|
||||
}
|
||||
if (command === "approve") await effects.recordCreation(tx, agent.companyId, id);
|
||||
if (next === "pausing" || next === "resuming" || next === "terminating" || next === "rejected") {
|
||||
await effects.recordStatus(tx, agent.companyId, id,
|
||||
next === "resuming" ? "paused" : "idle",
|
||||
next === "resuming" ? "idle" : next === "pausing" ? "paused" : "terminated");
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
async claim(id, owner, now) {
|
||||
const [agent] = await db.update(agents).set({
|
||||
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"]),
|
||||
sql`coalesce((${agents.lifecycleOperation}->>'leaseUntil')::timestamptz, '-infinity') <= ${now.toISOString()}::timestamptz`,
|
||||
sql`coalesce((${agents.lifecycleOperation}->>'retryAt')::timestamptz, '-infinity') <= ${now.toISOString()}::timestamptz`,
|
||||
)).returning();
|
||||
return agent ?? null;
|
||||
},
|
||||
async renew(agent, owner, now) {
|
||||
const rows = await db.update(agents).set({ lifecycleOperation: sql`jsonb_set(${agents.lifecycleOperation}, '{leaseUntil}', ${JSON.stringify(new Date(now.getTime() + 120_000).toISOString())}::jsonb)` })
|
||||
.where(and(eq(agents.id, agent.id), eq(agents.lifecycleVersion, agent.lifecycleVersion), sql`${agents.lifecycleOperation}->>'leaseOwner' = ${owner}`)).returning({ id: agents.id });
|
||||
return rows.length > 0;
|
||||
},
|
||||
async setRequiredPlugins(agent, owner, pluginIds) {
|
||||
const rows = await db.update(agents).set({ lifecycleRequiredPluginIds: pluginIds })
|
||||
.where(and(eq(agents.id, agent.id), eq(agents.lifecycleVersion, agent.lifecycleVersion),
|
||||
sql`${agents.lifecycleRequiredPluginIds} is null`,
|
||||
sql`${agents.lifecycleOperation}->>'leaseOwner' = ${owner}`)).returning({ id: agents.id });
|
||||
return rows.length > 0;
|
||||
},
|
||||
async completeHost(agent, owner) {
|
||||
const rows = await db.update(agents).set({
|
||||
lifecycleOperation: sql`jsonb_set(${agents.lifecycleOperation}, '{hostComplete}', 'true'::jsonb)`,
|
||||
}).where(and(eq(agents.id, agent.id), eq(agents.lifecycleVersion, agent.lifecycleVersion),
|
||||
sql`${agents.lifecycleOperation}->>'leaseOwner' = ${owner}`)).returning({ id: agents.id });
|
||||
return rows.length > 0;
|
||||
},
|
||||
async completePlugin(agent, owner, pluginId) {
|
||||
const rows = await db.update(agents).set({
|
||||
lifecycleOperation: sql`jsonb_set(${agents.lifecycleOperation}, '{completedPluginIds}',
|
||||
coalesce(${agents.lifecycleOperation}->'completedPluginIds', '[]'::jsonb) || ${JSON.stringify([pluginId])}::jsonb)`,
|
||||
}).where(and(eq(agents.id, agent.id), eq(agents.lifecycleVersion, agent.lifecycleVersion),
|
||||
sql`${agents.lifecycleRequiredPluginIds} @> ${JSON.stringify([pluginId])}::jsonb`,
|
||||
sql`${agents.lifecycleOperation}->>'leaseOwner' = ${owner}`)).returning({ id: agents.id });
|
||||
return rows.length > 0;
|
||||
},
|
||||
async defer(agent, owner, error, now) {
|
||||
const rows = await db.update(agents).set({
|
||||
lifecycleError: error,
|
||||
lifecycleOperation: sql`(${agents.lifecycleOperation} - 'leaseOwner' - 'leaseUntil')
|
||||
|| jsonb_build_object('retryAt', ${new Date(now.getTime() + (error ? 60_000 : 2_000)).toISOString()}::text)`,
|
||||
updatedAt: now,
|
||||
}).where(and(eq(agents.id, agent.id), eq(agents.lifecycleVersion, agent.lifecycleVersion),
|
||||
sql`${agents.lifecycleOperation}->>'leaseOwner' = ${owner}`)).returning({ id: agents.id });
|
||||
return rows.length > 0;
|
||||
},
|
||||
async pending(limit, now) {
|
||||
return db.select({ id: agents.id }).from(agents).where(and(
|
||||
inArray(agents.lifecycleState, ["preparing", "verifying", "pausing", "resuming", "terminating", "cleaning_up"]),
|
||||
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));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { LifecycleEffects } from "./effects.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { agents, agentApiKeys, type Db } from "@paperclipai/db";
|
||||
import { conflict, notFound } from "../../../errors.js";
|
||||
import { agentRecordQueries, type CreateAgentOptions, type CreateAgentData } from "../../../lib/agent-records.js";
|
||||
|
||||
export function agentRecords(db: Db, effects: LifecycleEffects) {
|
||||
const { getById } = agentRecordQueries(db);
|
||||
|
||||
return {
|
||||
getById,
|
||||
create: async (companyId: string, data: CreateAgentData, options?: CreateAgentOptions) => {
|
||||
if (Object.keys(data).some(key => key.startsWith("lifecycle"))) throw conflict("Lifecycle fields belong to the lifecycle module");
|
||||
return effects.transaction(db, companyId, async (txDb) => {
|
||||
const tx = txDb;
|
||||
const prepared = await effects.prepareHire(txDb, companyId, data, options);
|
||||
const created = await tx
|
||||
.insert(agents)
|
||||
.values({
|
||||
...prepared,
|
||||
lifecycleState: data.status === "pending_approval" ? "pending_approval" : data.status === "terminated" ? "terminated" : data.status === "paused" ? "paused" : "preparing",
|
||||
lifecycleVersion: 1,
|
||||
lifecycleHolds: data.status === "paused" ? [data.pauseReason ?? "manual"] : [],
|
||||
pauseReason: data.status === "paused" ? data.pauseReason ?? "manual" : null,
|
||||
pausedAt: data.status === "paused" ? new Date() : null,
|
||||
lifecycleOperation: { id: randomUUID(), hostComplete: false, completedPluginIds: [], attempts: 0, resumeState: "preparing", responsibleUserId: options?.responsibleUserId ?? options?.createdByUserId },
|
||||
status: data.status === "pending_approval" ? "pending_approval" : data.status === "terminated" ? "terminated" : "paused",
|
||||
companyId,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
await effects.initializeHire(txDb, created, options);
|
||||
if (created.status !== "pending_approval" && created.status !== "terminated") {
|
||||
await effects.recordCreation(txDb, companyId, created.id);
|
||||
}
|
||||
const normalizedCreated = await agentRecordQueries(txDb).getById(created.id);
|
||||
if (!normalizedCreated) {
|
||||
throw notFound("Agent not found");
|
||||
}
|
||||
return normalizedCreated;
|
||||
});
|
||||
},
|
||||
|
||||
clearError: async (id: string) => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) return null;
|
||||
if (existing.status === "terminated") throw conflict("Cannot clear error on terminated agent");
|
||||
if (existing.status === "pending_approval") {
|
||||
throw conflict("Pending approval agents cannot have errors cleared");
|
||||
}
|
||||
if (existing.status !== "error") {
|
||||
throw conflict("Only agents in error status can have their error cleared");
|
||||
}
|
||||
|
||||
const updated = await db
|
||||
.update(agents)
|
||||
.set({
|
||||
status: "idle",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
errorReason: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(agents.id, id), eq(agents.status, "error"), eq(agents.lifecycleState, "ready")))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!updated) {
|
||||
throw conflict("Only agents in error status can have their error cleared");
|
||||
}
|
||||
return getById(updated.id);
|
||||
},
|
||||
|
||||
rejectPendingHire: async (id: string) => {
|
||||
const rows = await db.update(agents).set({ status: "terminated", lifecycleState: "rejected",
|
||||
lifecycleVersion: sql`${agents.lifecycleVersion} + 1`, lifecycleOperation: null,
|
||||
updatedAt: new Date() }).where(and(eq(agents.id, id), eq(agents.lifecycleState, "pending_approval"))).returning();
|
||||
for (const row of rows) {
|
||||
await effects.clearPrimary(db, row.companyId, id);
|
||||
await db.update(agentApiKeys).set({ revokedAt: new Date() }).where(eq(agentApiKeys.agentId, id));
|
||||
await effects.recordStatus(db, row.companyId, id, "pending_approval", "terminated");
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
|
||||
activatePendingApproval: async (id: string, approvedPayload?: Record<string, unknown> | null, requestedByUserId?: string | null) => {
|
||||
const activatedAgent = await db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
const existing = await agentRecordQueries(txDb).getById(id);
|
||||
if (!existing || existing.status !== "pending_approval") return null;
|
||||
const patch = await effects.prepareHireApproval(txDb, existing, approvedPayload);
|
||||
const updated = await tx
|
||||
.update(agents)
|
||||
.set({ ...patch, status: "paused", lifecycleState: "preparing", lifecycleVersion: existing.lifecycleVersion + 1, lifecycleError: null, lifecycleOperation: sql`jsonb_build_object('id', ${randomUUID()}::text, 'hostComplete', false, 'completedPluginIds', '[]'::jsonb, 'attempts', 0, 'responsibleUserId', coalesce(nullif(${agents.lifecycleOperation}->'responsibleUserId', 'null'::jsonb), to_jsonb(${requestedByUserId ?? null}::text)))`, updatedAt: new Date() })
|
||||
.where(and(eq(agents.id, id), eq(agents.status, "pending_approval")))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!updated) return null;
|
||||
await effects.completeHireApproval(txDb, updated, existing);
|
||||
await effects.recordCreation(txDb, existing.companyId, updated.id);
|
||||
const agent = await agentRecordQueries(txDb).getById(updated.id);
|
||||
if (!agent) {
|
||||
throw notFound("Agent not found");
|
||||
}
|
||||
return agent;
|
||||
});
|
||||
|
||||
if (activatedAgent) {
|
||||
return { agent: activatedAgent, activated: true };
|
||||
}
|
||||
|
||||
const existing = await getById(id);
|
||||
return existing ? { agent: existing, activated: false } : null;
|
||||
},
|
||||
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { AgentLifecycleOperation, AgentLifecycleState } from "@paperclipai/shared";
|
||||
import type { LifecycleCommand } from "../domain/policy.js";
|
||||
|
||||
export interface LifecycleAgent {
|
||||
id: string;
|
||||
companyId: string;
|
||||
lifecycleState: AgentLifecycleState;
|
||||
lifecycleVersion: number;
|
||||
lifecycleRequiredPluginIds: string[] | null;
|
||||
lifecycleError: string | null;
|
||||
lifecycleOperation: AgentLifecycleOperation | null;
|
||||
}
|
||||
|
||||
export interface LifecycleStore {
|
||||
get(id: string): Promise<LifecycleAgent | null>;
|
||||
change(id: string, command: LifecycleCommand, options?: { version?: number; owner?: string; reason?: string }): Promise<LifecycleAgent | null>;
|
||||
claim(id: string, owner: string, now: Date): Promise<LifecycleAgent | null>;
|
||||
renew(agent: LifecycleAgent, owner: string, now: Date): Promise<boolean>;
|
||||
setRequiredPlugins(agent: LifecycleAgent, owner: string, pluginIds: string[]): Promise<boolean>;
|
||||
completeHost(agent: LifecycleAgent, owner: string): Promise<boolean>;
|
||||
completePlugin(agent: LifecycleAgent, owner: string, pluginId: string): Promise<boolean>;
|
||||
defer(agent: LifecycleAgent, owner: string, error: string | null, now: Date): Promise<boolean>;
|
||||
pending(limit: number, now: Date): Promise<string[]>;
|
||||
}
|
||||
|
||||
export interface LifecycleDriver {
|
||||
requiredPluginIds(agent: LifecycleAgent): Promise<string[]>;
|
||||
runHost(agent: LifecycleAgent): Promise<"complete" | "pending">;
|
||||
runPlugin(agent: LifecycleAgent, pluginId: string): Promise<"complete" | "pending">;
|
||||
}
|
||||
|
||||
export interface LifecycleFailure {
|
||||
stage: "claim" | "renew" | "select_plugins" | "save_plugins" | "host" | "plugin"
|
||||
| "complete_host" | "complete_plugin" | "transition" | "defer" | "scan" | "policy_scan";
|
||||
agentId?: string;
|
||||
companyId?: string;
|
||||
phase?: AgentLifecycleState;
|
||||
operationId?: string;
|
||||
version?: number;
|
||||
pluginId?: string;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { LifecycleDriver, LifecycleStore, LifecycleFailure } from "./ports.js";
|
||||
import { isLifecycleWorkPending } from "../domain/policy.js";
|
||||
|
||||
/** Database leases protect each attempt across server processes. */
|
||||
export function createLifecycleWorker(store: LifecycleStore, driver: LifecycleDriver, reportFailure: (context: LifecycleFailure, error: unknown) => void, canRun = () => true) {
|
||||
let stopped = false;
|
||||
const active = new Map<string, Promise<void>>();
|
||||
let running: Promise<void> | undefined;
|
||||
|
||||
async function attempt<T>(context: LifecycleFailure, work: () => Promise<T>): Promise<T> {
|
||||
try { return await work(); }
|
||||
catch (error) { reportFailure(context, error); throw error; }
|
||||
}
|
||||
|
||||
async function process(id: string) {
|
||||
phase: for (let step = 0; !stopped && canRun() && step < 8; step++) {
|
||||
const owner = randomUUID();
|
||||
const agent = await attempt({ stage: "claim", agentId: id }, () => store.claim(id, owner, new Date()));
|
||||
if (!agent || !isLifecycleWorkPending(agent.lifecycleState)) return;
|
||||
const context = { agentId: agent.id, companyId: agent.companyId, phase: agent.lifecycleState,
|
||||
operationId: agent.lifecycleOperation?.id, version: agent.lifecycleVersion };
|
||||
const runStep = <T>(stage: LifecycleFailure["stage"], work: () => Promise<T>, pluginId?: string) =>
|
||||
attempt({ ...context, stage, ...(pluginId ? { pluginId } : {}) }, work);
|
||||
const renewal = setInterval(() => {
|
||||
void runStep("renew", () => store.renew(agent, owner, new Date())).catch(() => {});
|
||||
}, 30_000);
|
||||
renewal.unref?.();
|
||||
try {
|
||||
const operation = agent.lifecycleOperation!;
|
||||
const pluginIds = agent.lifecycleRequiredPluginIds ?? await runStep("select_plugins", () => driver.requiredPluginIds(agent));
|
||||
if (agent.lifecycleRequiredPluginIds === null && !await runStep("save_plugins", () => store.setRequiredPlugins(agent, owner, pluginIds))) continue;
|
||||
if (!canRun()) {
|
||||
await runStep("defer", () => store.defer(agent, owner, null, new Date()));
|
||||
return;
|
||||
}
|
||||
if (!operation.hostComplete) {
|
||||
if (!await runStep("renew", () => store.renew(agent, owner, new Date()))) continue;
|
||||
if (await runStep("host", () => driver.runHost(agent)) === "pending") {
|
||||
if (!await runStep("defer", () => store.defer(agent, owner, null, new Date()))) continue;
|
||||
return;
|
||||
}
|
||||
if (!await runStep("complete_host", () => store.completeHost(agent, owner))) continue;
|
||||
}
|
||||
for (const pluginId of pluginIds) {
|
||||
if (operation.completedPluginIds.includes(pluginId)) continue;
|
||||
if (!canRun()) {
|
||||
await runStep("defer", () => store.defer(agent, owner, null, new Date()));
|
||||
return;
|
||||
}
|
||||
if (!await runStep("renew", () => store.renew(agent, owner, new Date()))) continue phase;
|
||||
if (await runStep("plugin", () => driver.runPlugin(agent, pluginId), pluginId) === "pending") {
|
||||
if (!await runStep("defer", () => store.defer(agent, owner, null, new Date()))) continue phase;
|
||||
return;
|
||||
}
|
||||
if (!await runStep("complete_plugin", () => store.completePlugin(agent, owner, pluginId), pluginId)) continue phase;
|
||||
}
|
||||
const changed = await runStep("transition", () => store.change(id, "complete", { version: agent.lifecycleVersion, owner }));
|
||||
if (changed && !isLifecycleWorkPending(changed.lifecycleState)) return;
|
||||
} catch {
|
||||
if (!await runStep("defer", () => store.defer(agent, owner, "The lifecycle step failed. Retry the operation.", new Date()))) continue;
|
||||
return;
|
||||
} finally {
|
||||
clearInterval(renewal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sweep() {
|
||||
const pending = await attempt({ stage: "scan" }, () => store.pending(100, new Date()));
|
||||
const entries = pending.values();
|
||||
await Promise.all(Array.from({ length: Math.min(4, pending.length) }, async () => {
|
||||
for (const id of entries) await start(id);
|
||||
}));
|
||||
}
|
||||
|
||||
function start(id: string): Promise<void> {
|
||||
if (stopped || !canRun()) return Promise.resolve();
|
||||
const existing = active.get(id);
|
||||
if (existing) return existing;
|
||||
const work = process(id).finally(() => { active.delete(id); });
|
||||
active.set(id, work);
|
||||
return work;
|
||||
}
|
||||
|
||||
return {
|
||||
process: start,
|
||||
async stop() {
|
||||
stopped = true;
|
||||
await Promise.allSettled([...active.values()]);
|
||||
},
|
||||
sweep() {
|
||||
return running ??= sweep().finally(() => { running = undefined; });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { agentLifecycleCompanyDeletion } from "./adapters/company-deletion.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { updateAgentConfiguration } from "./adapters/configuration.js";
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { AgentLifecycleState } from "@paperclipai/shared";
|
||||
|
||||
export type LifecycleCommand = "reconcile" | "approve" | "reject" | "pause" | "resume" | "terminate" | "complete" | "retry";
|
||||
export class AgentLifecycleConflict extends Error {
|
||||
constructor(message: string) { super(message); this.name = "AgentLifecycleConflict"; }
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
export function isAgentAwaitingSetup(agent: { lifecycleState: string; lifecycleHolds: readonly string[] }): boolean {
|
||||
return ["preparing", "verifying", "resuming"].includes(agent.lifecycleState) && agent.lifecycleHolds.length === 0;
|
||||
}
|
||||
|
||||
export function transition(state: AgentLifecycleState, command: LifecycleCommand,
|
||||
resumeState: "preparing" | "verifying" | "ready" = "ready"): AgentLifecycleState {
|
||||
if (command === "retry") {
|
||||
if (["preparing", "verifying", "pausing", "resuming", "terminating", "cleaning_up"].includes(state)) return state;
|
||||
} else if (command === "terminate") {
|
||||
if (["terminated", "rejected", "terminating", "cleaning_up"].includes(state)) return state;
|
||||
return "terminating";
|
||||
} else if (command === "approve") {
|
||||
if (state === "pending_approval") return "preparing";
|
||||
} else if (command === "reject") {
|
||||
if (state === "pending_approval") return "rejected";
|
||||
if (state === "rejected") return state;
|
||||
} else if (command === "pause") {
|
||||
if (state === "paused" || state === "pausing") return state;
|
||||
if (["ready", "preparing", "verifying", "resuming"].includes(state)) return "pausing";
|
||||
} else if (command === "resume") {
|
||||
if (state === "ready" || state === "resuming") return state;
|
||||
if (state === "paused") return "resuming";
|
||||
} else if (command === "complete") {
|
||||
switch (state) {
|
||||
case "preparing": return "verifying";
|
||||
case "verifying": return "ready";
|
||||
case "pausing": return "paused";
|
||||
case "resuming": return resumeState;
|
||||
case "terminating": return "cleaning_up";
|
||||
case "cleaning_up": return "terminated";
|
||||
}
|
||||
}
|
||||
throw new AgentLifecycleConflict(`Cannot ${command} an agent in state ${state}`);
|
||||
}
|
||||
|
||||
export function compatibilityStatus(state: AgentLifecycleState, executionStatus = "idle"): string {
|
||||
if (state === "ready") return ["active", "idle", "running", "error"].includes(executionStatus) ? executionStatus : "idle";
|
||||
if (state === "pending_approval") return "pending_approval";
|
||||
if (["terminating", "cleaning_up", "terminated", "rejected"].includes(state)) return "terminated";
|
||||
return "paused";
|
||||
}
|
||||
|
||||
export function isLifecycleWorkPending(state: AgentLifecycleState): boolean {
|
||||
return ["preparing", "verifying", "pausing", "resuming", "terminating", "cleaning_up"].includes(state);
|
||||
}
|
||||
|
||||
export function assertAgentPurgeAllowed(agent: { lifecycleState: string }) {
|
||||
if (!["terminated", "rejected"].includes(agent.lifecycleState)) {
|
||||
throw new AgentLifecycleConflict("Complete termination before deleting the agent");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { hireApprovalService as approvalRecords, type HireDecisionTarget } from "./adapters/approvals.js";
|
||||
import { agents } from "@paperclipai/db";
|
||||
import { and, asc, eq, gt, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type { LifecycleEffects } from "./adapters/effects.js";
|
||||
export type { LifecycleEffects } from "./adapters/effects.js";
|
||||
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 { createLifecycleWorker } from "./application/worker.js";
|
||||
import type { LifecycleDriver } from "./application/ports.js";
|
||||
|
||||
export { AgentLifecycleConflict, canConfigureAgentConnection, isAgentAwaitingSetup } from "./domain/policy.js";
|
||||
|
||||
export type { HireDecisionTarget } from "./adapters/approvals.js";
|
||||
export type { LifecycleDriver, LifecycleAgent, LifecycleFailure } from "./application/ports.js";
|
||||
|
||||
const policyStates = ["preparing", "verifying", "ready", "pausing", "paused", "resuming"] as const;
|
||||
const policyPageSize = 100;
|
||||
|
||||
const workers = new WeakMap<Db, ReturnType<typeof createLifecycleWorker>>();
|
||||
export function configureAgentLifecycle(db: Db, effects: LifecycleEffects, driver: LifecycleDriver, canRun = () => true) {
|
||||
assertRootDatabase(db);
|
||||
const worker = createLifecycleWorker(createLifecycleStore(db, effects), driver, effects.reportFailure, canRun);
|
||||
workers.set(db, worker);
|
||||
return worker;
|
||||
}
|
||||
export function scheduleAgentLifecycle(db: Db, id: string) {
|
||||
// The periodic sweep retries work if the process stops before this call.
|
||||
return workers.get(db)?.process(id).catch(() => {});
|
||||
}
|
||||
|
||||
export function createAgentLifecycle(db: Db, effects: LifecycleEffects) {
|
||||
assertRootDatabase(db);
|
||||
const records = agentRecords(db, effects);
|
||||
const store = createLifecycleStore(db, effects);
|
||||
async function change(id: string, command: "pause" | "resume" | "terminate" | "retry", reason?: string) {
|
||||
const result = await store.change(id, command, { reason });
|
||||
if (result) scheduleAgentLifecycle(db, id);
|
||||
return result ? records.getById(id) : null;
|
||||
}
|
||||
async function decideHire(target: HireDecisionTarget, decision: "approved" | "rejected", userId: string, note?: string | null) {
|
||||
const result = await approvalRecords(db, effects).decide(target, decision, userId, note);
|
||||
if (result?.hireApprovedAgentId) scheduleAgentLifecycle(db, result.hireApprovedAgentId);
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
async get(id: string) {
|
||||
const agent = await store.get(id);
|
||||
return agent ? {
|
||||
id: agent.id, companyId: agent.companyId, lifecycleState: agent.lifecycleState,
|
||||
lifecycleVersion: agent.lifecycleVersion, lifecycleError: agent.lifecycleError,
|
||||
} : null;
|
||||
},
|
||||
async requestHire(...args: Parameters<typeof records.create>) {
|
||||
const agent = await records.create(...args);
|
||||
scheduleAgentLifecycle(db, agent.id);
|
||||
return agent;
|
||||
},
|
||||
approveHire: (target: HireDecisionTarget, userId = "board", note?: string | null) => decideHire(target, "approved", userId, note),
|
||||
rejectHire: (target: HireDecisionTarget, userId = "board", note?: string | null) => decideHire(target, "rejected", userId, note),
|
||||
async updateConfiguration(id: string, data: AgentConfigurationPatch, options?: UpdateAgentOptions) {
|
||||
const existing = await records.getById(id);
|
||||
if (!existing) return null;
|
||||
const agent = await effects.transaction(db, existing.companyId, (tx, publications) =>
|
||||
updateAgentConfiguration(tx, effects, id, data, options, publications));
|
||||
if (data.budgetMonthlyCents !== undefined) await effects.enforceBudget(db, existing.companyId, id);
|
||||
scheduleAgentLifecycle(db, id);
|
||||
return agent;
|
||||
},
|
||||
async updateAndTransition(id: string, command: "pause" | "resume" | "terminate", data: AgentConfigurationPatch, options?: UpdateAgentOptions) {
|
||||
const existing = await records.getById(id);
|
||||
if (!existing) return null;
|
||||
const agent = await effects.transaction(db, existing.companyId, async (tx, publications) => {
|
||||
await updateAgentConfiguration(tx, effects, id, data, options, publications);
|
||||
await createLifecycleStore(tx, effects).change(id, command, { reason: command === "resume" ? "user" : "manual" });
|
||||
return agentRecords(tx, effects).getById(id);
|
||||
});
|
||||
if (data.budgetMonthlyCents !== undefined) await effects.enforceBudget(db, existing.companyId, id);
|
||||
scheduleAgentLifecycle(db, id);
|
||||
return agent;
|
||||
},
|
||||
async reconcilePolicyHolds(companyId: string, agentId?: string | null) {
|
||||
if (agentId === null) return;
|
||||
const rows = await db.select({ id: agents.id }).from(agents).where(and(
|
||||
eq(agents.companyId, companyId), agentId ? eq(agents.id, agentId) : undefined,
|
||||
inArray(agents.lifecycleState, policyStates),
|
||||
));
|
||||
for (const row of rows) {
|
||||
await store.change(row.id, "reconcile");
|
||||
scheduleAgentLifecycle(db, row.id);
|
||||
}
|
||||
},
|
||||
pauseAgent: (id: string, reason = "manual") => change(id, "pause", reason),
|
||||
resumeAgent: (id: string, reason = "user") => change(id, "resume", reason),
|
||||
terminateAgent: (id: string) => change(id, "terminate"),
|
||||
retry: (id: string) => change(id, "retry"),
|
||||
clearError: records.clearError,
|
||||
purgeAgent: (id: string) => purgeAgent(db, effects, id),
|
||||
};
|
||||
}
|
||||
|
||||
export function startAgentLifecycle(db: Db, effects: LifecycleEffects, driver: LifecycleDriver, canRun: () => boolean) {
|
||||
const worker = configureAgentLifecycle(db, effects, driver, canRun);
|
||||
let running: Promise<void> | undefined;
|
||||
let stopped = false;
|
||||
let nextPolicySweep = 0;
|
||||
let policyCursor: string | undefined;
|
||||
const store = createLifecycleStore(db, effects);
|
||||
function sweep() {
|
||||
if (stopped || !canRun()) return Promise.resolve();
|
||||
return running ??= (async () => {
|
||||
if (Date.now() >= nextPolicySweep) {
|
||||
try {
|
||||
const rows = await db.select({ id: agents.id }).from(agents).where(and(
|
||||
inArray(agents.lifecycleState, policyStates), policyCursor ? gt(agents.id, policyCursor) : undefined,
|
||||
)).orderBy(asc(agents.id)).limit(policyPageSize);
|
||||
for (const row of rows) {
|
||||
await store.change(row.id, "reconcile");
|
||||
scheduleAgentLifecycle(db, row.id);
|
||||
policyCursor = row.id;
|
||||
}
|
||||
if (rows.length < policyPageSize) policyCursor = undefined;
|
||||
}
|
||||
catch (error) { effects.reportFailure({ stage: "policy_scan" }, error); throw error; }
|
||||
nextPolicySweep = Date.now() + 60_000;
|
||||
}
|
||||
await worker.sweep();
|
||||
})().finally(() => { running = undefined; });
|
||||
}
|
||||
const timer = setInterval(() => { void sweep().catch(() => {}); }, 5_000);
|
||||
timer.unref?.();
|
||||
return {
|
||||
sweep,
|
||||
async stop() {
|
||||
stopped = true;
|
||||
clearInterval(timer);
|
||||
workers.delete(db);
|
||||
try { await running; } finally { await worker.stop(); }
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAgentLifecycle } from "../services/agent-lifecycle.js";
|
||||
import {
|
||||
createHash,
|
||||
generateKeyPairSync,
|
||||
@@ -2640,6 +2641,7 @@ export function accessRoutes(
|
||||
const access = accessService(db);
|
||||
const boardAuth = boardAuthService(db);
|
||||
const agents = agentService(db);
|
||||
const agentsLifecycle = createAgentLifecycle(db);
|
||||
const routeInviteResolutionNetwork = opts.inviteResolutionNetwork
|
||||
? { ...defaultInviteResolutionNetwork, ...opts.inviteResolutionNetwork }
|
||||
: inviteResolutionNetwork;
|
||||
@@ -4268,7 +4270,7 @@ export function accessRoutes(
|
||||
}))
|
||||
);
|
||||
|
||||
const created = await agents.create(companyId, {
|
||||
const created = await agentsLifecycle.requestHire(companyId, {
|
||||
name: agentName,
|
||||
role: "general",
|
||||
title: null,
|
||||
|
||||
+60
-563
@@ -1,7 +1,9 @@
|
||||
import { cancellationRequestId } from "../services/native-runtime/native-cancellation-request.js";
|
||||
import { aiRoutingHarness } from "@paperclipai/shared";
|
||||
import { agentHarnessType, type AgentRunnerChoice } from "@paperclipai/shared";
|
||||
import { resolveNewAgentRunnerForCompany } from "../services/agent-runner-selection.js";
|
||||
import { agentEnvironmentTestService } from "../services/agent-environment-test.js";
|
||||
import { createAgentLifecycle } from "../services/agent-lifecycle.js";
|
||||
|
||||
import { agentIdentityService } from "../services/agent-identity.js";
|
||||
import { aiConnectionRouterService, poolMemberRuntimeConfig } from "../services/ai-connection-router.js";
|
||||
import { connectionIntentService } from "../services/connection-intents.js";
|
||||
@@ -13,12 +15,17 @@ import { agentFileStore, agentFileTokenFromHash } from "../services/agent-file-s
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { resolveAgentAppearance, agentAvatarUrl } from "@paperclipai/shared";
|
||||
import { listOpenRouterModels } from "../services/openrouter-models.js";
|
||||
import { prepareManagedAiRuntime, withManagedAiProbe, assertManagedAiProjectAuth, stripAiAuthBindings } from "../services/ai-connection-runtime.js";
|
||||
import { ADAPTER_AUTH_MISSING_CHECK_CODE, AI_CONNECTION_CAPABILITIES, aiConnectionBindingSchema, type AiConnectionBinding, aiRuntimeConnectionBindingSchema, type AiRuntimeConnectionBinding } from "@paperclipai/shared";
|
||||
import { withManagedAiProbe, stripAiAuthBindings } from "../services/ai-connection-runtime.js";
|
||||
import {
|
||||
ADAPTER_AUTH_MISSING_CHECK_CODE,
|
||||
aiConnectionBindingSchema,
|
||||
aiRuntimeConnectionBindingSchema,
|
||||
type AiRuntimeConnectionBinding,
|
||||
} from "@paperclipai/shared";
|
||||
import { toolConnections } from "@paperclipai/db";
|
||||
import { aiConnectionService } from "../services/ai-connections.js";
|
||||
import { defaultAiConnectionForHire } from "../services/agent-ai-connection-default.js";
|
||||
import { assertAiConnectionCreateAccess, canInstallSharedAiConnectionForNewAgent, responsibleUserForAiRequest, validateAiApiKey } from "./ai-connections.js";
|
||||
import { assertAiConnectionCreateAccess, canInstallSharedAiConnectionForNewAgent, responsibleUserForAiRequest } from "./ai-connections.js";
|
||||
import { isAiConnectionCompatible } from "@paperclipai/shared";
|
||||
import { applyConnectorSkills, resolveConnectorAssignments, annotateConnectorSkills, isConnectorSkill } from "../services/connector-runtime.js";
|
||||
import { getExecutionBlocker } from "../services/execution-blocker.js";
|
||||
@@ -116,7 +123,7 @@ import {
|
||||
} from "./workspace-command-authz.js";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
import { environmentService } from "../services/environments.js";
|
||||
import { resolveEnvironmentExecutionTarget } from "../services/environment-execution-target.js";
|
||||
|
||||
import { environmentRuntimeService } from "../services/environment-runtime.js";
|
||||
import { resolvePluginSandboxProviderDriverByKey } from "../services/plugin-environment-driver.js";
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
@@ -563,10 +570,12 @@ export function agentRoutes(
|
||||
|
||||
const router = Router();
|
||||
const svc = agentService(db, { cancelWorkForScope: scope => heartbeat.cancelBudgetScopeWork(scope) });
|
||||
const svcLifecycle = createAgentLifecycle(db, { cancelWorkForScope: scope => heartbeat.cancelBudgetScopeWork(scope) });
|
||||
const access = accessService(db);
|
||||
const approvalsSvc = approvalService(db);
|
||||
const budgets = budgetService(db);
|
||||
const environmentsSvc = environmentService(db);
|
||||
const { resolveAdapterTestEnvironmentId, resolveAdapterTestExecutionContext, assertAdapterTestEnvironmentForCompany, testManagedEnvironment } = agentEnvironmentTestService(db, options.pluginWorkerManager);
|
||||
const environmentRuntime = environmentRuntimeService(db, {
|
||||
pluginWorkerManager: options.pluginWorkerManager,
|
||||
});
|
||||
@@ -1223,415 +1232,6 @@ export function agentRoutes(
|
||||
return rows.filter((_, index) => decisions[index]?.allowed);
|
||||
}
|
||||
|
||||
// A null agent override inherits the instance default, just like dispatch.
|
||||
// Resolve this before secrets or probes so a default remote environment can
|
||||
// never accidentally validate the account on the control-plane host.
|
||||
async function resolveAdapterTestEnvironmentId(companyId: string, environmentId: string | null | undefined) {
|
||||
if (environmentId) return environmentId;
|
||||
const settings = await instanceSettings.get();
|
||||
if (settings.defaultEnvironmentId) return settings.defaultEnvironmentId;
|
||||
if ((await instanceSettings.getExperimental()).enableManagedSandboxOnly === true) {
|
||||
const managed = await environmentsSvc.findManagedSandboxEnvironment(companyId);
|
||||
if (!managed) {
|
||||
throw unprocessable("The managed sandbox is unavailable. Restore Paperclip Computer and retry.", {
|
||||
code: "managed_sandbox_unavailable",
|
||||
});
|
||||
}
|
||||
return managed.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the execution target the adapter should run its test probes against.
|
||||
*
|
||||
* - No environmentId / local environment → returns a local target so the
|
||||
* adapter probes the Paperclip host (legacy behavior).
|
||||
* - SSH environment → builds an SSH execution target from the environment
|
||||
* config so the adapter probes the remote box. No lease is required:
|
||||
* the SSH spec is fully derived from the saved environment config.
|
||||
* - Sandbox / plugin environments → acquires an ad-hoc lease, realizes the
|
||||
* workspace, and resolves a sandbox execution target wired to the runtime
|
||||
* so the adapter probe runs inside the sandbox the same way a heartbeat
|
||||
* would. The returned `release` callback rolls the lease back when the
|
||||
* route is done.
|
||||
*
|
||||
* The caller MUST always invoke `release()` (typically in a `finally` block).
|
||||
*/
|
||||
async function resolveAdapterTestExecutionContext(input: {
|
||||
companyId: string;
|
||||
adapterType: string;
|
||||
environmentId: string | null;
|
||||
}): Promise<{
|
||||
executionTarget: AdapterExecutionTarget | null;
|
||||
environmentName: string | null;
|
||||
fallbackChecks: AdapterEnvironmentCheck[];
|
||||
sandboxIdentityCheck?: AdapterEnvironmentCheck | null;
|
||||
release: (status?: "released" | "failed") => Promise<void>;
|
||||
}> {
|
||||
const noopRelease = async () => {};
|
||||
|
||||
if (!input.environmentId) {
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: null,
|
||||
fallbackChecks: [],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
const requestedEnvironment = await environmentsSvc.getById(input.environmentId);
|
||||
if (!requestedEnvironment) {
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: null,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_not_found",
|
||||
level: "warn",
|
||||
message: "Selected environment was not found. The test did not run.",
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
// Managed-sandbox-only policy: redirect a Test that would run on the local
|
||||
// host onto the platform-managed sandbox, the same as a real run does
|
||||
// (resolveExecutionWorkspaceEnvironmentId in heartbeat). Without this
|
||||
// redirect the Test probes the local host while the run executes in the
|
||||
// managed sandbox, so a passing Test validates the wrong execution target.
|
||||
// With no active managed sandbox the Test fails closed — never local.
|
||||
let environment = requestedEnvironment;
|
||||
if (requestedEnvironment.driver === "local") {
|
||||
const managedSandboxOnly =
|
||||
(await instanceSettings.getExperimental()).enableManagedSandboxOnly === true;
|
||||
if (managedSandboxOnly) {
|
||||
const managedSandboxEnvironment = await environmentsSvc.findManagedSandboxEnvironment(
|
||||
input.companyId,
|
||||
);
|
||||
if (!managedSandboxEnvironment) {
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: requestedEnvironment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "managed_sandbox_unavailable",
|
||||
level: "error",
|
||||
message:
|
||||
"This instance runs agents only in its platform-managed sandbox, but no active managed sandbox environment exists. The test did not run.",
|
||||
hint: "Restore the managed sandbox environment, then test again.",
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
environment = managedSandboxEnvironment;
|
||||
}
|
||||
}
|
||||
|
||||
if (environment.driver === "local") {
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
if (environment.driver === "ssh") {
|
||||
try {
|
||||
const target = await resolveEnvironmentExecutionTarget({
|
||||
db,
|
||||
companyId: input.companyId,
|
||||
adapterType: input.adapterType,
|
||||
environment: {
|
||||
id: environment.id,
|
||||
driver: environment.driver,
|
||||
config: environment.config ?? null,
|
||||
},
|
||||
leaseMetadata: null,
|
||||
});
|
||||
if (target) {
|
||||
return {
|
||||
executionTarget: target,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_target_unavailable",
|
||||
level: "warn",
|
||||
message:
|
||||
`Could not resolve an execution target for environment "${environment.name}". The test did not run.`,
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_target_failed",
|
||||
level: "warn",
|
||||
message:
|
||||
`Could not connect to environment "${environment.name}" to run the test.`,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// sandbox / plugin / other remote drivers: spin up an ad-hoc lease, realize
|
||||
// the workspace inside the box, and run the same probe SSH uses against
|
||||
// a sandbox execution target wired to the environment runtime.
|
||||
//
|
||||
// We pass `heartbeatRunId: null` because there's no heartbeat run for an
|
||||
// operator-initiated `Test` invocation — the leases table FKs heartbeat
|
||||
// run id to heartbeat_runs.id, and we don't want to manufacture a fake
|
||||
// run row. Cleanup goes through the driver's `releaseRunLease` directly
|
||||
// (by lease record), since the batch helper queries by heartbeatRunId.
|
||||
//
|
||||
// Sandbox tests boot a fresh throwaway sandbox (never resume a retained
|
||||
// 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.
|
||||
const testEnvironment = environment.driver === "sandbox"
|
||||
? {
|
||||
...environment,
|
||||
config: {
|
||||
...(environment.config ?? {}),
|
||||
reuseLease: false,
|
||||
archiveOnRelease: true,
|
||||
},
|
||||
}
|
||||
: environment;
|
||||
let leaseRecord: Awaited<ReturnType<typeof environmentRuntime.acquireRunLease>>;
|
||||
try {
|
||||
leaseRecord = await environmentRuntime.acquireRunLease({
|
||||
companyId: input.companyId,
|
||||
environment: testEnvironment,
|
||||
issueId: null,
|
||||
heartbeatRunId: null,
|
||||
persistedExecutionWorkspace: null,
|
||||
// Re-check the company binding atomically at lease time. The route
|
||||
// guard already rejected a foreign environment, but the binding could
|
||||
// change between the guard check and the lease acquire. This closes
|
||||
// that check-to-lease race so a foreign sandbox never gets a lease.
|
||||
assertCompanyBinding: true,
|
||||
// Apply the active custom-image template so the Test boots with the
|
||||
// operator's captured sandbox customizations and prepared image state,
|
||||
// matching what real agent runs use. Without this the test would
|
||||
// silently fall back to the base image.
|
||||
applyCustomImageTemplate: true,
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_lease_acquire_failed",
|
||||
level: "error",
|
||||
message: `Could not acquire a lease for environment "${environment.name}".`,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
hint: "Check the environment's provider credentials and quota.",
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
const driver = environmentRuntime.getDriver(environment.driver);
|
||||
const releaseLease = async (status: "released" | "failed" = "released") => {
|
||||
try {
|
||||
if (driver) {
|
||||
await driver.releaseRunLease({
|
||||
environment: testEnvironment,
|
||||
lease: leaseRecord.lease,
|
||||
status,
|
||||
});
|
||||
} else {
|
||||
await environmentsSvc.releaseLease(leaseRecord.lease.id, status);
|
||||
}
|
||||
} catch (err) {
|
||||
// Cleanup failures must not mask the test result.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[adapter-test] Failed to release lease ${leaseRecord.lease.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let realizedCwd: string | null = null;
|
||||
try {
|
||||
const realized = await environmentRuntime.realizeWorkspace({
|
||||
environment: testEnvironment,
|
||||
lease: leaseRecord.lease,
|
||||
// No host workspace to copy for a Test invocation; sandbox/plugin
|
||||
// realize implementations use the lease metadata's remoteCwd to
|
||||
// create the working directory inside the box.
|
||||
workspace: {},
|
||||
});
|
||||
realizedCwd =
|
||||
typeof realized.cwd === "string" && realized.cwd.trim().length > 0
|
||||
? realized.cwd.trim()
|
||||
: null;
|
||||
} catch (err) {
|
||||
await releaseLease("failed");
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_workspace_realize_failed",
|
||||
level: "error",
|
||||
message: `Could not realize a workspace inside "${environment.name}".`,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
let target: AdapterExecutionTarget | null;
|
||||
try {
|
||||
// Prefer the cwd the realize step returned; fall back to lease metadata.
|
||||
const leaseMetadataForTarget: Record<string, unknown> | null =
|
||||
realizedCwd
|
||||
? { ...(leaseRecord.lease.metadata ?? {}), remoteCwd: realizedCwd }
|
||||
: (leaseRecord.lease.metadata as Record<string, unknown> | null) ?? null;
|
||||
|
||||
target = await resolveEnvironmentExecutionTarget({
|
||||
db,
|
||||
companyId: input.companyId,
|
||||
adapterType: input.adapterType,
|
||||
environment: {
|
||||
id: testEnvironment.id,
|
||||
driver: testEnvironment.driver,
|
||||
config: testEnvironment.config ?? null,
|
||||
},
|
||||
leaseId: leaseRecord.lease.id,
|
||||
leaseMetadata: leaseMetadataForTarget,
|
||||
lease: leaseRecord.lease,
|
||||
environmentRuntime,
|
||||
});
|
||||
} catch (err) {
|
||||
await releaseLease("failed");
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_target_failed",
|
||||
level: "error",
|
||||
message: `Could not resolve an execution target for "${environment.name}".`,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
await releaseLease("failed");
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_target_unsupported",
|
||||
level: "warn",
|
||||
message:
|
||||
`Adapter "${input.adapterType}" is not allowed in "${environment.name}" environments.`,
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
executionTarget: target,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [],
|
||||
sandboxIdentityCheck: buildSandboxIdentityCheck({
|
||||
environmentName: environment.name,
|
||||
lease: leaseRecord.lease,
|
||||
}),
|
||||
release: releaseLease,
|
||||
};
|
||||
}
|
||||
|
||||
function readMetadataString(metadata: Record<string, unknown>, keys: string[]): string | null {
|
||||
for (const key of keys) {
|
||||
const value = metadata[key];
|
||||
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildSandboxIdentityCheck(input: {
|
||||
environmentName: string;
|
||||
lease: {
|
||||
id: string;
|
||||
provider?: string | null;
|
||||
providerLeaseId?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
}): AdapterEnvironmentCheck {
|
||||
const metadata = input.lease.metadata ?? {};
|
||||
const provider = input.lease.provider ?? readMetadataString(metadata, ["provider"]);
|
||||
const sandboxId = readMetadataString(metadata, ["sandboxId", "sandboxID", "sandbox_id", "id"]);
|
||||
const sandboxName = readMetadataString(metadata, ["sandboxName", "sandbox_name", "name"]);
|
||||
const snapshotRef = readMetadataString(metadata, [
|
||||
"snapshot",
|
||||
"snapshotId",
|
||||
"snapshotID",
|
||||
"snapshotRef",
|
||||
"snapshot_ref",
|
||||
"templateRef",
|
||||
"template_ref",
|
||||
"templateId",
|
||||
"templateID",
|
||||
"image",
|
||||
"imageId",
|
||||
"imageID",
|
||||
"imageRef",
|
||||
"image_ref",
|
||||
]);
|
||||
const templateKind = readMetadataString(metadata, [
|
||||
"templateKind",
|
||||
"template_kind",
|
||||
"templateRefKind",
|
||||
"template_ref_kind",
|
||||
]);
|
||||
const detailParts = [
|
||||
`paperclipLeaseId=${input.lease.id}`,
|
||||
input.lease.providerLeaseId ? `providerLeaseId=${input.lease.providerLeaseId}` : null,
|
||||
provider ? `provider=${provider}` : null,
|
||||
sandboxId ? `sandboxId=${sandboxId}` : null,
|
||||
sandboxName ? `sandboxName=${sandboxName}` : null,
|
||||
snapshotRef ? `${templateKind ? `${templateKind}Ref` : "snapshotOrTemplateRef"}=${snapshotRef}` : null,
|
||||
].filter((part): part is string => Boolean(part));
|
||||
|
||||
return {
|
||||
code: "sandbox_test_identity",
|
||||
level: "info",
|
||||
message: `Environment test identity for "${input.environmentName}".`,
|
||||
detail: detailParts.join("; "),
|
||||
hint: "Use these provider-neutral IDs when comparing model-test output with provider logs or refreshed environment snapshots.",
|
||||
};
|
||||
}
|
||||
|
||||
async function getCurrentUserRedactionOptions() {
|
||||
return {
|
||||
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
|
||||
@@ -3419,105 +3019,6 @@ export function agentRoutes(
|
||||
res.json(detected);
|
||||
});
|
||||
|
||||
// The environment drivers the adapter Test route accepts. A local, SSH, or
|
||||
// sandbox environment can host a probe; a plugin environment cannot.
|
||||
const ADAPTER_TEST_ALLOWED_ENVIRONMENT_DRIVERS = ["local", "ssh", "sandbox"];
|
||||
|
||||
// The fail-closed tenant-binding guard for the adapter Test route. A caller
|
||||
// may name any instance environment by id, so the route must reject an
|
||||
// environment that binds to another company before it resolves secrets,
|
||||
// merges env, resolves the target, leases a sandbox, or runs the adapter
|
||||
// test. The guard checks the company binding BEFORE it validates the status
|
||||
// or the driver, so it never reveals the status or the driver of a foreign
|
||||
// environment. A same-company or an instance-global environment then gets the
|
||||
// shared driver and status validation.
|
||||
async function assertAdapterTestEnvironmentForCompany(
|
||||
companyId: string,
|
||||
environmentId: string,
|
||||
): Promise<void> {
|
||||
const environment = await environmentsSvc.getById(environmentId);
|
||||
if (!environment) {
|
||||
// A missing environment leaks no tenant state. The execution-context
|
||||
// resolver surfaces the existing environment_not_found check.
|
||||
return;
|
||||
}
|
||||
const boundCompanyIds = await environmentsSvc.listBoundCompanyIds(environmentId);
|
||||
if (boundCompanyIds.length > 0 && !boundCompanyIds.includes(companyId)) {
|
||||
throw forbidden("The selected environment belongs to another company.", {
|
||||
code: "environment_company_mismatch",
|
||||
});
|
||||
}
|
||||
await assertEnvironmentSelectionForCompany(environmentsSvc, companyId, environmentId, {
|
||||
allowedDrivers: ADAPTER_TEST_ALLOWED_ENVIRONMENT_DRIVERS,
|
||||
});
|
||||
}
|
||||
|
||||
async function testManagedEnvironment(adapterType: string, context: Parameters<ReturnType<typeof requireServerAdapter>["testEnvironment"]>[0], binding: AiConnectionBinding, managed: Awaited<ReturnType<typeof prepareManagedAiRuntime>>, agentId?: string) {
|
||||
const startedAt = new Date();
|
||||
const managedAiCredentialHome = adapterType === "paperclip_runner" && context.config.provider === "codex" && managed.home
|
||||
? path.join(managed.home, "provider") : undefined;
|
||||
const result = await probeManagedEnvironment(adapterType, { ...context, ...(managedAiCredentialHome ? { managedAiCredentialHome } : {}) }, binding);
|
||||
// A provider rejection invalidates the tested credential generation. A
|
||||
// missing CLI, unavailable environment, or other runtime error does not.
|
||||
if (result.status === "fail" && result.checks.some(check =>
|
||||
check.code === ADAPTER_AUTH_MISSING_CHECK_CODE || /_hello_probe_auth_required$/.test(check.code)
|
||||
|| check.code === "ai_connection_api_key_rejected",
|
||||
)) {
|
||||
await aiConnectionService(db).markAuthenticationFailed({
|
||||
companyId: context.companyId, agentId, runStartedAt: startedAt,
|
||||
attribution: { ...managed.attribution, identity: managed.identity },
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function probeManagedEnvironment(adapterType: string, context: Parameters<ReturnType<typeof requireServerAdapter>["testEnvironment"]>[0], binding: AiConnectionBinding) {
|
||||
await assertManagedAiProjectAuth(context.config, binding.provider, context.executionTarget);
|
||||
const result = await requireServerAdapter(adapterType).testEnvironment(context);
|
||||
if (result.status === "fail") return result;
|
||||
// The resolved method, not binding.method — on a responsible_user binding
|
||||
// that field is wire-compat only and the default connection decides.
|
||||
const resolvedMethod = (context.config as { managedAiConnection?: { method?: string } }).managedAiConnection?.method;
|
||||
// An api_key account does not take the CLI hello probe below: a key
|
||||
// travels as an env var any engine understands, and the engine's own test
|
||||
// above judged whether this runtime can execute with it — the ACP lane
|
||||
// deliberately runs no hello probe when a key is configured. Demanding one
|
||||
// anyway forced the CLI lane, whose probe needs a provider CLI on PATH,
|
||||
// and a clean install has none: that walled off onboarding's API-key path
|
||||
// on exactly the machines the release smoke exists to guard. The account
|
||||
// is still verified live here — the same provider-endpoint check the save
|
||||
// performed — so a key revoked since its save fails adoption rather than
|
||||
// producing an agent that cannot authenticate at runtime. The hello-probe
|
||||
// requirement stays for subscriptions: a stored login is a file layout
|
||||
// only a provider CLI reads, so proving the runtime lane can consume it
|
||||
// takes a real hello turn.
|
||||
if (resolvedMethod === "api_key" && !context.config.managedAiRouting) {
|
||||
const envKey = AI_CONNECTION_CAPABILITIES[binding.provider].methods.api_key?.envKey;
|
||||
const key = envKey ? parseObject(context.config.env)[envKey] : undefined;
|
||||
try {
|
||||
if (typeof key !== "string" || !key) throw unprocessable("The selected account's API key was not available to verify.");
|
||||
await validateAiApiKey(binding.provider, key);
|
||||
result.checks.push({ code: "ai_connection_api_key_reverified", level: "info", message: "The provider verified this API key for adoption." });
|
||||
} catch (error) {
|
||||
result.status = "fail";
|
||||
const rejected = error instanceof HttpError && asRecord(error.details)?.code === "ai_connection_api_key_rejected";
|
||||
result.checks.push({ code: rejected ? "ai_connection_api_key_rejected" : "ai_connection_verification_failed", level: "error", message: error instanceof HttpError ? error.message : "Could not verify the account. Try again." });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (!result.checks.some(check => check.code.includes("hello_probe"))) {
|
||||
const providerAdapter = context.config.managedAiRouting ? aiRoutingHarness(adapterType, context.config.provider, context.config.acpxAgent) : { anthropic: "claude_local", openai: "codex_local", openrouter: "opencode_local", xai: "grok_local", google: "gemini_local" }[binding.provider];
|
||||
const probe = await requireServerAdapter(providerAdapter).testEnvironment({ ...context, adapterType: providerAdapter, config: { ...context.config, engine: "cli" } });
|
||||
result.checks.push(...probe.checks);
|
||||
result.status = probe.status === "fail" ? "fail" : result.status === "warn" || probe.status === "warn" ? "warn" : "pass";
|
||||
}
|
||||
if (!result.checks.some(check => /hello_probe_(passed|succeeded)$/.test(check.code))) {
|
||||
result.status = "fail";
|
||||
result.checks.push({ code: "ai_connection_validation_incomplete", level: "error", message: "The selected account has not completed a provider hello test. Retry before adopting it." });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function validateManagedAgentBinding(req: Request, companyId: string, agentId: string, adapterType: string, config: Record<string, unknown>, binding: AiRuntimeConnectionBinding, environmentId: string | null | undefined, test: boolean, newAgent = false) {
|
||||
if (binding.mode === "router") {
|
||||
const settings = await import("../services/instance-settings.js");
|
||||
@@ -3565,7 +3066,9 @@ export function agentRoutes(
|
||||
try {
|
||||
if (!target.executionTarget && target.fallbackChecks.length > 0) throw unprocessable("The agent environment is not available for adoption");
|
||||
await withManagedAiProbe(db, { companyId, agentId, responsibleUserId: userId, adapterType, binding, config, allowUninstalledPersonal: newAgent, allowUninstalledShared, allowLegacyValidation: true }, async managed => {
|
||||
const result = await testManagedEnvironment(adapterType, { companyId, adapterType, config: managed.config, executionTarget: target.executionTarget, environmentName: target.environmentName }, binding, managed, agentId);
|
||||
const managedAiCredentialHome = adapterType === "paperclip_runner" && asRecord(managed.config)?.provider === "codex" && managed.home
|
||||
? path.join(managed.home, "provider") : undefined;
|
||||
const result = await testManagedEnvironment(adapterType, { companyId, adapterType, config: managed.config, managedAiCredentialHome, executionTarget: target.executionTarget, environmentName: target.environmentName }, binding, managed, agentId);
|
||||
if (result.status === "fail" || result.checks.some(check => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)) throw unprocessable("The selected AI connection failed validation in this agent’s environment. Run the agent test to see the failing checks.", {
|
||||
code: "ai_connection_validation_failed",
|
||||
checks: result.checks.filter(check => check.level === "error" || check.code === ADAPTER_AUTH_MISSING_CHECK_CODE).map(check => ({ code: check.code, level: check.level })),
|
||||
@@ -3788,7 +3291,9 @@ export function agentRoutes(
|
||||
}
|
||||
}
|
||||
const result = aiBinding ? await withManagedAiProbe(db, { companyId, agentId: req.body.agentId ?? "", responsibleUserId: responsibleUserForAiRequest(req), adapterType: type, binding: aiBinding, config: effectiveAdapterConfig, allowUninstalledPersonal: !req.body.agentId, allowUninstalledShared: !req.body.agentId && await canInstallSharedAiConnectionForNewAgent(db, req, companyId, aiBinding) }, async managed => {
|
||||
const tested = await testManagedEnvironment(type, { companyId, adapterType: type, config: managed.config, executionTarget, environmentName }, aiBinding, managed, savedAgentId ?? undefined);
|
||||
const managedAiCredentialHome = type === "paperclip_runner" && asRecord(managed.config)?.provider === "codex" && managed.home
|
||||
? path.join(managed.home, "provider") : undefined;
|
||||
const tested = await testManagedEnvironment(type, { companyId, adapterType: type, config: managed.config, managedAiCredentialHome, executionTarget, environmentName }, aiBinding, managed, savedAgentId ?? undefined);
|
||||
tested.checks.unshift({ code: "ai_connection_tested", level: "info", message: `Tested ${managed.accountName} — ${managed.accountOwnerUserId ? managed.accountOwnerUserId === responsibleUserForAiRequest(req) ? "your personal account" : "the owner’s account authorized for this agent" : "company-shared account"}. Responsible user: ${req.actor.type === "agent" ? responsibleUserForAiRequest(req) ?? "unavailable" : "the signed-in user"}.` });
|
||||
return tested;
|
||||
}) : await adapter.testEnvironment({ companyId, adapterType: type, config: effectiveAdapterConfig, executionTarget, environmentName });
|
||||
@@ -4858,7 +4363,7 @@ export function agentRoutes(
|
||||
const status = requiresApproval ? "pending_approval" : "idle";
|
||||
const managedHireBinding = normalizedHireInput.runtimeConfig?.aiConnection ? aiRuntimeConnectionBindingSchema.parse(normalizedHireInput.runtimeConfig.aiConnection) : undefined;
|
||||
const managedHireConnection = managedHireBinding ? await validateManagedAgentBinding(req, companyId, hiredAgentId, normalizedHireInput.adapterType, normalizedHireInput.adapterConfig, managedHireBinding, normalizedHireInput.defaultEnvironmentId, false, true) : undefined;
|
||||
const createdAgent = await svc.create(
|
||||
const createdAgent = await svcLifecycle.requestHire(
|
||||
companyId,
|
||||
{
|
||||
id: hiredAgentId,
|
||||
@@ -4870,6 +4375,7 @@ export function agentRoutes(
|
||||
{
|
||||
createdByUserId: req.actor.type === "board" ? req.actor.userId : null,
|
||||
runnerResolved: true,
|
||||
responsibleUserId: responsibleUserForAiRequest(req),
|
||||
aiConnectionInstall: managedHireConnection ? { ...managedHireConnection, createdByUserId: responsibleUserForAiRequest(req) } : undefined,
|
||||
claudeLogin: {
|
||||
storedSessionId: hireStoredSessionId ?? null,
|
||||
@@ -5122,7 +4628,7 @@ export function agentRoutes(
|
||||
|
||||
const managedBinding = normalizedRuntimeConfig.aiConnection ? aiRuntimeConnectionBindingSchema.parse(normalizedRuntimeConfig.aiConnection) : undefined;
|
||||
const managedConnection = managedBinding ? await validateManagedAgentBinding(req, companyId, agentId, createInput.adapterType, normalizedAdapterConfig, managedBinding, createInput.defaultEnvironmentId, false, true) : undefined;
|
||||
const createdAgent = await svc.create(
|
||||
const createdAgent = await svcLifecycle.requestHire(
|
||||
companyId,
|
||||
{
|
||||
id: agentId,
|
||||
@@ -5136,6 +4642,7 @@ export function agentRoutes(
|
||||
{
|
||||
createdByUserId: req.actor.type === "board" ? req.actor.userId : null,
|
||||
runnerResolved: true,
|
||||
responsibleUserId: responsibleUserForAiRequest(req),
|
||||
aiConnectionInstall: managedConnection ? { ...managedConnection, createdByUserId: responsibleUserForAiRequest(req) } : undefined,
|
||||
claudeLogin: {
|
||||
storedSessionId: createStoredSessionId ?? null,
|
||||
@@ -5680,6 +5187,9 @@ export function agentRoutes(
|
||||
const patchData = { ...(req.body as Record<string, unknown>) };
|
||||
const runner = patchData.runner as AgentRunnerChoice | undefined;
|
||||
delete patchData.runner;
|
||||
const requestedStatus = patchData.status;
|
||||
delete patchData.status;
|
||||
if (requestedStatus !== undefined && !["paused", "idle", "terminated"].includes(String(requestedStatus))) throw badRequest("Use an agent lifecycle command to change status");
|
||||
const replaceAdapterConfig = patchData.replaceAdapterConfig === true;
|
||||
delete patchData.replaceAdapterConfig;
|
||||
// The apply-existing flag is not an agent column. The server binds the fixed
|
||||
@@ -5871,7 +5381,7 @@ export function agentRoutes(
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const agent = await svc.update(id, patchData, {
|
||||
const updateOptions = {
|
||||
recordRevision: {
|
||||
createdByAgentId: actor.agentId,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
@@ -5884,7 +5394,11 @@ export function agentRoutes(
|
||||
applyExistingWithoutClaim:
|
||||
req.actor.type !== "agent" && applyStoredClaudeLogin,
|
||||
},
|
||||
});
|
||||
};
|
||||
const command = requestedStatus === "paused" ? "pause" : requestedStatus === "idle" ? "resume" : requestedStatus === "terminated" ? "terminate" : null;
|
||||
const agent = command
|
||||
? await svcLifecycle.updateAndTransition(id, command, patchData, updateOptions)
|
||||
: await svc.update(id, patchData, updateOptions);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
@@ -5906,13 +5420,28 @@ export function agentRoutes(
|
||||
res.json(redactAgentRowForResponse(agent));
|
||||
});
|
||||
|
||||
router.post("/agents/:id/lifecycle/retry", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const existing = await getAccessibleAgent(req, res, id);
|
||||
if (!existing) return;
|
||||
await assertCanUpdateAgent(req, existing);
|
||||
const agent = await svcLifecycle.retry(id);
|
||||
if (!agent) { res.status(404).json({ error: "Agent not found" }); return; }
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, { companyId: agent.companyId, actorType: actor.actorType, actorId: actor.actorId,
|
||||
agentId: actor.agentId, runId: actor.runId, agentApiKeyId: actor.agentApiKeyId,
|
||||
action: "agent.lifecycle_retried", entityType: "agent", entityId: agent.id });
|
||||
res.json(redactAgentRowForResponse(agent));
|
||||
});
|
||||
|
||||
router.post("/agents/:id/pause", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
if (!(await getAccessibleAgent(req, res, id))) {
|
||||
return;
|
||||
}
|
||||
const agent = await svc.pause(id);
|
||||
const agent = await svcLifecycle.pauseAgent(id);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
@@ -5945,7 +5474,7 @@ export function agentRoutes(
|
||||
});
|
||||
return;
|
||||
}
|
||||
const agent = await svc.resume(id);
|
||||
const agent = await svcLifecycle.resumeAgent(id);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
@@ -6011,34 +5540,16 @@ export function agentRoutes(
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the linked hire approval (clears it from the inbox) and run the
|
||||
// shared approval side effects: agent activation, budget policy, and the
|
||||
// hire-approved notification. Fall back to direct activation if no open
|
||||
// approval record exists (e.g. agents created before approvals were tracked).
|
||||
const decidedByUserId = req.actor.userId ?? "board";
|
||||
const openApproval = await approvalsSvc.findOpenHireApprovalForAgent(existing.companyId, id);
|
||||
|
||||
let agent: Awaited<ReturnType<typeof svc.getById>> | null = null;
|
||||
if (openApproval) {
|
||||
await approvalsSvc.approve(openApproval.id, decidedByUserId);
|
||||
agent = await svc.getById(id);
|
||||
} else {
|
||||
const approval = await svc.activatePendingApproval(id);
|
||||
if (!approval) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
if (!approval.activated) {
|
||||
res.status(409).json({ error: "Only pending approval agents can be approved" });
|
||||
return;
|
||||
}
|
||||
agent = approval.agent;
|
||||
}
|
||||
|
||||
const decision = await approvalsSvc.approveHire(id, req.actor.userId ?? "board");
|
||||
const agent = decision?.agent;
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
if (!decision.applied) {
|
||||
res.status(409).json({ error: "Only pending approval agents can be approved" });
|
||||
return;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: agent.companyId,
|
||||
@@ -6047,7 +5558,7 @@ export function agentRoutes(
|
||||
action: "agent.approved",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
details: { source: "agent_detail", approvalId: openApproval?.id ?? null },
|
||||
details: { source: "agent_detail", approvalId: decision.approval?.id ?? null },
|
||||
});
|
||||
|
||||
res.json(redactAgentRowForResponse(agent));
|
||||
@@ -6061,23 +5572,9 @@ export function agentRoutes(
|
||||
return;
|
||||
}
|
||||
|
||||
// Terminating an agent that is still awaiting approval is the agent-detail
|
||||
// equivalent of rejecting the hire. When a linked hire approval is still
|
||||
// open, delegate to approvalsSvc.reject(), which both resolves the approval
|
||||
// (clearing the inbox "Approve/Reject" card) and terminates the agent.
|
||||
// Mirror the approve path's branch-or-fallback so we never terminate twice:
|
||||
// reject() already calls agentsSvc.terminate() internally.
|
||||
let agent: Awaited<ReturnType<typeof svc.terminate>> = null;
|
||||
if (existing.status === "pending_approval") {
|
||||
const openApproval = await approvalsSvc.findOpenHireApprovalForAgent(existing.companyId, id);
|
||||
if (openApproval) {
|
||||
await approvalsSvc.reject(openApproval.id, req.actor.userId ?? "board");
|
||||
agent = await svc.getById(id);
|
||||
}
|
||||
}
|
||||
if (!agent) {
|
||||
agent = await svc.terminate(id);
|
||||
}
|
||||
const agent = existing.status === "pending_approval"
|
||||
? (await approvalsSvc.rejectHire(id, req.actor.userId ?? "board"))?.agent
|
||||
: await svcLifecycle.terminateAgent(id);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { validateAiApiKey } from "../services/ai-api-key-test.js";
|
||||
export { validateAiApiKey } from "../services/ai-api-key-test.js";
|
||||
import { supportsLocalAiLogin } from "../services/local-ai-login-policy.js";
|
||||
import { localAiLoginService } from "../services/local-ai-login.js";
|
||||
import { z } from "zod";
|
||||
@@ -133,41 +135,6 @@ export async function canInstallSharedAiConnectionForNewAgent(
|
||||
connection.creator === userId || await accessService(db).hasPermission(companyId, "user", userId, "tools:manage_connections");
|
||||
}
|
||||
|
||||
/** Fixed provider endpoints; credentials are never sent to a caller-supplied URL or through a redirect. */
|
||||
export async function validateAiApiKey(
|
||||
provider: AiProvider,
|
||||
key: string,
|
||||
request: typeof fetch = fetch,
|
||||
) {
|
||||
const endpoints = {
|
||||
anthropic: "https://api.anthropic.com/v1/models?limit=1",
|
||||
openai: "https://api.openai.com/v1/models",
|
||||
openrouter: "https://openrouter.ai/api/v1/key",
|
||||
xai: "https://api.x.ai/v1/models",
|
||||
google: "https://generativelanguage.googleapis.com/v1beta/models?pageSize=1",
|
||||
};
|
||||
let response: Response;
|
||||
try {
|
||||
response = await request(endpoints[provider], {
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(15000),
|
||||
headers:
|
||||
provider === "google" ? { "x-goog-api-key": key } : provider === "anthropic"
|
||||
? { "x-api-key": key, "anthropic-version": "2023-06-01" }
|
||||
: { Authorization: `Bearer ${key}` },
|
||||
});
|
||||
} catch {
|
||||
throw unprocessable("Could not verify the account. Try again.", { code: "ai_connection_verification_failed" });
|
||||
}
|
||||
await response.body?.cancel();
|
||||
if (!response.ok)
|
||||
throw unprocessable(
|
||||
response.status === 401 || response.status === 403
|
||||
? "The provider rejected this API key."
|
||||
: "The provider could not verify this account. Try again.",
|
||||
{ code: response.status === 401 || response.status === 403 ? "ai_connection_api_key_rejected" : "ai_connection_verification_failed" },
|
||||
);
|
||||
}
|
||||
|
||||
export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLocalAiLogin>[0] = {}) {
|
||||
function assertLocalLoginAvailable() {
|
||||
|
||||
@@ -1,52 +1 @@
|
||||
import { unprocessable } from "../errors.js";
|
||||
|
||||
export async function assertEnvironmentSelectionForCompany(
|
||||
environmentsSvc: {
|
||||
getById(environmentId: string): Promise<{
|
||||
id: string;
|
||||
driver: string;
|
||||
status?: string | null;
|
||||
config: Record<string, unknown> | null;
|
||||
} | null>;
|
||||
},
|
||||
_companyId: string,
|
||||
environmentId: string | null | undefined,
|
||||
options?: {
|
||||
allowedDrivers?: string[];
|
||||
allowedSandboxProviders?: string[];
|
||||
},
|
||||
) {
|
||||
if (environmentId === undefined || environmentId === null) return;
|
||||
const environment = await environmentsSvc.getById(environmentId);
|
||||
if (!environment) {
|
||||
throw unprocessable("Environment not found.");
|
||||
}
|
||||
if (environment.status === "archived") {
|
||||
throw unprocessable("Environment is archived.");
|
||||
}
|
||||
if (options?.allowedDrivers && !options.allowedDrivers.includes(environment.driver)) {
|
||||
throw unprocessable(
|
||||
`Environment driver "${environment.driver}" is not allowed here. Allowed drivers: ${options.allowedDrivers.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (environment.driver === "sandbox") {
|
||||
const config = environment.config && typeof environment.config === "object"
|
||||
? environment.config as Record<string, unknown>
|
||||
: {};
|
||||
const provider = typeof config.provider === "string" ? config.provider : "";
|
||||
if (provider === "fake") {
|
||||
throw unprocessable(
|
||||
`Environment sandbox provider "${provider}" is not allowed here. The built-in fake provider is probe-only and cannot execute runs.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
options?.allowedSandboxProviders
|
||||
&& options.allowedSandboxProviders.length > 0
|
||||
&& !options.allowedSandboxProviders.includes(provider)
|
||||
) {
|
||||
throw unprocessable(
|
||||
`Environment sandbox provider "${provider || "unknown"}" is not allowed here. Allowed providers: ${options.allowedSandboxProviders.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
export { assertEnvironmentSelectionForCompany } from "../services/environment-selection.js";
|
||||
@@ -3578,7 +3578,7 @@ registry.registerPath({
|
||||
summary: "Update an agent",
|
||||
request: {
|
||||
params: z.object({ id: z.string() }),
|
||||
body: jsonBody(updateAgentSchema.omit({ permissions: true })),
|
||||
body: jsonBody(updateAgentSchema.omit({ permissions: true, spentMonthlyCents: true })),
|
||||
},
|
||||
responses: {
|
||||
200: r.ok(),
|
||||
@@ -3850,6 +3850,15 @@ registry.registerPath({
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/agents/{id}/lifecycle/retry",
|
||||
tags: ["agents"],
|
||||
summary: "Retry an incomplete agent lifecycle step",
|
||||
request: { params: z.object({ id: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/agents/{id}/pause",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ActivityPublication } from "../types/activity-publication.js";
|
||||
export type { ActivityPublication } from "../types/activity-publication.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
@@ -66,11 +68,6 @@ export interface LogActivityInput {
|
||||
responsibleUserIdOverride?: string | null;
|
||||
}
|
||||
|
||||
export interface ActivityPublication {
|
||||
companyId: string;
|
||||
payload: Record<string, unknown>;
|
||||
pluginEvent: PluginEvent | null;
|
||||
}
|
||||
|
||||
export async function createActivityDetailsRedactor(db: Db) {
|
||||
const currentUserRedactionOptions = {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type { AgentConfigurationPatch, UpdateAgentOptions } from "../lib/agent-records.js";
|
||||
import { conflict } from "../errors.js";
|
||||
import { updateAgentConfiguration } from "../modules/agent-lifecycle/configuration.js";
|
||||
import { createAgentLifecycleEffects } from "./agent-lifecycle.js";
|
||||
|
||||
export function updateAgentConfigurationInTransaction(tx: Db, id: string, data: Omit<AgentConfigurationPatch, "budgetMonthlyCents">, options?: UpdateAgentOptions) {
|
||||
if (Object.prototype.hasOwnProperty.call(data, "budgetMonthlyCents")) {
|
||||
throw conflict("Budget changes require a root lifecycle command");
|
||||
}
|
||||
return updateAgentConfiguration(tx, createAgentLifecycleEffects(), id, data, options);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { assets, agentConfigRevisions, agentTaskSessions, agentRuntimeState, type Db } from "@paperclipai/db";
|
||||
import { normalizeAgentUrlKey } from "@paperclipai/shared";
|
||||
import { normalizePaperclipRunnerAdapterConfig } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { agentRecordQueries, type UpdateAgentOptions, type AgentConfigurationPatch, type AgentConfigurationRecord, isPlainRecord, buildConfigSnapshot,
|
||||
hasConfigPatchFields, changedPendingApprovalConfigFields, diffConfigSnapshot } from "../lib/agent-records.js";
|
||||
import { normalizeAgentPermissions } from "../lib/agent-permissions.js";
|
||||
import type { ActivityPublication } from "../types/activity-publication.js";
|
||||
import { secretService, assertClaudeOAuthBindingInvariant } from "./secrets.js";
|
||||
import { agentCredentialService } from "./agent-credentials.js";
|
||||
import { clearPrimaryAgent } from "./primary-agent.js";
|
||||
import { budgetServiceInTransaction } from "./budgets.js";
|
||||
|
||||
export async function prepareAgentConfiguration(db: Db, existing: AgentConfigurationRecord, data: AgentConfigurationPatch, options?: UpdateAgentOptions): Promise<AgentConfigurationPatch> {
|
||||
const id = existing.id;
|
||||
const { ensureManager, assertNoCycle, assertCompanyShortnameAvailable, assertBuiltInAgentMetadataMutationAllowed } = agentRecordQueries(db);
|
||||
if (existing.status === "pending_approval" && !options?.allowPendingApprovalConfigUpdate) {
|
||||
const changedFields = changedPendingApprovalConfigFields(agentRecordQueries(db).normalizeAgentRow(existing), data);
|
||||
if (changedFields.length > 0) {
|
||||
throw conflict("Pending approval agent configuration cannot be changed before board approval", {
|
||||
code: "pending_approval_agent_config_frozen",
|
||||
agentId: id,
|
||||
fields: changedFields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.reportsTo !== undefined) {
|
||||
if (data.reportsTo) {
|
||||
await ensureManager(existing.companyId, data.reportsTo);
|
||||
}
|
||||
await assertNoCycle(id, data.reportsTo);
|
||||
}
|
||||
|
||||
if (data.name !== undefined) {
|
||||
const previousShortname = normalizeAgentUrlKey(existing.name);
|
||||
const nextShortname = normalizeAgentUrlKey(data.name);
|
||||
if (previousShortname !== nextShortname) {
|
||||
await assertCompanyShortnameAvailable(existing.companyId, data.name, { excludeAgentId: id });
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(data, "metadata")) {
|
||||
assertBuiltInAgentMetadataMutationAllowed(existing.metadata, data.metadata, options);
|
||||
}
|
||||
|
||||
if (data.appearance?.customAvatarAssetId) {
|
||||
const [asset] = await db.select().from(assets).where(and(
|
||||
eq(assets.id, data.appearance.customAvatarAssetId), eq(assets.companyId, existing.companyId),
|
||||
eq(assets.createdByAgentId, id),
|
||||
));
|
||||
if (!asset || asset.contentType !== "image/png" || !asset.objectKey.startsWith(`${existing.companyId}/agent-avatars/${id}/`)) {
|
||||
throw unprocessable("Use the avatar upload endpoint to set this agent's image");
|
||||
}
|
||||
}
|
||||
const normalizedPatch = { ...data } as AgentConfigurationPatch;
|
||||
if (data.permissions !== undefined) {
|
||||
normalizedPatch.permissions = normalizeAgentPermissions(data.permissions);
|
||||
}
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(normalizedPatch, "adapterConfig") &&
|
||||
isPlainRecord(normalizedPatch.adapterConfig)
|
||||
) {
|
||||
const normalizedAdapterConfig = await secretService(db).normalizeAdapterConfigForPersistence(
|
||||
existing.companyId,
|
||||
normalizedPatch.adapterConfig,
|
||||
{ adapterType: (normalizedPatch.adapterType ?? existing.adapterType) as string },
|
||||
);
|
||||
normalizedPatch.adapterConfig = normalizePaperclipRunnerAdapterConfig(
|
||||
(normalizedPatch.adapterType ?? existing.adapterType) as string,
|
||||
normalizedAdapterConfig,
|
||||
);
|
||||
} else if (
|
||||
Object.prototype.hasOwnProperty.call(normalizedPatch, "adapterType")
|
||||
&& isPlainRecord(existing.adapterConfig)
|
||||
) {
|
||||
normalizedPatch.adapterConfig = normalizePaperclipRunnerAdapterConfig(
|
||||
normalizedPatch.adapterType as string,
|
||||
existing.adapterConfig,
|
||||
);
|
||||
}
|
||||
return normalizedPatch;
|
||||
}
|
||||
|
||||
export async function completeAgentConfiguration(txDb: Db, existing: AgentConfigurationRecord, updated: AgentConfigurationRecord, normalizedPatch: AgentConfigurationPatch, options: UpdateAgentOptions | undefined, publications: ActivityPublication[]) {
|
||||
const id = updated.id;
|
||||
const shouldRecordRevision = Boolean(options?.recordRevision) && hasConfigPatchFields(normalizedPatch);
|
||||
const beforeConfig = shouldRecordRevision
|
||||
? buildConfigSnapshot(agentRecordQueries(txDb).normalizeAgentRow(existing))
|
||||
: null;
|
||||
if (updated.status === "terminated") {
|
||||
await clearPrimaryAgent(txDb, updated.companyId, id);
|
||||
}
|
||||
|
||||
const priorAdapterConfig = isPlainRecord(existing.adapterConfig) ? existing.adapterConfig : {};
|
||||
const afterConfig = isPlainRecord(updated.adapterConfig) ? updated.adapterConfig : {};
|
||||
const changedExecution = updated.adapterType !== existing.adapterType
|
||||
|| (updated.adapterType === "paperclip_runner" && ["provider", "acpxAgent", "model"].some(
|
||||
(key) => priorAdapterConfig[key] !== afterConfig[key],
|
||||
));
|
||||
if (changedExecution) {
|
||||
await txDb.delete(agentTaskSessions).where(and(eq(agentTaskSessions.companyId, existing.companyId), eq(agentTaskSessions.agentId, id)));
|
||||
await txDb.update(agentRuntimeState).set({ adapterType: updated.adapterType, sessionId: null, stateJson: {}, updatedAt: new Date() })
|
||||
.where(and(eq(agentRuntimeState.companyId, existing.companyId), eq(agentRuntimeState.agentId, id)));
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(normalizedPatch, "adapterConfig")) {
|
||||
await agentCredentialService(txDb).enforceClaudeOAuthBindingClaim(txDb, {
|
||||
companyId: existing.companyId, consume: false, environmentId: null,
|
||||
childAdapterConfig: updated.adapterConfig, claudeLogin: options?.claudeLogin,
|
||||
decision: assertClaudeOAuthBindingInvariant({ adapterType: updated.adapterType,
|
||||
nextConfig: updated.adapterConfig, priorConfig: existing.adapterConfig }),
|
||||
});
|
||||
await agentCredentialService(txDb).syncAgentSecretBindings(
|
||||
updated, txDb,
|
||||
existing.adapterConfig,
|
||||
options?.recordRevision,
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedPatch.budgetMonthlyCents !== undefined) {
|
||||
await budgetServiceInTransaction(txDb, publications).upsertPolicy(existing.companyId, {
|
||||
scopeType: "agent", scopeId: id, amount: normalizedPatch.budgetMonthlyCents,
|
||||
isActive: normalizedPatch.budgetMonthlyCents > 0, windowKind: "calendar_month_utc",
|
||||
}, options?.recordRevision?.createdByUserId ?? null);
|
||||
}
|
||||
const normalizedUpdated = await agentRecordQueries(txDb).getById(updated.id);
|
||||
if (!normalizedUpdated) {
|
||||
throw notFound("Agent not found");
|
||||
}
|
||||
|
||||
if (shouldRecordRevision && beforeConfig) {
|
||||
const afterConfig = buildConfigSnapshot(normalizedUpdated);
|
||||
const changedKeys = diffConfigSnapshot(beforeConfig, afterConfig);
|
||||
if (changedKeys.length > 0) {
|
||||
await txDb.insert(agentConfigRevisions).values({
|
||||
companyId: normalizedUpdated.companyId,
|
||||
agentId: normalizedUpdated.id,
|
||||
createdByAgentId: options?.recordRevision?.createdByAgentId ?? null,
|
||||
createdByUserId: options?.recordRevision?.createdByUserId ?? null,
|
||||
source: options?.recordRevision?.source ?? "patch",
|
||||
rolledBackFromRevisionId: options?.recordRevision?.rolledBackFromRevisionId ?? null,
|
||||
changedKeys,
|
||||
beforeConfig: beforeConfig as unknown as Record<string, unknown>,
|
||||
afterConfig: afterConfig as unknown as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, type Db } from "@paperclipai/db";
|
||||
import { collectSecretRefs, collectUserSecretRefs, syncAgentAdapterEnvBindings } from "./agent-secret-bindings.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { claudeOAuthBindingsMatchExactly, claudeOAuthClaimRejectedError,
|
||||
CLAUDE_LOCAL_ADAPTER_TYPE, readClaudeOAuthBinding, secretService, type ClaudeOAuthBindingInvariantDecision } from "./secrets.js";
|
||||
import { createDbSetupTokenCleanupStore } from "./setup-token-session.js";
|
||||
import type { ClaudeLoginContext, RevisionMetadata } from "../lib/agent-records.js";
|
||||
|
||||
export function agentCredentialService(db: Db) {
|
||||
const secretsSvc = secretService(db);
|
||||
async function syncAgentSecretBindings(
|
||||
agent: { id: string; companyId: string; adapterConfig: unknown },
|
||||
dbClient: Db = db,
|
||||
previousAdapterConfig: unknown = null,
|
||||
actor: RevisionMetadata = {},
|
||||
) {
|
||||
const scopedSecretsSvc = dbClient === db ? secretsSvc : secretService(dbClient);
|
||||
await syncAgentAdapterEnvBindings({
|
||||
secretsSvc: scopedSecretsSvc,
|
||||
companyId: agent.companyId,
|
||||
agentId: agent.id,
|
||||
adapterConfig: agent.adapterConfig,
|
||||
});
|
||||
const previousRefs = new Set([
|
||||
...collectSecretRefs(previousAdapterConfig).map((ref) => `secret:${ref.secretId}:${ref.configPath}`),
|
||||
...collectUserSecretRefs(previousAdapterConfig).map((ref) => `user:${ref.definitionKey}:${ref.configPath}`),
|
||||
]);
|
||||
const createdRefs = [
|
||||
...collectSecretRefs(agent.adapterConfig).map((ref) => ({
|
||||
key: `secret:${ref.secretId}:${ref.configPath}`,
|
||||
configPath: ref.configPath,
|
||||
bindingType: "secret_ref",
|
||||
secretId: ref.secretId,
|
||||
definitionKey: null,
|
||||
})),
|
||||
...collectUserSecretRefs(agent.adapterConfig).map((ref) => ({
|
||||
key: `user:${ref.definitionKey}:${ref.configPath}`,
|
||||
configPath: ref.configPath,
|
||||
bindingType: "user_secret_ref",
|
||||
secretId: null,
|
||||
definitionKey: ref.definitionKey,
|
||||
})),
|
||||
].filter((ref) => !previousRefs.has(ref.key));
|
||||
const actorType = actor.createdByUserId ? "user" as const : actor.createdByAgentId ? "agent" as const : "system" as const;
|
||||
const actorId = actor.createdByUserId ?? actor.createdByAgentId ?? "system";
|
||||
for (const ref of createdRefs) {
|
||||
await logActivity(dbClient, {
|
||||
companyId: agent.companyId,
|
||||
actorType,
|
||||
actorId,
|
||||
agentId: actor.createdByAgentId ?? null,
|
||||
action: "secret.binding.created",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
details: {
|
||||
targetType: "agent",
|
||||
targetId: agent.id,
|
||||
configPath: ref.configPath,
|
||||
bindingType: ref.bindingType,
|
||||
secretId: ref.secretId,
|
||||
definitionKey: ref.definitionKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces the Claude OAuth binding claim inside a write transaction. It runs
|
||||
* after {@link assertClaudeOAuthBindingInvariant} decided that the write
|
||||
* introduces or keeps the fixed binding.
|
||||
*
|
||||
* When the write introduces the fixed binding:
|
||||
* * A create or hire path (`consume: true`) consumes a stored-session claim
|
||||
* with one conditional write. It builds the claim scope from the company,
|
||||
* the owner user, the fixed adapter, the environment, and the
|
||||
* `storedSessionId`. It inserts the binding only when the write returns one
|
||||
* row; otherwise it raises the fixed claim error, which rolls back the
|
||||
* whole transaction and inserts no binding.
|
||||
* * An update, approval, or rollback path (`consume: false`) carries no
|
||||
* claim, so it raises the same fixed claim error at once.
|
||||
*
|
||||
* The user-actor apply-existing path (`applyExistingWithoutClaim`) binds the
|
||||
* fixed reference with no login round trip. The route sets the flag only for
|
||||
* an authenticated user actor and derives the owner from that actor. The gate
|
||||
* permits the no-claim bind only when the owner already has a stored value for
|
||||
* the company. It reads the owner value status; it reads no token. A missing
|
||||
* owner or a missing stored value raises the same fixed claim error, so the
|
||||
* caller cannot tell the reasons apart.
|
||||
*
|
||||
* The hire-inheritance path (`inheritedFromAgentId`) binds the fixed
|
||||
* reference with no login round trip and no stored owner value, because the
|
||||
* owning user resolves per run, not from a value stored against this agent.
|
||||
* The route copies the parent's reference onto the child before this
|
||||
* transaction starts, so a concurrent version change on the parent can
|
||||
* leave the child holding a stale version. The gate re-reads the named
|
||||
* parent agent inside this transaction and permits the bind only when the
|
||||
* parent exists, is in the same company, is a `claude_local` agent, and its
|
||||
* current reference matches the child's copied reference exactly, including
|
||||
* the version selector. The gate locks the parent row with `SELECT ...
|
||||
* FOR UPDATE` before it reads the reference. The lock blocks a concurrent
|
||||
* credential rotation on the same parent row until this transaction
|
||||
* commits or rolls back, so the compare-and-bind check stays atomic with
|
||||
* the parent's current state. The route derives the parent identifier
|
||||
* from the authenticated agent actor, never from the request body, so the
|
||||
* gate treats it as a claim to verify, not a trusted value.
|
||||
*
|
||||
* A controlled internal override skips the claim for a migration or an
|
||||
* administrator repair. The function creates the fixed user-secret definition
|
||||
* before the caller runs the declaration synchronization, so the synchronized
|
||||
* declaration always references an existing definition.
|
||||
*/
|
||||
async function enforceClaudeOAuthBindingClaim(
|
||||
txDb: Db,
|
||||
input: {
|
||||
companyId: string;
|
||||
decision: ClaudeOAuthBindingInvariantDecision;
|
||||
consume: boolean;
|
||||
environmentId: string | null;
|
||||
claudeLogin?: ClaudeLoginContext;
|
||||
/**
|
||||
* The adapter config the write is about to persist. The
|
||||
* `inheritedFromAgentId` path reads the child's copied
|
||||
* `CLAUDE_CODE_OAUTH_TOKEN` reference from it, to compare against the
|
||||
* parent's current reference.
|
||||
*/
|
||||
childAdapterConfig?: unknown;
|
||||
},
|
||||
): Promise<void> {
|
||||
const ownerUserId = input.claudeLogin?.ownerUserId ?? null;
|
||||
if (input.decision.introducesBinding && !input.claudeLogin?.allowInternalBindingOverride) {
|
||||
if (input.claudeLogin?.applyExistingWithoutClaim) {
|
||||
// The user-actor apply-existing path. The route derived the owner from
|
||||
// the authenticated user actor. The gate binds the fixed reference only
|
||||
// when that owner already has a stored value. It reads no token.
|
||||
if (!ownerUserId) {
|
||||
throw claudeOAuthClaimRejectedError();
|
||||
}
|
||||
const stored = await secretService(txDb).readClaudeOAuthUserSecretStatus(
|
||||
input.companyId,
|
||||
ownerUserId,
|
||||
);
|
||||
if (!stored) {
|
||||
throw claudeOAuthClaimRejectedError();
|
||||
}
|
||||
} else if (input.claudeLogin?.inheritedFromAgentId) {
|
||||
// The hire-inheritance path. Re-read the named parent inside this
|
||||
// transaction; a caller-supplied identifier never binds on its own.
|
||||
// Compare the parent's current reference against the reference
|
||||
// already copied onto the child, including the version selector, so
|
||||
// a concurrent version change on the parent cannot leave the child
|
||||
// bound to a stale version.
|
||||
const parentId = input.claudeLogin.inheritedFromAgentId;
|
||||
const parent = await txDb
|
||||
.select({
|
||||
companyId: agents.companyId,
|
||||
adapterType: agents.adapterType,
|
||||
adapterConfig: agents.adapterConfig,
|
||||
})
|
||||
.from(agents)
|
||||
.where(eq(agents.id, parentId))
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const parentBinding = readClaudeOAuthBinding(parent?.adapterConfig ?? null);
|
||||
const childBinding = readClaudeOAuthBinding(input.childAdapterConfig ?? null);
|
||||
if (
|
||||
!parent ||
|
||||
parent.companyId !== input.companyId ||
|
||||
parent.adapterType !== CLAUDE_LOCAL_ADAPTER_TYPE ||
|
||||
!claudeOAuthBindingsMatchExactly(parentBinding, childBinding)
|
||||
) {
|
||||
throw claudeOAuthClaimRejectedError();
|
||||
}
|
||||
} else if (!input.consume) {
|
||||
throw claudeOAuthClaimRejectedError();
|
||||
} else {
|
||||
const consumed = await createDbSetupTokenCleanupStore(txDb).consumeStoredClaim({
|
||||
sessionId: input.claudeLogin?.storedSessionId ?? "",
|
||||
companyId: input.companyId,
|
||||
ownerUserId: ownerUserId ?? "",
|
||||
adapterType: CLAUDE_LOCAL_ADAPTER_TYPE,
|
||||
});
|
||||
if (!consumed) {
|
||||
throw claudeOAuthClaimRejectedError();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (input.decision.introducesBinding || input.decision.keepsBinding) {
|
||||
// Create the fixed definition before declaration synchronization.
|
||||
await secretService(txDb).ensureClaudeOAuthUserSecretDefinition(input.companyId, {
|
||||
userId: ownerUserId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { syncAgentSecretBindings, enforceClaudeOAuthBindingClaim };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { and, eq, or, sql } from "drizzle-orm";
|
||||
import { agents, agentApiKeys, agentRuntimeState, agentTaskSessions, agentWakeupRequests, activityLog,
|
||||
budgetReservations, heartbeatRunEvents, heartbeatRuns, issueExecutionDecisions, issues, issueComments,
|
||||
principalPermissionGrants, type Db } from "@paperclipai/db";
|
||||
import { conflict } from "../errors.js";
|
||||
import { issueThreadInteractionService } from "./issue-thread-interactions.js";
|
||||
|
||||
export async function deleteAgentDependencies(tx: Db, companyId: string, id: string) {
|
||||
const [decisionHold] = await tx.select({ id: budgetReservations.id }).from(budgetReservations).where(and(
|
||||
eq(budgetReservations.companyId, companyId), eq(budgetReservations.agentId, id),
|
||||
eq(budgetReservations.state, "held"), sql`${budgetReservations.decisionInvocationId} is not null`,
|
||||
)).limit(1);
|
||||
if (decisionHold) throw conflict("Wait for active decisions or resolve their unknown charges in Costs before deleting this agent", {
|
||||
code: "agent_decision_accounting_pending",
|
||||
});
|
||||
await issueThreadInteractionService(tx).cancelPendingForDeletedAddressee(companyId, id);
|
||||
await tx.update(agents).set({ reportsTo: null }).where(eq(agents.reportsTo, id));
|
||||
await tx
|
||||
.update(issues)
|
||||
.set({ assigneeAgentId: null, createdByAgentId: null })
|
||||
.where(or(eq(issues.assigneeAgentId, id), eq(issues.createdByAgentId, id)));
|
||||
await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.agentId, id));
|
||||
await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.agentId, id));
|
||||
await tx.delete(activityLog).where(
|
||||
or(
|
||||
eq(activityLog.agentId, id),
|
||||
sql`${activityLog.runId} in (select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.agentId} = ${id})`,
|
||||
),
|
||||
);
|
||||
await tx.delete(issueExecutionDecisions).where(eq(issueExecutionDecisions.actorAgentId, id));
|
||||
await tx.delete(issueComments).where(eq(issueComments.authorAgentId, id));
|
||||
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.agentId, id));
|
||||
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, id));
|
||||
await tx.delete(agentApiKeys).where(eq(agentApiKeys.agentId, id));
|
||||
await tx.delete(agentRuntimeState).where(eq(agentRuntimeState.agentId, id));
|
||||
await tx.delete(principalPermissionGrants).where(and(
|
||||
eq(principalPermissionGrants.companyId, companyId),
|
||||
eq(principalPermissionGrants.principalType, "agent"),
|
||||
eq(principalPermissionGrants.principalId, id),
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
import type { AdapterEnvironmentCheck } from "@paperclipai/adapter-utils";
|
||||
import { parseObject } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { aiRoutingHarness, ADAPTER_AUTH_MISSING_CHECK_CODE, AI_CONNECTION_CAPABILITIES, type AiConnectionBinding } from "@paperclipai/shared";
|
||||
import { requireServerAdapter } from "../adapters/index.js";
|
||||
import { forbidden, HttpError, unprocessable } from "../errors.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import { environmentService } from "./environments.js";
|
||||
import { environmentRuntimeService } from "./environment-runtime.js";
|
||||
import { resolveEnvironmentExecutionTarget } from "./environment-execution-target.js";
|
||||
import { prepareManagedAiRuntime, assertManagedAiProjectAuth } from "./ai-connection-runtime.js";
|
||||
import { aiConnectionService } from "./ai-connections.js";
|
||||
import { validateAiApiKey } from "./ai-api-key-test.js";
|
||||
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
import { assertEnvironmentSelectionForCompany } from "./environment-selection.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);
|
||||
const environmentsSvc = environmentService(db);
|
||||
const environmentRuntime = environmentRuntimeService(db, { pluginWorkerManager });
|
||||
// A null agent override inherits the instance default, just like dispatch.
|
||||
// Resolve this before secrets or probes so a default remote environment can
|
||||
// never accidentally validate the account on the control-plane host.
|
||||
async function resolveAdapterTestEnvironmentId(companyId: string, environmentId: string | null | undefined) {
|
||||
if (environmentId) return environmentId;
|
||||
const settings = await instanceSettings.get();
|
||||
if (settings.defaultEnvironmentId) return settings.defaultEnvironmentId;
|
||||
if ((await instanceSettings.getExperimental()).enableManagedSandboxOnly === true) {
|
||||
const managed = await environmentsSvc.findManagedSandboxEnvironment(companyId);
|
||||
if (!managed) {
|
||||
throw unprocessable("The managed sandbox is unavailable. Restore Paperclip Computer and retry.", {
|
||||
code: "managed_sandbox_unavailable",
|
||||
});
|
||||
}
|
||||
return managed.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the execution target the adapter should run its test probes against.
|
||||
*
|
||||
* - No environmentId / local environment → returns a local target so the
|
||||
* adapter probes the Paperclip host (legacy behavior).
|
||||
* - SSH environment → builds an SSH execution target from the environment
|
||||
* config so the adapter probes the remote box. No lease is required:
|
||||
* the SSH spec is fully derived from the saved environment config.
|
||||
* - Sandbox / plugin environments → acquires an ad-hoc lease, realizes the
|
||||
* workspace, and resolves a sandbox execution target wired to the runtime
|
||||
* so the adapter probe runs inside the sandbox the same way a heartbeat
|
||||
* would. The returned `release` callback rolls the lease back when the
|
||||
* route is done.
|
||||
*
|
||||
* The caller MUST always invoke `release()` (typically in a `finally` block).
|
||||
*/
|
||||
async function resolveAdapterTestExecutionContext(input: {
|
||||
agentId?: string;
|
||||
companyId: string;
|
||||
adapterType: string;
|
||||
environmentId: string | null;
|
||||
}): Promise<{
|
||||
executionTarget: AdapterExecutionTarget | null;
|
||||
environmentName: string | null;
|
||||
fallbackChecks: AdapterEnvironmentCheck[];
|
||||
sandboxIdentityCheck?: AdapterEnvironmentCheck | null;
|
||||
release: (status?: "released" | "failed") => Promise<void>;
|
||||
}> {
|
||||
const noopRelease = async () => {};
|
||||
|
||||
if (!input.environmentId) {
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: null,
|
||||
fallbackChecks: [],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
const requestedEnvironment = await environmentsSvc.getById(input.environmentId);
|
||||
if (!requestedEnvironment) {
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: null,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_not_found",
|
||||
level: "warn",
|
||||
message: "Selected environment was not found. The test did not run.",
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
// Managed-sandbox-only policy: redirect a Test that would run on the local
|
||||
// host onto the platform-managed sandbox, the same as a real run does
|
||||
// (resolveExecutionWorkspaceEnvironmentId in heartbeat). Without this
|
||||
// redirect the Test probes the local host while the run executes in the
|
||||
// managed sandbox, so a passing Test validates the wrong execution target.
|
||||
// With no active managed sandbox the Test fails closed — never local.
|
||||
let environment = requestedEnvironment;
|
||||
if (requestedEnvironment.driver === "local") {
|
||||
const managedSandboxOnly =
|
||||
(await instanceSettings.getExperimental()).enableManagedSandboxOnly === true;
|
||||
if (managedSandboxOnly) {
|
||||
const managedSandboxEnvironment = await environmentsSvc.findManagedSandboxEnvironment(
|
||||
input.companyId,
|
||||
);
|
||||
if (!managedSandboxEnvironment) {
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: requestedEnvironment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "managed_sandbox_unavailable",
|
||||
level: "error",
|
||||
message:
|
||||
"This instance runs agents only in its platform-managed sandbox, but no active managed sandbox environment exists. The test did not run.",
|
||||
hint: "Restore the managed sandbox environment, then test again.",
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
environment = managedSandboxEnvironment;
|
||||
}
|
||||
}
|
||||
|
||||
if (environment.driver === "local") {
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
if (environment.driver === "ssh") {
|
||||
try {
|
||||
const target = await resolveEnvironmentExecutionTarget({
|
||||
db,
|
||||
companyId: input.companyId,
|
||||
adapterType: input.adapterType,
|
||||
environment: {
|
||||
id: environment.id,
|
||||
driver: environment.driver,
|
||||
config: environment.config ?? null,
|
||||
},
|
||||
leaseMetadata: null,
|
||||
});
|
||||
if (target) {
|
||||
return {
|
||||
executionTarget: target,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_target_unavailable",
|
||||
level: "warn",
|
||||
message:
|
||||
`Could not resolve an execution target for environment "${environment.name}". The test did not run.`,
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_target_failed",
|
||||
level: "warn",
|
||||
message:
|
||||
`Could not connect to environment "${environment.name}" to run the test.`,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// sandbox / plugin / other remote drivers: spin up an ad-hoc lease, realize
|
||||
// the workspace inside the box, and run the same probe SSH uses against
|
||||
// a sandbox execution target wired to the environment runtime.
|
||||
//
|
||||
// We pass `heartbeatRunId: null` because there's no heartbeat run for an
|
||||
// operator-initiated `Test` invocation — the leases table FKs heartbeat
|
||||
// run id to heartbeat_runs.id, and we don't want to manufacture a fake
|
||||
// run row. Cleanup goes through the driver's `releaseRunLease` directly
|
||||
// (by lease record), since the batch helper queries by heartbeatRunId.
|
||||
//
|
||||
// Sandbox tests boot a fresh throwaway sandbox (never resume a retained
|
||||
// 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.
|
||||
const testEnvironment = environment.driver === "sandbox"
|
||||
? {
|
||||
...environment,
|
||||
config: {
|
||||
...(environment.config ?? {}),
|
||||
reuseLease: false,
|
||||
archiveOnRelease: true,
|
||||
},
|
||||
}
|
||||
: environment;
|
||||
let leaseRecord: Awaited<ReturnType<typeof environmentRuntime.acquireRunLease>>;
|
||||
try {
|
||||
leaseRecord = await environmentRuntime.acquireRunLease({
|
||||
companyId: input.companyId,
|
||||
agentId: input.agentId,
|
||||
environment: testEnvironment,
|
||||
issueId: null,
|
||||
heartbeatRunId: null,
|
||||
persistedExecutionWorkspace: null,
|
||||
// Re-check the company binding atomically at lease time. The route
|
||||
// guard already rejected a foreign environment, but the binding could
|
||||
// change between the guard check and the lease acquire. This closes
|
||||
// that check-to-lease race so a foreign sandbox never gets a lease.
|
||||
assertCompanyBinding: true,
|
||||
// Apply the active custom-image template so the Test boots with the
|
||||
// operator's captured sandbox customizations and prepared image state,
|
||||
// matching what real agent runs use. Without this the test would
|
||||
// silently fall back to the base image.
|
||||
applyCustomImageTemplate: true,
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_lease_acquire_failed",
|
||||
level: "error",
|
||||
message: `Could not acquire a lease for environment "${environment.name}".`,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
hint: "Check the environment's provider credentials and quota.",
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
const driver = environmentRuntime.getDriver(environment.driver);
|
||||
const releaseLease = async (status: "released" | "failed" = "released") => {
|
||||
try {
|
||||
if (driver) {
|
||||
await driver.releaseRunLease({
|
||||
environment: testEnvironment,
|
||||
lease: leaseRecord.lease,
|
||||
status,
|
||||
});
|
||||
} else {
|
||||
await environmentsSvc.releaseLease(leaseRecord.lease.id, status);
|
||||
}
|
||||
} catch (err) {
|
||||
// Cleanup failures must not mask the test result.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[adapter-test] Failed to release lease ${leaseRecord.lease.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let realizedCwd: string | null = null;
|
||||
try {
|
||||
const realized = await environmentRuntime.realizeWorkspace({
|
||||
environment: testEnvironment,
|
||||
lease: leaseRecord.lease,
|
||||
// No host workspace to copy for a Test invocation; sandbox/plugin
|
||||
// realize implementations use the lease metadata's remoteCwd to
|
||||
// create the working directory inside the box.
|
||||
workspace: {},
|
||||
});
|
||||
realizedCwd =
|
||||
typeof realized.cwd === "string" && realized.cwd.trim().length > 0
|
||||
? realized.cwd.trim()
|
||||
: null;
|
||||
} catch (err) {
|
||||
await releaseLease("failed");
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_workspace_realize_failed",
|
||||
level: "error",
|
||||
message: `Could not realize a workspace inside "${environment.name}".`,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
let target: AdapterExecutionTarget | null;
|
||||
try {
|
||||
// Prefer the cwd the realize step returned; fall back to lease metadata.
|
||||
const leaseMetadataForTarget: Record<string, unknown> | null =
|
||||
realizedCwd
|
||||
? { ...(leaseRecord.lease.metadata ?? {}), remoteCwd: realizedCwd }
|
||||
: (leaseRecord.lease.metadata as Record<string, unknown> | null) ?? null;
|
||||
|
||||
target = await resolveEnvironmentExecutionTarget({
|
||||
db,
|
||||
companyId: input.companyId,
|
||||
adapterType: input.adapterType,
|
||||
environment: {
|
||||
id: testEnvironment.id,
|
||||
driver: testEnvironment.driver,
|
||||
config: testEnvironment.config ?? null,
|
||||
},
|
||||
leaseId: leaseRecord.lease.id,
|
||||
leaseMetadata: leaseMetadataForTarget,
|
||||
lease: leaseRecord.lease,
|
||||
environmentRuntime,
|
||||
});
|
||||
} catch (err) {
|
||||
await releaseLease("failed");
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_target_failed",
|
||||
level: "error",
|
||||
message: `Could not resolve an execution target for "${environment.name}".`,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
await releaseLease("failed");
|
||||
return {
|
||||
executionTarget: null,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [
|
||||
{
|
||||
code: "environment_target_unsupported",
|
||||
level: "warn",
|
||||
message:
|
||||
`Adapter "${input.adapterType}" is not allowed in "${environment.name}" environments.`,
|
||||
},
|
||||
],
|
||||
release: noopRelease,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
executionTarget: target,
|
||||
environmentName: environment.name,
|
||||
fallbackChecks: [],
|
||||
sandboxIdentityCheck: buildSandboxIdentityCheck({
|
||||
environmentName: environment.name,
|
||||
lease: leaseRecord.lease,
|
||||
}),
|
||||
release: releaseLease,
|
||||
};
|
||||
}
|
||||
|
||||
function readMetadataString(metadata: Record<string, unknown>, keys: string[]): string | null {
|
||||
for (const key of keys) {
|
||||
const value = metadata[key];
|
||||
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildSandboxIdentityCheck(input: {
|
||||
environmentName: string;
|
||||
lease: {
|
||||
id: string;
|
||||
provider?: string | null;
|
||||
providerLeaseId?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
}): AdapterEnvironmentCheck {
|
||||
const metadata = input.lease.metadata ?? {};
|
||||
const provider = input.lease.provider ?? readMetadataString(metadata, ["provider"]);
|
||||
const sandboxId = readMetadataString(metadata, ["sandboxId", "sandboxID", "sandbox_id", "id"]);
|
||||
const sandboxName = readMetadataString(metadata, ["sandboxName", "sandbox_name", "name"]);
|
||||
const snapshotRef = readMetadataString(metadata, [
|
||||
"snapshot",
|
||||
"snapshotId",
|
||||
"snapshotID",
|
||||
"snapshotRef",
|
||||
"snapshot_ref",
|
||||
"templateRef",
|
||||
"template_ref",
|
||||
"templateId",
|
||||
"templateID",
|
||||
"image",
|
||||
"imageId",
|
||||
"imageID",
|
||||
"imageRef",
|
||||
"image_ref",
|
||||
]);
|
||||
const templateKind = readMetadataString(metadata, [
|
||||
"templateKind",
|
||||
"template_kind",
|
||||
"templateRefKind",
|
||||
"template_ref_kind",
|
||||
]);
|
||||
const detailParts = [
|
||||
`paperclipLeaseId=${input.lease.id}`,
|
||||
input.lease.providerLeaseId ? `providerLeaseId=${input.lease.providerLeaseId}` : null,
|
||||
provider ? `provider=${provider}` : null,
|
||||
sandboxId ? `sandboxId=${sandboxId}` : null,
|
||||
sandboxName ? `sandboxName=${sandboxName}` : null,
|
||||
snapshotRef ? `${templateKind ? `${templateKind}Ref` : "snapshotOrTemplateRef"}=${snapshotRef}` : null,
|
||||
].filter((part): part is string => Boolean(part));
|
||||
|
||||
return {
|
||||
code: "sandbox_test_identity",
|
||||
level: "info",
|
||||
message: `Environment test identity for "${input.environmentName}".`,
|
||||
detail: detailParts.join("; "),
|
||||
hint: "Use these provider-neutral IDs when comparing model-test output with provider logs or refreshed environment snapshots.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// The environment drivers the adapter Test route accepts. A local, SSH, or
|
||||
// sandbox environment can host a probe; a plugin environment cannot.
|
||||
const ADAPTER_TEST_ALLOWED_ENVIRONMENT_DRIVERS = ["local", "ssh", "sandbox"];
|
||||
|
||||
// The fail-closed tenant-binding guard for the adapter Test route. A caller
|
||||
// may name any instance environment by id, so the route must reject an
|
||||
// environment that binds to another company before it resolves secrets,
|
||||
// merges env, resolves the target, leases a sandbox, or runs the adapter
|
||||
// test. The guard checks the company binding BEFORE it validates the status
|
||||
// or the driver, so it never reveals the status or the driver of a foreign
|
||||
// environment. A same-company or an instance-global environment then gets the
|
||||
// shared driver and status validation.
|
||||
async function assertAdapterTestEnvironmentForCompany(
|
||||
companyId: string,
|
||||
environmentId: string,
|
||||
): Promise<void> {
|
||||
const environment = await environmentsSvc.getById(environmentId);
|
||||
if (!environment) {
|
||||
// A missing environment leaks no tenant state. The execution-context
|
||||
// resolver surfaces the existing environment_not_found check.
|
||||
return;
|
||||
}
|
||||
const boundCompanyIds = await environmentsSvc.listBoundCompanyIds(environmentId);
|
||||
if (boundCompanyIds.length > 0 && !boundCompanyIds.includes(companyId)) {
|
||||
throw forbidden("The selected environment belongs to another company.", {
|
||||
code: "environment_company_mismatch",
|
||||
});
|
||||
}
|
||||
await assertEnvironmentSelectionForCompany(environmentsSvc, companyId, environmentId, {
|
||||
allowedDrivers: ADAPTER_TEST_ALLOWED_ENVIRONMENT_DRIVERS,
|
||||
});
|
||||
}
|
||||
|
||||
async function testManagedEnvironment(adapterType: string, context: Parameters<ReturnType<typeof requireServerAdapter>["testEnvironment"]>[0], binding: AiConnectionBinding, managed: Awaited<ReturnType<typeof prepareManagedAiRuntime>>, agentId?: string) {
|
||||
const startedAt = new Date();
|
||||
const result = await probeManagedEnvironment(adapterType, context, binding);
|
||||
// A provider rejection invalidates the tested credential generation. A
|
||||
// missing CLI, unavailable environment, or other runtime error does not.
|
||||
if (result.status === "fail" && result.checks.some(check =>
|
||||
check.code === ADAPTER_AUTH_MISSING_CHECK_CODE || /_hello_probe_auth_required$/.test(check.code)
|
||||
|| check.code === "ai_connection_api_key_rejected",
|
||||
)) {
|
||||
await aiConnectionService(db).markAuthenticationFailed({
|
||||
companyId: context.companyId, agentId, runStartedAt: startedAt,
|
||||
attribution: { ...managed.attribution, identity: managed.identity },
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function probeManagedEnvironment(adapterType: string, context: Parameters<ReturnType<typeof requireServerAdapter>["testEnvironment"]>[0], binding: AiConnectionBinding) {
|
||||
await assertManagedAiProjectAuth(context.config, binding.provider, context.executionTarget);
|
||||
const result = await requireServerAdapter(adapterType).testEnvironment(context);
|
||||
if (result.status === "fail") return result;
|
||||
// The resolved method, not binding.method — on a responsible_user binding
|
||||
// that field is wire-compat only and the default connection decides.
|
||||
const resolvedMethod = (context.config as { managedAiConnection?: { method?: string } }).managedAiConnection?.method;
|
||||
// An api_key account does not take the CLI hello probe below: a key
|
||||
// travels as an env var any engine understands, and the engine's own test
|
||||
// above judged whether this runtime can execute with it — the ACP lane
|
||||
// deliberately runs no hello probe when a key is configured. Demanding one
|
||||
// anyway forced the CLI lane, whose probe needs a provider CLI on PATH,
|
||||
// and a clean install has none: that walled off onboarding's API-key path
|
||||
// on exactly the machines the release smoke exists to guard. The account
|
||||
// is still verified live here — the same provider-endpoint check the save
|
||||
// performed — so a key revoked since its save fails adoption rather than
|
||||
// producing an agent that cannot authenticate at runtime. The hello-probe
|
||||
// requirement stays for subscriptions: a stored login is a file layout
|
||||
// only a provider CLI reads, so proving the runtime lane can consume it
|
||||
// takes a real hello turn.
|
||||
if (resolvedMethod === "api_key" && !context.config.managedAiRouting) {
|
||||
const envKey = AI_CONNECTION_CAPABILITIES[binding.provider].methods.api_key?.envKey;
|
||||
const key = envKey ? parseObject(context.config.env)[envKey] : undefined;
|
||||
try {
|
||||
if (typeof key !== "string" || !key) throw unprocessable("The selected account's API key was not available to verify.");
|
||||
await validateAiApiKey(binding.provider, key);
|
||||
result.checks.push({ code: "ai_connection_api_key_reverified", level: "info", message: "The provider verified this API key for adoption." });
|
||||
} catch (error) {
|
||||
result.status = "fail";
|
||||
const rejected = error instanceof HttpError && asRecord(error.details)?.code === "ai_connection_api_key_rejected";
|
||||
result.checks.push({ code: rejected ? "ai_connection_api_key_rejected" : "ai_connection_verification_failed", level: "error", message: error instanceof HttpError ? error.message : "Could not verify the account. Try again." });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (!result.checks.some(check => check.code.includes("hello_probe"))) {
|
||||
const providerAdapter = context.config.managedAiRouting ? aiRoutingHarness(adapterType, context.config.provider, context.config.acpxAgent) : { anthropic: "claude_local", openai: "codex_local", openrouter: "opencode_local", xai: "grok_local", google: "gemini_local" }[binding.provider];
|
||||
const probe = await requireServerAdapter(providerAdapter).testEnvironment({ ...context, adapterType: providerAdapter, config: { ...context.config, engine: "cli" } });
|
||||
result.checks.push(...probe.checks);
|
||||
result.status = probe.status === "fail" ? "fail" : result.status === "warn" || probe.status === "warn" ? "warn" : "pass";
|
||||
}
|
||||
if (!result.checks.some(check => /hello_probe_(passed|succeeded)$/.test(check.code))) {
|
||||
result.status = "fail";
|
||||
result.checks.push({ code: "ai_connection_validation_incomplete", level: "error", message: "The selected account has not completed a provider hello test. Retry before adopting it." });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
return { resolveAdapterTestEnvironmentId, resolveAdapterTestExecutionContext, assertAdapterTestEnvironmentForCompany, testManagedEnvironment };
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { readProcessStartedAt } from "./hot-restart.js";
|
||||
import { runUsedConversationAdapter } from "./conversation-continuation.js";
|
||||
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { environmentLeases, heartbeatRuns, nativeRunFinalizations, type Db } from "@paperclipai/db";
|
||||
import { hasNativeLocalProcessStop } from "./native-local-process-stop.js";
|
||||
import { hasRemoteTerminationReceipt } from "./remote-execution-termination.js";
|
||||
import { isCancelledNativeStartup } from "./cancelled-native-startup.js";
|
||||
|
||||
/** Cancellation changes admission. Process and lease evidence controls cleanup. */
|
||||
export async function agentExecutionsHaveStopped(db: Db, agentIds: string[]) {
|
||||
if (!agentIds.length) return true;
|
||||
const runs = await db.select().from(heartbeatRuns).where(inArray(heartbeatRuns.agentId, agentIds));
|
||||
const runIds = db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(inArray(heartbeatRuns.agentId, agentIds));
|
||||
const leases = await db.select().from(environmentLeases).where(or(
|
||||
inArray(environmentLeases.heartbeatRunId, runIds),
|
||||
inArray(sql`${environmentLeases.metadata}->>'agentId'`, agentIds),
|
||||
));
|
||||
if (leases.some(lease => (!lease.releasedAt && !(lease.status === "retained" && lease.cleanupStatus === "success")) || lease.status === "pending_cleanup" || lease.cleanupStatus === "failed")) return false;
|
||||
const coordinators = await db.select().from(nativeRunFinalizations)
|
||||
.where(inArray(nativeRunFinalizations.runId, runIds));
|
||||
const now = new Date();
|
||||
const abandoned = coordinators.filter(row => row.leaseOwner);
|
||||
if (abandoned.some(row => !row.leaseExpiresAt || row.leaseExpiresAt > now || row.resultId ||
|
||||
runs.find(run => run.id === row.runId)?.status !== "cancelled")) return false;
|
||||
const absent = (pid: number) => {
|
||||
try { process.kill(pid, 0); return false; }
|
||||
catch (error) { return (error as NodeJS.ErrnoException).code === "ESRCH"; }
|
||||
};
|
||||
for (const coordinator of abandoned) {
|
||||
if (!coordinator.controllerPid || absent(coordinator.controllerPid)) continue;
|
||||
const observed = await readProcessStartedAt(coordinator.controllerPid).catch(() => null);
|
||||
if (!observed || !coordinator.controllerProcessStartedAt ||
|
||||
new Date(observed).getTime() === coordinator.controllerProcessStartedAt.getTime()) return false;
|
||||
}
|
||||
for (const run of runs) {
|
||||
if (!["succeeded", "failed", "cancelled", "timed_out", "interrupted"].includes(run.status)) return false;
|
||||
if (run.controllerLeaseExpiresAt && run.controllerLeaseExpiresAt > new Date()) return false;
|
||||
const runLeases = leases.filter(lease => lease.heartbeatRunId === run.id);
|
||||
if (runLeases.some(lease => lease.provider && lease.provider !== "local")) {
|
||||
if (run.status === "cancelled" && !runLeases.every(hasRemoteTerminationReceipt)) return false;
|
||||
continue;
|
||||
}
|
||||
let processAlive = Boolean(run.processPid && !absent(run.processPid));
|
||||
let groupAlive = Boolean(run.processGroupId && !absent(-run.processGroupId));
|
||||
if (processAlive && run.processStartedAt) {
|
||||
const observed = await readProcessStartedAt(run.processPid!).catch(() => null);
|
||||
if (observed && new Date(observed).getTime() !== run.processStartedAt.getTime()) {
|
||||
processAlive = false;
|
||||
if (run.processGroupId === run.processPid) groupAlive = false;
|
||||
}
|
||||
}
|
||||
if (processAlive || groupAlive) return false;
|
||||
if (run.status !== "cancelled" || !run.startedAt) continue;
|
||||
if (run.runtimeMode === "native" && !run.processPid && !run.processGroupId &&
|
||||
!await hasNativeLocalProcessStop(db, run.companyId, run.id) &&
|
||||
!await isCancelledNativeStartup(db, run, coordinators.find(row => row.runId === run.id))) return false;
|
||||
if (run.runtimeMode === "legacy" && !run.processPid && !run.processGroupId &&
|
||||
(run.resultJson?.executionCancellation as Record<string, unknown> | undefined)?.state !== "acknowledged" &&
|
||||
!(run.executionStage === "settled" && !await runUsedConversationAdapter(db, run)) &&
|
||||
!await isCancelledNativeStartup(db, run, coordinators.find(row => row.runId === run.id))) return false;
|
||||
}
|
||||
// Release an expired owner only after its cancelled execution has stop proof.
|
||||
for (const coordinator of abandoned) {
|
||||
const [cleared] = await db.update(nativeRunFinalizations).set({ leaseOwner: null, leaseExpiresAt: null, updatedAt: now })
|
||||
.where(and(eq(nativeRunFinalizations.runId, coordinator.runId),
|
||||
eq(nativeRunFinalizations.leaseOwner, coordinator.leaseOwner!),
|
||||
eq(nativeRunFinalizations.leaseExpiresAt, coordinator.leaseExpiresAt!),
|
||||
eq(nativeRunFinalizations.controllerGeneration, coordinator.controllerGeneration),
|
||||
eq(nativeRunFinalizations.phase, coordinator.phase), isNull(nativeRunFinalizations.resultId)))
|
||||
.returning({ runId: nativeRunFinalizations.runId });
|
||||
if (!cleared) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { agents, companies, type Db } from "@paperclipai/db";
|
||||
import type { AdapterEnvironmentTestResult } from "@paperclipai/adapter-utils";
|
||||
import { ADAPTER_AUTH_MISSING_CHECK_CODE, aiRuntimeConnectionBindingSchema } from "@paperclipai/shared";
|
||||
import { isForbiddenConfigEnvKey, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { requireServerAdapter } from "../adapters/index.js";
|
||||
import { agentEnvironmentTestService } from "./agent-environment-test.js";
|
||||
import { aiConnectionRouterService } from "./ai-connection-router.js";
|
||||
import { withManagedAiProbe, stripAiAuthBindings } from "./ai-connection-runtime.js";
|
||||
import { environmentService } from "./environments.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { dotRunnerBroker } from "./dot-runner-broker.js";
|
||||
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
import type { LifecycleAgent } from "../modules/agent-lifecycle/index.js";
|
||||
|
||||
export function assertHarnessTestPassed(result: AdapterEnvironmentTestResult) {
|
||||
const verified = result.checks.some(check => check.level === "info" &&
|
||||
(/hello_probe_(passed|succeeded)$/.test(check.code) || check.code === "ai_connection_api_key_reverified"));
|
||||
const authenticationFailed = result.checks.some(check =>
|
||||
check.code === ADAPTER_AUTH_MISSING_CHECK_CODE || /_hello_probe_auth_required$/.test(check.code));
|
||||
const incomplete = !verified && (result.status === "warn" || result.checks.some(check => check.code.includes("hello_probe")));
|
||||
if (result.status === "fail" || authenticationFailed || incomplete) throw Object.assign(new Error("The harness test failed"), { code: "harness_test_failed" });
|
||||
}
|
||||
|
||||
export function agentHarnessVerificationService(db: Db, manager: PluginWorkerManager) {
|
||||
const tests = agentEnvironmentTestService(db, manager);
|
||||
const secrets = secretService(db);
|
||||
const environments = environmentService(db);
|
||||
async function verify(snapshot: LifecycleAgent): Promise<"complete" | "pending"> {
|
||||
const [agent] = await db.select().from(agents).where(and(eq(agents.id, snapshot.id), eq(agents.lifecycleVersion, snapshot.lifecycleVersion)));
|
||||
if (!agent) return "pending";
|
||||
if (agent.adapterType === "paperclip_runner" && agent.adapterConfig.provider === "openai_dot") {
|
||||
const binding = await dotRunnerBroker(db).bindingForAgent(agent.companyId, agent.id);
|
||||
return binding?.status === "ready" && binding.subscriptionVerified && binding.id === agent.adapterConfig.dotBindingId ? "complete" : "pending";
|
||||
}
|
||||
const [company] = await db.select().from(companies).where(eq(companies.id, agent.companyId));
|
||||
const responsibleUserId = agent.lifecycleOperation?.responsibleUserId ?? company.defaultResponsibleUserId;
|
||||
const context = { consumerType: "agent" as const, consumerId: agent.id, actorType: "system" as const,
|
||||
actorId: "agent-lifecycle", responsibleUserId };
|
||||
let binding = agent.runtimeConfig.aiConnection ? aiRuntimeConnectionBindingSchema.parse(agent.runtimeConfig.aiConnection) : undefined;
|
||||
let config = agent.adapterConfig;
|
||||
if (binding?.mode === "router") {
|
||||
const selected = await aiConnectionRouterService(db, manager).resolve({ companyId: agent.companyId,
|
||||
poolId: binding.connectionId, agentId: agent.id, userId: responsibleUserId,
|
||||
adapterType: agent.adapterType, taskKey: `lifecycle:${agent.lifecycleOperation!.id}` });
|
||||
binding = selected.binding;
|
||||
config = { ...config, ...selected.runtimeConfig };
|
||||
}
|
||||
if (binding) config = { ...config, env: stripAiAuthBindings(config.env) };
|
||||
config = (await secrets.resolveAdapterConfigForRuntime(agent.companyId, config, context, { adapterType: agent.adapterType })).config;
|
||||
const environmentId = await tests.resolveAdapterTestEnvironmentId(agent.companyId, agent.defaultEnvironmentId);
|
||||
if (environmentId) {
|
||||
await tests.assertAdapterTestEnvironmentForCompany(agent.companyId, environmentId);
|
||||
const environment = await environments.getById(environmentId);
|
||||
const env = Object.fromEntries(Object.entries(binding ? stripAiAuthBindings(environment?.envVars) : parseObject(environment?.envVars))
|
||||
.filter(([key]) => !isForbiddenConfigEnvKey(key)));
|
||||
const resolved = await secrets.resolveEnvBindings(agent.companyId, env,
|
||||
{ ...context, consumerType: "environment", consumerId: environmentId });
|
||||
config = { ...config, env: { ...resolved.env, ...parseObject(config.env) } };
|
||||
}
|
||||
const target = await tests.resolveAdapterTestExecutionContext({ agentId: agent.id, companyId: agent.companyId, adapterType: agent.adapterType, environmentId });
|
||||
let status: "released" | "failed" = "failed";
|
||||
try {
|
||||
if (target.fallbackChecks.length) throw Object.assign(new Error("The test environment is unavailable"), { code: "test_environment_unavailable" });
|
||||
const input = { companyId: agent.companyId, adapterType: agent.adapterType, config,
|
||||
executionTarget: target.executionTarget, environmentName: target.environmentName };
|
||||
const result = binding ? await withManagedAiProbe(db, { ...input, agentId: agent.id, responsibleUserId, binding },
|
||||
managed => tests.testManagedEnvironment(agent.adapterType, { ...input, config: managed.config }, binding!, managed, agent.id))
|
||||
: await requireServerAdapter(agent.adapterType).testEnvironment(input);
|
||||
assertHarnessTestPassed(result);
|
||||
status = "released";
|
||||
return "complete";
|
||||
} finally { await target.release(status); }
|
||||
}
|
||||
return { verify };
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { agentAppearanceSchema, randomAgentAppearance } from "@paperclipai/shared";
|
||||
import { normalizePaperclipRunnerAdapterConfig } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { agents, toolConnectionInstalls, principalPermissionGrants, type Db } from "@paperclipai/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { unprocessable } from "../errors.js";
|
||||
import { agentRecordQueries, isPlainRecord, configPatchFromApprovalPayload, normalizeRuntimeConfigForNewAgent,
|
||||
deduplicateAgentName, type CreateAgentData, type CreateAgentOptions, type AgentConfigurationPatch, type AgentHireRecord } from "../lib/agent-records.js";
|
||||
import { normalizeAgentPermissions, permissionsImplyLowTrust, NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS,
|
||||
newStandardAgentGrantScope } from "../lib/agent-permissions.js";
|
||||
import { readBuiltInAgentMarker } from "../lib/built-in-agent-metadata.js";
|
||||
import { secretService, assertClaudeOAuthBindingInvariant } from "./secrets.js";
|
||||
import { agentCredentialService } from "./agent-credentials.js";
|
||||
import { agentIdentityService } from "./agent-identity.js";
|
||||
import { initializePrimaryAgent } from "./primary-agent.js";
|
||||
import { resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js";
|
||||
|
||||
export async function prepareAgentHire(db: Db, companyId: string, input: CreateAgentData, options?: CreateAgentOptions): Promise<CreateAgentData> {
|
||||
// Resolve every new hire before adapter-specific normalization and credentials.
|
||||
// Approval/import callers freeze their reviewed execution; runner is request-only.
|
||||
const { runner, ...data } = input;
|
||||
const selection = options?.runnerResolved
|
||||
? { adapterType: data.adapterType ?? "process", adapterConfig: isPlainRecord(data.adapterConfig) ? data.adapterConfig : {} }
|
||||
: await resolveNewAgentRunnerForCompany(db, companyId, {
|
||||
...data, adapterConfig: isPlainRecord(data.adapterConfig) ? data.adapterConfig : {}, runner,
|
||||
});
|
||||
data.adapterType = selection.adapterType;
|
||||
data.adapterConfig = selection.adapterConfig;
|
||||
const { ensureManager, assertBuiltInAgentMetadataMutationAllowed } = agentRecordQueries(db);
|
||||
if (data.appearance?.customAvatarAssetId) throw unprocessable("Create the agent before uploading its avatar");
|
||||
assertBuiltInAgentMetadataMutationAllowed(null, data.metadata, options);
|
||||
if (data.reportsTo) {
|
||||
await ensureManager(companyId, data.reportsTo);
|
||||
}
|
||||
|
||||
const existingAgents = await db
|
||||
.select({ id: agents.id, name: agents.name, status: agents.status })
|
||||
.from(agents)
|
||||
.where(eq(agents.companyId, companyId));
|
||||
const uniqueName = deduplicateAgentName(data.name, existingAgents);
|
||||
|
||||
const role = data.role ?? "general";
|
||||
const normalizedPermissions = normalizeAgentPermissions(data.permissions, { context: "create" });
|
||||
const runtimeConfig = normalizeRuntimeConfigForNewAgent(data.runtimeConfig);
|
||||
const adapterType = data.adapterType ?? "process";
|
||||
const rawAdapterConfig = isPlainRecord(data.adapterConfig)
|
||||
? await secretService(db).normalizeAdapterConfigForPersistence(companyId, data.adapterConfig, { adapterType })
|
||||
: {};
|
||||
const adapterConfig = normalizePaperclipRunnerAdapterConfig(adapterType, rawAdapterConfig);
|
||||
await agentCredentialService(db).enforceClaudeOAuthBindingClaim(db, {
|
||||
companyId, consume: true, environmentId: data.defaultEnvironmentId ?? null,
|
||||
childAdapterConfig: adapterConfig, claudeLogin: options?.claudeLogin,
|
||||
decision: assertClaudeOAuthBindingInvariant({ adapterType, nextConfig: adapterConfig }),
|
||||
});
|
||||
return { ...data, name: uniqueName,
|
||||
appearance: data.appearance == null ? randomAgentAppearance() : agentAppearanceSchema.parse(data.appearance),
|
||||
role, adapterType, adapterConfig, permissions: normalizedPermissions, runtimeConfig };
|
||||
}
|
||||
|
||||
export async function initializeAgentHire(txDb: Db, created: AgentHireRecord, options?: CreateAgentOptions) {
|
||||
const tx = txDb;
|
||||
const companyId = created.companyId;
|
||||
await agentIdentityService(txDb).ensureAgentIdentity(companyId, created.id);
|
||||
// New standard agents receive the standard direct grants at activation.
|
||||
// Low-trust and bundled agents keep their explicit, narrower grants.
|
||||
if (created.status !== "pending_approval" && !permissionsImplyLowTrust(created.permissions) &&
|
||||
!readBuiltInAgentMarker(created.metadata)) {
|
||||
await tx.insert(principalPermissionGrants).values(
|
||||
NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS.map((permissionKey) => ({
|
||||
companyId,
|
||||
principalType: "agent" as const,
|
||||
principalId: created.id,
|
||||
permissionKey,
|
||||
scope: newStandardAgentGrantScope(permissionKey, created.id),
|
||||
})),
|
||||
).onConflictDoNothing();
|
||||
}
|
||||
if (options?.aiConnectionInstall) {
|
||||
const install = options.aiConnectionInstall;
|
||||
const connectionIds = [...new Set([install.connectionId, ...(install.memberConnectionIds ?? [])])];
|
||||
await tx.insert(toolConnectionInstalls).values(connectionIds.map(connectionId => ({
|
||||
companyId, connectionId,
|
||||
targetType: "agent" as const, targetId: created.id,
|
||||
createdByUserId: install.createdByUserId,
|
||||
}))).onConflictDoNothing();
|
||||
}
|
||||
await agentCredentialService(txDb).syncAgentSecretBindings(created, txDb);
|
||||
if (options?.createdByUserId && !readBuiltInAgentMarker(created.metadata)) {
|
||||
await initializePrimaryAgent(txDb, companyId, options.createdByUserId, created.id);
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareAgentHireApproval(txDb: Db, existing: AgentHireRecord, approvedPayload?: Record<string, unknown> | null): Promise<AgentConfigurationPatch> {
|
||||
const approvedPatch = approvedPayload ? configPatchFromApprovalPayload(approvedPayload) : {};
|
||||
const patch = { ...approvedPatch } as AgentConfigurationPatch;
|
||||
const hasApprovedAdapterConfig = Object.prototype.hasOwnProperty.call(patch, "adapterConfig") && isPlainRecord(patch.adapterConfig);
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(patch, "adapterConfig") &&
|
||||
isPlainRecord(patch.adapterConfig)
|
||||
) {
|
||||
const normalizedAdapterConfig = await secretService(txDb).normalizeAdapterConfigForPersistence(
|
||||
existing.companyId, patch.adapterConfig, { adapterType: (patch.adapterType ?? existing.adapterType) as string },
|
||||
);
|
||||
patch.adapterConfig = normalizePaperclipRunnerAdapterConfig(
|
||||
(patch.adapterType ?? existing.adapterType) as string,
|
||||
normalizedAdapterConfig,
|
||||
);
|
||||
} else if (
|
||||
Object.prototype.hasOwnProperty.call(patch, "adapterType")
|
||||
&& isPlainRecord(existing.adapterConfig)
|
||||
) {
|
||||
patch.adapterConfig = normalizePaperclipRunnerAdapterConfig(
|
||||
patch.adapterType as string,
|
||||
existing.adapterConfig,
|
||||
);
|
||||
}
|
||||
if (patch.permissions !== undefined) {
|
||||
// The pending-approval activation replays the original hire
|
||||
// request, so the new-agent creation default applies.
|
||||
patch.permissions = normalizeAgentPermissions(patch.permissions, { context: "create" });
|
||||
}
|
||||
if (hasApprovedAdapterConfig) {
|
||||
await agentCredentialService(txDb).enforceClaudeOAuthBindingClaim(txDb, {
|
||||
companyId: existing.companyId, consume: false, environmentId: null,
|
||||
childAdapterConfig: patch.adapterConfig,
|
||||
decision: assertClaudeOAuthBindingInvariant({ adapterType: patch.adapterType ?? existing.adapterType,
|
||||
nextConfig: patch.adapterConfig, priorConfig: existing.adapterConfig }),
|
||||
});
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
export async function completeAgentHireApproval(txDb: Db, updated: AgentHireRecord, existing: AgentHireRecord) {
|
||||
const tx = txDb;
|
||||
await agentCredentialService(txDb).syncAgentSecretBindings(updated, txDb, existing.adapterConfig);
|
||||
if (!permissionsImplyLowTrust(updated.permissions) && !readBuiltInAgentMarker(updated.metadata)) {
|
||||
await tx.insert(principalPermissionGrants).values(
|
||||
NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS.map((permissionKey) => ({
|
||||
companyId: updated.companyId,
|
||||
principalType: "agent" as const,
|
||||
principalId: updated.id,
|
||||
permissionKey,
|
||||
scope: newStandardAgentGrantScope(permissionKey, updated.id),
|
||||
})),
|
||||
).onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { adoptAgentFiles, agentFileToken } from "./agent-file-store.js";
|
||||
import { and, desc, eq, getTableColumns, lt, or, sql } from "drizzle-orm";
|
||||
@@ -252,19 +253,15 @@ export function agentInstructionRevisionService(db: Db) {
|
||||
!state.agent.adapterConfig.instructionsBundleMode ||
|
||||
(bound.type === "plugin" && configuredEntryFile !== input.entryFile)
|
||||
) {
|
||||
await tx
|
||||
.update(agents)
|
||||
.set({
|
||||
adapterConfig: {
|
||||
...state.agent.adapterConfig,
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: state.root,
|
||||
instructionsEntryFile: state.entryFile,
|
||||
instructionsFilePath: `${state.root}/${state.entryFile}`,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, state.agent.id));
|
||||
await updateAgentConfigurationInTransaction(tx as unknown as Db, state.agent.id, {
|
||||
adapterConfig: {
|
||||
...state.agent.adapterConfig,
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsRootPath: state.root,
|
||||
instructionsEntryFile: state.entryFile,
|
||||
instructionsFilePath: `${state.root}/${state.entryFile}`,
|
||||
},
|
||||
}, { allowPendingApprovalConfigUpdate: true });
|
||||
}
|
||||
};
|
||||
// An exact replay can safely return the durable receipt even after its base
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { agents, plugins, pluginCompanySettings, type Db } from "@paperclipai/db";
|
||||
import { heartbeatService } from "./heartbeat.js";
|
||||
import { listInvalidOrgChainDescendantIds } from "./agent-invokability.js";
|
||||
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
import type { LifecycleDriver } from "../modules/agent-lifecycle/index.js";
|
||||
import { agentHarnessVerificationService } from "./agent-harness-verification.js";
|
||||
|
||||
export function createLifecycleDriver(db: Db, manager: PluginWorkerManager): LifecycleDriver {
|
||||
const { verify } = agentHarnessVerificationService(db, manager);
|
||||
const heartbeat = heartbeatService(db, { pluginWorkerManager: manager });
|
||||
return {
|
||||
async requiredPluginIds(agent) {
|
||||
if (agent.lifecycleRequiredPluginIds !== null) return agent.lifecycleRequiredPluginIds;
|
||||
const installed = await db.select().from(plugins).where(inArray(plugins.status, ["ready", "error", "installed", "upgrade_pending"]));
|
||||
const disabled = await db.select({ pluginId: pluginCompanySettings.pluginId }).from(pluginCompanySettings)
|
||||
.where(and(eq(pluginCompanySettings.companyId, agent.companyId), eq(pluginCompanySettings.enabled, false)));
|
||||
return installed.filter(plugin => !disabled.some(row => row.pluginId === plugin.id) &&
|
||||
plugin.manifestJson.agentLifecycle && plugin.manifestJson.capabilities.includes("agents.lifecycle.manage")).map(plugin => plugin.id);
|
||||
},
|
||||
async runPlugin(agent, pluginId) {
|
||||
const [plugin] = await db.select().from(plugins).where(eq(plugins.id, pluginId));
|
||||
const [settings] = await db.select().from(pluginCompanySettings).where(and(
|
||||
eq(pluginCompanySettings.pluginId, pluginId), eq(pluginCompanySettings.companyId, agent.companyId)));
|
||||
if (settings?.enabled === false || !plugin || plugin.status !== "ready" || !plugin.manifestJson.agentLifecycle || !plugin.manifestJson.capabilities.includes("agents.lifecycle.manage")) {
|
||||
throw Object.assign(new Error("A required lifecycle plugin is unavailable"), { code: "required_plugin_unavailable" });
|
||||
}
|
||||
const operationId = agent.lifecycleOperation!.id;
|
||||
const result = await manager.call(plugin.id, "agentLifecycle", { companyId: agent.companyId,
|
||||
agentId: agent.id, operationId, version: agent.lifecycleVersion, phase: agent.lifecycleState }, 30_000);
|
||||
if (!result || result.operationId !== operationId || result.version !== agent.lifecycleVersion || !["complete", "pending"].includes(result.status)) {
|
||||
throw Object.assign(new Error("Invalid lifecycle result"), { code: "invalid_lifecycle_result" });
|
||||
}
|
||||
return result.status;
|
||||
},
|
||||
async runHost(agent) {
|
||||
if (agent.lifecycleState === "verifying") return verify(agent);
|
||||
if (["pausing", "terminating", "cleaning_up"].includes(agent.lifecycleState)) {
|
||||
const companyAgents = await db.select().from(agents).where(eq(agents.companyId, agent.companyId));
|
||||
const ids = agent.lifecycleState === "pausing" ? [agent.id]
|
||||
: [agent.id, ...listInvalidOrgChainDescendantIds(agent.id, companyAgents)];
|
||||
if (!await heartbeat.stopInvocationsForAgents(ids, "Agent lifecycle stop requested")) return "pending";
|
||||
}
|
||||
return "complete";
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import * as lifecycle from "../modules/agent-lifecycle/index.js";
|
||||
import { withAccountingTransaction } from "./accounting-transaction.js";
|
||||
import { budgetServiceInTransaction, deliverBudgetEnforcement, policyBlocks, type BudgetServiceHooks } from "./budgets.js";
|
||||
import { recordAgentStatusEvent, recordResourceCreationEvent } from "./resource-lifecycle-events.js";
|
||||
import { clearPrimaryAgent } from "./primary-agent.js";
|
||||
import { deleteAgentDependencies } from "./agent-deletion.js";
|
||||
import { prepareAgentConfiguration, completeAgentConfiguration } from "./agent-configuration.js";
|
||||
import { prepareAgentHire, initializeAgentHire, prepareAgentHireApproval, completeAgentHireApproval } from "./agent-hiring.js";
|
||||
import { trackIdleWork } from "./task-admission.js";
|
||||
|
||||
export { scheduleAgentLifecycle } from "../modules/agent-lifecycle/index.js";
|
||||
export type { LifecycleDriver, LifecycleAgent } from "../modules/agent-lifecycle/index.js";
|
||||
|
||||
export function createAgentLifecycleEffects(hooks: BudgetServiceHooks = {}): lifecycle.LifecycleEffects {
|
||||
return {
|
||||
reportFailure(context, error) {
|
||||
// Only known codes are safe; provider errors can contain credentials or private data.
|
||||
const allowedCodes = new Set(["required_plugin_unavailable", "invalid_lifecycle_result", "harness_test_failed",
|
||||
"test_environment_unavailable", "ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "40001", "40P01", "53300", "57P01"]);
|
||||
const candidate = error as { code?: unknown; cause?: { code?: unknown } } | null;
|
||||
const code = [candidate?.code, candidate?.cause?.code].find(value => typeof value === "string" && allowedCodes.has(value));
|
||||
logger.warn({ ...context, failureCode: code ?? "unclassified" }, "Agent lifecycle operation failed");
|
||||
},
|
||||
transaction: withAccountingTransaction,
|
||||
deleteDependencies: deleteAgentDependencies,
|
||||
policyBlocks,
|
||||
setAgentBudget: (db, publications, companyId, agentId, amount, userId, isActive) =>
|
||||
budgetServiceInTransaction(db, publications).upsertPolicy(companyId, {
|
||||
scopeType: "agent", scopeId: agentId, amount, isActive, windowKind: "calendar_month_utc",
|
||||
}, userId),
|
||||
enforceBudget: (db, companyId, agentId) => deliverBudgetEnforcement(db, hooks, companyId, agentId),
|
||||
recordCreation: (db, companyId, agentId) => recordResourceCreationEvent(db, companyId, "agent", agentId),
|
||||
recordStatus: recordAgentStatusEvent,
|
||||
clearPrimary: clearPrimaryAgent,
|
||||
prepareConfiguration: prepareAgentConfiguration,
|
||||
completeConfiguration: completeAgentConfiguration,
|
||||
prepareHire: prepareAgentHire,
|
||||
initializeHire: initializeAgentHire,
|
||||
prepareHireApproval: prepareAgentHireApproval,
|
||||
completeHireApproval: completeAgentHireApproval,
|
||||
};
|
||||
}
|
||||
|
||||
export function createAgentLifecycle(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
return lifecycle.createAgentLifecycle(db, createAgentLifecycleEffects(hooks));
|
||||
}
|
||||
|
||||
function trackedDriver(driver: lifecycle.LifecycleDriver): lifecycle.LifecycleDriver {
|
||||
return { ...driver, runHost: agent => trackIdleWork(driver.runHost(agent)),
|
||||
runPlugin: (agent, pluginId) => trackIdleWork(driver.runPlugin(agent, pluginId)) };
|
||||
}
|
||||
|
||||
export function configureAgentLifecycle(db: Db, driver: lifecycle.LifecycleDriver, canRun = () => true) {
|
||||
return lifecycle.configureAgentLifecycle(db, createAgentLifecycleEffects(), trackedDriver(driver), canRun);
|
||||
}
|
||||
|
||||
export function startAgentLifecycle(db: Db, driver: lifecycle.LifecycleDriver, canRun: () => boolean) {
|
||||
return lifecycle.startAgentLifecycle(db, createAgentLifecycleEffects(), trackedDriver(driver), canRun);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import sharp from "sharp";
|
||||
@@ -5,7 +6,6 @@ import { agents, assets, companies, type Db } from "@paperclipai/db";
|
||||
import { MAX_AGENT_AVATAR_BYTES, resolveAgentAppearance, agentAvatarUrl, setAgentAvatarSchema, type SetAgentAvatarInput } from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
import { agentService } from "./agents.js";
|
||||
import { logActivity, publishActivity, type ActivityPublication, type LogActivityInput } from "./activity-log.js";
|
||||
|
||||
/** Decode and re-encode: exclude SVG, animation, metadata and unbounded pixel counts. No URL fetching. */
|
||||
@@ -60,7 +60,7 @@ export async function setAgentProfileAvatar(db: Db, storage: StorageService, com
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null }).returning();
|
||||
appearance.customAvatarAssetId = asset!.id;
|
||||
}
|
||||
await agentService(txDb).update(agentId, { appearance }, { recordRevision: {
|
||||
await updateAgentConfigurationInTransaction(txDb, agentId, { appearance }, { recordRevision: {
|
||||
createdByAgentId: actor.agentId, createdByUserId: actor.actorType === "user" ? actor.actorId : null, source: "avatar-upload",
|
||||
} });
|
||||
await logActivity(txDb, { ...actor, companyId, action: "agent.avatar_updated", entityType: "agent", entityId: agentId,
|
||||
|
||||
+28
-1231
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,38 @@
|
||||
import type { AiProvider } from "@paperclipai/shared";
|
||||
import { unprocessable } from "../errors.js";
|
||||
|
||||
/** Fixed provider endpoints; credentials are never sent to a caller-supplied URL or through a redirect. */
|
||||
export async function validateAiApiKey(
|
||||
provider: AiProvider,
|
||||
key: string,
|
||||
request: typeof fetch = fetch,
|
||||
) {
|
||||
const endpoints = {
|
||||
anthropic: "https://api.anthropic.com/v1/models?limit=1",
|
||||
openai: "https://api.openai.com/v1/models",
|
||||
openrouter: "https://openrouter.ai/api/v1/key",
|
||||
xai: "https://api.x.ai/v1/models",
|
||||
google: "https://generativelanguage.googleapis.com/v1beta/models?pageSize=1",
|
||||
};
|
||||
let response: Response;
|
||||
try {
|
||||
response = await request(endpoints[provider], {
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(15000),
|
||||
headers:
|
||||
provider === "google" ? { "x-goog-api-key": key } : provider === "anthropic"
|
||||
? { "x-api-key": key, "anthropic-version": "2023-06-01" }
|
||||
: { Authorization: `Bearer ${key}` },
|
||||
});
|
||||
} catch {
|
||||
throw unprocessable("Could not verify the account. Try again.", { code: "ai_connection_verification_failed" });
|
||||
}
|
||||
await response.body?.cancel();
|
||||
if (!response.ok)
|
||||
throw unprocessable(
|
||||
response.status === 401 || response.status === 403
|
||||
? "The provider rejected this API key."
|
||||
: "The provider could not verify this account. Try again.",
|
||||
{ code: response.status === 401 || response.status === 403 ? "ai_connection_api_key_rejected" : "ai_connection_verification_failed" },
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,19 @@
|
||||
import { agentAppearanceSchema } from "@paperclipai/shared";
|
||||
import { approvalResolutionRecords, resolvableApprovalStatuses as resolvableStatuses } from "../lib/approval-records.js";
|
||||
import { budgetService } from "./budgets.js";
|
||||
import { notifyHireApproved } from "./hire-hook.js";
|
||||
|
||||
import { and, asc, eq, inArray, sql, type SQL } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { approvalComments, approvals } from "@paperclipai/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { unprocessable } from "../errors.js";
|
||||
import { redactCurrentUserText } from "../log-redaction.js";
|
||||
import { agentService } from "./agents.js";
|
||||
import { budgetService, budgetServiceInTransaction } from "./budgets.js";
|
||||
import { withAccountingTransaction } from "./accounting-transaction.js";
|
||||
import { notifyHireApproved } from "./hire-hook.js";
|
||||
import { createAgentLifecycle } from "./agent-lifecycle.js";
|
||||
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
|
||||
export function approvalService(db: Db) {
|
||||
export function approvalRecords(db: Db) {
|
||||
const instanceSettings = instanceSettingsService(db);
|
||||
const canResolveStatuses = new Set(["pending", "revision_requested"]);
|
||||
const resolvableStatuses = Array.from(canResolveStatuses);
|
||||
type ApprovalRecord = typeof approvals.$inferSelect;
|
||||
type ResolutionResult = { approval: ApprovalRecord; applied: boolean };
|
||||
const { getExistingApproval, resolveApproval } = approvalResolutionRecords(db);
|
||||
|
||||
function redactApprovalComment<T extends { body: string }>(comment: T, censorUsernameInLogs: boolean): T {
|
||||
return {
|
||||
@@ -24,69 +22,8 @@ export function approvalService(db: Db) {
|
||||
};
|
||||
}
|
||||
|
||||
async function reconcileApprovedBuiltInAgent(companyId: string, payload: Record<string, unknown>, database: Db) {
|
||||
const sourceBuiltInAgentKey = typeof payload.sourceBuiltInAgentKey === "string" ? payload.sourceBuiltInAgentKey : null;
|
||||
if (!sourceBuiltInAgentKey) return;
|
||||
const { builtInAgentService } = await import("./built-in-agents.js");
|
||||
await builtInAgentService(database).ensure(companyId, sourceBuiltInAgentKey);
|
||||
}
|
||||
|
||||
async function getExistingApproval(id: string, database: Db = db) {
|
||||
const existing = await database
|
||||
.select()
|
||||
.from(approvals)
|
||||
.where(eq(approvals.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) throw notFound("Approval not found");
|
||||
return existing;
|
||||
}
|
||||
|
||||
async function resolveApproval(
|
||||
id: string,
|
||||
targetStatus: "approved" | "rejected",
|
||||
decidedByUserId: string,
|
||||
decisionNote: string | null | undefined,
|
||||
database: Db = db,
|
||||
): Promise<ResolutionResult> {
|
||||
const existing = await getExistingApproval(id, database);
|
||||
if (!canResolveStatuses.has(existing.status)) {
|
||||
if (existing.status === targetStatus) {
|
||||
return { approval: existing, applied: false };
|
||||
}
|
||||
throw unprocessable(
|
||||
`Only pending or revision requested approvals can be ${targetStatus === "approved" ? "approved" : "rejected"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updated = await database
|
||||
.update(approvals)
|
||||
.set({
|
||||
status: targetStatus,
|
||||
decidedByUserId,
|
||||
decisionNote: decisionNote ?? null,
|
||||
decidedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(approvals.id, id), inArray(approvals.status, resolvableStatuses)))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (updated) {
|
||||
return { approval: updated, applied: true };
|
||||
}
|
||||
|
||||
const latest = await getExistingApproval(id, database);
|
||||
if (latest.status === targetStatus) {
|
||||
return { approval: latest, applied: false };
|
||||
}
|
||||
|
||||
throw unprocessable(
|
||||
`Only pending or revision requested approvals can be ${targetStatus === "approved" ? "approved" : "rejected"}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
getExistingApproval, resolveApproval,
|
||||
list: (companyId: string, status?: string, readCondition?: SQL<boolean>) => {
|
||||
const conditions = [eq(approvals.companyId, companyId)];
|
||||
if (status) conditions.push(eq(approvals.status, status));
|
||||
@@ -142,113 +79,6 @@ export function approvalService(db: Db) {
|
||||
return updated;
|
||||
},
|
||||
|
||||
approve: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
|
||||
const now = new Date();
|
||||
const existing = await getExistingApproval(id);
|
||||
// Receipt writers lock company before agent. Hire approval must use that
|
||||
// order too, including activation of an existing pending agent.
|
||||
const result = await withAccountingTransaction(db, existing.companyId, async (txDb, publications) => {
|
||||
const agentsSvc = agentService(txDb);
|
||||
const budgets = budgetServiceInTransaction(txDb, publications);
|
||||
const { approval: updated, applied } = await resolveApproval(
|
||||
id,
|
||||
"approved",
|
||||
decidedByUserId,
|
||||
decisionNote,
|
||||
txDb,
|
||||
);
|
||||
|
||||
let hireApprovedAgentId: string | null = null;
|
||||
if (applied && updated.type === "hire_agent") {
|
||||
const payload = updated.payload as Record<string, unknown>;
|
||||
const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
|
||||
if (payloadAgentId) {
|
||||
await agentsSvc.activatePendingApproval(payloadAgentId, payload);
|
||||
await reconcileApprovedBuiltInAgent(updated.companyId, payload, txDb);
|
||||
hireApprovedAgentId = payloadAgentId;
|
||||
} else {
|
||||
const created = await agentsSvc.create(updated.companyId, {
|
||||
name: String(payload.name ?? "New Agent"),
|
||||
appearance: payload.appearance == null ? undefined : agentAppearanceSchema.parse(payload.appearance),
|
||||
role: String(payload.role ?? "general"),
|
||||
title: typeof payload.title === "string" ? payload.title : null,
|
||||
reportsTo: typeof payload.reportsTo === "string" ? payload.reportsTo : null,
|
||||
capabilities: typeof payload.capabilities === "string" ? payload.capabilities : null,
|
||||
adapterType: String(payload.adapterType ?? "process"),
|
||||
adapterConfig:
|
||||
typeof payload.adapterConfig === "object" && payload.adapterConfig !== null
|
||||
? (payload.adapterConfig as Record<string, unknown>)
|
||||
: {},
|
||||
budgetMonthlyCents:
|
||||
typeof payload.budgetMonthlyCents === "number" ? payload.budgetMonthlyCents : 0,
|
||||
metadata:
|
||||
typeof payload.metadata === "object" && payload.metadata !== null
|
||||
? (payload.metadata as Record<string, unknown>)
|
||||
: null,
|
||||
status: "idle",
|
||||
spentMonthlyCents: 0,
|
||||
permissions: undefined,
|
||||
lastHeartbeatAt: null,
|
||||
}, { runnerResolved: true, createdByUserId: updated.requestedByAgentId ? null : updated.requestedByUserId });
|
||||
hireApprovedAgentId = created?.id ?? null;
|
||||
}
|
||||
if (hireApprovedAgentId) {
|
||||
const budgetMonthlyCents =
|
||||
typeof payload.budgetMonthlyCents === "number" ? payload.budgetMonthlyCents : 0;
|
||||
if (budgetMonthlyCents > 0) {
|
||||
await budgets.upsertPolicy(
|
||||
updated.companyId,
|
||||
{
|
||||
scopeType: "agent",
|
||||
scopeId: hireApprovedAgentId,
|
||||
amount: budgetMonthlyCents,
|
||||
windowKind: "calendar_month_utc",
|
||||
},
|
||||
decidedByUserId,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return { approval: updated, applied, hireApprovedAgentId };
|
||||
});
|
||||
if (result.hireApprovedAgentId) {
|
||||
await budgetService(db).deliverPendingEnforcement(result.approval.companyId);
|
||||
void notifyHireApproved(db, {
|
||||
companyId: result.approval.companyId,
|
||||
agentId: result.hireApprovedAgentId,
|
||||
source: "approval",
|
||||
sourceId: id,
|
||||
approvedAt: now,
|
||||
}).catch(() => {});
|
||||
}
|
||||
return { approval: result.approval, applied: result.applied };
|
||||
},
|
||||
|
||||
reject: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
|
||||
return db.transaction(async tx => {
|
||||
const txDb = tx as unknown as Db;
|
||||
const { approval: updated, applied } = await resolveApproval(
|
||||
id,
|
||||
"rejected",
|
||||
decidedByUserId,
|
||||
decisionNote,
|
||||
txDb,
|
||||
);
|
||||
|
||||
if (applied && updated.type === "hire_agent") {
|
||||
const payload = updated.payload as Record<string, unknown>;
|
||||
const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
|
||||
if (payloadAgentId) {
|
||||
await agentService(txDb).terminate(payloadAgentId);
|
||||
}
|
||||
}
|
||||
|
||||
return { approval: updated, applied };
|
||||
});
|
||||
},
|
||||
|
||||
requestRevision: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
|
||||
const existing = await getExistingApproval(id);
|
||||
if (existing.status !== "pending") {
|
||||
@@ -332,3 +162,40 @@ export function approvalService(db: Db) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function approvalService(db: Db) {
|
||||
const { getExistingApproval, resolveApproval, ...records } = approvalRecords(db);
|
||||
async function reconcileApprovedBuiltInAgent(companyId: string, payload: Record<string, unknown>, database: Db) {
|
||||
const sourceBuiltInAgentKey = typeof payload.sourceBuiltInAgentKey === "string" ? payload.sourceBuiltInAgentKey : null;
|
||||
if (!sourceBuiltInAgentKey) return;
|
||||
const { builtInAgentService } = await import("./built-in-agents.js");
|
||||
await builtInAgentService(database).ensure(companyId, sourceBuiltInAgentKey);
|
||||
}
|
||||
|
||||
async function decideHire(target: string | { approvalId: string }, status: "approved" | "rejected", userId: string, note?: string | null) {
|
||||
const lifecycle = createAgentLifecycle(db);
|
||||
const result = await (status === "approved" ? lifecycle.approveHire : lifecycle.rejectHire)(target, userId, note);
|
||||
if (result?.hireApprovedAgentId && result.approval) {
|
||||
await reconcileApprovedBuiltInAgent(result.approval.companyId, result.approval.payload, db);
|
||||
await budgetService(db).deliverPendingEnforcement(result.approval.companyId);
|
||||
void notifyHireApproved(db, { companyId: result.approval.companyId, agentId: result.hireApprovedAgentId,
|
||||
source: "approval", sourceId: result.approval.id, approvedAt: result.approval.decidedAt ?? new Date() }).catch(() => {});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function decide(id: string, status: "approved" | "rejected", userId: string, note?: string | null) {
|
||||
const approval = await getExistingApproval(id);
|
||||
if (approval.type === "hire_agent") {
|
||||
const result = await decideHire({ approvalId: id }, status, userId, note);
|
||||
return { approval: result!.approval!, applied: result!.applied };
|
||||
}
|
||||
return resolveApproval(id, status, userId, note);
|
||||
}
|
||||
return { ...records,
|
||||
approveHire: (agentId: string, userId: string, note?: string | null) => decideHire(agentId, "approved", userId, note),
|
||||
rejectHire: (agentId: string, userId: string, note?: string | null) => decideHire(agentId, "rejected", userId, note),
|
||||
approve: (id: string, userId: string, note?: string | null) => decide(id, "approved", userId, note),
|
||||
reject: (id: string, userId: string, note?: string | null) => decide(id, "rejected", userId, note),
|
||||
};
|
||||
}
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
type TrustPresetResolution,
|
||||
} from "./trust-preset-resolver.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { normalizeAgentPermissions } from "./agent-permissions.js";
|
||||
import { normalizeAgentPermissions } from "../lib/agent-permissions.js";
|
||||
import { grantsForHumanRole, normalizeHumanRole } from "./company-member-roles.js";
|
||||
|
||||
export type AuthorizationActor =
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { compareCents, normalizeCents } from "@paperclipai/shared";
|
||||
import { and, desc, eq, gte, inArray, lt, ne, sql, or, isNull, type SQL } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { recordAgentStatusEvent } from "./resource-lifecycle-events.js";
|
||||
|
||||
import {
|
||||
agents,
|
||||
approvals,
|
||||
@@ -225,7 +225,7 @@ function observedBlocks(policy: PolicyRow, observed: Awaited<ReturnType<typeof c
|
||||
if (!policy.isActive || !policy.hardStopEnabled || policy.amount <= 0) return false;
|
||||
return compareCents(observed.totalExact, policy.amount) >= 0 || (observed.pendingRunCount - observed.recoveringRunCount) > 0 || (policy.unpricedUsagePolicy !== "allow" && observed.unpricedEventCount > 0);
|
||||
}
|
||||
async function policyBlocks(db: Db, policy: PolicyRow) {
|
||||
export async function policyBlocks(db: Db, policy: PolicyRow) {
|
||||
if (!policy.isActive || !policy.hardStopEnabled || policy.amount <= 0) return false;
|
||||
return observedBlocks(policy, await computeObservedSpend(db, policy));
|
||||
}
|
||||
@@ -282,18 +282,7 @@ export function budgetServiceInTransaction(db: Db, publications: ActivityPublica
|
||||
const recordActivity = (input: LogActivityInput) => logActivity(db, input, publications);
|
||||
async function pauseScopeForBudget(policy: PolicyRow) {
|
||||
const now = new Date();
|
||||
if (policy.scopeType === "agent") {
|
||||
await db.transaction(async tx => {
|
||||
const [agent] = await tx.select().from(agents)
|
||||
.where(and(eq(agents.id, policy.scopeId), eq(agents.companyId, policy.companyId))).for("update");
|
||||
if (agent && ["active", "idle", "running", "error"].includes(agent.status)) {
|
||||
await tx.update(agents).set({ status: "paused", pauseReason: "budget", pausedAt: now, updatedAt: now })
|
||||
.where(eq(agents.id, agent.id));
|
||||
await recordAgentStatusEvent(tx as unknown as Db, agent.companyId, agent.id, agent.status, "paused");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (policy.scopeType === "agent") return;
|
||||
|
||||
if (policy.scopeType === "project") {
|
||||
await db
|
||||
@@ -334,18 +323,7 @@ export function budgetServiceInTransaction(db: Db, publications: ActivityPublica
|
||||
if (await policyBlocks(db, candidate)) return;
|
||||
}
|
||||
const now = new Date();
|
||||
if (policy.scopeType === "agent") {
|
||||
await db.transaction(async tx => {
|
||||
const [agent] = await tx.select().from(agents)
|
||||
.where(and(eq(agents.id, policy.scopeId), eq(agents.companyId, policy.companyId))).for("update");
|
||||
if (agent?.status === "paused" && agent.pauseReason === "budget") {
|
||||
await tx.update(agents).set({ status: "idle", pauseReason: null, pausedAt: null, updatedAt: now })
|
||||
.where(eq(agents.id, agent.id));
|
||||
await recordAgentStatusEvent(tx as unknown as Db, agent.companyId, agent.id, agent.status, "idle");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (policy.scopeType === "agent") return;
|
||||
|
||||
if (policy.scopeType === "project") {
|
||||
await db
|
||||
@@ -993,7 +971,10 @@ export async function withCurrentBudgetEnforcement<T>(db: Db, scope: BudgetEnfor
|
||||
|
||||
/** Cancellation is an at-least-once external effect. A failed delivery never
|
||||
* rolls back committed spend, and its version remains pending for recovery. */
|
||||
export async function deliverBudgetEnforcement(db: Db, hooks: BudgetServiceHooks, companyId?: string) {
|
||||
export async function deliverBudgetEnforcement(db: Db, hooks: BudgetServiceHooks, companyId?: string, agentId?: string | null) {
|
||||
const { createAgentLifecycle } = await import("./agent-lifecycle.js");
|
||||
// Global recovery uses the bounded lifecycle sweep for policy holds.
|
||||
if (companyId) await createAgentLifecycle(db).reconcilePolicyHolds(companyId, agentId);
|
||||
if (!hooks.cancelWorkForScope) return;
|
||||
const pending = await db.select().from(budgetPolicies).where(and(
|
||||
sql`${budgetPolicies.enforcementVersion} > ${budgetPolicies.enforcementDeliveredVersion}`,
|
||||
@@ -1017,9 +998,9 @@ export async function deliverBudgetEnforcement(db: Db, hooks: BudgetServiceHooks
|
||||
|
||||
export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
const reads = budgetServiceInTransaction(db);
|
||||
async function mutate<T>(companyId: string, work: (service: ReturnType<typeof budgetServiceInTransaction>) => Promise<T>) {
|
||||
async function mutate<T>(companyId: string, work: (service: ReturnType<typeof budgetServiceInTransaction>) => Promise<T>, agentId?: string | null) {
|
||||
const result = await withAccountingTransaction(db, companyId, (tx, publications) => work(budgetServiceInTransaction(tx, publications)));
|
||||
await deliverBudgetEnforcement(db, hooks, companyId);
|
||||
await deliverBudgetEnforcement(db, hooks, companyId, agentId);
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
@@ -1044,7 +1025,7 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
evaluateCostEvent: (event: typeof costEvents.$inferSelect) =>
|
||||
mutate(event.companyId, (service) => service.evaluateCostEvent(event)),
|
||||
getInvocationBlock: (companyId: string, agentId: string | null, context?: { issueId?: string | null; projectId?: string | null }) =>
|
||||
mutate(companyId, (service) => service.getInvocationBlock(companyId, agentId, context)),
|
||||
mutate(companyId, (service) => service.getInvocationBlock(companyId, agentId, context), agentId),
|
||||
resolveIncident: (companyId: string, incidentId: string, input: BudgetIncidentResolutionInput, actorUserId: string) =>
|
||||
mutate(companyId, (service) => service.resolveIncident(companyId, incidentId, input, actorUserId)),
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAgentLifecycle } from "./agent-lifecycle.js";
|
||||
import fs from "node:fs/promises";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
@@ -22,7 +23,7 @@ import { approvalService } from "./approvals.js";
|
||||
import {
|
||||
readBuiltInAgentMarker,
|
||||
withBuiltInAgentMarker,
|
||||
} from "./built-in-agent-metadata.js";
|
||||
} from "../lib/built-in-agent-metadata.js";
|
||||
import { companySkillService } from "./company-skills.js";
|
||||
import { routineService } from "./routines.js";
|
||||
import { accessService } from "./access.js";
|
||||
@@ -808,6 +809,7 @@ function isBuiltInAgentMarkerConflict(error: unknown): boolean {
|
||||
|
||||
export function builtInAgentService(db: Db) {
|
||||
const agentSvc = agentService(db);
|
||||
const agentSvcLifecycle = createAgentLifecycle(db);
|
||||
const accessSvc = accessService(db);
|
||||
const approvalSvc = approvalService(db);
|
||||
const instructionsSvc = agentInstructionsService(db);
|
||||
@@ -1406,8 +1408,8 @@ export function builtInAgentService(db: Db) {
|
||||
}
|
||||
|
||||
async function ensureBuiltInAgentAssignable(agent: Agent) {
|
||||
if (agent.status !== "paused") return agent;
|
||||
const resumed = await agentSvc.resume(agent.id);
|
||||
if (agent.status !== "paused" || ["preparing", "verifying", "resuming"].includes(agent.lifecycleState ?? "")) return agent;
|
||||
const resumed = await agentSvcLifecycle.resumeAgent(agent.id);
|
||||
if (!resumed) throw notFound("Built-in agent not found");
|
||||
return resumed as Agent;
|
||||
}
|
||||
@@ -1667,7 +1669,7 @@ export function builtInAgentService(db: Db) {
|
||||
const [keep, ...duplicates] = markedRows;
|
||||
for (const duplicate of duplicates) {
|
||||
const openApproval = await approvalSvc.findOpenHireApprovalForAgent(companyId, duplicate.id);
|
||||
await agentSvc.terminate(duplicate.id);
|
||||
await agentSvcLifecycle.terminateAgent(duplicate.id);
|
||||
if (openApproval) {
|
||||
await approvalSvc.cancel(
|
||||
openApproval.id,
|
||||
@@ -1811,7 +1813,7 @@ export function builtInAgentService(db: Db) {
|
||||
: null;
|
||||
let created: Agent;
|
||||
try {
|
||||
created = await agentSvc.create(companyId, {
|
||||
created = await agentSvcLifecycle.requestHire(companyId, {
|
||||
...definitionPatch(definition, resolvedInput),
|
||||
status: definition.defaultStatus ?? "idle",
|
||||
pauseReason: definition.defaultStatus === "paused"
|
||||
@@ -1923,7 +1925,7 @@ export function builtInAgentService(db: Db) {
|
||||
: null;
|
||||
let pending: Agent;
|
||||
try {
|
||||
pending = await agentSvc.create(companyId, {
|
||||
pending = await agentSvcLifecycle.requestHire(companyId, {
|
||||
...definitionPatch(definition, input),
|
||||
status: "pending_approval",
|
||||
reportsTo,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { deleteCompany } from "./company-deletion.js";
|
||||
import { createAgentLifecycle } from "./agent-lifecycle.js";
|
||||
import { publishAccountingActivities } from "./accounting-transaction.js";
|
||||
import { budgetServiceInTransaction, deliverBudgetEnforcement, type BudgetServiceHooks } from "./budgets.js";
|
||||
import { and, count, eq, gte, inArray, isNull, lt, notInArray, sql } from "drizzle-orm";
|
||||
@@ -7,37 +9,10 @@ import {
|
||||
companyLogos,
|
||||
assets,
|
||||
agents,
|
||||
agentApiKeys,
|
||||
agentRuntimeState,
|
||||
agentTaskSessions,
|
||||
agentWakeupRequests,
|
||||
issues,
|
||||
issueComments,
|
||||
projects,
|
||||
goals,
|
||||
heartbeatRuns,
|
||||
runIdentityContexts,
|
||||
heartbeatRunEvents,
|
||||
costEvents,
|
||||
decisionInvocations,
|
||||
financeEvents,
|
||||
budgetPolicies,
|
||||
budgetIncidents,
|
||||
issueReadStates,
|
||||
approvalComments,
|
||||
approvals,
|
||||
activityLog,
|
||||
companySecrets,
|
||||
joinRequests,
|
||||
invites,
|
||||
principalPermissionGrants,
|
||||
companyMemberships,
|
||||
companySkills,
|
||||
documents,
|
||||
routineRuns,
|
||||
routineTriggers,
|
||||
routineRevisions,
|
||||
routines,
|
||||
} from "@paperclipai/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { isCloudManagedInstance } from "./cloud-instance.js";
|
||||
@@ -79,19 +54,7 @@ export function companyService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
type CompanyTx = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
|
||||
async function applyArchiveCascadeInTx(tx: CompanyTx, id: string) {
|
||||
const pausedAgentRows = await tx
|
||||
.update(agents)
|
||||
.set({
|
||||
status: "paused",
|
||||
pauseReason: "company_archived",
|
||||
pausedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(
|
||||
eq(agents.companyId, id),
|
||||
notInArray(agents.status, ["paused", "terminated", "pending_approval"]),
|
||||
))
|
||||
.returning({ id: agents.id });
|
||||
const pausedAgentRows = await tx.select({ id: agents.id }).from(agents).where(and(eq(agents.companyId, id), notInArray(agents.status, ["paused", "terminated", "pending_approval"])));
|
||||
|
||||
const activeRunIds = await tx
|
||||
.select({ id: heartbeatRuns.id })
|
||||
@@ -399,20 +362,7 @@ export function companyService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
|
||||
let agentsRestored = 0;
|
||||
if (willReactivate) {
|
||||
const restoredRows = await tx
|
||||
.update(agents)
|
||||
.set({
|
||||
status: "idle",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(
|
||||
eq(agents.companyId, id),
|
||||
eq(agents.status, "paused"),
|
||||
eq(agents.pauseReason, "company_archived"),
|
||||
))
|
||||
.returning({ id: agents.id });
|
||||
const restoredRows = await tx.select({ id: agents.id }).from(agents).where(and(eq(agents.companyId, id), eq(agents.pauseReason, "company_archived")));
|
||||
agentsRestored = restoredRows.length;
|
||||
}
|
||||
|
||||
@@ -464,6 +414,7 @@ export function companyService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
};
|
||||
});
|
||||
if (!result) return null;
|
||||
await createAgentLifecycle(db).reconcilePolicyHolds(id);
|
||||
publishAccountingActivities(id, budgetPublications);
|
||||
if (data.budgetMonthlyCents !== undefined) await deliverBudgetEnforcement(db, budgetHooks, id);
|
||||
// Post-commit, fire-and-forget, and BEFORE any finalization that
|
||||
@@ -542,6 +493,7 @@ export function companyService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
};
|
||||
});
|
||||
if (!result) return null;
|
||||
await createAgentLifecycle(db).reconcilePolicyHolds(id);
|
||||
|
||||
// Same doorbell rule as update(): the archive is committed, so ring
|
||||
// before finalization, which can throw without undoing it.
|
||||
@@ -553,67 +505,7 @@ export function companyService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
return result.company;
|
||||
},
|
||||
|
||||
remove: (id: string) =>
|
||||
db.transaction(async (tx) => {
|
||||
// Exclude accounting writers before taking child locks. KEY SHARE must
|
||||
// remain compatible: native writers can already hold a child row while
|
||||
// saving a company-scoped result. FOR UPDATE would deadlock that save.
|
||||
const [existing] = await tx.select({ id: companies.id }).from(companies)
|
||||
.where(eq(companies.id, id)).for("no key update");
|
||||
if (!existing) return null;
|
||||
// Finance can reference costs and both can reference runs. Incidents
|
||||
// reference policies and approvals; delete these dependents first.
|
||||
await tx.delete(financeEvents).where(eq(financeEvents.companyId, id));
|
||||
await tx.delete(decisionInvocations).where(eq(decisionInvocations.companyId, id));
|
||||
await tx.delete(costEvents).where(eq(costEvents.companyId, id));
|
||||
await tx.delete(budgetIncidents).where(eq(budgetIncidents.companyId, id));
|
||||
await tx.delete(budgetPolicies).where(eq(budgetPolicies.companyId, id));
|
||||
// Delete from child tables in dependency order
|
||||
const companyRunIds = await tx
|
||||
.select({ id: heartbeatRuns.id })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.companyId, id));
|
||||
|
||||
await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.companyId, id));
|
||||
if (companyRunIds.length > 0) {
|
||||
await tx
|
||||
.delete(heartbeatRunEvents)
|
||||
.where(inArray(heartbeatRunEvents.runId, companyRunIds.map((run) => run.id)));
|
||||
}
|
||||
await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.companyId, id));
|
||||
await tx.delete(activityLog).where(eq(activityLog.companyId, id));
|
||||
await tx.delete(runIdentityContexts).where(eq(runIdentityContexts.companyId, id));
|
||||
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, id));
|
||||
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, id));
|
||||
await tx.delete(agentApiKeys).where(eq(agentApiKeys.companyId, id));
|
||||
await tx.delete(agentRuntimeState).where(eq(agentRuntimeState.companyId, id));
|
||||
await tx.delete(issueComments).where(eq(issueComments.companyId, id));
|
||||
await tx.delete(approvalComments).where(eq(approvalComments.companyId, id));
|
||||
await tx.delete(approvals).where(eq(approvals.companyId, id));
|
||||
await tx.delete(companySecrets).where(eq(companySecrets.companyId, id));
|
||||
await tx.delete(joinRequests).where(eq(joinRequests.companyId, id));
|
||||
await tx.delete(invites).where(eq(invites.companyId, id));
|
||||
await tx.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, id));
|
||||
await tx.delete(companyMemberships).where(eq(companyMemberships.companyId, id));
|
||||
await tx.delete(companySkills).where(eq(companySkills.companyId, id));
|
||||
await tx.delete(routineRuns).where(eq(routineRuns.companyId, id));
|
||||
await tx.delete(routineTriggers).where(eq(routineTriggers.companyId, id));
|
||||
await tx.delete(routineRevisions).where(eq(routineRevisions.companyId, id));
|
||||
await tx.delete(routines).where(eq(routines.companyId, id));
|
||||
await tx.delete(issueReadStates).where(eq(issueReadStates.companyId, id));
|
||||
await tx.delete(documents).where(eq(documents.companyId, id));
|
||||
await tx.delete(issues).where(eq(issues.companyId, id));
|
||||
await tx.delete(companyLogos).where(eq(companyLogos.companyId, id));
|
||||
await tx.delete(assets).where(eq(assets.companyId, id));
|
||||
await tx.delete(goals).where(eq(goals.companyId, id));
|
||||
await tx.delete(projects).where(eq(projects.companyId, id));
|
||||
await tx.delete(agents).where(eq(agents.companyId, id));
|
||||
const rows = await tx
|
||||
.delete(companies)
|
||||
.where(eq(companies.id, id))
|
||||
.returning();
|
||||
return rows[0] ?? null;
|
||||
}),
|
||||
remove: (id: string) => deleteCompany(db, id),
|
||||
|
||||
stats: () =>
|
||||
Promise.all([
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { createAgentLifecycle, scheduleAgentLifecycle } from "./agent-lifecycle.js";
|
||||
import { agentLifecycleCompanyDeletion } from "../modules/agent-lifecycle/company-deletion.js";
|
||||
import {
|
||||
companies,
|
||||
agents,
|
||||
companyLogos,
|
||||
assets,
|
||||
agentApiKeys,
|
||||
agentRuntimeState,
|
||||
agentTaskSessions,
|
||||
agentWakeupRequests,
|
||||
issues,
|
||||
issueComments,
|
||||
projects,
|
||||
goals,
|
||||
heartbeatRuns,
|
||||
runIdentityContexts,
|
||||
heartbeatRunEvents,
|
||||
costEvents,
|
||||
decisionInvocations,
|
||||
financeEvents,
|
||||
budgetPolicies,
|
||||
budgetIncidents,
|
||||
issueReadStates,
|
||||
approvalComments,
|
||||
approvals,
|
||||
activityLog,
|
||||
companySecrets,
|
||||
joinRequests,
|
||||
invites,
|
||||
principalPermissionGrants,
|
||||
companyMemberships,
|
||||
companySkills,
|
||||
documents,
|
||||
routineRuns,
|
||||
routineTriggers,
|
||||
routineRevisions,
|
||||
routines,
|
||||
} from "@paperclipai/db";
|
||||
|
||||
export async function deleteCompany(db: Db, id: string) {
|
||||
const lifecycle = createAgentLifecycle(db);
|
||||
const companyAgents = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, id));
|
||||
for (const agent of companyAgents) {
|
||||
await lifecycle.terminateAgent(agent.id);
|
||||
await scheduleAgentLifecycle(db, agent.id);
|
||||
}
|
||||
return db.transaction(async (tx) => {
|
||||
// Exclude accounting writers before taking child locks. KEY SHARE must
|
||||
// remain compatible: native writers can already hold a child row while
|
||||
// saving a company-scoped result. FOR UPDATE would deadlock that save.
|
||||
const [existing] = await tx.select({ id: companies.id }).from(companies)
|
||||
.where(eq(companies.id, id)).for("no key update");
|
||||
if (!existing) return null;
|
||||
// Finance can reference costs and both can reference runs. Incidents
|
||||
// reference policies and approvals; delete these dependents first.
|
||||
await tx.delete(financeEvents).where(eq(financeEvents.companyId, id));
|
||||
await tx.delete(decisionInvocations).where(eq(decisionInvocations.companyId, id));
|
||||
await tx.delete(costEvents).where(eq(costEvents.companyId, id));
|
||||
await tx.delete(budgetIncidents).where(eq(budgetIncidents.companyId, id));
|
||||
await tx.delete(budgetPolicies).where(eq(budgetPolicies.companyId, id));
|
||||
// Delete from child tables in dependency order
|
||||
const companyRunIds = await tx
|
||||
.select({ id: heartbeatRuns.id })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.companyId, id));
|
||||
|
||||
await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.companyId, id));
|
||||
if (companyRunIds.length > 0) {
|
||||
await tx
|
||||
.delete(heartbeatRunEvents)
|
||||
.where(inArray(heartbeatRunEvents.runId, companyRunIds.map((run) => run.id)));
|
||||
}
|
||||
await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.companyId, id));
|
||||
await tx.delete(activityLog).where(eq(activityLog.companyId, id));
|
||||
await tx.delete(runIdentityContexts).where(eq(runIdentityContexts.companyId, id));
|
||||
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, id));
|
||||
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, id));
|
||||
await tx.delete(agentApiKeys).where(eq(agentApiKeys.companyId, id));
|
||||
await tx.delete(agentRuntimeState).where(eq(agentRuntimeState.companyId, id));
|
||||
await tx.delete(issueComments).where(eq(issueComments.companyId, id));
|
||||
await tx.delete(approvalComments).where(eq(approvalComments.companyId, id));
|
||||
await tx.delete(approvals).where(eq(approvals.companyId, id));
|
||||
await tx.delete(companySecrets).where(eq(companySecrets.companyId, id));
|
||||
await tx.delete(joinRequests).where(eq(joinRequests.companyId, id));
|
||||
await tx.delete(invites).where(eq(invites.companyId, id));
|
||||
await tx.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, id));
|
||||
await tx.delete(companyMemberships).where(eq(companyMemberships.companyId, id));
|
||||
await tx.delete(companySkills).where(eq(companySkills.companyId, id));
|
||||
await tx.delete(routineRuns).where(eq(routineRuns.companyId, id));
|
||||
await tx.delete(routineTriggers).where(eq(routineTriggers.companyId, id));
|
||||
await tx.delete(routineRevisions).where(eq(routineRevisions.companyId, id));
|
||||
await tx.delete(routines).where(eq(routines.companyId, id));
|
||||
await tx.delete(issueReadStates).where(eq(issueReadStates.companyId, id));
|
||||
await tx.delete(documents).where(eq(documents.companyId, id));
|
||||
await tx.delete(issues).where(eq(issues.companyId, id));
|
||||
await tx.delete(companyLogos).where(eq(companyLogos.companyId, id));
|
||||
await tx.delete(assets).where(eq(assets.companyId, id));
|
||||
await tx.delete(goals).where(eq(goals.companyId, id));
|
||||
await tx.delete(projects).where(eq(projects.companyId, id));
|
||||
await agentLifecycleCompanyDeletion.deleteCompanyData(tx, id);
|
||||
const rows = await tx
|
||||
.delete(companies)
|
||||
.where(eq(companies.id, id))
|
||||
.returning();
|
||||
return rows[0] ?? null;
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { normalizeAgentUrlKey } from "@paperclipai/shared";
|
||||
import { readBuiltInAgentMarker } from "./built-in-agent-metadata.js";
|
||||
import { readBuiltInAgentMarker } from "../lib/built-in-agent-metadata.js";
|
||||
|
||||
interface ExportAgentCandidate {
|
||||
id: string;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAgentLifecycle } from "./agent-lifecycle.js";
|
||||
import { agentAppearanceSchema, agentHarnessType, agentRunner, type AgentRunnerChoice } from "@paperclipai/shared";
|
||||
import { resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
@@ -3567,6 +3568,7 @@ export function parseGitHubSourceUrl(rawUrl: string) {
|
||||
export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
const companies = companyService(db);
|
||||
const agents = agentService(db);
|
||||
const agentsLifecycle = createAgentLifecycle(db);
|
||||
const assetRecords = assetService(db);
|
||||
const instructions = agentInstructionsService(db);
|
||||
const access = accessService(db);
|
||||
@@ -5687,9 +5689,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
: {};
|
||||
|
||||
if (planAgent.action === "update" && planAgent.existingAgentId) {
|
||||
if (pauseAutomations) await agentsLifecycle.pauseAgent(planAgent.existingAgentId, "import");
|
||||
let updated = await agents.update(planAgent.existingAgentId, {
|
||||
...patch,
|
||||
...automationPausePatch,
|
||||
});
|
||||
if (!updated) {
|
||||
warnings.push(`Skipped update for missing agent ${planAgent.existingAgentId}.`);
|
||||
@@ -5735,7 +5737,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let created = await agents.create(targetCompany.id, {
|
||||
let created = await agentsLifecycle.requestHire(targetCompany.id, {
|
||||
...patch,
|
||||
runner: agentRunner(patch.adapterType),
|
||||
...automationPausePatch,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { removeRuntimeSkillCache, resolveRuntimeSkillCache, runtimeSkillCacheSpec } from "./runtime-skill-cache.js";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
@@ -3925,15 +3926,9 @@ export function companySkillService(db: Db) {
|
||||
? { key: forkKey, versionId: null }
|
||||
: entry
|
||||
);
|
||||
const updated = await tx
|
||||
.update(agentsTable)
|
||||
.set({
|
||||
adapterConfig: writePaperclipSkillSyncPreference(adapterConfig, nextEntries),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(agentsTable.companyId, companyId), eq(agentsTable.id, item.agentId)))
|
||||
.returning({ id: agentsTable.id })
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const updated = await updateAgentConfigurationInTransaction(tx as unknown as Db, item.agentId, {
|
||||
adapterConfig: writePaperclipSkillSyncPreference(adapterConfig, nextEntries),
|
||||
}, { allowPendingApprovalConfigUpdate: true });
|
||||
if (!updated) throw notFound(`Agent not found for skill reassignment: ${item.agentId}`);
|
||||
}
|
||||
});
|
||||
@@ -4196,13 +4191,9 @@ export function companySkillService(db: Db) {
|
||||
entry.key === previousKey
|
||||
? { key: newKey, versionId: entry.versionId ?? null }
|
||||
: entry);
|
||||
await tx
|
||||
.update(agentsTable)
|
||||
.set({
|
||||
adapterConfig: writePaperclipSkillSyncPreference(adapterConfig, nextEntries),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(agentsTable.companyId, companyId), eq(agentsTable.id, item.agentId)));
|
||||
await updateAgentConfigurationInTransaction(tx as unknown as Db, item.agentId, {
|
||||
adapterConfig: writePaperclipSkillSyncPreference(adapterConfig, nextEntries),
|
||||
}, { allowPendingApprovalConfigUpdate: true });
|
||||
}
|
||||
} catch (error) {
|
||||
// Restore while the name locks are still held.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js";
|
||||
import { emailChannelService } from "./email-channels.js";
|
||||
import { emailConnectionService } from "./email-connections.js";
|
||||
import { grantConnectionAgentTools } from "./connection-agent-access.js";
|
||||
@@ -1035,7 +1036,7 @@ export function connectionIntentService(db: Db) {
|
||||
}
|
||||
const binding = options.validatedAdoption.binding;
|
||||
if (binding.provider !== payload.serviceSlug || binding.mode !== "responsible_user") throw conflict("Invalid legacy adoption binding");
|
||||
const updated = await agentService(txDb).update(agent.id, {
|
||||
const updated = await updateAgentConfigurationInTransaction(txDb, agent.id, {
|
||||
runtimeConfig: { ...agent.runtimeConfig, aiConnection: binding },
|
||||
}, { recordRevision: { createdByUserId: userId, source: "patch" } });
|
||||
if (!updated) throw notFound("Agent not found");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { and, desc, eq, isNull, ne, sql } from "drizzle-orm";
|
||||
import { activityLog, agents, companies, dotAgentBindings, type Db } from "@paperclipai/db";
|
||||
import { agentService } from "./agents.js";
|
||||
import { createAgentLifecycle, scheduleAgentLifecycle } from "./agent-lifecycle.js";
|
||||
import { withDedicatedDbConnection } from "@paperclipai/db";
|
||||
import { approvalService } from "./approvals.js";
|
||||
import { accessService } from "./access.js";
|
||||
import { agentInstructionsService } from "./agent-instructions.js";
|
||||
@@ -11,21 +13,22 @@ import { notFound, unprocessable } from "../errors.js";
|
||||
|
||||
/** One unfinished invitation per operator/company. Only non-secret references are persisted. */
|
||||
export function dotInvitationService(db: Db) {
|
||||
async function pending(database: Db, companyId: string, operatorId: string) {
|
||||
async function pending(database: Db, companyId: string, operatorId: string, recoverIncomplete = false) {
|
||||
const rows = await database.select({ agent: agents, binding: dotAgentBindings })
|
||||
.from(agents).leftJoin(dotAgentBindings, and(eq(dotAgentBindings.agentId, agents.id), isNull(dotAgentBindings.revokedAt)))
|
||||
.where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated"),
|
||||
eq(agents.adapterType, "paperclip_runner"), sql`${agents.adapterConfig}->>'provider' = 'openai_dot'`,
|
||||
sql`exists (select 1 from ${activityLog} where ${activityLog.companyId} = ${agents.companyId}
|
||||
sql`((${recoverIncomplete} and ${agents.metadata}->'dotInvitation'->>'operatorId' = ${operatorId}
|
||||
and ${agents.lifecycleOperation}->>'responsibleUserId' = ${operatorId}) or exists (select 1 from ${activityLog} where ${activityLog.companyId} = ${agents.companyId}
|
||||
and ${activityLog.entityId} = ${agents.id}::text and ${activityLog.entityType} = 'agent'
|
||||
and ${activityLog.actorType} = 'user' and ${activityLog.actorId} = ${operatorId}
|
||||
and ${activityLog.action} = 'agent.hire_created' and ${activityLog.details}->>'source' = 'dot-invitation')`)).orderBy(desc(agents.createdAt));
|
||||
and ${activityLog.action} = 'agent.hire_created' and ${activityLog.details}->>'source' = 'dot-invitation'))`)).orderBy(desc(agents.createdAt));
|
||||
return rows.find(row => row.binding?.status !== "ready")?.agent ?? null;
|
||||
}
|
||||
async function describe(agent: typeof agents.$inferSelect) {
|
||||
const approval = await approvalService(db).findOpenHireApprovalForAgent(agent.companyId, agent.id);
|
||||
async function describe(agent: Pick<typeof agents.$inferSelect, "id" | "name" | "companyId" | "status">, database: Db = db) {
|
||||
const approval = await approvalService(database).findOpenHireApprovalForAgent(agent.companyId, agent.id);
|
||||
return { agent: { id: agent.id, name: agent.name, status: agent.status }, approvalId: approval?.id ?? null,
|
||||
binding: await dotRunnerBroker(db).bindingForAgent(agent.companyId, agent.id) };
|
||||
binding: await dotRunnerBroker(database).bindingForAgent(agent.companyId, agent.id) };
|
||||
}
|
||||
return {
|
||||
async resume(companyId: string, operatorId: string) {
|
||||
@@ -34,16 +37,24 @@ export function dotInvitationService(db: Db) {
|
||||
},
|
||||
async create(companyId: string, operatorId: string) {
|
||||
if (!await dotRunnerBroker(db).enabled()) throw unprocessable("Enable OpenAI Dot and Assistant connections (MCP) in experimental settings.");
|
||||
const agent = await db.transaction(async tx => {
|
||||
const database = tx as unknown as Db;
|
||||
const database = db;
|
||||
const lockKey = `paperclip:dot-invitation:${companyId}:${operatorId}`;
|
||||
return withDedicatedDbConnection(db, lockDb => lockDb.transaction(async lock => {
|
||||
await lock.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
|
||||
// SQL locking also serializes retries on different control-plane replicas.
|
||||
const [company] = await tx.select().from(companies).where(eq(companies.id, companyId)).for("update");
|
||||
const [company] = await database.select().from(companies).where(eq(companies.id, companyId));
|
||||
if (!company) throw notFound("Company not found");
|
||||
if (company.status !== "active") throw unprocessable("Activate this company before inviting Dot.");
|
||||
const existing = await pending(database, companyId, operatorId);
|
||||
if (existing) return existing;
|
||||
const existing = await pending(database, companyId, operatorId, true);
|
||||
if (existing) {
|
||||
const [completed] = await database.select({ id: activityLog.id }).from(activityLog).where(and(
|
||||
eq(activityLog.companyId, companyId), eq(activityLog.entityId, existing.id),
|
||||
eq(activityLog.action, "agent.hire_created"), eq(activityLog.actorId, operatorId),
|
||||
sql`${activityLog.details}->>'source' = 'dot-invitation'`)).limit(1);
|
||||
if (completed) return describe(existing, database);
|
||||
}
|
||||
const svc = agentService(database);
|
||||
let created = await svc.create(companyId, {
|
||||
let created = existing ?? await createAgentLifecycle(database).requestHire(companyId, {
|
||||
name: "Dot", role: "general", adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "openai_dot", lifecycleMode: "per_turn", allowUnmeteredProvider: true,
|
||||
dotWorkspaceAccess: false, dotAttachmentAccess: false },
|
||||
@@ -54,25 +65,28 @@ export function dotInvitationService(db: Db) {
|
||||
const bundle = await agentInstructionsService(database).materializeManagedBundle(created,
|
||||
await loadDefaultAgentInstructionsBundle("default"), { entryFile: "AGENTS.md", replaceExisting: false });
|
||||
created = (await svc.update(created.id, { adapterConfig: bundle.adapterConfig }, { allowPendingApprovalConfigUpdate: true }))!;
|
||||
const access = accessService(database);
|
||||
await access.ensureMembership(companyId, "agent", created.id, "member", "active");
|
||||
await access.setPrincipalPermission(companyId, "agent", created.id, "tasks:assign", true, operatorId);
|
||||
const approval = company.requireBoardApprovalForNewAgents ? await approvalService(database).create(companyId, {
|
||||
type: "hire_agent", status: "pending", requestedByUserId: operatorId,
|
||||
payload: { agentId: created.id, name: created.name, role: created.role, adapterType: created.adapterType,
|
||||
adapterConfig: created.adapterConfig, runtimeConfig: created.runtimeConfig, metadata: created.metadata,
|
||||
budgetMonthlyCents: created.budgetMonthlyCents, requestedConfigurationSnapshot: {
|
||||
adapterType: created.adapterType, runtimeConfig: created.runtimeConfig, adapterConfig: created.adapterConfig,
|
||||
} },
|
||||
}) : null;
|
||||
await logActivity(database, { companyId, actorType: "user", actorId: operatorId, action: "agent.hire_created",
|
||||
entityType: "agent", entityId: created.id, details: { name: created.name, role: created.role,
|
||||
requiresApproval: !!approval, approvalId: approval?.id ?? null, source: "dot-invitation" } });
|
||||
if (approval) await logActivity(database, { companyId, actorType: "user", actorId: operatorId, action: "approval.created",
|
||||
entityType: "approval", entityId: approval.id, details: { type: "hire_agent", agentId: created.id } });
|
||||
return created;
|
||||
});
|
||||
return describe(agent);
|
||||
await database.transaction(async tx => {
|
||||
const txDb = tx as unknown as Db;
|
||||
const access = accessService(txDb);
|
||||
await access.ensureMembership(companyId, "agent", created.id, "member", "active");
|
||||
await access.setPrincipalPermission(companyId, "agent", created.id, "tasks:assign", true, operatorId);
|
||||
const approval = await approvalService(txDb).findOpenHireApprovalForAgent(companyId, created.id) ?? (company.requireBoardApprovalForNewAgents ? await approvalService(txDb).create(companyId, {
|
||||
type: "hire_agent", status: "pending", requestedByUserId: operatorId,
|
||||
payload: { agentId: created.id, name: created.name, role: created.role, adapterType: created.adapterType,
|
||||
adapterConfig: created.adapterConfig, runtimeConfig: created.runtimeConfig, metadata: created.metadata,
|
||||
budgetMonthlyCents: created.budgetMonthlyCents, requestedConfigurationSnapshot: {
|
||||
adapterType: created.adapterType, runtimeConfig: created.runtimeConfig, adapterConfig: created.adapterConfig,
|
||||
} },
|
||||
}) : null);
|
||||
await logActivity(txDb, { companyId, actorType: "user", actorId: operatorId, action: "agent.hire_created",
|
||||
entityType: "agent", entityId: created.id, details: { name: created.name, role: created.role,
|
||||
requiresApproval: !!approval, approvalId: approval?.id ?? null, source: "dot-invitation" } });
|
||||
if (approval) await logActivity(txDb, { companyId, actorType: "user", actorId: operatorId, action: "approval.created",
|
||||
entityType: "approval", entityId: approval.id, details: { type: "hire_agent", agentId: created.id } });
|
||||
});
|
||||
scheduleAgentLifecycle(db, created.id);
|
||||
return describe(created, database);
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user