openvibers/openvibe.actor Commits

Each run is a chat you can open: a personal agent's run that passed its check also goes to its owner's Vibe chat named after it (chat.vibe.deliver, once per task, retried with backoff); send_to_vibe on the agent, on by default, in the API and on the form (#122)

OP OpenVibers committed 18 days ago · 36c7c2ea64 · 11 files, +227 −11

Changes

ddiff --git a/migrations/0013_agent_send_to_vibe.sql b/migrations/0013_agent_send_to_vibe.sql
nnew file mode 100644
iindex 0000000..e08865c
---- /dev/null
++++ b/migrations/0013_agent_send_to_vibe.sql
@@@ -0,0 +1,20 @@
++-- phase: expand
++-- Each run is a chat you can open (consumer plan §4, the Vibe app's Tasks): a personal agent's run that passed its check
++-- goes to its owner's own Vibe chat named after the agent, on OpenVibe.Chat (capability chat.vibe.deliver;
++-- server/agents/vibe-deliver.js). send_to_vibe is the agent's switch, on by default; actor_vibe_deliveries keeps what was
++-- sent, and retries what was not.
++ALTER TABLE actor_agents ADD COLUMN send_to_vibe boolean NOT NULL DEFAULT true;
++CREATE TABLE actor_vibe_deliveries (
++ task_id text COLLATE "C" PRIMARY KEY,
++ owner text COLLATE "C" NOT NULL,
++ agent_id text COLLATE "C" NOT NULL,
++ state text COLLATE "C" NOT NULL CHECK (state IN ('pending', 'sent', 'failed')),
++ attempts integer NOT NULL DEFAULT 0,
++ next_at text COLLATE "C" NOT NULL,
++ chat_url text,
++ error text,
++ created_at text COLLATE "C" NOT NULL,
++ sent_at text COLLATE "C"
++);
++CREATE INDEX actor_vibe_deliveries_due ON actor_vibe_deliveries (state, next_at);
++CREATE INDEX actor_vibe_deliveries_owner ON actor_vibe_deliveries (owner);
ddiff --git a/public/css/agents.css b/public/css/agents.css
iindex 0c42cfb..366b2e2 100644
---- a/public/css/agents.css
++++ b/public/css/agents.css
@@@ -17,6 +17,9 @@
.pa-panel textarea { resize: vertical; }
.pa-form { display: grid; grid-template-columns: repeat(auto-fill, minmax(12.5rem, 1fr)); gap: .9rem 1rem; width: 100%; }
.pa-form .pa-wide { grid-column: 1 / -1; }
++.pa-panel label.pa-check { flex-direction: row; flex-wrap: wrap; align-items: center; gap: .3rem .5rem; }
++.pa-panel .pa-check input[type="checkbox"] { width: 1.1rem; height: 1.1rem; margin: 0; padding: 0; accent-color: var(--accent, #3b82f6); }
++.pa-check .pa-hint { flex-basis: 100%; }
.pa-hint { display: block; font-size: 13px; font-weight: 400; color: var(--text-secondary, #96a7c2); }
.pa-runs, .pa-notes { list-style: none; padding: 0; margin: 0 0 .75rem; }
.pa-runs li { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: .25rem 1rem; padding: .45rem 0; border-bottom: 1px solid var(--border-color, rgba(255,255,255,.08)); }
ddiff --git a/server/agents/personal.js b/server/agents/personal.js
iindex 12f5b1c..50675c5 100644
---- a/server/agents/personal.js
++++ b/server/agents/personal.js
@@@ -46,7 +46,7 @@ function nextRun(agent, after) {
}
function validate(input, config, current = null) {
-- const allowed = ['name', 'instructions', 'mode', 'budget_per_run_usd', 'daily_runs_max', 'schedule', 'schedule_at', 'schedule_dow', 'quiet_from', 'quiet_to', 'status'];
++ const allowed = ['name', 'instructions', 'mode', 'budget_per_run_usd', 'daily_runs_max', 'schedule', 'schedule_at', 'schedule_dow', 'quiet_from', 'quiet_to', 'send_to_vibe', 'status'];
if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some((k) => !allowed.includes(k))) return 'Unknown agent setting.';
const a = { ...current, ...input };
if (typeof a.name !== 'string' || a.name.trim().length < 1 || a.name.length > 60) return 'name must be 1–60 characters.';
@@@ -63,6 +63,7 @@ function validate(input, config, current = null) {
if (a.quiet_from === a.quiet_to) return 'Quiet hours must not start and end at the same time.';
if (['daily', 'weekly'].includes(a.schedule) && inQuiet(a, `2000-01-01T${a.schedule_at}:00.000Z`)) return 'schedule_at falls inside the quiet hours.';
}
++ if (a.send_to_vibe !== undefined && typeof a.send_to_vibe !== 'boolean') return 'send_to_vibe must be true or false.';
if (!['active', 'paused'].includes(a.status)) return 'status is invalid.';
return null;
}
@@@ -72,7 +73,7 @@ const list = (s, owner) => s.db.many('SELECT * FROM actor_agents WHERE owner = $
const memory = (s, owner, id) => s.db.many('SELECT * FROM actor_agent_memory WHERE owner = $1 AND agent_id = $2 ORDER BY created_order DESC', [owner, id]);
async function create(s, config, owner, input) {
-- const body = { mode: 'balanced', budget_per_run_usd: config.allowance.perTaskUsd, daily_runs_max: 1, schedule: 'off', schedule_at: null, schedule_dow: null, quiet_from: null, quiet_to: null, status: 'active', ...input };
++ const body = { mode: 'balanced', budget_per_run_usd: config.allowance.perTaskUsd, daily_runs_max: 1, schedule: 'off', schedule_at: null, schedule_dow: null, quiet_from: null, quiet_to: null, send_to_vibe: true, status: 'active', ...input };
const invalid = validate(body, config);
if (invalid) return { error: invalid };
return s.tx(async () => {
@@@ -83,9 +84,9 @@ async function create(s, config, owner, input) {
const count = Number(await s.db.value('SELECT count(*) FROM actor_agents WHERE owner = $1', [owner]));
if (count >= config.agents.maxPerOwner) return { limit: true };
const now = s.iso();
-- const row = await s.db.maybe(`INSERT INTO actor_agents (id, owner, name, instructions, mode, budget_per_run_usd, daily_runs_max, schedule, schedule_at, schedule_dow, quiet_from, quiet_to, next_run_at, status, created_at, updated_at)
-- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$15) RETURNING *`,
-- [s.newId('agt'), owner, body.name, body.instructions, body.mode, body.budget_per_run_usd, body.daily_runs_max, body.schedule, body.schedule_at, body.schedule_dow, body.quiet_from, body.quiet_to, nextRun(body, now), body.status, now]);
++ const row = await s.db.maybe(`INSERT INTO actor_agents (id, owner, name, instructions, mode, budget_per_run_usd, daily_runs_max, schedule, schedule_at, schedule_dow, quiet_from, quiet_to, next_run_at, status, created_at, updated_at, send_to_vibe)
++ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$15,$16) RETURNING *`,
++ [s.newId('agt'), owner, body.name, body.instructions, body.mode, body.budget_per_run_usd, body.daily_runs_max, body.schedule, body.schedule_at, body.schedule_dow, body.quiet_from, body.quiet_to, nextRun(body, now), body.status, now, body.send_to_vibe]);
return { row };
});
}
ddiff --git a/server/agents/vibe-deliver.js b/server/agents/vibe-deliver.js
nnew file mode 100644
iindex 0000000..e36d935
---- /dev/null
++++ b/server/agents/vibe-deliver.js
@@@ -0,0 +1,80 @@
++'use strict';
++/**
++ * Each run is a chat you can open (consumer plan §4, the Vibe app's Tasks; capability chat.vibe.deliver). A personal
++ * agent's run that passed its check goes to its owner's own Vibe chat, named after the agent, on OpenVibe.Chat, while
++ * the agent's send_to_vibe is on (the default). A sweep every TICK_MS finds the runs that finished in the last WINDOW_MS
++ * and were not sent yet (actor_vibe_deliveries) and sends each with its task id as the idempotency key. A failure is
++ * tried again with backoff, up to MAX_ATTEMPTS; Chat refusing it (4xx other than 429) ends it at once. Off when Chat's
++ * address or Actor's client secret is missing (server/chat/client.js).
++ *
++ * createVibeDelivery({ s, config, chat, log }) → { tick() → how many were sent, start(), stop() }
++ */
++const TICK_MS = 30_000;
++const WINDOW_MS = 2 * 3600_000;
++const MAX_ATTEMPTS = 8;
++const TEXT_MAX = 7500;
++const BATCH = 20;
++
++function createVibeDelivery({ s, config, chat, log = console }) {
++ let timer = null;
++
++ const answerOf = (t) => {
++ let r = t.result;
++ if (typeof r === 'string') { try { r = JSON.parse(r); } catch { r = null; } }
++ const text = r && typeof r.answer === 'string' ? r.answer.trim() : '';
++ return text.length > TEXT_MAX ? `${text.slice(0, TEXT_MAX - 1).trimEnd()}…` : text;
++ };
++
++ async function record(t, fields) {
++ await s.db.query(`INSERT INTO actor_vibe_deliveries (task_id, owner, agent_id, state, attempts, next_at, chat_url, error, created_at, sent_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
++ ON CONFLICT (task_id) DO UPDATE SET state = EXCLUDED.state, attempts = EXCLUDED.attempts, next_at = EXCLUDED.next_at,
++ chat_url = EXCLUDED.chat_url, error = EXCLUDED.error, sent_at = EXCLUDED.sent_at`,
++ [t.id, t.requester, t.agent_id, fields.state, fields.attempts, fields.next_at, fields.chat_url || null, fields.error || null, s.iso(), fields.sent_at || null]);
++ }
++
++ async function tick() {
++ if (!chat.deliverEnabled) return 0;
++ const now = s.iso();
++ const since = new Date(Date.parse(now) - WINDOW_MS).toISOString();
++ // New runs inside the window, and earlier failures whose retry is due (wherever their run falls).
++ const rows = await s.db.many(`SELECT t.id, t.requester, t.result, a.id AS agent_id, a.name, COALESCE(d.attempts, 0) AS attempts
++ FROM tasks t
++ JOIN actor_agents a ON t.via = 'agent:' || a.id AND a.owner = t.requester
++ LEFT JOIN actor_vibe_deliveries d ON d.task_id = t.id
++ WHERE t.state = 'succeeded' AND a.send_to_vibe
++ AND ((d.task_id IS NULL AND t.finished_at >= $1) OR (d.state = 'pending' AND d.next_at <= $2))
++ ORDER BY t.finished_at LIMIT ${BATCH}`, [since, now]);
++ let sent = 0;
++ for (const t of rows) {
++ const text = answerOf(t);
++ const attempts = Number(t.attempts) + 1;
++ if (!text || !/^user:usr_/.test(t.requester)) {
++ await record(t, { state: 'failed', attempts, next_at: now, error: 'nothing to send' });
++ continue;
++ }
++ const out = await chat.deliver({ subject: t.requester.slice(5), agent: { id: t.agent_id, name: t.name }, text, url: `${config.baseUrl}/tasks/${t.id}`, key: t.id });
++ if (out.ok) {
++ sent++;
++ await record(t, { state: 'sent', attempts, next_at: now, chat_url: out.url, sent_at: s.iso() });
++ continue;
++ }
++ const final = attempts >= MAX_ATTEMPTS || (out.status >= 400 && out.status < 500 && out.status !== 429);
++ const wait = Math.min(60, 2 ** attempts) * 60_000;
++ await record(t, { state: final ? 'failed' : 'pending', attempts, next_at: new Date(Date.parse(now) + wait).toISOString(), error: String(out.error || out.status).slice(0, 300) });
++ if (final) log.warn(`[Actor] a run did not reach Vibe: ${t.id} (${String(out.error || out.status).slice(0, 120)})`);
++ }
++ return sent;
++ }
++
++ function start() {
++ if (timer || !chat.deliverEnabled) return;
++ timer = setInterval(() => { tick().catch((err) => log.warn('[Actor] Vibe delivery tick failed:', err && err.message)); }, TICK_MS);
++ timer.unref();
++ }
++ function stop() { if (timer) clearInterval(timer); timer = null; }
++
++ return { tick, start, stop };
++}
++
++module.exports = { createVibeDelivery, WINDOW_MS, MAX_ATTEMPTS, TEXT_MAX };
ddiff --git a/server/app.js b/server/app.js
iindex caf6584..68db29b 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -93,7 +93,9 @@ async function createApp(opts = {}) {
const chatClient = opts.chatClient || createChatClient({ config, fetchImpl });
const chatAsks = createChatAsks({ s, config, chat: chatClient, now: s.now, log,
createTask: (args) => createTask({ s, config, engine, webhooks, ...args }) });
-- const ctx = { config, s, keys, sso, stream, adapters, engine, webhooks, principal, projects, log, chatAsks };
++ // Each personal agent's run that passed its check also goes to its owner's Vibe chat (agents/vibe-deliver.js).
++ const vibeDelivery = require('./agents/vibe-deliver').createVibeDelivery({ s, config, chat: chatClient, log });
++ const ctx = { config, s, keys, sso, stream, adapters, engine, webhooks, principal, projects, log, chatAsks, vibeDelivery };
// Account export and deletion (ADR-033, ./identity/account-data.js): the two tables that hold a person's rows.
// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;
ddiff --git a/server/chat/client.js b/server/chat/client.js
iindex c7f3641..188930c 100644
---- a/server/chat/client.js
++++ b/server/chat/client.js
@@@ -5,6 +5,10 @@
* principal. A message names its room (the global chat, or a channel and its live stream), the message it answers and
* an idempotency key, and shows as "OpenVibe Actor" with the AI and bot marks. Off without ACTOR_CHAT_URL or a client
* secret.
++ *
++ * deliver() puts a personal agent's run into its owner's own Vibe chat named after the agent (server/agents/
++ * vibe-deliver.js): POST /internal/chat/vibe/deliver, capability chat.vibe.deliver, with a token of its own so a missing
++ * grant for one never refuses the other.
*/
const { serviceAuth } = require('openvibe-contracts');
@@@ -18,6 +22,10 @@ function createChatClient({ config, fetchImpl = globalThis.fetch, tokens: tokens
tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
audience: 'openvibe.chat', scope: 'chat.message.send', fetchImpl,
});
++ const deliverTokens = !enabled ? null : tokensOverride || serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.chat', scope: 'chat.vibe.deliver', fetchImpl,
++ });
/** Post `text` in `room`, answering message `replyTo` → { ok, status, id?, error? }. `key` makes a retry a no-op. */
async function post({ key, room, text, replyTo = null }) {
@@@ -43,7 +51,24 @@ function createChatClient({ config, fetchImpl = globalThis.fetch, tokens: tokens
}
}
-- return { enabled, post };
++ /** chat.vibe-deliver-request@1 → { ok, status, url?, repeated?, error? }. `key` (the task id) makes a retry a no-op. */
++ async function deliver({ subject, agent, text, url, key }) {
++ if (!enabled) return { ok: false, status: 0, error: 'Vibe delivery is off here (ACTOR_CHAT_URL)' };
++ const body = { subject, source: { kind: 'agent', id: agent.id, name: String(agent.name || '').slice(0, 80) }, text, ...(url ? { url } : {}), idempotency_key: key };
++ try {
++ const res = await fetchImpl(`${base}/internal/chat/vibe/deliver`, {
++ method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await deliverTokens.authHeaders()) },
++ body: JSON.stringify(body), signal: AbortSignal.timeout(10_000),
++ });
++ const data = await res.json().catch(() => null);
++ if (!res.ok) return { ok: false, status: res.status, error: (data && (data.detail || data.error || data.title)) || `Chat answered ${res.status}` };
++ return { ok: true, status: res.status, url: data && data.url, repeated: !!(data && data.repeated) };
++ } catch (err) {
++ return { ok: false, status: 0, error: (err && err.message) || String(err) };
++ }
++ }
++
++ return { enabled, deliverEnabled: enabled, post, deliver };
}
module.exports = { createChatClient, NAME, DISPLAY };
ddiff --git a/server/db.js b/server/db.js
iindex 64ae602..d264afb 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -9,7 +9,7 @@ const path = require('path');
const { createDb } = require('openvibe-sdk/db');
const { ids } = require('openvibe-contracts');
--const TABLES = ['tasks', 'task_events', 'spend_daily', 'actor_agents', 'actor_agent_memory', 'actor_agent_proposals', 'actor_approvals'];
++const TABLES = ['tasks', 'task_events', 'spend_daily', 'actor_agents', 'actor_agent_memory', 'actor_agent_proposals', 'actor_vibe_deliveries', 'actor_approvals'];
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
ddiff --git a/server/http/agent-pages.js b/server/http/agent-pages.js
iindex abcc27e..423fb70 100644
---- a/server/http/agent-pages.js
++++ b/server/http/agent-pages.js
@@@ -59,6 +59,7 @@ function createAgentPages(ctx) {
<label>Quiet from (UTC) <input name="quiet_from" type="time" value="${a.quiet_from || ''}"><span class="pa-hint">No scheduled run starts in quiet hours; Run now still works.</span></label>
<label>Quiet until (UTC) <input name="quiet_to" type="time" value="${a.quiet_to || ''}"><span class="pa-hint">It may cross midnight, like 22:00 to 07:00.</span></label>
<label>Status <select name="status"><option value="active"${a.status !== 'paused' ? raw(' selected') : ''}>Active</option><option value="paused"${a.status === 'paused' ? raw(' selected') : ''}>Paused</option></select></label>
++<label class="pa-wide pa-check"><input type="hidden" name="send_to_vibe" value="0"><input type="checkbox" name="send_to_vibe" value="1"${a.send_to_vibe === false ? '' : raw(' checked')}> Send each run to Vibe <span class="pa-hint">Each result also lands in a chat named after this agent on openvibe.chat/vibe, where you can ask Vibe about it.</span></label>
</div>`;
}
const when = (a) => a.schedule === 'off' ? 'Runs when you run it' : (a.schedule === 'hourly' ? 'Every hour'
@@@ -68,7 +69,9 @@ function createAgentPages(ctx) {
return { name: body.name, instructions: body.instructions, mode: body.mode,
budget_per_run_usd: Number(body.budget_per_run_usd), daily_runs_max: Number(body.daily_runs_max), schedule: body.schedule,
schedule_at: body.schedule_at || null, schedule_dow: body.schedule_dow === '' ? null : Number(body.schedule_dow),
-- quiet_from: body.quiet_from || null, quiet_to: body.quiet_to || null, status: body.status };
++ quiet_from: body.quiet_from || null, quiet_to: body.quiet_to || null, status: body.status,
++ // The hidden 0 always comes; a ticked box adds a 1.
++ ...(body.send_to_vibe !== undefined ? { send_to_vibe: [].concat(body.send_to_vibe).includes('1') } : {}) };
}
r.get('/me/agents', async (req, res) => {
@@@ -135,6 +138,8 @@ ${a.state === 'pending' ? html`<form method="post" action="/me/approvals/${a.id}
const pendingSuggestions = suggestions.filter((p) => p.state === 'pending');
const applied = suggestions.filter((p) => p.state === 'applied').slice(0, 5);
const runs = await s.db.many('SELECT id, state, created_at, result, cost_usd, error_detail FROM tasks WHERE requester = $1 AND via = $2 ORDER BY id DESC LIMIT 20', [a.owner, `agent:${a.id}`]);
++ // Its Vibe chat (agents/vibe-deliver.js), once a run has gone there.
++ const vibeChat = a.send_to_vibe ? await s.db.maybe("SELECT chat_url FROM actor_vibe_deliveries WHERE agent_id = $1 AND owner = $2 AND state = 'sent' AND chat_url IS NOT NULL ORDER BY sent_at DESC LIMIT 1", [a.id, a.owner]) : null;
// The run journal: each run's first line (its answer, or why it did not finish) and what it cost.
const gist = (t) => {
let r = t.result;
@@@ -152,7 +157,7 @@ ${a.last_skip_reason ? html`<p class="notice" role="status">The last scheduled r
<section class="pa-panel"><h2>Run now</h2><form method="post" action="/me/agents/${a.id}/run"><label>Input (optional) <span class="pa-hint">Anything this run should also consider.</span><textarea name="input" maxlength="8000" rows="3"></textarea></label><button type="submit" class="pa-btn pa-primary">Run now</button></form></section>
<section class="pa-panel"><h2>Wake it when something happens</h2><p>Besides its schedule, this agent can run the moment something happens: a channel goes live, a new blog post, a page changes, a price drops, a webhook arrives. <a href="https://openvibe.watch">OpenVibe.Watch</a> watches for it and runs this agent with what it saw.</p>
<p class="pa-actions"><a class="pa-btn" href="https://openvibe.watch/watches/new?template=event&amp;agent_id=${a.id}">When something happens on OpenVibe</a> <a class="pa-btn" href="https://openvibe.watch/watches/new?template=page&amp;agent_id=${a.id}">When a page changes</a></p></section>
--<section class="pa-panel"><h2>Recent runs</h2>${runs.length ? html`<ul class="pa-runs">${runs.map((t) => html`<li><a href="/tasks/${t.id}">${time(t.created_at)}</a> <span class="pill ${pill(t.state)}">${t.state}</span>${usd(t.cost_usd)}${gist(t) ? html`<span class="pa-run-gist">${gist(t)}</span>` : ''}</li>`)}</ul>` : html`<p class="muted">No run yet.</p>`}</section>
++<section class="pa-panel"><h2>Recent runs</h2>${vibeChat && /^https:\/\/openvibe\.chat\/vibe\/v-[0-9a-f]{12}$/.test(vibeChat.chat_url) ? html`<p class="pa-hint">Each run also lands in <a href="${vibeChat.chat_url}">its Vibe chat</a>, where you can ask Vibe about it.</p>` : ''}${runs.length ? html`<ul class="pa-runs">${runs.map((t) => html`<li><a href="/tasks/${t.id}">${time(t.created_at)}</a> <span class="pill ${pill(t.state)}">${t.state}</span>${usd(t.cost_usd)}${gist(t) ? html`<span class="pa-run-gist">${gist(t)}</span>` : ''}</li>`)}</ul>` : html`<p class="muted">No run yet.</p>`}</section>
</div>
${pendingSuggestions.length || applied.length ? html`<section class="pa-panel" aria-labelledby="pa-sugg-h"><h2 id="pa-sugg-h">Suggestions from your agent</h2>
<p class="pa-hint">After a run that passed its check, the agent may suggest a line for its instructions. Nothing changes until you add it, and you can undo it.</p>
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 246aafe..80d43dd 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -38,6 +38,8 @@ const TABLES = [
{ table: 'actor_agent_memory', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agent-memory.json' },
// What their agents suggested adding to their instructions, and what they decided (agents/personal.js).
{ table: 'actor_agent_proposals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agent-suggestions.json' },
++ // Which of their agents' runs went to their Vibe chats (agents/vibe-deliver.js).
++ { table: 'actor_vibe_deliveries', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agent-runs-sent-to-vibe.json' },
{ table: 'actor_agents', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agents.json' },
// What the person's agents asked them to approve (agents/approvals.js), and what they decided.
{ table: 'actor_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approvals.json' },
ddiff --git a/server/index.js b/server/index.js
iindex 4015599..203f6cf 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -20,7 +20,7 @@ const PRUNE_EVERY_MS = 6 * 60 * 60_000;
function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
return gracefulStop({
name: 'Actor', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
-- close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.engine.stop(), () => ctx.webhooks.stop(), () => ctx.chatAsks.stop(), () => ctx.keys.client.stop(), async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); }, () => ctx.s.close(), ...extra],
++ close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.engine.stop(), () => ctx.webhooks.stop(), () => ctx.chatAsks.stop(), () => { if (ctx.vibeDelivery) ctx.vibeDelivery.stop(); }, () => ctx.keys.client.stop(), async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); }, () => ctx.s.close(), ...extra],
});
}
@@@ -36,6 +36,8 @@ async function start() {
ctx.webhooks.start();
// Replies to "@actor …" in chat (chat/asks.js), posted once their task has ended.
ctx.chatAsks.start();
++ // Personal agents' runs into their owners' Vibe chats (agents/vibe-deliver.js).
++ ctx.vibeDelivery.start();
const server = app.listen(config.port, config.host, () => {
const available = require('./agents/catalog').list(config).filter((a) => a.available).map((a) => a.id);
ddiff --git a/test/agents-vibe.test.js b/test/agents-vibe.test.js
nnew file mode 100644
iindex 0000000..c7cc74c
---- /dev/null
++++ b/test/agents-vibe.test.js
@@@ -0,0 +1,76 @@
++'use strict';
++// Each run is a chat you can open (consumer plan §4; server/agents/vibe-deliver.js, migration 0013, chat.vibe.deliver):
++// a personal agent's run that passed its check goes to its owner's Vibe chat named after it, once, with the task id as
++// the key; a failure waits and is tried again, Chat refusing it ends it; an agent with send_to_vibe off sends nothing;
++// the switch is on by default, in the API and on the form.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const personal = require('../server/agents/personal');
++const { createVibeDelivery } = require('../server/agents/vibe-deliver');
++const SAME = { 'sec-fetch-site': 'same-origin' };
++
++(async () => {
++ const sent = [];
++ let answer = () => ({ ok: true, status: 201, url: 'https://openvibe.chat/vibe/v-0123456789ab', repeated: false });
++ const chat = { enabled: true, deliverEnabled: true, post: async () => ({ ok: true, status: 201 }), deliver: async (b) => { sent.push(b); return answer(b); } };
++ const t = await boot({ chatClient: chat, adapters: {
++ checkers: { local: async () => ({ text: '{"ok":true,"reason":"answers"}', cost_usd: 0 }) },
++ run: { 'open-model': async () => ({ text: '**Two new jobs** today.\nREMEMBER: Saw two jobs', sources: [], model: 'fake' }) },
++ } });
++ try {
++ const who = t.network.addUser('vibe-runner');
++ const owner = `user:${who.subject}`;
++ const made = (await personal.create(t.ctx.s, t.config, owner, { name: 'Remote jobs', instructions: 'Find remote jobs.', mode: 'private', budget_per_run_usd: 0.02, daily_runs_max: 5, schedule: 'off', status: 'active' })).row;
++ const delivery = createVibeDelivery({ s: t.ctx.s, config: t.config, chat, log: { warn() {} } });
++ const runOnce = async () => { const out = await personal.run(t.ctx, made, ''); assert.strictEqual(out.status, 201); await t.waitFor(out.task.id); return out.task.id; };
++
++ await check('a run that passed its check goes to Vibe once: the owner, the agent, the answer, its page, the task id as key', async () => {
++ assert.strictEqual(made.send_to_vibe, true, 'on by default');
++ const id = await runOnce();
++ assert.strictEqual(await delivery.tick(), 1);
++ assert.deepStrictEqual(sent.pop(), { subject: who.subject, agent: { id: made.id, name: 'Remote jobs' }, text: '**Two new jobs** today.', url: `${t.config.baseUrl}/tasks/${id}`, key: id });
++ assert.strictEqual(await delivery.tick(), 0, 'sent once');
++ const row = await t.ctx.s.db.maybe('SELECT state, attempts, chat_url FROM actor_vibe_deliveries WHERE task_id = $1', [id]);
++ assert.deepStrictEqual([row.state, Number(row.attempts), row.chat_url], ['sent', 1, 'https://openvibe.chat/vibe/v-0123456789ab']);
++ assert.match((await t.get(`/me/agents/${made.id}`, { as: who })).text, /lands in <a href="https:\/\/openvibe\.chat\/vibe\/v-0123456789ab">its Vibe chat<\/a>/, 'the agent page links its chat');
++ });
++
++ await check('Chat down: it waits and is tried again later; Chat refusing it ends it', async () => {
++ answer = () => ({ ok: false, status: 503, error: 'Chat answered 503' });
++ const id = await runOnce();
++ assert.strictEqual(await delivery.tick(), 0);
++ let row = await t.ctx.s.db.maybe('SELECT state, attempts, next_at FROM actor_vibe_deliveries WHERE task_id = $1', [id]);
++ assert.deepStrictEqual([row.state, Number(row.attempts)], ['pending', 1]);
++ assert.ok(Date.parse(row.next_at) > Date.now() + 60_000, 'waits before the next try');
++ const tries = sent.length;
++ assert.strictEqual(await delivery.tick(), 0);
++ assert.strictEqual(sent.length, tries, 'not before its time');
++ await t.ctx.s.db.query('UPDATE actor_vibe_deliveries SET next_at = $1 WHERE task_id = $2', ['2000-01-01T00:00:00.000Z', id]);
++ answer = () => ({ ok: false, status: 422, error: 'unknown person' });
++ await delivery.tick();
++ row = await t.ctx.s.db.maybe('SELECT state, attempts, error FROM actor_vibe_deliveries WHERE task_id = $1', [id]);
++ assert.deepStrictEqual([row.state, Number(row.attempts), row.error], ['failed', 2, 'unknown person']);
++ answer = () => ({ ok: true, status: 201, url: 'https://openvibe.chat/vibe/v-0123456789ab' });
++ assert.strictEqual(await delivery.tick(), 0, 'a refused run is not sent again');
++ });
++
++ await check('send_to_vibe off: nothing goes; the API takes only true or false; the form keeps the box', async () => {
++ const off = await personal.update(t.ctx.s, t.config, owner, made.id, { send_to_vibe: false });
++ assert.strictEqual(off.row.send_to_vibe, false);
++ await runOnce();
++ const before = sent.length;
++ assert.strictEqual(await delivery.tick(), 0);
++ assert.strictEqual(sent.length, before);
++ assert.match((await personal.update(t.ctx.s, t.config, owner, made.id, { send_to_vibe: 'yes' })).error, /true or false/);
++ const page = await t.get(`/me/agents/${made.id}`, { as: who });
++ assert.match(page.text, /name="send_to_vibe" value="1"> Send each run to Vibe/, 'unticked while off');
++ const fresh = await t.get('/me/agents', { as: who });
++ assert.match(fresh.text, /name="send_to_vibe" value="1" checked> Send each run to Vibe/, 'a new agent starts with it on');
++ const api = await t.get(`/api/v1/me/agents/${made.id}`, { as: who });
++ assert.strictEqual(api.json().send_to_vibe, false);
++ const on = await t.get(`/api/v1/me/agents/${made.id}`, { as: who, method: 'PATCH', json: { send_to_vibe: true }, headers: SAME });
++ assert.strictEqual(on.json().send_to_vibe, true, on.text);
++ });
++ } finally { await t.close(); }
++ done();
++})();