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
+257
View File
@@ -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 };
}
+121
View File
@@ -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)
// ═══════════════════════════════════════════════════════════════