openvibers/openvibe.actor Commits
Actor: the Console — tasks now, what waits for you, today, your agents, computers and robots, and one box to start a task (#54)
Changes
@@@ -102,6 +102,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu- **OpenVibe.Search** (`ACTOR_SEARCH_URL`): content across the network, for the runtime's `search_openvibe` tool.- **OpenVibe.Tools** (`ACTOR_TOOLS_URL`): the catalog, the run API and the page reader, for `find_tools`, `run_tool` and `read_page`. Called as any anonymous caller would.- **OpenVibe.Bot** (`ACTOR_BOT_URL`, default `http://127.0.0.1:4630`; empty turns it off): a signed-in person's own robots, for `list_my_robots` and `robot_steps`. Actor calls Bot with its own client-credentials token (audience `openvibe.bot`, `bot.robot.read` + `bot.robot.control`) and `X-OV-Subject` naming the person. Bot answers only for the robot's owner, and only on robots where that owner turned on "Let my OpenVibe.Actor agent drive this robot". Bot's gate, limits and e-stop judge every step. App, agent and service tasks never see these tools.++- **The Console** (`/console`, plan T17 layer 3): one page for a signed-in person. It has a command box (the ordinary task form) and their running and recent tasks; it refreshes itself while something runs, never while they type. It shows what waits for them: their approvals, and approvals waiting on their own computers. It also shows today's spend against the free tier, their agents, and their computers and robots, read from OpenVibe.Bot as them with a 4 s bound, so a slow Bot never holds the page. Links go to Services, Watch and Codes for the rest. It works without JavaScript (`server/http/console-pages.js`, `test/console.test.js`).- **A person's own computers, also through OpenVibe.Bot** (plan T14/T17, contracts 0.166.0): `list_my_computers`, `computer_list`, `computer_read`, `computer_run` (argv, never a shell line) and `computer_write`, on machines installed with OpenVibe Node's `--computer` plugin. Actor calls Bot's machine API with its own token (`bot.computer.control`) and `X-OV-Subject` naming the person. Bot answers only for the machine's owner, and only where that owner turned on "Let my agent use this computer". The level the owner set on the machine decides what may happen there; at `ask`, each run or write waits up to two minutes for the owner's approval on the machine. Only a signed-in person's task sees these tools (`server/agents/computers.js`, `test/computers.test.js`).- **OpenVibe.Events** (`ACTOR_EVENTS_URL`): the two ADR-033 subscriptions created at boot, and delivery to the loopback `/internal/events`.- **The model providers**: DeepSeek (`ACTOR_DEEPSEEK_API_KEY`, `api.deepseek.com`), OpenAI (`ACTOR_OPENAI_API_KEY`, `api.openai.com/v1`) for the web-search agent and the cross-family check, and an OpenAI-compatible local server (`ACTOR_LOCAL_LLM_URL`) for private mode.nnew file mode 100644@@@ -0,0 +1,58 @@++/* The Console (server/http/console-pages.js): a command box over a grid of cards. */++.cn-head { margin: 1.25rem 0 1rem; }++.cn-eyebrow { margin: 0; font-size: 13px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--accent-light, #60a5fa); }++.cn-head h1 { margin: .2rem 0 .35rem; }++.cn-lede { margin: 0; color: var(--text-secondary, #96a7c2); max-width: 46rem; }++.cn-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }++++.cn-command { display: grid; gap: .6rem; margin: 0 0 1.25rem; padding: .9rem; border-radius: 14px; border: 1px solid var(--border-color, rgba(255,255,255,.12)); background: var(--bg-card, #131c2e); }++.cn-command textarea { width: 100%; box-sizing: border-box; min-height: 3.2rem; resize: vertical; padding: .7rem .8rem; border-radius: 10px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-primary, #0b1220); color: var(--text-primary, #e6edf7); font: inherit; line-height: 1.45; }++.cn-command textarea:focus { border-color: var(--accent, #3b82f6); outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 25%, transparent); }++.cn-command-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: .6rem; }++.cn-mode { display: inline-flex; align-items: center; gap: .45rem; font-size: 14px; color: var(--text-secondary, #96a7c2); }++.cn-run { display: inline-flex; align-items: center; justify-content: center; min-height: 2.5rem; padding: 0 1.2rem; border: 0; border-radius: 10px; background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); font: inherit; font-weight: 700; text-decoration: none; cursor: pointer; }++.cn-run:hover { filter: brightness(1.1); }++.cn-mode select { min-height: 2.4rem; padding: 0 .5rem; border-radius: 8px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-primary, #0b1220); color: var(--text-primary, #e6edf7); font: inherit; }++++.cn-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .9rem; margin-bottom: 2rem; }++.cn-card { display: flex; flex-direction: column; gap: .5rem; min-width: 0; padding: 1rem 1.05rem; border-radius: 14px; border: 1px solid var(--border-color, rgba(255,255,255,.12)); background: var(--bg-card, #131c2e); }++.cn-card h2 { display: flex; align-items: center; gap: .5rem; margin: 0 0 .15rem; font-size: 1rem; }++.cn-wide { grid-column: span 2; }++.cn-full { grid-column: 1 / -1; }++.cn-full .cn-links { grid-template-columns: repeat(3, minmax(0, 1fr)); column-gap: 1.25rem; }++.cn-full .cn-links li { border-top: 0; }++.cn-attn { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); }++.cn-count { display: inline-flex; align-items: center; justify-content: center; min-width: 1.5rem; height: 1.5rem; padding: 0 .4rem; border-radius: 999px; background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); font-size: 12px; font-weight: 700; }++.cn-empty, .cn-note { margin: 0; color: var(--text-secondary, #96a7c2); font-size: 14px; }++.cn-more { margin: auto 0 0; padding-top: .35rem; font-size: 14px; }++++.cn-tasks, .cn-list, .cn-links { list-style: none; margin: 0; padding: 0; display: grid; gap: .1rem; }++.cn-task { display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-areas: "pill text" ". meta"; column-gap: .6rem; align-items: baseline; padding: .5rem 0; border-top: 1px solid var(--border-color, rgba(255,255,255,.08)); }++.cn-task:first-child { border-top: 0; }++.cn-task .pill { grid-area: pill; white-space: nowrap; }++.cn-task a { grid-area: text; color: var(--text-primary, #e6edf7); text-decoration: none; overflow-wrap: anywhere; }++.cn-task a:hover, .cn-task a:focus-visible { color: var(--accent-light, #60a5fa); text-decoration: underline; }++.cn-task .cn-meta { grid-area: meta; }++.cn-meta { color: var(--text-secondary, #96a7c2); font-size: 13px; }++.cn-list li, .cn-links li { display: flex; flex-wrap: wrap; align-items: center; gap: .2rem .55rem; padding: .45rem 0; border-top: 1px solid var(--border-color, rgba(255,255,255,.08)); }++.cn-list li:first-child, .cn-links li:first-child { border-top: 0; }++.cn-list a, .cn-links a { color: var(--text-primary, #e6edf7); }++.cn-links li { flex-direction: column; align-items: flex-start; }++.cn-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-secondary, #96a7c2); opacity: .5; flex: none; }++.cn-dot.on { background: var(--success, #22c55e); opacity: 1; }++++.cn-stat { margin: .1rem 0 0; color: var(--text-secondary, #96a7c2); }++.cn-stat b { color: var(--text-primary, #e6edf7); font-size: 1.25rem; font-variant-numeric: tabular-nums; }++.cn-meter { height: 6px; border-radius: 999px; background: color-mix(in srgb, var(--text-secondary, #96a7c2) 20%, transparent); overflow: hidden; }++.cn-meter span { display: block; height: 100%; border-radius: inherit; background: var(--accent, #3b82f6); }++++@media (max-width: 60rem) { .cn-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }++@media (max-width: 40rem) {++ .cn-grid { grid-template-columns: minmax(0, 1fr); }++ .cn-wide { grid-column: auto; }++ .cn-full .cn-links { grid-template-columns: minmax(0, 1fr); }++ .cn-full .cn-links li { border-top: 1px solid var(--border-color, rgba(255,255,255,.08)); }++ .cn-full .cn-links li:first-child { border-top: 0; }++ .cn-command-row { justify-content: space-between; }++ .cn-command textarea { min-height: 5.4rem; }++}nnew file mode 100644@@@ -0,0 +1,11 @@++// The Console refreshes itself while a task runs (server/http/console-pages.js), but never under a person who is typing++// a task: it waits until the command box is empty and not focused.++(function () {++ 'use strict';++ var head = document.querySelector('[data-live]');++ if (!head) return;++ var every = Math.max(5, Number(head.getAttribute('data-live')) || 10) * 1000;++ var box = document.getElementById('cn-task');++ function busy() { return box && (document.activeElement === box || box.value.trim() !== ''); }++ setInterval(function () { if (!busy() && document.visibilityState === 'visible') location.reload(); }, every);++})();@@@ -32,6 +32,7 @@ const { createApi } = require('./http/api');const resourceIndex = require('./registry/resource-index');const { createPageRoutes } = require('./http/pages');const { createAgentPages } = require('./http/agent-pages');++const { createConsolePages } = require('./http/console-pages');const { createActorReadiness } = require('./observability');const { createCallerLimits } = require('./http/caller-limits');const accountDataLib = require('./identity/account-data');@@@ -46,7 +47,7 @@ const VERSION = require('../package.json').version;* opts: config, store, now (clock), fetchImpl (Network), providerFetch (model providers and OpenVibe services), log,* limitsNow, callerLimits (false: count nobody, tests only), valkey, adapters (tests: stand-in agents),* accountSend (a stand-in for Network's internal routes; tests only), webhookPost (a stand-in for the guarded webhook-- * poster; tests only)++ * poster; tests only), bot ({ robots, computers }: stand-ins for the Console's OpenVibe.Bot reads; tests only)*/async function createApp(opts = {}) {const config = opts.config || configLib.load();@@@ -157,6 +158,7 @@ async function createApp(opts = {}) {// ── Pages ───────────────────────────────────────────────app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.ACTOR_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));app.use(createAgentPages(ctx));++ app.use(createConsolePages(ctx, opts.bot || {}));app.use(createPageRoutes(ctx));app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/">giving Actor a task</a>, <a href="/agents">the agents</a> or <a href="/docs">the API</a>.</p>` }));nnew file mode 100644@@@ -0,0 +1,160 @@++'use strict';++++/**++ * The Console (plan T17 layer 3): one page where a signed-in person sees what their agents, tasks, computers and robots++ * are doing, and tells them what to do next. It is the command interface, not an authority: tasks, agents and approvals++ * are Actor's own; computers and robots are read from OpenVibe.Bot as the person (agents/computers.js, agents/robots.js),++ * and everything else links to the service that owns it (OpenVibe.Services for resources, Watch, Codes).++ *++ * It works without JavaScript: the command box is the ordinary task form (POST /tasks), and while a task is running the++ * page refreshes itself every 10 seconds (a <noscript> refresh; public/js/console.js does it with JavaScript, never++ * while the person is typing a task).++ */++const cache = require('openvibe-shared/cache-policy');++const { asyncRouter } = require('./router');++const { html, raw, time } = require('../render/html');++const { send } = require('../render/layout');++const store = require('../tasks/store');++const personal = require('../agents/personal');++const approvals = require('../agents/approvals');++const catalog = require('../agents/catalog');++const { MODES } = require('../agents/router');++const { createRobots } = require('../agents/robots');++const { createComputers } = require('../agents/computers');++++const LIVE = new Set(['queued', 'running', 'verifying']);++const STATE_TEXT = { queued: 'Waiting', running: 'Working', verifying: 'Checking', succeeded: 'Done', failed: 'Didn\'t finish', cancelled: 'Cancelled' };++const LEVEL_TEXT = { observe: 'Observe', ask: 'Ask first', trusted: 'Trusted', full: 'Full control' };++const MODE_LABEL = { cheapest: 'Cheapest', balanced: 'Balanced', best: 'Best', fastest: 'Fastest', private: 'Private' };++const BOT_TIMEOUT_MS = 4000;++++const usd = (n) => (n == null ? '—' : n === 0 ? '$0' : n < 0.01 ? `$${Number(n).toFixed(4)}` : `$${Number(n).toFixed(2)}`);++const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);++const pct = (a, b) => (b > 0 ? Math.max(0, Math.min(100, Math.round((a / b) * 100))) : 0);++++function createConsolePages(ctx, { robots = null, computers = null } = {}) {++ const { config, s } = ctx;++ const r = asyncRouter();++ const bot = { robots: robots || createRobots({ config }), computers: computers || createComputers({ config }) };++ const tier = config.allowance;++ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, css: ['css/console.css'], ...o });++ const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;++ const pages = config.services.botPublicUrl || 'https://openvibe.bot';++++ /** One Bot read, bounded so a slow Bot never holds the page; { ok, value } or { ok: false, why }. */++ async function fromBot(fn) {++ try {++ const value = await fn({ signal: AbortSignal.timeout(BOT_TIMEOUT_MS) });++ if (value && !Array.isArray(value) && value.error) return { ok: false, why: value.error };++ return { ok: true, value };++ } catch { return { ok: false, why: 'OpenVibe.Bot did not answer in time.' }; }++ }++++ function commandBox() {++ return html`<form class="cn-command" method="post" action="/tasks" aria-labelledby="cn-command-h">++<h2 id="cn-command-h" class="cn-sr">Give Actor a task</h2>++<label class="cn-sr" for="cn-task">What should Actor do?</label>++<textarea id="cn-task" name="task" rows="2" maxlength="20000" required placeholder="Tell Actor what to do: “check my site's certificate”, “what changed in my repo today?”, “drive my rover to the door”"></textarea>++<div class="cn-command-row"><label class="cn-mode">Mode <select name="mode">${MODES.map((m) => html`<option value="${m}"${m === 'balanced' ? raw(' selected') : ''}>${MODE_LABEL[m]}</option>`)}</select></label>++<button class="cn-run" type="submit">Run it</button></div>++</form>`;++ }++++ const taskRow = (t) => {++ const result = t.result ? (typeof t.result === 'string' ? JSON.parse(t.result) : t.result) : null;++ const agent = result ? ((catalog.byId(result.agent) || {}).name || result.agent) : null;++ return html`<li class="cn-task"><span class="pill pill-${t.state}">${STATE_TEXT[t.state] || t.state}</span>++<a href="/tasks/${t.id}">${clip(t.task, 110)}</a>++<span class="cn-meta">${agent ? html`${agent} · ` : ''}${t.cost_usd == null ? '' : html`${usd(Number(t.cost_usd))} · `}${time(t.created_at)}</span></li>`;++ };++++ function computersCard(result) {++ const body = !bot.computers.enabled ? html`<p class="cn-empty">Not connected to OpenVibe.Bot on this server.</p>`++ : !result.ok ? html`<p class="cn-empty">${result.why}</p>`++ : !result.value.computers.length ? html`<p class="cn-empty">No computer is open to your agent. Add one on <a href="${pages}/machines">openvibe.bot/machines</a> with <code>--computer ask</code>, then turn on “Let my agent use this computer”.</p>`++ : html`<ul class="cn-list">${result.value.computers.map((c) => html`<li><span class="cn-dot${c.online ? ' on' : ''}" aria-hidden="true"></span><b>${c.name || c.id}</b>++<span class="cn-meta">${c.online ? 'Online' : 'Offline'} · ${LEVEL_TEXT[c.level] || 'On'}${c.can.length ? html` · ${c.can.join(', ')}` : ''}</span>++${c.waiting_for_approval ? html`<span class="pill pill-running">${String(c.waiting_for_approval)} waiting on the machine</span>` : ''}</li>`)}</ul>`;++ return html`<section class="cn-card" aria-labelledby="cn-computers"><h2 id="cn-computers">Your computers</h2>${body}++<p class="cn-more"><a href="${pages}/machines">Machines on OpenVibe.Bot</a></p></section>`;++ }++++ function robotsCard(result) {++ const body = !bot.robots.enabled ? html`<p class="cn-empty">Not connected to OpenVibe.Bot on this server.</p>`++ : !result.ok ? html`<p class="cn-empty">${result.why}</p>`++ : !result.value.length ? html`<p class="cn-empty">No robot yet. <a href="${pages}/try">Try one in your browser</a> or add yours on <a href="${pages}">openvibe.bot</a>.</p>`++ : html`<ul class="cn-list">${result.value.map((x) => html`<li><span class="cn-dot${x.online ? ' on' : ''}" aria-hidden="true"></span><a href="${x.panel}"><b>${x.name}</b></a>++<span class="cn-meta">${x.agent_control ? (x.online ? 'Online' : 'Offline') : 'Not open to your agent'}${x.estop && x.estop.latched ? ' · e-stop on' : ''}${typeof x.battery === 'number' ? ` · battery ${Math.round(x.battery * 100)}%` : ''}</span></li>`)}</ul>`;++ return html`<section class="cn-card" aria-labelledby="cn-robots"><h2 id="cn-robots">Your robots</h2>${body}++<p class="cn-more"><a href="${pages}/robots">Robots on OpenVibe.Bot</a></p></section>`;++ }++++ r.get('/console', async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ if (!signedIn(req)) {++ return page(req, res, {++ title: 'Console', crumbs: [{ label: 'Console' }],++ body: html`<header class="cn-head"><p class="cn-eyebrow">OpenVibe.Actor · console</p><h1>Console</h1>++<p class="cn-lede">One place for everything your agents do: the tasks running now, what waits for your approval, your agents, your own computers and robots, and what it all cost today.</p></header>++<p><a class="cn-run" href="/auth/login?next=%2Fconsole">Sign in to open your console</a></p>`,++ });++ }++ const requester = `user:${req.viewer.subject}`;++ const subject = req.viewer.subject;++ const [tasks, today, asked, agents, pcs, rovers] = await Promise.all([++ store.listTasks(s, requester, { limit: 30 }),++ store.spendToday(s, requester),++ approvals.list(s, requester),++ personal.list(s, requester),++ bot.computers.enabled ? fromBot((o) => bot.computers.mine(subject, o)) : { ok: false },++ bot.robots.enabled ? fromBot((o) => bot.robots.mine(subject, o)) : { ok: false },++ ]);++ const live = tasks.rows.filter((t) => LIVE.has(t.state));++ const recent = tasks.rows.filter((t) => !LIVE.has(t.state)).slice(0, 6);++ const waiting = asked.filter((a) => a.state === 'pending');++ const onMachines = pcs.ok ? pcs.value.computers.reduce((n, c) => n + (c.waiting_for_approval || 0), 0) : 0;++ const nWaiting = waiting.length + onMachines;++ page(req, res, {++ title: 'Console', crumbs: [{ label: 'Console' }],++ scripts: live.length ? ['js/console.js'] : [],++ body: html`${live.length ? raw('<noscript><meta http-equiv="refresh" content="10"></noscript>') : ''}<header class="cn-head"${live.length ? raw(' data-live="10"') : ''}><p class="cn-eyebrow">OpenVibe.Actor · console · alpha</p><h1>Console</h1>++<p class="cn-lede">What your agents, computers and robots are doing, and one box to tell them what to do next.</p></header>++${commandBox()}++<div class="cn-grid">++<section class="cn-card cn-wide" aria-labelledby="cn-now"><h2 id="cn-now">Now${live.length ? html` <span class="cn-count">${String(live.length)}</span>` : ''}</h2>++${live.length ? html`<ul class="cn-tasks">${live.map(taskRow)}</ul><p class="cn-note">This page refreshes every 10 seconds while something runs.</p>` : html`<p class="cn-empty">Nothing is running.</p>`}</section>++<section class="cn-card${nWaiting ? ' cn-attn' : ''}" aria-labelledby="cn-waiting"><h2 id="cn-waiting">Waiting for you${nWaiting ? html` <span class="cn-count">${String(nWaiting)}</span>` : ''}</h2>++${waiting.length ? html`<ul class="cn-list">${waiting.slice(0, 4).map((a) => html`<li><a href="/me/approvals/${a.id}"><b>${clip(a.summary, 80)}</b></a><span class="cn-meta">Asked ${time(a.created_at)}</span></li>`)}</ul>` : ''}++${onMachines ? html`<p class="cn-note">${String(onMachines)} on your computers: answer them there with <code>openvibe-node approvals</code>.</p>` : ''}++${nWaiting ? '' : html`<p class="cn-empty">Nothing waits for you.</p>`}++<p class="cn-more"><a href="/me/approvals">All approvals</a></p></section>++<section class="cn-card" aria-labelledby="cn-today"><h2 id="cn-today">Today</h2>++<p class="cn-stat"><b>${String(today.tasks)}</b> of ${String(tier.tasksPerDay)} free tasks</p>++<div class="cn-meter" role="progressbar" aria-label="Free tasks used today" aria-valuemin="0" aria-valuemax="${String(tier.tasksPerDay)}" aria-valuenow="${String(today.tasks)}"><span style="width:${String(pct(today.tasks, tier.tasksPerDay))}%"></span></div>++<p class="cn-stat"><b>${usd(today.usd)}</b> of ${usd(tier.perDayUsd)}</p>++<div class="cn-meter" role="progressbar" aria-label="Spent today" aria-valuemin="0" aria-valuemax="${String(tier.perDayUsd)}" aria-valuenow="${String(today.usd)}"><span style="width:${String(pct(today.usd, tier.perDayUsd))}%"></span></div>++<p class="cn-more"><a href="/tasks">Your tasks</a></p></section>++<section class="cn-card cn-wide" aria-labelledby="cn-recent"><h2 id="cn-recent">Recent tasks</h2>++${recent.length ? html`<ul class="cn-tasks">${recent.map(taskRow)}</ul>` : html`<p class="cn-empty">No finished task yet. Give Actor one above.</p>`}++<p class="cn-more"><a href="/tasks">All your tasks</a></p></section>++<section class="cn-card" aria-labelledby="cn-agents"><h2 id="cn-agents">Your agents</h2>++${agents.length ? html`<ul class="cn-list">${agents.slice(0, 5).map((a) => html`<li><span class="cn-dot${a.status === 'paused' ? '' : ' on'}" aria-hidden="true"></span><a href="/me/agents/${a.id}"><b>${a.name}</b></a>++<span class="cn-meta">${a.status === 'paused' ? 'Paused' : a.schedule === 'off' ? 'Runs when you run it' : `Every ${a.schedule === 'hourly' ? 'hour' : a.schedule === 'daily' ? 'day' : 'week'}`}${a.last_run_at ? html` · last ran ${time(a.last_run_at)}` : ''}</span></li>`)}</ul>`++ : html`<p class="cn-empty">No agent yet. An agent is a task you set up once, with a memory and a schedule.</p>`}++<p class="cn-more"><a href="/me/agents">${agents.length ? 'All your agents' : 'Create an agent'}</a></p></section>++${computersCard(pcs)}++${robotsCard(rovers)}++<section class="cn-card cn-full" aria-labelledby="cn-elsewhere"><h2 id="cn-elsewhere">Across OpenVibe</h2>++<ul class="cn-links">++<li><a href="https://openvibe.services/projects">Your projects and resources</a><span class="cn-meta">OpenVibe.Services: suspend, resume or delete what you own</span></li>++<li><a href="https://openvibe.watch">Your watches</a><span class="cn-meta">OpenVibe.Watch: wake an agent when something changes</span></li>++<li><a href="https://openvibe.codes/run">Coding on your machine</a><span class="cn-meta">OpenVibe.Codes: tasks on your own computer</span></li>++</ul></section>++</div>`,++ });++ });++++ return r;++}++++module.exports = { createConsolePages };@@@ -22,6 +22,7 @@ const NETWORK_URL = 'https://openvibe.network';const SITE_NAME = 'OpenVibe.Actor';const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');const NAV = [++ { label: 'Console', href: '/console' },{ label: 'Your tasks', href: '/tasks' },{ label: 'Agents', href: '/agents' },{ label: 'API', href: '/docs' },@@@ -65,7 +66,7 @@ function renderPage(o) {};// This site's own account links live in the shared navbar's account menu (the page's account// bar below is only for visitors without JavaScript).-- if (signedIn) nav.menu = { before: [{ label: 'Your tasks', href: '/tasks', icon: 'fa-list-check' }, { label: 'Your agents', href: '/me/agents', icon: 'fa-robot' }] };++ if (signedIn) nav.menu = { before: [{ label: 'Console', href: '/console', icon: 'fa-gauge' }, { label: 'Your tasks', href: '/tasks', icon: 'fa-list-check' }, { label: 'Your agents', href: '/me/agents', icon: 'fa-robot' }] };const footer = { service: 'actor', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };const account = signedIn? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`nnew file mode 100644@@@ -0,0 +1,103 @@++'use strict';++/**++ * The Console (server/http/console-pages.js, plan T17 layer 3): signed out it explains itself; signed in it shows the++ * person's running and recent tasks, what waits for them (their approvals, and approvals waiting on their own++ * computers), today's spend, their agents, their computers and robots read from OpenVibe.Bot as them, and links to the++ * services that own the rest. A slow or failing Bot never breaks the page. Only the person's own rows appear.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const store = require('../server/tasks/store');++const personal = require('../server/agents/personal');++const approvals = require('../server/agents/approvals');++++(async () => {++ const asked = [];++ let botMode = 'ok';++ const bot = {++ computers: {++ enabled: true,++ mine: async (subject) => {++ asked.push(['computers', subject]);++ if (botMode === 'fail') return { error: 'Bot answered 503 for your computers' };++ return { computers: [{ id: 'dev_01M4W5JBFDG2TJ8C4FRDMCWBZ9', name: 'desk-pc', online: true, level: 'ask', can: ['terminal', 'files'], folders: ['/home/ada'], waiting_for_approval: 2 }] };++ },++ },++ robots: {++ enabled: true,++ mine: async (subject, { signal }) => {++ asked.push(['robots', subject]);++ if (botMode === 'fail') return await new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(new Error('aborted'))));++ return [{ id: 'rob_01M4W5JBFDG2TJ8C4FRDMCWBZ9', name: 'Desk rover', agent_control: true, online: true, estop: { latched: false }, battery: 0.8, panel: 'https://openvibe.bot/panel/rob_01M4W5JBFDG2TJ8C4FRDMCWBZ9' }];++ },++ },++ };++ const t = await boot({ bot });++ try {++ const ada = t.network.addUser('ada');++ const bo = t.network.addUser('bo');++ const owner = `user:${ada.subject}`;++ const task = async (who, text, state) => {++ const id = t.ctx.s.newId('tsk');++ await store.insertTask(t.ctx.s, { id, requester: who, task: text, mode: 'balanced', budget_task: 0.05, budget_day: 0.5, created_at: t.ctx.s.iso() });++ if (state !== 'queued') await store.updateTask(t.ctx.s, id, { state, ...(state === 'succeeded' ? { cost_usd: 0.0012, finished_at: t.ctx.s.iso() } : {}) });++ return id;++ };++++ await check('signed out, the console says what it is and how to open it', async () => {++ const r = await t.get('/console');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /Sign in to open your console/);++ assert.ok(!asked.length, 'nothing asked of Bot for nobody');++ });++++ await check('signed in: running and recent tasks, what waits, today, agents, computers, robots and the rest', async () => {++ const running = await task(owner, 'Check the certificate of example.com', 'running');++ await task(owner, 'Summarise the Node.js release notes', 'succeeded');++ await task(`user:${bo.subject}`, 'Bo\'s private task', 'running');++ await approvals.request(t.ctx.s, { owner, kind: 'codes.task', summary: 'Fix the failing build in ada/site', payload: { task: 'fix it' } });++ await personal.create(t.ctx.s, t.config, owner, { name: 'Morning briefing', instructions: 'Summarise what shipped.', mode: 'balanced', budget_per_run_usd: 0.02, daily_runs_max: 1, schedule: 'daily', schedule_at: '08:00', status: 'active' });++ const r = await t.get('/console', { as: ada });++ assert.strictEqual(r.status, 200);++ const h = r.text;++ assert.match(h, /<h1>Console<\/h1>/);++ assert.match(h, /<form class="cn-command" method="post" action="\/tasks"/);++ assert.ok(h.includes(`href="/tasks/${running}"`) && h.includes('Check the certificate of example.com'));++ assert.match(h, /data-live="10"/, 'refreshes while something runs');++ assert.match(h, /Summarise the Node\.js release notes/);++ assert.ok(!h.includes('Bo's private task') && !h.includes("Bo's private task"), 'only the person\'s own tasks');++ assert.match(h, /Fix the failing build in ada\/site/);++ assert.match(h, /2 on your computers/);++ assert.match(h, /cn-count">3</, 'one approval here and two on the machine');++ assert.match(h, /Morning briefing/);++ assert.match(h, /desk-pc[\s\S]*Ask first · terminal, files/);++ assert.match(h, /Desk rover[\s\S]*battery 80%/);++ assert.match(h, /openvibe\.services\/projects/);++ assert.deepStrictEqual(asked.filter((a) => a[1] !== ada.subject), [], 'Bot is asked as the person only');++ });++++ await check('a failing or slow Bot leaves the rest of the page working', async () => {++ botMode = 'fail';++ const started = Date.now();++ const r = await t.get('/console', { as: ada });++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /Bot answered 503 for your computers/);++ assert.match(r.text, /OpenVibe\.Bot did not answer in time/);++ assert.match(r.text, /Morning briefing/);++ assert.ok(Date.now() - started < 8000, 'bounded by the Bot timeout');++ botMode = 'ok';++ });++++ await check('nothing running: no refresh; nothing waiting says so', async () => {++ const r = await t.get('/console', { as: bo });++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /data-live="10"/, 'Bo has a running task');++ const cy = t.network.addUser('cy');++ const quiet = await t.get('/console', { as: cy });++ assert.doesNotMatch(quiet.text, /data-live=/);++ assert.match(quiet.text, /Nothing is running\./);++ assert.match(quiet.text, /No agent yet/);++ });++ } finally { await t.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -42,7 +42,7 @@ async function boot(opts = {}) {const { createStore } = require('../../server/db');const testdb = await require('./db').testDb();const store = createStore(testdb.db, { now: opts.now });-- const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters, accountSend: opts.accountSend, webhookPost: opts.webhookPost });++ const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters, accountSend: opts.accountSend, webhookPost: opts.webhookPost, bot: opts.bot });await built.ctx.keys.ensure();const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;
@@ -102,6 +102,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
@@ -102,6 +102,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
- **OpenVibe.Search** (`ACTOR_SEARCH_URL`): content across the network, for the runtime's `search_openvibe` tool.
- **OpenVibe.Search** (`ACTOR_SEARCH_URL`): content across the network, for the runtime's `search_openvibe` tool.
- **OpenVibe.Tools** (`ACTOR_TOOLS_URL`): the catalog, the run API and the page reader, for `find_tools`, `run_tool` and `read_page`. Called as any anonymous caller would.
- **OpenVibe.Tools** (`ACTOR_TOOLS_URL`): the catalog, the run API and the page reader, for `find_tools`, `run_tool` and `read_page`. Called as any anonymous caller would.
- **OpenVibe.Bot** (`ACTOR_BOT_URL`, default `http://127.0.0.1:4630`; empty turns it off): a signed-in person's own robots, for `list_my_robots` and `robot_steps`. Actor calls Bot with its own client-credentials token (audience `openvibe.bot`, `bot.robot.read` + `bot.robot.control`) and `X-OV-Subject` naming the person. Bot answers only for the robot's owner, and only on robots where that owner turned on "Let my OpenVibe.Actor agent drive this robot". Bot's gate, limits and e-stop judge every step. App, agent and service tasks never see these tools.
- **OpenVibe.Bot** (`ACTOR_BOT_URL`, default `http://127.0.0.1:4630`; empty turns it off): a signed-in person's own robots, for `list_my_robots` and `robot_steps`. Actor calls Bot with its own client-credentials token (audience `openvibe.bot`, `bot.robot.read` + `bot.robot.control`) and `X-OV-Subject` naming the person. Bot answers only for the robot's owner, and only on robots where that owner turned on "Let my OpenVibe.Actor agent drive this robot". Bot's gate, limits and e-stop judge every step. App, agent and service tasks never see these tools.
+- **The Console** (`/console`, plan T17 layer 3): one page for a signed-in person. It has a command box (the ordinary task form) and their running and recent tasks; it refreshes itself while something runs, never while they type. It shows what waits for them: their approvals, and approvals waiting on their own computers. It also shows today's spend against the free tier, their agents, and their computers and robots, read from OpenVibe.Bot as them with a 4 s bound, so a slow Bot never holds the page. Links go to Services, Watch and Codes for the rest. It works without JavaScript (`server/http/console-pages.js`, `test/console.test.js`).
- **A person's own computers, also through OpenVibe.Bot** (plan T14/T17, contracts 0.166.0): `list_my_computers`, `computer_list`, `computer_read`, `computer_run` (argv, never a shell line) and `computer_write`, on machines installed with OpenVibe Node's `--computer` plugin. Actor calls Bot's machine API with its own token (`bot.computer.control`) and `X-OV-Subject` naming the person. Bot answers only for the machine's owner, and only where that owner turned on "Let my agent use this computer". The level the owner set on the machine decides what may happen there; at `ask`, each run or write waits up to two minutes for the owner's approval on the machine. Only a signed-in person's task sees these tools (`server/agents/computers.js`, `test/computers.test.js`).
- **A person's own computers, also through OpenVibe.Bot** (plan T14/T17, contracts 0.166.0): `list_my_computers`, `computer_list`, `computer_read`, `computer_run` (argv, never a shell line) and `computer_write`, on machines installed with OpenVibe Node's `--computer` plugin. Actor calls Bot's machine API with its own token (`bot.computer.control`) and `X-OV-Subject` naming the person. Bot answers only for the machine's owner, and only where that owner turned on "Let my agent use this computer". The level the owner set on the machine decides what may happen there; at `ask`, each run or write waits up to two minutes for the owner's approval on the machine. Only a signed-in person's task sees these tools (`server/agents/computers.js`, `test/computers.test.js`).
- **OpenVibe.Events** (`ACTOR_EVENTS_URL`): the two ADR-033 subscriptions created at boot, and delivery to the loopback `/internal/events`.
- **OpenVibe.Events** (`ACTOR_EVENTS_URL`): the two ADR-033 subscriptions created at boot, and delivery to the loopback `/internal/events`.
- **The model providers**: DeepSeek (`ACTOR_DEEPSEEK_API_KEY`, `api.deepseek.com`), OpenAI (`ACTOR_OPENAI_API_KEY`, `api.openai.com/v1`) for the web-search agent and the cross-family check, and an OpenAI-compatible local server (`ACTOR_LOCAL_LLM_URL`) for private mode.
- **The model providers**: DeepSeek (`ACTOR_DEEPSEEK_API_KEY`, `api.deepseek.com`), OpenAI (`ACTOR_OPENAI_API_KEY`, `api.openai.com/v1`) for the web-search agent and the cross-family check, and an OpenAI-compatible local server (`ACTOR_LOCAL_LLM_URL`) for private mode.
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+/* The Console (server/http/console-pages.js): a command box over a grid of cards. */
+.cn-head { margin: 1.25rem 0 1rem; }
+.cn-eyebrow { margin: 0; font-size: 13px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--accent-light, #60a5fa); }
+.cn-head h1 { margin: .2rem 0 .35rem; }
+.cn-lede { margin: 0; color: var(--text-secondary, #96a7c2); max-width: 46rem; }
+.cn-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+
+.cn-command { display: grid; gap: .6rem; margin: 0 0 1.25rem; padding: .9rem; border-radius: 14px; border: 1px solid var(--border-color, rgba(255,255,255,.12)); background: var(--bg-card, #131c2e); }
+.cn-command textarea { width: 100%; box-sizing: border-box; min-height: 3.2rem; resize: vertical; padding: .7rem .8rem; border-radius: 10px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-primary, #0b1220); color: var(--text-primary, #e6edf7); font: inherit; line-height: 1.45; }
+.cn-command textarea:focus { border-color: var(--accent, #3b82f6); outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 25%, transparent); }
+.cn-command-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: .6rem; }
+.cn-mode { display: inline-flex; align-items: center; gap: .45rem; font-size: 14px; color: var(--text-secondary, #96a7c2); }
+.cn-run { display: inline-flex; align-items: center; justify-content: center; min-height: 2.5rem; padding: 0 1.2rem; border: 0; border-radius: 10px; background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); font: inherit; font-weight: 700; text-decoration: none; cursor: pointer; }
+.cn-run:hover { filter: brightness(1.1); }
+.cn-mode select { min-height: 2.4rem; padding: 0 .5rem; border-radius: 8px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-primary, #0b1220); color: var(--text-primary, #e6edf7); font: inherit; }
+
+.cn-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .9rem; margin-bottom: 2rem; }
+.cn-card { display: flex; flex-direction: column; gap: .5rem; min-width: 0; padding: 1rem 1.05rem; border-radius: 14px; border: 1px solid var(--border-color, rgba(255,255,255,.12)); background: var(--bg-card, #131c2e); }
+.cn-card h2 { display: flex; align-items: center; gap: .5rem; margin: 0 0 .15rem; font-size: 1rem; }
+.cn-wide { grid-column: span 2; }
+.cn-full { grid-column: 1 / -1; }
+.cn-full .cn-links { grid-template-columns: repeat(3, minmax(0, 1fr)); column-gap: 1.25rem; }
+.cn-full .cn-links li { border-top: 0; }
+.cn-attn { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); }
+.cn-count { display: inline-flex; align-items: center; justify-content: center; min-width: 1.5rem; height: 1.5rem; padding: 0 .4rem; border-radius: 999px; background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); font-size: 12px; font-weight: 700; }
+.cn-empty, .cn-note { margin: 0; color: var(--text-secondary, #96a7c2); font-size: 14px; }
+.cn-more { margin: auto 0 0; padding-top: .35rem; font-size: 14px; }
+
+.cn-tasks, .cn-list, .cn-links { list-style: none; margin: 0; padding: 0; display: grid; gap: .1rem; }
+.cn-task { display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-areas: "pill text" ". meta"; column-gap: .6rem; align-items: baseline; padding: .5rem 0; border-top: 1px solid var(--border-color, rgba(255,255,255,.08)); }
+.cn-task:first-child { border-top: 0; }
+.cn-task .pill { grid-area: pill; white-space: nowrap; }
+.cn-task a { grid-area: text; color: var(--text-primary, #e6edf7); text-decoration: none; overflow-wrap: anywhere; }
+.cn-task a:hover, .cn-task a:focus-visible { color: var(--accent-light, #60a5fa); text-decoration: underline; }
+.cn-task .cn-meta { grid-area: meta; }
+.cn-meta { color: var(--text-secondary, #96a7c2); font-size: 13px; }
+.cn-list li, .cn-links li { display: flex; flex-wrap: wrap; align-items: center; gap: .2rem .55rem; padding: .45rem 0; border-top: 1px solid var(--border-color, rgba(255,255,255,.08)); }
+.cn-list li:first-child, .cn-links li:first-child { border-top: 0; }
+.cn-list a, .cn-links a { color: var(--text-primary, #e6edf7); }
+.cn-links li { flex-direction: column; align-items: flex-start; }
+.cn-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-secondary, #96a7c2); opacity: .5; flex: none; }
+.cn-dot.on { background: var(--success, #22c55e); opacity: 1; }
+
+.cn-stat { margin: .1rem 0 0; color: var(--text-secondary, #96a7c2); }
+.cn-stat b { color: var(--text-primary, #e6edf7); font-size: 1.25rem; font-variant-numeric: tabular-nums; }
+.cn-meter { height: 6px; border-radius: 999px; background: color-mix(in srgb, var(--text-secondary, #96a7c2) 20%, transparent); overflow: hidden; }
+.cn-meter span { display: block; height: 100%; border-radius: inherit; background: var(--accent, #3b82f6); }
+
+@media (max-width: 60rem) { .cn-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+@media (max-width: 40rem) {
+ .cn-grid { grid-template-columns: minmax(0, 1fr); }
+ .cn-wide { grid-column: auto; }
+ .cn-full .cn-links { grid-template-columns: minmax(0, 1fr); }
+ .cn-full .cn-links li { border-top: 1px solid var(--border-color, rgba(255,255,255,.08)); }
+ .cn-full .cn-links li:first-child { border-top: 0; }
+ .cn-command-row { justify-content: space-between; }
+ .cn-command textarea { min-height: 5.4rem; }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+// The Console refreshes itself while a task runs (server/http/console-pages.js), but never under a person who is typing
+// a task: it waits until the command box is empty and not focused.
+(function () {
+ 'use strict';
+ var head = document.querySelector('[data-live]');
+ if (!head) return;
+ var every = Math.max(5, Number(head.getAttribute('data-live')) || 10) * 1000;
+ var box = document.getElementById('cn-task');
+ function busy() { return box && (document.activeElement === box || box.value.trim() !== ''); }
+ setInterval(function () { if (!busy() && document.visibilityState === 'visible') location.reload(); }, every);
+})();
@@ -32,6 +32,7 @@ const { createApi } = require('./http/api');
@@ -32,6 +32,7 @@ const { createApi } = require('./http/api');
const resourceIndex = require('./registry/resource-index');
const resourceIndex = require('./registry/resource-index');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
const { createAgentPages } = require('./http/agent-pages');
const { createAgentPages } = require('./http/agent-pages');
+const { createConsolePages } = require('./http/console-pages');
const { createActorReadiness } = require('./observability');
const { createActorReadiness } = require('./observability');
const { createCallerLimits } = require('./http/caller-limits');
const { createCallerLimits } = require('./http/caller-limits');
const accountDataLib = require('./identity/account-data');
const accountDataLib = require('./identity/account-data');
@@ -46,7 +47,7 @@ const VERSION = require('../package.json').version;
@@ -46,7 +47,7 @@ const VERSION = require('../package.json').version;
* opts: config, store, now (clock), fetchImpl (Network), providerFetch (model providers and OpenVibe services), log,
* opts: config, store, now (clock), fetchImpl (Network), providerFetch (model providers and OpenVibe services), log,
* limitsNow, callerLimits (false: count nobody, tests only), valkey, adapters (tests: stand-in agents),
* limitsNow, callerLimits (false: count nobody, tests only), valkey, adapters (tests: stand-in agents),
* accountSend (a stand-in for Network's internal routes; tests only), webhookPost (a stand-in for the guarded webhook
* accountSend (a stand-in for Network's internal routes; tests only), webhookPost (a stand-in for the guarded webhook
- * poster; tests only)
+ * poster; tests only), bot ({ robots, computers }: stand-ins for the Console's OpenVibe.Bot reads; tests only)
*/
*/
async function createApp(opts = {}) {
async function createApp(opts = {}) {
const config = opts.config || configLib.load();
const config = opts.config || configLib.load();
@@ -157,6 +158,7 @@ async function createApp(opts = {}) {
@@ -157,6 +158,7 @@ async function createApp(opts = {}) {
// ── Pages ───────────────────────────────────────────────
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.ACTOR_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.ACTOR_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
app.use(createAgentPages(ctx));
app.use(createAgentPages(ctx));
+ app.use(createConsolePages(ctx, opts.bot || {}));
app.use(createPageRoutes(ctx));
app.use(createPageRoutes(ctx));
app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/">giving Actor a task</a>, <a href="/agents">the agents</a> or <a href="/docs">the API</a>.</p>` }));
app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/">giving Actor a task</a>, <a href="/agents">the agents</a> or <a href="/docs">the API</a>.</p>` }));
new file mode 100644
new file mode 100644
@@ -0,0 +1,160 @@
@@ -0,0 +1,160 @@
+'use strict';
+
+/**
+ * The Console (plan T17 layer 3): one page where a signed-in person sees what their agents, tasks, computers and robots
+ * are doing, and tells them what to do next. It is the command interface, not an authority: tasks, agents and approvals
+ * are Actor's own; computers and robots are read from OpenVibe.Bot as the person (agents/computers.js, agents/robots.js),
+ * and everything else links to the service that owns it (OpenVibe.Services for resources, Watch, Codes).
+ *
+ * It works without JavaScript: the command box is the ordinary task form (POST /tasks), and while a task is running the
+ * page refreshes itself every 10 seconds (a <noscript> refresh; public/js/console.js does it with JavaScript, never
+ * while the person is typing a task).
+ */
+const cache = require('openvibe-shared/cache-policy');
+const { asyncRouter } = require('./router');
+const { html, raw, time } = require('../render/html');
+const { send } = require('../render/layout');
+const store = require('../tasks/store');
+const personal = require('../agents/personal');
+const approvals = require('../agents/approvals');
+const catalog = require('../agents/catalog');
+const { MODES } = require('../agents/router');
+const { createRobots } = require('../agents/robots');
+const { createComputers } = require('../agents/computers');
+
+const LIVE = new Set(['queued', 'running', 'verifying']);
+const STATE_TEXT = { queued: 'Waiting', running: 'Working', verifying: 'Checking', succeeded: 'Done', failed: 'Didn\'t finish', cancelled: 'Cancelled' };
+const LEVEL_TEXT = { observe: 'Observe', ask: 'Ask first', trusted: 'Trusted', full: 'Full control' };
+const MODE_LABEL = { cheapest: 'Cheapest', balanced: 'Balanced', best: 'Best', fastest: 'Fastest', private: 'Private' };
+const BOT_TIMEOUT_MS = 4000;
+
+const usd = (n) => (n == null ? '—' : n === 0 ? '$0' : n < 0.01 ? `$${Number(n).toFixed(4)}` : `$${Number(n).toFixed(2)}`);
+const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
+const pct = (a, b) => (b > 0 ? Math.max(0, Math.min(100, Math.round((a / b) * 100))) : 0);
+
+function createConsolePages(ctx, { robots = null, computers = null } = {}) {
+ const { config, s } = ctx;
+ const r = asyncRouter();
+ const bot = { robots: robots || createRobots({ config }), computers: computers || createComputers({ config }) };
+ const tier = config.allowance;
+ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, css: ['css/console.css'], ...o });
+ const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
+ const pages = config.services.botPublicUrl || 'https://openvibe.bot';
+
+ /** One Bot read, bounded so a slow Bot never holds the page; { ok, value } or { ok: false, why }. */
+ async function fromBot(fn) {
+ try {
+ const value = await fn({ signal: AbortSignal.timeout(BOT_TIMEOUT_MS) });
+ if (value && !Array.isArray(value) && value.error) return { ok: false, why: value.error };
+ return { ok: true, value };
+ } catch { return { ok: false, why: 'OpenVibe.Bot did not answer in time.' }; }
+ }
+
+ function commandBox() {
+ return html`<form class="cn-command" method="post" action="/tasks" aria-labelledby="cn-command-h">
+<h2 id="cn-command-h" class="cn-sr">Give Actor a task</h2>
+<label class="cn-sr" for="cn-task">What should Actor do?</label>
+<textarea id="cn-task" name="task" rows="2" maxlength="20000" required placeholder="Tell Actor what to do: “check my site's certificate”, “what changed in my repo today?”, “drive my rover to the door”"></textarea>
+<div class="cn-command-row"><label class="cn-mode">Mode <select name="mode">${MODES.map((m) => html`<option value="${m}"${m === 'balanced' ? raw(' selected') : ''}>${MODE_LABEL[m]}</option>`)}</select></label>
+<button class="cn-run" type="submit">Run it</button></div>
+</form>`;
+ }
+
+ const taskRow = (t) => {
+ const result = t.result ? (typeof t.result === 'string' ? JSON.parse(t.result) : t.result) : null;
+ const agent = result ? ((catalog.byId(result.agent) || {}).name || result.agent) : null;
+ return html`<li class="cn-task"><span class="pill pill-${t.state}">${STATE_TEXT[t.state] || t.state}</span>
+<a href="/tasks/${t.id}">${clip(t.task, 110)}</a>
+<span class="cn-meta">${agent ? html`${agent} · ` : ''}${t.cost_usd == null ? '' : html`${usd(Number(t.cost_usd))} · `}${time(t.created_at)}</span></li>`;
+ };
+
+ function computersCard(result) {
+ const body = !bot.computers.enabled ? html`<p class="cn-empty">Not connected to OpenVibe.Bot on this server.</p>`
+ : !result.ok ? html`<p class="cn-empty">${result.why}</p>`
+ : !result.value.computers.length ? html`<p class="cn-empty">No computer is open to your agent. Add one on <a href="${pages}/machines">openvibe.bot/machines</a> with <code>--computer ask</code>, then turn on “Let my agent use this computer”.</p>`
+ : html`<ul class="cn-list">${result.value.computers.map((c) => html`<li><span class="cn-dot${c.online ? ' on' : ''}" aria-hidden="true"></span><b>${c.name || c.id}</b>
+<span class="cn-meta">${c.online ? 'Online' : 'Offline'} · ${LEVEL_TEXT[c.level] || 'On'}${c.can.length ? html` · ${c.can.join(', ')}` : ''}</span>
+${c.waiting_for_approval ? html`<span class="pill pill-running">${String(c.waiting_for_approval)} waiting on the machine</span>` : ''}</li>`)}</ul>`;
+ return html`<section class="cn-card" aria-labelledby="cn-computers"><h2 id="cn-computers">Your computers</h2>${body}
+<p class="cn-more"><a href="${pages}/machines">Machines on OpenVibe.Bot</a></p></section>`;
+ }
+
+ function robotsCard(result) {
+ const body = !bot.robots.enabled ? html`<p class="cn-empty">Not connected to OpenVibe.Bot on this server.</p>`
+ : !result.ok ? html`<p class="cn-empty">${result.why}</p>`
+ : !result.value.length ? html`<p class="cn-empty">No robot yet. <a href="${pages}/try">Try one in your browser</a> or add yours on <a href="${pages}">openvibe.bot</a>.</p>`
+ : html`<ul class="cn-list">${result.value.map((x) => html`<li><span class="cn-dot${x.online ? ' on' : ''}" aria-hidden="true"></span><a href="${x.panel}"><b>${x.name}</b></a>
+<span class="cn-meta">${x.agent_control ? (x.online ? 'Online' : 'Offline') : 'Not open to your agent'}${x.estop && x.estop.latched ? ' · e-stop on' : ''}${typeof x.battery === 'number' ? ` · battery ${Math.round(x.battery * 100)}%` : ''}</span></li>`)}</ul>`;
+ return html`<section class="cn-card" aria-labelledby="cn-robots"><h2 id="cn-robots">Your robots</h2>${body}
+<p class="cn-more"><a href="${pages}/robots">Robots on OpenVibe.Bot</a></p></section>`;
+ }
+
+ r.get('/console', async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ if (!signedIn(req)) {
+ return page(req, res, {
+ title: 'Console', crumbs: [{ label: 'Console' }],
+ body: html`<header class="cn-head"><p class="cn-eyebrow">OpenVibe.Actor · console</p><h1>Console</h1>
+<p class="cn-lede">One place for everything your agents do: the tasks running now, what waits for your approval, your agents, your own computers and robots, and what it all cost today.</p></header>
+<p><a class="cn-run" href="/auth/login?next=%2Fconsole">Sign in to open your console</a></p>`,
+ });
+ }
+ const requester = `user:${req.viewer.subject}`;
+ const subject = req.viewer.subject;
+ const [tasks, today, asked, agents, pcs, rovers] = await Promise.all([
+ store.listTasks(s, requester, { limit: 30 }),
+ store.spendToday(s, requester),
+ approvals.list(s, requester),
+ personal.list(s, requester),
+ bot.computers.enabled ? fromBot((o) => bot.computers.mine(subject, o)) : { ok: false },
+ bot.robots.enabled ? fromBot((o) => bot.robots.mine(subject, o)) : { ok: false },
+ ]);
+ const live = tasks.rows.filter((t) => LIVE.has(t.state));
+ const recent = tasks.rows.filter((t) => !LIVE.has(t.state)).slice(0, 6);
+ const waiting = asked.filter((a) => a.state === 'pending');
+ const onMachines = pcs.ok ? pcs.value.computers.reduce((n, c) => n + (c.waiting_for_approval || 0), 0) : 0;
+ const nWaiting = waiting.length + onMachines;
+ page(req, res, {
+ title: 'Console', crumbs: [{ label: 'Console' }],
+ scripts: live.length ? ['js/console.js'] : [],
+ body: html`${live.length ? raw('<noscript><meta http-equiv="refresh" content="10"></noscript>') : ''}<header class="cn-head"${live.length ? raw(' data-live="10"') : ''}><p class="cn-eyebrow">OpenVibe.Actor · console · alpha</p><h1>Console</h1>
+<p class="cn-lede">What your agents, computers and robots are doing, and one box to tell them what to do next.</p></header>
+${commandBox()}
+<div class="cn-grid">
+<section class="cn-card cn-wide" aria-labelledby="cn-now"><h2 id="cn-now">Now${live.length ? html` <span class="cn-count">${String(live.length)}</span>` : ''}</h2>
+${live.length ? html`<ul class="cn-tasks">${live.map(taskRow)}</ul><p class="cn-note">This page refreshes every 10 seconds while something runs.</p>` : html`<p class="cn-empty">Nothing is running.</p>`}</section>
+<section class="cn-card${nWaiting ? ' cn-attn' : ''}" aria-labelledby="cn-waiting"><h2 id="cn-waiting">Waiting for you${nWaiting ? html` <span class="cn-count">${String(nWaiting)}</span>` : ''}</h2>
+${waiting.length ? html`<ul class="cn-list">${waiting.slice(0, 4).map((a) => html`<li><a href="/me/approvals/${a.id}"><b>${clip(a.summary, 80)}</b></a><span class="cn-meta">Asked ${time(a.created_at)}</span></li>`)}</ul>` : ''}
+${onMachines ? html`<p class="cn-note">${String(onMachines)} on your computers: answer them there with <code>openvibe-node approvals</code>.</p>` : ''}
+${nWaiting ? '' : html`<p class="cn-empty">Nothing waits for you.</p>`}
+<p class="cn-more"><a href="/me/approvals">All approvals</a></p></section>
+<section class="cn-card" aria-labelledby="cn-today"><h2 id="cn-today">Today</h2>
+<p class="cn-stat"><b>${String(today.tasks)}</b> of ${String(tier.tasksPerDay)} free tasks</p>
+<div class="cn-meter" role="progressbar" aria-label="Free tasks used today" aria-valuemin="0" aria-valuemax="${String(tier.tasksPerDay)}" aria-valuenow="${String(today.tasks)}"><span style="width:${String(pct(today.tasks, tier.tasksPerDay))}%"></span></div>
+<p class="cn-stat"><b>${usd(today.usd)}</b> of ${usd(tier.perDayUsd)}</p>
+<div class="cn-meter" role="progressbar" aria-label="Spent today" aria-valuemin="0" aria-valuemax="${String(tier.perDayUsd)}" aria-valuenow="${String(today.usd)}"><span style="width:${String(pct(today.usd, tier.perDayUsd))}%"></span></div>
+<p class="cn-more"><a href="/tasks">Your tasks</a></p></section>
+<section class="cn-card cn-wide" aria-labelledby="cn-recent"><h2 id="cn-recent">Recent tasks</h2>
+${recent.length ? html`<ul class="cn-tasks">${recent.map(taskRow)}</ul>` : html`<p class="cn-empty">No finished task yet. Give Actor one above.</p>`}
+<p class="cn-more"><a href="/tasks">All your tasks</a></p></section>
+<section class="cn-card" aria-labelledby="cn-agents"><h2 id="cn-agents">Your agents</h2>
+${agents.length ? html`<ul class="cn-list">${agents.slice(0, 5).map((a) => html`<li><span class="cn-dot${a.status === 'paused' ? '' : ' on'}" aria-hidden="true"></span><a href="/me/agents/${a.id}"><b>${a.name}</b></a>
+<span class="cn-meta">${a.status === 'paused' ? 'Paused' : a.schedule === 'off' ? 'Runs when you run it' : `Every ${a.schedule === 'hourly' ? 'hour' : a.schedule === 'daily' ? 'day' : 'week'}`}${a.last_run_at ? html` · last ran ${time(a.last_run_at)}` : ''}</span></li>`)}</ul>`
+ : html`<p class="cn-empty">No agent yet. An agent is a task you set up once, with a memory and a schedule.</p>`}
+<p class="cn-more"><a href="/me/agents">${agents.length ? 'All your agents' : 'Create an agent'}</a></p></section>
+${computersCard(pcs)}
+${robotsCard(rovers)}
+<section class="cn-card cn-full" aria-labelledby="cn-elsewhere"><h2 id="cn-elsewhere">Across OpenVibe</h2>
+<ul class="cn-links">
+<li><a href="https://openvibe.services/projects">Your projects and resources</a><span class="cn-meta">OpenVibe.Services: suspend, resume or delete what you own</span></li>
+<li><a href="https://openvibe.watch">Your watches</a><span class="cn-meta">OpenVibe.Watch: wake an agent when something changes</span></li>
+<li><a href="https://openvibe.codes/run">Coding on your machine</a><span class="cn-meta">OpenVibe.Codes: tasks on your own computer</span></li>
+</ul></section>
+</div>`,
+ });
+ });
+
+ return r;
+}
+
+module.exports = { createConsolePages };
@@ -22,6 +22,7 @@ const NETWORK_URL = 'https://openvibe.network';
@@ -22,6 +22,7 @@ const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Actor';
const SITE_NAME = 'OpenVibe.Actor';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const NAV = [
const NAV = [
+ { label: 'Console', href: '/console' },
{ label: 'Your tasks', href: '/tasks' },
{ label: 'Your tasks', href: '/tasks' },
{ label: 'Agents', href: '/agents' },
{ label: 'Agents', href: '/agents' },
{ label: 'API', href: '/docs' },
{ label: 'API', href: '/docs' },
@@ -65,7 +66,7 @@ function renderPage(o) {
@@ -65,7 +66,7 @@ function renderPage(o) {
};
};
// This site's own account links live in the shared navbar's account menu (the page's account
// This site's own account links live in the shared navbar's account menu (the page's account
// bar below is only for visitors without JavaScript).
// bar below is only for visitors without JavaScript).
- if (signedIn) nav.menu = { before: [{ label: 'Your tasks', href: '/tasks', icon: 'fa-list-check' }, { label: 'Your agents', href: '/me/agents', icon: 'fa-robot' }] };
+ if (signedIn) nav.menu = { before: [{ label: 'Console', href: '/console', icon: 'fa-gauge' }, { label: 'Your tasks', href: '/tasks', icon: 'fa-list-check' }, { label: 'Your agents', href: '/me/agents', icon: 'fa-robot' }] };
const footer = { service: 'actor', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };
const footer = { service: 'actor', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };
const account = signedIn
const account = signedIn
? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
+'use strict';
+/**
+ * The Console (server/http/console-pages.js, plan T17 layer 3): signed out it explains itself; signed in it shows the
+ * person's running and recent tasks, what waits for them (their approvals, and approvals waiting on their own
+ * computers), today's spend, their agents, their computers and robots read from OpenVibe.Bot as them, and links to the
+ * services that own the rest. A slow or failing Bot never breaks the page. Only the person's own rows appear.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const store = require('../server/tasks/store');
+const personal = require('../server/agents/personal');
+const approvals = require('../server/agents/approvals');
+
+(async () => {
+ const asked = [];
+ let botMode = 'ok';
+ const bot = {
+ computers: {
+ enabled: true,
+ mine: async (subject) => {
+ asked.push(['computers', subject]);
+ if (botMode === 'fail') return { error: 'Bot answered 503 for your computers' };
+ return { computers: [{ id: 'dev_01M4W5JBFDG2TJ8C4FRDMCWBZ9', name: 'desk-pc', online: true, level: 'ask', can: ['terminal', 'files'], folders: ['/home/ada'], waiting_for_approval: 2 }] };
+ },
+ },
+ robots: {
+ enabled: true,
+ mine: async (subject, { signal }) => {
+ asked.push(['robots', subject]);
+ if (botMode === 'fail') return await new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(new Error('aborted'))));
+ return [{ id: 'rob_01M4W5JBFDG2TJ8C4FRDMCWBZ9', name: 'Desk rover', agent_control: true, online: true, estop: { latched: false }, battery: 0.8, panel: 'https://openvibe.bot/panel/rob_01M4W5JBFDG2TJ8C4FRDMCWBZ9' }];
+ },
+ },
+ };
+ const t = await boot({ bot });
+ try {
+ const ada = t.network.addUser('ada');
+ const bo = t.network.addUser('bo');
+ const owner = `user:${ada.subject}`;
+ const task = async (who, text, state) => {
+ const id = t.ctx.s.newId('tsk');
+ await store.insertTask(t.ctx.s, { id, requester: who, task: text, mode: 'balanced', budget_task: 0.05, budget_day: 0.5, created_at: t.ctx.s.iso() });
+ if (state !== 'queued') await store.updateTask(t.ctx.s, id, { state, ...(state === 'succeeded' ? { cost_usd: 0.0012, finished_at: t.ctx.s.iso() } : {}) });
+ return id;
+ };
+
+ await check('signed out, the console says what it is and how to open it', async () => {
+ const r = await t.get('/console');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /Sign in to open your console/);
+ assert.ok(!asked.length, 'nothing asked of Bot for nobody');
+ });
+
+ await check('signed in: running and recent tasks, what waits, today, agents, computers, robots and the rest', async () => {
+ const running = await task(owner, 'Check the certificate of example.com', 'running');
+ await task(owner, 'Summarise the Node.js release notes', 'succeeded');
+ await task(`user:${bo.subject}`, 'Bo\'s private task', 'running');
+ await approvals.request(t.ctx.s, { owner, kind: 'codes.task', summary: 'Fix the failing build in ada/site', payload: { task: 'fix it' } });
+ await personal.create(t.ctx.s, t.config, owner, { name: 'Morning briefing', instructions: 'Summarise what shipped.', mode: 'balanced', budget_per_run_usd: 0.02, daily_runs_max: 1, schedule: 'daily', schedule_at: '08:00', status: 'active' });
+ const r = await t.get('/console', { as: ada });
+ assert.strictEqual(r.status, 200);
+ const h = r.text;
+ assert.match(h, /<h1>Console<\/h1>/);
+ assert.match(h, /<form class="cn-command" method="post" action="\/tasks"/);
+ assert.ok(h.includes(`href="/tasks/${running}"`) && h.includes('Check the certificate of example.com'));
+ assert.match(h, /data-live="10"/, 'refreshes while something runs');
+ assert.match(h, /Summarise the Node\.js release notes/);
+ assert.ok(!h.includes('Bo's private task') && !h.includes("Bo's private task"), 'only the person\'s own tasks');
+ assert.match(h, /Fix the failing build in ada\/site/);
+ assert.match(h, /2 on your computers/);
+ assert.match(h, /cn-count">3</, 'one approval here and two on the machine');
+ assert.match(h, /Morning briefing/);
+ assert.match(h, /desk-pc[\s\S]*Ask first · terminal, files/);
+ assert.match(h, /Desk rover[\s\S]*battery 80%/);
+ assert.match(h, /openvibe\.services\/projects/);
+ assert.deepStrictEqual(asked.filter((a) => a[1] !== ada.subject), [], 'Bot is asked as the person only');
+ });
+
+ await check('a failing or slow Bot leaves the rest of the page working', async () => {
+ botMode = 'fail';
+ const started = Date.now();
+ const r = await t.get('/console', { as: ada });
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /Bot answered 503 for your computers/);
+ assert.match(r.text, /OpenVibe\.Bot did not answer in time/);
+ assert.match(r.text, /Morning briefing/);
+ assert.ok(Date.now() - started < 8000, 'bounded by the Bot timeout');
+ botMode = 'ok';
+ });
+
+ await check('nothing running: no refresh; nothing waiting says so', async () => {
+ const r = await t.get('/console', { as: bo });
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /data-live="10"/, 'Bo has a running task');
+ const cy = t.network.addUser('cy');
+ const quiet = await t.get('/console', { as: cy });
+ assert.doesNotMatch(quiet.text, /data-live=/);
+ assert.match(quiet.text, /Nothing is running\./);
+ assert.match(quiet.text, /No agent yet/);
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -42,7 +42,7 @@ async function boot(opts = {}) {
@@ -42,7 +42,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
const store = createStore(testdb.db, { now: opts.now });
- const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters, accountSend: opts.accountSend, webhookPost: opts.webhookPost });
+ const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters, accountSend: opts.accountSend, webhookPost: opts.webhookPost, bot: opts.bot });
await built.ctx.keys.ensure();
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;