783b7f4101
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.
114 lines
4.8 KiB
JavaScript
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,
|
|
};
|