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 60ce5435036..41271fbea2f 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -576,6 +576,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..34aa2d7f7ce 100644 --- a/server/src/__tests__/status-cards.test.ts +++ b/server/src/__tests__/status-cards.test.ts @@ -211,7 +211,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 bf57a7ac753..d094a47717b 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -3461,6 +3461,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..9e40dc99def 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,13 @@ 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 withHref(card: T) { + const [company] = await db.select({ issuePrefix: companies.issuePrefix }).from(companies).where(eq(companies.id, card.companyId)).limit(1); + return company ? { ...card, href: `/${company.issuePrefix}/status/${card.id}` } : card; + } + async function assertStatusCardsEnabled() { const experimental = await settings.getExperimental(); if (experimental.enableStatusCards !== true) throw notFound("Status cards are not enabled"); @@ -168,7 +176,7 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake 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)); + res.status(201).json(await withHref(publicCard(compile.card))); } catch (error) { await service.remove(card.id); throw error; @@ -179,9 +187,9 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake 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(await withHref(publicCard(await service.hydrate(card, { issueReadCondition: await issueReadSqlCondition(db, req.actor), - }))); + })))); }); router.patch("/status-cards/:id", validate(patchStatusCardSchema), async (req, res) => { @@ -203,7 +211,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(await withHref(publicCard(compile?.card ?? restore?.card ?? updated))); }); router.delete("/status-cards/:id", async (req, res) => {