Capture project and repository update lifecycle events (#15306)

## Thinking Path

> - Existing lifecycle capture records agent transitions and project
creation.
> - Project and repository edits need matching project update records.
> - The record must commit with the mutation so failed writes cannot
lose a hook.
> - Creation with repositories is one creation, and repository
replacement is one update.
> - Archive-only changes preserve project state without creating a hook.
> - Plugin consumption and provider behavior are separate work.

## Linked Issues or Issue Description

**Problem or motivation**

Project edits and repository/workspace changes lack durable lifecycle
records. A future resource plugin needs those changes captured alongside
the existing project creation hook. Archive-only changes must produce no
hook.

**Proposed solution**

Allow project `update` records in the existing lifecycle journal. Record
project and workspace mutations in their database transaction while
holding the project row lock. Suppress intermediate workspace hooks
during project creation and aggregate repository replacement.

**Alternatives considered**

Route-only hooks miss shared service callers. Recording after commit can
lose an event. Emitting a hook for each child mutation exposes
intermediate repository state.

**Roadmap alignment**

This completes project lifecycle capture begun in #15280. Plugin
delivery, VM/volume provisioning, and backfill remain separate. Searches
found no duplicate project lifecycle work; related #13306 concerns
decision events on the in-process plugin bus.

## What Changed

- Record project edits and workspace additions, updates, and removals as
project `update` events.
- Commit each event atomically with its mutation under the project row
lock.
- Keep project creation with repositories to one creation event and
repository replacement to one aggregate update.
- Ignore archive-only changes and retain workspace records.
- Extend the journal action constraint and document project update
capture.

## Verification

- `pnpm -r typecheck` passed on the narrowed scope.
- 57 tests passed across six lifecycle, project, repository, and
chat-project suites.
- Seven managed-sandbox workspace route tests and the CLI
lagging-worktree migration regression passed (65 targeted tests total).
- Full GitHub CI passed on `8ba6f97f22`; all required gates are green.
- Greptile scored the final project-only commit 5/5 with zero unresolved
review threads.
- The branch is current with `master` and has no merge conflicts.
- `git diff --check` and a local secret/PII scan passed.

## Risks

- Apply migration `0300_chunky_chamber.sql` before running the new
server. It permits project update actions and tolerates older JavaScript
worktree backups that omitted the prior CHECK constraint.
- Event-write failure intentionally rolls back the project or repository
mutation.
- Records contain identity and action; future consumers must load
current authorized project/workspace data.
- Plugin consumption, provider calls, volume cleanup, and backfill are
outside this PR.

## 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 07:33:18 -07:00
1 parent 1477d1ecea
commit 44e4979d23
8 files changed
+767 -30

No files matched your search

+4
View File
@@ -471,6 +471,10 @@ A partial unique `(company_id, resource_type, resource_id)` index deduplicates
creation. Each actual agent pause, resume, or termination appends another event,
including budget actions and generic status updates. The agent row stays locked
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.
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 worktree backups may 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" = 'update') OR ("resource_lifecycle_events"."resource_type" = 'agent' AND "resource_lifecycle_events"."action" IN ('pause', 'resume', 'terminate')));
@@ -1,6 +1,6 @@
{
"id": "02d31b6c-7df3-4f6c-b906-e2d369b5e1cd",
"prevId": "66a0b3c9-91bb-46b8-a868-a6facedbc14b",
"id": "dfa5064e-7b35-4d20-8a3d-aa808c4d92e0",
"prevId": "0eac674e-f69a-4f25-b800-3be01b8fd28d",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -2903,6 +2903,362 @@
},
"isRLSEnabled": false
},
"public.ai_connection_pools": {
"name": "ai_connection_pools",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"plugin_key": {
"name": "plugin_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"config": {
"name": "config",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"revision": {
"name": "revision",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"ai_connection_pools_id_tool_connections_id_fk": {
"name": "ai_connection_pools_id_tool_connections_id_fk",
"tableFrom": "ai_connection_pools",
"tableTo": "tool_connections",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"ai_connection_pools_company_id_companies_id_fk": {
"name": "ai_connection_pools_company_id_companies_id_fk",
"tableFrom": "ai_connection_pools",
"tableTo": "companies",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"ai_connection_pools_company_connection_fk": {
"name": "ai_connection_pools_company_connection_fk",
"tableFrom": "ai_connection_pools",
"tableTo": "tool_connections",
"columnsFrom": [
"company_id",
"id"
],
"columnsTo": [
"company_id",
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ai_connection_pools_company_id_uq": {
"name": "ai_connection_pools_company_id_uq",
"nullsNotDistinct": false,
"columns": [
"company_id",
"id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ai_connection_router_cursors": {
"name": "ai_connection_router_cursors",
"schema": "",
"columns": {
"pool_id": {
"name": "pool_id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"last_member_id": {
"name": "last_member_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"ai_connection_router_cursors_pool_id_ai_connection_pools_id_fk": {
"name": "ai_connection_router_cursors_pool_id_ai_connection_pools_id_fk",
"tableFrom": "ai_connection_router_cursors",
"tableTo": "ai_connection_pools",
"columnsFrom": [
"pool_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"ai_connection_router_cursors_company_id_companies_id_fk": {
"name": "ai_connection_router_cursors_company_id_companies_id_fk",
"tableFrom": "ai_connection_router_cursors",
"tableTo": "companies",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"ai_connection_router_cursors_company_pool_fk": {
"name": "ai_connection_router_cursors_company_pool_fk",
"tableFrom": "ai_connection_router_cursors",
"tableTo": "ai_connection_pools",
"columnsFrom": [
"company_id",
"pool_id"
],
"columnsTo": [
"company_id",
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ai_connection_task_pins": {
"name": "ai_connection_task_pins",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"pool_id": {
"name": "pool_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"agent_id": {
"name": "agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"task_key": {
"name": "task_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"selection": {
"name": "selection",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"member": {
"name": "member",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ai_connection_task_pins_affinity_uq": {
"name": "ai_connection_task_pins_affinity_uq",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "pool_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "agent_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "task_key",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ai_connection_task_pins_company_id_companies_id_fk": {
"name": "ai_connection_task_pins_company_id_companies_id_fk",
"tableFrom": "ai_connection_task_pins",
"tableTo": "companies",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"ai_connection_task_pins_pool_id_ai_connection_pools_id_fk": {
"name": "ai_connection_task_pins_pool_id_ai_connection_pools_id_fk",
"tableFrom": "ai_connection_task_pins",
"tableTo": "ai_connection_pools",
"columnsFrom": [
"pool_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"ai_connection_task_pins_agent_id_agents_id_fk": {
"name": "ai_connection_task_pins_agent_id_agents_id_fk",
"tableFrom": "ai_connection_task_pins",
"tableTo": "agents",
"columnsFrom": [
"agent_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"ai_connection_task_pins_company_pool_fk": {
"name": "ai_connection_task_pins_company_pool_fk",
"tableFrom": "ai_connection_task_pins",
"tableTo": "ai_connection_pools",
"columnsFrom": [
"company_id",
"pool_id"
],
"columnsTo": [
"company_id",
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"ai_connection_task_pins_company_agent_fk": {
"name": "ai_connection_task_pins_company_agent_fk",
"tableFrom": "ai_connection_task_pins",
"tableTo": "agents",
"columnsFrom": [
"company_id",
"agent_id"
],
"columnsTo": [
"company_id",
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ai_provider_defaults": {
"name": "ai_provider_defaults",
"schema": "",
@@ -12347,6 +12703,13 @@
"primaryKey": false,
"notNull": false
},
"ai_session_epoch": {
"name": "ai_session_epoch",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"latest_version": {
"name": "latest_version",
"type": "integer",
@@ -38918,6 +39281,140 @@
},
"isRLSEnabled": false
},
"public.resource_lifecycle_events": {
"name": "resource_lifecycle_events",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "bigint",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "resource_lifecycle_events_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "9223372036854775807",
"cache": "1",
"cycle": false
}
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"resource_type": {
"name": "resource_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"resource_id": {
"name": "resource_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"resource_lifecycle_events_creation_idx": {
"name": "resource_lifecycle_events_creation_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "resource_type",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "resource_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"where": "\"resource_lifecycle_events\".\"action\" = 'create'",
"concurrently": false,
"method": "btree",
"with": {}
},
"resource_lifecycle_events_company_idx": {
"name": "resource_lifecycle_events_company_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"resource_lifecycle_events_company_id_companies_id_fk": {
"name": "resource_lifecycle_events_company_id_companies_id_fk",
"tableFrom": "resource_lifecycle_events",
"tableTo": "companies",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"resource_lifecycle_events_resource_type_check": {
"name": "resource_lifecycle_events_resource_type_check",
"value": "\"resource_lifecycle_events\".\"resource_type\" IN ('agent', 'project')"
},
"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'))"
}
},
"isRLSEnabled": false
},
"public.routine_documents": {
"name": "routine_documents",
"schema": "",
@@ -44557,6 +45054,12 @@
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"error_at": {
"name": "error_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
@@ -44630,6 +45133,159 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tool_connection_app_syncs": {
"name": "tool_connection_app_syncs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"connection_id": {
"name": "connection_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"credential_key": {
"name": "credential_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"lease_id": {
"name": "lease_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true
},
"checked": {
"name": "checked",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"total": {
"name": "total",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"failed": {
"name": "failed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"last_completed_at": {
"name": "last_completed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"tool_connection_app_syncs_owner_key_uq": {
"name": "tool_connection_app_syncs_owner_key_uq",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "connection_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "credential_key",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"tool_connection_app_syncs_company_id_companies_id_fk": {
"name": "tool_connection_app_syncs_company_id_companies_id_fk",
"tableFrom": "tool_connection_app_syncs",
"tableTo": "companies",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"tool_connection_app_syncs_company_connection_fk": {
"name": "tool_connection_app_syncs_company_connection_fk",
"tableFrom": "tool_connection_app_syncs",
"tableTo": "tool_connections",
"columnsFrom": [
"company_id",
"connection_id"
],
"columnsTo": [
"company_id",
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tool_connection_installs": {
"name": "tool_connection_installs",
"schema": "",
@@ -44923,6 +45579,12 @@
"notNull": true,
"default": false
},
"agent_instructions": {
"name": "agent_instructions",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"config": {
"name": "config",
"type": "jsonb",
+7
View File
@@ -2087,6 +2087,13 @@
"when": 1791248126600,
"tag": "0299_absent_ser_duncan",
"breakpoints": true
},
{
"idx": 300,
"version": "7",
"when": 1791260726828,
"tag": "0300_chunky_chamber",
"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" | "pause" | "resume" | "terminate">().notNull(),
action: text("action").$type<"create" | "update" | "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} = '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} = 'update') OR (${table.resourceType} = 'agent' AND ${table.action} IN ('pause', 'resume', 'terminate'))`),
}));
@@ -198,10 +198,10 @@ describePostgres("Resource lifecycle events", () => {
await db.insert(projects).values({ id: oldProjectId, companyId, name: "Existing project" });
await agentService(db).update(oldAgentId, { name: "Renamed agent" });
await projectService(db).update(oldProjectId, { name: "Renamed project" });
expect(await events()).toEqual([]);
expect(await events()).toEqual([expect.objectContaining({ action: "update", resourceId: oldProjectId })]);
const agent = await createAgent();
const project = await projectService(db).create(companyId, { name: "New project" });
expect((await events()).map(row => row.resourceId).sort()).toEqual([agent.id, project.id].sort());
expect((await events()).filter(row => row.action === "create").map(row => row.resourceId).sort()).toEqual([agent.id, project.id].sort());
});
it("scopes resource identities by company and type", async () => {
@@ -218,4 +218,47 @@ describePostgres("Resource lifecycle events", () => {
await db.delete(companies).where(eq(companies.id, otherCompanyId));
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 () => {
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: [] },
]);
expect((await events()).map(row => row.action)).toEqual(["create"]);
await service.update(project.id, { name: "Updated project" });
const workspace = await service.createWorkspace(project.id, { repoUrl: "https://github.com/fixture/two" });
await service.updateWorkspace(project.id, workspace!.id, { repoRef: "main" });
await service.removeWorkspace(project.id, workspace!.id);
const beforeReplace = (await events()).length;
await service.replaceRepositories(project.id, [
{ id: "3", fullName: "fixture/three", url: "https://github.com/fixture/three", connections: [] },
{ id: "4", fullName: "fixture/four", url: "https://github.com/fixture/four", connections: [] },
]);
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("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" });
const workspace = await service.createWorkspace(project.id, { repoUrl: "https://github.com/fixture/retained" });
const before = await events();
await db.execute(sql`ALTER TABLE resource_lifecycle_events ADD CONSTRAINT fixture_reject_update CHECK (action <> 'update') NOT VALID`);
try {
await expect(service.update(project.id, { name: "Rejected update" })).rejects.toThrow();
await expect(service.createWorkspace(project.id, { repoUrl: "https://github.com/fixture/rejected" })).rejects.toThrow();
await expect(service.updateWorkspace(project.id, workspace!.id, { repoRef: "rejected" })).rejects.toThrow();
await expect(service.removeWorkspace(project.id, workspace!.id)).rejects.toThrow();
await expect(service.replaceRepositories(project.id, [])).rejects.toThrow();
expect(await service.getById(project.id)).toMatchObject({ name: "Retained project", workspaces: [expect.objectContaining({ id: workspace!.id, repoRef: null })] });
expect(await events()).toEqual(before);
} finally {
await db.execute(sql`ALTER TABLE resource_lifecycle_events DROP CONSTRAINT fixture_reject_update`);
}
});
});
+37 -24
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 { recordResourceCreationEvent } from "./resource-lifecycle-events.js";
import { recordProjectUpdateEvent, recordResourceCreationEvent } from "./resource-lifecycle-events.js";
type ProjectRow = typeof projects.$inferSelect;
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
@@ -568,7 +568,7 @@ async function ensureSinglePrimaryWorkspace(
);
}
export function projectService(db: Db) {
export function projectService(db: Db, options: { captureWorkspaceUpdates?: boolean } = {}) {
const createProject = async (
companyId: string,
data: Omit<typeof projects.$inferInsert, "companyId"> & { goalIds?: string[] },
@@ -838,7 +838,7 @@ export function projectService(db: Db) {
createWithRepositories: async (companyId: string, data: Parameters<typeof createProject>[1], repositories: import("@paperclipai/shared").ProjectRepository[]): Promise<ProjectWithGoals> => {
return db.transaction(async (tx) => {
const service = projectService(tx as unknown as Db);
const service = projectService(tx as unknown as Db, { captureWorkspaceUpdates: false });
const project = await service.create(companyId, data);
for (const repo of repositories) {
await service.createWorkspace(project.id, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id } });
@@ -851,7 +851,7 @@ export function projectService(db: Db) {
return db.transaction(async (tx) => {
const [project] = await tx.select().from(projects).where(eq(projects.id, projectId)).for("update");
if (!project) return null;
const service = projectService(tx as unknown as Db);
const service = projectService(tx as unknown as Db, { captureWorkspaceUpdates: false });
const existing = await service.listWorkspaces(projectId);
const ids = new Set(repositories.map((repo) => repo.id));
for (const workspace of existing) {
@@ -878,6 +878,7 @@ export function projectService(db: Db) {
} 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);
return service.getById(projectId);
});
},
@@ -887,13 +888,14 @@ export function projectService(db: Db) {
update: async (
id: string,
data: Partial<typeof projects.$inferInsert> & { goalIds?: string[] },
): Promise<ProjectWithGoals | null> => {
): Promise<ProjectWithGoals | null> => db.transaction(async tx => {
const db = tx as unknown as Db;
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 })
.from(projects)
.where(eq(projects.id, id))
.where(eq(projects.id, id)).for("update")
.then((rows) => rows[0] ?? null);
if (!existingProject) return null;
if (ids && ids.length > 0) {
@@ -935,10 +937,14 @@ export function projectService(db: Db) {
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);
}
const [withGoals] = await attachGoals(db, [row]);
const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : [];
return enriched ?? null;
},
}),
clearExecutionWorkspaceEnvironmentSelection: async (companyId: string, environmentId: string) => {
const rows = await db
@@ -954,16 +960,9 @@ export function projectService(db: Db) {
const policy = parseProjectExecutionWorkspacePolicy(row.executionWorkspacePolicy);
if (policy?.environmentId !== environmentId) continue;
await db
.update(projects)
.set({
executionWorkspacePolicy: {
...policy,
environmentId: null,
},
updatedAt: new Date(),
})
.where(eq(projects.id, row.id));
await projectService(db).update(row.id, {
executionWorkspacePolicy: { ...policy, environmentId: null },
});
cleared += 1;
}
@@ -1004,11 +1003,12 @@ export function projectService(db: Db) {
createWorkspace: async (
projectId: string,
data: CreateWorkspaceInput,
): Promise<ProjectWorkspace | null> => {
): Promise<ProjectWorkspace | null> => db.transaction(async tx => {
const db = tx as unknown as Db;
const project = await db
.select()
.from(projects)
.where(eq(projects.id, projectId))
.where(eq(projects.id, projectId)).for("update")
.then((rows) => rows[0] ?? null);
if (!project) return null;
@@ -1079,14 +1079,19 @@ export function projectService(db: Db) {
return row;
});
if (created && options.captureWorkspaceUpdates !== false) {
await recordProjectUpdateEvent(db, created.companyId, projectId);
}
return created ? toWorkspace(created) : null;
},
}),
updateWorkspace: async (
projectId: string,
workspaceId: string,
data: UpdateWorkspaceInput,
): Promise<ProjectWorkspace | null> => {
): Promise<ProjectWorkspace | null> => db.transaction(async tx => {
const db = tx as unknown as Db;
await db.select({ id: projects.id }).from(projects).where(eq(projects.id, projectId)).for("update");
const existing = await db
.select()
.from(projectWorkspaces)
@@ -1231,10 +1236,15 @@ export function projectService(db: Db) {
return row;
});
if (updated && options.captureWorkspaceUpdates !== false) {
await recordProjectUpdateEvent(db, updated.companyId, projectId);
}
return updated ? toWorkspace(updated) : null;
},
}),
removeWorkspace: async (projectId: string, workspaceId: string): Promise<ProjectWorkspace | null> => {
removeWorkspace: async (projectId: string, workspaceId: string): Promise<ProjectWorkspace | null> => db.transaction(async tx => {
const db = tx as unknown as Db;
await db.select({ id: projects.id }).from(projects).where(eq(projects.id, projectId)).for("update");
const existing = await db
.select()
.from(projectWorkspaces)
@@ -1281,8 +1291,11 @@ export function projectService(db: Db) {
return row;
});
if (removed && options.captureWorkspaceUpdates !== false) {
await recordProjectUpdateEvent(db, removed.companyId, projectId);
}
return removed ? toWorkspace(removed) : null;
},
}),
resolveByReference: async (companyId: string, reference: string) => {
const raw = reference.trim();
@@ -23,3 +23,8 @@ export async function recordAgentStatusEvent(db: Db, companyId: string, agentId:
if (!action) return;
await db.insert(resourceLifecycleEvents).values({ companyId, resourceType: "agent", resourceId: agentId, action });
}
/** 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" });
}