// Package specgen builds the code-first OpenAPI document from the Go contract // types. It lives outside apispec because it imports the controllers (to // reflect their request/response shapes), and controllers import apispec (for // the 501 stub) — keeping Build here breaks that cycle. apispec only embeds and // serves the committed openapi.yaml; specgen produces it. package specgen import ( "fmt" "net/http" "reflect" "strings" jsonschema "github.com/swaggest/jsonschema-go" openapi "github.com/swaggest/openapi-go" "github.com/swaggest/openapi-go/openapi31" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" ) // Build reflects the Go contract types and the operation registry below into // the OpenAPI document. It is the single source of truth for the /api/v1 // contract: `cmd/genspec` writes its output to apispec/openapi.yaml (the // committed, embedded artifact) and TestBuild_MatchesEmbedded asserts the embed // equals fresh Build() output so the two can never drift. Schema facets live as // struct tags on the service.*/controllers.* types; operation metadata (path, // status codes, summaries) lives here. // // Every wire shape is reflected straight from where it is used at runtime — the // request bodies, path params, and response envelopes from controllers, the // error envelope from httpd/envelope — so the served responses and the // generated schema share one definition each. func Build() ([]byte, error) { r := openapi31.NewReflector() // Derive `required` from the idiomatic Go convention: a JSON field without // `omitempty` is required. swaggest does not infer this on its own, so the // structs stay clean (only description/enum tags) and this hook adds the // required array. nonNullableSlices drops the spurious "null" type swaggest // stamps on every Go slice. r.DefaultOptions = append(r.DefaultOptions, jsonschema.InterceptProp(requiredFromJSONTag), jsonschema.InterceptNullability(nonNullableSlices), // Clean component schema names (which become the generated TS type names): // swaggest defaults to PackageType, e.g. "ProjectProject", "EnvelopeAPIError". jsonschema.InterceptDefName(schemaName), ) r.Spec.SetTitle("Agent Orchestrator HTTP daemon") r.Spec.SetVersion("0.1.0-route-shell") r.Spec.SetDescription("Loopback-only HTTP surface served by the Go daemon. " + "Generated from Go (code-first) — do not edit by hand; run `go generate ./...`.") r.Spec.Servers = []openapi31.Server{ *(&openapi31.Server{URL: "http://127.0.0.1:3001"}).WithDescription("Local daemon (loopback only)"), } r.Spec.Tags = []openapi31.Tag{ *(&openapi31.Tag{Name: "projects"}).WithDescription( "Project registry, configuration, and lifecycle administration"), *(&openapi31.Tag{Name: "sessions"}).WithDescription( "Agent session lifecycle and messaging"), } for _, op := range operations() { oc, err := r.NewOperationContext(op.method, op.path) if err != nil { return nil, fmt.Errorf("new operation %s %s: %w", op.method, op.path, err) } oc.SetID(op.id) oc.SetSummary(op.summary) oc.SetTags(op.tag) for _, param := range op.pathParams { oc.AddReqStructure(param) } if op.reqBody != nil { // AddReqStructure leaves requestBody.required absent, which // OpenAPI reads as optional. These bodies are mandatory, so force // it — otherwise validators/generators treat the body as skippable. oc.AddReqStructure(op.reqBody, openapi.WithCustomize(markRequestBodyRequired)) } for _, resp := range op.resps { oc.AddRespStructure(resp.body, openapi.WithHTTPStatus(resp.status)) } if err := r.AddOperation(oc); err != nil { return nil, fmt.Errorf("add operation %s %s: %w", op.method, op.path, err) } } return r.Spec.MarshalYAML() } // schemaName maps swaggest's default PackageType component names (e.g. // "ProjectProject", "EnvelopeAPIError") to the clean, stable schema names that // become the generated TypeScript type names. Every reflected type is listed // explicitly: an unrecognised default name is returned verbatim, so a new type // surfaces as a visibly-wrong "PackageType" name in the diff (and the drift // test) rather than silently colliding with an existing schema via a // TrimPrefix catch-all. func schemaName(_ reflect.Type, defaultName string) string { if clean, ok := schemaNames[defaultName]; ok { return clean } return defaultName } // schemaNames is the exhaustive default→clean mapping for every type reflected // by projectOperations(). Add an entry when a new contract type is introduced; // the drift test fails until the spec is regenerated, which flags the gap. var schemaNames = map[string]string{ // httpd/envelope "EnvelopeAPIError": "APIError", // domain "DomainProjectID": "ProjectID", "DomainSessionID": "SessionID", "DomainIssueID": "IssueID", "DomainSession": "Session", // httpd/controllers (wire envelopes) "ControllersListProjectsResponse": "ListProjectsResponse", "ControllersProjectResponse": "ProjectResponse", "ControllersGetProjectResponse": "ProjectGetResponse", "ControllersProjectOrDegraded": "ProjectOrDegraded", "ControllersProjectIDParam": "ProjectIDParam", "ControllersSessionIDParam": "SessionIDParam", "ControllersListSessionsQuery": "ListSessionsQuery", "ControllersListSessionsResponse": "ListSessionsResponse", "ControllersSpawnSessionRequest": "SpawnSessionRequest", "ControllersSessionResponse": "SessionResponse", "ControllersRenameSessionRequest": "RenameSessionRequest", "ControllersRestoreSessionResponse": "RestoreSessionResponse", "ControllersKillSessionResponse": "KillSessionResponse", "ControllersSendSessionMessageRequest": "SendSessionMessageRequest", "ControllersSendSessionMessageResponse": "SendSessionMessageResponse", "ControllersSpawnOrchestratorRequest": "SpawnOrchestratorRequest", "ControllersSpawnOrchestratorResponse": "SpawnOrchestratorResponse", "ControllersOrchestratorResponse": "OrchestratorResponse", // service/project entities + DTOs "ProjectProject": "Project", "ProjectSummary": "ProjectSummary", "ProjectDegraded": "DegradedProject", "ProjectAddInput": "AddProjectInput", "ProjectRemoveResult": "RemoveProjectResult", "ProjectTrackerConfig": "TrackerConfig", "ProjectSCMConfig": "SCMConfig", "ProjectSCMWebhookConfig": "SCMWebhookConfig", } // markRequestBodyRequired sets requestBody.required: true on the operation's // JSON body. swaggest leaves it absent (== optional) for AddReqStructure bodies. func markRequestBodyRequired(cor openapi.ContentOrReference) { if rb, ok := cor.(*openapi31.RequestBodyOrReference); ok && rb.RequestBody != nil { rb.RequestBody.WithRequired(true) } } // nonNullableSlices drops the "null" that swaggest unions into every Go slice // type (a nil slice marshals as JSON null). A required array field should be // `T[]`, not `T[] | null`; the handlers normalise nil to an empty slice, so // null never reaches the wire. Byte slices (base64 strings) are left alone. func nonNullableSlices(p jsonschema.InterceptNullabilityParams) { if !p.NullAdded || p.Type == nil || p.Type.Kind() != reflect.Slice { return } if p.Type.Elem().Kind() == reflect.Uint8 { return } p.Schema.TypeEns().WithSimpleTypes(jsonschema.Array) p.Schema.Type.SliceOfSimpleTypeValues = nil } // requiredFromJSONTag marks a property required when its json tag lacks // `omitempty` (the Go convention for "always present"). Runs after default // processing so ParentSchema exists; skips fields without a json tag (e.g. path // params, which swaggest marks required on their own). func requiredFromJSONTag(p jsonschema.InterceptPropParams) error { if !p.Processed || p.ParentSchema == nil { return nil } jsonTag := p.Field.Tag.Get("json") if jsonTag == "" || jsonTag == "-" { return nil } parts := strings.Split(jsonTag, ",") name := parts[0] if name == "" { name = p.Name } for _, opt := range parts[1:] { if opt == "omitempty" { return nil } } for _, existing := range p.ParentSchema.Required { if existing == name { return nil } } p.ParentSchema.Required = append(p.ParentSchema.Required, name) return nil } // --- operation registry ----------------------------------------------------- type respUnit struct { status int body any } type operation struct { method, path, id, summary string tag string pathParams []any // path/query param containers (e.g. ProjectIDParam) reqBody any // JSON request body struct, nil when the op takes none resps []respUnit } func operations() []operation { ops := append([]operation{}, projectOperations()...) ops = append(ops, sessionOperations()...) return ops } // projectOperations declares the 4 canonical /projects operations. The set must // stay 1:1 with the routes ProjectsController.Register mounts — // TestRouteSpecParity fails the build otherwise. func projectOperations() []operation { return []operation{ { method: http.MethodGet, path: "/api/v1/projects", id: "listProjects", tag: "projects", summary: "List all registered projects (active + degraded)", resps: []respUnit{ {http.StatusOK, controllers.ListProjectsResponse{}}, {http.StatusInternalServerError, envelope.APIError{}}, }, }, { method: http.MethodPost, path: "/api/v1/projects", id: "addProject", tag: "projects", summary: "Register a new project from a git repository path", reqBody: projectsvc.AddInput{}, resps: []respUnit{ {http.StatusCreated, controllers.ProjectResponse{}}, {http.StatusBadRequest, envelope.APIError{}}, {http.StatusConflict, envelope.APIError{}}, {http.StatusInternalServerError, envelope.APIError{}}, }, }, { method: http.MethodGet, path: "/api/v1/projects/{id}", id: "getProject", tag: "projects", summary: "Fetch one project; discriminates ok vs degraded", pathParams: []any{controllers.ProjectIDParam{}}, resps: []respUnit{ {http.StatusOK, controllers.GetProjectResponse{}}, {http.StatusNotFound, envelope.APIError{}}, {http.StatusInternalServerError, envelope.APIError{}}, }, }, { method: http.MethodDelete, path: "/api/v1/projects/{id}", id: "removeProject", tag: "projects", summary: "Remove a project; stops sessions, cleans workspaces, unregisters", pathParams: []any{controllers.ProjectIDParam{}}, resps: []respUnit{ {http.StatusOK, projectsvc.RemoveResult{}}, {http.StatusBadRequest, envelope.APIError{}}, {http.StatusNotFound, envelope.APIError{}}, {http.StatusInternalServerError, envelope.APIError{}}, }, }, } } func sessionOperations() []operation { return []operation{ { method: http.MethodGet, path: "/api/v1/sessions", id: "listSessions", tag: "sessions", summary: "List sessions", pathParams: []any{controllers.ListSessionsQuery{}}, resps: []respUnit{ {http.StatusOK, controllers.ListSessionsResponse{}}, {http.StatusBadRequest, envelope.APIError{}}, {http.StatusInternalServerError, envelope.APIError{}}, }, }, { method: http.MethodPost, path: "/api/v1/sessions", id: "spawnSession", tag: "sessions", summary: "Spawn a new agent session", reqBody: controllers.SpawnSessionRequest{}, resps: []respUnit{ {http.StatusCreated, controllers.SessionResponse{}}, {http.StatusBadRequest, envelope.APIError{}}, {http.StatusNotFound, envelope.APIError{}}, {http.StatusInternalServerError, envelope.APIError{}}, }, }, { method: http.MethodGet, path: "/api/v1/sessions/{sessionId}", id: "getSession", tag: "sessions", summary: "Fetch one session", pathParams: []any{controllers.SessionIDParam{}}, resps: []respUnit{ {http.StatusOK, controllers.SessionResponse{}}, {http.StatusNotFound, envelope.APIError{}}, {http.StatusInternalServerError, envelope.APIError{}}, }, }, { method: http.MethodPatch, path: "/api/v1/sessions/{sessionId}", id: "renameSession", tag: "sessions", summary: "Rename a session display name", pathParams: []any{controllers.SessionIDParam{}}, reqBody: controllers.RenameSessionRequest{}, resps: []respUnit{ {http.StatusOK, controllers.SessionResponse{}}, {http.StatusBadRequest, envelope.APIError{}}, {http.StatusNotFound, envelope.APIError{}}, {http.StatusNotImplemented, envelope.APIError{}}, }, }, { method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/restore", id: "restoreSession", tag: "sessions", summary: "Restore a terminated session", pathParams: []any{controllers.SessionIDParam{}}, resps: []respUnit{ {http.StatusOK, controllers.RestoreSessionResponse{}}, {http.StatusNotFound, envelope.APIError{}}, {http.StatusConflict, envelope.APIError{}}, {http.StatusInternalServerError, envelope.APIError{}}, }, }, { method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/kill", id: "killSession", tag: "sessions", summary: "Mark a session terminated and tear down runtime/workspace resources", pathParams: []any{controllers.SessionIDParam{}}, resps: []respUnit{ {http.StatusOK, controllers.KillSessionResponse{}}, {http.StatusNotFound, envelope.APIError{}}, {http.StatusConflict, envelope.APIError{}}, {http.StatusInternalServerError, envelope.APIError{}}, }, }, { method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/send", id: "sendSessionMessage", tag: "sessions", summary: "Send a message to a running session's agent", pathParams: []any{controllers.SessionIDParam{}}, reqBody: controllers.SendSessionMessageRequest{}, resps: []respUnit{ {http.StatusOK, controllers.SendSessionMessageResponse{}}, {http.StatusBadRequest, envelope.APIError{}}, {http.StatusNotFound, envelope.APIError{}}, {http.StatusInternalServerError, envelope.APIError{}}, }, }, { method: http.MethodPost, path: "/api/v1/orchestrators", id: "spawnOrchestrator", tag: "sessions", summary: "Spawn an orchestrator session", reqBody: controllers.SpawnOrchestratorRequest{}, resps: []respUnit{ {http.StatusCreated, controllers.SpawnOrchestratorResponse{}}, {http.StatusBadRequest, envelope.APIError{}}, {http.StatusNotFound, envelope.APIError{}}, {http.StatusInternalServerError, envelope.APIError{}}, {http.StatusNotImplemented, envelope.APIError{}}, }, }, } }