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 <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip committed 2026-10-06 11:30:38 -07:00
1 parent 9f7057e122
commit 27accec80d
6 files changed
+55 -16

No files matched your search

+5 -1
View File
@@ -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.
@@ -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')));
@@ -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'))`),
}));
@@ -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" });
+11 -8
View File
@@ -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;
}),
@@ -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<void> {
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<void> {
await db.insert(resourceLifecycleEvents).values({ companyId, resourceType: "project", resourceId: projectId, action });
}