From 44b2beaec9794d54fcfbf02cb7f5f80d95636474 Mon Sep 17 00:00:00 2001 From: drknowhow Date: Tue, 22 Sep 2026 09:42:56 -0400 Subject: [PATCH 1/7] perf(activity-log): skip rows of other types before parsing them find_last JSON-parsed every line walking back, so a type a project never logged cost a parse of the whole file (up to the 5 MB cap). The Hub's list_projects calls it about six times per project on every request. Chunks and lines without the type's JSON-encoded name are now skipped unparsed, and get_recent reads its tail from the end of the file instead of the whole file forward. list_projects: 628 ms -> 173 ms on 71 projects. Claude-Session: https://claude.ai/code/session_01DEKoSbeEFepPGwPHQDHxAJ --- services/activity_log.py | 58 +++++++++++++++++++++------- tests/test_activity_log_find_last.py | 30 +++++++++++++- 2 files changed, 72 insertions(+), 16 deletions(-) diff --git a/services/activity_log.py b/services/activity_log.py index 99eaf11..ac7d053 100644 --- a/services/activity_log.py +++ b/services/activity_log.py @@ -1,6 +1,6 @@ """ActivityLog — Append-only JSONL activity log for C3 events.""" import json -from collections import Counter, deque +from collections import Counter from datetime import datetime, timezone from pathlib import Path @@ -75,6 +75,9 @@ def find_last(self, event_type: str, limit: int = 1, """ if not self.log_file.exists(): return [] + # A row of this type holds its JSON-encoded name, so a chunk or line + # without those bytes cannot match and is skipped unparsed. + needle = json.dumps(event_type).encode("utf-8") events: list = [] try: with open(self.log_file, "rb") as handle: @@ -86,12 +89,19 @@ def find_last(self, event_type: str, limit: int = 1, position -= step handle.seek(position) block = handle.read(step) + tail - lines = block.split(b"\n") - # The first element may be a partial line: keep it for the - # next (earlier) chunk unless we are at the file start. - tail = lines.pop(0) if position > 0 else b"" - for raw in reversed(lines): - if not raw.strip(): + # The first line may be partial: keep it for the next + # (earlier) chunk unless we are at the file start. + tail = b"" + if position > 0: + cut = block.find(b"\n") + if cut < 0: + tail = block + continue + tail, block = block[:cut], block[cut + 1:] + if needle not in block: + continue + for raw in reversed(block.split(b"\n")): + if needle not in raw: continue try: entry = json.loads(raw.decode("utf-8", "replace")) @@ -119,15 +129,13 @@ def get_recent(self, limit: int = 100, event_type: str = None, # far back in the log behind many tool_call entries. Use a larger scan # window so they aren't missed. scan_factor = 100 if event_type else 5 - tail = deque(maxlen=max(1, limit * scan_factor)) - with open(self.log_file, encoding="utf-8") as handle: - for line in handle: - if line.strip(): - tail.append(line) - for line in reversed(tail): + needle = json.dumps(event_type).encode("utf-8") if event_type else b"" + for raw in self._tail_lines(max(1, limit * scan_factor)): + if needle not in raw: + continue try: - entry = json.loads(line) - except json.JSONDecodeError: + entry = json.loads(raw) + except ValueError: continue if event_type and entry.get("type") != event_type: continue @@ -141,6 +149,26 @@ def get_recent(self, limit: int = 100, event_type: str = None, break return events + def _tail_lines(self, count: int, chunk_bytes: int = 64 * 1024) -> list: + """The last ``count`` non-blank lines as bytes, newest first.""" + lines: list = [] + with open(self.log_file, "rb") as handle: + handle.seek(0, 2) + position = handle.tell() + tail = b"" + while position > 0 and len(lines) < count: + step = min(chunk_bytes, position) + position -= step + handle.seek(position) + parts = (handle.read(step) + tail).split(b"\n") + tail = parts.pop(0) if position > 0 else b"" + for raw in reversed(parts): + if raw.strip(): + lines.append(raw) + if len(lines) >= count: + break + return lines + def get_stats(self) -> dict: """Counts by event type, total events, time range.""" if not self.log_file.exists(): diff --git a/tests/test_activity_log_find_last.py b/tests/test_activity_log_find_last.py index 734fcb7..e5649cf 100644 --- a/tests/test_activity_log_find_last.py +++ b/tests/test_activity_log_find_last.py @@ -20,7 +20,7 @@ from services.activity_log import ActivityLog # noqa: E402 -class TestFindLast(unittest.TestCase): +class _LogCase(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) @@ -38,6 +38,8 @@ def _buried(self, noise: int): rows += [{"type": "tool_call", "timestamp": f"t{i}"} for i in range(noise)] self._write(rows) + +class TestFindLast(_LogCase): def test_finds_a_row_get_recent_cannot_see(self): self._buried(5000) self.assertEqual(self.activity.get_recent(limit=1, event_type="session_start"), []) @@ -91,6 +93,32 @@ def test_matches_get_recent_when_both_can_see_the_row(self): self.activity.find_last("session_start"), self.activity.get_recent(limit=1, event_type="session_start")) + def test_a_row_that_only_mentions_the_type_is_not_a_match(self): + self._write([ + {"type": "session_start", "session_id": "real", "timestamp": "t0"}, + {"type": "tool_call", "note": "session_start", "timestamp": "t1"}, + ]) + self.assertEqual( + [r["session_id"] for r in self.activity.find_last("session_start")], ["real"]) + self.assertEqual( + [r["timestamp"] for r in self.activity.get_recent(event_type="session_start")], + ["t0"]) + + +class TestGetRecentTail(_LogCase): + def test_untyped_reads_the_newest_rows_across_chunks(self): + self._write([{"type": "tool_call", "timestamp": f"t{i:05d}", "pad": "x" * 100} + for i in range(3000)]) + self.assertEqual([r["timestamp"] for r in self.activity.get_recent(limit=3)], + ["t02999", "t02998", "t02997"]) + + def test_window_counts_every_line_not_only_matches(self): + rows = [{"type": "decision", "timestamp": "t0"}] + rows += [{"type": "tool_call", "timestamp": f"t{i}"} for i in range(1, 101)] + self._write(rows) + self.assertEqual(self.activity.get_recent(limit=1, event_type="decision"), []) + self.assertEqual(len(self.activity.get_recent(limit=2, event_type="decision")), 1) + if __name__ == "__main__": unittest.main() From a39d67b8ed14f2e279f5bcccd6f82255c36ec621 Mon Sep 17 00:00:00 2001 From: drknowhow Date: Tue, 22 Sep 2026 09:43:04 -0400 Subject: [PATCH 2/7] perf(creds): check a whole vault's values with one registry read The missing-value flag added in 2.144 called is_resolvable per entry, which re-read config.json and re-resolved the home and project paths each time: 43 entries cost 362 registry reads, 912 path resolutions and 120 keyring reads. value_presence reads the registry, realm, sidecar and master key once per vault. is_resolvable delegates to it; the project UI, c3_credentials list and c3 creds list use presence_for over their merged view, and backup status probes each vault once and accepts a caller's map. Claude-Session: https://claude.ai/code/session_01DEKoSbeEFepPGwPHQDHxAJ --- cli/c3.py | 4 +- cli/server.py | 8 ++-- cli/tools/credentials.py | 3 +- services/credential_backup.py | 12 +++-- services/credential_store.py | 82 +++++++++++++++++++++++++++++----- tests/test_credential_store.py | 50 +++++++++++++++++++++ 6 files changed, 138 insertions(+), 21 deletions(-) diff --git a/cli/c3.py b/cli/c3.py index 62310a1..95b1ad5 100644 --- a/cli/c3.py +++ b/cli/c3.py @@ -6685,10 +6685,10 @@ def _creds_cmd_list(args, project_path: str) -> None: "(add --global for all projects).") return print(f"{len(entries)} credential(s) — project scope shadows global:") + present = cred_store.presence_for(entries, project_path) missing = 0 for name, entry in entries.items(): - gone = not cred_store.is_resolvable( - name, project_path=project_path, scope=entry["scope"]) + gone = not present[name] missing += gone print(" " + _creds_entry_line(name, entry, gone)) if missing: diff --git a/cli/server.py b/cli/server.py index 0afbc42..84bdd33 100644 --- a/cli/server.py +++ b/cli/server.py @@ -2764,12 +2764,12 @@ def api_credentials_list(): from services import credential_store as cred_store pp = str(PROJECT_PATH) usage = cred_store.read_usage_state(pp) + listed = cred_store.list_entries(pp) + present = cred_store.presence_for(listed, pp) out = [] - for name, entry in cred_store.list_entries(pp).items(): + for name, entry in listed.items(): out.append(cred_store.public_entry( - name, entry, usage=usage, - value_ok=cred_store.is_resolvable( - name, project_path=pp, scope=entry["scope"]))) + name, entry, usage=usage, value_ok=present[name])) return jsonify({"entries": out}) diff --git a/cli/tools/credentials.py b/cli/tools/credentials.py index f394aa2..3589d2f 100644 --- a/cli/tools/credentials.py +++ b/cli/tools/credentials.py @@ -98,8 +98,7 @@ def _act_list(project_path: str) -> str: "The user manages them via the Credentials UI tab or `c3 creds set`." ) usage = cs.read_usage_state(project_path) - missing = {n for n, e in entries.items() if not cs.is_resolvable( - n, project_path=project_path, scope=e["scope"])} + missing = {n for n, ok in cs.presence_for(entries, project_path).items() if not ok} lines = [f"[creds] {len(entries)} entries (project scope shadows global):"] lines += [_format_entry_line(n, e, usage, n in missing) for n, e in entries.items()] diff --git a/services/credential_backup.py b/services/credential_backup.py index bb03e47..785ee7c 100644 --- a/services/credential_backup.py +++ b/services/credential_backup.py @@ -310,20 +310,26 @@ def sync(project_paths=()) -> dict: return out -def status(project_paths=()) -> dict: +def status(project_paths=(), presence=None) -> dict: """Counts only — never a value. ``not_backed_up`` lists registered entries with a live value but no sealed copy; ``restorable`` lists entries whose - value is gone but whose copy is here.""" + value is gone but whose copy is here. + + ``presence`` maps realm to a ``credential_store.value_presence`` result + the caller already holds; any other vault is probed here, once.""" data = _load() records = data.get("records", {}) if data else {} out: dict = {"enabled": bool(data), "path": str(backup_path() or ""), "created": data.get("created", "") if data else "", "backed_up": sum(len(r) for r in records.values()), "restorable": [], "lost": [], "not_backed_up": []} + known = dict(presence or {}) for scope, pp, realm_s, name in _realm_entries(project_paths): label = name if scope == "global" else f"{name} ({pp})" has_copy = name in records.get(realm_s, {}) - if cs.is_resolvable(name, project_path=pp, scope=scope): + if realm_s not in known: + known[realm_s] = cs.value_presence(scope, pp) + if known[realm_s].get(name, False): if not has_copy: out["not_backed_up"].append(label) elif has_copy: diff --git a/services/credential_store.py b/services/credential_store.py index 13fb872..1b3db00 100644 --- a/services/credential_store.py +++ b/services/credential_store.py @@ -388,12 +388,14 @@ def _file_get(scope: str, project_path: str, realm_s: str, name: str) -> Optiona token = _load_sidecar(path).get(name) if not token: return None - key = _master_key(realm_s, create=False) - if key is None: + return _decrypt(_master_key(realm_s, create=False), token) + + +def _decrypt(key: Optional[bytes], token) -> Optional[str]: + if not key or not token: return None try: - fernet = _crypto_module()(key) - return fernet.decrypt(token.encode("ascii")).decode("utf-8") + return _crypto_module()(key).decrypt(token.encode("ascii")).decode("utf-8") except Exception: return None @@ -1136,15 +1138,75 @@ def is_resolvable(name: str, *, project_path: str = ".", scope: str = "") -> boo gateway) can answer "is this credential still good?" without importing get_value at all. That absence is what the source-grep invariant test asserts, which is a stronger guarantee than reviewing call sites.""" - raw = _get_raw(name, project_path=project_path, scope=scope) - if raw is None: + owning = _norm_scope(scope, project_path) if scope else _owning_scope(name, project_path) + if not owning: return False - if structured_type(name, project_path=project_path, scope=scope): + return value_presence(owning, project_path, names=[name]).get(name, False) + + +def value_presence(scope: str, project_path: str = ".", names=None) -> dict: + """``{name: bool}`` for one scope's registered entries (or only ``names``): + whether each stored value can still be decoded. Never a value. + + A structured entry counts only when its payload is a JSON object. The + registry, realm, sidecar and master key are read once for the batch, so a + listing costs one keyring read per entry plus the structured attestation + for values that are not JSON objects. + """ + scope = _norm_scope(scope, project_path) + entries = _read_entries(scope, project_path) + if names is not None: + entries = {n: entries[n] for n in names if n in entries} + if not entries: + return {} + try: + keyring = _keyring_module() + except RuntimeError: + return dict.fromkeys(entries, False) + realm_s = realm(scope, project_path) + sidecar, key = None, None + out = {} + for name, entry in entries.items(): + if entry.get("storage") == "file": + if sidecar is None: + path = _secrets_path(scope, project_path) + sidecar = _load_sidecar(path) if path is not None else {} + key = _master_key(realm_s, create=False) if sidecar else None + raw = _decrypt(key, sidecar.get(name)) + else: + try: + raw = keyring.get_password(KEYRING_SERVICE, _account(realm_s, name)) + except Exception: + raw = None + if raw is None: + out[name] = False + continue try: - return isinstance(json.loads(raw), dict) + is_object = isinstance(json.loads(raw), dict) + except (ValueError, RecursionError): + is_object = False + if is_object or entry.get("type") in STRUCTURED_TYPES: + out[name] = is_object + continue + try: + attested = keyring.get_password( + KEYRING_SERVICE, _struct_account(realm_s, name)) or "" except Exception: - return False - return True + attested = "" + out[name] = attested not in STRUCTURED_TYPES + return out + + +def presence_for(entries: dict, project_path: str = ".") -> dict: + """``{name: bool}`` for a :func:`list_entries` view, each name probed in + the realm that owns it.""" + by_scope: dict = {} + for name, entry in entries.items(): + by_scope.setdefault(entry["scope"], []).append(name) + out = dict.fromkeys(entries, False) + for scope, names in by_scope.items(): + out.update(value_presence(scope, project_path, names=names)) + return out def _get_raw(name: str, *, project_path: str = ".", scope: str = "") -> Optional[str]: diff --git a/tests/test_credential_store.py b/tests/test_credential_store.py index e8f84c8..0fc4de4 100644 --- a/tests/test_credential_store.py +++ b/tests/test_credential_store.py @@ -700,6 +700,56 @@ def test_missing_type_entry_stays_plain(self): self.assertEqual( cs.structured_type("OLD", project_path=self.project), "") + def _retype(self, name: str, ctype: str) -> None: + cfg_path = Path(self.project) / ".c3" / "config.json" + cfg = json.loads(cfg_path.read_text(encoding="utf-8")) + cfg["credentials"]["entries"][name]["type"] = ctype + cfg_path.write_text(json.dumps(cfg), encoding="utf-8") + + def test_value_presence_answers_per_entry_kind(self): + realm_s = cs.realm("project", self.project) + cs.set_credential("TOK", "plaintok", project_path=self.project) + cs.set_credential("VISA", CARD, project_path=self.project, ctype="card") + big = json.dumps({"full_name": "D T", "ssn": "x" * 250, "dob": "y" * 250, + "phone": "z" * 250, "email": "e" * 250}) + self.assertEqual(cs.set_credential("BIG", big, project_path=self.project, + ctype="identity")["storage"], "file") + cs.set_credential("GONE", "v", project_path=self.project) + del self._stub.store[(cs.KEYRING_SERVICE, cs._account(realm_s, "GONE"))] + cs.set_credential("FORGED", "plaintok", project_path=self.project) + self._retype("FORGED", "card") + cs.set_credential("DEMOTED", CARD, project_path=self.project, ctype="card") + self._retype("DEMOTED", "token") + self._stub.set_password(cs.KEYRING_SERVICE, cs._account(realm_s, "DEMOTED"), "flat") + + self.assertEqual(cs.value_presence("project", self.project), { + "TOK": True, "VISA": True, "BIG": True, "GONE": False, + "FORGED": False, "DEMOTED": False}) + self.assertEqual(cs.value_presence("project", self.project, names=["TOK", "NOPE"]), + {"TOK": True}) + + def test_value_presence_reads_the_registry_once(self): + for i in range(5): + cs.set_credential(f"T{i}", "plaintok", project_path=self.project) + reads = mock.Mock(wraps=cs._load_config) + lookups = mock.Mock(wraps=self._stub.get_password) + with mock.patch.object(cs, "_load_config", reads), \ + mock.patch.object(self._stub, "get_password", lookups): + self.assertTrue(all(cs.value_presence("project", self.project).values())) + self.assertEqual(reads.call_count, 1) + self.assertLessEqual(lookups.call_count, 10) + + def test_presence_for_answers_each_name_from_its_owning_realm(self): + cs.set_credential("SHARED", "g", scope="global", project_path=self.project) + cs.set_credential("LOCAL", "p", project_path=self.project) + cs.set_credential("BOTH", "g", scope="global", project_path=self.project) + cs.set_credential("BOTH", "p", project_path=self.project) + del self._stub.store[(cs.KEYRING_SERVICE, cs._account( + cs.realm("project", self.project), "BOTH"))] + listed = cs.list_entries(self.project) + self.assertEqual(cs.presence_for(listed, self.project), + {"SHARED": True, "LOCAL": True, "BOTH": False}) + if __name__ == "__main__": unittest.main() From d7233aea195377f66ec212f981607b708af0a5bb Mon Sep 17 00:00:00 2001 From: drknowhow Date: Tue, 22 Sep 2026 09:43:15 -0400 Subject: [PATCH 3/7] perf(hub): cross-project routes read the registry, not live sessions Eleven Hub call sites used list_projects() only for names and paths, paying for its per-project activity-log scan and session-port probes on every request, including the 5 s polls on the locks, access and enforcement pages. They now call list_registered(), the stored rows. The credentials overview and project listing probe each vault once via value_presence and hand the result to the backup banner instead of probing every entry twice. Test stubs rename list_projects to match. Claude-Session: https://claude.ai/code/session_01DEKoSbeEFepPGwPHQDHxAJ --- cli/hub_server.py | 51 ++++++++++++---------- services/project_manager.py | 9 ++++ tests/test_cred_audit.py | 4 +- tests/test_hub_access_routes.py | 2 +- tests/test_hub_credentials_routes_write.py | 2 +- tests/test_hub_enforcement_routes.py | 2 +- tests/test_hub_locks_routes.py | 2 +- tests/test_hub_rules_routes.py | 2 +- tests/test_hub_sessions_routes.py | 2 +- tests/test_token_tracking.py | 4 +- 10 files changed, 47 insertions(+), 33 deletions(-) diff --git a/cli/hub_server.py b/cli/hub_server.py index 77b6a9c..d31bbb9 100644 --- a/cli/hub_server.py +++ b/cli/hub_server.py @@ -1804,7 +1804,7 @@ def api_projects_browse(): return jsonify({"error": str(e)}), 400 if not resolved.is_dir(): return jsonify({"error": f"Not a directory: {resolved}"}), 404 - registered = {os.path.normcase(p.get("path", "")) for p in _pm()._read_projects()} + registered = {os.path.normcase(p.get("path", "")) for p in _pm().list_registered()} dirs = [] try: for child in sorted(resolved.iterdir(), key=lambda c: c.name.lower()): @@ -1913,7 +1913,7 @@ def api_search_global(): if wanted else None) candidates, skipped = [], [] - for p in _pm().list_projects(): + for p in _pm().list_registered(): ppath = p.get("path") or "" if wanted_norm is not None and os.path.normcase(ppath) not in wanted_norm: continue @@ -2138,14 +2138,15 @@ def api_projects_credentials(): usage = cred_store.read_usage_state(str(resolved)) home = cred_store.global_base() global_names = set(cred_store.list_entries(str(home))) if home else set() + listed = cred_store.list_entries(str(resolved)) + present = cred_store.presence_for(listed, str(resolved)) entries = [] - for name, entry in cred_store.list_entries(str(resolved)).items(): + for name, entry in listed.items(): entries.append(_cred_entry_public( name, entry, usage=usage, shadows_global=(entry.get("scope") == "project" and name in global_names), - value_ok=cred_store.is_resolvable( - name, project_path=str(resolved), scope=entry["scope"]))) + value_ok=present[name])) return jsonify({"path": str(resolved), "entries": entries}) @@ -2360,7 +2361,7 @@ def api_hub_credentials_audit(): only_global = (request.args.get("scope") or "").strip().lower() == "global" events, errors = [], [] - for p in ([] if only_global else _pm().list_projects()): + for p in ([] if only_global else _pm().list_registered()): ppath = str(p.get("path") or "") if not (Path(ppath) / ".c3").is_dir(): continue @@ -2417,9 +2418,12 @@ def api_hub_credentials_overview(): from services import credential_store as cred_store home = cred_store.global_base() global_entries = cred_store.list_entries(str(home)) if home else {} + # realm -> {name: value decodes}, probed once per vault and shared with + # the backup banner, which asks the same question of the same entries. + present = {"global": cred_store.value_presence("global", str(home))} if home else {} shadowed_in = {name: [] for name in global_entries} projects_out = [] - for p in _pm().list_projects(): + for p in _pm().list_registered(): ppath = str(p.get("path") or "") row = {"name": p.get("name") or "", "path": ppath, "initialized": True, "error": None, "entries": []} @@ -2428,14 +2432,15 @@ def api_hub_credentials_overview(): row["initialized"] = False else: usage = cred_store.read_usage_state(ppath) + ok = cred_store.value_presence("project", ppath) + present[cred_store.realm("project", ppath)] = ok for name, entry in cred_store.list_entries(ppath).items(): if entry.get("scope") != "project": continue row["entries"].append(_cred_entry_public( name, entry, usage=usage, shadows_global=name in global_entries, - value_ok=cred_store.is_resolvable( - name, project_path=ppath, scope="project"))) + value_ok=ok.get(name, False))) if name in shadowed_in: shadowed_in[name].append( {"name": row["name"], "path": ppath}) @@ -2446,21 +2451,21 @@ def api_hub_credentials_overview(): global_out = [ {**_cred_entry_public( name, entry, usage=global_usage, - value_ok=cred_store.is_resolvable( - name, project_path=str(home), scope="global")), + value_ok=present.get("global", {}).get(name, False)), "shadowed_in": shadowed_in.get(name, [])} for name, entry in global_entries.items() ] return jsonify({"global": {"entries": global_out}, "projects": projects_out, "backup": _cred_backup_summary( - [row["path"] for row in projects_out if row["initialized"]])}) + [row["path"] for row in projects_out if row["initialized"]], + present)}) -def _cred_backup_summary(project_paths) -> dict: +def _cred_backup_summary(project_paths, present) -> dict: """Backup state as counts for the Hub banner; a broken file reports its error.""" from services import credential_backup try: - st = credential_backup.status(project_paths) + st = credential_backup.status(project_paths, presence=present) except credential_backup.BackupError as exc: return {"enabled": True, "error": str(exc), "restorable": 0, "lost": 0} return {"enabled": st["enabled"], "restorable": len(st["restorable"]), @@ -2612,7 +2617,7 @@ def api_hub_tokens_overview(): days = _hub_tokens_days(request.args.get("days")) rows, totals = [], {"tool_calls": 0, "tool_tokens": 0, "session_tokens": 0, "saved": 0} - for p in _pm().list_projects(): + for p in _pm().list_registered(): ppath = str(p.get("path") or "") row = {"name": p.get("name") or "", "path": ppath, "initialized": True, "error": None, "tool_calls": 0, "tool_tokens": 0, @@ -2662,7 +2667,7 @@ def api_hub_locks_overview(): from services import agent_locks as al rows, total = [], 0 - for p in _pm().list_projects(): + for p in _pm().list_registered(): ppath = str(p.get("path") or "") row = {"name": p.get("name") or "", "path": ppath, "initialized": True, "error": None, "enabled": True, "mode": "advisory", @@ -2752,7 +2757,7 @@ def api_hub_enforcement_overview(): rows = [] totals = {"discipline": 0, "access": 0} - for p in _pm().list_projects(): + for p in _pm().list_registered(): ppath = str(p.get("path") or "") row = {"name": p.get("name") or "", "path": ppath, "initialized": True, "error": None, "mode": None, "scope": "", "set_by": "", @@ -3428,7 +3433,7 @@ def api_hub_override_policy_overview(): from services import override_policy as opol rows = [] - for p in _pm().list_projects(): + for p in _pm().list_registered(): ppath = str(p.get("path") or "") row = {"name": p.get("name") or Path(ppath).name, "path": ppath, "initialized": True, "error": None, "policy": None, @@ -3740,7 +3745,7 @@ def api_hub_access_overview(): empty = {"deny": [], "read_only": [], "confirm": [], "mask": 0, "corrupt": False} rows = [] - for p in _pm().list_projects(): + for p in _pm().list_registered(): ppath = str(p.get("path") or "") row = {"name": p.get("name") or Path(ppath).name, "path": ppath, "initialized": True, "error": None, "rules": dict(empty)} @@ -4268,7 +4273,7 @@ def api_pm_global(): from services.task_store import TaskStore tasks, skipped, by_project = [], [], {} - entries = _pm()._read_projects() + entries = _pm().list_registered() for p in entries: ppath = p.get("path") or "" if not Path(ppath).is_dir(): @@ -4808,7 +4813,7 @@ def _session_project(raw: str): if not raw: return None want = os.path.normcase(os.path.normpath(raw)) - for p in _pm().list_projects(): + for p in _pm().list_registered(): path = str(p.get("path") or "") if path and os.path.normcase(os.path.normpath(path)) == want: return {"name": str(p.get("name") or Path(path).name), "path": path} @@ -4847,7 +4852,7 @@ def one(p): return row with ThreadPoolExecutor(max_workers=8) as pool: - rows = list(pool.map(one, _pm().list_projects())) + rows = list(pool.map(one, _pm().list_registered())) rows.sort(key=lambda r: r.get("last_active") or "", reverse=True) return jsonify({"projects": rows, "features": _SESSIONS_FEATURES}) @@ -4876,7 +4881,7 @@ def api_hub_sessions_list(): res["errors"] = [] else: projects = [{"name": str(p.get("name") or ""), "path": str(p.get("path") or "")} - for p in _pm().list_projects()] + for p in _pm().list_registered()] res = sc.list_many(projects, stale=stale, q=q, limit=limit, before=before) return jsonify(res) except Exception as e: diff --git a/services/project_manager.py b/services/project_manager.py index 9d84102..0c7196a 100644 --- a/services/project_manager.py +++ b/services/project_manager.py @@ -533,6 +533,15 @@ def sweep_registry(self) -> dict: return {"dropped": len(registry) - len(valid) - len(reaped), "reaped": reaped} + def list_registered(self) -> list: + """The stored project rows (name, path, parent_path, ...) as registered. + + No liveness, session or config enrichment, so it touches one file. + Callers that only iterate projects use this; ``list_projects`` reads + every project's activity log and probes session ports. + """ + return self._read_projects() + def list_projects(self) -> list: projects = self._read_projects() registry = self._read_registry() diff --git a/tests/test_cred_audit.py b/tests/test_cred_audit.py index df6b4b5..6c2bd3d 100644 --- a/tests/test_cred_audit.py +++ b/tests/test_cred_audit.py @@ -170,7 +170,7 @@ def test_hub_audit_route_isolates_an_unreadable_project(self): rows = [{"name": "p", "path": str(proj)}] class _PM: - def list_projects(self): + def list_registered(self): return rows with mock.patch.object(hub_server, "_pm", new=lambda: _PM()), \ @@ -212,7 +212,7 @@ def test_scope_global_reads_the_shared_vault_alone(self): "scope": "global", "via": "hub"}) class _PM: - def list_projects(self): + def list_registered(self): return [{"name": "p", "path": str(proj)}] with mock.patch.object(hub_server, "_pm", new=lambda: _PM()), \ diff --git a/tests/test_hub_access_routes.py b/tests/test_hub_access_routes.py index 0d54628..548709a 100644 --- a/tests/test_hub_access_routes.py +++ b/tests/test_hub_access_routes.py @@ -37,7 +37,7 @@ class _StubPM: def __init__(self, projects): self._projects = projects - def list_projects(self): + def list_registered(self): return self._projects diff --git a/tests/test_hub_credentials_routes_write.py b/tests/test_hub_credentials_routes_write.py index ec3e518..307dd7a 100644 --- a/tests/test_hub_credentials_routes_write.py +++ b/tests/test_hub_credentials_routes_write.py @@ -63,7 +63,7 @@ class _StubPM: def __init__(self, rows): self.rows = rows - def list_projects(self): + def list_registered(self): return self.rows diff --git a/tests/test_hub_enforcement_routes.py b/tests/test_hub_enforcement_routes.py index d5f300f..1a92cc1 100644 --- a/tests/test_hub_enforcement_routes.py +++ b/tests/test_hub_enforcement_routes.py @@ -30,7 +30,7 @@ class _StubPM: def __init__(self, projects): self._projects = projects - def list_projects(self): + def list_registered(self): return self._projects diff --git a/tests/test_hub_locks_routes.py b/tests/test_hub_locks_routes.py index 85c0397..53c26e2 100644 --- a/tests/test_hub_locks_routes.py +++ b/tests/test_hub_locks_routes.py @@ -26,7 +26,7 @@ class _StubPM: def __init__(self, projects): self._projects = projects - def list_projects(self): + def list_registered(self): return self._projects diff --git a/tests/test_hub_rules_routes.py b/tests/test_hub_rules_routes.py index d4f08dd..9173823 100644 --- a/tests/test_hub_rules_routes.py +++ b/tests/test_hub_rules_routes.py @@ -41,7 +41,7 @@ class _StubPM: def __init__(self, projects): self._projects = projects - def list_projects(self): + def list_registered(self): return self._projects diff --git a/tests/test_hub_sessions_routes.py b/tests/test_hub_sessions_routes.py index 3f34995..692ef9a 100644 --- a/tests/test_hub_sessions_routes.py +++ b/tests/test_hub_sessions_routes.py @@ -35,7 +35,7 @@ class _StubPM: def __init__(self, projects): self._projects = projects - def list_projects(self): + def list_registered(self): return self._projects diff --git a/tests/test_token_tracking.py b/tests/test_token_tracking.py index 682ea03..903f4f3 100644 --- a/tests/test_token_tracking.py +++ b/tests/test_token_tracking.py @@ -287,7 +287,7 @@ def test_hub_overview_isolates_a_project_it_cannot_read(self): {"name": "bare", "path": str(Path(tmp) / "bare")}] class _PM: - def list_projects(self): + def list_registered(self): return rows with mock.patch.object(hub_server, "_pm", new=lambda: _PM()): @@ -307,7 +307,7 @@ def test_days_param_is_clamped_not_trusted(self): with tempfile.TemporaryDirectory() as tmp: class _PM: - def list_projects(self): + def list_registered(self): return [] with mock.patch.object(hub_server, "_pm", new=lambda: _PM()): From 6ff96303faca8eb5d29aae825c249b6d4d919e7b Mon Sep 17 00:00:00 2001 From: drknowhow Date: Tue, 22 Sep 2026 09:43:34 -0400 Subject: [PATCH 4/7] perf(sessions): list the transcript root once per request Every orphan C3 record checked every folder under ~/.claude/projects for its transcript (9.6k stat calls per Hub sessions request), and a project without an exact slug match re-listed the root. One listing now serves the whole request, and a transcript found in another folder goes through the per-file cache instead of being re-read each time. The listing is not kept across requests: Windows does not refresh a folder's mtime in its parent's listing when a file is created inside, so a kept one misses new sessions. Claude-Session: https://claude.ai/code/session_01DEKoSbeEFepPGwPHQDHxAJ --- services/session_catalog.py | 116 ++++++++++++++++++++++++---------- tests/test_session_catalog.py | 30 +++++++++ 2 files changed, 112 insertions(+), 34 deletions(-) diff --git a/services/session_catalog.py b/services/session_catalog.py index 6c23917..a42b3c8 100644 --- a/services/session_catalog.py +++ b/services/session_catalog.py @@ -112,7 +112,7 @@ def _bare(text: str) -> str: return re.sub(r"[^a-z0-9]", "", text.lower()) -def claude_transcript_dir(project) -> Path | None: +def claude_transcript_dir(project, index: _RootIndex | None = None) -> Path | None: """The transcript folder Claude Code uses for ``project``, or None. Exact slug first (every non-alphanumeric becomes ``-``), then the same slug @@ -131,21 +131,62 @@ def claude_transcript_dir(project) -> Path | None: if name and cand.is_dir(): return cand target = _bare(project_str) - try: - for d in root.iterdir(): - if d.is_dir() and _bare(d.name) == target: - return d - except OSError: - return None + if index is None or index.root != root: + index = _RootIndex(root) + for d in index.dirs(): + if _bare(d.name) == target: + return d return None -def _same_dir(a: str, b) -> bool: +class _RootIndex: + """One listing of the transcript root and, on first use, of which folder + holds each transcript id — shared by every project one request builds. + + Request-scoped on purpose: Windows does not refresh a folder's mtime in + its parent's listing when a file is created inside it, so a listing kept + across requests cannot tell that a new transcript appeared. + """ + + def __init__(self, root: Path): + self.root = root + self._dirs: list[Path] | None = None + self._owners: dict[str, Path] | None = None + self._lock = threading.Lock() + + def dirs(self) -> list[Path]: + with self._lock: + if self._dirs is None: + try: + with os.scandir(self.root) as it: + self._dirs = [Path(e.path) for e in it if e.is_dir()] + except OSError: + self._dirs = [] + return self._dirs + + def owner(self, stem: str) -> Path | None: + dirs = self.dirs() + with self._lock: + if self._owners is None: + owners: dict[str, Path] = {} + for d in dirs: + try: + with os.scandir(d) as it: + for e in it: + if e.name.endswith(".jsonl") and e.is_file(): + owners.setdefault(e.name[:-6], Path(e.path)) + except OSError: + continue + self._owners = owners + return self._owners.get(stem) + + +def _dir_key(path) -> str: try: - ra, rb = os.path.realpath(a), os.path.realpath(str(b)) - except Exception: - ra, rb = str(a), str(b) - return os.path.normcase(os.path.normpath(ra)) == os.path.normcase(os.path.normpath(rb)) + real = os.path.realpath(str(path)) + except (OSError, ValueError): + real = str(path) + return os.path.normcase(os.path.normpath(real)) # ── Reading one transcript ───────────────────────────────────────────────── @@ -569,36 +610,39 @@ def _transcript_files(tdir: Path | None, limit: int | None = MAX_TRANSCRIPTS) -> return files if limit is None else files[:limit] -def _find_elsewhere(host_id: str) -> Path | None: +def _find_elsewhere(host_id: str, index: _RootIndex) -> Path | None: """A Claude transcript for ``host_id`` in ANY project folder — a session run from a worktree lives under the worktree's slug, not the project's.""" if not UUID_RE.match(host_id or ""): return None - root = claude_projects_root() - try: - for d in root.iterdir(): - cand = d / f"{host_id}.jsonl" - if cand.is_file(): - return cand - except OSError: - return None - return None + return index.owner(host_id) + +def _build(project, name: str = "", index: _RootIndex | None = None) -> tuple[list[dict], dict]: + """Every row for one project (unfiltered) plus a lookup context. -def _build(project, name: str = "") -> tuple[list[dict], dict]: - """Every row for one project (unfiltered) plus a lookup context.""" + ``index``: a transcript-root listing shared across one request's builds. + """ project = Path(project) name = name or project.name + root = claude_projects_root() + if index is None or index.root != root: + index = _RootIndex(root) with _project_lock(project): cache = _load_cache(project) dirty: list = [] - tdir = claude_transcript_dir(project) + tdir = claude_transcript_dir(project, index) metas = {} + own_dir = _dir_key(project) + cwd_keys: dict[str, str] = {} for f in _transcript_files(tdir): meta = _cached(cache["transcripts"], f, scan_transcript, dirty) if not meta: continue - if meta.get("cwd") and not _same_dir(meta["cwd"], project): + cwd = meta.get("cwd") + if cwd and cwd not in cwd_keys: + cwd_keys[cwd] = _dir_key(cwd) + if cwd and cwd_keys[cwd] != own_dir: continue # a folder that only looks like ours metas[meta["id"]] = dict(meta, _path=str(f)) records = [] @@ -617,6 +661,7 @@ def _build(project, name: str = "") -> tuple[list[dict], dict]: snaps.append(snap) if dirty: _save_cache(project, cache) + dirty.clear() # C3 id → host id, from every place that states it. c3_to_host: dict[str, str] = {} @@ -669,14 +714,16 @@ def row_id_for(c3id: str) -> str: continue rec0 = recs[0] if _provider(rec0["source_system"]) == "claude" and UUID_RE.match(rid): - found = _find_elsewhere(rid) - if found is not None: - try: - metas[rid] = dict(scan_transcript(found), _path=str(found), _elsewhere=True) - continue - except Exception: - pass + found = _find_elsewhere(rid, index) + meta = _cached(cache["transcripts"], found, scan_transcript, dirty) \ + if found is not None else None + if meta: + metas[rid] = dict(meta, _path=str(found), _elsewhere=True) + continue metas[rid] = {"id": rid, "_c3_only": True, "provider": _provider(rec0["source_system"])} + if dirty: + with _project_lock(project): + _save_cache(project, cache) marks = _fold_marks(_read_marks(project)) cleared, pred, succ = _lifecycle(project) @@ -818,11 +865,12 @@ def one(p): if not path or not (Path(path) / ".c3").is_dir(): return path, [], "" try: - rows, _ = _build(path, str(p.get("name") or "")) + rows, _ = _build(path, str(p.get("name") or ""), index) return path, rows, "" except Exception as exc: # pragma: no cover - defensive return path, [], str(exc) + index = _RootIndex(claude_projects_root()) merged, errors = [], [] with ThreadPoolExecutor(max_workers=max(1, workers)) as pool: for path, rows, err in pool.map(one, projects): diff --git a/tests/test_session_catalog.py b/tests/test_session_catalog.py index b808523..6a97f9c 100644 --- a/tests/test_session_catalog.py +++ b/tests/test_session_catalog.py @@ -370,5 +370,35 @@ def test_unknown_traversal_and_foreign_ids_are_404(self): self.assertNotIn("argv", spec) +class TestRootIndex(_Base): + def test_a_worktree_transcript_created_after_a_listing_shows_on_the_next(self): + wt = self.proj / ".claude" / "worktrees" / "feat" + wt.mkdir(parents=True) + self.fx.tdir(wt) + self.fx.record(self.proj, "20260901_100000_aaaaaaaaaaaa", U1) + self.assertFalse(self.row(U1)["resume"]["can_launch"]) + self.fx.transcript(wt, U1, cwd=wt) + self.assertTrue(self.row(U1)["resume"]["can_launch"]) + + def test_a_transcript_in_another_folder_is_read_once_across_listings(self): + wt = self.proj / ".claude" / "worktrees" / "feat" + wt.mkdir(parents=True) + self.fx.transcript(wt, U1, cwd=wt) + self.fx.record(self.proj, "20260901_100000_aaaaaaaaaaaa", U1) + self.assertTrue(self.row(U1)["resume"]["can_launch"]) + with mock.patch.object(sc, "scan_transcript", wraps=sc.scan_transcript) as scan: + self.assertTrue(self.row(U1)["resume"]["can_launch"]) + scan.assert_not_called() + + def test_one_index_lists_the_folders_once(self): + self.fx.transcript(self.proj, U1) + index = sc._RootIndex(sc.claude_projects_root()) + self.assertIsNotNone(index.owner(U1)) + with mock.patch.object(sc.os, "scandir") as scandir: + self.assertIsNone(index.owner(U2)) + self.assertTrue(index.dirs()) + scandir.assert_not_called() + + if __name__ == "__main__": unittest.main() From 27616e6ac763ca178656fb20730d7d5c73b52a87 Mon Sep 17 00:00:00 2001 From: drknowhow Date: Tue, 22 Sep 2026 09:43:34 -0400 Subject: [PATCH 5/7] perf(ci): reuse the engine probe for 30 s on inspect The Hub CI tab re-inspects every 10 s (2 s during a run), and each inspect spawned act --version, docker version and docker image inspect: 250 ms here, and a stalled Docker Desktop can hold it for the 30 s probe timeout. inspect_project now passes max_age so a recent probe is reused. Starting a run and c3_ci doctor call availability() without it and still probe fresh. Claude-Session: https://claude.ai/code/session_01DEKoSbeEFepPGwPHQDHxAJ --- services/ci_act.py | 23 ++++++++++++++++++++++- services/ci_workflow.py | 7 +++++-- tests/test_ci_act.py | 27 +++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/services/ci_act.py b/services/ci_act.py index b153bbb..6f13aab 100644 --- a/services/ci_act.py +++ b/services/ci_act.py @@ -88,12 +88,33 @@ def _probe(cmd: list, timeout: int = 30) -> tuple: return 127, f"{type(exc).__name__}: {exc}" -def availability() -> dict: +#: How old an engine probe a display surface may reuse. The hub CI tab +#: re-inspects every 2-10 s, and each fresh probe spawns act and docker. +DISPLAY_PROBE_TTL_S = 30.0 +_display_probe: tuple = (0.0, None) + + +def availability(max_age: float = 0.0) -> dict: """Can this engine run at all? Reports WHY not, never just False. Both halves are checked because they fail independently and a user with act installed but Docker stopped deserves to be told which one to fix. + + ``max_age`` > 0 reuses a result at most that many seconds old from an + earlier call that also passed it; the default always probes, which is + what a run and ``doctor`` need. """ + global _display_probe + if max_age <= 0: + return _probe_engine() + at, info = _display_probe + if info is None or time.monotonic() - at > max_age: + info = _probe_engine() + _display_probe = (time.monotonic(), info) + return dict(info) + + +def _probe_engine() -> dict: act = find_act() info: dict = {"ok": False, "act": act, "act_version": "", "docker": False, "docker_version": "", "reason": ""} diff --git a/services/ci_workflow.py b/services/ci_workflow.py index c5582de..9841949 100644 --- a/services/ci_workflow.py +++ b/services/ci_workflow.py @@ -800,8 +800,11 @@ def inspect_project(project_path, event: str = "", engine: str = "auto") -> dict # Lazy: keeps parsing independent of the execution engines, and avoids # paying for a `docker version` probe on every import. from services import ci_act - engines = ci_act.availability() if engine in ("auto", "act") else { - "ok": False, "reason": "engine='native' — containers not considered"} + if engine in ("auto", "act"): + engines = ci_act.availability(max_age=ci_act.DISPLAY_PROBE_TTL_S) + else: + engines = {"ok": False, + "reason": "engine='native' — containers not considered"} act_ok = bool(engines.get("ok")) native, container, foreign, unsupported = [], [], [], [] diff --git a/tests/test_ci_act.py b/tests/test_ci_act.py index f3ef0b5..792e225 100644 --- a/tests/test_ci_act.py +++ b/tests/test_ci_act.py @@ -560,5 +560,32 @@ def test_runs_a_linux_job_and_reports_a_clean_failure(self): self.assertEqual(job.failures[0]["file"], "app/thing.py") +class TestDisplayProbeCache(unittest.TestCase): + def setUp(self): + self._saved = ci_act._display_probe + ci_act._display_probe = (0.0, None) + + def tearDown(self): + ci_act._display_probe = self._saved + + def test_max_age_reuses_a_recent_probe_and_the_default_always_probes(self): + with mock.patch.object(ci_act, "_probe_engine", + side_effect=lambda: {"ok": True}) as probe: + ci_act.availability(max_age=30) + ci_act.availability(max_age=30) + self.assertEqual(probe.call_count, 1) + ci_act.availability() + self.assertEqual(probe.call_count, 2) + + def test_an_expired_probe_runs_again(self): + with mock.patch.object(ci_act, "_probe_engine", + side_effect=lambda: {"ok": True}) as probe: + ci_act.availability(max_age=30) + at, info = ci_act._display_probe + ci_act._display_probe = (at - 31, info) + ci_act.availability(max_age=30) + self.assertEqual(probe.call_count, 2) + + if __name__ == "__main__": unittest.main() From c1c321746f21c1bdd02d5c7aa2b35d00ab0107a2 Mon Sep 17 00:00:00 2001 From: drknowhow Date: Tue, 22 Sep 2026 09:43:43 -0400 Subject: [PATCH 6/7] perf(ui): compile JSX only, fetch the credentials overview once babel-standalone's default presets include preset-env, which compiled the 600 KB Hub bundle to ES5 on every page load (1.9 s on a desktop vs 0.35 s for JSX alone); both shells now ask for the React preset only. The global vault manager takes its rows from the Credentials page's overview instead of loading it again. usePoll skips a tick while the last call is running, and the project list polls every 30 s off the Projects view. Both bundles ran top-level in a VM, and the Hub rendered in Chrome with no errors. Claude-Session: https://claude.ai/code/session_01DEKoSbeEFepPGwPHQDHxAJ --- cli/hub_ui.html | 2 +- cli/hub_ui/app.js | 5 +++- cli/hub_ui/components/hub_credentials.js | 35 ++++++++++++++---------- cli/hub_ui/state.js | 10 +++++-- cli/ui.html | 2 +- 5 files changed, 34 insertions(+), 20 deletions(-) diff --git a/cli/hub_ui.html b/cli/hub_ui.html index 9819f90..b20f805 100644 --- a/cli/hub_ui.html +++ b/cli/hub_ui.html @@ -137,7 +137,7 @@
- diff --git a/cli/hub_ui/app.js b/cli/hub_ui/app.js index b7df9a2..5a869ad 100644 --- a/cli/hub_ui/app.js +++ b/cli/hub_ui/app.js @@ -50,7 +50,10 @@ function App() { }, []); useEffect(() => { loadConfig(); loadProjects(); }, []); - usePoll(loadProjects, 5000); + // Live status matters on the project list and in a drill-in. Elsewhere it + // only refreshes the sidebar and top-bar counts, which may lag 30 s, and + // each poll reads every project's activity log. + usePoll(loadProjects, mainView === 'projects' || drill ? 5000 : 30000); // Persisted preferences const toggleTheme = () => { diff --git a/cli/hub_ui/components/hub_credentials.js b/cli/hub_ui/components/hub_credentials.js index b7e806a..8f70ad0 100644 --- a/cli/hub_ui/components/hub_credentials.js +++ b/cli/hub_ui/components/hub_credentials.js @@ -1381,7 +1381,10 @@ function CredSourcesStrip({ sources, onResync, busy }) { // path=null → the global vault (~/.c3): scope locked to 'global'. // path=string → that project's merged view (global entries + project shadows). -function CredsManager({ path, projectName, onChanged, bindSlash }) { +// Global vault (path null): rows arrive as `globalEntries` (null while the +// page's overview loads) and `onChanged` refills them; loading the overview +// here as well fetched the whole cross-project index twice. +function CredsManager({ path, projectName, onChanged, bindSlash, globalEntries }) { const isGlobal = !path; const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(true); @@ -1408,14 +1411,10 @@ function CredsManager({ path, projectName, onChanged, bindSlash }) { const withPath = (obj) => (path ? Object.assign({ path }, obj) : obj); const load = useCallback(async () => { + if (!path) return; try { - if (path) { - const data = await api.get('/api/projects/credentials?path=' + encodeURIComponent(path)); - setEntries((data && data.entries) || []); - } else { - const data = await api.get('/api/hub/credentials/overview'); - setEntries((((data || {}).global) || {}).entries || []); - } + const data = await credApi.listProject(path); + setEntries((data && data.entries) || []); setError(''); } catch (e) { setError(String(e)); } setLoading(false); @@ -1426,6 +1425,14 @@ function CredsManager({ path, projectName, onChanged, bindSlash }) { load(); }, [load]); + useEffect(() => { + if (path) return; + setEntries(globalEntries || []); + setLoading(globalEntries == null); + }, [path, globalEntries]); + + const refresh = () => { load(); if (onChanged) onChanged(); }; + // `/` focuses the filter, as it does on every other hub board — but only // where nothing above already claims the key. The hub Credentials page owns // `/` for its cross-project search, so the manager binds it solely when @@ -1447,7 +1454,7 @@ function CredsManager({ path, projectName, onChanged, bindSlash }) { return () => window.removeEventListener('keydown', onKey); }, [bindSlash, drawer, menu, confirm]); - const done = (msg) => { notify(msg); load(); if (onChanged) onChanged(); }; + const done = (msg) => { notify(msg); refresh(); }; const saveForm = async () => { if (!form || !form.name.trim()) return; @@ -1507,8 +1514,7 @@ function CredsManager({ path, projectName, onChanged, bindSlash }) { if (resp && resp.error) setError(resp.error); else { notify(`${field === 'inject' ? 'Auto-inject' : 'Agent access'} ${next ? 'enabled' : 'disabled'} for '${entry.name}'`); - load(); - if (onChanged) onChanged(); + refresh(); } } catch (e) { setError(apiErr(e)); } }; @@ -1619,7 +1625,7 @@ function CredsManager({ path, projectName, onChanged, bindSlash }) { } else { notify(`${label}: ${ok} ${ok === 1 ? 'entry' : 'entries'}`); } - if (action !== 'check') { exitSelect(); load(); if (onChanged) onChanged(); } + if (action !== 'check') { exitSelect(); refresh(); } } catch (e) { notify(apiErr(e), 'err'); } setBulkBusy(false); }; @@ -2012,7 +2018,7 @@ function CredsManager({ path, projectName, onChanged, bindSlash }) { { setDrawer(null); setReplaceOnOpen(false); }} - onChanged={() => { load(); if (onChanged) onChanged(); }} /> + onChanged={refresh} /> )} {confirm && setConfirm(null)} />} @@ -2420,7 +2426,8 @@ function HubCredentials({ projects, onOpenDrill }) { ) : sub === 'global' ? ( - + ) : !ov ? (
Loading…
) : (ov.projects || []).length === 0 ? ( diff --git a/cli/hub_ui/state.js b/cli/hub_ui/state.js index 9826d4b..72527d3 100644 --- a/cli/hub_ui/state.js +++ b/cli/hub_ui/state.js @@ -24,11 +24,15 @@ const IDE_OPTIONS = [ const ideLabel = (ide) => IDE_LABELS[ide] || ide || 'unknown'; -// Repeating poll that pauses while the tab is hidden. +// Repeating poll that pauses while the tab is hidden and skips a tick while +// the previous call is still running, so a slow route never stacks requests. const usePoll = (fn, ms) => { React.useEffect(() => { - const iv = setInterval(() => { - if (!document.hidden) fn(); + let running = false; + const iv = setInterval(async () => { + if (document.hidden || running) return; + running = true; + try { await fn(); } finally { running = false; } }, ms); return () => clearInterval(iv); }, [fn, ms]); diff --git a/cli/ui.html b/cli/ui.html index 1e1d20a..c2b4f01 100644 --- a/cli/ui.html +++ b/cli/ui.html @@ -160,7 +160,7 @@
- From b16815c2a72dcfc696e8d7e71c63407f577f5d2b Mon Sep 17 00:00:00 2001 From: drknowhow Date: Tue, 22 Sep 2026 09:43:56 -0400 Subject: [PATCH 7/7] =?UTF-8?q?release:=202.146.0=20=E2=80=94=20Hub=20page?= =?UTF-8?q?s=20stop=20re-reading=20every=20activity=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01DEKoSbeEFepPGwPHQDHxAJ --- CHANGELOG.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++ cli/c3.py | 2 +- pyproject.toml | 2 +- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a501c5..d0cbbc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,68 @@ All notable changes to Code Context Control (C3) are documented here. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.146.0] - 2026-09-22 + +### Changed — the Hub's cross-project pages stop re-reading every activity log + +The Credentials page took about a second to open on a fast desktop, longer on +a work laptop, and every save waited on two more reloads. Almost none of that +time went to credentials. Every cross-project Hub route called +`ProjectManager.list_projects()` just to get names and paths, and that call +reads each project's activity log to work out which sessions are live. +`ActivityLog.find_last` JSON-parsed every line on its way back through the +file, so for a type a project had never logged it parsed the whole file (up to +the 5 MB rotation cap), about six times per project. Measured with 71 +registered projects: + +| Route | Before | After | +|---|---|---| +| `/api/hub/credentials/overview` | 925 ms | 108 ms | +| `/api/hub/credentials/audit` | 1004 ms | 68 ms | +| `/api/hub/sessions` | 1745 ms | 287 ms | +| `/api/hub/tokens/overview` | 928 ms | 180 ms | +| locks, enforcement, access and approval-policy overviews | 760–825 ms | 14–26 ms | +| `/api/projects` (polled every 5 s) | 713 ms | 225 ms | +| `/api/ci/inspect` (polled every 2–10 s) | 327 ms | 61 ms | + +- `ActivityLog.find_last` and `get_recent(event_type=...)` skip any chunk or + line that does not contain the type's JSON-encoded name before parsing it. + `get_recent` reads the tail it keeps from the end of the file instead of + reading the whole file forward. +- `ProjectManager.list_registered()` returns the stored project rows with no + liveness sweep. The credentials, tokens, locks, enforcement, access, + approval-policy, sessions and global-search routes use it. `/api/projects` + keeps the full `list_projects()`. +- `credential_store.value_presence()` checks whether each value in a vault + still decodes with one registry read for the whole vault. Before, 43 entries + cost 362 reads of `config.json` and 912 path resolutions. `is_resolvable` + delegates to it, and the Hub, the project UI, `c3_credentials list` and + `c3 creds list` use it. The Hub's backup banner reuses the listing's probe + instead of repeating it. +- The Sessions view lists the Claude transcript root once per request instead + of once per lookup, and a transcript found in another project's folder (a + worktree session) goes through the per-file cache instead of being re-read + on every request. The listing is per request, not cached longer, because + Windows does not update a folder's mtime in its parent's listing when a file + is created inside it, so a kept listing could not see a new transcript. +- The CI tab reuses an engine probe (`act --version`, `docker version`, image + inspect) for 30 s. Starting a run and `c3_ci doctor` still probe fresh. + +### Changed — Hub pages compile faster in the browser and polls stop stacking + +- The Hub and project UI shells tell babel-standalone to apply only the React + preset. Its default also ran `preset-env`, which compiled the 600 KB bundle + down to ES5 on every page load: about 1.9 s on a desktop, against 0.35 s for + JSX alone. Current browsers run the rest natively. +- The Credentials page's global vault list takes its rows from the page's own + overview. Opening the page used to load that overview twice, and each save + reloaded it twice more. +- `usePoll` skips a tick while the previous call is still running, so a route + slower than its poll interval no longer stacks requests on the Hub. +- The project list polls every 30 s instead of 5 s when neither the Projects + view nor a project drill-in is open. The sidebar and top-bar counts can lag + by that much on other pages. + ## [2.145.2] - 2026-09-22 ### Fixed — a corrupt embedding store no longer kills the MCP server (#170) diff --git a/cli/c3.py b/cli/c3.py index 95b1ad5..196a754 100644 --- a/cli/c3.py +++ b/cli/c3.py @@ -91,7 +91,7 @@ # Config CONFIG_DIR = ".c3" CONFIG_FILE = ".c3/config.json" -__version__ = "2.145.2" +__version__ = "2.146.0" # The PreToolUse matcher for native subagent calls (installer and hub migration). AGENT_MATCHER = "Agent|Task" diff --git a/pyproject.toml b/pyproject.toml index afb8171..977dec3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "code-context-control" -version = "2.145.2" +version = "2.146.0" description = "Local MCP code-intelligence for AI coding tools: surgical search/read/edit, agent-config version history, path-level access + masking guards, and a multi-project hub." readme = "README.md" requires-python = ">=3.10"