From 627728bddeeea57fb0b0ceadef741eeb390ef8ea Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:52:59 -0700 Subject: [PATCH] feat: add authoritative issue PATCH receipts (#10478) 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. > - Agents update tasks through the issue API. > - The update response did not state which values changed. > - Blocker updates also did not echo the scalar blocker IDs. > - Agents therefore used an extra GET request to confirm a successful write. > - This pull request adds an authoritative change receipt and an optional small response. > - The benefit is fewer API calls with a clear and compatible write contract. ## Linked Issues or Issue Description No public GitHub issue exists for this change. ### Subsystem affected Cross-cutting: `server/`, `packages/shared`, and the UI issue cache. ### Problem or motivation A successful issue PATCH returned the updated issue, but it did not identify the effective changes. Blocker writes returned relation summaries without the scalar IDs. Agents could not distinguish a confirmed clear operation from missing data. The response must confirm committed field and blocker changes while existing UI clients continue to receive the full issue by default. ### Proposed solution Add a `changes` receipt. Add a conditional `blockedByIssueIds` echo. Support `Prefer: return=minimal`. Keep the full response as the default. ### Alternatives considered Make the small response the default for agent tokens. This would create different response contracts by actor type, so this pull request does not use that design. ### Roadmap alignment This is a focused control-plane reliability improvement. It does not duplicate an open roadmap milestone. ## What Changed - Compute committed issue row and relation changes in the issue service. - Omit no-op fields and truncate changed long text values to 200 characters. - Echo blocker ID arrays for blocker set and clear requests. - Add the opt-in `Prefer: return=minimal` response and `Preference-Applied` header. - Keep receipt metadata out of React Query issue caches. - Add route and embedded Postgres tests for the new contract. ## Verification - `pnpm exec vitest run server/src/__tests__/issue-activity-events-routes.test.ts` - `pnpm exec vitest run server/src/__tests__/issues-service.test.ts -t "returns authoritative update receipts for row fields and blocker relations"` - `pnpm --filter @paperclipai/shared typecheck` - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/ui typecheck` - `pnpm check:token-gates` - `git diff --check` ## Risks - Low compatibility risk. The default response only adds receipt fields. - Minimal mode is opt-in. Existing clients do not receive a smaller body. - The receipt excludes `updatedAt` because the response already returns it as the freshness anchor. - Prose API and agent workflow guidance will follow after the server contract is available. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex based on GPT-5. The exact deployment ID, context window size, and reasoning mode are not exposed to the agent. The agent used repository tools, code execution, and test 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- docs/api/issues.md | 56 ++++++ packages/shared/src/index.ts | 2 + packages/shared/src/types/index.ts | 2 + packages/shared/src/types/issue.ts | 8 + .../issue-activity-events-routes.test.ts | 177 ++++++++++++++++++ server/src/__tests__/issues-service.test.ts | 62 ++++++ .../__tests__/paperclip-skill-utils.test.ts | 10 + server/src/routes/issues.ts | 23 ++- server/src/services/issue-change-receipt.ts | 50 +++++ server/src/services/issues.ts | 50 ++++- skills/paperclip/SKILL.md | 2 +- skills/paperclip/references/api-reference.md | 44 ++++- ui/src/api/issues.ts | 3 + ui/src/pages/IssueDetail.tsx | 2 +- 14 files changed, 486 insertions(+), 5 deletions(-) create mode 100644 server/src/services/issue-change-receipt.ts diff --git a/docs/api/issues.md b/docs/api/issues.md index 617b17f557..e63e70c172 100644 --- a/docs/api/issues.md +++ b/docs/api/issues.md @@ -68,6 +68,62 @@ Updatable fields: `title`, `description`, `status`, `priority`, `assigneeAgentId For `PATCH /api/issues/{issueId}`, `assigneeAgentId` may be either the agent UUID or the agent shortname/urlKey within the same company. +### Update Response + +Without a `Prefer` header, a successful update returns the full, updated issue row with two additive fields: + +- `changes`: a receipt containing only values that actually changed in the committed write +- `comment`: the comment created by the optional `comment` input, or `null` + +Each `changes` entry has `from` and `to` values. Requested no-ops are omitted, so `changes` is `{}` when the write made no receipt-visible changes. Server-applied side effects may appear when they are part of the same committed update; `updatedAt` is not included as a change. + +```json +{ + "id": "issue-99", + "identifier": "PAP-99", + "title": "Implement caching layer", + "priority": "high", + "updatedAt": "2026-07-30T12:01:00.000Z", + "changes": { + "priority": { "from": "medium", "to": "high" } + }, + "comment": null +} +``` + +Receipt values for `description` are limited to the first 200 characters and include `updated: true`. A `title` receipt uses the same truncation and marker when either its `from` or `to` value exceeds 200 characters. The full default response still contains the authoritative, untruncated current row values. + +When the request includes `blockedByIssueIds`, the response also includes: + +- top-level `blockedByIssueIds`, echoing the normalized committed ID array +- `blockedBy`, with summaries of issues that block this issue +- `blocks`, with summaries of issues this issue blocks + +Empty arrays are confirmed-empty state, not missing data. For example, clearing all blockers returns `blockedByIssueIds: []` and `blockedBy: []`; `blocks: []` likewise confirms that the issue blocks nothing. + +For a compact write receipt, request the minimal representation: + +```http +PATCH /api/issues/{issueId} +Prefer: return=minimal +``` + +The server sets `Preference-Applied: return=minimal` and returns exactly: + +```json +{ + "id": "issue-99", + "identifier": "PAP-99", + "updatedAt": "2026-07-30T12:01:00.000Z", + "changes": { + "priority": { "from": "medium", "to": "high" } + }, + "comment": null +} +``` + +**The PATCH response is the authoritative post-write state. A confirming GET after a 2xx PATCH is unnecessary.** + ## Checkout (Claim Task) ``` diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 521616c7bd..8b59a27db6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -818,6 +818,8 @@ export type { ExternalObjectSummaryItem, CompactIssue, Issue, + IssueChangeReceiptEntry, + IssueChanges, IssueAssigneeAdapterOverrides, IssueBlockerDiagnosticFlag, IssueBlockerDiagnosticIssueSummary, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 34a8bdf50e..5602149739 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -542,6 +542,8 @@ export type { export type { CompactIssue, Issue, + IssueChangeReceiptEntry, + IssueChanges, IssueWorkMode, IssueAssigneeAdapterOverrides, IssueBlockerDiagnosticFlag, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 44867a219f..343d93f577 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -711,6 +711,14 @@ export interface IssueWatchdog extends IssueWatchdogSummary { updatedByRunId: string | null; } +export interface IssueChangeReceiptEntry { + from: unknown; + to: unknown; + updated?: true; +} + +export type IssueChanges = Record; + export interface Issue { id: string; companyId: string; diff --git a/server/src/__tests__/issue-activity-events-routes.test.ts b/server/src/__tests__/issue-activity-events-routes.test.ts index 550acc57c4..4560fb43e0 100644 --- a/server/src/__tests__/issue-activity-events-routes.test.ts +++ b/server/src/__tests__/issue-activity-events-routes.test.ts @@ -2,6 +2,7 @@ import express from "express"; import request from "supertest"; import { getTableName } from "drizzle-orm"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { buildIssueChanges } from "../services/issue-change-receipt.ts"; import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts"; const mockIssueService = vi.hoisted(() => ({ @@ -155,8 +156,29 @@ function makeIssue() { createdByUserId: "local-board", identifier: "PAP-580", title: "Activity event issue", + description: null, + priority: "medium", executionPolicy: null, executionState: null, + updatedAt: new Date("2026-07-30T12:00:00.000Z"), + }; +} + +function issueUpdateWithReceipt(issue: ReturnType, patch: Record) { + const { + actorAgentId: _actorAgentId, + actorUserId: _actorUserId, + blockedByIssueIds: _blockedByIssueIds, + ...issuePatch + } = patch; + const updated = { + ...issue, + ...issuePatch, + updatedAt: new Date("2026-07-30T12:01:00.000Z"), + }; + return { + ...updated, + changes: buildIssueChanges(issue, updated), }; } @@ -205,6 +227,161 @@ describe("issue activity event routes", () => { mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined); }); + it("returns a field-change receipt and omits a requested no-op field", async () => { + const issue = makeIssue(); + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => + issueUpdateWithReceipt(issue, patch)); + + const changed = await request(await createApp()) + .patch(`/api/issues/${issue.id}`) + .send({ priority: "high" }); + expect(changed.status).toBe(200); + expect(changed.body.changes).toEqual({ + priority: { from: "medium", to: "high" }, + }); + + const noOp = await request(await createApp()) + .patch(`/api/issues/${issue.id}`) + .send({ title: issue.title }); + expect(noOp.status).toBe(200); + expect(noOp.body.changes).not.toHaveProperty("title"); + }); + + it("echoes scalar blocker state and summaries when setting and clearing blockers", async () => { + const issue = makeIssue(); + const blockerId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + let blockedByIssueIds: string[] = []; + const relationSummaries = () => ({ + blockedBy: blockedByIssueIds.map((id) => ({ + id, + identifier: "PAP-10", + title: "Blocker", + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + })), + blocks: [], + }); + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.getRelationSummaries.mockImplementation(async () => relationSummaries()); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => { + const from = blockedByIssueIds; + blockedByIssueIds = [...new Set(patch.blockedByIssueIds as string[])].sort(); + return { + ...issueUpdateWithReceipt(issue, patch), + blockedByIssueIds, + changes: buildIssueChanges(issue, issue, { + blockedByIssueIds: { from, to: blockedByIssueIds }, + }), + }; + }); + + const setResponse = await request(await createApp()) + .patch(`/api/issues/${issue.id}`) + .send({ blockedByIssueIds: [blockerId] }); + expect(setResponse.status).toBe(200); + expect(setResponse.body).toMatchObject({ + blockedByIssueIds: [blockerId], + blockedBy: [{ id: blockerId }], + blocks: [], + changes: { blockedByIssueIds: { from: [], to: [blockerId] } }, + }); + + const clearResponse = await request(await createApp()) + .patch(`/api/issues/${issue.id}`) + .send({ blockedByIssueIds: [] }); + expect(clearResponse.status).toBe(200); + expect(clearResponse.body).toMatchObject({ + blockedByIssueIds: [], + blockedBy: [], + blocks: [], + changes: { blockedByIssueIds: { from: [blockerId], to: [] } }, + }); + }); + + it("truncates long text receipt values to 200 characters and marks them updated", async () => { + const issue = { + ...makeIssue(), + title: "a".repeat(240), + description: "b".repeat(240), + }; + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => + issueUpdateWithReceipt(issue, patch)); + + const response = await request(await createApp()) + .patch(`/api/issues/${issue.id}`) + .send({ title: "c".repeat(240), description: "d".repeat(240) }); + + expect(response.status).toBe(200); + expect(response.body.changes).toEqual({ + title: { from: "a".repeat(200), to: "c".repeat(200), updated: true }, + description: { from: "b".repeat(200), to: "d".repeat(200), updated: true }, + }); + }); + + it("returns only the minimal receipt fields when requested", async () => { + const issue = makeIssue(); + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => + issueUpdateWithReceipt(issue, patch)); + + const response = await request(await createApp()) + .patch(`/api/issues/${issue.id}`) + .set("Prefer", "respond-async, return=minimal") + .send({ priority: "high" }); + + expect(response.status).toBe(200); + expect(response.headers["preference-applied"]).toBe("return=minimal"); + expect(response.body).toEqual({ + id: issue.id, + identifier: issue.identifier, + updatedAt: "2026-07-30T12:01:00.000Z", + changes: { priority: { from: "medium", to: "high" } }, + comment: null, + }); + }); + + it("preserves the default full response body with additive receipt fields", async () => { + const issue = makeIssue(); + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => + issueUpdateWithReceipt(issue, patch)); + + const response = await request(await createApp()) + .patch(`/api/issues/${issue.id}`) + .send({ priority: "high" }); + + expect(response.status).toBe(200); + expect(response.headers["preference-applied"]).toBeUndefined(); + expect(response.body).toMatchInlineSnapshot(` + { + "assigneeAgentId": "22222222-2222-4222-8222-222222222222", + "assigneeUserId": null, + "changes": { + "priority": { + "from": "medium", + "to": "high", + }, + }, + "comment": null, + "companyId": "company-1", + "createdByUserId": "local-board", + "description": null, + "executionPolicy": null, + "executionState": null, + "id": "11111111-1111-4111-8111-111111111111", + "identifier": "PAP-580", + "priority": "high", + "status": "todo", + "title": "Activity event issue", + "updatedAt": "2026-07-30T12:01:00.000Z", + } + `); + }); + it("logs blocker activity with added and removed issue summaries", async () => { const issue = makeIssue(); mockIssueService.getById.mockResolvedValue(issue); diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index ffa2879e34..e3069cdb6a 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -3516,6 +3516,68 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", }; } + it("returns authoritative update receipts for row fields and blocker relations", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const blockerId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(issues).values([ + { + id: issueId, + companyId, + title: "Receipt issue", + description: "old description", + status: "todo", + priority: "medium", + }, + { + id: blockerId, + companyId, + title: "Blocker", + status: "todo", + priority: "high", + }, + ]); + + const fieldUpdate = await svc.update(issueId, { + title: "Receipt issue", + priority: "high", + description: "new description", + }); + expect(fieldUpdate?.changes).toEqual({ + priority: { from: "medium", to: "high" }, + description: { from: "old description", to: "new description", updated: true }, + }); + + const blockersSet = await svc.update(issueId, { blockedByIssueIds: [blockerId, blockerId] }); + expect(blockersSet?.blockedByIssueIds).toEqual([blockerId]); + expect(blockersSet?.changes.blockedByIssueIds).toEqual({ from: [], to: [blockerId] }); + + const blockersCleared = await svc.update(issueId, { blockedByIssueIds: [] }); + expect(blockersCleared?.blockedByIssueIds).toEqual([]); + expect(blockersCleared?.changes.blockedByIssueIds).toEqual({ from: [blockerId], to: [] }); + + await db.update(issues).set({ + title: "Concurrent receipt issue", + priority: "medium", + }).where(eq(issues.id, issueId)); + const [titleUpdate, priorityUpdate] = await Promise.all([ + svc.update(issueId, { title: "Concurrent title" }), + svc.update(issueId, { priority: "high" }), + ]); + expect(titleUpdate?.changes).toEqual({ + title: { from: "Concurrent receipt issue", to: "Concurrent title" }, + }); + expect(priorityUpdate?.changes).toEqual({ + priority: { from: "medium", to: "high" }, + }); + }); + it("persists blocked-by relations and exposes both blockedBy and blocks summaries", async () => { const companyId = randomUUID(); await db.insert(companies).values({ diff --git a/server/src/__tests__/paperclip-skill-utils.test.ts b/server/src/__tests__/paperclip-skill-utils.test.ts index 420d8fddc6..898866dd38 100644 --- a/server/src/__tests__/paperclip-skill-utils.test.ts +++ b/server/src/__tests__/paperclip-skill-utils.test.ts @@ -64,6 +64,16 @@ describe("paperclip skill utils", () => { await expect(fs.access(path.resolve("scripts/paperclip-upload-artifact.sh"))).rejects.toThrow(); }); + it("uses the authoritative PATCH response to confirm monitor scheduling", async () => { + const skillBody = await fs.readFile(path.resolve("skills/paperclip/SKILL.md"), "utf8"); + + expect(skillBody).toContain("Use that request's default full response"); + expect(skillBody).toContain("do not issue a confirming GET"); + expect(skillBody).toContain("`monitorNextCheckAt` is non-null"); + expect(skillBody).toContain("`assigneeAgentId` is set"); + expect(skillBody).toContain("`assigneeUserId` is null"); + }); + it("keeps the create-issue-interaction-ui guide as a maintainer-only skill", async () => { const skillPath = path.resolve(".agents/skills/create-issue-interaction-ui/SKILL.md"); const skillBody = await fs.readFile(skillPath, "utf8"); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index f414934ec7..1bb7c5379a 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -204,6 +204,13 @@ const MAX_ISSUE_COMMENT_LIMIT = 500; const updateIssueRouteSchema = updateIssueSchema.extend({ interrupt: z.boolean().optional(), }); + +function prefersMinimalIssueUpdateResponse(req: Request) { + return (req.get("Prefer") ?? "") + .split(",") + .some((preference) => preference.trim().toLowerCase() === "return=minimal"); +} + const refreshExternalObjectsSchema = z.object({ objectIds: z.array(z.string().uuid()).max(50).optional(), }).strict(); @@ -8296,6 +8303,8 @@ export function issueRoutes( updatedRelations = await svc.getRelationSummaries(issue.id); issueResponse = { ...issue, + blockedByIssueIds: + issue.blockedByIssueIds ?? [...new Set(req.body.blockedByIssueIds as string[])].sort(), blockedBy: updatedRelations.blockedBy, blocks: updatedRelations.blocks, }; @@ -9055,7 +9064,19 @@ export function issueRoutes( })(); await queueTaskWatchdogEvaluation(issue, actor.runId); - res.json({ ...issueResponse, comment }); + const changes = issueResponse.changes ?? {}; + if (prefersMinimalIssueUpdateResponse(req)) { + res.setHeader("Preference-Applied", "return=minimal"); + res.json({ + id: issueResponse.id, + identifier: issueResponse.identifier, + updatedAt: issueResponse.updatedAt, + changes, + comment, + }); + return; + } + res.json({ ...issueResponse, changes, comment }); }); router.delete("/issues/:id", async (req, res) => { diff --git a/server/src/services/issue-change-receipt.ts b/server/src/services/issue-change-receipt.ts new file mode 100644 index 0000000000..a69a6165e7 --- /dev/null +++ b/server/src/services/issue-change-receipt.ts @@ -0,0 +1,50 @@ +import { isDeepStrictEqual } from "node:util"; +import type { IssueChanges } from "@paperclipai/shared"; + +const ISSUE_CHANGE_TEXT_BUDGET = 200; + +function truncateIssueChangeText(value: unknown) { + if (typeof value !== "string") return value; + return Array.from(value).slice(0, ISSUE_CHANGE_TEXT_BUDGET).join(""); +} + +function canonicalIdArray(value: unknown): unknown { + if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) return value; + return [...new Set(value)].sort(); +} + +export function buildIssueChanges( + existing: Record, + updated: Record, + relationChanges: { + blockedByIssueIds?: { from: string[]; to: string[] }; + labelIds?: { from: string[]; to: string[] }; + } = {}, +): IssueChanges { + const changes: IssueChanges = {}; + const keys = new Set([...Object.keys(existing), ...Object.keys(updated)]); + keys.delete("updatedAt"); + + for (const key of keys) { + const from = existing[key]; + const to = updated[key]; + if (isDeepStrictEqual(from, to)) continue; + + const longText = + key === "description" || + (key === "title" && + ((typeof from === "string" && Array.from(from).length > ISSUE_CHANGE_TEXT_BUDGET) || + (typeof to === "string" && Array.from(to).length > ISSUE_CHANGE_TEXT_BUDGET))); + changes[key] = longText + ? { from: truncateIssueChangeText(from), to: truncateIssueChangeText(to), updated: true } + : { from, to }; + } + + for (const [key, change] of Object.entries(relationChanges)) { + const from = canonicalIdArray(change.from); + const to = canonicalIdArray(change.to); + if (!isDeepStrictEqual(from, to)) changes[key] = { from, to }; + } + + return changes; +} diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 760dda43ff..4acf9bec04 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -116,6 +116,7 @@ import { visibleIssueCondition } from "./issue-visibility.js"; import { finalizeStatusCardsForStalledGeneration } from "./status-card-finalization.js"; import { finalizeSummarySlotsForTerminalIssue } from "./summary-slot-finalization.js"; import { logActivity } from "./activity-log.js"; +import { buildIssueChanges } from "./issue-change-receipt.js"; const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"]; const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500; @@ -7063,6 +7064,24 @@ export function issueService(db: Db) { } const runUpdate = async (tx: any) => { + // The receipt baseline must be read under the same row lock as the + // write. Otherwise a concurrent update can be mistaken for a change + // made by this request. + const receiptExisting = await tx + .select() + .from(issues) + .where(eq(issues.id, id)) + .for("update") + .then((rows: Array) => rows[0] ?? null); + if (!receiptExisting) return null; + const [previousLabelsByIssueId, previousRelationSummaries] = await Promise.all([ + nextLabelIds !== undefined + ? labelMapForIssues(tx, [id]) + : Promise.resolve(new Map()), + blockedByIssueIds !== undefined + ? getIssueRelationSummaryMap(existing.companyId, [id], tx) + : Promise.resolve(new Map()), + ]); const defaultCompanyGoal = await getDefaultCompanyGoal(tx, existing.companyId); const [currentProjectGoalId, nextProjectGoalId] = await Promise.all([ getProjectDefaultGoalId(tx, existing.companyId, existing.projectId), @@ -7180,6 +7199,31 @@ export function issueService(db: Db) { } } const [enriched] = await withIssueLabels(tx, [updated]); + const nextBlockedByIssueIds = blockedByIssueIds === undefined + ? undefined + : [...new Set(blockedByIssueIds)].sort(); + const changes = buildIssueChanges( + receiptExisting as unknown as Record, + updated as unknown as Record, + { + ...(nextLabelIds !== undefined + ? { + labelIds: { + from: (previousLabelsByIssueId.get(id) ?? []).map((label) => label.id), + to: enriched.labelIds, + }, + } + : {}), + ...(nextBlockedByIssueIds !== undefined + ? { + blockedByIssueIds: { + from: (previousRelationSummaries.get(id)?.blockedBy ?? []).map((relation) => relation.id), + to: nextBlockedByIssueIds, + }, + } + : {}), + }, + ); if ( (issueData.status === "done" || issueData.status === "cancelled") && existing.status !== issueData.status && @@ -7199,7 +7243,11 @@ export function issueService(db: Db) { ); } } - return enriched; + return { + ...enriched, + ...(nextBlockedByIssueIds !== undefined ? { blockedByIssueIds: nextBlockedByIssueIds } : {}), + changes, + }; }; return dbOrTx === db ? db.transaction(runUpdate) : runUpdate(dbOrTx); diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 50aedf42ce..bab984ae8a 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -157,7 +157,7 @@ A "watcher" or "monitor" is not something that lives inside a run. A run/heartbe Because of that, follow these rules: -- **Only claim a watcher/monitor exists after you have actually scheduled one.** Describing a watcher in a comment does not create it. Schedule it by setting `executionPolicy.monitor.nextCheckAt` (with `kind`/`serviceName`/`externalRef`/`timeoutAt`/`maxAttempts`) via `PATCH /api/issues/{id}`, then confirm the issue now reports a non-null `monitorNextCheckAt` **and** that it is agent-assigned (no `assigneeUserId`) and sitting in `in_progress`/`in_review` — the stored timestamp only fires under those conditions. Run a check on demand with `POST /api/issues/{id}/monitor/check-now`. +- **Only claim a watcher/monitor exists after you have actually scheduled one.** Describing a watcher in a comment does not create it. Schedule it by setting `executionPolicy.monitor.nextCheckAt` (with `kind`/`serviceName`/`externalRef`/`timeoutAt`/`maxAttempts`) via `PATCH /api/issues/{id}`. Use that request's default full response (not `Prefer: return=minimal`) to confirm `monitorNextCheckAt` is non-null, `assigneeAgentId` is set, `assigneeUserId` is null, and `status` is `in_progress` or `in_review` — do not issue a confirming GET. The stored timestamp only fires under those conditions. Run a check on demand with `POST /api/issues/{id}/monitor/check-now`. - **Describe it in checkable terms.** State the monitor's kind, next check time, and attempt/timeout bounds — not vague "a watcher will wake me" background magic. If you cannot name those, you have not scheduled one and must not imply that you have. - **Never imply a live watcher on a task you are marking `done`.** `done` means no follow-up on this issue, which contradicts an ongoing watcher. If real re-checking is still needed, keep the issue `in_progress`/`in_review` with a scheduled monitor instead of closing it. - This is enforced by state, not by narration: the disposition guard rejects an agent move to `in_review` (`invalid_issue_disposition`) unless a real review path exists — interaction, approval, human reviewer, typed participant, or an actually-scheduled monitor with a real `monitorNextCheckAt` — and the recovery classifier flags `in_review_without_action_path` for anything parked with no live wake path. Keep your comments consistent with that real state. diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index a21a3eae6a..6582aa6cea 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -191,6 +191,48 @@ The response also includes `blockedBy` and `blocks` arrays showing first-class d Blocker wake semantics are strict: `issue_blockers_resolved` only fires when every blocker reaches `done`. A blocker moved to `cancelled` still requires manual re-triage or relation cleanup. +### Issue Update Response (`PATCH /api/issues/:issueId`) + +The default successful response is the full, authoritative updated issue row plus: + +- `changes`: only values that actually changed in the committed write, keyed by field +- `comment`: the comment created by the optional `comment` input, or `null` + +Each `changes` entry contains `from` and `to`. Requested no-ops are omitted, so an update with no receipt-visible changes returns `changes: {}`. Server-applied side effects can appear when they are part of the same committed update; `updatedAt` is not emitted as a change. + +```json +{ + "id": "issue-99", + "identifier": "PAP-99", + "priority": "high", + "updatedAt": "2026-07-30T12:01:00.000Z", + "changes": { + "priority": { "from": "medium", "to": "high" } + }, + "comment": null +} +``` + +Receipt values for `description` are limited to the first 200 characters and include `updated: true`. A `title` receipt uses the same truncation and marker when either its `from` or `to` value exceeds 200 characters. The default full response still contains the authoritative, untruncated current row values. + +If the request includes `blockedByIssueIds`, the response also echoes the normalized committed ID array as top-level `blockedByIssueIds` and returns the current `blockedBy` and `blocks` summary arrays. Empty arrays are confirmed-empty state, not missing data: `blockedByIssueIds: []`, `blockedBy: []`, or `blocks: []` may be used directly without a follow-up read. + +Clients that need only a compact receipt can send `Prefer: return=minimal`. The response includes `Preference-Applied: return=minimal` and exactly this shape: + +```json +{ + "id": "issue-99", + "identifier": "PAP-99", + "updatedAt": "2026-07-30T12:01:00.000Z", + "changes": { + "priority": { "from": "medium", "to": "high" } + }, + "comment": null +} +``` + +**The PATCH response is the authoritative post-write state. A confirming GET after a 2xx PATCH is unnecessary.** + ### Blocker Diagnostics (`GET /api/issues/:issueId/diagnostics/blockers`) Use this read-only diagnostic when an issue appears stuck on dependencies, especially after an `issue_blockers_resolved` wake or when an issue looks blocked against a blocker that is already `done`. @@ -1199,7 +1241,7 @@ Terminal states: `done`, `cancelled` | GET | `/api/issues/:issueId/diagnostics/wakes` | Read-only wake-history diagnostic with `diagnosis`, bounded events, and Case-B inference | | GET | `/api/issues/:issueId/diagnostics/subtree` | Read-only subtree diagnostic combining visible child, blocker, and wake edges with `diagnosis` | | POST | `/api/companies/:companyId/issues` | Create issue (supports `blockedByIssueIds: string[]` for dependencies) | -| PATCH | `/api/issues/:issueId` | Update issue (optional `comment` field; `blockedByIssueIds` replaces blocker set) | +| PATCH | `/api/issues/:issueId` | Update issue; response is authoritative and includes `changes` + `comment` (`Prefer: return=minimal` supported); `blockedByIssueIds` replaces blocker set | | POST | `/api/issues/:issueId/checkout` | Atomic checkout (claim + start). Idempotent if you already own it. | | POST | `/api/issues/:issueId/release` | Release task ownership | | GET | `/api/issues/:issueId/comments` | List comments | diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 73e6798b89..8ac720470d 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -9,6 +9,7 @@ import type { FeedbackTrace, FeedbackVote, Issue, + IssueChanges, IssueAttachment, IssueCostSummary, IssueComment, @@ -30,6 +31,8 @@ import { api, type RequestOptions } from "./client"; export type IssueUpdateResponse = Issue & { comment?: IssueComment | null; + changes: IssueChanges; + blockedByIssueIds?: string[]; }; export type ResolveRecoveryActionResponse = { diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 1e7c0507a8..fdea401769 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -2134,7 +2134,7 @@ export function IssueDetail() { return { previousDetailQueries, previousList, selectedCompanyId }; }, - onSuccess: ({ comment: _comment, ...nextIssue }) => { + onSuccess: ({ comment: _comment, changes: _changes, blockedByIssueIds: _blockedByIssueIds, ...nextIssue }) => { const issueRefs = new Set([issueId!, nextIssue.id]); if (nextIssue.identifier) issueRefs.add(nextIssue.identifier); mergeIssueResponseIntoCaches(issueRefs, nextIssue);