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)
Changes
@@@ -398,7 +398,10 @@ member on their first visit, and nobody else reads it, whatever role they once hfollows them every 10 s.- **Other kinds** (`code`, `computer`, `other`) wait for a worker: a member (or an agent working with a member's APItoken, 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 socketsends `room_task`.@@@ -412,7 +415,7 @@ member on their first visit, and nobody else reads it, whatever role they once hwithout 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)@@@ -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); }});@@@ -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 };@@@ -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, formconst 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`);}));}@@@ -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;
@@ -398,7 +398,10 @@ member on their first visit, and nobody else reads it, whatever role they once h
@@ -398,7 +398,10 @@ member on their first visit, and nobody else reads it, whatever role they once h
follows them every 10 s.
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
- **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
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
- **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
(`/r/:slug/tasks/:id`: details, the full result, sources, cost). Open pages refresh the panel when the chat socket
sends `room_task`.
sends `room_task`.
@@ -412,7 +415,7 @@ member on their first visit, and nobody else reads it, whatever role they once h
@@ -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.
without who asked or who took it.
API (`/api/chat/rooms`, also in openvibe-sdk): `GET|POST /:slug/tasks`, `GET /:slug/tasks/:id`,
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)
### Vibe, OpenVibe's assistant (openvibe.chat/vibe)
@@ -26,6 +26,8 @@
@@ -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,
* 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
* computer or other task
* POST /:slug/tasks/:id/report { status: succeeded | failed, result?, result_url? (https), error? }: whoever took it
* 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
* 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.
* 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)
@@ -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) => {
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); }
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) => {
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); }
try { res.json({ task: await workspace.report(await workRoom(req), req.user, req.params.id, req.body || {}) }); } catch (err) { workSend(res, err); }
});
});
@@ -129,6 +129,26 @@ async function cancel(room, user, id, deps = {}) {
@@ -129,6 +129,26 @@ async function cancel(room, user, id, deps = {}) {
return task;
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. */
/** 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 } = {}) {
async function claim(room, user, id, { runner = null } = {}) {
const a = await mustRead(room, user);
const a = await mustRead(room, user);
@@ -193,14 +213,19 @@ async function actorCall(method, path, subject, body, { fetchImpl = globalThis.f
@@ -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. */
/** The text Actor gets: what kind of work, the title, the details. */
function actorText(t) {
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);
return `${lead}: ${t.title}${t.details ? `\n\n${t.details}` : ''}`.slice(0, 7000);
}
}
async function startOnActor(room, t, deps = {}) {
async function startOnActor(room, t, deps = {}) {
try {
try {
const task = await actorCall('POST', '/api/v1/tasks', t.requested_subject, { task: actorText(t), idempotency_key: `chat-room-task-${t.id}` }, deps);
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) {
} catch (err) {
await setStatus(room, t.id, { status: 'failed', runner: 'actor', error: oneLine(err.message, LIMITS.error), finished_at: nowText() });
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(() => {});
await announce(room, await row(room, t.id), deps).catch(() => {});
@@ -276,4 +301,4 @@ function stop() { if (timer) clearInterval(timer); timer = null; }
@@ -276,4 +301,4 @@ function stop() { if (timer) clearInterval(timer); timer = null; }
function _setTokens(t) { tokens = t; }
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 };
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
* GET /r/:slug/tasks/:id one task: details, state, the full result and its sources
* 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/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/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)
* POST /r/:slug/tasks/:id/report say how it went (whoever took it)
*/
*/
const rooms = require('../rooms/rooms');
const rooms = require('../rooms/rooms');
@@ -35,6 +36,7 @@ function createWorkspacePages({ router, page, loadRoom, needUser, sameSite, form
@@ -35,6 +36,7 @@ function createWorkspacePages({ router, page, loadRoom, needUser, sameSite, form
const actions = [];
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.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' && !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 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>` : '';
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}">
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(
@@ -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>
<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>` : '';
<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>
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}
${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>'}`;
${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,
@@ -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`);
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) => {
router.post(`/r/:slug/tasks/:id/${op}`, formLimit, sameSite, workOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const room = await rooms.bySlug(req.params.slug);
const room = await rooms.bySlug(req.params.slug);
if (!room) return res.redirect(303, '/rooms');
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`);
res.redirect(303, op === 'claim' ? `${t.url}#oc-report` : `/r/${room.slug}#oc-queue`);
}));
}));
}
}
@@ -3,7 +3,8 @@
@@ -3,7 +3,8 @@
* Workspace rooms and the staff room (server/rooms/workspace.js, server/web/workspace-pages.js, migration 0027):
* 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;
* 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
* 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.
* work with queue_task in a workspace room and nowhere else; the room page shows the queue beside the conversation.
*/
*/
const assert = require('assert');
const assert = require('assert');
@@ -110,6 +111,35 @@ t('code work waits for a worker: claim, report, the room hears it', async () =>
@@ -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/);
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 () => {
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' });
let r = await api('POST', '/rooms/staff/tasks', staff2, { title: 'Draft the November roadmap', kind: 'other' });
const id = r.body.task.id;
const id = r.body.task.id;