Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
86 changes: 65 additions & 21 deletions src/api/workspace.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
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 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 +41,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 @@ -91,13 +94,9 @@ export async function waitForBuild(
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"));

const socket = await client.watchBuildLogsByBuildId(
workspace.latest_build.id,
logs,
[],
);

await new Promise<void>((resolve, reject) => {
Expand Down Expand Up @@ -130,3 +129,48 @@ export async function waitForBuild(
);
return updatedWorkspace;
}

/**
* Streams agent logs to the emitter in real-time.
* Returns the websocket and a completion promise that rejects on error.
*/
export async function streamAgentLogs(
client: CoderApi,
writeEmitter: vscode.EventEmitter<string>,
agent: WorkspaceAgent,
): Promise<{
socket: OneWayWebSocket<WorkspaceAgentLog[]>;
completion: Promise<void>;
}> {
const socket = await client.watchWorkspaceAgentLogs(agent.id, []);

const completion = new Promise<void>((resolve, reject) => {
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("error", (error) => {
const baseUrlRaw = client.getAxiosInstance().defaults.baseURL;
writeEmitter.fire(
`Error watching agent logs on ${baseUrlRaw}: ${errToStr(error, "no further details")}\r\n`,
);
return reject(
new Error(
`Failed to watch agent logs on ${baseUrlRaw}: ${errToStr(error, "no further details")}`,
),
);
});

socket.addEventListener("close", () => resolve());
});

return { socket, completion };
}
Loading