diff --git a/backend/go.mod b/backend/go.mod index 88ca590c..403a77e4 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -5,6 +5,7 @@ go 1.25.7 require ( github.com/go-chi/chi/v5 v5.1.0 github.com/pressly/goose/v3 v3.27.1 + gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.51.0 ) diff --git a/backend/go.sum b/backend/go.sum index 89f83929..24187476 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -36,6 +36,8 @@ golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= diff --git a/backend/internal/httpd/api.go b/backend/internal/httpd/api.go new file mode 100644 index 00000000..124a8d78 --- /dev/null +++ b/backend/internal/httpd/api.go @@ -0,0 +1,95 @@ +package httpd + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers" + "github.com/aoagents/agent-orchestrator/backend/internal/project" +) + +// APIDeps bundles every Manager the API layer's controllers depend on. There +// is exactly one Manager per resource, defined in that resource's own package +// (project.Manager, later session.Manager, ...), and the controllers see ONLY +// that interface — they don't reach past it to the LCM, adapters, or stores. +// Whether a Manager impl talks to the registry, the LCM, or an outbound port +// is its own concern. +// +// The route-shell PR (#20) leaves every field nil — handlers answer via +// apispec.NotImplemented and don't dereference them yet. The handler-impl PR +// wires real Managers and flips stubs to real logic one route at a time. +type APIDeps struct { + Projects project.Manager +} + +// API owns one controller per resource and is the single Register call the +// router invokes to mount the /api/v1 surface. Splitting per-resource means +// later PRs can land a controller's real handlers without touching the +// surrounding wiring. +type API struct { + cfg config.Config + projects *controllers.ProjectsController +} + +// NewAPI constructs the API surface from its dependencies. cfg carries the +// per-request timeout so the REST group can apply it without re-reading the +// environment. +func NewAPI(cfg config.Config, deps APIDeps) *API { + return &API{ + cfg: cfg, + projects: &controllers.ProjectsController{ + Mgr: deps.Projects, + }, + } +} + +// Register mounts the API surface on root. /api/v1 hosts the REST group with +// the per-request Timeout that the skeleton router (router.go) deliberately +// kept off the global stack — REST routes are bounded, but long-lived surfaces +// (/events SSE, /mux WS) live outside this group when they land. +// +// /mux is mounted outside /api/v1 for parity with the legacy TS surface; it is +// a phase-4 placeholder and stays unregistered here until that lane starts. +func (a *API) Register(root chi.Router) { + timeout := a.cfg.RequestTimeout + if timeout <= 0 { + timeout = config.DefaultRequestTimeout + } + + root.Route("/api/v1", func(r chi.Router) { + // The OpenAPI document is the source of truth for every contract on + // this surface; serve it so tooling (SDK generators, the OpenAPI + // validator in #19, the dashboard's developer tools) can fetch the + // whole spec from the same origin as the routes it describes. + apispec.RegisterServe(r, "/openapi.yaml") + + r.Group(func(r chi.Router) { + r.Use(middleware.Timeout(timeout)) + a.projects.Register(r) + // Sibling controllers (sessions, issues, prs, ...) plug in here in + // follow-up PRs #21 / #22 without touching the timeout group. + }) + // Surfaces that intentionally bypass the REST timeout (SSE, future WS) + // register at this level — none exist in the route-shell PR. + }) +} + +// notFoundJSON returns the locked envelope for unmatched routes. Chi's default +// 404 is a text/plain body; the API surface must answer JSON so consumers can +// parse it uniformly. +func notFoundJSON(w http.ResponseWriter, r *http.Request) { + writeAPIError(w, r, http.StatusNotFound, "not_found", "ROUTE_NOT_FOUND", + r.Method+" "+r.URL.Path+" has no handler", nil) +} + +// methodNotAllowedJSON returns the locked envelope when a method probes a +// known path without a matching verb (e.g. PUT /projects/{id} after we drop +// the legacy PUT alias). +func methodNotAllowedJSON(w http.ResponseWriter, r *http.Request) { + writeAPIError(w, r, http.StatusMethodNotAllowed, "method_not_allowed", "METHOD_NOT_ALLOWED", + r.Method+" not allowed on "+r.URL.Path, nil) +} diff --git a/backend/internal/httpd/apispec/apispec.go b/backend/internal/httpd/apispec/apispec.go new file mode 100644 index 00000000..627ad5db --- /dev/null +++ b/backend/internal/httpd/apispec/apispec.go @@ -0,0 +1,157 @@ +// Package apispec embeds the OpenAPI document, looks up per-operation +// slices, and writes the locked 501 envelope. The 501 body carries the +// operation's slice of the OpenAPI document so consumers discover the +// contract from the endpoint itself — no duplicate planned/contract +// metadata lives in code. +// +// The same document is served verbatim at /api/v1/openapi.yaml so +// tooling that wants the whole spec can fetch it once. +package apispec + +import ( + _ "embed" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + yaml "gopkg.in/yaml.v3" +) + +//go:embed openapi.yaml +var openapiYAML []byte + +// Spec is the parsed, in-memory view of the embedded OpenAPI document. It +// preserves the YAML shape verbatim so the JSON we emit on 501 responses +// matches the on-disk source. +type Spec struct { + doc map[string]any +} + +var ( + defaultOnce sync.Once + defaultSpec *Spec + defaultErr error +) + +// Default returns the process-wide spec parsed from the embedded YAML. It +// panics on a malformed embed — that is a build-time bug, not a runtime +// one, so failing fast at first use is the right call. +func Default() *Spec { + defaultOnce.Do(func() { + s, err := New(openapiYAML) + defaultSpec = s + defaultErr = err + }) + if defaultErr != nil { + panic(fmt.Sprintf("apispec: embedded openapi.yaml failed to parse: %v", defaultErr)) + } + return defaultSpec +} + +// New parses the supplied YAML bytes. Exposed so tests can construct an +// independent spec without touching the embedded default. +func New(yamlBytes []byte) (*Spec, error) { + var doc map[string]any + if err := yaml.Unmarshal(yamlBytes, &doc); err != nil { + return nil, fmt.Errorf("parse openapi: %w", err) + } + if doc == nil { + return nil, fmt.Errorf("parse openapi: empty document") + } + return &Spec{doc: doc}, nil +} + +// YAML returns the raw embedded document bytes. Used by the /openapi.yaml +// handler. +func (s *Spec) YAML() []byte { return openapiYAML } + +// Operation returns the spec slice for a single (method, path) pair, ready +// to be JSON-serialised. The slice is the OpenAPI Operation object (the +// inner block under e.g. paths./projects.get), with parent path-level +// parameters merged in for completeness. +// +// Returns nil if the path or method is not in the spec; that is treated as +// a developer error (route registered without spec coverage) — callers +// log/fail loudly rather than silently writing a partial 501 body. +func (s *Spec) Operation(method, path string) map[string]any { + paths, _ := s.doc["paths"].(map[string]any) + if paths == nil { + return nil + } + pathItem, _ := paths[path].(map[string]any) + if pathItem == nil { + return nil + } + op, _ := pathItem[strings.ToLower(method)].(map[string]any) + if op == nil { + return nil + } + + // Path-level parameters apply to every method on that path; merge them + // in so the slice is self-contained. + out := make(map[string]any, len(op)+1) + for k, v := range op { + out[k] = v + } + if params, ok := pathItem["parameters"]; ok { + // Prefer the operation's own parameters when both are present; + // otherwise inherit from the path level. + if _, exists := out["parameters"]; !exists { + out["parameters"] = params + } + } + return out +} + +// notImplementedResponse is the wire shape for 501 — APIError envelope +// plus a `spec` field carrying the operation slice. Mirrors the +// NotImplementedResponse schema in openapi.yaml. +type notImplementedResponse struct { + Error string `json:"error"` + Code string `json:"code"` + Message string `json:"message"` + RequestID string `json:"requestId,omitempty"` + Spec map[string]any `json:"spec"` +} + +// NotImplemented writes the locked 501 envelope, embedding the OpenAPI +// Operation slice that documents what this route WILL do. Replaces the +// throwaway PlannedRoute literals that the first cut of the route shell +// duplicated in controller code. +func NotImplemented(w http.ResponseWriter, r *http.Request, method, path string) { + op := Default().Operation(method, path) + if op == nil { + panic(fmt.Sprintf("apispec: missing operation for %s %s", method, path)) + } + body := notImplementedResponse{ + Error: "not_implemented", + Code: "NOT_IMPLEMENTED", + Message: method + " " + path + " is registered but not yet implemented", + RequestID: middleware.GetReqID(r.Context()), + Spec: op, + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusNotImplemented) + // A write error here means the client went away mid-response. + _ = json.NewEncoder(w).Encode(body) +} + +// ServeYAML serves the embedded openapi.yaml document. Mounted at +// /api/v1/openapi.yaml so spec-consuming tooling (#19's validator, +// SDK generators, the dashboard's developer tools) can fetch the +// whole document in one request. +func ServeYAML(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/yaml; charset=utf-8") + _, _ = w.Write(openapiYAML) +} + +// RegisterServe mounts ServeYAML on the supplied router. Kept as a +// helper so the router code only references one symbol from apispec +// for the static serve path. +func RegisterServe(r chi.Router, path string) { + r.Get(path, ServeYAML) +} diff --git a/backend/internal/httpd/apispec/apispec_test.go b/backend/internal/httpd/apispec/apispec_test.go new file mode 100644 index 00000000..b5bde562 --- /dev/null +++ b/backend/internal/httpd/apispec/apispec_test.go @@ -0,0 +1,70 @@ +package apispec_test + +import ( + "net/http/httptest" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" +) + +// TestDefaultLoadsEmbeddedSpec is the smoke test for //go:embed wiring: +// the default Spec must parse the embedded YAML without panicking and +// recognise a known operation. +func TestDefaultLoadsEmbeddedSpec(t *testing.T) { + op := apispec.Default().Operation("GET", "/api/v1/projects") + if op == nil { + t.Fatal("Default().Operation(GET, /api/v1/projects) = nil; embed broken or path missing") + } + if got, _ := op["operationId"].(string); got != "listProjects" { + t.Errorf("operationId = %q, want listProjects", got) + } +} + +// TestOperation_MissingPath returns nil for unknown paths — that's how the +// controller-side test catches "route registered without spec coverage". +func TestOperation_MissingPath(t *testing.T) { + if op := apispec.Default().Operation("GET", "/api/v1/no-such-route"); op != nil { + t.Errorf("unknown path returned %v, want nil", op) + } +} + +// TestOperation_MissingMethod returns nil for known path / unknown method. +func TestOperation_MissingMethod(t *testing.T) { + if op := apispec.Default().Operation("HEAD", "/api/v1/projects"); op != nil { + t.Errorf("HEAD on a GET-only path returned %v, want nil", op) + } +} + +// TestOperation_InheritsPathParameters covers the bit of behaviour that +// would silently rot otherwise: parameters declared at the path level +// (e.g. the {id} path param shared by GET/PATCH/DELETE) must show up on +// every operation's slice so the 501 response is self-contained. +func TestOperation_InheritsPathParameters(t *testing.T) { + op := apispec.Default().Operation("GET", "/api/v1/projects/{id}") + if op == nil { + t.Fatal("expected operation slice") + } + params, ok := op["parameters"].([]any) + if !ok || len(params) == 0 { + t.Fatalf("expected inherited path-level parameters, got %#v", op["parameters"]) + } +} + +// TestServeYAML serves the raw embedded document; tooling fetches it +// whole rather than reconstructing it from per-operation slices. +func TestServeYAML(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/v1/openapi.yaml", nil) + apispec.ServeYAML(rec, req) + + if rec.Code != 200 { + t.Fatalf("status = %d, want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/yaml") { + t.Errorf("Content-Type = %q, want application/yaml*", ct) + } + if !strings.Contains(rec.Body.String(), "openapi: 3.1.0") { + t.Errorf("body did not begin with an OpenAPI 3.1 doc") + } +} diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml new file mode 100644 index 00000000..2b60a3a5 --- /dev/null +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -0,0 +1,446 @@ +openapi: 3.1.0 +info: + title: Agent Orchestrator HTTP daemon + version: 0.1.0-route-shell + description: | + Loopback-only HTTP surface served by the Go daemon. This spec is the + source of truth for every route's contract — the 501 stubs in the + route-shell phase return the matching Operation slice as a `spec` + field, so consumers discover the contract by calling the endpoint + they care about. Real handlers in later PRs satisfy this same spec. + +servers: + - url: http://127.0.0.1:3001 + description: Local daemon (loopback only) + +tags: + - name: projects + description: Project registry, configuration, and lifecycle administration + +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" } + "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" } + + 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" } + responses: + "201": + description: Project registered + content: + application/json: + schema: + type: object + required: [project] + properties: + project: { $ref: "#/components/schemas/Project" } + "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" } } + "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 + responses: + "200": + description: Reload complete + content: + application/json: + schema: { $ref: "#/components/schemas/ReloadResult" } + "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" + get: + operationId: getProject + tags: [projects] + summary: Fetch one project; discriminates ok vs degraded + responses: + "200": + description: Project resolved (status discriminates ok vs degraded) + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectGetResponse" } + "404": { $ref: "#/components/responses/ProjectNotFound" } + "500": + description: Failed to load project + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + example: { error: internal, code: PROJECT_LOAD_FAILED, message: "Failed to load project" } + "501": { $ref: "#/components/responses/NotImplemented" } + x-rest-audit-notes: | + R5: degraded projects return 200 with a `status` discriminator + instead of 200 with an `error` field (as the legacy TS surface did). + Archived projects are hidden from list responses but still resolve by + id so historical sessions can keep their project_id reference. + + patch: + operationId: updateProjectConfig + tags: [projects] + summary: Patch behaviour-only fields (not implemented until config persistence lands) + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/UpdateProjectConfigRequest" } + responses: + "400": + description: Bad request + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + examples: + invalidJson: { value: { error: bad_request, code: INVALID_JSON, message: "Invalid JSON body" } } + identityFrozen: + value: + error: bad_request + code: IDENTITY_FROZEN + message: "Identity fields cannot be patched" + details: { fields: [projectId, path, repo, defaultBranch] } + invalidConfig: { value: { error: bad_request, code: INVALID_LOCAL_CONFIG, message: "Local project config failed validation" } } + "404": { $ref: "#/components/responses/ProjectNotFound" } + "409": + description: Project not in a patchable state + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + examples: + degraded: { value: { error: conflict, code: PROJECT_DEGRADED, message: "Project config is degraded; repair before patching" } } + missingPath: { value: { error: conflict, code: PROJECT_MISSING_PATH, message: "Project registry entry is missing a path" } } + "501": + description: Behaviour config persistence is not wired yet + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + example: { error: not_implemented, code: PROJECT_CONFIG_NOT_IMPLEMENTED, message: "Project config patching is not available until config persistence is wired" } + x-rest-audit-notes: | + R3: legacy `PUT /projects/{id}` (a TS alias of PATCH) is NOT + registered. PUT returns 405 Method Not Allowed. + R6: when config persistence lands this route returns { project }, not + { ok: true }. Until then, config patches return 501 instead of + pretending to persist fields the current project store cannot store. + + delete: + operationId: removeProject + tags: [projects] + summary: Archive a project; hides it from active lists while preserving id references + responses: + "200": + description: Project archived + content: + application/json: + schema: { $ref: "#/components/schemas/RemoveProjectResult" } + "400": + description: Invalid project id + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + example: { error: bad_request, code: INVALID_PROJECT_ID, message: "Project id failed storage-path validation" } + "404": { $ref: "#/components/responses/ProjectNotFound" } + "500": + description: Removal failed + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + example: { error: internal, code: PROJECT_REMOVE_FAILED, message: "Failed to remove project" } + "501": { $ref: "#/components/responses/NotImplemented" } + + /api/v1/projects/{id}/repair: + parameters: + - $ref: "#/components/parameters/ProjectIDPath" + post: + operationId: repairProject + tags: [projects] + summary: Repair a degraded project where automatic recovery is available + x-replaces: + - "POST /api/v1/projects/{id}" + x-rest-audit-notes: | + R4: this canonical path replaces the overloaded + `POST /api/v1/projects/{id}` from the legacy TS surface. + The legacy path is NOT registered; consumers must use /repair. + responses: + "200": + description: Project repaired + content: + application/json: + schema: + type: object + required: [project] + properties: + project: { $ref: "#/components/schemas/Project" } + "400": + description: Bad request + 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" } + +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 } + details: + 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: + type: object + required: [id, name, sessionPrefix] + properties: + id: { type: string } + name: { type: string } + sessionPrefix: { type: string } + resolveError: + type: string + description: Present iff the project is degraded. + + Project: + type: object + required: [id, name, path, repo, defaultBranch] + properties: + id: { type: string } + name: { type: string } + path: { type: string } + repo: + type: string + description: "\"owner/name\" or empty string when unset" + defaultBranch: { type: string, default: main } + agent: { type: string } + runtime: { type: string } + tracker: { $ref: "#/components/schemas/TrackerConfig" } + scm: { $ref: "#/components/schemas/SCMConfig" } + reactions: + type: object + additionalProperties: { $ref: "#/components/schemas/ReactionConfig" } + + DegradedProject: + 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: + status: + type: string + enum: [ok, degraded] + project: + oneOf: + - $ref: "#/components/schemas/Project" + - $ref: "#/components/schemas/DegradedProject" + AddProjectRequest: + type: object + required: [path] + properties: + path: + type: string + description: Repository path; supports ~ home-expansion. Must be a git repo. + projectId: + type: string + description: Optional override; defaults to basename(path). + name: + type: string + description: Optional display name; defaults to projectId. + + UpdateProjectConfigRequest: + type: object + description: | + Behaviour-only patch. Identity fields (projectId, path, repo, + defaultBranch) are rejected with 400 IDENTITY_FROZEN. The current Go + handler returns 501 PROJECT_CONFIG_NOT_IMPLEMENTED until config + persistence exists. + properties: + agent: { type: string } + runtime: { type: string } + tracker: { $ref: "#/components/schemas/TrackerConfig" } + scm: { $ref: "#/components/schemas/SCMConfig" } + reactions: + type: object + additionalProperties: { $ref: "#/components/schemas/ReactionConfig" } + + RemoveProjectResult: + type: object + required: [projectId, removedStorageDir] + properties: + projectId: { type: string } + removedStorageDir: { type: boolean } + + ReloadResult: + type: object + required: [reloaded, projectCount, degradedCount] + properties: + reloaded: { type: boolean } + projectCount: { type: integer } + degradedCount: { type: integer } + + # ---- Behaviour config blobs (ported from the TS Zod schemas) ---- + # These are the known config shapes only. The current Go handler does not + # preserve unknown passthrough keys until config persistence is implemented. + + TrackerConfig: + type: object + additionalProperties: true + properties: + plugin: { type: string } + package: { type: string } + path: { type: string } + + SCMConfig: + type: object + additionalProperties: true + properties: + plugin: { type: string } + package: { type: string } + path: { type: string } + webhook: + type: object + properties: + enabled: { type: boolean } + path: { type: string } + secretEnvVar: { type: string } + signatureHeader: { type: string } + eventHeader: { type: string } + deliveryHeader: { type: string } + maxBodyBytes: { type: integer } + + ReactionConfig: + type: object + properties: + auto: { type: boolean } + action: + type: string + enum: [send-to-agent, notify, auto-merge] + message: { type: string } + priority: + type: string + enum: [urgent, action, warning, info] + retries: { type: integer } + escalateAfter: + oneOf: + - { type: number } + - { type: string } + description: Either ms (number) or duration string ("30m"). + threshold: { type: string } + includeSummary: { type: boolean } diff --git a/backend/internal/httpd/controllers/projects.go b/backend/internal/httpd/controllers/projects.go new file mode 100644 index 00000000..8fa9db1f --- /dev/null +++ b/backend/internal/httpd/controllers/projects.go @@ -0,0 +1,219 @@ +// Package controllers holds the HTTP-facing controllers for the /api/v1 +// surface. Each controller groups one resource's routes, exposes a Register +// method that wires them on a chi.Router, and depends on exactly one +// *Manager interface from ports/inbound.go — never on a store, the LCM, an +// adapter, or any other port. Whether the Manager impl reaches past that +// boundary is its own concern. +// +// In the route-shell PR (#20) every handler is a one-line apispec.NotImplemented +// call: the contract lives in the OpenAPI document (apispec/openapi.yaml), and +// the 501 body returns that document's slice for the route so consumers can +// discover the contract from the endpoint itself. When real handlers land, +// the stub one-liner is replaced with the impl; no per-route planned +// metadata in code ever has to be deleted. +package controllers + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" + "github.com/aoagents/agent-orchestrator/backend/internal/project" +) + +// ProjectsController owns the 7 canonical /projects routes. The controller +// depends ONLY on project.Manager — it doesn't know whether the impl reaches +// into the registry, the LCM, an adapter, or all three. Mgr is nil while +// handlers are stubs; the handler-impl PR supplies a real project.Manager. +type ProjectsController struct { + Mgr project.Manager +} + +// Register mounts the project routes on the supplied router. Route order +// matters: /projects/reload must register before /projects/{id} for the POST +// verb, otherwise chi would treat "reload" as an {id} match for repair. +// +// Legacy paths that the REST audit dropped are deliberately NOT registered +// here. They surface as 405 (sibling method exists, e.g. PUT /projects/{id}) +// or 404 (no sibling). The mapping lives in apispec/openapi.yaml as +// `x-replaces` on the canonical operation so consumers discover the +// migration without leaving the spec. +func (c *ProjectsController) Register(r chi.Router) { + r.Get("/projects", c.list) + r.Post("/projects", c.add) + r.Post("/projects/reload", c.reload) // BEFORE /projects/{id} + r.Get("/projects/{id}", c.get) + r.Patch("/projects/{id}", c.updateConfig) + r.Delete("/projects/{id}", c.remove) + r.Post("/projects/{id}/repair", c.repair) +} + +func (c *ProjectsController) list(w http.ResponseWriter, r *http.Request) { + if c.Mgr == nil { + apispec.NotImplemented(w, r, "GET", "/api/v1/projects") + return + } + projects, err := c.Mgr.List(r.Context()) + if err != nil { + writeProjectError(w, r, err, http.StatusInternalServerError) + return + } + envelope.WriteJSON(w, http.StatusOK, map[string]any{"projects": projects}) +} + +func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) { + if c.Mgr == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/projects") + return + } + var in project.AddInput + if err := decodeJSON(r, &in); err != nil { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) + return + } + p, err := c.Mgr.Add(r.Context(), in) + if err != nil { + writeProjectError(w, r, err, http.StatusInternalServerError) + return + } + envelope.WriteJSON(w, http.StatusCreated, map[string]any{"project": p}) +} + +func (c *ProjectsController) get(w http.ResponseWriter, r *http.Request) { + if c.Mgr == nil { + apispec.NotImplemented(w, r, "GET", "/api/v1/projects/{id}") + return + } + got, err := c.Mgr.Get(r.Context(), projectID(r)) + if err != nil { + writeProjectError(w, r, err, http.StatusInternalServerError) + 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}) +} + +func (c *ProjectsController) updateConfig(w http.ResponseWriter, r *http.Request) { + if c.Mgr == nil { + apispec.NotImplemented(w, r, "PATCH", "/api/v1/projects/{id}") + return + } + if frozen, err := containsFrozenIdentityField(r); err != nil { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) + return + } else if len(frozen) > 0 { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "IDENTITY_FROZEN", "Identity fields cannot be patched", map[string]any{"fields": frozen}) + return + } + + var patch project.UpdateConfigInput + if err := decodeJSON(r, &patch); err != nil { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) + return + } + p, err := c.Mgr.UpdateConfig(r.Context(), projectID(r), patch) + if err != nil { + writeProjectError(w, r, err, http.StatusInternalServerError) + return + } + envelope.WriteJSON(w, http.StatusOK, map[string]any{"project": p}) +} + +func (c *ProjectsController) remove(w http.ResponseWriter, r *http.Request) { + if c.Mgr == nil { + apispec.NotImplemented(w, r, "DELETE", "/api/v1/projects/{id}") + return + } + result, err := c.Mgr.Remove(r.Context(), projectID(r)) + if err != nil { + writeProjectError(w, r, err, http.StatusInternalServerError) + return + } + envelope.WriteJSON(w, http.StatusOK, result) +} + +func (c *ProjectsController) repair(w http.ResponseWriter, r *http.Request) { + if c.Mgr == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/projects/{id}/repair") + return + } + p, err := c.Mgr.Repair(r.Context(), projectID(r)) + if err != nil { + writeProjectError(w, r, err, http.StatusInternalServerError) + return + } + envelope.WriteJSON(w, http.StatusOK, map[string]any{"project": p}) +} + +func (c *ProjectsController) reload(w http.ResponseWriter, r *http.Request) { + if c.Mgr == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/projects/reload") + return + } + result, err := c.Mgr.Reload(r.Context()) + if err != nil { + writeProjectError(w, r, err, http.StatusInternalServerError) + return + } + envelope.WriteJSON(w, http.StatusOK, result) +} + +func projectID(r *http.Request) domain.ProjectID { + return domain.ProjectID(chi.URLParam(r, "id")) +} + +func decodeJSON(r *http.Request, out any) error { + return json.NewDecoder(r.Body).Decode(out) +} + +func containsFrozenIdentityField(r *http.Request) ([]string, error) { + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + r.Body = io.NopCloser(bytes.NewReader(body)) + + var raw map[string]json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + return nil, err + } + var frozen []string + for _, field := range []string{"projectId", "path", "repo", "defaultBranch"} { + if _, ok := raw[field]; ok { + frozen = append(frozen, field) + } + } + return frozen, nil +} + +func writeProjectError(w http.ResponseWriter, r *http.Request, err error, fallbackStatus int) { + var pe *project.Error + if errors.As(err, &pe) { + status := fallbackStatus + switch pe.Kind { + case "bad_request": + status = http.StatusBadRequest + case "not_found": + status = http.StatusNotFound + case "conflict": + status = http.StatusConflict + case "not_implemented": + status = http.StatusNotImplemented + case "internal": + status = http.StatusInternalServerError + } + envelope.WriteAPIError(w, r, status, pe.Kind, pe.Code, pe.Message, pe.Details) + return + } + envelope.WriteAPIError(w, r, fallbackStatus, "internal", "INTERNAL_ERROR", "Internal server error", nil) +} diff --git a/backend/internal/httpd/controllers/projects_test.go b/backend/internal/httpd/controllers/projects_test.go new file mode 100644 index 00000000..4a6d19e1 --- /dev/null +++ b/backend/internal/httpd/controllers/projects_test.go @@ -0,0 +1,311 @@ +package controllers_test + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd" + "github.com/aoagents/agent-orchestrator/backend/internal/project" +) + +func newTestServer(t *testing.T) *httptest.Server { + t.Helper() + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, httpd.APIDeps{ + Projects: project.NewMemoryManager(), + })) + t.Cleanup(srv.Close) + return srv +} + +func TestProjectsRoutes_DefaultToStubsWithoutManager(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + srv := httptest.NewServer(httpd.NewRouter(config.Config{}, log)) + t.Cleanup(srv.Close) + + body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects", "") + assertJSON(t, headers) + assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED") +} + +func TestProjectsAPI_ListAddGetReload(t *testing.T) { + srv := newTestServer(t) + repo := gitRepo(t, "agent-orchestrator") + + body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects", "") + if status != http.StatusOK { + t.Fatalf("GET projects = %d, want 200; body=%s", status, body) + } + assertJSON(t, headers) + var list struct { + Projects []projectSummary `json:"projects"` + } + mustJSON(t, body, &list) + if len(list.Projects) != 0 { + t.Fatalf("initial project count = %d, want 0", len(list.Projects)) + } + + body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repo)+`,"projectId":"ao","name":"Agent Orchestrator"}`) + if status != http.StatusCreated { + t.Fatalf("POST project = %d, want 201; body=%s", status, body) + } + var add struct { + Project projectBody `json:"project"` + } + mustJSON(t, body, &add) + if add.Project.ID != "ao" || add.Project.Name != "Agent Orchestrator" || add.Project.DefaultBranch != "main" { + t.Fatalf("created project = %#v", add.Project) + } + + body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects/ao", "") + if status != http.StatusOK { + t.Fatalf("GET project = %d, want 200; body=%s", status, body) + } + var get struct { + Status string `json:"status"` + Project projectBody `json:"project"` + } + mustJSON(t, body, &get) + if get.Status != "ok" || get.Project.ID != "ao" { + t.Fatalf("get response = %#v", get) + } + + body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects/reload", "") + if status != http.StatusOK { + t.Fatalf("reload = %d, want 200; body=%s", status, body) + } + var reload struct { + Reloaded bool `json:"reloaded"` + ProjectCount int `json:"projectCount"` + DegradedCount int `json:"degradedCount"` + } + mustJSON(t, body, &reload) + if !reload.Reloaded || reload.ProjectCount != 1 || reload.DegradedCount != 0 { + t.Fatalf("reload response = %#v", reload) + } +} + +func TestProjectsAPI_AddValidationAndConflicts(t *testing.T) { + srv := newTestServer(t) + repoA := gitRepo(t, "repo-a") + repoB := gitRepo(t, "repo-b") + notRepo := t.TempDir() + + cases := []struct { + name, body, wantCode string + wantStatus int + }{ + {name: "invalid json", body: `{`, wantStatus: 400, wantCode: "INVALID_JSON"}, + {name: "missing path", body: `{}`, wantStatus: 400, wantCode: "PATH_REQUIRED"}, + {name: "not git", body: `{"path":` + quote(notRepo) + `}`, wantStatus: 400, wantCode: "NOT_A_GIT_REPO"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", tc.body) + assertErrorCode(t, body, status, tc.wantStatus, tc.wantCode) + }) + } + + body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoA)+`,"projectId":"shared"}`) + if status != http.StatusCreated { + t.Fatalf("seed create = %d, want 201; body=%s", status, body) + } + + body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoA)+`,"projectId":"other"}`) + assertErrorCode(t, body, status, http.StatusConflict, "PATH_ALREADY_REGISTERED") + + body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoB)+`,"projectId":"shared"}`) + assertErrorCode(t, body, status, http.StatusConflict, "ID_ALREADY_REGISTERED") +} + +func TestProjectsAPI_UpdateDeleteRepair(t *testing.T) { + srv := newTestServer(t) + repo := gitRepo(t, "repo") + + body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repo)+`,"projectId":"proj"}`) + if status != http.StatusCreated { + t.Fatalf("seed create = %d, want 201; body=%s", status, body) + } + + body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/projects/proj", `{"agent":"claude","runtime":"tmux"}`) + assertErrorCode(t, body, status, http.StatusNotImplemented, "PROJECT_CONFIG_NOT_IMPLEMENTED") + + body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/projects/proj", `{"path":"elsewhere"}`) + assertErrorCode(t, body, status, http.StatusBadRequest, "IDENTITY_FROZEN") + + body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects/proj/repair", "") + assertErrorCode(t, body, status, http.StatusBadRequest, "REPAIR_NOT_AVAILABLE") + + body, status, _ = doRequest(t, srv, "DELETE", "/api/v1/projects/proj", "") + if status != http.StatusOK { + t.Fatalf("DELETE = %d, want 200; body=%s", status, body) + } + var removed struct { + ProjectID string `json:"projectId"` + RemovedStorageDir bool `json:"removedStorageDir"` + } + mustJSON(t, body, &removed) + if removed.ProjectID != "proj" || removed.RemovedStorageDir { + t.Fatalf("delete response = %#v", removed) + } + + body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects/proj", "") + if status != http.StatusOK { + t.Fatalf("GET archived project = %d, want 200; body=%s", status, body) + } + + body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects", "") + if status != http.StatusOK { + t.Fatalf("GET projects after archive = %d, want 200; body=%s", status, body) + } + var list struct { + Projects []projectSummary `json:"projects"` + } + mustJSON(t, body, &list) + if len(list.Projects) != 0 { + t.Fatalf("active projects after archive = %d, want 0", len(list.Projects)) + } +} + +func TestProjectsRoutes_LegacyUnregistered(t *testing.T) { + srv := newTestServer(t) + + cases := []struct { + method, path, wantCode, why string + wantStatus int + }{ + {method: "PUT", path: "/api/v1/projects/p1", wantStatus: 405, wantCode: "METHOD_NOT_ALLOWED", why: "R3 PUT not registered"}, + {method: "POST", path: "/api/v1/projects/p1", wantStatus: 405, wantCode: "METHOD_NOT_ALLOWED", why: "R4 repair moved to /repair"}, + } + + 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) + }) + } +} + +func TestProjectsRoutes_MissingRoute(t *testing.T) { + srv := newTestServer(t) + body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects/p1/does-not-exist", "") + assertJSON(t, headers) + assertErrorCode(t, body, status, http.StatusNotFound, "ROUTE_NOT_FOUND") +} + +func TestOpenAPIYAMLServed(t *testing.T) { + srv := newTestServer(t) + body, status, headers := doRequest(t, srv, "GET", "/api/v1/openapi.yaml", "") + if status != http.StatusOK { + t.Fatalf("status = %d, want 200", status) + } + if ct := headers.Get("Content-Type"); !strings.HasPrefix(ct, "application/yaml") { + t.Errorf("Content-Type = %q, want application/yaml*", ct) + } + if !strings.Contains(string(body), "openapi: 3.1.0") { + t.Errorf("served body did not start with an OpenAPI 3.1 doc") + } +} + +type projectSummary struct { + ID string `json:"id"` + Name string `json:"name"` + SessionPrefix string `json:"sessionPrefix"` +} + +type projectBody struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Repo string `json:"repo"` + DefaultBranch string `json:"defaultBranch"` + Agent string `json:"agent"` + Runtime string `json:"runtime"` +} + +type errorBody struct { + Error string `json:"error"` + Code string `json:"code"` + Message string `json:"message"` + Details map[string]any `json:"details"` +} + +func doRequest(t *testing.T, srv *httptest.Server, method, path, body string) ([]byte, int, http.Header) { + t.Helper() + var req *http.Request + var err error + if body != "" { + req, err = http.NewRequest(method, srv.URL+path, strings.NewReader(body)) + } else { + req, err = http.NewRequest(method, srv.URL+path, nil) + } + if err != nil { + t.Fatalf("new request: %v", err) + } + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + defer resp.Body.Close() + buf, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + return buf, resp.StatusCode, resp.Header +} + +func gitRepo(t *testing.T, name string) string { + t.Helper() + dir := filepath.Join(t.TempDir(), name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("create git repo fixture: %v", err) + } + if out, err := exec.Command("git", "init", dir).CombinedOutput(); err != nil { + t.Fatalf("git init fixture: %v\n%s", err, out) + } + return dir +} + +func quote(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + +func mustJSON(t *testing.T, body []byte, out any) { + t.Helper() + if err := json.Unmarshal(body, out); err != nil { + t.Fatalf("unmarshal: %v\nbody=%s", err, body) + } +} + +func assertJSON(t *testing.T, headers http.Header) { + t.Helper() + if ct := headers.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Fatalf("Content-Type = %q, want JSON", ct) + } +} + +func assertErrorCode(t *testing.T, body []byte, status, wantStatus int, wantCode string) { + t.Helper() + if status != wantStatus { + t.Fatalf("status = %d, want %d\nbody=%s", status, wantStatus, body) + } + var got errorBody + mustJSON(t, body, &got) + if got.Code != wantCode { + t.Fatalf("code = %q, want %q\nbody=%s", got.Code, wantCode, body) + } +} diff --git a/backend/internal/httpd/envelope/envelope.go b/backend/internal/httpd/envelope/envelope.go new file mode 100644 index 00000000..3e1b2ade --- /dev/null +++ b/backend/internal/httpd/envelope/envelope.go @@ -0,0 +1,35 @@ +package envelope + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5/middleware" +) + +// APIError is the locked wire shape for every non-2xx response. +type APIError struct { + Error string `json:"error"` + Code string `json:"code"` + Message string `json:"message"` + RequestID string `json:"requestId,omitempty"` + Details map[string]any `json:"details,omitempty"` +} + +// WriteJSON serialises v as JSON with the given status. +func WriteJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// WriteAPIError emits the locked envelope for any non-2xx response. +func WriteAPIError(w http.ResponseWriter, r *http.Request, status int, kind, code, message string, details map[string]any) { + WriteJSON(w, status, APIError{ + Error: kind, + Code: code, + Message: message, + RequestID: middleware.GetReqID(r.Context()), + Details: details, + }) +} diff --git a/backend/internal/httpd/errors.go b/backend/internal/httpd/errors.go new file mode 100644 index 00000000..8b41c99f --- /dev/null +++ b/backend/internal/httpd/errors.go @@ -0,0 +1,22 @@ +package httpd + +import ( + "net/http" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" +) + +// APIError is the locked wire shape for every non-2xx response. It supersedes +// the legacy TS `{error: "msg"}` bag with a machine-readable Code and a +// RequestID for log correlation (sourced from chi's RequestID middleware). +// +// Details is open so collision-style errors can carry typed sub-fields +// (e.g. existingProjectId, suggestedProjectId on POST /projects 409s). +type APIError = envelope.APIError + +// writeAPIError emits the locked envelope for any non-2xx response. The +// request id falls back to empty when the chi middleware hasn't tagged the +// request (e.g. in tests that bypass NewRouter). +func writeAPIError(w http.ResponseWriter, r *http.Request, status int, kind, code, message string, details map[string]any) { + envelope.WriteAPIError(w, r, status, kind, code, message, details) +} diff --git a/backend/internal/httpd/json.go b/backend/internal/httpd/json.go index 9b87461f..64ccb340 100644 --- a/backend/internal/httpd/json.go +++ b/backend/internal/httpd/json.go @@ -1,17 +1,14 @@ package httpd import ( - "encoding/json" "net/http" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" ) // writeJSON serialises v as JSON with the given status. It is the single JSON // writer for the skeleton; the typed error envelope (open item Q1.3) will build // on this in a later phase. func writeJSON(w http.ResponseWriter, status int, v any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(status) - // A write error here means the client went away mid-response; there is - // nothing useful to do but stop. - _ = json.NewEncoder(w).Encode(v) + envelope.WriteJSON(w, status, v) } diff --git a/backend/internal/httpd/router.go b/backend/internal/httpd/router.go index 6e078b8d..1a076c61 100644 --- a/backend/internal/httpd/router.go +++ b/backend/internal/httpd/router.go @@ -30,6 +30,13 @@ import ( // probes. It is therefore applied per-surface when those subrouters are mounted // in Phase 1b; cfg.RequestTimeout carries the value through to that point. func NewRouter(cfg config.Config, log *slog.Logger) chi.Router { + return NewRouterWithAPI(cfg, log, APIDeps{}) +} + +// NewRouterWithAPI is the dependency-injected variant. main.go calls it with +// real Managers when they exist; tests/dev wiring inject mocks explicitly. +// Missing Managers intentionally keep the route-shell 501 behavior. +func NewRouterWithAPI(cfg config.Config, log *slog.Logger, deps APIDeps) chi.Router { r := chi.NewRouter() r.Use(middleware.Recoverer) @@ -37,7 +44,14 @@ func NewRouter(cfg config.Config, log *slog.Logger) chi.Router { r.Use(requestLogger(log)) r.Use(middleware.RealIP) + // JSON envelopes for unmatched routes / methods — chi's defaults are + // text/plain, which would break consumers that parse every response as + // the locked APIError shape. + r.NotFound(notFoundJSON) + r.MethodNotAllowed(methodNotAllowedJSON) + mountHealth(r) + NewAPI(cfg, deps).Register(r) return r } diff --git a/backend/internal/project/dto.go b/backend/internal/project/dto.go new file mode 100644 index 00000000..0e6f5ee5 --- /dev/null +++ b/backend/internal/project/dto.go @@ -0,0 +1,55 @@ +package project + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// Request/response shapes for Manager. They carry the data across the service +// boundary; the entities they reference (Project, Summary, Degraded) live in +// types.go in this same package. Named without a "Project" prefix because the +// package name already supplies it (project.AddInput, project.GetResult). + +// GetResult is the discriminated union returned by Manager.Get. Exactly one of +// Project / Degraded is non-nil; Status mirrors the discriminator on the wire +// so consumers branch on it without nil-checking both fields. +type GetResult struct { + Status string // "ok" | "degraded" + Project *Project // populated when Status == "ok" + Degraded *Degraded // populated when Status == "degraded" +} + +// AddInput is the body shape for POST /api/v1/projects. Path is required; +// ProjectID and Name default to basename(path) at the manager. Pointer fields +// preserve the "field absent" vs "field present empty" distinction so the +// manager can decide what to default and what to reject. +type AddInput struct { + Path string `json:"path"` + ProjectID *string `json:"projectId,omitempty"` + Name *string `json:"name,omitempty"` +} + +// UpdateConfigInput is the body shape for PATCH /api/v1/projects/{id}. Only +// behaviour fields are mutable; identity fields (projectId, path, repo, +// defaultBranch) are rejected by the handler with a 400 IDENTITY_FROZEN. +type UpdateConfigInput struct { + Agent *string `json:"agent,omitempty"` + Runtime *string `json:"runtime,omitempty"` + Tracker *TrackerConfig `json:"tracker,omitempty"` + SCM *SCMConfig `json:"scm,omitempty"` + Reactions *map[string]*ReactionConfig `json:"reactions,omitempty"` +} + +// RemoveResult reports what DELETE /api/v1/projects/{id} actually did. +// RemovedStorageDir is false when the project was registry-only (no on-disk +// session/workspace directory existed). +type RemoveResult struct { + ProjectID domain.ProjectID `json:"projectId"` + RemovedStorageDir bool `json:"removedStorageDir"` +} + +// ReloadResult is the response body of POST /api/v1/projects/reload — the +// manager invalidates its cached config and re-scans the registry; the counts +// help the dashboard show "loaded N projects, M degraded" feedback. +type ReloadResult struct { + Reloaded bool `json:"reloaded"` + ProjectCount int `json:"projectCount"` + DegradedCount int `json:"degradedCount"` +} diff --git a/backend/internal/project/errors.go b/backend/internal/project/errors.go new file mode 100644 index 00000000..f6687e1f --- /dev/null +++ b/backend/internal/project/errors.go @@ -0,0 +1,41 @@ +package project + +// Error is the manager-level error shape controllers can translate into the +// locked HTTP APIError envelope without knowing store internals. +type Error struct { + Kind string + Code string + Message string + Details map[string]any +} + +func (e *Error) Error() string { + if e == nil { + return "" + } + return e.Message +} + +func newError(kind, code, message string, details map[string]any) *Error { + return &Error{Kind: kind, Code: code, Message: message, Details: details} +} + +func badRequest(code, message string, details map[string]any) *Error { + return newError("bad_request", code, message, details) +} + +func notFound(code, message string) *Error { + return newError("not_found", code, message, nil) +} + +func conflict(code, message string, details map[string]any) *Error { + return newError("conflict", code, message, details) +} + +func notImplemented(code, message string) *Error { + return newError("not_implemented", code, message, nil) +} + +func internal(code, message string) *Error { + return newError("internal", code, message, nil) +} diff --git a/backend/internal/project/manager.go b/backend/internal/project/manager.go new file mode 100644 index 00000000..93ca84d9 --- /dev/null +++ b/backend/internal/project/manager.go @@ -0,0 +1,264 @@ +package project + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +type manager struct { + store Store +} + +var _ Manager = (*manager)(nil) + +func NewManager(store Store) Manager { + if store == nil { + store = NewMemoryStore() + } + return &manager{store: store} +} + +func NewMemoryManager() Manager { + return NewManager(NewMemoryStore()) +} + +func (m *manager) List(ctx context.Context) ([]Summary, error) { + projects, err := m.store.List(ctx) + if err != nil { + return nil, internal("PROJECTS_LIST_FAILED", "Failed to load projects") + } + out := make([]Summary, 0, len(projects)) + for _, row := range projects { + out = append(out, Summary{ + ID: domain.ProjectID(row.ID), + Name: displayName(row), + SessionPrefix: sessionPrefix(row.ID), + }) + } + return out, nil +} + +func (m *manager) Get(ctx context.Context, id domain.ProjectID) (GetResult, error) { + if err := validateProjectID(id); err != nil { + return GetResult{}, err + } + row, ok, err := m.store.Get(ctx, string(id)) + if err != nil { + return GetResult{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") + } + if !ok { + return GetResult{}, notFound("PROJECT_NOT_FOUND", "Unknown project") + } + p := projectFromRow(row) + return GetResult{Status: "ok", Project: &p}, nil +} + +func (m *manager) Add(ctx context.Context, in AddInput) (Project, error) { + path, err := normalizePath(in.Path) + if err != nil { + return Project{}, err + } + if !isGitRepo(path) { + return Project{}, badRequest("NOT_A_GIT_REPO", "Repository path must point to a git repository", nil) + } + + id := defaultProjectID(path) + if in.ProjectID != nil { + id = domain.ProjectID(strings.TrimSpace(*in.ProjectID)) + } + if err := validateProjectID(id); err != nil { + return Project{}, err + } + + name := string(id) + if in.Name != nil { + name = strings.TrimSpace(*in.Name) + } + if name == "" { + name = string(id) + } + + if existing, ok, err := m.store.FindByPath(ctx, path); err != nil { + return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") + } else if ok { + return Project{}, conflict("PATH_ALREADY_REGISTERED", "A project at this path is already registered", map[string]any{ + "existingProjectId": existing.ID, + "suggestedProjectId": string(m.suggestID(ctx, id)), + }) + } + if existing, ok, err := m.store.Get(ctx, string(id)); err != nil { + return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") + } else if ok && existing.Path != path { + return Project{}, conflict("ID_ALREADY_REGISTERED", "A project with this id is already registered for a different path", map[string]any{ + "existingProjectId": existing.ID, + "suggestedProjectId": string(m.suggestID(ctx, id)), + }) + } + + row := ProjectRow{ + ID: string(id), + Path: path, + DisplayName: name, + RegisteredAt: time.Now(), + } + if err := m.store.Upsert(ctx, row); err != nil { + return Project{}, err + } + return projectFromRow(row), nil +} + +func (m *manager) UpdateConfig(ctx context.Context, id domain.ProjectID, _ UpdateConfigInput) (Project, error) { + if err := validateProjectID(id); err != nil { + return Project{}, err + } + _, ok, err := m.store.Get(ctx, string(id)) + if err != nil { + return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") + } + if !ok { + return Project{}, notFound("PROJECT_NOT_FOUND", "Unknown project") + } + + return Project{}, notImplemented("PROJECT_CONFIG_NOT_IMPLEMENTED", "Project config patching is not available until config persistence is wired") +} + +func (m *manager) Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) { + if err := validateProjectID(id); err != nil { + return RemoveResult{}, err + } + ok, err := m.store.Archive(ctx, string(id), time.Now()) + if err != nil { + return RemoveResult{}, internal("PROJECT_REMOVE_FAILED", "Failed to remove project") + } + if !ok { + return RemoveResult{}, notFound("PROJECT_NOT_FOUND", "Unknown project") + } + return RemoveResult{ProjectID: id, RemovedStorageDir: false}, nil +} + +func (m *manager) Repair(ctx context.Context, id domain.ProjectID) (Project, error) { + if err := validateProjectID(id); err != nil { + return Project{}, err + } + if _, ok, err := m.store.Get(ctx, string(id)); err != nil { + return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") + } else if !ok { + return Project{}, notFound("PROJECT_NOT_FOUND", "Unknown project") + } + return Project{}, badRequest("REPAIR_NOT_AVAILABLE", "Automatic repair is not available for this degraded config", nil) +} + +func (m *manager) Reload(ctx context.Context) (ReloadResult, error) { + projects, err := m.store.List(ctx) + if err != nil { + return ReloadResult{}, internal("RELOAD_FAILED", "Failed to reload projects") + } + return ReloadResult{Reloaded: true, ProjectCount: len(projects), DegradedCount: 0}, nil +} + +func (m *manager) suggestID(ctx context.Context, base domain.ProjectID) domain.ProjectID { + for i := 1; ; i++ { + candidate := domain.ProjectID(string(base) + strconv.Itoa(i)) + if _, ok, _ := m.store.Get(ctx, string(candidate)); !ok { + return candidate + } + } +} + +func projectFromRow(row ProjectRow) Project { + return Project{ + ID: domain.ProjectID(row.ID), + Name: displayName(row), + Path: row.Path, + Repo: row.RepoOriginURL, + DefaultBranch: "main", + } +} + +func displayName(row ProjectRow) string { + if strings.TrimSpace(row.DisplayName) != "" { + return row.DisplayName + } + return row.ID +} + +func normalizePath(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", badRequest("PATH_REQUIRED", "Repository path is required", nil) + } + if strings.HasPrefix(raw, "~") { + home, err := os.UserHomeDir() + if err != nil { + return "", badRequest("INVALID_PATH", "Repository path could not be expanded", nil) + } + if raw == "~" { + raw = home + } else if strings.HasPrefix(raw, "~/") || strings.HasPrefix(raw, `~\`) { + raw = filepath.Join(home, raw[2:]) + } + } + abs, err := filepath.Abs(raw) + if err != nil { + return "", badRequest("INVALID_PATH", "Repository path is invalid", nil) + } + return filepath.Clean(abs), nil +} + +func isGitRepo(path string) bool { + cmd := exec.Command("git", "-C", path, "rev-parse", "--show-toplevel") + out, err := cmd.Output() + if err != nil { + return false + } + top := filepath.Clean(strings.TrimSpace(string(out))) + path = filepath.Clean(path) + top, err = filepath.EvalSymlinks(top) + if err != nil { + return false + } + path, err = filepath.EvalSymlinks(path) + if err != nil { + return false + } + + if strings.EqualFold(top, path) { + return true + } + return top == path +} + +func defaultProjectID(path string) domain.ProjectID { + id := strings.ToLower(filepath.Base(path)) + id = strings.TrimSpace(id) + id = strings.ReplaceAll(id, " ", "-") + return domain.ProjectID(id) +} + +var projectIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +func validateProjectID(id domain.ProjectID) error { + raw := string(id) + if raw == "" || raw == "." || raw == ".." || strings.ContainsAny(raw, `/\`) || !projectIDPattern.MatchString(raw) { + return badRequest("INVALID_PROJECT_ID", "Project id failed storage-path validation", nil) + } + return nil +} + +func sessionPrefix(id string) string { + if id == "" { + return "ao" + } + if len(id) <= 12 { + return id + } + return id[:12] +} diff --git a/backend/internal/project/memory_store.go b/backend/internal/project/memory_store.go new file mode 100644 index 00000000..945a7826 --- /dev/null +++ b/backend/internal/project/memory_store.go @@ -0,0 +1,108 @@ +package project + +import ( + "context" + "sync" + "time" +) + +// ProjectRow mirrors the project table shape from the sqlite storage PR. The +// memory store is intentionally row-based so the API layer does not depend on a +// richer mock model than the real DB will provide. +type ProjectRow struct { + ID string + Path string + RepoOriginURL string + DisplayName string + RegisteredAt time.Time + ArchivedAt time.Time +} + +type Store interface { + List(ctx context.Context) ([]ProjectRow, error) + Get(ctx context.Context, id string) (ProjectRow, bool, error) + FindByPath(ctx context.Context, path string) (ProjectRow, bool, error) + Upsert(ctx context.Context, row ProjectRow) error + Archive(ctx context.Context, id string, at time.Time) (bool, error) +} + +// MemoryStore is the mocked DB layer for the project API implementation. It is +// process-local and intentionally small, but concurrency-safe for HTTP tests. +type MemoryStore struct { + mu sync.Mutex + projects map[string]ProjectRow + paths map[string]string +} + +var _ Store = (*MemoryStore)(nil) + +func NewMemoryStore() *MemoryStore { + return &MemoryStore{ + projects: map[string]ProjectRow{}, + paths: map[string]string{}, + } +} + +func (s *MemoryStore) List(context.Context) ([]ProjectRow, error) { + s.mu.Lock() + defer s.mu.Unlock() + + out := make([]ProjectRow, 0, len(s.projects)) + for _, row := range s.projects { + if row.ArchivedAt.IsZero() { + out = append(out, row) + } + } + return out, nil +} + +func (s *MemoryStore) Get(_ context.Context, id string) (ProjectRow, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + row, ok := s.projects[id] + if !ok { + return ProjectRow{}, false, nil + } + return row, true, nil +} + +func (s *MemoryStore) FindByPath(_ context.Context, path string) (ProjectRow, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + id, ok := s.paths[path] + if !ok { + return ProjectRow{}, false, nil + } + row, ok := s.projects[id] + if !ok { + return ProjectRow{}, false, nil + } + return row, true, nil +} + +func (s *MemoryStore) Upsert(_ context.Context, row ProjectRow) error { + s.mu.Lock() + defer s.mu.Unlock() + + if existing, ok := s.projects[row.ID]; ok && existing.Path != row.Path { + delete(s.paths, existing.Path) + } + s.projects[row.ID] = row + s.paths[row.Path] = row.ID + return nil +} + +func (s *MemoryStore) Archive(_ context.Context, id string, at time.Time) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + row, ok := s.projects[id] + if !ok { + return false, nil + } + row.ArchivedAt = at + s.projects[id] = row + return true, nil +} diff --git a/backend/internal/project/project.go b/backend/internal/project/project.go new file mode 100644 index 00000000..a997519d --- /dev/null +++ b/backend/internal/project/project.go @@ -0,0 +1,44 @@ +// Package project owns the projects service contract: the Manager interface +// the HTTP layer calls and the request/response DTOs that cross it (dto.go). +// +// This is the pilot for the feature-package layout the backend is migrating +// toward: a resource's interface and DTOs live with the resource, not in a +// central catch-all. Controllers depend on project.Manager and nothing +// beneath it — whether the implementation reaches into the config registry, +// the lifecycle manager (to stop sessions on remove), or a workspace adapter +// (to destroy worktrees) is a private concern of the impl, which lands in a +// later handler-impl PR. This PR defines only the contract. +package project + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// Manager is the inbound contract for the /api/v1/projects surface. One +// implementation (this package, later); the HTTP controller is the consumer. +type Manager interface { + // List returns every registered project, including degraded entries + // (those whose config failed to load but whose registry entry survives). + List(ctx context.Context) ([]Summary, error) + + // Get returns one project, discriminating ok vs degraded via GetResult. + Get(ctx context.Context, id domain.ProjectID) (GetResult, error) + + // Add registers a new project from a git repository path. + Add(ctx context.Context, in AddInput) (Project, error) + + // UpdateConfig patches behaviour-only fields; identity fields are frozen. + UpdateConfig(ctx context.Context, id domain.ProjectID, patch UpdateConfigInput) (Project, error) + + // Remove unregisters a project, stopping its sessions and reclaiming + // managed workspaces. + Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) + + // Repair recovers a degraded project where automatic repair is available. + Repair(ctx context.Context, id domain.ProjectID) (Project, error) + + // Reload invalidates cached config and re-scans the global registry. + Reload(ctx context.Context) (ReloadResult, error) +} diff --git a/backend/internal/project/types.go b/backend/internal/project/types.go new file mode 100644 index 00000000..65e5daa2 --- /dev/null +++ b/backend/internal/project/types.go @@ -0,0 +1,96 @@ +package project + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// Project entities and the behaviour-config shapes they expose. These live in +// the project package (not domain/) because they are owned solely by the +// projects surface — only project identity (domain.ProjectID) is shared +// vocabulary with sessions/lifecycle/workspace, so that one type stays in +// domain. Keeping the entities, the Manager interface (project.go), and the +// transport DTOs (dto.go) together is the feature-package layout the backend +// is migrating toward. + +// Summary is the row shape returned by GET /api/v1/projects. It mirrors the TS +// ProjectInfo (packages/web/src/lib/project-name.ts) so the existing dashboard +// list view reads the Go daemon's response unchanged. ResolveError is set only +// for degraded projects (registry entry survives but config failed to load), +// so the list shows them with a warning instead of dropping them silently. +type Summary struct { + ID domain.ProjectID `json:"id"` + Name string `json:"name"` + SessionPrefix string `json:"sessionPrefix"` + ResolveError string `json:"resolveError,omitempty"` +} + +// Project is the full read-model returned by GET /api/v1/projects/{id} when the +// project resolves cleanly. It joins the registry identity fields with the +// project's behaviour config. +type Project struct { + ID domain.ProjectID `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Repo string `json:"repo"` // "owner/name" or "" + DefaultBranch string `json:"defaultBranch"` + Agent string `json:"agent,omitempty"` + Runtime string `json:"runtime,omitempty"` + Tracker *TrackerConfig `json:"tracker,omitempty"` + SCM *SCMConfig `json:"scm,omitempty"` + Reactions map[string]*ReactionConfig `json:"reactions,omitempty"` +} + +// Degraded is returned in place of Project when the project's config failed to +// load. The frontend uses ResolveError to render a recovery UI; the +// /projects/{id}/repair endpoint fixes a recoverable subset (e.g. legacy +// wrapped-config format). +type Degraded struct { + ID domain.ProjectID `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + ResolveError string `json:"resolveError"` +} + +// Behaviour-config shapes ported from the TS Zod schemas (packages/core/src/ +// config.ts). Only the fields the projects API actually exposes are modelled; +// the passthrough/unknown-key round-trip the legacy schemas allowed lands with +// the handler implementation (and the SQLite persistence work), not in this +// interface-only PR. + +// TrackerConfig mirrors TrackerConfigSchema. +type TrackerConfig struct { + Plugin string `json:"plugin,omitempty"` + Package string `json:"package,omitempty"` + Path string `json:"path,omitempty"` +} + +// SCMConfig mirrors SCMConfigSchema; Webhook nests its own optional block. +type SCMConfig struct { + Plugin string `json:"plugin,omitempty"` + Package string `json:"package,omitempty"` + Path string `json:"path,omitempty"` + Webhook *SCMWebhookConfig `json:"webhook,omitempty"` +} + +// SCMWebhookConfig — pointer Enabled distinguishes unset from explicit false. +type SCMWebhookConfig struct { + Enabled *bool `json:"enabled,omitempty"` + Path string `json:"path,omitempty"` + SecretEnvVar string `json:"secretEnvVar,omitempty"` + SignatureHeader string `json:"signatureHeader,omitempty"` + EventHeader string `json:"eventHeader,omitempty"` + DeliveryHeader string `json:"deliveryHeader,omitempty"` + MaxBodyBytes int `json:"maxBodyBytes,omitempty"` +} + +// ReactionConfig mirrors ReactionConfigSchema. EscalateAfter is either ms +// (number) or a duration string ("30m") in the TS schema, so it stays open as +// `any` until handler validation lands. +type ReactionConfig struct { + Auto *bool `json:"auto,omitempty"` + Action string `json:"action,omitempty"` // send-to-agent | notify | auto-merge + Message string `json:"message,omitempty"` + Priority string `json:"priority,omitempty"` // urgent | action | warning | info + Retries *int `json:"retries,omitempty"` + EscalateAfter any `json:"escalateAfter,omitempty"` + Threshold string `json:"threshold,omitempty"` + IncludeSummary *bool `json:"includeSummary,omitempty"` +} diff --git a/backend/internal/runfile/rename_windows.go b/backend/internal/runfile/rename_windows.go index 031411ee..70f5d1de 100644 --- a/backend/internal/runfile/rename_windows.go +++ b/backend/internal/runfile/rename_windows.go @@ -2,13 +2,18 @@ package runfile -import "syscall" +import ( + "syscall" + "unsafe" +) // movefileReplaceExisting tells MoveFileEx to overwrite dst if it already // exists. Mirrors MOVEFILE_REPLACE_EXISTING from the Win32 API; declared // locally so we don't pull in golang.org/x/sys for a single constant. const movefileReplaceExisting = 0x1 +var moveFileExW = syscall.NewLazyDLL("kernel32.dll").NewProc("MoveFileExW") + // atomicReplace renames src to dst, replacing dst if it exists. Go's // os.Rename on Windows happens to do the same MoveFileEx call internally, // but calling it directly makes the cross-platform contract explicit instead @@ -24,5 +29,13 @@ func atomicReplace(src, dst string) error { if err != nil { return err } - return syscall.MoveFileEx(srcPtr, dstPtr, movefileReplaceExisting) + ret, _, err := moveFileExW.Call( + uintptr(unsafe.Pointer(srcPtr)), + uintptr(unsafe.Pointer(dstPtr)), + uintptr(movefileReplaceExisting), + ) + if ret == 0 { + return err + } + return nil }