Skip to content

Commit bc4ba72

Browse files
committed
Add workspace state and agent state machines
1 parent b18dd33 commit bc4ba72

File tree

7 files changed

+565
-555
lines changed

7 files changed

+565
-555
lines changed

src/api/workspace.ts

Lines changed: 40 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { type Api } from "coder/site/src/api/api";
22
import {
33
type WorkspaceAgentLog,
4+
type ProvisionerJobLog,
45
type Workspace,
56
type WorkspaceAgent,
67
} from "coder/site/src/api/typesGenerated";
@@ -85,92 +86,72 @@ export async function startWorkspaceIfStoppedOrFailed(
8586
}
8687

8788
/**
88-
* Wait for the latest build to finish while streaming logs to the emitter.
89-
*
90-
* 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.
9191
*/
92-
export async function waitForBuild(
92+
export async function streamBuildLogs(
9393
client: CoderApi,
9494
writeEmitter: vscode.EventEmitter<string>,
9595
workspace: Workspace,
96-
): Promise<Workspace> {
96+
): Promise<OneWayWebSocket<ProvisionerJobLog>> {
9797
const socket = await client.watchBuildLogsByBuildId(
9898
workspace.latest_build.id,
9999
[],
100100
);
101101

102-
await new Promise<void>((resolve, reject) => {
103-
socket.addEventListener("message", (data) => {
104-
if (data.parseError) {
105-
writeEmitter.fire(
106-
errToStr(data.parseError, "Failed to parse message") + "\r\n",
107-
);
108-
} else {
109-
writeEmitter.fire(data.parsedMessage.output + "\r\n");
110-
}
111-
});
112-
113-
socket.addEventListener("error", (error) => {
114-
const baseUrlRaw = client.getAxiosInstance().defaults.baseURL;
115-
return reject(
116-
new Error(
117-
`Failed to watch workspace build on ${baseUrlRaw}: ${errToStr(error, "no further details")}`,
118-
),
102+
socket.addEventListener("message", (data) => {
103+
if (data.parseError) {
104+
writeEmitter.fire(
105+
errToStr(data.parseError, "Failed to parse message") + "\r\n",
119106
);
120-
});
107+
} else {
108+
writeEmitter.fire(data.parsedMessage.output + "\r\n");
109+
}
110+
});
121111

122-
socket.addEventListener("close", () => resolve());
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+
);
123117
});
124118

125-
writeEmitter.fire("Build complete\r\n");
126-
const updatedWorkspace = await client.getWorkspace(workspace.id);
127-
writeEmitter.fire(
128-
`Workspace is now ${updatedWorkspace.latest_build.status}\r\n`,
129-
);
130-
return updatedWorkspace;
119+
socket.addEventListener("close", () => {
120+
writeEmitter.fire("Build complete\r\n");
121+
});
122+
123+
return socket;
131124
}
132125

133126
/**
134127
* Streams agent logs to the emitter in real-time.
135-
* Returns the websocket and a completion promise that rejects on error.
128+
* Returns the websocket for lifecycle management.
136129
*/
137130
export async function streamAgentLogs(
138131
client: CoderApi,
139132
writeEmitter: vscode.EventEmitter<string>,
140133
agent: WorkspaceAgent,
141-
): Promise<{
142-
socket: OneWayWebSocket<WorkspaceAgentLog[]>;
143-
completion: Promise<void>;
144-
}> {
134+
): Promise<OneWayWebSocket<WorkspaceAgentLog[]>> {
145135
const socket = await client.watchWorkspaceAgentLogs(agent.id, []);
146136

147-
const completion = new Promise<void>((resolve, reject) => {
148-
socket.addEventListener("message", (data) => {
149-
if (data.parseError) {
150-
writeEmitter.fire(
151-
errToStr(data.parseError, "Failed to parse message") + "\r\n",
152-
);
153-
} else {
154-
for (const log of data.parsedMessage) {
155-
writeEmitter.fire(log.output + "\r\n");
156-
}
157-
}
158-
});
159-
160-
socket.addEventListener("error", (error) => {
161-
const baseUrlRaw = client.getAxiosInstance().defaults.baseURL;
137+
socket.addEventListener("message", (data) => {
138+
if (data.parseError) {
162139
writeEmitter.fire(
163-
`Error watching agent logs on ${baseUrlRaw}: ${errToStr(error, "no further details")}\r\n`,
140+
errToStr(data.parseError, "Failed to parse message") + "\r\n",
164141
);
165-
return reject(
166-
new Error(
167-
`Failed to watch agent logs on ${baseUrlRaw}: ${errToStr(error, "no further details")}`,
168-
),
169-
);
170-
});
142+
} else {
143+
for (const log of data.parsedMessage) {
144+
writeEmitter.fire(log.output + "\r\n");
145+
}
146+
}
147+
});
171148

172-
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+
);
173154
});
174155

175-
return { socket, completion };
156+
return socket;
176157
}

