From 9a10eacc3984387701e486f080ea505cd8e427f3 Mon Sep 17 00:00:00 2001 From: Vaibhaav Tiwari <155460282+Vaibhaav-Tiwari@users.noreply.github.com> Date: Sun, 31 May 2026 20:31:22 +0530 Subject: [PATCH] feat(api): implement project routes with mock manager/store (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(backend): HTTP daemon skeleton — config, health, runfile, graceful shutdown (#10) Phase 1a of the Go HTTP daemon lane (#10). Stands up the loopback-only sidecar skeleton the later REST/SSE/WS/static surfaces build on: - config: env-driven (AO_HOST/PORT/ENV/timeouts/run-file) with zero-config defaults; binds 127.0.0.1:3001; validates and fails fast on bad input. - httpd: chi router with the recoverer → request-id → logger → real-ip middleware stack and /healthz + /readyz probes. Per-request timeout is carried in config but intentionally not global — it scopes to /api/v1 in Phase 1b so it never throttles SSE/WS/health. - runfile: atomic PID + port handshake (running.json) for the Electron supervisor, with a dead-PID stale check so a crashed predecessor doesn't block startup while a live one fails fast. - server: bind-before-publish (port conflict fails fast), graceful shutdown on SIGINT/SIGTERM via signal.NotifyContext with a 10s hard timeout, and run-file cleanup on exit. Why: the daemon must be safely supervisable as a child process — the supervisor needs a discoverable PID/port and the daemon must not leave a half-started process or stale handshake behind. Locking the lifecycle down now keeps the future port split a small change rather than a rewrite. Tests cover config defaults/overrides/validation, run-file round-trip and live/dead PID detection, health probes, full Run lifecycle, and port-conflict fail-fast. Co-Authored-By: Claude Opus 4.7 * refactor(backend): drop Env config field — not needed yet (#10) Per review on #14: AO_ENV / Config.Env / IsProduction() weren't load-bearing for Phase 1a — they only switched the slog handler. Removing them now keeps the surface minimal; the env knob can come back later when a real consumer needs it. - config: remove Env field, AO_ENV parsing, and IsProduction helper. - main: collapse newLogger to a single text-handler path. - httpd: drop the env field from the listening log line. - tests: drop the env assertions and AO_ENV fixture. Co-Authored-By: Claude Opus 4.7 * docs: add backend run + config quick-start to README (#10) Co-Authored-By: Claude Opus 4.7 * fix(backend): address Phase 1a review comments (#10) - config: drop AO_HOST entirely — the daemon is loopback-only by design, so making the bind host env-configurable was a security footgun - config: use net.JoinHostPort in Addr() so IPv6 literals stay valid - config: reject zero/negative AO_REQUEST_TIMEOUT and AO_SHUTDOWN_TIMEOUT (time.ParseDuration accepts both; either would silently break the daemon — instant request expiry / no graceful drain) - runfile: split processAlive into unix/windows build-tagged files so liveness detection is reliable on both platforms (Windows uses OpenProcess; POSIX keeps signal 0) - runfile: document os.Rename overwrite semantics (atomic on POSIX, REPLACE_EXISTING on Windows) so the temp-then-rename pattern's cross-platform behaviour is explicit - httpd tests: give probe/waitForHealth clients an explicit per-request timeout so a stalled connect can't hang the test on the outer deadline * fix(backend): strip trailing blank line from runfile.go (#10) gofmt CI was failing because removing the orphan processAlive doc comment left an extra newline at EOF. * fix(backend): cross-platform run-file replace + AO_HOST rationale (#10) - runfile: introduce build-tagged atomicReplace — POSIX rename(2) on Unix, MoveFileEx with MOVEFILE_REPLACE_EXISTING on Windows. The Go runtime happens to do the Windows call internally already, but invoking it directly makes the cross-platform contract explicit instead of a runtime implementation detail - runfile: tighten process_unix.go build tag from `!windows` to `unix` so plan9/js/wasm fail to build rather than silently using a broken signal-0 probe - runfile: add TestWriteOverwritesExisting covering the stale run-file replace path that none of the previous tests exercised - config: anchor the loopback-only decision in the LoopbackHost doc so the next contributor doesn't reintroduce AO_HOST without the security rationale * fix(backend): route chi access logs through slog/stderr (#10) chi's middleware.Logger writes via stdlib log to stdout, but the daemon's slog logger writes to stderr — so REST traffic and daemon logs landed on different streams in different formats. Replace it with a small slog-backed requestLogger that: - Wraps the response writer via middleware.NewWrapResponseWriter so status/bytes are accurate even when handlers return without an explicit WriteHeader. - Reads the request id off the context set by middleware.RequestID (kept mounted just before this middleware so the id is available). - Emits one structured Info line per request with method, path, status, bytes, duration, and remote — same key=value shape as the rest of the daemon, one stream for the Electron supervisor to capture. * feat(api): projects route shell (7 routes, REST-corrected) — #20 Mounts the /api/v1 surface on the skeleton router (#10·1a) and registers the 7 canonical project routes as 501 stubs that emit a structured PlannedRoute body documenting the future contract. Shared scaffolding landed here (api.go, errors.go, stubs/, controllers/) so #21/#22 plug in without re-touching the wiring. WHY: opens the route-shell PRs in the Go HTTP daemon lane. Doing it interface-first lets the dashboard team build against the contract before any handler logic exists; the locked APIError envelope and PlannedRoute shape become #19's OpenAPI source-of-truth. REST audit corrections vs the legacy TS surface: R3 PUT /projects/:id alias of PATCH: PUT not registered → 405. R4 POST /projects/:id repair overload: canonical /repair; legacy 405. R5 degraded GET returns 200 with error field: discriminator status. R6 ok/success flag flips: drop on 2xx; return affected resource. R9 bare {error: msg}: locked {error,code,message,requestId,details?}. Legacy paths are deliberately NOT registered; each canonical handler carries PlannedRoute.Legacy so consumers can discover the migration. Zod schemas (TrackerConfig, SCMConfig, AgentConfig, ReactionConfig, LocalProjectConfig, RoleAgentConfig) ported to typed Go structs with an Extra map reserved for .passthrough() round-tripping in later PRs. Closes part of #18; targets feat/issue-10 until #14 merges. * refactor(api): collapse ProjectService → ProjectManager — #20 Controllers now depend on ONE inbound interface per resource — ports.ProjectManager — mirroring the existing ports.SessionManager + LifecycleManager pattern. Whether the manager impl reaches into the registry, the LCM, an outbound port, or all three is its own concern; the HTTP layer no longer has to know any of that. WHY: the original split named the boundary type "ProjectService" and put it in a sibling services.go. That implied a second category of port distinct from inbound.go's *Manager interfaces, even though they play the same role (things HTTP/CLI call into the core). Per review feedback, collapse them onto one Manager-per-resource pattern. Mechanical changes: - ports/inbound.go gains ProjectManager next to SessionManager. - ports/services.go renamed to projects.go; keeps only the DTOs the ProjectManager methods take/return. - ProjectsController.Svc renamed to Mgr; APIDeps.Projects type bumped to ports.ProjectManager. All tests pass unchanged; no behavioural change. * refactor(api): replace stubs/ with OpenAPI-as-source-of-truth — #20 The first cut of the route shell duplicated each route's contract twice: once as a Go literal (stubs.PlannedRoute{...}) in the controller, and implicitly in the PR description. The Go literal was ~230 LoC of pure throwaway that would be deleted in handler-impl PRs. This commit eliminates the duplication: - backend/internal/httpd/apispec/openapi.yaml: full OpenAPI 3.1 doc covering the 7 project routes + shared schemas (Project, APIError, config types). x-replaces records the legacy → canonical mapping REST-audit corrections produced. - apispec/apispec.go: //go:embed the YAML, expose Operation(method, path) → the spec slice as a map, NotImplemented(w, r, method, path) → 501 with that slice embedded as `spec`. - controllers/projects.go: each of 7 handlers is now a one-liner: apispec.NotImplemented(w, r, "GET", "/api/v1/projects"). - /api/v1/openapi.yaml serves the embedded document so tooling (SDK gen, the validator slated for #19, dashboard dev tools) can fetch the whole spec from the same origin as the routes. - stubs/ package deleted. When a real handler lands, only the apispec.NotImplemented line goes away — nothing else does. The spec stays as documentation; consumers never had to know it was throwaway. #19 (OpenAPI follow-up) is now half-folded into this PR; the validation middleware remains its own follow-up. Tests reshaped: assert envelope + spec.operationId + spec.x-replaces (replaces the old planned.legacy assertion); add TestOpenAPIYAMLServed to cover the static spec serve; add apispec_test.go for embed/lookup behaviour. * refactor(api): move projects contract to internal/project package — #20 Pilots the feature-package layout the backend is migrating toward: a resource's inbound interface and its DTOs live with the resource, not in a central ports/ catch-all. WHY: review flagged ports/ as vague. It conflates three jobs — the outbound capability seam (legit), single-impl inbound interfaces (Go idiom wants these consumer-side), and DTOs that aren't ports at all. This moves the projects contract out as the reference shape #21/#22 follow; the merged session/lifecycle/outbound contracts are left untouched and migrated separately. Scope: INTERFACE ONLY. No implementation — handlers still answer via apispec.NotImplemented and the injected project.Manager stays nil. The impl lands in a later handler-impl PR. Changes: - new internal/project: project.go (Manager interface, 7 endpoints) + dto.go (AddInput/GetResult/UpdateConfigInput/RemoveResult/ReloadResult, moved verbatim from ports/projects.go, Project-prefix dropped). - ports/projects.go deleted; ProjectManager removed from ports/inbound.go. outbound.go and facts.go untouched. - controllers/projects.go and httpd/api.go depend on project.Manager. Domain entities (Project, ProjectSummary, DegradedProject, config types) stay in domain/ as shared vocabulary. go build/vet/test/gofmt all clean; no behavioural change. * refactor(api): consolidate project types into internal/project — #20 Addresses PR review: (1) "why are config_types required at the moment?" and (2) "project objects already defined in project/ — how do we differentiate?" Both had the same root cause: project types were split across domain/ and project/. Fix — keep ALL project types in the project package; only domain.ProjectID (shared with sessions/lifecycle/workspace) stays in domain. - domain/project.go → project/types.go: Project, Summary, Degraded (renamed from ProjectSummary/DegradedProject; the package name carries the "Project" prefix now). - domain/config_types.go deleted. Kept only the 4 shapes the projects API actually exposes — TrackerConfig, SCMConfig, SCMWebhookConfig, ReactionConfig — moved into project/types.go. Dropped AgentConfig, AgentPermission, RoleAgentConfig, LocalProjectConfig (zero references) and the speculative `Extra map[string]any` passthrough fields (no marshaller existed, so they silently dropped data — premature). - project/dto.go + project/project.go reference the local types; ids stay domain.ProjectID. Net: one home for project types, no dead code. go build/vet/test/gofmt clean; no behavioural change (handlers still 501 via apispec). * feat(api): implement project routes with mock manager/store * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * merge: resolve conflicts with origin/main * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * refactor(httpd): share JSON/API error envelope helpers * fix(api): align project mock store with sqlite schema * fix(api): address project API review semantics * canonicalize both paths with filepath.EvalSymlinks before comparing * style(project): gofmt git repo validation --------- Co-authored-by: Aditi Chauhan Co-authored-by: Claude Opus 4.7 Co-authored-by: Vaibhaav Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- backend/go.mod | 1 + backend/go.sum | 2 + backend/internal/httpd/api.go | 95 ++++ backend/internal/httpd/apispec/apispec.go | 157 ++++++ .../internal/httpd/apispec/apispec_test.go | 70 +++ backend/internal/httpd/apispec/openapi.yaml | 446 ++++++++++++++++++ .../internal/httpd/controllers/projects.go | 219 +++++++++ .../httpd/controllers/projects_test.go | 311 ++++++++++++ backend/internal/httpd/envelope/envelope.go | 35 ++ backend/internal/httpd/errors.go | 22 + backend/internal/httpd/json.go | 9 +- backend/internal/httpd/router.go | 14 + backend/internal/project/dto.go | 55 +++ backend/internal/project/errors.go | 41 ++ backend/internal/project/manager.go | 264 +++++++++++ backend/internal/project/memory_store.go | 108 +++++ backend/internal/project/project.go | 44 ++ backend/internal/project/types.go | 96 ++++ backend/internal/runfile/rename_windows.go | 17 +- 19 files changed, 1998 insertions(+), 8 deletions(-) create mode 100644 backend/internal/httpd/api.go create mode 100644 backend/internal/httpd/apispec/apispec.go create mode 100644 backend/internal/httpd/apispec/apispec_test.go create mode 100644 backend/internal/httpd/apispec/openapi.yaml create mode 100644 backend/internal/httpd/controllers/projects.go create mode 100644 backend/internal/httpd/controllers/projects_test.go create mode 100644 backend/internal/httpd/envelope/envelope.go create mode 100644 backend/internal/httpd/errors.go create mode 100644 backend/internal/project/dto.go create mode 100644 backend/internal/project/errors.go create mode 100644 backend/internal/project/manager.go create mode 100644 backend/internal/project/memory_store.go create mode 100644 backend/internal/project/project.go create mode 100644 backend/internal/project/types.go diff --git a/backend/go.mod b/backend/go.mod index 88ca590cf..403a77e4e 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 89f839295..241874761 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 000000000..124a8d788 --- /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 000000000..627ad5dbe --- /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 000000000..b5bde5627 --- /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 000000000..2b60a3a53 --- /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 000000000..8fa9db1f7 --- /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 000000000..4a6d19e15 --- /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 000000000..3e1b2ade6 --- /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 000000000..8b41c99f6 --- /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 9b87461fb..64ccb340d 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 6e078b8df..1a076c617 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 000000000..0e6f5ee5e --- /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 000000000..f6687e1f7 --- /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 000000000..93ca84d9c --- /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 000000000..945a78268 --- /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 000000000..a997519df --- /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 000000000..65e5daa29 --- /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 031411eee..70f5d1ded 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 }