Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions clawmetry/static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) || '';
Expand Down
2 changes: 1 addition & 1 deletion clawmetry/templates/partials/banners.html
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@
<span id="sync-status-icon" style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#6366f1;animation:cm-sync-pulse 1.4s ease-in-out infinite;"></span>
</span>
<div style="flex:1;min-width:220px;">
<div id="sync-status-title" style="font-weight:600;color:#e2e8f0;">Syncing your OpenClaw workspace</div>
<div id="sync-status-title" style="font-weight:600;color:#e2e8f0;">Syncing your AI agents</div>
<div id="sync-status-subtitle" style="font-size:11px;color:#94a3b8;margin-top:2px;">Discovering sessions and indexing events. Your data will appear shortly.</div>
</div>
<div id="sync-status-stepper" style="display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end;"></div>
Expand Down
127 changes: 125 additions & 2 deletions dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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

Expand Down
180 changes: 180 additions & 0 deletions tests/test_sync_banner_names_real_runtimes.py
Original file line number Diff line number Diff line change
@@ -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
Loading