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 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) 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