Skip to content

Commit c49dc60

Browse files
committed
Wait for startup script when launching the workspace
1 parent fe6d1dc commit c49dc60

File tree

4 files changed

+129
-48
lines changed

4 files changed

+129
-48
lines changed

src/api/coderApi.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type ProvisionerJobLog,
1212
type Workspace,
1313
type WorkspaceAgent,
14+
type WorkspaceAgentLog,
1415
} from "coder/site/src/api/typesGenerated";
1516
import * as vscode from "vscode";
1617
import { type ClientOptions, type CloseEvent, type ErrorEvent } from "ws";
@@ -122,6 +123,24 @@ export class CoderApi extends Api {
122123
});
123124
};
124125

126+
watchWorkspaceAgentLogs = async (
127+
agentId: string,
128+
logs: WorkspaceAgentLog[],
129+
options?: ClientOptions,
130+
) => {
131+
const searchParams = new URLSearchParams({ follow: "true" });
132+
const lastLog = logs.at(-1);
133+
if (lastLog) {
134+
searchParams.append("after", lastLog.id.toString());
135+
}
136+
137+
return this.createWebSocket<WorkspaceAgentLog[]>({
138+
apiRoute: `/api/v2/workspaceagents/${agentId}/logs`,
139+
searchParams,
140+
options,
141+
});
142+
};
143+
125144
private async createWebSocket<TData = unknown>(
126145
configs: Omit<OneWayWebSocketInit, "location">,
127146
) {

src/api/workspace.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import { spawn } from "child_process";
22
import { type Api } from "coder/site/src/api/api";
3-
import { type Workspace } from "coder/site/src/api/typesGenerated";
3+
import {
4+
type WorkspaceAgentLog,
5+
type Workspace,
6+
} from "coder/site/src/api/typesGenerated";
47
import * as vscode from "vscode";
58

69
import { type FeatureSet } from "../featureSet";
710
import { getGlobalFlags } from "../globalFlags";
811
import { escapeCommandArg } from "../util";
12+
import { type OneWayWebSocket } from "../websocket/oneWayWebSocket";
913

1014
import { errToStr, createWorkspaceIdentifier } from "./api-helper";
1115
import { type CoderApi } from "./coderApi";
@@ -81,6 +85,43 @@ export async function startWorkspaceIfStoppedOrFailed(
8185
});
8286
}
8387

88+
/**
89+
* Wait for the latest build to finish while streaming logs to the emitter.
90+
*
91+
* Once completed, fetch the workspace again and return it.
92+
*/
93+
export async function writeAgentLogs(
94+
client: CoderApi,
95+
writeEmitter: vscode.EventEmitter<string>,
96+
agentId: string,
97+
): Promise<OneWayWebSocket<WorkspaceAgentLog[]>> {
98+
// This fetches the initial bunch of logs.
99+
const logs = await client.getWorkspaceAgentLogs(agentId);
100+
logs.forEach((log) => writeEmitter.fire(log.output + "\r\n"));
101+
102+
const socket = await client.watchWorkspaceAgentLogs(agentId, logs);
103+
104+
socket.addEventListener("message", (data) => {
105+
if (data.parseError) {
106+
writeEmitter.fire(
107+
errToStr(data.parseError, "Failed to parse message") + "\r\n",
108+
);
109+
} else {
110+
data.parsedMessage.forEach((message) =>
111+
writeEmitter.fire(message.output + "\r\n"),
112+
);
113+
}
114+
});
115+
116+
socket.addEventListener("error", (error) => {
117+
const baseUrlRaw = client.getAxiosInstance().defaults.baseURL;
118+
throw new Error(
119+
`Failed to watch workspace build on ${baseUrlRaw}: ${errToStr(error, "no further details")}`,
120+
);
121+
});
122+
return socket;
123+
}
124+
84125
/**
85126
* Wait for the latest build to finish while streaming logs to the emitter.
86127
*

src/extension.ts

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
353353
}),
354354
);
355355

356-
let shouldShowSshOutput = false;
357356
// Since the "onResolveRemoteAuthority:ssh-remote" activation event exists
358357
// in package.json we're able to perform actions before the authority is
359358
// resolved by the remote SSH extension.
@@ -371,7 +370,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
371370
);
372371
if (details) {
373372
ctx.subscriptions.push(details);
374-
shouldShowSshOutput = details.startedWorkspace;
375373
// Authenticate the plugin client which is used in the sidebar to display
376374
// workspaces belonging to this deployment.
377375
client.setHost(details.url);
@@ -462,27 +460,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
462460
}
463461
}
464462
}
465-
466-
if (shouldShowSshOutput) {
467-
showSshOutput();
468-
}
469463
}
470464

471465
async function showTreeViewSearch(id: string): Promise<void> {
472466
await vscode.commands.executeCommand(`${id}.focus`);
473467
await vscode.commands.executeCommand("list.find");
474468
}
475-
476-
function showSshOutput(): void {
477-
for (const command of [
478-
"opensshremotes.showLog",
479-
"windsurf-remote-openssh.showLog",
480-
]) {
481-
/**
482-
* We must not await this command because
483-
* 1) it may not exist
484-
* 2) it might cause the Remote SSH extension to be loaded synchronously
485-
*/
486-
void vscode.commands.executeCommand(command);
487-
}
488-
}

