agent-orchestrator/backend/internal/httpd/controllers/dto.go

177 lines
7.2 KiB
Go

package controllers
import (
"encoding/json"
"errors"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
)
// HTTP response envelopes for the projects surface — the SINGLE definition of
// each wire shape. The handlers encode these (envelope.WriteJSON), and
// apispec.Build reflects these same types into openapi.yaml, so the served
// contract and the generated spec can't disagree. The request side needs no
// wrappers: handlers decode the body straight into the project commands
// (projectsvc.AddInput), which apispec also reflects.
// ProjectIDParam is the {id} path parameter shared by the /projects/{id}
// routes. Handlers read it via chi.URLParam (see projectID); it is declared here
// so every wire input/output shape has one home, and apispec.Build reflects it
// as the path parameter.
type ProjectIDParam struct {
ID string `path:"id" description:"Project identifier (registry key)."`
}
// ListProjectsResponse is the body of GET /api/v1/projects.
type ListProjectsResponse struct {
Projects []projectsvc.Summary `json:"projects"`
}
// ProjectResponse is the { project } body shared by POST /projects (201).
type ProjectResponse struct {
Project projectsvc.Project `json:"project"`
}
// GetProjectResponse is the { status, project } body of GET /projects/{id},
// where project is oneOf Project|Degraded discriminated by status.
type GetProjectResponse struct {
Status string `json:"status" enum:"ok,degraded"`
Project ProjectOrDegraded `json:"project"`
}
// ProjectOrDegraded is the discriminated `project` field: exactly one of
// Project/Degraded is set. It marshals as whichever is present (so the handler
// emits the right object) and exposes the oneOf variants to the spec reflector
// (so apispec.Build emits `oneOf: [Project, Degraded]`) — one type, both jobs.
type ProjectOrDegraded struct {
Project *projectsvc.Project
Degraded *projectsvc.Degraded
}
// MarshalJSON encodes whichever variant is set (Project or Degraded).
func (p ProjectOrDegraded) MarshalJSON() ([]byte, error) {
switch {
case p.Degraded != nil:
return json.Marshal(p.Degraded)
case p.Project != nil:
return json.Marshal(p.Project)
default:
// Unreachable in practice: the handler validates the GetResult via
// newGetProjectResponse and writes a 500 before committing the 200
// status, so this never encodes. Kept as a last-resort backstop —
// erroring is still better than emitting a contract-breaking `null`,
// though by here the status is already sent, so the real guard is
// upstream.
return nil, errEmptyProjectOrDegraded
}
}
// errEmptyProjectOrDegraded marks a GetResult that set neither variant — a
// Manager-contract violation. newGetProjectResponse returns it so the handler
// can map it to a 500 before any response bytes are written.
var errEmptyProjectOrDegraded = errors.New("controllers: GetResult has neither Project nor Degraded set")
// JSONSchemaOneOf is read by swaggest's reflector (apispec.Build) to emit the
// oneOf for this field; it is not used at runtime.
func (ProjectOrDegraded) JSONSchemaOneOf() []interface{} {
return []interface{}{projectsvc.Project{}, projectsvc.Degraded{}}
}
// newGetProjectResponse maps the internal GetResult onto the wire envelope —
// the explicit project→httpd boundary the result type exists for. It errors
// when the result sets neither variant, so the handler can return a clean 500
// BEFORE writing the 200 status rather than flushing a truncated body.
func newGetProjectResponse(res projectsvc.GetResult) (GetProjectResponse, error) {
if res.Project == nil && res.Degraded == nil {
return GetProjectResponse{}, errEmptyProjectOrDegraded
}
return GetProjectResponse{
Status: res.Status,
Project: ProjectOrDegraded{Project: res.Project, Degraded: res.Degraded},
}, nil
}
// SessionIDParam is the {sessionId} path parameter shared by session routes.
type SessionIDParam struct {
SessionID string `path:"sessionId" description:"Session identifier, e.g. project-1."`
}
// ListSessionsQuery is the query string accepted by GET /api/v1/sessions.
type ListSessionsQuery struct {
Project string `query:"project,omitempty" description:"Project id filter."`
Active *bool `query:"active,omitempty" description:"When true, return non-terminated sessions; when false, return terminated sessions."`
OrchestratorOnly *bool `query:"orchestratorOnly,omitempty" description:"When true, return only orchestrator sessions."`
Fresh *bool `query:"fresh,omitempty" description:"When true, return only fresh non-terminated sessions."`
}
// ListSessionsResponse is the body of GET /api/v1/sessions.
type ListSessionsResponse struct {
Sessions []domain.Session `json:"sessions"`
}
// SpawnSessionRequest is the body of POST /api/v1/sessions.
type SpawnSessionRequest struct {
ProjectID domain.ProjectID `json:"projectId"`
IssueID domain.IssueID `json:"issueId,omitempty"`
Kind domain.SessionKind `json:"kind,omitempty" enum:"worker,orchestrator"`
Harness domain.AgentHarness `json:"harness,omitempty" enum:"claude-code,codex,aider,opencode"`
Branch string `json:"branch,omitempty"`
Prompt string `json:"prompt,omitempty" maxLength:"4096"`
AgentRules string `json:"agentRules,omitempty"`
}
// SessionResponse is the { session } body shared by session create/get.
type SessionResponse struct {
Session domain.Session `json:"session"`
}
// RenameSessionRequest is the body of PATCH /api/v1/sessions/{sessionId}.
type RenameSessionRequest struct {
DisplayName string `json:"displayName" minLength:"1"`
}
// RestoreSessionResponse is the body of POST /api/v1/sessions/{sessionId}/restore.
type RestoreSessionResponse struct {
OK bool `json:"ok"`
SessionID domain.SessionID `json:"sessionId"`
Session domain.Session `json:"session"`
}
// KillSessionResponse is the body of POST /api/v1/sessions/{sessionId}/kill.
type KillSessionResponse struct {
OK bool `json:"ok"`
SessionID domain.SessionID `json:"sessionId"`
Freed bool `json:"freed,omitempty"`
}
// SendSessionMessageRequest is the body of POST /api/v1/sessions/{sessionId}/send.
type SendSessionMessageRequest struct {
Message string `json:"message" minLength:"1" maxLength:"4096"`
}
// SendSessionMessageResponse is the body of POST /api/v1/sessions/{sessionId}/send.
type SendSessionMessageResponse struct {
OK bool `json:"ok"`
SessionID domain.SessionID `json:"sessionId"`
Message string `json:"message"`
}
// SpawnOrchestratorRequest is the body of POST /api/v1/orchestrators.
type SpawnOrchestratorRequest struct {
ProjectID domain.ProjectID `json:"projectId"`
Clean bool `json:"clean,omitempty"`
}
// SpawnOrchestratorResponse is the body of POST /api/v1/orchestrators.
type SpawnOrchestratorResponse struct {
Orchestrator OrchestratorResponse `json:"orchestrator"`
}
// OrchestratorResponse is the minimal orchestrator read model returned after spawn.
type OrchestratorResponse struct {
ID domain.SessionID `json:"id"`
ProjectID domain.ProjectID `json:"projectId"`
ProjectName string `json:"projectName,omitempty"`
}