diff --git a/backend/cmd/genspec/main.go b/backend/cmd/genspec/main.go new file mode 100644 index 00000000..c2310e94 --- /dev/null +++ b/backend/cmd/genspec/main.go @@ -0,0 +1,26 @@ +// Command genspec writes the code-first OpenAPI document produced by +// apispec.Build() to disk. It is invoked via `go generate` (see +// internal/httpd/apispec/gen.go); the output openapi.yaml is committed and +// embedded by the apispec package. +package main + +import ( + "flag" + "log" + "os" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec/specgen" +) + +func main() { + out := flag.String("out", "openapi.yaml", "output path for the generated OpenAPI document") + flag.Parse() + + doc, err := specgen.Build() + if err != nil { + log.Fatalf("genspec: build openapi: %v", err) + } + if err := os.WriteFile(*out, doc, 0o600); err != nil { + log.Fatalf("genspec: write %s: %v", *out, err) + } +} diff --git a/backend/go.mod b/backend/go.mod index a2de66a0..70689d85 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -8,6 +8,8 @@ require ( github.com/go-chi/chi/v5 v5.1.0 github.com/pressly/goose/v3 v3.27.1 github.com/spf13/cobra v1.10.1 + github.com/swaggest/jsonschema-go v0.3.79 + github.com/swaggest/openapi-go v0.2.61 golang.org/x/sys v0.43.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.51.0 @@ -23,8 +25,10 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/swaggest/refl v1.4.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/sync v0.20.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index cf3f0029..718a4a11 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,7 @@ +github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= +github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= +github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= +github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -15,6 +19,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= +github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= @@ -30,6 +36,8 @@ github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76cs github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= @@ -38,6 +46,18 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= +github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= +github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= +github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= +github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= +github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= +github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= +github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= +github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= +github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= @@ -50,6 +70,8 @@ golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= diff --git a/backend/internal/adapters/scm/github/doc.go b/backend/internal/adapters/scm/github/doc.go index 8dee9a34..6bc7b146 100644 --- a/backend/internal/adapters/scm/github/doc.go +++ b/backend/internal/adapters/scm/github/doc.go @@ -114,7 +114,7 @@ // // - The poller loop and cadence selection (issue #35). // - Webhook ingestion (this package is polling-only). -// - Persistence (PR Manager owns the row mapping; see internal/pr). +// - Persistence (PR Manager owns the row mapping; see internal/service/pr). // - Linear / GitLab providers (separate PRs). // - Issue tracking (separate lane, see internal/adapters/tracker). // - Comment-injection-into-session-context (Messenger lane, not SCM). diff --git a/backend/internal/cdc/cdc_test.go b/backend/internal/cdc/cdc_test.go index 6196120f..59d9e690 100644 --- a/backend/internal/cdc/cdc_test.go +++ b/backend/internal/cdc/cdc_test.go @@ -9,7 +9,6 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/cdc" "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/project" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) @@ -27,7 +26,7 @@ func seedSession(t *testing.T, s *sqlite.Store) domain.SessionRecord { t.Helper() ctx := context.Background() now := time.Now().UTC().Truncate(time.Second) - if err := s.Upsert(ctx, project.Row{ID: "mer", Path: "/m", RegisteredAt: now}); err != nil { + if err := s.UpsertProject(ctx, domain.ProjectRecord{ID: "mer", Path: "/m", RegisteredAt: now}); err != nil { t.Fatal(err) } r, err := s.CreateSession(ctx, domain.SessionRecord{ diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index b8d89053..59926922 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -14,8 +14,8 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/httpd" - "github.com/aoagents/agent-orchestrator/backend/internal/project" "github.com/aoagents/agent-orchestrator/backend/internal/runfile" + projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" "github.com/aoagents/agent-orchestrator/backend/internal/terminal" ) @@ -66,7 +66,7 @@ func Run() error { termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log) defer termMgr.Close() - srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{Projects: project.NewManager(store)}) + srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{Projects: projectsvc.New(store)}) if err != nil { stop() if cdcErr := cdcPipe.Stop(); cdcErr != nil { diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index 6d6dae04..d8c7a610 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -10,7 +10,6 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" "github.com/aoagents/agent-orchestrator/backend/internal/ports" - "github.com/aoagents/agent-orchestrator/backend/internal/project" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) @@ -37,7 +36,7 @@ func TestWiring_WriteFlowsToBroadcaster(t *testing.T) { var got []cdc.Event bcast.Subscribe(func(e cdc.Event) { mu.Lock(); got = append(got, e); mu.Unlock() }) - if err := store.Upsert(ctx, project.Row{ID: "mer", Path: "/repo/mer"}); err != nil { + if err := store.UpsertProject(ctx, domain.ProjectRecord{ID: "mer", Path: "/repo/mer"}); err != nil { t.Fatal(err) } rec, err := store.CreateSession(ctx, domain.SessionRecord{ diff --git a/backend/internal/domain/project.go b/backend/internal/domain/project.go new file mode 100644 index 00000000..b00e65c7 --- /dev/null +++ b/backend/internal/domain/project.go @@ -0,0 +1,13 @@ +package domain + +import "time" + +// ProjectRecord is the durable project registry row used by storage and services. +type ProjectRecord struct { + ID string + Path string + RepoOriginURL string + DisplayName string + RegisteredAt time.Time + ArchivedAt time.Time +} diff --git a/backend/internal/httpd/api.go b/backend/internal/httpd/api.go index a61ba784..b1741972 100644 --- a/backend/internal/httpd/api.go +++ b/backend/internal/httpd/api.go @@ -10,7 +10,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" - "github.com/aoagents/agent-orchestrator/backend/internal/project" + projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" ) // APIDeps bundles every Manager the API layer's controllers depend on. @@ -18,7 +18,7 @@ import ( // lifecycle reducers, adapters, or storage. A nil dependency keeps its routes // registered but returns the OpenAPI-backed 501 response. type APIDeps struct { - Projects project.Manager + Projects projectsvc.Manager Sessions controllers.SessionService } diff --git a/backend/internal/httpd/apispec/apispec.go b/backend/internal/httpd/apispec/apispec.go index 2603820f..97195853 100644 --- a/backend/internal/httpd/apispec/apispec.go +++ b/backend/internal/httpd/apispec/apispec.go @@ -27,7 +27,8 @@ var openapiYAML []byte // preserves the YAML shape verbatim so the JSON we emit on 501 responses // matches the on-disk source. type Spec struct { - doc map[string]any + doc map[string]any + rawYAML []byte } var ( @@ -61,7 +62,12 @@ func New(yamlBytes []byte) (*Spec, error) { if doc == nil { return nil, fmt.Errorf("parse openapi: empty document") } - return &Spec{doc: doc}, nil + return &Spec{doc: doc, rawYAML: yamlBytes}, nil +} + +// YAML returns the raw YAML bytes this spec was built from. +func (s *Spec) YAML() []byte { + return s.rawYAML } // Operation returns the spec slice for a single (method, path) pair, ready diff --git a/backend/internal/httpd/apispec/gen.go b/backend/internal/httpd/apispec/gen.go new file mode 100644 index 00000000..cd895850 --- /dev/null +++ b/backend/internal/httpd/apispec/gen.go @@ -0,0 +1,6 @@ +package apispec + +// openapi.yaml is generated from Go (see build.go) — do not edit it by hand. +// Regenerate with `go generate ./...` from the backend module root. +// +//go:generate go run ../../../cmd/genspec -out openapi.yaml diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index b626c578..c121ffe4 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -1,721 +1,824 @@ openapi: 3.1.0 info: + description: Loopback-only HTTP surface served by the Go daemon. Generated from + Go (code-first) — do not edit by hand; run `go generate ./...`. title: Agent Orchestrator HTTP daemon - version: 0.1.0 - description: | - Loopback-only HTTP surface served by the Go daemon. This document describes - the registered /api/v1 project routes and the shared error envelope used by - OpenAPI-backed 501 responses. Daemon control endpoints such as /healthz, - /readyz, /shutdown, and /mux are intentionally outside this REST spec. - + version: 0.1.0-route-shell servers: - - url: http://127.0.0.1:3001 - description: Local daemon (loopback only) - -tags: - - name: projects - description: Project registry, configuration, and lifecycle administration - - name: sessions - description: Agent session lifecycle and messaging - +- description: Local daemon (loopback only) + url: http://127.0.0.1:3001 paths: - /api/v1/projects: - get: - operationId: listProjects - tags: [projects] - summary: List active registered projects - responses: - "200": - description: Projects listed - content: - application/json: - schema: - type: object - required: [projects] - properties: - projects: - type: array - items: { $ref: "#/components/schemas/ProjectSummary" } - "500": - description: Failed to load projects - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: internal, code: PROJECTS_LIST_FAILED, message: "Failed to load projects" } - "501": { $ref: "#/components/responses/NotImplemented" } - + /api/v1/orchestrators: post: - operationId: addProject - tags: [projects] - summary: Register a new project from a git repository path + operationId: spawnOrchestrator requestBody: - required: true content: application/json: - schema: { $ref: "#/components/schemas/AddProjectRequest" } + schema: + $ref: '#/components/schemas/SpawnOrchestratorRequest' + required: true responses: "201": - description: Project registered content: application/json: schema: - type: object - required: [project] - properties: - project: { $ref: "#/components/schemas/Project" } + $ref: '#/components/schemas/SpawnOrchestratorResponse' + description: Created "400": - description: Bad request content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - examples: - invalidJson: { value: { error: bad_request, code: INVALID_JSON, message: "Invalid JSON body" } } - pathRequired: { value: { error: bad_request, code: PATH_REQUIRED, message: "Repository path is required" } } - notAGitRepo: { value: { error: bad_request, code: NOT_A_GIT_REPO, message: "Repository path must point to a git repository" } } - "409": - description: Conflict with an already-registered project - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - examples: - pathAlready: - value: - error: conflict - code: PATH_ALREADY_REGISTERED - message: "A project at this path is already registered" - details: - existingProjectId: existing-project-id - suggestedProjectId: suggested-project-id - idAlready: - value: - error: conflict - code: ID_ALREADY_REGISTERED - message: "A project with this id is already registered for a different path" - details: - existingProjectId: existing-project-id - suggestedProjectId: suggested-project-id - "501": { $ref: "#/components/responses/NotImplemented" } - - /api/v1/projects/reload: - post: - operationId: reloadProjects - tags: [projects] - summary: Invalidate cached config and re-scan the global registry - responses: - "200": - description: Reload complete + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request + "404": content: application/json: - schema: { $ref: "#/components/schemas/ReloadResult" } + schema: + $ref: '#/components/schemas/APIError' + description: Not Found "500": - description: Reload failed content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: internal, code: RELOAD_FAILED, message: "Failed to reload projects" } - "501": { $ref: "#/components/responses/NotImplemented" } - - /api/v1/projects/{id}: - parameters: - - $ref: "#/components/parameters/ProjectIDPath" + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Spawn an orchestrator session + tags: + - sessions + /api/v1/projects: get: - operationId: getProject - tags: [projects] - summary: Fetch one project; discriminates ok vs degraded + operationId: listProjects responses: "200": - description: Project resolved (status discriminates ok vs degraded) content: application/json: - schema: { $ref: "#/components/schemas/ProjectGetResponse" } - "404": { $ref: "#/components/responses/ProjectNotFound" } + schema: + $ref: '#/components/schemas/ListProjectsResponse' + description: OK "500": - description: Failed to load project content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: internal, code: PROJECT_LOAD_FAILED, message: "Failed to load project" } - "501": { $ref: "#/components/responses/NotImplemented" } - patch: - operationId: updateProjectConfig - tags: [projects] - summary: Patch behaviour-only fields (not implemented until config persistence lands) + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: List all registered projects (active + degraded) + tags: + - projects + post: + operationId: addProject requestBody: - required: true content: application/json: - schema: { $ref: "#/components/schemas/UpdateProjectConfigRequest" } + schema: + $ref: '#/components/schemas/AddProjectInput' + required: true responses: + "201": + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectResponse' + description: Created "400": - description: Bad request - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - examples: - invalidJson: { value: { error: bad_request, code: INVALID_JSON, message: "Invalid JSON body" } } - identityFrozen: - value: - error: bad_request - code: IDENTITY_FROZEN - message: "Identity fields cannot be patched" - details: { fields: [projectId, path, repo, defaultBranch] } - invalidConfig: { value: { error: bad_request, code: INVALID_LOCAL_CONFIG, message: "Local project config failed validation" } } - "404": { $ref: "#/components/responses/ProjectNotFound" } + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request "409": - description: Project not in a patchable state content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - examples: - degraded: { value: { error: conflict, code: PROJECT_DEGRADED, message: "Project config is degraded; repair before patching" } } - missingPath: { value: { error: conflict, code: PROJECT_MISSING_PATH, message: "Project registry entry is missing a path" } } - "501": - description: Behaviour config persistence is not wired yet + schema: + $ref: '#/components/schemas/APIError' + description: Conflict + "500": content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: not_implemented, code: PROJECT_CONFIG_NOT_IMPLEMENTED, message: "Project config patching is not available until config persistence is wired" } + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Register a new project from a git repository path + tags: + - projects + /api/v1/projects/{id}: delete: operationId: removeProject - tags: [projects] - summary: Archive a project; hides it from active lists while preserving id references + parameters: + - description: Project identifier (registry key). + in: path + name: id + required: true + schema: + description: Project identifier (registry key). + type: string responses: "200": - description: Project archived content: application/json: - schema: { $ref: "#/components/schemas/RemoveProjectResult" } + schema: + $ref: '#/components/schemas/RemoveProjectResult' + description: OK "400": - description: Invalid project id content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: bad_request, code: INVALID_PROJECT_ID, message: "Project id failed storage-path validation" } - "404": { $ref: "#/components/responses/ProjectNotFound" } + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found "500": - description: Removal failed content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: internal, code: PROJECT_REMOVE_FAILED, message: "Failed to remove project" } - "501": { $ref: "#/components/responses/NotImplemented" } - - /api/v1/projects/{id}/repair: - parameters: - - $ref: "#/components/parameters/ProjectIDPath" - post: - operationId: repairProject - tags: [projects] - summary: Repair a degraded project where automatic recovery is available - x-replaces: - - "POST /api/v1/projects/{id}" + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Remove a project; stops sessions, cleans workspaces, unregisters + tags: + - projects + get: + operationId: getProject + parameters: + - description: Project identifier (registry key). + in: path + name: id + required: true + schema: + description: Project identifier (registry key). + type: string responses: "200": - description: Project repaired content: application/json: schema: - type: object - required: [project] - properties: - project: { $ref: "#/components/schemas/Project" } - "400": - description: Bad request + $ref: '#/components/schemas/ProjectGetResponse' + description: OK + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "500": content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - examples: - notDegraded: { value: { error: bad_request, code: PROJECT_NOT_DEGRADED, message: "Project does not need repair" } } - notAvailable: { value: { error: bad_request, code: REPAIR_NOT_AVAILABLE, message: "Automatic repair is not available for this degraded config" } } - "404": { $ref: "#/components/responses/ProjectNotFound" } - "501": { $ref: "#/components/responses/NotImplemented" } - + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Fetch one project; discriminates ok vs degraded + tags: + - projects /api/v1/sessions: get: operationId: listSessions - tags: [sessions] - summary: List sessions parameters: - - name: project - in: query - schema: { type: string } - - name: active - in: query - schema: { type: boolean } - - name: orchestratorOnly - in: query - schema: { type: boolean } - - name: fresh - in: query - schema: { type: boolean } + - description: Project id filter. + in: query + name: project + schema: + description: Project id filter. + type: string + - description: When true, return non-terminated sessions; when false, return + terminated sessions. + in: query + name: active + schema: + description: When true, return non-terminated sessions; when false, return + terminated sessions. + type: + - "null" + - boolean + - description: When true, return only orchestrator sessions. + in: query + name: orchestratorOnly + schema: + description: When true, return only orchestrator sessions. + type: + - "null" + - boolean + - description: When true, return only fresh non-terminated sessions. + in: query + name: fresh + schema: + description: When true, return only fresh non-terminated sessions. + type: + - "null" + - boolean responses: "200": - description: Sessions listed content: application/json: schema: - type: object - required: [sessions] - properties: - sessions: - type: array - items: { $ref: "#/components/schemas/Session" } + $ref: '#/components/schemas/ListSessionsResponse' + description: OK "400": - description: Bad request content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - "501": { $ref: "#/components/responses/NotImplemented" } + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: List sessions + tags: + - sessions post: operationId: spawnSession - tags: [sessions] - summary: Spawn a new agent session requestBody: - required: true content: application/json: - schema: { $ref: "#/components/schemas/SpawnSessionRequest" } + schema: + $ref: '#/components/schemas/SpawnSessionRequest' + required: true responses: "201": - description: Session spawned content: application/json: schema: - type: object - required: [session] - properties: - session: { $ref: "#/components/schemas/Session" } + $ref: '#/components/schemas/SessionResponse' + description: Created "400": - description: Bad request - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - examples: - invalidJson: { value: { error: bad_request, code: INVALID_JSON, message: "Invalid JSON body" } } - projectRequired: { value: { error: bad_request, code: PROJECT_ID_REQUIRED, message: "projectId is required" } } - promptTooLong: { value: { error: bad_request, code: PROMPT_TOO_LONG, message: "prompt is too long" } } - "404": { $ref: "#/components/responses/ProjectNotFound" } - "500": { $ref: "#/components/responses/SessionOperationFailed" } - "501": { $ref: "#/components/responses/NotImplemented" } - + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Spawn a new agent session + tags: + - sessions /api/v1/sessions/{sessionId}: - parameters: - - $ref: "#/components/parameters/SessionIDPath" get: operationId: getSession - tags: [sessions] - summary: Fetch one session + parameters: + - description: Session identifier, e.g. project-1. + in: path + name: sessionId + required: true + schema: + description: Session identifier, e.g. project-1. + type: string responses: "200": - description: Session fetched content: application/json: schema: - type: object - required: [session] - properties: - session: { $ref: "#/components/schemas/Session" } - "404": { $ref: "#/components/responses/SessionNotFound" } - "501": { $ref: "#/components/responses/NotImplemented" } + $ref: '#/components/schemas/SessionResponse' + description: OK + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Fetch one session + tags: + - sessions patch: operationId: renameSession - tags: [sessions] - summary: Rename a session display name - requestBody: + parameters: + - description: Session identifier, e.g. project-1. + in: path + name: sessionId required: true + schema: + description: Session identifier, e.g. project-1. + type: string + requestBody: content: application/json: schema: - type: object - required: [displayName] - properties: - displayName: { type: string, minLength: 1 } + $ref: '#/components/schemas/RenameSessionRequest' + required: true responses: "200": - description: Session renamed content: application/json: schema: - type: object - required: [session] - properties: - session: { $ref: "#/components/schemas/Session" } + $ref: '#/components/schemas/SessionResponse' + description: OK "400": - description: Bad request content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - "404": { $ref: "#/components/responses/SessionNotFound" } - "501": { $ref: "#/components/responses/NotImplemented" } - - /api/v1/sessions/{sessionId}/restore: - parameters: - - $ref: "#/components/parameters/SessionIDPath" - post: - operationId: restoreSession - tags: [sessions] - summary: Restore a terminated session - responses: - "200": - description: Session restored + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "501": content: application/json: schema: - type: object - required: [ok, sessionId, session] - properties: - ok: { type: boolean } - sessionId: { type: string } - session: { $ref: "#/components/schemas/Session" } - "404": { $ref: "#/components/responses/SessionNotFound" } - "409": { $ref: "#/components/responses/SessionConflict" } - "501": { $ref: "#/components/responses/NotImplemented" } - + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Rename a session display name + tags: + - sessions /api/v1/sessions/{sessionId}/kill: - parameters: - - $ref: "#/components/parameters/SessionIDPath" post: operationId: killSession - tags: [sessions] - summary: Mark a session terminated and tear down runtime/workspace resources + parameters: + - description: Session identifier, e.g. project-1. + in: path + name: sessionId + required: true + schema: + description: Session identifier, e.g. project-1. + type: string responses: "200": - description: Kill attempted content: application/json: - schema: { $ref: "#/components/schemas/KillSessionResponse" } - "404": { $ref: "#/components/responses/SessionNotFound" } - "409": { $ref: "#/components/responses/SessionConflict" } - "501": { $ref: "#/components/responses/NotImplemented" } - - /api/v1/sessions/{sessionId}/send: - parameters: - - $ref: "#/components/parameters/SessionIDPath" + schema: + $ref: '#/components/schemas/KillSessionResponse' + description: OK + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Conflict + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Mark a session terminated and tear down runtime/workspace resources + tags: + - sessions + /api/v1/sessions/{sessionId}/restore: post: - operationId: sendSessionMessage - tags: [sessions] - summary: Send a message to a running session's agent - requestBody: + operationId: restoreSession + parameters: + - description: Session identifier, e.g. project-1. + in: path + name: sessionId required: true - content: - application/json: - schema: { $ref: "#/components/schemas/SendSessionMessageRequest" } + schema: + description: Session identifier, e.g. project-1. + type: string responses: "200": - description: Message accepted content: application/json: - schema: { $ref: "#/components/schemas/SendSessionMessageResponse" } - "400": - description: Bad request + schema: + $ref: '#/components/schemas/RestoreSessionResponse' + description: OK + "404": content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - examples: - invalidJson: { value: { error: bad_request, code: INVALID_JSON, message: "Invalid JSON body" } } - messageRequired: { value: { error: bad_request, code: MESSAGE_REQUIRED, message: "Message is required" } } - "404": { $ref: "#/components/responses/SessionNotFound" } - "500": { $ref: "#/components/responses/SessionOperationFailed" } - "501": { $ref: "#/components/responses/NotImplemented" } - - /api/v1/orchestrators: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Conflict + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Restore a terminated session + tags: + - sessions + /api/v1/sessions/{sessionId}/send: post: - operationId: spawnOrchestrator - tags: [sessions] - summary: Spawn an orchestrator session - requestBody: + operationId: sendSessionMessage + parameters: + - description: Session identifier, e.g. project-1. + in: path + name: sessionId required: true + schema: + description: Session identifier, e.g. project-1. + type: string + requestBody: content: application/json: - schema: { $ref: "#/components/schemas/SpawnOrchestratorRequest" } + schema: + $ref: '#/components/schemas/SendSessionMessageRequest' + required: true responses: - "201": - description: Orchestrator spawned + "200": content: application/json: - schema: { $ref: "#/components/schemas/SpawnOrchestratorResponse" } + schema: + $ref: '#/components/schemas/SendSessionMessageResponse' + description: OK "400": - description: Bad request content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - "404": { $ref: "#/components/responses/ProjectNotFound" } - "500": { $ref: "#/components/responses/SessionOperationFailed" } - "501": { $ref: "#/components/responses/NotImplemented" } - + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Send a message to a running session's agent + tags: + - sessions components: - parameters: - ProjectIDPath: - name: id - in: path - required: true - schema: { type: string, minLength: 1 } - description: Project identifier (registry key). - - SessionIDPath: - name: sessionId - in: path - required: true - schema: { type: string, minLength: 1 } - description: Session identifier, e.g. project-1. - - responses: - NotImplemented: - description: | - Route is registered but the handler has not been implemented yet. - The body carries the locked APIError envelope plus a `spec` field - containing this operation's slice of the OpenAPI document so - callers can discover the contract from the endpoint itself. - content: - application/json: - schema: { $ref: "#/components/schemas/NotImplementedResponse" } - - ProjectNotFound: - description: Project not found - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: not_found, code: PROJECT_NOT_FOUND, message: "Unknown project" } - - SessionNotFound: - description: Session not found - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: not_found, code: SESSION_NOT_FOUND, message: "Unknown session" } - - SessionConflict: - description: Session is not in a valid state for the requested operation - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - examples: - notRestorable: { value: { error: conflict, code: SESSION_NOT_RESTORABLE, message: "Session is not restorable" } } - incompleteHandle: { value: { error: conflict, code: SESSION_INCOMPLETE_HANDLE, message: "Session is missing runtime or workspace handles" } } - - SessionOperationFailed: - description: Session operation failed - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: internal, code: SESSION_OPERATION_FAILED, message: "Session operation failed" } - schemas: APIError: - type: object - required: [error, code, message] properties: - error: { type: string, description: "Short kind, e.g. not_found" } - code: { type: string, description: "SCREAMING_SNAKE machine code" } - message: { type: string, description: "Human-readable detail" } - requestId: { type: string } + code: + type: string details: + additionalProperties: {} type: object - additionalProperties: true - - NotImplementedResponse: - allOf: - - $ref: "#/components/schemas/APIError" - - type: object - required: [spec] - properties: - spec: - type: object - description: | - The OpenAPI Operation object for this method+path, served - inline so consumers discover the contract from the 501 - response without fetching the full spec. Mirrors the YAML - shape — see /api/v1/openapi.yaml for the full document. - - ProjectSummary: + error: + type: string + message: + type: string + requestId: + type: string + required: + - error + - code + - message type: object - required: [id, name, sessionPrefix] + AddProjectInput: properties: - id: { type: string } - name: { type: string } - sessionPrefix: { type: string } - resolveError: + name: + type: + - "null" + - string + path: type: string - description: Present iff the project is degraded. - - Project: + projectId: + type: + - "null" + - string + required: + - path type: object - required: [id, name, path, repo, defaultBranch] + DegradedProject: properties: - id: { type: string } - name: { type: string } - path: { type: string } - repo: + id: type: string - description: "\"owner/name\" or empty string when unset" - defaultBranch: { type: string, default: main } - agent: { type: string } - tracker: { $ref: "#/components/schemas/TrackerConfig" } - scm: { $ref: "#/components/schemas/SCMConfig" } - - DegradedProject: + name: + type: string + path: + type: string + resolveError: + type: string + required: + - id + - name + - path + - resolveError type: object - required: [id, name, path, resolveError] + DomainActivity: properties: - id: { type: string } - name: { type: string } - path: { type: string } - resolveError: { type: string } - - ProjectGetResponse: + lastActivityAt: + format: date-time + type: string + state: + type: string + required: + - state + - lastActivityAt type: object - required: [status, project] + KillSessionResponse: properties: - status: + freed: + type: boolean + ok: + type: boolean + sessionId: type: string - enum: [ok, degraded] - project: - oneOf: - - $ref: "#/components/schemas/Project" - - $ref: "#/components/schemas/DegradedProject" - AddProjectRequest: + required: + - ok + - sessionId type: object - required: [path] + ListProjectsResponse: properties: - path: + projects: + items: + $ref: '#/components/schemas/ProjectSummary' + type: array + required: + - projects + type: object + ListSessionsResponse: + properties: + sessions: + items: + $ref: '#/components/schemas/Session' + type: array + required: + - sessions + type: object + OrchestratorResponse: + properties: + id: type: string - description: Repository path; supports ~ home-expansion. Must be a git repo. projectId: type: string - description: Optional override; defaults to basename(path). + projectName: + type: string + required: + - id + - projectId + type: object + Project: + properties: + agent: + type: string + defaultBranch: + type: string + id: + type: string name: type: string - description: Optional display name; defaults to projectId. - - UpdateProjectConfigRequest: + path: + type: string + repo: + type: string + scm: + $ref: '#/components/schemas/SCMConfig' + tracker: + $ref: '#/components/schemas/TrackerConfig' + required: + - id + - name + - path + - repo + - defaultBranch type: object - description: | - Behaviour-only patch. Identity fields (projectId, path, repo, - defaultBranch) are rejected with 400 IDENTITY_FROZEN. The current Go - handler returns 501 PROJECT_CONFIG_NOT_IMPLEMENTED until config - persistence exists. + ProjectGetResponse: properties: - agent: { type: string } - tracker: { $ref: "#/components/schemas/TrackerConfig" } - scm: { $ref: "#/components/schemas/SCMConfig" } - - RemoveProjectResult: + project: + $ref: '#/components/schemas/ProjectOrDegraded' + status: + enum: + - ok + - degraded + type: string + required: + - status + - project + type: object + ProjectOrDegraded: + oneOf: + - $ref: '#/components/schemas/Project' + - $ref: '#/components/schemas/DegradedProject' type: object - required: [projectId, removedStorageDir] + ProjectResponse: properties: - projectId: { type: string } - removedStorageDir: { type: boolean } - - ReloadResult: + project: + $ref: '#/components/schemas/Project' + required: + - project type: object - required: [reloaded, projectCount, degradedCount] + ProjectSummary: properties: - reloaded: { type: boolean } - projectCount: { type: integer } - degradedCount: { type: integer } - - - Session: + id: + type: string + name: + type: string + resolveError: + type: string + sessionPrefix: + type: string + required: + - id + - name + - sessionPrefix type: object - required: [id, projectId, kind, activity, isTerminated, createdAt, updatedAt, status] + RemoveProjectResult: properties: - id: { type: string } - projectId: { type: string } - issueId: { type: string } - kind: { type: string, enum: [worker, orchestrator] } - harness: { type: string, enum: ["", claude-code, codex, aider, opencode] } - activity: { $ref: "#/components/schemas/SessionActivity" } - isTerminated: { type: boolean } - createdAt: { type: string, format: date-time } - updatedAt: { type: string, format: date-time } - status: + projectId: type: string - enum: [working, pr_open, draft, ci_failed, review_pending, changes_requested, approved, mergeable, merged, needs_input, idle, terminated] - - SessionActivity: + removedStorageDir: + type: boolean + required: + - projectId + - removedStorageDir type: object - required: [state, lastActivityAt] + RenameSessionRequest: properties: - state: { type: string, enum: [active, idle, waiting_input, exited] } - lastActivityAt: { type: string, format: date-time } - - SpawnSessionRequest: + displayName: + minLength: 1 + type: string + required: + - displayName type: object - required: [projectId] + RestoreSessionResponse: properties: - projectId: { type: string } - issueId: { type: string } - kind: { type: string, enum: [worker, orchestrator], default: worker } - harness: { type: string, enum: ["", claude-code, codex, aider, opencode] } - branch: { type: string } - prompt: { type: string, maxLength: 4096 } - agentRules: { type: string } - - SendSessionMessageRequest: + ok: + type: boolean + session: + $ref: '#/components/schemas/Session' + sessionId: + type: string + required: + - ok + - sessionId + - session + type: object + SCMConfig: + properties: + package: + type: string + path: + type: string + plugin: + type: string + webhook: + $ref: '#/components/schemas/SCMWebhookConfig' + type: object + SCMWebhookConfig: + properties: + deliveryHeader: + type: string + enabled: + type: + - "null" + - boolean + eventHeader: + type: string + maxBodyBytes: + type: integer + path: + type: string + secretEnvVar: + type: string + signatureHeader: + type: string type: object - required: [message] + SendSessionMessageRequest: properties: - message: { type: string, minLength: 1, maxLength: 4096 } - + message: + maxLength: 4096 + minLength: 1 + type: string + required: + - message + type: object SendSessionMessageResponse: + properties: + message: + type: string + ok: + type: boolean + sessionId: + type: string + required: + - ok + - sessionId + - message type: object - required: [ok, sessionId, message] + Session: properties: - ok: { type: boolean } - sessionId: { type: string } - message: { type: string } - - KillSessionResponse: + activity: + $ref: '#/components/schemas/DomainActivity' + createdAt: + format: date-time + type: string + harness: + type: string + id: + type: string + isTerminated: + type: boolean + issueId: + type: string + kind: + type: string + projectId: + type: string + status: + type: string + updatedAt: + format: date-time + type: string + required: + - id + - projectId + - kind + - activity + - isTerminated + - createdAt + - updatedAt + - status type: object - required: [ok, sessionId] + SessionResponse: properties: - ok: { type: boolean } - sessionId: { type: string } - freed: { type: boolean } - - SpawnOrchestratorRequest: + session: + $ref: '#/components/schemas/Session' + required: + - session type: object - required: [projectId] + SpawnOrchestratorRequest: properties: - projectId: { type: string } - clean: { type: boolean, default: false } - - SpawnOrchestratorResponse: + clean: + type: boolean + projectId: + type: string + required: + - projectId type: object - required: [orchestrator] + SpawnOrchestratorResponse: properties: orchestrator: - type: object - required: [id, projectId] - properties: - id: { type: string } - projectId: { type: string } - projectName: { type: string } - - # ---- Behaviour config blobs ---- - - TrackerConfig: + $ref: '#/components/schemas/OrchestratorResponse' + required: + - orchestrator type: object - additionalProperties: true + SpawnSessionRequest: properties: - plugin: { type: string } - package: { type: string } - path: { type: string } - - SCMConfig: + agentRules: + type: string + branch: + type: string + harness: + enum: + - claude-code + - codex + - aider + - opencode + type: string + issueId: + type: string + kind: + enum: + - worker + - orchestrator + type: string + projectId: + type: string + prompt: + maxLength: 4096 + type: string + required: + - projectId type: object - additionalProperties: true + TrackerConfig: properties: - plugin: { type: string } - package: { type: string } - path: { type: string } - webhook: - type: object - properties: - enabled: { type: boolean } - path: { type: string } - secretEnvVar: { type: string } - signatureHeader: { type: string } - eventHeader: { type: string } - deliveryHeader: { type: string } - maxBodyBytes: { type: integer } + package: + type: string + path: + type: string + plugin: + type: string + type: object +tags: +- description: Project registry, configuration, and lifecycle administration + name: projects +- description: Agent session lifecycle and messaging + name: sessions diff --git a/backend/internal/httpd/apispec/parity_test.go b/backend/internal/httpd/apispec/parity_test.go new file mode 100644 index 00000000..5bd29410 --- /dev/null +++ b/backend/internal/httpd/apispec/parity_test.go @@ -0,0 +1,66 @@ +package apispec_test + +import ( + "io" + "log/slog" + "net/http" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + yaml "gopkg.in/yaml.v3" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" +) + +// TestRouteSpecParity asserts the mounted /api/v1 routes and the OpenAPI +// operations are in 1:1 correspondence — so a route can't be added without +// spec coverage, and the spec can't describe a route that isn't served. +func TestRouteSpecParity(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + router := httpd.NewRouter(config.Config{}, log, nil) + + mounted := map[string]bool{} + err := chi.Walk(router, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { + if strings.HasPrefix(route, "/api/v1/") && route != "/api/v1/openapi.yaml" { + mounted[strings.ToUpper(method)+" "+route] = true + } + return nil + }) + if err != nil { + t.Fatalf("walk routes: %v", err) + } + if len(mounted) == 0 { + t.Fatal("no /api/v1 routes mounted — router wiring changed?") + } + + // Forward: every mounted route resolves to an operation slice. + for r := range mounted { + mp := strings.SplitN(r, " ", 2) + if apispec.Default().Operation(mp[0], mp[1]) == nil { + t.Errorf("mounted route %s has no OpenAPI operation", r) + } + } + + // Reverse: every spec operation is a mounted route. + var doc struct { + Paths map[string]map[string]yaml.Node `yaml:"paths"` + } + if err := yaml.Unmarshal(apispec.Default().YAML(), &doc); err != nil { + t.Fatalf("parse spec: %v", err) + } + httpMethods := map[string]bool{"get": true, "post": true, "put": true, "patch": true, "delete": true} + for path, item := range doc.Paths { + for method := range item { + if !httpMethods[method] { + continue // skip parameters, summary, etc. + } + key := strings.ToUpper(method) + " " + path + if !mounted[key] { + t.Errorf("spec operation %s has no mounted route", key) + } + } + } +} diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go new file mode 100644 index 00000000..f3cc8f43 --- /dev/null +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -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{}}, + }, + }, + } +} diff --git a/backend/internal/httpd/apispec/specgen/build_test.go b/backend/internal/httpd/apispec/specgen/build_test.go new file mode 100644 index 00000000..9951456b --- /dev/null +++ b/backend/internal/httpd/apispec/specgen/build_test.go @@ -0,0 +1,40 @@ +package specgen_test + +import ( + "bytes" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec/specgen" +) + +// TestBuild_MatchesEmbedded is the drift guard: the committed (embedded) +// openapi.yaml must equal fresh Build() output. If this fails, run +// `go generate ./...` and commit the result. +func TestBuild_MatchesEmbedded(t *testing.T) { + got, err := specgen.Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + embedded := apispec.Default().YAML() + if !bytes.Equal(got, embedded) { + t.Fatalf("embedded openapi.yaml is stale — run `go generate ./...` and commit.\n"+ + "len(fresh)=%d len(embedded)=%d", len(got), len(embedded)) + } +} + +// TestBuild_Deterministic guards against nondeterministic output (which would +// make the drift check flaky in CI). +func TestBuild_Deterministic(t *testing.T) { + a, err := specgen.Build() + if err != nil { + t.Fatalf("Build #1: %v", err) + } + b, err := specgen.Build() + if err != nil { + t.Fatalf("Build #2: %v", err) + } + if !bytes.Equal(a, b) { + t.Fatal("Build() is not deterministic across calls") + } +} diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go new file mode 100644 index 00000000..22d8f42e --- /dev/null +++ b/backend/internal/httpd/controllers/dto.go @@ -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"` +} diff --git a/backend/internal/httpd/controllers/projects.go b/backend/internal/httpd/controllers/projects.go index 91a1e47d..a23d9dff 100644 --- a/backend/internal/httpd/controllers/projects.go +++ b/backend/internal/httpd/controllers/projects.go @@ -5,10 +5,8 @@ package controllers import ( - "bytes" "encoding/json" "errors" - "io" "net/http" "github.com/go-chi/chi/v5" @@ -16,26 +14,21 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" - "github.com/aoagents/agent-orchestrator/backend/internal/project" + projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" ) // ProjectsController owns the /projects routes. The controller depends only on -// project.Manager; nil keeps routes registered but returns OpenAPI-backed 501s. +// projectsvc.Manager; nil keeps routes registered but returns OpenAPI-backed 501s. type ProjectsController struct { - Mgr project.Manager + Mgr projectsvc.Manager } -// Register mounts the project routes on the supplied router. Route order -// matters: /projects/reload must register before /projects/{id} for the POST -// verb, otherwise chi would treat "reload" as an {id} match for repair. +// Register mounts the project routes on the supplied router. func (c *ProjectsController) Register(r chi.Router) { r.Get("/projects", c.list) r.Post("/projects", c.add) - r.Post("/projects/reload", c.reload) // BEFORE /projects/{id} r.Get("/projects/{id}", c.get) - r.Patch("/projects/{id}", c.updateConfig) r.Delete("/projects/{id}", c.remove) - r.Post("/projects/{id}/repair", c.repair) } func (c *ProjectsController) list(w http.ResponseWriter, r *http.Request) { @@ -48,7 +41,10 @@ func (c *ProjectsController) list(w http.ResponseWriter, r *http.Request) { writeProjectError(w, r, err) return } - envelope.WriteJSON(w, http.StatusOK, map[string]any{"projects": projects}) + if projects == nil { + projects = []projectsvc.Summary{} + } + envelope.WriteJSON(w, http.StatusOK, ListProjectsResponse{Projects: projects}) } func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) { @@ -56,7 +52,7 @@ func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) { apispec.NotImplemented(w, r, "POST", "/api/v1/projects") return } - var in project.AddInput + var in projectsvc.AddInput if err := decodeJSON(r, &in); err != nil { envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) return @@ -66,7 +62,7 @@ func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) { writeProjectError(w, r, err) return } - envelope.WriteJSON(w, http.StatusCreated, map[string]any{"project": p}) + envelope.WriteJSON(w, http.StatusCreated, ProjectResponse{Project: p}) } func (c *ProjectsController) get(w http.ResponseWriter, r *http.Request) { @@ -79,37 +75,12 @@ func (c *ProjectsController) get(w http.ResponseWriter, r *http.Request) { writeProjectError(w, r, err) return } - if got.Status == "degraded" { - envelope.WriteJSON(w, http.StatusOK, map[string]any{"status": got.Status, "project": got.Degraded}) - return - } - envelope.WriteJSON(w, http.StatusOK, map[string]any{"status": got.Status, "project": got.Project}) -} - -func (c *ProjectsController) updateConfig(w http.ResponseWriter, r *http.Request) { - if c.Mgr == nil { - apispec.NotImplemented(w, r, "PATCH", "/api/v1/projects/{id}") - return - } - if frozen, err := containsFrozenIdentityField(r); err != nil { - envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) - return - } else if len(frozen) > 0 { - envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "IDENTITY_FROZEN", "Identity fields cannot be patched", map[string]any{"fields": frozen}) - return - } - - var patch project.UpdateConfigInput - if err := decodeJSON(r, &patch); err != nil { - envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) - return - } - p, err := c.Mgr.UpdateConfig(r.Context(), projectID(r), patch) + resp, err := newGetProjectResponse(got) if err != nil { - writeProjectError(w, r, err) + envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "INTERNAL_ERROR", "Internal server error", nil) return } - envelope.WriteJSON(w, http.StatusOK, map[string]any{"project": p}) + envelope.WriteJSON(w, http.StatusOK, resp) } func (c *ProjectsController) remove(w http.ResponseWriter, r *http.Request) { @@ -125,32 +96,6 @@ func (c *ProjectsController) remove(w http.ResponseWriter, r *http.Request) { envelope.WriteJSON(w, http.StatusOK, result) } -func (c *ProjectsController) repair(w http.ResponseWriter, r *http.Request) { - if c.Mgr == nil { - apispec.NotImplemented(w, r, "POST", "/api/v1/projects/{id}/repair") - return - } - p, err := c.Mgr.Repair(r.Context(), projectID(r)) - if err != nil { - writeProjectError(w, r, err) - return - } - envelope.WriteJSON(w, http.StatusOK, map[string]any{"project": p}) -} - -func (c *ProjectsController) reload(w http.ResponseWriter, r *http.Request) { - if c.Mgr == nil { - apispec.NotImplemented(w, r, "POST", "/api/v1/projects/reload") - return - } - result, err := c.Mgr.Reload(r.Context()) - if err != nil { - writeProjectError(w, r, err) - return - } - envelope.WriteJSON(w, http.StatusOK, result) -} - func projectID(r *http.Request) domain.ProjectID { return domain.ProjectID(chi.URLParam(r, "id")) } @@ -159,30 +104,10 @@ func decodeJSON(r *http.Request, out any) error { return json.NewDecoder(r.Body).Decode(out) } -func containsFrozenIdentityField(r *http.Request) ([]string, error) { - body, err := io.ReadAll(r.Body) - if err != nil { - return nil, err - } - r.Body = io.NopCloser(bytes.NewReader(body)) - - var raw map[string]json.RawMessage - if err := json.Unmarshal(body, &raw); err != nil { - return nil, err - } - var frozen []string - for _, field := range []string{"projectId", "path", "repo", "defaultBranch"} { - if _, ok := raw[field]; ok { - frozen = append(frozen, field) - } - } - return frozen, nil -} - -// writeProjectError maps a project.Error to its HTTP status, falling back to -// 500 for an unrecognized kind or a non-project.Error. +// writeProjectError maps a projectsvc.Error to its HTTP status, falling back to +// 500 for an unrecognized kind or a non-projectsvc.Error. func writeProjectError(w http.ResponseWriter, r *http.Request, err error) { - var pe *project.Error + var pe *projectsvc.Error if errors.As(err, &pe) { status := http.StatusInternalServerError switch pe.Kind { diff --git a/backend/internal/httpd/controllers/projects_test.go b/backend/internal/httpd/controllers/projects_test.go index 8d303da5..7de640ef 100644 --- a/backend/internal/httpd/controllers/projects_test.go +++ b/backend/internal/httpd/controllers/projects_test.go @@ -1,310 +1,533 @@ package controllers_test import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" "github.com/aoagents/agent-orchestrator/backend/internal/config" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd" - "github.com/aoagents/agent-orchestrator/backend/internal/project" + + projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" + + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) +// emptyGetManager returns a GetResult that sets neither Project nor Degraded — + +// a Manager-contract violation — so the test can prove the handler answers a + +// clean 500 before writing the 200 status. + +type emptyGetManager struct{ projectsvc.Manager } + +func (emptyGetManager) Get(context.Context, domain.ProjectID) (projectsvc.GetResult, error) { + + return projectsvc.GetResult{}, nil + +} + +// TestProjectsAPI_GetEmptyResultIs500 locks the fix for the discriminated-union + +// invariant: a degenerate GetResult must surface as a parseable 500 envelope, + +// not a 200 with truncated JSON. + +func TestProjectsAPI_GetEmptyResultIs500(t *testing.T) { + + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + + srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{ + + Projects: emptyGetManager{}, + })) + + t.Cleanup(srv.Close) + + body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects/whatever", "") + + assertJSON(t, headers) + + assertErrorCode(t, body, status, http.StatusInternalServerError, "INTERNAL_ERROR") + +} + func newTestServer(t *testing.T) *httptest.Server { + t.Helper() + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + + store, err := sqlite.Open(t.TempDir()) + + if err != nil { + + t.Fatalf("open store: %v", err) + + } + + t.Cleanup(func() { _ = store.Close() }) + srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{ - Projects: project.NewMemoryManager(), + + Projects: projectsvc.New(store), })) + t.Cleanup(srv.Close) + return srv + } func TestProjectsRoutes_DefaultToStubsWithoutManager(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + srv := httptest.NewServer(httpd.NewRouter(config.Config{}, log, nil)) + t.Cleanup(srv.Close) body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects", "") + assertJSON(t, headers) + assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED") + } -func TestProjectsAPI_ListAddGetReload(t *testing.T) { +func TestProjectsAPI_ListAddGet(t *testing.T) { + srv := newTestServer(t) + repo := gitRepo(t, "agent-orchestrator") body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects", "") + if status != http.StatusOK { + t.Fatalf("GET projects = %d, want 200; body=%s", status, body) + } + assertJSON(t, headers) + var list struct { Projects []projectSummary `json:"projects"` } + mustJSON(t, body, &list) + if len(list.Projects) != 0 { + t.Fatalf("initial project count = %d, want 0", len(list.Projects)) + } body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repo)+`,"projectId":"ao","name":"Agent Orchestrator"}`) + if status != http.StatusCreated { + t.Fatalf("POST project = %d, want 201; body=%s", status, body) + } + var add struct { Project projectBody `json:"project"` } + mustJSON(t, body, &add) + if add.Project.ID != "ao" || add.Project.Name != "Agent Orchestrator" || add.Project.DefaultBranch != "main" { + t.Fatalf("created project = %#v", add.Project) + } body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects/ao", "") + if status != http.StatusOK { + t.Fatalf("GET project = %d, want 200; body=%s", status, body) + } + var get struct { - Status string `json:"status"` + Status string `json:"status"` + Project projectBody `json:"project"` } + mustJSON(t, body, &get) + if get.Status != "ok" || get.Project.ID != "ao" { + t.Fatalf("get response = %#v", get) - } - body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects/reload", "") - if status != http.StatusOK { - t.Fatalf("reload = %d, want 200; body=%s", status, body) - } - var reload struct { - Reloaded bool `json:"reloaded"` - ProjectCount int `json:"projectCount"` - DegradedCount int `json:"degradedCount"` - } - mustJSON(t, body, &reload) - if !reload.Reloaded || reload.ProjectCount != 1 || reload.DegradedCount != 0 { - t.Fatalf("reload response = %#v", reload) } + } func TestProjectsAPI_AddValidationAndConflicts(t *testing.T) { + srv := newTestServer(t) + repoA := gitRepo(t, "repo-a") + repoB := gitRepo(t, "repo-b") + notRepo := t.TempDir() cases := []struct { name, body, wantCode string - wantStatus int + + wantStatus int }{ + {name: "invalid json", body: `{`, wantStatus: 400, wantCode: "INVALID_JSON"}, + {name: "missing path", body: `{}`, wantStatus: 400, wantCode: "PATH_REQUIRED"}, + {name: "not git", body: `{"path":` + quote(notRepo) + `}`, wantStatus: 400, wantCode: "NOT_A_GIT_REPO"}, } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", tc.body) + assertErrorCode(t, body, status, tc.wantStatus, tc.wantCode) + }) + } body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoA)+`,"projectId":"shared"}`) + if status != http.StatusCreated { + t.Fatalf("seed create = %d, want 201; body=%s", status, body) + } body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoA)+`,"projectId":"other"}`) + assertErrorCode(t, body, status, http.StatusConflict, "PATH_ALREADY_REGISTERED") body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoB)+`,"projectId":"shared"}`) + assertErrorCode(t, body, status, http.StatusConflict, "ID_ALREADY_REGISTERED") + } -func TestProjectsAPI_UpdateDeleteRepair(t *testing.T) { +func TestProjectsAPI_Delete(t *testing.T) { + srv := newTestServer(t) + repo := gitRepo(t, "repo") body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repo)+`,"projectId":"proj"}`) - if status != http.StatusCreated { - t.Fatalf("seed create = %d, want 201; body=%s", status, body) - } - body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/projects/proj", `{"agent":"claude"}`) - assertErrorCode(t, body, status, http.StatusNotImplemented, "PROJECT_CONFIG_NOT_IMPLEMENTED") + if status != http.StatusCreated { - body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/projects/proj", `{"path":"elsewhere"}`) - assertErrorCode(t, body, status, http.StatusBadRequest, "IDENTITY_FROZEN") + t.Fatalf("seed create = %d, want 201; body=%s", status, body) - body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects/proj/repair", "") - assertErrorCode(t, body, status, http.StatusBadRequest, "REPAIR_NOT_AVAILABLE") + } body, status, _ = doRequest(t, srv, "DELETE", "/api/v1/projects/proj", "") + if status != http.StatusOK { + t.Fatalf("DELETE = %d, want 200; body=%s", status, body) + } + var removed struct { - ProjectID string `json:"projectId"` - RemovedStorageDir bool `json:"removedStorageDir"` + ProjectID string `json:"projectId"` + + RemovedStorageDir bool `json:"removedStorageDir"` } + mustJSON(t, body, &removed) + if removed.ProjectID != "proj" || removed.RemovedStorageDir { + t.Fatalf("delete response = %#v", removed) + } body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects/proj", "") - if status != http.StatusOK { - t.Fatalf("GET archived project = %d, want 200; body=%s", status, body) + + if status != http.StatusNotFound { + + t.Fatalf("GET archived project = %d, want 404; body=%s", status, body) + } body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects", "") + if status != http.StatusOK { + t.Fatalf("GET projects after archive = %d, want 200; body=%s", status, body) + } + var list struct { Projects []projectSummary `json:"projects"` } + mustJSON(t, body, &list) + if len(list.Projects) != 0 { + t.Fatalf("active projects after archive = %d, want 0", len(list.Projects)) + } + } func TestProjectsRoutes_LegacyUnregistered(t *testing.T) { + srv := newTestServer(t) cases := []struct { method, path, wantCode, why string - wantStatus int + + wantStatus int }{ + {method: "PUT", path: "/api/v1/projects/p1", wantStatus: 405, wantCode: "METHOD_NOT_ALLOWED", why: "R3 PUT not registered"}, - {method: "POST", path: "/api/v1/projects/p1", wantStatus: 405, wantCode: "METHOD_NOT_ALLOWED", why: "R4 repair moved to /repair"}, } for _, tc := range cases { + t.Run(tc.why, func(t *testing.T) { + body, status, _ := doRequest(t, srv, tc.method, tc.path, "") + assertErrorCode(t, body, status, tc.wantStatus, tc.wantCode) + }) + } + } func TestProjectsRoutes_MissingRoute(t *testing.T) { + srv := newTestServer(t) + body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects/p1/does-not-exist", "") + assertJSON(t, headers) + assertErrorCode(t, body, status, http.StatusNotFound, "ROUTE_NOT_FOUND") + } func TestOpenAPIYAMLServed(t *testing.T) { + srv := newTestServer(t) + body, status, headers := doRequest(t, srv, "GET", "/api/v1/openapi.yaml", "") + if status != http.StatusOK { + t.Fatalf("status = %d, want 200", status) + } + if ct := headers.Get("Content-Type"); !strings.HasPrefix(ct, "application/yaml") { + t.Errorf("Content-Type = %q, want application/yaml*", ct) + } + if !strings.Contains(string(body), "openapi: 3.1.0") { + t.Errorf("served body did not start with an OpenAPI 3.1 doc") + } + } type projectSummary struct { - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id"` + + Name string `json:"name"` + SessionPrefix string `json:"sessionPrefix"` } type projectBody struct { - ID string `json:"id"` - Name string `json:"name"` - Path string `json:"path"` - Repo string `json:"repo"` + ID string `json:"id"` + + Name string `json:"name"` + + Path string `json:"path"` + + Repo string `json:"repo"` + DefaultBranch string `json:"defaultBranch"` - Agent string `json:"agent"` + + Agent string `json:"agent"` } type errorBody struct { - Error string `json:"error"` - Code string `json:"code"` - Message string `json:"message"` + Error string `json:"error"` + + Code string `json:"code"` + + Message string `json:"message"` + Details map[string]any `json:"details"` } func doRequest(t *testing.T, srv *httptest.Server, method, path, body string) ([]byte, int, http.Header) { + t.Helper() + var req *http.Request + var err error + if body != "" { + req, err = http.NewRequest(method, srv.URL+path, strings.NewReader(body)) + } else { + req, err = http.NewRequest(method, srv.URL+path, nil) + } + if err != nil { + t.Fatalf("new request: %v", err) + } + if body != "" { + req.Header.Set("Content-Type", "application/json") + } resp, err := srv.Client().Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + defer resp.Body.Close() + buf, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + return buf, resp.StatusCode, resp.Header + } func gitRepo(t *testing.T, name string) string { + t.Helper() + dir := filepath.Join(t.TempDir(), name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("create git repo fixture: %v", err) + } + if out, err := exec.Command("git", "init", dir).CombinedOutput(); err != nil { + t.Fatalf("git init fixture: %v\n%s", err, out) + } + return dir + } func quote(s string) string { + b, _ := json.Marshal(s) + return string(b) + } func mustJSON(t *testing.T, body []byte, out any) { + t.Helper() + if err := json.Unmarshal(body, out); err != nil { + t.Fatalf("unmarshal: %v\nbody=%s", err, body) + } + } func assertJSON(t *testing.T, headers http.Header) { + t.Helper() + if ct := headers.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Fatalf("Content-Type = %q, want JSON", ct) + } + } func assertErrorCode(t *testing.T, body []byte, status, wantStatus int, wantCode string) { + t.Helper() + if status != wantStatus { + t.Fatalf("status = %d, want %d\nbody=%s", status, wantStatus, body) + } + var got errorBody + mustJSON(t, body, &got) + if got.Code != wantCode { + t.Fatalf("code = %q, want %q\nbody=%s", got.Code, wantCode, body) + } + } diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index 6beccf8e..c97f388a 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -14,7 +14,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" "github.com/aoagents/agent-orchestrator/backend/internal/ports" - "github.com/aoagents/agent-orchestrator/backend/internal/service" + sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" ) @@ -25,7 +25,7 @@ const ( // SessionService is the controller-facing session service contract. type SessionService interface { - List(ctx context.Context, filter service.SessionListFilter) ([]domain.Session, error) + List(ctx context.Context, filter sessionsvc.ListFilter) ([]domain.Session, error) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) Get(ctx context.Context, id domain.SessionID) (domain.Session, error) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) @@ -66,17 +66,7 @@ func (c *SessionsController) list(w http.ResponseWriter, r *http.Request) { writeSessionError(w, r, err) return } - envelope.WriteJSON(w, http.StatusOK, map[string]any{"sessions": sessions}) -} - -type spawnSessionRequest struct { - ProjectID domain.ProjectID `json:"projectId"` - IssueID domain.IssueID `json:"issueId"` - Kind domain.SessionKind `json:"kind"` - Harness domain.AgentHarness `json:"harness"` - Branch string `json:"branch"` - Prompt string `json:"prompt"` - AgentRules string `json:"agentRules"` + envelope.WriteJSON(w, http.StatusOK, ListSessionsResponse{Sessions: sessions}) } func (c *SessionsController) spawn(w http.ResponseWriter, r *http.Request) { @@ -84,7 +74,7 @@ func (c *SessionsController) spawn(w http.ResponseWriter, r *http.Request) { apispec.NotImplemented(w, r, "POST", "/api/v1/sessions") return } - var in spawnSessionRequest + var in SpawnSessionRequest if err := decodeJSON(r, &in); err != nil { envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) return @@ -105,7 +95,7 @@ func (c *SessionsController) spawn(w http.ResponseWriter, r *http.Request) { writeSessionError(w, r, err) return } - envelope.WriteJSON(w, http.StatusCreated, map[string]any{"session": sess}) + envelope.WriteJSON(w, http.StatusCreated, SessionResponse{Session: sess}) } func (c *SessionsController) get(w http.ResponseWriter, r *http.Request) { @@ -118,7 +108,7 @@ func (c *SessionsController) get(w http.ResponseWriter, r *http.Request) { writeSessionError(w, r, err) return } - envelope.WriteJSON(w, http.StatusOK, map[string]any{"session": sess}) + envelope.WriteJSON(w, http.StatusOK, SessionResponse{Session: sess}) } func (c *SessionsController) rename(w http.ResponseWriter, r *http.Request) { @@ -135,7 +125,7 @@ func (c *SessionsController) restore(w http.ResponseWriter, r *http.Request) { writeSessionError(w, r, err) return } - envelope.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "sessionId": sessionID(r), "session": sess}) + envelope.WriteJSON(w, http.StatusOK, RestoreSessionResponse{OK: true, SessionID: sessionID(r), Session: sess}) } func (c *SessionsController) kill(w http.ResponseWriter, r *http.Request) { @@ -148,11 +138,7 @@ func (c *SessionsController) kill(w http.ResponseWriter, r *http.Request) { writeSessionError(w, r, err) return } - envelope.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "sessionId": sessionID(r), "freed": freed}) -} - -type sendSessionRequest struct { - Message string `json:"message"` + envelope.WriteJSON(w, http.StatusOK, KillSessionResponse{OK: true, SessionID: sessionID(r), Freed: freed}) } func (c *SessionsController) send(w http.ResponseWriter, r *http.Request) { @@ -160,7 +146,7 @@ func (c *SessionsController) send(w http.ResponseWriter, r *http.Request) { apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/send") return } - var in sendSessionRequest + var in SendSessionMessageRequest if err := decodeJSON(r, &in); err != nil { envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) return @@ -178,12 +164,7 @@ func (c *SessionsController) send(w http.ResponseWriter, r *http.Request) { writeSessionError(w, r, err) return } - envelope.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "sessionId": sessionID(r), "message": message}) -} - -type spawnOrchestratorRequest struct { - ProjectID domain.ProjectID `json:"projectId"` - Clean bool `json:"clean"` + envelope.WriteJSON(w, http.StatusOK, SendSessionMessageResponse{OK: true, SessionID: sessionID(r), Message: message}) } func (c *SessionsController) spawnOrchestrator(w http.ResponseWriter, r *http.Request) { @@ -191,7 +172,7 @@ func (c *SessionsController) spawnOrchestrator(w http.ResponseWriter, r *http.Re apispec.NotImplemented(w, r, "POST", "/api/v1/orchestrators") return } - var in spawnOrchestratorRequest + var in SpawnOrchestratorRequest if err := decodeJSON(r, &in); err != nil { envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) return @@ -202,7 +183,7 @@ func (c *SessionsController) spawnOrchestrator(w http.ResponseWriter, r *http.Re } if in.Clean { active := true - orchestrators, err := c.Svc.List(r.Context(), service.SessionListFilter{ProjectID: in.ProjectID, Active: &active, OrchestratorOnly: true}) + orchestrators, err := c.Svc.List(r.Context(), sessionsvc.ListFilter{ProjectID: in.ProjectID, Active: &active, OrchestratorOnly: true}) if err != nil { writeSessionError(w, r, err) return @@ -219,34 +200,36 @@ func (c *SessionsController) spawnOrchestrator(w http.ResponseWriter, r *http.Re writeSessionError(w, r, err) return } - envelope.WriteJSON(w, http.StatusCreated, map[string]any{"orchestrator": map[string]any{"id": sess.ID, "projectId": sess.ProjectID}}) + envelope.WriteJSON(w, http.StatusCreated, SpawnOrchestratorResponse{ + Orchestrator: OrchestratorResponse{ID: sess.ID, ProjectID: sess.ProjectID}, + }) } func sessionID(r *http.Request) domain.SessionID { return domain.SessionID(chi.URLParam(r, "sessionId")) } -func parseSessionListFilter(r *http.Request) (service.SessionListFilter, error) { +func parseSessionListFilter(r *http.Request) (sessionsvc.ListFilter, error) { q := r.URL.Query() - filter := service.SessionListFilter{ProjectID: domain.ProjectID(q.Get("project"))} + filter := sessionsvc.ListFilter{ProjectID: domain.ProjectID(q.Get("project"))} if raw := q.Get("active"); raw != "" { active, err := strconv.ParseBool(raw) if err != nil { - return service.SessionListFilter{}, errors.New("active must be a boolean") + return sessionsvc.ListFilter{}, errors.New("active must be a boolean") } filter.Active = &active } if raw := q.Get("orchestratorOnly"); raw != "" { orchestratorOnly, err := strconv.ParseBool(raw) if err != nil { - return service.SessionListFilter{}, errors.New("orchestratorOnly must be a boolean") + return sessionsvc.ListFilter{}, errors.New("orchestratorOnly must be a boolean") } filter.OrchestratorOnly = orchestratorOnly } if raw := q.Get("fresh"); raw != "" { fresh, err := strconv.ParseBool(raw) if err != nil { - return service.SessionListFilter{}, errors.New("fresh must be a boolean") + return sessionsvc.ListFilter{}, errors.New("fresh must be a boolean") } filter.Fresh = fresh } diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go index 62ef4c8c..7ec882cc 100644 --- a/backend/internal/httpd/controllers/sessions_test.go +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -13,7 +13,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/httpd" "github.com/aoagents/agent-orchestrator/backend/internal/ports" - "github.com/aoagents/agent-orchestrator/backend/internal/service" + sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" ) type fakeSessionService struct { @@ -27,7 +27,7 @@ func newFakeSessionService() *fakeSessionService { return &fakeSessionService{sessions: map[domain.SessionID]domain.Session{s.ID: s}} } -func (f *fakeSessionService) List(_ context.Context, filter service.SessionListFilter) ([]domain.Session, error) { +func (f *fakeSessionService) List(_ context.Context, filter sessionsvc.ListFilter) ([]domain.Session, error) { var out []domain.Session for _, s := range f.sessions { if filter.ProjectID != "" && s.ProjectID != filter.ProjectID { diff --git a/backend/internal/integration/lifecycle_sqlite_test.go b/backend/internal/integration/lifecycle_sqlite_test.go index b8d36332..4a103f45 100644 --- a/backend/internal/integration/lifecycle_sqlite_test.go +++ b/backend/internal/integration/lifecycle_sqlite_test.go @@ -9,9 +9,8 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" "github.com/aoagents/agent-orchestrator/backend/internal/ports" - prsvc "github.com/aoagents/agent-orchestrator/backend/internal/pr" - "github.com/aoagents/agent-orchestrator/backend/internal/project" - "github.com/aoagents/agent-orchestrator/backend/internal/service" + prsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/pr" + sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) @@ -55,7 +54,7 @@ func (c *captureMessenger) Send(_ context.Context, _ domain.SessionID, msg strin type stack struct { store *sqlite.Store - sm *service.Session + sm *sessionsvc.Service lcm *lifecycle.Manager prm *prsvc.Manager rt *stubRuntime @@ -71,7 +70,7 @@ func newStack(t *testing.T) *stack { t.Fatal(err) } t.Cleanup(func() { _ = store.Close() }) - if err := store.Upsert(ctx, project.Row{ID: "mer", Path: "/repo/mer", RegisteredAt: time.Now()}); err != nil { + if err := store.UpsertProject(ctx, domain.ProjectRecord{ID: "mer", Path: "/repo/mer", RegisteredAt: time.Now()}); err != nil { t.Fatal(err) } msg := &captureMessenger{} @@ -80,7 +79,7 @@ func newStack(t *testing.T) *stack { rt := &stubRuntime{} ws := &stubWorkspace{} mgr := sessionmanager.New(sessionmanager.Deps{Runtime: rt, Agent: stubAgent{}, Workspace: ws, Store: store, Messenger: msg, Lifecycle: lcm}) - sm := service.NewSession(mgr, store) + sm := sessionsvc.New(mgr, store) return &stack{store: store, sm: sm, lcm: lcm, prm: prm, rt: rt, ws: ws, msg: msg} } diff --git a/backend/internal/project/dto.go b/backend/internal/project/dto.go deleted file mode 100644 index 7146d455..00000000 --- a/backend/internal/project/dto.go +++ /dev/null @@ -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"` -} diff --git a/backend/internal/project/memory_store.go b/backend/internal/project/memory_store.go deleted file mode 100644 index c9f91a2a..00000000 --- a/backend/internal/project/memory_store.go +++ /dev/null @@ -1,117 +0,0 @@ -package project - -import ( - "context" - "sync" - "time" -) - -// Row mirrors the project table shape from the sqlite storage PR. The -// memory store is intentionally row-based so the API layer does not depend on a -// richer mock model than the real DB will provide. -type Row struct { - ID string - Path string - RepoOriginURL string - DisplayName string - RegisteredAt time.Time - ArchivedAt time.Time -} - -// Store is the project persistence the manager depends on. MemoryStore is the -// current in-process implementation; the sqlite adapter uses the same row shape. -type Store interface { - List(ctx context.Context) ([]Row, error) - Get(ctx context.Context, id string) (Row, bool, error) - FindByPath(ctx context.Context, path string) (Row, bool, error) - Upsert(ctx context.Context, row Row) error - Archive(ctx context.Context, id string, at time.Time) (bool, error) -} - -// MemoryStore is the mocked DB layer for the project API implementation. It is -// process-local and intentionally small, but concurrency-safe for HTTP tests. -type MemoryStore struct { - mu sync.Mutex - projects map[string]Row - paths map[string]string -} - -var _ Store = (*MemoryStore)(nil) - -// NewMemoryStore returns an empty, ready-to-use in-memory project store. -func NewMemoryStore() *MemoryStore { - return &MemoryStore{ - projects: map[string]Row{}, - paths: map[string]string{}, - } -} - -// List returns all non-archived projects, in unspecified order. -func (s *MemoryStore) List(context.Context) ([]Row, error) { - s.mu.Lock() - defer s.mu.Unlock() - - out := make([]Row, 0, len(s.projects)) - for _, row := range s.projects { - if row.ArchivedAt.IsZero() { - out = append(out, row) - } - } - return out, nil -} - -// Get returns the project with the given id, or ok=false if absent. -func (s *MemoryStore) Get(_ context.Context, id string) (Row, bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - - row, ok := s.projects[id] - if !ok { - return Row{}, false, nil - } - return row, true, nil -} - -// FindByPath returns the project registered at a filesystem path, or ok=false. -func (s *MemoryStore) FindByPath(_ context.Context, path string) (Row, bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - - id, ok := s.paths[path] - if !ok { - return Row{}, false, nil - } - row, ok := s.projects[id] - if !ok { - return Row{}, false, nil - } - return row, true, nil -} - -// Upsert inserts or replaces a project, keeping the path→id index in sync. -func (s *MemoryStore) Upsert(_ context.Context, row Row) error { - s.mu.Lock() - defer s.mu.Unlock() - - if existing, ok := s.projects[row.ID]; ok && existing.Path != row.Path { - delete(s.paths, existing.Path) - } - s.projects[row.ID] = row - s.paths[row.Path] = row.ID - return nil -} - -// Archive soft-deletes a project by stamping ArchivedAt; returns ok=false if -// the project doesn't exist. -func (s *MemoryStore) Archive(_ context.Context, id string, at time.Time) (bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - - row, ok := s.projects[id] - if !ok { - return false, nil - } - row.ArchivedAt = at - s.projects[id] = row - return true, nil -} diff --git a/backend/internal/project/project.go b/backend/internal/project/project.go deleted file mode 100644 index 14bf731a..00000000 --- a/backend/internal/project/project.go +++ /dev/null @@ -1,41 +0,0 @@ -// Package project owns the projects service contract: the Manager interface -// the HTTP layer calls and the request/response DTOs that cross it (dto.go). -// -// This is the pilot for the feature-package layout the backend is migrating -// toward: a resource's interface, implementation, and DTOs live with the -// resource, not in a central catch-all. Controllers depend on project.Manager -// and nothing beneath it. -package project - -import ( - "context" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -// Manager is the inbound contract for the /api/v1/projects surface. One -// implementation lives in this package; the HTTP controller is the consumer. -type Manager interface { - // List returns every registered project, including degraded entries - // (those whose config failed to load but whose registry entry survives). - List(ctx context.Context) ([]Summary, error) - - // Get returns one project, discriminating ok vs degraded via GetResult. - Get(ctx context.Context, id domain.ProjectID) (GetResult, error) - - // Add registers a new project from a git repository path. - Add(ctx context.Context, in AddInput) (Project, error) - - // UpdateConfig patches behaviour-only fields; identity fields are frozen. - UpdateConfig(ctx context.Context, id domain.ProjectID, patch UpdateConfigInput) (Project, error) - - // Remove unregisters a project, stopping its sessions and reclaiming - // managed workspaces. - Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) - - // Repair recovers a degraded project where automatic repair is available. - Repair(ctx context.Context, id domain.ProjectID) (Project, error) - - // Reload invalidates cached config and re-scans the global registry. - Reload(ctx context.Context) (ReloadResult, error) -} diff --git a/backend/internal/pr/manager.go b/backend/internal/service/pr/manager.go similarity index 100% rename from backend/internal/pr/manager.go rename to backend/internal/service/pr/manager.go diff --git a/backend/internal/pr/manager_test.go b/backend/internal/service/pr/manager_test.go similarity index 100% rename from backend/internal/pr/manager_test.go rename to backend/internal/service/pr/manager_test.go diff --git a/backend/internal/service/project/dto.go b/backend/internal/service/project/dto.go new file mode 100644 index 00000000..3d532932 --- /dev/null +++ b/backend/internal/service/project/dto.go @@ -0,0 +1,23 @@ +package project + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// GetResult is the discriminated result returned by Service.Get. +type GetResult struct { + Status string + Project *Project + Degraded *Degraded +} + +// AddInput is the body shape for POST /api/v1/projects. +type AddInput struct { + Path string `json:"path"` + ProjectID *string `json:"projectId,omitempty"` + Name *string `json:"name,omitempty"` +} + +// RemoveResult reports what DELETE /api/v1/projects/{id} actually did. +type RemoveResult struct { + ProjectID domain.ProjectID `json:"projectId"` + RemovedStorageDir bool `json:"removedStorageDir"` +} diff --git a/backend/internal/project/errors.go b/backend/internal/service/project/errors.go similarity index 82% rename from backend/internal/project/errors.go rename to backend/internal/service/project/errors.go index f6687e1f..9b61c49f 100644 --- a/backend/internal/project/errors.go +++ b/backend/internal/service/project/errors.go @@ -1,6 +1,6 @@ package project -// Error is the manager-level error shape controllers can translate into the +// Error is the service-level error shape controllers translate into the // locked HTTP APIError envelope without knowing store internals. type Error struct { Kind string @@ -32,10 +32,6 @@ func conflict(code, message string, details map[string]any) *Error { return newError("conflict", code, message, details) } -func notImplemented(code, message string) *Error { - return newError("not_implemented", code, message, nil) -} - func internal(code, message string) *Error { return newError("internal", code, message, nil) } diff --git a/backend/internal/project/manager.go b/backend/internal/service/project/service.go similarity index 64% rename from backend/internal/project/manager.go rename to backend/internal/service/project/service.go index 54c93b9a..0eb213ab 100644 --- a/backend/internal/project/manager.go +++ b/backend/internal/service/project/service.go @@ -13,29 +13,38 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) -type manager struct { - store Store -} +// 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) -var _ Manager = (*manager)(nil) + // Get returns one project, discriminating ok vs degraded via GetResult. + Get(ctx context.Context, id domain.ProjectID) (GetResult, error) -// NewManager returns a project Manager backed by the given Store, defaulting to -// an in-memory store when store is nil. -func NewManager(store Store) Manager { - if store == nil { - store = NewMemoryStore() - } - return &manager{store: store} + // 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) } -// NewMemoryManager returns a project Manager backed by a fresh in-memory store, -// for tests and ephemeral use. -func NewMemoryManager() Manager { - return NewManager(NewMemoryStore()) +// Service implements project registration and lookup use-cases for controllers. +type Service struct { + store Store +} + +var _ Manager = (*Service)(nil) + +// New returns a project service backed by the given durable store. +func New(store Store) *Service { + return &Service{store: store} } -func (m *manager) List(ctx context.Context) ([]Summary, error) { - projects, err := m.store.List(ctx) +// List returns every active registered project. +func (m *Service) List(ctx context.Context) ([]Summary, error) { + projects, err := m.store.ListProjects(ctx) if err != nil { return nil, internal("PROJECTS_LIST_FAILED", "Failed to load projects") } @@ -50,22 +59,24 @@ func (m *manager) List(ctx context.Context) ([]Summary, error) { return out, nil } -func (m *manager) Get(ctx context.Context, id domain.ProjectID) (GetResult, error) { +// Get returns one active project by id. +func (m *Service) Get(ctx context.Context, id domain.ProjectID) (GetResult, error) { if err := validateProjectID(id); err != nil { return GetResult{}, err } - row, ok, err := m.store.Get(ctx, string(id)) + row, ok, err := m.store.GetProject(ctx, string(id)) if err != nil { return GetResult{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") } - if !ok { + if !ok || !row.ArchivedAt.IsZero() { return GetResult{}, notFound("PROJECT_NOT_FOUND", "Unknown project") } p := projectFromRow(row) return GetResult{Status: "ok", Project: &p}, nil } -func (m *manager) Add(ctx context.Context, in AddInput) (Project, error) { +// Add registers a local git repository as a project. +func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { path, err := normalizePath(in.Path) if err != nil { return Project{}, err @@ -90,7 +101,7 @@ func (m *manager) Add(ctx context.Context, in AddInput) (Project, error) { name = string(id) } - if existing, ok, err := m.store.FindByPath(ctx, path); err != nil { + if existing, ok, err := m.store.FindProjectByPath(ctx, path); err != nil { return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") } else if ok { return Project{}, conflict("PATH_ALREADY_REGISTERED", "A project at this path is already registered", map[string]any{ @@ -98,47 +109,33 @@ func (m *manager) Add(ctx context.Context, in AddInput) (Project, error) { "suggestedProjectId": string(m.suggestID(ctx, id)), }) } - if existing, ok, err := m.store.Get(ctx, string(id)); err != nil { + if existing, ok, err := m.store.GetProject(ctx, string(id)); err != nil { return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") - } else if ok && existing.Path != path { + } else if ok && existing.ArchivedAt.IsZero() && existing.Path != path { return Project{}, conflict("ID_ALREADY_REGISTERED", "A project with this id is already registered for a different path", map[string]any{ "existingProjectId": existing.ID, "suggestedProjectId": string(m.suggestID(ctx, id)), }) } - row := Row{ + row := domain.ProjectRecord{ ID: string(id), Path: path, DisplayName: name, RegisteredAt: time.Now(), } - if err := m.store.Upsert(ctx, row); err != nil { + if err := m.store.UpsertProject(ctx, row); err != nil { return Project{}, err } return projectFromRow(row), nil } -func (m *manager) UpdateConfig(ctx context.Context, id domain.ProjectID, _ UpdateConfigInput) (Project, error) { - if err := validateProjectID(id); err != nil { - return Project{}, err - } - _, ok, err := m.store.Get(ctx, string(id)) - if err != nil { - return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") - } - if !ok { - return Project{}, notFound("PROJECT_NOT_FOUND", "Unknown project") - } - - return Project{}, notImplemented("PROJECT_CONFIG_NOT_IMPLEMENTED", "Project config patching is not available until config persistence is wired") -} - -func (m *manager) Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) { +// Remove archives a project registration. +func (m *Service) Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) { if err := validateProjectID(id); err != nil { return RemoveResult{}, err } - ok, err := m.store.Archive(ctx, string(id), time.Now()) + ok, err := m.store.ArchiveProject(ctx, string(id), time.Now()) if err != nil { return RemoveResult{}, internal("PROJECT_REMOVE_FAILED", "Failed to remove project") } @@ -148,36 +145,16 @@ func (m *manager) Remove(ctx context.Context, id domain.ProjectID) (RemoveResult return RemoveResult{ProjectID: id, RemovedStorageDir: false}, nil } -func (m *manager) Repair(ctx context.Context, id domain.ProjectID) (Project, error) { - if err := validateProjectID(id); err != nil { - return Project{}, err - } - if _, ok, err := m.store.Get(ctx, string(id)); err != nil { - return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") - } else if !ok { - return Project{}, notFound("PROJECT_NOT_FOUND", "Unknown project") - } - return Project{}, badRequest("REPAIR_NOT_AVAILABLE", "Automatic repair is not available for this degraded config", nil) -} - -func (m *manager) Reload(ctx context.Context) (ReloadResult, error) { - projects, err := m.store.List(ctx) - if err != nil { - return ReloadResult{}, internal("RELOAD_FAILED", "Failed to reload projects") - } - return ReloadResult{Reloaded: true, ProjectCount: len(projects), DegradedCount: 0}, nil -} - -func (m *manager) suggestID(ctx context.Context, base domain.ProjectID) domain.ProjectID { +func (m *Service) suggestID(ctx context.Context, base domain.ProjectID) domain.ProjectID { for i := 1; ; i++ { candidate := domain.ProjectID(string(base) + strconv.Itoa(i)) - if _, ok, _ := m.store.Get(ctx, string(candidate)); !ok { + if _, ok, _ := m.store.GetProject(ctx, string(candidate)); !ok { return candidate } } } -func projectFromRow(row Row) Project { +func projectFromRow(row domain.ProjectRecord) Project { return Project{ ID: domain.ProjectID(row.ID), Name: displayName(row), @@ -187,7 +164,7 @@ func projectFromRow(row Row) Project { } } -func displayName(row Row) string { +func displayName(row domain.ProjectRecord) string { if strings.TrimSpace(row.DisplayName) != "" { return row.DisplayName } diff --git a/backend/internal/service/project/service_test.go b/backend/internal/service/project/service_test.go new file mode 100644 index 00000000..d8ae22ec --- /dev/null +++ b/backend/internal/service/project/service_test.go @@ -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) + } +} diff --git a/backend/internal/service/project/store.go b/backend/internal/service/project/store.go new file mode 100644 index 00000000..504179c8 --- /dev/null +++ b/backend/internal/service/project/store.go @@ -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) +} diff --git a/backend/internal/project/types.go b/backend/internal/service/project/types.go similarity index 54% rename from backend/internal/project/types.go rename to backend/internal/service/project/types.go index 9e1e8b94..618500b4 100644 --- a/backend/internal/project/types.go +++ b/backend/internal/service/project/types.go @@ -2,17 +2,7 @@ package project import "github.com/aoagents/agent-orchestrator/backend/internal/domain" -// Project entities and the behaviour-config shapes they expose. These live in -// the project package (not domain/) because they are owned solely by the -// projects surface — only project identity (domain.ProjectID) is shared -// vocabulary with sessions/lifecycle/workspace, so that one type stays in -// domain. Keeping the entities, the Manager interface (project.go), and the -// transport DTOs (dto.go) together is the feature-package layout the backend -// is migrating toward. - -// Summary is the row shape returned by GET /api/v1/projects. ResolveError is -// set only for degraded projects, so the list can show them with a warning -// instead of dropping them silently. +// Summary is the row shape returned by GET /api/v1/projects. type Summary struct { ID domain.ProjectID `json:"id"` Name string `json:"name"` @@ -20,24 +10,19 @@ type Summary struct { ResolveError string `json:"resolveError,omitempty"` } -// Project is the full read-model returned by GET /api/v1/projects/{id} when the -// project resolves cleanly. It joins the registry identity fields with the -// project's behaviour config. +// Project is the full read-model returned by GET /api/v1/projects/{id}. type Project struct { ID domain.ProjectID `json:"id"` Name string `json:"name"` Path string `json:"path"` - Repo string `json:"repo"` // "owner/name" or "" + Repo string `json:"repo"` DefaultBranch string `json:"defaultBranch"` Agent string `json:"agent,omitempty"` Tracker *TrackerConfig `json:"tracker,omitempty"` SCM *SCMConfig `json:"scm,omitempty"` } -// Degraded is returned in place of Project when the project's config failed to -// load. The frontend uses ResolveError to render a recovery UI; the -// /projects/{id}/repair endpoint fixes a recoverable subset (e.g. legacy -// wrapped-config format). +// Degraded is returned in place of Project when project config failed to load. type Degraded struct { ID domain.ProjectID `json:"id"` Name string `json:"name"` @@ -45,18 +30,14 @@ type Degraded struct { ResolveError string `json:"resolveError"` } -// Behaviour-config shapes exposed by the projects API. Runtime selection and -// reaction rules are intentionally absent: the daemon has one runtime adapter and -// lifecycle owns agent nudges. - -// TrackerConfig mirrors TrackerConfigSchema. +// TrackerConfig mirrors tracker behaviour config exposed by the projects API. type TrackerConfig struct { Plugin string `json:"plugin,omitempty"` Package string `json:"package,omitempty"` Path string `json:"path,omitempty"` } -// SCMConfig mirrors SCMConfigSchema; Webhook nests its own optional block. +// SCMConfig mirrors SCM behaviour config exposed by the projects API. type SCMConfig struct { Plugin string `json:"plugin,omitempty"` Package string `json:"package,omitempty"` @@ -64,7 +45,7 @@ type SCMConfig struct { Webhook *SCMWebhookConfig `json:"webhook,omitempty"` } -// SCMWebhookConfig — pointer Enabled distinguishes unset from explicit false. +// SCMWebhookConfig describes SCM webhook settings. type SCMWebhookConfig struct { Enabled *bool `json:"enabled,omitempty"` Path string `json:"path,omitempty"` diff --git a/backend/internal/service/session.go b/backend/internal/service/session/service.go similarity index 73% rename from backend/internal/service/session.go rename to backend/internal/service/session/service.go index fae8d76b..fe2e63e7 100644 --- a/backend/internal/service/session.go +++ b/backend/internal/service/session/service.go @@ -1,4 +1,4 @@ -package service +package session import ( "context" @@ -9,37 +9,37 @@ import ( sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" ) -// SessionStore is the read-only persistence surface needed to assemble controller-facing session read models. -type SessionStore interface { +// Store is the read-only persistence surface needed to assemble controller-facing session read models. +type Store interface { GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error) GetDisplayPRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, bool, error) } -// SessionListFilter captures API-facing session list query filters. -type SessionListFilter struct { +// ListFilter captures API-facing session list query filters. +type ListFilter struct { ProjectID domain.ProjectID Active *bool OrchestratorOnly bool Fresh bool } -// Session is the controller-facing session service. It delegates command-side +// Service is the controller-facing session service. It delegates command-side // session operations to the internal sessionmanager.Manager and owns read-model // assembly, including user-facing display status derivation. -type Session struct { +type Service struct { manager *sessionmanager.Manager - store SessionStore + store Store } -// NewSession wires a controller-facing session service over an internal session Manager. -func NewSession(manager *sessionmanager.Manager, store SessionStore) *Session { - return &Session{manager: manager, store: store} +// New wires a controller-facing session service over an internal session Manager. +func New(manager *sessionmanager.Manager, store Store) *Service { + return &Service{manager: manager, store: store} } // Spawn creates a session and returns the API-facing read model. -func (s *Session) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) { +func (s *Service) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) { rec, err := s.manager.Spawn(ctx, cfg) if err != nil { return domain.Session{}, err @@ -48,7 +48,7 @@ func (s *Session) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess } // Restore relaunches a terminated session and returns the API-facing read model. -func (s *Session) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) { +func (s *Service) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) { rec, err := s.manager.Restore(ctx, id) if err != nil { return domain.Session{}, err @@ -57,22 +57,22 @@ func (s *Session) Restore(ctx context.Context, id domain.SessionID) (domain.Sess } // Kill delegates terminal intent and teardown to the internal manager. -func (s *Session) Kill(ctx context.Context, id domain.SessionID) (bool, error) { +func (s *Service) Kill(ctx context.Context, id domain.SessionID) (bool, error) { return s.manager.Kill(ctx, id) } // Send delegates agent messaging to the internal manager. -func (s *Session) Send(ctx context.Context, id domain.SessionID, message string) error { +func (s *Service) Send(ctx context.Context, id domain.SessionID, message string) error { return s.manager.Send(ctx, id, message) } // Cleanup delegates terminal workspace cleanup to the internal manager. -func (s *Session) Cleanup(ctx context.Context, project domain.ProjectID) ([]domain.SessionID, error) { +func (s *Service) Cleanup(ctx context.Context, project domain.ProjectID) ([]domain.SessionID, error) { return s.manager.Cleanup(ctx, project) } // List returns sessions as enriched display models after applying API filters. -func (s *Session) List(ctx context.Context, filter SessionListFilter) ([]domain.Session, error) { +func (s *Service) List(ctx context.Context, filter ListFilter) ([]domain.Session, error) { recs, err := s.listRecords(ctx, filter.ProjectID) if err != nil { return nil, err @@ -91,7 +91,7 @@ func (s *Session) List(ctx context.Context, filter SessionListFilter) ([]domain. return out, nil } -func (s *Session) listRecords(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) { +func (s *Service) listRecords(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) { if project == "" { recs, err := s.store.ListAllSessions(ctx) if err != nil { @@ -106,7 +106,7 @@ func (s *Session) listRecords(ctx context.Context, project domain.ProjectID) ([] return recs, nil } -func matchesSessionFilter(rec domain.SessionRecord, filter SessionListFilter) bool { +func matchesSessionFilter(rec domain.SessionRecord, filter ListFilter) bool { if filter.Active != nil && rec.IsTerminated == *filter.Active { return false } @@ -120,7 +120,7 @@ func matchesSessionFilter(rec domain.SessionRecord, filter SessionListFilter) bo } // Get returns one session as an enriched display model, or sessionmanager.ErrNotFound if it is absent. -func (s *Session) Get(ctx context.Context, id domain.SessionID) (domain.Session, error) { +func (s *Service) Get(ctx context.Context, id domain.SessionID) (domain.Session, error) { rec, ok, err := s.store.GetSession(ctx, id) if err != nil { return domain.Session{}, fmt.Errorf("get %s: %w", id, err) @@ -131,7 +131,7 @@ func (s *Session) Get(ctx context.Context, id domain.SessionID) (domain.Session, return s.toSession(ctx, rec) } -func (s *Session) toSession(ctx context.Context, rec domain.SessionRecord) (domain.Session, error) { +func (s *Service) toSession(ctx context.Context, rec domain.SessionRecord) (domain.Session, error) { pr, ok, err := s.store.GetDisplayPRFactsForSession(ctx, rec.ID) if err != nil { return domain.Session{}, fmt.Errorf("pr facts %s: %w", rec.ID, err) diff --git a/backend/internal/service/session_test.go b/backend/internal/service/session/service_test.go similarity index 54% rename from backend/internal/service/session_test.go rename to backend/internal/service/session/service_test.go index f092dc19..25c9610f 100644 --- a/backend/internal/service/session_test.go +++ b/backend/internal/service/session/service_test.go @@ -1,4 +1,4 @@ -package service +package session import ( "context" @@ -8,29 +8,29 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) -type fakeSessionStore struct { +type fakeStore struct { sessions map[domain.SessionID]domain.SessionRecord pr map[domain.SessionID]domain.PRFacts num int } -func newFakeSessionStore() *fakeSessionStore { - return &fakeSessionStore{sessions: map[domain.SessionID]domain.SessionRecord{}, pr: map[domain.SessionID]domain.PRFacts{}} +func newFakeStore() *fakeStore { + return &fakeStore{sessions: map[domain.SessionID]domain.SessionRecord{}, pr: map[domain.SessionID]domain.PRFacts{}} } -func (f *fakeSessionStore) CreateSession(_ context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) { +func (f *fakeStore) CreateSession(_ context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) { f.num++ rec.ID = domain.SessionID(fmt.Sprintf("%s-%d", rec.ProjectID, f.num)) f.sessions[rec.ID] = rec return rec, nil } -func (f *fakeSessionStore) GetSession(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) { +func (f *fakeStore) GetSession(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) { r, ok := f.sessions[id] return r, ok, nil } -func (f *fakeSessionStore) ListSessions(_ context.Context, p domain.ProjectID) ([]domain.SessionRecord, error) { +func (f *fakeStore) ListSessions(_ context.Context, p domain.ProjectID) ([]domain.SessionRecord, error) { var out []domain.SessionRecord for _, r := range f.sessions { if r.ProjectID == p { @@ -40,7 +40,7 @@ func (f *fakeSessionStore) ListSessions(_ context.Context, p domain.ProjectID) ( return out, nil } -func (f *fakeSessionStore) ListAllSessions(_ context.Context) ([]domain.SessionRecord, error) { +func (f *fakeStore) ListAllSessions(_ context.Context) ([]domain.SessionRecord, error) { out := make([]domain.SessionRecord, 0, len(f.sessions)) for _, r := range f.sessions { out = append(out, r) @@ -48,17 +48,17 @@ func (f *fakeSessionStore) ListAllSessions(_ context.Context) ([]domain.SessionR return out, nil } -func (f *fakeSessionStore) GetDisplayPRFactsForSession(_ context.Context, id domain.SessionID) (domain.PRFacts, bool, error) { +func (f *fakeStore) GetDisplayPRFactsForSession(_ context.Context, id domain.SessionID) (domain.PRFacts, bool, error) { pr, ok := f.pr[id] return pr, ok, nil } func TestSessionListDerivesStatusFromPRFacts(t *testing.T) { - st := newFakeSessionStore() + st := newFakeStore() st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Activity: domain.Activity{State: domain.ActivityActive}} st.pr["mer-1"] = domain.PRFacts{URL: "pr1", CI: domain.CIFailing} - list, err := (&Session{store: st}).List(context.Background(), SessionListFilter{ProjectID: "mer"}) + list, err := (&Service{store: st}).List(context.Background(), ListFilter{ProjectID: "mer"}) if err != nil { t.Fatal(err) } diff --git a/backend/internal/service/session_status.go b/backend/internal/service/session/status.go similarity index 98% rename from backend/internal/service/session_status.go rename to backend/internal/service/session/status.go index 801b7b19..ee929852 100644 --- a/backend/internal/service/session_status.go +++ b/backend/internal/service/session/status.go @@ -1,4 +1,4 @@ -package service +package session import "github.com/aoagents/agent-orchestrator/backend/internal/domain" diff --git a/backend/internal/service/session_status_test.go b/backend/internal/service/session/status_test.go similarity index 99% rename from backend/internal/service/session_status_test.go rename to backend/internal/service/session/status_test.go index b45125e1..3543214b 100644 --- a/backend/internal/service/session_status_test.go +++ b/backend/internal/service/session/status_test.go @@ -1,4 +1,4 @@ -package service +package session import ( "testing" diff --git a/backend/internal/storage/sqlite/gen/projects.sql.go b/backend/internal/storage/sqlite/gen/projects.sql.go index be255c5b..89c99d1e 100644 --- a/backend/internal/storage/sqlite/gen/projects.sql.go +++ b/backend/internal/storage/sqlite/gen/projects.sql.go @@ -32,7 +32,7 @@ func (q *Queries) ArchiveProject(ctx context.Context, arg ArchiveProjectParams) const findProjectByPath = `-- name: FindProjectByPath :one SELECT id, path, repo_origin_url, display_name, registered_at, archived_at -FROM projects WHERE path = ? +FROM projects WHERE path = ? AND archived_at IS NULL ` func (q *Queries) FindProjectByPath(ctx context.Context, path string) (Project, error) { diff --git a/backend/internal/storage/sqlite/queries/projects.sql b/backend/internal/storage/sqlite/queries/projects.sql index c5706035..3d41d0d5 100644 --- a/backend/internal/storage/sqlite/queries/projects.sql +++ b/backend/internal/storage/sqlite/queries/projects.sql @@ -17,7 +17,7 @@ FROM projects WHERE archived_at IS NULL ORDER BY id; -- name: FindProjectByPath :one SELECT id, path, repo_origin_url, display_name, registered_at, archived_at -FROM projects WHERE path = ?; +FROM projects WHERE path = ? AND archived_at IS NULL; -- name: ArchiveProject :execrows -UPDATE projects SET archived_at = ? WHERE id = ?; +UPDATE projects SET archived_at = ? WHERE id = ? AND archived_at IS NULL; diff --git a/backend/internal/storage/sqlite/store/project_store.go b/backend/internal/storage/sqlite/store/project_store.go index 1d216d3e..932205a8 100644 --- a/backend/internal/storage/sqlite/store/project_store.go +++ b/backend/internal/storage/sqlite/store/project_store.go @@ -8,14 +8,11 @@ import ( "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/project" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" ) -var _ project.Store = (*Store)(nil) - -// Upsert inserts or replaces a registered project row. -func (s *Store) Upsert(ctx context.Context, r project.Row) error { +// UpsertProject inserts or replaces a registered project row. +func (s *Store) UpsertProject(ctx context.Context, r domain.ProjectRecord) error { s.writeMu.Lock() defer s.writeMu.Unlock() return s.qw.UpsertProject(ctx, gen.UpsertProjectParams{ @@ -28,45 +25,45 @@ func (s *Store) Upsert(ctx context.Context, r project.Row) error { }) } -// Get returns a project by id, active or archived. -func (s *Store) Get(ctx context.Context, id string) (project.Row, bool, error) { +// GetProject returns a project by id, active or archived. +func (s *Store) GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error) { p, err := s.qr.GetProject(ctx, domain.ProjectID(id)) if errors.Is(err, sql.ErrNoRows) { - return project.Row{}, false, nil + return domain.ProjectRecord{}, false, nil } if err != nil { - return project.Row{}, false, fmt.Errorf("get project %s: %w", id, err) + return domain.ProjectRecord{}, false, fmt.Errorf("get project %s: %w", id, err) } return projectRowFromGen(p), true, nil } -// FindByPath returns a project registered at path, active or archived. -func (s *Store) FindByPath(ctx context.Context, path string) (project.Row, bool, error) { +// FindProjectByPath returns a project registered at path, active or archived. +func (s *Store) FindProjectByPath(ctx context.Context, path string) (domain.ProjectRecord, bool, error) { p, err := s.qr.FindProjectByPath(ctx, path) if errors.Is(err, sql.ErrNoRows) { - return project.Row{}, false, nil + return domain.ProjectRecord{}, false, nil } if err != nil { - return project.Row{}, false, fmt.Errorf("find project by path %s: %w", path, err) + return domain.ProjectRecord{}, false, fmt.Errorf("find project by path %s: %w", path, err) } return projectRowFromGen(p), true, nil } -// List returns active projects ordered by id. -func (s *Store) List(ctx context.Context) ([]project.Row, error) { +// ListProjects returns active projects ordered by id. +func (s *Store) ListProjects(ctx context.Context) ([]domain.ProjectRecord, error) { rows, err := s.qr.ListProjects(ctx) if err != nil { return nil, fmt.Errorf("list projects: %w", err) } - out := make([]project.Row, 0, len(rows)) + out := make([]domain.ProjectRecord, 0, len(rows)) for _, p := range rows { out = append(out, projectRowFromGen(p)) } return out, nil } -// Archive soft-deletes a project and reports whether a row was affected. -func (s *Store) Archive(ctx context.Context, id string, at time.Time) (bool, error) { +// ArchiveProject soft-deletes a project and reports whether a row was affected. +func (s *Store) ArchiveProject(ctx context.Context, id string, at time.Time) (bool, error) { s.writeMu.Lock() defer s.writeMu.Unlock() n, err := s.qw.ArchiveProject(ctx, gen.ArchiveProjectParams{ @@ -79,8 +76,8 @@ func (s *Store) Archive(ctx context.Context, id string, at time.Time) (bool, err return n > 0, nil } -func projectRowFromGen(p gen.Project) project.Row { - r := project.Row{ +func projectRowFromGen(p gen.Project) domain.ProjectRecord { + r := domain.ProjectRecord{ ID: string(p.ID), Path: p.Path, RepoOriginURL: p.RepoOriginURL, diff --git a/backend/internal/storage/sqlite/store/store_test.go b/backend/internal/storage/sqlite/store/store_test.go index 9befe1c2..7731e5ca 100644 --- a/backend/internal/storage/sqlite/store/store_test.go +++ b/backend/internal/storage/sqlite/store/store_test.go @@ -8,7 +8,6 @@ import ( "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/project" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) @@ -24,7 +23,7 @@ func newTestStore(t *testing.T) *sqlite.Store { func seedProject(t *testing.T, s *sqlite.Store, id string) { t.Helper() - if err := s.Upsert(context.Background(), project.Row{ + if err := s.UpsertProject(context.Background(), domain.ProjectRecord{ ID: id, Path: "/tmp/" + id, RegisteredAt: time.Now().UTC().Truncate(time.Second), }); err != nil { t.Fatalf("seed project %s: %v", id, err) @@ -49,24 +48,24 @@ func TestProjectCRUDAndArchive(t *testing.T) { ctx := context.Background() seedProject(t, s, "mer") - got, ok, err := s.Get(ctx, "mer") + got, ok, err := s.GetProject(ctx, "mer") if err != nil || !ok { t.Fatalf("get: ok=%v err=%v", ok, err) } if got.ID != "mer" || got.Path != "/tmp/mer" { t.Fatalf("project = %+v", got) } - if list, _ := s.List(ctx); len(list) != 1 { + if list, _ := s.ListProjects(ctx); len(list) != 1 { t.Fatalf("active list = %d, want 1", len(list)) } // archive hides from the active list but still resolves by id. - if ok, err := s.Archive(ctx, "mer", time.Now().UTC()); err != nil || !ok { + if ok, err := s.ArchiveProject(ctx, "mer", time.Now().UTC()); err != nil || !ok { t.Fatalf("archive: ok=%v err=%v", ok, err) } - if list, _ := s.List(ctx); len(list) != 0 { + if list, _ := s.ListProjects(ctx); len(list) != 0 { t.Fatalf("after archive, active list = %d, want 0", len(list)) } - if _, ok, _ := s.Get(ctx, "mer"); !ok { + if _, ok, _ := s.GetProject(ctx, "mer"); !ok { t.Fatal("archived project must still resolve by id") } }