diff --git a/server/src/__tests__/status-cards.test.ts b/server/src/__tests__/status-cards.test.ts index 68245be4f69..a0a89a7dd82 100644 --- a/server/src/__tests__/status-cards.test.ts +++ b/server/src/__tests__/status-cards.test.ts @@ -194,6 +194,47 @@ describeEmbeddedPostgres("status card routes", () => { expect(await db.select().from(issues)).toEqual([]); }); + it("keeps a card with a queued compile when its activity record fails", async () => { + const company = await seedCompany(); + await enableStatusCards(); + await seedSummarizer(company.id); + // Fail only the card's creation activity record, which is written after the compile is queued. + const failingActivityDb = new Proxy(db, { + get(target, property) { + if (property === "insert") { + return (table: unknown) => { + const builder = target.insert(table as never); + if (table !== activityLog) return builder; + return new Proxy(builder, { + get(inner, key) { + if (key === "values") { + return (values: { action?: string }) => { + if (values?.action === "status_card.created") throw new Error("activity log unavailable"); + return inner.values(values as never); + }; + } + const value = Reflect.get(inner, key, inner); + return typeof value === "function" ? value.bind(inner) : value; + }, + }); + }; + } + const value = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }) as Db; + const response = await request(createApp(failingActivityDb, localBoardActor())) + .post(`/api/companies/${company.id}/status-cards`) + .send({ interestPrompt: "Recently updated launch tasks" }); + + expect(response.status).toBe(500); + const cards = await db.select().from(statusCards); + expect(cards).toHaveLength(1); + const generation = await db.select().from(issues).where(eq(issues.id, cards[0]!.generatingIssueId!)).then((rows) => rows[0]); + expect(generation).toBeDefined(); + expect(generation!.status).not.toBe("cancelled"); + }); + it("creates, patches, archives, restores, lists updates, and deletes a card", async () => { const company = await seedCompany(); await enableStatusCards(); diff --git a/server/src/routes/status-cards.ts b/server/src/routes/status-cards.ts index f244942edb3..b86de76ae97 100644 --- a/server/src/routes/status-cards.ts +++ b/server/src/routes/status-cards.ts @@ -172,21 +172,23 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake await assertCanMutate(req, companyId); assertAgentPromptLimit(req, req.body.interestPrompt); const actor = getActorInfo(req); - // Resolve the link before creating the card: the catch below deletes the card, so nothing that - // runs after the compile is queued may fail there. + // Resolve the link before creating the card, so a failed lookup leaves nothing behind. const issuePrefix = await companyIssuePrefix(companyId); const card = await service.create(companyId, req.body, { agentId: actor.actorType === "agent" ? actor.actorId : null, userId: actor.actorType === "user" ? actor.actorId : null, }); + // Only a failure to queue the compile removes the new card. Once the compile is queued, the card + // and its generation task exist together, so later failures must not delete the card alone. + let compile: Awaited>; try { - const compile = await enqueueCompile(req, card.id); - await logMutation(req, companyId, "status_card.created", card.id, { state: card.state }); - res.status(201).json(withHref(publicCard(compile.card), issuePrefix)); + compile = await enqueueCompile(req, card.id); } catch (error) { await service.remove(card.id); throw error; } + await logMutation(req, companyId, "status_card.created", card.id, { state: card.state }); + res.status(201).json(withHref(publicCard(compile.card), issuePrefix)); }); router.get("/status-cards/:id", async (req, res) => {