diff --git a/clawmetry/static/js/app.js b/clawmetry/static/js/app.js index 73aaa4eadf..27fe60f2c2 100644 --- a/clawmetry/static/js/app.js +++ b/clawmetry/static/js/app.js @@ -19435,6 +19435,21 @@ function _cmSyncDismiss() { try { localStorage.setItem('cm-sync-verified-ts', String(Date.now())); } catch (e) {} } +// Name what we are actually syncing. `prog.runtimes` is the node's detected +// runtimes (see _sync_scope_runtimes in dashboard.py); the banner used to +// hardcode "your OpenClaw workspace", which is a flat lie on a machine that +// only runs Claude Code. Falls back to a runtime-neutral phrase when detection +// is empty or unavailable, never to a named runtime. +function _cmSyncScopeTitle(prog) { + var names = ((prog && prog.runtimes) || []).map(function (r) { + return (r && (r.label || r.id)) || ''; + }).filter(Boolean); + if (!names.length) return t('app.syncing_your_agents', null, 'Syncing your AI agents'); + if (names.length === 1) return 'Syncing your ' + names[0] + ' data'; + if (names.length === 2) return 'Syncing ' + names[0] + ' and ' + names[1]; + return 'Syncing ' + names.slice(0, 2).join(', ') + ' and ' + (names.length - 2) + ' more'; +} + function _cmSyncRender(prog, health) { var bar = document.getElementById('sync-status-banner'); if (!bar) return; @@ -19444,6 +19459,7 @@ function _cmSyncRender(prog, health) { var errBox = document.getElementById('sync-status-error'); var title = document.getElementById('sync-status-title'); if (!sub || !details || !stepper || !errBox || !title) return; + title.textContent = _cmSyncScopeTitle(prog); // Determine the active phase: highest-index phase that's running/complete. var phase = (prog && prog.phase) || ''; diff --git a/clawmetry/templates/partials/banners.html b/clawmetry/templates/partials/banners.html index 56e73647f6..3f587373da 100644 --- a/clawmetry/templates/partials/banners.html +++ b/clawmetry/templates/partials/banners.html @@ -352,7 +352,7 @@
-
Syncing your OpenClaw workspace
+
Syncing your AI agents
Discovering sessions and indexing events. Your data will appear shortly.
diff --git a/dashboard.py b/dashboard.py index 40466a7a3c..e1c72d5faa 100644 --- a/dashboard.py +++ b/dashboard.py @@ -3481,6 +3481,122 @@ def detect_config(args=None): pass +# Cache for _sync_scope_runtimes(). The sync banner polls, and adapter +# detect() calls glob session dirs (~3.3s measured on a busy machine), so this +# must never run inline in a request handler. 60s is well under how fast a user +# installs a new agent. +_SYNC_SCOPE_CACHE = {"ts": 0.0, "runtimes": [], "running": False} +_SYNC_SCOPE_LOCK = threading.Lock() + + +def _sync_scope_runtimes(): + """Cached runtime list for the sync banner, served off the request path. + + The first call returns ``[]`` (banner shows the runtime-neutral "Syncing + your AI agents") and kicks a background refresh; the next poll has the real + list. Never blocks, never raises. + """ + with _SYNC_SCOPE_LOCK: + stale = time.time() - float(_SYNC_SCOPE_CACHE.get("ts") or 0) >= 60 + if stale and not _SYNC_SCOPE_CACHE["running"]: + _SYNC_SCOPE_CACHE["running"] = True + threading.Thread(target=_sync_scope_refresh_safe, daemon=True).start() + return _SYNC_SCOPE_CACHE["runtimes"] + + +def _sync_scope_refresh_safe(): + """Thread target: refresh the cache, and always clear the in-flight flag. + + Without this a single unexpected raise would leave ``running`` True and + wedge the cache at its last value for the life of the process. + """ + try: + _sync_scope_refresh() + except Exception as _e: + with _SYNC_SCOPE_LOCK: + _SYNC_SCOPE_CACHE["ts"] = time.time() + _SYNC_SCOPE_CACHE["running"] = False + print(f"[sync-scope] runtime detection failed: {_e}") + + +def _sync_scope_refresh(): + """Detect which agent runtimes actually have sessions on this machine. + + Powers the sync banner title so it names the real runtimes ("Syncing your + Claude Code data") instead of asserting OpenClaw on a machine that never + had it. Pure filesystem detection: no DuckDB, no writer lock, never raises. + + Same honesty rule as ``_detect_runtimes_for_heartbeat``: a runtime is only + named when it has **sessions on disk**. Presence alone is not enough, the + Cursor IDE creates its state dir whether or not the agent was ever used, + and naming a runtime we aren't actually syncing is the same lie this + function exists to remove. + + Returns ``[{"id": ..., "label": ...}, ...]``; empty when nothing qualifies. + """ + found = {} # id -> {"label": str, "sessions": int} + + def _put(rid, label, sessions): + rid = str(rid or "").strip().lower() + if not rid: + return + cur = found.get(rid) + if cur is None or int(sessions or 0) > cur["sessions"]: + found[rid] = {"label": label or (cur or {}).get("label") or rid, + "sessions": int(sessions or 0)} + + # OpenClaw / NemoClaw ship as adapters in OSS. Prefer the live registry + # (plugins may have overridden an adapter); fall back to the built-ins, + # because registration happens at app creation and can be empty here. + _oss = [] + try: + from clawmetry.adapters import registry as _reg + _oss = list(_reg.detect_all()) + except Exception: + pass + if not _oss: + try: + from clawmetry.adapters.openclaw import OpenClawAdapter as _OC + from clawmetry.adapters.nemo import NemoClawAdapter as _NC + for _cls in (_OC, _NC): + try: + _oss.append(_cls().detect()) + except Exception: + pass + except Exception: + pass + for _r in _oss: + if getattr(_r, "detected", False): + _put(getattr(_r, "name", ""), getattr(_r, "display_name", ""), + getattr(_r, "session_count", 0)) + + # Every other runtime. The lite detector is free and always present; the + # family adapters are more accurate but live in clawmetry-pro, so they + # return nothing in OSS. Merge both, keep the higher count per runtime. + try: + from clawmetry import sync as _sync_mod + try: + for _r in (_sync_mod._detect_runtimes_lite() or []): + _put(_r.get("id"), _r.get("label"), _r.get("sessions")) + except Exception: + pass + try: + for _r in (_sync_mod._detect_family_runtimes() or []): + _put(_r.get("name"), _r.get("displayName"), _r.get("sessionCount")) + except Exception: + pass + except Exception: + pass + + rows = [{"id": k, "label": v["label"]} + for k, v in found.items() if v["sessions"] > 0] + with _SYNC_SCOPE_LOCK: + _SYNC_SCOPE_CACHE["ts"] = time.time() + _SYNC_SCOPE_CACHE["runtimes"] = rows + _SYNC_SCOPE_CACHE["running"] = False + return rows + + def _detect_workspace_from_config(): """Try to read workspace from Moltbot/OpenClaw agent config.""" config_paths = [ @@ -12476,15 +12592,22 @@ def _oss_integrations_shim(): # vivekchand/clawmetry#748 — Initial-sync progress for the dashboard # banner. The sync daemon writes ~/.clawmetry/sync_progress.json after # each phase; we just stream it through. Local-only, no auth. + # `runtimes` is added here (not by the daemon) so the banner can NAME what + # it is syncing instead of hardcoding "your OpenClaw workspace" on a + # machine that may only run Claude Code / Codex / Cursor. @app.route("/api/sync-progress", endpoint="sync_progress") def _sync_progress(): from flask import jsonify as _jsonify progress_path = os.path.expanduser("~/.clawmetry/sync_progress.json") if not os.path.isfile(progress_path): - return _jsonify({"error": "no sync progress yet"}), 404 + return _jsonify({"error": "no sync progress yet", + "runtimes": _sync_scope_runtimes()}), 404 try: with open(progress_path) as _f: - return _jsonify(json.load(_f)) + _payload = json.load(_f) + if isinstance(_payload, dict): + _payload["runtimes"] = _sync_scope_runtimes() + return _jsonify(_payload) except Exception as _e: return _jsonify({"error": f"unreadable: {_e}"}), 500 diff --git a/tests/test_sync_banner_names_real_runtimes.py b/tests/test_sync_banner_names_real_runtimes.py new file mode 100644 index 0000000000..8ffe2f68a1 --- /dev/null +++ b/tests/test_sync_banner_names_real_runtimes.py @@ -0,0 +1,180 @@ +"""Guards for the sync banner's runtime honesty (issue: hardcoded OpenClaw). + +The banner shipped the literal string "Syncing your OpenClaw workspace", so it +asserted OpenClaw on every machine, including the majority that only run Claude +Code / Codex / Cursor and have never installed OpenClaw. These tests pin the +three halves of the fix: + + 1. no hardcoded runtime name in the served template or the JS fallback, + 2. ``/api/sync-progress`` carries a ``runtimes`` list the banner can name, + 3. detection is honest (sessions on disk, never presence alone) and never + runs inline in the polled request handler. + +Each assertion fails against the pre-fix tree, which is the point. +""" +from __future__ import annotations + +import json +import os +import re +import time + +import pytest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +BANNERS = os.path.join(REPO, "clawmetry", "templates", "partials", "banners.html") +APP_JS = os.path.join(REPO, "clawmetry", "static", "js", "app.js") + + +def _read(path): + with open(path, encoding="utf-8") as fh: + return fh.read() + + +# ── 1. No hardcoded runtime in the banner's static copy ────────────────────── + +def test_banner_template_does_not_hardcode_a_runtime(): + """The static default must be runtime-neutral: it renders before any + detection has landed, on machines that may have no OpenClaw at all.""" + html = _read(BANNERS) + title = re.search(r'id="sync-status-title"[^>]*>([^<]*)<', html) + assert title, "sync-status-title element disappeared from banners.html" + text = title.group(1) + assert "OpenClaw" not in text, ( + f"sync banner still asserts OpenClaw in its static default: {text!r}" + ) + + +def test_banner_js_fallback_is_runtime_neutral(): + """_cmSyncScopeTitle must fall back to a neutral phrase, never to a + named runtime, when detection is empty or the endpoint 404s.""" + js = _read(APP_JS) + assert "function _cmSyncScopeTitle(" in js, "_cmSyncScopeTitle helper is gone" + fn = js[js.index("function _cmSyncScopeTitle("):] + fn = fn[: fn.index("\n}\n") + 3] + assert "Syncing your AI agents" in fn, "neutral fallback copy missing" + for runtime in ("OpenClaw", "Claude Code", "Codex", "Cursor"): + assert runtime not in fn, ( + f"{runtime!r} is hardcoded in the title builder; the name must come " + "from prog.runtimes, never from source" + ) + + +def test_render_sets_the_title_from_detected_runtimes(): + """A helper nobody calls is dead code: pin the wiring too.""" + js = _read(APP_JS) + body = js[js.index("function _cmSyncRender(prog, health) {"):] + body = body[: body.index("\nasync function _cmSyncTick")] + assert "_cmSyncScopeTitle(prog)" in body, ( + "_cmSyncRender no longer sets the title from the detected runtimes" + ) + + +# ── 2. The endpoint carries the runtimes ───────────────────────────────────── + +class _NullArgs: + """Stands in for the CLI argparse Namespace. + + ``detect_config()`` registers these routes, and its ``args=None`` path + builds a bare ``argparse.Namespace`` that then AttributeErrors on + ``args.log_dir``. Every attribute here answers None, so each ``if args and + args.x`` falls through to the normal auto-detection. + """ + + def __getattr__(self, name): + return None + + +@pytest.fixture(scope="module") +def client(): + import dashboard as _d + # The sync-progress route is registered inside detect_config(), not at + # import, so a bare `import dashboard` has an empty url_map. + if not any("/api/sync-progress" in str(r) for r in _d.app.url_map.iter_rules()): + _d.detect_config(_NullArgs()) + _d.app.config["TESTING"] = True + return _d.app.test_client() + + +def test_sync_progress_includes_runtimes_when_file_present(client, monkeypatch, tmp_path): + prog = tmp_path / "sync_progress.json" + prog.write_text(json.dumps({"phase": "session_metadata", "status": "running"})) + + real_expanduser = os.path.expanduser + + def fake_expanduser(p): + if p == "~/.clawmetry/sync_progress.json": + return str(prog) + return real_expanduser(p) + + monkeypatch.setattr(os.path, "expanduser", fake_expanduser) + r = client.get("/api/sync-progress") + assert r.status_code == 200 + body = r.get_json() + assert "runtimes" in body, "banner has nothing to name without a runtimes list" + assert isinstance(body["runtimes"], list) + + +def test_sync_progress_includes_runtimes_even_on_404(client, monkeypatch): + """Cold install: no progress file yet, but the banner still renders. It + must still be able to name the runtimes rather than falling back to a lie.""" + real_isfile = os.path.isfile + monkeypatch.setattr( + os.path, "isfile", + lambda p: False if str(p).endswith("sync_progress.json") else real_isfile(p), + ) + r = client.get("/api/sync-progress") + assert r.status_code == 404 + assert isinstance(r.get_json().get("runtimes"), list) + + +# ── 3. Detection is off the request path, and honest ───────────────────────── + +def test_scope_lookup_never_blocks_the_request(): + """Detection globs session dirs and measured ~3.3s. The banner polls, so a + blocking lookup would stall every poll. It must serve a cache.""" + import dashboard as _d + t0 = time.monotonic() + out = _d._sync_scope_runtimes() + elapsed = time.monotonic() - t0 + assert isinstance(out, list) + assert elapsed < 0.5, f"_sync_scope_runtimes blocked for {elapsed:.2f}s" + + +def test_zero_session_runtimes_are_never_named(monkeypatch): + """Presence is not usage. The Cursor IDE creates its state dir whether or + not the agent was ever run; naming it would be a different lie.""" + import dashboard as _d + from clawmetry import sync as _sync_mod + + monkeypatch.setattr( + _sync_mod, "_detect_runtimes_lite", + lambda: [{"id": "cursor", "label": "Cursor", "sessions": 0}, + {"id": "codex", "label": "Codex", "sessions": 7}], + raising=False, + ) + monkeypatch.setattr(_sync_mod, "_detect_family_runtimes", lambda: [], raising=False) + monkeypatch.setattr( + "clawmetry.adapters.registry.detect_all", lambda: [], raising=False + ) + monkeypatch.setattr( + _d, "_SYNC_SCOPE_CACHE", + {"ts": 0.0, "runtimes": [], "running": False}, raising=False, + ) + ids = {r["id"] for r in _d._sync_scope_refresh()} + assert "codex" in ids, "a runtime with real sessions must be named" + assert "cursor" not in ids, "a 0-session runtime must never be named" + + +def test_detection_failure_clears_the_inflight_flag(monkeypatch): + """A raise inside the refresh thread must not wedge the cache forever.""" + import dashboard as _d + monkeypatch.setattr( + _d, "_SYNC_SCOPE_CACHE", + {"ts": 0.0, "runtimes": [], "running": True}, raising=False, + ) + def boom(): + raise RuntimeError("detector exploded") + monkeypatch.setattr(_d, "_sync_scope_refresh", boom, raising=False) + _d._sync_scope_refresh_safe() + assert _d._SYNC_SCOPE_CACHE["running"] is False