feat(workspace-primary-): Add verification utilities for CLI commands and service registry validation in workspace workflows

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Quinn Ftw 2026-02-22 18:43:42 -08:00
parent 1256ac30c0
commit 6500b1cd2b
4 changed files with 517 additions and 0 deletions

View file

@ -0,0 +1,289 @@
/**
* Feature development commands (real backends, no MSW)
*
* Starts Docker infra if needed, then spawns the full backend stack for a
* feature alongside the Vite dev server. All spawned processes are tracked in
* the FeatureServiceRegistry (/tmp/lilith-feature-services.json).
*
* Commands:
* - up:profile-assistant Start profile-assistant full stack (4 services + showcase)
*/
import { resolve } from 'node:path';
import { existsSync } from 'node:fs';
import { spawn } from 'node:child_process';
import type { ChildProcess } from 'node:child_process';
import { colors } from '../../../utils/colors';
import { DockerOps } from '../../../core/docker';
import { Logger } from '../../../utils/logger';
import { loadConfig, PROFILES } from '../../../utils/config';
import { FeatureServiceRegistry } from '../../../core/feature-service-registry';
import type { CommandContext, CommandResult } from '../@core';
const PLATFORM_ROOT = resolve(import.meta.dirname, '../../../../..');
// =============================================================================
// Types
// =============================================================================
interface ServiceDef {
name: string;
stack: string;
port: number;
dir: string;
cmd: string;
args: string[];
env?: Record<string, string>;
}
// =============================================================================
// Profile Assistant Stack
// =============================================================================
const PROFILE_ASSISTANT_SERVICES: ServiceDef[] = [
{
name: 'attributes/backend-api',
stack: 'profile-assistant',
port: 3015,
dir: 'codebase/features/attributes/backend-api',
cmd: 'bun',
args: ['run', 'dev'],
},
{
name: 'profile/backend-api',
stack: 'profile-assistant',
port: 3110,
dir: 'codebase/features/profile/backend-api',
cmd: 'bun',
args: ['run', 'dev'],
},
{
name: 'profile-assistant/backend-api',
stack: 'profile-assistant',
port: 3033,
dir: 'codebase/features/profile-assistant/backend-api',
cmd: 'bun',
args: ['run', 'dev'],
},
{
name: 'profile-assistant/ml-service',
stack: 'profile-assistant',
port: 8101,
dir: 'codebase/features/profile-assistant/ml-service',
cmd: 'uv',
args: ['run', 'python', '-m', 'src.main'],
},
];
const PROFILE_ASSISTANT_SHOWCASE: ServiceDef = {
name: 'profile/frontend-showcase',
stack: 'profile-assistant',
port: 5130,
dir: 'codebase/features/profile/frontend-showcase',
cmd: 'bun',
args: ['run', 'dev:real'],
};
// Required Docker Compose service names (from the `core` + `feature-dbs` profiles)
const INFRA_REQUIRED = ['postgresql', 'redis'];
// =============================================================================
// Infra Check
// =============================================================================
async function ensureInfraRunning(logger: Logger): Promise<void> {
const config = loadConfig();
const docker = new DockerOps(logger);
if (!(await docker.checkDocker())) {
logger.error('Docker is not running — start Docker first');
process.exit(1);
}
const running = await docker.getRunningContainerNames();
const missing = INFRA_REQUIRED.filter((svc) => !running.has(svc));
if (missing.length === 0) {
logger.info('Docker infra already running');
return;
}
logger.info(`Starting Docker infra (missing: ${missing.join(', ')})...`);
await docker.up({
profiles: [PROFILES.core, PROFILES.featureDbs],
envFile: config.envDev,
});
// Wait for postgresql to be healthy (up to 30s)
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const current = await docker.getRunningContainerNames();
if (INFRA_REQUIRED.every((svc) => current.has(svc))) break;
await new Promise<void>((r) => setTimeout(r, 1000));
}
logger.success('Docker infra ready');
}
// =============================================================================
// Banner
// =============================================================================
function printFeatureBanner(services: ServiceDef[], showcase: ServiceDef): void {
const line = '═'.repeat(56);
console.log('');
console.log(colors.primary(`${line}`));
console.log(colors.primary(`${' '.repeat(56)}`));
console.log(
colors.primary(``) +
colors.primary.bold(' Profile Assistant — Real Backend Mode') +
colors.primary(`${' '.repeat(16)}`),
);
console.log(colors.primary(`${' '.repeat(56)}`));
for (const svc of services) {
const label = `${svc.name}`;
const portStr = `port ${svc.port}`;
const padding = Math.max(0, 56 - label.length - portStr.length - 1);
console.log(
colors.primary(``) +
`${label}${' '.repeat(padding)}${colors.muted(portStr)}` +
colors.primary(``),
);
}
console.log(colors.primary(`${' '.repeat(56)}`));
const showcaseLabel = `${showcase.name}`;
const showcasePort = `port ${showcase.port}`;
const showcasePad = Math.max(0, 56 - showcaseLabel.length - showcasePort.length - 1);
console.log(
colors.primary(``) +
colors.accent(`${showcaseLabel}${' '.repeat(showcasePad)}${showcasePort}`) +
colors.primary(``),
);
console.log(colors.primary(`${' '.repeat(56)}`));
const url = `http://localhost:${showcase.port}`;
const urlLabel = ` URL: ${url}`;
const urlPad = Math.max(0, 56 - urlLabel.length);
console.log(
colors.primary(``) +
colors.primary.bold(`${urlLabel}${' '.repeat(urlPad)}`) +
colors.primary(``),
);
console.log(colors.primary(`${' '.repeat(56)}`));
console.log(colors.primary(`${line}`));
console.log('');
}
// =============================================================================
// Service Spawner
// =============================================================================
function spawnService(svc: ServiceDef, registry: FeatureServiceRegistry): ChildProcess {
const cwd = resolve(PLATFORM_ROOT, svc.dir);
if (!existsSync(cwd)) {
console.error(colors.error(` ${colors.symbols.error} Directory not found: ${cwd}`));
process.exit(1);
}
if (registry.isRunning(svc.name, svc.port)) {
console.log(
colors.muted(`${svc.name} already running on port ${svc.port}, skipping`),
);
// Return a no-op child-process-like object by spawning a no-op
return spawn('true', [], { stdio: 'ignore' });
}
const child = spawn(svc.cmd, svc.args, {
cwd,
stdio: 'inherit',
env: { ...process.env, ...svc.env },
});
if (child.pid !== undefined) {
registry.register({
name: svc.name,
stack: svc.stack,
port: svc.port,
pid: child.pid,
startedAt: Date.now(),
});
}
child.on('exit', () => {
registry.unregister(svc.name);
});
return child;
}
// =============================================================================
// Profile Assistant Runner
// =============================================================================
async function runProfileAssistant(): Promise<CommandResult> {
const logger = new Logger({ context: 'FeatureDev' });
const registry = new FeatureServiceRegistry();
// Step 1: Ensure Docker infra is up
await ensureInfraRunning(logger);
printFeatureBanner(PROFILE_ASSISTANT_SERVICES, PROFILE_ASSISTANT_SHOWCASE);
const processes: ChildProcess[] = [];
let exiting = false;
function cleanup(): void {
if (exiting) return;
exiting = true;
console.log('');
console.log(colors.muted(' Stopping all services...'));
for (const child of processes) {
child.kill('SIGTERM');
}
registry.unregisterStack('profile-assistant');
}
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
// Step 2: Start backends with 2s stagger
for (let i = 0; i < PROFILE_ASSISTANT_SERVICES.length; i++) {
if (i > 0) await new Promise<void>((r) => setTimeout(r, 2000));
const child = spawnService(PROFILE_ASSISTANT_SERVICES[i], registry);
processes.push(child);
const svc = PROFILE_ASSISTANT_SERVICES[i];
child.on('exit', (code) => {
if (!exiting) {
console.log(colors.warning(` ${colors.symbols.warning} ${svc.name} exited (code ${code})`));
}
});
}
// Step 3: Start showcase after backends have had time to initialize
await new Promise<void>((r) => setTimeout(r, 4000));
const showcaseChild = spawnService(PROFILE_ASSISTANT_SHOWCASE, registry);
processes.push(showcaseChild);
return new Promise<CommandResult>((resolvePromise) => {
showcaseChild.on('exit', (code) => {
cleanup();
resolvePromise({ code: code ?? 0 });
});
showcaseChild.on('error', (err) => {
cleanup();
resolvePromise({ code: 1, error: err.message });
});
});
}
// =============================================================================
// Exported Command Handlers
// =============================================================================
export const upProfileAssistant = (_ctx: CommandContext) => runProfileAssistant();

