Add structured logging and ensure CLI shuts down cleanly
This commit is contained in:
1
package-lock.json
generated
1
package-lock.json
generated
@@ -7914,6 +7914,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
|
"pino": "^9.4.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
|
"pino": "^9.4.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -7,9 +7,14 @@ import {
|
|||||||
import { ConfigStore } from "./store"
|
import { ConfigStore } from "./store"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import type { ConfigFileUpdate } from "./schema"
|
import type { ConfigFileUpdate } from "./schema"
|
||||||
|
import { Logger } from "../logger"
|
||||||
|
|
||||||
export class BinaryRegistry {
|
export class BinaryRegistry {
|
||||||
constructor(private readonly configStore: ConfigStore, private readonly eventBus?: EventBus) {}
|
constructor(
|
||||||
|
private readonly configStore: ConfigStore,
|
||||||
|
private readonly eventBus: EventBus | undefined,
|
||||||
|
private readonly logger: Logger,
|
||||||
|
) {}
|
||||||
|
|
||||||
list(): BinaryRecord[] {
|
list(): BinaryRecord[] {
|
||||||
return this.mapRecords()
|
return this.mapRecords()
|
||||||
@@ -18,12 +23,14 @@ export class BinaryRegistry {
|
|||||||
resolveDefault(): BinaryRecord {
|
resolveDefault(): BinaryRecord {
|
||||||
const binaries = this.mapRecords()
|
const binaries = this.mapRecords()
|
||||||
if (binaries.length === 0) {
|
if (binaries.length === 0) {
|
||||||
|
this.logger.warn("No configured binaries found, falling back to opencode")
|
||||||
return this.buildFallbackRecord("opencode")
|
return this.buildFallbackRecord("opencode")
|
||||||
}
|
}
|
||||||
return binaries.find((binary) => binary.isDefault) ?? binaries[0]
|
return binaries.find((binary) => binary.isDefault) ?? binaries[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
create(request: BinaryCreateRequest): BinaryRecord {
|
create(request: BinaryCreateRequest): BinaryRecord {
|
||||||
|
this.logger.info({ path: request.path }, "Registering OpenCode binary")
|
||||||
const entry = {
|
const entry = {
|
||||||
path: request.path,
|
path: request.path,
|
||||||
version: undefined,
|
version: undefined,
|
||||||
@@ -49,6 +56,7 @@ export class BinaryRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
update(id: string, updates: BinaryUpdateRequest): BinaryRecord {
|
update(id: string, updates: BinaryUpdateRequest): BinaryRecord {
|
||||||
|
this.logger.info({ id }, "Updating OpenCode binary")
|
||||||
const config = this.configStore.get()
|
const config = this.configStore.get()
|
||||||
const updatedEntries = config.opencodeBinaries.map((binary) =>
|
const updatedEntries = config.opencodeBinaries.map((binary) =>
|
||||||
binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary,
|
binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary,
|
||||||
@@ -69,6 +77,7 @@ export class BinaryRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
remove(id: string) {
|
remove(id: string) {
|
||||||
|
this.logger.info({ id }, "Removing OpenCode binary")
|
||||||
const config = this.configStore.get()
|
const config = this.configStore.get()
|
||||||
const remaining = config.opencodeBinaries.filter((binary) => binary.path !== id)
|
const remaining = config.opencodeBinaries.filter((binary) => binary.path !== id)
|
||||||
const update: ConfigFileUpdate = { opencodeBinaries: remaining }
|
const update: ConfigFileUpdate = { opencodeBinaries: remaining }
|
||||||
@@ -82,6 +91,7 @@ export class BinaryRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
validatePath(path: string): BinaryValidationResult {
|
validatePath(path: string): BinaryValidationResult {
|
||||||
|
this.logger.debug({ path }, "Validating OpenCode binary path")
|
||||||
return this.validateRecord({
|
return this.validateRecord({
|
||||||
id: path,
|
id: path,
|
||||||
path,
|
path,
|
||||||
@@ -119,6 +129,7 @@ export class BinaryRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private emitChange() {
|
private emitChange() {
|
||||||
|
this.logger.debug("Emitting binaries changed event")
|
||||||
this.eventBus?.publish({ type: "config.binariesChanged", binaries: this.mapRecords() })
|
this.eventBus?.publish({ type: "config.binariesChanged", binaries: this.mapRecords() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
|
import { Logger } from "../logger"
|
||||||
import {
|
import {
|
||||||
AgentModelSelections,
|
AgentModelSelections,
|
||||||
ConfigFile,
|
ConfigFile,
|
||||||
@@ -14,7 +15,11 @@ export class ConfigStore {
|
|||||||
private cache: ConfigFile = DEFAULT_CONFIG
|
private cache: ConfigFile = DEFAULT_CONFIG
|
||||||
private loaded = false
|
private loaded = false
|
||||||
|
|
||||||
constructor(private readonly configPath: string, private readonly eventBus?: EventBus) {}
|
constructor(
|
||||||
|
private readonly configPath: string,
|
||||||
|
private readonly eventBus: EventBus | undefined,
|
||||||
|
private readonly logger: Logger,
|
||||||
|
) {}
|
||||||
|
|
||||||
load(): ConfigFile {
|
load(): ConfigFile {
|
||||||
if (this.loaded) {
|
if (this.loaded) {
|
||||||
@@ -27,11 +32,13 @@ export class ConfigStore {
|
|||||||
const content = fs.readFileSync(resolved, "utf-8")
|
const content = fs.readFileSync(resolved, "utf-8")
|
||||||
const parsed = JSON.parse(content)
|
const parsed = JSON.parse(content)
|
||||||
this.cache = ConfigFileSchema.parse(parsed)
|
this.cache = ConfigFileSchema.parse(parsed)
|
||||||
|
this.logger.debug({ resolved }, "Loaded existing config file")
|
||||||
} else {
|
} else {
|
||||||
this.cache = DEFAULT_CONFIG
|
this.cache = DEFAULT_CONFIG
|
||||||
|
this.logger.info({ resolved }, "No config file found, using defaults")
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Failed to load config", error)
|
this.logger.warn({ err: error }, "Failed to load config, using defaults")
|
||||||
this.cache = DEFAULT_CONFIG
|
this.cache = DEFAULT_CONFIG
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +59,7 @@ export class ConfigStore {
|
|||||||
this.cache = ConfigFileSchema.parse(merged)
|
this.cache = ConfigFileSchema.parse(merged)
|
||||||
this.persist()
|
this.persist()
|
||||||
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
|
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
|
||||||
|
this.logger.info("Config updated")
|
||||||
}
|
}
|
||||||
|
|
||||||
private mergeConfig(current: ConfigFile, partial: ConfigFile | ConfigFileUpdate): ConfigFile {
|
private mergeConfig(current: ConfigFile, partial: ConfigFile | ConfigFileUpdate): ConfigFile {
|
||||||
@@ -97,8 +105,9 @@ export class ConfigStore {
|
|||||||
const resolved = this.resolvePath(this.configPath)
|
const resolved = this.resolvePath(this.configPath)
|
||||||
fs.mkdirSync(path.dirname(resolved), { recursive: true })
|
fs.mkdirSync(path.dirname(resolved), { recursive: true })
|
||||||
fs.writeFileSync(resolved, JSON.stringify(this.cache, null, 2), "utf-8")
|
fs.writeFileSync(resolved, JSON.stringify(this.cache, null, 2), "utf-8")
|
||||||
|
this.logger.debug({ resolved }, "Persisted config file")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Failed to persist config", error)
|
this.logger.warn({ err: error }, "Failed to persist config")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { EventEmitter } from "events"
|
import { EventEmitter } from "events"
|
||||||
import { WorkspaceEventPayload } from "../api-types"
|
import { WorkspaceEventPayload } from "../api-types"
|
||||||
|
import { Logger } from "../logger"
|
||||||
|
|
||||||
export class EventBus extends EventEmitter {
|
export class EventBus extends EventEmitter {
|
||||||
|
constructor(private readonly logger?: Logger) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
publish(event: WorkspaceEventPayload): boolean {
|
publish(event: WorkspaceEventPayload): boolean {
|
||||||
|
this.logger?.debug({ event }, "Publishing workspace event")
|
||||||
return super.emit(event.type, event)
|
return super.emit(event.type, event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ import { FileSystemBrowser } from "./filesystem/browser"
|
|||||||
import { EventBus } from "./events/bus"
|
import { EventBus } from "./events/bus"
|
||||||
import { ServerMeta } from "./api-types"
|
import { ServerMeta } from "./api-types"
|
||||||
import { InstanceStore } from "./storage/instance-store"
|
import { InstanceStore } from "./storage/instance-store"
|
||||||
|
import { createLogger } from "./logger"
|
||||||
|
|
||||||
interface CliOptions {
|
interface CliOptions {
|
||||||
port: number
|
port: number
|
||||||
host: string
|
host: string
|
||||||
rootDir: string
|
rootDir: string
|
||||||
configPath: string
|
configPath: string
|
||||||
|
logLevel?: string
|
||||||
|
logDestination?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCliOptions(argv: string[]): CliOptions {
|
function parseCliOptions(argv: string[]): CliOptions {
|
||||||
@@ -34,20 +37,26 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
host: args.get("host") ?? process.env.CLI_HOST ?? "127.0.0.1",
|
host: args.get("host") ?? process.env.CLI_HOST ?? "127.0.0.1",
|
||||||
rootDir: args.get("root") ?? process.cwd(),
|
rootDir: args.get("root") ?? process.cwd(),
|
||||||
configPath: args.get("config") ?? process.env.CLI_CONFIG ?? "~/.config/codenomad/config.json",
|
configPath: args.get("config") ?? process.env.CLI_CONFIG ?? "~/.config/codenomad/config.json",
|
||||||
|
logLevel: args.get("log-level") ?? process.env.CLI_LOG_LEVEL,
|
||||||
|
logDestination: args.get("log-destination") ?? process.env.CLI_LOG_DESTINATION,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const options = parseCliOptions(process.argv.slice(2))
|
const options = parseCliOptions(process.argv.slice(2))
|
||||||
|
const logger = createLogger({ level: options.logLevel, destination: options.logDestination })
|
||||||
|
|
||||||
const eventBus = new EventBus()
|
logger.info({ options }, "Starting CodeNomad CLI server")
|
||||||
const configStore = new ConfigStore(options.configPath, eventBus)
|
|
||||||
const binaryRegistry = new BinaryRegistry(configStore, eventBus)
|
const eventBus = new EventBus(logger)
|
||||||
|
const configStore = new ConfigStore(options.configPath, eventBus, logger)
|
||||||
|
const binaryRegistry = new BinaryRegistry(configStore, eventBus, logger)
|
||||||
const workspaceManager = new WorkspaceManager({
|
const workspaceManager = new WorkspaceManager({
|
||||||
rootDir: options.rootDir,
|
rootDir: options.rootDir,
|
||||||
configStore,
|
configStore,
|
||||||
binaryRegistry,
|
binaryRegistry,
|
||||||
eventBus,
|
eventBus,
|
||||||
|
logger,
|
||||||
})
|
})
|
||||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir })
|
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir })
|
||||||
const instanceStore = new InstanceStore()
|
const instanceStore = new InstanceStore()
|
||||||
@@ -69,13 +78,36 @@ async function main() {
|
|||||||
eventBus,
|
eventBus,
|
||||||
serverMeta,
|
serverMeta,
|
||||||
instanceStore,
|
instanceStore,
|
||||||
|
logger,
|
||||||
})
|
})
|
||||||
|
|
||||||
await server.start()
|
await server.start()
|
||||||
|
logger.info({ port: options.port, host: options.host }, "HTTP server listening")
|
||||||
|
|
||||||
|
let shuttingDown = false
|
||||||
|
|
||||||
const shutdown = async () => {
|
const shutdown = async () => {
|
||||||
await server.stop()
|
if (shuttingDown) {
|
||||||
await workspaceManager.shutdown()
|
logger.info("Shutdown already in progress, ignoring signal")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shuttingDown = true
|
||||||
|
logger.info("Received shutdown signal, closing server")
|
||||||
|
try {
|
||||||
|
await server.stop()
|
||||||
|
logger.info("HTTP server stopped")
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, "Failed to stop HTTP server")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await workspaceManager.shutdown()
|
||||||
|
logger.info("Workspace manager shutdown complete")
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, "Workspace manager shutdown failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Exiting process")
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +116,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
console.error("CLI server crashed", error)
|
const logger = createLogger()
|
||||||
|
logger.error({ err: error }, "CLI server crashed")
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
|
|||||||
20
packages/cli/src/logger.ts
Normal file
20
packages/cli/src/logger.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import pino, { Logger as PinoLogger } from "pino"
|
||||||
|
|
||||||
|
export type Logger = PinoLogger
|
||||||
|
|
||||||
|
interface LoggerOptions {
|
||||||
|
level?: string
|
||||||
|
destination?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLogger(options: LoggerOptions = {}): Logger {
|
||||||
|
const level = (options.level ?? process.env.CLI_LOG_LEVEL ?? "info").toLowerCase()
|
||||||
|
const destination = options.destination ?? process.env.CLI_LOG_DESTINATION ?? "stdout"
|
||||||
|
|
||||||
|
if (destination && destination !== "stdout") {
|
||||||
|
const stream = pino.destination({ dest: destination, mkdir: true, sync: false })
|
||||||
|
return pino({ level }, stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pino({ level })
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { registerEventRoutes } from "./routes/events"
|
|||||||
import { registerStorageRoutes } from "./routes/storage"
|
import { registerStorageRoutes } from "./routes/storage"
|
||||||
import { ServerMeta } from "../api-types"
|
import { ServerMeta } from "../api-types"
|
||||||
import { InstanceStore } from "../storage/instance-store"
|
import { InstanceStore } from "../storage/instance-store"
|
||||||
|
import { Logger } from "../logger"
|
||||||
|
|
||||||
interface HttpServerDeps {
|
interface HttpServerDeps {
|
||||||
host: string
|
host: string
|
||||||
@@ -24,10 +25,24 @@ interface HttpServerDeps {
|
|||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
serverMeta: ServerMeta
|
serverMeta: ServerMeta
|
||||||
instanceStore: InstanceStore
|
instanceStore: InstanceStore
|
||||||
|
logger: Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createHttpServer(deps: HttpServerDeps) {
|
export function createHttpServer(deps: HttpServerDeps) {
|
||||||
const app = Fastify({ logger: false })
|
const fastifyLogger = deps.logger.child({ module: "http" })
|
||||||
|
const app = Fastify({ logger: fastifyLogger as any })
|
||||||
|
|
||||||
|
const sseClients = new Set<() => void>()
|
||||||
|
const registerSseClient = (cleanup: () => void) => {
|
||||||
|
sseClients.add(cleanup)
|
||||||
|
return () => sseClients.delete(cleanup)
|
||||||
|
}
|
||||||
|
const closeSseClients = () => {
|
||||||
|
for (const cleanup of Array.from(sseClients)) {
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
sseClients.clear()
|
||||||
|
}
|
||||||
|
|
||||||
app.register(cors, {
|
app.register(cors, {
|
||||||
origin: true,
|
origin: true,
|
||||||
@@ -38,12 +53,15 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
|
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
|
||||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||||
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||||
registerEventRoutes(app, { eventBus: deps.eventBus })
|
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient })
|
||||||
registerStorageRoutes(app, { instanceStore: deps.instanceStore })
|
registerStorageRoutes(app, { instanceStore: deps.instanceStore })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
instance: app,
|
instance: app,
|
||||||
start: () => app.listen({ port: deps.port, host: deps.host }),
|
start: () => app.listen({ port: deps.port, host: deps.host }),
|
||||||
stop: () => app.close(),
|
stop: () => {
|
||||||
|
closeSseClients()
|
||||||
|
return app.close()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { WorkspaceEventPayload } from "../../api-types"
|
|||||||
|
|
||||||
interface RouteDeps {
|
interface RouteDeps {
|
||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
|
registerClient: (cleanup: () => void) => () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
@@ -26,12 +27,23 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
reply.raw.write(`:hb ${Date.now()}\n\n`)
|
reply.raw.write(`:hb ${Date.now()}\n\n`)
|
||||||
}, 15000)
|
}, 15000)
|
||||||
|
|
||||||
|
let closed = false
|
||||||
const close = () => {
|
const close = () => {
|
||||||
|
if (closed) return
|
||||||
|
closed = true
|
||||||
clearInterval(heartbeat)
|
clearInterval(heartbeat)
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
|
reply.raw.end?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
request.raw.on("close", close)
|
const unregister = deps.registerClient(close)
|
||||||
request.raw.on("error", close)
|
|
||||||
|
const handleClose = () => {
|
||||||
|
close()
|
||||||
|
unregister()
|
||||||
|
}
|
||||||
|
|
||||||
|
request.raw.on("close", handleClose)
|
||||||
|
request.raw.on("error", handleClose)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import { BinaryRegistry } from "../config/binaries"
|
|||||||
import { FileSystemBrowser } from "../filesystem/browser"
|
import { FileSystemBrowser } from "../filesystem/browser"
|
||||||
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
|
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
|
||||||
import { WorkspaceRuntime } from "./runtime"
|
import { WorkspaceRuntime } from "./runtime"
|
||||||
|
import { Logger } from "../logger"
|
||||||
|
|
||||||
interface WorkspaceManagerOptions {
|
interface WorkspaceManagerOptions {
|
||||||
rootDir: string
|
rootDir: string
|
||||||
configStore: ConfigStore
|
configStore: ConfigStore
|
||||||
binaryRegistry: BinaryRegistry
|
binaryRegistry: BinaryRegistry
|
||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
|
logger: Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkspaceRecord extends WorkspaceDescriptor {}
|
interface WorkspaceRecord extends WorkspaceDescriptor {}
|
||||||
@@ -20,7 +22,7 @@ export class WorkspaceManager {
|
|||||||
private readonly runtime: WorkspaceRuntime
|
private readonly runtime: WorkspaceRuntime
|
||||||
|
|
||||||
constructor(private readonly options: WorkspaceManagerOptions) {
|
constructor(private readonly options: WorkspaceManagerOptions) {
|
||||||
this.runtime = new WorkspaceRuntime(this.options.eventBus)
|
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
list(): WorkspaceDescriptor[] {
|
list(): WorkspaceDescriptor[] {
|
||||||
@@ -53,6 +55,8 @@ export class WorkspaceManager {
|
|||||||
const binary = this.options.binaryRegistry.resolveDefault()
|
const binary = this.options.binaryRegistry.resolveDefault()
|
||||||
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
||||||
|
|
||||||
|
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: binary.path }, "Creating workspace")
|
||||||
|
|
||||||
const descriptor: WorkspaceRecord = {
|
const descriptor: WorkspaceRecord = {
|
||||||
id,
|
id,
|
||||||
path: workspacePath,
|
path: workspacePath,
|
||||||
@@ -84,12 +88,14 @@ export class WorkspaceManager {
|
|||||||
descriptor.status = "ready"
|
descriptor.status = "ready"
|
||||||
descriptor.updatedAt = new Date().toISOString()
|
descriptor.updatedAt = new Date().toISOString()
|
||||||
this.options.eventBus.publish({ type: "workspace.started", workspace: descriptor })
|
this.options.eventBus.publish({ type: "workspace.started", workspace: descriptor })
|
||||||
|
this.options.logger.info({ workspaceId: id, port }, "Workspace ready")
|
||||||
return descriptor
|
return descriptor
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
descriptor.status = "error"
|
descriptor.status = "error"
|
||||||
descriptor.error = error instanceof Error ? error.message : String(error)
|
descriptor.error = error instanceof Error ? error.message : String(error)
|
||||||
descriptor.updatedAt = new Date().toISOString()
|
descriptor.updatedAt = new Date().toISOString()
|
||||||
this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor })
|
this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor })
|
||||||
|
this.options.logger.error({ workspaceId: id, err: error }, "Workspace failed to start")
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,9 +104,12 @@ export class WorkspaceManager {
|
|||||||
const workspace = this.workspaces.get(id)
|
const workspace = this.workspaces.get(id)
|
||||||
if (!workspace) return undefined
|
if (!workspace) return undefined
|
||||||
|
|
||||||
|
this.options.logger.info({ workspaceId: id }, "Stopping workspace")
|
||||||
const wasRunning = Boolean(workspace.pid)
|
const wasRunning = Boolean(workspace.pid)
|
||||||
if (wasRunning) {
|
if (wasRunning) {
|
||||||
await this.runtime.stop(id).catch(() => {})
|
await this.runtime.stop(id).catch((error) => {
|
||||||
|
this.options.logger.warn({ workspaceId: id, err: error }, "Failed to stop workspace process cleanly")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
this.workspaces.delete(id)
|
this.workspaces.delete(id)
|
||||||
@@ -111,12 +120,19 @@ export class WorkspaceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async shutdown() {
|
async shutdown() {
|
||||||
for (const [id] of this.workspaces) {
|
this.options.logger.info("Shutting down all workspaces")
|
||||||
if (this.workspaces.get(id)?.pid) {
|
for (const [id, workspace] of this.workspaces) {
|
||||||
await this.runtime.stop(id).catch(() => {})
|
if (workspace.pid) {
|
||||||
|
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
|
||||||
|
await this.runtime.stop(id).catch((error) => {
|
||||||
|
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.options.logger.debug({ workspaceId: id }, "Workspace already stopped")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.workspaces.clear()
|
this.workspaces.clear()
|
||||||
|
this.options.logger.info("All workspaces cleared")
|
||||||
}
|
}
|
||||||
|
|
||||||
private requireWorkspace(id: string): WorkspaceRecord {
|
private requireWorkspace(id: string): WorkspaceRecord {
|
||||||
@@ -131,6 +147,8 @@ export class WorkspaceManager {
|
|||||||
const workspace = this.workspaces.get(workspaceId)
|
const workspace = this.workspaces.get(workspaceId)
|
||||||
if (!workspace) return
|
if (!workspace) return
|
||||||
|
|
||||||
|
this.options.logger.info({ workspaceId, ...info }, "Workspace process exited")
|
||||||
|
|
||||||
workspace.pid = undefined
|
workspace.pid = undefined
|
||||||
workspace.port = undefined
|
workspace.port = undefined
|
||||||
workspace.updatedAt = new Date().toISOString()
|
workspace.updatedAt = new Date().toISOString()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { existsSync, statSync } from "fs"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
||||||
|
import { Logger } from "../logger"
|
||||||
|
|
||||||
interface LaunchOptions {
|
interface LaunchOptions {
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
@@ -27,7 +28,7 @@ interface ManagedProcess {
|
|||||||
export class WorkspaceRuntime {
|
export class WorkspaceRuntime {
|
||||||
private processes = new Map<string, ManagedProcess>()
|
private processes = new Map<string, ManagedProcess>()
|
||||||
|
|
||||||
constructor(private readonly eventBus: EventBus) {}
|
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 }> {
|
||||||
this.validateFolder(options.folder)
|
this.validateFolder(options.folder)
|
||||||
@@ -36,6 +37,7 @@ export class WorkspaceRuntime {
|
|||||||
const env = { ...process.env, ...(options.environment ?? {}) }
|
const env = { ...process.env, ...(options.environment ?? {}) }
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
this.logger.info({ workspaceId: options.workspaceId, folder: options.folder }, "Launching OpenCode process")
|
||||||
const child = spawn(options.binaryPath, args, {
|
const child = spawn(options.binaryPath, args, {
|
||||||
cwd: options.folder,
|
cwd: options.folder,
|
||||||
env,
|
env,
|
||||||
@@ -49,22 +51,36 @@ export class WorkspaceRuntime {
|
|||||||
let stderrBuffer = ""
|
let stderrBuffer = ""
|
||||||
let portFound = false
|
let portFound = false
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
let warningTimer: NodeJS.Timeout | null = null
|
||||||
child.kill("SIGKILL")
|
|
||||||
reject(new Error("Server startup timeout (10s exceeded)"))
|
|
||||||
}, 10000)
|
|
||||||
|
|
||||||
const cleanup = () => {
|
const startWarningTimer = () => {
|
||||||
clearTimeout(timeout)
|
warningTimer = setInterval(() => {
|
||||||
|
this.logger.warn({ workspaceId: options.workspaceId }, "Workspace runtime has not reported a port yet")
|
||||||
|
}, 10000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopWarningTimer = () => {
|
||||||
|
if (warningTimer) {
|
||||||
|
clearInterval(warningTimer)
|
||||||
|
warningTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startWarningTimer()
|
||||||
|
|
||||||
|
const cleanupStreams = () => {
|
||||||
|
stopWarningTimer()
|
||||||
child.stdout?.removeAllListeners()
|
child.stdout?.removeAllListeners()
|
||||||
child.stderr?.removeAllListeners()
|
child.stderr?.removeAllListeners()
|
||||||
child.removeListener("error", handleError)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||||
|
this.logger.info({ workspaceId: options.workspaceId, code, signal }, "OpenCode process exited")
|
||||||
this.processes.delete(options.workspaceId)
|
this.processes.delete(options.workspaceId)
|
||||||
|
cleanupStreams()
|
||||||
|
child.removeListener("error", handleError)
|
||||||
|
child.removeListener("exit", handleExit)
|
||||||
if (!portFound) {
|
if (!portFound) {
|
||||||
cleanup()
|
|
||||||
const reason = stderrBuffer || `Process exited with code ${code}`
|
const reason = stderrBuffer || `Process exited with code ${code}`
|
||||||
reject(new Error(reason))
|
reject(new Error(reason))
|
||||||
} else {
|
} else {
|
||||||
@@ -73,9 +89,10 @@ export class WorkspaceRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleError = (error: Error) => {
|
const handleError = (error: Error) => {
|
||||||
cleanup()
|
cleanupStreams()
|
||||||
this.processes.delete(options.workspaceId)
|
|
||||||
child.removeListener("exit", handleExit)
|
child.removeListener("exit", handleExit)
|
||||||
|
this.processes.delete(options.workspaceId)
|
||||||
|
this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error")
|
||||||
reject(error)
|
reject(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,8 +113,11 @@ export class WorkspaceRuntime {
|
|||||||
const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i)
|
const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i)
|
||||||
if (portMatch) {
|
if (portMatch) {
|
||||||
portFound = true
|
portFound = true
|
||||||
cleanup()
|
cleanupStreams()
|
||||||
resolve({ pid: child.pid!, port: parseInt(portMatch[1], 10) })
|
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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,16 +134,6 @@ export class WorkspaceRuntime {
|
|||||||
this.emitLog(options.workspaceId, "error", line)
|
this.emitLog(options.workspaceId, "error", line)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
child.on("exit", (code, signal) => {
|
|
||||||
this.processes.delete(options.workspaceId)
|
|
||||||
if (!portFound) {
|
|
||||||
cleanup()
|
|
||||||
const reason = stderrBuffer || `Process exited with code ${code}`
|
|
||||||
reject(new Error(reason))
|
|
||||||
}
|
|
||||||
options.onExit?.({ workspaceId: options.workspaceId, code, signal, requested: managed.requestedStop })
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,24 +143,48 @@ export class WorkspaceRuntime {
|
|||||||
|
|
||||||
managed.requestedStop = true
|
managed.requestedStop = true
|
||||||
const child = managed.child
|
const child = managed.child
|
||||||
|
this.logger.info({ workspaceId }, "Stopping OpenCode process")
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const onExit = () => {
|
const cleanup = () => {
|
||||||
|
child.removeListener("exit", onExit)
|
||||||
child.removeListener("error", onError)
|
child.removeListener("error", onError)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onExit = () => {
|
||||||
|
cleanup()
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
const onError = (error: Error) => {
|
const onError = (error: Error) => {
|
||||||
child.removeListener("exit", onExit)
|
cleanup()
|
||||||
reject(error)
|
reject(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveIfAlreadyExited = () => {
|
||||||
|
if (child.exitCode !== null || child.signalCode !== null) {
|
||||||
|
this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited")
|
||||||
|
cleanup()
|
||||||
|
resolve()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
child.once("exit", onExit)
|
child.once("exit", onExit)
|
||||||
child.once("error", onError)
|
child.once("error", onError)
|
||||||
|
|
||||||
|
if (resolveIfAlreadyExited()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug({ workspaceId }, "Sending SIGTERM to workspace process")
|
||||||
child.kill("SIGTERM")
|
child.kill("SIGTERM")
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!child.killed) {
|
if (!child.killed) {
|
||||||
|
this.logger.warn({ workspaceId }, "Process did not stop after SIGTERM, force killing")
|
||||||
child.kill("SIGKILL")
|
child.kill("SIGKILL")
|
||||||
|
} else {
|
||||||
|
this.logger.debug({ workspaceId }, "Workspace process stopped gracefully before SIGKILL timeout")
|
||||||
}
|
}
|
||||||
}, 2000)
|
}, 2000)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
FileSystemEntry,
|
FileSystemEntry,
|
||||||
InstanceData,
|
InstanceData,
|
||||||
ServerMeta,
|
ServerMeta,
|
||||||
|
|
||||||
WorkspaceCreateRequest,
|
WorkspaceCreateRequest,
|
||||||
WorkspaceDescriptor,
|
WorkspaceDescriptor,
|
||||||
WorkspaceFileResponse,
|
WorkspaceFileResponse,
|
||||||
@@ -21,6 +21,15 @@ const DEFAULT_BASE = typeof window !== "undefined" ? window.__CODENOMAD_API_BASE
|
|||||||
const DEFAULT_EVENTS_URL = typeof window !== "undefined" ? window.__CODENOMAD_EVENTS_URL__ ?? "/api/events" : "/api/events"
|
const DEFAULT_EVENTS_URL = typeof window !== "undefined" ? window.__CODENOMAD_EVENTS_URL__ ?? "/api/events" : "/api/events"
|
||||||
const API_BASE = import.meta.env.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE
|
const API_BASE = import.meta.env.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE
|
||||||
const EVENTS_URL = API_BASE ? `${API_BASE}${DEFAULT_EVENTS_URL}` : DEFAULT_EVENTS_URL
|
const EVENTS_URL = API_BASE ? `${API_BASE}${DEFAULT_EVENTS_URL}` : DEFAULT_EVENTS_URL
|
||||||
|
const HTTP_PREFIX = "[HTTP]"
|
||||||
|
|
||||||
|
function logHttp(message: string, context?: Record<string, unknown>) {
|
||||||
|
if (context) {
|
||||||
|
console.log(`${HTTP_PREFIX} ${message}`, context)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log(`${HTTP_PREFIX} ${message}`)
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const url = API_BASE ? new URL(path, API_BASE).toString() : path
|
const url = API_BASE ? new URL(path, API_BASE).toString() : path
|
||||||
@@ -29,17 +38,30 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
...(init?.headers ?? {}),
|
...(init?.headers ?? {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, { ...init, headers })
|
const method = (init?.method ?? "GET").toUpperCase()
|
||||||
if (!response.ok) {
|
const startedAt = Date.now()
|
||||||
const message = await response.text()
|
logHttp(`${method} ${path}`)
|
||||||
throw new Error(message || `Request failed with ${response.status}`)
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { ...init, headers })
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await response.text()
|
||||||
|
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
|
||||||
|
throw new Error(message || `Request failed with ${response.status}`)
|
||||||
|
}
|
||||||
|
const duration = Date.now() - startedAt
|
||||||
|
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: duration })
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T
|
||||||
|
}
|
||||||
|
return (await response.json()) as T
|
||||||
|
} catch (error) {
|
||||||
|
logHttp(`${method} ${path} failed`, { durationMs: Date.now() - startedAt, error })
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
if (response.status === 204) {
|
|
||||||
return undefined as T
|
|
||||||
}
|
|
||||||
return (await response.json()) as T
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const cliApi = {
|
export const cliApi = {
|
||||||
fetchWorkspaces(): Promise<WorkspaceDescriptor[]> {
|
fetchWorkspaces(): Promise<WorkspaceDescriptor[]> {
|
||||||
return request<WorkspaceDescriptor[]>("/api/workspaces")
|
return request<WorkspaceDescriptor[]>("/api/workspaces")
|
||||||
@@ -124,16 +146,18 @@ export const cliApi = {
|
|||||||
return request(`/api/storage/instances/${encodeURIComponent(id)}`, { method: "DELETE" })
|
return request(`/api/storage/instances/${encodeURIComponent(id)}`, { method: "DELETE" })
|
||||||
},
|
},
|
||||||
connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) {
|
connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) {
|
||||||
|
console.log(`[SSE] Connecting to ${EVENTS_URL}`)
|
||||||
const source = new EventSource(EVENTS_URL)
|
const source = new EventSource(EVENTS_URL)
|
||||||
source.onmessage = (event) => {
|
source.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(event.data) as WorkspaceEventPayload
|
const payload = JSON.parse(event.data) as WorkspaceEventPayload
|
||||||
onEvent(payload)
|
onEvent(payload)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to parse SSE event", error)
|
console.error("[SSE] Failed to parse event", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
source.onerror = () => {
|
source.onerror = () => {
|
||||||
|
console.warn("[SSE] EventSource error, closing stream")
|
||||||
onError?.()
|
onError?.()
|
||||||
}
|
}
|
||||||
return source
|
return source
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ import { cliApi } from "./api-client"
|
|||||||
|
|
||||||
const RETRY_BASE_DELAY = 1000
|
const RETRY_BASE_DELAY = 1000
|
||||||
const RETRY_MAX_DELAY = 10000
|
const RETRY_MAX_DELAY = 10000
|
||||||
|
const SSE_PREFIX = "[SSE]"
|
||||||
|
|
||||||
|
function logSse(message: string, context?: Record<string, unknown>) {
|
||||||
|
if (context) {
|
||||||
|
console.log(`${SSE_PREFIX} ${message}`, context)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log(`${SSE_PREFIX} ${message}`)
|
||||||
|
}
|
||||||
|
|
||||||
class CliEvents {
|
class CliEvents {
|
||||||
private handlers = new Map<WorkspaceEventType | "*", Set<(event: WorkspaceEventPayload) => void>>()
|
private handlers = new Map<WorkspaceEventType | "*", Set<(event: WorkspaceEventPayload) => void>>()
|
||||||
@@ -17,8 +26,10 @@ class CliEvents {
|
|||||||
if (this.source) {
|
if (this.source) {
|
||||||
this.source.close()
|
this.source.close()
|
||||||
}
|
}
|
||||||
|
logSse("Connecting to backend events stream")
|
||||||
this.source = cliApi.connectEvents((event) => this.dispatch(event), () => this.scheduleReconnect())
|
this.source = cliApi.connectEvents((event) => this.dispatch(event), () => this.scheduleReconnect())
|
||||||
this.source.onopen = () => {
|
this.source.onopen = () => {
|
||||||
|
logSse("Events stream connected")
|
||||||
this.retryDelay = RETRY_BASE_DELAY
|
this.retryDelay = RETRY_BASE_DELAY
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,6 +39,7 @@ class CliEvents {
|
|||||||
this.source.close()
|
this.source.close()
|
||||||
this.source = null
|
this.source = null
|
||||||
}
|
}
|
||||||
|
logSse("Events stream disconnected, scheduling reconnect", { delayMs: this.retryDelay })
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.retryDelay = Math.min(this.retryDelay * 2, RETRY_MAX_DELAY)
|
this.retryDelay = Math.min(this.retryDelay * 2, RETRY_MAX_DELAY)
|
||||||
this.connect()
|
this.connect()
|
||||||
@@ -35,6 +47,7 @@ class CliEvents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private dispatch(event: WorkspaceEventPayload) {
|
private dispatch(event: WorkspaceEventPayload) {
|
||||||
|
logSse(`event ${event.type}`)
|
||||||
this.handlers.get("*")?.forEach((handler) => handler(event))
|
this.handlers.get("*")?.forEach((handler) => handler(event))
|
||||||
this.handlers.get(event.type)?.forEach((handler) => handler(event))
|
this.handlers.get(event.type)?.forEach((handler) => handler(event))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user