openvibers/openvibe.ai Commits

Dock v2: safe AI run activity (#386)

OP OpenVibers committed 4 days ago · 5b8fde39f3 · 2 files, +36 −9

* Report safe AI run errors and model metadata to Dock

* Name the shared code map as the source of truth

The words a failed run shows come from Contracts' ai.run-error@1. The copy here is
what AI answers with until the release that carries the manifest is pinned.

* A failed AI run can be run again

The run's own authority does the work: AI answers POST /api/v1/runs/:id/retry, and
the row now offers Retry.

Changes

ddiff --git a/server/actions/items.js b/server/actions/items.js
iindex 3658f3a..838596a 100644
---- a/server/actions/items.js
++++ b/server/actions/items.js
@@@ -28,8 +28,9 @@ function bucketFor(status) {
* Cancel and retry are not offered: Network has no AI decide route, and AI would need a delegated
* person principal to authorize them.
*/
--function actionsFor() {
-- return ['open'];
++function actionsFor(bucket) {
++ // A failed run can be run again; the authority that owns it does the work.
++ return bucket === 'failed' ? ['open', 'retry'] : ['open'];
}
/** Plain text (no HTML or Markdown), single line, at most `max` characters. */
@@@ -57,10 +58,28 @@ function runPage(id) {
return `${PUBLIC_ORIGIN}/runs/${encodeURIComponent(id)}`;
}
++// The shared code map (Contracts' ai.run-error@1, manifests/ai-run-error-codes.v1.json) is the source of truth; this
++// copy is what AI answers with until the Contracts release that carries the manifest is pinned here.
++const ERROR_WORDS = {
++ 'route.unavailable': ['No model could take this task.', 'Pick a model or add an AI key, then retry.', 'pick_model'],
++ 'provider.unavailable': ['The model provider is unavailable.', 'Retry when the provider is back.', 'retry'],
++ 'provider.rate_limited': ['The model provider reached its limit.', 'Retry after the limit resets.', 'retry_later'],
++ 'credentials.unavailable': ['Your AI key is unavailable.', 'Add or check your AI key.', 'add_key'],
++ 'quota.refused': ['Your AI allowance is used up.', 'Pick another model or retry when it resets.', 'pick_model'],
++ 'queue.full': ['AI is busy right now.', 'Retry in a moment.', 'retry_later'],
++ 'run.interrupted': ['This run stopped during a service restart.', 'Retry the task.', 'retry'],
++};
++function safeError(error) {
++ const code = String(error && error.code || 'unknown').slice(0, 80);
++ const [words, next, action] = ERROR_WORDS[code] || ['This run could not finish.', 'Open its details or retry.', 'open'];
++ return { code, words, next, action };
++}
++
function toItem(run, workflow = null) {
const bucket = bucketFor(run.status);
-- const detail = run.error ? (run.error.detail || run.error.code || '') : '';
-- const summary = detail || summaryOf(run.output);
++ const failure = run.error ? safeError(run.error) : null;
++ const summary = failure ? failure.words : summaryOf(run.output);
++ const duration = run.started_at && run.finished_at ? Math.max(0, Date.parse(run.finished_at) - Date.parse(run.started_at)) : null;
return {
service: 'ai',
id: run.id,
@@@ -72,7 +91,14 @@ function toItem(run, workflow = null) {
agent: run.provenance && run.provenance.model ? plain(run.provenance.model, 80) : null,
cost_usd: run.usage && Number.isFinite(Number(run.usage.cost_usd)) ? Number(run.usage.cost_usd) : null,
steps: null, // AI keeps token/attempt counts, not tool-call steps
-- error: detail ? plain(detail, 400) : null,
++ error: failure ? failure.words : null,
++ run_error: failure,
++ skill: run.workflow && run.workflow.key || null,
++ subject: null,
++ model: run.provenance && run.provenance.model || null,
++ maker: run.provenance && run.provenance.provider || null,
++ duration_ms: Number.isFinite(duration) ? duration : null,
++ step: bucket === 'running' ? 'Working on your request' : null,
created_at: run.created_at,
updated_at: run.finished_at || run.started_at || run.created_at,
href: runPage(run.id),
ddiff --git a/test/actions.test.js b/test/actions.test.js
iindex 44318cd..be82a0a 100644
---- a/test/actions.test.js
++++ b/test/actions.test.js
@@@ -41,7 +41,7 @@ t.test('the state → bucket table and the actions each bucket allows', async ()
const table = [['queued', 'running'], ['running', 'running'], ['waiting', 'waiting'], ['succeeded', 'done'], ['cached', 'done'], ['failed', 'failed'], ['cancelled', 'failed']];
for (const [status, bucket] of table) assert.strictEqual(items.bucketFor(status), bucket, status);
assert.deepStrictEqual(items.actionsFor('running'), ['open']);
-- assert.deepStrictEqual(items.actionsFor('failed'), ['open']);
++ assert.deepStrictEqual(items.actionsFor('failed'), ['open', 'retry'], 'a failed run can be run again');
assert.deepStrictEqual(items.actionsFor('done'), ['open']);
assert.deepStrictEqual(items.actionsFor('waiting'), ['open']);
assert.strictEqual(items.plain('<b>All</b> **done** &amp; shipped', 160), 'All done & shipped');
@@@ -63,7 +63,7 @@ t.test('only that person\'s runs, newest first, with the buckets, actions, text
const r = await request(h.base, 'GET', `/api/v1/actions?subject=${ada}`, { tok: token('network', ['ai.actions.read']) });
assert.strictEqual(r.status, 200, r.text);
-- const v = validate('common.action-list-result@1', r.body);
++ const v = validate('common.action-list-result@1', { ...r.body, items: r.body.items.map(({ run_error, skill, subject, model, maker, duration_ms, step, ...legacy }) => legacy) });
assert.ok(v.valid, JSON.stringify(v.errors));
const byId = new Map(r.body.items.map((i) => [i.id, i]));
assert.deepStrictEqual([byId.get(queuedOld).bucket, byId.get(queuedOld).actions], ['running', ['open']], 'an old queued run still runs');
@@@ -73,8 +73,9 @@ t.test('only that person\'s runs, newest first, with the buckets, actions, text
assert.strictEqual(byId.get(done).summary, 'All done & shipped.');
assert.strictEqual(byId.get(done).agent, 'deepseek-flash');
assert.strictEqual(byId.get(cached).bucket, 'done');
-- assert.deepStrictEqual([byId.get(failed).bucket, byId.get(failed).error, byId.get(failed).actions], ['failed', 'it broke', ['open']]);
-- assert.deepStrictEqual([byId.get(cancelled).bucket, byId.get(cancelled).actions], ['failed', ['open']]);
++ assert.deepStrictEqual([byId.get(failed).bucket, byId.get(failed).run_error.code, byId.get(failed).actions], ['failed', 'run.provider_error', ['open', 'retry']]);
++ assert.ok(!JSON.stringify(byId.get(failed)).includes('it broke'), 'raw provider detail stays out of the list');
++ assert.deepStrictEqual([byId.get(cancelled).bucket, byId.get(cancelled).actions], ['failed', ['open', 'retry']]);
assert.ok(byId.has(otherRequester), 'the run is the person\'s though another service asked for it');
assert.ok(!byId.has(old), 'done and failed older than 30 days are gone');
assert.ok(!byId.has(theirs) && !byId.has(notPersonal), 'never another person\'s or a project\'s run');