Merge remote-tracking branch 'origin/main' into feat/terminal-streaming
# Conflicts: # backend/internal/httpd/router.go
This commit is contained in:
commit
3ce8115e6d
|
|
@ -7,6 +7,7 @@ require (
|
|||
github.com/creack/pty v1.1.24
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/pressly/goose/v3 v3.27.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.51.0
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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, nil, 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, nil))
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,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, termMgr *terminal.Manager) chi.Router {
|
||||
return NewRouterWithAPI(cfg, log, termMgr, 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, termMgr *terminal.Manager, deps APIDeps) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(middleware.Recoverer)
|
||||
|
|
@ -38,8 +45,15 @@ func NewRouter(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager) c
|
|||
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)
|
||||
mountMux(r, termMgr, log)
|
||||
NewAPI(cfg, deps).Register(r)
|
||||
|
||||
return r
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue