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:
parent
1256ac30c0
commit
6500b1cd2b
4 changed files with 517 additions and 0 deletions
289
run/cli/commands/feature-dev/index.ts
Normal file
289
run/cli/commands/feature-dev/index.ts
Normal 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();
|
||||
|
|
@ -38,6 +38,7 @@ export const DEV_CLUSTER_PACKAGES = [
|
|||
'@lilith/seo-frontend',
|
||||
'@lilith/platform-admin',
|
||||
'@lilith/status-dashboard-frontend',
|
||||
'@lilith/truth-semantic-service',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
218
run/core/feature-service-registry.ts
Normal file
218
run/core/feature-service-registry.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue