From decd2132d3d67fec37730b48fd0df3a44e065515 Mon Sep 17 00:00:00 2001 From: Quinn Ftw Date: Fri, 30 Jan 2026 19:30:56 -0800 Subject: [PATCH] =?UTF-8?q?chore(cli):=20=F0=9F=94=A7=20Update=20CLI=20shu?= =?UTF-8?q?tdown=20scripts=20(shutdown-display.ts,=20shutdown-orchestrator?= =?UTF-8?q?.ts)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../commands/dev/@core/shutdown-display.ts | 158 +++++++++++++ run/core/shutdown-orchestrator.ts | 222 ++++++++++++++++++ 2 files changed, 380 insertions(+) create mode 100644 run/cli/commands/dev/@core/shutdown-display.ts create mode 100644 run/core/shutdown-orchestrator.ts diff --git a/run/cli/commands/dev/@core/shutdown-display.ts b/run/cli/commands/dev/@core/shutdown-display.ts new file mode 100644 index 0000000..dcd4278 --- /dev/null +++ b/run/cli/commands/dev/@core/shutdown-display.ts @@ -0,0 +1,158 @@ +/** + * Shutdown Display + * + * Terminal renderer for shutdown progress events. + * Renders discovery, per-service stop progress, Docker shutdown, and summary. + */ + +import { colors } from '../../../../utils/colors'; +import { formatDuration } from '../../@core/formatters'; +import type { + ShutdownProgressEvent, + ShutdownResult, + ServiceStopStatus, +} from '../../../../core/shutdown-orchestrator'; + +// ============================================================================= +// Display +// ============================================================================= + +export class ShutdownDisplay { + private serviceTimers = new Map(); + + /** + * Handle a progress event from ShutdownOrchestrator + */ + handleProgress(event: ShutdownProgressEvent): void { + switch (event.phase) { + case 'discovery': + this.renderDiscovery(event); + break; + case 'host-services': + this.renderServiceEvent(event); + break; + case 'docker-containers': + this.renderDockerEvent(event); + break; + } + } + + /** + * Render final summary after shutdown completes + */ + renderSummary(result: ShutdownResult): void { + const isEmpty = + result.hostServices.discovered.length === 0 && + result.containers.discovered.length === 0; + + if (isEmpty) { + console.log(''); + console.log(` ${colors.muted('Nothing to stop — cluster is not running.')}`); + console.log(''); + return; + } + + const line = '━'.repeat(54); + const statusIcon = result.success ? colors.symbols.success : colors.symbols.warning; + const statusText = result.success ? 'Cluster Stopped' : 'Cluster Stopped (with issues)'; + const duration = formatDuration(result.totalDurationMs); + + console.log(''); + console.log(colors.primary(line)); + console.log(colors.primary.bold(` ${statusIcon} ${statusText} (${duration})`)); + console.log(colors.primary(line)); + + // Counts + const parts: string[] = []; + + if (result.hostServices.stopped.length > 0) { + parts.push(`${colors.healthy('●')} Stopped: ${result.hostServices.stopped.length}`); + } + if (result.hostServices.forceKilled.length > 0) { + parts.push(`${colors.warning('⚠')} Killed: ${result.hostServices.forceKilled.length}`); + } + if (result.hostServices.failed.length > 0) { + parts.push(`${colors.error('✗')} Failed: ${result.hostServices.failed.length}`); + } + if (result.containers.stoppedCount > 0) { + const volText = result.containers.volumesRemoved ? ' (volumes removed)' : ''; + parts.push(`${colors.healthy('●')} Containers: ${result.containers.stoppedCount}${volText}`); + } + + if (parts.length > 0) { + console.log(` ${parts.join(' ')}`); + } + + console.log(''); + } + + // --------------------------------------------------------------------------- + // Phase Renderers + // --------------------------------------------------------------------------- + + private renderDiscovery(event: ShutdownProgressEvent): void { + // First discovery event: show phase header + if (event.message.startsWith('Discovering')) { + return; // Skip the "discovering..." message, only show results + } + + console.log(''); + console.log(colors.accent(` ▸ Discovery`)); + console.log(` ${colors.symbols.info} ${event.message}`); + } + + private renderServiceEvent(event: ShutdownProgressEvent): void { + if (!event.serviceId) return; + + if (event.status === 'stopping') { + // Track start time for duration calculation + this.serviceTimers.set(event.serviceId, Date.now()); + + // Print phase header on first service + if (event.current === 1) { + console.log(''); + console.log(colors.accent(` ▸ Stopping Host Services (${event.total})`)); + } + return; + } + + // Result event + const startTime = this.serviceTimers.get(event.serviceId); + const duration = startTime ? formatDuration(Date.now() - startTime) : ''; + const durationText = duration ? colors.muted(` (${duration})`) : ''; + + const paddedId = event.serviceId.padEnd(32); + const statusText = this.formatServiceStatus(event.status); + + console.log(` ${statusText} ${paddedId}${durationText}`); + } + + private renderDockerEvent(event: ShutdownProgressEvent): void { + if (event.message.includes('Stopping')) { + console.log(''); + console.log(colors.accent(` ▸ Docker Containers`)); + console.log(` ${colors.symbols.info} ${event.message}`); + } else { + console.log(` ${colors.healthy('●')} ${event.message}`); + } + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private formatServiceStatus(status?: ServiceStopStatus): string { + switch (status) { + case 'stopped': + return colors.healthy('●'); + case 'force-killed': + return colors.warning('⚠'); + case 'failed': + return colors.error('✗'); + case 'not-running': + return colors.muted('○'); + default: + return colors.muted('●'); + } + } +} diff --git a/run/core/shutdown-orchestrator.ts b/run/core/shutdown-orchestrator.ts new file mode 100644 index 0000000..6b9e417 --- /dev/null +++ b/run/core/shutdown-orchestrator.ts @@ -0,0 +1,222 @@ +/** + * Shutdown Orchestrator + * + * Coordinates graceful shutdown of the development cluster with + * discovery, per-service progress tracking, and structured results. + * + * Used by: dev:stop, dev:reset, dev:fresh, dev:restart + */ + +import { getOurServices } from '@lilith/service-orchestrator'; +import type { ContainerStatus } from './docker'; +import type { DockerOps } from './docker'; +import type { ServiceManager } from './services'; +import type { Logger } from '../utils/logger'; +import type { RunConfig } from '../utils/config'; + +// ============================================================================= +// Types +// ============================================================================= + +export type ShutdownPhase = 'discovery' | 'host-services' | 'docker-containers' | 'cleanup'; + +export type ServiceStopStatus = 'stopping' | 'stopped' | 'force-killed' | 'failed' | 'not-running'; + +export interface ShutdownProgressEvent { + phase: ShutdownPhase; + message: string; + serviceId?: string; + status?: ServiceStopStatus; + current?: number; + total?: number; +} + +export interface ShutdownOptions { + /** Remove Docker volumes (used by dev:reset/dev:fresh) */ + removeVolumes?: boolean; + /** Callback for progress updates */ + onProgress?: (event: ShutdownProgressEvent) => void; +} + +export interface ShutdownResult { + hostServices: { + discovered: string[]; + stopped: string[]; + forceKilled: string[]; + failed: string[]; + notRunning: string[]; + durationMs: number; + }; + containers: { + discovered: ContainerStatus[]; + stoppedCount: number; + durationMs: number; + volumesRemoved: boolean; + }; + totalDurationMs: number; + success: boolean; +} + +// ============================================================================= +// Shutdown Orchestrator +// ============================================================================= + +export class ShutdownOrchestrator { + constructor( + private logger: Logger, + private docker: DockerOps, + private services: ServiceManager, + private config: RunConfig, + ) {} + + /** + * Execute a full shutdown with discovery, per-service progress, and summary. + */ + async shutdown(options: ShutdownOptions = {}): Promise { + const totalStart = Date.now(); + const emit = options.onProgress; + + // ── Phase 1: Discovery ────────────────────────────────────────────── + emit?.({ + phase: 'discovery', + message: 'Discovering running services and containers...', + }); + + const [runningServiceIds, containerStatuses] = await Promise.all([ + this.discoverHostServices(), + this.docker.status({ envFile: this.config.envDev }), + ]); + + emit?.({ + phase: 'discovery', + message: `Found ${runningServiceIds.length} host services, ${containerStatuses.length} containers`, + }); + + // Short-circuit if nothing is running + if (runningServiceIds.length === 0 && containerStatuses.length === 0) { + return { + hostServices: { + discovered: [], + stopped: [], + forceKilled: [], + failed: [], + notRunning: [], + durationMs: 0, + }, + containers: { + discovered: [], + stoppedCount: 0, + durationMs: 0, + volumesRemoved: false, + }, + totalDurationMs: Date.now() - totalStart, + success: true, + }; + } + + // ── Phase 2: Stop Host Services ────────────────────────────────────── + const serviceStart = Date.now(); + const stopped: string[] = []; + const forceKilled: string[] = []; + const failed: string[] = []; + const notRunning: string[] = []; + + if (runningServiceIds.length > 0) { + for (let i = 0; i < runningServiceIds.length; i++) { + const serviceId = runningServiceIds[i]; + + emit?.({ + phase: 'host-services', + message: `Stopping ${serviceId}`, + serviceId, + status: 'stopping', + current: i + 1, + total: runningServiceIds.length, + }); + + const result = await this.services.stopServices([serviceId]); + + let status: ServiceStopStatus; + if (result.stopped.includes(serviceId)) { + stopped.push(serviceId); + status = 'stopped'; + } else if (result.failed.includes(serviceId)) { + failed.push(serviceId); + status = 'failed'; + } else if (result.notRunning.includes(serviceId)) { + notRunning.push(serviceId); + status = 'not-running'; + } else { + // Default to stopped (process already gone counts as stopped) + stopped.push(serviceId); + status = 'stopped'; + } + + emit?.({ + phase: 'host-services', + message: `${serviceId} ${status}`, + serviceId, + status, + current: i + 1, + total: runningServiceIds.length, + }); + } + } + + const serviceDuration = Date.now() - serviceStart; + + // ── Phase 3: Stop Docker Containers ───────────────────────────────── + const dockerStart = Date.now(); + + if (containerStatuses.length > 0) { + emit?.({ + phase: 'docker-containers', + message: `Stopping ${containerStatuses.length} containers${options.removeVolumes ? ' and removing volumes' : ''}...`, + }); + + await this.docker.down({ + envFile: this.config.envDev, + volumes: options.removeVolumes, + }); + + emit?.({ + phase: 'docker-containers', + message: `${containerStatuses.length} containers stopped`, + }); + } + + const dockerDuration = Date.now() - dockerStart; + + // ── Result ────────────────────────────────────────────────────────── + return { + hostServices: { + discovered: runningServiceIds, + stopped, + forceKilled, + failed, + notRunning, + durationMs: serviceDuration, + }, + containers: { + discovered: containerStatuses, + stoppedCount: containerStatuses.length, + durationMs: dockerDuration, + volumesRemoved: options.removeVolumes ?? false, + }, + totalDurationMs: Date.now() - totalStart, + success: failed.length === 0, + }; + } + + /** + * Discover running host services via PID files. + */ + private async discoverHostServices(): Promise { + try { + return await getOurServices(); + } catch { + this.logger.debug('No running services found (PID directory may not exist)'); + return []; + } + } +}