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