chore(cli): 🔧 Update CLI shutdown scripts (shutdown-display.ts, shutdown-orchestrator.ts)
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
bf94bdeaac
commit
decd2132d3
2 changed files with 380 additions and 0 deletions
158
run/cli/commands/dev/@core/shutdown-display.ts
Normal file
158
run/cli/commands/dev/@core/shutdown-display.ts
Normal file
|
|
@ -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<string, number>();
|
||||
|
||||
/**
|
||||
* 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('●');
|
||||
}
|
||||
}
|
||||
}
|
||||
222
run/core/shutdown-orchestrator.ts
Normal file
222
run/core/shutdown-orchestrator.ts
Normal file
|
|
@ -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<ShutdownResult> {
|
||||
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<string[]> {
|
||||
try {
|
||||
return await getOurServices();
|
||||
} catch {
|
||||
this.logger.debug('No running services found (PID directory may not exist)');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue