diff --git a/clawmetry/sync.py b/clawmetry/sync.py index 1cf4c71d42..4c2e2c370a 100644 --- a/clawmetry/sync.py +++ b/clawmetry/sync.py @@ -17682,6 +17682,98 @@ def _reliability_score_session(events): } +_QUALITY_SNAPSHOT_WINDOW_HOURS = 168 # 7d — what the Quality tab asks for + + +def _build_quality_snapshot(): + """The Quality report card, node-wide and per runtime, for the snapshot. + + Founder live-hit 2026-08-22: the hosted Quality tab said "Nothing to grade + yet" for a machine whose local tab showed an A over 119 graded runs. The + grade had never ridden the snapshot at all, and the hosted container + answered the request from its OWN DuckDB — which exists but is empty, so + it reported "no runs" instead of failing. An empty answer and an + unreachable machine looked identical on screen and meant opposite things. + + Read once, compose many: the node's sessions are queried ONCE (current + window, prior window, and a 30-day history for calibration) and grouped in + Python, so a card per runtime costs no extra queries. Sessions are graded + at ingest, so composing is mostly dict lookups; the bounded deep scan runs + once over the node's rows and every per-runtime card reuses that map. + + Returns ``{}`` on any failure — a snapshot must never fail to build over + one optional slice, and the cloud falls back to saying the grade lives on + your machine. + """ + try: + from datetime import timedelta # module scope imports datetime/timezone only + + from clawmetry import local_store as _ls + from clawmetry import quality_thresholds as _qt + from routes.quality import _assess_rows, compose_report_card + + store = _ls.get_store() + if store is None: + return {} + + hours = _QUALITY_SNAPSHOT_WINDOW_HOURS + now = datetime.now(timezone.utc) + since = (now - timedelta(hours=hours)).isoformat() + prior_since = (now - timedelta(hours=hours * 2)).isoformat() + hist_since = (now - timedelta(hours=24 * 30)).isoformat() + + rows = store.query_quality_sessions(since=since, limit=400) or [] + prior_rows = store.query_quality_sessions( + since=prior_since, until=since, limit=400) or [] + hist_rows = store.query_quality_sessions(since=hist_since, limit=1500) or [] + + by_rt_hist = {} + for h in hist_rows: + by_rt_hist.setdefault(h.get("runtime") or "openclaw", []).append(h) + thresholds = _qt.calibrate_all(by_rt_hist) + assessments = _assess_rows(rows, thresholds) + prior_assessments = _assess_rows(prior_rows, thresholds, deep_limit=0) + + def _card(sub_rows, sub_prior, runtime): + return compose_report_card( + sub_rows, sub_prior, hist_rows, + window_hours=hours, runtime=runtime, + assessments=assessments, prior_assessments=prior_assessments, + ) + + out = { + "window_hours": hours, + "all": _card(rows, prior_rows, None), + "byRuntime": {}, + } + # Calibration is identical in every card (same 30-day history), so it + # is carried ONCE here and re-attached client-side. Left inline it was + # ~1.9 kB duplicated across fourteen cards, a quarter of the slice + # spent saying the same thing. + out["thresholds"] = out["all"].get("thresholds") or {} + # Per-runtime cards so the hosted tab stays honest under the runtime + # switcher: a grade shown while a single runtime is selected must be + # THAT runtime's grade, never the node's total wearing its name. + # Every runtime the machine has run in the last 30 days, not only those + # with sessions THIS week: a runtime that was quiet this week needs a + # card saying so in its own name. Without one the hosted tab would fall + # back to the node-wide card and show another runtime's grade under + # this runtime's filter. + runtimes = {(r.get("runtime") or "openclaw") for r in rows} + runtimes |= {(h.get("runtime") or "openclaw") for h in hist_rows} + for rt in runtimes: + sub = [r for r in rows if (r.get("runtime") or "openclaw") == rt] + sub_prior = [r for r in prior_rows + if (r.get("runtime") or "openclaw") == rt] + out["byRuntime"][rt] = _card(sub, sub_prior, rt) + for _c in [out["all"], *out["byRuntime"].values()]: + _c.pop("thresholds", None) + return out + except Exception as exc: + log.debug(f"quality snapshot build failed (continuing): {exc}") + return {} + + def _build_reliability(limit_sessions=25, min_sessions=4): """Agent Reliability score for the cloud Pro Reliability tab (P1). @@ -21540,6 +21632,7 @@ def sync_system_snapshot(config: dict, state: dict, paths: dict) -> int: "governance": _build_governance(), "dailyUsage": _du, # #2142: computed once above, shared with `spending` "reliability": _build_reliability(), + "quality": _build_quality_snapshot(), "memoryAccess": _build_memory_access(), "traces": _build_traces(), "turnAnatomy": _build_turn_anatomy(), diff --git a/dashboard.py b/dashboard.py index bca49a4df0..d02b6f0992 100644 --- a/dashboard.py +++ b/dashboard.py @@ -3963,7 +3963,7 @@ def get_local_ip(): .theme-toggle { background: var(--button-bg); border: none; border-radius: 8px; padding: 8px 12px; color: var(--text-tertiary); cursor: pointer; font-size: 16px; margin-left: 12px; transition: all 0.15s; box-shadow: var(--card-shadow); } .theme-toggle:hover { background: var(--button-hover); color: var(--text-secondary); } .theme-toggle:active { transform: scale(0.98); } - + /* === Zoom Controls === */ .zoom-controls { display: flex; align-items: center; gap: 4px; margin-left: 12px; } .zoom-btn { background: var(--button-bg); border: 1px solid var(--border-primary); border-radius: 6px; width: 28px; height: 28px; color: var(--text-tertiary); cursor: pointer; font-size: 16px; font-weight: 700; display: flex; align-items: center; justify-content: center; transition: all 0.15s; } @@ -4391,7 +4391,7 @@ def get_local_ip(): .usage-table th { text-align: left; font-size: 12px; color: var(--text-muted); padding: 8px 12px; border-bottom: 1px solid var(--border-primary); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; } .usage-table td { padding: 8px 12px; font-size: 13px; color: var(--text-secondary); border-bottom: 1px solid var(--border-secondary); } .usage-table tr:last-child td { border-bottom: none; font-weight: 700; color: var(--text-accent); } - + /* === Cost Warnings === */ .cost-warning { padding: 12px 16px; border-radius: 8px; margin-bottom: 8px; display: flex; align-items: center; gap: 10px; font-size: 13px; } /* === Markdown Rendered Content === */ @@ -4576,7 +4576,7 @@ def get_local_ip(): .comp-modal-title { font-size: 18px; font-weight: 700; color: var(--text-primary); } .comp-modal-close { background: var(--button-bg); border: 1px solid var(--border-primary); border-radius: 8px; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 18px; color: var(--text-tertiary); transition: all 0.15s; } .comp-modal-close:hover { background: var(--bg-error); color: var(--text-error); } - + /* Time Travel Controls */ .time-travel-bar { display: none; padding: 12px 20px; border-bottom: 1px solid var(--border-primary); background: var(--bg-secondary); } .time-travel-bar.active { display: block; } @@ -4822,18 +4822,18 @@ def get_local_ip(): .heatmap-grid { min-width: 500px; } .chat-msg { max-width: 95%; } .usage-chart { height: 150px; } - + /* Enhanced Flow mobile optimizations */ - .flow-container { - padding-bottom: 20px; - overflow: visible; + .flow-container { + padding-bottom: 20px; + overflow: visible; } #flow-svg text { font-size: 11px !important; } .flow-label { font-size: 7px !important; } .flow-node rect { stroke-width: 1 !important; } .flow-node.active rect { stroke-width: 1.5 !important; } .brain-group { animation-duration: 1.8s; } /* Faster on mobile */ - + /* Mobile zoom controls */ .zoom-controls { margin-left: 8px; gap: 2px; } .zoom-btn { width: 24px; height: 24px; font-size: 14px; } @@ -4872,7 +4872,7 @@ def get_local_ip(): .card { padding: 12px 14px; } .card-label { font-size: 10px; } .card-value { font-size: 20px; } - + /* Overview grid already 1-col, just tighten gap */ .grid { gap: 8px; } @@ -5563,7 +5563,7 @@ def get_local_ip(): - + @@ -6179,7 +6179,7 @@ def get_local_ip():
- +
@@ -7500,7 +7500,7 @@ def get_local_ip(): const body = document.body; const toggle = document.getElementById('theme-toggle-btn'); const isLight = !body.hasAttribute('data-theme') || body.getAttribute('data-theme') !== 'dark'; - + if (isLight) { body.setAttribute('data-theme', 'dark'); toggle.innerHTML = _sunSVG; @@ -7518,7 +7518,7 @@ def get_local_ip(): const savedTheme = 'dark'; localStorage.setItem('openclaw-theme', 'dark'); const body = document.body; const toggle = document.getElementById('theme-toggle-btn'); - + if (savedTheme === 'dark') { body.setAttribute('data-theme', 'dark'); if (toggle) { toggle.innerHTML = _sunSVG; toggle.title = 'Switch to light theme'; } @@ -7545,14 +7545,14 @@ def get_local_ip(): function applyZoom() { const wrapper = document.getElementById('zoom-wrapper'); const levelDisplay = document.getElementById('zoom-level'); - + if (wrapper) { wrapper.style.transform = `scale(${currentZoom})`; } if (levelDisplay) { levelDisplay.textContent = Math.round(currentZoom * 100) + '%'; } - + // Save to localStorage localStorage.setItem('openclaw-zoom', currentZoom.toString()); } @@ -8125,12 +8125,12 @@ def get_local_ip(): } async function loadMiniWidgets(overview, usage) { - // 💰 Cost Ticker + // 💰 Cost Ticker function fmtCost(c) { return c >= 0.01 ? '$' + c.toFixed(2) : c > 0 ? '<$0.01' : '$0.00'; } document.getElementById('cost-today').textContent = fmtCost(usage.todayCost || 0); document.getElementById('cost-week').textContent = fmtCost(usage.weekCost || 0); document.getElementById('cost-month').textContent = fmtCost(usage.monthCost || 0); - + var trend = ''; if (usage.trend && usage.trend.trend) { var trendIcon = usage.trend.trend === 'increasing' ? '📈' : usage.trend.trend === 'decreasing' ? '📉' : '➡️'; @@ -8203,15 +8203,15 @@ def get_local_ip(): if (burnFallback) burnFallback.textContent = '--'; if (projFallback) projFallback.textContent = '--'; } - + // ⚡ Tool Activity (load from logs) loadToolActivity(); - + // 📊 Token Burn Rate function fmtTokens(n) { return n >= 1000000 ? (n/1000000).toFixed(1) + 'M' : n >= 1000 ? (n/1000).toFixed(0) + 'K' : String(n); } document.getElementById('token-rate').textContent = fmtTokens(usage.month || 0); document.getElementById('tokens-today').textContent = fmtTokens(usage.today || 0); - + // 🔥 Hot Sessions -- use /api/sessions for consistency with modal fetch('/api/sessions').then(function(r){return r.json()}).then(function(sd) { var sl = sd.sessions || sd || []; @@ -8229,7 +8229,7 @@ def get_local_ip(): }).catch(function() { document.getElementById('hot-sessions-count').textContent = overview.sessionCount || 0; }); - + // 📈 Model Mix document.getElementById('model-primary').textContent = overview.model || 'unknown'; var modelLabel = document.getElementById('main-activity-model'); @@ -8251,10 +8251,10 @@ def get_local_ip(): modelBreakdown = 'Primary model'; } document.getElementById('model-breakdown').textContent = modelBreakdown; - + // 🐝 Worker Bees (Sub-Agents) loadSubAgents(); - + } async function loadSubAgents() { @@ -8262,10 +8262,10 @@ def get_local_ip(): var data = await fetch('/api/subagents').then(r => r.json()); var counts = data.counts; var subagents = data.subagents; - + // Update main counter document.getElementById('subagents-count').textContent = counts.total; - + // Update status text var statusText = ''; if (counts.active > 0) { @@ -8278,7 +8278,7 @@ def get_local_ip(): statusText = 'All idle/stale'; } document.getElementById('subagents-status').textContent = statusText; - + // Update preview with top sub-agents (human-readable) var previewHtml = ''; if (subagents.length === 0) { @@ -8297,14 +8297,14 @@ def get_local_ip(): previewHtml += '' + agent.runtime + ''; previewHtml += '
'; }); - + if (subagents.length > 3) { previewHtml += '
+' + (subagents.length - 3) + ' more
'; } } - + document.getElementById('subagents-preview').innerHTML = previewHtml; - + } catch(e) { document.getElementById('subagents-count').textContent = '?'; document.getElementById('subagents-status').textContent = 'Error loading sub-agents'; @@ -8427,28 +8427,28 @@ def get_local_ip(): var logs = await fetch('/api/logs?lines=100').then(r => r.json()); var toolCounts = { exec: 0, browser: 0, search: 0, other: 0 }; var recentTools = []; - + logs.lines.forEach(function(line) { var msg = line.toLowerCase(); if (msg.includes('tool') || msg.includes('invoke')) { - if (msg.includes('exec') || msg.includes('shell')) { - toolCounts.exec++; recentTools.push('exec'); - } else if (msg.includes('browser') || msg.includes('screenshot')) { - toolCounts.browser++; recentTools.push('browser'); - } else if (msg.includes('web_search') || msg.includes('web_fetch')) { - toolCounts.search++; recentTools.push('search'); + if (msg.includes('exec') || msg.includes('shell')) { + toolCounts.exec++; recentTools.push('exec'); + } else if (msg.includes('browser') || msg.includes('screenshot')) { + toolCounts.browser++; recentTools.push('browser'); + } else if (msg.includes('web_search') || msg.includes('web_fetch')) { + toolCounts.search++; recentTools.push('search'); } else { toolCounts.other++; } } }); - + document.getElementById('tools-active').textContent = recentTools.slice(0, 3).join(', ') || 'Idle'; document.getElementById('tools-recent').textContent = 'Last ' + Math.min(logs.lines.length, 100) + ' log entries'; - + var sparks = document.querySelectorAll('.tool-spark span'); sparks[0].textContent = toolCounts.exec; - sparks[1].textContent = toolCounts.browser; + sparks[1].textContent = toolCounts.browser; sparks[2].textContent = toolCounts.search; } catch(e) { document.getElementById('tools-active').textContent = '--'; @@ -8459,26 +8459,26 @@ def get_local_ip(): try { var transcripts = await fetchJsonWithTimeout('/api/transcripts', 4000); var activities = []; - + // Get the most recent transcript to parse for activity if (transcripts.transcripts && transcripts.transcripts.length > 0) { var recent = transcripts.transcripts[0]; try { var transcript = await fetchJsonWithTimeout('/api/transcript/' + recent.id, 4000); var recentMessages = transcript.messages.slice(-10); // Last 10 messages - + recentMessages.forEach(function(msg) { if (msg.role === 'assistant' && msg.content) { var content = msg.content.toLowerCase(); var activity = ''; var time = new Date(msg.timestamp || Date.now()).toLocaleTimeString(); - + if (content.includes('searching') || content.includes('search')) { activity = time + ' [check] Searching web for information'; } else if (content.includes('reading') || content.includes('file')) { activity = time + ' 📖 Reading files'; } else if (content.includes('writing') || content.includes('edit')) { - activity = time + ' ✏️ Editing files'; + activity = time + ' ✏️ Editing files'; } else if (content.includes('exec') || content.includes('command')) { activity = time + ' ⚡ Running commands'; } else if (content.includes('browser') || content.includes('screenshot')) { @@ -8487,24 +8487,24 @@ def get_local_ip(): var preview = msg.content.substring(0, 80).replace(/[^\w\s]/g, ' ').trim(); activity = time + ' 💭 ' + preview + '...'; } - + if (activity) activities.push(activity); } }); } catch(e) {} } - + if (activities.length === 0) { activities = [ new Date().toLocaleTimeString() + ' 🤖 AI agent initialized', new Date().toLocaleTimeString() + ' 📡 Monitoring for activity...' ]; } - + var html = activities.slice(-8).map(function(a) { return '
' + escHtml(a) + '
'; }).join(''); - + document.getElementById('activity-stream').innerHTML = html; } catch(e) { document.getElementById('activity-stream').innerHTML = '
Error loading activity stream
'; @@ -12770,11 +12770,11 @@ def validate_configuration(): path = os.path.join(WORKSPACE, f) if os.path.exists(path): found_files.append(f) - + if not found_files: warnings.append(f"[warn] No OpenClaw workspace files found in {WORKSPACE}") tips.append("[tip] Create SOUL.md, AGENTS.md, or MEMORY.md to set up your agent workspace") - + # Check if log directory exists and has recent logs if not os.path.exists(LOG_DIR): warnings.append(f"[warn] Log directory doesn't exist: {LOG_DIR}") @@ -12782,17 +12782,17 @@ def validate_configuration(): else: # Check for recent log files log_pattern = os.path.join(LOG_DIR, "*claw*.log") - recent_logs = [f for f in glob.glob(log_pattern) + recent_logs = [f for f in glob.glob(log_pattern) if os.path.getmtime(f) > time.time() - 86400] # Last 24h if not recent_logs: warnings.append(f"[warn] No recent log files found in {LOG_DIR}") tips.append("[tip] Start your OpenClaw agent to see real-time data") - + # Check if sessions directory exists if not SESSIONS_DIR or not os.path.exists(SESSIONS_DIR): warnings.append(f"[warn] Sessions directory not found: {SESSIONS_DIR}") tips.append("[tip] Sessions will appear when your agent starts conversations") - + return warnings, tips @@ -12870,13 +12870,14 @@ def detect_config(args=None): else: # Auto-detect: check common locations including Docker volumes data_dir = _auto_detect_data_dir() - + if data_dir and os.path.isdir(data_dir): # Auto-set workspace, sessions, crons from data dir ws = os.path.join(data_dir, 'workspace') if os.path.isdir(ws) and not (args and args.workspace): if not args: - import argparse; args = argparse.Namespace() + import argparse + args = argparse.Namespace() args.workspace = ws sess = os.path.join(data_dir, 'agents', 'main', 'sessions') if os.path.isdir(sess) and not (args and getattr(args, 'sessions_dir', None)): diff --git a/docs/blueprints/quality-cloud-parity.md b/docs/blueprints/quality-cloud-parity.md new file mode 100644 index 0000000000..ac1dd19e42 --- /dev/null +++ b/docs/blueprints/quality-cloud-parity.md @@ -0,0 +1,200 @@ +# Feature Blueprint: Quality Grade Cloud Parity + +> Local mirror of the Software Factory Feature Blueprint of the same name. +> The hosted record is the system of record; this file exists so drift-bot, +> PR review, and headless agents can read the same spec without a factory +> login. When the hosted blueprint is created or updated, keep this file in +> step (FLYWHEEL 1f). + +## Feature Summary + +A hosted ClawMetry user viewing the Quality tab should see the same grade +that the local dashboard shows for their machine. Before this feature, the +hosted container answered quality requests from its own DuckDB — which is +present but empty — and reported a working machine as having produced nothing. +The grade now travels in the encrypted snapshot the daemon emits on each +sync cycle, and the hosted process refuses to answer from its own store. + +The root cause was two coupled faults: the quality grade never had a snapshot +slice, so the cloud had nothing to render; and the hosted endpoint fell back +to reading its own empty store rather than returning an honest "not available" +response. Both faults produced the same screen — "Nothing to grade yet" — for +opposite reasons. + +## Component Blueprint Composition + +This feature extends two existing components without redefining them. + +`#CloudSnapshotSync` in `clawmetry/sync.py` bakes the encrypted snapshot the +hosted dashboard renders. Per the cloud-parity hard gate, any data surface +that a hosted trial user can click must be served by a `cm-cloud-*` +interceptor over a snapshot slice, or must show an honest locked state. The +Quality tab inherited that obligation but had no slice; this feature adds one. + +`#QualityRoutes` in `routes/quality.py` serves `GET /api/quality/report-card` +and owns the composition logic for the report card payload. This feature +extracts the composition into a shared callable (`compose_report_card`) so +both the request handler and the daemon build identical payloads from the same +code path, eliminating the divergence that produced a different answer on +screen depending on where the request was served. + +## Feature-Specific Components + +```component +name: QualitySnapshotBuilder +container: ClawMetry Sync Daemon +responsibilities: + - Reading the node's quality sessions exactly three times per snapshot cycle: + the current window, the prior window, and a 30-day calibration history + - Calling compose_report_card once for the node-wide card and once per + runtime seen in either the current window or the 30-day history + - Hoisting calibration thresholds to the slice root rather than embedding + them in each per-runtime card + - Returning an empty dict {} on any exception so the snapshot never fails + to build over one optional slice +``` + +`#QualitySnapshotBuilder` is implemented as `_build_quality_snapshot()` in +`clawmetry/sync.py`, called from `sync_system_snapshot()`. It imports +`compose_report_card` from `routes.quality` at call time (late import, same +pattern as the other snapshot builders that use route-layer helpers). + +The three-read constraint is load-bearing: the daemon runs this builder on +every snapshot cycle, and emitting one card per runtime must not multiply the +store reads proportionally. Rows are grouped in Python after the three fetches; +no additional queries are made regardless of how many runtimes are present. + +The quiet-runtime rule is also load-bearing: a runtime that ran sessions in +the last 30 days but was quiet this week must still appear in `byRuntime` with +an honest "nothing to grade this week" card. Without it, the hosted tab would +fall back to the node-wide card when a runtime filter was selected and show +another runtime's grade under the wrong runtime's name. + +## System Contracts + +### Key Contracts + +The `quality` snapshot slice has the shape +`{window_hours, all, byRuntime, thresholds}`. `all` is the node-wide report +card. `byRuntime` is a dict keyed by runtime identifier; each value is the +report card for that runtime and never contains sessions belonging to a +different runtime. `thresholds` is the calibration map, hoisted once to the +slice root and absent from each per-runtime card (it is byte-identical across +cards and carrying it inline multiplied the slice size by the runtime count). + +`compose_report_card` in `routes/quality.py` is the single canonical builder +for the report card payload. Both the sync daemon (`_build_quality_snapshot`) +and the request handler (`quality_report_card`) call it. No other code path +may build a report card payload; duplicating the composition logic is the +drift pattern this feature closes. + +The store is read exactly three times per `_build_quality_snapshot()` call: +once for the current window (the `window_hours` parameter, default 168h), +once for the prior window of the same length (for week-over-week comparison), +and once for the 30-day calibration history. Emitting one card per runtime +must not add queries: rows are split in Python, and `_assess_rows` is called +once over the node's full window rows with its assessment map reused across +every per-runtime card. + +Every runtime that appears in either the current window rows or the 30-day +history rows gets its own entry in `byRuntime`. A runtime that was quiet this +week but active in the last 30 days gets a card stating it has nothing to +grade this week, in its own name. Absence from the current window is not +grounds for omission from `byRuntime`. + +The `quality` slice returns `{}` on any exception and must never propagate an +exception to `sync_system_snapshot`. A misconfiguration, an import failure, or +a store error in the quality builder must not prevent the rest of the snapshot +from being emitted. + +### Integration Contracts + +`GET /api/quality/report-card` when served by the hosted dashboard +(`CLAWMETRY_CLOUD=1`) always returns `store_available: false` with a message +explaining that the grade lives on the user's machine. It never reads the +hosted container's DuckDB for this response. The distinction matters because +an empty store answers queries successfully with zero rows, which produces +"Nothing to grade yet" — a false statement about a machine that is grading +normally. `store_available: false` is an honest "I cannot see your machine" +and must not say "Nothing to grade". + +`GET /api/quality/report-card` when served by a local dashboard +(`CLAWMETRY_CLOUD` unset) reads from the daemon query path and returns +`store_available: true` when the store answers (even with zero rows). +An empty local store is a true statement that this machine has no graded runs +this week; the response may say "Nothing to grade yet" in that case. + +An unreachable store — `_store_via_daemon_or_direct` returning `None` rather +than `[]` — is not an empty grade. The local handler returns +`store_available: false` in that case, using the same message as the hosted +process. The distinction between `None` (store unreachable) and `[]` (store +answered with nothing) is preserved through the entire call chain. + +### Integration Boundaries + +`compose_report_card` is importable from `routes.quality` by both the sync +daemon and the request handler. Its signature takes rows as arguments rather +than a fetch callable: the daemon fetches once and passes sub-lists; the +request handler passes the rows it has already fetched. Neither path is +special-cased inside the function. + +The sync daemon never calls `_store_via_daemon_or_direct`. It holds the writer +lock and reads from `local_store.get_store()` directly. The request handler +uses `_store_via_daemon_or_direct` to read through the daemon's query server. +The two paths are separated at the module boundary: `_build_quality_snapshot` +is in `sync.py`; `quality_report_card` is in `routes/quality.py`. + +The `CLAWMETRY_CLOUD` environment variable is the only signal the request +handler reads to decide whether to serve from the snapshot or refuse. It is +not threaded through as a function argument; `os.environ.get` is called at +request time. + +## Architecture Decision Records + +### ADR-001: Extract compose_report_card rather than duplicate composition + +Context: the request handler in `routes/quality.py` built the report card +payload inline. The sync daemon needed to build the same payload for the +snapshot. Duplicating the inline logic would have been the path of least +resistance but would have produced two code paths that could diverge silently. + +Decision: extract the composition into `compose_report_card`, make it accept +rows as arguments (rather than fetching internally), and have both callers +pass their already-fetched rows to it. + +Consequences: one code path for the report card payload. The daemon's +per-runtime cards are provably identical in structure to what the local request +handler would return, because they use the same function. The test +`test_precomputed_assessments_match_the_request_path` pins this. + +### ADR-002: CLAWMETRY_CLOUD gates the endpoint; no runtime argument + +Context: the hosted dashboard could signal its nature through a function +argument, a constructor parameter, or an environment variable. The environment +variable already existed (`CLAWMETRY_CLOUD`) and was already read by other +parts of the hosted process to suppress local-only behavior. + +Decision: read `os.environ.get('CLAWMETRY_CLOUD')` at request time in +`quality_report_card`. No new argument is added to the function. + +Consequences: the behavior is consistent with how other endpoints handle the +hosted/local distinction, the environment variable stays the single source of +truth, and tests can toggle the behavior with `monkeypatch.setenv` without +touching function signatures. + +### ADR-003: Calibration thresholds hoisted to slice root, not per-card + +Context: the first version of the snapshot slice embedded the full calibration +thresholds in every per-runtime card. At 30 runtimes the thresholds were +repeated 14 times; each copy was byte-identical. The thresholds made up +roughly a quarter of the slice by size. + +Decision: hoist `thresholds` to the slice root as a single key, and strip it +from every per-runtime card before returning. The cloud interceptor reattaches +it per card when composing the response. + +Consequences: slice size scales with the number of runtimes for the `byRuntime` +keys (unavoidable), but not for the calibration data (avoided). The per-card +strip is visible in `_build_quality_snapshot` after the per-runtime loop. +The test `test_calibration_is_carried_once_not_per_card` pins the absence of +`thresholds` in every individual card. diff --git a/routes/quality.py b/routes/quality.py index 395041d66e..85a2789a41 100644 --- a/routes/quality.py +++ b/routes/quality.py @@ -30,6 +30,7 @@ from __future__ import annotations +import os from datetime import datetime, timedelta, timezone from flask import Blueprint, jsonify, request @@ -91,72 +92,49 @@ def _iso_cutoff(hours: int) -> str: return (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat() -@bp_quality.route("/api/quality/report-card", methods=["GET"]) -def quality_report_card(): - """Everything the Quality tab renders, in one fetch. - - ``?window=7d`` (default), ``?runtime=`` (optional scope). No auth gate - — this is the free-tier home for the "is my agent OK?" answer, same - rationale as /api/evaluators and /api/evals/metrics. +def compose_report_card( + rows: list, + prior_rows: list, + hist_rows: list, + *, + window_hours: int = 168, + runtime: str | None = None, + assessments: dict | None = None, + prior_assessments: dict | None = None, +) -> dict: + """Build the Quality payload from rows already read out of the store. + + Split out of the request handler so the sync daemon can build the SAME + payload for the cloud snapshot without re-querying per runtime (founder + live-hit 2026-08-22: the hosted Quality tab said "Nothing to grade yet" + for a machine showing an A and 119 graded runs locally — the hosted + container answered from its own empty DuckDB, because nothing ever put + quality in the snapshot). + + Taking rows as arguments rather than a fetch callable is deliberate: the + daemon reads the node's sessions ONCE and groups them in Python, so + emitting a card per runtime costs no extra queries. """ from clawmetry import quality as _q from clawmetry import quality_thresholds as _qt - window_hours = _parse_window(request.args.get("window", "7d")) - runtime = (request.args.get("runtime") or "").strip() or None - if runtime == "all": - runtime = None - - since = _iso_cutoff(window_hours) - prior_since = _iso_cutoff(window_hours * 2) - - raw_rows = _store_via_daemon_or_direct( - "query_quality_sessions", - runtime=runtime, since=since, limit=400, - ) - # None means the store could not be reached at all (the hosted dashboard - # has no local DuckDB); [] means it answered and there is nothing there. - # Collapsing the two would tell a cloud user "nothing to grade yet" about - # a machine that is in fact working fine — a wrong answer dressed as an - # empty state. FLYWHEEL cloud-parity gate: be honestly unavailable. - if raw_rows is None: - payload = _q.compute_report_card([], {}) - payload.update({ - "window_hours": window_hours, - "runtime": runtime or "all", - "store_available": False, - "headline": "Quality is graded on your own machine.", - "subline": ( - "This view reads the run history stored locally by the " - "collector, which isn't reachable from here right now. " - "Open ClawMetry on the machine your agents run on, or start " - "the collector there, and the grade appears." - ), - }) - return jsonify(payload) - rows = raw_rows - prior_rows = _store_via_daemon_or_direct( - "query_quality_sessions", - runtime=runtime, since=prior_since, until=since, limit=400, - ) or [] - - # Calibrate per runtime off a 30-day history of the SAME runtime, so - # "rough" means unusual for this runtime on this install. - hist = _store_via_daemon_or_direct( - "query_quality_sessions", - runtime=runtime, since=_iso_cutoff(24 * 30), limit=1500, - ) or [] - by_runtime_hist: dict[str, list[dict]] = {} - for h in hist: + by_runtime_hist: dict = {} + for h in hist_rows or []: by_runtime_hist.setdefault(h.get("runtime") or "openclaw", []).append(h) thresholds = _qt.calibrate_all(by_runtime_hist) - assessments = _assess_rows(rows, thresholds) - prior_assessments = _assess_rows(prior_rows, thresholds, deep_limit=0) + # Pre-computed assessments are how the daemon emits a card PER RUNTIME + # without paying for a deep scan per runtime: it assesses the node's rows + # once and hands the same map to every card, which then reads only the + # sessions it contains. Passing none keeps the request-path behaviour. + if assessments is None: + assessments = _assess_rows(rows, thresholds) + if prior_assessments is None: + prior_assessments = _assess_rows(prior_rows or [], thresholds, deep_limit=0) payload = _q.compute_report_card( rows, assessments, - prior_rows=prior_rows, prior_assessments=prior_assessments, + prior_rows=prior_rows or [], prior_assessments=prior_assessments, ) payload["window_hours"] = window_hours payload["runtime"] = runtime or "all" @@ -180,7 +158,85 @@ def quality_report_card(): } payload["benign_filter"] = _benign_filter_state() payload["store_available"] = True - return jsonify(payload) + return payload + + +def unavailable_report_card(window_hours: int = 168, runtime: str | None = None) -> dict: + """The honest answer when this process cannot see the run history. + + Never an empty grade: "nothing to grade" and "I cannot see your machine + from here" look identical on screen and mean opposite things. The hosted + dashboard hit exactly that — it has a DuckDB file, it is simply empty, so + the store answered [] and the tab reported a working machine as having + produced nothing. + """ + from clawmetry import quality as _q + + payload = _q.compute_report_card([], {}) + payload.update({ + "window_hours": window_hours, + "runtime": runtime or "all", + "store_available": False, + "headline": "Quality is graded on your own machine.", + "subline": ( + "This view reads the run history stored locally by the " + "collector, which isn't reachable from here right now. " + "Open ClawMetry on the machine your agents run on, or start " + "the collector there, and the grade appears." + ), + }) + return payload + + +@bp_quality.route("/api/quality/report-card", methods=["GET"]) +def quality_report_card(): + """Everything the Quality tab renders, in one fetch. + + ``?window=7d`` (default), ``?runtime=`` (optional scope). No auth gate + — this is the free-tier home for the "is my agent OK?" answer, same + rationale as /api/evaluators and /api/evals/metrics. + """ + window_hours = _parse_window(request.args.get("window", "7d")) + runtime = (request.args.get("runtime") or "").strip() or None + if runtime == "all": + runtime = None + + # The hosted dashboard has no run history of its own — it ships with an + # EMPTY DuckDB, which answers queries rather than failing them. Reading it + # here produced "Nothing to grade yet" for machines that were grading + # fine, so the hosted process refuses to answer from it at all. The real + # grade reaches the cloud through the daemon's encrypted snapshot; when + # that slice is missing (older daemon) this honest message is what shows. + if os.environ.get("CLAWMETRY_CLOUD", "").strip(): + return jsonify(unavailable_report_card(window_hours, runtime)) + + since = _iso_cutoff(window_hours) + prior_since = _iso_cutoff(window_hours * 2) + + raw_rows = _store_via_daemon_or_direct( + "query_quality_sessions", + runtime=runtime, since=since, limit=400, + ) + # None means the store could not be reached at all; [] means it answered + # and there is nothing there. Collapsing the two would tell a user + # "nothing to grade yet" about a machine that is in fact working fine. + if raw_rows is None: + return jsonify(unavailable_report_card(window_hours, runtime)) + + prior_rows = _store_via_daemon_or_direct( + "query_quality_sessions", + runtime=runtime, since=prior_since, until=since, limit=400, + ) or [] + # Calibrate per runtime off a 30-day history of the SAME runtime, so + # "rough" means unusual for this runtime on this install. + hist = _store_via_daemon_or_direct( + "query_quality_sessions", + runtime=runtime, since=_iso_cutoff(24 * 30), limit=1500, + ) or [] + return jsonify(compose_report_card( + raw_rows, prior_rows, hist, + window_hours=window_hours, runtime=runtime, + )) def _assess_rows( diff --git a/tests/test_quality_cloud_parity.py b/tests/test_quality_cloud_parity.py new file mode 100644 index 0000000000..7c88794d45 --- /dev/null +++ b/tests/test_quality_cloud_parity.py @@ -0,0 +1,263 @@ +"""Quality reaches the hosted dashboard, and never lies when it cannot. + +Founder live-hit 2026-08-22: the hosted Quality tab said "Nothing to grade +yet" for a machine whose own dashboard showed an A over 119 graded runs, same +node, same runtime. Two faults, one screen: + + 1. The grade never rode the encrypted snapshot, so the cloud had nothing to + render -- the cloud-parity gate every data card is supposed to pass. + 2. The hosted container answered anyway, from its OWN DuckDB. That file + exists and is empty, so the query succeeded with zero rows and the tab + reported a working machine as having produced nothing. "Nothing to + grade" and "I cannot see your machine" look identical and mean opposite + things. + +These tests pin both: the daemon emits the slice (node-wide and per runtime), +and the hosted process refuses to answer from its own empty store. + +Blueprint: docs/blueprints/quality-cloud-parity.md +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +from flask import Flask + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + + +def _row(sid, runtime, cost=1.0, tool_err=0.0): + return { + "session_id": sid, + "runtime": runtime, + "cost_usd": cost, + "message_count": 12, + "toolErrorPct": tool_err, + "maxIdleGapSec": 5, + "status": "completed", + "title": f"task {sid}", + "started_at": "2026-08-20T10:00:00+00:00", + "ended_at": "2026-08-20T10:20:00+00:00", + "last_active_at": "2026-08-20T10:20:00+00:00", + "metadata": {"quality": {"measurable": True, "rough": False, + "signals": {}, "verdicts": []}}, + } + + +class _FakeStore: + """Answers query_quality_sessions the way the daemon's own handle does.""" + + def __init__(self, window_rows, hist_rows=None): + self.window_rows = window_rows + self.hist_rows = hist_rows if hist_rows is not None else window_rows + self.calls = 0 + + def query_quality_sessions(self, since=None, until=None, limit=None, runtime=None): + self.calls += 1 + if until: # prior window + return [] + if limit and limit > 1000: # the 30-day calibration read + return list(self.hist_rows) + return list(self.window_rows) + + +@pytest.fixture +def daemon_store(monkeypatch): + def _install(window_rows, hist_rows=None): + from clawmetry import local_store as ls + store = _FakeStore(window_rows, hist_rows) + monkeypatch.setattr(ls, "get_store", lambda *a, **k: store) + return store + return _install + + +# -- the daemon emits it ------------------------------------------------------- +# Blueprint contract: QualitySnapshotBuilder responsibilities + Key Contracts + +def test_snapshot_carries_node_wide_and_per_runtime_cards(daemon_store): + """The quality slice carries node-wide and per-runtime cards. + + Contract: docs/blueprints/quality-cloud-parity.md > Key Contracts > + quality slice shape {window_hours, all, byRuntime, thresholds}. + """ + from clawmetry import sync + + daemon_store([_row("s1", "claude_code"), _row("s2", "claude_code"), + _row("s3", "openclaw")]) + slice_ = sync._build_quality_snapshot() + + assert slice_["window_hours"] == 168 + assert slice_["all"]["total_runs"] == 3 + assert set(slice_["byRuntime"]) == {"claude_code", "openclaw"} + assert slice_["byRuntime"]["claude_code"]["total_runs"] == 2 + assert slice_["byRuntime"]["openclaw"]["total_runs"] == 1 + + +def test_per_runtime_card_never_carries_another_runtimes_runs(daemon_store): + """A per-runtime card is scoped exclusively to its own runtime. + + Contract: docs/blueprints/quality-cloud-parity.md > Key Contracts > + byRuntime values never contain sessions belonging to a different runtime. + """ + from clawmetry import sync + + daemon_store([_row("a", "claude_code"), _row("b", "codex"), _row("c", "codex")]) + cards = sync._build_quality_snapshot()["byRuntime"] + assert cards["claude_code"]["total_runs"] == 1 + assert cards["codex"]["total_runs"] == 2 + assert cards["claude_code"]["runtime"] == "claude_code" + + +def test_runtime_quiet_this_week_still_gets_its_own_card(daemon_store): + """A runtime active in the last 30 days gets its own card even if quiet now. + + Contract: docs/blueprints/quality-cloud-parity.md > Key Contracts > + Every runtime that appears in either the current window rows or the 30-day + history rows gets its own entry in byRuntime. + """ + from clawmetry import sync + + daemon_store([_row("a", "claude_code")], + hist_rows=[_row("a", "claude_code"), _row("old", "cursor")]) + cards = sync._build_quality_snapshot()["byRuntime"] + assert "cursor" in cards + assert cards["cursor"]["total_runs"] == 0 + assert "nothing to grade" in cards["cursor"]["headline"].lower() + + +def test_calibration_is_carried_once_not_per_card(daemon_store): + """Thresholds are hoisted to the slice root, absent from every per-card. + + Contract: docs/blueprints/quality-cloud-parity.md > ADR-003 and + Key Contracts > thresholds hoisted once to the slice root. + """ + from clawmetry import sync + + daemon_store([_row("a", "claude_code"), _row("b", "codex")]) + slice_ = sync._build_quality_snapshot() + assert "thresholds" in slice_ + assert "thresholds" not in slice_["all"] + for card in slice_["byRuntime"].values(): + assert "thresholds" not in card + + +def test_store_is_read_once_regardless_of_runtime_count(daemon_store): + """The daemon reads the store exactly 3 times per snapshot cycle. + + Contract: docs/blueprints/quality-cloud-parity.md > Key Contracts > + The store is read exactly three times per _build_quality_snapshot() call. + """ + from clawmetry import sync + + store = daemon_store([_row(f"s{i}", rt) for i, rt in + enumerate(["claude_code", "codex", "cursor", + "goose", "openclaw", "pi"])]) + sync._build_quality_snapshot() + assert store.calls == 3, ( + f"expected 3 reads (window, prior, history), got {store.calls}" + ) + + +def test_snapshot_slice_never_raises(monkeypatch): + """A broken store must not prevent the snapshot from being emitted. + + Contract: docs/blueprints/quality-cloud-parity.md > Key Contracts > + The quality slice returns {} on any exception. + """ + from clawmetry import local_store as ls + from clawmetry import sync + + def boom(*a, **k): + raise RuntimeError("store on fire") + + monkeypatch.setattr(ls, "get_store", boom) + assert sync._build_quality_snapshot() == {} + + +# -- the hosted process refuses to answer from its own empty store ------------- +# Blueprint contract: Integration Contracts + +def _app(): + from routes.quality import bp_quality + app = Flask(__name__) + app.register_blueprint(bp_quality) + return app + + +def test_hosted_dashboard_says_where_the_grade_lives(monkeypatch): + """The hosted process returns store_available:false, not an empty grade. + + Contract: docs/blueprints/quality-cloud-parity.md > Integration Contracts > + GET /api/quality/report-card with CLAWMETRY_CLOUD=1 always returns + store_available:false. store_available:false must not say 'Nothing to grade'. + """ + from routes import quality as qmod + + monkeypatch.setenv("CLAWMETRY_CLOUD", "1") + # An empty-but-working store, exactly what the hosted container has. + monkeypatch.setattr(qmod, "_store_via_daemon_or_direct", lambda *a, **k: []) + with _app().test_client() as c: + body = c.get("/api/quality/report-card?window=7d&runtime=claude_code").get_json() + assert body["store_available"] is False + assert "your own machine" in body["headline"].lower() + assert "nothing to grade" not in body["headline"].lower(), ( + "the hosted tab must not report a working machine as having produced " + "nothing -- that is the bug this closes" + ) + + +def test_local_dashboard_still_reports_a_real_empty_week(monkeypatch): + """Off the hosted dashboard, an empty store IS the answer. + + Contract: docs/blueprints/quality-cloud-parity.md > Integration Contracts > + GET /api/quality/report-card without CLAWMETRY_CLOUD reads from the daemon + query path and returns store_available:true when the store answers. + """ + from routes import quality as qmod + + monkeypatch.delenv("CLAWMETRY_CLOUD", raising=False) + monkeypatch.setattr(qmod, "_store_via_daemon_or_direct", lambda *a, **k: []) + with _app().test_client() as c: + body = c.get("/api/quality/report-card?window=7d").get_json() + assert body["store_available"] is True + assert "nothing to grade" in body["headline"].lower() + + +def test_unreachable_store_is_not_an_empty_grade(monkeypatch): + """An unreachable store (None) is distinct from an empty store ([]). + + Contract: docs/blueprints/quality-cloud-parity.md > Integration Contracts > + An unreachable store is not an empty grade -- store_available:false. + """ + from routes import quality as qmod + + monkeypatch.delenv("CLAWMETRY_CLOUD", raising=False) + monkeypatch.setattr(qmod, "_store_via_daemon_or_direct", lambda *a, **k: None) + with _app().test_client() as c: + body = c.get("/api/quality/report-card").get_json() + assert body["store_available"] is False + assert "your own machine" in body["headline"].lower() + + +def test_precomputed_assessments_match_the_request_path(daemon_store): + """Shared assessment map gives identical grade to a fresh per-request assessment. + + Contract: docs/blueprints/quality-cloud-parity.md > ADR-001 > + compose_report_card is the single canonical builder; the daemon's + precomputed-assessment path must produce the same grade as the request path. + """ + from routes.quality import _assess_rows, compose_report_card + from clawmetry import quality_thresholds as qt + + rows = [_row("a", "claude_code", cost=3.0), _row("b", "claude_code")] + thresholds = qt.calibrate_all({"claude_code": rows}) + shared = _assess_rows(rows, thresholds) + + fresh = compose_report_card(rows, [], rows, window_hours=168, runtime="claude_code") + reused = compose_report_card(rows, [], rows, window_hours=168, runtime="claude_code", + assessments=shared, prior_assessments={}) + assert fresh["grade"] == reused["grade"] + assert fresh["total_runs"] == reused["total_runs"] + assert fresh["graded_runs"] == reused["graded_runs"]