/** * @file cloneJobs.ts * @description The app-wide list of background clones (`GET /api/lanes/clone/jobs`), * shown by `components/BackgroundJobsDock.tsx`. The server keeps every job * across reloads and restarts, so this store is only a view of that list: * it is read on start, on window focus, after each start/retry/cancel, and * every 1.5 s while any job is queued or running. A job SEEN going from * active to finished in this page is announced once — a toast, the * `agenthub:clone-job` window event (open pages re-read what the clone * changed) and the starter's own `onDone` — and a done one then leaves the * dock by itself; a job that finished unseen (a closed tab, F5) stays until * dismissed. An older server answers a start synchronously: that answer is * announced the same way as a job that is already done. * @author Nguyễn Ngọc Trí Vĩ */ import { useSyncExternalStore } from "react"; import i18n from "i18next"; import { api, type CloneJob, type LaneSourcesView } from "./api"; import type { Lane } from "./types"; import { toast } from "./toast"; /** Window event carrying a finished `CloneJob` in `detail`. */ export const CLONE_JOB_EVENT = "agenthub:clone-job"; const POLL_MS = 1500; /** How long a done job the user watched stays in the dock after its toast. */ const DONE_LINGER_MS = 8000; export const isActiveJob = (j: Pick) => j.state === "queued" || j.state === "running"; type OnDone = (job: CloneJob, lane?: Lane) => void; type Creds = { username: string; password: string; remember?: boolean }; type WorkspaceBody = Omit[0], "background" | "progressToken">; type SourceBody = Omit[1], "background" | "progressToken">; let jobs: CloneJob[] = []; /** The last state seen per job, to tell a transition from a repeat. */ const known = new Map(); /** Jobs seen queued/running in this page: only their finish is announced. */ const watched = new Set(); /** Dismissed here: a poll already in flight must not bring them back. */ const hidden = new Set(); const callbacks = new Map(); const listeners = new Set<() => void>(); let timer: ReturnType | undefined; let started = false; let inflight: Promise | null = null; let legacyId = 0; const emit = () => listeners.forEach((l) => l()); function publish(next: CloneJob[]) { jobs = next.filter((j) => !hidden.has(j.id)); emit(); } function upsert(job: CloneJob) { publish([job, ...jobs.filter((j) => j.id !== job.id)]); } /** The user-facing reason a job did not finish. */ export function cloneJobError(job: CloneJob): string { const t = i18n.t.bind(i18n); const e = job.error; if (job.state === "cancelled") return t("lanes:cloneJobs.cancelled"); if (job.state === "interrupted" || e?.code === "EINTERRUPTED") return t("lanes:cloneJobs.interrupted"); const host = e?.auth?.host || job.url; if (e?.code === "ECREDENTIALS") return t("lanes:clone.loginNeeded", { host }); if (e?.code === "ESSHAUTH") return t("lanes:clone.sshRefused", { host }); return e?.message || t("lanes:cloneJobs.failed"); } function announce(job: CloneJob, lane?: Lane) { const t = i18n.t.bind(i18n); const path = job.result?.path || job.target; // A failure or a stop is told by its chip alone (reason + Retry): a toast // repeated the same long message on top of the chip, covering its buttons. if (job.state === "done") { toast.success( job.kind === "workspace" ? t("lanes:cloneJobs.doneWorkspace", { name: job.name, path, workspace: lane?.title || job.request?.title || job.name, }) : t("lanes:cloneJobs.doneSource", { name: job.name, path }) ); } window.dispatchEvent(new CustomEvent(CLONE_JOB_EVENT, { detail: job })); if (job.state === "done") { const cb = callbacks.get(job.id); callbacks.delete(job.id); try { cb?.(job, lane); } catch { /* a page's follow-up never breaks the store */ } } } function observe(list: CloneJob[]) { const finishedHere: CloneJob[] = []; for (const j of list) { const prev = known.get(j.id); known.set(j.id, j.state); if (isActiveJob(j)) { watched.add(j.id); continue; } if (!watched.has(j.id) || prev === j.state) continue; watched.delete(j.id); finishedHere.push(j); } for (const j of finishedHere) { announce(j); if (j.state === "done") setTimeout(() => void dismissCloneJob(j.id), DONE_LINGER_MS); } } function schedule(ms = POLL_MS) { if (timer) clearTimeout(timer); timer = setTimeout(() => { timer = undefined; void refreshCloneJobs(); }, ms); } /** Read the list now (shared while one read is in flight). */ export function refreshCloneJobs(): Promise { if (!inflight) { inflight = Promise.resolve() .then(() => api.lanes.cloneJobs()) .then((r) => { const list = Array.isArray(r?.jobs) ? r.jobs : []; observe(list); publish(list); }) // An older server (404) or a blip: keep what we had; a start polls again. .catch(() => undefined) .finally(() => { inflight = null; if (jobs.some(isActiveJob)) schedule(); }); } return inflight; } const onFocus = () => void refreshCloneJobs(); /** Start following the list (the dock calls this once, on mount). */ export function ensureCloneJobs(): void { if (started) return; started = true; void refreshCloneJobs(); window.addEventListener("focus", onFocus); } function track(job: CloneJob, onDone?: OnDone) { if (onDone) callbacks.set(job.id, onDone); if (isActiveJob(job)) watched.add(job.id); known.set(job.id, job.state); upsert(job); if (isActiveJob(job)) schedule(); else observe([job]); } /** The folder `git clone ` makes: the URL's last segment, no `.git`. */ const cloneName = (url: string) => url .replace(/\/+$/, "") .split(/[/:]/) .pop() ?.replace(/\.git$/, "") || url; /** An older server's synchronous answer, as a job that is already done. */ function legacyJob( kind: CloneJob["kind"], body: { url: string; name?: string; parent?: string; title?: string }, path: string | undefined, laneId: number | null ): CloneJob { path = path || ""; const name = body.name || path.split("/").pop() || cloneName(body.url); return { id: `legacy-${++legacyId}`, kind, state: "done", url: body.url, name, parent: body.parent ?? "", target: path, laneId: kind === "source" ? laneId : null, position: null, phase: null, label: null, percent: null, detail: null, error: null, result: { laneId, path }, createdAt: new Date().toISOString(), finishedAt: new Date().toISOString(), request: { url: body.url, parent: body.parent ?? "", name, title: body.title }, }; } /** * Start a clone in the background. Resolves with its job once the server * accepted it (a 4xx validation error rejects, for the form to show); * `onDone` runs when this page sees it finish. */ export async function startClone( spec: | { kind: "workspace"; body: WorkspaceBody } | { kind: "source"; laneId: number; body: SourceBody }, opts: { onDone?: OnDone } = {} ): Promise { if (spec.kind === "workspace") { const r = await api.lanes.clone({ ...spec.body, background: true }); if ("job" in r && r.job) { track(r.job, opts.onDone); return r.job; } const legacy = r as { lane: Lane; path: string }; const job = legacyJob("workspace", spec.body, legacy.path, legacy.lane?.id ?? null); callbacks.set(job.id, opts.onDone ?? (() => {})); announce(job, legacy.lane); return job; } const r = await api.lanes.cloneFolder(spec.laneId, { ...spec.body, background: true }); if ("job" in r && r.job) { track(r.job, opts.onDone); return r.job; } const legacy = r as LaneSourcesView & { path: string }; const job = legacyJob("source", spec.body, legacy.path, spec.laneId); callbacks.set(job.id, opts.onDone ?? (() => {})); announce(job); return job; } /** Retry a failed / interrupted / cancelled job, optionally with a login. */ export async function retryCloneJob(id: string, credentials?: Creds): Promise { const { job } = await api.lanes.retryCloneJob(id, credentials); const cb = callbacks.get(id); callbacks.delete(id); hidden.add(id); publish(jobs.filter((j) => j.id !== id)); track(job, cb); return job; } /** Stop a queued or running clone. */ export async function cancelCloneJob(id: string): Promise { try { const { job } = await api.lanes.cancelCloneJob(id); if (job) { observe([job]); upsert(job); } } catch (e) { const err = e as { status?: number; code?: string }; if (err.code === "EDONE") { void refreshCloneJobs(); return; } if (err.status === 404) { toast.error(i18n.t("lanes:cloneJobs.stopUnsupported")); return; } toast.error(e); } } /** Forget a finished job here and on the server. */ export async function dismissCloneJob(id: string): Promise { hidden.add(id); callbacks.delete(id); watched.delete(id); publish(jobs.filter((j) => j.id !== id)); await Promise.resolve() .then(() => api.lanes.dismissCloneJob(id)) .catch(() => undefined); } function subscribe(l: () => void) { listeners.add(l); return () => { listeners.delete(l); }; } /** The jobs to show, newest first. */ export function useCloneJobs(): CloneJob[] { return useSyncExternalStore(subscribe, () => jobs); } /** Tests only: forget everything, as a fresh page load would. */ export function resetCloneJobsForTests(): void { if (timer) clearTimeout(timer); timer = undefined; jobs = []; known.clear(); watched.clear(); hidden.clear(); callbacks.clear(); inflight = null; started = false; window.removeEventListener("focus", onFocus); emit(); }