claude-failover/internal/api/server_test.go

117 lines
3.2 KiB
Go
Raw Normal View History

feat(phase2-E): multi-provider routing via secutools delegation Adds optional delegation of agent-queue tasks to the SecuAAS secutools AI platform (GPU / Gemini / Claude API) instead of dispatching to a local Claude Code tmux session. Per-task opt-in via YAML frontmatter fields preferred_ai, allow_delegation, complexity_hint — absence keeps the Phase 1 behaviour exactly (zero breaking change). Go side: - internal/secutools: HTTP client with exponential-backoff retries (SubmitJob/GetJob/WaitForResult), DecideProvider map adapter for CLI use, table tests. - internal/router: struct-typed Decide() with strict precedence (needs_claude_code > preferred_ai=claude-code > allow_delegation=false > preferred_ai > fail-safe local on unknown). - internal/delegation: Manager submits jobs, writes .md.delegated markers for on-restart recovery, runs a periodic reaper that moves completed jobs into done/ with provider/cost footer and failed jobs into failed/. - internal/dispatcher: WithDelegation() opt-in, routeTask hook before findFreeSession, skips .md.delegated in assignNextTask. - internal/api: /api/delegated/status (active jobs + counters), /watchdog/status extended with delegation counters. - cmd/ccl-delegate: small CLI exposing submit/get/result/decide so the bash dispatcher can call the same contract without duplicating logic. - cmd/claude-failover: delegation wired opt-in via SECUTOOLS_API_KEY. Tests: - 29+ new unit tests across router, secutools, delegation, dispatcher, api packages. go test -race -count=1 clean. - tests/phase2-E-integration.sh: bash end-to-end against a Python stdlib mock HTTP server, exercising the dev-management scripts. Forward-compat with watchdog (Phase 1 B1 already ignores state=delegated_to_secutools) so delegated tasks aren't flagged stale.
2026-04-17 02:17:19 +00:00
package api
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"forge.secuaas.ovh/olivier/claude-failover/internal/delegation"
"forge.secuaas.ovh/olivier/claude-failover/internal/state"
)
// fakeDelegation implements DelegationProvider for tests.
type fakeDelegation struct {
active []delegation.ActiveJob
snapshot delegation.Snapshot
}
func (f *fakeDelegation) Active() []delegation.ActiveJob { return f.active }
func (f *fakeDelegation) CountersSnapshot() delegation.Snapshot { return f.snapshot }
func newTestServer(t *testing.T, deleg DelegationProvider) *httptest.Server {
t.Helper()
s := New("ignored", state.New(""))
if deleg != nil {
s.WithDelegation(deleg)
}
mux := http.NewServeMux()
mux.HandleFunc("/health", s.handleHealth)
mux.HandleFunc("/status", s.handleStatus)
mux.HandleFunc("/watchdog/status", s.handleWatchdogStatus)
mux.HandleFunc("/api/delegated/status", s.handleDelegatedStatus)
return httptest.NewServer(mux)
}
func TestHandleHealth(t *testing.T) {
srv := newTestServer(t, nil)
defer srv.Close()
resp, err := http.Get(srv.URL + "/health")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), `"status":"ok"`) {
t.Errorf("unexpected /health body: %s", body)
}
}
func TestHandleDelegatedStatus_Disabled(t *testing.T) {
srv := newTestServer(t, nil)
defer srv.Close()
resp, err := http.Get(srv.URL + "/api/delegated/status")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("expected 404 when delegation disabled, got %d", resp.StatusCode)
}
}
func TestHandleDelegatedStatus_Enabled(t *testing.T) {
deleg := &fakeDelegation{
active: []delegation.ActiveJob{
{JobID: "j1", Project: "/p/a", TaskFile: "task-1.md", Provider: "gpu", Duration: "12s"},
},
snapshot: delegation.Snapshot{Active: 1, CompletedTotal: 5, FailedTotal: 1},
}
srv := newTestServer(t, deleg)
defer srv.Close()
resp, err := http.Get(srv.URL + "/api/delegated/status")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var out struct {
Active []delegation.ActiveJob `json:"active"`
Counters delegation.Snapshot `json:"counters"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
if len(out.Active) != 1 || out.Active[0].JobID != "j1" {
t.Errorf("active mismatch: %+v", out.Active)
}
if out.Counters.CompletedTotal != 5 || out.Counters.Active != 1 {
t.Errorf("counters mismatch: %+v", out.Counters)
}
}
func TestHandleWatchdogStatus_IncludesDelegationCounters(t *testing.T) {
deleg := &fakeDelegation{snapshot: delegation.Snapshot{
Active: 2, CompletedTotal: 7, FailedTotal: 3,
}}
srv := newTestServer(t, deleg)
defer srv.Close()
resp, err := http.Get(srv.URL + "/watchdog/status")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
for _, want := range []string{`"delegated_active":2`, `"delegated_completed_total":7`, `"delegated_failed_total":3`} {
if !strings.Contains(string(body), want) {
t.Errorf("missing %s in body: %s", want, body)
}
}
}