Skip to content
31 changes: 28 additions & 3 deletions src/api/coderApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -109,18 +110,42 @@ export class CoderApi extends Api {
logs: ProvisionerJobLog[],
options?: ClientOptions,
) => {
return this.watchLogs<ProvisionerJobLog>(
`/api/v2/workspacebuilds/${buildId}/logs`,
logs,
options,
);
};

watchWorkspaceAgentLogs = async (
agentId: string,
logs: WorkspaceAgentLog[],
options?: ClientOptions,
) => {
return this.watchLogs<WorkspaceAgentLog[]>(
`/api/v2/workspaceagents/${agentId}/logs`,
logs,
options,
);
};

private async watchLogs<TData>(
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<ProvisionerJobLog>({
apiRoute: `/api/v2/workspacebuilds/${buildId}/logs`,
return this.createWebSocket<TData>({
apiRoute,
searchParams,
options,
});
};
}

private async createWebSocket<TData = unknown>(
configs: Omit<OneWayWebSocketInit, "location">,
Expand Down
125 changes: 75 additions & 50 deletions src/api/workspace.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
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 ProvisionerJobLog,
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";
Expand Down Expand Up @@ -36,35 +42,33 @@ 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
const cmd = `${escapeCommandArg(binPath)} ${startArgs.join(" ")}`;
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) => {
Expand All @@ -82,51 +86,72 @@ 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.
* Streams build logs to the emitter in real-time.
* Returns the websocket for lifecycle management.
*/
export async function waitForBuild(
export async function streamBuildLogs(
client: CoderApi,
writeEmitter: vscode.EventEmitter<string>,
workspace: Workspace,
): Promise<Workspace> {
// 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"));

): Promise<OneWayWebSocket<ProvisionerJobLog>> {
const socket = await client.watchBuildLogsByBuildId(
workspace.latest_build.id,
logs,
[],
);

await new Promise<void>((resolve, reject) => {
socket.addEventListener("message", (data) => {
if (data.parseError) {
writeEmitter.fire(
errToStr(data.parseError, "Failed to parse message") + "\r\n",
);
} else {
writeEmitter.fire(data.parsedMessage.output + "\r\n");
}
});
socket.addEventListener("message", (data) => {
if (data.parseError) {
writeEmitter.fire(
errToStr(data.parseError, "Failed to parse message") + "\r\n",
);
} else {
writeEmitter.fire(data.parsedMessage.output + "\r\n");
}
});

socket.addEventListener("error", (error) => {
const baseUrlRaw = client.getAxiosInstance().defaults.baseURL;
writeEmitter.fire(
`Error watching workspace build logs on ${baseUrlRaw}: ${errToStr(error, "no further details")}\r\n`,
);
});

socket.addEventListener("close", () => {
writeEmitter.fire("Build complete\r\n");
});

return socket;
}

socket.addEventListener("error", (error) => {
const baseUrlRaw = client.getAxiosInstance().defaults.baseURL;
return reject(
new Error(
`Failed to watch workspace build on ${baseUrlRaw}: ${errToStr(error, "no further details")}`,
),
/**
* Streams agent logs to the emitter in real-time.
* Returns the websocket for lifecycle management.
*/
export async function streamAgentLogs(
client: CoderApi,
writeEmitter: vscode.EventEmitter<string>,
agent: WorkspaceAgent,
): Promise<OneWayWebSocket<WorkspaceAgentLog[]>> {
const socket = await client.watchWorkspaceAgentLogs(agent.id, []);

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("close", () => resolve());
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`,
);
});

writeEmitter.fire("Build complete\r\n");
const updatedWorkspace = await client.getWorkspace(workspace.id);
writeEmitter.fire(
`Workspace is now ${updatedWorkspace.latest_build.status}\r\n`,
);
return updatedWorkspace;
return socket;
}
130 changes: 4 additions & 126 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { type SecretsManager } from "./core/secretsManager";
import { CertificateError } from "./error";
import { getGlobalFlags } from "./globalFlags";
import { type Logger } from "./logging/logger";
import { maybeAskAgent, maybeAskUrl } from "./promptUtils";
import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util";
import {
AgentTreeItem,
Expand Down Expand Up @@ -58,129 +59,6 @@ export class Commands {
this.contextManager = serviceContainer.getContextManager();
}

/**
* Find the requested agent if specified, otherwise return the agent if there
* is only one or ask the user to pick if there are multiple. Return
* undefined if the user cancels.
*/
public async maybeAskAgent(
agents: WorkspaceAgent[],
filter?: string,
): Promise<WorkspaceAgent | undefined> {
const filteredAgents = filter
? agents.filter((agent) => agent.name === filter)
: agents;
if (filteredAgents.length === 0) {
throw new Error("Workspace has no matching agents");
} else if (filteredAgents.length === 1) {
return filteredAgents[0];
} else {
const quickPick = vscode.window.createQuickPick();
quickPick.title = "Select an agent";
quickPick.busy = true;
const agentItems: vscode.QuickPickItem[] = filteredAgents.map((agent) => {
let icon = "$(debug-start)";
if (agent.status !== "connected") {
icon = "$(debug-stop)";
}
return {
alwaysShow: true,
label: `${icon} ${agent.name}`,
detail: `${agent.name} • Status: ${agent.status}`,
};
});
quickPick.items = agentItems;
quickPick.busy = false;
quickPick.show();

const selected = await new Promise<WorkspaceAgent | undefined>(
(resolve) => {
quickPick.onDidHide(() => resolve(undefined));
quickPick.onDidChangeSelection((selected) => {
if (selected.length < 1) {
return resolve(undefined);
}
const agent = filteredAgents[quickPick.items.indexOf(selected[0])];
resolve(agent);
});
},
);
quickPick.dispose();
return selected;
}
}

/**
* Ask the user for the URL, letting them choose from a list of recent URLs or
* CODER_URL or enter a new one. Undefined means the user aborted.
*/
private async askURL(selection?: string): Promise<string | undefined> {
const defaultURL = vscode.workspace
.getConfiguration()
.get<string>("coder.defaultUrl")
?.trim();
const quickPick = vscode.window.createQuickPick();
quickPick.value =
selection || defaultURL || process.env.CODER_URL?.trim() || "";
quickPick.placeholder = "https://example.coder.com";
quickPick.title = "Enter the URL of your Coder deployment.";

// Initial items.
quickPick.items = this.mementoManager
.withUrlHistory(defaultURL, process.env.CODER_URL)
.map((url) => ({
alwaysShow: true,
label: url,
}));

// Quick picks do not allow arbitrary values, so we add the value itself as
// an option in case the user wants to connect to something that is not in
// the list.
quickPick.onDidChangeValue((value) => {
quickPick.items = this.mementoManager
.withUrlHistory(defaultURL, process.env.CODER_URL, value)
.map((url) => ({
alwaysShow: true,
label: url,
}));
});

quickPick.show();

const selected = await new Promise<string | undefined>((resolve) => {
quickPick.onDidHide(() => resolve(undefined));
quickPick.onDidChangeSelection((selected) => resolve(selected[0]?.label));
});
quickPick.dispose();
return selected;
}

/**
* Ask the user for the URL if it was not provided, letting them choose from a
* list of recent URLs or the default URL or CODER_URL or enter a new one, and
* normalizes the returned URL. Undefined means the user aborted.
*/
public async maybeAskUrl(
providedUrl: string | undefined | null,
lastUsedUrl?: string,
): Promise<string | undefined> {
let url = providedUrl || (await this.askURL(lastUsedUrl));
if (!url) {
// User aborted.
return undefined;
}

// Normalize URL.
if (!url.startsWith("http://") && !url.startsWith("https://")) {
// Default to HTTPS if not provided so URLs can be typed more easily.
url = "https://" + url;
}
while (url.endsWith("/")) {
url = url.substring(0, url.length - 1);
}
return url;
}

/**
* Log into the provided deployment. If the deployment URL is not specified,
* ask for it first with a menu showing recent URLs along with the default URL
Expand All @@ -197,7 +75,7 @@ export class Commands {
}
this.logger.info("Logging in");

const url = await this.maybeAskUrl(args?.url);
const url = await maybeAskUrl(this.mementoManager, args?.url);
if (!url) {
return; // The user aborted.
}
Expand Down Expand Up @@ -488,7 +366,7 @@ export class Commands {
);
} else if (item instanceof WorkspaceTreeItem) {
const agents = await this.extractAgentsWithFallback(item.workspace);
const agent = await this.maybeAskAgent(agents);
const agent = await maybeAskAgent(agents);
if (!agent) {
// User declined to pick an agent.
return;
Expand Down Expand Up @@ -611,7 +489,7 @@ export class Commands {
}

const agents = await this.extractAgentsWithFallback(workspace);
const agent = await this.maybeAskAgent(agents, agentName);
const agent = await maybeAskAgent(agents, agentName);
if (!agent) {
// User declined to pick an agent.
return;
Expand Down
Loading