Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3ef5386
fix(dbt-tools): move to dbt-integration 0.3.13 so the dbt adapter sta…
ralphstodomingo Sep 24, 2026
4c7ea0e
test(dbt-tools): keep the build checks that still apply, and run the …
ralphstodomingo Sep 29, 2026
4873087
Merge origin/main into fix/dbt-tools-dbt-integration-0.3
ralphstodomingo Oct 12, 2026
3137586
Merge origin/main into fix/dbt-tools-dbt-integration-0.3
ralphstodomingo Oct 13, 2026
f09d259
Merge origin/main into fix/dbt-tools-dbt-integration-0.3
ralphstodomingo Oct 14, 2026
ccc92cc
fix(dbt-tools): require dbt-integration ^0.3.15 so the compiled binar…
ralphstodomingo Oct 14, 2026
97f6c2c
test(dbt-tools): compare the bridge path against the install dir's re…
ralphstodomingo Oct 15, 2026
775161b
Merge remote-tracking branch 'origin/main' into fix/dbt-tools-dbt-int…
ralphstodomingo Oct 19, 2026
89932fa
Merge remote-tracking branch 'origin/main' into fix/dbt-tools-dbt-int…
ralphstodomingo Oct 29, 2026
ada3263
fix(dbt-tools): say what the bundle check checks
ralphstodomingo Oct 29, 2026
9631118
fix(dbt-tools): give dbt Cloud mode its dbt Core command engine
ralphstodomingo Oct 30, 2026
c0f6a99
test(dbt-tools): start the adapter under Bun in CI and tighten the re…
ralphstodomingo Oct 30, 2026
5018286
test(dbt-tools): run the Bun case as the CLI with a temp HOME
ralphstodomingo Oct 30, 2026
dc2e358
test(dbt-tools): check the bridge output stays off the host streams
ralphstodomingo Nov 5, 2026
8c17b8c
Merge remote-tracking branch 'origin/main' into fix/dbt-tools-dbt-int…
ralphstodomingo Nov 5, 2026
c619ddc
fix(dbt-tools): take dbt-integration 0.3.17 and read its stale-manife…
ralphstodomingo Nov 5, 2026
b68e41e
fix(dbt_lineage): start no adapter when dbt-integration has no lineag…
ralphstodomingo Nov 5, 2026
2c48874
test(dbt-tools): give the dbt Cloud cases a stand-in dbt Cloud CLI
ralphstodomingo Nov 5, 2026
3a5281b
refactor(dbt-tools): print a failure in its own function
ralphstodomingo Nov 5, 2026
ace3ebb
Merge branch 'main' into fix/dbt-tools-dbt-integration-0.3
ralphstodomingo Nov 9, 2026
efa7dc9
chore(dbt-tools): lock dbt-integration 0.3.17
ralphstodomingo Nov 9, 2026
88f730f
fix(dbt-tools): lead a text error answer with the stale-manifest note
ralphstodomingo Nov 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -286,9 +286,19 @@ jobs:
echo "::error::dbt-tools bundle contains hardcoded absolute path in __dirname"
exit 1
fi
# Verify __dirname was patched to runtime resolution
if ! grep -q 'import.meta.dirname' packages/dbt-tools/dist/index.js; then
echo "::error::dbt-tools bundle missing import.meta.dirname patch"
# The bridge path is built at runtime from the bundle's own URL
if ! grep -q 'fileURLToPath(import.meta.url)' packages/dbt-tools/dist/index.js; then

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, non-blocking (the preceding bun run build step already fails on this): the release smoke check is weaker than copy-python.ts.

Mutation on the built bundle: replacing x.join(_, "node_python_bridge.py") with a literal /home/runner/work/…/node_python_bridge.py still passes all three release greps. fileURLToPath(import.meta.url) is still present, and there is no var __dirname = "/…". copy-python.ts (its "node_python_bridge.py" include) and build-integrity (its checkout-path check) both catch it. To make this step stand on its own, also grep for "node_python_bridge.py" and for $GITHUB_WORKSPACE in dist/index.js.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c0f6a99. The step now checks two more things in dist/index.js:

  • the bare "node_python_bridge.py" literal must be present;
  • $GITHUB_WORKSPACE must not appear. The step also fails if the variable is unset, because an empty pattern would match everything.

Results on a local build:

  • a clean build passes;
  • your mutation (the join replaced by a literal checkout path) fails on the first new check;
  • a checkout path appended to the bundle fails on the second.

echo "::error::dbt-tools bundle lacks fileURLToPath(import.meta.url), which the bridge path is built from"
exit 1
fi
# ...joined with the bare script name, not a literal path ending in it
if ! grep -qF '"node_python_bridge.py"' packages/dbt-tools/dist/index.js; then
echo "::error::dbt-tools bundle lacks the \"node_python_bridge.py\" name the bridge path is joined from"
exit 1
fi
# No path from this checkout baked into the bundle
if [ -z "$GITHUB_WORKSPACE" ] || grep -qF "$GITHUB_WORKSPACE" packages/dbt-tools/dist/index.js; then
echo "::error::dbt-tools bundle contains the build checkout path, or GITHUB_WORKSPACE is unset"
exit 1
fi
echo "dbt-tools smoke test passed"
Expand Down
82 changes: 18 additions & 64 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/dbt-tools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
"scripts": {
"build": "bun build src/index.ts --outdir dist --target node --format esm && bun run script/copy-python.ts",
"typecheck": "tsc --noEmit",
"test": "bun test test/cli.test.ts test/config.test.ts test/dbt-cli.test.ts test/dbt-resolve.test.ts test/build-integrity.test.ts --timeout 30000",
"test": "bun test test/cli.test.ts test/config.test.ts test/dbt-cli.test.ts test/dbt-resolve.test.ts test/adapter-bun.test.ts test/stale-manifest.test.ts test/build-integrity.test.ts --timeout 30000",
"test:e2e": "bun test test/e2e/ --timeout 300000"
},
"dependencies": {
"@altimateai/dbt-integration": "^0.2.2"
"@altimateai/dbt-integration": "^0.3.17"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
Expand Down
31 changes: 13 additions & 18 deletions packages/dbt-tools/script/copy-python.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cpSync, existsSync, readFileSync, writeFileSync } from "fs"
import { cpSync, existsSync, readFileSync } from "fs"
import { dirname, join } from "path"

