Files
MATTERMOST_OPENCLAW_LIVESTATUS/test/integration/status-watcher.test.js
sol e3bd6c52dd feat: Phase 2 — session monitor, lifecycle, watcher manager
Phase 2 (Session Monitor + Lifecycle):
- src/session-monitor.js: polls sessions.json every 2s for new/ended sessions
  - Detects agents via transcriptDir subdirectory scan
  - Resolves channelId/rootPostId from session key format
  - Emits session-added/session-removed events
  - Handles multi-agent environments
  - Falls back to defaultChannel for non-MM sessions
- src/watcher-manager.js: top-level orchestrator
  - Starts session-monitor, status-watcher, health-server
  - Creates/updates Mattermost status posts on session events
  - Sub-agent linking: children embedded in parent status
  - Offset persistence (save/restore lastOffset on restart)
  - Post recovery on restart (search channel history for marker)
  - SIGTERM/SIGINT graceful shutdown: mark all boxes interrupted
  - CLI: node watcher-manager.js start|stop|status
  - MAX_ACTIVE_SESSIONS enforcement

Integration tests:
- test/integration/session-monitor.test.js: 14 tests
  - Session detection, removal, multi-agent, malformed JSON handling
- test/integration/status-watcher.test.js: 13 tests
  - JSONL parsing, tool_call/result pairs, idle detection, offset recovery

All 86 tests pass (59 unit + 27 integration). make check clean.
2026-03-07 17:32:28 +00:00

268 lines
8.8 KiB
JavaScript

