spotlight icon indicating copy to clipboard operation
spotlight copied to clipboard

Spotlight Control Center

Open BYK opened this issue 1 month ago • 0 comments

Spotlight Control Center

Overview

Track all spotlight run instances with registry + real-time pings. Users can view, switch between, and manage instances from any Spotlight UI (default port 8969) or via spotlight list CLI.

Key Design Decisions

  • Registry only for spotlight run: Electron and default port instances don't register
  • Single Control Center port: Always 8969 (no multi-port discovery needed)
  • Ping to default port only: New instances ping localhost:8969
  • Reuse existing SSE: Ping events via existing /stream connection
  • Robust staleness check: Healthcheck endpoint + PID verification with start time
  • No state persistence: Start fresh when switching (can add later with IndexedDB)

1. Instance Registry

Location: /tmp/spotlight-$USER/instances/

Metadata: instance_$UUID.json

{
  "instanceId": "uuid-v7",
  "port": 54321,
  "pid": 12345,
  "pidStartTime": 1700000000000,
  "childPid": 12346,
  "childPidStartTime": 1700000001000,
  "command": "npm run dev",
  "cmdArgs": ["npm", "run", "dev"],
  "cwd": "/path/to/project",
  "startTime": "2024-11-20T10:30:00.000Z",
  "projectName": "my-app",
  "detectedType": "package.json|docker-compose"
}

Note: pidStartTime and childPidStartTime prevent false positives from PID reuse (PIDs can be recycled after process termination).

2. Instance Registration (cli/run.ts)

