Skip to content

Commit 35387f2

Browse files
authored
Add workspace and agent state machines with improved progress tracking (#627)
Introduces state machines to manage workspace and agent lifecycle transitions during connection. Improves user experience with clearer progress messages, enhanced websocket-based log streaming, and proper handling of blocking startup scripts. Previously, the extension would connect before startup scripts completed, leaving users waiting with no indication. Now it waits for scripts to finish and shows clear progress throughout the connection process. Closes #626
1 parent a1ad85e commit 35387f2

File tree

10 files changed

+654
-431
lines changed

10 files changed

+654
-431
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## Unreleased
44

5+
### Changed
6+
7+
- Improved workspace connection progress messages and enhanced the workspace build terminal
8+
with better log streaming. The extension now also waits for blocking startup scripts to
9+
complete before connecting, providing clear progress indicators during the wait.
10+
511
## [v1.11.3](https://github.com/coder/vscode-coder/releases/tag/v1.11.3) 2025-10-22
612

713
### Fixed

src/api/coderApi.ts

Lines changed: 28 additions & 3 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";
@@ -109,18 +110,42 @@ export class CoderApi extends Api {
109110
logs: ProvisionerJobLog[],
110111
options?: ClientOptions,
111112
) => {
113+
return this.watchLogs<ProvisionerJobLog>(
114+
`/api/v2/workspacebuilds/${buildId}/logs`,
115+
logs,
116+
options,
117+
);
118+
};
119+
120+
watchWorkspaceAgentLogs = async (
121+
agentId: string,
122+
logs: WorkspaceAgentLog[],
123+
options?: ClientOptions,
124+
) => {
125+
return this.watchLogs<WorkspaceAgentLog[]>(
126+
`/api/v2/workspaceagents/${agentId}/logs`,
127+
logs,
128+
options,
129+
);
130+
};
131+
132+
private async watchLogs<TData>(
133+
apiRoute: string,
134+
logs: { id: number }[],
135+
options?: ClientOptions,
136+
) {
112137
const searchParams = new URLSearchParams({ follow: "true" });
113138
const lastLog = logs.at(-1);
114139
if (lastLog) {
115140
searchParams.append("after", lastLog.id.toString());
116141
}
117142

118-
return this.createWebSocket<ProvisionerJobLog>({
119-
apiRoute: `/api/v2/workspacebuilds/${buildId}/logs`,
143+
return this.createWebSocket<TData>({
144+
apiRoute,
120145
searchParams,
121146
options,
122147
});
123-
};
148+
}
124149

125150
private async createWebSocket<TData = unknown>(
126151
configs: Omit<OneWayWebSocketInit, "location">,

src/api/workspace.ts

Lines changed: 75 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
1-
import { spawn } from "child_process";
21
import { type Api } from "coder/site/src/api/api";
3-
import { type Workspace } from "coder/site/src/api/typesGenerated";
2+
import {
3+
type WorkspaceAgentLog,
4+
type ProvisionerJobLog,
5+
type Workspace,
6+
type WorkspaceAgent,
7+
} from "coder/site/src/api/typesGenerated";
8+
import { spawn } from "node:child_process";
49
import * as vscode from "vscode";
510

611
import { type FeatureSet } from "../featureSet";
712
import { getGlobalFlags } from "../globalFlags";
813
import { escapeCommandArg } from "../util";
14+
import { type OneWayWebSocket } from "../websocket/oneWayWebSocket";
915

1016
import { errToStr, createWorkspaceIdentifier } from "./api-helper";
1117
import { type CoderApi } from "./coderApi";
@@ -36,35 +42,33 @@ export async function startWorkspaceIfStoppedOrFailed(
3642
createWorkspaceIdentifier(workspace),
3743
];
3844
if (featureSet.buildReason) {
39-
startArgs.push(...["--reason", "vscode_connection"]);
45+
startArgs.push("--reason", "vscode_connection");
4046
}
4147

4248
// { shell: true } requires one shell-safe command string, otherwise we lose all escaping
4349
const cmd = `${escapeCommandArg(binPath)} ${startArgs.join(" ")}`;
4450
const startProcess = spawn(cmd, { shell: true });
4551

4652
startProcess.stdout.on("data", (data: Buffer) => {
47-
data
53+
const lines = data
4854
.toString()
4955
.split(/\r*\n/)
50-
.forEach((line: string) => {
51-
if (line !== "") {
52-
writeEmitter.fire(line.toString() + "\r\n");
53-
}
54-
});
56+
.filter((line) => line !== "");
57+
for (const line of lines) {
58+
writeEmitter.fire(line.toString() + "\r\n");
59+
}
5560
});
5661

5762
let capturedStderr = "";
5863
startProcess.stderr.on("data", (data: Buffer) => {
59-
data
64+
const lines = data
6065
.toString()
6166
.split(/\r*\n/)
62-
.forEach((line: string) => {
63-
if (line !== "") {
64-
writeEmitter.fire(line.toString() + "\r\n");
65-
capturedStderr += line.toString() + "\n";
66-
}
67-
});
67+
.filter((line) => line !== "");
68+
for (const line of lines) {
69+
writeEmitter.fire(line.toString() + "\r\n");
70+
capturedStderr += line.toString() + "\n";
71+
}
6872
});
6973

7074
startProcess.on("close", (code: number) => {
@@ -82,51 +86,72 @@ export async function startWorkspaceIfStoppedOrFailed(
8286
}
8387

8488
/**
85-
* Wait for the latest build to finish while streaming logs to the emitter.
86-
*
87-
* Once completed, fetch the workspace again and return it.
89+
* Streams build logs to the emitter in real-time.
90+
* Returns the websocket for lifecycle management.
8891
*/
89-
export async function waitForBuild(
92+
export async function streamBuildLogs(
9093
client: CoderApi,
9194
writeEmitter: vscode.EventEmitter<string>,
9295
workspace: Workspace,
93-
): Promise<Workspace> {
94-
// This fetches the initial bunch of logs.
95-
const logs = await client.getWorkspaceBuildLogs(workspace.latest_build.id);
96-
logs.forEach((log) => writeEmitter.fire(log.output + "\r\n"));
97-
96+
): Promise<OneWayWebSocket<ProvisionerJobLog>> {
9897
const socket = await client.watchBuildLogsByBuildId(
9998
workspace.latest_build.id,
100-
logs,
99+
[],
101100
);
102101

103-
await new Promise<void>((resolve, reject) => {
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-
writeEmitter.fire(data.parsedMessage.output + "\r\n");
111-
}
112-
});
102+
socket.addEventListener("message", (data) => {
103+
if (data.parseError) {
104+
writeEmitter.fire(
105+
errToStr(data.parseError, "Failed to parse message") + "\r\n",
106+
);
107+
} else {
108+
writeEmitter.fire(data.parsedMessage.output + "\r\n");
109+
}
110+
});
111+
112+
socket.addEventListener("error", (error) => {
113+
const baseUrlRaw = client.getAxiosInstance().defaults.baseURL;
114+
writeEmitter.fire(
115+
`Error watching workspace build logs on ${baseUrlRaw}: ${errToStr(error, "no further details")}\r\n`,
116+
);
117+
});
118+
119+
socket.addEventListener("close", () => {
120+
writeEmitter.fire("Build complete\r\n");
121+
});
122+
123+
return socket;
124+
}
113125

114-
socket.addEventListener("error", (error) => {
115-
const baseUrlRaw = client.getAxiosInstance().defaults.baseURL;
116-
return reject(
117-
new Error(
118-
`Failed to watch workspace build on ${baseUrlRaw}: ${errToStr(error, "no further details")}`,
119-
),
126+
/**
127+
* Streams agent logs to the emitter in real-time.
128+
* Returns the websocket for lifecycle management.
129+
*/
130+
export async function streamAgentLogs(
131+
client: CoderApi,
132+
writeEmitter: vscode.EventEmitter<string>,
133+
agent: WorkspaceAgent,
134+
): Promise<OneWayWebSocket<WorkspaceAgentLog[]>> {
135+
const socket = await client.watchWorkspaceAgentLogs(agent.id, []);
136+
137+
socket.addEventListener("message", (data) => {
138+
if (data.parseError) {
139+
writeEmitter.fire(
140+
errToStr(data.parseError, "Failed to parse message") + "\r\n",
120141
);
121-
});
142+
} else {
143+
for (const log of data.parsedMessage) {
144+
writeEmitter.fire(log.output + "\r\n");
145+
}
146+
}
147+
});
122148

123-
socket.addEventListener("close", () => resolve());
149+
socket.addEventListener("error", (error) => {
150+
const baseUrlRaw = client.getAxiosInstance().defaults.baseURL;
151+
writeEmitter.fire(
152+
`Error watching agent logs on ${baseUrlRaw}: ${errToStr(error, "no further details")}\r\n`,
153+
);
124154
});
125155

126-
writeEmitter.fire("Build complete\r\n");
127-
const updatedWorkspace = await client.getWorkspace(workspace.id);
128-
writeEmitter.fire(
129-
`Workspace is now ${updatedWorkspace.latest_build.status}\r\n`,
130-
);
131-
return updatedWorkspace;
156+
return socket;
132157
}

0 commit comments

Comments
 (0)