feat(api): implement project routes with mock manager/store (#47)

* feat(backend): HTTP daemon skeleton — config, health, runfile, graceful shutdown (#10)

Phase 1a of the Go HTTP daemon lane (#10). Stands up the loopback-only
sidecar skeleton the later REST/SSE/WS/static surfaces build on:

- config: env-driven (AO_HOST/PORT/ENV/timeouts/run-file) with zero-config
  defaults; binds 127.0.0.1:3001; validates and fails fast on bad input.
- httpd: chi router with the recoverer → request-id → logger → real-ip
  middleware stack and /healthz + /readyz probes. Per-request timeout is
  carried in config but intentionally not global — it scopes to /api/v1 in
  Phase 1b so it never throttles SSE/WS/health.
- runfile: atomic PID + port handshake (running.json) for the Electron
  supervisor, with a dead-PID stale check so a crashed predecessor doesn't
  block startup while a live one fails fast.
- server: bind-before-publish (port conflict fails fast), graceful shutdown
  on SIGINT/SIGTERM via signal.NotifyContext with a 10s hard timeout, and
  run-file cleanup on exit.

Why: the daemon must be safely supervisable as a child process — the
supervisor needs a discoverable PID/port and the daemon must not leave a
half-started process or stale handshake behind. Locking the lifecycle down
now keeps the future port split a small change rather than a rewrite.

Tests cover config defaults/overrides/validation, run-file round-trip and
live/dead PID detection, health probes, full Run lifecycle, and port-conflict
fail-fast.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(backend): drop Env config field — not needed yet (#10)

Per review on #14: AO_ENV / Config.Env / IsProduction() weren't load-bearing
for Phase 1a — they only switched the slog handler. Removing them now keeps
the surface minimal; the env knob can come back later when a real consumer
needs it.

- config: remove Env field, AO_ENV parsing, and IsProduction helper.
- main: collapse newLogger to a single text-handler path.
- httpd: drop the env field from the listening log line.
- tests: drop the env assertions and AO_ENV fixture.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: add backend run + config quick-start to README (#10)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(backend): address Phase 1a review comments (#10)

- config: drop AO_HOST entirely — the daemon is loopback-only by design,
  so making the bind host env-configurable was a security footgun
- config: use net.JoinHostPort in Addr() so IPv6 literals stay valid
- config: reject zero/negative AO_REQUEST_TIMEOUT and AO_SHUTDOWN_TIMEOUT
  (time.ParseDuration accepts both; either would silently break the
  daemon — instant request expiry / no graceful drain)
- runfile: split processAlive into unix/windows build-tagged files so
  liveness detection is reliable on both platforms (Windows uses
  OpenProcess; POSIX keeps signal 0)
- runfile: document os.Rename overwrite semantics (atomic on POSIX,
  REPLACE_EXISTING on Windows) so the temp-then-rename pattern's
  cross-platform behaviour is explicit
- httpd tests: give probe/waitForHealth clients an explicit per-request
  timeout so a stalled connect can't hang the test on the outer deadline

* fix(backend): strip trailing blank line from runfile.go (#10)

gofmt CI was failing because removing the orphan processAlive doc
comment left an extra newline at EOF.

* fix(backend): cross-platform run-file replace + AO_HOST rationale (#10)

- runfile: introduce build-tagged atomicReplace — POSIX rename(2) on
  Unix, MoveFileEx with MOVEFILE_REPLACE_EXISTING on Windows. The Go
  runtime happens to do the Windows call internally already, but
  invoking it directly makes the cross-platform contract explicit
  instead of a runtime implementation detail
- runfile: tighten process_unix.go build tag from `!windows` to `unix`
  so plan9/js/wasm fail to build rather than silently using a broken
  signal-0 probe
- runfile: add TestWriteOverwritesExisting covering the stale run-file
  replace path that none of the previous tests exercised
- config: anchor the loopback-only decision in the LoopbackHost doc so
  the next contributor doesn't reintroduce AO_HOST without the security
  rationale

* fix(backend): route chi access logs through slog/stderr (#10)

chi's middleware.Logger writes via stdlib log to stdout, but the
daemon's slog logger writes to stderr — so REST traffic and daemon
logs landed on different streams in different formats. Replace it
with a small slog-backed requestLogger that:

- Wraps the response writer via middleware.NewWrapResponseWriter so
  status/bytes are accurate even when handlers return without an
  explicit WriteHeader.
- Reads the request id off the context set by middleware.RequestID
  (kept mounted just before this middleware so the id is available).
- Emits one structured Info line per request with method, path,
  status, bytes, duration, and remote — same key=value shape as the
  rest of the daemon, one stream for the Electron supervisor to
  capture.

* feat(api): projects route shell (7 routes, REST-corrected) — #20

Mounts the /api/v1 surface on the skeleton router (#10·1a) and registers
the 7 canonical project routes as 501 stubs that emit a structured
PlannedRoute body documenting the future contract. Shared scaffolding
landed here (api.go, errors.go, stubs/, controllers/) so #21/#22 plug in
without re-touching the wiring.

WHY: opens the route-shell PRs in the Go HTTP daemon lane. Doing it
interface-first lets the dashboard team build against the contract
before any handler logic exists; the locked APIError envelope and
PlannedRoute shape become #19's OpenAPI source-of-truth.

REST audit corrections vs the legacy TS surface:
  R3 PUT /projects/:id alias of PATCH: PUT not registered → 405.
  R4 POST /projects/:id repair overload: canonical /repair; legacy 405.
  R5 degraded GET returns 200 with error field: discriminator status.
  R6 ok/success flag flips: drop on 2xx; return affected resource.
  R9 bare {error: msg}: locked {error,code,message,requestId,details?}.

Legacy paths are deliberately NOT registered; each canonical handler
carries PlannedRoute.Legacy so consumers can discover the migration.

Zod schemas (TrackerConfig, SCMConfig, AgentConfig, ReactionConfig,
LocalProjectConfig, RoleAgentConfig) ported to typed Go structs with an
Extra map reserved for .passthrough() round-tripping in later PRs.

Closes part of #18; targets feat/issue-10 until #14 merges.

* refactor(api): collapse ProjectService → ProjectManager — #20

Controllers now depend on ONE inbound interface per resource — ports.ProjectManager —
mirroring the existing ports.SessionManager + LifecycleManager pattern.
Whether the manager impl reaches into the registry, the LCM, an outbound
port, or all three is its own concern; the HTTP layer no longer has to
know any of that.

WHY: the original split named the boundary type "ProjectService" and put
it in a sibling services.go. That implied a second category of port
distinct from inbound.go's *Manager interfaces, even though they play
the same role (things HTTP/CLI call into the core). Per review feedback,
collapse them onto one Manager-per-resource pattern.

Mechanical changes:
- ports/inbound.go gains ProjectManager next to SessionManager.
- ports/services.go renamed to projects.go; keeps only the DTOs the
  ProjectManager methods take/return.
- ProjectsController.Svc renamed to Mgr; APIDeps.Projects type bumped
  to ports.ProjectManager.

All tests pass unchanged; no behavioural change.

* refactor(api): replace stubs/ with OpenAPI-as-source-of-truth — #20

The first cut of the route shell duplicated each route's contract twice:
once as a Go literal (stubs.PlannedRoute{...}) in the controller, and
implicitly in the PR description. The Go literal was ~230 LoC of pure
throwaway that would be deleted in handler-impl PRs.

This commit eliminates the duplication:

  - backend/internal/httpd/apispec/openapi.yaml: full OpenAPI 3.1 doc
    covering the 7 project routes + shared schemas (Project, APIError,
    config types). x-replaces records the legacy → canonical mapping
    REST-audit corrections produced.
  - apispec/apispec.go: //go:embed the YAML, expose Operation(method,
    path) → the spec slice as a map, NotImplemented(w, r, method, path)
    → 501 with that slice embedded as `spec`.
  - controllers/projects.go: each of 7 handlers is now a one-liner:
    apispec.NotImplemented(w, r, "GET", "/api/v1/projects").
  - /api/v1/openapi.yaml serves the embedded document so tooling
    (SDK gen, the validator slated for #19, dashboard dev tools) can
    fetch the whole spec from the same origin as the routes.
  - stubs/ package deleted.

When a real handler lands, only the apispec.NotImplemented line goes
away — nothing else does. The spec stays as documentation; consumers
never had to know it was throwaway. #19 (OpenAPI follow-up) is now
half-folded into this PR; the validation middleware remains its own
follow-up.

Tests reshaped: assert envelope + spec.operationId + spec.x-replaces
(replaces the old planned.legacy assertion); add TestOpenAPIYAMLServed
to cover the static spec serve; add apispec_test.go for embed/lookup
behaviour.

* refactor(api): move projects contract to internal/project package — #20

Pilots the feature-package layout the backend is migrating toward: a
resource's inbound interface and its DTOs live with the resource, not in
a central ports/ catch-all.

WHY: review flagged ports/ as vague. It conflates three jobs — the
outbound capability seam (legit), single-impl inbound interfaces (Go
idiom wants these consumer-side), and DTOs that aren't ports at all.
This moves the projects contract out as the reference shape #21/#22
follow; the merged session/lifecycle/outbound contracts are left
untouched and migrated separately.

Scope: INTERFACE ONLY. No implementation — handlers still answer via
apispec.NotImplemented and the injected project.Manager stays nil. The
impl lands in a later handler-impl PR.

Changes:
- new internal/project: project.go (Manager interface, 7 endpoints) +
  dto.go (AddInput/GetResult/UpdateConfigInput/RemoveResult/ReloadResult,
  moved verbatim from ports/projects.go, Project-prefix dropped).
- ports/projects.go deleted; ProjectManager removed from ports/inbound.go.
  outbound.go and facts.go untouched.
- controllers/projects.go and httpd/api.go depend on project.Manager.

Domain entities (Project, ProjectSummary, DegradedProject, config types)
stay in domain/ as shared vocabulary.

go build/vet/test/gofmt all clean; no behavioural change.

* refactor(api): consolidate project types into internal/project — #20

Addresses PR review: (1) "why are config_types required at the moment?"
and (2) "project objects already defined in project/ — how do we
differentiate?"

Both had the same root cause: project types were split across domain/
and project/. Fix — keep ALL project types in the project package; only
domain.ProjectID (shared with sessions/lifecycle/workspace) stays in
domain.

- domain/project.go → project/types.go: Project, Summary, Degraded
  (renamed from ProjectSummary/DegradedProject; the package name carries
  the "Project" prefix now).
- domain/config_types.go deleted. Kept only the 4 shapes the projects
  API actually exposes — TrackerConfig, SCMConfig, SCMWebhookConfig,
  ReactionConfig — moved into project/types.go. Dropped AgentConfig,
  AgentPermission, RoleAgentConfig, LocalProjectConfig (zero references)
  and the speculative `Extra map[string]any` passthrough fields (no
  marshaller existed, so they silently dropped data — premature).
- project/dto.go + project/project.go reference the local types; ids
  stay domain.ProjectID.

Net: one home for project types, no dead code. go build/vet/test/gofmt
clean; no behavioural change (handlers still 501 via apispec).

* feat(api): implement project routes with mock manager/store

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* merge: resolve conflicts with origin/main

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* refactor(httpd): share JSON/API error envelope helpers

* fix(api): align project mock store with sqlite schema

* fix(api): address project API review semantics

* canonicalize both paths with filepath.EvalSymlinks before comparing

* style(project): gofmt git repo validation

---------

Co-authored-by: Aditi Chauhan <aditi1178@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Vaibhaav <user@example.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Vaibhaav Tiwari 2026-05-31 20:31:22 +05:30 committed by GitHub
parent 0672dbb266
commit 9a10eacc39
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1998 additions and 8 deletions

View File

@ -5,6 +5,7 @@ go 1.25.7
require (
github.com/go-chi/chi/v5 v5.1.0
github.com/pressly/goose/v3 v3.27.1
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.51.0
)

View File

@ -36,6 +36,8 @@ golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=

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, httpd.APIDeps{
Projects: project.NewMemoryManager(),
}))
t.Cleanup(srv.Close)
return srv
}
func TestProjectsRoutes_DefaultToStubsWithoutManager(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := httptest.NewServer(httpd.NewRouter(config.Config{}, log))
t.Cleanup(srv.Close)
body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects", "")
assertJSON(t, headers)
assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED")
}
func TestProjectsAPI_ListAddGetReload(t *testing.T) {
srv := newTestServer(t)
repo := gitRepo(t, "agent-orchestrator")
body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects", "")
if status != http.StatusOK {
t.Fatalf("GET projects = %d, want 200; body=%s", status, body)
}
assertJSON(t, headers)
var list struct {
Projects []projectSummary `json:"projects"`
}
mustJSON(t, body, &list)
if len(list.Projects) != 0 {
t.Fatalf("initial project count = %d, want 0", len(list.Projects))
}
body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repo)+`,"projectId":"ao","name":"Agent Orchestrator"}`)
if status != http.StatusCreated {
t.Fatalf("POST project = %d, want 201; body=%s", status, body)
}
var add struct {
Project projectBody `json:"project"`
}
mustJSON(t, body, &add)
if add.Project.ID != "ao" || add.Project.Name != "Agent Orchestrator" || add.Project.DefaultBranch != "main" {
t.Fatalf("created project = %#v", add.Project)
}
body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects/ao", "")
if status != http.StatusOK {
t.Fatalf("GET project = %d, want 200; body=%s", status, body)
}
var get struct {
Status string `json:"status"`
Project projectBody `json:"project"`
}
mustJSON(t, body, &get)
if get.Status != "ok" || get.Project.ID != "ao" {
t.Fatalf("get response = %#v", get)
}
body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects/reload", "")
if status != http.StatusOK {
t.Fatalf("reload = %d, want 200; body=%s", status, body)
}
var reload struct {
Reloaded bool `json:"reloaded"`
ProjectCount int `json:"projectCount"`
DegradedCount int `json:"degradedCount"`
}
mustJSON(t, body, &reload)
if !reload.Reloaded || reload.ProjectCount != 1 || reload.DegradedCount != 0 {
t.Fatalf("reload response = %#v", reload)
}
}
func TestProjectsAPI_AddValidationAndConflicts(t *testing.T) {
srv := newTestServer(t)
repoA := gitRepo(t, "repo-a")
repoB := gitRepo(t, "repo-b")
notRepo := t.TempDir()
cases := []struct {
name, body, wantCode string
wantStatus int
}{
{name: "invalid json", body: `{`, wantStatus: 400, wantCode: "INVALID_JSON"},
{name: "missing path", body: `{}`, wantStatus: 400, wantCode: "PATH_REQUIRED"},
{name: "not git", body: `{"path":` + quote(notRepo) + `}`, wantStatus: 400, wantCode: "NOT_A_GIT_REPO"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", tc.body)
assertErrorCode(t, body, status, tc.wantStatus, tc.wantCode)
})
}
body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoA)+`,"projectId":"shared"}`)
if status != http.StatusCreated {
t.Fatalf("seed create = %d, want 201; body=%s", status, body)
}
body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoA)+`,"projectId":"other"}`)
assertErrorCode(t, body, status, http.StatusConflict, "PATH_ALREADY_REGISTERED")
body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoB)+`,"projectId":"shared"}`)
assertErrorCode(t, body, status, http.StatusConflict, "ID_ALREADY_REGISTERED")
}
func TestProjectsAPI_UpdateDeleteRepair(t *testing.T) {
srv := newTestServer(t)
repo := gitRepo(t, "repo")
body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repo)+`,"projectId":"proj"}`)
if status != http.StatusCreated {
t.Fatalf("seed create = %d, want 201; body=%s", status, body)
}
body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/projects/proj", `{"agent":"claude","runtime":"tmux"}`)
assertErrorCode(t, body, status, http.StatusNotImplemented, "PROJECT_CONFIG_NOT_IMPLEMENTED")
body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/projects/proj", `{"path":"elsewhere"}`)
assertErrorCode(t, body, status, http.StatusBadRequest, "IDENTITY_FROZEN")
body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects/proj/repair", "")
assertErrorCode(t, body, status, http.StatusBadRequest, "REPAIR_NOT_AVAILABLE")
body, status, _ = doRequest(t, srv, "DELETE", "/api/v1/projects/proj", "")
if status != http.StatusOK {
t.Fatalf("DELETE = %d, want 200; body=%s", status, body)
}
var removed struct {
ProjectID string `json:"projectId"`
RemovedStorageDir bool `json:"removedStorageDir"`
}
mustJSON(t, body, &removed)
if removed.ProjectID != "proj" || removed.RemovedStorageDir {
t.Fatalf("delete response = %#v", removed)
}
body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects/proj", "")
if status != http.StatusOK {
t.Fatalf("GET archived project = %d, want 200; body=%s", status, body)
}
body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects", "")
if status != http.StatusOK {
t.Fatalf("GET projects after archive = %d, want 200; body=%s", status, body)
}
var list struct {
Projects []projectSummary `json:"projects"`
}
mustJSON(t, body, &list)
if len(list.Projects) != 0 {
t.Fatalf("active projects after archive = %d, want 0", len(list.Projects))
}
}
func TestProjectsRoutes_LegacyUnregistered(t *testing.T) {
srv := newTestServer(t)
cases := []struct {
method, path, wantCode, why string
wantStatus int
}{
{method: "PUT", path: "/api/v1/projects/p1", wantStatus: 405, wantCode: "METHOD_NOT_ALLOWED", why: "R3 PUT not registered"},
{method: "POST", path: "/api/v1/projects/p1", wantStatus: 405, wantCode: "METHOD_NOT_ALLOWED", why: "R4 repair moved to /repair"},
}
for _, tc := range cases {
t.Run(tc.why, func(t *testing.T) {
body, status, _ := doRequest(t, srv, tc.method, tc.path, "")
assertErrorCode(t, body, status, tc.wantStatus, tc.wantCode)
})
}
}
func TestProjectsRoutes_MissingRoute(t *testing.T) {
srv := newTestServer(t)
body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects/p1/does-not-exist", "")
assertJSON(t, headers)
assertErrorCode(t, body, status, http.StatusNotFound, "ROUTE_NOT_FOUND")
}
func TestOpenAPIYAMLServed(t *testing.T) {
srv := newTestServer(t)
body, status, headers := doRequest(t, srv, "GET", "/api/v1/openapi.yaml", "")
if status != http.StatusOK {
t.Fatalf("status = %d, want 200", status)
}
if ct := headers.Get("Content-Type"); !strings.HasPrefix(ct, "application/yaml") {
t.Errorf("Content-Type = %q, want application/yaml*", ct)
}
if !strings.Contains(string(body), "openapi: 3.1.0") {
t.Errorf("served body did not start with an OpenAPI 3.1 doc")
}
}
type projectSummary struct {
ID string `json:"id"`
Name string `json:"name"`
SessionPrefix string `json:"sessionPrefix"`
}
type projectBody struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Repo string `json:"repo"`
DefaultBranch string `json:"defaultBranch"`
Agent string `json:"agent"`
Runtime string `json:"runtime"`
}
type errorBody struct {
Error string `json:"error"`
Code string `json:"code"`
Message string `json:"message"`
Details map[string]any `json:"details"`
}
func doRequest(t *testing.T, srv *httptest.Server, method, path, body string) ([]byte, int, http.Header) {
t.Helper()
var req *http.Request
var err error
if body != "" {
req, err = http.NewRequest(method, srv.URL+path, strings.NewReader(body))
} else {
req, err = http.NewRequest(method, srv.URL+path, nil)
}
if err != nil {
t.Fatalf("new request: %v", err)
}
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatalf("do request: %v", err)
}
defer resp.Body.Close()
buf, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
return buf, resp.StatusCode, resp.Header
}
func gitRepo(t *testing.T, name string) string {
t.Helper()
dir := filepath.Join(t.TempDir(), name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("create git repo fixture: %v", err)
}
if out, err := exec.Command("git", "init", dir).CombinedOutput(); err != nil {
t.Fatalf("git init fixture: %v\n%s", err, out)
}
return dir
}
func quote(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
func mustJSON(t *testing.T, body []byte, out any) {
t.Helper()
if err := json.Unmarshal(body, out); err != nil {
t.Fatalf("unmarshal: %v\nbody=%s", err, body)
}
}
func assertJSON(t *testing.T, headers http.Header) {
t.Helper()
if ct := headers.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Fatalf("Content-Type = %q, want JSON", ct)
}
}
func assertErrorCode(t *testing.T, body []byte, status, wantStatus int, wantCode string) {
t.Helper()
if status != wantStatus {
t.Fatalf("status = %d, want %d\nbody=%s", status, wantStatus, body)
}
var got errorBody
mustJSON(t, body, &got)
if got.Code != wantCode {
t.Fatalf("code = %q, want %q\nbody=%s", got.Code, wantCode, body)
}
}

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

@ -30,6 +30,13 @@ import (
// probes. It is therefore applied per-surface when those subrouters are mounted
// in Phase 1b; cfg.RequestTimeout carries the value through to that point.
func NewRouter(cfg config.Config, log *slog.Logger) chi.Router {
return NewRouterWithAPI(cfg, log, APIDeps{})
}
// NewRouterWithAPI is the dependency-injected variant. main.go calls it with
// real Managers when they exist; tests/dev wiring inject mocks explicitly.
// Missing Managers intentionally keep the route-shell 501 behavior.
func NewRouterWithAPI(cfg config.Config, log *slog.Logger, deps APIDeps) chi.Router {
r := chi.NewRouter()
r.Use(middleware.Recoverer)
@ -37,7 +44,14 @@ func NewRouter(cfg config.Config, log *slog.Logger) chi.Router {
r.Use(requestLogger(log))
r.Use(middleware.RealIP)
// JSON envelopes for unmatched routes / methods — chi's defaults are
// text/plain, which would break consumers that parse every response as
// the locked APIError shape.
r.NotFound(notFoundJSON)
r.MethodNotAllowed(methodNotAllowedJSON)
mountHealth(r)
NewAPI(cfg, deps).Register(r)
return r
}

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
}