685eb748e8
- 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.
206 lines
6.9 KiB
JavaScript
206 lines
6.9 KiB
JavaScript
/**
|
||
* @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,
|
||
};
|