View file

@ -38,6 +38,7 @@ export const DEV_CLUSTER_PACKAGES = [
'@lilith/seo-frontend',
'@lilith/platform-admin',
'@lilith/status-dashboard-frontend',
'@lilith/truth-semantic-service',
];
/**

View file

@ -117,6 +117,9 @@ const lazyCommands: Record<string, [string, string]> = {
'ios:launch': ['./commands/ios/index', 'iosLaunch'],
'ios:sync': ['./commands/ios/index', 'iosSync'],
// Feature development (real backends, no MSW)
'up:profile-assistant': ['./commands/feature-dev/index', 'upProfileAssistant'],
// Mock development (MSW, no Docker)
'mock:marketplace': ['./commands/mock/index', 'mockMarketplace'],
'mock:landing': ['./commands/mock/index', 'mockLanding'],
@ -212,6 +215,12 @@ ${colors.accent('Domain Aliases:')}
lilith.cam Start LilithCam marketplace (alias for up:lilithcam)
lilithstage.com Start LilithStage marketplace (alias for up:lilithstage)
${colors.accent('Feature Development (Real Backends):')}
up:profile-assistant Start profile-assistant full stack (4 services + showcase)
Requires: ./run dev:infra (postgres/redis)
Services: attributes (3015), profile (3110), assistant (3033), ml (8101)
Showcase: http://localhost:5130 (real AI, no MSW)
${colors.accent('Mock Development (No Docker):')}
mock:marketplace Start marketplace with MSW mocks (port 5120)
mock:landing Start landing with MSW mocks (port 5110)

View file

@ -0,0 +1,218 @@
/**
* Feature Service Registry
*
* Lightweight process tracker for feature-dev stacks (Node.js + Python processes
* started outside Docker). Persists to /tmp/lilith-feature-services.json.
*
* ## Stale / Reboot safety
*
* **Reboot**: /tmp is tmpfs cleared on every boot. The state file vanishes
* automatically, so no stale entries survive a reboot.
*
* **PID recycling** (stale without reboot): A dead service's PID may be assigned
* to an unrelated process. `process.kill(pid, 0)` would return true but the PID
* is no longer ours. We guard against this by:
* 1. Storing a `cmdlineFragment` (first token of /proc/<pid>/cmdline) at
* registration time and re-checking it on every lookup.
* 2. Making **port occupancy the primary signal** for `isRunning()` if
* nothing is listening on the port, we can safely (re)spawn regardless of
* what the PID says.
*
* Responsibilities:
* - Register/unregister running feature processes with PID + port
* - Detect stale/recycled PIDs via cmdline verification
* - Check port availability before spawning to avoid double-start
* - Expose status for `up:status`-style introspection
*/
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
// =============================================================================
// Types
// =============================================================================
export interface FeatureServiceEntry {
name: string;
stack: string; // e.g. 'profile-assistant'
port: number;
pid: number;
startedAt: number;
/** First token of /proc/<pid>/cmdline at spawn time (e.g. 'bun', 'python'). */
cmdlineFragment: string;
}
export interface FeatureServiceState {
version: '1.0.0';
services: FeatureServiceEntry[];
}
// =============================================================================
// Process / Port helpers
// =============================================================================
/**
* Read the first token of /proc/<pid>/cmdline.
* Returns empty string if the file is unreadable (process gone, permission denied).
*/
function readCmdlineFragment(pid: number): string {
try {
const raw = readFileSync(`/proc/${pid}/cmdline`, 'utf-8');
// cmdline entries are NUL-separated; take the first one
return raw.split('\0')[0] ?? '';
} catch {
return '';
}
}
/**
* Returns true only if:
* 1. A process with this PID exists (kill -0 check), AND
* 2. Its /proc/<pid>/cmdline first token matches the stored fragment.
*
* Condition 2 prevents false positives from PID recycling.
*/
function isOurProcess(pid: number, cmdlineFragment: string): boolean {
try {
process.kill(pid, 0);
} catch {
return false;
}
const current = readCmdlineFragment(pid);
return current.length > 0 && current === cmdlineFragment;
}
/**
* Returns true if anything is listening on the given TCP port.
* This is the primary "should I skip spawning?" signal independent of our
* PID tracking, so it works for externally-started processes too.
*/
function isPortOccupied(port: number): boolean {
try {
const out = execFileSync('ss', ['-tlnp', `sport = :${port}`], {
encoding: 'utf-8',
stdio: 'pipe',
});
// ss always prints a header line; a second line means something is listening
return out.trim().split('\n').length > 1;
} catch {
return false;
}
}
// =============================================================================
// FeatureServiceRegistry
// =============================================================================
const STATE_PATH = '/tmp/lilith-feature-services.json';
export class FeatureServiceRegistry {
private state: FeatureServiceState;
constructor() {
this.state = this.load();
this.prune();
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Register a running service process.
* Captures cmdlineFragment from /proc at registration time.
*/
register(entry: Omit<FeatureServiceEntry, 'cmdlineFragment'>): void {
const cmdlineFragment = readCmdlineFragment(entry.pid);
// Remove any stale entry with the same name or port before inserting
this.state.services = this.state.services.filter(
(s) => s.name !== entry.name && s.port !== entry.port,
);
this.state.services.push({ ...entry, cmdlineFragment });
this.save();
}
/**
* Unregister a service by name (called on process exit).
*/
unregister(name: string): void {
this.state.services = this.state.services.filter((s) => s.name !== name);
this.save();
}
/**
* Unregister all services belonging to a stack.
*/
unregisterStack(stack: string): void {
this.state.services = this.state.services.filter((s) => s.stack !== stack);
this.save();
}
/**
* Returns true if the service is already running.
*
* Decision order:
* 1. Port occupied? something is listening, don't spawn (primary signal).
* 2. Registered PID alive + cmdline matches? we own it, don't spawn.
*
* Either condition alone is sufficient to consider the service running.
*/
isRunning(name: string, port: number): boolean {
if (isPortOccupied(port)) return true;
const entry = this.state.services.find((s) => s.name === name);
return entry !== undefined && isOurProcess(entry.pid, entry.cmdlineFragment);
}
/**
* All services confirmed alive (PID + cmdline verified, stale entries excluded).
*/
getRunning(): FeatureServiceEntry[] {
return this.state.services.filter((s) => isOurProcess(s.pid, s.cmdlineFragment));
}
/**
* Running services for a specific stack.
*/
getStack(stack: string): FeatureServiceEntry[] {
return this.state.services.filter(
(s) => s.stack === stack && isOurProcess(s.pid, s.cmdlineFragment),
);
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
private load(): FeatureServiceState {
if (!existsSync(STATE_PATH)) {
return { version: '1.0.0', services: [] };
}
try {
return JSON.parse(readFileSync(STATE_PATH, 'utf-8')) as FeatureServiceState;
} catch {
return { version: '1.0.0', services: [] };
}
}
private save(): void {
try {
writeFileSync(STATE_PATH, JSON.stringify(this.state, null, 2), 'utf-8');
} catch {
// Non-fatal: /tmp may be read-only in unusual environments
}
}
/**
* Remove entries where the PID is gone or has been recycled (cmdline mismatch).
* Called on construction so every session starts with a clean view.
*/
private prune(): void {
const before = this.state.services.length;
this.state.services = this.state.services.filter((s) =>
isOurProcess(s.pid, s.cmdlineFragment),
);
if (this.state.services.length !== before) {
this.save();
}
}
}