refactor: move project manager to service layer (#68)

* refactor(project): manager talks to the sqlite store; drop the in-memory store

The project Manager now runs only against the durable backend store: remove the
process-local MemoryStore (and NewMemoryManager), and require a real Store. The
daemon already wires the sqlite store; tests now build a real temp-dir sqlite
store instead of the mock.

- Move Row + the Store port to project/store.go. The Store interface stays
  because it is the dependency-inversion port that lets the manager reach the
  backend without an import cycle (storage imports project.Row), not an extra
  mock layer — there is no longer any in-memory implementation.
- NewManager requires a non-nil Store (no in-memory fallback).
- Add project/manager_test.go: List/Add/Get/Remove happy paths +
  PATH_REQUIRED/NOT_A_GIT_REPO/PATH_ALREADY_REGISTERED/ID_ALREADY_REGISTERED,
  PROJECT_NOT_FOUND/INVALID_PROJECT_ID, and UpdateConfig — all against a real
  sqlite store (the service-logic tests #47 lacked).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(project): trim routes, consolidate package, add code-first OpenAPI

- Remove POST /reload, PATCH /{id}, POST /{id}/repair routes and their
  Manager methods (Reload, UpdateConfig, Repair) and DTOs (ReloadResult,
  UpdateConfigInput) — not needed at this stage
- Merge Manager interface into manager.go; delete project.go (single-impl
  split served no purpose)
- Remove dead notImplemented helper from errors.go
- Port PR #59 code-first OpenAPI generation: controllers/dto.go named
  response types, specgen/build.go (4 routes), parity + drift tests,
  cmd/genspec, go generate wiring; regenerate openapi.yaml
- Add swaggest deps; add YAML() method to apispec.Spec

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* fix(project): address PR review comments

- t.Skipf → t.Fatalf in gitRepo helper: git failures now hard-fail
  instead of silently skipping manager tests on a misconfigured runner
- FindProjectByPath: add AND archived_at IS NULL so archived paths don't
  permanently block re-registration (update queries/projects.sql and
  generated gen/projects.sql.go)
- Add TestManager_ReaddAfterRemove to lock the fix

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* fixed lint and fmt

* addressed greptile comments

* Apply suggestions from code review

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* project tests fix

* project_tests fix

* fix: Linting and formatting fix

* refactor: move project manager into service layer (#68)

* refactor: split service package by resource (#68)

* fix: ignore archived project id conflicts (#68)

* refactor: move pr manager into service layer (#68)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: itrytoohard <ayetrytoohard@gmail.com>
This commit is contained in:
neversettle 2026-06-02 01:26:48 +05:30 committed by GitHub
parent 424e6e824b
commit 3a93e33331
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 2123 additions and 1236 deletions

View File

@ -0,0 +1,26 @@
// Command genspec writes the code-first OpenAPI document produced by
// apispec.Build() to disk. It is invoked via `go generate` (see
// internal/httpd/apispec/gen.go); the output openapi.yaml is committed and
// embedded by the apispec package.
package main
import (
"flag"
"log"
"os"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec/specgen"
)
func main() {
out := flag.String("out", "openapi.yaml", "output path for the generated OpenAPI document")
flag.Parse()
doc, err := specgen.Build()
if err != nil {
log.Fatalf("genspec: build openapi: %v", err)
}
if err := os.WriteFile(*out, doc, 0o600); err != nil {
log.Fatalf("genspec: write %s: %v", *out, err)
}
}

View File

@ -8,6 +8,8 @@ require (
github.com/go-chi/chi/v5 v5.1.0 github.com/go-chi/chi/v5 v5.1.0
github.com/pressly/goose/v3 v3.27.1 github.com/pressly/goose/v3 v3.27.1
github.com/spf13/cobra v1.10.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 golang.org/x/sys v0.43.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.51.0 modernc.org/sqlite v1.51.0
@ -23,8 +25,10 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/spf13/pflag v1.0.9 // 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 go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.20.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/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect

View File

@ -1,3 +1,7 @@
github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ=
github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg=
github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E=
github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= 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= 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/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 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= 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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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/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 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= 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= 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/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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= 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= 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=

View File

@ -114,7 +114,7 @@
// //
// - The poller loop and cadence selection (issue #35). // - The poller loop and cadence selection (issue #35).
// - Webhook ingestion (this package is polling-only). // - 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). // - Linear / GitLab providers (separate PRs).
// - Issue tracking (separate lane, see internal/adapters/tracker). // - Issue tracking (separate lane, see internal/adapters/tracker).
// - Comment-injection-into-session-context (Messenger lane, not SCM). // - Comment-injection-into-session-context (Messenger lane, not SCM).

View File

@ -9,7 +9,6 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/cdc" "github.com/aoagents/agent-orchestrator/backend/internal/cdc"
"github.com/aoagents/agent-orchestrator/backend/internal/domain" "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" "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() t.Helper()
ctx := context.Background() ctx := context.Background()
now := time.Now().UTC().Truncate(time.Second) 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) t.Fatal(err)
} }
r, err := s.CreateSession(ctx, domain.SessionRecord{ r, err := s.CreateSession(ctx, domain.SessionRecord{

View File

@ -14,8 +14,8 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij"
"github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd" "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/runfile"
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
"github.com/aoagents/agent-orchestrator/backend/internal/terminal" "github.com/aoagents/agent-orchestrator/backend/internal/terminal"
) )
@ -66,7 +66,7 @@ func Run() error {
termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log) termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log)
defer termMgr.Close() defer termMgr.Close()
srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{Projects: project.NewManager(store)}) srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{Projects: projectsvc.New(store)})
if err != nil { if err != nil {
stop() stop()
if cdcErr := cdcPipe.Stop(); cdcErr != nil { if cdcErr := cdcPipe.Stop(); cdcErr != nil {

View File

@ -10,7 +10,6 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle"
"github.com/aoagents/agent-orchestrator/backend/internal/ports" "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" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
) )
@ -37,7 +36,7 @@ func TestWiring_WriteFlowsToBroadcaster(t *testing.T) {
var got []cdc.Event var got []cdc.Event
bcast.Subscribe(func(e cdc.Event) { mu.Lock(); got = append(got, e); mu.Unlock() }) 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) t.Fatal(err)
} }
rec, err := store.CreateSession(ctx, domain.SessionRecord{ rec, err := store.CreateSession(ctx, domain.SessionRecord{

View File

@ -0,0 +1,13 @@
package domain
import "time"
// ProjectRecord is the durable project registry row used by storage and services.
type ProjectRecord struct {
ID string
Path string
RepoOriginURL string
DisplayName string
RegisteredAt time.Time
ArchivedAt time.Time
}

View File

@ -10,7 +10,7 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers" "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/httpd/envelope"
"github.com/aoagents/agent-orchestrator/backend/internal/project" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
) )
// APIDeps bundles every Manager the API layer's controllers depend on. // 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 // lifecycle reducers, adapters, or storage. A nil dependency keeps its routes
// registered but returns the OpenAPI-backed 501 response. // registered but returns the OpenAPI-backed 501 response.
type APIDeps struct { type APIDeps struct {
Projects project.Manager Projects projectsvc.Manager
Sessions controllers.SessionService Sessions controllers.SessionService
} }

View File

@ -27,7 +27,8 @@ var openapiYAML []byte
// preserves the YAML shape verbatim so the JSON we emit on 501 responses // preserves the YAML shape verbatim so the JSON we emit on 501 responses
// matches the on-disk source. // matches the on-disk source.
type Spec struct { type Spec struct {
doc map[string]any doc map[string]any
rawYAML []byte
} }
var ( var (
@ -61,7 +62,12 @@ func New(yamlBytes []byte) (*Spec, error) {
if doc == nil { if doc == nil {
return nil, fmt.Errorf("parse openapi: empty document") 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 // Operation returns the spec slice for a single (method, path) pair, ready

View File

@ -0,0 +1,6 @@
package apispec
// openapi.yaml is generated from Go (see build.go) — do not edit it by hand.
// Regenerate with `go generate ./...` from the backend module root.
//
//go:generate go run ../../../cmd/genspec -out openapi.yaml

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,66 @@
package apispec_test
import (
"io"
"log/slog"
"net/http"
"strings"
"testing"
"github.com/go-chi/chi/v5"
yaml "gopkg.in/yaml.v3"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec"
)
// TestRouteSpecParity asserts the mounted /api/v1 routes and the OpenAPI
// operations are in 1:1 correspondence — so a route can't be added without
// spec coverage, and the spec can't describe a route that isn't served.
func TestRouteSpecParity(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
router := httpd.NewRouter(config.Config{}, log, nil)
mounted := map[string]bool{}
err := chi.Walk(router, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
if strings.HasPrefix(route, "/api/v1/") && route != "/api/v1/openapi.yaml" {
mounted[strings.ToUpper(method)+" "+route] = true
}
return nil
})
if err != nil {
t.Fatalf("walk routes: %v", err)
}
if len(mounted) == 0 {
t.Fatal("no /api/v1 routes mounted — router wiring changed?")
}
// Forward: every mounted route resolves to an operation slice.
for r := range mounted {
mp := strings.SplitN(r, " ", 2)
if apispec.Default().Operation(mp[0], mp[1]) == nil {
t.Errorf("mounted route %s has no OpenAPI operation", r)
}
}
// Reverse: every spec operation is a mounted route.
var doc struct {
Paths map[string]map[string]yaml.Node `yaml:"paths"`
}
if err := yaml.Unmarshal(apispec.Default().YAML(), &doc); err != nil {
t.Fatalf("parse spec: %v", err)
}
httpMethods := map[string]bool{"get": true, "post": true, "put": true, "patch": true, "delete": true}
for path, item := range doc.Paths {
for method := range item {
if !httpMethods[method] {
continue // skip parameters, summary, etc.
}
key := strings.ToUpper(method) + " " + path
if !mounted[key] {
t.Errorf("spec operation %s has no mounted route", key)
}
}
}
}

View File

@ -0,0 +1,362 @@
// Package specgen builds the code-first OpenAPI document from the Go contract
// types. It lives outside apispec because it imports the controllers (to
// reflect their request/response shapes), and controllers import apispec (for
// the 501 stub) — keeping Build here breaks that cycle. apispec only embeds and
// serves the committed openapi.yaml; specgen produces it.
package specgen
import (
"fmt"
"net/http"
"reflect"
"strings"
jsonschema "github.com/swaggest/jsonschema-go"
openapi "github.com/swaggest/openapi-go"
"github.com/swaggest/openapi-go/openapi31"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
)
// Build reflects the Go contract types and the operation registry below into
// the OpenAPI document. It is the single source of truth for the /api/v1
// contract: `cmd/genspec` writes its output to apispec/openapi.yaml (the
// committed, embedded artifact) and TestBuild_MatchesEmbedded asserts the embed
// equals fresh Build() output so the two can never drift. Schema facets live as
// struct tags on the service.*/controllers.* types; operation metadata (path,
// status codes, summaries) lives here.
//
// Every wire shape is reflected straight from where it is used at runtime — the
// request bodies, path params, and response envelopes from controllers, the
// error envelope from httpd/envelope — so the served responses and the
// generated schema share one definition each.
func Build() ([]byte, error) {
r := openapi31.NewReflector()
// Derive `required` from the idiomatic Go convention: a JSON field without
// `omitempty` is required. swaggest does not infer this on its own, so the
// structs stay clean (only description/enum tags) and this hook adds the
// required array. nonNullableSlices drops the spurious "null" type swaggest
// stamps on every Go slice.
r.DefaultOptions = append(r.DefaultOptions,
jsonschema.InterceptProp(requiredFromJSONTag),
jsonschema.InterceptNullability(nonNullableSlices),
// Clean component schema names (which become the generated TS type names):
// swaggest defaults to PackageType, e.g. "ProjectProject", "EnvelopeAPIError".
jsonschema.InterceptDefName(schemaName),
)
r.Spec.SetTitle("Agent Orchestrator HTTP daemon")
r.Spec.SetVersion("0.1.0-route-shell")
r.Spec.SetDescription("Loopback-only HTTP surface served by the Go daemon. " +
"Generated from Go (code-first) — do not edit by hand; run `go generate ./...`.")
r.Spec.Servers = []openapi31.Server{
*(&openapi31.Server{URL: "http://127.0.0.1:3001"}).WithDescription("Local daemon (loopback only)"),
}
r.Spec.Tags = []openapi31.Tag{
*(&openapi31.Tag{Name: "projects"}).WithDescription(
"Project registry, configuration, and lifecycle administration"),
*(&openapi31.Tag{Name: "sessions"}).WithDescription(
"Agent session lifecycle and messaging"),
}
for _, op := range operations() {
oc, err := r.NewOperationContext(op.method, op.path)
if err != nil {
return nil, fmt.Errorf("new operation %s %s: %w", op.method, op.path, err)
}
oc.SetID(op.id)
oc.SetSummary(op.summary)
oc.SetTags(op.tag)
for _, param := range op.pathParams {
oc.AddReqStructure(param)
}
if op.reqBody != nil {
// AddReqStructure leaves requestBody.required absent, which
// OpenAPI reads as optional. These bodies are mandatory, so force
// it — otherwise validators/generators treat the body as skippable.
oc.AddReqStructure(op.reqBody, openapi.WithCustomize(markRequestBodyRequired))
}
for _, resp := range op.resps {
oc.AddRespStructure(resp.body, openapi.WithHTTPStatus(resp.status))
}
if err := r.AddOperation(oc); err != nil {
return nil, fmt.Errorf("add operation %s %s: %w", op.method, op.path, err)
}
}
return r.Spec.MarshalYAML()
}
// schemaName maps swaggest's default PackageType component names (e.g.
// "ProjectProject", "EnvelopeAPIError") to the clean, stable schema names that
// become the generated TypeScript type names. Every reflected type is listed
// explicitly: an unrecognised default name is returned verbatim, so a new type
// surfaces as a visibly-wrong "PackageType" name in the diff (and the drift
// test) rather than silently colliding with an existing schema via a
// TrimPrefix catch-all.
func schemaName(_ reflect.Type, defaultName string) string {
if clean, ok := schemaNames[defaultName]; ok {
return clean
}
return defaultName
}
// schemaNames is the exhaustive default→clean mapping for every type reflected
// by projectOperations(). Add an entry when a new contract type is introduced;
// the drift test fails until the spec is regenerated, which flags the gap.
var schemaNames = map[string]string{
// httpd/envelope
"EnvelopeAPIError": "APIError",
// domain
"DomainProjectID": "ProjectID",
"DomainSessionID": "SessionID",
"DomainIssueID": "IssueID",
"DomainSession": "Session",
// httpd/controllers (wire envelopes)
"ControllersListProjectsResponse": "ListProjectsResponse",
"ControllersProjectResponse": "ProjectResponse",
"ControllersGetProjectResponse": "ProjectGetResponse",
"ControllersProjectOrDegraded": "ProjectOrDegraded",
"ControllersProjectIDParam": "ProjectIDParam",
"ControllersSessionIDParam": "SessionIDParam",
"ControllersListSessionsQuery": "ListSessionsQuery",
"ControllersListSessionsResponse": "ListSessionsResponse",
"ControllersSpawnSessionRequest": "SpawnSessionRequest",
"ControllersSessionResponse": "SessionResponse",
"ControllersRenameSessionRequest": "RenameSessionRequest",
"ControllersRestoreSessionResponse": "RestoreSessionResponse",
"ControllersKillSessionResponse": "KillSessionResponse",
"ControllersSendSessionMessageRequest": "SendSessionMessageRequest",
"ControllersSendSessionMessageResponse": "SendSessionMessageResponse",
"ControllersSpawnOrchestratorRequest": "SpawnOrchestratorRequest",
"ControllersSpawnOrchestratorResponse": "SpawnOrchestratorResponse",
"ControllersOrchestratorResponse": "OrchestratorResponse",
// service/project entities + DTOs
"ProjectProject": "Project",
"ProjectSummary": "ProjectSummary",
"ProjectDegraded": "DegradedProject",
"ProjectAddInput": "AddProjectInput",
"ProjectRemoveResult": "RemoveProjectResult",
"ProjectTrackerConfig": "TrackerConfig",
"ProjectSCMConfig": "SCMConfig",
"ProjectSCMWebhookConfig": "SCMWebhookConfig",
}
// markRequestBodyRequired sets requestBody.required: true on the operation's
// JSON body. swaggest leaves it absent (== optional) for AddReqStructure bodies.
func markRequestBodyRequired(cor openapi.ContentOrReference) {
if rb, ok := cor.(*openapi31.RequestBodyOrReference); ok && rb.RequestBody != nil {
rb.RequestBody.WithRequired(true)
}
}
// nonNullableSlices drops the "null" that swaggest unions into every Go slice
// type (a nil slice marshals as JSON null). A required array field should be
// `T[]`, not `T[] | null`; the handlers normalise nil to an empty slice, so
// null never reaches the wire. Byte slices (base64 strings) are left alone.
func nonNullableSlices(p jsonschema.InterceptNullabilityParams) {
if !p.NullAdded || p.Type == nil || p.Type.Kind() != reflect.Slice {
return
}
if p.Type.Elem().Kind() == reflect.Uint8 {
return
}
p.Schema.TypeEns().WithSimpleTypes(jsonschema.Array)
p.Schema.Type.SliceOfSimpleTypeValues = nil
}
// requiredFromJSONTag marks a property required when its json tag lacks
// `omitempty` (the Go convention for "always present"). Runs after default
// processing so ParentSchema exists; skips fields without a json tag (e.g. path
// params, which swaggest marks required on their own).
func requiredFromJSONTag(p jsonschema.InterceptPropParams) error {
if !p.Processed || p.ParentSchema == nil {
return nil
}
jsonTag := p.Field.Tag.Get("json")
if jsonTag == "" || jsonTag == "-" {
return nil
}
parts := strings.Split(jsonTag, ",")
name := parts[0]
if name == "" {
name = p.Name
}
for _, opt := range parts[1:] {
if opt == "omitempty" {
return nil
}
}
for _, existing := range p.ParentSchema.Required {
if existing == name {
return nil
}
}
p.ParentSchema.Required = append(p.ParentSchema.Required, name)
return nil
}
// --- operation registry -----------------------------------------------------
type respUnit struct {
status int
body any
}
type operation struct {
method, path, id, summary string
tag string
pathParams []any // path/query param containers (e.g. ProjectIDParam)
reqBody any // JSON request body struct, nil when the op takes none
resps []respUnit
}
func operations() []operation {
ops := append([]operation{}, projectOperations()...)
ops = append(ops, sessionOperations()...)
return ops
}
// projectOperations declares the 4 canonical /projects operations. The set must
// stay 1:1 with the routes ProjectsController.Register mounts —
// TestRouteSpecParity fails the build otherwise.
func projectOperations() []operation {
return []operation{
{
method: http.MethodGet, path: "/api/v1/projects", id: "listProjects", tag: "projects",
summary: "List all registered projects (active + degraded)",
resps: []respUnit{
{http.StatusOK, controllers.ListProjectsResponse{}},
{http.StatusInternalServerError, envelope.APIError{}},
},
},
{
method: http.MethodPost, path: "/api/v1/projects", id: "addProject", tag: "projects",
summary: "Register a new project from a git repository path",
reqBody: projectsvc.AddInput{},
resps: []respUnit{
{http.StatusCreated, controllers.ProjectResponse{}},
{http.StatusBadRequest, envelope.APIError{}},
{http.StatusConflict, envelope.APIError{}},
{http.StatusInternalServerError, envelope.APIError{}},
},
},
{
method: http.MethodGet, path: "/api/v1/projects/{id}", id: "getProject", tag: "projects",
summary: "Fetch one project; discriminates ok vs degraded",
pathParams: []any{controllers.ProjectIDParam{}},
resps: []respUnit{
{http.StatusOK, controllers.GetProjectResponse{}},
{http.StatusNotFound, envelope.APIError{}},
{http.StatusInternalServerError, envelope.APIError{}},
},
},
{
method: http.MethodDelete, path: "/api/v1/projects/{id}", id: "removeProject", tag: "projects",
summary: "Remove a project; stops sessions, cleans workspaces, unregisters",
pathParams: []any{controllers.ProjectIDParam{}},
resps: []respUnit{
{http.StatusOK, projectsvc.RemoveResult{}},
{http.StatusBadRequest, envelope.APIError{}},
{http.StatusNotFound, envelope.APIError{}},
{http.StatusInternalServerError, envelope.APIError{}},
},
},
}
}
func sessionOperations() []operation {
return []operation{
{
method: http.MethodGet, path: "/api/v1/sessions", id: "listSessions", tag: "sessions",
summary: "List sessions",
pathParams: []any{controllers.ListSessionsQuery{}},
resps: []respUnit{
{http.StatusOK, controllers.ListSessionsResponse{}},
{http.StatusBadRequest, envelope.APIError{}},
{http.StatusInternalServerError, envelope.APIError{}},
},
},
{
method: http.MethodPost, path: "/api/v1/sessions", id: "spawnSession", tag: "sessions",
summary: "Spawn a new agent session",
reqBody: controllers.SpawnSessionRequest{},
resps: []respUnit{
{http.StatusCreated, controllers.SessionResponse{}},
{http.StatusBadRequest, envelope.APIError{}},
{http.StatusNotFound, envelope.APIError{}},
{http.StatusInternalServerError, envelope.APIError{}},
},
},
{
method: http.MethodGet, path: "/api/v1/sessions/{sessionId}", id: "getSession", tag: "sessions",
summary: "Fetch one session",
pathParams: []any{controllers.SessionIDParam{}},
resps: []respUnit{
{http.StatusOK, controllers.SessionResponse{}},
{http.StatusNotFound, envelope.APIError{}},
{http.StatusInternalServerError, envelope.APIError{}},
},
},
{
method: http.MethodPatch, path: "/api/v1/sessions/{sessionId}", id: "renameSession", tag: "sessions",
summary: "Rename a session display name",
pathParams: []any{controllers.SessionIDParam{}},
reqBody: controllers.RenameSessionRequest{},
resps: []respUnit{
{http.StatusOK, controllers.SessionResponse{}},
{http.StatusBadRequest, envelope.APIError{}},
{http.StatusNotFound, envelope.APIError{}},
{http.StatusNotImplemented, envelope.APIError{}},
},
},
{
method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/restore", id: "restoreSession", tag: "sessions",
summary: "Restore a terminated session",
pathParams: []any{controllers.SessionIDParam{}},
resps: []respUnit{
{http.StatusOK, controllers.RestoreSessionResponse{}},
{http.StatusNotFound, envelope.APIError{}},
{http.StatusConflict, envelope.APIError{}},
{http.StatusInternalServerError, envelope.APIError{}},
},
},
{
method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/kill", id: "killSession", tag: "sessions",
summary: "Mark a session terminated and tear down runtime/workspace resources",
pathParams: []any{controllers.SessionIDParam{}},
resps: []respUnit{
{http.StatusOK, controllers.KillSessionResponse{}},
{http.StatusNotFound, envelope.APIError{}},
{http.StatusConflict, envelope.APIError{}},
{http.StatusInternalServerError, envelope.APIError{}},
},
},
{
method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/send", id: "sendSessionMessage", tag: "sessions",
summary: "Send a message to a running session's agent",
pathParams: []any{controllers.SessionIDParam{}},
reqBody: controllers.SendSessionMessageRequest{},
resps: []respUnit{
{http.StatusOK, controllers.SendSessionMessageResponse{}},
{http.StatusBadRequest, envelope.APIError{}},
{http.StatusNotFound, envelope.APIError{}},
{http.StatusInternalServerError, envelope.APIError{}},
},
},
{
method: http.MethodPost, path: "/api/v1/orchestrators", id: "spawnOrchestrator", tag: "sessions",
summary: "Spawn an orchestrator session",
reqBody: controllers.SpawnOrchestratorRequest{},
resps: []respUnit{
{http.StatusCreated, controllers.SpawnOrchestratorResponse{}},
{http.StatusBadRequest, envelope.APIError{}},
{http.StatusNotFound, envelope.APIError{}},
{http.StatusInternalServerError, envelope.APIError{}},
{http.StatusNotImplemented, envelope.APIError{}},
},
},
}
}

View File

@ -0,0 +1,40 @@
package specgen_test
import (
"bytes"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec/specgen"
)
// TestBuild_MatchesEmbedded is the drift guard: the committed (embedded)
// openapi.yaml must equal fresh Build() output. If this fails, run
// `go generate ./...` and commit the result.
func TestBuild_MatchesEmbedded(t *testing.T) {
got, err := specgen.Build()
if err != nil {
t.Fatalf("Build: %v", err)
}
embedded := apispec.Default().YAML()
if !bytes.Equal(got, embedded) {
t.Fatalf("embedded openapi.yaml is stale — run `go generate ./...` and commit.\n"+
"len(fresh)=%d len(embedded)=%d", len(got), len(embedded))
}
}
// TestBuild_Deterministic guards against nondeterministic output (which would
// make the drift check flaky in CI).
func TestBuild_Deterministic(t *testing.T) {
a, err := specgen.Build()
if err != nil {
t.Fatalf("Build #1: %v", err)
}
b, err := specgen.Build()
if err != nil {
t.Fatalf("Build #2: %v", err)
}
if !bytes.Equal(a, b) {
t.Fatal("Build() is not deterministic across calls")
}
}

View File

@ -0,0 +1,176 @@
package controllers
import (
"encoding/json"
"errors"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
)
// HTTP response envelopes for the projects surface — the SINGLE definition of
// each wire shape. The handlers encode these (envelope.WriteJSON), and
// apispec.Build reflects these same types into openapi.yaml, so the served
// contract and the generated spec can't disagree. The request side needs no
// wrappers: handlers decode the body straight into the project commands
// (projectsvc.AddInput), which apispec also reflects.
// ProjectIDParam is the {id} path parameter shared by the /projects/{id}
// routes. Handlers read it via chi.URLParam (see projectID); it is declared here
// so every wire input/output shape has one home, and apispec.Build reflects it
// as the path parameter.
type ProjectIDParam struct {
ID string `path:"id" description:"Project identifier (registry key)."`
}
// ListProjectsResponse is the body of GET /api/v1/projects.
type ListProjectsResponse struct {
Projects []projectsvc.Summary `json:"projects"`
}
// ProjectResponse is the { project } body shared by POST /projects (201).
type ProjectResponse struct {
Project projectsvc.Project `json:"project"`
}
// GetProjectResponse is the { status, project } body of GET /projects/{id},
// where project is oneOf Project|Degraded discriminated by status.
type GetProjectResponse struct {
Status string `json:"status" enum:"ok,degraded"`
Project ProjectOrDegraded `json:"project"`
}
// ProjectOrDegraded is the discriminated `project` field: exactly one of
// Project/Degraded is set. It marshals as whichever is present (so the handler
// emits the right object) and exposes the oneOf variants to the spec reflector
// (so apispec.Build emits `oneOf: [Project, Degraded]`) — one type, both jobs.
type ProjectOrDegraded struct {
Project *projectsvc.Project
Degraded *projectsvc.Degraded
}
// MarshalJSON encodes whichever variant is set (Project or Degraded).
func (p ProjectOrDegraded) MarshalJSON() ([]byte, error) {
switch {
case p.Degraded != nil:
return json.Marshal(p.Degraded)
case p.Project != nil:
return json.Marshal(p.Project)
default:
// Unreachable in practice: the handler validates the GetResult via
// newGetProjectResponse and writes a 500 before committing the 200
// status, so this never encodes. Kept as a last-resort backstop —
// erroring is still better than emitting a contract-breaking `null`,
// though by here the status is already sent, so the real guard is
// upstream.
return nil, errEmptyProjectOrDegraded
}
}
// errEmptyProjectOrDegraded marks a GetResult that set neither variant — a
// Manager-contract violation. newGetProjectResponse returns it so the handler
// can map it to a 500 before any response bytes are written.
var errEmptyProjectOrDegraded = errors.New("controllers: GetResult has neither Project nor Degraded set")
// JSONSchemaOneOf is read by swaggest's reflector (apispec.Build) to emit the
// oneOf for this field; it is not used at runtime.
func (ProjectOrDegraded) JSONSchemaOneOf() []interface{} {
return []interface{}{projectsvc.Project{}, projectsvc.Degraded{}}
}
// newGetProjectResponse maps the internal GetResult onto the wire envelope —
// the explicit project→httpd boundary the result type exists for. It errors
// when the result sets neither variant, so the handler can return a clean 500
// BEFORE writing the 200 status rather than flushing a truncated body.
func newGetProjectResponse(res projectsvc.GetResult) (GetProjectResponse, error) {
if res.Project == nil && res.Degraded == nil {
return GetProjectResponse{}, errEmptyProjectOrDegraded
}
return GetProjectResponse{
Status: res.Status,
Project: ProjectOrDegraded{Project: res.Project, Degraded: res.Degraded},
}, nil
}
// SessionIDParam is the {sessionId} path parameter shared by session routes.
type SessionIDParam struct {
SessionID string `path:"sessionId" description:"Session identifier, e.g. project-1."`
}
// ListSessionsQuery is the query string accepted by GET /api/v1/sessions.
type ListSessionsQuery struct {
Project string `query:"project,omitempty" description:"Project id filter."`
Active *bool `query:"active,omitempty" description:"When true, return non-terminated sessions; when false, return terminated sessions."`
OrchestratorOnly *bool `query:"orchestratorOnly,omitempty" description:"When true, return only orchestrator sessions."`
Fresh *bool `query:"fresh,omitempty" description:"When true, return only fresh non-terminated sessions."`
}
// ListSessionsResponse is the body of GET /api/v1/sessions.
type ListSessionsResponse struct {
Sessions []domain.Session `json:"sessions"`
}
// SpawnSessionRequest is the body of POST /api/v1/sessions.
type SpawnSessionRequest struct {
ProjectID domain.ProjectID `json:"projectId"`
IssueID domain.IssueID `json:"issueId,omitempty"`
Kind domain.SessionKind `json:"kind,omitempty" enum:"worker,orchestrator"`
Harness domain.AgentHarness `json:"harness,omitempty" enum:"claude-code,codex,aider,opencode"`
Branch string `json:"branch,omitempty"`
Prompt string `json:"prompt,omitempty" maxLength:"4096"`
AgentRules string `json:"agentRules,omitempty"`
}
// SessionResponse is the { session } body shared by session create/get.
type SessionResponse struct {
Session domain.Session `json:"session"`
}
// RenameSessionRequest is the body of PATCH /api/v1/sessions/{sessionId}.
type RenameSessionRequest struct {
DisplayName string `json:"displayName" minLength:"1"`
}
// RestoreSessionResponse is the body of POST /api/v1/sessions/{sessionId}/restore.
type RestoreSessionResponse struct {
OK bool `json:"ok"`
SessionID domain.SessionID `json:"sessionId"`
Session domain.Session `json:"session"`
}
// KillSessionResponse is the body of POST /api/v1/sessions/{sessionId}/kill.
type KillSessionResponse struct {
OK bool `json:"ok"`
SessionID domain.SessionID `json:"sessionId"`
Freed bool `json:"freed,omitempty"`
}
// SendSessionMessageRequest is the body of POST /api/v1/sessions/{sessionId}/send.
type SendSessionMessageRequest struct {
Message string `json:"message" minLength:"1" maxLength:"4096"`
}
// SendSessionMessageResponse is the body of POST /api/v1/sessions/{sessionId}/send.
type SendSessionMessageResponse struct {
OK bool `json:"ok"`
SessionID domain.SessionID `json:"sessionId"`
Message string `json:"message"`
}
// SpawnOrchestratorRequest is the body of POST /api/v1/orchestrators.
type SpawnOrchestratorRequest struct {
ProjectID domain.ProjectID `json:"projectId"`
Clean bool `json:"clean,omitempty"`
}
// SpawnOrchestratorResponse is the body of POST /api/v1/orchestrators.
type SpawnOrchestratorResponse struct {
Orchestrator OrchestratorResponse `json:"orchestrator"`
}
// OrchestratorResponse is the minimal orchestrator read model returned after spawn.
type OrchestratorResponse struct {
ID domain.SessionID `json:"id"`
ProjectID domain.ProjectID `json:"projectId"`
ProjectName string `json:"projectName,omitempty"`
}

View File

@ -5,10 +5,8 @@
package controllers package controllers
import ( import (
"bytes"
"encoding/json" "encoding/json"
"errors" "errors"
"io"
"net/http" "net/http"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -16,26 +14,21 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" "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/httpd/envelope"
"github.com/aoagents/agent-orchestrator/backend/internal/project" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
) )
// ProjectsController owns the /projects routes. The controller depends only on // ProjectsController owns the /projects routes. The controller depends only on
// project.Manager; nil keeps routes registered but returns OpenAPI-backed 501s. // projectsvc.Manager; nil keeps routes registered but returns OpenAPI-backed 501s.
type ProjectsController struct { type ProjectsController struct {
Mgr project.Manager Mgr projectsvc.Manager
} }
// Register mounts the project routes on the supplied router. Route order // Register mounts the project routes on the supplied router.
// matters: /projects/reload must register before /projects/{id} for the POST
// verb, otherwise chi would treat "reload" as an {id} match for repair.
func (c *ProjectsController) Register(r chi.Router) { func (c *ProjectsController) Register(r chi.Router) {
r.Get("/projects", c.list) r.Get("/projects", c.list)
r.Post("/projects", c.add) r.Post("/projects", c.add)
r.Post("/projects/reload", c.reload) // BEFORE /projects/{id}
r.Get("/projects/{id}", c.get) r.Get("/projects/{id}", c.get)
r.Patch("/projects/{id}", c.updateConfig)
r.Delete("/projects/{id}", c.remove) r.Delete("/projects/{id}", c.remove)
r.Post("/projects/{id}/repair", c.repair)
} }
func (c *ProjectsController) list(w http.ResponseWriter, r *http.Request) { 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) writeProjectError(w, r, err)
return return
} }
envelope.WriteJSON(w, http.StatusOK, map[string]any{"projects": projects}) if projects == nil {
projects = []projectsvc.Summary{}
}
envelope.WriteJSON(w, http.StatusOK, ListProjectsResponse{Projects: projects})
} }
func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) { func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) {
@ -56,7 +52,7 @@ func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) {
apispec.NotImplemented(w, r, "POST", "/api/v1/projects") apispec.NotImplemented(w, r, "POST", "/api/v1/projects")
return return
} }
var in project.AddInput var in projectsvc.AddInput
if err := decodeJSON(r, &in); err != nil { if err := decodeJSON(r, &in); err != nil {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil)
return return
@ -66,7 +62,7 @@ func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) {
writeProjectError(w, r, err) writeProjectError(w, r, err)
return 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) { 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) writeProjectError(w, r, err)
return return
} }
if got.Status == "degraded" { resp, err := newGetProjectResponse(got)
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)
if err != nil { if err != nil {
writeProjectError(w, r, err) envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "INTERNAL_ERROR", "Internal server error", nil)
return 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) { 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) 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 { func projectID(r *http.Request) domain.ProjectID {
return domain.ProjectID(chi.URLParam(r, "id")) return domain.ProjectID(chi.URLParam(r, "id"))
} }
@ -159,30 +104,10 @@ func decodeJSON(r *http.Request, out any) error {
return json.NewDecoder(r.Body).Decode(out) return json.NewDecoder(r.Body).Decode(out)
} }
func containsFrozenIdentityField(r *http.Request) ([]string, error) { // writeProjectError maps a projectsvc.Error to its HTTP status, falling back to
body, err := io.ReadAll(r.Body) // 500 for an unrecognized kind or a non-projectsvc.Error.
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) { func writeProjectError(w http.ResponseWriter, r *http.Request, err error) {
var pe *project.Error var pe *projectsvc.Error
if errors.As(err, &pe) { if errors.As(err, &pe) {
status := http.StatusInternalServerError status := http.StatusInternalServerError
switch pe.Kind { switch pe.Kind {

View File

@ -1,310 +1,533 @@
package controllers_test package controllers_test
import ( import (
"context"
"encoding/json" "encoding/json"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/aoagents/agent-orchestrator/backend/internal/config" "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/httpd"
"github.com/aoagents/agent-orchestrator/backend/internal/project"
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
) )
func newTestServer(t *testing.T) *httptest.Server { // emptyGetManager returns a GetResult that sets neither Project nor Degraded —
t.Helper()
// a Manager-contract violation — so the test can prove the handler answers a
// clean 500 before writing the 200 status.
type emptyGetManager struct{ projectsvc.Manager }
func (emptyGetManager) Get(context.Context, domain.ProjectID) (projectsvc.GetResult, error) {
return projectsvc.GetResult{}, nil
}
// TestProjectsAPI_GetEmptyResultIs500 locks the fix for the discriminated-union
// invariant: a degenerate GetResult must surface as a parseable 500 envelope,
// not a 200 with truncated JSON.
func TestProjectsAPI_GetEmptyResultIs500(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil)) log := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{ srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{
Projects: project.NewMemoryManager(),
Projects: emptyGetManager{},
})) }))
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects/whatever", "")
assertJSON(t, headers)
assertErrorCode(t, body, status, http.StatusInternalServerError, "INTERNAL_ERROR")
}
func newTestServer(t *testing.T) *httptest.Server {
t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
store, err := sqlite.Open(t.TempDir())
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{
Projects: projectsvc.New(store),
}))
t.Cleanup(srv.Close)
return srv return srv
} }
func TestProjectsRoutes_DefaultToStubsWithoutManager(t *testing.T) { func TestProjectsRoutes_DefaultToStubsWithoutManager(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil)) log := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := httptest.NewServer(httpd.NewRouter(config.Config{}, log, nil)) srv := httptest.NewServer(httpd.NewRouter(config.Config{}, log, nil))
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects", "") body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects", "")
assertJSON(t, headers) assertJSON(t, headers)
assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED") assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED")
} }
func TestProjectsAPI_ListAddGetReload(t *testing.T) { func TestProjectsAPI_ListAddGet(t *testing.T) {
srv := newTestServer(t) srv := newTestServer(t)
repo := gitRepo(t, "agent-orchestrator") repo := gitRepo(t, "agent-orchestrator")
body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects", "") body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects", "")
if status != http.StatusOK { if status != http.StatusOK {
t.Fatalf("GET projects = %d, want 200; body=%s", status, body) t.Fatalf("GET projects = %d, want 200; body=%s", status, body)
} }
assertJSON(t, headers) assertJSON(t, headers)
var list struct { var list struct {
Projects []projectSummary `json:"projects"` Projects []projectSummary `json:"projects"`
} }
mustJSON(t, body, &list) mustJSON(t, body, &list)
if len(list.Projects) != 0 { if len(list.Projects) != 0 {
t.Fatalf("initial project count = %d, want 0", len(list.Projects)) 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"}`) body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repo)+`,"projectId":"ao","name":"Agent Orchestrator"}`)
if status != http.StatusCreated { if status != http.StatusCreated {
t.Fatalf("POST project = %d, want 201; body=%s", status, body) t.Fatalf("POST project = %d, want 201; body=%s", status, body)
} }
var add struct { var add struct {
Project projectBody `json:"project"` Project projectBody `json:"project"`
} }
mustJSON(t, body, &add) mustJSON(t, body, &add)
if add.Project.ID != "ao" || add.Project.Name != "Agent Orchestrator" || add.Project.DefaultBranch != "main" { if add.Project.ID != "ao" || add.Project.Name != "Agent Orchestrator" || add.Project.DefaultBranch != "main" {
t.Fatalf("created project = %#v", add.Project) t.Fatalf("created project = %#v", add.Project)
} }
body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects/ao", "") body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects/ao", "")
if status != http.StatusOK { if status != http.StatusOK {
t.Fatalf("GET project = %d, want 200; body=%s", status, body) t.Fatalf("GET project = %d, want 200; body=%s", status, body)
}
var get struct {
Status string `json:"status"`
Project projectBody `json:"project"`
}
mustJSON(t, body, &get)
if get.Status != "ok" || get.Project.ID != "ao" {
t.Fatalf("get response = %#v", get)
} }
body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects/reload", "") var get struct {
if status != http.StatusOK { Status string `json:"status"`
t.Fatalf("reload = %d, want 200; body=%s", status, body)
Project projectBody `json:"project"`
} }
var reload struct {
Reloaded bool `json:"reloaded"` mustJSON(t, body, &get)
ProjectCount int `json:"projectCount"`
DegradedCount int `json:"degradedCount"` if get.Status != "ok" || get.Project.ID != "ao" {
}
mustJSON(t, body, &reload) t.Fatalf("get response = %#v", get)
if !reload.Reloaded || reload.ProjectCount != 1 || reload.DegradedCount != 0 {
t.Fatalf("reload response = %#v", reload)
} }
} }
func TestProjectsAPI_AddValidationAndConflicts(t *testing.T) { func TestProjectsAPI_AddValidationAndConflicts(t *testing.T) {
srv := newTestServer(t) srv := newTestServer(t)
repoA := gitRepo(t, "repo-a") repoA := gitRepo(t, "repo-a")
repoB := gitRepo(t, "repo-b") repoB := gitRepo(t, "repo-b")
notRepo := t.TempDir() notRepo := t.TempDir()
cases := []struct { cases := []struct {
name, body, wantCode string name, body, wantCode string
wantStatus int
wantStatus int
}{ }{
{name: "invalid json", body: `{`, wantStatus: 400, wantCode: "INVALID_JSON"}, {name: "invalid json", body: `{`, wantStatus: 400, wantCode: "INVALID_JSON"},
{name: "missing path", body: `{}`, wantStatus: 400, wantCode: "PATH_REQUIRED"}, {name: "missing path", body: `{}`, wantStatus: 400, wantCode: "PATH_REQUIRED"},
{name: "not git", body: `{"path":` + quote(notRepo) + `}`, wantStatus: 400, wantCode: "NOT_A_GIT_REPO"}, {name: "not git", body: `{"path":` + quote(notRepo) + `}`, wantStatus: 400, wantCode: "NOT_A_GIT_REPO"},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", tc.body) body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", tc.body)
assertErrorCode(t, body, status, tc.wantStatus, tc.wantCode) assertErrorCode(t, body, status, tc.wantStatus, tc.wantCode)
}) })
} }
body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoA)+`,"projectId":"shared"}`) body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoA)+`,"projectId":"shared"}`)
if status != http.StatusCreated { if status != http.StatusCreated {
t.Fatalf("seed create = %d, want 201; body=%s", status, body) 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"}`) body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoA)+`,"projectId":"other"}`)
assertErrorCode(t, body, status, http.StatusConflict, "PATH_ALREADY_REGISTERED") assertErrorCode(t, body, status, http.StatusConflict, "PATH_ALREADY_REGISTERED")
body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoB)+`,"projectId":"shared"}`) body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoB)+`,"projectId":"shared"}`)
assertErrorCode(t, body, status, http.StatusConflict, "ID_ALREADY_REGISTERED") assertErrorCode(t, body, status, http.StatusConflict, "ID_ALREADY_REGISTERED")
} }
func TestProjectsAPI_UpdateDeleteRepair(t *testing.T) { func TestProjectsAPI_Delete(t *testing.T) {
srv := newTestServer(t) srv := newTestServer(t)
repo := gitRepo(t, "repo") repo := gitRepo(t, "repo")
body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repo)+`,"projectId":"proj"}`) body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repo)+`,"projectId":"proj"}`)
if status != http.StatusCreated { if status != http.StatusCreated {
t.Fatalf("seed create = %d, want 201; body=%s", status, body) 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", "") body, status, _ = doRequest(t, srv, "DELETE", "/api/v1/projects/proj", "")
if status != http.StatusOK { if status != http.StatusOK {
t.Fatalf("DELETE = %d, want 200; body=%s", status, body) t.Fatalf("DELETE = %d, want 200; body=%s", status, body)
} }
var removed struct { var removed struct {
ProjectID string `json:"projectId"` ProjectID string `json:"projectId"`
RemovedStorageDir bool `json:"removedStorageDir"`
RemovedStorageDir bool `json:"removedStorageDir"`
} }
mustJSON(t, body, &removed) mustJSON(t, body, &removed)
if removed.ProjectID != "proj" || removed.RemovedStorageDir { if removed.ProjectID != "proj" || removed.RemovedStorageDir {
t.Fatalf("delete response = %#v", removed) t.Fatalf("delete response = %#v", removed)
} }
body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects/proj", "") 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", "") body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects", "")
if status != http.StatusOK { if status != http.StatusOK {
t.Fatalf("GET projects after archive = %d, want 200; body=%s", status, body) t.Fatalf("GET projects after archive = %d, want 200; body=%s", status, body)
} }
var list struct { var list struct {
Projects []projectSummary `json:"projects"` Projects []projectSummary `json:"projects"`
} }
mustJSON(t, body, &list) mustJSON(t, body, &list)
if len(list.Projects) != 0 { if len(list.Projects) != 0 {
t.Fatalf("active projects after archive = %d, want 0", len(list.Projects)) t.Fatalf("active projects after archive = %d, want 0", len(list.Projects))
} }
} }
func TestProjectsRoutes_LegacyUnregistered(t *testing.T) { func TestProjectsRoutes_LegacyUnregistered(t *testing.T) {
srv := newTestServer(t) srv := newTestServer(t)
cases := []struct { cases := []struct {
method, path, wantCode, why string method, path, wantCode, why string
wantStatus int
wantStatus int
}{ }{
{method: "PUT", path: "/api/v1/projects/p1", wantStatus: 405, wantCode: "METHOD_NOT_ALLOWED", why: "R3 PUT not registered"}, {method: "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 { for _, tc := range cases {
t.Run(tc.why, func(t *testing.T) { t.Run(tc.why, func(t *testing.T) {
body, status, _ := doRequest(t, srv, tc.method, tc.path, "") body, status, _ := doRequest(t, srv, tc.method, tc.path, "")
assertErrorCode(t, body, status, tc.wantStatus, tc.wantCode) assertErrorCode(t, body, status, tc.wantStatus, tc.wantCode)
}) })
} }
} }
func TestProjectsRoutes_MissingRoute(t *testing.T) { func TestProjectsRoutes_MissingRoute(t *testing.T) {
srv := newTestServer(t) srv := newTestServer(t)
body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects/p1/does-not-exist", "") body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects/p1/does-not-exist", "")
assertJSON(t, headers) assertJSON(t, headers)
assertErrorCode(t, body, status, http.StatusNotFound, "ROUTE_NOT_FOUND") assertErrorCode(t, body, status, http.StatusNotFound, "ROUTE_NOT_FOUND")
} }
func TestOpenAPIYAMLServed(t *testing.T) { func TestOpenAPIYAMLServed(t *testing.T) {
srv := newTestServer(t) srv := newTestServer(t)
body, status, headers := doRequest(t, srv, "GET", "/api/v1/openapi.yaml", "") body, status, headers := doRequest(t, srv, "GET", "/api/v1/openapi.yaml", "")
if status != http.StatusOK { if status != http.StatusOK {
t.Fatalf("status = %d, want 200", status) t.Fatalf("status = %d, want 200", status)
} }
if ct := headers.Get("Content-Type"); !strings.HasPrefix(ct, "application/yaml") { if ct := headers.Get("Content-Type"); !strings.HasPrefix(ct, "application/yaml") {
t.Errorf("Content-Type = %q, want application/yaml*", ct) t.Errorf("Content-Type = %q, want application/yaml*", ct)
} }
if !strings.Contains(string(body), "openapi: 3.1.0") { if !strings.Contains(string(body), "openapi: 3.1.0") {
t.Errorf("served body did not start with an OpenAPI 3.1 doc") t.Errorf("served body did not start with an OpenAPI 3.1 doc")
} }
} }
type projectSummary struct { type projectSummary struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"`
Name string `json:"name"`
SessionPrefix string `json:"sessionPrefix"` SessionPrefix string `json:"sessionPrefix"`
} }
type projectBody struct { type projectBody struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"` Name string `json:"name"`
Repo string `json:"repo"`
Path string `json:"path"`
Repo string `json:"repo"`
DefaultBranch string `json:"defaultBranch"` DefaultBranch string `json:"defaultBranch"`
Agent string `json:"agent"`
Agent string `json:"agent"`
} }
type errorBody struct { type errorBody struct {
Error string `json:"error"` Error string `json:"error"`
Code string `json:"code"`
Message string `json:"message"` Code string `json:"code"`
Message string `json:"message"`
Details map[string]any `json:"details"` Details map[string]any `json:"details"`
} }
func doRequest(t *testing.T, srv *httptest.Server, method, path, body string) ([]byte, int, http.Header) { func doRequest(t *testing.T, srv *httptest.Server, method, path, body string) ([]byte, int, http.Header) {
t.Helper() t.Helper()
var req *http.Request var req *http.Request
var err error var err error
if body != "" { if body != "" {
req, err = http.NewRequest(method, srv.URL+path, strings.NewReader(body)) req, err = http.NewRequest(method, srv.URL+path, strings.NewReader(body))
} else { } else {
req, err = http.NewRequest(method, srv.URL+path, nil) req, err = http.NewRequest(method, srv.URL+path, nil)
} }
if err != nil { if err != nil {
t.Fatalf("new request: %v", err) t.Fatalf("new request: %v", err)
} }
if body != "" { if body != "" {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
} }
resp, err := srv.Client().Do(req) resp, err := srv.Client().Do(req)
if err != nil { if err != nil {
t.Fatalf("do request: %v", err) t.Fatalf("do request: %v", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
buf, err := io.ReadAll(resp.Body) buf, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
t.Fatalf("read body: %v", err) t.Fatalf("read body: %v", err)
} }
return buf, resp.StatusCode, resp.Header return buf, resp.StatusCode, resp.Header
} }
func gitRepo(t *testing.T, name string) string { func gitRepo(t *testing.T, name string) string {
t.Helper() t.Helper()
dir := filepath.Join(t.TempDir(), name) dir := filepath.Join(t.TempDir(), name)
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("create git repo fixture: %v", err) t.Fatalf("create git repo fixture: %v", err)
} }
if out, err := exec.Command("git", "init", dir).CombinedOutput(); err != nil { if out, err := exec.Command("git", "init", dir).CombinedOutput(); err != nil {
t.Fatalf("git init fixture: %v\n%s", err, out) t.Fatalf("git init fixture: %v\n%s", err, out)
} }
return dir return dir
} }
func quote(s string) string { func quote(s string) string {
b, _ := json.Marshal(s) b, _ := json.Marshal(s)
return string(b) return string(b)
} }
func mustJSON(t *testing.T, body []byte, out any) { func mustJSON(t *testing.T, body []byte, out any) {
t.Helper() t.Helper()
if err := json.Unmarshal(body, out); err != nil { if err := json.Unmarshal(body, out); err != nil {
t.Fatalf("unmarshal: %v\nbody=%s", err, body) t.Fatalf("unmarshal: %v\nbody=%s", err, body)
} }
} }
func assertJSON(t *testing.T, headers http.Header) { func assertJSON(t *testing.T, headers http.Header) {
t.Helper() t.Helper()
if ct := headers.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { if ct := headers.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Fatalf("Content-Type = %q, want JSON", ct) t.Fatalf("Content-Type = %q, want JSON", ct)
} }
} }
func assertErrorCode(t *testing.T, body []byte, status, wantStatus int, wantCode string) { func assertErrorCode(t *testing.T, body []byte, status, wantStatus int, wantCode string) {
t.Helper() t.Helper()
if status != wantStatus { if status != wantStatus {
t.Fatalf("status = %d, want %d\nbody=%s", status, wantStatus, body) t.Fatalf("status = %d, want %d\nbody=%s", status, wantStatus, body)
} }
var got errorBody var got errorBody
mustJSON(t, body, &got) mustJSON(t, body, &got)
if got.Code != wantCode { if got.Code != wantCode {
t.Fatalf("code = %q, want %q\nbody=%s", got.Code, wantCode, body) t.Fatalf("code = %q, want %q\nbody=%s", got.Code, wantCode, body)
} }
} }

