mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
fix(documents): say whether an exact revision read is still current (#19939)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Task documents keep a revision history, and agents read and restore
revisions through the API
> - The exact-revision read returns one revision, but it does not say if
that revision is still current
> - An agent read the revision that it restored earlier by its ID and
reported it as "latest", but another person had saved a newer edit
> - This pull request adds the document's current revision to the
exact-revision read
> - The benefit is that a pinned read cannot pass as a read of the
current document
## Linked Issues or Issue Description
**What happened**
In a chat eval, the agent restored a plan revision. Then another person
saved a new revision. The user asked the agent to read the current plan.
The agent called `GET
/api/issues/{id}/documents/plan/revisions/{revisionId}` with the ID of
its own restored revision. The response did not show that a newer
revision existed. The agent reported the old revision as the latest and
said that the read confirmed this.
**Expected behavior**
A read of one exact revision must show if that revision is still
current, and which revision is current. Then the reader can see a newer
edit and read the current document.
**Steps to reproduce**
1. Save two revisions of a task plan.
2. Call `GET
/api/issues/{id}/documents/plan/revisions/{firstRevisionId}`.
3. See that the response gives no sign that revision 2 is the current
revision.
## What Changed
- `getIssueDocumentRevision` now also returns `latestRevisionId`,
`latestRevisionNumber` and `isLatest`.
- Shared type `ExactDocumentRevision` describes this response. The UI
client uses it.
- The OpenAPI description of the exact-revision read names these fields
and points to `GET /api/issues/{id}/documents/{key}` for the current
text.
- The service test checks `isLatest` for an older and a current
revision.
## Verification
- `cd server && pnpm exec vitest run
src/__tests__/documents-service.test.ts
src/__tests__/openapi-routes.test.ts` (42 passed).
- `npx tsc --noEmit -p packages/shared` passes.
## Risks
- Low risk. The change only adds fields to a read response. No caller
removes or renames a field. No migration.
## Model Used
- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), 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
- [x] 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
a4a3e342ec
commit
779913333a
7 files changed
+16
-3
No files matched your search
@@ -1220,6 +1220,7 @@ export type {
|
||||
IssueDocument,
|
||||
IssueDocumentSummary,
|
||||
DocumentRevision,
|
||||
ExactDocumentRevision,
|
||||
DocumentFormat,
|
||||
LegacyPlanDocument,
|
||||
IssueAttachment,
|
||||
|
||||
@@ -820,6 +820,7 @@ export type {
|
||||
IssueDocument,
|
||||
IssueDocumentSummary,
|
||||
DocumentRevision,
|
||||
ExactDocumentRevision,
|
||||
DocumentFormat,
|
||||
LegacyPlanDocument,
|
||||
IssueAncestor,
|
||||
|
||||
@@ -214,6 +214,12 @@ export interface DocumentRevision {
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface ExactDocumentRevision extends DocumentRevision {
|
||||
latestRevisionId: string | null;
|
||||
latestRevisionNumber: number;
|
||||
isLatest: boolean;
|
||||
}
|
||||
|
||||
export interface LegacyPlanDocument {
|
||||
key: "plan";
|
||||
body: string;
|
||||
|
||||
@@ -109,6 +109,9 @@ describeEmbeddedPostgres("documentService system issue documents", () => {
|
||||
const pinned = await svc.getIssueDocumentRevision(issueId, "plan", original!.latestRevisionId!);
|
||||
expect(pinned?.body).toBe("# Plan");
|
||||
expect(pinned?.id).toBe(original!.latestRevisionId);
|
||||
const current = await svc.getIssueDocumentByKey(issueId, "plan");
|
||||
expect(pinned).toMatchObject({ isLatest: false, latestRevisionId: current!.latestRevisionId, latestRevisionNumber: 2 });
|
||||
expect(await svc.getIssueDocumentRevision(issueId, "plan", current!.latestRevisionId!)).toMatchObject({ isLatest: true, revisionNumber: 2, body: "# Newer plan" });
|
||||
expect(await svc.getIssueDocumentRevision(issueId, "other-document", original!.latestRevisionId!)).toBeNull();
|
||||
expect(await svc.getIssueDocumentRevision(randomUUID(), "plan", original!.latestRevisionId!)).toBeNull();
|
||||
expect(await svc.getIssueDocumentRevision(issueId, "plan", randomUUID())).toBeNull();
|
||||
|
||||
@@ -4625,7 +4625,7 @@ registry.registerPath({
|
||||
tags: ["issues"],
|
||||
summary: "Get an exact issue document revision",
|
||||
description:
|
||||
"Available to board users and agents with current issue read access. Returns the specified revision only when its document is linked to this issue under the given key. Document keys are trimmed and lowercased before validation. This read does not restore or modify the document.",
|
||||
"Available to board users and agents with current issue read access. Returns the specified revision only when its document is linked to this issue under the given key. Document keys are trimmed and lowercased before validation. The response also carries the document's current latestRevisionId and latestRevisionNumber, and isLatest says whether this revision is still current; to read the current text, use GET /api/issues/{id}/documents/{key}. This read does not restore or modify the document.",
|
||||
request: {
|
||||
params: z.object({
|
||||
id: z.string(),
|
||||
|
||||
@@ -181,11 +181,12 @@ export function documentService(db: Db) {
|
||||
format: documentRevisions.format, body: documentRevisions.body, changeSummary: documentRevisions.changeSummary,
|
||||
createdByAgentId: documentRevisions.createdByAgentId, createdByUserId: documentRevisions.createdByUserId,
|
||||
createdByRunId: documentRevisions.createdByRunId, createdAt: documentRevisions.createdAt,
|
||||
latestRevisionId: documents.latestRevisionId, latestRevisionNumber: documents.latestRevisionNumber,
|
||||
}).from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.innerJoin(documentRevisions, and(eq(documentRevisions.documentId, documents.id), eq(documentRevisions.companyId, documents.companyId)))
|
||||
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key), eq(documentRevisions.id, revisionId))).limit(1);
|
||||
return revision ?? null;
|
||||
return revision ? { ...revision, isLatest: revision.latestRevisionId === revision.id } : null;
|
||||
},
|
||||
|
||||
listIssueDocumentRevisions: async (issueId: string, rawKey: string) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
InboxMineSnapshot,
|
||||
CreateIssueTreeHold,
|
||||
DocumentRevision,
|
||||
ExactDocumentRevision,
|
||||
FeedbackTargetType,
|
||||
FeedbackTrace,
|
||||
FeedbackVote,
|
||||
@@ -747,7 +748,7 @@ export const issuesApi = {
|
||||
`/issues/${id}/documents/${encodeURIComponent(key)}/revisions`,
|
||||
),
|
||||
getDocumentRevision: (id: string, key: string, revisionId: string) =>
|
||||
api.get<DocumentRevision>(`/issues/${id}/documents/${encodeURIComponent(key)}/revisions/${encodeURIComponent(revisionId)}`),
|
||||
api.get<ExactDocumentRevision>(`/issues/${id}/documents/${encodeURIComponent(key)}/revisions/${encodeURIComponent(revisionId)}`),
|
||||
restoreDocumentRevision: (id: string, key: string, revisionId: string) =>
|
||||
api.post<IssueDocument>(
|
||||
`/issues/${id}/documents/${encodeURIComponent(key)}/revisions/${revisionId}/restore`,
|
||||
|
||||
Reference in new issue
Block a user