diff --git a/src/api/coderApi.ts b/src/api/coderApi.ts index da624bad..ef120ce4 100644 --- a/src/api/coderApi.ts +++ b/src/api/coderApi.ts @@ -11,6 +11,7 @@ import { type ProvisionerJobLog, type Workspace, type WorkspaceAgent, + type WorkspaceAgentLog, } from "coder/site/src/api/typesGenerated"; import * as vscode from "vscode"; import { type ClientOptions, type CloseEvent, type ErrorEvent } from "ws"; @@ -109,18 +110,42 @@ export class CoderApi extends Api { logs: ProvisionerJobLog[], options?: ClientOptions, ) => { + return this.watchLogs( + `/api/v2/workspacebuilds/${buildId}/logs`, + logs, + options, + ); + }; + + watchWorkspaceAgentLogs = async ( + agentId: string, + logs: WorkspaceAgentLog[], + options?: ClientOptions, + ) => { + return this.watchLogs( + `/api/v2/workspaceagents/${agentId}/logs`, + logs, + options, + ); + }; + + private async watchLogs( + apiRoute: string, + logs: { id: number }[], + options?: ClientOptions, + ) { const searchParams = new URLSearchParams({ follow: "true" }); const lastLog = logs.at(-1); if (lastLog) { searchParams.append("after", lastLog.id.toString()); } - return this.createWebSocket({ - apiRoute: `/api/v2/workspacebuilds/${buildId}/logs`, + return this.createWebSocket({ + apiRoute, searchParams, options, }); - }; + } private async createWebSocket( configs: Omit, diff --git a/src/api/workspace.ts b/src/api/workspace.ts index cb03d9fc..c662e2af 100644 --- a/src/api/workspace.ts +++ b/src/api/workspace.ts @@ -1,11 +1,16 @@ -import { spawn } from "child_process"; import { type Api } from "coder/site/src/api/api"; -import { type Workspace } from "coder/site/src/api/typesGenerated"; +import { + type WorkspaceAgentLog, + type Workspace, + type WorkspaceAgent, +} from "coder/site/src/api/typesGenerated"; +import { spawn } from "node:child_process"; import * as vscode from "vscode"; import { type FeatureSet } from "../featureSet"; import { getGlobalFlags } from "../globalFlags"; import { escapeCommandArg } from "../util"; +import { type OneWayWebSocket } from "../websocket/oneWayWebSocket"; import { errToStr, createWorkspaceIdentifier } from "./api-helper"; import { type CoderApi } from "./coderApi"; @@ -36,7 +41,7 @@ export async function startWorkspaceIfStoppedOrFailed( createWorkspaceIdentifier(workspace), ]; if (featureSet.buildReason) { - startArgs.push(...["--reason", "vscode_connection"]); + startArgs.push("--reason", "vscode_connection"); } // { shell: true } requires one shell-safe command string, otherwise we lose all escaping @@ -44,27 +49,25 @@ export async function startWorkspaceIfStoppedOrFailed( const startProcess = spawn(cmd, { shell: true }); startProcess.stdout.on("data", (data: Buffer) => { - data + const lines = data .toString() .split(/\r*\n/) - .forEach((line: string) => { - if (line !== "") { - writeEmitter.fire(line.toString() + "\r\n"); - } - }); + .filter((line) => line !== ""); + for (const line of lines) { + writeEmitter.fire(line.toString() + "\r\n"); + } }); let capturedStderr = ""; startProcess.stderr.on("data", (data: Buffer) => { - data + const lines = data .toString() .split(/\r*\n/) - .forEach((line: string) => { - if (line !== "") { - writeEmitter.fire(line.toString() + "\r\n"); - capturedStderr += line.toString() + "\n"; - } - }); + .filter((line) => line !== ""); + for (const line of lines) { + writeEmitter.fire(line.toString() + "\r\n"); + capturedStderr += line.toString() + "\n"; + } }); startProcess.on("close", (code: number) => { @@ -93,7 +96,9 @@ export async function waitForBuild( ): Promise { // This fetches the initial bunch of logs. const logs = await client.getWorkspaceBuildLogs(workspace.latest_build.id); - logs.forEach((log) => writeEmitter.fire(log.output + "\r\n")); + for (const log of logs) { + writeEmitter.fire(log.output + "\r\n"); + } const socket = await client.watchBuildLogsByBuildId( workspace.latest_build.id, @@ -130,3 +135,55 @@ export async function waitForBuild( ); return updatedWorkspace; } + +/** + * Streams agent logs to the emitter in real-time. + * Fetches existing logs and subscribes to new logs via websocket. + * Returns the websocket and a completion promise that rejects on error. + */ +export async function streamAgentLogs( + client: CoderApi, + writeEmitter: vscode.EventEmitter, + agent: WorkspaceAgent, +): Promise<{ + socket: OneWayWebSocket; + completion: Promise; +}> { + // This fetches the initial bunch of logs. + const logs = await client.getWorkspaceAgentLogs(agent.id); + for (const log of logs) { + writeEmitter.fire(log.output + "\r\n"); + } + + const socket = await client.watchWorkspaceAgentLogs(agent.id, logs); + + const completion = new Promise((resolve, reject) => { + socket.addEventListener("message", (data) => { + if (data.parseError) { + writeEmitter.fire( + errToStr(data.parseError, "Failed to parse message") + "\r\n", + ); + } else { + for (const log of data.parsedMessage) { + writeEmitter.fire(log.output + "\r\n"); + } + } + }); + + socket.addEventListener("error", (error) => { + const baseUrlRaw = client.getAxiosInstance().defaults.baseURL; + writeEmitter.fire( + `Error watching agent logs on ${baseUrlRaw}: ${errToStr(error, "no further details")}\r\n`, + ); + return reject( + new Error( + `Failed to watch agent logs on ${baseUrlRaw}: ${errToStr(error, "no further details")}`, + ), + ); + }); + + socket.addEventListener("close", () => resolve()); + }); + + return { socket, completion }; +} diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 97cb858e..833ed8e8 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -1,14 +1,15 @@ import { isAxiosError } from "axios"; import { type Api } from "coder/site/src/api/api"; import { + type WorkspaceAgentLog, type Workspace, type WorkspaceAgent, } from "coder/site/src/api/typesGenerated"; import find from "find-process"; -import * as fs from "fs/promises"; import * as jsonc from "jsonc-parser"; -import * as os from "os"; -import * as path from "path"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; import prettyBytes from "pretty-bytes"; import * as semver from "semver"; import * as vscode from "vscode"; @@ -24,6 +25,7 @@ import { CoderApi } from "../api/coderApi"; import { needToken } from "../api/utils"; import { startWorkspaceIfStoppedOrFailed, + streamAgentLogs, waitForBuild, } from "../api/workspace"; import { type Commands } from "../commands"; @@ -43,10 +45,12 @@ import { findPort, parseRemoteAuthority, } from "../util"; +import { type OneWayWebSocket } from "../websocket/oneWayWebSocket"; import { WorkspaceMonitor } from "../workspace/workspaceMonitor"; import { SSHConfig, type SSHValues, mergeSSHConfigValues } from "./sshConfig"; import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport"; +import { TerminalSession } from "./terminalSession"; export interface RemoteDetails extends vscode.Disposable { url: string; @@ -130,29 +134,13 @@ export class Remote { const workspaceName = createWorkspaceIdentifier(workspace); // A terminal will be used to stream the build, if one is necessary. - let writeEmitter: undefined | vscode.EventEmitter; - let terminal: undefined | vscode.Terminal; + let terminalSession: TerminalSession | undefined; let attempts = 0; - function initWriteEmitterAndTerminal(): vscode.EventEmitter { - writeEmitter ??= new vscode.EventEmitter(); - if (!terminal) { - terminal = vscode.window.createTerminal({ - name: "Build Log", - location: vscode.TerminalLocation.Panel, - // Spin makes this gear icon spin! - iconPath: new vscode.ThemeIcon("gear~spin"), - pty: { - onDidWrite: writeEmitter.event, - close: () => undefined, - open: () => undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as Partial as any, - }); - terminal.show(true); - } - return writeEmitter; - } + const getOrCreateTerminal = () => { + terminalSession ??= new TerminalSession("Build Log"); + return terminalSession.writeEmitter; + }; try { // Show a notification while we wait. @@ -170,9 +158,12 @@ export class Remote { case "pending": case "starting": case "stopping": - writeEmitter = initWriteEmitterAndTerminal(); this.logger.info(`Waiting for ${workspaceName}...`); - workspace = await waitForBuild(client, writeEmitter, workspace); + workspace = await waitForBuild( + client, + getOrCreateTerminal(), + workspace, + ); break; case "stopped": if ( @@ -181,14 +172,13 @@ export class Remote { ) { return undefined; } - writeEmitter = initWriteEmitterAndTerminal(); this.logger.info(`Starting ${workspaceName}...`); workspace = await startWorkspaceIfStoppedOrFailed( client, globalConfigDir, binPath, workspace, - writeEmitter, + getOrCreateTerminal(), featureSet, ); break; @@ -202,14 +192,13 @@ export class Remote { ) { return undefined; } - writeEmitter = initWriteEmitterAndTerminal(); this.logger.info(`Starting ${workspaceName}...`); workspace = await startWorkspaceIfStoppedOrFailed( client, globalConfigDir, binPath, workspace, - writeEmitter, + getOrCreateTerminal(), featureSet, ); break; @@ -236,12 +225,7 @@ export class Remote { }, ); } finally { - if (writeEmitter) { - writeEmitter.dispose(); - } - if (terminal) { - terminal.dispose(); - } + terminalSession?.dispose(); } } @@ -558,39 +542,29 @@ export class Remote { // Wait for the agent to connect. if (agent.status === "connecting") { this.logger.info(`Waiting for ${workspaceName}/${agent.name}...`); - await vscode.window.withProgress( + const updatedAgent = await this.waitForAgentWithProgress( + monitor, + agent, { - title: "Waiting for the agent to connect...", - location: vscode.ProgressLocation.Notification, + progressTitle: "Waiting for the agent to connect...", + timeoutMs: 3 * 60 * 1000, + errorDialogTitle: `Failed to wait for ${agent.name} connection`, }, - async () => { - await new Promise((resolve) => { - const updateEvent = monitor.onChange.event((workspace) => { - if (!agent) { - return; - } - const agents = extractAgents(workspace.latest_build.resources); - const found = agents.find((newAgent) => { - return newAgent.id === agent.id; - }); - if (!found) { - return; - } - agent = found; - if (agent.status === "connecting") { - return; - } - updateEvent.dispose(); - resolve(); - }); - }); + (foundAgent) => { + if (foundAgent.status !== "connecting") { + return foundAgent; + } + return undefined; }, ); + if (!updatedAgent) { + return; + } + agent = updatedAgent; this.logger.info(`Agent ${agent.name} status is now`, agent.status); } // Make sure the agent is connected. - // TODO: Should account for the lifecycle state as well? if (agent.status !== "connected") { const result = await this.vscodeProposed.window.showErrorMessage( `${workspaceName}/${agent.name} ${agent.status}`, @@ -608,6 +582,64 @@ export class Remote { return; } + if (agent.lifecycle_state === "starting") { + const isBlocking = agent.scripts.some( + (script) => script.start_blocks_login, + ); + if (isBlocking) { + this.logger.info( + `Waiting for ${workspaceName}/${agent.name} startup...`, + ); + + let terminalSession: TerminalSession | undefined; + let socket: OneWayWebSocket | undefined; + try { + terminalSession = new TerminalSession("Agent Log"); + const { socket: agentSocket, completion: logsCompletion } = + await streamAgentLogs( + workspaceClient, + terminalSession.writeEmitter, + agent, + ); + socket = agentSocket; + + const agentStatePromise = this.waitForAgentWithProgress( + monitor, + agent, + { + progressTitle: "Waiting for agent startup scripts...", + timeoutMs: 5 * 60 * 1000, + errorDialogTitle: `Failed to wait for ${agent.name} startup`, + }, + (foundAgent) => { + if (foundAgent.lifecycle_state !== "starting") { + return foundAgent; + } + return undefined; + }, + ); + + // Race between logs completion (which fails on socket error) and agent state change + const updatedAgent = await Promise.race([ + agentStatePromise, + logsCompletion.then(() => agentStatePromise), + ]); + + if (!updatedAgent) { + return; + } + agent = updatedAgent; + this.logger.info( + `Agent ${agent.name} lifecycle state is now`, + agent.lifecycle_state, + ); + } finally { + terminalSession?.dispose(); + socket?.close(); + } + } + } + const logDir = this.getLogDir(featureSet); // This ensures the Remote SSH extension resolves the host to execute the @@ -733,6 +765,95 @@ export class Remote { return ` --log-dir ${escapeCommandArg(logDir)} -v`; } + /** + * Waits for an agent condition with progress notification and error handling. + * Returns the updated agent or undefined if the user chooses to close remote. + */ + private async waitForAgentWithProgress( + monitor: WorkspaceMonitor, + agent: WorkspaceAgent, + options: { + progressTitle: string; + timeoutMs: number; + errorDialogTitle: string; + }, + checker: (agent: WorkspaceAgent) => WorkspaceAgent | undefined, + ): Promise { + try { + const foundAgent = await vscode.window.withProgress( + { + title: options.progressTitle, + location: vscode.ProgressLocation.Notification, + }, + async () => + this.waitForAgentCondition( + monitor, + agent, + checker, + options.timeoutMs, + `Timeout: ${options.errorDialogTitle}`, + ), + ); + return foundAgent; + } catch (error) { + this.logger.error(options.errorDialogTitle, error); + const result = await this.vscodeProposed.window.showErrorMessage( + options.errorDialogTitle, + { + useCustom: true, + modal: true, + detail: error instanceof Error ? error.message : String(error), + }, + "Close Remote", + ); + if (result === "Close Remote") { + await this.closeRemote(); + } + return undefined; + } + } + + /** + * Waits for an agent condition to be met by monitoring workspace changes. + * Returns the result when the condition is met or throws an error on timeout. + */ + private async waitForAgentCondition( + monitor: WorkspaceMonitor, + agent: WorkspaceAgent, + checker: (agent: WorkspaceAgent) => T | undefined, + timeoutMs: number, + timeoutMessage: string, + ): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + updateEvent.dispose(); + reject(new Error(timeoutMessage)); + }, timeoutMs); + + const updateEvent = monitor.onChange.event((workspace) => { + try { + const agents = extractAgents(workspace.latest_build.resources); + const foundAgent = agents.find((a) => a.id === agent.id); + if (!foundAgent) { + throw new Error( + `Agent ${agent.name} not found in workspace resources`, + ); + } + const result = checker(foundAgent); + if (result !== undefined) { + clearTimeout(timeout); + updateEvent.dispose(); + resolve(result); + } + } catch (error) { + clearTimeout(timeout); + updateEvent.dispose(); + reject(error); + } + }); + }); + } + // updateSSHConfig updates the SSH configuration with a wildcard that handles // all Coder entries. private async updateSSHConfig( diff --git a/src/remote/terminalSession.ts b/src/remote/terminalSession.ts new file mode 100644 index 00000000..358134a1 --- /dev/null +++ b/src/remote/terminalSession.ts @@ -0,0 +1,39 @@ +import * as vscode from "vscode"; + +/** + * Manages a terminal and its associated write emitter as a single unit. + * Ensures both are created together and disposed together properly. + */ +export class TerminalSession implements vscode.Disposable { + public readonly writeEmitter: vscode.EventEmitter; + public readonly terminal: vscode.Terminal; + + constructor(name: string) { + this.writeEmitter = new vscode.EventEmitter(); + this.terminal = vscode.window.createTerminal({ + name, + location: vscode.TerminalLocation.Panel, + // Spin makes this gear icon spin! + iconPath: new vscode.ThemeIcon("gear~spin"), + pty: { + onDidWrite: this.writeEmitter.event, + close: () => undefined, + open: () => undefined, + }, + }); + this.terminal.show(true); + } + + dispose(): void { + try { + this.writeEmitter.dispose(); + } catch { + // Ignore disposal errors + } + try { + this.terminal.dispose(); + } catch { + // Ignore disposal errors + } + } +}