refactor: move project manager to service layer (#68)
* refactor(project): manager talks to the sqlite store; drop the in-memory store The project Manager now runs only against the durable backend store: remove the process-local MemoryStore (and NewMemoryManager), and require a real Store. The daemon already wires the sqlite store; tests now build a real temp-dir sqlite store instead of the mock. - Move Row + the Store port to project/store.go. The Store interface stays because it is the dependency-inversion port that lets the manager reach the backend without an import cycle (storage imports project.Row), not an extra mock layer — there is no longer any in-memory implementation. - NewManager requires a non-nil Store (no in-memory fallback). - Add project/manager_test.go: List/Add/Get/Remove happy paths + PATH_REQUIRED/NOT_A_GIT_REPO/PATH_ALREADY_REGISTERED/ID_ALREADY_REGISTERED, PROJECT_NOT_FOUND/INVALID_PROJECT_ID, and UpdateConfig — all against a real sqlite store (the service-logic tests #47 lacked). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(project): trim routes, consolidate package, add code-first OpenAPI - Remove POST /reload, PATCH /{id}, POST /{id}/repair routes and their Manager methods (Reload, UpdateConfig, Repair) and DTOs (ReloadResult, UpdateConfigInput) — not needed at this stage - Merge Manager interface into manager.go; delete project.go (single-impl split served no purpose) - Remove dead notImplemented helper from errors.go - Port PR #59 code-first OpenAPI generation: controllers/dto.go named response types, specgen/build.go (4 routes), parity + drift tests, cmd/genspec, go generate wiring; regenerate openapi.yaml - Add swaggest deps; add YAML() method to apispec.Spec Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * fix(project): address PR review comments - t.Skipf → t.Fatalf in gitRepo helper: git failures now hard-fail instead of silently skipping manager tests on a misconfigured runner - FindProjectByPath: add AND archived_at IS NULL so archived paths don't permanently block re-registration (update queries/projects.sql and generated gen/projects.sql.go) - Add TestManager_ReaddAfterRemove to lock the fix Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * fixed lint and fmt * addressed greptile comments * Apply suggestions from code review Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * project tests fix * project_tests fix * fix: Linting and formatting fix * refactor: move project manager into service layer (#68) * refactor: split service package by resource (#68) * fix: ignore archived project id conflicts (#68) * refactor: move pr manager into service layer (#68) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: itrytoohard <ayetrytoohard@gmail.com>
This commit is contained in:
parent
424e6e824b
commit
3a93e33331
|
|
@ -0,0 +1,26 @@
|
|||
// Command genspec writes the code-first OpenAPI document produced by
|
||||
// apispec.Build() to disk. It is invoked via `go generate` (see
|
||||
// internal/httpd/apispec/gen.go); the output openapi.yaml is committed and
|
||||
// embedded by the apispec package.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec/specgen"
|
||||
)
|
||||
|
||||
func main() {
|
||||
out := flag.String("out", "openapi.yaml", "output path for the generated OpenAPI document")
|
||||
flag.Parse()
|
||||
|
||||
doc, err := specgen.Build()
|
||||
if err != nil {
|
||||
log.Fatalf("genspec: build openapi: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(*out, doc, 0o600); err != nil {
|
||||
log.Fatalf("genspec: write %s: %v", *out, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ require (
|
|||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/pressly/goose/v3 v3.27.1
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/swaggest/jsonschema-go v0.3.79
|
||||
github.com/swaggest/openapi-go v0.2.61
|
||||
golang.org/x/sys v0.43.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.51.0
|
||||
|
|
@ -23,8 +25,10 @@ require (
|
|||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/swaggest/refl v1.4.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ=
|
||||
github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg=
|
||||
github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E=
|
||||
github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
|
|
@ -15,6 +19,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc=
|
||||
github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
|
|
@ -30,6 +36,8 @@ github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76cs
|
|||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
|
|
@ -38,6 +46,18 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
|||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ=
|
||||
github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU=
|
||||
github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs=
|
||||
github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0=
|
||||
github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc=
|
||||
github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw=
|
||||
github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k=
|
||||
github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA=
|
||||
github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=
|
||||
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
|
||||
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
|
||||
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
|
|
@ -50,6 +70,8 @@ golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
|||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@
|
|||
//
|
||||
// - The poller loop and cadence selection (issue #35).
|
||||
// - Webhook ingestion (this package is polling-only).
|
||||
// - Persistence (PR Manager owns the row mapping; see internal/pr).
|
||||
// - Persistence (PR Manager owns the row mapping; see internal/service/pr).
|
||||
// - Linear / GitLab providers (separate PRs).
|
||||
// - Issue tracking (separate lane, see internal/adapters/tracker).
|
||||
// - Comment-injection-into-session-context (Messenger lane, not SCM).
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import (
|
|||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/cdc"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/project"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
|
|
@ -27,7 +26,7 @@ func seedSession(t *testing.T, s *sqlite.Store) domain.SessionRecord {
|
|||
t.Helper()
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
if err := s.Upsert(ctx, project.Row{ID: "mer", Path: "/m", RegisteredAt: now}); err != nil {
|
||||
if err := s.UpsertProject(ctx, domain.ProjectRecord{ID: "mer", Path: "/m", RegisteredAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := s.CreateSession(ctx, domain.SessionRecord{
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ import (
|
|||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/config"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/project"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
|
||||
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/terminal"
|
||||
)
|
||||
|
|
@ -66,7 +66,7 @@ func Run() error {
|
|||
termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log)
|
||||
defer termMgr.Close()
|
||||
|
||||
srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{Projects: project.NewManager(store)})
|
||||
srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{Projects: projectsvc.New(store)})
|
||||
if err != nil {
|
||||
stop()
|
||||
if cdcErr := cdcPipe.Stop(); cdcErr != nil {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import (
|
|||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/lifecycle"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/project"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
|
|
@ -37,7 +36,7 @@ func TestWiring_WriteFlowsToBroadcaster(t *testing.T) {
|
|||
var got []cdc.Event
|
||||
bcast.Subscribe(func(e cdc.Event) { mu.Lock(); got = append(got, e); mu.Unlock() })
|
||||
|
||||
if err := store.Upsert(ctx, project.Row{ID: "mer", Path: "/repo/mer"}); err != nil {
|
||||
if err := store.UpsertProject(ctx, domain.ProjectRecord{ID: "mer", Path: "/repo/mer"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, err := store.CreateSession(ctx, domain.SessionRecord{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// ProjectRecord is the durable project registry row used by storage and services.
|
||||
type ProjectRecord struct {
|
||||
ID string
|
||||
Path string
|
||||
RepoOriginURL string
|
||||
DisplayName string
|
||||
RegisteredAt time.Time
|
||||
ArchivedAt time.Time
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ import (
|
|||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/project"
|
||||
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
|
||||
)
|
||||
|
||||
// APIDeps bundles every Manager the API layer's controllers depend on.
|
||||
|
|
@ -18,7 +18,7 @@ import (
|
|||
// lifecycle reducers, adapters, or storage. A nil dependency keeps its routes
|
||||
// registered but returns the OpenAPI-backed 501 response.
|
||||
type APIDeps struct {
|
||||
Projects project.Manager
|
||||
Projects projectsvc.Manager
|
||||
Sessions controllers.SessionService
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ var openapiYAML []byte
|
|||
// preserves the YAML shape verbatim so the JSON we emit on 501 responses
|
||||
// matches the on-disk source.
|
||||
type Spec struct {
|
||||
doc map[string]any
|
||||
doc map[string]any
|
||||
rawYAML []byte
|
||||
}
|
||||
|
||||
var (
|
||||
|
|
@ -61,7 +62,12 @@ func New(yamlBytes []byte) (*Spec, error) {
|
|||
if doc == nil {
|
||||
return nil, fmt.Errorf("parse openapi: empty document")
|
||||
}
|
||||
return &Spec{doc: doc}, nil
|
||||
return &Spec{doc: doc, rawYAML: yamlBytes}, nil
|
||||
}
|
||||
|
||||
// YAML returns the raw YAML bytes this spec was built from.
|
||||
func (s *Spec) YAML() []byte {
|
||||
return s.rawYAML
|
||||
}
|
||||
|
||||
// Operation returns the spec slice for a single (method, path) pair, ready
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
package apispec
|
||||
|
||||
// openapi.yaml is generated from Go (see build.go) — do not edit it by hand.
|
||||
// Regenerate with `go generate ./...` from the backend module root.
|
||||
//
|
||||
//go:generate go run ../../../cmd/genspec -out openapi.yaml
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,66 @@
|
|||
package apispec_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
yaml "gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/config"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec"
|
||||
)
|
||||
|
||||
// TestRouteSpecParity asserts the mounted /api/v1 routes and the OpenAPI
|
||||
// operations are in 1:1 correspondence — so a route can't be added without
|
||||
// spec coverage, and the spec can't describe a route that isn't served.
|
||||
func TestRouteSpecParity(t *testing.T) {
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
router := httpd.NewRouter(config.Config{}, log, nil)
|
||||
|
||||
mounted := map[string]bool{}
|
||||
err := chi.Walk(router, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
|
||||
if strings.HasPrefix(route, "/api/v1/") && route != "/api/v1/openapi.yaml" {
|
||||
mounted[strings.ToUpper(method)+" "+route] = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk routes: %v", err)
|
||||
}
|
||||
if len(mounted) == 0 {
|
||||
t.Fatal("no /api/v1 routes mounted — router wiring changed?")
|
||||
}
|
||||
|
||||
// Forward: every mounted route resolves to an operation slice.
|
||||
for r := range mounted {
|
||||
mp := strings.SplitN(r, " ", 2)
|
||||
if apispec.Default().Operation(mp[0], mp[1]) == nil {
|
||||
t.Errorf("mounted route %s has no OpenAPI operation", r)
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse: every spec operation is a mounted route.
|
||||
var doc struct {
|
||||
Paths map[string]map[string]yaml.Node `yaml:"paths"`
|
||||
}
|
||||
if err := yaml.Unmarshal(apispec.Default().YAML(), &doc); err != nil {
|
||||
t.Fatalf("parse spec: %v", err)
|
||||
}
|
||||
httpMethods := map[string]bool{"get": true, "post": true, "put": true, "patch": true, "delete": true}
|
||||
for path, item := range doc.Paths {
|
||||
for method := range item {
|
||||
if !httpMethods[method] {
|
||||
continue // skip parameters, summary, etc.
|
||||
}
|
||||
key := strings.ToUpper(method) + " " + path
|
||||
if !mounted[key] {
|
||||
t.Errorf("spec operation %s has no mounted route", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,362 @@
|
|||
// Package specgen builds the code-first OpenAPI document from the Go contract
|
||||
// types. It lives outside apispec because it imports the controllers (to
|
||||
// reflect their request/response shapes), and controllers import apispec (for
|
||||
// the 501 stub) — keeping Build here breaks that cycle. apispec only embeds and
|
||||
// serves the committed openapi.yaml; specgen produces it.
|
||||
package specgen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
jsonschema "github.com/swaggest/jsonschema-go"
|
||||
openapi "github.com/swaggest/openapi-go"
|
||||
"github.com/swaggest/openapi-go/openapi31"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
|
||||
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
|
||||
)
|
||||
|
||||
// Build reflects the Go contract types and the operation registry below into
|
||||
// the OpenAPI document. It is the single source of truth for the /api/v1
|
||||
// contract: `cmd/genspec` writes its output to apispec/openapi.yaml (the
|
||||
// committed, embedded artifact) and TestBuild_MatchesEmbedded asserts the embed
|
||||
// equals fresh Build() output so the two can never drift. Schema facets live as
|
||||
// struct tags on the service.*/controllers.* types; operation metadata (path,
|
||||
// status codes, summaries) lives here.
|
||||
//
|
||||
// Every wire shape is reflected straight from where it is used at runtime — the
|
||||
// request bodies, path params, and response envelopes from controllers, the
|
||||
// error envelope from httpd/envelope — so the served responses and the
|
||||
// generated schema share one definition each.
|
||||
func Build() ([]byte, error) {
|
||||
r := openapi31.NewReflector()
|
||||
// Derive `required` from the idiomatic Go convention: a JSON field without
|
||||
// `omitempty` is required. swaggest does not infer this on its own, so the
|
||||
// structs stay clean (only description/enum tags) and this hook adds the
|
||||
// required array. nonNullableSlices drops the spurious "null" type swaggest
|
||||
// stamps on every Go slice.
|
||||
r.DefaultOptions = append(r.DefaultOptions,
|
||||
jsonschema.InterceptProp(requiredFromJSONTag),
|
||||
jsonschema.InterceptNullability(nonNullableSlices),
|
||||
// Clean component schema names (which become the generated TS type names):
|
||||
// swaggest defaults to PackageType, e.g. "ProjectProject", "EnvelopeAPIError".
|
||||
jsonschema.InterceptDefName(schemaName),
|
||||
)
|
||||
|
||||
r.Spec.SetTitle("Agent Orchestrator HTTP daemon")
|
||||
r.Spec.SetVersion("0.1.0-route-shell")
|
||||
r.Spec.SetDescription("Loopback-only HTTP surface served by the Go daemon. " +
|
||||
"Generated from Go (code-first) — do not edit by hand; run `go generate ./...`.")
|
||||
r.Spec.Servers = []openapi31.Server{
|
||||
*(&openapi31.Server{URL: "http://127.0.0.1:3001"}).WithDescription("Local daemon (loopback only)"),
|
||||
}
|
||||
r.Spec.Tags = []openapi31.Tag{
|
||||
*(&openapi31.Tag{Name: "projects"}).WithDescription(
|
||||
"Project registry, configuration, and lifecycle administration"),
|
||||
*(&openapi31.Tag{Name: "sessions"}).WithDescription(
|
||||
"Agent session lifecycle and messaging"),
|
||||
}
|
||||
|
||||
for _, op := range operations() {
|
||||
oc, err := r.NewOperationContext(op.method, op.path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new operation %s %s: %w", op.method, op.path, err)
|
||||
}
|
||||
oc.SetID(op.id)
|
||||
oc.SetSummary(op.summary)
|
||||
oc.SetTags(op.tag)
|
||||
for _, param := range op.pathParams {
|
||||
oc.AddReqStructure(param)
|
||||
}
|
||||
if op.reqBody != nil {
|
||||
// AddReqStructure leaves requestBody.required absent, which
|
||||
// OpenAPI reads as optional. These bodies are mandatory, so force
|
||||
// it — otherwise validators/generators treat the body as skippable.
|
||||
oc.AddReqStructure(op.reqBody, openapi.WithCustomize(markRequestBodyRequired))
|
||||
}
|
||||
for _, resp := range op.resps {
|
||||
oc.AddRespStructure(resp.body, openapi.WithHTTPStatus(resp.status))
|
||||
}
|
||||
if err := r.AddOperation(oc); err != nil {
|
||||
return nil, fmt.Errorf("add operation %s %s: %w", op.method, op.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return r.Spec.MarshalYAML()
|
||||
}
|
||||
|
||||
// schemaName maps swaggest's default PackageType component names (e.g.
|
||||
// "ProjectProject", "EnvelopeAPIError") to the clean, stable schema names that
|
||||
// become the generated TypeScript type names. Every reflected type is listed
|
||||
// explicitly: an unrecognised default name is returned verbatim, so a new type
|
||||
// surfaces as a visibly-wrong "PackageType" name in the diff (and the drift
|
||||
// test) rather than silently colliding with an existing schema via a
|
||||
// TrimPrefix catch-all.
|
||||
func schemaName(_ reflect.Type, defaultName string) string {
|
||||
if clean, ok := schemaNames[defaultName]; ok {
|
||||
return clean
|
||||
}
|
||||
return defaultName
|
||||
}
|
||||
|
||||
// schemaNames is the exhaustive default→clean mapping for every type reflected
|
||||
// by projectOperations(). Add an entry when a new contract type is introduced;
|
||||
// the drift test fails until the spec is regenerated, which flags the gap.
|
||||
var schemaNames = map[string]string{
|
||||
// httpd/envelope
|
||||
"EnvelopeAPIError": "APIError",
|
||||
// domain
|
||||
"DomainProjectID": "ProjectID",
|
||||
"DomainSessionID": "SessionID",
|
||||
"DomainIssueID": "IssueID",
|
||||
"DomainSession": "Session",
|
||||
// httpd/controllers (wire envelopes)
|
||||
"ControllersListProjectsResponse": "ListProjectsResponse",
|
||||
"ControllersProjectResponse": "ProjectResponse",
|
||||
"ControllersGetProjectResponse": "ProjectGetResponse",
|
||||
"ControllersProjectOrDegraded": "ProjectOrDegraded",
|
||||
"ControllersProjectIDParam": "ProjectIDParam",
|
||||
"ControllersSessionIDParam": "SessionIDParam",
|
||||
"ControllersListSessionsQuery": "ListSessionsQuery",
|
||||
"ControllersListSessionsResponse": "ListSessionsResponse",
|
||||
"ControllersSpawnSessionRequest": "SpawnSessionRequest",
|
||||
"ControllersSessionResponse": "SessionResponse",
|
||||
"ControllersRenameSessionRequest": "RenameSessionRequest",
|
||||
"ControllersRestoreSessionResponse": "RestoreSessionResponse",
|
||||
"ControllersKillSessionResponse": "KillSessionResponse",
|
||||
"ControllersSendSessionMessageRequest": "SendSessionMessageRequest",
|
||||
"ControllersSendSessionMessageResponse": "SendSessionMessageResponse",
|
||||
"ControllersSpawnOrchestratorRequest": "SpawnOrchestratorRequest",
|
||||
"ControllersSpawnOrchestratorResponse": "SpawnOrchestratorResponse",
|
||||
"ControllersOrchestratorResponse": "OrchestratorResponse",
|
||||
// service/project entities + DTOs
|
||||
"ProjectProject": "Project",
|
||||
"ProjectSummary": "ProjectSummary",
|
||||
"ProjectDegraded": "DegradedProject",
|
||||
"ProjectAddInput": "AddProjectInput",
|
||||
"ProjectRemoveResult": "RemoveProjectResult",
|
||||
"ProjectTrackerConfig": "TrackerConfig",
|
||||
"ProjectSCMConfig": "SCMConfig",
|
||||
"ProjectSCMWebhookConfig": "SCMWebhookConfig",
|
||||
}
|
||||
|
||||
// markRequestBodyRequired sets requestBody.required: true on the operation's
|
||||
// JSON body. swaggest leaves it absent (== optional) for AddReqStructure bodies.
|
||||
func markRequestBodyRequired(cor openapi.ContentOrReference) {
|
||||
if rb, ok := cor.(*openapi31.RequestBodyOrReference); ok && rb.RequestBody != nil {
|
||||
rb.RequestBody.WithRequired(true)
|
||||
}
|
||||
}
|
||||
|
||||
// nonNullableSlices drops the "null" that swaggest unions into every Go slice
|
||||
// type (a nil slice marshals as JSON null). A required array field should be
|
||||
// `T[]`, not `T[] | null`; the handlers normalise nil to an empty slice, so
|
||||
// null never reaches the wire. Byte slices (base64 strings) are left alone.
|
||||
func nonNullableSlices(p jsonschema.InterceptNullabilityParams) {
|
||||
if !p.NullAdded || p.Type == nil || p.Type.Kind() != reflect.Slice {
|
||||
return
|
||||
}
|
||||
if p.Type.Elem().Kind() == reflect.Uint8 {
|
||||
return
|
||||
}
|
||||
p.Schema.TypeEns().WithSimpleTypes(jsonschema.Array)
|
||||
p.Schema.Type.SliceOfSimpleTypeValues = nil
|
||||
}
|
||||
|
||||
// requiredFromJSONTag marks a property required when its json tag lacks
|
||||
// `omitempty` (the Go convention for "always present"). Runs after default
|
||||
// processing so ParentSchema exists; skips fields without a json tag (e.g. path
|
||||
// params, which swaggest marks required on their own).
|
||||
func requiredFromJSONTag(p jsonschema.InterceptPropParams) error {
|
||||
if !p.Processed || p.ParentSchema == nil {
|
||||
return nil
|
||||
}
|
||||
jsonTag := p.Field.Tag.Get("json")
|
||||
if jsonTag == "" || jsonTag == "-" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(jsonTag, ",")
|
||||
name := parts[0]
|
||||
if name == "" {
|
||||
name = p.Name
|
||||
}
|
||||
for _, opt := range parts[1:] {
|
||||
if opt == "omitempty" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
for _, existing := range p.ParentSchema.Required {
|
||||
if existing == name {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
p.ParentSchema.Required = append(p.ParentSchema.Required, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- operation registry -----------------------------------------------------
|
||||
|
||||
type respUnit struct {
|
||||
status int
|
||||
body any
|
||||
}
|
||||
|
||||
type operation struct {
|
||||
method, path, id, summary string
|
||||
tag string
|
||||
pathParams []any // path/query param containers (e.g. ProjectIDParam)
|
||||
reqBody any // JSON request body struct, nil when the op takes none
|
||||
resps []respUnit
|
||||
}
|
||||
|
||||
func operations() []operation {
|
||||
ops := append([]operation{}, projectOperations()...)
|
||||
ops = append(ops, sessionOperations()...)
|
||||
return ops
|
||||
}
|
||||
|
||||
// projectOperations declares the 4 canonical /projects operations. The set must
|
||||
// stay 1:1 with the routes ProjectsController.Register mounts —
|
||||
// TestRouteSpecParity fails the build otherwise.
|
||||
func projectOperations() []operation {
|
||||
return []operation{
|
||||
{
|
||||
method: http.MethodGet, path: "/api/v1/projects", id: "listProjects", tag: "projects",
|
||||
summary: "List all registered projects (active + degraded)",
|
||||
resps: []respUnit{
|
||||
{http.StatusOK, controllers.ListProjectsResponse{}},
|
||||
{http.StatusInternalServerError, envelope.APIError{}},
|
||||
},
|
||||
},
|
||||
{
|
||||
method: http.MethodPost, path: "/api/v1/projects", id: "addProject", tag: "projects",
|
||||
summary: "Register a new project from a git repository path",
|
||||
reqBody: projectsvc.AddInput{},
|
||||
resps: []respUnit{
|
||||
{http.StatusCreated, controllers.ProjectResponse{}},
|
||||
{http.StatusBadRequest, envelope.APIError{}},
|
||||
{http.StatusConflict, envelope.APIError{}},
|
||||
{http.StatusInternalServerError, envelope.APIError{}},
|
||||
},
|
||||
},
|
||||
{
|
||||
method: http.MethodGet, path: "/api/v1/projects/{id}", id: "getProject", tag: "projects",
|
||||
summary: "Fetch one project; discriminates ok vs degraded",
|
||||
pathParams: []any{controllers.ProjectIDParam{}},
|
||||
resps: []respUnit{
|
||||
{http.StatusOK, controllers.GetProjectResponse{}},
|
||||
{http.StatusNotFound, envelope.APIError{}},
|
||||
{http.StatusInternalServerError, envelope.APIError{}},
|
||||
},
|
||||
},
|
||||
{
|
||||
method: http.MethodDelete, path: "/api/v1/projects/{id}", id: "removeProject", tag: "projects",
|
||||
summary: "Remove a project; stops sessions, cleans workspaces, unregisters",
|
||||
pathParams: []any{controllers.ProjectIDParam{}},
|
||||
resps: []respUnit{
|
||||
{http.StatusOK, projectsvc.RemoveResult{}},
|
||||
{http.StatusBadRequest, envelope.APIError{}},
|
||||
{http.StatusNotFound, envelope.APIError{}},
|
||||
{http.StatusInternalServerError, envelope.APIError{}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func sessionOperations() []operation {
|
||||
return []operation{
|
||||
{
|
||||
method: http.MethodGet, path: "/api/v1/sessions", id: "listSessions", tag: "sessions",
|
||||
summary: "List sessions",
|
||||
pathParams: []any{controllers.ListSessionsQuery{}},
|
||||
resps: []respUnit{
|
||||
{http.StatusOK, controllers.ListSessionsResponse{}},
|
||||
{http.StatusBadRequest, envelope.APIError{}},
|
||||
{http.StatusInternalServerError, envelope.APIError{}},
|
||||
},
|
||||
},
|
||||
{
|
||||
method: http.MethodPost, path: "/api/v1/sessions", id: "spawnSession", tag: "sessions",
|
||||
summary: "Spawn a new agent session",
|
||||
reqBody: controllers.SpawnSessionRequest{},
|
||||
resps: []respUnit{
|
||||
{http.StatusCreated, controllers.SessionResponse{}},
|
||||
{http.StatusBadRequest, envelope.APIError{}},
|
||||
{http.StatusNotFound, envelope.APIError{}},
|
||||
{http.StatusInternalServerError, envelope.APIError{}},
|
||||
},
|
||||
},
|
||||
{
|
||||
method: http.MethodGet, path: "/api/v1/sessions/{sessionId}", id: "getSession", tag: "sessions",
|
||||
summary: "Fetch one session",
|
||||
pathParams: []any{controllers.SessionIDParam{}},
|
||||
resps: []respUnit{
|
||||
{http.StatusOK, controllers.SessionResponse{}},
|
||||
{http.StatusNotFound, envelope.APIError{}},
|
||||
{http.StatusInternalServerError, envelope.APIError{}},
|
||||
},
|
||||
},
|
||||
{
|
||||
method: http.MethodPatch, path: "/api/v1/sessions/{sessionId}", id: "renameSession", tag: "sessions",
|
||||
summary: "Rename a session display name",
|
||||
pathParams: []any{controllers.SessionIDParam{}},
|
||||
reqBody: controllers.RenameSessionRequest{},
|
||||
resps: []respUnit{
|
||||
{http.StatusOK, controllers.SessionResponse{}},
|
||||
{http.StatusBadRequest, envelope.APIError{}},
|
||||
{http.StatusNotFound, envelope.APIError{}},
|
||||
{http.StatusNotImplemented, envelope.APIError{}},
|
||||
},
|
||||
},
|
||||
{
|
||||
method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/restore", id: "restoreSession", tag: "sessions",
|
||||
summary: "Restore a terminated session",
|
||||
pathParams: []any{controllers.SessionIDParam{}},
|
||||
resps: []respUnit{
|
||||
{http.StatusOK, controllers.RestoreSessionResponse{}},
|
||||
{http.StatusNotFound, envelope.APIError{}},
|
||||
{http.StatusConflict, envelope.APIError{}},
|
||||
{http.StatusInternalServerError, envelope.APIError{}},
|
||||
},
|
||||
},
|
||||
{
|
||||
method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/kill", id: "killSession", tag: "sessions",
|
||||
summary: "Mark a session terminated and tear down runtime/workspace resources",
|
||||
pathParams: []any{controllers.SessionIDParam{}},
|
||||
resps: []respUnit{
|
||||
{http.StatusOK, controllers.KillSessionResponse{}},
|
||||
{http.StatusNotFound, envelope.APIError{}},
|
||||
{http.StatusConflict, envelope.APIError{}},
|
||||
{http.StatusInternalServerError, envelope.APIError{}},
|
||||
},
|
||||
},
|
||||
{
|
||||
method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/send", id: "sendSessionMessage", tag: "sessions",
|
||||
summary: "Send a message to a running session's agent",
|
||||
pathParams: []any{controllers.SessionIDParam{}},
|
||||
reqBody: controllers.SendSessionMessageRequest{},
|
||||
resps: []respUnit{
|
||||
{http.StatusOK, controllers.SendSessionMessageResponse{}},
|
||||
{http.StatusBadRequest, envelope.APIError{}},
|
||||
{http.StatusNotFound, envelope.APIError{}},
|
||||
{http.StatusInternalServerError, envelope.APIError{}},
|
||||
},
|
||||
},
|
||||
{
|
||||
method: http.MethodPost, path: "/api/v1/orchestrators", id: "spawnOrchestrator", tag: "sessions",
|
||||
summary: "Spawn an orchestrator session",
|
||||
reqBody: controllers.SpawnOrchestratorRequest{},
|
||||
resps: []respUnit{
|
||||
{http.StatusCreated, controllers.SpawnOrchestratorResponse{}},
|
||||
{http.StatusBadRequest, envelope.APIError{}},
|
||||
{http.StatusNotFound, envelope.APIError{}},
|
||||
{http.StatusInternalServerError, envelope.APIError{}},
|
||||
{http.StatusNotImplemented, envelope.APIError{}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package specgen_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec/specgen"
|
||||
)
|
||||
|
||||
// TestBuild_MatchesEmbedded is the drift guard: the committed (embedded)
|
||||
// openapi.yaml must equal fresh Build() output. If this fails, run
|
||||
// `go generate ./...` and commit the result.
|
||||
func TestBuild_MatchesEmbedded(t *testing.T) {
|
||||
got, err := specgen.Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
embedded := apispec.Default().YAML()
|
||||
if !bytes.Equal(got, embedded) {
|
||||
t.Fatalf("embedded openapi.yaml is stale — run `go generate ./...` and commit.\n"+
|
||||
"len(fresh)=%d len(embedded)=%d", len(got), len(embedded))
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuild_Deterministic guards against nondeterministic output (which would
|
||||
// make the drift check flaky in CI).
|
||||
func TestBuild_Deterministic(t *testing.T) {
|
||||
a, err := specgen.Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Build #1: %v", err)
|
||||
}
|
||||
b, err := specgen.Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Build #2: %v", err)
|
||||
}
|
||||
if !bytes.Equal(a, b) {
|
||||
t.Fatal("Build() is not deterministic across calls")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
|
||||
)
|
||||
|
||||
// HTTP response envelopes for the projects surface — the SINGLE definition of
|
||||
// each wire shape. The handlers encode these (envelope.WriteJSON), and
|
||||
// apispec.Build reflects these same types into openapi.yaml, so the served
|
||||
// contract and the generated spec can't disagree. The request side needs no
|
||||
// wrappers: handlers decode the body straight into the project commands
|
||||
// (projectsvc.AddInput), which apispec also reflects.
|
||||
|
||||
// ProjectIDParam is the {id} path parameter shared by the /projects/{id}
|
||||
// routes. Handlers read it via chi.URLParam (see projectID); it is declared here
|
||||
// so every wire input/output shape has one home, and apispec.Build reflects it
|
||||
// as the path parameter.
|
||||
type ProjectIDParam struct {
|
||||
ID string `path:"id" description:"Project identifier (registry key)."`
|
||||
}
|
||||
|
||||
// ListProjectsResponse is the body of GET /api/v1/projects.
|
||||
type ListProjectsResponse struct {
|
||||
Projects []projectsvc.Summary `json:"projects"`
|
||||
}
|
||||
|
||||
// ProjectResponse is the { project } body shared by POST /projects (201).
|
||||
type ProjectResponse struct {
|
||||
Project projectsvc.Project `json:"project"`
|
||||
}
|
||||
|
||||
// GetProjectResponse is the { status, project } body of GET /projects/{id},
|
||||
// where project is oneOf Project|Degraded discriminated by status.
|
||||
type GetProjectResponse struct {
|
||||
Status string `json:"status" enum:"ok,degraded"`
|
||||
Project ProjectOrDegraded `json:"project"`
|
||||
}
|
||||
|
||||
// ProjectOrDegraded is the discriminated `project` field: exactly one of
|
||||
// Project/Degraded is set. It marshals as whichever is present (so the handler
|
||||
// emits the right object) and exposes the oneOf variants to the spec reflector
|
||||
// (so apispec.Build emits `oneOf: [Project, Degraded]`) — one type, both jobs.
|
||||
type ProjectOrDegraded struct {
|
||||
Project *projectsvc.Project
|
||||
Degraded *projectsvc.Degraded
|
||||
}
|
||||
|
||||
// MarshalJSON encodes whichever variant is set (Project or Degraded).
|
||||
func (p ProjectOrDegraded) MarshalJSON() ([]byte, error) {
|
||||
switch {
|
||||
case p.Degraded != nil:
|
||||
return json.Marshal(p.Degraded)
|
||||
case p.Project != nil:
|
||||
return json.Marshal(p.Project)
|
||||
default:
|
||||
// Unreachable in practice: the handler validates the GetResult via
|
||||
// newGetProjectResponse and writes a 500 before committing the 200
|
||||
// status, so this never encodes. Kept as a last-resort backstop —
|
||||
// erroring is still better than emitting a contract-breaking `null`,
|
||||
// though by here the status is already sent, so the real guard is
|
||||
// upstream.
|
||||
return nil, errEmptyProjectOrDegraded
|
||||
}
|
||||
}
|
||||
|
||||
// errEmptyProjectOrDegraded marks a GetResult that set neither variant — a
|
||||
// Manager-contract violation. newGetProjectResponse returns it so the handler
|
||||
// can map it to a 500 before any response bytes are written.
|
||||
var errEmptyProjectOrDegraded = errors.New("controllers: GetResult has neither Project nor Degraded set")
|
||||
|
||||
// JSONSchemaOneOf is read by swaggest's reflector (apispec.Build) to emit the
|
||||
// oneOf for this field; it is not used at runtime.
|
||||
func (ProjectOrDegraded) JSONSchemaOneOf() []interface{} {
|
||||
return []interface{}{projectsvc.Project{}, projectsvc.Degraded{}}
|
||||
}
|
||||
|
||||
// newGetProjectResponse maps the internal GetResult onto the wire envelope —
|
||||
// the explicit project→httpd boundary the result type exists for. It errors
|
||||
// when the result sets neither variant, so the handler can return a clean 500
|
||||
// BEFORE writing the 200 status rather than flushing a truncated body.
|
||||
func newGetProjectResponse(res projectsvc.GetResult) (GetProjectResponse, error) {
|
||||
if res.Project == nil && res.Degraded == nil {
|
||||
return GetProjectResponse{}, errEmptyProjectOrDegraded
|
||||
}
|
||||
return GetProjectResponse{
|
||||
Status: res.Status,
|
||||
Project: ProjectOrDegraded{Project: res.Project, Degraded: res.Degraded},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SessionIDParam is the {sessionId} path parameter shared by session routes.
|
||||
type SessionIDParam struct {
|
||||
SessionID string `path:"sessionId" description:"Session identifier, e.g. project-1."`
|
||||
}
|
||||
|
||||
// ListSessionsQuery is the query string accepted by GET /api/v1/sessions.
|
||||
type ListSessionsQuery struct {
|
||||
Project string `query:"project,omitempty" description:"Project id filter."`
|
||||
Active *bool `query:"active,omitempty" description:"When true, return non-terminated sessions; when false, return terminated sessions."`
|
||||
OrchestratorOnly *bool `query:"orchestratorOnly,omitempty" description:"When true, return only orchestrator sessions."`
|
||||
Fresh *bool `query:"fresh,omitempty" description:"When true, return only fresh non-terminated sessions."`
|
||||
}
|
||||
|
||||
// ListSessionsResponse is the body of GET /api/v1/sessions.
|
||||
type ListSessionsResponse struct {
|
||||
Sessions []domain.Session `json:"sessions"`
|
||||
}
|
||||
|
||||
// SpawnSessionRequest is the body of POST /api/v1/sessions.
|
||||
type SpawnSessionRequest struct {
|
||||
ProjectID domain.ProjectID `json:"projectId"`
|
||||
IssueID domain.IssueID `json:"issueId,omitempty"`
|
||||
Kind domain.SessionKind `json:"kind,omitempty" enum:"worker,orchestrator"`
|
||||
Harness domain.AgentHarness `json:"harness,omitempty" enum:"claude-code,codex,aider,opencode"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
Prompt string `json:"prompt,omitempty" maxLength:"4096"`
|
||||
AgentRules string `json:"agentRules,omitempty"`
|
||||
}
|
||||
|
||||
// SessionResponse is the { session } body shared by session create/get.
|
||||
type SessionResponse struct {
|
||||
Session domain.Session `json:"session"`
|
||||
}
|
||||
|
||||
// RenameSessionRequest is the body of PATCH /api/v1/sessions/{sessionId}.
|
||||
type RenameSessionRequest struct {
|
||||
DisplayName string `json:"displayName" minLength:"1"`
|
||||
}
|
||||
|
||||
// RestoreSessionResponse is the body of POST /api/v1/sessions/{sessionId}/restore.
|
||||
type RestoreSessionResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
SessionID domain.SessionID `json:"sessionId"`
|
||||
Session domain.Session `json:"session"`
|
||||
}
|
||||
|
||||
// KillSessionResponse is the body of POST /api/v1/sessions/{sessionId}/kill.
|
||||
type KillSessionResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
SessionID domain.SessionID `json:"sessionId"`
|
||||
Freed bool `json:"freed,omitempty"`
|
||||
}
|
||||
|
||||
// SendSessionMessageRequest is the body of POST /api/v1/sessions/{sessionId}/send.
|
||||
type SendSessionMessageRequest struct {
|
||||
Message string `json:"message" minLength:"1" maxLength:"4096"`
|
||||
}
|
||||
|
||||
// SendSessionMessageResponse is the body of POST /api/v1/sessions/{sessionId}/send.
|
||||
type SendSessionMessageResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
SessionID domain.SessionID `json:"sessionId"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// SpawnOrchestratorRequest is the body of POST /api/v1/orchestrators.
|
||||
type SpawnOrchestratorRequest struct {
|
||||
ProjectID domain.ProjectID `json:"projectId"`
|
||||
Clean bool `json:"clean,omitempty"`
|
||||
}
|
||||
|
||||
// SpawnOrchestratorResponse is the body of POST /api/v1/orchestrators.
|
||||
type SpawnOrchestratorResponse struct {
|
||||
Orchestrator OrchestratorResponse `json:"orchestrator"`
|
||||
}
|
||||
|
||||
// OrchestratorResponse is the minimal orchestrator read model returned after spawn.
|
||||
type OrchestratorResponse struct {
|
||||
ID domain.SessionID `json:"id"`
|
||||
ProjectID domain.ProjectID `json:"projectId"`
|
||||
ProjectName string `json:"projectName,omitempty"`
|
||||
}
|
||||
|
|
@ -5,10 +5,8 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
|
@ -16,26 +14,21 @@ import (
|
|||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/project"
|
||||
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
|
||||
)
|
||||
|
||||
// ProjectsController owns the /projects routes. The controller depends only on
|
||||
// project.Manager; nil keeps routes registered but returns OpenAPI-backed 501s.
|
||||
// projectsvc.Manager; nil keeps routes registered but returns OpenAPI-backed 501s.
|
||||
type ProjectsController struct {
|
||||
Mgr project.Manager
|
||||
Mgr projectsvc.Manager
|
||||
}
|
||||
|
||||
// Register mounts the project routes on the supplied router. Route order
|
||||
// matters: /projects/reload must register before /projects/{id} for the POST
|
||||
// verb, otherwise chi would treat "reload" as an {id} match for repair.
|
||||
// Register mounts the project routes on the supplied router.
|
||||
func (c *ProjectsController) Register(r chi.Router) {
|
||||
r.Get("/projects", c.list)
|
||||
r.Post("/projects", c.add)
|
||||
r.Post("/projects/reload", c.reload) // BEFORE /projects/{id}
|
||||
r.Get("/projects/{id}", c.get)
|
||||
r.Patch("/projects/{id}", c.updateConfig)
|
||||
r.Delete("/projects/{id}", c.remove)
|
||||
r.Post("/projects/{id}/repair", c.repair)
|
||||
}
|
||||
|
||||
func (c *ProjectsController) list(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -48,7 +41,10 @@ func (c *ProjectsController) list(w http.ResponseWriter, r *http.Request) {
|
|||
writeProjectError(w, r, err)
|
||||
return
|
||||
}
|
||||
envelope.WriteJSON(w, http.StatusOK, map[string]any{"projects": projects})
|
||||
if projects == nil {
|
||||
projects = []projectsvc.Summary{}
|
||||
}
|
||||
envelope.WriteJSON(w, http.StatusOK, ListProjectsResponse{Projects: projects})
|
||||
}
|
||||
|
||||
func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -56,7 +52,7 @@ func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) {
|
|||
apispec.NotImplemented(w, r, "POST", "/api/v1/projects")
|
||||
return
|
||||
}
|
||||
var in project.AddInput
|
||||
var in projectsvc.AddInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil)
|
||||
return
|
||||
|
|
@ -66,7 +62,7 @@ func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) {
|
|||
writeProjectError(w, r, err)
|
||||
return
|
||||
}
|
||||
envelope.WriteJSON(w, http.StatusCreated, map[string]any{"project": p})
|
||||
envelope.WriteJSON(w, http.StatusCreated, ProjectResponse{Project: p})
|
||||
}
|
||||
|
||||
func (c *ProjectsController) get(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -79,37 +75,12 @@ func (c *ProjectsController) get(w http.ResponseWriter, r *http.Request) {
|
|||
writeProjectError(w, r, err)
|
||||
return
|
||||
}
|
||||
if got.Status == "degraded" {
|
||||
envelope.WriteJSON(w, http.StatusOK, map[string]any{"status": got.Status, "project": got.Degraded})
|
||||
return
|
||||
}
|
||||
envelope.WriteJSON(w, http.StatusOK, map[string]any{"status": got.Status, "project": got.Project})
|
||||
}
|
||||
|
||||
func (c *ProjectsController) updateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if c.Mgr == nil {
|
||||
apispec.NotImplemented(w, r, "PATCH", "/api/v1/projects/{id}")
|
||||
return
|
||||
}
|
||||
if frozen, err := containsFrozenIdentityField(r); err != nil {
|
||||
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil)
|
||||
return
|
||||
} else if len(frozen) > 0 {
|
||||
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "IDENTITY_FROZEN", "Identity fields cannot be patched", map[string]any{"fields": frozen})
|
||||
return
|
||||
}
|
||||
|
||||
var patch project.UpdateConfigInput
|
||||
if err := decodeJSON(r, &patch); err != nil {
|
||||
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil)
|
||||
return
|
||||
}
|
||||
p, err := c.Mgr.UpdateConfig(r.Context(), projectID(r), patch)
|
||||
resp, err := newGetProjectResponse(got)
|
||||
if err != nil {
|
||||
writeProjectError(w, r, err)
|
||||
envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "INTERNAL_ERROR", "Internal server error", nil)
|
||||
return
|
||||
}
|
||||
envelope.WriteJSON(w, http.StatusOK, map[string]any{"project": p})
|
||||
envelope.WriteJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (c *ProjectsController) remove(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -125,32 +96,6 @@ func (c *ProjectsController) remove(w http.ResponseWriter, r *http.Request) {
|
|||
envelope.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (c *ProjectsController) repair(w http.ResponseWriter, r *http.Request) {
|
||||
if c.Mgr == nil {
|
||||
apispec.NotImplemented(w, r, "POST", "/api/v1/projects/{id}/repair")
|
||||
return
|
||||
}
|
||||
p, err := c.Mgr.Repair(r.Context(), projectID(r))
|
||||
if err != nil {
|
||||
writeProjectError(w, r, err)
|
||||
return
|
||||
}
|
||||
envelope.WriteJSON(w, http.StatusOK, map[string]any{"project": p})
|
||||
}
|
||||
|
||||
func (c *ProjectsController) reload(w http.ResponseWriter, r *http.Request) {
|
||||
if c.Mgr == nil {
|
||||
apispec.NotImplemented(w, r, "POST", "/api/v1/projects/reload")
|
||||
return
|
||||
}
|
||||
result, err := c.Mgr.Reload(r.Context())
|
||||
if err != nil {
|
||||
writeProjectError(w, r, err)
|
||||
return
|
||||
}
|
||||
envelope.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func projectID(r *http.Request) domain.ProjectID {
|
||||
return domain.ProjectID(chi.URLParam(r, "id"))
|
||||
}
|
||||
|
|
@ -159,30 +104,10 @@ func decodeJSON(r *http.Request, out any) error {
|
|||
return json.NewDecoder(r.Body).Decode(out)
|
||||
}
|
||||
|
||||
func containsFrozenIdentityField(r *http.Request) ([]string, error) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var frozen []string
|
||||
for _, field := range []string{"projectId", "path", "repo", "defaultBranch"} {
|
||||
if _, ok := raw[field]; ok {
|
||||
frozen = append(frozen, field)
|
||||
}
|
||||
}
|
||||
return frozen, nil
|
||||
}
|
||||
|
||||
// writeProjectError maps a project.Error to its HTTP status, falling back to
|
||||
// 500 for an unrecognized kind or a non-project.Error.
|
||||
// writeProjectError maps a projectsvc.Error to its HTTP status, falling back to
|
||||
// 500 for an unrecognized kind or a non-projectsvc.Error.
|
||||
func writeProjectError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
var pe *project.Error
|
||||
var pe *projectsvc.Error
|
||||
if errors.As(err, &pe) {
|
||||
status := http.StatusInternalServerError
|
||||
switch pe.Kind {
|
||||
|
|
|
|||
|
|
@ -1,310 +1,533 @@
|
|||
package controllers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"encoding/json"
|
||||
|
||||
"io"
|
||||
|
||||
"log/slog"
|
||||
|
||||
"net/http"
|
||||
|
||||
"net/http/httptest"
|
||||
|
||||
"os"
|
||||
|
||||
"os/exec"
|
||||
|
||||
"path/filepath"
|
||||
|
||||
"strings"
|
||||
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/config"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/project"
|
||||
|
||||
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
// emptyGetManager returns a GetResult that sets neither Project nor Degraded —
|
||||
|
||||
// a Manager-contract violation — so the test can prove the handler answers a
|
||||
|
||||
// clean 500 before writing the 200 status.
|
||||
|
||||
type emptyGetManager struct{ projectsvc.Manager }
|
||||
|
||||
func (emptyGetManager) Get(context.Context, domain.ProjectID) (projectsvc.GetResult, error) {
|
||||
|
||||
return projectsvc.GetResult{}, nil
|
||||
|
||||
}
|
||||
|
||||
// TestProjectsAPI_GetEmptyResultIs500 locks the fix for the discriminated-union
|
||||
|
||||
// invariant: a degenerate GetResult must surface as a parseable 500 envelope,
|
||||
|
||||
// not a 200 with truncated JSON.
|
||||
|
||||
func TestProjectsAPI_GetEmptyResultIs500(t *testing.T) {
|
||||
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{
|
||||
Projects: project.NewMemoryManager(),
|
||||
|
||||
Projects: emptyGetManager{},
|
||||
}))
|
||||
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects/whatever", "")
|
||||
|
||||
assertJSON(t, headers)
|
||||
|
||||
assertErrorCode(t, body, status, http.StatusInternalServerError, "INTERNAL_ERROR")
|
||||
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T) *httptest.Server {
|
||||
|
||||
t.Helper()
|
||||
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
store, err := sqlite.Open(t.TempDir())
|
||||
|
||||
if err != nil {
|
||||
|
||||
t.Fatalf("open store: %v", err)
|
||||
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
|
||||
srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{
|
||||
|
||||
Projects: projectsvc.New(store),
|
||||
}))
|
||||
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
return srv
|
||||
|
||||
}
|
||||
|
||||
func TestProjectsRoutes_DefaultToStubsWithoutManager(t *testing.T) {
|
||||
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
srv := httptest.NewServer(httpd.NewRouter(config.Config{}, log, nil))
|
||||
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects", "")
|
||||
|
||||
assertJSON(t, headers)
|
||||
|
||||
assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED")
|
||||
|
||||
}
|
||||
|
||||
func TestProjectsAPI_ListAddGetReload(t *testing.T) {
|
||||
func TestProjectsAPI_ListAddGet(t *testing.T) {
|
||||
|
||||
srv := newTestServer(t)
|
||||
|
||||
repo := gitRepo(t, "agent-orchestrator")
|
||||
|
||||
body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects", "")
|
||||
|
||||
if status != http.StatusOK {
|
||||
|
||||
t.Fatalf("GET projects = %d, want 200; body=%s", status, body)
|
||||
|
||||
}
|
||||
|
||||
assertJSON(t, headers)
|
||||
|
||||
var list struct {
|
||||
Projects []projectSummary `json:"projects"`
|
||||
}
|
||||
|
||||
mustJSON(t, body, &list)
|
||||
|
||||
if len(list.Projects) != 0 {
|
||||
|
||||
t.Fatalf("initial project count = %d, want 0", len(list.Projects))
|
||||
|
||||
}
|
||||
|
||||
body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repo)+`,"projectId":"ao","name":"Agent Orchestrator"}`)
|
||||
|
||||
if status != http.StatusCreated {
|
||||
|
||||
t.Fatalf("POST project = %d, want 201; body=%s", status, body)
|
||||
|
||||
}
|
||||
|
||||
var add struct {
|
||||
Project projectBody `json:"project"`
|
||||
}
|
||||
|
||||
mustJSON(t, body, &add)
|
||||
|
||||
if add.Project.ID != "ao" || add.Project.Name != "Agent Orchestrator" || add.Project.DefaultBranch != "main" {
|
||||
|
||||
t.Fatalf("created project = %#v", add.Project)
|
||||
|
||||
}
|
||||
|
||||
body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects/ao", "")
|
||||
|
||||
if status != http.StatusOK {
|
||||
|
||||
t.Fatalf("GET project = %d, want 200; body=%s", status, body)
|
||||
}
|
||||
var get struct {
|
||||
Status string `json:"status"`
|
||||
Project projectBody `json:"project"`
|
||||
}
|
||||
mustJSON(t, body, &get)
|
||||
if get.Status != "ok" || get.Project.ID != "ao" {
|
||||
t.Fatalf("get response = %#v", get)
|
||||
|
||||
}
|
||||
|
||||
body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects/reload", "")
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("reload = %d, want 200; body=%s", status, body)
|
||||
var get struct {
|
||||
Status string `json:"status"`
|
||||
|
||||
Project projectBody `json:"project"`
|
||||
}
|
||||
var reload struct {
|
||||
Reloaded bool `json:"reloaded"`
|
||||
ProjectCount int `json:"projectCount"`
|
||||
DegradedCount int `json:"degradedCount"`
|
||||
}
|
||||
mustJSON(t, body, &reload)
|
||||
if !reload.Reloaded || reload.ProjectCount != 1 || reload.DegradedCount != 0 {
|
||||
t.Fatalf("reload response = %#v", reload)
|
||||
|
||||
mustJSON(t, body, &get)
|
||||
|
||||
if get.Status != "ok" || get.Project.ID != "ao" {
|
||||
|
||||
t.Fatalf("get response = %#v", get)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestProjectsAPI_AddValidationAndConflicts(t *testing.T) {
|
||||
|
||||
srv := newTestServer(t)
|
||||
|
||||
repoA := gitRepo(t, "repo-a")
|
||||
|
||||
repoB := gitRepo(t, "repo-b")
|
||||
|
||||
notRepo := t.TempDir()
|
||||
|
||||
cases := []struct {
|
||||
name, body, wantCode string
|
||||
wantStatus int
|
||||
|
||||
wantStatus int
|
||||
}{
|
||||
|
||||
{name: "invalid json", body: `{`, wantStatus: 400, wantCode: "INVALID_JSON"},
|
||||
|
||||
{name: "missing path", body: `{}`, wantStatus: 400, wantCode: "PATH_REQUIRED"},
|
||||
|
||||
{name: "not git", body: `{"path":` + quote(notRepo) + `}`, wantStatus: 400, wantCode: "NOT_A_GIT_REPO"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", tc.body)
|
||||
|
||||
assertErrorCode(t, body, status, tc.wantStatus, tc.wantCode)
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoA)+`,"projectId":"shared"}`)
|
||||
|
||||
if status != http.StatusCreated {
|
||||
|
||||
t.Fatalf("seed create = %d, want 201; body=%s", status, body)
|
||||
|
||||
}
|
||||
|
||||
body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoA)+`,"projectId":"other"}`)
|
||||
|
||||
assertErrorCode(t, body, status, http.StatusConflict, "PATH_ALREADY_REGISTERED")
|
||||
|
||||
body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoB)+`,"projectId":"shared"}`)
|
||||
|
||||
assertErrorCode(t, body, status, http.StatusConflict, "ID_ALREADY_REGISTERED")
|
||||
|
||||
}
|
||||
|
||||
func TestProjectsAPI_UpdateDeleteRepair(t *testing.T) {
|
||||
func TestProjectsAPI_Delete(t *testing.T) {
|
||||
|
||||
srv := newTestServer(t)
|
||||
|
||||
repo := gitRepo(t, "repo")
|
||||
|
||||
body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repo)+`,"projectId":"proj"}`)
|
||||
|
||||
if status != http.StatusCreated {
|
||||
|
||||
t.Fatalf("seed create = %d, want 201; body=%s", status, body)
|
||||
|
||||
}
|
||||
|
||||
body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/projects/proj", `{"agent":"claude"}`)
|
||||
assertErrorCode(t, body, status, http.StatusNotImplemented, "PROJECT_CONFIG_NOT_IMPLEMENTED")
|
||||
|
||||
body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/projects/proj", `{"path":"elsewhere"}`)
|
||||
assertErrorCode(t, body, status, http.StatusBadRequest, "IDENTITY_FROZEN")
|
||||
|
||||
body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects/proj/repair", "")
|
||||
assertErrorCode(t, body, status, http.StatusBadRequest, "REPAIR_NOT_AVAILABLE")
|
||||
|
||||
body, status, _ = doRequest(t, srv, "DELETE", "/api/v1/projects/proj", "")
|
||||
|
||||
if status != http.StatusOK {
|
||||
|
||||
t.Fatalf("DELETE = %d, want 200; body=%s", status, body)
|
||||
|
||||
}
|
||||
|
||||
var removed struct {
|
||||
ProjectID string `json:"projectId"`
|
||||
RemovedStorageDir bool `json:"removedStorageDir"`
|
||||
ProjectID string `json:"projectId"`
|
||||
|
||||
RemovedStorageDir bool `json:"removedStorageDir"`
|
||||
}
|
||||
|
||||
mustJSON(t, body, &removed)
|
||||
|
||||
if removed.ProjectID != "proj" || removed.RemovedStorageDir {
|
||||
|
||||
t.Fatalf("delete response = %#v", removed)
|
||||
|
||||
}
|
||||
|
||||
body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects/proj", "")
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("GET archived project = %d, want 200; body=%s", status, body)
|
||||
|
||||
if status != http.StatusNotFound {
|
||||
|
||||
t.Fatalf("GET archived project = %d, want 404; body=%s", status, body)
|
||||
|
||||
}
|
||||
|
||||
body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects", "")
|
||||
|
||||
if status != http.StatusOK {
|
||||
|
||||
t.Fatalf("GET projects after archive = %d, want 200; body=%s", status, body)
|
||||
|
||||
}
|
||||
|
||||
var list struct {
|
||||
Projects []projectSummary `json:"projects"`
|
||||
}
|
||||
|
||||
mustJSON(t, body, &list)
|
||||
|
||||
if len(list.Projects) != 0 {
|
||||
|
||||
t.Fatalf("active projects after archive = %d, want 0", len(list.Projects))
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestProjectsRoutes_LegacyUnregistered(t *testing.T) {
|
||||
|
||||
srv := newTestServer(t)
|
||||
|
||||
cases := []struct {
|
||||
method, path, wantCode, why string
|
||||
wantStatus int
|
||||
|
||||
wantStatus int
|
||||
}{
|
||||
|
||||
{method: "PUT", path: "/api/v1/projects/p1", wantStatus: 405, wantCode: "METHOD_NOT_ALLOWED", why: "R3 PUT not registered"},
|
||||
{method: "POST", path: "/api/v1/projects/p1", wantStatus: 405, wantCode: "METHOD_NOT_ALLOWED", why: "R4 repair moved to /repair"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
|
||||
t.Run(tc.why, func(t *testing.T) {
|
||||
|
||||
body, status, _ := doRequest(t, srv, tc.method, tc.path, "")
|
||||
|
||||
assertErrorCode(t, body, status, tc.wantStatus, tc.wantCode)
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestProjectsRoutes_MissingRoute(t *testing.T) {
|
||||
|
||||
srv := newTestServer(t)
|
||||
|
||||
body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects/p1/does-not-exist", "")
|
||||
|
||||
assertJSON(t, headers)
|
||||
|
||||
assertErrorCode(t, body, status, http.StatusNotFound, "ROUTE_NOT_FOUND")
|
||||
|
||||
}
|
||||
|
||||
func TestOpenAPIYAMLServed(t *testing.T) {
|
||||
|
||||
srv := newTestServer(t)
|
||||
|
||||
body, status, headers := doRequest(t, srv, "GET", "/api/v1/openapi.yaml", "")
|
||||
|
||||
if status != http.StatusOK {
|
||||
|
||||
t.Fatalf("status = %d, want 200", status)
|
||||
|
||||
}
|
||||
|
||||
if ct := headers.Get("Content-Type"); !strings.HasPrefix(ct, "application/yaml") {
|
||||
|
||||
t.Errorf("Content-Type = %q, want application/yaml*", ct)
|
||||
|
||||
}
|
||||
|
||||
if !strings.Contains(string(body), "openapi: 3.1.0") {
|
||||
|
||||
t.Errorf("served body did not start with an OpenAPI 3.1 doc")
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
type projectSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
|
||||
Name string `json:"name"`
|
||||
|
||||
SessionPrefix string `json:"sessionPrefix"`
|
||||
}
|
||||
|
||||
type projectBody struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Repo string `json:"repo"`
|
||||
ID string `json:"id"`
|
||||
|
||||
Name string `json:"name"`
|
||||
|
||||
Path string `json:"path"`
|
||||
|
||||
Repo string `json:"repo"`
|
||||
|
||||
DefaultBranch string `json:"defaultBranch"`
|
||||
Agent string `json:"agent"`
|
||||
|
||||
Agent string `json:"agent"`
|
||||
}
|
||||
|
||||
type errorBody struct {
|
||||
Error string `json:"error"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error"`
|
||||
|
||||
Code string `json:"code"`
|
||||
|
||||
Message string `json:"message"`
|
||||
|
||||
Details map[string]any `json:"details"`
|
||||
}
|
||||
|
||||
func doRequest(t *testing.T, srv *httptest.Server, method, path, body string) ([]byte, int, http.Header) {
|
||||
|
||||
t.Helper()
|
||||
|
||||
var req *http.Request
|
||||
|
||||
var err error
|
||||
|
||||
if body != "" {
|
||||
|
||||
req, err = http.NewRequest(method, srv.URL+path, strings.NewReader(body))
|
||||
|
||||
} else {
|
||||
|
||||
req, err = http.NewRequest(method, srv.URL+path, nil)
|
||||
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
||||
t.Fatalf("new request: %v", err)
|
||||
|
||||
}
|
||||
|
||||
if body != "" {
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
}
|
||||
|
||||
resp, err := srv.Client().Do(req)
|
||||
|
||||
if err != nil {
|
||||
|
||||
t.Fatalf("do request: %v", err)
|
||||
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
buf, err := io.ReadAll(resp.Body)
|
||||
|
||||
if err != nil {
|
||||
|
||||
t.Fatalf("read body: %v", err)
|
||||
|
||||
}
|
||||
|
||||
return buf, resp.StatusCode, resp.Header
|
||||
|
||||
}
|
||||
|
||||
func gitRepo(t *testing.T, name string) string {
|
||||
|
||||
t.Helper()
|
||||
|
||||
dir := filepath.Join(t.TempDir(), name)
|
||||
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
|
||||
t.Fatalf("create git repo fixture: %v", err)
|
||||
|
||||
}
|
||||
|
||||
if out, err := exec.Command("git", "init", dir).CombinedOutput(); err != nil {
|
||||
|
||||
t.Fatalf("git init fixture: %v\n%s", err, out)
|
||||
|
||||
}
|
||||
|
||||
return dir
|
||||
|
||||
}
|
||||
|
||||
func quote(s string) string {
|
||||
|
||||
b, _ := json.Marshal(s)
|
||||
|
||||
return string(b)
|
||||
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, body []byte, out any) {
|
||||
|
||||
t.Helper()
|
||||
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
|
||||
t.Fatalf("unmarshal: %v\nbody=%s", err, body)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func assertJSON(t *testing.T, headers http.Header) {
|
||||
|
||||
t.Helper()
|
||||
|
||||
if ct := headers.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
|
||||
t.Fatalf("Content-Type = %q, want JSON", ct)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func assertErrorCode(t *testing.T, body []byte, status, wantStatus int, wantCode string) {
|
||||
|
||||
t.Helper()
|
||||
|
||||
if status != wantStatus {
|
||||
|
||||
t.Fatalf("status = %d, want %d\nbody=%s", status, wantStatus, body)
|
||||
|
||||
}
|
||||
|
||||
var got errorBody
|
||||
|
||||
mustJSON(t, body, &got)
|
||||
|
||||
if got.Code != wantCode {
|
||||
|
||||
t.Fatalf("code = %q, want %q\nbody=%s", got.Code, wantCode, body)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import (
|
|||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/service"
|
||||
sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session"
|
||||
sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager"
|
||||
)
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ const (
|
|||
|
||||
// SessionService is the controller-facing session service contract.
|
||||
type SessionService interface {
|
||||
List(ctx context.Context, filter service.SessionListFilter) ([]domain.Session, error)
|
||||
List(ctx context.Context, filter sessionsvc.ListFilter) ([]domain.Session, error)
|
||||
Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error)
|
||||
Get(ctx context.Context, id domain.SessionID) (domain.Session, error)
|
||||
Restore(ctx context.Context, id domain.SessionID) (domain.Session, error)
|
||||
|
|
@ -66,17 +66,7 @@ func (c *SessionsController) list(w http.ResponseWriter, r *http.Request) {
|
|||
writeSessionError(w, r, err)
|
||||
return
|
||||
}
|
||||
envelope.WriteJSON(w, http.StatusOK, map[string]any{"sessions": sessions})
|
||||
}
|
||||
|
||||
type spawnSessionRequest struct {
|
||||
ProjectID domain.ProjectID `json:"projectId"`
|
||||
IssueID domain.IssueID `json:"issueId"`
|
||||
Kind domain.SessionKind `json:"kind"`
|
||||
Harness domain.AgentHarness `json:"harness"`
|
||||
Branch string `json:"branch"`
|
||||
Prompt string `json:"prompt"`
|
||||
AgentRules string `json:"agentRules"`
|
||||
envelope.WriteJSON(w, http.StatusOK, ListSessionsResponse{Sessions: sessions})
|
||||
}
|
||||
|
||||
func (c *SessionsController) spawn(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -84,7 +74,7 @@ func (c *SessionsController) spawn(w http.ResponseWriter, r *http.Request) {
|
|||
apispec.NotImplemented(w, r, "POST", "/api/v1/sessions")
|
||||
return
|
||||
}
|
||||
var in spawnSessionRequest
|
||||
var in SpawnSessionRequest
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil)
|
||||
return
|
||||
|
|
@ -105,7 +95,7 @@ func (c *SessionsController) spawn(w http.ResponseWriter, r *http.Request) {
|
|||
writeSessionError(w, r, err)
|
||||
return
|
||||
}
|
||||
envelope.WriteJSON(w, http.StatusCreated, map[string]any{"session": sess})
|
||||
envelope.WriteJSON(w, http.StatusCreated, SessionResponse{Session: sess})
|
||||
}
|
||||
|
||||
func (c *SessionsController) get(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -118,7 +108,7 @@ func (c *SessionsController) get(w http.ResponseWriter, r *http.Request) {
|
|||
writeSessionError(w, r, err)
|
||||
return
|
||||
}
|
||||
envelope.WriteJSON(w, http.StatusOK, map[string]any{"session": sess})
|
||||
envelope.WriteJSON(w, http.StatusOK, SessionResponse{Session: sess})
|
||||
}
|
||||
|
||||
func (c *SessionsController) rename(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -135,7 +125,7 @@ func (c *SessionsController) restore(w http.ResponseWriter, r *http.Request) {
|
|||
writeSessionError(w, r, err)
|
||||
return
|
||||
}
|
||||
envelope.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "sessionId": sessionID(r), "session": sess})
|
||||
envelope.WriteJSON(w, http.StatusOK, RestoreSessionResponse{OK: true, SessionID: sessionID(r), Session: sess})
|
||||
}
|
||||
|
||||
func (c *SessionsController) kill(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -148,11 +138,7 @@ func (c *SessionsController) kill(w http.ResponseWriter, r *http.Request) {
|
|||
writeSessionError(w, r, err)
|
||||
return
|
||||
}
|
||||
envelope.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "sessionId": sessionID(r), "freed": freed})
|
||||
}
|
||||
|
||||
type sendSessionRequest struct {
|
||||
Message string `json:"message"`
|
||||
envelope.WriteJSON(w, http.StatusOK, KillSessionResponse{OK: true, SessionID: sessionID(r), Freed: freed})
|
||||
}
|
||||
|
||||
func (c *SessionsController) send(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -160,7 +146,7 @@ func (c *SessionsController) send(w http.ResponseWriter, r *http.Request) {
|
|||
apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/send")
|
||||
return
|
||||
}
|
||||
var in sendSessionRequest
|
||||
var in SendSessionMessageRequest
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil)
|
||||
return
|
||||
|
|
@ -178,12 +164,7 @@ func (c *SessionsController) send(w http.ResponseWriter, r *http.Request) {
|
|||
writeSessionError(w, r, err)
|
||||
return
|
||||
}
|
||||
envelope.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "sessionId": sessionID(r), "message": message})
|
||||
}
|
||||
|
||||
type spawnOrchestratorRequest struct {
|
||||
ProjectID domain.ProjectID `json:"projectId"`
|
||||
Clean bool `json:"clean"`
|
||||
envelope.WriteJSON(w, http.StatusOK, SendSessionMessageResponse{OK: true, SessionID: sessionID(r), Message: message})
|
||||
}
|
||||
|
||||
func (c *SessionsController) spawnOrchestrator(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -191,7 +172,7 @@ func (c *SessionsController) spawnOrchestrator(w http.ResponseWriter, r *http.Re
|
|||
apispec.NotImplemented(w, r, "POST", "/api/v1/orchestrators")
|
||||
return
|
||||
}
|
||||
var in spawnOrchestratorRequest
|
||||
var in SpawnOrchestratorRequest
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil)
|
||||
return
|
||||
|
|
@ -202,7 +183,7 @@ func (c *SessionsController) spawnOrchestrator(w http.ResponseWriter, r *http.Re
|
|||
}
|
||||
if in.Clean {
|
||||
active := true
|
||||
orchestrators, err := c.Svc.List(r.Context(), service.SessionListFilter{ProjectID: in.ProjectID, Active: &active, OrchestratorOnly: true})
|
||||
orchestrators, err := c.Svc.List(r.Context(), sessionsvc.ListFilter{ProjectID: in.ProjectID, Active: &active, OrchestratorOnly: true})
|
||||
if err != nil {
|
||||
writeSessionError(w, r, err)
|
||||
return
|
||||
|
|
@ -219,34 +200,36 @@ func (c *SessionsController) spawnOrchestrator(w http.ResponseWriter, r *http.Re
|
|||
writeSessionError(w, r, err)
|
||||
return
|
||||
}
|
||||
envelope.WriteJSON(w, http.StatusCreated, map[string]any{"orchestrator": map[string]any{"id": sess.ID, "projectId": sess.ProjectID}})
|
||||
envelope.WriteJSON(w, http.StatusCreated, SpawnOrchestratorResponse{
|
||||
Orchestrator: OrchestratorResponse{ID: sess.ID, ProjectID: sess.ProjectID},
|
||||
})
|
||||
}
|
||||
|
||||
func sessionID(r *http.Request) domain.SessionID {
|
||||
return domain.SessionID(chi.URLParam(r, "sessionId"))
|
||||
}
|
||||
|
||||
func parseSessionListFilter(r *http.Request) (service.SessionListFilter, error) {
|
||||
func parseSessionListFilter(r *http.Request) (sessionsvc.ListFilter, error) {
|
||||
q := r.URL.Query()
|
||||
filter := service.SessionListFilter{ProjectID: domain.ProjectID(q.Get("project"))}
|
||||
filter := sessionsvc.ListFilter{ProjectID: domain.ProjectID(q.Get("project"))}
|
||||
if raw := q.Get("active"); raw != "" {
|
||||
active, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return service.SessionListFilter{}, errors.New("active must be a boolean")
|
||||
return sessionsvc.ListFilter{}, errors.New("active must be a boolean")
|
||||
}
|
||||
filter.Active = &active
|
||||
}
|
||||
if raw := q.Get("orchestratorOnly"); raw != "" {
|
||||
orchestratorOnly, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return service.SessionListFilter{}, errors.New("orchestratorOnly must be a boolean")
|
||||
return sessionsvc.ListFilter{}, errors.New("orchestratorOnly must be a boolean")
|
||||
}
|
||||
filter.OrchestratorOnly = orchestratorOnly
|
||||
}
|
||||
if raw := q.Get("fresh"); raw != "" {
|
||||
fresh, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return service.SessionListFilter{}, errors.New("fresh must be a boolean")
|
||||
return sessionsvc.ListFilter{}, errors.New("fresh must be a boolean")
|
||||
}
|
||||
filter.Fresh = fresh
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import (
|
|||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/service"
|
||||
sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session"
|
||||
)
|
||||
|
||||
type fakeSessionService struct {
|
||||
|
|
@ -27,7 +27,7 @@ func newFakeSessionService() *fakeSessionService {
|
|||
return &fakeSessionService{sessions: map[domain.SessionID]domain.Session{s.ID: s}}
|
||||
}
|
||||
|
||||
func (f *fakeSessionService) List(_ context.Context, filter service.SessionListFilter) ([]domain.Session, error) {
|
||||
func (f *fakeSessionService) List(_ context.Context, filter sessionsvc.ListFilter) ([]domain.Session, error) {
|
||||
var out []domain.Session
|
||||
for _, s := range f.sessions {
|
||||
if filter.ProjectID != "" && s.ProjectID != filter.ProjectID {
|
||||
|
|
|
|||
|
|
@ -9,9 +9,8 @@ import (
|
|||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/lifecycle"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
prsvc "github.com/aoagents/agent-orchestrator/backend/internal/pr"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/project"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/service"
|
||||
prsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/pr"
|
||||
sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session"
|
||||
sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
|
||||
)
|
||||
|
|
@ -55,7 +54,7 @@ func (c *captureMessenger) Send(_ context.Context, _ domain.SessionID, msg strin
|
|||
|
||||
type stack struct {
|
||||
store *sqlite.Store
|
||||
sm *service.Session
|
||||
sm *sessionsvc.Service
|
||||
lcm *lifecycle.Manager
|
||||
prm *prsvc.Manager
|
||||
rt *stubRuntime
|
||||
|
|
@ -71,7 +70,7 @@ func newStack(t *testing.T) *stack {
|
|||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
if err := store.Upsert(ctx, project.Row{ID: "mer", Path: "/repo/mer", RegisteredAt: time.Now()}); err != nil {
|
||||
if err := store.UpsertProject(ctx, domain.ProjectRecord{ID: "mer", Path: "/repo/mer", RegisteredAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msg := &captureMessenger{}
|
||||
|
|
@ -80,7 +79,7 @@ func newStack(t *testing.T) *stack {
|
|||
rt := &stubRuntime{}
|
||||
ws := &stubWorkspace{}
|
||||
mgr := sessionmanager.New(sessionmanager.Deps{Runtime: rt, Agent: stubAgent{}, Workspace: ws, Store: store, Messenger: msg, Lifecycle: lcm})
|
||||
sm := service.NewSession(mgr, store)
|
||||
sm := sessionsvc.New(mgr, store)
|
||||
return &stack{store: store, sm: sm, lcm: lcm, prm: prm, rt: rt, ws: ws, msg: msg}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
package project
|
||||
|
||||
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
|
||||
// Request/response shapes for Manager. They carry the data across the service
|
||||
// boundary; the entities they reference (Project, Summary, Degraded) live in
|
||||
// types.go in this same package. Named without a "Project" prefix because the
|
||||
// package name already supplies it (project.AddInput, project.GetResult).
|
||||
|
||||
// GetResult is the discriminated union returned by Manager.Get. Exactly one of
|
||||
// Project / Degraded is non-nil; Status mirrors the discriminator on the wire
|
||||
// so consumers branch on it without nil-checking both fields.
|
||||
type GetResult struct {
|
||||
Status string // "ok" | "degraded"
|
||||
Project *Project // populated when Status == "ok"
|
||||
Degraded *Degraded // populated when Status == "degraded"
|
||||
}
|
||||
|
||||
// AddInput is the body shape for POST /api/v1/projects. Path is required;
|
||||
// ProjectID and Name default to basename(path) at the manager. Pointer fields
|
||||
// preserve the "field absent" vs "field present empty" distinction so the
|
||||
// manager can decide what to default and what to reject.
|
||||
type AddInput struct {
|
||||
Path string `json:"path"`
|
||||
ProjectID *string `json:"projectId,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateConfigInput is the body shape for PATCH /api/v1/projects/{id}. Only
|
||||
// behaviour fields are mutable; identity fields (projectId, path, repo,
|
||||
// defaultBranch) are rejected by the handler with a 400 IDENTITY_FROZEN.
|
||||
type UpdateConfigInput struct {
|
||||
Agent *string `json:"agent,omitempty"`
|
||||
Tracker *TrackerConfig `json:"tracker,omitempty"`
|
||||
SCM *SCMConfig `json:"scm,omitempty"`
|
||||
}
|
||||
|
||||
// RemoveResult reports what DELETE /api/v1/projects/{id} actually did.
|
||||
// RemovedStorageDir is false when the project was registry-only (no on-disk
|
||||
// session/workspace directory existed).
|
||||
type RemoveResult struct {
|
||||
ProjectID domain.ProjectID `json:"projectId"`
|
||||
RemovedStorageDir bool `json:"removedStorageDir"`
|
||||
}
|
||||
|
||||
// ReloadResult is the response body of POST /api/v1/projects/reload — the
|
||||
// manager invalidates its cached config and re-scans the registry; the counts
|
||||
// help the dashboard show "loaded N projects, M degraded" feedback.
|
||||
type ReloadResult struct {
|
||||
Reloaded bool `json:"reloaded"`
|
||||
ProjectCount int `json:"projectCount"`
|
||||
DegradedCount int `json:"degradedCount"`
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
package project
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Row mirrors the project table shape from the sqlite storage PR. The
|
||||
// memory store is intentionally row-based so the API layer does not depend on a
|
||||
// richer mock model than the real DB will provide.
|
||||
type Row struct {
|
||||
ID string
|
||||
Path string
|
||||
RepoOriginURL string
|
||||
DisplayName string
|
||||
RegisteredAt time.Time
|
||||
ArchivedAt time.Time
|
||||
}
|
||||
|
||||
// Store is the project persistence the manager depends on. MemoryStore is the
|
||||
// current in-process implementation; the sqlite adapter uses the same row shape.
|
||||
type Store interface {
|
||||
List(ctx context.Context) ([]Row, error)
|
||||
Get(ctx context.Context, id string) (Row, bool, error)
|
||||
FindByPath(ctx context.Context, path string) (Row, bool, error)
|
||||
Upsert(ctx context.Context, row Row) error
|
||||
Archive(ctx context.Context, id string, at time.Time) (bool, error)
|
||||
}
|
||||
|
||||
// MemoryStore is the mocked DB layer for the project API implementation. It is
|
||||
// process-local and intentionally small, but concurrency-safe for HTTP tests.
|
||||
type MemoryStore struct {
|
||||
mu sync.Mutex
|
||||
projects map[string]Row
|
||||
paths map[string]string
|
||||
}
|
||||
|
||||
var _ Store = (*MemoryStore)(nil)
|
||||
|
||||
// NewMemoryStore returns an empty, ready-to-use in-memory project store.
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
return &MemoryStore{
|
||||
projects: map[string]Row{},
|
||||
paths: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
// List returns all non-archived projects, in unspecified order.
|
||||
func (s *MemoryStore) List(context.Context) ([]Row, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
out := make([]Row, 0, len(s.projects))
|
||||
for _, row := range s.projects {
|
||||
if row.ArchivedAt.IsZero() {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Get returns the project with the given id, or ok=false if absent.
|
||||
func (s *MemoryStore) Get(_ context.Context, id string) (Row, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
row, ok := s.projects[id]
|
||||
if !ok {
|
||||
return Row{}, false, nil
|
||||
}
|
||||
return row, true, nil
|
||||
}
|
||||
|
||||
// FindByPath returns the project registered at a filesystem path, or ok=false.
|
||||
func (s *MemoryStore) FindByPath(_ context.Context, path string) (Row, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
id, ok := s.paths[path]
|
||||
if !ok {
|
||||
return Row{}, false, nil
|
||||
}
|
||||
row, ok := s.projects[id]
|
||||
if !ok {
|
||||
return Row{}, false, nil
|
||||
}
|
||||
return row, true, nil
|
||||
}
|
||||
|
||||
// Upsert inserts or replaces a project, keeping the path→id index in sync.
|
||||
func (s *MemoryStore) Upsert(_ context.Context, row Row) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if existing, ok := s.projects[row.ID]; ok && existing.Path != row.Path {
|
||||
delete(s.paths, existing.Path)
|
||||
}
|
||||
s.projects[row.ID] = row
|
||||
s.paths[row.Path] = row.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
// Archive soft-deletes a project by stamping ArchivedAt; returns ok=false if
|
||||
// the project doesn't exist.
|
||||
func (s *MemoryStore) Archive(_ context.Context, id string, at time.Time) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
row, ok := s.projects[id]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
row.ArchivedAt = at
|
||||
s.projects[id] = row
|
||||
return true, nil
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
// Package project owns the projects service contract: the Manager interface
|
||||
// the HTTP layer calls and the request/response DTOs that cross it (dto.go).
|
||||
//
|
||||
// This is the pilot for the feature-package layout the backend is migrating
|
||||
// toward: a resource's interface, implementation, and DTOs live with the
|
||||
// resource, not in a central catch-all. Controllers depend on project.Manager
|
||||
// and nothing beneath it.
|
||||
package project
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
)
|
||||
|
||||
// Manager is the inbound contract for the /api/v1/projects surface. One
|
||||
// implementation lives in this package; the HTTP controller is the consumer.
|
||||
type Manager interface {
|
||||
// List returns every registered project, including degraded entries
|
||||
// (those whose config failed to load but whose registry entry survives).
|
||||
List(ctx context.Context) ([]Summary, error)
|
||||
|
||||
// Get returns one project, discriminating ok vs degraded via GetResult.
|
||||
Get(ctx context.Context, id domain.ProjectID) (GetResult, error)
|
||||
|
||||
// Add registers a new project from a git repository path.
|
||||
Add(ctx context.Context, in AddInput) (Project, error)
|
||||
|
||||
// UpdateConfig patches behaviour-only fields; identity fields are frozen.
|
||||
UpdateConfig(ctx context.Context, id domain.ProjectID, patch UpdateConfigInput) (Project, error)
|
||||
|
||||
// Remove unregisters a project, stopping its sessions and reclaiming
|
||||
// managed workspaces.
|
||||
Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error)
|
||||
|
||||
// Repair recovers a degraded project where automatic repair is available.
|
||||
Repair(ctx context.Context, id domain.ProjectID) (Project, error)
|
||||
|
||||
// Reload invalidates cached config and re-scans the global registry.
|
||||
Reload(ctx context.Context) (ReloadResult, error)
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package project
|
||||
|
||||
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
|
||||
// GetResult is the discriminated result returned by Service.Get.
|
||||
type GetResult struct {
|
||||
Status string
|
||||
Project *Project
|
||||
Degraded *Degraded
|
||||
}
|
||||
|
||||
// AddInput is the body shape for POST /api/v1/projects.
|
||||
type AddInput struct {
|
||||
Path string `json:"path"`
|
||||
ProjectID *string `json:"projectId,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// RemoveResult reports what DELETE /api/v1/projects/{id} actually did.
|
||||
type RemoveResult struct {
|
||||
ProjectID domain.ProjectID `json:"projectId"`
|
||||
RemovedStorageDir bool `json:"removedStorageDir"`
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
package project
|
||||
|
||||
// Error is the manager-level error shape controllers can translate into the
|
||||
// Error is the service-level error shape controllers translate into the
|
||||
// locked HTTP APIError envelope without knowing store internals.
|
||||
type Error struct {
|
||||
Kind string
|
||||
|
|
@ -32,10 +32,6 @@ func conflict(code, message string, details map[string]any) *Error {
|
|||
return newError("conflict", code, message, details)
|
||||
}
|
||||
|
||||
func notImplemented(code, message string) *Error {
|
||||
return newError("not_implemented", code, message, nil)
|
||||
}
|
||||
|
||||
func internal(code, message string) *Error {
|
||||
return newError("internal", code, message, nil)
|
||||
}
|
||||
|
|
@ -13,29 +13,38 @@ import (
|
|||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
)
|
||||
|
||||
type manager struct {
|
||||
// Manager is the controller-facing contract for the /api/v1/projects surface.
|
||||
type Manager interface {
|
||||
// List returns every registered project, including degraded entries
|
||||
// (those whose config failed to load but whose registry entry survives).
|
||||
List(ctx context.Context) ([]Summary, error)
|
||||
|
||||
// Get returns one project, discriminating ok vs degraded via GetResult.
|
||||
Get(ctx context.Context, id domain.ProjectID) (GetResult, error)
|
||||
|
||||
// Add registers a new project from a git repository path.
|
||||
Add(ctx context.Context, in AddInput) (Project, error)
|
||||
|
||||
// Remove unregisters a project, stopping its sessions and reclaiming
|
||||
// managed workspaces.
|
||||
Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error)
|
||||
}
|
||||
|
||||
// Service implements project registration and lookup use-cases for controllers.
|
||||
type Service struct {
|
||||
store Store
|
||||
}
|
||||
|
||||
var _ Manager = (*manager)(nil)
|
||||
var _ Manager = (*Service)(nil)
|
||||
|
||||
// NewManager returns a project Manager backed by the given Store, defaulting to
|
||||
// an in-memory store when store is nil.
|
||||
func NewManager(store Store) Manager {
|
||||
if store == nil {
|
||||
store = NewMemoryStore()
|
||||
}
|
||||
return &manager{store: store}
|
||||
// New returns a project service backed by the given durable store.
|
||||
func New(store Store) *Service {
|
||||
return &Service{store: store}
|
||||
}
|
||||
|
||||
// NewMemoryManager returns a project Manager backed by a fresh in-memory store,
|
||||
// for tests and ephemeral use.
|
||||
func NewMemoryManager() Manager {
|
||||
return NewManager(NewMemoryStore())
|
||||
}
|
||||
|
||||
func (m *manager) List(ctx context.Context) ([]Summary, error) {
|
||||
projects, err := m.store.List(ctx)
|
||||
// List returns every active registered project.
|
||||
func (m *Service) List(ctx context.Context) ([]Summary, error) {
|
||||
projects, err := m.store.ListProjects(ctx)
|
||||
if err != nil {
|
||||
return nil, internal("PROJECTS_LIST_FAILED", "Failed to load projects")
|
||||
}
|
||||
|
|
@ -50,22 +59,24 @@ func (m *manager) List(ctx context.Context) ([]Summary, error) {
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (m *manager) Get(ctx context.Context, id domain.ProjectID) (GetResult, error) {
|
||||
// Get returns one active project by id.
|
||||
func (m *Service) Get(ctx context.Context, id domain.ProjectID) (GetResult, error) {
|
||||
if err := validateProjectID(id); err != nil {
|
||||
return GetResult{}, err
|
||||
}
|
||||
row, ok, err := m.store.Get(ctx, string(id))
|
||||
row, ok, err := m.store.GetProject(ctx, string(id))
|
||||
if err != nil {
|
||||
return GetResult{}, internal("PROJECT_LOAD_FAILED", "Failed to load project")
|
||||
}
|
||||
if !ok {
|
||||
if !ok || !row.ArchivedAt.IsZero() {
|
||||
return GetResult{}, notFound("PROJECT_NOT_FOUND", "Unknown project")
|
||||
}
|
||||
p := projectFromRow(row)
|
||||
return GetResult{Status: "ok", Project: &p}, nil
|
||||
}
|
||||
|
||||
func (m *manager) Add(ctx context.Context, in AddInput) (Project, error) {
|
||||
// Add registers a local git repository as a project.
|
||||
func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) {
|
||||
path, err := normalizePath(in.Path)
|
||||
if err != nil {
|
||||
return Project{}, err
|
||||
|
|
@ -90,7 +101,7 @@ func (m *manager) Add(ctx context.Context, in AddInput) (Project, error) {
|
|||
name = string(id)
|
||||
}
|
||||
|
||||
if existing, ok, err := m.store.FindByPath(ctx, path); err != nil {
|
||||
if existing, ok, err := m.store.FindProjectByPath(ctx, path); err != nil {
|
||||
return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project")
|
||||
} else if ok {
|
||||
return Project{}, conflict("PATH_ALREADY_REGISTERED", "A project at this path is already registered", map[string]any{
|
||||
|
|
@ -98,47 +109,33 @@ func (m *manager) Add(ctx context.Context, in AddInput) (Project, error) {
|
|||
"suggestedProjectId": string(m.suggestID(ctx, id)),
|
||||
})
|
||||
}
|
||||
if existing, ok, err := m.store.Get(ctx, string(id)); err != nil {
|
||||
if existing, ok, err := m.store.GetProject(ctx, string(id)); err != nil {
|
||||
return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project")
|
||||
} else if ok && existing.Path != path {
|
||||
} else if ok && existing.ArchivedAt.IsZero() && existing.Path != path {
|
||||
return Project{}, conflict("ID_ALREADY_REGISTERED", "A project with this id is already registered for a different path", map[string]any{
|
||||
"existingProjectId": existing.ID,
|
||||
"suggestedProjectId": string(m.suggestID(ctx, id)),
|
||||
})
|
||||
}
|
||||
|
||||
row := Row{
|
||||
row := domain.ProjectRecord{
|
||||
ID: string(id),
|
||||
Path: path,
|
||||
DisplayName: name,
|
||||
RegisteredAt: time.Now(),
|
||||
}
|
||||
if err := m.store.Upsert(ctx, row); err != nil {
|
||||
if err := m.store.UpsertProject(ctx, row); err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
return projectFromRow(row), nil
|
||||
}
|
||||
|
||||
func (m *manager) UpdateConfig(ctx context.Context, id domain.ProjectID, _ UpdateConfigInput) (Project, error) {
|
||||
if err := validateProjectID(id); err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
_, ok, err := m.store.Get(ctx, string(id))
|
||||
if err != nil {
|
||||
return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project")
|
||||
}
|
||||
if !ok {
|
||||
return Project{}, notFound("PROJECT_NOT_FOUND", "Unknown project")
|
||||
}
|
||||
|
||||
return Project{}, notImplemented("PROJECT_CONFIG_NOT_IMPLEMENTED", "Project config patching is not available until config persistence is wired")
|
||||
}
|
||||
|
||||
func (m *manager) Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) {
|
||||
// Remove archives a project registration.
|
||||
func (m *Service) Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) {
|
||||
if err := validateProjectID(id); err != nil {
|
||||
return RemoveResult{}, err
|
||||
}
|
||||
ok, err := m.store.Archive(ctx, string(id), time.Now())
|
||||
ok, err := m.store.ArchiveProject(ctx, string(id), time.Now())
|
||||
if err != nil {
|
||||
return RemoveResult{}, internal("PROJECT_REMOVE_FAILED", "Failed to remove project")
|
||||
}
|
||||
|
|
@ -148,36 +145,16 @@ func (m *manager) Remove(ctx context.Context, id domain.ProjectID) (RemoveResult
|
|||
return RemoveResult{ProjectID: id, RemovedStorageDir: false}, nil
|
||||
}
|
||||
|
||||
func (m *manager) Repair(ctx context.Context, id domain.ProjectID) (Project, error) {
|
||||
if err := validateProjectID(id); err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
if _, ok, err := m.store.Get(ctx, string(id)); err != nil {
|
||||
return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project")
|
||||
} else if !ok {
|
||||
return Project{}, notFound("PROJECT_NOT_FOUND", "Unknown project")
|
||||
}
|
||||
return Project{}, badRequest("REPAIR_NOT_AVAILABLE", "Automatic repair is not available for this degraded config", nil)
|
||||
}
|
||||
|
||||
func (m *manager) Reload(ctx context.Context) (ReloadResult, error) {
|
||||
projects, err := m.store.List(ctx)
|
||||
if err != nil {
|
||||
return ReloadResult{}, internal("RELOAD_FAILED", "Failed to reload projects")
|
||||
}
|
||||
return ReloadResult{Reloaded: true, ProjectCount: len(projects), DegradedCount: 0}, nil
|
||||
}
|
||||
|
||||
func (m *manager) suggestID(ctx context.Context, base domain.ProjectID) domain.ProjectID {
|
||||
func (m *Service) suggestID(ctx context.Context, base domain.ProjectID) domain.ProjectID {
|
||||
for i := 1; ; i++ {
|
||||
candidate := domain.ProjectID(string(base) + strconv.Itoa(i))
|
||||
if _, ok, _ := m.store.Get(ctx, string(candidate)); !ok {
|
||||
if _, ok, _ := m.store.GetProject(ctx, string(candidate)); !ok {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func projectFromRow(row Row) Project {
|
||||
func projectFromRow(row domain.ProjectRecord) Project {
|
||||
return Project{
|
||||
ID: domain.ProjectID(row.ID),
|
||||
Name: displayName(row),
|
||||
|
|
@ -187,7 +164,7 @@ func projectFromRow(row Row) Project {
|
|||
}
|
||||
}
|
||||
|
||||
func displayName(row Row) string {
|
||||
func displayName(row domain.ProjectRecord) string {
|
||||
if strings.TrimSpace(row.DisplayName) != "" {
|
||||
return row.DisplayName
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
package project_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/service/project"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
// newManager builds a Manager over a real, throwaway sqlite store (pure-Go
|
||||
// driver, migrations run on Open) — no in-memory store.
|
||||
func newManager(t *testing.T) project.Manager {
|
||||
t.Helper()
|
||||
store, err := sqlite.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
return project.New(store)
|
||||
}
|
||||
|
||||
// gitRepo creates a real git repository in a fresh temp dir and returns its path.
|
||||
func gitRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if out, err := exec.Command("git", "init", dir).CombinedOutput(); err != nil {
|
||||
t.Fatalf("git unavailable: %v (%s)", err, out)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func ptr(s string) *string { return &s }
|
||||
|
||||
// wantCode asserts err is a *project.Error carrying the given machine code.
|
||||
func wantCode(t *testing.T, err error, code string) {
|
||||
t.Helper()
|
||||
var e *project.Error
|
||||
if !errors.As(err, &e) {
|
||||
t.Fatalf("error = %v, want *project.Error", err)
|
||||
}
|
||||
if e.Code != code {
|
||||
t.Fatalf("code = %q, want %q", e.Code, code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_AddListGetRemove(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
m := newManager(t)
|
||||
repo := gitRepo(t)
|
||||
|
||||
if got, err := m.List(ctx); err != nil || len(got) != 0 {
|
||||
t.Fatalf("List() = %v, %v; want empty", got, err)
|
||||
}
|
||||
|
||||
proj, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao"), Name: ptr("Agent Orchestrator")})
|
||||
if err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
if proj.ID != "ao" || proj.Name != "Agent Orchestrator" || proj.Path != repo || proj.DefaultBranch != "main" {
|
||||
t.Fatalf("Add returned %#v", proj)
|
||||
}
|
||||
|
||||
list, err := m.List(ctx)
|
||||
if err != nil || len(list) != 1 || list[0].ID != "ao" {
|
||||
t.Fatalf("List() = %v, %v; want [ao]", list, err)
|
||||
}
|
||||
|
||||
res, err := m.Get(ctx, "ao")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if res.Status != "ok" || res.Project == nil || res.Project.ID != "ao" {
|
||||
t.Fatalf("Get = %#v", res)
|
||||
}
|
||||
|
||||
rm, err := m.Remove(ctx, "ao")
|
||||
if err != nil {
|
||||
t.Fatalf("Remove: %v", err)
|
||||
}
|
||||
if rm.ProjectID != "ao" || rm.RemovedStorageDir {
|
||||
t.Fatalf("Remove = %#v", rm)
|
||||
}
|
||||
if list, _ := m.List(ctx); len(list) != 0 {
|
||||
t.Fatalf("active list after remove = %d, want 0", len(list))
|
||||
}
|
||||
_, err = m.Get(ctx, "ao")
|
||||
wantCode(t, err, "PROJECT_NOT_FOUND")
|
||||
}
|
||||
|
||||
func TestManager_ReaddAfterRemove(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
m := newManager(t)
|
||||
repo := gitRepo(t)
|
||||
|
||||
if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao")}); err != nil {
|
||||
t.Fatalf("first Add: %v", err)
|
||||
}
|
||||
if _, err := m.Remove(ctx, "ao"); err != nil {
|
||||
t.Fatalf("Remove: %v", err)
|
||||
}
|
||||
if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao2")}); err != nil {
|
||||
t.Fatalf("re-add same path after remove: %v", err)
|
||||
}
|
||||
|
||||
otherRepo := gitRepo(t)
|
||||
if _, err := m.Remove(ctx, "ao2"); err != nil {
|
||||
t.Fatalf("Remove ao2: %v", err)
|
||||
}
|
||||
if _, err := m.Add(ctx, project.AddInput{Path: otherRepo, ProjectID: ptr("ao2")}); err != nil {
|
||||
t.Fatalf("re-add same id at different path after remove: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_AddValidationAndConflicts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
m := newManager(t)
|
||||
|
||||
_, err := m.Add(ctx, project.AddInput{Path: ""})
|
||||
wantCode(t, err, "PATH_REQUIRED")
|
||||
|
||||
_, err = m.Add(ctx, project.AddInput{Path: t.TempDir()}) // exists but not a git repo
|
||||
wantCode(t, err, "NOT_A_GIT_REPO")
|
||||
|
||||
repoA, repoB := gitRepo(t), gitRepo(t)
|
||||
if _, err := m.Add(ctx, project.AddInput{Path: repoA, ProjectID: ptr("shared")}); err != nil {
|
||||
t.Fatalf("seed add: %v", err)
|
||||
}
|
||||
_, err = m.Add(ctx, project.AddInput{Path: repoA, ProjectID: ptr("other")})
|
||||
wantCode(t, err, "PATH_ALREADY_REGISTERED")
|
||||
|
||||
_, err = m.Add(ctx, project.AddInput{Path: repoB, ProjectID: ptr("shared")})
|
||||
wantCode(t, err, "ID_ALREADY_REGISTERED")
|
||||
}
|
||||
|
||||
func TestManager_GetUpdateRemoveErrors(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
m := newManager(t)
|
||||
|
||||
_, err := m.Get(ctx, "nope")
|
||||
wantCode(t, err, "PROJECT_NOT_FOUND")
|
||||
|
||||
_, err = m.Get(ctx, domain.ProjectID("bad/id"))
|
||||
wantCode(t, err, "INVALID_PROJECT_ID")
|
||||
|
||||
_, err = m.Remove(ctx, "nope")
|
||||
wantCode(t, err, "PROJECT_NOT_FOUND")
|
||||
|
||||
repo := gitRepo(t)
|
||||
if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("p")}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package project
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
)
|
||||
|
||||
// Store is the durable project persistence surface required by Service.
|
||||
type Store interface {
|
||||
ListProjects(ctx context.Context) ([]domain.ProjectRecord, error)
|
||||
GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error)
|
||||
FindProjectByPath(ctx context.Context, path string) (domain.ProjectRecord, bool, error)
|
||||
UpsertProject(ctx context.Context, row domain.ProjectRecord) error
|
||||
ArchiveProject(ctx context.Context, id string, at time.Time) (bool, error)
|
||||
}
|
||||
|
|
@ -2,17 +2,7 @@ package project
|
|||
|
||||
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
|
||||
// Project entities and the behaviour-config shapes they expose. These live in
|
||||
// the project package (not domain/) because they are owned solely by the
|
||||
// projects surface — only project identity (domain.ProjectID) is shared
|
||||
// vocabulary with sessions/lifecycle/workspace, so that one type stays in
|
||||
// domain. Keeping the entities, the Manager interface (project.go), and the
|
||||
// transport DTOs (dto.go) together is the feature-package layout the backend
|
||||
// is migrating toward.
|
||||
|
||||
// Summary is the row shape returned by GET /api/v1/projects. ResolveError is
|
||||
// set only for degraded projects, so the list can show them with a warning
|
||||
// instead of dropping them silently.
|
||||
// Summary is the row shape returned by GET /api/v1/projects.
|
||||
type Summary struct {
|
||||
ID domain.ProjectID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
|
@ -20,24 +10,19 @@ type Summary struct {
|
|||
ResolveError string `json:"resolveError,omitempty"`
|
||||
}
|
||||
|
||||
// Project is the full read-model returned by GET /api/v1/projects/{id} when the
|
||||
// project resolves cleanly. It joins the registry identity fields with the
|
||||
// project's behaviour config.
|
||||
// Project is the full read-model returned by GET /api/v1/projects/{id}.
|
||||
type Project struct {
|
||||
ID domain.ProjectID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Repo string `json:"repo"` // "owner/name" or ""
|
||||
Repo string `json:"repo"`
|
||||
DefaultBranch string `json:"defaultBranch"`
|
||||
Agent string `json:"agent,omitempty"`
|
||||
Tracker *TrackerConfig `json:"tracker,omitempty"`
|
||||
SCM *SCMConfig `json:"scm,omitempty"`
|
||||
}
|
||||
|
||||
// Degraded is returned in place of Project when the project's config failed to
|
||||
// load. The frontend uses ResolveError to render a recovery UI; the
|
||||
// /projects/{id}/repair endpoint fixes a recoverable subset (e.g. legacy
|
||||
// wrapped-config format).
|
||||
// Degraded is returned in place of Project when project config failed to load.
|
||||
type Degraded struct {
|
||||
ID domain.ProjectID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
|
@ -45,18 +30,14 @@ type Degraded struct {
|
|||
ResolveError string `json:"resolveError"`
|
||||
}
|
||||
|
||||
// Behaviour-config shapes exposed by the projects API. Runtime selection and
|
||||
// reaction rules are intentionally absent: the daemon has one runtime adapter and
|
||||
// lifecycle owns agent nudges.
|
||||
|
||||
// TrackerConfig mirrors TrackerConfigSchema.
|
||||
// TrackerConfig mirrors tracker behaviour config exposed by the projects API.
|
||||
type TrackerConfig struct {
|
||||
Plugin string `json:"plugin,omitempty"`
|
||||
Package string `json:"package,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// SCMConfig mirrors SCMConfigSchema; Webhook nests its own optional block.
|
||||
// SCMConfig mirrors SCM behaviour config exposed by the projects API.
|
||||
type SCMConfig struct {
|
||||
Plugin string `json:"plugin,omitempty"`
|
||||
Package string `json:"package,omitempty"`
|
||||
|
|
@ -64,7 +45,7 @@ type SCMConfig struct {
|
|||
Webhook *SCMWebhookConfig `json:"webhook,omitempty"`
|
||||
}
|
||||
|
||||
// SCMWebhookConfig — pointer Enabled distinguishes unset from explicit false.
|
||||
// SCMWebhookConfig describes SCM webhook settings.
|
||||
type SCMWebhookConfig struct {
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package service
|
||||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -9,37 +9,37 @@ import (
|
|||
sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager"
|
||||
)
|
||||
|
||||
// SessionStore is the read-only persistence surface needed to assemble controller-facing session read models.
|
||||
type SessionStore interface {
|
||||
// Store is the read-only persistence surface needed to assemble controller-facing session read models.
|
||||
type Store interface {
|
||||
GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error)
|
||||
ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error)
|
||||
ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error)
|
||||
GetDisplayPRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, bool, error)
|
||||
}
|
||||
|
||||
// SessionListFilter captures API-facing session list query filters.
|
||||
type SessionListFilter struct {
|
||||
// ListFilter captures API-facing session list query filters.
|
||||
type ListFilter struct {
|
||||
ProjectID domain.ProjectID
|
||||
Active *bool
|
||||
OrchestratorOnly bool
|
||||
Fresh bool
|
||||
}
|
||||
|
||||
// Session is the controller-facing session service. It delegates command-side
|
||||
// Service is the controller-facing session service. It delegates command-side
|
||||
// session operations to the internal sessionmanager.Manager and owns read-model
|
||||
// assembly, including user-facing display status derivation.
|
||||
type Session struct {
|
||||
type Service struct {
|
||||
manager *sessionmanager.Manager
|
||||
store SessionStore
|
||||
store Store
|
||||
}
|
||||
|
||||
// NewSession wires a controller-facing session service over an internal session Manager.
|
||||
func NewSession(manager *sessionmanager.Manager, store SessionStore) *Session {
|
||||
return &Session{manager: manager, store: store}
|
||||
// New wires a controller-facing session service over an internal session Manager.
|
||||
func New(manager *sessionmanager.Manager, store Store) *Service {
|
||||
return &Service{manager: manager, store: store}
|
||||
}
|
||||
|
||||
// Spawn creates a session and returns the API-facing read model.
|
||||
func (s *Session) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) {
|
||||
func (s *Service) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) {
|
||||
rec, err := s.manager.Spawn(ctx, cfg)
|
||||
if err != nil {
|
||||
return domain.Session{}, err
|
||||
|
|
@ -48,7 +48,7 @@ func (s *Session) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
|
|||
}
|
||||
|
||||
// Restore relaunches a terminated session and returns the API-facing read model.
|
||||
func (s *Session) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) {
|
||||
func (s *Service) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) {
|
||||
rec, err := s.manager.Restore(ctx, id)
|
||||
if err != nil {
|
||||
return domain.Session{}, err
|
||||
|
|
@ -57,22 +57,22 @@ func (s *Session) Restore(ctx context.Context, id domain.SessionID) (domain.Sess
|
|||
}
|
||||
|
||||
// Kill delegates terminal intent and teardown to the internal manager.
|
||||
func (s *Session) Kill(ctx context.Context, id domain.SessionID) (bool, error) {
|
||||
func (s *Service) Kill(ctx context.Context, id domain.SessionID) (bool, error) {
|
||||
return s.manager.Kill(ctx, id)
|
||||
}
|
||||
|
||||
// Send delegates agent messaging to the internal manager.
|
||||
func (s *Session) Send(ctx context.Context, id domain.SessionID, message string) error {
|
||||
func (s *Service) Send(ctx context.Context, id domain.SessionID, message string) error {
|
||||
return s.manager.Send(ctx, id, message)
|
||||
}
|
||||
|
||||
// Cleanup delegates terminal workspace cleanup to the internal manager.
|
||||
func (s *Session) Cleanup(ctx context.Context, project domain.ProjectID) ([]domain.SessionID, error) {
|
||||
func (s *Service) Cleanup(ctx context.Context, project domain.ProjectID) ([]domain.SessionID, error) {
|
||||
return s.manager.Cleanup(ctx, project)
|
||||
}
|
||||
|
||||
// List returns sessions as enriched display models after applying API filters.
|
||||
func (s *Session) List(ctx context.Context, filter SessionListFilter) ([]domain.Session, error) {
|
||||
func (s *Service) List(ctx context.Context, filter ListFilter) ([]domain.Session, error) {
|
||||
recs, err := s.listRecords(ctx, filter.ProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -91,7 +91,7 @@ func (s *Session) List(ctx context.Context, filter SessionListFilter) ([]domain.
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Session) listRecords(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) {
|
||||
func (s *Service) listRecords(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) {
|
||||
if project == "" {
|
||||
recs, err := s.store.ListAllSessions(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -106,7 +106,7 @@ func (s *Session) listRecords(ctx context.Context, project domain.ProjectID) ([]
|
|||
return recs, nil
|
||||
}
|
||||
|
||||
func matchesSessionFilter(rec domain.SessionRecord, filter SessionListFilter) bool {
|
||||
func matchesSessionFilter(rec domain.SessionRecord, filter ListFilter) bool {
|
||||
if filter.Active != nil && rec.IsTerminated == *filter.Active {
|
||||
return false
|
||||
}
|
||||
|
|
@ -120,7 +120,7 @@ func matchesSessionFilter(rec domain.SessionRecord, filter SessionListFilter) bo
|
|||
}
|
||||
|
||||
// Get returns one session as an enriched display model, or sessionmanager.ErrNotFound if it is absent.
|
||||
func (s *Session) Get(ctx context.Context, id domain.SessionID) (domain.Session, error) {
|
||||
func (s *Service) Get(ctx context.Context, id domain.SessionID) (domain.Session, error) {
|
||||
rec, ok, err := s.store.GetSession(ctx, id)
|
||||
if err != nil {
|
||||
return domain.Session{}, fmt.Errorf("get %s: %w", id, err)
|
||||
|
|
@ -131,7 +131,7 @@ func (s *Session) Get(ctx context.Context, id domain.SessionID) (domain.Session,
|
|||
return s.toSession(ctx, rec)
|
||||
}
|
||||
|
||||
func (s *Session) toSession(ctx context.Context, rec domain.SessionRecord) (domain.Session, error) {
|
||||
func (s *Service) toSession(ctx context.Context, rec domain.SessionRecord) (domain.Session, error) {
|
||||
pr, ok, err := s.store.GetDisplayPRFactsForSession(ctx, rec.ID)
|
||||
if err != nil {
|
||||
return domain.Session{}, fmt.Errorf("pr facts %s: %w", rec.ID, err)
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package service
|
||||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -8,29 +8,29 @@ import (
|
|||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
)
|
||||
|
||||
type fakeSessionStore struct {
|
||||
type fakeStore struct {
|
||||
sessions map[domain.SessionID]domain.SessionRecord
|
||||
pr map[domain.SessionID]domain.PRFacts
|
||||
num int
|
||||
}
|
||||
|
||||
func newFakeSessionStore() *fakeSessionStore {
|
||||
return &fakeSessionStore{sessions: map[domain.SessionID]domain.SessionRecord{}, pr: map[domain.SessionID]domain.PRFacts{}}
|
||||
func newFakeStore() *fakeStore {
|
||||
return &fakeStore{sessions: map[domain.SessionID]domain.SessionRecord{}, pr: map[domain.SessionID]domain.PRFacts{}}
|
||||
}
|
||||
|
||||
func (f *fakeSessionStore) CreateSession(_ context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) {
|
||||
func (f *fakeStore) CreateSession(_ context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) {
|
||||
f.num++
|
||||
rec.ID = domain.SessionID(fmt.Sprintf("%s-%d", rec.ProjectID, f.num))
|
||||
f.sessions[rec.ID] = rec
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func (f *fakeSessionStore) GetSession(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) {
|
||||
func (f *fakeStore) GetSession(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) {
|
||||
r, ok := f.sessions[id]
|
||||
return r, ok, nil
|
||||
}
|
||||
|
||||
func (f *fakeSessionStore) ListSessions(_ context.Context, p domain.ProjectID) ([]domain.SessionRecord, error) {
|
||||
func (f *fakeStore) ListSessions(_ context.Context, p domain.ProjectID) ([]domain.SessionRecord, error) {
|
||||
var out []domain.SessionRecord
|
||||
for _, r := range f.sessions {
|
||||
if r.ProjectID == p {
|
||||
|
|
@ -40,7 +40,7 @@ func (f *fakeSessionStore) ListSessions(_ context.Context, p domain.ProjectID) (
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeSessionStore) ListAllSessions(_ context.Context) ([]domain.SessionRecord, error) {
|
||||
func (f *fakeStore) ListAllSessions(_ context.Context) ([]domain.SessionRecord, error) {
|
||||
out := make([]domain.SessionRecord, 0, len(f.sessions))
|
||||
for _, r := range f.sessions {
|
||||
out = append(out, r)
|
||||
|
|
@ -48,17 +48,17 @@ func (f *fakeSessionStore) ListAllSessions(_ context.Context) ([]domain.SessionR
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeSessionStore) GetDisplayPRFactsForSession(_ context.Context, id domain.SessionID) (domain.PRFacts, bool, error) {
|
||||
func (f *fakeStore) GetDisplayPRFactsForSession(_ context.Context, id domain.SessionID) (domain.PRFacts, bool, error) {
|
||||
pr, ok := f.pr[id]
|
||||
return pr, ok, nil
|
||||
}
|
||||
|
||||
func TestSessionListDerivesStatusFromPRFacts(t *testing.T) {
|
||||
st := newFakeSessionStore()
|
||||
st := newFakeStore()
|
||||
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Activity: domain.Activity{State: domain.ActivityActive}}
|
||||
st.pr["mer-1"] = domain.PRFacts{URL: "pr1", CI: domain.CIFailing}
|
||||
|
||||
list, err := (&Session{store: st}).List(context.Background(), SessionListFilter{ProjectID: "mer"})
|
||||
list, err := (&Service{store: st}).List(context.Background(), ListFilter{ProjectID: "mer"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package service
|
||||
package session
|
||||
|
||||
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package service
|
||||
package session
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
|
@ -32,7 +32,7 @@ func (q *Queries) ArchiveProject(ctx context.Context, arg ArchiveProjectParams)
|
|||
|
||||
const findProjectByPath = `-- name: FindProjectByPath :one
|
||||
SELECT id, path, repo_origin_url, display_name, registered_at, archived_at
|
||||
FROM projects WHERE path = ?
|
||||
FROM projects WHERE path = ? AND archived_at IS NULL
|
||||
`
|
||||
|
||||
func (q *Queries) FindProjectByPath(ctx context.Context, path string) (Project, error) {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ FROM projects WHERE archived_at IS NULL ORDER BY id;
|
|||
|
||||
-- name: FindProjectByPath :one
|
||||
SELECT id, path, repo_origin_url, display_name, registered_at, archived_at
|
||||
FROM projects WHERE path = ?;
|
||||
FROM projects WHERE path = ? AND archived_at IS NULL;
|
||||
|
||||
-- name: ArchiveProject :execrows
|
||||
UPDATE projects SET archived_at = ? WHERE id = ?;
|
||||
UPDATE projects SET archived_at = ? WHERE id = ? AND archived_at IS NULL;
|
||||
|
|
|
|||
|
|
@ -8,14 +8,11 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/project"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen"
|
||||
)
|
||||
|
||||
var _ project.Store = (*Store)(nil)
|
||||
|
||||
// Upsert inserts or replaces a registered project row.
|
||||
func (s *Store) Upsert(ctx context.Context, r project.Row) error {
|
||||
// UpsertProject inserts or replaces a registered project row.
|
||||
func (s *Store) UpsertProject(ctx context.Context, r domain.ProjectRecord) error {
|
||||
s.writeMu.Lock()
|
||||
defer s.writeMu.Unlock()
|
||||
return s.qw.UpsertProject(ctx, gen.UpsertProjectParams{
|
||||
|
|
@ -28,45 +25,45 @@ func (s *Store) Upsert(ctx context.Context, r project.Row) error {
|
|||
})
|
||||
}
|
||||
|
||||
// Get returns a project by id, active or archived.
|
||||
func (s *Store) Get(ctx context.Context, id string) (project.Row, bool, error) {
|
||||
// GetProject returns a project by id, active or archived.
|
||||
func (s *Store) GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error) {
|
||||
p, err := s.qr.GetProject(ctx, domain.ProjectID(id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return project.Row{}, false, nil
|
||||
return domain.ProjectRecord{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return project.Row{}, false, fmt.Errorf("get project %s: %w", id, err)
|
||||
return domain.ProjectRecord{}, false, fmt.Errorf("get project %s: %w", id, err)
|
||||
}
|
||||
return projectRowFromGen(p), true, nil
|
||||
}
|
||||
|
||||
// FindByPath returns a project registered at path, active or archived.
|
||||
func (s *Store) FindByPath(ctx context.Context, path string) (project.Row, bool, error) {
|
||||
// FindProjectByPath returns a project registered at path, active or archived.
|
||||
func (s *Store) FindProjectByPath(ctx context.Context, path string) (domain.ProjectRecord, bool, error) {
|
||||
p, err := s.qr.FindProjectByPath(ctx, path)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return project.Row{}, false, nil
|
||||
return domain.ProjectRecord{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return project.Row{}, false, fmt.Errorf("find project by path %s: %w", path, err)
|
||||
return domain.ProjectRecord{}, false, fmt.Errorf("find project by path %s: %w", path, err)
|
||||
}
|
||||
return projectRowFromGen(p), true, nil
|
||||
}
|
||||
|
||||
// List returns active projects ordered by id.
|
||||
func (s *Store) List(ctx context.Context) ([]project.Row, error) {
|
||||
// ListProjects returns active projects ordered by id.
|
||||
func (s *Store) ListProjects(ctx context.Context) ([]domain.ProjectRecord, error) {
|
||||
rows, err := s.qr.ListProjects(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list projects: %w", err)
|
||||
}
|
||||
out := make([]project.Row, 0, len(rows))
|
||||
out := make([]domain.ProjectRecord, 0, len(rows))
|
||||
for _, p := range rows {
|
||||
out = append(out, projectRowFromGen(p))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Archive soft-deletes a project and reports whether a row was affected.
|
||||
func (s *Store) Archive(ctx context.Context, id string, at time.Time) (bool, error) {
|
||||
// ArchiveProject soft-deletes a project and reports whether a row was affected.
|
||||
func (s *Store) ArchiveProject(ctx context.Context, id string, at time.Time) (bool, error) {
|
||||
s.writeMu.Lock()
|
||||
defer s.writeMu.Unlock()
|
||||
n, err := s.qw.ArchiveProject(ctx, gen.ArchiveProjectParams{
|
||||
|
|
@ -79,8 +76,8 @@ func (s *Store) Archive(ctx context.Context, id string, at time.Time) (bool, err
|
|||
return n > 0, nil
|
||||
}
|
||||
|
||||
func projectRowFromGen(p gen.Project) project.Row {
|
||||
r := project.Row{
|
||||
func projectRowFromGen(p gen.Project) domain.ProjectRecord {
|
||||
r := domain.ProjectRecord{
|
||||
ID: string(p.ID),
|
||||
Path: p.Path,
|
||||
RepoOriginURL: p.RepoOriginURL,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/project"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
|
|
@ -24,7 +23,7 @@ func newTestStore(t *testing.T) *sqlite.Store {
|
|||
|
||||
func seedProject(t *testing.T, s *sqlite.Store, id string) {
|
||||
t.Helper()
|
||||
if err := s.Upsert(context.Background(), project.Row{
|
||||
if err := s.UpsertProject(context.Background(), domain.ProjectRecord{
|
||||
ID: id, Path: "/tmp/" + id, RegisteredAt: time.Now().UTC().Truncate(time.Second),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed project %s: %v", id, err)
|
||||
|
|
@ -49,24 +48,24 @@ func TestProjectCRUDAndArchive(t *testing.T) {
|
|||
ctx := context.Background()
|
||||
seedProject(t, s, "mer")
|
||||
|
||||
got, ok, err := s.Get(ctx, "mer")
|
||||
got, ok, err := s.GetProject(ctx, "mer")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("get: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if got.ID != "mer" || got.Path != "/tmp/mer" {
|
||||
t.Fatalf("project = %+v", got)
|
||||
}
|
||||
if list, _ := s.List(ctx); len(list) != 1 {
|
||||
if list, _ := s.ListProjects(ctx); len(list) != 1 {
|
||||
t.Fatalf("active list = %d, want 1", len(list))
|
||||
}
|
||||
// archive hides from the active list but still resolves by id.
|
||||
if ok, err := s.Archive(ctx, "mer", time.Now().UTC()); err != nil || !ok {
|
||||
if ok, err := s.ArchiveProject(ctx, "mer", time.Now().UTC()); err != nil || !ok {
|
||||
t.Fatalf("archive: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if list, _ := s.List(ctx); len(list) != 0 {
|
||||
if list, _ := s.ListProjects(ctx); len(list) != 0 {
|
||||
t.Fatalf("after archive, active list = %d, want 0", len(list))
|
||||
}
|
||||
if _, ok, _ := s.Get(ctx, "mer"); !ok {
|
||||
if _, ok, _ := s.GetProject(ctx, "mer"); !ok {
|
||||
t.Fatal("archived project must still resolve by id")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue