mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
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:
@@ -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';
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user