/** * `mosaic fleet backlog --json` — Mosaic-native backlog of record. * * Mosaic OWNS this backlog end-to-end on its existing Postgres storage layer * (`@mosaicstack/db`). It REPLACES the former Hermes adapter — there is NO * runtime dependency on Hermes. * * Storage tier (the existing storage-layer convention, no new engine): * - default: embedded PGlite at /fleet/backlog (real Postgres * semantics, persisted on disk so the operator's backlog survives reboots * and `mosaic update` — see install.sh PRESERVE_PATHS). * - DATABASE_URL set: full server Postgres — same code, no change. * * Migrations run on first use so the `backlog` table always exists. */ import { mkdir } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; import type { Command } from 'commander'; import { BacklogService, DEFAULT_CLAIM_TTL_SECONDS, type BacklogCard, type DbHandle, } from '@mosaicstack/db'; function defaultMosaicHome(): string { return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); } /** Resolve where the embedded PGlite backlog store lives (default tier). */ export function defaultBacklogDataDir(mosaicHome = defaultMosaicHome()): string { return join(mosaicHome, 'fleet', 'backlog'); } /** * Open a db handle for the backlog and ensure the schema exists. * * Tier detection mirrors the storage layer: DATABASE_URL => server Postgres * (migrations applied via runMigrations); otherwise embedded PGlite at the * fleet/backlog data dir (migrations applied via runPgliteMigrations). */ async function openBacklogDb(mosaicHome: string): Promise { const { createDb, createPgliteDb, runMigrations, runPgliteMigrations } = await import('@mosaicstack/db'); const url = process.env['DATABASE_URL']; if (url) { await runMigrations(url); return createDb(url); } const dataDir = process.env['PGLITE_DATA_DIR'] ?? defaultBacklogDataDir(mosaicHome); // PGlite writes a file-backed store to dataDir but does not create missing // parent directories (e.g. /fleet). Create them first. Skip for // the in-memory pseudo-paths so a memory:// store never touches the fs. if (!dataDir.startsWith('memory://') && dataDir !== ':memory:') { await mkdir(dataDir, { recursive: true }); } const handle = createPgliteDb(dataDir); await runPgliteMigrations(handle); return handle; } function parseDependsOn(value?: string): string[] { if (!value) return []; return value .split(',') .map((s) => s.trim()) .filter((s) => s.length > 0); } function parseAcceptance(value?: string): unknown { if (!value) return null; try { return JSON.parse(value); } catch { // Fall back to a list of newline/semicolon-separated criteria. return value .split(/[\n;]/) .map((s) => s.trim()) .filter((s) => s.length > 0); } } function printCard(card: BacklogCard | null, json?: boolean): void { if (json) { console.log(JSON.stringify(card)); return; } if (!card) { console.log('(none)'); return; } const deps = card.dependsOn.length ? card.dependsOn.join(',') : '-'; console.log( `${card.id}\t[${card.status}]\tp=${card.priority}\tphase=${card.phase ?? '-'}\tdeps=${deps}\t${card.title}`, ); } function printCards(cards: BacklogCard[], json?: boolean): void { if (json) { console.log(JSON.stringify(cards)); return; } if (cards.length === 0) { console.log('(no cards)'); return; } for (const card of cards) printCard(card, false); } /** * Register `backlog` under an existing `fleet` command. * `mosaicHomeFor` resolves the active --mosaic-home (parent flag) at call time. */ export function registerFleetBacklogCommand( fleetCmd: Command, mosaicHomeFor: () => string, ): Command { const backlogCmd = fleetCmd .command('backlog') .description('Mosaic-native backlog of record (atomic claim + TTL, deps DAG)'); const withSvc = async (fn: (svc: BacklogService) => Promise): Promise => { const handle = await openBacklogDb(mosaicHomeFor()); try { return await fn(new BacklogService(handle.db)); } finally { await handle.close(); } }; backlogCmd .command('create') .description('Create a backlog card (idempotency_key dedups)') .requiredOption('--id ', 'Stable card id') .requiredOption('--title ', 'Card title') .option('--body <body>', 'Card body / description') .option('--phase <phase>', 'Board/phase grouping') .option('--priority <n>', 'Priority (higher = sooner)', (v) => parseInt(v, 10), 0) .option('--depends-on <ids>', 'Comma-separated dependency card ids') .option('--acceptance <json>', 'Acceptance criteria (JSON or ;/newline list)') .option('--idempotency-key <key>', 'Dedup key; repeat returns the existing card') .option('--json', 'Print JSON') .action( async (opts: { id: string; title: string; body?: string; phase?: string; priority: number; dependsOn?: string; acceptance?: string; idempotencyKey?: string; json?: boolean; }) => { const card = await withSvc((svc) => svc.create({ id: opts.id, title: opts.title, body: opts.body ?? null, phase: opts.phase ?? null, priority: opts.priority, dependsOn: parseDependsOn(opts.dependsOn), acceptance: parseAcceptance(opts.acceptance), idempotencyKey: opts.idempotencyKey ?? null, }), ); printCard(card, opts.json); }, ); backlogCmd .command('list') .description('List cards (filters: --status, --phase, --ready-only)') .option('--status <status>', 'Filter by status: ready|claimed|blocked|done') .option('--phase <phase>', 'Filter by phase') .option('--ready-only', 'Only cards that are ready AND have all deps done') .option('--json', 'Print JSON') .action( async (opts: { status?: BacklogCard['status']; phase?: string; readyOnly?: boolean; json?: boolean; }) => { const cards = await withSvc((svc) => svc.list({ ...(opts.status ? { status: opts.status } : {}), ...(opts.phase ? { phase: opts.phase } : {}), ...(opts.readyOnly ? { readyOnly: true } : {}), }), ); printCards(cards, opts.json); }, ); backlogCmd .command('claim') .description('Atomically claim the highest-priority ready card (FOR UPDATE SKIP LOCKED)') .requiredOption('--owner <owner>', 'Claim owner (worker/agent id)') .option( '--ttl <sec>', 'Claim TTL in seconds', (v) => parseInt(v, 10), DEFAULT_CLAIM_TTL_SECONDS, ) .option('--id <id>', 'Claim a specific card by id') .option('--json', 'Print JSON') .action(async (opts: { owner: string; ttl: number; id?: string; json?: boolean }) => { const card = await withSvc((svc) => svc.claim({ owner: opts.owner, ttlSeconds: opts.ttl, ...(opts.id ? { id: opts.id } : {}) }), ); printCard(card, opts.json); if (!card && !opts.json) process.exitCode = 0; }); backlogCmd .command('reclaim') .description('Release expired claims back to ready (or a specific --id)') .option('--id <id>', 'Release a specific card regardless of expiry') .option('--json', 'Print JSON') .action(async (opts: { id?: string; json?: boolean }) => { const result = await withSvc((svc) => svc.reclaim(opts.id ? { id: opts.id } : {})); if (opts.json) { console.log(JSON.stringify(result)); } else if (result.reclaimed.length === 0) { console.log('(nothing to reclaim)'); } else { console.log(`reclaimed: ${result.reclaimed.join(', ')}`); } }); backlogCmd .command('link') .description('Add a depends_on edge (--from depends on --to)') .requiredOption('--from <id>', 'Card that gains the dependency') .requiredOption('--to <id>', 'Card it now depends on') .option('--json', 'Print JSON') .action(async (opts: { from: string; to: string; json?: boolean }) => { const card = await withSvc((svc) => svc.link(opts.from, opts.to)); printCard(card, opts.json); }); backlogCmd .command('stats') .description('Counts by status, oldest-ready age, expired-claim count') .option('--json', 'Print JSON') .action(async (opts: { json?: boolean }) => { const stats = await withSvc((svc) => svc.stats()); if (opts.json) { console.log(JSON.stringify(stats)); return; } console.log(`total: ${stats.total}`); console.log( `ready=${stats.counts.ready} claimed=${stats.counts.claimed} ` + `blocked=${stats.counts.blocked} done=${stats.counts.done}`, ); console.log(`oldest-ready-age: ${stats.oldestReadyAgeSeconds ?? '-'}s`); console.log(`expired-claims: ${stats.expiredClaimCount}`); }); backlogCmd .command('block') .description('Mark a card blocked') .requiredOption('--id <id>', 'Card id') .option('--json', 'Print JSON') .action(async (opts: { id: string; json?: boolean }) => { const card = await withSvc((svc) => svc.block(opts.id)); printCard(card, opts.json); }); backlogCmd .command('complete') .description('Mark a card done') .requiredOption('--id <id>', 'Card id') .option('--json', 'Print JSON') .action(async (opts: { id: string; json?: boolean }) => { const card = await withSvc((svc) => svc.complete(opts.id)); printCard(card, opts.json); }); return backlogCmd; }