From fe6d1dc05855758efd12e43e7385c48893582d80 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Fri, 17 Oct 2025 16:11:01 +0300 Subject: [PATCH 1/4] Show Remote SSH Output panel on workspace start --- src/extension.ts | 20 ++++++++++++++++++++ src/remote/remote.ts | 4 ++++ 2 files changed, 24 insertions(+) diff --git a/src/extension.ts b/src/extension.ts index aba94cfe..3b52b21c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -353,6 +353,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }), ); + let shouldShowSshOutput = false; // Since the "onResolveRemoteAuthority:ssh-remote" activation event exists // in package.json we're able to perform actions before the authority is // resolved by the remote SSH extension. @@ -370,6 +371,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ); if (details) { ctx.subscriptions.push(details); + shouldShowSshOutput = details.startedWorkspace; // Authenticate the plugin client which is used in the sidebar to display // workspaces belonging to this deployment. client.setHost(details.url); @@ -460,9 +462,27 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } } } + + if (shouldShowSshOutput) { + showSshOutput(); + } } async function showTreeViewSearch(id: string): Promise { await vscode.commands.executeCommand(`${id}.focus`); await vscode.commands.executeCommand("list.find"); } + +function showSshOutput(): void { + for (const command of [ + "opensshremotes.showLog", + "windsurf-remote-openssh.showLog", + ]) { + /** + * We must not await this command because + * 1) it may not exist + * 2) it might cause the Remote SSH extension to be loaded synchronously + */ + void vscode.commands.executeCommand(command); + } +} diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 97cb858e..3ff7ef9c 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -51,6 +51,7 @@ import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport"; export interface RemoteDetails extends vscode.Disposable { url: string; token: string; + startedWorkspace: boolean; } export class Remote { @@ -415,6 +416,7 @@ export class Remote { } } + let startedWorkspace = false; const disposables: vscode.Disposable[] = []; try { // Register before connection so the label still displays! @@ -442,6 +444,7 @@ export class Remote { await this.closeRemote(); return; } + startedWorkspace = true; workspace = updatedWorkspace; } this.commands.workspace = workspace; @@ -681,6 +684,7 @@ export class Remote { return { url: baseUrlRaw, token, + startedWorkspace, dispose: () => { disposables.forEach((d) => d.dispose()); }, From c49dc60fad8a5afa9b3d0743aa4ec8fecdcdb030 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 23 Oct 2025 16:49:29 +0300 Subject: [PATCH 2/4] Wait for startup script when launching the workspace --- src/api/coderApi.ts | 19 +++++++++ src/api/workspace.ts | 43 +++++++++++++++++++- src/extension.ts | 20 ---------- src/remote/remote.ts | 95 +++++++++++++++++++++++++++++++------------- 4 files changed, 129 insertions(+), 48 deletions(-) diff --git a/src/api/coderApi.ts b/src/api/coderApi.ts index da624bad..697bf9d2 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"; @@ -122,6 +123,24 @@ export class CoderApi extends Api { }); }; + watchWorkspaceAgentLogs = async ( + agentId: string, + logs: WorkspaceAgentLog[], + 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/workspaceagents/${agentId}/logs`, + searchParams, + options, + }); + }; + private async createWebSocket( configs: Omit, ) { diff --git a/src/api/workspace.ts b/src/api/workspace.ts index cb03d9fc..958a55a0 100644 --- a/src/api/workspace.ts +++ b/src/api/workspace.ts @@ -1,11 +1,15 @@ 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, +} from "coder/site/src/api/typesGenerated"; 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"; @@ -81,6 +85,43 @@ export async function startWorkspaceIfStoppedOrFailed( }); } +/** + * Wait for the latest build to finish while streaming logs to the emitter. + * + * Once completed, fetch the workspace again and return it. + */ +export async function writeAgentLogs( + client: CoderApi, + writeEmitter: vscode.EventEmitter, + agentId: string, +): Promise> { + // This fetches the initial bunch of logs. + const logs = await client.getWorkspaceAgentLogs(agentId); + logs.forEach((log) => writeEmitter.fire(log.output + "\r\n")); + + const socket = await client.watchWorkspaceAgentLogs(agentId, logs); + + socket.addEventListener("message", (data) => { + if (data.parseError) { + writeEmitter.fire( + errToStr(data.parseError, "Failed to parse message") + "\r\n", + ); + } else { + data.parsedMessage.forEach((message) => + writeEmitter.fire(message.output + "\r\n"), + ); + } + }); + + socket.addEventListener("error", (error) => { + const baseUrlRaw = client.getAxiosInstance().defaults.baseURL; + throw new Error( + `Failed to watch workspace build on ${baseUrlRaw}: ${errToStr(error, "no further details")}`, + ); + }); + return socket; +} + /** * Wait for the latest build to finish while streaming logs to the emitter. * diff --git a/src/extension.ts b/src/extension.ts index 3b52b21c..aba94cfe 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -353,7 +353,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }), ); - let shouldShowSshOutput = false; // Since the "onResolveRemoteAuthority:ssh-remote" activation event exists // in package.json we're able to perform actions before the authority is // resolved by the remote SSH extension. @@ -371,7 +370,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ); if (details) { ctx.subscriptions.push(details); - shouldShowSshOutput = details.startedWorkspace; // Authenticate the plugin client which is used in the sidebar to display // workspaces belonging to this deployment. client.setHost(details.url); @@ -462,27 +460,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } } } - - if (shouldShowSshOutput) { - showSshOutput(); - } } async function showTreeViewSearch(id: string): Promise { await vscode.commands.executeCommand(`${id}.focus`); await vscode.commands.executeCommand("list.find"); } - -function showSshOutput(): void { - for (const command of [ - "opensshremotes.showLog", - "windsurf-remote-openssh.showLog", - ]) { - /** - * We must not await this command because - * 1) it may not exist - * 2) it might cause the Remote SSH extension to be loaded synchronously - */ - void vscode.commands.executeCommand(command); - } -} diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 3ff7ef9c..16d6cf00 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -25,6 +25,7 @@ import { needToken } from "../api/utils"; import { startWorkspaceIfStoppedOrFailed, waitForBuild, + writeAgentLogs, } from "../api/workspace"; import { type Commands } from "../commands"; import { type CliManager } from "../core/cliManager"; @@ -51,7 +52,6 @@ import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport"; export interface RemoteDetails extends vscode.Disposable { url: string; token: string; - startedWorkspace: boolean; } export class Remote { @@ -131,29 +131,18 @@ 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 writeEmitter: vscode.EventEmitter | undefined; + let terminal: vscode.Terminal | 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); + const initBuildLogTerminal = () => { + if (!writeEmitter) { + const init = this.initWriteEmitterAndTerminal("Build Log"); + writeEmitter = init.writeEmitter; + terminal = init.terminal; } return writeEmitter; - } + }; try { // Show a notification while we wait. @@ -171,7 +160,7 @@ export class Remote { case "pending": case "starting": case "stopping": - writeEmitter = initWriteEmitterAndTerminal(); + writeEmitter = initBuildLogTerminal(); this.logger.info(`Waiting for ${workspaceName}...`); workspace = await waitForBuild(client, writeEmitter, workspace); break; @@ -182,7 +171,7 @@ export class Remote { ) { return undefined; } - writeEmitter = initWriteEmitterAndTerminal(); + writeEmitter = initBuildLogTerminal(); this.logger.info(`Starting ${workspaceName}...`); workspace = await startWorkspaceIfStoppedOrFailed( client, @@ -203,7 +192,7 @@ export class Remote { ) { return undefined; } - writeEmitter = initWriteEmitterAndTerminal(); + writeEmitter = initBuildLogTerminal(); this.logger.info(`Starting ${workspaceName}...`); workspace = await startWorkspaceIfStoppedOrFailed( client, @@ -246,6 +235,27 @@ export class Remote { } } + private initWriteEmitterAndTerminal(name: string): { + writeEmitter: vscode.EventEmitter; + terminal: vscode.Terminal; + } { + const writeEmitter = new vscode.EventEmitter(); + const terminal = vscode.window.createTerminal({ + name, + location: vscode.TerminalLocation.Panel, + // Spin makes this gear icon spin! + iconPath: new vscode.ThemeIcon("gear~spin"), + pty: { + onDidWrite: writeEmitter.event, + close: () => undefined, + open: () => undefined, + }, + }); + terminal.show(true); + + return { writeEmitter, terminal }; + } + /** * Ensure the workspace specified by the remote authority is ready to receive * SSH connections. Return undefined if the authority is not for a Coder @@ -416,7 +426,6 @@ export class Remote { } } - let startedWorkspace = false; const disposables: vscode.Disposable[] = []; try { // Register before connection so the label still displays! @@ -444,7 +453,6 @@ export class Remote { await this.closeRemote(); return; } - startedWorkspace = true; workspace = updatedWorkspace; } this.commands.workspace = workspace; @@ -593,7 +601,6 @@ export class Remote { } // 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}`, @@ -611,6 +618,41 @@ export class Remote { return; } + if (agent.lifecycle_state === "starting") { + const isBlocking = agent.scripts.some( + (script) => script.start_blocks_login, + ); + if (isBlocking) { + const { writeEmitter, terminal } = + this.initWriteEmitterAndTerminal("Agent Log"); + const socket = await writeAgentLogs( + workspaceClient, + writeEmitter, + agent.id, + ); + await new Promise((resolve) => { + const updateEvent = monitor.onChange.event((workspace) => { + const agents = extractAgents(workspace.latest_build.resources); + const found = agents.find((newAgent) => { + return newAgent.id === agent.id; + }); + if (!found) { + return; + } + agent = found; + if (agent.lifecycle_state === "starting") { + return; + } + updateEvent.dispose(); + resolve(); + }); + }); + writeEmitter.dispose(); + terminal.dispose(); + socket.close(); + } + } + const logDir = this.getLogDir(featureSet); // This ensures the Remote SSH extension resolves the host to execute the @@ -684,7 +726,6 @@ export class Remote { return { url: baseUrlRaw, token, - startedWorkspace, dispose: () => { disposables.forEach((d) => d.dispose()); }, From e6bb283014f1393503846c289cedbebaf53b940f Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Fri, 24 Oct 2025 12:00:04 +0300 Subject: [PATCH 3/4] Refactoring and cleanup of agent startup script waiting --- src/api/workspace.ts | 122 +++++++++------- src/remote/remote.ts | 268 ++++++++++++++++++++++------------ src/remote/terminalSession.ts | 39 +++++ 3 files changed, 280 insertions(+), 149 deletions(-) create mode 100644 src/remote/terminalSession.ts diff --git a/src/api/workspace.ts b/src/api/workspace.ts index 958a55a0..c662e2af 100644 --- a/src/api/workspace.ts +++ b/src/api/workspace.ts @@ -1,9 +1,10 @@ -import { spawn } from "child_process"; import { type Api } from "coder/site/src/api/api"; 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"; @@ -40,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 @@ -48,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) => { @@ -85,43 +84,6 @@ export async function startWorkspaceIfStoppedOrFailed( }); } -/** - * Wait for the latest build to finish while streaming logs to the emitter. - * - * Once completed, fetch the workspace again and return it. - */ -export async function writeAgentLogs( - client: CoderApi, - writeEmitter: vscode.EventEmitter, - agentId: string, -): Promise> { - // This fetches the initial bunch of logs. - const logs = await client.getWorkspaceAgentLogs(agentId); - logs.forEach((log) => writeEmitter.fire(log.output + "\r\n")); - - const socket = await client.watchWorkspaceAgentLogs(agentId, logs); - - socket.addEventListener("message", (data) => { - if (data.parseError) { - writeEmitter.fire( - errToStr(data.parseError, "Failed to parse message") + "\r\n", - ); - } else { - data.parsedMessage.forEach((message) => - writeEmitter.fire(message.output + "\r\n"), - ); - } - }); - - socket.addEventListener("error", (error) => { - const baseUrlRaw = client.getAxiosInstance().defaults.baseURL; - throw new Error( - `Failed to watch workspace build on ${baseUrlRaw}: ${errToStr(error, "no further details")}`, - ); - }); - return socket; -} - /** * Wait for the latest build to finish while streaming logs to the emitter. * @@ -134,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, @@ -171,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 16d6cf00..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,8 +25,8 @@ import { CoderApi } from "../api/coderApi"; import { needToken } from "../api/utils"; import { startWorkspaceIfStoppedOrFailed, + streamAgentLogs, waitForBuild, - writeAgentLogs, } from "../api/workspace"; import { type Commands } from "../commands"; import { type CliManager } from "../core/cliManager"; @@ -44,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; @@ -131,17 +134,12 @@ export class Remote { const workspaceName = createWorkspaceIdentifier(workspace); // A terminal will be used to stream the build, if one is necessary. - let writeEmitter: vscode.EventEmitter | undefined; - let terminal: vscode.Terminal | undefined; + let terminalSession: TerminalSession | undefined; let attempts = 0; - const initBuildLogTerminal = () => { - if (!writeEmitter) { - const init = this.initWriteEmitterAndTerminal("Build Log"); - writeEmitter = init.writeEmitter; - terminal = init.terminal; - } - return writeEmitter; + const getOrCreateTerminal = () => { + terminalSession ??= new TerminalSession("Build Log"); + return terminalSession.writeEmitter; }; try { @@ -160,9 +158,12 @@ export class Remote { case "pending": case "starting": case "stopping": - writeEmitter = initBuildLogTerminal(); this.logger.info(`Waiting for ${workspaceName}...`); - workspace = await waitForBuild(client, writeEmitter, workspace); + workspace = await waitForBuild( + client, + getOrCreateTerminal(), + workspace, + ); break; case "stopped": if ( @@ -171,14 +172,13 @@ export class Remote { ) { return undefined; } - writeEmitter = initBuildLogTerminal(); this.logger.info(`Starting ${workspaceName}...`); workspace = await startWorkspaceIfStoppedOrFailed( client, globalConfigDir, binPath, workspace, - writeEmitter, + getOrCreateTerminal(), featureSet, ); break; @@ -192,14 +192,13 @@ export class Remote { ) { return undefined; } - writeEmitter = initBuildLogTerminal(); this.logger.info(`Starting ${workspaceName}...`); workspace = await startWorkspaceIfStoppedOrFailed( client, globalConfigDir, binPath, workspace, - writeEmitter, + getOrCreateTerminal(), featureSet, ); break; @@ -226,36 +225,10 @@ export class Remote { }, ); } finally { - if (writeEmitter) { - writeEmitter.dispose(); - } - if (terminal) { - terminal.dispose(); - } + terminalSession?.dispose(); } } - private initWriteEmitterAndTerminal(name: string): { - writeEmitter: vscode.EventEmitter; - terminal: vscode.Terminal; - } { - const writeEmitter = new vscode.EventEmitter(); - const terminal = vscode.window.createTerminal({ - name, - location: vscode.TerminalLocation.Panel, - // Spin makes this gear icon spin! - iconPath: new vscode.ThemeIcon("gear~spin"), - pty: { - onDidWrite: writeEmitter.event, - close: () => undefined, - open: () => undefined, - }, - }); - terminal.show(true); - - return { writeEmitter, terminal }; - } - /** * Ensure the workspace specified by the remote authority is ready to receive * SSH connections. Return undefined if the authority is not for a Coder @@ -569,34 +542,25 @@ 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); } @@ -623,33 +587,56 @@ export class Remote { (script) => script.start_blocks_login, ); if (isBlocking) { - const { writeEmitter, terminal } = - this.initWriteEmitterAndTerminal("Agent Log"); - const socket = await writeAgentLogs( - workspaceClient, - writeEmitter, - agent.id, + this.logger.info( + `Waiting for ${workspaceName}/${agent.name} startup...`, ); - await new Promise((resolve) => { - const updateEvent = monitor.onChange.event((workspace) => { - const agents = extractAgents(workspace.latest_build.resources); - const found = agents.find((newAgent) => { - return newAgent.id === agent.id; - }); - if (!found) { - return; - } - agent = found; - if (agent.lifecycle_state === "starting") { - return; - } - updateEvent.dispose(); - resolve(); - }); - }); - writeEmitter.dispose(); - terminal.dispose(); - socket.close(); + + 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(); + } } } @@ -778,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 + } + } +} From a34f9c6682a6e6d0ecf7aaf0ba210106b9c6beaf Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Fri, 24 Oct 2025 12:22:21 +0300 Subject: [PATCH 4/4] More refactoring --- src/api/coderApi.ts | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/api/coderApi.ts b/src/api/coderApi.ts index 697bf9d2..ef120ce4 100644 --- a/src/api/coderApi.ts +++ b/src/api/coderApi.ts @@ -110,17 +110,11 @@ export class CoderApi extends Api { logs: ProvisionerJobLog[], 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`, - searchParams, + return this.watchLogs( + `/api/v2/workspacebuilds/${buildId}/logs`, + logs, options, - }); + ); }; watchWorkspaceAgentLogs = async ( @@ -128,18 +122,30 @@ export class CoderApi extends Api { 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/workspaceagents/${agentId}/logs`, + return this.createWebSocket({ + apiRoute, searchParams, options, }); - }; + } private async createWebSocket( configs: Omit,