src/commands.ts

Lines changed: 4 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { type SecretsManager } from "./core/secretsManager";
1919
import { CertificateError } from "./error";
2020
import { getGlobalFlags } from "./globalFlags";
2121
import { type Logger } from "./logging/logger";
22+
import { maybeAskAgent, maybeAskUrl } from "./promptUtils";
2223
import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util";
2324
import {
2425
AgentTreeItem,
@@ -58,129 +59,6 @@ export class Commands {
5859
this.contextManager = serviceContainer.getContextManager();
5960
}
6061

61-
/**
62-
* Find the requested agent if specified, otherwise return the agent if there
63-
* is only one or ask the user to pick if there are multiple. Return
64-
* undefined if the user cancels.
65-
*/
66-
public async maybeAskAgent(
67-
agents: WorkspaceAgent[],
68-
filter?: string,
69-
): Promise<WorkspaceAgent | undefined> {
70-
const filteredAgents = filter
71-
? agents.filter((agent) => agent.name === filter)
72-
: agents;
73-
if (filteredAgents.length === 0) {
74-
throw new Error("Workspace has no matching agents");
75-
} else if (filteredAgents.length === 1) {
76-
return filteredAgents[0];
77-
} else {
78-
const quickPick = vscode.window.createQuickPick();
79-
quickPick.title = "Select an agent";
80-
quickPick.busy = true;
81-
const agentItems: vscode.QuickPickItem[] = filteredAgents.map((agent) => {
82-
let icon = "$(debug-start)";
83-
if (agent.status !== "connected") {
84-
icon = "$(debug-stop)";
85-
}
86-
return {
87-
alwaysShow: true,
88-
label: `${icon} ${agent.name}`,
89-
detail: `${agent.name} • Status: ${agent.status}`,
90-
};
91-
});
92-
quickPick.items = agentItems;
93-
quickPick.busy = false;
94-
quickPick.show();
95-
96-
const selected = await new Promise<WorkspaceAgent | undefined>(
97-
(resolve) => {
98-
quickPick.onDidHide(() => resolve(undefined));
99-
quickPick.onDidChangeSelection((selected) => {
100-
if (selected.length < 1) {
101-
return resolve(undefined);
102-
}
103-
const agent = filteredAgents[quickPick.items.indexOf(selected[0])];
104-
resolve(agent);
105-
});
106-
},
107-
);
108-
quickPick.dispose();
109-
return selected;
110-
}
111-
}
112-
113-
/**
114-
* Ask the user for the URL, letting them choose from a list of recent URLs or
115-
* CODER_URL or enter a new one. Undefined means the user aborted.
116-
*/
117-
private async askURL(selection?: string): Promise<string | undefined> {
118-
const defaultURL = vscode.workspace
119-
.getConfiguration()
120-
.get<string>("coder.defaultUrl")
121-
?.trim();
122-
const quickPick = vscode.window.createQuickPick();
123-
quickPick.value =
124-
selection || defaultURL || process.env.CODER_URL?.trim() || "";
125-
quickPick.placeholder = "https://example.coder.com";
126-
quickPick.title = "Enter the URL of your Coder deployment.";
127-
128-
// Initial items.
129-
quickPick.items = this.mementoManager
130-
.withUrlHistory(defaultURL, process.env.CODER_URL)
131-
.map((url) => ({
132-
alwaysShow: true,
133-
label: url,
134-
}));
135-
136-
// Quick picks do not allow arbitrary values, so we add the value itself as
137-
// an option in case the user wants to connect to something that is not in
138-
// the list.
139-
quickPick.onDidChangeValue((value) => {
140-
quickPick.items = this.mementoManager
141-
.withUrlHistory(defaultURL, process.env.CODER_URL, value)
142-
.map((url) => ({
143-
alwaysShow: true,
144-
label: url,
145-
}));
146-
});
147-
148-
quickPick.show();
149-
150-
const selected = await new Promise<string | undefined>((resolve) => {
151-
quickPick.onDidHide(() => resolve(undefined));
152-
quickPick.onDidChangeSelection((selected) => resolve(selected[0]?.label));
153-
});
154-
quickPick.dispose();
155-
return selected;
156-
}
157-
158-
/**
159-
* Ask the user for the URL if it was not provided, letting them choose from a
160-
* list of recent URLs or the default URL or CODER_URL or enter a new one, and
161-
* normalizes the returned URL. Undefined means the user aborted.
162-
*/
163-
public async maybeAskUrl(
164-
providedUrl: string | undefined | null,
165-
lastUsedUrl?: string,
166-
): Promise<string | undefined> {
167-
let url = providedUrl || (await this.askURL(lastUsedUrl));
168-
if (!url) {
169-
// User aborted.
170-
return undefined;
171-
}
172-
173-
// Normalize URL.
174-
if (!url.startsWith("http://") && !url.startsWith("https://")) {
175-
// Default to HTTPS if not provided so URLs can be typed more easily.
176-
url = "https://" + url;
177-
}
178-
while (url.endsWith("/")) {
179-
url = url.substring(0, url.length - 1);
180-
}
181-
return url;
182-
}
183-
18462
/**
18563
* Log into the provided deployment. If the deployment URL is not specified,
18664
* ask for it first with a menu showing recent URLs along with the default URL
@@ -197,7 +75,7 @@ export class Commands {
19775
}
19876
this.logger.info("Logging in");
19977

200-
const url = await this.maybeAskUrl(args?.url);
78+
const url = await maybeAskUrl(this.mementoManager, args?.url);
20179
if (!url) {
20280
return; // The user aborted.
20381
}
@@ -488,7 +366,7 @@ export class Commands {
488366
);
489367
} else if (item instanceof WorkspaceTreeItem) {
490368
const agents = await this.extractAgentsWithFallback(item.workspace);
491-
const agent = await this.maybeAskAgent(agents);
369+
const agent = await maybeAskAgent(agents);
492370
if (!agent) {
493371
// User declined to pick an agent.
494372
return;
@@ -611,7 +489,7 @@ export class Commands {
611489
}
612490

613491
const agents = await this.extractAgentsWithFallback(workspace);
614-
const agent = await this.maybeAskAgent(agents, agentName);
492+
const agent = await maybeAskAgent(agents, agentName);
615493
if (!agent) {
616494
// User declined to pick an agent.
617495
return;

src/extension.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Commands } from "./commands";
1212
import { ServiceContainer } from "./core/container";
1313
import { AuthAction } from "./core/secretsManager";
1414
import { CertificateError, getErrorDetail } from "./error";
15+
import { maybeAskUrl } from "./promptUtils";
1516
import { Remote } from "./remote/remote";
1617
import { toSafeHost } from "./util";
1718
import {
@@ -147,7 +148,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
147148
// queries will default to localhost) so ask for it if missing.
148149
// Pre-populate in case we do have the right URL so the user can just
149150
// hit enter and move on.
150-
const url = await commands.maybeAskUrl(
151+
const url = await maybeAskUrl(
152+
mementoManager,
151153
params.get("url"),
152154
mementoManager.getUrl(),
153155
);
@@ -230,7 +232,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
230232
// queries will default to localhost) so ask for it if missing.
231233
// Pre-populate in case we do have the right URL so the user can just
232234
// hit enter and move on.
233-
const url = await commands.maybeAskUrl(
235+
const url = await maybeAskUrl(
236+
mementoManager,
234237
params.get("url"),
235238
mementoManager.getUrl(),
236239
);

0 commit comments

Comments
 (0)