7a88b74016
- Artifacts hub: upload a file from the computer; make a skill by uploading a Markdown file, files, a folder or a .zip/.skill, or by writing it in the dialog; add plugins by picking from the list a marketplace link answers (POST /api/artifacts/plugin/inspect); Settings → AI tools → Artifacts Hub. - Console tabs are combos of up to four split panes (ConsoleTabs, PaneGrid). - Accounts: an own avatar (PUT/DELETE /api/me/avatar, raster only, ≤ 1 MB, <data dir>/avatars/), shown with name and role at the sidebar's foot and on the workspace owner groups (GET /api/users/:id/avatar, owner_avatar_at). - Users page: per-user storage (GET /api/users/storage, measured in a worker) and account-pool spend/tokens per account (server/lib/pool-usage.js). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
173 lines
5.4 KiB
JavaScript
173 lines
5.4 KiB
JavaScript
/**
|
|
* @file user-storage.js
|
|
* @description How much disk each server-mode user's HOME folder takes
|
|
* (`homes.homeOf`: their own Linux home under S3, their folder under
|
|
* AGENTHUB_USERS_ROOT otherwise) — the Users page's Storage column
|
|
* (`GET /api/users/storage`). A home is walked in a worker thread, never on
|
|
* the event loop (a member decides how many files their home holds), one home
|
|
* at a time, under a time limit (`AGENTHUB_STORAGE_TIMEOUT_MS`, default 120 s
|
|
* — past it the number is a lower bound, `partial`). The walk is `du`-like and
|
|
* read-only: `lstat` only (no file is opened, a symlink is never followed),
|
|
* stays on the home's file system, counts allocated blocks (a sparse file
|
|
* costs what it really uses) and a hard-linked inode once. Results are cached
|
|
* in memory for `FRESH_MS`; a read returns the cache at once and measures the
|
|
* stale homes in the background. A user whose "home" is only the host
|
|
* account's own (an admin with no folder of their own) is not measured.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { Worker, isMainThread, workerData, parentPort } = require("node:worker_threads");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
const FRESH_MS = 15 * 60_000;
|
|
|
|
/** Walk `root`: `{ bytes, files, dirs, partial }`. Runs in the worker. */
|
|
function walk(root, deadline) {
|
|
const out = { bytes: 0, files: 0, dirs: 0, partial: false };
|
|
let top;
|
|
try {
|
|
top = fs.lstatSync(root, { bigint: false });
|
|
} catch {
|
|
return { ...out, partial: true };
|
|
}
|
|
if (!top.isDirectory()) return out;
|
|
const seen = new Set(); // dev:ino of hard-linked files already counted
|
|
const stack = [root];
|
|
while (stack.length) {
|
|
if (Date.now() > deadline) {
|
|
out.partial = true;
|
|
break;
|
|
}
|
|
const dir = stack.pop();
|
|
let names;
|
|
try {
|
|
names = fs.readdirSync(dir);
|
|
} catch {
|
|
out.partial = true; // unreadable (no CAP_DAC_READ_SEARCH, a race): a lower bound
|
|
continue;
|
|
}
|
|
for (const name of names) {
|
|
const p = path.join(dir, name);
|
|
let st;
|
|
try {
|
|
st = fs.lstatSync(p);
|
|
} catch {
|
|
continue; // gone meanwhile
|
|
}
|
|
if (st.nlink > 1 && !st.isDirectory()) {
|
|
const key = `${st.dev}:${st.ino}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
}
|
|
out.bytes += st.blocks * 512;
|
|
if (st.isDirectory()) {
|
|
out.dirs += 1;
|
|
if (st.dev === top.dev) stack.push(p); // one file system, like du -x
|
|
} else {
|
|
out.files += 1;
|
|
}
|
|
}
|
|
}
|
|
out.bytes += top.blocks * 512;
|
|
return out;
|
|
}
|
|
|
|
if (!isMainThread && workerData?.agenthubStorageWorker) {
|
|
const { dir, timeoutMs } = workerData;
|
|
parentPort.postMessage(walk(dir, Date.now() + timeoutMs));
|
|
}
|
|
|
|
function timeoutMs() {
|
|
return Number(process.env.AGENTHUB_STORAGE_TIMEOUT_MS) || 120_000;
|
|
}
|
|
|
|
/** Measure one folder in a fresh worker; never rejects. */
|
|
function measure(dir) {
|
|
return new Promise((resolve) => {
|
|
let w;
|
|
const fail = () => resolve({ bytes: 0, files: 0, dirs: 0, partial: true });
|
|
try {
|
|
w = new Worker(__filename, {
|
|
workerData: { agenthubStorageWorker: true, dir, timeoutMs: timeoutMs() },
|
|
});
|
|
} catch {
|
|
return fail();
|
|
}
|
|
// The walk stops itself at the deadline; this only catches a worker stuck
|
|
// inside one syscall (a hung network mount).
|
|
const kill = setTimeout(() => {
|
|
w.terminate().catch(() => {});
|
|
fail();
|
|
}, timeoutMs() + 30_000);
|
|
kill.unref?.();
|
|
w.once("message", (r) => {
|
|
clearTimeout(kill);
|
|
resolve(r);
|
|
w.terminate().catch(() => {});
|
|
});
|
|
w.once("error", () => {
|
|
clearTimeout(kill);
|
|
fail();
|
|
});
|
|
});
|
|
}
|
|
|
|
const cache = new Map(); // dir → { bytes, files, dirs, partial, measuredAt }
|
|
const queue = []; // dirs waiting to be measured
|
|
let running = null; // dir being measured
|
|
|
|
function pump() {
|
|
if (running || !queue.length) return;
|
|
running = queue.shift();
|
|
const dir = running;
|
|
measure(dir).then((r) => {
|
|
cache.set(dir, { ...r, measuredAt: new Date().toISOString() });
|
|
running = null;
|
|
pump();
|
|
});
|
|
}
|
|
|
|
function enqueue(dir) {
|
|
if (running === dir || queue.includes(dir)) return;
|
|
queue.push(dir);
|
|
pump();
|
|
}
|
|
|
|
/**
|
|
* The storage of each of `users` (rows from `auth.listUsers()`):
|
|
* `{ storage: { [id]: { bytes, files, partial, measuredAt } | null }, measuring }`.
|
|
* `null` = nothing to measure (no home of their own) or not measured yet —
|
|
* `measuring` says whether more is coming. `refresh` re-measures every home.
|
|
*/
|
|
function storageFor(users, { refresh = false } = {}) {
|
|
const os = require("node:os");
|
|
const { homeOf } = require("./homes");
|
|
const hostHome = path.resolve(os.homedir());
|
|
const storage = {};
|
|
let measuring = false;
|
|
for (const u of users) {
|
|
let dir = null;
|
|
try {
|
|
dir = homeOf(u);
|
|
} catch {
|
|
dir = null;
|
|
}
|
|
if (!dir || path.resolve(dir) === hostHome) {
|
|
storage[u.id] = null;
|
|
continue;
|
|
}
|
|
dir = path.resolve(dir);
|
|
const hit = cache.get(dir);
|
|
const stale = !hit || refresh || Date.now() - Date.parse(hit.measuredAt) > FRESH_MS;
|
|
if (stale) enqueue(dir);
|
|
if (running === dir || queue.includes(dir)) measuring = true;
|
|
storage[u.id] = hit
|
|
? { bytes: hit.bytes, files: hit.files, partial: hit.partial, measuredAt: hit.measuredAt }
|
|
: null;
|
|
}
|
|
return { storage, measuring };
|
|
}
|
|
|
|
module.exports = { storageFor, walk, _cache: cache };
|