From adb7633d7be0500ea6320dbe2c5ea39a0ad72947 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:28:11 -0600 Subject: [PATCH] fix(status-cards): give agents the person's restore route when archiving a card (#19903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Status cards are saved summaries of company work. People and agents can archive and restore them. > - A person asked a chat agent to archive a card "to reuse it next quarter". The agent archived it correctly. > - The archive response had no board route, and the PATCH operation had no description. The completion guidance tells agents not to guess routes. > - So the agent told the person to restore the card "by clearing `archived`". That is an API instruction, not a route a person can use. > - This pull request gives single-card responses an `href` to the Status page and documents archive and restore on the PATCH operation. > - The benefit is that an agent can give the person a usable link and a true account of restore, which starts a fresh summary. ## Linked Issues or Issue Description **What happened** A chat agent archived a status card. Its reply told the person to restore the card by clearing `archived` on the card. It gave no link to the place where a person restores it. **Expected behavior** The reply links the card on the Status page, where a person can show archived cards and click Restore. It also says that restore starts a fresh summary. **Steps to reproduce** 1. Enable status cards and create a card as an agent. 2. Ask the CEO agent in chat to archive the card for later reuse. 3. Read the reply. It has no usable restore route, because `PATCH /api/status-cards/{id}` returns no `href` and has no description. ## What Changed - `POST /api/companies/{companyId}/status-cards`, `GET /api/status-cards/{id}` and `PATCH /api/status-cards/{id}` now return `href`, the company-scoped Status page route for the card. - The shared `statusCardSchema` has an optional `href` field. - The `PATCH /api/status-cards/{id}` OpenAPI operation has a description. It says that archive keeps the card and its history, that a person restores it on the Status page with the returned `href`, and that restore starts a fresh full summary. - The create route reads the company issue prefix before it creates the card. A failed link lookup can not delete a card whose compile task is queued. - Regression tests cover the `href` on the archive and get responses, the new PATCH description, and a failed prefix read on create (no card and no task are left). ## Verification - `pnpm exec vitest run server/src/__tests__/status-cards.test.ts server/src/__tests__/openapi-routes.test.ts` (71 tests pass). The new create-route test fails on the first commit and passes on the fix. - End to end: the chat eval `status-cards.archive-card` runs with this change on an eval branch. The result is in the eval report for that case. ## Risks - Low risk. The change adds one optional response field and one description. List responses do not change. The UI ignores the new field. - Each single-card response does one more small company read for the issue prefix. On create, this read occurs before the card exists, so its failure leaves nothing behind. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), used in Claude Code with tool use and code execution. ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- packages/shared/src/validators/status-card.ts | 2 + server/src/__tests__/openapi-routes.test.ts | 8 +++ server/src/__tests__/status-cards.test.ts | 71 ++++++++++++++++++- server/src/routes/openapi.ts | 3 + server/src/routes/status-cards.ts | 30 ++++++-- 5 files changed, 106 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/validators/status-card.ts b/packages/shared/src/validators/status-card.ts index 0dccd38bed2..9095a835a40 100644 --- a/packages/shared/src/validators/status-card.ts +++ b/packages/shared/src/validators/status-card.ts @@ -102,6 +102,8 @@ export const statusCardSchema = z.object({ watchedIssueCount: z.number().int().nonnegative().optional(), todayTokens: z.number().int().nonnegative().optional(), todayCostCents: z.number().nonnegative().optional(), + /** Company-scoped board route where people view, restore and refresh the card. */ + href: z.string().optional(), }); export const statusCardUpdateChangeSchema = z.object({ diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index baae442e78c..9dcba27a991 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -557,6 +557,14 @@ describe("openapi routes", () => { expect(dismiss.description).toContain("viewers may dismiss their own"); }); + it("tells agents that archiving keeps a status card and where a person restores it", async () => { + const res = await request(createApp()).get("/api/openapi.json"); + const patch = res.body.paths["/api/status-cards/{id}"].patch; + expect(patch.description).toContain("it is not deleted"); + expect(patch.description).toContain("give them the returned href as a Markdown link"); + expect(patch.description).toContain("Restoring (archived: false) starts a fresh full summary"); + }); + it("documents exact failed-run selection and durable accepted retry responses", async () => { const res = await request(createApp()).get("/api/openapi.json"); const wake = res.body.paths["/api/agents/{id}/wakeup"].post; diff --git a/server/src/__tests__/status-cards.test.ts b/server/src/__tests__/status-cards.test.ts index c1faade83fa..a0a89a7dd82 100644 --- a/server/src/__tests__/status-cards.test.ts +++ b/server/src/__tests__/status-cards.test.ts @@ -168,6 +168,73 @@ describeEmbeddedPostgres("status card routes", () => { expect(await db.select().from(issues).then((rows) => rows[0])).toMatchObject({ status: "cancelled" }); }); + it("creates nothing when the card link cannot be resolved", async () => { + const company = await seedCompany(); + await enableStatusCards(); + await seedSummarizer(company.id); + // The link lookup runs before the card is created, so its failure cannot strand a queued compile task. + const failingPrefixDb = new Proxy(db, { + get(target, property) { + if (property === "select") { + return (fields?: Record) => { + if (fields && "issuePrefix" in fields) throw new Error("company read unavailable"); + return target.select(fields as never); + }; + } + const value = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }) as Db; + const response = await request(createApp(failingPrefixDb, localBoardActor())) + .post(`/api/companies/${company.id}/status-cards`) + .send({ interestPrompt: "Recently updated launch tasks" }); + + expect(response.status).toBe(500); + expect(await db.select().from(statusCards)).toEqual([]); + 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(); @@ -211,7 +278,9 @@ describeEmbeddedPostgres("status card routes", () => { const archived = await request(app).patch(`/api/status-cards/${created.body.id}`).send({ archived: true }); expect(archived.status).toBe(200); - expect(archived.body).toMatchObject({ archivedAt: expect.any(String), generatingIssueId: null }); + // The person's restore route travels with the archive receipt, so an agent can link it. + expect(archived.body).toMatchObject({ archivedAt: expect.any(String), generatingIssueId: null, href: `/${company.issuePrefix}/status/${created.body.id}` }); + expect((await request(app).get(`/api/status-cards/${created.body.id}`)).body.href).toBe(`/${company.issuePrefix}/status/${created.body.id}`); expect(await db.select().from(issues).where(eq(issues.id, created.body.generatingIssueId)).then((rows) => rows[0]?.status)).toBe("cancelled"); expect((await request(app).get(`/api/companies/${company.id}/status-cards`)).body).toEqual([]); expect((await request(app).get(`/api/companies/${company.id}/status-cards?archived=true`)).body).toHaveLength(1); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index b52b5c716e7..d6745237251 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -3443,6 +3443,9 @@ registerCurrentRoute({ path: "/api/status-cards/{id}", tags: ["status-cards"], summary: "Update, archive, or restore a status card", + description: "Archiving (archived: true) hides the card from the active list but keeps the card, its summary and its update history; it is not deleted. " + + "A person views and restores archived cards on the company's Status page (Show archived, then Restore); give them the returned href as a Markdown link. " + + "Restoring (archived: false) starts a fresh full summary for a card with a compiled query, so restored content is regenerated before it is current.", body: patchStatusCardSchema, }); diff --git a/server/src/routes/status-cards.ts b/server/src/routes/status-cards.ts index d21616b7cf2..b86de76ae97 100644 --- a/server/src/routes/status-cards.ts +++ b/server/src/routes/status-cards.ts @@ -1,5 +1,6 @@ import { Router, type Request } from "express"; -import type { Db } from "@paperclipai/db"; +import { companies, type Db } from "@paperclipai/db"; +import { eq } from "drizzle-orm"; import { createStatusCardSchema, listStatusCardsQuerySchema, @@ -30,6 +31,16 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake const issueSvc = issueService(db); const heartbeat = opts.heartbeat ?? heartbeatService(db); + // People view, restore and refresh a card on the company's Status page. Single-card responses carry that + // route so an agent can give the person a usable link instead of an API instruction. + async function companyIssuePrefix(companyId: string) { + const [company] = await db.select({ issuePrefix: companies.issuePrefix }).from(companies).where(eq(companies.id, companyId)).limit(1); + return company?.issuePrefix ?? null; + } + function withHref(card: T, issuePrefix: string | null) { + return issuePrefix ? { ...card, href: `/${issuePrefix}/status/${card.id}` } : card; + } + async function assertStatusCardsEnabled() { const experimental = await settings.getExperimental(); if (experimental.enableStatusCards !== true) throw notFound("Status cards are not enabled"); @@ -161,27 +172,32 @@ 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, 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(publicCard(compile.card)); + 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) => { await assertStatusCardsEnabled(); const card = await getAccessibleResource(req, res, service.getById(req.params.id as string), "Status card not found"); if (!card) return; - res.json(publicCard(await service.hydrate(card, { + res.json(withHref(publicCard(await service.hydrate(card, { issueReadCondition: await issueReadSqlCondition(db, req.actor), - }))); + })), await companyIssuePrefix(card.companyId))); }); router.patch("/status-cards/:id", validate(patchStatusCardSchema), async (req, res) => { @@ -203,7 +219,7 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake fields: Object.keys(req.body), archived: Boolean(updated.archivedAt), }); - res.json(publicCard(compile?.card ?? restore?.card ?? updated)); + res.json(withHref(publicCard(compile?.card ?? restore?.card ?? updated), await companyIssuePrefix(card.companyId))); }); router.delete("/status-cards/:id", async (req, res) => {