'use strict';
/**
* Integration tests for status-watcher.js
* Tests JSONL file watching and event emission.
*/
const { describe, it, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { StatusWatcher } = require('../../src/status-watcher');
function createTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'sw-test-'));
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function appendLine(file, obj) {
fs.appendFileSync(file, JSON.stringify(obj) + '\n');
}
describe('StatusWatcher', () => {
let tmpDir;
let watcher;
beforeEach(() => {
tmpDir = createTmpDir();
});
afterEach(() => {
if (watcher) {
watcher.stop();
watcher = null;
}
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch (_e) {
/* ignore */
}
});
describe('session management', () => {
it('addSession() registers a session', () => {
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
const file = path.join(tmpDir, 'test.jsonl');
fs.writeFileSync(file, '');
watcher.addSession('test:key', file);
assert.ok(watcher.sessions.has('test:key'));
assert.ok(watcher.fileToSession.has(file));
});
it('removeSession() cleans up', () => {
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
const file = path.join(tmpDir, 'test.jsonl');
fs.writeFileSync(file, '');
watcher.addSession('test:key', file);
watcher.removeSession('test:key');
assert.ok(!watcher.sessions.has('test:key'));
assert.ok(!watcher.fileToSession.has(file));
});
it('getSessionState() returns state', () => {
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
const file = path.join(tmpDir, 'test.jsonl');
fs.writeFileSync(file, '');
watcher.addSession('test:key', file);
const state = watcher.getSessionState('test:key');
assert.ok(state);
assert.equal(state.sessionKey, 'test:key');
});
});
describe('JSONL parsing', () => {
it('reads existing content on addSession', () => {
const file = path.join(tmpDir, 'test.jsonl');
fs.writeFileSync(file, '');
appendLine(file, { type: 'assistant', text: 'Hello world' });
appendLine(file, { type: 'tool_call', name: 'exec', id: '1' });
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
watcher.addSession('test:key', file);
const state = watcher.getSessionState('test:key');
assert.ok(state.lines.length > 0);
assert.ok(state.lines.some((l) => l.includes('exec')));
});
it('emits session-update when file changes', async () => {
const file = path.join(tmpDir, 'test.jsonl');
fs.writeFileSync(file, '');
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
watcher.start();
watcher.addSession('test:key', file);
const updates = [];
watcher.on('session-update', (key, state) => updates.push({ key, state }));
await sleep(100);
appendLine(file, { type: 'assistant', text: 'Starting task...' });
await sleep(500);
assert.ok(updates.length > 0);
assert.equal(updates[0].key, 'test:key');
});
it('parses tool_call and tool_result pairs', () => {
const file = path.join(tmpDir, 'test.jsonl');
fs.writeFileSync(file, '');
appendLine(file, { type: 'tool_call', name: 'exec', id: '1' });
appendLine(file, { type: 'tool_result', name: 'exec', id: '1' });
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
watcher.addSession('test:key', file);
const state = watcher.getSessionState('test:key');
assert.equal(state.pendingToolCalls, 0);
// Line should show [OK]
const execLine = state.lines.find((l) => l.includes('exec'));
assert.ok(execLine);
assert.ok(execLine.includes('[OK]'));
});
it('tracks pendingToolCalls correctly', () => {
const file = path.join(tmpDir, 'test.jsonl');
fs.writeFileSync(file, '');
appendLine(file, { type: 'tool_call', name: 'exec', id: '1' });
appendLine(file, { type: 'tool_call', name: 'Read', id: '2' });
appendLine(file, { type: 'tool_result', name: 'exec', id: '1' });
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
watcher.addSession('test:key', file);
const state = watcher.getSessionState('test:key');
assert.equal(state.pendingToolCalls, 1); // Read still pending
});
it('tracks token usage', () => {
const file = path.join(tmpDir, 'test.jsonl');
fs.writeFileSync(file, '');
appendLine(file, { type: 'usage', input_tokens: 1000, output_tokens: 500 });
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
watcher.addSession('test:key', file);
const state = watcher.getSessionState('test:key');
assert.equal(state.tokenCount, 1500);
});
it('detects file truncation (compaction)', () => {
const file = path.join(tmpDir, 'test.jsonl');
// Write some content
fs.writeFileSync(
file,
JSON.stringify({ type: 'assistant', text: 'Hello' }) +
'\n' +
JSON.stringify({ type: 'tool_call', name: 'exec', id: '1' }) +
'\n',
);
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
watcher.addSession('test:key', file);
const state = watcher.getSessionState('test:key');
const originalOffset = state.lastOffset;
assert.ok(originalOffset > 0);
// Truncate the file (simulate compaction)
fs.writeFileSync(file, JSON.stringify({ type: 'assistant', text: 'Resumed' }) + '\n');
// Force a re-read
watcher._readFile('test:key', state);
// Offset should have reset
assert.ok(
state.lastOffset < originalOffset ||
state.lines.includes('[session compacted - continuing]'),
);
});
it('handles malformed JSONL lines gracefully', () => {
const file = path.join(tmpDir, 'test.jsonl');
fs.writeFileSync(file, 'not valid json\n{"type":"assistant","text":"ok"}\n');
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
// Should not throw
assert.doesNotThrow(() => {
watcher.addSession('test:key', file);
});
const state = watcher.getSessionState('test:key');
assert.ok(state);
assert.ok(state.lines.some((l) => l.includes('ok')));
});
});
describe('idle detection', () => {
it('emits session-idle after timeout with no activity', async () => {
const file = path.join(tmpDir, 'test.jsonl');
fs.writeFileSync(file, '');
appendLine(file, { type: 'assistant', text: 'Starting...' });
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 0.2 }); // 200ms
watcher.addSession('test:key', file);
const idle = [];
watcher.on('session-idle', (key) => idle.push(key));
await sleep(600);
assert.ok(idle.length > 0);
assert.equal(idle[0], 'test:key');
});
it('resets idle timer on new activity', async () => {
const file = path.join(tmpDir, 'test.jsonl');
fs.writeFileSync(file, '');
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 0.3 }); // 300ms
watcher.start();
watcher.addSession('test:key', file);
const idle = [];
watcher.on('session-idle', (key) => idle.push(key));
// Write activity at 150ms (before 300ms timeout)
await sleep(150);
appendLine(file, { type: 'assistant', text: 'Still working...' });
// At 350ms: idle timer was reset, so no idle yet
await sleep(200);
assert.equal(idle.length, 0);
// At 600ms: idle timer fires (150+300=450ms > 200+300=500ms... need to wait full 300ms)
await sleep(400);
assert.equal(idle.length, 1);
});
});
describe('offset recovery', () => {
it('resumes from saved offset', () => {
const file = path.join(tmpDir, 'test.jsonl');
const line1 = JSON.stringify({ type: 'assistant', text: 'First' }) + '\n';
const line2 = JSON.stringify({ type: 'assistant', text: 'Second' }) + '\n';
fs.writeFileSync(file, line1 + line2);
watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 });
// Start with offset at start of line2 (after line1)
watcher.addSession('test:key', file, { lastOffset: line1.length });
const state = watcher.getSessionState('test:key');
// Should only have parsed line2
assert.ok(state.lines.some((l) => l.includes('Second')));
assert.ok(!state.lines.some((l) => l.includes('First')));
});
});
});