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:
Alex Newman
2026-04-23 01:38:47 -07:00
parent 63b243dfdf
commit 4be36e416d
14 changed files with 386 additions and 560 deletions

File diff suppressed because one or more lines are too long

View File

@@ -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
*/

View File

@@ -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')
}]
});
});
/**

View File

@@ -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'
};
}

View 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';
}
}

View File

@@ -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'
};
}

View File

@@ -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'
};
}

View File

@@ -98,7 +98,6 @@ export class SQLiteSearchStrategy extends BaseSearchStrategy implements SearchSt
return {
results: { observations, sessions, prompts },
usedChroma: false,
fellBack: false,
strategy: 'sqlite'
};
}

View File

@@ -54,7 +54,6 @@ export abstract class BaseSearchStrategy implements SearchStrategy {
prompts: []
},
usedChroma: strategy === 'chroma' || strategy === 'hybrid',
fellBack: false,
strategy
};
}

View File

@@ -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;
}

View File

@@ -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 () => {

View File

@@ -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 () => {

View File

@@ -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');
});
});

View File

@@ -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);