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:
2026-10-19 10:05:19 +07:00
parent aa8ac6cad9
commit 9c98638ec0
20 changed files with 776 additions and 39 deletions
+25 -8
View File
@@ -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,