openvibers/openvibe.chat Commits

Workspace rooms: the person who asked a code or computer task can run it on their own computer (OpenVibe.Actor hands code to Codes on their Run workers, computer work to Bot); a task Actor holds stays taken (#166)

OP OpenVibers committed 19 days ago · ac92653690 · 5 files, +74 −9

Changes

ddiff --git a/README.md b/README.md
iindex 0a5908d..47a691d 100644
---- a/README.md
++++ b/README.md
@@@ -398,7 +398,10 @@ member on their first visit, and nobody else reads it, whatever role they once h
follows them every 10 s.
- **Other kinds** (`code`, `computer`, `other`) wait for a worker: a member (or an agent working with a member's API
token, such as the agent harness) claims one, then reports how it went and where it landed (an https link, such as a
-- pull request).
++ pull request). Or the person who asked a `code` or `computer` task presses **Run on my computer**: it becomes their
++ own OpenVibe.Actor task, which hands code to OpenVibe.Codes on their machines (OpenVibe.Run's workers) and computer
++ work to their computer through OpenVibe.Bot, at the level they set there; without such a machine the task fails with
++ Actor's reason.
- **Results** of every task, whoever ran it, are announced in the room by its Vibe with a link to the task's page
(`/r/:slug/tasks/:id`: details, the full result, sources, cost). Open pages refresh the panel when the chat socket
sends `room_task`.
@@@ -412,7 +415,7 @@ member on their first visit, and nobody else reads it, whatever role they once h
without who asked or who took it.
API (`/api/chat/rooms`, also in openvibe-sdk): `GET|POST /:slug/tasks`, `GET /:slug/tasks/:id`,
--`POST /:slug/tasks/:id/cancel|claim|report`.
++`POST /:slug/tasks/:id/cancel|claim|run|report`.
### Vibe, OpenVibe's assistant (openvibe.chat/vibe)
ddiff --git a/server/rooms/routes.js b/server/rooms/routes.js
iindex 7261328..fefe514 100644
---- a/server/rooms/routes.js
++++ b/server/rooms/routes.js
@@@ -26,6 +26,8 @@
* POST /:slug/tasks/:id/claim { runner? }: a worker (a member, or an agent with a member's API token) takes a code,
* computer or other task
* POST /:slug/tasks/:id/report { status: succeeded | failed, result?, result_url? (https), error? }: whoever took it
++ * POST /:slug/tasks/:id/run the asker runs their code or computer task on their own computer (OpenVibe.Actor:
++ * Codes on their Run workers, or their computer through Bot)
*
* Role and room changes reach a running call at once (call-server.js applyRoomAccess): a new speaker
* may talk, a demoted one is muted, a blocked person is dropped.
@@@ -268,6 +270,9 @@ router.post('/:slug/tasks/:id/cancel', requireAuth, workLimit, async (req, res)
router.post('/:slug/tasks/:id/claim', requireAuth, workLimit, async (req, res) => {
try { res.json({ task: await workspace.claim(await workRoom(req), req.user, req.params.id, { runner: (req.body || {}).runner || null }) }); } catch (err) { workSend(res, err); }
});
++router.post('/:slug/tasks/:id/run', requireAuth, workLimit, async (req, res) => {
++ try { res.json({ task: await workspace.runOnComputer(await workRoom(req), req.user, req.params.id) }); } catch (err) { workSend(res, err); }
++});
router.post('/:slug/tasks/:id/report', requireAuth, workLimit, async (req, res) => {
try { res.json({ task: await workspace.report(await workRoom(req), req.user, req.params.id, req.body || {}) }); } catch (err) { workSend(res, err); }
});
ddiff --git a/server/rooms/workspace.js b/server/rooms/workspace.js
iindex bd4df91..31f56ea 100644
---- a/server/rooms/workspace.js
++++ b/server/rooms/workspace.js
@@@ -129,6 +129,26 @@ async function cancel(room, user, id, deps = {}) {
return task;
}
++/**
++ * "Run on my computer": the person who asked hands their own code or computer task to OpenVibe.Actor, which sends code
++ * to OpenVibe.Codes on their machines (OpenVibe.Run's worker chain) and computer work to their computer through
++ * OpenVibe.Bot, at the level they set there. Without such a machine Actor says so and the task shows why.
++ */
++async function runOnComputer(room, user, id, deps = {}) {
++ await mustRead(room, user);
++ const t = await row(room, id);
++ if (!t) fail(404, 'work.not_found', 'No such task in this room');
++ if (!user || Number(t.requested_by) !== Number(user.id)) fail(403, 'work.not_yours', 'Only the person who asked runs a task on their own computer');
++ if (!['code', 'computer'].includes(t.kind)) fail(409, 'work.not_computer', 'Only code and computer tasks run on a computer');
++ if (t.status !== 'queued') fail(409, 'work.taken', 'This task is already being worked on or finished');
++ const r = await db.run("UPDATE room_tasks SET status = 'running', runner = 'actor', started_at = ov_now(), updated_at = ov_now() WHERE id = ? AND room_id = ? AND status = 'queued'", [t.id, room.id]);
++ if (!r.changes) fail(409, 'work.taken', 'Someone took it a moment ago');
++ await startOnActor(room, t, deps);
++ const task = shape(await row(room, t.id), room);
++ changed(room, task);
++ return task;
++}
++
/** A worker takes a task no service runs: a person who may post, or an agent with such a person's API token. */
async function claim(room, user, id, { runner = null } = {}) {
const a = await mustRead(room, user);
@@@ -193,14 +213,19 @@ async function actorCall(method, path, subject, body, { fetchImpl = globalThis.f
/** The text Actor gets: what kind of work, the title, the details. */
function actorText(t) {
-- const lead = { research: 'Research this and write a short report for a team, with the links of your sources', web: 'Search the web and answer, with the links of your sources', answer: 'Answer this for a team' }[t.kind] || 'Do this';
++ const lead = {
++ research: 'Research this and write a short report for a team, with the links of your sources', web: 'Search the web and answer, with the links of your sources', answer: 'Answer this for a team',
++ code: 'Make this code change with OpenVibe.Codes on my own computer, run its tests, and hand it in as a pull request for review (never straight to main)',
++ computer: 'Do this on my own computer, step by step, and say what you did',
++ }[t.kind] || 'Do this';
return `${lead}: ${t.title}${t.details ? `\n\n${t.details}` : ''}`.slice(0, 7000);
}
async function startOnActor(room, t, deps = {}) {
try {
const task = await actorCall('POST', '/api/v1/tasks', t.requested_subject, { task: actorText(t), idempotency_key: `chat-room-task-${t.id}` }, deps);
-- await setStatus(room, t.id, { actor_task: String(task.id), runner: 'actor', status: task.state === 'running' ? 'running' : 'queued', started_at: nowText() });
++ // A task a worker could take stays taken while Actor holds it, so nobody claims it twice.
++ await setStatus(room, t.id, { actor_task: String(task.id), runner: 'actor', status: task.state === 'running' || !QUICK.has(t.kind) ? 'running' : 'queued', started_at: nowText() });
} catch (err) {
await setStatus(room, t.id, { status: 'failed', runner: 'actor', error: oneLine(err.message, LIMITS.error), finished_at: nowText() });
await announce(room, await row(room, t.id), deps).catch(() => {});
@@@ -276,4 +301,4 @@ function stop() { if (timer) clearInterval(timer); timer = null; }
function _setTokens(t) { tokens = t; }
--module.exports = { KINDS, QUICK, WorkError, list, get, add, cancel, claim, report, poll, announce, start, stop, shape, _setTokens };
++module.exports = { KINDS, QUICK, WorkError, list, get, add, cancel, claim, report, runOnComputer, poll, announce, start, stop, shape, _setTokens };
ddiff --git a/server/web/workspace-pages.js b/server/web/workspace-pages.js
iindex 009d505..6d1c233 100644
---- a/server/web/workspace-pages.js
++++ b/server/web/workspace-pages.js
@@@ -9,6 +9,7 @@
* GET /r/:slug/tasks/:id one task: details, state, the full result and its sources
* POST /r/:slug/tasks/:id/cancel the person who asked, or a moderator
* POST /r/:slug/tasks/:id/claim take a code, computer or other task
++ * POST /r/:slug/tasks/:id/run the asker runs their code or computer task on their own computer (Actor, Codes, Run)
* POST /r/:slug/tasks/:id/report say how it went (whoever took it)
*/
const rooms = require('../rooms/rooms');
@@@ -35,6 +36,7 @@ function createWorkspacePages({ router, page, loadRoom, needUser, sameSite, form
const actions = [];
if ((t.status === 'queued' || t.status === 'running') && (mine || can.moderate)) actions.push(`<form method="post" action="/r/${esc(room.slug)}/tasks/${t.id}/cancel"><button type="submit" class="oc-link">Cancel</button></form>`);
if (t.status === 'queued' && !t.quick && can.post) actions.push(`<form method="post" action="/r/${esc(room.slug)}/tasks/${t.id}/claim"><button type="submit" class="oc-link">I'll take it</button></form>`);
++ if (t.status === 'queued' && mine && (t.kind === 'code' || t.kind === 'computer')) actions.push(`<form method="post" action="/r/${esc(room.slug)}/tasks/${t.id}/run" title="OpenVibe.Actor does it on your own computer (OpenVibe.Codes for code, through OpenVibe.Run)"><button type="submit" class="oc-link">Run on my computer</button></form>`);
const result = t.status === 'succeeded' && t.result ? `<p class="oc-task-result">${linkify(t.result.length > 280 ? `${t.result.slice(0, 280).replace(/\s+\S*$/, '')}…` : t.result)}</p>` : '';
const err = t.status === 'failed' && t.error ? `<p class="oc-task-error">${esc(t.error)}</p>` : '';
return `<li class="oc-task oc-task-${esc(t.status)}" data-task="${t.id}">
@@@ -55,7 +57,7 @@ ${result}${err}${actions.length ? `<span class="oc-task-actions">${actions.join(
<label>Details <textarea name="details" rows="3" maxlength="6000" placeholder="Where, constraints, what done looks like"></textarea></label>
<p><button type="submit">Add to the queue</button></p></form></details>` : '';
return `<h2 id="oc-queue-h">Work queue <span class="oc-muted">${working ? `· ${working} working` : ''}${waiting ? ` · ${waiting} waiting` : ''}</span></h2>
--<p class="oc-muted oc-queue-how">Ask <code>@vibe</code> to look into or do something, or add it here. Research and web answers run at once; code and computer work waits for a worker to take it. Results come back into the room.</p>
++<p class="oc-muted oc-queue-how">Ask <code>@vibe</code> to look into or do something, or add it here. Research and web answers run at once; code and computer work waits for a worker to take it, or for the person who asked to run it on their own computer. Results come back into the room.</p>
${form}
${tasks.length ? `<ol class="oc-tasks">${tasks.map((t) => taskItem(t, room, can, me)).join('\n')}</ol>` : '<p class="oc-muted">Nothing queued yet.</p>'}`;
}
@@@ -78,12 +80,12 @@ ${tasks.length ? `<ol class="oc-tasks">${tasks.map((t) => taskItem(t, room, can,
res.redirect(303, `/r/${room.slug}#oc-queue`);
}));
-- for (const op of ['cancel', 'claim']) {
++ for (const op of ['cancel', 'claim', 'run']) {
router.post(`/r/:slug/tasks/:id/${op}`, formLimit, sameSite, workOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const room = await rooms.bySlug(req.params.slug);
if (!room) return res.redirect(303, '/rooms');
-- const t = await workspace[op](room, actor.user, req.params.id);
++ const t = await (op === 'run' ? workspace.runOnComputer : workspace[op])(room, actor.user, req.params.id);
res.redirect(303, op === 'claim' ? `${t.url}#oc-report` : `/r/${room.slug}#oc-queue`);
}));
}
ddiff --git a/test/workspace.test.js b/test/workspace.test.js
iindex a6950f8..610885a 100644
---- a/test/workspace.test.js
++++ b/test/workspace.test.js
@@@ -3,7 +3,8 @@
* Workspace rooms and the staff room (server/rooms/workspace.js, server/web/workspace-pages.js, migration 0027):
* the staff room exists, only staff read it and each member of staff becomes a member; anyone who may post adds work;
* quick kinds run at once as OpenVibe.Actor tasks of the person who asked and their results come back into the room
-- * from Vibe; code work waits for a worker to claim it and report; cancel is the asker's or a moderator's; Vibe queues
++ * from Vibe; code work waits for a worker to claim it and report, or the asker runs it on their own computer through
++ * Actor; cancel is the asker's or a moderator's; Vibe queues
* work with queue_task in a workspace room and nowhere else; the room page shows the queue beside the conversation.
*/
const assert = require('assert');
@@@ -110,6 +111,35 @@ t('code work waits for a worker: claim, report, the room hears it', async () =>
assert.match(msgs[msgs.length - 1].message, /^✅ Done: "Add a dark favicon to Tools"[\s\S]*https:\/\/github\.com\/OpenVibers\/OpenVibe\.Tools\/pull\/1/);
});
++t('run on my computer: the asker hands their code task to Actor, which runs it on their own machine', async () => {
++ const realFetch = globalThis.fetch;
++ globalThis.fetch = async (url, o) => (String(url).startsWith('http://127.0.0.1:4950') ? actorFetch(url, o) : realFetch(url, o));
++ try {
++ let r = await api('POST', '/rooms/staff/tasks', staff, { title: 'Fix the Tools favicon on dark tabs', kind: 'code', details: 'OpenVibe.Tools, public/favicon.svg' });
++ const id = r.body.task.id;
++ assert.strictEqual((await api('POST', `/rooms/staff/tasks/${id}/run`, staff2)).status, 403, 'only the person who asked');
++ const research = (await api('GET', '/rooms/staff/tasks?status=succeeded', staff)).body.tasks.find((x) => x.kind === 'research').id;
++ assert.strictEqual((await api('POST', `/rooms/staff/tasks/${research}/run`, staff)).status, 409, 'code and computer work only');
++ const cookie = `ov_token=${staff.token}`;
++ assert.match((await h.http('GET', '/r/staff/queue', { headers: { cookie } })).text, new RegExp(`/r/staff/tasks/${id}/run"`), 'the asker sees the button');
++ assert.ok(!(await h.http('GET', '/r/staff/queue', { headers: { cookie: `ov_token=${staff2.token}` } })).text.includes(`/tasks/${id}/run"`), 'nobody else does');
++ actorTask = { id: 'tsk_2', state: 'queued' };
++ actorCalls.length = 0;
++ r = await api('POST', `/rooms/staff/tasks/${id}/run`, staff);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual([r.body.task.status, r.body.task.runner], ['running', 'actor'], 'taken while Actor holds it');
++ const create = actorCalls.find((c) => c.method === 'POST');
++ assert.strictEqual(create.subject, h.live.users.get(staff.id).subject_id);
++ assert.match(create.body.task, /^Make this code change with OpenVibe\.Codes on my own computer[^:]*: Fix the Tools favicon on dark tabs\n\nOpenVibe\.Tools, public\/favicon\.svg$/);
++ assert.strictEqual((await api('POST', `/rooms/staff/tasks/${id}/claim`, staff2)).status, 409, 'nobody claims it twice');
++ assert.strictEqual((await api('POST', `/rooms/staff/tasks/${id}/run`, staff)).status, 409, 'once');
++ actorTask = { id: 'tsk_2', state: 'succeeded', result: { answer: 'Opened a pull request with the dark favicon.' } };
++ await workspace.poll();
++ r = await api('GET', `/rooms/staff/tasks/${id}`, staff);
++ assert.deepStrictEqual([r.body.task.status, r.body.task.result], ['succeeded', 'Opened a pull request with the dark favicon.']);
++ } finally { globalThis.fetch = realFetch; }
++});
++
t('cancel: the asker or a moderator; others not', async () => {
let r = await api('POST', '/rooms/staff/tasks', staff2, { title: 'Draft the November roadmap', kind: 'other' });
const id = r.body.task.id;