diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 772e97668e..a0f30babbb 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -452,6 +452,37 @@ Hosted AWS provider notes live in [SECRETS-AWS-PROVIDER.md](./SECRETS-AWS-PROVID Migration `0274_agent_chat.sql` adds conversation identity/state and session generation/boundary columns to `issues`, plus idempotent client request IDs and processed session-boundary generations to `issue_comments`. The company/agent/user unique index resolves concurrent first writes to one issue. A check constraint preserves the assigned-agent identity and prevents terminal conversation status. Comment request IDs are unique per issue and user. There is no separate chat/message store. Provider sessions continue to use `agent_task_sessions`; `/new` removes only the matching conversation session, and session writers fence stale generations against the issue row. +## Resource lifecycle events + +`resource_lifecycle_events` records content-free lifecycle hooks in the same +transaction as the resource change. Hired agents and new projects emit `create`. +Pending hires emit creation only when `activatePendingApproval` succeeds. +Rejected hires emit termination without creation. Agents created as terminated +emit no creation event. Capture is generic and works on self-hosted and managed +instances; recording an event does not authorize a provider operation. + +A partial unique `(company_id, resource_type, resource_id)` index deduplicates +creation. Each actual agent pause, resume, or termination appends another event, +including budget actions and generic status updates. The agent row stays locked +until status and event commit, so concurrent repeat requests emit one hook. +Termination commits API-key revocation in that same transaction. +Hire approval and rejection commit with agent activation or termination, so a +failed event write leaves the decision pending and retryable. + +The numeric event ID orders transitions for a resource. Future plugin delivery +must enforce company scope, preserve resource order, and track acknowledgments +per plugin. A global high-water mark can skip transactions that have not yet +committed; it is not a safe delivery cursor. The journal stores only identity, +action, and timestamps, not repository snapshots, credentials, provider config, +or resource health. Company deletion cascades to its events. Resource deletion +retains events, so consumers must revalidate existence and eligibility and load +current authorized repository data. A termination hook does not authorize +removing persistent VM or project data. + +The migration creates an empty table. It does not scan or backfill existing +installs. Plugin delivery, retention, retries, and provider integration are +separate work. This change makes no provider calls and adds no plugin read API. + ## Legacy controller ownership Legacy run claims atomically record `controller_boot_id`, a database-clock diff --git a/packages/db/src/migrations/0297_foamy_swordsman.sql b/packages/db/src/migrations/0297_foamy_swordsman.sql new file mode 100644 index 0000000000..b9f682960e --- /dev/null +++ b/packages/db/src/migrations/0297_foamy_swordsman.sql @@ -0,0 +1,14 @@ +CREATE TABLE "resource_lifecycle_events" ( + "id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "resource_lifecycle_events_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1), + "company_id" uuid NOT NULL, + "resource_type" text NOT NULL, + "resource_id" uuid NOT NULL, + "action" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "resource_lifecycle_events_resource_type_check" CHECK ("resource_lifecycle_events"."resource_type" IN ('agent', 'project')), + CONSTRAINT "resource_lifecycle_events_action_check" CHECK ("resource_lifecycle_events"."action" = 'create' OR ("resource_lifecycle_events"."resource_type" = 'agent' AND "resource_lifecycle_events"."action" IN ('pause', 'resume', 'terminate'))) +); +--> statement-breakpoint +ALTER TABLE "resource_lifecycle_events" ADD CONSTRAINT "resource_lifecycle_events_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "resource_lifecycle_events_creation_idx" ON "resource_lifecycle_events" USING btree ("company_id","resource_type","resource_id") WHERE "resource_lifecycle_events"."action" = 'create';--> statement-breakpoint +CREATE INDEX "resource_lifecycle_events_company_idx" ON "resource_lifecycle_events" USING btree ("company_id","id"); \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0292_snapshot.json b/packages/db/src/migrations/meta/0297_snapshot.json similarity index 99% rename from packages/db/src/migrations/meta/0292_snapshot.json rename to packages/db/src/migrations/meta/0297_snapshot.json index a10ea2e010..76e640413e 100644 --- a/packages/db/src/migrations/meta/0292_snapshot.json +++ b/packages/db/src/migrations/meta/0297_snapshot.json @@ -1,6 +1,6 @@ { - "id": "239dfc30-3531-48b5-a06f-72608de479da", - "prevId": "a02dd1cf-75c9-4c4d-b13d-cf5dcd729ad3", + "id": "ed75c75c-748b-4420-8a68-ec5b34394e75", + "prevId": "a6c67322-9605-4234-b05d-b3585e943e42", "version": "7", "dialect": "postgresql", "tables": { @@ -31465,6 +31465,13 @@ "primaryKey": false, "notNull": true }, + "title_needs_generation": { + "name": "title_needs_generation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, "description": { "name": "description", "type": "text", @@ -38911,6 +38918,140 @@ }, "isRLSEnabled": false }, + "public.resource_lifecycle_events": { + "name": "resource_lifecycle_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "resource_lifecycle_events_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "resource_lifecycle_events_creation_idx": { + "name": "resource_lifecycle_events_creation_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"resource_lifecycle_events\".\"action\" = 'create'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_lifecycle_events_company_idx": { + "name": "resource_lifecycle_events_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_lifecycle_events_company_id_companies_id_fk": { + "name": "resource_lifecycle_events_company_id_companies_id_fk", + "tableFrom": "resource_lifecycle_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "resource_lifecycle_events_resource_type_check": { + "name": "resource_lifecycle_events_resource_type_check", + "value": "\"resource_lifecycle_events\".\"resource_type\" IN ('agent', 'project')" + }, + "resource_lifecycle_events_action_check": { + "name": "resource_lifecycle_events_action_check", + "value": "\"resource_lifecycle_events\".\"action\" = 'create' OR (\"resource_lifecycle_events\".\"resource_type\" = 'agent' AND \"resource_lifecycle_events\".\"action\" IN ('pause', 'resume', 'terminate'))" + } + }, + "isRLSEnabled": false + }, "public.routine_documents": { "name": "routine_documents", "schema": "", @@ -44490,6 +44631,298 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.tool_connection_app_snapshots": { + "name": "tool_connection_app_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_key": { + "name": "credential_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "toolkit": { + "name": "toolkit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accounts": { + "name": "accounts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error_at": { + "name": "error_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "tool_connection_app_snapshots_owner_toolkit_uq": { + "name": "tool_connection_app_snapshots_owner_toolkit_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "toolkit", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tool_connection_app_snapshots_company_id_companies_id_fk": { + "name": "tool_connection_app_snapshots_company_id_companies_id_fk", + "tableFrom": "tool_connection_app_snapshots", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_connection_app_snapshots_company_connection_fk": { + "name": "tool_connection_app_snapshots_company_connection_fk", + "tableFrom": "tool_connection_app_snapshots", + "tableTo": "tool_connections", + "columnsFrom": [ + "company_id", + "connection_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_connection_app_syncs": { + "name": "tool_connection_app_syncs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_key": { + "name": "credential_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lease_id": { + "name": "lease_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checked": { + "name": "checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total": { + "name": "total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed": { + "name": "failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_completed_at": { + "name": "last_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tool_connection_app_syncs_owner_key_uq": { + "name": "tool_connection_app_syncs_owner_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tool_connection_app_syncs_company_id_companies_id_fk": { + "name": "tool_connection_app_syncs_company_id_companies_id_fk", + "tableFrom": "tool_connection_app_syncs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_connection_app_syncs_company_connection_fk": { + "name": "tool_connection_app_syncs_company_connection_fk", + "tableFrom": "tool_connection_app_syncs", + "tableTo": "tool_connections", + "columnsFrom": [ + "company_id", + "connection_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.tool_connection_installs": { "name": "tool_connection_installs", "schema": "", @@ -46202,6 +46635,27 @@ "method": "btree", "with": {} }, + "tool_mcp_gateway_tokens_expiry_idx": { + "name": "tool_mcp_gateway_tokens_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, "tool_mcp_gateway_tokens_gateway_idx": { "name": "tool_mcp_gateway_tokens_gateway_idx", "columns": [ diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 9c603bcb3b..4b91019db5 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2066,6 +2066,13 @@ "when": 1791028244412, "tag": "0296_stiff_thaddeus_ross", "breakpoints": true + }, + { + "idx": 297, + "version": "7", + "when": 1791233758328, + "tag": "0297_foamy_swordsman", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 75116399cc..ce1ebff078 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -200,6 +200,7 @@ export { pluginWebhookDeliveries } from "./plugin_webhooks.js"; export { pluginLogs } from "./plugin_logs.js"; export { runIdentityContexts } from "./run_identity_contexts.js"; export { connectionIntentDeliveries } from "./connection_intent_deliveries.js"; +export { resourceLifecycleEvents } from "./resource_lifecycle_events.js"; export { toolActionDeliveries } from "./tool_action_deliveries.js"; export { chatTeamsFileTransfers } from "./chat_teams_file_transfers.js"; diff --git a/packages/db/src/schema/resource_lifecycle_events.ts b/packages/db/src/schema/resource_lifecycle_events.ts new file mode 100644 index 0000000000..087af412f9 --- /dev/null +++ b/packages/db/src/schema/resource_lifecycle_events.ts @@ -0,0 +1,18 @@ +import { bigint, check, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import { companies } from "./companies.js"; + +/** Content-free lifecycle journal. Consumers must revalidate the resource before provisioning. */ +export const resourceLifecycleEvents = pgTable("resource_lifecycle_events", { + id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + resourceType: text("resource_type").$type<"agent" | "project">().notNull(), + resourceId: uuid("resource_id").notNull(), + action: text("action").$type<"create" | "pause" | "resume" | "terminate">().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + resourceIdx: uniqueIndex("resource_lifecycle_events_creation_idx").on(table.companyId, table.resourceType, table.resourceId).where(sql`${table.action} = 'create'`), + companyIdx: index("resource_lifecycle_events_company_idx").on(table.companyId, table.id), + resourceTypeCheck: check("resource_lifecycle_events_resource_type_check", sql`${table.resourceType} IN ('agent', 'project')`), + actionCheck: check("resource_lifecycle_events_action_check", sql`${table.action} = 'create' OR (${table.resourceType} = 'agent' AND ${table.action} IN ('pause', 'resume', 'terminate'))`), +})); diff --git a/server/src/__tests__/approvals-service.test.ts b/server/src/__tests__/approvals-service.test.ts index e3411d89dd..e944e2b416 100644 --- a/server/src/__tests__/approvals-service.test.ts +++ b/server/src/__tests__/approvals-service.test.ts @@ -49,7 +49,7 @@ function createDbStub(selectResults: ApprovalRecord[][], updateResults: Approval const update = vi.fn(() => ({ set })); return { - db: { select, update }, + db: { select, update, transaction: vi.fn(async (callback: (db: unknown) => Promise) => callback({ select, update })) }, selectWhere, returning, }; @@ -105,6 +105,17 @@ describe("approvalService resolution idempotency", () => { expect(mockNotifyHireApproved).toHaveBeenCalledTimes(1); }); + it("does not notify the adapter when the approval transaction fails to commit", async () => { + const approved = createApproval("approved"); + const dbStub = createDbStub([[createApproval("pending")]], [approved]); + dbStub.db.transaction.mockImplementationOnce(async callback => { + await callback(dbStub.db); + throw new Error("commit failed"); + }); + await expect(approvalService(dbStub.db as any).approve("approval-1", "board")).rejects.toThrow("commit failed"); + expect(mockNotifyHireApproved).not.toHaveBeenCalled(); + }); + it("creates the agent from payload when approval does not reference a pending agent", async () => { const approved = { ...createApproval("approved"), diff --git a/server/src/__tests__/budgets-service.test.ts b/server/src/__tests__/budgets-service.test.ts index 8a67682726..8e2d68ed22 100644 --- a/server/src/__tests__/budgets-service.test.ts +++ b/server/src/__tests__/budgets-service.test.ts @@ -26,7 +26,10 @@ type SelectResult = unknown[]; function createDbStub(selectResults: SelectResult[]) { const pendingSelects = [...selectResults]; - const selectWhere = vi.fn(async () => pendingSelects.shift() ?? []); + const selectWhere = vi.fn(() => { + const query = Promise.resolve(pendingSelects.shift() ?? []); + return Object.assign(query, { for: () => query }); + }); const selectThen = vi.fn((resolve: (value: unknown[]) => unknown) => Promise.resolve(resolve(pendingSelects.shift() ?? []))); const selectOrderBy = vi.fn(async () => pendingSelects.shift() ?? []); const selectFrom = vi.fn(() => ({ @@ -62,6 +65,7 @@ function createDbStub(selectResults: SelectResult[]) { select, insert, update, + transaction: async (callback: (db: unknown) => Promise) => callback({ select, insert, update }), }, queueInsert: (rows: unknown[]) => { pendingInserts.push(rows); @@ -105,6 +109,7 @@ describe("budgetService", () => { status: "running", pauseReason: null, }], + [{ id: "agent-1", companyId: "company-1", status: "running" }], ]); dbStub.queueInsert([{ diff --git a/server/src/__tests__/resource-lifecycle-events.test.ts b/server/src/__tests__/resource-lifecycle-events.test.ts new file mode 100644 index 0000000000..a61e31f51a --- /dev/null +++ b/server/src/__tests__/resource-lifecycle-events.test.ts @@ -0,0 +1,221 @@ +import { randomUUID } from "node:crypto"; +import { eq, sql } from "drizzle-orm"; +import { agentApiKeys, agents, budgetPolicies, companies, costEvents, createDb, projects, resourceLifecycleEvents, type Db } from "@paperclipai/db"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { agentService } from "../services/agents.js"; +import { approvalService } from "../services/approvals.js"; +import { budgetService } from "../services/budgets.js"; +import { projectService } from "../services/projects.js"; +import { recordResourceCreationEvent } from "../services/resource-lifecycle-events.js"; + +const support = await getEmbeddedPostgresTestSupport(); +const describePostgres = support.supported ? describe : describe.skip; +if (!support.supported) console.warn(`Skipping lifecycle event database tests: ${support.reason}`); + +describePostgres("Resource lifecycle events", () => { + let database: Awaited>; + let db: Db; + let companyId: string; + + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase("paperclip-resource-events-"); + db = createDb(database.connectionString); + }, 90_000); + afterAll(async () => { await database?.cleanup(); }); + beforeEach(async () => { + vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", undefined); + vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", undefined); + 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 }); + + it("records direct and approval-free hires once, including concurrent duplicate submissions", async () => { + const agent = await createAgent(); + await Promise.all(Array.from({ length: 5 }, () => recordResourceCreationEvent(db, companyId, "agent", agent.id))); + expect(await events()).toEqual([expect.objectContaining({ resourceType: "agent", resourceId: agent.id, action: "create" })]); + }); + + it("records pending hires only after approval and ignores repeated approval", async () => { + const agent = await createAgent("pending_approval"); + expect(await events()).toEqual([]); + 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); + expect(await events()).toEqual([expect.objectContaining({ resourceType: "agent", resourceId: agent.id })]); + }); + + it("records legacy approvals that create the agent at approval time", async () => { + 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(); + expect(intent?.resourceType).toBe("agent"); + expect(await agentService(db).getById(intent!.resourceId)).toMatchObject({ status: "idle", companyId }); + }); + + it("does not allocate for rejected or terminated hires", async () => { + const agent = await createAgent("pending_approval"); + const approval = await approvalService(db).create(companyId, { type: "hire_agent", status: "pending", payload: { agentId: agent.id } }); + await approvalService(db).reject(approval.id, "fixture-board"); + await createAgent("terminated"); + expect(await events()).toEqual([expect.objectContaining({ action: "terminate", resourceId: agent.id })]); + }); + + it("rolls back hire rejection if termination cannot record its hook, allowing a retry", async () => { + const agent = await createAgent("pending_approval"); + const service = approvalService(db); + const approval = await service.create(companyId, { type: "hire_agent", status: "pending", payload: { agentId: agent.id } }); + await db.execute(sql`ALTER TABLE resource_lifecycle_events ADD CONSTRAINT fixture_reject_hook CHECK (false) NOT VALID`); + try { + await expect(service.reject(approval.id, "fixture-board")).rejects.toThrow(); + expect(await service.getById(approval.id)).toMatchObject({ status: "pending" }); + expect(await agentService(db).getById(agent.id)).toMatchObject({ status: "pending_approval" }); + expect(await events()).toEqual([]); + } finally { + await db.execute(sql`ALTER TABLE resource_lifecycle_events DROP CONSTRAINT fixture_reject_hook`); + } + expect(await service.reject(approval.id, "fixture-board")).toMatchObject({ applied: true, approval: { status: "rejected" } }); + expect(await agentService(db).getById(agent.id)).toMatchObject({ status: "terminated" }); + expect(await service.reject(approval.id, "fixture-board")).toMatchObject({ applied: false }); + expect(await events()).toEqual([expect.objectContaining({ action: "terminate", resourceId: agent.id })]); + }); + + it("records projects with zero or multiple repository workspaces without storing repository data", async () => { + const empty = await projectService(db).create(companyId, { name: "Empty project" }); + const project = await projectService(db).createWithRepositories(companyId, { name: "Repository project" }, [ + { id: "1", fullName: "fixture/one", url: "https://github.com/fixture/one", connections: [] }, + { id: "2", fullName: "fixture/two", url: "https://github.com/fixture/two", connections: [] }, + ]); + expect(project.workspaces).toHaveLength(2); + const rows = await events(); + expect(rows.map(row => row.resourceId).sort()).toEqual([empty.id, project.id].sort()); + expect(rows.every(row => row.resourceType === "project")).toBe(true); + expect(Object.keys(rows[0]).sort()).toEqual(["action", "companyId", "createdAt", "id", "resourceId", "resourceType"]); + }); + + it("records every pause/resume cycle and termination in resource order, without duplicate hooks", async () => { + const agent = await createAgent(); + const service = agentService(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))); + 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 }); + }); + + 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 }); + expect(await events()).toEqual([]); + }); + + it("records budget pause and resume hooks without replaying repeated budget evaluation", async () => { + const agent = await createAgent(); + const service = budgetService(db); + await db.insert(budgetPolicies).values({ companyId, scopeType: "agent", scopeId: agent.id, metric: "billed_cents", windowKind: "calendar_month_utc", amount: 100, notifyEnabled: false }); + const [event] = await db.insert(costEvents).values({ companyId, agentId: agent.id, provider: "fixture", model: "fixture", costCents: 150, occurredAt: new Date() }).returning(); + await service.evaluateCostEvent(event); + 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"); + 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"]); + }); + + 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 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(); + 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)); + expect(key.revokedAt).toBeNull(); + expect(await events()).toEqual(before); + } finally { + await db.execute(sql`ALTER TABLE resource_lifecycle_events DROP CONSTRAINT fixture_reject_hook`); + } + }); + + it("rolls back agent and project events with the outer creation transaction", async () => { + const agentId = randomUUID(); + 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 projectService(txDb).createWithRepositories(companyId, { id: projectId, name: "Rolled back project" }, []); + throw new Error("rollback fixture"); + })).rejects.toThrow("rollback fixture"); + expect(await events()).toEqual([]); + expect(await agentService(db).getById(agentId)).toBeNull(); + expect(await projectService(db).getById(projectId)).toBeNull(); + }); + + it("fails creation and activation atomically if the intent cannot be persisted", async () => { + const pending = await createAgent("pending_approval"); + const approval = await approvalService(db).create(companyId, { type: "hire_agent", status: "pending", payload: { agentId: pending.id } }); + await db.execute(sql`ALTER TABLE resource_lifecycle_events ADD CONSTRAINT fixture_reject_intent CHECK (false) NOT VALID`); + try { + await expect(createAgent()).rejects.toThrow(); + await expect(projectService(db).create(companyId, { name: "Rejected project" })).rejects.toThrow(); + await expect(approvalService(db).approve(approval.id, "fixture-board")).rejects.toThrow(); + expect(await approvalService(db).getById(approval.id)).toMatchObject({ status: "pending" }); + expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toEqual([expect.objectContaining({ id: pending.id, status: "pending_approval" })]); + expect(await db.select().from(projects).where(eq(projects.companyId, companyId))).toEqual([]); + expect(await events()).toEqual([]); + } finally { + await db.execute(sql`ALTER TABLE resource_lifecycle_events DROP CONSTRAINT fixture_reject_intent`); + } + }); + + it("captures self-hosted lifecycle events without backfilling older resources", async () => { + vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", undefined); + vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", undefined); + const oldAgentId = randomUUID(); + const oldProjectId = randomUUID(); + await db.insert(agents).values({ id: oldAgentId, companyId, name: "Existing agent" }); + await db.insert(projects).values({ id: oldProjectId, companyId, name: "Existing project" }); + await agentService(db).update(oldAgentId, { name: "Renamed agent" }); + await projectService(db).update(oldProjectId, { name: "Renamed project" }); + expect(await events()).toEqual([]); + const agent = await createAgent(); + const project = await projectService(db).create(companyId, { name: "New project" }); + expect((await events()).map(row => row.resourceId).sort()).toEqual([agent.id, project.id].sort()); + }); + + it("scopes resource identities by company and type", async () => { + vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", undefined); + vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "fixture-token"); + const id = randomUUID(); + await recordResourceCreationEvent(db, companyId, "agent", id); + await recordResourceCreationEvent(db, companyId, "project", id); + const otherCompanyId = randomUUID(); + await db.insert(companies).values({ id: otherCompanyId, name: "Other company", issuePrefix: "OTHER" }); + await recordResourceCreationEvent(db, otherCompanyId, "agent", id); + expect(await events()).toHaveLength(2); + expect(await db.select().from(resourceLifecycleEvents).where(eq(resourceLifecycleEvents.companyId, otherCompanyId))).toHaveLength(1); + await db.delete(companies).where(eq(companies.id, otherCompanyId)); + expect(await db.select().from(resourceLifecycleEvents).where(eq(resourceLifecycleEvents.companyId, otherCompanyId))).toEqual([]); + }); +}); diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index c0dd80270d..93c437da8d 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -38,6 +38,7 @@ import { syncAgentAdapterEnvBindings, } from "./agent-secret-bindings.js"; import { logActivity } from "./activity-log.js"; +import { recordAgentStatusEvent, recordResourceCreationEvent } from "./resource-lifecycle-events.js"; import { normalizeAgentPermissions } from "./agent-permissions.js"; import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js"; import { @@ -788,6 +789,16 @@ export function agentService(db: Db) { type AgentUpdateResult = Awaited>; const applyUpdate = async (txDb: Db): Promise => { + const current = data.status !== undefined + ? await txDb.select().from(agents).where(eq(agents.id, id)).for("update").then(rows => rows[0] ?? null) + : existing; + if (!current) return null; + if (current.status === "terminated" && data.status && data.status !== "terminated") { + throw conflict("Terminated agents cannot be resumed"); + } + if (current.status === "pending_approval" && data.status && data.status !== "pending_approval" && data.status !== "terminated") { + throw conflict("Pending approval agents cannot be activated directly"); + } const updated = await txDb .update(agents) .set({ ...normalizedPatch, updatedAt: new Date() }) @@ -795,6 +806,9 @@ export function agentService(db: Db) { .returning() .then((rows) => rows[0] ?? null); if (!updated) return null; + if (data.status !== undefined) { + await recordAgentStatusEvent(txDb, updated.companyId, id, current.status, updated.status); + } const priorAdapterConfig = isPlainRecord(existing.adapterConfig) ? existing.adapterConfig : {}; const afterConfig = isPlainRecord(updated.adapterConfig) ? updated.adapterConfig : {}; @@ -938,6 +952,9 @@ export function agentService(db: Db) { }).onConflictDoNothing(); } await syncAgentSecretBindings(created, txDb); + if (created.status !== "pending_approval" && created.status !== "terminated") { + await recordResourceCreationEvent(txDb, companyId, "agent", created.id); + } const normalizedCreated = await agentService(txDb).getById(created.id); if (!normalizedCreated) { throw notFound("Agent not found"); @@ -953,19 +970,12 @@ export function agentService(db: Db) { if (!existing) return null; if (existing.status === "terminated") throw conflict("Cannot pause terminated agent"); - const updated = await db - .update(agents) - .set({ - status: "paused", - pauseReason: reason, - pausedAt: new Date(), - errorReason: null, - updatedAt: new Date(), - }) - .where(eq(agents.id, id)) - .returning() - .then((rows) => rows[0] ?? null); - return updated ? getById(updated.id) : null; + return updateAgent(id, { + status: "paused", + pauseReason: reason, + pausedAt: new Date(), + errorReason: null, + }); }, resume: async (id: string) => { @@ -976,19 +986,12 @@ export function agentService(db: Db) { throw conflict("Pending approval agents cannot be resumed"); } - const updated = await db - .update(agents) - .set({ - status: "idle", - pauseReason: null, - pausedAt: null, - errorReason: null, - updatedAt: new Date(), - }) - .where(eq(agents.id, id)) - .returning() - .then((rows) => rows[0] ?? null); - return updated ? getById(updated.id) : null; + return updateAgent(id, { + status: "idle", + pauseReason: null, + pausedAt: null, + errorReason: null, + }); }, clearError: async (id: string) => { @@ -1025,23 +1028,19 @@ export function agentService(db: Db) { const existing = await getById(id); if (!existing) return null; - await db - .update(agents) - .set({ + return db.transaction(async tx => { + const txDb = tx as unknown as Db; + const updated = await agentService(txDb).update(id, { status: "terminated", pauseReason: null, pausedAt: null, errorReason: null, updatedAt: new Date(), - }) - .where(eq(agents.id, id)); - - await db - .update(agentApiKeys) - .set({ revokedAt: new Date() }) - .where(eq(agentApiKeys.agentId, id)); - - return getById(id); + }); + if (!updated) return null; + await tx.update(agentApiKeys).set({ revokedAt: new Date() }).where(eq(agentApiKeys.agentId, id)); + return updated; + }); }, remove: async (id: string) => { @@ -1150,6 +1149,7 @@ export function agentService(db: Db) { }); } await syncAgentSecretBindings(updated, txDb, existing.adapterConfig); + await recordResourceCreationEvent(txDb, existing.companyId, "agent", updated.id); const agent = await agentService(txDb).getById(updated.id); if (!agent) { throw notFound("Agent not found"); diff --git a/server/src/services/approvals.ts b/server/src/services/approvals.ts index 3da5dd6f8f..4fbbf0a23e 100644 --- a/server/src/services/approvals.ts +++ b/server/src/services/approvals.ts @@ -10,8 +10,6 @@ import { notifyHireApproved } from "./hire-hook.js"; import { instanceSettingsService } from "./instance-settings.js"; export function approvalService(db: Db) { - const agentsSvc = agentService(db); - const budgets = budgetService(db); const instanceSettings = instanceSettingsService(db); const canResolveStatuses = new Set(["pending", "revision_requested"]); const resolvableStatuses = Array.from(canResolveStatuses); @@ -25,15 +23,15 @@ export function approvalService(db: Db) { }; } - async function reconcileApprovedBuiltInAgent(companyId: string, payload: Record) { + async function reconcileApprovedBuiltInAgent(companyId: string, payload: Record, database: Db) { const sourceBuiltInAgentKey = typeof payload.sourceBuiltInAgentKey === "string" ? payload.sourceBuiltInAgentKey : null; if (!sourceBuiltInAgentKey) return; const { builtInAgentService } = await import("./built-in-agents.js"); - await builtInAgentService(db).ensure(companyId, sourceBuiltInAgentKey); + await builtInAgentService(database).ensure(companyId, sourceBuiltInAgentKey); } - async function getExistingApproval(id: string) { - const existing = await db + async function getExistingApproval(id: string, database: Db = db) { + const existing = await database .select() .from(approvals) .where(eq(approvals.id, id)) @@ -47,8 +45,9 @@ export function approvalService(db: Db) { targetStatus: "approved" | "rejected", decidedByUserId: string, decisionNote: string | null | undefined, + database: Db = db, ): Promise { - const existing = await getExistingApproval(id); + const existing = await getExistingApproval(id, database); if (!canResolveStatuses.has(existing.status)) { if (existing.status === targetStatus) { return { approval: existing, applied: false }; @@ -59,7 +58,7 @@ export function approvalService(db: Db) { } const now = new Date(); - const updated = await db + const updated = await database .update(approvals) .set({ status: targetStatus, @@ -76,7 +75,7 @@ export function approvalService(db: Db) { return { approval: updated, applied: true }; } - const latest = await getExistingApproval(id); + const latest = await getExistingApproval(id, database); if (latest.status === targetStatus) { return { approval: latest, applied: false }; } @@ -142,93 +141,107 @@ export function approvalService(db: Db) { }, approve: async (id: string, decidedByUserId: string, decisionNote?: string | null) => { - const { approval: updated, applied } = await resolveApproval( - id, - "approved", - decidedByUserId, - decisionNote, - ); - - let hireApprovedAgentId: string | null = null; const now = new Date(); - if (applied && updated.type === "hire_agent") { - const payload = updated.payload as Record; - const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null; - if (payloadAgentId) { - await agentsSvc.activatePendingApproval(payloadAgentId, payload); - await reconcileApprovedBuiltInAgent(updated.companyId, payload); - hireApprovedAgentId = payloadAgentId; - } else { - const created = await agentsSvc.create(updated.companyId, { - name: String(payload.name ?? "New Agent"), - appearance: payload.appearance == null ? undefined : agentAppearanceSchema.parse(payload.appearance), - role: String(payload.role ?? "general"), - title: typeof payload.title === "string" ? payload.title : null, - reportsTo: typeof payload.reportsTo === "string" ? payload.reportsTo : null, - capabilities: typeof payload.capabilities === "string" ? payload.capabilities : null, - adapterType: String(payload.adapterType ?? "process"), - adapterConfig: - typeof payload.adapterConfig === "object" && payload.adapterConfig !== null - ? (payload.adapterConfig as Record) - : {}, - budgetMonthlyCents: - typeof payload.budgetMonthlyCents === "number" ? payload.budgetMonthlyCents : 0, - metadata: - typeof payload.metadata === "object" && payload.metadata !== null - ? (payload.metadata as Record) - : null, - status: "idle", - spentMonthlyCents: 0, - permissions: undefined, - lastHeartbeatAt: null, - }); - hireApprovedAgentId = created?.id ?? null; - } - if (hireApprovedAgentId) { - const budgetMonthlyCents = - typeof payload.budgetMonthlyCents === "number" ? payload.budgetMonthlyCents : 0; - if (budgetMonthlyCents > 0) { - await budgets.upsertPolicy( - updated.companyId, - { - scopeType: "agent", - scopeId: hireApprovedAgentId, - amount: budgetMonthlyCents, - windowKind: "calendar_month_utc", - }, - decidedByUserId, - ); - } - void notifyHireApproved(db, { - companyId: updated.companyId, - agentId: hireApprovedAgentId, - source: "approval", - sourceId: id, - approvedAt: now, - }).catch(() => {}); - } - } + const result = await db.transaction(async tx => { + const txDb = tx as unknown as Db; + const agentsSvc = agentService(txDb); + const budgets = budgetService(txDb); + const { approval: updated, applied } = await resolveApproval( + id, + "approved", + decidedByUserId, + decisionNote, + txDb, + ); - return { approval: updated, applied }; + let hireApprovedAgentId: string | null = null; + if (applied && updated.type === "hire_agent") { + const payload = updated.payload as Record; + const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null; + if (payloadAgentId) { + await agentsSvc.activatePendingApproval(payloadAgentId, payload); + await reconcileApprovedBuiltInAgent(updated.companyId, payload, txDb); + hireApprovedAgentId = payloadAgentId; + } else { + const created = await agentsSvc.create(updated.companyId, { + name: String(payload.name ?? "New Agent"), + appearance: payload.appearance == null ? undefined : agentAppearanceSchema.parse(payload.appearance), + role: String(payload.role ?? "general"), + title: typeof payload.title === "string" ? payload.title : null, + reportsTo: typeof payload.reportsTo === "string" ? payload.reportsTo : null, + capabilities: typeof payload.capabilities === "string" ? payload.capabilities : null, + adapterType: String(payload.adapterType ?? "process"), + adapterConfig: + typeof payload.adapterConfig === "object" && payload.adapterConfig !== null + ? (payload.adapterConfig as Record) + : {}, + budgetMonthlyCents: + typeof payload.budgetMonthlyCents === "number" ? payload.budgetMonthlyCents : 0, + metadata: + typeof payload.metadata === "object" && payload.metadata !== null + ? (payload.metadata as Record) + : null, + status: "idle", + spentMonthlyCents: 0, + permissions: undefined, + lastHeartbeatAt: null, + }); + hireApprovedAgentId = created?.id ?? null; + } + if (hireApprovedAgentId) { + const budgetMonthlyCents = + typeof payload.budgetMonthlyCents === "number" ? payload.budgetMonthlyCents : 0; + if (budgetMonthlyCents > 0) { + await budgets.upsertPolicy( + updated.companyId, + { + scopeType: "agent", + scopeId: hireApprovedAgentId, + amount: budgetMonthlyCents, + windowKind: "calendar_month_utc", + }, + decidedByUserId, + ); + } + + } + } + + return { approval: updated, applied, hireApprovedAgentId }; + }); + if (result.hireApprovedAgentId) { + void notifyHireApproved(db, { + companyId: result.approval.companyId, + agentId: result.hireApprovedAgentId, + source: "approval", + sourceId: id, + approvedAt: now, + }).catch(() => {}); + } + return { approval: result.approval, applied: result.applied }; }, reject: async (id: string, decidedByUserId: string, decisionNote?: string | null) => { - const { approval: updated, applied } = await resolveApproval( - id, - "rejected", - decidedByUserId, - decisionNote, - ); + return db.transaction(async tx => { + const txDb = tx as unknown as Db; + const { approval: updated, applied } = await resolveApproval( + id, + "rejected", + decidedByUserId, + decisionNote, + txDb, + ); - if (applied && updated.type === "hire_agent") { - const payload = updated.payload as Record; - const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null; - if (payloadAgentId) { - await agentsSvc.terminate(payloadAgentId); + if (applied && updated.type === "hire_agent") { + const payload = updated.payload as Record; + const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null; + if (payloadAgentId) { + await agentService(txDb).terminate(payloadAgentId); + } } - } - return { approval: updated, applied }; + return { approval: updated, applied }; + }); }, requestRevision: async (id: string, decidedByUserId: string, decisionNote?: string | null) => { diff --git a/server/src/services/budgets.ts b/server/src/services/budgets.ts index e281dcb51f..8938cb4b7c 100644 --- a/server/src/services/budgets.ts +++ b/server/src/services/budgets.ts @@ -1,5 +1,6 @@ import { and, desc, eq, gte, inArray, lt, ne, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; +import { recordAgentStatusEvent } from "./resource-lifecycle-events.js"; import { agents, approvals, @@ -214,15 +215,15 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) { async function pauseScopeForBudget(policy: PolicyRow) { const now = new Date(); if (policy.scopeType === "agent") { - await db - .update(agents) - .set({ - status: "paused", - pauseReason: "budget", - pausedAt: now, - updatedAt: now, - }) - .where(and(eq(agents.id, policy.scopeId), inArray(agents.status, ["active", "idle", "running", "error"]))); + await db.transaction(async tx => { + const [agent] = await tx.select().from(agents) + .where(and(eq(agents.id, policy.scopeId), eq(agents.companyId, policy.companyId))).for("update"); + if (agent && ["active", "idle", "running", "error"].includes(agent.status)) { + await tx.update(agents).set({ status: "paused", pauseReason: "budget", pausedAt: now, updatedAt: now }) + .where(eq(agents.id, agent.id)); + await recordAgentStatusEvent(tx as unknown as Db, agent.companyId, agent.id, agent.status, "paused"); + } + }); return; } @@ -261,15 +262,15 @@ export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) { async function resumeScopeFromBudget(policy: PolicyRow) { const now = new Date(); if (policy.scopeType === "agent") { - await db - .update(agents) - .set({ - status: "idle", - pauseReason: null, - pausedAt: null, - updatedAt: now, - }) - .where(and(eq(agents.id, policy.scopeId), eq(agents.pauseReason, "budget"))); + await db.transaction(async tx => { + const [agent] = await tx.select().from(agents) + .where(and(eq(agents.id, policy.scopeId), eq(agents.companyId, policy.companyId))).for("update"); + if (agent?.status === "paused" && agent.pauseReason === "budget") { + await tx.update(agents).set({ status: "idle", pauseReason: null, pausedAt: null, updatedAt: now }) + .where(eq(agents.id, agent.id)); + await recordAgentStatusEvent(tx as unknown as Db, agent.companyId, agent.id, agent.status, "idle"); + } + }); return; } diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 1bfa0b9f6c..40a28a876d 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -34,6 +34,7 @@ import { listCurrentRuntimeServicesForProjectWorkspaces } from "./workspace-runt import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js"; import { resolveManagedProjectWorkspaceDir } from "../home-paths.js"; +import { recordResourceCreationEvent } from "./resource-lifecycle-events.js"; type ProjectRow = typeof projects.$inferSelect; type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect; @@ -592,19 +593,21 @@ export function projectService(db: Db) { // together (goalIds wins resolution, mirroring the update path). const legacyGoalId = ids?.[0] ?? null; - const row = await db - .insert(projects) - .values({ ...projectData, goalId: legacyGoalId, companyId }) - .returning() - .then((rows) => rows[0]); - - if (ids && ids.length > 0) { - await syncGoalLinks(db, row.id, companyId, ids); - } - - const [withGoals] = await attachGoals(db, [row]); - const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : []; - return enriched!; + return db.transaction(async (tx) => { + const txDb = tx as unknown as Db; + const row = await tx + .insert(projects) + .values({ ...projectData, goalId: legacyGoalId, companyId }) + .returning() + .then((rows) => rows[0]); + if (ids && ids.length > 0) { + await syncGoalLinks(txDb, row.id, companyId, ids); + } + await recordResourceCreationEvent(txDb, companyId, "project", row.id); + const [withGoals] = await attachGoals(txDb, [row]); + const [enriched] = withGoals ? await attachWorkspaces(txDb, [withGoals]) : []; + return enriched!; + }); }; const getProjectById = async (id: string): Promise => { diff --git a/server/src/services/resource-lifecycle-events.ts b/server/src/services/resource-lifecycle-events.ts new file mode 100644 index 0000000000..ed1d7536f0 --- /dev/null +++ b/server/src/services/resource-lifecycle-events.ts @@ -0,0 +1,25 @@ +import { resourceLifecycleEvents, type Db } from "@paperclipai/db"; +import { sql } from "drizzle-orm"; + +/** Call inside the transaction that creates the resource or approves the hire. */ +export async function recordResourceCreationEvent( + db: Db, + companyId: string, + resourceType: "agent" | "project", + resourceId: string, +): Promise { + await db.insert(resourceLifecycleEvents).values({ companyId, resourceType, resourceId, action: "create" }).onConflictDoNothing({ + target: [resourceLifecycleEvents.companyId, resourceLifecycleEvents.resourceType, resourceLifecycleEvents.resourceId], + where: sql`${resourceLifecycleEvents.action} = 'create'`, + }); +} + +/** Call with the agent row locked, in the same transaction as the status change. */ +export async function recordAgentStatusEvent(db: Db, companyId: string, agentId: string, before: string, after: string): Promise { + if (before === after) return; + const action = after === "terminated" ? "terminate" + : after === "paused" ? "pause" + : before === "paused" && ["active", "idle", "running", "error"].includes(after) ? "resume" : null; + if (!action) return; + await db.insert(resourceLifecycleEvents).values({ companyId, resourceType: "agent", resourceId: agentId, action }); +}