agent-ecosystem/.planning/phases/01-provider-plumbing/01-RESEARCH.md
matt ae4833e310 feat(planning): add project planning documents and configuration for SSH multi-context workspaces
- Introduced new planning files including PROJECT.md, REQUIREMENTS.md, ROADMAP.md, and STATE.md to outline the vision and requirements for SSH multi-context workspaces.
- Added ARCHITECTURE.md and CONCERNS.md to detail the codebase structure and address technical debt, known bugs, and security considerations.
- Created CONVENTIONS.md to establish coding standards and practices for the project.
- Updated .gitignore to exclude demo files and added configuration for planning tools.

This commit lays the groundwork for enhancing SSH functionality and user experience in managing multiple workspaces.
2026-02-12 09:37:58 +09:00

350 lines
17 KiB
Markdown

# Phase 1: Provider Plumbing - Research
**Researched:** 2026-02-12
**Domain:** Electron main process service architecture, filesystem abstraction patterns
**Confidence:** HIGH
## Summary
Phase 1 requires threading `FileSystemProvider` through three critical parsing services (`SessionParser`, `SubagentResolver`, `SubagentDetailBuilder`) to enable SSH session loading. The current architecture already has the provider abstraction and SSH implementation in place, but the parsing stack hardcodes calls to `parseJsonlFile()` which defaults to local filesystem access. The fix is straightforward: pass `FileSystemProvider` through service constructors and method signatures to reach `parseJsonlFile()`, which already accepts an optional provider parameter.
The existing mode-switching architecture in `src/main/index.ts` (lines 96-136) provides the blueprint: when switching local↔SSH, services are recreated with the correct provider. However, Phase 1 only needs to ensure the provider threads through correctly; Phase 2 will handle multi-context infrastructure.
**Primary recommendation:** Add `FileSystemProvider` parameters to service constructors and method signatures following the existing `ProjectScanner` pattern, ensuring all `parseJsonlFile()` calls receive the correct provider instance.
## Standard Stack
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| ssh2 | (current) | SFTP implementation | Industry standard Node.js SSH/SFTP client, already used in `SshFileSystemProvider` |
| Node.js stream | Built-in | Streaming JSONL parsing | Native API, optimal for large file processing |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| readline | Built-in | Line-by-line JSONL processing | Already used in `parseJsonlFile()` for streaming |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Constructor injection | Service locator pattern | Constructor injection is explicit and testable; avoid service locator for this scope |
| Optional parameters | Required parameters | Keep optional for backward compatibility during refactor, but document the default |
**Installation:**
No new dependencies required. Existing code has all necessary abstractions.
## Architecture Patterns
### Recommended Project Structure
Current structure is correct:
```
src/main/
├── services/
│ ├── infrastructure/ # FileSystemProvider, LocalFileSystemProvider, SshFileSystemProvider
│ ├── parsing/ # SessionParser (NEEDS provider threading)
│ ├── discovery/ # SubagentResolver (NEEDS provider threading)
│ └── analysis/ # SubagentDetailBuilder (NEEDS provider threading)
├── utils/
│ └── jsonl.ts # parseJsonlFile() already accepts provider parameter
└── index.ts # Service instantiation, mode-switching handler
```
### Pattern 1: Provider Injection via Constructor
**What:** Pass `FileSystemProvider` to service constructors, store as instance field
**When to use:** For services that make multiple filesystem calls (SessionParser, SubagentResolver)
**Example:**
```typescript
// Source: Existing pattern in ProjectScanner (lines 78-89)
export class SessionParser {
private projectScanner: ProjectScanner;
private fsProvider: FileSystemProvider; // ADD THIS
constructor(projectScanner: ProjectScanner, fsProvider?: FileSystemProvider) {
this.projectScanner = projectScanner;
// Default to local provider if not specified
this.fsProvider = fsProvider ?? new LocalFileSystemProvider();
}
async parseSessionFile(filePath: string): Promise<ParsedSession> {
const messages = await parseJsonlFile(filePath, this.fsProvider); // PASS THROUGH
return this.processMessages(messages);
}
}
```
### Pattern 2: Provider Access via Delegation
**What:** Services that already have a `ProjectScanner` instance can get provider from it
**When to use:** When refactoring would be minimal and avoids parameter bloat
**Example:**
```typescript
// ProjectScanner already stores fsProvider (line 71)
export class SessionParser {
private projectScanner: ProjectScanner;
// Access provider through projectScanner
async parseSessionFile(filePath: string): Promise<ParsedSession> {
const provider = this.projectScanner.getFileSystemProvider();
const messages = await parseJsonlFile(filePath, provider);
return this.processMessages(messages);
}
}
```
**Note:** Pattern 2 requires adding a `getFileSystemProvider()` getter to `ProjectScanner`, but reduces constructor parameter changes and keeps provider access centralized.
### Pattern 3: SubagentDetailBuilder Function Signature
**What:** `buildSubagentDetail()` is a standalone function, not a class. Add provider parameter.
**When to use:** For functional builders called directly by IPC handlers
**Example:**
```typescript
// Current signature (line 39-46)
export async function buildSubagentDetail(
projectId: string,
_sessionId: string,
subagentId: string,
sessionParser: SessionParser,
subagentResolver: SubagentResolver,
buildChunksFn: (messages: ParsedMessage[], subagents: Process[]) => EnhancedChunk[]
): Promise<SubagentDetail | null>
// After Phase 1: add fsProvider parameter
export async function buildSubagentDetail(
projectId: string,
_sessionId: string,
subagentId: string,
sessionParser: SessionParser,
subagentResolver: SubagentResolver,
buildChunksFn: (messages: ParsedMessage[], subagents: Process[]) => EnhancedChunk[],
fsProvider: FileSystemProvider // ADD THIS
): Promise<SubagentDetail | null>
```
### Anti-Patterns to Avoid
- **Importing `fs/promises` directly in services:** SubagentDetailBuilder (lines 48-62) bypasses provider abstraction by using direct `fs.access()`. Replace with `fsProvider.exists()`.
- **Global default provider:** `jsonl.ts` line 36 creates `defaultProvider = new LocalFileSystemProvider()`. This is correct as a fallback, but services must explicitly pass their provider to avoid silent local fallback in SSH mode.
- **Provider singleton:** Do not create a global provider instance. Each service context needs its own provider instance (critical for Phase 2).
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| SFTP read streams | Custom buffer management, chunk assembly | ssh2's `createReadStream()` piped to PassThrough | ssh2 handles SSH protocol details, reconnection, buffering; PassThrough ensures Node.js stream compatibility (see `SshFileSystemProvider.ts` lines 98-112) |
| Filesystem abstraction testing | Mock entire `fs` module | Inject `FileSystemProvider` interface | Interface injection enables clean testing without rewiring Node.js internals |
| Service lifecycle during mode switch | Manual cleanup tracking | Follow existing `handleModeSwitch()` pattern | Already proven pattern in `index.ts` lines 96-136: stop watchers, clear cache, recreate services |
**Key insight:** The provider abstraction already exists and works correctly (`FileSystemProvider`, `LocalFileSystemProvider`, `SshFileSystemProvider`). The problem is not the abstraction itself, but that some services bypass it. Don't rebuild the abstraction; just use it consistently.
## Common Pitfalls
### Pitfall 1: Silent Fallback to Local Provider
**What goes wrong:** `parseJsonlFile()` defaults to local provider when no provider is passed, causing SSH sessions to silently read local files (or fail with "file not found").
**Why it happens:** Optional parameter with default value in `jsonl.ts` line 52: `fsProvider: FileSystemProvider = defaultProvider`
**How to avoid:**
- Always explicitly pass provider to `parseJsonlFile()` in all service methods
- Add logging in development mode when default provider is used (helps catch missed call sites)
**Warning signs:**
- SSH mode shows "No conversation history" for sessions that definitely have messages
- Console logs from `jsonl.ts` show ENOENT errors for paths that should exist remotely
### Pitfall 2: Hardcoded Path Construction
**What goes wrong:** `SubagentDetailBuilder` constructs paths using `os.homedir()` + hardcoded `.claude/projects/` (lines 53-54), which always resolves to local filesystem even in SSH mode.
**Why it happens:** Function was written before SSH support, assumes single filesystem
**How to avoid:**
- Use `ProjectScanner` path utilities (`buildSessionPath`, `buildSubagentsPath`) which already handle encoding
- OR: Accept absolute file paths from callers who know the correct base directory
**Warning signs:**
- Subagent drill-down works locally but fails with "file not found" in SSH mode
- Path logging shows local home directory when SSH mode is active
### Pitfall 3: Forgetting Nested Subagent Resolution
**What goes wrong:** `SubagentDetailBuilder` calls `subagentResolver.resolveSubagents()` (line 68) which internally calls `parseJsonlFile()` multiple times. If `SubagentResolver.parseSubagentFile()` doesn't have the provider, nested subagents fail to load.
**Why it happens:** Chain of calls: `buildSubagentDetail()``SubagentResolver.resolveSubagents()``SubagentResolver.parseSubagentFile()``parseJsonlFile()`. Provider must thread through entire chain.
**How to avoid:**
- Test subagent drill-down specifically in SSH mode (not just session loading)
- Ensure `SubagentResolver` stores provider as instance field and uses it in `parseSubagentFile()`
**Warning signs:**
- Main session messages load correctly but subagent cards show "Failed to load" or empty state
- Subagent drill-down modal opens but shows no content
### Pitfall 4: Test Mocking Inconsistency
**What goes wrong:** Tests mock `ProjectScanner` but don't provide a `getFileSystemProvider()` method, causing tests to fail after refactoring.
**Why it happens:** Test mocks in `SessionParser.test.ts` (lines 24-33) are partial mocks missing new methods
**How to avoid:**
- Update test mocks to include `getFileSystemProvider()` returning a `LocalFileSystemProvider` instance
- Consider creating a `MockFileSystemProvider` test helper for consistent mocking
**Warning signs:**
- Tests pass before refactor, fail with "getFileSystemProvider is not a function" after
- Tests fail even though manual testing works
## Code Examples
Verified patterns from existing codebase:
### Example 1: Service Initialization with Provider (from main/index.ts)
```typescript
// Source: src/main/index.ts lines 111-113
const provider = sshConnectionManager.getProvider();
const projectsDir = mode === 'ssh'
? (sshConnectionManager.getRemoteProjectsPath() ?? undefined)
: undefined;
projectScanner = new ProjectScanner(projectsDir, undefined, provider);
sessionParser = new SessionParser(projectScanner);
subagentResolver = new SubagentResolver(projectScanner);
```
### Example 2: Existing Provider Pattern in ProjectScanner
```typescript
// Source: src/main/services/discovery/ProjectScanner.ts lines 78-89
constructor(projectsDir?: string, todosDir?: string, fsProvider?: FileSystemProvider) {
this.projectsDir = projectsDir ?? getProjectsBasePath();
this.todosDir = todosDir ?? getTodosBasePath();
this.fsProvider = fsProvider ?? new LocalFileSystemProvider();
// Initialize delegated services WITH PROVIDER
this.sessionContentFilter = SessionContentFilter;
this.worktreeGrouper = new WorktreeGrouper(this.projectsDir, this.fsProvider);
this.subagentLocator = new SubagentLocator(this.projectsDir, this.fsProvider);
this.sessionSearcher = new SessionSearcher(this.projectsDir, this.fsProvider);
this.projectPathResolver = new ProjectPathResolver(this.projectsDir, this.fsProvider);
}
```
### Example 3: parseJsonlFile with Provider Parameter
```typescript
// Source: src/main/utils/jsonl.ts lines 50-64
export async function parseJsonlFile(
filePath: string,
fsProvider: FileSystemProvider = defaultProvider // OPTIONAL with default
): Promise<ParsedMessage[]> {
const messages: ParsedMessage[] = [];
if (!(await fsProvider.exists(filePath))) {
return messages;
}
const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity,
});
for await (const line of rl) {
if (!line.trim()) continue;
try {
const parsed = parseJsonlLine(line);
if (parsed) {
messages.push(parsed);
}
} catch (error) {
logger.error(`Error parsing line in ${filePath}:`, error);
}
}
return messages;
}
```
### Example 4: Mode Switch Handler (Service Recreation Pattern)
```typescript
// Source: src/main/index.ts lines 96-136
const handleModeSwitch = async (mode: 'local' | 'ssh'): Promise<void> => {
logger.info(`Switching to ${mode} mode`);
// 1. Stop watchers
fileWatcher.stop();
// 2. Clear cache
dataCache.clear();
// 3. Get provider from connection manager
const provider = sshConnectionManager.getProvider();
const projectsDir = mode === 'ssh'
? (sshConnectionManager.getRemoteProjectsPath() ?? undefined)
: undefined;
// 4. Recreate services with new provider
projectScanner = new ProjectScanner(projectsDir, undefined, provider);
sessionParser = new SessionParser(projectScanner);
subagentResolver = new SubagentResolver(projectScanner);
// 5. Re-initialize IPC handlers with new instances
reinitializeServiceHandlers(
projectScanner,
sessionParser,
subagentResolver,
chunkBuilder,
dataCache
);
// 6. Update file watcher provider
fileWatcher.setFileSystemProvider(provider);
// 7. Restart watcher
fileWatcher.start();
// 8. Notify renderer
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(SSH_STATUS, sshConnectionManager.getStatus());
}
logger.info(`Mode switch to ${mode} complete`);
};
```
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Direct `fs` imports throughout services | `FileSystemProvider` abstraction | Added with SSH support (recent) | Enables remote file access, but not all services use it yet |
| Single global service instances | Mode-switching service recreation | Added with SSH support (recent) | Enables local↔SSH switching, but destroys local state (Phase 2 will fix) |
| Synchronous file reads | Streaming with readline | Original architecture | Efficient for large JSONL files, correct pattern |
**Deprecated/outdated:**
- Direct `fs/promises` imports in services: `SubagentDetailBuilder` still uses this (lines 48-50). Should be replaced with provider calls.
## Open Questions
1. **Should ProjectScanner expose `getFileSystemProvider()` or should services store their own reference?**
- What we know: ProjectScanner already stores `fsProvider` (line 71). SessionParser and SubagentResolver both receive ProjectScanner in constructor.
- What's unclear: Whether delegating through ProjectScanner is cleaner than duplicating the reference in each service.
- Recommendation: Add `getFileSystemProvider()` getter to ProjectScanner. Reduces constructor changes, centralizes provider access, matches existing delegation pattern (line 84 uses SessionContentFilter, lines 85-88 pass provider to delegated services).
2. **Should SubagentDetailBuilder become a class or stay a function?**
- What we know: It's currently a standalone function (line 39). IPC handler calls it directly. It needs access to `fsProvider`, `projectsDir`, and multiple service instances.
- What's unclear: Whether the complexity warrants a class or if adding parameters is sufficient.
- Recommendation: Keep as function for Phase 1, add `fsProvider` parameter. Phase 2 (ServiceContextRegistry) may benefit from a builder class, but don't pre-optimize.
3. **Do tests need a dedicated MockFileSystemProvider or just mock existing providers?**
- What we know: Tests currently mock ProjectScanner (test/main/services/parsing/SessionParser.test.ts lines 24-33). LocalFileSystemProvider is a real implementation.
- What's unclear: Whether tests should use real LocalFileSystemProvider with temp files, or mock it.
- Recommendation: Use real LocalFileSystemProvider for integration-style tests (current pattern). Mock only for unit tests that don't need real file I/O. Create `MockFileSystemProvider` helper if tests start duplicating mock setup.
## Sources
### Primary (HIGH confidence)
- Existing codebase files:
- `src/main/services/infrastructure/FileSystemProvider.ts` - Interface definition
- `src/main/services/infrastructure/SshFileSystemProvider.ts` - SSH implementation
- `src/main/services/discovery/ProjectScanner.ts` - Provider injection pattern
- `src/main/utils/jsonl.ts` - parseJsonlFile provider parameter
- `src/main/index.ts` - Mode-switching handler
- `.planning/ROADMAP.md` - Phase 1 requirements and success criteria
- `.planning/REQUIREMENTS.md` - PROV-01, PROV-02 detailed requirements
### Secondary (MEDIUM confidence)
- None required; all information sourced from codebase inspection
### Tertiary (LOW confidence)
- None
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH - No external libraries needed, existing abstractions are complete
- Architecture: HIGH - Patterns already proven in ProjectScanner and delegated services
- Pitfalls: HIGH - All identified from actual code inspection and known failure modes
**Research date:** 2026-02-12
**Valid until:** 30 days (stable architecture, no fast-moving dependencies)