Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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