From d9f600043f13cf19172da1373d1336a0db31664b Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Tue, 6 Oct 2026 11:43:16 -0700 Subject: [PATCH] Capture project archive and restore lifecycle events (#15371) ## Thinking Path > - Paperclip manages projects and their repositories. > - Resource changes commit to a durable lifecycle journal. > - Project archive changes currently leave no journal record. > - Consumers need to observe archive and restore transitions. > - The record must commit with the project change. > - This PR adds project archive capture before plugin delivery in #15343. ## Linked Issues or Issue Description **Problem or motivation** Archiving a project does not record a lifecycle event. Restoring an archived project also emits no event when it is the only change. Consumers cannot observe these transitions through the journal. **Proposed solution** Record `archive` when an active project becomes archived. Record `update` when an archived project is restored. Keep both writes in the existing project transaction and row lock. Repeated status requests emit no new status record. **Alternatives considered** An in-process notification can disappear on restart. A separate archive service would duplicate the existing mutation path. Use the existing journal and project transaction. **Roadmap alignment** This extends lifecycle capture from #15280 and #15306. It should merge before the delivery PR #15343. The roadmap and related PR search showed no duplicate archive lifecycle capture. ## What Changed - Allow project `archive` records in the journal constraint and TypeScript type. - Capture archive and restore transitions under the existing project row lock. - Emit `update` then `archive` for an edit combined with archive. - Preserve repository records and roll back the project change if capture fails. - Add migration `0307_cool_naoko.sql`, tests, and database documentation. ## Verification - `pnpm -r typecheck` passed after the final master rebase. - `pnpm build` passed before the final master rebase; the final CI build also passed. - 20 targeted lifecycle, migration snapshot, and legacy restore tests passed on the current migration. - Tests cover concurrent archives, repeated archive/restore requests, repository retention, combined edits, atomic rollback, and upgrades from older JavaScript backups without the action constraint. - All 53 applicable CI checks passed on final commit `1021953035`; the two Storybook checks were skipped as expected. - Greptile scored the final commit 5/5 with no unresolved review threads. - `git diff --check` and a local secret/PII scan passed. - Prior CI found a missing-constraint restore failure; the migration now handles it. A separate runtime readiness timeout passed locally. The current full CI run passes both paths. ## Risks - Older JavaScript restores may omit the action check. The migration tolerates its absence and installs the complete check. - Apply the constraint migration before running the new capture code. It takes a short table lock and validates existing journal rows; it changes no existing records. - This PR captures future transitions only. The one-time baseline and plugin consumption remain in #15343, which must be rebased after this PR merges. - Archive records authorize no provider cleanup. Provider behavior and volume retention remain separate work. - An edit combined with archive emits two ordered records in one transaction. ## 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 | 6 +- .../db/src/migrations/0307_cool_naoko.sql | 3 + ...{0302_snapshot.json => 0307_snapshot.json} | 246 +++++++++++++++++- packages/db/src/migrations/meta/_journal.json | 7 + .../src/schema/resource_lifecycle_events.ts | 4 +- .../resource-lifecycle-events.test.ts | 36 ++- server/src/services/projects.ts | 19 +- .../src/services/resource-lifecycle-events.ts | 4 +- 8 files changed, 304 insertions(+), 21 deletions(-) create mode 100644 packages/db/src/migrations/0307_cool_naoko.sql rename packages/db/src/migrations/meta/{0302_snapshot.json => 0307_snapshot.json} (99%) diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 2c87b1a3c70..6bb4307f58a 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -506,7 +506,11 @@ 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. +commits. An active-to-archived transition emits `archive`; restoring an archived +project emits `update`. Repeat archive or restore requests emit no new status +record. An edit combined with archive emits `update` followed by `archive` in +the same transaction. Archiving preserves workspace records and authorizes no +provider cleanup. 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/0307_cool_naoko.sql b/packages/db/src/migrations/0307_cool_naoko.sql new file mode 100644 index 00000000000..a9bd927e2c0 --- /dev/null +++ b/packages/db/src/migrations/0307_cool_naoko.sql @@ -0,0 +1,3 @@ +-- Older JavaScript restores can 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" IN ('update', 'archive')) OR ("resource_lifecycle_events"."resource_type" = 'agent' AND "resource_lifecycle_events"."action" IN ('pause', 'resume', 'terminate'))); diff --git a/packages/db/src/migrations/meta/0302_snapshot.json b/packages/db/src/migrations/meta/0307_snapshot.json similarity index 99% rename from packages/db/src/migrations/meta/0302_snapshot.json rename to packages/db/src/migrations/meta/0307_snapshot.json index 3dc0c17a529..327a2ff5674 100644 --- a/packages/db/src/migrations/meta/0302_snapshot.json +++ b/packages/db/src/migrations/meta/0307_snapshot.json @@ -1,6 +1,6 @@ { - "id": "befbd58f-acb8-4fd4-bb64-4c1c0cfd0f3e", - "prevId": "357aa875-870e-4232-9b3f-4194a2c74844", + "id": "ff1eeb0a-fea3-4cd1-b45c-ce712c4667ff", + "prevId": "164deda4-7a03-45fb-8236-d6ea0c43da11", "version": "7", "dialect": "postgresql", "tables": { @@ -2894,7 +2894,7 @@ "checkConstraints": { "ai_connection_defaults_provider_check": { "name": "ai_connection_defaults_provider_check", - "value": "\"ai_connection_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai')" + "value": "\"ai_connection_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google')" }, "ai_connection_defaults_method_check": { "name": "ai_connection_defaults_method_check", @@ -3367,7 +3367,7 @@ "checkConstraints": { "ai_provider_defaults_provider_check": { "name": "ai_provider_defaults_provider_check", - "value": "\"ai_provider_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai')" + "value": "\"ai_provider_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google')" } }, "isRLSEnabled": false @@ -39410,7 +39410,7 @@ }, "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'))" + "value": "\"resource_lifecycle_events\".\"action\" = 'create' OR (\"resource_lifecycle_events\".\"resource_type\" = 'project' AND \"resource_lifecycle_events\".\"action\" IN ('update', 'archive')) OR (\"resource_lifecycle_events\".\"resource_type\" = 'agent' AND \"resource_lifecycle_events\".\"action\" IN ('pause', 'resume', 'terminate'))" } }, "isRLSEnabled": false @@ -51701,6 +51701,13 @@ "primaryKey": false, "notNull": true }, + "grant_types": { + "name": "grant_types", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"authorization_code\",\"refresh_token\"]'::jsonb" + }, "created_at": { "name": "created_at", "type": "timestamp with time zone", @@ -51717,6 +51724,181 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.mcp_oauth_device_requests": { + "name": "mcp_oauth_device_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_code_hash": { + "name": "device_code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code_hash": { + "name": "user_code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "requested_company_id": { + "name": "requested_company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "next_poll_at": { + "name": "next_poll_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_oauth_device_code_uq": { + "name": "mcp_oauth_device_code_uq", + "columns": [ + { + "expression": "device_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_user_code_uq": { + "name": "mcp_oauth_user_code_uq", + "columns": [ + { + "expression": "user_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_device_expiry_idx": { + "name": "mcp_oauth_device_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_oauth_device_requests_client_id_mcp_oauth_clients_id_fk": { + "name": "mcp_oauth_device_requests_client_id_mcp_oauth_clients_id_fk", + "tableFrom": "mcp_oauth_device_requests", + "tableTo": "mcp_oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_device_requests_grant_id_mcp_oauth_grants_id_fk": { + "name": "mcp_oauth_device_requests_grant_id_mcp_oauth_grants_id_fk", + "tableFrom": "mcp_oauth_device_requests", + "tableTo": "mcp_oauth_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.mcp_oauth_grants": { "name": "mcp_oauth_grants", "schema": "", @@ -51842,6 +52024,54 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.mcp_oauth_metadata_admissions": { + "name": "mcp_oauth_metadata_admissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mcp_oauth_metadata_admissions_expiry_idx": { + "name": "mcp_oauth_metadata_admissions_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.mcp_oauth_requests": { "name": "mcp_oauth_requests", "schema": "", @@ -51888,6 +52118,12 @@ "primaryKey": false, "notNull": true }, + "requested_company_id": { + "name": "requested_company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "grant_id": { "name": "grant_id", "type": "uuid", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index d8885a5c32b..ef06669ccd9 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2136,6 +2136,13 @@ "when": 1791308714862, "tag": "0306_familiar_titania", "breakpoints": true + }, + { + "idx": 307, + "version": "7", + "when": 1791311441769, + "tag": "0307_cool_naoko", + "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 c9bae44d3d2..6eb56240d09 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" | "update" | "pause" | "resume" | "terminate">().notNull(), + action: text("action").$type<"create" | "update" | "archive" | "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} = 'project' AND ${table.action} = 'update') 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} IN ('update', 'archive')) 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 229f514d783..54de2a4b349 100644 --- a/server/src/__tests__/resource-lifecycle-events.test.ts +++ b/server/src/__tests__/resource-lifecycle-events.test.ts @@ -219,7 +219,7 @@ describePostgres("Resource lifecycle events", () => { 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 () => { + it("captures project and workspace updates and batches repository replacement", 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: [] }, @@ -236,12 +236,42 @@ describePostgres("Resource lifecycle events", () => { ]); 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("records archive once per transition and update on restore, retaining repositories", async () => { + const service = projectService(db); + const project = await service.createWithRepositories(companyId, { name: "Archive fixture" }, [ + { id: "1", fullName: "fixture/one", url: "https://github.com/fixture/one", connections: [] }, + ]); + await Promise.all([ + service.update(project.id, { archivedAt: new Date(), name: undefined }), + service.update(project.id, { archivedAt: new Date() }), + ]); + expect((await events()).map(row => row.action)).toEqual(["create", "archive"]); + expect((await service.getById(project.id))?.workspaces).toHaveLength(1); + await service.update(project.id, { archivedAt: null }); + await service.update(project.id, { archivedAt: null }); + await service.update(project.id, { archivedAt: new Date(), name: "Edited and archived" }); + expect((await events()).sort((a, b) => a.id - b.id).map(row => row.action)) + .toEqual(["create", "archive", "update", "update", "archive"]); + }); + + it("rolls back archive and combined edits when the archive record fails", async () => { + const service = projectService(db); + const project = await service.create(companyId, { name: "Retained project" }); + const before = await events(); + await db.execute(sql`ALTER TABLE resource_lifecycle_events ADD CONSTRAINT fixture_reject_archive CHECK (action <> 'archive') NOT VALID`); + try { + await expect(service.update(project.id, { archivedAt: new Date(), name: "Rejected edit" })).rejects.toThrow(); + expect(await service.getById(project.id)).toMatchObject({ name: "Retained project", archivedAt: null }); + expect(await events()).toEqual(before); + } finally { + await db.execute(sql`ALTER TABLE resource_lifecycle_events DROP CONSTRAINT fixture_reject_archive`); + } + }); + 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" }); diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index f00057f06a5..9fe3efc255f 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 { recordProjectUpdateEvent, recordResourceCreationEvent } from "./resource-lifecycle-events.js"; +import { recordProjectLifecycleEvent, recordResourceCreationEvent } from "./resource-lifecycle-events.js"; type ProjectRow = typeof projects.$inferSelect; type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect; @@ -878,7 +878,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool } 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); + await recordProjectLifecycleEvent(tx as unknown as Db, project.companyId, projectId); return service.getById(projectId); }); }, @@ -893,7 +893,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool 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 }) + .select({ id: projects.id, companyId: projects.companyId, name: projects.name, archivedAt: projects.archivedAt }) .from(projects) .where(eq(projects.id, id)).for("update") .then((rows) => rows[0] ?? null); @@ -937,8 +937,11 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool 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); + if (Object.entries(projectData).some(([key, value]) => value !== undefined && key !== "archivedAt" && key !== "updatedAt") || ids !== undefined || (existingProject.archivedAt && !row.archivedAt)) { + await recordProjectLifecycleEvent(db, row.companyId, id); + } + if (row.archivedAt && !existingProject.archivedAt) { + await recordProjectLifecycleEvent(db, row.companyId, id, "archive"); } const [withGoals] = await attachGoals(db, [row]); @@ -1080,7 +1083,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool }); if (created && options.captureWorkspaceUpdates !== false) { - await recordProjectUpdateEvent(db, created.companyId, projectId); + await recordProjectLifecycleEvent(db, created.companyId, projectId); } return created ? toWorkspace(created) : null; }), @@ -1237,7 +1240,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool }); if (updated && options.captureWorkspaceUpdates !== false) { - await recordProjectUpdateEvent(db, updated.companyId, projectId); + await recordProjectLifecycleEvent(db, updated.companyId, projectId); } return updated ? toWorkspace(updated) : null; }), @@ -1292,7 +1295,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool }); if (removed && options.captureWorkspaceUpdates !== false) { - await recordProjectUpdateEvent(db, removed.companyId, projectId); + await recordProjectLifecycleEvent(db, removed.companyId, projectId); } return removed ? toWorkspace(removed) : null; }), diff --git a/server/src/services/resource-lifecycle-events.ts b/server/src/services/resource-lifecycle-events.ts index 9e34dcb5d38..73bd9444fb3 100644 --- a/server/src/services/resource-lifecycle-events.ts +++ b/server/src/services/resource-lifecycle-events.ts @@ -25,6 +25,6 @@ export async function recordAgentStatusEvent(db: Db, companyId: string, agentId: } /** 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" }); +export async function recordProjectLifecycleEvent(db: Db, companyId: string, projectId: string, action: "update" | "archive" = "update"): Promise { + await db.insert(resourceLifecycleEvents).values({ companyId, resourceType: "project", resourceId: projectId, action }); }