mirror of
https://github.com/thedotmack/claude-mem
synced 2026-04-25 17:15:04 +02:00
refactor: land PATHFINDER Plan 04 (narrowed) — search fail-fast
Phases 3, 5, 6 only. Plan-doc inaccuracies for phases 1/2/4/7/8/9
deferred for plan reconciliation:
- Phase 1/2: ObservationRow type doesn't exist; the four
"formatters" operate on three incompatible types.
- Phase 4: RECENCY_WINDOW_MS already imported from
SEARCH_CONSTANTS at every call site.
- Phase 7: getExistingChromaIds is NOT @deprecated and has an
active caller in ChromaSync.backfillMissingSyncs.
- Phase 8: estimateTokens already consolidated.
- Phase 9: knowledge-corpus rewrite blocked on PG-3
prompt-caching cost smoke test.
Phase 3 — Delete SearchManager.findByConcept/findByFile/findByType.
SearchRoutes handlers (handleSearchByConcept/File/Type) now call
searchManager.getOrchestrator().findByXxx() directly via new
getter accessors on SearchManager. ~250 LoC deleted.
Phase 5 — Fail-fast Chroma. Created
src/services/worker/search/errors.ts with ChromaUnavailableError
extends AppError(503, 'CHROMA_UNAVAILABLE'). Deleted
SearchOrchestrator.executeWithFallback's Chroma-failed
SQLite-fallback branch; runtime Chroma errors now throw 503.
"Path 3" (chromaSync was null at construction — explicit-
uninitialized config) preserved as legitimate empty-result state
per plan text. ChromaSearchStrategy.search no longer wraps in
try/catch — errors propagate.
Phase 6 — Delete HybridSearchStrategy three try/catch silent
fallback blocks (findByConcept, findByType, findByFile) at lines
~82-95, ~120-132, ~161-172. Removed `fellBack` field from
StrategySearchResult type and every return site
(SQLiteSearchStrategy, BaseSearchStrategy.emptyResult,
SearchOrchestrator).
Tests updated (Principle 7 — delete in same PR):
- search-orchestrator.test.ts: "fall back to SQLite" rewritten
as "throw ChromaUnavailableError (HTTP 503)".
- chroma/hybrid/sqlite-search-strategy tests: rewritten to
rejects.toThrow; removed fellBack assertions.
Verification: SearchManager.findBy → 0; fellBack → 0 in src/.
bun test tests/worker/search/ → 122 pass, 0 fail.
bun test (suite-wide) → 1407 pass, baseline maintained, 0 new
failures. bun run build succeeds.
Plan: PATHFINDER-2026-04-22/04-read-path.md (Phases 3, 5, 6)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -52,6 +52,22 @@ export class SearchManager {
|
||||
this.timelineBuilder = new TimelineBuilder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessor for the underlying orchestrator. Used by HTTP routes that need
|
||||
* raw StrategySearchResult instead of formatted MCP text output.
|
||||
*/
|
||||
getOrchestrator(): SearchOrchestrator {
|
||||
return this.orchestrator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessor for the formatter. Used by HTTP routes that construct
|
||||
* text output from raw orchestrator results.
|
||||
*/
|
||||
getFormatter(): FormattingService {
|
||||
return this.formatter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Chroma vector database via ChromaSync
|
||||
* @deprecated Use orchestrator.search() instead
|
||||
@@ -1203,265 +1219,6 @@ export class SearchManager {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tool handler: find_by_concept
|
||||
*/
|
||||
async findByConcept(args: any): Promise<any> {
|
||||
const normalized = this.normalizeParams(args);
|
||||
const { concepts: concept, ...filters } = normalized;
|
||||
let results: ObservationSearchResult[] = [];
|
||||
|
||||
// Metadata-first, semantic-enhanced search
|
||||
if (this.chromaSync) {
|
||||
logger.debug('SEARCH', 'Using metadata-first + semantic ranking for concept search', {});
|
||||
|
||||
// Step 1: SQLite metadata filter (get all IDs with this concept)
|
||||
const metadataResults = this.sessionSearch.findByConcept(concept, filters);
|
||||
logger.debug('SEARCH', 'Found observations with concept', { concept, count: metadataResults.length });
|
||||
|
||||
if (metadataResults.length > 0) {
|
||||
// Step 2: Chroma semantic ranking (rank by relevance to concept)
|
||||
const ids = metadataResults.map(obs => obs.id);
|
||||
const chromaResults = await this.queryChroma(concept, Math.min(ids.length, 100));
|
||||
|
||||
// Intersect: Keep only IDs that passed metadata filter, in semantic rank order
|
||||
const rankedIds: number[] = [];
|
||||
for (const chromaId of chromaResults.ids) {
|
||||
if (ids.includes(chromaId) && !rankedIds.includes(chromaId)) {
|
||||
rankedIds.push(chromaId);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('SEARCH', 'Chroma ranked results by semantic relevance', { count: rankedIds.length });
|
||||
|
||||
// Step 3: Hydrate in semantic rank order
|
||||
if (rankedIds.length > 0) {
|
||||
results = this.sessionStore.getObservationsByIds(rankedIds, { limit: filters.limit || 20 });
|
||||
// Restore semantic ranking order
|
||||
results.sort((a, b) => rankedIds.indexOf(a.id) - rankedIds.indexOf(b.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to SQLite-only if Chroma unavailable or failed
|
||||
if (results.length === 0) {
|
||||
logger.debug('SEARCH', 'Using SQLite-only concept search', {});
|
||||
results = this.sessionSearch.findByConcept(concept, filters);
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: `No observations found with concept "${concept}"`
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
// Format as table
|
||||
const header = `Found ${results.length} observation(s) with concept "${concept}"\n\n${this.formatter.formatTableHeader()}`;
|
||||
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: header + '\n' + formattedResults.join('\n')
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tool handler: find_by_file
|
||||
*/
|
||||
async findByFile(args: any): Promise<any> {
|
||||
const normalized = this.normalizeParams(args);
|
||||
const { files: rawFilePath, ...filters } = normalized;
|
||||
// Handle both string and array (normalizeParams may split on comma)
|
||||
const filePath = Array.isArray(rawFilePath) ? rawFilePath[0] : rawFilePath;
|
||||
let observations: ObservationSearchResult[] = [];
|
||||
let sessions: SessionSummarySearchResult[] = [];
|
||||
|
||||
// Metadata-first, semantic-enhanced search for observations
|
||||
if (this.chromaSync) {
|
||||
logger.debug('SEARCH', 'Using metadata-first + semantic ranking for file search', {});
|
||||
|
||||
// Step 1: SQLite metadata filter (get all results with this file)
|
||||
const metadataResults = this.sessionSearch.findByFile(filePath, filters);
|
||||
logger.debug('SEARCH', 'Found results for file', { file: filePath, observations: metadataResults.observations.length, sessions: metadataResults.sessions.length });
|
||||
|
||||
// Sessions: Keep as-is (already summarized, no semantic ranking needed)
|
||||
sessions = metadataResults.sessions;
|
||||
|
||||
// Observations: Apply semantic ranking
|
||||
if (metadataResults.observations.length > 0) {
|
||||
// Step 2: Chroma semantic ranking (rank by relevance to file path)
|
||||
const ids = metadataResults.observations.map(obs => obs.id);
|
||||
const chromaResults = await this.queryChroma(filePath, Math.min(ids.length, 100));
|
||||
|
||||
// Intersect: Keep only IDs that passed metadata filter, in semantic rank order
|
||||
const rankedIds: number[] = [];
|
||||
for (const chromaId of chromaResults.ids) {
|
||||
if (ids.includes(chromaId) && !rankedIds.includes(chromaId)) {
|
||||
rankedIds.push(chromaId);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('SEARCH', 'Chroma ranked observations by semantic relevance', { count: rankedIds.length });
|
||||
|
||||
// Step 3: Hydrate in semantic rank order
|
||||
if (rankedIds.length > 0) {
|
||||
observations = this.sessionStore.getObservationsByIds(rankedIds, { limit: filters.limit || 20 });
|
||||
// Restore semantic ranking order
|
||||
observations.sort((a, b) => rankedIds.indexOf(a.id) - rankedIds.indexOf(b.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to SQLite-only if Chroma unavailable or failed
|
||||
if (observations.length === 0 && sessions.length === 0) {
|
||||
logger.debug('SEARCH', 'Using SQLite-only file search', {});
|
||||
const results = this.sessionSearch.findByFile(filePath, filters);
|
||||
observations = results.observations;
|
||||
sessions = results.sessions;
|
||||
}
|
||||
|
||||
const totalResults = observations.length + sessions.length;
|
||||
|
||||
if (totalResults === 0) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: `No results found for file "${filePath}"`
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
// Combine observations and sessions with timestamps for date grouping
|
||||
const combined: Array<{
|
||||
type: 'observation' | 'session';
|
||||
data: ObservationSearchResult | SessionSummarySearchResult;
|
||||
epoch: number;
|
||||
created_at: string;
|
||||
}> = [
|
||||
...observations.map(obs => ({
|
||||
type: 'observation' as const,
|
||||
data: obs,
|
||||
epoch: obs.created_at_epoch,
|
||||
created_at: obs.created_at
|
||||
})),
|
||||
...sessions.map(sess => ({
|
||||
type: 'session' as const,
|
||||
data: sess,
|
||||
epoch: sess.created_at_epoch,
|
||||
created_at: sess.created_at
|
||||
}))
|
||||
];
|
||||
|
||||
// Sort by date (most recent first)
|
||||
combined.sort((a, b) => b.epoch - a.epoch);
|
||||
|
||||
// Group by date for proper timeline rendering
|
||||
const resultsByDate = groupByDate(combined, item => item.created_at);
|
||||
|
||||
// Format with date headers for proper date parsing by folder CLAUDE.md generator
|
||||
const lines: string[] = [];
|
||||
lines.push(`Found ${totalResults} result(s) for file "${filePath}"`);
|
||||
lines.push('');
|
||||
|
||||
for (const [day, dayResults] of resultsByDate) {
|
||||
lines.push(`### ${day}`);
|
||||
lines.push('');
|
||||
lines.push(this.formatter.formatTableHeader());
|
||||
|
||||
for (const result of dayResults) {
|
||||
if (result.type === 'observation') {
|
||||
lines.push(this.formatter.formatObservationIndex(result.data as ObservationSearchResult, 0));
|
||||
} else {
|
||||
lines.push(this.formatter.formatSessionIndex(result.data as SessionSummarySearchResult, 0));
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: lines.join('\n')
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tool handler: find_by_type
|
||||
*/
|
||||
async findByType(args: any): Promise<any> {
|
||||
const normalized = this.normalizeParams(args);
|
||||
const { type, ...filters } = normalized;
|
||||
const typeStr = Array.isArray(type) ? type.join(', ') : type;
|
||||
let results: ObservationSearchResult[] = [];
|
||||
|
||||
// Metadata-first, semantic-enhanced search
|
||||
if (this.chromaSync) {
|
||||
logger.debug('SEARCH', 'Using metadata-first + semantic ranking for type search', {});
|
||||
|
||||
// Step 1: SQLite metadata filter (get all IDs with this type)
|
||||
const metadataResults = this.sessionSearch.findByType(type, filters);
|
||||
logger.debug('SEARCH', 'Found observations with type', { type: typeStr, count: metadataResults.length });
|
||||
|
||||
if (metadataResults.length > 0) {
|
||||
// Step 2: Chroma semantic ranking (rank by relevance to type)
|
||||
const ids = metadataResults.map(obs => obs.id);
|
||||
const chromaResults = await this.queryChroma(typeStr, Math.min(ids.length, 100));
|
||||
|
||||
// Intersect: Keep only IDs that passed metadata filter, in semantic rank order
|
||||
const rankedIds: number[] = [];
|
||||
for (const chromaId of chromaResults.ids) {
|
||||
if (ids.includes(chromaId) && !rankedIds.includes(chromaId)) {
|
||||
rankedIds.push(chromaId);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('SEARCH', 'Chroma ranked results by semantic relevance', { count: rankedIds.length });
|
||||
|
||||
// Step 3: Hydrate in semantic rank order
|
||||
if (rankedIds.length > 0) {
|
||||
results = this.sessionStore.getObservationsByIds(rankedIds, { limit: filters.limit || 20 });
|
||||
// Restore semantic ranking order
|
||||
results.sort((a, b) => rankedIds.indexOf(a.id) - rankedIds.indexOf(b.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to SQLite-only if Chroma unavailable or failed
|
||||
if (results.length === 0) {
|
||||
logger.debug('SEARCH', 'Using SQLite-only type search', {});
|
||||
results = this.sessionSearch.findByType(type, filters);
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: `No observations found with type "${typeStr}"`
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
// Format as table
|
||||
const header = `Found ${results.length} observation(s) with type "${typeStr}"\n\n${this.formatter.formatTableHeader()}`;
|
||||
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: header + '\n' + formattedResults.join('\n')
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tool handler: get_recent_context
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,8 @@ import express, { Request, Response } from 'express';
|
||||
import { SearchManager } from '../../SearchManager.js';
|
||||
import { BaseRouteHandler } from '../BaseRouteHandler.js';
|
||||
import { logger } from '../../../../utils/logger.js';
|
||||
import { groupByDate } from '../../../../shared/timeline-formatting.js';
|
||||
import type { ObservationSearchResult, SessionSummarySearchResult } from '../../../sqlite/types.js';
|
||||
|
||||
export class SearchRoutes extends BaseRouteHandler {
|
||||
constructor(
|
||||
@@ -120,28 +122,156 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
/**
|
||||
* Search observations by concept
|
||||
* GET /api/search/by-concept?concept=discovery&limit=5
|
||||
*
|
||||
* Chroma errors surface as 503 via ChromaUnavailableError (thrown by orchestrator).
|
||||
*/
|
||||
private handleSearchByConcept = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const result = await this.searchManager.findByConcept(req.query);
|
||||
res.json(result);
|
||||
const orchestrator = this.searchManager.getOrchestrator();
|
||||
const formatter = this.searchManager.getFormatter();
|
||||
const query = req.query as Record<string, any>;
|
||||
const rawConcept = query.concepts ?? query.concept;
|
||||
const concept = Array.isArray(rawConcept) ? rawConcept[0] : rawConcept;
|
||||
const strategyResult = await orchestrator.findByConcept(concept, query);
|
||||
const observations = strategyResult.results.observations;
|
||||
|
||||
if (observations.length === 0) {
|
||||
res.json({
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: `No observations found with concept "${concept}"`
|
||||
}]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const header = `Found ${observations.length} observation(s) with concept "${concept}"\n\n${formatter.formatTableHeader()}`;
|
||||
const rows = observations.map((obs: ObservationSearchResult, i: number) => formatter.formatObservationIndex(obs, i));
|
||||
res.json({
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: header + '\n' + rows.join('\n')
|
||||
}]
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Search by file path
|
||||
* GET /api/search/by-file?filePath=...&limit=10
|
||||
*
|
||||
* Chroma errors surface as 503 via ChromaUnavailableError (thrown by orchestrator).
|
||||
*/
|
||||
private handleSearchByFile = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const result = await this.searchManager.findByFile(req.query);
|
||||
res.json(result);
|
||||
const orchestrator = this.searchManager.getOrchestrator();
|
||||
const formatter = this.searchManager.getFormatter();
|
||||
const query = req.query as Record<string, any>;
|
||||
// Accept both filePath and files for API compatibility
|
||||
const rawFilePath = query.filePath ?? query.files;
|
||||
const filePath = Array.isArray(rawFilePath)
|
||||
? rawFilePath[0]
|
||||
: (typeof rawFilePath === 'string' && rawFilePath.includes(','))
|
||||
? rawFilePath.split(',')[0].trim()
|
||||
: rawFilePath;
|
||||
|
||||
const { observations, sessions } = await orchestrator.findByFile(filePath, query);
|
||||
const totalResults = observations.length + sessions.length;
|
||||
|
||||
if (totalResults === 0) {
|
||||
res.json({
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: `No results found for file "${filePath}"`
|
||||
}]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Combine observations and sessions with timestamps for date grouping
|
||||
const combined: Array<{
|
||||
type: 'observation' | 'session';
|
||||
data: ObservationSearchResult | SessionSummarySearchResult;
|
||||
epoch: number;
|
||||
created_at: string;
|
||||
}> = [
|
||||
...observations.map((obs: ObservationSearchResult) => ({
|
||||
type: 'observation' as const,
|
||||
data: obs,
|
||||
epoch: obs.created_at_epoch,
|
||||
created_at: obs.created_at
|
||||
})),
|
||||
...sessions.map((sess: SessionSummarySearchResult) => ({
|
||||
type: 'session' as const,
|
||||
data: sess,
|
||||
epoch: sess.created_at_epoch,
|
||||
created_at: sess.created_at
|
||||
}))
|
||||
];
|
||||
|
||||
combined.sort((a, b) => b.epoch - a.epoch);
|
||||
const resultsByDate = groupByDate(combined, item => item.created_at);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`Found ${totalResults} result(s) for file "${filePath}"`);
|
||||
lines.push('');
|
||||
|
||||
for (const [day, dayResults] of resultsByDate) {
|
||||
lines.push(`### ${day}`);
|
||||
lines.push('');
|
||||
lines.push(formatter.formatTableHeader());
|
||||
for (const result of dayResults) {
|
||||
if (result.type === 'observation') {
|
||||
lines.push(formatter.formatObservationIndex(result.data as ObservationSearchResult, 0));
|
||||
} else {
|
||||
lines.push(formatter.formatSessionIndex(result.data as SessionSummarySearchResult, 0));
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
res.json({
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: lines.join('\n')
|
||||
}]
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Search observations by type
|
||||
* GET /api/search/by-type?type=bugfix&limit=10
|
||||
*
|
||||
* Chroma errors surface as 503 via ChromaUnavailableError (thrown by orchestrator).
|
||||
*/
|
||||
private handleSearchByType = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const result = await this.searchManager.findByType(req.query);
|
||||
res.json(result);
|
||||
const orchestrator = this.searchManager.getOrchestrator();
|
||||
const formatter = this.searchManager.getFormatter();
|
||||
const query = req.query as Record<string, any>;
|
||||
const rawType = query.type;
|
||||
const type = (typeof rawType === 'string' && rawType.includes(','))
|
||||
? rawType.split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||
: rawType;
|
||||
const typeStr = Array.isArray(type) ? type.join(', ') : type;
|
||||
|
||||
const strategyResult = await orchestrator.findByType(type, query);
|
||||
const observations = strategyResult.results.observations;
|
||||
|
||||
if (observations.length === 0) {
|
||||
res.json({
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: `No observations found with type "${typeStr}"`
|
||||
}]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const header = `Found ${observations.length} observation(s) with type "${typeStr}"\n\n${formatter.formatTableHeader()}`;
|
||||
const rows = observations.map((obs: ObservationSearchResult, i: number) => formatter.formatObservationIndex(obs, i));
|
||||
res.json({
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: header + '\n' + rows.join('\n')
|
||||
}]
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
SearchResults,
|
||||
ObservationSearchResult
|
||||
} from './types.js';
|
||||
import { ChromaUnavailableError } from './errors.js';
|
||||
import { logger } from '../../../utils/logger.js';
|
||||
|
||||
/**
|
||||
@@ -88,34 +89,27 @@ export class SearchOrchestrator {
|
||||
}
|
||||
|
||||
// PATH 2: CHROMA SEMANTIC SEARCH (query text + Chroma available)
|
||||
// Fail-fast: if Chroma errors, ChromaSearchStrategy now lets the error
|
||||
// propagate. We catch it here only to translate into a typed 503.
|
||||
if (this.chromaStrategy) {
|
||||
logger.debug('SEARCH', 'Orchestrator: Using Chroma semantic search', {});
|
||||
const result = await this.chromaStrategy.search(options);
|
||||
|
||||
// If Chroma succeeded (even with 0 results), return
|
||||
if (result.usedChroma) {
|
||||
return result;
|
||||
try {
|
||||
return await this.chromaStrategy.search(options);
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
throw new ChromaUnavailableError(
|
||||
`Chroma query failed: ${errorObj.message}`,
|
||||
errorObj
|
||||
);
|
||||
}
|
||||
|
||||
// Chroma failed - fall back to SQLite for filter-only
|
||||
logger.debug('SEARCH', 'Orchestrator: Chroma failed, falling back to SQLite', {});
|
||||
const fallbackResult = await this.sqliteStrategy.search({
|
||||
...options,
|
||||
query: undefined // Remove query for SQLite fallback
|
||||
});
|
||||
|
||||
return {
|
||||
...fallbackResult,
|
||||
fellBack: true
|
||||
};
|
||||
}
|
||||
|
||||
// PATH 3: No Chroma available
|
||||
logger.debug('SEARCH', 'Orchestrator: Chroma not available', {});
|
||||
// PATH 3: Chroma not configured (explicitly uninitialized at construction).
|
||||
// This is a legitimate config state — return empty results, not an error.
|
||||
logger.debug('SEARCH', 'Orchestrator: Chroma not configured', {});
|
||||
return {
|
||||
results: { observations: [], sessions: [], prompts: [] },
|
||||
usedChroma: false,
|
||||
fellBack: false,
|
||||
strategy: 'sqlite'
|
||||
};
|
||||
}
|
||||
@@ -130,12 +124,11 @@ export class SearchOrchestrator {
|
||||
return await this.hybridStrategy.findByConcept(concept, options);
|
||||
}
|
||||
|
||||
// Fallback to SQLite
|
||||
// Chroma not configured: SQLite metadata-only result.
|
||||
const results = this.sqliteStrategy.findByConcept(concept, options);
|
||||
return {
|
||||
results: { observations: results, sessions: [], prompts: [] },
|
||||
usedChroma: false,
|
||||
fellBack: false,
|
||||
strategy: 'sqlite'
|
||||
};
|
||||
}
|
||||
@@ -150,12 +143,11 @@ export class SearchOrchestrator {
|
||||
return await this.hybridStrategy.findByType(type, options);
|
||||
}
|
||||
|
||||
// Fallback to SQLite
|
||||
// Chroma not configured: SQLite metadata-only result.
|
||||
const results = this.sqliteStrategy.findByType(type, options);
|
||||
return {
|
||||
results: { observations: results, sessions: [], prompts: [] },
|
||||
usedChroma: false,
|
||||
fellBack: false,
|
||||
strategy: 'sqlite'
|
||||
};
|
||||
}
|
||||
|
||||
16
src/services/worker/search/errors.ts
Normal file
16
src/services/worker/search/errors.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Search-related error classes
|
||||
*/
|
||||
|
||||
import { AppError } from '../../server/ErrorHandler.js';
|
||||
|
||||
/**
|
||||
* Thrown when Chroma is expected to be available but failed at query time.
|
||||
* Maps to HTTP 503 Service Unavailable.
|
||||
*/
|
||||
export class ChromaUnavailableError extends AppError {
|
||||
constructor(message: string, cause?: Error) {
|
||||
super(message, 503, 'CHROMA_UNAVAILABLE', cause ? { cause: cause.message } : undefined);
|
||||
this.name = 'ChromaUnavailableError';
|
||||
}
|
||||
}
|
||||
@@ -59,31 +59,16 @@ export class ChromaSearchStrategy extends BaseSearchStrategy implements SearchSt
|
||||
const searchSessions = searchType === 'all' || searchType === 'sessions';
|
||||
const searchPrompts = searchType === 'all' || searchType === 'prompts';
|
||||
|
||||
let observations: ObservationSearchResult[] = [];
|
||||
let sessions: SessionSummarySearchResult[] = [];
|
||||
let prompts: UserPromptSearchResult[] = [];
|
||||
|
||||
// Build Chroma where filter for doc_type and project
|
||||
const whereFilter = this.buildWhereFilter(searchType, project);
|
||||
|
||||
logger.debug('SEARCH', 'ChromaSearchStrategy: Querying Chroma', { query, searchType });
|
||||
|
||||
try {
|
||||
return await this.executeChromaSearch(query, whereFilter, {
|
||||
searchObservations, searchSessions, searchPrompts,
|
||||
obsType, concepts, files, orderBy, limit, project
|
||||
});
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
logger.error('WORKER', 'ChromaSearchStrategy: Search failed', {}, errorObj);
|
||||
// Return empty result - caller may try fallback strategy
|
||||
return {
|
||||
results: { observations: [], sessions: [], prompts: [] },
|
||||
usedChroma: false,
|
||||
fellBack: false,
|
||||
strategy: 'chroma'
|
||||
};
|
||||
}
|
||||
// Fail-fast: errors propagate to orchestrator, which translates to HTTP 503.
|
||||
return await this.executeChromaSearch(query, whereFilter, {
|
||||
searchObservations, searchSessions, searchPrompts,
|
||||
obsType, concepts, files, orderBy, limit, project
|
||||
});
|
||||
}
|
||||
|
||||
private async executeChromaSearch(
|
||||
@@ -111,7 +96,6 @@ export class ChromaSearchStrategy extends BaseSearchStrategy implements SearchSt
|
||||
return {
|
||||
results: { observations: [], sessions: [], prompts: [] },
|
||||
usedChroma: true,
|
||||
fellBack: false,
|
||||
strategy: 'chroma'
|
||||
};
|
||||
}
|
||||
@@ -143,7 +127,6 @@ export class ChromaSearchStrategy extends BaseSearchStrategy implements SearchSt
|
||||
return {
|
||||
results: { observations, sessions, prompts },
|
||||
usedChroma: true,
|
||||
fellBack: false,
|
||||
strategy: 'chroma'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,20 +79,8 @@ export class HybridSearchStrategy extends BaseSearchStrategy implements SearchSt
|
||||
|
||||
const ids = metadataResults.map(obs => obs.id);
|
||||
|
||||
try {
|
||||
return await this.rankAndHydrate(concept, ids, limit);
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
logger.error('WORKER', 'HybridSearchStrategy: findByConcept failed', {}, errorObj);
|
||||
// Fall back to metadata-only results
|
||||
const results = this.sessionSearch.findByConcept(concept, filterOptions);
|
||||
return {
|
||||
results: { observations: results, sessions: [], prompts: [] },
|
||||
usedChroma: false,
|
||||
fellBack: true,
|
||||
strategy: 'hybrid'
|
||||
};
|
||||
}
|
||||
// Fail-fast: Chroma errors propagate to orchestrator (HTTP 503).
|
||||
return await this.rankAndHydrate(concept, ids, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,19 +105,8 @@ export class HybridSearchStrategy extends BaseSearchStrategy implements SearchSt
|
||||
|
||||
const ids = metadataResults.map(obs => obs.id);
|
||||
|
||||
try {
|
||||
return await this.rankAndHydrate(typeStr, ids, limit);
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
logger.error('WORKER', 'HybridSearchStrategy: findByType failed', {}, errorObj);
|
||||
const results = this.sessionSearch.findByType(type as any, filterOptions);
|
||||
return {
|
||||
results: { observations: results, sessions: [], prompts: [] },
|
||||
usedChroma: false,
|
||||
fellBack: true,
|
||||
strategy: 'hybrid'
|
||||
};
|
||||
}
|
||||
// Fail-fast: Chroma errors propagate to orchestrator (HTTP 503).
|
||||
return await this.rankAndHydrate(typeStr, ids, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,18 +135,8 @@ export class HybridSearchStrategy extends BaseSearchStrategy implements SearchSt
|
||||
|
||||
const ids = metadataResults.observations.map(obs => obs.id);
|
||||
|
||||
try {
|
||||
return await this.rankAndHydrateForFile(filePath, ids, limit, sessions);
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
logger.error('WORKER', 'HybridSearchStrategy: findByFile failed', {}, errorObj);
|
||||
const results = this.sessionSearch.findByFile(filePath, filterOptions);
|
||||
return {
|
||||
observations: results.observations,
|
||||
sessions: results.sessions,
|
||||
usedChroma: false
|
||||
};
|
||||
}
|
||||
// Fail-fast: Chroma errors propagate to orchestrator (HTTP 503).
|
||||
return await this.rankAndHydrateForFile(filePath, ids, limit, sessions);
|
||||
}
|
||||
|
||||
private async rankAndHydrate(
|
||||
@@ -191,7 +158,6 @@ export class HybridSearchStrategy extends BaseSearchStrategy implements SearchSt
|
||||
return {
|
||||
results: { observations, sessions: [], prompts: [] },
|
||||
usedChroma: true,
|
||||
fellBack: false,
|
||||
strategy: 'hybrid'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,7 +98,6 @@ export class SQLiteSearchStrategy extends BaseSearchStrategy implements SearchSt
|
||||
return {
|
||||
results: { observations, sessions, prompts },
|
||||
usedChroma: false,
|
||||
fellBack: false,
|
||||
strategy: 'sqlite'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,7 +54,6 @@ export abstract class BaseSearchStrategy implements SearchStrategy {
|
||||
prompts: []
|
||||
},
|
||||
usedChroma: strategy === 'chroma' || strategy === 'hybrid',
|
||||
fellBack: false,
|
||||
strategy
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,8 +103,6 @@ export interface StrategySearchResult {
|
||||
results: SearchResults;
|
||||
/** Whether Chroma was used successfully */
|
||||
usedChroma: boolean;
|
||||
/** Whether fallback was triggered */
|
||||
fellBack: boolean;
|
||||
/** Strategy that produced the results */
|
||||
strategy: SearchStrategyHint;
|
||||
}
|
||||
|
||||
@@ -150,16 +150,19 @@ describe('SearchOrchestrator', () => {
|
||||
expect(mockChromaSync.queryChroma).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fall back to SQLite when Chroma fails', async () => {
|
||||
it('should throw ChromaUnavailableError (HTTP 503) when Chroma fails', async () => {
|
||||
mockChromaSync.queryChroma = mock(() => Promise.reject(new Error('Chroma unavailable')));
|
||||
|
||||
const result = await orchestrator.search({
|
||||
query: 'test query'
|
||||
// Fail-fast: Chroma errors propagate as ChromaUnavailableError
|
||||
// (HTTP 503 via the AppError status code) rather than silently
|
||||
// falling back to SQLite.
|
||||
await expect(
|
||||
orchestrator.search({ query: 'test query' })
|
||||
).rejects.toMatchObject({
|
||||
name: 'ChromaUnavailableError',
|
||||
statusCode: 503,
|
||||
code: 'CHROMA_UNAVAILABLE'
|
||||
});
|
||||
|
||||
// Chroma failed, should have fallen back
|
||||
expect(result.fellBack).toBe(true);
|
||||
expect(result.usedChroma).toBe(false);
|
||||
});
|
||||
|
||||
it('should normalize comma-separated concepts', async () => {
|
||||
|
||||
@@ -130,7 +130,6 @@ describe('ChromaSearchStrategy', () => {
|
||||
const result = await strategy.search(options);
|
||||
|
||||
expect(result.usedChroma).toBe(true);
|
||||
expect(result.fellBack).toBe(false);
|
||||
expect(result.strategy).toBe('chroma');
|
||||
});
|
||||
|
||||
@@ -310,23 +309,18 @@ describe('ChromaSearchStrategy', () => {
|
||||
expect(mockSessionStore.getObservationsByIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle Chroma errors gracefully (returns usedChroma: false)', async () => {
|
||||
it('should propagate Chroma errors (fail-fast, no silent fallback)', async () => {
|
||||
mockChromaSync.queryChroma = mock(() => Promise.reject(new Error('Chroma connection failed')));
|
||||
|
||||
const options: StrategySearchOptions = {
|
||||
query: 'test query'
|
||||
};
|
||||
|
||||
const result = await strategy.search(options);
|
||||
|
||||
expect(result.usedChroma).toBe(false);
|
||||
expect(result.fellBack).toBe(false);
|
||||
expect(result.results.observations).toHaveLength(0);
|
||||
expect(result.results.sessions).toHaveLength(0);
|
||||
expect(result.results.prompts).toHaveLength(0);
|
||||
// Fail-fast: the orchestrator wraps this into a ChromaUnavailableError (HTTP 503).
|
||||
await expect(strategy.search(options)).rejects.toThrow('Chroma connection failed');
|
||||
});
|
||||
|
||||
it('should handle SQLite hydration errors gracefully', async () => {
|
||||
it('should propagate SQLite hydration errors (fail-fast)', async () => {
|
||||
mockSessionStore.getObservationsByIds = mock(() => {
|
||||
throw new Error('SQLite error');
|
||||
});
|
||||
@@ -336,10 +330,7 @@ describe('ChromaSearchStrategy', () => {
|
||||
searchType: 'observations'
|
||||
};
|
||||
|
||||
const result = await strategy.search(options);
|
||||
|
||||
expect(result.usedChroma).toBe(false); // Error occurred
|
||||
expect(result.results.observations).toHaveLength(0);
|
||||
await expect(strategy.search(options)).rejects.toThrow('SQLite error');
|
||||
});
|
||||
|
||||
it('should correctly align IDs with metadatas when Chroma returns duplicate sqlite_ids (multiple docs per observation)', async () => {
|
||||
|
||||
@@ -198,7 +198,6 @@ describe('HybridSearchStrategy', () => {
|
||||
expect(mockSessionSearch.findByConcept).toHaveBeenCalledWith('test-concept', expect.any(Object));
|
||||
expect(mockChromaSync.queryChroma).toHaveBeenCalledWith('test-concept', expect.any(Number));
|
||||
expect(result.usedChroma).toBe(true);
|
||||
expect(result.fellBack).toBe(false);
|
||||
expect(result.strategy).toBe('hybrid');
|
||||
});
|
||||
|
||||
@@ -251,18 +250,16 @@ describe('HybridSearchStrategy', () => {
|
||||
expect(mockChromaSync.queryChroma).not.toHaveBeenCalled(); // Should short-circuit
|
||||
});
|
||||
|
||||
it('should fall back to metadata-only on Chroma error', async () => {
|
||||
it('should propagate Chroma error (fail-fast, no silent fallback)', async () => {
|
||||
mockChromaSync.queryChroma = mock(() => Promise.reject(new Error('Chroma failed')));
|
||||
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const result = await strategy.findByConcept('test-concept', options);
|
||||
|
||||
expect(result.usedChroma).toBe(false);
|
||||
expect(result.fellBack).toBe(true);
|
||||
expect(result.results.observations).toHaveLength(3); // All metadata results
|
||||
await expect(
|
||||
strategy.findByConcept('test-concept', options)
|
||||
).rejects.toThrow('Chroma failed');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -307,18 +304,16 @@ describe('HybridSearchStrategy', () => {
|
||||
expect(result.results.observations[0].id).toBe(2);
|
||||
});
|
||||
|
||||
it('should fall back on Chroma error', async () => {
|
||||
it('should propagate Chroma error (fail-fast, no silent fallback)', async () => {
|
||||
mockChromaSync.queryChroma = mock(() => Promise.reject(new Error('Chroma unavailable')));
|
||||
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const result = await strategy.findByType('bugfix', options);
|
||||
|
||||
expect(result.usedChroma).toBe(false);
|
||||
expect(result.fellBack).toBe(true);
|
||||
expect(result.results.observations.length).toBeGreaterThan(0);
|
||||
await expect(
|
||||
strategy.findByType('bugfix', options)
|
||||
).rejects.toThrow('Chroma unavailable');
|
||||
});
|
||||
|
||||
it('should return empty when no metadata matches', async () => {
|
||||
@@ -394,18 +389,16 @@ describe('HybridSearchStrategy', () => {
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should fall back on Chroma error', async () => {
|
||||
it('should propagate Chroma error (fail-fast, no silent fallback)', async () => {
|
||||
mockChromaSync.queryChroma = mock(() => Promise.reject(new Error('Chroma down')));
|
||||
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const result = await strategy.findByFile('/path/to/file.ts', options);
|
||||
|
||||
expect(result.usedChroma).toBe(false);
|
||||
expect(result.observations.length).toBeGreaterThan(0);
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
await expect(
|
||||
strategy.findByFile('/path/to/file.ts', options)
|
||||
).rejects.toThrow('Chroma down');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -116,7 +116,6 @@ describe('SQLiteSearchStrategy', () => {
|
||||
const result = await strategy.search(options);
|
||||
|
||||
expect(result.usedChroma).toBe(false);
|
||||
expect(result.fellBack).toBe(false);
|
||||
expect(result.strategy).toBe('sqlite');
|
||||
expect(result.results.observations).toHaveLength(1);
|
||||
expect(result.results.sessions).toHaveLength(1);
|
||||
|
||||
Reference in New Issue
Block a user