mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
fix(web): usable past-conversation list, and search that finds past sessions
Two home-screen reports from @jordan8037310, both about history that is present but unreachable. #260 — "Resume Conversation" rendered 4 rows, then a button that appended every remaining row into a `max-height: 240px` box, so 35 conversations landed in a four-row scroll well with no ordering or filtering. Rendering now goes through `_renderHistoryList()` over a cached corpus: 10 rows to start, Show more/Show less that grows and shrinks the box (the height cap is class-driven, `.history-list.expanded`), plus a filter box (name, folder, #case label, prompts), a sort control (recent / name / folder, pinned rows still first) and a shown-of-total count. A filter implies expansion, so every match is visible, and the whole header hides as one unit while a federated search is active. The A-Z sort keys off the same string the row renders, since most rows are transcript-backed and carry no session name at all. #261 — the search box could not match a past project by folder name: `harvestSources()` built its session corpus from the live in-memory map, while past sessions come from `/api/sessions/unified` (lifecycle log + transcript scan). Folding that scan into the request path would have cost the search its no-filesystem-reads property, so the corpus arrives via a bounded snapshot instead: `session-history-index.ts` is published as a side effect of `/api/sessions/unified` (the home screen fetches it on open, which is the same screen the search box lives on) and rebuilt fire-and-forget, single-flight and TTL-guarded when a search finds it stale. A result for a closed session now resumes the conversation rather than selecting a tab that no longer exists, and is badged RESUME. The snapshot is stored unscoped with a per-row owner and re-filtered through canAccessOwned() on read, so multi-user sees exactly what /api/sessions/unified exposes: own sessions only, host-wide transcript history admin-only. Live rows are harvested first and win the dedupe. Verified end-to-end against a real instance with 60 past sessions: cold process answers its first search without history and its second with it; folder-name queries return resume targets; clicking one posts the right resumeSessionId + workingDir. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* @fileoverview Issue #260 — the home screen's "Resume Conversation" list.
|
||||
*
|
||||
* With ~35 past sessions the list showed 4 rows, then a button that dumped every
|
||||
* remaining row into a fixed 240px box, with no way to sort or filter. The fix
|
||||
* moved rendering into `_renderHistoryList()` over a cached corpus, so what is
|
||||
* worth pinning is the model, not the pixels:
|
||||
* 1. the collapsed page is _HISTORY_INITIAL_COUNT rows, not 4,
|
||||
* 2. "Show more" expands the LIST and marks the box expanded (the CSS cap is
|
||||
* class-driven — without the class, expanding just deepens a scroll well),
|
||||
* 3. filtering matches name / folder / case label / prompt, and implies
|
||||
* expansion (hiding matches behind "Show more" defeats typing a filter),
|
||||
* 4. sorting is alphabetical by name or folder, with pinned rows still on top.
|
||||
*
|
||||
* Loaded via `vm` against a stub CodemanApp with a fake DOM — same harness as
|
||||
* resume-name.test.ts. `_buildHistoryItem` is stubbed: this pins WHICH rows get
|
||||
* rendered and in what order, not how one row looks.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
interface FakeEl {
|
||||
id: string;
|
||||
value: string;
|
||||
textContent: string;
|
||||
scrollTop: number;
|
||||
className: string;
|
||||
children: FakeEl[];
|
||||
classes: Set<string>;
|
||||
listeners: Record<string, ((ev: unknown) => void)[]>;
|
||||
classList: { toggle: (c: string, on: boolean) => void; contains: (c: string) => boolean };
|
||||
replaceChildren: () => void;
|
||||
appendChild: (child: FakeEl) => FakeEl;
|
||||
addEventListener: (type: string, fn: (ev: unknown) => void) => void;
|
||||
style: Record<string, string>;
|
||||
}
|
||||
|
||||
function fakeEl(id: string): FakeEl {
|
||||
const el = {
|
||||
id,
|
||||
value: '',
|
||||
textContent: '',
|
||||
scrollTop: 0,
|
||||
className: '',
|
||||
children: [] as FakeEl[],
|
||||
classes: new Set<string>(),
|
||||
listeners: {} as Record<string, ((ev: unknown) => void)[]>,
|
||||
style: {} as Record<string, string>,
|
||||
} as FakeEl;
|
||||
el.classList = {
|
||||
toggle: (c: string, on: boolean) => (on ? el.classes.add(c) : el.classes.delete(c)),
|
||||
contains: (c: string) => el.classes.has(c),
|
||||
};
|
||||
el.replaceChildren = () => {
|
||||
el.children = [];
|
||||
};
|
||||
el.appendChild = (child: FakeEl) => {
|
||||
el.children.push(child);
|
||||
return child;
|
||||
};
|
||||
el.addEventListener = (type: string, fn: (ev: unknown) => void) => {
|
||||
(el.listeners[type] ||= []).push(fn);
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
/**
|
||||
* The element map the vm's `document.getElementById` resolves against. Swapped
|
||||
* per test — the closure is defined in THIS realm, so the shipping code inside
|
||||
* the vm reads whatever the current test installed.
|
||||
*/
|
||||
let currentEls: Record<string, FakeEl> = {};
|
||||
|
||||
function loadTerminalUiPrototype(): Record<string, any> {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
CodemanApp: class CodemanApp {},
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
document: {
|
||||
addEventListener: vi.fn(),
|
||||
getElementById: (id: string) => currentEls[id] ?? null,
|
||||
createElement: () => fakeEl('created'),
|
||||
},
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
});
|
||||
vm.runInContext(`${source}\nglobalThis.__proto = CodemanApp.prototype;`, context);
|
||||
return (context as unknown as { __proto: Record<string, any> }).__proto;
|
||||
}
|
||||
|
||||
const proto = loadTerminalUiPrototype();
|
||||
|
||||
type Row = {
|
||||
sessionId: string;
|
||||
name?: string;
|
||||
workingDir?: string;
|
||||
firstPrompt?: string;
|
||||
pinned?: boolean;
|
||||
lastActivityAt?: number;
|
||||
};
|
||||
|
||||
/** Host object carrying the real render/filter/sort methods over a fake DOM. */
|
||||
function makeApp(rows: Row[], cases: Array<{ name: string; path: string }> = []) {
|
||||
const els: Record<string, FakeEl> = {
|
||||
historyList: fakeEl('historyList'),
|
||||
historyFilter: fakeEl('historyFilter'),
|
||||
historySort: fakeEl('historySort'),
|
||||
historyCount: fakeEl('historyCount'),
|
||||
};
|
||||
els.historySort.value = 'recent';
|
||||
|
||||
const app: any = {
|
||||
_HISTORY_INITIAL_COUNT: proto._HISTORY_INITIAL_COUNT,
|
||||
_historyAll: rows,
|
||||
_historyCases: cases,
|
||||
_renderHistoryList: proto._renderHistoryList,
|
||||
_historyRowMatches: proto._historyRowMatches,
|
||||
_sortHistoryRows: proto._sortHistoryRows,
|
||||
_historyRowLabel: proto._historyRowLabel,
|
||||
_resolveCaseLabel: proto._resolveCaseLabel,
|
||||
_shortenHomePath: proto._shortenHomePath,
|
||||
// One fake node per row, tagged so assertions can read back the order.
|
||||
_buildHistoryItem: (s: Row) => {
|
||||
const el = fakeEl('item');
|
||||
el.textContent = s.sessionId;
|
||||
return el;
|
||||
},
|
||||
els,
|
||||
/** Rendered row ids, excluding the show-more/less button and empty state. */
|
||||
renderedIds(): string[] {
|
||||
return els.historyList.children.filter((c) => c.id === 'item').map((c) => c.textContent);
|
||||
},
|
||||
button(): FakeEl | undefined {
|
||||
return els.historyList.children.find((c) => c.id === 'created');
|
||||
},
|
||||
};
|
||||
|
||||
// Point the vm's document at this app's elements, then run the shipping method.
|
||||
app._render = () => {
|
||||
currentEls = els;
|
||||
app._renderHistoryList();
|
||||
};
|
||||
return app;
|
||||
}
|
||||
|
||||
function rows(n: number, overrides: Partial<Row> = {}): Row[] {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
sessionId: `s${i}`,
|
||||
name: `w${i}-project${i}`,
|
||||
workingDir: `/home/u/project${i}`,
|
||||
lastActivityAt: 1000 - i,
|
||||
...overrides,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('issue #260 — collapsed page size', () => {
|
||||
it('shows more than the old 4 rows before "Show more"', () => {
|
||||
expect(proto._HISTORY_INITIAL_COUNT).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
it('renders the initial page and a "Show more" button for the rest', () => {
|
||||
const app = makeApp(rows(35));
|
||||
app._render();
|
||||
expect(app.renderedIds()).toHaveLength(proto._HISTORY_INITIAL_COUNT);
|
||||
expect(app.button()?.textContent).toBe(`Show ${35 - proto._HISTORY_INITIAL_COUNT} more`);
|
||||
expect(app.els.historyList.classList.contains('expanded')).toBe(false);
|
||||
});
|
||||
|
||||
it('expanding renders every row AND marks the box expanded', () => {
|
||||
const app = makeApp(rows(35));
|
||||
app._historyExpanded = true;
|
||||
app._render();
|
||||
expect(app.renderedIds()).toHaveLength(35);
|
||||
// Without this class the CSS max-height stays at the collapsed cap and the
|
||||
// extra rows land in a four-row scroll well — the original bug.
|
||||
expect(app.els.historyList.classList.contains('expanded')).toBe(true);
|
||||
expect(app.button()?.textContent).toBe('Show less');
|
||||
});
|
||||
|
||||
it('shows no button at all when everything fits', () => {
|
||||
const app = makeApp(rows(3));
|
||||
app._render();
|
||||
expect(app.renderedIds()).toHaveLength(3);
|
||||
expect(app.button()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('issue #260 — filter', () => {
|
||||
it('matches on folder name and shows every match without expanding first', () => {
|
||||
const app = makeApp([
|
||||
...rows(30),
|
||||
{ sessionId: 'x1', name: 'w99-invoices', workingDir: '/home/u/invoices', lastActivityAt: 1 },
|
||||
{ sessionId: 'x2', name: 'w98-other', workingDir: '/home/u/invoices-archive', lastActivityAt: 2 },
|
||||
]);
|
||||
app.els.historyFilter.value = 'invoices';
|
||||
app._render();
|
||||
expect(app.renderedIds().sort()).toEqual(['x1', 'x2']);
|
||||
expect(app.els.historyList.classList.contains('expanded')).toBe(true);
|
||||
expect(app.els.historyCount.textContent).toBe('2 of 32');
|
||||
});
|
||||
|
||||
it('matches on the case label and on a prompt', () => {
|
||||
const app = makeApp(
|
||||
[
|
||||
{ sessionId: 'c1', name: 'w1-x', workingDir: '/home/u/cases/billing', lastActivityAt: 1 },
|
||||
{
|
||||
sessionId: 'p1',
|
||||
name: 'w2-y',
|
||||
workingDir: '/home/u/other',
|
||||
firstPrompt: 'fix the CSV export',
|
||||
lastActivityAt: 2,
|
||||
},
|
||||
],
|
||||
[{ name: 'billing', path: '/home/u/cases/billing' }]
|
||||
);
|
||||
app.els.historyFilter.value = '#billing';
|
||||
app._render();
|
||||
expect(app.renderedIds()).toEqual(['c1']);
|
||||
|
||||
app.els.historyFilter.value = 'csv export';
|
||||
app._render();
|
||||
expect(app.renderedIds()).toEqual(['p1']);
|
||||
});
|
||||
|
||||
it('renders an empty state when nothing matches', () => {
|
||||
const app = makeApp(rows(5));
|
||||
app.els.historyFilter.value = 'zzzz';
|
||||
app._render();
|
||||
expect(app.renderedIds()).toEqual([]);
|
||||
expect(app.els.historyList.children[0].textContent).toContain('No conversations match');
|
||||
});
|
||||
});
|
||||
|
||||
describe('issue #260 — sort', () => {
|
||||
const unsorted: Row[] = [
|
||||
{ sessionId: 'b', name: 'beta', workingDir: '/home/u/zeta', lastActivityAt: 300 },
|
||||
{ sessionId: 'a', name: 'alpha', workingDir: '/home/u/yankee', lastActivityAt: 200 },
|
||||
{ sessionId: 'c', name: 'gamma', workingDir: '/home/u/xray', lastActivityAt: 100 },
|
||||
];
|
||||
|
||||
it('recent keeps the backend order', () => {
|
||||
const app = makeApp(unsorted);
|
||||
app._render();
|
||||
expect(app.renderedIds()).toEqual(['b', 'a', 'c']);
|
||||
});
|
||||
|
||||
it('sorts by name', () => {
|
||||
const app = makeApp(unsorted);
|
||||
app.els.historySort.value = 'name';
|
||||
app._render();
|
||||
expect(app.renderedIds()).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('sorts by folder basename', () => {
|
||||
const app = makeApp(unsorted);
|
||||
app.els.historySort.value = 'folder';
|
||||
app._render();
|
||||
expect(app.renderedIds()).toEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('sorts transcript rows (no session name) by the prompt shown as their title', () => {
|
||||
// Most past rows come from a transcript and have no name at all. Keying the
|
||||
// A–Z sort off `name` alone made "Name A–Z" a no-op for them.
|
||||
const app = makeApp([
|
||||
{ sessionId: 'z', workingDir: '/home/u/one', firstPrompt: 'zebra crossing' },
|
||||
{ sessionId: 'a', workingDir: '/home/u/two', firstPrompt: 'apple pie' },
|
||||
{ sessionId: 'm', workingDir: '/home/u/three', firstPrompt: 'middle ground' },
|
||||
]);
|
||||
app.els.historySort.value = 'name';
|
||||
app._render();
|
||||
expect(app.renderedIds()).toEqual(['a', 'm', 'z']);
|
||||
});
|
||||
|
||||
it('keeps pinned rows on top in every sort mode', () => {
|
||||
const app = makeApp([{ sessionId: 'p', name: 'zulu', workingDir: '/home/u/zulu', pinned: true }, ...unsorted]);
|
||||
for (const mode of ['recent', 'name', 'folder']) {
|
||||
app.els.historySort.value = mode;
|
||||
app._render();
|
||||
expect(app.renderedIds()[0]).toBe('p');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9,12 +9,13 @@
|
||||
* (sessions + events) return results. Source data is injected via the mock
|
||||
* route context (sessions map, runSummaryTrackers map, attachment history).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { registerSearchRoutes } from '../../src/web/routes/search-routes.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { createMockRouteContext } from '../mocks/index.js';
|
||||
import { RunSummaryTracker } from '../../src/run-summary.js';
|
||||
import { resetHistorySessionIndex, setHistorySessionIndex } from '../../src/web/session-history-index.js';
|
||||
|
||||
type Ctx = ReturnType<typeof createMockRouteContext>;
|
||||
|
||||
@@ -206,3 +207,97 @@ describe('GET /api/search — caps & filters', () => {
|
||||
expect(types).toEqual(['event']);
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #261: with 3 live sessions and ~35 past ones, searching a past project's
|
||||
// folder name matched nothing — the corpus was the live session map alone. Past
|
||||
// sessions now arrive from the out-of-band history index snapshot.
|
||||
describe('GET /api/search — past sessions (history index)', () => {
|
||||
beforeEach(() => {
|
||||
resetHistorySessionIndex();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetHistorySessionIndex();
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
});
|
||||
|
||||
it('matches a past session by folder name with no live session at all', async () => {
|
||||
const { app } = await harness();
|
||||
setHistorySessionIndex([
|
||||
{
|
||||
sessionId: 'cod-9',
|
||||
name: 'w4-needlework',
|
||||
workingDir: '/home/u/projects/needlework',
|
||||
claudeSessionId: 'claude-uuid',
|
||||
timestamp: 1000,
|
||||
live: false,
|
||||
},
|
||||
]);
|
||||
const res = await app.inject({ method: 'GET', url: '/api/search?q=needlework' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.totalResults).toBe(1);
|
||||
expect(body.data.groups[0].results[0].jumpTo).toMatchObject({
|
||||
kind: 'resume-session',
|
||||
sessionId: 'cod-9',
|
||||
claudeSessionId: 'claude-uuid',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not duplicate a session that is both live and in the snapshot', async () => {
|
||||
const { app } = await harness((ctx) => {
|
||||
ctx.sessions.set(
|
||||
'dup',
|
||||
fakeSession({ id: 'dup', name: 'needle live', workingDir: '/home/u/needle', lastActivityAt: 5 }) as never
|
||||
);
|
||||
});
|
||||
setHistorySessionIndex([
|
||||
{ sessionId: 'dup', name: 'needle live', workingDir: '/home/u/needle', timestamp: 5, live: true },
|
||||
]);
|
||||
const body = JSON.parse((await app.inject({ method: 'GET', url: '/api/search?q=needle' })).body);
|
||||
expect(body.data.totalResults).toBe(1);
|
||||
// The live harvest wins, so the card still switches to the open tab.
|
||||
expect(body.data.groups[0].results[0].jumpTo.kind).toBe('session');
|
||||
});
|
||||
|
||||
it('multi-user: a non-admin sees neither another user’s past session nor unowned host-wide history', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const app = Fastify({ logger: false });
|
||||
app.addHook('onRequest', async (req) => {
|
||||
(req as unknown as { authUser: unknown }).authUser = { username: 'bob', role: 'user' };
|
||||
});
|
||||
const ctx = createMockRouteContext();
|
||||
ctx.sessions.clear();
|
||||
ctx.runSummaryTrackers.clear();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
registerSearchRoutes(app, ctx as any);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
|
||||
setHistorySessionIndex([
|
||||
{
|
||||
sessionId: 'mine',
|
||||
name: 'needle-bob',
|
||||
workingDir: '/home/u/needle-bob',
|
||||
timestamp: 3,
|
||||
owner: 'bob',
|
||||
live: false,
|
||||
},
|
||||
{
|
||||
sessionId: 'hers',
|
||||
name: 'needle-alice',
|
||||
workingDir: '/home/u/needle-alice',
|
||||
timestamp: 2,
|
||||
owner: 'alice',
|
||||
live: false,
|
||||
},
|
||||
// Host-wide transcript row: no owning session, so admin-only — the same
|
||||
// rule GET /api/sessions/unified applies when it drops history for non-admins.
|
||||
{ sessionId: 'hostwide', name: 'needle-host', workingDir: '/srv/needle-host', timestamp: 1, live: false },
|
||||
]);
|
||||
|
||||
const body = JSON.parse((await app.inject({ method: 'GET', url: '/api/search?q=needle' })).body);
|
||||
expect(body.data.groups[0].results.map((r: { sessionId: string }) => r.sessionId)).toEqual(['mine']);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -233,3 +233,61 @@ describe('searchSources — result card shape & path safety', () => {
|
||||
expect(searchSources('', data).totalResults).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Past sessions (issue #261). The corpus used to be the live session map alone,
|
||||
// so a folder in the home screen's Resume list matched nothing. History rows now
|
||||
// arrive marked, and a card for one has to RESUME the conversation — selecting a
|
||||
// tab that no longer exists is a no-op the user reads as a broken result.
|
||||
describe('searchSources — past (history) sessions', () => {
|
||||
it('matches a past session by folder name and returns a resume jump target', () => {
|
||||
const data = sources({
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'cod-1',
|
||||
sessionName: 'w3-invoices',
|
||||
workingDir: '/home/u/projects/invoices',
|
||||
timestamp: 500,
|
||||
history: true,
|
||||
claudeSessionId: 'claude-uuid-1',
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = searchSources('invoices', data);
|
||||
expect(res.totalResults).toBe(1);
|
||||
expect(res.groups[0].results[0].jumpTo).toEqual({
|
||||
kind: 'resume-session',
|
||||
sessionId: 'cod-1',
|
||||
claudeSessionId: 'claude-uuid-1',
|
||||
workingDir: '/home/u/projects/invoices',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a live session on the plain session jump target', () => {
|
||||
const data = sources({
|
||||
sessions: [{ sessionId: 'live-1', sessionName: 'w1-invoices', workingDir: '/home/u/invoices', timestamp: 1 }],
|
||||
});
|
||||
expect(searchSources('invoices', data).groups[0].results[0].jumpTo).toEqual({
|
||||
kind: 'session',
|
||||
sessionId: 'live-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not offer a resume for a history row with no working directory', () => {
|
||||
const data = sources({
|
||||
sessions: [{ sessionId: 'cod-2', sessionName: 'needle-run', workingDir: '', timestamp: 1, history: true }],
|
||||
});
|
||||
// Nothing to resume INTO — a resume card here would always fail.
|
||||
expect(searchSources('needle', data).groups[0].results[0].jumpTo.kind).toBe('session');
|
||||
});
|
||||
|
||||
it('falls back to the folder basename when a transcript row has no name', () => {
|
||||
const data = sources({
|
||||
sessions: [
|
||||
{ sessionId: 'cod-3', sessionName: '', workingDir: '/home/u/proj/needle-app', timestamp: 1, history: true },
|
||||
],
|
||||
});
|
||||
const r = searchSources('needle', data).groups[0].results[0];
|
||||
expect(r.sessionName).toBe('needle-app');
|
||||
expect(r.snippet).toContain('/home/u/proj/needle-app');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Unit tests for the past-session search index (issue #261).
|
||||
*
|
||||
* The index is the seam that lets `GET /api/search` match sessions that are no
|
||||
* longer running WITHOUT doing disk I/O per keystroke. Three properties matter
|
||||
* and are pinned here: the snapshot stays bounded, the refresh never happens on
|
||||
* the caller's timeline (fire-and-forget, single-flight, TTL-guarded), and the
|
||||
* stored rows carry the owner needed to re-apply multi-user scoping on read —
|
||||
* the snapshot is written unscoped, so losing that field would leak one user's
|
||||
* folders into another user's search.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
buildHistorySessionIndexItems,
|
||||
ensureHistorySessionIndexFresh,
|
||||
getHistorySessionIndex,
|
||||
isHistorySessionIndexStale,
|
||||
resetHistorySessionIndex,
|
||||
setHistoryIndexRefresher,
|
||||
setHistorySessionIndex,
|
||||
HISTORY_INDEX_MAX_ITEMS,
|
||||
HISTORY_INDEX_TTL_MS,
|
||||
type MergedSessionLike,
|
||||
} from '../src/web/session-history-index.js';
|
||||
|
||||
beforeEach(() => {
|
||||
resetHistorySessionIndex();
|
||||
});
|
||||
|
||||
describe('buildHistorySessionIndexItems', () => {
|
||||
const merged: MergedSessionLike[] = [
|
||||
{ sessionId: 'a', name: 'w1-alpha', workingDir: '/home/u/alpha', lastActivityAt: 300 },
|
||||
{ sessionId: 'b', name: '', workingDir: '/home/u/beta', claudeSessionId: 'uuid-b', createdAt: 200 },
|
||||
{ sessionId: 'c', name: 'gamma', workingDir: '', lastActivityAt: 100 },
|
||||
];
|
||||
|
||||
it('projects name, dir, timestamp, owner and liveness', () => {
|
||||
const items = buildHistorySessionIndexItems(
|
||||
merged,
|
||||
new Map([
|
||||
['a', 'alice'],
|
||||
['b', undefined],
|
||||
]),
|
||||
new Set(['a'])
|
||||
);
|
||||
expect(items.map((i) => i.sessionId)).toEqual(['a', 'b', 'c']);
|
||||
expect(items[0]).toMatchObject({ owner: 'alice', live: true, timestamp: 300 });
|
||||
// Transcript-only row: no owner (host-wide) and not live.
|
||||
expect(items[1]).toMatchObject({ owner: undefined, live: false, timestamp: 200, claudeSessionId: 'uuid-b' });
|
||||
});
|
||||
|
||||
it('drops rows with neither a name nor a working directory', () => {
|
||||
const items = buildHistorySessionIndexItems([{ sessionId: 'empty' }, ...merged], new Map(), new Set());
|
||||
expect(items.some((i) => i.sessionId === 'empty')).toBe(false);
|
||||
});
|
||||
|
||||
it('caps the projection at HISTORY_INDEX_MAX_ITEMS', () => {
|
||||
const many: MergedSessionLike[] = Array.from({ length: HISTORY_INDEX_MAX_ITEMS + 50 }, (_, i) => ({
|
||||
sessionId: `s${i}`,
|
||||
name: `session ${i}`,
|
||||
workingDir: `/home/u/p${i}`,
|
||||
lastActivityAt: i,
|
||||
}));
|
||||
expect(buildHistorySessionIndexItems(many, new Map(), new Set())).toHaveLength(HISTORY_INDEX_MAX_ITEMS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapshot storage', () => {
|
||||
it('starts empty and stale', () => {
|
||||
expect(getHistorySessionIndex().items).toEqual([]);
|
||||
expect(isHistorySessionIndexStale()).toBe(true);
|
||||
});
|
||||
|
||||
it('caps on write even when the caller did not', () => {
|
||||
const items = Array.from({ length: HISTORY_INDEX_MAX_ITEMS + 10 }, (_, i) => ({
|
||||
sessionId: `s${i}`,
|
||||
name: 'x',
|
||||
workingDir: '/x',
|
||||
timestamp: i,
|
||||
live: false,
|
||||
}));
|
||||
setHistorySessionIndex(items);
|
||||
expect(getHistorySessionIndex().items).toHaveLength(HISTORY_INDEX_MAX_ITEMS);
|
||||
});
|
||||
|
||||
it('goes stale again once the TTL elapses', () => {
|
||||
const t0 = 1_000_000;
|
||||
setHistorySessionIndex([{ sessionId: 's', name: 'n', workingDir: '/d', timestamp: 1, live: false }], t0);
|
||||
expect(isHistorySessionIndexStale(t0 + HISTORY_INDEX_TTL_MS - 1)).toBe(false);
|
||||
expect(isHistorySessionIndexStale(t0 + HISTORY_INDEX_TTL_MS + 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureHistorySessionIndexFresh', () => {
|
||||
it('returns synchronously — the rebuild must never be on the request path', async () => {
|
||||
let resolveRefresh: () => void = () => {};
|
||||
const refresher = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
})
|
||||
);
|
||||
setHistoryIndexRefresher(refresher);
|
||||
|
||||
ensureHistorySessionIndexFresh();
|
||||
// Called, but the caller is already past it while the rebuild is pending.
|
||||
expect(refresher).toHaveBeenCalledTimes(1);
|
||||
expect(getHistorySessionIndex().items).toEqual([]);
|
||||
resolveRefresh();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
it('is single-flight: a second call while a rebuild is pending is a no-op', async () => {
|
||||
let resolveRefresh: () => void = () => {};
|
||||
const refresher = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
})
|
||||
);
|
||||
setHistoryIndexRefresher(refresher);
|
||||
|
||||
ensureHistorySessionIndexFresh();
|
||||
ensureHistorySessionIndexFresh();
|
||||
ensureHistorySessionIndexFresh();
|
||||
expect(refresher).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveRefresh();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
// Snapshot still stale (the fake refresher wrote nothing) → next call runs again.
|
||||
ensureHistorySessionIndexFresh();
|
||||
expect(refresher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not rebuild while the snapshot is fresh', () => {
|
||||
const refresher = vi.fn(async () => {});
|
||||
setHistoryIndexRefresher(refresher);
|
||||
setHistorySessionIndex([{ sessionId: 's', name: 'n', workingDir: '/d', timestamp: 1, live: false }]);
|
||||
ensureHistorySessionIndexFresh();
|
||||
expect(refresher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the previous snapshot when a rebuild throws, and retries next time', async () => {
|
||||
setHistorySessionIndex([{ sessionId: 'keep', name: 'n', workingDir: '/d', timestamp: 1, live: false }], 1);
|
||||
const refresher = vi.fn(async () => {
|
||||
throw new Error('scan failed');
|
||||
});
|
||||
setHistoryIndexRefresher(refresher);
|
||||
|
||||
ensureHistorySessionIndexFresh();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(getHistorySessionIndex().items[0].sessionId).toBe('keep');
|
||||
|
||||
ensureHistorySessionIndexFresh();
|
||||
expect(refresher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('is a no-op when no refresher is registered', () => {
|
||||
expect(() => ensureHistorySessionIndexFresh()).not.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user