33from __future__ import annotations
44
55import logging
6+ import os
67from datetime import datetime , timezone
78from typing import Any
89
1112from api ._flask_types import FlaskReturn , json_response
1213from api .error_codes import ErrorCode , error_response
1314from models .search import SearchHitDict
14- from models .session import MessageDict
1515from utils .exclusion_rules import is_session_excluded
1616from utils .search_index import (
1717 index_is_usable ,
1818 index_search_enabled ,
19+ message_searchable_text ,
1920 query_index_hits ,
2021 resolve_search_since_ms ,
2122 search_snippet ,
2223 timestamp_in_search_window_iso ,
2324 timestamp_to_ms ,
24- tool_result_searchable_text ,
2525)
2626from utils .session_cache import get_cached_session
2727from utils .session_path import get_claude_projects_dir , list_projects , list_sessions
3232
3333_DEFAULT_LIMIT = 50
3434_MAX_LIMIT = 500
35+ _MAX_QUERY_LEN = 500
3536_MAX_SEARCH_SINCE_DAYS = 36_500
3637
3738
@@ -53,10 +54,10 @@ def _parse_since_days(raw: str | None) -> int | None:
5354 return None
5455 try :
5556 days = int (str (raw ).strip ())
56- except ValueError :
57- return None
57+ except ValueError as exc :
58+ raise ValueError ( "Invalid since_days: must be a positive integer" ) from exc
5859 if days <= 0 :
59- return None
60+ raise ValueError ( "Invalid since_days: must be a positive integer" )
6061 return min (days , _MAX_SEARCH_SINCE_DAYS )
6162
6263
@@ -71,16 +72,44 @@ def _rank_search_hits(results: list[SearchHitDict]) -> list[SearchHitDict]:
7172 )
7273
7374
74- def _message_searchable_text (msg : MessageDict ) -> str :
75- text = msg .get ("text" , "" ) or msg .get ("content" , "" )
76- if not isinstance (text , str ):
77- text = ""
78- tool_result = msg .get ("tool_result" )
79- if isinstance (tool_result , dict ):
80- tool_text = tool_result_searchable_text (tool_result )
81- if tool_text :
82- text = f"{ text } \n { tool_text } " if text else tool_text
83- return text
75+ def _hit_dedup_key (hit : SearchHitDict ) -> tuple [str , str , str | None , str ]:
76+ ts = hit .get ("timestamp" )
77+ return (
78+ hit ["project" ],
79+ hit ["session_id" ],
80+ ts if isinstance (ts , str ) else None ,
81+ hit ["role" ],
82+ )
83+
84+
85+ def _merge_search_hits (
86+ primary : list [SearchHitDict ],
87+ extra : list [SearchHitDict ],
88+ * ,
89+ max_results : int ,
90+ ) -> list [SearchHitDict ]:
91+ seen = {_hit_dedup_key (hit ) for hit in primary }
92+ merged = list (primary )
93+ for hit in extra :
94+ key = _hit_dedup_key (hit )
95+ if key in seen :
96+ continue
97+ merged .append (hit )
98+ seen .add (key )
99+ if len (merged ) >= max_results :
100+ break
101+ return _rank_search_hits (merged )[:max_results ]
102+
103+
104+ def _projects_dir_inaccessible (projects_dir : str ) -> bool :
105+ """True when the projects path exists but cannot be listed (503 case)."""
106+ try :
107+ if not os .path .isdir (projects_dir ):
108+ return False
109+ os .listdir (projects_dir )
110+ return False
111+ except OSError :
112+ return True
84113
85114
86115def _index_hit_excluded (
@@ -104,7 +133,7 @@ def _index_hit_excluded(
104133 file_path ,
105134 exc_info = True ,
106135 )
107- return False
136+ return True
108137 return is_session_excluded (rules , session , project_name )
109138
110139
@@ -116,13 +145,14 @@ def _search_via_index(
116145 * ,
117146 since_ms : int | None ,
118147 max_results : int ,
119- ) -> list [SearchHitDict ] | None :
148+ ) -> tuple [ list [SearchHitDict ] | None , bool ] :
120149 if not index_search_enabled () or not index_is_usable (projects_dir , rules ):
121- return None
150+ return None , False
122151
123152 rules_fp = rules_fingerprint (rules )
124153 results : list [SearchHitDict ] = []
125154 sql_offset = 0
155+ fts_exhausted = False
126156 while len (results ) < max_results :
127157 need = max_results - len (results )
128158 indexed = query_index_hits (
@@ -131,9 +161,18 @@ def _search_via_index(
131161 max_results = need ,
132162 sql_offset = sql_offset ,
133163 )
164+ if indexed ["index_locked" ]:
165+ _logger .warning (
166+ "Search index locked during query; %d hit(s) collected so far" ,
167+ len (results ),
168+ )
169+ if results :
170+ return _rank_search_hits (results )[:max_results ], False
171+ return None , False
134172 if not indexed ["query_ok" ]:
135- return None
173+ return None , False
136174 if indexed ["sql_rows_fetched" ] == 0 :
175+ fts_exhausted = indexed ["sql_exhausted" ]
137176 break
138177
139178 for hit in indexed ["hits" ]:
@@ -160,8 +199,50 @@ def _search_via_index(
160199
161200 sql_offset += indexed ["sql_rows_fetched" ]
162201 if indexed ["sql_exhausted" ]:
202+ fts_exhausted = True
163203 break
164- return _rank_search_hits (results )[:max_results ]
204+ return _rank_search_hits (results )[:max_results ], fts_exhausted
205+
206+
207+ def _resolve_search_results (
208+ base : str ,
209+ rules : list [Any ],
210+ query : str ,
211+ query_lower : str ,
212+ * ,
213+ since_ms : int | None ,
214+ max_results : int ,
215+ ) -> list [SearchHitDict ]:
216+ indexed , _fts_exhausted = _search_via_index (
217+ base ,
218+ rules ,
219+ query ,
220+ query_lower ,
221+ since_ms = since_ms ,
222+ max_results = max_results ,
223+ )
224+ if indexed is None :
225+ return _search_live_scan (
226+ base ,
227+ rules ,
228+ query ,
229+ query_lower ,
230+ since_ms = since_ms ,
231+ max_results = max_results ,
232+ )
233+
234+ if len (indexed ) >= max_results :
235+ return indexed
236+
237+ live = _search_live_scan (
238+ base ,
239+ rules ,
240+ query ,
241+ query_lower ,
242+ since_ms = since_ms ,
243+ max_results = max_results ,
244+ )
245+ return _merge_search_hits (indexed , live , max_results = max_results )
165246
166247
167248def _search_live_scan (
@@ -192,7 +273,7 @@ def _search_live_scan(
192273 continue
193274
194275 for msg in session ["messages" ]:
195- text = _message_searchable_text (msg )
276+ text = message_searchable_text (msg )
196277 if not text or query_lower not in text .lower ():
197278 continue
198279 if not timestamp_in_search_window_iso (
@@ -215,9 +296,20 @@ def _search_live_scan(
215296
216297@search_bp .route ("/api/search" )
217298def search () -> FlaskReturn :
218- query = request .args .get ("q" , "" ).strip ()
299+ raw_query = request .args .get ("q" , "" )
300+ query = raw_query .strip ()
219301 if not query :
220- return json_response ([])
302+ return error_response (
303+ ErrorCode .SEARCH_EMPTY_QUERY ,
304+ "Search query is required" ,
305+ 400 ,
306+ )
307+ if len (query ) > _MAX_QUERY_LEN :
308+ return error_response (
309+ ErrorCode .SEARCH_QUERY_TOO_LONG ,
310+ f"Search query must be at most { _MAX_QUERY_LEN } characters" ,
311+ 400 ,
312+ )
221313
222314 try :
223315 max_results = _parse_limit (request .args .get ("limit" ))
@@ -228,35 +320,49 @@ def search() -> FlaskReturn:
228320 400 ,
229321 )
230322
323+ since_days_raw = request .args .get ("since_days" )
324+ try :
325+ since_days = _parse_since_days (since_days_raw )
326+ except ValueError :
327+ return error_response (
328+ ErrorCode .SEARCH_INVALID_SINCE_DAYS ,
329+ "Invalid since_days: must be a positive integer" ,
330+ 400 ,
331+ )
332+
231333 query_lower = query .lower ()
232334 all_history = request .args .get ("all_history" ) in ("1" , "true" )
233335 since_ms = resolve_search_since_ms (
234336 all_history = all_history ,
235- since_days = _parse_since_days ( request . args . get ( " since_days" )) ,
337+ since_days = since_days ,
236338 now = datetime .now (timezone .utc ),
237339 )
238340
239341 base = current_app .config .get ("CLAUDE_PROJECTS_DIR" ) or get_claude_projects_dir ()
240- rules = current_app .config .get ("EXCLUSION_RULES" ) or []
342+ if _projects_dir_inaccessible (base ):
343+ return error_response (
344+ ErrorCode .SEARCH_PROJECTS_UNAVAILABLE ,
345+ "Claude projects directory is not accessible" ,
346+ 503 ,
347+ )
241348
242- indexed = _search_via_index (
243- base ,
244- rules ,
245- query ,
246- query_lower ,
247- since_ms = since_ms ,
248- max_results = max_results ,
249- )
250- if indexed is not None :
251- return json_response (indexed )
349+ rules = current_app .config .get ("EXCLUSION_RULES" ) or []
252350
253- return json_response (
254- _search_live_scan (
255- base ,
256- rules ,
257- query ,
258- query_lower ,
259- since_ms = since_ms ,
260- max_results = max_results ,
351+ try :
352+ return json_response (
353+ _resolve_search_results (
354+ base ,
355+ rules ,
356+ query ,
357+ query_lower ,
358+ since_ms = since_ms ,
359+ max_results = max_results ,
360+ )
361+ )
362+ except Exception :
363+ _logger .exception ("Unexpected error during search" )
364+ return error_response (
365+ ErrorCode .INTERNAL_ERROR ,
366+ "Search failed" ,
367+ 500 ,
261368 )
262- )
0 commit comments