openvibers/openvibe.run Commits
Report Run command delivery and safe retry (#255)
Changes
@@@ -256,7 +256,7 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), logconst r = await desks.act(row.machine, row.desk, bare(row.owner), { id, kind: cmd.kind, value: cmd.value });if (r === null) {const ended = await finish(row, 'host_lost', 'its machine no longer holds the desk', { release: false });-- return { session_id: row.id, id, result: 'refused', code: 'run.session.host_lost', reason: 'the session\'s machine went away', state: ended ? ended.state : 'ended' };++ return { session_id: row.id, id, result: 'refused', code: 'run.session.host_lost', reason: 'the session\'s machine went away', state: ended ? ended.state : 'ended', delivery: 'not_delivered', retry: { safe: true, reason: 'The machine no longer holds this desk' } };}const renewed = { ...lease, expires_ms: now() + HOLDER_MS };if (renewed.expires_ms !== lease.expires_ms) await store.update(db, row.id, { control_lease: renewed, last_used_at: now(), updated_at: now() });@@@ -265,6 +265,12 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), logif (r.code != null) out.code = String(r.code).slice(0, 80);if (r.reason != null) out.reason = String(r.reason).slice(0, 1000);if (r.data && typeof r.data === 'object' && out.result === 'ack') out.data = r.data;++ // An older Bot may omit delivery. Never infer that a timeout or refusal is safe to repeat.++ out.delivery = ['delivered', 'not_delivered', 'uncertain'].includes(r.delivery) ? r.delivery++ : ['ack', 'nack'].includes(out.result) ? 'delivered' : 'uncertain';++ out.retry = { safe: out.delivery === 'not_delivered', reason: out.delivery === 'not_delivered'++ ? 'Bot did not deliver the command' : out.delivery === 'uncertain'++ ? 'The machine may already have run this command' : 'The machine already answered this command' };await logAction(row, out.id, cmd, caller, out.result, out.code || null);return out;}@@@ -41,6 +41,7 @@ function fakeDesks() {async get(machine, desk) { calls.push(['get', machine, desk]); return leases.get(`${machine} ${desk}`) || null; },async act(machine, desk, subject, cmd) {calls.push(['act', machine, desk, subject, cmd]);++ if (cmd.id === 'not-delivered') return { machine_id: machine, desk, id: cmd.id, result: 'refused', delivery: 'not_delivered', retry: { safe: true } };if (!leases.has(`${machine} ${desk}`)) return null;if (cmd.value && cmd.value.action === 'active_origin') return { machine_id: machine, desk, id: cmd.id, result: 'ack', data: { origin: 'https://example.org' } };if (cmd.value && cmd.value.action === 'blind_fill') return { machine_id: machine, desk, id: cmd.id, result: 'ack', data: { outcome: 'ok' } };@@@ -112,7 +113,9 @@ function fakeDesks() {await check('commands reach its desk; anyone else\'s call is 404', async () => {const r = await t.call('POST', `/api/v1/sessions/${ses.id}/commands`, { ...as(ada), body: { id: 'c1', kind: 'browser', value: { action: 'open', url: 'https://example.org/' } } });assert.strictEqual(r.status, 200, r.text);-- valid(r.json, 'run.session-control-result@1');++ const { delivery, retry, ...releasedShape } = r.json;++ valid(releasedShape, 'run.session-control-result@1'); // pinned Contracts predates the additive fields++ assert.deepStrictEqual([delivery, retry.safe], ['delivered', false]);assert.deepStrictEqual([r.json.result, r.json.state, r.json.data.title], ['ack', 'running', 'Example']);const act = desks.calls.filter((c) => c[0] === 'act').at(-1);assert.deepStrictEqual([act[1], act[2], act[4].kind], [OPENVIBE, ses.placement.desk, 'browser']);@@@ -131,6 +134,8 @@ function fakeDesks() {assert.strictEqual(timeline.json.actions[0].action, 'open');assert.ok(!JSON.stringify(timeline.json).includes('https://example.org'), 'timeline omits command values and URLs');assert.strictEqual((await t.call('GET', `/api/v1/sessions/${ses.id}/timeline`, as(bea))).status, 404);++ const refused = await t.call('POST', `/api/v1/sessions/${ses.id}/commands`, { ...as(ada), body: { id: 'not-delivered', kind: 'browser', value: { action: 'read' } } });++ assert.deepStrictEqual([refused.json.delivery, refused.json.retry.safe], ['not_delivered', true]);});await check('the B3 control lease hands off to an agent, refuses the former holder, and owner take-back wins', async () => {
@@ -256,7 +256,7 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
@@ -256,7 +256,7 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
const r = await desks.act(row.machine, row.desk, bare(row.owner), { id, kind: cmd.kind, value: cmd.value });
const r = await desks.act(row.machine, row.desk, bare(row.owner), { id, kind: cmd.kind, value: cmd.value });
if (r === null) {
if (r === null) {
const ended = await finish(row, 'host_lost', 'its machine no longer holds the desk', { release: false });
const ended = await finish(row, 'host_lost', 'its machine no longer holds the desk', { release: false });
- return { session_id: row.id, id, result: 'refused', code: 'run.session.host_lost', reason: 'the session\'s machine went away', state: ended ? ended.state : 'ended' };
+ return { session_id: row.id, id, result: 'refused', code: 'run.session.host_lost', reason: 'the session\'s machine went away', state: ended ? ended.state : 'ended', delivery: 'not_delivered', retry: { safe: true, reason: 'The machine no longer holds this desk' } };
}
}
const renewed = { ...lease, expires_ms: now() + HOLDER_MS };
const renewed = { ...lease, expires_ms: now() + HOLDER_MS };
if (renewed.expires_ms !== lease.expires_ms) await store.update(db, row.id, { control_lease: renewed, last_used_at: now(), updated_at: now() });
if (renewed.expires_ms !== lease.expires_ms) await store.update(db, row.id, { control_lease: renewed, last_used_at: now(), updated_at: now() });
@@ -265,6 +265,12 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
@@ -265,6 +265,12 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
if (r.code != null) out.code = String(r.code).slice(0, 80);
if (r.code != null) out.code = String(r.code).slice(0, 80);
if (r.reason != null) out.reason = String(r.reason).slice(0, 1000);
if (r.reason != null) out.reason = String(r.reason).slice(0, 1000);
if (r.data && typeof r.data === 'object' && out.result === 'ack') out.data = r.data;
if (r.data && typeof r.data === 'object' && out.result === 'ack') out.data = r.data;
+ // An older Bot may omit delivery. Never infer that a timeout or refusal is safe to repeat.
+ out.delivery = ['delivered', 'not_delivered', 'uncertain'].includes(r.delivery) ? r.delivery
+ : ['ack', 'nack'].includes(out.result) ? 'delivered' : 'uncertain';
+ out.retry = { safe: out.delivery === 'not_delivered', reason: out.delivery === 'not_delivered'
+ ? 'Bot did not deliver the command' : out.delivery === 'uncertain'
+ ? 'The machine may already have run this command' : 'The machine already answered this command' };
await logAction(row, out.id, cmd, caller, out.result, out.code || null);
await logAction(row, out.id, cmd, caller, out.result, out.code || null);
return out;
return out;
}
}
@@ -41,6 +41,7 @@ function fakeDesks() {
@@ -41,6 +41,7 @@ function fakeDesks() {
async get(machine, desk) { calls.push(['get', machine, desk]); return leases.get(`${machine} ${desk}`) || null; },
async get(machine, desk) { calls.push(['get', machine, desk]); return leases.get(`${machine} ${desk}`) || null; },
async act(machine, desk, subject, cmd) {
async act(machine, desk, subject, cmd) {
calls.push(['act', machine, desk, subject, cmd]);
calls.push(['act', machine, desk, subject, cmd]);
+ if (cmd.id === 'not-delivered') return { machine_id: machine, desk, id: cmd.id, result: 'refused', delivery: 'not_delivered', retry: { safe: true } };
if (!leases.has(`${machine} ${desk}`)) return null;
if (!leases.has(`${machine} ${desk}`)) return null;
if (cmd.value && cmd.value.action === 'active_origin') return { machine_id: machine, desk, id: cmd.id, result: 'ack', data: { origin: 'https://example.org' } };
if (cmd.value && cmd.value.action === 'active_origin') return { machine_id: machine, desk, id: cmd.id, result: 'ack', data: { origin: 'https://example.org' } };
if (cmd.value && cmd.value.action === 'blind_fill') return { machine_id: machine, desk, id: cmd.id, result: 'ack', data: { outcome: 'ok' } };
if (cmd.value && cmd.value.action === 'blind_fill') return { machine_id: machine, desk, id: cmd.id, result: 'ack', data: { outcome: 'ok' } };
@@ -112,7 +113,9 @@ function fakeDesks() {
@@ -112,7 +113,9 @@ function fakeDesks() {
await check('commands reach its desk; anyone else\'s call is 404', async () => {
await check('commands reach its desk; anyone else\'s call is 404', async () => {
const r = await t.call('POST', `/api/v1/sessions/${ses.id}/commands`, { ...as(ada), body: { id: 'c1', kind: 'browser', value: { action: 'open', url: 'https://example.org/' } } });
const r = await t.call('POST', `/api/v1/sessions/${ses.id}/commands`, { ...as(ada), body: { id: 'c1', kind: 'browser', value: { action: 'open', url: 'https://example.org/' } } });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
- valid(r.json, 'run.session-control-result@1');
+ const { delivery, retry, ...releasedShape } = r.json;
+ valid(releasedShape, 'run.session-control-result@1'); // pinned Contracts predates the additive fields
+ assert.deepStrictEqual([delivery, retry.safe], ['delivered', false]);
assert.deepStrictEqual([r.json.result, r.json.state, r.json.data.title], ['ack', 'running', 'Example']);
assert.deepStrictEqual([r.json.result, r.json.state, r.json.data.title], ['ack', 'running', 'Example']);
const act = desks.calls.filter((c) => c[0] === 'act').at(-1);
const act = desks.calls.filter((c) => c[0] === 'act').at(-1);
assert.deepStrictEqual([act[1], act[2], act[4].kind], [OPENVIBE, ses.placement.desk, 'browser']);
assert.deepStrictEqual([act[1], act[2], act[4].kind], [OPENVIBE, ses.placement.desk, 'browser']);
@@ -131,6 +134,8 @@ function fakeDesks() {
@@ -131,6 +134,8 @@ function fakeDesks() {
assert.strictEqual(timeline.json.actions[0].action, 'open');
assert.strictEqual(timeline.json.actions[0].action, 'open');
assert.ok(!JSON.stringify(timeline.json).includes('https://example.org'), 'timeline omits command values and URLs');
assert.ok(!JSON.stringify(timeline.json).includes('https://example.org'), 'timeline omits command values and URLs');
assert.strictEqual((await t.call('GET', `/api/v1/sessions/${ses.id}/timeline`, as(bea))).status, 404);
assert.strictEqual((await t.call('GET', `/api/v1/sessions/${ses.id}/timeline`, as(bea))).status, 404);
+ const refused = await t.call('POST', `/api/v1/sessions/${ses.id}/commands`, { ...as(ada), body: { id: 'not-delivered', kind: 'browser', value: { action: 'read' } } });
+ assert.deepStrictEqual([refused.json.delivery, refused.json.retry.safe], ['not_delivered', true]);
});
});
await check('the B3 control lease hands off to an agent, refuses the former holder, and owner take-back wins', async () => {
await check('the B3 control lease hands off to an agent, refuses the former holder, and owner take-back wins', async () => {