Files
AgentHub/server/lib/session-payers.js
T
nntrivi2001 1a365a45bd feat(server-mode): collaborators run sessions on their own account (sharing phase 2)
A user a workspace is shared with may now start and resume AI sessions in
it. The run uses THEIR OWN account — a Claude pool setup-token, a Codex/Cursor
pool API key, or an AI Proxy of theirs — never the owner's login or a pool
login (409 ENOCOLLABACCOUNT otherwise). Their quota decides (EQUOTA, their own
flag file, the sweep goes by payer).

Money is per payer by UTC hour (session_payers, server/lib/session-payers.js):
a session nobody else ran is billed as before; one a collaborator started is
theirs; a resume changes hands from the next hour. Only Claude books by the
hour, so a cross-payer resume of any other tool and a collaborator's
resumeLast are refused (409 ECOLLABRESUME); a Terminal tab stays the owner's.
Users page, details, quotas, Today (mine=1) and developer budgets count by
payer. Docs: SERVER-MODE.md, API.md, CLAUDE.md, plan status.
2026-11-06 11:10:05 +07:00

150 lines
5.8 KiB
JavaScript

/**
* @file session-payers.js
* @description Who pays for a session, hour by hour (server mode, workspace
* sharing phase 2 — docs/plans/workspace-sharing/). A session is its
* workspace OWNER's money until someone else runs it: a user the workspace is
* shared with starts or resumes it (`dashboard_runs.started_by`), and from
* then on it is theirs — until the owner (or another collaborator) runs it
* again. Recorded in `session_payers` (session, from UTC hour, user); a
* session with no row is billed exactly as before.
*
* Hours are the booking unit (`hourlyTokenRows`), so a switch takes effect
* from the NEXT hour: the hour a resume happens in stays with the previous
* payer, while a session the run itself started is the starter's from its
* first hour (`0000`). The rest of a session's total that no hour holds is
* booked at its start hour, like every other report.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const db = () => require("../db").db;
const WHOLE = "0000";
const HOUR_MS = 3_600_000;
const hourOf = (ms) => new Date(ms).toISOString().slice(0, 13);
/** How far before its run a session may have started and still be the run's own (clock skew). */
const NEW_SESSION_SLACK_MS = 60_000;
/**
* Note the payer of run `runId`'s session — its starter, else the workspace
* owner — once the run knows its session. Best effort: a run never fails
* because this did.
*/
function noteRun(runId, now = Date.now()) {
try {
const run = db()
.prepare(
`SELECT r.session_id, r.started_by, r.started_at, r.tool, l.owner_id
FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE r.id = ?`
)
.get(runId);
// A Terminal tab is no account of its starter's: a `claude` typed there
// runs on the folder account's own login, so it stays the owner's money.
if (!run?.session_id || run.owner_id == null || run.tool === "shell") return;
const payer = run.started_by ?? run.owner_id;
const put = db().prepare(
"INSERT OR REPLACE INTO session_payers (session_id, from_hour, user_id) VALUES (?, ?, ?)"
);
db().transaction(() => {
const rows = timeline(run.session_id);
const current = rows.length ? rows[rows.length - 1].user_id : run.owner_id;
if (current === payer) return;
// The run's own new session (started with it, or not seen yet) is the
// starter's whole; a resumed one changes hands from the hour after the
// run started (a hook may tell us late).
const s = db().prepare("SELECT started_at FROM sessions WHERE id = ?").get(run.session_id);
const startedMs = Date.parse(run.started_at);
const fresh = !s || Date.parse(s.started_at) >= startedMs - NEW_SESSION_SLACK_MS;
const from =
fresh && !rows.length
? WHOLE
: hourOf((Number.isFinite(startedMs) ? Math.min(startedMs, now) : now) + HOUR_MS);
// The first change of hands: the owner's part becomes a row too, so the
// timeline alone answers for every hour.
if (!rows.length && from !== WHOLE) put.run(run.session_id, WHOLE, run.owner_id);
put.run(run.session_id, from, payer);
})();
} catch (err) {
console.warn(`[session-payers] run ${runId}: ${err.message}`);
}
}
/** Who pays for `sessionId` now: its timeline's last payer, else `ownerId`. */
function currentPayer(sessionId, ownerId) {
const rows = timeline(sessionId);
return rows.length ? rows[rows.length - 1].user_id : ownerId;
}
function timeline(sessionId) {
return db()
.prepare(
"SELECT from_hour, user_id FROM session_payers WHERE session_id = ? ORDER BY from_hour"
)
.all(sessionId);
}
/**
* Every split session's timeline: `{has(sessionId), payer(sessionId, hour)}`.
* `hour` is an hourly row's `date` (`YYYY-MM-DDTHH`).
*/
function index() {
const map = new Map();
for (const r of db()
.prepare("SELECT session_id, from_hour, user_id FROM session_payers ORDER BY from_hour")
.all()) {
if (!map.has(r.session_id)) map.set(r.session_id, []);
map.get(r.session_id).push(r);
}
return {
has: (sid) => map.has(sid),
payer(sid, hour) {
const rows = map.get(sid);
if (!rows) return null;
let uid = rows[0].user_id;
for (const r of rows) {
if (r.from_hour <= hour) uid = r.user_id;
else break;
}
return uid;
},
};
}
/** SQL: the split sessions (`s.id IN (…)` / `NOT IN`). */
const SPLIT = "SELECT session_id FROM session_payers";
/**
* Daily-shaped token rows (`date` = local date for `tzModifier`, as
* `dailyTokenRows`) of the hours `uid` pays for in split sessions matching
* `whereClause` — which must already narrow to `s.id IN (SPLIT)`.
*/
function paidDailyRows(uid, whereClause, params, tzModifier = "+0 minutes") {
const { hourlyTokenRows } = require("../routes/pricing");
const shift = Number.parseInt(String(tzModifier), 10) || 0;
const idx = index();
const out = [];
for (const r of hourlyTokenRows(whereClause, params)) {
if (idx.payer(r.session_id, r.date) !== uid) continue;
const { session_id: _sid, ...row } = r;
row.date = new Date(Date.parse(`${r.date}:00:00Z`) + shift * 60_000).toISOString().slice(0, 10);
out.push(row);
}
return out;
}
/** Map(session id → first-wins owner) of `OWNED_SESSIONS`, with each hour's payer laid over it. */
function payerResolver() {
const { OWNED_SESSIONS } = require("../routes/users");
const owner = new Map();
for (const r of db().prepare(OWNED_SESSIONS).all()) {
if (!owner.has(r.session_id)) owner.set(r.session_id, r.uid);
}
const idx = index();
/** The user an hourly row's money is (null: nobody's). */
return (row) =>
idx.has(row.session_id)
? idx.payer(row.session_id, row.date)
: (owner.get(row.session_id) ?? null);
}
module.exports = { noteRun, currentPayer, index, paidDailyRows, payerResolver, SPLIT, WHOLE };