225 lines
8.1 KiB
TypeScript
225 lines
8.1 KiB
TypeScript
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<F> = {
|
||
|
|
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<F, J extends SemanticJob>(
|
||
|
|
jobs: readonly J[],
|
||
|
|
features: readonly AnnotationRetryFeature<F>[],
|
||
|
|
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<string>,
|
||
|
|
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<T extends SemanticJob>(
|
||
|
|
jobs: readonly T[],
|
||
|
|
matches: (job: T) => boolean,
|
||
|
|
) {
|
||
|
|
const scopes = new Map<string, string[]>();
|
||
|
|
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<string>();
|
||
|
|
const entries = new Map<string, T>();
|
||
|
|
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<T extends PreparationScope>(
|
||
|
|
plans: T[],
|
||
|
|
visible: Range,
|
||
|
|
occupied: number,
|
||
|
|
submitted: ReadonlySet<string>,
|
||
|
|
cached: ReadonlySet<string>,
|
||
|
|
) {
|
||
|
|
const groups = new Map<string, T[]>(),
|
||
|
|
seen = new Set<string>();
|
||
|
|
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<typeof setTimeout> | 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);
|
||
|
|
}
|
||
|
|
}
|