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:
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
273
packages/server/src/integrations/ollama-cloud.ts
Normal file
273
packages/server/src/integrations/ollama-cloud.ts
Normal 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]
|
||||
@@ -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 })
|
||||
|
||||
|
||||
|
||||
224
packages/server/src/server/routes/ollama.ts
Normal file
224
packages/server/src/server/routes/ollama.ts
Normal 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))
|
||||
}
|
||||
134
packages/server/src/server/routes/qwen.ts
Normal file
134
packages/server/src/server/routes/qwen.ts
Normal 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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { InstanceData } from "../api-types"
|
||||
const DEFAULT_INSTANCE_DATA: InstanceData = {
|
||||
messageHistory: [],
|
||||
agentModelSelections: {},
|
||||
sessionTasks: {},
|
||||
}
|
||||
|
||||
export class InstanceStore {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user