feat(pool): an admin's switch moves a user's Claude login at once
PUT /api/accounts/pins (and agenthub accounts pin) now writes a pinned Claude login into the user's home right away (claude-pool.switchHome): every home's newer pair is copied back first, the member's own login is never written over, and no pane is restarted — running sessions take the new pair at their next credentials read. A live home stays on the login it holds, so unpinning does not flip it back. The Users page calls it "Switch a user's account" and says what happened.
This commit is contained in:
@@ -2,12 +2,14 @@
|
||||
* @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
|
||||
* balancer would pick (claude-pool.js / codex-pool.js `pick`) — the Users
|
||||
* page's "switch a user's account". 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.
|
||||
* pane's home cannot take) falls back to the balancer. A Claude login is
|
||||
* written into the user's home right away (`applyPin`), over the pool login
|
||||
* their running panes use, so those move to it without a restart.
|
||||
*
|
||||
* 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
|
||||
@@ -79,6 +81,20 @@ function setPin(userId, tool, account) {
|
||||
return { userId: user.id, username: user.username, account: name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a pin take effect now where it can: a Claude login is written into
|
||||
* the user's home at once (claude-pool `switchHome`), so their running panes
|
||||
* move to it without a restart. Anything else goes in at the next spawn.
|
||||
* Answers what happened (see `switchHome`).
|
||||
*/
|
||||
async function applyPin(userId, tool) {
|
||||
const pin = pinFor(userId, tool);
|
||||
if (!pin || tool !== "claude") return "next-session";
|
||||
const user = db().prepare("SELECT username FROM users WHERE id = ?").get(userId);
|
||||
const member = user ? require("./run-as").accountForUser(user.username) : null;
|
||||
return poolOf(tool).switchHome(member, pin);
|
||||
}
|
||||
|
||||
/** A removed account takes its pins with it. */
|
||||
function dropAccount(tool, account) {
|
||||
try {
|
||||
@@ -88,4 +104,4 @@ function dropAccount(tool, account) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { TOOLS, pinFor, listPins, setPin, dropAccount };
|
||||
module.exports = { TOOLS, pinFor, listPins, setPin, applyPin, dropAccount };
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
* 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.
|
||||
* An admin may switch a user to one account (account-pins.js): that user's
|
||||
* panes skip the ranking whenever they may use it, and a login is written
|
||||
* into their home at once (`switchHome`) — running panes take it at their
|
||||
* next credentials read, without a restart.
|
||||
* 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.
|
||||
@@ -335,6 +337,40 @@ async function refreshLogins() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An admin's switch (account-pins.js): put pool login `name` into `member`'s
|
||||
* home NOW, over the pool login it holds — never over a login the member made
|
||||
* themselves. Every home's newer pair is copied back first (refreshLogins),
|
||||
* so the login leaving the home keeps its only live refresh token. Running
|
||||
* panes are not restarted: Claude Code takes the new pair when it next
|
||||
* re-reads its credentials (before a refresh), new panes at once. Answers
|
||||
* `switched`, `kept` (already there), `own-login` (left alone), `expired`, or
|
||||
* `next-session` (a setup-token, or no member home: it goes in at spawn).
|
||||
*/
|
||||
async function switchHome(member, name) {
|
||||
const first = readAccount(validName(name));
|
||||
if (!first) throw err("ENOACCOUNT", `no account ${name} in the pool`);
|
||||
if (first.kind !== "login" || !member || member.host) return "next-session";
|
||||
await refreshLogins();
|
||||
const a = readAccount(first.name);
|
||||
if (!a || expired(a)) return "expired";
|
||||
const disk = homeCreds(member);
|
||||
if (disk && ownsHome(a, member.name, disk)) return "kept";
|
||||
const holder = disk && allAccounts().find((x) => ownsHome(x, member.name, disk));
|
||||
if (disk && !holder) return "own-login";
|
||||
if (holder) {
|
||||
const placed = { ...holder.placed };
|
||||
delete placed[member.name];
|
||||
save({ ...holder, placed });
|
||||
}
|
||||
save(writeLogin(a, member));
|
||||
runAs().markClaudeOnboarded(member, {
|
||||
...(a.account ? { oauthAccount: a.account } : {}),
|
||||
...(a.userID ? { userID: a.userID } : {}),
|
||||
});
|
||||
return "switched";
|
||||
}
|
||||
|
||||
// ── Quota, read in the background ──────────────────────────────────────────
|
||||
|
||||
/** name → { at, fiveHour?, sevenDay?, error? } */
|
||||
@@ -461,7 +497,10 @@ function pick(member, ownerId = null) {
|
||||
} else {
|
||||
// One .credentials.json per home: while a pane of theirs runs on a
|
||||
// pool login, the next pane stays on that one.
|
||||
live = usable.find((a) => a.kind === "login" && liveInHome(a.name, member)) || null;
|
||||
// The login in the home is the one they run on — after an admin's
|
||||
// switch (switchHome) it is no longer the one those panes started on.
|
||||
const liveLogin = usable.find((a) => a.kind === "login" && liveInHome(a.name, member));
|
||||
live = liveLogin ? placed || liveLogin : null;
|
||||
}
|
||||
}
|
||||
// A pinned token goes in by env and wins outright; a pinned login waits
|
||||
@@ -550,6 +589,7 @@ module.exports = {
|
||||
poolStatus,
|
||||
pickAccount,
|
||||
forSpawn,
|
||||
switchHome,
|
||||
refreshQuotas,
|
||||
refreshLogins,
|
||||
__read: readAccount,
|
||||
|
||||
Reference in New Issue
Block a user