chore(roadmap): add idempotent prod roadmap sync script

scripts/sync-roadmap.js takes its own online backup of the target DB, then
upserts the 10 canonical roadmap items matched by display_order — updating
title/description/status/target/features in place so ids, attributions and
shipped_at are preserved. Inserts only when a display_order is missing; never
deletes. Run on the box after deploy with BIFROST_DB_PATH set.
This commit is contained in:
Arlind 2026-06-18 16:29:14 +02:00
parent ec24694a6d
commit e4accb614b

150
scripts/sync-roadmap.js Normal file
View file

@ -0,0 +1,150 @@
#!/usr/bin/env node
//
// One-off, idempotent sync of the canonical roadmap content into a target DB.
//
// BIFROST_DB_PATH=/opt/fenja/data/bifrost-portal/bifrost.db node scripts/sync-roadmap.js
//
// What it does, in order:
// 1. Takes its OWN online backup of the target DB to
// <db>.pre-roadmap-sync-<timestamp>.bak (safe while the app is running).
// 2. Upserts the 10 canonical items MATCHED BY display_order — it UPDATEs
// title / description / status / target / features in place, so row ids,
// roadmap_attributions, created_at and shipped_at are all preserved.
// 3. Inserts a canonical item only if no row exists at that display_order.
// 4. Leaves any extra prod rows (display_order outside 1..N) untouched and
// logs them — it never deletes.
//
// Safe to re-run. Does NOT wipe the table (unlike scripts/seed-demo.js, which
// must never be run against prod).
import Database from 'better-sqlite3';
import { existsSync } from 'node:fs';
// ── Canonical roadmap (content only; no demo dates/attributions) ───────────
const ROADMAP = [
{ display_order: 1, title: 'MCP Usage', status: 'shipping', target: 'July 2026',
description: 'Connect Fenja to external MCP servers — databases, internal tools, and other software — so it can read from and act across your systems.',
features: ['Connect external MCP servers', 'Reach databases, internal tools & software', 'Per-connection credentials & scoping'] },
{ display_order: 2, title: 'Extended Logging', status: 'shipping', target: 'July 2026',
description: 'Granular logging and audit controls, capturing usage down to the individual user, request, and document.',
features: ['Granular usage & audit logging', 'Per-user, per-request, per-document trails', 'Exportable audit records'] },
{ display_order: 3, title: 'Wiki 2.0', status: 'in_beta', target: 'July 2026',
description: 'A major wiki upgrade: version history with diffs between revisions, admin-locked pages, and a richer page structure.',
features: ['Version history with revision diffs', 'Admin-locked pages', 'Richer page structure'] },
{ display_order: 4, title: 'Interview 2.0', status: 'planned', target: 'July 2026',
description: 'A major Interviews upgrade: build and manage interview scripts, send invites, and track responses in one place.',
features: ['Build & manage interview scripts', 'Send invites & track responses', 'Centralised results view'] },
{ display_order: 5, title: 'Fenja Analyze', status: 'planned', target: 'August 2026',
description: 'The first release of Fenja Analyze: ask a question in plain language, run it as a query against your data, and get an answer with sources.',
features: ['Plain-language questions → queries', 'Answers grounded in your data', 'Source citations'] },
{ display_order: 6, title: 'Fenja Agentic', status: 'planned', target: 'September 2026',
description: 'The first release of Agents: create and orchestrate agents from an admin panel, wiring them to the tools and data they need.',
features: ['Create agents from an admin panel', 'Orchestrate multi-agent workflows', 'Wire agents to tools & data'] },
{ display_order: 7, title: 'Fenja Dev', status: 'planned', target: 'September 2026',
description: 'The first release of Fenja Dev: a sovereign IDE with terminal and git integration, running entirely inside your environment.',
features: ['Sovereign in-environment IDE', 'Integrated terminal', 'Git integration'] },
{ display_order: 8, title: 'HTML Reports', status: 'planned', target: 'September 2026',
description: 'Structured reporting for Fenja Analyze: admins define report templates so generated analyses render as clean, shareable HTML.',
features: ['Admin-defined report templates', 'Analyses rendered as clean HTML', 'Shareable, structured output'] },
{ display_order: 9, title: 'Self-Service Agents', status: 'planned', target: 'October 2026',
description: 'A major expansion of the agent experience, letting users create and run their own agents without admin involvement.',
features: ['User-created agents (no admin needed)', 'Run & manage your own agents', 'Reuse across tasks'] },
{ display_order: 10, title: 'Self-service Routines & Skills', status: 'exploring', target: 'October 2026',
description: "Personal and domain-level routines and skills: users build reusable, tailored workflows for their own and their team's recurring tasks.",
features: ['Personal & domain-level routines', 'Reusable, tailored workflows', 'Built for recurring team tasks'] },
];
async function main() {
const dbPath = process.env.BIFROST_DB_PATH;
if (!dbPath) {
console.error('!! BIFROST_DB_PATH is not set. Refusing to guess the DB location.');
process.exit(1);
}
if (!existsSync(dbPath)) {
console.error(`!! No database at ${dbPath}`);
process.exit(1);
}
const db = new Database(dbPath);
db.pragma('foreign_keys = ON');
// Guard: the features column must exist (migration 0009). Deploy runs
// migrate.js, so run this AFTER deploying.
const cols = db.prepare('PRAGMA table_info(roadmap_items)').all().map((c) => c.name);
if (!cols.includes('features')) {
console.error('!! roadmap_items.features column is missing — run `node scripts/migrate.js` (deploy) first.');
process.exit(1);
}
// 1. Online backup (safe while the app is running). Timestamp from the OS.
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `${dbPath}.pre-roadmap-sync-${ts}.bak`;
console.log(`==> Backing up current DB -> ${backupPath}`);
await db.backup(backupPath);
// Snapshot current titles for the diff log.
const before = db.prepare('SELECT display_order, title FROM roadmap_items ORDER BY display_order').all();
console.log(`==> Current roadmap (${before.length} rows):`);
before.forEach((r) => console.log(` ${r.display_order}. ${r.title}`));
// 2/3. Upsert by display_order, inside a transaction.
const findByOrder = db.prepare('SELECT id FROM roadmap_items WHERE display_order = ?');
const update = db.prepare(`
UPDATE roadmap_items
SET title = @title, description = @description, status = @status,
target = @target, features = @features, updated_at = datetime('now')
WHERE id = @id
`);
const insert = db.prepare(`
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text, features)
VALUES (@title, @description, @status, @target, @display_order, NULL, NULL, @features)
`);
let updated = 0;
let inserted = 0;
const run = db.transaction(() => {
for (const item of ROADMAP) {
const row = findByOrder.get(item.display_order);
const params = {
title: item.title,
description: item.description,
status: item.status,
target: item.target,
features: JSON.stringify(item.features),
display_order: item.display_order,
};
if (row) {
update.run({ ...params, id: row.id });
updated += 1;
} else {
insert.run(params);
inserted += 1;
}
}
});
run();
// 4. Report extras that were left untouched (never deleted).
const maxOrder = ROADMAP.length;
const extras = db
.prepare('SELECT display_order, title FROM roadmap_items WHERE display_order > ? ORDER BY display_order')
.all(maxOrder);
console.log(`==> Synced: ${updated} updated in place, ${inserted} inserted.`);
if (extras.length > 0) {
console.log(`==> Left untouched (display_order > ${maxOrder}) — review in /admin if these should go:`);
extras.forEach((r) => console.log(` ${r.display_order}. ${r.title}`));
}
const after = db.prepare('SELECT display_order, title, status, target FROM roadmap_items ORDER BY display_order').all();
console.log('==> Roadmap now:');
after.forEach((r) => console.log(` ${r.display_order}. ${r.title} [${r.status} · ${r.target}]`));
db.close();
console.log(`==> Done. Backup kept at ${backupPath}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});