operator-plane: headless smoke + effective-config panel + ledger + unpin ruff
Step 6: tests/smoke_dashboard_headless.py renders the real page in a headless browser and fails on any JS console/pageerror (standalone script, not pytest-collected; CI non-blocking first). admin_api gains GET /api/config/effective (per-key masked value + source: overrides.env vs compose, missing .env.example keys flagged as drift) + an admin.html table (values XSS-escaped). dashboard.html gains the Experiment Ledger panel reading the plan-006 verdicts from the snapshot (empty-state until the tables exist). Removes the last CI ruff per-file pin (api/main.py:F821) now that os/hmac are imported; scoped ruff is green unpinned. Marks plan 007 DONE in plans/README.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
21687c97ff
commit
e2b3c1ceae
7 files changed
+244
-6
No files matched your search
@@ -35,15 +35,12 @@ jobs:
|
||||
with: { python-version: "3.13" } # match Dockerfile's python:3.13-slim
|
||||
- run: pip install -r requirements.txt ruff
|
||||
# ruff F gate, scoped to the money path (tests/ and root-level scratch
|
||||
# scripts excluded). Pre-existing errors are pinned per file+rule so NEW
|
||||
# F errors anywhere in scope still fail; remove each pin when its owning
|
||||
# plan lands:
|
||||
# api/main.py F821 (missing os/hmac imports) -> plan 007
|
||||
# scripts excluded). All per-file pins have been cleared as their owning
|
||||
# plans landed (api/main.py F821 os/hmac -> plan 007), so this is unpinned.
|
||||
- run: >-
|
||||
ruff check --select F
|
||||
run_live_paper.py trading_execution.py telegram_bot.py
|
||||
api/ alpaca_paper/ runtime/ storage/ strategy/ research/
|
||||
--per-file-ignores "api/main.py:F821"
|
||||
- run: python -m pytest -q
|
||||
env:
|
||||
DATABASE_URL: postgresql://momentum:momentum@localhost:5432/momentum
|
||||
|
||||
@@ -86,6 +86,56 @@ def _editable(key: str) -> bool:
|
||||
and not any(m in key for m in SECRET_MARKERS))
|
||||
|
||||
|
||||
def _mask_value(key: str, value: str) -> str:
|
||||
"""Mask secret VALUES (*** + last 4). Uses the broader SECRET_MARKERS set so
|
||||
DATABASE_URL / *_WEBHOOK_URL are masked too — narrowing to KEY|SECRET|TOKEN|
|
||||
PASSWORD would leak the DB password in the config table."""
|
||||
if any(m in key.upper() for m in SECRET_MARKERS):
|
||||
tail = value[-4:] if value and len(value) >= 4 else ""
|
||||
return "***" + tail
|
||||
return value
|
||||
|
||||
|
||||
def _env_example_keys() -> set[str]:
|
||||
"""Keys documented in .env.example — the 'required' config surface used for
|
||||
drift detection (a documented key absent from the effective env is drift)."""
|
||||
path = os.path.join(os.path.dirname(__file__), ".env.example")
|
||||
keys: set[str] = set()
|
||||
try:
|
||||
with open(path) as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
keys.add(line.split("=", 1)[0].strip())
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return keys
|
||||
|
||||
|
||||
def effective_config() -> dict:
|
||||
"""Per-key effective value + source for the admin config table.
|
||||
|
||||
Merge order baked < compose < overrides.env (< learned). Sources we can prove
|
||||
at runtime: ``overrides.env`` (from data/overrides.env) vs ``compose`` (the
|
||||
process env — baked and compose are indistinguishable inside the container, so
|
||||
both surface as ``compose``); a documented .env.example key that is absent
|
||||
surfaces as ``missing`` (drift). Secret values are masked."""
|
||||
overrides = env_overrides.read_overrides()
|
||||
example = _env_example_keys()
|
||||
keys = set(example) | set(overrides) | {k for k in os.environ if _editable(k)}
|
||||
out: dict[str, dict] = {}
|
||||
for key in sorted(keys):
|
||||
value = os.environ.get(key)
|
||||
if value is None:
|
||||
out[key] = {"value_masked": None,
|
||||
"source": "missing" if key in example else "unset",
|
||||
"drift": key in example}
|
||||
continue
|
||||
source = "overrides.env" if key in overrides else "compose"
|
||||
out[key] = {"value_masked": _mask_value(key, str(value)), "source": source}
|
||||
return out
|
||||
|
||||
|
||||
# ---------- docker over the unix socket (stdlib only) ----------
|
||||
class _SockConn(http.client.HTTPConnection):
|
||||
def __init__(self):
|
||||
@@ -230,6 +280,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"overrides": _safe_overrides()})
|
||||
if path == "/api/profiles":
|
||||
return self._send(200, profiles())
|
||||
if path == "/api/config/effective":
|
||||
return self._send(200, {"config": effective_config()})
|
||||
return self._send(404, {"error": "unknown route"})
|
||||
|
||||
def do_POST(self): # noqa: N802
|
||||
|
||||
@@ -65,6 +65,9 @@ color:var(--bg);padding:10px 20px;border-radius:10px;font-size:14px;opacity:0;tr
|
||||
<div class="row" style="margin-top:12px"><button onclick="saveCfg()">Save changes</button>
|
||||
<span class="mut" id="cfgnote"></span></div></div>
|
||||
|
||||
<h2>Effective config <span class="mut" style="text-transform:none;letter-spacing:0">— resolved value + source per key; <span class="tag">overrides.env</span> beats compose. Secrets masked. A red row = a documented .env.example key is missing (drift).</span></h2>
|
||||
<div class="card"><table id="effcfg"><thead><tr><th>key</th><th>effective value</th><th>source</th></tr></thead><tbody></tbody></table></div>
|
||||
|
||||
<h2>Trading mode</h2>
|
||||
<div class="card row">
|
||||
<div style="flex:1"><b>Paper ⇄ Live switch</b><div class="mut">Live requires ALPACA_LIVE_API_KEY / ALPACA_LIVE_SECRET_KEY in the deployment env. Switching restarts nothing by itself — restart the loop after.</div></div>
|
||||
@@ -78,6 +81,7 @@ color:var(--bg);padding:10px 20px;border-radius:10px;font-size:14px;opacity:0;tr
|
||||
</div>
|
||||
<script>
|
||||
const $=q=>document.querySelector(q);let staged={};
|
||||
const esch=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const tok=()=>localStorage.getItem('admintok')||'';
|
||||
function saveToken(){localStorage.setItem('admintok',$('#token').value);load()}
|
||||
async function api(p,opt={}){opt.headers={...(opt.headers||{}),'X-Admin-Token':tok(),'Content-Type':'application/json'};
|
||||
@@ -104,6 +108,11 @@ async function load(){
|
||||
<td>${k} ${cfg.overrides[k]!==undefined?'<span class="tag">override</span>':''}</td>
|
||||
<td><input type="text" value="${v}" oninput="staged['${k}']=this.value"></td>
|
||||
<td>${cfg.overrides[k]!==undefined?`<button class="sec" onclick="delKey('${k}')">reset</button>`:''}</td></tr>`).join('');
|
||||
const eff=await api('/api/config/effective');
|
||||
$('#effcfg tbody').innerHTML=Object.entries(eff.config||{}).map(([k,v])=>`<tr${v.drift?' style="background:rgba(220,38,38,.12)"':''}>
|
||||
<td>${esch(k)}</td>
|
||||
<td>${v.value_masked==null?'<span class="mut">—</span>':esch(v.value_masked)}</td>
|
||||
<td><span class="tag">${esch(v.source)}</span></td></tr>`).join('');
|
||||
$('#secrets').innerHTML=Object.entries(cfg.secrets_present).map(([k,v])=>
|
||||
`<span style="margin-right:18px">${v?'✅':'❌'} ${k}</span>`).join('');
|
||||
const prof=await api('/api/profiles');
|
||||
|
||||
@@ -520,6 +520,11 @@
|
||||
<div id="alltime" class="acct-row"><span class="empty">No closed trades recorded yet.</span></div>
|
||||
</details>
|
||||
|
||||
<details class="panel" id="p-experiments">
|
||||
<summary><h2>Experiment Ledger <span id="exp-count" class="count">0</span><span class="hint">pre-registered counterfactuals · latest verdict per experiment</span></h2></summary>
|
||||
<div id="experiments"><span class="empty">No experiments registered — plan 006 fills this.</span></div>
|
||||
</details>
|
||||
|
||||
<details class="panel" id="p-feed">
|
||||
<summary><h2>Activity Feed <span id="feed-count" class="count">0</span></h2></summary>
|
||||
<div id="feed"></div>
|
||||
@@ -784,6 +789,29 @@ function catalystCell(adv) {
|
||||
return chip + dil;
|
||||
}
|
||||
|
||||
// Experiment ledger (plan 006 tables via the snapshot). Verdict pill colors come
|
||||
// from :root tokens; bars-vs-observed shown inline (the pre-registered pass bar
|
||||
// beside the observed metrics).
|
||||
function renderExperiments(rows) {
|
||||
rows = rows || [];
|
||||
$("exp-count").textContent = rows.length;
|
||||
if (!rows.length) { fill("experiments", null, "No experiments registered — plan 006 fills this."); return; }
|
||||
const vcolor = { PASS: "var(--green)", FAIL: "var(--red)", INCONCLUSIVE: "var(--muted)",
|
||||
RUNNING: "var(--accent)", KILLED: "var(--amber)" };
|
||||
const kv = (o) => Object.entries(o || {}).map(e => esc(e[0]) + "=" + esc(e[1])).join(", ") || "—";
|
||||
const trs = rows.map(e => {
|
||||
const v = String(e.verdict || e.status || "RUNNING").toUpperCase();
|
||||
const col = vcolor[v] || "var(--muted)";
|
||||
return '<tr title="' + esc(e.hypothesis || "") + '">' +
|
||||
'<td class="sym">' + esc(e.experiment) + '</td>' +
|
||||
'<td class="num">' + esc(e.n != null ? e.n : "—") + '</td>' +
|
||||
'<td class="num" style="max-width:230px;overflow:hidden;text-overflow:ellipsis">' + kv(e.bars) + '</td>' +
|
||||
'<td class="num" style="max-width:230px;overflow:hidden;text-overflow:ellipsis">' + kv(e.metrics) + '</td>' +
|
||||
'<td><span class="pill" style="color:' + col + ';border-color:' + col + '">' + esc(v) + '</span></td></tr>';
|
||||
});
|
||||
fill("experiments", table(["Experiment", "n", "Pre-registered bar", "Observed", "Verdict"], trs), "");
|
||||
}
|
||||
|
||||
function table(headers, rows) {
|
||||
if (!rows.length) return null;
|
||||
return "<table><thead><tr>" + headers.map(h => "<th>" + h + "</th>").join("") + "</tr></thead><tbody>" + rows.join("") + "</tbody></table>";
|
||||
@@ -1178,6 +1206,7 @@ function render(d) {
|
||||
renderCatalystEngine(d.catalyst_engine);
|
||||
renderCatalystFeed(d.catalyst_feed);
|
||||
renderAllTime(d.alltime);
|
||||
renderExperiments(d.experiments);
|
||||
renderDaily(d.daily);
|
||||
renderTrades(d.pnl);
|
||||
renderEquity(d.equity_curve);
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ your row when done.
|
||||
| 004 | Learning flywheel — one population module, scheduled eod_replay + nightly_tune, atomic writes | P1 | M | 001 (002 rec.) | DONE |
|
||||
| 005 | Entry-path unification + decision receipts | P2 | M | 001, 003, 004 | DONE |
|
||||
| 006 | Experiment OS — pre-registered counterfactual experiments over logged fires (flagship) | P1 | L | 004 (005 rec.) | DONE |
|
||||
| 007 | Operator control plane — dashboard truth + kill switch + effective config (step 1 = prod bug fix, ship early) | P2 | M | 001, 003 (006 for ledger data) | TODO |
|
||||
| 007 | Operator control plane — dashboard truth + kill switch + effective config (step 1 = prod bug fix, ship early) | P2 | M | 001, 003 (006 for ledger data) | DONE |
|
||||
|
||||
Status values: TODO | IN PROGRESS | DONE | BLOCKED (with one-line reason) | REJECTED (with one-line rationale)
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Headless dashboard smoke test (plan 007 step 6).
|
||||
|
||||
Renders the real dashboard page in a headless browser and FAILS on any JS console
|
||||
/ page error — the net that would have caught the do_POST NameError's sibling
|
||||
class (a runtime JS bug curl can't see). Standalone script (NOT a pytest module —
|
||||
the filename has no ``test_`` prefix so pytest never collects it); wired into CI
|
||||
as a NON-BLOCKING step first (loopback binds are flaky on WSL hosts), promote to
|
||||
blocking after one green week.
|
||||
|
||||
Setup (once per environment):
|
||||
pip install playwright && python -m playwright install chrome-headless-shell
|
||||
# minimal Linux/WSL: if the browser won't launch with a libasound error, stub it
|
||||
mkdir -p ~/.lib-stubs && touch ~/.lib-stubs/libasound.so.2
|
||||
export LD_LIBRARY_PATH=~/.lib-stubs:$LD_LIBRARY_PATH
|
||||
|
||||
Exit codes: 0 = no JS errors (pass); 1 = JS errors or assertion failure;
|
||||
2 = setup missing (playwright/browser unavailable — treated as skip in CI).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
# run standalone (`python tests/smoke_dashboard_headless.py`) -> put the repo root
|
||||
# on the path so ``api`` / ``storage`` import without an installed package.
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def _drop_schema(schema: str) -> None:
|
||||
try:
|
||||
import psycopg2
|
||||
url = os.environ.get("DATABASE_URL")
|
||||
if url:
|
||||
conn = psycopg2.connect(url)
|
||||
conn.autocommit = True
|
||||
conn.cursor().execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE')
|
||||
conn.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def main() -> int:
|
||||
os.environ.setdefault("PG_SKIP_SCHEMA", "1")
|
||||
schema = f"mem_{uuid.uuid4().hex[:12]}"
|
||||
os.environ["MOMENTUM_PG_SCHEMA"] = schema
|
||||
|
||||
from api.main import DashboardState, serve_in_background
|
||||
from storage.event_store import EventStore
|
||||
|
||||
store = EventStore("momentum") # creates the isolated schema
|
||||
state = DashboardState("momentum", execution_service=None)
|
||||
httpd, _ = serve_in_background(state, host="127.0.0.1", port=0)
|
||||
port = httpd.server_address[1]
|
||||
|
||||
errors: list[str] = []
|
||||
try:
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"SKIP — playwright not installed: {exc}")
|
||||
return 2
|
||||
with sync_playwright() as p:
|
||||
try:
|
||||
browser = p.chromium.launch(channel="chrome-headless-shell",
|
||||
args=["--no-sandbox"])
|
||||
except Exception: # noqa: BLE001 — fall back to a plain chromium install
|
||||
try:
|
||||
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"SKIP — no launchable browser: {exc}")
|
||||
return 2
|
||||
page = browser.new_page()
|
||||
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
|
||||
page.on("pageerror", lambda e: errors.append(str(e)))
|
||||
# NOT networkidle: the dashboard holds an SSE stream open (+ polling), so
|
||||
# it never idles. Load the DOM, then settle so the snapshot fetch + render
|
||||
# run and any JS error surfaces.
|
||||
page.goto(f"http://127.0.0.1:{port}", wait_until="domcontentloaded")
|
||||
page.wait_for_timeout(2500)
|
||||
assert page.locator(".panel").count() >= 8, "panels missing"
|
||||
assert page.locator("#mode-badge").count() == 1, "badge missing"
|
||||
browser.close()
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
store.close()
|
||||
_drop_schema(schema)
|
||||
|
||||
if errors:
|
||||
print(f"FAIL — {len(errors)} JS errors: {errors}")
|
||||
return 1
|
||||
print("0 JS errors")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Admin effective-config endpoint: masking, drift, and fail-closed auth (plan 007 step 6)."""
|
||||
|
||||
import admin_api
|
||||
|
||||
|
||||
def test_effective_config_masks_secret_values(monkeypatch):
|
||||
monkeypatch.setenv("ALPACA_API_KEY", "supersecretvalue")
|
||||
monkeypatch.setenv("TRADING_MIN_QUALITY_SCORE", "0.60")
|
||||
cfg = admin_api.effective_config()
|
||||
masked = cfg["ALPACA_API_KEY"]["value_masked"]
|
||||
assert masked.startswith("***") and "supersecret" not in masked
|
||||
# non-secret editable knob is shown verbatim, sourced from the process env
|
||||
assert cfg["TRADING_MIN_QUALITY_SCORE"]["value_masked"] == "0.60"
|
||||
assert cfg["TRADING_MIN_QUALITY_SCORE"]["source"] == "compose"
|
||||
|
||||
|
||||
def test_mask_value_covers_database_url():
|
||||
masked = admin_api._mask_value("DATABASE_URL", "postgresql://u:pw@host:5432/db")
|
||||
assert masked.startswith("***") and "pw@host" not in masked
|
||||
|
||||
|
||||
def test_overrides_source_wins(monkeypatch):
|
||||
monkeypatch.setattr(admin_api.env_overrides, "read_overrides",
|
||||
lambda: {"TRADING_PRICE_MAX": "20.0"})
|
||||
monkeypatch.setenv("TRADING_PRICE_MAX", "20.0")
|
||||
cfg = admin_api.effective_config()
|
||||
assert cfg["TRADING_PRICE_MAX"]["source"] == "overrides.env"
|
||||
|
||||
|
||||
def test_missing_required_key_is_drift(monkeypatch):
|
||||
monkeypatch.delenv("POLYGON_API_KEY", raising=False) # documented in .env.example
|
||||
cfg = admin_api.effective_config()
|
||||
row = cfg.get("POLYGON_API_KEY", {})
|
||||
assert row.get("drift") is True
|
||||
assert row.get("source") == "missing"
|
||||
|
||||
|
||||
def test_admin_auth_fail_closed(monkeypatch):
|
||||
h = admin_api.Handler.__new__(admin_api.Handler)
|
||||
monkeypatch.setattr(admin_api, "ADMIN_TOKEN", "")
|
||||
h.headers = {}
|
||||
assert h._authed() is False # no token configured -> deny
|
||||
monkeypatch.setattr(admin_api, "ADMIN_TOKEN", "tok")
|
||||
h.headers = {"X-Admin-Token": "tok"}
|
||||
assert h._authed() is True
|
||||
h.headers = {"X-Admin-Token": "nope"}
|
||||
assert h._authed() is False
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import pytest
|
||||
pytest.main([__file__, "-q"])
|
||||
Reference in new issue
Block a user