diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 2b4480f9d6..f194265751 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -471,6 +471,10 @@ 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. +Project edits and workspace additions, updates, and removals append `update`. +Repository replacement emits one aggregate update; project creation with repositories +emits only creation. Project mutations hold the project row lock until their record +commits. An archive-only change emits nothing and preserves workspace records. 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. diff --git a/packages/db/src/migrations/0300_chunky_chamber.sql b/packages/db/src/migrations/0300_chunky_chamber.sql new file mode 100644 index 0000000000..4b300a0b5b --- /dev/null +++ b/packages/db/src/migrations/0300_chunky_chamber.sql @@ -0,0 +1,3 @@ +-- Older JavaScript worktree backups may omit CHECK constraints. +ALTER TABLE "resource_lifecycle_events" DROP CONSTRAINT IF EXISTS "resource_lifecycle_events_action_check";--> statement-breakpoint +ALTER TABLE "resource_lifecycle_events" ADD CONSTRAINT "resource_lifecycle_events_action_check" CHECK ("resource_lifecycle_events"."action" = 'create' OR ("resource_lifecycle_events"."resource_type" = 'project' AND "resource_lifecycle_events"."action" = 'update') OR ("resource_lifecycle_events"."resource_type" = 'agent' AND "resource_lifecycle_events"."action" IN ('pause', 'resume', 'terminate'))); \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0295_snapshot.json b/packages/db/src/migrations/meta/0300_snapshot.json similarity index 98% rename from packages/db/src/migrations/meta/0295_snapshot.json rename to packages/db/src/migrations/meta/0300_snapshot.json index 13a13294ad..ebf532f8cd 100644 --- a/packages/db/src/migrations/meta/0295_snapshot.json +++ b/packages/db/src/migrations/meta/0300_snapshot.json @@ -1,6 +1,6 @@ { - "id": "02d31b6c-7df3-4f6c-b906-e2d369b5e1cd", - "prevId": "66a0b3c9-91bb-46b8-a868-a6facedbc14b", + "id": "dfa5064e-7b35-4d20-8a3d-aa808c4d92e0", + "prevId": "0eac674e-f69a-4f25-b800-3be01b8fd28d", "version": "7", "dialect": "postgresql", "tables": { @@ -2903,6 +2903,362 @@ }, "isRLSEnabled": false }, + "public.ai_connection_pools": { + "name": "ai_connection_pools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plugin_key": { + "name": "plugin_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ai_connection_pools_id_tool_connections_id_fk": { + "name": "ai_connection_pools_id_tool_connections_id_fk", + "tableFrom": "ai_connection_pools", + "tableTo": "tool_connections", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_connection_pools_company_id_companies_id_fk": { + "name": "ai_connection_pools_company_id_companies_id_fk", + "tableFrom": "ai_connection_pools", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_connection_pools_company_connection_fk": { + "name": "ai_connection_pools_company_connection_fk", + "tableFrom": "ai_connection_pools", + "tableTo": "tool_connections", + "columnsFrom": [ + "company_id", + "id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ai_connection_pools_company_id_uq": { + "name": "ai_connection_pools_company_id_uq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_connection_router_cursors": { + "name": "ai_connection_router_cursors", + "schema": "", + "columns": { + "pool_id": { + "name": "pool_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_member_id": { + "name": "last_member_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "ai_connection_router_cursors_pool_id_ai_connection_pools_id_fk": { + "name": "ai_connection_router_cursors_pool_id_ai_connection_pools_id_fk", + "tableFrom": "ai_connection_router_cursors", + "tableTo": "ai_connection_pools", + "columnsFrom": [ + "pool_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_connection_router_cursors_company_id_companies_id_fk": { + "name": "ai_connection_router_cursors_company_id_companies_id_fk", + "tableFrom": "ai_connection_router_cursors", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_connection_router_cursors_company_pool_fk": { + "name": "ai_connection_router_cursors_company_pool_fk", + "tableFrom": "ai_connection_router_cursors", + "tableTo": "ai_connection_pools", + "columnsFrom": [ + "company_id", + "pool_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_connection_task_pins": { + "name": "ai_connection_task_pins", + "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 + }, + "pool_id": { + "name": "pool_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_key": { + "name": "task_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "selection": { + "name": "selection", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "member": { + "name": "member", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_connection_task_pins_affinity_uq": { + "name": "ai_connection_task_pins_affinity_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pool_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_connection_task_pins_company_id_companies_id_fk": { + "name": "ai_connection_task_pins_company_id_companies_id_fk", + "tableFrom": "ai_connection_task_pins", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_connection_task_pins_pool_id_ai_connection_pools_id_fk": { + "name": "ai_connection_task_pins_pool_id_ai_connection_pools_id_fk", + "tableFrom": "ai_connection_task_pins", + "tableTo": "ai_connection_pools", + "columnsFrom": [ + "pool_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_connection_task_pins_agent_id_agents_id_fk": { + "name": "ai_connection_task_pins_agent_id_agents_id_fk", + "tableFrom": "ai_connection_task_pins", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_connection_task_pins_company_pool_fk": { + "name": "ai_connection_task_pins_company_pool_fk", + "tableFrom": "ai_connection_task_pins", + "tableTo": "ai_connection_pools", + "columnsFrom": [ + "company_id", + "pool_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_connection_task_pins_company_agent_fk": { + "name": "ai_connection_task_pins_company_agent_fk", + "tableFrom": "ai_connection_task_pins", + "tableTo": "agents", + "columnsFrom": [ + "company_id", + "agent_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.ai_provider_defaults": { "name": "ai_provider_defaults", "schema": "", @@ -12347,6 +12703,13 @@ "primaryKey": false, "notNull": false }, + "ai_session_epoch": { + "name": "ai_session_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, "latest_version": { "name": "latest_version", "type": "integer", @@ -38918,6 +39281,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\" = 'project' AND \"resource_lifecycle_events\".\"action\" = 'update') 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": "", @@ -44557,6 +45054,12 @@ "primaryKey": false, "notNull": true, "default": "now()" + }, + "error_at": { + "name": "error_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false } }, "indexes": { @@ -44630,6 +45133,159 @@ "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": "", @@ -44923,6 +45579,12 @@ "notNull": true, "default": false }, + "agent_instructions": { + "name": "agent_instructions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "config": { "name": "config", "type": "jsonb", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index e411db2742..6708962593 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2087,6 +2087,13 @@ "when": 1791248126600, "tag": "0299_absent_ser_duncan", "breakpoints": true + }, + { + "idx": 300, + "version": "7", + "when": 1791260726828, + "tag": "0300_chunky_chamber", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/resource_lifecycle_events.ts b/packages/db/src/schema/resource_lifecycle_events.ts index 087af412f9..c9bae44d3d 100644 --- a/packages/db/src/schema/resource_lifecycle_events.ts +++ b/packages/db/src/schema/resource_lifecycle_events.ts @@ -8,11 +8,11 @@ export const resourceLifecycleEvents = pgTable("resource_lifecycle_events", { 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(), + action: text("action").$type<"create" | "update" | "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'))`), + actionCheck: check("resource_lifecycle_events_action_check", sql`${table.action} = 'create' OR (${table.resourceType} = 'project' AND ${table.action} = 'update') OR (${table.resourceType} = 'agent' AND ${table.action} IN ('pause', 'resume', 'terminate'))`), })); diff --git a/server/src/__tests__/resource-lifecycle-events.test.ts b/server/src/__tests__/resource-lifecycle-events.test.ts index a61e31f51a..229f514d78 100644 --- a/server/src/__tests__/resource-lifecycle-events.test.ts +++ b/server/src/__tests__/resource-lifecycle-events.test.ts @@ -198,10 +198,10 @@ describePostgres("Resource lifecycle events", () => { 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([]); + expect(await events()).toEqual([expect.objectContaining({ action: "update", resourceId: oldProjectId })]); 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()); + expect((await events()).filter(row => row.action === "create").map(row => row.resourceId).sort()).toEqual([agent.id, project.id].sort()); }); it("scopes resource identities by company and type", async () => { @@ -218,4 +218,47 @@ describePostgres("Resource lifecycle events", () => { await db.delete(companies).where(eq(companies.id, otherCompanyId)); expect(await db.select().from(resourceLifecycleEvents).where(eq(resourceLifecycleEvents.companyId, otherCompanyId))).toEqual([]); }); + + it("captures project and workspace updates, batches repository replacement, and ignores archive-only changes", async () => { + const service = projectService(db); + const project = await service.createWithRepositories(companyId, { name: "Mutable project" }, [ + { id: "1", fullName: "fixture/one", url: "https://github.com/fixture/one", connections: [] }, + ]); + expect((await events()).map(row => row.action)).toEqual(["create"]); + await service.update(project.id, { name: "Updated project" }); + const workspace = await service.createWorkspace(project.id, { repoUrl: "https://github.com/fixture/two" }); + await service.updateWorkspace(project.id, workspace!.id, { repoRef: "main" }); + await service.removeWorkspace(project.id, workspace!.id); + const beforeReplace = (await events()).length; + await service.replaceRepositories(project.id, [ + { id: "3", fullName: "fixture/three", url: "https://github.com/fixture/three", connections: [] }, + { id: "4", fullName: "fixture/four", url: "https://github.com/fixture/four", connections: [] }, + ]); + expect(await events()).toHaveLength(beforeReplace + 1); + const beforeArchive = await events(); + await service.update(project.id, { archivedAt: new Date(), name: undefined }); + expect(await events()).toEqual(beforeArchive); + expect((await service.getById(project.id))?.workspaces).toHaveLength(2); + expect(beforeArchive.sort((a, b) => a.id - b.id).map(row => row.action)).toEqual(["create", "update", "update", "update", "update", "update"]); + }); + + it("rolls back project and repository mutations when their update record fails", async () => { + const service = projectService(db); + const project = await service.create(companyId, { name: "Retained project" }); + const workspace = await service.createWorkspace(project.id, { repoUrl: "https://github.com/fixture/retained" }); + const before = await events(); + await db.execute(sql`ALTER TABLE resource_lifecycle_events ADD CONSTRAINT fixture_reject_update CHECK (action <> 'update') NOT VALID`); + try { + await expect(service.update(project.id, { name: "Rejected update" })).rejects.toThrow(); + await expect(service.createWorkspace(project.id, { repoUrl: "https://github.com/fixture/rejected" })).rejects.toThrow(); + await expect(service.updateWorkspace(project.id, workspace!.id, { repoRef: "rejected" })).rejects.toThrow(); + await expect(service.removeWorkspace(project.id, workspace!.id)).rejects.toThrow(); + await expect(service.replaceRepositories(project.id, [])).rejects.toThrow(); + expect(await service.getById(project.id)).toMatchObject({ name: "Retained project", workspaces: [expect.objectContaining({ id: workspace!.id, repoRef: null })] }); + expect(await events()).toEqual(before); + } finally { + await db.execute(sql`ALTER TABLE resource_lifecycle_events DROP CONSTRAINT fixture_reject_update`); + } + }); + }); diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 40a28a876d..f00057f06a 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -34,7 +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"; +import { recordProjectUpdateEvent, recordResourceCreationEvent } from "./resource-lifecycle-events.js"; type ProjectRow = typeof projects.$inferSelect; type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect; @@ -568,7 +568,7 @@ async function ensureSinglePrimaryWorkspace( ); } -export function projectService(db: Db) { +export function projectService(db: Db, options: { captureWorkspaceUpdates?: boolean } = {}) { const createProject = async ( companyId: string, data: Omit & { goalIds?: string[] }, @@ -838,7 +838,7 @@ export function projectService(db: Db) { createWithRepositories: async (companyId: string, data: Parameters[1], repositories: import("@paperclipai/shared").ProjectRepository[]): Promise => { return db.transaction(async (tx) => { - const service = projectService(tx as unknown as Db); + const service = projectService(tx as unknown as Db, { captureWorkspaceUpdates: false }); const project = await service.create(companyId, data); for (const repo of repositories) { await service.createWorkspace(project.id, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id } }); @@ -851,7 +851,7 @@ export function projectService(db: Db) { return db.transaction(async (tx) => { const [project] = await tx.select().from(projects).where(eq(projects.id, projectId)).for("update"); if (!project) return null; - const service = projectService(tx as unknown as Db); + const service = projectService(tx as unknown as Db, { captureWorkspaceUpdates: false }); const existing = await service.listWorkspaces(projectId); const ids = new Set(repositories.map((repo) => repo.id)); for (const workspace of existing) { @@ -878,6 +878,7 @@ export function projectService(db: Db) { } else await service.createWorkspace(projectId, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id } }); } await tx.update(projects).set({ updatedAt: new Date() }).where(eq(projects.id, projectId)); + await recordProjectUpdateEvent(tx as unknown as Db, project.companyId, projectId); return service.getById(projectId); }); }, @@ -887,13 +888,14 @@ export function projectService(db: Db) { update: async ( id: string, data: Partial & { goalIds?: string[] }, - ): Promise => { + ): Promise => db.transaction(async tx => { + const db = tx as unknown as Db; const { goalIds: inputGoalIds, ...projectData } = data; const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId }); const existingProject = await db .select({ id: projects.id, companyId: projects.companyId, name: projects.name }) .from(projects) - .where(eq(projects.id, id)) + .where(eq(projects.id, id)).for("update") .then((rows) => rows[0] ?? null); if (!existingProject) return null; if (ids && ids.length > 0) { @@ -935,10 +937,14 @@ export function projectService(db: Db) { await syncGoalLinks(db, id, row.companyId, ids); } + if (Object.entries(projectData).some(([key, value]) => value !== undefined && key !== "archivedAt" && key !== "updatedAt") || ids !== undefined) { + await recordProjectUpdateEvent(db, row.companyId, id); + } + const [withGoals] = await attachGoals(db, [row]); const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : []; return enriched ?? null; - }, + }), clearExecutionWorkspaceEnvironmentSelection: async (companyId: string, environmentId: string) => { const rows = await db @@ -954,16 +960,9 @@ export function projectService(db: Db) { const policy = parseProjectExecutionWorkspacePolicy(row.executionWorkspacePolicy); if (policy?.environmentId !== environmentId) continue; - await db - .update(projects) - .set({ - executionWorkspacePolicy: { - ...policy, - environmentId: null, - }, - updatedAt: new Date(), - }) - .where(eq(projects.id, row.id)); + await projectService(db).update(row.id, { + executionWorkspacePolicy: { ...policy, environmentId: null }, + }); cleared += 1; } @@ -1004,11 +1003,12 @@ export function projectService(db: Db) { createWorkspace: async ( projectId: string, data: CreateWorkspaceInput, - ): Promise => { + ): Promise => db.transaction(async tx => { + const db = tx as unknown as Db; const project = await db .select() .from(projects) - .where(eq(projects.id, projectId)) + .where(eq(projects.id, projectId)).for("update") .then((rows) => rows[0] ?? null); if (!project) return null; @@ -1079,14 +1079,19 @@ export function projectService(db: Db) { return row; }); + if (created && options.captureWorkspaceUpdates !== false) { + await recordProjectUpdateEvent(db, created.companyId, projectId); + } return created ? toWorkspace(created) : null; - }, + }), updateWorkspace: async ( projectId: string, workspaceId: string, data: UpdateWorkspaceInput, - ): Promise => { + ): Promise => db.transaction(async tx => { + const db = tx as unknown as Db; + await db.select({ id: projects.id }).from(projects).where(eq(projects.id, projectId)).for("update"); const existing = await db .select() .from(projectWorkspaces) @@ -1231,10 +1236,15 @@ export function projectService(db: Db) { return row; }); + if (updated && options.captureWorkspaceUpdates !== false) { + await recordProjectUpdateEvent(db, updated.companyId, projectId); + } return updated ? toWorkspace(updated) : null; - }, + }), - removeWorkspace: async (projectId: string, workspaceId: string): Promise => { + removeWorkspace: async (projectId: string, workspaceId: string): Promise => db.transaction(async tx => { + const db = tx as unknown as Db; + await db.select({ id: projects.id }).from(projects).where(eq(projects.id, projectId)).for("update"); const existing = await db .select() .from(projectWorkspaces) @@ -1281,8 +1291,11 @@ export function projectService(db: Db) { return row; }); + if (removed && options.captureWorkspaceUpdates !== false) { + await recordProjectUpdateEvent(db, removed.companyId, projectId); + } return removed ? toWorkspace(removed) : null; - }, + }), resolveByReference: async (companyId: string, reference: string) => { const raw = reference.trim(); diff --git a/server/src/services/resource-lifecycle-events.ts b/server/src/services/resource-lifecycle-events.ts index ed1d7536f0..9e34dcb5d3 100644 --- a/server/src/services/resource-lifecycle-events.ts +++ b/server/src/services/resource-lifecycle-events.ts @@ -23,3 +23,8 @@ export async function recordAgentStatusEvent(db: Db, companyId: string, agentId: if (!action) return; await db.insert(resourceLifecycleEvents).values({ companyId, resourceType: "agent", resourceId: agentId, action }); } + +/** Call while holding the project row lock, in the mutation transaction. */ +export async function recordProjectUpdateEvent(db: Db, companyId: string, projectId: string): Promise { + await db.insert(resourceLifecycleEvents).values({ companyId, resourceType: "project", resourceId: projectId, action: "update" }); +}