From 27accec80d55cfde5e78279cc212551b14814732 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Tue, 6 Oct 2026 10:32:03 -0700 Subject: [PATCH] Capture project archive and restore lifecycle events Record archive transitions and restore updates in the existing project mutation transaction. Keep repeated archive requests idempotent under the project row lock, retain repository records, and roll back combined edits when capture fails. Lifecycle database and migration snapshot tests pass, as does workspace typecheck. Co-Authored-By: Paperclip --- doc/DATABASE.md | 6 +++- .../src/migrations/0306_many_sabretooth.sql | 2 ++ .../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 +-- 6 files changed, 55 insertions(+), 16 deletions(-) create mode 100644 packages/db/src/migrations/0306_many_sabretooth.sql 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/0306_many_sabretooth.sql b/packages/db/src/migrations/0306_many_sabretooth.sql new file mode 100644 index 00000000000..a5005e35cf1 --- /dev/null +++ b/packages/db/src/migrations/0306_many_sabretooth.sql @@ -0,0 +1,2 @@ +ALTER TABLE "resource_lifecycle_events" DROP CONSTRAINT "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'))); \ 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 }); }