mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
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:
6 files changed
+55
-16
No files matched your search
+5
-1
@@ -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" });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in new issue
Block a user