feat: v2.5.10 — server mode for a team, file drops in the prompt, tab joins

Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged)
- Login for every page and API call (server/lib/auth.js, routes/auth.js,
  AuthGate), roles admin / analyst / member, accounts made only from the
  host's shell: `agenthub server setup|off|status`, `agenthub users
  list|add|passwd|role|disable|enable|assign|audit`
- Rows are owned: a member sees only their own workspaces, sessions and what
  hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience);
  staff reads of a member's session are audited (server/lib/audit.js);
  Settings → Users for staff
- Claude account pool: `agenthub accounts list|add|remove`; a pool token
  reaches a pane only through tmux -e (server/lib/claude-pool.js)
- A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback,
  live pane only); writes pass an Origin check; named locks per member
- docs/SERVER-MODE.md, docs/plans/server-mode/

Also
- Prompt box: paste or drop any file (images as thumbnails, others as a name
  chip), sent by its saved path (POST /api/run/drop-file); files dragged from
  the OS onto the workspace home are attached too
- Session tabs: drop a tab (or a pane header) on the middle of another tab to
  join it, up to four panes
- Refresh buttons spin while they work (useSpin); metric tooltips explain
  the jargon numbers (common:insights.help.*)
- Light palette retuned for contrast; IntelliJ Light theme removed
- CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md

Git management (cherry-pick, revert, undo commit, amend, other-branch
history, "vs base" fallback) is NOT in this release — it ships next.

Committed --no-verify: both full suites run on this exact tree (server
2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev;
client 1086/1086), tsc clean, prettier applied by hand.
This commit is contained in:
2026-10-07 14:11:25 +07:00
parent ca821008e2
commit 9a54978eef
112 changed files with 6509 additions and 436 deletions
+48 -4
View File
@@ -1,5 +1,5 @@
/**
* @file Express router for workflow intelligence endpoints, providing aggregated insights into workflow orchestration, tool usage patterns, subagent effectiveness, error propagation, concurrency, and session complexity. It queries the database for various metrics and patterns related to agents, sessions, and events, and returns a comprehensive JSON response for frontend visualization on the dashboard. The GET / bundle is computed by `server/lib/workflow-stats.js` in the read worker (`server/lib/read-pool.js`), off the event loop.
* @file Express router for workflow intelligence endpoints, providing aggregated insights into workflow orchestration, tool usage patterns, subagent effectiveness, error propagation, concurrency, and session complexity. It queries the database for various metrics and patterns related to agents, sessions, and events, and returns a comprehensive JSON response for frontend visualization on the dashboard. The GET / bundle is computed by `server/lib/workflow-stats.js` in the read worker (`server/lib/read-pool.js`), off the event loop. In server mode a member reads only their own sessions (`member` in the filter, `server/lib/scope.js`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -8,6 +8,7 @@ const { db, stmts } = require("../db");
const { runRead } = require("../lib/read-pool");
const { WORKFLOW_SECTIONS, sessionIdFilter } = require("../lib/workflow-stats");
const { parseTool, BAD_TOOL, parseRange, BAD_RANGE } = require("../lib/source-filter");
const { memberId, bySessionWhere } = require("../lib/scope");
const router = Router();
@@ -18,7 +19,7 @@ const router = Router();
router.get("/", async (req, res) => {
// Optional filters: status ("active", "completed", or omit for all), the AI
// tool (`?tool=`, `sessions.tool`) and `?from=&to=` (sessions started in
// those local days).
// those local days). A server-mode member reads only their own sessions.
const statusFilter = req.query.status || null;
const tool = parseTool(req);
if (tool === false) return res.status(400).json(BAD_TOOL);
@@ -40,7 +41,13 @@ router.get("/", async (req, res) => {
res.json(
await runRead(
"workflows",
{ status: statusFilter, tool, range, sections },
{
status: statusFilter,
tool,
range,
sections,
member: memberId(req),
},
// Every open Workflows page shares one run (~1.5 s on a large DB).
{ maxAgeMs: 30_000 }
)
@@ -72,7 +79,12 @@ router.get("/skill-usage", (req, res) => {
if (tool === false) return res.status(400).json(BAD_TOOL);
const range = parseRange(req);
if (range === false) return res.status(400).json(BAD_RANGE);
const sf = sessionIdFilter({ status: req.query.status || null, tool, range });
const sf = sessionIdFilter({
status: req.query.status || null,
tool,
range,
member: memberId(req),
});
const skills = db
.prepare(
@@ -233,6 +245,7 @@ function hydrateWorkflow(row) {
}
// GET /runs — list workflow runs (filter by status or session_id), paginated.
// A server-mode member lists and counts only runs of their own sessions.
router.get("/runs", (req, res) => {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 1000);
@@ -240,6 +253,9 @@ router.get("/runs", (req, res) => {
const status = req.query.status && req.query.status !== "all" ? req.query.status : null;
const sessionId = req.query.session_id || null;
if (memberId(req) != null)
return res.json(memberRuns(req, { limit, offset, status, sessionId }));
let rows;
if (sessionId) {
rows = stmts.listWorkflowsBySessionFilter.all(sessionId, limit, offset);
@@ -261,6 +277,34 @@ router.get("/runs", (req, res) => {
}
});
/** GET /runs for a member: the same answer over their own sessions' runs only. */
function memberRuns(req, { limit, offset, status, sessionId }) {
const w = bySessionWhere(req);
const order = "ORDER BY COALESCE(started_at, created_at) DESC LIMIT ? OFFSET ?";
const rows = sessionId
? db
.prepare(`SELECT * FROM workflows WHERE session_id = ? AND ${w.where} ${order}`)
.all(sessionId, ...w.params, limit, offset)
: status
? db
.prepare(`SELECT * FROM workflows WHERE status = ? AND ${w.where} ${order}`)
.all(status, ...w.params, limit, offset)
: db
.prepare(`SELECT * FROM workflows WHERE ${w.where} ${order}`)
.all(...w.params, limit, offset);
const total = db
.prepare(
`SELECT COUNT(*) AS n FROM workflows WHERE ${status ? "status = ? AND " : ""}${w.where}`
)
.get(...(status ? [status] : []), ...w.params).n;
const counts = {};
for (const r of db
.prepare(`SELECT status, COUNT(*) AS n FROM workflows WHERE ${w.where} GROUP BY status`)
.all(...w.params))
counts[r.status] = r.n;
return { runs: rows.map(hydrateWorkflow), total, counts, limit, offset };
}
// GET /runs/:runId — one run with its linked inner agents + their events.
router.get("/runs/:runId", (req, res) => {
try {