mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 14:09:42 +02:00
COD-160 unified session list: backend service + endpoint
First increment of the read-only "complete + searchable session list".
- New src/services/unified-session-service.ts: mergeUnifiedSessions() combines
live + persisted (state.json) + lifecycle + ~/.claude transcript history + mux
stats into one list de-duped by sessionId, with precedence
history < lifecycle < persisted < live, a meaningfulness floor that drops bare
lifecycle/mux-only noise, and a stable newest-first sort. Plus
filterAndPaginate() (case-insensitive q over name/firstPrompt/workingDir/
sessionId; total before paging; limit clamped [1,500]). No IO — unit-testable.
- New GET /api/sessions/unified in session-routes.ts: gathers the five sources
from ctx (sessions/store/lifecycle/scanProjectDir/mux, each try/caught), feeds
the pure service, returns { sessions, total } (ApiResponse envelope). testMode
short-circuits to empty.
Tests: unified-session-service.test.ts (12, pure) + unified-sessions-routes.test.ts
(4, app.inject).
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for the pure unified-session merge/filter service (COD-121).
|
||||
*
|
||||
* Covers dedup across sources, source precedence, mux-stat merge + meaningfulness
|
||||
* floor, sort ordering, and filterAndPaginate (search + paging + clamps).
|
||||
* Node env only — no jsdom, no IO.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
mergeUnifiedSessions,
|
||||
filterAndPaginate,
|
||||
type UnifiedSessionItem,
|
||||
} from '../../src/services/unified-session-service.js';
|
||||
|
||||
describe('mergeUnifiedSessions', () => {
|
||||
it('dedupes the same sessionId across live + persisted into one item', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 's1', status: 'working', isWorking: true }],
|
||||
persisted: [{ id: 's1', status: 'idle' }],
|
||||
history: [{ sessionId: 's1', workingDir: '/w', sizeBytes: 5000, lastModified: '2026-01-01T00:00:00.000Z' }],
|
||||
});
|
||||
expect(merged).toHaveLength(1);
|
||||
const item = merged[0];
|
||||
expect(item.sessionId).toBe('s1');
|
||||
// sources accumulate from every contributing source (order-insensitive)
|
||||
expect([...item.sources].sort()).toEqual(['history', 'live', 'persisted']);
|
||||
// live wins for status
|
||||
expect(item.status).toBe('working');
|
||||
expect(item.isWorking).toBe(true);
|
||||
});
|
||||
|
||||
it('lets live status win over persisted (precedence)', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
persisted: [{ id: 's1', status: 'idle', name: 'Persisted Name' }],
|
||||
live: [{ id: 's1', status: 'working' }],
|
||||
});
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].status).toBe('working');
|
||||
// persisted name survives because live did not provide one
|
||||
expect(merged[0].name).toBe('Persisted Name');
|
||||
});
|
||||
|
||||
it('merges mux stats onto a live item but drops a mux-only entry with no name', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 's1', status: 'working' }],
|
||||
mux: [
|
||||
{ sessionId: 's1', stats: { memoryMB: 42, cpuPercent: 3.5 }, remote: true },
|
||||
{ sessionId: 'noise', stats: { memoryMB: 10, cpuPercent: 1 } },
|
||||
],
|
||||
});
|
||||
expect(merged).toHaveLength(1);
|
||||
const item = merged[0];
|
||||
expect(item.sessionId).toBe('s1');
|
||||
expect(item.stats).toEqual({ memoryMB: 42, cpuPercent: 3.5 });
|
||||
expect(item.remote).toBe(true);
|
||||
});
|
||||
|
||||
it('drops a lifecycle-only entry with no name/firstPrompt but keeps a history item with firstPrompt', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
lifecycle: [{ sessionId: 'bare', event: 'created', ts: 1000 }],
|
||||
history: [
|
||||
{
|
||||
sessionId: 'hist',
|
||||
workingDir: '/w',
|
||||
sizeBytes: 9000,
|
||||
lastModified: '2026-01-02T00:00:00.000Z',
|
||||
firstPrompt: 'do the thing',
|
||||
},
|
||||
],
|
||||
});
|
||||
const ids = merged.map((m) => m.sessionId);
|
||||
expect(ids).toContain('hist');
|
||||
expect(ids).not.toContain('bare');
|
||||
});
|
||||
|
||||
it('sorts by lastActivityAt desc with undefined last', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [
|
||||
{ id: 'a', status: 'idle', lastActivityAt: 100 },
|
||||
{ id: 'b', status: 'idle', lastActivityAt: 300 },
|
||||
{ id: 'c', status: 'idle' }, // no lastActivityAt → sorts last
|
||||
{ id: 'd', status: 'idle', lastActivityAt: 200 },
|
||||
],
|
||||
});
|
||||
expect(merged.map((m) => m.sessionId)).toEqual(['b', 'd', 'a', 'c']);
|
||||
});
|
||||
|
||||
it('derives lastActivityAt from history lastModified when none better exists', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
history: [{ sessionId: 'h', workingDir: '/w', sizeBytes: 5000, lastModified: '2026-01-01T00:00:00.000Z' }],
|
||||
});
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].lastActivityAt).toBe(new Date('2026-01-01T00:00:00.000Z').getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterAndPaginate', () => {
|
||||
const items: UnifiedSessionItem[] = [
|
||||
{ sessionId: 's1', name: 'Alpha build', sources: ['live'], workingDir: '/repo/alpha' },
|
||||
{ sessionId: 's2', name: 'Beta', firstPrompt: 'fix the login bug', sources: ['history'], workingDir: '/repo/beta' },
|
||||
{ sessionId: 's3', name: 'Gamma', sources: ['persisted'], workingDir: '/srv/gamma' },
|
||||
];
|
||||
|
||||
it('filters by name (case-insensitive)', () => {
|
||||
const r = filterAndPaginate(items, { q: 'alpha' });
|
||||
expect(r.total).toBe(1);
|
||||
expect(r.sessions[0].sessionId).toBe('s1');
|
||||
});
|
||||
|
||||
it('filters by firstPrompt and workingDir', () => {
|
||||
expect(filterAndPaginate(items, { q: 'login bug' }).sessions[0].sessionId).toBe('s2');
|
||||
expect(filterAndPaginate(items, { q: '/srv/' }).sessions[0].sessionId).toBe('s3');
|
||||
});
|
||||
|
||||
it('reports total as the pre-page filtered count', () => {
|
||||
const r = filterAndPaginate(items, { q: 'repo', limit: 1 });
|
||||
// both s1 and s2 have /repo/ workingDir
|
||||
expect(r.total).toBe(2);
|
||||
expect(r.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('clamps limit to a max of 500', () => {
|
||||
const r = filterAndPaginate(items, { limit: 99999 });
|
||||
expect(r.sessions).toHaveLength(items.length);
|
||||
// clamp does not throw and returns all 3 (< 500)
|
||||
expect(r.total).toBe(3);
|
||||
});
|
||||
|
||||
it('clamps limit to a min of 1', () => {
|
||||
const r = filterAndPaginate(items, { limit: 0 });
|
||||
expect(r.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('paginates with disjoint pages via offset', () => {
|
||||
const page1 = filterAndPaginate(items, { offset: 0, limit: 2 });
|
||||
const page2 = filterAndPaginate(items, { offset: 2, limit: 2 });
|
||||
expect(page1.sessions.map((s) => s.sessionId)).toEqual(['s1', 's2']);
|
||||
expect(page2.sessions.map((s) => s.sessionId)).toEqual(['s3']);
|
||||
const overlap = page1.sessions
|
||||
.map((s) => s.sessionId)
|
||||
.filter((id) => page2.sessions.map((s2) => s2.sessionId).includes(id));
|
||||
expect(overlap).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user