Files
backnotprop__plannotator/packages/ui/webmcp/changes.ts
Michael Ramos c2950e709f fix: pre-release QA findings for 0.27.9 (#1405)
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.
2026-08-27 15:23:28 -07:00

252 lines
8.4 KiB
TypeScript

/**
* The change tracker behind "since your last read".
*
* WebMCP page tools receive no caller identity and the spec's model is one
* agent per tab, so the watermark is per tab, per page load, and any agent
* that wants its own passes `since` explicitly (every response returns
* `cursor`). Two agents on one tab share the implicit watermark; that is a
* documented limitation.
*
* Pure, no DOM, so it can move to `@plannotator/core` untouched.
*/
/** `source` stamped on every annotation a browser agent creates through the tools. */
export const BROWSER_AGENT_SOURCE = 'browser-agent';
/** The slice of an annotation the tracker hashes. */
export interface TrackedAnnotation {
id: string;
text?: string;
originalText?: string;
source?: string;
inReplyTo?: string;
images?: ReadonlyArray<{ path: string }>;
}
export interface ChangeEntry {
seq: number;
hash: string;
agent: boolean;
}
export interface Tombstone {
id: string;
seq: number;
/** The removed annotation was agent-authored (its `source` was the agent stamp). */
agent: boolean;
}
export interface ObserveDelta {
added: string[];
changed: string[];
removed: Tombstone[];
}
export function isAgentAnnotation(annotation: { source?: string }): boolean {
return annotation.source === BROWSER_AGENT_SOURCE;
}
export function hashAnnotation(annotation: TrackedAnnotation): string {
return JSON.stringify([
annotation.text ?? '',
annotation.originalText ?? '',
annotation.inReplyTo ?? '',
annotation.images?.map((image) => image.path) ?? [],
]);
}
const CURSOR_PREFIX = 'w:';
/**
* Tombstones kept per tracker. A removal past this many is forgotten oldest
* first (FIFO by seq), together with the agent-ownership records of the
* forgotten id, so a create/delete loop cannot grow the tracker for the
* life of the tab. A tombstone older than 2,000 later removals is one no
* `since` in practice still asks about.
*/
export const MAX_TOMBSTONES = 2000;
export class AnnotationChangeTracker {
private readonly entries = new Map<string, ChangeEntry>();
private readonly tombstones = new Map<string, Tombstone>();
/** Hash the agent wrote for an id; the seq assigned to that exact state is never "new" to the agent. */
private readonly ownHashes = new Map<string, string>();
private readonly ownSeqs = new Map<string, number>();
/** Ids the agent removed itself; their tombstones are never reported back to it. */
private readonly agentRemoved = new Set<string>();
private counter = 0;
private mark = 0;
/** Wall-clock time of the last seq change (null until anything changes). */
lastActivity: number | null = null;
constructor(private readonly now: () => number = () => Date.now()) {}
/** Highest seq handed out so far. */
get seq(): number {
return this.counter;
}
/** The implicit per-tab watermark. */
get watermark(): number {
return this.mark;
}
cursor(): string {
return `${CURSOR_PREFIX}${this.counter}`;
}
/** Accepts a `cursor` string (`w:47`) or a bare number; anything else is null. */
static parseSince(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return Math.floor(value);
if (typeof value === 'string') {
const digits = value.startsWith(CURSOR_PREFIX) ? value.slice(CURSOR_PREFIX.length) : value;
if (/^\d+$/.test(digits)) return Number(digits);
}
return null;
}
/** Advance the implicit watermark (default: to the current seq). */
advance(to: number = this.counter): void {
this.mark = Math.max(this.mark, Math.min(to, this.counter));
}
/**
* Claim an annotation state as the agent's own: when the tracker next sees
* this id with this exact hash, that seq is remembered so `newSince` skips
* it. A later human edit produces a different hash, a new seq, and IS new.
*/
claimOwn(annotation: TrackedAnnotation): void {
this.ownHashes.set(annotation.id, hashAnnotation(annotation));
const entry = this.entries.get(annotation.id);
if (entry && entry.hash === this.ownHashes.get(annotation.id)) this.ownSeqs.set(annotation.id, entry.seq);
}
/**
* Record that the agent removed `id` itself, so the resulting tombstone
* is not attributed to the human (`removedSince` skips it).
*/
claimRemoved(id: string): void {
this.agentRemoved.add(id);
}
/** One O(n) pass over the current list; assigns seqs and tombstones. */
observe(list: ReadonlyArray<TrackedAnnotation>): ObserveDelta {
const delta: ObserveDelta = { added: [], changed: [], removed: [] };
const seen = new Set<string>();
let touched = false;
for (const annotation of list) {
seen.add(annotation.id);
const hash = hashAnnotation(annotation);
const agent = isAgentAnnotation(annotation);
const existing = this.entries.get(annotation.id);
if (existing && existing.hash === hash) {
existing.agent = agent;
continue;
}
const seq = ++this.counter;
touched = true;
this.entries.set(annotation.id, { seq, hash, agent });
this.tombstones.delete(annotation.id);
if (existing) delta.changed.push(annotation.id);
else delta.added.push(annotation.id);
if (this.ownHashes.get(annotation.id) === hash) this.ownSeqs.set(annotation.id, seq);
}
for (const [id, entry] of this.entries) {
if (seen.has(id)) continue;
this.entries.delete(id);
const seq = ++this.counter;
touched = true;
const tombstone: Tombstone = { id, seq, agent: entry.agent || this.ownHashes.has(id) };
this.tombstones.set(id, tombstone);
delta.removed.push(tombstone);
}
this.pruneTombstones();
// A re-added id is a fresh record: forget any agent-removal claim on it.
for (const id of seen) this.agentRemoved.delete(id);
if (touched) this.lastActivity = this.now();
return delta;
}
/** Bounded eviction (MAX_TOMBSTONES): the oldest tombstones go, and with
* them the ownership and agent-removal records that only mattered while
* the id could still be asked about. Live entries keep theirs. */
private pruneTombstones(): void {
while (this.tombstones.size > MAX_TOMBSTONES) {
const oldest = this.tombstones.keys().next().value;
if (oldest === undefined) break;
this.tombstones.delete(oldest);
this.ownHashes.delete(oldest);
this.ownSeqs.delete(oldest);
this.agentRemoved.delete(oldest);
}
}
seqOf(id: string): number | undefined {
return this.entries.get(id)?.seq;
}
/** Whether `id` changed after `since` in a way the agent did not author. */
isNew(id: string, since: number = this.mark): boolean {
const entry = this.entries.get(id);
if (!entry || entry.seq <= since) return false;
return this.ownSeqs.get(id) !== entry.seq;
}
/** Ids added or edited after `since`, excluding states the agent wrote. */
newSince(since: number = this.mark): string[] {
const ids: string[] = [];
for (const [id] of this.entries) if (this.isNew(id, since)) ids.push(id);
return ids;
}
/** Tombstones written after `since`, excluding removals the agent made itself. */
removedSince(since: number = this.mark): Tombstone[] {
const removed: Tombstone[] = [];
for (const tombstone of this.tombstones.values()) {
if (tombstone.seq > since && !this.agentRemoved.has(tombstone.id)) removed.push(tombstone);
}
return removed;
}
/**
* Whether the agent claimed `id` in this page load. This is the ownership
* key for update/remove: a `source` stamp alone can be forged through the
* external-annotations API, a claim cannot.
*/
isOwn(id: string): boolean {
return this.ownHashes.has(id);
}
/** Whether the tracker has ever seen `id` (live or removed). */
knows(id: string): boolean {
return this.entries.has(id) || this.tombstones.has(id);
}
}
/**
* Per-path trackers for folder and linked-doc sessions, each with its own
* read watermark so `newSinceLastRead` is per document.
*/
export class ChangeTrackerSet {
private readonly trackers = new Map<string, AnnotationChangeTracker>();
constructor(private readonly now: () => number = () => Date.now()) {}
forPath(path: string): AnnotationChangeTracker {
let tracker = this.trackers.get(path);
if (!tracker) {
tracker = new AnnotationChangeTracker(this.now);
this.trackers.set(path, tracker);
}
return tracker;
}
has(path: string): boolean {
return this.trackers.has(path);
}
paths(): string[] {
return [...this.trackers.keys()];
}
}