# @bluecadet/launchpad-controller

[github](https://github.com/bluecadet/launchpad/tree/main/packages/controller) · [npm](https://www.npmjs.com/package/@bluecadet/launchpad-controller) · [changelog](https://github.com/bluecadet/launchpad/blob/main/packages/controller/CHANGELOG.md)

The controller package is the orchestration layer every other subsystem plugs into. It owns the plugin lifecycle, routes commands, and carries the event bus that content, monitor, scheduler, and observability all communicate over.

## Configuration

- `pidFile`

  **Input type:** `string`

  **Required:** No

  **Input default:** `".launchpad/launchpad.pid"`

  Daemon PID file path. Relative paths resolve from the config file directory.

- `socketPath`

  **Input type:** `string`

  **Required:** No

  **Input default:** `".launchpad/launchpad.sock"`

  IPC socket path. Relative paths resolve from the config file directory.

- `logging`

  **Input type:** `object`

  **Required:** No

  **Input default:** `{}`

  Controller-owned file logging configuration.

These are input defaults; logging has its own nested defaults. See [Controller Logging](/reference/controller/logging/) for the full logging reference and [configuration reference conventions](/reference/cli/config-loading/#configuration-reference-conventions) for default and requiredness notation.

## Integration with Subsystems

Plugins integrate with the controller through two complementary contracts:

**Plugin properties** — capabilities the plugin *offers* to the controller:

```typescript
type InstantiatedPlugin<TCommand> = Partial<
  CommandExecutor<TCommand> &  // Execute commands
  Readyable &                  // Act once every plugin is set up
  Disconnectable               // Clean disconnect
>;
```

**Plugin config** — controller-owned metadata and lifecycle hooks:

```typescript
interface PluginConfig<TCommand, TState> {
  name: string;
  manifest?: PluginManifest<TCommand>;
  setup(ctx: PluginContext<TState>): ResultAsync<InstantiatedPlugin<TCommand>, Error>;
  summarize?(state: LaunchpadState): Section | null;
}
```

**PluginContext** — infrastructure the controller *provides* to the plugin:

```typescript
interface PluginContext<TState = unknown> {
  eventBus: EventBus;          // Type-safe event bus
  logger: Logger;              // Scoped logger
  logSource?: LoggerSource;    // Controller-owned canonical log source
  abortSignal: AbortSignal;    // Cancelled on controller shutdown
  cwd: string;                 // Working directory
  mode: ControllerMode;        // "task" | "persistent" — which mode the controller is running in
  getStatusSnapshot: () => StatusSnapshot; // Aggregates every plugin's summarize(); use sparingly
  dispatchCommand: (command) => ResultAsync<unknown, Error>;
  updateState: (producer: (draft: TState) => void) => void;
  getGlobalState: () => VersionedLaunchpadState;
  onGlobalStatePatch: (handler) => () => void;
}
```

### Explicit Command Registration

Plugins that handle commands must declare them in `manifest.commands` and implement `executeCommand()`:

```typescript
interface CommandExecutor<TCommand> {
  executeCommand(command: TCommand): ResultAsync<unknown, Error>;
}

definePlugin({
  name: 'my-plugin',
  manifest: {
    commands: [{ id: 'my-plugin.increment' }],
  },
  setup(ctx: PluginContext<MyState>) {
    ctx.updateState(() => ({ count: 0 }));

    return okAsync({
      executeCommand(command) {
        ctx.updateState(draft => { draft.count++; });
        return okAsync(undefined);
      }
    });
  }
});
```

The controller only dispatches commands that have been explicitly registered. Launchpad no longer infers command ownership from command name prefixes.

### Logging

The controller owns canonical segmented JSONL, an optional human-readable text view, rotation, retention, and the per-destination checkpoint store. `PluginContext.logSource` exposes the source when available; its `flush` and `createReader` methods and each reader’s `read`, `ack`, and `close` methods return `ResultAsync` so callers handle failures explicitly. See [Controller Logging](/reference/controller/logging/) for configuration, file-layout migration, locking, and failure behavior.

### Process ownership

Only one controller may be active for a configured project identity, whether it runs in task or persistent mode. Construction does not open files or acquire ownership. The first `registerPlugin()` or `start()` acquires an operating-system lease at `<resolved pidFile>.lock` before initializing file logging or running plugin setup. Contention, filesystem errors, and unavailable native locking return an error through the lifecycle method’s `ResultAsync`; the constructor does not throw for these failures.

Overlapping calls to `start()` share one startup attempt. If startup fails, the controller aborts its signal, waits for registered plugins to disconnect, and closes logging before releasing ownership. `stop()` performs the same resource cleanup even if plugins were registered before startup or registration failed. When startup is still pending, `stop()` waits for that attempt before shutting down. The `stop` workflow runs only for a started controller.

Ownership is released only after plugin shutdown and physical logging I/O settle. Always call and await `stop()` when disposing a controller, including after failed registration. The logging-close wait is bounded: a timeout emits a warning and lets `stop()` finish, but any pending canonical or checkpoint I/O continues to hold both the project and log-directory leases. A successor’s `start()` or `registerPlugin()` therefore returns a contention error until that I/O settles; shutdown does not cancel it or release ownership early. A stopped controller cannot be restarted or accept new plugins; construct a new controller for another run. If the process crashes, the operating system releases the leases automatically.

The adjacent PID file is persistent-daemon discovery metadata, not the ownership authority. It remains absent for task runs. The lease file is permanent and must not be deleted, renamed, or replaced; its presence does not mean a controller is active.

This closes startup races that a PID existence check cannot prevent. Concurrent task commands do not run a second local controller, and a task controller cannot overlap a persistent controller that uses the same resolved `pidFile` identity.

### Host-Owned Workflows

Hosts declare startup and shutdown orchestration in config and hand it to the controller:

```typescript
export default defineConfig({
  plugins: [content({}), monitor({})],
  workflows: {
    start: ['content.fetch', 'monitor.connect', 'monitor.start'],
  },
});
```

The controller exposes `setWorkflows()` and `runWorkflow()` so every host can run the same named workflow sequence. `LaunchpadController.stop()` automatically runs the `stop` workflow before plugin disconnects.

#### Step failure handling

Workflows run every step **best-effort**. If a step fails, the controller records the error, emits `workflow:step:error`, and continues with the remaining steps. After all steps run, the workflow reports an aggregated failure (`workflow:error`) if any step errored.

This means a failed `content.fetch` no longer prevents `monitor.start` from launching apps — content fetching stages its output before promoting it, so the previously-published content remains on disk and the monitor runs against the last good content.

`launchpad start` treats the aggregated failure the same way: it logs the errors and keeps the controller running, so a failed workflow step never takes down apps that started successfully.

To make a step fatal — halting the workflow and skipping the remaining steps when it fails — wrap it in an object with `stopOnError`:

```typescript
export default defineConfig({
  workflows: {
    // 'publish' is skipped if 'build' fails
    deploy: [{ step: 'build', stopOnError: true }, 'publish'],
  },
});
```

### State Management

Plugins own their domain logic; the controller owns the state infrastructure. Plugins call `ctx.updateState()` to establish and mutate their state slice — the controller lazily creates a scoped store on first call and handles patch generation, versioning, and broadcasting.

To read the full aggregated state (all plugins + system), use `ctx.getGlobalState()`. Prefer `ctx.eventBus` or `ctx.dispatchCommand` for cross-plugin communication over polling global state.

### Status Snapshots

Plugins can contribute to `launchpad status` by adding a `summarize(state)` function to their plugin config. The controller calls every registered plugin’s `summarize()` hook, drops `null` results, sorts the returned sections by `order`, and sends the resulting status snapshot over IPC.

```typescript
import type { LaunchpadState, Section } from '@bluecadet/launchpad-utils/types';

definePlugin({
  name: 'my-plugin',
  setup(ctx: PluginContext<MyState>) {
    ctx.updateState(() => ({ ready: false }));
    return okAsync({});
  },
  summarize(state: LaunchpadState): Section | null {
    const pluginState = state.plugins['my-plugin'] as MyState | undefined;
    if (!pluginState) return null;

    return {
      name: 'my-plugin',
      title: 'My Plugin',
      rows: [{ type: 'kv', label: 'Ready', value: pluginState.ready ? 'Yes' : 'No' }],
    };
  },
});
```

### Transports

Transports are plugins that expose the command bus and event bus over a wire protocol — the IPC socket used by the CLI, and the [HTTP/SSE transport](/reference/controller/transports/) for browsers and Unity/.NET consumers. See [Transports](/reference/controller/transports/) for the HTTP/SSE surface.

### Readyable

Plugin `setup()` runs sequentially in registration order, so anything a plugin emits during its own setup is invisible to plugins registered after it. Plugins with startup work that depends on the rest of the system — announcing state on the event bus, for example — implement `ready()` instead.

```typescript
interface Readyable {
  ready(): ResultAsync<void, Error>;
}
```

The host calls `LaunchpadController.ready()` once it has finished registering plugins; the controller then calls `ready()` on every registered plugin in registration order. A `ready()` that fails or throws is logged and contained — it neither fails startup nor blocks another plugin’s `ready()`. Repeat calls are no-ops, and a plugin registered after the phase has run is readied right after its `setup()`, so every plugin sees the hook exactly once.

### Disconnectable

Plugins that manage long-lived resources (connections, child processes) implement `disconnect()`. It is called *after* `abortSignal` is fired, so in-flight async work is already cancelled by the time `disconnect()` runs.

```typescript
interface Disconnectable {
  disconnect(reason: DisconnectReason): ResultAsync<void, Error>;
}
```

## Usage Modes

### Task Mode

Ephemeral controller instances for one-off operations:

1. Create controller in task mode
2. Start controller
3. Register subsystems
4. Run the ready phase (`ready()`)
5. Execute command(s)
6. Stop controller

This mode is used when no persistent controller is running. It uses the same project ownership lease as persistent mode, so overlapping task runs fail instead of mutating project resources concurrently.

### Persistent Mode

Long-running controller that stays active to handle multiple commands:

- Started with `launchpad start` (optionally detached with `-d`)
- Registers plugins, then runs the ready phase (`ready()`) once all of them are set up — this is where, for example, the content plugin’s startup announcement feeds the HTTP/SSE transport’s replay backlog
- Opens IPC socket for inter-process communication
- Holds the project ownership lease and stores PID metadata for discovery
- Handles multiple CLI commands without reinitializing
- Gracefully shuts down with `launchpad stop`

Subsequent `launchpad` commands (content, monitor, status) detect the running controller and communicate with it via IPC instead of creating ephemeral instances.

#### Benefits

- **Faster command execution**: No initialization overhead for subsequent commands
- **Shared state**: All commands operate against the same controller instance
- **Detached mode**: Can run in background with `-d` flag
- **IPC protocol**: Type-safe command execution and state queries
- **Graceful shutdown**: Multi-stage shutdown with IPC fallbacks (SIGTERM → SIGKILL)

## Type Safety

The controller uses TypeScript declaration merging against `@bluecadet/launchpad-utils/types` to provide type-safe events without circular dependencies:

```typescript
// The controller declares system events
declare module '@bluecadet/launchpad-utils/types' {
  interface LaunchpadEvents {
    'system:shutdown': { code?: number; signal?: string };
    'content:fetch:start': { timestamp: Date };
    'monitor:app:started': { appName: string; pid: number };
  }
}
```
