diff --git a/internal/taskapi/draft_assist_host.go b/internal/taskapi/draft_assist_host.go new file mode 100644 index 00000000..3cdb7863 --- /dev/null +++ b/internal/taskapi/draft_assist_host.go @@ -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, + } +} diff --git a/internal/taskapi/draftsidecar/doc.go b/internal/taskapi/draftsidecar/doc.go index 5b272ff8..6bb5a0a4 100644 --- a/internal/taskapi/draftsidecar/doc.go +++ b/internal/taskapi/draftsidecar/doc.go @@ -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 " line from stdout to learn the port, then keeps a // health probe running against GET /readyz. On crashes it respawns with diff --git a/internal/taskapi/draftsidecar/resolve.go b/internal/taskapi/draftsidecar/resolve.go new file mode 100644 index 00000000..354a8fcb --- /dev/null +++ b/internal/taskapi/draftsidecar/resolve.go @@ -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 +} diff --git a/internal/taskapi/draftsidecar/resolve_test.go b/internal/taskapi/draftsidecar/resolve_test.go new file mode 100644 index 00000000..1aed7993 --- /dev/null +++ b/internal/taskapi/draftsidecar/resolve_test.go @@ -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) + } +} diff --git a/internal/taskapi/http.go b/internal/taskapi/http.go index 76f78bcf..85f0b0e8 100644 --- a/internal/taskapi/http.go +++ b/internal/taskapi/http.go @@ -1,109 +1,33 @@ package taskapi import ( - "github.com/AlexsanderHamir/Hamix/pkgs/tasks/realtime" - - "context" "log/slog" "net/http" - "os" - "os/exec" "github.com/AlexsanderHamir/Hamix/internal/taskapi/composition" - "github.com/AlexsanderHamir/Hamix/internal/taskapi/draftsidecar" draftassistcontract "github.com/AlexsanderHamir/Hamix/pkgs/draftassist/contract" - draftassistmetrics "github.com/AlexsanderHamir/Hamix/pkgs/draftassist/metrics" - "github.com/AlexsanderHamir/Hamix/pkgs/draftassist/runner" draftassiststore "github.com/AlexsanderHamir/Hamix/pkgs/draftassist/store" "github.com/AlexsanderHamir/Hamix/pkgs/obs/calltrace" "github.com/AlexsanderHamir/Hamix/pkgs/repo" "github.com/AlexsanderHamir/Hamix/pkgs/tasks/handler" "github.com/AlexsanderHamir/Hamix/pkgs/tasks/middleware" "github.com/AlexsanderHamir/Hamix/pkgs/tasks/postgres" + "github.com/AlexsanderHamir/Hamix/pkgs/tasks/realtime" ) -// staticReadyProbe implements handler.ReadyProbe 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 +// DraftAssistReady is the optional /draft-assist/ready probe. The sidecar +// supervisor and boot-time static probes implement it. +type DraftAssistReady interface { + Ready() (ready bool, runner, reason string) } -// Ready implements handler.ReadyProbe (and draftassist/handler.ReadyProbe). -// -//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 -} - -// draftAssistRunnerSelection returns the runner + ready probe pair to -// wire into the handler. The chosen path depends on: -// -// - hamix-draft-agent on PATH: no → fake + no_runner. -// - hamix-draft-agent on PATH, 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() (draftassistcontract.Runner, handler.HandlerOption, func() error) { - slog.Debug("trace", "cmd", calltrace.LogCmd, "operation", "taskapi.draftAssistRunnerSelection") - fake := runner.NewFake(runner.FakeOptions{}) - noopCloser := func() error { return nil } - - binPath, lookErr := exec.LookPath(draftsidecar.BinaryName) - if lookErr != nil { - slog.Info("draft-assist runner=fake reason=no_runner", - "cmd", calltrace.LogCmd, - "operation", "taskapi.draftAssistRunnerSelection", - "binary", draftsidecar.BinaryName, - ) - return fake, handler.WithDraftAssistReady(staticReadyProbe{ - runner: "missing", - reason: draftassistmetrics.ReasonNoRunner, - }), noopCloser - } - - if os.Getenv(draftsidecar.APIKeyEnv) == "" { - slog.Info("draft-assist runner=fake reason=missing_key", - "cmd", calltrace.LogCmd, - "operation", "taskapi.draftAssistRunnerSelection", - "binary", binPath, - ) - return fake, handler.WithDraftAssistReady(staticReadyProbe{ - runner: "sdk", - reason: draftassistmetrics.ReasonMissingKey, - }), 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 fake, handler.WithDraftAssistReady(staticReadyProbe{ - runner: "sdk", - reason: draftassistmetrics.ReasonSidecarDown, - }), noopCloser - } - slog.Info("draft-assist runner=sdk", - "cmd", calltrace.LogCmd, - "operation", "taskapi.draftAssistRunnerSelection", - "binary", binPath, - "port", sup.Port(), - ) - sdk := draftsidecar.NewRunner(draftsidecar.RunnerOptions{ - PortSource: sup, - }) - return sdk, handler.WithDraftAssistReady(sup), sup.Close +// DraftAssistHost is the already-started draft-assist runner the HTTP +// stack mounts. Production hosts pass DefaultDraftAssistHost(); tests +// inject Fake so CI does not spawn hamix-draft-agent. +type DraftAssistHost struct { + Runner draftassistcontract.Runner + Ready DraftAssistReady + Close func() error } // NewHTTPHandler returns the REST + SSE task API with the standard middleware stack @@ -118,7 +42,10 @@ func draftAssistRunnerSelection() (draftassistcontract.Runner, handler.HandlerOp // Pass a nil agent control to opt out of the supervisor-aware // /settings sub-routes (PATCH /settings, POST /settings/probe-cursor, // POST /settings/cancel-current-run); GET /settings still works. -func NewHTTPHandler(s *composition.API, hub *realtime.SSEHub, rep *repo.Root, agent handler.AgentWorkerControl, drift postgres.SchemaDriftReport) (http.Handler, func() error) { +// +// da must already own the runner lifetime. NewHTTPHandler never looks +// up hamix-draft-agent or constructs a Fake runner. +func NewHTTPHandler(s *composition.API, hub *realtime.SSEHub, rep *repo.Root, agent handler.AgentWorkerControl, drift postgres.SchemaDriftReport, da DraftAssistHost) (http.Handler, func() error) { slog.Debug("trace", "cmd", calltrace.LogCmd, "operation", "internal.taskapi.NewHTTPHandler") opts := []handler.HandlerOption{ handler.WithSchemaDriftReport(drift), @@ -129,13 +56,15 @@ func NewHTTPHandler(s *composition.API, hub *realtime.SSEHub, rep *repo.Root, ag if rep == nil { opts = append(opts, handler.WithRepoProvider(handler.NewSettingsRepoProvider(s))) } - // Draft-assist (ADR-0101): pick the SDK-backed sidecar when the - // binary + CURSOR_API_KEY are present; else keep the in-process fake - // runner so CI and offline dev keep working. - draftRunner, readyOpt, closer := draftAssistRunnerSelection() - opts = append(opts, - handler.WithDraftAssist(draftassiststore.NewMemoryStore(), draftRunner), - readyOpt, - ) + if da.Runner != nil { + opts = append(opts, handler.WithDraftAssist(draftassiststore.NewMemoryStore(), da.Runner)) + if da.Ready != nil { + opts = append(opts, handler.WithDraftAssistReady(da.Ready)) + } + } + closer := da.Close + if closer == nil { + closer = func() error { return nil } + } return middleware.Stack(handler.NewHandler(s, hub, rep, opts...), calltrace.Path), closer } diff --git a/internal/taskapi/server_smoke_test.go b/internal/taskapi/server_smoke_test.go index cce1f60e..8b77d380 100644 --- a/internal/taskapi/server_smoke_test.go +++ b/internal/taskapi/server_smoke_test.go @@ -11,6 +11,7 @@ import ( "github.com/AlexsanderHamir/Hamix/internal/taskapi" "github.com/AlexsanderHamir/Hamix/internal/taskapi/composition" "github.com/AlexsanderHamir/Hamix/internal/tasktestdb" + "github.com/AlexsanderHamir/Hamix/pkgs/draftassist/runner" "github.com/AlexsanderHamir/Hamix/pkgs/tasks/postgres" ) @@ -24,6 +25,8 @@ func TestNewHTTPHandler_healthAndBootstrap(t *testing.T) { Status: postgres.SchemaDriftOK, CodeRevision: postgres.SchemaRevision, DBRevision: postgres.SchemaRevision, + }, taskapi.DraftAssistHost{ + Runner: runner.NewFake(runner.FakeOptions{}), }) srv := httptest.NewServer(api) t.Cleanup(func() { diff --git a/internal/taskapiruntime/runtime.go b/internal/taskapiruntime/runtime.go index 2b6c0b68..0ceaf069 100644 --- a/internal/taskapiruntime/runtime.go +++ b/internal/taskapiruntime/runtime.go @@ -43,10 +43,10 @@ type Runtime struct { AgentQueue *agents.MemoryQueue AgentWorker *agentworker.Supervisor - db *gorm.DB - stopAgents context.CancelFunc - closeHTTP func() error - cmdName string + db *gorm.DB + stopAgents context.CancelFunc + closeHTTP func() error + cmdName string } // Start opens the database, optionally migrates, wires agents, and builds @@ -89,7 +89,7 @@ func buildRuntime(ctx context.Context, db *gorm.DB, drift postgres.SchemaDriftRe return nil, err } - api, closeHTTP := taskapi.NewHTTPHandler(store, hub, nil, aw, drift) + api, closeHTTP := taskapi.NewHTTPHandler(store, hub, nil, aw, drift, taskapi.DefaultDraftAssistHost()) return &Runtime{ Handler: api, SchemaDrift: drift,