- 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.
350 lines
17 KiB
Markdown
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)
|