Merge PR #133: cross-session search (federated GET /api/search + history-panel search box) (COD-113)

This commit is contained in:
Codeman maintainer
2026-06-25 00:10:38 +02:00
12 changed files with 1559 additions and 1 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 };
});
}