Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions demos/multi-tool-live-show/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
cables-leg-result.png
125 changes: 125 additions & 0 deletions demos/multi-tool-live-show/README.md
Original file line number Diff line number Diff line change
@@ -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.
192 changes: 192 additions & 0 deletions demos/multi-tool-live-show/cables_leg.js
Original file line number Diff line number Diff line change
@@ -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); });
39 changes: 39 additions & 0 deletions demos/multi-tool-live-show/listen_as_tixl.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading