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"]; + }; + }; + }; + }; +}