From 62028a64ed87950283a1c80cefa99e24c3f0d169 Mon Sep 17 00:00:00 2001 From: Haider Date: Fri, 12 Jun 2026 01:11:35 +0530 Subject: [PATCH 1/2] fix: [AI-7082] bubble real dbt show error instead of generic "Could not parse" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `execDbtShow` swallowed `run()` rejections silently — when `dbt show` crashed (e.g. corrupted `dbt_packages/*`, missing `dbt_project.yml`, DB connection refused), the agent saw a misleading "Could not parse dbt show output in any format" message and treated it as transient. The real `Runtime Error: ...` from `dbt`'s stderr never surfaced. Capture the `execFile` rejection's `.stderr` and `.stdout`, scan recovered JSON log lines for `level: "error"` events (dbt with `--log-format json` emits structured error events even on crash), and surface the real error. Preserve the existing generic message only when both `run()` invocations exit 0 but the output is genuinely unparseable — the condition the message was actually designed for. - src/dbt-cli.ts: 2 catch blocks now retain the error; new `extractDbtError()` helper picks structured event > stderr > message. - test/dbt-cli.test.ts: 6 new cases (real stderr surfaces, structured event preferred, ENOENT fallback, generic message preserved on exit-0 unparseable). 24/24 pass. Scoped to `execDbtShow`. `execDbtCompile` and `execDbtCompileInline` share the same masking pattern but have manifest.json / `--quiet` fallbacks that reduce impact — addressed separately if needed. --- packages/dbt-tools/src/dbt-cli.ts | 65 ++++++++++++++++++++++-- packages/dbt-tools/test/dbt-cli.test.ts | 67 +++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 4 deletions(-) diff --git a/packages/dbt-tools/src/dbt-cli.ts b/packages/dbt-tools/src/dbt-cli.ts index be9b92a974..d7fa9db793 100644 --- a/packages/dbt-tools/src/dbt-cli.ts +++ b/packages/dbt-tools/src/dbt-cli.ts @@ -222,11 +222,16 @@ export async function execDbtShow(sql: string, limit?: number) { if (limit !== undefined) args.push("--limit", String(limit)) let lines: Record[] + // AI-7082: capture the run() error so we can bubble the real dbt failure + // up if all parse tiers fail; the generic "Could not parse" alone misleads + // callers into treating structural project errors as transient. + let primaryRunError: ExecFileError | undefined try { const { stdout } = await run(args) lines = parseJsonLines(stdout) - } catch { - lines = [] + } catch (e) { + primaryRunError = e as ExecFileError + lines = parseJsonLines(primaryRunError.stdout ?? "") } // --- Tier 1: known field paths --- @@ -281,6 +286,7 @@ export async function execDbtShow(sql: string, limit?: number) { } // --- Tier 3: plain text fallback (ASCII table) --- + let plainRunError: ExecFileError | undefined try { const plainArgs = ["show", "--inline", sql] if (limit !== undefined) plainArgs.push("--limit", String(limit)) @@ -295,8 +301,15 @@ export async function execDbtShow(sql: string, limit?: number) { compiledSql: sql, } } - } catch { - // Plain text dbt show also failed — fall through to error below + } catch (e) { + plainRunError = e as ExecFileError + } + + // AI-7082: if either run() rejected, dbt actually crashed — surface the real + // error instead of the generic "Could not parse" message. + const realError = extractDbtError(lines, primaryRunError, plainRunError) + if (realError) { + throw new Error(`dbt show failed: ${realError}`) } throw new Error( @@ -305,6 +318,50 @@ export async function execDbtShow(sql: string, limit?: number) { ) } +/** AI-7082: shape of an execFile rejection — carries stdout/stderr alongside message. */ +interface ExecFileError extends Error { + stdout?: string + stderr?: string + code?: number | string +} + +/** + * AI-7082: pick the best human-readable error from a failed `dbt show` invocation. + * + * Preference order: + * 1. A structured `level: "error"` event in the JSON log (dbt's own error msg). + * 2. Stderr from the JSON-mode run. + * 3. Stderr from the plain-text-mode run. + * 4. The exception message itself. + * + * Returns undefined if neither run rejected — caller falls back to the generic + * "Could not parse" message, which is correct when dbt exited 0 but emitted + * something we can't decode. + */ +function extractDbtError( + lines: Record[], + primary?: ExecFileError, + plain?: ExecFileError, +): string | undefined { + if (!primary && !plain) return undefined + + const errorEvent = lines.find( + (l: any) => l.info?.level === "error" || l.level === "error", + ) as any + const structuredMsg = errorEvent?.info?.msg ?? errorEvent?.msg + + const primaryStderr = primary?.stderr?.toString().trim() + const plainStderr = plain?.stderr?.toString().trim() + + return ( + (typeof structuredMsg === "string" && structuredMsg.length > 0 ? structuredMsg : undefined) ?? + (primaryStderr && primaryStderr.length > 0 ? primaryStderr : undefined) ?? + (plainStderr && plainStderr.length > 0 ? plainStderr : undefined) ?? + primary?.message ?? + plain?.message + ) +} + /** * Compile a model via `dbt compile --select ` and return compiled SQL. */ diff --git a/packages/dbt-tools/test/dbt-cli.test.ts b/packages/dbt-tools/test/dbt-cli.test.ts index e8c7c18ebf..5f70a075ad 100644 --- a/packages/dbt-tools/test/dbt-cli.test.ts +++ b/packages/dbt-tools/test/dbt-cli.test.ts @@ -149,6 +149,73 @@ describe("execDbtShow", () => { await expect(execDbtShow("SELECT 1")).rejects.toThrow("Could not parse dbt show output in any format") }) + + // --- AI-7082: bubble real dbt error instead of generic "Could not parse" --- + + test("AI-7082: surfaces real dbt stderr when run fails", async () => { + mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: any, cb: Function) => { + const err: any = new Error("Command failed: dbt show --inline ...") + err.code = 1 + err.stdout = "" + err.stderr = + "Runtime Error: Failed to read package: No dbt_project.yml found at expected path dbt_packages/dbt_utils/dbt_project.yml" + cb(err, err.stdout, err.stderr) + }) + + await expect(execDbtShow("SELECT 1")).rejects.toThrow(/Failed to read package/) + await expect(execDbtShow("SELECT 1")).rejects.toThrow(/dbt show failed/) + }) + + test("AI-7082: prefers structured error event in JSON log over raw stderr", async () => { + const errorLog = JSON.stringify({ + info: { + level: "error", + msg: "Compilation Error: Model 'foo' depends on a node named 'bar' which was not found", + }, + }) + mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: any, cb: Function) => { + const err: any = new Error("Command failed") + err.code = 1 + err.stdout = errorLog + err.stderr = "exit status 1" + cb(err, err.stdout, err.stderr) + }) + + await expect(execDbtShow("SELECT 1")).rejects.toThrow(/Compilation Error.*Model 'foo'/) + }) + + test("AI-7082: does not surface generic 'Could not parse' when dbt actually crashed", async () => { + mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: any, cb: Function) => { + const err: any = new Error("Command failed") + err.code = 2 + err.stdout = "" + err.stderr = "Database Error: connection refused" + cb(err, err.stdout, err.stderr) + }) + + await expect(execDbtShow("SELECT 1")).rejects.not.toThrow(/Could not parse dbt show output/) + }) + + test("AI-7082: preserves generic 'Could not parse' when dbt exited 0 but output unparseable", async () => { + // Existing behavior — dbt didn't crash, we just couldn't decode its output. + mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: any, cb: Function) => { + cb(null, "some unparseable output", "") + }) + + await expect(execDbtShow("SELECT 1")).rejects.toThrow("Could not parse dbt show output in any format") + }) + + test("AI-7082: falls back to error message when stderr is empty", async () => { + mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: any, cb: Function) => { + const err: any = new Error("spawn ENOENT") + err.code = "ENOENT" + err.stdout = "" + err.stderr = "" + cb(err, "", "") + }) + + await expect(execDbtShow("SELECT 1")).rejects.toThrow(/spawn ENOENT|dbt show failed/) + }) }) // --------------------------------------------------------------------------- From 62e21bf00106147509ce4fb4f6ec9a4b617f6763 Mon Sep 17 00:00:00 2001 From: Haider Date: Fri, 12 Jun 2026 01:29:59 +0530 Subject: [PATCH 2/2] chore: drop Jira key references in favor of GitHub issue #932 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit altimate-code is tracked on GitHub, not Jira. Remove the `AI-7082` labels from code comments, test names, and the section header — the PR description carries the cross-reference instead. No functional changes; 24/24 bun:test still pass. --- packages/dbt-tools/src/dbt-cli.ts | 14 +++++++------- packages/dbt-tools/test/dbt-cli.test.ts | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/dbt-tools/src/dbt-cli.ts b/packages/dbt-tools/src/dbt-cli.ts index d7fa9db793..77c4b60797 100644 --- a/packages/dbt-tools/src/dbt-cli.ts +++ b/packages/dbt-tools/src/dbt-cli.ts @@ -222,9 +222,9 @@ export async function execDbtShow(sql: string, limit?: number) { if (limit !== undefined) args.push("--limit", String(limit)) let lines: Record[] - // AI-7082: capture the run() error so we can bubble the real dbt failure - // up if all parse tiers fail; the generic "Could not parse" alone misleads - // callers into treating structural project errors as transient. + // Capture the run() error so we can bubble the real dbt failure up if all + // parse tiers fail; the generic "Could not parse" alone misleads callers + // into treating structural project errors as transient. let primaryRunError: ExecFileError | undefined try { const { stdout } = await run(args) @@ -305,8 +305,8 @@ export async function execDbtShow(sql: string, limit?: number) { plainRunError = e as ExecFileError } - // AI-7082: if either run() rejected, dbt actually crashed — surface the real - // error instead of the generic "Could not parse" message. + // If either run() rejected, dbt actually crashed — surface the real error + // instead of the generic "Could not parse" message. const realError = extractDbtError(lines, primaryRunError, plainRunError) if (realError) { throw new Error(`dbt show failed: ${realError}`) @@ -318,7 +318,7 @@ export async function execDbtShow(sql: string, limit?: number) { ) } -/** AI-7082: shape of an execFile rejection — carries stdout/stderr alongside message. */ +/** Shape of an execFile rejection — carries stdout/stderr alongside message. */ interface ExecFileError extends Error { stdout?: string stderr?: string @@ -326,7 +326,7 @@ interface ExecFileError extends Error { } /** - * AI-7082: pick the best human-readable error from a failed `dbt show` invocation. + * Pick the best human-readable error from a failed `dbt show` invocation. * * Preference order: * 1. A structured `level: "error"` event in the JSON log (dbt's own error msg). diff --git a/packages/dbt-tools/test/dbt-cli.test.ts b/packages/dbt-tools/test/dbt-cli.test.ts index 5f70a075ad..738f110e44 100644 --- a/packages/dbt-tools/test/dbt-cli.test.ts +++ b/packages/dbt-tools/test/dbt-cli.test.ts @@ -150,9 +150,9 @@ describe("execDbtShow", () => { await expect(execDbtShow("SELECT 1")).rejects.toThrow("Could not parse dbt show output in any format") }) - // --- AI-7082: bubble real dbt error instead of generic "Could not parse" --- + // --- Bubble real dbt error instead of generic "Could not parse" --- - test("AI-7082: surfaces real dbt stderr when run fails", async () => { + test("surfaces real dbt stderr when run fails", async () => { mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: any, cb: Function) => { const err: any = new Error("Command failed: dbt show --inline ...") err.code = 1 @@ -166,7 +166,7 @@ describe("execDbtShow", () => { await expect(execDbtShow("SELECT 1")).rejects.toThrow(/dbt show failed/) }) - test("AI-7082: prefers structured error event in JSON log over raw stderr", async () => { + test("prefers structured error event in JSON log over raw stderr", async () => { const errorLog = JSON.stringify({ info: { level: "error", @@ -184,7 +184,7 @@ describe("execDbtShow", () => { await expect(execDbtShow("SELECT 1")).rejects.toThrow(/Compilation Error.*Model 'foo'/) }) - test("AI-7082: does not surface generic 'Could not parse' when dbt actually crashed", async () => { + test("does not surface generic 'Could not parse' when dbt actually crashed", async () => { mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: any, cb: Function) => { const err: any = new Error("Command failed") err.code = 2 @@ -196,7 +196,7 @@ describe("execDbtShow", () => { await expect(execDbtShow("SELECT 1")).rejects.not.toThrow(/Could not parse dbt show output/) }) - test("AI-7082: preserves generic 'Could not parse' when dbt exited 0 but output unparseable", async () => { + test("preserves generic 'Could not parse' when dbt exited 0 but output unparseable", async () => { // Existing behavior — dbt didn't crash, we just couldn't decode its output. mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: any, cb: Function) => { cb(null, "some unparseable output", "") @@ -205,7 +205,7 @@ describe("execDbtShow", () => { await expect(execDbtShow("SELECT 1")).rejects.toThrow("Could not parse dbt show output in any format") }) - test("AI-7082: falls back to error message when stderr is empty", async () => { + test("falls back to error message when stderr is empty", async () => { mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: any, cb: Function) => { const err: any = new Error("spawn ENOENT") err.code = "ENOENT"