@bluecadet/launchpad-controller
on this page
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
pidFileInput type:
stringRequired: No
Input default:
".launchpad/launchpad.pid"Daemon PID file path. Relative paths resolve from the config file directory.
socketPathInput type:
stringRequired: No
Input default:
".launchpad/launchpad.sock"IPC socket path. Relative paths resolve from the config file directory.
loggingInput type:
objectRequired: No
Input default:
{}Controller-owned file logging configuration.
These are input defaults; logging has its own nested defaults. See Controller Logging for the full logging reference and 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:
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:
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:
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():
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 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:
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:
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.
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 for browsers and Unity/.NET consumers. See 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.
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.
interface Disconnectable {
disconnect(reason: DisconnectReason): ResultAsync<void, Error>;
}Usage Modes
Task Mode
Ephemeral controller instances for one-off operations:
- Create controller in task mode
- Start controller
- Register subsystems
- Run the ready phase (
ready()) - Execute command(s)
- 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
-dflag - 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:
// 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 };
}
}