Files
shopify-ai-backup/chat/OPENCODE_SERVER_INTEGRATION.md
2026-02-19 19:50:02 +00:00

1276 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# OpenCode Server Integration Guide
## Overview
This guide details how to integrate OpenCode's `serve` command with the chat server to enable a persistent single-process architecture, reducing resource consumption when multiple sessions are running concurrently.
## Current Architecture vs. Proposed
### Current (Per-Message Spawning)
```
┌─────────────┐ spawn ┌─────────────┐
│ Chat Server │ ──────────────▶│ OpenCode │ (Process 1)
│ │ │ --session A │
│ │ └─────────────┘
│ │ spawn ┌─────────────┐
│ │ ──────────────▶│ OpenCode │ (Process 2)
│ │ │ --session B │
└─────────────┘ └─────────────┘
Memory: 2 processes × ~300MB = ~600MB
Startup: ~1-2s latency per message
```
### Proposed (Persistent Server)
```
┌─────────────┐ HTTP/WS ┌─────────────┐
│ Chat Server │ ◀─────────────▶│ OpenCode │ (Single Process)
│ │ │ serve │
│ │ │ :4096 │
│ │ │ │
│ Session A ─┼── POST /session/A/message
│ Session B ─┼── POST /session/B/message
└─────────────┘ └─────────────┘
Memory: 1 process × ~400MB = ~400MB
Startup: ~10-50ms latency per message
```
## OpenCode Server API Reference
### Server Startup
```bash
opencode serve --port 4096 --hostname 127.0.0.1
```
**Options:**
- `--port`: Port number (default: 4096, auto-selects if 4096 is busy)
- `--hostname`: Bind address (default: 127.0.0.1)
- `--mdns`: Enable mDNS discovery (optional)
- `--cors`: CORS whitelist origins (optional)
**Environment Variables:**
- `OPENCODE_SERVER_PASSWORD`: Basic auth password (optional but recommended)
- `OPENCODE_SERVER_USERNAME`: Basic auth username (default: "opencode")
### Key Endpoints
#### Session Management
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/session` | List all sessions |
| POST | `/session` | Create new session |
| GET | `/session/:sessionID` | Get session details |
| DELETE | `/session/:sessionID` | Delete session |
| PATCH | `/session/:sessionID` | Update session (title, etc.) |
| POST | `/session/:sessionID/abort` | Abort running task |
#### Messaging
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/session/:sessionID/message` | Get all messages |
| POST | `/session/:sessionID/message` | Send prompt (streaming response) |
| POST | `/session/:sessionID/prompt_async` | Send prompt (non-blocking) |
| POST | `/session/:sessionID/command` | Send command |
| POST | `/session/:sessionID/shell` | Execute shell command |
#### Events
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/event` | SSE stream of all events |
| GET | `/session/status` | Get all session statuses |
#### Project Context
All endpoints accept either:
- Query parameter: `?directory=/path/to/project`
- Header: `x-opencode-directory: /path/to/project`
### Request/Response Schemas
#### Create Session
```typescript
// POST /session
// Request body (optional)
{
title?: string,
agent?: string, // Agent ID (e.g., "general", "code-review")
model?: {
providerID: string,
modelID: string
}
}
// Response
{
id: string,
title: string,
directory: string,
time: {
created: number,
updated: number
},
// ... other fields
}
```
#### Send Message
```typescript
// POST /session/:sessionID/message
// Request body
{
prompt: {
parts: Array<{ type: "text", text: string } | { type: "image", url: string }>
},
agent?: string,
model?: {
providerID: string,
modelID: string
}
}
// Response (streaming)
{
info: {
id: string,
sessionID: string,
role: "assistant",
// ...
},
parts: Array<MessagePart>
}
```
#### Event Stream (SSE)
```typescript
// GET /event
// Events are JSON objects separated by newlines
// Event types include:
{
type: "server.connected",
properties: {}
}
{
type: "message.part",
properties: {
sessionID: string,
messageID: string,
part: { ... }
}
}
{
type: "message.complete",
properties: {
sessionID: string,
messageID: string
}
}
{
type: "session.status",
properties: {
sessionID: string,
status: "idle" | "busy" | "error"
}
}
{
type: "server.heartbeat",
properties: {}
}
```
## Implementation Plan
### Phase 1: OpencodeProcessManager Enhancement
**File:** `server.js` (lines 1117-1380)
#### 1.1 Add Connection State
```javascript
class OpencodeProcessManager {
constructor() {
this.process = null;
this.isReady = false;
this.baseUrl = 'http://127.0.0.1:4096';
this.pendingRequests = new Map();
this.eventSource = null;
this.eventHandlers = new Map(); // sessionId -> callback[]
this.lastActivity = Date.now();
this.sessionWorkspaces = new Map(); // sessionId -> { directory, userId, appId }
this.authHeader = null;
this.connectionAttempts = 0;
this.maxConnectionAttempts = 3;
this.healthCheckInterval = null;
}
}
```
#### 1.2 Server Startup Method
```javascript
async start() {
if (this.process && this.isReady) {
log('OpenCode server already running');
return;
}
log('Starting OpenCode server...');
const cliCommand = resolveCliCommand('opencode');
const port = Number(process.env.OPENCODE_SERVER_PORT || 4096);
const hostname = process.env.OPENCODE_SERVER_HOST || '127.0.0.1';
// Set auth if password is configured
const password = process.env.OPENCODE_SERVER_PASSWORD;
if (password) {
const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode';
this.authHeader = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64');
}
try {
// Verify CLI exists
fsSync.accessSync(cliCommand, fsSync.constants.X_OK);
} catch (err) {
throw new Error(`OpenCode CLI not found: ${cliCommand}`);
}
// Start the serve process
const args = ['serve', '--port', String(port), '--hostname', hostname];
this.process = spawn(cliCommand, args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
OPENCODE_SERVER_PASSWORD: password || '',
OPENCODE_SERVER_USERNAME: process.env.OPENCODE_SERVER_USERNAME || 'opencode'
}
});
// Handle process events
this.process.stdout.on('data', (data) => {
const text = data.toString();
log('OpenCode server stdout', { text: text.slice(0, 200) });
// Detect ready signal
if (text.includes('listening on http://')) {
this.isReady = true;
this.connectionAttempts = 0;
log('OpenCode server ready', { port, hostname });
// Start event subscription
this.subscribeToEvents();
// Start health check
this.startHealthCheck();
}
});
this.process.stderr.on('data', (data) => {
log('OpenCode server stderr', { text: data.toString().slice(0, 200) });
});
this.process.on('error', (err) => {
log('OpenCode server error', { error: String(err) });
this.isReady = false;
this.handleServerError(err);
});
this.process.on('exit', (code, signal) => {
log('OpenCode server exited', { code, signal });
this.isReady = false;
this.process = null;
this.stopHealthCheck();
this.closeEventSource();
// Auto-restart if not shutting down
if (!isShuttingDown && this.connectionAttempts < this.maxConnectionAttempts) {
this.connectionAttempts++;
log('Auto-restarting OpenCode server', { attempt: this.connectionAttempts });
setTimeout(() => this.start().catch(e =>
log('Failed to restart OpenCode server', { error: String(e) })
), 2000 * this.connectionAttempts);
}
});
// Wait for ready signal
await this.waitForReady(30000);
this.baseUrl = `http://${hostname}:${port}`;
}
```
#### 1.3 Health Check Implementation
```javascript
startHealthCheck() {
if (this.healthCheckInterval) return;
this.healthCheckInterval = setInterval(async () => {
if (!this.isReady || !this.process) return;
try {
const response = await fetch(`${this.baseUrl}/session/status`, {
method: 'GET',
headers: this.getHeaders()
});
if (!response.ok) {
log('Health check failed', { status: response.status });
if (response.status >= 500) {
this.isReady = false;
}
}
} catch (err) {
log('Health check error', { error: String(err) });
this.isReady = false;
}
}, 30000); // Every 30 seconds
}
stopHealthCheck() {
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = null;
}
}
```
#### 1.4 Event Subscription (SSE)
```javascript
subscribeToEvents() {
if (this.eventSource) {
this.closeEventSource();
}
// Use native HTTP client for SSE (Node.js doesn't have EventSource by default)
const url = new URL('/event', this.baseUrl);
const headers = {
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache'
};
if (this.authHeader) {
headers['Authorization'] = this.authHeader;
}
const req = http.request({
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: 'GET',
headers
}, (res) => {
let buffer = '';
res.on('data', (chunk) => {
buffer += chunk.toString();
// Process complete SSE messages
const lines = buffer.split('\n\n');
buffer = lines.pop() || ''; // Keep incomplete message in buffer
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const event = JSON.parse(line.slice(6));
this.handleServerEvent(event);
} catch (e) {
log('Failed to parse SSE event', { line: line.slice(0, 100) });
}
}
}
});
res.on('end', () => {
log('SSE connection closed');
this.eventSource = null;
// Reconnect if server is still running
if (this.isReady && !isShuttingDown) {
setTimeout(() => this.subscribeToEvents(), 1000);
}
});
});
req.on('error', (err) => {
log('SSE connection error', { error: String(err) });
this.eventSource = null;
if (this.isReady && !isShuttingDown) {
setTimeout(() => this.subscribeToEvents(), 2000);
}
});
req.end();
this.eventSource = req;
}
closeEventSource() {
if (this.eventSource) {
this.eventSource.destroy();
this.eventSource = null;
}
}
handleServerEvent(event) {
// Route events to appropriate handlers
const { type, properties } = event;
switch (type) {
case 'server.connected':
log('SSE connected to OpenCode server');
break;
case 'server.heartbeat':
// No action needed, just keeps connection alive
break;
case 'message.part':
this.handleMessagePart(properties);
break;
case 'message.complete':
this.handleMessageComplete(properties);
break;
case 'session.status':
this.handleSessionStatus(properties);
break;
case 'tool.call':
this.handleToolCall(properties);
break;
case 'tool.result':
this.handleToolResult(properties);
break;
case 'instance.disposed':
log('OpenCode instance disposed');
break;
default:
log('Unhandled server event', { type, properties });
}
}
```
#### 1.5 HTTP Request Helper
```javascript
getHeaders(directory = null) {
const headers = {
'Content-Type': 'application/json'
};
if (this.authHeader) {
headers['Authorization'] = this.authHeader;
}
if (directory) {
headers['x-opencode-directory'] = directory;
}
return headers;
}
async request(method, path, body = null, directory = null) {
if (!this.isReady) {
throw new Error('OpenCode server not ready');
}
const url = new URL(path, this.baseUrl);
if (directory) {
url.searchParams.set('directory', directory);
}
const options = {
method,
headers: this.getHeaders(directory)
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(url.toString(), options);
if (!response.ok) {
const errorText = await response.text();
const error = new Error(`OpenCode API error: ${response.status} ${errorText}`);
error.status = response.status;
throw error;
}
// Handle streaming responses
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
return response.json();
}
return response;
}
```
#### 1.6 Session Management
```javascript
async createSession(workspaceDir, options = {}) {
const response = await this.request('POST', '/session', {
title: options.title || `Session ${Date.now()}`,
agent: options.agent,
model: options.model
}, workspaceDir);
const sessionId = response.id;
// Track session
this.sessionWorkspaces.set(sessionId, {
directory: workspaceDir,
userId: options.userId,
appId: options.appId,
createdAt: Date.now()
});
return response;
}
async getSession(sessionId, directory) {
return this.request('GET', `/session/${sessionId}`, null, directory);
}
async listSessions(directory) {
return this.request('GET', '/session', null, directory);
}
async deleteSession(sessionId, directory) {
const result = await this.request('DELETE', `/session/${sessionId}`, null, directory);
this.sessionWorkspaces.delete(sessionId);
return result;
}
```
#### 1.7 Message Sending
```javascript
async sendMessage(sessionId, workspaceDir, message, options = {}) {
const body = {
prompt: {
parts: [{ type: 'text', text: message.content }]
}
};
// Add images if present
if (message.attachments?.length) {
for (const attachment of message.attachments) {
if (attachment.type?.startsWith('image/')) {
body.prompt.parts.push({
type: 'image',
url: attachment.url // Must be a data URL or accessible URL
});
}
}
}
// Add agent/model if specified
if (options.agent) body.agent = options.agent;
if (options.model) body.model = options.model;
return this.request('POST', `/session/${sessionId}/message`, body, workspaceDir);
}
async sendMessageAsync(sessionId, workspaceDir, message, options = {}) {
// Non-blocking version - returns immediately, events come via SSE
const body = {
prompt: {
parts: [{ type: 'text', text: message.content }]
}
};
if (options.agent) body.agent = options.agent;
if (options.model) body.model = options.model;
await this.request('POST', `/session/${sessionId}/prompt_async`, body, workspaceDir);
}
async abortSession(sessionId, directory) {
return this.request('POST', `/session/${sessionId}/abort`, null, directory);
}
```
#### 1.8 Event Handlers for Streaming
```javascript
handleMessagePart(properties) {
const { sessionID, messageID, part } = properties;
// Find the pending request for this session
const pending = this.pendingRequests.get(messageID);
if (pending?.streamCallback) {
pending.streamCallback({
type: 'part',
part
});
}
// Also notify session-specific handlers
const handlers = this.eventHandlers.get(sessionID);
if (handlers) {
for (const handler of handlers) {
handler({ type: 'part', messageID, part });
}
}
}
handleMessageComplete(properties) {
const { sessionID, messageID } = properties;
// Resolve the pending request
const pending = this.pendingRequests.get(messageID);
if (pending) {
pending.resolve({ sessionID, messageID });
this.pendingRequests.delete(messageID);
}
// Notify session handlers
const handlers = this.eventHandlers.get(sessionID);
if (handlers) {
for (const handler of handlers) {
handler({ type: 'complete', messageID });
}
}
}
handleSessionStatus(properties) {
const { sessionID, status } = properties;
const workspace = this.sessionWorkspaces.get(sessionID);
if (workspace) {
workspace.status = status;
workspace.lastStatusUpdate = Date.now();
}
log('Session status update', { sessionID, status });
}
handleToolCall(properties) {
const { sessionID, messageID, tool, input } = properties;
// Forward to stream callback for real-time display
const pending = this.pendingRequests.get(messageID);
if (pending?.streamCallback) {
pending.streamCallback({
type: 'tool_call',
tool,
input
});
}
}
handleToolResult(properties) {
const { sessionID, messageID, tool, output } = properties;
const pending = this.pendingRequests.get(messageID);
if (pending?.streamCallback) {
pending.streamCallback({
type: 'tool_result',
tool,
output
});
}
}
// Register/unregister event handlers for a session
registerEventHandler(sessionId, handler) {
if (!this.eventHandlers.has(sessionId)) {
this.eventHandlers.set(sessionId, new Set());
}
this.eventHandlers.get(sessionId).add(handler);
return () => {
this.eventHandlers.get(sessionId)?.delete(handler);
if (this.eventHandlers.get(sessionId)?.size === 0) {
this.eventHandlers.delete(sessionId);
}
};
}
```
#### 1.9 Cleanup and Shutdown
```javascript
async stop() {
log('Stopping OpenCode server...');
this.isReady = false;
this.stopHealthCheck();
this.closeEventSource();
// Reject all pending requests
for (const [messageId, pending] of this.pendingRequests) {
pending.reject(new Error('OpenCode server shutting down'));
}
this.pendingRequests.clear();
this.eventHandlers.clear();
this.sessionWorkspaces.clear();
if (this.process) {
// Graceful shutdown
this.process.kill('SIGTERM');
await new Promise((resolve) => {
const timeout = setTimeout(() => {
if (this.process) {
log('Force killing OpenCode server');
this.process.kill('SIGKILL');
}
resolve();
}, 5000);
if (this.process) {
this.process.once('exit', () => {
clearTimeout(timeout);
resolve();
});
} else {
clearTimeout(timeout);
resolve();
}
});
this.process = null;
}
log('OpenCode server stopped');
}
getStats() {
return {
isRunning: !!this.process,
isReady: this.isReady,
baseUrl: this.baseUrl,
activeSessions: this.sessionWorkspaces.size,
pendingRequests: this.pendingRequests.size,
eventHandlers: this.eventHandlers.size,
lastActivity: this.lastActivity,
idleTime: Date.now() - this.lastActivity
};
}
```
### Phase 2: Integration with Chat Server
#### 2.1 Modify sendToOpencode Function
**File:** `server.js` (around line 9519)
```javascript
async function sendToOpencode({ session, model, content, message, cli, streamCallback, opencodeSessionId }) {
const messageKey = message?.id || 'unknown';
// Check if server mode is available
if (opencodeManager.isReady) {
log('Using OpenCode server mode', { sessionId: session.id, messageId: messageKey });
try {
// Ensure session exists in OpenCode
let ocSessionId = opencodeSessionId || session.opencodeSessionId;
if (!ocSessionId) {
// Create new session in OpenCode server
const newSession = await opencodeManager.createSession(session.workspaceDir, {
title: session.title || `Chat ${session.id}`,
userId: session.userId,
appId: session.appId
});
ocSessionId = newSession.id;
session.opencodeSessionId = ocSessionId;
session.initialOpencodeSessionId = ocSessionId;
await persistState();
log('Created new OpenCode session', { ocSessionId });
}
// Register stream callback
let streamUnregister = null;
if (streamCallback) {
streamUnregister = opencodeManager.registerEventHandler(ocSessionId, (event) => {
streamCallback(event);
});
}
// Track this request
const requestId = messageKey;
opencodeManager.pendingRequests.set(requestId, {
resolve: () => {},
reject: () => {},
streamCallback,
started: Date.now()
});
// Send message
const response = await opencodeManager.sendMessageAsync(ocSessionId, session.workspaceDir, {
content,
attachments: message?.attachments
}, {
model: model ? { providerID: 'openrouter', modelID: model } : undefined
});
// Messages will come via SSE, handled by event handlers
// Return a promise that resolves when complete
return new Promise((resolve, reject) => {
const pending = opencodeManager.pendingRequests.get(requestId);
if (pending) {
pending.resolve = (result) => {
if (streamUnregister) streamUnregister();
opencodeManager.pendingRequests.delete(requestId);
resolve(result);
};
pending.reject = (error) => {
if (streamUnregister) streamUnregister();
opencodeManager.pendingRequests.delete(requestId);
reject(error);
};
}
// Timeout safety
setTimeout(() => {
if (opencodeManager.pendingRequests.has(requestId)) {
if (streamUnregister) streamUnregister();
opencodeManager.pendingRequests.delete(requestId);
reject(new Error('OpenCode request timed out'));
}
}, 600000); // 10 minutes
});
} catch (error) {
log('OpenCode server mode failed, falling back to spawn', { error: String(error) });
// Fall through to spawn-based execution
}
}
// Fallback: Original spawn-based implementation
return sendToOpencodeViaSpawn({ session, model, content, message, cli, streamCallback, opencodeSessionId });
}
```
#### 2.2 Initialize Server on Startup
**File:** `server.js` (in the startup section, around where server starts)
```javascript
async function initializeOpencodeServer() {
const useServerMode = process.env.OPENCODE_SERVER_MODE !== 'false';
if (useServerMode) {
try {
log('Initializing OpenCode server mode...');
await opencodeManager.start();
log('OpenCode server mode initialized successfully');
} catch (error) {
log('Failed to initialize OpenCode server mode, using spawn fallback', {
error: String(error)
});
}
} else {
log('OpenCode server mode disabled, using spawn mode');
}
}
// Call during server startup
async function main() {
// ... existing initialization ...
await initializeOpencodeServer();
// ... start HTTP server ...
}
```
#### 2.3 Cleanup on Shutdown
**File:** `server.js` (in gracefulShutdown function)
```javascript
async function gracefulShutdown(signal) {
if (isShuttingDown) return;
isShuttingDown = true;
log(`Received ${signal}, starting graceful shutdown...`);
// Stop periodic tasks
stopAutoSave();
stopMemoryCleanup();
// Stop OpenCode server
log('Stopping OpenCode server...');
await opencodeManager.stop();
// ... rest of existing shutdown logic ...
}
```
### Phase 3: Configuration Options
#### Environment Variables
```bash
# Enable/disable server mode (default: enabled)
OPENCODE_SERVER_MODE=true
# Server port (default: 4096)
OPENCODE_SERVER_PORT=4096
# Server hostname (default: 127.0.0.1)
OPENCODE_SERVER_HOST=127.0.0.1
# Basic auth password (recommended for production)
OPENCODE_SERVER_PASSWORD=your-secure-password
# Basic auth username (default: opencode)
OPENCODE_SERVER_USERNAME=opencode
# Auto-restart on failure (default: true)
OPENCODE_SERVER_AUTO_RESTART=true
# Maximum connection attempts before fallback (default: 3)
OPENCODE_SERVER_MAX_RETRIES=3
```
#### Feature Flag Integration
```javascript
// In configuration section
const OPENCODE_SERVER_MODE = process.env.OPENCODE_SERVER_MODE !== 'false';
const OPENCODE_SERVER_PORT = Number(process.env.OPENCODE_SERVER_PORT || 4096);
const OPENCODE_SERVER_HOST = process.env.OPENCODE_SERVER_HOST || '127.0.0.1';
const OPENCODE_SERVER_PASSWORD = process.env.OPENCODE_SERVER_PASSWORD || '';
const OPENCODE_SERVER_USERNAME = process.env.OPENCODE_SERVER_USERNAME || 'opencode';
const OPENCODE_SERVER_AUTO_RESTART = process.env.OPENCODE_SERVER_AUTO_RESTART !== 'false';
const OPENCODE_SERVER_MAX_RETRIES = Number(process.env.OPENCODE_SERVER_MAX_RETRIES || 3);
```
### Phase 4: Error Handling and Fallback
#### 4.1 Automatic Fallback Strategy
```javascript
class OpencodeProcessManager {
// ... existing code ...
async executeWithFallback(sessionId, workspaceDir, operation, fallbackFn) {
// Try server mode first
if (this.isReady) {
try {
return await operation();
} catch (error) {
// Check if error is recoverable
if (this.isRecoverableError(error)) {
log('Recoverable error in server mode, retrying', { error: String(error) });
await this.restart();
return await operation();
}
// Fall back to spawn mode
log('Server mode failed, falling back to spawn', { error: String(error) });
return await fallbackFn();
}
}
// Server not ready, use spawn mode
return await fallbackFn();
}
isRecoverableError(error) {
// Connection errors, timeouts, and 5xx errors are recoverable
if (error.code === 'ECONNREFUSED') return true;
if (error.code === 'ECONNRESET') return true;
if (error.code === 'ETIMEDOUT') return true;
if (error.status >= 500 && error.status < 600) return true;
return false;
}
async restart() {
log('Restarting OpenCode server...');
await this.stop();
await new Promise(resolve => setTimeout(resolve, 1000));
await this.start();
}
}
```
#### 4.2 Graceful Degradation
```javascript
// In sendToOpencodeWithFallback
async function sendToOpencodeWithFallback({ session, model, content, message, cli, streamCallback, opencodeSessionId, plan }) {
const useServerMode = opencodeManager.isReady;
if (useServerMode) {
try {
return await sendToOpencode({ session, model, content, message, cli, streamCallback, opencodeSessionId });
} catch (error) {
log('Server mode failed after retries, using spawn fallback', {
error: String(error),
sessionId: session.id
});
}
}
// Original spawn-based implementation
return sendToOpencodeViaSpawn({ session, model, content, message, cli, streamCallback, opencodeSessionId, plan });
}
```
### Phase 5: Monitoring and Observability
#### 5.1 Health Endpoint
```javascript
// Add to HTTP server routes
app.get('/api/opencode/status', async (req, res) => {
const stats = opencodeManager.getStats();
res.json({
serverMode: {
enabled: OPENCODE_SERVER_MODE,
...stats
},
fallbackMode: !opencodeManager.isReady
});
});
app.post('/api/opencode/restart', async (req, res) => {
try {
await opencodeManager.restart();
res.json({ success: true, stats: opencodeManager.getStats() });
} catch (error) {
res.status(500).json({ error: String(error) });
}
});
```
#### 5.2 Metrics Logging
```javascript
class OpencodeProcessManager {
// ... existing code ...
logMetrics() {
const stats = this.getStats();
const memUsage = process.memoryUsage();
log('OpenCode server metrics', {
...stats,
memory: {
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024) + 'MB',
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024) + 'MB',
rss: Math.round(memUsage.rss / 1024 / 1024) + 'MB'
}
});
}
}
// Log metrics periodically
setInterval(() => {
if (opencodeManager.isReady) {
opencodeManager.logMetrics();
}
}, 60000); // Every minute
```
## Testing Strategy
### Unit Tests
```javascript
// test/opencode-server.test.js
describe('OpencodeProcessManager', () => {
let manager;
beforeEach(() => {
manager = new OpencodeProcessManager();
});
afterEach(async () => {
await manager.stop();
});
test('should start server successfully', async () => {
await manager.start();
expect(manager.isReady).toBe(true);
expect(manager.process).toBeDefined();
});
test('should handle server crash and restart', async () => {
await manager.start();
manager.process.kill('SIGKILL');
await new Promise(r => setTimeout(r, 5000));
expect(manager.isReady).toBe(true);
});
test('should create and track sessions', async () => {
await manager.start();
const session = await manager.createSession('/tmp/test');
expect(session.id).toBeDefined();
expect(manager.sessionWorkspaces.has(session.id)).toBe(true);
});
test('should handle concurrent requests', async () => {
await manager.start();
const session = await manager.createSession('/tmp/test');
const requests = Array(10).fill(null).map(() =>
manager.sendMessage(session.id, '/tmp/test', { content: 'test' })
);
const results = await Promise.all(requests);
expect(results).toHaveLength(10);
});
});
```
### Integration Tests
```javascript
describe('OpenCode Server Integration', () => {
test('should fallback to spawn mode on server failure', async () => {
// Force server failure
await opencodeManager.stop();
opencodeManager.isReady = false;
// Send message - should use spawn fallback
const result = await sendToOpencodeWithFallback({
session: { id: 'test', workspaceDir: '/tmp' },
model: 'test-model',
content: 'Hello'
});
expect(result).toBeDefined();
});
test('should handle SSE event streaming', async () => {
await opencodeManager.start();
const events = [];
const unregister = opencodeManager.registerEventHandler('test-session', (event) => {
events.push(event);
});
// Trigger an event
opencodeManager.handleServerEvent({
type: 'message.part',
properties: { sessionID: 'test-session', part: { text: 'test' } }
});
expect(events).toHaveLength(1);
unregister();
});
});
```
## Migration Path
### Step 1: Add Feature Flag (Low Risk)
1. Add environment variable `OPENCODE_SERVER_MODE=false` (default disabled)
2. Add OpencodeProcessManager class enhancements
3. Test in development
### Step 2: Enable for Testing (Medium Risk)
1. Set `OPENCODE_SERVER_MODE=true` in staging
2. Monitor metrics and error rates
3. Compare resource usage with spawn mode
### Step 3: Gradual Rollout (Low Risk)
1. Enable for subset of users/sessions
2. Use canary deployment pattern
3. Monitor for issues
### Step 4: Full Rollout (Production)
1. Enable for all traffic
2. Keep fallback mode available
3. Remove spawn mode code in future version (optional)
## Performance Comparison
### Expected Improvements
| Metric | Spawn Mode | Server Mode | Improvement |
|--------|------------|-------------|-------------|
| Memory per session | ~300MB | Shared ~400MB | 33% reduction (2 sessions) |
| Startup latency | 1-2s | 10-50ms | 95% reduction |
| CPU overhead | High (process spawn) | Low (HTTP calls) | Significant |
| Concurrent sessions | Limited by memory | Higher capacity | Better scalability |
### Benchmarking Script
```bash
#!/bin/bash
# benchmark-opencode.sh
echo "Benchmarking OpenCode modes..."
# Spawn mode
OPENCODE_SERVER_MODE=false node server.js &
PID1=$!
sleep 5
ab -n 100 -c 10 http://localhost:4000/api/test
kill $PID1
# Server mode
OPENCODE_SERVER_MODE=true node server.js &
PID2=$!
sleep 5
ab -n 100 -c 10 http://localhost:4000/api/test
kill $PID2
```
## Troubleshooting
### Common Issues
1. **Port already in use**: Change `OPENCODE_SERVER_PORT` or stop conflicting service
2. **Connection refused**: Check if OpenCode CLI is properly installed
3. **Auth failures**: Verify `OPENCODE_SERVER_PASSWORD` matches
4. **Memory issues**: Monitor with `/api/opencode/status` endpoint
### Debug Mode
```bash
# Enable verbose logging
DEBUG=opencode:* OPENCODE_SERVER_MODE=true node server.js
# Check server status
curl http://localhost:4000/api/opencode/status
# Manual restart
curl -X POST http://localhost:4000/api/opencode/restart
```
## Future Enhancements
1. **Connection pooling**: Reuse HTTP connections for better performance
2. **Request batching**: Combine multiple operations into single API calls
3. **Multi-server support**: Load balance across multiple OpenCode instances
4. **Circuit breaker**: Automatic mode switching based on error rates
5. **Prometheus metrics**: Export metrics for monitoring systems