Files
AgentHub/client/src/lib/cloneJobs.ts
T
nntrivi2001 2b26fe1efa fix(jobs): a failed clone's chip is readable and clickable
A failed or stopped clone also raised a toast on the same corner, a layer
above the dock, so it covered the chip's x / Retry / Stop and repeated the
same long message; the chip now says it alone, and every toast stacks above
the dock (--jobs-dock-h). A URL with no space to break at (user@host:/PATH)
wraps inside the chip and inside a toast instead of running off the screen.
Open Settings -> Git is a router link; a plain <a href> reloaded the app.
2026-11-02 11:26:48 +07:00

314 lines
9.9 KiB
TypeScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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<CloneJob, "state">) =>
j.state === "queued" || j.state === "running";
type OnDone = (job: CloneJob, lane?: Lane) => void;
type Creds = { username: string; password: string; remember?: boolean };
type WorkspaceBody = Omit<Parameters<typeof api.lanes.clone>[0], "background" | "progressToken">;
type SourceBody = Omit<Parameters<typeof api.lanes.cloneFolder>[1], "background" | "progressToken">;
let jobs: CloneJob[] = [];
/** The last state seen per job, to tell a transition from a repeat. */
const known = new Map<string, CloneJob["state"]>();
/** Jobs seen queued/running in this page: only their finish is announced. */
const watched = new Set<string>();
/** Dismissed here: a poll already in flight must not bring them back. */
const hidden = new Set<string>();
const callbacks = new Map<string, OnDone>();
const listeners = new Set<() => void>();
let timer: ReturnType<typeof setTimeout> | undefined;
let started = false;
let inflight: Promise<void> | 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<CloneJob>(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<void> {
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 <url>` 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<CloneJob> {
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<CloneJob> {
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<void> {
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<void> {
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();
}