From 95df96e06a635f59c45abb1ac219fcf98bb73f7a Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 13 Jun 2026 19:03:02 -0400 Subject: [PATCH 1/2] COD-9 add cross-session search backend (GET /api/search) v1 Bounded federated search over in-memory stores (sessions/cases, run-summary events, file paths). Zod-validated query (q 1-200 chars, types csv, limit 1-60), grouped session->event->file with exact-match-first + recency tiebreak, total cap 60 + per-group cap 25, snippet cap 200, path-safety (relativePath only). Frontend search box (history panel) deferred to next cycle; resume/history-prompt text matching deferred to v1.1 (lives in large on-disk files, out of v1 bounded scope). New: src/search-service.ts (pure core), src/types/search.ts, src/web/routes/search-routes.ts. Tests: test/search-service.test.ts (14), test/routes/search-routes.test.ts (10). --- src/search-service.ts | 207 ++++++++++++++++++++++++++ src/types/index.ts | 1 + src/types/search.ts | 77 ++++++++++ src/web/routes/index.ts | 1 + src/web/routes/search-routes.ts | 162 ++++++++++++++++++++ src/web/schemas.ts | 32 ++++ src/web/server.ts | 2 + test/routes/search-routes.test.ts | 208 ++++++++++++++++++++++++++ test/search-service.test.ts | 235 ++++++++++++++++++++++++++++++ 9 files changed, 925 insertions(+) create mode 100644 src/search-service.ts create mode 100644 src/types/search.ts create mode 100644 src/web/routes/search-routes.ts create mode 100644 test/routes/search-routes.test.ts create mode 100644 test/search-service.test.ts diff --git a/src/search-service.ts b/src/search-service.ts new file mode 100644 index 00000000..620fad9c --- /dev/null +++ b/src/search-service.ts @@ -0,0 +1,207 @@ +/** + * @fileoverview Pure cross-session federated search core (COD-9). + * + * `searchSources()` is the testable heart of `GET /api/search`: it takes a + * normalized query plus already-collected, in-memory source data and returns + * grouped, ranked, and capped results. It performs NO I/O — the route wrapper + * (`src/web/routes/search-routes.ts`) is responsible for harvesting the source + * arrays from the live server stores (sessions, run-summary trackers, attachment + * histories) in a bounded way before calling this. + * + * v1 scope (do not expand here): three sources — sessions/cases, run-summary + * events, file paths. Terminal-buffer scanning and any persisted index are + * explicitly deferred. + * + * Ranking: results are grouped by source type in the fixed order + * sessions → events → files. Within each group, exact (case-insensitive) + * name/path matches come first, then recency (newest timestamp first) as the + * tiebreak. There is no relevance-scoring pass in v1. + * + * Safety: file results only ever expose a workspace-relative path — server- + * private absolute paths are never placed in a result. Per-group and total caps + * bound the output so a broad query cannot return an unbounded payload. + * + * Key exports: + * - searchSources() — the pure core. + * - SEARCH_TOTAL_CAP / SEARCH_PER_GROUP_CAP — the output bounds. + * - SearchSources and the *Input row types — the source-data contract. + */ + +import type { SearchResult, SearchResultGroup, SearchResponseData, SearchSourceType } from './types/search.js'; + +/** Maximum results returned across all groups combined. */ +export const SEARCH_TOTAL_CAP = 60; +/** Maximum results returned within any single source group. */ +export const SEARCH_PER_GROUP_CAP = 25; +/** Maximum characters in a result snippet. */ +export const SEARCH_SNIPPET_MAX = 200; + +/** A live-session row harvested for the session/case source. */ +export interface SessionSearchInput { + sessionId: string; + sessionName: string; + workingDir: string; + /** Recency timestamp (e.g. lastActivityAt or createdAt). */ + timestamp: number; +} + +/** A run-summary timeline event harvested for the event source. */ +export interface EventSearchInput { + sessionId: string; + sessionName: string; + eventId: string; + title: string; + details: string; + timestamp: number; +} + +/** A per-session attachment harvested for the file source. */ +export interface FileSearchInput { + sessionId: string; + sessionName: string; + fileName: string; + /** Workspace-relative path, if known. Absolute/external paths are never passed in. */ + relativePath: string | undefined; + timestamp: number; + /** Attachment history item id, used as the jump-to target. */ + itemId: string; +} + +/** The full set of in-memory source data the pure core searches over. */ +export interface SearchSources { + sessions: SessionSearchInput[]; + events: EventSearchInput[]; + files: FileSearchInput[]; +} + +/** Fixed group/render order. */ +const GROUP_ORDER: SearchSourceType[] = ['session', 'event', 'file']; + +function truncate(text: string, max = SEARCH_SNIPPET_MAX): string { + const trimmed = text.trim().replace(/\s+/g, ' '); + return trimmed.length > max ? trimmed.slice(0, max - 1) + '…' : trimmed; +} + +/** + * Sort a group's results: exact matches first, then newest timestamp first. + * Stable for equal keys. + */ +function sortGroup(rows: SearchResult[]): SearchResult[] { + return rows + .map((result, index) => ({ result, index })) + .sort((a, b) => { + if (a.result.exactMatch !== b.result.exactMatch) { + return a.result.exactMatch ? -1 : 1; + } + if (a.result.timestamp !== b.result.timestamp) { + return b.result.timestamp - a.result.timestamp; + } + return a.index - b.index; + }) + .map((r) => r.result); +} + +/** + * Search the provided in-memory sources for `query`. + * + * @param query Raw query string (already length-validated by the route). Blank + * queries return an empty result set. + * @param sources Harvested, bounded source arrays. + */ +export function searchSources(query: string, sources: SearchSources): SearchResponseData { + const needle = query.trim().toLowerCase(); + if (needle.length === 0) { + return { query: query.trim(), groups: [], totalResults: 0, truncated: false }; + } + + const contains = (s: string | undefined): boolean => !!s && s.toLowerCase().includes(needle); + const isExact = (s: string | undefined): boolean => !!s && s.toLowerCase() === needle; + + // -- Source: sessions/cases -- + const sessionRows: SearchResult[] = []; + for (const s of sources.sessions) { + if (contains(s.sessionName) || contains(s.workingDir) || contains(s.sessionId)) { + sessionRows.push({ + type: 'session', + sessionId: s.sessionId, + sessionName: s.sessionName, + timestamp: s.timestamp, + snippet: truncate(s.workingDir ? `${s.sessionName} — ${s.workingDir}` : s.sessionName), + exactMatch: isExact(s.sessionName), + jumpTo: { kind: 'session', sessionId: s.sessionId }, + }); + } + } + + // -- Source: run-summary events -- + const eventRows: SearchResult[] = []; + for (const e of sources.events) { + if (contains(e.title) || contains(e.details)) { + const snippetBase = e.details && contains(e.details) ? `${e.title}: ${e.details}` : e.title; + eventRows.push({ + type: 'event', + sessionId: e.sessionId, + sessionName: e.sessionName, + timestamp: e.timestamp, + snippet: truncate(snippetBase), + exactMatch: isExact(e.title), + jumpTo: { kind: 'run-summary', sessionId: e.sessionId, targetId: e.eventId }, + }); + } + } + + // -- Source: file paths -- + const fileRows: SearchResult[] = []; + for (const f of sources.files) { + if (contains(f.fileName) || contains(f.relativePath)) { + fileRows.push({ + type: 'file', + sessionId: f.sessionId, + sessionName: f.sessionName, + timestamp: f.timestamp, + snippet: truncate(f.relativePath ?? f.fileName), + // Exact match keys off the safe path (or filename) — never an absolute path. + exactMatch: isExact(f.relativePath) || isExact(f.fileName), + jumpTo: { + kind: 'file-preview', + sessionId: f.sessionId, + targetId: f.itemId, + // Only ever expose a relative path; absolute/external paths are not passed in. + relativePath: f.relativePath, + }, + }); + } + } + + const byType: Record = { + session: sortGroup(sessionRows), + event: sortGroup(eventRows), + file: sortGroup(fileRows), + }; + + const groups: SearchResultGroup[] = []; + let total = 0; + let truncated = false; + + for (const type of GROUP_ORDER) { + const all = byType[type]; + if (all.length === 0) continue; + + // Per-group cap. + let capped = all.slice(0, SEARCH_PER_GROUP_CAP); + if (all.length > capped.length) truncated = true; + + // Total cap (never exceed the global budget). + const remaining = SEARCH_TOTAL_CAP - total; + if (capped.length > remaining) { + capped = capped.slice(0, Math.max(0, remaining)); + truncated = true; + } + if (capped.length === 0) continue; + + groups.push({ type, results: capped }); + total += capped.length; + } + + return { query: query.trim(), groups, totalResults: total, truncated }; +} diff --git a/src/types/index.ts b/src/types/index.ts index 7624f89b..5a5b0911 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -68,3 +68,4 @@ export * from './plan.js'; export * from './orchestrator.js'; export * from './update.js'; export * from './workflow-run.js'; +export * from './search.js'; diff --git a/src/types/search.ts b/src/types/search.ts new file mode 100644 index 00000000..418aa236 --- /dev/null +++ b/src/types/search.ts @@ -0,0 +1,77 @@ +/** + * @fileoverview Cross-session federated search types (COD-9). + * + * Defines the typed shapes for `GET /api/search` — a bounded, in-memory + * federated search across three v1 sources: live sessions/cases, run-summary + * timeline events, and per-session attachment file paths. Terminal-buffer scans + * and any persisted index are explicitly out of scope for v1. + * + * Key exports: + * - SearchSourceType — the federated source kinds, also the group order key. + * - SearchResult — a single typed result card (source, session id/name, + * timestamp, snippet, jump-to action target). + * - SearchJumpTarget — where the frontend should navigate when a card is opened. + * - SearchResponseData — grouped result payload returned in the ApiResponse envelope. + * + * No I/O, no dependencies on other domain modules. The pure search core lives + * in `src/search-service.ts`; the route wrapper in `src/web/routes/search-routes.ts`. + */ + +/** Federated source kinds. Group/render order is sessions → events → files. */ +export type SearchSourceType = 'session' | 'event' | 'file'; + +/** Where the frontend should jump when a result card is activated. */ +export interface SearchJumpTarget { + /** Kind of navigation target. */ + kind: 'session' | 'run-summary' | 'file-preview'; + /** Owning Codeman session id (always present — every result is session-scoped). */ + sessionId: string; + /** + * Secondary identifier for the target: + * - kind 'run-summary': the run-summary event id + * - kind 'file-preview': the attachment history item id + * - kind 'session': undefined (the sessionId is sufficient) + */ + targetId?: string; + /** + * Workspace-relative path for file-preview targets. Never an absolute path — + * server-private external paths are intentionally omitted to avoid leakage. + */ + relativePath?: string; +} + +/** A single typed search result card. */ +export interface SearchResult { + /** Which federated source produced this result. */ + type: SearchSourceType; + /** Owning Codeman session id. */ + sessionId: string; + /** Display name of the owning session / case. */ + sessionName: string; + /** Millisecond timestamp used for recency ranking and display. */ + timestamp: number; + /** Short, already-truncated snippet describing the match. */ + snippet: string; + /** True when the query matched the primary name/path exactly (case-insensitive). */ + exactMatch: boolean; + /** Navigation target for the jump-to action. */ + jumpTo: SearchJumpTarget; +} + +/** A group of results for one source type, in render order. */ +export interface SearchResultGroup { + type: SearchSourceType; + results: SearchResult[]; +} + +/** Payload returned as `data` inside the standard ApiResponse envelope. */ +export interface SearchResponseData { + /** The normalized query that was executed. */ + query: string; + /** Results grouped by source type, ordered sessions → events → files. */ + groups: SearchResultGroup[]; + /** Total number of results across all groups (after caps applied). */ + totalResults: number; + /** True if any group or the total was capped (more matches existed). */ + truncated: boolean; +} diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts index ed7c1a34..9adaec30 100644 --- a/src/web/routes/index.ts +++ b/src/web/routes/index.ts @@ -17,4 +17,5 @@ export { registerRalphRoutes } from './ralph-routes.js'; export { registerPlanRoutes } from './plan-routes.js'; export { registerOrchestratorRoutes } from './orchestrator-routes.js'; export { registerClipboardRoutes } from './clipboard-routes.js'; +export { registerSearchRoutes } from './search-routes.js'; export { registerWsRoutes } from './ws-routes.js'; diff --git a/src/web/routes/search-routes.ts b/src/web/routes/search-routes.ts new file mode 100644 index 00000000..0eaa19f8 --- /dev/null +++ b/src/web/routes/search-routes.ts @@ -0,0 +1,162 @@ +/** + * @fileoverview Cross-session federated search route (COD-9). + * + * Registers `GET /api/search?q=&types=&limit=` — a bounded, in-memory search + * across three v1 sources, returned in the standard ApiResponse envelope: + * 1. sessions/cases — name, working directory, session id + * 2. run-summary events — event title/details (from the live run-summary trackers) + * 3. file paths — per-session attachment history (workspace-relative paths only) + * + * This route is a THIN wrapper: it harvests the source arrays from the live + * server stores (held on the route context) in a bounded way, then delegates + * grouping/ranking/capping to the pure `searchSources()` core in + * `src/search-service.ts`. Terminal-buffer scanning and any persisted index are + * out of scope for v1. + * + * Safety: query input is Zod-validated (length-bounded `q`, allowlisted `types`, + * numeric `limit`); only workspace-relative file paths are ever exposed (the + * server-private `externalPath` on attachment history is never read here); and + * the pure core enforces a per-group and total result cap so a broad query + * cannot return an unbounded payload. No terminal output is read. + * + * Endpoints: GET /api/search + */ + +import { FastifyInstance } from 'fastify'; +import { parseBody } from '../route-helpers.js'; +import { SearchQuerySchema } from '../schemas.js'; +import { + searchSources, + type SearchSources, + type SessionSearchInput, + type EventSearchInput, + type FileSearchInput, +} from '../../search-service.js'; +import type { SearchSourceType } from '../../types/search.js'; +import type { SessionPort, InfraPort } from '../ports/index.js'; + +/** + * Per-source harvest caps. These bound how much in-memory data we hand to the + * pure core BEFORE it applies its own result caps — they keep the harvest itself + * cheap on large deployments (e.g. 50 sessions × many events). They are + * deliberately well above the result caps so ranking still sees enough candidates. + */ +const MAX_EVENTS_PER_SESSION = 500; + +interface SessionLike { + id: string; + name: string; + workingDir: string; + lastActivityAt?: number; + createdAt?: number; + attachmentHistory?: Array<{ + id: string; + fileName: string; + relativePath?: string; + timestamp?: number; + mtimeMs?: number; + }>; +} + +/** + * Harvest the three source arrays from the live in-memory stores. Reads only + * bounded, already-loaded data — no disk I/O, no terminal buffers. + */ +function harvestSources(ctx: SessionPort & InfraPort): SearchSources { + const sessions: SessionSearchInput[] = []; + const events: EventSearchInput[] = []; + const files: FileSearchInput[] = []; + + for (const raw of ctx.sessions.values()) { + const s = raw as unknown as SessionLike; + const sessionName = s.name ?? ''; + const timestamp = s.lastActivityAt ?? s.createdAt ?? 0; + + sessions.push({ + sessionId: s.id, + sessionName, + workingDir: s.workingDir ?? '', + timestamp, + }); + + // Files: per-session attachment history. Only the workspace-relative path is + // surfaced; the server-private externalPath is intentionally never read. + const history = s.attachmentHistory ?? []; + for (const item of history) { + files.push({ + sessionId: s.id, + sessionName, + fileName: item.fileName, + relativePath: item.relativePath, + timestamp: item.timestamp ?? item.mtimeMs ?? timestamp, + itemId: item.id, + }); + } + } + + // Events: from the live run-summary trackers, keyed by session id. + for (const [sessionId, tracker] of ctx.runSummaryTrackers) { + const session = ctx.sessions.get(sessionId) as unknown as SessionLike | undefined; + const sessionName = session?.name ?? ''; + const summary = tracker.getSummary(); + // Newest events are most relevant; cap the per-session harvest. + const evts = summary.events.slice(-MAX_EVENTS_PER_SESSION); + for (const e of evts) { + events.push({ + sessionId, + sessionName, + eventId: e.id, + title: e.title, + details: e.details ?? '', + timestamp: e.timestamp, + }); + } + } + + return { sessions, events, files }; +} + +export function registerSearchRoutes(app: FastifyInstance, ctx: SessionPort & InfraPort): void { + app.get('/api/search', async (req) => { + // Zod-validate the query. parseBody throws a structured 400 on failure. + const { q, types, limit } = parseBody(SearchQuerySchema, req.query); + + const allowed: Set | null = types + ? new Set( + types + .split(',') + .map((t) => t.trim()) + .filter(Boolean) as SearchSourceType[] + ) + : null; + + const sources = harvestSources(ctx); + + // Apply the optional source-type filter before searching so excluded + // sources never contribute to (or consume budget in) the result set. + const filtered: SearchSources = { + sessions: !allowed || allowed.has('session') ? sources.sessions : [], + events: !allowed || allowed.has('event') ? sources.events : [], + files: !allowed || allowed.has('file') ? sources.files : [], + }; + + const result = searchSources(q, filtered); + + // Optional caller-supplied total cap (always on top of the core's hard caps). + if (limit !== undefined && result.totalResults > limit) { + let remaining = limit; + const cappedGroups = []; + for (const group of result.groups) { + if (remaining <= 0) break; + const slice = group.results.slice(0, remaining); + remaining -= slice.length; + cappedGroups.push({ type: group.type, results: slice }); + } + result.groups = cappedGroups; + result.totalResults = limit; + result.truncated = true; + } + + return { success: true, data: result }; + }); +} diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 06457799..a9e22259 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -708,3 +708,35 @@ export const OrchestratorStartSchema = z.object({ export const OrchestratorRejectSchema = z.object({ feedback: z.string().min(1).max(10000), }); + +// ========== Cross-Session Search (COD-9) ========== + +/** Valid federated source kinds for `GET /api/search?types=`. */ +export const SEARCH_SOURCE_TYPES = ['session', 'event', 'file'] as const; + +/** + * GET /api/search query validation. + * + * Query params arrive as strings: `q` is bounded (1..200 chars), `types` is an + * optional comma-separated allowlisted CSV, and `limit` is an optional coerced + * integer clamped to 1..60. Validation is the first line of defense — a missing + * or oversized `q`, an unknown type, or a non-numeric limit is rejected with 400. + */ +export const SearchQuerySchema = z.object({ + q: z.string().trim().min(1, 'Query is required').max(200, 'Query too long (max 200 chars)'), + types: z + .string() + .max(100) + .optional() + .refine( + (v) => + v === undefined || + v + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + .every((t) => (SEARCH_SOURCE_TYPES as readonly string[]).includes(t)), + { message: 'Invalid types value' } + ), + limit: z.coerce.number().int().min(1).max(60).optional(), +}); diff --git a/src/web/server.ts b/src/web/server.ts index a156ec11..d91fed02 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -149,6 +149,7 @@ import { registerRalphRoutes, registerPlanRoutes, registerClipboardRoutes, + registerSearchRoutes, registerOrchestratorRoutes, registerWsRoutes, } from './routes/index.js'; @@ -869,6 +870,7 @@ export class WebServer extends EventEmitter { registerRalphRoutes(this.app, ctx); registerPlanRoutes(this.app, ctx); registerClipboardRoutes(this.app, ctx); + registerSearchRoutes(this.app, ctx); registerOrchestratorRoutes(this.app, ctx); registerWsRoutes(this.app, ctx, () => this.getHostPolicy()); } diff --git a/test/routes/search-routes.test.ts b/test/routes/search-routes.test.ts new file mode 100644 index 00000000..99b59c42 --- /dev/null +++ b/test/routes/search-routes.test.ts @@ -0,0 +1,208 @@ +/** + * @fileoverview Tests for the cross-session search route (COD-9). + * + * Uses app.inject() — no real HTTP ports needed. + * Port: N/A (app.inject doesn't open ports) + * + * Covers query validation (400s), result shaping (grouped cards), caps, + * exact-before-recency ranking, and that at least two distinct sources + * (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 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'; + +type Ctx = ReturnType; + +async function harness(configure?: (ctx: Ctx) => void): Promise<{ app: FastifyInstance; ctx: Ctx }> { + const app = Fastify({ logger: false }); + // Start from an empty session map so tests fully control the source data. + const ctx = createMockRouteContext(); + ctx.sessions.clear(); + ctx.runSummaryTrackers.clear(); + configure?.(ctx); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + registerSearchRoutes(app, ctx as any); + installRouteErrorHandler(app); + await app.ready(); + return { app, ctx }; +} + +/** Minimal session-like object compatible with the route's reads. */ +function fakeSession(opts: { + id: string; + name: string; + workingDir: string; + lastActivityAt?: number; + attachmentHistory?: unknown[]; +}) { + return { + id: opts.id, + name: opts.name, + workingDir: opts.workingDir, + lastActivityAt: opts.lastActivityAt ?? 0, + createdAt: 0, + attachmentHistory: opts.attachmentHistory ?? [], + }; +} + +describe('GET /api/search — validation', () => { + it('400 on missing q', async () => { + const { app } = await harness(); + const res = await app.inject({ method: 'GET', url: '/api/search' }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).success).toBe(false); + }); + + it('400 on empty q', async () => { + const { app } = await harness(); + const res = await app.inject({ method: 'GET', url: '/api/search?q=' }); + expect(res.statusCode).toBe(400); + }); + + it('400 on oversized q (>200 chars)', async () => { + const { app } = await harness(); + const q = 'x'.repeat(201); + const res = await app.inject({ method: 'GET', url: `/api/search?q=${q}` }); + expect(res.statusCode).toBe(400); + }); + + it('400 on bad types value', async () => { + const { app } = await harness(); + const res = await app.inject({ method: 'GET', url: '/api/search?q=foo&types=session,bogus' }); + expect(res.statusCode).toBe(400); + }); + + it('400 on non-numeric limit', async () => { + const { app } = await harness(); + const res = await app.inject({ method: 'GET', url: '/api/search?q=foo&limit=abc' }); + expect(res.statusCode).toBe(400); + }); +}); + +describe('GET /api/search — shaping & multi-source', () => { + beforeEach(() => {}); + + it('returns grouped results from sessions and events (two distinct sources)', async () => { + const { app } = await harness((ctx) => { + ctx.sessions.set( + 's1', + fakeSession({ id: 's1', name: 'needle session', workingDir: '/home/u/proj', lastActivityAt: 100 }) as never + ); + const tracker = new RunSummaryTracker('s2', 'Other session'); + tracker.addEvent('warning', 'info', 'found a needle', 'in the logs'); + ctx.runSummaryTrackers.set('s2', tracker); + ctx.sessions.set( + 's2', + fakeSession({ id: 's2', name: 'Other session', workingDir: '/x', lastActivityAt: 50 }) as never + ); + }); + const res = await app.inject({ method: 'GET', url: '/api/search?q=needle' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + const types = body.data.groups.map((g: { type: string }) => g.type); + expect(types).toContain('session'); + expect(types).toContain('event'); + // Group order: sessions before events. + expect(types.indexOf('session')).toBeLessThan(types.indexOf('event')); + expect(body.data.totalResults).toBe(2); + + const sessionResult = body.data.groups.find((g: { type: string }) => g.type === 'session').results[0]; + expect(sessionResult.sessionId).toBe('s1'); + expect(sessionResult.sessionName).toBe('needle session'); + expect(typeof sessionResult.timestamp).toBe('number'); + expect(typeof sessionResult.snippet).toBe('string'); + expect(sessionResult.jumpTo).toEqual({ kind: 'session', sessionId: 's1' }); + }); + + it('exposes file results via relativePath and never leaks an absolute path', async () => { + const { app } = await harness((ctx) => { + ctx.sessions.set( + 's1', + fakeSession({ + id: 's1', + name: 'Alpha', + workingDir: '/home/u/proj', + lastActivityAt: 1, + attachmentHistory: [ + { + id: 'item-1', + sessionId: 's1', + fileName: 'needle.txt', + extension: 'txt', + attachmentType: 'text', + size: 10, + mtimeMs: 0, + timestamp: 5, + source: 'detected', + relativePath: 'docs/needle.txt', + externalPath: '/home/u/secret/needle.txt', + }, + ], + }) as never + ); + }); + const res = await app.inject({ method: 'GET', url: '/api/search?q=needle' }); + const body = JSON.parse(res.body); + const fileGroup = body.data.groups.find((g: { type: string }) => g.type === 'file'); + expect(fileGroup).toBeTruthy(); + expect(fileGroup.results[0].jumpTo.relativePath).toBe('docs/needle.txt'); + // The server-private absolute/external path must never appear in the payload. + expect(res.body).not.toContain('/home/u/secret'); + }); + + it('ranks exact session-name matches before more-recent partial matches', async () => { + const { app } = await harness((ctx) => { + ctx.sessions.set( + 'exact-old', + fakeSession({ id: 'exact-old', name: 'needle', workingDir: '/x', lastActivityAt: 1 }) as never + ); + ctx.sessions.set( + 'partial-new', + fakeSession({ id: 'partial-new', name: 'needle-haystack', workingDir: '/x', lastActivityAt: 9999 }) as never + ); + }); + const res = await app.inject({ method: 'GET', url: '/api/search?q=needle' }); + const body = JSON.parse(res.body); + const ids = body.data.groups[0].results.map((r: { sessionId: string }) => r.sessionId); + expect(ids).toEqual(['exact-old', 'partial-new']); + }); +}); + +describe('GET /api/search — caps & filters', () => { + it('respects the limit query param as a total cap', async () => { + const { app } = await harness((ctx) => { + for (let i = 0; i < 20; i++) { + ctx.sessions.set( + `s${i}`, + fakeSession({ id: `s${i}`, name: `needle ${i}`, workingDir: '/x', lastActivityAt: i }) as never + ); + } + }); + const res = await app.inject({ method: 'GET', url: '/api/search?q=needle&limit=5' }); + const body = JSON.parse(res.body); + expect(body.data.totalResults).toBe(5); + expect(body.data.truncated).toBe(true); + }); + + it('filters by types when provided', async () => { + const { app } = await harness((ctx) => { + ctx.sessions.set( + 's1', + fakeSession({ id: 's1', name: 'needle session', workingDir: '/x', lastActivityAt: 1 }) as never + ); + const tracker = new RunSummaryTracker('s1', 'needle session'); + tracker.addEvent('warning', 'info', 'needle event', ''); + ctx.runSummaryTrackers.set('s1', tracker); + }); + const res = await app.inject({ method: 'GET', url: '/api/search?q=needle&types=event' }); + const body = JSON.parse(res.body); + const types = body.data.groups.map((g: { type: string }) => g.type); + expect(types).toEqual(['event']); + }); +}); diff --git a/test/search-service.test.ts b/test/search-service.test.ts new file mode 100644 index 00000000..2f71c061 --- /dev/null +++ b/test/search-service.test.ts @@ -0,0 +1,235 @@ +/** + * Unit tests for the pure cross-session search core (COD-9). + * + * The core (`searchSources`) takes already-collected, in-memory source data + * plus a normalized query and returns grouped/ranked/capped results. No I/O, + * no live server — these tests exercise grouping order, exact-before-recency + * ranking, caps (total + per-group), snippet shaping, and path safety. + */ +import { describe, it, expect } from 'vitest'; +import { searchSources, SEARCH_TOTAL_CAP, SEARCH_PER_GROUP_CAP, type SearchSources } from '../src/search-service.js'; + +function sources(overrides: Partial = {}): SearchSources { + return { + sessions: [], + events: [], + files: [], + ...overrides, + }; +} + +describe('searchSources — grouping & order', () => { + it('orders groups sessions → events → files', () => { + const data = sources({ + files: [ + { + sessionId: 's1', + sessionName: 'Alpha', + fileName: 'query.txt', + relativePath: 'docs/query.txt', + timestamp: 100, + itemId: 'f1', + }, + ], + events: [ + { sessionId: 's1', sessionName: 'Alpha', eventId: 'e1', title: 'query started', details: '', timestamp: 100 }, + ], + sessions: [{ sessionId: 's1', sessionName: 'query session', workingDir: '/home/u/proj', timestamp: 100 }], + }); + const res = searchSources('query', data); + expect(res.groups.map((g) => g.type)).toEqual(['session', 'event', 'file']); + }); + + it('omits empty groups', () => { + const data = sources({ + sessions: [{ sessionId: 's1', sessionName: 'query session', workingDir: '/home/u/proj', timestamp: 100 }], + }); + const res = searchSources('query', data); + expect(res.groups.map((g) => g.type)).toEqual(['session']); + }); +}); + +describe('searchSources — matching across distinct sources', () => { + it('returns results from at least two distinct sources', () => { + const data = sources({ + sessions: [{ sessionId: 's1', sessionName: 'needle project', workingDir: '/home/u/proj', timestamp: 100 }], + events: [ + { sessionId: 's2', sessionName: 'Other', eventId: 'e1', title: 'found a needle', details: '', timestamp: 100 }, + ], + }); + const res = searchSources('needle', data); + const types = res.groups.map((g) => g.type); + expect(types).toContain('session'); + expect(types).toContain('event'); + expect(res.totalResults).toBe(2); + }); + + it('matches session working directory', () => { + const data = sources({ + sessions: [{ sessionId: 's1', sessionName: 'Unrelated', workingDir: '/home/u/needle-dir', timestamp: 100 }], + }); + const res = searchSources('needle', data); + expect(res.totalResults).toBe(1); + expect(res.groups[0].results[0].sessionId).toBe('s1'); + }); + + it('matches event details, not just title', () => { + const data = sources({ + events: [ + { + sessionId: 's1', + sessionName: 'A', + eventId: 'e1', + title: 'nothing here', + details: 'a needle in details', + timestamp: 100, + }, + ], + }); + const res = searchSources('needle', data); + expect(res.totalResults).toBe(1); + }); + + it('is case-insensitive', () => { + const data = sources({ + sessions: [{ sessionId: 's1', sessionName: 'NEEDLE', workingDir: '/x', timestamp: 100 }], + }); + expect(searchSources('needle', data).totalResults).toBe(1); + }); +}); + +describe('searchSources — ranking (exact before recency)', () => { + it('places exact name matches before more-recent partial matches', () => { + const data = sources({ + sessions: [ + { sessionId: 'old-exact', sessionName: 'needle', workingDir: '/x', timestamp: 1 }, + { sessionId: 'new-partial', sessionName: 'needle-haystack', workingDir: '/x', timestamp: 9999 }, + ], + }); + const res = searchSources('needle', data); + const ids = res.groups[0].results.map((r) => r.sessionId); + expect(ids).toEqual(['old-exact', 'new-partial']); + expect(res.groups[0].results[0].exactMatch).toBe(true); + }); + + it('within the same exactness tier, sorts newest first', () => { + const data = sources({ + sessions: [ + { sessionId: 'older', sessionName: 'needle-a', workingDir: '/x', timestamp: 10 }, + { sessionId: 'newer', sessionName: 'needle-b', workingDir: '/x', timestamp: 20 }, + ], + }); + const res = searchSources('needle', data); + expect(res.groups[0].results.map((r) => r.sessionId)).toEqual(['newer', 'older']); + }); +}); + +describe('searchSources — caps', () => { + it('enforces the per-group cap and flags truncated', () => { + const sessions = Array.from({ length: SEARCH_PER_GROUP_CAP + 5 }, (_, i) => ({ + sessionId: `s${i}`, + sessionName: `needle ${i}`, + workingDir: '/x', + timestamp: i, + })); + const res = searchSources('needle', sources({ sessions })); + expect(res.groups[0].results.length).toBe(SEARCH_PER_GROUP_CAP); + expect(res.truncated).toBe(true); + }); + + it('enforces the total cap across groups', () => { + // Fill every group to its per-group cap; total must not exceed SEARCH_TOTAL_CAP. + const mk = (n: number, f: (i: number) => T) => Array.from({ length: n }, (_, i) => f(i)); + const data = sources({ + sessions: mk(SEARCH_PER_GROUP_CAP, (i) => ({ + sessionId: `s${i}`, + sessionName: `needle ${i}`, + workingDir: '/x', + timestamp: i, + })), + events: mk(SEARCH_PER_GROUP_CAP, (i) => ({ + sessionId: `e${i}`, + sessionName: 'E', + eventId: `e${i}`, + title: `needle ${i}`, + details: '', + timestamp: i, + })), + files: mk(SEARCH_PER_GROUP_CAP, (i) => ({ + sessionId: `f${i}`, + sessionName: 'F', + fileName: `needle${i}.txt`, + relativePath: `d/needle${i}.txt`, + timestamp: i, + itemId: `f${i}`, + })), + }); + const res = searchSources('needle', data); + expect(res.totalResults).toBeLessThanOrEqual(SEARCH_TOTAL_CAP); + }); +}); + +describe('searchSources — result card shape & path safety', () => { + it('shapes a file result with a relative-path jump target and no absolute leakage', () => { + const data = sources({ + files: [ + { + sessionId: 's1', + sessionName: 'Alpha', + fileName: 'needle.txt', + relativePath: 'docs/needle.txt', + timestamp: 123, + itemId: 'item-1', + }, + ], + }); + const r = searchSources('needle', data).groups[0].results[0]; + expect(r.type).toBe('file'); + expect(r.sessionId).toBe('s1'); + expect(r.sessionName).toBe('Alpha'); + expect(r.timestamp).toBe(123); + expect(r.jumpTo).toEqual({ + kind: 'file-preview', + sessionId: 's1', + targetId: 'item-1', + relativePath: 'docs/needle.txt', + }); + // No absolute path anywhere in the serialized result. + expect(JSON.stringify(r)).not.toContain('/home/'); + }); + + it('drops files that only have a server-private absolute path (no relativePath)', () => { + const data = sources({ + files: [ + { + sessionId: 's1', + sessionName: 'Alpha', + fileName: 'needle.txt', + relativePath: undefined, + timestamp: 1, + itemId: 'i1', + }, + ], + }); + // fileName still matches, but there is no safe relativePath to expose → still + // returned, but jumpTo must not carry an absolute path. + const res = searchSources('needle', data); + if (res.totalResults > 0) { + expect(res.groups[0].results[0].jumpTo.relativePath).toBeUndefined(); + } + }); + + it('truncates long snippets', () => { + const longDetail = 'needle ' + 'x'.repeat(500); + const data = sources({ + events: [{ sessionId: 's1', sessionName: 'A', eventId: 'e1', title: 'evt', details: longDetail, timestamp: 1 }], + }); + const r = searchSources('needle', data).groups[0].results[0]; + expect(r.snippet.length).toBeLessThanOrEqual(200); + }); + + it('returns empty for a blank query', () => { + const data = sources({ sessions: [{ sessionId: 's1', sessionName: 'needle', workingDir: '/x', timestamp: 1 }] }); + expect(searchSources('', data).totalResults).toBe(0); + }); +}); From 9afaccc85dc0ab1c6c462c757267da3908cb0072 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 13 Jun 2026 19:20:06 -0400 Subject: [PATCH 2/2] COD-9 add cross-session search frontend (history-panel search box) v1 Search box + grouped result cards + filters folded into the welcome/history panel, wired to GET /api/search. Debounced query (250ms), type-filter chips (session/event/file), client-side case/status/date filters, grouped cards (badge, name, timestamp, snippet) with jump-to (session->selectSession, run-summary->openRunSummary, file-preview->openFilePreview), empty-state + truncated notice. All result text via textContent (no XSS surface). Files: index.html (panel markup), terminal-ui.js (search mixin + initSearchPanel), styles.css (.search-* styles). --- src/web/public/index.html | 41 ++++- src/web/public/styles.css | 258 ++++++++++++++++++++++++++ src/web/public/terminal-ui.js | 336 ++++++++++++++++++++++++++++++++++ 3 files changed, 634 insertions(+), 1 deletion(-) diff --git a/src/web/public/index.html b/src/web/public/index.html index 4c4c7734..9a2a87b2 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -311,7 +311,46 @@

Codeman

Or press Ctrl+Enter to start

diff --git a/src/web/public/styles.css b/src/web/public/styles.css index eeba9e9b..3506c3fe 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2466,6 +2466,264 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { max-width: 560px; } +/* ── COD-9 cross-session search (folded into the welcome history panel) ── */ +.search-panel { + width: 100%; + margin-bottom: 0.9rem; + text-align: left; +} + +.search-input-row { + position: relative; + display: flex; + align-items: center; +} + +.search-input { + flex: 1; + width: 100%; + box-sizing: border-box; + padding: 0.55rem 2rem 0.55rem 0.8rem; + font-size: 0.85rem; + color: var(--text); + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + outline: none; + transition: border-color var(--transition-smooth), background var(--transition-smooth); +} + +.search-input:focus { + border-color: rgba(59, 130, 246, 0.5); + background: rgba(255, 255, 255, 0.06); +} + +.search-input::placeholder { + color: var(--text-dim); +} + +.search-clear-btn { + position: absolute; + right: 0.4rem; + top: 50%; + transform: translateY(-50%); + appearance: none; + border: none; + background: transparent; + color: var(--text-dim); + font-size: 1.2rem; + line-height: 1; + padding: 0.1rem 0.35rem; + cursor: pointer; + border-radius: 6px; +} + +.search-clear-btn:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.08); +} + +.search-filters { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; + align-items: center; +} + +.search-filter-group { + display: flex; + gap: 0.3rem; + flex-wrap: wrap; +} + +.search-filter-secondary { + margin-left: auto; +} + +.search-filter-chip { + appearance: none; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.03); + color: var(--text-muted); + font-size: 0.68rem; + padding: 0.28rem 0.6rem; + border-radius: 999px; + cursor: pointer; + transition: all var(--transition-smooth); +} + +.search-filter-chip:hover { + border-color: rgba(59, 130, 246, 0.3); + color: var(--text); +} + +.search-filter-chip.active { + background: rgba(59, 130, 246, 0.18); + border-color: rgba(59, 130, 246, 0.5); + color: #cdddff; +} + +.search-select { + appearance: none; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.1); + color: var(--text-muted); + font-size: 0.68rem; + padding: 0.28rem 0.5rem; + border-radius: 6px; + cursor: pointer; + max-width: 9rem; +} + +.search-select:focus { + outline: none; + border-color: rgba(59, 130, 246, 0.5); +} + +.search-results { + display: flex; + flex-direction: column; + gap: 0.3rem; + margin-top: 0.7rem; + max-height: 320px; + overflow-y: auto; +} + +.search-results[hidden] { + display: none; +} + +.search-group-header { + display: flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.4rem; + padding: 0 0.2rem; +} + +.search-group-header:first-child { + margin-top: 0; +} + +.search-group-label { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dim); + font-weight: 600; +} + +.search-group-count { + font-size: 0.6rem; + color: var(--text-muted); + background: rgba(255, 255, 255, 0.06); + border-radius: 999px; + padding: 0.05rem 0.4rem; +} + +.search-result-card { + display: flex; + flex-direction: column; + gap: 0.25rem; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 8px; + padding: 0.5rem 0.7rem; + cursor: pointer; + transition: all var(--transition-smooth); +} + +.search-result-card:hover, +.search-result-card:focus-visible { + border-color: rgba(59, 130, 246, 0.35); + background: rgba(255, 255, 255, 0.06); + outline: none; +} + +.search-result-top { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; +} + +.search-result-badge { + font-size: 0.58rem; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.1rem 0.4rem; + border-radius: 4px; + flex-shrink: 0; + font-weight: 600; +} + +.search-badge-session { + background: rgba(59, 130, 246, 0.18); + color: #9dc0ff; +} + +.search-badge-event { + background: rgba(168, 85, 247, 0.18); + color: #d4b3ff; +} + +.search-badge-file { + background: rgba(34, 197, 94, 0.16); + color: #95e6b3; +} + +.search-result-name { + flex: 1; + min-width: 0; + font-size: 0.74rem; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.search-result-time { + font-size: 0.65rem; + color: var(--text-dim); + white-space: nowrap; + flex-shrink: 0; +} + +.search-result-snippet { + font-size: 0.7rem; + color: var(--text-muted); + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-word; +} + +.search-empty, +.search-truncated { + font-size: 0.72rem; + color: var(--text-dim); + padding: 0.6rem 0.2rem; + text-align: left; +} + +.search-truncated { + border-top: 1px dashed rgba(255, 255, 255, 0.08); + margin-top: 0.3rem; + color: var(--text-muted); +} + +@media (max-width: 640px) { + .search-filter-secondary { + margin-left: 0; + } + .search-select { + max-width: 7rem; + } +} + .history-title { font-size: 0.85rem; color: var(--text-dim); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index c131c4c4..d44e5dbf 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -983,6 +983,7 @@ Object.assign(CodemanApp.prototype, { overlay.classList.add('visible'); this.loadTunnelStatus(); this.loadHistorySessions(); + this.initSearchPanel(); } // Home screen has no input target — hide the CJK textarea (activeSessionId // is null by the time we get here). Guarded: defined on the app object. @@ -2253,3 +2254,338 @@ Object.assign(CodemanApp.prototype, { } }, }); + +// ═══════════════════════════════════════════════════════════════ +// COD-9 — Cross-session search (folded into the welcome history panel) +// Consumes GET /api/search; renders grouped result cards with jump-to actions. +// ═══════════════════════════════════════════════════════════════ + +(function (global) { + const SEARCH_DEBOUNCE_MS = 250; + const SEARCH_LIMIT = 60; + const SOURCE_LABELS = { session: 'Sessions', event: 'Events', file: 'Files' }; + + /** Human-friendly relative-ish timestamp matching the history panel's style. */ + function formatSearchTime(ts) { + if (!Number.isFinite(ts)) return ''; + const d = new Date(ts); + return ( + d.toLocaleDateString('en', { month: 'short', day: 'numeric' }) + + ' ' + + d.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false }) + ); + } + + global.CodemanSearch = { SEARCH_DEBOUNCE_MS, SEARCH_LIMIT, SOURCE_LABELS, formatSearchTime }; +})(window); + +Object.assign(CodemanApp.prototype, { + /** + * Wire up the search box, filter chips, and selects inside the welcome + * history panel. Idempotent — safe to call every time the overlay opens. + */ + initSearchPanel() { + const input = document.getElementById('searchInput'); + if (!input || this._searchPanelWired) { + // Even when already wired, refresh the case dropdown (cases may have loaded since). + if (this._searchPanelWired) this._populateSearchCaseFilter(); + return; + } + this._searchPanelWired = true; + + // Active source-type filter set (mirrors the chip .active state → types= param). + this._searchTypes = new Set(['session', 'event', 'file']); + this._searchSecondary = { caseLabel: '', status: '', days: '' }; + this._searchDebounceTimer = null; + this._searchSeq = 0; + this._searchLastData = null; + + const clearBtn = document.getElementById('searchClearBtn'); + const results = document.getElementById('searchResults'); + + input.addEventListener('input', () => { + if (clearBtn) clearBtn.hidden = input.value.length === 0; + this._scheduleSearch(); + }); + input.addEventListener('keydown', (ev) => { + if (ev.key === 'Escape' && input.value) { + ev.stopPropagation(); + this._clearSearch(); + } + }); + + if (clearBtn) { + clearBtn.addEventListener('click', () => this._clearSearch()); + } + + document.querySelectorAll('#searchFilters .search-filter-chip').forEach((chip) => { + chip.addEventListener('click', () => { + const t = chip.dataset.typeFilter; + // Keep at least one type selected. + if (this._searchTypes.has(t) && this._searchTypes.size === 1) return; + if (this._searchTypes.has(t)) { + this._searchTypes.delete(t); + chip.classList.remove('active'); + } else { + this._searchTypes.add(t); + chip.classList.add('active'); + } + this._runSearch(); + }); + }); + + const caseSel = document.getElementById('searchCaseFilter'); + const statusSel = document.getElementById('searchStatusFilter'); + const dateSel = document.getElementById('searchDateFilter'); + if (caseSel) { + caseSel.addEventListener('change', () => { + this._searchSecondary.caseLabel = caseSel.value; + this._renderSearch(this._searchLastData); + }); + } + if (statusSel) { + statusSel.addEventListener('change', () => { + this._searchSecondary.status = statusSel.value; + this._renderSearch(this._searchLastData); + }); + } + if (dateSel) { + dateSel.addEventListener('change', () => { + this._searchSecondary.days = dateSel.value; + this._renderSearch(this._searchLastData); + }); + } + + this._populateSearchCaseFilter(); + if (results) results.hidden = true; + }, + + /** Fill the case