Merge remote-tracking branch 'origin/main' into feat/terminal-streaming

# Conflicts:
#	backend/internal/httpd/router.go
This commit is contained in:
Pritom14 2026-05-31 20:50:26 +05:30
commit 3ce8115e6d
19 changed files with 1998 additions and 8 deletions

View File

@ -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
)

View File

@ -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=

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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")
}
}

View File

@ -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 }

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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,
})
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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"`
}

View File

@ -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)
}

View File

@ -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]
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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"`
}

View File

@ -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
}