chore: v2.8.71 — remote clients (company hub), artifacts shared with chosen people, folders and viewable copies, fixes
Remote clients (docs/plans/remote-clients/README.md, B1–B3 for the Claude pool): - server/hub/ holds the hub-only code (devices, remote sessions, upload ingest, LAN gateway), required only in server mode; hub-boundary.test.js keeps local code from importing it. - A local install enrolls with `agenthub remote login` or Settings → AI tools → Company hub; the "Company account" AI Proxy (reserved name agenthub.company) sends a session through the hub's gateway, which adds a pool account's token — no credential reaches the machine. Only those sessions are uploaded, hourly and at their end; secret-shaped strings are redacted on the client. - Gateway refuses: unknown device (401), a message without a session id, an unregistered session under AGENTHUB_REMOTE_GATE=block, a user over quota (EQUOTA), a purged session (410). Device tokens 90 days, conversations 30. - New alert rule type remote_unmonitored: a company-account session its device never registered; a late registration clears the flag instead. - The hub imports a session once per upload round (chunks carry `more`), not once per chunk. Artifacts: - Share an artifact with chosen people (artifact_shares, PATCH sharedWith, GET /api/artifacts/people) besides everyone; a "Shared with you" tab. Only the owner or an admin sees or edits who it is shared with; audited. - Folders: each account's own arrangement (agenthub.artifacts.folders pref). - A claude.ai page keeps a copy viewable without the publishing account: its HTML file (and supporting files) snapshotted at publish, or the copy Claude saves for a Doc / Slides / Design in artifacts-export/ (a synchronous PostToolUse hint hook, scripts/artifact-export-hint.js, admin switch), attached to the page at Stop. Viewed in HtmlFrame's sandbox, Markdown, or pdf.js (pdfjs-dist 6.4.299, legacy build, loaded only for a PDF). Fixes: - tmux: a cached /proc/<pid>/exe of an exited server falls back to PATH's tmux (stop the last pane, start again within 10 s failed with ENOENT). - shelves: a shelf restores beside changes in other files. - chat: a finished reply ends the turn even with no hook at all. - Settings → AI tools: profile list display tweaks.
This commit is contained in:
@@ -0,0 +1,582 @@
|
||||
/**
|
||||
* @file remote-client.js
|
||||
* @description A LOCAL AgentHub's side of the remote-clients architecture
|
||||
* (docs/plans/remote-clients/README.md): this machine enrolled with the
|
||||
* company hub, its "Company account" sessions, and their hourly upload.
|
||||
*
|
||||
* - Enrollment: `agenthub remote login <hub>` → `login()` posts the user's
|
||||
* hub password to the hub ONCE and keeps the device token it answers in
|
||||
* `<data dir>/runtime/remote-client.json` (0600). The password is never
|
||||
* stored. `logout()` revokes the token on the hub and deletes the file.
|
||||
* - A session started on the Company account (`proxy: "agenthub.company"`,
|
||||
* proxy-apply.js) runs Claude Code against the hub's gateway with the
|
||||
* device token as its key; `registerSession` tells the hub its id at start
|
||||
* (no content). Every other session — the machine's own login, the user's
|
||||
* own AI Proxies — is never registered and never uploaded.
|
||||
* - Upload: every `DASHBOARD_REMOTE_UPLOAD_MS` (an hour) and when such a
|
||||
* session ends, the NEW whole lines of its transcript and its sub-agents'
|
||||
* are sent, each line's secret-shaped strings replaced first
|
||||
* (privacy.js `redactValue`). How far each file was sent is kept in
|
||||
* `remote_uploads`; the transcript files themselves are the queue, so an
|
||||
* offline machine simply sends the rest next time.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const { getDataDir } = require("./claude-home");
|
||||
|
||||
/** = ai-proxies.js `COMPANY_NAME`, the Company account's proxy name. */
|
||||
const HUB_PROXY = "agenthub.company";
|
||||
/** One upload request carries at most this many bytes of a file. */
|
||||
const CHUNK_BYTES = 4 * 1024 * 1024;
|
||||
/**
|
||||
* A transcript line longer than this (a big paste, an image) is sent as a
|
||||
* short placeholder instead: JSON escaping can double it, and the hub's
|
||||
* upload parser takes 12 MB — one such line would stop the session's uploads
|
||||
* for good. The hub's offset still moves past it.
|
||||
*/
|
||||
const MAX_LINE_BYTES = 5 * 1024 * 1024;
|
||||
/** One upload round sends at most this much in all (the rest next round). */
|
||||
const ROUND_BYTES = 64 * 1024 * 1024;
|
||||
const TIMEOUT_MS = 15_000;
|
||||
|
||||
const err = (code, message, status = 400) => Object.assign(new Error(message), { code, status });
|
||||
const sql = (q) => require("../db").db.prepare(q);
|
||||
|
||||
const stateFile = () => path.join(getDataDir(), "runtime", "remote-client.json");
|
||||
|
||||
/** The enrollment, or null. */
|
||||
function readState() {
|
||||
try {
|
||||
const s = JSON.parse(fs.readFileSync(stateFile(), "utf8"));
|
||||
return s && typeof s.hub === "string" && typeof s.token === "string" ? s : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeState(s) {
|
||||
const file = stateFile();
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||
const tmp = `${file}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(s, null, 2), { mode: 0o600 });
|
||||
fs.chmodSync(tmp, 0o600);
|
||||
fs.renameSync(tmp, file);
|
||||
}
|
||||
|
||||
/** Whether this machine holds a device token that has not expired. */
|
||||
function enrolled() {
|
||||
const s = readState();
|
||||
return Boolean(s && !(Date.parse(s.device?.expiresAt) <= Date.now()));
|
||||
}
|
||||
|
||||
/** What the UI and CLI show — never the token. */
|
||||
function status() {
|
||||
const s = readState();
|
||||
if (!s) return { enrolled: false };
|
||||
const expired = Date.parse(s.device?.expiresAt) <= Date.now();
|
||||
return {
|
||||
enrolled: !expired,
|
||||
expired,
|
||||
hub: s.hub,
|
||||
user: s.user || null,
|
||||
device: s.device || null,
|
||||
enrolledAt: s.enrolledAt || null,
|
||||
retainDays: s.retainDays || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The hub's base URL: http(s), no credentials, no query — its origin + path. */
|
||||
function normalizeHub(url) {
|
||||
let u;
|
||||
try {
|
||||
u = new URL(String(url || "").trim());
|
||||
} catch {
|
||||
throw err("EBADHUB", "the hub address must be a URL like http://10.1.13.7:4820");
|
||||
}
|
||||
if (!/^https?:$/.test(u.protocol)) throw err("EBADHUB", "the hub address must be http or https");
|
||||
if (u.username || u.password) throw err("EBADHUB", "the hub address may not carry a login");
|
||||
return `${u.origin}${u.pathname.replace(/\/+$/, "")}`;
|
||||
}
|
||||
|
||||
/** A JSON call to the hub; throws a coded error (EHUBDOWN when it cannot be reached). */
|
||||
async function hubFetch(
|
||||
hub,
|
||||
apiPath,
|
||||
{ method = "GET", token, body, timeoutMs = TIMEOUT_MS } = {}
|
||||
) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${hub}${apiPath}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
} catch (e) {
|
||||
throw err(
|
||||
"EHUBDOWN",
|
||||
`cannot reach the company hub at ${hub} — company accounts work only on the company network (${e.cause?.code || e.name})`,
|
||||
503
|
||||
);
|
||||
}
|
||||
let data = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
if (!res.ok) {
|
||||
const e = err(
|
||||
data?.error?.code || `EHUB${res.status}`,
|
||||
data?.error?.message || `the hub answered ${res.status}`,
|
||||
res.status === 401 ? 401 : res.status >= 500 ? 502 : res.status
|
||||
);
|
||||
if (data?.error?.expected) e.expected = data.error.expected;
|
||||
throw e;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Enroll this machine; the password goes to the hub once and is not kept. */
|
||||
async function login({ hub, username, password, deviceName }) {
|
||||
const base = normalizeHub(hub);
|
||||
if (!username || !password) throw err("EBADLOGIN", "username and password are required");
|
||||
const r = await hubFetch(base, "/api/remote/device/login", {
|
||||
method: "POST",
|
||||
body: {
|
||||
username: String(username),
|
||||
password: String(password),
|
||||
deviceName: String(deviceName || os.hostname()),
|
||||
},
|
||||
});
|
||||
if (!r || typeof r.token !== "string") throw err("EHUB", "the hub gave no device token", 502);
|
||||
writeState({
|
||||
hub: base,
|
||||
token: r.token,
|
||||
device: r.device,
|
||||
user: r.user,
|
||||
retainDays: r.retainDays,
|
||||
enrolledAt: new Date().toISOString(),
|
||||
});
|
||||
return status();
|
||||
}
|
||||
|
||||
/** Revoke this machine's token on the hub (best effort) and forget it here. */
|
||||
async function logout() {
|
||||
const s = readState();
|
||||
if (!s) return { ok: true, wasEnrolled: false };
|
||||
let revoked = true;
|
||||
try {
|
||||
await hubFetch(s.hub, "/api/remote/device/logout", {
|
||||
method: "POST",
|
||||
token: s.token,
|
||||
body: {},
|
||||
});
|
||||
} catch {
|
||||
revoked = false; // offline: the token still expires or an admin revokes it
|
||||
}
|
||||
fs.rmSync(stateFile(), { force: true });
|
||||
return { ok: true, wasEnrolled: true, revokedOnHub: revoked };
|
||||
}
|
||||
|
||||
function requireState() {
|
||||
const s = readState();
|
||||
if (!s)
|
||||
throw err("ENOHUB", "this machine is not enrolled with a company hub (agenthub remote login)");
|
||||
if (Date.parse(s.device?.expiresAt) <= Date.now()) {
|
||||
throw err("EDEVICE", "this machine's hub login expired — run agenthub remote login again", 401);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Check the hub answers and still accepts this device (before a session starts). */
|
||||
async function ping() {
|
||||
const s = requireState();
|
||||
return hubFetch(s.hub, "/api/remote/device/me", { token: s.token, timeoutMs: 3000 });
|
||||
}
|
||||
|
||||
/** Tell the hub a Company-account session starts (its id and folder — no content). */
|
||||
async function registerSession({ sessionId, cwd }) {
|
||||
const s = requireState();
|
||||
markCompany(sessionId);
|
||||
return hubFetch(s.hub, "/api/remote/device/sessions", {
|
||||
method: "POST",
|
||||
token: s.token,
|
||||
body: { sessionId, cwd, tool: "claude" },
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register in the background; a failure is only logged (the gateway then
|
||||
* refuses or flags the session — a hub that cannot be reached cannot serve it
|
||||
* anyway). Used where the caller cannot wait: a spawn, a hook.
|
||||
*/
|
||||
function registerSoon({ sessionId, cwd }) {
|
||||
try {
|
||||
markCompany(sessionId);
|
||||
} catch {
|
||||
/* the DB write is retried by the next hook */
|
||||
}
|
||||
registerSession({ sessionId, cwd }).catch((e) =>
|
||||
console.warn(`[remote] could not register session ${sessionId}: ${e.message}`)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook from a pane that runs on the Company account, for a session not
|
||||
* registered yet (`/clear` and a compaction give the pane a NEW session id):
|
||||
* register it. A cheap no-op for every other session.
|
||||
*/
|
||||
function onHookSession({ sessionId, runId, cwd }) {
|
||||
if (!sessionId || !runId) return;
|
||||
const run = sql("SELECT proxy, cwd FROM dashboard_runs WHERE id = ?").get(String(runId));
|
||||
if (!run || run.proxy !== HUB_PROXY || isCompany(sessionId) || !readState()) return;
|
||||
registerSoon({ sessionId: String(sessionId), cwd: cwd || run.cwd });
|
||||
}
|
||||
|
||||
/**
|
||||
* What a Claude pane on the Company account gets in its `--settings` file:
|
||||
* the hub's gateway as its endpoint and the device token as its key. The
|
||||
* command-line layer outranks the user's and the project's settings.
|
||||
*/
|
||||
function claudeEnv() {
|
||||
const s = requireState();
|
||||
return {
|
||||
ANTHROPIC_BASE_URL: `${s.hub}/api/remote/gw`,
|
||||
ANTHROPIC_AUTH_TOKEN: s.token,
|
||||
// Through a base URL Claude Code writes 5-minute cache entries; the
|
||||
// subscription's 1-hour ones are kept by asking (verified 2026-10-09).
|
||||
ENABLE_PROMPT_CACHING_1H: "1",
|
||||
};
|
||||
}
|
||||
|
||||
// ── Upload ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** One transcript line with its secret-shaped strings replaced. */
|
||||
function redactLine(line) {
|
||||
const { redactValue, redactText } = require("./privacy");
|
||||
try {
|
||||
return JSON.stringify(redactValue(JSON.parse(line)));
|
||||
} catch {
|
||||
return redactText(line);
|
||||
}
|
||||
}
|
||||
|
||||
const REGISTERED = "#registered";
|
||||
const ENDED = "#ended";
|
||||
|
||||
/** Remember `sessionId` as a Company-account session (the uploader's list). */
|
||||
function markCompany(sessionId) {
|
||||
sql("INSERT OR IGNORE INTO remote_uploads (session_id, rel_path, sent) VALUES (?, ?, 0)").run(
|
||||
String(sessionId),
|
||||
REGISTERED
|
||||
);
|
||||
// A resume opens it again: its end is sent again when it ends again.
|
||||
sql("DELETE FROM remote_uploads WHERE session_id = ? AND rel_path = ?").run(
|
||||
String(sessionId),
|
||||
ENDED
|
||||
);
|
||||
}
|
||||
|
||||
const isCompany = (sessionId) =>
|
||||
Boolean(
|
||||
sql("SELECT 1 FROM remote_uploads WHERE session_id = ? AND rel_path = ?").get(
|
||||
String(sessionId),
|
||||
REGISTERED
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* The Company-account sessions this machine ran: `[{sessionId, live}]` —
|
||||
* live while a pane still holds it, or it wrote in the last 10 minutes.
|
||||
*/
|
||||
function companySessions() {
|
||||
return sql(
|
||||
`SELECT u.session_id AS sessionId,
|
||||
(EXISTS (SELECT 1 FROM dashboard_runs r WHERE r.session_id = u.session_id
|
||||
AND r.status IN ('running', 'spawning'))
|
||||
OR COALESCE((SELECT s.updated_at FROM sessions s WHERE s.id = u.session_id), '')
|
||||
>= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-10 minutes')) AS live
|
||||
FROM remote_uploads u
|
||||
WHERE u.rel_path = ?`
|
||||
).all(REGISTERED);
|
||||
}
|
||||
|
||||
/** A session's files the hub takes: `[{rel, abs}]` — its JSONL and its sub-agents'. */
|
||||
function sessionFiles(sessionId) {
|
||||
const row = sql("SELECT transcript_path FROM sessions WHERE id = ?").get(sessionId);
|
||||
const main = row?.transcript_path;
|
||||
if (!main || !fs.existsSync(main)) return [];
|
||||
const out = [{ rel: `${sessionId}.jsonl`, abs: main }];
|
||||
const subDir = path.join(path.dirname(main), sessionId, "subagents");
|
||||
const walk = (dir, relDir) => {
|
||||
let ents = [];
|
||||
try {
|
||||
ents = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of ents) {
|
||||
const abs = path.join(dir, e.name);
|
||||
const rel = `${relDir}/${e.name}`;
|
||||
if (e.isFile() && /^agent-[A-Za-z0-9_-]{1,100}\.jsonl$/.test(e.name)) out.push({ rel, abs });
|
||||
else if (e.isDirectory() && relDir === `${sessionId}/subagents` && e.name === "workflows") {
|
||||
walk(abs, rel);
|
||||
} else if (e.isDirectory() && relDir === `${sessionId}/subagents/workflows`) {
|
||||
if (/^[A-Za-z0-9_-]{1,100}$/.test(e.name)) walk(abs, rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(subDir, `${sessionId}/subagents`);
|
||||
return out;
|
||||
}
|
||||
|
||||
const sentOf = (sid, rel) =>
|
||||
sql("SELECT sent FROM remote_uploads WHERE session_id = ? AND rel_path = ?").get(sid, rel)
|
||||
?.sent ?? 0;
|
||||
function setSent(sid, rel, sent) {
|
||||
sql(
|
||||
`INSERT INTO remote_uploads (session_id, rel_path, sent, sent_at)
|
||||
VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
ON CONFLICT(session_id, rel_path) DO UPDATE SET sent = excluded.sent, sent_at = excluded.sent_at`
|
||||
).run(sid, rel, sent);
|
||||
}
|
||||
/**
|
||||
* The next chunk of `abs` from byte `from`: whole lines only, at most
|
||||
* CHUNK_BYTES (a single longer line goes alone). Null when nothing complete is new.
|
||||
*/
|
||||
function readChunk(abs, from) {
|
||||
let size;
|
||||
try {
|
||||
size = fs.statSync(abs).size;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (size <= from) return null;
|
||||
const want = Math.min(size - from, CHUNK_BYTES);
|
||||
const fd = fs.openSync(abs, "r");
|
||||
try {
|
||||
let buf = Buffer.alloc(want);
|
||||
let n = fs.readSync(fd, buf, 0, want, from);
|
||||
buf = buf.subarray(0, n);
|
||||
let cut = buf.lastIndexOf(0x0a);
|
||||
if (cut < 0 && size - from > want) {
|
||||
// One line longer than a chunk: read it whole, up to its newline.
|
||||
let more = buf;
|
||||
let pos = from + n;
|
||||
while (cut < 0 && pos < size) {
|
||||
const extra = Buffer.alloc(Math.min(CHUNK_BYTES, size - pos));
|
||||
const m = fs.readSync(fd, extra, 0, extra.length, pos);
|
||||
if (m <= 0) break;
|
||||
pos += m;
|
||||
more = Buffer.concat([more, extra.subarray(0, m)]);
|
||||
cut = more.lastIndexOf(0x0a);
|
||||
}
|
||||
buf = more;
|
||||
}
|
||||
if (cut < 0) return null; // the last line is still being written
|
||||
const raw = buf.subarray(0, cut + 1).toString("utf8");
|
||||
const lines = raw.split("\n");
|
||||
lines.pop(); // the "" after the final newline
|
||||
const data =
|
||||
lines
|
||||
.map((l) =>
|
||||
Buffer.byteLength(l) > MAX_LINE_BYTES
|
||||
? JSON.stringify({ type: "agenthub-omitted", bytes: Buffer.byteLength(l) })
|
||||
: l.trim()
|
||||
? redactLine(l)
|
||||
: l
|
||||
)
|
||||
.join("\n") + "\n";
|
||||
return { from, to: from + cut + 1, data };
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
/** Send what is new of one session. Returns bytes sent; throws on a hub error. */
|
||||
async function uploadSession(s, { sessionId, live }, budget) {
|
||||
let sent = 0;
|
||||
let registeredAgain = false;
|
||||
/** One upload request, registering the session again once if the hub asks. */
|
||||
const post = async (body, timeoutMs) => {
|
||||
try {
|
||||
return await hubFetch(s.hub, "/api/remote/device/upload", {
|
||||
method: "POST",
|
||||
token: s.token,
|
||||
body: { sessionId, ...body },
|
||||
timeoutMs,
|
||||
});
|
||||
} catch (e) {
|
||||
// Registered under this machine's previous device token (it logged in
|
||||
// again since): register it with this one — the hub moves the same
|
||||
// user's session over — and send again.
|
||||
const cwd =
|
||||
e.code === "ENOSESSION" && !registeredAgain
|
||||
? sql("SELECT cwd FROM sessions WHERE id = ?").get(sessionId)?.cwd
|
||||
: null;
|
||||
if (!cwd) throw e;
|
||||
registeredAgain = true;
|
||||
await registerSession({ sessionId, cwd });
|
||||
return post(body, timeoutMs);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const files = sessionFiles(sessionId);
|
||||
// Every chunk says `more: true`; the round's closing request (below) tells
|
||||
// the hub to import, so a long session is parsed once per round, not once
|
||||
// per chunk.
|
||||
let chunks = 0;
|
||||
files: for (const f of files) {
|
||||
for (;;) {
|
||||
if (sent >= budget) break files;
|
||||
const chunk = readChunk(f.abs, sentOf(sessionId, f.rel));
|
||||
if (!chunk) break;
|
||||
try {
|
||||
const r = await post(
|
||||
{
|
||||
files: [{ path: f.rel, from: chunk.from, to: chunk.to, data: chunk.data }],
|
||||
more: true,
|
||||
},
|
||||
60_000
|
||||
);
|
||||
chunks++;
|
||||
setSent(sessionId, f.rel, r.files?.[0]?.consumed ?? chunk.to);
|
||||
sent += chunk.to - chunk.from;
|
||||
} catch (e) {
|
||||
// The hub holds a different offset (it lost a chunk, or we did):
|
||||
// continue from where IT stands.
|
||||
if (e.code === "EOFFSET" && Number.isSafeInteger(e.expected?.consumed)) {
|
||||
setSent(sessionId, f.rel, e.expected.consumed);
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
const ending = !live && files.length && sentOf(sessionId, ENDED) === 0 && sent < budget;
|
||||
if (ending) {
|
||||
await post({ files: [], ended: true });
|
||||
setSent(sessionId, ENDED, 1);
|
||||
} else if (chunks > 0) {
|
||||
await post({ files: [] }, 60_000);
|
||||
}
|
||||
} catch (e) {
|
||||
// The hub already removed it (retention): nothing more of it is sent.
|
||||
if (e.code !== "EPURGED") throw e;
|
||||
sql("DELETE FROM remote_uploads WHERE session_id = ?").run(sessionId);
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
let running = null;
|
||||
|
||||
/**
|
||||
* One upload round over every Company-account session (or only `onlySession`).
|
||||
* Returns `{sessions, bytes, errors}`. Rounds never overlap.
|
||||
*/
|
||||
async function uploadAll({ onlySession = null } = {}) {
|
||||
if (running) return running;
|
||||
running = (async () => {
|
||||
const out = { sessions: 0, bytes: 0, errors: [] };
|
||||
const s = readState();
|
||||
if (!s || !enrolled()) return out;
|
||||
let budget = ROUND_BYTES;
|
||||
for (const row of companySessions()) {
|
||||
if (onlySession && row.sessionId !== onlySession) continue;
|
||||
if (!row.live && sentOf(row.sessionId, ENDED) === 1) continue;
|
||||
try {
|
||||
const n = await uploadSession(s, row, budget);
|
||||
out.sessions++;
|
||||
out.bytes += n;
|
||||
budget -= n;
|
||||
} catch (e) {
|
||||
out.errors.push({ sessionId: row.sessionId, code: e.code, message: e.message });
|
||||
if (e.code === "EHUBDOWN" || e.code === "EDEVICE" || e.status === 401) break;
|
||||
}
|
||||
if (budget <= 0) break;
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
try {
|
||||
return await running;
|
||||
} finally {
|
||||
running = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** What was sent, per session, for the "Sent to the hub" view. */
|
||||
function uploads() {
|
||||
return sql(
|
||||
`SELECT u.session_id AS sessionId,
|
||||
SUM(CASE WHEN u.rel_path LIKE '#%' THEN 0 ELSE u.sent END) AS bytes,
|
||||
MAX(CASE WHEN u.rel_path LIKE '#%' THEN NULL ELSE u.sent_at END) AS lastSentAt,
|
||||
MAX(u.rel_path = '${ENDED}') AS ended,
|
||||
s.name AS title, s.cwd, s.started_at AS startedAt
|
||||
FROM remote_uploads u LEFT JOIN sessions s ON s.id = u.session_id
|
||||
GROUP BY u.session_id ORDER BY COALESCE(lastSentAt, s.started_at) DESC LIMIT 200`
|
||||
).all();
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
const endTimers = new Map();
|
||||
|
||||
/** The hourly round, while this machine is enrolled (checked at every tick). */
|
||||
function startUploader() {
|
||||
if (timer) return;
|
||||
const every = Number(process.env.DASHBOARD_REMOTE_UPLOAD_MS ?? 60 * 60 * 1000);
|
||||
if (!Number.isFinite(every) || every <= 0) return;
|
||||
timer = setInterval(() => {
|
||||
if (!readState()) return;
|
||||
uploadAll().catch((e) => console.warn(`[remote] upload round failed: ${e.message}`));
|
||||
}, every);
|
||||
timer.unref();
|
||||
}
|
||||
|
||||
/** A session ended: send its rest soon (debounced; only a Company-account one). */
|
||||
function onSessionEnd(sessionId) {
|
||||
if (!sessionId || !isCompany(sessionId) || !readState()) return;
|
||||
clearTimeout(endTimers.get(sessionId));
|
||||
const t = setTimeout(
|
||||
() => {
|
||||
endTimers.delete(sessionId);
|
||||
uploadAll({ onlySession: sessionId }).catch(() => {});
|
||||
},
|
||||
Number(process.env.DASHBOARD_REMOTE_END_DELAY_MS ?? 15_000)
|
||||
);
|
||||
t.unref();
|
||||
endTimers.set(sessionId, t);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HUB_PROXY,
|
||||
readState,
|
||||
status,
|
||||
enrolled,
|
||||
normalizeHub,
|
||||
login,
|
||||
logout,
|
||||
ping,
|
||||
registerSession,
|
||||
registerSoon,
|
||||
onHookSession,
|
||||
isCompany,
|
||||
claudeEnv,
|
||||
redactLine,
|
||||
companySessions,
|
||||
sessionFiles,
|
||||
readChunk,
|
||||
MAX_LINE_BYTES,
|
||||
uploadAll,
|
||||
uploads,
|
||||
startUploader,
|
||||
onSessionEnd,
|
||||
};
|
||||
Reference in New Issue
Block a user