From 0ed2ae477856fe81445b896eae452eadc64febd7 Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Mon, 1 Jun 2026 16:14:08 +0530 Subject: [PATCH 01/13] refactor(project): manager talks to the sqlite store; drop the in-memory store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../httpd/controllers/projects_test.go | 8 +- backend/internal/project/manager.go | 14 +- backend/internal/project/manager_test.go | 132 ++++++++++++++++++ backend/internal/project/memory_store.go | 117 ---------------- backend/internal/project/store.go | 32 +++++ 5 files changed, 174 insertions(+), 129 deletions(-) create mode 100644 backend/internal/project/manager_test.go delete mode 100644 backend/internal/project/memory_store.go create mode 100644 backend/internal/project/store.go diff --git a/backend/internal/httpd/controllers/projects_test.go b/backend/internal/httpd/controllers/projects_test.go index 8d303da5..0f192946 100644 --- a/backend/internal/httpd/controllers/projects_test.go +++ b/backend/internal/httpd/controllers/projects_test.go @@ -15,13 +15,19 @@ import ( "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/storage/sqlite" ) 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: project.NewMemoryManager(), + Projects: project.NewManager(store), })) t.Cleanup(srv.Close) return srv diff --git a/backend/internal/project/manager.go b/backend/internal/project/manager.go index 54c93b9a..11654d66 100644 --- a/backend/internal/project/manager.go +++ b/backend/internal/project/manager.go @@ -19,21 +19,13 @@ type manager struct { var _ Manager = (*manager)(nil) -// NewManager returns a project Manager backed by the given Store, defaulting to -// an in-memory store when store is nil. +// NewManager returns a project Manager backed by the given Store — the durable +// sqlite store in the daemon, a real temp-dir sqlite store in tests. store must +// be non-nil; there is no in-memory fallback. func NewManager(store Store) Manager { - if store == nil { - store = NewMemoryStore() - } return &manager{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) if err != nil { diff --git a/backend/internal/project/manager_test.go b/backend/internal/project/manager_test.go new file mode 100644 index 00000000..0c2fa1cf --- /dev/null +++ b/backend/internal/project/manager_test.go @@ -0,0 +1,132 @@ +package project_test + +import ( + "context" + "errors" + "os/exec" + "testing" + + "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" +) + +// 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.NewManager(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.Skipf("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)) + } +} + +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) + } + _, err = m.UpdateConfig(ctx, "p", project.UpdateConfigInput{}) + wantCode(t, err, "PROJECT_CONFIG_NOT_IMPLEMENTED") +} diff --git a/backend/internal/project/memory_store.go b/backend/internal/project/memory_store.go deleted file mode 100644 index c9f91a2a..00000000 --- a/backend/internal/project/memory_store.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/project/store.go b/backend/internal/project/store.go new file mode 100644 index 00000000..015cf08f --- /dev/null +++ b/backend/internal/project/store.go @@ -0,0 +1,32 @@ +package project + +import ( + "context" + "time" +) + +// Row mirrors the project table shape from the sqlite storage layer. The manager +// consumes rows through the Store port below; the sqlite store returns this same +// shape, so the API layer never depends on a richer model than the DB provides. +type Row struct { + ID string + Path string + RepoOriginURL string + DisplayName string + RegisteredAt time.Time + ArchivedAt time.Time +} + +// Store is the project persistence port the manager talks to. It exists to +// invert the dependency: the storage layer imports this package (for Row), so +// the manager reaches the backend through this interface rather than importing +// the concrete *sqlite.Store — which would create an import cycle +// (project → sqlite → store → project). The real *sqlite.Store satisfies it; +// tests pass a real temp-dir sqlite store. There is no in-memory implementation. +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) +} From 36b822dc9208bd48d205450b5e1e5b34166aad11 Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Mon, 1 Jun 2026 17:48:32 +0530 Subject: [PATCH 02/13] refactor(project): trim routes, consolidate package, add code-first OpenAPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/cmd/genspec/main.go | 26 + backend/go.mod | 4 + backend/go.sum | 22 + backend/internal/httpd/apispec/apispec.go | 10 +- backend/internal/httpd/apispec/gen.go | 6 + backend/internal/httpd/apispec/openapi.yaml | 826 +++++------------- backend/internal/httpd/apispec/parity_test.go | 66 ++ .../internal/httpd/apispec/specgen/build.go | 242 +++++ .../httpd/apispec/specgen/build_test.go | 40 + backend/internal/httpd/controllers/dto.go | 91 ++ .../internal/httpd/controllers/projects.go | 93 +- .../httpd/controllers/projects_test.go | 54 +- backend/internal/project/dto.go | 17 - backend/internal/project/errors.go | 4 - backend/internal/project/manager.go | 55 +- backend/internal/project/project.go | 41 - 16 files changed, 771 insertions(+), 826 deletions(-) create mode 100644 backend/cmd/genspec/main.go create mode 100644 backend/internal/httpd/apispec/gen.go create mode 100644 backend/internal/httpd/apispec/parity_test.go create mode 100644 backend/internal/httpd/apispec/specgen/build.go create mode 100644 backend/internal/httpd/apispec/specgen/build_test.go create mode 100644 backend/internal/httpd/controllers/dto.go delete mode 100644 backend/internal/project/project.go diff --git a/backend/cmd/genspec/main.go b/backend/cmd/genspec/main.go new file mode 100644 index 00000000..67f1eb22 --- /dev/null +++ b/backend/cmd/genspec/main.go @@ -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, 0o644); err != nil { + log.Fatalf("genspec: write %s: %v", *out, err) + } +} diff --git a/backend/go.mod b/backend/go.mod index a2de66a0..70689d85 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index cf3f0029..718a4a11 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/httpd/apispec/apispec.go b/backend/internal/httpd/apispec/apispec.go index 2603820f..97195853 100644 --- a/backend/internal/httpd/apispec/apispec.go +++ b/backend/internal/httpd/apispec/apispec.go @@ -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 diff --git a/backend/internal/httpd/apispec/gen.go b/backend/internal/httpd/apispec/gen.go new file mode 100644 index 00000000..cd895850 --- /dev/null +++ b/backend/internal/httpd/apispec/gen.go @@ -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 diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index b626c578..9c758399 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -1,721 +1,313 @@ openapi: 3.1.0 info: + description: Loopback-only HTTP surface served by the Go daemon. Generated from + Go (code-first) — do not edit by hand; run `go generate ./...`. title: Agent Orchestrator HTTP daemon - version: 0.1.0 - description: | - Loopback-only HTTP surface served by the Go daemon. This document describes - the registered /api/v1 project routes and the shared error envelope used by - OpenAPI-backed 501 responses. Daemon control endpoints such as /healthz, - /readyz, /shutdown, and /mux are intentionally outside this REST spec. - + version: 0.1.0-route-shell servers: - - url: http://127.0.0.1:3001 - description: Local daemon (loopback only) - -tags: - - name: projects - description: Project registry, configuration, and lifecycle administration - - name: sessions - description: Agent session lifecycle and messaging - +- description: Local daemon (loopback only) + url: http://127.0.0.1:3001 paths: /api/v1/projects: get: operationId: listProjects - tags: [projects] - summary: List active registered projects responses: "200": - description: Projects listed content: application/json: schema: - type: object - required: [projects] - properties: - projects: - type: array - items: { $ref: "#/components/schemas/ProjectSummary" } + $ref: '#/components/schemas/ListProjectsResponse' + description: OK "500": - description: Failed to load projects content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: internal, code: PROJECTS_LIST_FAILED, message: "Failed to load projects" } - "501": { $ref: "#/components/responses/NotImplemented" } - + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: List all registered projects (active + degraded) + tags: + - projects post: operationId: addProject - tags: [projects] - summary: Register a new project from a git repository path requestBody: - required: true content: application/json: - schema: { $ref: "#/components/schemas/AddProjectRequest" } + schema: + $ref: '#/components/schemas/AddInput' + required: true responses: "201": - description: Project registered content: application/json: schema: - type: object - required: [project] - properties: - project: { $ref: "#/components/schemas/Project" } + $ref: '#/components/schemas/ProjectResponse' + description: Created "400": - description: Bad request content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - examples: - invalidJson: { value: { error: bad_request, code: INVALID_JSON, message: "Invalid JSON body" } } - pathRequired: { value: { error: bad_request, code: PATH_REQUIRED, message: "Repository path is required" } } - notAGitRepo: { value: { error: bad_request, code: NOT_A_GIT_REPO, message: "Repository path must point to a git repository" } } + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request "409": - description: Conflict with an already-registered project - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - examples: - pathAlready: - value: - error: conflict - code: PATH_ALREADY_REGISTERED - message: "A project at this path is already registered" - details: - existingProjectId: existing-project-id - suggestedProjectId: suggested-project-id - idAlready: - value: - error: conflict - code: ID_ALREADY_REGISTERED - message: "A project with this id is already registered for a different path" - details: - existingProjectId: existing-project-id - suggestedProjectId: suggested-project-id - "501": { $ref: "#/components/responses/NotImplemented" } - - /api/v1/projects/reload: - post: - operationId: reloadProjects - tags: [projects] - summary: Invalidate cached config and re-scan the global registry - responses: - "200": - description: Reload complete content: application/json: - schema: { $ref: "#/components/schemas/ReloadResult" } + schema: + $ref: '#/components/schemas/APIError' + description: Conflict "500": - description: Reload failed content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: internal, code: RELOAD_FAILED, message: "Failed to reload projects" } - "501": { $ref: "#/components/responses/NotImplemented" } - + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Register a new project from a git repository path + tags: + - projects /api/v1/projects/{id}: - parameters: - - $ref: "#/components/parameters/ProjectIDPath" - get: - operationId: getProject - tags: [projects] - summary: Fetch one project; discriminates ok vs degraded - responses: - "200": - description: Project resolved (status discriminates ok vs degraded) - content: - application/json: - schema: { $ref: "#/components/schemas/ProjectGetResponse" } - "404": { $ref: "#/components/responses/ProjectNotFound" } - "500": - description: Failed to load project - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: internal, code: PROJECT_LOAD_FAILED, message: "Failed to load project" } - "501": { $ref: "#/components/responses/NotImplemented" } - patch: - operationId: updateProjectConfig - tags: [projects] - summary: Patch behaviour-only fields (not implemented until config persistence lands) - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/UpdateProjectConfigRequest" } - responses: - "400": - description: Bad request - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - examples: - invalidJson: { value: { error: bad_request, code: INVALID_JSON, message: "Invalid JSON body" } } - identityFrozen: - value: - error: bad_request - code: IDENTITY_FROZEN - message: "Identity fields cannot be patched" - details: { fields: [projectId, path, repo, defaultBranch] } - invalidConfig: { value: { error: bad_request, code: INVALID_LOCAL_CONFIG, message: "Local project config failed validation" } } - "404": { $ref: "#/components/responses/ProjectNotFound" } - "409": - description: Project not in a patchable state - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - examples: - degraded: { value: { error: conflict, code: PROJECT_DEGRADED, message: "Project config is degraded; repair before patching" } } - missingPath: { value: { error: conflict, code: PROJECT_MISSING_PATH, message: "Project registry entry is missing a path" } } - "501": - description: Behaviour config persistence is not wired yet - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: not_implemented, code: PROJECT_CONFIG_NOT_IMPLEMENTED, message: "Project config patching is not available until config persistence is wired" } delete: operationId: removeProject - tags: [projects] - summary: Archive a project; hides it from active lists while preserving id references - responses: - "200": - description: Project archived - content: - application/json: - schema: { $ref: "#/components/schemas/RemoveProjectResult" } - "400": - description: Invalid project id - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: bad_request, code: INVALID_PROJECT_ID, message: "Project id failed storage-path validation" } - "404": { $ref: "#/components/responses/ProjectNotFound" } - "500": - description: Removal failed - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: internal, code: PROJECT_REMOVE_FAILED, message: "Failed to remove project" } - "501": { $ref: "#/components/responses/NotImplemented" } - - /api/v1/projects/{id}/repair: - parameters: - - $ref: "#/components/parameters/ProjectIDPath" - post: - operationId: repairProject - tags: [projects] - summary: Repair a degraded project where automatic recovery is available - x-replaces: - - "POST /api/v1/projects/{id}" - responses: - "200": - description: Project repaired - content: - application/json: - schema: - type: object - required: [project] - properties: - project: { $ref: "#/components/schemas/Project" } - "400": - description: Bad request - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - examples: - notDegraded: { value: { error: bad_request, code: PROJECT_NOT_DEGRADED, message: "Project does not need repair" } } - notAvailable: { value: { error: bad_request, code: REPAIR_NOT_AVAILABLE, message: "Automatic repair is not available for this degraded config" } } - "404": { $ref: "#/components/responses/ProjectNotFound" } - "501": { $ref: "#/components/responses/NotImplemented" } - - /api/v1/sessions: - get: - operationId: listSessions - tags: [sessions] - summary: List sessions parameters: - - name: project - in: query - schema: { type: string } - - name: active - in: query - schema: { type: boolean } - - name: orchestratorOnly - in: query - schema: { type: boolean } - - name: fresh - in: query - schema: { type: boolean } + - description: Project identifier (registry key). + in: path + name: id + required: true + schema: + description: Project identifier (registry key). + type: string responses: "200": - description: Sessions listed content: application/json: schema: - type: object - required: [sessions] - properties: - sessions: - type: array - items: { $ref: "#/components/schemas/Session" } + $ref: '#/components/schemas/RemoveResult' + description: OK "400": - description: Bad request - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - "501": { $ref: "#/components/responses/NotImplemented" } - post: - operationId: spawnSession - tags: [sessions] - summary: Spawn a new agent session - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/SpawnSessionRequest" } - responses: - "201": - description: Session spawned content: application/json: schema: - type: object - required: [session] - properties: - session: { $ref: "#/components/schemas/Session" } - "400": - description: Bad request + $ref: '#/components/schemas/APIError' + description: Bad Request + "404": content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - examples: - invalidJson: { value: { error: bad_request, code: INVALID_JSON, message: "Invalid JSON body" } } - projectRequired: { value: { error: bad_request, code: PROJECT_ID_REQUIRED, message: "projectId is required" } } - promptTooLong: { value: { error: bad_request, code: PROMPT_TOO_LONG, message: "prompt is too long" } } - "404": { $ref: "#/components/responses/ProjectNotFound" } - "500": { $ref: "#/components/responses/SessionOperationFailed" } - "501": { $ref: "#/components/responses/NotImplemented" } - - /api/v1/sessions/{sessionId}: - parameters: - - $ref: "#/components/parameters/SessionIDPath" - get: - operationId: getSession - tags: [sessions] - summary: Fetch one session - responses: - "200": - description: Session fetched + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "500": content: application/json: schema: - type: object - required: [session] - properties: - session: { $ref: "#/components/schemas/Session" } - "404": { $ref: "#/components/responses/SessionNotFound" } - "501": { $ref: "#/components/responses/NotImplemented" } - patch: - operationId: renameSession - tags: [sessions] - summary: Rename a session display name - requestBody: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Remove a project; stops sessions, cleans workspaces, unregisters + tags: + - projects + get: + operationId: getProject + parameters: + - description: Project identifier (registry key). + in: path + name: id required: true - content: - application/json: - schema: - type: object - required: [displayName] - properties: - displayName: { type: string, minLength: 1 } + schema: + description: Project identifier (registry key). + type: string responses: "200": - description: Session renamed content: application/json: schema: - type: object - required: [session] - properties: - session: { $ref: "#/components/schemas/Session" } - "400": - description: Bad request - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - "404": { $ref: "#/components/responses/SessionNotFound" } - "501": { $ref: "#/components/responses/NotImplemented" } - - /api/v1/sessions/{sessionId}/restore: - parameters: - - $ref: "#/components/parameters/SessionIDPath" - post: - operationId: restoreSession - tags: [sessions] - summary: Restore a terminated session - responses: - "200": - description: Session restored + $ref: '#/components/schemas/ProjectGetResponse' + description: OK + "404": content: application/json: schema: - type: object - required: [ok, sessionId, session] - properties: - ok: { type: boolean } - sessionId: { type: string } - session: { $ref: "#/components/schemas/Session" } - "404": { $ref: "#/components/responses/SessionNotFound" } - "409": { $ref: "#/components/responses/SessionConflict" } - "501": { $ref: "#/components/responses/NotImplemented" } - - /api/v1/sessions/{sessionId}/kill: - parameters: - - $ref: "#/components/parameters/SessionIDPath" - post: - operationId: killSession - tags: [sessions] - summary: Mark a session terminated and tear down runtime/workspace resources - responses: - "200": - description: Kill attempted - content: - application/json: - schema: { $ref: "#/components/schemas/KillSessionResponse" } - "404": { $ref: "#/components/responses/SessionNotFound" } - "409": { $ref: "#/components/responses/SessionConflict" } - "501": { $ref: "#/components/responses/NotImplemented" } - - /api/v1/sessions/{sessionId}/send: - parameters: - - $ref: "#/components/parameters/SessionIDPath" - post: - operationId: sendSessionMessage - tags: [sessions] - summary: Send a message to a running session's agent - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/SendSessionMessageRequest" } - responses: - "200": - description: Message accepted - content: - application/json: - schema: { $ref: "#/components/schemas/SendSessionMessageResponse" } - "400": - description: Bad request - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - examples: - invalidJson: { value: { error: bad_request, code: INVALID_JSON, message: "Invalid JSON body" } } - messageRequired: { value: { error: bad_request, code: MESSAGE_REQUIRED, message: "Message is required" } } - "404": { $ref: "#/components/responses/SessionNotFound" } - "500": { $ref: "#/components/responses/SessionOperationFailed" } - "501": { $ref: "#/components/responses/NotImplemented" } - - /api/v1/orchestrators: - post: - operationId: spawnOrchestrator - tags: [sessions] - summary: Spawn an orchestrator session - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/SpawnOrchestratorRequest" } - responses: - "201": - description: Orchestrator spawned - content: - application/json: - schema: { $ref: "#/components/schemas/SpawnOrchestratorResponse" } - "400": - description: Bad request + $ref: '#/components/schemas/APIError' + description: Not Found + "500": content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - "404": { $ref: "#/components/responses/ProjectNotFound" } - "500": { $ref: "#/components/responses/SessionOperationFailed" } - "501": { $ref: "#/components/responses/NotImplemented" } - + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Fetch one project; discriminates ok vs degraded + tags: + - projects components: - parameters: - ProjectIDPath: - name: id - in: path - required: true - schema: { type: string, minLength: 1 } - description: Project identifier (registry key). - - SessionIDPath: - name: sessionId - in: path - required: true - schema: { type: string, minLength: 1 } - description: Session identifier, e.g. project-1. - - responses: - NotImplemented: - description: | - Route is registered but the handler has not been implemented yet. - The body carries the locked APIError envelope plus a `spec` field - containing this operation's slice of the OpenAPI document so - callers can discover the contract from the endpoint itself. - content: - application/json: - schema: { $ref: "#/components/schemas/NotImplementedResponse" } - - ProjectNotFound: - description: Project not found - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: not_found, code: PROJECT_NOT_FOUND, message: "Unknown project" } - - SessionNotFound: - description: Session not found - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: not_found, code: SESSION_NOT_FOUND, message: "Unknown session" } - - SessionConflict: - description: Session is not in a valid state for the requested operation - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - examples: - notRestorable: { value: { error: conflict, code: SESSION_NOT_RESTORABLE, message: "Session is not restorable" } } - incompleteHandle: { value: { error: conflict, code: SESSION_INCOMPLETE_HANDLE, message: "Session is missing runtime or workspace handles" } } - - SessionOperationFailed: - description: Session operation failed - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: internal, code: SESSION_OPERATION_FAILED, message: "Session operation failed" } - schemas: APIError: - type: object - required: [error, code, message] properties: - error: { type: string, description: "Short kind, e.g. not_found" } - code: { type: string, description: "SCREAMING_SNAKE machine code" } - message: { type: string, description: "Human-readable detail" } - requestId: { type: string } + code: + type: string details: + additionalProperties: {} type: object - additionalProperties: true - - NotImplementedResponse: - allOf: - - $ref: "#/components/schemas/APIError" - - type: object - required: [spec] - properties: - spec: - type: object - description: | - The OpenAPI Operation object for this method+path, served - inline so consumers discover the contract from the 501 - response without fetching the full spec. Mirrors the YAML - shape — see /api/v1/openapi.yaml for the full document. - - ProjectSummary: - type: object - required: [id, name, sessionPrefix] - properties: - id: { type: string } - name: { type: string } - sessionPrefix: { type: string } - resolveError: + error: type: string - description: Present iff the project is degraded. - - Project: - type: object - required: [id, name, path, repo, defaultBranch] - properties: - id: { type: string } - name: { type: string } - path: { type: string } - repo: + message: type: string - description: "\"owner/name\" or empty string when unset" - defaultBranch: { type: string, default: main } - agent: { type: string } - tracker: { $ref: "#/components/schemas/TrackerConfig" } - scm: { $ref: "#/components/schemas/SCMConfig" } - - DegradedProject: - type: object - required: [id, name, path, resolveError] - properties: - id: { type: string } - name: { type: string } - path: { type: string } - resolveError: { type: string } - - ProjectGetResponse: - type: object - required: [status, project] - properties: - status: + requestId: type: string - enum: [ok, degraded] - project: - oneOf: - - $ref: "#/components/schemas/Project" - - $ref: "#/components/schemas/DegradedProject" - AddProjectRequest: + required: + - error + - code + - message type: object - required: [path] + AddInput: properties: + name: + type: + - "null" + - string path: type: string - description: Repository path; supports ~ home-expansion. Must be a git repo. projectId: + type: + - "null" + - string + required: + - path + type: object + Degraded: + properties: + id: type: string - description: Optional override; defaults to basename(path). name: type: string - description: Optional display name; defaults to projectId. - - UpdateProjectConfigRequest: - type: object - description: | - Behaviour-only patch. Identity fields (projectId, path, repo, - defaultBranch) are rejected with 400 IDENTITY_FROZEN. The current Go - handler returns 501 PROJECT_CONFIG_NOT_IMPLEMENTED until config - persistence exists. - properties: - agent: { type: string } - tracker: { $ref: "#/components/schemas/TrackerConfig" } - scm: { $ref: "#/components/schemas/SCMConfig" } - - RemoveProjectResult: + path: + type: string + resolveError: + type: string + required: + - id + - name + - path + - resolveError type: object - required: [projectId, removedStorageDir] + ListProjectsResponse: properties: - projectId: { type: string } - removedStorageDir: { type: boolean } - - ReloadResult: + projects: + items: + $ref: '#/components/schemas/Summary' + type: array + required: + - projects type: object - required: [reloaded, projectCount, degradedCount] + Project: properties: - reloaded: { type: boolean } - projectCount: { type: integer } - degradedCount: { type: integer } - - - Session: + agent: + type: string + defaultBranch: + type: string + id: + type: string + name: + type: string + path: + type: string + repo: + type: string + scm: + $ref: '#/components/schemas/SCMConfig' + tracker: + $ref: '#/components/schemas/TrackerConfig' + required: + - id + - name + - path + - repo + - defaultBranch type: object - required: [id, projectId, kind, activity, isTerminated, createdAt, updatedAt, status] + ProjectGetResponse: properties: - id: { type: string } - projectId: { type: string } - issueId: { type: string } - kind: { type: string, enum: [worker, orchestrator] } - harness: { type: string, enum: ["", claude-code, codex, aider, opencode] } - activity: { $ref: "#/components/schemas/SessionActivity" } - isTerminated: { type: boolean } - createdAt: { type: string, format: date-time } - updatedAt: { type: string, format: date-time } + project: + $ref: '#/components/schemas/ProjectOrDegraded' status: + enum: + - ok + - degraded type: string - enum: [working, pr_open, draft, ci_failed, review_pending, changes_requested, approved, mergeable, merged, needs_input, idle, terminated] - - SessionActivity: + required: + - status + - project type: object - required: [state, lastActivityAt] - properties: - state: { type: string, enum: [active, idle, waiting_input, exited] } - lastActivityAt: { type: string, format: date-time } - - SpawnSessionRequest: + ProjectOrDegraded: + oneOf: + - $ref: '#/components/schemas/Project' + - $ref: '#/components/schemas/Degraded' type: object - required: [projectId] + ProjectResponse: properties: - projectId: { type: string } - issueId: { type: string } - kind: { type: string, enum: [worker, orchestrator], default: worker } - harness: { type: string, enum: ["", claude-code, codex, aider, opencode] } - branch: { type: string } - prompt: { type: string, maxLength: 4096 } - agentRules: { type: string } - - SendSessionMessageRequest: + project: + $ref: '#/components/schemas/Project' + required: + - project type: object - required: [message] + RemoveResult: properties: - message: { type: string, minLength: 1, maxLength: 4096 } - - SendSessionMessageResponse: + projectId: + type: string + removedStorageDir: + type: boolean + required: + - projectId + - removedStorageDir type: object - required: [ok, sessionId, message] + SCMConfig: properties: - ok: { type: boolean } - sessionId: { type: string } - message: { type: string } - - KillSessionResponse: + package: + type: string + path: + type: string + plugin: + type: string + webhook: + $ref: '#/components/schemas/SCMWebhookConfig' type: object - required: [ok, sessionId] + SCMWebhookConfig: properties: - ok: { type: boolean } - sessionId: { type: string } - freed: { type: boolean } - - SpawnOrchestratorRequest: + deliveryHeader: + type: string + enabled: + type: + - "null" + - boolean + eventHeader: + type: string + maxBodyBytes: + type: integer + path: + type: string + secretEnvVar: + type: string + signatureHeader: + type: string type: object - required: [projectId] + Summary: properties: - projectId: { type: string } - clean: { type: boolean, default: false } - - SpawnOrchestratorResponse: + id: + type: string + name: + type: string + resolveError: + type: string + sessionPrefix: + type: string + required: + - id + - name + - sessionPrefix type: object - required: [orchestrator] - properties: - orchestrator: - type: object - required: [id, projectId] - properties: - id: { type: string } - projectId: { type: string } - projectName: { type: string } - - # ---- Behaviour config blobs ---- - TrackerConfig: - type: object - additionalProperties: true properties: - plugin: { type: string } - package: { type: string } - path: { type: string } - - SCMConfig: + package: + type: string + path: + type: string + plugin: + type: string type: object - additionalProperties: true - properties: - plugin: { type: string } - package: { type: string } - path: { type: string } - webhook: - type: object - properties: - enabled: { type: boolean } - path: { type: string } - secretEnvVar: { type: string } - signatureHeader: { type: string } - eventHeader: { type: string } - deliveryHeader: { type: string } - maxBodyBytes: { type: integer } +tags: +- description: Project registry, configuration, and lifecycle administration + name: projects diff --git a/backend/internal/httpd/apispec/parity_test.go b/backend/internal/httpd/apispec/parity_test.go new file mode 100644 index 00000000..5bd29410 --- /dev/null +++ b/backend/internal/httpd/apispec/parity_test.go @@ -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) + } + } + } +} diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go new file mode 100644 index 00000000..e838be0c --- /dev/null +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -0,0 +1,242 @@ +// 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" + "github.com/aoagents/agent-orchestrator/backend/internal/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 project.*/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". + r.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"), + } + + for _, op := range projectOperations() { + 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("projects") + 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", + // httpd/controllers (wire envelopes) + "ControllersListProjectsResponse": "ListProjectsResponse", + "ControllersProjectResponse": "ProjectResponse", + "ControllersGetProjectResponse": "ProjectGetResponse", + "ControllersProjectOrDegraded": "ProjectOrDegraded", + // project (entities + DTOs) + "ProjectProject": "Project", + "ProjectSummary": "Summary", + "ProjectDegraded": "Degraded", + "ProjectAddInput": "AddInput", + "ProjectRemoveResult": "RemoveResult", + "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 + pathParams []any // path/query param containers (e.g. ProjectIDParam) + reqBody any // JSON request body struct, nil when the op takes none + resps []respUnit +} + +// 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", + 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", + summary: "Register a new project from a git repository path", + reqBody: project.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", + 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", + summary: "Remove a project; stops sessions, cleans workspaces, unregisters", + pathParams: []any{controllers.ProjectIDParam{}}, + resps: []respUnit{ + {http.StatusOK, project.RemoveResult{}}, + {http.StatusBadRequest, envelope.APIError{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + } +} diff --git a/backend/internal/httpd/apispec/specgen/build_test.go b/backend/internal/httpd/apispec/specgen/build_test.go new file mode 100644 index 00000000..9951456b --- /dev/null +++ b/backend/internal/httpd/apispec/specgen/build_test.go @@ -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") + } +} diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go new file mode 100644 index 00000000..71b731b7 --- /dev/null +++ b/backend/internal/httpd/controllers/dto.go @@ -0,0 +1,91 @@ +package controllers + +import ( + "encoding/json" + "errors" + + "github.com/aoagents/agent-orchestrator/backend/internal/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 +// (project.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 []project.Summary `json:"projects"` +} + +// ProjectResponse is the { project } body shared by POST /projects (201). +type ProjectResponse struct { + Project project.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 *project.Project + Degraded *project.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{}{project.Project{}, project.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 project.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 +} diff --git a/backend/internal/httpd/controllers/projects.go b/backend/internal/httpd/controllers/projects.go index 91a1e47d..9236b128 100644 --- a/backend/internal/httpd/controllers/projects.go +++ b/backend/internal/httpd/controllers/projects.go @@ -5,10 +5,8 @@ package controllers import ( - "bytes" "encoding/json" "errors" - "io" "net/http" "github.com/go-chi/chi/v5" @@ -25,17 +23,12 @@ type ProjectsController struct { Mgr project.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 = []project.Summary{} + } + envelope.WriteJSON(w, http.StatusOK, ListProjectsResponse{Projects: projects}) } func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) { @@ -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,26 +104,6 @@ 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. func writeProjectError(w http.ResponseWriter, r *http.Request, err error) { diff --git a/backend/internal/httpd/controllers/projects_test.go b/backend/internal/httpd/controllers/projects_test.go index 0f192946..1e6dcdbb 100644 --- a/backend/internal/httpd/controllers/projects_test.go +++ b/backend/internal/httpd/controllers/projects_test.go @@ -1,6 +1,7 @@ package controllers_test import ( + "context" "encoding/json" "io" "log/slog" @@ -13,11 +14,36 @@ import ( "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" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) +// 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{ project.Manager } + +func (emptyGetManager) Get(context.Context, domain.ProjectID) (project.GetResult, error) { + return project.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: 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)) @@ -43,7 +69,7 @@ func TestProjectsRoutes_DefaultToStubsWithoutManager(t *testing.T) { 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") @@ -84,20 +110,6 @@ func TestProjectsAPI_ListAddGetReload(t *testing.T) { 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 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) - } } func TestProjectsAPI_AddValidationAndConflicts(t *testing.T) { @@ -133,7 +145,7 @@ func TestProjectsAPI_AddValidationAndConflicts(t *testing.T) { 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") @@ -142,15 +154,6 @@ func TestProjectsAPI_UpdateDeleteRepair(t *testing.T) { 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) @@ -190,7 +193,6 @@ func TestProjectsRoutes_LegacyUnregistered(t *testing.T) { 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 { diff --git a/backend/internal/project/dto.go b/backend/internal/project/dto.go index 7146d455..3ec316f6 100644 --- a/backend/internal/project/dto.go +++ b/backend/internal/project/dto.go @@ -26,15 +26,6 @@ type AddInput struct { 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). @@ -43,11 +34,3 @@ type RemoveResult struct { 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"` -} diff --git a/backend/internal/project/errors.go b/backend/internal/project/errors.go index f6687e1f..c4e29d8c 100644 --- a/backend/internal/project/errors.go +++ b/backend/internal/project/errors.go @@ -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) } diff --git a/backend/internal/project/manager.go b/backend/internal/project/manager.go index 11654d66..c5ba5e0a 100644 --- a/backend/internal/project/manager.go +++ b/backend/internal/project/manager.go @@ -1,3 +1,5 @@ +// Package project owns the projects service contract: the Manager interface, +// its implementation, and the request/response DTOs that cross it. package project import ( @@ -13,6 +15,24 @@ import ( "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) + + // Remove unregisters a project, stopping its sessions and reclaiming + // managed workspaces. + Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) +} + type manager struct { store Store } @@ -111,21 +131,6 @@ func (m *manager) Add(ctx context.Context, in AddInput) (Project, error) { 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) { if err := validateProjectID(id); err != nil { return RemoveResult{}, err @@ -140,26 +145,6 @@ 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 { for i := 1; ; i++ { candidate := domain.ProjectID(string(base) + strconv.Itoa(i)) diff --git a/backend/internal/project/project.go b/backend/internal/project/project.go deleted file mode 100644 index 14bf731a..00000000 --- a/backend/internal/project/project.go +++ /dev/null @@ -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) -} From c6c85157b3d5068e94abafec378f75a820bd3526 Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Mon, 1 Jun 2026 17:48:43 +0530 Subject: [PATCH 03/13] fix(project): address PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/internal/project/manager_test.go | 20 ++++++++++++++++--- .../storage/sqlite/gen/projects.sql.go | 2 +- .../storage/sqlite/queries/projects.sql | 2 +- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/backend/internal/project/manager_test.go b/backend/internal/project/manager_test.go index 0c2fa1cf..a6ab4cef 100644 --- a/backend/internal/project/manager_test.go +++ b/backend/internal/project/manager_test.go @@ -28,7 +28,7 @@ func gitRepo(t *testing.T) string { t.Helper() dir := t.TempDir() if out, err := exec.Command("git", "init", dir).CombinedOutput(); err != nil { - t.Skipf("git unavailable: %v (%s)", err, out) + t.Fatalf("git unavailable: %v (%s)", err, out) } return dir } @@ -89,6 +89,22 @@ func TestManager_AddListGetRemove(t *testing.T) { } } +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 after remove: %v", err) + } +} + func TestManager_AddValidationAndConflicts(t *testing.T) { ctx := context.Background() m := newManager(t) @@ -127,6 +143,4 @@ func TestManager_GetUpdateRemoveErrors(t *testing.T) { if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("p")}); err != nil { t.Fatalf("seed: %v", err) } - _, err = m.UpdateConfig(ctx, "p", project.UpdateConfigInput{}) - wantCode(t, err, "PROJECT_CONFIG_NOT_IMPLEMENTED") } diff --git a/backend/internal/storage/sqlite/gen/projects.sql.go b/backend/internal/storage/sqlite/gen/projects.sql.go index be255c5b..89c99d1e 100644 --- a/backend/internal/storage/sqlite/gen/projects.sql.go +++ b/backend/internal/storage/sqlite/gen/projects.sql.go @@ -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) { diff --git a/backend/internal/storage/sqlite/queries/projects.sql b/backend/internal/storage/sqlite/queries/projects.sql index c5706035..3f12aedd 100644 --- a/backend/internal/storage/sqlite/queries/projects.sql +++ b/backend/internal/storage/sqlite/queries/projects.sql @@ -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 = ?; From 51fb1d06669307b706546901706048962a8d50b0 Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Mon, 1 Jun 2026 17:57:02 +0530 Subject: [PATCH 04/13] fixed lint and fmt --- backend/cmd/genspec/main.go | 2 +- backend/internal/httpd/apispec/specgen/build.go | 6 +++--- backend/internal/httpd/controllers/dto.go | 1 + backend/internal/project/dto.go | 1 - 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/cmd/genspec/main.go b/backend/cmd/genspec/main.go index 67f1eb22..c2310e94 100644 --- a/backend/cmd/genspec/main.go +++ b/backend/cmd/genspec/main.go @@ -20,7 +20,7 @@ func main() { if err != nil { log.Fatalf("genspec: build openapi: %v", err) } - if err := os.WriteFile(*out, doc, 0o644); err != nil { + if err := os.WriteFile(*out, doc, 0o600); err != nil { log.Fatalf("genspec: write %s: %v", *out, err) } } diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index e838be0c..d9ba7612 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -42,10 +42,10 @@ func Build() ([]byte, error) { 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), ) - // Clean component schema names (which become the generated TS type names): - // swaggest defaults to PackageType, e.g. "ProjectProject", "EnvelopeAPIError". - r.InterceptDefName(schemaName) r.Spec.SetTitle("Agent Orchestrator HTTP daemon") r.Spec.SetVersion("0.1.0-route-shell") diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index 71b731b7..266f0082 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -48,6 +48,7 @@ type ProjectOrDegraded struct { Degraded *project.Degraded } +// MarshalJSON encodes whichever variant is set (Project or Degraded). func (p ProjectOrDegraded) MarshalJSON() ([]byte, error) { switch { case p.Degraded != nil: diff --git a/backend/internal/project/dto.go b/backend/internal/project/dto.go index 3ec316f6..9975a120 100644 --- a/backend/internal/project/dto.go +++ b/backend/internal/project/dto.go @@ -33,4 +33,3 @@ type RemoveResult struct { ProjectID domain.ProjectID `json:"projectId"` RemovedStorageDir bool `json:"removedStorageDir"` } - From 5172cde11294e95a1522b18a0acd6251fc2551a8 Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Mon, 1 Jun 2026 18:04:06 +0530 Subject: [PATCH 05/13] addressed greptile comments --- backend/internal/project/manager.go | 2 +- backend/internal/project/manager_test.go | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/internal/project/manager.go b/backend/internal/project/manager.go index c5ba5e0a..c7a73b2d 100644 --- a/backend/internal/project/manager.go +++ b/backend/internal/project/manager.go @@ -70,7 +70,7 @@ func (m *manager) Get(ctx context.Context, id domain.ProjectID) (GetResult, erro 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) diff --git a/backend/internal/project/manager_test.go b/backend/internal/project/manager_test.go index a6ab4cef..1328cf7e 100644 --- a/backend/internal/project/manager_test.go +++ b/backend/internal/project/manager_test.go @@ -87,6 +87,8 @@ func TestManager_AddListGetRemove(t *testing.T) { 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) { From bce5cfe7c39545dd87a5d985969c1e93ed79d6a2 Mon Sep 17 00:00:00 2001 From: neversettle <41864816+neversettle17-101@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:13:53 +0530 Subject: [PATCH 06/13] Apply suggestions from code review Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- backend/internal/storage/sqlite/queries/projects.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/storage/sqlite/queries/projects.sql b/backend/internal/storage/sqlite/queries/projects.sql index 3f12aedd..3d41d0d5 100644 --- a/backend/internal/storage/sqlite/queries/projects.sql +++ b/backend/internal/storage/sqlite/queries/projects.sql @@ -20,4 +20,4 @@ SELECT id, path, repo_origin_url, display_name, registered_at, archived_at 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; From 860cd9648807b62218142d883fb4acfdb1376710 Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Mon, 1 Jun 2026 18:14:34 +0530 Subject: [PATCH 07/13] project tests fix --- backend/internal/httpd/controllers/projects_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/internal/httpd/controllers/projects_test.go b/backend/internal/httpd/controllers/projects_test.go index 1e6dcdbb..69f30603 100644 --- a/backend/internal/httpd/controllers/projects_test.go +++ b/backend/internal/httpd/controllers/projects_test.go @@ -168,8 +168,8 @@ func TestProjectsAPI_Delete(t *testing.T) { } 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", "") From 277909f3a8db5d8edf12541a32b1bffdb8057016 Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Mon, 1 Jun 2026 18:16:33 +0530 Subject: [PATCH 08/13] project_tests fix --- .../httpd/controllers/projects_test.go | 318 ++++++++++++++++++ 1 file changed, 318 insertions(+) diff --git a/backend/internal/httpd/controllers/projects_test.go b/backend/internal/httpd/controllers/projects_test.go index 69f30603..7ef0df77 100644 --- a/backend/internal/httpd/controllers/projects_test.go +++ b/backend/internal/httpd/controllers/projects_test.go @@ -1,318 +1,636 @@ 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" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" + ) + + // 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{ project.Manager } + + func (emptyGetManager) Get(context.Context, domain.ProjectID) (project.GetResult, error) { + return project.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: 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: project.NewManager(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_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) + } + } + + 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 + }{ + {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_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, "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"` + } + 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.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 + }{ + {method: "PUT", path: "/api/v1/projects/p1", wantStatus: 405, wantCode: "METHOD_NOT_ALLOWED", why: "R3 PUT not registered"}, + } + + 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"` + SessionPrefix string `json:"sessionPrefix"` + } + + type projectBody struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Repo string `json:"repo"` + DefaultBranch string `json:"defaultBranch"` + Agent string `json:"agent"` + } + + type errorBody struct { + 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) + } + } + From a7eea0e3b053d6db6f29821faa6ace5776044ec9 Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Mon, 1 Jun 2026 18:22:37 +0530 Subject: [PATCH 09/13] fix: Linting and formatting fix --- .../httpd/controllers/projects_test.go | 133 ++---------------- 1 file changed, 15 insertions(+), 118 deletions(-) diff --git a/backend/internal/httpd/controllers/projects_test.go b/backend/internal/httpd/controllers/projects_test.go index 7ef0df77..c5fbb12a 100644 --- a/backend/internal/httpd/controllers/projects_test.go +++ b/backend/internal/httpd/controllers/projects_test.go @@ -1,9 +1,6 @@ package controllers_test - - import ( - "context" "encoding/json" @@ -26,8 +23,6 @@ import ( "testing" - - "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -37,11 +32,8 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/project" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" - ) - - // emptyGetManager returns a GetResult that sets neither Project nor Degraded — // a Manager-contract violation — so the test can prove the handler answers a @@ -50,16 +42,12 @@ import ( type emptyGetManager struct{ project.Manager } - - func (emptyGetManager) Get(context.Context, domain.ProjectID) (project.GetResult, error) { return project.GetResult{}, nil } - - // TestProjectsAPI_GetEmptyResultIs500 locks the fix for the discriminated-union // invariant: a degenerate GetResult must surface as a parseable 500 envelope, @@ -73,13 +61,10 @@ func TestProjectsAPI_GetEmptyResultIs500(t *testing.T) { srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{ Projects: emptyGetManager{}, - })) t.Cleanup(srv.Close) - - body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects/whatever", "") assertJSON(t, headers) @@ -88,8 +73,6 @@ func TestProjectsAPI_GetEmptyResultIs500(t *testing.T) { } - - func newTestServer(t *testing.T) *httptest.Server { t.Helper() @@ -109,7 +92,6 @@ func newTestServer(t *testing.T) *httptest.Server { srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{ Projects: project.NewManager(store), - })) t.Cleanup(srv.Close) @@ -118,8 +100,6 @@ func newTestServer(t *testing.T) *httptest.Server { } - - func TestProjectsRoutes_DefaultToStubsWithoutManager(t *testing.T) { log := slog.New(slog.NewTextHandler(io.Discard, nil)) @@ -128,8 +108,6 @@ func TestProjectsRoutes_DefaultToStubsWithoutManager(t *testing.T) { t.Cleanup(srv.Close) - - body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects", "") assertJSON(t, headers) @@ -138,16 +116,12 @@ func TestProjectsRoutes_DefaultToStubsWithoutManager(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 { @@ -159,9 +133,7 @@ func TestProjectsAPI_ListAddGet(t *testing.T) { assertJSON(t, headers) var list struct { - Projects []projectSummary `json:"projects"` - } mustJSON(t, body, &list) @@ -172,8 +144,6 @@ func TestProjectsAPI_ListAddGet(t *testing.T) { } - - body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repo)+`,"projectId":"ao","name":"Agent Orchestrator"}`) if status != http.StatusCreated { @@ -183,9 +153,7 @@ func TestProjectsAPI_ListAddGet(t *testing.T) { } var add struct { - Project projectBody `json:"project"` - } mustJSON(t, body, &add) @@ -196,8 +164,6 @@ func TestProjectsAPI_ListAddGet(t *testing.T) { } - - body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects/ao", "") if status != http.StatusOK { @@ -207,11 +173,9 @@ func TestProjectsAPI_ListAddGet(t *testing.T) { } var get struct { - - Status string `json:"status"` + Status string `json:"status"` Project projectBody `json:"project"` - } mustJSON(t, body, &get) @@ -224,8 +188,6 @@ func TestProjectsAPI_ListAddGet(t *testing.T) { } - - func TestProjectsAPI_AddValidationAndConflicts(t *testing.T) { srv := newTestServer(t) @@ -236,14 +198,10 @@ func TestProjectsAPI_AddValidationAndConflicts(t *testing.T) { notRepo := t.TempDir() - - cases := []struct { - name, body, wantCode string - wantStatus int - + wantStatus int }{ {name: "invalid json", body: `{`, wantStatus: 400, wantCode: "INVALID_JSON"}, @@ -251,7 +209,6 @@ func TestProjectsAPI_AddValidationAndConflicts(t *testing.T) { {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 { @@ -266,8 +223,6 @@ func TestProjectsAPI_AddValidationAndConflicts(t *testing.T) { } - - body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoA)+`,"projectId":"shared"}`) if status != http.StatusCreated { @@ -276,30 +231,22 @@ func TestProjectsAPI_AddValidationAndConflicts(t *testing.T) { } - - 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_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 { @@ -308,8 +255,6 @@ func TestProjectsAPI_Delete(t *testing.T) { } - - body, status, _ = doRequest(t, srv, "DELETE", "/api/v1/projects/proj", "") if status != http.StatusOK { @@ -319,11 +264,9 @@ func TestProjectsAPI_Delete(t *testing.T) { } var removed struct { + ProjectID string `json:"projectId"` - ProjectID string `json:"projectId"` - - RemovedStorageDir bool `json:"removedStorageDir"` - + RemovedStorageDir bool `json:"removedStorageDir"` } mustJSON(t, body, &removed) @@ -334,8 +277,6 @@ func TestProjectsAPI_Delete(t *testing.T) { } - - body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects/proj", "") if status != http.StatusNotFound { @@ -344,8 +285,6 @@ func TestProjectsAPI_Delete(t *testing.T) { } - - body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects", "") if status != http.StatusOK { @@ -355,9 +294,7 @@ func TestProjectsAPI_Delete(t *testing.T) { } var list struct { - Projects []projectSummary `json:"projects"` - } mustJSON(t, body, &list) @@ -370,28 +307,19 @@ func TestProjectsAPI_Delete(t *testing.T) { } - - 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"}, - } - - for _, tc := range cases { t.Run(tc.why, func(t *testing.T) { @@ -406,8 +334,6 @@ func TestProjectsRoutes_LegacyUnregistered(t *testing.T) { } - - func TestProjectsRoutes_MissingRoute(t *testing.T) { srv := newTestServer(t) @@ -420,8 +346,6 @@ func TestProjectsRoutes_MissingRoute(t *testing.T) { } - - func TestOpenAPIYAMLServed(t *testing.T) { srv := newTestServer(t) @@ -448,52 +372,38 @@ func TestOpenAPIYAMLServed(t *testing.T) { } - - type projectSummary struct { + ID string `json:"id"` - ID string `json:"id"` - - Name string `json:"name"` + Name string `json:"name"` SessionPrefix string `json:"sessionPrefix"` - } - - type projectBody struct { + ID string `json:"id"` - ID string `json:"id"` + Name string `json:"name"` - Name string `json:"name"` + Path string `json:"path"` - Path string `json:"path"` - - Repo string `json:"repo"` + Repo string `json:"repo"` DefaultBranch string `json:"defaultBranch"` - Agent string `json:"agent"` - + Agent string `json:"agent"` } - - type errorBody struct { + Error string `json:"error"` - Error string `json:"error"` - - Code string `json:"code"` + Code string `json:"code"` - Message string `json:"message"` + 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() @@ -524,8 +434,6 @@ func doRequest(t *testing.T, srv *httptest.Server, method, path, body string) ([ } - - resp, err := srv.Client().Do(req) if err != nil { @@ -548,8 +456,6 @@ func doRequest(t *testing.T, srv *httptest.Server, method, path, body string) ([ } - - func gitRepo(t *testing.T, name string) string { t.Helper() @@ -572,8 +478,6 @@ func gitRepo(t *testing.T, name string) string { } - - func quote(s string) string { b, _ := json.Marshal(s) @@ -582,8 +486,6 @@ func quote(s string) string { } - - func mustJSON(t *testing.T, body []byte, out any) { t.Helper() @@ -596,8 +498,6 @@ func mustJSON(t *testing.T, body []byte, out any) { } - - func assertJSON(t *testing.T, headers http.Header) { t.Helper() @@ -610,8 +510,6 @@ func assertJSON(t *testing.T, headers http.Header) { } - - func assertErrorCode(t *testing.T, body []byte, status, wantStatus int, wantCode string) { t.Helper() @@ -633,4 +531,3 @@ func assertErrorCode(t *testing.T, body []byte, status, wantStatus int, wantCode } } - From b940df43a3b34a04e3da556929a7022fd8b82e22 Mon Sep 17 00:00:00 2001 From: itrytoohard Date: Tue, 2 Jun 2026 00:07:20 +0530 Subject: [PATCH 10/13] refactor: move project manager into service layer (#68) --- backend/internal/cdc/cdc_test.go | 3 +- backend/internal/daemon/daemon.go | 4 +- backend/internal/daemon/wiring_test.go | 3 +- backend/internal/domain/project.go | 13 + backend/internal/httpd/api.go | 4 +- backend/internal/httpd/apispec/openapi.yaml | 537 +++++++++++++++++- .../internal/httpd/apispec/specgen/build.go | 166 +++++- backend/internal/httpd/controllers/dto.go | 100 +++- .../internal/httpd/controllers/projects.go | 16 +- .../httpd/controllers/projects_test.go | 10 +- .../internal/httpd/controllers/sessions.go | 41 +- .../integration/lifecycle_sqlite_test.go | 3 +- backend/internal/project/dto.go | 35 -- backend/internal/project/errors.go | 37 -- backend/internal/project/store.go | 32 -- .../manager.go => service/project.go} | 108 ++-- backend/internal/service/project_dto.go | 23 + backend/internal/service/project_errors.go | 37 ++ backend/internal/service/project_store.go | 17 + .../project_test.go} | 32 +- .../types.go => service/project_types.go} | 39 +- .../storage/sqlite/store/project_store.go | 37 +- .../storage/sqlite/store/store_test.go | 13 +- 23 files changed, 984 insertions(+), 326 deletions(-) create mode 100644 backend/internal/domain/project.go delete mode 100644 backend/internal/project/dto.go delete mode 100644 backend/internal/project/errors.go delete mode 100644 backend/internal/project/store.go rename backend/internal/{project/manager.go => service/project.go} (50%) create mode 100644 backend/internal/service/project_dto.go create mode 100644 backend/internal/service/project_errors.go create mode 100644 backend/internal/service/project_store.go rename backend/internal/{project/manager_test.go => service/project_test.go} (72%) rename backend/internal/{project/types.go => service/project_types.go} (52%) diff --git a/backend/internal/cdc/cdc_test.go b/backend/internal/cdc/cdc_test.go index 6196120f..59d9e690 100644 --- a/backend/internal/cdc/cdc_test.go +++ b/backend/internal/cdc/cdc_test.go @@ -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{ diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index b8d89053..ea8e842a 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -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" + "github.com/aoagents/agent-orchestrator/backend/internal/service" "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: service.NewProject(store)}) if err != nil { stop() if cdcErr := cdcPipe.Stop(); cdcErr != nil { diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index 6d6dae04..d8c7a610 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -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{ diff --git a/backend/internal/domain/project.go b/backend/internal/domain/project.go new file mode 100644 index 00000000..b00e65c7 --- /dev/null +++ b/backend/internal/domain/project.go @@ -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 +} diff --git a/backend/internal/httpd/api.go b/backend/internal/httpd/api.go index a61ba784..172ed256 100644 --- a/backend/internal/httpd/api.go +++ b/backend/internal/httpd/api.go @@ -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" + "github.com/aoagents/agent-orchestrator/backend/internal/service" ) // 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 service.ProjectManager Sessions controllers.SessionService } diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 9c758399..c121ffe4 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -8,6 +8,49 @@ servers: - description: Local daemon (loopback only) url: http://127.0.0.1:3001 paths: + /api/v1/orchestrators: + post: + operationId: spawnOrchestrator + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SpawnOrchestratorRequest' + required: true + responses: + "201": + content: + application/json: + schema: + $ref: '#/components/schemas/SpawnOrchestratorResponse' + description: Created + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Spawn an orchestrator session + tags: + - sessions /api/v1/projects: get: operationId: listProjects @@ -33,7 +76,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/AddInput' + $ref: '#/components/schemas/AddProjectInput' required: true responses: "201": @@ -79,7 +122,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/RemoveResult' + $ref: '#/components/schemas/RemoveProjectResult' description: OK "400": content: @@ -134,6 +177,300 @@ paths: summary: Fetch one project; discriminates ok vs degraded tags: - projects + /api/v1/sessions: + get: + operationId: listSessions + parameters: + - description: Project id filter. + in: query + name: project + schema: + description: Project id filter. + type: string + - description: When true, return non-terminated sessions; when false, return + terminated sessions. + in: query + name: active + schema: + description: When true, return non-terminated sessions; when false, return + terminated sessions. + type: + - "null" + - boolean + - description: When true, return only orchestrator sessions. + in: query + name: orchestratorOnly + schema: + description: When true, return only orchestrator sessions. + type: + - "null" + - boolean + - description: When true, return only fresh non-terminated sessions. + in: query + name: fresh + schema: + description: When true, return only fresh non-terminated sessions. + type: + - "null" + - boolean + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ListSessionsResponse' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: List sessions + tags: + - sessions + post: + operationId: spawnSession + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SpawnSessionRequest' + required: true + responses: + "201": + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + description: Created + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Spawn a new agent session + tags: + - sessions + /api/v1/sessions/{sessionId}: + get: + operationId: getSession + parameters: + - description: Session identifier, e.g. project-1. + in: path + name: sessionId + required: true + schema: + description: Session identifier, e.g. project-1. + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + description: OK + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Fetch one session + tags: + - sessions + patch: + operationId: renameSession + parameters: + - description: Session identifier, e.g. project-1. + in: path + name: sessionId + required: true + schema: + description: Session identifier, e.g. project-1. + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RenameSessionRequest' + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Rename a session display name + tags: + - sessions + /api/v1/sessions/{sessionId}/kill: + post: + operationId: killSession + parameters: + - description: Session identifier, e.g. project-1. + in: path + name: sessionId + required: true + schema: + description: Session identifier, e.g. project-1. + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/KillSessionResponse' + description: OK + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Conflict + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Mark a session terminated and tear down runtime/workspace resources + tags: + - sessions + /api/v1/sessions/{sessionId}/restore: + post: + operationId: restoreSession + parameters: + - description: Session identifier, e.g. project-1. + in: path + name: sessionId + required: true + schema: + description: Session identifier, e.g. project-1. + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/RestoreSessionResponse' + description: OK + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Conflict + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Restore a terminated session + tags: + - sessions + /api/v1/sessions/{sessionId}/send: + post: + operationId: sendSessionMessage + parameters: + - description: Session identifier, e.g. project-1. + in: path + name: sessionId + required: true + schema: + description: Session identifier, e.g. project-1. + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SendSessionMessageRequest' + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/SendSessionMessageResponse' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Send a message to a running session's agent + tags: + - sessions components: schemas: APIError: @@ -154,7 +491,7 @@ components: - code - message type: object - AddInput: + AddProjectInput: properties: name: type: @@ -169,7 +506,7 @@ components: required: - path type: object - Degraded: + DegradedProject: properties: id: type: string @@ -185,15 +522,59 @@ components: - path - resolveError type: object + DomainActivity: + properties: + lastActivityAt: + format: date-time + type: string + state: + type: string + required: + - state + - lastActivityAt + type: object + KillSessionResponse: + properties: + freed: + type: boolean + ok: + type: boolean + sessionId: + type: string + required: + - ok + - sessionId + type: object ListProjectsResponse: properties: projects: items: - $ref: '#/components/schemas/Summary' + $ref: '#/components/schemas/ProjectSummary' type: array required: - projects type: object + ListSessionsResponse: + properties: + sessions: + items: + $ref: '#/components/schemas/Session' + type: array + required: + - sessions + type: object + OrchestratorResponse: + properties: + id: + type: string + projectId: + type: string + projectName: + type: string + required: + - id + - projectId + type: object Project: properties: agent: @@ -235,7 +616,7 @@ components: ProjectOrDegraded: oneOf: - $ref: '#/components/schemas/Project' - - $ref: '#/components/schemas/Degraded' + - $ref: '#/components/schemas/DegradedProject' type: object ProjectResponse: properties: @@ -244,7 +625,22 @@ components: required: - project type: object - RemoveResult: + ProjectSummary: + properties: + id: + type: string + name: + type: string + resolveError: + type: string + sessionPrefix: + type: string + required: + - id + - name + - sessionPrefix + type: object + RemoveProjectResult: properties: projectId: type: string @@ -254,6 +650,27 @@ components: - projectId - removedStorageDir type: object + RenameSessionRequest: + properties: + displayName: + minLength: 1 + type: string + required: + - displayName + type: object + RestoreSessionResponse: + properties: + ok: + type: boolean + session: + $ref: '#/components/schemas/Session' + sessionId: + type: string + required: + - ok + - sessionId + - session + type: object SCMConfig: properties: package: @@ -284,20 +701,112 @@ components: signatureHeader: type: string type: object - Summary: + SendSessionMessageRequest: + properties: + message: + maxLength: 4096 + minLength: 1 + type: string + required: + - message + type: object + SendSessionMessageResponse: + properties: + message: + type: string + ok: + type: boolean + sessionId: + type: string + required: + - ok + - sessionId + - message + type: object + Session: properties: + activity: + $ref: '#/components/schemas/DomainActivity' + createdAt: + format: date-time + type: string + harness: + type: string id: type: string - name: + isTerminated: + type: boolean + issueId: type: string - resolveError: + kind: type: string - sessionPrefix: + projectId: + type: string + status: + type: string + updatedAt: + format: date-time type: string required: - id - - name - - sessionPrefix + - projectId + - kind + - activity + - isTerminated + - createdAt + - updatedAt + - status + type: object + SessionResponse: + properties: + session: + $ref: '#/components/schemas/Session' + required: + - session + type: object + SpawnOrchestratorRequest: + properties: + clean: + type: boolean + projectId: + type: string + required: + - projectId + type: object + SpawnOrchestratorResponse: + properties: + orchestrator: + $ref: '#/components/schemas/OrchestratorResponse' + required: + - orchestrator + type: object + SpawnSessionRequest: + properties: + agentRules: + type: string + branch: + type: string + harness: + enum: + - claude-code + - codex + - aider + - opencode + type: string + issueId: + type: string + kind: + enum: + - worker + - orchestrator + type: string + projectId: + type: string + prompt: + maxLength: 4096 + type: string + required: + - projectId type: object TrackerConfig: properties: @@ -311,3 +820,5 @@ components: tags: - description: Project registry, configuration, and lifecycle administration name: projects +- description: Agent session lifecycle and messaging + name: sessions diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index d9ba7612..1da55c55 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -17,7 +17,7 @@ import ( "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" + "github.com/aoagents/agent-orchestrator/backend/internal/service" ) // Build reflects the Go contract types and the operation registry below into @@ -25,7 +25,7 @@ import ( // 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 project.*/controllers.* types; operation metadata (path, +// 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 @@ -57,16 +57,18 @@ func Build() ([]byte, error) { 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 projectOperations() { + 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("projects") + oc.SetTags(op.tag) for _, param := range op.pathParams { oc.AddReqStructure(param) } @@ -109,20 +111,37 @@ var schemaNames = map[string]string{ "EnvelopeAPIError": "APIError", // domain "DomainProjectID": "ProjectID", + "DomainSessionID": "SessionID", + "DomainIssueID": "IssueID", + "DomainSession": "Session", // httpd/controllers (wire envelopes) - "ControllersListProjectsResponse": "ListProjectsResponse", - "ControllersProjectResponse": "ProjectResponse", - "ControllersGetProjectResponse": "ProjectGetResponse", - "ControllersProjectOrDegraded": "ProjectOrDegraded", - // project (entities + DTOs) - "ProjectProject": "Project", - "ProjectSummary": "Summary", - "ProjectDegraded": "Degraded", - "ProjectAddInput": "AddInput", - "ProjectRemoveResult": "RemoveResult", - "ProjectTrackerConfig": "TrackerConfig", - "ProjectSCMConfig": "SCMConfig", - "ProjectSCMWebhookConfig": "SCMWebhookConfig", + "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 + "ServiceProject": "Project", + "ServiceProjectSummary": "ProjectSummary", + "ServiceDegradedProject": "DegradedProject", + "ServiceAddProjectInput": "AddProjectInput", + "ServiceRemoveProjectResult": "RemoveProjectResult", + "ServiceTrackerConfig": "TrackerConfig", + "ServiceSCMConfig": "SCMConfig", + "ServiceSCMWebhookConfig": "SCMWebhookConfig", } // markRequestBodyRequired sets requestBody.required: true on the operation's @@ -188,18 +207,25 @@ type respUnit struct { 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", + method: http.MethodGet, path: "/api/v1/projects", id: "listProjects", tag: "projects", summary: "List all registered projects (active + degraded)", resps: []respUnit{ {http.StatusOK, controllers.ListProjectsResponse{}}, @@ -207,9 +233,9 @@ func projectOperations() []operation { }, }, { - method: http.MethodPost, path: "/api/v1/projects", id: "addProject", + method: http.MethodPost, path: "/api/v1/projects", id: "addProject", tag: "projects", summary: "Register a new project from a git repository path", - reqBody: project.AddInput{}, + reqBody: service.AddProjectInput{}, resps: []respUnit{ {http.StatusCreated, controllers.ProjectResponse{}}, {http.StatusBadRequest, envelope.APIError{}}, @@ -218,7 +244,7 @@ func projectOperations() []operation { }, }, { - method: http.MethodGet, path: "/api/v1/projects/{id}", id: "getProject", + 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{ @@ -228,14 +254,108 @@ func projectOperations() []operation { }, }, { - method: http.MethodDelete, path: "/api/v1/projects/{id}", id: "removeProject", + 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, project.RemoveResult{}}, + {http.StatusOK, service.RemoveProjectResult{}}, + {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{}}, }, }, } diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index 266f0082..b101a0a7 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -4,7 +4,8 @@ import ( "encoding/json" "errors" - "github.com/aoagents/agent-orchestrator/backend/internal/project" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/service" ) // HTTP response envelopes for the projects surface — the SINGLE definition of @@ -12,7 +13,7 @@ import ( // 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 -// (project.AddInput), which apispec also reflects. +// (service.AddProjectInput), 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 @@ -24,12 +25,12 @@ type ProjectIDParam struct { // ListProjectsResponse is the body of GET /api/v1/projects. type ListProjectsResponse struct { - Projects []project.Summary `json:"projects"` + Projects []service.ProjectSummary `json:"projects"` } // ProjectResponse is the { project } body shared by POST /projects (201). type ProjectResponse struct { - Project project.Project `json:"project"` + Project service.Project `json:"project"` } // GetProjectResponse is the { status, project } body of GET /projects/{id}, @@ -44,8 +45,8 @@ type GetProjectResponse struct { // 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 *project.Project - Degraded *project.Degraded + Project *service.Project + Degraded *service.DegradedProject } // MarshalJSON encodes whichever variant is set (Project or Degraded). @@ -74,14 +75,14 @@ var errEmptyProjectOrDegraded = errors.New("controllers: GetResult has neither P // 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{}{project.Project{}, project.Degraded{}} + return []interface{}{service.Project{}, service.DegradedProject{}} } // 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 project.GetResult) (GetProjectResponse, error) { +func newGetProjectResponse(res service.GetProjectResult) (GetProjectResponse, error) { if res.Project == nil && res.Degraded == nil { return GetProjectResponse{}, errEmptyProjectOrDegraded } @@ -90,3 +91,86 @@ func newGetProjectResponse(res project.GetResult) (GetProjectResponse, error) { 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"` +} diff --git a/backend/internal/httpd/controllers/projects.go b/backend/internal/httpd/controllers/projects.go index 9236b128..a1886f74 100644 --- a/backend/internal/httpd/controllers/projects.go +++ b/backend/internal/httpd/controllers/projects.go @@ -14,13 +14,13 @@ 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" + "github.com/aoagents/agent-orchestrator/backend/internal/service" ) // ProjectsController owns the /projects routes. The controller depends only on -// project.Manager; nil keeps routes registered but returns OpenAPI-backed 501s. +// service.ProjectManager; nil keeps routes registered but returns OpenAPI-backed 501s. type ProjectsController struct { - Mgr project.Manager + Mgr service.ProjectManager } // Register mounts the project routes on the supplied router. @@ -42,7 +42,7 @@ func (c *ProjectsController) list(w http.ResponseWriter, r *http.Request) { return } if projects == nil { - projects = []project.Summary{} + projects = []service.ProjectSummary{} } envelope.WriteJSON(w, http.StatusOK, ListProjectsResponse{Projects: projects}) } @@ -52,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 service.AddProjectInput if err := decodeJSON(r, &in); err != nil { envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) return @@ -104,10 +104,10 @@ func decodeJSON(r *http.Request, out any) error { return json.NewDecoder(r.Body).Decode(out) } -// 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 service.ProjectError to its HTTP status, falling back to +// 500 for an unrecognized kind or a non-service.ProjectError. func writeProjectError(w http.ResponseWriter, r *http.Request, err error) { - var pe *project.Error + var pe *service.ProjectError if errors.As(err, &pe) { status := http.StatusInternalServerError switch pe.Kind { diff --git a/backend/internal/httpd/controllers/projects_test.go b/backend/internal/httpd/controllers/projects_test.go index c5fbb12a..6d3be5fd 100644 --- a/backend/internal/httpd/controllers/projects_test.go +++ b/backend/internal/httpd/controllers/projects_test.go @@ -29,7 +29,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/httpd" - "github.com/aoagents/agent-orchestrator/backend/internal/project" + "github.com/aoagents/agent-orchestrator/backend/internal/service" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) @@ -40,11 +40,11 @@ import ( // clean 500 before writing the 200 status. -type emptyGetManager struct{ project.Manager } +type emptyGetManager struct{ service.ProjectManager } -func (emptyGetManager) Get(context.Context, domain.ProjectID) (project.GetResult, error) { +func (emptyGetManager) Get(context.Context, domain.ProjectID) (service.GetProjectResult, error) { - return project.GetResult{}, nil + return service.GetProjectResult{}, nil } @@ -91,7 +91,7 @@ func newTestServer(t *testing.T) *httptest.Server { srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{ - Projects: project.NewManager(store), + Projects: service.NewProject(store), })) t.Cleanup(srv.Close) diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index 6beccf8e..1560c7cf 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -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 @@ -219,7 +200,9 @@ 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 { diff --git a/backend/internal/integration/lifecycle_sqlite_test.go b/backend/internal/integration/lifecycle_sqlite_test.go index b8d36332..ca171864 100644 --- a/backend/internal/integration/lifecycle_sqlite_test.go +++ b/backend/internal/integration/lifecycle_sqlite_test.go @@ -10,7 +10,6 @@ import ( "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" sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" @@ -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{} diff --git a/backend/internal/project/dto.go b/backend/internal/project/dto.go deleted file mode 100644 index 9975a120..00000000 --- a/backend/internal/project/dto.go +++ /dev/null @@ -1,35 +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"` -} - -// 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"` -} diff --git a/backend/internal/project/errors.go b/backend/internal/project/errors.go deleted file mode 100644 index c4e29d8c..00000000 --- a/backend/internal/project/errors.go +++ /dev/null @@ -1,37 +0,0 @@ -package project - -// Error is the manager-level error shape controllers can translate into the -// locked HTTP APIError envelope without knowing store internals. -type Error struct { - Kind string - Code string - Message string - Details map[string]any -} - -func (e *Error) Error() string { - if e == nil { - return "" - } - return e.Message -} - -func newError(kind, code, message string, details map[string]any) *Error { - return &Error{Kind: kind, Code: code, Message: message, Details: details} -} - -func badRequest(code, message string, details map[string]any) *Error { - return newError("bad_request", code, message, details) -} - -func notFound(code, message string) *Error { - return newError("not_found", code, message, nil) -} - -func conflict(code, message string, details map[string]any) *Error { - return newError("conflict", code, message, details) -} - -func internal(code, message string) *Error { - return newError("internal", code, message, nil) -} diff --git a/backend/internal/project/store.go b/backend/internal/project/store.go deleted file mode 100644 index 015cf08f..00000000 --- a/backend/internal/project/store.go +++ /dev/null @@ -1,32 +0,0 @@ -package project - -import ( - "context" - "time" -) - -// Row mirrors the project table shape from the sqlite storage layer. The manager -// consumes rows through the Store port below; the sqlite store returns this same -// shape, so the API layer never depends on a richer model than the DB provides. -type Row struct { - ID string - Path string - RepoOriginURL string - DisplayName string - RegisteredAt time.Time - ArchivedAt time.Time -} - -// Store is the project persistence port the manager talks to. It exists to -// invert the dependency: the storage layer imports this package (for Row), so -// the manager reaches the backend through this interface rather than importing -// the concrete *sqlite.Store — which would create an import cycle -// (project → sqlite → store → project). The real *sqlite.Store satisfies it; -// tests pass a real temp-dir sqlite store. There is no in-memory implementation. -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) -} diff --git a/backend/internal/project/manager.go b/backend/internal/service/project.go similarity index 50% rename from backend/internal/project/manager.go rename to backend/internal/service/project.go index c7a73b2d..fa452e52 100644 --- a/backend/internal/project/manager.go +++ b/backend/internal/service/project.go @@ -1,6 +1,4 @@ -// Package project owns the projects service contract: the Manager interface, -// its implementation, and the request/response DTOs that cross it. -package project +package service import ( "context" @@ -15,45 +13,44 @@ import ( "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 { +// ProjectManager is the controller-facing contract for the /api/v1/projects surface. +type ProjectManager 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) + List(ctx context.Context) ([]ProjectSummary, error) - // Get returns one project, discriminating ok vs degraded via GetResult. - Get(ctx context.Context, id domain.ProjectID) (GetResult, error) + // Get returns one project, discriminating ok vs degraded via GetProjectResult. + Get(ctx context.Context, id domain.ProjectID) (GetProjectResult, error) // Add registers a new project from a git repository path. - Add(ctx context.Context, in AddInput) (Project, error) + Add(ctx context.Context, in AddProjectInput) (Project, error) // Remove unregisters a project, stopping its sessions and reclaiming // managed workspaces. - Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) + Remove(ctx context.Context, id domain.ProjectID) (RemoveProjectResult, error) } -type manager struct { - store Store +// ProjectService implements project registration and lookup use-cases for controllers. +type ProjectService struct { + store ProjectStore } -var _ Manager = (*manager)(nil) +var _ ProjectManager = (*ProjectService)(nil) -// NewManager returns a project Manager backed by the given Store — the durable -// sqlite store in the daemon, a real temp-dir sqlite store in tests. store must -// be non-nil; there is no in-memory fallback. -func NewManager(store Store) Manager { - return &manager{store: store} +// NewProject returns a project service backed by the given durable store. +func NewProject(store ProjectStore) *ProjectService { + return &ProjectService{store: store} } -func (m *manager) List(ctx context.Context) ([]Summary, error) { - projects, err := m.store.List(ctx) +// List returns every active registered project. +func (m *ProjectService) List(ctx context.Context) ([]ProjectSummary, error) { + projects, err := m.store.ListProjects(ctx) if err != nil { - return nil, internal("PROJECTS_LIST_FAILED", "Failed to load projects") + return nil, projectInternal("PROJECTS_LIST_FAILED", "Failed to load projects") } - out := make([]Summary, 0, len(projects)) + out := make([]ProjectSummary, 0, len(projects)) for _, row := range projects { - out = append(out, Summary{ + out = append(out, ProjectSummary{ ID: domain.ProjectID(row.ID), Name: displayName(row), SessionPrefix: sessionPrefix(row.ID), @@ -62,28 +59,30 @@ 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 *ProjectService) Get(ctx context.Context, id domain.ProjectID) (GetProjectResult, error) { if err := validateProjectID(id); err != nil { - return GetResult{}, err + return GetProjectResult{}, 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") + return GetProjectResult{}, projectInternal("PROJECT_LOAD_FAILED", "Failed to load project") } if !ok || !row.ArchivedAt.IsZero() { - return GetResult{}, notFound("PROJECT_NOT_FOUND", "Unknown project") + return GetProjectResult{}, projectNotFound("PROJECT_NOT_FOUND", "Unknown project") } p := projectFromRow(row) - return GetResult{Status: "ok", Project: &p}, nil + return GetProjectResult{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 *ProjectService) Add(ctx context.Context, in AddProjectInput) (Project, error) { path, err := normalizePath(in.Path) if err != nil { return Project{}, err } if !isGitRepo(path) { - return Project{}, badRequest("NOT_A_GIT_REPO", "Repository path must point to a git repository", nil) + return Project{}, projectBadRequest("NOT_A_GIT_REPO", "Repository path must point to a git repository", nil) } id := defaultProjectID(path) @@ -102,59 +101,60 @@ 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 { - return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") + if existing, ok, err := m.store.FindProjectByPath(ctx, path); err != nil { + return Project{}, projectInternal("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{ + return Project{}, projectConflict("PATH_ALREADY_REGISTERED", "A project at this path is already registered", map[string]any{ "existingProjectId": existing.ID, "suggestedProjectId": string(m.suggestID(ctx, id)), }) } - if existing, ok, err := m.store.Get(ctx, string(id)); err != nil { - return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") + if existing, ok, err := m.store.GetProject(ctx, string(id)); err != nil { + return Project{}, projectInternal("PROJECT_LOAD_FAILED", "Failed to load project") } else if ok && existing.Path != path { - return Project{}, conflict("ID_ALREADY_REGISTERED", "A project with this id is already registered for a different path", map[string]any{ + return Project{}, projectConflict("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) Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) { +// Remove archives a project registration. +func (m *ProjectService) Remove(ctx context.Context, id domain.ProjectID) (RemoveProjectResult, error) { if err := validateProjectID(id); err != nil { - return RemoveResult{}, err + return RemoveProjectResult{}, 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") + return RemoveProjectResult{}, projectInternal("PROJECT_REMOVE_FAILED", "Failed to remove project") } if !ok { - return RemoveResult{}, notFound("PROJECT_NOT_FOUND", "Unknown project") + return RemoveProjectResult{}, projectNotFound("PROJECT_NOT_FOUND", "Unknown project") } - return RemoveResult{ProjectID: id, RemovedStorageDir: false}, nil + return RemoveProjectResult{ProjectID: id, RemovedStorageDir: false}, nil } -func (m *manager) suggestID(ctx context.Context, base domain.ProjectID) domain.ProjectID { +func (m *ProjectService) 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), @@ -164,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 } @@ -174,12 +174,12 @@ func displayName(row Row) string { func normalizePath(raw string) (string, error) { raw = strings.TrimSpace(raw) if raw == "" { - return "", badRequest("PATH_REQUIRED", "Repository path is required", nil) + return "", projectBadRequest("PATH_REQUIRED", "Repository path is required", nil) } if strings.HasPrefix(raw, "~") { home, err := os.UserHomeDir() if err != nil { - return "", badRequest("INVALID_PATH", "Repository path could not be expanded", nil) + return "", projectBadRequest("INVALID_PATH", "Repository path could not be expanded", nil) } if raw == "~" { raw = home @@ -189,7 +189,7 @@ func normalizePath(raw string) (string, error) { } abs, err := filepath.Abs(raw) if err != nil { - return "", badRequest("INVALID_PATH", "Repository path is invalid", nil) + return "", projectBadRequest("INVALID_PATH", "Repository path is invalid", nil) } return filepath.Clean(abs), nil } @@ -229,7 +229,7 @@ var projectIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) func validateProjectID(id domain.ProjectID) error { raw := string(id) if raw == "" || raw == "." || raw == ".." || strings.ContainsAny(raw, `/\`) || !projectIDPattern.MatchString(raw) { - return badRequest("INVALID_PROJECT_ID", "Project id failed storage-path validation", nil) + return projectBadRequest("INVALID_PROJECT_ID", "Project id failed storage-path validation", nil) } return nil } diff --git a/backend/internal/service/project_dto.go b/backend/internal/service/project_dto.go new file mode 100644 index 00000000..f2b6dade --- /dev/null +++ b/backend/internal/service/project_dto.go @@ -0,0 +1,23 @@ +package service + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// GetProjectResult is the discriminated result returned by ProjectService.Get. +type GetProjectResult struct { + Status string + Project *Project + Degraded *DegradedProject +} + +// AddProjectInput is the body shape for POST /api/v1/projects. +type AddProjectInput struct { + Path string `json:"path"` + ProjectID *string `json:"projectId,omitempty"` + Name *string `json:"name,omitempty"` +} + +// RemoveProjectResult reports what DELETE /api/v1/projects/{id} actually did. +type RemoveProjectResult struct { + ProjectID domain.ProjectID `json:"projectId"` + RemovedStorageDir bool `json:"removedStorageDir"` +} diff --git a/backend/internal/service/project_errors.go b/backend/internal/service/project_errors.go new file mode 100644 index 00000000..145d9efb --- /dev/null +++ b/backend/internal/service/project_errors.go @@ -0,0 +1,37 @@ +package service + +// ProjectError is the service-level error shape controllers translate into the +// locked HTTP APIError envelope without knowing store internals. +type ProjectError struct { + Kind string + Code string + Message string + Details map[string]any +} + +func (e *ProjectError) Error() string { + if e == nil { + return "" + } + return e.Message +} + +func newProjectError(kind, code, message string, details map[string]any) *ProjectError { + return &ProjectError{Kind: kind, Code: code, Message: message, Details: details} +} + +func projectBadRequest(code, message string, details map[string]any) *ProjectError { + return newProjectError("bad_request", code, message, details) +} + +func projectNotFound(code, message string) *ProjectError { + return newProjectError("not_found", code, message, nil) +} + +func projectConflict(code, message string, details map[string]any) *ProjectError { + return newProjectError("conflict", code, message, details) +} + +func projectInternal(code, message string) *ProjectError { + return newProjectError("internal", code, message, nil) +} diff --git a/backend/internal/service/project_store.go b/backend/internal/service/project_store.go new file mode 100644 index 00000000..d10757f0 --- /dev/null +++ b/backend/internal/service/project_store.go @@ -0,0 +1,17 @@ +package service + +import ( + "context" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// ProjectStore is the durable project persistence surface required by ProjectService. +type ProjectStore 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) +} diff --git a/backend/internal/project/manager_test.go b/backend/internal/service/project_test.go similarity index 72% rename from backend/internal/project/manager_test.go rename to backend/internal/service/project_test.go index 1328cf7e..dcf7c718 100644 --- a/backend/internal/project/manager_test.go +++ b/backend/internal/service/project_test.go @@ -1,4 +1,4 @@ -package project_test +package service_test import ( "context" @@ -7,20 +7,20 @@ import ( "testing" "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/project" + "github.com/aoagents/agent-orchestrator/backend/internal/service" "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 { +func newManager(t *testing.T) service.ProjectManager { t.Helper() store, err := sqlite.Open(t.TempDir()) if err != nil { t.Fatalf("open store: %v", err) } t.Cleanup(func() { _ = store.Close() }) - return project.NewManager(store) + return service.NewProject(store) } // gitRepo creates a real git repository in a fresh temp dir and returns its path. @@ -35,12 +35,12 @@ func gitRepo(t *testing.T) string { func ptr(s string) *string { return &s } -// wantCode asserts err is a *project.Error carrying the given machine code. +// wantCode asserts err is a *service.ProjectError carrying the given machine code. func wantCode(t *testing.T, err error, code string) { t.Helper() - var e *project.Error + var e *service.ProjectError if !errors.As(err, &e) { - t.Fatalf("error = %v, want *project.Error", err) + t.Fatalf("error = %v, want *service.ProjectError", err) } if e.Code != code { t.Fatalf("code = %q, want %q", e.Code, code) @@ -56,7 +56,7 @@ func TestManager_AddListGetRemove(t *testing.T) { 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")}) + proj, err := m.Add(ctx, service.AddProjectInput{Path: repo, ProjectID: ptr("ao"), Name: ptr("Agent Orchestrator")}) if err != nil { t.Fatalf("Add: %v", err) } @@ -96,13 +96,13 @@ func TestManager_ReaddAfterRemove(t *testing.T) { m := newManager(t) repo := gitRepo(t) - if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao")}); err != nil { + if _, err := m.Add(ctx, service.AddProjectInput{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 { + if _, err := m.Add(ctx, service.AddProjectInput{Path: repo, ProjectID: ptr("ao2")}); err != nil { t.Fatalf("re-add after remove: %v", err) } } @@ -111,20 +111,20 @@ func TestManager_AddValidationAndConflicts(t *testing.T) { ctx := context.Background() m := newManager(t) - _, err := m.Add(ctx, project.AddInput{Path: ""}) + _, err := m.Add(ctx, service.AddProjectInput{Path: ""}) wantCode(t, err, "PATH_REQUIRED") - _, err = m.Add(ctx, project.AddInput{Path: t.TempDir()}) // exists but not a git repo + _, err = m.Add(ctx, service.AddProjectInput{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 { + if _, err := m.Add(ctx, service.AddProjectInput{Path: repoA, ProjectID: ptr("shared")}); err != nil { t.Fatalf("seed add: %v", err) } - _, err = m.Add(ctx, project.AddInput{Path: repoA, ProjectID: ptr("other")}) + _, err = m.Add(ctx, service.AddProjectInput{Path: repoA, ProjectID: ptr("other")}) wantCode(t, err, "PATH_ALREADY_REGISTERED") - _, err = m.Add(ctx, project.AddInput{Path: repoB, ProjectID: ptr("shared")}) + _, err = m.Add(ctx, service.AddProjectInput{Path: repoB, ProjectID: ptr("shared")}) wantCode(t, err, "ID_ALREADY_REGISTERED") } @@ -142,7 +142,7 @@ func TestManager_GetUpdateRemoveErrors(t *testing.T) { wantCode(t, err, "PROJECT_NOT_FOUND") repo := gitRepo(t) - if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("p")}); err != nil { + if _, err := m.Add(ctx, service.AddProjectInput{Path: repo, ProjectID: ptr("p")}); err != nil { t.Fatalf("seed: %v", err) } } diff --git a/backend/internal/project/types.go b/backend/internal/service/project_types.go similarity index 52% rename from backend/internal/project/types.go rename to backend/internal/service/project_types.go index 9e1e8b94..afd12b90 100644 --- a/backend/internal/project/types.go +++ b/backend/internal/service/project_types.go @@ -1,62 +1,43 @@ -package project +package service 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. -type Summary struct { +// ProjectSummary is the row shape returned by GET /api/v1/projects. +type ProjectSummary struct { ID domain.ProjectID `json:"id"` Name string `json:"name"` SessionPrefix string `json:"sessionPrefix"` 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). -type Degraded struct { +// DegradedProject is returned in place of Project when project config failed to load. +type DegradedProject struct { ID domain.ProjectID `json:"id"` Name string `json:"name"` Path string `json:"path"` 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"` diff --git a/backend/internal/storage/sqlite/store/project_store.go b/backend/internal/storage/sqlite/store/project_store.go index 1d216d3e..932205a8 100644 --- a/backend/internal/storage/sqlite/store/project_store.go +++ b/backend/internal/storage/sqlite/store/project_store.go @@ -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, diff --git a/backend/internal/storage/sqlite/store/store_test.go b/backend/internal/storage/sqlite/store/store_test.go index 9befe1c2..7731e5ca 100644 --- a/backend/internal/storage/sqlite/store/store_test.go +++ b/backend/internal/storage/sqlite/store/store_test.go @@ -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") } } From 77e233cfe3f5018f7d3690767776033dd5c9fd83 Mon Sep 17 00:00:00 2001 From: itrytoohard Date: Tue, 2 Jun 2026 00:12:03 +0530 Subject: [PATCH 11/13] refactor: split service package by resource (#68) --- backend/internal/daemon/daemon.go | 4 +- backend/internal/httpd/api.go | 4 +- .../internal/httpd/apispec/specgen/build.go | 24 +++--- backend/internal/httpd/controllers/dto.go | 16 ++-- .../internal/httpd/controllers/projects.go | 16 ++-- .../httpd/controllers/projects_test.go | 10 +-- .../internal/httpd/controllers/sessions.go | 16 ++-- .../httpd/controllers/sessions_test.go | 4 +- .../integration/lifecycle_sqlite_test.go | 6 +- backend/internal/service/project/dto.go | 23 ++++++ backend/internal/service/project/errors.go | 37 +++++++++ .../{project.go => project/service.go} | 80 +++++++++---------- .../service_test.go} | 32 ++++---- .../{project_store.go => project/store.go} | 6 +- .../{project_types.go => project/types.go} | 10 +-- backend/internal/service/project_dto.go | 23 ------ backend/internal/service/project_errors.go | 37 --------- .../{session.go => session/service.go} | 42 +++++----- .../service_test.go} | 22 ++--- .../{session_status.go => session/status.go} | 2 +- .../status_test.go} | 2 +- 21 files changed, 208 insertions(+), 208 deletions(-) create mode 100644 backend/internal/service/project/dto.go create mode 100644 backend/internal/service/project/errors.go rename backend/internal/service/{project.go => project/service.go} (60%) rename backend/internal/service/{project_test.go => project/service_test.go} (74%) rename backend/internal/service/{project_store.go => project/store.go} (78%) rename backend/internal/service/{project_types.go => project/types.go} (89%) delete mode 100644 backend/internal/service/project_dto.go delete mode 100644 backend/internal/service/project_errors.go rename backend/internal/service/{session.go => session/service.go} (73%) rename backend/internal/service/{session_test.go => session/service_test.go} (54%) rename backend/internal/service/{session_status.go => session/status.go} (98%) rename backend/internal/service/{session_status_test.go => session/status_test.go} (99%) diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index ea8e842a..59926922 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -15,7 +15,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/httpd" "github.com/aoagents/agent-orchestrator/backend/internal/runfile" - "github.com/aoagents/agent-orchestrator/backend/internal/service" + 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: service.NewProject(store)}) + srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{Projects: projectsvc.New(store)}) if err != nil { stop() if cdcErr := cdcPipe.Stop(); cdcErr != nil { diff --git a/backend/internal/httpd/api.go b/backend/internal/httpd/api.go index 172ed256..b1741972 100644 --- a/backend/internal/httpd/api.go +++ b/backend/internal/httpd/api.go @@ -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/service" + 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 service.ProjectManager + Projects projectsvc.Manager Sessions controllers.SessionService } diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 1da55c55..f3cc8f43 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -17,7 +17,7 @@ import ( "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/service" + projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" ) // Build reflects the Go contract types and the operation registry below into @@ -133,15 +133,15 @@ var schemaNames = map[string]string{ "ControllersSpawnOrchestratorRequest": "SpawnOrchestratorRequest", "ControllersSpawnOrchestratorResponse": "SpawnOrchestratorResponse", "ControllersOrchestratorResponse": "OrchestratorResponse", - // service project entities + DTOs - "ServiceProject": "Project", - "ServiceProjectSummary": "ProjectSummary", - "ServiceDegradedProject": "DegradedProject", - "ServiceAddProjectInput": "AddProjectInput", - "ServiceRemoveProjectResult": "RemoveProjectResult", - "ServiceTrackerConfig": "TrackerConfig", - "ServiceSCMConfig": "SCMConfig", - "ServiceSCMWebhookConfig": "SCMWebhookConfig", + // 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 @@ -235,7 +235,7 @@ func projectOperations() []operation { { method: http.MethodPost, path: "/api/v1/projects", id: "addProject", tag: "projects", summary: "Register a new project from a git repository path", - reqBody: service.AddProjectInput{}, + reqBody: projectsvc.AddInput{}, resps: []respUnit{ {http.StatusCreated, controllers.ProjectResponse{}}, {http.StatusBadRequest, envelope.APIError{}}, @@ -258,7 +258,7 @@ func projectOperations() []operation { summary: "Remove a project; stops sessions, cleans workspaces, unregisters", pathParams: []any{controllers.ProjectIDParam{}}, resps: []respUnit{ - {http.StatusOK, service.RemoveProjectResult{}}, + {http.StatusOK, projectsvc.RemoveResult{}}, {http.StatusBadRequest, envelope.APIError{}}, {http.StatusNotFound, envelope.APIError{}}, {http.StatusInternalServerError, envelope.APIError{}}, diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index b101a0a7..22d8f42e 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -5,7 +5,7 @@ import ( "errors" "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/service" + projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" ) // HTTP response envelopes for the projects surface — the SINGLE definition of @@ -13,7 +13,7 @@ import ( // 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 -// (service.AddProjectInput), which apispec also reflects. +// (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 @@ -25,12 +25,12 @@ type ProjectIDParam struct { // ListProjectsResponse is the body of GET /api/v1/projects. type ListProjectsResponse struct { - Projects []service.ProjectSummary `json:"projects"` + Projects []projectsvc.Summary `json:"projects"` } // ProjectResponse is the { project } body shared by POST /projects (201). type ProjectResponse struct { - Project service.Project `json:"project"` + Project projectsvc.Project `json:"project"` } // GetProjectResponse is the { status, project } body of GET /projects/{id}, @@ -45,8 +45,8 @@ type GetProjectResponse struct { // 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 *service.Project - Degraded *service.DegradedProject + Project *projectsvc.Project + Degraded *projectsvc.Degraded } // MarshalJSON encodes whichever variant is set (Project or Degraded). @@ -75,14 +75,14 @@ var errEmptyProjectOrDegraded = errors.New("controllers: GetResult has neither P // 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{}{service.Project{}, service.DegradedProject{}} + 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 service.GetProjectResult) (GetProjectResponse, error) { +func newGetProjectResponse(res projectsvc.GetResult) (GetProjectResponse, error) { if res.Project == nil && res.Degraded == nil { return GetProjectResponse{}, errEmptyProjectOrDegraded } diff --git a/backend/internal/httpd/controllers/projects.go b/backend/internal/httpd/controllers/projects.go index a1886f74..a23d9dff 100644 --- a/backend/internal/httpd/controllers/projects.go +++ b/backend/internal/httpd/controllers/projects.go @@ -14,13 +14,13 @@ 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/service" + projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" ) // ProjectsController owns the /projects routes. The controller depends only on -// service.ProjectManager; nil keeps routes registered but returns OpenAPI-backed 501s. +// projectsvc.Manager; nil keeps routes registered but returns OpenAPI-backed 501s. type ProjectsController struct { - Mgr service.ProjectManager + Mgr projectsvc.Manager } // Register mounts the project routes on the supplied router. @@ -42,7 +42,7 @@ func (c *ProjectsController) list(w http.ResponseWriter, r *http.Request) { return } if projects == nil { - projects = []service.ProjectSummary{} + projects = []projectsvc.Summary{} } envelope.WriteJSON(w, http.StatusOK, ListProjectsResponse{Projects: projects}) } @@ -52,7 +52,7 @@ func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) { apispec.NotImplemented(w, r, "POST", "/api/v1/projects") return } - var in service.AddProjectInput + 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 @@ -104,10 +104,10 @@ func decodeJSON(r *http.Request, out any) error { return json.NewDecoder(r.Body).Decode(out) } -// writeProjectError maps a service.ProjectError to its HTTP status, falling back to -// 500 for an unrecognized kind or a non-service.ProjectError. +// 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 *service.ProjectError + var pe *projectsvc.Error if errors.As(err, &pe) { status := http.StatusInternalServerError switch pe.Kind { diff --git a/backend/internal/httpd/controllers/projects_test.go b/backend/internal/httpd/controllers/projects_test.go index 6d3be5fd..7de640ef 100644 --- a/backend/internal/httpd/controllers/projects_test.go +++ b/backend/internal/httpd/controllers/projects_test.go @@ -29,7 +29,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/httpd" - "github.com/aoagents/agent-orchestrator/backend/internal/service" + projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) @@ -40,11 +40,11 @@ import ( // clean 500 before writing the 200 status. -type emptyGetManager struct{ service.ProjectManager } +type emptyGetManager struct{ projectsvc.Manager } -func (emptyGetManager) Get(context.Context, domain.ProjectID) (service.GetProjectResult, error) { +func (emptyGetManager) Get(context.Context, domain.ProjectID) (projectsvc.GetResult, error) { - return service.GetProjectResult{}, nil + return projectsvc.GetResult{}, nil } @@ -91,7 +91,7 @@ func newTestServer(t *testing.T) *httptest.Server { srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{ - Projects: service.NewProject(store), + Projects: projectsvc.New(store), })) t.Cleanup(srv.Close) diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index 1560c7cf..c97f388a 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -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) @@ -183,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 @@ -209,27 +209,27 @@ 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 } diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go index 62ef4c8c..7ec882cc 100644 --- a/backend/internal/httpd/controllers/sessions_test.go +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -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 { diff --git a/backend/internal/integration/lifecycle_sqlite_test.go b/backend/internal/integration/lifecycle_sqlite_test.go index ca171864..b20000e4 100644 --- a/backend/internal/integration/lifecycle_sqlite_test.go +++ b/backend/internal/integration/lifecycle_sqlite_test.go @@ -10,7 +10,7 @@ import ( "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/service" + 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" ) @@ -54,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 @@ -79,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} } diff --git a/backend/internal/service/project/dto.go b/backend/internal/service/project/dto.go new file mode 100644 index 00000000..3d532932 --- /dev/null +++ b/backend/internal/service/project/dto.go @@ -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"` +} diff --git a/backend/internal/service/project/errors.go b/backend/internal/service/project/errors.go new file mode 100644 index 00000000..9b61c49f --- /dev/null +++ b/backend/internal/service/project/errors.go @@ -0,0 +1,37 @@ +package project + +// Error is the service-level error shape controllers translate into the +// locked HTTP APIError envelope without knowing store internals. +type Error struct { + Kind string + Code string + Message string + Details map[string]any +} + +func (e *Error) Error() string { + if e == nil { + return "" + } + return e.Message +} + +func newError(kind, code, message string, details map[string]any) *Error { + return &Error{Kind: kind, Code: code, Message: message, Details: details} +} + +func badRequest(code, message string, details map[string]any) *Error { + return newError("bad_request", code, message, details) +} + +func notFound(code, message string) *Error { + return newError("not_found", code, message, nil) +} + +func conflict(code, message string, details map[string]any) *Error { + return newError("conflict", code, message, details) +} + +func internal(code, message string) *Error { + return newError("internal", code, message, nil) +} diff --git a/backend/internal/service/project.go b/backend/internal/service/project/service.go similarity index 60% rename from backend/internal/service/project.go rename to backend/internal/service/project/service.go index fa452e52..3c4f05d9 100644 --- a/backend/internal/service/project.go +++ b/backend/internal/service/project/service.go @@ -1,4 +1,4 @@ -package service +package project import ( "context" @@ -13,44 +13,44 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) -// ProjectManager is the controller-facing contract for the /api/v1/projects surface. -type ProjectManager interface { +// 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) ([]ProjectSummary, error) + List(ctx context.Context) ([]Summary, error) - // Get returns one project, discriminating ok vs degraded via GetProjectResult. - Get(ctx context.Context, id domain.ProjectID) (GetProjectResult, 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 AddProjectInput) (Project, error) + 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) (RemoveProjectResult, error) + Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) } -// ProjectService implements project registration and lookup use-cases for controllers. -type ProjectService struct { - store ProjectStore +// Service implements project registration and lookup use-cases for controllers. +type Service struct { + store Store } -var _ ProjectManager = (*ProjectService)(nil) +var _ Manager = (*Service)(nil) -// NewProject returns a project service backed by the given durable store. -func NewProject(store ProjectStore) *ProjectService { - return &ProjectService{store: store} +// New returns a project service backed by the given durable store. +func New(store Store) *Service { + return &Service{store: store} } // List returns every active registered project. -func (m *ProjectService) List(ctx context.Context) ([]ProjectSummary, error) { +func (m *Service) List(ctx context.Context) ([]Summary, error) { projects, err := m.store.ListProjects(ctx) if err != nil { - return nil, projectInternal("PROJECTS_LIST_FAILED", "Failed to load projects") + return nil, internal("PROJECTS_LIST_FAILED", "Failed to load projects") } - out := make([]ProjectSummary, 0, len(projects)) + out := make([]Summary, 0, len(projects)) for _, row := range projects { - out = append(out, ProjectSummary{ + out = append(out, Summary{ ID: domain.ProjectID(row.ID), Name: displayName(row), SessionPrefix: sessionPrefix(row.ID), @@ -60,29 +60,29 @@ func (m *ProjectService) List(ctx context.Context) ([]ProjectSummary, error) { } // Get returns one active project by id. -func (m *ProjectService) Get(ctx context.Context, id domain.ProjectID) (GetProjectResult, error) { +func (m *Service) Get(ctx context.Context, id domain.ProjectID) (GetResult, error) { if err := validateProjectID(id); err != nil { - return GetProjectResult{}, err + return GetResult{}, err } row, ok, err := m.store.GetProject(ctx, string(id)) if err != nil { - return GetProjectResult{}, projectInternal("PROJECT_LOAD_FAILED", "Failed to load project") + return GetResult{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") } if !ok || !row.ArchivedAt.IsZero() { - return GetProjectResult{}, projectNotFound("PROJECT_NOT_FOUND", "Unknown project") + return GetResult{}, notFound("PROJECT_NOT_FOUND", "Unknown project") } p := projectFromRow(row) - return GetProjectResult{Status: "ok", Project: &p}, nil + return GetResult{Status: "ok", Project: &p}, nil } // Add registers a local git repository as a project. -func (m *ProjectService) Add(ctx context.Context, in AddProjectInput) (Project, error) { +func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { path, err := normalizePath(in.Path) if err != nil { return Project{}, err } if !isGitRepo(path) { - return Project{}, projectBadRequest("NOT_A_GIT_REPO", "Repository path must point to a git repository", nil) + return Project{}, badRequest("NOT_A_GIT_REPO", "Repository path must point to a git repository", nil) } id := defaultProjectID(path) @@ -102,17 +102,17 @@ func (m *ProjectService) Add(ctx context.Context, in AddProjectInput) (Project, } if existing, ok, err := m.store.FindProjectByPath(ctx, path); err != nil { - return Project{}, projectInternal("PROJECT_LOAD_FAILED", "Failed to load project") + return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") } else if ok { - return Project{}, projectConflict("PATH_ALREADY_REGISTERED", "A project at this path is already registered", map[string]any{ + return Project{}, conflict("PATH_ALREADY_REGISTERED", "A project at this path is already registered", map[string]any{ "existingProjectId": existing.ID, "suggestedProjectId": string(m.suggestID(ctx, id)), }) } if existing, ok, err := m.store.GetProject(ctx, string(id)); err != nil { - return Project{}, projectInternal("PROJECT_LOAD_FAILED", "Failed to load project") + return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") } else if ok && existing.Path != path { - return Project{}, projectConflict("ID_ALREADY_REGISTERED", "A project with this id is already registered for a different path", map[string]any{ + 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)), }) @@ -131,21 +131,21 @@ func (m *ProjectService) Add(ctx context.Context, in AddProjectInput) (Project, } // Remove archives a project registration. -func (m *ProjectService) Remove(ctx context.Context, id domain.ProjectID) (RemoveProjectResult, error) { +func (m *Service) Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) { if err := validateProjectID(id); err != nil { - return RemoveProjectResult{}, err + return RemoveResult{}, err } ok, err := m.store.ArchiveProject(ctx, string(id), time.Now()) if err != nil { - return RemoveProjectResult{}, projectInternal("PROJECT_REMOVE_FAILED", "Failed to remove project") + return RemoveResult{}, internal("PROJECT_REMOVE_FAILED", "Failed to remove project") } if !ok { - return RemoveProjectResult{}, projectNotFound("PROJECT_NOT_FOUND", "Unknown project") + return RemoveResult{}, notFound("PROJECT_NOT_FOUND", "Unknown project") } - return RemoveProjectResult{ProjectID: id, RemovedStorageDir: false}, nil + return RemoveResult{ProjectID: id, RemovedStorageDir: false}, nil } -func (m *ProjectService) 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.GetProject(ctx, string(candidate)); !ok { @@ -174,12 +174,12 @@ func displayName(row domain.ProjectRecord) string { func normalizePath(raw string) (string, error) { raw = strings.TrimSpace(raw) if raw == "" { - return "", projectBadRequest("PATH_REQUIRED", "Repository path is required", nil) + return "", badRequest("PATH_REQUIRED", "Repository path is required", nil) } if strings.HasPrefix(raw, "~") { home, err := os.UserHomeDir() if err != nil { - return "", projectBadRequest("INVALID_PATH", "Repository path could not be expanded", nil) + return "", badRequest("INVALID_PATH", "Repository path could not be expanded", nil) } if raw == "~" { raw = home @@ -189,7 +189,7 @@ func normalizePath(raw string) (string, error) { } abs, err := filepath.Abs(raw) if err != nil { - return "", projectBadRequest("INVALID_PATH", "Repository path is invalid", nil) + return "", badRequest("INVALID_PATH", "Repository path is invalid", nil) } return filepath.Clean(abs), nil } @@ -229,7 +229,7 @@ var projectIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) func validateProjectID(id domain.ProjectID) error { raw := string(id) if raw == "" || raw == "." || raw == ".." || strings.ContainsAny(raw, `/\`) || !projectIDPattern.MatchString(raw) { - return projectBadRequest("INVALID_PROJECT_ID", "Project id failed storage-path validation", nil) + return badRequest("INVALID_PROJECT_ID", "Project id failed storage-path validation", nil) } return nil } diff --git a/backend/internal/service/project_test.go b/backend/internal/service/project/service_test.go similarity index 74% rename from backend/internal/service/project_test.go rename to backend/internal/service/project/service_test.go index dcf7c718..37d3af4c 100644 --- a/backend/internal/service/project_test.go +++ b/backend/internal/service/project/service_test.go @@ -1,4 +1,4 @@ -package service_test +package project_test import ( "context" @@ -7,20 +7,20 @@ import ( "testing" "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/service" + "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) service.ProjectManager { +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 service.NewProject(store) + return project.New(store) } // gitRepo creates a real git repository in a fresh temp dir and returns its path. @@ -35,12 +35,12 @@ func gitRepo(t *testing.T) string { func ptr(s string) *string { return &s } -// wantCode asserts err is a *service.ProjectError carrying the given machine code. +// 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 *service.ProjectError + var e *project.Error if !errors.As(err, &e) { - t.Fatalf("error = %v, want *service.ProjectError", err) + t.Fatalf("error = %v, want *project.Error", err) } if e.Code != code { t.Fatalf("code = %q, want %q", e.Code, code) @@ -56,7 +56,7 @@ func TestManager_AddListGetRemove(t *testing.T) { t.Fatalf("List() = %v, %v; want empty", got, err) } - proj, err := m.Add(ctx, service.AddProjectInput{Path: repo, ProjectID: ptr("ao"), Name: ptr("Agent Orchestrator")}) + proj, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao"), Name: ptr("Agent Orchestrator")}) if err != nil { t.Fatalf("Add: %v", err) } @@ -96,13 +96,13 @@ func TestManager_ReaddAfterRemove(t *testing.T) { m := newManager(t) repo := gitRepo(t) - if _, err := m.Add(ctx, service.AddProjectInput{Path: repo, ProjectID: ptr("ao")}); err != nil { + 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, service.AddProjectInput{Path: repo, ProjectID: ptr("ao2")}); err != nil { + if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao2")}); err != nil { t.Fatalf("re-add after remove: %v", err) } } @@ -111,20 +111,20 @@ func TestManager_AddValidationAndConflicts(t *testing.T) { ctx := context.Background() m := newManager(t) - _, err := m.Add(ctx, service.AddProjectInput{Path: ""}) + _, err := m.Add(ctx, project.AddInput{Path: ""}) wantCode(t, err, "PATH_REQUIRED") - _, err = m.Add(ctx, service.AddProjectInput{Path: t.TempDir()}) // exists but not a git repo + _, 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, service.AddProjectInput{Path: repoA, ProjectID: ptr("shared")}); err != nil { + if _, err := m.Add(ctx, project.AddInput{Path: repoA, ProjectID: ptr("shared")}); err != nil { t.Fatalf("seed add: %v", err) } - _, err = m.Add(ctx, service.AddProjectInput{Path: repoA, ProjectID: ptr("other")}) + _, err = m.Add(ctx, project.AddInput{Path: repoA, ProjectID: ptr("other")}) wantCode(t, err, "PATH_ALREADY_REGISTERED") - _, err = m.Add(ctx, service.AddProjectInput{Path: repoB, ProjectID: ptr("shared")}) + _, err = m.Add(ctx, project.AddInput{Path: repoB, ProjectID: ptr("shared")}) wantCode(t, err, "ID_ALREADY_REGISTERED") } @@ -142,7 +142,7 @@ func TestManager_GetUpdateRemoveErrors(t *testing.T) { wantCode(t, err, "PROJECT_NOT_FOUND") repo := gitRepo(t) - if _, err := m.Add(ctx, service.AddProjectInput{Path: repo, ProjectID: ptr("p")}); err != nil { + if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("p")}); err != nil { t.Fatalf("seed: %v", err) } } diff --git a/backend/internal/service/project_store.go b/backend/internal/service/project/store.go similarity index 78% rename from backend/internal/service/project_store.go rename to backend/internal/service/project/store.go index d10757f0..504179c8 100644 --- a/backend/internal/service/project_store.go +++ b/backend/internal/service/project/store.go @@ -1,4 +1,4 @@ -package service +package project import ( "context" @@ -7,8 +7,8 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) -// ProjectStore is the durable project persistence surface required by ProjectService. -type ProjectStore interface { +// 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) diff --git a/backend/internal/service/project_types.go b/backend/internal/service/project/types.go similarity index 89% rename from backend/internal/service/project_types.go rename to backend/internal/service/project/types.go index afd12b90..618500b4 100644 --- a/backend/internal/service/project_types.go +++ b/backend/internal/service/project/types.go @@ -1,9 +1,9 @@ -package service +package project import "github.com/aoagents/agent-orchestrator/backend/internal/domain" -// ProjectSummary is the row shape returned by GET /api/v1/projects. -type ProjectSummary struct { +// Summary is the row shape returned by GET /api/v1/projects. +type Summary struct { ID domain.ProjectID `json:"id"` Name string `json:"name"` SessionPrefix string `json:"sessionPrefix"` @@ -22,8 +22,8 @@ type Project struct { SCM *SCMConfig `json:"scm,omitempty"` } -// DegradedProject is returned in place of Project when project config failed to load. -type DegradedProject struct { +// 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"` Path string `json:"path"` diff --git a/backend/internal/service/project_dto.go b/backend/internal/service/project_dto.go deleted file mode 100644 index f2b6dade..00000000 --- a/backend/internal/service/project_dto.go +++ /dev/null @@ -1,23 +0,0 @@ -package service - -import "github.com/aoagents/agent-orchestrator/backend/internal/domain" - -// GetProjectResult is the discriminated result returned by ProjectService.Get. -type GetProjectResult struct { - Status string - Project *Project - Degraded *DegradedProject -} - -// AddProjectInput is the body shape for POST /api/v1/projects. -type AddProjectInput struct { - Path string `json:"path"` - ProjectID *string `json:"projectId,omitempty"` - Name *string `json:"name,omitempty"` -} - -// RemoveProjectResult reports what DELETE /api/v1/projects/{id} actually did. -type RemoveProjectResult struct { - ProjectID domain.ProjectID `json:"projectId"` - RemovedStorageDir bool `json:"removedStorageDir"` -} diff --git a/backend/internal/service/project_errors.go b/backend/internal/service/project_errors.go deleted file mode 100644 index 145d9efb..00000000 --- a/backend/internal/service/project_errors.go +++ /dev/null @@ -1,37 +0,0 @@ -package service - -// ProjectError is the service-level error shape controllers translate into the -// locked HTTP APIError envelope without knowing store internals. -type ProjectError struct { - Kind string - Code string - Message string - Details map[string]any -} - -func (e *ProjectError) Error() string { - if e == nil { - return "" - } - return e.Message -} - -func newProjectError(kind, code, message string, details map[string]any) *ProjectError { - return &ProjectError{Kind: kind, Code: code, Message: message, Details: details} -} - -func projectBadRequest(code, message string, details map[string]any) *ProjectError { - return newProjectError("bad_request", code, message, details) -} - -func projectNotFound(code, message string) *ProjectError { - return newProjectError("not_found", code, message, nil) -} - -func projectConflict(code, message string, details map[string]any) *ProjectError { - return newProjectError("conflict", code, message, details) -} - -func projectInternal(code, message string) *ProjectError { - return newProjectError("internal", code, message, nil) -} diff --git a/backend/internal/service/session.go b/backend/internal/service/session/service.go similarity index 73% rename from backend/internal/service/session.go rename to backend/internal/service/session/service.go index fae8d76b..fe2e63e7 100644 --- a/backend/internal/service/session.go +++ b/backend/internal/service/session/service.go @@ -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) diff --git a/backend/internal/service/session_test.go b/backend/internal/service/session/service_test.go similarity index 54% rename from backend/internal/service/session_test.go rename to backend/internal/service/session/service_test.go index f092dc19..25c9610f 100644 --- a/backend/internal/service/session_test.go +++ b/backend/internal/service/session/service_test.go @@ -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) } diff --git a/backend/internal/service/session_status.go b/backend/internal/service/session/status.go similarity index 98% rename from backend/internal/service/session_status.go rename to backend/internal/service/session/status.go index 801b7b19..ee929852 100644 --- a/backend/internal/service/session_status.go +++ b/backend/internal/service/session/status.go @@ -1,4 +1,4 @@ -package service +package session import "github.com/aoagents/agent-orchestrator/backend/internal/domain" diff --git a/backend/internal/service/session_status_test.go b/backend/internal/service/session/status_test.go similarity index 99% rename from backend/internal/service/session_status_test.go rename to backend/internal/service/session/status_test.go index b45125e1..3543214b 100644 --- a/backend/internal/service/session_status_test.go +++ b/backend/internal/service/session/status_test.go @@ -1,4 +1,4 @@ -package service +package session import ( "testing" From 356a23c0ba4225838a3d8dab6e469d493f512310 Mon Sep 17 00:00:00 2001 From: itrytoohard Date: Tue, 2 Jun 2026 00:19:28 +0530 Subject: [PATCH 12/13] fix: ignore archived project id conflicts (#68) --- backend/internal/service/project/service.go | 2 +- backend/internal/service/project/service_test.go | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/project/service.go b/backend/internal/service/project/service.go index 3c4f05d9..0eb213ab 100644 --- a/backend/internal/service/project/service.go +++ b/backend/internal/service/project/service.go @@ -111,7 +111,7 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { } 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)), diff --git a/backend/internal/service/project/service_test.go b/backend/internal/service/project/service_test.go index 37d3af4c..d8ae22ec 100644 --- a/backend/internal/service/project/service_test.go +++ b/backend/internal/service/project/service_test.go @@ -103,7 +103,15 @@ func TestManager_ReaddAfterRemove(t *testing.T) { t.Fatalf("Remove: %v", err) } if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao2")}); err != nil { - t.Fatalf("re-add after remove: %v", err) + 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) } } From 9da9adb56e6cbe9bd2a5cd62b4d5d0d1f500e0bd Mon Sep 17 00:00:00 2001 From: itrytoohard Date: Tue, 2 Jun 2026 01:23:43 +0530 Subject: [PATCH 13/13] refactor: move pr manager into service layer (#68) --- backend/internal/adapters/scm/github/doc.go | 2 +- backend/internal/integration/lifecycle_sqlite_test.go | 2 +- backend/internal/{ => service}/pr/manager.go | 0 backend/internal/{ => service}/pr/manager_test.go | 0 4 files changed, 2 insertions(+), 2 deletions(-) rename backend/internal/{ => service}/pr/manager.go (100%) rename backend/internal/{ => service}/pr/manager_test.go (100%) diff --git a/backend/internal/adapters/scm/github/doc.go b/backend/internal/adapters/scm/github/doc.go index 8dee9a34..6bc7b146 100644 --- a/backend/internal/adapters/scm/github/doc.go +++ b/backend/internal/adapters/scm/github/doc.go @@ -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). diff --git a/backend/internal/integration/lifecycle_sqlite_test.go b/backend/internal/integration/lifecycle_sqlite_test.go index b20000e4..4a103f45 100644 --- a/backend/internal/integration/lifecycle_sqlite_test.go +++ b/backend/internal/integration/lifecycle_sqlite_test.go @@ -9,7 +9,7 @@ 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" + 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" diff --git a/backend/internal/pr/manager.go b/backend/internal/service/pr/manager.go similarity index 100% rename from backend/internal/pr/manager.go rename to backend/internal/service/pr/manager.go diff --git a/backend/internal/pr/manager_test.go b/backend/internal/service/pr/manager_test.go similarity index 100% rename from backend/internal/pr/manager_test.go rename to backend/internal/service/pr/manager_test.go