From e081c38e6a0f15f88bf402c4b5f3cb5870ff4302 Mon Sep 17 00:00:00 2001 From: vdmo Date: Thu, 1 Oct 2026 20:07:19 +1000 Subject: [PATCH 1/3] fix(resolume-bridge): duplicate dispatch and wrong parameter index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real bugs, both found live while building demos/multi-tool-live-show — the first thing in this repo to send this bridge a real address in the exact format its own docstring documents. - handle_osc_message registered under two overlapping OSC patterns ("/composition/*/*/*" and "/composition/*/*/*/*"). A real 5-segment address matches both (confirmed directly via Dispatcher.handlers_for_address returning 2 handlers for one address), so every real control change was sent to the Conductor twice — two PCHI messages, two rule evaluations, two signed receipts per actual change. Only the one pattern this bridge documents is kept. - parameter was read from parts[4], the fixed "video" category segment every layer-video address shares, not parts[5], the actual parameter name. Every real parameter (opacity, strobe_intensity, whatever) silently collapsed onto target "video" — confirmed via the Conductor's own log showing resolume_layer_main.video being set instead of resolume_layer_main.strobe_intensity, so a rule gating on the real parameter name never fired regardless of the value sent. Verified live against a real running Conductor: one OSC send now produces exactly one "[OSC] ... -> PCHI sent" log line and one correctly- named control parameter, with a rule gating on that real parameter name now actually firing. Co-Authored-By: Claude Sonnet 5 --- .../resolume_pchi_bridge.py | 44 ++++++++++++++----- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/tools/resolume-pchi-bridge/resolume_pchi_bridge.py b/tools/resolume-pchi-bridge/resolume_pchi_bridge.py index 97fbaeb..4830f4a 100644 --- a/tools/resolume-pchi-bridge/resolume_pchi_bridge.py +++ b/tools/resolume-pchi-bridge/resolume_pchi_bridge.py @@ -140,17 +140,31 @@ def __init__( print(f" Sending PCHI to {pchi_conductor_host}:{pchi_conductor_port}") def handle_osc_message(self, address: str, *args): - """Handle incoming OSC message from Resolume""" + """Handle incoming OSC message from Resolume. + + Found live, by this repo's demos/multi-tool-live-show sending a + real address in the format this bridge's own docstring documents: + `/composition/layers/{layer_id}/video/{parameter}` + splits (on a leading '/') into + ['', 'composition', 'layers', '{layer_id}', 'video', '{parameter}'] + — six elements, with the real parameter at index 5. This used to + read `parts[4]` ("video", the fixed category segment every + video-layer address shares) as the parameter instead — every + real layer control silently collapsed onto target "video", + discarding which parameter (opacity, strobe_intensity, ...) had + actually changed. Confirmed via the Conductor's own log: + `resolume_layer_main.video` was being set, never + `resolume_layer_main.strobe_intensity`, so a rule gating on the + real parameter name never fired no matter the value sent. + """ try: - # Parse Resolume OSC address - # Format: /composition/layers/{layer_id}/video/{parameter} parts = address.split('/') - - if len(parts) < 4: + + if len(parts) < 6: return - - layer_id = parts[3] if len(parts) > 3 else "unknown" - parameter = parts[4] if len(parts) > 4 else "unknown" + + layer_id = parts[3] + parameter = parts[5] value = args[0] if args else 0.0 # Update state @@ -273,9 +287,19 @@ def start(self): print("Starting Resolume PCHI Bridge...") self.running = True - # Setup OSC dispatcher + # Setup OSC dispatcher. Only one mapping, not two: python-osc's + # `*` wildcard matching isn't strict about trailing segments, so + # a real 5-segment address like + # /composition/layers/main/video/strobe_intensity matched BOTH + # "/composition/*/*/*" and "/composition/*/*/*/*" (confirmed via + # Dispatcher.handlers_for_address returning 2 handlers for one + # address) — handle_osc_message ran twice per real OSC message, + # producing two PCHI messages, two rule evaluations, and two + # signed receipts for every single actual change. The one + # pattern this bridge's own docstring documents + # (/composition/layers/{layer_id}/video/{parameter}, six parts + # after split) is the only one kept. dispatcher = Dispatcher() - dispatcher.map("/composition/*/*/*", self.handle_osc_message) dispatcher.map("/composition/*/*/*/*", self.handle_osc_message) # Start heartbeat thread From ff38ef1bb8f99fd04503bee45c4cbd5e9b5554ec Mon Sep 17 00:00:00 2001 From: vdmo Date: Thu, 1 Oct 2026 20:07:27 +1000 Subject: [PATCH 2/3] feat(cables-relay): tolerate a loaded peer, add a /status check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two robustness additions, both motivated by real, reproducible failures seen while building demos/multi-tool-live-show on a machine under heavy unrelated CPU load: - Both of this relay's WebSocket legs (to the Conductor, and to a cables.gl patch) now use a 60s ping_timeout, up from the `websockets` library's 20s default. A busy peer — a loaded Conductor, a CPU-starved browser tab — can miss a pong well inside 20s without actually being dead; this was observed directly as repeated "1011 keepalive ping timeout" disconnects that went away once the unrelated load did. - GET /status now returns {"cables_clients_connected": N}, so anything scripting against this relay can check whether a governance event actually has anywhere to go before sending a control update and waiting for a verdict, instead of assuming a connection exists because a patch's URL field was set once. Co-Authored-By: Claude Sonnet 5 --- tools/cables-pchi-relay/README.md | 12 +++++++++ tools/cables-pchi-relay/cables_pchi_relay.py | 27 ++++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/tools/cables-pchi-relay/README.md b/tools/cables-pchi-relay/README.md index 3d9a6a5..9fd721e 100644 --- a/tools/cables-pchi-relay/README.md +++ b/tools/cables-pchi-relay/README.md @@ -74,6 +74,18 @@ constraint). `gate_state_code` (1.0/0.5/0.0 = ALLOW/ESCALATE/DENY) is included too, in case a numeric comparison is more convenient in your op graph than a string comparison. +**Checking whether a patch is actually connected:** + +`GET http://:9100/status` returns +`{"cables_clients_connected": N}`. Useful for anything scripting against +this relay (a demo, a test, a monitoring check) that needs to know +whether a governance event has anywhere to go before sending a control +update and waiting for a verdict — a cables op's WebSocket connection +can drop and not immediately reconnect (seen in practice right after +changing its URL field), and a control update sent while `N` is `0` +won't be missed by the Conductor, but its verdict has no live +destination until a client reconnects. + ## CORS A cables patch is served from a different origin than this relay, so diff --git a/tools/cables-pchi-relay/cables_pchi_relay.py b/tools/cables-pchi-relay/cables_pchi_relay.py index 144fcc1..a6f7824 100644 --- a/tools/cables-pchi-relay/cables_pchi_relay.py +++ b/tools/cables-pchi-relay/cables_pchi_relay.py @@ -157,7 +157,14 @@ async def watch_conductor_governance(self): target = f"ws://{self.pchi_host}:{self.pchi_ws_port}" while self.running: try: - async with websockets.connect(target) as ws: + # ping_timeout=60 (the `websockets` library default is 20): + # a busy peer — a loaded conductor, a CPU-starved browser + # tab on the cables side of this relay — can miss a pong + # well inside 20s without actually being dead. Seen in + # practice: real "1011 keepalive ping timeout" disconnects + # under heavy unrelated CPU load, not a sign either side + # had actually stopped responding. + async with websockets.connect(target, ping_timeout=60) as ws: print(f"[Conductor WS] connected to {target}") async for raw in ws: await self._handle_conductor_message(raw) @@ -213,7 +220,11 @@ def _run_asyncio_loop(self): asyncio.set_event_loop(self._loop) async def main(): - async with websockets.serve(self._cables_ws_handler, "0.0.0.0", self.ws_port): + # Same reasoning as the Conductor-facing client above: a + # cables.gl tab running in a CPU-starved browser can miss a + # pong well inside the library's 20s default without its + # connection actually being dead. + async with websockets.serve(self._cables_ws_handler, "0.0.0.0", self.ws_port, ping_timeout=60): print(f"cables-facing WebSocket server listening on port {self.ws_port}") await self.watch_conductor_governance() @@ -234,6 +245,18 @@ def do_OPTIONS(self): # CORS preflight — a JSON POST body triggers this in ev self._cors_headers() self.end_headers() + def do_GET(self): + if self.path != "/status": + self.send_response(404) + self._cors_headers() + self.end_headers() + return + self.send_response(200) + self._cors_headers() + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"cables_clients_connected": len(relay.cables_clients)}).encode()) + def do_POST(self): if self.path != "/pchi/control": self.send_response(404) From 4d3a9b6990476cdc0ed8d2ee185f0090ddbf9798 Mon Sep 17 00:00:00 2001 From: vdmo Date: Thu, 1 Oct 2026 20:13:03 +1000 Subject: [PATCH 3/3] =?UTF-8?q?feat(demos):=20multi-tool=20live=20show=20?= =?UTF-8?q?=E2=80=94=20one=20rule,=20three=20tools'=20real=20bridges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demonstrates the actual "many tools, one truth" claim: a control update sent through any one tool's real, unmodified bridge is validated and governed once by a real Conductor, and every other connected tool sees the same verdict live — not three separate integrations that happen to agree by convention. One real Conductor, one Only-Lang rule file, three tools' real bridge code from tools/ (resolume-pchi-bridge, tixl-pchi-bridge, cables-pchi-relay — none modified for this). TiXL and Resolume Arena are GUI apps not installed in this environment, so their editors are stood in for by scripts sending the exact real wire shapes those tools' own operators send; the bridges receiving them can't tell the difference. See README.md for the precise, honest accounting of what's genuinely live here versus simulated. Two phases: TiXL raises a shared object to a safe value (every tool sees a signed ALLOW), then Resolume — a different tool's bridge — pushes the same object past the rule's threshold (every tool sees the same signed DENY, proving propagation doesn't depend on which tool caused the update). Building this surfaced two real, previously-undiscovered bugs, now fixed in separate commits on this branch: resolume-pchi-bridge was double-dispatching every message and reading the wrong address segment as the parameter name (silently collapsing every real parameter onto "video"); cables-pchi-relay's WebSocket legs used a 20s keepalive timeout too tight for a loaded peer. Verified live, repeatedly: the TiXL and Resolume legs and the signed governance chain are solid and reproducible — confirmed via listen_as_tixl.py (a real OSC listener, independent of cables.gl) receiving live ALLOW/DENY verdicts regardless of which tool triggered them, and scripts/verify_governance_chain.py confirming every receipt. The cables.gl leg — a real headless Chromium against the real public cables.gl site — is demonstrably sensitive to heavy host CPU load (reproduced across 8 full runs on a machine under ~550% unrelated contention); run_demo.sh does not let that leg's failure block the rest of the demo, and README.md documents this honestly rather than papering over it. The same mechanism was independently proven working earlier in this project's history under lighter load. Co-Authored-By: Claude Sonnet 5 --- demos/multi-tool-live-show/.gitignore | 2 + demos/multi-tool-live-show/README.md | 125 +++++++ demos/multi-tool-live-show/cables_leg.js | 192 ++++++++++ demos/multi-tool-live-show/listen_as_tixl.py | 39 +++ demos/multi-tool-live-show/package-lock.json | 328 ++++++++++++++++++ demos/multi-tool-live-show/package.json | 8 + demos/multi-tool-live-show/run_demo.sh | 127 +++++++ .../multi-tool-live-show/send_as_resolume.py | 33 ++ demos/multi-tool-live-show/send_as_tixl.py | 32 ++ .../multi-tool-live-show/show-rules.only-pchi | 19 + 10 files changed, 905 insertions(+) create mode 100644 demos/multi-tool-live-show/.gitignore create mode 100644 demos/multi-tool-live-show/README.md create mode 100755 demos/multi-tool-live-show/cables_leg.js create mode 100755 demos/multi-tool-live-show/listen_as_tixl.py create mode 100644 demos/multi-tool-live-show/package-lock.json create mode 100644 demos/multi-tool-live-show/package.json create mode 100755 demos/multi-tool-live-show/run_demo.sh create mode 100755 demos/multi-tool-live-show/send_as_resolume.py create mode 100755 demos/multi-tool-live-show/send_as_tixl.py create mode 100644 demos/multi-tool-live-show/show-rules.only-pchi diff --git a/demos/multi-tool-live-show/.gitignore b/demos/multi-tool-live-show/.gitignore new file mode 100644 index 0000000..10e73b8 --- /dev/null +++ b/demos/multi-tool-live-show/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +cables-leg-result.png diff --git a/demos/multi-tool-live-show/README.md b/demos/multi-tool-live-show/README.md new file mode 100644 index 0000000..ea66c50 --- /dev/null +++ b/demos/multi-tool-live-show/README.md @@ -0,0 +1,125 @@ +# Demo: Multi-Tool Live Show + +One real PCHI Conductor. One rule file. Three tools' real, unmodified +bridge/relay code from `tools/`. This demonstrates the actual claim +behind "many tools, one truth": a control update sent through *any one* +tool's bridge is validated and governed once, and every other connected +tool sees the same verdict live — not three separate integrations that +happen to agree by convention. + +## What's genuinely live here, and what's simulated + +Be precise about this, because it matters: + +- **Genuinely live**: the PCHI Conductor (the real `primeswarm-pchi` + binary), the Only-Lang rule engine, the signed/hash-chained governance + log, every bridge's real code (`tixl-pchi-bridge`, + `resolume-pchi-bridge`, `cables-pchi-relay`, unmodified), and the + cables.gl leg — a real headless Chromium driving the actual, live, + public `cables.gl` editor and its real `WebSocket_v2` op. +- **Simulated**: TiXL and Resolume Arena are both GUI applications (TiXL + is Windows/.NET, Resolume is a commercial Windows/Mac app) not + installed in this environment. `send_as_tixl.py` and + `send_as_resolume.py` stand in for their editors — but they send the + *exact real wire shapes* those tools' own OSC operators send + (confirmed from TiXL's actual source for one, from the real bugs + fixed against Resolume's actual format in this repo's PR #3 for the + other), to the real, unmodified bridges. The bridges themselves + cannot tell the difference between these scripts and the real editors. +- **Resolume's bridge is one-directional.** Unlike the TiXL bridge and + the cables.gl relay, `resolume-pchi-bridge` has no governance-receive + leg — it only sends control updates to the Conductor, it doesn't + listen for verdicts coming back. That's a real, current limitation of + that bridge (see its source), not something this demo works around, + which is why there's a `listen_as_tixl.py` but no + `listen_as_resolume.py`. + +## Why every tool converges on `resolume_layer_main.strobe_intensity` + +In a real show, each tool addresses things its own way — this is the +whole reason a shared Conductor matters. For this demo, the OSC address +sent "as Resolume" (`/composition/layers/main/video/strobe_intensity`) +happens to produce that exact key because `resolume-pchi-bridge` always +derives `target_id` as `resolume_layer_{layer_id}` — real, unmodified +behavior, not special-cased for this demo. TiXL's bridge has no fixed +naming convention at all (its `Strings` input is a free-form +`"target_id.parameter"` string), so `send_as_tixl.py` simply uses the +same key on purpose. This mirrors exactly what an operator setting up a +real show has to do: agree on shared names across tools, the same way +you'd agree on OSC addresses today — PCHI doesn't invent that agreement +for you, it gives every tool a single, verified place to enforce rules +once that agreement exists. + +## Running it + +Requirements: Rust toolchain, Python 3 with `python-osc`, `numpy`, and +`websockets` installed (`pip install python-osc numpy websockets`, +already needed by the individual bridges this reuses), Node.js, and +`/snap/bin/chromium` (or pass `--chromium-path` to `cables_leg.js`). + +```bash +npm install # puppeteer-core, for the cables.gl leg +./run_demo.sh +``` + +It builds the Conductor, starts all four real processes, waits for +startup, then runs two phases: + +1. **Safe value.** TiXL raises `resolume_layer_main.strobe_intensity` to + `0.3`. The rule's `evolve` action fires (every update gets a visible + ALLOW acknowledgement — see `show-rules.only-pchi`), and a live + cables.gl browser session — pointed at this demo's + `cables-pchi-relay`, not at TiXL — shows it arriving in real time. +2. **Violation.** Resolume — a *different* tool's bridge — pushes the + same object to `0.95`, past the rule's `0.8` threshold. The Conductor + denies it, signs a receipt, and broadcasts the denial. The same + cables.gl session sees the same verdict live, proving propagation + doesn't depend on which tool caused the update. + +You'll see **two** governance events for Phase B, not one: the routine +`evolve` acknowledgement every update gets, immediately followed by the +`DENY` the threshold rule adds. That's the real rule-evaluation loop +running every rule in the file in order, not a bug. + +### The cables.gl leg's environment sensitivity + +Of everything in this demo, the live cables.gl browser leg is the one +genuinely sensitive to the host machine's load. It's a real headless +Chromium rendering the real public cables.gl editor (WebGL, a live +WebSocket) — under heavy *unrelated* CPU contention on the host, the +browser tab can be starved enough that its WebSocket connection to this +demo's relay drops and doesn't reconnect in time for the verdict to +arrive before `run_demo.sh` checks. This was observed directly while +building this demo: three unrelated background processes consuming +500%+ combined CPU on the same machine caused repeated, reproducible +connection drops that disappeared once that load wasn't present. Both +of this relay's WebSocket legs now use a 60s ping timeout (up from the +`websockets` library's 20s default) specifically because of this, and +`cables_leg.js` checks the relay's real `/status` endpoint and +re-triggers the op's connection before giving up — but on a sufficiently +loaded machine, this leg can still fail where the rest of the demo +doesn't. + +That's why `run_demo.sh` does **not** let a Phase A/B cables.gl failure +block the rest of the run: the Conductor, the rule engine, the signed +governance chain, and the TiXL governance listener are verified +independently of it, from the real Conductor log and the exported, +independently-verified receipt chain, every run, regardless of what the +browser leg managed to observe. A "FAIL" in the summary's Phase A/B +lines means specifically that the browser didn't see the verdict in +time this run — not that the update wasn't validated, governed, and +signed, which the Conductor log and the governance export right above +the summary show happened regardless. + +At the end, the script exports the full signed governance log and runs +`scripts/verify_governance_chain.py` against it — the same independent +check an auditor would run, over everything this run actually did. + +## What this doesn't claim + +This isn't a benchmark or a production deployment guide. The 2-second +sleeps between starting each process are generous, not tuned; the demo +rule file is deliberately small. What it does prove, concretely: a real +rule, written once, enforced identically regardless of which of three +different tools' bridges a message arrives through, with every decision +independently verifiable afterward. diff --git a/demos/multi-tool-live-show/cables_leg.js b/demos/multi-tool-live-show/cables_leg.js new file mode 100755 index 0000000..c99dbbf --- /dev/null +++ b/demos/multi-tool-live-show/cables_leg.js @@ -0,0 +1,192 @@ +#!/usr/bin/env node +/** + * The cables.gl leg of the multi-tool live show demo. + * + * Drives the real, live, publicly-hosted cables.gl editor via headless + * Chromium — not a mock — pointing its real WebSocket_v2 op at this + * demo's running cables-pchi-relay, then runs a caller-supplied trigger + * command (sending the actual control update through a *different* + * tool's bridge) and confirms the verdict arrives in the op's own + * parameter panel. This is the "one truth" proof: the update doesn't + * come from cables.gl's own HTTP endpoint, so receiving it live proves + * propagation is tool-agnostic, not just a same-tool echo. + * + * Getting a public-origin page to reach a Conductor on localhost means + * working through a real Chrome security boundary (Local Network + * Access) — see the --disable-features flags below, and + * tools/cables-pchi-relay/README.md's "Verification status" section + * for how this was discovered. + */ +const puppeteer = require('puppeteer-core'); +const { execSync } = require('child_process'); +const http = require('http'); + +function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } + +function arg(name, fallback) { + const i = process.argv.indexOf(`--${name}`); + return i !== -1 ? process.argv[i + 1] : fallback; +} + +async function getCablesFrame(page) { + return page.frames().find(f => f.url().includes('sandbox.cables.gl')); +} + +// Asks the relay itself whether a cables client is actually connected +// right now — ground truth, not an assumption from "we set the URL +// field once a while ago." The op's connection can drop and not +// reconnect on its own (seen in practice right after a URL change). +function relayClientsConnected(httpPort) { + return new Promise((resolve) => { + const req = http.get({ hostname: '127.0.0.1', port: httpPort, path: '/status', timeout: 3000 }, res => { + let data = ''; + res.on('data', c => data += c); + res.on('end', () => { + try { resolve(JSON.parse(data).cables_clients_connected); } + catch { resolve(0); } + }); + }); + req.on('error', () => resolve(0)); + req.on('timeout', () => { req.destroy(); resolve(0); }); + }); +} + +async function main() { + const relayWsPort = arg('relay-ws-port', '9101'); + const relayHttpPort = arg('relay-http-port', '9100'); + const triggerCmd = arg('trigger-cmd', null); + const chromiumPath = arg('chromium-path', '/snap/bin/chromium'); + const screenshotPath = arg('screenshot', 'cables-leg-result.png'); + + const browser = await puppeteer.launch({ + executablePath: chromiumPath, + headless: true, + args: [ + '--no-sandbox', '--disable-setuid-sandbox', '--enable-unsafe-swiftshader', + '--disable-features=LocalNetworkAccessChecks,LocalNetworkAccessChecksWebSockets', + ], + defaultViewport: { width: 1700, height: 1000 }, + protocolTimeout: 180000, + }); + const page = await browser.newPage(); + + let sawFirstReceive = false; + page.on('console', msg => { + const t = msg.text(); + if (/GL Driver|WebGL-0x/.test(t)) return; + if (t.includes('WebSocket_v2')) sawFirstReceive = true; + }); + + console.log('[cables leg] loading the live cables.gl WebSocket example patch...'); + await page.goto('https://cables.gl/edit/gu7DBo', { waitUntil: 'networkidle2', timeout: 30000 }).catch(() => {}); + + // 120s, not 60s: this environment has seen real, unrelated CPU + // contention (other processes at 200%+) stretch a normally-fast page + // load well past a minute. Generous on purpose, not a sign anything's + // actually slow about the patch itself. + const deadline = Date.now() + 120000; + while (!sawFirstReceive && Date.now() < deadline) await sleep(500); + console.log('[cables leg] patch is live:', sawFirstReceive); + if (!sawFirstReceive) { + console.log('[cables leg] WARNING: readiness signal never seen — proceeding anyway, but node selection may fail.'); + } + + // Pan-independent node selection: fixed canvas coordinates aren't + // reliable across page loads, search->click->Enter is. + await page.mouse.click(726, 559).catch(() => {}); // close tutorial overlay if present + await sleep(1500); + await page.mouse.move(500, 500); + await page.keyboard.down('Control'); await page.keyboard.press('KeyF'); await page.keyboard.up('Control'); + await sleep(1500); + await page.keyboard.type('WebSocket', { delay: 80 }); + await sleep(1500); + await page.mouse.click(175, 300); + await sleep(800); + await page.keyboard.press('Enter'); + await sleep(2000); + + const frame = await getCablesFrame(page); + const relayUrl = `ws://127.0.0.1:${relayWsPort}`; + + async function pointOpAtRelay() { + return frame.evaluate((newUrl) => { + const el = Array.from(document.querySelectorAll('input[class*="watchPortValue"]'))[0]; + if (!el) return null; + el.focus(); + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + setter.call(el, newUrl); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', bubbles: true })); + el.blur(); + return el.value; + }, relayUrl); + } + + const setUrl = await pointOpAtRelay(); + if (setUrl !== relayUrl) { + console.log('[cables leg] FAILED: could not point the real op at the relay.'); + await page.screenshot({ path: screenshotPath }); + await browser.close(); + process.exit(1); + } + console.log('[cables leg] real WebSocket_v2 op now connected to', relayUrl); + + // Don't trust that setting the URL means a connection exists by the + // time we trigger — ask the relay. The op's connection has been + // observed to drop shortly after a URL change and not reconnect on + // its own; if that's happened, re-apply the URL to force a fresh + // connection attempt rather than sending a trigger into a dead socket. + let connected = 0; + for (let i = 0; i < 10 && connected === 0; i++) { + await sleep(1000); + connected = await relayClientsConnected(relayHttpPort); + } + console.log('[cables leg] relay reports', connected, 'cables client(s) connected'); + + let result = { allFields: [], rawData: null }; + const maxAttempts = 3; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + if (connected === 0) { + console.log('[cables leg] relay sees no connected client — re-pointing the op to force reconnect'); + await pointOpAtRelay(); + for (let i = 0; i < 10 && connected === 0; i++) { + await sleep(1000); + connected = await relayClientsConnected(relayHttpPort); + } + console.log('[cables leg] relay now reports', connected, 'cables client(s) connected'); + } + + if (triggerCmd) { + console.log(`[cables leg] attempt ${attempt}/${maxAttempts}: triggering via a DIFFERENT tool's bridge:`, triggerCmd); + console.log(execSync(triggerCmd).toString().trim()); + } + await sleep(5000); + result = await frame.evaluate(() => { + const fields = Array.from(document.querySelectorAll('input[type="text"], textarea')) + .map(el => ({ cls: el.className, value: el.value })) + .filter(x => x.value && x.value.length > 0); + const raw = fields.find(f => f.value.includes('gate_state')); + return { allFields: fields, rawData: raw ? raw.value : null }; + }); + if (result.rawData) break; + console.log(`[cables leg] attempt ${attempt} saw nothing yet, retrying...`); + connected = await relayClientsConnected(relayHttpPort); + } + + await page.screenshot({ path: screenshotPath }); + await browser.close(); + + console.log('[cables leg] RESULT_JSON_START'); + console.log(JSON.stringify(result, null, 1)); + console.log('[cables leg] RESULT_JSON_END'); + + if (!result.rawData) { + console.log('[cables leg] FAILED: no gate_state payload observed in the op\'s Raw Data output.'); + process.exit(1); + } + console.log('[cables leg] SUCCESS: the real cables.gl op received', result.rawData); +} + +main().catch(e => { console.error('[cables leg] FAILED:', e.message); process.exit(1); }); diff --git a/demos/multi-tool-live-show/listen_as_tixl.py b/demos/multi-tool-live-show/listen_as_tixl.py new file mode 100755 index 0000000..9e1928f --- /dev/null +++ b/demos/multi-tool-live-show/listen_as_tixl.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Simulates TiXL's OscInput operator receiving live governance verdicts. + +Listens on the real tixl-pchi-bridge's --osc-out-port for the real +/pchi/governance/gate_state float it emits (1.0/0.5/0.0 = +ALLOW/ESCALATE/DENY, confirmed float-only from OscInput's real source — +see tixl-pchi-bridge's own docstring) and prints each one as it arrives, +standing in for TiXL's own OscInput operator since TiXL's GUI editor +isn't installed in this environment. +""" +import argparse +import time +from pythonosc.dispatcher import Dispatcher +from pythonosc.osc_server import BlockingOSCUDPServer + +GATE_NAMES = {1.0: "ALLOW", 0.5: "ESCALATE", 0.0: "DENY"} + + +def handle_gate_state(address, *args): + code = args[0] + name = GATE_NAMES.get(code, f"unknown({code})") + print(f"[as-TiXL OscInput] {time.strftime('%H:%M:%S')} {address} = {code} ({name})") + + +def main(): + parser = argparse.ArgumentParser(description="Listen for governance verdicts as if from TiXL's OscInput") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=9002, help="tixl-pchi-bridge --osc-out-port") + args = parser.parse_args() + + dispatcher = Dispatcher() + dispatcher.map("/pchi/governance/gate_state", handle_gate_state) + server = BlockingOSCUDPServer((args.host, args.port), dispatcher) + print(f"[as-TiXL OscInput] listening on {args.host}:{args.port}") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/demos/multi-tool-live-show/package-lock.json b/demos/multi-tool-live-show/package-lock.json new file mode 100644 index 0000000..4f50a82 --- /dev/null +++ b/demos/multi-tool-live-show/package-lock.json @@ -0,0 +1,328 @@ +{ + "name": "pchi-multi-tool-live-show-demo", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pchi-multi-tool-live-show-demo", + "dependencies": { + "puppeteer-core": "^25.9.0" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.3.tgz", + "integrity": "sha512-2Bt3m6dDAJqmZehn0wiXSYyQmuhyiHHZ7FjvrVaS1W2RoFGV6eCK71UvKYkNJjkwC1Q9nobjhvTMhLQztFVINA==", + "license": "Apache-2.0", + "dependencies": { + "modern-tar": "^0.8.4", + "yargs": "^18.0.0" + }, + "bin": { + "browsers": "lib/main-cli.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1", + "yauzl": "^2.10.0 || ^3.4.0" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + }, + "yauzl": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chromium-bidi": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz", + "integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1687809", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1687809.tgz", + "integrity": "sha512-t7kSb+UKYdyxcmqy98U2P86ne6rCve3DIJ4+lp54i/hhClgFuG35JrLj23rO14Jw3sWDsp7i+22QhNU0+/qk8g==", + "license": "BSD-3-Clause" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/modern-tar": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.5.tgz", + "integrity": "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/puppeteer-core": { + "version": "25.12.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.12.0.tgz", + "integrity": "sha512-z6LQUt5SH7jwGtGA+nMtJuEjZIsqPiwQ+EFHCYuVDQrJpvifNmmGuyDvTzAKPyFzihxgHc5f3aMzu2kn8Ps37A==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.2.3", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1687809", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.3", + "ws": "^8.21.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/string-width": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/typed-query-selector": { + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.3.tgz", + "integrity": "sha512-/dtuMZyutHLlrxWhhC1uib00ELUr2dZ8C9B8YzZYsJmeE3V56OxRblrSM2UaF142pcMtak7ehZ6+poWGeehNIg==", + "license": "MIT" + }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz", + "integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==", + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz", + "integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/demos/multi-tool-live-show/package.json b/demos/multi-tool-live-show/package.json new file mode 100644 index 0000000..c5a7d1d --- /dev/null +++ b/demos/multi-tool-live-show/package.json @@ -0,0 +1,8 @@ +{ + "name": "pchi-multi-tool-live-show-demo", + "private": true, + "description": "Browser automation for the cables.gl leg of the multi-tool live show demo", + "dependencies": { + "puppeteer-core": "^25.9.0" + } +} diff --git a/demos/multi-tool-live-show/run_demo.sh b/demos/multi-tool-live-show/run_demo.sh new file mode 100755 index 0000000..61ccbc1 --- /dev/null +++ b/demos/multi-tool-live-show/run_demo.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Multi-Tool Live Show demo — orchestration. +# +# One real PCHI Conductor, three real bridge/relay processes (no code +# changed from what ships in tools/), one rule file. Two phases: a safe +# value change and a rule violation, each triggered by a DIFFERENT +# tool's bridge while a real, live cables.gl browser session watches — +# proving propagation is tool-agnostic, not a same-tool echo. +# +# See README.md for exactly what's genuinely live here (the Conductor, +# the governance chain, every bridge's real code, the cables.gl leg) +# versus simulated (TiXL's and Resolume's own GUI editors, which aren't +# installed in this environment — their real wire formats are sent by +# plain scripts standing in for them). +set -euo pipefail + +DEMO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$DEMO_DIR/../.." && pwd)" +SCRATCH="$(mktemp -d)" +LOG_DIR="$SCRATCH/logs" +mkdir -p "$LOG_DIR" + +echo "Scratch dir (governance files, screenshots, logs): $SCRATCH" + +PIDS=() +cleanup() { + echo + echo "--- shutting down demo processes ---" + for pid in "${PIDS[@]:-}"; do + kill "$pid" 2>/dev/null || true + done + wait 2>/dev/null || true +} +trap cleanup EXIT + +echo "--- building the conductor (cargo build -p primeswarm-pchi) ---" +(cd "$REPO_ROOT" && cargo build -p primeswarm-pchi --quiet) + +echo "--- starting the Conductor: real binary, demo rule file ---" +(cd "$SCRATCH" && exec "$REPO_ROOT/target/debug/primeswarm-pchi" --rules "$DEMO_DIR/show-rules.only-pchi") \ + > "$LOG_DIR/conductor.log" 2>&1 & +PIDS+=($!) +sleep 2 + +echo "--- starting the real, unmodified resolume-pchi-bridge ---" +python3 -u "$REPO_ROOT/tools/resolume-pchi-bridge/resolume_pchi_bridge.py" \ + > "$LOG_DIR/resolume-bridge.log" 2>&1 & +PIDS+=($!) + +echo "--- starting the real, unmodified tixl-pchi-bridge ---" +python3 -u "$REPO_ROOT/tools/tixl-pchi-bridge/tixl_pchi_bridge.py" \ + > "$LOG_DIR/tixl-bridge.log" 2>&1 & +PIDS+=($!) + +echo "--- starting the real, unmodified cables-pchi-relay ---" +python3 -u "$REPO_ROOT/tools/cables-pchi-relay/cables_pchi_relay.py" \ + > "$LOG_DIR/cables-relay.log" 2>&1 & +PIDS+=($!) + +sleep 2 + +echo "--- starting the TiXL governance listener (stands in for OscInput) ---" +python3 -u "$DEMO_DIR/listen_as_tixl.py" > "$LOG_DIR/tixl-listener.log" 2>&1 & +PIDS+=($!) + +sleep 2 +echo "All processes up. Logs: $LOG_DIR" + +echo +echo "=== Phase A: TiXL raises resolume_layer_main.strobe_intensity to a SAFE level (0.3) ===" +echo " A live cables.gl browser session, pointed at this demo's relay, watches it arrive." +echo " (This leg — a real headless browser against the real public cables.gl site — is the" +echo " most environment-sensitive part of this demo. It does not block Phases B/C below if" +echo " it has a bad run; see README.md's note on this.)" +set +e +node "$DEMO_DIR/cables_leg.js" \ + --relay-ws-port 9101 \ + --trigger-cmd "python3 $DEMO_DIR/send_as_tixl.py resolume_layer_main strobe_intensity 0.3" \ + --screenshot "$SCRATCH/phase-a-safe.png" \ + | tee "$LOG_DIR/phase-a-cables.log" +PHASE_A_STATUS=${PIPESTATUS[0]} +set -e + +echo +echo "=== Phase B: Resolume pushes the SAME object past the limit (0.95) ===" +echo " A different tool's bridge triggers it this time — cables.gl still sees the denial live." +set +e +node "$DEMO_DIR/cables_leg.js" \ + --relay-ws-port 9101 \ + --trigger-cmd "python3 $DEMO_DIR/send_as_resolume.py strobe_intensity 0.95" \ + --screenshot "$SCRATCH/phase-b-deny.png" \ + | tee "$LOG_DIR/phase-b-cables.log" +PHASE_B_STATUS=${PIPESTATUS[0]} +set -e + +echo +echo "=== What TiXL's own OscInput (listen_as_tixl.py) saw, independently of cables.gl ===" +cat "$LOG_DIR/tixl-listener.log" + +echo +echo "=== Signed, hash-chained governance receipts — exported, not just asserted ===" +curl -s http://127.0.0.1:3000/governance/export > "$SCRATCH/export.json" +python3 -m json.tool < "$SCRATCH/export.json" + +echo +echo "=== Independent verification of that export (no trust in this conductor's dashboard) ===" +python3 "$REPO_ROOT/scripts/verify_governance_chain.py" "$SCRATCH/export.json" + +echo +echo "=== Summary ===" +status_word() { [ "$1" -eq 0 ] && echo "PASS" || echo "FAIL"; } +echo " Phase A (TiXL -> cables.gl, safe value): $(status_word "$PHASE_A_STATUS")" +echo " Phase B (Resolume -> cables.gl, violation): $(status_word "$PHASE_B_STATUS")" +echo " TiXL governance listener, Conductor, signed receipt chain: see output above (not gated by cables.gl)" +if [ "$PHASE_A_STATUS" -ne 0 ] || [ "$PHASE_B_STATUS" -ne 0 ]; then + echo + echo " A FAIL above means the live cables.gl browser leg specifically didn't observe the" + echo " verdict this run — see README.md's note on this leg's environment sensitivity. It" + echo " does not mean the Conductor, the rule, or the signed receipt chain failed: check the" + echo " 'Set control parameter' / 'ALLOW'/'DENY' lines above from the real Conductor log —" + echo " those happen over real UDP from this run's real bridge processes regardless of" + echo " whether the browser leg kept up." +fi + +echo +echo "Screenshots: $SCRATCH/phase-a-safe.png, $SCRATCH/phase-b-deny.png" +echo "Full process logs: $LOG_DIR" diff --git a/demos/multi-tool-live-show/send_as_resolume.py b/demos/multi-tool-live-show/send_as_resolume.py new file mode 100755 index 0000000..663d6b2 --- /dev/null +++ b/demos/multi-tool-live-show/send_as_resolume.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Simulates Resolume Arena sending its real composition OSC. + +Sends the real OSC address shape Resolume Arena itself sends +(/composition/layers/{layer_id}/video/{parameter}) to the real, +unmodified resolume-pchi-bridge. Resolume Arena is a commercial +Windows/Mac GUI app not installed in this environment, so this script +stands in for it. Layer id "main" is chosen deliberately — see this +demo's README for why, and note resolume-pchi-bridge is control-only: +it has no governance-receive leg to listen on the way the TiXL bridge +and cables.gl relay do, so there's no "listen_as_resolume.py" here. +""" +import argparse +from pythonosc import udp_client + + +def main(): + parser = argparse.ArgumentParser(description="Send a control update as if from Resolume Arena") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=7000, help="resolume-pchi-bridge --resolume-port") + parser.add_argument("--layer", default="main") + parser.add_argument("parameter") + parser.add_argument("value", type=float) + args = parser.parse_args() + + client = udp_client.SimpleUDPClient(args.host, args.port) + address = f"/composition/layers/{args.layer}/video/{args.parameter}" + client.send_message(address, args.value) + print(f"[as-Resolume] {address} {args.value} -> {args.host}:{args.port}") + + +if __name__ == "__main__": + main() diff --git a/demos/multi-tool-live-show/send_as_tixl.py b/demos/multi-tool-live-show/send_as_tixl.py new file mode 100755 index 0000000..603b7cd --- /dev/null +++ b/demos/multi-tool-live-show/send_as_tixl.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Simulates TiXL's OscOutput operator sending a control update. + +Not a fake protocol — this sends the exact real OSC shape TiXL's +OscOutput would (fixed address /pchi/control, Values=[float], +Strings=["target_id.parameter"]), confirmed from TiXL's actual source +(Operators/Io/Symbols/lib/io/osc/OscOutput.cs). TiXL itself is a +Windows/.NET GUI editor not installed in this environment, so this +script stands in for it; the bridge receiving this message is the real, +unmodified tixl-pchi-bridge. +""" +import argparse +from pythonosc import udp_client + + +def main(): + parser = argparse.ArgumentParser(description="Send a control update as if from TiXL's OscOutput") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=9001, help="tixl-pchi-bridge --osc-in-port") + parser.add_argument("target_id") + parser.add_argument("parameter") + parser.add_argument("value", type=float) + args = parser.parse_args() + + client = udp_client.SimpleUDPClient(args.host, args.port) + key = f"{args.target_id}.{args.parameter}" + client.send_message("/pchi/control", [args.value, key]) + print(f"[as-TiXL] /pchi/control {args.value} \"{key}\" -> {args.host}:{args.port}") + + +if __name__ == "__main__": + main() diff --git a/demos/multi-tool-live-show/show-rules.only-pchi b/demos/multi-tool-live-show/show-rules.only-pchi new file mode 100644 index 0000000..65bbc75 --- /dev/null +++ b/demos/multi-tool-live-show/show-rules.only-pchi @@ -0,0 +1,19 @@ +# Multi-Tool Live Show demo — Only-Lang rules +# +# Two rules, one shared object (resolume_layer_main.strobe_intensity), +# enforced identically no matter which tool's bridge the update arrived +# through. See demos/multi-tool-live-show/README.md for why every tool +# in this demo converges on this exact key. + +harmony(1e-12) + +# Fires on every update to the object this demo watches, regardless of +# value — gives Phase A (a safe value) a visible, signed ALLOW event to +# show propagating live, not just "nothing happened" silence. +if resolume_layer_main.strobe_intensity >= 0.0 then + evolve(resolume_layer_main, strobe_intensity) +end + +if resolume_layer_main.strobe_intensity > 0.8 then + deny("Strobe intensity exceeds venue safety limit") +end