Skip to content

Commit 6e0f81d

Browse files
anandgupta42claude
andauthored
fix: auto-bootstrap Python engine before starting bridge (#25)
* fix: auto-bootstrap Python engine before starting bridge Bridge.start() now calls ensureEngine() to download uv, create an isolated venv, and install altimate-engine before spawning the Python subprocess. resolvePython() also checks the managed venv path so the correct interpreter is used after bootstrapping. Previously, resolvePython() would fall through to system python3 which doesn't have altimate_engine installed, causing ModuleNotFoundError on first run. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add bridge client tests for ensureEngine and resolvePython - Export resolvePython() from client.ts for direct unit testing - Test that ALTIMATE_CLI_PYTHON env var takes highest priority - Test that managed engine venv is used when present on disk - Test fallback to python3 when no venvs exist - Test that ensureEngine() is called before bridge spawn - Mock only bridge/engine module to avoid leaking into other tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 391f365 commit 6e0f81d

File tree

2 files changed

+181
-17
lines changed

2 files changed

+181
-17
lines changed

packages/altimate-code/src/bridge/client.ts

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,32 @@
99
import { spawn, type ChildProcess } from "child_process"
1010
import { existsSync } from "fs"
1111
import path from "path"
12+
import { ensureEngine, enginePythonPath } from "./engine"
1213
import type { BridgeMethod, BridgeMethods } from "./protocol"
1314

15+
/** Resolve the Python interpreter to use for the engine sidecar.
16+
* Exported for testing — not part of the public API. */
17+
export function resolvePython(): string {
18+
// 1. Explicit env var
19+
if (process.env.ALTIMATE_CLI_PYTHON) return process.env.ALTIMATE_CLI_PYTHON
20+
21+
// 2. Check for .venv relative to altimate-engine package (local dev)
22+
const engineDir = path.resolve(__dirname, "..", "..", "..", "altimate-engine")
23+
const venvPython = path.join(engineDir, ".venv", "bin", "python")
24+
if (existsSync(venvPython)) return venvPython
25+
26+
// 3. Check for .venv in cwd
27+
const cwdVenv = path.join(process.cwd(), ".venv", "bin", "python")
28+
if (existsSync(cwdVenv)) return cwdVenv
29+
30+
// 4. Check the managed engine venv (created by ensureEngine)
31+
const managedPython = enginePythonPath()
32+
if (existsSync(managedPython)) return managedPython
33+
34+
// 5. Fallback
35+
return "python3"
36+
}
37+
1438
export namespace Bridge {
1539
let child: ChildProcess | undefined
1640
let requestId = 0
@@ -43,24 +67,8 @@ export namespace Bridge {
4367
})
4468
}
4569

46-
function resolvePython(): string {
47-
// 1. Explicit env var
48-
if (process.env.ALTIMATE_CLI_PYTHON) return process.env.ALTIMATE_CLI_PYTHON
49-
50-
// 2. Check for .venv relative to altimate-engine package
51-
const engineDir = path.resolve(__dirname, "..", "..", "..", "altimate-engine")
52-
const venvPython = path.join(engineDir, ".venv", "bin", "python")
53-
if (existsSync(venvPython)) return venvPython
54-
55-
// 3. Check for .venv in cwd
56-
const cwdVenv = path.join(process.cwd(), ".venv", "bin", "python")
57-
if (existsSync(cwdVenv)) return cwdVenv
58-
59-
// 4. Fallback
60-
return "python3"
61-
}
62-
6370
async function start() {
71+
await ensureEngine()
6472
const pythonCmd = resolvePython()
6573
child = spawn(pythonCmd, ["-m", "altimate_engine.server"], {
6674
stdio: ["pipe", "pipe", "pipe"],
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { describe, expect, test, mock, afterEach } from "bun:test"
2+
import path from "path"
3+
import fsp from "fs/promises"
4+
import { existsSync } from "fs"
5+
import os from "os"
6+
7+
// ---------------------------------------------------------------------------
8+
// Mock state
9+
// ---------------------------------------------------------------------------
10+
11+
let ensureEngineCalls = 0
12+
let managedPythonPath = "/nonexistent/managed-engine/venv/bin/python"
13+
14+
// ---------------------------------------------------------------------------
15+
// Mock: bridge/engine (only module we mock — avoids leaking into other tests)
16+
// ---------------------------------------------------------------------------
17+
18+
mock.module("../../src/bridge/engine", () => ({
19+
ensureEngine: async () => {
20+
ensureEngineCalls++
21+
},
22+
enginePythonPath: () => managedPythonPath,
23+
}))
24+
25+
// ---------------------------------------------------------------------------
26+
// Import module under test — AFTER mock.module() calls
27+
// ---------------------------------------------------------------------------
28+
29+
const { resolvePython } = await import("../../src/bridge/client")
30+
31+
// ---------------------------------------------------------------------------
32+
// Helpers
33+
// ---------------------------------------------------------------------------
34+
35+
const tmpRoot = path.join(os.tmpdir(), "bridge-test-" + process.pid + "-" + Math.random().toString(36).slice(2))
36+
37+
async function createFakeFile(filePath: string) {
38+
await fsp.mkdir(path.dirname(filePath), { recursive: true })
39+
await fsp.writeFile(filePath, "")
40+
}
41+
42+
// Paths that resolvePython() checks for dev/cwd venvs.
43+
// From source file: __dirname is <repo>/packages/altimate-code/src/bridge/
44+
// From test file: __dirname is <repo>/packages/altimate-code/test/bridge/
45+
// Both resolve 3 levels up to <repo>/packages/, so the dev venv path is identical.
46+
const devVenvPython = path.resolve(__dirname, "..", "..", "..", "altimate-engine", ".venv", "bin", "python")
47+
const cwdVenvPython = path.join(process.cwd(), ".venv", "bin", "python")
48+
const hasLocalDevVenv = existsSync(devVenvPython) || existsSync(cwdVenvPython)
49+
50+
// ---------------------------------------------------------------------------
51+
// Tests
52+
// ---------------------------------------------------------------------------
53+
54+
describe("resolvePython", () => {
55+
afterEach(async () => {
56+
ensureEngineCalls = 0
57+
delete process.env.ALTIMATE_CLI_PYTHON
58+
managedPythonPath = "/nonexistent/managed-engine/venv/bin/python"
59+
await fsp.rm(tmpRoot, { recursive: true, force: true }).catch(() => {})
60+
})
61+
62+
test("prefers ALTIMATE_CLI_PYTHON env var over all other sources", () => {
63+
process.env.ALTIMATE_CLI_PYTHON = "/custom/python3.12"
64+
expect(resolvePython()).toBe("/custom/python3.12")
65+
})
66+
67+
test("env var takes priority even when managed venv exists on disk", async () => {
68+
const fakePython = path.join(tmpRoot, "managed", "venv", "bin", "python")
69+
await createFakeFile(fakePython)
70+
managedPythonPath = fakePython
71+
72+
process.env.ALTIMATE_CLI_PYTHON = "/override/python3"
73+
expect(resolvePython()).toBe("/override/python3")
74+
})
75+
76+
test("uses managed engine venv when it exists on disk", async () => {
77+
if (hasLocalDevVenv) {
78+
console.log("Skipping: local dev venv exists, can't test managed venv resolution in isolation")
79+
return
80+
}
81+
82+
const fakePython = path.join(tmpRoot, "managed", "venv", "bin", "python")
83+
await createFakeFile(fakePython)
84+
managedPythonPath = fakePython
85+
86+
expect(resolvePython()).toBe(fakePython)
87+
})
88+
89+
test("falls back to python3 when no venvs exist", () => {
90+
if (hasLocalDevVenv) {
91+
console.log("Skipping: local dev venv exists, can't test fallback in isolation")
92+
return
93+
}
94+
95+
expect(resolvePython()).toBe("python3")
96+
})
97+
98+
test("does not use managed venv when it does not exist on disk", () => {
99+
if (hasLocalDevVenv) {
100+
console.log("Skipping: local dev venv exists")
101+
return
102+
}
103+
104+
// managedPythonPath points to nonexistent path by default
105+
expect(resolvePython()).toBe("python3")
106+
})
107+
108+
test("checks enginePythonPath() from the engine module", async () => {
109+
if (hasLocalDevVenv) {
110+
console.log("Skipping: local dev venv exists")
111+
return
112+
}
113+
114+
// Initially the path doesn't exist → falls back to python3
115+
expect(resolvePython()).toBe("python3")
116+
117+
// Now create the file and update the managed path
118+
const fakePython = path.join(tmpRoot, "engine-venv", "bin", "python")
119+
await createFakeFile(fakePython)
120+
managedPythonPath = fakePython
121+
122+
// Now it should find the managed venv
123+
expect(resolvePython()).toBe(fakePython)
124+
})
125+
})
126+
127+
describe("Bridge.start integration", () => {
128+
// These tests verify that ensureEngine() is called by observing the
129+
// ensureEngineCalls counter. We don't mock child_process, so start()
130+
// will attempt a real spawn — we use /bin/echo which exists but
131+
// won't speak JSON-RPC, causing the bridge ping to fail.
132+
133+
afterEach(() => {
134+
ensureEngineCalls = 0
135+
delete process.env.ALTIMATE_CLI_PYTHON
136+
managedPythonPath = "/nonexistent/managed-engine/venv/bin/python"
137+
})
138+
139+
test("ensureEngine is called when bridge starts", async () => {
140+
const { Bridge } = await import("../../src/bridge/client")
141+
142+
// /bin/echo exists and will spawn successfully but won't respond to
143+
// the JSON-RPC ping, so start() will eventually fail on verification.
144+
process.env.ALTIMATE_CLI_PYTHON = "/bin/echo"
145+
146+
try {
147+
await Bridge.call("ping", {} as any)
148+
} catch {
149+
// Expected: the bridge ping verification will fail
150+
}
151+
152+
// Even though the ping failed, ensureEngine was called before the spawn attempt
153+
expect(ensureEngineCalls).toBeGreaterThanOrEqual(1)
154+
Bridge.stop()
155+
})
156+
})

0 commit comments

Comments
 (0)