Files
AgentHub/client/src/lib/uploadJobs.ts
T
nntrivi2001 12ea853170 feat(files): uploads survive a dashboard restart
An upload through the File Explorer is now sent in 8 MB parts that the
server stages on the host (<data dir>/upload-staging/<user>/<id>/,
server/lib/upload-staging.js) and writes only once every byte is there,
through the same upload/uploadBatch code and guards (?staged=<id>). A
restart, an update or a crash pauses it: the browser waits up to 3 min,
asks how much arrived (GET /fs/upload-part) and sends the rest; the dock
says the dashboard is restarting meanwhile. A commit's answer is stored
and replayed, never written twice; a refused one (EEXISTS) keeps the
bytes for a retry with another conflict policy.

A file uploaded into a member's folder is now written to a hidden temp
name and moved onto its name only when whole: write-as cannot tell a
dropped connection from the end of a file, and `replace` left a truncated
file under the real name when the server died mid-upload.

New: GET|POST|DELETE /api/{lanes/:id,homes/:who}/fs/upload-part; 409
EOFFSET/EINCOMPLETE/EBUSY, 429 ETOOMANY. Verified on a sandbox server:
SIGTERM mid-upload of a 60 MB file in a real browser, resumed and
sha256-identical.
2026-11-02 16:12:57 +07:00

173 lines
5.3 KiB
TypeScript

/**
* @file uploadJobs.ts
* @description The app-wide list of File Explorer uploads, shown by
* `components/BackgroundJobsDock.tsx` so an upload's progress outlives the
* Files page. The upload loop itself stays in `FileExplorer.tsx`; it reports
* here (`beginUpload` → `update` → `end`) and checks the job's AbortSignal,
* which **Stop** (`stopUpload`) fires. The bytes live in the browser, so an
* upload cannot survive a reload: while one runs, a small record of it is
* kept in localStorage (refreshed every ~2 s), and the next page load shows
* a record that stopped being refreshed as an interrupted upload until it is
* dismissed. A fresh record belongs to another open tab and is left alone.
* A dashboard RESTART is not an interruption: the upload is sent in parts
* (`api.files.uploadStaged`) and the job shows `waiting` until it resumes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useSyncExternalStore } from "react";
export interface UploadJob {
id: string;
/** Where the files go, as the user reads it ("workspace/folder"). */
folder: string;
/** The file (or batch) being sent now. */
name: string;
done: number;
total: number;
sent: number;
bytes: number;
state: "running" | "stopping" | "interrupted";
/** The dashboard is away (a restart): the upload waits and then resumes. */
waiting?: boolean;
}
type Stored = UploadJob & { beat: number };
const KEY = "agenthub.uploads.inflight";
const BEAT_MS = 2000;
/** A running record older than this is from a page that is gone. */
const STALE_MS = 6000;
const listeners = new Set<() => void>();
const controllers = new Map<string, AbortController>();
/** Ids this page owns in storage (its own uploads and the interrupted ones it shows). */
const mine = new Set<string>();
let uploads: UploadJob[] = [];
let beat: ReturnType<typeof setInterval> | undefined;
let lastWrite = 0;
const emit = () => listeners.forEach((l) => l());
function readStored(): Stored[] {
try {
const v: unknown = JSON.parse(localStorage.getItem(KEY) || "[]");
return Array.isArray(v) ? (v as Stored[]).filter((r) => r && typeof r.id === "string") : [];
} catch {
return [];
}
}
function persist() {
lastWrite = Date.now();
try {
const others = readStored().filter((r) => !mine.has(r.id));
const own = uploads.map((u) => ({ ...u, beat: lastWrite }));
const all = [...others, ...own];
if (all.length) localStorage.setItem(KEY, JSON.stringify(all));
else localStorage.removeItem(KEY);
} catch {
/* storage off: the dock still works for this page */
}
}
/** Load what a previous page left behind: a stale running record = interrupted. */
function loadInterrupted() {
const now = Date.now();
const left = readStored().filter(
(r) => r.state === "interrupted" || now - (Number(r.beat) || 0) > STALE_MS
);
for (const r of left) mine.add(r.id);
uploads = left.map((r) => ({
id: r.id,
folder: String(r.folder ?? ""),
name: String(r.name ?? ""),
done: Number(r.done) || 0,
total: Number(r.total) || 0,
sent: Number(r.sent) || 0,
bytes: Number(r.bytes) || 0,
state: "interrupted",
}));
if (left.length) persist();
}
loadInterrupted();
function set(id: string, patch: Partial<UploadJob>) {
uploads = uploads.map((u) => (u.id === id ? { ...u, ...patch } : u));
emit();
if (Date.now() - lastWrite > 1000) persist();
}
function heartbeat() {
const running = uploads.some((u) => u.state !== "interrupted");
if (running && !beat) beat = setInterval(persist, BEAT_MS);
if (!running && beat) {
clearInterval(beat);
beat = undefined;
}
}
/** Register an upload; the loop reports through the handle and checks `signal`. */
export function beginUpload(init: { folder: string; total: number; bytes: number }) {
const id = `up-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const ctrl = new AbortController();
controllers.set(id, ctrl);
mine.add(id);
uploads = [...uploads, { id, name: "", done: 0, sent: 0, state: "running", ...init }];
emit();
persist();
heartbeat();
return {
id,
signal: ctrl.signal,
update: (patch: Partial<Pick<UploadJob, "name" | "done" | "sent" | "waiting">>) =>
set(id, patch),
end: () => {
controllers.delete(id);
uploads = uploads.filter((u) => u.id !== id);
emit();
persist();
mine.delete(id);
heartbeat();
},
};
}
/** Stop a running upload: the request in flight is dropped, the loop ends. */
export function stopUpload(id: string): void {
const ctrl = controllers.get(id);
if (!ctrl) return;
set(id, { state: "stopping" });
ctrl.abort();
}
/** Forget an interrupted upload's record. */
export function dismissUpload(id: string): void {
uploads = uploads.filter((u) => u.id !== id);
emit();
persist();
mine.delete(id);
}
function subscribe(l: () => void) {
listeners.add(l);
return () => {
listeners.delete(l);
};
}
export function useUploadJobs(): UploadJob[] {
return useSyncExternalStore(subscribe, () => uploads);
}
/** Tests only: re-read storage as a fresh page load would. */
export function reloadUploadsForTests(): void {
for (const c of controllers.values()) c.abort();
controllers.clear();
mine.clear();
if (beat) clearInterval(beat);
beat = undefined;
lastWrite = 0;
loadInterrupted();
emit();
}