openvibers/openvibe.codes Commits

Agent Streams S3: the finished task's report, from its proof (#288)

OP OpenVibers committed 7 days ago · 9847db3ec3 · 6 files, +269 −1

* Agent Streams S3: Agent Streams S3: the finished task's report, from its proof

* Agent Streams S3: the session report Codes sends at a task's end, and its test

* Agent Streams S3: the report client is on the SSRF ratchet

Changes

ddiff --git a/server/config.js b/server/config.js
iindex 3a8ab04..8936ad7 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -45,6 +45,8 @@ function load(env = process.env) {
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
networkUrl,
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
++ // OpenVibe.Report (the session report of a finished task, Agent Streams S3).
++ reportInternalUrl: trim(env.OV_REPORT_INTERNAL_URL || 'http://127.0.0.1:5080'),
networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
// OpenVibe Git (Forgejo, deploy/forgejo): its loopback address for the home page's repository list (public
// repositories only, no token), and the public address people use.
ddiff --git a/server/domain/run-tasks.js b/server/domain/run-tasks.js
iindex 3d4b390..6ff8800 100644
---- a/server/domain/run-tasks.js
++++ b/server/domain/run-tasks.js
@@@ -147,7 +147,7 @@ function usageOf(result) {
};
}
--function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null, runTokens = null, connections = null, attempts = null, proof = null, benchAttemptEnded = null, conductor = null, fleet = null, notices = null, placementContext = null, harnesses = null, knowledge = null }) {
++function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null, runTokens = null, connections = null, attempts = null, proof = null, benchAttemptEnded = null, conductor = null, fleet = null, notices = null, placementContext = null, harnesses = null, knowledge = null, report = null }) {
const db = store.db;
const who = (row) => ({ subject: row.subject, project: row.project_id });
// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).
@@@ -155,6 +155,8 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
const taskOutbox = outbox || require('../events/outbox').createOutbox({ store, log });
// The proof of a finished task (stage C8): the tests, the pages, the diff and the second model's opinion.
const taskProof = proof || require('./proof').createProof({ store, config, log });
++ // The session report of a finished task (Agent Streams S3): Codes writes it to OpenVibe.Report when the task ends.
++ const reportClient = report || require('../report').createReportClient({ config, log });
const attemptStore = attempts || require('./attempts').createAttempts({ store, config, log });
/**
* The conductor's turns (server/domain/conductor.js). Wired by the app after both exist, because the conductor
@@@ -737,6 +739,11 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
[row.id, outcome.state, outcome.note, store.now()]);
await notifyOutcome(saved);
await recordPlacementOutcome(saved);
++ // Agent Streams S3: the finished task's report, from its proof. A report that cannot be written never
++ // changes the task: the task is done either way.
++ try { await writeSessionReport(saved, collected, ending); } catch (e) {
++ log.warn('[Codes] the session report of task ' + row.id + ' failed: ' + String((e && e.message) || e).slice(0, 200));
++ }
return saved;
}
if (!plan.kind) return row;
@@@ -744,6 +751,20 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
return done.row || row;
}
++ /**
++ * Agent Streams S3: the session report of a finished task, from its proof.
++ *
++ * The proof is what the task's own machine collected: the tests that ran, the pages that were shot, the diff and
++ * the second model's opinion. The report is that proof in plain words: what was asked, what came out, what it
++ * cost and how it ended. The raw transcript is never in it, and Report's house redactor runs over every text field
++ * before anything is stored.
++ */
++ async function writeSessionReport(row, proofItems, ending) {
++ const payload = require('../session-report').buildSessionReport({ row, proofItems, ending });
++ if (!payload) return null;
++ return reportClient.send(payload);
++ }
++
/**
* The proof this attempt's machine collected: the items are kept, and each one is an event on the task's stream.
*
ddiff --git a/server/report.js b/server/report.js
nnew file mode 100644
iindex 0000000..f71033c
---- /dev/null
++++ b/server/report.js
@@@ -0,0 +1,66 @@
++'use strict';
++
++/**
++ * The session report of a finished task (Agent Streams S3).
++ *
++ * When a task ends, Codes sends its report to OpenVibe.Report: what was asked, what came out, the proof (the tests,
++ * the screenshots and the review), what it cost and how it ended. The write is idempotent per (source, task ref), so
++ * a task that ends twice keeps one report.
++ *
++ * The token is Codes' own, for the audience openvibe.report, with the capability report.session.write (Network's
++ * principals seed). The report never holds the raw transcript: Report's house redactor runs over every text field
++ * before anything is stored.
++ *
++ * createReportClient({ config, fetchImpl, log })
++ * send(payload) the report of one finished task
++ */
++const TOKEN_TTL_MS = 10 * 60_000;
++
++function createReportClient({ config = {}, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() } = {}) {
++ const base = String(config.reportInternalUrl || 'http://127.0.0.1:5080').replace(/\/+$/, '');
++ let cached = null;
++
++ /** Codes' service token for Report's audience. */
++ async function token() {
++ if (cached && cached.until > now()) return cached.token;
++ const clientId = config.oauth && config.oauth.clientId;
++ const clientSecret = config.oauth && config.oauth.clientSecret;
++ if (!clientId || !clientSecret) throw new Error('Codes has no Network client secret, so it cannot write a session report');
++ const body = new URLSearchParams({
++ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret,
++ audience: 'openvibe.report', scope: 'report.session.write',
++ });
++ const res = await fetchImpl(`${config.networkInternalUrl}/oauth/token`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
++ body: body.toString(),
++ signal: AbortSignal.timeout(15_000),
++ });
++ const doc = await res.json().catch(() => null);
++ if (!res.ok || !doc || !doc.access_token) throw new Error(`OpenVibe.Network answered ${res.status}`);
++ cached = { token: String(doc.access_token), until: now() + TOKEN_TTL_MS };
++ return cached.token;
++ }
++
++ /** Send one task's report. Throws when Report does not answer; the caller decides what that means. */
++ async function send(payload) {
++ const t = await token();
++ const res = await fetchImpl(`${base}/internal/session-reports`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${t}` },
++ body: JSON.stringify(payload),
++ signal: AbortSignal.timeout(15_000),
++ });
++ const doc = await res.json().catch(() => null);
++ if (!res.ok) {
++ const err = new Error((doc && (doc.detail || doc.error)) || `OpenVibe.Report answered ${res.status}`);
++ err.status = res.status;
++ throw err;
++ }
++ return doc;
++ }
++
++ return { send, token, baseUrl: base };
++}
++
++module.exports = { createReportClient };
ddiff --git a/server/session-report.js b/server/session-report.js
nnew file mode 100644
iindex 0000000..5de69b7
---- /dev/null
++++ b/server/session-report.js
@@@ -0,0 +1,89 @@
++'use strict';
++
++/**
++ * The session report of a finished task (Agent Streams S3): the payload Codes sends to OpenVibe.Report.
++ *
++ * The proof is what the task's own machine collected (engine/proof.js, harness/proof.js): the tests that ran, the
++ * pages that were shot, the diff and the second model's opinion. The report is that proof in plain words: what was
++ * asked, what came out, the steps, what it cost and how it ended.
++ *
++ * Never in the report: the raw transcript (Codes keeps it for the owner, privately), a secret (Report's house
++ * redactor runs over every text field before anything is stored) and the code itself (a pull request is a link).
++ */
++
++/** The proof item's own data, whatever shape it arrives in. */
++function dataOf(item) {
++ if (!item || typeof item !== 'object') return {};
++ if (item.data && typeof item.data === 'object') return { ...item.data, kind: item.data.kind || item.kind };
++ return item;
++}
++
++function buildSessionReport({ row, proofItems = [], ending = null } = {}) {
++ const task = row && typeof row === 'object' ? row : {};
++ const owner = String(task.subject || '');
++ if (!owner.startsWith('usr_')) return null;
++ const items = (Array.isArray(proofItems) ? proofItems : []).map(dataOf);
++ const kindOf = (k) => items.find((i) => i.kind === k) || null;
++ const testsItem = kindOf('tests');
++ const reviewItem = kindOf('review');
++ const shotsItem = kindOf('screenshots');
++ const diffItem = kindOf('diff');
++ const count = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
++ const tests = testsItem ? { passed: count(testsItem.passed), failed: count(testsItem.failed) } : null;
++ const screenshots = shotsItem
++ ? [].concat(shotsItem.media_ids || shotsItem.screenshots || shotsItem.ids || []).map(String).filter(Boolean).slice(0, 20)
++ : [];
++ const review = reviewItem
++ ? { verdict: String(reviewItem.verdict || reviewItem.summary || 'Reviewed').slice(0, 40), notes: reviewItem.notes || reviewItem.note || null }
++ : null;
++ const outputs = [];
++ if (diffItem) {
++ outputs.push({
++ kind: 'file',
++ url: diffItem.url || null,
++ title: String(diffItem.title || diffItem.summary || 'What changed').slice(0, 200),
++ state: null,
++ });
++ }
++ // A task that opened a pull request has one to point at (a link, never the code itself).
++ if (task.pull_request) {
++ outputs.push({
++ kind: 'pr',
++ url: task.pr_url || null,
++ title: String(task.pr_title || 'The pull request').slice(0, 200),
++ state: task.pr_state || null,
++ });
++ }
++ const timeline = items.map((i) => ({
++ at: i.at || task.finished_at || null,
++ title: String(i.title || i.label || i.kind || 'a step').slice(0, 120),
++ status: i.status === 'failed' ? 'failed' : 'passed',
++ detail: i.detail || i.summary || null,
++ tests: i.kind === 'tests' ? { passed: count(i.passed), failed: count(i.failed) } : null,
++ }));
++ const started = task.started_at ? Date.parse(String(task.started_at)) : NaN;
++ const ended = task.finished_at ? Date.parse(String(task.finished_at)) : NaN;
++ const worked = Number.isFinite(started) && Number.isFinite(ended) ? Math.max(0, Math.round((ended - started) / 1000)) : 0;
++ const outcome = task.outcome === 'done' ? 'done' : (task.outcome === 'stopped' ? 'stopped' : 'failed');
++ const title = String(task.title || (String(task.prompt || '').split('\n')[0]) || 'A task').slice(0, 200);
++ return {
++ owner,
++ source: 'codes',
++ task: { title, ref: task.id, url: 'https://openvibe.codes/t/' + encodeURIComponent(String(task.id || '')) },
++ started_at: task.started_at || null,
++ ended_at: task.finished_at || null,
++ worked_seconds: worked,
++ outcome,
++ stopped_because: outcome === 'done' ? null : (task.outcome_note || null),
++ summary: String(task.outcome_note || title).slice(0, 600),
++ timeline,
++ outputs,
++ proof: { tests, screenshots, review },
++ cost_usd: ending && ending.cost_usd != null ? Number(ending.cost_usd) : null,
++ models: ending && ending.model ? [String(ending.model)] : [],
++ highlights: [],
++ visibility: 'private',
++ };
++}
++
++module.exports = { buildSessionReport, dataOf };
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 061fb53..6764fdd 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -61,6 +61,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
'server/domain/fleet.js': 'OpenVibe.Bot (CODES_BOT_INTERNAL_URL, configured) /api/v1/machines with the person\'s own session token: their machines and the fleet their Nodes reported; the path is fixed, never a URL from a person',
'server/domain/ship.js': 'the service\'s own GET <publicOrigin>/release.json and api.github.com\'s compare, both at fixed paths: the origin is the contracts\' service manifest, never a URL from a person; the shas are checked hex',
'server/events-consumer.js': 'OpenVibe.Events subscriptions (EVENTS_URL, configured) at boot; no URL from a person',
++ 'server/report.js': 'OpenVibe.Report (OV_REPORT_INTERNAL_URL, configured) for the report of a finished task, and the Network token endpoint (OV_NETWORK_INTERNAL_URL, configured) for the Codes service token; the paths are fixed, no URL from a person',
'server/events/outbox.js': 'OpenVibe.Events publish (EVENTS_URL, configured) of fixed codes.task.* payloads; no URL from a person',
};
const root = path.join(__dirname, '..');
ddiff --git a/test/session-report.test.js b/test/session-report.test.js
nnew file mode 100644
iindex 0000000..20d64d3
---- /dev/null
++++ b/test/session-report.test.js
@@@ -0,0 +1,89 @@
++'use strict';
++/**
++ * Agent Streams S3: Codes sends the finished task's report, from its proof.
++ *
++ * node test/session-report.test.js
++ */
++const assert = require('assert');
++const { buildSessionReport } = require('../server/session-report');
++const { createReportClient } = require('../server/report');
++
++const OWNER = 'usr_01JAN2C3D4E5F6G7H8J9K0MNPS';
++
++function task(over = {}) {
++ return {
++ id: 'tsk_01JAN2C3D4E5F6G7H8J9K0MNPS',
++ subject: OWNER,
++ prompt: 'Ship the checkout page',
++ started_at: '2026-11-06T18:00:00.000Z',
++ finished_at: '2026-11-06T18:42:00.000Z',
++ outcome: 'done',
++ outcome_note: 'Done with proof',
++ ...over,
++ };
++}
++
++const proofItems = [
++ { kind: 'tests', passed: 12, failed: 0, title: 'Run the checkout tests', at: '2026-11-06T18:20:00.000Z' },
++ { kind: 'screenshots', media_ids: ['med_01JAN2C3D4E5F6G7H8J9K0MNPS'], title: 'The pages it built' },
++ { kind: 'diff', summary: 'Three files changed, 120 lines added', label: 'diff' },
++ { kind: 'review', verdict: 'Looks good', notes: 'The mobile layout needs a wider gutter.' },
++];
++
++(async () => {
++ // The report the task's end sends.
++ const report = buildSessionReport({ row: task({ pull_request: true }), proofItems, ending: { cost_usd: 0.31, model: 'claude-sonnet-4' } });
++ assert.ok(report, 'the task has a report');
++ assert.strictEqual(report.owner, OWNER, "the report is the person's");
++ assert.strictEqual(report.source, 'codes');
++ assert.strictEqual(report.task.ref, 'tsk_01JAN2C3D4E5F6G7H8J9K0MNPS', 'the task is the task it is about');
++ assert.strictEqual(report.task.url, 'https://openvibe.codes/t/tsk_01JAN2C3D4E5F6G7H8J9K0MNPS');
++ assert.strictEqual(report.outcome, 'done');
++ assert.strictEqual(report.worked_seconds, 2520, '42 minutes of work');
++ assert.strictEqual(report.cost_usd, 0.31, 'what it cost');
++ assert.deepStrictEqual(report.models, ['claude-sonnet-4'], 'which models ran');
++ assert.deepStrictEqual(report.proof.tests, { passed: 12, failed: 0 }, 'the tests from the proof');
++ assert.deepStrictEqual(report.proof.screenshots, ['med_01JAN2C3D4E5F6G7H8J9K0MNPS'], 'the screenshots from the proof');
++ assert.strictEqual(report.proof.review.verdict, 'Looks good', 'the review from the proof');
++ assert.strictEqual(report.outputs.length, 2, 'what came out');
++ assert.strictEqual(report.outputs[0].kind, 'file', 'the diff is the change');
++ assert.strictEqual(report.outputs[1].kind, 'pr', 'a task that opened a pull request has one to point at');
++ assert.strictEqual(report.timeline.length, 4, 'the steps are the proof items');
++ assert.strictEqual(report.timeline[0].tests.passed, 12, 'the tests step counts them');
++ assert.strictEqual(report.visibility, 'private', 'a report is private until its owner says otherwise');
++
++ // A task that did not finish says why, in plain words.
++ const stopped = buildSessionReport({ row: task({ outcome: 'unfinished', outcome_note: "Didn't finish, with what is left" }), proofItems: [] });
++ assert.strictEqual(stopped.outcome, 'failed');
++ assert.strictEqual(stopped.stopped_because, "Didn't finish, with what is left");
++
++ // A task of nobody's has no report.
++ assert.strictEqual(buildSessionReport({ row: task({ subject: 'x' }), proofItems: [] }), null);
++
++ const calls = [];
++ const fetchImpl = async (url, opts = {}) => {
++ calls.push({ url: String(url), opts });
++ if (String(url).includes('/oauth/token')) {
++ return { ok: true, status: 200, json: async () => ({ access_token: 'the-service-token' }) };
++ }
++ return { ok: true, status: 201, json: async () => ({ id: 'rps_01JAN2C3D4E5F6G7H8J9K0MNPS', url: 'https://openvibe.report/s/rps_01JAN2C3D4E5F6G7H8J9K0MNPS', created: true }) };
++ };
++ const client = createReportClient({
++ config: { networkInternalUrl: 'http://127.0.0.1:4000', reportInternalUrl: 'http://127.0.0.1:5080', oauth: { clientId: 'codes', clientSecret: 'the-secret' } },
++ fetchImpl,
++ });
++ const out = await client.send(report);
++ assert.strictEqual(out.id, 'rps_01JAN2C3D4E5F6G7H8J9K0MNPS');
++ const token = calls.find((c) => c.url.includes('/oauth/token'));
++ assert.ok(token, 'Codes asks Network for a token');
++ assert.ok(String(token.opts.body).includes("audience=openvibe.report"), "for Report's audience");
++ assert.ok(String(token.opts.body).includes('scope=report.session.write'), 'with the report capability');
++ const post = calls.find((c) => c.url.includes('/internal/session-reports'));
++ assert.ok(post, 'Codes posts the report to Report');
++ assert.strictEqual(post.opts.method, 'POST');
++ assert.strictEqual(post.opts.headers.Authorization, 'Bearer the-service-token');
++ const sent = JSON.parse(post.opts.body);
++ assert.strictEqual(sent.proof.tests.passed, 12, 'the proof goes with it');
++
++ console.log(' ✓ Codes sends the finished task\'s report, from its proof');
++})().catch((e) => { console.error(e); process.exit(1); });