Files
AgentHub/server/lib/session-account.js
T
nntrivi2001 783b7f4101 fix(cost): bill a session outside every workspace to its Linux account's user
A script's claude -p in a temp folder (the SG Flow eval runner strips the
pane's env) had no workspace, so its spend reached nobody's Today, the Users
page or a spend quota — ~$57 of a $74 day was missing from the status bar.

sessions.account records the Linux account a session ran as (the hook's
peer uid, else its folder for poller-made and old rows); a session no
workspace owns is owned by that account's user — a member's to them, the
host's to the admin ticked 'Owns this server's host account' (or the only
admin). Workspace ownership still wins.
2026-10-30 08:50:34 +07:00

114 lines
4.8 KiB
JavaScript

/**
* @file session-account.js
* @description Which Linux account a session ran as (`sessions.account`: ""
* = the host's, else an S3 member's name), and whose bill a session no
* workspace owns goes on. A workspace owns a session through `lane_id` or a
* `dashboard_runs` row (server/lib/scope.js); a session started OUTSIDE every
* workspace — a script's `claude -p`, a CLI typed in a plain terminal — has
* neither, and without this its spend counted for nobody: not in anyone's
* Today, the Users page or a spend quota.
*
* The account is the hook's PEER (server/lib/hook-auth.js: the uid of the
* loopback connection, never the body) when a hook creates the session; a
* session no hook made (the Codex/Antigravity/Cursor/omp pollers, history)
* gets it from its folder, the S3 rule `accountForPath` already applies to
* every pane. A pushed session (another machine's) never gets one. A member
* account bills to the user of that name; the host account to the admin an
* admin marked as its owner (`users.host_account`), or — with no mark — the
* only enabled admin, if there is exactly one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const sql = (q) => require("../db").db.prepare(q);
// The user the host account's sessions bill to (null: nobody marked and not exactly one admin).
const HOST_OWNER = `COALESCE(
(SELECT MIN(id) FROM users WHERE host_account = 1 AND role = 'admin'),
(SELECT MIN(id) FROM users WHERE role = 'admin' AND disabled = 0 HAVING COUNT(*) = 1))`;
// A session no workspace owns: neither stamped onto an owned lane nor held by
// a run of one — exactly the complement of scope.js's ownership.
const UNOWNED = `(s.lane_id IS NULL OR s.lane_id NOT IN (SELECT id FROM lanes WHERE owner_id IS NOT NULL))
AND s.id NOT IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
WHERE r.session_id IS NOT NULL AND l.owner_id IS NOT NULL)`;
/** `session_id` → `uid` for every session billed by its account (no workspace owns it). */
const ACCOUNT_OWNED = `
SELECT s.id AS session_id, u.id AS uid
FROM sessions s JOIN users u
ON u.id = CASE WHEN s.account = '' THEN ${HOST_OWNER}
ELSE (SELECT id FROM users WHERE username = s.account) END
WHERE s.account IS NOT NULL AND ${UNOWNED}`;
/** The account a hook's peer names: "" for the host, a member's name, null otherwise. */
function accountOfPeer(peer) {
if (!peer || peer.kind === "host") return "";
if (peer.kind === "member" && peer.name) return peer.name;
return null;
}
/** Record a hook-created session's account — first writer wins, a pushed hook never writes. */
function stampFromPeer(sessionId, peer, pushSource = null) {
if (pushSource || !sessionId) return;
const account = accountOfPeer(peer);
if (account == null) return;
sql("UPDATE sessions SET account = ? WHERE id = ? AND account IS NULL").run(account, sessionId);
}
/** The account a folder runs as: "" outside the users root, a member's name inside it. */
function accountOfPath(cwd) {
const linux = require("./linux-accounts");
const conf = linux.s3Enabled() ? linux.serverConf() : null;
const root = conf?.usersRoot;
const path = require("path");
if (!root || typeof cwd !== "string" || !path.isAbsolute(cwd)) return "";
const rel = path.relative(root, path.resolve(cwd));
if (rel.startsWith("..") || path.isAbsolute(rel)) return "";
// The users root itself is nobody's.
return rel.split(path.sep)[0] || null;
}
/**
* Give every local session still without an account the one its folder runs
* as (sessions the pollers made, and history from before the column). Cheap
* once caught up: only `account IS NULL` rows are read.
*/
function stampFromFolders() {
const rows = sql(
"SELECT id, cwd FROM sessions WHERE account IS NULL AND source NOT LIKE 'push:%' LIMIT 5000"
).all();
if (!rows.length) return 0;
const set = sql("UPDATE sessions SET account = ? WHERE id = ? AND account IS NULL");
let n = 0;
require("../db").db.transaction(() => {
for (const r of rows) {
const account = accountOfPath(r.cwd);
if (account != null) n += set.run(account, r.id).changes;
}
})();
return n;
}
/** Mark `userId` (an admin) as the host account's owner, or clear the mark with null. */
function setHostOwner(userId) {
require("../db").db.transaction(() => {
sql("UPDATE users SET host_account = 0 WHERE host_account = 1").run();
if (userId != null) sql("UPDATE users SET host_account = 1 WHERE id = ?").run(userId);
})();
}
/** The user id the host account bills to right now, or null. */
function hostOwner() {
return sql(`SELECT ${HOST_OWNER} AS id`).get()?.id ?? null;
}
module.exports = {
ACCOUNT_OWNED,
accountOfPeer,
stampFromPeer,
accountOfPath,
stampFromFolders,
setHostOwner,
hostOwner,
};