openvibers/openvibe.bot Commits

machines: store the guided first run and show it (#301)

OP OpenVibers committed 7 days ago · 9647233f03 · 8 files, +298 −6

OpenVibe.Node sends one node.first_run telemetry event per guided first-run step (installed,
paired, self_check with ok or the reason, first_job with the machine's OS, CPUs, memory and
free disk). Bot now stores one row per device and step in device_first_run (expand-only), with
an allowlist for the machine fields so no path, username or address is kept.

The machines page shows what a machine's first job found, and /admin/first-run (reviewers only)
counts how many machines reach each step and the median time from the step before. Telemetry
handling stays fire-and-forget so device frame order and the sensor window are unchanged.

Changes

ddiff --git a/migrations/0030_device_first_run.sql b/migrations/0030_device_first_run.sql
nnew file mode 100644
iindex 0000000..8ec4895
---- /dev/null
++++ b/migrations/0030_device_first_run.sql
@@@ -0,0 +1,19 @@
++-- phase: expand
++-- The guided first run (OpenVibe.Node's `openvibe-node`, docs/install.md#first-run-guided-and-measured): one row per
++-- device and step, from the `node.first_run` telemetry events the Node sends over its existing device link. It powers
++-- the machines page's figures for a machine's first job and the admin funnel (how many installs reach each step, and
++-- how long each took). The fields are an allowlist: no path, username or address is stored.
++CREATE TABLE IF NOT EXISTS device_first_run (
++ device_id text NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
++ step text NOT NULL CHECK (step IN ('installed', 'paired', 'self_check', 'first_job')),
++ at timestamptz NOT NULL,
++ agent_version text,
++ os text,
++ ok boolean,
++ reason text,
++ info jsonb NOT NULL DEFAULT '{}'::jsonb,
++ received_at timestamptz NOT NULL,
++ PRIMARY KEY (device_id, step)
++);
++-- The admin funnel reads every step in time order; the machines page reads one machine's first_job (the primary key).
++CREATE INDEX IF NOT EXISTS device_first_run_at ON device_first_run (at);
ddiff --git a/public/directory.css b/public/directory.css
iindex 0598434..6d12d2b 100644
---- a/public/directory.css
++++ b/public/directory.css
@@@ -122,3 +122,10 @@
/* hidden wins over .dir-btn's display (the panel link waits for a robot, the downloads for the files). */
.sd [hidden] { display: none !important; }
.sd-made { margin: 0; padding: 12px 14px; border-radius: 10px; border: 1px solid rgba(34, 197, 94, .45); background: rgba(34, 197, 94, .08); }
++
++/* The admin first-run funnel (/admin/first-run): the same directory shell, one compact table. */
++.dir-table { width: 100%; border-collapse: collapse; margin: 0 0 12px; font-size: 15px; }
++.dir-table th, .dir-table td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--border, #1f2d47); }
++.dir-table thead th { font-size: 13px; letter-spacing: .04em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); }
++.dir-table tbody th { font-weight: 650; }
++.dir-table tbody td:nth-child(2) { font-variant-numeric: tabular-nums; }
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 677083e..6cce3f8 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -921,6 +921,60 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
WHERE id = $1 AND revoked_at IS NULL RETURNING *`,
[id, JSON.stringify(fleet), iso(now())]);
}
++ // ── The guided first run (OpenVibe.Node's `openvibe-node`, docs/install.md#first-run) ──────────────
++ // One row per device and step, from the `node.first_run` telemetry events the Node already sends. The fields are
++ // an allowlist: a path, a username or an address is never stored even if a buggy client sends one.
++ const FIRST_RUN_STEPS = new Set(['installed', 'paired', 'self_check', 'first_job']);
++ const firstRunNumber = (v) => (Number.isFinite(v) && v >= 0 && v <= Number.MAX_SAFE_INTEGER ? Math.floor(v) : undefined);
++ const firstRunText = (v, max) => (typeof v === 'string' ? v.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, max) || null : null);
++ function firstRunInfo(fields) {
++ const info = {};
++ const arch = firstRunText(fields.arch, 24);
++ if (arch) info.arch = arch;
++ for (const key of ['cpus', 'memory_total_bytes', 'memory_free_bytes', 'disk_total_bytes', 'disk_free_bytes']) {
++ const n = firstRunNumber(fields[key]);
++ if (n !== undefined) info[key] = n;
++ }
++ return info;
++ }
++ /**
++ * Store the first-run steps in a telemetry frame. A resent step updates its time (the newest run wins); an event
++ * that is not `node.first_run` with a known step is ignored, never an error for the caller.
++ */
++ async function recordFirstRun(deviceId, events) {
++ if (!Array.isArray(events) || !deviceId) return 0;
++ let stored = 0;
++ for (const event of events) {
++ if (!event || event.name !== 'node.first_run' || !event.fields || typeof event.fields !== 'object') continue;
++ const step = firstRunText(event.fields.step, 20);
++ if (!FIRST_RUN_STEPS.has(step)) continue;
++ const ts = Number.isFinite(event.ts) && event.ts > 0 ? event.ts : now();
++ // A clock far in the future would make every duration meaningless; keep it on this side of the present.
++ const at = iso(Math.min(Math.max(ts, Date.UTC(2020, 0, 1)), now() + 5 * 60_000));
++ const reason = step === 'self_check' ? firstRunText(event.fields.reason, 240) : null;
++ const ok = step === 'self_check' && typeof event.fields.ok === 'boolean' ? event.fields.ok : null;
++ const info = step === 'first_job' ? firstRunInfo(event.fields) : {};
++ await db.query(
++ `INSERT INTO device_first_run (device_id, step, at, agent_version, os, ok, reason, info, received_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)
++ ON CONFLICT (device_id, step) DO UPDATE SET at = EXCLUDED.at, agent_version = EXCLUDED.agent_version,
++ os = EXCLUDED.os, ok = EXCLUDED.ok, reason = EXCLUDED.reason, info = EXCLUDED.info,
++ received_at = EXCLUDED.received_at`,
++ [deviceId, step, at, firstRunText(event.fields.version, 40), firstRunText(event.fields.os, 24), ok, reason,
++ JSON.stringify(info), iso(now())]);
++ stored++;
++ }
++ return stored;
++ }
++ /** What the machine's first job found (the machines page), or null when it has not run yet. */
++ async function firstRunOf(deviceId) {
++ return db.maybe("SELECT step, at, agent_version, os, ok, reason, info FROM device_first_run WHERE device_id = $1 AND step = 'first_job'", [deviceId]);
++ }
++ /** Every stored first-run step, for the admin funnel (the page counts and times them). */
++ async function firstRunFunnel() {
++ return db.many('SELECT device_id, step, at, agent_version, os, ok, reason, info FROM device_first_run ORDER BY at, device_id, step');
++ }
++
/** Revoke a Network-paired device's principal on Network too (null: Network no longer knows it). */
async function revokeNode(principalId) {
if (!nodes) fail(503, 'bot.network_unavailable', 'Network-paired machines are not configured here');
@@@ -1373,7 +1427,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
localProfile, saveLocalProfile, useCatalogueProfile },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl, onPaired },
-- devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, setFleet, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, revokeByNode, touchSeen, setOnline, ownerOf, onChange },
++ devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, setFleet, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, revokeByNode, touchSeen, setOnline, ownerOf, onChange,
++ recordFirstRun, firstRunOf, firstRunFunnel },
machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setAiModels, setWatchProbe, setVoice, setSharedDesks, listSharedDesks,
setRoles: setMachineRoles, rename: renameMachine, runningJobs, reportStatus: reportMachineStatus,
screen: { key: screenKey, liveKey: liveScreenKey, live: screenLive, end: screenEnd, archive: screenArchive } },
ddiff --git a/server/realtime.js b/server/realtime.js
iindex 01dd4df..c57d9af 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -492,6 +492,13 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
conn.lastTelemetry = now();
}
conn.telemetry = msg;
++ if (urgent && !conn.sim) {
++ // The guided first run's steps ride these events (OpenVibe.Node's docs/install.md#first-run): store the
++ // step times for the machines page and the admin funnel. Not awaited: the sensor broadcast below must
++ // keep its frame order (two telemetry frames are handled concurrently), and a storage failure must never
++ // break a robot's telemetry. The domain ignores an event that is not a known step.
++ domain.devices.recordFirstRun(conn.device.id, msg.events).catch((e) => log.warn(`[Bot] first-run steps: ${e.message}`));
++ }
for (const robotId of deviceRobotIds(conn.device)) broadcast(robotId);
}
ddiff --git a/server/web/directory.js b/server/web/directory.js
iindex 4a6db44..7efe7e7 100644
---- a/server/web/directory.js
++++ b/server/web/directory.js
@@@ -298,7 +298,8 @@ ${mine.length ? `<ul class="dir-list">${mine.map((s) => submissionRow(s)).join('
${pending ? `<section class="dir-sec" aria-labelledby="queue-h"><h2 id="queue-h">Waiting for review</h2>
${signing ? '' : '<p class="dir-error" role="alert">No profile signing key is configured, so nothing can be approved yet (BOT_PROFILE_SIGNING_KEY).</p>'}
<p class="muted">Check the profile's commands and limits are safe for the hardware it names, that its driver's source is public and matches, and that the guide is a real guide. Approving signs it and puts it in the catalogue.</p>
--${pending.length ? `<ul class="dir-list">${pending.map((s) => submissionRow(s, { review: true, by: names.get(s.submitter_subject) })).join('')}</ul>` : '<p class="muted">The queue is empty.</p>'}</section>` : ''}`;
++${pending.length ? `<ul class="dir-list">${pending.map((s) => submissionRow(s, { review: true, by: names.get(s.submitter_subject) })).join('')}</ul>` : '<p class="muted">The queue is empty.</p>'}
++<p class="muted"><a href="/admin/first-run">The guided first run</a>: how many machines reach each step, and how long each took.</p></section>` : ''}`;
return shell({ config, path: '/robots/submissions', title: 'Community robots', description: 'Community robot profiles on OpenVibe.Bot.', body });
}
ddiff --git a/server/web/render.js b/server/web/render.js
iindex 9899f4f..fc5e64b 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -813,11 +813,31 @@ function renderMachinesPage({ machines = [], pairing = null }) {
+ `<button type="submit" class="button quiet" aria-pressed="${m.watch_probe ? 'true' : 'false'}">${m.watch_probe ? 'Stop my watches checking from here' : 'Let my watches check things from this computer'}</button></form>` : '')
+ '</div>';
};
++ // The guided first run's one job (Node v0.39.0): what this machine is, from the first_job step Bot stored.
++ const bytesText = (n) => {
++ if (!Number.isFinite(n) || n < 0) return '';
++ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
++ let v = n, i = 0;
++ while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
++ return `${v >= 10 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
++ };
++ const firstRunRow = (m) => {
++ const f = m.first_run;
++ if (!f) return '';
++ const i = f.info && typeof f.info === 'object' ? f.info : {};
++ const bits = [];
++ if (Number.isInteger(i.cpus) && i.cpus > 0) bits.push(`${i.cpus} CPUs`);
++ if (Number.isFinite(i.memory_total_bytes) && i.memory_total_bytes > 0) bits.push(`${bytesText(i.memory_free_bytes)} free of ${bytesText(i.memory_total_bytes)} memory`);
++ if (Number.isFinite(i.disk_total_bytes) && i.disk_total_bytes > 0) bits.push(`${bytesText(i.disk_free_bytes)} free disk`);
++ bits.push(`${f.os || 'this machine'}${i.arch ? '/' + i.arch : ''}`);
++ return `<div class="machine-first-run"><span class="pill">First run</span><span class="muted">${esc(bits.join(' · '))}</span>`
++ + '<span class="muted small">What its first job found: OS, CPUs, memory and free disk. No file, account or address leaves the machine.</span></div>';
++ };
const list = machines.length
? `<ul class="robots">${machines.map((m) => `<li class="robot-card"><span class="robot-name">${icon('plug')}<span>${esc(m.name || m.id)}</span></span>`
+ `<span class="robot-meta"><span class="pill">${m.online ? 'Online' : 'Offline'}</span>${classesOf(m).map((c) => ` <span class="pill">${esc(c)} jobs</span>`).join('')}`
+ `${m.agent_version ? ` <span class="pill">Node ${esc(m.agent_version)}</span>` : ''}${m.robot ? ` <span class="pill">Serves ${esc(m.robot)}</span>` : ''}</span>`
-- + computerRow(m) + voiceRow(m)
++ + firstRunRow(m) + computerRow(m) + voiceRow(m)
+ `<span class="robot-actions"><form method="post" action="/machines/${esc(m.id)}/remove"><button type="submit" class="button quiet">Remove</button></form></span></li>`).join('')}</ul>`
: `<div class="empty">${icon('plug', 'icon empty-icon')}<p class="empty-title">No machines yet.</p><p>Add your computer or a server you own. It runs the jobs you start, such as a coding task on openvibe.codes, and nobody else's.</p></div>`;
// Adding a machine is a choice of what it does, each with its own command (one copy button each), not steps.
@@@ -1020,4 +1040,68 @@ function renderPhonePage() {
return page('This phone is a robot', body, { scripts: ['/panel/phone-device.js'], bodyClass: 'phone-page', description: 'Turn a phone into a robot on OpenVibe.Bot from its browser: its camera, voice and face, with nothing to install.', canonical: '/device/phone' });
}
--module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, camerasOf, esc };
++/**
++ * /admin/first-run: the measured first run, reviewers only. One row per step the Node reported (node.first_run), so
++ * the number that reach each step and the median time between them answer whether installs get to a working machine.
++ */
++function renderFirstRunPage({ rows = [] }) {
++ const order = ['installed', 'paired', 'self_check', 'first_job'];
++ const byStep = new Map(order.map((s) => [s, []]));
++ const byDevice = new Map();
++ for (const r of rows) {
++ if (!byStep.has(r.step)) continue;
++ const at = Date.parse(r.at);
++ byStep.get(r.step).push({ ...r, ms: Number.isFinite(at) ? at : 0 });
++ if (!byDevice.has(r.device_id)) byDevice.set(r.device_id, {});
++ byDevice.get(r.device_id)[r.step] = Number.isFinite(at) ? at : 0;
++ }
++ const median = (values) => {
++ if (!values.length) return null;
++ const a = [...values].sort((x, y) => x - y);
++ const m = Math.floor(a.length / 2);
++ return a.length % 2 ? a[m] : (a[m - 1] + a[m]) / 2;
++ };
++ const span = (from, to) => median([...byDevice.values()]
++ .map((d) => (d[from] && d[to] && d[to] >= d[from] ? (d[to] - d[from]) / 1000 : null))
++ .filter((x) => x !== null));
++ const secs = (v) => {
++ if (v === null || v === undefined) return '—';
++ if (v < 1) return 'under a second';
++ if (v < 90) return `${Math.round(v)} s`;
++ const m = Math.round(v / 60 * 10) / 10;
++ return `${m} min`;
++ };
++ const count = (s) => byStep.get(s).length;
++ const checks = byStep.get('self_check');
++ const failed = checks.filter((r) => r.ok === false).length;
++ const rowsHtml = [
++ ['Installed', count('installed'), null],
++ ['Paired', count('paired'), span('installed', 'paired')],
++ ['Self-check', count('self_check'), span('paired', 'self_check')],
++ ['First job done', count('first_job'), span('self_check', 'first_job')],
++ ].map(([label, n, d]) => `<tr><th scope="row">${label}</th><td>${n}</td><td>${esc(secs(d))}</td></tr>`).join('');
++ const latest = [...byDevice.entries()].map(([id, steps]) => {
++ const last = order.filter((s) => steps[s]).at(-1) || 'installed';
++ const at = steps[last] || steps.installed || 0;
++ return { id, last, at };
++ }).sort((a, b) => b.at - a.at).slice(0, 25);
++ const recent = latest.length
++ ? `<ul class="dir-list">${latest.map(({ id, last, at }) => {
++ const row = byStep.get(last).find((r) => r.device_id === id);
++ return `<li class="dir-card"><div class="dir-card-link"><code>${esc(id)}</code><span class="dir-card-text">${esc(last.replace('_', ' '))}`
++ + `${row && row.agent_version ? ` · Node ${esc(row.agent_version)}` : ''}${row && row.os ? ` · ${esc(row.os)}` : ''}`
++ + `${at ? ` · <time datetime="${esc(new Date(at).toISOString())}" data-local-time>${esc(new Date(at).toISOString().slice(0, 16).replace('T', ' '))}</time> UTC` : ''}</span></div></li>`;
++ }).join('')}</ul>`
++ : '<p class="muted">No machine has reported a first run yet.</p>';
++ const body = `<p class="dir-crumb"><a href="/robots/submissions">Review</a> / First runs</p>
++<header class="dir-hero"><h1>First run</h1><p class="muted">How many machines reach each step of OpenVibe Node's guided first run, and the median time from the step before. A machine reports one event per step over its own device link (<code>node.first_run</code>); nothing here names a person, folder or address.</p></header>
++<section class="dir-sec"><h2>The funnel</h2>
++<table class="dir-table"><caption class="sr-only">First-run steps, machines and median time</caption>
++<thead><tr><th scope="col">Step</th><th scope="col">Machines</th><th scope="col">Median from the step before</th></tr></thead>
++<tbody>${rowsHtml}</tbody></table>
++<p class="muted">${count('self_check') - failed} self-check${count('self_check') - failed === 1 ? '' : 's'} passed${failed ? `, ${failed} failed` : ''}. A failed check is still a machine that got that far; its reason is in the machine's own record.</p></section>
++<section class="dir-sec"><h2>Most recent</h2>${recent}</section>`;
++ return page('First run', body, { description: 'The guided first run of OpenVibe Node, measured step by step.', canonical: '/admin/first-run' });
++}
++
++module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage, camerasOf, esc };
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 98fed6e..5b20e7c 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -39,7 +39,7 @@ const { getProfile, listProfiles, loadProfiles } = require('../profiles');
const { frameAncestors } = require('../config');
const { BotError } = require('../util');
const kits = require('../kits');
--const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved } = require('./render');
++const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage } = require('./render');
const { createMachineScreen } = require('../machine-screen');
const { createScreenShares } = require('../screen-shares');
const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');
@@@ -461,6 +461,15 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
return res.redirect(303, '/robots/submissions');
}));
++ // The guided first run's funnel, reviewers only (OpenVibe.Node docs/install.md): the Node reports one
++ // node.first_run telemetry event per step, and Bot stores one row per device and step.
++ r.get('/admin/first-run', page(async (req, res) => {
++ const me = requireUser(req);
++ if (!isReviewer(me)) throw new BotError(404, 'bot.not_found', 'no such page');
++ res.setHeader('Content-Security-Policy', HOME_CSP);
++ res.type('html').send(renderFirstRunPage({ rows: await domain.devices.firstRunFunnel() }));
++ }));
++
r.post('/robots', ...manage, express.urlencoded({ extended: false, limit: '8kb' }), page(async (req, res) => {
const me = requireUser(req);
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
@@@ -591,8 +600,13 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const robotId = Array.isArray(d.robot_ids) ? d.robot_ids[0] : null;
const robot = robotId ? await domain.robots.get(robotId) : null;
const sensors = hub ? ((hub.deviceState(d.id).telemetry || {}).sensors || {}) : {};
++ // Read online first: the first-run query must not make a machine look offline because the page took longer.
++ const online = !!(hub && hub.isOnline(d.id));
++ // What the guided first run's one job found on this machine (Node's node.first_run first_job event).
++ const firstRun = await domain.devices.firstRunOf(d.id);
return {
-- ...d, online: !!(hub && hub.isOnline(d.id)), robot: robot ? robot.name : null,
++ ...d, online, robot: robot ? robot.name : null,
++ first_run: firstRun,
computer_level: typeof sensors.computer_level === 'string' ? sensors.computer_level : null,
computer_waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,
computer_screen_stream: sensors.computer_screen_stream === 'live' ? 'live' : 'off',
ddiff --git a/test/first-run.test.js b/test/first-run.test.js
nnew file mode 100644
iindex 0000000..488497d
---- /dev/null
++++ b/test/first-run.test.js
@@@ -0,0 +1,105 @@
++'use strict';
++// The guided first run, measured (OpenVibe.Node's `openvibe-node`): the Node sends one `node.first_run` telemetry
++// event per step (installed, paired, self_check with ok or the reason, first_job with the machine's counts). Bot stores
++// one row per device and step, the machine's page shows what its first job found, and a reviewer sees the funnel.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot();
++ const alex = t.network.newUser('alex');
++ const ada = t.network.newUser('ada');
++ ada.role = 'admin';
++ const cookie = (u) => ({ Cookie: `ov_token=${t.network.signUser(u)}` });
++ const get = (p, u) => fetch(t.base + p, { redirect: 'manual', headers: u ? cookie(u) : {} });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ const poll = async (fn, ms = 5000) => {
++ const end = Date.now() + ms;
++ for (;;) {
++ const v = await fn();
++ if (v) return v;
++ if (Date.now() > end) return null;
++ await wait(50);
++ }
++ };
++ async function pairOverWs(robotId, code, capabilities = {}) {
++ const dev = await t.ws('/device');
++ dev.send({ type: 'pair', robot: robotId, code, agent_version: 'v0.39.0', device_kind: 'onboard', drivers: ['dryrun'],
++ capabilities: { worker: { runtime_classes: ['function'] }, ...capabilities } });
++ const paired = await dev.waitFor((m) => m.type === 'paired');
++ await dev.waitFor((m) => m.type === 'hello');
++ await dev.waitFor((m) => m.type === 'config');
++ return { dev, paired };
++ }
++ const rowsFor = (id) => t.db.many('SELECT * FROM device_first_run WHERE device_id = $1 ORDER BY at', [id]);
++
++ const { robot, pairing } = await t.robot(alex);
++ const { dev, paired } = await pairOverWs(robot.id, pairing.code);
++ const at = Date.now() - 60_000;
++ const event = (step, extra = {}) => ({ name: 'node.first_run', ts: at + ['installed', 'paired', 'self_check', 'first_job'].indexOf(step) * 1000,
++ fields: { step, version: 'v0.39.0', os: 'linux', arch: 'amd64', ...extra } });
++
++ await check('one node.first_run event per step is stored, with the machine counts and no personal fields', async () => {
++ dev.send({ type: 'telemetry', events: [
++ event('installed'),
++ event('paired'),
++ event('self_check', { ok: true }),
++ event('first_job', { cpus: 8, memory_total_bytes: 16 << 30, memory_free_bytes: 8 << 30,
++ disk_total_bytes: 100 << 30, disk_free_bytes: 40 << 30,
++ // A buggy client's extra fields are never stored.
++ path: '/home/alice', user: 'alice', ip: '10.0.0.7' }),
++ ] });
++ const rows = await poll(async () => { const r = await rowsFor(paired.device_id); return r.length === 4 ? r : null; });
++ assert.ok(rows, 'the four steps were stored');
++ assert.deepStrictEqual(rows.map((r) => r.step), ['installed', 'paired', 'self_check', 'first_job']);
++ assert.strictEqual(rows[0].agent_version, 'v0.39.0');
++ assert.strictEqual(rows[0].os, 'linux');
++ assert.strictEqual(rows[2].ok, true);
++ assert.strictEqual(rows[3].ok, null);
++ assert.strictEqual(rows[3].info.cpus, 8);
++ assert.strictEqual(rows[3].info.memory_total_bytes, 16 << 30);
++ assert.strictEqual(rows[3].info.disk_free_bytes, 40 << 30);
++ assert.deepStrictEqual(Object.keys(rows[3].info).sort(), ['arch', 'cpus', 'disk_free_bytes', 'disk_total_bytes', 'memory_free_bytes', 'memory_total_bytes']);
++ const stored = JSON.stringify(rows);
++ assert.ok(!stored.includes('/home/alice') && !stored.includes('alice') && !stored.includes('10.0.0.7'), 'no path, user or address is stored');
++ });
++
++ await check('a failed self-check keeps its reason for the funnel; a resend updates the step instead of adding one', async () => {
++ dev.send({ type: 'telemetry', events: [event('self_check', { ok: false, reason: 'OpenVibe.Bot answered HTTP 503' })] });
++ const failed = await poll(async () => { const r = await rowsFor(paired.device_id); const s = r.find((x) => x.step === 'self_check'); return s && s.ok === false ? s : null; });
++ assert.ok(failed, 'the failure replaced the pass');
++ assert.strictEqual(failed.reason, 'OpenVibe.Bot answered HTTP 503');
++ dev.send({ type: 'telemetry', events: [event('self_check', { ok: false, reason: 'bad\u0000line' }), { name: 'other', ts: at, fields: { step: 'installed' } },
++ { name: 'node.first_run', ts: at, fields: { step: 'not_a_step' } }] });
++ const cleaned = await poll(async () => { const r = await rowsFor(paired.device_id); const s = r.find((x) => x.step === 'self_check'); return s && s.reason === 'bad line' ? s : null; });
++ assert.ok(cleaned, 'control characters are cleaned from the reason');
++ const rows = await rowsFor(paired.device_id);
++ assert.strictEqual(rows.length, 4, 'ignored events added no rows and the resend added none');
++ });
++
++ await check('the owner’s machines page shows what the first job found', async () => {
++ const page = await (await get('/machines', alex)).text();
++ assert.ok(page.includes('First run'), page.slice(0, 500));
++ assert.ok(page.includes('8 CPUs') && page.includes('free disk') && page.includes('linux/amd64'), 'the machine figures are shown');
++ assert.ok(!page.includes('/home/alice'));
++ });
++
++ await check('only a reviewer reads the funnel, and it counts machines and the time between steps', async () => {
++ const normal = await get('/admin/first-run', alex);
++ assert.strictEqual(normal.status, 404);
++ const anon = await get('/admin/first-run');
++ assert.notStrictEqual(anon.status, 200, 'signed out: not the funnel');
++ const r = await get('/admin/first-run', ada);
++ assert.strictEqual(r.status, 200);
++ const page = await r.text();
++ assert.ok(page.includes('First run') && page.includes('The funnel'), 'the funnel page');
++ assert.ok(page.includes('First job done') && page.includes('Review'), `the steps are listed: ${page.slice(0, 900)}`);
++ assert.ok(page.includes('1 s') || page.includes('under a second'), 'the median between steps (the fixture is 1 s apart)');
++ assert.match(page, new RegExp(paired.device_id), 'the recent machine');
++ assert.ok(page.includes('0 self-checks passed, 1 failed') || page.includes('1 failed'), 'the failed self-check is counted as reached');
++ });
++
++ dev.close();
++ await t.close();
++ done();
++})();