View File

@ -14,7 +14,7 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
"github.com/aoagents/agent-orchestrator/backend/internal/ports" "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" sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager"
) )
@ -25,7 +25,7 @@ const (
// SessionService is the controller-facing session service contract. // SessionService is the controller-facing session service contract.
type SessionService interface { 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) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error)
Get(ctx context.Context, id domain.SessionID) (domain.Session, error) Get(ctx context.Context, id domain.SessionID) (domain.Session, error)
Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error)
@ -66,17 +66,7 @@ func (c *SessionsController) list(w http.ResponseWriter, r *http.Request) {
writeSessionError(w, r, err) writeSessionError(w, r, err)
return return
} }
envelope.WriteJSON(w, http.StatusOK, map[string]any{"sessions": sessions}) envelope.WriteJSON(w, http.StatusOK, ListSessionsResponse{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"`
} }
func (c *SessionsController) spawn(w http.ResponseWriter, r *http.Request) { 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") apispec.NotImplemented(w, r, "POST", "/api/v1/sessions")
return return
} }
var in spawnSessionRequest var in SpawnSessionRequest
if err := decodeJSON(r, &in); err != nil { if err := decodeJSON(r, &in); err != nil {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil)
return return
@ -105,7 +95,7 @@ func (c *SessionsController) spawn(w http.ResponseWriter, r *http.Request) {
writeSessionError(w, r, err) writeSessionError(w, r, err)
return 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) { 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) writeSessionError(w, r, err)
return 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) { 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) writeSessionError(w, r, err)
return 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) { 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) writeSessionError(w, r, err)
return return
} }
envelope.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "sessionId": sessionID(r), "freed": freed}) envelope.WriteJSON(w, http.StatusOK, KillSessionResponse{OK: true, SessionID: sessionID(r), Freed: freed})
}
type sendSessionRequest struct {
Message string `json:"message"`
} }
func (c *SessionsController) send(w http.ResponseWriter, r *http.Request) { 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") apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/send")
return return
} }
var in sendSessionRequest var in SendSessionMessageRequest
if err := decodeJSON(r, &in); err != nil { if err := decodeJSON(r, &in); err != nil {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil)
return return
@ -178,12 +164,7 @@ func (c *SessionsController) send(w http.ResponseWriter, r *http.Request) {
writeSessionError(w, r, err) writeSessionError(w, r, err)
return return
} }
envelope.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "sessionId": sessionID(r), "message": message}) envelope.WriteJSON(w, http.StatusOK, SendSessionMessageResponse{OK: true, SessionID: sessionID(r), Message: message})
}
type spawnOrchestratorRequest struct {
ProjectID domain.ProjectID `json:"projectId"`
Clean bool `json:"clean"`
} }
func (c *SessionsController) spawnOrchestrator(w http.ResponseWriter, r *http.Request) { 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") apispec.NotImplemented(w, r, "POST", "/api/v1/orchestrators")
return return
} }
var in spawnOrchestratorRequest var in SpawnOrchestratorRequest
if err := decodeJSON(r, &in); err != nil { if err := decodeJSON(r, &in); err != nil {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil)
return return
@ -202,7 +183,7 @@ func (c *SessionsController) spawnOrchestrator(w http.ResponseWriter, r *http.Re
} }
if in.Clean { if in.Clean {
active := true 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 { if err != nil {
writeSessionError(w, r, err) writeSessionError(w, r, err)
return return
@ -219,34 +200,36 @@ func (c *SessionsController) spawnOrchestrator(w http.ResponseWriter, r *http.Re
writeSessionError(w, r, err) writeSessionError(w, r, err)
return 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 { func sessionID(r *http.Request) domain.SessionID {
return domain.SessionID(chi.URLParam(r, "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() 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 != "" { if raw := q.Get("active"); raw != "" {
active, err := strconv.ParseBool(raw) active, err := strconv.ParseBool(raw)
if err != nil { 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 filter.Active = &active
} }
if raw := q.Get("orchestratorOnly"); raw != "" { if raw := q.Get("orchestratorOnly"); raw != "" {
orchestratorOnly, err := strconv.ParseBool(raw) orchestratorOnly, err := strconv.ParseBool(raw)
if err != nil { 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 filter.OrchestratorOnly = orchestratorOnly
} }
if raw := q.Get("fresh"); raw != "" { if raw := q.Get("fresh"); raw != "" {
fresh, err := strconv.ParseBool(raw) fresh, err := strconv.ParseBool(raw)
if err != nil { 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 filter.Fresh = fresh
} }

View File

@ -13,7 +13,7 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd" "github.com/aoagents/agent-orchestrator/backend/internal/httpd"
"github.com/aoagents/agent-orchestrator/backend/internal/ports" "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 { type fakeSessionService struct {
@ -27,7 +27,7 @@ func newFakeSessionService() *fakeSessionService {
return &fakeSessionService{sessions: map[domain.SessionID]domain.Session{s.ID: s}} 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 var out []domain.Session
for _, s := range f.sessions { for _, s := range f.sessions {
if filter.ProjectID != "" && s.ProjectID != filter.ProjectID { if filter.ProjectID != "" && s.ProjectID != filter.ProjectID {

View File

@ -9,9 +9,8 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle"
"github.com/aoagents/agent-orchestrator/backend/internal/ports" "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"
"github.com/aoagents/agent-orchestrator/backend/internal/project" sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session"
"github.com/aoagents/agent-orchestrator/backend/internal/service"
sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
) )
@ -55,7 +54,7 @@ func (c *captureMessenger) Send(_ context.Context, _ domain.SessionID, msg strin
type stack struct { type stack struct {
store *sqlite.Store store *sqlite.Store
sm *service.Session sm *sessionsvc.Service
lcm *lifecycle.Manager lcm *lifecycle.Manager
prm *prsvc.Manager prm *prsvc.Manager
rt *stubRuntime rt *stubRuntime
@ -71,7 +70,7 @@ func newStack(t *testing.T) *stack {
t.Fatal(err) t.Fatal(err)
} }
t.Cleanup(func() { _ = store.Close() }) 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) t.Fatal(err)
} }
msg := &captureMessenger{} msg := &captureMessenger{}
@ -80,7 +79,7 @@ func newStack(t *testing.T) *stack {
rt := &stubRuntime{} rt := &stubRuntime{}
ws := &stubWorkspace{} ws := &stubWorkspace{}
mgr := sessionmanager.New(sessionmanager.Deps{Runtime: rt, Agent: stubAgent{}, Workspace: ws, Store: store, Messenger: msg, Lifecycle: lcm}) 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} return &stack{store: store, sm: sm, lcm: lcm, prm: prm, rt: rt, ws: ws, msg: msg}
} }

View File

@ -1,53 +0,0 @@
package project
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
// Request/response shapes for Manager. They carry the data across the service
// boundary; the entities they reference (Project, Summary, Degraded) live in
// types.go in this same package. Named without a "Project" prefix because the
// package name already supplies it (project.AddInput, project.GetResult).
// GetResult is the discriminated union returned by Manager.Get. Exactly one of
// Project / Degraded is non-nil; Status mirrors the discriminator on the wire
// so consumers branch on it without nil-checking both fields.
type GetResult struct {
Status string // "ok" | "degraded"
Project *Project // populated when Status == "ok"
Degraded *Degraded // populated when Status == "degraded"
}
// AddInput is the body shape for POST /api/v1/projects. Path is required;
// ProjectID and Name default to basename(path) at the manager. Pointer fields
// preserve the "field absent" vs "field present empty" distinction so the
// manager can decide what to default and what to reject.
type AddInput struct {
Path string `json:"path"`
ProjectID *string `json:"projectId,omitempty"`
Name *string `json:"name,omitempty"`
}
// UpdateConfigInput is the body shape for PATCH /api/v1/projects/{id}. Only
// behaviour fields are mutable; identity fields (projectId, path, repo,
// defaultBranch) are rejected by the handler with a 400 IDENTITY_FROZEN.
type UpdateConfigInput struct {
Agent *string `json:"agent,omitempty"`
Tracker *TrackerConfig `json:"tracker,omitempty"`
SCM *SCMConfig `json:"scm,omitempty"`
}
// RemoveResult reports what DELETE /api/v1/projects/{id} actually did.
// RemovedStorageDir is false when the project was registry-only (no on-disk
// session/workspace directory existed).
type RemoveResult struct {
ProjectID domain.ProjectID `json:"projectId"`
RemovedStorageDir bool `json:"removedStorageDir"`
}
// ReloadResult is the response body of POST /api/v1/projects/reload — the
// manager invalidates its cached config and re-scans the registry; the counts
// help the dashboard show "loaded N projects, M degraded" feedback.
type ReloadResult struct {
Reloaded bool `json:"reloaded"`
ProjectCount int `json:"projectCount"`
DegradedCount int `json:"degradedCount"`
}

View File

@ -1,117 +0,0 @@
package project
import (
"context"
"sync"
"time"
)
// Row mirrors the project table shape from the sqlite storage PR. The
// memory store is intentionally row-based so the API layer does not depend on a
// richer mock model than the real DB will provide.
type Row struct {
ID string
Path string
RepoOriginURL string
DisplayName string
RegisteredAt time.Time
ArchivedAt time.Time
}
// Store is the project persistence the manager depends on. MemoryStore is the
// current in-process implementation; the sqlite adapter uses the same row shape.
type Store interface {
List(ctx context.Context) ([]Row, error)
Get(ctx context.Context, id string) (Row, bool, error)
FindByPath(ctx context.Context, path string) (Row, bool, error)
Upsert(ctx context.Context, row Row) error
Archive(ctx context.Context, id string, at time.Time) (bool, error)
}
// MemoryStore is the mocked DB layer for the project API implementation. It is
// process-local and intentionally small, but concurrency-safe for HTTP tests.
type MemoryStore struct {
mu sync.Mutex
projects map[string]Row
paths map[string]string
}
var _ Store = (*MemoryStore)(nil)
// NewMemoryStore returns an empty, ready-to-use in-memory project store.
func NewMemoryStore() *MemoryStore {
return &MemoryStore{
projects: map[string]Row{},
paths: map[string]string{},
}
}
// List returns all non-archived projects, in unspecified order.
func (s *MemoryStore) List(context.Context) ([]Row, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Row, 0, len(s.projects))
for _, row := range s.projects {
if row.ArchivedAt.IsZero() {
out = append(out, row)
}
}
return out, nil
}
// Get returns the project with the given id, or ok=false if absent.
func (s *MemoryStore) Get(_ context.Context, id string) (Row, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
row, ok := s.projects[id]
if !ok {
return Row{}, false, nil
}
return row, true, nil
}
// FindByPath returns the project registered at a filesystem path, or ok=false.
func (s *MemoryStore) FindByPath(_ context.Context, path string) (Row, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
id, ok := s.paths[path]
if !ok {
return Row{}, false, nil
}
row, ok := s.projects[id]
if !ok {
return Row{}, false, nil
}
return row, true, nil
}
// Upsert inserts or replaces a project, keeping the path→id index in sync.
func (s *MemoryStore) Upsert(_ context.Context, row Row) error {
s.mu.Lock()
defer s.mu.Unlock()
if existing, ok := s.projects[row.ID]; ok && existing.Path != row.Path {
delete(s.paths, existing.Path)
}
s.projects[row.ID] = row
s.paths[row.Path] = row.ID
return nil
}
// Archive soft-deletes a project by stamping ArchivedAt; returns ok=false if
// the project doesn't exist.
func (s *MemoryStore) Archive(_ context.Context, id string, at time.Time) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
row, ok := s.projects[id]
if !ok {
return false, nil
}
row.ArchivedAt = at
s.projects[id] = row
return true, nil
}

View File

@ -1,41 +0,0 @@
// Package project owns the projects service contract: the Manager interface
// the HTTP layer calls and the request/response DTOs that cross it (dto.go).
//
// This is the pilot for the feature-package layout the backend is migrating
// toward: a resource's interface, implementation, and DTOs live with the
// resource, not in a central catch-all. Controllers depend on project.Manager
// and nothing beneath it.
package project
import (
"context"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
// Manager is the inbound contract for the /api/v1/projects surface. One
// implementation lives in this package; the HTTP controller is the consumer.
type Manager interface {
// List returns every registered project, including degraded entries
// (those whose config failed to load but whose registry entry survives).
List(ctx context.Context) ([]Summary, error)
// Get returns one project, discriminating ok vs degraded via GetResult.
Get(ctx context.Context, id domain.ProjectID) (GetResult, error)
// Add registers a new project from a git repository path.
Add(ctx context.Context, in AddInput) (Project, error)
// UpdateConfig patches behaviour-only fields; identity fields are frozen.
UpdateConfig(ctx context.Context, id domain.ProjectID, patch UpdateConfigInput) (Project, error)
// Remove unregisters a project, stopping its sessions and reclaiming
// managed workspaces.
Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error)
// Repair recovers a degraded project where automatic repair is available.
Repair(ctx context.Context, id domain.ProjectID) (Project, error)
// Reload invalidates cached config and re-scans the global registry.
Reload(ctx context.Context) (ReloadResult, error)
}

View File

@ -0,0 +1,23 @@
package project
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
// GetResult is the discriminated result returned by Service.Get.
type GetResult struct {
Status string
Project *Project
Degraded *Degraded
}
// AddInput is the body shape for POST /api/v1/projects.
type AddInput struct {
Path string `json:"path"`
ProjectID *string `json:"projectId,omitempty"`
Name *string `json:"name,omitempty"`
}
// RemoveResult reports what DELETE /api/v1/projects/{id} actually did.
type RemoveResult struct {
ProjectID domain.ProjectID `json:"projectId"`
RemovedStorageDir bool `json:"removedStorageDir"`
}

View File

@ -1,6 +1,6 @@
package project package project
// Error is the manager-level error shape controllers can translate into the // Error is the service-level error shape controllers translate into the
// locked HTTP APIError envelope without knowing store internals. // locked HTTP APIError envelope without knowing store internals.
type Error struct { type Error struct {
Kind string Kind string
@ -32,10 +32,6 @@ func conflict(code, message string, details map[string]any) *Error {
return newError("conflict", code, message, details) 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 { func internal(code, message string) *Error {
return newError("internal", code, message, nil) return newError("internal", code, message, nil)
} }

View File

@ -13,29 +13,38 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/domain"
) )
type manager struct { // Manager is the controller-facing contract for the /api/v1/projects surface.
type Manager interface {
// List returns every registered project, including degraded entries
// (those whose config failed to load but whose registry entry survives).
List(ctx context.Context) ([]Summary, error)
// Get returns one project, discriminating ok vs degraded via GetResult.
Get(ctx context.Context, id domain.ProjectID) (GetResult, error)
// Add registers a new project from a git repository path.
Add(ctx context.Context, in AddInput) (Project, error)
// Remove unregisters a project, stopping its sessions and reclaiming
// managed workspaces.
Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error)
}
// Service implements project registration and lookup use-cases for controllers.
type Service struct {
store Store store Store
} }
var _ Manager = (*manager)(nil) var _ Manager = (*Service)(nil)
// NewManager returns a project Manager backed by the given Store, defaulting to // New returns a project service backed by the given durable store.
// an in-memory store when store is nil. func New(store Store) *Service {
func NewManager(store Store) Manager { return &Service{store: store}
if store == nil {
store = NewMemoryStore()
}
return &manager{store: store}
} }
// NewMemoryManager returns a project Manager backed by a fresh in-memory store, // List returns every active registered project.
// for tests and ephemeral use. func (m *Service) List(ctx context.Context) ([]Summary, error) {
func NewMemoryManager() Manager { projects, err := m.store.ListProjects(ctx)
return NewManager(NewMemoryStore())
}
func (m *manager) List(ctx context.Context) ([]Summary, error) {
projects, err := m.store.List(ctx)
if err != nil { if err != nil {
return nil, internal("PROJECTS_LIST_FAILED", "Failed to load projects") return nil, internal("PROJECTS_LIST_FAILED", "Failed to load projects")
} }
@ -50,22 +59,24 @@ func (m *manager) List(ctx context.Context) ([]Summary, error) {
return out, nil return out, nil
} }
func (m *manager) Get(ctx context.Context, id domain.ProjectID) (GetResult, error) { // Get returns one active project by id.
func (m *Service) Get(ctx context.Context, id domain.ProjectID) (GetResult, error) {
if err := validateProjectID(id); err != nil { if err := validateProjectID(id); err != nil {
return GetResult{}, err return GetResult{}, err
} }
row, ok, err := m.store.Get(ctx, string(id)) row, ok, err := m.store.GetProject(ctx, string(id))
if err != nil { if err != nil {
return GetResult{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") 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") return GetResult{}, notFound("PROJECT_NOT_FOUND", "Unknown project")
} }
p := projectFromRow(row) p := projectFromRow(row)
return GetResult{Status: "ok", Project: &p}, nil return GetResult{Status: "ok", Project: &p}, nil
} }
func (m *manager) Add(ctx context.Context, in AddInput) (Project, error) { // Add registers a local git repository as a project.
func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) {
path, err := normalizePath(in.Path) path, err := normalizePath(in.Path)
if err != nil { if err != nil {
return Project{}, err return Project{}, err
@ -90,7 +101,7 @@ func (m *manager) Add(ctx context.Context, in AddInput) (Project, error) {
name = string(id) name = string(id)
} }
if existing, ok, err := m.store.FindByPath(ctx, path); err != nil { if existing, ok, err := m.store.FindProjectByPath(ctx, path); err != nil {
return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project")
} else if ok { } else if ok {
return Project{}, conflict("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{
@ -98,47 +109,33 @@ func (m *manager) Add(ctx context.Context, in AddInput) (Project, error) {
"suggestedProjectId": string(m.suggestID(ctx, id)), "suggestedProjectId": string(m.suggestID(ctx, id)),
}) })
} }
if existing, ok, err := m.store.Get(ctx, string(id)); err != nil { if existing, ok, err := m.store.GetProject(ctx, string(id)); err != nil {
return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") 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{ return Project{}, conflict("ID_ALREADY_REGISTERED", "A project with this id is already registered for a different path", map[string]any{
"existingProjectId": existing.ID, "existingProjectId": existing.ID,
"suggestedProjectId": string(m.suggestID(ctx, id)), "suggestedProjectId": string(m.suggestID(ctx, id)),
}) })
} }
row := Row{ row := domain.ProjectRecord{
ID: string(id), ID: string(id),
Path: path, Path: path,
DisplayName: name, DisplayName: name,
RegisteredAt: time.Now(), 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 Project{}, err
} }
return projectFromRow(row), nil return projectFromRow(row), nil
} }
func (m *manager) UpdateConfig(ctx context.Context, id domain.ProjectID, _ UpdateConfigInput) (Project, error) { // Remove archives a project registration.
if err := validateProjectID(id); err != nil { func (m *Service) Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) {
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 { if err := validateProjectID(id); err != nil {
return RemoveResult{}, err return RemoveResult{}, err
} }
ok, err := m.store.Archive(ctx, string(id), time.Now()) ok, err := m.store.ArchiveProject(ctx, string(id), time.Now())
if err != nil { if err != nil {
return RemoveResult{}, internal("PROJECT_REMOVE_FAILED", "Failed to remove project") return RemoveResult{}, internal("PROJECT_REMOVE_FAILED", "Failed to remove project")
} }
@ -148,36 +145,16 @@ func (m *manager) Remove(ctx context.Context, id domain.ProjectID) (RemoveResult
return RemoveResult{ProjectID: id, RemovedStorageDir: false}, nil return RemoveResult{ProjectID: id, RemovedStorageDir: false}, nil
} }
func (m *manager) Repair(ctx context.Context, id domain.ProjectID) (Project, error) { func (m *Service) suggestID(ctx context.Context, base domain.ProjectID) domain.ProjectID {
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++ { for i := 1; ; i++ {
candidate := domain.ProjectID(string(base) + strconv.Itoa(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 return candidate
} }
} }
} }
func projectFromRow(row Row) Project { func projectFromRow(row domain.ProjectRecord) Project {
return Project{ return Project{
ID: domain.ProjectID(row.ID), ID: domain.ProjectID(row.ID),
Name: displayName(row), Name: displayName(row),
@ -187,7 +164,7 @@ func projectFromRow(row Row) Project {
} }
} }
func displayName(row Row) string { func displayName(row domain.ProjectRecord) string {
if strings.TrimSpace(row.DisplayName) != "" { if strings.TrimSpace(row.DisplayName) != "" {
return row.DisplayName return row.DisplayName
} }

View File

@ -0,0 +1,156 @@
package project_test
import (
"context"
"errors"
"os/exec"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/service/project"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
)
// newManager builds a Manager over a real, throwaway sqlite store (pure-Go
// driver, migrations run on Open) — no in-memory store.
func newManager(t *testing.T) project.Manager {
t.Helper()
store, err := sqlite.Open(t.TempDir())
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
return project.New(store)
}
// gitRepo creates a real git repository in a fresh temp dir and returns its path.
func gitRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
if out, err := exec.Command("git", "init", dir).CombinedOutput(); err != nil {
t.Fatalf("git unavailable: %v (%s)", err, out)
}
return dir
}
func ptr(s string) *string { return &s }
// wantCode asserts err is a *project.Error carrying the given machine code.
func wantCode(t *testing.T, err error, code string) {
t.Helper()
var e *project.Error
if !errors.As(err, &e) {
t.Fatalf("error = %v, want *project.Error", err)
}
if e.Code != code {
t.Fatalf("code = %q, want %q", e.Code, code)
}
}
func TestManager_AddListGetRemove(t *testing.T) {
ctx := context.Background()
m := newManager(t)
repo := gitRepo(t)
if got, err := m.List(ctx); err != nil || len(got) != 0 {
t.Fatalf("List() = %v, %v; want empty", got, err)
}
proj, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao"), Name: ptr("Agent Orchestrator")})
if err != nil {
t.Fatalf("Add: %v", err)
}
if proj.ID != "ao" || proj.Name != "Agent Orchestrator" || proj.Path != repo || proj.DefaultBranch != "main" {
t.Fatalf("Add returned %#v", proj)
}
list, err := m.List(ctx)
if err != nil || len(list) != 1 || list[0].ID != "ao" {
t.Fatalf("List() = %v, %v; want [ao]", list, err)
}
res, err := m.Get(ctx, "ao")
if err != nil {
t.Fatalf("Get: %v", err)
}
if res.Status != "ok" || res.Project == nil || res.Project.ID != "ao" {
t.Fatalf("Get = %#v", res)
}
rm, err := m.Remove(ctx, "ao")
if err != nil {
t.Fatalf("Remove: %v", err)
}
if rm.ProjectID != "ao" || rm.RemovedStorageDir {
t.Fatalf("Remove = %#v", rm)
}
if list, _ := m.List(ctx); len(list) != 0 {
t.Fatalf("active list after remove = %d, want 0", len(list))
}
_, err = m.Get(ctx, "ao")
wantCode(t, err, "PROJECT_NOT_FOUND")
}
func TestManager_ReaddAfterRemove(t *testing.T) {
ctx := context.Background()
m := newManager(t)
repo := gitRepo(t)
if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao")}); err != nil {
t.Fatalf("first Add: %v", err)
}
if _, err := m.Remove(ctx, "ao"); err != nil {
t.Fatalf("Remove: %v", err)
}
if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao2")}); err != nil {
t.Fatalf("re-add same path after remove: %v", err)
}
otherRepo := gitRepo(t)
if _, err := m.Remove(ctx, "ao2"); err != nil {
t.Fatalf("Remove ao2: %v", err)
}
if _, err := m.Add(ctx, project.AddInput{Path: otherRepo, ProjectID: ptr("ao2")}); err != nil {
t.Fatalf("re-add same id at different path after remove: %v", err)
}
}
func TestManager_AddValidationAndConflicts(t *testing.T) {
ctx := context.Background()
m := newManager(t)
_, err := m.Add(ctx, project.AddInput{Path: ""})
wantCode(t, err, "PATH_REQUIRED")
_, err = m.Add(ctx, project.AddInput{Path: t.TempDir()}) // exists but not a git repo
wantCode(t, err, "NOT_A_GIT_REPO")
repoA, repoB := gitRepo(t), gitRepo(t)
if _, err := m.Add(ctx, project.AddInput{Path: repoA, ProjectID: ptr("shared")}); err != nil {
t.Fatalf("seed add: %v", err)
}
_, err = m.Add(ctx, project.AddInput{Path: repoA, ProjectID: ptr("other")})
wantCode(t, err, "PATH_ALREADY_REGISTERED")
_, err = m.Add(ctx, project.AddInput{Path: repoB, ProjectID: ptr("shared")})
wantCode(t, err, "ID_ALREADY_REGISTERED")
}
func TestManager_GetUpdateRemoveErrors(t *testing.T) {
ctx := context.Background()
m := newManager(t)
_, err := m.Get(ctx, "nope")
wantCode(t, err, "PROJECT_NOT_FOUND")
_, err = m.Get(ctx, domain.ProjectID("bad/id"))
wantCode(t, err, "INVALID_PROJECT_ID")
_, err = m.Remove(ctx, "nope")
wantCode(t, err, "PROJECT_NOT_FOUND")
repo := gitRepo(t)
if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("p")}); err != nil {
t.Fatalf("seed: %v", err)
}
}

