Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
121 changes: 121 additions & 0 deletions internal/taskapi/draft_assist_host.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package taskapi

import (
"context"
"log/slog"
"os"

"github.com/AlexsanderHamir/Hamix/internal/taskapi/draftsidecar"
draftassistmetrics "github.com/AlexsanderHamir/Hamix/pkgs/draftassist/metrics"
"github.com/AlexsanderHamir/Hamix/pkgs/draftassist/runner"
"github.com/AlexsanderHamir/Hamix/pkgs/obs/calltrace"
)

// staticReadyProbe implements DraftAssistReady for boot-time fallback
// paths that cannot recover without operator action (missing binary or
// missing API key). It reports ready=false with a stable reason so the
// SPA banner can show actionable copy.
type staticReadyProbe struct {
runner string
reason string
}

// Ready implements DraftAssistReady.
//
//funclogmeasure:skip category=hot-path reason="Static ready-probe accessor; /draft-assist/ready already traces."
func (s staticReadyProbe) Ready() (bool, string, string) {
return false, s.runner, s.reason
}

// DefaultDraftAssistHost picks the SDK sidecar when ResolveBinary finds
// hamix-draft-agent and CURSOR_API_KEY is set; otherwise it keeps the
// in-process fake runner so CI and offline hosts still serve.
//
//funclogmeasure:skip category=tool-required-noop reason="Boot-time runner picker; each branch emits a decision log."
func DefaultDraftAssistHost() DraftAssistHost {
return draftAssistRunnerSelection()
}

// draftAssistRunnerSelection returns the runner + ready probe pair to
// wire into the handler. The chosen path depends on:
//
// - ResolveBinary miss: fake + no_runner.
// - Binary found, CURSOR_API_KEY unset: fake + missing_key.
// - Both present: sidecar SDK runner + supervisor.Ready() probe.
//
// The supervisor spawn error also degrades to fake + sidecar_down; the
// SPA banner then invites the operator to retry.
//
//funclogmeasure:skip category=tool-required-noop reason="Boot-time runner picker; each branch emits a decision log."
func draftAssistRunnerSelection() DraftAssistHost {
slog.Debug("trace", "cmd", calltrace.LogCmd, "operation", "taskapi.draftAssistRunnerSelection")
fake := runner.NewFake(runner.FakeOptions{})
noopCloser := func() error { return nil }

binPath, lookErr := draftsidecar.ResolveBinary()
if lookErr != nil {
slog.Info("draft-assist runner=fake reason=no_runner",
"cmd", calltrace.LogCmd,
"operation", "taskapi.draftAssistRunnerSelection",
"binary", draftsidecar.BinaryName,
"err", lookErr,
)
return DraftAssistHost{
Runner: fake,
Ready: staticReadyProbe{
runner: "missing",
reason: draftassistmetrics.ReasonNoRunner,
},
Close: noopCloser,
}
}

if os.Getenv(draftsidecar.APIKeyEnv) == "" {
slog.Info("draft-assist runner=fake reason=missing_key",
"cmd", calltrace.LogCmd,
"operation", "taskapi.draftAssistRunnerSelection",
"binary", binPath,
)
return DraftAssistHost{
Runner: fake,
Ready: staticReadyProbe{
runner: "sdk",
reason: draftassistmetrics.ReasonMissingKey,
},
Close: noopCloser,
}
}

sup := draftsidecar.NewSupervisor(draftsidecar.Options{
BinaryPath: binPath,
Stderr: os.Stderr,
})
if err := sup.Start(context.Background()); err != nil {
slog.Warn("draft-assist supervisor failed to start; falling back to fake",
"cmd", calltrace.LogCmd,
"operation", "taskapi.draftAssistRunnerSelection",
"err", err,
)
return DraftAssistHost{
Runner: fake,
Ready: staticReadyProbe{
runner: "sdk",
reason: draftassistmetrics.ReasonSidecarDown,
},
Close: noopCloser,
}
}
slog.Info("draft-assist runner=sdk",
"cmd", calltrace.LogCmd,
"operation", "taskapi.draftAssistRunnerSelection",
"binary", binPath,
"port", sup.Port(),
)
return DraftAssistHost{
Runner: draftsidecar.NewRunner(draftsidecar.RunnerOptions{
PortSource: sup,
}),
Ready: sup,
Close: sup.Close,
}
}
3 changes: 2 additions & 1 deletion internal/taskapi/draftsidecar/doc.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Package draftsidecar owns the lifetime of the hamix-draft-agent sidecar
// process and adapts its loopback HTTP+SSE surface to draftassist
// contract.Runner. The supervisor discovers the binary via exec.LookPath,
// contract.Runner. ResolveBinary locates the launcher (HAMIX_DRAFT_AGENT_BIN,
// a sibling of the current executable, then exec.LookPath). The supervisor
// spawns it with --port 0 so a free port is chosen, parses the
// "listening on <port>" line from stdout to learn the port, then keeps a
// health probe running against GET /readyz. On crashes it respawns with
Expand Down
78 changes: 78 additions & 0 deletions internal/taskapi/draftsidecar/resolve.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package draftsidecar

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)

