/** * @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ĩ */ 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 };