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 <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip authored and GitHub committed 2026-10-06 11:43:16 -07:00
1 parent 9f7057e122
commit d9f600043f
8 files changed
+304 -21

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,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')));
@@ -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",
+7
View File
@@ -2136,6 +2136,13 @@
"when": 1791308714862,
"tag": "0306_familiar_titania",
"breakpoints": true
},
{
"idx": 307,
"version": "7",
"when": 1791311441769,
"tag": "0307_cool_naoko",
"breakpoints": true
}
]
}
@@ -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 });
}