mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
fix(status-cards): give agents the person's restore route when archiving a card (#19903)
## 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
2d89486c57
commit
adb7633d7b
5 files changed
+106
-8
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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<T extends { id: string }>(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<ReturnType<typeof enqueueCompile>>;
|
||||
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) => {
|
||||
|
||||
Reference in new issue
Block a user