openvibers/openvibe.computer Commits

Connect Computer to Run session controls (#7)

OP OpenVibers committed 5 days ago · 363476cb51 · 5 files, +35 −26

Changes

ddiff --git a/server/api.js b/server/api.js
iindex 6c5714b..d45b8c6 100644
---- a/server/api.js
++++ b/server/api.js
@@@ -19,8 +19,8 @@ function createApi({ run, sso }) {
const t = token(req, res); if (!t) return;
if (req.body?.kind && req.body.kind !== 'browser') return res.status(422).json({ code: 'computer.kind.unsupported', detail: 'Run currently creates browser sessions only.' });
const { template, kind, ...runBody } = req.body || {};
-- if (template) return res.status(501).json({ code: 'computer.template.pending', detail: 'Run does not accept templates yet.' });
-- res.status(201).json(await run.create(t, { kind: 'browser', ...runBody }));
++ if (template && !['browser', 'kept-browser', 'throwaway'].includes(template)) return res.status(422).json({ code: 'computer.template.invalid', detail: 'Choose browser, kept-browser or throwaway.' });
++ res.status(201).json(await run.create(t, { kind: 'browser', ...(template ? { template } : {}), ...runBody }));
}));
r.get('/computers/:id', wrap(async (req, res) => { const t = token(req, res); if (!t) return; res.json(await run.get(t, req.params.id)); }));
r.post('/computers/:id/stop', wrap(async (req, res) => { const t = token(req, res); if (!t) return; res.json(await run.stop(t, req.params.id)); }));
@@@ -37,8 +37,7 @@ function createApi({ run, sso }) {
}));
r.post('/computers/:id/handoff', wrap(async (req, res) => {
const t = token(req, res); if (!t) return;
-- await run.get(t, req.params.id);
-- res.status(501).type('application/problem+json').json({ type: 'about:blank', title: 'Run handoff unavailable', status: 501, code: 'computer.handoff.pending', detail: 'Run does not expose B3 control modes or a handoff route yet.' });
++ res.json(await run.take(t, req.params.id));
}));
return r;
}
ddiff --git a/server/pages.js b/server/pages.js
iindex fea2767..0a15fc7 100644
---- a/server/pages.js
++++ b/server/pages.js
@@@ -9,7 +9,7 @@ function createPages({ config, run, sso }) {
const r = express.Router();
const viewerOf = (req) => req.user || req.viewer || { kind: 'anonymous' };
const tokenOf = (req) => sso.extractToken(req);
-- const card = (k) => `<article class="kind-card kind-card--ready"><span class="eyebrow">${esc(k.badge)}</span><h3>${esc(k.name)}</h3><p>${esc(k.for)}</p><p class="runs">Runs on ${esc(k.runs)}</p><form method="post" action="/start"><input type="hidden" name="kind" value="${esc(k.id)}"><button class="button-primary" type="submit">Start a ${esc(k.name.toLowerCase())}</button></form></article>`;
++ const card = (k) => `<article class="kind-card kind-card--ready"><span class="eyebrow">${esc(k.badge)}</span><h3>${esc(k.name)}</h3><p>${esc(k.for)}</p><p class="runs">Runs on ${esc(k.runs)}</p><form method="post" action="/start"><input type="hidden" name="kind" value="${esc(k.id)}"><label for="template-${esc(k.id)}">Start from template</label><select id="template-${esc(k.id)}" name="template"><option value="browser">Browser</option><option value="throwaway">Throwaway</option><option value="kept-browser" disabled>Kept browser · not available on this machine</option></select><button class="button-primary" type="submit">Start a ${esc(k.name.toLowerCase())}</button></form></article>`;
const soon = (k) => `<li class="kind-soon"><b>${esc(k.name)}</b><span>${esc(k.for)}</span><small>${esc(k.runs.replace(/\.$/, ''))}</small></li>`;
r.get('/', async (req, res) => {
const viewer = viewerOf(req); let sessions = [];
@@@ -26,26 +26,30 @@ function createPages({ config, run, sso }) {
if (kind !== 'browser') return res.redirect(303, '/#start');
if (viewerOf(req).kind !== 'user') return res.redirect(303, `/auth/login?next=${encodeURIComponent('/')}`);
const viewer = viewerOf(req);
-- try { const session = await run.create(tokenOf(req), { kind: 'browser', idempotency_key: `computer:${crypto.randomUUID()}` }); return res.redirect(303, `/computers/${encodeURIComponent(session.id)}`); }
++ const template = String(req.body.template || 'browser');
++ if (!['browser', 'kept-browser', 'throwaway'].includes(template)) return res.redirect(303, '/#start');
++ try { const session = await run.create(tokenOf(req), { kind: 'browser', template, idempotency_key: `computer:${crypto.randomUUID()}` }); return res.redirect(303, `/computers/${encodeURIComponent(session.id)}`); }
catch (e) { send(res, { title: 'Could not start computer', body: `<section class="page-intro"><h1>Could not start this computer</h1><p>${esc(e.message)}</p><a href="/#start">Try again</a></section>`, viewer, config, path: '/' }, e.status || 503); }
});
r.get('/computers/:id', async (req, res) => {
const viewer = viewerOf(req); if (viewer.kind !== 'user') return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
try {
const session = await run.get(tokenOf(req), req.params.id);
-- const modes = ['You drive, your AI watches', 'Your AI drives, you watch', 'Your AI drives and asks first', 'Together', 'Guests watch', 'Paused'];
++ const [control, timeline, snapshot] = await Promise.all([run.control(tokenOf(req), session.id), run.timeline(tokenOf(req), session.id), run.snapshot(tokenOf(req), session.id)]);
++ const holder = control.lease?.holder;
++ const actions = (timeline.actions || []).map((a) => `<li><strong>${esc(a.action || a.kind)}</strong><span>${esc(a.actor.kind)} · ${esc(a.result)}${a.code ? ` · ${esc(a.code)}` : ''}</span><time>${esc(a.at)}</time></li>`).join('');
const body = `<section class="page-intro"><a href="/">← Your computers</a><div class="title-row"><div><span class="eyebrow">${esc(session.state)} · ${esc(session.id)}</span><h1>Browser computer</h1></div><form method="post" action="/computers/${encodeURIComponent(session.id)}/stop"><button class="button-danger" ${session.state === 'ended' ? 'disabled' : ''}>Stop computer</button></form></div><p>Placed by OpenVibe Run. ${esc(session.placement?.machine || 'Run is choosing a machine')} · ${esc(session.usage?.desk_seconds || 0)} seconds used.</p></section>
-- <section class="screen-panel"><div class="screen-placeholder"><span class="screen-led"></span><h2>Live screen connects through Run</h2><p>Run currently supports one off screen commands. Its live desk stream is not exposed yet.</p></div><div class="mode-panel"><span class="eyebrow">WHO HOLDS THE CONTROLS</span><h2>Control mode</h2><ul class="mode-list">${modes.map((m) => `<li>${esc(m)} <span>Planned</span></li>`).join('')}</ul><form method="post" action="/computers/${encodeURIComponent(session.id)}/handoff"><button class="button-primary" disabled title="Arrives with Run's control lease">Take over</button></form><p class="fine-print">Run does not expose a control handoff or B3 mode yet. This request checks the session with Run and makes no change until Run adds that support.</p></div></section><section class="timeline"><span class="eyebrow">ACTIVITY</span><h2>Action timeline</h2><p>Run does not expose a session action timeline yet.</p><button class="button-secondary" disabled>Snapshot · coming when Run supports it</button></section>`;
++ <section class="screen-panel"><div class="screen-placeholder"><span class="screen-led"></span><h2>Live screen connects through Run</h2><p>Run currently exposes one-off screen commands. Live streaming is not available yet.</p></div><div class="mode-panel"><span class="eyebrow">WHO HOLDS THE CONTROLS</span><h2>${holder ? `${esc(holder.kind)} · ${esc(holder.subject || 'nobody')}` : 'Controls are free'}</h2><p class="fine-print">Lease ${esc(control.lease?.seq || 1)} · ${control.lease?.expires_ms ? `expires ${esc(new Date(control.lease.expires_ms).toISOString())}` : 'no expiry'}</p><form method="post" action="/computers/${encodeURIComponent(session.id)}/handoff"><button class="button-primary" ${session.state === 'ended' ? 'disabled' : ''}>Take over</button></form></div></section><section class="timeline"><span class="eyebrow">ACTIVITY</span><h2>Action timeline</h2>${actions ? `<ol class="computer-list">${actions}</ol>` : '<p>No commands recorded yet.</p>'}<p class="fine-print">${esc(snapshot.message || 'Snapshots are not available on this machine.')}</p></section>`;
send(res, { title: 'Your browser computer', body, viewer, config, path: req.path });
} catch (e) { send(res, { title: 'Computer unavailable', body: `<section class="page-intro"><h1>Computer unavailable</h1><p>${esc(e.message)}</p><a href="/">Back to your computers</a></section>`, viewer, config, path: req.path }, e.status || 503); }
});
r.post('/computers/:id/stop', async (req, res) => { try { await run.stop(tokenOf(req), req.params.id); res.redirect(303, '/'); } catch (e) { send(res, { title: 'Could not stop computer', body: `<h1>Could not stop computer</h1><p>${esc(e.message)}</p><a href="/">Back</a>`, viewer: viewerOf(req), config }, e.status || 503); } });
r.post('/computers/:id/handoff', async (req, res) => {
const viewer = viewerOf(req); if (viewer.kind !== 'user') return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
-- try { await run.get(tokenOf(req), req.params.id); send(res, { title: 'Take over', body: '<section class="page-intro"><h1>Take over is waiting on Run</h1><p>Run does not expose the B3 control lease yet, so this session was not changed.</p><a href="/computers">Back to computers</a></section>', viewer, config }); }
++ try { await run.take(tokenOf(req), req.params.id); res.redirect(303, `/computers/${encodeURIComponent(req.params.id)}`); }
catch (e) { send(res, { title: 'Could not take over', body: `<h1>Could not take over</h1><p>${esc(e.message)}</p>`, viewer, config }, e.status || 503); }
});
-- r.get('/templates', (_req, res) => send(res, { title: 'Computer templates', body: `<section class="page-intro"><span class="eyebrow">ONE CLICK SETUPS</span><h1>Templates</h1><p>Choose a starting point for the computer you need. Run's current session API starts browser desks; templates wait on Run and the broker.</p></section><div class="template-grid">${templates.map((t) => `<article class="template-card"><span class="template-mark">${esc(t.mark)}</span><h2>${esc(t.name)}</h2><p>${esc(t.text)}</p><span class="availability">Coming as Run adds template support</span></article>`).join('')}</div>`, viewer: viewerOf(_req), config, path: '/templates' }));
++ r.get('/templates', (_req, res) => send(res, { title: 'Computer templates', body: `<section class="page-intro"><span class="eyebrow">START A BROWSER COMPUTER</span><h1>Templates</h1><p>Choose the browser profile behavior before Run places the desk.</p></section><div class="template-grid">${templates.map((t) => `<article class="template-card"><span class="template-mark">${esc(t.mark)}</span><h2>${esc(t.name)}</h2><p>${esc(t.text)}</p>${t.available ? `<form method="post" action="/start"><input type="hidden" name="kind" value="browser"><input type="hidden" name="template" value="${esc(t.id)}"><button class="button-primary">Start ${esc(t.name)}</button></form>` : '<span class="availability">Not available on this machine</span>'}</article>`).join('')}</div>`, viewer: viewerOf(_req), config, path: '/templates' }));
r.get('/pricing', (_req, res) => send(res, { title: 'Pricing', body: `<section class="page-intro"><span class="eyebrow">CLEAR BEFORE YOU START</span><h1>Pricing</h1><p>Run enforces the session budget and placement. Prices become final when configured by the owner.</p></section><div class="price-grid"><article><h2>Your Link machines</h2><p>Free, with no time limit.</p></article><article><h2>Browser allowance</h2><p>${freeText(config.browserFreeMinutes, '20 hours per month (proposed)')}</p></article><article><h2>Desktop allowance</h2><p>${freeText(config.desktopFreeMinutes, '1 hour per month (proposed)')}</p></article><article><h2>After the allowance</h2><p>${priceText(config.pricePerMinute)}</p></article></div><p class="fine-print">The free allowances and per-minute price come from service configuration. Values marked proposed are not yet owner-set.</p>`, viewer: viewerOf(_req), config, path: '/pricing' }));
r.get('/docs', (_req, res) => send(res, { title: 'Computer API docs', body: `<section class="page-intro"><span class="eyebrow">BUILD ON COMPUTER</span><h1>API documentation</h1><p>Computer forwards each signed-in person's Network token to Run. Run owns budgets, placement and session state.</p><p><a href="/openapi.json">OpenAPI 3.1</a> · <a href="/.well-known/agent.json">Agent descriptor</a></p></section><pre>GET /api/v1/computers\nPOST /api/v1/computers\nGET /api/v1/computers/{id}\nPOST /api/v1/computers/{id}/stop\nPOST /api/v1/computers/{id}/screenshot\nPOST /api/v1/computers/{id}/act\nPOST /api/v1/computers/{id}/handoff</pre><p>Use a user Network token with the matching Run session capability. Computer does not use a service token on the person's behalf.</p>`, viewer: viewerOf(_req), config, path: '/docs' }));
r.get('/updates', (_req, res) => send(res, { title: 'Updates', body: '<section class="page-intro"><span class="eyebrow">UPDATES</span><h1>Computer is taking shape</h1><p>The first front door uses Run browser sessions. Desktop, GPU and always-on sessions follow as placement and templates arrive in Run.</p></section>', viewer: viewerOf(_req), config, path: '/updates' }));
ddiff --git a/server/product.js b/server/product.js
iindex c72d5be..0362e0f 100644
---- a/server/product.js
++++ b/server/product.js
@@@ -16,12 +16,9 @@ function kinds(config) {
];
}
const templates = [
-- { mark: '01', name: 'Streaming box', text: 'OBS ready for a stream to OpenVibe Live.' },
-- { mark: '02', name: 'Studio box', text: 'A home for the OpenVibe creative apps.' },
-- { mark: '03', name: 'Code box', text: 'An IDE alongside your OpenVibe Codes work.' },
-- { mark: '04', name: 'Blender box', text: 'A rendering setup with the right GPU.' },
-- { mark: '05', name: 'Games box', text: 'A computer set up for games and a shared screen.' },
-- { mark: '06', name: 'Research box', text: 'A browser with your Vibe in the Dock.' },
++ { mark: '01', id: 'browser', name: 'Browser', text: 'A new browser desk for this session.', available: true },
++ { mark: '02', id: 'kept-browser', name: 'Kept browser', text: 'Reuse a saved browser profile between sessions.', available: false },
++ { mark: '03', id: 'throwaway', name: 'Throwaway', text: 'A fresh browser desk that ends with this session.', available: true },
];
/** The whole price in one sentence (it was repeated, half-configured, on every card). */
function priceLine(config) {
ddiff --git a/server/run-client.js b/server/run-client.js
iindex b7f0656..84ea439 100644
---- a/server/run-client.js
++++ b/server/run-client.js
@@@ -16,9 +16,16 @@ function createRunClient({ baseUrl, fetchImpl = globalThis.fetch }) {
return {
list: (token, query = '') => request(token, `/sessions${query}`),
get: (token, id) => request(token, `/sessions/${encodeURIComponent(id)}`),
-- create: (token, body) => request(token, '/sessions', { method: 'POST', body }),
++ create: (token, body) => {
++ const { template, ...payload } = body || {};
++ return request(token, `/sessions${template ? `?template=${encodeURIComponent(template)}` : ''}`, { method: 'POST', body: payload });
++ },
stop: (token, id) => request(token, `/sessions/${encodeURIComponent(id)}/end`, { method: 'POST', body: {} }),
command: (token, id, body) => request(token, `/sessions/${encodeURIComponent(id)}/commands`, { method: 'POST', body }),
++ control: (token, id) => request(token, `/sessions/${encodeURIComponent(id)}/control`),
++ take: (token, id) => request(token, `/sessions/${encodeURIComponent(id)}/control/take`, { method: 'POST', body: {} }),
++ timeline: (token, id) => request(token, `/sessions/${encodeURIComponent(id)}/timeline`),
++ snapshot: (token, id) => request(token, `/sessions/${encodeURIComponent(id)}/snapshot`),
};
}
module.exports = { createRunClient, RunError };
ddiff --git a/test/computer.test.js b/test/computer.test.js
iindex 64a7aff..58aaef7 100644
---- a/test/computer.test.js
++++ b/test/computer.test.js
@@@ -22,13 +22,13 @@ async function serve(opts) {
test('home renders signed out and signed in; sign-in and computer controls are visible', async (t) => {
const calls = [];
-- const run = { list: async (token) => { calls.push(['list', token]); return { sessions: [{ id: 'ses_a', kind: 'browser', state: 'running', usage: { desk_seconds: 12 } }] }; }, get: async (token, id) => { calls.push(['get', token, id]); return { id, kind: 'browser', state: 'running', usage: { desk_seconds: 12 } }; }, stop: async () => ({}) };
++ const run = { list: async (token) => { calls.push(['list', token]); return { sessions: [{ id: 'ses_a', kind: 'browser', state: 'running', usage: { desk_seconds: 12 } }] }; }, get: async (token, id) => { calls.push(['get', token, id]); return { id, kind: 'browser', state: 'running', usage: { desk_seconds: 12 } }; }, control: async () => ({ lease: { holder: { kind: 'person', subject: 'usr_a' }, seq: 1 } }), timeline: async () => ({ actions: [] }), snapshot: async () => ({ available: false, message: 'Snapshots are not available on this machine.' }), stop: async () => ({}), take: async () => ({}) };
const s = await serve({ run }); t.after(s.close);
const guest = await fetch(s.base); assert.equal(guest.status, 200); assert.match(await guest.text(), /Sign in with OpenVibe/);
const home = await fetch(s.base, { headers: { cookie: 'ov_token=person-a' } }); const html = await home.text();
assert.match(html, /Your computers/); assert.match(html, /Browser/); assert.match(html, /20 hours of cloud browser a month \(proposed\)/); assert.match(html, /Coming as Run adds them/); assert.doesNotMatch(html, /hero-art|account-line/);
const detail = await fetch(`${s.base}/computers/ses_a`, { headers: { cookie: 'ov_token=person-a' } }); const page = await detail.text();
-- for (const mode of ['You drive, your AI watches', 'Your AI drives, you watch', 'Your AI drives and asks first', 'Together', 'Guests watch', 'Paused']) assert.match(page, new RegExp(mode));
++ assert.match(page, /person · usr_a/); assert.match(page, /Snapshots are not available on this machine/);
assert.match(page, /Take over/); assert.equal(calls[0][1], 'person-a'); assert.deepEqual(calls[1], ['get', 'person-a', 'ses_a']);
for (const path of ['/templates', '/pricing', '/docs', '/updates']) { const r = await fetch(`${s.base}${path}`); assert.equal(r.status, 200, `${path} renders signed out`); }
for (const path of ['/templates', '/pricing', '/docs', '/updates']) { const r = await fetch(`${s.base}${path}`, { headers: { cookie: 'ov_token=person-a' } }); assert.equal(r.status, 200, `${path} renders signed in`); }
@@@ -52,21 +52,23 @@ test('starting from the home page is a signed-in POST to Run', async (t) => {
assert.equal(guest.status, 303); assert.match(guest.headers.get('location'), /auth\/login/); assert.equal(calls.length, 0);
const signed = await fetch(`${s.base}/start`, { method: 'POST', headers: { cookie: 'ov_token=person-a', 'content-type': 'application/x-www-form-urlencoded' }, body: 'kind=browser', redirect: 'manual' });
assert.equal(signed.status, 303); assert.equal(signed.headers.get('location'), '/computers/ses_started');
-- assert.equal(calls[0][0], 'person-a'); assert.equal(calls[0][1].kind, 'browser'); assert.match(calls[0][1].idempotency_key, /^computer:/);
++ assert.equal(calls[0][0], 'person-a'); assert.equal(calls[0][1].kind, 'browser'); assert.equal(calls[0][1].template, 'browser'); assert.match(calls[0][1].idempotency_key, /^computer:/);
++ await fetch(`${s.base}/start`, { method: 'POST', headers: { cookie: 'ov_token=person-a', 'content-type': 'application/x-www-form-urlencoded' }, body: 'kind=browser&template=throwaway', redirect: 'manual' });
++ assert.equal(calls[1][1].template, 'throwaway');
});
--test('screen, act and stop map to Run; handoff reports unsupported Run capability', async (t) => {
-- const calls = []; const run = { command: async (token, id, body) => { calls.push([token, id, body]); return { result: { outcome: 'ack' } }; }, stop: async (token, id) => { calls.push([token, id, 'stop']); return { state: 'ended' }; }, get: async (token, id) => { calls.push([token, id, 'get']); return { id, state: 'running' }; } };
++test('screen, act, stop and take-back map to Run', async (t) => {
++ const calls = []; const run = { command: async (token, id, body) => { calls.push([token, id, body]); return { result: { outcome: 'ack' } }; }, stop: async (token, id) => { calls.push([token, id, 'stop']); return { state: 'ended' }; }, take: async (token, id) => { calls.push([token, id, 'take']); return { lease: { seq: 2 } }; } };
const s = await serve({ run }); t.after(s.close); const headers = { authorization: 'Bearer user-token-A', 'content-type': 'application/json' };
assert.equal((await fetch(`${s.base}/api/v1/computers/ses_a/screenshot`, { method: 'POST', headers, body: '{}' })).status, 200);
assert.equal((await fetch(`${s.base}/api/v1/computers/ses_a/act`, { method: 'POST', headers, body: JSON.stringify({ kind: 'browser', value: { action: 'read' } }) })).status, 200);
assert.equal((await fetch(`${s.base}/api/v1/computers/ses_a/stop`, { method: 'POST', headers, body: '{}' })).status, 200);
-- const handoff = await fetch(`${s.base}/api/v1/computers/ses_a/handoff`, { method: 'POST', headers, body: '{}' }); assert.equal(handoff.status, 501);
-- assert.equal(calls[0][2].kind, 'screen'); assert.equal(calls[1][2].kind, 'browser'); assert.deepEqual(calls[2], ['user-token-A', 'ses_a', 'stop']); assert.deepEqual(calls[3], ['user-token-A', 'ses_a', 'get']);
++ const handoff = await fetch(`${s.base}/api/v1/computers/ses_a/handoff`, { method: 'POST', headers, body: '{}' }); assert.equal(handoff.status, 200);
++ assert.equal(calls[0][2].kind, 'screen'); assert.equal(calls[1][2].kind, 'browser'); assert.deepEqual(calls[2], ['user-token-A', 'ses_a', 'stop']); assert.deepEqual(calls[3], ['user-token-A', 'ses_a', 'take']);
});
test('Run client sends the original bearer token on each upstream request', async () => {
const seen = []; const client = createRunClient({ baseUrl: 'https://run.example', fetchImpl: async (url, options) => { seen.push([url, options.headers.authorization, options.method]); return { ok: true, json: async () => ({ sessions: [] }) }; } });
-- await client.list('one-token'); await client.get('another-token', 'ses_abc');
-- assert.deepEqual(seen, [['https://run.example/api/v1/sessions', 'Bearer one-token', 'GET'], ['https://run.example/api/v1/sessions/ses_abc', 'Bearer another-token', 'GET']]);
++ await client.list('one-token'); await client.get('another-token', 'ses_abc'); await client.create('third-token', { kind: 'browser', template: 'throwaway' });
++ assert.deepEqual(seen, [['https://run.example/api/v1/sessions', 'Bearer one-token', 'GET'], ['https://run.example/api/v1/sessions/ses_abc', 'Bearer another-token', 'GET'], ['https://run.example/api/v1/sessions?template=throwaway', 'Bearer third-token', 'POST']]);
});