On Startup:

  1. Generate instanceId with uuidv7()

  2. Get process start times using pidusage:

    import pidusage from 'pidusage';
    const stats = await pidusage(process.pid);
    const pidStartTime = stats.timestamp - stats.elapsed;
    
  3. Write metadata atomically (temp file + rename)

  4. Store spotlight PID + child PID with their start times

  5. Ping Control Center:

    • POST http://localhost:8969/api/instances/ping
    • Timeout: 500ms
    • Fire-and-forget (don't block if not running)
  6. Register cleanup to remove metadata on exit

3. Registry Manager (registry/manager.ts)

class InstanceRegistry {
  register(metadata: InstanceMetadata): void
  unregister(instanceId: string): void
  list(): Promise<InstanceInfo[]>  // with health check
  cleanup(): Promise<void>          // remove stale
  terminate(instanceId: string): Promise<boolean>
}

Health Check (3-tier verification):

async function checkInstanceHealth(instance: InstanceMetadata): HealthStatus {
  // 1. Try healthcheck endpoint first (fastest if responsive)
  try {
    const response = await fetch(`http://localhost:${instance.port}/health`, {
      signal: AbortSignal.timeout(1000)
    });
    if (response.ok) return 'healthy';
  } catch {}
  
  // 2. Verify PIDs with start time (handles PID reuse)
  const spotlightAlive = await isPIDValid(instance.pid, instance.pidStartTime);
  const childAlive = await isPIDValid(instance.childPid, instance.childPidStartTime);
  
  if (spotlightAlive && childAlive) return 'unresponsive'; // Processes alive but not responding
  if (!spotlightAlive && !childAlive) return 'dead';       // Both dead - clean up
  if (spotlightAlive && !childAlive) return 'dead';        // Shouldn't happen (spotlight exits with child)
  if (!spotlightAlive && childAlive) return 'orphaned';    // Child orphaned - spotlight crashed
}

async function isPIDValid(pid: number, expectedStartTime: number): Promise<boolean> {
  try {
    const stats = await pidusage(pid);
    const actualStartTime = stats.timestamp - stats.elapsed;
    // Allow 1s tolerance for timing differences
    return Math.abs(actualStartTime - expectedStartTime) < 1000;
  } catch {
    return false; // Process doesn't exist
  }
}

Status meanings:

  • healthy: Healthcheck responded
  • unresponsive: Processes alive but healthcheck timeout (hung?)
  • dead: Process(es) terminated
  • orphaned: Child process alive but spotlight crashed

Terminate (cross-platform):

  • Kill spotlight PID and child PID using process.kill()
  • Remove metadata file
  • Use SIGTERM on Unix, default on Windows

Dependencies:

  • Add pidusage package for cross-platform process info

4. CLI List Command (cli/list.ts)

spotlight list [-f format] [--all]

All Formatters Supported:

human (default):

2024-11-20 10:30:45 [INFO] [spotlight] my-app@54321 (npm run dev) - http://localhost:54321

Uses formatLogLine() from existing human formatter

json:

[{"instanceId":"uuid-1","projectName":"my-app","port":54321,...}]

logfmt:

instanceId=uuid-1 projectName=my-app port=54321 command="npm run dev"...

md:

| Project | Port | Command | Started | PID | URL | Status |

Options:

  • -f, --format: Output format
  • --all: Include unresponsive/orphaned instances

5. Sidecar API (routes/instances.ts)

GET /api/instances
  // List all with health check + cleanup
  // Returns: InstanceInfo[]

POST /api/instances/ping
  // Receive ping from new instance
  // Body: InstanceMetadata
  // Returns: 204 No Content
  // Broadcasts via existing SSE

POST /api/instances/:id/terminate
  // Terminate instance
  // Returns: 200 OK or 404

GET /api/instances/current
  // Get current instance metadata
  // Returns: InstanceMetadata

Ping Broadcast (reuses existing SSE):

// When /api/instances/ping receives request:
const container = new EventContainer(
  'spotlight/instance-ping',
  Buffer.from(JSON.stringify(metadata))
);
getBuffer().put(container);

// UI already subscribes to /stream
// Just add handler for 'spotlight/instance-ping' event type

6. Control Center UI (ui/control-center/)

Components:

ControlCenter.tsx       # Main container
InstanceList.tsx        # List of instances  
InstanceCard.tsx        # Individual instance
ConnectionSwitcher.tsx  # Switch instances
store/instancesSlice.ts # Zustand slice

Update Strategy:

  • Mount: Fetch /api/instances
  • Real-time: Handle spotlight/instance-ping from existing SSE
  • Cleanup: Re-fetch every 10s to remove stale

Uses Existing SSE Connection!

No new connection needed. Reuses same /stream that handles errors/traces/logs.

Features:

  • Instance cards (port, command, uptime, status)
  • Status badges: healthy (green), unresponsive (yellow), orphaned (orange)
  • Connect button → switch instances
  • Terminate button (with confirmation)
  • Search/filter
  • Instance count badge in nav

7. Connection Switching (lib/connectionManager.ts)

No State Persistence:

Always start fresh when switching instances.

Switch Flow:

1. Disconnect from current sidecar
2. Clear/reset store state
3. Update sidecar URL to target port
4. Reconnect to new sidecar
5. Fetch fresh data from new instance

Simple and clean - just reconnect and start fresh!

8. File Structure

New:

server/registry/manager.ts, types.ts, utils.ts
server/cli/list.ts
server/routes/instances.ts
ui/control-center/[components]
ui/lib/connectionManager.ts

Modified:

server/cli/run.ts       # Add registration + ping
server/cli.ts           # Add list command
ui/App.tsx              # Integrate Control Center
ui/sidecar.ts           # Add handler for instance-ping events
package.json            # Add pidusage dependency

9. Implementation Steps

  1. Add pidusage dependency
  2. Registry infrastructure (manager, health checks with pidusage, terminate)
  3. Registration in cli/run.ts (metadata with start times + ping)
  4. CLI list command (all formatters)
  5. Sidecar API (endpoints + ping broadcast)
  6. UI store + connection manager (no persistence)
  7. Control Center UI components
  8. Cross-platform testing

Technical Notes

Security: Registry dir 0700, validate inputs, own instances only

Cross-platform: pidusage handles Linux/macOS/Windows differences

Performance: 1s healthcheck timeout, 500ms ping timeout, cache 10s

Errors: Graceful fallbacks, skip corrupted files, user-friendly UI

PID Reuse: Solved via start time verification with pidusage

BYK avatar Nov 20 '25 10:11 BYK