From 465fec9edacb48554d72a56e83145f6434ab3725 Mon Sep 17 00:00:00 2001 From: itrytoohard Date: Mon, 1 Jun 2026 02:33:24 +0530 Subject: [PATCH 1/8] feat(api): code-first OpenAPI generation + typed frontend client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR was building, leaving #24 conflicting on nearly every file. The route shell itself is now redundant, but two pieces of #24 are genuinely net-new and absent from main — salvage them here, rebuilt on top of #47's merged code: - Code-first OpenAPI: apispec/specgen reflects the controllers' request/response types and project DTOs (the same types the handlers use at runtime) into openapi.yaml via swaggest. `cmd/genspec` + `go:generate` regenerate the committed, embedded spec; a drift test (TestBuild_MatchesEmbedded) and a route parity test (TestRouteSpecParity) fail CI if the spec and the code disagree. This replaces main's hand-maintained openapi.yaml so the "single source of truth" claim is actually enforced, not aspirational. - Typed frontend client: frontend/src/api/schema.d.ts is generated from that spec via openapi-typescript (`npm run gen:api`), consumed by a small openapi-fetch client. The frontend now gets its types from the daemon contract instead of hand-maintaining them. specgen lives outside apispec (which controllers import for the 501 stub) to avoid an import cycle. Handlers now encode named response DTOs (controllers/dto.go) instead of map[string]any so the generator reflects the real wire shapes. A gen-verify CI job regenerates both artifacts and fails on a stale commit. Tradeoff: the generated spec drops the hand-authored examples / x-rest-audit notes from #47's openapi.yaml; those can be re-added as operation metadata in specgen if wanted. Behaviour-only patch (no handler logic changes). Supersedes the codegen + frontend parts of #24. Refs #20, #47. --- .github/workflows/go.yml | 38 + backend/cmd/genspec/main.go | 26 + backend/go.mod | 4 + backend/go.sum | 22 + backend/internal/httpd/apispec/gen.go | 6 + backend/internal/httpd/apispec/openapi.yaml | 655 ++++++++++-------- backend/internal/httpd/apispec/parity_test.go | 66 ++ .../internal/httpd/apispec/specgen/build.go | 223 ++++++ .../httpd/apispec/specgen/build_test.go | 40 ++ backend/internal/httpd/controllers/dto.go | 71 ++ .../internal/httpd/controllers/projects.go | 14 +- frontend/package-lock.json | 348 ++++++++++ frontend/package.json | 5 + frontend/src/api/client.ts | 11 + frontend/src/api/schema.d.ts | 504 ++++++++++++++ 15 files changed, 1740 insertions(+), 293 deletions(-) create mode 100644 backend/cmd/genspec/main.go create mode 100644 backend/internal/httpd/apispec/gen.go create mode 100644 backend/internal/httpd/apispec/parity_test.go create mode 100644 backend/internal/httpd/apispec/specgen/build.go create mode 100644 backend/internal/httpd/apispec/specgen/build_test.go create mode 100644 backend/internal/httpd/controllers/dto.go create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/schema.d.ts diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 4df3f44c..779dbebf 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: @@ -68,3 +69,40 @@ jobs: working-directory: backend # Blocking on the full ruleset: the tree is clean at zero findings, so # any new issue fails CI rather than being grandfathered. + + # 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-file: backend/go.mod + 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/backend/cmd/genspec/main.go b/backend/cmd/genspec/main.go new file mode 100644 index 00000000..67f1eb22 --- /dev/null +++ b/backend/cmd/genspec/main.go @@ -0,0 +1,26 @@ +// Command genspec writes the code-first OpenAPI document produced by +// apispec.Build() to disk. It is invoked via `go generate` (see +// internal/httpd/apispec/gen.go); the output openapi.yaml is committed and +// embedded by the apispec package. +package main + +import ( + "flag" + "log" + "os" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec/specgen" +) + +func main() { + out := flag.String("out", "openapi.yaml", "output path for the generated OpenAPI document") + flag.Parse() + + doc, err := specgen.Build() + if err != nil { + log.Fatalf("genspec: build openapi: %v", err) + } + if err := os.WriteFile(*out, doc, 0o644); err != nil { + log.Fatalf("genspec: write %s: %v", *out, err) + } +} diff --git a/backend/go.mod b/backend/go.mod index a2de66a0..70689d85 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -8,6 +8,8 @@ require ( github.com/go-chi/chi/v5 v5.1.0 github.com/pressly/goose/v3 v3.27.1 github.com/spf13/cobra v1.10.1 + github.com/swaggest/jsonschema-go v0.3.79 + github.com/swaggest/openapi-go v0.2.61 golang.org/x/sys v0.43.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.51.0 @@ -23,8 +25,10 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/swaggest/refl v1.4.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/sync v0.20.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index cf3f0029..718a4a11 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,7 @@ +github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= +github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= +github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= +github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -15,6 +19,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= +github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= @@ -30,6 +36,8 @@ github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76cs github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= @@ -38,6 +46,18 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= +github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= +github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= +github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= +github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= +github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= +github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= +github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= +github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= +github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= @@ -50,6 +70,8 @@ golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= diff --git a/backend/internal/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 970ec7f4..43fc8628 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -1,398 +1,485 @@ openapi: 3.1.0 info: + description: Loopback-only HTTP surface served by the Go daemon. Generated from + Go (code-first) — do not edit by hand; run `go generate ./...`. title: Agent Orchestrator HTTP daemon - version: 0.1.0 - description: | - Loopback-only HTTP surface served by the Go daemon. This document describes - the registered /api/v1 project routes and the shared error envelope used by - OpenAPI-backed 501 responses. Daemon control endpoints such as /healthz, - /readyz, /shutdown, and /mux are intentionally outside this REST spec. - + version: 0.1.0-route-shell servers: - - url: http://127.0.0.1:3001 - description: Local daemon (loopback only) - -tags: - - name: projects - description: Project registry, configuration, and lifecycle administration - +- 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" } + 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" } + schema: + $ref: '#/components/schemas/UpdateConfigInput' responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectResponse' + description: OK "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" } + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found "409": - description: Project not in a patchable state content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - examples: - degraded: { value: { error: conflict, code: PROJECT_DEGRADED, message: "Project config is degraded; repair before patching" } } - missingPath: { value: { error: conflict, code: PROJECT_MISSING_PATH, message: "Project registry entry is missing a path" } } - "501": - description: Behaviour config persistence is not wired yet + schema: + $ref: '#/components/schemas/APIError' + description: Conflict + "500": content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: not_implemented, code: PROJECT_CONFIG_NOT_IMPLEMENTED, message: "Project config patching is not available until config persistence is wired" } - delete: - operationId: removeProject - tags: [projects] - summary: Archive a project; hides it from active lists while preserving id references + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Patch behaviour-only fields (identity is frozen) + tags: + - projects + /api/v1/projects/{id}/repair: + post: + operationId: repairProject + parameters: + - description: Project identifier (registry key). + in: path + name: id + required: true + schema: + description: Project identifier (registry key). + type: string responses: "200": - description: Project archived content: application/json: - schema: { $ref: "#/components/schemas/RemoveProjectResult" } - "400": - description: Invalid project id + schema: + $ref: '#/components/schemas/ProjectResponse' + description: OK + "404": content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: bad_request, code: INVALID_PROJECT_ID, message: "Project id failed storage-path validation" } - "404": { $ref: "#/components/responses/ProjectNotFound" } + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Conflict "500": - description: Removal failed content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: internal, code: PROJECT_REMOVE_FAILED, message: "Failed to remove project" } - "501": { $ref: "#/components/responses/NotImplemented" } - - /api/v1/projects/{id}/repair: - parameters: - - $ref: "#/components/parameters/ProjectIDPath" + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Recover a degraded project where automatic repair is available + tags: + - projects + /api/v1/projects/reload: post: - operationId: repairProject - tags: [projects] - summary: Repair a degraded project where automatic recovery is available - x-replaces: - - "POST /api/v1/projects/{id}" + operationId: reloadProjects responses: "200": - description: Project repaired content: application/json: schema: - type: object - required: [project] - properties: - project: { $ref: "#/components/schemas/Project" } - "400": - description: Bad request + $ref: '#/components/schemas/ReloadResult' + description: OK + "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: Invalidate cached config and re-scan the project registry + 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: + type: string details: + additionalProperties: {} type: object - additionalProperties: true - - NotImplementedResponse: - allOf: - - $ref: "#/components/schemas/APIError" - - type: object - required: [spec] - properties: - spec: - type: object - description: | - The OpenAPI Operation object for this method+path, served - inline so consumers discover the contract from the 501 - response without fetching the full spec. Mirrors the YAML - shape — see /api/v1/openapi.yaml for the full document. - - ProjectSummary: + error: + type: string + message: + type: string + requestId: + type: string + required: + - error + - code + - message + type: object + AddInput: + properties: + name: + type: + - "null" + - string + path: + type: string + projectId: + type: + - "null" + - string + required: + - path type: object - required: [id, name, sessionPrefix] + Degraded: properties: - id: { type: string } - name: { type: string } - sessionPrefix: { type: string } + 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 + ListProjectsResponse: + properties: + projects: + items: + $ref: '#/components/schemas/Summary' + type: + - array + - "null" + required: + - projects type: object - required: [id, name, path, repo, defaultBranch] + Project: properties: - id: { type: string } - name: { type: string } - path: { type: string } + agent: + type: string + defaultBranch: + type: string + id: + type: string + name: + type: string + path: + type: string + reactions: + additionalProperties: + $ref: '#/components/schemas/ReactionConfig' + type: object repo: type: string - description: "\"owner/name\" or empty string when unset" - defaultBranch: { type: string, default: main } - agent: { type: string } - tracker: { $ref: "#/components/schemas/TrackerConfig" } - scm: { $ref: "#/components/schemas/SCMConfig" } - - DegradedProject: + runtime: + type: string + 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: type: string - description: Repository path; supports ~ home-expansion. Must be a git repo. - projectId: + auto: + type: + - "null" + - boolean + escalateAfter: {} + includeSummary: + type: + - "null" + - boolean + message: type: string - description: Optional override; defaults to basename(path). - name: + priority: + 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. + ReloadResult: properties: - agent: { type: string } - tracker: { $ref: "#/components/schemas/TrackerConfig" } - scm: { $ref: "#/components/schemas/SCMConfig" } - - RemoveProjectResult: + degradedCount: + type: integer + projectCount: + type: integer + reloaded: + type: boolean + required: + - reloaded + - projectCount + - degradedCount 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 ---- - - 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 } + id: + type: string + name: + type: string + resolveError: + type: string + sessionPrefix: + type: string + required: + - id + - name + - sessionPrefix + type: object + TrackerConfig: + properties: + package: + type: string + path: + type: string + plugin: + type: string + type: object + UpdateConfigInput: + properties: + agent: + type: + - "null" + - string + reactions: + additionalProperties: + $ref: '#/components/schemas/ReactionConfig' + type: + - "null" + - object + runtime: + type: + - "null" + - string + 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..5bd29410 --- /dev/null +++ b/backend/internal/httpd/apispec/parity_test.go @@ -0,0 +1,66 @@ +package apispec_test + +import ( + "io" + "log/slog" + "net/http" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + yaml "gopkg.in/yaml.v3" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" +) + +// TestRouteSpecParity asserts the mounted /api/v1 routes and the OpenAPI +// operations are in 1:1 correspondence — so a route can't be added without +// spec coverage, and the spec can't describe a route that isn't served. +func TestRouteSpecParity(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + router := httpd.NewRouter(config.Config{}, log, nil) + + mounted := map[string]bool{} + err := chi.Walk(router, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { + if strings.HasPrefix(route, "/api/v1/") && route != "/api/v1/openapi.yaml" { + mounted[strings.ToUpper(method)+" "+route] = true + } + return nil + }) + if err != nil { + t.Fatalf("walk routes: %v", err) + } + if len(mounted) == 0 { + t.Fatal("no /api/v1 routes mounted — router wiring changed?") + } + + // Forward: every mounted route resolves to an operation slice. + for r := range mounted { + mp := strings.SplitN(r, " ", 2) + if apispec.Default().Operation(mp[0], mp[1]) == nil { + t.Errorf("mounted route %s has no OpenAPI operation", r) + } + } + + // Reverse: every spec operation is a mounted route. + var doc struct { + Paths map[string]map[string]yaml.Node `yaml:"paths"` + } + if err := yaml.Unmarshal(apispec.Default().YAML(), &doc); err != nil { + t.Fatalf("parse spec: %v", err) + } + httpMethods := map[string]bool{"get": true, "post": true, "put": true, "patch": true, "delete": true} + for path, item := range doc.Paths { + for method := range item { + if !httpMethods[method] { + continue // skip parameters, summary, etc. + } + key := strings.ToUpper(method) + " " + path + if !mounted[key] { + t.Errorf("spec operation %s has no mounted route", key) + } + } + } +} diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go new file mode 100644 index 00000000..190af2d5 --- /dev/null +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -0,0 +1,223 @@ +// Package specgen builds the code-first OpenAPI document from the Go contract +// types. It lives outside apispec because it imports the controllers (to +// reflect their request/response shapes), and controllers import apispec (for +// the 501 stub) — keeping Build here breaks that cycle. apispec only embeds and +// serves the committed openapi.yaml; specgen produces it. +package specgen + +import ( + "fmt" + "net/http" + "reflect" + "strings" + + "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/envelope" + "github.com/aoagents/agent-orchestrator/backend/internal/project" +) + +// Build reflects the Go contract types and the operation registry below into +// the OpenAPI document. It is the single source of truth for the /api/v1 +// contract: `cmd/genspec` writes its output to apispec/openapi.yaml (the +// committed, embedded artifact) and TestBuild_MatchesEmbedded asserts the embed +// equals fresh Build() output so the two can never drift. Schema facets live as +// struct tags on the project.*/controllers.* types; operation metadata (path, +// status codes, summaries) lives here. +// +// Every wire shape is reflected straight from where it is used at runtime — the +// request bodies, path params, and response envelopes from controllers, the +// error envelope from httpd/envelope — so the served responses and the +// generated schema share one definition each. +func Build() ([]byte, error) { + r := openapi31.NewReflector() + // Derive `required` from the idiomatic Go convention: a JSON field without + // `omitempty` is required. swaggest does not infer this on its own, so the + // structs stay clean (only description/enum tags) and this hook adds the + // required array. + 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", "EnvelopeAPIError". + 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 "EnvelopeAPIError": + return "APIError" + case "DomainProjectID": + return "ProjectID" + case "ControllersListProjectsResponse": + return "ListProjectsResponse" + case "ControllersProjectResponse": + return "ProjectResponse" + case "ControllersGetProjectResponse": + return "ProjectGetResponse" + case "ControllersProjectOrDegraded": + return "ProjectOrDegraded" + } + // project.* types: "ProjectProject" -> "Project", "ProjectSummary" -> "Summary", + // "ProjectAddInput" -> "AddInput", "ProjectTrackerConfig" -> "TrackerConfig", 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 +} + +// projectOperations declares the 7 canonical /projects operations. The set must +// stay 1:1 with the routes ProjectsController.Register mounts — +// TestRouteSpecParity fails the build otherwise. +func projectOperations() []operation { + return []operation{ + { + method: http.MethodGet, path: "/api/v1/projects", id: "listProjects", + summary: "List all registered projects (active + degraded)", + resps: []respUnit{ + {http.StatusOK, controllers.ListProjectsResponse{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/projects", id: "addProject", + summary: "Register a new project from a git repository path", + reqs: []any{project.AddInput{}}, + resps: []respUnit{ + {http.StatusCreated, controllers.ProjectResponse{}}, + {http.StatusBadRequest, envelope.APIError{}}, + {http.StatusConflict, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/projects/reload", id: "reloadProjects", + summary: "Invalidate cached config and re-scan the project registry", + resps: []respUnit{ + {http.StatusOK, project.ReloadResult{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + { + 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, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + { + 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, envelope.APIError{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusConflict, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + { + 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, envelope.APIError{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/projects/{id}/repair", id: "repairProject", + summary: "Recover a degraded project where automatic repair is available", + reqs: []any{controllers.ProjectIDParam{}}, + resps: []respUnit{ + {http.StatusOK, controllers.ProjectResponse{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusConflict, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + } +} diff --git a/backend/internal/httpd/apispec/specgen/build_test.go b/backend/internal/httpd/apispec/specgen/build_test.go new file mode 100644 index 00000000..9951456b --- /dev/null +++ b/backend/internal/httpd/apispec/specgen/build_test.go @@ -0,0 +1,40 @@ +package specgen_test + +import ( + "bytes" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec/specgen" +) + +// TestBuild_MatchesEmbedded is the drift guard: the committed (embedded) +// openapi.yaml must equal fresh Build() output. If this fails, run +// `go generate ./...` and commit the result. +func TestBuild_MatchesEmbedded(t *testing.T) { + got, err := specgen.Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + embedded := apispec.Default().YAML() + if !bytes.Equal(got, embedded) { + t.Fatalf("embedded openapi.yaml is stale — run `go generate ./...` and commit.\n"+ + "len(fresh)=%d len(embedded)=%d", len(got), len(embedded)) + } +} + +// TestBuild_Deterministic guards against nondeterministic output (which would +// make the drift check flaky in CI). +func TestBuild_Deterministic(t *testing.T) { + a, err := specgen.Build() + if err != nil { + t.Fatalf("Build #1: %v", err) + } + b, err := specgen.Build() + if err != nil { + t.Fatalf("Build #2: %v", err) + } + if !bytes.Equal(a, b) { + t.Fatal("Build() is not deterministic across calls") + } +} diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go new file mode 100644 index 00000000..19dcf8d8 --- /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 (envelope.WriteJSON), and +// apispec.Build reflects these same types into openapi.yaml, so the served +// contract and the generated spec can't disagree. The request side needs no +// wrappers: handlers decode the body straight into the project commands +// (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), +// PATCH /projects/{id} (200), and POST /projects/{id}/repair (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 91a1e47d..25f4cd42 100644 --- a/backend/internal/httpd/controllers/projects.go +++ b/backend/internal/httpd/controllers/projects.go @@ -48,7 +48,7 @@ func (c *ProjectsController) list(w http.ResponseWriter, r *http.Request) { writeProjectError(w, r, err) return } - envelope.WriteJSON(w, http.StatusOK, map[string]any{"projects": projects}) + envelope.WriteJSON(w, http.StatusOK, ListProjectsResponse{Projects: projects}) } func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) { @@ -66,7 +66,7 @@ func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) { writeProjectError(w, r, err) return } - envelope.WriteJSON(w, http.StatusCreated, map[string]any{"project": p}) + envelope.WriteJSON(w, http.StatusCreated, ProjectResponse{Project: p}) } func (c *ProjectsController) get(w http.ResponseWriter, r *http.Request) { @@ -79,11 +79,7 @@ func (c *ProjectsController) get(w http.ResponseWriter, r *http.Request) { writeProjectError(w, r, err) return } - if got.Status == "degraded" { - envelope.WriteJSON(w, http.StatusOK, map[string]any{"status": got.Status, "project": got.Degraded}) - return - } - envelope.WriteJSON(w, http.StatusOK, map[string]any{"status": got.Status, "project": got.Project}) + envelope.WriteJSON(w, http.StatusOK, newGetProjectResponse(got)) } func (c *ProjectsController) updateConfig(w http.ResponseWriter, r *http.Request) { @@ -109,7 +105,7 @@ func (c *ProjectsController) updateConfig(w http.ResponseWriter, r *http.Request writeProjectError(w, r, err) return } - envelope.WriteJSON(w, http.StatusOK, map[string]any{"project": p}) + envelope.WriteJSON(w, http.StatusOK, ProjectResponse{Project: p}) } func (c *ProjectsController) remove(w http.ResponseWriter, r *http.Request) { @@ -135,7 +131,7 @@ func (c *ProjectsController) repair(w http.ResponseWriter, r *http.Request) { writeProjectError(w, r, err) return } - envelope.WriteJSON(w, http.StatusOK, map[string]any{"project": p}) + envelope.WriteJSON(w, http.StatusOK, ProjectResponse{Project: p}) } func (c *ProjectsController) reload(w http.ResponseWriter, r *http.Request) { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7eeda0af..7547daa5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,11 +7,40 @@ "": { "name": "agent-orchestrator-frontend", "version": "0.0.0", + "dependencies": { + "openapi-fetch": "^0.17.0" + }, "devDependencies": { "electron": "^33.0.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 +63,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 +196,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 +239,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 +288,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 +308,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 +522,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 +703,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 +767,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 +836,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 +890,42 @@ "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==", + "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==", + "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", @@ -864,6 +1188,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 +1202,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..1c744fb7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,10 +7,15 @@ "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 ." }, + "dependencies": { + "openapi-fetch": "^0.17.0" + }, "devDependencies": { "electron": "^33.0.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..36290edd --- /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. `components["schemas"]["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..0d09700f --- /dev/null +++ b/frontend/src/api/schema.d.ts @@ -0,0 +1,504 @@ +/** + * 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; + }; + "/api/v1/projects/{id}/repair": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Recover a degraded project where automatic repair is available */ + post: operations["repairProject"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/projects/reload": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Invalidate cached config and re-scan the project registry */ + post: operations["reloadProjects"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + APIError: { + code: string; + details?: { + [key: string]: unknown; + }; + error: string; + message: string; + requestId?: string; + }; + AddInput: { + name?: null | string; + path: string; + projectId?: null | string; + }; + Degraded: { + id: string; + name: string; + path: string; + resolveError: string; + }; + ListProjectsResponse: { + projects: components["schemas"]["Summary"][] | null; + }; + Project: { + agent?: string; + defaultBranch: string; + id: string; + name: string; + path: string; + reactions?: { + [key: string]: components["schemas"]["ReactionConfig"]; + }; + repo: string; + runtime?: string; + 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: { + action?: string; + auto?: null | boolean; + escalateAfter?: unknown; + includeSummary?: null | boolean; + message?: string; + priority?: string; + retries?: null | number; + threshold?: string; + }; + ReloadResult: { + degradedCount: number; + projectCount: number; + reloaded: boolean; + }; + 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; + resolveError?: string; + sessionPrefix: string; + }; + TrackerConfig: { + package?: string; + path?: string; + plugin?: string; + }; + UpdateConfigInput: { + agent?: null | string; + reactions?: null | { + [key: string]: components["schemas"]["ReactionConfig"]; + }; + runtime?: null | string; + 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"]; + }; + }; + }; + }; + repairProject: { + 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"]["ProjectResponse"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + reloadProjects: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReloadResult"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; +} From 7d2e35e4816c5b991102fad8f9703912fc2b4636 Mon Sep 17 00:00:00 2001 From: itrytoohard Date: Mon, 1 Jun 2026 03:41:31 +0530 Subject: [PATCH 2/8] =?UTF-8?q?fix(api):=20address=20review=20=E2=80=94=20?= =?UTF-8?q?required=20bodies,=20non-null=20arrays,=20strict=20schema=20nam?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the four review comments on #59: - ProjectOrDegraded.MarshalJSON now errors when neither Project nor Degraded is set instead of silently emitting `{"project": null}`, which would breach the required oneOf[Project, Degraded] contract. - requestBody.required: true is now set for addProject and updateProjectConfig via WithCustomize — swaggest left it absent (== optional) before. - schemaName replaces the TrimPrefix catch-all with an exhaustive default→clean map; an unrecognised type is returned verbatim so it surfaces in the diff rather than silently colliding with an existing schema. - nonNullableSlices strips the spurious "null" swaggest unions into every Go slice, so `projects` is `Summary[]` not `Summary[] | null`; the list handler normalises a nil slice to [] so the wire matches the non-nullable schema. Regenerated openapi.yaml + frontend schema.d.ts. Refs #59. --- backend/internal/httpd/apispec/openapi.yaml | 6 +- .../internal/httpd/apispec/specgen/build.go | 122 +++++++++++++----- backend/internal/httpd/controllers/dto.go | 14 +- .../internal/httpd/controllers/projects.go | 5 + frontend/src/api/schema.d.ts | 6 +- 5 files changed, 111 insertions(+), 42 deletions(-) diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 43fc8628..eaacb7f4 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -34,6 +34,7 @@ paths: application/json: schema: $ref: '#/components/schemas/AddInput' + required: true responses: "201": content: @@ -148,6 +149,7 @@ paths: application/json: schema: $ref: '#/components/schemas/UpdateConfigInput' + required: true responses: "200": content: @@ -296,9 +298,7 @@ components: projects: items: $ref: '#/components/schemas/Summary' - type: - - array - - "null" + type: array required: - projects type: object diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 190af2d5..6dd6a9d3 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -11,8 +11,8 @@ import ( "reflect" "strings" - "github.com/swaggest/jsonschema-go" - "github.com/swaggest/openapi-go" + jsonschema "github.com/swaggest/jsonschema-go" + openapi "github.com/swaggest/openapi-go" "github.com/swaggest/openapi-go/openapi31" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers" @@ -37,8 +37,12 @@ func Build() ([]byte, error) { // Derive `required` from the idiomatic Go convention: a JSON field without // `omitempty` is required. swaggest does not infer this on its own, so the // structs stay clean (only description/enum tags) and this hook adds the - // required array. - r.DefaultOptions = append(r.DefaultOptions, jsonschema.InterceptProp(requiredFromJSONTag)) + // required array. nonNullableSlices drops the spurious "null" type swaggest + // stamps on every Go slice. + r.DefaultOptions = append(r.DefaultOptions, + jsonschema.InterceptProp(requiredFromJSONTag), + jsonschema.InterceptNullability(nonNullableSlices), + ) // Clean component schema names (which become the generated TS type names): // swaggest defaults to PackageType, e.g. "ProjectProject", "EnvelopeAPIError". r.InterceptDefName(schemaName) @@ -63,8 +67,14 @@ func Build() ([]byte, error) { oc.SetID(op.id) oc.SetSummary(op.summary) oc.SetTags("projects") - for _, req := range op.reqs { - oc.AddReqStructure(req) + for _, param := range op.pathParams { + oc.AddReqStructure(param) + } + if op.reqBody != nil { + // AddReqStructure leaves requestBody.required absent, which + // OpenAPI reads as optional. These bodies are mandatory, so force + // it — otherwise validators/generators treat the body as skippable. + oc.AddReqStructure(op.reqBody, openapi.WithCustomize(markRequestBodyRequired)) } for _, resp := range op.resps { oc.AddRespStructure(resp.body, openapi.WithHTTPStatus(resp.status)) @@ -77,26 +87,68 @@ func Build() ([]byte, error) { return r.Spec.MarshalYAML() } -// schemaName maps swaggest's default PackageType component names to clean, -// stable schema names (these become the generated TypeScript type names). +// schemaName maps swaggest's default PackageType component names (e.g. +// "ProjectProject", "EnvelopeAPIError") to the clean, stable schema names that +// become the generated TypeScript type names. Every reflected type is listed +// explicitly: an unrecognised default name is returned verbatim, so a new type +// surfaces as a visibly-wrong "PackageType" name in the diff (and the drift +// test) rather than silently colliding with an existing schema via a +// TrimPrefix catch-all. func schemaName(_ reflect.Type, defaultName string) string { - switch defaultName { - case "EnvelopeAPIError": - return "APIError" - case "DomainProjectID": - return "ProjectID" - case "ControllersListProjectsResponse": - return "ListProjectsResponse" - case "ControllersProjectResponse": - return "ProjectResponse" - case "ControllersGetProjectResponse": - return "ProjectGetResponse" - case "ControllersProjectOrDegraded": - return "ProjectOrDegraded" + if clean, ok := schemaNames[defaultName]; ok { + return clean + } + return defaultName +} + +// schemaNames is the exhaustive default→clean mapping for every type reflected +// by projectOperations(). Add an entry when a new contract type is introduced; +// the drift test fails until the spec is regenerated, which flags the gap. +var schemaNames = map[string]string{ + // httpd/envelope + "EnvelopeAPIError": "APIError", + // domain + "DomainProjectID": "ProjectID", + // httpd/controllers (wire envelopes) + "ControllersListProjectsResponse": "ListProjectsResponse", + "ControllersProjectResponse": "ProjectResponse", + "ControllersGetProjectResponse": "ProjectGetResponse", + "ControllersProjectOrDegraded": "ProjectOrDegraded", + // project (entities + DTOs) + "ProjectProject": "Project", + "ProjectSummary": "Summary", + "ProjectDegraded": "Degraded", + "ProjectAddInput": "AddInput", + "ProjectUpdateConfigInput": "UpdateConfigInput", + "ProjectRemoveResult": "RemoveResult", + "ProjectReloadResult": "ReloadResult", + "ProjectTrackerConfig": "TrackerConfig", + "ProjectSCMConfig": "SCMConfig", + "ProjectSCMWebhookConfig": "SCMWebhookConfig", + "ProjectReactionConfig": "ReactionConfig", +} + +// markRequestBodyRequired sets requestBody.required: true on the operation's +// JSON body. swaggest leaves it absent (== optional) for AddReqStructure bodies. +func markRequestBodyRequired(cor openapi.ContentOrReference) { + if rb, ok := cor.(*openapi31.RequestBodyOrReference); ok && rb.RequestBody != nil { + rb.RequestBody.WithRequired(true) + } +} + +// nonNullableSlices drops the "null" that swaggest unions into every Go slice +// type (a nil slice marshals as JSON null). A required array field should be +// `T[]`, not `T[] | null`; the handlers normalise nil to an empty slice, so +// null never reaches the wire. Byte slices (base64 strings) are left alone. +func nonNullableSlices(p jsonschema.InterceptNullabilityParams) { + if !p.NullAdded || p.Type == nil || p.Type.Kind() != reflect.Slice { + return + } + if p.Type.Elem().Kind() == reflect.Uint8 { + return } - // project.* types: "ProjectProject" -> "Project", "ProjectSummary" -> "Summary", - // "ProjectAddInput" -> "AddInput", "ProjectTrackerConfig" -> "TrackerConfig", etc. - return strings.TrimPrefix(defaultName, "Project") + p.Schema.TypeEns().WithSimpleTypes(jsonschema.Array) + p.Schema.Type.SliceOfSimpleTypeValues = nil } // requiredFromJSONTag marks a property required when its json tag lacks @@ -139,7 +191,8 @@ type respUnit struct { type operation struct { method, path, id, summary string - reqs []any + pathParams []any // path/query param containers (e.g. ProjectIDParam) + reqBody any // JSON request body struct, nil when the op takes none resps []respUnit } @@ -159,7 +212,7 @@ func projectOperations() []operation { { method: http.MethodPost, path: "/api/v1/projects", id: "addProject", summary: "Register a new project from a git repository path", - reqs: []any{project.AddInput{}}, + reqBody: project.AddInput{}, resps: []respUnit{ {http.StatusCreated, controllers.ProjectResponse{}}, {http.StatusBadRequest, envelope.APIError{}}, @@ -177,8 +230,8 @@ func projectOperations() []operation { }, { method: http.MethodGet, path: "/api/v1/projects/{id}", id: "getProject", - summary: "Fetch one project; discriminates ok vs degraded", - reqs: []any{controllers.ProjectIDParam{}}, + summary: "Fetch one project; discriminates ok vs degraded", + pathParams: []any{controllers.ProjectIDParam{}}, resps: []respUnit{ {http.StatusOK, controllers.GetProjectResponse{}}, {http.StatusNotFound, envelope.APIError{}}, @@ -187,8 +240,9 @@ func projectOperations() []operation { }, { method: http.MethodPatch, path: "/api/v1/projects/{id}", id: "updateProjectConfig", - summary: "Patch behaviour-only fields (identity is frozen)", - reqs: []any{controllers.ProjectIDParam{}, project.UpdateConfigInput{}}, + summary: "Patch behaviour-only fields (identity is frozen)", + pathParams: []any{controllers.ProjectIDParam{}}, + reqBody: project.UpdateConfigInput{}, resps: []respUnit{ {http.StatusOK, controllers.ProjectResponse{}}, {http.StatusBadRequest, envelope.APIError{}}, @@ -199,8 +253,8 @@ func projectOperations() []operation { }, { method: http.MethodDelete, path: "/api/v1/projects/{id}", id: "removeProject", - summary: "Remove a project; stops sessions, cleans workspaces, unregisters", - reqs: []any{controllers.ProjectIDParam{}}, + summary: "Remove a project; stops sessions, cleans workspaces, unregisters", + pathParams: []any{controllers.ProjectIDParam{}}, resps: []respUnit{ {http.StatusOK, project.RemoveResult{}}, {http.StatusBadRequest, envelope.APIError{}}, @@ -210,8 +264,8 @@ func projectOperations() []operation { }, { method: http.MethodPost, path: "/api/v1/projects/{id}/repair", id: "repairProject", - summary: "Recover a degraded project where automatic repair is available", - reqs: []any{controllers.ProjectIDParam{}}, + summary: "Recover a degraded project where automatic repair is available", + pathParams: []any{controllers.ProjectIDParam{}}, resps: []respUnit{ {http.StatusOK, controllers.ProjectResponse{}}, {http.StatusNotFound, envelope.APIError{}}, diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index 19dcf8d8..1b86db02 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -2,6 +2,7 @@ package controllers import ( "encoding/json" + "errors" "github.com/aoagents/agent-orchestrator/backend/internal/project" ) @@ -49,10 +50,19 @@ type ProjectOrDegraded struct { } func (p ProjectOrDegraded) MarshalJSON() ([]byte, error) { - if p.Degraded != nil { + switch { + case p.Degraded != nil: return json.Marshal(p.Degraded) + case p.Project != nil: + return json.Marshal(p.Project) + default: + // Neither variant set — the spec declares `project` as a required + // oneOf[Project, Degraded], so emitting `null` would silently breach + // the contract. Fail loudly instead: a GetResult with both pointers + // nil is a Manager bug, and the encode error surfaces it rather than + // shipping an invalid body. + return nil, errors.New("controllers: ProjectOrDegraded has neither Project nor Degraded set") } - return json.Marshal(p.Project) } // JSONSchemaOneOf is read by swaggest's reflector (apispec.Build) to emit the diff --git a/backend/internal/httpd/controllers/projects.go b/backend/internal/httpd/controllers/projects.go index 25f4cd42..929292d2 100644 --- a/backend/internal/httpd/controllers/projects.go +++ b/backend/internal/httpd/controllers/projects.go @@ -48,6 +48,11 @@ func (c *ProjectsController) list(w http.ResponseWriter, r *http.Request) { writeProjectError(w, r, err) return } + // The spec types `projects` as a non-nullable array; a nil slice would + // marshal as JSON null and breach that. Normalise so the wire is always []. + if projects == nil { + projects = []project.Summary{} + } envelope.WriteJSON(w, http.StatusOK, ListProjectsResponse{Projects: projects}) } diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 0d09700f..57a785f5 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -100,7 +100,7 @@ export interface components { resolveError: string; }; ListProjectsResponse: { - projects: components["schemas"]["Summary"][] | null; + projects: components["schemas"]["Summary"][]; }; Project: { agent?: string; @@ -224,7 +224,7 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: { + requestBody: { content: { "application/json": components["schemas"]["AddInput"]; }; @@ -369,7 +369,7 @@ export interface operations { }; cookie?: never; }; - requestBody?: { + requestBody: { content: { "application/json": components["schemas"]["UpdateConfigInput"]; }; From 61e69fbf838daea79c59b097427d1cf634facf66 Mon Sep 17 00:00:00 2001 From: itrytoohard Date: Mon, 1 Jun 2026 03:53:38 +0530 Subject: [PATCH 3/8] fix(api): map degenerate GetResult to a clean 500 before writing status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the P1 follow-up on #59: returning an error from ProjectOrDegraded.MarshalJSON does not "surface" the contract violation — envelope.WriteJSON discards the encode error after the 200 status and a partial JSON frame have already been flushed, leaving the client with a truncated, unparseable 200 (worse than the previous null). Validate the invariant upstream instead: newGetProjectResponse now returns an error when the GetResult sets neither Project nor Degraded, and the get handler maps that to a 500 envelope before any status/body is written. MarshalJSON keeps the error branch only as an unreachable last-resort backstop, with the comment corrected to say so. Adds TestProjectsAPI_GetEmptyResultIs500 to lock the clean-500 behavior. Refs #59. --- backend/internal/httpd/controllers/dto.go | 29 +++++++++++++------ .../internal/httpd/controllers/projects.go | 9 +++++- .../httpd/controllers/projects_test.go | 28 ++++++++++++++++++ 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index 1b86db02..2f30674a 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -56,15 +56,21 @@ func (p ProjectOrDegraded) MarshalJSON() ([]byte, error) { case p.Project != nil: return json.Marshal(p.Project) default: - // Neither variant set — the spec declares `project` as a required - // oneOf[Project, Degraded], so emitting `null` would silently breach - // the contract. Fail loudly instead: a GetResult with both pointers - // nil is a Manager bug, and the encode error surfaces it rather than - // shipping an invalid body. - return nil, errors.New("controllers: ProjectOrDegraded has neither Project nor Degraded set") + // Unreachable in practice: the handler validates the GetResult via + // newGetProjectResponse and writes a 500 before committing the 200 + // status, so this never encodes. Kept as a last-resort backstop — + // erroring is still better than emitting a contract-breaking `null`, + // though by here the status is already sent, so the real guard is + // upstream. + return nil, errEmptyProjectOrDegraded } } +// errEmptyProjectOrDegraded marks a GetResult that set neither variant — a +// Manager-contract violation. newGetProjectResponse returns it so the handler +// can map it to a 500 before any response bytes are written. +var errEmptyProjectOrDegraded = errors.New("controllers: GetResult has neither Project nor Degraded set") + // JSONSchemaOneOf is read by swaggest's reflector (apispec.Build) to emit the // oneOf for this field; it is not used at runtime. func (ProjectOrDegraded) JSONSchemaOneOf() []interface{} { @@ -72,10 +78,15 @@ func (ProjectOrDegraded) JSONSchemaOneOf() []interface{} { } // 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 { +// the explicit project→httpd boundary the result type exists for. It errors +// when the result sets neither variant, so the handler can return a clean 500 +// BEFORE writing the 200 status rather than flushing a truncated body. +func newGetProjectResponse(res project.GetResult) (GetProjectResponse, error) { + if res.Project == nil && res.Degraded == nil { + return GetProjectResponse{}, errEmptyProjectOrDegraded + } return GetProjectResponse{ Status: res.Status, Project: ProjectOrDegraded{Project: res.Project, Degraded: res.Degraded}, - } + }, nil } diff --git a/backend/internal/httpd/controllers/projects.go b/backend/internal/httpd/controllers/projects.go index 929292d2..a5d8bff6 100644 --- a/backend/internal/httpd/controllers/projects.go +++ b/backend/internal/httpd/controllers/projects.go @@ -84,7 +84,14 @@ func (c *ProjectsController) get(w http.ResponseWriter, r *http.Request) { writeProjectError(w, r, err) return } - envelope.WriteJSON(w, http.StatusOK, newGetProjectResponse(got)) + resp, err := newGetProjectResponse(got) + if err != nil { + // GetResult set neither variant — a Manager-contract violation. Map it + // to a 500 here, before any status/body is written. + writeProjectError(w, r, err, http.StatusInternalServerError) + return + } + envelope.WriteJSON(w, http.StatusOK, resp) } func (c *ProjectsController) updateConfig(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/httpd/controllers/projects_test.go b/backend/internal/httpd/controllers/projects_test.go index 8d303da5..b059eee1 100644 --- a/backend/internal/httpd/controllers/projects_test.go +++ b/backend/internal/httpd/controllers/projects_test.go @@ -1,6 +1,7 @@ package controllers_test import ( + "context" "encoding/json" "io" "log/slog" @@ -13,10 +14,37 @@ import ( "testing" "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/httpd" "github.com/aoagents/agent-orchestrator/backend/internal/project" ) +// emptyGetManager returns a GetResult that sets neither Project nor Degraded — +// a Manager-contract violation — so the test can prove the handler answers a +// clean 500 before writing the 200 status, rather than flushing a truncated +// body when the discriminated union fails to marshal. Other methods are +// unused; the embedded nil interface would panic if one were called. +type emptyGetManager struct{ project.Manager } + +func (emptyGetManager) Get(context.Context, domain.ProjectID) (project.GetResult, error) { + return project.GetResult{}, nil +} + +// TestProjectsAPI_GetEmptyResultIs500 locks the fix for the discriminated-union +// invariant: a degenerate GetResult must surface as a parseable 500 envelope, +// not a 200 with truncated JSON. +func TestProjectsAPI_GetEmptyResultIs500(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{ + Projects: emptyGetManager{}, + })) + t.Cleanup(srv.Close) + + body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects/whatever", "") + assertJSON(t, headers) + assertErrorCode(t, body, status, http.StatusInternalServerError, "INTERNAL_ERROR") +} + func newTestServer(t *testing.T) *httptest.Server { t.Helper() log := slog.New(slog.NewTextHandler(io.Discard, nil)) From 4645ab95622e99a7f33df00de6f739bf67bff2d2 Mon Sep 17 00:00:00 2001 From: itrytoohard Date: Mon, 1 Jun 2026 21:57:21 +0530 Subject: [PATCH 4/8] chore(api): adapt to main after rebase (writeProjectError arity, apispec API, regen) Rebased onto main, which landed several changes that intersect this branch: - writeProjectError is now 3-arg (status derived from project.Error.Kind); the get handler's degenerate-GetResult guard now calls the 3-arg form. - apispec.Spec dropped its YAML() method; add apispec.Embedded() returning the raw embedded bytes, used by the generator's drift + route-parity tests. - #62 removed Runtime/Reactions/ReactionConfig from the project types, so the regenerated openapi.yaml (and frontend schema.d.ts) drop those, and the dead ReactionConfig entry is removed from the schema-name map. - CI go.yml: keep main's new golangci-lint `lint` job alongside the gen-verify job, both reading the Go version from go.mod. go build/vet/test all pass; spec + TS regenerate with no drift; gofmt clean. --- backend/internal/httpd/apispec/apispec.go | 5 +++ backend/internal/httpd/apispec/openapi.yaml | 40 ------------------- backend/internal/httpd/apispec/parity_test.go | 2 +- .../internal/httpd/apispec/specgen/build.go | 1 - .../httpd/apispec/specgen/build_test.go | 2 +- .../internal/httpd/controllers/projects.go | 5 ++- frontend/src/api/schema.d.ts | 18 --------- 7 files changed, 10 insertions(+), 63 deletions(-) diff --git a/backend/internal/httpd/apispec/apispec.go b/backend/internal/httpd/apispec/apispec.go index 2603820f..3b60b14b 100644 --- a/backend/internal/httpd/apispec/apispec.go +++ b/backend/internal/httpd/apispec/apispec.go @@ -64,6 +64,11 @@ func New(yamlBytes []byte) (*Spec, error) { return &Spec{doc: doc}, nil } +// Embedded returns the raw bytes of the committed, embedded openapi.yaml. The +// code-first generator's drift and route-parity tests compare against it +// (specgen.Build() must equal these bytes); ServeYAML writes the same bytes. +func Embedded() []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 diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index eaacb7f4..b9773d33 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -314,14 +314,8 @@ components: type: string path: type: string - reactions: - additionalProperties: - $ref: '#/components/schemas/ReactionConfig' - type: object repo: type: string - runtime: - type: string scm: $ref: '#/components/schemas/SCMConfig' tracker: @@ -358,30 +352,6 @@ components: required: - project type: object - ReactionConfig: - properties: - action: - type: string - auto: - type: - - "null" - - boolean - escalateAfter: {} - includeSummary: - type: - - "null" - - boolean - message: - type: string - priority: - type: string - retries: - type: - - "null" - - integer - threshold: - type: string - type: object ReloadResult: properties: degradedCount: @@ -465,16 +435,6 @@ components: type: - "null" - string - reactions: - additionalProperties: - $ref: '#/components/schemas/ReactionConfig' - type: - - "null" - - object - runtime: - type: - - "null" - - string scm: $ref: '#/components/schemas/SCMConfig' tracker: diff --git a/backend/internal/httpd/apispec/parity_test.go b/backend/internal/httpd/apispec/parity_test.go index 5bd29410..733353ad 100644 --- a/backend/internal/httpd/apispec/parity_test.go +++ b/backend/internal/httpd/apispec/parity_test.go @@ -48,7 +48,7 @@ func TestRouteSpecParity(t *testing.T) { var doc struct { Paths map[string]map[string]yaml.Node `yaml:"paths"` } - if err := yaml.Unmarshal(apispec.Default().YAML(), &doc); err != nil { + if err := yaml.Unmarshal(apispec.Embedded(), &doc); err != nil { t.Fatalf("parse spec: %v", err) } httpMethods := map[string]bool{"get": true, "post": true, "put": true, "patch": true, "delete": true} diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 6dd6a9d3..726f7c9b 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -125,7 +125,6 @@ var schemaNames = map[string]string{ "ProjectTrackerConfig": "TrackerConfig", "ProjectSCMConfig": "SCMConfig", "ProjectSCMWebhookConfig": "SCMWebhookConfig", - "ProjectReactionConfig": "ReactionConfig", } // markRequestBodyRequired sets requestBody.required: true on the operation's diff --git a/backend/internal/httpd/apispec/specgen/build_test.go b/backend/internal/httpd/apispec/specgen/build_test.go index 9951456b..0d464cef 100644 --- a/backend/internal/httpd/apispec/specgen/build_test.go +++ b/backend/internal/httpd/apispec/specgen/build_test.go @@ -16,7 +16,7 @@ func TestBuild_MatchesEmbedded(t *testing.T) { if err != nil { t.Fatalf("Build: %v", err) } - embedded := apispec.Default().YAML() + embedded := apispec.Embedded() if !bytes.Equal(got, embedded) { t.Fatalf("embedded openapi.yaml is stale — run `go generate ./...` and commit.\n"+ "len(fresh)=%d len(embedded)=%d", len(got), len(embedded)) diff --git a/backend/internal/httpd/controllers/projects.go b/backend/internal/httpd/controllers/projects.go index a5d8bff6..43ef6ad8 100644 --- a/backend/internal/httpd/controllers/projects.go +++ b/backend/internal/httpd/controllers/projects.go @@ -87,8 +87,9 @@ func (c *ProjectsController) get(w http.ResponseWriter, r *http.Request) { resp, err := newGetProjectResponse(got) if err != nil { // GetResult set neither variant — a Manager-contract violation. Map it - // to a 500 here, before any status/body is written. - writeProjectError(w, r, err, http.StatusInternalServerError) + // to a 500 here, before any status/body is written. A plain error (not + // *project.Error) falls through writeProjectError to a 500 envelope. + writeProjectError(w, r, err) return } envelope.WriteJSON(w, http.StatusOK, resp) diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 57a785f5..723d25c2 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -108,11 +108,7 @@ export interface components { id: string; name: string; path: string; - reactions?: { - [key: string]: components["schemas"]["ReactionConfig"]; - }; repo: string; - runtime?: string; scm?: components["schemas"]["SCMConfig"]; tracker?: components["schemas"]["TrackerConfig"]; }; @@ -125,16 +121,6 @@ export interface components { ProjectResponse: { project: components["schemas"]["Project"]; }; - ReactionConfig: { - action?: string; - auto?: null | boolean; - escalateAfter?: unknown; - includeSummary?: null | boolean; - message?: string; - priority?: string; - retries?: null | number; - threshold?: string; - }; ReloadResult: { degradedCount: number; projectCount: number; @@ -172,10 +158,6 @@ export interface components { }; UpdateConfigInput: { agent?: null | string; - reactions?: null | { - [key: string]: components["schemas"]["ReactionConfig"]; - }; - runtime?: null | string; scm?: components["schemas"]["SCMConfig"]; tracker?: components["schemas"]["TrackerConfig"]; }; From 55e4f5e3c68b31bf52622f2d43c6410bbbe414d9 Mon Sep 17 00:00:00 2001 From: itrytoohard Date: Mon, 1 Jun 2026 22:01:08 +0530 Subject: [PATCH 5/8] fix(api): satisfy golangci-lint (gosec, revive, staticcheck) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the lint CI job (golangci-lint v2.12.2, which can't run locally against the go1.25 module): - gosec G306: genspec writes openapi.yaml with 0o600 instead of 0o644. - revive: add the missing doc comment on ProjectOrDegraded.MarshalJSON. - staticcheck SA1019: r.InterceptDefName is deprecated; pass jsonschema.InterceptDefName(schemaName) via DefaultOptions instead. No behavior change — the regenerated spec is byte-identical (the InterceptDefName move produces the same output). build/vet/test pass; gofmt clean. --- backend/cmd/genspec/main.go | 2 +- backend/internal/httpd/apispec/specgen/build.go | 7 ++++--- backend/internal/httpd/controllers/dto.go | 4 ++++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/backend/cmd/genspec/main.go b/backend/cmd/genspec/main.go index 67f1eb22..c2310e94 100644 --- a/backend/cmd/genspec/main.go +++ b/backend/cmd/genspec/main.go @@ -20,7 +20,7 @@ func main() { if err != nil { log.Fatalf("genspec: build openapi: %v", err) } - if err := os.WriteFile(*out, doc, 0o644); err != nil { + if err := os.WriteFile(*out, doc, 0o600); err != nil { log.Fatalf("genspec: write %s: %v", *out, err) } } diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 726f7c9b..80ecbbf0 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -39,13 +39,14 @@ func Build() ([]byte, error) { // structs stay clean (only description/enum tags) and this hook adds the // required array. nonNullableSlices drops the spurious "null" type swaggest // stamps on every Go slice. + // + // schemaName cleans the component names (the generated TS type names); + // swaggest defaults to PackageType, e.g. "ProjectProject", "EnvelopeAPIError". r.DefaultOptions = append(r.DefaultOptions, jsonschema.InterceptProp(requiredFromJSONTag), jsonschema.InterceptNullability(nonNullableSlices), + jsonschema.InterceptDefName(schemaName), ) - // Clean component schema names (which become the generated TS type names): - // swaggest defaults to PackageType, e.g. "ProjectProject", "EnvelopeAPIError". - r.InterceptDefName(schemaName) r.Spec.SetTitle("Agent Orchestrator HTTP daemon") r.Spec.SetVersion("0.1.0-route-shell") diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index 2f30674a..053b8200 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -49,6 +49,10 @@ type ProjectOrDegraded struct { Degraded *project.Degraded } +// MarshalJSON emits whichever variant is set so the handler sends the right +// object. It errors when neither is set rather than emitting a contract-breaking +// `null` — though the handler validates that upstream and returns a 500 before +// writing, so this branch is an unreachable backstop (see newGetProjectResponse). func (p ProjectOrDegraded) MarshalJSON() ([]byte, error) { switch { case p.Degraded != nil: From 94bccd7a93f79da1bcbbe6260b4559aedf6d764c Mon Sep 17 00:00:00 2001 From: Vaibhaav Date: Tue, 2 Jun 2026 15:12:38 +0530 Subject: [PATCH 6/8] feat(api): add session route shell --- .gitattributes | 1 + backend/internal/httpd/api.go | 4 +- backend/internal/httpd/apispec/openapi.yaml | 304 ++++++++++++++++++ .../internal/httpd/apispec/specgen/build.go | 106 +++++- backend/internal/httpd/controllers/dto.go | 44 +++ .../internal/httpd/controllers/sessions.go | 43 +++ .../httpd/controllers/sessions_test.go | 68 ++++ 7 files changed, 562 insertions(+), 8 deletions(-) create mode 100644 .gitattributes create mode 100644 backend/internal/httpd/controllers/sessions.go create mode 100644 backend/internal/httpd/controllers/sessions_test.go diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..01de7f9e --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +backend/internal/httpd/apispec/openapi.yaml text eol=lf diff --git a/backend/internal/httpd/api.go b/backend/internal/httpd/api.go index 9480cdad..944b5318 100644 --- a/backend/internal/httpd/api.go +++ b/backend/internal/httpd/api.go @@ -26,6 +26,7 @@ type APIDeps struct { type API struct { cfg config.Config projects *controllers.ProjectsController + sessions *controllers.SessionsController } // NewAPI constructs the API surface from its dependencies. cfg carries the @@ -37,6 +38,7 @@ func NewAPI(cfg config.Config, deps APIDeps) *API { projects: &controllers.ProjectsController{ Mgr: deps.Projects, }, + sessions: &controllers.SessionsController{}, } } @@ -55,7 +57,7 @@ func (a *API) Register(root chi.Router) { r.Group(func(r chi.Router) { r.Use(middleware.Timeout(timeout)) a.projects.Register(r) - // Sibling REST controllers plug in here. + a.sessions.Register(r) }) // Surfaces that intentionally bypass the REST timeout register at this level. }) diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index b9773d33..c4778067 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -242,6 +242,215 @@ paths: summary: Invalidate cached config and re-scan the project registry tags: - projects + /api/v1/sessions: + get: + operationId: listSessions + parameters: + - description: Optional project id filter. + in: query + name: project + schema: + description: Optional project id filter. + type: string + - description: Optional active/terminal filter. + in: query + name: active + schema: + description: Optional active/terminal filter. + type: + - "null" + - boolean + - description: When true, return only orchestrator sessions. + in: query + name: orchestratorOnly + schema: + description: When true, return only orchestrator sessions. + type: + - "null" + - boolean + - description: Optional freshness filter for dashboard reads. + in: query + name: fresh + schema: + description: Optional freshness filter for dashboard reads. + type: + - "null" + - boolean + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ListSessionsResponse' + description: OK + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: List dashboard sessions + tags: + - sessions + post: + operationId: spawnSession + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SpawnSessionRequest' + required: true + responses: + "201": + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + description: Created + "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": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Spawn a new session + tags: + - sessions + /api/v1/sessions/{id}: + get: + operationId: getSession + parameters: + - description: Session identifier. + in: path + name: id + required: true + schema: + description: Session identifier. + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + description: OK + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Fetch one dashboard session + tags: + - sessions + /api/v1/sessions/{id}/restore: + post: + operationId: restoreSession + parameters: + - description: Session identifier. + in: path + name: id + required: true + schema: + description: Session identifier. + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + description: OK + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Conflict + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Unprocessable Entity + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Restore a terminated session + tags: + - sessions + /api/v1/sessions/{id}/send: + post: + operationId: sendSessionMessage + parameters: + - description: Session identifier. + in: path + name: id + required: true + schema: + description: Session identifier. + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SendSessionRequest' + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/SendSessionResponse' + 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": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Send a message to a session + tags: + - sessions components: schemas: APIError: @@ -262,6 +471,17 @@ components: - code - message type: object + Activity: + properties: + lastActivityAt: + format: date-time + type: string + state: + type: string + required: + - state + - lastActivityAt + type: object AddInput: properties: name: @@ -302,6 +522,15 @@ components: required: - projects type: object + ListSessionsResponse: + properties: + sessions: + items: + $ref: '#/components/schemas/Session' + type: array + required: + - sessions + type: object Project: properties: agent: @@ -405,6 +634,79 @@ components: signatureHeader: type: string type: object + SendSessionRequest: + properties: + message: + description: Message to send to the session agent. + type: string + required: + - message + type: object + SendSessionResponse: + properties: + message: + type: string + sessionId: + type: string + required: + - sessionId + - message + type: object + Session: + properties: + activity: + $ref: '#/components/schemas/Activity' + createdAt: + format: date-time + type: string + harness: + type: string + id: + type: string + isTerminated: + type: boolean + issueId: + type: string + kind: + type: string + projectId: + type: string + status: + type: string + updatedAt: + format: date-time + type: string + required: + - id + - projectId + - kind + - activity + - isTerminated + - createdAt + - updatedAt + - status + type: object + SessionResponse: + properties: + session: + $ref: '#/components/schemas/Session' + required: + - session + type: object + SpawnSessionRequest: + properties: + issueId: + description: Optional tracker issue id. + type: string + projectId: + description: Project that owns the session. + type: string + prompt: + description: Initial prompt passed to the agent. + type: string + required: + - projectId + type: object Summary: properties: id: @@ -443,3 +745,5 @@ components: tags: - description: Project registry, configuration, and lifecycle administration name: projects +- description: Session lifecycle commands and dashboard read models + name: sessions diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 80ecbbf0..ec54a16b 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -58,16 +58,18 @@ func Build() ([]byte, error) { r.Spec.Tags = []openapi31.Tag{ *(&openapi31.Tag{Name: "projects"}).WithDescription( "Project registry, configuration, and lifecycle administration"), + *(&openapi31.Tag{Name: "sessions"}).WithDescription( + "Session lifecycle commands and dashboard read models"), } - for _, op := range projectOperations() { + for _, op := range append(projectOperations(), sessionOperations()...) { 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") + oc.SetTags(op.tag) for _, param := range op.pathParams { oc.AddReqStructure(param) } @@ -109,12 +111,27 @@ var schemaNames = map[string]string{ // httpd/envelope "EnvelopeAPIError": "APIError", // domain - "DomainProjectID": "ProjectID", + "DomainProjectID": "ProjectID", + "DomainSessionID": "SessionID", + "DomainIssueID": "IssueID", + "DomainSession": "Session", + "DomainSessionRecord": "SessionRecord", + "DomainSessionStatus": "SessionStatus", + "DomainSessionKind": "SessionKind", + "DomainAgentHarness": "AgentHarness", + "DomainActivity": "Activity", // httpd/controllers (wire envelopes) "ControllersListProjectsResponse": "ListProjectsResponse", "ControllersProjectResponse": "ProjectResponse", "ControllersGetProjectResponse": "ProjectGetResponse", "ControllersProjectOrDegraded": "ProjectOrDegraded", + "ControllersSessionIDParam": "SessionIDParam", + "ControllersListSessionsQuery": "ListSessionsQuery", + "ControllersSpawnSessionRequest": "SpawnSessionRequest", + "ControllersListSessionsResponse": "ListSessionsResponse", + "ControllersSessionResponse": "SessionResponse", + "ControllersSendSessionRequest": "SendSessionRequest", + "ControllersSendSessionResponse": "SendSessionResponse", // project (entities + DTOs) "ProjectProject": "Project", "ProjectSummary": "Summary", @@ -190,10 +207,10 @@ type respUnit struct { } type operation struct { - method, path, id, summary string - pathParams []any // path/query param containers (e.g. ProjectIDParam) - reqBody any // JSON request body struct, nil when the op takes none - resps []respUnit + method, path, id, summary, tag string + pathParams []any // path/query param containers (e.g. ProjectIDParam) + reqBody any // JSON request body struct, nil when the op takes none + resps []respUnit } // projectOperations declares the 7 canonical /projects operations. The set must @@ -203,6 +220,7 @@ func projectOperations() []operation { return []operation{ { method: http.MethodGet, path: "/api/v1/projects", id: "listProjects", + tag: "projects", summary: "List all registered projects (active + degraded)", resps: []respUnit{ {http.StatusOK, controllers.ListProjectsResponse{}}, @@ -211,6 +229,7 @@ func projectOperations() []operation { }, { method: http.MethodPost, path: "/api/v1/projects", id: "addProject", + tag: "projects", summary: "Register a new project from a git repository path", reqBody: project.AddInput{}, resps: []respUnit{ @@ -222,6 +241,7 @@ func projectOperations() []operation { }, { method: http.MethodPost, path: "/api/v1/projects/reload", id: "reloadProjects", + tag: "projects", summary: "Invalidate cached config and re-scan the project registry", resps: []respUnit{ {http.StatusOK, project.ReloadResult{}}, @@ -230,6 +250,7 @@ func projectOperations() []operation { }, { method: http.MethodGet, path: "/api/v1/projects/{id}", id: "getProject", + tag: "projects", summary: "Fetch one project; discriminates ok vs degraded", pathParams: []any{controllers.ProjectIDParam{}}, resps: []respUnit{ @@ -240,6 +261,7 @@ func projectOperations() []operation { }, { method: http.MethodPatch, path: "/api/v1/projects/{id}", id: "updateProjectConfig", + tag: "projects", summary: "Patch behaviour-only fields (identity is frozen)", pathParams: []any{controllers.ProjectIDParam{}}, reqBody: project.UpdateConfigInput{}, @@ -253,6 +275,7 @@ func projectOperations() []operation { }, { method: http.MethodDelete, path: "/api/v1/projects/{id}", id: "removeProject", + tag: "projects", summary: "Remove a project; stops sessions, cleans workspaces, unregisters", pathParams: []any{controllers.ProjectIDParam{}}, resps: []respUnit{ @@ -264,6 +287,7 @@ func projectOperations() []operation { }, { method: http.MethodPost, path: "/api/v1/projects/{id}/repair", id: "repairProject", + tag: "projects", summary: "Recover a degraded project where automatic repair is available", pathParams: []any{controllers.ProjectIDParam{}}, resps: []respUnit{ @@ -275,3 +299,71 @@ func projectOperations() []operation { }, } } + +// sessionOperations declares the first session route-shell slice. It only +// includes routes whose wire bytes are settled enough to code-generate. +func sessionOperations() []operation { + return []operation{ + { + method: http.MethodGet, path: "/api/v1/sessions", id: "listSessions", + tag: "sessions", + summary: "List dashboard sessions", + pathParams: []any{ + controllers.ListSessionsQuery{}, + }, + resps: []respUnit{ + {http.StatusOK, controllers.ListSessionsResponse{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/sessions", id: "spawnSession", + tag: "sessions", + summary: "Spawn a new session", + reqBody: controllers.SpawnSessionRequest{}, + resps: []respUnit{ + {http.StatusCreated, controllers.SessionResponse{}}, + {http.StatusBadRequest, envelope.APIError{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + { + method: http.MethodGet, path: "/api/v1/sessions/{id}", id: "getSession", + tag: "sessions", + summary: "Fetch one dashboard session", + pathParams: []any{controllers.SessionIDParam{}}, + resps: []respUnit{ + {http.StatusOK, controllers.SessionResponse{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/sessions/{id}/restore", id: "restoreSession", + tag: "sessions", + summary: "Restore a terminated session", + pathParams: []any{controllers.SessionIDParam{}}, + resps: []respUnit{ + {http.StatusOK, controllers.SessionResponse{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusConflict, envelope.APIError{}}, + {http.StatusUnprocessableEntity, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/sessions/{id}/send", id: "sendSessionMessage", + tag: "sessions", + summary: "Send a message to a session", + pathParams: []any{controllers.SessionIDParam{}}, + reqBody: controllers.SendSessionRequest{}, + resps: []respUnit{ + {http.StatusOK, controllers.SendSessionResponse{}}, + {http.StatusBadRequest, envelope.APIError{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + } +} diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index 053b8200..4a0b47f4 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/project" ) @@ -94,3 +95,46 @@ func newGetProjectResponse(res project.GetResult) (GetProjectResponse, error) { Project: ProjectOrDegraded{Project: res.Project, Degraded: res.Degraded}, }, nil } + +// SessionIDParam is the {id} path parameter shared by session item and command +// routes. +type SessionIDParam struct { + ID string `path:"id" description:"Session identifier."` +} + +// ListSessionsQuery is the filter surface for GET /api/v1/sessions. +type ListSessionsQuery struct { + Project string `query:"project" description:"Optional project id filter."` + Active *bool `query:"active" description:"Optional active/terminal filter."` + OrchestratorOnly *bool `query:"orchestratorOnly" description:"When true, return only orchestrator sessions."` + Fresh *bool `query:"fresh" description:"Optional freshness filter for dashboard reads."` +} + +// SpawnSessionRequest is the body of POST /api/v1/sessions. +type SpawnSessionRequest struct { + ProjectID domain.ProjectID `json:"projectId" description:"Project that owns the session."` + IssueID domain.IssueID `json:"issueId,omitempty" description:"Optional tracker issue id."` + Prompt string `json:"prompt,omitempty" description:"Initial prompt passed to the agent."` +} + +// ListSessionsResponse is the body of GET /api/v1/sessions. +type ListSessionsResponse struct { + Sessions []domain.Session `json:"sessions"` +} + +// SessionResponse is the { session } body shared by session read, spawn, and +// restore routes. +type SessionResponse struct { + Session domain.Session `json:"session"` +} + +// SendSessionRequest is the body of POST /api/v1/sessions/{id}/send. +type SendSessionRequest struct { + Message string `json:"message" description:"Message to send to the session agent."` +} + +// SendSessionResponse is the success body of POST /api/v1/sessions/{id}/send. +type SendSessionResponse struct { + SessionID domain.SessionID `json:"sessionId"` + Message string `json:"message"` +} diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go new file mode 100644 index 00000000..28a9a576 --- /dev/null +++ b/backend/internal/httpd/controllers/sessions.go @@ -0,0 +1,43 @@ +package controllers + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" +) + +// SessionsController owns the canonical /sessions route shell. Business logic +// lands later; these handlers expose the code-generated contract as 501s. +type SessionsController struct{} + +// Register mounts only routes whose request/response bytes are settled enough +// for the first session route-shell slice. +func (c *SessionsController) Register(r chi.Router) { + r.Get("/sessions", c.list) + r.Post("/sessions", c.spawn) + r.Get("/sessions/{id}", c.get) + r.Post("/sessions/{id}/restore", c.restore) + r.Post("/sessions/{id}/send", c.send) +} + +func (c *SessionsController) list(w http.ResponseWriter, r *http.Request) { + apispec.NotImplemented(w, r, "GET", "/api/v1/sessions") +} + +func (c *SessionsController) spawn(w http.ResponseWriter, r *http.Request) { + apispec.NotImplemented(w, r, "POST", "/api/v1/sessions") +} + +func (c *SessionsController) get(w http.ResponseWriter, r *http.Request) { + apispec.NotImplemented(w, r, "GET", "/api/v1/sessions/{id}") +} + +func (c *SessionsController) restore(w http.ResponseWriter, r *http.Request) { + apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{id}/restore") +} + +func (c *SessionsController) send(w http.ResponseWriter, r *http.Request) { + apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{id}/send") +} diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go new file mode 100644 index 00000000..8abd9638 --- /dev/null +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -0,0 +1,68 @@ +package controllers_test + +import ( + "net/http" + "testing" +) + +func TestSessionsRoutes_DefaultToStubs(t *testing.T) { + srv := newTestServer(t) + + cases := []struct { + method string + path string + body string + }{ + {method: "GET", path: "/api/v1/sessions"}, + {method: "POST", path: "/api/v1/sessions", body: `{"projectId":"proj","prompt":"start"}`}, + {method: "GET", path: "/api/v1/sessions/s1"}, + {method: "POST", path: "/api/v1/sessions/s1/restore"}, + {method: "POST", path: "/api/v1/sessions/s1/send", body: `{"message":"hello"}`}, + } + + 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) + assertJSON(t, headers) + assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED") + }) + } +} + +func TestSessionsRoutes_DroppedAndDeferredUnregistered(t *testing.T) { + srv := newTestServer(t) + + cases := []struct { + method, path, wantCode, why string + wantStatus int + }{ + { + method: "POST", + path: "/api/v1/sessions/s1/message", + wantStatus: http.StatusNotFound, + wantCode: "ROUTE_NOT_FOUND", + why: "duplicate /message route is dropped in favor of /send", + }, + { + method: "POST", + path: "/api/v1/spawn", + wantStatus: http.StatusNotFound, + wantCode: "ROUTE_NOT_FOUND", + why: "legacy spawn route is not registered", + }, + { + method: "POST", + path: "/api/v1/sessions/s1/kill", + wantStatus: http.StatusNotFound, + wantCode: "ROUTE_NOT_FOUND", + why: "kill response bytes are not locked in this first slice", + }, + } + + 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) + }) + } +} From 683790578513d9811a45cb265e73cf788583c159 Mon Sep 17 00:00:00 2001 From: Vaibhaav Date: Tue, 2 Jun 2026 15:45:40 +0530 Subject: [PATCH 7/8] fix(api): address session shell review --- backend/internal/httpd/api.go | 6 +++++- backend/internal/httpd/controllers/dto.go | 8 ++++---- backend/internal/httpd/controllers/sessions.go | 5 ++++- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/backend/internal/httpd/api.go b/backend/internal/httpd/api.go index 944b5318..e01375cc 100644 --- a/backend/internal/httpd/api.go +++ b/backend/internal/httpd/api.go @@ -11,6 +11,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" "github.com/aoagents/agent-orchestrator/backend/internal/project" + "github.com/aoagents/agent-orchestrator/backend/internal/session" ) // APIDeps bundles every Manager the API layer's controllers depend on. @@ -19,6 +20,7 @@ import ( // registered but returns the OpenAPI-backed 501 response. type APIDeps struct { Projects project.Manager + Sessions *session.Manager } // API owns one controller per resource and is the single Register call the @@ -38,7 +40,9 @@ func NewAPI(cfg config.Config, deps APIDeps) *API { projects: &controllers.ProjectsController{ Mgr: deps.Projects, }, - sessions: &controllers.SessionsController{}, + sessions: &controllers.SessionsController{ + Mgr: deps.Sessions, + }, } } diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index 4a0b47f4..0e19759b 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -104,10 +104,10 @@ type SessionIDParam struct { // ListSessionsQuery is the filter surface for GET /api/v1/sessions. type ListSessionsQuery struct { - Project string `query:"project" description:"Optional project id filter."` - Active *bool `query:"active" description:"Optional active/terminal filter."` - OrchestratorOnly *bool `query:"orchestratorOnly" description:"When true, return only orchestrator sessions."` - Fresh *bool `query:"fresh" description:"Optional freshness filter for dashboard reads."` + Project domain.ProjectID `query:"project" description:"Optional project id filter."` + Active *bool `query:"active" description:"Optional active/terminal filter."` + OrchestratorOnly *bool `query:"orchestratorOnly" description:"When true, return only orchestrator sessions."` + Fresh *bool `query:"fresh" description:"Optional freshness filter for dashboard reads."` } // SpawnSessionRequest is the body of POST /api/v1/sessions. diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index 28a9a576..8c19169a 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -6,11 +6,14 @@ import ( "github.com/go-chi/chi/v5" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" + "github.com/aoagents/agent-orchestrator/backend/internal/session" ) // SessionsController owns the canonical /sessions route shell. Business logic // lands later; these handlers expose the code-generated contract as 501s. -type SessionsController struct{} +type SessionsController struct { + Mgr *session.Manager +} // Register mounts only routes whose request/response bytes are settled enough // for the first session route-shell slice. From 44f7af79bc6012f4613ec6e1dbf8e832b6858281 Mon Sep 17 00:00:00 2001 From: Vaibhaav Date: Tue, 2 Jun 2026 15:56:14 +0530 Subject: [PATCH 8/8] fix(api): remove unused session schema names --- backend/internal/httpd/apispec/specgen/build.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index ec54a16b..fc9f507b 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -116,17 +116,12 @@ var schemaNames = map[string]string{ "DomainIssueID": "IssueID", "DomainSession": "Session", "DomainSessionRecord": "SessionRecord", - "DomainSessionStatus": "SessionStatus", - "DomainSessionKind": "SessionKind", - "DomainAgentHarness": "AgentHarness", "DomainActivity": "Activity", // httpd/controllers (wire envelopes) "ControllersListProjectsResponse": "ListProjectsResponse", "ControllersProjectResponse": "ProjectResponse", "ControllersGetProjectResponse": "ProjectGetResponse", "ControllersProjectOrDegraded": "ProjectOrDegraded", - "ControllersSessionIDParam": "SessionIDParam", - "ControllersListSessionsQuery": "ListSessionsQuery", "ControllersSpawnSessionRequest": "SpawnSessionRequest", "ControllersListSessionsResponse": "ListSessionsResponse", "ControllersSessionResponse": "SessionResponse",