agent-ecosystem/.planning/phases/02-service-infrastructure/02-03-PLAN.md
matt 85f34eb828 docs(02-service-infrastructure): create phase plan (3 plans, 3 waves)
Plan 01: ServiceContext + ServiceContextRegistry + dispose() methods
Plan 02: Wire registry into main/index.ts + update IPC routing
Plan 03: Context management IPC + preload bridge + connection profiles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 00:49:19 +00:00

299 lines
13 KiB
Markdown

---
phase: 02-service-infrastructure
plan: 03
type: execute
wave: 3
depends_on: ["02-02"]
files_modified:
- src/main/ipc/context.ts
- src/main/ipc/handlers.ts
- src/preload/constants/ipcChannels.ts
- src/preload/index.ts
- src/shared/types/api.ts
- src/main/services/infrastructure/ConfigManager.ts
autonomous: true
must_haves:
truths:
- "Renderer can list all available contexts (local + SSH workspaces)"
- "Renderer can request a context switch and receive confirmation"
- "Renderer can get the current active context ID"
- "Context change events propagate to renderer via IPC"
- "SSH connection profiles are persisted in ConfigManager for reconnection"
artifacts:
- path: "src/main/ipc/context.ts"
provides: "Context management IPC handlers"
exports: ["initializeContextHandlers", "registerContextHandlers", "removeContextHandlers"]
- path: "src/preload/constants/ipcChannels.ts"
provides: "Context IPC channel constants"
contains: "CONTEXT_"
- path: "src/preload/index.ts"
provides: "Context API exposed to renderer"
contains: "context:"
- path: "src/shared/types/api.ts"
provides: "Context API type definitions on ElectronAPI"
contains: "context"
key_links:
- from: "src/preload/index.ts"
to: "src/main/ipc/context.ts"
via: "IPC invoke for context operations"
pattern: "ipcRenderer\\.invoke.*CONTEXT_"
- from: "src/main/ipc/context.ts"
to: "src/main/services/infrastructure/ServiceContextRegistry.ts"
via: "calls registry methods"
pattern: "registry\\.(list|switch|getActiveContextId)"
- from: "src/main/services/infrastructure/ConfigManager.ts"
to: "src/shared/types/api.ts"
via: "connection profiles stored in config"
pattern: "sshProfiles"
---
<objective>
Create the context management IPC channels, preload bridge, and connection profile persistence so the renderer can query, switch, and manage workspace contexts.
Purpose: This completes the service infrastructure by giving the renderer process the ability to interact with the context system. Without this, the registry exists but the UI has no way to list contexts, trigger switches, or restore saved connections. Connection profiles in ConfigManager enable reconnection without re-entering credentials (Success Criterion 4).
Output: New context IPC handler module, updated preload bridge with context API, connection profiles in ConfigManager config schema.
</objective>
<execution_context>
@.planning/phases/02-service-infrastructure/02-RESEARCH.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/02-service-infrastructure/02-01-PLAN.md
@.planning/phases/02-service-infrastructure/02-02-PLAN.md
@src/main/ipc/handlers.ts
@src/main/ipc/ssh.ts
@src/preload/index.ts
@src/preload/constants/ipcChannels.ts
@src/shared/types/api.ts
@src/main/services/infrastructure/ConfigManager.ts
@src/main/services/infrastructure/ServiceContextRegistry.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: Create context IPC handler and channel constants</name>
<files>
src/main/ipc/context.ts
src/main/ipc/handlers.ts
src/preload/constants/ipcChannels.ts
</files>
<action>
**src/preload/constants/ipcChannels.ts** - Add context channel constants:
```typescript
// Context API Channels
export const CONTEXT_LIST = 'context:list';
export const CONTEXT_GET_ACTIVE = 'context:getActive';
export const CONTEXT_SWITCH = 'context:switch';
export const CONTEXT_CHANGED = 'context:changed'; // main -> renderer event
```
Note: context:connect-ssh and context:disconnect-ssh are NOT needed here — those operations are handled by the existing SSH IPC handlers (ssh:connect, ssh:disconnect) which now internally create/destroy contexts via the registry (done in Plan 02). The context IPC is for listing and switching only.
**src/main/ipc/context.ts** - Create context management handler module:
Follow the existing handler pattern (initialize, register, remove exports):
1. Module state:
```typescript
let registry: ServiceContextRegistry;
```
2. `initializeContextHandlers(reg: ServiceContextRegistry)`:
- Store registry reference
3. `registerContextHandlers(ipcMain: IpcMain)`:
- `CONTEXT_LIST` handler: Returns `registry.list()` — array of `{ id: string; type: 'local' | 'ssh' }`. Wrap in try/catch, return `{ success: true, data: [...] }`.
- `CONTEXT_GET_ACTIVE` handler: Returns `registry.getActiveContextId()`. Wrap in try/catch, return `{ success: true, data: contextId }`.
- `CONTEXT_SWITCH` handler: Takes `contextId: string` argument. Calls `registry.switch(contextId)`. On success return `{ success: true, data: { contextId } }`. On error (context doesn't exist), return `{ success: false, error: message }`.
Important: After switching, the caller (or this handler) should also trigger the `onContextSwitched` callback to re-wire FileWatcher events. Two approaches:
- Store the onContextSwitched callback in this module too
- Have the switch handler emit the CONTEXT_CHANGED event to renderer
Approach: Accept an `onContextSwitched` callback in `initializeContextHandlers` (same pattern as SSH handler). After successful switch, call it with the new active context, and also send CONTEXT_CHANGED event to renderer via mainWindow.
Updated init signature: `initializeContextHandlers(reg: ServiceContextRegistry, onSwitched: (context: ServiceContext) => void)`
In the CONTEXT_SWITCH handler:
```typescript
const { current } = registry.switch(contextId);
onSwitched(current);
// mainWindow notification happens inside onSwitched callback
return { success: true, data: { contextId } };
```
4. `removeContextHandlers(ipcMain: IpcMain)`:
- Remove all three handlers
**src/main/ipc/handlers.ts** - Register the new context handler module:
1. Import: `import { initializeContextHandlers, registerContextHandlers, removeContextHandlers } from './context';`
2. In `initializeIpcHandlers()`, add: `initializeContextHandlers(registry, onContextSwitched)` — the `onContextSwitched` callback needs to be passed through. Update the `initializeIpcHandlers` signature to accept it:
```typescript
export function initializeIpcHandlers(
registry: ServiceContextRegistry,
updater: UpdaterService,
sshManager: SshConnectionManager,
onContextSwitched: (context: ServiceContext) => void,
): void
```
3. Register: `registerContextHandlers(ipcMain);`
4. Remove: `removeContextHandlers(ipcMain);` in `removeIpcHandlers()`
</action>
<verify>
Run `pnpm typecheck` — must pass. Verify context.ts exports the three standard functions. Verify ipcChannels.ts has CONTEXT_LIST, CONTEXT_GET_ACTIVE, CONTEXT_SWITCH, CONTEXT_CHANGED constants.
</verify>
<done>
Context IPC handler created with list, getActive, and switch operations. Channel constants defined. Handler registered in handlers.ts init/register/remove flow. Context switching triggers onContextSwitched callback for event re-wiring.
</done>
</task>
<task type="auto">
<name>Task 2: Expose context API in preload bridge and add connection profiles to ConfigManager</name>
<files>
src/preload/index.ts
src/shared/types/api.ts
src/main/services/infrastructure/ConfigManager.ts
</files>
<action>
**src/shared/types/api.ts** - Add context API types to ElectronAPI:
1. Define `ContextInfo` type:
```typescript
export interface ContextInfo {
id: string;
type: 'local' | 'ssh';
}
```
2. Define `SshConnectionProfile` type (for saved profiles):
```typescript
export interface SshConnectionProfile {
id: string;
name: string;
host: string;
port: number;
username: string;
authMethod: 'password' | 'privateKey' | 'agent' | 'auto';
privateKeyPath?: string;
}
```
3. Add `context` property to `ElectronAPI` interface:
```typescript
context: {
list: () => Promise<ContextInfo[]>;
getActive: () => Promise<string>;
switch: (contextId: string) => Promise<{ contextId: string }>;
onChanged: (callback: (event: unknown, data: ContextInfo) => void) => () => void;
};
```
Note: Check the existing `ElectronAPI` type definition in `src/shared/types/api.ts` to see the exact structure and follow its conventions. The type may be defined there or it may be inferred from the preload implementation. If it's explicitly defined, add the context property. If it's not (i.e., type is inferred from `contextBridge.exposeInMainWorld`), then just update preload/index.ts and the type will follow.
**src/preload/index.ts** - Add context methods to electronAPI:
1. Import new channel constants: `CONTEXT_LIST`, `CONTEXT_GET_ACTIVE`, `CONTEXT_SWITCH`, `CONTEXT_CHANGED`
2. Add `context` namespace to the electronAPI object (alongside existing `ssh`, `config`, `notifications` etc.):
```typescript
context: {
list: async () => {
return invokeIpcWithResult<ContextInfo[]>(CONTEXT_LIST);
},
getActive: async () => {
return invokeIpcWithResult<string>(CONTEXT_GET_ACTIVE);
},
switch: async (contextId: string) => {
return invokeIpcWithResult<{ contextId: string }>(CONTEXT_SWITCH, contextId);
},
onChanged: (callback: (event: unknown, data: { id: string; type: string }) => void) => {
ipcRenderer.on(CONTEXT_CHANGED, callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void);
return () => {
ipcRenderer.removeListener(CONTEXT_CHANGED, callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void);
};
},
},
```
3. Import `ContextInfo` type if needed for type safety (may need to add to the imports from `@shared/types`).
**src/main/services/infrastructure/ConfigManager.ts** - Add connection profiles:
1. Add `SshConnectionProfile` interface (or import from shared types if defined there):
```typescript
export interface SshConnectionProfile {
id: string;
name: string;
host: string;
port: number;
username: string;
authMethod: SshAuthMethod;
privateKeyPath?: string;
}
```
2. Find the SSH config section type in ConfigManager. Currently there's `ssh.lastConnection`. Extend to add `ssh.profiles`:
```typescript
ssh: {
lastConnection: SshLastConnection | null;
profiles: SshConnectionProfile[];
lastActiveContextId: string; // Which context was active when app closed
}
```
3. Add defaults in the default config: `profiles: []`, `lastActiveContextId: 'local'`
4. Add profile management methods to ConfigManager:
- `addSshProfile(profile: SshConnectionProfile): void` — adds to profiles array, saves
- `removeSshProfile(profileId: string): void` — removes by id, saves
- `updateSshProfile(profileId: string, updates: Partial<SshConnectionProfile>): void` — updates, saves
- `getSshProfiles(): SshConnectionProfile[]` — returns profiles array
- `setLastActiveContextId(contextId: string): void` — persists for restore on restart
5. Ensure the config migration handles existing configs that don't have `profiles` or `lastActiveContextId` fields (add them with defaults in the config loading/merge logic).
</action>
<verify>
1. Run `pnpm typecheck` — must pass with zero errors
2. Run `pnpm test` — all existing tests pass (especially config-related tests)
3. Verify preload/index.ts has `context` namespace with list, getActive, switch, onChanged methods
4. Verify ConfigManager has ssh.profiles and ssh.lastActiveContextId with defaults
5. Verify ElectronAPI type includes context property
</verify>
<done>
Renderer can call window.electronAPI.context.list(), context.getActive(), context.switch(id), and context.onChanged(callback). ConfigManager stores SSH connection profiles array and last active context ID for reconnection on restart. All types are properly shared between processes.
</done>
</task>
</tasks>
<verification>
1. `pnpm typecheck` passes with zero errors
2. `pnpm test` — all existing tests pass
3. Context IPC channels exist: context:list, context:getActive, context:switch, context:changed
4. Preload exposes window.electronAPI.context with 4 methods
5. ConfigManager config includes ssh.profiles (array) and ssh.lastActiveContextId (string)
6. ElectronAPI type includes context property definition
</verification>
<success_criteria>
- Renderer process can list all contexts, get active context, switch contexts, and listen for context changes
- SSH connection profiles persisted in ConfigManager for quick reconnection
- Last active context ID persisted for app restart restoration
- All IPC channels follow existing naming and error handling patterns
- No regressions in existing tests or type checking
</success_criteria>
<output>
After completion, create `.planning/phases/02-service-infrastructure/02-03-SUMMARY.md`
</output>