2b26fe1efa
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.
314 lines
9.9 KiB
TypeScript
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();
|
|
}
|