Merge pull request 'feat: storage abstraction retrofit — adapters for queue, storage, memory (phases 1-4)' (#365) from feat/storage-abstraction into main
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful

This commit was merged in pull request #365.
This commit is contained in:
2026-04-03 04:40:57 +00:00
38 changed files with 3243 additions and 89 deletions

View File

@@ -22,6 +22,7 @@
},
"dependencies": {
"@clack/prompts": "^0.9.0",
"@mosaic/config": "workspace:^",
"@mosaic/mosaic": "workspace:^",
"@mosaic/prdy": "workspace:^",
"@mosaic/quality-rails": "workspace:^",

View File

@@ -7,6 +7,7 @@ import { registerAgentCommand } from './commands/agent.js';
import { registerMissionCommand } from './commands/mission.js';
// prdy is registered via launch.ts
import { registerLaunchCommands } from './commands/launch.js';
import { registerGatewayCommand } from './commands/gateway.js';
const _require = createRequire(import.meta.url);
const CLI_VERSION: string = (_require('../package.json') as { version: string }).version;
@@ -290,6 +291,10 @@ sessionsCmd
}
});
// ─── gateway ──────────────────────────────────────────────────────────
registerGatewayCommand(program);
// ─── agent ─────────────────────────────────────────────────────────────
registerAgentCommand(program);

View File

@@ -0,0 +1,198 @@
import { createInterface } from 'node:readline';
import { spawn } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import type { Command } from 'commander';
import {
DEFAULT_LOCAL_CONFIG,
DEFAULT_TEAM_CONFIG,
loadConfig,
type MosaicConfig,
type StorageTier,
} from '@mosaic/config';
function ask(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
return new Promise((res) => rl.question(question, res));
}
async function runInit(opts: { tier?: string; output: string }): Promise<void> {
const outputPath = resolve(opts.output);
let tier: StorageTier;
if (opts.tier) {
if (opts.tier !== 'local' && opts.tier !== 'team') {
console.error(`Invalid tier "${opts.tier}" — expected "local" or "team"`);
process.exit(1);
}
tier = opts.tier;
} else {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await ask(rl, 'Select tier (local/team) [local]: ');
rl.close();
const trimmed = answer.trim().toLowerCase();
tier = trimmed === 'team' ? 'team' : 'local';
}
let config: MosaicConfig;
if (tier === 'local') {
config = DEFAULT_LOCAL_CONFIG;
} else {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const dbUrl = await ask(
rl,
'DATABASE_URL [postgresql://mosaic:mosaic@localhost:5432/mosaic]: ',
);
const valkeyUrl = await ask(rl, 'VALKEY_URL [redis://localhost:6379]: ');
rl.close();
config = {
...DEFAULT_TEAM_CONFIG,
storage: {
type: 'postgres',
url: dbUrl.trim() || 'postgresql://mosaic:mosaic@localhost:5432/mosaic',
},
queue: {
type: 'bullmq',
url: valkeyUrl.trim() || 'redis://localhost:6379',
},
};
}
writeFileSync(outputPath, JSON.stringify(config, null, 2) + '\n');
console.log(`\nWrote ${outputPath}`);
console.log('\nNext steps:');
console.log(' 1. Review the generated config');
console.log(' 2. Run: pnpm --filter @mosaic/gateway exec tsx src/main.ts');
}
const PID_FILE = resolve(process.cwd(), '.mosaic/gateway.pid');
function writePidFile(pid: number): void {
const dir = dirname(PID_FILE);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(PID_FILE, String(pid));
}
function readPidFile(): number | null {
if (!existsSync(PID_FILE)) return null;
const raw = readFileSync(PID_FILE, 'utf-8').trim();
const pid = Number(raw);
return Number.isFinite(pid) ? pid : null;
}
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function printConfigSummary(config: MosaicConfig): void {
console.log(` Tier: ${config.tier}`);
console.log(` Storage: ${config.storage.type}`);
console.log(` Queue: ${config.queue.type}`);
console.log(` Memory: ${config.memory.type}`);
}
export function registerGatewayCommand(program: Command): void {
const gateway = program.command('gateway').description('Gateway management commands');
gateway
.command('init')
.description('Generate a mosaic.config.json for the gateway')
.option('--tier <tier>', 'Storage tier: local or team (skips interactive prompt)')
.option('--output <path>', 'Output file path', './mosaic.config.json')
.action(async (opts: { tier?: string; output: string }) => {
await runInit(opts);
});
gateway
.command('start')
.description('Start the Mosaic gateway process')
.option('--port <port>', 'Port to listen on (overrides config)')
.option('--daemon', 'Run in background and write PID to .mosaic/gateway.pid')
.action((opts: { port?: string; daemon?: boolean }) => {
const config = loadConfig();
const port = opts.port ?? '4000';
console.log('Starting gateway…');
printConfigSummary(config);
console.log(` Port: ${port}`);
const entryPoint = resolve(process.cwd(), 'apps/gateway/src/main.ts');
const env = { ...process.env, GATEWAY_PORT: port };
if (opts.daemon) {
const child = spawn('npx', ['tsx', entryPoint], {
env,
stdio: 'ignore',
detached: true,
});
child.unref();
if (child.pid) {
writePidFile(child.pid);
console.log(`\nGateway started in background (PID ${child.pid})`);
console.log(`PID file: ${PID_FILE}`);
}
} else {
const child = spawn('npx', ['tsx', entryPoint], {
env,
stdio: 'inherit',
});
child.on('exit', (code) => {
process.exit(code ?? 0);
});
}
});
gateway
.command('stop')
.description('Stop the running gateway process')
.action(() => {
const pid = readPidFile();
if (pid === null) {
console.error('No PID file found at', PID_FILE);
process.exit(1);
}
if (!isProcessRunning(pid)) {
console.log(`Process ${pid} is not running. Removing stale PID file.`);
unlinkSync(PID_FILE);
return;
}
process.kill(pid, 'SIGTERM');
unlinkSync(PID_FILE);
console.log(`Gateway stopped (PID ${pid})`);
});
gateway
.command('status')
.description('Show gateway process status')
.action(() => {
const config = loadConfig();
const pid = readPidFile();
if (pid !== null && isProcessRunning(pid)) {
console.log('Gateway: running');
console.log(` PID: ${pid}`);
} else {
console.log('Gateway: stopped');
if (pid !== null) {
console.log(` (stale PID file for ${pid})`);
unlinkSync(PID_FILE);
}
}
console.log('');
console.log('Config:');
printConfigSummary(config);
});
}