From 8fc14bb248b2c144188fbab29ce5726b7df801ab Mon Sep 17 00:00:00 2001 From: Raj D <25481060+radroid@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:15:54 -0400 Subject: [PATCH 1/2] fix(server): treat slow `az --version` as present, not missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The source-control discovery probe hard-capped the CLI version probe at 5s and mapped every failure — including a timeout — to `status: "missing"`, which rendered the "install az" hint and skipped the fast auth check. On Windows `az --version` routinely takes 6-8s (Python startup + the Azure CLI "updates available" check), so an installed, authenticated Azure CLI was reported "not available". - Raise the discovery CLI probe timeout from 5s to 15s (CLI_PROBE_TIMEOUT_MS). - Branch probeCli's error handler on the cause tag: only VcsProcessSpawnError (ENOENT) => "missing" + install hint. A timeout (or other runtime failure) => "available", so probeSourceControlProvider still runs the fast `az account show` auth probe instead of short-circuiting. Adds SourceControlProviderDiscovery.test.ts covering: timeout => available + auth runs, spawn error => missing + auth skipped, the raised timeout, and timeout + unauthenticated. Fixes #4 Co-Authored-By: Claude Opus 4.8 --- .../SourceControlProviderDiscovery.test.ts | 174 ++++++++++++++++++ .../SourceControlProviderDiscovery.ts | 28 ++- 2 files changed, 194 insertions(+), 8 deletions(-) create mode 100644 apps/server/src/sourceControl/SourceControlProviderDiscovery.test.ts diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.test.ts new file mode 100644 index 00000000000..197a89fba01 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.test.ts @@ -0,0 +1,174 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { type VcsError, VcsProcessSpawnError, VcsProcessTimeoutError } from "@t3tools/contracts"; + +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as AzureDevOps from "./AzureDevOpsSourceControlProvider.ts"; +import { probeSourceControlProvider } from "./SourceControlProviderDiscovery.ts"; + +const processOutput = ( + stdout: string, + options?: { + readonly stderr?: string; + readonly exitCode?: ChildProcessSpawner.ExitCode; + }, +): VcsProcess.VcsProcessOutput => ({ + exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0), + stdout, + stderr: options?.stderr ?? "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +interface CapturingProcess { + readonly process: VcsProcess.VcsProcess["Service"]; + readonly calls: ReadonlyArray; +} + +const makeProcess = ( + run: (input: VcsProcess.VcsProcessInput) => Effect.Effect, +): CapturingProcess => { + const calls: VcsProcess.VcsProcessInput[] = []; + return { + calls, + process: { + run: (input) => { + calls.push(input); + return run(input); + }, + }, + }; +}; + +const isVersionCall = (input: VcsProcess.VcsProcessInput): boolean => input.args[0] === "--version"; +const isAuthCall = (input: VcsProcess.VcsProcessInput): boolean => input.args[0] === "account"; + +it.effect( + "reports Azure CLI as available and still runs the auth probe when `--version` times out", + () => + Effect.gen(function* () { + const { process, calls } = makeProcess((input) => { + if (isVersionCall(input)) { + return Effect.fail( + new VcsProcessTimeoutError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + timeoutMs: input.timeoutMs ?? 0, + }), + ); + } + return Effect.succeed(processOutput("azure-user@example.com\n")); + }); + + const item = yield* probeSourceControlProvider({ + spec: AzureDevOps.discovery, + process, + cwd: "/repo", + }); + + assert.strictEqual(item.status, "available"); + assert.strictEqual(item.auth.status, "authenticated"); + assert.deepStrictEqual(item.auth.account, Option.some("azure-user@example.com")); + assert.ok( + calls.some(isAuthCall), + "expected an `account show` auth probe to run after a slow `--version`", + ); + }), +); + +it.effect( + "reports Azure CLI as missing and skips the auth probe when `--version` cannot spawn", + () => + Effect.gen(function* () { + const { process, calls } = makeProcess((input) => { + if (isVersionCall(input)) { + return Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: Object.assign(new Error("spawn az ENOENT"), { code: "ENOENT" }), + }), + ); + } + return Effect.succeed(processOutput("azure-user@example.com\n")); + }); + + const item = yield* probeSourceControlProvider({ + spec: AzureDevOps.discovery, + process, + cwd: "/repo", + }); + + assert.strictEqual(item.status, "missing"); + assert.strictEqual(item.auth.status, "unknown"); + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0]?.args[0], "--version"); + }), +); + +it.effect( + "raises the `--version` probe timeout above 15s and reports the version on the happy path", + () => + Effect.gen(function* () { + const { process, calls } = makeProcess((input) => { + if (isVersionCall(input)) { + return Effect.succeed(processOutput("azure-cli 2.87.0\n")); + } + return Effect.succeed(processOutput("azure-user@example.com\n")); + }); + + const item = yield* probeSourceControlProvider({ + spec: AzureDevOps.discovery, + process, + cwd: "/repo", + }); + + const versionCall = calls.find(isVersionCall); + assert.ok(versionCall, "expected a `--version` probe to run"); + assert.ok( + (versionCall.timeoutMs ?? 0) >= 15_000, + `expected the \`--version\` probe timeout to be >= 15000ms, got ${String(versionCall.timeoutMs)}`, + ); + assert.deepStrictEqual(item.version, Option.some("azure-cli 2.87.0")); + assert.strictEqual(item.status, "available"); + assert.strictEqual(item.auth.status, "authenticated"); + }), +); + +it.effect( + "reports available but unauthenticated when `--version` times out and `account show` exits non-zero", + () => + Effect.gen(function* () { + const { process } = makeProcess((input) => { + if (isVersionCall(input)) { + return Effect.fail( + new VcsProcessTimeoutError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + timeoutMs: input.timeoutMs ?? 0, + }), + ); + } + return Effect.succeed( + processOutput("", { + stderr: "ERROR: Please run 'az login' to setup account.", + exitCode: ChildProcessSpawner.ExitCode(1), + }), + ); + }); + + const item = yield* probeSourceControlProvider({ + spec: AzureDevOps.discovery, + process, + cwd: "/repo", + }); + + assert.strictEqual(item.status, "available"); + assert.strictEqual(item.auth.status, "unauthenticated"); + }), +); diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index e3a6bd1fb20..751bc57f59d 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -10,6 +10,11 @@ import * as Option from "effect/Option"; import type * as SourceControlProvider from "./SourceControlProvider.ts"; import type * as VcsProcess from "../vcs/VcsProcess.ts"; +// `az --version` on Windows routinely takes 6-8s (Python startup + Azure CLI's +// "updates available" check), so the old 5s cap misclassified a present-but-slow +// CLI as missing. Raise the discovery probe budget well above that. (issue #4) +const CLI_PROBE_TIMEOUT_MS = 15_000; + export interface SourceControlAuthProbeInput { readonly stdout: string; readonly stderr: string; @@ -167,7 +172,7 @@ function probeCli(input: { command: input.spec.executable, args: input.spec.versionArgs, cwd: input.cwd, - timeoutMs: 5_000, + timeoutMs: CLI_PROBE_TIMEOUT_MS, maxOutputBytes: 8_000, appendTruncationMarker: true, }) @@ -186,17 +191,24 @@ function probeCli(input: { detail: Option.none(), }) satisfies DiscoveryProbeResult, ), - Effect.catch((cause) => - Effect.succeed({ + Effect.catch((cause) => { + // Only a genuine spawn failure (e.g. ENOENT) means the CLI is absent, so + // that is the sole case that reports "missing" and shows the install hint. + // Any other failure (timeout, non-zero exit, etc.) means the CLI is present + // but the version probe was slow/noisy; keep it "available" so + // `probeSourceControlProvider` still runs the fast `account show` auth + // probe instead of short-circuiting to the install hint. (issue #4) + const status = cause._tag === "VcsProcessSpawnError" ? "missing" : "available"; + return Effect.succeed({ kind: input.spec.kind, label: input.spec.label, executable: input.spec.executable, - status: "missing" as const, + status, version: Option.none(), installHint: input.spec.installHint, detail: detailFromCause(cause), - } satisfies DiscoveryProbeResult), - ), + } satisfies DiscoveryProbeResult); + }), ); } @@ -244,7 +256,7 @@ export function probeSourceControlProvider(input: { args: spec.authArgs, cwd: input.cwd, allowNonZeroExit: true, - timeoutMs: 5_000, + timeoutMs: CLI_PROBE_TIMEOUT_MS, maxOutputBytes: 8_000, appendTruncationMarker: true, }) @@ -287,7 +299,7 @@ export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvide args: spec.authArgs, cwd: input.cwd, allowNonZeroExit: true, - timeoutMs: 5_000, + timeoutMs: CLI_PROBE_TIMEOUT_MS, maxOutputBytes: 8_000, appendTruncationMarker: true, }) From d8987faad5d8d6b4d74256f0bf0ff651cf189bd5 Mon Sep 17 00:00:00 2001 From: Raj D <25481060+radroid@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:26:23 -0400 Subject: [PATCH 2/2] test(server): cover non-spawn `--version` failure mapping to available Address review nit: the "all non-spawn errors => available" branch was only exercised via VcsProcessTimeoutError. Add a case where `--version` fails with a non-zero exit (VcsProcessExitError) and assert the provider stays available and the auth probe still runs. Co-Authored-By: Claude Opus 4.8 --- .../SourceControlProviderDiscovery.test.ts | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.test.ts index 197a89fba01..4c57b3af3c8 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.test.ts @@ -2,7 +2,12 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { type VcsError, VcsProcessSpawnError, VcsProcessTimeoutError } from "@t3tools/contracts"; +import { + type VcsError, + VcsProcessExitError, + VcsProcessSpawnError, + VcsProcessTimeoutError, +} from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOps from "./AzureDevOpsSourceControlProvider.ts"; @@ -172,3 +177,40 @@ it.effect( assert.strictEqual(item.auth.status, "unauthenticated"); }), ); + +it.effect( + "treats a non-spawn `--version` failure (e.g. a non-zero exit) as present and still runs the auth probe", + () => + Effect.gen(function* () { + const { process, calls } = makeProcess((input) => { + if (isVersionCall(input)) { + return Effect.fail( + new VcsProcessExitError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + exitCode: 1, + detail: "Process exited with a non-zero status.", + }), + ); + } + return Effect.succeed(processOutput("azure-user@example.com\n")); + }); + + const item = yield* probeSourceControlProvider({ + spec: AzureDevOps.discovery, + process, + cwd: "/repo", + }); + + // Only a genuine spawn failure means "missing"; any other runtime failure of + // `--version` (here a non-zero exit) means the CLI is present, so it stays + // available and the auth probe still runs. (issue #4) + assert.strictEqual(item.status, "available"); + assert.strictEqual(item.auth.status, "authenticated"); + assert.ok( + calls.some(isAuthCall), + "expected the auth probe to run after a non-spawn `--version` failure", + ); + }), +);