mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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>
162 lines
5.9 KiB
TypeScript
162 lines
5.9 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|