286 lines
9.6 KiB
TypeScript
286 lines
9.6 KiB
TypeScript
/**
|
|
* `mosaic fleet backlog <sub> --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 <mosaicHome>/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<DbHandle> {
|
|
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. <mosaicHome>/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 <T>(fn: (svc: BacklogService) => Promise<T>): Promise<T> => {
|
|
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 <id>', 'Stable card id')
|
|
.requiredOption('--title <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;
|
|
}
|