feat: v2.8.0 — Artifacts uploads and plugin links, console tab combos, account avatars
- 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>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* @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 };
|
||||
Reference in New Issue
Block a user