diff --git a/src/ctx_monitor.py b/src/ctx_monitor.py index a9cbf52..de00ed9 100644 --- a/src/ctx_monitor.py +++ b/src/ctx_monitor.py @@ -91,11 +91,20 @@ _MONITOR_MUTATIONS_ENABLED = True _GRAPH_CACHE_KEY: tuple[Any, ...] | None = None _GRAPH_CACHE_VALUE: Any | None = None +_PACKAGED_GRAPH_EXPORT_ID_CACHE: str | None | bool = None _OVERLAY_INDEX_COVERAGE_CACHE_KEY: tuple[Any, ...] | None = None _OVERLAY_INDEX_COVERAGE_CACHE_VALUE: bool | None = None _SIDECAR_INDEX_CACHE_KEY: tuple[tuple[Path, float, int], ...] | None = None _SIDECAR_INDEX_CACHE_VALUE: dict[tuple[str, str], dict] | None = None +_SIDECAR_FILTER_CACHE_SIGNATURE: tuple[Any, ...] | None = None +_SIDECAR_FILTER_CACHE_VALUE: dict[tuple[Any, ...], list[dict[str, Any]]] = {} +_KPI_SUMMARY_CACHE_KEY: tuple[Any, ...] | None = None +_KPI_SUMMARY_CACHE_VALUE: Any | None = None +_KPI_SUMMARY_CACHE_AT = 0.0 _WIKI_INDEX_LIMIT_PER_TYPE = 500 +_SKILLS_PAGE_DEFAULT_LIMIT = 100 +_SKILLS_PAGE_MAX_LIMIT = 500 +_KPI_SUMMARY_CACHE_SECONDS = 30 _GRAPH_REPORT_RE = re.compile(r"Nodes:\s*([\d,]+)\s*\|\s*Edges:\s*([\d,]+)") _MAX_POST_BODY_BYTES = 64 * 1024 _DASHBOARD_INDEX_MEMBER = "graphify-out/dashboard-neighborhoods.sqlite3" @@ -1772,6 +1781,231 @@ def _all_sidecars() -> list[dict]: return list(_sidecar_index().values()) +def _skills_page_int( + value: str | None, + *, + default: int, + minimum: int = 1, + maximum: int | None = None, +) -> int: + try: + parsed = int(str(value or "").strip()) + except ValueError: + parsed = default + parsed = max(minimum, parsed) + if maximum is not None: + parsed = min(maximum, parsed) + return parsed + + +def _skills_query_values(raw: str | None, allowed: set[str]) -> set[str]: + values = { + item.strip() + for item in str(raw or "").split(",") + if item.strip() + } + return {item for item in values if item in allowed} + + +def _sidecar_sort_key(sidecar: dict) -> tuple[str, float, str]: + return ( + str(sidecar.get("grade") or "F"), + -float(sidecar.get("raw_score") or sidecar.get("score") or 0.0), + str(sidecar.get("slug") or ""), + ) + + +def _sidecar_card_payload(sidecar: dict) -> dict[str, Any]: + slug = str(sidecar.get("slug") or "") + entity_type = _sidecar_entity_type(sidecar) + return { + "slug": slug, + "grade": str(sidecar.get("grade") or "F"), + "type": entity_type, + "hard_floor": str(sidecar.get("hard_floor") or ""), + "raw_score": float(sidecar.get("raw_score") or sidecar.get("score") or 0.0), + "sidecar_href": f"/skill/{quote(slug)}?type={quote(entity_type)}", + "wiki_href": f"/wiki/{quote(slug)}?type={quote(entity_type)}", + "graph_href": f"/graph?slug={quote(slug)}&type={quote(entity_type)}", + } + + +def _sidecar_filter_signature(files: list[Path]) -> tuple[Any, ...]: + roots = (_sidecar_dir(), _sidecar_dir() / "mcp") + root_counts = { + root.resolve(): sum(1 for path in files if path.parent == root) + for root in roots + } + signature: list[tuple[str, int, int]] = [] + for root in roots: + if not root.is_dir(): + signature.append((str(root.resolve()), 0, 0)) + continue + stat = root.stat() + signature.append(( + str(root.resolve()), + int(getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000))), + root_counts.get(root.resolve(), 0), + )) + return tuple(signature) + + +def _sidecar_candidate_files( + files: list[Path], + *, + q: str, + types: set[str], +) -> list[Path]: + q_lower = q.lower() + candidates = [ + path for path in files + if not q_lower or q_lower in path.stem.lower() + ] + if not types: + return candidates + if types == {"mcp-server"}: + return [path for path in candidates if path.parent.name == "mcp"] + if "mcp-server" not in types: + return [path for path in candidates if path.parent.name != "mcp"] + return candidates + + +def _filtered_sidecar_records( + files: list[Path], + *, + q: str, + types: set[str], + grades: set[str], + hide_floor: bool, +) -> list[dict[str, Any]]: + """Return cached filtered sidecar card records for /skills search.""" + global _SIDECAR_FILTER_CACHE_SIGNATURE, _SIDECAR_FILTER_CACHE_VALUE + + signature = _sidecar_filter_signature(files) + if _SIDECAR_FILTER_CACHE_SIGNATURE != signature: + _SIDECAR_FILTER_CACHE_SIGNATURE = signature + _SIDECAR_FILTER_CACHE_VALUE = {} + cache_key = ( + q.lower(), + tuple(sorted(types)), + tuple(sorted(grades)), + hide_floor, + ) + cached = _SIDECAR_FILTER_CACHE_VALUE.get(cache_key) + if cached is not None: + return cached + + records: list[dict[str, Any]] = [] + for path in _sidecar_candidate_files(files, q=q, types=types): + sidecar = _read_sidecar_file(path) + if sidecar is None: + continue + if not _sidecar_matches_filters( + sidecar, + q=q, + types=types, + grades=grades, + hide_floor=hide_floor, + ): + continue + records.append(_sidecar_card_payload(sidecar)) + records.sort(key=_sidecar_sort_key) + if len(_SIDECAR_FILTER_CACHE_VALUE) >= 32: + _SIDECAR_FILTER_CACHE_VALUE.clear() + _SIDECAR_FILTER_CACHE_VALUE[cache_key] = records + return records + + +def _sidecar_matches_filters( + sidecar: dict, + *, + q: str, + types: set[str], + grades: set[str], + hide_floor: bool, +) -> bool: + entity_type = _sidecar_entity_type(sidecar) + grade = str(sidecar.get("grade") or "F") + floor = str(sidecar.get("hard_floor") or "") + if types and entity_type not in types: + return False + if grades and grade not in grades: + return False + if hide_floor and floor: + return False + if q: + return q.lower() in str(sidecar.get("slug") or "").lower() + return True + + +def _sidecar_page_payload(qs: dict[str, str] | None = None) -> dict[str, Any]: + """Return a paginated sidecar payload for /skills and its JSON API.""" + qs = qs or {} + page = _skills_page_int(qs.get("page"), default=1) + limit = _skills_page_int( + qs.get("limit"), + default=_SKILLS_PAGE_DEFAULT_LIMIT, + maximum=_SKILLS_PAGE_MAX_LIMIT, + ) + q = str(qs.get("q") or "").strip() + types = _skills_query_values(qs.get("type"), set(_DASHBOARD_ENTITY_TYPES)) + grades = _skills_query_values(qs.get("grade"), {"A", "B", "C", "D", "F"}) + hide_floor = str(qs.get("hide_floor") or "").strip().lower() in { + "1", "true", "yes", "on", + } + + files = _sidecar_files() + catalog_total = len(files) + has_filters = bool(q or types or grades or hide_floor) + if has_filters: + sidecars = _filtered_sidecar_records( + files, + q=q, + types=types, + grades=grades, + hide_floor=hide_floor, + ) + total = len(sidecars) + start = (page - 1) * limit + page_sidecars = sidecars[start:start + limit] + else: + total = catalog_total + start = (page - 1) * limit + selected_files = files[start:start + limit] + page_sidecars = [ + sidecar + for path in selected_files + if (sidecar := _read_sidecar_file(path)) is not None + ] + if catalog_total <= limit: + page_sidecars.sort(key=_sidecar_sort_key) + + pages = max(1, math.ceil(total / limit)) if total else 1 + if page > pages: + page = pages + return _sidecar_page_payload({ + **qs, + "page": str(page), + "limit": str(limit), + }) + + return { + "items": [_sidecar_card_payload(sidecar) for sidecar in page_sidecars], + "total": total, + "catalog_total": catalog_total, + "page": page, + "limit": limit, + "pages": pages, + "has_next": page < pages, + "has_prev": page > 1, + "filtered": has_filters, + "q": q, + "types": sorted(types), + "grades": sorted(grades), + "hide_floor": hide_floor, + } + + # ─── Aggregations ──────────────────────────────────────────────────────────── @@ -2623,6 +2857,30 @@ def _dashboard_graph_index_archives() -> list[Path]: return archives +def _packaged_graph_export_id() -> str | None: + global _PACKAGED_GRAPH_EXPORT_ID_CACHE + if isinstance(_PACKAGED_GRAPH_EXPORT_ID_CACHE, bool): + return None + if isinstance(_PACKAGED_GRAPH_EXPORT_ID_CACHE, str): + return _PACKAGED_GRAPH_EXPORT_ID_CACHE + module_root = Path(__file__).resolve().parent.parent + try: + data = json.loads( + (module_root / "graph" / "communities.json").read_text( + encoding="utf-8", + ) + ) + except (OSError, json.JSONDecodeError): + _PACKAGED_GRAPH_EXPORT_ID_CACHE = False + return None + export_id = data.get("export_id") if isinstance(data, dict) else None + if isinstance(export_id, str) and export_id.strip(): + _PACKAGED_GRAPH_EXPORT_ID_CACHE = export_id.strip() + return export_id.strip() + _PACKAGED_GRAPH_EXPORT_ID_CACHE = False + return None + + def _archive_graph_export_id(archive: Path) -> str | None: try: with tarfile.open(archive, "r:gz") as tar: @@ -2652,13 +2910,20 @@ def _ensure_dashboard_graph_index() -> Path | None: target.unlink() except OSError: return None - if (_wiki_dir() / "graphify-out" / "graph-report.md").is_file(): + + manifest_export_id = _dashboard_graph_manifest_export_id() + packaged_export_id = _packaged_graph_export_id() + if ( + manifest_export_id is not None + and packaged_export_id is not None + and manifest_export_id != packaged_export_id + ): return None archives = _dashboard_graph_index_archives() if not archives: return None - if _dashboard_graph_manifest_export_id() is None: + if manifest_export_id is None: return None target.parent.mkdir(parents=True, exist_ok=True) @@ -2672,8 +2937,7 @@ def _ensure_dashboard_graph_index() -> Path | None: except OSError: return None for archive in archives: - archive_export_id = _archive_graph_export_id(archive) - manifest_export_id = _dashboard_graph_manifest_export_id() + archive_export_id = packaged_export_id or _archive_graph_export_id(archive) if manifest_export_id and archive_export_id and archive_export_id != manifest_export_id: continue try: @@ -2992,6 +3256,15 @@ def _graph_neighborhood( ) if indexed is not None: return indexed + manifest_export_id = _dashboard_graph_manifest_export_id() + packaged_export_id = _packaged_graph_export_id() + if ( + not _dashboard_graph_index_path().is_file() + and manifest_export_id is not None + and packaged_export_id is not None + and manifest_export_id != packaged_export_id + ): + return {"nodes": [], "edges": [], "center": None} try: G = _load_dashboard_graph() except Exception: # noqa: BLE001 — graph is advisory; blank on error @@ -3418,17 +3691,9 @@ def _render_session_detail(session_id: str) -> str: return _layout(f"Session {session_id}", body) -def _render_skills() -> str: - sidecars = _all_sidecars() - sidecars.sort(key=lambda s: (s.get("grade", "F"), -s.get("raw_score", 0.0))) - - # Sidebar stats for the filter UI. - grade_counts = {"A": 0, "B": 0, "C": 0, "D": 0, "F": 0} - type_counts = {entity_type: 0 for entity_type in _DASHBOARD_ENTITY_TYPES} - for sc in sidecars: - grade_counts[sc.get("grade", "F")] = grade_counts.get(sc.get("grade", "F"), 0) + 1 - st = _sidecar_entity_type(sc) - type_counts[st] = type_counts.get(st, 0) + 1 +def _render_skills(qs: dict[str, str] | None = None) -> str: + payload = _sidecar_page_payload(qs) + sidecars = payload["items"] cards = "".join( f"
str: f"{html.escape(s.get('grade', 'F'))}" f"
" f"
" - f"score {s.get('raw_score', 0.0):.3f} · {html.escape(s.get('subject_type', 'skill'))}" + f"score {s.get('raw_score', 0.0):.3f} · {html.escape(s.get('type', s.get('subject_type', 'skill')))}" f"{' · ' + html.escape(s.get('hard_floor','')) if s.get('hard_floor') else ''}" f"
" f"
" @@ -3454,45 +3719,92 @@ def _render_skills() -> str: for s in sidecars ) - grade_checkboxes = "".join( - f"" - for g in ("A", "B", "C", "D", "F") + start_index = ((payload["page"] - 1) * payload["limit"]) + 1 if payload["total"] else 0 + end_index = min(payload["page"] * payload["limit"], payload["total"]) + summary = ( + f"Showing {start_index}-{end_index} of {payload['total']} matching sidecars" + if payload["filtered"] + else f"Showing {start_index}-{end_index} of {payload['catalog_total']} sidecars" ) - type_checkboxes = "".join( - f"" + query_base = { + key: value + for key, value in (qs or {}).items() + if key not in {"page"} + } + + def page_href(page: int) -> str: + params = { + **query_base, + "page": str(max(1, page)), + "limit": str(payload["limit"]), + } + query = "&".join( + f"{quote(str(key))}={quote(str(value))}" + for key, value in params.items() + if str(value).strip() + ) + return "/skills" + (f"?{query}" if query else "") + + prev_link = ( + f"previous" + if payload["has_prev"] + else "previous" + ) + next_link = ( + f"next" + if payload["has_next"] + else "next" + ) + pagination = ( + "
" + f"{html.escape(summary)} · page " + f"{payload['page']} of {payload['pages']}" + f"{prev_link} · {next_link}" + "
" + ) + selected_type = ",".join(payload["types"]) + selected_grade = ",".join(payload["grades"]) + type_options = "" + "".join( + f"" for t in _DASHBOARD_ENTITY_TYPES ) + grade_options = "" + "".join( + f"" + for g in ("A", "B", "C", "D", "F") + ) + limit_options = "".join( + f"" + for n in (50, 100, 200, 500) + ) + hide_checked = " checked" if payload["hide_floor"] else "" body = ( "