src/remote/remote.ts

Lines changed: 68 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { needToken } from "../api/utils";
2525
import {
2626
startWorkspaceIfStoppedOrFailed,
2727
waitForBuild,
28+
writeAgentLogs,
2829
} from "../api/workspace";
2930
import { type Commands } from "../commands";
3031
import { type CliManager } from "../core/cliManager";
@@ -51,7 +52,6 @@ import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport";
5152
export interface RemoteDetails extends vscode.Disposable {
5253
url: string;
5354
token: string;
54-
startedWorkspace: boolean;
5555
}
5656

5757
export class Remote {
@@ -131,29 +131,18 @@ export class Remote {
131131
const workspaceName = createWorkspaceIdentifier(workspace);
132132

133133
// A terminal will be used to stream the build, if one is necessary.
134-
let writeEmitter: undefined | vscode.EventEmitter<string>;
135-
let terminal: undefined | vscode.Terminal;
134+
let writeEmitter: vscode.EventEmitter<string> | undefined;
135+
let terminal: vscode.Terminal | undefined;
136136
let attempts = 0;
137137

138-
function initWriteEmitterAndTerminal(): vscode.EventEmitter<string> {
139-
writeEmitter ??= new vscode.EventEmitter<string>();
140-
if (!terminal) {
141-
terminal = vscode.window.createTerminal({
142-
name: "Build Log",
143-
location: vscode.TerminalLocation.Panel,
144-
// Spin makes this gear icon spin!
145-
iconPath: new vscode.ThemeIcon("gear~spin"),
146-
pty: {
147-
onDidWrite: writeEmitter.event,
148-
close: () => undefined,
149-
open: () => undefined,
150-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
151-
} as Partial<vscode.Pseudoterminal> as any,
152-
});
153-
terminal.show(true);
138+
const initBuildLogTerminal = () => {
139+
if (!writeEmitter) {
140+
const init = this.initWriteEmitterAndTerminal("Build Log");
141+
writeEmitter = init.writeEmitter;
142+
terminal = init.terminal;
154143
}
155144
return writeEmitter;
156-
}
145+
};
157146

158147
try {
159148
// Show a notification while we wait.
@@ -171,7 +160,7 @@ export class Remote {
171160
case "pending":
172161
case "starting":
173162
case "stopping":
174-
writeEmitter = initWriteEmitterAndTerminal();
163+
writeEmitter = initBuildLogTerminal();
175164
this.logger.info(`Waiting for ${workspaceName}...`);
176165
workspace = await waitForBuild(client, writeEmitter, workspace);
177166
break;
@@ -182,7 +171,7 @@ export class Remote {
182171
) {
183172
return undefined;
184173
}
185-
writeEmitter = initWriteEmitterAndTerminal();
174+
writeEmitter = initBuildLogTerminal();
186175
this.logger.info(`Starting ${workspaceName}...`);
187176
workspace = await startWorkspaceIfStoppedOrFailed(
188177
client,
@@ -203,7 +192,7 @@ export class Remote {
203192
) {
204193
return undefined;
205194
}
206-
writeEmitter = initWriteEmitterAndTerminal();
195+
writeEmitter = initBuildLogTerminal();
207196
this.logger.info(`Starting ${workspaceName}...`);
208197
workspace = await startWorkspaceIfStoppedOrFailed(
209198
client,
@@ -246,6 +235,27 @@ export class Remote {
246235
}
247236
}
248237

238+
private initWriteEmitterAndTerminal(name: string): {
239+
writeEmitter: vscode.EventEmitter<string>;
240+
terminal: vscode.Terminal;
241+
} {
242+
const writeEmitter = new vscode.EventEmitter<string>();
243+
const terminal = vscode.window.createTerminal({
244+
name,
245+
location: vscode.TerminalLocation.Panel,
246+
// Spin makes this gear icon spin!
247+
iconPath: new vscode.ThemeIcon("gear~spin"),
248+
pty: {
249+
onDidWrite: writeEmitter.event,
250+
close: () => undefined,
251+
open: () => undefined,
252+
},
253+
});
254+
terminal.show(true);
255+
256+
return { writeEmitter, terminal };
257+
}
258+
249259
/**
250260
* Ensure the workspace specified by the remote authority is ready to receive
251261
* SSH connections. Return undefined if the authority is not for a Coder
@@ -416,7 +426,6 @@ export class Remote {
416426
}
417427
}
418428

419-
let startedWorkspace = false;
420429
const disposables: vscode.Disposable[] = [];
421430
try {
422431
// Register before connection so the label still displays!
@@ -444,7 +453,6 @@ export class Remote {
444453
await this.closeRemote();
445454
return;
446455
}
447-
startedWorkspace = true;
448456
workspace = updatedWorkspace;
449457
}
450458
this.commands.workspace = workspace;
@@ -593,7 +601,6 @@ export class Remote {
593601
}
594602

595603
// Make sure the agent is connected.
596-
// TODO: Should account for the lifecycle state as well?
597604
if (agent.status !== "connected") {
598605
const result = await this.vscodeProposed.window.showErrorMessage(
599606
`${workspaceName}/${agent.name} ${agent.status}`,
@@ -611,6 +618,41 @@ export class Remote {
611618
return;
612619
}
613620

621+
if (agent.lifecycle_state === "starting") {
622+
const isBlocking = agent.scripts.some(
623+
(script) => script.start_blocks_login,
624+
);
625+
if (isBlocking) {
626+
const { writeEmitter, terminal } =
627+
this.initWriteEmitterAndTerminal("Agent Log");
628+
const socket = await writeAgentLogs(
629+
workspaceClient,
630+
writeEmitter,
631+
agent.id,
632+
);
633+
await new Promise<void>((resolve) => {
634+
const updateEvent = monitor.onChange.event((workspace) => {
635+
const agents = extractAgents(workspace.latest_build.resources);
636+
const found = agents.find((newAgent) => {
637+
return newAgent.id === agent.id;
638+
});
639+
if (!found) {
640+
return;
641+
}
642+
agent = found;
643+
if (agent.lifecycle_state === "starting") {
644+
return;
645+
}
646+
updateEvent.dispose();
647+
resolve();
648+
});
649+
});
650+
writeEmitter.dispose();
651+
terminal.dispose();
652+
socket.close();
653+
}
654+
}
655+
614656
const logDir = this.getLogDir(featureSet);
615657

616658
// This ensures the Remote SSH extension resolves the host to execute the
@@ -684,7 +726,6 @@ export class Remote {
684726
return {
685727
url: baseUrlRaw,
686728
token,
687-
startedWorkspace,
688729
dispose: () => {
689730
disposables.forEach((d) => d.dispose());
690731
},

0 commit comments

Comments
 (0)