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:
neversettle 2026-06-02 01:26:48 +05:30 committed by GitHub
parent 424e6e824b
commit 3a93e33331
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 2123 additions and 1236 deletions

View File

@ -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)
}
}

View File

@ -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

View File

@ -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=

View File

@ -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).

View File

@ -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{

View File

@ -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 {

View File

@ -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{

View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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)
}
}
}
}

View File

@ -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{}},
},
},
}
}

View File

@ -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")
}
}

View File

@ -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"`
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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}
}

View File

@ -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"`
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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"`
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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"`

View File

@ -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)

View File

@ -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)
}

View File

@ -1,4 +1,4 @@
package service
package session
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"

View File

@ -1,4 +1,4 @@
package service
package session
import (
"testing"

View File

@ -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) {

View File

@ -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;

View File

@ -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,

View File

@ -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")
}
}