Files
NomadArch/tasks/done/003-process-manager.md
Gemini AI 157449a9ad restore: recover deleted documentation, CI/CD, and infrastructure files
Restored from origin/main (b4663fb):
- .github/ workflows and issue templates
- .gitignore (proper exclusions)
- .opencode/agent/web_developer.md
- AGENTS.md, BUILD.md, PROGRESS.md
- dev-docs/ (9 architecture/implementation docs)
- docs/screenshots/ (4 UI screenshots)
- images/ (CodeNomad icons)
- package-lock.json (dependency lockfile)
- tasks/ (25+ project task files)

Also restored original source files that were modified:
- packages/ui/src/App.tsx
- packages/ui/src/lib/logger.ts
- packages/ui/src/stores/instances.ts
- packages/server/src/server/routes/workspaces.ts
- packages/server/src/workspaces/manager.ts
- packages/server/src/workspaces/runtime.ts
- packages/server/package.json

Kept new additions:
- Install-*.bat/.sh (enhanced installers)
- Launch-*.bat/.sh (new launchers)
- README.md (SEO optimized with GLM 4.7)
2025-12-23 13:03:48 +04:00

431 lines
9.0 KiB
Markdown

# Task 003: OpenCode Server Process Management
## Goal
Implement the ability to spawn, manage, and kill OpenCode server processes from the Electron main process.
## Prerequisites
- Task 001 completed (project setup)
- Task 002 completed (folder selection working)
- OpenCode CLI installed and in PATH
- Understanding of Node.js child_process API
## Acceptance Criteria
- [ ] Can spawn `opencode serve` for a folder
- [ ] Parses stdout to extract port number
- [ ] Returns port and PID to renderer
- [ ] Handles spawn errors gracefully
- [ ] Can kill process on command
- [ ] Captures and forwards stdout/stderr
- [ ] Timeout protection (10 seconds)
- [ ] Process cleanup on app quit
## Steps
### 1. Create Process Manager Module
**electron/main/process-manager.ts:**
**Exports:**
```typescript
interface ProcessInfo {
pid: number
port: number
}
interface ProcessManager {
spawn(folder: string): Promise<ProcessInfo>
kill(pid: number): Promise<void>
getStatus(pid: number): "running" | "stopped" | "unknown"
getAllProcesses(): Map<number, ProcessMeta>
}
interface ProcessMeta {
pid: number
port: number
folder: string
startTime: number
childProcess: ChildProcess
}
```
### 2. Implement Spawn Logic
**spawn(folder: string):**
**Pre-flight checks:**
- Verify `opencode` binary exists in PATH
- Use `which opencode` or `where opencode`
- If not found, reject with helpful error
- Verify folder exists and is directory
- Use `fs.stat()` to check
- If invalid, reject with error
- Verify folder is readable
- Check permissions
- If denied, reject with error
**Process spawning:**
- Use `child_process.spawn()`
- Command: `opencode`
- Args: `['serve', '--port', '0']`
- Port 0 = random available port
- Options:
- `cwd`: The selected folder
- `stdio`: `['ignore', 'pipe', 'pipe']`
- stdin: ignore
- stdout: pipe (we'll read it)
- stderr: pipe (for errors)
- `env`: Inherit process.env
- `shell`: false (security)
**Port extraction:**
- Listen to stdout data events
- Buffer output line by line
- Regex match: `/Server listening on port (\d+)/` or similar
- Extract port number when found
- Store process metadata
- Resolve promise with { pid, port }
**Timeout handling:**
- Set 10 second timeout
- If port not found within timeout:
- Kill the process
- Reject promise with timeout error
- Clear timeout once port found
**Error handling:**
- Listen to process 'error' event
- Common: ENOENT (binary not found)
- Reject promise immediately
- Listen to process 'exit' event
- If exits before port found:
- Read stderr buffer
- Reject with exit code and stderr
### 3. Implement Kill Logic
**kill(pid: number):**
**Find process:**
- Look up pid in internal Map
- If not found, reject with "Process not found"
**Graceful shutdown:**
- Send SIGTERM signal first
- Wait 2 seconds
- If still running, send SIGKILL
- Remove from internal Map
- Resolve when process exits
**Cleanup:**
- Close stdio streams
- Remove all event listeners
- Free resources
### 4. Implement Status Check
**getStatus(pid: number):**
**Check if running:**
- On Unix: Use `process.kill(pid, 0)`
- Returns true if running
- Throws if not running
- On Windows: Use tasklist or similar
- Return 'running', 'stopped', or 'unknown'
### 5. Add Process Tracking
**Internal state:**
```typescript
const processes = new Map<number, ProcessMeta>()
```
**Track all spawned processes:**
- Add on successful spawn
- Remove on kill or exit
- Use for cleanup on app quit
### 6. Implement Auto-cleanup
**On app quit:**
- Listen to app 'before-quit' event
- Kill all tracked processes
- Wait for all to exit (with timeout)
- Prevent quit until cleanup done
**On process crash:**
- Listen to process 'exit' event
- If unexpected exit:
- Log error
- Notify renderer via IPC
- Remove from tracking
### 7. Add Logging
**Log output forwarding:**
- Listen to stdout/stderr
- Parse into lines
- Send to renderer via IPC events
- Event: 'instance:log'
- Payload: { pid, level: 'info' | 'error', message }
**Log important events:**
- Process spawned
- Port discovered
- Process exited
- Errors occurred
### 8. Add IPC Handlers
**electron/main/ipc.ts (new file):**
**Register handlers:**
```typescript
ipcMain.handle("process:spawn", async (event, folder: string) => {
return await processManager.spawn(folder)
})
ipcMain.handle("process:kill", async (event, pid: number) => {
return await processManager.kill(pid)
})
ipcMain.handle("process:status", async (event, pid: number) => {
return processManager.getStatus(pid)
})
```
**Send events:**
```typescript
// When process exits unexpectedly
webContents.send("process:exited", { pid, code, signal })
// When log output received
webContents.send("process:log", { pid, level, message })
```
### 9. Update Preload Script
**electron/preload/index.ts additions:**
**Expose methods:**
```typescript
electronAPI: {
spawnServer: (folder: string) => Promise<{ pid: number, port: number }>
killServer: (pid: number) => Promise<void>
getServerStatus: (pid: number) => Promise<string>
onServerExited: (callback: (data: any) => void) => void
onServerLog: (callback: (data: any) => void) => void
}
```
**Type definitions:**
```typescript
interface ProcessInfo {
pid: number
port: number
}
interface ElectronAPI {
// ... previous methods
spawnServer: (folder: string) => Promise<ProcessInfo>
killServer: (pid: number) => Promise<void>
getServerStatus: (pid: number) => Promise<"running" | "stopped" | "unknown">
onServerExited: (callback: (data: { pid: number; code: number }) => void) => void
onServerLog: (callback: (data: { pid: number; level: string; message: string }) => void) => void
}
```
### 10. Create Instance Store
**src/stores/instances.ts:**
**State:**
```typescript
interface Instance {
id: string // UUID
folder: string
port: number
pid: number
status: "starting" | "ready" | "error" | "stopped"
error?: string
}
interface InstanceStore {
instances: Map<string, Instance>
activeInstanceId: string | null
}
```
**Actions:**
```typescript
async function createInstance(folder: string) {
const id = generateId()
// Add with 'starting' status
instances.set(id, {
id,
folder,
port: 0,
pid: 0,
status: "starting",
})
try {
// Spawn server
const { pid, port } = await window.electronAPI.spawnServer(folder)
// Update with port and pid
instances.set(id, {
...instances.get(id)!,
port,
pid,
status: "ready",
})
return id
} catch (error) {
// Update with error
instances.set(id, {
...instances.get(id)!,
status: "error",
error: error.message,
})
throw error
}
}
async function removeInstance(id: string) {
const instance = instances.get(id)
if (!instance) return
// Kill server
if (instance.pid) {
await window.electronAPI.killServer(instance.pid)
}
// Remove from store
instances.delete(id)
// If was active, clear active
if (activeInstanceId === id) {
activeInstanceId = null
}
}
```
### 11. Wire Up Folder Selection
**src/App.tsx updates:**
**After folder selected:**
```typescript
async function handleSelectFolder() {
const folder = await window.electronAPI.selectFolder()
if (!folder) return
try {
const instanceId = await createInstance(folder)
setActiveInstance(instanceId)
// Hide empty state, show instance UI
setHasInstances(true)
} catch (error) {
console.error("Failed to create instance:", error)
// TODO: Show error toast
}
}
```
**Listen for process exit:**
```typescript
onMount(() => {
window.electronAPI.onServerExited(({ pid }) => {
// Find instance by PID
const instance = Array.from(instances.values()).find((i) => i.pid === pid)
if (instance) {
// Update status
instances.set(instance.id, {
...instance,
status: "stopped",
})
// TODO: Show notification (Task 010)
}
})
})
```
## Testing Checklist
**Manual Tests:**
1. Select folder → Server spawns
2. Console shows "Spawned PID: XXX, Port: YYYY"
3. Check `ps aux | grep opencode` → Process running
4. Quit app → Process killed
5. Select invalid folder → Error shown
6. Select without opencode installed → Helpful error
7. Spawn multiple instances → All tracked
8. Kill one instance → Others continue running
**Error Cases:**
- opencode not in PATH
- Permission denied on folder
- Port already in use (should not happen with port 0)
- Server crashes immediately
- Timeout (server takes >10s to start)
**Edge Cases:**
- Very long folder path
- Folder with spaces in name
- Folder on network drive (slow to spawn)
- Multiple instances same folder (different ports)
## Dependencies
- **Blocks:** Task 004 (needs running server to connect SDK)
- **Blocked by:** Task 001, Task 002
## Estimated Time
4-5 hours
## Notes
- Security: Never use shell execution with user input
- Cross-platform: Test on macOS, Windows, Linux
- Error messages must be actionable
- Log everything for debugging
- Consider rate limiting (max 10 instances?)
- Memory: Track process memory usage (future enhancement)