import { sameCacheConfiguration } from "./cache.js"; import { intersects, type Range } from "./canvas/types.js"; export type Feature = { processor: string; generation: string; scope: string; reveal: string; configRevision: number; enabled: boolean; }; export type Job = { sourceRevision: string; featureRevision: number; start: number; end: number; status: string }; export function processorMatches(job: { key: string }, revision: string, previousRevisions: readonly string[] = []) { return [revision, ...previousRevisions].some((revision) => job.key.startsWith(revision + ":")); } export function sameScope( job: Job & { configuration: string }, sourceRevision: string, configuration: string, scope: Range, ) { return ( job.sourceRevision === sourceRevision && sameCacheConfiguration(job.configuration, configuration) && job.start === scope.start && job.end === scope.end ); } type SemanticJob = { configuration: string; data: { key: string; kind: string; status: string; sourceRevision: string; start: number; end: number; createdAt: string; }; }; export type AnnotationRetryFeature = { feature: F; configuration: string; revision: string; aliases?: readonly string[]; styles: readonly string[]; }; /** Nearby controls describe the newest attempt for a semantic scope, not its * history. Shared renderer instances and verified processor aliases must not * multiply controls. Sidebar computations already have their own controls. */ export function selectAnnotationRetryJobs( jobs: readonly J[], features: readonly AnnotationRetryFeature[], sourceRevision: string, visible: Range, ) { const scopes: { configuration: string; start: number; end: number; revision: string }[] = []; const result: { feature: F; job: J }[] = []; if (visible.end <= visible.start) return result; for (const job of [...jobs].sort((a, b) => b.data.createdAt.localeCompare(a.data.createdAt))) { if (job.data.kind !== "annotation" || job.data.sourceRevision !== sourceRevision || !intersects(job.data, visible)) continue; const matching = features.filter( (feature) => feature.revision && processorMatches(job.data, feature.revision, feature.aliases) && sameCacheConfiguration(feature.configuration, job.configuration), ); const feature = matching.find((feature) => feature.styles.some((style) => style !== "sidebar")); if (!feature || matching.some((feature) => feature.styles.includes("sidebar"))) continue; if ( scopes.some( (scope) => scope.revision === feature.revision && scope.start === job.data.start && scope.end === job.data.end && sameCacheConfiguration(scope.configuration, job.configuration), ) ) continue; scopes.push({ configuration: job.configuration, start: job.data.start, end: job.data.end, revision: feature.revision, }); if (["failed", "skipped", "uncertain"].includes(job.data.status)) result.push({ feature: feature.feature, job }); } return result; } export function annotationRetryStatus(status: string, error: string) { if (error.includes("AGENT_SETUP_UNAVAILABLE")) return "Setup was unavailable. Try again when connected."; if (status === "skipped") return "Not prepared. Try again when ready."; if (status === "uncertain") return "Preparation could not be confirmed."; return "Could not prepare annotations."; } /** Only an explicit control invokes this dispatcher. Guard double clicks until * the public operation settles; neither selection nor a status update retries. */ export function explicitAnnotationRetry( key: string, inFlight: Set, dispatch: (settled: () => void) => boolean, changed: () => void, ) { if (inFlight.has(key)) return false; inFlight.add(key); changed(); const settled = () => { inFlight.delete(key); changed(); }; try { if (dispatch(settled)) return true; settled(); return false; } catch (error) { settled(); throw error; } } /** A successful task replaces its whole semantic scope, including omitted * annotations. Deduplicating individual items resurrects removed old output. */ export function selectSuccessfulAnnotationJobs( jobs: readonly T[], matches: (job: T) => boolean, ) { const scopes = new Map(); return jobs .filter((job) => job.data.kind === "annotation" && job.data.status === "ready" && matches(job)) .sort((a, b) => b.data.createdAt.localeCompare(a.data.createdAt)) .filter((job) => { const key = JSON.stringify([job.data.sourceRevision, job.data.start, job.data.end]); const configurations = scopes.get(key) ?? []; if (configurations.some((configuration) => sameCacheConfiguration(configuration, job.configuration))) return false; configurations.push(job.configuration); scopes.set(key, configurations); return true; }); } /** Index pointers are authoritative even if an older successful attempt was * explicitly restored. Keep attempt history for progress/errors, not as a * second successful result for the same indexed key. */ export function mergeCachedAnnotationEntries< T extends { node: { _camino: { ref: { object: string } }; data: { key: string; kind: string; status: string } }; }, >(cached: readonly T[], history: readonly T[]) { const keys = new Set(); const entries = new Map(); for (const entry of cached) { if (keys.has(entry.node.data.key)) continue; keys.add(entry.node.data.key); entries.set(entry.node._camino.ref.object, entry); } for (const entry of history) { if (entry.node.data.kind === "annotation" && entry.node.data.status === "ready" && keys.has(entry.node.data.key)) continue; if (!entries.has(entry.node._camino.ref.object)) entries.set(entry.node._camino.ref.object, entry); } return [...entries.values()]; } export type PreparationScope = { key: string; configuration: string; range: Range; local: boolean }; /** Independent scopes may share the two model slots. Nearby features take turns * at equal distance; completed, failed and already-submitted work is excluded. */ export function preparationQueue( plans: T[], visible: Range, occupied: number, submitted: ReadonlySet, cached: ReadonlySet, ) { const groups = new Map(), seen = new Set(); for (const plan of plans) { if (plan.range.end <= plan.range.start || seen.has(plan.key) || submitted.has(plan.key) || cached.has(plan.key)) continue; seen.add(plan.key); const group = groups.get(plan.configuration) ?? []; group.push(plan); groups.set(plan.configuration, group); } const waiting: T[] = []; for (let index = 0; ; index++) { let found = false; for (const group of groups.values()) if (group[index]) { waiting.push(group[index]); found = true; } if (!found) break; } const distance = ({ range }: T) => range.end <= visible.start ? visible.start - range.end + 1 : range.start >= visible.end ? range.start - visible.end + 1 : 0; waiting.sort((a, b) => distance(a) - distance(b)); let slots = Math.max(0, 2 - occupied); const admit = waiting.filter((plan) => plan.local || slots-- > 0); return { waiting, admit }; } /** Intent scheduler for heavy hover work. It never owns the durable model job: * leaving hover cancels intent; an admitted result remains useful in its cache. * Local/section/cached reveal happens outside this scheduler, immediately. */ export class HoverIntent { private timer: ReturnType | null = null; private generation = 0; constructor(private readonly delay = 420) {} cancel() { this.generation++; if (this.timer !== null) clearTimeout(this.timer); this.timer = null; } schedule(callback: () => void) { this.cancel(); const generation = this.generation; this.timer = setTimeout(() => { this.timer = null; if (generation === this.generation) callback(); }, this.delay); } }