diff --git a/cmd/aima/infra.go b/cmd/aima/infra.go index e45260e..11d154a 100644 --- a/cmd/aima/infra.go +++ b/cmd/aima/infra.go @@ -300,10 +300,19 @@ func buildNativeRuntime(dataDir string, engineAssets []knowledge.EngineAsset) ru platform := goruntime.GOOS + "-" + goruntime.GOARCH distDir := filepath.Join(dataDir, "dist", platform) bm := engine.NewBinaryManager(distDir) + // Pre-installed engine dirs (AIMA_ENGINE_DIR) — the SAME source the engine + // scanner reads — so a scanned native engine binary is also launchable. + var engineDirs []string + for _, dir := range filepath.SplitList(os.Getenv("AIMA_ENGINE_DIR")) { + if dir = strings.TrimSpace(dir); dir != "" { + engineDirs = append(engineDirs, dir) + } + } return runtime.NewNativeRuntime( filepath.Join(dataDir, "logs"), distDir, filepath.Join(dataDir, "deployments"), + runtime.WithEngineDirs(engineDirs), runtime.WithBinaryResolver(func(ctx context.Context, src *engine.BinarySource) (string, error) { if !deployAutoPullAllowed(ctx) { name := "engine binary" diff --git a/internal/runtime/native.go b/internal/runtime/native.go index 45db917..e50ad0c 100644 --- a/internal/runtime/native.go +++ b/internal/runtime/native.go @@ -61,8 +61,9 @@ type BinaryResolveFunc func(ctx context.Context, source *engine.BinarySource) (s // NativeRuntime manages inference engines as direct OS processes. type NativeRuntime struct { logDir string - distDir string // e.g. ~/.aima/dist/windows-amd64/ - deployDir string // e.g. ~/.aima/deployments/ — persisted deployment metadata + distDir string // e.g. ~/.aima/dist/windows-amd64/ + engineDirs []string // pre-installed engine dirs (AIMA_ENGINE_DIR) — same source the engine scanner uses + deployDir string // e.g. ~/.aima/deployments/ — persisted deployment metadata resolveBinary BinaryResolveFunc engineAssets []knowledge.EngineAsset processes map[string]*nativeProcess @@ -101,6 +102,16 @@ func WithNativeEngineAssets(assets []knowledge.EngineAsset) NativeOption { } } +// WithEngineDirs registers pre-installed engine directories (from AIMA_ENGINE_DIR). +// The native binary resolver checks these — the SAME dirs the engine scanner uses — +// so a bare engine name (e.g. "llama-server") that was discovered by scanning is also +// launchable, even when it is neither in dist nor on PATH. Keeps "scanned ⇒ launchable". +func WithEngineDirs(dirs []string) NativeOption { + return func(r *NativeRuntime) { + r.engineDirs = dirs + } +} + func (r *NativeRuntime) Name() string { return "native" } func (r *NativeRuntime) Deploy(ctx context.Context, req *DeployRequest) error { @@ -177,17 +188,19 @@ func (r *NativeRuntime) Deploy(ctx context.Context, req *DeployRequest) error { return fmt.Errorf("create log file: %w", err) } - // Resolve binary: dist/ first, then auto-download if source is available - if r.distDir != "" { - if resolved := r.findInDist(command[0]); resolved != "" { + // Resolve binary: dist/ first, then the pre-installed engine dirs + // (AIMA_ENGINE_DIR — the SAME dirs the engine scanner uses, so anything scanning + // found is launchable), then auto-download if a source is available. + if resolved := r.findInDist(command[0]); resolved != "" { + command[0] = resolved + } else if resolved := r.findInEngineDirs(command[0]); resolved != "" { + command[0] = resolved + } else if r.resolveBinary != nil && req.BinarySource != nil { + slog.Info("binary not in dist or engine dirs, attempting auto-download", "binary", command[0]) + if resolved, err := r.resolveBinary(ctx, req.BinarySource); err == nil { command[0] = resolved - } else if r.resolveBinary != nil && req.BinarySource != nil { - slog.Info("binary not in dist, attempting auto-download", "binary", command[0]) - if resolved, err := r.resolveBinary(ctx, req.BinarySource); err == nil { - command[0] = resolved - } else { - slog.Warn("auto-download failed, will try PATH", "binary", command[0], "error", err) - } + } else { + slog.Warn("auto-download failed, will try PATH", "binary", command[0], "error", err) } } @@ -852,14 +865,32 @@ func (r *NativeRuntime) loadAllMeta() []*deploymentMeta { // findInDist checks for a binary in the dist directory. // On Windows, also tries with .exe suffix. func (r *NativeRuntime) findInDist(name string) string { + return findBinaryIn([]string{r.distDir}, name) +} + +// findInEngineDirs resolves a bare engine binary name against the pre-installed +// engine dirs (AIMA_ENGINE_DIR). This mirrors how the engine scanner discovers +// binaries, so an engine that scanning found is also the one that gets launched. +func (r *NativeRuntime) findInEngineDirs(name string) string { + return findBinaryIn(r.engineDirs, name) +} + +// findBinaryIn returns the absolute path of name (with .exe on Windows) in the +// first of dirs that contains it. +func findBinaryIn(dirs []string, name string) string { candidates := []string{name} if goruntime.GOOS == "windows" && !strings.HasSuffix(name, ".exe") { candidates = append(candidates, name+".exe") } - for _, c := range candidates { - p := filepath.Join(r.distDir, c) - if _, err := os.Stat(p); err == nil { - return p + for _, dir := range dirs { + if dir == "" { + continue + } + for _, c := range candidates { + p := filepath.Join(dir, c) + if _, err := os.Stat(p); err == nil { + return p + } } } return "" diff --git a/internal/runtime/native_test.go b/internal/runtime/native_test.go index 5d717d0..ff74a5b 100644 --- a/internal/runtime/native_test.go +++ b/internal/runtime/native_test.go @@ -1132,3 +1132,31 @@ func TestDeployAppendsCustomPortFlags(t *testing.T) { t.Fatalf("command = %q, should not contain synthesized --port flag", argStr) } } + +func TestFindInEngineDirsResolvesScannedBinary(t *testing.T) { + // A native engine binary discovered by scanning AIMA_ENGINE_DIR must also be + // launchable: the native runtime resolves the bare binary name against the same + // engine dirs, so "scanned ⇒ launchable" holds even when it is not in dist/PATH. + dir := t.TempDir() + fileName := "llama-server" + if runtime.GOOS == "windows" { + fileName += ".exe" + } + want := filepath.Join(dir, fileName) + if err := os.WriteFile(want, []byte("stub"), 0o755); err != nil { + t.Fatalf("write stub: %v", err) + } + + // Empty dir entries are skipped; the bare name resolves to the absolute path. + r := &NativeRuntime{engineDirs: []string{"", dir}} + if got := r.findInEngineDirs("llama-server"); got != want { + t.Errorf("findInEngineDirs = %q, want %q", got, want) + } + // Missing binary and no configured dirs both resolve to empty. + if got := r.findInEngineDirs("nope"); got != "" { + t.Errorf("missing binary: got %q, want empty", got) + } + if got := (&NativeRuntime{}).findInEngineDirs("llama-server"); got != "" { + t.Errorf("no engine dirs: got %q, want empty", got) + } +}