View File

@ -0,0 +1,17 @@
package project
import (
"context"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
// Store is the durable project persistence surface required by Service.
type Store interface {
ListProjects(ctx context.Context) ([]domain.ProjectRecord, error)
GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error)
FindProjectByPath(ctx context.Context, path string) (domain.ProjectRecord, bool, error)
UpsertProject(ctx context.Context, row domain.ProjectRecord) error
ArchiveProject(ctx context.Context, id string, at time.Time) (bool, error)
}

View File

@ -2,17 +2,7 @@ package project
import "github.com/aoagents/agent-orchestrator/backend/internal/domain" import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
// Project entities and the behaviour-config shapes they expose. These live in // Summary is the row shape returned by GET /api/v1/projects.
// 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 { type Summary struct {
ID domain.ProjectID `json:"id"` ID domain.ProjectID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@ -20,24 +10,19 @@ type Summary struct {
ResolveError string `json:"resolveError,omitempty"` ResolveError string `json:"resolveError,omitempty"`
} }
// Project is the full read-model returned by GET /api/v1/projects/{id} when the // Project is the full read-model returned by GET /api/v1/projects/{id}.
// project resolves cleanly. It joins the registry identity fields with the
// project's behaviour config.
type Project struct { type Project struct {
ID domain.ProjectID `json:"id"` ID domain.ProjectID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"` Path string `json:"path"`
Repo string `json:"repo"` // "owner/name" or "" Repo string `json:"repo"`
DefaultBranch string `json:"defaultBranch"` DefaultBranch string `json:"defaultBranch"`
Agent string `json:"agent,omitempty"` Agent string `json:"agent,omitempty"`
Tracker *TrackerConfig `json:"tracker,omitempty"` Tracker *TrackerConfig `json:"tracker,omitempty"`
SCM *SCMConfig `json:"scm,omitempty"` SCM *SCMConfig `json:"scm,omitempty"`
} }
// Degraded is returned in place of Project when the project's config failed to // Degraded is returned in place of Project when project config failed to load.
// 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 { type Degraded struct {
ID domain.ProjectID `json:"id"` ID domain.ProjectID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@ -45,18 +30,14 @@ type Degraded struct {
ResolveError string `json:"resolveError"` ResolveError string `json:"resolveError"`
} }
// Behaviour-config shapes exposed by the projects API. Runtime selection and // TrackerConfig mirrors tracker behaviour config exposed by the projects API.
// reaction rules are intentionally absent: the daemon has one runtime adapter and
// lifecycle owns agent nudges.
// TrackerConfig mirrors TrackerConfigSchema.
type TrackerConfig struct { type TrackerConfig struct {
Plugin string `json:"plugin,omitempty"` Plugin string `json:"plugin,omitempty"`
Package string `json:"package,omitempty"` Package string `json:"package,omitempty"`
Path string `json:"path,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 { type SCMConfig struct {
Plugin string `json:"plugin,omitempty"` Plugin string `json:"plugin,omitempty"`
Package string `json:"package,omitempty"` Package string `json:"package,omitempty"`
@ -64,7 +45,7 @@ type SCMConfig struct {
Webhook *SCMWebhookConfig `json:"webhook,omitempty"` Webhook *SCMWebhookConfig `json:"webhook,omitempty"`
} }
// SCMWebhookConfig — pointer Enabled distinguishes unset from explicit false. // SCMWebhookConfig describes SCM webhook settings.
type SCMWebhookConfig struct { type SCMWebhookConfig struct {
Enabled *bool `json:"enabled,omitempty"` Enabled *bool `json:"enabled,omitempty"`
Path string `json:"path,omitempty"` Path string `json:"path,omitempty"`

View File

@ -1,4 +1,4 @@
package service package session
import ( import (
"context" "context"
@ -9,37 +9,37 @@ import (
sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" 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. // Store is the read-only persistence surface needed to assemble controller-facing session read models.
type SessionStore interface { type Store interface {
GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error)
ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error)
ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error) ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error)
GetDisplayPRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, bool, error) GetDisplayPRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, bool, error)
} }
// SessionListFilter captures API-facing session list query filters. // ListFilter captures API-facing session list query filters.
type SessionListFilter struct { type ListFilter struct {
ProjectID domain.ProjectID ProjectID domain.ProjectID
Active *bool Active *bool
OrchestratorOnly bool OrchestratorOnly bool
Fresh 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 // session operations to the internal sessionmanager.Manager and owns read-model
// assembly, including user-facing display status derivation. // assembly, including user-facing display status derivation.
type Session struct { type Service struct {
manager *sessionmanager.Manager manager *sessionmanager.Manager
store SessionStore store Store
} }
// NewSession wires a controller-facing session service over an internal session Manager. // New wires a controller-facing session service over an internal session Manager.
func NewSession(manager *sessionmanager.Manager, store SessionStore) *Session { func New(manager *sessionmanager.Manager, store Store) *Service {
return &Session{manager: manager, store: store} return &Service{manager: manager, store: store}
} }
// Spawn creates a session and returns the API-facing read model. // 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) rec, err := s.manager.Spawn(ctx, cfg)
if err != nil { if err != nil {
return domain.Session{}, err 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. // 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) rec, err := s.manager.Restore(ctx, id)
if err != nil { if err != nil {
return domain.Session{}, err 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. // 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) return s.manager.Kill(ctx, id)
} }
// Send delegates agent messaging to the internal manager. // 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) return s.manager.Send(ctx, id, message)
} }
// Cleanup delegates terminal workspace cleanup to the internal manager. // 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) return s.manager.Cleanup(ctx, project)
} }
// List returns sessions as enriched display models after applying API filters. // 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) recs, err := s.listRecords(ctx, filter.ProjectID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -91,7 +91,7 @@ func (s *Session) List(ctx context.Context, filter SessionListFilter) ([]domain.
return out, nil 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 == "" { if project == "" {
recs, err := s.store.ListAllSessions(ctx) recs, err := s.store.ListAllSessions(ctx)
if err != nil { if err != nil {
@ -106,7 +106,7 @@ func (s *Session) listRecords(ctx context.Context, project domain.ProjectID) ([]
return recs, nil 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 { if filter.Active != nil && rec.IsTerminated == *filter.Active {
return false 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. // 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) rec, ok, err := s.store.GetSession(ctx, id)
if err != nil { if err != nil {
return domain.Session{}, fmt.Errorf("get %s: %w", id, err) 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) 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) pr, ok, err := s.store.GetDisplayPRFactsForSession(ctx, rec.ID)
if err != nil { if err != nil {
return domain.Session{}, fmt.Errorf("pr facts %s: %w", rec.ID, err) return domain.Session{}, fmt.Errorf("pr facts %s: %w", rec.ID, err)

View File

@ -1,4 +1,4 @@
package service package session
import ( import (
"context" "context"
@ -8,29 +8,29 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/domain"
) )
type fakeSessionStore struct { type fakeStore struct {
sessions map[domain.SessionID]domain.SessionRecord sessions map[domain.SessionID]domain.SessionRecord
pr map[domain.SessionID]domain.PRFacts pr map[domain.SessionID]domain.PRFacts
num int num int
} }
func newFakeSessionStore() *fakeSessionStore { func newFakeStore() *fakeStore {
return &fakeSessionStore{sessions: map[domain.SessionID]domain.SessionRecord{}, pr: map[domain.SessionID]domain.PRFacts{}} 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++ f.num++
rec.ID = domain.SessionID(fmt.Sprintf("%s-%d", rec.ProjectID, f.num)) rec.ID = domain.SessionID(fmt.Sprintf("%s-%d", rec.ProjectID, f.num))
f.sessions[rec.ID] = rec f.sessions[rec.ID] = rec
return rec, nil 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] r, ok := f.sessions[id]
return r, ok, nil 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 var out []domain.SessionRecord
for _, r := range f.sessions { for _, r := range f.sessions {
if r.ProjectID == p { if r.ProjectID == p {
@ -40,7 +40,7 @@ func (f *fakeSessionStore) ListSessions(_ context.Context, p domain.ProjectID) (
return out, nil 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)) out := make([]domain.SessionRecord, 0, len(f.sessions))
for _, r := range f.sessions { for _, r := range f.sessions {
out = append(out, r) out = append(out, r)
@ -48,17 +48,17 @@ func (f *fakeSessionStore) ListAllSessions(_ context.Context) ([]domain.SessionR
return out, nil 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] pr, ok := f.pr[id]
return pr, ok, nil return pr, ok, nil
} }
func TestSessionListDerivesStatusFromPRFacts(t *testing.T) { 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.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} 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

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

View File

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

View File

@ -32,7 +32,7 @@ func (q *Queries) ArchiveProject(ctx context.Context, arg ArchiveProjectParams)
const findProjectByPath = `-- name: FindProjectByPath :one const findProjectByPath = `-- name: FindProjectByPath :one
SELECT id, path, repo_origin_url, display_name, registered_at, archived_at 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) { func (q *Queries) FindProjectByPath(ctx context.Context, path string) (Project, error) {

View File

@ -17,7 +17,7 @@ FROM projects WHERE archived_at IS NULL ORDER BY id;
-- name: FindProjectByPath :one -- name: FindProjectByPath :one
SELECT id, path, repo_origin_url, display_name, registered_at, archived_at 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 -- name: ArchiveProject :execrows
UPDATE projects SET archived_at = ? WHERE id = ?; UPDATE projects SET archived_at = ? WHERE id = ? AND archived_at IS NULL;

View File

@ -8,14 +8,11 @@ import (
"time" "time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain" "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" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen"
) )
var _ project.Store = (*Store)(nil) // UpsertProject inserts or replaces a registered project row.
func (s *Store) UpsertProject(ctx context.Context, r domain.ProjectRecord) error {
// Upsert inserts or replaces a registered project row.
func (s *Store) Upsert(ctx context.Context, r project.Row) error {
s.writeMu.Lock() s.writeMu.Lock()
defer s.writeMu.Unlock() defer s.writeMu.Unlock()
return s.qw.UpsertProject(ctx, gen.UpsertProjectParams{ 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. // GetProject returns a project by id, active or archived.
func (s *Store) Get(ctx context.Context, id string) (project.Row, bool, error) { func (s *Store) GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error) {
p, err := s.qr.GetProject(ctx, domain.ProjectID(id)) p, err := s.qr.GetProject(ctx, domain.ProjectID(id))
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return project.Row{}, false, nil return domain.ProjectRecord{}, false, nil
} }
if err != 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 return projectRowFromGen(p), true, nil
} }
// FindByPath returns a project registered at path, active or archived. // FindProjectByPath returns a project registered at path, active or archived.
func (s *Store) FindByPath(ctx context.Context, path string) (project.Row, bool, error) { func (s *Store) FindProjectByPath(ctx context.Context, path string) (domain.ProjectRecord, bool, error) {
p, err := s.qr.FindProjectByPath(ctx, path) p, err := s.qr.FindProjectByPath(ctx, path)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return project.Row{}, false, nil return domain.ProjectRecord{}, false, nil
} }
if err != 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 return projectRowFromGen(p), true, nil
} }
// List returns active projects ordered by id. // ListProjects returns active projects ordered by id.
func (s *Store) List(ctx context.Context) ([]project.Row, error) { func (s *Store) ListProjects(ctx context.Context) ([]domain.ProjectRecord, error) {
rows, err := s.qr.ListProjects(ctx) rows, err := s.qr.ListProjects(ctx)
if err != nil { if err != nil {
return nil, fmt.Errorf("list projects: %w", err) 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 { for _, p := range rows {
out = append(out, projectRowFromGen(p)) out = append(out, projectRowFromGen(p))
} }
return out, nil return out, nil
} }
// Archive soft-deletes a project and reports whether a row was affected. // ArchiveProject 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) { func (s *Store) ArchiveProject(ctx context.Context, id string, at time.Time) (bool, error) {
s.writeMu.Lock() s.writeMu.Lock()
defer s.writeMu.Unlock() defer s.writeMu.Unlock()
n, err := s.qw.ArchiveProject(ctx, gen.ArchiveProjectParams{ 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 return n > 0, nil
} }
func projectRowFromGen(p gen.Project) project.Row { func projectRowFromGen(p gen.Project) domain.ProjectRecord {
r := project.Row{ r := domain.ProjectRecord{
ID: string(p.ID), ID: string(p.ID),
Path: p.Path, Path: p.Path,
RepoOriginURL: p.RepoOriginURL, RepoOriginURL: p.RepoOriginURL,

View File

@ -8,7 +8,6 @@ import (
"time" "time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain" "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" "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) { func seedProject(t *testing.T, s *sqlite.Store, id string) {
t.Helper() 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), ID: id, Path: "/tmp/" + id, RegisteredAt: time.Now().UTC().Truncate(time.Second),
}); err != nil { }); err != nil {
t.Fatalf("seed project %s: %v", id, err) t.Fatalf("seed project %s: %v", id, err)
@ -49,24 +48,24 @@ func TestProjectCRUDAndArchive(t *testing.T) {
ctx := context.Background() ctx := context.Background()
seedProject(t, s, "mer") seedProject(t, s, "mer")
got, ok, err := s.Get(ctx, "mer") got, ok, err := s.GetProject(ctx, "mer")
if err != nil || !ok { if err != nil || !ok {
t.Fatalf("get: ok=%v err=%v", ok, err) t.Fatalf("get: ok=%v err=%v", ok, err)
} }
if got.ID != "mer" || got.Path != "/tmp/mer" { if got.ID != "mer" || got.Path != "/tmp/mer" {
t.Fatalf("project = %+v", got) 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)) t.Fatalf("active list = %d, want 1", len(list))
} }
// archive hides from the active list but still resolves by id. // 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) 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)) 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") t.Fatal("archived project must still resolve by id")
} }
} }