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 }); }