| 1 | import type { SessionOperationAuthority, SessionResource, useSessionOperations } from "./useSessionOperations"; |
| 2 | |
| 3 | export type PendingRevisionInput = { |
| 4 | visible: SessionResource; resources: readonly SessionResource[]; running: boolean; ready: boolean; |
| 5 | operations: ReturnType<typeof useSessionOperations>; |
| 6 | send(target: SessionResource, text: string, authority: SessionOperationAuthority): Promise<void>; report(error: unknown): void; |
| 7 | }; |
| 8 | type Committed = { epoch: number; input: PendingRevisionInput }; |
| 9 | type Entry = { target: SessionResource; text: string; failedAt?: number; failed?: boolean }; |
| 10 | const key = (target: SessionResource) => JSON.stringify([target.tabId, target.sessionKey]); |
| 11 | |
| 12 | async function deliver(entry: Entry, committed: Committed) { |
| 13 | return committed.input.operations(entry.target, "plan-revision", { entry, send: committed.input.send }, async ({ entry, send }, authority) => { |
| 14 | authority.checkpoint(); |
| 15 | try { await send(entry.target, entry.text, authority); } catch (error) { |
| 16 | authority.checkpoint(); |
| 17 | // Resource failure retention is independent of permission to show error UI. |
| 18 | entry.failed = true; |
| 19 | throw error; |
| 20 | } |
| 21 | authority.checkpoint(); |
| 22 | }); |
| 23 | } |
| 24 | |
| 25 | /** Latest revision per source; only an identical active request can release its slot. */ |
| 26 | export function createPendingRevisionOwner(read: () => Committed | undefined) { |
| 27 | const queued = new Map<string, Entry>(); |
| 28 | const active = new Map<string, Entry>(); |
| 29 | let eligibility = "", eligibilityRevision = 0; |
| 30 | const pump = () => { |
| 31 | const committed = read(); |
| 32 | if (!committed) return; |
| 33 | const nextEligibility = JSON.stringify([key(committed.input.visible), committed.input.running, committed.input.ready]); |
| 34 | if (eligibility !== nextEligibility) { eligibility = nextEligibility; eligibilityRevision++; } |
| 35 | const valid = new Set(committed.input.resources.map(key)); |
| 36 | for (const id of queued.keys()) if (!valid.has(id)) queued.delete(id); |
| 37 | for (const id of active.keys()) if (!valid.has(id)) active.delete(id); |
| 38 | const id = key(committed.input.visible), entry = queued.get(id); |
| 39 | if (!committed.input.ready || committed.input.running || !entry || entry.failedAt === eligibilityRevision || active.has(id)) return; |
| 40 | entry.failed = false; |
| 41 | active.set(id, entry); |
| 42 | void deliver(entry, committed).then(outcome => { |
| 43 | if (read()?.epoch !== committed.epoch || active.get(id) !== entry) return; |
| 44 | if (entry.failed) { |
| 45 | // Keep the user's revision, but only a later source activation/idle |
| 46 | // transition (or a new revision) may retry it, never unrelated renders. |
| 47 | entry.failedAt = eligibilityRevision; |
| 48 | if (outcome.status === "failed") read()?.input.report(outcome.error); |
| 49 | } else if (queued.get(id) === entry) queued.delete(id); |
| 50 | }).finally(() => { |
| 51 | if (read()?.epoch !== committed.epoch || active.get(id) !== entry) return; |
| 52 | active.delete(id); |
| 53 | // A replacement revision is a new request, not a retry of the old one. |
| 54 | pump(); |
| 55 | }); |
| 56 | }; |
| 57 | return { |
| 58 | remember(tabId: string, text: string) { |
| 59 | const committed = read(); |
| 60 | const target = committed?.input.resources.find(resource => resource.tabId === tabId); |
| 61 | if (!target || !text) return; |
| 62 | queued.set(key(target), { target, text }); |
| 63 | pump(); |
| 64 | }, |
| 65 | pump, |
| 66 | dispose() { queued.clear(); active.clear(); }, |
| 67 | }; |
| 68 | } |
| 69 |