From 474bb54bc59303aa0baa63e0b1af1eec3d8046fe Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 7 Sep 2026 23:07:07 +0100 Subject: [PATCH 1/4] feat: Improve local setup, graph loading, and shutdown --- cmd/agent/main.go | 38 +++++++- cmd/agent/main_test.go | 131 +++++++++++++++++++++++++++ internal/api/server.go | 31 ++++++- internal/api/stop_test.go | 36 ++++++++ internal/runtime/runtime.go | 24 +++++ ui/src/components/FolderPicker.vue | 7 +- ui/src/components/GraphWorkbench.vue | 31 +++++-- ui/src/components/Onboarding.vue | 12 +-- ui/src/composables/useCodeGraph.js | 1 + 9 files changed, 288 insertions(+), 23 deletions(-) create mode 100644 cmd/agent/main_test.go create mode 100644 internal/api/stop_test.go diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 083d962..37028ce 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -71,11 +71,29 @@ func run() error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - supervised := make(chan error, 1) - go func() { supervised <- supervisor.Run(ctx) }() + supervised := make(chan struct{}) + var coreErr error + go func() { + defer close(supervised) + coreErr = supervisor.Run(ctx) + cleanup, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + if err := installed.Stop(cleanup, port); err != nil && coreErr == nil { + coreErr = err + } + }() served := make(chan error, 1) server := api.New(client, supervisor, Version, coreURL) + server.SetStop(func(request context.Context) error { + stop() + select { + case <-supervised: + return coreErr + case <-request.Done(): + return request.Err() + } + }) go func() { served <- server.Serve(ctx, cfg.Listen) }() // A repository read once answers about last month, so it is read again on @@ -89,10 +107,20 @@ func run() error { // Whichever half stops first ends the agent: an agent serving without a // core answers nothing, and a core nobody serves is not reachable. select { - case err := <-supervised: - return err + case <-supervised: + stop() + serverErr := <-served + if coreErr != nil { + return coreErr + } + return serverErr case err := <-served: - return err + stop() + <-supervised + if err != nil { + return err + } + return coreErr } } diff --git a/cmd/agent/main_test.go b/cmd/agent/main_test.go new file mode 100644 index 0000000..8bb5724 --- /dev/null +++ b/cmd/agent/main_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "syscall" + "testing" + "time" +) + +func TestAgentProcess(t *testing.T) { + switch os.Getenv("SOURCEANT_TEST_PROCESS") { + case "agent": + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Exit(0) + case "core": + port := os.Args[len(os.Args)-1] + stopped := make(chan os.Signal, 1) + signal.Notify(stopped, syscall.SIGTERM) + go func() { + <-stopped + time.Sleep(200 * time.Millisecond) + if err := os.WriteFile(os.Getenv("SOURCEANT_TEST_STOPPED"), []byte("stopped"), 0600); err != nil { + os.Exit(1) + } + os.Exit(0) + }() + _ = http.ListenAndServe("127.0.0.1:"+port, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) + os.Exit(1) + } +} + +func TestShutdownWaitsForCore(t *testing.T) { + for _, mode := range []string{"http", "signal"} { + t.Run(mode, func(t *testing.T) { + dir := t.TempDir() + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + wrapper := filepath.Join(dir, "core") + if err := os.WriteFile(wrapper, []byte("#!/bin/sh\nexport SOURCEANT_TEST_PROCESS=core\nexec \"$SOURCEANT_TEST_BINARY\" -test.run=TestAgentProcess -- \"$@\"\n"), 0700); err != nil { + t.Fatal(err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + address := listener.Addr().String() + _ = listener.Close() + stopped := filepath.Join(dir, "stopped") + cmd := exec.Command(binary, "-test.run=TestAgentProcess") + cmd.Env = append(os.Environ(), "SOURCEANT_TEST_PROCESS=agent", "SOURCEANT_TEST_BINARY="+binary, "SOURCEANT_TEST_STOPPED="+stopped, "SOURCEANT_CORE="+wrapper, "SOURCEANT_CORE_PORT=", "SOURCEANT_AGENT_LISTEN="+address) + var output bytes.Buffer + cmd.Stdout = &output + cmd.Stderr = &output + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + done := make(chan struct{}) + var waitErr error + go func() { waitErr = cmd.Wait(); close(done) }() + t.Cleanup(func() { + _ = cmd.Process.Signal(syscall.SIGTERM) + select { + case <-done: + case <-time.After(10 * time.Second): + _ = cmd.Process.Kill() + } + }) + client := &http.Client{Timeout: 10 * time.Second} + ready := false + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + response, err := client.Get("http://" + address + "/health") + if err == nil { + var status struct { + CoreUp bool `json:"core_up"` + } + decodeErr := json.NewDecoder(response.Body).Decode(&status) + _ = response.Body.Close() + if decodeErr == nil && status.CoreUp { + ready = true + break + } + } + time.Sleep(20 * time.Millisecond) + } + if !ready { + t.Fatal("agent did not become ready") + } + if mode == "http" { + request, _ := http.NewRequest(http.MethodPost, "http://"+address+"/api/stop", nil) + request.Header.Set("X-Sourceant-Client", "cli") + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + if response.StatusCode != 204 { + t.Fatalf("stop returned %d", response.StatusCode) + } + } else { + if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { + t.Fatal(err) + } + } + select { + case <-done: + if waitErr != nil { + t.Fatalf("%v: %s", waitErr, output.String()) + } + case <-time.After(10 * time.Second): + t.Fatal("agent did not stop") + } + if _, err := os.Stat(stopped); err != nil { + t.Fatal("agent exited before core cleanup completed") + } + }) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 9a21c5b..622f139 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -10,6 +10,7 @@ import ( "context" "encoding/json" "errors" + "net" "net/http" "strconv" "time" @@ -69,6 +70,7 @@ type Server struct { supervisor Supervision version string coreURL string + stop func(context.Context) error } // New builds the agent's HTTP surface. @@ -81,9 +83,29 @@ func New(reader Reader, supervisor Supervision, version, coreURL string) *Server } } +func (s *Server) SetStop(stop func(context.Context) error) { s.stop = stop } + +func (s *Server) stopStack(w http.ResponseWriter, r *http.Request) { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil || !net.ParseIP(host).IsLoopback() || r.Header.Get("Origin") != "" || r.Header.Get("X-Sourceant-Client") != "cli" { + write(w, http.StatusForbidden, problem{Error: "stop is only available to the local CLI"}) + return + } + if s.stop == nil { + write(w, http.StatusNotImplemented, problem{Error: "this agent does not support stopping"}) + return + } + if err := s.stop(r.Context()); err != nil { + write(w, http.StatusInternalServerError, problem{Error: err.Error()}) + return + } + w.WriteHeader(http.StatusNoContent) +} + // Handler is the agent's routes. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() + mux.HandleFunc("POST /api/stop", s.stopStack) mux.HandleFunc("GET /health", s.health) mux.HandleFunc("GET /api/repositories", s.repositories) mux.HandleFunc("POST /api/repositories", s.addRepository) @@ -466,14 +488,19 @@ func (s *Server) Serve(ctx context.Context, address string) error { Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second, } + shutdownDone := make(chan struct{}) go func() { + defer close(shutdownDone) <-ctx.Done() - shutdown, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + shutdown, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second) defer cancel() - _ = server.Shutdown(shutdown) + if err := server.Shutdown(shutdown); err != nil { + _ = server.Close() + } }() if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { return err } + <-shutdownDone return nil } diff --git a/internal/api/stop_test.go b/internal/api/stop_test.go new file mode 100644 index 0000000..0220d00 --- /dev/null +++ b/internal/api/stop_test.go @@ -0,0 +1,36 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestStopRequiresLocalCLI(t *testing.T) { + for _, tc := range []struct { + name, remote, origin, client string + want int + }{ + {"local", "127.0.0.1:1234", "", "cli", 204}, + {"ipv6", "[::1]:1234", "", "cli", 204}, + {"remote", "192.0.2.1:1234", "", "cli", 403}, + {"browser", "127.0.0.1:1234", "http://example.org", "cli", 403}, + {"missing client", "127.0.0.1:1234", "", "", 403}, + } { + t.Run(tc.name, func(t *testing.T) { + server := New(nil, nil, "test", "") + stopped := false + server.SetStop(func(context.Context) error { stopped = true; return nil }) + req := httptest.NewRequest(http.MethodPost, "/api/stop", nil) + req.RemoteAddr = tc.remote + req.Header.Set("Origin", tc.origin) + req.Header.Set("X-Sourceant-Client", tc.client) + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, req) + if response.Code != tc.want || stopped != (tc.want == 204) { + t.Fatalf("status=%d stopped=%v", response.Code, stopped) + } + }) + } +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 9dd0b86..24df27a 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -7,13 +7,16 @@ package runtime import ( + "context" "encoding/json" "errors" "fmt" "net/url" "os" + "os/exec" "path/filepath" "strconv" + "strings" ) // Kind is how the core is installed. @@ -209,3 +212,24 @@ func throughTheHost(address string) string { } return parsed.String() } + +func (c Core) Stop(ctx context.Context, port int) error { + if c.Runtime != Docker { + return nil + } + name := "sourceant-core-" + strconv.Itoa(port) + output, err := exec.CommandContext(ctx, "docker", "container", "ls", "--all", "--format", "{{.Names}}").CombinedOutput() + if err != nil { + return fmt.Errorf("checking the core container: %w: %s", err, output) + } + for _, container := range strings.Fields(string(output)) { + if container == name { + output, err = exec.CommandContext(ctx, "docker", "container", "stop", "--timeout", "5", name).CombinedOutput() + if err != nil { + return fmt.Errorf("stopping the core container: %w: %s", err, output) + } + break + } + } + return nil +} diff --git a/ui/src/components/FolderPicker.vue b/ui/src/components/FolderPicker.vue index 75ce448..a44368b 100644 --- a/ui/src/components/FolderPicker.vue +++ b/ui/src/components/FolderPicker.vue @@ -45,8 +45,8 @@ async function add() { busy.value = true problem.value = '' try { - await api.addRepository(listing.value.path, name.value.trim()) - await api.index('', { everything: true }) + const repository = await api.addRepository(listing.value.path, name.value.trim()) + await api.index(repository.name) emit('added') emit('close') } catch (error) { @@ -96,6 +96,9 @@ async function add() { {{ problem }} +

