Files
AgentHub/server/lib/pool-groups.js
T
nntrivi2001 685eb748e8 feat: account pool per tool with groups and a Cursor pool; update runs in the background
- The update dialog can be closed while an update applies: it keeps running,
  the page still reloads itself when the new server is up, and a failure
  re-opens the dialog with its error.
- Account pool card: one tab per AI tool (Claude Code, Codex, Cursor CLI,
  Oh My Pi, and every other installed tool, which says it has no pool),
  accounts as cards with 5-hour and weekly quota shown as what is LEFT plus
  their reset times, and the saved profiles of Settings → AI tools →
  Profiles as cards with "Share to pool".
- Cursor account pool (server/lib/cursor-pool.js): Cursor API keys handed to
  a cursor-agent pane only through tmux -e CURSOR_API_KEY;
  `agenthub accounts add <name> --tool cursor`.
- Pool groups (server/lib/pool-groups.js, /api/accounts/groups): a group's
  members run only on its accounts of each tool, a user in no group on the
  accounts no group lists, a pin still wins. account_pins loses its tool
  CHECK (rebuilt, pins kept); three new tables.
- Oh My Pi login sharing can be switched from its pool tab too.
2026-10-22 17:02:08 +07:00

206 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file pool-groups.js
* @description Account pool GROUPS (server mode): an admin splits the pool so
* each team's sessions run on that team's own accounts. A group is a name, its
* members (one group per user, `pool_group_users`) and the pool accounts it
* may use, of every tool (`pool_group_accounts`; one account may serve many
* groups). The balancers (claude-pool.js, codex-pool.js, cursor-pool.js) ask
* `allowed(ownerId, tool)` which accounts a workspace OWNER's new pane may
* take: a member of a group gets only that group's accounts of the tool —
* none means the user's own sign-in, as with an empty pool — and a user in no
* group gets the SHARED pool, the accounts no group lists. With no group at
* all every account is shared, exactly as before groups existed. A pin
* (account-pins.js) is an admin's explicit choice and is honoured whatever the
* group. Local mode has no users: everything is shared there.
*
* Accounts are named, not referenced — the pool lives in files — so removing
* an account drops its group rows (`dropAccount`); deleting a user or a group
* cascades.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const err = (code, message) => Object.assign(new Error(message), { code });
const db = () => require("../db").db;
const pins = () => require("./account-pins");
const NAME_MAX = 64;
function validName(name) {
const n = String(name ?? "").trim();
if (!n || n.length > NAME_MAX || /[\u0000-\u001f]/.test(n)) {
throw err("EBADNAME", `group name: 1–${NAME_MAX} characters`);
}
return n;
}
function groupRow(id) {
const g = Number.isInteger(id)
? db().prepare("SELECT id, name FROM pool_groups WHERE id = ?").get(id)
: null;
if (!g) throw err("ENOGROUP", `no group ${id}`);
return g;
}
/** Every group with its members and accounts per tool. */
function listGroups() {
const groups = db()
.prepare("SELECT id, name, created_at AS createdAt FROM pool_groups ORDER BY name")
.all();
const users = db()
.prepare(
`SELECT g.group_id AS groupId, u.id AS userId, u.username
FROM pool_group_users g JOIN users u ON u.id = g.user_id ORDER BY u.username`
)
.all();
const accounts = db()
.prepare("SELECT group_id AS groupId, tool, account FROM pool_group_accounts ORDER BY account")
.all();
return groups.map((g) => ({
...g,
users: users
.filter((u) => u.groupId === g.id)
.map(({ userId, username }) => ({ userId, username })),
accounts: Object.fromEntries(
pins().TOOLS.map((t) => [
t,
accounts.filter((a) => a.groupId === g.id && a.tool === t).map((a) => a.account),
])
),
}));
}
function createGroup(name) {
const n = validName(name);
try {
const id = db().prepare("INSERT INTO pool_groups (name) VALUES (?)").run(n).lastInsertRowid;
return { id: Number(id), name: n };
} catch (e) {
if (String(e.code).startsWith("SQLITE_CONSTRAINT")) throw err("EEXISTS", `group ${n} exists`);
throw e;
}
}
function renameGroup(id, name) {
const g = groupRow(id);
const n = validName(name);
try {
db().prepare("UPDATE pool_groups SET name = ? WHERE id = ?").run(n, g.id);
} catch (e) {
if (String(e.code).startsWith("SQLITE_CONSTRAINT")) throw err("EEXISTS", `group ${n} exists`);
throw e;
}
return { id: g.id, name: n };
}
/** Its members go back to the shared pool; its accounts become shared if no other group lists them. */
function deleteGroup(id) {
const g = groupRow(id);
db().prepare("DELETE FROM pool_groups WHERE id = ?").run(g.id);
return g;
}
/**
* Exactly `userIds` are the group's members afterwards — a user in another
* group moves here (one group per user). One transaction.
*/
function setGroupUsers(id, userIds) {
const g = groupRow(id);
if (!Array.isArray(userIds) || !userIds.every(Number.isInteger)) {
throw err("EBADUSER", "userIds: an array of user ids");
}
const want = [...new Set(userIds)];
for (const uid of want) {
if (!db().prepare("SELECT 1 FROM users WHERE id = ?").get(uid))
throw err("ENOUSER", `no user ${uid}`);
}
db().transaction(() => {
db().prepare("DELETE FROM pool_group_users WHERE group_id = ?").run(g.id);
const put = db().prepare(
`INSERT INTO pool_group_users (user_id, group_id) VALUES (?, ?)
ON CONFLICT(user_id) DO UPDATE SET group_id = excluded.group_id`
);
for (const uid of want) put.run(uid, g.id);
})();
return g;
}
/** Exactly `names` are the group's accounts of `tool` afterwards. */
function setGroupAccounts(id, tool, names) {
const g = groupRow(id);
if (!pins().TOOLS.includes(tool)) throw err("EBADTOOL", `tool: ${pins().TOOLS.join(", ")}`);
if (!Array.isArray(names) || !names.every((n) => typeof n === "string")) {
throw err("EBADNAME", "accounts: an array of account names");
}
const want = [...new Set(names)];
const pool = pins().poolOf(tool);
for (const n of want) {
if (!pool.hasAccount(n)) throw err("ENOACCOUNT", `no ${tool} account ${n} in the pool`);
}
db().transaction(() => {
db().prepare("DELETE FROM pool_group_accounts WHERE group_id = ? AND tool = ?").run(g.id, tool);
const put = db().prepare(
"INSERT INTO pool_group_accounts (group_id, tool, account) VALUES (?, ?, ?)"
);
for (const n of want) put.run(g.id, tool, n);
})();
return g;
}
/**
* Which accounts of `tool` a pane of workspace owner `ownerId` may take, as a
* predicate on the account name: their group's, else the shared ones; their
* pin always. Everything when groups do not apply (local mode, no group yet,
* or no database to ask).
*/
function allowed(ownerId, tool) {
if (!require("./auth").isServerMode()) return () => true;
try {
if (!db().prepare("SELECT 1 FROM pool_groups LIMIT 1").get()) return () => true;
const pin = pins().pinFor(ownerId, tool);
const group = Number.isInteger(ownerId)
? db().prepare("SELECT group_id FROM pool_group_users WHERE user_id = ?").get(ownerId)
: null;
const names = new Set(
(group
? db()
.prepare("SELECT account FROM pool_group_accounts WHERE group_id = ? AND tool = ?")
.all(group.group_id, tool)
: []
).map((r) => r.account)
);
const grouped = group
? null
: new Set(
db()
.prepare("SELECT DISTINCT account FROM pool_group_accounts WHERE tool = ?")
.all(tool)
.map((r) => r.account)
);
return (name) => name === pin || (group ? names.has(name) : !grouped.has(name));
} catch {
return () => true; // a spawn never fails over a group lookup
}
}
/** A removed account leaves every group. */
function dropAccount(tool, account) {
try {
db()
.prepare("DELETE FROM pool_group_accounts WHERE tool = ? AND account = ?")
.run(tool, account);
} catch {
/* no database (a bare CLI test): nothing grouped */
}
}
module.exports = {
listGroups,
createGroup,
renameGroup,
deleteGroup,
setGroupUsers,
setGroupAccounts,
allowed,
dropAccount,
};