openvibers/openvibe.ai Commits
Dock v2: safe AI run activity (#386)
Changes
@@@ -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),@@@ -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** & shipped', 160), 'All done & shipped');@@@ -63,7 +63,7 @@ t.test('only that person\'s runs, newest first, with the buckets, actions, textconst 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, textassert.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');
@@ -28,8 +28,9 @@ function bucketFor(status) {
@@ -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
* Cancel and retry are not offered: Network has no AI decide route, and AI would need a delegated
* person principal to authorize them.
* 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. */
/** Plain text (no HTML or Markdown), single line, at most `max` characters. */
@@ -57,10 +58,28 @@ function runPage(id) {
@@ -57,10 +58,28 @@ function runPage(id) {
return `${PUBLIC_ORIGIN}/runs/${encodeURIComponent(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) {
function toItem(run, workflow = null) {
const bucket = bucketFor(run.status);
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 {
return {
service: 'ai',
service: 'ai',
id: run.id,
id: run.id,
@@ -72,7 +91,14 @@ function toItem(run, workflow = null) {
@@ -72,7 +91,14 @@ function toItem(run, workflow = null) {
agent: run.provenance && run.provenance.model ? plain(run.provenance.model, 80) : 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,
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
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,
created_at: run.created_at,
updated_at: run.finished_at || run.started_at || run.created_at,
updated_at: run.finished_at || run.started_at || run.created_at,
href: runPage(run.id),
href: runPage(run.id),
@@ -41,7 +41,7 @@ t.test('the state → bucket table and the actions each bucket allows', async ()
@@ -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']];
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);
for (const [status, bucket] of table) assert.strictEqual(items.bucketFor(status), bucket, status);
assert.deepStrictEqual(items.actionsFor('running'), ['open']);
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('done'), ['open']);
assert.deepStrictEqual(items.actionsFor('waiting'), ['open']);
assert.deepStrictEqual(items.actionsFor('waiting'), ['open']);
assert.strictEqual(items.plain('<b>All</b> **done** & shipped', 160), 'All done & shipped');
assert.strictEqual(items.plain('<b>All</b> **done** & shipped', 160), 'All done & shipped');
@@ -63,7 +63,7 @@ t.test('only that person\'s runs, newest first, with the buckets, actions, text
@@ -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']) });
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);
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));
assert.ok(v.valid, JSON.stringify(v.errors));
const byId = new Map(r.body.items.map((i) => [i.id, i]));
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');
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
@@ -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).summary, 'All done & shipped.');
assert.strictEqual(byId.get(done).agent, 'deepseek-flash');
assert.strictEqual(byId.get(done).agent, 'deepseek-flash');
assert.strictEqual(byId.get(cached).bucket, 'done');
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(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(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');
assert.ok(!byId.has(theirs) && !byId.has(notPersonal), 'never another person\'s or a project\'s run');