openvibers/openvibe.blog Commits
Draft weekly site devblogs through the AI writer (#252)
Changes
@@@ -2,6 +2,8 @@## Unreleased++- Weekly devblogs use the content.devblog AI agent and blog.devblog.write task: meaningful merged changes, stored screenshots, rich blocks, and a person-only review gate. Owner requests and the opt-in scheduler share durable weekly reservations. Per-site and network indexes use the first figure and summary; the back-catalogue script defaults to a read-only dry run.++- The screenshot pass, from the Blog's side (plan L9, devblog §3.3): when the patch notes are drafted, Blog reads the shotsOpenVibe.Host captured (the newest one taken before the release and the first one taken after it, per page and viewport).When the two differ by more than 0.5 % of the pixels, both are attached to the draft (before, after) with the alt text and thennew file mode 100644@@@ -0,0 +1,24 @@++# Weekly devblog drafts++++`POST /api/v1/blogs/{handle}/posts/devblog { "service": "live" }` and the editor’s **Draft a devblog** use the same writer. Only the owner of the configured destination blog may invoke it. The reusable `actor.skill@1` document is served at `/skills/devblog.json`; it describes the input and the draft-only result, and grants no permissions. Actor can load this document; importing it into Actor’s central catalogue is a separate registration action.++++Blog supplies existing `changelog_entries`, clustered stories, commit/PR/file evidence, its voice card, and Media’s stored Host screenshots. `content.devblog` runs through AI task `blog.devblog.write` (AI PR #397 supplies choices), honoring the caller’s saved task choice and owner defaults. It plans, writes, checks claims and, if needed, revises once and checks again. It makes at most five model calls. No paid image or search tool is invoked. With no screenshot, the existing typographic hero is used. The draft records AI workflow, run and model provenance; attached Media IDs make figures render through B1. A person must approve the revision before publish or schedule; an AI-editor approval is insufficient.++++`BLOG_DEVBLOG_ENABLED=on` enables weekly drafting from the existing changelog worker (also requires `BLOG_WORKER` and changelog collection enabled). It considers the previous complete Monday–Sunday UTC week. On-demand drafting considers the current week. Both use the same durable `(service, week_start)` reservation and a seven-day live cadence check, serialized by the destination blog row. An existing draft is returned unchanged. Failed scheduled attempts do not retry automatically; an owner’s explicit request can retry. A process interruption leaves a reserved slot for operator reconciliation: verify the AI run and any stored draft before clearing it. Daily Patch notes and their `post_id` assignments remain unchanged.++++By default sites use `CHANGELOG_BLOG` (the official blog). Set `BLOG_DEVBLOG_SITES` to a JSON object such as `{"my-site":"my-blog"}` to bind another site to its owner’s blog. This trusted deployment mapping prevents a member from claiming a different site. Only meaningful merged changes qualify: pins and test/docs-only changes do not. Weeks exceeding 200 commits require review before drafting; nothing is silently truncated into a partial weekly post.++++Public `/devblog` lists published devblogs across blogs; `/devblog/{service}` narrows to a site. `/api/v1/devblogs?service=live` returns the same public summaries and first figure, with offset pagination. Existing B1 classes/styles are retained. Private posts and drafts never enter these lists. Folded patch notes read the exact historical date window and the weekly slot’s site.++++## Back catalogue++++Run a dry run first, against a local database or an exported JSON array of changelog rows:++++```++node scripts/devblog-backfill.js --from 2026-10-01 --to 2026-11-09 --dry-run++node scripts/devblog-backfill.js --input /tmp/changelog-entries.json --from 2026-10-01 --to 2026-11-09++```++++Without `--input`, the dry run uses a read-only PostgreSQL transaction with a 10-second statement timeout. It does not boot/migrate/seed the app, fetch screenshots, call AI, or write posts. It prints each qualifying site/week and omits already reserved/drafted weeks. `--to` is exclusive. Windows are grouped into whole UTC weeks; an actual draft includes all entries in that week.++++Only after reviewing that list, `--write` drafts those weeks from the local database. Historical backfill bypasses the rolling seven-day guard but never the unique weekly reservation or human publication gate. `NODE_ENV=production`, snapshot input with writes, and combining `--dry-run` with `--write` are refused. Do not run the writer against production for this rollout. No legacy post is redirected or replaced while its weekly replacement is a draft.nnew file mode 100644@@@ -0,0 +1,14 @@++-- phase: expand++-- Separate weekly reservations: the daily Patch notes retain ownership of changelog entries.++CREATE TABLE IF NOT EXISTS blog_devblog_slots (++ service text NOT NULL,++ week_start text NOT NULL,++ blog_id text NOT NULL REFERENCES blogs(id),++ status text NOT NULL CHECK (status IN ('reserved', 'drafted', 'failed')),++ attempt integer NOT NULL DEFAULT 1,++ post_id text REFERENCES blog_posts(id),++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL,++ PRIMARY KEY (service, week_start)++);++CREATE INDEX IF NOT EXISTS blog_devblog_slots_blog ON blog_devblog_slots(blog_id, created_at);nnew file mode 100644@@@ -0,0 +1,30 @@++{++ "skill": "actor.skill@1",++ "slug": "blog-devblog",++ "name": "Draft my project’s devblog",++ "description": "Use when a site owner wants a weekly devblog from merged changes, with evidence, screenshots and human publication review.",++ "version": "1.0.0",++ "instructions": "Ask for the site service id and its configured blog handle. Use POST https://openvibe.blog/api/v1/blogs/{handle}/posts/devblog with {service}. The authenticated owner is the actor; never impersonate another person. Blog gathers changelog stories and stored screenshots, then runs the content.devblog workflow using the blog.devblog.write AI choices task. It returns a draft post. Show the owner its editor URL /write/posts/{id} and the run provenance. Never publish, approve, schedule, buy artwork, or change a saved model choice. A 409 means a weekly reservation, cadence limit or pending attempt; show that result rather than circumventing it. A failed run is not a draft. The same action serves every configured site, including the network’s own sites.",++ "resources": [++ {++ "path": "input.json",++ "media_type": "application/json",++ "text": "{\"type\":\"object\",\"required\":[\"handle\",\"service\"],\"properties\":{\"handle\":{\"type\":\"string\",\"title\":\"Blog handle\"},\"service\":{\"type\":\"string\",\"title\":\"Site service id\"}}}"++ }++ ],++ "requires": {++ "kinds": [++ "post"++ ],++ "hosts": [++ "openvibe.blog",++ "ai.openvibe.services"++ ]++ },++ "runs": "prompt",++ "license": "AGPL-3.0-only",++ "integrity": {++ "alg": "sha256",++ "hash": "8cf9086570242b1a577fceea14553f74d767683d8d1f82be544fca22fb94594d"++ }++}nnew file mode 100644@@@ -0,0 +1,47 @@++#!/usr/bin/env node++'use strict';++// Dry run never boots the app (boot migrates/seeds) and never calls an AI provider.++const fs = require('fs');++const { Client } = require('pg');++const { candidates, weekOf, WEEK } = require('../server/devblog');++async function main(args = process.argv.slice(2)) {++ const opt = name => { const i = args.indexOf(`--${name}`); return i < 0 ? null : args[i + 1]; };++ const to = opt('to') || weekOf(Date.now());++ const from = opt('from') || new Date(Date.parse(to) - 6 * WEEK).toISOString().slice(0, 10);++ if (![from, to].every(v => /^\d{4}-\d{2}-\d{2}$/.test(v) && Number.isFinite(Date.parse(v))) || from >= to) throw new Error('Use --from YYYY-MM-DD --to YYYY-MM-DD (exclusive)');++ let entries, slots = [];++ if (opt('input')) entries = JSON.parse(fs.readFileSync(opt('input'), 'utf8'));++ else {++ const client = new Client({ connectionString: process.env.DATABASE_URL });++ await client.connect();++ try {++ await client.query('BEGIN READ ONLY');++ await client.query("SET LOCAL statement_timeout = '10s'");++ entries = (await client.query('SELECT * FROM changelog_entries WHERE deployed_at >= $1 AND deployed_at < $2 ORDER BY deployed_at, id', [from, to])).rows;++ // Before the B2 migration, there are no weekly slots yet.++ if ((await client.query("SELECT to_regclass('blog_devblog_slots') AS name")).rows[0].name) slots = (await client.query('SELECT service, week_start, status FROM blog_devblog_slots')).rows;++ await client.query('ROLLBACK');++ } finally { await client.end(); }++ }++ const plan = candidates(entries, { from, to }).filter(c => !slots.some(s => s.service === c.service && s.week_start === c.week && s.status !== 'failed'));++ for (const c of plan) console.log(`DRAFT ${c.service} week ${c.week}: ${c.entries.length} merged changes`);++ console.log(`${plan.length} draft(s); ${args.includes('--write') ? 'write requested' : 'dry run, no AI calls or writes'}`);++ if (!args.includes('--write')) return plan;++ if (args.includes('--dry-run')) throw new Error('Choose --dry-run or --write, not both');++ if (process.env.NODE_ENV === 'production') throw new Error('Back-catalogue writes are disabled in production');++ if (opt('input')) throw new Error('--input is a dry-run snapshot only; writes read the local database');++ const { ctx } = await require('../server/app').createApp();++ try {++ for (const c of plan) {++ const blog = await ctx.devblog.blogFor(c.service);++ if (!blog) throw new Error(`No configured blog for ${c.service}`);++ const owner = await ctx.store.db.prepare("SELECT subject FROM blog_memberships WHERE blog_id = ? AND role = 'owner' ORDER BY created_at LIMIT 1").get(blog.id);++ if (!owner) throw new Error(`No owner for ${c.service}`);++ const out = await ctx.devblog.draft({ kind: 'user', subject: owner.subject }, blog, { service: c.service, week: c.week, backfill: true, retry: true });++ console.log(`Saved draft ${out.post.id}`);++ }++ } finally { ctx.worker.stop(); await ctx.outbox.stop(); await ctx.store.close(); }++ return plan;++}++if (require.main === module) main().catch(() => { console.error('Devblog backfill failed; no post was published. Check the input, configuration and draft queue.'); process.exitCode = 1; });++module.exports = { main };@@@ -104,11 +104,13 @@ async function createApp(opts = {}) {// through it; posts.publish refuses a draft with a blocking check or no person's approval.const patchNotes = opts.patchNotes || require('./patch-notes').createPatchNotes({ config, store, blogs, posts, publication, aiDrafts, media, fetchImpl, log });const changelog = opts.changelog || require('./changelog').createChangelog({ config, store, blogs, posts, patchNotes, fetchImpl, log });++ const devblog = require('./devblog').createDevblog({ config, store, blogs, posts, access, aiDrafts, media, log });++ if (changelog.attachDevblog) changelog.attachDevblog(devblog);if (patchNotes.attach) patchNotes.attach({ changelog });posts.setPublishGate(patchNotes.gate);// The AI desks (server/desks/): digests from Sources' items and the satire desk, beside the blogs.const desks = opts.desks || require('./desks/desks').createDesks({ config, store, fetchImpl, log });-- const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, vip, auth, viewers, access, worker, aiDrafts, changelog, patchNotes, desks, indexnow, sharing };++ const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, vip, auth, viewers, access, worker, aiDrafts, changelog, patchNotes, devblog, desks, indexnow, sharing };const app = express();app.disable('x-powered-by');@@@ -258,6 +260,10 @@ async function createApp(opts = {}) {// page its own route renders; the twins are rendered signed out through the same public router.{const agentRouter = express.Router();++ agentRouter.get('/.well-known/agent.json', (_req, res) => res.json({++ ...require('openvibe-shared/agent-layer').agentManifest(require('./agent-descriptor')),++ skills: [{ name: 'Draft my project’s devblog', url: `${config.baseUrl}/skills/devblog.json`, task: 'blog.devblog.write', workflow: 'content.devblog', result: 'draft' }],++ }));require('openvibe-shared/agent-layer').mount(agentRouter, {descriptor: require('./agent-descriptor'),render: require('./agent-pages').createAgentPages({ router: publicRoutes.router, host: new URL(config.baseUrl).hostname }).renderPublicPage,@@@ -314,6 +314,7 @@ function createChangelog({ config, store, blogs, posts, patchNotes = null, fetch}}++ let devblog = null;async function tick() {stats.runs++; stats.lastRunAt = new Date(nowMs()).toISOString();try {@@@ -321,6 +322,7 @@ function createChangelog({ config, store, blogs, posts, patchNotes = null, fetchif (patchNotes && patchNotes.compact) {try { await patchNotes.compact(); } catch (err) { log.warn && log.warn(`[changelog] fold skipped: ${err.message}`); }}++ if (devblog) await devblog.tick();const list = await q.pending.all();const d = await due(list);if (d.due) return await fileDraft(list.slice(0, c.batchSize * 3), d.reason);@@@ -365,7 +367,7 @@ function createChangelog({ config, store, blogs, posts, patchNotes = null, fetch/** Every site with entries: [{ service, entries, latest_at }], most recently shipped first. */async function sites() { return await q.sites.all(); }-- return { tick, collect, due, fileDraft, enrich, pendingEntries, markEntries, entriesOf, moveEntries, openPulls, start, stop, entries, page, latestPost, recentPosts, sites, importHistory, stats: async () => ({ enabled: c.enabled, ...stats, github_token: cachedToken.source, pending: (await q.pending.all()).length }) };++ return { attachDevblog: value => { devblog = value; }, tick, collect, due, fileDraft, enrich, pendingEntries, markEntries, entriesOf, moveEntries, openPulls, start, stop, entries, page, latestPost, recentPosts, sites, importHistory, stats: async () => ({ enabled: c.enabled, ...stats, github_token: cachedToken.source, pending: (await q.pending.all()).length }) };}/** A stable id for a batch (tests). */@@@ -69,6 +69,7 @@ function load(env = process.env) {ai: env.CHANGELOG_AI_DRAFT !== 'off',},// OpenVibe.AI for "Draft with AI" (server/domain/ai-drafts.js); empty = the button is not offered.++ devblog: { enabled: env.BLOG_DEVBLOG_ENABLED === 'on', sites: JSON.parse(env.BLOG_DEVBLOG_SITES || '{}') },aiUrl: trim(env.OV_AI_INTERNAL_URL || ''),// OpenVibe.Sources (sources.item.read): the items the AI desks write their digests from (server/desks/).sources: { internalUrl: trim(env.OV_SOURCES_INTERNAL_URL || ''), timeoutMs: int(env.BLOG_SOURCES_TIMEOUT_MS, 10000) },nnew file mode 100644@@@ -0,0 +1,117 @@++'use strict';++const { build } = require('../patch-notes/stories');++const { intentOf } = require('../patch-notes/text');++const { createVoiceCards } = require('../patch-notes/voice');++const { ApiError } = require('../http/errors');++const blocks = require('openvibe-publishing/blocks');++const WEEK = 7 * 86400000;++const date = ms => new Date(ms).toISOString().slice(0, 10);++function weekOf(value) {++ const d = new Date(value); if (!Number.isFinite(+d)) throw new ApiError(422, 'devblog.date', 'Invalid week');++ d.setUTCHours(0, 0, 0, 0); d.setUTCDate(d.getUTCDate() - (d.getUTCDay() + 6) % 7); return date(+d);++}++function meaningful(e) {++ const subject = String(e.subject || '');++ if (/^(?:test|docs?|ci|build|chore)(?:\([^)]*\))?[:\s]|^Merge\b|^openvibe-(?:shared|sdk|contracts|publishing)\s+v|^(?:pin|bump)\b|dependenc/i.test(subject)) return false;++ let files = e.files; if (typeof files === 'string') { try { files = JSON.parse(files); } catch { files = []; } }++ if (Array.isArray(files) && files.length && files.every(f => /(^|\/)(?:test|tests|docs|\.github)\/|\.(?:md|test\.js|spec\.js)$|(?:^|\/)package(?:-lock)?\.json$/.test(typeof f === 'string' ? f : f.filename || ''))) return false;++ return /^(?:feat|fix)(?:\([^)]*\))?!?:\s/i.test(subject) || ['feature', 'fix'].includes(intentOf(e).kind);++}++function candidates(entries, { from, to }) {++ const groups = new Map();++ for (const e of entries) {++ if (!e.service || !e.deployed_at || e.deployed_at < from || e.deployed_at >= to) continue;++ const week = weekOf(e.deployed_at), key = `${e.service}:${week}`;++ if (!groups.has(key)) groups.set(key, { service: e.service, week, entries: [] });++ groups.get(key).entries.push(e);++ }++ return [...groups.values()].filter(g => g.entries.some(meaningful)).sort((a, b) => a.week.localeCompare(b.week) || a.service.localeCompare(b.service));++}++function createDevblog({ config, store, blogs, posts, access, aiDrafts, media, log = console }) {++ const db = store.db, voice = createVoiceCards({ store });++ async function blogFor(service) {++ const handle = (config.devblog.sites || {})[service] || config.changelog.blogHandle;++ return blogs.byHandle(handle);++ }++ async function authorize(viewer, blog, service) {++ if (!viewer?.subject) throw new ApiError(401, 'subject.required', 'Sign in to draft a devblog');++ if (!/^[a-z][a-z0-9-]{0,63}$/.test(service)) throw new ApiError(422, 'devblog.site', 'Choose a site');++ const target = await blogFor(service);++ if (!target || target.id !== blog.id || await access.effectiveRole(store, viewer, blog) !== 'owner') throw new ApiError(403, 'devblog.owner_required', 'Only the owner of the site’s configured blog may draft its devblog');++ }++ async function draft(viewer, blog, { service, week = weekOf(store.now()), backfill = false, retry = false } = {}, opts = {}) {++ await authorize(viewer, blog, service);++ if (!aiDrafts.enabled) throw new ApiError(503, 'ai.not_configured', 'The devblog writer is not configured');++ const start = weekOf(week), end = date(Date.parse(start) + WEEK);++ if (Date.parse(start) > store.now()) throw new ApiError(422, 'devblog.future', 'The week has not started');++ const entries = await db.prepare('SELECT * FROM changelog_entries WHERE service = ? AND deployed_at >= ? AND deployed_at < ? ORDER BY deployed_at, id').all(service, start, end);++ const useful = entries.filter(meaningful);++ if (!useful.length) throw new ApiError(422, 'devblog.no_changes', 'No meaningful merged changes this week');++ if (entries.length > 200) throw new ApiError(422, 'devblog.too_many_changes', 'Split or review this unusually large week before drafting');++ const reservation = await store.tx(async () => {++ // Serializes live cadence decisions across processes as well as weekly INSERT races.++ await db.prepare('SELECT id FROM blogs WHERE id = ? FOR UPDATE').get(blog.id);++ const slot = await db.prepare('SELECT * FROM blog_devblog_slots WHERE service = ? AND week_start = ?').get(service, start);++ if (slot?.status === 'drafted') return { post: await posts.get(slot.post_id), created: false };++ if (slot?.status === 'failed' && !retry) throw new ApiError(409, 'devblog.failed', 'The previous attempt failed; an owner can retry it');++ if (slot?.status === 'reserved') throw new ApiError(409, 'devblog.running', 'This week’s draft is already being written');++ if (!backfill) {++ const recent = await db.prepare("SELECT 1 AS x FROM blog_devblog_slots WHERE service = ? AND status IN ('reserved', 'drafted') AND created_at > ?").get(service, store.now() - WEEK);++ if (recent) throw new ApiError(409, 'devblog.cadence', 'This site already has a devblog draft in the last seven days');++ }++ await db.prepare(`INSERT INTO blog_devblog_slots (service, week_start, blog_id, status, created_at, updated_at) VALUES (?, ?, ?, 'reserved', ?, ?)++ ON CONFLICT (service, week_start) DO UPDATE SET status = 'reserved', created_at = excluded.created_at, attempt = blog_devblog_slots.attempt + 1, updated_at = excluded.updated_at`).run(service, start, blog.id, store.now(), store.now());++ return { attempt: (slot?.attempt || 0) + 1 };++ });++ if (reservation.post) return reservation;++ try {++ const sources = entries.map(e => ({ source_type: 'git.commit', source_id: e.sha, url: `https://github.com/${e.repo}/commit/${e.sha}`, title: e.subject, author: e.author || 'The OpenVibe team', content: JSON.stringify({ subject: e.subject, body: e.message_body || '', files: e.files || [], pull_request: e.pr_number || null }).slice(0, 10000) }));++ const map = build(useful, { maxStories: 8 });++ const grouped = new Set(map.stories.flatMap(s => s.entries.map(e => e.sha)));++ const storyGroups = [...map.stories, ...useful.filter(e => !grouped.has(e.sha)).map(e => ({ title: e.subject, entries: [e] }))].slice(0, 8);++ const stories = storyGroups.map(s => ({ title: s.title, sources: s.entries.map(e => entries.findIndex(x => x.sha === e.sha)), authors: [...new Set(s.entries.map(e => e.author || 'The OpenVibe team'))] }));++ const shots = media?.listShots ? await media.listShots().catch(() => []) : [];++ const screenshots = [];++ for (const shot of shots) {++ const m = shot.metadata || {};++ const source = entries.findIndex(e => m.service === service && m.release && (m.release === e.release || m.release === e.sha));++ if (source < 0 || !/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(shot.id)) continue;++ screenshots.push({ media_id: shot.id, source, alt: String(m.alt || `${service}: ${m.path || '/'} at ${m.viewport || 'desktop'} width`).slice(0, 300), caption: String(m.caption || `${service} ${m.path || '/'} after this release`).slice(0, 500) });++ if (screenshots.length === 24) break;++ }++ const result = await aiDrafts.devblog(viewer, blog, { site: service, from: start, to: date(Date.parse(end) - 1), sources, stories, screenshots, voice: JSON.stringify(await voice.get(blog.kind === 'official' ? 'official' : blog.id)).slice(0, 6000) }, { ...opts, idempotencyKey: `devblog:${service}:${start}:${reservation.attempt}` });++ const out = result.output;++ if (!out?.title || !out.summary || !out.blocks || !blocks.validate(out.blocks).ok || out.checks?.length) throw new ApiError(422, 'devblog.invalid', 'The writer returned an invalid draft');++ const allowed = new Set(screenshots.map(s => s.media_id));++ const figures = [];++ const visit = n => { if (n.type === 'figure') { if (!allowed.has(n.attrs?.src)) throw new ApiError(422, 'devblog.media', 'The draft named an unknown screenshot'); figures.push(n); } for (const c of n.children || []) visit(c); };++ visit(out.blocks);++ // Disclosure does not claim a person has reviewed the new draft.++ out.blocks.children.splice(1, 0, { type: 'text', value: `Written by our AI writer (${result.workflow.model || 'model recorded in the AI run'}) from the team’s commits.` });++ return await store.tx(async () => {++ const aiViewer = { kind: 'service', service: 'svc:ai', origin: 'ai', subject: viewer.subject };++ const made = await posts.create(aiViewer, blog, { title: out.title, summary: out.summary, blocks: out.blocks, categories: ['Devblog'], tags: ['devblog', service], series: `${service} devblog`, authorship: { workflow: result.workflow, stubProvider: result.synthetic }, citations: sources.map(s => ({ url: s.url, title: s.title })), message: `Devblog draft, ${service}, week ${start}` }, opts);++ for (const f of figures) await posts.attach(aiViewer, made.post, { mediaId: f.attrs.src, alt: f.attrs.alt, caption: f.attrs.caption });++ await db.prepare("UPDATE blog_devblog_slots SET status = 'drafted', post_id = ?, updated_at = ? WHERE service = ? AND week_start = ?").run(made.post.id, store.now(), service, start);++ return { ...made, created: true };++ });++ } catch (err) {++ await db.prepare("UPDATE blog_devblog_slots SET status = 'failed', updated_at = ? WHERE service = ? AND week_start = ? AND status = 'reserved'").run(store.now(), service, start);++ throw err;++ }++ }++ async function tick() {++ if (!config.devblog.enabled || !aiDrafts.enabled) return;++ const week = date(Date.parse(weekOf(store.now())) - WEEK);++ const sites = await db.prepare('SELECT DISTINCT service FROM changelog_entries WHERE deployed_at >= ?').all(week);++ for (const { service } of sites) {++ const blog = await blogFor(service); if (!blog) continue;++ const owner = await db.prepare("SELECT subject FROM blog_memberships WHERE blog_id = ? AND role = 'owner' ORDER BY created_at LIMIT 1").get(blog.id);++ if (!owner) continue;++ try { await draft({ kind: 'user', subject: owner.subject }, blog, { service, week }); }++ catch (err) { if (![409, 422].includes(err.status)) log.warn(`[devblog] ${service}: ${err.code || 'draft failed'}`); }++ }++ }++ return { draft, tick, blogFor };++}++module.exports = { createDevblog, candidates, meaningful, weekOf, WEEK };@@@ -121,7 +121,18 @@ function createAiDrafts({ config, store, posts, access, fetchImpl = globalThis.f}}-- return { enabled: ai.enabled, imageEnabled: ai.enabled, draft, rawDraft, image };++ // Publishing 1.6's client handles polling/provenance; add the choices task to this workflow's request.++ const devblogClient = createAiClient({ baseUrl: config.aiUrl, tokenClient, fetch: (url, options) => {++ if (options.method === 'POST' && options.body) {++ const body = JSON.parse(options.body);++ if (body.workflow === 'content.devblog') options = { ...options, body: JSON.stringify({ ...body, task: 'blog.devblog.write' }) };++ }++ return fetchImpl(url, options);++ } });++ async function devblog(viewer, blog, input, options = {}) {++ return devblogClient.run('content.devblog', input, { ...options, onBehalfOf: { type: 'user', id: viewer.subject }, target: { service: 'blog', type: 'blog', id: String(blog.id) } });++ }++ return { devblog, enabled: ai.enabled, imageEnabled: ai.enabled, draft, rawDraft, image };}module.exports = { createAiDrafts };@@@ -208,6 +208,12 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,let publishGate = null;function setPublishGate(fn) { publishGate = typeof fn === 'function' ? fn : null; }++ async function devblogReview(post, rev) {++ if (!await db.prepare('SELECT 1 AS x FROM blog_devblog_slots WHERE post_id = ?').get(post.id)) return;++ const review = await publication.reviewOf(post, rev);++ if (!require('./review-kind').isPersonReview(review)) throw new ApiError(409, 'devblog.person_review_required', 'A person must approve this devblog revision before publication');++ }++const api = {VISIBILITIES, MEDIA_ROLES,@@@ -231,7 +237,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,* same viewer's post page would refuse.* filters: blogId, termIds (any), seriesId, authorSubject, restricted, restrictedAuthor, limit, offset*/-- async listPublished({ blogId = null, termIds = null, seriesId = null, authorSubject = null, restricted = false, restrictedAuthor = null, limit = 20, offset = 0, order = 'recent' } = {}) {++ async listPublished({ service = null, categorySlug = null, blogId = null, termIds = null, seriesId = null, authorSubject = null, restricted = false, restrictedAuthor = null, limit = 20, offset = 0, order = 'recent' } = {}) {const where = ["p.state = 'published'"];const args = [];if (restricted) where.push("p.visibility IN ('public','members','private')");@@@ -239,6 +245,8 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,where.push("(p.visibility = 'public' OR (p.visibility IN ('members','private') AND p.author_subject = ?))");args.push(restrictedAuthor);} else where.push("p.visibility = 'public'");++ if (service) { where.push('EXISTS (SELECT 1 FROM blog_devblog_slots ds WHERE ds.post_id = p.id AND ds.service = ?)'); args.push(service); }++ if (categorySlug) { where.push("EXISTS (SELECT 1 FROM blog_term_links l JOIN blog_terms t ON t.id = l.term_id WHERE l.entity_id = p.id AND t.slug = ? AND t.vocabulary <> 'tag')"); args.push(categorySlug); }if (blogId) { where.push('p.blog_id = ?'); args.push(blogId); }if (seriesId) { where.push('p.series_id = ?'); args.push(seriesId); }if (authorSubject) { where.push('p.author_subject = ?'); args.push(authorSubject); }@@@ -390,6 +398,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,const rev = revisionNumber ? await store.revisions.get(post.id, revisionNumber) : await store.revisions.head(post.id);if (!rev) throw new ApiError(404, 'revision.not_found', `No revision ${revisionNumber} of this post`);if (post.state === 'published' && post.published_revision === rev.number) return { changed: false, post };++ await devblogReview(post, rev);const ok = canPublish(publication.authorshipOf(rev) || { mode: 'human' }, await publication.reviewOf(post, rev));if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} is AI-generated and needs a person's or the AI editor's approval before it can be published (${ok.reason})`);if (publishGate) {@@@ -467,6 +476,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,const rev = action === 'publish' ? (revision ? await store.revisions.get(post.id, parseInt(revision, 10)) : await store.revisions.head(post.id)) : null;if (action === 'publish') {if (!rev) throw new ApiError(404, 'revision.not_found', 'No such revision');++ await devblogReview(post, rev);const ok = canPublish(publication.authorshipOf(rev) || { mode: 'human' }, await publication.reviewOf(post, rev));if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} needs a person's or the AI editor's approval before it can be scheduled (${ok.reason})`);}@@@ -60,10 +60,13 @@ function createReading({ store, blogs, posts: postsApi, publication, people, medconst { tags, categories } = await termsView(post, blog, { networkTags: !perBlogLinks });// The hero art of a devblog is its card in a list: the block names a Media id, and the card// shows the picture when Media can deliver it.-- const hero = (blocks.parse(rev.content).children || []).find((c) => c.type === 'hero');-- const artId = hero && hero.attrs && typeof hero.attrs.art === 'string' ? hero.attrs.art : null;++ const tree = blocks.parse(rev.content);++ const hero = (tree.children || []).find((c) => c.type === 'hero');++ let figure = null;++ blocks.walk(tree, node => { if (!figure && node.type === 'figure') figure = node; });++ const artId = figure?.attrs?.src || (hero && hero.attrs && typeof hero.attrs.art === 'string' ? hero.attrs.art : null);return {-- post, rev, blog,++ post, rev: { ...rev, fields: { ...rev.fields, summary: rev.fields.summary || (hero ? blocks.toText(hero).replace(/^.*?\n/, '').trim().slice(0, 500) : null) } }, blog,url: publication.postPath(blog, post),author: person ? { name: person.name, url: urls.author(person, blog) } : null,tags, categories,@@@ -200,6 +200,15 @@ function createApi(ctx) {return { total, posts: await Promise.all(rows.map(async (p) => await postDto(p))) };}));++ router.get('/devblogs', run(async (req, res) => {++ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);++ const offset = Math.min(Math.max(parseInt(req.query.offset, 10) || 0, 0), 100000);++ const result = await posts.listPublished({ categorySlug: 'devblog', service: req.query.service ? String(req.query.service) : null, limit, offset });++ const items = await reading.listItems(result.posts);++ res.set('Access-Control-Allow-Origin', '*');++ return { total: result.total, posts: items.map(i => ({ id: i.post.id, title: i.rev.fields.title, summary: i.rev.fields.summary, figure: i.art, url: publication.abs(i.url), blog: i.blog.handle })), next: offset + limit < result.total ? offset + limit : null };++ }));++// The network changelog (server/changelog.js): what shipped on every OpenVibe site, newest first, and// the latest patch notes post. Public: every site's "recently shipped" reads it.router.get('/changelog', run(async (req, res) => {@@@ -220,6 +229,13 @@ function createApi(ctx) {};}));++ router.post('/blogs/:handle/posts/devblog', guard('blog.post.create'), B('blog.post.ai_draft'), jsonBody, run(async (req) => {++ const blog = await mustBlog(req);++ const result = await ctx.devblog.draft(req.viewer, blog, { service: String(req.body.service || ''), retry: true }, tp(req));++ await after(null, result.post.id, req);++ return { post: await postDto(result.post, { full: true }), created: result.created };++ }, 201));++// Draft with AI: OpenVibe.AI's blog.draft_post writes a draft (AI-authored, noindex until reviewed).router.post('/blogs/:handle/posts/ai-draft', guard('blog.post.create'), B('blog.post.ai_draft'), jsonBody, run(async (req) => {const blog = await mustBlog(req);@@@ -58,6 +58,7 @@ function createEditorRoutes(ctx) {// form shares its budget with the API route that does the same thing.const B = (name) => limits.budget(name);router.post('/start', B('blog.blog.create'));++ router.post('/@:handle/devblog', B('blog.post.ai_draft'));router.post('/@:handle/ai-draft', B('blog.post.ai_draft'));router.post('/@:handle/new', B('blog.post.create'));router.post('/@:handle/settings', B('blog.blog.configure'));@@@ -146,6 +147,13 @@ function createEditorRoutes(ctx) {}));// Draft with AI: the draft is AI-authored and stays a noindex draft until someone reviews it.++ router.post('/@:handle/devblog', wrap(async (req, res) => {++ const blog = await blogFor(req); if (!blog) return notFound(req, res);++ const { post } = await ctx.devblog.draft(req.viewer, blog, { service: String(req.body.service || ''), retry: true }, { traceparent: req.ov && req.ov.traceparent });++ await effects.after(null, post, req.ov);++ res.redirect(303, `/write/posts/${post.id}?saved=Devblog%20draft%20ready%20for%20review`);++ }));++router.post('/@:handle/ai-draft', wrap(async (req, res) => {const blog = await blogFor(req);if (!blog || !ctx.aiDrafts || !ctx.aiDrafts.enabled) return notFound(req, res);@@@ -125,6 +125,17 @@ route('get', '/changelog', {latest_post: nullable('object'), posts: arrayOf({ type: 'object' }), sites: arrayOf({ type: 'object' }),} }), ...errors([400, 429]) },});++route('get', '/devblogs', {++ operationId: 'listDevblogs', summary: 'Published devblogs across the network or for one site', tags: ['posts'],++ parameters: [{ name: 'service', in: 'query', schema: str() }, { name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 100 } }, { name: 'offset', in: 'query', schema: { type: 'integer', minimum: 0, maximum: 100000 } }],++ responses: { 200: json('Public summaries and the blocks’ first figure', { type: 'object' }), ...errors() },++});++route('post', '/blogs/{handle}/posts/devblog', {++ operationId: 'draftDevblog', summary: 'Draft a devblog from a site’s merged changes', tags: ['posts'], security: PERSON, parameters: [HANDLE],++ description: 'Only the owner of the site’s configured blog. At most one draft per site per week and seven-day cadence; meaningful changes only. Runs content.devblog using AI task blog.devblog.write. Never publishes. An existing weekly draft is returned unchanged.',++ requestBody: body({ type: 'object', required: ['service'], properties: { service: str({ pattern: '^[a-z][a-z0-9-]{0,63}$' }) } }),++ responses: { 201: json('The draft', { type: 'object', properties: { post: ref('Post'), created: { type: 'boolean' } } }), 409: problem, 422: problem, 503: problem, ...errors() },++});route('post', '/blogs/{handle}/posts/ai-draft', {operationId: 'draftPostWithAi', summary: 'Have OpenVibe.AI write a draft', tags: ['posts'], security: PERSON, parameters: [HANDLE],description: 'blog.post.create. The draft is AI-authored and cannot publish until a person reviews it; 503 when AI drafting is not configured here.',@@@ -103,16 +103,17 @@ function createPublicRoutes(ctx) {* are categories (Devblog, Daily, Community, Guides); a tab whose category does not exist yet is* empty, not hidden — every tab is a real, crawlable link.*/-- async function indexData(req, blog, base) {++ async function indexData(req, blog, base, { network = false, service = null } = {}) {const page = pageNumber(req);-- const scope = await access.listingScope(store, req.viewer, blog);++ const scope = network ? { restricted: false } : await access.listingScope(store, req.viewer, blog);const wanted = String(req.query.tab || 'latest').toLowerCase();const tab = homePage.INDEX_TABS.find((t) => t.id === wanted) || homePage.INDEX_TABS[0];const q = String(req.query.q || '').replace(/\s+/g, ' ').trim().slice(0, 80);const term = tab.slug ? await store.taxonomy.bySlug(blogs.categoryVocabulary(blog), tab.slug) : null;// A tab names a category. Until that category has a post there is nothing to list: falling back to// the unfiltered index would show every post under a tab that promises one kind.-- const tabEmpty = Boolean(tab.slug && !term);++ const tabEmpty = !network && Boolean(tab.slug && !term);++ const filters = network ? { blogId: null, categorySlug: tab.slug || 'devblog', service } : { blogId: blog.id, ...(term ? { termIds: [term.id] } : {}) };let rows;let total;if (tabEmpty) {@@@ -121,14 +122,14 @@ function createPublicRoutes(ctx) {} else if (q) {// A name filter is over the titles people see: a bounded read, filtered in place. The index is// small by design (a blog's published posts), and the unfiltered listing keeps its SQL pager.-- const all = await posts.listPublished({ blogId: blog.id, ...scope, ...(term ? { termIds: [term.id] } : {}), limit: 500 });++ const all = await posts.listPublished({ ...filters, ...scope, limit: 500 });const revs = await store.revisions.getMany(all.posts.map((p) => ({ entityId: p.id, revision: p.published_revision })));const needle = q.toLowerCase();const matched = all.posts.filter((p, i) => revs[i] && String((revs[i].fields || {}).title || '').toLowerCase().includes(needle));total = matched.length;rows = matched.slice((page - 1) * PER_PAGE, page * PER_PAGE);} else {-- ({ total, posts: rows } = await posts.listPublished({ blogId: blog.id, ...scope, ...(term ? { termIds: [term.id] } : {}), limit: PER_PAGE, offset: (page - 1) * PER_PAGE }));++ ({ total, posts: rows } = await posts.listPublished({ ...filters, ...scope, limit: PER_PAGE, offset: (page - 1) * PER_PAGE }));}const pager = ssr.paginate({page, perPage: PER_PAGE, total,@@@ -226,11 +227,12 @@ function createPublicRoutes(ctx) {// The devblog index on its own address: the same newest-first featured post, tabs, filter and art// cards the official blog's home shows, with nothing else. Every tab and every page is a real link.-- router.get('/devblog', wrap(async (req, res) => {++ router.get(['/devblog', '/devblog/:service'], wrap(async (req, res) => {const blog = await blogs.official();-- const { tab, q, items, pager } = await indexData(req, blog, '/devblog');++ const base = req.params.service ? `/devblog/${encodeURIComponent(req.params.service)}` : '/devblog';++ const { tab, q, items, pager } = await indexData(req, blog, base, { network: true, service: req.params.service || null });if (pager.outOfRange && pager.total) return notFound(req, res);-- const canonical = seo.canonicalUrl(config.baseUrl, '/devblog', { query: ['page', 'tab', 'q'] });++ const canonical = seo.canonicalUrl(config.baseUrl, base, { query: ['page', 'tab', 'q'] });const blogLd = { '@context': 'https://schema.org', '@type': 'Blog', '@id': `${publication.abs('/devblog')}#blog`, name: 'OpenVibe Devblog', url: publication.abs('/devblog'), publisher: { '@type': 'Organization', name: 'OpenVibe', url: 'https://openvibe.network' } };return send(req, res, 200, {title: 'Devblog',@@@ -240,7 +242,7 @@ function createPublicRoutes(ctx) {prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,jsonLd: [blogLd, ...homePage.homeJsonLd({ base: config.baseUrl.replace(/\/+$/, ''), items })],styles: [showcase.STYLESHEET], bodyClass: 'blog-devbog',-- body: `<div class="bh-devbog"><header class="bh-devbog-head"><h1>OpenVibe Devblog</h1><p class="lede">Named updates from the team: what changed, why we did it, and what it means for you.</p></header>${homePage.indexList({ base: '/devblog', tab: tab.id, q, items, pager })}</div>`,++ body: `<div class="bh-devbog"><header class="bh-devbog-head"><h1>OpenVibe Devblog</h1><p class="lede">Named updates from the team: what changed, why we did it, and what it means for you.</p></header>${homePage.indexList({ base, tab: tab.id, q, items, pager })}</div>`,}, { cacheable: true });}));@@@ -420,7 +422,8 @@ function createPublicRoutes(ctx) {*/async function patchNotesFor(blog, post, from, to) {if (!changelog || !changelog.entries) return [];-- const rows = await changelog.entries({ limit: 200 }).catch(() => []);++ const slot = await store.db.prepare('SELECT service FROM blog_devblog_slots WHERE post_id = ?').get(post.id);++ const rows = await store.db.prepare(`SELECT * FROM changelog_entries WHERE deployed_at >= ? AND deployed_at < ? ${slot ? 'AND service = ?' : ''} ORDER BY deployed_at, id`).all(from || '1970-01-01', to ? new Date(Date.parse(to) + 86400000).toISOString().slice(0, 10) : '9999-01-01', ...(slot ? [slot.service] : []));const lo = from ? Date.parse(String(from) + 'T00:00:00Z') : null;const hi = to ? Date.parse(String(to) + 'T23:59:59Z') : null;const groups = new Map();@@@ -430,7 +433,7 @@ function createPublicRoutes(ctx) {if (lo !== null && at < lo) continue;if (hi !== null && at > hi) continue;if (!groups.has(e.service)) groups.set(e.service, []);-- groups.get(e.service).push({ sha: e.short, url: e.url, title: e.subject });++ groups.get(e.service).push({ sha: e.sha.slice(0, 7), url: `https://github.com/${e.repo}/commit/${e.sha}`, title: e.subject });}return [...groups.entries()].map(([site, commits]) => ({ site, commits }));}@@@ -58,6 +58,11 @@ function aiDraftForm({ blog, csrf }) {<label>Tone <input name="tone" maxlength="200" placeholder="e.g. friendly, plain (optional)"></label><label>Audience <input name="audience" maxlength="300" placeholder="Who is it for? (optional)"></label><button type="submit">Write a draft</button>++</form>++<form method="post" action="/write/@${blog.handle}/devblog">${csrfField(csrf)}++<label>Site <input name="service" required maxlength="64" placeholder="e.g. live"></label>++<p class="muted">Site owners can draft a weekly devblog from meaningful merged changes. A person reviews and publishes it.</p>++<button type="submit">Draft a devblog</button></form></details>`;}@@@ -101,7 +101,7 @@ const MED = 'med_01J8ZQ4X3M000000000000000A';// Page 2 is cards alone: the first post of the page is not featured away, so loading the next// page in place never misses a post.assert.doesNotMatch(second.text, /class="bh-feature"/);-- assert.strictEqual((second.text.match(/<li class="bh-card bh-artcard">/g) || []).length, 5);++ assert.strictEqual((second.text.match(/<li class="bh-card bh-artcard">/g) || []).length, 2);});await check('the index keeps WebSite and ItemList structured data', async () => {nnew file mode 100644@@@ -0,0 +1,107 @@++'use strict';++const assert = require('assert');++const http = require('http');++const { boot, check, done, makeClock } = require('./helpers/boot');++const { meaningful, candidates, weekOf, WEEK } = require('../server/devblog');++const { main } = require('../scripts/devblog-backfill');++const MED = 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const RUN = 'run_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const long = 'We added search to the channel page so readers can find a channel from the page. '.repeat(12);++(async () => {++ const requests = []; let fail = false, release, hold = false;++ const server = http.createServer(async (req, res) => {++ let raw = ''; for await (const c of req) raw += c;++ const send = body => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };++ if (req.method === 'GET') return send({ citations: [] });++ const body = JSON.parse(raw); requests.push(body);++ if (hold) await new Promise(r => { release = r; });++ if (fail) return send({ run: { id: RUN, status: 'failed', error: { code: 'devblog.check_failed' } } });++ send({ run: { id: RUN, status: 'succeeded', workflow: { key: 'content.devblog', version: 1 }, provenance: { model: 'local-test-writer' }, synthetic: false,++ output: { title: 'Search Party', summary: 'Channel search is ready.', checks: [], blocks: { type: 'root', children: [++ { type: 'hero', attrs: { kicker: 'Devblog' }, children: [{ type: 'text', value: '# Search Party\n\nChannel search is ready.' }] },++ { type: 'section', attrs: { id: 'channel-search' }, children: [{ type: 'text', value: `## Channel search\n\n${long}` }, { type: 'figure', attrs: { src: MED, alt: 'Channel search', caption: 'The search field' }, children: [] }] },++ { type: 'patchnotes', attrs: { from: body.input.from, to: body.input.to }, children: [] },++ ] } } } });++ });++ await new Promise(r => server.listen(0, '127.0.0.1', r));++ const clock = makeClock();++ const t = await boot({ clock, limitsNow: clock.now, env: { OV_AI_INTERNAL_URL: `http://127.0.0.1:${server.address().port}` } });++ const db = t.ctx.store.db; const blog = await t.ctx.blogs.official();++ t.media.objects.set(MED, 'ready');++ t.ctx.media.listShots = async () => [{ id: MED, metadata: { service: 'live', path: '/channels', viewport: '390', release: 'r1' } }];++ async function seed(service, at, subject = 'Add channel search', sha = 'abc') {++ await db.prepare(`INSERT INTO changelog_entries (service, repo, sha, subject, deployed_at, committed_at, release, release_lines, major) VALUES (?, ?, ?, ?, ?, ?, 'r1', 100, 0)`).run(service, `OpenVibers/OpenVibe.${service}`, sha, subject, at, at);++ }++ let post;++ await check('noise-only weeks never call AI; dry-run weeks are Monday based', async () => {++ assert.equal(weekOf('2026-11-08'), '2026-11-02');++ for (const subject of ['test: search page', 'docs: editor guide', 'openvibe-sdk v1.0.0', 'Pin search dependency']) assert.equal(meaningful({ subject }), false);++ assert.equal(meaningful({ subject: 'feat: Add video chapters' }), true);++ assert.equal(meaningful({ subject: 'fix: Restore stream playback' }), true);++ assert.equal(meaningful({ subject: 'Fix search', files: '["test/search.test.js"]' }), false);++ assert.deepEqual(candidates([{ service: 'live', deployed_at: '2026-11-03', subject: 'docs: search' }], { from: '2026-11-01', to: '2026-11-09' }), []);++ await seed('docs', new Date(t.clock.now()).toISOString(), 'docs: search');++ const r = await t.get('/api/v1/blogs/openvibe/posts/devblog', { as: t.official, json: { service: 'docs' } });++ assert.equal(r.status, 422, r.text); assert.equal(requests.length, 0);++ });++ await check('owner authorization, concurrent reservation, task request and attached blocks', async () => {++ await seed('live', new Date(t.clock.now()).toISOString());++ const stranger = t.network.addUser('stranger');++ assert.equal((await t.get('/api/v1/blogs/openvibe/posts/devblog', { as: stranger, json: { service: 'live' } })).status, 403);++ hold = true;++ const first = t.get('/api/v1/blogs/openvibe/posts/devblog', { as: t.official, json: { service: 'live' } });++ while (!release) await new Promise(r => setTimeout(r, 10));++ const second = await t.get('/api/v1/blogs/openvibe/posts/devblog', { as: t.official, json: { service: 'live' } });++ assert.equal(second.status, 409, second.text); release(); hold = false;++ const r = await first; assert.equal(r.status, 201, r.text); post = r.json().post;++ assert.equal(post.state, 'draft'); assert.equal(post.authorship.mode, 'ai');++ assert.equal(post.authorship.workflow.id, 'content.devblog');++ assert.equal(requests[0].task, 'blog.devblog.write'); assert.equal(requests[0].input.screenshots[0].media_id, MED);++ assert.equal((await t.ctx.posts.attachments(post))[0].mediaId, MED);++ const repeat = await t.get('/api/v1/blogs/openvibe/posts/devblog', { as: t.official, json: { service: 'live' } });++ assert.equal(repeat.json().post.id, post.id); assert.equal(requests.length, 1);++ assert.equal((await db.prepare('SELECT post_id FROM changelog_entries WHERE service = ?').get('live')).post_id, null, 'daily Patch notes still owns entry assignment');++ });++ await check('a person must approve; AI editor approval cannot publish or schedule it', async () => {++ assert.equal((await t.get(`/api/v1/posts/${post.id}/publish`, { as: t.official, json: {} })).status, 409);++ await t.ctx.store.reviews.recordAiEditor({ entityId: post.id, revision: 1, principal: 'svc:ai-editor', decision: 'approved' });++ await assert.rejects(() => t.ctx.posts.applyPublish(post.id, 1, { kind: 'service', service: 'svc:ai-editor' }), e => e.code === 'devblog.person_review_required');++ const r = await t.get(`/api/v1/posts/${post.id}/reviews`, { as: t.official, json: { revision: 1, decision: 'approved' } });++ assert.equal(r.status, 201, r.text);++ const published = await t.get(`/api/v1/posts/${post.id}/publish`, { as: t.official, json: {} }); assert.equal(published.status, 200, published.text);++ const page = await t.get('/devblog/live'); assert.match(page.text, /Search Party/); assert.match(page.text, /Channel search is ready/); assert(page.text.includes(MED));++ assert.doesNotMatch((await t.get('/devblog/blog')).text, /Search Party/);++ assert.match((await t.get('/devblog')).text, /Search Party/);++ const api = await t.get('/api/v1/devblogs?service=live');++ assert.equal(api.status, 200, api.text); assert.equal(api.json().posts[0].figure.mediaId, MED);++ assert.equal(api.json().posts[0].summary, 'Channel search is ready.');++ assert.equal((await t.get('/.well-known/agent.json')).json().skills[0].task, 'blog.devblog.write');++ const skill = (await t.get('/skills/devblog.json')).json();++ assert.equal(skill.skill, 'actor.skill@1');++ const { integrity, ...fields } = skill;++ assert.equal(integrity.hash, require('crypto').createHash('sha256').update(JSON.stringify(fields)).digest('hex'));++++ });++ await check('seven-day guard survives a new week; failed runs leave no post and require an owner retry', async () => {++ t.clock.advance(6 * 86400000);++ await seed('live', new Date(t.clock.now()).toISOString(), 'Add search menu', 'def');++ assert.equal((await t.get('/api/v1/blogs/openvibe/posts/devblog', { as: t.official, json: { service: 'live' } })).status, 409);++ t.clock.advance(86400000 + 1); fail = true;++ const before = (await db.prepare('SELECT COUNT(*) AS n FROM blog_posts').get()).n;++ const failed = await t.get('/api/v1/blogs/openvibe/posts/devblog', { as: t.official, json: { service: 'live' } }); assert.equal(failed.status, 502, failed.text);++ assert.equal((await db.prepare('SELECT COUNT(*) AS n FROM blog_posts').get()).n, before);++ await assert.rejects(() => t.ctx.devblog.draft({ kind: 'user', subject: t.official.subject }, blog, { service: 'live' }), e => e.code === 'devblog.failed');++ fail = false;++ const retry = await t.get('/api/v1/blogs/openvibe/posts/devblog', { as: t.official, json: { service: 'live' } }); assert.equal(retry.status, 201, retry.text);++ });++ await check('snapshot backfill is dry-run only and does not call AI', async () => {++ const fs = require('fs'), path = require('path'), os = require('os');++ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'devblog-plan-')), 'entries.json');++ fs.writeFileSync(file, JSON.stringify([{ service: 'live', subject: 'Add search menu', deployed_at: '2026-11-03' }]));++ const before = requests.length;++ const plan = await main(['--input', file, '--from', '2026-11-02', '--to', '2026-11-09']); assert.equal(plan.length, 1); assert.equal(requests.length, before);++ await assert.rejects(() => main(['--input', file, '--from', '2026-11-02', '--to', '2026-11-09', '--write']));++ fs.rmSync(path.dirname(file), { recursive: true });++ });++ await t.close(); await new Promise(r => server.close(r)); done();++})();@@@ -64,6 +64,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');'server/changelog.js': 'Network registry (configured) and api.github.com (fixed host) for the repositories the registry names','server/clients/community.js': 'OpenVibe.Community (configured)','server/desks/desks.js': 'OpenVibe.Food\'s recipe library feed (OV_FOOD_URL, configured; the budget-recipes desk)',++ 'server/domain/ai-drafts.js': 'Publishing AI client calls only configured OV_AI_INTERNAL_URL; the devblog adapter adds a task field, never a caller URL','server/clients/media.js': 'OpenVibe.Media (configured)','server/clients/network.js': 'OpenVibe.Network (configured)','server/sharing.js': 'OpenVibe.Network (configured) for identity lookups and the notification push; never a URL a user chose',
@@ -2,6 +2,8 @@
@@ -2,6 +2,8 @@
## Unreleased
## Unreleased
+- Weekly devblogs use the content.devblog AI agent and blog.devblog.write task: meaningful merged changes, stored screenshots, rich blocks, and a person-only review gate. Owner requests and the opt-in scheduler share durable weekly reservations. Per-site and network indexes use the first figure and summary; the back-catalogue script defaults to a read-only dry run.
+
- The screenshot pass, from the Blog's side (plan L9, devblog §3.3): when the patch notes are drafted, Blog reads the shots
- The screenshot pass, from the Blog's side (plan L9, devblog §3.3): when the patch notes are drafted, Blog reads the shots
OpenVibe.Host captured (the newest one taken before the release and the first one taken after it, per page and viewport).
OpenVibe.Host captured (the newest one taken before the release and the first one taken after it, per page and viewport).
When the two differ by more than 0.5 % of the pixels, both are attached to the draft (before, after) with the alt text and the
When the two differ by more than 0.5 % of the pixels, both are attached to the draft (before, after) with the alt text and the
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+# Weekly devblog drafts
+
+`POST /api/v1/blogs/{handle}/posts/devblog { "service": "live" }` and the editor’s **Draft a devblog** use the same writer. Only the owner of the configured destination blog may invoke it. The reusable `actor.skill@1` document is served at `/skills/devblog.json`; it describes the input and the draft-only result, and grants no permissions. Actor can load this document; importing it into Actor’s central catalogue is a separate registration action.
+
+Blog supplies existing `changelog_entries`, clustered stories, commit/PR/file evidence, its voice card, and Media’s stored Host screenshots. `content.devblog` runs through AI task `blog.devblog.write` (AI PR #397 supplies choices), honoring the caller’s saved task choice and owner defaults. It plans, writes, checks claims and, if needed, revises once and checks again. It makes at most five model calls. No paid image or search tool is invoked. With no screenshot, the existing typographic hero is used. The draft records AI workflow, run and model provenance; attached Media IDs make figures render through B1. A person must approve the revision before publish or schedule; an AI-editor approval is insufficient.
+
+`BLOG_DEVBLOG_ENABLED=on` enables weekly drafting from the existing changelog worker (also requires `BLOG_WORKER` and changelog collection enabled). It considers the previous complete Monday–Sunday UTC week. On-demand drafting considers the current week. Both use the same durable `(service, week_start)` reservation and a seven-day live cadence check, serialized by the destination blog row. An existing draft is returned unchanged. Failed scheduled attempts do not retry automatically; an owner’s explicit request can retry. A process interruption leaves a reserved slot for operator reconciliation: verify the AI run and any stored draft before clearing it. Daily Patch notes and their `post_id` assignments remain unchanged.
+
+By default sites use `CHANGELOG_BLOG` (the official blog). Set `BLOG_DEVBLOG_SITES` to a JSON object such as `{"my-site":"my-blog"}` to bind another site to its owner’s blog. This trusted deployment mapping prevents a member from claiming a different site. Only meaningful merged changes qualify: pins and test/docs-only changes do not. Weeks exceeding 200 commits require review before drafting; nothing is silently truncated into a partial weekly post.
+
+Public `/devblog` lists published devblogs across blogs; `/devblog/{service}` narrows to a site. `/api/v1/devblogs?service=live` returns the same public summaries and first figure, with offset pagination. Existing B1 classes/styles are retained. Private posts and drafts never enter these lists. Folded patch notes read the exact historical date window and the weekly slot’s site.
+
+## Back catalogue
+
+Run a dry run first, against a local database or an exported JSON array of changelog rows:
+
+```
+node scripts/devblog-backfill.js --from 2026-10-01 --to 2026-11-09 --dry-run
+node scripts/devblog-backfill.js --input /tmp/changelog-entries.json --from 2026-10-01 --to 2026-11-09
+```
+
+Without `--input`, the dry run uses a read-only PostgreSQL transaction with a 10-second statement timeout. It does not boot/migrate/seed the app, fetch screenshots, call AI, or write posts. It prints each qualifying site/week and omits already reserved/drafted weeks. `--to` is exclusive. Windows are grouped into whole UTC weeks; an actual draft includes all entries in that week.
+
+Only after reviewing that list, `--write` drafts those weeks from the local database. Historical backfill bypasses the rolling seven-day guard but never the unique weekly reservation or human publication gate. `NODE_ENV=production`, snapshot input with writes, and combining `--dry-run` with `--write` are refused. Do not run the writer against production for this rollout. No legacy post is redirected or replaced while its weekly replacement is a draft.
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
+-- phase: expand
+-- Separate weekly reservations: the daily Patch notes retain ownership of changelog entries.
+CREATE TABLE IF NOT EXISTS blog_devblog_slots (
+ service text NOT NULL,
+ week_start text NOT NULL,
+ blog_id text NOT NULL REFERENCES blogs(id),
+ status text NOT NULL CHECK (status IN ('reserved', 'drafted', 'failed')),
+ attempt integer NOT NULL DEFAULT 1,
+ post_id text REFERENCES blog_posts(id),
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL,
+ PRIMARY KEY (service, week_start)
+);
+CREATE INDEX IF NOT EXISTS blog_devblog_slots_blog ON blog_devblog_slots(blog_id, created_at);
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+{
+ "skill": "actor.skill@1",
+ "slug": "blog-devblog",
+ "name": "Draft my project’s devblog",
+ "description": "Use when a site owner wants a weekly devblog from merged changes, with evidence, screenshots and human publication review.",
+ "version": "1.0.0",
+ "instructions": "Ask for the site service id and its configured blog handle. Use POST https://openvibe.blog/api/v1/blogs/{handle}/posts/devblog with {service}. The authenticated owner is the actor; never impersonate another person. Blog gathers changelog stories and stored screenshots, then runs the content.devblog workflow using the blog.devblog.write AI choices task. It returns a draft post. Show the owner its editor URL /write/posts/{id} and the run provenance. Never publish, approve, schedule, buy artwork, or change a saved model choice. A 409 means a weekly reservation, cadence limit or pending attempt; show that result rather than circumventing it. A failed run is not a draft. The same action serves every configured site, including the network’s own sites.",
+ "resources": [
+ {
+ "path": "input.json",
+ "media_type": "application/json",
+ "text": "{\"type\":\"object\",\"required\":[\"handle\",\"service\"],\"properties\":{\"handle\":{\"type\":\"string\",\"title\":\"Blog handle\"},\"service\":{\"type\":\"string\",\"title\":\"Site service id\"}}}"
+ }
+ ],
+ "requires": {
+ "kinds": [
+ "post"
+ ],
+ "hosts": [
+ "openvibe.blog",
+ "ai.openvibe.services"
+ ]
+ },
+ "runs": "prompt",
+ "license": "AGPL-3.0-only",
+ "integrity": {
+ "alg": "sha256",
+ "hash": "8cf9086570242b1a577fceea14553f74d767683d8d1f82be544fca22fb94594d"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+#!/usr/bin/env node
+'use strict';
+// Dry run never boots the app (boot migrates/seeds) and never calls an AI provider.
+const fs = require('fs');
+const { Client } = require('pg');
+const { candidates, weekOf, WEEK } = require('../server/devblog');
+async function main(args = process.argv.slice(2)) {
+ const opt = name => { const i = args.indexOf(`--${name}`); return i < 0 ? null : args[i + 1]; };
+ const to = opt('to') || weekOf(Date.now());
+ const from = opt('from') || new Date(Date.parse(to) - 6 * WEEK).toISOString().slice(0, 10);
+ if (![from, to].every(v => /^\d{4}-\d{2}-\d{2}$/.test(v) && Number.isFinite(Date.parse(v))) || from >= to) throw new Error('Use --from YYYY-MM-DD --to YYYY-MM-DD (exclusive)');
+ let entries, slots = [];
+ if (opt('input')) entries = JSON.parse(fs.readFileSync(opt('input'), 'utf8'));
+ else {
+ const client = new Client({ connectionString: process.env.DATABASE_URL });
+ await client.connect();
+ try {
+ await client.query('BEGIN READ ONLY');
+ await client.query("SET LOCAL statement_timeout = '10s'");
+ entries = (await client.query('SELECT * FROM changelog_entries WHERE deployed_at >= $1 AND deployed_at < $2 ORDER BY deployed_at, id', [from, to])).rows;
+ // Before the B2 migration, there are no weekly slots yet.
+ if ((await client.query("SELECT to_regclass('blog_devblog_slots') AS name")).rows[0].name) slots = (await client.query('SELECT service, week_start, status FROM blog_devblog_slots')).rows;
+ await client.query('ROLLBACK');
+ } finally { await client.end(); }
+ }
+ const plan = candidates(entries, { from, to }).filter(c => !slots.some(s => s.service === c.service && s.week_start === c.week && s.status !== 'failed'));
+ for (const c of plan) console.log(`DRAFT ${c.service} week ${c.week}: ${c.entries.length} merged changes`);
+ console.log(`${plan.length} draft(s); ${args.includes('--write') ? 'write requested' : 'dry run, no AI calls or writes'}`);
+ if (!args.includes('--write')) return plan;
+ if (args.includes('--dry-run')) throw new Error('Choose --dry-run or --write, not both');
+ if (process.env.NODE_ENV === 'production') throw new Error('Back-catalogue writes are disabled in production');
+ if (opt('input')) throw new Error('--input is a dry-run snapshot only; writes read the local database');
+ const { ctx } = await require('../server/app').createApp();
+ try {
+ for (const c of plan) {
+ const blog = await ctx.devblog.blogFor(c.service);
+ if (!blog) throw new Error(`No configured blog for ${c.service}`);
+ const owner = await ctx.store.db.prepare("SELECT subject FROM blog_memberships WHERE blog_id = ? AND role = 'owner' ORDER BY created_at LIMIT 1").get(blog.id);
+ if (!owner) throw new Error(`No owner for ${c.service}`);
+ const out = await ctx.devblog.draft({ kind: 'user', subject: owner.subject }, blog, { service: c.service, week: c.week, backfill: true, retry: true });
+ console.log(`Saved draft ${out.post.id}`);
+ }
+ } finally { ctx.worker.stop(); await ctx.outbox.stop(); await ctx.store.close(); }
+ return plan;
+}
+if (require.main === module) main().catch(() => { console.error('Devblog backfill failed; no post was published. Check the input, configuration and draft queue.'); process.exitCode = 1; });
+module.exports = { main };
@@ -104,11 +104,13 @@ async function createApp(opts = {}) {
@@ -104,11 +104,13 @@ async function createApp(opts = {}) {
// through it; posts.publish refuses a draft with a blocking check or no person's approval.
// through it; posts.publish refuses a draft with a blocking check or no person's approval.
const patchNotes = opts.patchNotes || require('./patch-notes').createPatchNotes({ config, store, blogs, posts, publication, aiDrafts, media, fetchImpl, log });
const patchNotes = opts.patchNotes || require('./patch-notes').createPatchNotes({ config, store, blogs, posts, publication, aiDrafts, media, fetchImpl, log });
const changelog = opts.changelog || require('./changelog').createChangelog({ config, store, blogs, posts, patchNotes, fetchImpl, log });
const changelog = opts.changelog || require('./changelog').createChangelog({ config, store, blogs, posts, patchNotes, fetchImpl, log });
+ const devblog = require('./devblog').createDevblog({ config, store, blogs, posts, access, aiDrafts, media, log });
+ if (changelog.attachDevblog) changelog.attachDevblog(devblog);
if (patchNotes.attach) patchNotes.attach({ changelog });
if (patchNotes.attach) patchNotes.attach({ changelog });
posts.setPublishGate(patchNotes.gate);
posts.setPublishGate(patchNotes.gate);
// The AI desks (server/desks/): digests from Sources' items and the satire desk, beside the blogs.
// The AI desks (server/desks/): digests from Sources' items and the satire desk, beside the blogs.
const desks = opts.desks || require('./desks/desks').createDesks({ config, store, fetchImpl, log });
const desks = opts.desks || require('./desks/desks').createDesks({ config, store, fetchImpl, log });
- const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, vip, auth, viewers, access, worker, aiDrafts, changelog, patchNotes, desks, indexnow, sharing };
+ const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, vip, auth, viewers, access, worker, aiDrafts, changelog, patchNotes, devblog, desks, indexnow, sharing };
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -258,6 +260,10 @@ async function createApp(opts = {}) {
@@ -258,6 +260,10 @@ async function createApp(opts = {}) {
// page its own route renders; the twins are rendered signed out through the same public router.
// page its own route renders; the twins are rendered signed out through the same public router.
{
{
const agentRouter = express.Router();
const agentRouter = express.Router();
+ agentRouter.get('/.well-known/agent.json', (_req, res) => res.json({
+ ...require('openvibe-shared/agent-layer').agentManifest(require('./agent-descriptor')),
+ skills: [{ name: 'Draft my project’s devblog', url: `${config.baseUrl}/skills/devblog.json`, task: 'blog.devblog.write', workflow: 'content.devblog', result: 'draft' }],
+ }));
require('openvibe-shared/agent-layer').mount(agentRouter, {
require('openvibe-shared/agent-layer').mount(agentRouter, {
descriptor: require('./agent-descriptor'),
descriptor: require('./agent-descriptor'),
render: require('./agent-pages').createAgentPages({ router: publicRoutes.router, host: new URL(config.baseUrl).hostname }).renderPublicPage,
render: require('./agent-pages').createAgentPages({ router: publicRoutes.router, host: new URL(config.baseUrl).hostname }).renderPublicPage,
@@ -314,6 +314,7 @@ function createChangelog({ config, store, blogs, posts, patchNotes = null, fetch
@@ -314,6 +314,7 @@ function createChangelog({ config, store, blogs, posts, patchNotes = null, fetch
}
}
}
}
+ let devblog = null;
async function tick() {
async function tick() {
stats.runs++; stats.lastRunAt = new Date(nowMs()).toISOString();
stats.runs++; stats.lastRunAt = new Date(nowMs()).toISOString();
try {
try {
@@ -321,6 +322,7 @@ function createChangelog({ config, store, blogs, posts, patchNotes = null, fetch
@@ -321,6 +322,7 @@ function createChangelog({ config, store, blogs, posts, patchNotes = null, fetch
if (patchNotes && patchNotes.compact) {
if (patchNotes && patchNotes.compact) {
try { await patchNotes.compact(); } catch (err) { log.warn && log.warn(`[changelog] fold skipped: ${err.message}`); }
try { await patchNotes.compact(); } catch (err) { log.warn && log.warn(`[changelog] fold skipped: ${err.message}`); }
}
}
+ if (devblog) await devblog.tick();
const list = await q.pending.all();
const list = await q.pending.all();
const d = await due(list);
const d = await due(list);
if (d.due) return await fileDraft(list.slice(0, c.batchSize * 3), d.reason);
if (d.due) return await fileDraft(list.slice(0, c.batchSize * 3), d.reason);
@@ -365,7 +367,7 @@ function createChangelog({ config, store, blogs, posts, patchNotes = null, fetch
@@ -365,7 +367,7 @@ function createChangelog({ config, store, blogs, posts, patchNotes = null, fetch
/** Every site with entries: [{ service, entries, latest_at }], most recently shipped first. */
/** Every site with entries: [{ service, entries, latest_at }], most recently shipped first. */
async function sites() { return await q.sites.all(); }
async function sites() { return await q.sites.all(); }
- return { tick, collect, due, fileDraft, enrich, pendingEntries, markEntries, entriesOf, moveEntries, openPulls, start, stop, entries, page, latestPost, recentPosts, sites, importHistory, stats: async () => ({ enabled: c.enabled, ...stats, github_token: cachedToken.source, pending: (await q.pending.all()).length }) };
+ return { attachDevblog: value => { devblog = value; }, tick, collect, due, fileDraft, enrich, pendingEntries, markEntries, entriesOf, moveEntries, openPulls, start, stop, entries, page, latestPost, recentPosts, sites, importHistory, stats: async () => ({ enabled: c.enabled, ...stats, github_token: cachedToken.source, pending: (await q.pending.all()).length }) };
}
}
/** A stable id for a batch (tests). */
/** A stable id for a batch (tests). */
@@ -69,6 +69,7 @@ function load(env = process.env) {
@@ -69,6 +69,7 @@ function load(env = process.env) {
ai: env.CHANGELOG_AI_DRAFT !== 'off',
ai: env.CHANGELOG_AI_DRAFT !== 'off',
},
},
// OpenVibe.AI for "Draft with AI" (server/domain/ai-drafts.js); empty = the button is not offered.
// OpenVibe.AI for "Draft with AI" (server/domain/ai-drafts.js); empty = the button is not offered.
+ devblog: { enabled: env.BLOG_DEVBLOG_ENABLED === 'on', sites: JSON.parse(env.BLOG_DEVBLOG_SITES || '{}') },
aiUrl: trim(env.OV_AI_INTERNAL_URL || ''),
aiUrl: trim(env.OV_AI_INTERNAL_URL || ''),
// OpenVibe.Sources (sources.item.read): the items the AI desks write their digests from (server/desks/).
// OpenVibe.Sources (sources.item.read): the items the AI desks write their digests from (server/desks/).
sources: { internalUrl: trim(env.OV_SOURCES_INTERNAL_URL || ''), timeoutMs: int(env.BLOG_SOURCES_TIMEOUT_MS, 10000) },
sources: { internalUrl: trim(env.OV_SOURCES_INTERNAL_URL || ''), timeoutMs: int(env.BLOG_SOURCES_TIMEOUT_MS, 10000) },
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
+'use strict';
+const { build } = require('../patch-notes/stories');
+const { intentOf } = require('../patch-notes/text');
+const { createVoiceCards } = require('../patch-notes/voice');
+const { ApiError } = require('../http/errors');
+const blocks = require('openvibe-publishing/blocks');
+const WEEK = 7 * 86400000;
+const date = ms => new Date(ms).toISOString().slice(0, 10);
+function weekOf(value) {
+ const d = new Date(value); if (!Number.isFinite(+d)) throw new ApiError(422, 'devblog.date', 'Invalid week');
+ d.setUTCHours(0, 0, 0, 0); d.setUTCDate(d.getUTCDate() - (d.getUTCDay() + 6) % 7); return date(+d);
+}
+function meaningful(e) {
+ const subject = String(e.subject || '');
+ if (/^(?:test|docs?|ci|build|chore)(?:\([^)]*\))?[:\s]|^Merge\b|^openvibe-(?:shared|sdk|contracts|publishing)\s+v|^(?:pin|bump)\b|dependenc/i.test(subject)) return false;
+ let files = e.files; if (typeof files === 'string') { try { files = JSON.parse(files); } catch { files = []; } }
+ if (Array.isArray(files) && files.length && files.every(f => /(^|\/)(?:test|tests|docs|\.github)\/|\.(?:md|test\.js|spec\.js)$|(?:^|\/)package(?:-lock)?\.json$/.test(typeof f === 'string' ? f : f.filename || ''))) return false;
+ return /^(?:feat|fix)(?:\([^)]*\))?!?:\s/i.test(subject) || ['feature', 'fix'].includes(intentOf(e).kind);
+}
+function candidates(entries, { from, to }) {
+ const groups = new Map();
+ for (const e of entries) {
+ if (!e.service || !e.deployed_at || e.deployed_at < from || e.deployed_at >= to) continue;
+ const week = weekOf(e.deployed_at), key = `${e.service}:${week}`;
+ if (!groups.has(key)) groups.set(key, { service: e.service, week, entries: [] });
+ groups.get(key).entries.push(e);
+ }
+ return [...groups.values()].filter(g => g.entries.some(meaningful)).sort((a, b) => a.week.localeCompare(b.week) || a.service.localeCompare(b.service));
+}
+function createDevblog({ config, store, blogs, posts, access, aiDrafts, media, log = console }) {
+ const db = store.db, voice = createVoiceCards({ store });
+ async function blogFor(service) {
+ const handle = (config.devblog.sites || {})[service] || config.changelog.blogHandle;
+ return blogs.byHandle(handle);
+ }
+ async function authorize(viewer, blog, service) {
+ if (!viewer?.subject) throw new ApiError(401, 'subject.required', 'Sign in to draft a devblog');
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(service)) throw new ApiError(422, 'devblog.site', 'Choose a site');
+ const target = await blogFor(service);
+ if (!target || target.id !== blog.id || await access.effectiveRole(store, viewer, blog) !== 'owner') throw new ApiError(403, 'devblog.owner_required', 'Only the owner of the site’s configured blog may draft its devblog');
+ }
+ async function draft(viewer, blog, { service, week = weekOf(store.now()), backfill = false, retry = false } = {}, opts = {}) {
+ await authorize(viewer, blog, service);
+ if (!aiDrafts.enabled) throw new ApiError(503, 'ai.not_configured', 'The devblog writer is not configured');
+ const start = weekOf(week), end = date(Date.parse(start) + WEEK);
+ if (Date.parse(start) > store.now()) throw new ApiError(422, 'devblog.future', 'The week has not started');
+ const entries = await db.prepare('SELECT * FROM changelog_entries WHERE service = ? AND deployed_at >= ? AND deployed_at < ? ORDER BY deployed_at, id').all(service, start, end);
+ const useful = entries.filter(meaningful);
+ if (!useful.length) throw new ApiError(422, 'devblog.no_changes', 'No meaningful merged changes this week');
+ if (entries.length > 200) throw new ApiError(422, 'devblog.too_many_changes', 'Split or review this unusually large week before drafting');
+ const reservation = await store.tx(async () => {
+ // Serializes live cadence decisions across processes as well as weekly INSERT races.
+ await db.prepare('SELECT id FROM blogs WHERE id = ? FOR UPDATE').get(blog.id);
+ const slot = await db.prepare('SELECT * FROM blog_devblog_slots WHERE service = ? AND week_start = ?').get(service, start);
+ if (slot?.status === 'drafted') return { post: await posts.get(slot.post_id), created: false };
+ if (slot?.status === 'failed' && !retry) throw new ApiError(409, 'devblog.failed', 'The previous attempt failed; an owner can retry it');
+ if (slot?.status === 'reserved') throw new ApiError(409, 'devblog.running', 'This week’s draft is already being written');
+ if (!backfill) {
+ const recent = await db.prepare("SELECT 1 AS x FROM blog_devblog_slots WHERE service = ? AND status IN ('reserved', 'drafted') AND created_at > ?").get(service, store.now() - WEEK);
+ if (recent) throw new ApiError(409, 'devblog.cadence', 'This site already has a devblog draft in the last seven days');
+ }
+ await db.prepare(`INSERT INTO blog_devblog_slots (service, week_start, blog_id, status, created_at, updated_at) VALUES (?, ?, ?, 'reserved', ?, ?)
+ ON CONFLICT (service, week_start) DO UPDATE SET status = 'reserved', created_at = excluded.created_at, attempt = blog_devblog_slots.attempt + 1, updated_at = excluded.updated_at`).run(service, start, blog.id, store.now(), store.now());
+ return { attempt: (slot?.attempt || 0) + 1 };
+ });
+ if (reservation.post) return reservation;
+ try {
+ const sources = entries.map(e => ({ source_type: 'git.commit', source_id: e.sha, url: `https://github.com/${e.repo}/commit/${e.sha}`, title: e.subject, author: e.author || 'The OpenVibe team', content: JSON.stringify({ subject: e.subject, body: e.message_body || '', files: e.files || [], pull_request: e.pr_number || null }).slice(0, 10000) }));
+ const map = build(useful, { maxStories: 8 });
+ const grouped = new Set(map.stories.flatMap(s => s.entries.map(e => e.sha)));
+ const storyGroups = [...map.stories, ...useful.filter(e => !grouped.has(e.sha)).map(e => ({ title: e.subject, entries: [e] }))].slice(0, 8);
+ const stories = storyGroups.map(s => ({ title: s.title, sources: s.entries.map(e => entries.findIndex(x => x.sha === e.sha)), authors: [...new Set(s.entries.map(e => e.author || 'The OpenVibe team'))] }));
+ const shots = media?.listShots ? await media.listShots().catch(() => []) : [];
+ const screenshots = [];
+ for (const shot of shots) {
+ const m = shot.metadata || {};
+ const source = entries.findIndex(e => m.service === service && m.release && (m.release === e.release || m.release === e.sha));
+ if (source < 0 || !/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(shot.id)) continue;
+ screenshots.push({ media_id: shot.id, source, alt: String(m.alt || `${service}: ${m.path || '/'} at ${m.viewport || 'desktop'} width`).slice(0, 300), caption: String(m.caption || `${service} ${m.path || '/'} after this release`).slice(0, 500) });
+ if (screenshots.length === 24) break;
+ }
+ const result = await aiDrafts.devblog(viewer, blog, { site: service, from: start, to: date(Date.parse(end) - 1), sources, stories, screenshots, voice: JSON.stringify(await voice.get(blog.kind === 'official' ? 'official' : blog.id)).slice(0, 6000) }, { ...opts, idempotencyKey: `devblog:${service}:${start}:${reservation.attempt}` });
+ const out = result.output;
+ if (!out?.title || !out.summary || !out.blocks || !blocks.validate(out.blocks).ok || out.checks?.length) throw new ApiError(422, 'devblog.invalid', 'The writer returned an invalid draft');
+ const allowed = new Set(screenshots.map(s => s.media_id));
+ const figures = [];
+ const visit = n => { if (n.type === 'figure') { if (!allowed.has(n.attrs?.src)) throw new ApiError(422, 'devblog.media', 'The draft named an unknown screenshot'); figures.push(n); } for (const c of n.children || []) visit(c); };
+ visit(out.blocks);
+ // Disclosure does not claim a person has reviewed the new draft.
+ out.blocks.children.splice(1, 0, { type: 'text', value: `Written by our AI writer (${result.workflow.model || 'model recorded in the AI run'}) from the team’s commits.` });
+ return await store.tx(async () => {
+ const aiViewer = { kind: 'service', service: 'svc:ai', origin: 'ai', subject: viewer.subject };
+ const made = await posts.create(aiViewer, blog, { title: out.title, summary: out.summary, blocks: out.blocks, categories: ['Devblog'], tags: ['devblog', service], series: `${service} devblog`, authorship: { workflow: result.workflow, stubProvider: result.synthetic }, citations: sources.map(s => ({ url: s.url, title: s.title })), message: `Devblog draft, ${service}, week ${start}` }, opts);
+ for (const f of figures) await posts.attach(aiViewer, made.post, { mediaId: f.attrs.src, alt: f.attrs.alt, caption: f.attrs.caption });
+ await db.prepare("UPDATE blog_devblog_slots SET status = 'drafted', post_id = ?, updated_at = ? WHERE service = ? AND week_start = ?").run(made.post.id, store.now(), service, start);
+ return { ...made, created: true };
+ });
+ } catch (err) {
+ await db.prepare("UPDATE blog_devblog_slots SET status = 'failed', updated_at = ? WHERE service = ? AND week_start = ? AND status = 'reserved'").run(store.now(), service, start);
+ throw err;
+ }
+ }
+ async function tick() {
+ if (!config.devblog.enabled || !aiDrafts.enabled) return;
+ const week = date(Date.parse(weekOf(store.now())) - WEEK);
+ const sites = await db.prepare('SELECT DISTINCT service FROM changelog_entries WHERE deployed_at >= ?').all(week);
+ for (const { service } of sites) {
+ const blog = await blogFor(service); if (!blog) continue;
+ const owner = await db.prepare("SELECT subject FROM blog_memberships WHERE blog_id = ? AND role = 'owner' ORDER BY created_at LIMIT 1").get(blog.id);
+ if (!owner) continue;
+ try { await draft({ kind: 'user', subject: owner.subject }, blog, { service, week }); }
+ catch (err) { if (![409, 422].includes(err.status)) log.warn(`[devblog] ${service}: ${err.code || 'draft failed'}`); }
+ }
+ }
+ return { draft, tick, blogFor };
+}
+module.exports = { createDevblog, candidates, meaningful, weekOf, WEEK };
@@ -121,7 +121,18 @@ function createAiDrafts({ config, store, posts, access, fetchImpl = globalThis.f
@@ -121,7 +121,18 @@ function createAiDrafts({ config, store, posts, access, fetchImpl = globalThis.f
}
}
}
}
- return { enabled: ai.enabled, imageEnabled: ai.enabled, draft, rawDraft, image };
+ // Publishing 1.6's client handles polling/provenance; add the choices task to this workflow's request.
+ const devblogClient = createAiClient({ baseUrl: config.aiUrl, tokenClient, fetch: (url, options) => {
+ if (options.method === 'POST' && options.body) {
+ const body = JSON.parse(options.body);
+ if (body.workflow === 'content.devblog') options = { ...options, body: JSON.stringify({ ...body, task: 'blog.devblog.write' }) };
+ }
+ return fetchImpl(url, options);
+ } });
+ async function devblog(viewer, blog, input, options = {}) {
+ return devblogClient.run('content.devblog', input, { ...options, onBehalfOf: { type: 'user', id: viewer.subject }, target: { service: 'blog', type: 'blog', id: String(blog.id) } });
+ }
+ return { devblog, enabled: ai.enabled, imageEnabled: ai.enabled, draft, rawDraft, image };
}
}
module.exports = { createAiDrafts };
module.exports = { createAiDrafts };
@@ -208,6 +208,12 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
@@ -208,6 +208,12 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
let publishGate = null;
let publishGate = null;
function setPublishGate(fn) { publishGate = typeof fn === 'function' ? fn : null; }
function setPublishGate(fn) { publishGate = typeof fn === 'function' ? fn : null; }
+ async function devblogReview(post, rev) {
+ if (!await db.prepare('SELECT 1 AS x FROM blog_devblog_slots WHERE post_id = ?').get(post.id)) return;
+ const review = await publication.reviewOf(post, rev);
+ if (!require('./review-kind').isPersonReview(review)) throw new ApiError(409, 'devblog.person_review_required', 'A person must approve this devblog revision before publication');
+ }
+
const api = {
const api = {
VISIBILITIES, MEDIA_ROLES,
VISIBILITIES, MEDIA_ROLES,
@@ -231,7 +237,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
@@ -231,7 +237,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
* same viewer's post page would refuse.
* same viewer's post page would refuse.
* filters: blogId, termIds (any), seriesId, authorSubject, restricted, restrictedAuthor, limit, offset
* filters: blogId, termIds (any), seriesId, authorSubject, restricted, restrictedAuthor, limit, offset
*/
*/
- async listPublished({ blogId = null, termIds = null, seriesId = null, authorSubject = null, restricted = false, restrictedAuthor = null, limit = 20, offset = 0, order = 'recent' } = {}) {
+ async listPublished({ service = null, categorySlug = null, blogId = null, termIds = null, seriesId = null, authorSubject = null, restricted = false, restrictedAuthor = null, limit = 20, offset = 0, order = 'recent' } = {}) {
const where = ["p.state = 'published'"];
const where = ["p.state = 'published'"];
const args = [];
const args = [];
if (restricted) where.push("p.visibility IN ('public','members','private')");
if (restricted) where.push("p.visibility IN ('public','members','private')");
@@ -239,6 +245,8 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
@@ -239,6 +245,8 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
where.push("(p.visibility = 'public' OR (p.visibility IN ('members','private') AND p.author_subject = ?))");
where.push("(p.visibility = 'public' OR (p.visibility IN ('members','private') AND p.author_subject = ?))");
args.push(restrictedAuthor);
args.push(restrictedAuthor);
} else where.push("p.visibility = 'public'");
} else where.push("p.visibility = 'public'");
+ if (service) { where.push('EXISTS (SELECT 1 FROM blog_devblog_slots ds WHERE ds.post_id = p.id AND ds.service = ?)'); args.push(service); }
+ if (categorySlug) { where.push("EXISTS (SELECT 1 FROM blog_term_links l JOIN blog_terms t ON t.id = l.term_id WHERE l.entity_id = p.id AND t.slug = ? AND t.vocabulary <> 'tag')"); args.push(categorySlug); }
if (blogId) { where.push('p.blog_id = ?'); args.push(blogId); }
if (blogId) { where.push('p.blog_id = ?'); args.push(blogId); }
if (seriesId) { where.push('p.series_id = ?'); args.push(seriesId); }
if (seriesId) { where.push('p.series_id = ?'); args.push(seriesId); }
if (authorSubject) { where.push('p.author_subject = ?'); args.push(authorSubject); }
if (authorSubject) { where.push('p.author_subject = ?'); args.push(authorSubject); }
@@ -390,6 +398,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
@@ -390,6 +398,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
const rev = revisionNumber ? await store.revisions.get(post.id, revisionNumber) : await store.revisions.head(post.id);
const rev = revisionNumber ? await store.revisions.get(post.id, revisionNumber) : await store.revisions.head(post.id);
if (!rev) throw new ApiError(404, 'revision.not_found', `No revision ${revisionNumber} of this post`);
if (!rev) throw new ApiError(404, 'revision.not_found', `No revision ${revisionNumber} of this post`);
if (post.state === 'published' && post.published_revision === rev.number) return { changed: false, post };
if (post.state === 'published' && post.published_revision === rev.number) return { changed: false, post };
+ await devblogReview(post, rev);
const ok = canPublish(publication.authorshipOf(rev) || { mode: 'human' }, await publication.reviewOf(post, rev));
const ok = canPublish(publication.authorshipOf(rev) || { mode: 'human' }, await publication.reviewOf(post, rev));
if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} is AI-generated and needs a person's or the AI editor's approval before it can be published (${ok.reason})`);
if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} is AI-generated and needs a person's or the AI editor's approval before it can be published (${ok.reason})`);
if (publishGate) {
if (publishGate) {
@@ -467,6 +476,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
@@ -467,6 +476,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
const rev = action === 'publish' ? (revision ? await store.revisions.get(post.id, parseInt(revision, 10)) : await store.revisions.head(post.id)) : null;
const rev = action === 'publish' ? (revision ? await store.revisions.get(post.id, parseInt(revision, 10)) : await store.revisions.head(post.id)) : null;
if (action === 'publish') {
if (action === 'publish') {
if (!rev) throw new ApiError(404, 'revision.not_found', 'No such revision');
if (!rev) throw new ApiError(404, 'revision.not_found', 'No such revision');
+ await devblogReview(post, rev);
const ok = canPublish(publication.authorshipOf(rev) || { mode: 'human' }, await publication.reviewOf(post, rev));
const ok = canPublish(publication.authorshipOf(rev) || { mode: 'human' }, await publication.reviewOf(post, rev));
if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} needs a person's or the AI editor's approval before it can be scheduled (${ok.reason})`);
if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} needs a person's or the AI editor's approval before it can be scheduled (${ok.reason})`);
}
}
@@ -60,10 +60,13 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
@@ -60,10 +60,13 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
const { tags, categories } = await termsView(post, blog, { networkTags: !perBlogLinks });
const { tags, categories } = await termsView(post, blog, { networkTags: !perBlogLinks });
// The hero art of a devblog is its card in a list: the block names a Media id, and the card
// The hero art of a devblog is its card in a list: the block names a Media id, and the card
// shows the picture when Media can deliver it.
// shows the picture when Media can deliver it.
- const hero = (blocks.parse(rev.content).children || []).find((c) => c.type === 'hero');
- const artId = hero && hero.attrs && typeof hero.attrs.art === 'string' ? hero.attrs.art : null;
+ const tree = blocks.parse(rev.content);
+ const hero = (tree.children || []).find((c) => c.type === 'hero');
+ let figure = null;
+ blocks.walk(tree, node => { if (!figure && node.type === 'figure') figure = node; });
+ const artId = figure?.attrs?.src || (hero && hero.attrs && typeof hero.attrs.art === 'string' ? hero.attrs.art : null);
return {
return {
- post, rev, blog,
+ post, rev: { ...rev, fields: { ...rev.fields, summary: rev.fields.summary || (hero ? blocks.toText(hero).replace(/^.*?\n/, '').trim().slice(0, 500) : null) } }, blog,
url: publication.postPath(blog, post),
url: publication.postPath(blog, post),
author: person ? { name: person.name, url: urls.author(person, blog) } : null,
author: person ? { name: person.name, url: urls.author(person, blog) } : null,
tags, categories,
tags, categories,
@@ -200,6 +200,15 @@ function createApi(ctx) {
@@ -200,6 +200,15 @@ function createApi(ctx) {
return { total, posts: await Promise.all(rows.map(async (p) => await postDto(p))) };
return { total, posts: await Promise.all(rows.map(async (p) => await postDto(p))) };
}));
}));
+ router.get('/devblogs', run(async (req, res) => {
+ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);
+ const offset = Math.min(Math.max(parseInt(req.query.offset, 10) || 0, 0), 100000);
+ const result = await posts.listPublished({ categorySlug: 'devblog', service: req.query.service ? String(req.query.service) : null, limit, offset });
+ const items = await reading.listItems(result.posts);
+ res.set('Access-Control-Allow-Origin', '*');
+ return { total: result.total, posts: items.map(i => ({ id: i.post.id, title: i.rev.fields.title, summary: i.rev.fields.summary, figure: i.art, url: publication.abs(i.url), blog: i.blog.handle })), next: offset + limit < result.total ? offset + limit : null };
+ }));
+
// The network changelog (server/changelog.js): what shipped on every OpenVibe site, newest first, and
// The network changelog (server/changelog.js): what shipped on every OpenVibe site, newest first, and
// the latest patch notes post. Public: every site's "recently shipped" reads it.
// the latest patch notes post. Public: every site's "recently shipped" reads it.
router.get('/changelog', run(async (req, res) => {
router.get('/changelog', run(async (req, res) => {
@@ -220,6 +229,13 @@ function createApi(ctx) {
@@ -220,6 +229,13 @@ function createApi(ctx) {
};
};
}));
}));
+ router.post('/blogs/:handle/posts/devblog', guard('blog.post.create'), B('blog.post.ai_draft'), jsonBody, run(async (req) => {
+ const blog = await mustBlog(req);
+ const result = await ctx.devblog.draft(req.viewer, blog, { service: String(req.body.service || ''), retry: true }, tp(req));
+ await after(null, result.post.id, req);
+ return { post: await postDto(result.post, { full: true }), created: result.created };
+ }, 201));
+
// Draft with AI: OpenVibe.AI's blog.draft_post writes a draft (AI-authored, noindex until reviewed).
// Draft with AI: OpenVibe.AI's blog.draft_post writes a draft (AI-authored, noindex until reviewed).
router.post('/blogs/:handle/posts/ai-draft', guard('blog.post.create'), B('blog.post.ai_draft'), jsonBody, run(async (req) => {
router.post('/blogs/:handle/posts/ai-draft', guard('blog.post.create'), B('blog.post.ai_draft'), jsonBody, run(async (req) => {
const blog = await mustBlog(req);
const blog = await mustBlog(req);
@@ -58,6 +58,7 @@ function createEditorRoutes(ctx) {
@@ -58,6 +58,7 @@ function createEditorRoutes(ctx) {
// form shares its budget with the API route that does the same thing.
// form shares its budget with the API route that does the same thing.
const B = (name) => limits.budget(name);
const B = (name) => limits.budget(name);
router.post('/start', B('blog.blog.create'));
router.post('/start', B('blog.blog.create'));
+ router.post('/@:handle/devblog', B('blog.post.ai_draft'));
router.post('/@:handle/ai-draft', B('blog.post.ai_draft'));
router.post('/@:handle/ai-draft', B('blog.post.ai_draft'));
router.post('/@:handle/new', B('blog.post.create'));
router.post('/@:handle/new', B('blog.post.create'));
router.post('/@:handle/settings', B('blog.blog.configure'));
router.post('/@:handle/settings', B('blog.blog.configure'));
@@ -146,6 +147,13 @@ function createEditorRoutes(ctx) {
@@ -146,6 +147,13 @@ function createEditorRoutes(ctx) {
}));
}));
// Draft with AI: the draft is AI-authored and stays a noindex draft until someone reviews it.
// Draft with AI: the draft is AI-authored and stays a noindex draft until someone reviews it.
+ router.post('/@:handle/devblog', wrap(async (req, res) => {
+ const blog = await blogFor(req); if (!blog) return notFound(req, res);
+ const { post } = await ctx.devblog.draft(req.viewer, blog, { service: String(req.body.service || ''), retry: true }, { traceparent: req.ov && req.ov.traceparent });
+ await effects.after(null, post, req.ov);
+ res.redirect(303, `/write/posts/${post.id}?saved=Devblog%20draft%20ready%20for%20review`);
+ }));
+
router.post('/@:handle/ai-draft', wrap(async (req, res) => {
router.post('/@:handle/ai-draft', wrap(async (req, res) => {
const blog = await blogFor(req);
const blog = await blogFor(req);
if (!blog || !ctx.aiDrafts || !ctx.aiDrafts.enabled) return notFound(req, res);
if (!blog || !ctx.aiDrafts || !ctx.aiDrafts.enabled) return notFound(req, res);
@@ -125,6 +125,17 @@ route('get', '/changelog', {
@@ -125,6 +125,17 @@ route('get', '/changelog', {
latest_post: nullable('object'), posts: arrayOf({ type: 'object' }), sites: arrayOf({ type: 'object' }),
latest_post: nullable('object'), posts: arrayOf({ type: 'object' }), sites: arrayOf({ type: 'object' }),
} }), ...errors([400, 429]) },
} }), ...errors([400, 429]) },
});
});
+route('get', '/devblogs', {
+ operationId: 'listDevblogs', summary: 'Published devblogs across the network or for one site', tags: ['posts'],
+ parameters: [{ name: 'service', in: 'query', schema: str() }, { name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 100 } }, { name: 'offset', in: 'query', schema: { type: 'integer', minimum: 0, maximum: 100000 } }],
+ responses: { 200: json('Public summaries and the blocks’ first figure', { type: 'object' }), ...errors() },
+});
+route('post', '/blogs/{handle}/posts/devblog', {
+ operationId: 'draftDevblog', summary: 'Draft a devblog from a site’s merged changes', tags: ['posts'], security: PERSON, parameters: [HANDLE],
+ description: 'Only the owner of the site’s configured blog. At most one draft per site per week and seven-day cadence; meaningful changes only. Runs content.devblog using AI task blog.devblog.write. Never publishes. An existing weekly draft is returned unchanged.',
+ requestBody: body({ type: 'object', required: ['service'], properties: { service: str({ pattern: '^[a-z][a-z0-9-]{0,63}$' }) } }),
+ responses: { 201: json('The draft', { type: 'object', properties: { post: ref('Post'), created: { type: 'boolean' } } }), 409: problem, 422: problem, 503: problem, ...errors() },
+});
route('post', '/blogs/{handle}/posts/ai-draft', {
route('post', '/blogs/{handle}/posts/ai-draft', {
operationId: 'draftPostWithAi', summary: 'Have OpenVibe.AI write a draft', tags: ['posts'], security: PERSON, parameters: [HANDLE],
operationId: 'draftPostWithAi', summary: 'Have OpenVibe.AI write a draft', tags: ['posts'], security: PERSON, parameters: [HANDLE],
description: 'blog.post.create. The draft is AI-authored and cannot publish until a person reviews it; 503 when AI drafting is not configured here.',
description: 'blog.post.create. The draft is AI-authored and cannot publish until a person reviews it; 503 when AI drafting is not configured here.',
@@ -103,16 +103,17 @@ function createPublicRoutes(ctx) {
@@ -103,16 +103,17 @@ function createPublicRoutes(ctx) {
* are categories (Devblog, Daily, Community, Guides); a tab whose category does not exist yet is
* are categories (Devblog, Daily, Community, Guides); a tab whose category does not exist yet is
* empty, not hidden — every tab is a real, crawlable link.
* empty, not hidden — every tab is a real, crawlable link.
*/
*/
- async function indexData(req, blog, base) {
+ async function indexData(req, blog, base, { network = false, service = null } = {}) {
const page = pageNumber(req);
const page = pageNumber(req);
- const scope = await access.listingScope(store, req.viewer, blog);
+ const scope = network ? { restricted: false } : await access.listingScope(store, req.viewer, blog);
const wanted = String(req.query.tab || 'latest').toLowerCase();
const wanted = String(req.query.tab || 'latest').toLowerCase();
const tab = homePage.INDEX_TABS.find((t) => t.id === wanted) || homePage.INDEX_TABS[0];
const tab = homePage.INDEX_TABS.find((t) => t.id === wanted) || homePage.INDEX_TABS[0];
const q = String(req.query.q || '').replace(/\s+/g, ' ').trim().slice(0, 80);
const q = String(req.query.q || '').replace(/\s+/g, ' ').trim().slice(0, 80);
const term = tab.slug ? await store.taxonomy.bySlug(blogs.categoryVocabulary(blog), tab.slug) : null;
const term = tab.slug ? await store.taxonomy.bySlug(blogs.categoryVocabulary(blog), tab.slug) : null;
// A tab names a category. Until that category has a post there is nothing to list: falling back to
// A tab names a category. Until that category has a post there is nothing to list: falling back to
// the unfiltered index would show every post under a tab that promises one kind.
// the unfiltered index would show every post under a tab that promises one kind.
- const tabEmpty = Boolean(tab.slug && !term);
+ const tabEmpty = !network && Boolean(tab.slug && !term);
+ const filters = network ? { blogId: null, categorySlug: tab.slug || 'devblog', service } : { blogId: blog.id, ...(term ? { termIds: [term.id] } : {}) };
let rows;
let rows;
let total;
let total;
if (tabEmpty) {
if (tabEmpty) {
@@ -121,14 +122,14 @@ function createPublicRoutes(ctx) {
@@ -121,14 +122,14 @@ function createPublicRoutes(ctx) {
} else if (q) {
} else if (q) {
// A name filter is over the titles people see: a bounded read, filtered in place. The index is
// A name filter is over the titles people see: a bounded read, filtered in place. The index is
// small by design (a blog's published posts), and the unfiltered listing keeps its SQL pager.
// small by design (a blog's published posts), and the unfiltered listing keeps its SQL pager.
- const all = await posts.listPublished({ blogId: blog.id, ...scope, ...(term ? { termIds: [term.id] } : {}), limit: 500 });
+ const all = await posts.listPublished({ ...filters, ...scope, limit: 500 });
const revs = await store.revisions.getMany(all.posts.map((p) => ({ entityId: p.id, revision: p.published_revision })));
const revs = await store.revisions.getMany(all.posts.map((p) => ({ entityId: p.id, revision: p.published_revision })));
const needle = q.toLowerCase();
const needle = q.toLowerCase();
const matched = all.posts.filter((p, i) => revs[i] && String((revs[i].fields || {}).title || '').toLowerCase().includes(needle));
const matched = all.posts.filter((p, i) => revs[i] && String((revs[i].fields || {}).title || '').toLowerCase().includes(needle));
total = matched.length;
total = matched.length;
rows = matched.slice((page - 1) * PER_PAGE, page * PER_PAGE);
rows = matched.slice((page - 1) * PER_PAGE, page * PER_PAGE);
} else {
} else {
- ({ total, posts: rows } = await posts.listPublished({ blogId: blog.id, ...scope, ...(term ? { termIds: [term.id] } : {}), limit: PER_PAGE, offset: (page - 1) * PER_PAGE }));
+ ({ total, posts: rows } = await posts.listPublished({ ...filters, ...scope, limit: PER_PAGE, offset: (page - 1) * PER_PAGE }));
}
}
const pager = ssr.paginate({
const pager = ssr.paginate({
page, perPage: PER_PAGE, total,
page, perPage: PER_PAGE, total,
@@ -226,11 +227,12 @@ function createPublicRoutes(ctx) {
@@ -226,11 +227,12 @@ function createPublicRoutes(ctx) {
// The devblog index on its own address: the same newest-first featured post, tabs, filter and art
// The devblog index on its own address: the same newest-first featured post, tabs, filter and art
// cards the official blog's home shows, with nothing else. Every tab and every page is a real link.
// cards the official blog's home shows, with nothing else. Every tab and every page is a real link.
- router.get('/devblog', wrap(async (req, res) => {
+ router.get(['/devblog', '/devblog/:service'], wrap(async (req, res) => {
const blog = await blogs.official();
const blog = await blogs.official();
- const { tab, q, items, pager } = await indexData(req, blog, '/devblog');
+ const base = req.params.service ? `/devblog/${encodeURIComponent(req.params.service)}` : '/devblog';
+ const { tab, q, items, pager } = await indexData(req, blog, base, { network: true, service: req.params.service || null });
if (pager.outOfRange && pager.total) return notFound(req, res);
if (pager.outOfRange && pager.total) return notFound(req, res);
- const canonical = seo.canonicalUrl(config.baseUrl, '/devblog', { query: ['page', 'tab', 'q'] });
+ const canonical = seo.canonicalUrl(config.baseUrl, base, { query: ['page', 'tab', 'q'] });
const blogLd = { '@context': 'https://schema.org', '@type': 'Blog', '@id': `${publication.abs('/devblog')}#blog`, name: 'OpenVibe Devblog', url: publication.abs('/devblog'), publisher: { '@type': 'Organization', name: 'OpenVibe', url: 'https://openvibe.network' } };
const blogLd = { '@context': 'https://schema.org', '@type': 'Blog', '@id': `${publication.abs('/devblog')}#blog`, name: 'OpenVibe Devblog', url: publication.abs('/devblog'), publisher: { '@type': 'Organization', name: 'OpenVibe', url: 'https://openvibe.network' } };
return send(req, res, 200, {
return send(req, res, 200, {
title: 'Devblog',
title: 'Devblog',
@@ -240,7 +242,7 @@ function createPublicRoutes(ctx) {
@@ -240,7 +242,7 @@ function createPublicRoutes(ctx) {
prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,
prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,
jsonLd: [blogLd, ...homePage.homeJsonLd({ base: config.baseUrl.replace(/\/+$/, ''), items })],
jsonLd: [blogLd, ...homePage.homeJsonLd({ base: config.baseUrl.replace(/\/+$/, ''), items })],
styles: [showcase.STYLESHEET], bodyClass: 'blog-devbog',
styles: [showcase.STYLESHEET], bodyClass: 'blog-devbog',
- body: `<div class="bh-devbog"><header class="bh-devbog-head"><h1>OpenVibe Devblog</h1><p class="lede">Named updates from the team: what changed, why we did it, and what it means for you.</p></header>${homePage.indexList({ base: '/devblog', tab: tab.id, q, items, pager })}</div>`,
+ body: `<div class="bh-devbog"><header class="bh-devbog-head"><h1>OpenVibe Devblog</h1><p class="lede">Named updates from the team: what changed, why we did it, and what it means for you.</p></header>${homePage.indexList({ base, tab: tab.id, q, items, pager })}</div>`,
}, { cacheable: true });
}, { cacheable: true });
}));
}));
@@ -420,7 +422,8 @@ function createPublicRoutes(ctx) {
@@ -420,7 +422,8 @@ function createPublicRoutes(ctx) {
*/
*/
async function patchNotesFor(blog, post, from, to) {
async function patchNotesFor(blog, post, from, to) {
if (!changelog || !changelog.entries) return [];
if (!changelog || !changelog.entries) return [];
- const rows = await changelog.entries({ limit: 200 }).catch(() => []);
+ const slot = await store.db.prepare('SELECT service FROM blog_devblog_slots WHERE post_id = ?').get(post.id);
+ const rows = await store.db.prepare(`SELECT * FROM changelog_entries WHERE deployed_at >= ? AND deployed_at < ? ${slot ? 'AND service = ?' : ''} ORDER BY deployed_at, id`).all(from || '1970-01-01', to ? new Date(Date.parse(to) + 86400000).toISOString().slice(0, 10) : '9999-01-01', ...(slot ? [slot.service] : []));
const lo = from ? Date.parse(String(from) + 'T00:00:00Z') : null;
const lo = from ? Date.parse(String(from) + 'T00:00:00Z') : null;
const hi = to ? Date.parse(String(to) + 'T23:59:59Z') : null;
const hi = to ? Date.parse(String(to) + 'T23:59:59Z') : null;
const groups = new Map();
const groups = new Map();
@@ -430,7 +433,7 @@ function createPublicRoutes(ctx) {
@@ -430,7 +433,7 @@ function createPublicRoutes(ctx) {
if (lo !== null && at < lo) continue;
if (lo !== null && at < lo) continue;
if (hi !== null && at > hi) continue;
if (hi !== null && at > hi) continue;
if (!groups.has(e.service)) groups.set(e.service, []);
if (!groups.has(e.service)) groups.set(e.service, []);
- groups.get(e.service).push({ sha: e.short, url: e.url, title: e.subject });
+ groups.get(e.service).push({ sha: e.sha.slice(0, 7), url: `https://github.com/${e.repo}/commit/${e.sha}`, title: e.subject });
}
}
return [...groups.entries()].map(([site, commits]) => ({ site, commits }));
return [...groups.entries()].map(([site, commits]) => ({ site, commits }));
}
}
@@ -58,6 +58,11 @@ function aiDraftForm({ blog, csrf }) {
@@ -58,6 +58,11 @@ function aiDraftForm({ blog, csrf }) {
<label>Tone <input name="tone" maxlength="200" placeholder="e.g. friendly, plain (optional)"></label>
<label>Tone <input name="tone" maxlength="200" placeholder="e.g. friendly, plain (optional)"></label>
<label>Audience <input name="audience" maxlength="300" placeholder="Who is it for? (optional)"></label>
<label>Audience <input name="audience" maxlength="300" placeholder="Who is it for? (optional)"></label>
<button type="submit">Write a draft</button>
<button type="submit">Write a draft</button>
+</form>
+<form method="post" action="/write/@${blog.handle}/devblog">${csrfField(csrf)}
+<label>Site <input name="service" required maxlength="64" placeholder="e.g. live"></label>
+<p class="muted">Site owners can draft a weekly devblog from meaningful merged changes. A person reviews and publishes it.</p>
+<button type="submit">Draft a devblog</button>
</form></details>`;
</form></details>`;
}
}
@@ -101,7 +101,7 @@ const MED = 'med_01J8ZQ4X3M000000000000000A';
@@ -101,7 +101,7 @@ const MED = 'med_01J8ZQ4X3M000000000000000A';
// Page 2 is cards alone: the first post of the page is not featured away, so loading the next
// Page 2 is cards alone: the first post of the page is not featured away, so loading the next
// page in place never misses a post.
// page in place never misses a post.
assert.doesNotMatch(second.text, /class="bh-feature"/);
assert.doesNotMatch(second.text, /class="bh-feature"/);
- assert.strictEqual((second.text.match(/<li class="bh-card bh-artcard">/g) || []).length, 5);
+ assert.strictEqual((second.text.match(/<li class="bh-card bh-artcard">/g) || []).length, 2);
});
});
await check('the index keeps WebSite and ItemList structured data', async () => {
await check('the index keeps WebSite and ItemList structured data', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
+'use strict';
+const assert = require('assert');
+const http = require('http');
+const { boot, check, done, makeClock } = require('./helpers/boot');
+const { meaningful, candidates, weekOf, WEEK } = require('../server/devblog');
+const { main } = require('../scripts/devblog-backfill');
+const MED = 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const RUN = 'run_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const long = 'We added search to the channel page so readers can find a channel from the page. '.repeat(12);
+(async () => {
+ const requests = []; let fail = false, release, hold = false;
+ const server = http.createServer(async (req, res) => {
+ let raw = ''; for await (const c of req) raw += c;
+ const send = body => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
+ if (req.method === 'GET') return send({ citations: [] });
+ const body = JSON.parse(raw); requests.push(body);
+ if (hold) await new Promise(r => { release = r; });
+ if (fail) return send({ run: { id: RUN, status: 'failed', error: { code: 'devblog.check_failed' } } });
+ send({ run: { id: RUN, status: 'succeeded', workflow: { key: 'content.devblog', version: 1 }, provenance: { model: 'local-test-writer' }, synthetic: false,
+ output: { title: 'Search Party', summary: 'Channel search is ready.', checks: [], blocks: { type: 'root', children: [
+ { type: 'hero', attrs: { kicker: 'Devblog' }, children: [{ type: 'text', value: '# Search Party\n\nChannel search is ready.' }] },
+ { type: 'section', attrs: { id: 'channel-search' }, children: [{ type: 'text', value: `## Channel search\n\n${long}` }, { type: 'figure', attrs: { src: MED, alt: 'Channel search', caption: 'The search field' }, children: [] }] },
+ { type: 'patchnotes', attrs: { from: body.input.from, to: body.input.to }, children: [] },
+ ] } } } });
+ });
+ await new Promise(r => server.listen(0, '127.0.0.1', r));
+ const clock = makeClock();
+ const t = await boot({ clock, limitsNow: clock.now, env: { OV_AI_INTERNAL_URL: `http://127.0.0.1:${server.address().port}` } });
+ const db = t.ctx.store.db; const blog = await t.ctx.blogs.official();
+ t.media.objects.set(MED, 'ready');
+ t.ctx.media.listShots = async () => [{ id: MED, metadata: { service: 'live', path: '/channels', viewport: '390', release: 'r1' } }];
+ async function seed(service, at, subject = 'Add channel search', sha = 'abc') {
+ await db.prepare(`INSERT INTO changelog_entries (service, repo, sha, subject, deployed_at, committed_at, release, release_lines, major) VALUES (?, ?, ?, ?, ?, ?, 'r1', 100, 0)`).run(service, `OpenVibers/OpenVibe.${service}`, sha, subject, at, at);
+ }
+ let post;
+ await check('noise-only weeks never call AI; dry-run weeks are Monday based', async () => {
+ assert.equal(weekOf('2026-11-08'), '2026-11-02');
+ for (const subject of ['test: search page', 'docs: editor guide', 'openvibe-sdk v1.0.0', 'Pin search dependency']) assert.equal(meaningful({ subject }), false);
+ assert.equal(meaningful({ subject: 'feat: Add video chapters' }), true);
+ assert.equal(meaningful({ subject: 'fix: Restore stream playback' }), true);
+ assert.equal(meaningful({ subject: 'Fix search', files: '["test/search.test.js"]' }), false);
+ assert.deepEqual(candidates([{ service: 'live', deployed_at: '2026-11-03', subject: 'docs: search' }], { from: '2026-11-01', to: '2026-11-09' }), []);
+ await seed('docs', new Date(t.clock.now()).toISOString(), 'docs: search');
+ const r = await t.get('/api/v1/blogs/openvibe/posts/devblog', { as: t.official, json: { service: 'docs' } });
+ assert.equal(r.status, 422, r.text); assert.equal(requests.length, 0);
+ });
+ await check('owner authorization, concurrent reservation, task request and attached blocks', async () => {
+ await seed('live', new Date(t.clock.now()).toISOString());
+ const stranger = t.network.addUser('stranger');
+ assert.equal((await t.get('/api/v1/blogs/openvibe/posts/devblog', { as: stranger, json: { service: 'live' } })).status, 403);
+ hold = true;
+ const first = t.get('/api/v1/blogs/openvibe/posts/devblog', { as: t.official, json: { service: 'live' } });
+ while (!release) await new Promise(r => setTimeout(r, 10));
+ const second = await t.get('/api/v1/blogs/openvibe/posts/devblog', { as: t.official, json: { service: 'live' } });
+ assert.equal(second.status, 409, second.text); release(); hold = false;
+ const r = await first; assert.equal(r.status, 201, r.text); post = r.json().post;
+ assert.equal(post.state, 'draft'); assert.equal(post.authorship.mode, 'ai');
+ assert.equal(post.authorship.workflow.id, 'content.devblog');
+ assert.equal(requests[0].task, 'blog.devblog.write'); assert.equal(requests[0].input.screenshots[0].media_id, MED);
+ assert.equal((await t.ctx.posts.attachments(post))[0].mediaId, MED);
+ const repeat = await t.get('/api/v1/blogs/openvibe/posts/devblog', { as: t.official, json: { service: 'live' } });
+ assert.equal(repeat.json().post.id, post.id); assert.equal(requests.length, 1);
+ assert.equal((await db.prepare('SELECT post_id FROM changelog_entries WHERE service = ?').get('live')).post_id, null, 'daily Patch notes still owns entry assignment');
+ });
+ await check('a person must approve; AI editor approval cannot publish or schedule it', async () => {
+ assert.equal((await t.get(`/api/v1/posts/${post.id}/publish`, { as: t.official, json: {} })).status, 409);
+ await t.ctx.store.reviews.recordAiEditor({ entityId: post.id, revision: 1, principal: 'svc:ai-editor', decision: 'approved' });
+ await assert.rejects(() => t.ctx.posts.applyPublish(post.id, 1, { kind: 'service', service: 'svc:ai-editor' }), e => e.code === 'devblog.person_review_required');
+ const r = await t.get(`/api/v1/posts/${post.id}/reviews`, { as: t.official, json: { revision: 1, decision: 'approved' } });
+ assert.equal(r.status, 201, r.text);
+ const published = await t.get(`/api/v1/posts/${post.id}/publish`, { as: t.official, json: {} }); assert.equal(published.status, 200, published.text);
+ const page = await t.get('/devblog/live'); assert.match(page.text, /Search Party/); assert.match(page.text, /Channel search is ready/); assert(page.text.includes(MED));
+ assert.doesNotMatch((await t.get('/devblog/blog')).text, /Search Party/);
+ assert.match((await t.get('/devblog')).text, /Search Party/);
+ const api = await t.get('/api/v1/devblogs?service=live');
+ assert.equal(api.status, 200, api.text); assert.equal(api.json().posts[0].figure.mediaId, MED);
+ assert.equal(api.json().posts[0].summary, 'Channel search is ready.');
+ assert.equal((await t.get('/.well-known/agent.json')).json().skills[0].task, 'blog.devblog.write');
+ const skill = (await t.get('/skills/devblog.json')).json();
+ assert.equal(skill.skill, 'actor.skill@1');
+ const { integrity, ...fields } = skill;
+ assert.equal(integrity.hash, require('crypto').createHash('sha256').update(JSON.stringify(fields)).digest('hex'));
+
+ });
+ await check('seven-day guard survives a new week; failed runs leave no post and require an owner retry', async () => {
+ t.clock.advance(6 * 86400000);
+ await seed('live', new Date(t.clock.now()).toISOString(), 'Add search menu', 'def');
+ assert.equal((await t.get('/api/v1/blogs/openvibe/posts/devblog', { as: t.official, json: { service: 'live' } })).status, 409);
+ t.clock.advance(86400000 + 1); fail = true;
+ const before = (await db.prepare('SELECT COUNT(*) AS n FROM blog_posts').get()).n;
+ const failed = await t.get('/api/v1/blogs/openvibe/posts/devblog', { as: t.official, json: { service: 'live' } }); assert.equal(failed.status, 502, failed.text);
+ assert.equal((await db.prepare('SELECT COUNT(*) AS n FROM blog_posts').get()).n, before);
+ await assert.rejects(() => t.ctx.devblog.draft({ kind: 'user', subject: t.official.subject }, blog, { service: 'live' }), e => e.code === 'devblog.failed');
+ fail = false;
+ const retry = await t.get('/api/v1/blogs/openvibe/posts/devblog', { as: t.official, json: { service: 'live' } }); assert.equal(retry.status, 201, retry.text);
+ });
+ await check('snapshot backfill is dry-run only and does not call AI', async () => {
+ const fs = require('fs'), path = require('path'), os = require('os');
+ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'devblog-plan-')), 'entries.json');
+ fs.writeFileSync(file, JSON.stringify([{ service: 'live', subject: 'Add search menu', deployed_at: '2026-11-03' }]));
+ const before = requests.length;
+ const plan = await main(['--input', file, '--from', '2026-11-02', '--to', '2026-11-09']); assert.equal(plan.length, 1); assert.equal(requests.length, before);
+ await assert.rejects(() => main(['--input', file, '--from', '2026-11-02', '--to', '2026-11-09', '--write']));
+ fs.rmSync(path.dirname(file), { recursive: true });
+ });
+ await t.close(); await new Promise(r => server.close(r)); done();
+})();
@@ -64,6 +64,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
@@ -64,6 +64,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
'server/changelog.js': 'Network registry (configured) and api.github.com (fixed host) for the repositories the registry names',
'server/changelog.js': 'Network registry (configured) and api.github.com (fixed host) for the repositories the registry names',
'server/clients/community.js': 'OpenVibe.Community (configured)',
'server/clients/community.js': 'OpenVibe.Community (configured)',
'server/desks/desks.js': 'OpenVibe.Food\'s recipe library feed (OV_FOOD_URL, configured; the budget-recipes desk)',
'server/desks/desks.js': 'OpenVibe.Food\'s recipe library feed (OV_FOOD_URL, configured; the budget-recipes desk)',
+ 'server/domain/ai-drafts.js': 'Publishing AI client calls only configured OV_AI_INTERNAL_URL; the devblog adapter adds a task field, never a caller URL',
'server/clients/media.js': 'OpenVibe.Media (configured)',
'server/clients/media.js': 'OpenVibe.Media (configured)',
'server/clients/network.js': 'OpenVibe.Network (configured)',
'server/clients/network.js': 'OpenVibe.Network (configured)',
'server/sharing.js': 'OpenVibe.Network (configured) for identity lookups and the notification push; never a URL a user chose',
'server/sharing.js': 'OpenVibe.Network (configured) for identity lookups and the notification push; never a URL a user chose',