openvibers/openvibe.actor Commits
A parked chat turn says what it waits for, with Wake now and Stop waiting (A2c) (#269)
Changes
@@@ -49,6 +49,8 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app/* The screen: what Actor sees while it works, with Bot's live view one tap away. */.ct-screen { margin: 0; display: grid; gap: 6px; }++/* A parked turn (plan A2): what it waits for, until when, and Wake now / Stop waiting. */++.ct-wait .ct-wait-until { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); font-size: 14px; }.ct-screen[hidden] { display: none; }.ct-screen-frame { position: relative; overflow: hidden; border-radius: 14px; border: 1px solid var(--line); background: #05070d; aspect-ratio: 16 / 10; display: grid; place-items: center; }.ct-screen-frame img { display: block; width: 100%; height: 100%; object-fit: contain; }@@@ -271,6 +271,24 @@var said = typeof e.text === 'string' ? e.text.replace(/…$/, '').trim() : '';if (e.step === 'tool_call' || (e.step === 'text' && said && d.text.trim().indexOf(said) === 0)) { turn._draft = null; draft(turn); }}++ // A wait's deadline in the reader's own zone ("at 18:40", or with the day when it is not today).++ function localTimes(root) {++ (root || document).querySelectorAll('time[data-ct-local]').forEach(function (t) {++ var d = new Date(t.getAttribute('datetime'));++ if (isNaN(d)) return;++ var today = d.toDateString() === new Date().toDateString();++ t.textContent = 'at ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + (today ? '' : ' on ' + d.toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short' }));++ });++ }++ localTimes(chat);++ // A parked turn's card (plan A2) comes and goes with the wait: it is outside the work list the refresh keeps.++ function syncWait(turn, next) {++ var cur = turn.querySelector('[data-ct-wait]'), fresh = next.querySelector('[data-ct-wait]');++ if (cur && !fresh) cur.remove();++ else if (fresh && !cur) { var reply = turn.querySelector('.ovt-reply'); if (reply) reply.appendChild(fresh); }++ else if (fresh && cur && cur.outerHTML !== fresh.outerHTML) cur.replaceWith(fresh);++ localTimes(turn);++ }function refresh(turn, done) {var id = turn.getAttribute('data-task');return fetch('/chat/turn/' + encodeURIComponent(id), { credentials: 'same-origin' })@@@ -296,6 +314,7 @@if (scr && scr2 && (scr.hidden !== scr2.hidden || scr.getAttribute('data-desk') !== scr2.getAttribute('data-desk'))) { scr.replaceWith(scr2); screen(turn); }liveLine(turn);draft(turn);++ syncWait(turn, next);} else {stopScreen(turn);// An answer the person already watched being written is not typed in again: it only takes its@@@ -244,6 +244,22 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=<form method="post" action="/chat" class="ct-retry"><input type="hidden" name="task" value="${row.task}"><input type="hidden" name="mode" value="best">${isFollow(row) ? html`<input type="hidden" name="parent" value="${row.parent_id}">` : ''}<button class="ct-opt" type="submit">Try again in Best mode</button><a class="ct-act" href="/tasks/${row.id}?view=details">Details</a></form></div>`;}++ /**++ * A parked turn (plan A2, Watch mode): what it waits for, until when, and the person's two moves. Wake now resumes++ * it with no event (the run checks for itself); Stop waiting ends the wait and the run carries on without it. The++ * deadline is written as a <time> the page shows in the reader's own zone (js/chat.js), "in about 2 h" without it.++ */++ function waitBlock(row, wait) {++ if (!wait) return '';++ const left = Math.max(0, Date.parse(wait.deadline_at) - now());++ const mins = Math.round(left / 60000);++ const about = mins < 1 ? 'any moment now' : mins < 90 ? `in about ${mins} min` : `in about ${Math.round(mins / 60)} h`;++ return html`<form class="ovt-card is-info ct-wait" method="post" action="/chat/${row.id}/wait" data-ct-wait>++<p class="ovt-card-h">${raw(glyphs.svg('clock', { size: 18 }))}<span><b>Waiting</b> ${String(wait.line || 'for something to happen').replace(/^waiting\s+/i, '').replace(/[.\s]+$/, '')}. Nothing is spent while it waits.</span></p>++<p class="ct-wait-until">It gives up <time datetime="${new Date(wait.deadline_at).toISOString()}" data-ct-local>${about}</time>, then ${wait.on_timeout === 'fail' ? 'stops' : wait.on_timeout === 'retry' ? 'tries that step once more' : 'asks you what next'}.</p>++<div class="ovt-card-acts"><button type="submit" class="ovt-card-btn" name="do" value="wake">Wake now</button><button type="submit" class="ovt-card-btn is-quiet" name="do" value="cancel">Stop waiting</button></div></form>`;++ }++/** The files beside the question (thread.files draws them). */function filesOf(row, files) {return (files || []).map((f) => ({ href: `/chat/file/${row.id}/${f.id}`, name: f.name, size: f.size, image: inputs.IMAGE.test(f.mime) }));@@@ -259,7 +275,11 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=else if (row.state === 'needs_you') reply = html`${workBlock(row, evs, false)}${questionBlock(row, evs)}`;else if (row.state === 'failed') reply = html`${workBlock(row, evs, false)}${failedBlock(row, t)}`;else if (row.state === 'cancelled') reply = html`${workBlock(row, evs, false)}<p class="ct-stopped">Stopped. What it spent before stopping stays spent.</p>`;-- else reply = html`${workBlock(row, evs, true)}${screenBlock(row, evs, true)}`;++ else {++ // A turn parked on a wait says what it waits for (plan A2); otherwise it is working.++ const parked = ctx.waits && typeof ctx.waits.listForTask === 'function' ? (await ctx.waits.listForTask(row.id).catch(() => [])).find((w) => w.status === 'waiting') : null;++ reply = html`${workBlock(row, evs, true)}${screenBlock(row, evs, true)}${waitBlock(row, parked)}`;++ }// data-task and data-last are Actor's (js/chat.js follows the task's event stream from data-last).return raw(thread.turn({ id: row.id, state: row.state, open, you: { text: row.task, files: filesOf(row, attached) }, reply: String(reply), attrs: { 'data-task': row.id, 'data-last': row.last_seq } }));}@@@ -456,6 +476,24 @@ ${radio('own:node', 'Your computer', 'A model you run at home (Ollama, LM Studioreturn res.redirect(303, `/chat/${row.id}#t-${row.id}`);});++ // A parked turn's two moves (plan A2): Wake now, or Stop waiting. The task's own person, from openvibe.actor only.++ r.post('/chat/:id/wait', express.urlencoded({ extended: false, limit: '1kb' }), async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ const wantsJson = String(req.get('accept') || '').includes('application/json');++ if (!signedIn(req) || !TASK_ID.test(req.params.id)) return wantsJson ? res.status(401).json({ error: 'Sign in first.' }) : res.redirect(303, '/auth/login?next=%2Fchat');++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Wake or stop a wait from openvibe.actor itself.');++ const row = await store.getTask(s, req.params.id);++ if (!row || row.requester !== requesterOf(req)) return wantsJson ? res.status(404).json({ error: 'That chat is not here.' }) : res.redirect(303, '/chat');++ const move = String((req.body && req.body.do) || '');++ const waits = ctx.waits;++ const open = waits ? (await waits.listForTask(row.id)).find((w) => w.status === 'waiting') : null;++ let done = null;++ if (open && move === 'wake') done = await waits.wakeByPerson(open.id, requesterOf(req));++ else if (open && move === 'cancel') done = await waits.cancel(open.id, requesterOf(req));++ if (wantsJson) return res.status(done ? 202 : 409).json(done ? { ok: true } : { error: open ? 'Say wake or cancel.' : 'It is not waiting any more.' });++ return res.redirect(303, `/chat/${row.id}#t-${row.id}`);++ });++// The live desk in a working turn (plan L55 C1b; the owner, 11-01: "show you the display… watch it… and steer it").// The page asks once the turn is on a desk; the answer is a short-lived Bot page it frames, where the person can// take the mouse. Only the task's requester, only same-origin, only while the turn works.@@@ -197,6 +197,46 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;assert.deepStrictEqual(await t.ctx.engine.steer('tsk_00000000000000000000000000', 'hi'), { ok: false, reason: 'this task is not running' });});++ await check('a parked turn (plan A2): it says what it waits for and until when; Wake now and Stop waiting, for its person only', async () => {++ const last = (await s.db.maybe('SELECT id FROM tasks WHERE requester = $1 ORDER BY id DESC LIMIT 1', [`user:${kim.subject}`])).id;++ await s.db.query("UPDATE tasks SET state = 'running' WHERE id = $1", [last]);++ const woke = [];++ const engine = t.ctx.engine;++ const before = { ev: engine.resumeWithEvent, note: engine.resumeWithNotice };++ engine.resumeWithEvent = async (id, p) => { woke.push(['wake', id, p && p.event]); return { id }; };++ engine.resumeWithNotice = async (id, n) => { woke.push(['cancel', id, n]); return { id }; };++ try {++ const w = await t.ctx.waits.create({ taskId: last, requester: `user:${kim.subject}`, source: 'time', seconds: 7200, line: 'waiting for maticus to go live' });++ const page = await t.get(`/chat/${last}`, { as: kim });++ assert.match(page.text, new RegExp(`<form class="ovt-card is-info ct-wait" method="post" action="/chat/${last}/wait" data-ct-wait>`));++ assert.match(page.text, /<b>Waiting<\/b> for maticus to go live\. Nothing is spent while it waits\./, 'the line, without saying waiting twice');++ assert.match(page.text, /<time datetime="[^"]+" data-ct-local>in about 2 h<\/time>, then asks you what next\./);++ assert.match(page.text, /name="do" value="wake">Wake now<\/button>/);++ // Another person, another site: nothing changes.++ assert.strictEqual((await t.get(`/chat/${last}/wait`, { as: bo, form: { do: 'wake' }, headers: { ...SAME, accept: 'application/json' } })).status, 404);++ assert.strictEqual((await t.get(`/chat/${last}/wait`, { as: kim, form: { do: 'wake' }, headers: { 'sec-fetch-site': 'cross-site' } })).status, 403);++ assert.strictEqual((await t.ctx.waits.get(w.id)).status, 'waiting');++ const wake = await t.get(`/chat/${last}/wait`, { as: kim, form: { do: 'wake' }, headers: SAME });++ assert.strictEqual(wake.status, 303);++ assert.strictEqual(wake.headers.get('location'), `/chat/${last}#t-${last}`);++ assert.strictEqual((await t.ctx.waits.get(w.id)).status, 'woken');++ assert.deepStrictEqual(woke.at(-1).slice(0, 3), ['wake', last, null], 'Wake now resumes it with no event');++ assert.doesNotMatch((await t.get(`/chat/${last}`, { as: kim })).text, /data-ct-wait/, 'no wait, no card');++ const again = await t.get(`/chat/${last}/wait`, { as: kim, form: { do: 'wake' }, headers: { ...SAME, accept: 'application/json' } });++ assert.strictEqual(again.status, 409, 'nothing to wake twice');++ await s.db.query("UPDATE tasks SET state = 'running' WHERE id = $1", [last]);++ const w2 = await t.ctx.waits.create({ taskId: last, requester: `user:${kim.subject}`, source: 'time', seconds: 900, line: 'waiting a while' });++ const stop = await t.get(`/chat/${last}/wait`, { as: kim, form: { do: 'cancel' }, headers: { ...SAME, accept: 'application/json' } });++ assert.strictEqual(stop.status, 202);++ assert.strictEqual((await t.ctx.waits.get(w2.id)).status, 'cancelled');++ assert.strictEqual(woke.at(-1)[0], 'cancel', 'Stop waiting: the run carries on without it');++ } finally {++ engine.resumeWithEvent = before.ev;++ engine.resumeWithNotice = before.note;++ await s.db.query("UPDATE tasks SET state = 'succeeded' WHERE id = $1", [last]);++ }++ });++await check('a working turn: the composer steers it and Stop is its own button; the screen waits for a picture', async () => {const last = (await s.db.maybe('SELECT id FROM tasks WHERE requester = $1 ORDER BY id DESC LIMIT 1', [`user:${kim.subject}`])).id;await s.db.query("UPDATE tasks SET state = 'running' WHERE id = $1", [last]);
@@ -49,6 +49,8 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app
@@ -49,6 +49,8 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app
/* The screen: what Actor sees while it works, with Bot's live view one tap away. */
/* The screen: what Actor sees while it works, with Bot's live view one tap away. */
.ct-screen { margin: 0; display: grid; gap: 6px; }
.ct-screen { margin: 0; display: grid; gap: 6px; }
+/* A parked turn (plan A2): what it waits for, until when, and Wake now / Stop waiting. */
+.ct-wait .ct-wait-until { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); font-size: 14px; }
.ct-screen[hidden] { display: none; }
.ct-screen[hidden] { display: none; }
.ct-screen-frame { position: relative; overflow: hidden; border-radius: 14px; border: 1px solid var(--line); background: #05070d; aspect-ratio: 16 / 10; display: grid; place-items: center; }
.ct-screen-frame { position: relative; overflow: hidden; border-radius: 14px; border: 1px solid var(--line); background: #05070d; aspect-ratio: 16 / 10; display: grid; place-items: center; }
.ct-screen-frame img { display: block; width: 100%; height: 100%; object-fit: contain; }
.ct-screen-frame img { display: block; width: 100%; height: 100%; object-fit: contain; }
@@ -271,6 +271,24 @@
@@ -271,6 +271,24 @@
var said = typeof e.text === 'string' ? e.text.replace(/…$/, '').trim() : '';
var said = typeof e.text === 'string' ? e.text.replace(/…$/, '').trim() : '';
if (e.step === 'tool_call' || (e.step === 'text' && said && d.text.trim().indexOf(said) === 0)) { turn._draft = null; draft(turn); }
if (e.step === 'tool_call' || (e.step === 'text' && said && d.text.trim().indexOf(said) === 0)) { turn._draft = null; draft(turn); }
}
}
+ // A wait's deadline in the reader's own zone ("at 18:40", or with the day when it is not today).
+ function localTimes(root) {
+ (root || document).querySelectorAll('time[data-ct-local]').forEach(function (t) {
+ var d = new Date(t.getAttribute('datetime'));
+ if (isNaN(d)) return;
+ var today = d.toDateString() === new Date().toDateString();
+ t.textContent = 'at ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + (today ? '' : ' on ' + d.toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short' }));
+ });
+ }
+ localTimes(chat);
+ // A parked turn's card (plan A2) comes and goes with the wait: it is outside the work list the refresh keeps.
+ function syncWait(turn, next) {
+ var cur = turn.querySelector('[data-ct-wait]'), fresh = next.querySelector('[data-ct-wait]');
+ if (cur && !fresh) cur.remove();
+ else if (fresh && !cur) { var reply = turn.querySelector('.ovt-reply'); if (reply) reply.appendChild(fresh); }
+ else if (fresh && cur && cur.outerHTML !== fresh.outerHTML) cur.replaceWith(fresh);
+ localTimes(turn);
+ }
function refresh(turn, done) {
function refresh(turn, done) {
var id = turn.getAttribute('data-task');
var id = turn.getAttribute('data-task');
return fetch('/chat/turn/' + encodeURIComponent(id), { credentials: 'same-origin' })
return fetch('/chat/turn/' + encodeURIComponent(id), { credentials: 'same-origin' })
@@ -296,6 +314,7 @@
@@ -296,6 +314,7 @@
if (scr && scr2 && (scr.hidden !== scr2.hidden || scr.getAttribute('data-desk') !== scr2.getAttribute('data-desk'))) { scr.replaceWith(scr2); screen(turn); }
if (scr && scr2 && (scr.hidden !== scr2.hidden || scr.getAttribute('data-desk') !== scr2.getAttribute('data-desk'))) { scr.replaceWith(scr2); screen(turn); }
liveLine(turn);
liveLine(turn);
draft(turn);
draft(turn);
+ syncWait(turn, next);
} else {
} else {
stopScreen(turn);
stopScreen(turn);
// An answer the person already watched being written is not typed in again: it only takes its
// An answer the person already watched being written is not typed in again: it only takes its
@@ -244,6 +244,22 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
@@ -244,6 +244,22 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
<form method="post" action="/chat" class="ct-retry"><input type="hidden" name="task" value="${row.task}"><input type="hidden" name="mode" value="best">${isFollow(row) ? html`<input type="hidden" name="parent" value="${row.parent_id}">` : ''}<button class="ct-opt" type="submit">Try again in Best mode</button><a class="ct-act" href="/tasks/${row.id}?view=details">Details</a></form></div>`;
<form method="post" action="/chat" class="ct-retry"><input type="hidden" name="task" value="${row.task}"><input type="hidden" name="mode" value="best">${isFollow(row) ? html`<input type="hidden" name="parent" value="${row.parent_id}">` : ''}<button class="ct-opt" type="submit">Try again in Best mode</button><a class="ct-act" href="/tasks/${row.id}?view=details">Details</a></form></div>`;
}
}
+ /**
+ * A parked turn (plan A2, Watch mode): what it waits for, until when, and the person's two moves. Wake now resumes
+ * it with no event (the run checks for itself); Stop waiting ends the wait and the run carries on without it. The
+ * deadline is written as a <time> the page shows in the reader's own zone (js/chat.js), "in about 2 h" without it.
+ */
+ function waitBlock(row, wait) {
+ if (!wait) return '';
+ const left = Math.max(0, Date.parse(wait.deadline_at) - now());
+ const mins = Math.round(left / 60000);
+ const about = mins < 1 ? 'any moment now' : mins < 90 ? `in about ${mins} min` : `in about ${Math.round(mins / 60)} h`;
+ return html`<form class="ovt-card is-info ct-wait" method="post" action="/chat/${row.id}/wait" data-ct-wait>
+<p class="ovt-card-h">${raw(glyphs.svg('clock', { size: 18 }))}<span><b>Waiting</b> ${String(wait.line || 'for something to happen').replace(/^waiting\s+/i, '').replace(/[.\s]+$/, '')}. Nothing is spent while it waits.</span></p>
+<p class="ct-wait-until">It gives up <time datetime="${new Date(wait.deadline_at).toISOString()}" data-ct-local>${about}</time>, then ${wait.on_timeout === 'fail' ? 'stops' : wait.on_timeout === 'retry' ? 'tries that step once more' : 'asks you what next'}.</p>
+<div class="ovt-card-acts"><button type="submit" class="ovt-card-btn" name="do" value="wake">Wake now</button><button type="submit" class="ovt-card-btn is-quiet" name="do" value="cancel">Stop waiting</button></div></form>`;
+ }
+
/** The files beside the question (thread.files draws them). */
/** The files beside the question (thread.files draws them). */
function filesOf(row, files) {
function filesOf(row, files) {
return (files || []).map((f) => ({ href: `/chat/file/${row.id}/${f.id}`, name: f.name, size: f.size, image: inputs.IMAGE.test(f.mime) }));
return (files || []).map((f) => ({ href: `/chat/file/${row.id}/${f.id}`, name: f.name, size: f.size, image: inputs.IMAGE.test(f.mime) }));
@@ -259,7 +275,11 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
@@ -259,7 +275,11 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
else if (row.state === 'needs_you') reply = html`${workBlock(row, evs, false)}${questionBlock(row, evs)}`;
else if (row.state === 'needs_you') reply = html`${workBlock(row, evs, false)}${questionBlock(row, evs)}`;
else if (row.state === 'failed') reply = html`${workBlock(row, evs, false)}${failedBlock(row, t)}`;
else if (row.state === 'failed') reply = html`${workBlock(row, evs, false)}${failedBlock(row, t)}`;
else if (row.state === 'cancelled') reply = html`${workBlock(row, evs, false)}<p class="ct-stopped">Stopped. What it spent before stopping stays spent.</p>`;
else if (row.state === 'cancelled') reply = html`${workBlock(row, evs, false)}<p class="ct-stopped">Stopped. What it spent before stopping stays spent.</p>`;
- else reply = html`${workBlock(row, evs, true)}${screenBlock(row, evs, true)}`;
+ else {
+ // A turn parked on a wait says what it waits for (plan A2); otherwise it is working.
+ const parked = ctx.waits && typeof ctx.waits.listForTask === 'function' ? (await ctx.waits.listForTask(row.id).catch(() => [])).find((w) => w.status === 'waiting') : null;
+ reply = html`${workBlock(row, evs, true)}${screenBlock(row, evs, true)}${waitBlock(row, parked)}`;
+ }
// data-task and data-last are Actor's (js/chat.js follows the task's event stream from data-last).
// data-task and data-last are Actor's (js/chat.js follows the task's event stream from data-last).
return raw(thread.turn({ id: row.id, state: row.state, open, you: { text: row.task, files: filesOf(row, attached) }, reply: String(reply), attrs: { 'data-task': row.id, 'data-last': row.last_seq } }));
return raw(thread.turn({ id: row.id, state: row.state, open, you: { text: row.task, files: filesOf(row, attached) }, reply: String(reply), attrs: { 'data-task': row.id, 'data-last': row.last_seq } }));
}
}
@@ -456,6 +476,24 @@ ${radio('own:node', 'Your computer', 'A model you run at home (Ollama, LM Studio
@@ -456,6 +476,24 @@ ${radio('own:node', 'Your computer', 'A model you run at home (Ollama, LM Studio
return res.redirect(303, `/chat/${row.id}#t-${row.id}`);
return res.redirect(303, `/chat/${row.id}#t-${row.id}`);
});
});
+ // A parked turn's two moves (plan A2): Wake now, or Stop waiting. The task's own person, from openvibe.actor only.
+ r.post('/chat/:id/wait', express.urlencoded({ extended: false, limit: '1kb' }), async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ const wantsJson = String(req.get('accept') || '').includes('application/json');
+ if (!signedIn(req) || !TASK_ID.test(req.params.id)) return wantsJson ? res.status(401).json({ error: 'Sign in first.' }) : res.redirect(303, '/auth/login?next=%2Fchat');
+ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Wake or stop a wait from openvibe.actor itself.');
+ const row = await store.getTask(s, req.params.id);
+ if (!row || row.requester !== requesterOf(req)) return wantsJson ? res.status(404).json({ error: 'That chat is not here.' }) : res.redirect(303, '/chat');
+ const move = String((req.body && req.body.do) || '');
+ const waits = ctx.waits;
+ const open = waits ? (await waits.listForTask(row.id)).find((w) => w.status === 'waiting') : null;
+ let done = null;
+ if (open && move === 'wake') done = await waits.wakeByPerson(open.id, requesterOf(req));
+ else if (open && move === 'cancel') done = await waits.cancel(open.id, requesterOf(req));
+ if (wantsJson) return res.status(done ? 202 : 409).json(done ? { ok: true } : { error: open ? 'Say wake or cancel.' : 'It is not waiting any more.' });
+ return res.redirect(303, `/chat/${row.id}#t-${row.id}`);
+ });
+
// The live desk in a working turn (plan L55 C1b; the owner, 11-01: "show you the display… watch it… and steer it").
// The live desk in a working turn (plan L55 C1b; the owner, 11-01: "show you the display… watch it… and steer it").
// The page asks once the turn is on a desk; the answer is a short-lived Bot page it frames, where the person can
// The page asks once the turn is on a desk; the answer is a short-lived Bot page it frames, where the person can
// take the mouse. Only the task's requester, only same-origin, only while the turn works.
// take the mouse. Only the task's requester, only same-origin, only while the turn works.
@@ -197,6 +197,46 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
@@ -197,6 +197,46 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
assert.deepStrictEqual(await t.ctx.engine.steer('tsk_00000000000000000000000000', 'hi'), { ok: false, reason: 'this task is not running' });
assert.deepStrictEqual(await t.ctx.engine.steer('tsk_00000000000000000000000000', 'hi'), { ok: false, reason: 'this task is not running' });
});
});
+ await check('a parked turn (plan A2): it says what it waits for and until when; Wake now and Stop waiting, for its person only', async () => {
+ const last = (await s.db.maybe('SELECT id FROM tasks WHERE requester = $1 ORDER BY id DESC LIMIT 1', [`user:${kim.subject}`])).id;
+ await s.db.query("UPDATE tasks SET state = 'running' WHERE id = $1", [last]);
+ const woke = [];
+ const engine = t.ctx.engine;
+ const before = { ev: engine.resumeWithEvent, note: engine.resumeWithNotice };
+ engine.resumeWithEvent = async (id, p) => { woke.push(['wake', id, p && p.event]); return { id }; };
+ engine.resumeWithNotice = async (id, n) => { woke.push(['cancel', id, n]); return { id }; };
+ try {
+ const w = await t.ctx.waits.create({ taskId: last, requester: `user:${kim.subject}`, source: 'time', seconds: 7200, line: 'waiting for maticus to go live' });
+ const page = await t.get(`/chat/${last}`, { as: kim });
+ assert.match(page.text, new RegExp(`<form class="ovt-card is-info ct-wait" method="post" action="/chat/${last}/wait" data-ct-wait>`));
+ assert.match(page.text, /<b>Waiting<\/b> for maticus to go live\. Nothing is spent while it waits\./, 'the line, without saying waiting twice');
+ assert.match(page.text, /<time datetime="[^"]+" data-ct-local>in about 2 h<\/time>, then asks you what next\./);
+ assert.match(page.text, /name="do" value="wake">Wake now<\/button>/);
+ // Another person, another site: nothing changes.
+ assert.strictEqual((await t.get(`/chat/${last}/wait`, { as: bo, form: { do: 'wake' }, headers: { ...SAME, accept: 'application/json' } })).status, 404);
+ assert.strictEqual((await t.get(`/chat/${last}/wait`, { as: kim, form: { do: 'wake' }, headers: { 'sec-fetch-site': 'cross-site' } })).status, 403);
+ assert.strictEqual((await t.ctx.waits.get(w.id)).status, 'waiting');
+ const wake = await t.get(`/chat/${last}/wait`, { as: kim, form: { do: 'wake' }, headers: SAME });
+ assert.strictEqual(wake.status, 303);
+ assert.strictEqual(wake.headers.get('location'), `/chat/${last}#t-${last}`);
+ assert.strictEqual((await t.ctx.waits.get(w.id)).status, 'woken');
+ assert.deepStrictEqual(woke.at(-1).slice(0, 3), ['wake', last, null], 'Wake now resumes it with no event');
+ assert.doesNotMatch((await t.get(`/chat/${last}`, { as: kim })).text, /data-ct-wait/, 'no wait, no card');
+ const again = await t.get(`/chat/${last}/wait`, { as: kim, form: { do: 'wake' }, headers: { ...SAME, accept: 'application/json' } });
+ assert.strictEqual(again.status, 409, 'nothing to wake twice');
+ await s.db.query("UPDATE tasks SET state = 'running' WHERE id = $1", [last]);
+ const w2 = await t.ctx.waits.create({ taskId: last, requester: `user:${kim.subject}`, source: 'time', seconds: 900, line: 'waiting a while' });
+ const stop = await t.get(`/chat/${last}/wait`, { as: kim, form: { do: 'cancel' }, headers: { ...SAME, accept: 'application/json' } });
+ assert.strictEqual(stop.status, 202);
+ assert.strictEqual((await t.ctx.waits.get(w2.id)).status, 'cancelled');
+ assert.strictEqual(woke.at(-1)[0], 'cancel', 'Stop waiting: the run carries on without it');
+ } finally {
+ engine.resumeWithEvent = before.ev;
+ engine.resumeWithNotice = before.note;
+ await s.db.query("UPDATE tasks SET state = 'succeeded' WHERE id = $1", [last]);
+ }
+ });
+
await check('a working turn: the composer steers it and Stop is its own button; the screen waits for a picture', async () => {
await check('a working turn: the composer steers it and Stop is its own button; the screen waits for a picture', async () => {
const last = (await s.db.maybe('SELECT id FROM tasks WHERE requester = $1 ORDER BY id DESC LIMIT 1', [`user:${kim.subject}`])).id;
const last = (await s.db.maybe('SELECT id FROM tasks WHERE requester = $1 ORDER BY id DESC LIMIT 1', [`user:${kim.subject}`])).id;
await s.db.query("UPDATE tasks SET state = 'running' WHERE id = $1", [last]);
await s.db.query("UPDATE tasks SET state = 'running' WHERE id = $1", [last]);