merge: integrate agent lifecycle ownership from master

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-09 11:24:59 -05:00
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 () => {
+3 -2
View File
@@ -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" });
+67 -16
View File
@@ -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);
+24 -10
View File
@@ -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,
}));
+5 -1
View File
@@ -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" });
});
+28 -2
View File
@@ -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,
+10 -8
View File
@@ -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 }]);
});
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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();