evOo-local/packages/core/src/computer-updates.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

104 lines
3.3 KiB
TypeScript

import { COMPUTER_UPDATE_STAGES, type ComputerUpdate } from "@rakazo/contracts";
/** Shared polling and local presentation state; the server owns operation lifetime. */
export function createComputerUpdates(client: {
list: () => Promise<ComputerUpdate[]>;
start: (botId: string, action: "update" | "recover") => Promise<ComputerUpdate>;
dismiss: (id: string) => Promise<unknown>;
releaseInterrupted: (id: string) => Promise<unknown>;
}) {
let state: { updates: ComputerUpdate[]; openId: string | null } = { updates: [], openId: null };
const listeners = new Set<() => void>();
let timer: ReturnType<typeof setTimeout> | undefined;
let generation = 0;
let revision = 0;
const emit = () => {
for (const listener of listeners) listener();
};
const poll = async (epoch: number) => {
const stamp = revision;
try {
const updates = await client.list();
if (epoch !== generation || stamp !== revision) return;
if (JSON.stringify(state.updates) === JSON.stringify(updates)) return;
state = {
updates,
openId: updates.some((item) => item.id === state.openId) ? state.openId : null,
};
emit();
} catch {
/* Keep the last known operation through transient connection failures. */
} finally {
if (epoch === generation) {
clearTimeout(timer);
timer = setTimeout(() => void poll(epoch), state.updates.length ? 1500 : 15000);
}
}
};
return {
getSnapshot: () => state,
subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
watch() {
const epoch = ++generation;
void poll(epoch);
return () => {
generation++;
clearTimeout(timer);
state = { updates: [], openId: null };
};
},
open(id: string | null) {
state = { ...state, openId: id };
emit();
},
async start(botId: string, action: "update" | "recover" = "update") {
const epoch = generation;
const update = await client.start(botId, action);
if (epoch !== generation) return;
revision++;
state = {
updates: [update, ...state.updates.filter((item) => item.id !== update.id)],
openId: update.id,
};
emit();
clearTimeout(timer);
timer = setTimeout(() => void poll(epoch), 1500);
},
async releaseInterrupted(id: string) {
const epoch = generation;
await client.releaseInterrupted(id);
if (epoch !== generation) return;
revision++;
await poll(epoch);
},
async dismiss(id: string) {
const epoch = generation;
await client.dismiss(id);
if (epoch !== generation) return;
revision++;
state = {
updates: state.updates.filter((item) => item.id !== id),
openId: state.openId === id ? null : state.openId,
};
emit();
},
};
}
export function computerUpdateStages(
action: ComputerUpdate["action"],
): readonly ComputerUpdate["stage"][] {
return action === "recover"
? COMPUTER_UPDATE_STAGES.filter((stage) => stage !== "saving")
: COMPUTER_UPDATE_STAGES;
}
export function computerUpdateNeedsAttention(update: ComputerUpdate): boolean {
return update.status === "failed" || update.status === "interrupted";
}