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.
162 lines
6.2 KiB
TypeScript
162 lines
6.2 KiB
TypeScript
/**
|
|
* Reply threading (`inReplyTo`) rules shared by every consumer: the feedback
|
|
* export, the annotations panel, and the external-annotation ingest.
|
|
*
|
|
* Browser-safe, zero-dep. `inReplyTo` is an additive field whose value can
|
|
* come from anywhere (a browser agent's tool call, a PATCH on
|
|
* /api/external-annotations that merges arbitrary fields), so consumers must
|
|
* never trust it to form a tree. The one rule every consumer applies:
|
|
*
|
|
* An annotation is a reply only when its `inReplyTo` names a DIFFERENT
|
|
* annotation in the same list AND following the chain of parents never
|
|
* comes back to the annotation itself. Everything else is a root: a plain
|
|
* comment, an orphan whose parent is absent, a self-reference, and every
|
|
* member of a cycle. Roots keep their original order. A reply to a cycle
|
|
* member stays a reply: its parent is rendered (as a root).
|
|
*
|
|
* Consequence: no annotation is ever dropped from a threaded rendering, and
|
|
* a reply always hangs under something that is itself rendered.
|
|
*
|
|
* Every walk here is linear in the number of annotations: each id is
|
|
* classified once and later chains stop at the first classified id, so a
|
|
* 5,000-deep chain (which a hostile or buggy tool can POST) costs 5,000
|
|
* steps, not 12.5 million.
|
|
*/
|
|
|
|
export interface ThreadableAnnotation {
|
|
id: string;
|
|
inReplyTo?: string | null;
|
|
}
|
|
|
|
/** The id `item` points at when that target could be a parent: present, and not itself. */
|
|
function replyTarget<T extends ThreadableAnnotation>(item: T, byId: Map<string, T>): string | null {
|
|
const target = typeof item.inReplyTo === "string" ? item.inReplyTo : null;
|
|
if (!target || target === item.id || !byId.has(target)) return null;
|
|
return target;
|
|
}
|
|
|
|
/**
|
|
* The effective parent of every annotation in `items`: the `inReplyTo`
|
|
* target for a valid reply, `null` for a root (see the module comment).
|
|
* O(n): ids are classified once, with path compression along each chain.
|
|
*/
|
|
export function resolveReplyParents<T extends ThreadableAnnotation>(
|
|
items: readonly T[],
|
|
): Map<string, string | null> {
|
|
const byId = new Map<string, T>();
|
|
for (const item of items) byId.set(item.id, item);
|
|
const parents = new Map<string, string | null>();
|
|
for (const item of items) {
|
|
if (parents.has(item.id)) continue;
|
|
// Walk the chain until it reaches an already classified id, a root, or
|
|
// an id already on this walk (a cycle). Ids on the walk are remembered
|
|
// with their position so the cycle segment can be told from the run-in.
|
|
const path: T[] = [];
|
|
const position = new Map<string, number>();
|
|
let current: T | undefined = item;
|
|
let cycleStart = -1;
|
|
while (current) {
|
|
if (parents.has(current.id)) break;
|
|
const seen = position.get(current.id);
|
|
if (seen !== undefined) {
|
|
cycleStart = seen;
|
|
break;
|
|
}
|
|
position.set(current.id, path.length);
|
|
path.push(current);
|
|
const target = replyTarget(current, byId);
|
|
if (!target) {
|
|
parents.set(current.id, null);
|
|
break;
|
|
}
|
|
current = byId.get(target);
|
|
}
|
|
// Cycle members are roots; everything before the cycle (and every id on
|
|
// a walk that ended at a known id or a root) is a reply of its target.
|
|
const replyEnd = cycleStart === -1 ? path.length : cycleStart;
|
|
for (let i = 0; i < path.length; i++) {
|
|
const node = path[i];
|
|
if (parents.has(node.id)) continue;
|
|
parents.set(node.id, i < replyEnd ? replyTarget(node, byId) : null);
|
|
}
|
|
}
|
|
return parents;
|
|
}
|
|
|
|
/**
|
|
* The timestamp of every annotation's thread root (its own when it is a
|
|
* root), keyed by id, in one linear pass over `parents`. Panels sort threads
|
|
* by this so a reply sits at its root's position on the timeline.
|
|
*/
|
|
export function resolveThreadRootTimestamps<T extends ThreadableAnnotation & { createdA: number }>(
|
|
items: readonly T[],
|
|
parents: ReadonlyMap<string, string | null> = resolveReplyParents(items),
|
|
): Map<string, number> {
|
|
const byId = new Map<string, T>();
|
|
for (const item of items) byId.set(item.id, item);
|
|
const rootTs = new Map<string, number>();
|
|
for (const item of items) {
|
|
if (rootTs.has(item.id)) continue;
|
|
const path: string[] = [];
|
|
let current: T | undefined = item;
|
|
let ts: number | undefined;
|
|
while (current) {
|
|
const known = rootTs.get(current.id);
|
|
if (known !== undefined) {
|
|
ts = known;
|
|
break;
|
|
}
|
|
path.push(current.id);
|
|
const parent = parents.get(current.id) ?? null;
|
|
if (!parent) {
|
|
ts = current.createdA;
|
|
break;
|
|
}
|
|
current = byId.get(parent);
|
|
}
|
|
const resolved = ts ?? item.createdA;
|
|
for (const id of path) rootTs.set(id, resolved);
|
|
}
|
|
return rootTs;
|
|
}
|
|
|
|
/**
|
|
* Validate an `inReplyTo` value about to be written onto annotation `id`
|
|
* (external-annotation PATCH ingest, both runtimes). A reply must point at
|
|
* an existing, different annotation, and must not close a cycle through the
|
|
* existing chain. `null`/`undefined` clear the field and are always valid.
|
|
* Returns the error message, or `null` when the value may be applied.
|
|
*/
|
|
export function validateReplyTarget(
|
|
all: readonly ThreadableAnnotation[],
|
|
id: string,
|
|
inReplyTo: unknown,
|
|
): string | null {
|
|
if (inReplyTo === undefined || inReplyTo === null) return null;
|
|
if (typeof inReplyTo !== "string" || inReplyTo.length === 0) {
|
|
return 'invalid "inReplyTo": must be the id of an existing annotation';
|
|
}
|
|
if (inReplyTo === id) {
|
|
return 'invalid "inReplyTo": an annotation cannot reply to itself';
|
|
}
|
|
const byId = new Map<string, ThreadableAnnotation>();
|
|
for (const item of all) byId.set(item.id, item);
|
|
if (!byId.has(inReplyTo)) {
|
|
return `invalid "inReplyTo": no annotation with id "${inReplyTo}"`;
|
|
}
|
|
// Would the target's own chain lead back to `id`? Then the write would
|
|
// create a cycle.
|
|
const seen = new Set<string>();
|
|
let current: ThreadableAnnotation | undefined = byId.get(inReplyTo);
|
|
while (current) {
|
|
if (current.id === id) {
|
|
return 'invalid "inReplyTo": the reply chain would form a cycle';
|
|
}
|
|
if (seen.has(current.id)) break; // pre-existing cycle elsewhere; not ours to close
|
|
seen.add(current.id);
|
|
const next = typeof current.inReplyTo === "string" ? current.inReplyTo : null;
|
|
current = next ? byId.get(next) : undefined;
|
|
}
|
|
return null;
|
|
}
|