openvibers/openvibe.bot Commits
Desk turn (Desks plan D4): GET /api/v1/machines/:id/desk-turn says whether a person is using a desk and when they last did, so an agent that handed its person a step waits for them (#178)
Changes
@@@ -457,6 +457,15 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submif (result.result === 'refused') fail(commandRefusalStatus(result.code), result.code, result.reason || null);res.json({ machine_id: device.id, ...result });}));++ // Who has a desk right now (Desks plan D4): an agent that asked its person to take over a step (a sign-in, a++ // CAPTCHA, a payment) waits for them to take it, then to stop. Read only; the person's own input is what moves it.++ r.get('/machines/:id/desk-turn', commandControl, wrap(async (req, res) => {++ const { device } = await computerMachine(req);++ const desk = typeof req.query.desk === 'string' && /^(main|private|desk-[a-z0-9][a-z0-9-]{0,23})$/.test(req.query.desk) ? req.query.desk : null;++ if (!desk) fail(422, 'bot.invalid_input', 'desk is main, private or desk-<name>');++ res.set('Cache-Control', 'no-store');++ res.json({ machine_id: device.id, desk, ...hub.deskTurn(device.id, desk) });++ }));// The live screen (plan T14): the machine's owner, signed in as themselves, watches it on their own private stream.// Never an agent, a watch or another service: screen_stream is not a bot.command@1 kind, and only this route sends it.const screenOwner = async (req) => {@@@ -119,6 +119,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datconst inflight = new Map(); // operator key → device-facing id, while the command is pending// The person always wins a screen (machines): `${device}|${main|private}` → until when a person's input holds it.const personTurns = new Map();++ const personLast = new Map(); // the same key → when a person last used that screen (ms), for desk turnsconst timers = [];let closed = false;@@@ -777,8 +778,9 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datconst turnKey = `${deviceId}|${deskOf(value)}`; // each desk is its own screen: a person on one never holds anotherconst person = conn.principal.kind === 'user' || conn.principal.kind === 'share';if (person) {-- if (personTurns.size > 5000) personTurns.clear();++ if (personTurns.size > 5000) { personTurns.clear(); personLast.clear(); }personTurns.set(turnKey, now() + config.device.personTurnMs);++ personLast.set(turnKey, now());} else if ((personTurns.get(turnKey) || 0) > now()) {const left = Math.ceil((personTurns.get(turnKey) - now()) / 1000);return refuse('bot.person_driving', `a person is using this screen right now: try again in ${left} s, after they stop`, summary);@@@ -1031,6 +1033,16 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat},bindJobs(j) { jobs = j; },handleUpgrade, attachSim, detachSim,++ /**++ * Who has a screen right now (Desks plan D4): whether a person is using it (their input holds agents off it for++ * BOT_PERSON_TURN_MS after each action) and when they last did. An agent that handed a desk to its person waits++ * for them to take it, then to stop, before it goes on.++ */++ deskTurn(deviceId, desk) {++ const key = `${deviceId}|${desk}`;++ const last = personLast.get(key) || null;++ return { person_driving: (personTurns.get(key) || 0) > now(), last_person_at: last ? new Date(last).toISOString() : null };++ },/** A one-time ticket for the embedded panel's Drive (POST /panel/:id/ticket), or null. */issueControlTicket(principal, robotId) { return tickets.issue(principal, robotId); },isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, pushEstop, closeWatchers, dropSubject, operatorCommand,@@@ -140,6 +140,26 @@ const { boot, check, done } = require('./helpers/app');withDesk.c.close();});++ await check('desk turn (Desks plan D4): an agent sees whether its person is using a desk and when they last did; read only', async () => {++ const m = await machine(alex, { ...COMPUTER, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 } });++ const token = actor();++ await t.call('POST', `/api/v1/machines/${m.deviceId}/agent-computer`, { user: alex, body: { on: true } });++ const turn = async (desk) => t.call('GET', `/api/v1/machines/${m.deviceId}/desk-turn?desk=${desk}`, asActor(token, alex.subject));++ const before = await turn('desk-task-a');++ assert.deepStrictEqual([before.status, before.json.person_driving, before.json.last_person_at], [200, false, null], 'nobody yet');++ assert.strictEqual((await cmd(m.deviceId, { kind: 'input', value: { action: 'move', x: 5, y: 5, width: 1280, height: 800, desk: 'desk-task-a' } }, { user: alex })).json.result, 'ack');++ const during = await turn('desk-task-a');++ assert.strictEqual(during.json.person_driving, true, 'the person is using it');++ assert.ok(Date.parse(during.json.last_person_at) <= Date.now());++ assert.strictEqual((await turn('desk-task-b')).json.person_driving, false, 'another desk is another screen');++ await t.wait(450); // BOT_PERSON_TURN_MS is 400 in this boot++ const after = await turn('desk-task-a');++ assert.deepStrictEqual([after.json.person_driving, after.json.last_person_at], [false, during.json.last_person_at], 'they stopped; when they last did stays');++ assert.strictEqual((await turn('desk-../x')).status, 422);++ await t.call('POST', `/api/v1/machines/${m.deviceId}/agent-computer`, { user: alex, body: { on: false } });++ m.c.close();++ });++await check('named desks (contracts 0.215.0, Node v0.19.0): one per agent task, each its own screen; an older Node runs one', async () => {const multi = await machine(alex, { ...COMPUTER, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 } });const single = await machine(alex, { ...COMPUTER, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true } });
@@ -457,6 +457,15 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -457,6 +457,15 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
if (result.result === 'refused') fail(commandRefusalStatus(result.code), result.code, result.reason || null);
if (result.result === 'refused') fail(commandRefusalStatus(result.code), result.code, result.reason || null);
res.json({ machine_id: device.id, ...result });
res.json({ machine_id: device.id, ...result });
}));
}));
+ // Who has a desk right now (Desks plan D4): an agent that asked its person to take over a step (a sign-in, a
+ // CAPTCHA, a payment) waits for them to take it, then to stop. Read only; the person's own input is what moves it.
+ r.get('/machines/:id/desk-turn', commandControl, wrap(async (req, res) => {
+ const { device } = await computerMachine(req);
+ const desk = typeof req.query.desk === 'string' && /^(main|private|desk-[a-z0-9][a-z0-9-]{0,23})$/.test(req.query.desk) ? req.query.desk : null;
+ if (!desk) fail(422, 'bot.invalid_input', 'desk is main, private or desk-<name>');
+ res.set('Cache-Control', 'no-store');
+ res.json({ machine_id: device.id, desk, ...hub.deskTurn(device.id, desk) });
+ }));
// The live screen (plan T14): the machine's owner, signed in as themselves, watches it on their own private stream.
// The live screen (plan T14): the machine's owner, signed in as themselves, watches it on their own private stream.
// Never an agent, a watch or another service: screen_stream is not a bot.command@1 kind, and only this route sends it.
// Never an agent, a watch or another service: screen_stream is not a bot.command@1 kind, and only this route sends it.
const screenOwner = async (req) => {
const screenOwner = async (req) => {
@@ -119,6 +119,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -119,6 +119,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
const inflight = new Map(); // operator key → device-facing id, while the command is pending
const inflight = new Map(); // operator key → device-facing id, while the command is pending
// The person always wins a screen (machines): `${device}|${main|private}` → until when a person's input holds it.
// The person always wins a screen (machines): `${device}|${main|private}` → until when a person's input holds it.
const personTurns = new Map();
const personTurns = new Map();
+ const personLast = new Map(); // the same key → when a person last used that screen (ms), for desk turns
const timers = [];
const timers = [];
let closed = false;
let closed = false;
@@ -777,8 +778,9 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -777,8 +778,9 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
const turnKey = `${deviceId}|${deskOf(value)}`; // each desk is its own screen: a person on one never holds another
const turnKey = `${deviceId}|${deskOf(value)}`; // each desk is its own screen: a person on one never holds another
const person = conn.principal.kind === 'user' || conn.principal.kind === 'share';
const person = conn.principal.kind === 'user' || conn.principal.kind === 'share';
if (person) {
if (person) {
- if (personTurns.size > 5000) personTurns.clear();
+ if (personTurns.size > 5000) { personTurns.clear(); personLast.clear(); }
personTurns.set(turnKey, now() + config.device.personTurnMs);
personTurns.set(turnKey, now() + config.device.personTurnMs);
+ personLast.set(turnKey, now());
} else if ((personTurns.get(turnKey) || 0) > now()) {
} else if ((personTurns.get(turnKey) || 0) > now()) {
const left = Math.ceil((personTurns.get(turnKey) - now()) / 1000);
const left = Math.ceil((personTurns.get(turnKey) - now()) / 1000);
return refuse('bot.person_driving', `a person is using this screen right now: try again in ${left} s, after they stop`, summary);
return refuse('bot.person_driving', `a person is using this screen right now: try again in ${left} s, after they stop`, summary);
@@ -1031,6 +1033,16 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -1031,6 +1033,16 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
},
},
bindJobs(j) { jobs = j; },
bindJobs(j) { jobs = j; },
handleUpgrade, attachSim, detachSim,
handleUpgrade, attachSim, detachSim,
+ /**
+ * Who has a screen right now (Desks plan D4): whether a person is using it (their input holds agents off it for
+ * BOT_PERSON_TURN_MS after each action) and when they last did. An agent that handed a desk to its person waits
+ * for them to take it, then to stop, before it goes on.
+ */
+ deskTurn(deviceId, desk) {
+ const key = `${deviceId}|${desk}`;
+ const last = personLast.get(key) || null;
+ return { person_driving: (personTurns.get(key) || 0) > now(), last_person_at: last ? new Date(last).toISOString() : null };
+ },
/** A one-time ticket for the embedded panel's Drive (POST /panel/:id/ticket), or null. */
/** A one-time ticket for the embedded panel's Drive (POST /panel/:id/ticket), or null. */
issueControlTicket(principal, robotId) { return tickets.issue(principal, robotId); },
issueControlTicket(principal, robotId) { return tickets.issue(principal, robotId); },
isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, pushEstop, closeWatchers, dropSubject, operatorCommand,
isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, pushEstop, closeWatchers, dropSubject, operatorCommand,
@@ -140,6 +140,26 @@ const { boot, check, done } = require('./helpers/app');
@@ -140,6 +140,26 @@ const { boot, check, done } = require('./helpers/app');
withDesk.c.close();
withDesk.c.close();
});
});
+ await check('desk turn (Desks plan D4): an agent sees whether its person is using a desk and when they last did; read only', async () => {
+ const m = await machine(alex, { ...COMPUTER, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 } });
+ const token = actor();
+ await t.call('POST', `/api/v1/machines/${m.deviceId}/agent-computer`, { user: alex, body: { on: true } });
+ const turn = async (desk) => t.call('GET', `/api/v1/machines/${m.deviceId}/desk-turn?desk=${desk}`, asActor(token, alex.subject));
+ const before = await turn('desk-task-a');
+ assert.deepStrictEqual([before.status, before.json.person_driving, before.json.last_person_at], [200, false, null], 'nobody yet');
+ assert.strictEqual((await cmd(m.deviceId, { kind: 'input', value: { action: 'move', x: 5, y: 5, width: 1280, height: 800, desk: 'desk-task-a' } }, { user: alex })).json.result, 'ack');
+ const during = await turn('desk-task-a');
+ assert.strictEqual(during.json.person_driving, true, 'the person is using it');
+ assert.ok(Date.parse(during.json.last_person_at) <= Date.now());
+ assert.strictEqual((await turn('desk-task-b')).json.person_driving, false, 'another desk is another screen');
+ await t.wait(450); // BOT_PERSON_TURN_MS is 400 in this boot
+ const after = await turn('desk-task-a');
+ assert.deepStrictEqual([after.json.person_driving, after.json.last_person_at], [false, during.json.last_person_at], 'they stopped; when they last did stays');
+ assert.strictEqual((await turn('desk-../x')).status, 422);
+ await t.call('POST', `/api/v1/machines/${m.deviceId}/agent-computer`, { user: alex, body: { on: false } });
+ m.c.close();
+ });
+
await check('named desks (contracts 0.215.0, Node v0.19.0): one per agent task, each its own screen; an older Node runs one', async () => {
await check('named desks (contracts 0.215.0, Node v0.19.0): one per agent task, each its own screen; an older Node runs one', async () => {
const multi = await machine(alex, { ...COMPUTER, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 } });
const multi = await machine(alex, { ...COMPUTER, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 } });
const single = await machine(alex, { ...COMPUTER, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true } });
const single = await machine(alex, { ...COMPUTER, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true } });