diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index e3ceaf1c..dcf9695d 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -6,6 +6,7 @@ on:
pull_request:
paths:
- "backend/**"
+ - "frontend/**"
- ".github/workflows/go.yml"
permissions:
@@ -22,7 +23,7 @@ jobs:
- uses: actions/setup-go@v5
with:
- go-version: "1.22"
+ go-version: "1.25.x"
cache: false
- name: Check formatting
@@ -42,3 +43,40 @@ jobs:
- name: Test
run: go test -race ./...
+
+ # gen-verify regenerates the code-first artifacts (openapi.yaml from Go, then
+ # the frontend TS types from that spec) and fails if the committed copies are
+ # stale — i.e. someone changed a Go contract type without running
+ # `go generate ./...` + `npm run gen:api`.
+ gen-verify:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: "1.25.x"
+ cache: false
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Generate OpenAPI from Go
+ working-directory: backend
+ run: go generate ./...
+
+ - name: Generate TypeScript from OpenAPI
+ working-directory: frontend
+ run: |
+ npm ci
+ npm run gen:api
+
+ - name: Fail on stale generated files
+ run: |
+ if ! git diff --exit-code; then
+ echo "::error::Generated files are stale. Run 'go generate ./...' in backend and 'npm run gen:api' in frontend, then commit."
+ exit 1
+ fi
diff --git a/README.md b/README.md
index f5c17deb..2933dd92 100644
--- a/README.md
+++ b/README.md
@@ -49,3 +49,28 @@ cd backend
gofmt -l . && go build ./... && go vet ./... && go test -race ./...
```
+## API contract (code-first OpenAPI)
+
+The `/api/v1` contract is **code-first**: the Go request/response types are the
+source of truth, and `backend/internal/httpd/apispec/openapi.yaml` plus the
+frontend types (`frontend/src/api/schema.d.ts`) are **generated** from them.
+Never hand-edit those files.
+
+**To change or add a route:**
+
+1. Edit the Go types (request/response structs + their `description`/`enum`/
+ `default` tags); for a new route also add the handler in
+ `controllers/projects.go` and its entry in `projectOperations()` in
+ `apispec/build.go`.
+2. Regenerate:
+ ```bash
+ cd backend && go generate ./... # Go → openapi.yaml
+ npm --prefix frontend run gen:api # openapi.yaml → schema.d.ts
+ ```
+3. `go test ./...` — the drift and route↔spec parity tests fail if anything is
+ out of sync.
+4. Commit the Go change together with the regenerated `openapi.yaml` and
+ `schema.d.ts`. CI's `gen-verify` job blocks merges on stale artifacts.
+
+Full details and rationale: [`docs/api-contract.md`](docs/api-contract.md).
+
diff --git a/backend/cmd/genspec/main.go b/backend/cmd/genspec/main.go
new file mode 100644
index 00000000..78372c5e
--- /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"
+)
+
+func main() {
+ out := flag.String("out", "openapi.yaml", "output path for the generated OpenAPI document")
+ flag.Parse()
+
+ doc, err := apispec.Build()
+ if err != nil {
+ log.Fatalf("genspec: build openapi: %v", err)
+ }
+ if err := os.WriteFile(*out, doc, 0o644); err != nil {
+ log.Fatalf("genspec: write %s: %v", *out, err)
+ }
+}
diff --git a/backend/go.mod b/backend/go.mod
index f270a14c..043ddbcc 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -7,6 +7,8 @@ require (
github.com/creack/pty v1.1.24
github.com/go-chi/chi/v5 v5.1.0
github.com/pressly/goose/v3 v3.27.1
+ github.com/swaggest/jsonschema-go v0.3.78
+ github.com/swaggest/openapi-go v0.2.61
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.51.0
)
@@ -19,9 +21,11 @@ require (
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sethvargo/go-retry v0.3.0 // 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
golang.org/x/sys v0.43.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 84823bbd..9663397f 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/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
@@ -14,6 +18,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/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
@@ -26,10 +32,24 @@ github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5s
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
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/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/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.78 h1:5+YFQrLxOR8z6CHvgtZc42WRy/Q9zRQQ4HoAxlinlHw=
+github.com/swaggest/jsonschema-go v0.3.78/go.mod h1:4nniXBuE+FIGkOGuidjOINMH7OEqZK3HCSbfDuLRI0g=
+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=
@@ -42,6 +62,8 @@ golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
diff --git a/backend/internal/httpd/api.go b/backend/internal/httpd/api.go
index 124a8d78..4b97478d 100644
--- a/backend/internal/httpd/api.go
+++ b/backend/internal/httpd/api.go
@@ -9,27 +9,19 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/config"
"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/httpx"
"github.com/aoagents/agent-orchestrator/backend/internal/project"
)
-// APIDeps bundles every Manager the API layer's controllers depend on. There
-// is exactly one Manager per resource, defined in that resource's own package
-// (project.Manager, later session.Manager, ...), and the controllers see ONLY
-// that interface — they don't reach past it to the LCM, adapters, or stores.
-// Whether a Manager impl talks to the registry, the LCM, or an outbound port
-// is its own concern.
-//
-// The route-shell PR (#20) leaves every field nil — handlers answer via
-// apispec.NotImplemented and don't dereference them yet. The handler-impl PR
-// wires real Managers and flips stubs to real logic one route at a time.
+// APIDeps bundles one Manager per resource, each defined in its own feature
+// package (project.Manager, later session.Manager, ...). While handlers are
+// stubs every field is nil; the handler-impl PR wires real Managers.
type APIDeps struct {
Projects project.Manager
}
-// API owns one controller per resource and is the single Register call the
-// router invokes to mount the /api/v1 surface. Splitting per-resource means
-// later PRs can land a controller's real handlers without touching the
-// surrounding wiring.
+// API owns one controller per resource and exposes the single Register call the
+// router invokes to mount the /api/v1 surface.
type API struct {
cfg config.Config
projects *controllers.ProjectsController
@@ -47,13 +39,11 @@ func NewAPI(cfg config.Config, deps APIDeps) *API {
}
}
-// Register mounts the API surface on root. /api/v1 hosts the REST group with
-// the per-request Timeout that the skeleton router (router.go) deliberately
-// kept off the global stack — REST routes are bounded, but long-lived surfaces
-// (/events SSE, /mux WS) live outside this group when they land.
-//
-// /mux is mounted outside /api/v1 for parity with the legacy TS surface; it is
-// a phase-4 placeholder and stays unregistered here until that lane starts.
+// Register mounts the /api/v1 REST surface on root. It serves the OpenAPI
+// document at /api/v1/openapi.yaml and wraps every controller route in a
+// per-request Timeout group, so the bounded REST handlers are time-limited
+// without affecting the health probes that router.go keeps off the global
+// stack.
func (a *API) Register(root chi.Router) {
timeout := a.cfg.RequestTimeout
if timeout <= 0 {
@@ -62,19 +52,17 @@ func (a *API) Register(root chi.Router) {
root.Route("/api/v1", func(r chi.Router) {
// The OpenAPI document is the source of truth for every contract on
- // this surface; serve it so tooling (SDK generators, the OpenAPI
- // validator in #19, the dashboard's developer tools) can fetch the
- // whole spec from the same origin as the routes it describes.
+ // this surface; serve it so tooling (SDK generators, OpenAPI
+ // validators, the dashboard's developer tools) can fetch the whole
+ // spec from the same origin as the routes it describes.
apispec.RegisterServe(r, "/openapi.yaml")
r.Group(func(r chi.Router) {
r.Use(middleware.Timeout(timeout))
a.projects.Register(r)
- // Sibling controllers (sessions, issues, prs, ...) plug in here in
- // follow-up PRs #21 / #22 without touching the timeout group.
+ // Additional resource controllers register inside this same
+ // timeout group.
})
- // Surfaces that intentionally bypass the REST timeout (SSE, future WS)
- // register at this level — none exist in the route-shell PR.
})
}
@@ -82,7 +70,7 @@ func (a *API) Register(root chi.Router) {
// 404 is a text/plain body; the API surface must answer JSON so consumers can
// parse it uniformly.
func notFoundJSON(w http.ResponseWriter, r *http.Request) {
- writeAPIError(w, r, http.StatusNotFound, "not_found", "ROUTE_NOT_FOUND",
+ httpx.WriteError(w, r, http.StatusNotFound, "not_found", "ROUTE_NOT_FOUND",
r.Method+" "+r.URL.Path+" has no handler", nil)
}
@@ -90,6 +78,6 @@ func notFoundJSON(w http.ResponseWriter, r *http.Request) {
// known path without a matching verb (e.g. PUT /projects/{id} after we drop
// the legacy PUT alias).
func methodNotAllowedJSON(w http.ResponseWriter, r *http.Request) {
- writeAPIError(w, r, http.StatusMethodNotAllowed, "method_not_allowed", "METHOD_NOT_ALLOWED",
+ httpx.WriteError(w, r, http.StatusMethodNotAllowed, "method_not_allowed", "METHOD_NOT_ALLOWED",
r.Method+" not allowed on "+r.URL.Path, nil)
}
diff --git a/backend/internal/httpd/apispec/apispec.go b/backend/internal/httpd/apispec/apispec.go
index 627ad5db..01a30137 100644
--- a/backend/internal/httpd/apispec/apispec.go
+++ b/backend/internal/httpd/apispec/apispec.go
@@ -1,157 +1,31 @@
-// Package apispec embeds the OpenAPI document, looks up per-operation
-// slices, and writes the locked 501 envelope. The 501 body carries the
-// operation's slice of the OpenAPI document so consumers discover the
-// contract from the endpoint itself — no duplicate planned/contract
-// metadata lives in code.
-//
-// The same document is served verbatim at /api/v1/openapi.yaml so
-// tooling that wants the whole spec can fetch it once.
+// Package apispec embeds the generated OpenAPI document (see build.go) and
+// serves it verbatim at /api/v1/openapi.yaml. The document is generated from Go
+// and drift-checked against build.go (build_internal_test.go), so this package
+// only needs to embed and publish it — no parsing or validation at runtime.
package apispec
import (
_ "embed"
- "encoding/json"
- "fmt"
"net/http"
- "strings"
- "sync"
"github.com/go-chi/chi/v5"
- "github.com/go-chi/chi/v5/middleware"
- yaml "gopkg.in/yaml.v3"
)
//go:embed openapi.yaml
var openapiYAML []byte
-// Spec is the parsed, in-memory view of the embedded OpenAPI document. It
-// 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
-}
-
-var (
- defaultOnce sync.Once
- defaultSpec *Spec
- defaultErr error
-)
-
-// Default returns the process-wide spec parsed from the embedded YAML. It
-// panics on a malformed embed — that is a build-time bug, not a runtime
-// one, so failing fast at first use is the right call.
-func Default() *Spec {
- defaultOnce.Do(func() {
- s, err := New(openapiYAML)
- defaultSpec = s
- defaultErr = err
- })
- if defaultErr != nil {
- panic(fmt.Sprintf("apispec: embedded openapi.yaml failed to parse: %v", defaultErr))
- }
- return defaultSpec
-}
-
-// New parses the supplied YAML bytes. Exposed so tests can construct an
-// independent spec without touching the embedded default.
-func New(yamlBytes []byte) (*Spec, error) {
- var doc map[string]any
- if err := yaml.Unmarshal(yamlBytes, &doc); err != nil {
- return nil, fmt.Errorf("parse openapi: %w", err)
- }
- if doc == nil {
- return nil, fmt.Errorf("parse openapi: empty document")
- }
- return &Spec{doc: doc}, nil
-}
-
-// YAML returns the raw embedded document bytes. Used by the /openapi.yaml
-// handler.
-func (s *Spec) YAML() []byte { return openapiYAML }
-
-// Operation returns the spec slice for a single (method, path) pair, ready
-// to be JSON-serialised. The slice is the OpenAPI Operation object (the
-// inner block under e.g. paths./projects.get), with parent path-level
-// parameters merged in for completeness.
-//
-// Returns nil if the path or method is not in the spec; that is treated as
-// a developer error (route registered without spec coverage) — callers
-// log/fail loudly rather than silently writing a partial 501 body.
-func (s *Spec) Operation(method, path string) map[string]any {
- paths, _ := s.doc["paths"].(map[string]any)
- if paths == nil {
- return nil
- }
- pathItem, _ := paths[path].(map[string]any)
- if pathItem == nil {
- return nil
- }
- op, _ := pathItem[strings.ToLower(method)].(map[string]any)
- if op == nil {
- return nil
- }
-
- // Path-level parameters apply to every method on that path; merge them
- // in so the slice is self-contained.
- out := make(map[string]any, len(op)+1)
- for k, v := range op {
- out[k] = v
- }
- if params, ok := pathItem["parameters"]; ok {
- // Prefer the operation's own parameters when both are present;
- // otherwise inherit from the path level.
- if _, exists := out["parameters"]; !exists {
- out["parameters"] = params
- }
- }
- return out
-}
-
-// notImplementedResponse is the wire shape for 501 — APIError envelope
-// plus a `spec` field carrying the operation slice. Mirrors the
-// NotImplementedResponse schema in openapi.yaml.
-type notImplementedResponse struct {
- Error string `json:"error"`
- Code string `json:"code"`
- Message string `json:"message"`
- RequestID string `json:"requestId,omitempty"`
- Spec map[string]any `json:"spec"`
-}
-
-// NotImplemented writes the locked 501 envelope, embedding the OpenAPI
-// Operation slice that documents what this route WILL do. Replaces the
-// throwaway PlannedRoute literals that the first cut of the route shell
-// duplicated in controller code.
-func NotImplemented(w http.ResponseWriter, r *http.Request, method, path string) {
- op := Default().Operation(method, path)
- if op == nil {
- panic(fmt.Sprintf("apispec: missing operation for %s %s", method, path))
- }
- body := notImplementedResponse{
- Error: "not_implemented",
- Code: "NOT_IMPLEMENTED",
- Message: method + " " + path + " is registered but not yet implemented",
- RequestID: middleware.GetReqID(r.Context()),
- Spec: op,
- }
- w.Header().Set("Content-Type", "application/json; charset=utf-8")
- w.WriteHeader(http.StatusNotImplemented)
- // A write error here means the client went away mid-response.
- _ = json.NewEncoder(w).Encode(body)
-}
+// Doc returns the embedded OpenAPI document bytes. Read-only; callers must not
+// mutate the returned slice.
+func Doc() []byte { return openapiYAML }
-// ServeYAML serves the embedded openapi.yaml document. Mounted at
-// /api/v1/openapi.yaml so spec-consuming tooling (#19's validator,
-// SDK generators, the dashboard's developer tools) can fetch the
-// whole document in one request.
+// ServeYAML serves the embedded openapi.yaml document, mounted at
+// /api/v1/openapi.yaml so tooling can fetch the whole spec in one request.
func ServeYAML(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
_, _ = w.Write(openapiYAML)
}
-// RegisterServe mounts ServeYAML on the supplied router. Kept as a
-// helper so the router code only references one symbol from apispec
-// for the static serve path.
+// RegisterServe mounts ServeYAML at path on the supplied router.
func RegisterServe(r chi.Router, path string) {
r.Get(path, ServeYAML)
}
diff --git a/backend/internal/httpd/apispec/apispec_test.go b/backend/internal/httpd/apispec/apispec_test.go
index b5bde562..3b7164e8 100644
--- a/backend/internal/httpd/apispec/apispec_test.go
+++ b/backend/internal/httpd/apispec/apispec_test.go
@@ -8,51 +8,15 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec"
)
-// TestDefaultLoadsEmbeddedSpec is the smoke test for //go:embed wiring:
-// the default Spec must parse the embedded YAML without panicking and
-// recognise a known operation.
-func TestDefaultLoadsEmbeddedSpec(t *testing.T) {
- op := apispec.Default().Operation("GET", "/api/v1/projects")
- if op == nil {
- t.Fatal("Default().Operation(GET, /api/v1/projects) = nil; embed broken or path missing")
- }
- if got, _ := op["operationId"].(string); got != "listProjects" {
- t.Errorf("operationId = %q, want listProjects", got)
- }
-}
-
-// TestOperation_MissingPath returns nil for unknown paths — that's how the
-// controller-side test catches "route registered without spec coverage".
-func TestOperation_MissingPath(t *testing.T) {
- if op := apispec.Default().Operation("GET", "/api/v1/no-such-route"); op != nil {
- t.Errorf("unknown path returned %v, want nil", op)
- }
-}
-
-// TestOperation_MissingMethod returns nil for known path / unknown method.
-func TestOperation_MissingMethod(t *testing.T) {
- if op := apispec.Default().Operation("HEAD", "/api/v1/projects"); op != nil {
- t.Errorf("HEAD on a GET-only path returned %v, want nil", op)
- }
-}
-
-// TestOperation_InheritsPathParameters covers the bit of behaviour that
-// would silently rot otherwise: parameters declared at the path level
-// (e.g. the {id} path param shared by GET/PATCH/DELETE) must show up on
-// every operation's slice so the 501 response is self-contained.
-func TestOperation_InheritsPathParameters(t *testing.T) {
- op := apispec.Default().Operation("GET", "/api/v1/projects/{id}")
- if op == nil {
- t.Fatal("expected operation slice")
- }
- params, ok := op["parameters"].([]any)
- if !ok || len(params) == 0 {
- t.Fatalf("expected inherited path-level parameters, got %#v", op["parameters"])
+// TestDocEmbedded is the //go:embed smoke test: the embedded document is present
+// and is a recognisable OpenAPI 3.1 doc.
+func TestDocEmbedded(t *testing.T) {
+ if !strings.Contains(string(apispec.Doc()), "openapi: 3.1.0") {
+ t.Fatal("embedded openapi.yaml is empty or not an OpenAPI 3.1 document")
}
}
-// TestServeYAML serves the raw embedded document; tooling fetches it
-// whole rather than reconstructing it from per-operation slices.
+// TestServeYAML serves the raw embedded document so tooling can fetch it whole.
func TestServeYAML(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/v1/openapi.yaml", nil)
diff --git a/backend/internal/httpd/apispec/build.go b/backend/internal/httpd/apispec/build.go
new file mode 100644
index 00000000..1d56ee44
--- /dev/null
+++ b/backend/internal/httpd/apispec/build.go
@@ -0,0 +1,192 @@
+package apispec
+
+import (
+ "fmt"
+ "net/http"
+ "reflect"
+ "strings"
+
+ "github.com/swaggest/jsonschema-go"
+ "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/httpx"
+ "github.com/aoagents/agent-orchestrator/backend/internal/project"
+)
+
+// Build reflects the Go contract types and 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 openapi.yaml (the committed, embedded
+// artifact) and a test asserts the embed equals fresh Build() output so the two
+// can never drift. Schema facets live as struct tags on the project.*/httpx
+// types; operation metadata (path, status codes, summaries) lives here.
+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/default tags) and this hook adds
+ // the required array.
+ r.DefaultOptions = append(r.DefaultOptions, jsonschema.InterceptProp(requiredFromJSONTag))
+ // Clean component schema names (which become the generated TS type names):
+ // swaggest defaults to PackageType, e.g. "ProjectProject", "HttpxError".
+ r.InterceptDefName(schemaName)
+
+ r.Spec.SetTitle("Agent Orchestrator HTTP daemon")
+ r.Spec.SetVersion("0.1.0-route-shell")
+ r.Spec.SetDescription("Loopback-only HTTP surface served by the Go daemon. " +
+ "Generated from Go (code-first) — do not edit by hand; run `go generate ./...`.")
+ r.Spec.Servers = []openapi31.Server{
+ *(&openapi31.Server{URL: "http://127.0.0.1:3001"}).WithDescription("Local daemon (loopback only)"),
+ }
+ r.Spec.Tags = []openapi31.Tag{
+ *(&openapi31.Tag{Name: "projects"}).WithDescription(
+ "Project registry, configuration, and lifecycle administration"),
+ }
+
+ for _, op := range projectOperations() {
+ oc, err := r.NewOperationContext(op.method, op.path)
+ if err != nil {
+ return nil, fmt.Errorf("new operation %s %s: %w", op.method, op.path, err)
+ }
+ oc.SetID(op.id)
+ oc.SetSummary(op.summary)
+ oc.SetTags("projects")
+ for _, req := range op.reqs {
+ oc.AddReqStructure(req)
+ }
+ 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 to clean,
+// stable schema names (these become the generated TypeScript type names).
+func schemaName(_ reflect.Type, defaultName string) string {
+ switch defaultName {
+ case "HttpxError":
+ return "APIError"
+ case "ControllersListProjectsResponse":
+ return "ListProjectsResponse"
+ case "ControllersProjectResponse":
+ return "ProjectResponse"
+ case "ControllersGetProjectResponse":
+ return "ProjectGetResponse"
+ case "ControllersProjectOrDegraded":
+ return "ProjectOrDegraded"
+ }
+ // project.* types: "ProjectProject" -> "Project", "ProjectSummary" -> "Summary", etc.
+ return strings.TrimPrefix(defaultName, "Project")
+}
+
+// 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
+ reqs []any
+ resps []respUnit
+}
+
+// All wire shapes are reflected straight from where they're used — request
+// bodies + path params from controllers/project, responses from controllers —
+// so the spec and the runtime share one definition each. No wire types are
+// declared in this package; build.go only wires the operation registry.
+
+func projectOperations() []operation {
+ return []operation{
+ {
+ method: http.MethodGet, path: "/api/v1/projects", id: "listProjects",
+ summary: "List all registered projects (active + degraded)",
+ resps: []respUnit{
+ {http.StatusOK, controllers.ListProjectsResponse{}},
+ {http.StatusInternalServerError, httpx.Error{}},
+ },
+ },
+ {
+ method: http.MethodPost, path: "/api/v1/projects", id: "addProject",
+ summary: "Register a new project from a git repository path",
+ reqs: []any{project.AddInput{}},
+ resps: []respUnit{
+ {http.StatusCreated, controllers.ProjectResponse{}},
+ {http.StatusBadRequest, httpx.Error{}},
+ {http.StatusConflict, httpx.Error{}},
+ {http.StatusInternalServerError, httpx.Error{}},
+ },
+ },
+ {
+ method: http.MethodGet, path: "/api/v1/projects/{id}", id: "getProject",
+ summary: "Fetch one project; discriminates ok vs degraded",
+ reqs: []any{controllers.ProjectIDParam{}},
+ resps: []respUnit{
+ {http.StatusOK, controllers.GetProjectResponse{}},
+ {http.StatusNotFound, httpx.Error{}},
+ {http.StatusInternalServerError, httpx.Error{}},
+ },
+ },
+ {
+ method: http.MethodPatch, path: "/api/v1/projects/{id}", id: "updateProjectConfig",
+ summary: "Patch behaviour-only fields (identity is frozen)",
+ reqs: []any{controllers.ProjectIDParam{}, project.UpdateConfigInput{}},
+ resps: []respUnit{
+ {http.StatusOK, controllers.ProjectResponse{}},
+ {http.StatusBadRequest, httpx.Error{}},
+ {http.StatusNotFound, httpx.Error{}},
+ {http.StatusConflict, httpx.Error{}},
+ {http.StatusInternalServerError, httpx.Error{}},
+ },
+ },
+ {
+ method: http.MethodDelete, path: "/api/v1/projects/{id}", id: "removeProject",
+ summary: "Remove a project; stops sessions, cleans workspaces, unregisters",
+ reqs: []any{controllers.ProjectIDParam{}},
+ resps: []respUnit{
+ {http.StatusOK, project.RemoveResult{}},
+ {http.StatusBadRequest, httpx.Error{}},
+ {http.StatusNotFound, httpx.Error{}},
+ {http.StatusInternalServerError, httpx.Error{}},
+ },
+ },
+ }
+}
diff --git a/backend/internal/httpd/apispec/build_internal_test.go b/backend/internal/httpd/apispec/build_internal_test.go
new file mode 100644
index 00000000..0e83b355
--- /dev/null
+++ b/backend/internal/httpd/apispec/build_internal_test.go
@@ -0,0 +1,36 @@
+package apispec
+
+import (
+ "bytes"
+ "testing"
+)
+
+// 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 := Build()
+ if err != nil {
+ t.Fatalf("Build: %v", err)
+ }
+ if !bytes.Equal(got, openapiYAML) {
+ t.Fatalf("embedded openapi.yaml is stale — run `go generate ./...` and commit.\n"+
+ "len(fresh)=%d len(embedded)=%d", len(got), len(openapiYAML))
+ }
+}
+
+// TestBuild_Deterministic guards against nondeterministic output (which would
+// make the drift check flaky in CI).
+func TestBuild_Deterministic(t *testing.T) {
+ a, err := Build()
+ if err != nil {
+ t.Fatalf("Build #1: %v", err)
+ }
+ b, err := 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/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 2b60a3a5..805c31fb 100644
--- a/backend/internal/httpd/apispec/openapi.yaml
+++ b/backend/internal/httpd/apispec/openapi.yaml
@@ -1,446 +1,435 @@
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-route-shell
- description: |
- Loopback-only HTTP surface served by the Go daemon. This spec is the
- source of truth for every route's contract — the 501 stubs in the
- route-shell phase return the matching Operation slice as a `spec`
- field, so consumers discover the contract by calling the endpoint
- they care about. Real handlers in later PRs satisfy this same spec.
-
servers:
- - url: http://127.0.0.1:3001
- description: Local daemon (loopback only)
-
-tags:
- - name: projects
- description: Project registry, configuration, and lifecycle administration
-
+- description: Local daemon (loopback only)
+ url: http://127.0.0.1:3001
paths:
/api/v1/projects:
get:
operationId: listProjects
- tags: [projects]
- summary: List active registered projects
responses:
"200":
- description: Projects listed
content:
application/json:
schema:
- type: object
- required: [projects]
- properties:
- projects:
- type: array
- items: { $ref: "#/components/schemas/ProjectSummary" }
+ $ref: '#/components/schemas/ListProjectsResponse'
+ description: OK
"500":
- description: Failed to load projects
content:
application/json:
- schema: { $ref: "#/components/schemas/APIError" }
- example: { error: internal, code: PROJECTS_LIST_FAILED, message: "Failed to load projects" }
- "501": { $ref: "#/components/responses/NotImplemented" }
-
+ schema:
+ $ref: '#/components/schemas/APIError'
+ description: Internal Server Error
+ summary: List all registered projects (active + degraded)
+ tags:
+ - projects
post:
operationId: addProject
- tags: [projects]
- summary: Register a new project from a git repository path
requestBody:
- required: true
content:
application/json:
- schema: { $ref: "#/components/schemas/AddProjectRequest" }
+ schema:
+ $ref: '#/components/schemas/AddInput'
responses:
"201":
- description: Project registered
content:
application/json:
schema:
- type: object
- required: [project]
- properties:
- project: { $ref: "#/components/schemas/Project" }
+ $ref: '#/components/schemas/ProjectResponse'
+ description: Created
"400":
- description: Bad request
content:
application/json:
- schema: { $ref: "#/components/schemas/APIError" }
- examples:
- invalidJson: { value: { error: bad_request, code: INVALID_JSON, message: "Invalid JSON body" } }
- pathRequired: { value: { error: bad_request, code: PATH_REQUIRED, message: "Repository path is required" } }
- notAGitRepo: { value: { error: bad_request, code: NOT_A_GIT_REPO, message: "Repository path must point to a git repository" } }
+ schema:
+ $ref: '#/components/schemas/APIError'
+ description: Bad Request
"409":
- description: Conflict with an already-registered project
content:
application/json:
- schema: { $ref: "#/components/schemas/APIError" }
- examples:
- pathAlready:
- value:
- error: conflict
- code: PATH_ALREADY_REGISTERED
- message: "A project at this path is already registered"
- details:
- existingProjectId: existing-project-id
- suggestedProjectId: suggested-project-id
- idAlready:
- value:
- error: conflict
- code: ID_ALREADY_REGISTERED
- message: "A project with this id is already registered for a different path"
- details:
- existingProjectId: existing-project-id
- suggestedProjectId: suggested-project-id
- "501": { $ref: "#/components/responses/NotImplemented" }
-
- /api/v1/projects/reload:
- post:
- operationId: reloadProjects
- tags: [projects]
- summary: Invalidate cached config and re-scan the global registry
+ schema:
+ $ref: '#/components/schemas/APIError'
+ description: Conflict
+ "500":
+ content:
+ application/json:
+ 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
+ parameters:
+ - description: Project identifier (registry key).
+ in: path
+ name: id
+ required: true
+ schema:
+ description: Project identifier (registry key).
+ type: string
responses:
"200":
- description: Reload complete
content:
application/json:
- schema: { $ref: "#/components/schemas/ReloadResult" }
+ schema:
+ $ref: '#/components/schemas/RemoveResult'
+ description: OK
+ "400":
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/APIError'
+ description: Bad Request
+ "404":
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/APIError'
+ description: Not Found
"500":
- 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
+ summary: Remove a project; stops sessions, cleans workspaces, unregisters
+ tags:
+ - projects
get:
operationId: getProject
- tags: [projects]
- summary: Fetch one project; discriminates ok vs degraded
+ parameters:
+ - description: Project identifier (registry key).
+ in: path
+ name: id
+ required: true
+ schema:
+ description: Project identifier (registry key).
+ type: string
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/ProjectGetResponse'
+ description: OK
+ "404":
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/APIError'
+ description: Not Found
"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" }
- x-rest-audit-notes: |
- R5: degraded projects return 200 with a `status` discriminator
- instead of 200 with an `error` field (as the legacy TS surface did).
- Archived projects are hidden from list responses but still resolve by
- id so historical sessions can keep their project_id reference.
-
+ schema:
+ $ref: '#/components/schemas/APIError'
+ description: Internal Server Error
+ summary: Fetch one project; discriminates ok vs degraded
+ tags:
+ - projects
patch:
operationId: updateProjectConfig
- tags: [projects]
- summary: Patch behaviour-only fields (not implemented until config persistence lands)
- requestBody:
+ parameters:
+ - description: Project identifier (registry key).
+ in: path
+ name: id
required: true
+ schema:
+ description: Project identifier (registry key).
+ type: string
+ requestBody:
content:
application/json:
- schema: { $ref: "#/components/schemas/UpdateProjectConfigRequest" }
- responses:
- "400":
- description: Bad request
- content:
- application/json:
- schema: { $ref: "#/components/schemas/APIError" }
- examples:
- invalidJson: { value: { error: bad_request, code: INVALID_JSON, message: "Invalid JSON body" } }
- identityFrozen:
- value:
- error: bad_request
- code: IDENTITY_FROZEN
- message: "Identity fields cannot be patched"
- details: { fields: [projectId, path, repo, defaultBranch] }
- invalidConfig: { value: { error: bad_request, code: INVALID_LOCAL_CONFIG, message: "Local project config failed validation" } }
- "404": { $ref: "#/components/responses/ProjectNotFound" }
- "409":
- description: Project not in a patchable state
- content:
- application/json:
- schema: { $ref: "#/components/schemas/APIError" }
- examples:
- degraded: { value: { error: conflict, code: PROJECT_DEGRADED, message: "Project config is degraded; repair before patching" } }
- missingPath: { value: { error: conflict, code: PROJECT_MISSING_PATH, message: "Project registry entry is missing a path" } }
- "501":
- description: Behaviour config persistence is not wired yet
- content:
- application/json:
- schema: { $ref: "#/components/schemas/APIError" }
- example: { error: not_implemented, code: PROJECT_CONFIG_NOT_IMPLEMENTED, message: "Project config patching is not available until config persistence is wired" }
- x-rest-audit-notes: |
- R3: legacy `PUT /projects/{id}` (a TS alias of PATCH) is NOT
- registered. PUT returns 405 Method Not Allowed.
- R6: when config persistence lands this route returns { project }, not
- { ok: true }. Until then, config patches return 501 instead of
- pretending to persist fields the current project store cannot store.
-
- delete:
- operationId: removeProject
- tags: [projects]
- summary: Archive a project; hides it from active lists while preserving id references
+ schema:
+ $ref: '#/components/schemas/UpdateConfigInput'
responses:
"200":
- description: Project archived
content:
application/json:
- schema: { $ref: "#/components/schemas/RemoveProjectResult" }
+ schema:
+ $ref: '#/components/schemas/ProjectResponse'
+ 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" }
- "500":
- description: Removal failed
+ schema:
+ $ref: '#/components/schemas/APIError'
+ description: Bad Request
+ "404":
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}"
- x-rest-audit-notes: |
- R4: this canonical path replaces the overloaded
- `POST /api/v1/projects/{id}` from the legacy TS surface.
- The legacy path is NOT registered; consumers must use /repair.
- responses:
- "200":
- description: Project repaired
+ schema:
+ $ref: '#/components/schemas/APIError'
+ description: Not Found
+ "409":
content:
application/json:
schema:
- type: object
- required: [project]
- properties:
- project: { $ref: "#/components/schemas/Project" }
- "400":
- description: Bad request
+ $ref: '#/components/schemas/APIError'
+ description: Conflict
+ "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: Patch behaviour-only fields (identity is frozen)
+ tags:
+ - projects
components:
- parameters:
- ProjectIDPath:
- name: id
- in: path
- required: true
- schema: { type: string, minLength: 1 }
- description: Project identifier (registry key).
-
- 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" }
-
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:
+ description: SCREAMING_SNAKE machine 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:
+ description: Short kind, e.g. not_found
+ type: string
+ message:
+ description: Human-readable detail
+ type: string
+ requestId:
+ type: string
+ required:
+ - error
+ - code
+ - message
type: object
- required: [id, name, sessionPrefix]
+ AddInput:
properties:
- id: { type: string }
- name: { type: string }
- sessionPrefix: { type: string }
+ name:
+ description: Optional display name; defaults to projectId.
+ type:
+ - "null"
+ - string
+ path:
+ description: Repository path; supports ~ home-expansion. Must be a git repo.
+ type: string
+ projectId:
+ description: Optional override; defaults to basename(path).
+ type:
+ - "null"
+ - string
+ required:
+ - path
+ type: object
+ Degraded:
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ path:
+ type: string
resolveError:
type: string
- description: Present iff the project is degraded.
-
- Project:
+ required:
+ - id
+ - name
+ - path
+ - resolveError
type: object
- required: [id, name, path, repo, defaultBranch]
+ ListProjectsResponse:
properties:
- id: { type: string }
- name: { type: string }
- path: { type: string }
- repo:
+ projects:
+ items:
+ $ref: '#/components/schemas/Summary'
+ type:
+ - array
+ - "null"
+ required:
+ - projects
+ type: object
+ Project:
+ properties:
+ agent:
+ additionalProperties: {}
+ description: Agent config blob (open object)
+ type: object
+ defaultBranch:
+ default: main
+ type: string
+ id:
+ type: string
+ name:
+ type: string
+ path:
type: string
- description: "\"owner/name\" or empty string when unset"
- defaultBranch: { type: string, default: main }
- agent: { type: string }
- runtime: { type: string }
- tracker: { $ref: "#/components/schemas/TrackerConfig" }
- scm: { $ref: "#/components/schemas/SCMConfig" }
reactions:
+ additionalProperties:
+ $ref: '#/components/schemas/ReactionConfig'
+ type: object
+ repo:
+ description: '"owner/name" or empty string when unset'
+ type: string
+ runtime:
+ additionalProperties: {}
+ description: Runtime (terminal multiplexer) config blob (open object)
type: object
- additionalProperties: { $ref: "#/components/schemas/ReactionConfig" }
-
- DegradedProject:
+ scm:
+ $ref: '#/components/schemas/SCMConfig'
+ tracker:
+ $ref: '#/components/schemas/TrackerConfig'
+ required:
+ - id
+ - name
+ - path
+ - repo
+ - defaultBranch
type: object
- required: [id, name, path, resolveError]
- properties:
- id: { type: string }
- name: { type: string }
- path: { type: string }
- resolveError: { type: string }
-
ProjectGetResponse:
- type: object
- required: [status, project]
properties:
+ project:
+ $ref: '#/components/schemas/ProjectOrDegraded'
status:
+ enum:
+ - ok
+ - degraded
type: string
- enum: [ok, degraded]
+ required:
+ - status
+ - project
+ type: object
+ ProjectOrDegraded:
+ oneOf:
+ - $ref: '#/components/schemas/Project'
+ - $ref: '#/components/schemas/Degraded'
+ type: object
+ ProjectResponse:
+ properties:
project:
- oneOf:
- - $ref: "#/components/schemas/Project"
- - $ref: "#/components/schemas/DegradedProject"
- AddProjectRequest:
+ $ref: '#/components/schemas/Project'
+ required:
+ - project
type: object
- required: [path]
+ ReactionConfig:
properties:
- path:
+ action:
+ enum:
+ - send-to-agent
+ - notify
+ - auto-merge
type: string
- description: Repository path; supports ~ home-expansion. Must be a git repo.
- projectId:
+ auto:
+ type:
+ - "null"
+ - boolean
+ escalateAfter:
+ description: Either ms (number) or a duration string ("30m")
+ includeSummary:
+ type:
+ - "null"
+ - boolean
+ message:
type: string
- description: Optional override; defaults to basename(path).
- name:
+ priority:
+ enum:
+ - urgent
+ - action
+ - warning
+ - info
+ type: string
+ retries:
+ type:
+ - "null"
+ - integer
+ threshold:
type: string
- description: Optional display name; defaults to projectId.
-
- UpdateProjectConfigRequest:
- type: object
- description: |
- Behaviour-only patch. Identity fields (projectId, path, repo,
- defaultBranch) are rejected with 400 IDENTITY_FROZEN. The current Go
- handler returns 501 PROJECT_CONFIG_NOT_IMPLEMENTED until config
- persistence exists.
- properties:
- agent: { type: string }
- runtime: { type: string }
- tracker: { $ref: "#/components/schemas/TrackerConfig" }
- scm: { $ref: "#/components/schemas/SCMConfig" }
- reactions:
- type: object
- additionalProperties: { $ref: "#/components/schemas/ReactionConfig" }
-
- RemoveProjectResult:
type: object
- required: [projectId, removedStorageDir]
+ RemoveResult:
properties:
- projectId: { type: string }
- removedStorageDir: { type: boolean }
-
- ReloadResult:
+ projectId:
+ type: string
+ removedStorageDir:
+ type: boolean
+ required:
+ - projectId
+ - removedStorageDir
type: object
- required: [reloaded, projectCount, degradedCount]
+ SCMConfig:
properties:
- reloaded: { type: boolean }
- projectCount: { type: integer }
- degradedCount: { type: integer }
-
- # ---- Behaviour config blobs (ported from the TS Zod schemas) ----
- # These are the known config shapes only. The current Go handler does not
- # preserve unknown passthrough keys until config persistence is implemented.
-
- TrackerConfig:
+ package:
+ type: string
+ path:
+ type: string
+ plugin:
+ type: string
+ webhook:
+ $ref: '#/components/schemas/SCMWebhookConfig'
type: object
- additionalProperties: true
+ SCMWebhookConfig:
properties:
- plugin: { type: string }
- package: { type: string }
- path: { type: string }
-
- SCMConfig:
+ deliveryHeader:
+ type: string
+ enabled:
+ type:
+ - "null"
+ - boolean
+ eventHeader:
+ type: string
+ maxBodyBytes:
+ type: integer
+ path:
+ type: string
+ secretEnvVar:
+ type: string
+ signatureHeader:
+ type: string
type: object
- additionalProperties: true
+ Summary:
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 }
-
- ReactionConfig:
+ id:
+ type: string
+ name:
+ type: string
+ resolveError:
+ description: Present iff the project is degraded.
+ type: string
+ sessionPrefix:
+ type: string
+ required:
+ - id
+ - name
+ - sessionPrefix
type: object
+ TrackerConfig:
properties:
- auto: { type: boolean }
- action:
+ package:
type: string
- enum: [send-to-agent, notify, auto-merge]
- message: { type: string }
- priority:
+ path:
type: string
- enum: [urgent, action, warning, info]
- retries: { type: integer }
- escalateAfter:
- oneOf:
- - { type: number }
- - { type: string }
- description: Either ms (number) or duration string ("30m").
- threshold: { type: string }
- includeSummary: { type: boolean }
+ plugin:
+ type: string
+ type: object
+ UpdateConfigInput:
+ properties:
+ agent:
+ additionalProperties: {}
+ type: object
+ reactions:
+ additionalProperties:
+ $ref: '#/components/schemas/ReactionConfig'
+ type:
+ - "null"
+ - object
+ runtime:
+ additionalProperties: {}
+ type: object
+ scm:
+ $ref: '#/components/schemas/SCMConfig'
+ tracker:
+ $ref: '#/components/schemas/TrackerConfig'
+ type: object
+tags:
+- description: Project registry, configuration, and lifecycle administration
+ name: projects
diff --git a/backend/internal/httpd/apispec/parity_test.go b/backend/internal/httpd/apispec/parity_test.go
new file mode 100644
index 00000000..1993085c
--- /dev/null
+++ b/backend/internal/httpd/apispec/parity_test.go
@@ -0,0 +1,68 @@
+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?")
+ }
+
+ // Build the spec's "METHOD /path" set from the embedded document.
+ var doc struct {
+ Paths map[string]map[string]yaml.Node `yaml:"paths"`
+ }
+ if err := yaml.Unmarshal(apispec.Doc(), &doc); err != nil {
+ t.Fatalf("parse spec: %v", err)
+ }
+ httpMethods := map[string]bool{"get": true, "post": true, "put": true, "patch": true, "delete": true}
+ specOps := map[string]bool{}
+ for path, item := range doc.Paths {
+ for method := range item {
+ if httpMethods[method] { // skip parameters, summary, etc.
+ specOps[strings.ToUpper(method)+" "+path] = true
+ }
+ }
+ }
+
+ // Forward: every mounted route has a spec operation.
+ for r := range mounted {
+ if !specOps[r] {
+ t.Errorf("mounted route %s has no OpenAPI operation", r)
+ }
+ }
+ // Reverse: every spec operation is a mounted route.
+ for op := range specOps {
+ if !mounted[op] {
+ t.Errorf("spec operation %s has no mounted route", op)
+ }
+ }
+}
diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go
new file mode 100644
index 00000000..2ef8c391
--- /dev/null
+++ b/backend/internal/httpd/controllers/dto.go
@@ -0,0 +1,71 @@
+package controllers
+
+import (
+ "encoding/json"
+
+ "github.com/aoagents/agent-orchestrator/backend/internal/project"
+)
+
+// HTTP response envelopes for the projects surface — the SINGLE definition of
+// each wire shape. The handlers encode these (httpx.WriteJSON), and
+// apispec.Build reflects these same types into openapi.yaml, so the served
+// contract and the generated spec can't disagree. The request side needs no
+// wrappers: handlers decode the body straight into the project commands
+// (project.AddInput / project.UpdateConfigInput), which apispec also reflects.
+
+// ProjectIDParam is the {id} path parameter shared by the /projects/{id}
+// routes. Handlers read it via chi.URLParam (see projectID); it is declared here
+// so every wire input/output shape has one home, and apispec.Build reflects it
+// as the path parameter.
+type ProjectIDParam struct {
+ ID string `path:"id" description:"Project identifier (registry key)."`
+}
+
+// ListProjectsResponse is the body of GET /api/v1/projects.
+type ListProjectsResponse struct {
+ Projects []project.Summary `json:"projects"`
+}
+
+// ProjectResponse is the { project } body shared by POST /projects (201) and
+// PATCH /projects/{id} (200).
+type ProjectResponse struct {
+ Project project.Project `json:"project"`
+}
+
+// GetProjectResponse is the { status, project } body of GET /projects/{id},
+// where project is oneOf Project|Degraded discriminated by status.
+type GetProjectResponse struct {
+ Status string `json:"status" enum:"ok,degraded"`
+ Project ProjectOrDegraded `json:"project"`
+}
+
+// ProjectOrDegraded is the discriminated `project` field: exactly one of
+// Project/Degraded is set. It marshals as whichever is present (so the handler
+// emits the right object) and exposes the oneOf variants to the spec reflector
+// (so apispec.Build emits `oneOf: [Project, Degraded]`) — one type, both jobs.
+type ProjectOrDegraded struct {
+ Project *project.Project
+ Degraded *project.Degraded
+}
+
+func (p ProjectOrDegraded) MarshalJSON() ([]byte, error) {
+ if p.Degraded != nil {
+ return json.Marshal(p.Degraded)
+ }
+ return json.Marshal(p.Project)
+}
+
+// JSONSchemaOneOf is read by swaggest's reflector (apispec.Build) to emit the
+// oneOf for this field; it is not used at runtime.
+func (ProjectOrDegraded) JSONSchemaOneOf() []interface{} {
+ return []interface{}{project.Project{}, project.Degraded{}}
+}
+
+// newGetProjectResponse maps the internal GetResult onto the wire envelope —
+// the explicit project→httpd boundary the result type exists for.
+func newGetProjectResponse(res project.GetResult) GetProjectResponse {
+ return GetProjectResponse{
+ Status: res.Status,
+ Project: ProjectOrDegraded{Project: res.Project, Degraded: res.Degraded},
+ }
+}
diff --git a/backend/internal/httpd/controllers/projects.go b/backend/internal/httpd/controllers/projects.go
index 8fa9db1f..6d742ff3 100644
--- a/backend/internal/httpd/controllers/projects.go
+++ b/backend/internal/httpd/controllers/projects.go
@@ -1,219 +1,143 @@
// Package controllers holds the HTTP-facing controllers for the /api/v1
-// surface. Each controller groups one resource's routes, exposes a Register
-// method that wires them on a chi.Router, and depends on exactly one
-// *Manager interface from ports/inbound.go — never on a store, the LCM, an
-// adapter, or any other port. Whether the Manager impl reaches past that
-// boundary is its own concern.
+// surface. Each controller groups one resource's routes and depends only on
+// that resource's application-service contract (here, project.Manager) — never
+// on a store, the LCM, or an adapter.
//
-// In the route-shell PR (#20) every handler is a one-line apispec.NotImplemented
-// call: the contract lives in the OpenAPI document (apispec/openapi.yaml), and
-// the 501 body returns that document's slice for the route so consumers can
-// discover the contract from the endpoint itself. When real handlers land,
-// the stub one-liner is replaced with the impl; no per-route planned
-// metadata in code ever has to be deleted.
+// Each handler maps the request→response transport: decode the body into the
+// project command, call the Manager, and encode the typed wire envelope (or
+// translate the Manager's typed httpx.APIErr into the locked error envelope via
+// writeErr). When Mgr is nil (no Manager wired) the handlers return 500
+// SERVICE_UNAVAILABLE — the route IS implemented, only the backing service is absent.
package controllers
import (
- "bytes"
- "encoding/json"
- "errors"
- "io"
"net/http"
"github.com/go-chi/chi/v5"
"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/httpd/httpx"
"github.com/aoagents/agent-orchestrator/backend/internal/project"
)
-// ProjectsController owns the 7 canonical /projects routes. The controller
-// depends ONLY on project.Manager — it doesn't know whether the impl reaches
-// into the registry, the LCM, an adapter, or all three. Mgr is nil while
-// handlers are stubs; the handler-impl PR supplies a real project.Manager.
+// ProjectsController owns the 5 canonical /projects routes. Mgr is nil until the
+// handler-impl PR supplies a real project.Manager; while nil the handlers return
+// 500 SERVICE_UNAVAILABLE.
+//
+// reload (dropped) and repair (deferred) are intentionally not registered —
+// per the route analysis verdicts.
type ProjectsController struct {
Mgr project.Manager
}
-// Register mounts the project routes on the supplied router. Route order
-// matters: /projects/reload must register before /projects/{id} for the POST
-// verb, otherwise chi would treat "reload" as an {id} match for repair.
-//
-// Legacy paths that the REST audit dropped are deliberately NOT registered
-// here. They surface as 405 (sibling method exists, e.g. PUT /projects/{id})
-// or 404 (no sibling). The mapping lives in apispec/openapi.yaml as
-// `x-replaces` on the canonical operation so consumers discover the
-// migration without leaving the spec.
+// Register mounts the project routes on the supplied router. REST-audit-dropped
+// legacy paths are not registered: they surface as 405 (e.g. PUT or POST on
+// /projects/{id}) or 404.
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) {
if c.Mgr == nil {
- apispec.NotImplemented(w, r, "GET", "/api/v1/projects")
+ c.serviceUnavailable(w, r)
return
}
- projects, err := c.Mgr.List(r.Context())
+ items, err := c.Mgr.List(r.Context())
if err != nil {
- writeProjectError(w, r, err, http.StatusInternalServerError)
+ c.writeErr(w, r, err)
return
}
- envelope.WriteJSON(w, http.StatusOK, map[string]any{"projects": projects})
+ httpx.WriteJSON(w, http.StatusOK, ListProjectsResponse{Projects: items})
}
func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) {
if c.Mgr == nil {
- apispec.NotImplemented(w, r, "POST", "/api/v1/projects")
+ c.serviceUnavailable(w, r)
return
}
var in project.AddInput
- if err := decodeJSON(r, &in); err != nil {
- envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil)
+ if err := httpx.DecodeJSON(r, &in); err != nil {
+ httpx.WriteError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil)
return
}
- p, err := c.Mgr.Add(r.Context(), in)
+ proj, err := c.Mgr.Add(r.Context(), in)
if err != nil {
- writeProjectError(w, r, err, http.StatusInternalServerError)
+ c.writeErr(w, r, err)
return
}
- envelope.WriteJSON(w, http.StatusCreated, map[string]any{"project": p})
+ httpx.WriteJSON(w, http.StatusCreated, ProjectResponse{Project: proj})
}
func (c *ProjectsController) get(w http.ResponseWriter, r *http.Request) {
if c.Mgr == nil {
- apispec.NotImplemented(w, r, "GET", "/api/v1/projects/{id}")
+ c.serviceUnavailable(w, r)
return
}
- got, err := c.Mgr.Get(r.Context(), projectID(r))
+ res, err := c.Mgr.Get(r.Context(), c.projectID(r))
if err != nil {
- writeProjectError(w, r, err, http.StatusInternalServerError)
+ c.writeErr(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})
+ httpx.WriteJSON(w, http.StatusOK, newGetProjectResponse(res))
}
func (c *ProjectsController) updateConfig(w http.ResponseWriter, r *http.Request) {
if c.Mgr == nil {
- apispec.NotImplemented(w, r, "PATCH", "/api/v1/projects/{id}")
+ c.serviceUnavailable(w, r)
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)
+ if err := httpx.DecodeJSON(r, &patch); err != nil {
+ httpx.WriteError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil)
return
}
- p, err := c.Mgr.UpdateConfig(r.Context(), projectID(r), patch)
+ proj, err := c.Mgr.UpdateConfig(r.Context(), c.projectID(r), patch)
if err != nil {
- writeProjectError(w, r, err, http.StatusInternalServerError)
+ c.writeErr(w, r, err)
return
}
- envelope.WriteJSON(w, http.StatusOK, map[string]any{"project": p})
+ httpx.WriteJSON(w, http.StatusOK, ProjectResponse{Project: proj})
}
func (c *ProjectsController) remove(w http.ResponseWriter, r *http.Request) {
if c.Mgr == nil {
- apispec.NotImplemented(w, r, "DELETE", "/api/v1/projects/{id}")
+ c.serviceUnavailable(w, r)
return
}
- result, err := c.Mgr.Remove(r.Context(), projectID(r))
+ res, err := c.Mgr.Remove(r.Context(), c.projectID(r))
if err != nil {
- writeProjectError(w, r, err, http.StatusInternalServerError)
+ c.writeErr(w, r, err)
return
}
- envelope.WriteJSON(w, http.StatusOK, result)
+ // RemoveResult is already wire-shaped ({projectId, removedStorageDir}).
+ httpx.WriteJSON(w, http.StatusOK, res)
}
-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, http.StatusInternalServerError)
- 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, http.StatusInternalServerError)
- return
- }
- envelope.WriteJSON(w, http.StatusOK, result)
-}
-
-func projectID(r *http.Request) domain.ProjectID {
+// projectID reads the {id} path parameter as a domain.ProjectID.
+func (c *ProjectsController) projectID(r *http.Request) domain.ProjectID {
return domain.ProjectID(chi.URLParam(r, "id"))
}
-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)
- }
+// writeErr renders a Manager error. Typed httpx.APIErr values (the Manager's
+// taxonomy: 400/404/409 with machine codes + details) map straight to the
+// locked envelope; anything else is an opaque 500 so internals don't leak.
+func (c *ProjectsController) writeErr(w http.ResponseWriter, r *http.Request, err error) {
+ if e, ok := httpx.AsAPIErr(err); ok {
+ httpx.WriteAPIErr(w, r, e)
+ return
}
- return frozen, nil
+ httpx.WriteError(w, r, http.StatusInternalServerError, "internal", "INTERNAL", "Internal error", nil)
}
-func writeProjectError(w http.ResponseWriter, r *http.Request, err error, fallbackStatus int) {
- var pe *project.Error
- if errors.As(err, &pe) {
- status := fallbackStatus
- switch pe.Kind {
- case "bad_request":
- status = http.StatusBadRequest
- case "not_found":
- status = http.StatusNotFound
- case "conflict":
- status = http.StatusConflict
- case "not_implemented":
- status = http.StatusNotImplemented
- case "internal":
- status = http.StatusInternalServerError
- }
- envelope.WriteAPIError(w, r, status, pe.Kind, pe.Code, pe.Message, pe.Details)
- return
- }
- envelope.WriteAPIError(w, r, fallbackStatus, "internal", "INTERNAL_ERROR", "Internal server error", nil)
+// serviceUnavailable is the route-shell response while project.Manager is nil.
+// The route and its transport ARE implemented — only the backing service is not
+// wired yet — so this is a server-side 500 with SERVICE_UNAVAILABLE, NOT a 501
+// "not implemented" (which would wrongly say the route doesn't exist yet).
+func (c *ProjectsController) serviceUnavailable(w http.ResponseWriter, r *http.Request) {
+ httpx.WriteError(w, r, http.StatusInternalServerError, "internal", "SERVICE_UNAVAILABLE",
+ "Project service is not available", nil)
}
diff --git a/backend/internal/httpd/controllers/projects_test.go b/backend/internal/httpd/controllers/projects_test.go
index d1ca2442..eeb124a9 100644
--- a/backend/internal/httpd/controllers/projects_test.go
+++ b/backend/internal/httpd/controllers/projects_test.go
@@ -1,181 +1,347 @@
package controllers_test
+// Route-shell tests for /api/v1/projects. Builds the full router (so the
+// /api/v1 mount, middleware, NotFound, and MethodNotAllowed handlers are
+// exercised together). With a Manager wired the handlers run their full
+// decode→call→encode path; with no Manager (the route-shell state) every
+// canonical route returns 500 SERVICE_UNAVAILABLE (the route is implemented,
+// the service isn't). Legacy paths the REST audit dropped return 405 (sibling
+// method exists) or 404 (no sibling); reload/repair are never registered.
+
import (
+ "context"
"encoding/json"
+ "errors"
"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/httpd/httpx"
"github.com/aoagents/agent-orchestrator/backend/internal/project"
)
func newTestServer(t *testing.T) *httptest.Server {
t.Helper()
+ // Discard logger keeps test output clean — the access-log middleware
+ // added in base #10·1a wants a non-nil *slog.Logger.
log := slog.New(slog.NewTextHandler(io.Discard, nil))
- srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{
- Projects: project.NewMemoryManager(),
- }))
+ srv := httptest.NewServer(httpd.NewRouter(config.Config{}, log, nil))
t.Cleanup(srv.Close)
return srv
}
-func TestProjectsRoutes_DefaultToStubsWithoutManager(t *testing.T) {
+// newTestServerWithManager builds the router with a real project.Manager wired
+// in, so the handlers run their full decode→call→encode path instead of the
+// nil-Mgr 500 SERVICE_UNAVAILABLE short-circuit.
+func newTestServerWithManager(t *testing.T, mgr project.Manager) *httptest.Server {
+ t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
- srv := httptest.NewServer(httpd.NewRouter(config.Config{}, log, nil))
+ srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{Projects: mgr}))
t.Cleanup(srv.Close)
+ return srv
+}
- body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects", "")
- assertJSON(t, headers)
- assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED")
+// fakeManager is a project.Manager that returns canned values and records what
+// the handlers passed it, so the transport tests can assert both directions.
+type fakeManager struct {
+ summaries []project.Summary
+ getResult project.GetResult
+ project project.Project
+ removeRes project.RemoveResult
+ err error // when non-nil, every method returns it
+
+ called bool // set by every method, so tests can assert the handler did/didn't reach the Manager
+ gotID domain.ProjectID
+ gotAdd project.AddInput
+ gotPatch project.UpdateConfigInput
}
-func TestProjectsAPI_ListAddGetReload(t *testing.T) {
- srv := newTestServer(t)
- repo := gitRepo(t, "agent-orchestrator")
+func (f *fakeManager) List(context.Context) ([]project.Summary, error) {
+ f.called = true
+ return f.summaries, f.err
+}
+
+func (f *fakeManager) Get(_ context.Context, id domain.ProjectID) (project.GetResult, error) {
+ f.called, f.gotID = true, id
+ return f.getResult, f.err
+}
- body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects", "")
+func (f *fakeManager) Add(_ context.Context, in project.AddInput) (project.Project, error) {
+ f.called, f.gotAdd = true, in
+ return f.project, f.err
+}
+
+func (f *fakeManager) UpdateConfig(_ context.Context, id domain.ProjectID, patch project.UpdateConfigInput) (project.Project, error) {
+ f.called, f.gotID, f.gotPatch = true, id, patch
+ return f.project, f.err
+}
+
+func (f *fakeManager) Remove(_ context.Context, id domain.ProjectID) (project.RemoveResult, error) {
+ f.called, f.gotID = true, id
+ return f.removeRes, f.err
+}
+
+// TestProjects_List_OK exercises the response side: a Manager result is encoded
+// into the { projects } envelope.
+func TestProjects_List_OK(t *testing.T) {
+ mgr := &fakeManager{summaries: []project.Summary{
+ {ID: "p1", Name: "One", SessionPrefix: "one"},
+ {ID: "p2", Name: "Two", SessionPrefix: "two", ResolveError: "boom"},
+ }}
+ srv := newTestServerWithManager(t, mgr)
+
+ body, status, _ := 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"`
+ t.Fatalf("status = %d, want 200\nbody=%s", status, body)
}
- mustJSON(t, body, &list)
- if len(list.Projects) != 0 {
- t.Fatalf("initial project count = %d, want 0", len(list.Projects))
+ var got struct {
+ Projects []map[string]any `json:"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)
+ if err := json.Unmarshal(body, &got); err != nil {
+ t.Fatalf("unmarshal: %v\nbody=%s", err, body)
}
- var add struct {
- Project projectBody `json:"project"`
+ if len(got.Projects) != 2 {
+ t.Fatalf("projects len = %d, want 2", len(got.Projects))
}
- 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)
+ if got.Projects[0]["sessionPrefix"] != "one" {
+ t.Errorf("projects[0].sessionPrefix = %v, want one", got.Projects[0]["sessionPrefix"])
}
+}
- 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)
+// TestProjects_Add_Created exercises the request side (body decoded into
+// AddInput) and the response side (201 + { project }).
+func TestProjects_Add_Created(t *testing.T) {
+ mgr := &fakeManager{project: project.Project{ID: "p1", Name: "One", Path: "/repo"}}
+ srv := newTestServerWithManager(t, mgr)
+
+ body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":"/repo","projectId":"p1"}`)
+ if status != http.StatusCreated {
+ t.Fatalf("status = %d, want 201\nbody=%s", status, body)
}
- var get struct {
- Status string `json:"status"`
- Project projectBody `json:"project"`
+ if mgr.gotAdd.Path != "/repo" {
+ t.Errorf("Manager got path %q, want /repo", mgr.gotAdd.Path)
}
- mustJSON(t, body, &get)
- if get.Status != "ok" || get.Project.ID != "ao" {
- t.Fatalf("get response = %#v", get)
+ if mgr.gotAdd.ProjectID == nil || *mgr.gotAdd.ProjectID != "p1" {
+ t.Errorf("Manager got projectId %v, want p1", mgr.gotAdd.ProjectID)
}
-
- 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 got struct {
+ Project map[string]any `json:"project"`
}
- var reload struct {
- Reloaded bool `json:"reloaded"`
- ProjectCount int `json:"projectCount"`
- DegradedCount int `json:"degradedCount"`
+ if err := json.Unmarshal(body, &got); err != nil {
+ t.Fatalf("unmarshal: %v\nbody=%s", err, body)
}
- mustJSON(t, body, &reload)
- if !reload.Reloaded || reload.ProjectCount != 1 || reload.DegradedCount != 0 {
- t.Fatalf("reload response = %#v", reload)
+ if got.Project["id"] != "p1" {
+ t.Errorf("project.id = %v, want p1", got.Project["id"])
}
}
-func TestProjectsAPI_AddValidationAndConflicts(t *testing.T) {
- srv := newTestServer(t)
- repoA := gitRepo(t, "repo-a")
- repoB := gitRepo(t, "repo-b")
- notRepo := t.TempDir()
-
+// TestProjects_BodyValidation covers the request validation the transport does
+// before any Manager logic: the JSON body must decode. Malformed and empty
+// bodies both fail closed with 400 INVALID_JSON on every route that reads a
+// body (POST add, PATCH updateConfig), the locked envelope is returned, and the
+// Manager is never reached.
+func TestProjects_BodyValidation(t *testing.T) {
cases := []struct {
- name, body, wantCode string
- wantStatus int
+ name, method, path, body string
}{
- {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"},
+ {name: "add/malformed", method: "POST", path: "/api/v1/projects", body: `{not json`},
+ {name: "add/empty", method: "POST", path: "/api/v1/projects", body: ``},
+ {name: "patch/malformed", method: "PATCH", path: "/api/v1/projects/p1", body: `{not json`},
+ {name: "patch/empty", method: "PATCH", path: "/api/v1/projects/p1", body: ``},
}
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)
+ mgr := &fakeManager{}
+ srv := newTestServerWithManager(t, mgr)
+ body, status, _ := doRequest(t, srv, tc.method, tc.path, tc.body)
+ if status != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400\nbody=%s", status, body)
+ }
+ assertEnvelope(t, body, "INVALID_JSON")
+ if mgr.called {
+ t.Error("Manager was called despite a body that failed to decode")
+ }
})
}
-
- 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) {
- 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","runtime":"tmux"}`)
- assertErrorCode(t, body, status, http.StatusNotImplemented, "PROJECT_CONFIG_NOT_IMPLEMENTED")
-
- body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/projects/proj", `{"path":"elsewhere"}`)
- assertErrorCode(t, body, status, http.StatusBadRequest, "IDENTITY_FROZEN")
-
- body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects/proj/repair", "")
- assertErrorCode(t, body, status, http.StatusBadRequest, "REPAIR_NOT_AVAILABLE")
+// TestProjects_Get_Discriminator confirms GetResult maps onto the { status,
+// project } envelope for both ok and degraded.
+func TestProjects_Get_Discriminator(t *testing.T) {
+ t.Run("ok", func(t *testing.T) {
+ mgr := &fakeManager{getResult: project.GetResult{
+ Status: "ok",
+ Project: &project.Project{ID: "p1", Name: "One"},
+ }}
+ srv := newTestServerWithManager(t, mgr)
+ body, status, _ := doRequest(t, srv, "GET", "/api/v1/projects/p1", "")
+ if status != http.StatusOK {
+ t.Fatalf("status = %d, want 200\nbody=%s", status, body)
+ }
+ if mgr.gotID != "p1" {
+ t.Errorf("Manager got id %q, want p1", mgr.gotID)
+ }
+ var got struct {
+ Status string `json:"status"`
+ Project map[string]any `json:"project"`
+ }
+ _ = json.Unmarshal(body, &got)
+ if got.Status != "ok" || got.Project["id"] != "p1" {
+ t.Errorf("got status=%q project=%v, want ok/p1", got.Status, got.Project)
+ }
+ })
+ t.Run("degraded", func(t *testing.T) {
+ mgr := &fakeManager{getResult: project.GetResult{
+ Status: "degraded",
+ Degraded: &project.Degraded{ID: "p1", ResolveError: "bad config"},
+ }}
+ srv := newTestServerWithManager(t, mgr)
+ body, status, _ := doRequest(t, srv, "GET", "/api/v1/projects/p1", "")
+ if status != http.StatusOK {
+ t.Fatalf("status = %d, want 200\nbody=%s", status, body)
+ }
+ var got struct {
+ Status string `json:"status"`
+ Project map[string]any `json:"project"`
+ }
+ _ = json.Unmarshal(body, &got)
+ if got.Status != "degraded" || got.Project["resolveError"] != "bad config" {
+ t.Errorf("got status=%q project=%v, want degraded/bad config", got.Status, got.Project)
+ }
+ })
+}
- body, status, _ = doRequest(t, srv, "DELETE", "/api/v1/projects/proj", "")
+// TestProjects_Remove_OK checks the { projectId, removedStorageDir } body.
+func TestProjects_Remove_OK(t *testing.T) {
+ mgr := &fakeManager{removeRes: project.RemoveResult{ProjectID: "p1", RemovedStorageDir: true}}
+ srv := newTestServerWithManager(t, mgr)
+ body, status, _ := doRequest(t, srv, "DELETE", "/api/v1/projects/p1", "")
if status != http.StatusOK {
- t.Fatalf("DELETE = %d, want 200; body=%s", status, body)
+ t.Fatalf("status = %d, want 200\nbody=%s", status, body)
}
- var removed struct {
+ var got struct {
ProjectID string `json:"projectId"`
RemovedStorageDir bool `json:"removedStorageDir"`
}
- mustJSON(t, body, &removed)
- if removed.ProjectID != "proj" || removed.RemovedStorageDir {
- t.Fatalf("delete response = %#v", removed)
+ _ = json.Unmarshal(body, &got)
+ if got.ProjectID != "p1" || !got.RemovedStorageDir {
+ t.Errorf("got %+v, want {p1 true}", got)
}
+}
- body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects/proj", "")
+// TestProjects_UpdateConfig_OK exercises PATCH: the body is decoded into
+// UpdateConfigInput, the Manager is called, and { project } returns 200.
+func TestProjects_UpdateConfig_OK(t *testing.T) {
+ mgr := &fakeManager{project: project.Project{ID: "p1", Name: "One", Agent: project.AgentConfig{"default": "claude"}}}
+ srv := newTestServerWithManager(t, mgr)
+
+ body, status, _ := doRequest(t, srv, "PATCH", "/api/v1/projects/p1", `{"agent":{"default":"claude"}}`)
if status != http.StatusOK {
- t.Fatalf("GET archived project = %d, want 200; body=%s", status, body)
+ t.Fatalf("status = %d, want 200\nbody=%s", status, body)
+ }
+ if mgr.gotID != "p1" {
+ t.Errorf("Manager got id %q, want p1", mgr.gotID)
+ }
+ if mgr.gotPatch.Agent["default"] != "claude" {
+ t.Errorf("Manager got patch agent %v, want claude", mgr.gotPatch.Agent)
+ }
+ var got struct {
+ Project map[string]any `json:"project"`
+ }
+ if err := json.Unmarshal(body, &got); err != nil {
+ t.Fatalf("unmarshal: %v\nbody=%s", err, body)
}
+ if got.Project["id"] != "p1" {
+ t.Errorf("project.id = %v, want p1", got.Project["id"])
+ }
+}
- 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)
+// TestProjects_ManagerErrorTranslation confirms the controller translates the
+// Manager's typed httpx.APIErr into the locked envelope (status + machine code),
+// and falls back to an opaque 500 INTERNAL for an untyped error so internals
+// never leak.
+func TestProjects_ManagerErrorTranslation(t *testing.T) {
+ typed := []struct {
+ name string
+ err error
+ wantStatus int
+ wantCode string
+ }{
+ {"not found → 404", httpx.NotFound("PROJECT_NOT_FOUND", "Unknown project"), http.StatusNotFound, "PROJECT_NOT_FOUND"},
+ {"bad request → 400", httpx.BadRequest("NOT_A_GIT_REPO", "not a repo", nil), http.StatusBadRequest, "NOT_A_GIT_REPO"},
+ {"conflict → 409", httpx.Conflict("PATH_ALREADY_REGISTERED", "dup", map[string]any{"existingProjectId": "ao"}), http.StatusConflict, "PATH_ALREADY_REGISTERED"},
}
- var list struct {
- Projects []projectSummary `json:"projects"`
+ for _, tc := range typed {
+ t.Run(tc.name, func(t *testing.T) {
+ srv := newTestServerWithManager(t, &fakeManager{err: tc.err})
+ body, status, _ := doRequest(t, srv, "GET", "/api/v1/projects", "")
+ if status != tc.wantStatus {
+ t.Fatalf("status = %d, want %d\nbody=%s", status, tc.wantStatus, body)
+ }
+ assertEnvelope(t, body, tc.wantCode)
+ })
}
- mustJSON(t, body, &list)
- if len(list.Projects) != 0 {
- t.Fatalf("active projects after archive = %d, want 0", len(list.Projects))
+
+ t.Run("untyped error → 500 INTERNAL", func(t *testing.T) {
+ srv := newTestServerWithManager(t, &fakeManager{err: errors.New("kaboom")})
+ body, status, _ := doRequest(t, srv, "GET", "/api/v1/projects", "")
+ if status != http.StatusInternalServerError {
+ t.Fatalf("status = %d, want 500\nbody=%s", status, body)
+ }
+ assertEnvelope(t, body, "INTERNAL")
+ })
+}
+
+// TestProjectsRoutes_NilManager walks every canonical /projects route with no
+// Manager wired (the route-shell state) and asserts a 500 SERVICE_UNAVAILABLE —
+// NOT a 501: the route and its transport are implemented, only the backing
+// service is absent, so it must not claim "not implemented".
+func TestProjectsRoutes_NilManager(t *testing.T) {
+ srv := newTestServer(t)
+
+ cases := []struct{ method, path, body string }{
+ {method: "GET", path: "/api/v1/projects"},
+ {method: "POST", path: "/api/v1/projects", body: `{}`},
+ {method: "GET", path: "/api/v1/projects/p1"},
+ {method: "PATCH", path: "/api/v1/projects/p1", body: `{}`},
+ {method: "DELETE", path: "/api/v1/projects/p1"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.method+" "+tc.path, func(t *testing.T) {
+ body, status, headers := doRequest(t, srv, tc.method, tc.path, tc.body)
+
+ if status != http.StatusInternalServerError {
+ t.Fatalf("status = %d, want 500\nbody=%s", status, body)
+ }
+ if ct := headers.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
+ t.Errorf("Content-Type = %q, want JSON", ct)
+ }
+ assertEnvelope(t, body, "SERVICE_UNAVAILABLE")
+
+ var got envelope
+ _ = json.Unmarshal(body, &got)
+ if got.Code == "NOT_IMPLEMENTED" || got.Error == "not_implemented" {
+ t.Errorf("must not signal not-implemented; got error/code = %q/%q", got.Error, got.Code)
+ }
+ })
}
}
+// TestProjectsRoutes_LegacyUnregistered confirms the dropped/deferred paths
+// are deliberately unregistered and fall through to the right handler:
+// - PUT/POST on /projects/{id} match the {id} path with no such method → 405.
+// (POST is the dropped legacy repair overload.)
+// - POST /projects/reload also matches {id}="reload" with no POST → 405,
+// since reload was dropped rather than registered.
+// - POST /projects/{id}/repair is a two-segment path with no route at all,
+// so it 404s; repair is deferred.
func TestProjectsRoutes_LegacyUnregistered(t *testing.T) {
srv := newTestServer(t)
@@ -184,24 +350,51 @@ func TestProjectsRoutes_LegacyUnregistered(t *testing.T) {
wantStatus int
}{
{method: "PUT", path: "/api/v1/projects/p1", wantStatus: 405, wantCode: "METHOD_NOT_ALLOWED", why: "R3 PUT not registered"},
- {method: "POST", path: "/api/v1/projects/p1", wantStatus: 405, wantCode: "METHOD_NOT_ALLOWED", why: "R4 repair moved to /repair"},
+ {method: "POST", path: "/api/v1/projects/p1", wantStatus: 405, wantCode: "METHOD_NOT_ALLOWED", why: "R4 repair overload unregistered"},
+ {method: "POST", path: "/api/v1/projects/reload", wantStatus: 405, wantCode: "METHOD_NOT_ALLOWED", why: "reload dropped; matches {id} with no POST"},
+ {method: "POST", path: "/api/v1/projects/p1/repair", wantStatus: 404, wantCode: "ROUTE_NOT_FOUND", why: "repair deferred; no route registered"},
}
for _, tc := range cases {
t.Run(tc.why, func(t *testing.T) {
body, status, _ := doRequest(t, srv, tc.method, tc.path, "")
- assertErrorCode(t, body, status, tc.wantStatus, tc.wantCode)
+ if status != tc.wantStatus {
+ t.Fatalf("%s %s = %d, want %d", tc.method, tc.path, status, tc.wantStatus)
+ }
+ var e envelope
+ if err := json.Unmarshal(body, &e); err != nil {
+ t.Fatalf("unmarshal: %v\nbody=%s", err, body)
+ }
+ if e.Code != tc.wantCode {
+ t.Errorf("code = %q, want %q", e.Code, tc.wantCode)
+ }
})
}
}
+// TestProjectsRoutes_MissingRoute confirms the JSON 404 envelope (not chi's
+// default text/plain) for routes that don't exist at all.
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")
+
+ if status != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", status)
+ }
+ if ct := headers.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
+ t.Errorf("Content-Type = %q, want JSON (router must override chi's text/plain default)", ct)
+ }
+ var e envelope
+ if err := json.Unmarshal(body, &e); err != nil {
+ t.Fatalf("unmarshal: %v\nbody=%s", err, body)
+ }
+ if e.Code != "ROUTE_NOT_FOUND" {
+ t.Errorf("code = %q, want ROUTE_NOT_FOUND", e.Code)
+ }
}
+// TestOpenAPIYAMLServed confirms the embedded spec is reachable at the
+// documented path so external tooling can fetch it.
func TestOpenAPIYAMLServed(t *testing.T) {
srv := newTestServer(t)
body, status, headers := doRequest(t, srv, "GET", "/api/v1/openapi.yaml", "")
@@ -212,31 +405,42 @@ func TestOpenAPIYAMLServed(t *testing.T) {
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")
+ t.Errorf("served body did not start with an OpenAPI 3.1 doc — first bytes:\n%s", firstLine(body))
}
}
-type projectSummary struct {
- ID string `json:"id"`
- Name string `json:"name"`
- SessionPrefix string `json:"sessionPrefix"`
+// envelope mirrors the locked APIError on the wire. We declare it in the test
+// rather than importing httpx's type so the test pins the JSON contract
+// independently of internal renames.
+type envelope struct {
+ Error string `json:"error"`
+ Code string `json:"code"`
+ Message string `json:"message"`
+ RequestID string `json:"requestId"`
}
-type projectBody struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Path string `json:"path"`
- Repo string `json:"repo"`
- DefaultBranch string `json:"defaultBranch"`
- Agent string `json:"agent"`
- Runtime string `json:"runtime"`
-}
-
-type errorBody struct {
- Error string `json:"error"`
- Code string `json:"code"`
- Message string `json:"message"`
- Details map[string]any `json:"details"`
+// assertEnvelope decodes body as the locked APIError and checks the machine
+// code plus that the envelope is fully populated. This pins R9: every non-2xx
+// response carries error, code, message, and a correlation requestId (the
+// router's RequestID middleware always tags it).
+func assertEnvelope(t *testing.T, body []byte, wantCode string) {
+ t.Helper()
+ var e envelope
+ if err := json.Unmarshal(body, &e); err != nil {
+ t.Fatalf("unmarshal envelope: %v\nbody=%s", err, body)
+ }
+ if e.Code != wantCode {
+ t.Errorf("code = %q, want %q", e.Code, wantCode)
+ }
+ if e.Error == "" {
+ t.Error("envelope.error empty")
+ }
+ if e.Message == "" {
+ t.Error("envelope.message empty")
+ }
+ if e.RequestID == "" {
+ t.Error("envelope.requestId empty — RequestID middleware not applied?")
+ }
}
func doRequest(t *testing.T, srv *httptest.Server, method, path, body string) ([]byte, int, http.Header) {
@@ -260,52 +464,23 @@ func doRequest(t *testing.T, srv *httptest.Server, method, path, body string) ([
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)
+ buf := make([]byte, 0, 1024)
+ tmp := make([]byte, 512)
+ for {
+ n, rerr := resp.Body.Read(tmp)
+ if n > 0 {
+ buf = append(buf, tmp[:n]...)
+ }
+ if rerr != nil {
+ break
+ }
}
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)
+func firstLine(b []byte) string {
+ if i := strings.IndexByte(string(b), '\n'); i >= 0 {
+ return string(b[:i])
}
- 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/envelope/envelope.go b/backend/internal/httpd/envelope/envelope.go
deleted file mode 100644
index 3e1b2ade..00000000
--- a/backend/internal/httpd/envelope/envelope.go
+++ /dev/null
@@ -1,35 +0,0 @@
-package envelope
-
-import (
- "encoding/json"
- "net/http"
-
- "github.com/go-chi/chi/v5/middleware"
-)
-
-// APIError is the locked wire shape for every non-2xx response.
-type APIError struct {
- Error string `json:"error"`
- Code string `json:"code"`
- Message string `json:"message"`
- RequestID string `json:"requestId,omitempty"`
- Details map[string]any `json:"details,omitempty"`
-}
-
-// WriteJSON serialises v as JSON with the given status.
-func WriteJSON(w http.ResponseWriter, status int, v any) {
- w.Header().Set("Content-Type", "application/json; charset=utf-8")
- w.WriteHeader(status)
- _ = json.NewEncoder(w).Encode(v)
-}
-
-// WriteAPIError emits the locked envelope for any non-2xx response.
-func WriteAPIError(w http.ResponseWriter, r *http.Request, status int, kind, code, message string, details map[string]any) {
- WriteJSON(w, status, APIError{
- Error: kind,
- Code: code,
- Message: message,
- RequestID: middleware.GetReqID(r.Context()),
- Details: details,
- })
-}
diff --git a/backend/internal/httpd/errors.go b/backend/internal/httpd/errors.go
deleted file mode 100644
index 8b41c99f..00000000
--- a/backend/internal/httpd/errors.go
+++ /dev/null
@@ -1,22 +0,0 @@
-package httpd
-
-import (
- "net/http"
-
- "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
-)
-
-// APIError is the locked wire shape for every non-2xx response. It supersedes
-// the legacy TS `{error: "msg"}` bag with a machine-readable Code and a
-// RequestID for log correlation (sourced from chi's RequestID middleware).
-//
-// Details is open so collision-style errors can carry typed sub-fields
-// (e.g. existingProjectId, suggestedProjectId on POST /projects 409s).
-type APIError = envelope.APIError
-
-// writeAPIError emits the locked envelope for any non-2xx response. The
-// request id falls back to empty when the chi middleware hasn't tagged the
-// request (e.g. in tests that bypass NewRouter).
-func writeAPIError(w http.ResponseWriter, r *http.Request, status int, kind, code, message string, details map[string]any) {
- envelope.WriteAPIError(w, r, status, kind, code, message, details)
-}
diff --git a/backend/internal/httpd/httpx/httpx.go b/backend/internal/httpd/httpx/httpx.go
new file mode 100644
index 00000000..76d6f384
--- /dev/null
+++ b/backend/internal/httpd/httpx/httpx.go
@@ -0,0 +1,108 @@
+// Package httpx holds the transport primitives shared across the HTTP surface:
+// the JSON writer, the locked error envelope, and a request-body decoder. It
+// is a leaf package (no imports of httpd or controllers) so both the router
+// (package httpd) and the resource controllers can depend on it without a
+// cycle — httpd imports controllers, so the writers can't live in httpd.
+package httpx
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+
+ "github.com/go-chi/chi/v5/middleware"
+)
+
+// WriteJSON serialises v as JSON with the given status. A write error means the
+// client went away mid-response; there is nothing useful to do but stop.
+func WriteJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(v)
+}
+
+// Error is the locked wire shape for every non-2xx response. It supersedes the
+// legacy TS `{error: "msg"}` bag with a machine-readable Code and a RequestID
+// for log correlation. Details is open so collision-style errors can carry
+// typed sub-fields (e.g. existingProjectId on POST /projects 409s).
+type Error struct {
+ Error string `json:"error" description:"Short kind, e.g. not_found"`
+ Code string `json:"code" description:"SCREAMING_SNAKE machine code"`
+ Message string `json:"message" description:"Human-readable detail"`
+ RequestID string `json:"requestId,omitempty"`
+ Details map[string]any `json:"details,omitempty"`
+}
+
+// WriteError emits the locked envelope for any non-2xx response. The request id
+// falls back to empty when the chi middleware hasn't tagged the request (e.g.
+// in tests that bypass the router).
+func WriteError(w http.ResponseWriter, r *http.Request, status int, kind, code, message string, details map[string]any) {
+ WriteJSON(w, status, Error{
+ Error: kind,
+ Code: code,
+ Message: message,
+ RequestID: middleware.GetReqID(r.Context()),
+ Details: details,
+ })
+}
+
+// APIErr is a typed application error carrying everything needed to render the
+// locked envelope: an HTTP status plus kind/code/message/details. Service layers
+// (e.g. project.Manager) return these so they don't need an http.ResponseWriter
+// or to know transport details; controllers translate them via AsAPIErr +
+// WriteAPIErr. This is "the http-layer error type" reused across layers — there
+// is deliberately no parallel error type in the feature packages.
+type APIErr struct {
+ Status int
+ Kind string
+ Code string
+ Message string
+ Details map[string]any
+}
+
+func (e *APIErr) Error() string {
+ if e == nil {
+ return ""
+ }
+ return e.Message
+}
+
+func newAPIErr(status int, kind, code, message string, details map[string]any) *APIErr {
+ return &APIErr{Status: status, Kind: kind, Code: code, Message: message, Details: details}
+}
+
+// BadRequest/NotFound/Conflict/Internal construct typed errors for the common
+// statuses. code is the SCREAMING_SNAKE machine code; details is optional.
+func BadRequest(code, message string, details map[string]any) *APIErr {
+ return newAPIErr(http.StatusBadRequest, "bad_request", code, message, details)
+}
+func NotFound(code, message string) *APIErr {
+ return newAPIErr(http.StatusNotFound, "not_found", code, message, nil)
+}
+func Conflict(code, message string, details map[string]any) *APIErr {
+ return newAPIErr(http.StatusConflict, "conflict", code, message, details)
+}
+func Internal(code, message string) *APIErr {
+ return newAPIErr(http.StatusInternalServerError, "internal", code, message, nil)
+}
+
+// AsAPIErr extracts an *APIErr from err, if present.
+func AsAPIErr(err error) (*APIErr, bool) {
+ var e *APIErr
+ if errors.As(err, &e) {
+ return e, true
+ }
+ return nil, false
+}
+
+// WriteAPIErr renders an *APIErr as the locked envelope.
+func WriteAPIErr(w http.ResponseWriter, r *http.Request, e *APIErr) {
+ WriteError(w, r, e.Status, e.Kind, e.Code, e.Message, e.Details)
+}
+
+// DecodeJSON decodes the request body into dst. It is lenient about unknown
+// keys on purpose: the project config blobs carry passthrough semantics, so
+// extra fields must survive rather than fail the request.
+func DecodeJSON(r *http.Request, dst any) error {
+ return json.NewDecoder(r.Body).Decode(dst)
+}
diff --git a/backend/internal/httpd/json.go b/backend/internal/httpd/json.go
deleted file mode 100644
index 64ccb340..00000000
--- a/backend/internal/httpd/json.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package httpd
-
-import (
- "net/http"
-
- "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
-)
-
-// writeJSON serialises v as JSON with the given status. It is the single JSON
-// writer for the skeleton; the typed error envelope (open item Q1.3) will build
-// on this in a later phase.
-func writeJSON(w http.ResponseWriter, status int, v any) {
- envelope.WriteJSON(w, status, v)
-}
diff --git a/backend/internal/httpd/router.go b/backend/internal/httpd/router.go
index 019f7efe..0c33ebea 100644
--- a/backend/internal/httpd/router.go
+++ b/backend/internal/httpd/router.go
@@ -1,7 +1,7 @@
-// Package httpd builds and runs the daemon's HTTP surface. Phase 1a is the
-// skeleton: the middleware stack, liveness/readiness probes, and a graceful
-// run loop. Route registration (/api/v1, /events, /mux, /) lands in later
-// phases on top of the router this package builds.
+// Package httpd builds and runs the daemon's HTTP surface: the middleware
+// stack, the liveness/readiness probes, JSON error envelopes for unmatched
+// routes, the terminal WebSocket mux (mux.go), the /api/v1 REST surface
+// (api.go), and a graceful run loop.
package httpd
import (
@@ -12,6 +12,7 @@ import (
"github.com/go-chi/chi/v5/middleware"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
+ "github.com/aoagents/agent-orchestrator/backend/internal/httpd/httpx"
"github.com/aoagents/agent-orchestrator/backend/internal/terminal"
)
@@ -25,18 +26,17 @@ import (
// requestLogger → slog-backed access log, stderr, carries the request id
// RealIP → normalise client IP (loopback proxy from the dev server)
//
-// The per-request Timeout from the decision table is deliberately NOT applied
-// globally: it must wrap only the /api/v1 REST surface, never the long-lived
-// SSE (/events) or WebSocket (/mux) surfaces, nor the always-must-answer health
-// probes. It is therefore applied per-surface when those subrouters are mounted
-// in Phase 1b; cfg.RequestTimeout carries the value through to that point.
+// The per-request Timeout from the decision table is deliberately NOT applied on
+// this global stack — that would also time out the health probes and the
+// long-lived terminal WebSocket. NewAPI applies it to the bounded /api/v1 REST
+// group instead (see api.go); cfg.RequestTimeout carries the value through.
func NewRouter(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager) chi.Router {
return NewRouterWithAPI(cfg, log, termMgr, APIDeps{})
}
-// NewRouterWithAPI is the dependency-injected variant. main.go calls it with
-// real Managers when they exist; tests/dev wiring inject mocks explicitly.
-// Missing Managers intentionally keep the route-shell 501 behavior.
+// NewRouterWithAPI is the dependency-injected variant. main.go calls it with the
+// real Managers; tests and the zero-dep NewRouter call it with empty APIDeps, so
+// route-shell handlers answer 500 SERVICE_UNAVAILABLE without wiring every port.
func NewRouterWithAPI(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager, deps APIDeps) chi.Router {
r := chi.NewRouter()
@@ -68,12 +68,12 @@ func mountHealth(r chi.Router) {
// handleHealthz is the liveness probe: it answers 200 as long as the process is
// up and serving. It does no dependency checks by design.
func handleHealthz(w http.ResponseWriter, _ *http.Request) {
- writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
+ httpx.WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleReadyz is the readiness probe. In the 1a skeleton the daemon is ready
// as soon as it is listening; later phases will gate this on dependency
// initialisation (e.g. store/event-bus warm-up).
func handleReadyz(w http.ResponseWriter, _ *http.Request) {
- writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
+ httpx.WriteJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}
diff --git a/backend/internal/httpd/server.go b/backend/internal/httpd/server.go
index 506f78b5..75de0eda 100644
--- a/backend/internal/httpd/server.go
+++ b/backend/internal/httpd/server.go
@@ -28,8 +28,9 @@ type Server struct {
// New constructs a Server and binds the listener immediately so a port
// conflict fails fast — before any running.json is written. The caller owns
// the returned Server's lifecycle via Run. termMgr may be nil, in which case
-// the /mux terminal surface is not mounted.
-func New(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager) (*Server, error) {
+// the /mux terminal surface is not mounted. deps carries the resource Managers
+// for the /api/v1 surface (a nil Manager leaves its routes answering 500).
+func New(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager, deps APIDeps) (*Server, error) {
ln, err := net.Listen("tcp", cfg.Addr())
if err != nil {
return nil, fmt.Errorf("bind %s (is a daemon already running?): %w", cfg.Addr(), err)
@@ -40,7 +41,7 @@ func New(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager) (*Serve
log: log,
listen: ln,
http: &http.Server{
- Handler: NewRouter(cfg, log, termMgr),
+ Handler: NewRouterWithAPI(cfg, log, termMgr, deps),
// ReadHeaderTimeout guards against slow-loris even on loopback;
// per-request body/handler timeouts are applied per-surface.
ReadHeaderTimeout: 10 * time.Second,
diff --git a/backend/internal/httpd/server_test.go b/backend/internal/httpd/server_test.go
index 39270d1c..748d2c97 100644
--- a/backend/internal/httpd/server_test.go
+++ b/backend/internal/httpd/server_test.go
@@ -51,7 +51,7 @@ func TestServerLifecycle(t *testing.T) {
RunFilePath: runPath,
}
- srv, err := New(cfg, discardLogger(), nil)
+ srv, err := New(cfg, discardLogger(), nil, APIDeps{})
if err != nil {
t.Fatalf("New: %v", err)
}
@@ -116,7 +116,7 @@ func waitForHealth(t *testing.T, base string) {
func TestNewFailsOnPortConflict(t *testing.T) {
cfg := config.Config{Host: "127.0.0.1", Port: 0, RunFilePath: filepath.Join(t.TempDir(), "r.json")}
- first, err := New(cfg, discardLogger(), nil)
+ first, err := New(cfg, discardLogger(), nil, APIDeps{})
if err != nil {
t.Fatalf("first New: %v", err)
}
@@ -124,7 +124,7 @@ func TestNewFailsOnPortConflict(t *testing.T) {
// Re-bind the exact port the first server took.
conflict := config.Config{Host: "127.0.0.1", Port: first.boundPort(), RunFilePath: cfg.RunFilePath}
- if _, err := New(conflict, discardLogger(), nil); err == nil {
+ if _, err := New(conflict, discardLogger(), nil, APIDeps{}); err == nil {
t.Fatal("New on an already-bound port = nil error, want bind failure")
}
}
diff --git a/backend/internal/project/dto.go b/backend/internal/project/dto.go
index 0e6f5ee5..2dcbf411 100644
--- a/backend/internal/project/dto.go
+++ b/backend/internal/project/dto.go
@@ -2,54 +2,41 @@ 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).
+// Request/result shapes for Manager. The entities they reference (Project,
+// Summary, Degraded) live in types.go.
-// 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.
+// GetResult is the internal result of Manager.Get — not the wire shape, so no
+// JSON tags. Exactly one of Project/Degraded is non-nil; Status is the
+// discriminator. The controller maps this onto the ProjectGetResponse envelope
+// ({status, project}) from openapi.yaml, keeping the oneOf out of this package.
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.
+// AddInput is the body for POST /api/v1/projects. Path is required; ProjectID
+// and Name default to basename(path). Pointers distinguish absent from empty.
type AddInput struct {
- Path string `json:"path"`
- ProjectID *string `json:"projectId,omitempty"`
- Name *string `json:"name,omitempty"`
+ Path string `json:"path" description:"Repository path; supports ~ home-expansion. Must be a git repo."`
+ ProjectID *string `json:"projectId,omitempty" description:"Optional override; defaults to basename(path)."`
+ Name *string `json:"name,omitempty" description:"Optional display name; defaults to projectId."`
}
-// 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.
+// UpdateConfigInput is the body for PATCH /api/v1/projects/{id}. Only behaviour
+// fields are mutable; identity fields are rejected with 400 IDENTITY_FROZEN. A
+// nil field means it was absent from the patch.
type UpdateConfigInput struct {
- Agent *string `json:"agent,omitempty"`
- Runtime *string `json:"runtime,omitempty"`
+ Agent AgentConfig `json:"agent,omitempty"`
+ Runtime RuntimeConfig `json:"runtime,omitempty"`
Tracker *TrackerConfig `json:"tracker,omitempty"`
SCM *SCMConfig `json:"scm,omitempty"`
Reactions *map[string]*ReactionConfig `json:"reactions,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).
+// RemoveResult is the body for DELETE /api/v1/projects/{id}. RemovedStorageDir
+// is false when the project was registry-only (no on-disk 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/errors.go b/backend/internal/project/errors.go
deleted file mode 100644
index f6687e1f..00000000
--- a/backend/internal/project/errors.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package project
-
-// Error is the manager-level error shape controllers can translate into the
-// locked HTTP APIError envelope without knowing store internals.
-type Error struct {
- Kind string
- Code string
- Message string
- Details map[string]any
-}
-
-func (e *Error) Error() string {
- if e == nil {
- return ""
- }
- return e.Message
-}
-
-func newError(kind, code, message string, details map[string]any) *Error {
- return &Error{Kind: kind, Code: code, Message: message, Details: details}
-}
-
-func badRequest(code, message string, details map[string]any) *Error {
- return newError("bad_request", code, message, details)
-}
-
-func notFound(code, message string) *Error {
- return newError("not_found", code, message, nil)
-}
-
-func conflict(code, message string, details map[string]any) *Error {
- return newError("conflict", code, message, details)
-}
-
-func notImplemented(code, message string) *Error {
- return newError("not_implemented", code, message, nil)
-}
-
-func internal(code, message string) *Error {
- return newError("internal", code, message, nil)
-}
diff --git a/backend/internal/project/manager.go b/backend/internal/project/manager.go
index 93ca84d9..22141061 100644
--- a/backend/internal/project/manager.go
+++ b/backend/internal/project/manager.go
@@ -11,32 +11,33 @@ import (
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
+ "github.com/aoagents/agent-orchestrator/backend/internal/httpd/httpx"
+ "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
)
+// manager is the registry-backed project.Manager. It calls the existing sqlite
+// project store directly (no extra store/port/adapter); the on-disk behaviour
+// config is not yet wired, so Get always resolves "ok" and UpdateConfig reports
+// that config persistence is unavailable. Errors are httpx.APIErr values the
+// controller translates to the locked envelope.
type manager struct {
- store Store
+ store *sqlite.Store
}
var _ Manager = (*manager)(nil)
-func NewManager(store Store) Manager {
- if store == nil {
- store = NewMemoryStore()
- }
+// NewManager builds the project Manager over the durable sqlite store.
+func NewManager(store *sqlite.Store) Manager {
return &manager{store: store}
}
-func NewMemoryManager() Manager {
- return NewManager(NewMemoryStore())
-}
-
func (m *manager) List(ctx context.Context) ([]Summary, error) {
- projects, err := m.store.List(ctx)
+ rows, err := m.store.ListProjects(ctx)
if err != nil {
- return nil, internal("PROJECTS_LIST_FAILED", "Failed to load projects")
+ return nil, httpx.Internal("PROJECTS_LIST_FAILED", "Failed to load projects")
}
- out := make([]Summary, 0, len(projects))
- for _, row := range projects {
+ out := make([]Summary, 0, len(rows))
+ for _, row := range rows {
out = append(out, Summary{
ID: domain.ProjectID(row.ID),
Name: displayName(row),
@@ -50,12 +51,12 @@ func (m *manager) Get(ctx context.Context, id domain.ProjectID) (GetResult, erro
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")
+ return GetResult{}, httpx.Internal("PROJECT_LOAD_FAILED", "Failed to load project")
}
if !ok {
- return GetResult{}, notFound("PROJECT_NOT_FOUND", "Unknown project")
+ return GetResult{}, httpx.NotFound("PROJECT_NOT_FOUND", "Unknown project")
}
p := projectFromRow(row)
return GetResult{Status: "ok", Project: &p}, nil
@@ -67,7 +68,7 @@ func (m *manager) Add(ctx context.Context, in AddInput) (Project, error) {
return Project{}, err
}
if !isGitRepo(path) {
- return Project{}, badRequest("NOT_A_GIT_REPO", "Repository path must point to a git repository", nil)
+ return Project{}, httpx.BadRequest("NOT_A_GIT_REPO", "Repository path must point to a git repository", nil)
}
id := defaultProjectID(path)
@@ -79,38 +80,35 @@ func (m *manager) Add(ctx context.Context, in AddInput) (Project, error) {
}
name := string(id)
- if in.Name != nil {
+ if in.Name != nil && strings.TrimSpace(*in.Name) != "" {
name = strings.TrimSpace(*in.Name)
}
- if name == "" {
- name = string(id)
- }
- if existing, ok, err := m.store.FindByPath(ctx, path); err != nil {
- return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project")
+ if existing, ok, err := m.findByPath(ctx, path); err != nil {
+ return Project{}, httpx.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{
+ return Project{}, httpx.Conflict("PATH_ALREADY_REGISTERED", "A project at this path is already registered", map[string]any{
"existingProjectId": existing.ID,
"suggestedProjectId": string(m.suggestID(ctx, id)),
})
}
- if existing, ok, err := m.store.Get(ctx, string(id)); err != nil {
- return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project")
+ if existing, ok, err := m.store.GetProject(ctx, string(id)); err != nil {
+ return Project{}, httpx.Internal("PROJECT_LOAD_FAILED", "Failed to load project")
} else if ok && existing.Path != path {
- return Project{}, conflict("ID_ALREADY_REGISTERED", "A project with this id is already registered for a different path", map[string]any{
+ return Project{}, httpx.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 := ProjectRow{
+ row := sqlite.ProjectRow{
ID: string(id),
Path: path,
DisplayName: name,
RegisteredAt: time.Now(),
}
- if err := m.store.Upsert(ctx, row); err != nil {
- return Project{}, err
+ if err := m.store.UpsertProject(ctx, row); err != nil {
+ return Project{}, httpx.Internal("PROJECT_ADD_FAILED", "Failed to add project")
}
return projectFromRow(row), nil
}
@@ -119,61 +117,62 @@ func (m *manager) UpdateConfig(ctx context.Context, id domain.ProjectID, _ Updat
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")
+ if _, ok, err := m.store.GetProject(ctx, string(id)); err != nil {
+ return Project{}, httpx.Internal("PROJECT_LOAD_FAILED", "Failed to load project")
+ } else if !ok {
+ return Project{}, httpx.NotFound("PROJECT_NOT_FOUND", "Unknown project")
}
-
- return Project{}, notImplemented("PROJECT_CONFIG_NOT_IMPLEMENTED", "Project config patching is not available until config persistence is wired")
+ // Identity is frozen and behaviour-config persistence isn't wired yet, so
+ // there is nothing this patch can durably change.
+ return Project{}, httpx.Conflict("PROJECT_CONFIG_UNAVAILABLE",
+ "Project config patching is unavailable until config persistence is wired", nil)
}
func (m *manager) 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())
- if err != nil {
- return RemoveResult{}, internal("PROJECT_REMOVE_FAILED", "Failed to remove project")
+ if _, ok, err := m.store.GetProject(ctx, string(id)); err != nil {
+ return RemoveResult{}, httpx.Internal("PROJECT_REMOVE_FAILED", "Failed to remove project")
+ } else if !ok {
+ return RemoveResult{}, httpx.NotFound("PROJECT_NOT_FOUND", "Unknown project")
}
- if !ok {
- return RemoveResult{}, notFound("PROJECT_NOT_FOUND", "Unknown project")
+ if err := m.store.ArchiveProject(ctx, string(id), time.Now()); err != nil {
+ return RemoveResult{}, httpx.Internal("PROJECT_REMOVE_FAILED", "Failed to remove project")
}
+ // removedStorageDir stays false until session/workspace storage management
+ // exists (see the Remove doc on the Manager interface).
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)
+// findByPath scans the active registry for a project at path. The project count
+// is small, so a List scan beats adding a dedicated indexed query for now.
+func (m *manager) findByPath(ctx context.Context, path string) (sqlite.ProjectRow, bool, error) {
+ rows, err := m.store.ListProjects(ctx)
if err != nil {
- return ReloadResult{}, internal("RELOAD_FAILED", "Failed to reload projects")
+ return sqlite.ProjectRow{}, false, err
}
- return ReloadResult{Reloaded: true, ProjectCount: len(projects), DegradedCount: 0}, nil
+ for _, r := range rows {
+ if r.Path == path {
+ return r, true, nil
+ }
+ }
+ return sqlite.ProjectRow{}, false, nil
}
+// suggestID returns the first "" id that is not already registered.
func (m *manager) 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 ProjectRow) Project {
+// --- pure helpers (registry → wire mapping, path/id validation) -------------
+
+func projectFromRow(row sqlite.ProjectRow) Project {
return Project{
ID: domain.ProjectID(row.ID),
Name: displayName(row),
@@ -183,62 +182,58 @@ func projectFromRow(row ProjectRow) Project {
}
}
-func displayName(row ProjectRow) string {
+func displayName(row sqlite.ProjectRow) string {
if strings.TrimSpace(row.DisplayName) != "" {
return row.DisplayName
}
return row.ID
}
+// normalizePath trims, ~-expands, and absolutizes a repository path.
func normalizePath(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
- return "", badRequest("PATH_REQUIRED", "Repository path is required", nil)
+ return "", httpx.BadRequest("PATH_REQUIRED", "Repository path is required", nil)
}
if strings.HasPrefix(raw, "~") {
home, err := os.UserHomeDir()
if err != nil {
- return "", badRequest("INVALID_PATH", "Repository path could not be expanded", nil)
+ return "", httpx.BadRequest("INVALID_PATH", "Repository path could not be expanded", nil)
}
- if raw == "~" {
+ switch {
+ case raw == "~":
raw = home
- } else if strings.HasPrefix(raw, "~/") || strings.HasPrefix(raw, `~\`) {
+ case strings.HasPrefix(raw, "~/") || strings.HasPrefix(raw, `~\`):
raw = filepath.Join(home, raw[2:])
}
}
abs, err := filepath.Abs(raw)
if err != nil {
- return "", badRequest("INVALID_PATH", "Repository path is invalid", nil)
+ return "", httpx.BadRequest("INVALID_PATH", "Repository path is invalid", nil)
}
return filepath.Clean(abs), nil
}
+// isGitRepo reports whether path is inside a git work tree whose top level is
+// path itself (a registered project must be a repo root, not a subdirectory).
func isGitRepo(path string) bool {
- cmd := exec.Command("git", "-C", path, "rev-parse", "--show-toplevel")
- out, err := cmd.Output()
+ out, err := exec.Command("git", "-C", path, "rev-parse", "--show-toplevel").Output()
if err != nil {
return false
}
- top := filepath.Clean(strings.TrimSpace(string(out)))
- path = filepath.Clean(path)
- top, err = filepath.EvalSymlinks(top)
+ top, err := filepath.EvalSymlinks(filepath.Clean(strings.TrimSpace(string(out))))
if err != nil {
return false
}
- path, err = filepath.EvalSymlinks(path)
+ p, err := filepath.EvalSymlinks(filepath.Clean(path))
if err != nil {
return false
}
-
- if strings.EqualFold(top, path) {
- return true
- }
- return top == path
+ return strings.EqualFold(top, p)
}
func defaultProjectID(path string) domain.ProjectID {
- id := strings.ToLower(filepath.Base(path))
- id = strings.TrimSpace(id)
+ id := strings.ToLower(strings.TrimSpace(filepath.Base(path)))
id = strings.ReplaceAll(id, " ", "-")
return domain.ProjectID(id)
}
@@ -248,17 +243,18 @@ var projectIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
func validateProjectID(id domain.ProjectID) error {
raw := string(id)
if raw == "" || raw == "." || raw == ".." || strings.ContainsAny(raw, `/\`) || !projectIDPattern.MatchString(raw) {
- return badRequest("INVALID_PROJECT_ID", "Project id failed storage-path validation", nil)
+ return httpx.BadRequest("INVALID_PROJECT_ID", "Project id failed storage-path validation", nil)
}
return nil
}
func sessionPrefix(id string) string {
- if id == "" {
+ switch {
+ case id == "":
return "ao"
- }
- if len(id) <= 12 {
+ case len(id) <= 12:
return id
+ default:
+ return id[:12]
}
- return id[:12]
}
diff --git a/backend/internal/project/manager_test.go b/backend/internal/project/manager_test.go
new file mode 100644
index 00000000..1cfc05c2
--- /dev/null
+++ b/backend/internal/project/manager_test.go
@@ -0,0 +1,144 @@
+package project_test
+
+import (
+ "context"
+ "os/exec"
+ "testing"
+
+ "github.com/aoagents/agent-orchestrator/backend/internal/domain"
+ "github.com/aoagents/agent-orchestrator/backend/internal/httpd/httpx"
+ "github.com/aoagents/agent-orchestrator/backend/internal/project"
+ "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
+)
+
+// newManager builds a Manager over a real, throwaway sqlite store (pure-Go
+// driver, migrations run on Open) — no fake, no in-memory store.
+func newManager(t *testing.T) project.Manager {
+ t.Helper()
+ store, err := sqlite.Open(t.TempDir())
+ if err != nil {
+ t.Fatalf("open store: %v", err)
+ }
+ t.Cleanup(func() { _ = store.Close() })
+ return project.NewManager(store)
+}
+
+// gitRepo creates a real git repository in a fresh temp dir and returns its path.
+func gitRepo(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ if out, err := exec.Command("git", "init", dir).CombinedOutput(); err != nil {
+ t.Skipf("git unavailable: %v (%s)", err, out)
+ }
+ return dir
+}
+
+func ptr(s string) *string { return &s }
+
+// wantAPIErr asserts err is an httpx.APIErr with the given status + code.
+func wantAPIErr(t *testing.T, err error, status int, code string) {
+ t.Helper()
+ e, ok := httpx.AsAPIErr(err)
+ if !ok {
+ t.Fatalf("error = %v, want *httpx.APIErr", err)
+ }
+ if e.Status != status || e.Code != code {
+ t.Fatalf("error = %d/%s, want %d/%s", e.Status, e.Code, status, code)
+ }
+}
+
+func TestManager_AddListGetRemove(t *testing.T) {
+ ctx := context.Background()
+ m := newManager(t)
+ repo := gitRepo(t)
+
+ // empty list
+ if got, err := m.List(ctx); err != nil || len(got) != 0 {
+ t.Fatalf("List() = %v, %v; want empty", got, err)
+ }
+
+ // add
+ 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 now has it, with derived sessionPrefix
+ list, err := m.List(ctx)
+ if err != nil || len(list) != 1 {
+ t.Fatalf("List() = %v, %v; want 1", list, err)
+ }
+ if list[0].ID != "ao" || list[0].SessionPrefix != "ao" {
+ t.Fatalf("summary = %#v", list[0])
+ }
+
+ // get → ok
+ 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)
+ }
+
+ // remove → archived, drops out of List but Get still resolves the row
+ rm, err := m.Remove(ctx, "ao")
+ if err != nil {
+ t.Fatalf("Remove: %v", err)
+ }
+ if rm.ProjectID != "ao" || rm.RemovedStorageDir {
+ t.Fatalf("Remove = %#v", rm)
+ }
+ if list, _ := m.List(ctx); len(list) != 0 {
+ t.Fatalf("active list after remove = %d, want 0", len(list))
+ }
+}
+
+func TestManager_AddValidationAndConflicts(t *testing.T) {
+ ctx := context.Background()
+ m := newManager(t)
+
+ _, err := m.Add(ctx, project.AddInput{Path: ""})
+ wantAPIErr(t, err, 400, "PATH_REQUIRED")
+
+ _, err = m.Add(ctx, project.AddInput{Path: t.TempDir()}) // dir exists but not a git repo
+ wantAPIErr(t, err, 400, "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)
+ }
+
+ // same path, different id → PATH_ALREADY_REGISTERED
+ _, err = m.Add(ctx, project.AddInput{Path: repoA, ProjectID: ptr("other")})
+ wantAPIErr(t, err, 409, "PATH_ALREADY_REGISTERED")
+
+ // same id, different path → ID_ALREADY_REGISTERED
+ _, err = m.Add(ctx, project.AddInput{Path: repoB, ProjectID: ptr("shared")})
+ wantAPIErr(t, err, 409, "ID_ALREADY_REGISTERED")
+}
+
+func TestManager_GetUpdateErrors(t *testing.T) {
+ ctx := context.Background()
+ m := newManager(t)
+
+ _, err := m.Get(ctx, "nope")
+ wantAPIErr(t, err, 404, "PROJECT_NOT_FOUND")
+
+ _, err = m.Get(ctx, domain.ProjectID("bad/id"))
+ wantAPIErr(t, err, 400, "INVALID_PROJECT_ID")
+
+ _, err = m.Remove(ctx, "nope")
+ wantAPIErr(t, err, 404, "PROJECT_NOT_FOUND")
+
+ // registry-only: config patching is unavailable even for an existing project
+ repo := gitRepo(t)
+ if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("p")}); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+ _, err = m.UpdateConfig(ctx, "p", project.UpdateConfigInput{})
+ wantAPIErr(t, err, 409, "PROJECT_CONFIG_UNAVAILABLE")
+}
diff --git a/backend/internal/project/memory_store.go b/backend/internal/project/memory_store.go
deleted file mode 100644
index 945a7826..00000000
--- a/backend/internal/project/memory_store.go
+++ /dev/null
@@ -1,108 +0,0 @@
-package project
-
-import (
- "context"
- "sync"
- "time"
-)
-
-// ProjectRow 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 ProjectRow struct {
- ID string
- Path string
- RepoOriginURL string
- DisplayName string
- RegisteredAt time.Time
- ArchivedAt time.Time
-}
-
-type Store interface {
- List(ctx context.Context) ([]ProjectRow, error)
- Get(ctx context.Context, id string) (ProjectRow, bool, error)
- FindByPath(ctx context.Context, path string) (ProjectRow, bool, error)
- Upsert(ctx context.Context, row ProjectRow) 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]ProjectRow
- paths map[string]string
-}
-
-var _ Store = (*MemoryStore)(nil)
-
-func NewMemoryStore() *MemoryStore {
- return &MemoryStore{
- projects: map[string]ProjectRow{},
- paths: map[string]string{},
- }
-}
-
-func (s *MemoryStore) List(context.Context) ([]ProjectRow, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- out := make([]ProjectRow, 0, len(s.projects))
- for _, row := range s.projects {
- if row.ArchivedAt.IsZero() {
- out = append(out, row)
- }
- }
- return out, nil
-}
-
-func (s *MemoryStore) Get(_ context.Context, id string) (ProjectRow, bool, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- row, ok := s.projects[id]
- if !ok {
- return ProjectRow{}, false, nil
- }
- return row, true, nil
-}
-
-func (s *MemoryStore) FindByPath(_ context.Context, path string) (ProjectRow, bool, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- id, ok := s.paths[path]
- if !ok {
- return ProjectRow{}, false, nil
- }
- row, ok := s.projects[id]
- if !ok {
- return ProjectRow{}, false, nil
- }
- return row, true, nil
-}
-
-func (s *MemoryStore) Upsert(_ context.Context, row ProjectRow) 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
-}
-
-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
index a997519d..e712c7de 100644
--- a/backend/internal/project/project.go
+++ b/backend/internal/project/project.go
@@ -1,13 +1,16 @@
-// Package project owns the projects service contract: the Manager interface
-// the HTTP layer calls and the request/response DTOs that cross it (dto.go).
+// Package project owns the projects service contract: the Manager interface and
+// the DTOs that cross it (dto.go), plus the project entities (types.go).
//
-// This is the pilot for the feature-package layout the backend is migrating
-// toward: a resource's interface and DTOs live with the resource, not in a
-// central catch-all. Controllers depend on project.Manager and nothing
-// beneath it — whether the implementation reaches into the config registry,
-// the lifecycle manager (to stop sessions on remove), or a workspace adapter
-// (to destroy worktrees) is a private concern of the impl, which lands in a
-// later handler-impl PR. This PR defines only the contract.
+// Manager is an application-service contract reused across protocols (HTTP
+// today, CLI next), so it lives in the feature package rather than beside one
+// consumer — mirroring ports.SessionManager. This is the pilot for the
+// feature-package layout: a resource's interface, entities, and DTOs live with
+// the resource. Consumers depend on Manager and nothing beneath it; what the
+// impl reaches into (config registry, LCM, workspace adapter) is its own
+// concern and lands in the handler-impl PR. This PR defines only the contract.
+//
+// Reload and Repair are absent by design: the route analysis dropped reload
+// and deferred repair.
package project
import (
@@ -16,8 +19,8 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
-// Manager is the inbound contract for the /api/v1/projects surface. One
-// implementation (this package, later); the HTTP controller is the consumer.
+// Manager is the inbound contract for project operations, called by the HTTP
+// controller today and the CLI later.
type Manager interface {
// List returns every registered project, including degraded entries
// (those whose config failed to load but whose registry entry survives).
@@ -35,10 +38,4 @@ type Manager interface {
// 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/project/types.go b/backend/internal/project/types.go
index 65e5daa2..3f4e0f3e 100644
--- a/backend/internal/project/types.go
+++ b/backend/internal/project/types.go
@@ -2,46 +2,38 @@ 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.
+// Project entities and behaviour-config shapes live here, not in domain/,
+// because only project identity (domain.ProjectID) is shared vocabulary; the
+// rest is owned by the projects surface.
-// Summary is the row shape returned by GET /api/v1/projects. It mirrors the TS
-// ProjectInfo (packages/web/src/lib/project-name.ts) so the existing dashboard
-// list view reads the Go daemon's response unchanged. ResolveError is set only
-// for degraded projects (registry entry survives but config failed to load),
-// so the list shows them with a warning instead of dropping them silently.
+// Summary is the row shape returned by GET /api/v1/projects. ResolveError is
+// set only for degraded projects so the list can flag them rather than drop
+// them.
type Summary struct {
ID domain.ProjectID `json:"id"`
Name string `json:"name"`
SessionPrefix string `json:"sessionPrefix"`
- ResolveError string `json:"resolveError,omitempty"`
+ ResolveError string `json:"resolveError,omitempty" description:"Present iff the project is degraded."`
}
// 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 resolves cleanly: registry identity joined with behaviour config.
type Project struct {
ID domain.ProjectID `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
- Repo string `json:"repo"` // "owner/name" or ""
- DefaultBranch string `json:"defaultBranch"`
- Agent string `json:"agent,omitempty"`
- Runtime string `json:"runtime,omitempty"`
+ Repo string `json:"repo" description:"\"owner/name\" or empty string when unset"`
+ DefaultBranch string `json:"defaultBranch" default:"main"`
+ Agent AgentConfig `json:"agent,omitempty" description:"Agent config blob (open object)"`
+ Runtime RuntimeConfig `json:"runtime,omitempty" description:"Runtime (terminal multiplexer) config blob (open object)"`
Tracker *TrackerConfig `json:"tracker,omitempty"`
SCM *SCMConfig `json:"scm,omitempty"`
Reactions map[string]*ReactionConfig `json:"reactions,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 replaces Project when the project's config failed to load.
+// ResolveError drives the frontend's recovery UI. Repair is deferred (see the
+// Manager doc), so a degraded project is read-only for now.
type Degraded struct {
ID domain.ProjectID `json:"id"`
Name string `json:"name"`
@@ -49,11 +41,17 @@ type Degraded struct {
ResolveError string `json:"resolveError"`
}
-// Behaviour-config shapes ported from the TS Zod schemas (packages/core/src/
-// config.ts). Only the fields the projects API actually exposes are modelled;
-// the passthrough/unknown-key round-trip the legacy schemas allowed lands with
-// the handler implementation (and the SQLite persistence work), not in this
-// interface-only PR.
+// Behaviour-config shapes ported from the TS Zod schemas. Only the fields the
+// API exposes are modelled; passthrough of unknown keys lands with the handler
+// impl, not this interface-only PR.
+
+// AgentConfig and RuntimeConfig are open config objects (the legacy local
+// config models agent/runtime as provider-defined blocks, not bare strings).
+// Concrete fields are ported with the handler impl; a nil map omits the field.
+type (
+ AgentConfig = map[string]any
+ RuntimeConfig = map[string]any
+)
// TrackerConfig mirrors TrackerConfigSchema.
type TrackerConfig struct {
@@ -86,11 +84,11 @@ type SCMWebhookConfig struct {
// `any` until handler validation lands.
type ReactionConfig struct {
Auto *bool `json:"auto,omitempty"`
- Action string `json:"action,omitempty"` // send-to-agent | notify | auto-merge
+ Action string `json:"action,omitempty" enum:"send-to-agent,notify,auto-merge"`
Message string `json:"message,omitempty"`
- Priority string `json:"priority,omitempty"` // urgent | action | warning | info
+ Priority string `json:"priority,omitempty" enum:"urgent,action,warning,info"`
Retries *int `json:"retries,omitempty"`
- EscalateAfter any `json:"escalateAfter,omitempty"`
+ EscalateAfter any `json:"escalateAfter,omitempty" description:"Either ms (number) or a duration string (\"30m\")"`
Threshold string `json:"threshold,omitempty"`
IncludeSummary *bool `json:"includeSummary,omitempty"`
}
diff --git a/backend/main.go b/backend/main.go
index e825039f..514ecc2b 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -15,6 +15,7 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd"
+ "github.com/aoagents/agent-orchestrator/backend/internal/project"
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
"github.com/aoagents/agent-orchestrator/backend/internal/terminal"
@@ -75,7 +76,10 @@ func run() error {
termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log)
defer termMgr.Close()
- srv, err := httpd.New(cfg, log, termMgr)
+ // The project registry API is backed directly by the durable sqlite store.
+ srv, err := httpd.New(cfg, log, termMgr, httpd.APIDeps{
+ Projects: project.NewManager(store),
+ })
if err != nil {
return err
}
diff --git a/docs/README.md b/docs/README.md
index f42f222f..434ee756 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -15,6 +15,7 @@ fakes) on the `feat/lcm-sm-contracts` integration branch.
|-----|----------------|
| [architecture.md](architecture.md) | How the lane works: the OBSERVE→DECIDE→ACT loop, the canonical state model, the package layout, every component, and the load-bearing invariants. Read this first. |
| [status.md](status.md) | What's done (PR by PR), what's left, the integration to-dos, the open cross-lane contract questions, and how to build/test. |
+| [api-contract.md](api-contract.md) | The code-first `/api/v1` OpenAPI contract: how the spec + frontend types are generated from Go, and the process to follow when changing an API route. |
## The one-paragraph mental model
diff --git a/docs/api-contract.md b/docs/api-contract.md
new file mode 100644
index 00000000..9ab150dd
--- /dev/null
+++ b/docs/api-contract.md
@@ -0,0 +1,77 @@
+# API contract — code-first OpenAPI
+
+The `/api/v1` HTTP contract is **code-first**: the Go request/response types are
+the source of truth, and `openapi.yaml` plus the frontend TypeScript types are
+**generated** from them. You never hand-write the spec or the TS types.
+
+```
+ Go types + operation registry openapi.yaml (generated) consumers
+ ───────────────────────────── ──────────────────────── ─────────
+ project.AddInput, Project, ... ──► apispec.Build() ──► cmd/genspec ──► go:embed ──► GET /api/v1/openapi.yaml
+ httpx.Error, controllers.*Response (swaggest reflect) writes file (apispec.go) + frontend/src/api/schema.d.ts
+ + (openapi-typescript)
+ projectOperations() registry
+```
+
+## Where each piece lives
+
+| Piece | Location |
+|---|---|
+| Request bodies / results | `backend/internal/project/dto.go` (`AddInput`, `UpdateConfigInput`, `RemoveResult`) |
+| Response envelopes + path params | `backend/internal/httpd/controllers/dto.go` (`ListProjectsResponse`, `ProjectResponse`, `GetProjectResponse`, `ProjectOrDegraded`, `ProjectIDParam`) |
+| Entities | `backend/internal/project/types.go` (`Project`, `Summary`, `Degraded`, config blobs) |
+| Error envelope (`APIError`) | `backend/internal/httpd/httpx/httpx.go` (`Error`) |
+| Operation registry + generator | `backend/internal/httpd/apispec/build.go` (`Build()`, `projectOperations()`) |
+| Generator entrypoint | `backend/cmd/genspec/main.go` (run by `//go:generate`, see `apispec/gen.go`) |
+| Generated spec (embedded + served) | `backend/internal/httpd/apispec/openapi.yaml` |
+| Generated TS types + client | `frontend/src/api/schema.d.ts`, `frontend/src/api/client.ts` |
+
+Schema facets are plain struct tags on those types — `description`, `enum`,
+`default`; `required` is derived automatically from the absence of `,omitempty`.
+The same Go types are used by the handlers (to decode/encode) **and** by
+`apispec.Build()` (to reflect the schema), so the runtime and the spec can't
+disagree.
+
+## Process: changing or adding an API route
+
+> **Rule:** edit Go, then regenerate. Never hand-edit `openapi.yaml` or
+> `schema.d.ts` — they are generated artifacts.
+
+1. **Edit the Go contract.**
+ - *Change a field / shape:* edit the struct where it lives (see the table
+ above) and adjust its tags. `required` follows `omitempty`.
+ - *Add or remove a route:* do **both** —
+ 1. wire the handler in `controllers/projects.go` (`Register` + the handler
+ func), and
+ 2. add/remove its entry in `projectOperations()` in `apispec/build.go`
+ (method, path, `operationId`, summary, request struct(s), response
+ struct per status code).
+
+2. **Regenerate** (from `backend/`):
+ ```bash
+ go generate ./... # Go → openapi.yaml
+ npm --prefix ../frontend run gen:api # openapi.yaml → frontend/src/api/schema.d.ts
+ ```
+
+3. **Verify:** `go test ./...`
+ - `TestBuild_MatchesEmbedded` fails if you forgot to regenerate `openapi.yaml`.
+ - `TestRouteSpecParity` fails if a mounted route has no spec operation (or a
+ spec operation has no route).
+
+4. **Commit** the Go change **together with** the regenerated `openapi.yaml` and
+ `schema.d.ts`.
+
+5. **CI** (`.github/workflows/go.yml`, job `gen-verify`) re-runs both generators
+ and `git diff --exit-code`s — a stale artifact blocks the merge.
+
+## Guarantees
+
+- **No drift:** `go test` (locally) and CI both fail if the committed artifacts
+ don't match a fresh generation.
+- **Route ↔ spec parity:** every served `/api/v1` route has a spec operation and
+ vice-versa (`parity_test.go`).
+- **One definition per shape:** each wire type is declared once, in the package
+ that uses it; `apispec/build.go` declares no wire types — only the registry.
+
+> Out of scope here: runtime request/response **validation** against the spec
+> (a middleware) is tracked separately (#19).
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 7eeda0af..b5c45740 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,9 +9,36 @@
"version": "0.0.0",
"devDependencies": {
"electron": "^33.0.0",
+ "openapi-fetch": "^0.17.0",
+ "openapi-typescript": "^7.13.0",
"typescript": "^5.6.0"
}
},
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@electron/get": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
@@ -34,6 +61,52 @@
"global-agent": "^3.0.0"
}
},
+ "node_modules/@redocly/ajv": {
+ "version": "8.11.2",
+ "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
+ "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2",
+ "uri-js-replace": "^1.0.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/@redocly/config": {
+ "version": "0.22.0",
+ "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz",
+ "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@redocly/openapi-core": {
+ "version": "1.34.15",
+ "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.15.tgz",
+ "integrity": "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@redocly/ajv": "8.11.2",
+ "@redocly/config": "0.22.0",
+ "colorette": "1.4.0",
+ "https-proxy-agent": "7.0.6",
+ "js-levenshtein": "1.1.6",
+ "js-yaml": "4.1.1",
+ "minimatch": "5.1.9",
+ "pluralize": "8.0.0",
+ "yaml-ast-parser": "0.0.43"
+ },
+ "engines": {
+ "node": ">=18.17.0",
+ "npm": ">=9.5.0"
+ }
+ },
"node_modules/@sindresorhus/is": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
@@ -121,6 +194,40 @@
"@types/node": "*"
}
},
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/ansi-colors": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
+ "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/boolean": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
@@ -130,6 +237,16 @@
"license": "MIT",
"optional": true
},
+ "node_modules/brace-expansion": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
+ "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
@@ -169,6 +286,13 @@
"node": ">=8"
}
},
+ "node_modules/change-case": {
+ "version": "5.4.4",
+ "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
+ "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/clone-response": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
@@ -182,6 +306,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/colorette": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
+ "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -389,6 +520,13 @@
"@types/yauzl": "^2.9.1"
}
},
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/fd-slicer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
@@ -563,6 +701,63 @@
"node": ">=10.19.0"
}
},
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/index-to-position": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
+ "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/js-levenshtein": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
+ "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -570,6 +765,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
@@ -632,6 +834,19 @@
"node": ">=4"
}
},
+ "node_modules/minimatch": {
+ "version": "5.1.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
+ "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -673,6 +888,44 @@
"wrappy": "1"
}
},
+ "node_modules/openapi-fetch": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz",
+ "integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "openapi-typescript-helpers": "^0.1.0"
+ }
+ },
+ "node_modules/openapi-typescript": {
+ "version": "7.13.0",
+ "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz",
+ "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@redocly/openapi-core": "^1.34.6",
+ "ansi-colors": "^4.1.3",
+ "change-case": "^5.4.4",
+ "parse-json": "^8.3.0",
+ "supports-color": "^10.2.2",
+ "yargs-parser": "^21.1.1"
+ },
+ "bin": {
+ "openapi-typescript": "bin/cli.js"
+ },
+ "peerDependencies": {
+ "typescript": "^5.x"
+ }
+ },
+ "node_modules/openapi-typescript-helpers": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz",
+ "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/p-cancelable": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
@@ -683,6 +936,37 @@
"node": ">=8"
}
},
+ "node_modules/parse-json": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
+ "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.26.2",
+ "index-to-position": "^1.1.0",
+ "type-fest": "^4.39.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parse-json/node_modules/type-fest": {
+ "version": "4.41.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
+ "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
@@ -690,6 +974,23 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/pluralize": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
+ "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@@ -724,6 +1025,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/resolve-alpn": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
@@ -819,6 +1130,19 @@
"node": ">= 8.0"
}
},
+ "node_modules/supports-color": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
+ "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
"node_modules/type-fest": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
@@ -839,6 +1163,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -864,6 +1189,13 @@
"node": ">= 4.0.0"
}
},
+ "node_modules/uri-js-replace": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
+ "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -871,6 +1203,23 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/yaml-ast-parser": {
+ "version": "0.0.43",
+ "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
+ "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/yauzl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index b58407e8..003ef311 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -7,10 +7,13 @@
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
+ "gen:api": "openapi-typescript ../backend/internal/httpd/apispec/openapi.yaml -o src/api/schema.d.ts",
"start": "npm run build && electron ."
},
"devDependencies": {
"electron": "^33.0.0",
+ "openapi-fetch": "^0.17.0",
+ "openapi-typescript": "^7.13.0",
"typescript": "^5.6.0"
}
}
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
new file mode 100644
index 00000000..e8a668e6
--- /dev/null
+++ b/frontend/src/api/client.ts
@@ -0,0 +1,11 @@
+import createClient from "openapi-fetch";
+import type { paths } from "./schema";
+
+// Typed client for the daemon's loopback /api/v1 surface. Request and response
+// types come from ./schema.d.ts, which is generated from the backend OpenAPI
+// document (`npm run gen:api`) — never hand-maintained.
+export const api = createClient({ baseUrl: "http://127.0.0.1:3001" });
+
+// Re-export the generated component schemas for convenient use in the renderer,
+// e.g. `Project`, `Summary`, `APIError`.
+export type { components, paths } from "./schema";
diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts
new file mode 100644
index 00000000..7e26458a
--- /dev/null
+++ b/frontend/src/api/schema.d.ts
@@ -0,0 +1,408 @@
+/**
+ * This file was auto-generated by openapi-typescript.
+ * Do not make direct changes to the file.
+ */
+
+export interface paths {
+ "/api/v1/projects": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** List all registered projects (active + degraded) */
+ get: operations["listProjects"];
+ put?: never;
+ /** Register a new project from a git repository path */
+ post: operations["addProject"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/projects/{id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Fetch one project; discriminates ok vs degraded */
+ get: operations["getProject"];
+ put?: never;
+ post?: never;
+ /** Remove a project; stops sessions, cleans workspaces, unregisters */
+ delete: operations["removeProject"];
+ options?: never;
+ head?: never;
+ /** Patch behaviour-only fields (identity is frozen) */
+ patch: operations["updateProjectConfig"];
+ trace?: never;
+ };
+}
+export type webhooks = Record;
+export interface components {
+ schemas: {
+ APIError: {
+ /** @description SCREAMING_SNAKE machine code */
+ code: string;
+ details?: {
+ [key: string]: unknown;
+ };
+ /** @description Short kind, e.g. not_found */
+ error: string;
+ /** @description Human-readable detail */
+ message: string;
+ requestId?: string;
+ };
+ AddInput: {
+ /** @description Optional display name; defaults to projectId. */
+ name?: null | string;
+ /** @description Repository path; supports ~ home-expansion. Must be a git repo. */
+ path: string;
+ /** @description Optional override; defaults to basename(path). */
+ projectId?: null | string;
+ };
+ Degraded: {
+ id: string;
+ name: string;
+ path: string;
+ resolveError: string;
+ };
+ ListProjectsResponse: {
+ projects: components["schemas"]["Summary"][] | null;
+ };
+ Project: {
+ /** @description Agent config blob (open object) */
+ agent?: {
+ [key: string]: unknown;
+ };
+ /** @default main */
+ defaultBranch: string;
+ id: string;
+ name: string;
+ path: string;
+ reactions?: {
+ [key: string]: components["schemas"]["ReactionConfig"];
+ };
+ /** @description "owner/name" or empty string when unset */
+ repo: string;
+ /** @description Runtime (terminal multiplexer) config blob (open object) */
+ runtime?: {
+ [key: string]: unknown;
+ };
+ scm?: components["schemas"]["SCMConfig"];
+ tracker?: components["schemas"]["TrackerConfig"];
+ };
+ ProjectGetResponse: {
+ project: components["schemas"]["ProjectOrDegraded"];
+ /** @enum {string} */
+ status: "ok" | "degraded";
+ };
+ ProjectOrDegraded: components["schemas"]["Project"] | components["schemas"]["Degraded"];
+ ProjectResponse: {
+ project: components["schemas"]["Project"];
+ };
+ ReactionConfig: {
+ /** @enum {string} */
+ action?: "send-to-agent" | "notify" | "auto-merge";
+ auto?: null | boolean;
+ /** @description Either ms (number) or a duration string ("30m") */
+ escalateAfter?: unknown;
+ includeSummary?: null | boolean;
+ message?: string;
+ /** @enum {string} */
+ priority?: "urgent" | "action" | "warning" | "info";
+ retries?: null | number;
+ threshold?: string;
+ };
+ RemoveResult: {
+ projectId: string;
+ removedStorageDir: boolean;
+ };
+ SCMConfig: {
+ package?: string;
+ path?: string;
+ plugin?: string;
+ webhook?: components["schemas"]["SCMWebhookConfig"];
+ };
+ SCMWebhookConfig: {
+ deliveryHeader?: string;
+ enabled?: null | boolean;
+ eventHeader?: string;
+ maxBodyBytes?: number;
+ path?: string;
+ secretEnvVar?: string;
+ signatureHeader?: string;
+ };
+ Summary: {
+ id: string;
+ name: string;
+ /** @description Present iff the project is degraded. */
+ resolveError?: string;
+ sessionPrefix: string;
+ };
+ TrackerConfig: {
+ package?: string;
+ path?: string;
+ plugin?: string;
+ };
+ UpdateConfigInput: {
+ agent?: {
+ [key: string]: unknown;
+ };
+ reactions?: null | {
+ [key: string]: components["schemas"]["ReactionConfig"];
+ };
+ runtime?: {
+ [key: string]: unknown;
+ };
+ scm?: components["schemas"]["SCMConfig"];
+ tracker?: components["schemas"]["TrackerConfig"];
+ };
+ };
+ responses: never;
+ parameters: never;
+ requestBodies: never;
+ headers: never;
+ pathItems: never;
+}
+export type $defs = Record;
+export interface operations {
+ listProjects: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ListProjectsResponse"];
+ };
+ };
+ /** @description Internal Server Error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["APIError"];
+ };
+ };
+ };
+ };
+ addProject: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: {
+ content: {
+ "application/json": components["schemas"]["AddInput"];
+ };
+ };
+ responses: {
+ /** @description Created */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ProjectResponse"];
+ };
+ };
+ /** @description Bad Request */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["APIError"];
+ };
+ };
+ /** @description Conflict */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["APIError"];
+ };
+ };
+ /** @description Internal Server Error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["APIError"];
+ };
+ };
+ };
+ };
+ getProject: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Project identifier (registry key). */
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ProjectGetResponse"];
+ };
+ };
+ /** @description Not Found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["APIError"];
+ };
+ };
+ /** @description Internal Server Error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["APIError"];
+ };
+ };
+ };
+ };
+ removeProject: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Project identifier (registry key). */
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["RemoveResult"];
+ };
+ };
+ /** @description Bad Request */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["APIError"];
+ };
+ };
+ /** @description Not Found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["APIError"];
+ };
+ };
+ /** @description Internal Server Error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["APIError"];
+ };
+ };
+ };
+ };
+ updateProjectConfig: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Project identifier (registry key). */
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: {
+ content: {
+ "application/json": components["schemas"]["UpdateConfigInput"];
+ };
+ };
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ProjectResponse"];
+ };
+ };
+ /** @description Bad Request */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["APIError"];
+ };
+ };
+ /** @description Not Found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["APIError"];
+ };
+ };
+ /** @description Conflict */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["APIError"];
+ };
+ };
+ /** @description Internal Server Error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["APIError"];
+ };
+ };
+ };
+ };
+}