From effe1b8af8b1b8419a8de677394259fe8ec3d847 Mon Sep 17 00:00:00 2001 From: kesslerio Date: Fri, 14 Aug 2026 20:51:22 -0700 Subject: [PATCH 1/9] feat(sessions): add dashboard OpenClaw and Hermes attach config Operators can attach conversation sources from Settings via local convention, state dir, or URL, with tokens kept in the secrets store. --- .gitignore | 1 + package.json | 2 +- server/__tests__/session-sources.test.js | 238 +++++++++++++++++++++++ server/config.js | 4 + server/index.js | 14 ++ server/secretsStore.js | 178 +++++++++++++---- server/sessionSources.js | 145 ++++++++++++++ src/api/client.ts | 13 ++ src/api/types.ts | 32 +++ src/components/SettingsDialog.tsx | 200 ++++++++++++++++++- 10 files changed, 784 insertions(+), 43 deletions(-) create mode 100644 server/__tests__/session-sources.test.js create mode 100644 server/sessionSources.js diff --git a/.gitignore b/.gitignore index 848f58f..ab604f0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ dist/ sparks.json config/sparks-secrets.json config/.secrets-key +config/session-sources.json config/bench-history.json config/gpu-memory.json *.log diff --git a/package.json b/package.json index 4601d7f..9c16565 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dev:client": "vite", "build": "vite build", "typecheck": "tsc --noEmit", - "test": "node --test server/collectors/__tests__/*.test.js server/sparks/__tests__/*.test.js", + "test": "node --test server/collectors/__tests__/*.test.js server/sparks/__tests__/*.test.js server/__tests__/*.test.js", "preview": "vite preview", "start": "node server/index.js", "docker:up": "docker compose up -d", diff --git a/server/__tests__/session-sources.test.js b/server/__tests__/session-sources.test.js new file mode 100644 index 0000000..6e0e9d2 --- /dev/null +++ b/server/__tests__/session-sources.test.js @@ -0,0 +1,238 @@ +/** + * Session source attach config (U1). + * + * Uses temp dirs + env path injection so tests never touch the real config/ volume. + * Run: node --test server/__tests__/session-sources.test.js + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sparkdash-session-sources-")); +const sourcesPath = path.join(tmpDir, "session-sources.json"); +const secretsPath = path.join(tmpDir, "sparks-secrets.json"); +const keyPath = path.join(tmpDir, ".secrets-key"); + +process.env.SESSION_SOURCES_JSON_PATH = sourcesPath; +process.env.SPARKS_SECRETS_PATH = secretsPath; +process.env.SECRETS_KEY_PATH = keyPath; +process.env.SPARKDASH_SECRETS_KEY = "sparkdash-session-sources-test-key"; +delete process.env.OPENCLAW_STATE_DIR; +delete process.env.HERMES_HOME; + +const secretsStore = await import("../secretsStore.js"); +const sessionSources = await import("../sessionSources.js"); + +const { + loadSecrets, + saveSecrets, + resetSecretsKeyCache, +} = secretsStore; +const { + getPublicSessionSources, + updateSessionSources, + loadSessionSources, +} = sessionSources; + +function resetFiles() { + for (const filePath of [sourcesPath, secretsPath, keyPath]) { + try { + fs.unlinkSync(filePath); + } catch { + /* missing is fine */ + } + } + if (typeof resetSecretsKeyCache === "function") { + resetSecretsKeyCache(); + } +} + +test.after(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test("AE4 config: both sources disabled/absent is a valid normalized config", () => { + resetFiles(); + const loaded = loadSessionSources(); + assert.equal(loaded.openclaw.enabled, false); + assert.equal(loaded.hermes.enabled, false); + const pub = getPublicSessionSources(); + assert.equal(pub.openclaw.enabled, false); + assert.equal(pub.hermes.enabled, false); + assert.equal(pub.openclaw.hasToken, false); + assert.equal(pub.hermes.hasToken, false); + assert.equal("token" in pub.openclaw, false); + assert.equal("token" in pub.hermes, false); +}); + +test("AE5: URL attach persists and round-trips without leaking the token on GET", () => { + resetFiles(); + const token = "super-secret-openclaw-token"; + const pub = updateSessionSources({ + openclaw: { + enabled: true, + mode: "url", + url: "http://127.0.0.1:18789", + token, + }, + }); + + assert.equal(pub.openclaw.enabled, true); + assert.equal(pub.openclaw.mode, "url"); + assert.equal(pub.openclaw.url, "http://127.0.0.1:18789"); + assert.equal(pub.openclaw.hasToken, true); + assert.equal("token" in pub.openclaw, false); + assert.equal(JSON.stringify(pub).includes(token), false); + + const disk = JSON.parse(fs.readFileSync(sourcesPath, "utf8")); + assert.equal(disk.openclaw.enabled, true); + assert.equal(disk.openclaw.mode, "url"); + assert.equal(disk.openclaw.url, "http://127.0.0.1:18789"); + assert.equal(JSON.stringify(disk).includes(token), false); + assert.equal("token" in (disk.openclaw || {}), false); + + const secretsRaw = fs.readFileSync(secretsPath, "utf8"); + assert.equal(secretsRaw.includes(token), false); + + const reloaded = getPublicSessionSources(); + assert.equal(reloaded.openclaw.enabled, true); + assert.equal(reloaded.openclaw.mode, "url"); + assert.equal(reloaded.openclaw.url, "http://127.0.0.1:18789"); + assert.equal(reloaded.openclaw.hasToken, true); + assert.equal("token" in reloaded.openclaw, false); + assert.equal(JSON.stringify(reloaded).includes(token), false); +}); + +test("local mode saves conventional product paths, not this fleet", () => { + resetFiles(); + const pub = updateSessionSources({ + openclaw: { enabled: true, mode: "local" }, + hermes: { enabled: true, mode: "local" }, + }); + + assert.equal(pub.openclaw.mode, "local"); + assert.equal(pub.hermes.mode, "local"); + assert.equal(pub.openclaw.conventionalStateDir, "~/.openclaw"); + assert.equal(pub.hermes.conventionalStateDir, "~/.hermes"); + + const dumped = `${JSON.stringify(pub)}\n${fs.readFileSync(sourcesPath, "utf8")}`; + assert.equal(/alphaclaw/i.test(dumped), false); + assert.equal(dumped.includes("/.local/state/alphaclaw"), false); + assert.match(dumped, /~\/\.openclaw/); + assert.match(dumped, /~\/\.hermes/); +}); + +test("documented attach fields and unknown extras survive save/reload", () => { + resetFiles(); + fs.writeFileSync( + sourcesPath, + JSON.stringify( + { + openclaw: { + enabled: true, + mode: "state-dir", + url: "http://127.0.0.1:18789", + stateDir: "/tmp/openclaw-state", + futureFlag: true, + }, + hermes: { + enabled: false, + mode: "local", + url: "", + stateDir: "", + extraReader: "v2", + }, + }, + null, + 2 + ) + "\n" + ); + + const loaded = loadSessionSources(); + assert.equal(loaded.openclaw.enabled, true); + assert.equal(loaded.openclaw.mode, "state-dir"); + assert.equal(loaded.openclaw.url, "http://127.0.0.1:18789"); + assert.equal(loaded.openclaw.stateDir, "/tmp/openclaw-state"); + assert.equal(loaded.openclaw.futureFlag, true); + assert.equal(loaded.hermes.extraReader, "v2"); + + updateSessionSources({ openclaw: { enabled: false } }); + const disk = JSON.parse(fs.readFileSync(sourcesPath, "utf8")); + assert.equal(disk.openclaw.enabled, false); + assert.equal(disk.openclaw.mode, "state-dir"); + assert.equal(disk.openclaw.url, "http://127.0.0.1:18789"); + assert.equal(disk.openclaw.stateDir, "/tmp/openclaw-state"); + assert.equal(disk.openclaw.futureFlag, true); + assert.equal(disk.hermes.extraReader, "v2"); +}); + +test("omitted token on PATCH does not wipe; empty string clears", () => { + resetFiles(); + updateSessionSources({ + openclaw: { enabled: true, mode: "url", url: "http://127.0.0.1:18789", token: "keep-me" }, + }); + assert.equal(getPublicSessionSources().openclaw.hasToken, true); + + updateSessionSources({ + openclaw: { enabled: true, mode: "url", url: "http://127.0.0.1:18789" }, + }); + assert.equal(getPublicSessionSources().openclaw.hasToken, true); + + updateSessionSources({ + openclaw: { token: "" }, + }); + assert.equal(getPublicSessionSources().openclaw.hasToken, false); +}); + +test("disallowed URL host is rejected", () => { + resetFiles(); + assert.throws( + () => + updateSessionSources({ + openclaw: { + enabled: true, + mode: "url", + url: "http://169.254.169.254:18789", + }, + }), + /disallowed host/i + ); + assert.equal(fs.existsSync(sourcesPath), false); + + assert.throws( + () => + updateSessionSources({ + hermes: { + enabled: true, + mode: "url", + url: "http://169.254.1.1:9119", + }, + }), + /disallowed host/i + ); +}); + +test("v1 sparks-secrets.json passwords still load after session-token field is added", () => { + resetFiles(); + saveSecrets(new Map([["spark-a", "ssh-password-a"]])); + const v1 = JSON.parse(fs.readFileSync(secretsPath, "utf8")); + assert.equal(v1.version, 1); + assert.ok(v1.secrets["spark-a"]); + assert.equal(v1.sessionSourceTokens, undefined); + assert.equal(loadSecrets().get("spark-a"), "ssh-password-a"); + + updateSessionSources({ hermes: { token: "hermes-session-token" } }); + assert.equal(loadSecrets().get("spark-a"), "ssh-password-a"); + assert.equal(getPublicSessionSources().hermes.hasToken, true); + + saveSecrets(new Map([["spark-a", "ssh-password-a"], ["spark-b", "ssh-password-b"]])); + assert.equal(loadSecrets().get("spark-a"), "ssh-password-a"); + assert.equal(loadSecrets().get("spark-b"), "ssh-password-b"); + assert.equal(getPublicSessionSources().hermes.hasToken, true); + + saveSecrets(new Map()); + assert.equal(loadSecrets().size, 0); + assert.equal(getPublicSessionSources().hermes.hasToken, true); +}); diff --git a/server/config.js b/server/config.js index fbb558c..a524b51 100644 --- a/server/config.js +++ b/server/config.js @@ -15,6 +15,9 @@ const SPARKS_SECRETS_PATH = /** AES key file (auto-generated if SPARKDASH_SECRETS_KEY unset). */ const SECRETS_KEY_PATH = process.env.SECRETS_KEY_PATH || path.join(ROOT, "config", ".secrets-key"); +/** Dashboard-level OpenClaw / Hermes Agent attach records (no tokens). */ +const SESSION_SOURCES_JSON_PATH = + process.env.SESSION_SOURCES_JSON_PATH || path.join(ROOT, "config", "session-sources.json"); // ─── LLM probe timeout ────────────────────────────────── const LLM_PROBE_TIMEOUT_MS = 3000; @@ -76,6 +79,7 @@ export { GPU_MEMORY_JSON_PATH, SPARKS_SECRETS_PATH, SECRETS_KEY_PATH, + SESSION_SOURCES_JSON_PATH, LLM_PROBE_TIMEOUT_MS, SSH_CONNECT_TIMEOUT, POLL_INTERVAL_GPU, diff --git a/server/index.js b/server/index.js index a64e2f6..ae597c7 100644 --- a/server/index.js +++ b/server/index.js @@ -11,6 +11,7 @@ import { SparkMonitor } from "./sparks/SparkMonitor.js"; import { sshExec, sshTest, llmTest } from "./collectors/ssh.js"; import { validateSparkTarget, createRateLimiter } from "./validate.js"; import { getSettings, updateSettings, loadSettings } from "./settings.js"; +import { getPublicSessionSources, updateSessionSources } from "./sessionSources.js"; import { broadcastForLanIp, effectiveMac, normalizeMac, sendWol } from "./wol.js"; import { decodeBenchManager, @@ -260,6 +261,19 @@ app.put("/api/settings", (req, res) => { } }); +// Dashboard-level conversation sources (tokens never returned) +app.get("/api/session-sources", (_req, res) => { + res.json(getPublicSessionSources()); +}); + +app.patch("/api/session-sources", (req, res) => { + try { + res.json(updateSessionSources(req.body || {})); + } catch (err) { + res.status(400).json({ error: err.message }); + } +}); + app.get("/api/sparks/:id/metrics", (req, res) => { const monitor = monitors.get(req.params.id); if (!monitor) return res.status(404).json({ error: "Spark not found" }); diff --git a/server/secretsStore.js b/server/secretsStore.js index bfc27ce..3962e6b 100644 --- a/server/secretsStore.js +++ b/server/secretsStore.js @@ -1,11 +1,16 @@ /** - * Encrypted SSH password store — survives process/Docker restarts. + * Encrypted secret store — survives process/Docker restarts. * - * Passwords are NEVER written to sparks.json and NEVER returned by the API. + * SSH passwords and session-source tokens are NEVER written to sparks.json + * / session-sources.json and NEVER returned by GET APIs. * They live in: * - memory (Map) for SSH collectors * - config/sparks-secrets.json (AES-256-GCM ciphertext, volume-mounted) * + * File shape (v1, backward compatible): + * { version: 1, secrets, sessionSourceTokens? } + * sessionSourceTokens keys: openclaw | hermes + * * Encryption key: * - SPARKDASH_SECRETS_KEY env (passphrase or 64-char hex), or * - auto-generated config/.secrets-key (persists with ./config volume) @@ -20,10 +25,16 @@ const ALGO = "aes-256-gcm"; const IV_LEN = 12; const TAG_LEN = 16; const KEY_LEN = 32; +const TOKEN_IDS = Object.freeze(["openclaw", "hermes"]); /** Cached key so we never regenerate mid-process. */ let _cachedKey = null; +/** Test helper: drop the in-process key cache. Does not rotate the key file. */ +export function resetSecretsKeyCache() { + _cachedKey = null; +} + function ensureDir(filePath) { const dir = path.dirname(filePath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); @@ -108,6 +119,73 @@ function decrypt(blobB64, key) { return Buffer.concat([decipher.update(data), decipher.final()]).toString("utf8"); } +function asBlobMap(value) { + if (typeof value !== "object" || value === null || Array.isArray(value)) return {}; + return value; +} + +function readRawStore() { + const empty = { version: 1, secrets: {}, sessionSourceTokens: {} }; + if (!fs.existsSync(SPARKS_SECRETS_PATH)) return empty; + const data = JSON.parse(fs.readFileSync(SPARKS_SECRETS_PATH, "utf8")); + return { + version: 1, + secrets: asBlobMap(data?.secrets), + sessionSourceTokens: asBlobMap(data?.sessionSourceTokens), + }; +} + +function hasTokenBlobs(tokenBlobs) { + return TOKEN_IDS.some((id) => typeof tokenBlobs[id] === "string" && tokenBlobs[id]); +} + +function unlinkStoreFile() { + if (!fs.existsSync(SPARKS_SECRETS_PATH)) return; + try { + fs.accessSync(SPARKS_SECRETS_PATH, fs.constants.W_OK); + fs.unlinkSync(SPARKS_SECRETS_PATH); + } catch (err) { + throw new Error( + `Failed to clear secrets file (permission?): ${err.message}. ` + + `Run: sudo chown -R $(id -u):$(id -g) config/sparks-secrets.json` + ); + } +} + +function persistStore(secretBlobs, tokenBlobs) { + const secrets = asBlobMap(secretBlobs); + const sessionSourceTokens = {}; + for (const id of TOKEN_IDS) { + if (typeof tokenBlobs?.[id] === "string" && tokenBlobs[id]) { + sessionSourceTokens[id] = tokenBlobs[id]; + } + } + const hasSecrets = Object.keys(secrets).length > 0; + if (!hasSecrets && !hasTokenBlobs(sessionSourceTokens)) { + unlinkStoreFile(); + return; + } + const payload = { version: 1, secrets }; + if (hasTokenBlobs(sessionSourceTokens)) payload.sessionSourceTokens = sessionSourceTokens; + atomicWrite(SPARKS_SECRETS_PATH, JSON.stringify(payload, null, 2) + "\n", 0o644); +} + +function decryptEntries(entries, key, kind) { + const map = new Map(); + let failed = 0; + for (const [id, blob] of Object.entries(entries)) { + if (!id || typeof blob !== "string") continue; + try { + const value = decrypt(blob, key); + if (value) map.set(id, value); + } catch { + failed += 1; + console.error(`[secretsStore] Failed to decrypt ${kind} for ${id} (wrong/missing key?)`); + } + } + return { map, failed }; +} + /** * Load sparkId -> password map from disk. * @returns {Map} @@ -118,24 +196,9 @@ export function loadSecrets() { try { const key = resolveKey(); - const raw = fs.readFileSync(SPARKS_SECRETS_PATH, "utf8"); - const data = JSON.parse(raw); - const entries = data?.secrets || {}; - if (typeof entries !== "object" || entries === null) return map; - - let failed = 0; - for (const [id, blob] of Object.entries(entries)) { - if (!id || typeof blob !== "string") continue; - try { - const pw = decrypt(blob, key); - if (pw) map.set(id, pw); - } catch { - failed += 1; - console.error( - `[secretsStore] Failed to decrypt password for ${id} (wrong/missing key?)` - ); - } - } + const raw = readRawStore(); + const { map: loaded, failed } = decryptEntries(raw.secrets, key, "password"); + for (const [id, pw] of loaded) map.set(id, pw); if (map.size > 0) { console.log(`[secretsStore] Loaded ${map.size} SSH password(s) from encrypted store`); } @@ -151,7 +214,8 @@ export function loadSecrets() { } /** - * Persist sparkId -> password map (encrypted). Empty map removes the file. + * Persist sparkId -> password map (encrypted). Empty passwords remove those + * slots; the file is deleted only when passwords AND session tokens are gone. * Throws on failure so callers can surface errors to the UI. * * Encrypted file mode is 0o644 so bind-mounted volumes stay usable across @@ -160,19 +224,9 @@ export function loadSecrets() { * @param {Map} passwords */ export function saveSecrets(passwords) { + const raw = readRawStore(); if (!passwords || passwords.size === 0) { - // Only delete if we can read the path; never "clear" on a failed load - if (fs.existsSync(SPARKS_SECRETS_PATH)) { - try { - fs.accessSync(SPARKS_SECRETS_PATH, fs.constants.W_OK); - fs.unlinkSync(SPARKS_SECRETS_PATH); - } catch (err) { - throw new Error( - `Failed to clear secrets file (permission?): ${err.message}. ` + - `Run: sudo chown -R $(id -u):$(id -g) config/sparks-secrets.json` - ); - } - } + persistStore({}, raw.sessionSourceTokens); return; } @@ -181,8 +235,60 @@ export function saveSecrets(passwords) { for (const [id, pw] of passwords.entries()) { if (pw) secrets[id] = encrypt(pw, key); } - - const payload = JSON.stringify({ version: 1, secrets }, null, 2) + "\n"; - atomicWrite(SPARKS_SECRETS_PATH, payload, 0o644); + persistStore(secrets, raw.sessionSourceTokens); console.log(`[secretsStore] Saved ${Object.keys(secrets).length} SSH password(s)`); } + +function decryptTokenMap(blobs, key) { + const { map } = decryptEntries(blobs, key, "session source token"); + /** @type {Record} */ + const out = {}; + for (const id of TOKEN_IDS) { + const value = map.get(id); + if (value) out[id] = value; + } + return out; +} + +/** @returns {Record} plaintext tokens keyed openclaw | hermes */ +export function loadSessionSourceTokens() { + try { + const raw = readRawStore(); + if (!hasTokenBlobs(raw.sessionSourceTokens)) return {}; + return decryptTokenMap(raw.sessionSourceTokens, resolveKey()); + } catch (err) { + console.error(`[secretsStore] Failed to load session source tokens: ${err.message}`); + return {}; + } +} + +export function hasSessionSourceToken(id) { + return Boolean(loadSessionSourceTokens()[id]); +} + +/** + * Merge session-source token slots. Omitted keys leave the stored token; + * empty string clears that slot. + * @param {{ openclaw?: string, hermes?: string }} patch + * @returns {Record} + */ +export function patchSessionSourceTokens(patch) { + const raw = readRawStore(); + const key = resolveKey(); + const current = decryptTokenMap(raw.sessionSourceTokens, key); + const body = patch && typeof patch === "object" ? patch : {}; + for (const id of TOKEN_IDS) { + if (!Object.prototype.hasOwnProperty.call(body, id)) continue; + const value = body[id]; + if (value == null) continue; + if (value === "") delete current[id]; + else current[id] = String(value); + } + /** @type {Record} */ + const tokenBlobs = {}; + for (const id of TOKEN_IDS) { + if (current[id]) tokenBlobs[id] = encrypt(current[id], key); + } + persistStore(raw.secrets, tokenBlobs); + return current; +} diff --git a/server/sessionSources.js b/server/sessionSources.js new file mode 100644 index 0000000..11dab73 --- /dev/null +++ b/server/sessionSources.js @@ -0,0 +1,145 @@ +/** + * Dashboard-level OpenClaw / Hermes Agent conversation-source attach config. + * Tokens live in secretsStore, never in this JSON file. + */ +import fs from "fs"; +import { SESSION_SOURCES_JSON_PATH } from "./config.js"; +import { atomicWrite } from "./util/atomicWrite.js"; +import { isAllowedTargetHost } from "./validate.js"; +import { hasSessionSourceToken, patchSessionSourceTokens } from "./secretsStore.js"; + +const SOURCE_IDS = Object.freeze(["openclaw", "hermes"]); +const MODES = new Set(["local", "url", "state-dir"]); +const PUBLIC_ONLY = new Set(["token", "hasToken", "conventionalStateDir"]); + +const DEFAULT_ATTACH = Object.freeze({ + enabled: false, + mode: "local", + url: "", + stateDir: "", +}); + +export function conventionalStateDir(id) { + if (id === "openclaw") { + const env = process.env.OPENCLAW_STATE_DIR; + return env && env.trim() ? env.trim() : "~/.openclaw"; + } + if (id === "hermes") { + const env = process.env.HERMES_HOME; + return env && env.trim() ? env.trim() : "~/.hermes"; + } + return ""; +} + +function normalizeAttach(raw) { + const extras = raw && typeof raw === "object" && !Array.isArray(raw) ? { ...raw } : {}; + for (const key of PUBLIC_ONLY) delete extras[key]; + const mode = MODES.has(extras.mode) ? extras.mode : DEFAULT_ATTACH.mode; + return { + ...extras, + enabled: Boolean(extras.enabled), + mode, + url: typeof extras.url === "string" ? extras.url.trim() : "", + stateDir: typeof extras.stateDir === "string" ? extras.stateDir.trim() : "", + }; +} + +function normalizeConfig(raw) { + const base = raw && typeof raw === "object" && !Array.isArray(raw) ? { ...raw } : {}; + return { + ...base, + openclaw: normalizeAttach(base.openclaw), + hermes: normalizeAttach(base.hermes), + }; +} + +function hostFromUrl(url) { + try { + return new URL(url).hostname; + } catch { + return ""; + } +} + +function validateAttach(attach) { + if (attach.mode !== "url" || !attach.url) return; + const host = hostFromUrl(attach.url); + if (!host || !isAllowedTargetHost(host)) { + throw new Error(`Invalid or disallowed host: ${host || attach.url}`); + } +} + +function persistableAttach(attach) { + const rest = { ...attach }; + for (const key of PUBLIC_ONLY) delete rest[key]; + return rest; +} + +function saveSessionSources(config) { + const payload = { + ...config, + openclaw: persistableAttach(config.openclaw), + hermes: persistableAttach(config.hermes), + }; + atomicWrite(SESSION_SOURCES_JSON_PATH, JSON.stringify(payload, null, 2) + "\n", 0o644); +} + +export function loadSessionSources() { + try { + const raw = fs.readFileSync(SESSION_SOURCES_JSON_PATH, "utf8"); + return normalizeConfig(JSON.parse(raw)); + } catch (err) { + if (err.code === "ENOENT") return normalizeConfig({}); + console.error("[sessionSources] Failed to load session-sources.json:", err.message); + return normalizeConfig({}); + } +} + +function publicAttach(id, attach) { + const rest = persistableAttach(attach); + return { + ...rest, + hasToken: hasSessionSourceToken(id), + conventionalStateDir: conventionalStateDir(id), + }; +} + +export function getPublicSessionSources() { + const config = loadSessionSources(); + return { + ...config, + openclaw: publicAttach("openclaw", config.openclaw), + hermes: publicAttach("hermes", config.hermes), + }; +} + +function tokenPatchFromBody(patch) { + /** @type {Record} */ + const out = {}; + for (const id of SOURCE_IDS) { + const src = patch[id]; + if (!src || typeof src !== "object") continue; + if (!Object.prototype.hasOwnProperty.call(src, "token")) continue; + if (src.token == null) continue; + out[id] = String(src.token); + } + return out; +} + +export function updateSessionSources(patch) { + const body = patch && typeof patch === "object" ? patch : {}; + const current = loadSessionSources(); + const next = { ...current }; + for (const id of SOURCE_IDS) { + const src = body[id]; + if (!src || typeof src !== "object") continue; + const attachPatch = { ...src }; + for (const key of PUBLIC_ONLY) delete attachPatch[key]; + next[id] = normalizeAttach({ ...current[id], ...attachPatch }); + validateAttach(next[id]); + } + const tokens = tokenPatchFromBody(body); + if (Object.keys(tokens).length > 0) patchSessionSourceTokens(tokens); + saveSessionSources(next); + return getPublicSessionSources(); +} diff --git a/src/api/client.ts b/src/api/client.ts index f83cfa3..53524e1 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -2,6 +2,8 @@ import type { DecodeBenchJob, DecodeBenchListResponse, LlmMetrics, + SessionSources, + SessionSourcesPatch, Settings, ShowcaseSessionState, ShowcaseStartRequest, @@ -306,3 +308,14 @@ export function updateSettings(patch: Partial): Promise { body: JSON.stringify(patch), }); } + +export function fetchSessionSources(): Promise { + return apiFetch("/api/session-sources"); +} + +export function updateSessionSources(patch: SessionSourcesPatch): Promise { + return apiFetch("/api/session-sources", { + method: "PATCH", + body: JSON.stringify(patch), + }); +} diff --git a/src/api/types.ts b/src/api/types.ts index 75d48b2..13ba5d0 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -252,6 +252,38 @@ export interface Settings { density: "comfortable" | "compact"; } +export type SessionSourceMode = "local" | "url" | "state-dir"; + +/** Dashboard-level OpenClaw / Hermes Agent attach record. Token never returned. */ +export interface SessionSourceAttach { + enabled: boolean; + mode: SessionSourceMode; + url: string; + stateDir: string; + hasToken: boolean; + /** Conventional local path (`~/.openclaw` / `~/.hermes`, or env override). */ + conventionalStateDir: string; +} + +export interface SessionSources { + openclaw: SessionSourceAttach; + hermes: SessionSourceAttach; +} + +export interface SessionSourcePatch { + enabled?: boolean; + mode?: SessionSourceMode; + url?: string; + stateDir?: string; + /** Omit to leave stored token; empty string clears. Never returned on GET. */ + token?: string; +} + +export interface SessionSourcesPatch { + openclaw?: SessionSourcePatch; + hermes?: SessionSourcePatch; +} + export interface SparksListResponse { sparks: SparkConfig[]; } diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index d073577..e92e57c 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -1,6 +1,17 @@ import { useEffect, useState } from "react"; -import { fetchSettings, updateSettings } from "../api/client"; -import type { Settings } from "../api/types"; +import { + fetchSessionSources, + fetchSettings, + updateSessionSources, + updateSettings, +} from "../api/client"; +import type { + SessionSourceAttach, + SessionSourceMode, + SessionSources, + SessionSourcesPatch, + Settings, +} from "../api/types"; import { useModalPresence } from "../hooks/useModalPresence"; interface SettingsDialogProps { @@ -26,8 +37,130 @@ const POLL_PRESETS = [ { label: "10s", value: 10000 }, ]; +const SOURCE_LABELS = { openclaw: "OpenClaw", hermes: "Hermes Agent" } as const; +const SOURCE_IDS = ["openclaw", "hermes"] as const; +const MODE_OPTIONS: { value: SessionSourceMode; label: string }[] = [ + { value: "local", label: "Local" }, + { value: "url", label: "URL" }, + { value: "state-dir", label: "State dir" }, +]; + +const fieldClass = + "w-full rounded border border-border bg-surface-elevated px-3 py-1.5 text-xs text-text outline-none focus:border-accent"; + +function Toggle({ + on, + onClick, +}: { + on: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function SessionSourceFields({ + id, + source, + tokenDraft, + onSource, + onToken, + onClearToken, +}: { + id: (typeof SOURCE_IDS)[number]; + source: SessionSourceAttach; + tokenDraft: string; + onSource: (patch: Partial) => void; + onToken: (value: string) => void; + onClearToken: () => void; +}) { + return ( +
+ + + {source.mode === "local" && ( +

Uses {source.conventionalStateDir}

+ )} + {source.mode === "url" && ( + onSource({ url: e.target.value })} + placeholder="http://127.0.0.1:18789" + className={fieldClass} + aria-label={`${SOURCE_LABELS[id]} URL`} + /> + )} + {source.mode === "state-dir" && ( + onSource({ stateDir: e.target.value })} + placeholder="State directory" + className={fieldClass} + aria-label={`${SOURCE_LABELS[id]} state directory`} + /> + )} +
+ onToken(e.target.value)} + placeholder={ + source.hasToken ? "Token stored — leave blank to keep" : "Optional token" + } + className={fieldClass} + aria-label={`${SOURCE_LABELS[id]} token`} + /> + {source.hasToken && !tokenDraft && ( + + )} +
+
+ ); +} + export function SettingsDialog({ open, onClose, onSaved }: SettingsDialogProps) { const [settings, setSettings] = useState(null); + const [sessionSources, setSessionSources] = useState(null); + const [tokenDrafts, setTokenDrafts] = useState({ openclaw: "", hermes: "" }); + const [clearTokens, setClearTokens] = useState({ openclaw: false, hermes: false }); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -38,15 +171,20 @@ export function SettingsDialog({ open, onClose, onSaved }: SettingsDialogProps) useEffect(() => { if (!open) { setSettings(null); + setSessionSources(null); + setTokenDrafts({ openclaw: "", hermes: "" }); + setClearTokens({ openclaw: false, hermes: false }); setError(null); setDirty(false); return; } let cancelled = false; setLoading(true); - fetchSettings() - .then((s) => { - if (!cancelled) setSettings(s); + Promise.all([fetchSettings(), fetchSessionSources()]) + .then(([s, sources]) => { + if (cancelled) return; + setSettings(s); + setSessionSources(sources); }) .catch((err: Error) => { if (!cancelled) setError(err.message); @@ -66,11 +204,31 @@ export function SettingsDialog({ open, onClose, onSaved }: SettingsDialogProps) setDirty(true); }; + const patchSource = (id: (typeof SOURCE_IDS)[number], patch: Partial) => { + setSessionSources((prev) => (prev ? { ...prev, [id]: { ...prev[id], ...patch } } : prev)); + setDirty(true); + }; + const handleSave = async () => { if (!settings) return; setSaving(true); setError(null); try { + if (sessionSources) { + const sessionPatch: SessionSourcesPatch = {}; + for (const id of SOURCE_IDS) { + const src = sessionSources[id]; + const draft = tokenDrafts[id]; + sessionPatch[id] = { + enabled: src.enabled, + mode: src.mode, + url: src.url, + stateDir: src.stateDir, + ...(clearTokens[id] && !draft ? { token: "" } : draft ? { token: draft } : {}), + }; + } + await updateSessionSources(sessionPatch); + } const result = await updateSettings(settings); setSettings(result); setDirty(false); @@ -94,7 +252,7 @@ export function SettingsDialog({ open, onClose, onSaved }: SettingsDialogProps) if (e.target === e.currentTarget) onClose(); }} > -
+

Settings

{loading &&

Loading…

} @@ -252,6 +410,36 @@ export function SettingsDialog({ open, onClose, onSaved }: SettingsDialogProps)
+ + {sessionSources && ( +
+

Session sources

+

+ Optional OpenClaw and Hermes Agent conversations. Local defaults are{" "} + ~/.openclaw (or OPENCLAW_STATE_DIR) and ~/.hermes (or HERMES_HOME). Use a + state dir or URL when the product is on another host or in Docker. +

+ {SOURCE_IDS.map((id) => ( + patchSource(id, patch)} + onToken={(value) => { + setTokenDrafts((prev) => ({ ...prev, [id]: value })); + setClearTokens((prev) => ({ ...prev, [id]: false })); + setDirty(true); + }} + onClearToken={() => { + setTokenDrafts((prev) => ({ ...prev, [id]: "" })); + setClearTokens((prev) => ({ ...prev, [id]: true })); + patchSource(id, { hasToken: false }); + }} + /> + ))} +
+ )}
)} From 47a431580bc579572b2fe47b555dfd0cde02ad59 Mon Sep 17 00:00:00 2001 From: kesslerio Date: Fri, 14 Aug 2026 20:56:48 -0700 Subject: [PATCH 2/9] feat(sessions): project OpenClaw and Hermes rows onto Sparks Match conversation origins to Spark LLM listen host and port, and badge each row from mid-turn only so recency cannot mint generating. --- .../__tests__/sessionProjector.test.js | 221 ++++++++++++++++++ server/collectors/sessionProjector.js | 82 +++++++ src/api/types.ts | 14 ++ 3 files changed, 317 insertions(+) create mode 100644 server/collectors/__tests__/sessionProjector.test.js create mode 100644 server/collectors/sessionProjector.js diff --git a/server/collectors/__tests__/sessionProjector.test.js b/server/collectors/__tests__/sessionProjector.test.js new file mode 100644 index 0000000..5435874 --- /dev/null +++ b/server/collectors/__tests__/sessionProjector.test.js @@ -0,0 +1,221 @@ +/** + * Origin projector (U2): source session rows → per-Spark conversation lists. + * + * Pure function — no HTTP, no clocks, no engine /slots joins. + * Run: npm test + */ +import { test } from "node:test"; +import { strict as assert } from "node:assert"; +import { projectConversations } from "../sessionProjector.js"; + +function spark(overrides = {}) { + return { + id: "spark-local", + lanIp: "192.168.4.51", + isLocal: true, + llmPorts: [8888], + role: "standalone", + ...overrides, + }; +} + +function row(overrides = {}) { + return { + source: "openclaw", + handle: "topic-a", + originHost: "192.168.4.51", + originPort: 8888, + midTurn: true, + ...overrides, + }; +} + +function rowsFor(result, sparkId) { + assert.equal(sparkId in result, true, `expected key ${sparkId}`); + return result[sparkId]; +} + +test("AE1: mid-turn + origin match is generating; midTurn false is stalled", () => { + const sparks = [spark()]; + const generating = projectConversations([row({ handle: "chat-a", midTurn: true })], sparks); + assert.deepEqual(rowsFor(generating, "spark-local"), [ + { source: "openclaw", handle: "chat-a", badge: "generating", port: 8888 }, + ]); + + const stalled = projectConversations([row({ handle: "chat-a", midTurn: false })], sparks); + assert.deepEqual(rowsFor(stalled, "spark-local"), [ + { source: "openclaw", handle: "chat-a", badge: "stalled", port: 8888 }, + ]); +}); + +test("AE2: one mid-turn sibling generating, the other stalled", () => { + const result = projectConversations( + [ + row({ handle: "chat-a", midTurn: true }), + row({ handle: "chat-b", midTurn: false }), + ], + [spark()] + ); + const list = rowsFor(result, "spark-local"); + const byHandle = Object.fromEntries(list.map((r) => [r.handle, r])); + assert.equal(byHandle["chat-a"].badge, "generating"); + assert.equal(byHandle["chat-b"].badge, "stalled"); + assert.equal(list.length, 2); +}); + +test("AE3: midTurn unknown is unknown, never generating", () => { + const result = projectConversations( + [row({ handle: "chat-u", midTurn: "unknown" })], + [spark()] + ); + assert.deepEqual(rowsFor(result, "spark-local"), [ + { source: "openclaw", handle: "chat-u", badge: "unknown", port: 8888 }, + ]); +}); + +test("cloud api.openai.com origin matches no Spark", () => { + const result = projectConversations( + [ + row({ + handle: "cloud-chat", + originHost: "api.openai.com", + originPort: 443, + midTurn: true, + }), + ], + [spark(), spark({ id: "spark-b", lanIp: "192.168.4.52", isLocal: false, llmPorts: [8888] })] + ); + assert.deepEqual(result, {}); +}); + +test("two Sparks, same model id, different ports: row only on matching port", () => { + const sparks = [ + spark({ id: "spark-a", lanIp: "192.168.4.51", isLocal: false, llmPorts: [4000], modelId: "same-model" }), + spark({ id: "spark-b", lanIp: "192.168.4.52", isLocal: false, llmPorts: [4001], modelId: "same-model" }), + ]; + const result = projectConversations( + [ + row({ + handle: "shared-model-chat", + originHost: "192.168.4.52", + originPort: 4001, + midTurn: true, + }), + ], + sparks + ); + assert.equal("spark-a" in result, false); + assert.deepEqual(rowsFor(result, "spark-b"), [ + { source: "openclaw", handle: "shared-model-chat", badge: "generating", port: 4001 }, + ]); +}); + +test("local Spark matches loopback origin; non-local Spark with other lanIp does not", () => { + const localSpark = spark({ + id: "spark-local", + lanIp: "192.168.4.51", + isLocal: true, + llmPorts: [8888], + }); + const remoteSpark = spark({ + id: "spark-remote", + lanIp: "192.168.4.99", + isLocal: false, + llmPorts: [8888], + }); + const result = projectConversations( + [row({ handle: "loopback-chat", originHost: "127.0.0.1", originPort: 8888, midTurn: true })], + [localSpark, remoteSpark] + ); + assert.deepEqual(rowsFor(result, "spark-local"), [ + { source: "openclaw", handle: "loopback-chat", badge: "generating", port: 8888 }, + ]); + assert.equal("spark-remote" in result, false); +}); + +test("recency-only is_active does not mint generating", () => { + const result = projectConversations( + [ + row({ + handle: "recent-chat", + midTurn: "unknown", + is_active: true, + isActive: true, + }), + row({ + source: "hermes", + handle: "recency-only", + midTurn: undefined, + is_active: true, + isActive: true, + }), + ], + [spark()] + ); + const list = rowsFor(result, "spark-local"); + for (const conversation of list) { + assert.equal(conversation.badge, "unknown"); + assert.notEqual(conversation.badge, "generating"); + } + assert.equal(list.length, 2); +}); + +test("projected JSON has no Date.now-like changing field", () => { + const rows = [ + row({ handle: "chat-a", midTurn: true }), + row({ source: "hermes", handle: "chat-b", midTurn: false }), + ]; + const sparks = [spark()]; + const first = projectConversations(rows, sparks); + const second = projectConversations(rows, sparks); + const json1 = JSON.stringify(first); + const json2 = JSON.stringify(second); + const jsonAgain = JSON.stringify(first); + assert.equal(json1, json2); + assert.equal(json1, jsonAgain); + assert.equal(/\d{13}/.test(json1), false, "must not embed millisecond timestamps"); + for (const conversation of first["spark-local"]) { + assert.deepEqual(Object.keys(conversation).sort(), ["badge", "handle", "port", "source"]); + } +}); + +test("localhost and bracketed ::1 match an isLocal Spark", () => { + const local = spark({ id: "spark-local", isLocal: true, llmPorts: [8888] }); + const localhostHit = projectConversations( + [row({ handle: "lh", originHost: "localhost", originPort: 8888 })], + [local] + ); + const v6Hit = projectConversations( + [row({ handle: "v6", originHost: "[::1]", originPort: 8888 })], + [local] + ); + assert.equal(rowsFor(localhostHit, "spark-local")[0].handle, "lh"); + assert.equal(rowsFor(v6Hit, "spark-local")[0].handle, "v6"); +}); + +test("worker with empty llmPorts gets no rows even when lanIp matches", () => { + const worker = spark({ + id: "spark-worker", + lanIp: "192.168.4.51", + isLocal: false, + llmPorts: [], + role: "worker", + workerNode: true, + }); + const result = projectConversations([row({ midTurn: true })], [worker]); + assert.deepEqual(result, {}); +}); + +test("list is capped at 20 and sorted by source, handle, port", () => { + const rows = []; + for (let i = 0; i < 21; i++) { + const n = String(20 - i).padStart(2, "0"); + rows.push(row({ source: "hermes", handle: `h-${n}`, midTurn: false })); + } + rows.push(row({ source: "openclaw", handle: "z-last", originPort: 8888, midTurn: false })); + const list = rowsFor(projectConversations(rows, [spark()]), "spark-local"); + assert.equal(list.length, 20); + const keys = list.map((r) => `${r.source}\0${r.handle}\0${r.port}`); + const sorted = [...keys].sort(); + assert.deepEqual(keys, sorted); +}); diff --git a/server/collectors/sessionProjector.js b/server/collectors/sessionProjector.js new file mode 100644 index 0000000..cbb7b02 --- /dev/null +++ b/server/collectors/sessionProjector.js @@ -0,0 +1,82 @@ +/** + * Project source session rows onto Sparks by LLM listen origin (host+port). + * Occupancy badges are per-conversation mid-turn, never recency or clocks. + */ + +const LOOPBACK_HOSTS = ["127.0.0.1", "localhost", "::1"]; +const LIST_CAP = 20; + +/** + * @param {object[]} rows + * @param {object[]} sparks + * @returns {Record} + */ +export function projectConversations(rows, sparks) { + const bySpark = {}; + for (const spark of Array.isArray(sparks) ? sparks : []) { + if (!spark?.id) continue; + const projected = projectSpark(Array.isArray(rows) ? rows : [], spark); + if (projected.length > 0) bySpark[spark.id] = projected; + } + return bySpark; +} + +function projectSpark(rows, spark) { + const ports = listenPorts(spark); + if (ports.size === 0) return []; + const hosts = listenHosts(spark); + const matched = []; + for (const row of rows) { + const port = Number(row?.originPort); + if (!ports.has(port)) continue; + if (!hosts.has(normalizeHost(row?.originHost))) continue; + matched.push(toConversationRow(row, port)); + } + matched.sort(compareRows); + return matched.slice(0, LIST_CAP); +} + +function listenPorts(spark) { + const ports = new Set(); + for (const value of spark.llmPorts ?? []) { + const port = Number(value); + if (Number.isInteger(port) && port >= 1 && port <= 65535) ports.add(port); + } + return ports; +} + +function listenHosts(spark) { + const hosts = new Set(); + const lan = normalizeHost(spark.lanIp); + if (lan) hosts.add(lan); + if (spark.isLocal) { + for (const host of LOOPBACK_HOSTS) hosts.add(host); + } + return hosts; +} + +function normalizeHost(value) { + if (value == null) return ""; + let host = String(value).trim().toLowerCase(); + if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1); + return host; +} + +function toConversationRow(row, port) { + return { + source: row.source, + handle: String(row.handle ?? ""), + badge: badgeFromMidTurn(row.midTurn), + port, + }; +} + +function badgeFromMidTurn(midTurn) { + if (midTurn === true) return "generating"; + if (midTurn === false) return "stalled"; + return "unknown"; +} + +function compareRows(a, b) { + return a.source.localeCompare(b.source) || a.handle.localeCompare(b.handle) || a.port - b.port; +} diff --git a/src/api/types.ts b/src/api/types.ts index 13ba5d0..ab05694 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -205,6 +205,18 @@ export interface SparkMetrics { llm: LlmMetrics[]; } +// ─── Bound gateway conversations (occupancy; not LlmMetrics) ─ +export type ConversationSource = "openclaw" | "hermes"; +export type ConversationBadge = "generating" | "stalled" | "unknown"; + +/** Live-calls row: handle + badge only. No transcripts or session files. */ +export interface ConversationRow { + source: ConversationSource; + handle: string; + badge: ConversationBadge; + port: number; +} + // ─── Spark snapshot (server pushes this) ────────────────── export interface SparkSnapshot { id: string; @@ -231,6 +243,8 @@ export interface SparkSnapshot { llmPorts: number[]; hardware: HardwareInfo; metrics: SparkMetrics; + /** Bound OpenClaw / Hermes conversations. Omit when empty (U5). */ + conversations?: ConversationRow[]; } // ─── WebSocket envelope ─────────────────────────────────── From 8e9fe1df350c04e3b79348128ace8160228aa92d Mon Sep 17 00:00:00 2001 From: kesslerio Date: Fri, 14 Aug 2026 21:03:50 -0700 Subject: [PATCH 3/9] feat(sessions): collect OpenClaw conversations without engine joins Map sessions.list rows to projector input using provider baseUrl and hasActiveRun, returning an empty list when the source is unreachable. --- server/collectors/OpenClawSessions.js | 195 +++++++++++ .../__tests__/OpenClawSessions.test.js | 311 ++++++++++++++++++ 2 files changed, 506 insertions(+) create mode 100644 server/collectors/OpenClawSessions.js create mode 100644 server/collectors/__tests__/OpenClawSessions.test.js diff --git a/server/collectors/OpenClawSessions.js b/server/collectors/OpenClawSessions.js new file mode 100644 index 0000000..29a5f54 --- /dev/null +++ b/server/collectors/OpenClawSessions.js @@ -0,0 +1,195 @@ +/** + * OpenClaw conversation collector (U3). + * Projector input rows only: source, handle, origin, midTurn. + * Occupancy is hasActiveRun (or status===running). Never transcripts. Never throws. + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { HOST_PATHS } from "../config.js"; +import { conventionalStateDir } from "../sessionSources.js"; + +const HANDLE_FIELDS = ["label", "displayName", "key"]; + +/** + * @param {string} url + * @returns {{ host: string, port: number } | null} + */ +export function parseBaseUrl(url) { + if (!url || typeof url !== "string") return null; + try { + const parsed = new URL(url); + const host = parsed.hostname; + if (!host) return null; + const port = parsed.port + ? Number(parsed.port) + : parsed.protocol === "https:" + ? 443 + : 80; + if (!Number.isInteger(port) || port < 1 || port > 65535) return null; + return { host, port }; + } catch { + return null; + } +} + +/** + * @param {unknown} sessions + * @param {Record} providers + * @returns {object[]} + */ +export function mapOpenClawSessions(sessions, providers) { + const list = normalizeSessions(sessions); + const byId = providers && typeof providers === "object" ? providers : {}; + const rows = []; + for (const item of list) { + const row = mapOneSession(item, byId); + if (row) rows.push(row); + } + return rows; +} + +/** + * @param {{ enabled?: boolean, mode?: string, url?: string, stateDir?: string }} attach + * @param {object} [deps] + * @returns {Promise} + */ +export async function collectOpenClawSessions(attach, deps = {}) { + try { + if (!attach?.enabled) return []; + const loaded = await loadOpenClawPayload(attach, deps); + if (!loaded) return []; + return mapOpenClawSessions(loaded.sessions, loaded.providers); + } catch { + return []; + } +} + +function mapOneSession(session, providers) { + if (!session || typeof session !== "object") return null; + const origin = parseBaseUrl(providers[session.modelProvider]?.baseUrl); + if (!origin) return null; + const handle = sessionHandle(session); + if (!handle) return null; + return { + source: "openclaw", + handle, + originHost: origin.host, + originPort: origin.port, + midTurn: midTurnOf(session), + }; +} + +function sessionHandle(session) { + for (const field of HANDLE_FIELDS) { + const value = session[field]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return ""; +} + +function midTurnOf(session) { + if (session.hasActiveRun === true) return true; + if (session.hasActiveRun === false) return false; + if (session.status === "running") return true; + return "unknown"; +} + +function normalizeSessions(sessions) { + if (Array.isArray(sessions)) return sessions; + if (sessions && Array.isArray(sessions.sessions)) return sessions.sessions; + if (sessions && typeof sessions === "object") return sessionsFromMap(sessions); + return []; +} + +function sessionsFromMap(store) { + const rows = []; + for (const [key, value] of Object.entries(store)) { + if (!value || typeof value !== "object" || Array.isArray(value)) continue; + rows.push({ key, ...value }); + } + return rows; +} + +async function loadOpenClawPayload(attach, deps) { + if (attach.mode === "url") return loadFromUrl(attach, deps); + return loadFromStateDir(attach, deps); +} + +async function loadFromUrl(attach, deps) { + if (typeof deps.rpc === "function") return loadFromRpc(deps.rpc); + const fetchJson = deps.fetchJson ?? defaultFetchJson; + return unwrapGatewayPayload(await fetchJson(attach.url, { token: deps.token })); +} + +async function loadFromRpc(rpc) { + const [listed, config] = await Promise.all([rpc("sessions.list"), rpc("config.get")]); + const sessions = Array.isArray(listed) ? listed : listed?.sessions ?? listed; + return { sessions, providers: config?.models?.providers ?? {} }; +} + +function unwrapGatewayPayload(payload) { + if (!payload || typeof payload !== "object") return { sessions: [], providers: {} }; + return { + sessions: payload.sessions ?? [], + providers: payload.providers ?? payload.models?.providers ?? {}, + }; +} + +async function loadFromStateDir(attach, deps) { + const dir = resolveStateDir(attach, deps); + const readFile = deps.readFile ?? ((filePath) => fs.promises.readFile(filePath, "utf8")); + const config = JSON.parse(await readFile(path.join(dir, "openclaw.json"))); + const sessionsRaw = JSON.parse(await readFile(path.join(dir, "sessions.json"))); + return { + sessions: sessionsRaw?.sessions ?? sessionsRaw, + providers: config?.models?.providers ?? {}, + }; +} + +function resolveStateDir(attach, deps) { + const home = deps.homedir ?? os.homedir(); + if (attach.mode === "state-dir" && attach.stateDir) { + return expandTilde(attach.stateDir, home); + } + const conventional = deps.conventionalStateDir ?? conventionalStateDir("openclaw"); + return remapHostRoot(expandTilde(String(conventional || ""), home), deps); +} + +function expandTilde(raw, home) { + const value = String(raw || ""); + if (value === "~") return home; + if (value.startsWith("~/")) return path.join(home, value.slice(2)); + return value; +} + +function remapHostRoot(expanded, deps) { + const hostRoot = deps.hostRoot === undefined ? HOST_PATHS.ROOT : deps.hostRoot; + if (!hostRoot) return expanded; + const isReadable = deps.isReadable ?? pathReadable; + if (isReadable(expanded)) return expanded; + if (!expanded.startsWith("/") || !isReadable(hostRoot)) return expanded; + const mapped = path.join(hostRoot, expanded.slice(1)); + return isReadable(mapped) ? mapped : expanded; +} + +function pathReadable(filePath) { + try { + fs.accessSync(filePath); + return true; + } catch { + return false; + } +} + +async function defaultFetchJson(url, { token } = {}) { + const headers = { Accept: "application/json" }; + if (token) headers.Authorization = `Bearer ${token}`; + const res = await fetch(url, { headers }); + if (!res.ok) { + const err = new Error(`HTTP ${res.status}`); + err.status = res.status; + throw err; + } + return res.json(); +} diff --git a/server/collectors/__tests__/OpenClawSessions.test.js b/server/collectors/__tests__/OpenClawSessions.test.js new file mode 100644 index 0000000..f5e1e0d --- /dev/null +++ b/server/collectors/__tests__/OpenClawSessions.test.js @@ -0,0 +1,311 @@ +/** + * OpenClaw conversation collector (U3): sessions + provider origins → projector rows. + * + * Fixture JSON only. Injected loaders — no live gateway, no host ~/.openclaw. + * Run: node --test server/collectors/__tests__/OpenClawSessions.test.js + */ +import { test } from "node:test"; +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { + mapOpenClawSessions, + collectOpenClawSessions, +} from "../OpenClawSessions.js"; + +const MODULE_PATH = fileURLToPath(new URL("../OpenClawSessions.js", import.meta.url)); + +const SPARK_PROVIDERS = { + spark: { baseUrl: "http://127.0.0.1:4000/v1" }, +}; + +function session(overrides = {}) { + return { + key: "agent:main:telegram:topic:1", + label: "World Cup", + modelProvider: "spark", + hasActiveRun: true, + ...overrides, + }; +} + +function expectedRow(overrides = {}) { + return { + source: "openclaw", + handle: "World Cup", + originHost: "127.0.0.1", + originPort: 4000, + midTurn: true, + ...overrides, + }; +} + +test("hasActiveRun true + baseUrl http://127.0.0.1:4000 maps origin and midTurn true", () => { + const rows = mapOpenClawSessions([session({ hasActiveRun: true })], SPARK_PROVIDERS); + assert.deepEqual(rows, [expectedRow({ midTurn: true })]); +}); + +test("hasActiveRun false is midTurn false", () => { + const rows = mapOpenClawSessions([session({ hasActiveRun: false })], SPARK_PROVIDERS); + assert.deepEqual(rows, [expectedRow({ midTurn: false })]); +}); + +test("missing occupancy field is midTurn unknown", () => { + const rows = mapOpenClawSessions( + [session({ hasActiveRun: undefined, status: undefined })], + SPARK_PROVIDERS + ); + assert.equal(rows.length, 1); + assert.equal(rows[0].midTurn, "unknown"); +}); + +test("status running is midTurn true when hasActiveRun is absent", () => { + const { hasActiveRun: _omit, ...rest } = session(); + const rows = mapOpenClawSessions([{ ...rest, status: "running" }], SPARK_PROVIDERS); + assert.equal(rows[0].midTurn, true); +}); + +test("hasActiveRun false wins over status running", () => { + const rows = mapOpenClawSessions( + [session({ hasActiveRun: false, status: "running" })], + SPARK_PROVIDERS + ); + assert.equal(rows[0].midTurn, false); +}); + +test("handle comes from label, never lastMessage / preview / transcript", () => { + const rows = mapOpenClawSessions( + [ + session({ + label: "Topic A", + lastMessage: "secret transcript body", + preview: "preview text", + transcript: "full transcript", + }), + ], + SPARK_PROVIDERS + ); + assert.equal(rows[0].handle, "Topic A"); + const json = JSON.stringify(rows); + assert.equal(json.includes("secret transcript body"), false); + assert.equal(json.includes("preview text"), false); + assert.equal(json.includes("full transcript"), false); + assert.deepEqual(Object.keys(rows[0]).sort(), [ + "handle", + "midTurn", + "originHost", + "originPort", + "source", + ]); +}); + +test("handle falls back to key, never a transcript field", () => { + const rows = mapOpenClawSessions( + [ + session({ + label: "", + displayName: "", + key: "agent:main:discord:ch1", + lastMessage: "hello world transcript", + preview: "sneaky preview", + }), + ], + SPARK_PROVIDERS + ); + assert.equal(rows[0].handle, "agent:main:discord:ch1"); + assert.equal(JSON.stringify(rows).includes("hello world transcript"), false); + assert.equal(JSON.stringify(rows).includes("sneaky preview"), false); +}); + +test("handle falls back to displayName when label is empty", () => { + const rows = mapOpenClawSessions( + [session({ label: " ", displayName: "Agent Main" })], + SPARK_PROVIDERS + ); + assert.equal(rows[0].handle, "Agent Main"); +}); + +test("cloud provider https://api.openai.com/v1 still emits a row", () => { + const rows = mapOpenClawSessions( + [session({ modelProvider: "openai", label: "cloud-chat" })], + { openai: { baseUrl: "https://api.openai.com/v1" } } + ); + assert.equal(rows.length, 1); + assert.equal(rows[0].originHost, "api.openai.com"); + assert.equal(rows[0].originPort, 443); + assert.equal(rows[0].handle, "cloud-chat"); + assert.equal(rows[0].source, "openclaw"); +}); + +test("session without provider baseUrl is omitted", () => { + const rows = mapOpenClawSessions( + [session({ modelProvider: "missing" }), session({ hasActiveRun: true })], + SPARK_PROVIDERS + ); + assert.deepEqual(rows, [expectedRow()]); +}); + +test("store map without hasActiveRun is unknown unless status running", () => { + const store = { + "sess-a": { modelProvider: "spark", label: "A" }, + "sess-b": { modelProvider: "spark", label: "B", status: "running" }, + }; + const rows = mapOpenClawSessions(store, SPARK_PROVIDERS); + const byHandle = Object.fromEntries(rows.map((r) => [r.handle, r])); + assert.equal(byHandle.A.midTurn, "unknown"); + assert.equal(byHandle.B.midTurn, true); + assert.equal(byHandle.A.originPort, 4000); +}); + +test("wrapped { sessions: [...] } list is accepted", () => { + const rows = mapOpenClawSessions({ sessions: [session({ hasActiveRun: false })] }, SPARK_PROVIDERS); + assert.equal(rows[0].midTurn, false); +}); + +test("disabled attach returns [] and does not load", async () => { + let loaded = false; + const rows = await collectOpenClawSessions( + { enabled: false, mode: "local" }, + { + readFile: async () => { + loaded = true; + throw new Error("should not read"); + }, + fetchJson: async () => { + loaded = true; + throw new Error("should not fetch"); + }, + } + ); + assert.deepEqual(rows, []); + assert.equal(loaded, false); +}); + +test("unreachable / throwing loader returns [] and does not throw", async () => { + const rows = await collectOpenClawSessions( + { enabled: true, mode: "url", url: "http://127.0.0.1:18789" }, + { + fetchJson: async () => { + throw new Error("ECONNREFUSED"); + }, + } + ); + assert.deepEqual(rows, []); +}); + +test("url 404 returns []", async () => { + const rows = await collectOpenClawSessions( + { enabled: true, mode: "url", url: "http://127.0.0.1:18789" }, + { + fetchJson: async () => { + const err = new Error("HTTP 404"); + err.status = 404; + throw err; + }, + } + ); + assert.deepEqual(rows, []); +}); + +test("url mode unwraps models.providers and nested sessions; token from deps", async () => { + let seenToken; + const rows = await collectOpenClawSessions( + { + enabled: true, + mode: "url", + url: "http://127.0.0.1:18789", + token: "from-attach", + }, + { + token: "from-deps", + fetchJson: async (url, opts) => { + assert.equal(url, "http://127.0.0.1:18789"); + seenToken = opts.token; + return { + sessions: { sessions: [session({ hasActiveRun: false })] }, + models: { providers: SPARK_PROVIDERS }, + }; + }, + } + ); + assert.equal(seenToken, "from-deps"); + assert.deepEqual(rows, [expectedRow({ midTurn: false })]); +}); + +test("state-dir reads openclaw.json + sessions.json via injected readFile", async () => { + const dir = "/tmp/openclaw-fixture"; + const files = { + [`${dir}/openclaw.json`]: JSON.stringify({ models: { providers: SPARK_PROVIDERS } }), + [`${dir}/sessions.json`]: JSON.stringify({ sessions: [session({ hasActiveRun: true })] }), + }; + const rows = await collectOpenClawSessions( + { enabled: true, mode: "state-dir", stateDir: dir }, + { + readFile: async (filePath) => { + if (!(filePath in files)) { + const err = new Error(`ENOENT ${filePath}`); + err.code = "ENOENT"; + throw err; + } + return files[filePath]; + }, + } + ); + assert.deepEqual(rows, [expectedRow({ midTurn: true })]); +}); + +test("local mode reads conventional state dir files", async () => { + const dir = "/opt/openclaw-home"; + const files = { + [`${dir}/openclaw.json`]: JSON.stringify({ models: { providers: SPARK_PROVIDERS } }), + [`${dir}/sessions.json`]: JSON.stringify({ + "agent:main:telegram:topic:1": { + modelProvider: "spark", + label: "World Cup", + }, + }), + }; + const seen = []; + const rows = await collectOpenClawSessions( + { enabled: true, mode: "local" }, + { + conventionalStateDir: dir, + hostRoot: "", + readFile: async (filePath) => { + seen.push(filePath); + if (!(filePath in files)) throw new Error(`missing ${filePath}`); + return files[filePath]; + }, + } + ); + assert.ok(seen.some((p) => p.endsWith("openclaw.json"))); + assert.ok(seen.some((p) => p.endsWith("sessions.json"))); + assert.equal(rows[0].midTurn, "unknown"); + assert.equal(rows[0].originHost, "127.0.0.1"); + assert.equal(rows[0].originPort, 4000); +}); + +test("missing state files return [] not throw", async () => { + const rows = await collectOpenClawSessions( + { enabled: true, mode: "state-dir", stateDir: "/no/such/openclaw" }, + { + readFile: async () => { + const err = new Error("ENOENT"); + err.code = "ENOENT"; + throw err; + }, + } + ); + assert.deepEqual(rows, []); +}); + +test("module has no alphaclaw strings and no llmPorts HTTP", () => { + const src = readFileSync(MODULE_PATH, "utf8"); + assert.equal(/alphaclaw/i.test(src), false); + assert.equal(/\bmama\b/i.test(src), false); + assert.equal(/kalliope/i.test(src), false); + assert.equal(/llmPorts/.test(src), false); + assert.equal(/projectConversations/.test(src), false); + assert.equal(/\/v1\/chat/.test(src), false); + assert.equal(/\/v1\/models/.test(src), false); +}); From 049cf62add61d744c4b963e87953ddc917ba1de9 Mon Sep 17 00:00:00 2001 From: kesslerio Date: Fri, 14 Aug 2026 21:07:55 -0700 Subject: [PATCH 4/9] feat(sessions): read Hermes Agent conversations without recency generating Map dashboard session rows to projector input from billing/profile origin, keeping is_active as unknown unless a live status is present. --- server/collectors/HermesSessions.js | 231 +++++++++++++ .../__tests__/HermesSessions.test.js | 307 ++++++++++++++++++ 2 files changed, 538 insertions(+) create mode 100644 server/collectors/HermesSessions.js create mode 100644 server/collectors/__tests__/HermesSessions.test.js diff --git a/server/collectors/HermesSessions.js b/server/collectors/HermesSessions.js new file mode 100644 index 0000000..1fa3bf2 --- /dev/null +++ b/server/collectors/HermesSessions.js @@ -0,0 +1,231 @@ +/** + * Hermes Agent conversation reader (U4). + * Projector input rows only: source, handle, origin, midTurn. + * Recency is_active is never mid-turn. Never transcripts. Never throws. + * + * Local/state-dir: sessions.json plus optional config.json or profile.json + * (`model.base_url`). URL mode: GET /api/sessions. Native sqlite (state.db) + * is not read — better-sqlite3 is not a dependency. + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { HOST_PATHS } from "../config.js"; +import { conventionalStateDir } from "../sessionSources.js"; + +const HANDLE_FIELDS = ["title", "source", "id"]; +const LIVE_STATUS = new Set(["working", "running"]); +const PROFILE_FILES = ["config.json", "profile.json"]; + +/** + * @param {string} url + * @returns {{ host: string, port: number } | null} + */ +export function parseBaseUrl(url) { + if (!url || typeof url !== "string") return null; + try { + const parsed = new URL(url); + const host = parsed.hostname; + if (!host) return null; + const port = parsed.port + ? Number(parsed.port) + : parsed.protocol === "https:" + ? 443 + : 80; + if (!Number.isInteger(port) || port < 1 || port > 65535) return null; + return { host, port }; + } catch { + return null; + } +} + +/** + * @param {unknown} sessions + * @param {object} [profiles] + * @returns {object[]} + */ +export function mapHermesSessions(sessions, profiles) { + const list = normalizeSessions(sessions); + const rows = []; + for (const item of list) { + const row = mapOneSession(item, profiles); + if (row) rows.push(row); + } + return rows; +} + +/** + * @param {{ enabled?: boolean, mode?: string, url?: string, stateDir?: string }} attach + * @param {object} [deps] + * @returns {Promise} + */ +export async function collectHermesSessions(attach, deps = {}) { + try { + if (!attach?.enabled) return []; + const loaded = await loadHermesPayload(attach, deps); + if (!loaded) return []; + return mapHermesSessions(loaded.sessions, loaded.profiles); + } catch { + return []; + } +} + +function mapOneSession(session, profiles) { + if (!session || typeof session !== "object") return null; + const origin = parseBaseUrl(originUrlOf(session, profiles)); + if (!origin) return null; + const handle = sessionHandle(session); + if (!handle) return null; + return { + source: "hermes", + handle, + originHost: origin.host, + originPort: origin.port, + midTurn: midTurnOf(session), + }; +} + +function sessionHandle(session) { + for (const field of HANDLE_FIELDS) { + const value = session[field]; + if (typeof value === "string" && value.trim()) return value.trim(); + if (typeof value === "number" && Number.isFinite(value)) return String(value); + } + return ""; +} + +function midTurnOf(session) { + if (LIVE_STATUS.has(session.status)) return true; + return "unknown"; +} + +function originUrlOf(session, profiles) { + if (typeof session.billing_base_url === "string" && session.billing_base_url.trim()) { + return session.billing_base_url.trim(); + } + return profileBaseUrl(profiles); +} + +function profileBaseUrl(profiles) { + if (!profiles || typeof profiles !== "object") return ""; + const nested = profiles.model?.base_url; + if (typeof nested === "string" && nested.trim()) return nested.trim(); + if (typeof profiles.base_url === "string" && profiles.base_url.trim()) { + return profiles.base_url.trim(); + } + return ""; +} + +function normalizeSessions(sessions) { + if (Array.isArray(sessions)) return sessions; + if (sessions && Array.isArray(sessions.sessions)) return sessions.sessions; + return []; +} + +async function loadHermesPayload(attach, deps) { + if (typeof deps.listSessions === "function") { + return { + sessions: await deps.listSessions(), + profiles: deps.profiles ?? {}, + }; + } + if (attach.mode === "url") return loadFromUrl(attach, deps); + return loadFromStateDir(attach, deps); +} + +async function loadFromUrl(attach, deps) { + const fetchJson = deps.fetchJson ?? defaultFetchJson; + const token = deps.token; + const payload = await fetchJson(sessionsUrl(attach.url), { token }); + const profiles = deps.profiles ?? (await loadProfilesFromUrl(attach.url, fetchJson, token)); + return { sessions: payload, profiles }; +} + +function sessionsUrl(raw) { + const base = String(raw || "").replace(/\/+$/, ""); + if (!base) return ""; + if (/\/api\/sessions(?:\?|$)/.test(base)) { + return base.includes("?") ? base : `${base}?limit=50`; + } + return `${base}/api/sessions?limit=50`; +} + +async function loadProfilesFromUrl(raw, fetchJson, token) { + const base = String(raw || "").replace(/\/+$/, ""); + for (const suffix of ["/api/config", "/api/profile"]) { + try { + const payload = await fetchJson(`${base}${suffix}`, { token }); + if (payload && typeof payload === "object") return payload; + } catch { + // optional profile/config + } + } + return {}; +} + +async function loadFromStateDir(attach, deps) { + const dir = resolveStateDir(attach, deps); + const readFile = deps.readFile ?? ((filePath) => fs.promises.readFile(filePath, "utf8")); + const sessionsRaw = JSON.parse(await readFile(path.join(dir, "sessions.json"))); + const profiles = await loadProfilesFromDir(dir, readFile); + return { sessions: sessionsRaw, profiles }; +} + +async function loadProfilesFromDir(dir, readFile) { + for (const name of PROFILE_FILES) { + try { + const raw = JSON.parse(await readFile(path.join(dir, name))); + if (raw && typeof raw === "object") return raw; + } catch { + // optional + } + } + return {}; +} + +function resolveStateDir(attach, deps) { + const home = deps.homedir ?? os.homedir(); + if (attach.mode === "state-dir" && attach.stateDir) { + return expandTilde(attach.stateDir, home); + } + const conventional = deps.conventionalStateDir ?? conventionalStateDir("hermes"); + return remapHostRoot(expandTilde(String(conventional || ""), home), deps); +} + +function expandTilde(raw, home) { + const value = String(raw || ""); + if (value === "~") return home; + if (value.startsWith("~/")) return path.join(home, value.slice(2)); + return value; +} + +function remapHostRoot(expanded, deps) { + const hostRoot = deps.hostRoot === undefined ? HOST_PATHS.ROOT : deps.hostRoot; + if (!hostRoot) return expanded; + const isReadable = deps.isReadable ?? pathReadable; + if (isReadable(expanded)) return expanded; + if (!expanded.startsWith("/") || !isReadable(hostRoot)) return expanded; + const mapped = path.join(hostRoot, expanded.slice(1)); + return isReadable(mapped) ? mapped : expanded; +} + +function pathReadable(filePath) { + try { + fs.accessSync(filePath); + return true; + } catch { + return false; + } +} + +async function defaultFetchJson(url, { token } = {}) { + const headers = { Accept: "application/json" }; + if (token) headers.Authorization = `Bearer ${token}`; + const res = await fetch(url, { headers }); + if (!res.ok) { + const err = new Error(`HTTP ${res.status}`); + err.status = res.status; + throw err; + } + return res.json(); +} diff --git a/server/collectors/__tests__/HermesSessions.test.js b/server/collectors/__tests__/HermesSessions.test.js new file mode 100644 index 0000000..329c8c6 --- /dev/null +++ b/server/collectors/__tests__/HermesSessions.test.js @@ -0,0 +1,307 @@ +/** + * Hermes Agent conversation reader (U4): dashboard sessions → projector rows. + * + * Recency is_active is never mid-turn. Fixture JSON / injected loaders only. + * Run: node --test server/collectors/__tests__/HermesSessions.test.js + */ +import { test } from "node:test"; +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { + mapHermesSessions, + collectHermesSessions, +} from "../HermesSessions.js"; + +const MODULE_PATH = fileURLToPath(new URL("../HermesSessions.js", import.meta.url)); + +const PROFILE = { model: { base_url: "http://127.0.0.1:8888/v1" } }; + +function session(overrides = {}) { + return { + id: "sess-1", + source: "cli", + model: "local-model", + title: "Coding session", + is_active: true, + billing_base_url: "http://127.0.0.1:8888/v1", + preview: "user asked a secret question", + ...overrides, + }; +} + +function expectedRow(overrides = {}) { + return { + source: "hermes", + handle: "Coding session", + originHost: "127.0.0.1", + originPort: 8888, + midTurn: "unknown", + ...overrides, + }; +} + +test("is_active true without a mid-turn field is midTurn unknown", () => { + const rows = mapHermesSessions([session({ is_active: true })]); + assert.deepEqual(rows, [expectedRow({ midTurn: "unknown" })]); +}); + +test("origin from billing_base_url is still emitted when midTurn is unknown", () => { + const rows = mapHermesSessions([session({ is_active: true })]); + assert.equal(rows.length, 1); + assert.equal(rows[0].originHost, "127.0.0.1"); + assert.equal(rows[0].originPort, 8888); + assert.equal(rows[0].midTurn, "unknown"); + assert.equal(rows[0].source, "hermes"); +}); + +test("billing_base_url http://127.0.0.1:8888/v1 → origin 127.0.0.1:8888", () => { + const rows = mapHermesSessions([ + session({ billing_base_url: "http://127.0.0.1:8888/v1" }), + ]); + assert.equal(rows[0].originHost, "127.0.0.1"); + assert.equal(rows[0].originPort, 8888); +}); + +test("is_active true + status working is midTurn true", () => { + const rows = mapHermesSessions([ + session({ is_active: true, status: "working" }), + ]); + assert.deepEqual(rows, [expectedRow({ midTurn: true })]); +}); + +test("status running is midTurn true; is_active is ignored", () => { + const rows = mapHermesSessions([ + session({ is_active: false, status: "running" }), + ]); + assert.equal(rows[0].midTurn, true); +}); + +test("handle is title; preview is absent from the JSON row", () => { + const rows = mapHermesSessions([ + session({ + title: "Topic A", + preview: "secret transcript body", + }), + ]); + assert.equal(rows[0].handle, "Topic A"); + const json = JSON.stringify(rows); + assert.equal(json.includes("secret transcript body"), false); + assert.equal(json.includes("preview"), false); + assert.deepEqual(Object.keys(rows[0]).sort(), [ + "handle", + "midTurn", + "originHost", + "originPort", + "source", + ]); +}); + +test("handle falls back to source, then id; preview is never the handle", () => { + const noTitle = mapHermesSessions([ + session({ title: "", source: "telegram", preview: "sneaky preview" }), + ]); + assert.equal(noTitle[0].handle, "telegram"); + assert.equal(JSON.stringify(noTitle).includes("sneaky preview"), false); + + const idOnly = mapHermesSessions([ + session({ title: " ", source: "", id: "abc-123", preview: "body" }), + ]); + assert.equal(idOnly[0].handle, "abc-123"); + assert.equal(JSON.stringify(idOnly).includes("body"), false); +}); + +test("profile model.base_url supplies origin when billing_base_url is absent", () => { + const { billing_base_url: _omit, ...rest } = session(); + const rows = mapHermesSessions([rest], PROFILE); + assert.equal(rows.length, 1); + assert.equal(rows[0].originHost, "127.0.0.1"); + assert.equal(rows[0].originPort, 8888); +}); + +test("billing_base_url wins over profile model.base_url", () => { + const rows = mapHermesSessions( + [session({ billing_base_url: "http://10.0.0.2:4000/v1" })], + { model: { base_url: "http://127.0.0.1:8888/v1" } } + ); + assert.equal(rows[0].originHost, "10.0.0.2"); + assert.equal(rows[0].originPort, 4000); +}); + +test("session without origin URL is omitted", () => { + const { billing_base_url: _omit, ...rest } = session(); + const rows = mapHermesSessions( + [rest, session({ title: "kept" })], + {} + ); + assert.deepEqual(rows, [expectedRow({ handle: "kept" })]); +}); + +test("wrapped { sessions: [...] } list is accepted", () => { + const rows = mapHermesSessions({ sessions: [session({ is_active: true })] }); + assert.equal(rows[0].midTurn, "unknown"); + assert.equal(rows[0].handle, "Coding session"); +}); + +test("disabled attach returns [] and does not load", async () => { + let loaded = false; + const rows = await collectHermesSessions( + { enabled: false, mode: "local" }, + { + readFile: async () => { + loaded = true; + throw new Error("should not read"); + }, + fetchJson: async () => { + loaded = true; + throw new Error("should not fetch"); + }, + listSessions: async () => { + loaded = true; + throw new Error("should not list"); + }, + } + ); + assert.deepEqual(rows, []); + assert.equal(loaded, false); +}); + +test("throwing fetch returns [] and does not throw", async () => { + const rows = await collectHermesSessions( + { enabled: true, mode: "url", url: "http://127.0.0.1:9119" }, + { + fetchJson: async () => { + throw new Error("ECONNREFUSED"); + }, + } + ); + assert.deepEqual(rows, []); +}); + +test("url mode GETs /api/sessions?limit=50 with Bearer token from deps", async () => { + let seenUrl; + let seenToken; + const rows = await collectHermesSessions( + { enabled: true, mode: "url", url: "http://127.0.0.1:9119" }, + { + token: "from-deps", + fetchJson: async (url, opts) => { + if (String(url).includes("/api/sessions")) { + seenUrl = url; + seenToken = opts.token; + return { sessions: [session({ is_active: true })] }; + } + const err = new Error("HTTP 404"); + err.status = 404; + throw err; + }, + } + ); + assert.equal(seenUrl, "http://127.0.0.1:9119/api/sessions?limit=50"); + assert.equal(seenToken, "from-deps"); + assert.deepEqual(rows, [expectedRow({ midTurn: "unknown" })]); +}); + +test("injected listSessions is mapped; throwing listSessions returns []", async () => { + const listed = await collectHermesSessions( + { enabled: true, mode: "url", url: "http://127.0.0.1:9119" }, + { + listSessions: async () => [session({ status: "working" })], + } + ); + assert.deepEqual(listed, [expectedRow({ midTurn: true })]); + + const failed = await collectHermesSessions( + { enabled: true, mode: "local" }, + { + listSessions: async () => { + throw new Error("boom"); + }, + } + ); + assert.deepEqual(failed, []); +}); + +test("state-dir reads sessions.json + optional config.json via injected readFile", async () => { + const dir = "/tmp/hermes-fixture"; + const { billing_base_url: _omit, ...rest } = session({ is_active: true }); + const files = { + [`${dir}/sessions.json`]: JSON.stringify({ sessions: [rest] }), + [`${dir}/config.json`]: JSON.stringify(PROFILE), + }; + const rows = await collectHermesSessions( + { enabled: true, mode: "state-dir", stateDir: dir }, + { + readFile: async (filePath) => { + if (!(filePath in files)) { + const err = new Error(`ENOENT ${filePath}`); + err.code = "ENOENT"; + throw err; + } + return files[filePath]; + }, + } + ); + assert.deepEqual(rows, [expectedRow({ midTurn: "unknown" })]); +}); + +test("local mode reads conventional state dir; profile.json supplies base_url", async () => { + const dir = "/opt/hermes-home"; + const { billing_base_url: _omit, ...rest } = session(); + const files = { + [`${dir}/sessions.json`]: JSON.stringify([rest]), + [`${dir}/profile.json`]: JSON.stringify(PROFILE), + }; + const seen = []; + const rows = await collectHermesSessions( + { enabled: true, mode: "local" }, + { + conventionalStateDir: dir, + hostRoot: "", + readFile: async (filePath) => { + seen.push(filePath); + if (!(filePath in files)) { + const err = new Error(`ENOENT ${filePath}`); + err.code = "ENOENT"; + throw err; + } + return files[filePath]; + }, + } + ); + assert.ok(seen.some((p) => p.endsWith("sessions.json"))); + assert.ok(seen.some((p) => p.endsWith("config.json") || p.endsWith("profile.json"))); + assert.equal(rows[0].midTurn, "unknown"); + assert.equal(rows[0].originHost, "127.0.0.1"); + assert.equal(rows[0].originPort, 8888); +}); + +test("missing state files return [] not throw", async () => { + const rows = await collectHermesSessions( + { enabled: true, mode: "state-dir", stateDir: "/no/such/hermes" }, + { + readFile: async () => { + const err = new Error("ENOENT"); + err.code = "ENOENT"; + throw err; + }, + } + ); + assert.deepEqual(rows, []); +}); + +test("module has no CLI update probe, alphaclaw, or llmPorts HTTP", () => { + const src = readFileSync(MODULE_PATH, "utf8"); + assert.equal(/hermes update/.test(src), false); + assert.equal(/check\(/.test(src), false); + assert.equal(/alphaclaw/i.test(src), false); + assert.equal(/\bmama\b/i.test(src), false); + assert.equal(/kalliope/i.test(src), false); + assert.equal(/llmPorts/.test(src), false); + assert.equal(/projectConversations/.test(src), false); + assert.equal(/OpenClawSessions/.test(src), false); + assert.equal(/HermesProbe/.test(src), false); + assert.equal(/POLL_INTERVAL_HERMES/.test(src), false); + assert.equal(/\/v1\/chat/.test(src), false); + assert.equal(/\/v1\/models/.test(src), false); +}); From a33ae1c1f8da551fbb2b11d866d8a4c83f809740 Mon Sep 17 00:00:00 2001 From: kesslerio Date: Fri, 14 Aug 2026 21:14:42 -0700 Subject: [PATCH 5/9] feat(sessions): show bound conversations on the Spark LLM panel Poll OpenClaw and Hermes occupancy once per LLM tick and list generating, stalled, or unknown chats without touching the existing LLM probe. --- .../__tests__/occupancyPoller.test.js | 127 ++++++++++++++++++ server/collectors/occupancyPoller.js | 50 +++++++ server/index.js | 57 +++++++- server/sparks/SparkMonitor.js | 17 +++ .../SparkMonitor.conversations.test.js | 104 ++++++++++++++ src/components/SparkPage/ConversationList.tsx | 37 +++++ src/components/SparkPage/LlmPanel.tsx | 24 +++- src/components/SparkPage/SparkPage.tsx | 1 + 8 files changed, 411 insertions(+), 6 deletions(-) create mode 100644 server/collectors/__tests__/occupancyPoller.test.js create mode 100644 server/collectors/occupancyPoller.js create mode 100644 server/sparks/__tests__/SparkMonitor.conversations.test.js create mode 100644 src/components/SparkPage/ConversationList.tsx diff --git a/server/collectors/__tests__/occupancyPoller.test.js b/server/collectors/__tests__/occupancyPoller.test.js new file mode 100644 index 0000000..76423f5 --- /dev/null +++ b/server/collectors/__tests__/occupancyPoller.test.js @@ -0,0 +1,127 @@ +/** + * Dashboard occupancy poller (U5): collect once, project onto Sparks. + * Never throws. Does not read showcase/bench. Disabled sources skip I/O. + * Run: npm test + */ +import { test } from "node:test"; +import { strict as assert } from "node:assert"; +import { pollOccupancy } from "../occupancyPoller.js"; + +function spark(overrides = {}) { + return { + id: "spark-local", + lanIp: "127.0.0.1", + isLocal: true, + llmPorts: [8888], + role: "standalone", + ...overrides, + }; +} + +function row(overrides = {}) { + return { + source: "openclaw", + handle: "chat-a", + originHost: "127.0.0.1", + originPort: 8888, + midTurn: false, + ...overrides, + }; +} + +function sources({ openclaw = false, hermes = false } = {}) { + return { + openclaw: { enabled: openclaw, mode: "local", url: "", stateDir: "" }, + hermes: { enabled: hermes, mode: "local", url: "", stateDir: "" }, + }; +} + +test("AE4: empty sources skip collect and return {}", async () => { + let called = 0; + const collect = async () => { + called += 1; + throw new Error("should not collect"); + }; + const result = await pollOccupancy({ + sparks: [spark()], + sources: sources(), + tokens: {}, + collectOpenClaw: collect, + collectHermes: collect, + }); + assert.deepEqual(result, {}); + assert.equal(called, 0); +}); + +test("AE4: occupancy throw returns {} and does not throw", async () => { + const result = await pollOccupancy({ + sparks: [spark()], + sources: sources({ openclaw: true }), + tokens: {}, + collectOpenClaw: async () => { + throw new Error("gateway down"); + }, + collectHermes: async () => { + throw new Error("hermes down"); + }, + }); + assert.deepEqual(result, {}); +}); + +test("disabled sources: collect fns not called", async () => { + let openclaw = 0; + let hermes = 0; + const result = await pollOccupancy({ + sparks: [spark()], + sources: sources({ openclaw: false, hermes: false }), + collectOpenClaw: async () => { + openclaw += 1; + return [row()]; + }, + collectHermes: async () => { + hermes += 1; + return [row({ source: "hermes", handle: "ha" })]; + }, + }); + assert.deepEqual(result, {}); + assert.equal(openclaw, 0); + assert.equal(hermes, 0); +}); + +test("AE6: showcase running flag does not mint generating", async () => { + const result = await pollOccupancy({ + sparks: [spark()], + sources: sources({ openclaw: true }), + tokens: {}, + collectOpenClaw: async () => [ + row({ handle: "stalled-chat", midTurn: false }), + row({ handle: "unknown-chat", midTurn: "unknown" }), + ], + collectHermes: async () => [], + showcaseRunning: true, + decodeBenchRunning: true, + }); + const list = result["spark-local"]; + assert.ok(Array.isArray(list)); + const byHandle = Object.fromEntries(list.map((r) => [r.handle, r])); + assert.equal(byHandle["stalled-chat"].badge, "stalled"); + assert.equal(byHandle["unknown-chat"].badge, "unknown"); + assert.notEqual(byHandle["stalled-chat"].badge, "generating"); + assert.notEqual(byHandle["unknown-chat"].badge, "generating"); +}); + +test("per-source catch: throwing source contributes [] and sibling still projects", async () => { + const result = await pollOccupancy({ + sparks: [spark()], + sources: sources({ openclaw: true, hermes: true }), + collectOpenClaw: async () => { + throw new Error("openclaw boom"); + }, + collectHermes: async () => [ + row({ source: "hermes", handle: "agent-1", midTurn: "unknown" }), + ], + }); + assert.deepEqual(result["spark-local"], [ + { source: "hermes", handle: "agent-1", badge: "unknown", port: 8888 }, + ]); +}); diff --git a/server/collectors/occupancyPoller.js b/server/collectors/occupancyPoller.js new file mode 100644 index 0000000..7ff8456 --- /dev/null +++ b/server/collectors/occupancyPoller.js @@ -0,0 +1,50 @@ +/** + * Dashboard occupancy poll (U5). Collect once per tick, then project onto Sparks. + * Never throws. Does not read showcase/bench. Skip I/O when both sources are off. + */ +import { collectOpenClawSessions } from "./OpenClawSessions.js"; +import { collectHermesSessions } from "./HermesSessions.js"; +import { projectConversations } from "./sessionProjector.js"; + +/** + * @param {object} opts + * @param {object[]} opts.sparks + * @param {{ openclaw?: { enabled?: boolean }, hermes?: { enabled?: boolean } }} opts.sources + * @param {{ openclaw?: string, hermes?: string }} [opts.tokens] + * @param {Function} [opts.collectOpenClaw] + * @param {Function} [opts.collectHermes] + * @param {Function} [opts.project] + * @returns {Promise>} + */ +export async function pollOccupancy({ + sparks, + sources, + tokens = {}, + collectOpenClaw = collectOpenClawSessions, + collectHermes = collectHermesSessions, + project = projectConversations, +} = {}) { + if (!sources?.openclaw?.enabled && !sources?.hermes?.enabled) return {}; + const [openclawRows, hermesRows] = await Promise.all([ + sources.openclaw?.enabled + ? collectSafe(collectOpenClaw, sources.openclaw, { token: tokens.openclaw }) + : [], + sources.hermes?.enabled + ? collectSafe(collectHermes, sources.hermes, { token: tokens.hermes }) + : [], + ]); + try { + return project([...openclawRows, ...hermesRows], sparks); + } catch { + return {}; + } +} + +async function collectSafe(collect, attach, deps) { + try { + const rows = await collect(attach, deps); + return Array.isArray(rows) ? rows : []; + } catch { + return []; + } +} diff --git a/server/index.js b/server/index.js index ae597c7..d2300c3 100644 --- a/server/index.js +++ b/server/index.js @@ -11,7 +11,10 @@ import { SparkMonitor } from "./sparks/SparkMonitor.js"; import { sshExec, sshTest, llmTest } from "./collectors/ssh.js"; import { validateSparkTarget, createRateLimiter } from "./validate.js"; import { getSettings, updateSettings, loadSettings } from "./settings.js"; -import { getPublicSessionSources, updateSessionSources } from "./sessionSources.js"; +import { getPublicSessionSources, loadSessionSources, updateSessionSources } from "./sessionSources.js"; +import { loadSessionSourceTokens } from "./secretsStore.js"; +import { pollOccupancy } from "./collectors/occupancyPoller.js"; +import { POLL_INTERVAL_LLM } from "./config.js"; import { broadcastForLanIp, effectiveMac, normalizeMac, sendWol } from "./wol.js"; import { decodeBenchManager, @@ -97,6 +100,56 @@ function orderedSnapshots() { .map((m) => m.snapshot()); } +// Occupancy is dashboard-level (KTD1), on LLM cadence, never folded into _pollDomain("llm"). +let _occupancyInflight = false; +/** @type {ReturnType | null} */ +let occupancyTimer = null; + +function applyOccupancy(bySpark) { + for (const [id, monitor] of monitors) { + monitor.setConversations(bySpark[id] || []); + } +} + +function sourcesEnabled(sources) { + return Boolean(sources?.openclaw?.enabled || sources?.hermes?.enabled); +} + +async function tickOccupancy() { + if (_occupancyInflight) return; + const sources = loadSessionSources(); + if (!sourcesEnabled(sources)) { + applyOccupancy({}); + return; + } + _occupancyInflight = true; + try { + const bySpark = await pollOccupancy({ + sparks: registry.sparks, + sources, + tokens: loadSessionSourceTokens(), + }); + applyOccupancy(bySpark); + } catch (err) { + console.error("[occupancy] poll error:", err.message); + } finally { + _occupancyInflight = false; + } +} + +function startOccupancyPoll() { + if (occupancyTimer != null) return; + occupancyTimer = setInterval(() => void tickOccupancy(), POLL_INTERVAL_LLM); + void tickOccupancy(); +} + +function stopOccupancyPoll() { + if (occupancyTimer == null) return; + clearInterval(occupancyTimer); + occupancyTimer = null; + _occupancyInflight = false; +} + // ─── Express app ───────────────────────────────────────── const app = express(); const server = createServer(app); @@ -1073,6 +1126,7 @@ server.listen(PORT, "0.0.0.0", () => { console.log(`[sparkDash] server listening on http://0.0.0.0:${PORT}`); console.log(`[sparkDash] WebSocket endpoint ws://0.0.0.0:${PORT}/ws`); startAllMonitors(); + startOccupancyPoll(); }); // ─── Graceful shutdown ───────────────────────────────── @@ -1086,6 +1140,7 @@ function shutdown(signal) { clearInterval(broadcastTimer); broadcastTimer = null; } + stopOccupancyPoll(); for (const m of monitors.values()) m.stop(); monitors.clear(); } catch (err) { diff --git a/server/sparks/SparkMonitor.js b/server/sparks/SparkMonitor.js index ce96ee7..41811a1 100644 --- a/server/sparks/SparkMonitor.js +++ b/server/sparks/SparkMonitor.js @@ -65,6 +65,9 @@ export class SparkMonitor { this._running = false; /** @type {Record} in-flight domain guards */ this._inflight = {}; + + /** Bound conversation rows from the dashboard occupancy poller. */ + this._conversations = []; } /** Hot-update config without tearing down poll loops / rate baselines. */ @@ -163,6 +166,19 @@ export class SparkMonitor { console.log(`[SparkMonitor] ${this.spark.id} stopped`); } + /** Store a copy of occupancy rows. Dashboard poller owns collection. */ + setConversations(rows) { + this._conversations = Array.isArray(rows) ? rows.map((row) => ({ ...row })) : []; + } + + /** Occupancy list for snapshot: omit when empty, disabled, or worker. */ + _conversationSnapshot() { + if (!this._llmMonitoringEnabled()) return {}; + const rows = this._conversations; + if (!Array.isArray(rows) || rows.length === 0) return {}; + return { conversations: rows }; + } + /** Return a full snapshot of this Spark's metrics. */ snapshot() { const ports = this._llmMonitoringEnabled() ? this._llmPorts() : []; @@ -198,6 +214,7 @@ export class SparkMonitor { unifiedMemory: this._metrics.unifiedMemory, llm: this._metrics.llm, }, + ...this._conversationSnapshot(), }; } diff --git a/server/sparks/__tests__/SparkMonitor.conversations.test.js b/server/sparks/__tests__/SparkMonitor.conversations.test.js new file mode 100644 index 0000000..0574153 --- /dev/null +++ b/server/sparks/__tests__/SparkMonitor.conversations.test.js @@ -0,0 +1,104 @@ +/** + * SparkMonitor occupancy attach (U5). + * Conversations are top-level, omitted when empty/disabled/worker. + * No per-tick timestamp. Occupancy must not blank metrics.llm. + * Run: npm test + */ +import { test } from "node:test"; +import { strict as assert } from "node:assert"; +import { SparkMonitor } from "../SparkMonitor.js"; + +function stubSpark(overrides = {}) { + return { + id: "spark-local", + name: "Local", + lanIp: "127.0.0.1", + isLocal: true, + llmPorts: [8888], + role: "standalone", + llmMonitoring: true, + disabledDevices: [], + disabledInterfaces: [], + ...overrides, + }; +} + +function llmMetrics() { + return [ + { + available: true, + backend: "vllm", + modelId: "test-model", + modelPath: null, + contextLength: 8192, + gpuMemoryUtilization: 0.5, + slotsActive: 0, + slotsTotal: 0, + generationTps: 0, + prefillTps: 0, + totalOutputTokens: 0, + error: null, + }, + ]; +} + +function conversation(overrides = {}) { + return { + source: "openclaw", + handle: "chat-a", + badge: "stalled", + port: 8888, + ...overrides, + }; +} + +function monitorWithLlm(spark = stubSpark()) { + const monitor = new SparkMonitor(spark); + monitor._metrics.llm = llmMetrics(); + return monitor; +} + +test("AE4: occupancy empty leaves metrics.llm and omits conversations", () => { + const monitor = monitorWithLlm(); + monitor.setConversations([]); + const snap = monitor.snapshot(); + assert.equal(snap.metrics.llm[0].available, true); + assert.equal(snap.metrics.llm[0].modelId, "test-model"); + assert.equal("conversations" in snap, false); +}); + +test("worker spark omits conversations even after setConversations", () => { + const monitor = monitorWithLlm(stubSpark({ role: "worker", workerNode: true, llmMonitoring: false })); + monitor.setConversations([conversation()]); + const snap = monitor.snapshot(); + assert.equal("conversations" in snap, false); +}); + +test("unchanged conversation list: two snapshot() JSON strings equal", () => { + const monitor = monitorWithLlm(); + monitor.setConversations([conversation({ handle: "chat-a", badge: "generating" })]); + const a = JSON.stringify(monitor.snapshot()); + const b = JSON.stringify(monitor.snapshot()); + assert.equal(a, b); + assert.ok(!a.includes("Date")); + const snap = monitor.snapshot(); + assert.equal(snap.conversations.length, 1); + assert.equal(snap.conversations[0].handle, "chat-a"); + assert.equal("timestamp" in snap.metrics, false); +}); + +test("llmOn snapshot includes conversations when rows exist", () => { + const monitor = monitorWithLlm(); + monitor.setConversations([conversation()]); + const snap = monitor.snapshot(); + assert.deepEqual(snap.conversations, [conversation()]); + assert.equal(snap.metrics.llm[0].available, true); +}); + +test("setConversations stores a copy", () => { + const monitor = monitorWithLlm(); + const rows = [conversation()]; + monitor.setConversations(rows); + rows[0].handle = "mutated"; + assert.equal(monitor.snapshot().conversations[0].handle, "chat-a"); +}); diff --git a/src/components/SparkPage/ConversationList.tsx b/src/components/SparkPage/ConversationList.tsx new file mode 100644 index 0000000..699770b --- /dev/null +++ b/src/components/SparkPage/ConversationList.tsx @@ -0,0 +1,37 @@ +import type { ConversationBadge, ConversationRow, ConversationSource } from "../../api/types"; + +const SOURCE_LABEL: Record = { + openclaw: "OpenClaw", + hermes: "Hermes Agent", +}; + +const BADGE_CLASS: Record = { + generating: "text-accent", + stalled: "text-muted", + unknown: "text-warning", +}; + +interface ConversationListProps { + conversations: ConversationRow[]; +} + +export function ConversationList({ conversations }: ConversationListProps) { + if (conversations.length === 0) return null; + + return ( +
+ {conversations.map((row) => ( +
+
+ {SOURCE_LABEL[row.source]} + {row.handle} +
+ {row.badge} +
+ ))} +
+ ); +} diff --git a/src/components/SparkPage/LlmPanel.tsx b/src/components/SparkPage/LlmPanel.tsx index b29ed82..0cf0162 100644 --- a/src/components/SparkPage/LlmPanel.tsx +++ b/src/components/SparkPage/LlmPanel.tsx @@ -1,16 +1,18 @@ import { useState, useEffect, useRef, useCallback } from "react"; -import type { LlmMetrics } from "../../api/types"; +import type { ConversationRow, LlmMetrics } from "../../api/types"; import { updateLlmPort } from "../../api/client"; import { Sparkline } from "../ui/Sparkline"; import { Panel } from "../ui/Panel"; import { BotIcon, GearIcon, InfoIcon } from "../ui/icons"; import { useMetricsHistoryTail } from "../../hooks/metricsStore"; import { BenchmarkDialog } from "./BenchmarkDialog"; +import { ConversationList } from "./ConversationList"; interface LlmPanelProps { llm: LlmMetrics | null; sparkId: string; llmPort: number; + conversations?: ConversationRow[]; onRemovePort?: (port: number) => void; className?: string; } @@ -125,7 +127,14 @@ function MetricInfoTip({ ); } -export function LlmPanel({ llm, sparkId, llmPort, onRemovePort, className }: LlmPanelProps) { +export function LlmPanel({ + llm, + sparkId, + llmPort, + conversations = [], + onRemovePort, + className, +}: LlmPanelProps) { // Tail keyed by port so multi-port LLM sparklines stay distinct (8b). const genHistory = useMetricsHistoryTail(sparkId, `llm:${llmPort}.tps`); const [showSettings, setShowSettings] = useState(false); @@ -283,9 +292,12 @@ export function LlmPanel({ llm, sparkId, llmPort, onRemovePort, className }: Llm ) : !available ? ( -
- -

No model loaded on :{llmPort}

+
+
+ +

No model loaded on :{llmPort}

+
+
) : (
@@ -523,6 +535,8 @@ export function LlmPanel({ llm, sparkId, llmPort, onRemovePort, className }: Llm
)} + +
+ /> Auto-hide offline Sparks on Overview
@@ -324,23 +313,12 @@ export function SettingsDialog({ open, onClose, onSaved }: SettingsDialogProps) {/* Benchmark debug traces */}