From f5dfd4442ed7f05a71e7d688f28f1afb2765ff89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ClodoCap=C3=A9o?= <159788250+ClodoCapeo@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:43:38 +0200 Subject: [PATCH 1/5] test(m9): add Blue-trigger reactive-repaint scene fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deterministic, checked-in fixtures for the M9 proof (Blue ADR 001 §4): - m9-blueprint-bg.json: a Blue passthrough blueprint (event→output, single core.input `colour`) whose /trigger maps outputs to the scoped leaf __inputs.blue.pulsar-m9-bg.colour. Stdlib-only nodes → publishes on any deployed Blue; never compiled by Orion (runs in Blue's interpreter). - m9-scene.lsml.json: an LSML 1.1 bundle whose frame BACKGROUND binds that leaf, declared as an operator_input with default colour A (#1A9E57) so Orion seeds A on boot and accepts Blue's later in-scope write (sceneAcceptsPath). No Canvas schema change — the existing per-node bind. Colour A (#1A9E57) vs trigger colour B (#C81E5A) are 305 apart in RGB Manhattan, far past the modal tolerance, so the A→B repaint assertion is unambiguous. Verified against the real Blue leaf_mapper (produces the exact bound leaf) and Orion validateBindingKeys (blueprint-free scene → binding passes compile). Refs #53 Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/fixtures/m9-blueprint-bg.json | 52 +++++++++++++++++++++++++++ scripts/fixtures/m9-scene.lsml.json | 47 ++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 scripts/fixtures/m9-blueprint-bg.json create mode 100644 scripts/fixtures/m9-scene.lsml.json diff --git a/scripts/fixtures/m9-blueprint-bg.json b/scripts/fixtures/m9-blueprint-bg.json new file mode 100644 index 0000000..5d49a24 --- /dev/null +++ b/scripts/fixtures/m9-blueprint-bg.json @@ -0,0 +1,52 @@ +{ + "_fixture": "M9 background-colour blueprint (Pulsar #53, Blue ADR 001 §3.2). A trigger-driven passthrough: a core.event.on-start@1 fires a core.output@1 sink whose `value` is wired from a single core.input@1 named `colour`. The output node config.name = `colour`, so a /trigger run with inputs={'colour': '#C81E5A'} produces outputs={'colour': '#C81E5A'} (executor.py:192-201: core.input reads state.inputs[name]; core.output writes state.outputs[name]=inputs['value']). Blue's leaf_mapper then maps outputs -> __inputs.blue.., i.e. __inputs.blue.pulsar-m9-bg.colour = the trigger input. This is the VALUE B the M9 repaint proves on screen; the scene seeds the leaf's default A (#1A9E57) via operator_inputs. Unlike the M8 fixtures this blueprint is NOT bound into the scene by blueprint-key and is NEVER compiled by Orion — it runs only in Blue's in-process interpreter at trigger time and pushes a leaf. core.input/core.output/core.event.on-start@1 are all in Blue's stdlib seeder (stdlib_seeder.py:651-705), so the version publishes on any deployed Blue. Node body mirrors Blue's graph schema (config/inputs/outputs, definition=namespace.name@version) and the trigger-endpoint test's _pipe_graph (tests/test_trigger_endpoint.py:92-104).", + "blueprint": { + "slug": "pulsar-m9-bg", + "name": "Pulsar M9 — background colour", + "kind": "function", + "tags": ["pulsar-m9", "fixture"], + "interface": { + "inputs": [ + { "name": "colour", "type": "core.primitive.string" } + ], + "outputs": [ + { "name": "colour", "type": "core.primitive.string" } + ], + "side_effects": [] + } + }, + "graph": { + "nodes": [ + { + "id": "evt", + "definition": "core.event.on-start@1", + "config": {}, + "position": { "x": 0, "y": 0 }, + "outputs": [{ "id": "evt.then", "name": "then", "type": "core.exec", "kind": "exec" }] + }, + { + "id": "in.colour", + "definition": "core.input@1", + "config": { "name": "colour" }, + "position": { "x": 1, "y": 0 }, + "outputs": [{ "id": "in.colour.value", "name": "value", "type": "core.primitive.json", "kind": "data" }] + }, + { + "id": "out.colour", + "definition": "core.output@1", + "config": { "name": "colour" }, + "position": { "x": 2, "y": 0 }, + "inputs": [ + { "id": "out.colour.in", "name": "in", "type": "core.exec", "kind": "exec" }, + { "id": "out.colour.value", "name": "value", "type": "core.primitive.json", "kind": "data" } + ], + "outputs": [{ "id": "out.colour.then", "name": "then", "type": "core.exec", "kind": "exec" }] + } + ], + "edges": [ + { "id": "e.fire", "from_node": "evt", "from_port": "then", "to_node": "out.colour", "to_port": "in" }, + { "id": "e.val", "from_node": "in.colour", "from_port": "value", "to_node": "out.colour", "to_port": "value" } + ], + "variables": [] + } +} diff --git a/scripts/fixtures/m9-scene.lsml.json b/scripts/fixtures/m9-scene.lsml.json new file mode 100644 index 0000000..0a83d41 --- /dev/null +++ b/scripts/fixtures/m9-scene.lsml.json @@ -0,0 +1,47 @@ +{ + "lsml": "1.1", + "scene_id": "0c8a9e57-0000-4000-8000-000000000m90", + "scene_version": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "operator_inputs": [ + { + "path": "__inputs.blue.pulsar-m9-bg.colour", + "label": "M9 background (Blue-driven)", + "type": "colour", + "default": "#1A9E57" + } + ], + "layout": { + "kind": "frame", + "id": "root", + "size": { "w": 1920, "h": 1080 }, + "background": "#1A9E57", + "bind": { "background": "__inputs.blue.pulsar-m9-bg.colour" }, + "children": [ + { + "kind": "stack", + "id": "col", + "direction": "vertical", + "gap": 24, + "align": "center", + "justify": "center", + "children": [ + { + "kind": "text", + "id": "m9-marker", + "value": "M9 reactive repaint", + "style": { "fontSize": 96, "fontWeight": 800, "color": "#FFFFFF", "textAlign": "center", "fontFamily": "Inter" } + }, + { + "kind": "shape", + "id": "chip", + "geometry": "rect", + "size": { "w": 320, "h": 96 }, + "fill": "#0A0A0A", + "cornerRadius": 16, + "stroke": { "color": "#FFFFFF", "width": 3 } + } + ] + } + ] + } +} From a8ac60da9826da8b502f4670c0d7eceef084278d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ClodoCap=C3=A9o?= <159788250+ClodoCapeo@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:43:38 +0200 Subject: [PATCH 2/5] test(m9): add M9 SETUP leg (author + push + activate + fire trigger) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derived from m8_setup.py, retargeted at ADR Blue 001's reactive contract. Reuses the M8 toolkit wholesale (gateway HTTP client, LSML hash port, redaction ports, Solar URL composer) by importing m8_setup; only the SETUP orchestration + the M9 result shape are new. Differences vs M8: one blueprint (not N≥2); the scene definition carries blueprints=[] (the visible binds an operator-input leaf, not a blueprint-key leaf); the provenance target A is read from the scene's operator_inputs default (the exact declaration that drives the live leaf). Adds fire_trigger: POST /blue/api/v1/blueprints/{id}/trigger with the operator JWT as an Authorization: Bearer HEADER (never a query string — ADR Blue 001 R6), inputs={colour: B}, asserting 200 + the colour passed through to the leaf. Refs #53 Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/m9_setup.py | 301 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 scripts/m9_setup.py diff --git a/scripts/m9_setup.py b/scripts/m9_setup.py new file mode 100644 index 0000000..ca95a77 --- /dev/null +++ b/scripts/m9_setup.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +"""M9 SETUP leg — author a Canvas scene whose visible frame background is +bound to a Blue-driven input leaf ``__inputs.blue..``, push it +through Orion, make it the active show, mint a viewer show-token, and +compose the Solar URL — exactly the M8 authoring prologue, retargeted at +the **reactive-repaint** contract of ADR Blue 001. + +Where M8 proved an *authored* scene reaches the wire, M9 proves that a +**Blue trigger repaints a live scene**. The decisive structural change vs +M8 is the binding source: + + - M8 bound a text primitive to a **blueprint-key** leaf (``score.value``) + seeded by a compute graph Orion compiles into the scene. + - M9 binds the **frame background** to an **operator-input** leaf + ``__inputs.blue.pulsar-m9-bg.colour`` that Orion seeds from the + layout's declared ``default`` (colour **A**) and that **Blue** later + overwrites via ``POST /api/v1/blueprints/{id}/trigger`` (colour **B**). + The blueprint is NOT compiled into the scene; it runs only in Blue's + interpreter at trigger time and pushes the leaf (ADR Blue 001 §3.2.1-4). + +Why bind the **frame background** and not a text label: the M9 proof is a +modal-colour delta (A→B) measured on the captured CEF frame. The frame +background is a single large flat field, so its modal colour IS the bound +region — the M8 whole-frame modal metric (``analyse_frame``) becomes the +M9 region metric verbatim, with no cropping. ``bind: {"background": ...}`` +is the existing per-node bind mechanism (Solar's ``resolveProps`` applies +any binding key to the node's props — render/tree.js:118-127); **no Canvas +schema change** (ADR Blue 001 §3.2.4). + +Leaf acceptance: Orion only accepts a write to a path the active scene +**declares** (adapters/inbox.go::sceneAcceptsPath — defaults, operator_inputs +or a binding target). Declaring ``__inputs.blue.pulsar-m9-bg.colour`` as a +layout ``operator_inputs`` entry both (a) seeds default **A** on boot +(Orion criterion 11) and (b) makes Blue's later push in-scope. The +service-token side (``paths=["__inputs.blue.*"]``) is the auth gate; this +is the *scene-side* declaration the write also needs. + +What it does, in order (mirrors m8_setup S1-S9, minus the N>=2 rule): + + S2. Ensure the ONE Blue passthrough blueprint exists + is published, + idempotent by slug. The graph is event→output with a single + core.input named ``colour`` (Blue stdlib nodes only), so a + ``/trigger`` with ``inputs={"colour": B}`` yields + ``outputs={"colour": B}`` → leaf ``__inputs.blue..colour = B``. + S3. PUT the deterministic, checked-in LSML scene bundle (with the + operator_input default A + the frame-background bind) into Canvas's + A0 content-addressed store at its own hash H. + S1. Ensure the test scene row exists (status=ready), idempotent by name. + S4. Save a definition revision at canvas_version=H with **blueprints=[]** + (M9 binds an operator-input leaf, not a blueprint-key leaf). + S5. Push that revision through Orion; assert 200 + no diagnostics.errors. + S6. Drive the active-scene. + S7. Round-trip GET /orion/show — assert active_scene_id == scene_id. + S8. Mint a viewer show-token (operator-only). + S9. Compose the Solar LSDP URL. + +SECRET HYGIENE (unchanged from M8, ADR Blue 001 R4 / §A1.5): + - The SETUP/trigger operator credential is read from the environment + ONLY (M8_OPERATOR_TOKEN — reused; the same short-TTL admin JWT drives + SETUP and the /trigger fire). It is NEVER ORION_OPERATOR_TOKEN and is + never logged. + - The minted viewer show-token is redacted by ``redact_solar_url``; + the operator JWT + show-token are scrubbed by ``redact_token`` + everywhere else. + - NO token is committed anywhere in this module or the fixtures. + +This module REUSES the M8 toolkit wholesale (the gateway HTTP client, the +LSML hash port, the redaction ports, the Solar URL composer) by importing +``m8_setup`` — only the SETUP orchestration + the M9 result shape are new. +""" +from __future__ import annotations + +import json +import pathlib +from dataclasses import dataclass +from typing import Any + +import m8_setup +from m8_setup import ( # noqa: F401 — re-exported for the probe's convenience + GatewayClient, + SetupError, + compose_solar_url, + hash_bundle, + hex_to_rgb, + redact_solar_url, + redact_token, +) + +FIXTURES_DIR = pathlib.Path(__file__).resolve().parent / "fixtures" +SCENE_BUNDLE_FIXTURE = FIXTURES_DIR / "m9-scene.lsml.json" +BLUEPRINT_FIXTURE = FIXTURES_DIR / "m9-blueprint-bg.json" + +# The Canvas scene name M9 authors/reuses idempotently (find-or-create). +SCENE_NAME = "Pulsar M9 — Blue-driven reactive repaint test" + +# The output port the blueprint emits; one port → one leaf segment. +M9_OUTPUT_PORT = "colour" + +# Colour B the /trigger pushes — the post-repaint background. Distinct from +# the fixture's operator-input default A (#1A9E57) by a wide modal margin: +# A=(26,158,87) vs B=(200,30,90) → Manhattan 305, far past MODAL_COLOUR_TOL +# (24) so the A≠B repaint assertion is unambiguous and noise-proof. +M9_TRIGGER_COLOUR_B = "#C81E5A" + +# Reuse M8's Solar/wire defaults verbatim — the bridge is LSDP-mode +# independent (ADR Blue 001 §1.2), so M9 ships on the same LSDP wire as M8. +DEFAULT_SOLAR_VERSION = m8_setup.DEFAULT_SOLAR_VERSION +DEFAULT_SHOW_STREAM_PATH = m8_setup.DEFAULT_SHOW_STREAM_PATH + + +def load_scene_bundle() -> tuple[dict[str, Any], str]: + """Load the checked-in M9 bundle, stamp its real ``scene_version``, and + return ``(bundle, H)``. Same content-address discipline as M8: the + fixture ships a zeroed ``scene_version`` placeholder; we compute H + (hash.go parity, scene_version zeroed) and seal it so the stored + bundle self-certifies its identity for the A0 address-check.""" + bundle = json.loads(SCENE_BUNDLE_FIXTURE.read_text(encoding="utf-8")) + h = hash_bundle(bundle) + bundle["scene_version"] = "sha256:" + h + return bundle, h + + +def leaf_default_a(bundle: dict[str, Any], leaf_path: str) -> str: + """Return the operator-input default colour A declared for ``leaf_path``. + + This is the value Orion seeds on boot and the pre-trigger capture A + must match. We read it from the scene's own ``operator_inputs`` rather + than re-deriving from ``layout.background`` so the provenance target is + sourced from the EXACT declaration that drives the live leaf.""" + for oi in bundle.get("operator_inputs", []): + if isinstance(oi, dict) and oi.get("path") == leaf_path: + default = oi.get("default") + if not isinstance(default, str): + raise SetupError( + f"operator_input {leaf_path!r} has no string default " + f"(got {default!r}) — cannot derive provenance colour A" + ) + return default + raise SetupError( + f"scene declares no operator_input for {leaf_path!r}; Orion would " + "reject the Blue push (sceneAcceptsPath) and never seed default A" + ) + + +@dataclass +class SetupResult: + """Everything the M9 probe needs after SETUP, for capture-A / fire / + capture-B and provenance.""" + + scene_id: str + bundle_hash: str # H — the LSML content address we stored + pushed_scene_version: str # authoritative scene_version from the push + blueprint_id: str # the trigger target (POST .../blueprints/{id}/trigger) + blueprint_slug: str # the leaf segment + leaf_path: str # __inputs.blue.. — the bound leaf + solar_url: str # show-token EMBEDDED — log only via redact_solar_url + show_token: str # raw — never log; redaction secret source + colour_a: str # leaf default (#RRGGBB) — pre-trigger modal target + colour_b: str # trigger-pushed colour (#RRGGBB) — post-trigger target + + @property + def rgb_a(self) -> tuple[int, int, int]: + return hex_to_rgb(self.colour_a) + + @property + def rgb_b(self) -> tuple[int, int, int]: + return hex_to_rgb(self.colour_b) + + +def run_setup( + *, gateway_url: str, operator_token: str, twitch_key: str, + solar_version: str, show_stream_path: str, log, +) -> SetupResult: + """Execute the full M9 SETUP leg and return a SetupResult. ``log`` is a + callable taking a single already-redacted string (the probe's print).""" + bundle, h = load_scene_bundle() + client = GatewayClient( + base_url=gateway_url, + operator_token=operator_token, + # Redaction secret set: operator JWT + Twitch key (show-token added + # after mint). Any of these in a response body is scrubbed. + secrets=[s for s in (operator_token, twitch_key) if s], + ) + + fixture = json.loads(BLUEPRINT_FIXTURE.read_text(encoding="utf-8")) + slug = fixture["blueprint"]["slug"] + leaf_path = f"__inputs.blue.{slug}.{M9_OUTPUT_PORT}" + colour_a = leaf_default_a(bundle, leaf_path) + + log("[S2] ensuring Blue passthrough blueprint (idempotent by slug) ...") + blueprint_id = client.ensure_blueprint(fixture) + log(f" blueprint slug={slug!r} id={blueprint_id} ({BLUEPRINT_FIXTURE.name})") + log(f" bound leaf={leaf_path} default(A)={colour_a} trigger(B)={M9_TRIGGER_COLOUR_B}") + + log(f"[S3] storing LSML bundle in Canvas A0 (H={h}) ...") + client.put_lsml_bundle(bundle, h) + + log("[S1] ensuring Canvas scene row (status=ready, idempotent by name) ...") + scene_id = client.ensure_scene(SCENE_NAME) + log(f" scene_id={scene_id}") + + log("[S4] saving definition revision (canvas_version=H, blueprints=[]) ...") + # M9 binds an operator-input leaf, not a blueprint-key leaf, so the + # definition carries NO blueprints[] — the visible's source is the + # __inputs.blue.* leaf Blue writes, declared in the bundle's + # operator_inputs (seeds A; makes the Blue push in-scope). + definition_id = client.save_definition(scene_id, h, []) + log(f" definition_id={definition_id}") + + log("[S5] pushing revision through Orion (lsml_bundle_hash=H) ...") + push = client.push_definition(scene_id, definition_id, h) + pushed_version = str(push["scene_version"]) + log(f" push OK scene_version={pushed_version} diagnostics.errors=[]") + if pushed_version == "sha256:" + h: + log(" note: Orion adopted the stored LSML hash (byte-match adopt-on-verify)") + else: + log(" note: Orion minted a legacy scene_version (LSML_HASH_MISMATCH, " + "non-fatal); provenance uses active_scene_id + push scene_version") + + log("[S6] driving active-scene ...") + client.set_active_scene(scene_id) + + log("[S7] round-trip GET /orion/show (provenance marker 3) ...") + show = client.get_show() + active = show.get("active_scene_id") + if active != scene_id: + raise SetupError( + f"provenance FAIL: active_scene_id={active!r} != scene_id={scene_id!r}" + ) + log(f" active_scene_id == scene_id ({scene_id}) — server-side provenance OK") + + log("[S8] minting viewer show-token (operator-only) ...") + show_token = client.mint_show_token() + log(" show-token minted ()") + + log("[S9] composing Solar v%s LSDP URL ..." % solar_version) + solar_url = compose_solar_url( + gateway_url=gateway_url, show_token=show_token, + solar_version=solar_version, show_stream_path=show_stream_path, + ) + log(f" solar_url={redact_solar_url(solar_url)}") + + return SetupResult( + scene_id=scene_id, + bundle_hash=h, + pushed_scene_version=pushed_version, + blueprint_id=blueprint_id, + blueprint_slug=slug, + leaf_path=leaf_path, + solar_url=solar_url, + show_token=show_token, + colour_a=colour_a, + colour_b=M9_TRIGGER_COLOUR_B, + ) + + +def fire_trigger( + *, gateway_url: str, operator_token: str, secrets: list[str], + blueprint_id: str, colour_b: str, log, +) -> dict[str, Any]: + """Fire ``POST /api/v1/blueprints/{id}/trigger`` (gateway path + ``/blue/api/v1/blueprints/{id}/trigger``) with the operator Bearer and + ``inputs={"colour": B}``. + + The operator JWT rides as an ``Authorization: Bearer`` **header** (never + a query string) — ADR Blue 001 R6 requires operator/admin, enforced by + Blue's ``require_trigger_role`` reading the gateway-injected + ``X-Authenticated-Role``; ZabGate injects that role only after it + validates the header Bearer. We assert the response is 200 and that the + push mapped our leaf, so the probe knows the leaf write was issued + before it polls for the repaint. + + Returns the (already JSON-decoded) trigger response so the caller can + inspect ``outputs`` + ``pushed{leaves,delivered}``. Raises SetupError on + a non-200 (redacted).""" + client = GatewayClient( + base_url=gateway_url, + operator_token=operator_token, + secrets=secrets, + ) + log(f"[TRIGGER] POST /blue/api/v1/blueprints/{blueprint_id}/trigger " + f"(operator Bearer header) inputs={{'colour': {colour_b!r}}} ...") + _, resp = client._request( # noqa: SLF001 — same module family as M8's client + "POST", + f"/blue/api/v1/blueprints/{blueprint_id}/trigger", + body={"inputs": {M9_OUTPUT_PORT: colour_b}}, + auth=True, + expect=(200,), + ) + outputs = resp.get("outputs") or {} + pushed = resp.get("pushed") or {} + leaves = [leaf.get("path") for leaf in pushed.get("leaves", []) if isinstance(leaf, dict)] + log(f" <- 200 outputs={outputs} pushed.delivered={pushed.get('delivered')} " + f"pushed.leaves={leaves}") + if outputs.get(M9_OUTPUT_PORT) != colour_b: + raise SetupError( + f"trigger outputs[{M9_OUTPUT_PORT!r}]={outputs.get(M9_OUTPUT_PORT)!r} " + f"!= requested B={colour_b!r} — the blueprint did not pass the " + "colour through; the leaf would carry the wrong value" + ) + return resp From 89c2bd6c0ee9c9b8f9857fd829b3a678a26b15a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ClodoCap=C3=A9o?= <159788250+ClodoCapeo@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:43:38 +0200 Subject: [PATCH 3/5] test(m9): add live repaint probe + grep-assert wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit probe-m9-canvas-live.py proves Blue ADR 001 §4: a /trigger repaints a live scene with no reload. Reuses the M6 CEF core (spawn/reap, stdlib PNG decode, analyse_frame, broadcast) and the M8 non-blank provenance pre-flight. The proof is capture-A / fire / capture-B on the SAME browser_source: 1. pre-flight → non-blank AND modal ≈ seeded default A (build/m9-before.png); 2. fire the trigger (operator Bearer header) with inputs={colour: B}; 3. poll the same source until modal ≈ B within Orion's input→delta budget (REPAINT_DEADLINE_S) AND Manhattan(B,A) > REPAINT_MIN_DELTA (build/m9-after.png). SetCaptureSource is issued once, so a change is a live LSDP repaint, not a reload. A 200 from /trigger alone does NOT pass — the pixels must move (best-effort delivery, ADR Blue 001 §3.2.6). Broadcast is opt-in (--broadcast), so the proof needs no Twitch key. run-m9.ps1 tees stdout + grep-asserts no credential (the operator JWT — now also the /trigger credential — the Twitch key, and JWT-shaped show-tokens) leaks to stdout / the before+after PNGs / VOD. A leak fails the run regardless of probe exit. UTF-8 no BOM. Refs #53 Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/probe-m9-canvas-live.py | 521 ++++++++++++++++++++++++++++++++ scripts/run-m9.ps1 | 121 ++++++++ 2 files changed, 642 insertions(+) create mode 100644 scripts/probe-m9-canvas-live.py create mode 100644 scripts/run-m9.ps1 diff --git a/scripts/probe-m9-canvas-live.py b/scripts/probe-m9-canvas-live.py new file mode 100644 index 0000000..344a1c2 --- /dev/null +++ b/scripts/probe-m9-canvas-live.py @@ -0,0 +1,521 @@ +#!/usr/bin/env python3 +"""Pulsar M9 live probe — a Blue **trigger** repaints a live scene on air, +proven pixel-by-pixel (ADR Blue 001 §6 criterion 4, the M9 proof). + +M9 is M8's authored Canvas scene reaching the wire (the SETUP + non-blank +provenance pre-flight are reused wholesale), PLUS the reactive step ADR +Blue 001 exists to prove: firing ``POST /api/v1/blueprints/{id}/trigger`` +mutates a **live** scene **with no reload**. + +The decisive chain (ADR Blue 001 §3.2.5): + + trigger → Blue interprets (execute_version) → maps outputs to + __inputs.blue.. → pushes input frame on the scoped + service-token WS → Orion CanWritePath + write leaf → recompute → + delta → LSDP wire → Solar repaints the bound region, no reload. + +THE PROOF (capture-A / fire / capture-B): + + 1. SETUP (m9_setup) authors a scene whose **frame background** is bound + to ``__inputs.blue.pulsar-m9-bg.colour``, declared as an operator-input + with default colour **A** (#1A9E57). Orion seeds A on boot. + 2. The reused M6 CEF core points a browser_source at the live Solar URL + (SetCaptureSource — called ONCE; never re-created between A and B, so + the later change is a live repaint, not a reload). + 3. Pre-flight: poll the captured frame until non-blank AND its modal + colour ≈ **A**. This is capture-A (``build/m9-before.png``) — it ties + the on-air pixels to the seeded leaf default before any trigger. + 4. Fire ``/trigger`` with the operator Bearer **header** and + ``inputs={"colour": B}`` (#C81E5A). ADR Blue 001 R6 — operator/admin + only; the JWT rides as ``Authorization: Bearer`` (header, never query). + 5. Poll the *same* browser_source until its modal colour moves to ≈ **B** + within Orion's input-to-delta budget (+ CEF render/screenshot slack). + This is capture-B (``build/m9-after.png``). + 6. Assert **B ≈ target-B** AND **Manhattan(B, A) > REPAINT_MIN_DELTA** — + the bound region demonstrably changed on screen, caused by the trigger, + with no reload. A frame that never leaves A (push lost / leaf rejected / + scene didn't bind) FAILS; a frame that changed to something other than + B (wrong scene / ambient) FAILS. + +Broadcast: M9's core IS the repaint proof and runs in the pre-flight +(``--preflight-only`` is the default-meaningful mode). The 30s Twitch +broadcast leg is reused verbatim from M6/M8 and is OPTIONAL (``--broadcast``) +— going live is not what M9 proves; the proven repaint is (so a CI run needs +no Twitch key). When broadcasting, the M6 bounded anti-boot-race +StartDestination retry applies as in M8. + +SECRET HYGIENE (ADR Blue 001 R4 / R6, M8 parity — load-bearing): + - NO token committed anywhere (no baked Solar URL / JWT). + - The operator JWT (drives SETUP **and** the /trigger fire), the Twitch + key, and the minted show-token all come from the étage-1 environment. + - The operator credential is M8_OPERATOR_TOKEN (admin, short-TTL) — NOT + ORION_OPERATOR_TOKEN. The SAME operator JWT authorises /trigger (R6). + - Every line emitting solar_url is passed through redact_solar_url; the + Twitch key + operator JWT + show-token are scrubbed everywhere else; + and run-m9.ps1's grep-assert fails the run if any credential — including + the operator JWT used on /trigger — leaks to stdout / PNG / VOD. + +Usage (from the repo root, against the built -Full rundir): + pip install websockets + export M8_OPERATOR_TOKEN=... # étage-1 admin JWT, short-TTL (SETUP + /trigger) + export M8_GATEWAY_URL=http://127.0.0.1:8099 # tunnel'd gateway base + python scripts/probe-m9-canvas-live.py # author+push+capture-A+trigger+capture-B (the proof) + export TWITCH_STREAM_KEY=... # étage-1, broadcast leg only + python scripts/probe-m9-canvas-live.py --broadcast # + 30s Twitch broadcast + +Exit codes (mirror M8): + 0 pass (repaint proven A→B; if --broadcast, live ok too) + 1 fail (setup / provenance / repaint / broadcast assertion failed) + 2 config error (no operator token, no exe, no key for broadcast, bad args) + 3 typed skip (browser_source not registered — LIGHT build, needs -Full) +""" +from __future__ import annotations + +import argparse +import asyncio +import importlib.util +import os +import pathlib +import secrets +import socket +import sys +import time +from typing import Optional + +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + except Exception: + pass + +SCRIPTS_DIR = pathlib.Path(__file__).resolve().parent +REPO_ROOT = SCRIPTS_DIR.parent + +# Ensure the sibling m8_setup / m9_setup modules import regardless of CWD. +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +# Import the M6 broadcast + CEF core by path (hyphenated filename → not a +# module identifier). Reused wholesale: CEF spawn/reap, pure-stdlib PNG +# decode + analyse_frame, the broadcast loop, secret redaction. +_m6_path = SCRIPTS_DIR / "probe-m6-live.py" +_spec = importlib.util.spec_from_file_location("probe_m6_live", _m6_path) +assert _spec is not None and _spec.loader is not None +m6 = importlib.util.module_from_spec(_spec) +sys.modules["probe_m6_live"] = m6 +_spec.loader.exec_module(m6) + +import m8_setup # noqa: E402 +import m9_setup # noqa: E402 + +try: + import websockets # noqa: F401 (used inside m6.run via the shared import) +except ImportError: + print("error: pip install websockets (pure WS client — no native deps)") + sys.exit(2) + + +BUILD_DIR = REPO_ROOT / "build" +PROOF_BEFORE_PNG = BUILD_DIR / "m9-before.png" +PROOF_AFTER_PNG = BUILD_DIR / "m9-after.png" +LIVE_VOD_DIR = BUILD_DIR / "m9-canvas-vod" + +# Modal-colour provenance tolerance (Manhattan in RGB) — same band M8 uses: +# the CEF render + PNG re-encode shift colours slightly, but a large flat +# field's modal stays tight to the authored hex. +MODAL_COLOUR_TOL = 24 + +# The minimum modal delta between the pre-trigger (A) and post-trigger (B) +# frames for the repaint to count as REAL. A=(26,158,87), B=(200,30,90) are +# 305 apart, so this 80 floor is comfortably below the real signal yet far +# above any CEF/encode jitter (which stays within MODAL_COLOUR_TOL≈24). A +# frame that never repainted stays at distance ~0 and FAILS here. +REPAINT_MIN_DELTA = 80 + +# Budget for the repaint to land after the trigger fires. Orion's +# input→delta is ≤ 50 ms (Orion criterion 4) and the LSDP push is sub-second; +# the slack here is for CEF to render the delta + our 1 Hz screenshot poll +# cadence — NOT for the leaf write itself. A repaint that needs longer than +# this is a real failure (push lost / scene not reacting), not a slow render. +REPAINT_DEADLINE_S = 8.0 +REPAINT_POLL_INTERVAL_S = 0.5 + + +def _manhattan(a: tuple[int, int, int], b: tuple[int, int, int]) -> int: + return abs(a[0] - b[0]) + abs(a[1] - b[1]) + abs(a[2] - b[2]) + + +def _modal_ok(modal: Optional[tuple[int, int, int]], + target: tuple[int, int, int]) -> tuple[bool, int]: + """(ok, manhattan) for a modal-colour match within MODAL_COLOUR_TOL.""" + if modal is None: + return False, 1 << 30 + dist = _manhattan(modal, target) + return dist <= MODAL_COLOUR_TOL, dist + + +async def _grab_modal(inbox, ws) -> tuple[Optional[tuple[int, int, int]], Optional[bytes], dict]: + """One GetSourceScreenshot → (modal_rgb, png_bytes, metrics). + + Targets the same managed CEF source the pre-flight created + (CAPTURE_SOURCE_NAME). Returns (None, None, {}) when the shot is not + ready or fails to decode — the caller polls.""" + r = await m6.request(inbox, ws, "GetSourceScreenshot", f"m9-{time.monotonic()}", { + "sourceName": m6.CAPTURE_SOURCE_NAME, + "imageFormat": "png", + "imageWidth": m6.CANVAS_W, + "imageHeight": m6.CANVAS_H, + }) + if not r["requestStatus"]["result"]: + return None, None, {} + try: + png = m6._strip_data_uri(r["responseData"]["imageData"]) # noqa: SLF001 + w, h, ch, px = m6.decode_png(png) + except Exception: # noqa: BLE001 + return None, None, {} + metrics = m6.analyse_frame(w, h, ch, px) + return metrics.get("modal"), png, metrics + + +async def capture_before(inbox, ws, solar_url: str, + rgb_a: tuple[int, int, int]) -> tuple[int, Optional[bytes]]: + """Pre-flight: render the live Solar scene, confirm non-blank AND modal + ≈ A, save build/m9-before.png. Returns (rc, before_png). rc=0 means the + scene is on-air at the seeded default A — the baseline the trigger will + move. Reuses M6's SetCaptureSource + non-blank poll (the browser_source + is created HERE, once, and never re-created).""" + # Point M6's module proof path + dirs at the M9 BEFORE artefact so the + # reused non-blank core writes where the M9 wrapper/CI expect. + m6.PROOF_PNG = PROOF_BEFORE_PNG + m6.BUILD_DIR = BUILD_DIR + + rc, metrics = await m6.preflight_non_blank(inbox, ws, solar_url) + if rc != 0: + print("[M9] capture-A FAILED at the non-blank stage — the live Solar " + "scene never produced a frame. NOT proceeding to the trigger. " + "Diagnose: LSDP WS to the gateway failed (token/.lsdp gate?), " + "Solar bundle 404, or the active scene never streamed. See the " + "m6 diagnosis above + the saved PNG.") + return 1, None + + modal = metrics.get("modal") + ok, dist = _modal_ok(modal, rgb_a) + print(f"[M9] capture-A modal-colour check: captured modal={modal} " + f"target_A={rgb_a} manhattan={dist} tol={MODAL_COLOUR_TOL}") + if not ok: + print("[M9] capture-A FAILED — the frame is non-blank but its modal " + "colour does NOT match the seeded leaf default A " + f"(#{rgb_a[0]:02X}{rgb_a[1]:02X}{rgb_a[2]:02X}). The scene on " + "air is not M9's, or the leaf was not seeded from its declared " + "default. NOT firing the trigger. Inspect: " + str(PROOF_BEFORE_PNG)) + return 1, None + + before_png = PROOF_BEFORE_PNG.read_bytes() if PROOF_BEFORE_PNG.exists() else None + print(f"[M9] capture-A PROVEN — non-blank AND modal ≈ seeded default A. " + f"Baseline frame: {PROOF_BEFORE_PNG}") + return 0, before_png + + +async def capture_after(inbox, ws, + rgb_a: tuple[int, int, int], + rgb_b: tuple[int, int, int]) -> tuple[int, Optional[bytes]]: + """Post-trigger: poll the SAME browser_source until its modal colour + moves to ≈ B, save build/m9-after.png, and assert the repaint is real. + + The assertions (the M9 proof): + - modal_after ≈ B (within MODAL_COLOUR_TOL): the trigger's pushed + value reached the pixels. + - Manhattan(modal_after, A) > REPAINT_MIN_DELTA: the bound region + DEMONSTRABLY changed from the pre-trigger baseline — not the same + frame, not jitter. + No SetCaptureSource is issued here: the browser_source from capture-A is + untouched, so a change is a live LSDP repaint, not a reload. + """ + print(f"-> polling for repaint to B={rgb_b} (deadline {REPAINT_DEADLINE_S:.0f}s; " + f"Orion input→delta is ≤50ms, slack is CEF render + 2Hz screenshot poll) ...") + deadline = time.monotonic() + REPAINT_DEADLINE_S + attempt = 0 + last_modal: Optional[tuple[int, int, int]] = None + last_png: Optional[bytes] = None + while time.monotonic() < deadline: + attempt += 1 + modal, png, _ = await _grab_modal(inbox, ws) + if modal is not None: + last_modal, last_png = modal, png + ok_b, dist_b = _modal_ok(modal, rgb_b) + delta_a = _manhattan(modal, rgb_a) + if attempt == 1 or attempt % 4 == 0: + print(f" attempt {attempt}: modal={modal} dist_to_B={dist_b} " + f"delta_from_A={delta_a}") + if ok_b and delta_a > REPAINT_MIN_DELTA: + BUILD_DIR.mkdir(parents=True, exist_ok=True) + if png is not None: + PROOF_AFTER_PNG.write_bytes(png) + print(f"[M9] REPAINT PROVEN — modal moved A={rgb_a} → B={modal} " + f"(≈ target B={rgb_b}, dist_to_B={dist_b} ≤ {MODAL_COLOUR_TOL}; " + f"delta_from_A={delta_a} > {REPAINT_MIN_DELTA}). The Blue " + f"trigger repainted the bound region on screen, no reload. " + f"Proof PNG: {PROOF_AFTER_PNG}") + return 0, png + await asyncio.sleep(REPAINT_POLL_INTERVAL_S) + + # Deadline elapsed without a proven repaint — diagnose precisely. + if last_png is not None: + BUILD_DIR.mkdir(parents=True, exist_ok=True) + PROOF_AFTER_PNG.write_bytes(last_png) + print("\n[M9] REPAINT FAILED — the bound region did not move to B within " + f"the budget. Last modal={last_modal} (target B={rgb_b}, A={rgb_a}).") + if last_modal is not None and _manhattan(last_modal, rgb_a) <= REPAINT_MIN_DELTA: + print(" The frame STAYED at A: the trigger's leaf write never reached " + "the live scene. Likely: Blue's bridge is off (no BLUE_OPERATOR_TOKEN " + "→ pushed.delivered=false), the leaf is out of scope/undeclared " + "(Orion CanWritePath / sceneAcceptsPath rejected it — check the " + "operator_inputs declaration), or Orion never recomputed/emitted " + "the delta. The /trigger returned 200 regardless (best-effort, " + "ADR Blue 001 §3.2.6), so a 200 alone does NOT prove the repaint.") + elif last_modal is not None: + print(" The frame CHANGED but not to B: a different value reached the " + "leaf, or a different scene is on air. Inspect both PNGs.") + else: + print(" No screenshot decoded post-trigger — the browser source " + "stopped producing frames.") + print(f" before={PROOF_BEFORE_PNG} after={PROOF_AFTER_PNG}") + return 1, last_png + + +async def run(ws_url: str, password: str, setup: "m9_setup.SetupResult", + gateway_url: str, operator_token: str, scrub: list[str], + stream_key: str, duration_sec: int, broadcast: bool, + pulsar) -> int: + import json + print(f"connecting: {ws_url}") + async with websockets.connect( + ws_url, subprotocols=["obswebsocket.json"], max_size=2**24, + ping_interval=None, close_timeout=15, open_timeout=10, + ) as ws: + hello = json.loads(await asyncio.wait_for(ws.recv(), timeout=10)) + if hello.get("op") != 0: + print(f"error: expected Hello (op=0), got {hello}") + return 1 + identify_d: dict = { + "rpcVersion": hello["d"]["rpcVersion"], + "eventSubscriptions": m6.EVENT_SUBSCRIPTION_ALL, + } + if "authentication" in hello["d"]: + a = hello["d"]["authentication"] + identify_d["authentication"] = m6.compute_auth( + password, a["salt"], a["challenge"]) + await ws.send(json.dumps({"op": 1, "d": identify_d})) + ident = json.loads(await asyncio.wait_for(ws.recv(), timeout=10)) + if ident.get("op") != 2: + print(f"error: identify failed: {ident}") + return 1 + print("identified (v5 auth OK)") + + inbox = m6.Inbox() + + resp = await m6.request(inbox, ws, "GetInputKindList", "kinds", {}) + kinds = set(resp["responseData"]["inputKinds"]) + if "browser_source" not in kinds: + print("SKIP: browser_source NOT registered — LIGHT build (no CEF). " + "M9 needs a full build (scripts/build-win.ps1 -Full). " + "Typed skip, NOT a pass.") + return 3 + print(f"browser_source registered ({len(kinds)} input kinds total)") + + # --- capture-A: scene on air at the seeded default A --------------- + rc, _ = await capture_before(inbox, ws, setup.solar_url, setup.rgb_a) + if rc != 0: + return 1 + + # --- fire the trigger (operator Bearer header, R6) ----------------- + print("\n[M9] firing the Blue trigger (the live mutation) ...") + try: + m9_setup.fire_trigger( + gateway_url=gateway_url, + operator_token=operator_token, + secrets=scrub, + blueprint_id=setup.blueprint_id, + colour_b=setup.colour_b, + log=print, + ) + except m9_setup.SetupError as exc: + print(f"[M9] trigger FAILED: {exc}") + return 1 + + # --- capture-B: prove the bound region repainted to B -------------- + rc, _ = await capture_after(inbox, ws, setup.rgb_a, setup.rgb_b) + if rc != 0: + return 1 + + if not broadcast: + print("[M9] repaint proven; --broadcast not set, skipping go-live.") + return 0 + + print("\n[M9] going live to Twitch (repaint proven) ...") + m6.LIVE_VOD_DIR = LIVE_VOD_DIR + return await m6.broadcast(inbox, ws, stream_key, duration_sec, pulsar) + + +def pick_free_port() -> int: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + finally: + s.close() + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Pulsar M9 — Blue trigger repaints a live Canvas scene, proven A→B") + ap.add_argument("--exe", type=pathlib.Path, + default=pathlib.Path(os.environ.get("PULSAR_EXE", str(m6.DEFAULT_EXE))), + help="path to pulsar.exe (default: built rundir)") + ap.add_argument("--gateway-url", type=str, + default=os.environ.get("M8_GATEWAY_URL", "http://127.0.0.1:8099"), + help="ZabGate base URL (default: the tunnel'd gateway). All " + "SETUP + /trigger HTTP is gateway-first against this base.") + ap.add_argument("--solar-version", type=str, + default=os.environ.get("M8_SOLAR_VERSION", + m9_setup.DEFAULT_SOLAR_VERSION), + help="Solar bundle version served at /static/solar/v{N}/ " + "(default 0.2.0 — the LSDP wire).") + ap.add_argument("--show-stream-path", type=str, + default=os.environ.get("M8_SHOW_STREAM_PATH", + m9_setup.DEFAULT_SHOW_STREAM_PATH), + choices=["stream.lsdp", "stream"], + help="show-stream wire path: stream.lsdp (LSDP, default) or " + "stream (bespoke fallback).") + ap.add_argument("--broadcast", action="store_true", + help="after proving the repaint, also broadcast 30s to Twitch " + "(needs TWITCH_STREAM_KEY). Off by default — M9 proves " + "the repaint, not the go-live.") + ap.add_argument("--duration", type=int, + default=int(os.environ.get("LIVE_TEST_DURATION", "30")), + help="broadcast duration in seconds (default 30, --broadcast only)") + ap.add_argument("--fps", type=int, + default=int(os.environ.get("LIVE_TEST_FPS", "60")), + help="encoder fps target (default 60)") + ap.add_argument("--ready-timeout", type=float, default=m6.READY_TIMEOUT_S) + args = ap.parse_args() + + exe: pathlib.Path = args.exe + if not exe.exists(): + print(f"error: pulsar.exe not found at {exe}") + print("Build it first: scripts/build-win.ps1 -Full") + return 2 + + # The SETUP + /trigger operator credential is the dedicated short-TTL + # admin token from étage-1 (reused from M8) — NOT ORION_OPERATOR_TOKEN. + # The SAME JWT authorises /trigger (ADR Blue 001 R6, operator/admin). + operator_token = os.environ.get("M8_OPERATOR_TOKEN", "").strip() + if not operator_token: + print("error: M8_OPERATOR_TOKEN env var is empty (required). It is the " + "admin/operator JWT for SETUP (save/push/active-scene/mint) AND for " + "the /trigger fire (ADR Blue 001 R6 requires operator/admin). Source " + "it from the étage-1 secret as a SHORT-TTL admin token — do NOT reuse " + "ORION_OPERATOR_TOKEN. Never commit it.") + return 2 + if operator_token == os.environ.get("ORION_OPERATOR_TOKEN", "").strip() and operator_token: + print("error: M8_OPERATOR_TOKEN must NOT equal ORION_OPERATOR_TOKEN " + "(the long-lived exp-2027 service token). Mint a dedicated " + "short-TTL admin token for the test run.") + return 2 + + stream_key = os.environ.get("TWITCH_STREAM_KEY", "").strip() + if args.broadcast and not stream_key: + print("error: --broadcast set but TWITCH_STREAM_KEY env var is empty. " + "Set it from the étage-1 secret; never commit. (Omit --broadcast " + "to prove the repaint without going live.)") + return 2 + + print("=== M9 SETUP leg (gateway-first authoring + activation) ===") + print(f" gateway: {args.gateway_url}") + print(f" wire: {args.show_stream_path} solar: v{args.solar_version}") + print(f" M8_OPERATOR_TOKEN: (SETUP + /trigger)") + print(f" TWITCH_STREAM_KEY: {'' if stream_key else ''}" + f"{'' if args.broadcast else ' (broadcast off)'}") + + try: + setup = m9_setup.run_setup( + gateway_url=args.gateway_url, + operator_token=operator_token, + twitch_key=stream_key, + solar_version=args.solar_version, + show_stream_path=args.show_stream_path, + log=print, + ) + except m9_setup.SetupError as exc: + print(f"FAIL: SETUP leg failed: {exc}") + return 1 + + print("=== M9 SETUP done — scene authored, pushed, active, token minted ===") + print(f" scene_id={setup.scene_id}") + print(f" bundle_hash(H)={setup.bundle_hash}") + print(f" pushed_scene_version={setup.pushed_scene_version}") + print(f" blueprint_id={setup.blueprint_id} slug={setup.blueprint_slug}") + print(f" bound_leaf={setup.leaf_path}") + print(f" colour A(default)={setup.colour_a} colour B(trigger)={setup.colour_b}") + print(f" solar_url={m9_setup.redact_solar_url(setup.solar_url)}") + + # Secrets to scrub from EVERY diagnostic line below + passed to the + # trigger client so a 4xx body can never echo the JWT. The operator JWT + # is now ALSO the /trigger credential — it must be redacted everywhere. + scrub = [s for s in (stream_key, operator_token, setup.show_token) if s] + + def safe(text: str) -> str: + return m9_setup.redact_token(text, *scrub) + + port = pick_free_port() + password = secrets.token_urlsafe(16) + print(f"\n=== M9 PROOF: capture-A → trigger → capture-B (reused M6 CEF core) ===") + print(f"spawning: {exe}") + print(f" PULSAR_PORT={port} PULSAR_PASSWORD=") + + # Point the reused M6 core at the M9 artefact paths + destination name. + m6.LIVE_VOD_DIR = LIVE_VOD_DIR + m6.PROOF_PNG = PROOF_BEFORE_PNG + m6.BUILD_DIR = BUILD_DIR + m6.DESTINATION_NAME = "pulsar-m9-canvas-live" + pulsar = m6.PulsarProcess(exe, port, password, args.fps) + rc = 1 + try: + pulsar.spawn() + ws_url, sentinel_pw = pulsar.wait_ready(args.ready_timeout) + print(f"READY: {ws_url}") + rc = asyncio.run(run( + ws_url, sentinel_pw, setup, + args.gateway_url, operator_token, scrub, + stream_key, args.duration, args.broadcast, pulsar, + )) + except KeyboardInterrupt: + print("interrupted") + rc = 130 + except Exception as exc: # noqa: BLE001 — top-level probe diagnostic + print(f"FAIL: {safe(str(exc))}") + if pulsar.proc is not None: + print(safe(pulsar.diag())) + rc = 1 + finally: + if rc != 0: + tail = pulsar.lines[-80:] + if tail: + print("\n---- pulsar stdout (last 80 lines, redacted) ----") + for ln in tail: + print(f" {safe(ln)}") + print("---- end pulsar stdout ----\n") + pulsar.shutdown() + if pulsar.proc is not None and pulsar.proc.poll() is None: + print("error: pulsar.exe still running after shutdown attempt") + rc = rc or 1 + else: + print("pulsar.exe reaped cleanly") + + print("PASS" if rc == 0 else (f"SKIPPED (exit {rc})" if rc == 3 + else f"FAILED (exit {rc})")) + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/run-m9.ps1 b/scripts/run-m9.ps1 new file mode 100644 index 0000000..370ef82 --- /dev/null +++ b/scripts/run-m9.ps1 @@ -0,0 +1,121 @@ +# run-m9.ps1 - wrapper for the M9 Blue-trigger live-repaint probe with a +# hard secret grep-assert (ADR Blue 001 R4/R6, M8 parity). +# +# It runs scripts/probe-m9-canvas-live.py, tees stdout to a log, then +# grep-asserts that NONE of the credentials appear in clear in the captured +# stdout OR in the produced before/after proof PNGs / VOD artefacts. The +# credential set for M9 is the SAME operator JWT (it drives BOTH the SETUP +# legs AND the /trigger fire - ADR Blue 001 R6) plus the Twitch stream key; +# the viewer show-token is minted at runtime. A leak fails the run (exit 1) +# REGARDLESS of the probe's own exit code - redaction is a gate, not a log +# nicety. +# +# The credentials come from the etage-1 environment (M8_OPERATOR_TOKEN, +# TWITCH_STREAM_KEY). Reading the minted show-token back out of the probe to +# scan for it is not possible from here, so we instead assert the KNOWN +# secrets never appear, and that no raw `token=eyJ` / `token%3DeyJ` +# JWT-shaped substring survived in the log. +# +# Usage (from the repo root): +# $env:M8_OPERATOR_TOKEN = "..." # etage-1 admin short-TTL JWT +# $env:M8_GATEWAY_URL = "http://127.0.0.1:8099" +# pwsh scripts/run-m9.ps1 # prove the repaint (no Twitch) +# $env:TWITCH_STREAM_KEY = "..." # etage-1 (broadcast leg only) +# pwsh scripts/run-m9.ps1 -Broadcast # + 30s broadcast + +[CmdletBinding()] +param( + [switch] $Broadcast, + [string] $GatewayUrl = $env:M8_GATEWAY_URL, + [string] $ShowStreamPath = "stream.lsdp", + [string] $SolarVersion = "0.2.0", + [string] $PythonExe = "python" +) + +$ErrorActionPreference = "Stop" +$repoRoot = Split-Path -Parent $PSScriptRoot +$stdoutLog = Join-Path $repoRoot "build\m9-probe-stdout.log" +$beforePng = Join-Path $repoRoot "build\m9-before.png" +$afterPng = Join-Path $repoRoot "build\m9-after.png" +$vodDir = Join-Path $repoRoot "build\m9-canvas-vod" + +New-Item -ItemType Directory -Force -Path (Join-Path $repoRoot "build") | Out-Null + +# --- Build the probe argument list --------------------------------------- +$probeArgs = @("scripts/probe-m9-canvas-live.py") +if ($Broadcast) { $probeArgs += "--broadcast" } +if ($GatewayUrl) { $probeArgs += @("--gateway-url", $GatewayUrl) } +$probeArgs += @("--show-stream-path", $ShowStreamPath) +$probeArgs += @("--solar-version", $SolarVersion) + +# --- Pre-flight env sanity (fail fast, never echo the values) ------------ +if (-not $env:M8_OPERATOR_TOKEN) { + Write-Error "M8_OPERATOR_TOKEN is not set (etage-1 admin short-TTL JWT; drives SETUP + /trigger). Refusing to run." +} +if ($Broadcast -and -not $env:TWITCH_STREAM_KEY) { + Write-Error "TWITCH_STREAM_KEY is not set (etage-1) and -Broadcast requested. Refusing to run." +} + +# --- Run the probe, tee stdout ------------------------------------------- +Push-Location $repoRoot +try { + Write-Host "[run-m9] launching probe (wire=$ShowStreamPath solar=v$SolarVersion broadcast=$Broadcast)" + & $PythonExe @probeArgs 2>&1 | Tee-Object -FilePath $stdoutLog + $probeExit = $LASTEXITCODE +} finally { + Pop-Location +} + +# --- Grep-assert: no credential in clear in stdout or artefacts ----------- +$secrets = @() +if ($env:M8_OPERATOR_TOKEN) { $secrets += $env:M8_OPERATOR_TOKEN } +if ($env:TWITCH_STREAM_KEY) { $secrets += $env:TWITCH_STREAM_KEY } + +$leak = $false + +function Assert-NoSecret { + param([string] $Path, [string[]] $Needles, [string] $Label) + if (-not (Test-Path $Path)) { return $false } + $bytes = [System.IO.File]::ReadAllBytes($Path) + $text = [System.Text.Encoding]::UTF8.GetString($bytes) + foreach ($n in $Needles) { + if ($n -and $text.Contains($n)) { + Write-Host "::error::SECRET LEAK - a credential appears in clear in $Label ($Path)" + return $true + } + } + return $false +} + +# 1. The known secrets (operator JWT used on SETUP + /trigger, Twitch key) +# must not appear anywhere. +$leak = (Assert-NoSecret -Path $stdoutLog -Needles $secrets -Label "probe stdout") -or $leak +$leak = (Assert-NoSecret -Path $beforePng -Needles $secrets -Label "before proof PNG") -or $leak +$leak = (Assert-NoSecret -Path $afterPng -Needles $secrets -Label "after proof PNG") -or $leak + +# 2. Heuristic: the minted show-token is unknown to this wrapper, so assert +# no un-redacted JWT-shaped token survived in the log - neither a plain +# `token=eyJ...` nor a url-encoded `token%3DeyJ...`. +if (Test-Path $stdoutLog) { + $log = Get-Content -Raw -Path $stdoutLog + if ($log -match "token=eyJ" -or $log -match "token%3DeyJ") { + Write-Host "::error::SECRET LEAK - an un-redacted show-token (token=eyJ / token%3DeyJ) survived in the probe stdout" + $leak = $true + } +} + +# 3. Scan VOD artefacts too (defensive; only present when -Broadcast). +if (Test-Path $vodDir) { + Get-ChildItem -Path $vodDir -File -Recurse -ErrorAction SilentlyContinue | ForEach-Object { + $leak = (Assert-NoSecret -Path $_.FullName -Needles $secrets -Label "VOD artefact") -or $leak + } +} + +if ($leak) { + Write-Host "[run-m9] GREP-ASSERT FAILED - credential leak detected; failing the run regardless of probe exit ($probeExit)." + exit 1 +} + +Write-Host "[run-m9] grep-assert clean - no credential leaked to stdout / PNGs / VOD." +Write-Host "[run-m9] probe exit code: $probeExit" +exit $probeExit From fbdbc3bf3c9b7427edb95a8a1ed3e6f3c6811adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ClodoCap=C3=A9o?= <159788250+ClodoCapeo@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:32:42 +0200 Subject: [PATCH 4/5] refactor(m9): fire Blue trigger mid-broadcast so the swap is on air The M9 probe proved the A->B repaint BEFORE going live, so the broadcast only ever showed a static magenta field -- the green->magenta transition never appeared on the stream or in the VOD. Reorder to M8's scene-switch pattern: go live on the green A frame, then fire /trigger at duration/2 WHILE broadcasting so the swap happens in direct. New live leg broadcast_with_live_trigger(): CreateDestination -> StartDestination (M6/M8 anti-boot-race retry) on GREEN A -> capture-A on the live wire -> poll ~duration/2 (green) -> /trigger {colour:B} at mid -> capture-B on the live wire (assert modal~B, Manhattan(A->B) > floor) -> poll the magenta half -> StopRecord/StopDestination. The VOD now records the on-air green->magenta cut. Live is now the DEFAULT (porteur: a l'antenne par defaut); --no-broadcast keeps the prove-only mode (pre-flight -> trigger -> capture-B, no Twitch key) for CI. run-m9.ps1 inverts to -NoBroadcast accordingly. Secret redaction unchanged: operator JWT / Twitch key / show-token scrubbed, grep-assert intact. py_compile clean. Refs #53 Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/probe-m9-canvas-live.py | 388 ++++++++++++++++++++++++++------ scripts/run-m9.ps1 | 18 +- 2 files changed, 328 insertions(+), 78 deletions(-) diff --git a/scripts/probe-m9-canvas-live.py b/scripts/probe-m9-canvas-live.py index 344a1c2..945fc7e 100644 --- a/scripts/probe-m9-canvas-live.py +++ b/scripts/probe-m9-canvas-live.py @@ -1,11 +1,15 @@ #!/usr/bin/env python3 -"""Pulsar M9 live probe — a Blue **trigger** repaints a live scene on air, -proven pixel-by-pixel (ADR Blue 001 §6 criterion 4, the M9 proof). +"""Pulsar M9 live probe — a Blue **trigger** repaints a live scene **on air, +in direct**, proven pixel-by-pixel (ADR Blue 001 §6 criterion 4, the M9 proof). M9 is M8's authored Canvas scene reaching the wire (the SETUP + non-blank provenance pre-flight are reused wholesale), PLUS the reactive step ADR Blue 001 exists to prove: firing ``POST /api/v1/blueprints/{id}/trigger`` -mutates a **live** scene **with no reload**. +mutates a **live** scene **with no reload** — and the porteur must be able to +**watch that swap happen on the broadcast**, exactly like M8's mid-stream +scene-switch (probe-twitch-scene-switch.py fires its switch at duration/2 +*while live*). So in M9 the **trigger fires mid-broadcast**, not before it: +the VOD shows a hard green→magenta cut on air, not a static magenta field. The decisive chain (ADR Blue 001 §3.2.5): @@ -14,35 +18,40 @@ service-token WS → Orion CanWritePath + write leaf → recompute → delta → LSDP wire → Solar repaints the bound region, no reload. -THE PROOF (capture-A / fire / capture-B): +THE PROOF — live by default (broadcast → capture-A → trigger@mid → capture-B): 1. SETUP (m9_setup) authors a scene whose **frame background** is bound to ``__inputs.blue.pulsar-m9-bg.colour``, declared as an operator-input with default colour **A** (#1A9E57). Orion seeds A on boot. 2. The reused M6 CEF core points a browser_source at the live Solar URL - (SetCaptureSource — called ONCE; never re-created between A and B, so - the later change is a live repaint, not a reload). - 3. Pre-flight: poll the captured frame until non-blank AND its modal - colour ≈ **A**. This is capture-A (``build/m9-before.png``) — it ties - the on-air pixels to the seeded leaf default before any trigger. - 4. Fire ``/trigger`` with the operator Bearer **header** and - ``inputs={"colour": B}`` (#C81E5A). ADR Blue 001 R6 — operator/admin - only; the JWT rides as ``Authorization: Bearer`` (header, never query). - 5. Poll the *same* browser_source until its modal colour moves to ≈ **B** - within Orion's input-to-delta budget (+ CEF render/screenshot slack). - This is capture-B (``build/m9-after.png``). - 6. Assert **B ≈ target-B** AND **Manhattan(B, A) > REPAINT_MIN_DELTA** — - the bound region demonstrably changed on screen, caused by the trigger, - with no reload. A frame that never leaves A (push lost / leaf rejected / - scene didn't bind) FAILS; a frame that changed to something other than - B (wrong scene / ambient) FAILS. - -Broadcast: M9's core IS the repaint proof and runs in the pre-flight -(``--preflight-only`` is the default-meaningful mode). The 30s Twitch -broadcast leg is reused verbatim from M6/M8 and is OPTIONAL (``--broadcast``) -— going live is not what M9 proves; the proven repaint is (so a CI run needs -no Twitch key). When broadcasting, the M6 bounded anti-boot-race -StartDestination retry applies as in M8. + (SetCaptureSource — called ONCE in the pre-flight; never re-created + between A and B, so the later change is a live repaint, not a reload). + 3. Pre-flight (before going live): poll the captured frame until non-blank + so the green field A is demonstrably rendering. + 4. **StartDestination → the broadcast goes live on the GREEN A frame.** + 5. **capture-A** *during the live broadcast*: grab the on-air frame, assert + modal ≈ **A**, save ``build/m9-before.png`` — the stream starts green. + 6. Poll metrics ~duration/2 (the live + VOD show green) — destination + active, drop ratio within budget. + 7. **At t≈duration/2, fire ``/trigger``** with the operator Bearer + **header** and ``inputs={"colour": B}`` (#C81E5A) — ADR Blue 001 R6, + operator/admin only; the JWT rides as ``Authorization: Bearer`` (header, + never query). The bound background flips to magenta **on air, in direct**. + 8. **capture-B** *during the live broadcast*: poll the *same* browser_source + until its modal moves to ≈ **B**, save ``build/m9-after.png``, and assert + **B ≈ target-B** AND **Manhattan(B, A) > REPAINT_MIN_DELTA** — the bound + region demonstrably changed on screen, on the live wire, no reload. A + frame that never leaves A (push lost / leaf rejected / scene didn't bind) + FAILS; a frame that changed to something other than B FAILS. + 9. Poll ~the remaining duration (the live + VOD show magenta), + StopDestination. **The VOD shows the green→magenta transition on air.** + +The M6 bounded anti-boot-race StartDestination retry applies as in M8. + +Broadcast vs proof-only: the live broadcast with the **mid-stream trigger** is +the DEFAULT (porteur preference: à l'antenne par défaut). ``--no-broadcast`` +keeps the original "prove the repaint without going live" mode (pre-flight → +trigger → capture-B, no Twitch), useful for a CI run with no Twitch key. SECRET HYGIENE (ADR Blue 001 R4 / R6, M8 parity — load-bearing): - NO token committed anywhere (no baked Solar URL / JWT). @@ -59,14 +68,14 @@ pip install websockets export M8_OPERATOR_TOKEN=... # étage-1 admin JWT, short-TTL (SETUP + /trigger) export M8_GATEWAY_URL=http://127.0.0.1:8099 # tunnel'd gateway base - python scripts/probe-m9-canvas-live.py # author+push+capture-A+trigger+capture-B (the proof) - export TWITCH_STREAM_KEY=... # étage-1, broadcast leg only - python scripts/probe-m9-canvas-live.py --broadcast # + 30s Twitch broadcast + export TWITCH_STREAM_KEY=... # étage-1 (required for the default live run) + python scripts/probe-m9-canvas-live.py # LIVE: broadcast→capture-A→trigger@mid→capture-B→stop + python scripts/probe-m9-canvas-live.py --no-broadcast # prove the repaint without going live (no Twitch key) Exit codes (mirror M8): - 0 pass (repaint proven A→B; if --broadcast, live ok too) + 0 pass (repaint proven A→B on air; if live, broadcast ok too) 1 fail (setup / provenance / repaint / broadcast assertion failed) - 2 config error (no operator token, no exe, no key for broadcast, bad args) + 2 config error (no operator token, no exe, no key for the live run, bad args) 3 typed skip (browser_source not registered — LIGHT build, needs -Full) """ from __future__ import annotations @@ -140,6 +149,19 @@ REPAINT_DEADLINE_S = 8.0 REPAINT_POLL_INTERVAL_S = 0.5 +# Live-broadcast leg (mid-stream trigger). The trigger fires at duration/2 so +# the VOD shows roughly equal halves: green A, then magenta B. The metric poll +# cadence + the destination-active / drop-ratio thresholds are M6/M8 verbatim +# (m6.POLL_INTERVAL_SEC / m6.FRAME_DROP_RATIO_MAX), so this leg makes no new +# claim about broadcast health — it reuses the proven M8 live assertions and +# only inserts capture-A (right after going live) and the trigger@mid + capture-B. +LIVE_POLL_INTERVAL_S = m6.POLL_INTERVAL_SEC + +# Minimum broadcast duration that leaves room for: go-live + capture-A, a green +# half, the trigger + capture-B, a magenta half. Below this the mid-stream swap +# has no visible runway on either side. +MIN_LIVE_DURATION_S = 12 + def _manhattan(a: tuple[int, int, int], b: tuple[int, int, int]) -> int: return abs(a[0] - b[0]) + abs(a[1] - b[1]) + abs(a[2] - b[2]) @@ -177,41 +199,55 @@ async def _grab_modal(inbox, ws) -> tuple[Optional[tuple[int, int, int]], Option return metrics.get("modal"), png, metrics -async def capture_before(inbox, ws, solar_url: str, - rgb_a: tuple[int, int, int]) -> tuple[int, Optional[bytes]]: - """Pre-flight: render the live Solar scene, confirm non-blank AND modal - ≈ A, save build/m9-before.png. Returns (rc, before_png). rc=0 means the - scene is on-air at the seeded default A — the baseline the trigger will - move. Reuses M6's SetCaptureSource + non-blank poll (the browser_source - is created HERE, once, and never re-created).""" +async def preflight_render(inbox, ws, solar_url: str) -> int: + """Pre-flight, BEFORE going live: create the browser_source (ONCE) and poll + until the live Solar scene renders a non-blank frame. rc=0 means the green + A field is on screen and ready to broadcast. No modal-A assertion here — + that is capture-A, taken *after* the broadcast starts (so the proof is that + the LIVE stream opened on green). The browser_source created here is never + re-created, so the later A→B change is a repaint, not a reload.""" # Point M6's module proof path + dirs at the M9 BEFORE artefact so the # reused non-blank core writes where the M9 wrapper/CI expect. m6.PROOF_PNG = PROOF_BEFORE_PNG m6.BUILD_DIR = BUILD_DIR - rc, metrics = await m6.preflight_non_blank(inbox, ws, solar_url) + rc, _metrics = await m6.preflight_non_blank(inbox, ws, solar_url) if rc != 0: - print("[M9] capture-A FAILED at the non-blank stage — the live Solar " - "scene never produced a frame. NOT proceeding to the trigger. " + print("[M9] pre-flight FAILED at the non-blank stage — the live Solar " + "scene never produced a frame. NOT going live / NOT triggering. " "Diagnose: LSDP WS to the gateway failed (token/.lsdp gate?), " "Solar bundle 404, or the active scene never streamed. See the " "m6 diagnosis above + the saved PNG.") - return 1, None - - modal = metrics.get("modal") + return 1 + print("[M9] pre-flight PASSED — the Solar scene renders non-blank; the " + "green A field is ready to broadcast.") + return 0 + + +async def assert_capture_a(inbox, ws, rgb_a: tuple[int, int, int], + *, on_air: bool) -> tuple[int, Optional[bytes]]: + """capture-A: grab the captured frame, assert modal ≈ A, save + build/m9-before.png. Returns (rc, before_png). When ``on_air`` this is + taken *during the live broadcast* — the proof that the stream STARTED green + (the baseline the mid-stream trigger will move). No SetCaptureSource: the + pre-flight's browser_source is untouched.""" + where = "on air (live)" if on_air else "pre-flight" + modal, png, _ = await _grab_modal(inbox, ws) ok, dist = _modal_ok(modal, rgb_a) - print(f"[M9] capture-A modal-colour check: captured modal={modal} " + print(f"[M9] capture-A ({where}) modal-colour check: captured modal={modal} " f"target_A={rgb_a} manhattan={dist} tol={MODAL_COLOUR_TOL}") if not ok: - print("[M9] capture-A FAILED — the frame is non-blank but its modal " - "colour does NOT match the seeded leaf default A " + print("[M9] capture-A FAILED — the frame's modal colour does NOT match " + "the seeded leaf default A " f"(#{rgb_a[0]:02X}{rgb_a[1]:02X}{rgb_a[2]:02X}). The scene on " "air is not M9's, or the leaf was not seeded from its declared " "default. NOT firing the trigger. Inspect: " + str(PROOF_BEFORE_PNG)) return 1, None - + BUILD_DIR.mkdir(parents=True, exist_ok=True) + if png is not None: + PROOF_BEFORE_PNG.write_bytes(png) before_png = PROOF_BEFORE_PNG.read_bytes() if PROOF_BEFORE_PNG.exists() else None - print(f"[M9] capture-A PROVEN — non-blank AND modal ≈ seeded default A. " + print(f"[M9] capture-A PROVEN ({where}) — modal ≈ seeded default A. " f"Baseline frame: {PROOF_BEFORE_PNG}") return 0, before_png @@ -283,6 +319,185 @@ async def capture_after(inbox, ws, return 1, last_png +async def broadcast_with_live_trigger( + inbox, ws, setup: "m9_setup.SetupResult", gateway_url: str, + operator_token: str, scrub: list[str], stream_key: str, + duration_sec: int, pulsar) -> int: + """The LIVE M9 proof: broadcast on the GREEN A frame, capture-A on air, + fire the trigger at t≈duration/2 so the swap happens **in direct**, then + capture-B on air and assert the repaint. The VOD records green→magenta. + + Structure mirrors probe-twitch-scene-switch.py's broadcast(): the switch + fires at ``duration/2`` *inside* the live poll loop. Here the "switch" is + the Blue /trigger; capture-A / capture-B straddle it on the live wire. The + destination lifecycle (CreateDestination, anti-boot-race StartDestination + retry, StartRecord, GetDestinations/GetAdaptiveState poll, Stop*) is the + M6/M8 core reused verbatim. + + The pre-flight (browser_source create + non-blank) MUST have already run + against ``setup.solar_url`` so the green field is on screen before we go + live.""" + import json + trigger_at = duration_sec / 2.0 + key = stream_key + + # 1. CreateDestination(twitch) — key passed opaquely, never printed. + r = await m6.vendor_call(inbox, ws, "create-dest", "pulsar", + "CreateDestination", { + "name": m6.DESTINATION_NAME, "kind": "twitch", "key": key, + }) + dest_id = m6.vendor_response_data(r).get("id") + if not dest_id: + status = m6.vendor_request_status(r) + print(f"FAIL: CreateDestination returned no id; " + f"status={m6.redact(json.dumps(status), key)}") + return 1 + print(f"-> CreateDestination(twitch) id={dest_id}") + + # 2. StartDestination with the bounded anti-boot-race retry (M8 parity). + # The broadcast goes live on the GREEN A frame (pre-flight rendered it). + if not await m6.start_destination_with_retry(inbox, ws, dest_id, key): + await m6.vendor_call(inbox, ws, "rm-dest", "pulsar", + "RemoveDestination", {"id": dest_id}) + return 1 + + # 2b. StartRecord — the offline VOD that will show the green→magenta cut. + recording = False + r = await m6.request(inbox, ws, "StartRecord", "start-rec") + if r.get("requestStatus", {}).get("result"): + recording = True + print(f"-> StartRecord ok (writing under {LIVE_VOD_DIR}) — the VOD will " + f"capture the on-air green→magenta transition") + else: + print(f" warn: StartRecord declined: {r.get('requestStatus')}") + + rc = 0 + try: + # 3. capture-A DURING the live broadcast — the stream started green. + print("\n[M9] capturing A on the LIVE wire (the broadcast opened on green) ...") + rc_a, _ = await assert_capture_a(inbox, ws, setup.rgb_a, on_air=True) + if rc_a != 0: + rc = 1 + + triggered = False + repaint_ok = False + start_t = time.time() + poll = 0 + adaptive_seen = 0 + while rc == 0 and time.time() - start_t < duration_sec: + await asyncio.sleep(LIVE_POLL_INTERVAL_S) + poll += 1 + elapsed = time.time() - start_t + + # 4. MID-STREAM TRIGGER at t≈duration/2 — the swap, on air. + if not triggered and elapsed >= trigger_at: + print(f"\n** BLUE TRIGGER @ t={elapsed:.1f}s (mid-stream) : firing " + f"/trigger {{'colour': B}} — the bound background flips " + f"GREEN→MAGENTA on air, in direct ...") + try: + m9_setup.fire_trigger( + gateway_url=gateway_url, + operator_token=operator_token, + secrets=scrub, + blueprint_id=setup.blueprint_id, + colour_b=setup.colour_b, + log=print, + ) + except m9_setup.SetupError as exc: + print(f"FAIL: [M9] trigger FAILED on air: {exc}") + rc = 1 + break + triggered = True + + # 5. capture-B DURING the live broadcast — assert the repaint + # happened on the wire (modal→B, Manhattan(A→B) > floor). + print("[M9] capturing B on the LIVE wire (the repaint is on air) ...") + rc_b, _ = await capture_after(inbox, ws, setup.rgb_a, setup.rgb_b) + if rc_b != 0: + rc = 1 + break + repaint_ok = True + + # Live-health poll — M6/M8 verbatim assertions. + r = await m6.vendor_call(inbox, ws, f"get-dest-{poll}", "pulsar", + "GetDestinations", {}) + lst = m6.vendor_response_data(r).get("destinations", []) + ours = next((d for d in lst if d.get("id") == dest_id), None) + if not ours or not ours.get("active"): + print(f"FAIL: destination not active at poll #{poll}: {ours}") + await asyncio.sleep(0.3) + rtmp = m6._scan_rtmp_diagnostic(pulsar.lines) # noqa: SLF001 + if rtmp: + print(" RTMP ingest diagnostic (pulsar log):") + for ln in rtmp[-6:]: + print(f" {m6.redact(ln, key)}") + rc = 1 + break + + r = await m6.vendor_call(inbox, ws, f"get-adapt-{poll}", "pulsar", + "GetAdaptiveState", {}) + adapt = m6.vendor_response_data(r) + samples = int(adapt.get("samples", 0)) + adaptive_seen = max(adaptive_seen, samples) + drop_ratio = float(adapt.get("last_drop_ratio", 0.0)) + cur_kbps = adapt.get("current_kbps") + + sr = await m6.request(inbox, ws, "GetStats", f"stats-{poll}") + stats = sr.get("responseData", {}) or {} + fps = stats.get("activeFps") + fps_str = f"{fps:.1f}" if isinstance(fps, (int, float)) else "—" + phase = "MAGENTA(B)" if triggered else "GREEN(A)" + + print(f" poll #{poll} t={elapsed:.0f}s active=true phase={phase} " + f"samples={samples} drop_ratio={drop_ratio:.4f} " + f"bitrate={cur_kbps} fps={fps_str}") + + if drop_ratio > m6.FRAME_DROP_RATIO_MAX: + print(f"FAIL: frame drop ratio {drop_ratio:.4f} > " + f"{m6.FRAME_DROP_RATIO_MAX} at poll #{poll}") + rc = 1 + break + + if rc == 0 and not triggered: + print("FAIL: broadcast ended before the mid-stream trigger fired " + "(duration too short?)") + rc = 1 + elif rc == 0 and not repaint_ok: + print("FAIL: the trigger fired but the on-air repaint was not proven") + rc = 1 + finally: + # 6. Stop cleanly (best-effort even on a failed poll) — leaves no orphan. + if recording: + try: + r = await m6.request(inbox, ws, "StopRecord", "stop-rec") + vod = (r.get("responseData", {}) or {}).get("outputPath") + if vod: + print(f"-> StopRecord finalised: {vod}") + print(f"LIVE_VOD_PATH={vod}") + except Exception as exc: # noqa: BLE001 + print(f" warn: StopRecord error: {exc}") + try: + await m6.vendor_call(inbox, ws, "stop-dest", "pulsar", + "StopDestination", {"id": dest_id}) + print("-> StopDestination ok") + except Exception as exc: # noqa: BLE001 + print(f" warn: StopDestination error: {exc}") + try: + await m6.vendor_call(inbox, ws, "rm-dest", "pulsar", + "RemoveDestination", {"id": dest_id}) + except Exception: + pass + + if rc == 0 and adaptive_seen <= 0: + print(f"FAIL: adaptive worker never reported samples (saw {adaptive_seen})") + rc = 1 + if rc == 0: + print(f"-> LIVE M9 clean: broadcast opened GREEN, trigger fired mid-stream, " + f"on-air repaint to MAGENTA proven, adaptive_samples={adaptive_seen}. " + f"The VOD shows the green→magenta transition.") + return rc + + async def run(ws_url: str, password: str, setup: "m9_setup.SetupResult", gateway_url: str, operator_token: str, scrub: list[str], stream_key: str, duration_sec: int, broadcast: bool, @@ -323,13 +538,32 @@ async def run(ws_url: str, password: str, setup: "m9_setup.SetupResult", return 3 print(f"browser_source registered ({len(kinds)} input kinds total)") - # --- capture-A: scene on air at the seeded default A --------------- - rc, _ = await capture_before(inbox, ws, setup.solar_url, setup.rgb_a) + # --- pre-flight: create the browser_source ONCE + render green A ----- + # The green field must be on screen BEFORE we go live (live path) or + # before we trigger (no-broadcast path). SetCaptureSource happens here + # and is never re-issued, so the later A→B change is a repaint. + if await preflight_render(inbox, ws, setup.solar_url) != 0: + return 1 + + if broadcast: + # === LIVE: broadcast → capture-A → trigger@mid → capture-B → stop. + # The trigger fires IN the broadcast (t≈duration/2) so the swap is + # visible on air + in the VOD, exactly like M8's scene-switch. + print("\n[M9] going live to Twitch — the green A frame, then the " + "mid-stream Blue trigger flips it to magenta ON AIR ...") + m6.LIVE_VOD_DIR = LIVE_VOD_DIR + return await broadcast_with_live_trigger( + inbox, ws, setup, gateway_url, operator_token, scrub, + stream_key, duration_sec, pulsar) + + # === --no-broadcast: prove the repaint without going live (no Twitch). + # capture-A → trigger → capture-B, all in the pre-flight CEF, no wire. + print("\n[M9] --no-broadcast: proving the repaint without going live ...") + rc, _ = await assert_capture_a(inbox, ws, setup.rgb_a, on_air=False) if rc != 0: return 1 - # --- fire the trigger (operator Bearer header, R6) ----------------- - print("\n[M9] firing the Blue trigger (the live mutation) ...") + print("\n[M9] firing the Blue trigger (the mutation) ...") try: m9_setup.fire_trigger( gateway_url=gateway_url, @@ -343,18 +577,11 @@ async def run(ws_url: str, password: str, setup: "m9_setup.SetupResult", print(f"[M9] trigger FAILED: {exc}") return 1 - # --- capture-B: prove the bound region repainted to B -------------- rc, _ = await capture_after(inbox, ws, setup.rgb_a, setup.rgb_b) if rc != 0: return 1 - - if not broadcast: - print("[M9] repaint proven; --broadcast not set, skipping go-live.") - return 0 - - print("\n[M9] going live to Twitch (repaint proven) ...") - m6.LIVE_VOD_DIR = LIVE_VOD_DIR - return await m6.broadcast(inbox, ws, stream_key, duration_sec, pulsar) + print("[M9] repaint proven; --no-broadcast set, skipping go-live.") + return 0 def pick_free_port() -> int: @@ -387,13 +614,16 @@ def main() -> int: choices=["stream.lsdp", "stream"], help="show-stream wire path: stream.lsdp (LSDP, default) or " "stream (bespoke fallback).") - ap.add_argument("--broadcast", action="store_true", - help="after proving the repaint, also broadcast 30s to Twitch " - "(needs TWITCH_STREAM_KEY). Off by default — M9 proves " - "the repaint, not the go-live.") + ap.add_argument("--no-broadcast", dest="broadcast", action="store_false", + help="prove the repaint WITHOUT going live (no Twitch key): " + "pre-flight → capture-A → trigger → capture-B. By " + "default M9 goes LIVE and fires the trigger mid-stream " + "so the green→magenta swap is visible on air + in the VOD.") + ap.set_defaults(broadcast=True) ap.add_argument("--duration", type=int, default=int(os.environ.get("LIVE_TEST_DURATION", "30")), - help="broadcast duration in seconds (default 30, --broadcast only)") + help="broadcast duration in seconds (default 30); the " + "mid-stream trigger fires at duration/2 (live run only)") ap.add_argument("--fps", type=int, default=int(os.environ.get("LIVE_TEST_FPS", "60")), help="encoder fps target (default 60)") @@ -425,14 +655,24 @@ def main() -> int: stream_key = os.environ.get("TWITCH_STREAM_KEY", "").strip() if args.broadcast and not stream_key: - print("error: --broadcast set but TWITCH_STREAM_KEY env var is empty. " - "Set it from the étage-1 secret; never commit. (Omit --broadcast " - "to prove the repaint without going live.)") + print("error: live run requires TWITCH_STREAM_KEY (it broadcasts and " + "fires the trigger mid-stream so the swap is on air). Set it from " + "the étage-1 secret; never commit. (Pass --no-broadcast to prove " + "the repaint without going live, no Twitch key.)") + return 2 + + if args.broadcast and args.duration < MIN_LIVE_DURATION_S: + print(f"error: --duration must be >= {MIN_LIVE_DURATION_S}s for the live " + "run so the mid-stream trigger (at duration/2) has a green half " + "before and a magenta half after.") return 2 print("=== M9 SETUP leg (gateway-first authoring + activation) ===") print(f" gateway: {args.gateway_url}") print(f" wire: {args.show_stream_path} solar: v{args.solar_version}") + print(f" mode: {'LIVE (broadcast + trigger@mid-stream, on air)' if args.broadcast else 'prove-only (--no-broadcast)'}") + if args.broadcast: + print(f" duration: {args.duration}s trigger@: {args.duration/2:.0f}s (mid-stream)") print(f" M8_OPERATOR_TOKEN: (SETUP + /trigger)") print(f" TWITCH_STREAM_KEY: {'' if stream_key else ''}" f"{'' if args.broadcast else ' (broadcast off)'}") @@ -469,7 +709,11 @@ def safe(text: str) -> str: port = pick_free_port() password = secrets.token_urlsafe(16) - print(f"\n=== M9 PROOF: capture-A → trigger → capture-B (reused M6 CEF core) ===") + if args.broadcast: + print("\n=== M9 PROOF (LIVE): broadcast → capture-A → trigger@mid → " + "capture-B → stop (reused M6 CEF core) ===") + else: + print(f"\n=== M9 PROOF: capture-A → trigger → capture-B (reused M6 CEF core) ===") print(f"spawning: {exe}") print(f" PULSAR_PORT={port} PULSAR_PASSWORD=") diff --git a/scripts/run-m9.ps1 b/scripts/run-m9.ps1 index 370ef82..51fa81e 100644 --- a/scripts/run-m9.ps1 +++ b/scripts/run-m9.ps1 @@ -19,13 +19,18 @@ # Usage (from the repo root): # $env:M8_OPERATOR_TOKEN = "..." # etage-1 admin short-TTL JWT # $env:M8_GATEWAY_URL = "http://127.0.0.1:8099" -# pwsh scripts/run-m9.ps1 # prove the repaint (no Twitch) -# $env:TWITCH_STREAM_KEY = "..." # etage-1 (broadcast leg only) -# pwsh scripts/run-m9.ps1 -Broadcast # + 30s broadcast +# $env:TWITCH_STREAM_KEY = "..." # etage-1 (required for the live run) +# pwsh scripts/run-m9.ps1 # LIVE: broadcast + trigger mid-stream (on air) +# pwsh scripts/run-m9.ps1 -NoBroadcast # prove the repaint without going live (no Twitch) +# +# DEFAULT = live, on air: the broadcast opens on the green A frame and the Blue +# /trigger fires at duration/2 so the green->magenta swap is visible on the +# stream + in the VOD (porteur preference). -NoBroadcast keeps the old +# prove-only mode for a CI run with no Twitch key. [CmdletBinding()] param( - [switch] $Broadcast, + [switch] $NoBroadcast, [string] $GatewayUrl = $env:M8_GATEWAY_URL, [string] $ShowStreamPath = "stream.lsdp", [string] $SolarVersion = "0.2.0", @@ -42,8 +47,9 @@ $vodDir = Join-Path $repoRoot "build\m9-canvas-vod" New-Item -ItemType Directory -Force -Path (Join-Path $repoRoot "build") | Out-Null # --- Build the probe argument list --------------------------------------- +$Broadcast = -not $NoBroadcast $probeArgs = @("scripts/probe-m9-canvas-live.py") -if ($Broadcast) { $probeArgs += "--broadcast" } +if ($NoBroadcast) { $probeArgs += "--no-broadcast" } if ($GatewayUrl) { $probeArgs += @("--gateway-url", $GatewayUrl) } $probeArgs += @("--show-stream-path", $ShowStreamPath) $probeArgs += @("--solar-version", $SolarVersion) @@ -53,7 +59,7 @@ if (-not $env:M8_OPERATOR_TOKEN) { Write-Error "M8_OPERATOR_TOKEN is not set (etage-1 admin short-TTL JWT; drives SETUP + /trigger). Refusing to run." } if ($Broadcast -and -not $env:TWITCH_STREAM_KEY) { - Write-Error "TWITCH_STREAM_KEY is not set (etage-1) and -Broadcast requested. Refusing to run." + Write-Error "TWITCH_STREAM_KEY is not set (etage-1) and a LIVE run was requested (default). Refusing to run. Pass -NoBroadcast to prove the repaint without going live." } # --- Run the probe, tee stdout ------------------------------------------- From de065eedd6545c417e72cd92c9dc66c64431b7d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ClodoCap=C3=A9o?= <159788250+ClodoCapeo@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:27:56 +0200 Subject: [PATCH 5/5] chore(m9): add UTF-8 BOM to run-m9.ps1 wrapper Carry the M9 wave debt (BOM prefix) on its owning branch so it survives the ADR-003 land. Unrelated to ADR 003; belongs to the M9 lot. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/run-m9.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/run-m9.ps1 b/scripts/run-m9.ps1 index 51fa81e..3144c7f 100644 --- a/scripts/run-m9.ps1 +++ b/scripts/run-m9.ps1 @@ -1,4 +1,4 @@ -# run-m9.ps1 - wrapper for the M9 Blue-trigger live-repaint probe with a +# run-m9.ps1 - wrapper for the M9 Blue-trigger live-repaint probe with a # hard secret grep-assert (ADR Blue 001 R4/R6, M8 parity). # # It runs scripts/probe-m9-canvas-live.py, tees stdout to a log, then