From 89c17b240ce0100ad31ab54f0fb9d9aeb06c7ec4 Mon Sep 17 00:00:00 2001 From: Vinicius Souza <43080861+viniciussouzax@users.noreply.github.com> Date: Sat, 6 Jun 2026 23:48:22 -0300 Subject: [PATCH] feat(chat): self-heal the session list from chat logs when the cache is lost sessions.json (under ~/.claude-code-web) is only a fast-access cache; the JSONL logs under workspace/ADWs/logs/chat are the durable source of truth. When the cache directory is not persisted (e.g. a container redeploy where it wasn't on a volume), the conversation list came back empty even though the history was intact on disk. Rebuild the in-memory index from the logs when the loaded cache is empty: - ChatLogger.listSessions() enumerates sessions from the log filenames ({agentName}_{shortId}.jsonl), splitting on the last underscore so agent names containing underscores still parse correctly. - TerminalServer.rebuildSessionsFromLogs() reads each log back into a session entry and is invoked from loadPersistedSessions() only when the cache is empty, then persists the rebuilt index. It is idempotent and never duplicates cached sessions; timestamps come from the logs so the normal TTL policy still drops stale sessions. Adds tests for listSessions() parsing and the empty-cache rebuild. Refs #101 Co-Authored-By: Claude Opus 4.8 --- dashboard/terminal-server/src/server.js | 52 ++++++++++++++++ .../terminal-server/src/utils/chat-logger.js | 23 ++++++++ dashboard/terminal-server/test/server.test.js | 59 +++++++++++++++++++ 3 files changed, 134 insertions(+) diff --git a/dashboard/terminal-server/src/server.js b/dashboard/terminal-server/src/server.js index f5f8605a..3a85ff7f 100644 --- a/dashboard/terminal-server/src/server.js +++ b/dashboard/terminal-server/src/server.js @@ -58,12 +58,64 @@ class TerminalServer { this.claudeSessions = sessions; if (sessions.size > 0) { console.log(`Loaded ${sessions.size} persisted sessions`); + } else { + // The cache (sessions.json) is empty or was lost — e.g. a container + // redeploy where its directory wasn't on a volume. Rebuild the list + // from the durable chat logs so conversations don't disappear. + const recovered = this.rebuildSessionsFromLogs(); + if (recovered > 0) { + console.log(`Session cache empty — recovered ${recovered} session(s) from chat logs`); + await this.saveSessionsToDisk(); + } } } catch (error) { console.error('Failed to load persisted sessions:', error); } } + /** + * Rebuild the in-memory session index from the durable chat logs. + * + * sessions.json is only a fast-access cache; the JSONL logs under + * workspace/ADWs/logs/chat are the source of truth (see ChatLogger). If the + * cache is lost, the conversation list would otherwise come back empty even + * though the history is intact on disk. This reads each log back so the list + * self-heals. + * + * Only adds sessions not already present, so it never duplicates cached ones. + * Timestamps come from the log, so stale sessions are still dropped by the + * normal TTL policy on the next save / GC pass. Returns the count recovered. + */ + rebuildSessionsFromLogs() { + let recovered = 0; + for (const { agentName, sessionId } of this.chatLogger.listSessions()) { + if (this.claudeSessions.has(sessionId)) continue; + const messages = this.chatLogger.read(agentName, sessionId); + if (!messages.length) continue; + const firstUser = messages.find((m) => m.role === 'user' && m.text); + const lastTs = messages[messages.length - 1].ts || Date.now(); + this.claudeSessions.set(sessionId, { + id: sessionId, + name: firstUser ? firstUser.text.slice(0, 60) : `${agentName} session`, + created: new Date(lastTs), + lastActivity: new Date(lastTs), + lastAccessed: Date.now(), + workingDir: this.baseFolder, + agentName, + active: false, + outputBuffer: [], + connections: new Set(), + mode: 'chat', + chatHistory: messages, + sdkSessionId: null, + ticketId: null, + archived: false, + }); + recovered += 1; + } + return recovered; + } + setupAutoSave() { if (this.autoSaveIntervalMs > 0) { this.autoSaveInterval = setInterval(() => { diff --git a/dashboard/terminal-server/src/utils/chat-logger.js b/dashboard/terminal-server/src/utils/chat-logger.js index c2db7620..92700bcd 100644 --- a/dashboard/terminal-server/src/utils/chat-logger.js +++ b/dashboard/terminal-server/src/utils/chat-logger.js @@ -132,6 +132,29 @@ class ChatLogger { exists(agentName, sessionId) { return fs.existsSync(this._logPath(agentName, sessionId)); } + + /** + * List every chat session that has a durable log on disk. + * + * Parses the JSONL filenames ({agentName}_{shortId}.jsonl). Agent names may + * contain underscores (see _logPath), so the session id is the segment after + * the LAST underscore. Returns [{ agentName, sessionId }] (empty on error). + */ + listSessions() { + try { + const out = []; + for (const file of fs.readdirSync(this.logsDir)) { + if (!file.endsWith('.jsonl')) continue; + const base = file.slice(0, -'.jsonl'.length); + const i = base.lastIndexOf('_'); + if (i <= 0 || i === base.length - 1) continue; // need non-empty agent and id + out.push({ agentName: base.slice(0, i), sessionId: base.slice(i + 1) }); + } + return out; + } catch { + return []; + } + } } module.exports = ChatLogger; diff --git a/dashboard/terminal-server/test/server.test.js b/dashboard/terminal-server/test/server.test.js index e74363f1..d811863b 100644 --- a/dashboard/terminal-server/test/server.test.js +++ b/dashboard/terminal-server/test/server.test.js @@ -114,3 +114,62 @@ test('TerminalServer purges stale sessions and reports health', async () => { server.close(); }); + +const ChatLogger = require('../src/utils/chat-logger'); + +function makeChatLog(baseFolder, fileName, lines) { + const chatDir = path.join(baseFolder, 'workspace', 'ADWs', 'logs', 'chat'); + fs.mkdirSync(chatDir, { recursive: true }); + fs.writeFileSync(path.join(chatDir, fileName), lines.map((l) => JSON.stringify(l)).join('\n') + '\n'); +} + +test('ChatLogger.listSessions parses agent and session id (agent names may contain underscores)', () => { + const baseFolder = makeTempDir('evonexus-listsessions-'); + const chatDir = path.join(baseFolder, 'workspace', 'ADWs', 'logs', 'chat'); + fs.mkdirSync(chatDir, { recursive: true }); + fs.writeFileSync(path.join(chatDir, 'oracle_abc12345.jsonl'), ''); + fs.writeFileSync(path.join(chatDir, 'my_agent_def67890.jsonl'), ''); + fs.writeFileSync(path.join(chatDir, 'not-a-log.txt'), 'ignored'); + + const logger = new ChatLogger(baseFolder); + const sessions = logger.listSessions().sort((a, b) => a.sessionId.localeCompare(b.sessionId)); + + assert.equal(sessions.length, 2); + assert.deepEqual(sessions[0], { agentName: 'oracle', sessionId: 'abc12345' }); + assert.deepEqual(sessions[1], { agentName: 'my_agent', sessionId: 'def67890' }); +}); + +test('TerminalServer rebuilds the session index from chat logs when the cache is empty', async () => { + const baseFolder = makeTempDir('evonexus-selfheal-'); + makeChatLog(baseFolder, 'oracle_abc12345.jsonl', [ + { role: 'user', text: 'Hello there', ts: Date.now(), uuid: 'u1' }, + { role: 'assistant', text: 'Hi!', ts: Date.now(), uuid: 'a1' }, + ]); + + const server = new TerminalServer({ + port: 0, + dev: false, + sessionGcIntervalMs: 0, + autoSaveIntervalMs: 0, + }); + await server.ready; + + // Point the logger at our temp logs and start from an empty cache. + server.chatLogger = new ChatLogger(baseFolder); + server.claudeSessions = new Map(); + + const recovered = server.rebuildSessionsFromLogs(); + + assert.equal(recovered, 1); + assert.equal(server.claudeSessions.has('abc12345'), true); + const recoveredSession = server.claudeSessions.get('abc12345'); + assert.equal(recoveredSession.agentName, 'oracle'); + assert.equal(recoveredSession.mode, 'chat'); + assert.equal(recoveredSession.name, 'Hello there'); + assert.equal(recoveredSession.chatHistory.length, 2); + + // Idempotent: a second pass must not duplicate already-present sessions. + assert.equal(server.rebuildSessionsFromLogs(), 0); + + server.close(); +});