From 44e4979d23bfb0728baad89f844033e1b77faefe Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Tue, 6 Oct 2026 07:33:18 -0700 Subject: [PATCH] Capture project and repository update lifecycle events (#15306) ## Thinking Path > - Existing lifecycle capture records agent transitions and project creation. > - Project and repository edits need matching project update records. > - The record must commit with the mutation so failed writes cannot lose a hook. > - Creation with repositories is one creation, and repository replacement is one update. > - Archive-only changes preserve project state without creating a hook. > - Plugin consumption and provider behavior are separate work. ## Linked Issues or Issue Description **Problem or motivation** Project edits and repository/workspace changes lack durable lifecycle records. A future resource plugin needs those changes captured alongside the existing project creation hook. Archive-only changes must produce no hook. **Proposed solution** Allow project `update` records in the existing lifecycle journal. Record project and workspace mutations in their database transaction while holding the project row lock. Suppress intermediate workspace hooks during project creation and aggregate repository replacement. **Alternatives considered** Route-only hooks miss shared service callers. Recording after commit can lose an event. Emitting a hook for each child mutation exposes intermediate repository state. **Roadmap alignment** This completes project lifecycle capture begun in #15280. Plugin delivery, VM/volume provisioning, and backfill remain separate. Searches found no duplicate project lifecycle work; related #13306 concerns decision events on the in-process plugin bus. ## What Changed - Record project edits and workspace additions, updates, and removals as project `update` events. - Commit each event atomically with its mutation under the project row lock. - Keep project creation with repositories to one creation event and repository replacement to one aggregate update. - Ignore archive-only changes and retain workspace records. - Extend the journal action constraint and document project update capture. ## Verification - `pnpm -r typecheck` passed on the narrowed scope. - 57 tests passed across six lifecycle, project, repository, and chat-project suites. - Seven managed-sandbox workspace route tests and the CLI lagging-worktree migration regression passed (65 targeted tests total). - Full GitHub CI passed on `8ba6f97f22`; all required gates are green. - Greptile scored the final project-only commit 5/5 with zero unresolved review threads. - The branch is current with `master` and has no merge conflicts. - `git diff --check` and a local secret/PII scan passed. ## Risks - Apply migration `0300_chunky_chamber.sql` before running the new server. It permits project update actions and tolerates older JavaScript worktree backups that omitted the prior CHECK constraint. - Event-write failure intentionally rolls back the project or repository mutation. - Records contain identity and action; future consumers must load current authorized project/workspace data. - Plugin consumption, provider calls, volume cleanup, and backfill are outside this PR. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, repository inspection, code execution, and tool use. The exact deployment model ID and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/DATABASE.md | 4 + .../db/src/migrations/0300_chunky_chamber.sql | 3 + ...{0295_snapshot.json => 0300_snapshot.json} | 666 +++++++++++++++++- packages/db/src/migrations/meta/_journal.json | 7 + .../src/schema/resource_lifecycle_events.ts | 4 +- .../resource-lifecycle-events.test.ts | 47 +- server/src/services/projects.ts | 61 +- .../src/services/resource-lifecycle-events.ts | 5 + 8 files changed, 767 insertions(+), 30 deletions(-) create mode 100644 packages/db/src/migrations/0300_chunky_chamber.sql rename packages/db/src/migrations/meta/{0295_snapshot.json => 0300_snapshot.json} (98%) 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" }); +}