restore: bring back all custom UI enhancements from checkpoint

Restored from commit 52be710 (checkpoint before qwen oauth + todo roller):

Enhanced UI Features:
- SMART FIX button with AI code analysis
- APEX (Autonomous Programming EXecution) mode
- SHIELD (Auto-approval) mode
- MULTIX MODE multi-task pipeline interface
- Live streaming token counter
- Thinking indicator with bouncing dots animation

Components restored:
- packages/ui/src/components/chat/multi-task-chat.tsx
- packages/ui/src/components/instance/instance-shell2.tsx
- packages/ui/src/components/settings/OllamaCloudSettings.tsx
- packages/ui/src/components/settings/QwenCodeSettings.tsx
- packages/ui/src/stores/solo-store.ts
- packages/ui/src/stores/task-actions.ts
- packages/ui/src/stores/session-events.ts (autonomous mode)
- packages/server/src/integrations/ollama-cloud.ts
- packages/server/src/server/routes/ollama.ts
- packages/server/src/server/routes/qwen.ts

This ensures all custom features are preserved in source control.
This commit is contained in:
Gemini AI
2025-12-23 13:18:37 +04:00
Unverified
parent 157449a9ad
commit c4ac079660
47 changed files with 4550 additions and 527 deletions

View File

@@ -16,10 +16,11 @@
"codenomad": "dist/bin.js"
},
"scripts": {
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json",
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && npm run prepare-config",
"build:ui": "npm run build --prefix ../ui",
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
"dev": "cross-env CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
"dev": "cross-env CODENOMAD_DEV=1 CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {

View File

@@ -8,6 +8,20 @@ import type {
RecentFolder,
} from "./config/schema"
export type TaskStatus = "completed" | "interrupted" | "in-progress" | "pending"
export interface Task {
id: string
title: string
status: TaskStatus
timestamp: number
messageIds?: string[] // IDs of messages associated with this task
}
export interface SessionTasks {
[sessionId: string]: Task[]
}
/**
* Canonical HTTP/SSE contract for the CLI server.
* These types are consumed by both the CLI implementation and any UI clients.
@@ -109,6 +123,7 @@ export type WorkspaceFileSearchResponse = FileSystemEntry[]
export interface InstanceData {
messageHistory: string[]
agentModelSelections: AgentModelSelection
sessionTasks?: SessionTasks // Multi-task chat support: tasks per session
}
export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"

View File

@@ -0,0 +1,273 @@
/**
* Ollama Cloud API Integration
* Provides access to Ollama's cloud models through API
*/
import { z } from "zod"
// Configuration schema for Ollama Cloud
export const OllamaCloudConfigSchema = z.object({
apiKey: z.string().optional(),
endpoint: z.string().default("https://ollama.com"),
enabled: z.boolean().default(false)
})
export type OllamaCloudConfig = z.infer<typeof OllamaCloudConfigSchema>
// Model information schema
export const OllamaModelSchema = z.object({
name: z.string(),
size: z.string(),
digest: z.string(),
modified_at: z.string(),
created_at: z.string()
})
export type OllamaModel = z.infer<typeof OllamaModelSchema>
// Chat message schema
export const ChatMessageSchema = z.object({
role: z.enum(["user", "assistant", "system"]),
content: z.string(),
images: z.array(z.string()).optional()
})
export type ChatMessage = z.infer<typeof ChatMessageSchema>
// Chat request/response schemas
export const ChatRequestSchema = z.object({
model: z.string(),
messages: z.array(ChatMessageSchema),
stream: z.boolean().default(false),
options: z.object({
temperature: z.number().min(0).max(2).optional(),
top_p: z.number().min(0).max(1).optional()
}).optional()
})
export const ChatResponseSchema = z.object({
model: z.string(),
created_at: z.string(),
message: ChatMessageSchema,
done: z.boolean().optional(),
total_duration: z.number().optional(),
load_duration: z.number().optional(),
prompt_eval_count: z.number().optional(),
prompt_eval_duration: z.number().optional(),
eval_count: z.number().optional(),
eval_duration: z.number().optional()
})
export type ChatRequest = z.infer<typeof ChatRequestSchema>
export type ChatResponse = z.infer<typeof ChatResponseSchema>
export class OllamaCloudClient {
private config: OllamaCloudConfig
private baseUrl: string
constructor(config: OllamaCloudConfig) {
this.config = config
this.baseUrl = config.endpoint.replace(/\/$/, "") // Remove trailing slash
}
/**
* Test connection to Ollama Cloud API
*/
async testConnection(): Promise<boolean> {
try {
const response = await this.makeRequest("/api/tags", {
method: "GET"
})
return response.ok
} catch (error) {
console.error("Ollama Cloud connection test failed:", error)
return false
}
}
/**
* List available models
*/
async listModels(): Promise<OllamaModel[]> {
try {
const response = await this.makeRequest("/api/tags", {
method: "GET"
})
if (!response.ok) {
throw new Error(`Failed to fetch models: ${response.statusText}`)
}
const data = await response.json()
return z.array(OllamaModelSchema).parse(data.models || [])
} catch (error) {
console.error("Failed to list Ollama Cloud models:", error)
throw error
}
}
/**
* Generate chat completion
*/
async chat(request: ChatRequest): Promise<AsyncIterable<ChatResponse>> {
if (!this.config.apiKey) {
throw new Error("Ollama Cloud API key is required")
}
const headers: Record<string, string> = {
"Content-Type": "application/json"
}
// Add authorization header if API key is provided
if (this.config.apiKey) {
headers["Authorization"] = `Bearer ${this.config.apiKey}`
}
try {
const response = await fetch(`${this.baseUrl}/api/chat`, {
method: "POST",
headers,
body: JSON.stringify(request)
})
if (!response.ok) {
throw new Error(`Chat request failed: ${response.statusText}`)
}
if (request.stream) {
return this.parseStreamingResponse(response)
} else {
const data = ChatResponseSchema.parse(await response.json())
return this.createAsyncIterable([data])
}
} catch (error) {
console.error("Ollama Cloud chat request failed:", error)
throw error
}
}
/**
* Pull a model (for cloud models, this just makes them available)
*/
async pullModel(modelName: string): Promise<void> {
const headers: Record<string, string> = {
"Content-Type": "application/json"
}
if (this.config.apiKey) {
headers["Authorization"] = `Bearer ${this.config.apiKey}`
}
const response = await fetch(`${this.baseUrl}/api/pull`, {
method: "POST",
headers,
body: JSON.stringify({ name: modelName })
})
if (!response.ok) {
throw new Error(`Failed to pull model ${modelName}: ${response.statusText}`)
}
}
/**
* Parse streaming response
*/
private async *parseStreamingResponse(response: Response): AsyncIterable<ChatResponse> {
if (!response.body) {
throw new Error("Response body is missing")
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const lines = decoder.decode(value, { stream: true }).split('\n').filter(line => line.trim())
for (const line of lines) {
try {
const data = JSON.parse(line)
const chatResponse = ChatResponseSchema.parse(data)
yield chatResponse
if (chatResponse.done) {
return
}
} catch (parseError) {
// Skip invalid JSON lines
console.warn("Failed to parse streaming line:", line, parseError)
}
}
}
} finally {
reader.releaseLock()
}
}
/**
* Create async iterable from array
*/
private async *createAsyncIterable<T>(items: T[]): AsyncIterable<T> {
for (const item of items) {
yield item
}
}
/**
* Make authenticated request to API
*/
private async makeRequest(endpoint: string, options: RequestInit): Promise<Response> {
const url = `${this.baseUrl}${endpoint}`
const headers: Record<string, string> = {
...options.headers as Record<string, string>
}
// Add authorization header if API key is provided
if (this.config.apiKey) {
headers["Authorization"] = `Bearer ${this.config.apiKey}`
}
return fetch(url, {
...options,
headers
})
}
/**
* Get cloud-specific models (models ending with -cloud)
*/
async getCloudModels(): Promise<OllamaModel[]> {
const allModels = await this.listModels()
return allModels.filter(model => model.name.endsWith("-cloud"))
}
/**
* Validate API key format
*/
static validateApiKey(apiKey: string): boolean {
return typeof apiKey === "string" && apiKey.length > 0
}
/**
* Get available cloud model names
*/
async getCloudModelNames(): Promise<string[]> {
const cloudModels = await this.getCloudModels()
return cloudModels.map(model => model.name)
}
}
// Default cloud models based on Ollama documentation
export const DEFAULT_CLOUD_MODELS = [
"gpt-oss:120b-cloud",
"llama3.1:70b-cloud",
"llama3.1:8b-cloud",
"qwen2.5:32b-cloud",
"qwen2.5:7b-cloud"
] as const
export type CloudModelName = typeof DEFAULT_CLOUD_MODELS[number]

