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)
This commit is contained in:
430
tasks/done/003-process-manager.md
Normal file
430
tasks/done/003-process-manager.md
Normal file
@@ -0,0 +1,430 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user