// BinEnv is the optional absolute (or cwd-relative) path to the
// hamix-draft-agent launcher. When set, ResolveBinary does not fall
// through to sibling or PATH lookup if the file is missing.
const BinEnv = "HAMIX_DRAFT_AGENT_BIN"

// ResolveBinary locates the hamix-draft-agent launcher. First hit wins:
// HAMIX_DRAFT_AGENT_BIN, a launcher next to the current executable, then
// exec.LookPath(BinaryName).
//
//funclogmeasure:skip category=tool-required-noop reason="Boot-time path lookup; no production operation boundary."
func ResolveBinary() (string, error) {
exeDir := ""
if exe, err := os.Executable(); err == nil {
exeDir = filepath.Dir(exe)
}
return resolveBinary(strings.TrimSpace(os.Getenv(BinEnv)), exeDir, exec.LookPath)
}

type lookPathFunc func(file string) (string, error)

func resolveBinary(envPath, exeDir string, lookPath lookPathFunc) (string, error) {
if envPath != "" {
abs, err := filepath.Abs(envPath)
if err != nil {
return "", fmt.Errorf("draftsidecar: %s: %w", BinEnv, err)
}
if err := checkLauncher(abs); err != nil {
return "", fmt.Errorf("draftsidecar: %s=%s: %w (set %s to a built launcher or start via scripts/dev.*)", BinEnv, abs, err, BinEnv)
}
return abs, nil
}
if exeDir != "" {
if sibling := findSibling(exeDir); sibling != "" {
return sibling, nil
}
}
if lookPath != nil {
if found, err := lookPath(BinaryName); err == nil && found != "" {
return found, nil
}
}
return "", fmt.Errorf("draftsidecar: %s not found: set %s or start via scripts/dev.* so the launcher is built", BinaryName, BinEnv)
}

func findSibling(dir string) string {
for _, name := range launcherNames() {
p := filepath.Join(dir, name)
if checkLauncher(p) == nil {
return p
}
}
return ""
}

func launcherNames() []string {
return []string{BinaryName, BinaryName + ".cmd", BinaryName + ".exe"}
}

func checkLauncher(path string) error {
st, err := os.Stat(path)
if err != nil {
return err
}
if st.IsDir() {
return fmt.Errorf("is a directory")
}
return nil
}
109 changes: 109 additions & 0 deletions internal/taskapi/draftsidecar/resolve_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package draftsidecar

import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)

func writeLauncher(t *testing.T, dir, name string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
return path
}

func TestResolveBinary_envWins(t *testing.T) {
dir := t.TempDir()
want := writeLauncher(t, dir, "pinned-agent")
got, err := resolveBinary(want, "unused-exe-dir", func(string) (string, error) {
t.Fatal("lookPath must not run when env is set")
return "", errors.New("unreachable")
})
if err != nil {
t.Fatal(err)
}
abs, err := filepath.Abs(want)
if err != nil {
t.Fatal(err)
}
if got != abs {
t.Fatalf("got %q want %q", got, abs)
}
}

func TestResolveBinary_envMissingFile(t *testing.T) {
missing := filepath.Join(t.TempDir(), "no-such-launcher")
_, err := resolveBinary(missing, t.TempDir(), func(string) (string, error) {
t.Fatal("must not fall through when env is set")
return "", errors.New("unreachable")
})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), BinEnv) {
t.Fatalf("error %q should name %s", err, BinEnv)
}
}

func TestResolveBinary_sibling(t *testing.T) {
dir := t.TempDir()
want := writeLauncher(t, dir, BinaryName)
got, err := resolveBinary("", dir, func(string) (string, error) {
t.Fatal("lookPath must not run when sibling exists")
return "", errors.New("unreachable")
})
if err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}

func TestResolveBinary_siblingCmd(t *testing.T) {
dir := t.TempDir()
want := writeLauncher(t, dir, BinaryName+".cmd")
got, err := resolveBinary("", dir, func(string) (string, error) {
return "", errors.New("not on PATH")
})
if err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}

func TestResolveBinary_lookPath(t *testing.T) {
want := filepath.Join(t.TempDir(), "from-path", BinaryName)
got, err := resolveBinary("", t.TempDir(), func(name string) (string, error) {
if name != BinaryName {
t.Fatalf("LookPath name %q", name)
}
return want, nil
})
if err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}

func TestResolveBinary_missing(t *testing.T) {
_, err := resolveBinary("", t.TempDir(), func(string) (string, error) {
return "", errors.New("not found")
})
if err == nil {
t.Fatal("expected error")
}
msg := err.Error()
if !strings.Contains(msg, BinaryName) || !strings.Contains(msg, BinEnv) {
t.Fatalf("error %q should name binary and %s", err, BinEnv)
}
}
Loading
Loading