chore: v2.8.5 — Trash opens on one folder per user, server setup installs S3

- Files → Trash: ONE sidebar entry. An admin's opens on a folder per user's
  Trash (My Trash first, item counts from GET /api/homes/trash, an unreadable
  one showing its error); a folder opens that user's Trash (path bar
  Trash › user, Up back to the folders). An admin may restore/purge in
  another user's Trash (audited); purging asks first. The merged "All users"
  list is gone from the UI.
- A home's Trash is its user's whole recycle bin (home store + host store,
  items tagged `store`).
- `agenthub server setup` installs one Linux user per account on Linux by
  default (`--no-system` opts out); install-system messages drop `sudo`.
- Settings → Git: the author email suggests <username>@<AGENTHUB_EMAIL_DOMAIN>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 16:02:11 +07:00
parent 09efb089cf
commit 758609775f
27 changed files with 839 additions and 107 deletions
+97 -7
View File
@@ -2,7 +2,8 @@
* @file The workspace File Explorer (`/api/lanes/:id/fs/*`): list a folder,
* make a folder or an empty file, rename, move and copy (within a workspace
* or into another of the same account), delete into a per-account TRASH that
* can be restored (kept TRASH_DAYS), upload a file streamed from the request
* can be restored (kept TRASH_DAYS) — and, for `/api/homes`, one recycle bin
* per USER over every store their deletes went to — upload a file streamed from the request
* into any (new) sub-folder, and download files or folders as one streamed zip.
*
* Every path is the read path's own (git-diff.js): `lanePath` (lexical, no
@@ -17,7 +18,7 @@
* Writes run as the folder's ACCOUNT (run-as.js): a member's through
* `write-as` (sudo, as them — the host never writes in their home), the
* host's directly. A move/copy/trash in a member's folder needs write-as's
* `move`/`copy` (`EUPGRADE` until `sudo agenthub server install-system` has
* `move`/`copy` (`EUPGRADE` until `agenthub server install-system` has
* installed it). A copy or move never crosses accounts (`EFOREIGN`).
*
* A name that is taken is never silently replaced: `conflict` decides —
@@ -140,7 +141,7 @@ function needMove(acct) {
if (acct && !runAs.writeAsCanMove()) {
throw bad(
"EUPGRADE",
"moving or copying in a member's folder needs the updated helper: run `sudo agenthub server install-system` once"
"moving or copying in a member's folder needs the updated helper: run `agenthub server install-system` once"
);
}
}
@@ -468,12 +469,17 @@ async function dropTrash(acct, idDir) {
async function listTrash(dir, userKey) {
const root = await laneRoot(dir);
const acct = accountAt(root);
const tr = trashRoot(acct, userKey);
const items = await readStore(acct, trashRoot(acct, userKey));
return { items, keepDays: TRASH_DAYS };
}
/** The live items of one trash folder `tr` (of `acct`), expired ones dropped. */
async function readStore(acct, tr, store) {
let ids = [];
try {
ids = (await fs.promises.readdir(tr)).filter((n) => TRASH_ID_RE.test(n));
} catch {
return { items: [] };
return [];
}
const cutoff = Date.now() - TRASH_DAYS * 86_400_000;
const items = [];
@@ -490,15 +496,20 @@ async function listTrash(dir, userKey) {
path: meta.rel,
isDir: !!meta.isDir,
deletedAt: meta.deletedAt,
...(store ? { store } : {}),
});
}
items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
return { items, keepDays: TRASH_DAYS };
return items;
}
function trashEntry(acct, userKey, id) {
return entryAt(acct, trashRoot(acct, userKey), id);
}
function entryAt(acct, tr, id) {
if (!TRASH_ID_RE.test(String(id || ""))) throw bad("EBADPATH", "no such trash item");
const idDir = path.join(trashRoot(acct, userKey), id);
const idDir = path.join(tr, id);
const meta = readMeta(acct, idDir);
if (!meta) throw bad("ENOFILE", "no such trash item");
return { idDir, meta };
@@ -515,6 +526,11 @@ async function restore(dir, id, userKey, resolveLane) {
const acct = accountAt(root);
const { idDir, meta } = trashEntry(acct, userKey, id);
const home = (meta.laneId != null && resolveLane(meta.laneId)) || dir;
return putBack(acct, idDir, meta, home, dir);
}
/** Move trash entry `idDir` back under folder `home` (`dir`: the fallback). */
async function putBack(acct, idDir, meta, home, dir) {
const parentRel = path.posix.dirname(String(meta.rel || meta.name));
const relDir = parentRel === "." ? "" : parentRel;
await ensureFolders(home, relDir);
@@ -530,6 +546,77 @@ async function restore(dir, id, userKey, resolveLane) {
return { path: relDir ? `${relDir}/${name}` : name, laneId: home === dir ? null : meta.laneId };
}
// ── A user's recycle bin ───────────────────────────────────────────────────
//
// One Trash per USER, whatever place a delete happened in: the trash of their
// home's account (a member's `~/.local/share/agenthub/trash/`, or the host's
// `<data dir>/trash/<user id>/` when their home is the host's) plus — when
// that is another folder — the host trash under their user id, where what
// they deleted in a host-folder workspace went. Items carry `store`
// ("home" | "host") so a restore or purge finds the right one.
/** The trash stores of the user `userKey` whose home is `homeDir`. */
function userStores(homeDir, userKey) {
const acct = accountAt(homeDir);
const out = [{ store: "home", acct, root: trashRoot(acct, userKey) }];
const host = trashRoot(null, userKey);
if (acct || path.resolve(host) !== path.resolve(out[0].root)) {
out.push({ store: "host", acct: null, root: host });
}
return out;
}
function pickStore(homeDir, userKey, store) {
const s = userStores(homeDir, userKey).find((x) => x.store === (store || "home"));
if (!s) throw bad("EBADPATH", "no such trash");
return s;
}
/** Every item of the user's recycle bin, newest first. */
async function listUserTrash(homeDir, userKey) {
const items = [];
for (const s of userStores(homeDir, userKey)) {
items.push(...(await readStore(s.acct, s.root, s.store)));
}
items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
return { items, keepDays: TRASH_DAYS };
}
/**
* Restore item `id` of the user's `store`: into the workspace it came from
* when `resolveLane` still gives one, else — for the home store — into the
* home. A host-store item whose workspace is gone has nowhere of the same
* account to go (`ENOFILE`).
*/
async function restoreUserTrash(homeDir, userKey, store, id, resolveLane) {
const s = pickStore(homeDir, userKey, store);
const { idDir, meta } = entryAt(s.acct, s.root, id);
const fallback = s.store === "home" ? homeDir : null;
const home = (meta.laneId != null && resolveLane(meta.laneId)) || fallback;
if (!home) {
throw bad("ENOFILE", "the workspace this came from is gone — there is nowhere to restore it");
}
return putBack(s.acct, idDir, meta, home, fallback);
}
/** Delete item `id` of `store` forever, or `"all"` of every store. */
async function purgeUserTrash(homeDir, userKey, store, id) {
if (id === "all") {
let n = 0;
for (const s of userStores(homeDir, userKey)) {
for (const it of await readStore(s.acct, s.root)) {
await dropTrash(s.acct, path.join(s.root, it.id));
n++;
}
}
return { purged: n };
}
const s = pickStore(homeDir, userKey, store);
const { idDir } = entryAt(s.acct, s.root, id);
await dropTrash(s.acct, idDir);
return { purged: 1 };
}
async function purgeTrash(dir, userKey, id) {
const root = await laneRoot(dir);
const acct = accountAt(root);
@@ -800,6 +887,9 @@ module.exports = {
remove,
trashOne,
listTrash,
listUserTrash,
restoreUserTrash,
purgeUserTrash,
restore,
purgeTrash,
upload,