View File

@@ -18,6 +18,8 @@ import { registerFilesystemRoutes } from "./routes/filesystem"
import { registerMetaRoutes } from "./routes/meta"
import { registerEventRoutes } from "./routes/events"
import { registerStorageRoutes } from "./routes/storage"
import { registerOllamaRoutes } from "./routes/ollama"
import { registerQwenRoutes } from "./routes/qwen"
import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store"
@@ -110,6 +112,8 @@ export function createHttpServer(deps: HttpServerDeps) {
eventBus: deps.eventBus,
workspaceManager: deps.workspaceManager,
})
registerOllamaRoutes(app, { logger: deps.logger })
registerQwenRoutes(app, { logger: deps.logger })
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })

View File

@@ -0,0 +1,224 @@
import { FastifyInstance, FastifyReply } from "fastify"
import { OllamaCloudClient, type OllamaCloudConfig, type ChatRequest } from "../../integrations/ollama-cloud"
import { Logger } from "../../logger"
interface OllamaRouteDeps {
logger: Logger
}
export async function registerOllamaRoutes(
app: FastifyInstance,
deps: OllamaRouteDeps
) {
const logger = deps.logger.child({ component: "ollama-routes" })
// Get Ollama Cloud configuration
app.get('/api/ollama/config', async (request, reply) => {
try {
const config = getOllamaConfig()
return { config: { ...config, apiKey: config.apiKey ? '***' : undefined } }
} catch (error) {
logger.error({ error }, "Failed to get Ollama config")
return reply.status(500).send({ error: "Failed to get Ollama configuration" })
}
})
// Update Ollama Cloud configuration
app.post('/api/ollama/config', {
schema: {
type: 'object',
required: ['enabled'],
properties: {
enabled: { type: 'boolean' },
apiKey: { type: 'string' },
endpoint: { type: 'string' }
}
}
}, async (request, reply) => {
try {
const { enabled, apiKey, endpoint } = request.body as any
updateOllamaConfig({ enabled, apiKey, endpoint })
logger.info("Ollama Cloud configuration updated")
return { success: true, config: { enabled, endpoint, apiKey: apiKey ? '***' : undefined } }
} catch (error) {
logger.error({ error }, "Failed to update Ollama config")
return reply.status(500).send({ error: "Failed to update Ollama configuration" })
}
})
// Test Ollama Cloud connection
app.post('/api/ollama/test', async (request, reply) => {
try {
const config = getOllamaConfig()
if (!config.enabled) {
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
}
const client = new OllamaCloudClient(config)
const isConnected = await client.testConnection()
return { connected: isConnected }
} catch (error) {
logger.error({ error }, "Ollama Cloud connection test failed")
return reply.status(500).send({ error: "Connection test failed" })
}
})
// List available models
app.get('/api/ollama/models', async (request, reply) => {
try {
const config = getOllamaConfig()
if (!config.enabled) {
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
}
const client = new OllamaCloudClient(config)
const models = await client.listModels()
return { models }
} catch (error) {
logger.error({ error }, "Failed to list Ollama models")
return reply.status(500).send({ error: "Failed to list models" })
}
})
// Get cloud models only
app.get('/api/ollama/models/cloud', async (request, reply) => {
try {
const config = getOllamaConfig()
if (!config.enabled) {
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
}
const client = new OllamaCloudClient(config)
const cloudModels = await client.getCloudModels()
return { models: cloudModels }
} catch (error) {
logger.error({ error }, "Failed to list cloud models")
return reply.status(500).send({ error: "Failed to list cloud models" })
}
})
// Chat completion endpoint
app.post('/api/ollama/chat', {
schema: {
type: 'object',
required: ['model', 'messages'],
properties: {
model: { type: 'string' },
messages: {
type: 'array',
items: {
type: 'object',
required: ['role', 'content'],
properties: {
role: { type: 'string', enum: ['user', 'assistant', 'system'] },
content: { type: 'string' }
}
}
},
stream: { type: 'boolean' },
options: {
type: 'object',
properties: {
temperature: { type: 'number', minimum: 0, maximum: 2 },
top_p: { type: 'number', minimum: 0, maximum: 1 }
}
}
}
}
}, async (request, reply) => {
try {
const config = getOllamaConfig()
if (!config.enabled) {
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
}
const client = new OllamaCloudClient(config)
const chatRequest = request.body as ChatRequest
// Set appropriate headers for streaming
if (chatRequest.stream) {
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
try {
const stream = await client.chat(chatRequest)
for await (const chunk of stream) {
reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`)
if (chunk.done) {
reply.raw.write('data: [DONE]\n\n')
break
}
}
reply.raw.end()
} catch (streamError) {
logger.error({ error: streamError }, "Streaming failed")
reply.raw.end()
}
} else {
const response = await client.chat(chatRequest)
return response
}
} catch (error) {
logger.error({ error }, "Ollama chat request failed")
return reply.status(500).send({ error: "Chat request failed" })
}
})
// Pull model endpoint
app.post('/api/ollama/pull', {
schema: {
type: 'object',
required: ['model'],
properties: {
model: { type: 'string' }
}
}
}, async (request, reply) => {
try {
const config = getOllamaConfig()
if (!config.enabled) {
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
}
const client = new OllamaCloudClient(config)
const { model } = request.body as any
// Start async pull operation
client.pullModel(model).catch(error => {
logger.error({ error, model }, "Failed to pull model")
})
return { message: `Started pulling model: ${model}` }
} catch (error) {
logger.error({ error }, "Failed to initiate model pull")
return reply.status(500).send({ error: "Failed to start model pull" })
}
})
logger.info("Ollama Cloud routes registered")
}
// Configuration management functions
function getOllamaConfig(): OllamaCloudConfig {
try {
const stored = localStorage.getItem('ollama_cloud_config')
return stored ? JSON.parse(stored) : { enabled: false, endpoint: "https://ollama.com" }
} catch {
return { enabled: false, endpoint: "https://ollama.com" }
}
}
function updateOllamaConfig(config: Partial<OllamaCloudConfig>): void {
const current = getOllamaConfig()
const updated = { ...current, ...config }
localStorage.setItem('ollama_cloud_config', JSON.stringify(updated))
}

View File

@@ -0,0 +1,134 @@
import { FastifyInstance, FastifyReply } from "fastify"
import { Logger } from "../../logger"
interface QwenRouteDeps {
logger: Logger
}
export async function registerQwenRoutes(
app: FastifyInstance,
deps: QwenRouteDeps
) {
const logger = deps.logger.child({ component: "qwen-routes" })
// Get OAuth URL for Qwen authentication
app.get('/api/qwen/oauth/url', async (request, reply) => {
try {
const { clientId, redirectUri } = request.query as any
if (!clientId) {
return reply.status(400).send({ error: "Client ID is required" })
}
const authUrl = new URL('https://qwen.ai/oauth/authorize')
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('client_id', clientId)
authUrl.searchParams.set('redirect_uri', redirectUri || `${request.protocol}//${request.host}/auth/qwen/callback`)
authUrl.searchParams.set('scope', 'read write')
authUrl.searchParams.set('state', generateState())
return { authUrl: authUrl.toString() }
} catch (error) {
logger.error({ error }, "Failed to generate OAuth URL")
return reply.status(500).send({ error: "Failed to generate OAuth URL" })
}
})
// Exchange authorization code for token
app.post('/api/qwen/oauth/exchange', {
schema: {
type: 'object',
required: ['code', 'state'],
properties: {
code: { type: 'string' },
state: { type: 'string' },
client_id: { type: 'string' },
redirect_uri: { type: 'string' }
}
}
}, async (request, reply) => {
try {
const { code, state, client_id, redirect_uri } = request.body as any
// Exchange code for token with Qwen
const tokenResponse = await fetch('https://qwen.ai/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: client_id,
code,
redirect_uri: redirect_uri
})
})
if (!tokenResponse.ok) {
throw new Error(`Token exchange failed: ${tokenResponse.statusText}`)
}
const tokenData = await tokenResponse.json()
// Get user info
const userResponse = await fetch('https://qwen.ai/api/user', {
headers: {
'Authorization': `Bearer ${tokenData.access_token}`
}
})
if (!userResponse.ok) {
throw new Error(`Failed to fetch user info: ${userResponse.statusText}`)
}
const userData = await userResponse.json()
return {
success: true,
user: userData,
token: {
access_token: tokenData.access_token,
token_type: tokenData.token_type,
expires_in: tokenData.expires_in,
scope: tokenData.scope
}
}
} catch (error) {
logger.error({ error }, "Qwen OAuth token exchange failed")
return reply.status(500).send({ error: "OAuth exchange failed" })
}
})
// Get user info
app.get('/api/qwen/user', async (request, reply) => {
try {
const authHeader = request.headers.authorization
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return reply.status(401).send({ error: "Authorization required" })
}
const token = authHeader.substring(7)
const userResponse = await fetch('https://qwen.ai/api/user', {
headers: {
'Authorization': `Bearer ${token}`
}
})
if (!userResponse.ok) {
return reply.status(401).send({ error: "Invalid token" })
}
const userData = await userResponse.json()
return { user: userData }
} catch (error) {
logger.error({ error }, "Failed to fetch Qwen user info")
return reply.status(500).send({ error: "Failed to fetch user info" })
}
})
logger.info("Qwen OAuth routes registered")
}
function generateState(): string {
return Math.random().toString(36).substring(2, 15) + Date.now().toString(36)
}

