openvibers/openvibe.report Commits

Agent Streams S3: the report of every agent session (#108)

OP OpenVibers committed 8 days ago · 22a32181a5 · 13 files, +926 −15

* Agent Streams S3: Agent Streams S3: the report of every agent session

* Agent Streams S3: the report page's parts, the counted fold and the shots

* Session report page: one wrapping meta row, the tests as a chip, screenshots as a strip, steps with status dots

Changes

ddiff --git a/migrations/0007_session_reports.sql b/migrations/0007_session_reports.sql
nnew file mode 100644
iindex 0000000..39c806f
---- /dev/null
++++ b/migrations/0007_session_reports.sql
@@@ -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);
ddiff --git a/public/css/app.css b/public/css/app.css
iindex e2a7ce6..1d05ed4 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -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; }
ddiff --git a/scripts/session-report-shots.js b/scripts/session-report-shots.js
nnew file mode 100644
iindex 0000000..b5dafe3
---- /dev/null
++++ b/scripts/session-report-shots.js
@@@ -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); });
ddiff --git a/server/app.js b/server/app.js
iindex a561140..602ccf5 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 }));
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 53beace..135164a 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -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,
})),
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 8af8683..1fd4146 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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) => {
ddiff --git a/server/http/session-reports.js b/server/http/session-reports.js
nnew file mode 100644
iindex 0000000..80019f4
---- /dev/null
++++ b/server/http/session-reports.js
@@@ -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 };
ddiff --git a/server/search-index.js b/server/search-index.js
iindex a9ff279..b5f0b09 100644
---- a/server/search-index.js
++++ b/server/search-index.js
@@@ -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 };
ddiff --git a/server/session-reports.js b/server/session-reports.js
nnew file mode 100644
iindex 0000000..e79f2a9
---- /dev/null
++++ b/server/session-reports.js
@@@ -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 };
ddiff --git a/server/views/index.js b/server/views/index.js
iindex d3f6037..8633da6 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -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 });
}
--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 };
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 7ebe733..f0e96c5 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -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) => {
ddiff --git a/test/session-report-page.test.js b/test/session-report-page.test.js
nnew file mode 100644
iindex 0000000..9e1c206
---- /dev/null
++++ b/test/session-report-page.test.js
@@@ -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); });
ddiff --git a/test/session-reports.test.js b/test/session-reports.test.js
nnew file mode 100644
iindex 0000000..e7b0d01
---- /dev/null
++++ b/test/session-reports.test.js
@@@ -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); });