openvibers/openvibe.blog Commits

Draft weekly site devblogs through the AI writer (#252)

OP OpenVibers committed 4 days ago · 12df6f0941 · 20 files, +437 −19

* Draft weekly site devblogs for human review

* Document devblog AI adapter in outbound request inventory

* Include conventional feature changes in weekly story planning

* Allow explicit backfill writes to retry failed historical drafts

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex c454a89..e324835 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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 shots
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
ddiff --git a/docs/devblog-writer.md b/docs/devblog-writer.md
nnew file mode 100644
iindex 0000000..401aab9
---- /dev/null
++++ b/docs/devblog-writer.md
@@@ -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.
ddiff --git a/migrations/0010_devblog_slots.sql b/migrations/0010_devblog_slots.sql
nnew file mode 100644
iindex 0000000..5fccfcf
---- /dev/null
++++ b/migrations/0010_devblog_slots.sql
@@@ -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);
ddiff --git a/public/skills/devblog.json b/public/skills/devblog.json
nnew file mode 100644
iindex 0000000..d2cdf84
---- /dev/null
++++ b/public/skills/devblog.json
@@@ -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"
++ }
++}
ddiff --git a/scripts/devblog-backfill.js b/scripts/devblog-backfill.js
nnew file mode 100644
iindex 0000000..08b8969
---- /dev/null
++++ b/scripts/devblog-backfill.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
iindex e0d922e..75e738e 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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,
ddiff --git a/server/changelog.js b/server/changelog.js
iindex 87e88bc..4ebf52a 100644
---- a/server/changelog.js
++++ b/server/changelog.js
@@@ -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, fetch
if (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). */
ddiff --git a/server/config.js b/server/config.js
iindex d6c7561..2e1d04d 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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) },
ddiff --git a/server/devblog/index.js b/server/devblog/index.js
nnew file mode 100644
iindex 0000000..a042729
---- /dev/null
++++ b/server/devblog/index.js
@@@ -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 };
ddiff --git a/server/domain/ai-drafts.js b/server/domain/ai-drafts.js
iindex f9a9bcb..f43f0ea 100644
---- a/server/domain/ai-drafts.js
++++ b/server/domain/ai-drafts.js
@@@ -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 };
ddiff --git a/server/domain/posts.js b/server/domain/posts.js
iindex 92a53f0..c6c8882 100644
---- a/server/domain/posts.js
++++ b/server/domain/posts.js
@@@ -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})`);
}
ddiff --git a/server/domain/reading.js b/server/domain/reading.js
iindex 2c45938..8e7b9cf 100644
---- a/server/domain/reading.js
++++ b/server/domain/reading.js
@@@ -60,10 +60,13 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
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
// 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,
ddiff --git a/server/http/api.js b/server/http/api.js
iindex ee23923..d7a36d7 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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);
ddiff --git a/server/http/editor.js b/server/http/editor.js
iindex b2a738a..fe52f38 100644
---- a/server/http/editor.js
++++ b/server/http/editor.js
@@@ -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);
ddiff --git a/server/http/openapi.js b/server/http/openapi.js
iindex 294bded..59e81ce 100644
---- a/server/http/openapi.js
++++ b/server/http/openapi.js
@@@ -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.',
ddiff --git a/server/http/public.js b/server/http/public.js
iindex 5c629b6..c8b257b 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -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 }));
}
ddiff --git a/server/render/editor.js b/server/render/editor.js
iindex 4889597..8a4c832 100644
---- a/server/render/editor.js
++++ b/server/render/editor.js
@@@ -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>`;
}
ddiff --git a/test/devblog-index.test.js b/test/devblog-index.test.js
iindex 8eaa245..e9b527e 100644
---- a/test/devblog-index.test.js
++++ b/test/devblog-index.test.js
@@@ -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 () => {
ddiff --git a/test/devblog-writer.test.js b/test/devblog-writer.test.js
nnew file mode 100644
iindex 0000000..4ad677a
---- /dev/null
++++ b/test/devblog-writer.test.js
@@@ -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();
++})();
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 818dae3..33c66f5 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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',