18e574a765
A pane's tab shows the other people who have it open (pane-presence.js, in memory, fed by the /ws-pty attach; GET /api/lanes/:id/presence and the pane_presence broadcast to the owner and full collaborators) and says "Billed to <name>" for a pane a collaborator opened (startedBy on the run). The first real keystroke of each attach into a shared workspace's pane by anyone but its owner is audited as `type into <run id>` (mouse, focus and terminal replies to tmux do not count). The pty socket now pings, so a vanished browser is ended at the next re-check and leaves the presence list.
112 lines
3.4 KiB
JavaScript
112 lines
3.4 KiB
JavaScript
/**
|
|
* @file pane-presence.js
|
|
* @description Who has which pane open right now (server mode, workspace
|
|
* sharing phase 4): every terminal attach (`/ws-pty/:runId`, websocket.js)
|
|
* adds its logged-in user to its pane, and its close takes them off. In
|
|
* memory only — a restart drops every attach anyway. A change is broadcast as
|
|
* `pane_presence` `{laneId, runId, users}` to the workspace's owner and the
|
|
* users it is shared with; `GET /api/lanes/:id/presence` gives the current
|
|
* state. The first keystroke of an attach into a SHARED workspace's pane by
|
|
* anyone but its owner is written to `audit_log` (`type into <run id>`) — who
|
|
* typed, never what.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
/** runId → Map(userId → {id, name, n}) — `n` attaches of that user. */
|
|
const panes = new Map();
|
|
|
|
const laneOf = (runId) => {
|
|
const m = /^agenthub-lane-(\d+)(?:-\d+)?$/.exec(String(runId || ""));
|
|
return m ? Number(m[1]) : null;
|
|
};
|
|
|
|
const nameOf = (user) => user.displayName || user.display_name || user.username;
|
|
|
|
function users(runId) {
|
|
return [...(panes.get(runId)?.values() || [])].map(({ id, name }) => ({ id, name }));
|
|
}
|
|
|
|
function announce(runId) {
|
|
try {
|
|
require("../websocket").broadcast("pane_presence", {
|
|
laneId: laneOf(runId),
|
|
runId,
|
|
users: users(runId),
|
|
});
|
|
} catch {
|
|
/* no websocket server (tests) */
|
|
}
|
|
}
|
|
|
|
/** `user` opened pane `runId`. */
|
|
function join(runId, user) {
|
|
if (!user || laneOf(runId) == null) return;
|
|
if (!panes.has(runId)) panes.set(runId, new Map());
|
|
const here = panes.get(runId);
|
|
const me = here.get(user.id);
|
|
if (me) {
|
|
me.n += 1;
|
|
return;
|
|
}
|
|
here.set(user.id, { id: user.id, name: nameOf(user), n: 1 });
|
|
announce(runId);
|
|
}
|
|
|
|
/** `user` closed one attach of pane `runId`. */
|
|
function leave(runId, user) {
|
|
const here = user && panes.get(runId);
|
|
const me = here?.get(user.id);
|
|
if (!me) return;
|
|
me.n -= 1;
|
|
if (me.n > 0) return;
|
|
here.delete(user.id);
|
|
if (!here.size) panes.delete(runId);
|
|
announce(runId);
|
|
}
|
|
|
|
/** `{runId: [{id, name}]}` for workspace `laneId`'s panes. */
|
|
function forLane(laneId) {
|
|
const out = {};
|
|
for (const runId of panes.keys()) {
|
|
if (laneOf(runId) === Number(laneId)) out[runId] = users(runId);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Whether workspace `laneId` is shared with anyone, whole or a session of it. */
|
|
function isShared(laneId) {
|
|
const { db } = require("../db");
|
|
return Boolean(
|
|
db
|
|
.prepare(
|
|
`SELECT 1 FROM lane_shares WHERE lane_id = ?
|
|
UNION ALL SELECT 1 FROM session_shares
|
|
WHERE session_id IN (SELECT id FROM sessions WHERE lane_id = ?) LIMIT 1`
|
|
)
|
|
.get(laneId, laneId)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The first keystroke of an attach: in a shared workspace, write who typed
|
|
* into which pane (`audit_log`, owner = the workspace's owner). Best effort.
|
|
*/
|
|
function auditInput(runId, user) {
|
|
const laneId = laneOf(runId);
|
|
if (!user || laneId == null) return;
|
|
try {
|
|
if (!isShared(laneId)) return;
|
|
const { db } = require("../db");
|
|
const owner = db.prepare("SELECT owner_id FROM lanes WHERE id = ?").get(laneId)?.owner_id;
|
|
if (owner === user.id) return; // the owner in their own workspace
|
|
require("./audit").recordAccountChange(user.id, owner ?? null, "PTY", `type into ${runId}`);
|
|
} catch (err) {
|
|
console.warn("[presence] could not audit:", err.message);
|
|
}
|
|
}
|
|
|
|
/** Test seam. */
|
|
const __reset = () => panes.clear();
|
|
|
|
module.exports = { join, leave, forLane, auditInput, users, __reset };
|