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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@
replay the buffered connection logs instead. Close codes never reached the
reconnect logic, so these closes retried forever. Server-initiated normal
closes (`1000`/`1001`) keep reconnecting.
- Update a workspace in one build on Coder 2.36 and later. The connection's
own autostart could take the build slot between the update's stop and start,
so the workspace came back on the template version it already had.
- Ask whether to connect to the existing version when an update fails, instead
of connecting to it with only a warning.
- Hide the Tasks panel on deployments before 2.29, which do not serve
`/api/v2/tasks` and answered every poll with a 404.

## [v1.16.3](https://github.com/coder/vscode-coder/releases/tag/v1.16.3) 2026-09-14

Expand Down
60 changes: 51 additions & 9 deletions src/api/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,20 @@ import { errToStr, createWorkspaceIdentifier } from "./api-helper";

import type { Api } from "coder/site/src/api/api";
import type {
CreateWorkspaceBuildOnSuccessRequest,
ProvisionerJobLog,
Workspace,
WorkspaceAgentLog,
WorkspaceBuildParameter,
} from "coder/site/src/api/typesGenerated";

import type { FeatureSet } from "../featureSet";
import type { CliFeatureSet, ServerFeatureSet } from "../featureSet";
import type { UnidirectionalStream } from "../websocket/eventStreamConnection";

import type { CoderApi } from "./coderApi";

const BUILD_REASON = "vscode_connection";