+ Reading this repository. Large repositories can take several minutes. +

diff --git a/ui/src/components/GraphWorkbench.vue b/ui/src/components/GraphWorkbench.vue index bddc088..82f78d5 100644 --- a/ui/src/components/GraphWorkbench.vue +++ b/ui/src/components/GraphWorkbench.vue @@ -1,5 +1,5 @@ @@ -67,17 +68,14 @@ onMounted(async () => { diff --git a/ui/src/composables/useCodeGraph.js b/ui/src/composables/useCodeGraph.js index 0374985..aa4f719 100644 --- a/ui/src/composables/useCodeGraph.js +++ b/ui/src/composables/useCodeGraph.js @@ -9,6 +9,7 @@ export function useCodeGraph() { async function fetchCodeGraph(owner, repo, ask = {}) { loading.value = true + problem.value = null try { graph.value = await api.graph(`${owner}/${repo}`, ask) problem.value = null From 6f83d7cfc55deb306237fae5b390adac79986441 Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 7 Sep 2026 23:13:31 +0100 Subject: [PATCH 2/4] fix(ui): Use available controls for graph errors --- ui/src/components/GraphWorkbench.vue | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ui/src/components/GraphWorkbench.vue b/ui/src/components/GraphWorkbench.vue index 82f78d5..88010c8 100644 --- a/ui/src/components/GraphWorkbench.vue +++ b/ui/src/components/GraphWorkbench.vue @@ -1,5 +1,5 @@