mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge PR #133: cross-session search (federated GET /api/search + history-panel search box) (COD-113)
This commit is contained in:
@@ -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<SearchSourceType, SearchResult[]> = {
|
||||
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 };
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -312,7 +312,46 @@
|
||||
<div class="welcome-qr-url" id="welcomeQrUrl"></div>
|
||||
</div>
|
||||
<div class="history-sessions" id="historySessions" style="display:none">
|
||||
<h3 class="history-title">Resume Conversation</h3>
|
||||
<div class="search-panel" id="searchPanel">
|
||||
<div class="search-input-row">
|
||||
<input
|
||||
type="search"
|
||||
id="searchInput"
|
||||
class="search-input"
|
||||
placeholder="Search sessions, events, files…"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
maxlength="200"
|
||||
aria-label="Search across sessions"
|
||||
/>
|
||||
<button type="button" id="searchClearBtn" class="search-clear-btn" aria-label="Clear search" hidden>×</button>
|
||||
</div>
|
||||
<div class="search-filters" id="searchFilters">
|
||||
<div class="search-filter-group" role="group" aria-label="Source type filter">
|
||||
<button type="button" class="search-filter-chip active" data-type-filter="session">Sessions</button>
|
||||
<button type="button" class="search-filter-chip active" data-type-filter="event">Events</button>
|
||||
<button type="button" class="search-filter-chip active" data-type-filter="file">Files</button>
|
||||
</div>
|
||||
<div class="search-filter-group search-filter-secondary">
|
||||
<select id="searchCaseFilter" class="search-select" aria-label="Filter by case">
|
||||
<option value="">All cases</option>
|
||||
</select>
|
||||
<select id="searchStatusFilter" class="search-select" aria-label="Filter by session status">
|
||||
<option value="">Any status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="history">History</option>
|
||||
</select>
|
||||
<select id="searchDateFilter" class="search-select" aria-label="Filter by date range">
|
||||
<option value="">Any time</option>
|
||||
<option value="1">Past 24h</option>
|
||||
<option value="7">Past 7 days</option>
|
||||
<option value="30">Past 30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-results" id="searchResults" hidden></div>
|
||||
</div>
|
||||
<h3 class="history-title" id="historyTitle">Resume Conversation</h3>
|
||||
<div class="history-list" id="historyList"></div>
|
||||
</div>
|
||||
<p class="welcome-hint">Or press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> to start</p>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <select> from loaded cases (#caseName values). */
|
||||
_populateSearchCaseFilter() {
|
||||
const sel = document.getElementById('searchCaseFilter');
|
||||
if (!sel) return;
|
||||
const cases = Array.isArray(this.cases) ? this.cases : [];
|
||||
const names = Array.from(new Set(cases.map((c) => c && c.name).filter(Boolean))).sort();
|
||||
const current = sel.value;
|
||||
// Rebuild options (keep the "All cases" placeholder).
|
||||
sel.innerHTML = '';
|
||||
const all = document.createElement('option');
|
||||
all.value = '';
|
||||
all.textContent = 'All cases';
|
||||
sel.appendChild(all);
|
||||
for (const name of names) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = name;
|
||||
opt.textContent = '#' + name;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
if (current && names.includes(current)) sel.value = current;
|
||||
},
|
||||
|
||||
/** Debounced trigger from the input event. */
|
||||
_scheduleSearch() {
|
||||
clearTimeout(this._searchDebounceTimer);
|
||||
this._searchDebounceTimer = setTimeout(() => this._runSearch(), window.CodemanSearch.SEARCH_DEBOUNCE_MS);
|
||||
},
|
||||
|
||||
_clearSearch() {
|
||||
const input = document.getElementById('searchInput');
|
||||
const clearBtn = document.getElementById('searchClearBtn');
|
||||
if (input) input.value = '';
|
||||
if (clearBtn) clearBtn.hidden = true;
|
||||
this._searchLastData = null;
|
||||
this._renderSearch(null);
|
||||
},
|
||||
|
||||
/** Execute the federated search request and render the result. */
|
||||
async _runSearch() {
|
||||
const input = document.getElementById('searchInput');
|
||||
if (!input) return;
|
||||
const q = input.value.trim();
|
||||
if (q.length === 0) {
|
||||
this._searchLastData = null;
|
||||
this._renderSearch(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const types = Array.from(this._searchTypes);
|
||||
const params = new URLSearchParams();
|
||||
params.set('q', q.slice(0, 200));
|
||||
if (types.length > 0 && types.length < 3) params.set('types', types.join(','));
|
||||
params.set('limit', String(window.CodemanSearch.SEARCH_LIMIT));
|
||||
|
||||
const seq = ++this._searchSeq;
|
||||
const data = await this._apiJson('/api/search?' + params.toString());
|
||||
// Drop stale responses (a newer query already fired).
|
||||
if (seq !== this._searchSeq) return;
|
||||
|
||||
if (!data) {
|
||||
// null = request error or 400 (bad input). Show an empty/error state.
|
||||
this._searchLastData = { query: q, groups: [], totalResults: 0, truncated: false, _error: true };
|
||||
} else {
|
||||
this._searchLastData = data;
|
||||
}
|
||||
this._renderSearch(this._searchLastData);
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply client-side secondary filters (case / status / date) to a group's
|
||||
* results. Type filtering already happened server-side via types=.
|
||||
*/
|
||||
_applySecondaryFilters(results) {
|
||||
const { caseLabel, status, days } = this._searchSecondary;
|
||||
let out = results;
|
||||
if (caseLabel) {
|
||||
const want = '#' + caseLabel;
|
||||
out = out.filter((r) => (r.sessionName || '').includes(want) || r.sessionName === caseLabel);
|
||||
}
|
||||
if (status) {
|
||||
const activeIds = new Set((this.sessionOrder || []).concat(Object.keys(this.sessions || {})));
|
||||
out = out.filter((r) => {
|
||||
const isActive = activeIds.has(r.sessionId);
|
||||
return status === 'active' ? isActive : !isActive;
|
||||
});
|
||||
}
|
||||
if (days) {
|
||||
const cutoff = Date.now() - Number(days) * 24 * 60 * 60 * 1000;
|
||||
out = out.filter((r) => Number.isFinite(r.timestamp) && r.timestamp >= cutoff);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
/** Render the grouped result cards (or empty/loading states). */
|
||||
_renderSearch(data) {
|
||||
const results = document.getElementById('searchResults');
|
||||
const historyTitle = document.getElementById('historyTitle');
|
||||
const historyList = document.getElementById('historyList');
|
||||
if (!results) return;
|
||||
|
||||
const searching = !!data;
|
||||
// Hide the plain "Resume Conversation" history list while a search is active.
|
||||
if (historyTitle) historyTitle.style.display = searching ? 'none' : '';
|
||||
if (historyList) historyList.style.display = searching ? 'none' : '';
|
||||
|
||||
results.innerHTML = '';
|
||||
if (!data) {
|
||||
results.hidden = true;
|
||||
return;
|
||||
}
|
||||
results.hidden = false;
|
||||
|
||||
if (data._error) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'search-empty';
|
||||
empty.textContent = 'Search unavailable — check the query and try again.';
|
||||
results.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply secondary (client-side) filters and recompute shown total.
|
||||
const groups = (data.groups || [])
|
||||
.map((g) => ({ type: g.type, results: this._applySecondaryFilters(g.results || []) }))
|
||||
.filter((g) => g.results.length > 0);
|
||||
|
||||
const shownTotal = groups.reduce((n, g) => n + g.results.length, 0);
|
||||
|
||||
if (shownTotal === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'search-empty';
|
||||
empty.textContent = 'No results for "' + (data.query || '') + '"';
|
||||
results.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'search-group-header';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'search-group-label';
|
||||
label.textContent = window.CodemanSearch.SOURCE_LABELS[group.type] || group.type;
|
||||
const count = document.createElement('span');
|
||||
count.className = 'search-group-count';
|
||||
count.textContent = String(group.results.length);
|
||||
header.append(label, count);
|
||||
results.appendChild(header);
|
||||
|
||||
for (const r of group.results) {
|
||||
results.appendChild(this._buildSearchResultCard(r));
|
||||
}
|
||||
}
|
||||
|
||||
if (data.truncated) {
|
||||
const trunc = document.createElement('div');
|
||||
trunc.className = 'search-truncated';
|
||||
trunc.textContent = 'Showing the top matches — refine your search to narrow results.';
|
||||
results.appendChild(trunc);
|
||||
}
|
||||
},
|
||||
|
||||
/** Build a single result card DOM node wired to its jump-to action. */
|
||||
_buildSearchResultCard(r) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'search-result-card';
|
||||
card.dataset.type = r.type;
|
||||
card.tabIndex = 0;
|
||||
card.setAttribute('role', 'button');
|
||||
|
||||
const topRow = document.createElement('div');
|
||||
topRow.className = 'search-result-top';
|
||||
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'search-result-badge search-badge-' + r.type;
|
||||
badge.textContent = (window.CodemanSearch.SOURCE_LABELS[r.type] || r.type).replace(/s$/, '');
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'search-result-name';
|
||||
name.textContent = r.sessionName || r.sessionId || '(session)';
|
||||
|
||||
const time = document.createElement('span');
|
||||
time.className = 'search-result-time';
|
||||
time.textContent = window.CodemanSearch.formatSearchTime(r.timestamp);
|
||||
|
||||
topRow.append(badge, name, time);
|
||||
|
||||
const snippet = document.createElement('div');
|
||||
snippet.className = 'search-result-snippet';
|
||||
snippet.textContent = r.snippet || '';
|
||||
|
||||
card.append(topRow, snippet);
|
||||
|
||||
const jump = () => this._jumpToSearchResult(r);
|
||||
card.addEventListener('click', jump);
|
||||
card.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault();
|
||||
jump();
|
||||
}
|
||||
});
|
||||
|
||||
return card;
|
||||
},
|
||||
|
||||
/**
|
||||
* Navigate to a search result by jumpTo.kind, reusing the existing app methods:
|
||||
* session → selectSession(sessionId) (open/switch to the session)
|
||||
* run-summary → openRunSummary(sessionId) (session options → summary tab)
|
||||
* file-preview→ openFilePreview(path, sessionId, attachmentId)
|
||||
*/
|
||||
_jumpToSearchResult(r) {
|
||||
const jt = r && r.jumpTo;
|
||||
if (!jt) return;
|
||||
// Leaving the welcome overlay so the target surface is visible.
|
||||
if (typeof this.hideWelcome === 'function') this.hideWelcome();
|
||||
|
||||
try {
|
||||
if (jt.kind === 'run-summary') {
|
||||
this.openRunSummary(jt.sessionId);
|
||||
} else if (jt.kind === 'file-preview') {
|
||||
this.openFilePreview(jt.relativePath || '', jt.sessionId, jt.targetId || null);
|
||||
} else {
|
||||
// 'session' (default)
|
||||
this.selectSession(jt.sessionId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[search] jump failed', err);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -710,3 +710,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(),
|
||||
});
|
||||
|
||||
@@ -150,6 +150,7 @@ import {
|
||||
registerRalphRoutes,
|
||||
registerPlanRoutes,
|
||||
registerClipboardRoutes,
|
||||
registerSearchRoutes,
|
||||
registerOrchestratorRoutes,
|
||||
registerWsRoutes,
|
||||
} from './routes/index.js';
|
||||
@@ -870,6 +871,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());
|
||||
}
|
||||
|
||||
@@ -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<typeof createMockRouteContext>;
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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 = <T>(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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user