View File

@@ -3,7 +3,7 @@ import { z } from "zod"
import { InstanceStore } from "../../storage/instance-store"
import { EventBus } from "../../events/bus"
import { ModelPreferenceSchema } from "../../config/schema"
import type { InstanceData } from "../../api-types"
import type { InstanceData, Task, SessionTasks } from "../../api-types"
import { WorkspaceManager } from "../../workspaces/manager"
interface RouteDeps {
@@ -12,14 +12,24 @@ interface RouteDeps {
workspaceManager: WorkspaceManager
}
const TaskSchema = z.object({
id: z.string(),
title: z.string(),
status: z.enum(["completed", "interrupted", "in-progress", "pending"]),
timestamp: z.number(),
messageIds: z.array(z.string()).optional(),
})
const InstanceDataSchema = z.object({
messageHistory: z.array(z.string()).default([]),
agentModelSelections: z.record(z.string(), ModelPreferenceSchema).default({}),
sessionTasks: z.record(z.string(), z.array(TaskSchema)).optional(),
})
const EMPTY_INSTANCE_DATA: InstanceData = {
messageHistory: [],
agentModelSelections: {},
sessionTasks: {},
}
export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {

View File

@@ -35,10 +35,16 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
})
app.post("/api/workspaces", async (request, reply) => {
const body = WorkspaceCreateSchema.parse(request.body ?? {})
const workspace = await deps.workspaceManager.create(body.path, body.name)
reply.code(201)
return workspace
try {
const body = WorkspaceCreateSchema.parse(request.body ?? {})
const workspace = await deps.workspaceManager.create(body.path, body.name)
reply.code(201)
return workspace
} catch (error) {
request.log.error({ err: error }, "Failed to create workspace")
const message = error instanceof Error ? error.message : "Failed to create workspace"
reply.code(400).type("text/plain").send(message)
}
})
app.get<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => {

View File

@@ -7,6 +7,7 @@ import type { InstanceData } from "../api-types"
const DEFAULT_INSTANCE_DATA: InstanceData = {
messageHistory: [],
agentModelSelections: {},
sessionTasks: {},
}
export class InstanceStore {

View File

@@ -1,5 +1,6 @@
import path from "path"
import { spawnSync } from "child_process"
import { connect } from "net"
import { EventBus } from "../events/bus"
import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries"
@@ -7,8 +8,11 @@ import { FileSystemBrowser } from "../filesystem/browser"
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
import { WorkspaceRuntime } from "./runtime"
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
import { Logger } from "../logger"
import { getOpencodeConfigDir } from "../opencode-config"
const STARTUP_STABILITY_DELAY_MS = 1500
interface WorkspaceManagerOptions {
rootDir: string
@@ -23,9 +27,11 @@ interface WorkspaceRecord extends WorkspaceDescriptor {}
export class WorkspaceManager {
private readonly workspaces = new Map<string, WorkspaceRecord>()
private readonly runtime: WorkspaceRuntime
private readonly opencodeConfigDir: string
constructor(private readonly options: WorkspaceManagerOptions) {
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
this.opencodeConfigDir = getOpencodeConfigDir()
}
list(): WorkspaceDescriptor[] {
@@ -97,10 +103,15 @@ export class WorkspaceManager {
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
const environment = this.options.configStore.get().preferences.environmentVariables ?? {}
const preferences = this.options.configStore.get().preferences ?? {}
const userEnvironment = preferences.environmentVariables ?? {}
const environment = {
...userEnvironment,
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
}
try {
const { pid, port } = await this.runtime.launch({
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
workspaceId: id,
folder: workspacePath,
binaryPath: resolvedBinaryPath,
@@ -108,6 +119,8 @@ export class WorkspaceManager {
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
})
await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
descriptor.pid = pid
descriptor.port = port
descriptor.status = "ready"
@@ -117,11 +130,20 @@ export class WorkspaceManager {
return descriptor
} catch (error) {
descriptor.status = "error"
descriptor.error = error instanceof Error ? error.message : String(error)
let errorMessage = error instanceof Error ? error.message : String(error)
// Check for common OpenCode issues
if (errorMessage.includes('ENOENT') || errorMessage.includes('command not found')) {
errorMessage = `OpenCode binary not found at '${resolvedBinaryPath}'. Please install OpenCode CLI from https://opencode.ai/ and ensure it's in your PATH.`
} else if (errorMessage.includes('health check')) {
errorMessage = `Workspace health check failed. OpenCode started but is not responding correctly. Check OpenCode logs for details.`
}
descriptor.error = errorMessage
descriptor.updatedAt = new Date().toISOString()
this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor })
this.options.logger.error({ workspaceId: id, err: error }, "Workspace failed to start")
throw error
throw new Error(errorMessage)
}
}
@@ -233,6 +255,200 @@ export class WorkspaceManager {
return undefined
}
private async waitForWorkspaceReadiness(params: {
workspaceId: string
port: number
exitPromise: Promise<ProcessExitInfo>
getLastOutput: () => string
}) {
await Promise.race([
this.waitForPortAvailability(params.port),
params.exitPromise.then((info) => {
throw this.buildStartupError(
params.workspaceId,
"exited before becoming ready",
info,
params.getLastOutput(),
)
}),
])
await this.waitForInstanceHealth(params)
await Promise.race([
this.delay(STARTUP_STABILITY_DELAY_MS),
params.exitPromise.then((info) => {
throw this.buildStartupError(
params.workspaceId,
"exited shortly after start",
info,
params.getLastOutput(),
)
}),
])
}
private async waitForInstanceHealth(params: {
workspaceId: string
port: number
exitPromise: Promise<ProcessExitInfo>
getLastOutput: () => string
}) {
const probeResult = await Promise.race([
this.probeInstance(params.workspaceId, params.port),
params.exitPromise.then((info) => {
throw this.buildStartupError(
params.workspaceId,
"exited during health checks",
info,
params.getLastOutput(),
)
}),
])
if (probeResult.ok) {
return
}
const latestOutput = params.getLastOutput().trim()
const outputDetails = latestOutput ? ` Last output: ${latestOutput}` : ""
const reason = probeResult.reason ?? "Health check failed"
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.${outputDetails}`)
}
private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> {
// Try multiple possible health check endpoints
const endpoints = [
`/project/current`,
`/health`,
`/status`,
`/`,
`/api/health`
]
this.options.logger.info({ workspaceId, port, endpoints }, "Starting health check probe")
for (const endpoint of endpoints) {
const url = `http://127.0.0.1:${port}${endpoint}`
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10000) // 10 second timeout
const response = await fetch(url, {
method: 'GET',
headers: {
'User-Agent': 'NomadArch-HealthCheck/1.0'
},
signal: controller.signal
})
clearTimeout(timeoutId)
this.options.logger.debug({ workspaceId, status: response.status, url, endpoint }, "Health probe response received")
if (response.ok) {
this.options.logger.info({ workspaceId, port, endpoint }, "Health check passed")
return { ok: true }
} else {
this.options.logger.debug({ workspaceId, status: response.status, url, endpoint }, "Health probe endpoint returned error")
}
} catch (error) {
this.options.logger.debug({ workspaceId, port, err: error, url, endpoint }, "Health probe endpoint failed")
// Continue to next endpoint
}
}
// All endpoints failed
const reason = `OpenCode server started but is not responding to any known health endpoints (/project/current, /health, /status, /, /api/health)`
this.options.logger.error({ workspaceId, port }, "All health check endpoints failed")
return { ok: false, reason }
}
private buildStartupError(
workspaceId: string,
phase: string,
exitInfo: ProcessExitInfo,
lastOutput: string,
): Error {
const exitDetails = this.describeExit(exitInfo)
const trimmedOutput = lastOutput.trim()
const outputDetails = trimmedOutput ? ` Last output: ${trimmedOutput}` : ""
return new Error(`Workspace ${workspaceId} ${phase} (${exitDetails}).${outputDetails}`)
}
private waitForPortAvailability(port: number, timeoutMs = 5000): Promise<void> {
this.options.logger.info({ port, timeoutMs }, "Waiting for port availability - STARTING")
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeoutMs
let settled = false
let retryTimer: NodeJS.Timeout | null = null
let attemptCount = 0
const cleanup = () => {
settled = true
if (retryTimer) {
clearTimeout(retryTimer)
retryTimer = null
}
}
const tryConnect = () => {
if (settled) {
return
}
attemptCount++
this.options.logger.debug({ port, attempt: attemptCount, timeRemaining: Math.max(0, deadline - Date.now()) }, "Attempting to connect to workspace port")
const socket = connect({ port, host: "127.0.0.1" }, () => {
this.options.logger.info({ port, attempt: attemptCount }, "Port is available - SUCCESS")
cleanup()
socket.end()
resolve()
})
socket.once("error", (error) => {
this.options.logger.debug({ port, attempt: attemptCount, err: error instanceof Error ? error.message : String(error) }, "Port connection failed - retrying")
socket.destroy()
if (settled) {
return
}
if (Date.now() >= deadline) {
this.options.logger.error({ port, attempt: attemptCount, timeoutMs }, "Port did not become available - TIMEOUT")
cleanup()
reject(new Error(`Workspace port ${port} did not become ready within ${timeoutMs}ms`))
} else {
retryTimer = setTimeout(() => {
retryTimer = null
tryConnect()
}, 100)
}
})
}
tryConnect()
})
}
private delay(durationMs: number): Promise<void> {
if (durationMs <= 0) {
return Promise.resolve()
}
return new Promise((resolve) => setTimeout(resolve, durationMs))
}
private describeExit(info: ProcessExitInfo): string {
if (info.signal) {
return `signal ${info.signal}`
}
if (info.code !== null) {
return `code ${info.code}`
}
return "unknown reason"
}
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
const workspace = this.workspaces.get(workspaceId)
if (!workspace) return

