mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
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:
1 parent
9f7057e122
commit
d9f600043f
8 files changed
+304
-21
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,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')));
|
||||
+241
-5
@@ -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
@@ -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" });
|
||||
|
||||
@@ -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