399 lines
13 KiB
TypeScript
399 lines
13 KiB
TypeScript
// @generated — DO NOT EDIT. Source: packages/shared/external-annotation.ts
|
|
/**
|
|
* 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.
|
|
*/
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 Claude review findings)
|
|
severity?: string; // "important" | "nit" | "pre_existing"
|
|
reasoning?: string; // Validation chain explaining how the issue was confirmed
|
|
}
|
|
|
|
const VALID_REVIEW_TYPES = ["comment", "suggestion", "concern"];
|
|
const VALID_SIDES = ["old", "new"];
|
|
const VALID_SCOPES = ["line", "file"];
|
|
|
|
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;
|
|
|
|
const filePath = requireString(obj, "filePath", i);
|
|
if (typeof filePath !== "string") return filePath;
|
|
|
|
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` };
|
|
}
|
|
|
|
// 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(", ")}`,
|
|
};
|
|
}
|
|
|
|
// 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(", ")}`,
|
|
};
|
|
}
|
|
|
|
// 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`,
|
|
};
|
|
}
|
|
|
|
annotations.push({
|
|
id: crypto.randomUUID(),
|
|
type,
|
|
scope,
|
|
filePath,
|
|
lineStart: obj.lineStart,
|
|
lineEnd: obj.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 Claude review findings)
|
|
...(typeof obj.severity === "string" && { severity: obj.severity }),
|
|
...(typeof obj.reasoning === "string" && { reasoning: obj.reasoning }),
|
|
});
|
|
}
|
|
|
|
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. */
|
|
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;
|
|
const merged = { ...annotations[idx], ...fields, id } 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);
|
|
};
|
|
},
|
|
};
|
|
}
|