View File

@@ -1,5 +1,5 @@
import { ChildProcess, spawn } from "child_process"
import { existsSync, statSync } from "fs"
import { existsSync, statSync, accessSync, constants } from "fs"
import path from "path"
import { EventBus } from "../events/bus"
import { LogLevel, WorkspaceLogEntry } from "../api-types"
@@ -13,7 +13,7 @@ interface LaunchOptions {
onExit?: (info: ProcessExitInfo) => void
}
interface ProcessExitInfo {
export interface ProcessExitInfo {
workspaceId: string
code: number | null
signal: NodeJS.Signals | null
@@ -30,11 +30,35 @@ export class WorkspaceRuntime {
constructor(private readonly eventBus: EventBus, private readonly logger: Logger) {}
async launch(options: LaunchOptions): Promise<{ pid: number; port: number }> {
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
this.validateFolder(options.folder)
// Check if binary exists before attempting to launch
try {
accessSync(options.binaryPath, constants.F_OK)
} catch (error) {
throw new Error(`OpenCode binary not found: ${options.binaryPath}. Please install OpenCode CLI from https://opencode.ai/ and ensure it's in your PATH.`)
}
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
const env = { ...process.env, ...(options.environment ?? {}) }
const env = {
...process.env,
...(options.environment ?? {}),
"OPENCODE_SERVER_HOST": "127.0.0.1",
"OPENCODE_SERVER_PORT": "0",
"NODE_ENV": "production"
}
this.logger.info(
{ workspaceId: options.workspaceId, binaryPath: options.binaryPath, args },
"Starting OpenCode with arguments"
)
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
const exitPromise = new Promise<ProcessExitInfo>((resolveExit) => {
exitResolve = resolveExit
})
let lastOutput = ""
return new Promise((resolve, reject) => {
this.logger.info(
@@ -83,11 +107,21 @@ export class WorkspaceRuntime {
cleanupStreams()
child.removeListener("error", handleError)
child.removeListener("exit", handleExit)
const exitInfo: ProcessExitInfo = {
workspaceId: options.workspaceId,
code,
signal,
requested: managed.requestedStop,
}
if (exitResolve) {
exitResolve(exitInfo)
exitResolve = null
}
if (!portFound) {
const reason = stderrBuffer || `Process exited with code ${code}`
reject(new Error(reason))
} else {
options.onExit?.({ workspaceId: options.workspaceId, code, signal, requested: managed.requestedStop })
options.onExit?.(exitInfo)
}
}
@@ -96,6 +130,10 @@ export class WorkspaceRuntime {
child.removeListener("exit", handleExit)
this.processes.delete(options.workspaceId)
this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error")
if (exitResolve) {
exitResolve({ workspaceId: options.workspaceId, code: null, signal: null, requested: managed.requestedStop })
exitResolve = null
}
reject(error)
}
@@ -109,18 +147,28 @@ export class WorkspaceRuntime {
stdoutBuffer = lines.pop() ?? ""
for (const line of lines) {
if (!line.trim()) continue
const trimmed = line.trim()
if (!trimmed) continue
lastOutput = trimmed
this.emitLog(options.workspaceId, "info", line)
if (!portFound) {
const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i)
this.logger.debug({ workspaceId: options.workspaceId, line: trimmed }, "OpenCode output line")
// Try multiple patterns for port detection
const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i) ||
line.match(/server listening on http:\/\/.+:(\d+)/i) ||
line.match(/listening on http:\/\/.+:(\d+)/i) ||
line.match(/:(\d+)/i)
if (portMatch) {
portFound = true
cleanupStreams()
child.removeListener("error", handleError)
const port = parseInt(portMatch[1], 10)
this.logger.info({ workspaceId: options.workspaceId, port }, "Workspace runtime allocated port")
resolve({ pid: child.pid!, port })
this.logger.info({ workspaceId: options.workspaceId, port, matchedLine: trimmed }, "Workspace runtime allocated port - PORT DETECTED")
const getLastOutput = () => lastOutput.trim()
resolve({ pid: child.pid!, port, exitPromise, getLastOutput })
} else {
this.logger.debug({ workspaceId: options.workspaceId, line: trimmed }, "Port detection - no match in this line")
}
}
}
@@ -133,7 +181,9 @@ export class WorkspaceRuntime {
stderrBuffer = lines.pop() ?? ""
for (const line of lines) {
if (!line.trim()) continue
const trimmed = line.trim()
if (!trimmed) continue
lastOutput = `[stderr] ${trimmed}`
this.emitLog(options.workspaceId, "error", line)
}
})