openvibers/openvibe.live Commits

Arena on stream: a streamer runs a Bench round, chat votes, Live casts one audience_poll (#433)

OP OpenVibers committed 15 days ago · 68bd1b3ae6 · 19 files, +565 −10

Changes

ddiff --git a/.env.example b/.env.example
iindex dce9c356..2d7613a1 100644
---- a/.env.example
++++ b/.env.example
@@@ -195,3 +195,4 @@ OV_BILLING_PUBLIC_URL=https://billing.openvibe.network
# Live reads and writes them with its service token (audience openvibe.inventory) acting for the person.
#INVENTORY_URL=http://127.0.0.1:5030
# OV_BILLING_TIMEOUT_MS=8000
++OV_BENCH_INTERNAL_URL=http://127.0.0.1:5230
ddiff --git a/docs/bench-stream.md b/docs/bench-stream.md
nnew file mode 100644
iindex 00000000..015f2a04
---- /dev/null
++++ b/docs/bench-stream.md
@@@ -0,0 +1,11 @@
++# Bench on stream
++
++A streamer starts a round from the Dashboard while live. Live asks Bench to create a public task and round, then waits for two usable entries. The watch page shows both entries. HTML entries use an empty iframe sandbox with a CSP that blocks scripts and network requests, matching Bench's Design HTML rules.
++
++When the entries are ready, Live posts one chat line opening a 15–600 second vote. OpenVibe.Chat handles `!1`, `!2`, and `!tie` and forwards the signed-in viewer's Live user id and stream id through `POST /internal/chat-effects/bench-vote`. Live verifies the user's Network subject, excludes the streamer, and stores one vote per user per poll. A background job closes votes after the deadline and survives a restart because the poll, deadline, and votes are in Live's database.
++
++Live casts a single Bench service judgement with `judge: audience_poll`, the two entry ids, the plurality outcome, and `vote_counts: { left, right, tie, voters }`. The result is a tie when `!tie` leads or the lead is shared. Bench treats a poll with viewers as one Elo game regardless of voter count; a zero-voter poll is recorded without changing ratings. The counts remain on the judgement for audit and the open dataset. Live then posts the result in chat.
++
++Network must grant Live's service principal `bench.task.create`, `bench.round.create`, and `bench.judgement.cast` for audience `openvibe.bench`. Round reads are public. Set `OV_BENCH_INTERNAL_URL` to Bench's internal origin (default `http://127.0.0.1:5230`). Chat's existing `live.chat_effects.write` grant covers the vote effect.
++
++Live passes the streamer's verified Network subject on task and round creation, so the daily per-person quota belongs to the streamer. Bench accepts this ownership header only from `service:live`; the judgement always belongs to the Live service. A pending poll is abandoned if its stream ends before its entries arrive.
ddiff --git a/migrations/0022_bench_stream.sql b/migrations/0022_bench_stream.sql
nnew file mode 100644
iindex 00000000..e671fd7e
---- /dev/null
++++ b/migrations/0022_bench_stream.sql
@@@ -0,0 +1,24 @@
++-- phase: expand
++CREATE TABLE live_bench_polls (
++ id bigserial PRIMARY KEY,
++ streamer_id bigint NOT NULL REFERENCES users(id),
++ stream_id bigint NOT NULL REFERENCES streams(id),
++ round_id text NOT NULL UNIQUE,
++ kind_id text NOT NULL,
++ prompt text NOT NULL,
++ status text NOT NULL CHECK (status IN ('pending', 'open', 'closing', 'closed', 'failed')),
++ duration_seconds integer NOT NULL,
++ closes_at timestamptz,
++ entries jsonb,
++ vote_counts jsonb,
++ judgement_id text,
++ created_at timestamptz NOT NULL DEFAULT now()
++);
++CREATE INDEX live_bench_polls_by_streamer ON live_bench_polls (streamer_id, id DESC);
++CREATE UNIQUE INDEX live_bench_one_active_per_streamer ON live_bench_polls (streamer_id) WHERE status IN ('pending', 'open', 'closing');
++CREATE TABLE live_bench_votes (
++ poll_id bigint NOT NULL REFERENCES live_bench_polls(id) ON DELETE CASCADE,
++ user_id bigint NOT NULL REFERENCES users(id),
++ choice text NOT NULL CHECK (choice IN ('left', 'right', 'tie')),
++ PRIMARY KEY (poll_id, user_id)
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex 6acc09c0..8ba84092 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -24,7 +24,7 @@
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
@@@ -2660,9 +2660,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.67.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "integrity": "sha512-s7NzjaNcowVcOIiup2z0ze+xsq/qnr++3pHSI8JzirqJPQ5JsBSUF5RkjOHyfk9bRKlmhY0fsNS6x4dsYylyqw==",
++ "version": "0.71.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0",
++ "integrity": "sha512-5cGZrMtY6sUsYDiXTw40gn7Lu4LCJI0UvvcO7oWHPAQ+QNN9QeRd4LC8jT67nq2C7GsgS3KN0aDA+3tV2J4avw==",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 282328cd..469dbdb1 100644
---- a/package.json
++++ b/package.json
@@@ -43,7 +43,7 @@
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
ddiff --git a/public/features.json b/public/features.json
iindex 2a7015ce..3f92a382 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -114,7 +114,8 @@
"/js/channel-socials.js",
"/js/channel-chat-resize.js",
"/js/channel-support.js",
-- "/js/app-channel.js"
++ "/js/app-channel.js",
++ "/js/bench-stream-watch.js"
],
"stubs": [
"toggleChannelChat"
@@@ -240,7 +241,8 @@
"/js/settings.js",
"/js/dashboard-moderation.js",
"/js/powerchat.js",
-- "/js/dashboard.js"
++ "/js/dashboard.js",
++ "/js/bench-stream-dashboard.js"
],
"stubs": [
"regenerateDashStreamKey"
ddiff --git a/public/fragments/channel.html b/public/fragments/channel.html
iindex dea81121..1546db5f 100644
---- a/public/fragments/channel.html
++++ b/public/fragments/channel.html
@@@ -8,6 +8,7 @@
<!-- Live Stream Area (edge-to-edge) -->
<div class="ch-live-area" id="ch-live-area" style="display:none">
++ <section class="dash-card" id="ch-bench-stream" aria-label="Bench vote" hidden></section>
<div class="stream-layout">
<div class="stream-main">
<div class="video-container" id="video-container">
ddiff --git a/public/fragments/dashboard.html b/public/fragments/dashboard.html
iindex ed7a947a..d1a9fe0b 100644
---- a/public/fragments/dashboard.html
++++ b/public/fragments/dashboard.html
@@@ -14,7 +14,28 @@
<button class="ch-tab" role="tab" aria-selected="false" data-dtab="points" onclick="switchDashTab('points', this)"><i class="fa-solid fa-star"></i> Points</button>
</div>
-- <div class="ch-tab-panel dash-tab-panel active" id="dash-panel-home" role="tabpanel"><div class="dash-grid" id="dash-grid-home"></div></div>
++ <div class="ch-tab-panel dash-tab-panel active" id="dash-panel-home" role="tabpanel"><div class="dash-grid" id="dash-grid-home">
++ <section class="dash-card" id="dash-bench-stream" aria-labelledby="dash-bench-title">
++ <h3 id="dash-bench-title">Bench on stream</h3>
++ <form id="dash-bench-form">
++ <label for="dash-bench-kind">Kind</label>
++ <select class="form-input" id="dash-bench-kind" name="kind" required>
++ <option value="design.page">Design page</option><option value="design.component">Design component</option>
++ <option value="meme.caption">Meme caption</option><option value="chat.reply">Chat reply</option>
++ <option value="games.fun">Games fun</option><option value="vision.pair">Vision pair</option>
++ <option value="codes.review">Codes review</option><option value="surf.desk">Surf desk</option>
++ <option value="speech.tts">Speech TTS</option><option value="translate.ja">Translate Japanese</option>
++ <option value="search.answer">Search answer</option>
++ </select>
++ <label for="dash-bench-prompt">Prompt</label>
++ <textarea class="form-input" id="dash-bench-prompt" name="prompt" maxlength="4000" required></textarea>
++ <label for="dash-bench-duration">Vote time (seconds)</label>
++ <input class="form-input" id="dash-bench-duration" name="duration" type="number" min="15" max="600" value="60" required>
++ <button class="btn" type="submit">Start round</button>
++ </form>
++ <p id="dash-bench-status" role="status"></p>
++ </section>
++ </div></div>
<div class="ch-tab-panel dash-tab-panel" id="dash-panel-chatai" role="tabpanel"><div class="dash-grid" id="dash-grid-chatai"></div></div>
<div class="ch-tab-panel dash-tab-panel" id="dash-panel-moderation" role="tabpanel"><div class="dash-grid" id="dash-grid-moderation"></div></div>
<div class="ch-tab-panel dash-tab-panel" id="dash-panel-controls" role="tabpanel"><div class="dash-grid" id="dash-grid-controls"></div></div>
ddiff --git a/public/js/bench-stream-dashboard.js b/public/js/bench-stream-dashboard.js
nnew file mode 100644
iindex 00000000..a441940d
---- /dev/null
++++ b/public/js/bench-stream-dashboard.js
@@@ -0,0 +1,33 @@
++/* The dashboard's Bench round starter. Chat votes are collected by Live after Bench finishes the entries. */
++(function () {
++ const form = document.getElementById('dash-bench-form');
++ const status = document.getElementById('dash-bench-status');
++ if (!form || !status) return;
++ async function refresh() {
++ try {
++ const { poll } = await api('/bench-stream/mine');
++ if (!poll) {
++ status.textContent = '';
++ form.querySelector('button').disabled = false;
++ return;
++ }
++ status.textContent = `Round ${poll.round_id}: ${poll.status}${poll.status === 'open' ? ` — vote !1, !2 or !tie until ${new Date(poll.closes_at).toLocaleTimeString()}` : ''}`;
++ form.querySelector('button').disabled = ['pending', 'open', 'closing'].includes(poll.status);
++ } catch (e) { status.textContent = e.message || 'Could not load Bench.'; }
++ }
++ form.addEventListener('submit', async (event) => {
++ event.preventDefault();
++ const button = form.querySelector('button');
++ button.disabled = true;
++ status.textContent = 'Starting Bench round…';
++ try {
++ const { poll } = await api('/bench-stream/mine', { method: 'POST', body: {
++ kind: form.elements.kind.value, prompt: form.elements.prompt.value,
++ duration_seconds: Number(form.elements.duration.value),
++ } });
++ status.textContent = `Round ${poll.round_id} is generating its entries.`;
++ } catch (e) { status.textContent = e.message || 'Could not start Bench round.'; button.disabled = false; }
++ });
++ refresh();
++ setInterval(() => { if (document.getElementById('page-dashboard')?.classList.contains('active')) refresh(); }, 5000);
++})();
ddiff --git a/public/js/bench-stream-watch.js b/public/js/bench-stream-watch.js
nnew file mode 100644
iindex 00000000..f9aaa4a9
---- /dev/null
++++ b/public/js/bench-stream-watch.js
@@@ -0,0 +1,81 @@
++/* Plain watch-page side panel; the main session owns the visual treatment. */
++(function () {
++ const panel = document.getElementById('ch-bench-stream');
++ if (!panel) return;
++ let shown = '';
++ function entryView(entry, number) {
++ const box = document.createElement('article');
++ box.className = 'dash-card';
++ const heading = document.createElement('h4');
++ heading.textContent = `Entry ${number}`;
++ box.append(heading);
++ const payload = entry.payload || {};
++ if (typeof payload.html === 'string') {
++ const frame = document.createElement('iframe');
++ frame.title = `Bench entry ${number}`;
++ frame.setAttribute('sandbox', '');
++ frame.setAttribute('referrerpolicy', 'no-referrer');
++ frame.srcdoc = '<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">'
++ + '<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; style-src \'unsafe-inline\'; img-src data:">'
++ + '<style>html,body{margin:0;min-height:100%;background:#fff}img{max-width:100%;height:auto}</style>' + payload.html;
++ box.append(frame);
++ } else {
++ for (const [key, value] of Object.entries(payload)) {
++ if (typeof value !== 'string') {
++ if (value == null) continue;
++ const pre = document.createElement('pre');
++ pre.textContent = `${key}: ${JSON.stringify(value, null, 2)}`;
++ box.append(pre);
++ continue;
++ }
++ if (key === 'image' && (/^https:\/\//i.test(value) || /^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(value))) {
++ const img = document.createElement('img');
++ img.src = value;
++ img.alt = `Bench entry ${number}`;
++ img.loading = 'lazy';
++ box.append(img);
++ continue;
++ }
++ if (key === 'audio_url' && /^https:\/\//i.test(value)) {
++ const audio = document.createElement('audio');
++ audio.controls = true;
++ audio.src = value;
++ box.append(audio);
++ continue;
++ }
++ const p = document.createElement('p');
++ p.textContent = `${key}: ${value}`;
++ box.append(p);
++ }
++ }
++ return box;
++ }
++ async function refresh() {
++ const username = typeof currentChannelUsername === 'string' ? currentChannelUsername : null;
++ if (!username || !location.pathname.startsWith('/@')) return;
++ try {
++ const { poll } = await api(`/bench-stream/channel/${encodeURIComponent(username)}`);
++ if (username !== currentChannelUsername || !location.pathname.startsWith('/@')) return;
++ if (!poll || poll.status === 'failed' || (typeof currentStreamId !== 'undefined' && Number(currentStreamId) !== poll.stream_id)) {
++ panel.hidden = true; shown = ''; return;
++ }
++ const key = `${poll.round_id}:${poll.status}`;
++ if (shown === key) return;
++ shown = key;
++ panel.replaceChildren();
++ const title = document.createElement('h3');
++ title.textContent = `Bench: ${poll.kind_id}`;
++ const prompt = document.createElement('p');
++ prompt.textContent = poll.prompt;
++ const state = document.createElement('p');
++ state.textContent = poll.status === 'open' ? `Vote in chat: !1, !2 or !tie. Closes ${new Date(poll.closes_at).toLocaleTimeString()}.`
++ : poll.status === 'closed' ? `Result: ${poll.vote_counts.left} for 1, ${poll.vote_counts.right} for 2, ${poll.vote_counts.tie} ties (${poll.vote_counts.voters} voters).`
++ : 'Entries are generating…';
++ panel.append(title, prompt, state);
++ if (poll.entries) for (const [i, entry] of poll.entries.entries()) panel.append(entryView(entry, i + 1));
++ panel.hidden = false;
++ } catch { /* Chat and the stream stay usable while Bench is unavailable. */ }
++ }
++ refresh();
++ setInterval(refresh, 3000);
++})();
ddiff --git a/server/bench-stream/bench-client.js b/server/bench-stream/bench-client.js
nnew file mode 100644
iindex 00000000..117a4f06
---- /dev/null
++++ b/server/bench-stream/bench-client.js
@@@ -0,0 +1,22 @@
++'use strict';
++
++const { createClient } = require('openvibe-sdk/core');
++const { createBenchClient } = require('openvibe-sdk/bench');
++const principal = require('../net/network-principal');
++
++let bench;
++function client() {
++ if (require('../drill').enabled) throw new Error('Bench is unavailable in a restore drill');
++ if (!principal.configured()) throw new Error('Live has no service credentials for Bench');
++ if (!bench) {
++ const baseUrl = (process.env.OV_BENCH_INTERNAL_URL || 'http://127.0.0.1:5230').replace(/\/+$/, '');
++ bench = createBenchClient(createClient({
++ baseUrls: { bench: baseUrl }, autoDiscover: false, retries: 0,
++ getToken: async () => (await principal.serviceHeaders('openvibe.bench')).Authorization.replace(/^Bearer\s+/i, ''),
++ invalidateToken: () => principal.invalidate('openvibe.bench'),
++ }));
++ }
++ return bench;
++}
++
++module.exports = { client };
ddiff --git a/server/bench-stream/routes.js b/server/bench-stream/routes.js
nnew file mode 100644
iindex 00000000..84b5bf4b
---- /dev/null
++++ b/server/bench-stream/routes.js
@@@ -0,0 +1,38 @@
++'use strict';
++
++const express = require('express');
++const db = require('../db/database');
++const { requireAuth } = require('../auth/auth');
++const poll = require('./service');
++const router = express.Router();
++
++router.get('/channel/:username', async (req, res) => {
++ res.set('Cache-Control', 'no-store');
++ try {
++ const user = await db.getUserByUsername(req.params.username);
++ if (!user) return res.status(404).json({ error: 'Channel not found' });
++ return res.json({ poll: await poll.current(user.id) });
++ } catch (err) { console.warn('[BenchStream] read:', err.message); return res.status(503).json({ error: 'Bench is unavailable' }); }
++});
++
++router.get('/mine', requireAuth, async (req, res) => {
++ res.set('Cache-Control', 'no-store');
++ try { return res.json({ poll: await poll.current(req.user.id) }); }
++ catch (err) { console.warn('[BenchStream] mine:', err.message); return res.status(503).json({ error: 'Bench is unavailable' }); }
++});
++
++router.post('/mine', requireAuth, async (req, res) => {
++ res.set('Cache-Control', 'no-store');
++ try {
++ const b = req.body || {};
++ return res.status(201).json({ poll: await poll.start(req.user.id, {
++ kind: b.kind, prompt: b.prompt, durationSeconds: b.duration_seconds == null ? 60 : b.duration_seconds,
++ }) });
++ } catch (err) {
++ if (/^(Choose|Write|Vote time|Go live|Finish|Sign in again)/.test(err.message)) return res.status(400).json({ error: err.message });
++ console.warn('[BenchStream] start:', err.message);
++ return res.status(503).json({ error: 'Bench could not start the round' });
++ }
++});
++
++module.exports = router;
ddiff --git a/server/bench-stream/service.js b/server/bench-stream/service.js
nnew file mode 100644
iindex 00000000..b33ff209
---- /dev/null
++++ b/server/bench-stream/service.js
@@@ -0,0 +1,122 @@
++'use strict';
++
++const db = require('../db/database');
++const { client } = require('./bench-client');
++const delivery = require('../chat/chat-delivery');
++const { publicBenchPoll: publicPoll } = require('../web/serializers');
++
++const VALID_KINDS = new Set(['design.page', 'design.component', 'games.fun', 'meme.caption', 'vision.pair', 'codes.review', 'surf.desk', 'chat.reply', 'speech.tts', 'translate.ja', 'search.answer']);
++
++async function line(row, message, suffix) {
++ await delivery.message({ stream_id: Number(row.stream_id), channel_user_id: Number(row.streamer_id), user_id: null,
++ username: 'OpenVibe.Bench', message, message_type: 'system',
++ metadata: { kind: 'bench_stream', round_id: row.round_id }, key: `bench:${row.round_id}:${suffix}` });
++}
++
++async function start(streamerId, { kind, prompt, durationSeconds = 60 }) {
++ if (!VALID_KINDS.has(kind)) throw new Error('Choose a pairwise Bench kind.');
++ if (typeof prompt !== 'string' || !prompt.trim() || prompt.length > 4000) throw new Error('Write a prompt of at most 4000 characters.');
++ if (!Number.isInteger(durationSeconds) || durationSeconds < 15 || durationSeconds > 600) throw new Error('Vote time must be 15 to 600 seconds.');
++ const streams = await db.getLiveStreamsByUserId(streamerId);
++ const stream = streams && streams[0];
++ if (!stream) throw new Error('Go live before starting a Bench round.');
++ const active = await db.get("SELECT id FROM live_bench_polls WHERE streamer_id = ? AND status IN ('pending', 'open', 'closing') LIMIT 1", [streamerId]);
++ if (active) throw new Error('Finish the current Bench round first.');
++ const subject = await require('../auth/identity-sync').subjectOf(streamerId);
++ if (!subject) throw new Error('Sign in again to start a Bench round.');
++ const task = await client().tasks.create(kind, { prompt: prompt.trim(), public: true }, { actingSubject: subject });
++ const created = await client().rounds.create(task.task.id, {}, { actingSubject: subject });
++ try {
++ await db.run(`INSERT INTO live_bench_polls (streamer_id, stream_id, round_id, kind_id, prompt, status, duration_seconds)
++ VALUES (?, ?, ?, ?, ?, 'pending', ?)`, [streamerId, stream.id, created.round.id, kind, prompt.trim(), durationSeconds]);
++ } catch (err) {
++ if (err.code === '23505') throw new Error('Finish the current Bench round first.');
++ throw err;
++ }
++ return await current(streamerId);
++}
++
++async function current(streamerId) {
++ const row = await db.get(`SELECT p.* FROM live_bench_polls p JOIN streams s ON s.id = p.stream_id
++ WHERE p.streamer_id = ? AND s.is_live = 1 ORDER BY p.id DESC LIMIT 1`, [streamerId]);
++ if (!row) return null;
++ return publicPoll(row);
++}
++
++async function advance(row) {
++ if (row.status === 'pending') {
++ const stream = await db.getStreamById(row.stream_id);
++ if (!stream || !stream.is_live) {
++ await db.run("UPDATE live_bench_polls SET status = 'failed' WHERE id = ? AND status = 'pending'", [row.id]);
++ return;
++ }
++ const { round } = await client().rounds.get(row.round_id);
++ if (round.status === 'failed') {
++ await db.run("UPDATE live_bench_polls SET status = 'failed' WHERE id = ? AND status = 'pending'", [row.id]);
++ return;
++ }
++ const entries = (round.entries || []).filter((e) => !e.error).slice(0, 2);
++ if (round.status !== 'finished' || entries.length !== 2) return;
++ const opened = await db.run(`UPDATE live_bench_polls SET status = 'open', entries = ?, closes_at = now() + duration_seconds * interval '1 second'
++ WHERE id = ? AND status = 'pending'`, [JSON.stringify(entries), row.id]);
++ if (opened.changes) await line(row, `Bench vote is open for ${row.duration_seconds}s: !1, !2 or !tie. ${row.prompt}`.slice(0, 480), 'open');
++ return;
++ }
++ if (row.status !== 'open' && row.status !== 'closing') return;
++ if (row.status === 'open' && new Date(row.closes_at).getTime() > Date.now()) return;
++ if (row.status === 'open') {
++ const claimed = await db.run("UPDATE live_bench_polls SET status = 'closing' WHERE id = ? AND status = 'open'", [row.id]);
++ if (!claimed.changes) return;
++ }
++ const counts = { left: 0, right: 0, tie: 0, voters: 0 };
++ for (const v of await db.all('SELECT choice, COUNT(*)::int AS n FROM live_bench_votes WHERE poll_id = ? GROUP BY choice', [row.id])) {
++ counts[v.choice] = Number(v.n);
++ counts.voters += Number(v.n);
++ }
++ const entries = typeof row.entries === 'string' ? JSON.parse(row.entries) : row.entries;
++ const outcome = counts.left > counts.right && counts.left > counts.tie ? 'left'
++ : counts.right > counts.left && counts.right > counts.tie ? 'right' : 'tie';
++ let judgementId = row.judgement_id;
++ if (!judgementId) {
++ try {
++ const result = await client().judgements.cast(row.round_id, { judge: 'audience_poll', pair: { left: entries[0].id, right: entries[1].id }, outcome, vote_counts: counts });
++ judgementId = result.judgement.id;
++ } catch (err) {
++ if (err.status !== 409 || err.code !== 'bench.already_judged') throw err;
++ // A previous close reached Bench and crashed before saving the receipt. Bench's one-service-per-round rule prevents a duplicate.
++ judgementId = 'already-recorded';
++ }
++ }
++ await db.run("UPDATE live_bench_polls SET status = 'closed', vote_counts = ?, judgement_id = ? WHERE id = ? AND status = 'closing'", [JSON.stringify(counts), judgementId, row.id]);
++ const summary = `${counts.left} for 1, ${counts.right} for 2, ${counts.tie} tie (${counts.voters} voters)`;
++ await line(row, `Bench vote closed: ${outcome === 'left' ? '1 wins' : outcome === 'right' ? '2 wins' : 'tie'} — ${summary}.`, 'closed');
++}
++
++async function vote({ streamId, userId, choice }) {
++ if (!Number.isSafeInteger(streamId) || !Number.isSafeInteger(userId) || !['left', 'right', 'tie'].includes(choice)) return { ok: false, message: 'Invalid vote.' };
++ const stream = await db.getStreamById(streamId);
++ if (!stream || !stream.is_live) return { ok: false, message: 'The stream is not live.' };
++ if (Number(stream.user_id) === userId) return { ok: false, message: 'The streamer cannot vote in their own round.' };
++ if (!await db.getUserById(userId) || !await require('../auth/identity-sync').subjectOf(userId)) return { ok: false, message: 'Sign in to vote.' };
++ return await db.tx(async () => {
++ // Closing updates this same row. A vote admitted before the deadline commits before the tally runs.
++ const row = await db.get("SELECT * FROM live_bench_polls WHERE stream_id = ? AND status = 'open' ORDER BY id DESC LIMIT 1 FOR UPDATE", [streamId]);
++ if (!row) return { ok: false, message: 'There is no open Bench vote.' };
++ if (new Date(row.closes_at).getTime() <= Date.now()) return { ok: false, message: 'Voting has closed.' };
++ const added = await db.run(`INSERT INTO live_bench_votes (poll_id, user_id, choice)
++ SELECT ?, ?, ? WHERE EXISTS (SELECT 1 FROM live_bench_polls WHERE id = ? AND status = 'open' AND closes_at > now())
++ ON CONFLICT DO NOTHING`, [row.id, userId, choice, row.id]);
++ return added.changes ? { ok: true, message: 'Your Bench vote is counted.' } : { ok: false, message: 'You already voted, or voting has closed.' };
++ });
++}
++
++function startJobs() {
++ require('../utils/jobs').every('bench-stream-polls', 3000, async () => {
++ for (const row of await db.all("SELECT * FROM live_bench_polls WHERE status IN ('pending', 'open', 'closing') ORDER BY id LIMIT 30")) {
++ try { await advance(row); }
++ catch (err) { console.warn('[BenchStream] advance:', err.message); }
++ }
++ }, { initialDelayMs: 1000 });
++}
++
++module.exports = { start, current, vote, startJobs, publicPoll, advance };
ddiff --git a/server/chat/live-context-routes.js b/server/chat/live-context-routes.js
iindex 4e2a7baa..e6727c21 100644
---- a/server/chat/live-context-routes.js
++++ b/server/chat/live-context-routes.js
@@@ -25,7 +25,7 @@
* /internal/chat-effects/* capability live.chat_effects.write — side effects
* anon, ip-log, viewer-counts, viewer-snapshots, user-color, ban, approve-ip, channel-settings,
* alert-sound, ensure-channel, site-settings, chat-message, ai/mod-command, arena-command,
-- * media-queue, hardware, paste, translate, notify/dm, notify/dm-read, notify/call-invite, asset-sync
++ * media-queue, hardware, bench-vote, paste, translate, notify/dm, notify/dm-read, notify/call-invite, asset-sync
*
* Every effect that acts for a person re-checks that person here (moderator, owner, admin) — Chat
* checked first; Live does not take its word for it.
@@@ -510,6 +510,18 @@ const CHAT_ROBOT_MS = 600;
const HARDWARE_COOLDOWN_MS = 250;
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const hardwareLast = new Map(); // `${streamer}:${viewer}` → last command ms
++effectsRouter.post('/bench-vote', async (req, res) => {
++ const streamId = int(req.body?.stream_id);
++ const userId = int(req.body?.viewer_user_id);
++ const choice = String(req.body?.choice || '');
++ try {
++ const out = await require('../bench-stream/service').vote({ streamId, userId, choice });
++ res.json(out);
++ } catch (err) {
++ console.warn('[BenchStream] vote:', err.message);
++ res.status(503).json({ ok: false, message: 'Bench could not count that vote.' });
++ }
++});
// What a refusal from Bot means, in chat words (Bot's codes, OpenVibe.Bot docs/protocol.md §2).
const ROBOT_REFUSALS = {
'bot.not_an_operator': 'You cannot drive this robot. Its owner decides who may.',
ddiff --git a/server/index.js b/server/index.js
iindex bb1675e4..2dc7865c 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -770,6 +770,7 @@ app.use('/data/offline', express.static(paths.data('offline'), {
// ── API Routes ───────────────────────────────────────────────
app.use('/api/auth', authRoutes);
++app.use('/api/bench-stream', require('./bench-stream/routes'));
// Channel ↔ OpenVibe.Bot robot binding (LIVE_BOT_EMBED, off by default: the route answers 404).
app.use('/api/streams', require('./bot/routes'));
app.use('/api/streams', streamRoutes);
@@@ -1467,6 +1468,7 @@ async function start() {
try { require('./ai/easter-egg-job').start(); } catch (e) { console.warn('[AI] easter-egg job not started:', e.message); }
// The Rundown's desk replaced the Arena's jobs (the drama desk, the beef listener and judges, 2026-10-19).
try { require('./rundown/desk').start(); } catch (e) { console.warn('[Rundown] desk not started:', e.message); }
++ try { require('./bench-stream/service').startJobs(); } catch (e) { console.warn('[BenchStream] poll job not started:', e.message); }
try { require('./recap/recap').start(); } catch (e) { console.warn('[Recap] job not started:', e.message); }
// Continuous audio → stream_timeline_events. Gated behind ai_timeline_enabled
// (default off), so starting it is a no-op until switched on in admin.
ddiff --git a/server/web/serializers.js b/server/web/serializers.js
iindex e9f2bce0..bbb8ec27 100644
---- a/server/web/serializers.js
++++ b/server/web/serializers.js
@@@ -57,6 +57,15 @@ function publicUserProfile(u) {
'openvibe_coins_balance', 'last_seen', 'is_owner']);
}
++/** The watch page's Bench poll: local ownership and voter records stay private. */
++function publicBenchPoll(row) {
++ if (!row) return null;
++ const entries = typeof row.entries === 'string' ? JSON.parse(row.entries) : row.entries;
++ const voteCounts = typeof row.vote_counts === 'string' ? JSON.parse(row.vote_counts) : row.vote_counts;
++ return { round_id: row.round_id, stream_id: Number(row.stream_id), kind_id: row.kind_id, prompt: row.prompt, status: row.status,
++ closes_at: row.closes_at, entries: entries || null, vote_counts: voteCounts || null, judgement_id: row.judgement_id || null };
++}
++
// ── Feed items (server/content/feed.js) ───────────────────────────────────────────────────────
// Built from Media, Community and Live rows that carry far more than a card needs (file paths,
// storage keys, owner subjects, AI state). Copied through an allow-list, so a field a source adds
@@@ -110,4 +119,4 @@ function findSecrets(value, path = '$', out = []) {
return out;
}
--module.exports = { ALWAYS_SECRET, omit, publicManagedStream, publicChannel, publicStream, publicUserProfile, publicFeedItem, findSecrets };
++module.exports = { ALWAYS_SECRET, omit, publicManagedStream, publicChannel, publicStream, publicUserProfile, publicBenchPoll, publicFeedItem, findSecrets };
ddiff --git a/test/bench-stream-round.test.js b/test/bench-stream-round.test.js
nnew file mode 100644
iindex 00000000..2e61b987
---- /dev/null
++++ b/test/bench-stream-round.test.js
@@@ -0,0 +1,71 @@
++'use strict';
++const assert = require('assert');
++const db = require('../server/db/database');
++const benchClient = require('../server/bench-stream/bench-client');
++const delivery = require('../server/chat/chat-delivery');
++
++const calls = [];
++benchClient.client = () => ({
++ tasks: { create: async (kind, body, options) => { calls.push(['task', kind, body, options]); return { task: { id: 'btk_test' } }; } },
++ rounds: {
++ create: async (taskId, body, options) => { calls.push(['round', taskId, body, options]); return { round: { id: 'brd_test' } }; },
++ get: async () => ({ round: { status: 'finished', entries: [
++ { id: 'bte_first', payload: { reply: 'one' }, error: null },
++ { id: 'bte_second', payload: { reply: 'two' }, error: null },
++ ] } }),
++ },
++ judgements: { cast: async (id, body) => { calls.push(['judge', id, body]); return { judgement: { id: 'bju_test' } }; } },
++});
++delivery.message = async (body) => { calls.push(['line', body.message]); return 1; };
++const poll = require('../server/bench-stream/service');
++
++(async () => {
++ await db.initDb();
++ await db.run(`INSERT INTO users (id, username, display_name, email, password_hash, role, created_at) OVERRIDING SYSTEM VALUE
++ VALUES (31, 'bench_owner', 'Bench Owner', 'bench_owner@test', 'x', 'streamer', '2025-01-01 00:00:00')`);
++ await db.run(`INSERT INTO users (id, username, display_name) OVERRIDING SYSTEM VALUE
++ VALUES (32, 'bench_one', 'One'), (33, 'bench_two', 'Two'), (34, 'bench_three', 'Three')`);
++ for (const id of [31, 32, 33, 34]) {
++ await db.run("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', ?, ?)",
++ [id, String(id), id === 31 ? 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA' : `usr_01J9AAAAAAAAAAAAAAAAAAAA${id}`]);
++ }
++ const channel = await db.ensureChannel(31);
++ const created = await db.createStream({ user_id: 31, channel_id: channel.id, title: 'Bench live', protocol: 'rtmp' });
++ const streamId = Number(created.lastInsertRowid);
++ await db.run('UPDATE streams SET is_live = 1 WHERE id = ?', [streamId]);
++
++ const started = await poll.start(31, { kind: 'chat.reply', prompt: 'Answer warmly', durationSeconds: 30 });
++ assert.strictEqual(started.status, 'pending');
++ assert.deepStrictEqual(calls.slice(0, 2).map((c) => c[0]), ['task', 'round']);
++ assert.strictEqual(calls[0][3].actingSubject, 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA');
++ assert.strictEqual(calls[1][3].actingSubject, 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA');
++ await assert.rejects(() => poll.start(31, { kind: 'chat.reply', prompt: 'Again' }), /Finish the current/);
++
++ let row = await db.get('SELECT * FROM live_bench_polls WHERE round_id = ?', ['brd_test']);
++ await poll.advance(row);
++ row = await db.get('SELECT * FROM live_bench_polls WHERE round_id = ?', ['brd_test']);
++ assert.strictEqual(row.status, 'open');
++ assert.strictEqual((await poll.current(31)).entries.length, 2);
++ assert.ok(calls.some((c) => c[0] === 'line' && /!1, !2 or !tie/.test(c[1])));
++
++ const votes = await Promise.all([
++ poll.vote({ streamId, userId: 32, choice: 'left' }),
++ poll.vote({ streamId, userId: 32, choice: 'right' }),
++ poll.vote({ streamId, userId: 33, choice: 'left' }),
++ poll.vote({ streamId, userId: 34, choice: 'tie' }),
++ ]);
++ assert.strictEqual(votes.filter((v) => v.ok).length, 3, 'a concurrent duplicate is not counted');
++ assert.strictEqual((await poll.vote({ streamId, userId: 31, choice: 'right' })).ok, false);
++
++ await db.run("UPDATE live_bench_polls SET closes_at = now() - interval '1 second' WHERE id = ?", [row.id]);
++ row = await db.get('SELECT * FROM live_bench_polls WHERE id = ?', [row.id]);
++ await poll.advance(row);
++ const closed = await db.get('SELECT * FROM live_bench_polls WHERE id = ?', [row.id]);
++ assert.strictEqual(closed.status, 'closed');
++ assert.deepStrictEqual(calls.find((c) => c[0] === 'judge')[2].vote_counts, { left: 2, right: 0, tie: 1, voters: 3 });
++ assert.strictEqual(calls.find((c) => c[0] === 'judge')[2].outcome, 'left');
++ assert.ok(calls.some((c) => c[0] === 'line' && /1 wins.*3 voters/.test(c[1])));
++ await poll.advance(closed);
++ assert.strictEqual(calls.filter((c) => c[0] === 'judge').length, 1);
++ console.log('✅ Bench stream round: creates task, opens chat vote, closes and records one service judgement');
++})().catch((err) => { console.error(err); process.exitCode = 1; });
ddiff --git a/test/bench-stream-vote.test.js b/test/bench-stream-vote.test.js
nnew file mode 100644
iindex 00000000..fbffc22f
---- /dev/null
++++ b/test/bench-stream-vote.test.js
@@@ -0,0 +1,40 @@
++'use strict';
++const assert = require('assert');
++const db = require('../server/db/database');
++const identity = require('../server/auth/identity-sync');
++const service = require('../server/bench-stream/service');
++
++const original = { getStreamById: db.getStreamById, get: db.get, getUserById: db.getUserById,
++ run: db.run, tx: db.tx, subjectOf: identity.subjectOf };
++const votes = new Set();
++const poll = { id: 5, stream_id: 17, streamer_id: 2, status: 'open', closes_at: new Date(Date.now() + 60000).toISOString() };
++db.getStreamById = async (id) => id === 17 ? { id: 17, user_id: 2, is_live: 1 } : null;
++db.get = async (sql) => sql.includes('live_bench_polls') ? poll : null;
++db.getUserById = async (id) => id === 3 || id === 4 ? { id } : null;
++identity.subjectOf = async (id) => id === 3 ? 'usr_test' : null;
++db.tx = async (fn) => await fn();
++db.run = async (sql, params) => {
++ if (!sql.includes('INSERT INTO live_bench_votes')) throw new Error('unexpected write');
++ const key = `${params[0]}:${params[1]}`;
++ if (votes.has(key)) return { changes: 0 };
++ votes.add(key);
++ return { changes: 1 };
++};
++
++(async () => {
++ try {
++ assert.strictEqual((await service.vote({ streamId: 17, userId: 0, choice: 'left' })).ok, false, 'anonymous viewer');
++ assert.strictEqual((await service.vote({ streamId: 17, userId: 2, choice: 'left' })).ok, false, 'streamer');
++ assert.strictEqual((await service.vote({ streamId: 17, userId: 4, choice: 'left' })).ok, false, 'no Network identity');
++ assert.strictEqual((await service.vote({ streamId: 17, userId: 3, choice: 'left' })).ok, true, 'signed-in viewer');
++ assert.strictEqual((await service.vote({ streamId: 17, userId: 3, choice: 'right' })).ok, false, 'second command cannot change vote');
++ assert.strictEqual(votes.size, 1);
++ poll.closes_at = new Date(Date.now() - 1000).toISOString();
++ poll.status = 'closed';
++ assert.strictEqual((await service.vote({ streamId: 17, userId: 3, choice: 'tie' })).ok, false, 'late vote');
++ console.log('✅ Bench stream votes: signed-in only, streamer excluded, one per viewer, closed deadline');
++ } finally {
++ Object.assign(db, { getStreamById: original.getStreamById, get: original.get, getUserById: original.getUserById, run: original.run, tx: original.tx });
++ identity.subjectOf = original.subjectOf;
++ }
++})().catch((err) => { console.error(err); process.exitCode = 1; });
ddiff --git a/test/bench-stream-watch.test.js b/test/bench-stream-watch.test.js
nnew file mode 100644
iindex 00000000..d3e9044f
---- /dev/null
++++ b/test/bench-stream-watch.test.js
@@@ -0,0 +1,65 @@
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++const { publicBenchPoll } = require('../server/web/serializers');
++
++class Element {
++ constructor(tag) { this.tagName = tag; this.children = []; this.attrs = {}; this.hidden = true; }
++ append(...children) { this.children.push(...children); }
++ replaceChildren(...children) { this.children = children; }
++ setAttribute(key, value) { this.attrs[key] = value; }
++ set innerHTML(value) { throw new Error('Entry content must not become page HTML'); }
++}
++
++(async () => {
++ const panel = new Element('section');
++ let refresh;
++ let poll = { round_id: 'brd_test', stream_id: 17, kind_id: 'design.page', prompt: '<script>prompt</script>', status: 'open',
++ closes_at: new Date(Date.now() + 30000).toISOString(), entries: [
++ { payload: { html: '<h1>First</h1><script>parent.stolen = true</script>' } },
++ { payload: { result: { move: 'Second' }, reply: '<img src=x onerror=alert(1)>' } },
++ ] };
++ const context = vm.createContext({
++ document: { getElementById: () => panel, createElement: (tag) => new Element(tag) },
++ currentChannelUsername: 'streamer', currentStreamId: 17, location: { pathname: '/@streamer' },
++ api: async () => ({ poll }), setInterval: (fn) => { refresh = fn; },
++ });
++ vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/js/bench-stream-watch.js'), 'utf8'), context);
++ await new Promise(setImmediate);
++ assert.strictEqual(panel.hidden, false);
++ assert.strictEqual(panel.children[1].textContent, '<script>prompt</script>');
++ const entries = panel.children.filter((e) => e.tagName === 'article');
++ assert.strictEqual(entries.length, 2);
++ const frame = entries[0].children.find((e) => e.tagName === 'iframe');
++ assert.strictEqual(frame.attrs.sandbox, '');
++ assert.strictEqual(frame.attrs.referrerpolicy, 'no-referrer');
++ assert.ok(frame.srcdoc.includes("default-src 'none'; style-src 'unsafe-inline'; img-src data:"));
++ assert.ok(entries[1].children.some((e) => e.tagName === 'pre' && e.textContent.includes('Second')));
++ assert.ok(entries[1].children.some((e) => e.tagName === 'p' && e.textContent.includes('<img')));
++ await refresh();
++ assert.strictEqual(panel.children[3], entries[0], 'polling keeps the existing iframe');
++
++ poll = { ...poll, status: 'closed', vote_counts: { left: 2, right: 1, tie: 0, voters: 3 } };
++ await refresh();
++ assert.ok(panel.children[2].textContent.includes('3 voters'));
++ context.currentStreamId = 18;
++ await refresh();
++ assert.strictEqual(panel.hidden, true, 'an old stream cannot show its poll on a new stream');
++
++ let answer;
++ context.currentStreamId = 17;
++ context.api = () => new Promise((resolve) => { answer = resolve; });
++ const pending = refresh();
++ context.currentChannelUsername = 'another';
++ answer({ poll });
++ await pending;
++ assert.strictEqual(panel.hidden, true, 'a late response cannot replace another channel');
++
++ const publicRow = publicBenchPoll({ ...poll, streamer_id: 31, user_id: 32, secret: 'private' });
++ assert.strictEqual(publicRow.streamer_id, undefined);
++ assert.strictEqual(publicRow.user_id, undefined);
++ assert.strictEqual(publicRow.secret, undefined);
++ console.log('✅ Bench watch: two entries, sandbox and CSP, safe text, results, stream isolation, private row fields');
++})().catch((err) => { console.error(err); process.exitCode = 1; });