## Summary - Adds file writing capability to Monaco editor in the file viewer - Implements writeFile API on the server for workspace files - Integrates save functionality into the file viewer UI with proper state management ## Bug Fixes (Review Feedback) - Fixed failed save discarding edits when switching files - now checks save result and only proceeds if successful - Fixed refresh overwriting dirty editor state - now prompts for confirmation before discarding edits - Fixed save button unable to save empty files - changed check from `if (content)` to `if (content !== undefined && content !== null)` - Added agent edit conflict detection - when agent edits file while user has unsaved changes, shows conflict dialog with Overwrite/Cancel options - Fixed dialog appearing behind unpinned sidebar - increased alert dialog z-index to z-100 ## Related Issues - Closes #251 --------- Co-authored-by: Jess Chadwick <jchadwick@gmail.com>
486 lines
16 KiB
TypeScript
486 lines
16 KiB
TypeScript
import path from "path"
|
|
import { spawnSync } from "child_process"
|
|
import { connect } from "net"
|
|
import { EventBus } from "../events/bus"
|
|
import type { SettingsService } from "../settings/service"
|
|
import type { BinaryResolver } from "../settings/binaries"
|
|
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, ProcessExitInfo } from "./runtime"
|
|
import { Logger } from "../logger"
|
|
import { getOpencodeConfigDir } from "../opencode-config.js"
|
|
import {
|
|
buildOpencodeBasicAuthHeader,
|
|
DEFAULT_OPENCODE_USERNAME,
|
|
generateOpencodeServerPassword,
|
|
OPENCODE_SERVER_PASSWORD_ENV,
|
|
OPENCODE_SERVER_USERNAME_ENV,
|
|
} from "./opencode-auth"
|
|
|
|
const STARTUP_STABILITY_DELAY_MS = 1500
|
|
|
|
interface WorkspaceManagerOptions {
|
|
rootDir: string
|
|
settings: SettingsService
|
|
binaryResolver: BinaryResolver
|
|
eventBus: EventBus
|
|
logger: Logger
|
|
getServerBaseUrl: () => string
|
|
/** Optional CA bundle path to trust CodeNomad HTTPS certs. */
|
|
nodeExtraCaCertsPath?: string
|
|
}
|
|
|
|
interface WorkspaceRecord extends WorkspaceDescriptor {}
|
|
|
|
export class WorkspaceManager {
|
|
private readonly workspaces = new Map<string, WorkspaceRecord>()
|
|
private readonly runtime: WorkspaceRuntime
|
|
private readonly opencodeConfigDir: string
|
|
private readonly opencodeAuth = new Map<string, { username: string; password: string; authorization: string }>()
|
|
|
|
constructor(private readonly options: WorkspaceManagerOptions) {
|
|
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
|
|
this.opencodeConfigDir = getOpencodeConfigDir()
|
|
}
|
|
|
|
list(): WorkspaceDescriptor[] {
|
|
return Array.from(this.workspaces.values())
|
|
}
|
|
|
|
get(id: string): WorkspaceDescriptor | undefined {
|
|
return this.workspaces.get(id)
|
|
}
|
|
|
|
getInstancePort(id: string): number | undefined {
|
|
return this.workspaces.get(id)?.port
|
|
}
|
|
|
|
getInstanceAuthorizationHeader(id: string): string | undefined {
|
|
return this.opencodeAuth.get(id)?.authorization
|
|
}
|
|
|
|
listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] {
|
|
const workspace = this.requireWorkspace(workspaceId)
|
|
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
|
return browser.list(relativePath)
|
|
}
|
|
|
|
searchFiles(workspaceId: string, query: string, options?: WorkspaceFileSearchOptions): FileSystemEntry[] {
|
|
const workspace = this.requireWorkspace(workspaceId)
|
|
return searchWorkspaceFiles(workspace.path, query, options)
|
|
}
|
|
|
|
readFile(workspaceId: string, relativePath: string): WorkspaceFileResponse {
|
|
const workspace = this.requireWorkspace(workspaceId)
|
|
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
|
const contents = browser.readFile(relativePath)
|
|
return {
|
|
workspaceId,
|
|
relativePath,
|
|
contents,
|
|
}
|
|
}
|
|
|
|
writeFile(workspaceId: string, relativePath: string, contents: string): void {
|
|
const workspace = this.requireWorkspace(workspaceId)
|
|
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
|
browser.writeFile(relativePath, contents)
|
|
}
|
|
|
|
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
|
|
|
const id = `${Date.now().toString(36)}`
|
|
const binary = this.options.binaryResolver.resolveDefault()
|
|
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
|
|
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
|
clearWorkspaceSearchCache(workspacePath)
|
|
|
|
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
|
|
|
|
const proxyPath = `/workspaces/${id}/worktrees/root/instance`
|
|
|
|
|
|
const descriptor: WorkspaceRecord = {
|
|
id,
|
|
path: workspacePath,
|
|
name,
|
|
status: "starting",
|
|
proxyPath,
|
|
binaryId: resolvedBinaryPath,
|
|
binaryLabel: binary.label,
|
|
binaryVersion: binary.version,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
}
|
|
|
|
this.workspaces.set(id, descriptor)
|
|
|
|
|
|
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
|
|
|
const serverConfig = this.options.settings.getOwner("config", "server")
|
|
const envVars = (serverConfig as any)?.environmentVariables
|
|
const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {}
|
|
|
|
const opencodeUsername = DEFAULT_OPENCODE_USERNAME
|
|
const opencodePassword = generateOpencodeServerPassword()
|
|
const authorization = buildOpencodeBasicAuthHeader({ username: opencodeUsername, password: opencodePassword })
|
|
if (!authorization) {
|
|
throw new Error("Failed to build OpenCode auth header")
|
|
}
|
|
this.opencodeAuth.set(id, { username: opencodeUsername, password: opencodePassword, authorization })
|
|
|
|
const environment = {
|
|
...userEnvironment,
|
|
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
|
|
CODENOMAD_INSTANCE_ID: id,
|
|
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
|
|
...(this.options.nodeExtraCaCertsPath ? { NODE_EXTRA_CA_CERTS: this.options.nodeExtraCaCertsPath } : {}),
|
|
[OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername,
|
|
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
|
}
|
|
|
|
try {
|
|
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
|
|
workspaceId: id,
|
|
folder: workspacePath,
|
|
binaryPath: resolvedBinaryPath,
|
|
environment,
|
|
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
|
})
|
|
|
|
const runtimeVersion = await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
|
|
if (runtimeVersion) {
|
|
descriptor.binaryVersion = runtimeVersion
|
|
}
|
|
|
|
descriptor.pid = pid
|
|
descriptor.port = port
|
|
descriptor.status = "ready"
|
|
descriptor.updatedAt = new Date().toISOString()
|
|
this.options.eventBus.publish({ type: "workspace.started", workspace: descriptor })
|
|
this.options.logger.info({ workspaceId: id, port }, "Workspace ready")
|
|
return descriptor
|
|
} catch (error) {
|
|
descriptor.status = "error"
|
|
descriptor.error = error instanceof Error ? error.message : String(error)
|
|
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
|
|
}
|
|
}
|
|
|
|
async delete(id: string): Promise<WorkspaceDescriptor | undefined> {
|
|
const workspace = this.workspaces.get(id)
|
|
if (!workspace) return undefined
|
|
|
|
this.options.logger.info({ workspaceId: id }, "Stopping workspace")
|
|
const wasRunning = Boolean(workspace.pid)
|
|
if (wasRunning) {
|
|
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.opencodeAuth.delete(id)
|
|
clearWorkspaceSearchCache(workspace.path)
|
|
if (!wasRunning) {
|
|
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
|
|
}
|
|
return workspace
|
|
}
|
|
|
|
async shutdown() {
|
|
this.options.logger.info("Shutting down all workspaces")
|
|
|
|
const stopTasks: Array<Promise<void>> = []
|
|
|
|
for (const [id, workspace] of this.workspaces) {
|
|
if (!workspace.pid) {
|
|
this.options.logger.debug({ workspaceId: id }, "Workspace already stopped")
|
|
continue
|
|
}
|
|
|
|
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
|
|
stopTasks.push(
|
|
this.runtime.stop(id).catch((error) => {
|
|
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
|
|
}),
|
|
)
|
|
}
|
|
|
|
if (stopTasks.length > 0) {
|
|
await Promise.allSettled(stopTasks)
|
|
}
|
|
|
|
this.workspaces.clear()
|
|
this.opencodeAuth.clear()
|
|
this.options.logger.info("All workspaces cleared")
|
|
}
|
|
|
|
private requireWorkspace(id: string): WorkspaceRecord {
|
|
const workspace = this.workspaces.get(id)
|
|
if (!workspace) {
|
|
throw new Error("Workspace not found")
|
|
}
|
|
return workspace
|
|
}
|
|
|
|
private resolveBinaryPath(identifier: string): string {
|
|
if (!identifier) {
|
|
return identifier
|
|
}
|
|
|
|
const looksLikePath = identifier.includes("/") || identifier.includes("\\") || identifier.startsWith(".")
|
|
if (path.isAbsolute(identifier) || looksLikePath) {
|
|
return identifier
|
|
}
|
|
|
|
const locator = process.platform === "win32" ? "where" : "which"
|
|
|
|
try {
|
|
const result = spawnSync(locator, [identifier], { encoding: "utf8" })
|
|
if (result.status === 0 && result.stdout) {
|
|
const candidates = result.stdout
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0)
|
|
.filter((line) => !/^INFO:/i.test(line))
|
|
|
|
if (candidates.length > 0) {
|
|
const resolved = this.pickBinaryCandidate(candidates)
|
|
this.options.logger.debug({ identifier, resolved, candidates }, "Resolved binary path from system PATH")
|
|
return resolved
|
|
}
|
|
} else if (result.error) {
|
|
this.options.logger.warn({ identifier, err: result.error }, "Failed to resolve binary path via locator command")
|
|
}
|
|
} catch (error) {
|
|
this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH")
|
|
}
|
|
|
|
return identifier
|
|
}
|
|
|
|
private pickBinaryCandidate(candidates: string[]): string {
|
|
if (process.platform !== "win32") {
|
|
return candidates[0] ?? ""
|
|
}
|
|
|
|
const extensionPreference = [".exe", ".cmd", ".bat", ".ps1"]
|
|
|
|
for (const ext of extensionPreference) {
|
|
const match = candidates.find((candidate) => candidate.toLowerCase().endsWith(ext))
|
|
if (match) {
|
|
return match
|
|
}
|
|
}
|
|
|
|
return candidates[0] ?? ""
|
|
}
|
|
|
|
private async waitForWorkspaceReadiness(params: {
|
|
workspaceId: string
|
|
port: number
|
|
exitPromise: Promise<ProcessExitInfo>
|
|
getLastOutput: () => string
|
|
}): Promise<string | undefined> {
|
|
|
|
await Promise.race([
|
|
this.waitForPortAvailability(params.port),
|
|
params.exitPromise.then((info) => {
|
|
throw this.buildStartupError(
|
|
params.workspaceId,
|
|
"exited before becoming ready",
|
|
info,
|
|
params.getLastOutput(),
|
|
)
|
|
}),
|
|
])
|
|
|
|
const version = 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(),
|
|
)
|
|
}),
|
|
])
|
|
|
|
return version
|
|
}
|
|
|
|
private async waitForInstanceHealth(params: {
|
|
workspaceId: string
|
|
port: number
|
|
exitPromise: Promise<ProcessExitInfo>
|
|
getLastOutput: () => string
|
|
}): Promise<string | undefined> {
|
|
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 probeResult.version
|
|
}
|
|
|
|
const latestOutput = params.getLastOutput().trim()
|
|
if (latestOutput) {
|
|
throw new Error(latestOutput)
|
|
}
|
|
const reason = probeResult.reason ?? "Health check failed"
|
|
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`)
|
|
}
|
|
|
|
private async probeInstance(
|
|
workspaceId: string,
|
|
port: number,
|
|
): Promise<{ ok: boolean; reason?: string; version?: string }> {
|
|
const url = `http://127.0.0.1:${port}/global/health`
|
|
|
|
try {
|
|
const headers: Record<string, string> = {}
|
|
const authHeader = this.opencodeAuth.get(workspaceId)?.authorization
|
|
if (authHeader) {
|
|
headers["Authorization"] = authHeader
|
|
}
|
|
|
|
const response = await fetch(url, { headers })
|
|
if (!response.ok) {
|
|
const reason = `/global/health returned HTTP ${response.status}`
|
|
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
|
|
return { ok: false, reason }
|
|
}
|
|
|
|
const payload = (await response.json().catch(() => null)) as null | { healthy?: unknown; version?: unknown }
|
|
const healthy = payload?.healthy === true
|
|
const version = typeof payload?.version === "string" ? payload.version.trim() : undefined
|
|
|
|
if (!healthy) {
|
|
const reason = "Instance reported unhealthy"
|
|
this.options.logger.debug({ workspaceId, payload }, "Health probe returned unhealthy response")
|
|
return { ok: false, reason }
|
|
}
|
|
|
|
return { ok: true, version: version || undefined }
|
|
} catch (error) {
|
|
const reason = error instanceof Error ? error.message : String(error)
|
|
this.options.logger.debug({ workspaceId, err: error }, "Health probe 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> {
|
|
return new Promise((resolve, reject) => {
|
|
const deadline = Date.now() + timeoutMs
|
|
let settled = false
|
|
let retryTimer: NodeJS.Timeout | null = null
|
|
|
|
const cleanup = () => {
|
|
settled = true
|
|
if (retryTimer) {
|
|
clearTimeout(retryTimer)
|
|
retryTimer = null
|
|
}
|
|
}
|
|
|
|
const tryConnect = () => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
const socket = connect({ port, host: "127.0.0.1" }, () => {
|
|
cleanup()
|
|
socket.end()
|
|
resolve()
|
|
})
|
|
socket.once("error", () => {
|
|
socket.destroy()
|
|
if (settled) {
|
|
return
|
|
}
|
|
if (Date.now() >= deadline) {
|
|
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
|
|
|
|
this.opencodeAuth.delete(workspaceId)
|
|
|
|
this.options.logger.info({ workspaceId, ...info }, "Workspace process exited")
|
|
|
|
workspace.pid = undefined
|
|
workspace.port = undefined
|
|
workspace.updatedAt = new Date().toISOString()
|
|
|
|
if (info.requested || info.code === 0) {
|
|
workspace.status = "stopped"
|
|
workspace.error = undefined
|
|
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId })
|
|
} else {
|
|
workspace.status = "error"
|
|
workspace.error = `Process exited with code ${info.code}`
|
|
this.options.eventBus.publish({ type: "workspace.error", workspace })
|
|
}
|
|
}
|
|
}
|