openvibers/openvibe.report Commits
Agent Streams S3: the report of every agent session (#108)
Changes
nnew file mode 100644@@@ -0,0 +1,44 @@++-- phase: expand++-- Agent Streams S3: the report of every agent session (report.session@1).++--++-- A session report is one agent session's story: what was asked, what came out, what it cost and how it ended.++-- It is written by the first-party service that ran the session (Codes, Actor, Live) through++-- POST /internal/session-reports, under the capability report.session.write, and it is idempotent per++-- (source, task_ref): the same session's task always answers the same report, so a repeat never makes a second one.++--++-- The report never holds the raw transcript, a secret or a diff: the house redactor runs over every text field++-- before anything is stored, and a pull request is only ever a link.++--++-- This migration only adds a table and its indexes: nothing that exists is changed or dropped, so it can run++-- beside the old code and the old code keeps working.++CREATE TABLE report_sessions (++ id text COLLATE "C" PRIMARY KEY,++ owner text COLLATE "C" NOT NULL,++ source text COLLATE "C" NOT NULL,++ task_ref text COLLATE "C" NOT NULL,++ task_title text NOT NULL,++ task_url text,++ started_at timestamptz,++ ended_at timestamptz,++ worked_seconds bigint NOT NULL DEFAULT 0,++ outcome text COLLATE "C" NOT NULL,++ stopped_because text,++ summary text NOT NULL,++ timeline jsonb NOT NULL DEFAULT '[]'::jsonb,++ outputs jsonb NOT NULL DEFAULT '[]'::jsonb,++ proof jsonb NOT NULL DEFAULT '{}'::jsonb,++ cost_usd numeric,++ models jsonb NOT NULL DEFAULT '[]'::jsonb,++ highlights jsonb NOT NULL DEFAULT '[]'::jsonb,++ stream jsonb,++ visibility text COLLATE "C" NOT NULL DEFAULT 'private',++ created_at timestamptz NOT NULL DEFAULT now(),++ updated_at timestamptz NOT NULL DEFAULT now()++);++++-- The idempotency key: one report per (source, task).++CREATE UNIQUE INDEX report_sessions_source_task ON report_sessions (source, task_ref);++-- The Rundown's "What agents built today" and the sitemap read the public ones, newest first.++CREATE INDEX report_sessions_public ON report_sessions (visibility, created_at DESC);++-- A person's own reports.++CREATE INDEX report_sessions_owner ON report_sessions (owner, created_at DESC);@@@ -353,3 +353,22 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex.rp-fold-block > summary + * { margin-top: 0 !important; padding-top: 4px !important; }.rp-fold-block[open] { padding-bottom: 12px; }}++/* Session reports (S3): one wrapping meta row, the tests as a chip, screenshots as a strip, steps with status dots. */++.rp-sr-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; margin: 6px 0 0; color: var(--text-secondary, #9aa4b2); font-size: .9rem; }++.rp-proof-tests { display: inline-flex; margin: 0 0 12px; padding: 4px 12px; border-radius: 999px; font-weight: 700; font-size: .9rem; }++.rp-proof-tests.is-ok { background: color-mix(in srgb, #22c55e 16%, transparent); color: #4ade80; }++.rp-proof-tests.is-bad { background: color-mix(in srgb, #ef4444 16%, transparent); color: #f87171; }++.rp-shots { list-style: none; margin: 0 0 12px; padding: 0 0 4px; display: flex; gap: 10px; overflow-x: auto; position: relative; }++.rp-shots img { display: block; width: 200px; height: 125px; object-fit: cover; border-radius: 10px; border: 1px solid var(--border, #233047); background: var(--bg-card, #111827); }++.rp-review p { margin: 0 0 6px; }++.rp-steps { list-style: none; margin: 0; padding: 0; display: grid; gap: 0; }++.rp-steps li { display: grid; grid-template-columns: 14px minmax(0, 1fr); gap: 10px; padding: 8px 0; border-left: 2px solid var(--border, #233047); margin-left: 6px; padding-left: 0; }++.rp-steps li > div { min-width: 0; }++.rp-steps p { margin: 0; overflow-wrap: anywhere; }++.rp-step-dot { width: 10px; height: 10px; margin: 6px 0 0 -6px; border-radius: 999px; background: #3b82f6; box-shadow: 0 0 0 3px var(--bg-primary, #0b1220); }++.rp-steps li.is-passed .rp-step-dot { background: #22c55e; }++.rp-steps li.is-failed .rp-step-dot { background: #ef4444; }++.rp-steps li.is-skipped .rp-step-dot { background: #6b7280; }++.rp-step-st { color: var(--text-secondary, #9aa4b2); font-size: .82rem; font-weight: 500; }++.rp-steps li.is-failed .rp-step-st { color: #f87171; }++.rp-step-detail { color: var(--text-secondary, #9aa4b2); font-size: .9rem; }nnew file mode 100644@@@ -0,0 +1,98 @@++'use strict';++/**++ * The report page, shot locally (no deployment, no network): 390 and 1280, dark and light.++ *++ * node scripts/session-report-shots.js [outDir]++ */++const path = require('path');++const fs = require('fs');++const { boot } = require('../test/helpers/boot');++const { chromium } = require('/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright');++const { BUILTIN_THEMES } = require('openvibe-shared/builtin-themes');++++// The site wears the theme it is given: the frame reads it from localStorage (ov_theme), and the network's theme++// service is not reachable offline, so the light shots are taken with the site's own light theme (Paper).++const lightTheme = (Array.isArray(BUILTIN_THEMES) ? BUILTIN_THEMES : Object.values(BUILTIN_THEMES || {}))++ .find((t) => t && t.id === 'paper');++++const outDir = process.argv[2] || '/tmp/report-shots';++++(async () => {++ const t = await boot({ db: true, now: () => Date.parse('2026-11-06T20:00:00Z') });++ try {++ const out = await t.ctx.sessionReports.write({++ owner: 'usr_01JAN2C3D4E5F6G7H8J9K0MNPS',++ source: 'codes',++ task: { title: 'Ship the checkout page', ref: 'tsk_01JAN2C3D4E5F6G7H8J9K0MNPS', url: 'https://openvibe.codes/t/tsk_01JAN2C3D4E5F6G7H8J9K0MNPS' },++ started_at: '2026-11-06T18:00:00.000Z',++ ended_at: '2026-11-06T18:42:00.000Z',++ worked_seconds: 2520,++ outcome: 'done',++ summary: 'Asked for a checkout page; the agent read the shop, built the page, ran the tests and opened a pull request.',++ timeline: [++ { at: '2026-11-06T18:03:00.000Z', title: 'Read the shop code', status: 'passed', detail: 'The coupon logic lives in cart.js' },++ { at: '2026-11-06T18:21:00.000Z', title: 'Build the checkout page', status: 'passed' },++ { at: '2026-11-06T18:33:00.000Z', title: 'Run the checkout tests', status: 'passed', tests: { passed: 12, failed: 0 } },++ ],++ outputs: [++ { kind: 'pr', url: 'https://forgejo.example/openvibe/site/pulls/7', title: 'Checkout page', state: 'open' },++ { kind: 'vod', url: 'https://openvibe.video/v/vod_01JAN2C3D4E5F6G7H8J9K0MNPS', title: 'The recording' },++ ],++ proof: {++ tests: { passed: 12, failed: 0 },++ screenshots: ['med_01JAN2C3D4E5F6G7H8J9K0MNPS'],++ review: { verdict: 'Looks good', notes: 'The mobile layout needs a wider gutter on the totals row.' },++ },++ cost_usd: 0.31,++ models: ['claude-sonnet-4'],++ highlights: ['Found the coupon bug on the first read', 'Twelve tests, no failures'],++ visibility: 'public',++ });++ const url = t.base + '/s/' + out.id;++ fs.mkdirSync(outDir, { recursive: true });++ const b = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });++ const notes = [];++ for (const [w, scheme] of [[390, 'dark'], [390, 'light'], [1280, 'dark'], [1280, 'light']]) {++ const pg = await b.newPage({++ viewport: { width: w, height: 900 }, colorScheme: scheme, deviceScaleFactor: 2,++ isMobile: w < 700, hasTouch: w < 700,++ });++ const errs = [];++ pg.on('pageerror', (e) => errs.push(e.message));++ pg.on('console', (m) => { if (m.type() === 'error') errs.push(m.text()); });++ await pg.goto(url, { waitUntil: 'load' });++ // The site wears its own theme; the light shots put the light theme on (the offline frame cannot ask++ // openvibe.network which one the person chose).++ if (scheme === 'light') {++ await pg.addInitScript((theme) => {++ try { localStorage.setItem('ov_theme', JSON.stringify(theme)); } catch (e) { /* */ }++ }, lightTheme);++ }++ await pg.waitForTimeout(1200);++ if (scheme === 'light') {++ // The site's own theme variables, put on the page the way the frame does it (openvibe-shared/app-icon.js):++ // the network's theme service is not reachable offline, so the light theme is applied here.++ await pg.evaluate((theme) => {++ const el = document.documentElement;++ const vars = (theme && theme.variables) || {};++ for (const [k, v] of Object.entries(vars)) if (k.charAt(0) === '-') el.style.setProperty(k, v);++ if (vars['--bg-primary']) el.style.background = vars['--bg-primary'];++ if (vars['--text-primary']) el.style.color = vars['--text-primary'];++ if (vars['--color-scheme']) el.style.colorScheme = vars['--color-scheme'];++ if (theme && theme.id) el.setAttribute('data-theme', theme.id);++ }, lightTheme);++ await pg.waitForTimeout(400);++ }++++ const docW = await pg.evaluate(() => document.documentElement.scrollWidth);++ const file = path.join(outDir, 'session-report-' + w + '-' + scheme + '.png');++ await pg.screenshot({ path: file, fullPage: true });++ notes.push({ width: w, scheme, scrollWidth: docW, overflow: docW > w, errors: errs, file });++ await pg.close();++ }++ await b.close();++ console.log(JSON.stringify({ url, notes }, null, 2));++ } finally {++ await t.close();++ }++})().catch((e) => { console.error(e); process.exit(1); });@@@ -36,6 +36,8 @@ const { createAiUsage } = require('./analytics/ai-usage');const { createAiExchange } = require('./analytics/ai-exchange');const createAiExchangeConfig = require('./ai-exchange-config').create;const { createSearchIndex } = require('./search-index');++const { createSessionReports } = require('./session-reports');++const { createSessionReportRoutes } = require('./http/session-reports');const { createShadow } = require('./analytics/shadow');const { createApi } = require('./http/api');const { createPageRoutes, createPublicPages } = require('./http/pages');@@@ -72,8 +74,10 @@ async function createApp(opts = {}) {// AI Exchange analytics (G-P9, flag off): Billing's settlements, aggregated. No database, no ledger.const aiExchange = opts.aiExchange !== undefined ? opts.aiExchange : (store ? createAiExchange({ store, now, log }) : null);const shadow = opts.shadow || (store ? createShadow({ store, network, now, log }) : null);++ // The report of every agent session (Agent Streams S3): one table, one route, one page.++ const sessionReports = opts.sessionReports || (store ? createSessionReports({ store, config, log }) : null);// The Rundown topic pages in OpenVibe.Search (started by server/index.js, never under REPORT_DRILL).-- const searchIndex = opts.searchIndex || (store ? createSearchIndex({ config, store, live, log }) : null);++ const searchIndex = opts.searchIndex || (store ? createSearchIndex({ config, store, live, sessionReports, log }) : null);views.configure({ baseUrl: config.baseUrl });// A person's sign-in (the `report` client, the same id and secret Report already reads Billing with).@@@ -83,7 +87,7 @@ async function createApp(opts = {}) {secureCookies: /^https:/.test(config.baseUrl), fetch: fetchImpl, log,});-- const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, analytics, shadow, sso, aiUsage, aiExchange, searchIndex, exchangeConfig };++ const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, analytics, shadow, sso, aiUsage, aiExchange, searchIndex, exchangeConfig, sessionReports };const app = express();if (exchangeConfig) exchangeConfig.mount(app);// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts@@@ -184,6 +188,9 @@ async function createApp(opts = {}) {app.use('/auth', sso.router(express));app.use(sso.optionalAuth());++ // ── The door for the first-party services that ran an agent session (Codes, Actor, Live) ──++ if (sessionReports) app.use('/internal', createSessionReportRoutes({ config, sessionReports, log, verify: opts.verifyServiceToken }));++// ── Loopback-only operator and Events routes ────────────app.use('/internal', createInternalRoutes({ analytics, shadow, config, now, log }));@@@ -32,7 +32,7 @@ function siteUpdated() {}}--function createDiscoveryRoutes({ config, live, bench }) {++function createDiscoveryRoutes({ config, live, bench, sessionReports = null }) {const r = express.Router();const site = String(config.baseUrl || 'https://openvibe.report').replace(/\/+$/, '');const abs = (p) => `${site}${p}`;@@@ -46,7 +46,7 @@ function createDiscoveryRoutes({ config, live, bench }) {});r.get('/sitemap.xml', async (_req, res) => {-- const [data, ai] = await Promise.all([live.topics(), bench.kinds()]);++ const [data, ai, reports] = await Promise.all([live.topics(), bench.kinds(), sessionReports ? sessionReports.listPublic({ limit: 500 }) : []]);const topics = (data && Array.isArray(data.topics)) ? data.topics : [];const kinds = (ai && Array.isArray(ai.data.kinds)) ? ai.data.kinds : [];const lastmod = siteUpdated();@@@ -56,6 +56,7 @@ function createDiscoveryRoutes({ config, live, bench }) {{ loc: abs('/design'), changefreq: 'weekly', priority: 0.6 },{ loc: abs('/rundown'), changefreq: 'daily', priority: 0.9 },{ loc: abs('/ai'), changefreq: 'daily', priority: 0.9 },++ ...reports.map((rep) => ({ loc: abs('/s/' + encodeURIComponent(rep.id)), changefreq: 'daily', priority: 0.6 })),...kinds.filter((k) => k && bench.isKindId(k.id)).map((k) => ({loc: abs(`/ai/${encodeURIComponent(k.id)}`), changefreq: 'daily', priority: 0.7,})),@@@ -29,7 +29,7 @@ const designHealth = require('../data/design-health');const CACHE = 'public, max-age=120';/** The pages the agent layer twins: their data, then the view. Each returns { html } or null (nothing to show). */--function createPublicPages({ network, live, bench, analytics = null }) {++function createPublicPages({ sessionReports, network, live, bench, analytics = null }) {const numbers = (days) => (analytics ? analytics.stats(days) : network.stats(days));async function home() {@@@ -72,15 +72,18 @@ function createPublicPages({ network, live, bench, analytics = null }) {function design() { return { html: designViews.designHealth(designHealth.all()) }; }async function rundown() {-- const [r, topics] = await Promise.all([live.rundown(), live.topics()]);-- return { html: views.rundown({ rundown: r, topics }) };++ const [r, topics, reports] = await Promise.all([++ live.rundown(), live.topics(),++ sessionReports ? sessionReports.listPublicToday({ limit: 24 }) : [],++ ]);++ return { html: views.rundown({ rundown: r, topics, reports }) };}return { home, stats, developers, design, rundown, aiIndex, aiBoard, aiModel };}function createPageRoutes(ctx) {-- const { live, bench } = ctx;++ const { live, bench, sessionReports } = ctx;const r = express.Router();const publicPages = ctx.publicPages || createPublicPages(ctx);// A path ending in .md is a Markdown twin (server/agent-pages.js), never a page parameter.@@@ -149,9 +152,23 @@ function createPageRoutes(ctx) {});r.get('/rundown', async (req, res) => {-- const [rundown, topics] = await Promise.all([live.rundown(), live.topics()]);++ const [rundown, topics, reports] = await Promise.all([++ live.rundown(), live.topics(),++ sessionReports ? sessionReports.listPublicToday({ limit: 24 }) : [],++ ]);if (!rundown || !topics) return notFound(req, res);-- return send(req, res, views.rundown({ rundown, topics }));++ return send(req, res, views.rundown({ rundown, topics, reports }));++ });++++ // One agent session's report (Agent Streams S3). A private report is the owner's alone, an unlisted one opens++ // for anyone with the link, a public one opens for everyone.++ r.get('/s/:id', async (req, res) => {++ const id = String(req.params.id || '');++ const rep = sessionReports ? await sessionReports.get(id) : null;++ if (!rep) return notFound(req, res);++ const viewer = req.user ? String(req.user.sub || req.user.id || '') : '';++ if (rep.visibility === 'private' && viewer !== rep.owner) return notFound(req, res);++ return send(req, res, views.sessionReport({ report: rep, signedIn: Boolean(viewer) }));});r.get('/rundown/t/:slug', async (req, res) => {nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++++/**++ * POST /internal/session-reports: the door for the first-party services that ran an agent session.++ *++ * The caller is Codes, Actor or Live, with a Network service token that holds the capability report.session.write++ * for the audience openvibe.report (the grant is in Network's principals seed). The body names the person the++ * session was theirs; a token that acts for a person may only write that person's report.++ *++ * The answer is the report's id and its page (openvibe.report/s/<id>), and the write is idempotent per++ * (source, task ref): the same session's task always answers the same report.++ */++const express = require('express');++const contracts = require('openvibe-contracts');++const { verifyServiceToken } = require('openvibe-sdk/auth');++++const CAP = 'report.session.write';++const AUDIENCE = 'openvibe.report';++++function bearer(req) {++ const h = String(req.headers.authorization || '');++ const m = h.match(/^Bearer\s+(.+)$/i);++ return m ? m[1].trim() : null;++}++++function createSessionReportRoutes({ config, sessionReports, log = console, verify = null }) {++ const r = express.Router();++ const net = String((config && (config.networkPublicUrl || config.networkUrl)) || 'https://openvibe.network').replace(/\/+$/, '');++ const issuer = (config && config.network && config.network.issuer) || config.networkIssuer || 'https://openvibe.network';++ const jwks = `${net}/api/.well-known/jwks`;++++ r.post('/session-reports', express.json({ limit: '256kb' }), async (req, res) => {++ const token = bearer(req);++ if (!token) return res.status(401).json({ error: 'token.required', detail: 'a service token is required' });++ const check = verify || verifyServiceToken;++ const v = await check(token, { jwks, issuer, audience: AUDIENCE, contracts, log });++ if (!v.ok) {++ const code = v.code === 'token.unavailable' ? 503 : 401;++ return res.status(code).json({ error: v.code || 'token.invalid', detail: v.reason || 'the token was not accepted' });++ }++ const claims = v.claims || {};++ const caps = [].concat(claims.cap || claims.scope || []);++ if (!caps.includes(CAP)) {++ return res.status(403).json({ error: 'capability.forbidden', detail: `the token does not hold ${CAP}` });++ }++ const body = req.body && typeof req.body === 'object' ? req.body : {};++ const subject = claims.on_behalf_of || claims.subject || null;++ if (subject && subject !== body.owner) {++ return res.status(403).json({ error: 'subject.forbidden', detail: 'the token may only write the report of the person it acts for' });++ }++ try {++ const out = await sessionReports.write(body);++ return res.status(out.created ? 201 : 200).json(out);++ } catch (err) {++ return res.status(400).json({ error: 'report.invalid', detail: String((err && err.message) || err).slice(0, 300) });++ }++ });++++++ // The stream part: Live tells Report that an agent stream ended. The report the tool wrote gets the stream added++ // to it; when there is no such report, a stream-only report is written.++ r.post('/session-reports/stream', express.json({ limit: '64kb' }), async (req, res) => {++ const token = bearer(req);++ if (!token) return res.status(401).json({ error: 'token.required', detail: 'a service token is required' });++ const check = verify || verifyServiceToken;++ const v = await check(token, { jwks, issuer, audience: AUDIENCE, contracts, log });++ if (!v.ok) {++ const code = v.code === 'token.unavailable' ? 503 : 401;++ return res.status(code).json({ error: v.code || 'token.invalid', detail: v.reason || 'the token was not accepted' });++ }++ const caps = [].concat((v.claims || {}).cap || (v.claims || {}).scope || []);++ if (!caps.includes(CAP)) {++ return res.status(403).json({ error: 'capability.forbidden', detail: 'the token does not hold ' + CAP });++ }++ try {++ const out = await sessionReports.writeStream(req.body || {});++ return res.status(out.created ? 201 : 200).json(out);++ } catch (err) {++ return res.status(400).json({ error: 'report.invalid', detail: String((err && err.message) || err).slice(0, 300) });++ }++ });++++ return r;++}++++module.exports = { createSessionReportRoutes, CAP, AUDIENCE };@@@ -53,7 +53,23 @@ function describeTopic(t) {};}--function createSearchIndex({ config, store, live, log = console, outbox = null }) {++/** One session report -> what its Report page says, as search-feed's document description. */++function describeSession(rep) {++ const outputs = (rep.outputs || []).map((o) => o.title).filter(Boolean);++ return {++ listed: true,++ noindex: false,++ title: rep.task.title + ': the session report',++ summary: rep.summary,++ body: [rep.summary, outputs.length ? 'What came out: ' + outputs.join(', ') + '.' : ''].filter(Boolean).join('\n'),++ facets: { source: rep.source, outcome: rep.outcome },++ authorship: { mode: 'ai' },++ publishedAt: rep.created_at || null,++ updatedAt: rep.created_at || null,++ };++}++++function createSearchIndex({ config, store, live, sessionReports = null, log = console, outbox = null }) {const db = store.db;const out = outbox || createServiceOutbox({db, source: 'report', eventsUrl: (config.events && config.events.url) || null, networkInternalUrl: config.networkInternalUrl,@@@ -73,6 +89,18 @@ function createSearchIndex({ config, store, live, log = console, outbox = null }const feed = createSearchFeed({owner: 'report', db, outbox: out, baseUrl: config.baseUrl, now: store.now, log,types: {++ // The public session reports (Agent Streams S3). Only the public ones: a private or unlisted report is++ // never in a search index.++ session: {++ page: (row) => '/s/' + row.id,++ document: describeSession,++ rows: async (after, limit) => (sessionReports ? await sessionReports.listPublic({ limit: 500 }) : [])++ .filter((r) => r.id > String(after || '')).slice(0, limit),++ exists: async (ids) => {++ const have = new Set((sessionReports ? await sessionReports.listPublic({ limit: 500 }) : []).map((r) => r.id));++ return ids.filter((id) => have.has(id));++ },++ },topic: {page: (row) => `/rundown/t/${row.id}`,document: describeTopic,@@@ -111,4 +139,4 @@ function createSearchIndex({ config, store, live, log = console, outbox = null }return { feed, outbox: out, sweep, start, stop, status: () => out.status() };}--module.exports = { createSearchIndex, describeTopic, EVENT_TYPES };++module.exports = { createSearchIndex, describeTopic, describeSession, EVENT_TYPES };nnew file mode 100644@@@ -0,0 +1,256 @@++'use strict';++++/**++ * Agent Streams S3: the report of every agent session (report.session@1).++ *++ * A session report is one agent session's story: what was asked, what came out, what it cost, how it ended. It is++ * written by the first-party service that ran the session (Codes, Actor, Live) through++ * POST /internal/session-reports under the capability report.session.write, and the write is idempotent per++ * (source, task ref): the same session's task always answers the same report, so a repeat never makes a second one.++ *++ * What is never in a report: the raw transcript (the source service keeps it for the owner, privately), a secret++ * (the house redactor - openvibe-sdk/redact - runs over every text field before anything is stored) and code or++ * diffs (a pull request is a link; its content shows only when the repository is public).++ */++const { redact } = require('openvibe-sdk/redact');++const contracts = require('openvibe-contracts');++++const ID_RE = /^rps_[0-9A-HJKMNP-TV-Z]{26}$/;++const SOURCES = new Set(['codes', 'actor', 'desk', 'node']);++const VISIBILITIES = new Set(['private', 'unlisted', 'public']);++++/** The house redactor over one string. The SDK answers { text, count }. */++function scrub(text) {++ if (typeof text !== 'string' || !text) return text;++ const out = redact(text);++ return out && typeof out.text === 'string' ? out.text : text;++}++++/** The house redactor over every string inside a value (strings, arrays, plain objects). */++function scrubDeep(value) {++ if (typeof value === 'string') return scrub(value);++ if (Array.isArray(value)) return value.map(scrubDeep);++ if (value && typeof value === 'object') {++ const out = {};++ for (const [k, v] of Object.entries(value)) out[k] = scrubDeep(v);++ return out;++ }++ return value;++}++++function str(value, max) {++ if (value == null) return null;++ const s = String(value);++ return s.length > max ? s.slice(0, max) : s;++}++++function num(value) {++ const n = Number(value);++ return Number.isFinite(n) ? n : null;++}++++/**++ * The body of POST /internal/session-reports → the report it stores. Every text field goes through the redactor++ * first, so a secret or an .env line never reaches the database.++ */++function cleanBody(body) {++ const b = body && typeof body === 'object' ? body : {};++ const task = b.task && typeof b.task === 'object' ? b.task : {};++ const source = SOURCES.has(b.source) ? b.source : null;++ const title = str(scrub(task.title), 200);++ const ref = str(scrub(task.ref), 200);++ if (!source) throw new Error('source is missing or unknown');++ if (!title || !ref) throw new Error('the task needs a title and a ref');++ const owner = str(b.owner, 64);++ if (!owner) throw new Error('owner is missing');++ const outcome = ['done', 'stopped', 'failed'].includes(b.outcome) ? b.outcome : 'done';++ const summary = str(scrub(b.summary), 600);++ if (!summary) throw new Error('summary is missing');++ const timeline = (Array.isArray(b.timeline) ? b.timeline : []).slice(0, 200).map((s) => ({++ at: s && s.at ? String(s.at) : null,++ title: str(scrub(s && s.title), 120) || '',++ status: ['started', 'passed', 'failed', 'skipped'].includes(s && s.status) ? s.status : 'started',++ detail: s && s.detail ? str(scrub(s.detail), 500) : null,++ tests: s && s.tests ? { passed: num(s.tests.passed) || 0, failed: num(s.tests.failed) || 0 } : null,++ cost_usd: s && s.cost_usd != null ? num(s.cost_usd) : null,++ })).filter((s) => s.title);++ const outputs = (Array.isArray(b.outputs) ? b.outputs : []).slice(0, 50).map((o) => ({++ kind: ['pr', 'file', 'page', 'deploy', 'clip', 'vod'].includes(o && o.kind) ? o.kind : 'file',++ url: o && o.url ? str(o.url, 2048) : null,++ title: str(scrub(o && o.title), 200) || '',++ state: o && o.state ? str(scrub(o.state), 60) : null,++ })).filter((o) => o.title);++ const p = b.proof && typeof b.proof === 'object' ? b.proof : {};++ const proof = {++ tests: p.tests ? { passed: num(p.tests.passed) || 0, failed: num(p.tests.failed) || 0 } : null,++ screenshots: (Array.isArray(p.screenshots) ? p.screenshots : []).slice(0, 20).map((s) => str(s, 200)).filter(Boolean),++ review: p.review ? {++ verdict: str(scrub(p.review.verdict), 40) || '',++ notes: p.review.notes ? str(scrub(p.review.notes), 2000) : null,++ } : null,++ };++ if (proof.review && !proof.review.verdict) proof.review = null;++ const stream = b.stream && typeof b.stream === 'object' && b.stream.ags_id++ ? { ags_id: str(b.stream.ags_id, 64), vod_id: b.stream.vod_id ? str(b.stream.vod_id, 120) : null }++ : null;++ return {++ owner,++ source,++ task_ref: ref,++ task_title: title,++ task_url: task.url ? str(task.url, 2048) : null,++ started_at: b.started_at ? String(b.started_at) : null,++ ended_at: b.ended_at ? String(b.ended_at) : null,++ worked_seconds: Math.max(0, Math.round(num(b.worked_seconds) || 0)),++ outcome,++ stopped_because: b.stopped_because ? str(scrub(b.stopped_because), 300) : null,++ summary,++ timeline,++ outputs,++ proof,++ cost_usd: b.cost_usd != null ? num(b.cost_usd) : null,++ models: (Array.isArray(b.models) ? b.models : []).slice(0, 20).map((m) => str(scrub(m), 120)).filter(Boolean),++ highlights: (Array.isArray(b.highlights) ? b.highlights : []).slice(0, 5).map((h) => str(scrub(h), 200)).filter(Boolean),++ stream,++ visibility: VISIBILITIES.has(b.visibility) ? b.visibility : 'private',++ stream_only: Boolean(b.stream_only),++ };++}++++function rowToReport(row) {++ if (!row) return null;++ const json = (v, fallback) => {++ if (v == null) return fallback;++ if (typeof v === 'object') return v;++ try { return JSON.parse(v); } catch { return fallback; }++ };++ return {++ id: row.id,++ owner: row.owner,++ source: row.source,++ task: { title: row.task_title, ref: row.task_ref, url: row.task_url || null },++ started_at: row.started_at,++ ended_at: row.ended_at,++ worked_seconds: Number(row.worked_seconds) || 0,++ outcome: row.outcome,++ stopped_because: row.stopped_because || null,++ summary: row.summary,++ timeline: json(row.timeline, []),++ outputs: json(row.outputs, []),++ proof: json(row.proof, { tests: null, screenshots: [], review: null }),++ cost_usd: row.cost_usd == null ? null : Number(row.cost_usd),++ models: json(row.models, []),++ highlights: json(row.highlights, []),++ stream: json(row.stream, null),++ visibility: row.visibility,++ created_at: row.created_at,++ };++}++++function createSessionReports({ store, config, log = console }) {++ const db = store && store.db;++ const base = String((config && config.baseUrl) || 'https://openvibe.report').replace(/\/+$/, '');++ const urlFor = (id) => `${base}/s/${id}`;++++ /** Store the report. Idempotent per (source, task ref): a repeat answers the report that is already there. */++ async function write(body) {++ if (!db) throw new Error('no database');++ const clean = cleanBody(body);++ const id = 'rps_' + contracts.ids.ulid();++ try {++ await db.prepare(`INSERT INTO report_sessions++ (id, owner, source, task_ref, task_title, task_url, started_at, ended_at, worked_seconds, outcome,++ stopped_because, summary, timeline, outputs, proof, cost_usd, models, highlights, stream, visibility,++ created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb, ?::jsonb, ?, ?::jsonb, ?::jsonb, ?::jsonb, ?, now(), now())`)++ .run(id, clean.owner, clean.source, clean.task_ref, clean.task_title, clean.task_url,++ clean.started_at, clean.ended_at, clean.worked_seconds, clean.outcome, clean.stopped_because,++ clean.summary, JSON.stringify(clean.timeline), JSON.stringify(clean.outputs), JSON.stringify(clean.proof),++ clean.cost_usd, JSON.stringify(clean.models), JSON.stringify(clean.highlights),++ clean.stream ? JSON.stringify(clean.stream) : null, clean.visibility);++ return { id, url: urlFor(id), created: true };++ } catch (err) {++ const again = await db.prepare('SELECT id FROM report_sessions WHERE source = ? AND task_ref = ?')++ .get(clean.source, clean.task_ref);++ if (again) return { id: again.id, url: urlFor(again.id), created: false };++ throw err;++ }++ }++++ /** One report, by id. */++ async function get(id) {++ if (!db || !ID_RE.test(String(id || ''))) return null;++ const row = await db.prepare('SELECT * FROM report_sessions WHERE id = ?').get(id);++ return rowToReport(row);++ }++++ /**++ * Live's stream part: the agent stream a session streamed on.++ *++ * The tool that ran the session wrote the report; when the stream ends, the stream is added to that report++ * (matched by the same source and task ref). When there is no such report - the session was the stream itself and++ * no tool finished it - a stream-only report is written instead.++ */++ async function writeStream({ source, task_ref: taskRef, ags_id: agsId, vod_id: vodId = null, owner = null, task = null, started_at: startedAt = null, ended_at: endedAt = null, worked_seconds: workedSeconds = 0, visibility = 'private' } = {}) {++ if (!db) throw new Error('no database');++ if (!agsId) throw new Error('the agent stream id is missing');++ const attached = await attachStream({ source, task_ref: taskRef, stream: { ags_id: agsId, vod_id: vodId } });++ if (attached) return { ...attached, created: false, stream_only: false };++ if (!owner) throw new Error('the owner is missing');++ const title = (task && task.title) || 'An agent session';++ const ref = taskRef || agsId;++ return write({++ owner,++ source: source || 'desk',++ task: { title, ref },++ started_at: startedAt,++ ended_at: endedAt,++ worked_seconds: workedSeconds,++ outcome: 'done',++ summary: 'The agent streamed this session; there is no tool to finish it, so this is the stream itself.',++ timeline: [],++ outputs: vodId ? [{ kind: 'vod', title: 'The recording', url: null }] : [],++ proof: { tests: null, screenshots: [], review: null },++ cost_usd: null,++ models: [],++ highlights: [],++ stream: { ags_id: agsId, vod_id: vodId || null },++ visibility,++ stream_only: true,++ });++ }++++ /** The public reports, newest first. Only these are ever listed, indexed or in the sitemap. */++ async function listPublic({ limit = 24 } = {}) {++ if (!db) return [];++ const rows = await db.prepare(++ `SELECT * FROM report_sessions WHERE visibility = 'public' ORDER BY created_at DESC, id DESC LIMIT ?`)++ .all(Math.max(1, Math.min(200, Number(limit) || 24)));++ return rows.map(rowToReport);++ }++++ /** The public reports written today (the Rundown's "What agents built today"). */++ async function listPublicToday({ limit = 24, now = () => new Date() } = {}) {++ const all = await listPublic({ limit: 200 });++ const d = now();++ const day = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;++ return all.filter((r) => String(r.created_at || '').slice(0, 10) === day).slice(0, limit);++ }++++ /**++ * Live's stream part: the agent stream a session streamed on, matched by the same source and task ref the tool++ * used. The tool's report keeps its id; the stream is added to it.++ */++ async function attachStream({ source, task_ref: taskRef, stream }) {++ if (!db || !stream || !stream.ags_id) return null;++ const out = await db.prepare(++ 'UPDATE report_sessions SET stream = ?::jsonb, updated_at = now() WHERE source = ? AND task_ref = ? RETURNING id')++ .run(JSON.stringify({ ags_id: stream.ags_id, vod_id: stream.vod_id || null }), source, taskRef);++ const id = out && out.rows && out.rows[0] && out.rows[0].id;++ return id ? { id, url: urlFor(id) } : null;++ }++++ return { write, writeStream, get, listPublic, listPublicToday, attachStream, urlFor };++}++++module.exports = { createSessionReports, cleanBody, scrub, scrubDeep, rowToReport, ID_RE };@@@ -303,7 +303,7 @@ ${A && A.daily && A.daily.length > 1 ? `<section aria-labelledby="rp-acc-h"><h2return layout({ title: `Every site's numbers, ${days} days`, description: 'Page views, people, streams and how fast each OpenVibe service answers, open to everyone.', body, active: '/stats', path: `/stats${Number(days) === 30 ? '' : `?days=${Number(days)}`}`, agent: true });}--function rundown({ rundown: R = {}, topics } = {}) {++function rundown({ rundown: R = {}, topics, reports } = {}) {const all = ((topics && topics.topics) || []).slice(0, 24);const onAir = (R.on_air || []).map(streamCard).filter(Boolean);const voices = (R.voices || []).slice(0, 12);@@@ -315,10 +315,123 @@ ${onAir.length ? kit.rail({ id: 'on-air', title: 'On air now', items: onAir }) :${R.lead ? `<section class="rp-lead" aria-labelledby="rp-lead-h"><p class="rp-kicker">Lead</p><h2 id="rp-lead-h">${esc(R.lead.title)}</h2>${R.lead.what ? `<p class="rp-lead-what">${esc(R.lead.what)}</p>` : ''}${R.lead.take ? `<p class="rp-lead-take">${esc(R.lead.take)}</p>` : ''}</section>` : ''}${all.length ? `<section aria-labelledby="rp-t-h"><h2 id="rp-t-h">Topics</h2><ul class="rp-topics">${all.map(topicCard).join('')}</ul></section>` : ''}${voices.length ? `<section aria-labelledby="rp-v-h"><h2 id="rp-v-h">Voices</h2><ul class="rp-voices">${voices.map((v) => `<li><a href="/rundown/s/${encodeURIComponent((v.user || {}).username || '')}">${kit.mark({ name: who(v.user), src: v.user && v.user.avatar_url, size: 40 })}<span><b>${esc(who(v.user))}</b><small>${[n(v.topics) ? `${exact(v.topics)} topic${Number(v.topics) === 1 ? '' : 's'}` : '', n(v.segments) ? `${exact(v.segments)} take${Number(v.segments) === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ')}</small></span></a></li>`).join('')}</ul></section>` : ''}--${streams.length ? kit.rail({ id: 'episodes', title: 'Recent streams', items: streams }) : ''}`;++${streams.length ? kit.rail({ id: 'episodes', title: 'Recent streams', items: streams }) : ''}++${reports && reports.length ? reportsSection(reports) : ''}`;return layout({ title: 'The Rundown: what streamers talked about', description: 'Topics, takes and crossovers from what streamers said on OpenVibe, written up by AI from the streams themselves.', body, active: '/rundown', path: '/rundown' });}++++// Agent Streams S3: the report of one agent session (report.session@1). Seven parts, in the order a reader wants++// them: what it was and how it ended, what was asked, what came out, the proof, the steps, what to watch, what to do.++function reportOutcome(rep) {++ if (rep.outcome === 'done') return { label: 'Done', cls: 'is-done' };++ if (rep.outcome === 'failed') return { label: 'Failed', cls: 'is-failed' };++ return { label: 'Stopped', cls: 'is-stopped' };++}++++function worked(rep) {++ const m = Math.round((Number(rep.worked_seconds) || 0) / 60);++ if (!m) return 'under a minute';++ if (m < 60) return m + ' min';++ const h = Math.floor(m / 60);++ return h + ' h ' + (m % 60) + ' min';++}++++// A proof shot is a Media object (med_…); the picture is at Media's own address, the id stays under it.++function shotURL(id) {++ const s = String(id == null ? '' : id).trim();++ if (/^med_[0-9A-Za-z]+$/.test(s)) return 'https://openvibe.media/o/' + s + '?w=640';++ if (/^https?:\/\//i.test(s)) return s;++ return null;++}++++function reportCard(rep) {++ const out = reportOutcome(rep);++ return '<li class="rp-report"><a href="/s/' + encodeURIComponent(rep.id) + '"><span class="rp-kind">' + esc(out.label)++ + '</span><b>' + esc(rep.task.title) + '</b><span class="rp-report-s">' + esc(rep.summary) + '</span>'++ + '<span class="rp-meta">' + AI + ' · ' + esc(worked(rep))++ + (rep.cost_usd != null ? ' · $' + esc(String(rep.cost_usd)) : '') + '</span></a></li>';++}++++// The Rundown's new section: what agents built today. The fold is counted ("More (N)"), never a silent cut.++function reportsSection(reports) {++ const all = (reports || []).slice(0, 24);++ const shown = all.slice(0, 8);++ const rest = all.slice(8);++ return '<section aria-labelledby="rp-ag-h"><div class="rp-head-row"><h2 id="rp-ag-h">What agents built today</h2>'++ + '<span class="rp-counts"><b>' + exact(all.length) + '</b> report' + (all.length === 1 ? '' : 's') + '</span></div>'++ + '<ul class="rp-topics">' + shown.map(reportCard).join('') + '</ul>'++ + (rest.length ? '<details class="rp-fold"><summary>More (' + exact(rest.length) + ')</summary><ul class="rp-topics">'++ + rest.map(reportCard).join('') + '</ul></details>' : '')++ + '</section>';++}++++function sessionReport({ report: rep, signedIn = false } = {}) {++ if (!rep) return notFound();++ const out = reportOutcome(rep);++ const proof = rep.proof || {};++ const total = proof.tests ? (Number(proof.tests.passed) || 0) + (Number(proof.tests.failed) || 0) : 0;++ const tests = proof.tests ? (Number(proof.tests.passed) || 0) + ' of ' + total + ' tests passing' : '';++ const testsOk = proof.tests ? !(Number(proof.tests.failed) > 0) : false;++ const shots = proof.screenshots || [];++ const watch = (rep.outputs || []).filter((o) => o.kind === 'vod' || o.kind === 'clip');++ const made = (rep.outputs || []).filter((o) => o.kind !== 'vod' && o.kind !== 'clip');++ const steps = rep.timeline || [];++ let body = '<header class="rp-page-h"><p class="rp-kicker">Session report · ' + esc(rep.source) + '</p><h1>'++ + esc(rep.task.title) + '</h1><p class="rp-sr-meta"><span class="rp-kind ' + out.cls + '">' + esc(out.label)++ + '</span>' + AI + '<span>worked ' + esc(worked(rep)) + '</span>'++ + (rep.cost_usd != null ? '<span>$' + esc(String(rep.cost_usd)) + '</span>' : '')++ + (rep.models && rep.models.length ? '<span>' + esc(rep.models.join(', ')) + '</span>' : '') + '</p></header>';++ body += '<section aria-labelledby="rp-sum-h"><h2 id="rp-sum-h">What was asked</h2><p class="rp-lede">'++ + esc(rep.summary) + '</p>'++ + '</section>';++ if (made.length) {++ body += '<section aria-labelledby="rp-out-h"><h2 id="rp-out-h">What came out</h2><ul class="rp-chips">'++ + made.map((o) => '<li><a href="' + (o.url ? esc(o.url) : '#') + '">' + esc(o.title)++ + (o.state ? ' (' + esc(o.state) + ')' : '') + '</a></li>').join('') + '</ul></section>';++ }++ body += '<section aria-labelledby="rp-pr-h"><h2 id="rp-pr-h">The proof</h2>'++ + (tests ? '<p class="rp-proof-tests ' + (testsOk ? 'is-ok' : 'is-bad') + '">' + esc(tests) + '</p>' : '')++ + (shots.length ? '<ul class="rp-shots" aria-label="Screenshots">' + shots.map((s, i) => {++ const u = shotURL(s);++ return u ? '<li><a href="' + esc(u) + '"><img src="' + esc(u) + '" alt="Screenshot ' + (i + 1) + '" width="200" height="125" loading="lazy" decoding="async"></a></li>' : '';++ }).join('') + '</ul>' : '')++ + (proof.review ? '<div class="rp-review"><p><b>Review:</b> ' + esc(proof.review.verdict) + '</p>'++ + (proof.review.notes ? '<details class="rp-fold"><summary>The notes</summary><p>' + esc(proof.review.notes) + '</p></details>' : '')++ + '</div>' : '')++ + (!tests && !shots.length && !proof.review ? '<p class="rp-meta">No proof was recorded for this session.</p>' : '')++ + '</section>';++ if (steps.length || rep.stopped_because) {++ body += '<section aria-labelledby="rp-tl-h"><h2 id="rp-tl-h">How it went</h2>'++ + (rep.stopped_because ? '<p class="rp-summary">It stopped: ' + esc(rep.stopped_because) + '</p>' : '')++ + '<ol class="rp-steps">'++ + steps.map((s) => {++ const st = ['passed', 'failed', 'skipped', 'started'].includes(s.status) ? s.status : 'started';++ return '<li class="is-' + st + '"><span class="rp-step-dot" aria-hidden="true"></span><div><p><b>' + esc(s.title) + '</b> <span class="rp-step-st">'++ + esc(st === 'started' ? 'running' : st)++ + (s.tests ? ' · ' + (Number(s.tests.passed) || 0) + ' of ' + ((Number(s.tests.passed) || 0) + (Number(s.tests.failed) || 0)) + ' passing' : '') + '</span></p>'++ + (s.detail ? '<p class="rp-step-detail">' + esc(s.detail) + '</p>' : '') + '</div></li>';++ }).join('')++ + '</ol></section>';++ }++ if (watch.length) {++ body += '<section aria-labelledby="rp-wa-h"><h2 id="rp-wa-h">Watch</h2><ul class="rp-chips">'++ + watch.map((o) => '<li><a href="' + (o.url ? esc(o.url) : '#') + '">' + esc(o.title) + '</a></li>').join('')++ + '</ul></section>';++ }++ body += '<section aria-labelledby="rp-ac-h"><h2 id="rp-ac-h">What to do</h2><p class="rp-act">'++ + '<a class="rp-btn" href="/s/' + encodeURIComponent(rep.id) + '">Share this report</a>'++ + (signedIn ? ' <a class="rp-btn is-quiet" href="https://openvibe.codes/t/new?from=' + encodeURIComponent(rep.id) + '">Do this again</a>' : '')++ + '</p></section>';++ return layout({++ title: rep.task.title + ': the session report',++ description: rep.summary,++ body,++ active: '/rundown',++ path: '/s/' + rep.id,++ });++}++function topic({ topic: d } = {}) {const t = d.topic || {};const takes = (d.takes || []).slice(0, 40);@@@ -693,4 +806,4 @@ function notFound() {return layout({ title: 'Not found', robots: 'noindex, follow', body: `<header class="rp-page-h"><h1>Nothing here</h1><p class="rp-lede">That page does not exist, or its source has nothing for it. <a href="/">Back to Report</a>.</p></header>` });}--module.exports = { home, stats, rundown, topic, episode, speaker, recap, channel, you, youSignedOut, developers, aiIndex, aiBoard, aiModel, aiExchange, staffOnly, usdNano, paretoRows, paretoChart, notFound, layout, setRelease, configure };++module.exports = { home, stats, rundown, sessionReport, topic, episode, speaker, recap, channel, you, youSignedOut, developers, aiIndex, aiBoard, aiModel, aiExchange, staffOnly, usdNano, paretoRows, paretoChart, notFound, layout, setRelease, configure };@@@ -171,6 +171,7 @@ async function boot(opts = {}) {sso: opts.sso,shadow: opts.shadow,...(opts.aiUsage ? { aiUsage: opts.aiUsage } : {}),++ ...(opts.verifyServiceToken ? { verifyServiceToken: opts.verifyServiceToken } : {}),releaseEnv: { ...process.env, RELEASE_COMMIT: '0000000' },});const server = await new Promise((resolve) => {nnew file mode 100644@@@ -0,0 +1,109 @@++'use strict';++/**++ * Agent Streams S3: the report page, the Rundown's section and the Search document.++ *++ * node test/session-report-page.test.js++ */++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++const { createSearchIndex } = require('../server/search-index');++++const OWNER = 'usr_01JAN2C3D4E5F6G7H8J9K0MNPS';++++function body(over = {}) {++ return {++ owner: OWNER,++ source: 'codes',++ task: { title: 'Ship the checkout page', ref: 'tsk_01JAN2C3D4E5F6G7H8J9K0MNPS' },++ started_at: '2026-11-06T18:00:00.000Z',++ ended_at: '2026-11-06T18:42:00.000Z',++ worked_seconds: 2520,++ outcome: 'done',++ summary: 'Asked for a checkout page; the agent built it and ran the tests.',++ timeline: [{ at: '2026-11-06T18:20:00.000Z', title: 'Run the tests', status: 'passed', tests: { passed: 12, failed: 0 } }],++ outputs: [++ { kind: 'pr', url: 'https://forgejo.example/openvibe/site/pulls/7', title: 'Checkout page', state: 'open' },++ { kind: 'vod', url: 'https://openvibe.video/v/vod_1', title: 'The recording' },++ ],++ proof: { tests: { passed: 12, failed: 0 }, screenshots: ['med_01JAN2C3D4E5F6G7H8J9K0MNPS'], review: { verdict: 'Looks good', notes: 'The mobile layout needs a wider gutter.' } },++ cost_usd: 0.31,++ models: ['claude-sonnet-4'],++ highlights: ['Found the coupon bug on the first read'],++ visibility: 'public',++ ...over,++ };++}++++(async () => {++ const verifyServiceToken = async () => ({ ok: true, claims: { sub: 'svc:codes', cap: ['report.session.write'], aud: ['openvibe.report'] } });++ const t = await boot({ db: true, verifyServiceToken, now: () => Date.parse('2026-11-06T20:00:00Z') });++ try {++ const write = await t.get('/internal/session-reports', { json: body(), headers: { authorization: 'Bearer good' } });++ assert.strictEqual(write.status, 201);++ const id = write.json().id;++++ await check('the page renders every part', async () => {++ const page = await t.get('/s/' + id);++ assert.strictEqual(page.status, 200);++ const text = page.text;++ for (const part of ['Ship the checkout page', 'What was asked', 'What came out', 'The proof', 'How it went', 'Watch', 'What to do']) {++ assert.ok(text.includes(part), part);++ }++ assert.ok(text.includes('Done'), 'the outcome is a status chip');++ assert.ok(text.includes('worked 42 min'), 'how long it worked');++ assert.ok(text.includes('$0.31'), 'what it cost');++ assert.ok(text.includes('12 of 12 tests passing') && text.includes('rp-proof-tests is-ok'), 'the tests, as one chip');++ assert.ok(text.includes('med_01JAN2C3D4E5F6G7H8J9K0MNPS'), 'the screenshots');++ assert.ok(text.includes('Looks good'), 'the review in one line');++ assert.ok(text.includes('The notes'), 'the notes are folded');++ assert.ok(text.includes('Checkout page'), 'the output');++ assert.ok(text.includes('Run the tests'), 'the step');++ });++++ await check('the Rundown shows what agents built today, with a counted fold', async () => {++ // Nine public reports: the first eight are shown, the ninth is behind the fold, and the fold counts.++ for (let i = 2; i <= 9; i++) {++ const r = await t.get('/internal/session-reports', {++ json: body({ task: { title: 'Report ' + i, ref: 'tsk_fold_' + i } }),++ headers: { authorization: 'Bearer good' },++ });++ assert.strictEqual(r.status, 201);++ }++ const page = await t.get('/rundown');++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('What agents built today'), 'the section');++ assert.ok(page.text.includes('Ship the checkout page'), 'the report is in it');++ assert.ok(page.text.includes('/s/' + id), 'and it links to the report');++ assert.ok(page.text.includes('More (1)'), 'the fold counts what is behind it');++ assert.ok(page.text.includes('<b>9</b> reports'), 'and the section counts them all');++ });++++ await check('the public report goes to Search, and only the public one', async () => {++ const searchIndex = createSearchIndex({++ config: t.config, store: t.ctx.store, live: { topics: async () => ({ topics: [] }) },++ sessionReports: t.ctx.sessionReports, log: { log() {}, warn() {} },++ });++ await searchIndex.sweep();++ const db = t.ctx.store.db;++ const outbox = (await db.many('SELECT envelope FROM event_outbox ORDER BY id')).map((r) => r.envelope);++ const docs = outbox.map((e) => e.payload).filter((p) => p && p.type === 'session');++ const doc = docs.find((d) => d.id === id);++ assert.ok(doc, 'the public report is a document');++ assert.strictEqual(doc.canonical_url, 'https://openvibe.report/s/' + id);++ assert.ok(doc.title.includes('Ship the checkout page'), 'the document says what the page says');++ const priv = await t.get('/internal/session-reports', {++ json: body({ task: { title: 'A private one', ref: 'tsk_private_1' }, visibility: 'private' }),++ headers: { authorization: 'Bearer good' },++ });++ await searchIndex.sweep();++ const docs2 = (await db.many('SELECT envelope FROM event_outbox ORDER BY id')).map((r) => r.envelope)++ .map((e) => e.payload).filter((p) => p && p.type === 'session');++ assert.ok(!docs2.some((d) => d.id === priv.json().id), 'a private report is never in Search');++ assert.ok(contracts.validate('search.index-document@1', doc).valid, 'the document fits the contract');++ });++ } finally {++ await t.close();++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,132 @@++'use strict';++/**++ * Agent Streams S3: the report of every agent session.++ *++ * node test/session-reports.test.js++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const OWNER = 'usr_01JAN2C3D4E5F6G7H8J9K0MNPS';++const TASK = { title: 'Ship the checkout page', ref: 'tsk_01JAN2C3D4E5F6G7H8J9K0MNPS' };++++function body(over = {}) {++ return {++ owner: OWNER,++ source: 'codes',++ task: TASK,++ started_at: '2026-11-06T18:00:00.000Z',++ ended_at: '2026-11-06T18:42:00.000Z',++ worked_seconds: 2520,++ outcome: 'done',++ summary: 'Asked for a checkout page; the agent built it and ran the tests.',++ timeline: [{ at: '2026-11-06T18:20:00.000Z', title: 'Run the tests', status: 'passed' }],++ outputs: [{ kind: 'pr', url: 'https://forgejo.example/openvibe/site/pulls/7', title: 'Checkout page' }],++ proof: { tests: { passed: 12, failed: 0 }, screenshots: [], review: null },++ cost_usd: 0.31,++ models: ['claude-sonnet-4'],++ highlights: ['Found the coupon bug on the first read'],++ visibility: 'private',++ ...over,++ };++}++++(async () => {++ const tokens = { good: null, weak: null, none: null };++ const verifyServiceToken = async (token) => {++ if (!token) return { ok: false, code: 'token.required', reason: 'no token' };++ if (token === 'weak') return { ok: true, claims: { sub: 'svc:codes', cap: ['live.agent_stream.publish'], aud: ['openvibe.report'] } };++ return { ok: true, claims: { sub: 'svc:codes', cap: ['report.session.write'], aud: ['openvibe.report'] } };++ };++ const t = await boot({ db: true, verifyServiceToken, now: () => Date.parse('2026-11-06T20:00:00Z') });++ try {++ await check('the door needs a service token', async () => {++ const r = await t.get('/internal/session-reports', { json: body() });++ assert.strictEqual(r.status, 401);++ });++++ await check('a token without the capability is refused', async () => {++ const r = await t.get('/internal/session-reports', { json: body(), headers: { authorization: 'Bearer weak' } });++ assert.strictEqual(r.status, 403);++ assert.strictEqual(r.json().error, 'capability.forbidden');++ });++++ await check('a service with the capability writes the report', async () => {++ const r = await t.get('/internal/session-reports', { json: body(), headers: { authorization: 'Bearer good' } });++ assert.strictEqual(r.status, 201);++ const out = r.json();++ assert.ok(/^rps_[0-9A-HJKMNP-TV-Z]{26}$/.test(out.id), out.id);++ assert.strictEqual(out.url, 'https://openvibe.report/s/' + out.id);++ assert.strictEqual(out.created, true);++ tokens.good = out.id;++ });++++ await check('the write is idempotent per source and task ref', async () => {++ const r = await t.get('/internal/session-reports', { json: body(), headers: { authorization: 'Bearer good' } });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.json().id, tokens.good, 'the same task answers the same report');++ assert.strictEqual(r.json().created, false);++ });++++ await check('the house redactor runs over every text field', async () => {++ const secret = 'sk-ant-api03-REALSECRETVALUE1234567890';++ const r = await t.get('/internal/session-reports', {++ json: body({++ task: { title: 'Ship it', ref: 'tsk_redact_1' },++ summary: 'It ran with ' + secret + ' and read OPENAI_API_KEY=sk-live-abcdefghijklmnop from the .env file.',++ highlights: ['key ' + secret],++ timeline: [{ at: '2026-11-06T18:00:00.000Z', title: 'Read ' + secret, status: 'passed' }],++ }),++ headers: { authorization: 'Bearer good' },++ });++ assert.strictEqual(r.status, 201);++ const rep = await t.ctx.sessionReports.get(r.json().id);++ assert.ok(rep, 'the report is stored');++ assert.ok(!rep.summary.includes(secret), 'the secret is not in the summary');++ assert.ok(!/sk-live-abcdefghijklmnop/.test(rep.summary), 'the .env value is not in the summary');++ assert.ok(!rep.highlights.join(' ').includes(secret), 'the secret is not in the highlights');++ assert.ok(!rep.timeline.map((s) => s.title).join(' ').includes(secret), 'the secret is not in the steps');++ });++++ await check('a private report is never listed', async () => {++ const listed = await t.ctx.sessionReports.listPublic({ limit: 50 });++ assert.ok(!listed.some((rep) => rep.id === tokens.good), 'a private report is not public');++ const page = await t.get('/s/' + tokens.good);++ assert.strictEqual(page.status, 404, 'a private report is not a page for anyone else');++ });++++ await check('an unlisted report opens for anyone with the link', async () => {++ const r = await t.get('/internal/session-reports', {++ json: body({ task: { title: 'Unlisted one', ref: 'tsk_unlisted_1' }, visibility: 'unlisted' }),++ headers: { authorization: 'Bearer good' },++ });++ assert.strictEqual(r.status, 201);++ const page = await t.get('/s/' + r.json().id);++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('Unlisted one'), 'the page shows the task');++ assert.ok(page.text.includes('What was asked'), 'the page has the summary part');++ assert.ok(page.text.includes('The proof'), 'the page has the proof part');++ assert.ok(page.text.includes('How it went'), 'the page has the steps part');++ assert.ok(page.text.includes('What to do'), 'the page has the actions part');++ const listed = await t.ctx.sessionReports.listPublic({ limit: 50 });++ assert.ok(!listed.some((rep) => rep.id === r.json().id), 'an unlisted report is not listed');++ });++++ await check('a public report is in the Rundown section and the sitemap', async () => {++ const r = await t.get('/internal/session-reports', {++ json: body({ task: { title: 'Public one', ref: 'tsk_public_1' }, visibility: 'public' }),++ headers: { authorization: 'Bearer good' },++ });++ assert.strictEqual(r.status, 201);++ const id = r.json().id;++ const rundown = await t.get('/rundown');++ const sm = await t.get('/sitemap.xml');++ assert.ok(sm.text.includes('/s/' + id), 'the sitemap has the public report');++ const listed = await t.ctx.sessionReports.listPublic({ limit: 50 });++ assert.ok(listed.some((rep) => rep.id === id), 'the public report is listed');++ });++ } finally {++ await t.close();++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+-- phase: expand
+-- Agent Streams S3: the report of every agent session (report.session@1).
+--
+-- A session report is one agent session's story: what was asked, what came out, what it cost and how it ended.
+-- It is written by the first-party service that ran the session (Codes, Actor, Live) through
+-- POST /internal/session-reports, under the capability report.session.write, and it is idempotent per
+-- (source, task_ref): the same session's task always answers the same report, so a repeat never makes a second one.
+--
+-- The report never holds the raw transcript, a secret or a diff: the house redactor runs over every text field
+-- before anything is stored, and a pull request is only ever a link.
+--
+-- This migration only adds a table and its indexes: nothing that exists is changed or dropped, so it can run
+-- beside the old code and the old code keeps working.
+CREATE TABLE report_sessions (
+ id text COLLATE "C" PRIMARY KEY,
+ owner text COLLATE "C" NOT NULL,
+ source text COLLATE "C" NOT NULL,
+ task_ref text COLLATE "C" NOT NULL,
+ task_title text NOT NULL,
+ task_url text,
+ started_at timestamptz,
+ ended_at timestamptz,
+ worked_seconds bigint NOT NULL DEFAULT 0,
+ outcome text COLLATE "C" NOT NULL,
+ stopped_because text,
+ summary text NOT NULL,
+ timeline jsonb NOT NULL DEFAULT '[]'::jsonb,
+ outputs jsonb NOT NULL DEFAULT '[]'::jsonb,
+ proof jsonb NOT NULL DEFAULT '{}'::jsonb,
+ cost_usd numeric,
+ models jsonb NOT NULL DEFAULT '[]'::jsonb,
+ highlights jsonb NOT NULL DEFAULT '[]'::jsonb,
+ stream jsonb,
+ visibility text COLLATE "C" NOT NULL DEFAULT 'private',
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+-- The idempotency key: one report per (source, task).
+CREATE UNIQUE INDEX report_sessions_source_task ON report_sessions (source, task_ref);
+-- The Rundown's "What agents built today" and the sitemap read the public ones, newest first.
+CREATE INDEX report_sessions_public ON report_sessions (visibility, created_at DESC);
+-- A person's own reports.
+CREATE INDEX report_sessions_owner ON report_sessions (owner, created_at DESC);
@@ -353,3 +353,22 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
@@ -353,3 +353,22 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
.rp-fold-block > summary + * { margin-top: 0 !important; padding-top: 4px !important; }
.rp-fold-block > summary + * { margin-top: 0 !important; padding-top: 4px !important; }
.rp-fold-block[open] { padding-bottom: 12px; }
.rp-fold-block[open] { padding-bottom: 12px; }
}
}
+/* Session reports (S3): one wrapping meta row, the tests as a chip, screenshots as a strip, steps with status dots. */
+.rp-sr-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; margin: 6px 0 0; color: var(--text-secondary, #9aa4b2); font-size: .9rem; }
+.rp-proof-tests { display: inline-flex; margin: 0 0 12px; padding: 4px 12px; border-radius: 999px; font-weight: 700; font-size: .9rem; }
+.rp-proof-tests.is-ok { background: color-mix(in srgb, #22c55e 16%, transparent); color: #4ade80; }
+.rp-proof-tests.is-bad { background: color-mix(in srgb, #ef4444 16%, transparent); color: #f87171; }
+.rp-shots { list-style: none; margin: 0 0 12px; padding: 0 0 4px; display: flex; gap: 10px; overflow-x: auto; position: relative; }
+.rp-shots img { display: block; width: 200px; height: 125px; object-fit: cover; border-radius: 10px; border: 1px solid var(--border, #233047); background: var(--bg-card, #111827); }
+.rp-review p { margin: 0 0 6px; }
+.rp-steps { list-style: none; margin: 0; padding: 0; display: grid; gap: 0; }
+.rp-steps li { display: grid; grid-template-columns: 14px minmax(0, 1fr); gap: 10px; padding: 8px 0; border-left: 2px solid var(--border, #233047); margin-left: 6px; padding-left: 0; }
+.rp-steps li > div { min-width: 0; }
+.rp-steps p { margin: 0; overflow-wrap: anywhere; }
+.rp-step-dot { width: 10px; height: 10px; margin: 6px 0 0 -6px; border-radius: 999px; background: #3b82f6; box-shadow: 0 0 0 3px var(--bg-primary, #0b1220); }
+.rp-steps li.is-passed .rp-step-dot { background: #22c55e; }
+.rp-steps li.is-failed .rp-step-dot { background: #ef4444; }
+.rp-steps li.is-skipped .rp-step-dot { background: #6b7280; }
+.rp-step-st { color: var(--text-secondary, #9aa4b2); font-size: .82rem; font-weight: 500; }
+.rp-steps li.is-failed .rp-step-st { color: #f87171; }
+.rp-step-detail { color: var(--text-secondary, #9aa4b2); font-size: .9rem; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -0,0 +1,98 @@
+'use strict';
+/**
+ * The report page, shot locally (no deployment, no network): 390 and 1280, dark and light.
+ *
+ * node scripts/session-report-shots.js [outDir]
+ */
+const path = require('path');
+const fs = require('fs');
+const { boot } = require('../test/helpers/boot');
+const { chromium } = require('/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright');
+const { BUILTIN_THEMES } = require('openvibe-shared/builtin-themes');
+
+// The site wears the theme it is given: the frame reads it from localStorage (ov_theme), and the network's theme
+// service is not reachable offline, so the light shots are taken with the site's own light theme (Paper).
+const lightTheme = (Array.isArray(BUILTIN_THEMES) ? BUILTIN_THEMES : Object.values(BUILTIN_THEMES || {}))
+ .find((t) => t && t.id === 'paper');
+
+const outDir = process.argv[2] || '/tmp/report-shots';
+
+(async () => {
+ const t = await boot({ db: true, now: () => Date.parse('2026-11-06T20:00:00Z') });
+ try {
+ const out = await t.ctx.sessionReports.write({
+ owner: 'usr_01JAN2C3D4E5F6G7H8J9K0MNPS',
+ source: 'codes',
+ task: { title: 'Ship the checkout page', ref: 'tsk_01JAN2C3D4E5F6G7H8J9K0MNPS', url: 'https://openvibe.codes/t/tsk_01JAN2C3D4E5F6G7H8J9K0MNPS' },
+ started_at: '2026-11-06T18:00:00.000Z',
+ ended_at: '2026-11-06T18:42:00.000Z',
+ worked_seconds: 2520,
+ outcome: 'done',
+ summary: 'Asked for a checkout page; the agent read the shop, built the page, ran the tests and opened a pull request.',
+ timeline: [
+ { at: '2026-11-06T18:03:00.000Z', title: 'Read the shop code', status: 'passed', detail: 'The coupon logic lives in cart.js' },
+ { at: '2026-11-06T18:21:00.000Z', title: 'Build the checkout page', status: 'passed' },
+ { at: '2026-11-06T18:33:00.000Z', title: 'Run the checkout tests', status: 'passed', tests: { passed: 12, failed: 0 } },
+ ],
+ outputs: [
+ { kind: 'pr', url: 'https://forgejo.example/openvibe/site/pulls/7', title: 'Checkout page', state: 'open' },
+ { kind: 'vod', url: 'https://openvibe.video/v/vod_01JAN2C3D4E5F6G7H8J9K0MNPS', title: 'The recording' },
+ ],
+ proof: {
+ tests: { passed: 12, failed: 0 },
+ screenshots: ['med_01JAN2C3D4E5F6G7H8J9K0MNPS'],
+ review: { verdict: 'Looks good', notes: 'The mobile layout needs a wider gutter on the totals row.' },
+ },
+ cost_usd: 0.31,
+ models: ['claude-sonnet-4'],
+ highlights: ['Found the coupon bug on the first read', 'Twelve tests, no failures'],
+ visibility: 'public',
+ });
+ const url = t.base + '/s/' + out.id;
+ fs.mkdirSync(outDir, { recursive: true });
+ const b = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
+ const notes = [];
+ for (const [w, scheme] of [[390, 'dark'], [390, 'light'], [1280, 'dark'], [1280, 'light']]) {
+ const pg = await b.newPage({
+ viewport: { width: w, height: 900 }, colorScheme: scheme, deviceScaleFactor: 2,
+ isMobile: w < 700, hasTouch: w < 700,
+ });
+ const errs = [];
+ pg.on('pageerror', (e) => errs.push(e.message));
+ pg.on('console', (m) => { if (m.type() === 'error') errs.push(m.text()); });
+ await pg.goto(url, { waitUntil: 'load' });
+ // The site wears its own theme; the light shots put the light theme on (the offline frame cannot ask
+ // openvibe.network which one the person chose).
+ if (scheme === 'light') {
+ await pg.addInitScript((theme) => {
+ try { localStorage.setItem('ov_theme', JSON.stringify(theme)); } catch (e) { /* */ }
+ }, lightTheme);
+ }
+ await pg.waitForTimeout(1200);
+ if (scheme === 'light') {
+ // The site's own theme variables, put on the page the way the frame does it (openvibe-shared/app-icon.js):
+ // the network's theme service is not reachable offline, so the light theme is applied here.
+ await pg.evaluate((theme) => {
+ const el = document.documentElement;
+ const vars = (theme && theme.variables) || {};
+ for (const [k, v] of Object.entries(vars)) if (k.charAt(0) === '-') el.style.setProperty(k, v);
+ if (vars['--bg-primary']) el.style.background = vars['--bg-primary'];
+ if (vars['--text-primary']) el.style.color = vars['--text-primary'];
+ if (vars['--color-scheme']) el.style.colorScheme = vars['--color-scheme'];
+ if (theme && theme.id) el.setAttribute('data-theme', theme.id);
+ }, lightTheme);
+ await pg.waitForTimeout(400);
+ }
+
+ const docW = await pg.evaluate(() => document.documentElement.scrollWidth);
+ const file = path.join(outDir, 'session-report-' + w + '-' + scheme + '.png');
+ await pg.screenshot({ path: file, fullPage: true });
+ notes.push({ width: w, scheme, scrollWidth: docW, overflow: docW > w, errors: errs, file });
+ await pg.close();
+ }
+ await b.close();
+ console.log(JSON.stringify({ url, notes }, null, 2));
+ } finally {
+ await t.close();
+ }
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -36,6 +36,8 @@ const { createAiUsage } = require('./analytics/ai-usage');
@@ -36,6 +36,8 @@ const { createAiUsage } = require('./analytics/ai-usage');
const { createAiExchange } = require('./analytics/ai-exchange');
const { createAiExchange } = require('./analytics/ai-exchange');
const createAiExchangeConfig = require('./ai-exchange-config').create;
const createAiExchangeConfig = require('./ai-exchange-config').create;
const { createSearchIndex } = require('./search-index');
const { createSearchIndex } = require('./search-index');
+const { createSessionReports } = require('./session-reports');
+const { createSessionReportRoutes } = require('./http/session-reports');
const { createShadow } = require('./analytics/shadow');
const { createShadow } = require('./analytics/shadow');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createPageRoutes, createPublicPages } = require('./http/pages');
const { createPageRoutes, createPublicPages } = require('./http/pages');
@@ -72,8 +74,10 @@ async function createApp(opts = {}) {
@@ -72,8 +74,10 @@ async function createApp(opts = {}) {
// AI Exchange analytics (G-P9, flag off): Billing's settlements, aggregated. No database, no ledger.
// AI Exchange analytics (G-P9, flag off): Billing's settlements, aggregated. No database, no ledger.
const aiExchange = opts.aiExchange !== undefined ? opts.aiExchange : (store ? createAiExchange({ store, now, log }) : null);
const aiExchange = opts.aiExchange !== undefined ? opts.aiExchange : (store ? createAiExchange({ store, now, log }) : null);
const shadow = opts.shadow || (store ? createShadow({ store, network, now, log }) : null);
const shadow = opts.shadow || (store ? createShadow({ store, network, now, log }) : null);
+ // The report of every agent session (Agent Streams S3): one table, one route, one page.
+ const sessionReports = opts.sessionReports || (store ? createSessionReports({ store, config, log }) : null);
// The Rundown topic pages in OpenVibe.Search (started by server/index.js, never under REPORT_DRILL).
// The Rundown topic pages in OpenVibe.Search (started by server/index.js, never under REPORT_DRILL).
- const searchIndex = opts.searchIndex || (store ? createSearchIndex({ config, store, live, log }) : null);
+ const searchIndex = opts.searchIndex || (store ? createSearchIndex({ config, store, live, sessionReports, log }) : null);
views.configure({ baseUrl: config.baseUrl });
views.configure({ baseUrl: config.baseUrl });
// A person's sign-in (the `report` client, the same id and secret Report already reads Billing with).
// A person's sign-in (the `report` client, the same id and secret Report already reads Billing with).
@@ -83,7 +87,7 @@ async function createApp(opts = {}) {
@@ -83,7 +87,7 @@ async function createApp(opts = {}) {
secureCookies: /^https:/.test(config.baseUrl), fetch: fetchImpl, log,
secureCookies: /^https:/.test(config.baseUrl), fetch: fetchImpl, log,
});
});
- const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, analytics, shadow, sso, aiUsage, aiExchange, searchIndex, exchangeConfig };
+ const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, analytics, shadow, sso, aiUsage, aiExchange, searchIndex, exchangeConfig, sessionReports };
const app = express();
const app = express();
if (exchangeConfig) exchangeConfig.mount(app);
if (exchangeConfig) exchangeConfig.mount(app);
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
@@ -184,6 +188,9 @@ async function createApp(opts = {}) {
@@ -184,6 +188,9 @@ async function createApp(opts = {}) {
app.use('/auth', sso.router(express));
app.use('/auth', sso.router(express));
app.use(sso.optionalAuth());
app.use(sso.optionalAuth());
+ // ── The door for the first-party services that ran an agent session (Codes, Actor, Live) ──
+ if (sessionReports) app.use('/internal', createSessionReportRoutes({ config, sessionReports, log, verify: opts.verifyServiceToken }));
+
// ── Loopback-only operator and Events routes ────────────
// ── Loopback-only operator and Events routes ────────────
app.use('/internal', createInternalRoutes({ analytics, shadow, config, now, log }));
app.use('/internal', createInternalRoutes({ analytics, shadow, config, now, log }));
@@ -32,7 +32,7 @@ function siteUpdated() {
@@ -32,7 +32,7 @@ function siteUpdated() {
}
}
}
}
-function createDiscoveryRoutes({ config, live, bench }) {
+function createDiscoveryRoutes({ config, live, bench, sessionReports = null }) {
const r = express.Router();
const r = express.Router();
const site = String(config.baseUrl || 'https://openvibe.report').replace(/\/+$/, '');
const site = String(config.baseUrl || 'https://openvibe.report').replace(/\/+$/, '');
const abs = (p) => `${site}${p}`;
const abs = (p) => `${site}${p}`;
@@ -46,7 +46,7 @@ function createDiscoveryRoutes({ config, live, bench }) {
@@ -46,7 +46,7 @@ function createDiscoveryRoutes({ config, live, bench }) {
});
});
r.get('/sitemap.xml', async (_req, res) => {
r.get('/sitemap.xml', async (_req, res) => {
- const [data, ai] = await Promise.all([live.topics(), bench.kinds()]);
+ const [data, ai, reports] = await Promise.all([live.topics(), bench.kinds(), sessionReports ? sessionReports.listPublic({ limit: 500 }) : []]);
const topics = (data && Array.isArray(data.topics)) ? data.topics : [];
const topics = (data && Array.isArray(data.topics)) ? data.topics : [];
const kinds = (ai && Array.isArray(ai.data.kinds)) ? ai.data.kinds : [];
const kinds = (ai && Array.isArray(ai.data.kinds)) ? ai.data.kinds : [];
const lastmod = siteUpdated();
const lastmod = siteUpdated();
@@ -56,6 +56,7 @@ function createDiscoveryRoutes({ config, live, bench }) {
@@ -56,6 +56,7 @@ function createDiscoveryRoutes({ config, live, bench }) {
{ loc: abs('/design'), changefreq: 'weekly', priority: 0.6 },
{ loc: abs('/design'), changefreq: 'weekly', priority: 0.6 },
{ loc: abs('/rundown'), changefreq: 'daily', priority: 0.9 },
{ loc: abs('/rundown'), changefreq: 'daily', priority: 0.9 },
{ loc: abs('/ai'), changefreq: 'daily', priority: 0.9 },
{ loc: abs('/ai'), changefreq: 'daily', priority: 0.9 },
+ ...reports.map((rep) => ({ loc: abs('/s/' + encodeURIComponent(rep.id)), changefreq: 'daily', priority: 0.6 })),
...kinds.filter((k) => k && bench.isKindId(k.id)).map((k) => ({
...kinds.filter((k) => k && bench.isKindId(k.id)).map((k) => ({
loc: abs(`/ai/${encodeURIComponent(k.id)}`), changefreq: 'daily', priority: 0.7,
loc: abs(`/ai/${encodeURIComponent(k.id)}`), changefreq: 'daily', priority: 0.7,
})),
})),
@@ -29,7 +29,7 @@ const designHealth = require('../data/design-health');
@@ -29,7 +29,7 @@ const designHealth = require('../data/design-health');
const CACHE = 'public, max-age=120';
const CACHE = 'public, max-age=120';
/** The pages the agent layer twins: their data, then the view. Each returns { html } or null (nothing to show). */
/** The pages the agent layer twins: their data, then the view. Each returns { html } or null (nothing to show). */
-function createPublicPages({ network, live, bench, analytics = null }) {
+function createPublicPages({ sessionReports, network, live, bench, analytics = null }) {
const numbers = (days) => (analytics ? analytics.stats(days) : network.stats(days));
const numbers = (days) => (analytics ? analytics.stats(days) : network.stats(days));
async function home() {
async function home() {
@@ -72,15 +72,18 @@ function createPublicPages({ network, live, bench, analytics = null }) {
@@ -72,15 +72,18 @@ function createPublicPages({ network, live, bench, analytics = null }) {
function design() { return { html: designViews.designHealth(designHealth.all()) }; }
function design() { return { html: designViews.designHealth(designHealth.all()) }; }
async function rundown() {
async function rundown() {
- const [r, topics] = await Promise.all([live.rundown(), live.topics()]);
- return { html: views.rundown({ rundown: r, topics }) };
+ const [r, topics, reports] = await Promise.all([
+ live.rundown(), live.topics(),
+ sessionReports ? sessionReports.listPublicToday({ limit: 24 }) : [],
+ ]);
+ return { html: views.rundown({ rundown: r, topics, reports }) };
}
}
return { home, stats, developers, design, rundown, aiIndex, aiBoard, aiModel };
return { home, stats, developers, design, rundown, aiIndex, aiBoard, aiModel };
}
}
function createPageRoutes(ctx) {
function createPageRoutes(ctx) {
- const { live, bench } = ctx;
+ const { live, bench, sessionReports } = ctx;
const r = express.Router();
const r = express.Router();
const publicPages = ctx.publicPages || createPublicPages(ctx);
const publicPages = ctx.publicPages || createPublicPages(ctx);
// A path ending in .md is a Markdown twin (server/agent-pages.js), never a page parameter.
// A path ending in .md is a Markdown twin (server/agent-pages.js), never a page parameter.
@@ -149,9 +152,23 @@ function createPageRoutes(ctx) {
@@ -149,9 +152,23 @@ function createPageRoutes(ctx) {
});
});
r.get('/rundown', async (req, res) => {
r.get('/rundown', async (req, res) => {
- const [rundown, topics] = await Promise.all([live.rundown(), live.topics()]);
+ const [rundown, topics, reports] = await Promise.all([
+ live.rundown(), live.topics(),
+ sessionReports ? sessionReports.listPublicToday({ limit: 24 }) : [],
+ ]);
if (!rundown || !topics) return notFound(req, res);
if (!rundown || !topics) return notFound(req, res);
- return send(req, res, views.rundown({ rundown, topics }));
+ return send(req, res, views.rundown({ rundown, topics, reports }));
+ });
+
+ // One agent session's report (Agent Streams S3). A private report is the owner's alone, an unlisted one opens
+ // for anyone with the link, a public one opens for everyone.
+ r.get('/s/:id', async (req, res) => {
+ const id = String(req.params.id || '');
+ const rep = sessionReports ? await sessionReports.get(id) : null;
+ if (!rep) return notFound(req, res);
+ const viewer = req.user ? String(req.user.sub || req.user.id || '') : '';
+ if (rep.visibility === 'private' && viewer !== rep.owner) return notFound(req, res);
+ return send(req, res, views.sessionReport({ report: rep, signedIn: Boolean(viewer) }));
});
});
r.get('/rundown/t/:slug', async (req, res) => {
r.get('/rundown/t/:slug', async (req, res) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+
+/**
+ * POST /internal/session-reports: the door for the first-party services that ran an agent session.
+ *
+ * The caller is Codes, Actor or Live, with a Network service token that holds the capability report.session.write
+ * for the audience openvibe.report (the grant is in Network's principals seed). The body names the person the
+ * session was theirs; a token that acts for a person may only write that person's report.
+ *
+ * The answer is the report's id and its page (openvibe.report/s/<id>), and the write is idempotent per
+ * (source, task ref): the same session's task always answers the same report.
+ */
+const express = require('express');
+const contracts = require('openvibe-contracts');
+const { verifyServiceToken } = require('openvibe-sdk/auth');
+
+const CAP = 'report.session.write';
+const AUDIENCE = 'openvibe.report';
+
+function bearer(req) {
+ const h = String(req.headers.authorization || '');
+ const m = h.match(/^Bearer\s+(.+)$/i);
+ return m ? m[1].trim() : null;
+}
+
+function createSessionReportRoutes({ config, sessionReports, log = console, verify = null }) {
+ const r = express.Router();
+ const net = String((config && (config.networkPublicUrl || config.networkUrl)) || 'https://openvibe.network').replace(/\/+$/, '');
+ const issuer = (config && config.network && config.network.issuer) || config.networkIssuer || 'https://openvibe.network';
+ const jwks = `${net}/api/.well-known/jwks`;
+
+ r.post('/session-reports', express.json({ limit: '256kb' }), async (req, res) => {
+ const token = bearer(req);
+ if (!token) return res.status(401).json({ error: 'token.required', detail: 'a service token is required' });
+ const check = verify || verifyServiceToken;
+ const v = await check(token, { jwks, issuer, audience: AUDIENCE, contracts, log });
+ if (!v.ok) {
+ const code = v.code === 'token.unavailable' ? 503 : 401;
+ return res.status(code).json({ error: v.code || 'token.invalid', detail: v.reason || 'the token was not accepted' });
+ }
+ const claims = v.claims || {};
+ const caps = [].concat(claims.cap || claims.scope || []);
+ if (!caps.includes(CAP)) {
+ return res.status(403).json({ error: 'capability.forbidden', detail: `the token does not hold ${CAP}` });
+ }
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
+ const subject = claims.on_behalf_of || claims.subject || null;
+ if (subject && subject !== body.owner) {
+ return res.status(403).json({ error: 'subject.forbidden', detail: 'the token may only write the report of the person it acts for' });
+ }
+ try {
+ const out = await sessionReports.write(body);
+ return res.status(out.created ? 201 : 200).json(out);
+ } catch (err) {
+ return res.status(400).json({ error: 'report.invalid', detail: String((err && err.message) || err).slice(0, 300) });
+ }
+ });
+
+
+ // The stream part: Live tells Report that an agent stream ended. The report the tool wrote gets the stream added
+ // to it; when there is no such report, a stream-only report is written.
+ r.post('/session-reports/stream', express.json({ limit: '64kb' }), async (req, res) => {
+ const token = bearer(req);
+ if (!token) return res.status(401).json({ error: 'token.required', detail: 'a service token is required' });
+ const check = verify || verifyServiceToken;
+ const v = await check(token, { jwks, issuer, audience: AUDIENCE, contracts, log });
+ if (!v.ok) {
+ const code = v.code === 'token.unavailable' ? 503 : 401;
+ return res.status(code).json({ error: v.code || 'token.invalid', detail: v.reason || 'the token was not accepted' });
+ }
+ const caps = [].concat((v.claims || {}).cap || (v.claims || {}).scope || []);
+ if (!caps.includes(CAP)) {
+ return res.status(403).json({ error: 'capability.forbidden', detail: 'the token does not hold ' + CAP });
+ }
+ try {
+ const out = await sessionReports.writeStream(req.body || {});
+ return res.status(out.created ? 201 : 200).json(out);
+ } catch (err) {
+ return res.status(400).json({ error: 'report.invalid', detail: String((err && err.message) || err).slice(0, 300) });
+ }
+ });
+
+ return r;
+}
+
+module.exports = { createSessionReportRoutes, CAP, AUDIENCE };
@@ -53,7 +53,23 @@ function describeTopic(t) {
@@ -53,7 +53,23 @@ function describeTopic(t) {
};
};
}
}
-function createSearchIndex({ config, store, live, log = console, outbox = null }) {
+/** One session report -> what its Report page says, as search-feed's document description. */
+function describeSession(rep) {
+ const outputs = (rep.outputs || []).map((o) => o.title).filter(Boolean);
+ return {
+ listed: true,
+ noindex: false,
+ title: rep.task.title + ': the session report',
+ summary: rep.summary,
+ body: [rep.summary, outputs.length ? 'What came out: ' + outputs.join(', ') + '.' : ''].filter(Boolean).join('\n'),
+ facets: { source: rep.source, outcome: rep.outcome },
+ authorship: { mode: 'ai' },
+ publishedAt: rep.created_at || null,
+ updatedAt: rep.created_at || null,
+ };
+}
+
+function createSearchIndex({ config, store, live, sessionReports = null, log = console, outbox = null }) {
const db = store.db;
const db = store.db;
const out = outbox || createServiceOutbox({
const out = outbox || createServiceOutbox({
db, source: 'report', eventsUrl: (config.events && config.events.url) || null, networkInternalUrl: config.networkInternalUrl,
db, source: 'report', eventsUrl: (config.events && config.events.url) || null, networkInternalUrl: config.networkInternalUrl,
@@ -73,6 +89,18 @@ function createSearchIndex({ config, store, live, log = console, outbox = null }
@@ -73,6 +89,18 @@ function createSearchIndex({ config, store, live, log = console, outbox = null }
const feed = createSearchFeed({
const feed = createSearchFeed({
owner: 'report', db, outbox: out, baseUrl: config.baseUrl, now: store.now, log,
owner: 'report', db, outbox: out, baseUrl: config.baseUrl, now: store.now, log,
types: {
types: {
+ // The public session reports (Agent Streams S3). Only the public ones: a private or unlisted report is
+ // never in a search index.
+ session: {
+ page: (row) => '/s/' + row.id,
+ document: describeSession,
+ rows: async (after, limit) => (sessionReports ? await sessionReports.listPublic({ limit: 500 }) : [])
+ .filter((r) => r.id > String(after || '')).slice(0, limit),
+ exists: async (ids) => {
+ const have = new Set((sessionReports ? await sessionReports.listPublic({ limit: 500 }) : []).map((r) => r.id));
+ return ids.filter((id) => have.has(id));
+ },
+ },
topic: {
topic: {
page: (row) => `/rundown/t/${row.id}`,
page: (row) => `/rundown/t/${row.id}`,
document: describeTopic,
document: describeTopic,
@@ -111,4 +139,4 @@ function createSearchIndex({ config, store, live, log = console, outbox = null }
@@ -111,4 +139,4 @@ function createSearchIndex({ config, store, live, log = console, outbox = null }
return { feed, outbox: out, sweep, start, stop, status: () => out.status() };
return { feed, outbox: out, sweep, start, stop, status: () => out.status() };
}
}
-module.exports = { createSearchIndex, describeTopic, EVENT_TYPES };
+module.exports = { createSearchIndex, describeTopic, describeSession, EVENT_TYPES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,256 @@
@@ -0,0 +1,256 @@
+'use strict';
+
+/**
+ * Agent Streams S3: the report of every agent session (report.session@1).
+ *
+ * A session report is one agent session's story: what was asked, what came out, what it cost, how it ended. It is
+ * written by the first-party service that ran the session (Codes, Actor, Live) through
+ * POST /internal/session-reports under the capability report.session.write, and the write is idempotent per
+ * (source, task ref): the same session's task always answers the same report, so a repeat never makes a second one.
+ *
+ * What is never in a report: the raw transcript (the source service keeps it for the owner, privately), a secret
+ * (the house redactor - openvibe-sdk/redact - runs over every text field before anything is stored) and code or
+ * diffs (a pull request is a link; its content shows only when the repository is public).
+ */
+const { redact } = require('openvibe-sdk/redact');
+const contracts = require('openvibe-contracts');
+
+const ID_RE = /^rps_[0-9A-HJKMNP-TV-Z]{26}$/;
+const SOURCES = new Set(['codes', 'actor', 'desk', 'node']);
+const VISIBILITIES = new Set(['private', 'unlisted', 'public']);
+
+/** The house redactor over one string. The SDK answers { text, count }. */
+function scrub(text) {
+ if (typeof text !== 'string' || !text) return text;
+ const out = redact(text);
+ return out && typeof out.text === 'string' ? out.text : text;
+}
+
+/** The house redactor over every string inside a value (strings, arrays, plain objects). */
+function scrubDeep(value) {
+ if (typeof value === 'string') return scrub(value);
+ if (Array.isArray(value)) return value.map(scrubDeep);
+ if (value && typeof value === 'object') {
+ const out = {};
+ for (const [k, v] of Object.entries(value)) out[k] = scrubDeep(v);
+ return out;
+ }
+ return value;
+}
+
+function str(value, max) {
+ if (value == null) return null;
+ const s = String(value);
+ return s.length > max ? s.slice(0, max) : s;
+}
+
+function num(value) {
+ const n = Number(value);
+ return Number.isFinite(n) ? n : null;
+}
+
+/**
+ * The body of POST /internal/session-reports → the report it stores. Every text field goes through the redactor
+ * first, so a secret or an .env line never reaches the database.
+ */
+function cleanBody(body) {
+ const b = body && typeof body === 'object' ? body : {};
+ const task = b.task && typeof b.task === 'object' ? b.task : {};
+ const source = SOURCES.has(b.source) ? b.source : null;
+ const title = str(scrub(task.title), 200);
+ const ref = str(scrub(task.ref), 200);
+ if (!source) throw new Error('source is missing or unknown');
+ if (!title || !ref) throw new Error('the task needs a title and a ref');
+ const owner = str(b.owner, 64);
+ if (!owner) throw new Error('owner is missing');
+ const outcome = ['done', 'stopped', 'failed'].includes(b.outcome) ? b.outcome : 'done';
+ const summary = str(scrub(b.summary), 600);
+ if (!summary) throw new Error('summary is missing');
+ const timeline = (Array.isArray(b.timeline) ? b.timeline : []).slice(0, 200).map((s) => ({
+ at: s && s.at ? String(s.at) : null,
+ title: str(scrub(s && s.title), 120) || '',
+ status: ['started', 'passed', 'failed', 'skipped'].includes(s && s.status) ? s.status : 'started',
+ detail: s && s.detail ? str(scrub(s.detail), 500) : null,
+ tests: s && s.tests ? { passed: num(s.tests.passed) || 0, failed: num(s.tests.failed) || 0 } : null,
+ cost_usd: s && s.cost_usd != null ? num(s.cost_usd) : null,
+ })).filter((s) => s.title);
+ const outputs = (Array.isArray(b.outputs) ? b.outputs : []).slice(0, 50).map((o) => ({
+ kind: ['pr', 'file', 'page', 'deploy', 'clip', 'vod'].includes(o && o.kind) ? o.kind : 'file',
+ url: o && o.url ? str(o.url, 2048) : null,
+ title: str(scrub(o && o.title), 200) || '',
+ state: o && o.state ? str(scrub(o.state), 60) : null,
+ })).filter((o) => o.title);
+ const p = b.proof && typeof b.proof === 'object' ? b.proof : {};
+ const proof = {
+ tests: p.tests ? { passed: num(p.tests.passed) || 0, failed: num(p.tests.failed) || 0 } : null,
+ screenshots: (Array.isArray(p.screenshots) ? p.screenshots : []).slice(0, 20).map((s) => str(s, 200)).filter(Boolean),
+ review: p.review ? {
+ verdict: str(scrub(p.review.verdict), 40) || '',
+ notes: p.review.notes ? str(scrub(p.review.notes), 2000) : null,
+ } : null,
+ };
+ if (proof.review && !proof.review.verdict) proof.review = null;
+ const stream = b.stream && typeof b.stream === 'object' && b.stream.ags_id
+ ? { ags_id: str(b.stream.ags_id, 64), vod_id: b.stream.vod_id ? str(b.stream.vod_id, 120) : null }
+ : null;
+ return {
+ owner,
+ source,
+ task_ref: ref,
+ task_title: title,
+ task_url: task.url ? str(task.url, 2048) : null,
+ started_at: b.started_at ? String(b.started_at) : null,
+ ended_at: b.ended_at ? String(b.ended_at) : null,
+ worked_seconds: Math.max(0, Math.round(num(b.worked_seconds) || 0)),
+ outcome,
+ stopped_because: b.stopped_because ? str(scrub(b.stopped_because), 300) : null,
+ summary,
+ timeline,
+ outputs,
+ proof,
+ cost_usd: b.cost_usd != null ? num(b.cost_usd) : null,
+ models: (Array.isArray(b.models) ? b.models : []).slice(0, 20).map((m) => str(scrub(m), 120)).filter(Boolean),
+ highlights: (Array.isArray(b.highlights) ? b.highlights : []).slice(0, 5).map((h) => str(scrub(h), 200)).filter(Boolean),
+ stream,
+ visibility: VISIBILITIES.has(b.visibility) ? b.visibility : 'private',
+ stream_only: Boolean(b.stream_only),
+ };
+}
+
+function rowToReport(row) {
+ if (!row) return null;
+ const json = (v, fallback) => {
+ if (v == null) return fallback;
+ if (typeof v === 'object') return v;
+ try { return JSON.parse(v); } catch { return fallback; }
+ };
+ return {
+ id: row.id,
+ owner: row.owner,
+ source: row.source,
+ task: { title: row.task_title, ref: row.task_ref, url: row.task_url || null },
+ started_at: row.started_at,
+ ended_at: row.ended_at,
+ worked_seconds: Number(row.worked_seconds) || 0,
+ outcome: row.outcome,
+ stopped_because: row.stopped_because || null,
+ summary: row.summary,
+ timeline: json(row.timeline, []),
+ outputs: json(row.outputs, []),
+ proof: json(row.proof, { tests: null, screenshots: [], review: null }),
+ cost_usd: row.cost_usd == null ? null : Number(row.cost_usd),
+ models: json(row.models, []),
+ highlights: json(row.highlights, []),
+ stream: json(row.stream, null),
+ visibility: row.visibility,
+ created_at: row.created_at,
+ };
+}
+
+function createSessionReports({ store, config, log = console }) {
+ const db = store && store.db;
+ const base = String((config && config.baseUrl) || 'https://openvibe.report').replace(/\/+$/, '');
+ const urlFor = (id) => `${base}/s/${id}`;
+
+ /** Store the report. Idempotent per (source, task ref): a repeat answers the report that is already there. */
+ async function write(body) {
+ if (!db) throw new Error('no database');
+ const clean = cleanBody(body);
+ const id = 'rps_' + contracts.ids.ulid();
+ try {
+ await db.prepare(`INSERT INTO report_sessions
+ (id, owner, source, task_ref, task_title, task_url, started_at, ended_at, worked_seconds, outcome,
+ stopped_because, summary, timeline, outputs, proof, cost_usd, models, highlights, stream, visibility,
+ created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb, ?::jsonb, ?, ?::jsonb, ?::jsonb, ?::jsonb, ?, now(), now())`)
+ .run(id, clean.owner, clean.source, clean.task_ref, clean.task_title, clean.task_url,
+ clean.started_at, clean.ended_at, clean.worked_seconds, clean.outcome, clean.stopped_because,
+ clean.summary, JSON.stringify(clean.timeline), JSON.stringify(clean.outputs), JSON.stringify(clean.proof),
+ clean.cost_usd, JSON.stringify(clean.models), JSON.stringify(clean.highlights),
+ clean.stream ? JSON.stringify(clean.stream) : null, clean.visibility);
+ return { id, url: urlFor(id), created: true };
+ } catch (err) {
+ const again = await db.prepare('SELECT id FROM report_sessions WHERE source = ? AND task_ref = ?')
+ .get(clean.source, clean.task_ref);
+ if (again) return { id: again.id, url: urlFor(again.id), created: false };
+ throw err;
+ }
+ }
+
+ /** One report, by id. */
+ async function get(id) {
+ if (!db || !ID_RE.test(String(id || ''))) return null;
+ const row = await db.prepare('SELECT * FROM report_sessions WHERE id = ?').get(id);
+ return rowToReport(row);
+ }
+
+ /**
+ * Live's stream part: the agent stream a session streamed on.
+ *
+ * The tool that ran the session wrote the report; when the stream ends, the stream is added to that report
+ * (matched by the same source and task ref). When there is no such report - the session was the stream itself and
+ * no tool finished it - a stream-only report is written instead.
+ */
+ async function writeStream({ source, task_ref: taskRef, ags_id: agsId, vod_id: vodId = null, owner = null, task = null, started_at: startedAt = null, ended_at: endedAt = null, worked_seconds: workedSeconds = 0, visibility = 'private' } = {}) {
+ if (!db) throw new Error('no database');
+ if (!agsId) throw new Error('the agent stream id is missing');
+ const attached = await attachStream({ source, task_ref: taskRef, stream: { ags_id: agsId, vod_id: vodId } });
+ if (attached) return { ...attached, created: false, stream_only: false };
+ if (!owner) throw new Error('the owner is missing');
+ const title = (task && task.title) || 'An agent session';
+ const ref = taskRef || agsId;
+ return write({
+ owner,
+ source: source || 'desk',
+ task: { title, ref },
+ started_at: startedAt,
+ ended_at: endedAt,
+ worked_seconds: workedSeconds,
+ outcome: 'done',
+ summary: 'The agent streamed this session; there is no tool to finish it, so this is the stream itself.',
+ timeline: [],
+ outputs: vodId ? [{ kind: 'vod', title: 'The recording', url: null }] : [],
+ proof: { tests: null, screenshots: [], review: null },
+ cost_usd: null,
+ models: [],
+ highlights: [],
+ stream: { ags_id: agsId, vod_id: vodId || null },
+ visibility,
+ stream_only: true,
+ });
+ }
+
+ /** The public reports, newest first. Only these are ever listed, indexed or in the sitemap. */
+ async function listPublic({ limit = 24 } = {}) {
+ if (!db) return [];
+ const rows = await db.prepare(
+ `SELECT * FROM report_sessions WHERE visibility = 'public' ORDER BY created_at DESC, id DESC LIMIT ?`)
+ .all(Math.max(1, Math.min(200, Number(limit) || 24)));
+ return rows.map(rowToReport);
+ }
+
+ /** The public reports written today (the Rundown's "What agents built today"). */
+ async function listPublicToday({ limit = 24, now = () => new Date() } = {}) {
+ const all = await listPublic({ limit: 200 });
+ const d = now();
+ const day = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
+ return all.filter((r) => String(r.created_at || '').slice(0, 10) === day).slice(0, limit);
+ }
+
+ /**
+ * Live's stream part: the agent stream a session streamed on, matched by the same source and task ref the tool
+ * used. The tool's report keeps its id; the stream is added to it.
+ */
+ async function attachStream({ source, task_ref: taskRef, stream }) {
+ if (!db || !stream || !stream.ags_id) return null;
+ const out = await db.prepare(
+ 'UPDATE report_sessions SET stream = ?::jsonb, updated_at = now() WHERE source = ? AND task_ref = ? RETURNING id')
+ .run(JSON.stringify({ ags_id: stream.ags_id, vod_id: stream.vod_id || null }), source, taskRef);
+ const id = out && out.rows && out.rows[0] && out.rows[0].id;
+ return id ? { id, url: urlFor(id) } : null;
+ }
+
+ return { write, writeStream, get, listPublic, listPublicToday, attachStream, urlFor };
+}
+
+module.exports = { createSessionReports, cleanBody, scrub, scrubDeep, rowToReport, ID_RE };
@@ -303,7 +303,7 @@ ${A && A.daily && A.daily.length > 1 ? `<section aria-labelledby="rp-acc-h"><h2
@@ -303,7 +303,7 @@ ${A && A.daily && A.daily.length > 1 ? `<section aria-labelledby="rp-acc-h"><h2
return layout({ title: `Every site's numbers, ${days} days`, description: 'Page views, people, streams and how fast each OpenVibe service answers, open to everyone.', body, active: '/stats', path: `/stats${Number(days) === 30 ? '' : `?days=${Number(days)}`}`, agent: true });
return layout({ title: `Every site's numbers, ${days} days`, description: 'Page views, people, streams and how fast each OpenVibe service answers, open to everyone.', body, active: '/stats', path: `/stats${Number(days) === 30 ? '' : `?days=${Number(days)}`}`, agent: true });
}
}
-function rundown({ rundown: R = {}, topics } = {}) {
+function rundown({ rundown: R = {}, topics, reports } = {}) {
const all = ((topics && topics.topics) || []).slice(0, 24);
const all = ((topics && topics.topics) || []).slice(0, 24);
const onAir = (R.on_air || []).map(streamCard).filter(Boolean);
const onAir = (R.on_air || []).map(streamCard).filter(Boolean);
const voices = (R.voices || []).slice(0, 12);
const voices = (R.voices || []).slice(0, 12);
@@ -315,10 +315,123 @@ ${onAir.length ? kit.rail({ id: 'on-air', title: 'On air now', items: onAir }) :
@@ -315,10 +315,123 @@ ${onAir.length ? kit.rail({ id: 'on-air', title: 'On air now', items: onAir }) :
${R.lead ? `<section class="rp-lead" aria-labelledby="rp-lead-h"><p class="rp-kicker">Lead</p><h2 id="rp-lead-h">${esc(R.lead.title)}</h2>${R.lead.what ? `<p class="rp-lead-what">${esc(R.lead.what)}</p>` : ''}${R.lead.take ? `<p class="rp-lead-take">${esc(R.lead.take)}</p>` : ''}</section>` : ''}
${R.lead ? `<section class="rp-lead" aria-labelledby="rp-lead-h"><p class="rp-kicker">Lead</p><h2 id="rp-lead-h">${esc(R.lead.title)}</h2>${R.lead.what ? `<p class="rp-lead-what">${esc(R.lead.what)}</p>` : ''}${R.lead.take ? `<p class="rp-lead-take">${esc(R.lead.take)}</p>` : ''}</section>` : ''}
${all.length ? `<section aria-labelledby="rp-t-h"><h2 id="rp-t-h">Topics</h2><ul class="rp-topics">${all.map(topicCard).join('')}</ul></section>` : ''}
${all.length ? `<section aria-labelledby="rp-t-h"><h2 id="rp-t-h">Topics</h2><ul class="rp-topics">${all.map(topicCard).join('')}</ul></section>` : ''}
${voices.length ? `<section aria-labelledby="rp-v-h"><h2 id="rp-v-h">Voices</h2><ul class="rp-voices">${voices.map((v) => `<li><a href="/rundown/s/${encodeURIComponent((v.user || {}).username || '')}">${kit.mark({ name: who(v.user), src: v.user && v.user.avatar_url, size: 40 })}<span><b>${esc(who(v.user))}</b><small>${[n(v.topics) ? `${exact(v.topics)} topic${Number(v.topics) === 1 ? '' : 's'}` : '', n(v.segments) ? `${exact(v.segments)} take${Number(v.segments) === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ')}</small></span></a></li>`).join('')}</ul></section>` : ''}
${voices.length ? `<section aria-labelledby="rp-v-h"><h2 id="rp-v-h">Voices</h2><ul class="rp-voices">${voices.map((v) => `<li><a href="/rundown/s/${encodeURIComponent((v.user || {}).username || '')}">${kit.mark({ name: who(v.user), src: v.user && v.user.avatar_url, size: 40 })}<span><b>${esc(who(v.user))}</b><small>${[n(v.topics) ? `${exact(v.topics)} topic${Number(v.topics) === 1 ? '' : 's'}` : '', n(v.segments) ? `${exact(v.segments)} take${Number(v.segments) === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ')}</small></span></a></li>`).join('')}</ul></section>` : ''}
-${streams.length ? kit.rail({ id: 'episodes', title: 'Recent streams', items: streams }) : ''}`;
+${streams.length ? kit.rail({ id: 'episodes', title: 'Recent streams', items: streams }) : ''}
+${reports && reports.length ? reportsSection(reports) : ''}`;
return layout({ title: 'The Rundown: what streamers talked about', description: 'Topics, takes and crossovers from what streamers said on OpenVibe, written up by AI from the streams themselves.', body, active: '/rundown', path: '/rundown' });
return layout({ title: 'The Rundown: what streamers talked about', description: 'Topics, takes and crossovers from what streamers said on OpenVibe, written up by AI from the streams themselves.', body, active: '/rundown', path: '/rundown' });
}
}
+
+// Agent Streams S3: the report of one agent session (report.session@1). Seven parts, in the order a reader wants
+// them: what it was and how it ended, what was asked, what came out, the proof, the steps, what to watch, what to do.
+function reportOutcome(rep) {
+ if (rep.outcome === 'done') return { label: 'Done', cls: 'is-done' };
+ if (rep.outcome === 'failed') return { label: 'Failed', cls: 'is-failed' };
+ return { label: 'Stopped', cls: 'is-stopped' };
+}
+
+function worked(rep) {
+ const m = Math.round((Number(rep.worked_seconds) || 0) / 60);
+ if (!m) return 'under a minute';
+ if (m < 60) return m + ' min';
+ const h = Math.floor(m / 60);
+ return h + ' h ' + (m % 60) + ' min';
+}
+
+// A proof shot is a Media object (med_…); the picture is at Media's own address, the id stays under it.
+function shotURL(id) {
+ const s = String(id == null ? '' : id).trim();
+ if (/^med_[0-9A-Za-z]+$/.test(s)) return 'https://openvibe.media/o/' + s + '?w=640';
+ if (/^https?:\/\//i.test(s)) return s;
+ return null;
+}
+
+function reportCard(rep) {
+ const out = reportOutcome(rep);
+ return '<li class="rp-report"><a href="/s/' + encodeURIComponent(rep.id) + '"><span class="rp-kind">' + esc(out.label)
+ + '</span><b>' + esc(rep.task.title) + '</b><span class="rp-report-s">' + esc(rep.summary) + '</span>'
+ + '<span class="rp-meta">' + AI + ' · ' + esc(worked(rep))
+ + (rep.cost_usd != null ? ' · $' + esc(String(rep.cost_usd)) : '') + '</span></a></li>';
+}
+
+// The Rundown's new section: what agents built today. The fold is counted ("More (N)"), never a silent cut.
+function reportsSection(reports) {
+ const all = (reports || []).slice(0, 24);
+ const shown = all.slice(0, 8);
+ const rest = all.slice(8);
+ return '<section aria-labelledby="rp-ag-h"><div class="rp-head-row"><h2 id="rp-ag-h">What agents built today</h2>'
+ + '<span class="rp-counts"><b>' + exact(all.length) + '</b> report' + (all.length === 1 ? '' : 's') + '</span></div>'
+ + '<ul class="rp-topics">' + shown.map(reportCard).join('') + '</ul>'
+ + (rest.length ? '<details class="rp-fold"><summary>More (' + exact(rest.length) + ')</summary><ul class="rp-topics">'
+ + rest.map(reportCard).join('') + '</ul></details>' : '')
+ + '</section>';
+}
+
+function sessionReport({ report: rep, signedIn = false } = {}) {
+ if (!rep) return notFound();
+ const out = reportOutcome(rep);
+ const proof = rep.proof || {};
+ const total = proof.tests ? (Number(proof.tests.passed) || 0) + (Number(proof.tests.failed) || 0) : 0;
+ const tests = proof.tests ? (Number(proof.tests.passed) || 0) + ' of ' + total + ' tests passing' : '';
+ const testsOk = proof.tests ? !(Number(proof.tests.failed) > 0) : false;
+ const shots = proof.screenshots || [];
+ const watch = (rep.outputs || []).filter((o) => o.kind === 'vod' || o.kind === 'clip');
+ const made = (rep.outputs || []).filter((o) => o.kind !== 'vod' && o.kind !== 'clip');
+ const steps = rep.timeline || [];
+ let body = '<header class="rp-page-h"><p class="rp-kicker">Session report · ' + esc(rep.source) + '</p><h1>'
+ + esc(rep.task.title) + '</h1><p class="rp-sr-meta"><span class="rp-kind ' + out.cls + '">' + esc(out.label)
+ + '</span>' + AI + '<span>worked ' + esc(worked(rep)) + '</span>'
+ + (rep.cost_usd != null ? '<span>$' + esc(String(rep.cost_usd)) + '</span>' : '')
+ + (rep.models && rep.models.length ? '<span>' + esc(rep.models.join(', ')) + '</span>' : '') + '</p></header>';
+ body += '<section aria-labelledby="rp-sum-h"><h2 id="rp-sum-h">What was asked</h2><p class="rp-lede">'
+ + esc(rep.summary) + '</p>'
+ + '</section>';
+ if (made.length) {
+ body += '<section aria-labelledby="rp-out-h"><h2 id="rp-out-h">What came out</h2><ul class="rp-chips">'
+ + made.map((o) => '<li><a href="' + (o.url ? esc(o.url) : '#') + '">' + esc(o.title)
+ + (o.state ? ' (' + esc(o.state) + ')' : '') + '</a></li>').join('') + '</ul></section>';
+ }
+ body += '<section aria-labelledby="rp-pr-h"><h2 id="rp-pr-h">The proof</h2>'
+ + (tests ? '<p class="rp-proof-tests ' + (testsOk ? 'is-ok' : 'is-bad') + '">' + esc(tests) + '</p>' : '')
+ + (shots.length ? '<ul class="rp-shots" aria-label="Screenshots">' + shots.map((s, i) => {
+ const u = shotURL(s);
+ return u ? '<li><a href="' + esc(u) + '"><img src="' + esc(u) + '" alt="Screenshot ' + (i + 1) + '" width="200" height="125" loading="lazy" decoding="async"></a></li>' : '';
+ }).join('') + '</ul>' : '')
+ + (proof.review ? '<div class="rp-review"><p><b>Review:</b> ' + esc(proof.review.verdict) + '</p>'
+ + (proof.review.notes ? '<details class="rp-fold"><summary>The notes</summary><p>' + esc(proof.review.notes) + '</p></details>' : '')
+ + '</div>' : '')
+ + (!tests && !shots.length && !proof.review ? '<p class="rp-meta">No proof was recorded for this session.</p>' : '')
+ + '</section>';
+ if (steps.length || rep.stopped_because) {
+ body += '<section aria-labelledby="rp-tl-h"><h2 id="rp-tl-h">How it went</h2>'
+ + (rep.stopped_because ? '<p class="rp-summary">It stopped: ' + esc(rep.stopped_because) + '</p>' : '')
+ + '<ol class="rp-steps">'
+ + steps.map((s) => {
+ const st = ['passed', 'failed', 'skipped', 'started'].includes(s.status) ? s.status : 'started';
+ return '<li class="is-' + st + '"><span class="rp-step-dot" aria-hidden="true"></span><div><p><b>' + esc(s.title) + '</b> <span class="rp-step-st">'
+ + esc(st === 'started' ? 'running' : st)
+ + (s.tests ? ' · ' + (Number(s.tests.passed) || 0) + ' of ' + ((Number(s.tests.passed) || 0) + (Number(s.tests.failed) || 0)) + ' passing' : '') + '</span></p>'
+ + (s.detail ? '<p class="rp-step-detail">' + esc(s.detail) + '</p>' : '') + '</div></li>';
+ }).join('')
+ + '</ol></section>';
+ }
+ if (watch.length) {
+ body += '<section aria-labelledby="rp-wa-h"><h2 id="rp-wa-h">Watch</h2><ul class="rp-chips">'
+ + watch.map((o) => '<li><a href="' + (o.url ? esc(o.url) : '#') + '">' + esc(o.title) + '</a></li>').join('')
+ + '</ul></section>';
+ }
+ body += '<section aria-labelledby="rp-ac-h"><h2 id="rp-ac-h">What to do</h2><p class="rp-act">'
+ + '<a class="rp-btn" href="/s/' + encodeURIComponent(rep.id) + '">Share this report</a>'
+ + (signedIn ? ' <a class="rp-btn is-quiet" href="https://openvibe.codes/t/new?from=' + encodeURIComponent(rep.id) + '">Do this again</a>' : '')
+ + '</p></section>';
+ return layout({
+ title: rep.task.title + ': the session report',
+ description: rep.summary,
+ body,
+ active: '/rundown',
+ path: '/s/' + rep.id,
+ });
+}
+
function topic({ topic: d } = {}) {
function topic({ topic: d } = {}) {
const t = d.topic || {};
const t = d.topic || {};
const takes = (d.takes || []).slice(0, 40);
const takes = (d.takes || []).slice(0, 40);
@@ -693,4 +806,4 @@ function notFound() {
@@ -693,4 +806,4 @@ function notFound() {
return layout({ title: 'Not found', robots: 'noindex, follow', body: `<header class="rp-page-h"><h1>Nothing here</h1><p class="rp-lede">That page does not exist, or its source has nothing for it. <a href="/">Back to Report</a>.</p></header>` });
return layout({ title: 'Not found', robots: 'noindex, follow', body: `<header class="rp-page-h"><h1>Nothing here</h1><p class="rp-lede">That page does not exist, or its source has nothing for it. <a href="/">Back to Report</a>.</p></header>` });
}
}
-module.exports = { home, stats, rundown, topic, episode, speaker, recap, channel, you, youSignedOut, developers, aiIndex, aiBoard, aiModel, aiExchange, staffOnly, usdNano, paretoRows, paretoChart, notFound, layout, setRelease, configure };
+module.exports = { home, stats, rundown, sessionReport, topic, episode, speaker, recap, channel, you, youSignedOut, developers, aiIndex, aiBoard, aiModel, aiExchange, staffOnly, usdNano, paretoRows, paretoChart, notFound, layout, setRelease, configure };
@@ -171,6 +171,7 @@ async function boot(opts = {}) {
@@ -171,6 +171,7 @@ async function boot(opts = {}) {
sso: opts.sso,
sso: opts.sso,
shadow: opts.shadow,
shadow: opts.shadow,
...(opts.aiUsage ? { aiUsage: opts.aiUsage } : {}),
...(opts.aiUsage ? { aiUsage: opts.aiUsage } : {}),
+ ...(opts.verifyServiceToken ? { verifyServiceToken: opts.verifyServiceToken } : {}),
releaseEnv: { ...process.env, RELEASE_COMMIT: '0000000' },
releaseEnv: { ...process.env, RELEASE_COMMIT: '0000000' },
});
});
const server = await new Promise((resolve) => {
const server = await new Promise((resolve) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
+'use strict';
+/**
+ * Agent Streams S3: the report page, the Rundown's section and the Search document.
+ *
+ * node test/session-report-page.test.js
+ */
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+const { createSearchIndex } = require('../server/search-index');
+
+const OWNER = 'usr_01JAN2C3D4E5F6G7H8J9K0MNPS';
+
+function body(over = {}) {
+ return {
+ owner: OWNER,
+ source: 'codes',
+ task: { title: 'Ship the checkout page', ref: 'tsk_01JAN2C3D4E5F6G7H8J9K0MNPS' },
+ started_at: '2026-11-06T18:00:00.000Z',
+ ended_at: '2026-11-06T18:42:00.000Z',
+ worked_seconds: 2520,
+ outcome: 'done',
+ summary: 'Asked for a checkout page; the agent built it and ran the tests.',
+ timeline: [{ at: '2026-11-06T18:20:00.000Z', title: 'Run the tests', status: 'passed', tests: { passed: 12, failed: 0 } }],
+ outputs: [
+ { kind: 'pr', url: 'https://forgejo.example/openvibe/site/pulls/7', title: 'Checkout page', state: 'open' },
+ { kind: 'vod', url: 'https://openvibe.video/v/vod_1', title: 'The recording' },
+ ],
+ proof: { tests: { passed: 12, failed: 0 }, screenshots: ['med_01JAN2C3D4E5F6G7H8J9K0MNPS'], review: { verdict: 'Looks good', notes: 'The mobile layout needs a wider gutter.' } },
+ cost_usd: 0.31,
+ models: ['claude-sonnet-4'],
+ highlights: ['Found the coupon bug on the first read'],
+ visibility: 'public',
+ ...over,
+ };
+}
+
+(async () => {
+ const verifyServiceToken = async () => ({ ok: true, claims: { sub: 'svc:codes', cap: ['report.session.write'], aud: ['openvibe.report'] } });
+ const t = await boot({ db: true, verifyServiceToken, now: () => Date.parse('2026-11-06T20:00:00Z') });
+ try {
+ const write = await t.get('/internal/session-reports', { json: body(), headers: { authorization: 'Bearer good' } });
+ assert.strictEqual(write.status, 201);
+ const id = write.json().id;
+
+ await check('the page renders every part', async () => {
+ const page = await t.get('/s/' + id);
+ assert.strictEqual(page.status, 200);
+ const text = page.text;
+ for (const part of ['Ship the checkout page', 'What was asked', 'What came out', 'The proof', 'How it went', 'Watch', 'What to do']) {
+ assert.ok(text.includes(part), part);
+ }
+ assert.ok(text.includes('Done'), 'the outcome is a status chip');
+ assert.ok(text.includes('worked 42 min'), 'how long it worked');
+ assert.ok(text.includes('$0.31'), 'what it cost');
+ assert.ok(text.includes('12 of 12 tests passing') && text.includes('rp-proof-tests is-ok'), 'the tests, as one chip');
+ assert.ok(text.includes('med_01JAN2C3D4E5F6G7H8J9K0MNPS'), 'the screenshots');
+ assert.ok(text.includes('Looks good'), 'the review in one line');
+ assert.ok(text.includes('The notes'), 'the notes are folded');
+ assert.ok(text.includes('Checkout page'), 'the output');
+ assert.ok(text.includes('Run the tests'), 'the step');
+ });
+
+ await check('the Rundown shows what agents built today, with a counted fold', async () => {
+ // Nine public reports: the first eight are shown, the ninth is behind the fold, and the fold counts.
+ for (let i = 2; i <= 9; i++) {
+ const r = await t.get('/internal/session-reports', {
+ json: body({ task: { title: 'Report ' + i, ref: 'tsk_fold_' + i } }),
+ headers: { authorization: 'Bearer good' },
+ });
+ assert.strictEqual(r.status, 201);
+ }
+ const page = await t.get('/rundown');
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('What agents built today'), 'the section');
+ assert.ok(page.text.includes('Ship the checkout page'), 'the report is in it');
+ assert.ok(page.text.includes('/s/' + id), 'and it links to the report');
+ assert.ok(page.text.includes('More (1)'), 'the fold counts what is behind it');
+ assert.ok(page.text.includes('<b>9</b> reports'), 'and the section counts them all');
+ });
+
+ await check('the public report goes to Search, and only the public one', async () => {
+ const searchIndex = createSearchIndex({
+ config: t.config, store: t.ctx.store, live: { topics: async () => ({ topics: [] }) },
+ sessionReports: t.ctx.sessionReports, log: { log() {}, warn() {} },
+ });
+ await searchIndex.sweep();
+ const db = t.ctx.store.db;
+ const outbox = (await db.many('SELECT envelope FROM event_outbox ORDER BY id')).map((r) => r.envelope);
+ const docs = outbox.map((e) => e.payload).filter((p) => p && p.type === 'session');
+ const doc = docs.find((d) => d.id === id);
+ assert.ok(doc, 'the public report is a document');
+ assert.strictEqual(doc.canonical_url, 'https://openvibe.report/s/' + id);
+ assert.ok(doc.title.includes('Ship the checkout page'), 'the document says what the page says');
+ const priv = await t.get('/internal/session-reports', {
+ json: body({ task: { title: 'A private one', ref: 'tsk_private_1' }, visibility: 'private' }),
+ headers: { authorization: 'Bearer good' },
+ });
+ await searchIndex.sweep();
+ const docs2 = (await db.many('SELECT envelope FROM event_outbox ORDER BY id')).map((r) => r.envelope)
+ .map((e) => e.payload).filter((p) => p && p.type === 'session');
+ assert.ok(!docs2.some((d) => d.id === priv.json().id), 'a private report is never in Search');
+ assert.ok(contracts.validate('search.index-document@1', doc).valid, 'the document fits the contract');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,132 @@
@@ -0,0 +1,132 @@
+'use strict';
+/**
+ * Agent Streams S3: the report of every agent session.
+ *
+ * node test/session-reports.test.js
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const OWNER = 'usr_01JAN2C3D4E5F6G7H8J9K0MNPS';
+const TASK = { title: 'Ship the checkout page', ref: 'tsk_01JAN2C3D4E5F6G7H8J9K0MNPS' };
+
+function body(over = {}) {
+ return {
+ owner: OWNER,
+ source: 'codes',
+ task: TASK,
+ started_at: '2026-11-06T18:00:00.000Z',
+ ended_at: '2026-11-06T18:42:00.000Z',
+ worked_seconds: 2520,
+ outcome: 'done',
+ summary: 'Asked for a checkout page; the agent built it and ran the tests.',
+ timeline: [{ at: '2026-11-06T18:20:00.000Z', title: 'Run the tests', status: 'passed' }],
+ outputs: [{ kind: 'pr', url: 'https://forgejo.example/openvibe/site/pulls/7', title: 'Checkout page' }],
+ proof: { tests: { passed: 12, failed: 0 }, screenshots: [], review: null },
+ cost_usd: 0.31,
+ models: ['claude-sonnet-4'],
+ highlights: ['Found the coupon bug on the first read'],
+ visibility: 'private',
+ ...over,
+ };
+}
+
+(async () => {
+ const tokens = { good: null, weak: null, none: null };
+ const verifyServiceToken = async (token) => {
+ if (!token) return { ok: false, code: 'token.required', reason: 'no token' };
+ if (token === 'weak') return { ok: true, claims: { sub: 'svc:codes', cap: ['live.agent_stream.publish'], aud: ['openvibe.report'] } };
+ return { ok: true, claims: { sub: 'svc:codes', cap: ['report.session.write'], aud: ['openvibe.report'] } };
+ };
+ const t = await boot({ db: true, verifyServiceToken, now: () => Date.parse('2026-11-06T20:00:00Z') });
+ try {
+ await check('the door needs a service token', async () => {
+ const r = await t.get('/internal/session-reports', { json: body() });
+ assert.strictEqual(r.status, 401);
+ });
+
+ await check('a token without the capability is refused', async () => {
+ const r = await t.get('/internal/session-reports', { json: body(), headers: { authorization: 'Bearer weak' } });
+ assert.strictEqual(r.status, 403);
+ assert.strictEqual(r.json().error, 'capability.forbidden');
+ });
+
+ await check('a service with the capability writes the report', async () => {
+ const r = await t.get('/internal/session-reports', { json: body(), headers: { authorization: 'Bearer good' } });
+ assert.strictEqual(r.status, 201);
+ const out = r.json();
+ assert.ok(/^rps_[0-9A-HJKMNP-TV-Z]{26}$/.test(out.id), out.id);
+ assert.strictEqual(out.url, 'https://openvibe.report/s/' + out.id);
+ assert.strictEqual(out.created, true);
+ tokens.good = out.id;
+ });
+
+ await check('the write is idempotent per source and task ref', async () => {
+ const r = await t.get('/internal/session-reports', { json: body(), headers: { authorization: 'Bearer good' } });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.json().id, tokens.good, 'the same task answers the same report');
+ assert.strictEqual(r.json().created, false);
+ });
+
+ await check('the house redactor runs over every text field', async () => {
+ const secret = 'sk-ant-api03-REALSECRETVALUE1234567890';
+ const r = await t.get('/internal/session-reports', {
+ json: body({
+ task: { title: 'Ship it', ref: 'tsk_redact_1' },
+ summary: 'It ran with ' + secret + ' and read OPENAI_API_KEY=sk-live-abcdefghijklmnop from the .env file.',
+ highlights: ['key ' + secret],
+ timeline: [{ at: '2026-11-06T18:00:00.000Z', title: 'Read ' + secret, status: 'passed' }],
+ }),
+ headers: { authorization: 'Bearer good' },
+ });
+ assert.strictEqual(r.status, 201);
+ const rep = await t.ctx.sessionReports.get(r.json().id);
+ assert.ok(rep, 'the report is stored');
+ assert.ok(!rep.summary.includes(secret), 'the secret is not in the summary');
+ assert.ok(!/sk-live-abcdefghijklmnop/.test(rep.summary), 'the .env value is not in the summary');
+ assert.ok(!rep.highlights.join(' ').includes(secret), 'the secret is not in the highlights');
+ assert.ok(!rep.timeline.map((s) => s.title).join(' ').includes(secret), 'the secret is not in the steps');
+ });
+
+ await check('a private report is never listed', async () => {
+ const listed = await t.ctx.sessionReports.listPublic({ limit: 50 });
+ assert.ok(!listed.some((rep) => rep.id === tokens.good), 'a private report is not public');
+ const page = await t.get('/s/' + tokens.good);
+ assert.strictEqual(page.status, 404, 'a private report is not a page for anyone else');
+ });
+
+ await check('an unlisted report opens for anyone with the link', async () => {
+ const r = await t.get('/internal/session-reports', {
+ json: body({ task: { title: 'Unlisted one', ref: 'tsk_unlisted_1' }, visibility: 'unlisted' }),
+ headers: { authorization: 'Bearer good' },
+ });
+ assert.strictEqual(r.status, 201);
+ const page = await t.get('/s/' + r.json().id);
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('Unlisted one'), 'the page shows the task');
+ assert.ok(page.text.includes('What was asked'), 'the page has the summary part');
+ assert.ok(page.text.includes('The proof'), 'the page has the proof part');
+ assert.ok(page.text.includes('How it went'), 'the page has the steps part');
+ assert.ok(page.text.includes('What to do'), 'the page has the actions part');
+ const listed = await t.ctx.sessionReports.listPublic({ limit: 50 });
+ assert.ok(!listed.some((rep) => rep.id === r.json().id), 'an unlisted report is not listed');
+ });
+
+ await check('a public report is in the Rundown section and the sitemap', async () => {
+ const r = await t.get('/internal/session-reports', {
+ json: body({ task: { title: 'Public one', ref: 'tsk_public_1' }, visibility: 'public' }),
+ headers: { authorization: 'Bearer good' },
+ });
+ assert.strictEqual(r.status, 201);
+ const id = r.json().id;
+ const rundown = await t.get('/rundown');
+ const sm = await t.get('/sitemap.xml');
+ assert.ok(sm.text.includes('/s/' + id), 'the sitemap has the public report');
+ const listed = await t.ctx.sessionReports.listPublic({ limit: 50 });
+ assert.ok(listed.some((rep) => rep.id === id), 'the public report is listed');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });