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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0.0-beta.2
1.0.0-beta.3
38 changes: 33 additions & 5 deletions cmd/agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}

Expand Down
131 changes: 131 additions & 0 deletions cmd/agent/main_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
}
31 changes: 29 additions & 2 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"context"
"encoding/json"
"errors"
"net"
"net/http"
"strconv"
"time"
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
36 changes: 36 additions & 0 deletions internal/api/stop_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading
Loading