Quality sidecars

" - f"

{len(sidecars)} sidecars · click any card to drill in.

" - "
" + f"

{payload['catalog_total']} sidecars · click any card to drill in.

" + + pagination + + "
" # ── Left filter sidebar ────────────────────────────────────── "" # ── Card grid ──────────────────────────────────────────────── "
x.value); }\n" - "function activeTypes() { return Array.from(document.querySelectorAll('.type-filter:checked')).map(x => x.value); }\n" - "function apply() {\n" - " const q = search.value.trim().toLowerCase();\n" - " const grades = new Set(activeGrades());\n" - " const types = new Set(activeTypes());\n" - " const hideF = hideFloor.checked;\n" - " let shown = 0;\n" - " cards.forEach(c => {\n" - " const ok = grades.has(c.dataset.grade) && types.has(c.dataset.type)\n" - " && (!q || c.dataset.slug.toLowerCase().includes(q))\n" - " && (!hideF || !c.dataset.floor);\n" - " c.style.display = ok ? '' : 'none';\n" - " if (ok) shown++;\n" - " });\n" - " document.getElementById('match-count').textContent = shown + ' of ' + cards.length + ' match';\n" - "}\n" - "search.addEventListener('input', apply);\n" - "hideFloor.addEventListener('change', apply);\n" - "document.querySelectorAll('.grade-filter, .type-filter').forEach(el => el.addEventListener('change', apply));\n" - "apply();\n" + "document.querySelectorAll('#skills-filter-form select').forEach(el => {\n" + " el.addEventListener('change', () => el.form.submit());\n" + "});\n" "" ) return _layout("Skills", body) @@ -5745,6 +6036,17 @@ def _render_harness_wizard() -> str: return _layout("Harness Setup", body) +def _kpi_summary_cache_key(sidecar_dir: Path) -> tuple[Any, ...]: + parts: list[Any] = [] + for path in (sidecar_dir, sidecar_dir / "mcp"): + try: + stat = path.stat() + parts.extend((path.resolve(), stat.st_mtime_ns, stat.st_size)) + except OSError: + parts.extend((path.resolve(), None, None)) + return tuple(parts) + + def _kpi_summary(): """Compute the KPI DashboardSummary using the default source layout. @@ -5760,6 +6062,14 @@ def _kpi_summary(): sidecar_dir = _sidecar_dir() if not sidecar_dir.is_dir(): return None + cache_key = _kpi_summary_cache_key(sidecar_dir) + global _KPI_SUMMARY_CACHE_AT, _KPI_SUMMARY_CACHE_KEY, _KPI_SUMMARY_CACHE_VALUE + if ( + _KPI_SUMMARY_CACHE_KEY == cache_key + and _KPI_SUMMARY_CACHE_VALUE is not None + and time.monotonic() - _KPI_SUMMARY_CACHE_AT < _KPI_SUMMARY_CACHE_SECONDS + ): + return _KPI_SUMMARY_CACHE_VALUE try: from ctx_config import cfg # type: ignore sources = LifecycleSources( @@ -5774,9 +6084,13 @@ def _kpi_summary(): sidecar_dir=sidecar_dir, ) try: - return generate(sources=sources, top_n=25) + summary = generate(sources=sources, top_n=25) except Exception: # noqa: BLE001 return None + _KPI_SUMMARY_CACHE_KEY = cache_key + _KPI_SUMMARY_CACHE_VALUE = summary + _KPI_SUMMARY_CACHE_AT = time.monotonic() + return summary def _render_kpi() -> str: @@ -6622,7 +6936,7 @@ def do_GET(self) -> None: # noqa: N802 — stdlib signature elif path.startswith("/session/"): self._send_html(_render_session_detail(path.split("/session/", 1)[1])) elif path == "/skills": - self._send_html(_render_skills()) + self._send_html(_render_skills(qs)) elif path.startswith("/skill/"): self._send_html(_render_skill_detail( path.split("/skill/", 1)[1], @@ -6674,6 +6988,8 @@ def do_GET(self) -> None: # noqa: N802 — stdlib signature }) elif path == "/api/grades.json": self._send_json(_grade_distribution_payload()) + elif path == "/api/sidecars.json": + self._send_json(_sidecar_page_payload(qs)) elif path == "/api/runtime.json": self._send_json(_runtime_lifecycle_summary()) elif path == "/api/config.json": diff --git a/src/kpi_dashboard.py b/src/kpi_dashboard.py index 9a59f30..c1c4367 100644 --- a/src/kpi_dashboard.py +++ b/src/kpi_dashboard.py @@ -26,6 +26,7 @@ from __future__ import annotations import argparse +import concurrent.futures import json import logging import sys @@ -41,10 +42,9 @@ STATE_ARCHIVE, STATE_DEMOTE, STATE_WATCH, - load_lifecycle_state, ) from skill_category import CATEGORIES, infer_category, read_existing_category -from skill_quality import QualityScore, load_quality +from skill_quality import QualityScore from ctx.core.wiki.wiki_utils import parse_frontmatter_and_body _logger = logging.getLogger(__name__) @@ -54,6 +54,8 @@ _LIFECYCLE_STATES: tuple[str, ...] = ( STATE_ACTIVE, STATE_WATCH, STATE_DEMOTE, STATE_ARCHIVE, ) +_PARALLEL_QUALITY_READ_THRESHOLD = 512 +_QUALITY_READ_WORKERS = 8 # ──────────────────────────────────────────────────────────────────── @@ -109,19 +111,33 @@ def to_dict(self) -> dict[str, Any]: # ──────────────────────────────────────────────────────────────────── -def _skill_source_path(slug: str, sources: LifecycleSources) -> Path | None: - skill_path = sources.skills_dir / slug / "SKILL.md" - if skill_path.is_file(): - return skill_path - agent_path = sources.agents_dir / f"{slug}.md" - if agent_path.is_file(): - return agent_path +def _skill_source_path( + slug: str, + sources: LifecycleSources, + *, + subject_type: str | None = None, +) -> Path | None: + if subject_type in (None, "skill"): + skill_path = sources.skills_dir / slug / "SKILL.md" + if skill_path.is_file(): + return skill_path + if subject_type in (None, "agent"): + agent_path = sources.agents_dir / f"{slug}.md" + if agent_path.is_file(): + return agent_path return None -def _resolve_category(slug: str, sources: LifecycleSources) -> str: +def _resolve_category( + slug: str, + sources: LifecycleSources, + *, + subject_type: str | None = None, +) -> str: """Read existing category, else infer from tags, else uncategorized.""" - path = _skill_source_path(slug, sources) + if subject_type not in (None, "skill", "agent"): + return _UNCATEGORIZED + path = _skill_source_path(slug, sources, subject_type=subject_type) if path is None: return _UNCATEGORIZED try: @@ -165,18 +181,40 @@ def _iter_quality_slugs(sidecar_dir: Path) -> list[str]: return out -def _quality_sources(sidecar_dir: Path) -> list[tuple[str, Path]]: - out: list[tuple[str, Path]] = [ - (slug, sidecar_dir) +def _quality_sources(sidecar_dir: Path) -> list[tuple[str, Path, Path]]: + out: list[tuple[str, Path, Path]] = [ + (slug, sidecar_dir, sidecar_dir / f"{slug}.json") for slug in _iter_quality_slugs(sidecar_dir) ] mcp_dir = sidecar_dir / "mcp" if mcp_dir.is_dir(): for slug in _iter_quality_slugs(mcp_dir): - out.append((slug, mcp_dir)) + out.append((slug, mcp_dir, mcp_dir / f"{slug}.json")) return out +def _read_quality_file( + path: Path, + *, + subject_type_override: str | None = None, +) -> QualityScore | None: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"quality sidecar must be a JSON object: {path}") + subject_type = subject_type_override or str(data.get("subject_type") or "skill") + return QualityScore( + slug=str(data["slug"]), + subject_type=subject_type, + raw_score=float(data.get("raw_score", 0.0)), + score=float(data.get("score", 0.0)), + grade=str(data.get("grade") or "D"), + hard_floor=data.get("hard_floor"), + signals={}, + weights={}, + computed_at=str(data.get("computed_at") or ""), + ) + + def _iter_lifecycle_slugs(sidecar_dir: Path) -> list[str]: if not sidecar_dir.is_dir(): return [] @@ -184,6 +222,44 @@ def _iter_lifecycle_slugs(sidecar_dir: Path) -> list[str]: return sorted(p.name[: -len(suffix)] for p in sidecar_dir.glob(f"*{suffix}")) +def _read_lifecycle_file(path: Path) -> LifecycleState | None: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + if not isinstance(data, dict): + return None + history_raw = data.get("history", []) + history = tuple( + dict(e) for e in history_raw if isinstance(e, dict) + ) + try: + streak = int(data.get("consecutive_d_count", 0)) + except (TypeError, ValueError): + streak = 0 + return LifecycleState( + slug=str(data.get("slug") or path.name.removesuffix(".lifecycle.json")), + subject_type=str(data.get("subject_type") or "skill"), + state=str(data.get("state") or STATE_ACTIVE), + state_since=str(data.get("state_since") or ""), + consecutive_d_count=streak, + last_grade=str(data.get("last_grade") or ""), + last_seen_computed_at=str(data.get("last_seen_computed_at") or ""), + history=history, + ) + + +def _load_lifecycle_states(sidecar_dir: Path) -> dict[str, LifecycleState]: + if not sidecar_dir.is_dir(): + return {} + states: dict[str, LifecycleState] = {} + for path in sorted(sidecar_dir.glob("*.lifecycle.json")): + state = _read_lifecycle_file(path) + if state is not None: + states[state.slug] = state + return states + + def _build_row( slug: str, *, @@ -201,7 +277,7 @@ def _build_row( return EntityRow( slug=slug, subject_type=subject, - category=_resolve_category(slug, sources), + category=_resolve_category(slug, sources, subject_type=subject), grade=(score.grade if score is not None else ""), score=(score.score if score is not None else 0.0), hard_floor=(score.hard_floor if score is not None else None), @@ -224,28 +300,51 @@ def collect_rows( *, sources: LifecycleSources, ) -> list[EntityRow]: """Walk both sinks and return one row per known slug (union).""" - quality_rows: list[tuple[str, Path, QualityScore | None, LifecycleState | None]] = [] - quality_subjects: set[tuple[str, str]] = set() - for slug, sidecar_dir in _quality_sources(sources.sidecar_dir): + lifecycle_cache: dict[Path, dict[str, LifecycleState]] = {} + + def lifecycle_states(sidecar_dir: Path) -> dict[str, LifecycleState]: + if sidecar_dir not in lifecycle_cache: + lifecycle_cache[sidecar_dir] = _load_lifecycle_states(sidecar_dir) + return lifecycle_cache[sidecar_dir] + + def load_quality_source( + source: tuple[str, Path, Path], + ) -> tuple[str, Path, QualityScore | None]: + slug, sidecar_dir, sidecar_path = source try: - score = load_quality( - slug, - sidecar_dir=sidecar_dir, + score = _read_quality_file( + sidecar_path, + subject_type_override=( + "mcp-server" if sidecar_dir.name == "mcp" else None + ), ) - except (json.JSONDecodeError, ValueError, OSError) as exc: + except (json.JSONDecodeError, ValueError, OSError, KeyError, TypeError) as exc: _logger.warning("kpi_dashboard: skipping %s: %s", slug, exc) score = None + return slug, sidecar_dir, score + + quality_sources = _quality_sources(sources.sidecar_dir) + if len(quality_sources) >= _PARALLEL_QUALITY_READ_THRESHOLD: + with concurrent.futures.ThreadPoolExecutor( + max_workers=_QUALITY_READ_WORKERS, + ) as pool: + quality_results = list(pool.map(load_quality_source, quality_sources)) + else: + quality_results = [load_quality_source(source) for source in quality_sources] + + quality_rows: list[tuple[str, Path, QualityScore | None, LifecycleState | None]] = [] + quality_subjects: set[tuple[str, str]] = set() + for slug, sidecar_dir, score in quality_results: if score is not None: quality_subjects.add((slug, score.subject_type)) quality_rows.append((slug, sidecar_dir, score, None)) lifecycle_rows: list[tuple[str, Path, QualityScore | None, LifecycleState | None]] = [] - for slug in _iter_lifecycle_slugs(sources.sidecar_dir): - lc = load_lifecycle_state(slug, sidecar_dir=sources.sidecar_dir) - if lc is None: - continue - if (slug, lc.subject_type) not in quality_subjects: - lifecycle_rows.append((slug, sources.sidecar_dir, None, lc)) + for lifecycle_slug, lifecycle_state in lifecycle_states(sources.sidecar_dir).items(): + if (lifecycle_slug, lifecycle_state.subject_type) not in quality_subjects: + lifecycle_rows.append( + (lifecycle_slug, sources.sidecar_dir, None, lifecycle_state) + ) row_sources = sorted( quality_rows + lifecycle_rows, @@ -259,12 +358,12 @@ def collect_rows( if sidecar_dir != sources.sidecar_dir: candidates.append(sources.sidecar_dir) for candidate_dir in candidates: - candidate = load_lifecycle_state(slug, sidecar_dir=candidate_dir) + candidate = lifecycle_states(candidate_dir).get(slug) if candidate is not None and candidate.subject_type == score.subject_type: lc = candidate break elif lc is None: - lc = load_lifecycle_state(slug, sidecar_dir=sidecar_dir) + lc = lifecycle_states(sidecar_dir).get(slug) if lc is not None: state = lc.state streak = lc.consecutive_d_count diff --git a/src/tests/test_ctx_monitor.py b/src/tests/test_ctx_monitor.py index 72488e4..b58eecb 100644 --- a/src/tests/test_ctx_monitor.py +++ b/src/tests/test_ctx_monitor.py @@ -29,6 +29,13 @@ def fake_claude(tmp_path: Path, monkeypatch) -> Path: (claude / "skill-quality").mkdir(parents=True) monkeypatch.setattr(cm, "_claude_dir", lambda: claude) monkeypatch.setattr(cm, "_dashboard_graph_index_archives", lambda: []) + monkeypatch.setattr(cm, "_SIDECAR_INDEX_CACHE_KEY", None) + monkeypatch.setattr(cm, "_SIDECAR_INDEX_CACHE_VALUE", None) + monkeypatch.setattr(cm, "_SIDECAR_FILTER_CACHE_SIGNATURE", None) + monkeypatch.setattr(cm, "_SIDECAR_FILTER_CACHE_VALUE", {}) + monkeypatch.setattr(cm, "_KPI_SUMMARY_CACHE_KEY", None) + monkeypatch.setattr(cm, "_KPI_SUMMARY_CACHE_VALUE", None) + monkeypatch.setattr(cm, "_KPI_SUMMARY_CACHE_AT", 0.0) return claude @@ -300,7 +307,8 @@ def test_render_skills_includes_harness_filter_and_typed_links(fake_claude: Path html = cm._render_skills() - assert "class='type-filter' value='harness'" in html + assert "class='type-filter'" in html + assert "value='harness'" in html assert "/skill/langgraph?type=harness" in html assert "/wiki/langgraph?type=harness" in html assert "/graph?slug=langgraph&type=harness" in html @@ -2067,6 +2075,7 @@ def test_graph_neighborhood_extracts_missing_dashboard_index_from_archive( tar.add(seed, arcname="./graphify-out/dashboard-neighborhoods.sqlite3") monkeypatch.setattr(cm, "_dashboard_graph_index_archives", lambda: [archive]) + monkeypatch.setattr(cm, "_packaged_graph_export_id", lambda: "archive-export") monkeypatch.setattr( cm, "_load_dashboard_graph", @@ -2103,6 +2112,7 @@ def test_dashboard_index_extraction_skips_archive_export_mismatch( tar.add(seed, arcname="./graphify-out/dashboard-neighborhoods.sqlite3") monkeypatch.setattr(cm, "_dashboard_graph_index_archives", lambda: [archive]) + monkeypatch.setattr(cm, "_packaged_graph_export_id", lambda: None) monkeypatch.setattr( cm, "_dashboard_index_matches_manifest", @@ -2115,20 +2125,17 @@ def test_dashboard_index_extraction_skips_archive_export_mismatch( assert not (graph_dir / "dashboard-neighborhoods.sqlite3").exists() -def test_dashboard_index_extraction_skips_installed_graph_report( +def test_dashboard_index_extraction_skips_packaged_export_mismatch( fake_claude: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: graph_dir = fake_claude / "skill-wiki" / "graphify-out" graph_dir.mkdir(parents=True) (graph_dir / "graph-export-manifest.json").write_text( - json.dumps({"version": 1, "export_id": "local-export"}), - encoding="utf-8", - ) - (graph_dir / "graph-report.md").write_text( - "> Nodes: 12 | Edges: 34 | Communities: 2\n", + json.dumps({"version": 1, "export_id": "old-local-export"}), encoding="utf-8", ) + monkeypatch.setattr(cm, "_packaged_graph_export_id", lambda: "new-packaged-export") monkeypatch.setattr( cm, "_dashboard_graph_index_archives", @@ -2138,6 +2145,68 @@ def test_dashboard_index_extraction_skips_installed_graph_report( assert cm._ensure_dashboard_graph_index() is None +def test_graph_neighborhood_skips_full_graph_on_packaged_export_mismatch( + fake_claude: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + graph_dir = fake_claude / "skill-wiki" / "graphify-out" + graph_dir.mkdir(parents=True) + (graph_dir / "graph-export-manifest.json").write_text( + json.dumps({"version": 1, "export_id": "old-local-export"}), + encoding="utf-8", + ) + monkeypatch.setattr(cm, "_packaged_graph_export_id", lambda: "new-packaged-export") + monkeypatch.setattr( + cm, + "_load_dashboard_graph", + lambda: (_ for _ in ()).throw(AssertionError("full graph loaded")), + ) + + assert cm._graph_neighborhood("github", entity_type="mcp-server") == { + "nodes": [], + "edges": [], + "center": None, + } + + +def test_dashboard_index_extraction_works_with_installed_graph_report( + fake_claude: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + graph_dir = fake_claude / "skill-wiki" / "graphify-out" + graph_dir.mkdir(parents=True) + (graph_dir / "graph-export-manifest.json").write_text( + json.dumps({"version": 1, "export_id": "local-export"}), + encoding="utf-8", + ) + (graph_dir / "graph-report.md").write_text( + "> Nodes: 12 | Edges: 34 | Communities: 2\n", + encoding="utf-8", + ) + seed = tmp_path / "dashboard-neighborhoods.sqlite3" + conn = sqlite3.connect(seed) + try: + conn.execute("CREATE TABLE meta(key TEXT PRIMARY KEY, value TEXT NOT NULL)") + conn.execute("INSERT INTO meta VALUES(?,?)", ("export_id", json.dumps("local-export"))) + conn.commit() + finally: + conn.close() + archive_manifest = tmp_path / "graph-export-manifest.json" + archive_manifest.write_text( + json.dumps({"version": 1, "export_id": "local-export"}), + encoding="utf-8", + ) + archive = tmp_path / "wiki-graph-runtime.tar.gz" + with tarfile.open(archive, "w:gz") as tar: + tar.add(archive_manifest, arcname="./graphify-out/graph-export-manifest.json") + tar.add(seed, arcname="./graphify-out/dashboard-neighborhoods.sqlite3") + monkeypatch.setattr(cm, "_dashboard_graph_index_archives", lambda: [archive]) + monkeypatch.setattr(cm, "_packaged_graph_export_id", lambda: "local-export") + + assert cm._ensure_dashboard_graph_index() == graph_dir / "dashboard-neighborhoods.sqlite3" + + def test_graph_neighborhood_bypasses_archive_index_when_runtime_overlays_exist( fake_claude: Path, monkeypatch: pytest.MonkeyPatch, @@ -2193,6 +2262,7 @@ def test_graph_neighborhood_rejects_stale_dashboard_index( G = nx.Graph() G.add_node("skill:fallback", label="fallback", type="skill", tags=[]) + monkeypatch.setattr(cm, "_packaged_graph_export_id", lambda: None) monkeypatch.setattr(cm, "_load_dashboard_graph", lambda: G) result = cm._graph_neighborhood("fallback", entity_type="skill") @@ -2345,7 +2415,7 @@ def test_render_skills_emits_sidebar_filters(fake_claude: Path) -> None: "subject_type": "agent", "hard_floor": "intake_fail"}) html_out = cm._render_skills() - # Sidebar must expose a text search + grade checkboxes + type checkboxes. + # Sidebar must expose text search plus grade/type filters. assert "id='skill-search'" in html_out assert "class='grade-filter'" in html_out assert "class='type-filter'" in html_out @@ -2357,6 +2427,113 @@ def test_render_skills_emits_sidebar_filters(fake_claude: Path) -> None: assert ">graph" in html_out +def test_render_skills_paginates_sidecar_page(fake_claude: Path) -> None: + for slug in ("a", "b", "c"): + _write_sidecar(fake_claude, slug, { + "slug": slug, + "grade": "A", + "raw_score": 0.9, + "subject_type": "skill", + }) + + html_out = cm._render_skills({"limit": "2"}) + + assert "Showing 1-2 of 3 sidecars" in html_out + assert "next" in html_out + assert "a" in html_out + assert "b" in html_out + assert "c" not in html_out + + +def test_sidecar_page_payload_searches_full_catalog(fake_claude: Path) -> None: + for slug in ("alpha-review", "beta-build", "gamma-review"): + _write_sidecar(fake_claude, slug, { + "slug": slug, + "grade": "A", + "raw_score": 0.9, + "subject_type": "skill", + }) + + payload = cm._sidecar_page_payload({"q": "review", "limit": "1"}) + + assert payload["total"] == 2 + assert payload["pages"] == 2 + assert [item["slug"] for item in payload["items"]] == ["alpha-review"] + + +def test_sidecar_page_payload_filters_type_grade_and_floor(fake_claude: Path) -> None: + _write_sidecar(fake_claude, "agent-a", { + "slug": "agent-a", + "grade": "A", + "raw_score": 0.9, + "subject_type": "agent", + }) + _write_sidecar(fake_claude, "skill-a", { + "slug": "skill-a", + "grade": "A", + "raw_score": 0.9, + "subject_type": "skill", + }) + _write_sidecar(fake_claude, "agent-floored", { + "slug": "agent-floored", + "grade": "A", + "raw_score": 0.9, + "subject_type": "agent", + "hard_floor": "intake_fail", + }) + + payload = cm._sidecar_page_payload({ + "type": "agent", + "grade": "A", + "hide_floor": "1", + }) + + assert payload["total"] == 1 + assert payload["items"][0]["slug"] == "agent-a" + + +def test_sidecar_page_payload_reuses_cached_search_records( + fake_claude: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + for slug in ("alpha-review", "beta-review"): + _write_sidecar(fake_claude, slug, { + "slug": slug, + "grade": "A", + "raw_score": 0.9, + "subject_type": "skill", + }) + original_read = cm._read_sidecar_file + reads = 0 + + def counting_read(path: Path) -> dict | None: + nonlocal reads + reads += 1 + return original_read(path) + + monkeypatch.setattr(cm, "_read_sidecar_file", counting_read) + + first = cm._sidecar_page_payload({"q": "review"}) + reads_after_first_search = reads + second = cm._sidecar_page_payload({"q": "review"}) + + assert [item["slug"] for item in first["items"]] == ["alpha-review", "beta-review"] + assert [item["slug"] for item in second["items"]] == ["alpha-review", "beta-review"] + assert reads_after_first_search == 2 + assert reads == reads_after_first_search + + _write_sidecar(fake_claude, "delta-review", { + "slug": "delta-review", + "grade": "A", + "raw_score": 0.8, + "subject_type": "skill", + }) + refreshed = cm._sidecar_page_payload({"q": "delta"}) + + assert [item["slug"] for item in refreshed["items"]] == ["delta-review"] + assert reads > reads_after_first_search + + def test_render_wiki_index_lists_entities(fake_claude: Path) -> None: skills_dir = fake_claude / "skill-wiki" / "entities" / "skills" agents_dir = fake_claude / "skill-wiki" / "entities" / "agents" @@ -2797,6 +2974,33 @@ def test_api_kpi_summary_shape(fake_claude: Path) -> None: assert d["grade_counts"].get("A", 0) == 1 +def test_kpi_summary_cache_reuses_recent_summary( + fake_claude: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + import kpi_dashboard as kd + + _write_sidecar(fake_claude, "alpha", { + "slug": "alpha", "subject_type": "skill", + "grade": "A", "raw_score": 0.9, "score": 0.9, + "hard_floor": None, "computed_at": "2026-04-19T10:00:00+00:00", + }) + monkeypatch.setattr(cm, "_KPI_SUMMARY_CACHE_KEY", None) + monkeypatch.setattr(cm, "_KPI_SUMMARY_CACHE_VALUE", None) + real_generate = kd.generate + calls = 0 + + def wrapped_generate(*, sources, top_n=10, now=None): + nonlocal calls + calls += 1 + return real_generate(sources=sources, top_n=top_n, now=now) + + monkeypatch.setattr(kd, "generate", wrapped_generate) + + assert cm._kpi_summary() is not None + assert cm._kpi_summary() is not None + assert calls == 1 + + def test_layout_nav_includes_wiki_and_kpi() -> None: """Every rendered page must include the new Wiki + KPI tabs in the top nav — the user explicitly asked for them to be accessible.""" diff --git a/src/tests/test_kpi_dashboard.py b/src/tests/test_kpi_dashboard.py index 1e42aea..3291018 100644 --- a/src/tests/test_kpi_dashboard.py +++ b/src/tests/test_kpi_dashboard.py @@ -193,6 +193,26 @@ def test_mcp_quality_subdir_is_included( ("filesystem", "mcp-server") ] + def test_mcp_rows_do_not_probe_skill_or_agent_sources( + self, sources: cl.LifecycleSources, monkeypatch: pytest.MonkeyPatch, + ) -> None: + _write_quality( + sources.sidecar_dir / "mcp", + "filesystem", + subject_type="mcp-server", + grade="A", + score=0.88, + ) + + def fail_source_probe(*args: object, **kwargs: object) -> Path | None: + raise AssertionError("MCP rows should not stat skill/agent source paths") + + monkeypatch.setattr(kd, "_skill_source_path", fail_source_probe) + + rows = kd.collect_rows(sources=sources) + + assert rows[0].category == "uncategorized" + def test_lifecycle_only_mcp_keeps_subject_type( self, sources: cl.LifecycleSources, ) -> None: