openvibers/openvibe.codes Commits
Agent Streams S3: the finished task's report, from its proof (#288)
Changes
@@@ -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.@@@ -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, ouconst 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, oureturn 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.*nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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, '..');nnew file mode 100644@@@ -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); });
@@ -45,6 +45,8 @@ function load(env = process.env) {
@@ -45,6 +45,8 @@ function load(env = process.env) {
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
networkUrl,
networkUrl,
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
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),
networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
// OpenVibe Git (Forgejo, deploy/forgejo): its loopback address for the home page's repository list (public
// 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.
// repositories only, no token), and the public address people use.
@@ -147,7 +147,7 @@ function usageOf(result) {
@@ -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 db = store.db;
const who = (row) => ({ subject: row.subject, project: row.project_id });
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).
// 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
@@ -155,6 +155,8 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
const taskOutbox = outbox || require('../events/outbox').createOutbox({ store, log });
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.
// 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 });
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 });
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
* 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
@@ -737,6 +739,11 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
[row.id, outcome.state, outcome.note, store.now()]);
[row.id, outcome.state, outcome.note, store.now()]);
await notifyOutcome(saved);
await notifyOutcome(saved);
await recordPlacementOutcome(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;
return saved;
}
}
if (!plan.kind) return row;
if (!plan.kind) return row;
@@ -744,6 +751,20 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -744,6 +751,20 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
return done.row || row;
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.
* The proof this attempt's machine collected: the items are kept, and each one is an event on the task's stream.
*
*
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -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 };
@@ -61,6 +61,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
@@ -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/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/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/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',
'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, '..');
const root = path.join(__dirname, '..');
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -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); });