1a365a45bd
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.
150 lines
5.8 KiB
JavaScript
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 };
|