Demo-first onboarding: shareable, scrubbable run-replay artifact (cap-evolve replay) - #220
Demo-first onboarding: shareable, scrubbable run-replay artifact (cap-evolve replay)#220OsherElhadad wants to merge 1 commit into
cap-evolve replay)#220Conversation
…lay` (#122) Closes #122. cap-evolve had no "see it work before you configure anything" on-ramp, and no artifact showing what happened during a run *over time* — dashboard.html shows the final reduced state, not the play-by-play of the search. `cap-evolve replay --demo` renders a bundled real toy_calc run as ONE self-contained HTML file with play / pause / scrub / speed over the run's own virtual timeline: candidates proposed, accepted/rejected with the gate's reason, best-so-far advancing, live accepted/rejected counters. It opens from file:// with no server, no network, no node and no credentials; zero external origins; prefers-reduced-motion shows the finished state instead of autoplaying; long eval pauses are clamped so playback never stalls. `cap-evolve replay [run_dir]` does the same for your own runs. Built on #191's shared event-tail helper: each frame's `line` IS eventstream.format_event's line, so the terminal and the replay narrate a run with the same words and the same sanitiser, by construction. Backward scrubbing is an array index — the whole ordered log (read_new_events offset 0) becomes a frame list inlined in the artifact, which a single-file artifact has to do anyway, so a time→offset index would buy nothing. Also fixes #209 at the root: the payload embedded in the inline <script> was escaped with a one-sequence denylist, and a model writing `<!--<script>` in a rejected-edit reason shifted the HTML parser and blanked the page (measured: sections 5 -> 0, body 793 -> 34 chars). One shared `json_for_html` now encodes every </>/& (plus U+2028/9), so the data the JS reads is unchanged while being inert to the parser. Both the dashboard and the replay ride that one encoder. Hardening the scrub, since the artifact is shared: `redact` gains the ghp_/github_pat_ /UUID shapes (same hunk as #193/#215) plus a shape-independent pass over this process's credential-looking env values — keyed on the VALUE being opaque, not just the key name, because a canary test proved a bare high-entropy secret under MODEL_ENDPOINT_SUFFIX walked straight past the key-name heuristic. Event text is redacted BEFORE rendering, since rendering truncates long fields and half a secret matches no rule. The existing self-contained-dashboard guard is widened from a marker denylist to a shape check (any absolute http(s) URL, one-host allowlist) and now asserts the replay panel, so the artifact is covered by the guard that already exists rather than a second one.
🔬 EvidenceAll commands run in a clean worktree at 1. Full test suite$ cd /tmp/wt-122 && PYTHONPATH=/tmp/wt-122/core python -m pytest core/tests -q
........................................................................ [ 32%]
........................................................................ [ 64%]
........................................................................ [ 96%]
......... [100%]
225 passed in 67.84s (0:01:07)2. The 8 new tests, verbose$ PYTHONPATH=core python -m pytest core/tests/test_replay_artifact.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.2, pytest-9.1.1, pluggy-1.6.0 -- /private/tmp/ce-venv/bin/python
cachedir: .pytest_cache
rootdir: /private/tmp/wt-122/core
configfile: pyproject.toml
plugins: anyio-4.14.2
collecting ... collected 8 items
core/tests/test_replay_artifact.py::test_replay_frames_are_ordered_and_cover_the_run PASSED [ 12%]
core/tests/test_replay_artifact.py::test_replay_survives_a_malformed_event PASSED [ 25%]
core/tests/test_replay_artifact.py::test_replay_is_embedded_in_the_artifact PASSED [ 37%]
core/tests/test_replay_artifact.py::test_artifact_references_no_external_origin PASSED [ 50%]
core/tests/test_replay_artifact.py::test_no_canary_of_any_shape_survives_into_the_artifact PASSED [ 62%]
core/tests/test_replay_artifact.py::test_hostile_event_text_does_not_change_the_artifact_structure PASSED [ 75%]
core/tests/test_replay_artifact.py::test_json_for_html_encodes_every_parser_shifting_char PASSED [ 87%]
core/tests/test_replay_artifact.py::test_replay_demo_builds_the_bundled_artifact_with_no_project PASSED [100%]
============================== 8 passed in 0.30s ===============================3. compileall$ python -m compileall -q core skills; echo "exit=$?"
exit=04. Produce the real artifact from the real zero-API runFirst, the recorded run itself came from the real pipeline ( $ python -m cap_evolve.cli run --spec $D/.capevolve/project/capevolve.yaml --project $D/.capevolve/project --run-ts demo
{
"run_dir": ".capevolve/run_demo",
"best_id": "cand_0001",
"baseline_val": 0.0,
"test_reward": 1.0,
"test_baseline_reward": 0.0,
"test_delta": 1.0,
"test_pass_k": {"1": 1.0, "2": 0.0},
"iterations": 3,
"dashboard": ".capevolve/run_demo/dashboard.html"
}That run dir is committed as $ PYTHONPATH=core python -m cap_evolve.cli replay --demo -o /tmp/replay-demo.html
{"artifact": "/tmp/replay-demo.html", "frames": 15, "run": "/private/tmp/wt-122/examples/toy_calc/recorded_run", "url": "file:///private/tmp/replay-demo.html"}
$ ls -l /tmp/replay-demo.html
41625 /tmp/replay-demo.html5. Zero external origins$ grep -oE "https?://[A-Za-z0-9.\-]+" /tmp/replay-demo.html | sort | uniq -c
1 http://www.w3.org
$ for m in "<link" "cdn." "fetch(" "@import" "src=\"http" "href=\"http"; do printf "%-14s %s
" "$m" "$(grep -c -F -- "$m" /tmp/replay-demo.html)"; done
<link 0
cdn. 0
fetch( 0
@import 0
src="http 0
href="http 0
# the one http:// is the SVG XML namespace attribute, not a subresource:
$ grep -oE ".{25}http://www.w3.org.{12}" /tmp/replay-demo.htmlThe existing guard (widened from a marker denylist to a shape check) covers it: $ PYTHONPATH=core python -m pytest core/tests/test_dashboard.py -q -k self_contained
. [100%]
1 passed, 9 deselected in 0.04s6. The replay actually steps through the run — headless ChromiumProbe script ( $ python /tmp/probe.py /tmp/replay-demo.html /tmp/replay-demo.png
{
"sections": 6,
"body_chars": 2350,
"replay_present": 1,
"log_rows": 15,
"clock_t0": "1.0s / 2.8s · 6/15",
"now_t0": "[03:25:37] ACCEPT cand_0001 val=1.0000 (parent 0.0000) — paired Δ̄=+1.0000 > 0 (SE=0 → STRICT fallback, warned; n=2)",
"meters_t0": "best so far 1.000\naccepted 1\nrejected 0\nevents 6",
"errors": [
"Cannot set properties of undefined (setting 'textContent')"
],
"at_end": {
"clock": "2.8s / 2.8s · 15/15",
"now": "[03:25:38] FINALIZE test=1.0000 (baseline 0.0000, Δ+1.0000) best=cand_0001",
"meters": "best so far 1.000\naccepted 1\nrejected 2\nevents 15",
"cur": "[03:25:38] FINALIZE test=1.0000 (baseline 0.0000, Δ+1.0000) best=cand_0001"
},
"back_to_mid": {
"clock": "1.4s / 2.8s · 6/15",
"now": "[03:25:37] ACCEPT cand_0001 val=1.0000 (parent 0.0000) — paired Δ̄=+1.0000 > 0 (SE=0 → STRICT fallback, warned; n=2)",
"meters": "best so far 1.000\naccepted 1\nrejected 0\nevents 6",
"cur": "[03:25:37] ACCEPT cand_0001 val=1.0000 (parent 0.0000) — paired Δ̄=+1.0000 > 0 (SE=0 → STRICT fallback, warned; n=2)"
},
"back_to_zero": {
"clock": "0.0s / 2.8s · 1/15",
"now": "[03:25:36] splits frozen train=4 val=2 test=2 (test sealed)",
"meters": "best so far —\naccepted 0\nrejected 0\nevents 1",
"cur": "[03:25:36] splits frozen train=4 val=2 test=2 (test sealed)"
},
"screenshot": "/tmp/replay-demo.png"
}The single $ python /tmp/probe2.py <dashboard.html generated by the run, before this change>
{"sections": 5, "body_chars": 913, "rp": 0, "errors": ["Cannot set properties of undefined (setting 'textContent')"]}So the artifact goes 5 sections / 913 chars → 6 sections / 2266 chars with a working player, and scrubbing backwards recomputes best-so-far and the accepted/rejected counters correctly at every position. 7. No leak — 6 canaries, 4 shapes, innocent AND secret-looking keysPlanted in $ bash /tmp/canary.sh
planted 6 canaries under ['OPENAI_API_KEY', 'GITHUB_TOKEN', 'WATSONX_APIKEY', 'MODEL_ENDPOINT_SUFFIX', 'DEPLOYMENT_ID', 'RUNTIME_PROFILE']
{"artifact": "/tmp/canary.html", "frames": 18, "run": "/tmp/canaryrun/run", "url": "file:///private/tmp/canary.html"}
--- grep -c each canary in the artifact ---
OPENAI_API_KEY sk-canaryAAAAAAAAAAAAAAAAAAAAAAAA1 -> 0
GITHUB_TOKEN ghp_canaryBBBBBBBBBBBBBBBBBBBBBBBB2 -> 0
WATSONX_APIKEY canaryCCCCCCCCCCCCCCCCCCCCCCCCCCCC3 -> 0
MODEL_ENDPOINT_SUFFIX canaryDDDDDDDDDDDDDDDDDDDDDDDDDDDD4 -> 0
DEPLOYMENT_ID 3f2a1b4c-canary-4d5e-8f90-abcdef012345 -> 0
RUNTIME_PROFILE github_pat_canaryEEEEEEEEEEEEEEEEEEEEEEEE5 -> 0
--- any 'canary' substring at all ---
0
0Three of those keys — Proof the leak was real — with only the And a second real bug, from the same test: ( 8. Injection is inert — structure asserted, not "no exception"Four real artifacts built with the hostile string in both $ python /tmp/probe2.py /tmp/inj-clean.html
{"sections": 5, "body_chars": 1292, "rp": 1, "errors": ["Cannot set properties of undefined (setting 'textContent')"]}
$ python /tmp/probe2.py /tmp/inj-comment.html
{"sections": 5, "body_chars": 1288, "rp": 1, "errors": ["Cannot set properties of undefined (setting 'textContent')"]}
$ python /tmp/probe2.py /tmp/inj-close.html
{"sections": 5, "body_chars": 1368, "rp": 1, "errors": ["Cannot set properties of undefined (setting 'textContent')"]}
$ python /tmp/probe2.py /tmp/inj-forge.html
{"sections": 5, "body_chars": 1408, "rp": 1, "errors": ["Cannot set properties of undefined (setting 'textContent')"]}
And the old sink, for contrast. I reverted $ grep -c "OLD #209 sink" core/cap_evolve/dashboard.py
1
$ python /tmp/inject.py "<!--<script>" /tmp/inj-OLD.html && python /tmp/probe2.py /tmp/inj-OLD.html
{"sections": 0, "body_chars": 34, "rp": 0, "errors": []}0 sections, 34 chars, no player, and no error raised — the silent denial-of-view #209 describes. With the encode-everything fix: 5 sections, 1288 chars, player present. The file was restored immediately after ( 9. Frontend untouched (no
|
|
❌ Automatic Labeling Failed An error occurred while trying to automatically label this pull request. Please check the workflow logs for details and add labels manually. |
Closes #122.
What this is
cap-evolve replayrenders a recorded run as one self-contained HTML file that plays the run back over its own virtual timeline — play / pause / scrub / speed. You watch candidates get proposed, accepted or rejected with the gate's reason, the best-so-far advance, and live accepted/rejected/event counters, instead of only seeing the final reduced state thatdashboard.htmlshows today.How a newcomer opens it with zero setup
No project, no
capevolve.yaml, no credentials, no model calls, no server, no node, no network.--demouses a real recordedtoy_calcrun committed atexamples/toy_calc/recorded_run/(68 KB:events.jsonl+ baseline/final/state/splits + rollouts), produced by the actual zero-APImock-optimizer pipeline — not a synthetic fixture. The command prints the file it wrote and itsfile://URL; the artifact opens straight from disk.cap-evolve replay [run_dir](or with no argument: your newest run) does the same for your own runs. Linked as step 0 ofdocs/GETTING_STARTED.mdand as the first line of the site's "try it" block.Reduced motion is honoured: under
prefers-reduced-motion: reducethe page renders the finished state instead of autoplaying. Long eval pauses are clamped (1.5 s of virtual time per gap) so a 15-minute eval doesn't stall playback.How backward scrubbing works
read_new_events(path, 0)gives the whole ordered log deterministically;build_replayfolds it into a frame list (rel,kind,line,cand,status,val,best) that is inlined in the artifact. Seeking to any time — forwards or backwards — is a linear scan to an array index and a recompute of the counters fromframes[0..k]. No seeking, no time→offset index.That is the deliberate answer to #191's reviewer's noted gap. A single-file shareable artifact has to inline the whole log anyway, so an index would buy nothing: an offset index only pays off when you can avoid reading the bytes, and here they are already in the file. Bound stated in the code (
ponytail:comment):events.jsonlis 3.4 KB fortoy_calc, ~200 KB for a 500-iteration run — fine in memory and fine inlined. If a run ever logs enough to matter, the fix is to cap the frame list, not to make the reader seek.Scrubbing (it's shareable, so it's redacted) — canary evidence
Everything goes through the existing
dashboard.redact; no second scrubber. Two real leaks were found by the canary test and fixed at the root:_env_secret_values(the Addcap-evolve doctorinstall/health diagnostic #193/Terminal degradation ladder + redacted forensic crash log (#144) #215 hunk) keys on the key name, soMODEL_ENDPOINT_SUFFIX=canaryDDD…passed straight through — exactly what Terminal degradation ladder + redacted forensic crash log (#144) #215's review predicted. Fixed by also covering values that are opaque by shape (≥20 chars, no whitespace, no/, ≥2 character classes) under any key name. Whitespace//exclusions keepPATH/PWD/prose settings out, so the false-positive cost is "a long opaque config value shows as«redacted»in a report" — cheap next to shipping a live key in a file people paste into issues.format_eventclipsoptimizer_errorto 200 chars, and half a credential matches no shape regex and no literal env value.build_replaynowredacts each event before rendering it.Also ported the
ghp_/github_pat_/ UUID shapes (identical hunk to #193 and #215 — whichever lands first, the others are a no-op merge).Six canaries in four shapes planted under both innocent and secret-looking env keys, and inside both model-written fields (
reasonandoptimizer_error.error, the latter with noKEY=prefix to lean on), thengrep -con a real generated artifact:grep -cOPENAI_API_KEYsk-canaryAAAA…1GITHUB_TOKENghp_canaryBBBB…2WATSONX_APIKEYcanaryCCCC…3MODEL_ENDPOINT_SUFFIX(innocent)canaryDDDD…4DEPLOYMENT_ID(innocent)3f2a1b4c-canary-…RUNTIME_PROFILE(innocent)github_pat_canaryEEEE…5grep -c -i canaryover the whole artifact → 0. Full output in the Evidence comment.Injection — I fixed #209's sink
Not avoided: fixed at the root, because the replay payload rides the same interpolation and every other future payload field would inherit the bug.
dashboard.py:657wasjson.dumps(...).replace("</", "<\\/")— a one-sequence denylist. Replaced with one shareddashboard.json_for_htmlthat encodes every<,>,&(plus U+2028/9) as<etc. Inside a JSON string literal those parse back to the original characters, so the data the JS reads is bit-identical while being inert to the HTML parser. Encode-everything, not the sixth denylist in this batch.Measured in headless Chromium on real generated artifacts, asserting structure, not "no exception":
reason/optimizer_errorpayload<!--<script>— old sink<!--<script>— new sink</script><script>…innerHTML=""</script>FINALIZElineThe newline forgery and ANSI/OSC (
\x1b]0;pwned\x07\x1b[2J) are already dead via #191'ssanitize, which every frame'slinegoes through by construction — asserted in the test ("\n" not in line,"\x1b" not in line).Zero external origins
grep -oE "https?://[A-Za-z0-9.-]+"over the built artifact →http://www.w3.orgonly (the SVG XML namespace, not a fetch). No<link, nocdn., nofetch(, no@import.Rather than a second guard, I widened the existing one (
test_render_html_self_contained_and_parseable) from a marker denylist to a shape check — any absolutehttp(s)URL, one-host allowlist — and made it assert theRun replaypanel. The replay artifact is the same renderer, so it's covered by construction. This is the same denylist→shape lesson as #192's CDN guard.Expected merge order
--followandcap-evolve tail#191 (issue Classic cap-evolve run is silent for its entire duration — add --follow / cap-evolve tail #116) — this PR is based onfeat/issue-116-follow-tailand consumeseventstream. Merge feat(observability): live terminal progress via--followandcap-evolve tail#191 first; the base then retargets tomaincleanly.cap-evolve doctorinstall/health diagnostic #193 / Terminal degradation ladder + redacted forensic crash log (#144) #215 — theghp_/github_pat_/UUID +_env_secret_valueshunk is identical in all three, so the later ones are no-op merges. My_looks_opaqueextension sits on top and is additive.dashboard/frontend/dist/is not touched or committed (per Committed dashboard dist/ with hashed filenames makes every concurrent frontend PR conflict, and can silently ship a stale bundle #188).Verification
225 = 179 baseline + 38 from #191's base + 8 new in
core/tests/test_replay_artifact.py. 0 failed.test_dashboard_launch.py::test_maybe_launch_spawns_when_available(#200, environmentally flaky on port 7878) passed in this environment.Real artifact from a real zero-API run:
Headless Chromium, scrubbing the real demo artifact — forward to the end, then backwards to the middle and to zero (state recomputes correctly each time):
Counters at those positions:
best so far —/accepted 0/rejected 0at t=0 →best so far 1.000/accepted 1/rejected 2at the end, and correctly back down when scrubbed backwards.No frontend changes (
git status --short dashboard/→ empty), so nonpm ci/tsc/ vitest run is needed.Full commands and untruncated output in the 🔬 Evidence comment.
Files touched
core/cap_evolve/dashboard.py—build_replay,json_for_html(Model-controlled event text can blank the generated dashboard.html (incomplete script-context escape at dashboard.py:657) #209 fix), replay player CSS+JS in the template,redacthardeningcore/cap_evolve/cli.py—cap-evolve replaysubcommand +DEMO_RUNcore/tests/test_replay_artifact.py— new, 8 testscore/tests/test_dashboard.py— existing self-contained guard widened to a shape check + replay panelexamples/toy_calc/recorded_run/— new, the bundled real run for--demoexamples/toy_calc/README.md,docs/GETTING_STARTED.md,site/index.html,CHANGELOG.md