diff --git a/src/main/adapters/cli.adapter.ts b/src/main/adapters/cli.adapter.ts index 3dba71ca..5c5ce887 100644 --- a/src/main/adapters/cli.adapter.ts +++ b/src/main/adapters/cli.adapter.ts @@ -1,6 +1,13 @@ import { ChildProcessWithoutNullStreams, spawn } from 'child_process'; import { BrowserWindow } from 'electron'; -import { CliMessage } from '../../types/backend'; +import { CliMessage, CliProcessEnvironment } from '../../types/backend'; + +const createProcessEnvironment = (environment?: CliProcessEnvironment) => ({ + ...process.env, + ...(environment?.DBT_ALLOW_EXPERIMENTAL_ADAPTERS === 'true' + ? { DBT_ALLOW_EXPERIMENTAL_ADAPTERS: 'true' } + : {}), +}); class CliAdapter { private process: ChildProcessWithoutNullStreams | null = null; @@ -9,7 +16,11 @@ class CliAdapter { return this.process; } - async runCommandWithoutStreaming(command: string, args?: string[]) { + async runCommandWithoutStreaming( + command: string, + args?: string[], + environment?: CliProcessEnvironment, + ) { return new Promise((resolve, reject) => { if (this.process) { reject(new Error('A command is already running. Please wait.')); @@ -17,9 +28,15 @@ class CliAdapter { } if (args && args.length > 0) { - this.process = spawn(command, args, { shell: false }); + this.process = spawn(command, args, { + shell: false, + env: createProcessEnvironment(environment), + }); } else { - this.process = spawn(command, { shell: true }); + this.process = spawn(command, { + shell: true, + env: createProcessEnvironment(environment), + }); } // Drain stdout/stderr to avoid the child process blocking when buffers fill. @@ -42,7 +59,12 @@ class CliAdapter { }); } - runCommand(mainWindow: BrowserWindow, command: string, args?: string[]) { + runCommand( + mainWindow: BrowserWindow, + command: string, + args?: string[], + environment?: CliProcessEnvironment, + ) { return new Promise((resolve, reject) => { if (this.process) { reject(new Error('A command is already running. Please wait.')); @@ -52,9 +74,15 @@ class CliAdapter { mainWindow.webContents.send('cli:clear'); if (args && args.length > 0) { - this.process = spawn(command, args, { shell: false }); + this.process = spawn(command, args, { + shell: false, + env: createProcessEnvironment(environment), + }); } else { - this.process = spawn(command, { shell: true }); + this.process = spawn(command, { + shell: true, + env: createProcessEnvironment(environment), + }); } this.messageHandler( @@ -82,34 +110,19 @@ class CliAdapter { }); this.process.on('close', (code) => { - // Send exit event first - mainWindow.webContents.send('cli:exit', code); - - // Always send done event for frontend to know command completed - mainWindow.webContents.send('cli:done'); - - // Reset process - this.process = null; - - // Handle promise resolution if (code === 0) { - this.messageHandler( - { - type: 'success', - message: `Command executed successfully.`, - }, - mainWindow, - ); + mainWindow.webContents.send('cli:exit', code); + mainWindow.webContents.send('cli:done', code); resolve(); } else { - // Don't call messageHandler with error type here since it calls stopCommand - // Just add the exit code message directly - mainWindow.webContents.send( - 'cli:output', - `Process exited with code ${code}`, - ); + const exitMessage = `Process exited with code ${code}`; + mainWindow.webContents.send('cli:error', exitMessage); + mainWindow.webContents.send('cli:exit', code); + mainWindow.webContents.send('cli:done', code); reject(new Error(`Process exited with error code ${code}`)); } + + this.process = null; }); this.process.on('error', (err) => { diff --git a/src/main/ipcHandlers/cli.ipcHandlers.ts b/src/main/ipcHandlers/cli.ipcHandlers.ts index 14291900..1650080a 100644 --- a/src/main/ipcHandlers/cli.ipcHandlers.ts +++ b/src/main/ipcHandlers/cli.ipcHandlers.ts @@ -1,5 +1,6 @@ import { ipcMain, BrowserWindow } from 'electron'; import { CliAdapter } from '../adapters'; +import { CliProcessEnvironment } from '../../types/backend'; const cliAdapter = new CliAdapter(); @@ -38,11 +39,17 @@ const registerCliHandlers = (mainWindow: BrowserWindow) => { args: { command: string; args?: string[]; + environment?: CliProcessEnvironment; cb?: (message: string) => void; }, ) => { try { - await cliAdapter.runCommand(mainWindow, args.command, args.args); + await cliAdapter.runCommand( + mainWindow, + args.command, + args.args, + args.environment, + ); return { success: true }; } catch (error: any) { const errorMessage = diff --git a/src/main/ipcHandlers/settings.ipcHandlers.ts b/src/main/ipcHandlers/settings.ipcHandlers.ts index 7e376c66..cc0b28a8 100644 --- a/src/main/ipcHandlers/settings.ipcHandlers.ts +++ b/src/main/ipcHandlers/settings.ipcHandlers.ts @@ -3,7 +3,7 @@ import { initializeDataStorage } from '../utils/setupHelpers'; import { FileDialogProperties, SettingsType } from '../../types/backend'; import { SettingsService } from '../services'; import { SettingsChannels } from '../../types/ipc'; -import { DbtVersionManagerService } from '../services/dbtVersionManager.service'; +import { DbtCoreVersionService } from '../services/dbtCoreVersion.service'; const handlerChannels: SettingsChannels[] = [ 'settings:load', @@ -19,6 +19,18 @@ const handlerChannels: SettingsChannels[] = [ 'settings:restart', 'settings:getBasename', 'settings:getDirname', + 'dbt:versions:list', + 'dbt:installed:get', + 'dbt:versionChange:plan', + 'dbt:versionChange:install', + 'dbt:compatibility:check', + 'dbt:adapters:active', + 'dbt:adapters:check', + 'dbt:packages:installed', + 'dbt:package:installLatest', + 'dbt:package:uninstall', + 'dbt:packageVersions:list', + 'dbt:packageVersion:install', ]; const removeSettingsIpcHandlers = () => { @@ -133,16 +145,56 @@ const registerSettingsHandlers = (mainWindow: BrowserWindow) => { return SettingsService.installSqlGlot(); }); - ipcMain.handle('dbt:versions:list', async () => { - return DbtVersionManagerService.listDbtCoreVersions(); + ipcMain.handle('dbt:versions:list', async (_event, request) => { + return DbtCoreVersionService.listDbtCoreVersions(request); + }); + + ipcMain.handle('dbt:installed:get', async () => { + return DbtCoreVersionService.getInstalledDbtCore(); + }); + + ipcMain.handle('dbt:versionChange:plan', async (_event, request) => { + return DbtCoreVersionService.planVersionChange(request); + }); + + ipcMain.handle('dbt:versionChange:install', async (_event, request) => { + return DbtCoreVersionService.installVersionChange(request); + }); + + ipcMain.handle('dbt:compatibility:check', async () => { + return DbtCoreVersionService.checkCurrentProjectCompatibility(); + }); + + ipcMain.handle('dbt:adapters:active', async (_event, request) => { + return DbtCoreVersionService.getActiveAdapterCapabilities( + request?.projectPath, + ); + }); + + ipcMain.handle('dbt:adapters:check', async (_event, request) => { + return DbtCoreVersionService.checkProjectAdapterCompatibility( + request?.projectPath, + ); + }); + + ipcMain.handle('dbt:packages:installed', async () => { + return DbtCoreVersionService.getInstalledPackages(); + }); + + ipcMain.handle('dbt:package:installLatest', async (_event, request) => { + return DbtCoreVersionService.installLatestPackage(request); + }); + + ipcMain.handle('dbt:package:uninstall', async (_event, request) => { + return DbtCoreVersionService.uninstallPackage(request); }); ipcMain.handle('dbt:packageVersions:list', async (_event, req) => { - return DbtVersionManagerService.listPackageVersions(req?.packageName); + return DbtCoreVersionService.listPackageVersions(req?.packageName); }); ipcMain.handle('dbt:packageVersion:install', async (_event, req) => { - return DbtVersionManagerService.installPackageVersion(req); + return DbtCoreVersionService.installPackageVersion(req); }); }; diff --git a/src/main/services/agent.service.ts b/src/main/services/agent.service.ts index 25c3976e..1aa35173 100644 --- a/src/main/services/agent.service.ts +++ b/src/main/services/agent.service.ts @@ -36,6 +36,10 @@ import type { } from '../../types/agentEvents'; import { getUserMessageLimitError } from '../../types/agentEvents'; import { toError } from '../utils/errorSerializer'; +import { + getToolResultError, + isToolResultFailure, +} from '../../shared/toolResult'; // ─── AI Settings ───────────────────────────────────────────────────────────── @@ -1214,13 +1218,16 @@ COMBINED SUMMARY:`, }); break; case 'tool-result': { + const toolOutput = + (chunk as any).output ?? (chunk as any).result; + const toolFailed = isToolResultFailure(toolOutput); collectedToolCalls.push({ toolName: chunk.toolName, toolCallId: chunk.toolCallId, input: (chunk as any).input ?? (chunk as any).args, - output: (chunk as any).output ?? (chunk as any).result, + output: toolOutput, stepNumber: currentStepNumber >= 0 ? currentStepNumber : 0, - status: 'done', + status: toolFailed ? 'error' : 'done', }); const part = collectedParts.find( (p) => @@ -1228,9 +1235,11 @@ COMBINED SUMMARY:`, p.toolCallId === chunk.toolCallId, ); if (part) { - part.result = - (chunk as any).output ?? (chunk as any).result; - part.status = 'done'; + part.result = toolOutput; + part.error = toolFailed + ? getToolResultError(toolOutput) + : undefined; + part.status = toolFailed ? 'error' : 'done'; } break; } @@ -1298,21 +1307,24 @@ COMBINED SUMMARY:`, // Collect tool calls from steps for persistence result.steps?.forEach((step: any, idx: number) => { step.toolResults?.forEach((tr: any) => { + const toolOutput = (tr as any).output ?? (tr as any).result; + const toolFailed = isToolResultFailure(toolOutput); collectedToolCalls.push({ toolName: tr.toolName, toolCallId: tr.toolCallId, input: (tr as any).input ?? (tr as any).args, - output: (tr as any).output ?? (tr as any).result, + output: toolOutput, stepNumber: idx, - status: 'done', + status: toolFailed ? 'error' : 'done', }); collectedParts.push({ type: 'tool-call', toolCallId: tr.toolCallId, toolName: tr.toolName, args: (tr as any).input ?? (tr as any).args, - result: (tr as any).output ?? (tr as any).result, - status: 'done', + result: toolOutput, + error: toolFailed ? getToolResultError(toolOutput) : undefined, + status: toolFailed ? 'error' : 'done', }); }); }); @@ -1383,7 +1395,10 @@ COMBINED SUMMARY:`, status: tc.status === 'done' ? 'completed' : 'failed', startedAt: new Date().toISOString(), completedAt: new Date().toISOString(), - errorMessage: null, + errorMessage: + tc.status === 'error' + ? getToolResultError(tc.output) || 'Tool execution failed' + : null, })); await MainDatabaseService.addMessageWithContext( diff --git a/src/main/services/ai/agents/projectAgent.ts b/src/main/services/ai/agents/projectAgent.ts index 332f5060..0eafe461 100644 --- a/src/main/services/ai/agents/projectAgent.ts +++ b/src/main/services/ai/agents/projectAgent.ts @@ -95,6 +95,9 @@ ${skills ?? ''} - Before acting, identify how success will be checked. - Use the smallest reliable verification available, such as connection tests, \`dbt debug\`, dbt logs, file readback, command output, or explicit user confirmation. - Do not claim success until the relevant outcome has been verified. +- A dbt tool result with \`ok: false\`, \`success: false\`, a nonzero \`exitCode\`, or any models reported as errors is a failed run, even if other models succeeded. Never summarize a partial dbt run as successful. +- For dbt commands, report the processed, successful, and failed counts from the actual execution summary. Claim full success only when the exit code is zero and the error count is zero. +- After creating or changing an incremental model, a successful first build is not sufficient verification. Run it a second time to exercise the \`is_incremental()\` branch, and only claim success when that incremental run also passes. - If verification fails, explain the failure clearly and stop, retry with evidence, or ask the user for clarification. ## File Ownership Rules diff --git a/src/main/services/ai/tools/dbt.tools.ts b/src/main/services/ai/tools/dbt.tools.ts index a7e75e6c..8d04715f 100644 --- a/src/main/services/ai/tools/dbt.tools.ts +++ b/src/main/services/ai/tools/dbt.tools.ts @@ -8,6 +8,7 @@ import type { BrowserWindow } from 'electron'; import * as fs from 'fs'; import * as path from 'path'; import SettingsService from '../../settings.service'; +import { DbtCoreVersionService } from '../../dbtCoreVersion.service'; import AgentService from '../../agent.service'; import { TerminalConfirmGate } from './terminalConfirmGate'; @@ -102,6 +103,16 @@ export async function executeDbtCommand(opts: { } try { + const adapterCheck = + await DbtCoreVersionService.checkProjectAdapterCompatibility(projectPath); + if (!adapterCheck.adapter.canExecute) { + return { + ok: false, + error: `dbt command blocked: ${adapterCheck.adapter.notes}`, + adapter: adapterCheck.adapter, + }; + } + const dbtExe = await SettingsService.getDbtExePath(); const args = normalizedCommand.split(' ').filter(Boolean); if (select) args.push('--select', select); @@ -174,6 +185,8 @@ export async function executeDbtCommand(opts: { ok: false, command: displayCmd, error: err.message, + exitCode: null, + output: fullOutput, stdout: '', stderr: err.message, }); @@ -181,15 +194,13 @@ export async function executeDbtCommand(opts: { child.on('close', (code) => { if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send( - 'cli:done', - `Process exited with code ${code}`, - ); + mainWindow.webContents.send('cli:done', code); } if (code === 0) { resolve({ ok: true, command: displayCmd, + exitCode: 0, output: fullOutput, }); } else { @@ -197,6 +208,8 @@ export async function executeDbtCommand(opts: { ok: false, command: displayCmd, error: `Command failed with code ${code}`, + exitCode: code, + output: fullOutput, stdout: fullOutput, stderr: '', }); @@ -208,6 +221,8 @@ export async function executeDbtCommand(opts: { ok: false, command: `dbt ${normalizedCommand}`, error: error.message || 'Command failed', + exitCode: typeof error.status === 'number' ? error.status : null, + output: error.stdout || error.stderr || '', stdout: error.stdout || '', stderr: error.stderr || '', }; @@ -388,6 +403,18 @@ export const runDbtCommand = tool({ }; } + const adapterCheck = + await DbtCoreVersionService.checkProjectAdapterCompatibility( + projectPath, + ); + if (!adapterCheck.adapter.canExecute) { + return { + success: false, + error: `dbt command blocked: ${adapterCheck.adapter.notes}`, + adapter: adapterCheck.adapter, + }; + } + // Resolve dbt executable from app-managed venv const dbtExe = await SettingsService.getDbtExePath(); @@ -444,6 +471,7 @@ export const runDbtCommand = tool({ return { success: true, command: displayCmd, + exitCode: 0, output, }; } catch (error: any) { @@ -451,6 +479,8 @@ export const runDbtCommand = tool({ success: false, command: `dbt ${command}`, error: error.message || 'Command execution failed', + exitCode: typeof error.status === 'number' ? error.status : null, + output: error.stdout || error.stderr || '', stdout: error.stdout || '', stderr: error.stderr || '', }; diff --git a/src/main/services/ai/tools/studio/cli.tools.ts b/src/main/services/ai/tools/studio/cli.tools.ts index 5eda6e2e..d3071dec 100644 --- a/src/main/services/ai/tools/studio/cli.tools.ts +++ b/src/main/services/ai/tools/studio/cli.tools.ts @@ -72,6 +72,8 @@ export function createStudioCliTools(options: { error: result?.error ?? 'dbt command failed', data: { command: result?.command ?? command, + exitCode: result?.exitCode ?? null, + output: result?.output ?? result?.stdout ?? '', stdout: result?.stdout ?? '', stderr: result?.stderr ?? '', }, @@ -85,6 +87,7 @@ export function createStudioCliTools(options: { ok: true, data: { command: result.command, + exitCode: result.exitCode ?? 0, output: result.output ?? '', }, meta: { diff --git a/src/main/services/dbtCoreVersion.service.ts b/src/main/services/dbtCoreVersion.service.ts new file mode 100644 index 00000000..a3ec0ee6 --- /dev/null +++ b/src/main/services/dbtCoreVersion.service.ts @@ -0,0 +1,1098 @@ +import axios from 'axios'; +import fs from 'fs-extra'; +import { spawn } from 'child_process'; +import path from 'path'; +import os from 'os'; +import yaml from 'js-yaml'; +import SettingsService from './settings.service'; +import ProjectsService from './projects.service'; +import { + DbtAdapterCapability, + DbtAdapterCapabilityResponse, + DbtAdapterCompatibility, + DbtProjectAdapterCheck, + DbtProjectCompatibilityResult, + DbtCoreVersionListItem, + DbtVersionChangePlan, + DbtVersionChangePlanRequest, + DbtVersionChangeRequest, + DbtVersionListOptions, + DbtVersionListResponse, + InstalledDbtCoreInfo, + InstalledPythonPackagesResponse, + PythonPackageActionRequest, + PythonPackageInstallVersionRequest, + PythonPackageInstallVersionResponse, + PythonPackageVersionListItem, + PythonPackageVersionListResponse, +} from '../../types/backend'; + +type VersionTriple = { + major: number; + minor: number; + patch: number; + preRank: number; + preNumber: number; +}; + +type PypiProjectJson = { + releases?: Record; +}; + +type ProcessResult = { + exitCode: number | null; + stdout: string; + stderr: string; +}; + +type ProcessOptions = { + cwd?: string; + env?: Record; +}; + +const ADAPTER_PACKAGES = [ + 'dbt-postgres', + 'dbt-snowflake', + 'dbt-bigquery', + 'dbt-redshift', + 'dbt-databricks', + 'dbt-duckdb', +] as const; + +const V2_ADAPTERS: Record< + string, + Omit +> = { + snowflake: { + status: 'preview', + driver: 'adbc', + requiresNetworkOnFirstUse: true, + canExecute: true, + notes: + 'v2 preview adapter. Validate authentication and project behavior before production use.', + }, + bigquery: { + status: 'preview', + driver: 'adbc', + requiresNetworkOnFirstUse: true, + canExecute: true, + notes: + 'v2 preview adapter. Validate authentication and project behavior before production use.', + }, + redshift: { + status: 'preview', + driver: 'adbc', + requiresNetworkOnFirstUse: true, + canExecute: true, + notes: + 'v2 preview adapter. Validate authentication and project behavior before production use.', + }, + databricks: { + status: 'preview', + driver: 'adbc', + requiresNetworkOnFirstUse: true, + canExecute: true, + notes: + 'v2 preview adapter. Validate authentication and project behavior before production use.', + }, + duckdb: { + status: 'preview', + driver: 'native', + requiresNetworkOnFirstUse: false, + canExecute: true, + notes: + 'v2 preview adapter. Validate project behavior before production use.', + }, +}; + +const adapterDisplayName = (adapter: string | null): string => { + if (!adapter) return 'Unknown adapter'; + const labels: Record = { + bigquery: 'BigQuery', + databricks: 'Databricks', + duckdb: 'DuckDB', + postgres: 'PostgreSQL', + redshift: 'Redshift', + snowflake: 'Snowflake', + }; + return labels[adapter] ?? adapter; +}; + +const normalizeAdapter = (value: string | undefined | null): string | null => { + const adapter = value?.trim().toLowerCase(); + if (!adapter) return null; + return adapter === 'ducklake' ? 'duckdb' : adapter; +}; + +const ALLOWED_PYTHON_PACKAGES = new Set([ + 'dbt-core', + ...ADAPTER_PACKAGES, + 'sqlglot', +]); + +const VERSION_PATTERN = + /^[0-9]+(?:\.[0-9]+)*(?:(?:a|b|rc)[0-9]+)?(?:\.post[0-9]+)?(?:\.dev[0-9]+)?$/i; + +const normalizeVersion = (version: string): string => + version + .trim() + .toLowerCase() + .replace(/^v/, '') + .replace(/-alpha[.-]?/, 'a') + .replace(/-beta[.-]?/, 'b') + .replace(/-rc[.-]?/, 'rc'); + +const parseVersionTriple = (version: string): VersionTriple | null => { + const cleaned = normalizeVersion(version); + const match = cleaned.match( + /^([0-9]+)(?:\.([0-9]+))?(?:\.([0-9]+))?(?:(a|b|rc)([0-9]+))?/, + ); + if (!match) return null; + + const major = Number(match[1] ?? 0); + const minor = Number(match[2] ?? 0); + const patch = Number(match[3] ?? 0); + const preLabel = match[4] ?? null; + let preRank = 3; + if (preLabel === 'a') preRank = 0; + if (preLabel === 'b') preRank = 1; + if (preLabel === 'rc') preRank = 2; + const preNumber = Number(match[5] ?? 0); + + if (Number.isNaN(major) || Number.isNaN(minor) || Number.isNaN(patch)) { + return null; + } + + return { major, minor, patch, preRank, preNumber }; +}; + +const compareVersions = (a: string, b: string): number => { + const va = parseVersionTriple(a); + const vb = parseVersionTriple(b); + if (!va || !vb) return a.localeCompare(b); + + if (va.major !== vb.major) return va.major > vb.major ? 1 : -1; + if (va.minor !== vb.minor) return va.minor > vb.minor ? 1 : -1; + if (va.patch !== vb.patch) return va.patch > vb.patch ? 1 : -1; + + if (va.preRank !== vb.preRank) return va.preRank > vb.preRank ? 1 : -1; + if (va.preNumber !== vb.preNumber) { + return va.preNumber > vb.preNumber ? 1 : -1; + } + return 0; +}; + +const isPrerelease = (version: string): boolean => { + const v = parseVersionTriple(version); + return v ? v.preRank < 3 : false; +}; + +const parsePipShowVersion = (output: string): string | null => { + const versionMatch = output.match(/^Version:\s*(.+)$/m); + return versionMatch ? versionMatch[1].trim() : null; +}; + +const isValidPackageVersion = (version: string): boolean => + VERSION_PATTERN.test(normalizeVersion(version)); + +const outputReportsVersion = (output: string, expected: string): boolean => { + const expectedNormalized = normalizeVersion(expected); + const reportedVersions = + output.match( + /\bv?[0-9]+\.[0-9]+(?:\.[0-9]+)?(?:-(?:alpha|beta|rc)[.-]?[0-9]+|(?:a|b|rc)[0-9]+)?\b/gi, + ) ?? []; + + return reportedVersions.some( + (reported) => normalizeVersion(reported) === expectedNormalized, + ); +}; + +const classifyVersionChange = ( + preview: boolean, + comparison: number, +): DbtVersionChangePlan['direction'] => { + if (preview) return 'preview-install'; + if (comparison > 0) return 'upgrade'; + if (comparison < 0) return 'downgrade'; + return 'reinstall'; +}; + +const summarizeProcessResult = ( + command: 'parse' | 'compile', + result: ProcessResult, +): string => { + const output = (result.stderr || result.stdout || '').trim(); + if (output) return output.slice(-12000); + if (result.exitCode === 0) return `${command} completed successfully.`; + return `${command} failed without diagnostic output.`; +}; + +const getDbtExecutableForPython = (python: string): string => { + return path.join( + path.dirname(python), + process.platform === 'win32' ? 'dbt.exe' : 'dbt', + ); +}; + +const runProcess = ( + command: string, + args: string[], + options?: ProcessOptions, +): Promise => { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + shell: false, + ...(options?.cwd ? { cwd: options.cwd } : {}), + ...(options?.env ? { env: options.env } : {}), + }); + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (data) => { + stdout += String(data); + }); + + child.stderr.on('data', (data) => { + stderr += String(data); + }); + + child.on('error', (error) => { + reject(error); + }); + + child.on('close', (exitCode) => { + resolve({ exitCode, stdout, stderr }); + }); + }); +}; + +const getPythonExecutable = async (pythonPath?: string): Promise => { + const settings = await SettingsService.loadSettings(); + const configuredPython = settings.pythonPath?.trim(); + + if (!configuredPython || !(await fs.pathExists(configuredPython))) { + throw new Error( + 'Managed Python environment not found. Install Python from Settings first.', + ); + } + + if ( + pythonPath && + path.resolve(pythonPath) !== path.resolve(configuredPython) + ) { + throw new Error( + 'The requested Python executable is not the app-managed environment.', + ); + } + + return configuredPython; +}; + +export class DbtCoreVersionService { + static async listDbtCoreVersions( + options?: DbtVersionListOptions, + ): Promise { + let projectJson: PypiProjectJson; + + try { + projectJson = await this.fetchPypiProjectJson('dbt-core'); + } catch { + return { + versions: [], + latestStable: null, + currentVersion: + (await SettingsService.loadSettings()).dbtVersion || null, + }; + } + + const includePrerelease = options?.includePrerelease ?? false; + const limit = Math.min(Math.max(Math.floor(options?.limit ?? 5), 1), 100); + + const allVersions = Object.entries(projectJson.releases ?? {}) + .filter(([, files]) => + files.some((releaseFile) => releaseFile.yanked !== true), + ) + .map(([version]) => version) + .filter((v) => parseVersionTriple(v) !== null) + .sort((a, b) => compareVersions(b, a)); + + const stableVersions = allVersions.filter((v) => !isPrerelease(v)); + const versions = (includePrerelease ? allVersions : stableVersions).slice( + 0, + limit, + ); + + const latestStable = stableVersions.length > 0 ? stableVersions[0] : null; + const settings = await SettingsService.loadSettings(); + + const out: DbtCoreVersionListItem[] = versions.map((v) => ({ + version: v, + isPrerelease: isPrerelease(v), + isLatestStable: v === latestStable, + isInstalled: v === settings.dbtVersion, + channel: isPrerelease(v) ? 'preview' : 'stable', + })); + + return { + versions: out, + latestStable, + currentVersion: settings.dbtVersion || null, + }; + } + + static async listPackageVersions( + packageName: string | undefined, + ): Promise { + const safeName = String(packageName ?? '').trim(); + if (!ALLOWED_PYTHON_PACKAGES.has(safeName)) { + return { + packageName: safeName, + versions: [], + latestStable: null, + }; + } + + if (!safeName) { + return { + packageName: '', + versions: [], + latestStable: null, + }; + } + + let projectJson: PypiProjectJson; + + try { + projectJson = await this.fetchPypiProjectJson(safeName); + } catch { + return { + packageName: safeName, + versions: [], + latestStable: null, + }; + } + + const versions = Object.entries(projectJson.releases ?? {}) + .filter(([, files]) => + files.some((releaseFile) => releaseFile.yanked !== true), + ) + .map(([version]) => version) + .filter((v) => parseVersionTriple(v) !== null) + .filter((v) => !isPrerelease(v)) + .sort((a, b) => compareVersions(b, a)); + + const latestStable = versions.length > 0 ? versions[0] : null; + const latestFive = versions.slice(0, 5); + + const out: PythonPackageVersionListItem[] = latestFive.map((v) => ({ + version: v, + isPrerelease: false, + })); + + return { + packageName: safeName, + versions: out, + latestStable, + }; + } + + static async getInstalledPackages(): Promise { + const python = await getPythonExecutable(); + const entries = await Promise.all( + [...ALLOWED_PYTHON_PACKAGES].map(async (packageName) => { + const result = await runProcess(python, [ + '-m', + 'pip', + 'show', + packageName, + ]); + const version = + result.exitCode === 0 ? parsePipShowVersion(result.stdout) : null; + return version ? ([packageName, version] as const) : null; + }), + ); + const packages = Object.fromEntries( + entries.filter((entry): entry is readonly [string, string] => !!entry), + ); + + return { packages }; + } + + static async installLatestPackage( + req: PythonPackageActionRequest, + ): Promise { + const packageName = String(req.packageName ?? '').trim(); + if ( + !ALLOWED_PYTHON_PACKAGES.has(packageName) || + packageName === 'dbt-core' + ) { + return { ok: false, error: `Package not allowed: ${packageName}` }; + } + + try { + const python = await getPythonExecutable(req.pythonPath); + const install = await runProcess(python, [ + '-m', + 'pip', + 'install', + '--upgrade', + '--no-cache-dir', + packageName, + ]); + if (install.exitCode !== 0) { + return { + ok: false, + error: install.stderr || install.stdout || 'Package install failed.', + }; + } + + const shown = await runProcess(python, [ + '-m', + 'pip', + 'show', + packageName, + ]); + const installedVersion = parsePipShowVersion(shown.stdout); + return installedVersion + ? { ok: true, installedVersion } + : { ok: false, error: `Unable to verify ${packageName}.` }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + static async uninstallPackage( + req: PythonPackageActionRequest, + ): Promise { + const packageName = String(req.packageName ?? '').trim(); + if (!ALLOWED_PYTHON_PACKAGES.has(packageName)) { + return { ok: false, error: `Package not allowed: ${packageName}` }; + } + + try { + const python = await getPythonExecutable(req.pythonPath); + const uninstall = await runProcess(python, [ + '-m', + 'pip', + 'uninstall', + '--yes', + packageName, + ]); + if (uninstall.exitCode !== 0) { + return { + ok: false, + error: + uninstall.stderr || uninstall.stdout || 'Package uninstall failed.', + }; + } + + if (packageName === 'dbt-core') { + const settings = await SettingsService.loadSettings(); + await SettingsService.saveSettings({ + ...settings, + dbtPath: '', + dbtVersion: '', + }); + } + return { ok: true }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + static async planVersionChange( + request: DbtVersionChangePlanRequest, + ): Promise { + const targetVersion = String(request.targetVersion ?? '').trim(); + if (!isValidPackageVersion(targetVersion)) { + throw new Error(`Invalid dbt-core version: ${targetVersion}`); + } + + const settings = await SettingsService.loadSettings(); + const currentVersion = settings.dbtVersion || null; + const preview = isPrerelease(targetVersion); + const comparison = currentVersion + ? compareVersions(targetVersion, currentVersion) + : 1; + const direction = classifyVersionChange(preview, comparison); + const currentParsed = currentVersion + ? parseVersionTriple(currentVersion) + : null; + const targetParsed = parseVersionTriple(targetVersion); + const isMajorVersionChange = + !!currentParsed && + !!targetParsed && + currentParsed.major !== targetParsed.major; + const adapters = + request.includeAdapters === false + ? [] + : await this.inspectAdapterCompatibility(targetVersion); + const warnings = [ + ...(isMajorVersionChange + ? [ + 'This is a major dbt-core version change. Project parsing, CLI flags, packages, and adapters may require migration.', + ] + : []), + ...(preview + ? [ + 'Preview releases can change without notice and should be tested before production use.', + 'Apache dbt-core package provenance is required for this v2/preview installation.', + ] + : []), + ]; + + return { + currentVersion, + targetVersion, + direction, + channel: preview ? 'preview' : 'stable', + isMajorVersionChange, + globalImpactWarning: + 'This changes the global dbt-core version used by all local projects. Projects that require another version may fail until you switch back.', + warnings, + adapters, + rollbackVersion: currentVersion, + }; + } + + static async installVersionChange( + request: DbtVersionChangeRequest, + ): Promise { + const previousSettings = await SettingsService.loadSettings(); + const plan = await this.planVersionChange(request); + const result = await this.installDbtCoreVersion({ + pythonPath: request.pythonPath, + packageName: 'dbt-core', + version: request.targetVersion, + }); + return { + ...result, + previousVersion: plan.currentVersion, + previousDbtPath: previousSettings.dbtPath || null, + adapterWarnings: plan.adapters, + }; + } + + static async installPackageVersion( + req: PythonPackageInstallVersionRequest, + ): Promise { + const safeName = String(req.packageName ?? '').trim(); + + if (safeName === 'dbt-core') { + return this.installDbtCoreVersion(req); + } + + return this.installGenericPythonPackageVersion(req); + } + + static async installDbtCoreVersion( + req: PythonPackageInstallVersionRequest, + ): Promise { + const safeVersion = String(req.version ?? '').trim(); + + if (!safeVersion) { + return { ok: false, error: 'version is required' }; + } + if (!isValidPackageVersion(safeVersion)) { + return { ok: false, error: `Invalid dbt-core version: ${safeVersion}` }; + } + + try { + const python = await getPythonExecutable(req.pythonPath); + const installResult = await runProcess(python, [ + '-m', + 'pip', + 'install', + '--upgrade', + '--force-reinstall', + '--no-cache-dir', + `dbt-core==${safeVersion}`, + ]); + + if (installResult.exitCode !== 0) { + return { + ok: false, + error: + installResult.stderr || + installResult.stdout || + `pip install failed with exit code ${installResult.exitCode}`, + }; + } + + const installed = await this.verifyDbtInstall(python); + if ( + !installed.isDbtCorePackage || + !installed.isExecutableVerified || + installed.version !== safeVersion + ) { + return { + ok: false, + error: + `Installed dbt-core verification failed. Expected ${safeVersion}, found ${ + installed.version || 'unknown' + }. ${installed.error ?? ''}`.trim(), + }; + } + + if (installed.hasProprietaryDbtPackage) { + return { + ok: false, + error: + 'Unsupported dbt distribution detected. Rosetta supports Apache dbt-core only.', + }; + } + + const settings = await SettingsService.loadSettings(); + const { dbtPath } = installed; + if (!dbtPath) { + return { + ok: false, + error: 'Installed dbt-core executable was not found.', + }; + } + await SettingsService.saveSettings({ + ...settings, + dbtPath, + dbtVersion: installed.version, + }); + + return { + ok: true, + installedVersion: installed.version, + dbtPath, + }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + static async verifyDbtInstall( + pythonPath?: string, + ): Promise { + const python = await getPythonExecutable(pythonPath); + const pipShow = await runProcess(python, ['-m', 'pip', 'show', 'dbt-core']); + const proprietaryDbt = await runProcess(python, [ + '-m', + 'pip', + 'show', + 'dbt', + ]); + const version = + pipShow.exitCode === 0 ? parsePipShowVersion(pipShow.stdout) : null; + + const dbtExecutable = getDbtExecutableForPython(python); + let dbtVersionOutput: string | undefined; + let isExecutableVerified = false; + let verificationError: string | undefined; + + if (await fs.pathExists(dbtExecutable)) { + try { + const dbtVersion = await runProcess(dbtExecutable, ['--version']); + dbtVersionOutput = [dbtVersion.stdout, dbtVersion.stderr] + .filter(Boolean) + .join('\n') + .trim(); + isExecutableVerified = + dbtVersion.exitCode === 0 && + !!version && + outputReportsVersion(dbtVersionOutput, version); + if (!isExecutableVerified) { + verificationError = + 'The managed dbt executable did not report the installed dbt-core version.'; + } + } catch (error) { + verificationError = + error instanceof Error + ? error.message + : 'Unable to run dbt --version.'; + } + } else { + verificationError = `dbt executable not found at ${dbtExecutable}`; + } + + return { + version, + pythonPath: python, + dbtPath: (await fs.pathExists(dbtExecutable)) ? dbtExecutable : null, + dbtVersionOutput, + isDbtCorePackage: pipShow.exitCode === 0 && !!version, + isExecutableVerified, + hasProprietaryDbtPackage: proprietaryDbt.exitCode === 0, + error: verificationError, + }; + } + + static async getInstalledDbtCore(): Promise { + try { + return await this.verifyDbtInstall(); + } catch (error) { + const settings = await SettingsService.loadSettings(); + return { + version: null, + pythonPath: settings.pythonPath || '', + dbtPath: null, + isDbtCorePackage: false, + isExecutableVerified: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + static async checkCurrentProjectCompatibility(): Promise { + const project = await ProjectsService.getSelectedProject(); + if (!project) { + return { + ok: false, + diagnostics: [], + recommendations: [], + error: 'No current project is selected.', + }; + } + + const installed = await this.getInstalledDbtCore(); + if (!installed.isExecutableVerified || !installed.dbtPath) { + return { + ok: false, + projectName: project.name, + projectPath: project.path, + diagnostics: [], + recommendations: [], + error: + installed.error || + 'The active dbt-core installation is not verified.', + }; + } + + const adapterCheck = await this.checkProjectAdapterCompatibility( + project.path, + ); + if (!adapterCheck.adapter.canExecute) { + return { + ok: false, + projectName: project.name, + projectPath: project.path, + diagnostics: [], + recommendations: [ + 'Switch the global dbt runtime to a stable v1 release in Settings before running this project.', + ], + error: `${adapterCheck.adapter.displayName}: ${adapterCheck.adapter.notes}`, + }; + } + + const temporaryRoot = await fs.mkdtemp( + path.join(os.tmpdir(), 'rosetta-dbt-compatibility-'), + ); + const commandEnvironment = { + ...process.env, + DBT_TARGET_PATH: path.join(temporaryRoot, 'target'), + DBT_LOG_PATH: path.join(temporaryRoot, 'logs'), + }; + const diagnostics: DbtProjectCompatibilityResult['diagnostics'] = []; + + try { + const parseResult = await runProcess( + installed.dbtPath, + ['parse', '--project-dir', project.path], + { cwd: project.path, env: commandEnvironment }, + ); + diagnostics.push({ + command: 'parse', + ok: parseResult.exitCode === 0, + exitCode: parseResult.exitCode, + summary: summarizeProcessResult('parse', parseResult), + }); + + if (parseResult.exitCode === 0) { + const compileResult = await runProcess( + installed.dbtPath, + ['compile', '--project-dir', project.path], + { cwd: project.path, env: commandEnvironment }, + ); + diagnostics.push({ + command: 'compile', + ok: compileResult.exitCode === 0, + exitCode: compileResult.exitCode, + summary: summarizeProcessResult('compile', compileResult), + }); + } + + return { + ok: diagnostics.length === 2 && diagnostics.every((item) => item.ok), + projectName: project.name, + projectPath: project.path, + diagnostics, + recommendations: [ + 'Run dbt deps manually if package dependencies need refreshing; it can modify dependency artifacts and require network access.', + ...(installed.version?.startsWith('2.') + ? [ + 'Treat parse or compile failures as v2 migration diagnostics and resolve deprecated flags, macros, packages, and behavior changes before production use.', + ] + : []), + ], + }; + } catch (error) { + return { + ok: false, + projectName: project.name, + projectPath: project.path, + diagnostics, + recommendations: [], + error: + error instanceof Error + ? error.message + : 'Compatibility check failed.', + }; + } finally { + await fs.remove(temporaryRoot); + } + } + + private static async installGenericPythonPackageVersion( + req: PythonPackageInstallVersionRequest, + ): Promise { + const safeName = String(req.packageName ?? '').trim(); + const safeVersion = String(req.version ?? '').trim(); + + if (!ALLOWED_PYTHON_PACKAGES.has(safeName)) { + return { + ok: false, + error: `Package not allowed: ${safeName || '(empty)'}`, + }; + } + + if (!safeName) { + return { ok: false, error: 'packageName is required' }; + } + if (!safeVersion) { + return { ok: false, error: 'version is required' }; + } + if (!isValidPackageVersion(safeVersion)) { + return { ok: false, error: `Invalid package version: ${safeVersion}` }; + } + + try { + const python = await getPythonExecutable(req.pythonPath); + const result = await runProcess(python, [ + '-m', + 'pip', + 'install', + '--upgrade', + '--force-reinstall', + '--no-cache-dir', + `${safeName}==${safeVersion}`, + ]); + + if (result.exitCode !== 0) { + return { + ok: false, + error: + result.stderr || + result.stdout || + `pip install failed with exit code ${result.exitCode}`, + }; + } + + const shown = await runProcess(python, ['-m', 'pip', 'show', safeName]); + const installedVersion = + shown.exitCode === 0 ? parsePipShowVersion(shown.stdout) : null; + if (installedVersion !== safeVersion) { + return { + ok: false, + error: `Installed package verification failed. Expected ${safeVersion}, found ${installedVersion || 'unknown'}.`, + }; + } + + return { ok: true, installedVersion }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + private static async resolveProjectAdapter(projectPath?: string): Promise<{ + adapter: string | null; + source: DbtAdapterCapability['source']; + projectPath?: string; + }> { + const selected = await ProjectsService.getSelectedProject(); + const resolvedPath = projectPath || selected?.path; + if (selected && selected.path === resolvedPath && selected.connection?.type) + return { + adapter: normalizeAdapter(selected.connection.type), + source: 'connection', + projectPath: resolvedPath, + }; + if (!resolvedPath) return { adapter: null, source: 'unresolved' }; + try { + const projectConfig = yaml.load( + await fs.readFile(path.join(resolvedPath, 'dbt_project.yml'), 'utf8'), + ) as { profile?: string } | undefined; + const profiles = yaml.load( + await fs.readFile(path.join(resolvedPath, 'profiles.yml'), 'utf8'), + ) as Record | undefined; + const profile = + projectConfig?.profile && profiles?.[projectConfig.profile]; + const output = profile?.target + ? profile?.outputs?.[profile.target] + : Object.values(profile?.outputs ?? {})[0]; + return { + adapter: normalizeAdapter(output?.type), + source: output?.type ? 'profiles.yml' : 'unresolved', + projectPath: resolvedPath, + }; + } catch { + return { adapter: null, source: 'unresolved', projectPath: resolvedPath }; + } + } + + private static capabilityFor( + adapter: string | null, + source: DbtAdapterCapability['source'], + isV2: boolean, + ): DbtAdapterCapability { + if (!isV2) + return { + adapter, + displayName: adapterDisplayName(adapter), + status: adapter ? 'supported' : 'unknown', + driver: 'unknown', + requiresNetworkOnFirstUse: false, + canExecute: true, + source, + notes: adapter + ? 'dbt Core v1 uses separately installed Python adapter packages. Confirm the matching package is installed.' + : 'Could not determine the project adapter. Verify profiles.yml before running.', + }; + if (!adapter) + return { + adapter: null, + displayName: adapterDisplayName(null), + status: 'unknown', + driver: 'unknown', + requiresNetworkOnFirstUse: false, + canExecute: false, + source, + notes: + 'Rosetta could not determine this project adapter. v2 execution is blocked until the connection or profiles.yml identifies it.', + }; + const capability = V2_ADAPTERS[adapter]; + if (capability) + return { + adapter, + displayName: adapterDisplayName(adapter), + source, + ...capability, + }; + return { + adapter, + displayName: adapterDisplayName(adapter), + status: 'unsupported', + driver: 'unknown', + requiresNetworkOnFirstUse: false, + canExecute: false, + source, + notes: `${adapterDisplayName(adapter)} is not available in Rosetta's dbt Core v2 compatibility table. Switch to dbt Core v1 for this project.`, + }; + } + + static async getActiveAdapterCapabilities( + projectPath?: string, + ): Promise { + const settings = await SettingsService.loadSettings(); + let runtime: DbtAdapterCapabilityResponse['runtime'] = 'unknown'; + if (settings.dbtVersion?.startsWith('2.')) runtime = 'v2'; + if (settings.dbtVersion?.startsWith('1.')) runtime = 'v1'; + const resolved = await this.resolveProjectAdapter(projectPath); + const adapter = this.capabilityFor( + resolved.adapter, + resolved.source, + runtime === 'v2', + ); + return { + dbtCoreVersion: settings.dbtVersion || null, + runtime, + packageProvenance: + runtime === 'unknown' ? 'unverified' : 'apache-dbt-core', + projectPath: resolved.projectPath, + adapters: [adapter], + }; + } + + static async checkProjectAdapterCompatibility( + projectPath?: string, + ): Promise { + const result = await this.getActiveAdapterCapabilities(projectPath); + return { ...result, adapter: result.adapters[0] }; + } + + private static async inspectAdapterCompatibility( + targetVersion: string, + ): Promise { + const python = await getPythonExecutable(); + const target = parseVersionTriple(targetVersion); + const adapters: (DbtAdapterCompatibility | null)[] = await Promise.all( + ADAPTER_PACKAGES.map(async (packageName) => { + const result = await runProcess(python, [ + '-m', + 'pip', + 'show', + packageName, + ]); + const installedVersion = + result.exitCode === 0 ? parsePipShowVersion(result.stdout) : null; + if (!installedVersion) return null; + + const adapter = parseVersionTriple(installedVersion); + if (target && adapter && target.major === adapter.major) { + return { + packageName, + installedVersion, + status: 'likely-compatible' as const, + message: `Installed major version matches dbt-core ${targetVersion}; verify the adapter's published compatibility range.`, + }; + } + if (target && target.major >= 2) { + const message = + packageName === 'dbt-postgres' + ? `Postgres is not supported safely by dbt-core ${targetVersion}. Rosetta blocks v2 Postgres execution; use a stable dbt-core v1 release for Postgres projects.` + : `Compatibility with dbt-core ${targetVersion} is not proven. A v2-capable adapter may be required.`; + return { + packageName, + installedVersion, + status: 'warning' as const, + message, + }; + } + return { + packageName, + installedVersion, + status: 'unknown' as const, + message: `Confirm this adapter supports dbt-core ${targetVersion} after the version change.`, + }; + }), + ); + + return adapters.filter( + (adapter): adapter is DbtAdapterCompatibility => adapter !== null, + ); + } + + private static async fetchPypiProjectJson( + packageName: string, + ): Promise { + const url = `https://pypi.org/pypi/${encodeURIComponent(packageName)}/json`; + const res = await axios.get(url, { timeout: 15000 }); + return res.data as PypiProjectJson; + } +} diff --git a/src/main/services/dbtVersionManager.service.ts b/src/main/services/dbtVersionManager.service.ts deleted file mode 100644 index 5960d830..00000000 --- a/src/main/services/dbtVersionManager.service.ts +++ /dev/null @@ -1,196 +0,0 @@ -import axios from 'axios'; -import fs from 'fs-extra'; -import { CliAdapter } from '../adapters'; -import SettingsService from './settings.service'; -import { - DbtCoreVersionListItem, - DbtVersionListResponse, - PythonPackageInstallVersionRequest, - PythonPackageInstallVersionResponse, - PythonPackageVersionListItem, - PythonPackageVersionListResponse, -} from '../../types/backend'; - -type VersionTriple = { - major: number; - minor: number; - patch: number; - pre: string | null; -}; - -const parseVersionTriple = (version: string): VersionTriple | null => { - // Accepts: 1.8.2, 1.8, 1.8.0rc1, 1.8.0b2 - const cleaned = version.trim().replace(/^v/, ''); - const match = cleaned.match( - /^([0-9]+)(?:\.([0-9]+))?(?:\.([0-9]+))?([a-zA-Z].*)?$/, - ); - if (!match) return null; - - const major = Number(match[1] ?? 0); - const minor = Number(match[2] ?? 0); - const patch = Number(match[3] ?? 0); - const pre = match[4] ? match[4] : null; - - if (Number.isNaN(major) || Number.isNaN(minor) || Number.isNaN(patch)) { - return null; - } - - return { major, minor, patch, pre }; -}; - -const compareVersions = (a: string, b: string): number => { - const va = parseVersionTriple(a); - const vb = parseVersionTriple(b); - if (!va || !vb) return a.localeCompare(b); - - if (va.major !== vb.major) return va.major > vb.major ? 1 : -1; - if (va.minor !== vb.minor) return va.minor > vb.minor ? 1 : -1; - if (va.patch !== vb.patch) return va.patch > vb.patch ? 1 : -1; - - // Stable > prerelease - if (va.pre && !vb.pre) return -1; - if (!va.pre && vb.pre) return 1; - - if (va.pre === vb.pre) return 0; - return String(va.pre).localeCompare(String(vb.pre)); -}; - -const isPrerelease = (version: string): boolean => { - const v = parseVersionTriple(version); - return !!v?.pre; -}; - -const getPythonExecutable = async (pythonPath?: string): Promise => { - if (pythonPath) { - return `"${pythonPath}"`; - } - - const settings = await SettingsService.loadSettings(); - if (settings.pythonPath && (await fs.pathExists(settings.pythonPath))) { - return `"${settings.pythonPath}"`; - } - - return 'python'; -}; - -type PypiProjectJson = { - releases?: Record; -}; - -export class DbtVersionManagerService { - static async listDbtCoreVersions(): Promise { - let projectJson: PypiProjectJson; - - try { - projectJson = await this.fetchPypiProjectJson('dbt-core'); - } catch { - return { - versions: [], - latestStable: null, - currentVersion: - (await SettingsService.loadSettings()).dbtVersion || null, - }; - } - - const versions = Object.keys(projectJson.releases ?? {}) - .filter((v) => parseVersionTriple(v) !== null) - .filter((v) => !isPrerelease(v)) - .sort((a, b) => compareVersions(b, a)); - - const latestStable = versions.length > 0 ? versions[0] : null; - const latestFive = versions.slice(0, 5); - - const out: DbtCoreVersionListItem[] = latestFive.map((v) => ({ - version: v, - isPrerelease: false, - })); - - const settings = await SettingsService.loadSettings(); - - return { - versions: out, - latestStable, - currentVersion: settings.dbtVersion || null, - }; - } - - static async listPackageVersions( - packageName: string | undefined, - ): Promise { - const safeName = String(packageName ?? '').trim(); - if (!safeName) { - return { - packageName: '', - versions: [], - latestStable: null, - }; - } - - let projectJson: PypiProjectJson; - - try { - projectJson = await this.fetchPypiProjectJson(safeName); - } catch { - return { - packageName: safeName, - versions: [], - latestStable: null, - }; - } - - const versions = Object.keys(projectJson.releases ?? {}) - .filter((v) => parseVersionTriple(v) !== null) - .filter((v) => !isPrerelease(v)) - .sort((a, b) => compareVersions(b, a)); - - const latestStable = versions.length > 0 ? versions[0] : null; - const latestFive = versions.slice(0, 5); - - const out: PythonPackageVersionListItem[] = latestFive.map((v) => ({ - version: v, - isPrerelease: false, - })); - - return { - packageName: safeName, - versions: out, - latestStable, - }; - } - - static async installPackageVersion( - req: PythonPackageInstallVersionRequest, - ): Promise { - const python = await getPythonExecutable(req.pythonPath); - const safeName = String(req.packageName ?? '').trim(); - const safeVersion = String(req.version ?? '').trim(); - - if (!safeName) { - return { ok: false, error: 'packageName is required' }; - } - if (!safeVersion) { - return { ok: false, error: 'version is required' }; - } - - const cliAdapter = new CliAdapter(); - try { - await cliAdapter.runCommandWithoutStreaming( - `${python} -m pip install --upgrade --force-reinstall --no-cache-dir ${safeName}==${safeVersion}`, - ); - return { ok: true }; - } catch (error) { - return { - ok: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - } - - private static async fetchPypiProjectJson( - packageName: string, - ): Promise { - const url = `https://pypi.org/pypi/${encodeURIComponent(packageName)}/json`; - const res = await axios.get(url, { timeout: 15000 }); - return res.data as PypiProjectJson; - } -} diff --git a/src/renderer/components/chat/AgentStepBlock.tsx b/src/renderer/components/chat/AgentStepBlock.tsx index 663bd302..629464d6 100644 --- a/src/renderer/components/chat/AgentStepBlock.tsx +++ b/src/renderer/components/chat/AgentStepBlock.tsx @@ -33,6 +33,7 @@ export const AgentStepBlock: React.FC = ({ case 'writeFile': writes.push(tc); break; + case 'studio_cli_run_dbt': case 'runDbtCommand': runs.push(tc); break; diff --git a/src/renderer/components/chat/MessageRenderer.tsx b/src/renderer/components/chat/MessageRenderer.tsx index 36e68a33..2f38adc6 100644 --- a/src/renderer/components/chat/MessageRenderer.tsx +++ b/src/renderer/components/chat/MessageRenderer.tsx @@ -16,6 +16,10 @@ import { Terminal, } from '@mui/icons-material'; import Collapse from '@mui/material/Collapse'; +import { + getToolResultError, + isToolResultFailure, +} from '../../../shared/toolResult'; import { useAppContext } from '../../hooks'; import { useSaveFileContent } from '../../controllers'; @@ -95,13 +99,14 @@ function buildStepsFromToolCalls( failed: 'error', }; + const resultFailed = isToolResultFailure(tc.toolOutput); const toolCallState: ToolCallState = { id: toolCallId, toolName: tc.toolName, args: displayArgs as Record, result: tc.toolOutput, - error: tc.errorMessage ?? undefined, - status: statusMap[tc.status] ?? 'done', + error: tc.errorMessage ?? getToolResultError(tc.toolOutput) ?? undefined, + status: resultFailed ? 'error' : (statusMap[tc.status] ?? 'done'), }; if (!stepMap.has(stepNumber)) { diff --git a/src/renderer/components/chat/ToolCallRow.tsx b/src/renderer/components/chat/ToolCallRow.tsx index 19bb54a8..a9bb4136 100644 --- a/src/renderer/components/chat/ToolCallRow.tsx +++ b/src/renderer/components/chat/ToolCallRow.tsx @@ -16,6 +16,10 @@ import SearchIcon from '@mui/icons-material/Search'; import HandymanIcon from '@mui/icons-material/Handyman'; import { useTheme } from '@mui/material/styles'; +import { + getToolResultError, + isToolResultFailure, +} from '../../../shared/toolResult'; import { FileTypeBadge } from '../../utils/fileTypeIcon'; import type { ToolCallState } from '../../hooks/useAgentStream'; import { renderArguments, renderResult } from './ToolCallFormatters'; @@ -286,7 +290,9 @@ export const ToolCallRow: React.FC = ({ }; const { icon, label, suffix } = getToolDisplayInfo(); - const hasError = toolCall.status === 'error'; + const hasError = + toolCall.status === 'error' || isToolResultFailure(toolCall.result); + const resultError = toolCall.error ?? getToolResultError(toolCall.result); const safeStringify = (obj: any) => { try { const seen = new WeakSet(); @@ -419,12 +425,8 @@ export const ToolCallRow: React.FC = ({ overflowX: 'auto', }} > - {hasError && toolCall.error && ( - - {typeof toolCall.error === 'string' - ? toolCall.error - : (toolCall.error as any)?.message || String(toolCall.error)} - + {hasError && resultError && ( + {resultError} )} diff --git a/src/renderer/components/settings/DbtSettings.tsx b/src/renderer/components/settings/DbtSettings.tsx index e6d86599..b2c8812b 100644 --- a/src/renderer/components/settings/DbtSettings.tsx +++ b/src/renderer/components/settings/DbtSettings.tsx @@ -21,6 +21,10 @@ import { Accordion, AccordionSummary, AccordionDetails, + Dialog, + DialogActions, + DialogContent, + DialogTitle, } from '@mui/material'; import { Info, @@ -34,23 +38,60 @@ import { ExpandMore, } from '@mui/icons-material'; import { + DbtAdapterCapabilityResponse, + DbtProjectCompatibilityResult, + DbtVersionChangePlan, DbtVersionListResponse, + PythonPackageInstallVersionResponse, PythonPackageVersionListResponse, SettingsType, } from '../../../types/backend'; -import { useCli } from '../../hooks'; -import { settingsServices } from '../../services'; import { - listDbtCoreVersions, - installPackageVersion, - listPackageVersions, -} from '../../services/dbtVersions.service'; + useCheckCurrentProjectCompatibility, + useGetActiveAdapterCapabilities, + useGetInstalledDbtCore, + useGetInstalledPackages, + useInstallDbtVersionChange, + useInstallLatestPackage, + useInstallPackageVersion, + useListDbtCoreVersions, + useListPackageVersions, + usePlanDbtVersionChange, + useUninstallPackage, +} from '../../controllers'; interface DbtSettingsProps { settings: SettingsType; onInstallDbtSave: (key: string, value: string) => void; } +const RuntimeLanguageIcon = ({ language }: { language: 'python' | 'rust' }) => ( + +); + export const DbtSettings: React.FC = ({ settings, onInstallDbtSave, @@ -58,7 +99,18 @@ export const DbtSettings: React.FC = ({ const [isLoadingInstall, setIsLoadingInstall] = React.useState(false); const [currentPackage, setCurrentPackage] = React.useState(''); const [installProgress, setInstallProgress] = React.useState(0); - const { runCommand } = useCli(); + const listDbtCoreVersions = useListDbtCoreVersions(); + const getInstalledDbtCore = useGetInstalledDbtCore(); + const getInstalledPackages = useGetInstalledPackages(); + const installPackageVersion = useInstallPackageVersion(); + const installLatestPackage = useInstallLatestPackage(); + const uninstallPackage = useUninstallPackage(); + const listPackageVersions = useListPackageVersions(); + const planDbtVersionChange = usePlanDbtVersionChange(); + const installDbtVersionChange = useInstallDbtVersionChange(); + const checkCurrentProjectCompatibility = + useCheckCurrentProjectCompatibility(); + const getActiveAdapterCapabilities = useGetActiveAdapterCapabilities(); const [isLoadingDialog, setIsLoadingDialog] = React.useState(false); const [loadingMessage, setLoadingMessage] = React.useState(''); @@ -86,6 +138,18 @@ export const DbtSettings: React.FC = ({ React.useState(null); const [isCheckingDbtCoreVersions, setIsCheckingDbtCoreVersions] = React.useState(false); + const [showOlderVersions, setShowOlderVersions] = React.useState(false); + const [versionChangePlan, setVersionChangePlan] = + React.useState(null); + const [isVersionChangeDialogOpen, setIsVersionChangeDialogOpen] = + React.useState(false); + const [runProjectCheck, setRunProjectCheck] = React.useState(true); + const [versionChangeResult, setVersionChangeResult] = + React.useState(null); + const [compatibilityResult, setCompatibilityResult] = + React.useState(null); + const [adapterCapabilities, setAdapterCapabilities] = + React.useState(null); const [packageVersions, setPackageVersions] = React.useState< Record @@ -128,6 +192,14 @@ export const DbtSettings: React.FC = ({ return 0; }; + const getAdapterAlertSeverity = ( + status: 'likely-compatible' | 'warning' | 'unknown', + ): 'success' | 'warning' | 'info' => { + if (status === 'likely-compatible') return 'success'; + if (status === 'warning') return 'warning'; + return 'info'; + }; + const handlePackageToggle = (packageName: string) => { if (packageName === 'dbt-core') return; // Don't allow unchecking dbt-core @@ -142,18 +214,8 @@ export const DbtSettings: React.FC = ({ setLoadingMessage('Checking dbt version...'); try { - const python = settings.pythonPath - ? `"${settings.pythonPath}"` - : 'python'; - const result = await runCommand(`${python} -m pip show dbt-core`); - - if (result.error.length > 0) { - return null; - } - - const outputText = result.output.join('\n'); - const versionMatch = outputText.match(/Version:\s*(.+)/); - return versionMatch ? versionMatch[1].trim() : null; + const installed = await getInstalledDbtCore(); + return installed.isExecutableVerified ? installed.version : null; } catch (error) { return null; } finally { @@ -183,52 +245,57 @@ export const DbtSettings: React.FC = ({ setIsLoadingDialog(true); try { - if (settings.pythonPath) { - const python = `"${settings.pythonPath}"`; - - setCurrentPackage('Setting up pip...'); - setLoadingMessage('Setting up pip...'); - try { - await runCommand(`${python} -m ensurepip --upgrade`); - } catch { - /* Continue even if this fails */ - } + if (!settings.pythonPath) { + throw new Error( + 'Managed Python environment not found. Install Python first.', + ); + } - for (let i = 0; i < packages.length; i++) { - const pkg = packages[i]; - setCurrentPackage(pkg); - setLoadingMessage(`Installing ${pkg}...`); - setInstallProgress((i / packages.length) * 100); + const availableDbtVersions = + dbtCoreVersions ?? (await listDbtCoreVersions({ limit: 5 })); + const targetDbtVersion = availableDbtVersions.latestStable; + if (!targetDbtVersion) { + throw new Error('No stable dbt-core version is available.'); + } - try { - await runCommand(`${python} -m pip install ${pkg}`); - } catch { - /* Continue with next package */ + for (let i = 0; i < packages.length; i++) { + const pkg = packages[i]; + setCurrentPackage(pkg); + setLoadingMessage(`Installing ${pkg}...`); + setInstallProgress((i / packages.length) * 100); + + if (pkg === 'dbt-core') { + const result = await installPackageVersion({ + pythonPath: settings.pythonPath, + packageName: pkg, + version: targetDbtVersion, + }); + if (!result.ok) { + setVersionChangeResult(result); + break; } - } - } else { - try { - for (let i = 0; i < packages.length; i++) { - const pkg = packages[i]; - setCurrentPackage(pkg); - setInstallProgress((i / packages.length) * 100); - - try { - await runCommand(`pip install ${pkg}`); - } catch { - /* Continue with next package */ - } + if (result.dbtPath) { + onInstallDbtSave('dbtPath', result.dbtPath); + } + if (result.installedVersion) { + onInstallDbtSave('dbtVersion', result.installedVersion); + } + } else { + const result = await installLatestPackage({ + pythonPath: settings.pythonPath, + packageName: pkg, + }); + if (!result.ok) { + setVersionChangeResult({ + ok: false, + error: result.error || `Unable to install ${pkg}.`, + }); } - } catch { - /* empty */ } } - - // Get and save the dbt path - setCurrentPackage('Locating dbt path...'); setInstallProgress(100); - const dbtPath = await settingsServices.getDbtPath(); - onInstallDbtSave('dbtPath', dbtPath); + // eslint-disable-next-line no-use-before-define + await checkInstalledPackages(); } finally { setIsLoadingInstall(false); setCurrentPackage(''); @@ -236,93 +303,16 @@ export const DbtSettings: React.FC = ({ } }; - const checkPackagesIndividually = async ( - python: string, - packages: string[], - installed: { [key: string]: string }, - ) => { - for (const pkg of packages) { - if (!isCheckingPackages) break; - - try { - await new Promise((resolve) => { - setTimeout(resolve, 200); - }); - - const result = await runCommand(`${python} -m pip show ${pkg}`); - - if (result.output.length > 0 && result.error.length === 0) { - const outputText = result.output.join('\n'); - const versionMatch = outputText.match(/Version:\s*(.+)/); - if (versionMatch) { - installed[pkg] = versionMatch[1].trim(); - } - } - } catch { - /* empty */ - } - } - }; - async function checkInstalledPackages(): Promise { if (isCheckingPackages) return; setIsCheckingPackages(true); - const python = settings.pythonPath ? `"${settings.pythonPath}"` : 'python'; - const packages = Object.keys(packageDescriptions); - const installed: { [key: string]: string } = {}; - try { - const result = await runCommand(`${python} -m pip list --format=json`); - - if (result.output.length > 0 && result.error.length === 0) { - const outputText = result.output.join('\n').trim(); - - try { - const lines = outputText.split('\n'); - let jsonStartIndex = -1; - let jsonEndIndex = -1; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line.startsWith('[') && jsonStartIndex === -1) { - jsonStartIndex = i; - } - if ( - line.endsWith(']') && - jsonStartIndex !== -1 && - jsonEndIndex === -1 - ) { - jsonEndIndex = i; - break; - } - } - - if (jsonStartIndex !== -1 && jsonEndIndex !== -1) { - const jsonLines = lines.slice(jsonStartIndex, jsonEndIndex + 1); - const jsonString = jsonLines.join('\n'); - - const pipList = JSON.parse(jsonString); - - packages.forEach((pkg) => { - const found = pipList.find((item: any) => item.name === pkg); - if (found) { - installed[pkg] = found.version; - } - }); - } else { - await checkPackagesIndividually(python, packages, installed); - } - } catch (parseError) { - await checkPackagesIndividually(python, packages, installed); - } - } else { - await checkPackagesIndividually(python, packages, installed); - } - } catch (error) { - await checkPackagesIndividually(python, packages, installed); + const result = await getInstalledPackages(); + setInstalledPackages(result.packages); + } catch { + setInstalledPackages({}); } finally { - setInstalledPackages(installed); setIsCheckingPackages(false); } } @@ -330,7 +320,10 @@ export const DbtSettings: React.FC = ({ const refreshDbtCoreVersions = async () => { setIsCheckingDbtCoreVersions(true); try { - const data = await listDbtCoreVersions(); + const data = await listDbtCoreVersions({ + includePrerelease: true, + limit: 100, + }); setDbtCoreVersions(data); } finally { setIsCheckingDbtCoreVersions(false); @@ -352,20 +345,77 @@ export const DbtSettings: React.FC = ({ }); if (!res.ok) { + setVersionChangeResult(res); return; } + await checkInstalledPackages(); + } finally { + setInstallingPackageKey(null); + setIsLoadingDialog(false); + setLoadingMessage(''); + } + }; - if (packageName === 'dbt-core') { - const installedVersion = await getDbtVersion(); - if (installedVersion) { - onInstallDbtSave('dbtVersion', installedVersion); + const prepareDbtVersionChange = async (version: string) => { + setIsLoadingDialog(true); + setLoadingMessage(`Planning dbt-core ${version} change...`); + setVersionChangeResult(null); + setCompatibilityResult(null); + try { + const plan = await planDbtVersionChange({ + targetVersion: version, + includeAdapters: true, + }); + setVersionChangePlan(plan); + setRunProjectCheck(plan.isMajorVersionChange); + setIsVersionChangeDialogOpen(true); + } catch (error) { + setVersionChangeResult({ + ok: false, + error: + error instanceof Error ? error.message : 'Unable to plan change.', + }); + } finally { + setIsLoadingDialog(false); + setLoadingMessage(''); + } + }; + + const confirmDbtVersionChange = async () => { + if (!versionChangePlan) return; + setIsVersionChangeDialogOpen(false); + setIsLoadingDialog(true); + setInstallingPackageKey(`dbt-core@${versionChangePlan.targetVersion}`); + setLoadingMessage( + `Installing dbt-core==${versionChangePlan.targetVersion}...`, + ); + try { + const result = await installDbtVersionChange({ + targetVersion: versionChangePlan.targetVersion, + includeAdapters: true, + pythonPath: settings.pythonPath, + }); + setVersionChangeResult(result); + if (result.ok) { + if (result.dbtPath) onInstallDbtSave('dbtPath', result.dbtPath); + if (result.installedVersion) { + onInstallDbtSave('dbtVersion', result.installedVersion); } await refreshDbtCoreVersions(); - } - - if (settings.dbtPath && settings.dbtPath !== 'dbt') { await checkInstalledPackages(); + if (runProjectCheck) { + setLoadingMessage( + 'Checking the current project with dbt parse and compile...', + ); + setCompatibilityResult(await checkCurrentProjectCompatibility()); + } } + } catch (error) { + setVersionChangeResult({ + ok: false, + error: + error instanceof Error ? error.message : 'Version change failed.', + }); } finally { setInstallingPackageKey(null); setIsLoadingDialog(false); @@ -373,8 +423,11 @@ export const DbtSettings: React.FC = ({ } }; - const handleInstallDbtCoreVersionClick = (version: string) => { - installSinglePackageVersion('dbt-core', version).catch(() => undefined); + const handleRollback = () => { + if (!versionChangeResult?.previousVersion) return; + prepareDbtVersionChange(versionChangeResult.previousVersion).catch( + () => undefined, + ); }; const fetchPackageVersions = async (packageName: string) => { @@ -413,10 +466,14 @@ export const DbtSettings: React.FC = ({ setLoadingMessage(`Uninstalling ${packageName}...`); try { - const python = settings.pythonPath - ? `"${settings.pythonPath}"` - : 'python'; - await runCommand(`${python} -m pip uninstall -y ${packageName}`); + const result = await uninstallPackage({ + pythonPath: settings.pythonPath, + packageName, + }); + if (!result.ok) { + setVersionChangeResult(result); + return; + } setInstalledPackages((prev) => { const updated = { ...prev }; @@ -426,6 +483,7 @@ export const DbtSettings: React.FC = ({ if (packageName === 'dbt-core') { onInstallDbtSave('dbtPath', ''); + onInstallDbtSave('dbtVersion', ''); } } finally { setIsLoadingInstall(false); @@ -440,22 +498,17 @@ export const DbtSettings: React.FC = ({ setLoadingMessage(`Installing ${packageName}...`); try { - const python = settings.pythonPath - ? `"${settings.pythonPath}"` - : 'python'; - await runCommand(`${python} -m pip install ${packageName}`); - - // Check if the package was installed successfully - const result = await runCommand(`${python} -m pip show ${packageName}`); - if (result.output.length > 0 && result.error.length === 0) { - const outputText = result.output.join('\n'); - const versionMatch = outputText.match(/Version:\s*(.+)/); - const version = versionMatch ? versionMatch[1].trim() : 'unknown'; - + const result = await installLatestPackage({ + pythonPath: settings.pythonPath, + packageName, + }); + if (result.ok && result.installedVersion) { setInstalledPackages((prev) => ({ ...prev, - [packageName]: version, + [packageName]: result.installedVersion as string, })); + } else if (!result.ok) { + setVersionChangeResult(result); } } finally { setIsLoadingInstall(false); @@ -518,6 +571,12 @@ export const DbtSettings: React.FC = ({ refreshDbtCoreVersions().catch(() => undefined); }, []); + useEffect(() => { + getActiveAdapterCapabilities() + .then(setAdapterCapabilities) + .catch(() => setAdapterCapabilities(null)); + }, [getActiveAdapterCapabilities, settings.dbtVersion]); + const handleRefreshDbtCoreVersionsClick = () => { refreshDbtCoreVersions().catch(() => undefined); }; @@ -526,8 +585,120 @@ export const DbtSettings: React.FC = ({ checkInstalledPackages().catch(() => undefined); }; + const availableVersions = dbtCoreVersions?.versions ?? []; + const pythonDbtVersions = availableVersions + .filter( + (item) => Number.parseInt(item.version, 10) === 1 && !item.isPrerelease, + ) + .slice(0, showOlderVersions ? 15 : 5); + const rustDbtVersions = availableVersions + .filter((item) => Number.parseInt(item.version, 10) >= 2) + .slice(0, showOlderVersions ? 15 : 5); + + const renderVersionList = ( + versions: DbtVersionListResponse['versions'], + emptyMessage: string, + ) => { + if (versions.length === 0) { + return {emptyMessage}; + } + + return ( + + {versions.map((item, index) => { + const installed = settings.dbtVersion; + const isInstalled = item.isInstalled || installed === item.version; + const isLatest = item.isLatestStable; + + let actionLabel = installed ? 'Downgrade' : 'Install'; + if (isInstalled) { + actionLabel = 'Installed'; + } else if (item.isPrerelease) { + actionLabel = 'Install Preview'; + } else if ( + installed && + compareSimpleVersions(item.version, installed) > 0 + ) { + actionLabel = 'Upgrade'; + } + + return ( + + + + + {item.version} + + {isInstalled && ( + <> + + + + )} + {isLatest && !item.isPrerelease && ( + + )} + {item.isPrerelease && ( + + )} + + } + /> + + + + + {index < versions.length - 1 && } + + ); + })} + + ); + }; + return ( - + = ({ {settings.dbtPath && settings.dbtPath !== 'dbt' ? ( - dbt™ is installed at: {settings.dbtPath} + {settings.dbtVersion?.startsWith('2.') + ? 'dbt Core v2 (Rust)' + : 'dbt Core v1 (Python)'}{' '} + is active at: {settings.dbtPath} {settings?.dbtVersion && ( Version: {settings?.dbtVersion} @@ -561,322 +735,501 @@ export const DbtSettings: React.FC = ({ ) : null} - - Available dbt-core versions (latest 5) - {isCheckingDbtCoreVersions && } - - + {isCheckingDbtCoreVersions && } + + + + - {(dbtCoreVersions?.versions ?? []).length > 0 ? ( - + Changing the active dbt runtime affects all local projects. Preview + versions are hidden by default and should be tested before production + use. + + + {versionChangeResult && ( + + {versionChangeResult.ok + ? `Verified dbt-core ${versionChangeResult.installedVersion} is now active.` + : versionChangeResult.error || + 'The dbt-core version change failed.'} + {versionChangeResult.ok && versionChangeResult.previousVersion && ( + + )} + + )} + + {compatibilityResult && ( + + + {compatibilityResult.ok + ? `Project ${compatibilityResult.projectName} passed dbt parse and compile.` + : `Project ${compatibilityResult.projectName || ''} has migration diagnostics.`} + + {compatibilityResult.error && ( + + {compatibilityResult.error} + + )} + {compatibilityResult.diagnostics + .filter((diagnostic) => !diagnostic.ok) + .map((diagnostic) => ( + + + dbt {diagnostic.command} failed + + + {diagnostic.summary} + + + ))} + {compatibilityResult.recommendations.map((recommendation) => ( + + {recommendation} + + ))} + + )} + + + - {(dbtCoreVersions?.versions ?? []).map((item) => { - const installed = settings.dbtVersion; - const isInstalled = installed === item.version; - const isLatest = - item.version === (dbtCoreVersions?.latestStable ?? null); - - let actionLabel = 'Downgrade'; - if (isInstalled) { - actionLabel = 'Installed'; - } else if ( - installed && - compareSimpleVersions(item.version, installed) > 0 - ) { - actionLabel = 'Upgrade'; - } - - return ( - - - - - {item.version} - - - {isInstalled && ( - - - - - )} + + + + + + dbt Core v1 + + + Python engine · Stable runtime + + + + + + + + + + Recommended for production projects and broad adapter + compatibility. + + + {renderVersionList( + pythonDbtVersions, + 'No dbt Core v1 releases are available.', + )} + + - {isLatest && !item.isPrerelease && ( - - )} - - } - /> - - - - - - - ); - })} - - ) : ( - No versions available. - )} + + + + + + + dbt Core v2 + + + Rust engine · Preview runtime + + + + + + + + + + + Preview releases are available in Rosetta DBT Studio. Validate + project and adapter compatibility before production use. + + + {renderVersionList( + rustDbtVersions, + 'No dbt Core v2 preview releases are available.', + )} + + + - Adapter packages + {settings.dbtVersion?.startsWith('2.') + ? 'Project adapter compatibility' + : 'Adapter packages'} - {( - [ - 'dbt-postgres', - 'dbt-snowflake', - 'dbt-bigquery', - 'dbt-redshift', - 'dbt-databricks', - 'dbt-duckdb', - ] as const - ).map((pkg) => { - const installed = installedPackages[pkg]; - const versions = packageVersions[pkg]?.versions ?? []; - const latestStable = packageVersions[pkg]?.latestStable ?? null; - const isLoading = isCheckingPackageVersions[pkg] ?? false; + {settings.dbtVersion?.startsWith('2.') && adapterCapabilities && ( + + + dbt Core v2 includes its own adapter runtime. Python adapter + packages are used only by dbt Core v1 and do not enable dbt Core + v2 adapters. + + {adapterCapabilities.adapters.map((adapter) => ( + + + {adapter.displayName} + + + + + {adapter.notes} + + + ))} + + )} - return ( - - }> - - - - {pkg} + {!settings.dbtVersion?.startsWith('2.') && + ( + [ + 'dbt-postgres', + 'dbt-snowflake', + 'dbt-bigquery', + 'dbt-redshift', + 'dbt-databricks', + 'dbt-duckdb', + ] as const + ).map((pkg) => { + const installed = installedPackages[pkg]; + const versions = packageVersions[pkg]?.versions ?? []; + const latestStable = packageVersions[pkg]?.latestStable ?? null; + const isLoading = isCheckingPackageVersions[pkg] ?? false; + + return ( + + }> + + + + {pkg} + + {installed && ( + + )} + + + {packageDescriptions[pkg]} - {installed && ( - - )} - - {packageDescriptions[pkg]} - - - - - - - - {installed && ( - )} - - {versions.length > 0 ? ( - - {versions.map((v) => { - const isInstalled = installed === v.version; - const isLatest = v.version === latestStable; - - let actionLabel = 'Install'; - if (isInstalled) { - actionLabel = 'Installed'; - } else if ( - installed && - compareSimpleVersions(v.version, installed) > 0 - ) { - actionLabel = 'Upgrade'; - } else if (installed) { - actionLabel = 'Downgrade'; - } + {installed && ( + + )} + - return ( - - - - 0 ? ( + + {versions.map((v) => { + const isInstalled = installed === v.version; + const isLatest = v.version === latestStable; + + let actionLabel = 'Install'; + if (isInstalled) { + actionLabel = 'Installed'; + } else if ( + installed && + compareSimpleVersions(v.version, installed) > 0 + ) { + actionLabel = 'Upgrade'; + } else if (installed) { + actionLabel = 'Downgrade'; + } + + return ( + + + - {v.version} - - - {isInstalled && ( - - + {v.version} + + + {isInstalled && ( + + + + + )} + + {isLatest && !v.isPrerelease && ( - - )} - - {isLatest && !v.isPrerelease && ( - - )} - - } - /> - - - - - - - ); - })} - - ) : ( - - Click "Load Versions" to view versions. - - )} - - - ); - })} + /> + + + + + + + ); + })} + + ) : ( + + Click "Load Versions" to view versions. + + )} + + + ); + })} {settings.dbtPath && settings.dbtPath !== 'dbt' ? ( @@ -1074,6 +1427,103 @@ export const DbtSettings: React.FC = ({ )} + setIsVersionChangeDialogOpen(false)} + fullWidth + maxWidth="sm" + > + Confirm global dbt-core version change + + {versionChangePlan && ( + + + Current version:{' '} + {versionChangePlan.currentVersion || 'Not installed'} + + + Target version: {versionChangePlan.targetVersion} + + + Change: {versionChangePlan.direction} ( + {versionChangePlan.channel}) + + + + {versionChangePlan.globalImpactWarning} + + + {versionChangePlan.warnings.map((warning) => ( + + {warning} + + ))} + + {versionChangePlan.adapters.length > 0 && ( + + + Installed adapter compatibility + + {versionChangePlan.adapters.map((adapter) => ( + + {adapter.packageName} {adapter.installedVersion}:{' '} + {adapter.message} + + ))} + + )} + + {(versionChangePlan.channel === 'preview' || + versionChangePlan.targetVersion.startsWith('2.')) && ( + + Rosetta will install only the Apache-licensed dbt-core package + and will block an ambiguous or proprietary dbt distribution. + + )} + + + setRunProjectCheck(event.target.checked) + } + /> + } + label="Check current project after install (dbt parse and compile)" + /> + + Artifacts and logs are redirected to a temporary directory. dbt + deps is not run automatically because it can modify dependencies + and require network access. + + + )} + + + + + + + theme.zIndex.drawer + 1, color: '#fff' }} diff --git a/src/renderer/controllers/dbtVersions.controller.ts b/src/renderer/controllers/dbtVersions.controller.ts new file mode 100644 index 00000000..da2dd77a --- /dev/null +++ b/src/renderer/controllers/dbtVersions.controller.ts @@ -0,0 +1,99 @@ +import { useCallback } from 'react'; +import { + DbtAdapterCapabilityResponse, + DbtProjectAdapterCheck, + DbtProjectCompatibilityResult, + DbtVersionChangePlan, + DbtVersionChangePlanRequest, + DbtVersionChangeRequest, + DbtVersionListOptions, + DbtVersionListResponse, + InstalledDbtCoreInfo, + InstalledPythonPackagesResponse, + PythonPackageActionRequest, + PythonPackageInstallVersionRequest, + PythonPackageInstallVersionResponse, + PythonPackageVersionListRequest, + PythonPackageVersionListResponse, +} from '../../types/backend'; +import * as dbtVersionsService from '../services/dbtVersions.service'; + +export const useListDbtCoreVersions = (): (( + options?: DbtVersionListOptions, +) => Promise) => + useCallback( + (options = {}) => dbtVersionsService.listDbtCoreVersions(options), + [], + ); + +export const useGetInstalledDbtCore = + (): (() => Promise) => + useCallback(() => dbtVersionsService.getInstalledDbtCore(), []); + +export const usePlanDbtVersionChange = (): (( + request: DbtVersionChangePlanRequest, +) => Promise) => + useCallback((request) => dbtVersionsService.planVersionChange(request), []); + +export const useInstallDbtVersionChange = (): (( + request: DbtVersionChangeRequest, +) => Promise) => + useCallback( + (request) => dbtVersionsService.installVersionChange(request), + [], + ); + +export const useCheckCurrentProjectCompatibility = + (): (() => Promise) => + useCallback( + () => dbtVersionsService.checkCurrentProjectCompatibility(), + [], + ); + +export const useCheckProjectAdapterCompatibility = (): (( + projectPath?: string, +) => Promise) => + useCallback( + (projectPath) => + dbtVersionsService.checkProjectAdapterCompatibility(projectPath), + [], + ); + +export const useGetActiveAdapterCapabilities = (): (( + projectPath?: string, +) => Promise) => + useCallback( + (projectPath) => + dbtVersionsService.getActiveAdapterCapabilities(projectPath), + [], + ); + +export const useGetInstalledPackages = + (): (() => Promise) => + useCallback(() => dbtVersionsService.getInstalledPackages(), []); + +export const useInstallLatestPackage = (): (( + request: PythonPackageActionRequest, +) => Promise) => + useCallback( + (request) => dbtVersionsService.installLatestPackage(request), + [], + ); + +export const useUninstallPackage = (): (( + request: PythonPackageActionRequest, +) => Promise) => + useCallback((request) => dbtVersionsService.uninstallPackage(request), []); + +export const useListPackageVersions = (): (( + request: PythonPackageVersionListRequest, +) => Promise) => + useCallback((request) => dbtVersionsService.listPackageVersions(request), []); + +export const useInstallPackageVersion = (): (( + request: PythonPackageInstallVersionRequest, +) => Promise) => + useCallback( + (request) => dbtVersionsService.installPackageVersion(request), + [], + ); diff --git a/src/renderer/controllers/index.ts b/src/renderer/controllers/index.ts index 4ef62f1b..8efe7601 100644 --- a/src/renderer/controllers/index.ts +++ b/src/renderer/controllers/index.ts @@ -15,3 +15,4 @@ export * from './mcp.controller'; export * from './skills.controller'; export * from './editor.controller'; export * from './analyticsPages.controller'; +export * from './dbtVersions.controller'; diff --git a/src/renderer/hooks/useAgentStream.ts b/src/renderer/hooks/useAgentStream.ts index c5047ae2..6e32a4df 100644 --- a/src/renderer/hooks/useAgentStream.ts +++ b/src/renderer/hooks/useAgentStream.ts @@ -9,6 +9,10 @@ import * as agentService from '../services/agent.service'; import { parseAgentError } from '../utils/agentErrorParser'; import { QUERY_KEYS } from '../config/constants'; import type { ParsedAgentError } from '../utils/agentErrorParser'; +import { + getToolResultError, + isToolResultFailure, +} from '../../shared/toolResult'; // --------------------------------------------------------------------------- // Content part types — ordered interleaved stream units (Kiro/Claude style) @@ -241,11 +245,17 @@ export const useAgentStream = (sessionId: number | undefined) => { case 'tool-result': { const resultToolCallId = (chunk as any).toolCallId as string; const { output } = chunk as any; + const failed = isToolResultFailure(output); setStreamState((prev) => withParts(prev, (parts) => parts.map((p) => p.type === 'tool-call' && p.toolCallId === resultToolCallId - ? { ...p, result: output, status: 'done' as const } + ? { + ...p, + result: output, + error: failed ? getToolResultError(output) : undefined, + status: failed ? ('error' as const) : ('done' as const), + } : p, ), ), diff --git a/src/renderer/hooks/useCli.tsx b/src/renderer/hooks/useCli.tsx index bbeeaac2..490f2984 100644 --- a/src/renderer/hooks/useCli.tsx +++ b/src/renderer/hooks/useCli.tsx @@ -8,12 +8,19 @@ import React, { ReactNode, } from 'react'; import { projectsServices } from '../services'; +import { CliProcessEnvironment } from '../../types/backend'; + +export interface CliCommandResult { + output: string[]; + error: string[]; + exitCode: number | null; +} interface CliCommand { id: string; command: string; startTime: number; - resolve?: (result: { output: string[]; error: string[] }) => void; + resolve?: (result: CliCommandResult) => void; reject?: (error: Error) => void; // eslint-disable-next-line no-undef timeoutId?: NodeJS.Timeout; @@ -32,8 +39,13 @@ interface CliContextValue extends CliState { command: string, args?: string[], timeoutMs?: number, - ) => Promise<{ output: string[]; error: string[] }>; - runCommandAsync: (command: string, args?: string[]) => void; // Fire and forget for terminal usage + environment?: CliProcessEnvironment, + ) => Promise; + runCommandAsync: ( + command: string, + args?: string[], + environment?: CliProcessEnvironment, + ) => void; // Fire and forget for terminal usage stopCommand: () => void; clearOutput: () => void; sendInput: (input: string) => void; @@ -86,13 +98,15 @@ export const CliProvider: React.FC = ({ children }) => { })); }; - const handleDone = () => { + const handleDone = (...args: unknown[]) => { + const exitCode = typeof args[0] === 'number' ? args[0] : null; setState((prev) => { // Resolve promise if it exists if (prev.currentCommand?.resolve) { prev.currentCommand.resolve({ output: prev.output, error: prev.error, + exitCode, }); } @@ -193,46 +207,49 @@ export const CliProvider: React.FC = ({ children }) => { async ( commandString: string, args?: string[], - timeoutMs: number = 60000, - ): Promise<{ output: string[]; error: string[] }> => { + timeoutMs?: number, + environment?: CliProcessEnvironment, + ): Promise => { if (state.isRunning) { throw new Error('Another command is already running'); } const commandId = generateCommandId(); - return new Promise<{ output: string[]; error: string[] }>( - (resolve, reject) => { - const timeoutId = setTimeout(() => { - setState((prev) => ({ - ...prev, - isRunning: false, - currentCommand: null, - })); - reject(new Error(`Command timeout after ${timeoutMs}ms`)); - }, timeoutMs); - - const command: CliCommand = { - id: commandId, - command: commandString, - startTime: Date.now(), - resolve, - reject, - timeoutId, - }; - - // Clear previous output and start command + return new Promise((resolve, reject) => { + const commandTimeoutMs = timeoutMs ?? 60000; + const timeoutId = setTimeout(() => { setState((prev) => ({ ...prev, - output: [], - error: [], - isRunning: true, - currentCommand: command, - commandHistory: [...prev.commandHistory.slice(-19), commandString], // Keep last 20 commands + isRunning: false, + currentCommand: null, })); + reject(new Error(`Command timeout after ${commandTimeoutMs}ms`)); + }, commandTimeoutMs); + + const command: CliCommand = { + id: commandId, + command: commandString, + startTime: Date.now(), + resolve, + reject, + timeoutId, + }; + + // Clear previous output and start command + setState((prev) => ({ + ...prev, + output: [], + error: [], + isRunning: true, + currentCommand: command, + commandHistory: [...prev.commandHistory.slice(-19), commandString], // Keep last 20 commands + })); - // Execute command - projectsServices.runCliCommand(commandString, args).catch((err) => { + // Execute command + projectsServices + .runCliCommand(commandString, args, environment) + .catch((err) => { const errorMessage = err?.message || err?.toString() || 'Command failed'; setState((prev) => ({ @@ -243,15 +260,18 @@ export const CliProvider: React.FC = ({ children }) => { })); reject(new Error(errorMessage)); }); - }, - ); + }); }, [state.isRunning, generateCommandId], ); // Run command without waiting for result (for terminal usage) const runCommandAsync = useCallback( - (commandString: string, args?: string[]) => { + ( + commandString: string, + args?: string[], + environment?: CliProcessEnvironment, + ) => { if (state.isRunning) { // Add to output to show command was ignored setState((prev) => ({ @@ -280,14 +300,16 @@ export const CliProvider: React.FC = ({ children }) => { })); // Execute command - projectsServices.runCliCommand(commandString, args).catch((err) => { - const errorMessage = - err?.message || err?.toString() || 'Command failed'; - setState((prev) => ({ - ...prev, - error: [...prev.error, errorMessage], - })); - }); + projectsServices + .runCliCommand(commandString, args, environment) + .catch((err) => { + const errorMessage = + err?.message || err?.toString() || 'Command failed'; + setState((prev) => ({ + ...prev, + error: [...prev.error, errorMessage], + })); + }); }, [state.isRunning, generateCommandId], ); diff --git a/src/renderer/hooks/useDbt.ts b/src/renderer/hooks/useDbt.ts index 5b682850..175129c8 100644 --- a/src/renderer/hooks/useDbt.ts +++ b/src/renderer/hooks/useDbt.ts @@ -6,10 +6,12 @@ import { useApiKey, useGetConnections, useGetSettings, + useCheckProjectAdapterCompatibility, useSetConnectionEnvVariable, } from '../controllers'; import { Project, DbtCommandType, ConnectionInput } from '../../types/backend'; import { useAppContext } from './index'; +import { extractCliErrorDetails } from '../utils/dbtCommandResult'; interface UseDbtReturn { run: (project: Project, path?: string) => Promise; @@ -29,57 +31,6 @@ interface UseDbtReturn { activeCommand: DbtCommandType | null; } -const ANSI_ESCAPE_REGEX = /\[[0-9;]*m/g; -const ERROR_SUMMARY_REGEX = /ERROR=(\d+)/i; -const NON_ZERO_EXIT_REGEX = /Process exited with code\s+(\d+)/i; -const ERROR_HINT_REGEX = - /(Error importing adapter|Encountered an error|Runtime Error|Traceback|Database Error|Compilation Error|^ERROR:?\b|\bERROR\b)/i; -const ERROR_HINT_IGNORE_REGEX = /ERROR=0\b/i; - -const sanitizeCliLine = (line: string): string => - line.replace(ANSI_ESCAPE_REGEX, '').trimEnd(); - -const extractCliErrorDetails = ( - output: string[], - errors: string[], -): string[] => { - const details = new Set(); - - errors.forEach((err) => { - const sanitizedError = sanitizeCliLine(err); - if (sanitizedError) { - details.add(sanitizedError); - } - }); - - const cleanedOutput = output.map(sanitizeCliLine); - - cleanedOutput.forEach((line) => { - const match = line.match(ERROR_SUMMARY_REGEX); - if (match && Number(match[1]) > 0) { - details.add(line); - } - }); - - cleanedOutput.forEach((line) => { - const match = line.match(NON_ZERO_EXIT_REGEX); - if (match && Number(match[1]) !== 0) { - details.add(line); - } - }); - - cleanedOutput.forEach((line) => { - if (ERROR_HINT_IGNORE_REGEX.test(line)) { - return; - } - if (ERROR_HINT_REGEX.test(line)) { - details.add(line); - } - }); - - return Array.from(details); -}; - const useDbt = ( successCallback?: () => void, cloudRunCb?: (command: DbtCommandType) => void, @@ -99,6 +50,8 @@ const useDbt = ( getConnectionField, } = useSecureStorage(); const setEnvVariables = useSetConnectionEnvVariable(); + const checkProjectAdapterCompatibility = + useCheckProjectAdapterCompatibility(); const env = React.useMemo(() => { return apiKey ? environment : 'local'; @@ -284,6 +237,14 @@ const useDbt = ( return; } + const adapterCheck = await checkProjectAdapterCompatibility( + project.path, + ); + if (!adapterCheck.adapter.canExecute) { + if (options.showToast) toast.error(adapterCheck.adapter.notes); + return; + } + setActiveCommand(command); // Setup environment variables @@ -304,6 +265,7 @@ const useDbt = ( const aggregatedError = extractCliErrorDetails( result.output, result.error, + result.exitCode, ); // Handle success vs failure @@ -334,6 +296,8 @@ const useDbt = ( runCommand, successCallback, settings?.dbtPath, + settings?.dbtVersion, + checkProjectAdapterCompatibility, ], ); @@ -373,6 +337,14 @@ const useDbt = ( return ''; } + const adapterCheck = await checkProjectAdapterCompatibility( + project.path, + ); + if (!adapterCheck.adapter.canExecute) { + toast.error(adapterCheck.adapter.notes); + return ''; + } + setActiveCommand('compile'); // Setup environment variables @@ -398,6 +370,7 @@ const useDbt = ( const aggregatedError = extractCliErrorDetails( result.output, result.error, + result.exitCode, ); if (aggregatedError.length === 0) { // Extract the compiled SQL from the output @@ -462,7 +435,14 @@ const useDbt = ( setActiveCommand(null); } }, - [connections, setupConnectionEnv, buildCommand, runCommand], + [ + connections, + setupConnectionEnv, + buildCommand, + runCommand, + settings?.dbtVersion, + checkProjectAdapterCompatibility, + ], ), compileProject: useCallback( @@ -488,6 +468,14 @@ const useDbt = ( return ''; } + const adapterCheck = await checkProjectAdapterCompatibility( + project.path, + ); + if (!adapterCheck.adapter.canExecute) { + toast.error(adapterCheck.adapter.notes); + return ''; + } + setActiveCommand('list'); await setupConnectionEnv( connection.connection.name, @@ -502,6 +490,7 @@ const useDbt = ( const aggregatedError = extractCliErrorDetails( result.output, result.error, + result.exitCode, ); if (aggregatedError.length === 0) { return result.output.join('\n'); @@ -517,7 +506,14 @@ const useDbt = ( setActiveCommand(null); } }, - [connections, setupConnectionEnv, buildCommand, runCommand], + [ + connections, + setupConnectionEnv, + buildCommand, + runCommand, + settings?.dbtVersion, + checkProjectAdapterCompatibility, + ], ), debug: useCallback( diff --git a/src/renderer/services/dbtVersions.service.ts b/src/renderer/services/dbtVersions.service.ts index be19d7b4..45fab582 100644 --- a/src/renderer/services/dbtVersions.service.ts +++ b/src/renderer/services/dbtVersions.service.ts @@ -1,5 +1,15 @@ import { + DbtAdapterCapabilityResponse, + DbtProjectAdapterCheck, + DbtProjectCompatibilityResult, + DbtVersionChangePlan, + DbtVersionChangePlanRequest, + DbtVersionChangeRequest, + DbtVersionListOptions, DbtVersionListResponse, + InstalledDbtCoreInfo, + InstalledPythonPackagesResponse, + PythonPackageActionRequest, PythonPackageInstallVersionRequest, PythonPackageInstallVersionResponse, PythonPackageVersionListRequest, @@ -7,13 +17,97 @@ import { } from '../../types/backend'; import { client } from '../config/client'; -export const listDbtCoreVersions = - async (): Promise => { - const { data } = - await client.get('dbt:versions:list'); +export const listDbtCoreVersions = async ( + body: DbtVersionListOptions = {}, +): Promise => { + const { data } = await client.post< + DbtVersionListOptions, + DbtVersionListResponse + >('dbt:versions:list', body); + return data; +}; + +export const getInstalledDbtCore = async (): Promise => { + const { data } = await client.get('dbt:installed:get'); + return data; +}; + +export const planVersionChange = async ( + body: DbtVersionChangePlanRequest, +): Promise => { + const { data } = await client.post< + DbtVersionChangePlanRequest, + DbtVersionChangePlan + >('dbt:versionChange:plan', body); + return data; +}; + +export const installVersionChange = async ( + body: DbtVersionChangeRequest, +): Promise => { + const { data } = await client.post< + DbtVersionChangeRequest, + PythonPackageInstallVersionResponse + >('dbt:versionChange:install', body); + return data; +}; + +export const checkCurrentProjectCompatibility = + async (): Promise => { + const { data } = await client.get( + 'dbt:compatibility:check', + ); + return data; + }; + +export const getActiveAdapterCapabilities = async ( + projectPath?: string, +): Promise => { + const { data } = await client.post< + { projectPath?: string }, + DbtAdapterCapabilityResponse + >('dbt:adapters:active', { projectPath }); + return data; +}; + +export const checkProjectAdapterCompatibility = async ( + projectPath?: string, +): Promise => { + const { data } = await client.post< + { projectPath?: string }, + DbtProjectAdapterCheck + >('dbt:adapters:check', { projectPath }); + return data; +}; + +export const getInstalledPackages = + async (): Promise => { + const { data } = await client.get( + 'dbt:packages:installed', + ); return data; }; +export const installLatestPackage = async ( + body: PythonPackageActionRequest, +): Promise => { + const { data } = await client.post< + PythonPackageActionRequest, + PythonPackageInstallVersionResponse + >('dbt:package:installLatest', body); + return data; +}; + +export const uninstallPackage = async ( + body: PythonPackageActionRequest, +): Promise => { + const { data } = await client.post< + PythonPackageActionRequest, + PythonPackageInstallVersionResponse + >('dbt:package:uninstall', body); + return data; +}; + export const listPackageVersions = async ( body: PythonPackageVersionListRequest, ): Promise => { diff --git a/src/renderer/services/projects.service.ts b/src/renderer/services/projects.service.ts index f9d0af62..126876f9 100644 --- a/src/renderer/services/projects.service.ts +++ b/src/renderer/services/projects.service.ts @@ -6,6 +6,7 @@ import { Project, Table, EnhanceModelResponseType, + CliProcessEnvironment, } from '../../types/backend'; export const getProjects = async (): Promise => { @@ -234,10 +235,16 @@ export const getSelectedProject = async (): Promise => { export const runCliCommand = async ( command: string, args?: string[], + environment?: CliProcessEnvironment, ): Promise => { - await client.post<{ command: string; args?: string[] }>('cli:run', { + await client.post<{ + command: string; + args?: string[]; + environment?: CliProcessEnvironment; + }>('cli:run', { command, args, + environment, }); }; diff --git a/src/renderer/toastStyles.css b/src/renderer/toastStyles.css index 86f353d5..320641bb 100644 --- a/src/renderer/toastStyles.css +++ b/src/renderer/toastStyles.css @@ -1,35 +1,58 @@ /* Custom toast styles - smaller and theme-conforming */ .Toastify__toast-container { - width: 280px !important; + width: min(360px, calc(100vw - 24px)) !important; + max-width: calc(100vw - 24px) !important; padding: 8px !important; + box-sizing: border-box !important; } .Toastify__toast { min-height: 48px !important; + max-height: min(60vh, 480px) !important; padding: 10px 12px !important; border-radius: 8px !important; font-size: 13px !important; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif !important; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', + 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif !important; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important; margin-bottom: 8px !important; + overflow: hidden !important; + align-items: flex-start !important; } .Toastify__toast-body { + min-width: 0 !important; padding: 0 !important; margin: 0 !important; line-height: 1.4 !important; + align-items: flex-start !important; + overflow: hidden !important; +} + +.Toastify__toast-body > div:last-child { + min-width: 0 !important; + max-width: 100% !important; + max-height: min(52vh, 420px) !important; + padding-right: 4px !important; + overflow-x: hidden !important; + overflow-y: auto !important; + white-space: pre-wrap !important; + overflow-wrap: anywhere !important; + word-break: break-word !important; } .Toastify__toast-icon { + flex: 0 0 auto !important; width: 18px !important; margin-right: 10px !important; } .Toastify__close-button { + flex: 0 0 auto !important; opacity: 0.5 !important; - align-self: center !important; + align-self: flex-start !important; + margin-left: 6px !important; } .Toastify__close-button:hover { diff --git a/src/renderer/utils/dbtCommandResult.ts b/src/renderer/utils/dbtCommandResult.ts new file mode 100644 index 00000000..fdddf906 --- /dev/null +++ b/src/renderer/utils/dbtCommandResult.ts @@ -0,0 +1,46 @@ +const ANSI_ESCAPE_REGEX = /\[[0-9;]*m/g; +const ERROR_SUMMARY_REGEX = /ERROR=(\d+)/i; +const NON_ZERO_EXIT_REGEX = /Process exited with code\s+(\d+)/i; +const ERROR_HINT_REGEX = + /(Error importing adapter|Encountered an error|Runtime Error|Traceback|Database Error|Compilation Error|^ERROR:?\b|\bERROR\b)/i; +const ERROR_HINT_IGNORE_REGEX = /ERROR=0\b/i; + +const sanitizeCliLine = (line: string): string => + line.replace(ANSI_ESCAPE_REGEX, '').trimEnd(); + +export const extractCliErrorDetails = ( + output: string[], + errors: string[], + exitCode: number | null = null, +): string[] => { + const details = new Set(); + + if (exitCode !== null && exitCode !== 0) { + details.add(`Process exited with code ${exitCode}`); + } + + errors.forEach((err) => { + const sanitizedError = sanitizeCliLine(err); + if (sanitizedError) details.add(sanitizedError); + }); + + const cleanedOutput = output.map(sanitizeCliLine); + + cleanedOutput.forEach((line) => { + const match = line.match(ERROR_SUMMARY_REGEX); + if (match && Number(match[1]) > 0) details.add(line); + }); + + cleanedOutput.forEach((line) => { + const match = line.match(NON_ZERO_EXIT_REGEX); + if (match && Number(match[1]) !== 0) details.add(line); + }); + + cleanedOutput.forEach((line) => { + if (!ERROR_HINT_IGNORE_REGEX.test(line) && ERROR_HINT_REGEX.test(line)) { + details.add(line); + } + }); + + return Array.from(details); +}; diff --git a/src/renderer/utils/dbtProcessEnvironment.ts b/src/renderer/utils/dbtProcessEnvironment.ts new file mode 100644 index 00000000..b8b91827 --- /dev/null +++ b/src/renderer/utils/dbtProcessEnvironment.ts @@ -0,0 +1,28 @@ +import { ConnectionInput } from '../../types/backend'; + +const V2_SUPPORTED_ADAPTERS = new Set([ + 'snowflake', + 'bigquery', + 'databricks', + 'redshift', + 'duckdb', +]); + +const adapterLabel = (connectionType: ConnectionInput['type']): string => { + if (connectionType === 'postgres') return 'Postgres'; + if (connectionType === 'ducklake') return 'DuckLake'; + return connectionType.charAt(0).toUpperCase() + connectionType.slice(1); +}; + +export const getDbtV2CompatibilityError = ( + dbtVersion: string | undefined, + connectionType: ConnectionInput['type'], +): string | null => { + if ( + dbtVersion?.startsWith('2.') && + !V2_SUPPORTED_ADAPTERS.has(connectionType) + ) { + return `${adapterLabel(connectionType)} is not supported safely by dbt Core v2 preview. Switch the global dbt runtime to a stable v1 release in Settings before running this project.`; + } + return null; +}; diff --git a/src/shared/toolResult.ts b/src/shared/toolResult.ts new file mode 100644 index 00000000..b2c18771 --- /dev/null +++ b/src/shared/toolResult.ts @@ -0,0 +1,27 @@ +type ToolResultEnvelope = { + ok?: unknown; + success?: unknown; + error?: unknown; + data?: { + error?: unknown; + }; +}; + +const asEnvelope = (result: unknown): ToolResultEnvelope | null => { + if (!result || typeof result !== 'object') return null; + return result as ToolResultEnvelope; +}; + +export const isToolResultFailure = (result: unknown): boolean => { + const envelope = asEnvelope(result); + return envelope?.ok === false || envelope?.success === false; +}; + +export const getToolResultError = (result: unknown): string | undefined => { + const envelope = asEnvelope(result); + if (!envelope) return undefined; + const error = envelope.error ?? envelope.data?.error; + if (typeof error === 'string' && error.trim()) return error; + if (error instanceof Error) return error.message; + return undefined; +}; diff --git a/src/types/backend.ts b/src/types/backend.ts index 43100b01..4c19de4e 100644 --- a/src/types/backend.ts +++ b/src/types/backend.ts @@ -384,7 +384,15 @@ export type RosettaVersionInfo = { export type DbtCoreVersionListItem = { version: string; - isPrerelease?: boolean; + isPrerelease: boolean; + isLatestStable: boolean; + isInstalled: boolean; + channel: 'stable' | 'preview'; +}; + +export type DbtVersionListOptions = { + includePrerelease?: boolean; + limit?: number; }; export type DbtVersionListResponse = { @@ -414,9 +422,116 @@ export type PythonPackageInstallVersionRequest = { version: string; }; +export type DbtAdapterCompatibility = { + packageName: string; + installedVersion: string; + status: 'likely-compatible' | 'warning' | 'unknown'; + message: string; +}; + export type PythonPackageInstallVersionResponse = { ok: boolean; error?: string; + installedVersion?: string; + dbtPath?: string; + previousVersion?: string | null; + previousDbtPath?: string | null; + adapterWarnings?: DbtAdapterCompatibility[]; +}; + +export type PythonPackageActionRequest = { + pythonPath?: string; + packageName: string; +}; + +export type InstalledPythonPackagesResponse = { + packages: Record; +}; + +export type InstalledDbtCoreInfo = { + version: string | null; + pythonPath: string; + dbtPath: string | null; + dbtVersionOutput?: string; + isDbtCorePackage: boolean; + isExecutableVerified: boolean; + hasProprietaryDbtPackage?: boolean; + error?: string; +}; + +export type DbtVersionChangeDirection = + | 'upgrade' + | 'downgrade' + | 'reinstall' + | 'preview-install'; + +export type DbtVersionChangePlanRequest = { + targetVersion: string; + includeAdapters?: boolean; +}; + +export type DbtVersionChangePlan = { + currentVersion: string | null; + targetVersion: string; + direction: DbtVersionChangeDirection; + channel: 'stable' | 'preview'; + isMajorVersionChange: boolean; + globalImpactWarning: string; + warnings: string[]; + adapters: DbtAdapterCompatibility[]; + rollbackVersion: string | null; +}; + +export type DbtVersionChangeRequest = DbtVersionChangePlanRequest & { + pythonPath?: string; +}; + +export type DbtCompatibilityDiagnostic = { + command: 'parse' | 'compile'; + ok: boolean; + exitCode: number | null; + summary: string; +}; + +export type DbtProjectCompatibilityResult = { + ok: boolean; + projectName?: string; + projectPath?: string; + diagnostics: DbtCompatibilityDiagnostic[]; + recommendations: string[]; + error?: string; +}; + +export type DbtAdapterSupportStatus = + | 'supported' + | 'preview' + | 'experimental' + | 'unsupported' + | 'unknown'; + +export type DbtAdapterDriver = 'builtin' | 'adbc' | 'native' | 'unknown'; + +export type DbtAdapterCapability = { + adapter: string | null; + displayName: string; + status: DbtAdapterSupportStatus; + driver: DbtAdapterDriver; + requiresNetworkOnFirstUse: boolean; + canExecute: boolean; + source: 'connection' | 'profiles.yml' | 'unresolved'; + notes: string; +}; + +export type DbtAdapterCapabilityResponse = { + dbtCoreVersion: string | null; + runtime: 'v1' | 'v2' | 'unknown'; + packageProvenance: 'apache-dbt-core' | 'unverified'; + projectPath?: string; + adapters: DbtAdapterCapability[]; +}; + +export type DbtProjectAdapterCheck = DbtAdapterCapabilityResponse & { + adapter: DbtAdapterCapability; }; export type InstallResult = { @@ -447,6 +562,10 @@ export type CliMessage = { type: 'error' | 'info' | 'success'; }; +export type CliProcessEnvironment = { + DBT_ALLOW_EXPERIMENTAL_ADAPTERS?: 'true'; +}; + type ForeignKey = { name: string; schema: string; diff --git a/src/types/ipc.ts b/src/types/ipc.ts index e02dc524..de4ba351 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -30,6 +30,15 @@ export type SettingsChannels = | 'settings:duckdb:diagnose' | 'settings:installSqlGlot' | 'dbt:versions:list' + | 'dbt:installed:get' + | 'dbt:versionChange:plan' + | 'dbt:versionChange:install' + | 'dbt:compatibility:check' + | 'dbt:adapters:active' + | 'dbt:adapters:check' + | 'dbt:packages:installed' + | 'dbt:package:installLatest' + | 'dbt:package:uninstall' | 'dbt:packageVersions:list' | 'dbt:packageVersion:install'; diff --git a/tests/unit/components/DbtSettings.versionChange.test.tsx b/tests/unit/components/DbtSettings.versionChange.test.tsx new file mode 100644 index 00000000..8a801847 --- /dev/null +++ b/tests/unit/components/DbtSettings.versionChange.test.tsx @@ -0,0 +1,180 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { DbtSettings } from '../../../src/renderer/components/settings/DbtSettings'; + +const listVersions = jest.fn(); +const planVersionChange = jest.fn(); +const installVersionChange = jest.fn(); +const getInstalledPackages = jest.fn(); +const getActiveAdapterCapabilities = jest.fn(); + +jest.mock('../../../src/renderer/controllers', () => ({ + useListDbtCoreVersions: () => listVersions, + useGetInstalledDbtCore: () => jest.fn(), + useGetInstalledPackages: () => getInstalledPackages, + useInstallPackageVersion: () => jest.fn(), + useInstallLatestPackage: () => jest.fn(), + useUninstallPackage: () => jest.fn(), + useListPackageVersions: () => jest.fn(), + usePlanDbtVersionChange: () => planVersionChange, + useInstallDbtVersionChange: () => installVersionChange, + useCheckCurrentProjectCompatibility: () => jest.fn(), + useGetActiveAdapterCapabilities: () => getActiveAdapterCapabilities, +})); + +const stableItem = { + version: '1.11.12', + isPrerelease: false, + isLatestStable: true, + isInstalled: true, + channel: 'stable' as const, +}; + +const pythonPreviewItem = { + version: '1.12.0rc2', + isPrerelease: true, + isLatestStable: false, + isInstalled: false, + channel: 'preview' as const, +}; + +const previewItem = { + version: '2.0.0a4', + isPrerelease: true, + isLatestStable: false, + isInstalled: false, + channel: 'preview' as const, +}; + +const previousStableItems = ['1.11.11', '1.11.10', '1.11.9', '1.11.8'].map( + (version) => ({ + version, + isPrerelease: false, + isLatestStable: false, + isInstalled: false, + channel: 'stable' as const, + }), +); + +describe('DbtSettings version change flow', () => { + beforeEach(() => { + jest.clearAllMocks(); + getInstalledPackages.mockResolvedValue({ packages: {} }); + getActiveAdapterCapabilities.mockResolvedValue({ + dbtCoreVersion: '1.11.12', + runtime: 'v1', + packageProvenance: 'apache-dbt-core', + adapters: [], + }); + listVersions.mockImplementation( + async ({ includePrerelease }: { includePrerelease?: boolean }) => ({ + versions: includePrerelease + ? [previewItem, pythonPreviewItem, stableItem, ...previousStableItems] + : [stableItem, ...previousStableItems], + latestStable: '1.11.12', + currentVersion: '1.11.12', + }), + ); + planVersionChange.mockResolvedValue({ + currentVersion: '1.11.12', + targetVersion: '2.0.0a4', + direction: 'preview-install', + channel: 'preview', + isMajorVersionChange: true, + globalImpactWarning: + 'This changes the global dbt-core version used by all local projects.', + warnings: ['This is a major dbt-core version change.'], + adapters: [], + rollbackVersion: '1.11.12', + }); + }); + + const renderSettings = () => + render( + , + ); + + it('shows Python and Rust runtimes together and opens global confirmation', async () => { + renderSettings(); + + expect(await screen.findByText('1.11.12')).toBeInTheDocument(); + expect(screen.getByText('2.0.0a4')).toBeInTheDocument(); + expect(screen.getByText('dbt Core v1')).toBeInTheDocument(); + expect(screen.getByText('dbt Core v2')).toBeInTheDocument(); + expect(screen.queryByText(/Fusion/)).not.toBeInTheDocument(); + expect(screen.queryByText('1.12.0rc2')).not.toBeInTheDocument(); + expect(screen.queryByText(/Show preview/)).not.toBeInTheDocument(); + previousStableItems.forEach(({ version }) => { + expect(screen.getByText(version)).toBeInTheDocument(); + }); + expect(listVersions).toHaveBeenCalledWith({ + includePrerelease: true, + limit: 100, + }); + expect(screen.getByRole('button', { name: 'Installed' })).toBeDisabled(); + expect(screen.getByText('Latest stable')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Install Preview' })); + + expect( + await screen.findByText('Confirm global dbt-core version change'), + ).toBeInTheDocument(); + expect( + screen.getByText( + 'This changes the global dbt-core version used by all local projects.', + ), + ).toBeInTheDocument(); + expect( + screen.getByText(/Apache-licensed dbt-core package/), + ).toBeInTheDocument(); + }); + + it('shows a verified result and rollback action after confirmation', async () => { + installVersionChange.mockResolvedValue({ + ok: true, + installedVersion: '2.0.0a4', + dbtPath: '/managed/venv/bin/dbt', + previousVersion: '1.11.12', + adapterWarnings: [], + }); + renderSettings(); + + fireEvent.click( + await screen.findByRole('button', { name: 'Install Preview' }), + ); + await screen.findByText('Confirm global dbt-core version change'); + fireEvent.click( + await screen.findByRole('checkbox', { + name: /Check current project after install/, + }), + ); + fireEvent.click( + screen.getByRole('button', { name: 'Confirm version change' }), + ); + + await waitFor(() => + expect(installVersionChange).toHaveBeenCalledWith( + expect.objectContaining({ targetVersion: '2.0.0a4' }), + ), + ); + expect( + await screen.findByText('Verified dbt-core 2.0.0a4 is now active.'), + ).toBeInTheDocument(); + await waitFor(() => + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), + ); + expect( + screen.getByRole('button', { name: 'Roll back to 1.11.12' }), + ).toBeInTheDocument(); + }); +}); diff --git a/tests/unit/hooks/useDbt.experimentalAdapters.test.ts b/tests/unit/hooks/useDbt.experimentalAdapters.test.ts new file mode 100644 index 00000000..1e7f8f02 --- /dev/null +++ b/tests/unit/hooks/useDbt.experimentalAdapters.test.ts @@ -0,0 +1,11 @@ +import { getDbtV2CompatibilityError } from '../../../src/renderer/utils/dbtProcessEnvironment'; + +describe('dbt v2 adapter compatibility', () => { + it('blocks v2 Postgres and permits supported or v1 adapters', () => { + expect(getDbtV2CompatibilityError('2.0.0a4', 'postgres')).toContain( + 'Postgres is not supported safely', + ); + expect(getDbtV2CompatibilityError('1.11.12', 'postgres')).toBeNull(); + expect(getDbtV2CompatibilityError('2.0.0a4', 'duckdb')).toBeNull(); + }); +}); diff --git a/tests/unit/main/adapters/cli.adapter.test.ts b/tests/unit/main/adapters/cli.adapter.test.ts new file mode 100644 index 00000000..7e0eb5f7 --- /dev/null +++ b/tests/unit/main/adapters/cli.adapter.test.ts @@ -0,0 +1,82 @@ +import { EventEmitter } from 'events'; +import { spawn } from 'child_process'; +import CliAdapter from '../../../../src/main/adapters/cli.adapter'; + +jest.mock('child_process', () => ({ + spawn: jest.fn(), +})); + +const mockedSpawn = spawn as jest.MockedFunction; + +describe('CliAdapter environment', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('passes only the approved experimental-adapter flag to the child', async () => { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + mockedSpawn.mockReturnValue(child); + + const adapter = new CliAdapter(); + const command = adapter.runCommandWithoutStreaming( + '/managed/dbt', + ['run'], + { + DBT_ALLOW_EXPERIMENTAL_ADAPTERS: 'true', + MALICIOUS_EXTRA_KEY: 'blocked', + } as any, + ); + + expect(mockedSpawn).toHaveBeenCalledWith( + '/managed/dbt', + ['run'], + expect.objectContaining({ + shell: false, + env: expect.objectContaining({ + DBT_ALLOW_EXPERIMENTAL_ADAPTERS: 'true', + }), + }), + ); + const spawnOptions = mockedSpawn.mock.calls[0][2] as any; + expect(spawnOptions.env.MALICIOUS_EXTRA_KEY).toBeUndefined(); + + child.emit('close', 0); + await expect(command).resolves.toBeUndefined(); + }); + + it('publishes a nonzero exit code before completing the command', async () => { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + mockedSpawn.mockReturnValue(child); + const send = jest.fn(); + const adapter = new CliAdapter(); + + const command = adapter.runCommand( + { webContents: { send } } as any, + '/managed/dbt', + ['run'], + ); + const rejected = command.catch((error) => error as Error); + + child.emit('close', 139); + expect(((await rejected) as Error).message).toBe( + 'Process exited with error code 139', + ); + + expect(send).toHaveBeenCalledWith( + 'cli:error', + 'Process exited with code 139', + ); + expect(send).toHaveBeenCalledWith('cli:done', 139); + const errorCall = send.mock.calls.findIndex( + ([channel]) => channel === 'cli:error', + ); + const doneCall = send.mock.calls.findIndex( + ([channel]) => channel === 'cli:done', + ); + expect(errorCall).toBeLessThan(doneCall); + }); +}); diff --git a/tests/unit/main/services/ai/dbt.tools.test.ts b/tests/unit/main/services/ai/dbt.tools.test.ts new file mode 100644 index 00000000..1df0827d --- /dev/null +++ b/tests/unit/main/services/ai/dbt.tools.test.ts @@ -0,0 +1,61 @@ +import { EventEmitter } from 'events'; +import { spawn } from 'child_process'; +import SettingsService from '../../../../../src/main/services/settings.service'; +import { executeDbtCommand } from '../../../../../src/main/services/ai/tools/dbt.tools'; + +jest.mock('child_process', () => ({ + spawn: jest.fn(), + execFileSync: jest.fn(), +})); +jest.mock('../../../../../src/main/services/settings.service', () => ({ + __esModule: true, + default: { getDbtExePath: jest.fn() }, +})); +jest.mock('../../../../../src/main/services/agent.service', () => ({ + __esModule: true, + default: { + currentAgentContext: null, + getAgentContext: jest.fn(), + }, +})); + +const spawnMock = spawn as jest.MockedFunction; +const getDbtExePathMock = SettingsService.getDbtExePath as jest.MockedFunction< + typeof SettingsService.getDbtExePath +>; + +describe('executeDbtCommand', () => { + beforeEach(() => { + jest.clearAllMocks(); + getDbtExePathMock.mockResolvedValue('/managed/dbt'); + }); + + it('returns a failed envelope with output and exit code', async () => { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + spawnMock.mockImplementation(() => { + queueMicrotask(() => { + child.stdout.emit('data', Buffer.from('Summary: 8 total | 4 error\n')); + child.emit('close', 1); + }); + return child; + }); + + const command = executeDbtCommand({ + command: 'run', + projectPath: '/project', + toolName: 'studio_cli_run_dbt', + requireApproval: false, + }); + + await expect(command).resolves.toEqual( + expect.objectContaining({ + ok: false, + exitCode: 1, + error: 'Command failed with code 1', + output: expect.stringContaining('4 error'), + }), + ); + }); +}); diff --git a/tests/unit/services/dbtCoreVersion.service.test.ts b/tests/unit/services/dbtCoreVersion.service.test.ts new file mode 100644 index 00000000..906e6e21 --- /dev/null +++ b/tests/unit/services/dbtCoreVersion.service.test.ts @@ -0,0 +1,400 @@ +import { EventEmitter } from 'events'; +import axios from 'axios'; +import fs from 'fs-extra'; +import { spawn } from 'child_process'; +import SettingsService from '../../../src/main/services/settings.service'; +import ProjectsService from '../../../src/main/services/projects.service'; +import { DbtCoreVersionService } from '../../../src/main/services/dbtCoreVersion.service'; + +jest.mock('child_process', () => ({ spawn: jest.fn() })); +jest.mock('axios'); +jest.mock('fs-extra', () => ({ + __esModule: true, + default: { + pathExists: jest.fn(), + mkdtemp: jest.fn(), + remove: jest.fn(), + }, +})); +jest.mock('../../../src/main/services/projects.service', () => ({ + __esModule: true, + default: { getSelectedProject: jest.fn() }, +})); +jest.mock('../../../src/main/services/settings.service', () => ({ + __esModule: true, + default: { + loadSettings: jest.fn(), + saveSettings: jest.fn(), + }, +})); + +type CommandResult = { + exitCode: number; + stdout?: string; + stderr?: string; +}; + +const spawnMock = spawn as jest.MockedFunction; +const axiosGetMock = axios.get as jest.MockedFunction; +const pathExistsMock = fs.pathExists as jest.MockedFunction< + typeof fs.pathExists +>; +const loadSettingsMock = SettingsService.loadSettings as jest.MockedFunction< + typeof SettingsService.loadSettings +>; +const saveSettingsMock = SettingsService.saveSettings as jest.MockedFunction< + typeof SettingsService.saveSettings +>; +const getSelectedProjectMock = + ProjectsService.getSelectedProject as jest.MockedFunction< + typeof ProjectsService.getSelectedProject + >; +const mkdtempMock = fs.mkdtemp as jest.MockedFunction; +const removeMock = fs.remove as jest.MockedFunction; + +const mockCommands = (results: CommandResult[]) => { + spawnMock.mockImplementation((() => { + const result = results.shift(); + if (!result) throw new Error('Unexpected command'); + + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + process.nextTick(() => { + if (result.stdout) child.stdout.emit('data', result.stdout); + if (result.stderr) child.stderr.emit('data', result.stderr); + child.emit('close', result.exitCode); + }); + return child; + }) as typeof spawn); +}; + +describe('DbtCoreVersionService', () => { + const pythonPath = '/managed/venv/bin/python3'; + const dbtPath = '/managed/venv/bin/dbt'; + + beforeEach(() => { + jest.clearAllMocks(); + loadSettingsMock.mockResolvedValue({ + pythonPath, + dbtPath: '/old/venv/bin/dbt', + dbtVersion: '1.11.12', + } as any); + pathExistsMock.mockImplementation(async (candidate) => + [pythonPath, dbtPath].includes(String(candidate)), + ); + }); + + it('verifies the dbt executable beside the managed Python interpreter', async () => { + mockCommands([ + { exitCode: 0, stdout: 'Name: dbt-core\nVersion: 2.0.0a4\n' }, + { exitCode: 1 }, + { exitCode: 0, stdout: 'dbt Fusion 2.0.0-alpha.4\n' }, + ]); + + const installed = await DbtCoreVersionService.getInstalledDbtCore(); + + expect(installed).toMatchObject({ + version: '2.0.0a4', + pythonPath, + dbtPath, + isDbtCorePackage: true, + isExecutableVerified: true, + hasProprietaryDbtPackage: false, + }); + expect(spawnMock).toHaveBeenLastCalledWith(dbtPath, ['--version'], { + shell: false, + }); + }); + + it('does not fall back to a different Python interpreter', async () => { + const result = await DbtCoreVersionService.installDbtCoreVersion({ + pythonPath: '/usr/bin/python3', + packageName: 'dbt-core', + version: '2.0.0a4', + }); + + expect(result).toEqual({ + ok: false, + error: + 'The requested Python executable is not the app-managed environment.', + }); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('updates settings only after package and executable verification', async () => { + mockCommands([ + { exitCode: 0 }, + { exitCode: 0, stdout: 'Name: dbt-core\nVersion: 2.0.0a4\n' }, + { exitCode: 1 }, + { exitCode: 0, stdout: 'dbt Fusion 2.0.0-alpha.4\n' }, + ]); + + const result = await DbtCoreVersionService.installDbtCoreVersion({ + pythonPath, + packageName: 'dbt-core', + version: '2.0.0a4', + }); + + expect(result).toEqual({ + ok: true, + installedVersion: '2.0.0a4', + dbtPath, + }); + expect(spawnMock).toHaveBeenNthCalledWith( + 1, + pythonPath, + [ + '-m', + 'pip', + 'install', + '--upgrade', + '--force-reinstall', + '--no-cache-dir', + 'dbt-core==2.0.0a4', + ], + { shell: false }, + ); + expect(saveSettingsMock).toHaveBeenCalledWith( + expect.objectContaining({ dbtPath, dbtVersion: '2.0.0a4' }), + ); + }); + + it('rejects versions that are not exact package versions', async () => { + const result = await DbtCoreVersionService.installDbtCoreVersion({ + pythonPath, + packageName: 'dbt-core', + version: '2.0.0a4 --extra-index-url https://example.test', + }); + + expect(result.ok).toBe(false); + expect(result.error).toMatch(/^Invalid dbt-core version:/); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('returns a typed unverified result when managed Python is missing', async () => { + pathExistsMock.mockImplementation(async () => false); + + const installed = await DbtCoreVersionService.getInstalledDbtCore(); + + expect(installed).toMatchObject({ + version: null, + pythonPath, + dbtPath: null, + isDbtCorePackage: false, + isExecutableVerified: false, + }); + expect(installed.error).toContain('Managed Python environment not found'); + }); + + it('lists stable versions by default and adds typed preview metadata on demand', async () => { + axiosGetMock.mockResolvedValue({ + data: { + releases: { + '1.11.12': [{}], + '1.10.22': [{}], + '2.0.0a4': [{}], + '2.0.0a10': [{}], + '1.12.0rc2': [{}], + '9.9.9': [{ yanked: true }], + }, + }, + } as any); + + const stable = await DbtCoreVersionService.listDbtCoreVersions(); + const preview = await DbtCoreVersionService.listDbtCoreVersions({ + includePrerelease: true, + limit: 10, + }); + + expect(stable.versions.map((item) => item.version)).toEqual([ + '1.11.12', + '1.10.22', + ]); + expect(stable.latestStable).toBe('1.11.12'); + expect(stable.versions[0]).toMatchObject({ + isLatestStable: true, + isInstalled: true, + channel: 'stable', + }); + expect(preview.versions.map((item) => item.version)).toEqual([ + '2.0.0a10', + '2.0.0a4', + '1.12.0rc2', + '1.11.12', + '1.10.22', + ]); + expect(preview.versions[0]).toMatchObject({ + isPrerelease: true, + isLatestStable: false, + channel: 'preview', + }); + }); + + it.each([ + ['1.12.0', 'upgrade'], + ['1.10.22', 'downgrade'], + ['1.11.12', 'reinstall'], + ['2.0.0a4', 'preview-install'], + ] as const)('plans %s as %s', async (targetVersion, direction) => { + const plan = await DbtCoreVersionService.planVersionChange({ + targetVersion, + includeAdapters: false, + }); + + expect(plan.direction).toBe(direction); + expect(plan.rollbackVersion).toBe('1.11.12'); + expect(plan.channel).toBe( + direction === 'preview-install' ? 'preview' : 'stable', + ); + expect(plan.isMajorVersionChange).toBe(targetVersion.startsWith('2.')); + }); + + it('returns installed adapter warnings for a v2 preview plan', async () => { + mockCommands([ + { exitCode: 0, stdout: 'Name: dbt-postgres\nVersion: 1.9.0\n' }, + { exitCode: 1 }, + { exitCode: 1 }, + { exitCode: 1 }, + { exitCode: 1 }, + { exitCode: 1 }, + ]); + + const plan = await DbtCoreVersionService.planVersionChange({ + targetVersion: '2.0.0a4', + }); + + expect(plan.isMajorVersionChange).toBe(true); + expect(plan.warnings).toEqual( + expect.arrayContaining([expect.stringContaining('major dbt-core')]), + ); + expect(plan.adapters).toEqual([ + expect.objectContaining({ + packageName: 'dbt-postgres', + installedVersion: '1.9.0', + status: 'warning', + message: expect.stringContaining('blocks v2 Postgres execution'), + }), + ]); + }); + + it('does not accept v2 executable output without dbt-core package provenance', async () => { + mockCommands([ + { exitCode: 1 }, + { exitCode: 1 }, + { exitCode: 0, stdout: 'dbt Fusion 2.0.0-alpha.4\n' }, + ]); + + const installed = await DbtCoreVersionService.verifyDbtInstall(pythonPath); + + expect(installed.isDbtCorePackage).toBe(false); + expect(installed.isExecutableVerified).toBe(false); + }); + + it('blocks a proprietary dbt package and preserves settings', async () => { + mockCommands([ + { exitCode: 0 }, + { exitCode: 0, stdout: 'Name: dbt-core\nVersion: 2.0.0a4\n' }, + { exitCode: 0, stdout: 'Name: dbt\nVersion: 1.0.0\n' }, + { exitCode: 0, stdout: 'dbt Fusion 2.0.0-alpha.4\n' }, + ]); + + const result = await DbtCoreVersionService.installDbtCoreVersion({ + pythonPath, + packageName: 'dbt-core', + version: '2.0.0a4', + }); + + expect(result).toEqual({ + ok: false, + error: + 'Unsupported dbt distribution detected. Rosetta supports Apache dbt-core only.', + }); + expect(saveSettingsMock).not.toHaveBeenCalled(); + }); + + it('preserves settings when pip installation fails', async () => { + mockCommands([{ exitCode: 1, stderr: 'No matching distribution' }]); + + const result = await DbtCoreVersionService.installDbtCoreVersion({ + pythonPath, + packageName: 'dbt-core', + version: '2.0.0a4', + }); + + expect(result).toEqual({ ok: false, error: 'No matching distribution' }); + expect(saveSettingsMock).not.toHaveBeenCalled(); + }); + + it('checks parse and compile with temporary artifacts without running deps', async () => { + getSelectedProjectMock.mockResolvedValue({ + id: 'project-1', + name: 'Migration project', + path: '/projects/migration', + createdAt: '2026-07-13', + connection: { type: 'duckdb' } as any, + }); + mkdtempMock.mockImplementation( + async () => '/tmp/rosetta-dbt-compatibility-test', + ); + mockCommands([ + { exitCode: 0, stdout: 'Name: dbt-core\nVersion: 2.0.0a4\n' }, + { exitCode: 1 }, + { exitCode: 0, stdout: 'dbt Fusion 2.0.0-alpha.4\n' }, + { exitCode: 0, stdout: 'parse complete\n' }, + { exitCode: 0, stdout: 'compile complete\n' }, + ]); + + const result = + await DbtCoreVersionService.checkCurrentProjectCompatibility(); + + expect(result.ok).toBe(true); + expect(result.diagnostics.map((item) => item.command)).toEqual([ + 'parse', + 'compile', + ]); + expect( + spawnMock.mock.calls.flatMap((call) => call[1] as string[]), + ).not.toContain('deps'); + expect(spawnMock).toHaveBeenCalledWith( + dbtPath, + ['parse', '--project-dir', '/projects/migration'], + expect.objectContaining({ + shell: false, + cwd: '/projects/migration', + env: expect.objectContaining({ + DBT_TARGET_PATH: '/tmp/rosetta-dbt-compatibility-test/target', + DBT_LOG_PATH: '/tmp/rosetta-dbt-compatibility-test/logs', + }), + }), + ); + expect(removeMock).toHaveBeenCalledWith( + '/tmp/rosetta-dbt-compatibility-test', + ); + }); + + it('blocks v2 Postgres compatibility commands before native execution', async () => { + getSelectedProjectMock.mockResolvedValue({ + id: 'project-1', + name: 'Postgres project', + path: '/projects/postgres', + createdAt: '2026-07-13', + connection: { type: 'postgres' } as any, + }); + mockCommands([ + { exitCode: 0, stdout: 'Name: dbt-core\nVersion: 2.0.0a4\n' }, + { exitCode: 1 }, + { exitCode: 0, stdout: 'dbt Fusion 2.0.0-alpha.4\n' }, + ]); + + const result = + await DbtCoreVersionService.checkCurrentProjectCompatibility(); + + expect(result.ok).toBe(false); + expect(result.error).toContain('Postgres is not supported safely'); + expect(result.recommendations).toEqual([ + expect.stringContaining('stable v1'), + ]); + expect(mkdtempMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/shared/toolResult.test.ts b/tests/unit/shared/toolResult.test.ts new file mode 100644 index 00000000..fadf6863 --- /dev/null +++ b/tests/unit/shared/toolResult.test.ts @@ -0,0 +1,22 @@ +import { + getToolResultError, + isToolResultFailure, +} from '../../../src/shared/toolResult'; + +describe('tool result envelopes', () => { + it('recognizes explicit dbt tool failures', () => { + const result = { + ok: false, + error: 'Command failed with code 1', + data: { exitCode: 1 }, + }; + + expect(isToolResultFailure(result)).toBe(true); + expect(getToolResultError(result)).toBe('Command failed with code 1'); + }); + + it('does not mark successful envelopes as failures', () => { + expect(isToolResultFailure({ ok: true })).toBe(false); + expect(isToolResultFailure({ success: true })).toBe(false); + }); +}); diff --git a/tests/unit/utils/dbtCommandResult.test.ts b/tests/unit/utils/dbtCommandResult.test.ts new file mode 100644 index 00000000..e487cd0d --- /dev/null +++ b/tests/unit/utils/dbtCommandResult.test.ts @@ -0,0 +1,13 @@ +import { extractCliErrorDetails } from '../../../src/renderer/utils/dbtCommandResult'; + +describe('dbt command result detection', () => { + it('treats a nonzero process exit as a failure without relying on output', () => { + expect(extractCliErrorDetails([], [], 139)).toEqual([ + 'Process exited with code 139', + ]); + }); + + it('does not report a successful empty result as a failure', () => { + expect(extractCliErrorDetails([], [], 0)).toEqual([]); + }); +});