const dist = join(import.meta.dir, "..", "dist")
Expand All @@ -20,23 +20,18 @@ if (!existsSync(bridgePy)) {
cpSync(bridgePy, join(dist, "node_python_bridge.py"))
console.log(`Copied node_python_bridge.py → dist/`)

// 3. Fix the hardcoded __dirname that bun bakes at compile time.
// Replace it with a runtime resolution so the bridge script is found
// relative to the built index.js, not the CI runner's node_modules.
// 3. dbt-integration resolves node_python_bridge.py at runtime from its own
// location (`fileURLToPath(import.meta.url)`), which in this bundle is dist/,
// where step 2 put the script. This only checks that the bundle still has
// the references that path is built from and no path baked in at build time;
// test/build-integrity.test.ts runs the packaged CLI from another directory
// to check where the script is found.
const indexPath = join(dist, "index.js")
let code = readFileSync(indexPath, "utf8")
const pattern = /var __dirname\s*=\s*"[^"]*python-bridge[^"]*"/
if (pattern.test(code)) {
// import.meta.dirname is supported by Bun and Node >= 20.11.0.
// Fallback via __require handles older runtimes where import.meta.dirname is unavailable.
const replacement = `var __dirname = typeof import.meta.dirname === "string" ? import.meta.dirname : __require("path").dirname(__require("url").fileURLToPath(import.meta.url))`
code = code.replace(pattern, replacement)
writeFileSync(indexPath, code)
console.log(`Patched __dirname in dist/index.js`)
} else {
const found = code.match(/var __dirname[^;]*/)?.[0] ?? "(not found)"
console.error(`ERROR: could not find python-bridge __dirname to patch — the bundle format may have changed`)
console.error(` Pattern: ${pattern}`)
console.error(` Nearest match: ${found}`)
const code = readFileSync(indexPath, "utf8")
const baked = /var __dirname\s*=\s*"(?:[A-Za-z]:\\\\|\/)/
if (!code.includes("fileURLToPath(import.meta.url)") || !code.includes(`"node_python_bridge.py"`) || baked.test(code)) {
Comment thread
ralphstodomingo marked this conversation as resolved.
Resolved
console.error(`ERROR: dist/index.js lacks the references the bridge path is built from, or has a build-time path`)
console.error(` Has the @altimateai/dbt-integration bundle format changed?`)
process.exit(1)
}
console.log(`Checked dist/index.js: bridge path references present, no build-time path`)
35 changes: 21 additions & 14 deletions packages/dbt-tools/src/adapter.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { Config } from "./config"
import { bufferLog } from "./log-buffer"
import { recordManifest } from "./stale-manifest"
export { getRecentDbtLogs, clearDbtLogs } from "./log-buffer"
import {
DBTProjectIntegrationAdapter,
DBTProjectIntegrationAdapterEvents,
DEFAULT_CONFIGURATION_VALUES,
DBTCommandFactory,
DBTCommandExecutionInfrastructure,
Expand All @@ -15,6 +17,8 @@ import {
GraphParser,
SourceParser,
TestParser,
UnitTestParser,
SemanticModelParser,
ExposureParser,
FunctionParser,
DocParser,
Expand Down Expand Up @@ -63,7 +67,10 @@ function terminal(): DBTTerminal {
show: async () => {},
log: (msg: string) => bufferLog(`[dbt] ${msg}`),
trace: () => {},
debug: () => {},
// A crash at bridge startup is told only on the bridge's stderr, which the library logs here.
debug: (name: string, msg: string) => {
if (name === "PythonBridgeStderr") bufferLog(`[dbt:stderr] ${msg}`)
},
info: (_name: string, msg: string) => bufferLog(`[dbt] ${msg}`),
warn: (_name: string, msg: string) => bufferLog(`[dbt:warn] ${msg}`),
error: (_name: string, msg: string, e: unknown) => {
Expand All @@ -82,6 +89,12 @@ function env(cfg: Config): RuntimePythonEnvironment {
}
}

/** Whether this dbt-integration answers a model's lineage, for a caller deciding whether to `create`. */
export function hasModelLineage(): boolean {
const prototype = DBTProjectIntegrationAdapter.prototype as { getModelLineage?: unknown }
return typeof prototype.getModelLineage === "function"
}

export async function create(cfg: Config): Promise<DBTProjectIntegrationAdapter> {
const config = configuration(cfg)
const term = terminal()
Expand Down Expand Up @@ -141,6 +154,8 @@ export async function create(cfg: Config): Promise<DBTProjectIntegrationAdapter>
defer,
changed,
cloudVariantDetector,
// Every variant but fusion-stable runs on a dbt Core command engine, which needs these.
{ pythonDBTCommandExecutionStrategy: python, dbtConfiguration: config, dbtIntegrationClient: client },
)

const command = (
Expand Down Expand Up @@ -170,19 +185,7 @@ export async function create(cfg: Config): Promise<DBTProjectIntegrationAdapter>
defer: DeferConfig,
changed: () => void,
): DBTProjectIntegration =>
new DBTFusionCommandProjectIntegration(
infra,
factory,
cli,
runtime,
provider,
term,
root,
diag,
defer,
changed,
cloudVariantDetector,
)
new DBTFusionCommandProjectIntegration(infra, factory, cli, runtime, provider, term, root, diag, defer, changed)

const adapter = new DBTProjectIntegrationAdapter(
config,
Expand All @@ -200,13 +203,17 @@ export async function create(cfg: Config): Promise<DBTProjectIntegrationAdapter>
new GraphParser(term),
new SourceParser(term),
new TestParser(term),
new UnitTestParser(term),
new ExposureParser(term),
new FunctionParser(term),
new DocParser(term),
term,
new ModelDepthParser(term, client, config),
new SemanticModelParser(term),
)

// Before `initialize`, whose parse is the one the commands answer from.
adapter.on(DBTProjectIntegrationAdapterEvents.MANIFEST_PARSED, recordManifest)
try {
await adapter.initialize()
} catch (e) {
Expand Down
11 changes: 9 additions & 2 deletions packages/dbt-tools/src/commands/graph.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { DBTProjectIntegrationAdapter } from "@altimateai/dbt-integration"
import { execDbtLs } from "../dbt-cli"
import { staleManifestNote } from "../stale-manifest"

export async function children(adapter: DBTProjectIntegrationAdapter, args: string[]) {
const model = flag(args, "model")
if (!model) return { error: "Missing --model" }
try {
return await adapter.getChildrenModels({ table: model })
return fromManifest(await adapter.getChildrenModels({ table: model }))
} catch (e) {
// nodeMetaMap/graphMetaMap errors are specific to the library's manifest parsing.
// Also catch TypeError for property-access failures on undefined nodes.
Expand All @@ -23,7 +24,7 @@ export async function parents(adapter: DBTProjectIntegrationAdapter, args: strin
const model = flag(args, "model")
if (!model) return { error: "Missing --model" }
try {
return await adapter.getParentModels({ table: model })
return fromManifest(await adapter.getParentModels({ table: model }))
} catch (e) {
if (
e instanceof TypeError ||
Expand All @@ -35,6 +36,12 @@ export async function parents(adapter: DBTProjectIntegrationAdapter, args: strin
}
}

// The note comes first: a long answer can be cut off on its way to the reader.
function fromManifest<T>(models: T[]) {
const stale_manifest = staleManifestNote()
return stale_manifest ? { stale_manifest, models } : models

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (not a blocker): on a stale manifest, every node comes back as isExternalProject: true.

0.3.17's graph lookup computes isExternalProject: u.package_name !== this.getProjectName() on the live adapter. After the failed project check, that adapter's name is unknown_<uuid>. staleManifestProject() carries the manifest's metadata.project_name, but the lookup doesn't use it. Reproduced with a broken profile and an earlier manifest: children --model customers returned the project's own customer_totals with isExternalProject: true. With a healthy profile it returns false. 0.2.x/0.3.15 failed this command outright, so the bump is what makes this answer reachable.

Not blocking: the input is an edge case, the flaw is in the library, and the note you add already warns that the answer may not match the project. Follow-up options: fix it in dbt-integration (use the stale project's name in the lookup), or, while stale_manifest is set, recompute the flag here from each node's package against the manifest's project name.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. This one is already fixed in @altimateai/dbt-integration: on a stale manifest it now compares each node's package against the project the manifest was written for (its metadata.project_name), not the placeholder name. The fix ships in its next release. Nothing in dbt-tools changes; the bump to that release carries it.

}

function flag(args: string[], name: string): string | undefined {
const i = args.indexOf(`--${name}`)
return i >= 0 ? args[i + 1] : undefined
Expand Down
9 changes: 9 additions & 0 deletions packages/dbt-tools/src/commands/schema-verify.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ColumnMetaData, DBTProjectIntegrationAdapter } from "@altimateai/dbt-integration"
import { staleManifestNote } from "../stale-manifest"

/**
* Verify that a model's actual produced columns match the spec declared in
Expand All @@ -22,8 +23,16 @@ import type { ColumnMetaData, DBTProjectIntegrationAdapter } from "@altimateai/d
* Skip cases:
* - "no-spec": schema.yml doesn't declare columns for this model — nothing
* to verify; agent has no contract to fail against.
*
* Every answer rests on the manifest, its errors included, so each one says when that's a stale one.
*/
export async function schemaVerify(adapter: DBTProjectIntegrationAdapter, args: string[]) {
const answer = await verify(adapter, args)
const stale_manifest = staleManifestNote()
return stale_manifest ? { stale_manifest, ...answer } : answer

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Skip the note for answers that never read the manifest.

verify() returns { error: "Missing --model" } before it reads anything, and the wrapper still adds stale_manifest, so on a project whose check failed altimate-dbt schema-verify (no --model) answers { stale_manifest, error: "Missing --model" } — a note saying the answer comes from an old manifest when the answer is about a missing flag. The blanket attachment has the same effect in build.ts:76: every per-model result it collects carries its own copy, so a project-wide build repeats the full note once per mismatched model in schema_verify_summary.mismatches. Return the note only for answers that got past the --model check.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this one: neither answer is wrong. On a usage error the note is still true of the project, and the next call with --model answers from that manifest. In build, each copy is accurate, only repeated, and build writes that summary only after a successful project-wide dbt build. Recorded as R5 in the description.

}

async function verify(adapter: DBTProjectIntegrationAdapter, args: string[]) {
const model = flag(args, "model")
if (!model) return { error: "Missing --model" }

Expand Down
11 changes: 11 additions & 0 deletions packages/dbt-tools/src/failure-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/** What `--format text` prints on stderr for an error answer. */
export function failureLines(failed: Record<string, unknown>): string[] {
const lines: string[] = []
// The note comes first, as in the JSON answer: the error rests on that manifest.
const stale = failed.stale_manifest
if (stale && typeof stale === "object" && "message" in stale) lines.push(String(stale.message))
lines.push(String(failed.error))
if ("fix" in failed) lines.push(String(failed.fix))
if (Array.isArray(failed.logs)) lines.push(failed.logs.join("\n"))
return lines
}
24 changes: 11 additions & 13 deletions packages/dbt-tools/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { existsSync } from "fs"
import { read, type Config } from "./config"
import { init } from "./commands/init"
import { validate } from "./check"
import { failureLines } from "./failure-text"

const USAGE = {
commands: {
Expand Down Expand Up @@ -37,12 +38,6 @@ function flag(args: string[], name: string): string | undefined {

function diagnose(err: Error): { error: string; fix?: string } {
const msg = err.message || String(err)
if (msg.includes("private field") || msg.includes("#stdin") || msg.includes("#stdout")) {
return {
error: "Internal error: Bun runtime incompatibility with python-bridge. Please report this issue.",
fix: "Try updating: bun install, or file a bug at https://github.com/AltimateAI/altimate-code/issues",
}
}
if (msg.includes("IPC channel") || msg.includes("ERR_IPC_CHANNEL_CLOSED")) {
return {
error: "Failed to start Python bridge. The dbt Python process exited unexpectedly.",
Expand Down Expand Up @@ -75,8 +70,7 @@ function output(result: unknown) {
if (fmt && typeof result === "object" && result !== null) {
const obj = result as Record<string, unknown>
if ("error" in obj) {
console.error(String(obj.error))
if ("fix" in obj) console.error(String(obj.fix))
console.error(failureLines(obj).join("\n"))
process.exit(1)
}
if ("sql" in obj) {
Expand All @@ -92,7 +86,7 @@ function output(result: unknown) {
if (result && typeof result === "object" && "error" in result) process.exit(1)
}

function bail(err: unknown): never {
function failure(err: unknown) {
const result: Record<string, unknown> = diagnose(err instanceof Error ? err : new Error(String(err)))
// Include buffered dbt logs for diagnostics (see #249 — logs are buffered
// in-memory instead of written to stderr to avoid TUI corruption).
Expand All @@ -105,11 +99,15 @@ function bail(err: unknown): never {
} catch {
// log-buffer might not have been loaded yet
}
console.log(JSON.stringify(result, null, 2))
return result
}

function bail(err: unknown): never {
console.log(JSON.stringify(failure(err), null, 2))
process.exit(1)
}

// Catch unhandled rejections (bluebird throws outside normal promise chains)
// An error that escapes main() still ends as a JSON error on stdout
process.on("unhandledRejection", bail)
process.on("uncaughtException", bail)

Expand Down Expand Up @@ -142,13 +140,13 @@ async function main() {
const { configure } = await import("./dbt-cli")
configure({ pythonPath: cfg.pythonPath, projectRoot: cfg.projectRoot })

// Lazy import to avoid loading python-bridge until needed
// Lazy import: the adapter loads dbt-integration, which commands above don't need
let adapter
try {
const { create } = await import("./adapter")
adapter = await create(cfg)
} catch (err) {
return diagnose(err instanceof Error ? err : new Error(String(err)))
return failure(err)
}

let result: unknown
Expand Down
30 changes: 30 additions & 0 deletions packages/dbt-tools/src/stale-manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { ParsedManifest, StaleManifest } from "@altimateai/dbt-integration"

// Set when the project check failed and the last parse read what an earlier parse left on disk.
let stale: StaleManifest | undefined

/** Takes each parsed manifest from the adapter, whose graph lookups don't say which one they used. */
export function recordManifest(parsed: ParsedManifest): void {
stale = parsed.stale
}

function ago(ms: number): string {
const format = new Intl.RelativeTimeFormat("en")
const minutes = Math.round(ms / 60_000)
if (minutes < 60) return format.format(-minutes, "minute")
const hours = Math.round(minutes / 60)
if (hours < 48) return format.format(-hours, "hour")
return format.format(-Math.round(hours / 24), "day")
}

/** What an answer built from the manifest adds, or undefined when the manifest is current. */
export function staleManifestNote(now = Date.now()) {
if (!stale) return undefined
const age = Math.max(0, now - stale.writtenAt)
return {
message: `The dbt project check failed, so this comes from the manifest of the last successful parse, written ${ago(age)}. It may not match the project as it is now.`,
manifest_path: stale.manifestPath,
written_at: new Date(stale.writtenAt).toISOString(),
age_seconds: Math.round(age / 1000),
}
}
Loading
Loading