From 5e67299046dfab1ea30148396ab4ab40c8d1fadc Mon Sep 17 00:00:00 2001 From: "@rugpanov" Date: Wed, 9 Sep 2026 18:52:38 +0200 Subject: [PATCH] feat(python-setup): recover from a constraint conflict with a Retry-as-DB-Connect fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit *Why* The uv-native "set up Python environment" Full preset pins the cluster/serverless runtime's dependencies, so `uv sync` can fail when those pins conflict with the user's own. The CLI now emits a distinct E_PROVISION_CONFLICT for that case (shipped in CLI v1.16.0). Rather than the generic "adjust your dependencies" message, offer a one-click recovery: re-run as DB Connect (which drops the pins). *What* - E_PROVISION_CONFLICT error code + actionable copy; a genuine pin-vs-local conflict, distinct from the generic E_PROVISION. - Recovery toast (Full preset only): "Retry DB Connect setup" + "Open pyproject.toml"; "Show Logs" dropped from this self-service toast. - Retry restores the CLI's pre-merge pyproject.toml backup (result.backupPath) before re-running with --no-constraints — the pins are merged to disk before provision fails, so --no-constraints alone would leave them in place. Restore is atomic (temp + rename), refuses a backupPath outside the project, and a truthy-backupPath guard falls through to the generic path when absent (e.g. a no-op-merge re-run). - Guarded against loops (dbconnect skips constraints), stale-toast re-provision, and concurrent runs (coalesces onto an in-flight run). - Telemetry: a conflict_retry value on the setup.attempt trigger dimension, paired with the result outcome for the recovery success rate. *Verification* - Unit tests for the conflict copy, run-action dispatch, restore ordering, --no-constraints + adopt, telemetry pairing, no-loop, coalescing, stale/empty/ missing-backupPath fallbacks, and atomic/in-project restore wiring. - Recreated cleanly against main (supersedes #2181, which merged into the preset-picker branch, not main); tsc + targeted unit tests green. Co-authored-by: Isaac --- .../src/python-setup/README.md | 3 + .../PythonSetupEnvironmentSetup.test.ts | 540 ++++++++++++++++++ .../PythonSetupEnvironmentSetup.ts | 195 ++++++- .../controllers/pythonSetupDeps.test.ts | 212 +++++++ .../controllers/pythonSetupDeps.ts | 63 +- .../python-setup/models/PythonSetupResult.ts | 9 + .../python-setup/utils/errorMessages.test.ts | 38 ++ .../src/python-setup/utils/errorMessages.ts | 54 +- .../src/telemetry/constants.ts | 12 +- .../src/telemetry/pythonSetupExtensions.ts | 8 +- 10 files changed, 1083 insertions(+), 51 deletions(-) diff --git a/packages/databricks-vscode/src/python-setup/README.md b/packages/databricks-vscode/src/python-setup/README.md index c24b48e72..3f349c10b 100644 --- a/packages/databricks-vscode/src/python-setup/README.md +++ b/packages/databricks-vscode/src/python-setup/README.md @@ -19,6 +19,9 @@ the mapped message alone (see `reportSetupIssue.ts` for the closed routing list) such a conflict is usually the user's own declared dependencies. When the published constraints are what conflict, that genuine case is served by a soft, conditional pointer in the output log instead (see `formatSetupFailureDetail`). +`E_PROVISION_CONFLICT` (the CLI's distinct pins-vs-local code) follows the same +policy; the Full-preset flow additionally offers a restore-and-retry recovery +(see `buildConflictRecoveryActions`). **Privacy posture.** The issue body carries build metadata (error code, phase, env key, package manager, extension/CLI versions, OS) plus the CLI's stderr. The diff --git a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.test.ts b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.test.ts index b2c9088b8..f7913796a 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.test.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.test.ts @@ -124,6 +124,8 @@ function makeDeps( compute: {kind: "serverless", version: "5"}, }), adoptInterpreter: async () => {}, + openProjectFile: async () => {}, + restoreProjectFile: async () => {}, saveState: () => {}, notify: async () => {}, showReauthPrompt: async () => {}, @@ -1695,3 +1697,541 @@ describe("PythonSetupEnvironmentSetup.setup preset selection", () => { expect(telemetry.attempts[0].mode).to.equal("constraints-only"); }); }); + +describe("PythonSetupEnvironmentSetup constraint-conflict recovery", () => { + /** + * A Full-preset run whose pinned cluster dependencies conflict with the + * user's local ones: the CLI's distinct E_PROVISION_CONFLICT. Constraints are + * merged before the provision fails, so disk is mutated and a backup exists. + */ + function conflictResult(): PythonSetupResult { + return { + schemaVersion: 1, + command: "environments setup-local", + ok: false, + mode: "default", + dryRun: false, + greenfield: false, + backupPath: "/proj/pyproject.toml.bak", + phases: [ + {phase: "preflight", status: "ok"}, + {phase: "resolve", status: "ok"}, + {phase: "fetch", status: "ok"}, + {phase: "merge", status: "ok"}, + {phase: "provision", status: "error"}, + {phase: "validate", status: "pending"}, + ], + warnings: [], + durationMs: 0, + error: { + code: "E_PROVISION_CONFLICT", + failurePhase: "provision", + message: + "error: No solution found when resolving dependencies: the " + + "runtime requires pyarrow<19 but your project requires " + + "pyarrow==21.0.0", + diskMutated: true, + }, + }; + } + + /** A CLI that returns a scripted result per call, in order. */ + function makeScriptedCli(results: PythonSetupResult[]) { + const calls: SetupLocalInvocation[] = []; + let i = 0; + return { + calls, + run: async (invocation: SetupLocalInvocation) => { + calls.push(invocation); + return results[Math.min(i++, results.length - 1)]; + }, + }; + } + + it("shows the conflict copy with Retry and Open pyproject actions on a Full-preset conflict", async () => { + const shown: {message: string; actions?: PythonSetupErrorAction[]}[] = + []; + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + cli: makeCli({resolve: conflictResult()}), + pickSetupPreset: async () => "full", + showError: async (message, _detail, actions) => { + shown.push({message, actions}); + }, + }) + ); + + await setup.setup(); + + expect(shown).to.have.length(1); + expect(shown[0].message).to.match(/cluster dependencies conflict/i); + // The two recovery buttons, in order; both are in-process run-actions. + expect(shown[0].actions?.map((a) => a.label)).to.deep.equal([ + "Retry DB Connect setup", + "Open pyproject.toml", + ]); + for (const action of shown[0].actions ?? []) { + expect(action).to.have.property("run"); + } + }); + + it("hides the Show Logs button on the conflict toast (self-service)", async () => { + const seen: Array<{includeShowLogs?: boolean} | undefined> = []; + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + cli: makeCli({resolve: conflictResult()}), + pickSetupPreset: async () => "full", + showError: async (_message, _detail, _actions, options) => { + seen.push(options); + }, + }) + ); + + await setup.setup(); + + expect(seen).to.have.length(1); + expect(seen[0]).to.deep.equal({includeShowLogs: false}); + }); + + it("keeps the Show Logs button for an ordinary (non-conflict) failure", async () => { + const seen: Array<{includeShowLogs?: boolean} | undefined> = []; + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + cli: makeCli({resolve: ERROR_NO_TARGET}), + showError: async (_message, _detail, _actions, options) => { + seen.push(options); + }, + }) + ); + + await setup.setup(); + + // No opt-out: showError keeps its default trailing Show Logs button. + expect(seen[0]).to.equal(undefined); + }); + + it("re-runs with --no-constraints (and adopts) when Retry DB Connect setup is picked", async () => { + const cli = makeScriptedCli([conflictResult(), SUCCESS_REAL_RUN]); + const adopted: string[] = []; + let retryAction: PythonSetupErrorAction | undefined; + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + cli, + pickSetupPreset: async () => "full", + adoptInterpreter: async (venvPath) => { + adopted.push(venvPath); + }, + showError: async (_message, _detail, actions) => { + retryAction = actions?.find( + (a) => a.label === "Retry DB Connect setup" + ); + }, + }) + ); + + await setup.setup(); + // The initial Full run failed; the user clicks Retry (a deferred click, + // after the original run settled). + expect(setup.ready).to.equal(false); + expect(retryAction).to.not.equal(undefined); + await (retryAction as {run: () => Promise}).run(); + + // Two runs: the Full attempt, then the DB Connect retry (restore + --no-constraints). + expect(cli.calls).to.have.length(2); + expect(cli.calls[0].skipConstraints).to.equal(undefined); + expect(cli.calls[1].skipConstraints).to.equal(true); + expect(cli.calls[1].skipDbconnect).to.equal(undefined); + // The retry succeeded, so the interpreter is adopted and the project is ready. + expect(adopted).to.deep.equal([SUCCESS_REAL_RUN.venvPath]); + expect(setup.ready).to.equal(true); + }); + + it("records the retry as a dbconnect run with the conflict_retry trigger", async () => { + const cli = makeScriptedCli([conflictResult(), SUCCESS_REAL_RUN]); + const telemetry = makeTelemetryRecorder(); + let retryAction: PythonSetupErrorAction | undefined; + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + cli, + ...telemetry, + pickSetupPreset: async () => "full", + showError: async (_message, _detail, actions) => { + retryAction = actions?.find( + (a) => a.label === "Retry DB Connect setup" + ); + }, + }) + ); + + await setup.setup(); + await (retryAction as {run: () => Promise}).run(); + + // The initial Full attempt is a first-time setup; the retry is labeled + // distinctly so a conflict recovery can be counted separately from a + // first-time DB Connect pick (both would otherwise be initial/dbconnect). + expect( + telemetry.attempts.map((a) => ({ + preset: a.setupPreset, + trigger: a.trigger, + })) + ).to.deep.equal([ + {preset: "full", trigger: "initial"}, + {preset: "dbconnect", trigger: "conflict_retry"}, + ]); + // The paired result carries the retry's outcome — success here — so + // (b)/(a) is derivable from the same attempt→result pairing. + expect(telemetry.results).to.have.length(2); + expect(telemetry.results[0].outcome).to.equal("failed"); + expect(telemetry.results[1].outcome).to.equal("ok"); + }); + + it("opens pyproject.toml (at the run's cwd) when Open pyproject.toml is picked", async () => { + const openedRoots: string[] = []; + let openAction: PythonSetupErrorAction | undefined; + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + cli: makeCli({resolve: conflictResult()}), + projectRoot: () => "/proj", + pickSetupPreset: async () => "full", + openProjectFile: async (root) => { + openedRoots.push(root); + }, + showError: async (_message, _detail, actions) => { + openAction = actions?.find( + (a) => a.label === "Open pyproject.toml" + ); + }, + }) + ); + + await setup.setup(); + await (openAction as {run: () => Promise}).run(); + + expect(openedRoots).to.deep.equal(["/proj"]); + }); + + it("records the conflict failure without offering a report", async () => { + const telemetry = makeTelemetryRecorder(); + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + ...telemetry, + cli: makeCli({resolve: conflictResult()}), + pickSetupPreset: async () => "full", + }) + ); + + await setup.setup(); + + expect(telemetry.results[0]).to.include({ + outcome: "failed", + errorCode: "E_PROVISION_CONFLICT", + failurePhase: "provision", + // A conflict is the user's own deps → never report-worthy. + reportOffered: false, + }); + }); + + it("falls back to the generic action when a conflict arrives on a run that already dropped the pins", async () => { + // Defensive: only Full pins cluster deps, so a conflict on a dbconnect + // run should never happen — but if it does, "Retry DB Connect setup" would + // be nonsensical (and loop), so use the ordinary doc-link handling. + const shown: {actions?: PythonSetupErrorAction[]}[] = []; + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + cli: makeCli({resolve: conflictResult()}), + pickSetupPreset: async () => "dbconnect", + showError: async (_message, _detail, actions) => { + shown.push({actions}); + }, + }) + ); + + await setup.setup(); + + expect(shown[0].actions).to.deep.equal([ + { + label: "Resolve dependency conflicts", + url: "https://docs.astral.sh/uv/concepts/resolution/", + }, + ]); + }); + + it("does not loop: a DB Connect retry that also fails gets the generic action, not another Retry", async () => { + // The retry runs with --no-constraints, so even if it somehow returns + // E_PROVISION_CONFLICT again the !skipConstraints gate makes it + // non-recoverable — the generic doc-link handling, never a second + // "Retry DB Connect setup" (which would loop). + const cli = makeScriptedCli([conflictResult(), conflictResult()]); + const shown: {actions?: PythonSetupErrorAction[]}[] = []; + let retryAction: PythonSetupErrorAction | undefined; + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + cli, + pickSetupPreset: async () => "full", + showError: async (_message, _detail, actions) => { + shown.push({actions}); + retryAction = actions?.find( + (a) => a.label === "Retry DB Connect setup" + ); + }, + }) + ); + + await setup.setup(); + expect(retryAction).to.not.equal(undefined); + await (retryAction as {run: () => Promise}).run(); + + expect(cli.calls).to.have.length(2); + expect(cli.calls[1].skipConstraints).to.equal(true); + // The retry's failure toast carries only the generic doc link — no + // recovery button, so there is no way to loop. + expect(shown).to.have.length(2); + expect(shown[1].actions).to.deep.equal([ + { + label: "Resolve dependency conflicts", + url: "https://docs.astral.sh/uv/concepts/resolution/", + }, + ]); + }); + + it("coalesces a Retry click onto an in-flight run instead of starting a concurrent one", async () => { + // The retry goes through the same re-entrancy guard as a fresh setup: + // if another run is already in flight when the (still-open) conflict + // toast's Retry is clicked, it must coalesce onto that run rather than + // spawn a second, concurrent project-mutating CLI process — and, since a + // queued dbconnect retry could otherwise clobber a run that is about to + // succeed, coalescing (not queuing) is the intended, safe behavior. + let releaseSecond: (r: PythonSetupResult) => void = () => {}; + const secondGate = new Promise((res) => { + releaseSecond = res; + }); + const calls: SetupLocalInvocation[] = []; + let call = 0; + const cli = { + calls, + run: (invocation: SetupLocalInvocation) => { + calls.push(invocation); + return call++ === 0 + ? Promise.resolve(conflictResult()) + : secondGate; + }, + }; + let retryAction: PythonSetupErrorAction | undefined; + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + cli, + pickSetupPreset: async () => "full", + showError: async (_message, _detail, actions) => { + retryAction = actions?.find( + (a) => a.label === "Retry DB Connect setup" + ); + }, + }) + ); + + // First Full run fails with a conflict; the guard is now released. + await setup.setup(); + expect(calls).to.have.length(1); + expect(retryAction).to.not.equal(undefined); + + // A second setup starts and is left in flight (its guard is held). + const second = setup.setup(); + // Clicking the stale Retry now must coalesce onto the in-flight run. + const retryPromise = (retryAction as {run: () => Promise}).run(); + + releaseSecond(SUCCESS_REAL_RUN); + await Promise.all([second, retryPromise]); + + // Two runs total (the Full conflict and the second run) — the Retry did + // not spawn a third, concurrent CLI process. + expect(calls).to.have.length(2); + }); + + it("does not re-provision when the project became ready before a stale Retry is clicked", async () => { + // The conflict toast lingers in the Notifications Center, so its Retry + // can be clicked long after a separate run has since set the project up. + // Re-provisioning as DB Connect then would silently downgrade the + // working environment, so a stale Retry must no-op once ready. + const cli = makeScriptedCli([conflictResult(), SUCCESS_REAL_RUN]); + let retryAction: PythonSetupErrorAction | undefined; + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + cli, + pickSetupPreset: async () => "full", + showError: async (_message, _detail, actions) => { + retryAction = actions?.find( + (a) => a.label === "Retry DB Connect setup" + ); + }, + }) + ); + + // Full run conflicts (retry captured, project not ready)… + await setup.setup(); + expect(setup.ready).to.equal(false); + // …then a later run succeeds and marks the project ready. + await setup.setup(); + expect(setup.ready).to.equal(true); + expect(cli.calls).to.have.length(2); + + // Clicking the now-stale Retry must not re-run the CLI. + await (retryAction as {run: () => Promise}).run(); + expect(cli.calls).to.have.length(2); + expect(setup.ready).to.equal(true); + }); + + it("restores the CLI's backup before the DB Connect retry re-runs", async () => { + // The conflict fails after the pins were merged into pyproject.toml, so + // the retry must roll the file back to the CLI's backup *before* + // re-running with --no-constraints (which alone would leave the + // already-written conflicting pins in place). + const scripted = makeScriptedCli([conflictResult(), SUCCESS_REAL_RUN]); + const events: string[] = []; + const restored: Array<{root: string; backupPath: string}> = []; + // A CliRunner wrapper that records the run order (assigned to a variable + // so the extra fields aren't excess-property-checked as a literal would). + const cli = { + run: async (invocation: SetupLocalInvocation) => { + events.push("cli"); + return scripted.run(invocation); + }, + }; + let retryAction: PythonSetupErrorAction | undefined; + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + cli, + pickSetupPreset: async () => "full", + restoreProjectFile: async (root, backupPath) => { + events.push("restore"); + restored.push({root, backupPath}); + }, + showError: async (_message, _detail, actions) => { + retryAction = actions?.find( + (a) => a.label === "Retry DB Connect setup" + ); + }, + }) + ); + + await setup.setup(); + await (retryAction as {run: () => Promise}).run(); + + // The restore targets the run's cwd with the CLI-reported backup path… + expect(restored).to.deep.equal([ + {root: "/proj", backupPath: "/proj/pyproject.toml.bak"}, + ]); + // …and it happens between the failed Full run and the DB Connect retry. + expect(events).to.deep.equal(["cli", "restore", "cli"]); + }); + + it("does not run the CLI or record a retry attempt when the restore fails", async () => { + // A failed restore leaves the conflicting pins on disk, so re-running + // would just conflict again: abort before spawning the CLI or recording + // a second attempt, and let showError's action-error handling log it. + const cli = makeScriptedCli([conflictResult(), SUCCESS_REAL_RUN]); + const telemetry = makeTelemetryRecorder(); + let retryAction: PythonSetupErrorAction | undefined; + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + cli, + ...telemetry, + pickSetupPreset: async () => "full", + restoreProjectFile: async () => { + throw new Error("copy failed"); + }, + showError: async (_message, _detail, actions) => { + retryAction = actions?.find( + (a) => a.label === "Retry DB Connect setup" + ); + }, + }) + ); + + await setup.setup(); + expect(retryAction).to.not.equal(undefined); + + // The run-action rejects so showError (its only production caller) can + // log the failure; here we assert only that nothing was launched. + let threw = false; + try { + await (retryAction as {run: () => Promise}).run(); + } catch { + threw = true; + } + expect(threw).to.equal(true); + + // Only the initial Full attempt happened: no DB Connect retry ran, and + // no second attempt/result was recorded. + expect(cli.calls).to.have.length(1); + expect(telemetry.attempts).to.have.length(1); + expect(telemetry.results).to.have.length(1); + expect(setup.ready).to.equal(false); + }); + + it("does not offer Retry when the conflict result has no backup to restore", async () => { + // Without a backupPath there is nothing to roll the merged pins back to, + // so the restore-then-retry recovery cannot run — fall through to the + // ordinary doc-link handling instead of offering a Retry that would + // re-run against the still-conflicting file. + const noBackup: PythonSetupResult = { + ...conflictResult(), + backupPath: undefined, + }; + const shown: {actions?: PythonSetupErrorAction[]}[] = []; + const seenOptions: Array<{includeShowLogs?: boolean} | undefined> = []; + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + cli: makeCli({resolve: noBackup}), + pickSetupPreset: async () => "full", + showError: async (_message, _detail, actions, options) => { + shown.push({actions}); + seenOptions.push(options); + }, + }) + ); + + await setup.setup(); + + expect(shown[0].actions).to.deep.equal([ + { + label: "Resolve dependency conflicts", + url: "https://docs.astral.sh/uv/concepts/resolution/", + }, + ]); + // Not a self-service toast without the Retry button, so Show Logs stays. + expect(seenOptions[0]).to.equal(undefined); + }); + + it("does not offer Retry when backupPath is an empty string", async () => { + // The CLI marks backupPath omitempty, so "" shouldn't occur — but if it + // did, offering Retry would hand the user a button that can only throw + // (restore rejects an empty path) while hiding Show Logs. Treat empty as + // "no backup": fall through to the ordinary doc-link handling. + const emptyBackup: PythonSetupResult = { + ...conflictResult(), + backupPath: "", + }; + const shown: {actions?: PythonSetupErrorAction[]}[] = []; + const seenOptions: Array<{includeShowLogs?: boolean} | undefined> = []; + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + cli: makeCli({resolve: emptyBackup}), + pickSetupPreset: async () => "full", + showError: async (_message, _detail, actions, options) => { + shown.push({actions}); + seenOptions.push(options); + }, + }) + ); + + await setup.setup(); + + expect(shown[0].actions).to.deep.equal([ + { + label: "Resolve dependency conflicts", + url: "https://docs.astral.sh/uv/concepts/resolution/", + }, + ]); + expect(seenOptions[0]).to.equal(undefined); + }); +}); diff --git a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts index 2ff4f21b9..cfe2f6063 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts @@ -34,6 +34,7 @@ import { PythonSetupAttempt, PythonSetupResultReporter, } from "../../telemetry/pythonSetupExtensions"; +import type {PythonSetupRunTrigger} from "../../telemetry/constants"; import {PrimaryManager} from "../../language/packageManagerDetection"; import { isUvSetupSuitable, @@ -144,6 +145,26 @@ export interface PythonSetupSetupDeps { */ adoptInterpreter: (venvPath: string, projectRoot: string) => Promise; + /** + * Open the project's pyproject.toml in an editor — the "Open pyproject.toml" + * button on a constraint-conflict failure, so the user can inspect and + * adjust the dependencies that clashed. Takes the run's captured root so it + * targets the project the failing run mutated, even after a mid-run switch. + */ + openProjectFile: (projectRoot: string) => Promise; + + /** + * Restore `/pyproject.toml` from the CLI's pre-merge + * `backupPath` (see {@link PythonSetupResult.backupPath}) by copying it over + * the file. First step of the "Retry DB Connect setup" recovery, whose + * rationale lives on {@link buildConflictRecoveryActions}; a rejection there + * aborts the retry before any CLI run. + */ + restoreProjectFile: ( + projectRoot: string, + backupPath: string + ) => Promise; + saveState: (state: PythonSetupPersistedState) => void; /** @@ -171,11 +192,17 @@ export interface PythonSetupSetupDeps { * each opens an external URL or runs a VS Code command. Most failures carry * one; `E_UV_MISSING` carries two ("Install uv" + "Installation guide", see * `getPythonSetupErrorActions`). + * + * `options.includeShowLogs` defaults to true; pass `false` to omit the + * trailing "Show Logs" button — for a self-service toast whose own buttons + * are the remedy (the recoverable constraint conflict), so the row stays + * short. The channel is still written and revealed, so the log is reachable. */ showError: ( message: string, detail?: string, - actions?: PythonSetupErrorAction[] + actions?: PythonSetupErrorAction[], + options?: {includeShowLogs?: boolean} ) => Promise; showSuccess: (result: PythonSetupResult) => Promise; @@ -305,19 +332,27 @@ export class PythonSetupEnvironmentSetup implements Disposable { } setup(): Promise { - // Re-entrancy guard: coalesce concurrent callers onto the running run - // rather than spawning a second project-mutating CLI process. The guard - // releases when the run's *work* settles; the terminal notification is - // presented via {@link present} (fire-and-forget), so a toast left open - // never wedges the entry -- see that method. + return this.runGuarded(() => this.runSetup()); + } + + /** + * Re-entrancy guard: coalesce concurrent callers onto the running run rather + * than spawning a second project-mutating CLI process. The guard releases + * when the run's *work* settles; the terminal notification is presented via + * {@link present} (fire-and-forget), so a toast left open never wedges the + * entry -- see that method. Used both for a fresh {@link setup} and for the + * constraint-conflict retry, so a retry click cannot race a run already in + * flight. + */ + private runGuarded(run: () => Promise): Promise { if (this.inFlight) { return this.inFlight; } - const run = this.runSetup().finally(() => { + const guarded = run().finally(() => { this.inFlight = undefined; }); - this.inFlight = run; - return run; + this.inFlight = guarded; + return guarded; } /** @@ -344,8 +379,7 @@ export class PythonSetupEnvironmentSetup implements Disposable { } private async runSetup(): Promise { - const {cli, projectRoot, isVisible, resolveCompute, withProgress} = - this.deps; + const {projectRoot, isVisible, resolveCompute} = this.deps; const cwd = projectRoot(); if (cwd === undefined) { @@ -389,6 +423,29 @@ export class PythonSetupEnvironmentSetup implements Disposable { return; } + await this.runResolved(compute, cwd, preset); + } + + /** + * Run a resolved invocation (compute + preset) to completion: record the + * attempt, spawn the CLI under a progress indicator, then adopt the + * interpreter and persist state on success — or surface a mapped error on + * failure. Split out from {@link runSetup} so the constraint-conflict + * "Retry DB Connect setup" recovery can re-enter it with the `dbconnect` preset + * directly, without re-prompting the compute or the preset picker. + * + * `trigger` overrides how the attempt is labeled: the retry passes + * `conflict_retry`; the normal path leaves it undefined so + * {@link recordAttempt} derives `initial` / `rerun` from readiness. + */ + private async runResolved( + compute: SetupCompute, + cwd: string, + preset: SetupPreset, + trigger?: PythonSetupRunTrigger + ): Promise { + const {cli, withProgress} = this.deps; + const invocation: SetupLocalInvocation = { compute, ...presetToFlags(preset), @@ -401,7 +458,8 @@ export class PythonSetupEnvironmentSetup implements Disposable { const {reportResult, packageManager} = await this.recordAttempt( invocation, cwd, - preset + preset, + trigger ); // Per-run report context: the static build info plus this run's manager. const reportEnv: ReportEnvironment = { @@ -469,14 +527,36 @@ export class PythonSetupEnvironmentSetup implements Disposable { result.pythonResolution === "installed_fallback" ? "manual_selection_requested" : result.pythonResolution; - const actions = remediationActions.some( - (candidate) => - candidate.command === SELECT_PYTHON_INTERPRETER_COMMAND_ID - ) - ? remediationActions - : reportAction - ? [reportAction] - : remediationActions; + // A constraint conflict is recoverable only when this run carried the + // pins (Full preset) AND the CLI saved a pre-merge backup to roll them + // back to. Without either, fall through to the ordinary doc-link + // handling rather than offer a "retry as DB Connect" that can't work + // (nothing to restore) or would loop (a run that already skipped pins). + const conflictBackupPath = + result.error?.code === "E_PROVISION_CONFLICT" && + !invocation.skipConstraints && + // Truthiness, not just `!== undefined`: a (contract-forbidden) + // empty backupPath has nothing to restore, so it must fall + // through rather than offer a Retry that could only throw. + result.backupPath + ? result.backupPath + : undefined; + const recoverableConflict = conflictBackupPath !== undefined; + const actions = recoverableConflict + ? this.buildConflictRecoveryActions( + compute, + cwd, + conflictBackupPath + ) + : remediationActions.some( + (candidate) => + candidate.command === + SELECT_PYTHON_INTERPRETER_COMMAND_ID + ) + ? remediationActions + : reportAction + ? [reportAction] + : remediationActions; reportResult({ outcome: "failed", ...(pythonSetupFlow !== undefined ? {pythonSetupFlow} : {}), @@ -495,7 +575,12 @@ export class PythonSetupEnvironmentSetup implements Disposable { result, reportRepo ? reportLogLink(reportRepo) : undefined ), - actions + actions, + // The recoverable conflict is self-service via its Retry / + // Open buttons, so drop the trailing "Show Logs" to keep the + // notification's button row short (the channel is revealed + // regardless). + recoverableConflict ? {includeShowLogs: false} : undefined ) ); return; @@ -601,6 +686,56 @@ export class PythonSetupEnvironmentSetup implements Disposable { this.present(this.deps.showSuccess(result)); } + /** + * The two recovery buttons for a Full-preset constraint conflict, both + * run-actions (their behavior needs the run's live compute/cwd): + * + * - "Retry DB Connect setup" restores pyproject.toml from the CLI's pre-merge + * `backupPath`, then re-runs as DB Connect (`--no-constraints`). The restore + * is load-bearing: the conflict fails *after* the pins were merged to disk, + * so `--no-constraints` alone would leave the conflicting pins in place and + * only skip re-adding them. It runs through {@link runGuarded} — so a click + * can't race an in-flight run, and the restore only fires when the retry + * actually runs — and no-ops once the project is ready, so a stale toast's + * Retry can't downgrade an environment a later run provisioned. A failed + * restore throws before the CLI spawns, leaving showError to log it. + * - "Open pyproject.toml" opens the merged file so the user can adjust the + * dependencies that clashed. + */ + private buildConflictRecoveryActions( + compute: SetupCompute, + cwd: string, + backupPath: string + ): PythonSetupErrorAction[] { + return [ + { + label: "Retry DB Connect setup", + run: () => { + // A stale Retry (project provisioned by a later run since the + // conflict) must not re-provision and downgrade it. + if (this.readyRoots.has(cwd)) { + return; + } + return this.runGuarded(async () => { + // Restore before re-running; a failure throws here, before + // any attempt is recorded or the CLI spawns. + await this.deps.restoreProjectFile(cwd, backupPath); + await this.runResolved( + compute, + cwd, + "dbconnect", + "conflict_retry" + ); + }); + }, + }, + { + label: "Open pyproject.toml", + run: () => this.deps.openProjectFile(cwd), + }, + ]; + } + /** * Emit the attempt event for a run that is about to start and return its * outcome reporter. @@ -617,7 +752,8 @@ export class PythonSetupEnvironmentSetup implements Disposable { private async recordAttempt( invocation: SetupLocalInvocation, projectRoot: string, - setupPreset: SetupPreset + setupPreset: SetupPreset, + trigger?: PythonSetupRunTrigger ): Promise<{ reportResult: PythonSetupResultReporter; packageManager: PrimaryManager; @@ -665,11 +801,16 @@ export class PythonSetupEnvironmentSetup implements Disposable { mode: invocation.skipDbconnect ? "constraints-only" : "default", setupPreset, isGreenfield, - // A run against a project already marked ready this session is a - // re-run (the ready row's Re-run button / row click); anything - // else is the first setup. Derived from state, not the command, - // so every entry point labels the same event correctly. - trigger: this.readyRoots.has(projectRoot) ? "rerun" : "initial", + // An explicit trigger wins (the constraint-conflict retry passes + // `conflict_retry`, so its recovery clicks are countable and not + // conflated with a first-time DB Connect pick). Otherwise it is + // derived from state: a run against a project already marked ready + // this session is a re-run (the ready row's Re-run button / row + // click), anything else the first setup. Derived from state, not + // the command, so every entry point labels the same event. + trigger: + trigger ?? + (this.readyRoots.has(projectRoot) ? "rerun" : "initial"), }); return { packageManager, diff --git a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts index 2be23a135..79e316e5c 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts @@ -1,4 +1,7 @@ import {expect} from "chai"; +import {mkdtemp, readdir, readFile, rm, writeFile} from "fs/promises"; +import {tmpdir} from "os"; +import path from "path"; import {commands, env, QuickPick, QuickPickItem, Uri, window} from "vscode"; import { makePythonSetupDeps, @@ -919,6 +922,215 @@ describe("makePythonSetupDeps showError", () => { originalOpen; } }); + + it("runs a run-action's callback when its button is picked", async () => { + // A run-action carries an in-process closure (the constraint-conflict + // "Retry DB Connect setup" / "Open pyproject.toml" buttons need runtime + // state, so they can't be a static url/command). + const deps = makePythonSetupDeps( + makeWiring({log: {append: () => {}, show: () => {}}}) + ); + let ran = 0; + reply = "Retry DB Connect setup"; + + await deps.showError("conflict copy", "detail", [ + {label: "Retry DB Connect setup", run: async () => void ran++}, + ]); + + expect(ran).to.equal(1); + }); + + it("omits the Show Logs button when includeShowLogs is false", async () => { + // A self-service toast (the recoverable constraint conflict) drops the + // trailing Show Logs so its own action buttons fit; the channel is still + // revealed, so the log stays reachable. + const deps = makePythonSetupDeps( + makeWiring({log: {append: () => {}, show: () => {}}}) + ); + reply = undefined; + + await deps.showError( + "conflict copy", + "detail", + [ + {label: "Retry DB Connect setup", run: async () => {}}, + {label: "Open pyproject.toml", run: async () => {}}, + ], + {includeShowLogs: false} + ); + + expect(shownWith[0].actions).to.deep.equal([ + "Retry DB Connect setup", + "Open pyproject.toml", + ]); + expect(shownWith[0].actions).to.not.contain("Show Logs"); + }); + + it("does not run a run-action's callback when its button is not picked", async () => { + const deps = makePythonSetupDeps( + makeWiring({log: {append: () => {}, show: () => {}}}) + ); + let ran = 0; + reply = "Show Logs"; + + await deps.showError("conflict copy", "detail", [ + {label: "Retry DB Connect setup", run: async () => void ran++}, + ]); + + expect(ran).to.equal(0); + }); + + it("does not reject when a run-action's callback throws", async () => { + // showError is the failure-reporting path and its one caller does not + // wrap it, so a throwing callback must be contained (logged), not escape. + const appended: string[] = []; + const deps = makePythonSetupDeps( + makeWiring({ + log: {append: (c) => appended.push(c), show: () => {}}, + }) + ); + reply = "Retry DB Connect setup"; + + await deps.showError("conflict copy", "detail", [ + { + label: "Retry DB Connect setup", + run: async () => { + throw new Error("retry blew up"); + }, + }, + ]); + + expect(appended.join("")).to.contain("retry blew up"); + }); +}); + +describe("makePythonSetupDeps openProjectFile", () => { + let originalShow: typeof window.showTextDocument; + let opened: string[]; + + beforeEach(() => { + originalShow = window.showTextDocument; + opened = []; + (window as unknown as {showTextDocument: unknown}).showTextDocument = + async (uri: Uri) => { + opened.push(uri.fsPath); + return {} as any; + }; + }); + + afterEach(() => { + (window as unknown as {showTextDocument: unknown}).showTextDocument = + originalShow; + }); + + it("opens the project's pyproject.toml in an editor", async () => { + const deps = makePythonSetupDeps(makeWiring()); + + await deps.openProjectFile("/proj"); + + expect(opened).to.have.length(1); + expect(opened[0]).to.match(/[/\\]proj[/\\]pyproject\.toml$/); + }); +}); + +describe("makePythonSetupDeps restoreProjectFile", () => { + it("copies the CLI's backup over the project's pyproject.toml", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "vpex-restore-")); + try { + const pyproject = path.join(dir, "pyproject.toml"); + const backup = path.join(dir, "pyproject.toml.bak"); + // The failed run's conflicting file, and the pre-merge backup. + await writeFile(pyproject, "conflicting = true\n"); + await writeFile(backup, "original = true\n"); + + const deps = makePythonSetupDeps(makeWiring()); + await deps.restoreProjectFile(dir, backup); + + // pyproject.toml now holds the backup's (original) contents again. + expect(await readFile(pyproject, "utf8")).to.equal( + "original = true\n" + ); + } finally { + await rm(dir, {recursive: true, force: true}); + } + }); + + it("leaves no temp artifact behind after a successful restore", async () => { + // The restore writes atomically (copy to a temp sibling, then rename), + // so no stray *.tmp file may survive a successful run. + const dir = await mkdtemp(path.join(tmpdir(), "vpex-restore-")); + try { + const backup = path.join(dir, "pyproject.toml.bak"); + await writeFile(path.join(dir, "pyproject.toml"), "conflicting\n"); + await writeFile(backup, "original\n"); + + const deps = makePythonSetupDeps(makeWiring()); + await deps.restoreProjectFile(dir, backup); + + const entries = await readdir(dir); + expect(entries.sort()).to.deep.equal([ + "pyproject.toml", + "pyproject.toml.bak", + ]); + } finally { + await rm(dir, {recursive: true, force: true}); + } + }); + + it("refuses to restore from a backup outside the project (and leaves the file untouched)", async () => { + // backupPath comes from the CLI result; a path outside the project must + // never be copied over pyproject.toml. + const project = await mkdtemp(path.join(tmpdir(), "vpex-proj-")); + const outside = await mkdtemp(path.join(tmpdir(), "vpex-out-")); + try { + const pyproject = path.join(project, "pyproject.toml"); + await writeFile(pyproject, "conflicting\n"); + const foreign = path.join(outside, "secrets.bak"); + await writeFile(foreign, "SHOULD NOT LAND\n"); + + const deps = makePythonSetupDeps(makeWiring()); + let threw = false; + try { + await deps.restoreProjectFile(project, foreign); + } catch { + threw = true; + } + + expect(threw).to.equal(true); + // pyproject.toml is untouched; the foreign file never lands. + expect(await readFile(pyproject, "utf8")).to.equal("conflicting\n"); + } finally { + await rm(project, {recursive: true, force: true}); + await rm(outside, {recursive: true, force: true}); + } + }); + + it("leaves pyproject.toml untouched and cleans up when the copy fails", async () => { + // A missing backup (in-project path, so it passes the guard) makes the + // copy fail: the destination must be untouched and no temp left behind. + const dir = await mkdtemp(path.join(tmpdir(), "vpex-restore-")); + try { + const pyproject = path.join(dir, "pyproject.toml"); + await writeFile(pyproject, "conflicting\n"); + const missing = path.join(dir, "pyproject.toml.bak"); // never created + + const deps = makePythonSetupDeps(makeWiring()); + let threw = false; + try { + await deps.restoreProjectFile(dir, missing); + } catch { + threw = true; + } + + expect(threw).to.equal(true); + expect(await readFile(pyproject, "utf8")).to.equal("conflicting\n"); + // No *.tmp artifact survived the failed copy. + const entries = await readdir(dir); + expect(entries).to.deep.equal(["pyproject.toml"]); + } finally { + await rm(dir, {recursive: true, force: true}); + } + }); }); describe("makePythonSetupDeps showSuccess", () => { diff --git a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts index adbf317f8..5e41e996a 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts @@ -1,4 +1,6 @@ +import {randomBytes} from "crypto"; import {existsSync} from "fs"; +import {copyFile, rename, rm} from "fs/promises"; import path from "path"; import {commands, ProgressLocation, Uri, window} from "vscode"; import {PackageManagerDetection} from "../../language/packageManagerDetection"; @@ -276,6 +278,44 @@ export function makePythonSetupDeps( Uri.file(projectRoot) ); }, + openProjectFile: async (projectRoot: string) => { + // Open the pyproject.toml the conflicting run merged into, so the + // user can inspect/adjust the dependencies that clashed. A + // constraint conflict always mutates disk (constraints are merged + // before provisioning fails), so the file exists. + await window.showTextDocument( + Uri.file(path.join(projectRoot, "pyproject.toml")) + ); + }, + restoreProjectFile: async (projectRoot: string, backupPath: string) => { + // Restore the CLI's pre-merge backup over pyproject.toml (the seam's + // doc covers why the DB Connect retry needs this). + const root = path.resolve(projectRoot); + // backupPath comes from the CLI result: refuse anything resolving + // outside the project so a malformed/unexpected path can't copy an + // arbitrary file over pyproject.toml. Lexical containment (path is + // from the trusted local CLI, so symlink canonicalization is not + // warranted). + const rel = path.relative(root, path.resolve(backupPath)); + if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) { + throw new Error( + `Refusing to restore pyproject.toml from a backup outside the project: ${backupPath}` + ); + } + // Write atomically: copy to a temp sibling on the same filesystem, + // then rename over pyproject.toml, so an interrupted or failed copy + // can never leave the project file truncated. Clean the temp up if + // either step throws (rename consumes it on success). + const dest = path.join(root, "pyproject.toml"); + const tmp = `${dest}.${randomBytes(6).toString("hex")}.tmp`; + try { + await copyFile(backupPath, tmp); + await rename(tmp, dest); + } catch (e) { + await rm(tmp, {force: true}); + throw e; + } + }, // Stamp the persisted state with the completion time here (the // orchestrator supplies the env identity; the timestamp is a wiring // concern) and hand it to the injected store for drift detection. @@ -307,7 +347,8 @@ export function makePythonSetupDeps( showError: async ( message: string, detail?: string, - actions: PythonSetupErrorAction[] = [] + actions: PythonSetupErrorAction[] = [], + options?: {includeShowLogs?: boolean} ) => { // The mapped one-liner is deliberately concise and drops the CLI's // own explanation; write that detail into the channel so the log the @@ -336,10 +377,15 @@ export function makePythonSetupDeps( }); // Lead with the remediation buttons (e.g. "Install uv", then // "Installation guide") in order, so the action the user most likely - // wants comes first; "Show Logs" always trails. - const buttons = [...remediations.map((a) => a.label), showLogs]; + // wants comes first; "Show Logs" trails, unless the caller opted it + // out (a self-service toast whose own buttons are the remedy) — the + // channel is revealed above regardless, so the log stays reachable. + const includeShowLogs = options?.includeShowLogs !== false; + const buttons = includeShowLogs + ? [...remediations.map((a) => a.label), showLogs] + : remediations.map((a) => a.label); const picked = await window.showErrorMessage(message, ...buttons); - if (picked === showLogs) { + if (includeShowLogs && picked === showLogs) { wiring.log.show(); return; } @@ -369,11 +415,18 @@ export function makePythonSetupDeps( `\nCould not open ${chosen.url} in a browser.\n` ); } + } else if (chosen.run) { + // A run-action (the constraint-conflict "Retry DB Connect setup" + // / "Open pyproject.toml" buttons) invokes an in-process + // callback the orchestrator built with the run's live state. + await chosen.run(); } } catch (e) { const what = chosen.command ? `run ${chosen.command}` - : `open ${chosen.url}`; + : chosen.url + ? `open ${chosen.url}` + : `run the "${chosen.label}" action`; wiring.log.append( `\nFailed to ${what}: ${ e instanceof Error ? e.message : String(e) diff --git a/packages/databricks-vscode/src/python-setup/models/PythonSetupResult.ts b/packages/databricks-vscode/src/python-setup/models/PythonSetupResult.ts index 9f9e50976..3f7766f5a 100644 --- a/packages/databricks-vscode/src/python-setup/models/PythonSetupResult.ts +++ b/packages/databricks-vscode/src/python-setup/models/PythonSetupResult.ts @@ -34,6 +34,14 @@ export type PythonSetupPhaseStatus = "ok" | "error" | "pending"; * ErrorCode set. `E_AUTH` / `E_PYTHON_POLICY` appear in the spec but are never * emitted by the CLI (auth is handled by the shared workspace-client preflight * before a result object is built), so they are intentionally absent here. + * + * `E_PROVISION_CONFLICT` is the distinct code the CLI emits when `uv sync` fails + * specifically because the runtime's pinned dependencies conflict with the + * user's own (only the Full preset pins them); a generic provision failure stays + * `E_PROVISION`. The pins are already merged into pyproject.toml at the point of + * failure (`diskMutated: true`, with the pre-merge file saved to `backupPath`), + * which the extension's retry-as-DB-Connect recovery relies on: it restores that + * backup before re-running. */ export type PythonSetupErrorCode = | "E_USAGE" @@ -48,6 +56,7 @@ export type PythonSetupErrorCode = | "E_MERGE" | "E_PYTHON_INSTALL" | "E_PROVISION" + | "E_PROVISION_CONFLICT" | "E_VALIDATE"; export interface PythonSetupComputeInfo { diff --git a/packages/databricks-vscode/src/python-setup/utils/errorMessages.test.ts b/packages/databricks-vscode/src/python-setup/utils/errorMessages.test.ts index de1116533..20cc64fa3 100644 --- a/packages/databricks-vscode/src/python-setup/utils/errorMessages.test.ts +++ b/packages/databricks-vscode/src/python-setup/utils/errorMessages.test.ts @@ -139,6 +139,15 @@ describe("getPythonSetupErrorMessage", () => { expect(msg).to.not.match(/UV_INDEX_URL|pip\.conf/i); }); + it("maps E_PROVISION_CONFLICT to a cluster-vs-local conflict message", () => { + // The distinct constraint-conflict code (only the Full preset pins + // cluster deps) gets its own actionable copy, not E_PROVISION's generic + // "adjust your dependencies" text. + const msg = getPythonSetupErrorMessage(failure("E_PROVISION_CONFLICT")); + expect(msg).to.match(/cluster dependencies conflict/i); + expect(msg).to.match(/local dependencies/i); + }); + it("maps E_FETCH to an offline/unreachable message", () => { expect(getPythonSetupErrorMessage(failure("E_FETCH"))).to.match( /reach|offline|network/i @@ -340,6 +349,18 @@ describe("getPythonSetupErrorAction", () => { ); }); + it("points an E_PROVISION_CONFLICT at the uv resolution docs (generic fallback)", () => { + // The Full-preset flow builds its own retry/open buttons in the + // orchestrator; this code-keyed link is the fallback for a conflict that + // somehow arrives on a run that already dropped the pins. + expect( + getPythonSetupErrorAction(failure("E_PROVISION_CONFLICT")) + ).to.deep.equal({ + label: "Resolve dependency conflicts", + url: "https://docs.astral.sh/uv/concepts/resolution/", + }); + }); + it("points E_MANAGER_UNSUPPORTED at the uv projects docs", () => { expect( getPythonSetupErrorAction( @@ -641,6 +662,23 @@ describe("formatSetupFailureDetail", () => { ); }); + it("keeps the constraints-report hint for E_PROVISION_CONFLICT", () => { + // A constraint conflict was E_PROVISION before the CLI split out the + // distinct code; the soft "if you think it's the published constraints, + // report it" log pointer must not silently vanish, since a pins-vs-local + // conflict is exactly the case where the published constraints may be at + // fault. + const detail = formatSetupFailureDetail( + failure("E_PROVISION_CONFLICT", { + message: "No solution found when resolving dependencies", + }) + ); + expect(detail).to.match(/constraint/i); + expect(detail).to.contain( + "https://github.com/databricks/environments/issues/new" + ); + }); + it("adds no constraints-report hint for a blocked-index E_PROVISION", () => { // A blocked index is a local network condition, not a constraint defect. const detail = formatSetupFailureDetail( diff --git a/packages/databricks-vscode/src/python-setup/utils/errorMessages.ts b/packages/databricks-vscode/src/python-setup/utils/errorMessages.ts index 4f66c998f..ab01688c1 100644 --- a/packages/databricks-vscode/src/python-setup/utils/errorMessages.ts +++ b/packages/databricks-vscode/src/python-setup/utils/errorMessages.ts @@ -173,16 +173,26 @@ export const INSTALL_UV_COMMAND_ID = "databricks.environment.installUv"; /** * An optional remediation button to attach to a failure popup. Exactly one of - * `url` / `command` is set — a discriminated union (`?: never` on the other arm) - * forbids both/neither at compile time, while still letting callers read - * `action.url` / `action.command` as `string | undefined` without narrowing. - * `url` opens an external page (docs, issue); `command` runs a VS Code command - * (e.g. the one-click switch to manual setup). Kept alongside - * {@link getPythonSetupErrorMessage} so the copy and its call-to-action live together. + * `url` / `command` / `run` is set — a discriminated union (`?: never` on the + * other arms) forbids more than one at compile time, while still letting callers + * read `action.url` / `action.command` as `string | undefined` without + * narrowing. `url` opens an external page (docs, issue); `command` runs a VS + * Code command (e.g. the one-click switch to manual setup); `run` invokes an + * in-process callback for a button whose behavior needs runtime state and so + * can't be expressed as a static url/command (the constraint-conflict + * "Retry DB Connect setup" / "Open pyproject.toml" buttons, built by the + * orchestrator). Kept alongside {@link getPythonSetupErrorMessage} so the copy + * and its call-to-action live together. */ export type PythonSetupErrorAction = - | {label: string; url: string; command?: never} - | {label: string; command: string; url?: never}; + | {label: string; url: string; command?: never; run?: never} + | {label: string; command: string; url?: never; run?: never} + | { + label: string; + run: () => void | Promise; + url?: never; + command?: never; + }; /* eslint-disable @typescript-eslint/naming-convention */ const BASE_MESSAGE: Record< @@ -220,6 +230,9 @@ const BASE_MESSAGE: Record< E_PROVISION: () => "uv could not resolve the project's dependencies (a version conflict). " + "Review the conflict in the logs and adjust your dependencies.", + E_PROVISION_CONFLICT: () => + "The cluster dependencies conflict with your local dependencies, so uv " + + "sync couldn't resolve the environment.", E_VALIDATE: () => "The provisioned environment did not match the selected runtime.", }; @@ -296,6 +309,14 @@ const DOC_LINKS: Partial> = label: "Resolve dependency conflicts", url: UV_RESOLUTION_DOCS_URL, }, + // The Full-preset flow builds its own retry/open buttons in the + // orchestrator; this is the fallback link for a conflict that reaches the + // generic path instead — a run that already skipped constraints, or one + // with no backup to restore. + E_PROVISION_CONFLICT: { + label: "Resolve dependency conflicts", + url: UV_RESOLUTION_DOCS_URL, + }, E_NO_TARGET: { label: "Configure compute", url: DATABRICKS_CONFIGURE_DOCS_URL, @@ -463,11 +484,18 @@ export function formatSetupFailureDetail( 'setting to "manual". The extension then uses your existing interpreter/.venv as-is.' ); } - // A genuine E_PROVISION conflict gets no report button (it is usually the - // user's own dependencies). But if the *published constraints* are what - // conflict, that is a defect worth reporting — so offer a soft, conditional - // pointer here. Excludes the blocked-index variant, a local network issue. - if (err.code === "E_PROVISION" && !isIndexUnreachableFailure(result)) { + // A dependency conflict gets no report button (it is usually the user's own + // dependencies). But if the *published constraints* are what conflict, that + // is a defect worth reporting — so offer a soft, conditional pointer here. + // Covers both the generic E_PROVISION conflict and the distinct + // E_PROVISION_CONFLICT (a pins-vs-local conflict is exactly where the + // published constraints may be at fault, and it was E_PROVISION — carrying + // this pointer — before the CLI split the code out). Excludes the + // blocked-index variant, a local network issue. + if ( + (err.code === "E_PROVISION" || err.code === "E_PROVISION_CONFLICT") && + !isIndexUnreachableFailure(result) + ) { lines.push( "", "If you believe this conflict comes from the published runtime " + diff --git a/packages/databricks-vscode/src/telemetry/constants.ts b/packages/databricks-vscode/src/telemetry/constants.ts index 8e0c91736..e36223804 100644 --- a/packages/databricks-vscode/src/telemetry/constants.ts +++ b/packages/databricks-vscode/src/telemetry/constants.ts @@ -134,7 +134,7 @@ export type SetupTrigger = "auto_open" | "explicit_command" | "run" | "debug"; * again. One event, one enum dimension — so re-runs stay analysable without * fingerprinting on the command id. */ -export type PythonSetupRunTrigger = "initial" | "rerun"; +export type PythonSetupRunTrigger = "initial" | "rerun" | "conflict_retry"; /** Categorical outcome of Python acquisition and its user recovery path. */ export type PythonSetupFlow = @@ -557,9 +557,15 @@ export class EventTypes { "IDs/names, paths, or package names.", trigger: { comment: - "initial (first setup for the project this session) or rerun (re-running over an " + + "initial (first setup for the project this session), rerun (re-running over an " + "environment already provisioned this session, e.g. via the ready row's Re-run " + - "button). Session-scoped: a run after a window reload reads as initial again", + "button), or conflict_retry (a Full-preset run hit a cluster-vs-local dependency " + + "conflict and the user clicked 'Retry DB Connect setup', which re-runs with " + + "--no-constraints). conflict_retry counts conflict-recovery runs that started " + + "(a click whose pre-retry pyproject.toml restore fails, coalesces, or no-ops " + + "records none); pair it with the matching result's outcome for the recovery " + + "success rate. Session-scoped: a " + + "run after a window reload reads as initial again", }, packageManager: { comment: diff --git a/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.ts b/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.ts index a7e9a89dd..4af52c6cf 100644 --- a/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.ts +++ b/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.ts @@ -41,9 +41,11 @@ export interface PythonSetupAttempt { */ isGreenfield?: boolean; /** - * Whether this is the first setup for the project this session or a re-run - * over an environment already provisioned this session (session-scoped). - * Same event, one enum dimension. + * Whether this is the first setup for the project this session (`initial`), a + * re-run over an environment already provisioned this session (`rerun`), or a + * constraint-conflict recovery — the `Retry DB Connect setup` click on a + * failed Full-preset run (`conflict_retry`). Session-scoped; same event, one + * enum dimension. */ trigger: PythonSetupRunTrigger; }