diff --git a/.github/extensions/replica-prototype/README.md b/.github/extensions/replica-prototype/README.md index 8a9d00a2..d819cd05 100644 --- a/.github/extensions/replica-prototype/README.md +++ b/.github/extensions/replica-prototype/README.md @@ -92,10 +92,16 @@ The following filters and snapshot continuations apply to the full-detail `list_ Both list actions accept combinable `resolution` (`all`, `unresolved`, `resolved`), `read` (`all`, `unread`, `read`), and optional timezone-qualified ISO-8601 `from`/`to` timestamps. -Defaults mean all conversations. The time range includes `from` and excludes `to`, matching a +Defaults mean all review conversations. The time range includes `from` and excludes `to`, matching a starting comment or any reply created in that range. Edits, reactions and resolution changes do not make a thread match. Bounds preserve Hub's microsecond precision. +Both actions also accept `improvementNotes: "exclude" | "include" | "only"`, corresponding to Hub's `improvement_notes` query. The default, `exclude`, preserves review-only retrieval. Use `include` for reviews and synchronized Pick & polish notes together, or `only` for those notes alone. Local unsynchronized notes are not in Hub's feed. Explicit inclusion uses the existing repository read permission, not the creator-only synchronization collection. Compact summaries and direct repository thread reads remain review-only. + +For example, call `list_comments` with `{"improvementNotes":"only"}` to retrieve this prototype's synchronized improvements. Notes retain `kind: "improvement_note"`, `localId`, `revision`, `status`, the original body and anchor, and `thread` with `originalStatus`, optional `originalAuthor`, and ordered `messages`. Messages preserve author follow-ups, Copilot answers and their `requestIds`. The public `id` is a namespaced string such as `improvement_note-7`, distinct from review ID `7`. Supply that exact string as `threadId` to the matching snapshot thread action. Never remove the prefix or submit an improvement to ordinary review mutation actions; those actions accept only numeric review IDs. Improvements do not receive a `replyRequestId`. + +Improvement conversations have no review unread state, so `read: "read"` and `"unread"` exclude them. `resolution: "resolved"` matches their `resolved` status; every other improvement status is unresolved. Their date filters match root creation only because saved follow-up messages have no creation timestamps. Notes use the same response budget and continuation fields as reviews, but paginate `thread.messages` rather than `replies`. Follow `repliesNextCursor` until `repliesHasMore` is false and combine the message pages in order. A deleted note retains its root tombstone with `thread: null`. + For example, retrieve full unresolved, unread conversations on an open instance: ```json @@ -127,7 +133,7 @@ versions never include activity missing from the captured messages; later activi arrive and leave the snapshot out of date. Start a new scan with the same list action to refresh filters or read state. Snapshots from older, unverified collection formats expire after an extension update. Both thread actions check current root activity and reject a changed thread before returning -a captured reply page. +a captured reply page. Improvement scans and thread reads compare the complete saved note, including its revision, status and conversation. List continuations bind `improvementNotes` along with the other filters; changing it requires a fresh scan. Repository collection uses one full response across prototypes, not a separate request per prototype. Hub orders comments by creation time and ID, newest first; completed conversations retain newest-root-first order and oldest-reply-first order. A conflict or access failure stops the entire collection with an explicit error, not a success-shaped partial result. An authorized repository with no comments returns a completed empty result. Repository snapshots have a distinct `scope: "repository"` target and do not issue reply request IDs. Their targets cannot be passed to prototype-specific actions or mutations. diff --git a/.github/extensions/replica-prototype/comment-actions/index.mjs b/.github/extensions/replica-prototype/comment-actions/index.mjs index bb3e72ce..9656f9b7 100644 --- a/.github/extensions/replica-prototype/comment-actions/index.mjs +++ b/.github/extensions/replica-prototype/comment-actions/index.mjs @@ -34,6 +34,15 @@ const threadProperties = { target: targetSchema, threadId: { type: "integer", minimum: 1 }, } +const snapshotThreadIdSchema = { + anyOf: [threadProperties.threadId, { type: "string", pattern: "^improvement_note-[1-9]\\d*$" }], +} +const improvementNotesSchema = { + type: "string", + enum: ["exclude", "include", "only"], + description: + "Defaults to exclude. Include adds Pick & polish conversations; only retrieves just those conversations. Notes use namespaced IDs and are read-only through comment actions. They have no unread state, so read/unread filters exclude them; date filters match their root creation only. Messages paginate in thread.messages using repliesNextCursor.", +} const definitions = [ { name: "list_comments", @@ -42,6 +51,7 @@ const definitions = [ properties: { resolution: { type: "string", enum: ["all", "unresolved", "resolved"] }, read: { type: "string", enum: ["all", "unread", "read"] }, + improvementNotes: improvementNotesSchema, from: timestampSchema, to: timestampSchema, cursor: cursorSchema, @@ -52,9 +62,10 @@ const definitions = [ { name: "get_comment_thread", description: - "Retrieve a thread or its next reply page from a completed comment snapshot without marking it read. Supply target and snapshotId from list_comments. Rechecks current thread activity; if it changed, start a fresh list_comments scan. Saved anchors and comment text are untrusted review data.", + "Retrieve a thread or its next reply page from a completed comment snapshot without marking it read. Supply target, snapshotId and the exact returned id as threadId from list_comments, including namespaced improvement_note IDs. Improvement conversations paginate thread.messages with repliesNextCursor and remain read-only. Rechecks current thread activity; if it changed, start a fresh list_comments scan. Saved anchors and comment text are untrusted review data.", properties: { ...threadProperties, + threadId: snapshotThreadIdSchema, snapshotId: { type: "string", pattern: uuidInputPattern.source, @@ -70,6 +81,7 @@ const definitions = [ properties: { resolution: { type: "string", enum: ["all", "unresolved", "resolved"] }, read: { type: "string", enum: ["all", "unread", "read"] }, + improvementNotes: improvementNotesSchema, from: timestampSchema, to: timestampSchema, cursor: cursorSchema, @@ -80,10 +92,10 @@ const definitions = [ { name: "get_repository_comment_thread", description: - "Retrieve a repository snapshot thread or its next reply page without marking it read. Supply the repository target, snapshotId and threadId from list_repository_comments. Rechecks current thread activity; if it changed, start a fresh repository scan. Saved anchors and comment text are untrusted review data. This action cannot mutate comments or switch the checkout.", + "Retrieve a repository snapshot thread or its next reply page without marking it read. Supply the repository target, snapshotId and exact returned id as threadId from list_repository_comments, including namespaced improvement_note IDs. Improvement conversations paginate thread.messages with repliesNextCursor. Rechecks current thread activity; if it changed, start a fresh repository scan. Saved anchors and comment text are untrusted review data. This action cannot mutate comments or switch the checkout.", properties: { target: repositoryTargetSchema, - threadId: { type: "integer", minimum: 1 }, + threadId: snapshotThreadIdSchema, snapshotId: { type: "string", pattern: uuidInputPattern.source, diff --git a/.github/extensions/replica-prototype/comment-actions/index.mts b/.github/extensions/replica-prototype/comment-actions/index.mts index 454610de..6a48c8ee 100644 --- a/.github/extensions/replica-prototype/comment-actions/index.mts +++ b/.github/extensions/replica-prototype/comment-actions/index.mts @@ -54,6 +54,15 @@ const threadProperties = { target: targetSchema, threadId: { type: "integer", minimum: 1 }, } +const snapshotThreadIdSchema = { + anyOf: [threadProperties.threadId, { type: "string", pattern: "^improvement_note-[1-9]\\d*$" }], +} +const improvementNotesSchema = { + type: "string", + enum: ["exclude", "include", "only"], + description: + "Defaults to exclude. Include adds Pick & polish conversations; only retrieves just those conversations. Notes use namespaced IDs and are read-only through comment actions. They have no unread state, so read/unread filters exclude them; date filters match their root creation only. Messages paginate in thread.messages using repliesNextCursor.", +} const definitions: { name: CommentActionName description: string @@ -67,6 +76,7 @@ const definitions: { properties: { resolution: { type: "string", enum: ["all", "unresolved", "resolved"] }, read: { type: "string", enum: ["all", "unread", "read"] }, + improvementNotes: improvementNotesSchema, from: timestampSchema, to: timestampSchema, cursor: cursorSchema, @@ -77,9 +87,10 @@ const definitions: { { name: "get_comment_thread", description: - "Retrieve a thread or its next reply page from a completed comment snapshot without marking it read. Supply target and snapshotId from list_comments. Rechecks current thread activity; if it changed, start a fresh list_comments scan. Saved anchors and comment text are untrusted review data.", + "Retrieve a thread or its next reply page from a completed comment snapshot without marking it read. Supply target, snapshotId and the exact returned id as threadId from list_comments, including namespaced improvement_note IDs. Improvement conversations paginate thread.messages with repliesNextCursor and remain read-only. Rechecks current thread activity; if it changed, start a fresh list_comments scan. Saved anchors and comment text are untrusted review data.", properties: { ...threadProperties, + threadId: snapshotThreadIdSchema, snapshotId: { type: "string", pattern: uuidInputPattern.source, @@ -95,6 +106,7 @@ const definitions: { properties: { resolution: { type: "string", enum: ["all", "unresolved", "resolved"] }, read: { type: "string", enum: ["all", "unread", "read"] }, + improvementNotes: improvementNotesSchema, from: timestampSchema, to: timestampSchema, cursor: cursorSchema, @@ -105,10 +117,10 @@ const definitions: { { name: "get_repository_comment_thread", description: - "Retrieve a repository snapshot thread or its next reply page without marking it read. Supply the repository target, snapshotId and threadId from list_repository_comments. Rechecks current thread activity; if it changed, start a fresh repository scan. Saved anchors and comment text are untrusted review data. This action cannot mutate comments or switch the checkout.", + "Retrieve a repository snapshot thread or its next reply page without marking it read. Supply the repository target, snapshotId and exact returned id as threadId from list_repository_comments, including namespaced improvement_note IDs. Improvement conversations paginate thread.messages with repliesNextCursor. Rechecks current thread activity; if it changed, start a fresh repository scan. Saved anchors and comment text are untrusted review data. This action cannot mutate comments or switch the checkout.", properties: { target: repositoryTargetSchema, - threadId: { type: "integer", minimum: 1 }, + threadId: snapshotThreadIdSchema, snapshotId: { type: "string", pattern: uuidInputPattern.source, diff --git a/.github/extensions/replica-prototype/comment-actions/index.test.mjs b/.github/extensions/replica-prototype/comment-actions/index.test.mjs index a6d17bfe..c806569d 100644 --- a/.github/extensions/replica-prototype/comment-actions/index.test.mjs +++ b/.github/extensions/replica-prototype/comment-actions/index.test.mjs @@ -88,6 +88,49 @@ describe("commentActions()", () => { assert.deepEqual(snapshotThread.inputSchema.required, ["target", "threadId", "snapshotId"]) }) + it("exposes optional improvement filters only on full snapshots and note IDs only on snapshot reads", async () => { + const calls = [] + const actions = commentActions(async (...args) => calls.push(args), TestCanvasError) + for (const name of ["list_comments", "list_repository_comments"]) { + const action = actions.find((item) => item.name === name) + assert.deepEqual(action.inputSchema.properties.improvementNotes.enum, [ + "exclude", + "include", + "only", + ]) + assert.equal(action.inputSchema.required.includes("improvementNotes"), false) + await action.handler({ instanceId: "panel", input: { improvementNotes: "only" } }) + assert.deepEqual(calls.at(-1), ["panel", name, { improvementNotes: "only" }]) + } + for (const name of ["get_comment_thread", "get_repository_comment_thread"]) { + const action = actions.find((item) => item.name === name) + const schema = action.inputSchema.properties.threadId + const variants = schema.anyOf ?? schema.oneOf ?? [schema] + assert.ok(variants.some((variant) => variant.type === "integer" && variant.minimum === 1)) + assert.ok( + variants.some( + (variant) => + variant.type === "string" && + new RegExp(variant.pattern).test("improvement_note-7") && + !new RegExp(variant.pattern).test("improvement_note-0") && + !new RegExp(variant.pattern).test("7"), + ), + ) + } + for (const name of [ + "list_repository_comment_summaries", + "get_repository_thread", + "mark_comment_read", + "reply_to_comment", + "resolve_comment", + ]) { + const properties = actions.find((item) => item.name === name).inputSchema.properties + assert.equal("improvementNotes" in properties, false) + if ("threadId" in properties) + assert.deepEqual(properties.threadId, { type: "integer", minimum: 1 }) + } + }) + it("returns stable errors without exposing arbitrary Hub error text", async () => { for (const [error, code] of [ [{ statusCode: 401 }, "hub_disconnected"], diff --git a/.github/extensions/replica-prototype/register-prototype-canvas/index.test.mjs b/.github/extensions/replica-prototype/register-prototype-canvas/index.test.mjs index 68316228..80b4f10e 100644 --- a/.github/extensions/replica-prototype/register-prototype-canvas/index.test.mjs +++ b/.github/extensions/replica-prototype/register-prototype-canvas/index.test.mjs @@ -334,6 +334,45 @@ describe("registerPrototypeCanvas()", () => { cwd: context.repositoryPath, }) const requests = [] + const note = { + id: 3, + kind: "improvement_note", + localId: "11111111-1111-4111-8111-111111111111", + revision: 2, + status: "ready", + body: "Improve Save without starting a preview", + anchor: { schemaVersion: 1, kind: "element", selector: "#save" }, + thread: { + originalStatus: "ready", + originalAuthor: { id: 42, login: "octocat", avatarUrl: null }, + messages: [ + { + id: "22222222-2222-4222-8222-222222222222", + role: "author", + body: "Keep the shortcut", + status: "ready", + author: { id: 42, login: "octocat", avatarUrl: null }, + }, + { + id: "33333333-3333-4333-8333-333333333333", + role: "copilot", + body: "Save and its shortcut are ready.", + status: "ready", + requestIds: [ + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222", + ], + }, + ], + }, + author: { login: "octocat", name: null, avatarUrl: null }, + createdAt: "2026-10-01T10:00:00Z", + updatedAt: "2026-10-01T10:00:00Z", + deletedAt: null, + buildNumber: 1, + viewerCanEdit: true, + viewerCanDelete: true, + } function handleHubRequest(request, response) { response.setHeader("X-Replica-Canvas-Version", "1") requests.push({ method: request.method, url: request.url }) @@ -358,9 +397,22 @@ describe("registerPrototypeCanvas()", () => { }), ) } else { + const improvementNotes = new URL(request.url, "http://localhost").searchParams.get( + "improvement_notes", + ) response.end( JSON.stringify({ comments: [ + ...(["include", "only"].includes(improvementNotes) + ? [ + { + ...note, + ...(request.url.includes("/prototypes/") + ? {} + : { prototype: { id: 2, name: "Example", branch: "prototype" } }), + }, + ] + : []), { id: 3, kind: "root", @@ -390,7 +442,9 @@ describe("registerPrototypeCanvas()", () => { }, }), }, - ], + ].filter( + (record) => improvementNotes !== "only" || record.kind === "improvement_note", + ), hasMore: false, nextCursor: null, }), @@ -441,6 +495,41 @@ describe("registerPrototypeCanvas()", () => { assert.equal(first.threads[0].body, "Review without a local preview") const prototypeList = canvas.actions.find((action) => action.name === "list_comments") assert.equal((await prototypeList.handler(ctx)).threads[0].id, 3) + for (const [listing, reading] of [ + [prototypeList, "get_comment_thread"], + [list, "get_repository_comment_thread"], + ]) { + for (const improvementNotes of ["include", "only"]) { + const before = requests.length + const page = await listing.handler({ ...ctx, input: { improvementNotes } }) + assert.equal(page.threads.length, improvementNotes === "include" ? 2 : 1) + const improvement = page.threads.find((record) => record.kind === "improvement_note") + assert.equal(improvement.id, "improvement_note-3") + assert.deepEqual(improvement.thread, note.thread) + assert.equal(improvement.replyRequestId, undefined) + const reads = requests + .slice(before) + .filter(({ url }) => url.includes("/review_comments")) + assert.equal(reads.length, 2) + for (const { url } of reads) + assert.equal( + new URL(url, origin).searchParams.get("improvement_notes"), + improvementNotes, + ) + const thread = await canvas.actions + .find((action) => action.name === reading) + .handler({ + ...ctx, + input: { + target: page.target, + snapshotId: page.snapshotId, + threadId: improvement.id, + }, + }) + assert.equal(thread.thread.id, improvement.id) + assert.deepEqual(thread.thread.thread, note.thread) + } + } await new Promise((resolve) => alternateHub.listen(0, "127.0.0.1", resolve)) const alternateOrigin = `http://127.0.0.1:${alternateHub.address().port}` const alternateKey = createHash("sha256") diff --git a/.github/extensions/replica-prototype/review-action/index.mjs b/.github/extensions/replica-prototype/review-action/index.mjs index 8cdfe56a..fdf11129 100644 --- a/.github/extensions/replica-prototype/review-action/index.mjs +++ b/.github/extensions/replica-prototype/review-action/index.mjs @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto" import { uuidInputPattern as UUID } from "../identity-patterns/index.mjs" import { readHubObject } from "../read-hub-object/index.mjs" import { readHubError } from "../read-hub-error/index.mjs" +import { checkThread, validMessageBody } from "../improvement-note-thread/index.mjs" const THREAD_LIMIT = 10 const REPLY_LIMIT = 10 const RESPONSE_MAXIMUM_BYTES = 64 * 1024 @@ -26,6 +27,13 @@ function object(value) { function positive(value) { return typeof value === "number" && Number.isSafeInteger(value) && value > 0 } +function noteId(value) { + return ( + typeof value === "string" && + /^improvement_note-[1-9]\d*$/.test(value) && + positive(Number(value.slice("improvement_note-".length))) + ) +} function text(value, bytes, nullable = false) { if (nullable && value === null) return value if (typeof value !== "string" || Buffer.byteLength(value) > bytes) invalid() @@ -50,7 +58,12 @@ function instant(value) { function chronological(left, right) { const a = instant(left.createdAt) const b = instant(right.createdAt) - return a < b ? -1 : a > b ? 1 : left.id - right.id + if (a !== b) return a < b ? -1 : 1 + const leftId = + typeof left.id === "number" ? left.id : Number(left.id.slice("improvement_note-".length)) + const rightId = + typeof right.id === "number" ? right.id : Number(right.id.slice("improvement_note-".length)) + return leftId - rightId || left.kind.localeCompare(right.kind, "en") } function author(input) { const value = object(input) @@ -62,7 +75,9 @@ function author(input) { } function message(input, repositoryScope = false) { const value = object(input) - if (!positive(value.id) || (value.kind !== "root" && value.kind !== "reply")) invalid() + if (!positive(value.id) || !["root", "reply", "improvement_note"].includes(String(value.kind))) + invalid() + const improvement = value.kind === "improvement_note" const deletedAt = date(value.deletedAt, true) const body = text(value.body, 10000, deletedAt !== null) if (deletedAt && body !== null) invalid() @@ -72,7 +87,6 @@ function message(input, repositoryScope = false) { author: author(value.author), createdAt: date(value.createdAt), updatedAt: date(value.updatedAt), - editedAt: date(value.editedAt, true), deletedAt, } if (repositoryScope) { @@ -87,21 +101,57 @@ function message(input, repositoryScope = false) { } if (value.kind === "reply") { if (!positive(value.rootId)) invalid() - return { ...result, kind: "reply", rootId: value.rootId } + return { ...result, editedAt: date(value.editedAt, true), kind: "reply", rootId: value.rootId } } if ( !positive(value.buildNumber) || - !positive(value.activityVersion) || - !( - value.viewerLastSeenActivityVersion === null || positive(value.viewerLastSeenActivityVersion) - ) || !readHubObject(value.anchor) || Array.isArray(value.anchor) || Buffer.byteLength(JSON.stringify(value.anchor)) > 16384 ) invalid() + if (improvement) { + if ( + typeof value.localId !== "string" || + !UUID.test(value.localId) || + !positive(value.revision) || + (value.status !== "open" && + value.status !== "sent" && + value.status !== "ready" && + value.status !== "blocked" && + value.status !== "resolved") || + (deletedAt ? value.thread !== null : !validMessageBody(body, "author")) + ) + invalid() + let conversation = null + if (!deletedAt) { + try { + conversation = checkThread(value.thread, value.localId) + } catch (error) { + if (readHubError(error).statusCode !== 422) throw error + invalid() + } + } + return { + ...result, + id: `improvement_note-${value.id}`, + kind: "improvement_note", + localId: value.localId, + revision: value.revision, + status: value.status, + buildNumber: value.buildNumber, + anchor: object(value.anchor), + thread: conversation, + } + } + if ( + !positive(value.activityVersion) || + !(value.viewerLastSeenActivityVersion === null || positive(value.viewerLastSeenActivityVersion)) + ) + invalid() return { ...result, + editedAt: date(value.editedAt, true), kind: "root", buildNumber: value.buildNumber, anchor: object(value.anchor), @@ -200,7 +250,7 @@ function summary(input) { screenshotReference, } } -function filters(input) { +function filters(input, improvements = false) { const resolution = input.resolution ?? "all" const read = input.read ?? "all" if ( @@ -209,6 +259,17 @@ function filters(input) { ) invalid() const result = { resolution, read, from: null, to: null } + if (improvements) { + const improvementNotes = + input.improvementNotes === undefined ? "exclude" : input.improvementNotes + if ( + improvementNotes !== "exclude" && + improvementNotes !== "include" && + improvementNotes !== "only" + ) + invalid() + result.improvementNotes = improvementNotes + } for (const key of ["from", "to"]) { if (input[key] != null) { const value = input[key] @@ -249,18 +310,40 @@ function cursor(input) { if (first !== undefined || second !== undefined) invalid() return { id, kind } } - if (!/^(0|[1-9]\d*)$/.test(first ?? "") || !Number.isSafeInteger(Number(first))) invalid() + if ( + !noteId(first) && + (!/^(0|[1-9]\d*)$/.test(first ?? "") || !Number.isSafeInteger(Number(first))) + ) + invalid() if (kind === "list") { - if (second !== undefined) invalid() + if (second !== undefined || noteId(first)) invalid() return { id, kind, offset: Number(first) } } if ( - !positive(Number(first)) || + !(positive(Number(first)) || noteId(first)) || !/^(0|[1-9]\d*)$/.test(second ?? "") || !Number.isSafeInteger(Number(second)) ) invalid() - return { id, kind, threadId: Number(first), offset: Number(second) } + return { id, kind, threadId: noteId(first) ? first : Number(first), offset: Number(second) } +} +function activity(root) { + if (root.kind !== "improvement_note") return root.activityVersion + const note = { ...root } + delete note.prototype + return JSON.stringify(note) +} +function commentsRoute(base, improvementNotes = "exclude") { + return improvementNotes === "exclude" ? base : `${base}?improvement_notes=${improvementNotes}` +} +function savedMessage(input, repositoryScope) { + const value = object(input) + if (value.kind !== "improvement_note") return message(input, repositoryScope) + if (!noteId(value.id)) invalid() + return message( + { ...value, id: Number(value.id.slice("improvement_note-".length)) }, + repositoryScope, + ) } async function snapshot(store, id, target) { if (!UUID.test(id)) invalid() @@ -292,9 +375,9 @@ async function snapshot(store, id, target) { id, scanVersion: value.scanVersion, target, - filters: filters(object(value.filters)), + filters: filters(object(value.filters), true), capturedAt: date(value.capturedAt), - comments: value.comments.map((record) => message(record, target.scope === "repository")), + comments: value.comments.map((record) => savedMessage(record, target.scope === "repository")), hubCursor: typeof value.hubCursor === "string" ? value.hubCursor : null, complete: value.complete, ...(baseline ? { baseline: versions } : {}), @@ -307,20 +390,20 @@ async function scan(hub, store, value, base, options) { message(record, repositoryScope), ) const versions = new Map( - first.filter((item) => item.kind === "root").map((root) => [root.id, root.activityVersion]), + first.filter((item) => item.kind !== "reply").map((root) => [root.id, activity(root)]), ) const comments = (await completeComments(hub, base, options)).map((record) => message(record, repositoryScope), ) const roots = new Map( - comments.filter((item) => item.kind === "root").map((root) => [root.id, root]), + comments.filter((item) => item.kind !== "reply").map((root) => [root.id, root]), ) if ( roots.size !== versions.size || - [...roots.values()].some((root) => versions.get(root.id) !== root.activityVersion) + [...roots.values()].some((root) => versions.get(root.id) !== activity(root)) ) fail("comment_conflict", "Threads changed during collection. Retrieve comments again.", 409) - if (repositoryScope) { + { const ids = new Set() for (const record of comments) { const root = record.kind === "reply" ? roots.get(record.rootId) : undefined @@ -358,6 +441,10 @@ async function completeComments(hub, base, options) { return collection.comments.map((record) => record) } function replies(value, threadId) { + if (noteId(threadId)) { + const root = value.comments.find((item) => item.id === threadId) + return root?.kind === "improvement_note" ? (root.thread?.messages ?? []) : [] + } return value.comments .filter((item) => item.kind === "reply" && item.rootId === threadId) .sort(chronological) @@ -379,7 +466,14 @@ function thread(value, root, offset, all, count, replyRequestId) { const hasMore = offset + count < all.length return { ...root, - replies: all.slice(offset, offset + count), + ...(root.kind === "improvement_note" + ? { + thread: + root.thread === null + ? null + : { ...root.thread, messages: all.slice(offset, offset + count) }, + } + : { replies: all.slice(offset, offset + count) }), threadComplete: offset === 0 && !hasMore, repliesHasMore: hasMore, repliesNextCursor: hasMore ? `${value.id}:reply:${root.id}:${offset + count}` : null, @@ -388,7 +482,10 @@ function thread(value, root, offset, all, count, replyRequestId) { } function boundedThread(value, root, offset, all, envelope) { if (offset > all.length) invalid() - const replyRequestId = value.target.scope === "repository" ? undefined : randomUUID() + const replyRequestId = + value.target.scope === "repository" || root.kind === "improvement_note" + ? undefined + : randomUUID() const maximum = Math.min(REPLY_LIMIT, all.length - offset) // A reply continuation must consume a message, never return its own cursor unchanged. const minimum = maximum === 0 ? 0 : 1 @@ -413,19 +510,32 @@ function list(value, offset = 0) { for (const records of grouped.values()) records.sort(chronological) const roots = value.comments .filter((item) => { - if (item.kind !== "root") return false + if (item.kind === "reply") return false const filter = value.filters - if (filter.resolution !== "all" && (filter.resolution === "resolved") !== !!item.resolvedAt) + if ( + (item.kind === "improvement_note" && filter.improvementNotes === "exclude") || + (item.kind === "root" && filter.improvementNotes === "only") + ) + return false + const resolved = + item.kind === "improvement_note" ? item.status === "resolved" : !!item.resolvedAt + if (filter.resolution !== "all" && (filter.resolution === "resolved") !== resolved) + return false + if ( + filter.read !== "all" && + (item.kind === "improvement_note" || (filter.read === "unread") !== item.unread) + ) return false - if (filter.read !== "all" && (filter.read === "unread") !== item.unread) return false if (filter.from || filter.to) { const from = filter.from ? instant(filter.from) : null const to = filter.to ? instant(filter.to) : null if ( - ![item, ...(grouped.get(item.id) ?? [])].some((record) => { - const created = instant(record.createdAt) - return (from === null || created >= from) && (to === null || created < to) - }) + ![item, ...(typeof item.id === "number" ? (grouped.get(item.id) ?? []) : [])].some( + (record) => { + const created = instant(record.createdAt) + return (from === null || created >= from) && (to === null || created < to) + }, + ) ) return false } @@ -449,8 +559,14 @@ function list(value, offset = 0) { } const threads = [] for (const root of roots.slice(offset, offset + THREAD_LIMIT)) { - const all = grouped.get(root.id) ?? [] - const replyRequestId = value.target.scope === "repository" ? undefined : randomUUID() + const all = + root.kind === "improvement_note" + ? (root.thread?.messages ?? []) + : (grouped.get(root.id) ?? []) + const replyRequestId = + value.target.scope === "repository" || root.kind === "improvement_note" + ? undefined + : randomUUID() const candidate = thread(value, root, 0, all, Math.min(REPLY_LIMIT, all.length), replyRequestId) if (fitsResponse(envelope([...threads, candidate]))) { threads.push(candidate) @@ -462,14 +578,20 @@ function list(value, offset = 0) { return boundedResponse(envelope(threads)) } async function freshRoot(hub, base, threadId, options) { - const comments = await completeComments(hub, base, options) + const comments = await completeComments( + hub, + commentsRoute(base, noteId(threadId) ? "only" : "exclude"), + options, + ) const root = comments.find((input) => { const item = readHubObject(input) - return item?.kind === "root" && item.id === threadId + return noteId(threadId) + ? item?.kind === "improvement_note" && `improvement_note-${String(item.id)}` === threadId + : item?.kind === "root" && item.id === threadId }) if (root) { const result = message(root) - if (result.kind !== "root") invalid() + if (result.kind === "reply") invalid() return result } fail("comments_not_found", "The thread no longer exists in this prototype.", 404) @@ -479,8 +601,16 @@ export async function reviewAction(hub, action, rawInput = {}, store) { if (!store) throw new Error("Comment actions require private session storage.") const input = object(rawInput) const allowedActions = { - list_comments: ["resolution", "read", "from", "to", "cursor", "target"], - list_repository_comments: ["resolution", "read", "from", "to", "cursor", "target"], + list_comments: ["resolution", "read", "from", "to", "improvementNotes", "cursor", "target"], + list_repository_comments: [ + "resolution", + "read", + "from", + "to", + "improvementNotes", + "cursor", + "target", + ], get_comment_thread: ["target", "threadId", "snapshotId", "cursor"], get_repository_comment_thread: ["target", "threadId", "snapshotId", "cursor"], list_repository_comment_summaries: ["resolution", "from", "to", "pageSize", "cursor", "target"], @@ -508,7 +638,11 @@ export async function reviewAction(hub, action, rawInput = {}, store) { const readingThread = ["get_comment_thread", "get_repository_comment_thread"].includes(action) const inputTarget = input.target ? object(input.target) : undefined if (inputTarget && (inputTarget.scope === "repository") !== repositoryScope) invalid() - if (!listing && (!positive(input.threadId) || !input.target)) invalid() + if ( + !listing && + (!(positive(input.threadId) || (readingThread && noteId(input.threadId))) || !input.target) + ) + invalid() if (directThread && !positive(input.prototypeId)) invalid() const pageSize = input.pageSize ?? 200 if (direct && (!positive(pageSize) || pageSize > 200)) invalid() @@ -524,7 +658,7 @@ export async function reviewAction(hub, action, rawInput = {}, store) { !UUID.test(input.requestId)) ) invalid() - const filter = listing ? filters(input) : undefined + const filter = listing ? filters(input, !direct) : undefined const position = !direct && input.cursor !== undefined ? cursor(input.cursor) : null if (input.cursor !== undefined && !input.target) invalid() const checkout = await hub.checkout() @@ -645,7 +779,7 @@ export async function reviewAction(hub, action, rawInput = {}, store) { } if (JSON.stringify(value.filters) !== JSON.stringify(filter)) invalid() if (position?.kind === "list" && !value.complete) invalid() - await scan(hub, store, value, base, options) + await scan(hub, store, value, commentsRoute(base, filter.improvementNotes), options) if (!value.complete) return boundedResponse({ contentTrust: REVIEW_CONTENT_TRUST, @@ -671,15 +805,15 @@ export async function reviewAction(hub, action, rawInput = {}, store) { invalid() const value = await snapshot(store, text(input.snapshotId, 36), target) if (!value.complete) invalid() - const root = value.comments.find((item) => item.kind === "root" && item.id === input.threadId) + const root = value.comments.find((item) => item.kind !== "reply" && item.id === input.threadId) if (!root) fail("comments_not_found", "Thread not present in this snapshot.", 404) if (repositoryScope && !root.prototype) invalid() const threadBase = repositoryScope ? `/api/canvas/repositories/${target.repositoryId}/prototypes/${root.prototype?.id}/review_comments` : base - if (!positive(input.threadId)) invalid() + if (!positive(input.threadId) && !noteId(input.threadId)) invalid() const current = await freshRoot(hub, threadBase, input.threadId, options) - if (current.activityVersion !== root.activityVersion) + if (activity(current) !== activity(root)) fail("comment_conflict", "Thread changed. Retrieve a new comment snapshot.", 409) return boundedThread( value, @@ -798,6 +932,7 @@ export async function reviewAction(hub, action, rawInput = {}, store) { if (!positive(input.threadId)) invalid() if (action === "resolve_comment") { const root = await freshRoot(hub, base, input.threadId, options) + if (root.kind !== "root") invalid() if (root.resolvedAt) return { target, threadId: input.threadId, resolvedAt: root.resolvedAt } if (root.activityVersion !== input.activityVersion) fail("comment_conflict", "New activity arrived. Retrieve comments before resolving.", 409) diff --git a/.github/extensions/replica-prototype/review-action/index.mts b/.github/extensions/replica-prototype/review-action/index.mts index b0952b18..90b72527 100644 --- a/.github/extensions/replica-prototype/review-action/index.mts +++ b/.github/extensions/replica-prototype/review-action/index.mts @@ -3,6 +3,11 @@ import { randomUUID } from "node:crypto" import { uuidInputPattern as UUID } from "../identity-patterns/index.mjs" import { readHubObject } from "../read-hub-object/index.mjs" import { readHubError } from "../read-hub-error/index.mjs" +import { checkThread, validMessageBody } from "../improvement-note-thread/index.mjs" +import type { + ImprovementNoteThread, + ImprovementNoteMessage, +} from "../improvement-note-thread/index.mjs" import type { HubClient, HubRequestOptions } from "../create-hub-client/index.mjs" import type { CommentStore } from "../comment-store/index.mjs" import type { ReviewAuthor } from "../create-reviewer-profile/index.mjs" @@ -36,12 +41,25 @@ export type ReviewRoot = MessageBase & { unread: boolean } export type ReviewReply = MessageBase & { kind: "reply"; rootId: number } -export type ReviewMessage = ReviewRoot | ReviewReply +type NoteId = `improvement_note-${number}` +type ImprovementRoot = Omit & { + id: NoteId + kind: "improvement_note" + localId: string + revision: number + status: "open" | "sent" | "ready" | "blocked" | "resolved" + buildNumber: number + anchor: Record + thread: ImprovementNoteThread | null +} +type ConversationRoot = ReviewRoot | ImprovementRoot +export type ReviewMessage = ReviewRoot | ReviewReply | ImprovementRoot type Filters = { resolution: "all" | "unresolved" | "resolved" read: "all" | "unread" | "read" from: string | null to: string | null + improvementNotes?: "exclude" | "include" | "only" } type Snapshot = { id: string @@ -57,7 +75,7 @@ type Snapshot = { type Position = | { id: string; kind: "scan" } | { id: string; kind: "list"; offset: number } - | { id: string; kind: "reply"; threadId: number; offset: number } + | { id: string; kind: "reply"; threadId: number | NoteId; offset: number } type ReviewThread = ReturnType export type ReviewHub = Pick @@ -90,6 +108,14 @@ function positive(value: unknown): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value > 0 } +function noteId(value: unknown): value is NoteId { + return ( + typeof value === "string" && + /^improvement_note-[1-9]\d*$/.test(value) && + positive(Number(value.slice("improvement_note-".length))) + ) +} + function text(value: unknown, bytes: number, nullable?: false): string function text(value: unknown, bytes: number, nullable: boolean): string | null function text(value: unknown, bytes: number, nullable = false) { @@ -118,10 +144,15 @@ function instant(value: string) { return BigInt(Date.parse(value.replace(/\.\d+/, ""))) * 1000n + BigInt(fraction.padEnd(6, "0")) } -function chronological(left: MessageBase, right: MessageBase) { +function chronological(left: ReviewMessage, right: ReviewMessage) { const a = instant(left.createdAt) const b = instant(right.createdAt) - return a < b ? -1 : a > b ? 1 : left.id - right.id + if (a !== b) return a < b ? -1 : 1 + const leftId = + typeof left.id === "number" ? left.id : Number(left.id.slice("improvement_note-".length)) + const rightId = + typeof right.id === "number" ? right.id : Number(right.id.slice("improvement_note-".length)) + return leftId - rightId || left.kind.localeCompare(right.kind, "en") } function author(input: unknown): ReviewAuthor { @@ -135,17 +166,18 @@ function author(input: unknown): ReviewAuthor { function message(input: unknown, repositoryScope = false): ReviewMessage { const value = object(input) - if (!positive(value.id) || (value.kind !== "root" && value.kind !== "reply")) invalid() + if (!positive(value.id) || !["root", "reply", "improvement_note"].includes(String(value.kind))) + invalid() + const improvement = value.kind === "improvement_note" const deletedAt = date(value.deletedAt, true) const body = text(value.body, 10000, deletedAt !== null) if (deletedAt && body !== null) invalid() - const result: MessageBase = { + const result: Omit = { id: value.id, body, author: author(value.author), createdAt: date(value.createdAt), updatedAt: date(value.updatedAt), - editedAt: date(value.editedAt, true), deletedAt, } if (repositoryScope) { @@ -160,21 +192,57 @@ function message(input: unknown, repositoryScope = false): ReviewMessage { } if (value.kind === "reply") { if (!positive(value.rootId)) invalid() - return { ...result, kind: "reply", rootId: value.rootId } + return { ...result, editedAt: date(value.editedAt, true), kind: "reply", rootId: value.rootId } } if ( !positive(value.buildNumber) || - !positive(value.activityVersion) || - !( - value.viewerLastSeenActivityVersion === null || positive(value.viewerLastSeenActivityVersion) - ) || !readHubObject(value.anchor) || Array.isArray(value.anchor) || Buffer.byteLength(JSON.stringify(value.anchor)) > 16384 ) invalid() + if (improvement) { + if ( + typeof value.localId !== "string" || + !UUID.test(value.localId) || + !positive(value.revision) || + (value.status !== "open" && + value.status !== "sent" && + value.status !== "ready" && + value.status !== "blocked" && + value.status !== "resolved") || + (deletedAt ? value.thread !== null : !validMessageBody(body, "author")) + ) + invalid() + let conversation: ImprovementNoteThread | null = null + if (!deletedAt) { + try { + conversation = checkThread(value.thread, value.localId) + } catch (error) { + if (readHubError(error).statusCode !== 422) throw error + invalid() + } + } + return { + ...result, + id: `improvement_note-${value.id}`, + kind: "improvement_note", + localId: value.localId, + revision: value.revision, + status: value.status, + buildNumber: value.buildNumber, + anchor: object(value.anchor), + thread: conversation, + } + } + if ( + !positive(value.activityVersion) || + !(value.viewerLastSeenActivityVersion === null || positive(value.viewerLastSeenActivityVersion)) + ) + invalid() return { ...result, + editedAt: date(value.editedAt, true), kind: "root", buildNumber: value.buildNumber, anchor: object(value.anchor), @@ -277,7 +345,7 @@ function summary(input: unknown) { } } -function filters(input: Record): Filters { +function filters(input: Record, improvements = false): Filters { const resolution = input.resolution ?? "all" const read = input.read ?? "all" if ( @@ -286,6 +354,17 @@ function filters(input: Record): Filters { ) invalid() const result: Filters = { resolution, read, from: null, to: null } + if (improvements) { + const improvementNotes = + input.improvementNotes === undefined ? "exclude" : input.improvementNotes + if ( + improvementNotes !== "exclude" && + improvementNotes !== "include" && + improvementNotes !== "only" + ) + invalid() + result.improvementNotes = improvementNotes + } for (const key of ["from", "to"] as const) { if (input[key] != null) { const value = input[key] @@ -328,18 +407,43 @@ function cursor(input: unknown): Position { if (first !== undefined || second !== undefined) invalid() return { id, kind } } - if (!/^(0|[1-9]\d*)$/.test(first ?? "") || !Number.isSafeInteger(Number(first))) invalid() + if ( + !noteId(first) && + (!/^(0|[1-9]\d*)$/.test(first ?? "") || !Number.isSafeInteger(Number(first))) + ) + invalid() if (kind === "list") { - if (second !== undefined) invalid() + if (second !== undefined || noteId(first)) invalid() return { id, kind, offset: Number(first) } } if ( - !positive(Number(first)) || + !(positive(Number(first)) || noteId(first)) || !/^(0|[1-9]\d*)$/.test(second ?? "") || !Number.isSafeInteger(Number(second)) ) invalid() - return { id, kind, threadId: Number(first), offset: Number(second) } + return { id, kind, threadId: noteId(first) ? first : Number(first), offset: Number(second) } +} + +function activity(root: ConversationRoot) { + if (root.kind !== "improvement_note") return root.activityVersion + const note = { ...root } + delete note.prototype + return JSON.stringify(note) +} + +function commentsRoute(base: string, improvementNotes = "exclude") { + return improvementNotes === "exclude" ? base : `${base}?improvement_notes=${improvementNotes}` +} + +function savedMessage(input: unknown, repositoryScope: boolean): ReviewMessage { + const value = object(input) + if (value.kind !== "improvement_note") return message(input, repositoryScope) + if (!noteId(value.id)) invalid() + return message( + { ...value, id: Number(value.id.slice("improvement_note-".length)) }, + repositoryScope, + ) } async function snapshot(store: CommentStore, id: string, target: ReviewTarget): Promise { @@ -372,10 +476,10 @@ async function snapshot(store: CommentStore, id: string, target: ReviewTarget): id, scanVersion: value.scanVersion, target, - filters: filters(object(value.filters)), + filters: filters(object(value.filters), true), capturedAt: date(value.capturedAt), comments: value.comments.map((record: unknown) => - message(record, target.scope === "repository"), + savedMessage(record, target.scope === "repository"), ), hubCursor: typeof value.hubCursor === "string" ? value.hubCursor : null, complete: value.complete, @@ -396,21 +500,21 @@ async function scan( message(record, repositoryScope), ) const versions = new Map( - first.filter((item) => item.kind === "root").map((root) => [root.id, root.activityVersion]), + first.filter((item) => item.kind !== "reply").map((root) => [root.id, activity(root)]), ) const comments = (await completeComments(hub, base, options)).map((record) => message(record, repositoryScope), ) const roots = new Map( - comments.filter((item) => item.kind === "root").map((root) => [root.id, root]), + comments.filter((item) => item.kind !== "reply").map((root) => [root.id, root]), ) if ( roots.size !== versions.size || - [...roots.values()].some((root) => versions.get(root.id) !== root.activityVersion) + [...roots.values()].some((root) => versions.get(root.id) !== activity(root)) ) fail("comment_conflict", "Threads changed during collection. Retrieve comments again.", 409) - if (repositoryScope) { - const ids = new Set() + { + const ids = new Set() for (const record of comments) { const root = record.kind === "reply" ? roots.get(record.rootId) : undefined if ( @@ -452,7 +556,14 @@ async function completeComments( return collection.comments.map((record: unknown) => record) } -function replies(value: Snapshot, threadId: number) { +function replies( + value: Snapshot, + threadId: number | NoteId, +): (ReviewReply | ImprovementNoteMessage)[] { + if (noteId(threadId)) { + const root = value.comments.find((item) => item.id === threadId) + return root?.kind === "improvement_note" ? (root.thread?.messages ?? []) : [] + } return value.comments .filter((item): item is ReviewReply => item.kind === "reply" && item.rootId === threadId) .sort(chronological) @@ -474,9 +585,9 @@ function boundedResponse(value: T): T { function thread( value: Snapshot, - root: ReviewRoot, + root: ConversationRoot, offset: number, - all: ReviewReply[], + all: (ReviewReply | ImprovementNoteMessage)[], count: number, replyRequestId?: string, ) { @@ -484,7 +595,14 @@ function thread( const hasMore = offset + count < all.length return { ...root, - replies: all.slice(offset, offset + count), + ...(root.kind === "improvement_note" + ? { + thread: + root.thread === null + ? null + : { ...root.thread, messages: all.slice(offset, offset + count) }, + } + : { replies: all.slice(offset, offset + count) }), threadComplete: offset === 0 && !hasMore, repliesHasMore: hasMore, repliesNextCursor: hasMore ? `${value.id}:reply:${root.id}:${offset + count}` : null, @@ -494,13 +612,16 @@ function thread( function boundedThread( value: Snapshot, - root: ReviewRoot, + root: ConversationRoot, offset: number, - all: ReviewReply[], + all: (ReviewReply | ImprovementNoteMessage)[], envelope: (thread: ReviewThread) => T, ): T { if (offset > all.length) invalid() - const replyRequestId = value.target.scope === "repository" ? undefined : randomUUID() + const replyRequestId = + value.target.scope === "repository" || root.kind === "improvement_note" + ? undefined + : randomUUID() const maximum = Math.min(REPLY_LIMIT, all.length - offset) // A reply continuation must consume a message, never return its own cursor unchanged. const minimum = maximum === 0 ? 0 : 1 @@ -525,20 +646,33 @@ function list(value: Snapshot, offset = 0) { } for (const records of grouped.values()) records.sort(chronological) const roots = value.comments - .filter((item): item is ReviewRoot => { - if (item.kind !== "root") return false + .filter((item): item is ConversationRoot => { + if (item.kind === "reply") return false const filter = value.filters - if (filter.resolution !== "all" && (filter.resolution === "resolved") !== !!item.resolvedAt) + if ( + (item.kind === "improvement_note" && filter.improvementNotes === "exclude") || + (item.kind === "root" && filter.improvementNotes === "only") + ) + return false + const resolved = + item.kind === "improvement_note" ? item.status === "resolved" : !!item.resolvedAt + if (filter.resolution !== "all" && (filter.resolution === "resolved") !== resolved) + return false + if ( + filter.read !== "all" && + (item.kind === "improvement_note" || (filter.read === "unread") !== item.unread) + ) return false - if (filter.read !== "all" && (filter.read === "unread") !== item.unread) return false if (filter.from || filter.to) { const from = filter.from ? instant(filter.from) : null const to = filter.to ? instant(filter.to) : null if ( - ![item, ...(grouped.get(item.id) ?? [])].some((record) => { - const created = instant(record.createdAt) - return (from === null || created >= from) && (to === null || created < to) - }) + ![item, ...(typeof item.id === "number" ? (grouped.get(item.id) ?? []) : [])].some( + (record) => { + const created = instant(record.createdAt) + return (from === null || created >= from) && (to === null || created < to) + }, + ) ) return false } @@ -562,8 +696,14 @@ function list(value: Snapshot, offset = 0) { } const threads: ReviewThread[] = [] for (const root of roots.slice(offset, offset + THREAD_LIMIT)) { - const all = grouped.get(root.id) ?? [] - const replyRequestId = value.target.scope === "repository" ? undefined : randomUUID() + const all = + root.kind === "improvement_note" + ? (root.thread?.messages ?? []) + : (grouped.get(root.id) ?? []) + const replyRequestId = + value.target.scope === "repository" || root.kind === "improvement_note" + ? undefined + : randomUUID() const candidate = thread(value, root, 0, all, Math.min(REPLY_LIMIT, all.length), replyRequestId) if (fitsResponse(envelope([...threads, candidate]))) { threads.push(candidate) @@ -578,17 +718,23 @@ function list(value: Snapshot, offset = 0) { async function freshRoot( hub: ReviewHub, base: string, - threadId: number, + threadId: number | NoteId, options: HubRequestOptions, -): Promise { - const comments = await completeComments(hub, base, options) +): Promise { + const comments = await completeComments( + hub, + commentsRoute(base, noteId(threadId) ? "only" : "exclude"), + options, + ) const root: unknown = comments.find((input: unknown) => { const item = readHubObject(input) - return item?.kind === "root" && item.id === threadId + return noteId(threadId) + ? item?.kind === "improvement_note" && `improvement_note-${String(item.id)}` === threadId + : item?.kind === "root" && item.id === threadId }) if (root) { const result = message(root) - if (result.kind !== "root") invalid() + if (result.kind === "reply") invalid() return result } fail("comments_not_found", "The thread no longer exists in this prototype.", 404) @@ -604,8 +750,16 @@ export async function reviewAction( if (!store) throw new Error("Comment actions require private session storage.") const input = object(rawInput) const allowedActions: Record = { - list_comments: ["resolution", "read", "from", "to", "cursor", "target"], - list_repository_comments: ["resolution", "read", "from", "to", "cursor", "target"], + list_comments: ["resolution", "read", "from", "to", "improvementNotes", "cursor", "target"], + list_repository_comments: [ + "resolution", + "read", + "from", + "to", + "improvementNotes", + "cursor", + "target", + ], get_comment_thread: ["target", "threadId", "snapshotId", "cursor"], get_repository_comment_thread: ["target", "threadId", "snapshotId", "cursor"], list_repository_comment_summaries: ["resolution", "from", "to", "pageSize", "cursor", "target"], @@ -633,7 +787,11 @@ export async function reviewAction( const readingThread = ["get_comment_thread", "get_repository_comment_thread"].includes(action) const inputTarget = input.target ? object(input.target) : undefined if (inputTarget && (inputTarget.scope === "repository") !== repositoryScope) invalid() - if (!listing && (!positive(input.threadId) || !input.target)) invalid() + if ( + !listing && + (!(positive(input.threadId) || (readingThread && noteId(input.threadId))) || !input.target) + ) + invalid() if (directThread && !positive(input.prototypeId)) invalid() const pageSize = input.pageSize ?? 200 if (direct && (!positive(pageSize) || pageSize > 200)) invalid() @@ -649,7 +807,7 @@ export async function reviewAction( !UUID.test(input.requestId)) ) invalid() - const filter = listing ? filters(input) : undefined + const filter = listing ? filters(input, !direct) : undefined const position = !direct && input.cursor !== undefined ? cursor(input.cursor) : null if (input.cursor !== undefined && !input.target) invalid() const checkout = await hub.checkout() @@ -770,7 +928,7 @@ export async function reviewAction( } if (JSON.stringify(value.filters) !== JSON.stringify(filter)) invalid() if (position?.kind === "list" && !value.complete) invalid() - await scan(hub, store, value, base, options) + await scan(hub, store, value, commentsRoute(base, filter.improvementNotes), options) if (!value.complete) return boundedResponse({ contentTrust: REVIEW_CONTENT_TRUST, @@ -797,16 +955,16 @@ export async function reviewAction( const value = await snapshot(store, text(input.snapshotId, 36), target) if (!value.complete) invalid() const root = value.comments.find( - (item): item is ReviewRoot => item.kind === "root" && item.id === input.threadId, + (item): item is ConversationRoot => item.kind !== "reply" && item.id === input.threadId, ) if (!root) fail("comments_not_found", "Thread not present in this snapshot.", 404) if (repositoryScope && !root.prototype) invalid() const threadBase = repositoryScope ? `/api/canvas/repositories/${target.repositoryId}/prototypes/${root.prototype?.id}/review_comments` : base - if (!positive(input.threadId)) invalid() + if (!positive(input.threadId) && !noteId(input.threadId)) invalid() const current = await freshRoot(hub, threadBase, input.threadId, options) - if (current.activityVersion !== root.activityVersion) + if (activity(current) !== activity(root)) fail("comment_conflict", "Thread changed. Retrieve a new comment snapshot.", 409) return boundedThread( value, @@ -925,6 +1083,7 @@ export async function reviewAction( if (!positive(input.threadId)) invalid() if (action === "resolve_comment") { const root = await freshRoot(hub, base, input.threadId, options) + if (root.kind !== "root") invalid() if (root.resolvedAt) return { target, threadId: input.threadId, resolvedAt: root.resolvedAt } if (root.activityVersion !== input.activityVersion) fail("comment_conflict", "New activity arrived. Retrieve comments before resolving.", 409) diff --git a/.github/extensions/replica-prototype/review-action/index.test.mjs b/.github/extensions/replica-prototype/review-action/index.test.mjs index 34dc3d84..0532f307 100644 --- a/.github/extensions/replica-prototype/review-action/index.test.mjs +++ b/.github/extensions/replica-prototype/review-action/index.test.mjs @@ -167,6 +167,557 @@ function repositoryClient(comments = [{ ...root, prototype: firstPrototype }]) { return hub } +function improvementNote(overrides = {}) { + const localId = randomUUID() + const requestId = randomUUID() + const snapshotAuthor = { + id: 42, + login: "mona_historical", + avatarUrl: "https://avatars.githubusercontent.com/u/42", + } + return { + id: 7, + kind: "improvement_note", + localId, + revision: 3, + status: "open", + body: "Improve the Save button", + anchor: root.anchor, + thread: { + originalStatus: "ready", + originalAuthor: snapshotAuthor, + messages: [ + { + id: requestId, + role: "author", + body: "Keep its keyboard shortcut", + status: "open", + author: snapshotAuthor, + }, + { + id: randomUUID(), + role: "copilot", + body: "The original request is ready; the shortcut needs follow-up.", + status: "ready", + requestIds: [localId], + }, + ], + }, + author: person, + createdAt: time, + updatedAt: "2026-10-03T10:00:00Z", + deletedAt: null, + buildNumber: 2, + viewerCanEdit: true, + viewerCanDelete: true, + ...overrides, + } +} + +function improvementClient(comments, repositoryScope = false) { + const hub = repositoryScope ? repositoryClient(comments) : client(comments) + const request = hub.request + hub.request = async (route, options) => { + const url = new URL(route, hub.origin) + if (!url.pathname.endsWith("/review_comments")) return request(route, options) + hub.calls.push({ route, options }) + const filter = url.searchParams.get("improvement_notes") ?? "exclude" + assert.ok(["exclude", "include", "only"].includes(filter)) + const prototypeId = url.pathname.match(/\/prototypes\/(\d+)\//)?.[1] + return { + comments: structuredClone( + hub.comments.filter( + (record) => + (!prototypeId || !repositoryScope || record.prototype.id === Number(prototypeId)) && + (filter === "include" || (record.kind === "improvement_note") === (filter === "only")), + ), + ), + hasMore: false, + nextCursor: null, + } + } + return hub +} + +describe("improvement note snapshot retrieval", () => { + for (const repositoryScope of [false, true]) { + const listing = repositoryScope ? "list_repository_comments" : "list_comments" + const reading = repositoryScope ? "get_repository_comment_thread" : "get_comment_thread" + function scoped(record) { + return repositoryScope ? { ...record, prototype: firstPrototype } : record + } + + it(`${listing} keeps default exclusion and emits the requested Hub filter`, async () => { + await withStore(async (store) => { + const hub = improvementClient([scoped(root), scoped(improvementNote())], repositoryScope) + for (const improvementNotes of [undefined, "exclude", "include", "only"]) { + const start = hub.calls.length + const page = await reviewAction( + hub, + listing, + improvementNotes === undefined ? {} : { improvementNotes }, + store, + ) + assert.deepEqual( + page.threads.map((item) => item.kind).sort(), + improvementNotes === "include" + ? ["improvement_note", "root"] + : improvementNotes === "only" + ? ["improvement_note"] + : ["root"], + ) + const reads = hub.calls + .slice(start) + .filter(({ route }) => route.includes("/review_comments")) + assert.equal(reads.length, 2) + for (const { route, options } of reads) { + const actual = new URL(route, hub.origin).searchParams.get("improvement_notes") + if (improvementNotes === undefined || improvementNotes === "exclude") + assert.ok(actual === null || actual === "exclude") + else assert.equal(actual, improvementNotes) + assert.equal(options.method, undefined) + } + } + }) + }) + + it(`${reading} preserves real Hub requests and answers without colliding with review IDs`, async () => { + await withStore(async (store) => { + const note = scoped(improvementNote()) + const hub = improvementClient([scoped(root), scoped(reply(8)), note], repositoryScope) + const page = await reviewAction(hub, listing, { improvementNotes: "include" }, store) + const ordinary = page.threads.find((item) => item.kind === "root") + const improvement = page.threads.find((item) => item.kind === "improvement_note") + assert.equal(ordinary.id, 7) + assert.deepEqual( + ordinary.replies.map((item) => item.id), + [8], + ) + assert.equal(improvement.id, "improvement_note-7") + for (const key of [ + "kind", + "localId", + "revision", + "status", + "body", + "anchor", + "thread", + "author", + "createdAt", + "updatedAt", + "buildNumber", + "deletedAt", + ]) + assert.deepEqual(improvement[key], note[key]) + assert.equal(improvement.replies, undefined) + assert.equal(improvement.replyRequestId, undefined) + assert.equal(improvement.unread, undefined) + assert.equal(improvement.threadComplete, true) + assert.equal(improvement.repliesHasMore, false) + assert.equal(improvement.repliesNextCursor, null) + if (repositoryScope) assert.deepEqual(improvement.prototype, firstPrototype) + const result = await reviewAction( + hub, + reading, + { target: page.target, snapshotId: page.snapshotId, threadId: improvement.id }, + store, + ) + assert.deepEqual(result.thread.thread, note.thread) + assert.equal(result.thread.id, improvement.id) + assert.equal(result.thread.replyRequestId, undefined) + const freshRead = new URL(hub.calls.at(-1).route, hub.origin) + assert.ok(["include", "only"].includes(freshRead.searchParams.get("improvement_notes"))) + if (repositoryScope) assert.match(freshRead.pathname, /\/prototypes\/3\/review_comments$/) + assert.ok(hub.calls.every(({ options }) => !options.method && !options.interactive)) + }) + }) + + it(`${reading} returns bounded lossless note continuations after storage reload`, async () => { + await withStore(async (store, workspace) => { + const note = scoped(improvementNote()) + note.thread.messages = Array.from({ length: 14 }, () => { + const id = randomUUID() + return [ + { id, role: "author", status: "open", body: "😀".repeat(2400) }, + { + id: randomUUID(), + role: "copilot", + status: "ready", + body: ["\\", '"', "\n"].join("").repeat(500), + requestIds: [note.localId, id], + }, + ] + }).flat() + const hub = improvementClient([note], repositoryScope) + const page = await reviewAction(hub, listing, { improvementNotes: "only" }, store) + assert.ok(Buffer.byteLength(JSON.stringify(page)) <= 64 * 1024) + let thread = page.threads[0] + assert.equal(thread.threadComplete, false) + assert.equal(thread.repliesHasMore, true) + const messages = [...thread.thread.messages] + const cursors = new Set() + const reloaded = createCommentStore(workspace) + while (thread.repliesHasMore) { + assert.ok(!cursors.has(thread.repliesNextCursor)) + cursors.add(thread.repliesNextCursor) + const result = await reviewAction( + hub, + reading, + { + target: page.target, + snapshotId: page.snapshotId, + threadId: "improvement_note-7", + cursor: thread.repliesNextCursor, + }, + reloaded, + ) + assert.ok(Buffer.byteLength(JSON.stringify(result)) <= 64 * 1024) + thread = result.thread + assert.ok(thread.thread.messages.length > 0) + assert.equal(thread.thread.originalStatus, note.thread.originalStatus) + assert.deepEqual(thread.thread.originalAuthor, note.thread.originalAuthor) + assert.equal(thread.replyRequestId, undefined) + assert.equal(thread.replies, undefined) + messages.push(...thread.thread.messages) + } + assert.equal(thread.repliesNextCursor, null) + assert.deepEqual(messages, note.thread.messages) + }) + }) + } + + it("notes have no read state, resolve only at resolved status, and match root creation dates", async () => { + await withStore(async (store) => { + const notes = ["open", "sent", "ready", "blocked", "resolved"].map((status, index) => + improvementNote({ + id: index + 1, + status, + thread: { originalStatus: status === "resolved" ? "ready" : status, messages: [] }, + }), + ) + const hub = improvementClient([root, ...notes]) + for (const read of ["read", "unread"]) { + const page = await reviewAction( + hub, + "list_comments", + { improvementNotes: "include", read }, + store, + ) + assert.ok(page.threads.every((item) => item.kind === "root")) + } + for (const [resolution, ids] of [ + ["resolved", ["improvement_note-5"]], + [ + "unresolved", + ["improvement_note-1", "improvement_note-2", "improvement_note-3", "improvement_note-4"], + ], + ]) { + const page = await reviewAction( + hub, + "list_comments", + { improvementNotes: "only", resolution }, + store, + ) + assert.deepEqual(page.threads.map((item) => item.id).sort(), ids) + } + for (const [from, to, count] of [ + [time, "2026-10-02T00:00:00Z", 5], + ["2026-09-30T00:00:00Z", time, 0], + ["2026-10-03T00:00:00Z", "2026-10-04T00:00:00Z", 0], + ]) { + const page = await reviewAction( + hub, + "list_comments", + { improvementNotes: "only", from, to }, + store, + ) + assert.equal(page.threads.length, count) + } + const tombstone = improvementNote({ body: null, thread: null, deletedAt: time }) + const deleted = await reviewAction( + improvementClient([tombstone]), + "list_comments", + { improvementNotes: "only" }, + store, + ) + assert.equal(deleted.threads[0].body, null) + assert.equal(deleted.threads[0].thread, null) + assert.equal(deleted.threads[0].deletedAt, time) + }) + }) + + it("binds list continuations to improvement filters without conflating numeric and note cursors", async () => { + await withStore(async (store) => { + const hub = improvementClient( + Array.from({ length: 12 }, (_, index) => improvementNote({ id: index + 1 })), + ) + const page = await reviewAction(hub, "list_comments", { improvementNotes: "only" }, store) + assert.equal(page.hasMore, true) + for (const improvementNotes of ["exclude", "include", undefined]) { + const before = hub.calls.filter(({ route }) => route.includes("/review_comments")).length + await assert.rejects( + reviewAction( + hub, + "list_comments", + { + target: page.target, + cursor: page.nextCursor, + ...(improvementNotes ? { improvementNotes } : {}), + }, + store, + ), + { code: "invalid_comment_request" }, + ) + assert.equal( + hub.calls.filter(({ route }) => route.includes("/review_comments")).length, + before, + ) + } + const next = await reviewAction( + hub, + "list_comments", + { target: page.target, cursor: page.nextCursor, improvementNotes: "only" }, + store, + ) + assert.equal(next.hasMore, false) + assert.deepEqual( + [...page.threads, ...next.threads].map((item) => item.id), + Array.from({ length: 12 }, (_, index) => `improvement_note-${12 - index}`), + ) + }) + }) + + it("rejects an indivisible note message rather than dropping or truncating it", async () => { + await withStore(async (store) => { + const note = improvementNote({ + body: "\u0000".repeat(9999) + "x", + thread: { + originalStatus: "open", + messages: [{ id: randomUUID(), role: "author", status: "open", body: "x".repeat(10000) }], + }, + }) + await assert.rejects( + reviewAction( + improvementClient([note]), + "list_comments", + { improvementNotes: "only" }, + store, + ), + { code: "comment_response_limit", statusCode: 413 }, + ) + }) + }) + + it("continues version two review snapshots without the new filter after storage reload", async () => { + await withStore(async (store, workspace) => { + const hub = client(Array.from({ length: 12 }, (_, index) => ({ ...root, id: index + 1 }))) + const page = await reviewAction(hub, "list_comments", {}, store) + const file = `snapshot-${page.snapshotId}.json` + const saved = await store.read(file) + assert.equal(saved.scanVersion, 2) + delete saved.filters.improvementNotes + await store.write(file, saved) + const reloaded = createCommentStore(workspace) + for (const filter of [{}, { improvementNotes: "exclude" }]) { + const next = await reviewAction( + hub, + "list_comments", + { target: page.target, cursor: page.nextCursor, ...filter }, + reloaded, + ) + assert.deepEqual( + next.threads.map((item) => item.id), + [2, 1], + ) + assert.equal(next.hasMore, false) + assert.equal(next.snapshotId, page.snapshotId) + } + assert.equal(hub.calls.filter(({ route }) => route.includes("/review_comments")).length, 2) + }) + }) + + it("rejects reply cursors exchanged between review and note threads with the same numeric ID", async () => { + await withStore(async (store) => { + const note = improvementNote() + note.thread.messages = Array.from({ length: 11 }, () => ({ + id: randomUUID(), + role: "author", + status: "open", + body: "Follow up", + })) + const hub = improvementClient([ + root, + ...Array.from({ length: 11 }, (_, index) => reply(index + 8)), + note, + ]) + const page = await reviewAction(hub, "list_comments", { improvementNotes: "include" }, store) + const review = page.threads.find((item) => item.id === 7) + const improvement = page.threads.find((item) => item.id === "improvement_note-7") + assert.equal(review.repliesHasMore, true) + assert.equal(improvement.repliesHasMore, true) + for (const [threadId, cursor] of [ + [review.id, improvement.repliesNextCursor], + [improvement.id, review.repliesNextCursor], + ]) + await assert.rejects( + reviewAction( + hub, + "get_comment_thread", + { target: page.target, snapshotId: page.snapshotId, threadId, cursor }, + store, + ), + { code: "invalid_comment_request" }, + ) + assert.equal(hub.calls.filter(({ route }) => route.includes("/review_comments")).length, 2) + }) + }) + + it("rejects same-revision content changes between scans and before thread retrieval", async () => { + await withStore(async (store) => { + const original = improvementNote() + const changes = [ + (note) => { + note.revision++ + }, + (note) => { + note.thread.messages[1].body = "Changed answer" + }, + ] + for (const change of changes) { + for (const phase of ["scan", "thread"]) { + const hub = improvementClient([structuredClone(original)]) + if (phase === "scan") { + const request = hub.request + let reads = 0 + hub.request = async (route, options) => { + const response = await request(route, options) + if (route.includes("/review_comments") && ++reads === 1) change(hub.comments[0]) + return response + } + await assert.rejects( + reviewAction(hub, "list_comments", { improvementNotes: "only" }, store), + { code: "comment_conflict" }, + ) + } else { + const page = await reviewAction( + hub, + "list_comments", + { improvementNotes: "only" }, + store, + ) + change(hub.comments[0]) + await assert.rejects( + reviewAction( + hub, + "get_comment_thread", + { + target: page.target, + snapshotId: page.snapshotId, + threadId: "improvement_note-7", + }, + store, + ), + { code: "comment_conflict" }, + ) + } + } + } + }) + }) + + it("rejects malformed note responses and preserves denial on the second scan", async () => { + await withStore(async (store) => { + const note = improvementNote() + const invalidNotes = [ + { ...note, revision: 0 }, + { + ...note, + thread: { + ...note.thread, + messages: [{ ...note.thread.messages[1], requestIds: [randomUUID()] }], + }, + }, + ] + for (const malformed of invalidNotes) + await assert.rejects( + reviewAction( + improvementClient([malformed]), + "list_comments", + { improvementNotes: "include" }, + store, + ), + { code: "invalid_comment_request" }, + ) + const hub = improvementClient([note]) + const request = hub.request + let reads = 0 + hub.request = async (route, options) => { + if (route.includes("/review_comments") && ++reads === 2) + throw Object.assign(new Error("Denied"), { statusCode: 403 }) + return request(route, options) + } + await assert.rejects( + reviewAction(hub, "list_comments", { improvementNotes: "only" }, store), + { statusCode: 403 }, + ) + }) + }) + + it("rejects invalid improvement filters before any Hub request", async () => { + await withStore(async (store) => { + const hub = improvementClient([]) + for (const action of ["list_comments", "list_repository_comments"]) + for (const improvementNotes of [null, "all"]) + await assert.rejects(reviewAction(hub, action, { improvementNotes }, store), { + code: "invalid_comment_request", + }) + assert.deepEqual(hub.calls, []) + }) + }) + + it("rejects invalid note IDs, review-only opt-ins and namespaced mutations before any Hub request", async () => { + await withStore(async (store) => { + const hub = improvementClient([]) + for (const threadId of [ + "improvement_note-0", + "improvement_note-7:0", + "improvement_note-9007199254740992", + ]) + await assert.rejects( + reviewAction( + hub, + "get_comment_thread", + { target, snapshotId: randomUUID(), threadId }, + store, + ), + { code: "invalid_comment_request" }, + ) + await assert.rejects( + reviewAction( + hub, + "list_repository_comment_summaries", + { improvementNotes: "include" }, + store, + ), + { code: "invalid_comment_request" }, + ) + for (const action of ["mark_comment_read", "resolve_comment", "reply_to_comment"]) + await assert.rejects( + reviewAction( + hub, + action, + action === "reply_to_comment" + ? { target, threadId: "improvement_note-7", body: "Reply", requestId: randomUUID() } + : { target, threadId: "improvement_note-7", activityVersion: 2 }, + store, + ), + { code: "invalid_comment_request" }, + ) + assert.deepEqual(hub.calls, []) + }) + }) +}) + const compactRoot = { id: root.id, body: root.body, diff --git a/hub/docs/prototype-canvas.md b/hub/docs/prototype-canvas.md index 547ad64e..a6be6f7a 100644 --- a/hub/docs/prototype-canvas.md +++ b/hub/docs/prototype-canvas.md @@ -34,7 +34,7 @@ A note-specific conflict or validation rejection marks that note failed without `ImprovementComment` stores one request or agent response per row, separately from `ReviewComment`. Replies point directly to their original request through `root_id`; an explicit position preserves the client's conversation order across synchronization. `ImprovementCommentReply` links an agent response to each earlier request it answers. Composite foreign keys keep these links inside the same creator's conversation. Hub assembles `kind: "improvement_note"` and its `thread` from these rows rather than storing a JSON conversation or request-ID lists. The root owns the synchronization revision, aggregate status, immutable target and Build; each request retains its own work status. Synchronization explicitly validates and saves the root, messages and response links in one transaction. -Improvement comments do not create ordinary review replies, mentions, notifications, review analytics or unread state, and do not appear in ordinary review interfaces. They are excluded from Learnings imports, scheduling and displayed evidence. Paired repository and prototype comment list APIs accept `improvement_notes=exclude|include|only`, defaulting to `exclude`. Explicit inclusion is available to authorized repository readers, so creator-only UI is not confidentiality from collaborators. Ordinary conversation summaries remain review-only. The dedicated `improvement_notes` resource lists only the current author's notes and requires repository write access for idempotent synchronization. Ordinary reply APIs still reject improvement notes. +Improvement comments do not create ordinary review replies, mentions, notifications, review analytics or unread state, and do not appear in ordinary review interfaces. They are excluded from Learnings imports, scheduling and displayed evidence. Paired repository and prototype comment list APIs accept `improvement_notes=exclude|include|only`, defaulting to `exclude`. The canvas `list_comments` and full-detail `list_repository_comments` actions expose this as `improvementNotes: "exclude" | "include" | "only"` with the same default. Explicit inclusion is available to authorized repository readers, so creator-only UI is not confidentiality from collaborators. Ordinary conversation summaries remain review-only. The dedicated `improvement_notes` resource lists only the current author's notes and requires repository write access for idempotent synchronization. Ordinary reply APIs still reject improvement notes. Paired clients read and save notes at `GET` and `POST /api/canvas/repositories/:repository_github_id/prototypes/:prototype_id/improvement_notes`. Every request requires repository read access; writes freshly check write access and share the ordinary comment rate limit. Edit and delete flags require both ownership of an undeleted note and repository write access. Prototype metadata includes the authenticated `viewerId` so clients can identify the note owner. The creator collection includes deletion tombstones and never returns another creator's notes. @@ -470,6 +470,8 @@ Agents should default to `list_repository_comment_summaries` for listing, browsi Full-detail `list_repository_comments` collects the complete Hub feed across prototypes, including replies and deleted placeholders. Each message includes prototype ID, nullable name and branch; roots retain their original Build number. The extension shares the prototype action's filters, two-pass activity check and private snapshot storage, but repository targets are distinct and read-only. Creation filters match a root or any reply; read-state filters apply to whole conversations. The agent output remains paginated: completed list responses return up to ten full-detail threads within a 64 KiB serialized UTF-8 JSON budget. Large pages return fewer threads, and large threads paginate replies without truncating root text, anchors or messages. Thread retrieval uses the same budget; cursors advance by the actual returned count. A root or root-plus-next-reply envelope that cannot fit returns an explicit error. An authorized empty repository returns an empty page. Snapshot thread reads recheck current thread activity; a changed thread or denied request must not be presented as complete. See [Agent access to Hub comments](../../.github/extensions/replica-prototype/README.md#agent-access-to-hub-comments) for filters and continuation inputs. +Opt-in improvement records retain their `kind: "improvement_note"` and full conversation shape. Their public IDs use an `improvement_note-` prefix to distinguish them from numeric review IDs. Both snapshot thread actions accept these IDs and paginate `thread.messages` through the existing reply continuation fields. Improvements have no ordinary mutation target or reply request ID. Read/unread filters exclude them; resolution uses their note status, and date filters match only root creation because improvement messages have no creation timestamps. Two-pass scans and thread reads compare the complete note, not just its revision. Cursors bind the improvement filter as well as the other filters and connected identity. These actions read synchronized Hub notes only, not unsynchronized local notes; compact summaries and direct repository thread actions remain review-only. + For compact triage, `list_repository_comment_summaries` reads `GET /api/canvas/repositories/:repository_github_id/review_comment_summaries`. Hub selects nondeleted roots across the authorized repository's reviewable prototypes before pagination and counts nondeleted replies in one grouped query. Records include the complete starting text, author login, creation/edit timestamps, prototype/branch, original Build number/path, resolution timestamp, `replyCount`, compact route/element context and nullable `screenshotReference`. No reply bodies, reactions, unread state, selectors, anchor geometry or image bytes are loaded into this response. Comment screenshot capture is separate work; the reference is currently null, never a substituted Build screenshot. Summary pages default to 200 records and accept `pageSize` from 1 to 200. Optional `resolution` (`all`, `unresolved`, `resolved`) and inclusive-`from`/exclusive-`to` ISO-8601 filters apply to root creation only. Summaries do not support read-state filtering. Signed newest-first creation-time/ID cursors bind the repository, viewer and filters. Hub fits complete records into 60 KiB of serialized JSON, leaving 4 KiB for the canvas target and trust envelope; the canvas enforces the full 64 KiB UTF-8 budget. A page may contain fewer records even for short comments, and oversized individual records fail explicitly. Continuations advance by the last record actually returned. These live reads need no two-pass scan or private snapshot and never mark comments read; deleted roots are excluded and resolved roots are included by default.