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).
This commit is contained in:
Aamer Akhter
2026-06-19 12:35:16 -04:00
parent 1255e28f6f
commit 95df96e06a
9 changed files with 925 additions and 0 deletions
+1
View File
@@ -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';
+162
View File
@@ -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<SearchSourceType> | 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 };
});
}
+32
View File
@@ -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(),
});
+2
View File
@@ -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());
}