Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions dashboard/terminal-server/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
23 changes: 23 additions & 0 deletions dashboard/terminal-server/src/utils/chat-logger.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
59 changes: 59 additions & 0 deletions dashboard/terminal-server/test/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});