feat(pool): pin a user to one Claude/Codex pool account
An admin pins a server-mode user to one pool account per tool (account_pins; PUT /api/accounts/pins with the password step-up and audit, the Users page's Pin a user, agenthub accounts pin|unpin). The workspace owner's pin wins over the balancer whenever the pane may use the account; a pinned login still waits while another pool login holds the home.
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @file account-pins.js
|
||||
* @description Account pins: an admin's choice that one server-mode user's
|
||||
* new sessions of a tool run on one named pool account instead of the one the
|
||||
* balancer would pick (claude-pool.js / codex-pool.js `pick`). One pin per
|
||||
* user and tool (`account_pins`); the user is the workspace's OWNER
|
||||
* (`lanes.owner_id`), never whoever clicked Start. A pin is a preference the
|
||||
* pool still bounds: an account a pane may not use (expired, or a login the
|
||||
* pane's home cannot take) falls back to the balancer, and a login never
|
||||
* replaces another one a live pane in the same home runs on.
|
||||
*
|
||||
* Server mode only: in local mode there are no users and nothing is pinned.
|
||||
* Removing the account drops its pins (`dropAccount`); deleting the user drops
|
||||
* theirs (ON DELETE CASCADE, and `auth.deleteUser`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const TOOLS = ["claude", "codex"];
|
||||
|
||||
const err = (code, message) => Object.assign(new Error(message), { code });
|
||||
const db = () => require("../db").db;
|
||||
const poolOf = (tool) => require(tool === "codex" ? "./codex-pool" : "./claude-pool");
|
||||
|
||||
function validTool(tool) {
|
||||
if (!TOOLS.includes(tool)) throw err("EBADTOOL", "tool: claude or codex");
|
||||
return tool;
|
||||
}
|
||||
|
||||
/** The account name `ownerId`'s new `tool` sessions are pinned to, or null. */
|
||||
function pinFor(ownerId, tool) {
|
||||
if (!require("./auth").isServerMode() || !Number.isInteger(ownerId)) return null;
|
||||
try {
|
||||
return (
|
||||
db()
|
||||
.prepare("SELECT account FROM account_pins WHERE user_id = ? AND tool = ?")
|
||||
.get(ownerId, tool)?.account ?? null
|
||||
);
|
||||
} catch {
|
||||
return null; // a spawn never fails over a pin
|
||||
}
|
||||
}
|
||||
|
||||
/** Every pin of `tool`, with its user's name: `[{userId, username, account, pinnedAt}]`. */
|
||||
function listPins(tool) {
|
||||
return db()
|
||||
.prepare(
|
||||
`SELECT p.user_id AS userId, u.username, p.account, p.pinned_at AS pinnedAt
|
||||
FROM account_pins p JOIN users u ON u.id = p.user_id
|
||||
WHERE p.tool = ? ORDER BY u.username`
|
||||
)
|
||||
.all(validTool(tool));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin `userId`'s new `tool` sessions to `account`, or unpin them (`account`
|
||||
* null). Returns `{userId, username, account}`.
|
||||
*/
|
||||
function setPin(userId, tool, account) {
|
||||
validTool(tool);
|
||||
const user = Number.isInteger(userId)
|
||||
? db().prepare("SELECT id, username FROM users WHERE id = ?").get(userId)
|
||||
: null;
|
||||
if (!user) throw err("ENOUSER", `no user ${userId}`);
|
||||
if (account == null || account === "") {
|
||||
db().prepare("DELETE FROM account_pins WHERE user_id = ? AND tool = ?").run(user.id, tool);
|
||||
return { userId: user.id, username: user.username, account: null };
|
||||
}
|
||||
const name = String(account);
|
||||
if (!poolOf(tool).hasAccount(name)) {
|
||||
throw err("ENOACCOUNT", `no ${tool} account ${name} in the pool`);
|
||||
}
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO account_pins (user_id, tool, account) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, tool) DO UPDATE SET account = excluded.account,
|
||||
pinned_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`
|
||||
)
|
||||
.run(user.id, tool, name);
|
||||
return { userId: user.id, username: user.username, account: name };
|
||||
}
|
||||
|
||||
/** A removed account takes its pins with it. */
|
||||
function dropAccount(tool, account) {
|
||||
try {
|
||||
db().prepare("DELETE FROM account_pins WHERE tool = ? AND account = ?").run(tool, account);
|
||||
} catch {
|
||||
/* no database (a bare CLI test): nothing pinned */
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { TOOLS, pinFor, listPins, setPin, dropAccount };
|
||||
@@ -367,6 +367,7 @@ function deleteUser(username) {
|
||||
} catch {
|
||||
/* no personal profiles */
|
||||
}
|
||||
sql("DELETE FROM account_pins WHERE user_id = ?").run(u.id);
|
||||
sql("DELETE FROM users WHERE id = ?").run(u.id);
|
||||
return { id: u.id, username: u.username, workspaces: lanes.length };
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
* 3. least $ spent in the last 5 hours, then in the last 7 days — the
|
||||
* sessions its runs (`dashboard_runs.account`) held, over the hours they
|
||||
* held them (pool-usage.js).
|
||||
* An admin may pin a user to one account (account-pins.js): that user's
|
||||
* panes skip the ranking whenever they may use it.
|
||||
* The quota is read with the account's own token in the background, at most
|
||||
* every `QUOTA_TTL_MS`; a token the usage endpoint refuses simply has no
|
||||
* reading, and ranking falls back to spend.
|
||||
@@ -193,6 +195,7 @@ function removeAccount(name) {
|
||||
}
|
||||
}
|
||||
fs.rmSync(fileOf(n));
|
||||
require("./account-pins").dropAccount("claude", n);
|
||||
quotaCache.delete(n);
|
||||
}
|
||||
|
||||
@@ -434,10 +437,15 @@ function liveInHome(name, member) {
|
||||
});
|
||||
}
|
||||
|
||||
/** The full record a new Claude pane should run on, or null. `member` = run-as account. */
|
||||
function pick(member) {
|
||||
/**
|
||||
* The full record a new Claude pane should run on, or null. `member` = run-as
|
||||
* account; `ownerId` = the workspace owner, whose pin (account-pins.js) wins
|
||||
* over the ranking when this pane may use that account.
|
||||
*/
|
||||
function pick(member, ownerId = null) {
|
||||
let usable = allAccounts().filter((a) => !expired(a));
|
||||
if (usable.length === 0) return null;
|
||||
let live = null;
|
||||
if (!member) {
|
||||
// A host pane's home holds the host's own login — a pool account only when
|
||||
// it was moved in from there (`hostHolds`) and is still the pair on disk:
|
||||
@@ -453,10 +461,15 @@ function pick(member) {
|
||||
} else {
|
||||
// One .credentials.json per home: while a pane of theirs runs on a
|
||||
// pool login, the next pane stays on that one.
|
||||
const live = usable.find((a) => a.kind === "login" && liveInHome(a.name, member));
|
||||
if (live) return live;
|
||||
live = usable.find((a) => a.kind === "login" && liveInHome(a.name, member)) || null;
|
||||
}
|
||||
}
|
||||
// A pinned token goes in by env and wins outright; a pinned login waits
|
||||
// while another login holds this home.
|
||||
const pin = require("./account-pins").pinFor(ownerId, "claude");
|
||||
const pinned = pin ? usable.find((a) => a.name === pin) : null;
|
||||
if (pinned && (pinned.kind === "token" || !live || live === pinned)) return pinned;
|
||||
if (live) return live;
|
||||
if (usable.length === 0) return null;
|
||||
refreshQuotas().catch(() => {});
|
||||
const ranked = usable
|
||||
@@ -489,8 +502,8 @@ function pick(member) {
|
||||
* background re-read. A `login` is offered for a member, and for a host pane
|
||||
* only when it is the host's own login (`hostHolds`), which stays as it is.
|
||||
*/
|
||||
function pickAccount(member = null) {
|
||||
const a = pick(member);
|
||||
function pickAccount(member = null, ownerId = null) {
|
||||
const a = pick(member, ownerId);
|
||||
if (!a) return null;
|
||||
return a.kind === "login"
|
||||
? { name: a.name, kind: "login" }
|
||||
@@ -502,8 +515,8 @@ function pickAccount(member = null) {
|
||||
* `env` goes through tmux `-e` (a member's launch file); `place()` runs just
|
||||
* before the pane starts and writes a login into the member's home.
|
||||
*/
|
||||
function forSpawn(member = null) {
|
||||
const a = pick(member);
|
||||
function forSpawn(member = null, ownerId = null) {
|
||||
const a = pick(member, ownerId);
|
||||
if (!a) return null;
|
||||
if (a.kind === "token") {
|
||||
return { name: a.name, env: { CLAUDE_CODE_OAUTH_TOKEN: a.token }, place: () => {} };
|
||||
@@ -524,8 +537,12 @@ function forSpawn(member = null) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Is `name` an account in the pool (any state)? */
|
||||
const hasAccount = (name) => NAME_RE.test(String(name)) && Boolean(readAccount(String(name)));
|
||||
|
||||
module.exports = {
|
||||
BLOCK_PERCENT,
|
||||
hasAccount,
|
||||
REFRESH_AHEAD_MS,
|
||||
addAccount,
|
||||
addLoginAccount,
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
* the last 5 h, then 7 d. The reading is the newest `rate_limits` in the
|
||||
* rollouts of the sessions that account's runs started (no network), each
|
||||
* from the CODEX_HOME of the account its workspace runs as.
|
||||
* A user an admin pinned to one account (account-pins.js) skips the ranking.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -139,6 +140,7 @@ function removeAccount(name) {
|
||||
const n = validName(name);
|
||||
if (!readAccount(n)) throw err("ENOACCOUNT", `no account ${n} in the pool`);
|
||||
fs.rmSync(fileOf(n));
|
||||
require("./account-pins").dropAccount("codex", n);
|
||||
quotaCache = null;
|
||||
}
|
||||
|
||||
@@ -332,10 +334,15 @@ function syncBack() {
|
||||
|
||||
// ── Picking ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The full record a new Codex pane should run on, or null. `member` = run-as account. */
|
||||
function pick(member) {
|
||||
/**
|
||||
* The full record a new Codex pane should run on, or null. `member` = run-as
|
||||
* account; `ownerId` = the workspace owner, whose pin (account-pins.js) wins
|
||||
* over the ranking when this pane may use that account.
|
||||
*/
|
||||
function pick(member, ownerId = null) {
|
||||
let usable = allAccounts();
|
||||
if (usable.length === 0) return null;
|
||||
let live = null;
|
||||
if (!member) {
|
||||
// A host pane's CODEX_HOME holds the host's own login.
|
||||
usable = usable.filter((a) => a.kind === "apikey");
|
||||
@@ -348,10 +355,15 @@ function pick(member) {
|
||||
} else {
|
||||
// One auth.json per home: while a pane of theirs runs on a pool
|
||||
// login, the next pane stays on that one.
|
||||
const live = usable.find((a) => a.kind === "chatgpt" && liveInHome(a.name, member));
|
||||
if (live) return live;
|
||||
live = usable.find((a) => a.kind === "chatgpt" && liveInHome(a.name, member)) || null;
|
||||
}
|
||||
}
|
||||
// A pinned API key goes in by env and wins outright; a pinned ChatGPT login
|
||||
// waits while another login holds this home.
|
||||
const pin = require("./account-pins").pinFor(ownerId, "codex");
|
||||
const pinned = pin ? usable.find((a) => a.name === pin) : null;
|
||||
if (pinned && (pinned.kind === "apikey" || !live || live === pinned)) return pinned;
|
||||
if (live) return live;
|
||||
if (usable.length === 0) return null;
|
||||
const ranked = usable
|
||||
.map((a) => {
|
||||
@@ -395,8 +407,8 @@ function liveInHome(name, member) {
|
||||
}
|
||||
|
||||
/** `{name, kind}` of the account a new Codex pane would get, or null. */
|
||||
function pickAccount(member = null) {
|
||||
const a = pick(member);
|
||||
function pickAccount(member = null, ownerId = null) {
|
||||
const a = pick(member, ownerId);
|
||||
return a ? { name: a.name, kind: a.kind } : null;
|
||||
}
|
||||
|
||||
@@ -405,8 +417,8 @@ function pickAccount(member = null) {
|
||||
* this pane may use): `{name, argv, env, place}`. `env` goes through tmux `-e`
|
||||
* (a member's launch file); `place()` runs just before the pane starts.
|
||||
*/
|
||||
function forSpawn(member = null) {
|
||||
const a = pick(member);
|
||||
function forSpawn(member = null, ownerId = null) {
|
||||
const a = pick(member, ownerId);
|
||||
if (!a) return null;
|
||||
if (a.kind === "apikey") {
|
||||
const provider = `{name="OpenAI (AgentHub pool)",base_url="https://api.openai.com/v1",env_key="${KEY_ENV}",wire_api="responses"}`;
|
||||
@@ -439,8 +451,12 @@ function forSpawn(member = null) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Is `name` an account in the pool? */
|
||||
const hasAccount = (name) => NAME_RE.test(String(name)) && Boolean(readAccount(String(name)));
|
||||
|
||||
module.exports = {
|
||||
BLOCK_PERCENT,
|
||||
hasAccount,
|
||||
addAccount,
|
||||
removeAccount,
|
||||
poolStatus,
|
||||
|
||||
@@ -461,19 +461,20 @@ function spawnRun(args) {
|
||||
assertCwdAccount(laneId, cwd); // ECWDACCOUNT
|
||||
const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED
|
||||
// A Claude session on the vendor's own API runs on the least-loaded pool
|
||||
// account, if the admin added any (claude-pool.js); an AI Proxy brings its
|
||||
// account, if the admin added any (claude-pool.js) — or the one the
|
||||
// workspace owner is pinned to (account-pins.js); an AI Proxy brings its
|
||||
// own key, and an empty pool leaves the machine's login in charge.
|
||||
// A real login (kind `login`) is written into a MEMBER's home by place();
|
||||
// a setup-token goes in as CLAUDE_CODE_OAUTH_TOKEN.
|
||||
const poolAccount =
|
||||
entry.launcher === "claude" && !proxyProfile
|
||||
? require("./claude-pool").forSpawn(require("./run-as").accountForLane(laneId))
|
||||
? require("./claude-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner)
|
||||
: null;
|
||||
// Codex likewise, from its own pool (codex-pool.js): an API key through
|
||||
// `-e`, or a ChatGPT login written into the member's CODEX_HOME by place().
|
||||
const codexPool =
|
||||
entry.launcher === "codex" && !proxyProfile
|
||||
? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId))
|
||||
? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner)
|
||||
: null;
|
||||
const paneCred = require("./auth").newPaneToken();
|
||||
if (forkSession === true) {
|
||||
|
||||
Reference in New Issue
Block a user