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
43 changes: 39 additions & 4 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
import { runDiagnosticCli } from "./command/diagnosticFlow";
import * as cliExec from "./core/cliExec";
import { CertificateError } from "./error/certificateError";
import { toError } from "./error/errorUtils";
import { raceWithAbort, toError } from "./error/errorUtils";
import { type FeatureSet, featureSetForVersion } from "./featureSet";
import {
AuthTelemetry,
Expand Down Expand Up @@ -45,8 +45,9 @@ import {
} from "./remote/sshOverrides";
import { resolveCliAuth } from "./settings/cli";
import { appendVsCodeLogs } from "./supportBundle/appendVsCodeLogs";
import { getRemoteEditorLogGlobs } from "./supportBundle/workspaceFiles";
import { runExportTelemetryCommand } from "./telemetry/export/command";
import { toRemoteAuthority } from "./util/authority";
import { parseRemoteAuthority, toRemoteAuthority } from "./util/authority";
import { openInBrowser, toSafeHost } from "./util/uri";
import { vscodeProposed } from "./vscodeProposed";
import { parseNetcheckReport } from "./webviews/netcheck/types";
Expand Down Expand Up @@ -99,8 +100,11 @@ interface LoginArgs {
type WorkspaceResolution =
| {
readonly status: "selected";
readonly agentName?: string;
readonly client: CoderApi;
readonly workspaceId: string;
/** Active authority, only when it identifies this workspace. */
readonly remoteAuthority?: string;
}
| { readonly status: "cancelled" }
| {
Expand Down Expand Up @@ -424,7 +428,7 @@ export class Commands {
return;
}

const { client, workspaceId } = resolved;
const { agentName, client, workspaceId, remoteAuthority } = resolved;

const outputUri = await this.promptSupportBundlePath();
if (!outputUri) {
Expand All @@ -435,13 +439,30 @@ export class Commands {
const result = await withCancellableProgress(
async ({ signal, progress }) => {
progress.report({ message: "Resolving CLI..." });
// Independent of the CLI and never rejects, so resolve the globs
// concurrently and discard them when the CLI lacks support.
const remoteLogGlobs = getRemoteEditorLogGlobs({
appRoot: vscode.env.appRoot,
remoteAuthority,
logger: this.logger,
});
const env = await this.resolveCliEnv(client);
if (!env.featureSet.supportBundle) {
throw new SupportBundleUnsupportedCliError();
}

// The resolution APIs take no signal, so race to keep cancel honest.
const workspaceFiles = env.featureSet.supportBundleWorkspaceFiles
Comment thread
EhabY marked this conversation as resolved.
? await raceWithAbort(remoteLogGlobs, signal)
: [];

progress.report({ message: "Collecting diagnostics..." });
await cliExec.supportBundle(env, workspaceId, outputUri.fsPath, signal);
await cliExec.supportBundle(env, workspaceId, {
outputPath: outputUri.fsPath,
agentName,
workspaceFiles,
signal,
});

progress.report({ message: "Adding VS Code logs..." });
await appendVsCodeLogs(
Expand Down Expand Up @@ -1133,15 +1154,29 @@ export class Commands {
if (item) {
Comment thread
EhabY marked this conversation as resolved.
return {
status: "selected",
agentName: item instanceof AgentTreeItem ? item.agent.name : undefined,
client: this.extensionClient,
workspaceId: createWorkspaceIdentifier(item.workspace),
};
}
if (this.workspace && this.remoteWorkspaceClient) {
const remoteAuthority = vscodeProposed.env.remoteAuthority;
// Agent resolution is best-effort; a malformed authority must not
// block diagnostics for the whole workspace.
let agentName: string | undefined;
try {
agentName = remoteAuthority
? parseRemoteAuthority(remoteAuthority)?.agent || undefined
: undefined;
} catch (error) {
this.logger.warn("Could not resolve the connected agent", error);
}
return {
status: "selected",
agentName,
client: this.remoteWorkspaceClient,
workspaceId: createWorkspaceIdentifier(this.workspace),
remoteAuthority,
};
}
const pick = await this.pickWorkspace("diagnostic", {
Expand Down
11 changes: 9 additions & 2 deletions src/core/cliExec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,18 +105,25 @@ export async function netcheck(
export async function supportBundle(
env: CliEnv,
workspaceName: string,
outputPath: string,
signal?: AbortSignal,
options: {
outputPath: string;
agentName?: string;
workspaceFiles?: readonly string[];
signal?: AbortSignal;
},
): Promise<void> {
const { outputPath, agentName, workspaceFiles = [], signal } = options;
const globalFlags = getGlobalFlags(env.configs, env.auth);
const args = [
...globalFlags,
"support",
"bundle",
workspaceName,
...(agentName ? [agentName] : []),
"--output-file",
outputPath,
"--yes",
...workspaceFiles.flatMap((file) => ["--workspace-file", file]),
];
try {
await execFileAsync(env.binary, args, { signal });
Expand Down
29 changes: 27 additions & 2 deletions src/error/errorUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,37 @@ export function isAbortError(error: unknown): error is Error {
*/
export function throwIfAborted(signal: AbortSignal | undefined): void {
if (!signal?.aborted) return;
const reason: unknown = signal.reason;
throw reason instanceof Error
throw toAbortError(signal.reason);
}

function toAbortError(reason: unknown): Error {
return reason instanceof Error
? reason
: Object.assign(new Error("Aborted"), { name: "AbortError" });
}

/**
* Await `promise`, rejecting with an AbortError as soon as `signal` aborts.
* The underlying work is abandoned, not stopped; use for operations that
* cannot take a signal themselves.
*/
export async function raceWithAbort<T>(
promise: Promise<T>,
signal: AbortSignal,
): Promise<T> {
throwIfAborted(signal);
let onAbort!: () => void;
const aborted = new Promise<never>((_, reject) => {
onAbort = () => reject(toAbortError(signal.reason));
});
signal.addEventListener("abort", onAbort, { once: true });
try {
return await Promise.race([promise, aborted]);
} finally {
signal.removeEventListener("abort", onAbort);
}
}

// getErrorDetail is copied from coder/site, but changes the default return.
export const getErrorDetail = (error: unknown): string | undefined | null => {
if (isApiError(error)) {
Expand Down
3 changes: 3 additions & 0 deletions src/featureSet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface FeatureSet {
keyringAuth: boolean;
tokenRead: boolean;
supportBundle: boolean;
supportBundleWorkspaceFiles: boolean;
}

/**
Expand Down Expand Up @@ -49,5 +50,7 @@ export function featureSetForVersion(
tokenRead: versionAtLeast(version, "2.31.0"),
// `coder support bundle` (officially released/unhidden in 2.10.0)
supportBundle: versionAtLeast(version, "2.10.0"),
// --workspace-file flag for `coder support bundle`
supportBundleWorkspaceFiles: versionAtLeast(version, "2.36.0"),
};
}
Loading