/** Opens a stream once; subsequent open() calls are no-ops until closed. */
export class LazyStream<T> {
private stream: UnidirectionalStream<T> | null = null;
Expand Down Expand Up @@ -54,7 +57,8 @@ interface CliContext {
binPath: string;
workspace: Workspace;
write: (data: string) => void;
featureSet: FeatureSet;
cliFeatures: CliFeatureSet;
serverFeatures: ServerFeatureSet;
}

/** Streams CLI output via `ctx.write`; rejects with stderr on non-zero exit. */
Expand Down Expand Up @@ -107,8 +111,8 @@ export async function startWorkspace(ctx: CliContext): Promise<Workspace> {
}

const args = ["start", "--yes"];
if (ctx.featureSet.buildReason) {
args.push("--reason", "vscode_connection");
if (ctx.cliFeatures.buildReason) {
args.push("--reason", BUILD_REASON);
}

await runCliCommand(ctx, args);
Expand All @@ -118,14 +122,18 @@ export async function startWorkspace(ctx: CliContext): Promise<Workspace> {
/**
* Update a workspace to the latest template version. Callers must collect
* any newly-required parameters via `collectUpdateParameters` first; this
* function does not prompt. Falls back to the REST API on CLIs older than
* 2.24.
* function does not prompt. On servers before 2.36, updating takes two
* builds: `coder update`, or the REST API on CLIs before 2.24.
*/
export async function updateWorkspace(
ctx: CliContext,
parameters: WorkspaceBuildParameter[],
): Promise<Workspace> {
if (!ctx.featureSet.cliUpdate) {
if (ctx.serverFeatures.onSuccessBuild) {
return updateWorkspaceInOneBuild(ctx, parameters);
}

if (!ctx.cliFeatures.cliUpdate) {
return updateWorkspaceViaApi(ctx, parameters);
}

Expand All @@ -137,6 +145,40 @@ export async function updateWorkspace(
return ctx.restClient.getWorkspace(ctx.workspace.id);
}

/**
* Stop and start in one build, so nothing can take the slot in between. The
* returned workspace carries the stop build; the server starts it after.
*/
async function updateWorkspaceInOneBuild(
ctx: CliContext,
parameters: WorkspaceBuildParameter[],
): Promise<Workspace> {
// The build may have changed while parameters were collected.
const workspace = await ctx.restClient.getWorkspace(ctx.workspace.id);
const start: CreateWorkspaceBuildOnSuccessRequest = {
transition: "start",
rich_parameter_values: parameters,
};
const running = workspace.latest_build.status === "running";

ctx.write(
`${running ? "Restarting" : "Starting"} workspace with the updated template...\r\n`,
);
const build = await ctx.restClient.postWorkspaceBuild(
workspace.id,
running
? { transition: "stop", reason: BUILD_REASON, on_success: start }
: {
...start,
reason: BUILD_REASON,
// Pinning a follow-up build needs template update permission,
// so only a lone start can name the version.
template_version_id: workspace.template_active_version_id,
},
);
return { ...workspace, latest_build: build };
}

async function updateWorkspaceViaApi(
ctx: CliContext,
parameters: WorkspaceBuildParameter[],
Expand All @@ -145,8 +187,8 @@ async function updateWorkspaceViaApi(
ctx.write("Stopping workspace for update...\r\n");
const stopBuild = await ctx.restClient.stopWorkspace(ctx.workspace.id);
const stoppedJob = await ctx.restClient.waitForBuild(stopBuild);
if (stoppedJob?.status === "canceled") {
throw new Error("Workspace update cancelled during stop");
if (stoppedJob?.status !== "succeeded") {
throw new Error("Workspace update stop build did not succeed");
}
}

Expand Down
6 changes: 3 additions & 3 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { runDiagnosticCli } from "./command/diagnosticFlow";
import * as cliExec from "./core/cliExec";
import { CertificateError } from "./error/certificateError";
import { raceWithAbort, toError } from "./error/errorUtils";
import { type FeatureSet, featureSetForVersion } from "./featureSet";
import { type CliFeatureSet, cliFeatureSet } from "./featureSet";
import {
AuthTelemetry,
type AuthLoginOutcome,
Expand Down Expand Up @@ -1467,7 +1467,7 @@ export class Commands {
/** Resolve a CliEnv, preferring a locally cached binary over a network fetch. */
private async resolveCliEnv(
client: CoderApi,
): Promise<cliExec.CliEnv & { featureSet: FeatureSet }> {
): Promise<cliExec.CliEnv & { featureSet: CliFeatureSet }> {
const baseUrl = client.getAxiosInstance().defaults.baseURL;
if (!baseUrl) {
throw new Error("You are not logged in");
Expand All @@ -1477,7 +1477,7 @@ export class Commands {
(await this.cliManager.locateBinary(baseUrl)) ??
(await this.cliManager.fetchBinary(client));
const version = semver.parse(await cliExec.version(binary));
const featureSet = featureSetForVersion(version);
const featureSet = cliFeatureSet(version);
const configDir = this.pathResolver.getGlobalConfigDir(safeHost);
const configs = vscode.workspace.getConfiguration();
const auth = resolveCliAuth(configs, featureSet, baseUrl, configDir);
Expand Down
8 changes: 3 additions & 5 deletions src/core/cliCredentialManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { promisify } from "node:util";
import * as semver from "semver";

import { isAbortError } from "../error/errorUtils";
import { featureSetForVersion, type FeatureSet } from "../featureSet";
import { cliFeatureSet, type CliFeatureSet } from "../featureSet";
import {
categorizeCredentialError,
CredentialCliError,
Expand Down Expand Up @@ -36,7 +36,7 @@ const EXEC_LOG_INTERVAL_MS = 5_000;

interface ResolvedCli {
binPath: string;
featureSet: FeatureSet;
featureSet: CliFeatureSet;
auth: CliAuth;
flags: string[];
}
Expand Down Expand Up @@ -218,9 +218,7 @@ export class CliCredentialManager {
if (!binPath) {
return undefined;
}
const featureSet = featureSetForVersion(
semver.parse(await version(binPath)),
);
const featureSet = cliFeatureSet(semver.parse(await version(binPath)));
const configDir = this.pathResolver.getGlobalConfigDir(toSafeHost(url));
const auth = resolveCliAuth(configs, featureSet, url, configDir);
return { binPath, featureSet, auth, flags: getGlobalFlags(configs, auth) };
Expand Down
4 changes: 2 additions & 2 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { ServiceContainer } from "./core/container";
import { DeploymentManager } from "./deployment/deploymentManager";
import { CertificateError } from "./error/certificateError";
import { getErrorDetail, toError } from "./error/errorUtils";
import { tasksSupported } from "./featureSet";
import { serverFeatureSet } from "./featureSet";
import {
ActivationTelemetry,
type ActivationTracer,
Expand Down Expand Up @@ -209,7 +209,7 @@ async function doActivate(
if (deploymentManager.session.current.kind === "signedIn") {
try {
const buildInfo = await client.getBuildInfo();
supported = tasksSupported(semver.parse(buildInfo.version));
supported = serverFeatureSet(semver.parse(buildInfo.version)).tasks;
} catch (error) {
output.warn(
"Unable to fetch deployment version for Tasks panel",
Expand Down
40 changes: 24 additions & 16 deletions src/featureSet.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type * as semver from "semver";

export interface FeatureSet {
/** Capabilities keyed to the Coder CLI version. */
export interface CliFeatureSet {
cliLogin: boolean;
proxyLogDirectory: boolean;
wildcardSSH: boolean;
Expand All @@ -13,8 +14,14 @@ export interface FeatureSet {
allowRedirects: boolean;
}

/** Capabilities keyed to the Coder server (REST API) version. */
export interface ServerFeatureSet {
tasks: boolean;
onSuccessBuild: boolean;
}

/**
* True when the CLI version is at least `minVersion`, or is a dev build.
* True when the version is at least `minVersion`, or is a dev build.
* Returns false for null (unknown) versions.
*/
function versionAtLeast(
Expand All @@ -27,20 +34,8 @@ function versionAtLeast(
return version.compare(minVersion) >= 0 || version.prerelease[0] === "devel";
}

/**
* True when the deployment predates the June 2026 Tasks deprecation
* (2.34 and below). The deprecated Tasks panel is hidden everywhere else.
*/
export function tasksSupported(version: semver.SemVer | null): boolean {
return version !== null && !versionAtLeast(version, "2.35.0");
}

/**
* Builds and returns a FeatureSet object for a given coder version.
*/
export function featureSetForVersion(
version: semver.SemVer | null,
): FeatureSet {
/** Capabilities of the given CLI version. */
export function cliFeatureSet(version: semver.SemVer | null): CliFeatureSet {
return {
// `coder login --use-token-as-session` to write a token (file or keyring).
// The extension relies on this, so 0.25.0 is the minimum supported version.
Expand All @@ -65,3 +60,16 @@ export function featureSetForVersion(
allowRedirects: versionAtLeast(version, "2.38.0"),
};
}

/** Capabilities of the given deployment version. */
export function serverFeatureSet(
version: semver.SemVer | null,
): ServerFeatureSet {
return {
// `/api/v2/tasks`, stable from 2.29 until the 2.35 deprecation
tasks:
versionAtLeast(version, "2.29.0") && !versionAtLeast(version, "2.35.0"),
// `on_success` on a stop build, which queues the start in one request
onSuccessBuild: versionAtLeast(version, "2.36.0"),
};
}
24 changes: 19 additions & 5 deletions src/instrumentation/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { TelemetryReporter } from "../telemetry/reporter";
import type { Span } from "../telemetry/span";

export type WorkspacePromptAction = "start" | "update";
export type WorkspaceUpdatePrompt = "parameters" | "confirmation";
export type WorkspaceUpdatePrompt = "parameters" | "confirmation" | "failure";

/**
* Emits `workspace.state_transitioned` for a detected workspace transition.
Expand Down Expand Up @@ -141,13 +141,27 @@ export class WorkspaceOperationTelemetry {
public traceConfirmationPrompt<T>(
fn: () => Promise<T | undefined>,
): Promise<T | undefined> {
return this.traceUpdatePrompt("confirmation", async (span) => {
return this.tracePromptChoice("confirmation", "update", fn);
}

/** Records whether the user connects to the existing version anyway. */
public traceFailurePrompt(fn: () => Promise<boolean>): Promise<boolean> {
return this.tracePromptChoice("failure", "connect", fn);
}

/** Emits the prompt with `action`, or marks it aborted on a falsy answer. */
private tracePromptChoice<T>(
prompt: WorkspaceUpdatePrompt,
action: string,
fn: () => Promise<T>,
): Promise<T> {
return this.traceUpdatePrompt(prompt, async (span) => {
const value = await fn();
if (value === undefined) {
if (!value) {
span.markAborted();
return undefined;
return value;
}
span.setProperty("action", "update");
span.setProperty("action", action);
return value;
});
}
Expand Down
Loading