From 6500b1cd2b4b75861f2c6f52eeed09aeac533689 Mon Sep 17 00:00:00 2001 From: Quinn Ftw Date: Sun, 22 Feb 2026 18:43:42 -0800 Subject: [PATCH] =?UTF-8?q?feat(workspace-primary-):=20=E2=9C=A8=20Add=20v?= =?UTF-8?q?erification=20utilities=20for=20CLI=20commands=20and=20service?= =?UTF-8?q?=20registry=20validation=20in=20workspace=20workflows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- run/cli/commands/feature-dev/index.ts | 289 ++++++++++++++++++ .../commands/workspace/@core/verify-utils.ts | 1 + run/cli/index.ts | 9 + run/core/feature-service-registry.ts | 218 +++++++++++++ 4 files changed, 517 insertions(+) create mode 100644 run/cli/commands/feature-dev/index.ts create mode 100644 run/core/feature-service-registry.ts diff --git a/run/cli/commands/feature-dev/index.ts b/run/cli/commands/feature-dev/index.ts new file mode 100644 index 0000000..13b17e8 --- /dev/null +++ b/run/cli/commands/feature-dev/index.ts @@ -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; +} + +// ============================================================================= +// 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 { + 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((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 { + 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((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((r) => setTimeout(r, 4000)); + const showcaseChild = spawnService(PROFILE_ASSISTANT_SHOWCASE, registry); + processes.push(showcaseChild); + + return new Promise((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(); diff --git a/run/cli/commands/workspace/@core/verify-utils.ts b/run/cli/commands/workspace/@core/verify-utils.ts index 0aa1018..817b6b6 100644 --- a/run/cli/commands/workspace/@core/verify-utils.ts +++ b/run/cli/commands/workspace/@core/verify-utils.ts @@ -38,6 +38,7 @@ export const DEV_CLUSTER_PACKAGES = [ '@lilith/seo-frontend', '@lilith/platform-admin', '@lilith/status-dashboard-frontend', + '@lilith/truth-semantic-service', ]; /** diff --git a/run/cli/index.ts b/run/cli/index.ts index 657d674..9081716 100644 --- a/run/cli/index.ts +++ b/run/cli/index.ts @@ -117,6 +117,9 @@ const lazyCommands: Record = { '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) diff --git a/run/core/feature-service-registry.ts b/run/core/feature-service-registry.ts new file mode 100644 index 0000000..889d8b6 --- /dev/null +++ b/run/core/feature-service-registry.ts @@ -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//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//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//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//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): 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(); + } + } +}