Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
pull_request:
paths:
- "backend/**"
- "frontend/**"
- ".github/workflows/go.yml"

permissions:
Expand All @@ -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
Expand All @@ -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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

26 changes: 26 additions & 0 deletions backend/cmd/genspec/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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
Expand Down
22 changes: 22 additions & 0 deletions backend/go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand All @@ -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=
Expand All @@ -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=
Expand All @@ -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=
Expand Down
48 changes: 18 additions & 30 deletions backend/internal/httpd/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -62,34 +52,32 @@ 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.
})
}

// notFoundJSON returns the locked envelope for unmatched routes. Chi's default
// 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)
}

// methodNotAllowedJSON returns the locked envelope when a method probes a
// 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)
}
Loading