mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
c2950e709f
Fixes from the 0.27.9 pre-release review. Servers: an unreadable rendered-HTML root falls back to the startup snapshot on both runtimes with a once-per-process warning instead of hanging (Pi) or answering 500 (Bun); the version diff is recomputed against current bytes on reload and carried through the in-app Refresh instead of being dropped, with no history write on a GET. Client: a Refresh action on the compact touch shell; HtmlSurfaceControls renders Refresh independently of the eye; the dead HtmlSurfaceActions removed. Threading: one linear, cycle-safe reply resolution shared by the annotations panel, its sort, and the export (5,000-chain tests), PATCH ingest on both runtimes rejects self-references and cycles, nothing is ever dropped from feedback. WebMCP and viewer hygiene: bounded tombstone and request memories, per-instance minted ids, nudge id caps, waiter cleanup on unmount, a shared retry epoch for diagram blocks. Docs: HTML Refresh documented, the WebMCP design pointer fixed, marketing pages updated. AI-assisted (Claude) under maintainer direction.
511 lines
18 KiB
TypeScript
511 lines
18 KiB
TypeScript
/**
|
|
* External Annotations — shared types, store logic, and SSE helpers.
|
|
*
|
|
* Runtime-agnostic: no node:fs, no node:http, no Bun APIs.
|
|
* Both the Bun server handler and Pi server handler import this module
|
|
* and wrap it with their respective HTTP transport layers.
|
|
*
|
|
* The store is generic — plan servers store Annotation objects,
|
|
* review servers store CodeAnnotation objects. The mode-specific
|
|
* input transformers handle validation and field assignment.
|
|
*/
|
|
|
|
// Reply-threading validation for PATCH ingest, re-exported so both HTTP
|
|
// adapters import it from the module they already use.
|
|
export { validateReplyTarget } from "./annotation-threads";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Constraint for any annotation type the store can hold. */
|
|
export type StorableAnnotation = { id: string; source?: string };
|
|
|
|
export type ExternalAnnotationEvent<T = unknown> =
|
|
| { type: "snapshot"; annotations: T[] }
|
|
| { type: "add"; annotations: T[] }
|
|
| { type: "remove"; ids: string[] }
|
|
| { type: "clear"; source?: string }
|
|
| { type: "update"; id: string; annotation: T };
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SSE helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Heartbeat comment to keep SSE connections alive (sent every 30s). */
|
|
export const HEARTBEAT_COMMENT = ":\n\n";
|
|
|
|
/** Interval in ms between heartbeat comments. */
|
|
export const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
|
|
/** Encode an event as an SSE `data:` line. */
|
|
export function serializeSSEEvent<T>(event: ExternalAnnotationEvent<T>): string {
|
|
return `data: ${JSON.stringify(event)}\n\n`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Input validation — shared helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface ParseError {
|
|
error: string;
|
|
}
|
|
|
|
/**
|
|
* Unwrap a POST body into an array of raw input objects.
|
|
*
|
|
* Accepts either:
|
|
* - A single annotation object: `{ source: "...", ... }`
|
|
* - A batch wrapper: `{ annotations: [{ source: "...", ... }, ...] }`
|
|
*/
|
|
function unwrapBody(body: unknown): Record<string, unknown>[] | ParseError {
|
|
if (!body || typeof body !== "object") {
|
|
return { error: "Request body must be a JSON object" };
|
|
}
|
|
|
|
const obj = body as Record<string, unknown>;
|
|
|
|
// Batch format: { annotations: [...] }
|
|
if (Array.isArray(obj.annotations)) {
|
|
if (obj.annotations.length === 0) {
|
|
return { error: "annotations array must not be empty" };
|
|
}
|
|
const items: Record<string, unknown>[] = [];
|
|
for (let i = 0; i < obj.annotations.length; i++) {
|
|
const item = obj.annotations[i];
|
|
if (!item || typeof item !== "object") {
|
|
return { error: `annotations[${i}] must be an object` };
|
|
}
|
|
items.push(item as Record<string, unknown>);
|
|
}
|
|
return items;
|
|
}
|
|
|
|
// Single format: { source: "...", ... }
|
|
if (typeof obj.source === "string") {
|
|
return [obj as Record<string, unknown>];
|
|
}
|
|
|
|
return { error: 'Missing required "source" field or "annotations" array' };
|
|
}
|
|
|
|
function requireString(obj: Record<string, unknown>, field: string, index: number): string | ParseError {
|
|
const val = obj[field];
|
|
if (typeof val !== "string" || val.length === 0) {
|
|
return { error: `annotations[${index}] missing required "${field}" field` };
|
|
}
|
|
return val;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Plan mode transformer — produces Annotation objects
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** The Annotation type shape for plan mode (mirrors packages/ui/types.ts). */
|
|
interface PlanAnnotation {
|
|
id: string;
|
|
blockId: string;
|
|
startOffset: number;
|
|
endOffset: number;
|
|
type: string; // AnnotationType value
|
|
text?: string;
|
|
originalText: string;
|
|
createdA: number;
|
|
author?: string;
|
|
source?: string;
|
|
}
|
|
|
|
const VALID_PLAN_TYPES = ["DELETION", "COMMENT", "GLOBAL_COMMENT"];
|
|
|
|
export function transformPlanInput(
|
|
body: unknown,
|
|
): { annotations: PlanAnnotation[] } | ParseError {
|
|
const items = unwrapBody(body);
|
|
if ("error" in items) return items;
|
|
|
|
const annotations: PlanAnnotation[] = [];
|
|
for (let i = 0; i < items.length; i++) {
|
|
const obj = items[i];
|
|
|
|
const source = requireString(obj, "source", i);
|
|
if (typeof source !== "string") return source;
|
|
|
|
// Must have text content
|
|
if (typeof obj.text !== "string" || obj.text.length === 0) {
|
|
return { error: `annotations[${i}] missing required "text" field` };
|
|
}
|
|
|
|
// Validate type if provided, default to GLOBAL_COMMENT
|
|
const type = typeof obj.type === "string" ? obj.type : "GLOBAL_COMMENT";
|
|
if (!VALID_PLAN_TYPES.includes(type)) {
|
|
return {
|
|
error: `annotations[${i}] invalid type "${type}". Must be one of: ${VALID_PLAN_TYPES.join(", ")}`,
|
|
};
|
|
}
|
|
|
|
// DELETION requires originalText (the text to remove)
|
|
if (type === "DELETION" && (typeof obj.originalText !== "string" || obj.originalText.length === 0)) {
|
|
return { error: `annotations[${i}] DELETION type requires non-empty "originalText" field` };
|
|
}
|
|
|
|
// COMMENT requires originalText so the renderer can pin it to a phrase.
|
|
// External agents that want sidebar-only feedback should use GLOBAL_COMMENT
|
|
// instead — without a phrase to anchor to, a COMMENT renders as an empty
|
|
// quote bubble in the sidebar and exports as `Feedback on: ""`.
|
|
if (type === "COMMENT" && (typeof obj.originalText !== "string" || obj.originalText.length === 0)) {
|
|
return {
|
|
error: `annotations[${i}] COMMENT requires non-empty "originalText" field. Use GLOBAL_COMMENT for sidebar-only feedback.`,
|
|
};
|
|
}
|
|
|
|
annotations.push({
|
|
id: crypto.randomUUID(),
|
|
blockId: "external",
|
|
startOffset: 0,
|
|
endOffset: 0,
|
|
type,
|
|
text: String(obj.text),
|
|
originalText: typeof obj.originalText === "string" ? obj.originalText : "",
|
|
createdA: Date.now(),
|
|
author: typeof obj.author === "string" ? obj.author : undefined,
|
|
source,
|
|
});
|
|
}
|
|
|
|
return { annotations };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Review mode transformer — produces CodeAnnotation objects
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** The CodeAnnotation type shape for review mode (mirrors packages/ui/types.ts). */
|
|
interface ReviewAnnotation {
|
|
id: string;
|
|
type: string; // CodeAnnotationType value
|
|
scope?: string;
|
|
filePath: string;
|
|
lineStart: number;
|
|
lineEnd: number;
|
|
side: string;
|
|
text?: string;
|
|
suggestedCode?: string;
|
|
originalCode?: string;
|
|
createdAt: number;
|
|
author?: string;
|
|
source?: string;
|
|
// Agent review metadata (optional — only set by agent review findings)
|
|
severity?: string; // "important" | "nit" | "pre_existing"
|
|
reasoning?: string; // Validation chain explaining how the issue was confirmed
|
|
reviewProfileLabel?: string; // Custom review profile that produced this finding
|
|
prUrl?: string;
|
|
prNumber?: number;
|
|
prTitle?: string;
|
|
prRepo?: string;
|
|
diffScope?: "layer" | "full-stack";
|
|
commitSha?: string;
|
|
commitSubject?: string;
|
|
gitButlerDiffType?: string;
|
|
gitButlerDiffLabel?: string;
|
|
gitButlerBase?: string;
|
|
gitButlerSnapshotId?: string;
|
|
}
|
|
|
|
const VALID_REVIEW_TYPES = ["comment", "suggestion", "concern"];
|
|
const VALID_SIDES = ["old", "new"];
|
|
const VALID_SCOPES = ["line", "file", "general"];
|
|
|
|
/** A review finding's placement, derived from what it carries. */
|
|
export type FindingPlacement = {
|
|
scope: "line" | "file" | "general";
|
|
filePath: string;
|
|
lineStart: number;
|
|
lineEnd: number;
|
|
};
|
|
|
|
/**
|
|
* Classify an agent review finding by what it carries, so nothing is dropped:
|
|
* file + a usable line → a line comment
|
|
* file, no line → a whole-file comment
|
|
* neither → a general (review-level) comment
|
|
*
|
|
* For file and general placements the line is 0; for general the path is "".
|
|
* Consumers branch on `scope`, never on the sentinel values.
|
|
*/
|
|
export function classifyFindingPlacement(
|
|
filePath: string,
|
|
lineStart: number | null | undefined,
|
|
lineEnd: number | null | undefined,
|
|
): FindingPlacement {
|
|
const hasFile = filePath.length > 0;
|
|
const hasLine = typeof lineStart === "number";
|
|
if (hasFile && hasLine) {
|
|
return {
|
|
scope: "line",
|
|
filePath,
|
|
lineStart,
|
|
lineEnd: typeof lineEnd === "number" ? lineEnd : lineStart,
|
|
};
|
|
}
|
|
if (hasFile) {
|
|
return { scope: "file", filePath, lineStart: 0, lineEnd: 0 };
|
|
}
|
|
return { scope: "general", filePath: "", lineStart: 0, lineEnd: 0 };
|
|
}
|
|
|
|
export function transformReviewInput(
|
|
body: unknown,
|
|
): { annotations: ReviewAnnotation[] } | ParseError {
|
|
const items = unwrapBody(body);
|
|
if ("error" in items) return items;
|
|
|
|
const annotations: ReviewAnnotation[] = [];
|
|
for (let i = 0; i < items.length; i++) {
|
|
const obj = items[i];
|
|
|
|
const source = requireString(obj, "source", i);
|
|
if (typeof source !== "string") return source;
|
|
|
|
// scope: optional, defaults to "line"
|
|
const scope = typeof obj.scope === "string" ? obj.scope : "line";
|
|
if (!VALID_SCOPES.includes(scope)) {
|
|
return {
|
|
error: `annotations[${i}] invalid scope "${scope}". Must be one of: ${VALID_SCOPES.join(", ")}`,
|
|
};
|
|
}
|
|
|
|
// Location requirements depend on scope:
|
|
// line → filePath + lineStart + lineEnd required. A finding that claims
|
|
// a line must carry one, so a broken line finding is rejected
|
|
// rather than quietly passing as a vaguer comment.
|
|
// file → filePath required; line ignored (defaults to 0).
|
|
// general → no file, no line (review-level; defaults to "" / 0).
|
|
let filePath = "";
|
|
let lineStart = 0;
|
|
let lineEnd = 0;
|
|
if (scope !== "general") {
|
|
const fp = requireString(obj, "filePath", i);
|
|
if (typeof fp !== "string") return fp;
|
|
filePath = fp;
|
|
if (scope === "line") {
|
|
if (typeof obj.lineStart !== "number") {
|
|
return { error: `annotations[${i}] missing required "lineStart" field` };
|
|
}
|
|
if (typeof obj.lineEnd !== "number") {
|
|
return { error: `annotations[${i}] missing required "lineEnd" field` };
|
|
}
|
|
lineStart = obj.lineStart;
|
|
lineEnd = obj.lineEnd;
|
|
} else {
|
|
lineStart = typeof obj.lineStart === "number" ? obj.lineStart : 0;
|
|
lineEnd = typeof obj.lineEnd === "number" ? obj.lineEnd : 0;
|
|
}
|
|
}
|
|
|
|
// side: optional, defaults to "new"
|
|
const side = typeof obj.side === "string" ? obj.side : "new";
|
|
if (!VALID_SIDES.includes(side)) {
|
|
return {
|
|
error: `annotations[${i}] invalid side "${side}". Must be one of: ${VALID_SIDES.join(", ")}`,
|
|
};
|
|
}
|
|
|
|
// type: optional, defaults to "comment"
|
|
const type = typeof obj.type === "string" ? obj.type : "comment";
|
|
if (!VALID_REVIEW_TYPES.includes(type)) {
|
|
return {
|
|
error: `annotations[${i}] invalid type "${type}". Must be one of: ${VALID_REVIEW_TYPES.join(", ")}`,
|
|
};
|
|
}
|
|
|
|
// Must have at least text or suggestedCode
|
|
if (typeof obj.text !== "string" && typeof obj.suggestedCode !== "string") {
|
|
return {
|
|
error: `annotations[${i}] must have at least one of: text, suggestedCode`,
|
|
};
|
|
}
|
|
|
|
if (
|
|
obj.prNumber !== undefined &&
|
|
(typeof obj.prNumber !== "number" || !Number.isSafeInteger(obj.prNumber) || obj.prNumber <= 0)
|
|
) {
|
|
return { error: `annotations[${i}] invalid prNumber. Must be a positive integer` };
|
|
}
|
|
if (
|
|
obj.diffScope !== undefined &&
|
|
obj.diffScope !== "layer" &&
|
|
obj.diffScope !== "full-stack"
|
|
) {
|
|
return { error: `annotations[${i}] invalid diffScope. Must be one of: layer, full-stack` };
|
|
}
|
|
|
|
annotations.push({
|
|
id: crypto.randomUUID(),
|
|
type,
|
|
scope,
|
|
filePath,
|
|
lineStart,
|
|
lineEnd,
|
|
side,
|
|
text: typeof obj.text === "string" ? obj.text : undefined,
|
|
suggestedCode: typeof obj.suggestedCode === "string" ? obj.suggestedCode : undefined,
|
|
originalCode: typeof obj.originalCode === "string" ? obj.originalCode : undefined,
|
|
createdAt: Date.now(),
|
|
author: typeof obj.author === "string" ? obj.author : undefined,
|
|
source,
|
|
// Agent review metadata (optional — only set by agent review findings)
|
|
...(typeof obj.severity === "string" && { severity: obj.severity }),
|
|
...(typeof obj.reasoning === "string" && { reasoning: obj.reasoning }),
|
|
...(typeof obj.reviewProfileLabel === "string" && { reviewProfileLabel: obj.reviewProfileLabel }),
|
|
...(typeof obj.prUrl === "string" && { prUrl: obj.prUrl }),
|
|
...(typeof obj.prNumber === "number" && { prNumber: obj.prNumber }),
|
|
...(typeof obj.prTitle === "string" && { prTitle: obj.prTitle }),
|
|
...(typeof obj.prRepo === "string" && { prRepo: obj.prRepo }),
|
|
...((obj.diffScope === "layer" || obj.diffScope === "full-stack") && { diffScope: obj.diffScope }),
|
|
...(typeof obj.commitSha === "string" && { commitSha: obj.commitSha }),
|
|
...(typeof obj.commitSubject === "string" && { commitSubject: obj.commitSubject }),
|
|
...(typeof obj.gitButlerDiffType === "string" && { gitButlerDiffType: obj.gitButlerDiffType }),
|
|
...(typeof obj.gitButlerDiffLabel === "string" && { gitButlerDiffLabel: obj.gitButlerDiffLabel }),
|
|
...(typeof obj.gitButlerBase === "string" && { gitButlerBase: obj.gitButlerBase }),
|
|
...(typeof obj.gitButlerSnapshotId === "string" && { gitButlerSnapshotId: obj.gitButlerSnapshotId }),
|
|
});
|
|
}
|
|
|
|
return { annotations };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Annotation Store (generic)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type MutationListener<T> = (event: ExternalAnnotationEvent<T>) => void;
|
|
|
|
export interface AnnotationStore<T extends StorableAnnotation> {
|
|
/** Add fully-formed annotations. Returns the added annotations. */
|
|
add(items: T[]): T[];
|
|
/** Remove an annotation by ID. Returns true if found. */
|
|
remove(id: string): boolean;
|
|
/** Remove all annotations from a specific source. Returns count removed. */
|
|
clearBySource(source: string): number;
|
|
/**
|
|
* Update an annotation by ID. Returns the updated annotation, or null if
|
|
* not found. The identity fields `id` and `source` are pinned — values for
|
|
* them in `fields` are ignored (`source` gates verbatim skill-instruction
|
|
* injection in exported feedback and must not be clearable via PATCH).
|
|
*/
|
|
update(id: string, fields: Partial<T>): T | null;
|
|
/** Remove all annotations. Returns count removed. */
|
|
clearAll(): number;
|
|
/** Get all annotations (snapshot). */
|
|
getAll(): T[];
|
|
/** Monotonic version counter — incremented on every mutation. */
|
|
readonly version: number;
|
|
/** Register a listener for mutation events. Returns unsubscribe function. */
|
|
onMutation(listener: MutationListener<T>): () => void;
|
|
}
|
|
|
|
/**
|
|
* Create an in-memory annotation store.
|
|
*
|
|
* The store is runtime-agnostic — it holds data and emits events.
|
|
* HTTP transport (SSE broadcasting, request parsing) is handled by
|
|
* the server-specific adapter (Bun or Pi).
|
|
*/
|
|
export function createAnnotationStore<T extends StorableAnnotation>(): AnnotationStore<T> {
|
|
const annotations: T[] = [];
|
|
const listeners = new Set<MutationListener<T>>();
|
|
let version = 0;
|
|
|
|
function emit(event: ExternalAnnotationEvent<T>): void {
|
|
for (const listener of listeners) {
|
|
try {
|
|
listener(event);
|
|
} catch {
|
|
// Don't let a failing listener break the store
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
add(items) {
|
|
if (items.length > 0) {
|
|
for (const item of items) {
|
|
annotations.push(item);
|
|
}
|
|
version++;
|
|
emit({ type: "add", annotations: items });
|
|
}
|
|
return items;
|
|
},
|
|
|
|
remove(id) {
|
|
const idx = annotations.findIndex((a) => a.id === id);
|
|
if (idx === -1) return false;
|
|
annotations.splice(idx, 1);
|
|
version++;
|
|
emit({ type: "remove", ids: [id] });
|
|
return true;
|
|
},
|
|
|
|
update(id, fields) {
|
|
const idx = annotations.findIndex((a) => a.id === id);
|
|
if (idx === -1) return null;
|
|
// Identity fields are pinned and can never be set, cleared, or changed
|
|
// by an update: `id` addresses the annotation, and `source` is the
|
|
// security marker the feedback exporters key on — a tool-submitted
|
|
// annotation (one carrying a `source`) must never receive verbatim
|
|
// SKILL.md injection (#1229). PATCH is an unauthenticated localhost
|
|
// surface, so allowing `{"source": ""}` through the merge would let
|
|
// any local process strip the external marker and re-arm injection.
|
|
const patch = { ...fields } as Record<string, unknown>;
|
|
delete patch.id;
|
|
delete patch.source;
|
|
const merged = { ...annotations[idx], ...(patch as Partial<T>) } as T;
|
|
annotations[idx] = merged;
|
|
version++;
|
|
emit({ type: "update", id, annotation: merged });
|
|
return merged;
|
|
},
|
|
|
|
clearBySource(source) {
|
|
const before = annotations.length;
|
|
for (let i = annotations.length - 1; i >= 0; i--) {
|
|
if (annotations[i].source === source) {
|
|
annotations.splice(i, 1);
|
|
}
|
|
}
|
|
const removed = before - annotations.length;
|
|
if (removed > 0) {
|
|
version++;
|
|
emit({ type: "clear", source });
|
|
}
|
|
return removed;
|
|
},
|
|
|
|
clearAll() {
|
|
const count = annotations.length;
|
|
if (count > 0) {
|
|
annotations.length = 0;
|
|
version++;
|
|
emit({ type: "clear" });
|
|
}
|
|
return count;
|
|
},
|
|
|
|
getAll() {
|
|
return [...annotations];
|
|
},
|
|
|
|
get version() {
|
|
return version;
|
|
},
|
|
|
|
onMutation(listener) {
|
|
listeners.add(listener);
|
|
return () => {
|
|
listeners.delete(listener);
|
|
};
|
|
},
|
|
};
|
|
}
|