| 1 | import { app } from "./bridge"; |
| 2 | import type { TranscriptOutlineEntry, TranscriptOutlinePage, TranscriptOutlineRequest } from "./transcriptProtocol"; |
| 3 | |
| 4 | /** Reads one outline page. */ |
| 5 | export type OutlineRead = (tabId: string, request: TranscriptOutlineRequest) => Promise<TranscriptOutlinePage>; |
| 6 | |
| 7 | /** |
| 8 | * The host does not implement the outline protocol. This is a compatibility |
| 9 | * answer rather than a failure: it must not be presented as a retryable error, |
| 10 | * and it must not be mistaken for "this conversation has no turns". |
| 11 | */ |
| 12 | export class OutlineUnsupported extends Error {} |
| 13 | |
| 14 | export type TranscriptOutlineMode = |
| 15 | /** No outline protocol: the rail shows loaded turns only and claims nothing. */ |
| 16 | | "legacy" |
| 17 | /** Bound to a snapshot, pages still arriving. The rail keeps its area. */ |
| 18 | | "loading" |
| 19 | | "ready" |
| 20 | /** A real read failure. Known markers stay and a retry is offered. */ |
| 21 | | "error"; |
| 22 | |
| 23 | export interface TranscriptOutlineView { |
| 24 | readonly mode: TranscriptOutlineMode; |
| 25 | readonly snapshotId: string; |
| 26 | readonly entries: readonly TranscriptOutlineEntry[]; |
| 27 | readonly error?: string; |
| 28 | /** True when a read budget stopped the index short of the whole session. */ |
| 29 | readonly truncated?: boolean; |
| 30 | } |
| 31 | |
| 32 | const EMPTY_ENTRIES: readonly TranscriptOutlineEntry[] = []; |
| 33 | const LEGACY: TranscriptOutlineView = Object.freeze({ mode: "legacy", snapshotId: "", entries: EMPTY_ENTRIES }); |
| 34 | |
| 35 | // A hostile or buggy host drives this loop, so it is bounded like every other |
| 36 | // server-driven read in this codebase (MAX_REPLAY_PAGES, MAX_JUMP_PAGES). |
| 37 | // 64 pages of 1000 entries is far beyond any real conversation. |
| 38 | const MAX_OUTLINE_PAGES = 64; |
| 39 | const MAX_OUTLINE_ENTRIES = 20_000; |
| 40 | |
| 41 | /** |
| 42 | * A budget running out keeps the turns already indexed and marks the view |
| 43 | * truncated, so a huge conversation still navigates. Discarding the whole |
| 44 | * index would trade a bounded cost for losing navigation entirely. |
| 45 | */ |
| 46 | const TRUNCATED = "outline is incomplete"; |
| 47 | |
| 48 | /** |
| 49 | * The complete turn index of the installed snapshot, shared by the local |
| 50 | * controller and remote sessions. Paging the body changes what is mounted, not |
| 51 | * what exists, so this answers from one snapshot regardless of how much history |
| 52 | * the reader has loaded. |
| 53 | * |
| 54 | * Reads are fenced by tab generation and by snapshot identity: a response from a |
| 55 | * replaced session, a replaced snapshot, or a released tab can neither publish |
| 56 | * nor clear state. |
| 57 | */ |
| 58 | export class TranscriptOutlineStore { |
| 59 | private readonly views = new Map<string, TranscriptOutlineView>(); |
| 60 | private readonly listeners = new Map<string, Set<() => void>>(); |
| 61 | private readonly generations = new Map<string, number>(); |
| 62 | private readonly pending = new Map<string, Promise<void>>(); |
| 63 | private readonly readers = new Map<string, OutlineRead>(); |
| 64 | private readonly refreshers = new Map<string, () => Promise<void>>(); |
| 65 | |
| 66 | /** |
| 67 | * Bind a tab to the host that owns it. Local controllers and remote sessions |
| 68 | * share this index, so the owning hook registers the reader for its own tabs |
| 69 | * and a tab it never loaded stays in the legacy mode. |
| 70 | */ |
| 71 | register(tabId: string, read: OutlineRead, refresh?: () => Promise<void>): void { |
| 72 | this.readers.set(tabId, read); |
| 73 | if (refresh) this.refreshers.set(tabId, refresh); |
| 74 | } |
| 75 | |
| 76 | /** |
| 77 | * Have the owning session install a fresh snapshot, whatever the current view |
| 78 | * says. A navigation jump that hit a recycled cut needs this even when the |
| 79 | * outline itself still reads as ready, so it is not gated on the view's mode. |
| 80 | */ |
| 81 | async refresh(tabId: string): Promise<void> { |
| 82 | const refresh = this.refreshers.get(tabId); |
| 83 | if (!refresh) throw new Error("transcript snapshot refresh is unavailable"); |
| 84 | await refresh(); |
| 85 | // The cut notification starts outline synchronization without blocking the |
| 86 | // controller commit. A reader retry, however, must wait until the fresh |
| 87 | // identity is usable before it resolves its target again. |
| 88 | const pending = this.pending.get(tabId); |
| 89 | if (pending) await pending; |
| 90 | const view = this.views.get(tabId); |
| 91 | if (view?.mode === "error") throw new Error(view.error || "transcript outline refresh failed"); |
| 92 | } |
| 93 | |
| 94 | /** |
| 95 | * A user-initiated retry after a recycled cut. A stale id cannot be read |
| 96 | * again, so the owning session installs a fresh snapshot first and this index |
| 97 | * re-aligns with it through the ordinary cut notification. The body is only |
| 98 | * replaced by that explicit request, never as an automatic reaction. |
| 99 | */ |
| 100 | retry(tabId: string): Promise<void> { |
| 101 | const view = this.views.get(tabId); |
| 102 | if (view?.error && this.refreshers.has(tabId)) return this.refresh(tabId); |
| 103 | if (!view?.snapshotId) return Promise.resolve(); |
| 104 | return this.load(tabId, view.snapshotId); |
| 105 | } |
| 106 | |
| 107 | subscribe(tabId: string, listener: () => void): () => void { |
| 108 | let listeners = this.listeners.get(tabId); |
| 109 | if (!listeners) { listeners = new Set(); this.listeners.set(tabId, listeners); } |
| 110 | listeners.add(listener); |
| 111 | return () => { listeners.delete(listener); if (!listeners.size) this.listeners.delete(tabId); }; |
| 112 | } |
| 113 | |
| 114 | getView(tabId: string): TranscriptOutlineView { |
| 115 | return this.views.get(tabId) ?? LEGACY; |
| 116 | } |
| 117 | |
| 118 | /** Resolve an entry again after refreshing its snapshot. Message identity |
| 119 | * wins because an optimistic mounted key can differ from the durable record |
| 120 | * id learned later in the same app session. */ |
| 121 | resolve(tabId: string, target: TranscriptOutlineEntry): TranscriptOutlineEntry | undefined { |
| 122 | const entries = this.views.get(tabId)?.entries ?? EMPTY_ENTRIES; |
| 123 | if (target.messageId) { |
| 124 | const byMessage = entries.find(entry => entry.messageId === target.messageId); |
| 125 | if (byMessage) return byMessage; |
| 126 | } |
| 127 | return entries.find(entry => entry.id === target.id); |
| 128 | } |
| 129 | |
| 130 | /** Fence and hide a cut being replaced while preserving the owning host |
| 131 | * binding. A failed refresh can therefore be retried instead of degrading |
| 132 | * permanently to the legacy rail. */ |
| 133 | invalidate(tabId: string): void { |
| 134 | this.generations.set(tabId, (this.generations.get(tabId) ?? 0) + 1); |
| 135 | this.views.delete(tabId); |
| 136 | this.pending.delete(tabId); |
| 137 | this.publish(tabId); |
| 138 | } |
| 139 | |
| 140 | /** Drop a tab's index and fence every read still in flight for it. */ |
| 141 | release(tabId: string): void { |
| 142 | this.invalidate(tabId); |
| 143 | this.readers.delete(tabId); |
| 144 | this.refreshers.delete(tabId); |
| 145 | } |
| 146 | |
| 147 | /** |
| 148 | * Align the index with the installed snapshot. An unchanged snapshot reuses |
| 149 | * the current index instead of re-reading it, and an absent host or an absent |
| 150 | * snapshot stays in the legacy mode. |
| 151 | */ |
| 152 | sync(tabId: string, snapshotId: string | undefined): Promise<void> { |
| 153 | if (!snapshotId || !this.readers.has(tabId)) return Promise.resolve(); |
| 154 | const view = this.views.get(tabId); |
| 155 | if (view && view.snapshotId === snapshotId && view.mode !== "error") return this.pending.get(tabId) ?? Promise.resolve(); |
| 156 | return this.load(tabId, snapshotId); |
| 157 | } |
| 158 | |
| 159 | /** Retry after a failure, or after the capability was reported absent. */ |
| 160 | load(tabId: string, snapshotId: string): Promise<void> { |
| 161 | const read = this.readers.get(tabId); |
| 162 | if (!read) return Promise.resolve(); |
| 163 | const generation = (this.generations.get(tabId) ?? 0) + 1; |
| 164 | this.generations.set(tabId, generation); |
| 165 | this.set(tabId, { mode: "loading", snapshotId, entries: EMPTY_ENTRIES }); |
| 166 | const run = this.readAll(tabId, snapshotId, generation, read).finally(() => { |
| 167 | if (this.pending.get(tabId) === run) this.pending.delete(tabId); |
| 168 | }); |
| 169 | this.pending.set(tabId, run); |
| 170 | return run; |
| 171 | } |
| 172 | |
| 173 | private async readAll(tabId: string, snapshotId: string, generation: number, read: OutlineRead): Promise<void> { |
| 174 | const current = () => this.generations.get(tabId) === generation; |
| 175 | const entries: TranscriptOutlineEntry[] = []; |
| 176 | const seen = new Set<string>(); |
| 177 | let truncated = false; |
| 178 | try { |
| 179 | let offset = 0; |
| 180 | for (let pages = 0; ; pages++) { |
| 181 | if (pages >= MAX_OUTLINE_PAGES) { truncated = true; break; } |
| 182 | const page = await read(tabId, { snapshotId, offset }); |
| 183 | if (!current()) return; |
| 184 | if (page.stale) { |
| 185 | // The cut was recycled. Reporting it lets the caller install a fresh |
| 186 | // snapshot and resolve the target again; silently continuing would |
| 187 | // answer positions against a different revision. |
| 188 | this.set(tabId, { mode: "error", snapshotId, entries: EMPTY_ENTRIES, error: "outline snapshot expired" }); |
| 189 | return; |
| 190 | } |
| 191 | if (page.snapshotId !== snapshotId) { |
| 192 | this.set(tabId, { mode: "error", snapshotId, entries: EMPTY_ENTRIES, error: "outline snapshot changed" }); |
| 193 | return; |
| 194 | } |
| 195 | for (const entry of page.entries) { |
| 196 | // Identity is unique per turn; a duplicate would make the rail |
| 197 | // ambiguous and break keyed reconciliation. |
| 198 | if (seen.has(entry.id)) continue; |
| 199 | seen.add(entry.id); |
| 200 | entries.push(entry); |
| 201 | if (entries.length >= MAX_OUTLINE_ENTRIES) { truncated = true; break; } |
| 202 | } |
| 203 | if (truncated || page.done) break; |
| 204 | if (!Number.isSafeInteger(page.nextOffset) || page.nextOffset <= offset) { |
| 205 | this.set(tabId, { mode: "error", snapshotId, entries: EMPTY_ENTRIES, error: "outline cursor stalled" }); |
| 206 | return; |
| 207 | } |
| 208 | offset = page.nextOffset; |
| 209 | } |
| 210 | if (!current()) return; |
| 211 | entries.sort((left, right) => left.order - right.order); |
| 212 | this.set(tabId, { mode: "ready", snapshotId, entries, error: truncated ? TRUNCATED : undefined, truncated }); |
| 213 | } catch (error) { |
| 214 | if (!current()) return; |
| 215 | if (error instanceof OutlineUnsupported) { |
| 216 | // Keep any markers already known, and never report this as a network |
| 217 | // failure that the reader could retry. |
| 218 | this.set(tabId, { mode: "legacy", snapshotId: "", entries: EMPTY_ENTRIES }); |
| 219 | return; |
| 220 | } |
| 221 | this.set(tabId, { mode: "error", snapshotId, entries: EMPTY_ENTRIES, error: message(error) }); |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | private set(tabId: string, view: TranscriptOutlineView): void { |
| 226 | this.views.set(tabId, view); |
| 227 | this.publish(tabId); |
| 228 | } |
| 229 | |
| 230 | private publish(tabId: string): void { |
| 231 | for (const listener of [...(this.listeners.get(tabId) ?? [])]) listener(); |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | function message(error: unknown): string { |
| 236 | return error instanceof Error ? error.message : String(error); |
| 237 | } |
| 238 | |
| 239 | // Bridge-backed singleton, matching the transcript store: the owning session |
| 240 | // hook registers its own reader, and a tab it never loaded stays legacy. |
| 241 | let singleton: TranscriptOutlineStore | undefined; |
| 242 | |
| 243 | export function getTranscriptOutlineStore(): TranscriptOutlineStore { |
| 244 | singleton ??= new TranscriptOutlineStore(); |
| 245 | return singleton; |
| 246 | } |
| 247 | |
| 248 | /** Local tabs read the controller binding; absent means the host is older. */ |
| 249 | export function localOutlineRead(tabId: string, request: TranscriptOutlineRequest): Promise<TranscriptOutlinePage> { |
| 250 | const read = app.TranscriptOutlineForTab; |
| 251 | if (typeof read !== "function") return Promise.reject(new OutlineUnsupported("transcript outline is unavailable")); |
| 252 | return read(tabId, request).catch((error: unknown) => { throw unavailable(error); }); |
| 253 | } |
| 254 | |
| 255 | /** |
| 256 | * Remote tabs read the negotiated Serve route. The Go client already refuses |
| 257 | * the request when the capability was not advertised, so an unavailable |
| 258 | * projection is translated here rather than retried. |
| 259 | */ |
| 260 | export function remoteOutlineRead(tabId: string, request: TranscriptOutlineRequest): Promise<TranscriptOutlinePage> { |
| 261 | const read = app.RemoteTranscriptOutlineForTab; |
| 262 | if (typeof read !== "function") return Promise.reject(new OutlineUnsupported("remote transcript outline is unavailable")); |
| 263 | return read(tabId, request).catch((error: unknown) => { throw unavailable(error); }); |
| 264 | } |
| 265 | |
| 266 | /** |
| 267 | * The host answering "this controller has no outline projection" is a |
| 268 | * compatibility answer, not a failure: both transports must degrade to the |
| 269 | * loaded-turn rail rather than offer a retry that can never succeed. |
| 270 | */ |
| 271 | function unavailable(error: unknown): unknown { |
| 272 | return message(error).toLowerCase().includes("transcript projection is unavailable") |
| 273 | ? new OutlineUnsupported("transcript outline is unavailable") |
| 274 | : error; |
| 275 | } |
| 276 |