Merge pull request #139 from aakhter/cod-160-unified-session-service

Unified session list: backend service + endpoint
This commit is contained in:
Ark0N
2026-07-12 13:22:39 +02:00
committed by GitHub
5 changed files with 752 additions and 1 deletions
+167
View File
@@ -0,0 +1,167 @@
/**
* @fileoverview Route tests for GET /api/sessions/unified (COD-121).
*
* Uses app.inject() with a local envelope harness (mirrors the production
* preSerialization wrap so bodies appear as { success: true, data }). The
* default mock ctx has testMode=true (handler short-circuits to empty); tests
* that exercise the real merge path flip testMode off and stub the extra
* read-only methods the handler calls (store.getState, mux.getSessionsWithStats).
*/
import { describe, it, expect, afterEach, beforeAll, afterAll, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
// Keep the handler's IO fast + deterministic: stub the lifecycle log query so it
// never reads the real ~/.codeman/session-lifecycle.jsonl, and point HOME at an
// empty temp dir so the ~/.claude/projects transcript scan is a no-op.
vi.mock('../../src/session-lifecycle-log.js', async (orig) => {
const actual = await orig<typeof import('../../src/session-lifecycle-log.js')>();
return {
...actual,
getLifecycleLog: () => ({ query: async () => [] }) as unknown as ReturnType<typeof actual.getLifecycleLog>,
};
});
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
let tmpHome: string;
let prevHome: string | undefined;
beforeAll(() => {
tmpHome = mkdtempSync(join(tmpdir(), 'cod121-home-'));
prevHome = process.env.HOME;
process.env.HOME = tmpHome; // empty home → no ~/.claude/projects → fast empty scan
});
afterAll(() => {
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
rmSync(tmpHome, { recursive: true, force: true });
});
interface LocalHarness {
app: FastifyInstance;
ctx: MockRouteContext;
}
async function createEnvelopeHarness(ctx: MockRouteContext): Promise<LocalHarness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
registerSessionRoutes(app, ctx as never);
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
if (!req.url.startsWith('/api')) return done(null, payload);
if (payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown; errorCode?: unknown };
if (p.success === false) {
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
}
if (p.success === true) return done(null, payload);
return done(null, { success: true, data: payload });
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
/** Flip the default mock into "real" mode and stub the extra reads the handler makes. */
function makeLiveCtx(): MockRouteContext {
const ctx = createMockRouteContext();
// Real merge path (handler short-circuits when testMode is true).
(ctx as { testMode: boolean }).testMode = false;
// store.getState().sessions — the handler reads persisted sessions here.
(ctx.store as { getState?: () => unknown }).getState = vi.fn(() => ({ sessions: {} }));
// mux.getSessionsWithStats — optional; provide an empty list so the merge runs.
(ctx.mux as { getSessionsWithStats?: () => Promise<unknown[]> }).getSessionsWithStats = vi.fn(async () => []);
return ctx;
}
describe('GET /api/sessions/unified', () => {
let harness: LocalHarness;
afterEach(async () => {
if (harness) await harness.app.close();
// Drop any per-test transcript fixtures so other tests see an empty home.
rmSync(join(tmpHome, '.claude'), { recursive: true, force: true });
});
it('returns the {sessions,total} envelope with default (testMode) ctx', async () => {
harness = await createEnvelopeHarness(createMockRouteContext());
const res = await harness.app.inject({ method: 'GET', url: '/api/sessions/unified' });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(Array.isArray(body.data.sessions)).toBe(true);
expect(typeof body.data.total).toBe('number');
});
it('surfaces seeded live sessions when testMode is off', async () => {
// The default mock pre-populates one live session: 'test-session-1'.
harness = await createEnvelopeHarness(makeLiveCtx());
const res = await harness.app.inject({ method: 'GET', url: '/api/sessions/unified' });
expect(res.statusCode).toBe(200);
const body = res.json();
const ids = body.data.sessions.map((s: { sessionId: string }) => s.sessionId);
expect(ids).toContain('test-session-1');
expect(body.data.total).toBeGreaterThanOrEqual(1);
});
it('filters with ?q=', async () => {
harness = await createEnvelopeHarness(makeLiveCtx());
const res = await harness.app.inject({
method: 'GET',
url: '/api/sessions/unified?q=no-such-session-xyz',
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data.total).toBe(0);
expect(body.data.sessions).toHaveLength(0);
});
it('caps results with ?limit=', async () => {
harness = await createEnvelopeHarness(makeLiveCtx());
const res = await harness.app.inject({ method: 'GET', url: '/api/sessions/unified?limit=0' });
expect(res.statusCode).toBe(200);
const body = res.json();
// limit clamps to a minimum of 1, so at most 1 row is returned.
expect(body.data.sessions.length).toBeLessThanOrEqual(1);
});
it('folds a resumed session transcript (claudeSessionId != id) into ONE row', async () => {
// Seed a real transcript fixture keyed by the Claude conversation UUID
// (the .jsonl filename stem), like a resumed session leaves behind.
const uuid = 'aabbccdd-1111-2222-3333-444455556666';
const projDir = join(tmpHome, '.claude', 'projects', '-tmp-test-workdir');
mkdirSync(projDir, { recursive: true });
const line = JSON.stringify({ type: 'user', message: { role: 'user', content: 'resumed prompt' } }) + '\n';
writeFileSync(join(projDir, `${uuid}.jsonl`), line.repeat(60)); // >4000 bytes so the scanner keeps it
const ctx = makeLiveCtx();
// Resumed session: the live Codeman session owns that conversation UUID.
const live = ctx.sessions.get('test-session-1') as unknown as { claudeSessionId?: string };
live.claudeSessionId = uuid;
harness = await createEnvelopeHarness(ctx);
const res = await harness.app.inject({ method: 'GET', url: '/api/sessions/unified' });
expect(res.statusCode).toBe(200);
const rows = res.json().data.sessions as Array<{ sessionId: string; sources: string[] }>;
// No separate history-only row keyed by the conversation UUID…
expect(rows.map((r) => r.sessionId)).not.toContain(uuid);
// …the transcript merged into the owning live session instead.
const row = rows.find((r) => r.sessionId === 'test-session-1');
expect(row).toBeDefined();
expect(row!.sources).toContain('live');
expect(row!.sources).toContain('history');
});
});
@@ -0,0 +1,199 @@
/**
* @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('folds a resumed session transcript (claudeSessionId != id) into ONE row', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'cm-1', status: 'working', claudeSessionId: 'uuid-resume' }],
history: [
{
sessionId: 'uuid-resume', // transcript rows are keyed by the conversation UUID
workingDir: '/w',
sizeBytes: 7000,
lastModified: '2026-01-03T00:00:00.000Z',
firstPrompt: 'resumed prompt',
},
],
});
expect(merged).toHaveLength(1);
const item = merged[0];
expect(item.sessionId).toBe('cm-1');
expect([...item.sources].sort()).toEqual(['history', 'live']);
expect(item.firstPrompt).toBe('resumed prompt');
expect(item.sizeBytes).toBe(7000);
});
it('resolves history rows through a persisted claudeSessionId alias', () => {
const merged = mergeUnifiedSessions({
persisted: [{ id: 'cm-2', name: 'Resumed', claudeSessionId: 'uuid-p' }],
history: [{ sessionId: 'uuid-p', workingDir: '/w', sizeBytes: 4200, lastModified: '2026-01-04T00:00:00.000Z' }],
});
expect(merged).toHaveLength(1);
expect(merged[0].sessionId).toBe('cm-2');
expect([...merged[0].sources].sort()).toEqual(['history', 'persisted']);
});
it('surfaces the NEWEST lifecycle name/mode (entries arrive newest-first)', () => {
const merged = mergeUnifiedSessions({
// query() returns newest-first: the rename must win over the original
// name — an unconditional overwrite would leave the OLDEST standing.
lifecycle: [
{ sessionId: 'del-1', name: 'Renamed', mode: 'claude', ts: 2000, event: 'deleted' },
{ sessionId: 'del-1', name: 'Original', mode: 'shell', ts: 1000, event: 'created' },
],
history: [
{
sessionId: 'del-1',
workingDir: '/w',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'hello',
},
],
});
expect(merged).toHaveLength(1);
expect(merged[0].name).toBe('Renamed');
expect(merged[0].mode).toBe('claude');
});
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([]);
});
});
+8 -1
View File
@@ -101,7 +101,14 @@ describe('types utility functions', () => {
const state2 = createInitialRalphTrackerState();
expect(state1).not.toBe(state2);
expect(state1).toEqual(state2);
// `lastActivity` is stamped with Date.now(), so two calls that straddle a
// millisecond boundary differ by 1ms. Compare the rest of the initial
// state for equality and assert the timestamp is a number separately.
const { lastActivity: lastActivity1, ...rest1 } = state1;
const { lastActivity: lastActivity2, ...rest2 } = state2;
expect(rest1).toEqual(rest2);
expect(typeof lastActivity1).toBe('number');
expect(typeof lastActivity2).toBe('number');
});
it('should have correct types for all fields', () => {