Skip to content

Commit f9d613b

Browse files
Address feedback from brad
1 parent 5306e2a commit f9d613b

6 files changed

Lines changed: 131 additions & 71 deletions

File tree

services/search.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,11 @@ def resolve_search_since_ms(
7676
since_days: int | None = None,
7777
now: datetime | None = None,
7878
) -> int | None:
79-
"""Return epoch-ms cutoff for search, or ``None`` to search all history."""
79+
"""Return epoch-ms cutoff for search, or ``None`` to search all history.
80+
81+
Composers with no parseable timestamp (``updated_ms <= 0``) remain
82+
searchable when a window is active; see ``_INCLUDE_UNKNOWN_TIMESTAMPS_IN_WINDOW``.
83+
"""
8084
if all_history:
8185
return None
8286
days = since_days if since_days is not None else DEFAULT_SEARCH_WINDOW_DAYS
@@ -348,6 +352,7 @@ class _SearchWorkspaceAssigner:
348352
invalid_workspace_aliases: dict[str, str]
349353

350354
def workspace_for_composer(self, composer: Composer) -> str:
355+
# Deliberately omit global bubble index (same as list/summary paths).
351356
return assign_composer_workspace(
352357
composer,
353358
project_layouts_map=self.ctx.project_layouts_map,
@@ -477,7 +482,7 @@ def _search_global_storage_via_index(
477482
) -> list[SearchResult] | None:
478483
"""Search using local FTS index. Returns ``None`` to fall back to live scan."""
479484
from services.search_index import (
480-
query_all_composer_bubble_texts,
485+
query_all_bubble_texts_for_composer_ids,
481486
query_composer_bubble_hits,
482487
query_composer_rows_in_window,
483488
query_composer_title_hits,
@@ -520,6 +525,8 @@ def _search_global_storage_via_index(
520525
if query_lower in raw_lower:
521526
candidate_ids.add(composer_id)
522527

528+
all_bubbles_by_composer = query_all_bubble_texts_for_composer_ids(candidate_ids)
529+
523530
for composer_id in candidate_ids:
524531
composer_row = search_pool.get(composer_id)
525532
if composer_row is None:
@@ -584,7 +591,7 @@ def _search_global_storage_via_index(
584591
if not has_match:
585592
continue
586593

587-
all_bubble_texts = query_all_composer_bubble_texts(composer_id)
594+
all_bubble_texts = all_bubbles_by_composer.get(composer_id, [])
588595
exclusion_text = _composer_exclusion_text(
589596
project_name=project_name,
590597
title=title,

services/search_index.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"ensure_search_index",
4848
"index_is_usable",
4949
"index_search_enabled",
50+
"query_all_bubble_texts_for_composer_ids",
5051
"query_all_composer_bubble_texts",
5152
"query_composer_bubble_hits",
5253
"query_composer_title_hits",
@@ -258,6 +259,7 @@ def build_search_index(
258259
_create_schema(conn)
259260
composer_count = 0
260261
bubble_count = 0
262+
indexed_composer_ids: set[str] = set()
261263

262264
with open_global_db(workspace_path) as (src_conn, _):
263265
if src_conn is None:
@@ -267,6 +269,7 @@ def build_search_index(
267269
).fetchall()
268270
for row in composer_rows:
269271
composer_id = row["key"].split(":")[1]
272+
indexed_composer_ids.add(composer_id)
270273
raw_text = _composer_row_raw_text(row)
271274
try:
272275
cd = json.loads(raw_text)
@@ -300,6 +303,8 @@ def build_search_index(
300303
if len(parts) < 2 or not parts[1]:
301304
continue
302305
composer_id = parts[1]
306+
if composer_id not in indexed_composer_ids:
307+
continue
303308
text = _quick_bubble_text(row["value"])
304309
if not text:
305310
continue
@@ -485,18 +490,38 @@ def query_all_composer_bubble_texts(composer_id: str) -> list[str]:
485490
"""All indexed bubble texts for one composer (exclusion-rule checks)."""
486491
if not composer_id or not index_search_enabled():
487492
return []
493+
by_id = query_all_bubble_texts_for_composer_ids({composer_id})
494+
return by_id.get(composer_id, [])
495+
496+
497+
def query_all_bubble_texts_for_composer_ids(
498+
composer_ids: set[str] | frozenset[str],
499+
) -> dict[str, list[str]]:
500+
"""Batch-load all indexed bubble texts for exclusion-rule checks."""
501+
if not composer_ids or not index_search_enabled():
502+
return {}
488503

504+
ids = list(composer_ids)
505+
result: dict[str, list[str]] = {}
489506
with _index_db_conn(readonly=True) as conn:
490507
if conn is None:
491-
return []
508+
return {}
492509
try:
493-
rows = conn.execute(
494-
"SELECT text FROM bubbles_fts WHERE composer_id = ?",
495-
(composer_id,),
496-
).fetchall()
510+
for offset in range(0, len(ids), 500):
511+
chunk = ids[offset : offset + 500]
512+
placeholders = ",".join("?" * len(chunk))
513+
rows = conn.execute(
514+
"SELECT composer_id, text FROM bubbles_fts"
515+
f" WHERE composer_id IN ({placeholders})",
516+
chunk,
517+
).fetchall()
518+
for row in rows:
519+
text = row["text"]
520+
if text:
521+
result.setdefault(row["composer_id"], []).append(text)
497522
except sqlite3.Error:
498-
return []
499-
return [row["text"] for row in rows if row["text"]]
523+
return {}
524+
return result
500525

501526

502527
def query_composer_rows_in_window(

services/workspace_listing.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ def _build_workspace_projects_uncached(
133133
project_name_map=project_name_map,
134134
workspace_path_map=workspace_path_map,
135135
workspace_entries=workspace_entries,
136+
# Empty bubble map matches summary assignment (#95 perf tradeoff).
136137
bubble_map=bubble_map,
137138
composer_id_to_ws=composer_id_to_ws,
138139
invalid_workspace_ids=invalid_workspace_ids,

services/workspace_resolver.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def matching_workspace_ids_for_folder(
109109
first_folder = folders[0] if folders else None
110110
if first_folder:
111111
target_folder = normalize_file_path(first_folder)
112-
except Exception as e:
112+
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as e:
113113
warn_workspace_json_read(_logger, workspace_id, e)
114114
if not target_folder:
115115
return matching
@@ -120,7 +120,7 @@ def matching_workspace_ids_for_folder(
120120
f2 = folders2[0] if folders2 else None
121121
if f2 and normalize_file_path(f2) == target_folder:
122122
matching.add(entry["name"])
123-
except Exception as e:
123+
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as e:
124124
warn_workspace_json_read(_logger, entry["name"], e)
125125
return matching
126126

@@ -433,7 +433,7 @@ def infer_invalid_workspace_aliases(
433433
continue
434434
try:
435435
cd = json.loads(row["value"])
436-
except Exception as e:
436+
except (json.JSONDecodeError, TypeError, ValueError) as e:
437437
_logger.warning(
438438
"Failed to decode Composer from composerData:%s: %s",
439439
cid,

services/workspace_tabs.py

Lines changed: 41 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@
6767
)
6868
from utils.workspace_path import get_cli_chats_path
6969
from services.workspace_resolver import (
70-
determine_project_for_conversation,
7170
infer_invalid_workspace_aliases,
7271
lookup_workspace_display_name,
7372
matching_workspace_ids_for_folder,
@@ -576,38 +575,18 @@ def assemble_single_tab(
576575
return {"error": "Conversation not found"}, 404
577576

578577
row = rows[0]
579-
try:
580-
parsed = json.loads(row["value"])
581-
except (json.JSONDecodeError, TypeError, ValueError) as e:
582-
payload_len, payload_fp = _kv_payload_log_meta(row["value"])
583-
_logger.warning(
584-
"Failed to decode Composer from composerData:%s: %s (payload_len=%d, payload_sha256=%s)",
585-
composer_id,
586-
e,
587-
payload_len,
588-
payload_fp,
589-
)
590-
return {"error": "Failed to parse conversation"}, 500
591-
try:
592-
composer = Composer.from_dict(parsed, composer_id=composer_id)
593-
except SchemaError as e:
594-
_logger.warning(
595-
"Failed to parse Composer from composerData:%s: %s",
596-
composer_id,
597-
e,
598-
)
599-
return {"error": "Failed to parse conversation"}, 500
578+
composer = parse_composer_data_row(
579+
row["key"], row["value"], parse_warnings=parse_warnings,
580+
)
581+
if composer is None:
582+
return {"error": "Conversation not found"}, 404
600583

601-
# Verify the conversation belongs to the requested workspace.
602-
# Always scoped: only load messageRequestContext rows for this composer.
603584
project_layouts_map: dict[str, list[str]] = {}
604585
invalid_workspace_aliases: dict[str, str] = {}
605586
project_layouts_map[composer_id] = load_project_layouts_for_composer(
606587
global_db, composer_id,
607588
)
608589
if invalid_workspace_ids:
609-
# Alias resolution still needs the composer roster, but project layouts
610-
# are intentionally limited to this composer (single-tab scope).
611590
composer_rows_for_aliases = safe_fetchall(global_db, COMPOSER_ROWS_WITH_HEADERS_SQL)
612591
invalid_workspace_aliases = infer_invalid_workspace_aliases(
613592
composer_rows=composer_rows_for_aliases,
@@ -620,23 +599,24 @@ def assemble_single_tab(
620599
invalid_workspace_ids=invalid_workspace_ids,
621600
)
622601

623-
pid = determine_project_for_conversation(
624-
composer, composer_id, project_layouts_map,
625-
project_name_map, workspace_path_map,
626-
workspace_entries, {}, composer_id_to_ws, invalid_workspace_ids,
602+
bubble_map = load_bubbles_for_composer(
603+
global_db, composer_id, parse_warnings=parse_warnings,
604+
)
605+
assigned = assign_composer_workspace(
606+
composer,
607+
project_layouts_map=project_layouts_map,
608+
project_name_map=project_name_map,
609+
workspace_path_map=workspace_path_map,
610+
workspace_entries=workspace_entries,
611+
bubble_map=bubble_map,
612+
composer_id_to_ws=composer_id_to_ws,
613+
invalid_workspace_ids=invalid_workspace_ids,
614+
invalid_workspace_aliases=invalid_workspace_aliases,
627615
)
628-
mapped_ws = composer_id_to_ws.get(composer_id)
629-
if not pid and mapped_ws in invalid_workspace_ids:
630-
pid = invalid_workspace_aliases.get(mapped_ws)
631-
assigned = pid if pid else "global"
632616

633617
if assigned not in matching_ws_ids:
634618
return {"error": "Conversation not found"}, 404
635619

636-
# Scoped loads — only rows for this composer_id.
637-
bubble_map = load_bubbles_for_composer(
638-
global_db, composer_id, parse_warnings=parse_warnings
639-
)
640620
contexts = load_message_request_context_for_composer(global_db, composer_id)
641621
code_block_diffs = load_code_block_diffs_for_composer(global_db, composer_id)
642622

@@ -743,16 +723,18 @@ def assemble_workspace_tabs(
743723
# Get composer data entries with conversations
744724
composer_rows = safe_fetchall(global_db, COMPOSER_ROWS_WITH_HEADERS_SQL)
745725

746-
invalid_workspace_aliases = infer_invalid_workspace_aliases(
747-
composer_rows=composer_rows,
748-
project_layouts_map=project_layouts_map,
749-
project_name_map=project_name_map,
750-
workspace_path_map=workspace_path_map,
751-
workspace_entries=workspace_entries,
752-
bubble_map=bubble_map,
753-
composer_id_to_ws=composer_id_to_ws,
754-
invalid_workspace_ids=invalid_workspace_ids,
755-
)
726+
invalid_workspace_aliases: dict[str, str] = {}
727+
if invalid_workspace_ids:
728+
invalid_workspace_aliases = infer_invalid_workspace_aliases(
729+
composer_rows=composer_rows,
730+
project_layouts_map=project_layouts_map,
731+
project_name_map=project_name_map,
732+
workspace_path_map=workspace_path_map,
733+
workspace_entries=workspace_entries,
734+
bubble_map=bubble_map,
735+
composer_id_to_ws=composer_id_to_ws,
736+
invalid_workspace_ids=invalid_workspace_ids,
737+
)
756738

757739
for row in composer_rows:
758740
composer = parse_composer_data_row(
@@ -762,16 +744,18 @@ def assemble_workspace_tabs(
762744
continue
763745
composer_id = composer.composer_id
764746
try:
765-
# Determine project
766-
pid = determine_project_for_conversation(
767-
composer, composer_id, project_layouts_map,
768-
project_name_map, workspace_path_map,
769-
workspace_entries, bubble_map, composer_id_to_ws, invalid_workspace_ids,
747+
assigned = assign_composer_workspace(
748+
composer,
749+
project_layouts_map=project_layouts_map,
750+
project_name_map=project_name_map,
751+
workspace_path_map=workspace_path_map,
752+
workspace_entries=workspace_entries,
753+
# Assignment matches summary path; bubble_map used only for tab body.
754+
bubble_map={},
755+
composer_id_to_ws=composer_id_to_ws,
756+
invalid_workspace_ids=invalid_workspace_ids,
757+
invalid_workspace_aliases=invalid_workspace_aliases,
770758
)
771-
mapped_ws = composer_id_to_ws.get(composer_id)
772-
if not pid and mapped_ws in invalid_workspace_ids:
773-
pid = invalid_workspace_aliases.get(mapped_ws)
774-
assigned = pid if pid else "global"
775759

776760
if assigned not in matching_ws_ids:
777761
continue

tests/test_workspace_list_count_alignment.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313
from services.workspace_composer_scan import parse_composer_data_row
1414
from services.workspace_db import build_composer_id_to_workspace_id, collect_workspace_entries
1515
from services.workspace_listing import list_workspace_projects
16-
from services.workspace_tabs import list_workspace_tab_summaries
16+
from services.workspace_tabs import (
17+
assemble_single_tab,
18+
assemble_workspace_tabs,
19+
list_workspace_tab_summaries,
20+
)
1721
from tests._fixture_ids import HAPPY_COMPOSER_ID, HAPPY_WORKSPACE_ID
1822

1923

@@ -109,6 +113,45 @@ def test_parallel_composer_registry_covers_all_workspaces(tmp_path):
109113
assert mapping[f"cmp-{i}"] == ws_id
110114

111115

116+
def test_summary_and_full_tabs_share_assignment(tmp_path):
117+
"""Summary and full /tabs agree on which composers belong after assignment."""
118+
ws_root = _layout(tmp_path)
119+
global_db = os.path.join(tmp_path, "globalStorage", "state.vscdb")
120+
with contextlib.closing(sqlite3.connect(global_db)) as conn:
121+
conn.execute(
122+
"INSERT INTO cursorDiskKV ([key], value) VALUES (?, ?)",
123+
(
124+
f"bubbleId:{HAPPY_COMPOSER_ID}:b1",
125+
json.dumps({"type": "user", "text": "hello", "bubbleId": "b1"}),
126+
),
127+
)
128+
conn.commit()
129+
130+
summary, summary_status = list_workspace_tab_summaries(
131+
HAPPY_WORKSPACE_ID, ws_root, rules=[], nocache=True,
132+
)
133+
full, full_status = assemble_workspace_tabs(
134+
HAPPY_WORKSPACE_ID, ws_root, rules=[],
135+
)
136+
assert summary_status == 200
137+
assert full_status == 200
138+
summary_ids = {t["id"] for t in summary["tabs"]}
139+
full_ids = {t["id"] for t in full["tabs"]}
140+
assert summary_ids == full_ids
141+
142+
143+
def test_single_tab_null_composer_placeholder_returns_404(tmp_path):
144+
ws_root = _layout(
145+
tmp_path,
146+
extra_rows=[("composerData:empty-state-draft", None)],
147+
)
148+
payload, status = assemble_single_tab(
149+
"global", "empty-state-draft", ws_root, rules=[],
150+
)
151+
assert status == 404
152+
assert "error" in payload
153+
154+
112155
def test_excluded_model_dropped_from_list_and_summary(tmp_path):
113156
ws_root = _layout(tmp_path)
114157
rules = [["gpt-4o"]]

0 commit comments

Comments
 (0)