diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5aded10..4699b64 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [1.0.0-beta.3]
+
+### Added
+
+- Local stack shutdown waits for core cleanup and only stops this agent's container.
+- Graph loading and failure states explain the wait and allow retrying.
+- Model setup offers an optional skip, including when settings cannot load.
+
+### Fixed
+
+- Large graphs display their group controls in batches.
+- Adding a folder indexes only that repository.
+
+### Compatibility
+
+- Core `1.0.0-beta.2` is the compatibility baseline for this release.
+- Newer core beta releases require compatibility checks before being added to the supported set.
+
## [1.0.0-beta.2] - 2026-08-30
First release, versioned alongside the core it supervises.
diff --git a/README.md b/README.md
index 9f02ab4..b235992 100644
--- a/README.md
+++ b/README.md
@@ -47,3 +47,9 @@ make build
## Licence
MIT.
+
+## Versioning
+
+The agent is versioned independently of the CLI, core, and design package. Its release number does not select the core version. Setup records the core image or Python executable that the agent starts.
+
+Agent `1.0.0-beta.3` uses core `1.0.0-beta.2` as its compatibility baseline. Older cores are outside this release's supported baseline. No maximum core version is declared; this does not promise compatibility with future beta releases. Startup checks core health but does not enforce a version range. Select a known core release with `sourceant setup --core-version 1.0.0-beta.2`.
diff --git a/VERSION b/VERSION
index 7e0b231..b0a2ffd 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.0.0-beta.2
+1.0.0-beta.3
diff --git a/cmd/agent/main.go b/cmd/agent/main.go
index 083d962..ca9994d 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); 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..bfc0644 100644
--- a/internal/runtime/runtime.go
+++ b/internal/runtime/runtime.go
@@ -7,13 +7,17 @@
package runtime
import (
+ "context"
+ "crypto/rand"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
+ "os/exec"
"path/filepath"
"strconv"
+ "strings"
)
// Kind is how the core is installed.
@@ -32,7 +36,8 @@ const DefaultImage = "ghcr.io/sourceant/sourceant:latest"
// Core is everything needed to start the indexer.
type Core struct {
- Runtime Kind `json:"runtime"`
+ containerName string
+ Runtime Kind `json:"runtime"`
// Command is the executable, for the python runtime.
Command string `json:"command,omitempty"`
// Image is the container, for the docker runtime.
@@ -119,7 +124,7 @@ func Save(path string, config Config) error {
// this machine binds loopback and is reached there. A container binding
// loopback would bind the container's own, reachable by nothing, so it binds
// every interface inside and is published to loopback outside.
-func (c Core) Serve(port int) (string, []string, error) {
+func (c *Core) Serve(port int) (string, []string, error) {
number := strconv.Itoa(port)
switch c.Runtime {
case Python:
@@ -128,13 +133,14 @@ func (c Core) Serve(port int) (string, []string, error) {
}
return c.Command, []string{"serve", "--host", "127.0.0.1", "--port", number}, nil
case Docker:
+ c.containerName = "sourceant-core-" + rand.Text()
image := c.Image
if image == "" {
image = DefaultImage
}
args := []string{
"run", "--rm",
- "--name", "sourceant-core-" + number,
+ "--name", c.containerName,
"-p", "127.0.0.1:" + number + ":" + number,
}
if c.DataDir != "" {
@@ -209,3 +215,24 @@ func throughTheHost(address string) string {
}
return parsed.String()
}
+
+func (c Core) Stop(ctx context.Context) error {
+ if c.Runtime != Docker || c.containerName == "" {
+ return nil
+ }
+ name := c.containerName
+ 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/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go
index ac1d009..f29c049 100644
--- a/internal/runtime/runtime_test.go
+++ b/internal/runtime/runtime_test.go
@@ -133,3 +133,21 @@ func TestADataDirNobodyChoseIsNotMounted(t *testing.T) {
t.Errorf("mounted something without being told where: %v", args)
}
}
+
+func TestDockerLaunchesOnTheSamePortHaveDifferentOwners(t *testing.T) {
+ first := Core{Runtime: Docker}
+ second := Core{Runtime: Docker}
+ _, firstArgs, err := first.Serve(8931)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, secondArgs, err := second.Serve(8931)
+ if err != nil {
+ t.Fatal(err)
+ }
+ firstName := firstArgs[slices.Index(firstArgs, "--name")+1]
+ secondName := secondArgs[slices.Index(secondArgs, "--name")+1]
+ if firstName == secondName || firstName != first.containerName || secondName != second.containerName {
+ t.Fatalf("container ownership overlaps: %q and %q", firstName, secondName)
+ }
+}
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() {
+ Reading this repository. Large repositories can take several minutes. +
Reading your code and reading what it already states about itself need no model at all. Proposing what nobody wrote down does. Your key stays on this machine and goes to that provider and nowhere else.
-