mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #139 from aakhter/cod-160-unified-session-service
Unified session list: backend service + endpoint
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* @fileoverview Pure merge/filter logic for the unified session list (COD-121).
|
||||
*
|
||||
* Combines four read-only views of a session — live (in-memory `Session`),
|
||||
* persisted (`state.json`), transcript history (`~/.claude/projects`), and the
|
||||
* lifecycle audit log — plus mux process stats, into one de-duplicated list
|
||||
* keyed by sessionId. Transcript-history rows are keyed by the Claude
|
||||
* conversation UUID (the `.jsonl` filename stem), which diverges from the
|
||||
* Codeman id for resumed sessions — an alias map (claudeSessionId → Codeman id,
|
||||
* built from the live/persisted views) folds them into the owning session item.
|
||||
* Higher-precedence sources overwrite scalar fields when present
|
||||
* (history < lifecycle < persisted < live), while the `sources` array
|
||||
* always accumulates every contributing view. A "meaningfulness floor" drops
|
||||
* noise (bare lifecycle/mux-only rows with no name and no first prompt).
|
||||
*
|
||||
* PURE: no fs/IO and no node imports. All IO happens in the route that feeds
|
||||
* this module its inputs, which keeps the merge/sort/filter behavior unit-testable.
|
||||
*/
|
||||
|
||||
export type UnifiedSessionItem = {
|
||||
sessionId: string;
|
||||
name?: string;
|
||||
mode?: string;
|
||||
status?: string;
|
||||
isWorking?: boolean;
|
||||
workingDir?: string;
|
||||
createdAt?: number;
|
||||
lastActivityAt?: number;
|
||||
claudeSessionId?: string;
|
||||
firstPrompt?: string;
|
||||
sizeBytes?: number;
|
||||
remote?: boolean;
|
||||
sources: string[];
|
||||
stats?: { memoryMB: number; cpuPercent: number };
|
||||
};
|
||||
|
||||
/** Live in-memory session view (subset of `Session.toState()`). */
|
||||
export type LiveSessionInput = {
|
||||
id: string;
|
||||
name?: string;
|
||||
mode?: string;
|
||||
status?: string;
|
||||
isWorking?: boolean;
|
||||
workingDir?: string;
|
||||
createdAt?: number;
|
||||
lastActivityAt?: number;
|
||||
claudeSessionId?: string;
|
||||
};
|
||||
|
||||
/** Persisted session view (subset of `SessionState`). */
|
||||
export type PersistedSessionInput = {
|
||||
id: string;
|
||||
name?: string;
|
||||
mode?: string;
|
||||
status?: string;
|
||||
workingDir?: string;
|
||||
createdAt?: number;
|
||||
lastActivityAt?: number;
|
||||
/** Claude conversation ID this session resumes (`SessionState.resumeSessionId`). */
|
||||
claudeSessionId?: string;
|
||||
};
|
||||
|
||||
/** Lifecycle audit-log view. Entries are expected NEWEST-first (the order `SessionLifecycleLog.query()` returns). */
|
||||
export type LifecycleInput = {
|
||||
sessionId: string;
|
||||
name?: string;
|
||||
mode?: string;
|
||||
ts: number;
|
||||
event?: string;
|
||||
};
|
||||
|
||||
/** Transcript-history view (one `.jsonl` per session). */
|
||||
export type HistoryInput = {
|
||||
sessionId: string;
|
||||
workingDir: string;
|
||||
sizeBytes: number;
|
||||
lastModified: string;
|
||||
firstPrompt?: string;
|
||||
};
|
||||
|
||||
/** Mux process-stat view. */
|
||||
export type MuxStatInput = {
|
||||
sessionId: string;
|
||||
muxName?: string;
|
||||
mode?: string;
|
||||
stats?: { memoryMB: number; cpuPercent: number };
|
||||
remote?: boolean;
|
||||
};
|
||||
|
||||
export type UnifiedSources = {
|
||||
live?: LiveSessionInput[];
|
||||
persisted?: PersistedSessionInput[];
|
||||
lifecycle?: LifecycleInput[];
|
||||
history?: HistoryInput[];
|
||||
mux?: MuxStatInput[];
|
||||
};
|
||||
|
||||
/** Push a source tag onto an item exactly once. */
|
||||
function addSource(item: UnifiedSessionItem, source: string): void {
|
||||
if (!item.sources.includes(source)) item.sources.push(source);
|
||||
}
|
||||
|
||||
/** Get-or-create the accumulator item for a sessionId. */
|
||||
function ensureItem(map: Map<string, UnifiedSessionItem>, sessionId: string): UnifiedSessionItem {
|
||||
let item = map.get(sessionId);
|
||||
if (!item) {
|
||||
item = { sessionId, sources: [] };
|
||||
map.set(sessionId, item);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Overwrite a scalar field only when the incoming value is defined. */
|
||||
function overwrite<K extends keyof UnifiedSessionItem>(
|
||||
item: UnifiedSessionItem,
|
||||
key: K,
|
||||
value: UnifiedSessionItem[K] | undefined
|
||||
): void {
|
||||
if (value !== undefined) item[key] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge all source views into one list, applying precedence
|
||||
* (history → lifecycle → persisted → live) and the meaningfulness floor.
|
||||
*/
|
||||
export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionItem[] {
|
||||
const map = new Map<string, UnifiedSessionItem>();
|
||||
|
||||
// Alias map: Claude conversation UUID → owning Codeman session id. Resumed
|
||||
// (claudeSessionId = resumeSessionId != id) and /clear-respawned sessions
|
||||
// would otherwise surface twice — once as a live/persisted row and once as a
|
||||
// separate history-only row keyed by the conversation UUID. Live wins over
|
||||
// persisted on conflicting entries (registered last).
|
||||
const aliasToOwner = new Map<string, string>();
|
||||
for (const p of sources.persisted ?? []) {
|
||||
if (p.claudeSessionId !== undefined && p.claudeSessionId !== p.id) aliasToOwner.set(p.claudeSessionId, p.id);
|
||||
}
|
||||
for (const v of sources.live ?? []) {
|
||||
if (v.claudeSessionId !== undefined && v.claudeSessionId !== v.id) aliasToOwner.set(v.claudeSessionId, v.id);
|
||||
}
|
||||
const resolveId = (sessionId: string): string => aliasToOwner.get(sessionId) ?? sessionId;
|
||||
|
||||
// 1) history (lowest precedence; keys resolve through the alias map)
|
||||
for (const h of sources.history ?? []) {
|
||||
const item = ensureItem(map, resolveId(h.sessionId));
|
||||
addSource(item, 'history');
|
||||
overwrite(item, 'workingDir', h.workingDir);
|
||||
overwrite(item, 'sizeBytes', h.sizeBytes);
|
||||
overwrite(item, 'firstPrompt', h.firstPrompt);
|
||||
const ms = Date.parse(h.lastModified);
|
||||
if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms;
|
||||
}
|
||||
|
||||
// 2) lifecycle — entries arrive NEWEST-first, so first-seen wins for
|
||||
// name/mode (mirrors the lastActivityAt guard); unconditional overwrites
|
||||
// would leave the OLDEST entry in the window (stale name/mode) standing.
|
||||
for (const l of sources.lifecycle ?? []) {
|
||||
const item = ensureItem(map, resolveId(l.sessionId));
|
||||
addSource(item, 'lifecycle');
|
||||
if (item.name === undefined) overwrite(item, 'name', l.name);
|
||||
if (item.mode === undefined) overwrite(item, 'mode', l.mode);
|
||||
if (item.lastActivityAt === undefined && typeof l.ts === 'number') item.lastActivityAt = l.ts;
|
||||
}
|
||||
|
||||
// 3) persisted
|
||||
for (const p of sources.persisted ?? []) {
|
||||
const item = ensureItem(map, p.id);
|
||||
addSource(item, 'persisted');
|
||||
overwrite(item, 'name', p.name);
|
||||
overwrite(item, 'mode', p.mode);
|
||||
overwrite(item, 'status', p.status);
|
||||
overwrite(item, 'workingDir', p.workingDir);
|
||||
overwrite(item, 'createdAt', p.createdAt);
|
||||
overwrite(item, 'lastActivityAt', p.lastActivityAt);
|
||||
}
|
||||
|
||||
// 4) live (highest precedence)
|
||||
for (const v of sources.live ?? []) {
|
||||
const item = ensureItem(map, v.id);
|
||||
addSource(item, 'live');
|
||||
overwrite(item, 'name', v.name);
|
||||
overwrite(item, 'mode', v.mode);
|
||||
overwrite(item, 'status', v.status);
|
||||
overwrite(item, 'isWorking', v.isWorking);
|
||||
overwrite(item, 'workingDir', v.workingDir);
|
||||
overwrite(item, 'createdAt', v.createdAt);
|
||||
overwrite(item, 'lastActivityAt', v.lastActivityAt);
|
||||
overwrite(item, 'claudeSessionId', v.claudeSessionId);
|
||||
}
|
||||
|
||||
// 5) mux stats + remote flag (create item if mux-only)
|
||||
for (const m of sources.mux ?? []) {
|
||||
const item = ensureItem(map, m.sessionId);
|
||||
addSource(item, 'mux');
|
||||
overwrite(item, 'mode', m.mode);
|
||||
if (m.stats) item.stats = m.stats;
|
||||
if (m.remote !== undefined) item.remote = m.remote;
|
||||
}
|
||||
|
||||
// Meaningfulness floor: keep real rows, drop bare lifecycle/mux-only noise.
|
||||
const kept: UnifiedSessionItem[] = [];
|
||||
for (const item of map.values()) {
|
||||
const isReal =
|
||||
item.sources.includes('live') ||
|
||||
item.sources.includes('persisted') ||
|
||||
item.sources.includes('history') ||
|
||||
(item.firstPrompt !== undefined && item.firstPrompt !== '');
|
||||
if (isReal) kept.push(item);
|
||||
}
|
||||
|
||||
// Stable sort: lastActivityAt desc (undefined last), createdAt desc, sessionId asc.
|
||||
kept.sort((a, b) => {
|
||||
const la = a.lastActivityAt;
|
||||
const lb = b.lastActivityAt;
|
||||
if (la !== lb) {
|
||||
if (la === undefined) return 1;
|
||||
if (lb === undefined) return -1;
|
||||
return lb - la;
|
||||
}
|
||||
const ca = a.createdAt;
|
||||
const cb = b.createdAt;
|
||||
if (ca !== cb) {
|
||||
if (ca === undefined) return 1;
|
||||
if (cb === undefined) return -1;
|
||||
return cb - ca;
|
||||
}
|
||||
return a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0;
|
||||
});
|
||||
|
||||
return kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive substring filter (name + firstPrompt + workingDir + sessionId)
|
||||
* with offset/limit paging. `total` is the filtered count BEFORE paging.
|
||||
*/
|
||||
export function filterAndPaginate(
|
||||
items: UnifiedSessionItem[],
|
||||
opts: { q?: string; offset?: number; limit?: number }
|
||||
): { sessions: UnifiedSessionItem[]; total: number } {
|
||||
const q = (opts.q ?? '').trim().toLowerCase();
|
||||
const filtered = q
|
||||
? items.filter((it) => {
|
||||
const hay = [it.name, it.firstPrompt, it.workingDir, it.sessionId]
|
||||
.filter((v): v is string => typeof v === 'string')
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return hay.includes(q);
|
||||
})
|
||||
: items;
|
||||
|
||||
const total = filtered.length;
|
||||
const offset = Math.max(0, Math.floor(opts.offset ?? 0));
|
||||
const limit = Math.min(500, Math.max(1, Math.floor(opts.limit ?? 100)));
|
||||
const sessions = filtered.slice(offset, offset + limit);
|
||||
return { sessions, total };
|
||||
}
|
||||
@@ -55,6 +55,15 @@ import {
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { imageWatcher } from '../../image-watcher.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import {
|
||||
mergeUnifiedSessions,
|
||||
filterAndPaginate,
|
||||
type LiveSessionInput,
|
||||
type PersistedSessionInput,
|
||||
type LifecycleInput,
|
||||
type HistoryInput,
|
||||
type MuxStatInput,
|
||||
} from '../../services/unified-session-service.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
||||
import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
|
||||
import { RunSummaryTracker } from '../../run-summary.js';
|
||||
@@ -1749,6 +1758,118 @@ export function registerSessionRoutes(
|
||||
return { sessions: results.slice(0, 50) };
|
||||
});
|
||||
|
||||
// Unified, read-only session list: merges live + persisted + lifecycle +
|
||||
// transcript history + mux stats into one de-duplicated, searchable list
|
||||
// (COD-121). Pure merge/filter logic lives in unified-session-service.ts.
|
||||
app.get('/api/sessions/unified', async (req) => {
|
||||
const query = req.query as { q?: string; offset?: string; limit?: string };
|
||||
|
||||
if (ctx.testMode) {
|
||||
return { sessions: [], total: 0 };
|
||||
}
|
||||
|
||||
// Live (in-memory) sessions.
|
||||
const live: LiveSessionInput[] = [...ctx.sessions.values()].map((s) => {
|
||||
const st = s.toState();
|
||||
return {
|
||||
id: st.id,
|
||||
name: st.name,
|
||||
mode: st.mode,
|
||||
status: st.status,
|
||||
isWorking: s.isWorking,
|
||||
workingDir: st.workingDir,
|
||||
createdAt: st.createdAt,
|
||||
lastActivityAt: st.lastActivityAt,
|
||||
claudeSessionId: s.claudeSessionId ?? undefined,
|
||||
};
|
||||
});
|
||||
|
||||
// Persisted sessions (state.json). resumeSessionId is the Claude
|
||||
// conversation UUID a resumed session continues — feed it to the merge's
|
||||
// alias map so its transcript row folds into this session.
|
||||
const persisted: PersistedSessionInput[] = Object.values(ctx.store.getState().sessions).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
mode: p.mode,
|
||||
status: p.status,
|
||||
workingDir: p.workingDir,
|
||||
createdAt: p.createdAt,
|
||||
lastActivityAt: p.lastActivityAt,
|
||||
claudeSessionId: p.resumeSessionId,
|
||||
}));
|
||||
|
||||
// Lifecycle audit log (newest-first, capped).
|
||||
let lifecycle: LifecycleInput[] = [];
|
||||
try {
|
||||
const entries = await getLifecycleLog().query({ limit: 2000 });
|
||||
lifecycle = entries.map((e) => ({
|
||||
sessionId: e.sessionId,
|
||||
name: e.name,
|
||||
mode: e.mode,
|
||||
ts: e.ts,
|
||||
event: e.event,
|
||||
}));
|
||||
} catch {
|
||||
// Lifecycle log may be unavailable; treat as empty.
|
||||
}
|
||||
|
||||
// Transcript history (~/.claude/projects) — reuse the same scanner as the overview.
|
||||
const history: HistoryInput[] = [];
|
||||
try {
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
const headBuf = Buffer.alloc(16384);
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
for (const projDir of projectDirs) {
|
||||
const projPath = join(projectsDir, projDir);
|
||||
const list = await scanProjectDir(projPath, projDir, headBuf);
|
||||
for (const h of list) {
|
||||
history.push({
|
||||
sessionId: h.sessionId,
|
||||
workingDir: h.workingDir,
|
||||
sizeBytes: h.sizeBytes,
|
||||
lastModified: h.lastModified,
|
||||
firstPrompt: h.firstPrompt,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Projects dir may not exist.
|
||||
}
|
||||
|
||||
// Mux process stats (best-effort; guard against mocks lacking the method).
|
||||
let mux: MuxStatInput[] = [];
|
||||
try {
|
||||
const getStats = (ctx.mux as { getSessionsWithStats?: () => Promise<unknown[]> }).getSessionsWithStats;
|
||||
if (typeof getStats === 'function') {
|
||||
const muxSessions = (await getStats.call(ctx.mux)) as Array<{
|
||||
sessionId: string;
|
||||
muxName?: string;
|
||||
mode?: string;
|
||||
remote?: unknown;
|
||||
stats?: { memoryMB: number; cpuPercent: number };
|
||||
}>;
|
||||
mux = muxSessions.map((m) => ({
|
||||
sessionId: m.sessionId,
|
||||
muxName: m.muxName,
|
||||
mode: m.mode,
|
||||
remote: m.remote !== undefined ? true : undefined,
|
||||
stats: m.stats ? { memoryMB: m.stats.memoryMB, cpuPercent: m.stats.cpuPercent } : undefined,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// Mux stats are optional.
|
||||
}
|
||||
|
||||
const merged = mergeUnifiedSessions({ live, persisted, lifecycle, history, mux });
|
||||
const offset = query.offset !== undefined ? parseInt(query.offset, 10) : undefined;
|
||||
const limit = query.limit !== undefined ? parseInt(query.limit, 10) : undefined;
|
||||
return filterAndPaginate(merged, {
|
||||
q: query.q,
|
||||
offset: Number.isNaN(offset as number) ? undefined : offset,
|
||||
limit: Number.isNaN(limit as number) ? undefined : limit,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Paste Image (clipboard / drag-drop upload)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user