467 lines
20 KiB
Go
467 lines
20 KiB
Go
package controllers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
|
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
|
|
sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session"
|
|
)
|
|
|
|
// 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."`
|
|
}
|
|
|
|
// CleanupSessionsQuery is the query string accepted by POST /api/v1/sessions/cleanup.
|
|
type CleanupSessionsQuery struct {
|
|
Project string `query:"project,omitempty" description:"Project id filter. When omitted, clean terminated sessions across all projects."`
|
|
}
|
|
|
|
// SessionView is the session wire shape: the domain read model plus the
|
|
// display-safe branch name and the session's attributed pull requests in the
|
|
// curated SessionPRFacts shape. One session can own many PRs (e.g. a stack), so
|
|
// prs is a list. The embedded domain.Session.Metadata and domain.Session.PRs
|
|
// fields are json:"-"; these curated fields are what serialize.
|
|
type SessionView struct {
|
|
domain.Session
|
|
Branch string `json:"branch,omitempty"`
|
|
PRs []SessionPRFacts `json:"prs"`
|
|
}
|
|
|
|
// ListSessionsResponse is the body of GET /api/v1/sessions.
|
|
type ListSessionsResponse struct {
|
|
Sessions []SessionView `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,grok,droid,amp,agy,crush,cursor,qwen,copilot,goose,auggie,continue,devin,cline,kimi,kiro,kilocode,vibe,pi,autohand"`
|
|
Branch string `json:"branch,omitempty"`
|
|
Prompt string `json:"prompt,omitempty" maxLength:"4096"`
|
|
}
|
|
|
|
// SessionResponse is the { session } body shared by session create/get.
|
|
type SessionResponse struct {
|
|
Session SessionView `json:"session"`
|
|
}
|
|
|
|
// RenameSessionRequest is the body of PATCH /api/v1/sessions/{sessionId}.
|
|
type RenameSessionRequest struct {
|
|
DisplayName string `json:"displayName" minLength:"1"`
|
|
}
|
|
|
|
// RenameSessionResponse is the body of PATCH /api/v1/sessions/{sessionId}.
|
|
type RenameSessionResponse struct {
|
|
OK bool `json:"ok"`
|
|
SessionID domain.SessionID `json:"sessionId"`
|
|
DisplayName string `json:"displayName"`
|
|
}
|
|
|
|
// RestoreSessionResponse is the body of POST /api/v1/sessions/{sessionId}/restore.
|
|
type RestoreSessionResponse struct {
|
|
OK bool `json:"ok"`
|
|
SessionID domain.SessionID `json:"sessionId"`
|
|
Session SessionView `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"`
|
|
}
|
|
|
|
// RollbackSessionResponse is the body of POST /api/v1/sessions/{sessionId}/rollback.
|
|
// Exactly one of Deleted/Killed is true on a successful rollback; both are
|
|
// false when the session was already absent or already terminated (benign).
|
|
type RollbackSessionResponse struct {
|
|
OK bool `json:"ok"`
|
|
SessionID domain.SessionID `json:"sessionId"`
|
|
Deleted bool `json:"deleted,omitempty"`
|
|
Killed bool `json:"killed,omitempty"`
|
|
}
|
|
|
|
// CleanupSkippedSession is one terminal session whose workspace cleanup
|
|
// preserved rather than reclaimed (a dirty worktree is never force-deleted),
|
|
// with the user-facing reason.
|
|
type CleanupSkippedSession struct {
|
|
SessionID domain.SessionID `json:"sessionId"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// CleanupSessionsResponse is the body of POST /api/v1/sessions/cleanup.
|
|
type CleanupSessionsResponse struct {
|
|
OK bool `json:"ok"`
|
|
Cleaned []domain.SessionID `json:"cleaned"`
|
|
Skipped []CleanupSkippedSession `json:"skipped"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// SessionPRFacts is the pull-request read shape returned under session PR routes.
|
|
type SessionPRFacts struct {
|
|
URL string `json:"url"`
|
|
Number int `json:"number"`
|
|
State string `json:"state" enum:"draft,open,merged,closed"`
|
|
CI domain.CIState `json:"ci" enum:"unknown,pending,passing,failing"`
|
|
Review domain.ReviewDecision `json:"review" enum:"none,approved,changes_requested,review_required"`
|
|
Mergeability domain.Mergeability `json:"mergeability" enum:"unknown,mergeable,conflicting,blocked,unstable"`
|
|
ReviewComments bool `json:"reviewComments"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
}
|
|
|
|
// SessionPRSummary is the concise desktop SCM read model returned by GET
|
|
// /sessions/{sessionId}/pr. It intentionally omits CI log tails and review
|
|
// comment bodies.
|
|
type SessionPRSummary struct {
|
|
URL string `json:"url"`
|
|
HTMLURL string `json:"htmlUrl,omitempty"`
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
State domain.PRState `json:"state" enum:"draft,open,merged,closed"`
|
|
Provider string `json:"provider" enum:"github"`
|
|
Repo string `json:"repo"`
|
|
Author string `json:"author"`
|
|
SourceBranch string `json:"sourceBranch"`
|
|
TargetBranch string `json:"targetBranch"`
|
|
HeadSHA string `json:"headSha"`
|
|
Additions int `json:"additions"`
|
|
Deletions int `json:"deletions"`
|
|
ChangedFiles int `json:"changedFiles"`
|
|
CI SessionPRCISummary `json:"ci"`
|
|
Review SessionPRReviewSummary `json:"review"`
|
|
Mergeability SessionPRMergeabilitySummary `json:"mergeability"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
ObservedAt time.Time `json:"observedAt,omitempty"`
|
|
CIObservedAt time.Time `json:"ciObservedAt,omitempty"`
|
|
ReviewObservedAt time.Time `json:"reviewObservedAt,omitempty"`
|
|
}
|
|
|
|
// SessionPRCISummary is the CI status block for a session PR summary.
|
|
type SessionPRCISummary struct {
|
|
State domain.CIState `json:"state" enum:"unknown,pending,passing,failing"`
|
|
FailingChecks []SessionPRFailingCheck `json:"failingChecks"`
|
|
}
|
|
|
|
// SessionPRFailingCheck is one failed or cancelled CI check for a PR.
|
|
type SessionPRFailingCheck struct {
|
|
Name string `json:"name"`
|
|
Status domain.PRCheckStatus `json:"status" enum:"failed,cancelled"`
|
|
Conclusion string `json:"conclusion"`
|
|
URL string `json:"url,omitempty"`
|
|
}
|
|
|
|
// SessionPRReviewSummary is the review state block for a session PR summary.
|
|
type SessionPRReviewSummary struct {
|
|
Decision domain.ReviewDecision `json:"decision" enum:"none,approved,changes_requested,review_required"`
|
|
HasUnresolvedHumanComments bool `json:"hasUnresolvedHumanComments"`
|
|
UnresolvedBy []SessionPRUnresolvedReviewer `json:"unresolvedBy"`
|
|
}
|
|
|
|
// SessionPRUnresolvedReviewer groups unresolved human comments by reviewer.
|
|
type SessionPRUnresolvedReviewer struct {
|
|
ReviewerID string `json:"reviewerId"`
|
|
Count int `json:"count"`
|
|
Links []SessionPRReviewCommentLink `json:"links"`
|
|
}
|
|
|
|
// SessionPRReviewCommentLink points to one unresolved review comment.
|
|
type SessionPRReviewCommentLink struct {
|
|
URL string `json:"url,omitempty"`
|
|
File string `json:"file,omitempty"`
|
|
Line int `json:"line,omitempty"`
|
|
}
|
|
|
|
// SessionPRMergeabilitySummary is the mergeability block for a session PR summary.
|
|
type SessionPRMergeabilitySummary struct {
|
|
State domain.Mergeability `json:"state" enum:"unknown,mergeable,conflicting,blocked,unstable"`
|
|
Reasons []string `json:"reasons"`
|
|
PRURL string `json:"prUrl"`
|
|
ConflictFiles []SessionPRConflictFile `json:"conflictFiles,omitempty"`
|
|
}
|
|
|
|
// SessionPRConflictFile is one file involved in a PR merge conflict.
|
|
type SessionPRConflictFile struct {
|
|
Path string `json:"path"`
|
|
URL string `json:"url,omitempty"`
|
|
}
|
|
|
|
// ListSessionPRsResponse is the body of GET /sessions/{sessionId}/pr.
|
|
type ListSessionPRsResponse struct {
|
|
SessionID domain.SessionID `json:"sessionId"`
|
|
PRs []SessionPRSummary `json:"prs"`
|
|
}
|
|
|
|
// NewSessionPRSummary maps the service PR summary model to its HTTP DTO.
|
|
func NewSessionPRSummary(in sessionsvc.PRSummary) SessionPRSummary {
|
|
return SessionPRSummary{
|
|
URL: in.URL,
|
|
HTMLURL: in.HTMLURL,
|
|
Number: in.Number,
|
|
Title: in.Title,
|
|
State: in.State,
|
|
Provider: in.Provider,
|
|
Repo: in.Repo,
|
|
Author: in.Author,
|
|
SourceBranch: in.SourceBranch,
|
|
TargetBranch: in.TargetBranch,
|
|
HeadSHA: in.HeadSHA,
|
|
Additions: in.Additions,
|
|
Deletions: in.Deletions,
|
|
ChangedFiles: in.ChangedFiles,
|
|
CI: newSessionPRCISummary(in.CI),
|
|
Review: newSessionPRReviewSummary(in.Review),
|
|
Mergeability: newSessionPRMergeabilitySummary(in.Mergeability),
|
|
UpdatedAt: in.UpdatedAt,
|
|
ObservedAt: in.ObservedAt,
|
|
CIObservedAt: in.CIObservedAt,
|
|
ReviewObservedAt: in.ReviewObservedAt,
|
|
}
|
|
}
|
|
|
|
func newSessionPRCISummary(in sessionsvc.PRCISummary) SessionPRCISummary {
|
|
checks := make([]SessionPRFailingCheck, 0, len(in.FailingChecks))
|
|
for _, ch := range in.FailingChecks {
|
|
checks = append(checks, SessionPRFailingCheck{Name: ch.Name, Status: ch.Status, Conclusion: ch.Conclusion, URL: ch.URL})
|
|
}
|
|
return SessionPRCISummary{State: in.State, FailingChecks: checks}
|
|
}
|
|
|
|
func newSessionPRReviewSummary(in sessionsvc.PRReviewSummary) SessionPRReviewSummary {
|
|
reviewers := make([]SessionPRUnresolvedReviewer, 0, len(in.UnresolvedBy))
|
|
for _, reviewer := range in.UnresolvedBy {
|
|
links := make([]SessionPRReviewCommentLink, 0, len(reviewer.Links))
|
|
for _, link := range reviewer.Links {
|
|
links = append(links, SessionPRReviewCommentLink{URL: link.URL, File: link.File, Line: link.Line})
|
|
}
|
|
reviewers = append(reviewers, SessionPRUnresolvedReviewer{ReviewerID: reviewer.ReviewerID, Count: reviewer.Count, Links: links})
|
|
}
|
|
return SessionPRReviewSummary{Decision: in.Decision, HasUnresolvedHumanComments: in.HasUnresolvedHumanComments, UnresolvedBy: reviewers}
|
|
}
|
|
|
|
func newSessionPRMergeabilitySummary(in sessionsvc.PRMergeabilitySummary) SessionPRMergeabilitySummary {
|
|
files := make([]SessionPRConflictFile, 0, len(in.ConflictFiles))
|
|
for _, file := range in.ConflictFiles {
|
|
files = append(files, SessionPRConflictFile{Path: file.Path, URL: file.URL})
|
|
}
|
|
return SessionPRMergeabilitySummary{State: in.State, Reasons: in.Reasons, PRURL: in.PRURL, ConflictFiles: files}
|
|
}
|
|
|
|
// ClaimPRRequest is the body of POST /sessions/{sessionId}/pr/claim.
|
|
type ClaimPRRequest struct {
|
|
PR string `json:"pr" minLength:"1"`
|
|
AllowTakeover *bool `json:"allowTakeover,omitempty"`
|
|
}
|
|
|
|
// ClaimPRResponse is the body of POST /sessions/{sessionId}/pr/claim.
|
|
type ClaimPRResponse struct {
|
|
OK bool `json:"ok"`
|
|
SessionID domain.SessionID `json:"sessionId"`
|
|
PRs []SessionPRFacts `json:"prs"`
|
|
BranchChanged bool `json:"branchChanged"`
|
|
TakenOverFrom []domain.SessionID `json:"takenOverFrom"`
|
|
}
|
|
|
|
// SetActivityRequest is the body of POST /api/v1/sessions/{sessionId}/activity.
|
|
type SetActivityRequest struct {
|
|
State string `json:"state" enum:"active,idle,waiting_input,exited" description:"Agent activity state reported by an agent hook."`
|
|
}
|
|
|
|
// SetActivityResponse is the body of POST /api/v1/sessions/{sessionId}/activity.
|
|
type SetActivityResponse struct {
|
|
OK bool `json:"ok"`
|
|
SessionID domain.SessionID `json:"sessionId"`
|
|
State string `json:"state"`
|
|
}
|
|
|
|
// OrchestratorIDParam is the {id} path parameter for orchestrator routes.
|
|
type OrchestratorIDParam struct {
|
|
ID string `path:"id" description:"Orchestrator session identifier, e.g. project-orchestrator."`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// ListNotificationsQuery is the query string accepted by GET /api/v1/notifications.
|
|
type ListNotificationsQuery struct {
|
|
Status string `query:"status,omitempty" enum:"unread" description:"Notification status filter. V1 supports only unread."`
|
|
Limit int `query:"limit,omitempty" minimum:"1" maximum:"100" description:"Maximum notifications to return. Defaults to 50; capped at 100."`
|
|
}
|
|
|
|
// NotificationStreamQuery is the query string accepted by GET /api/v1/notifications/stream.
|
|
type NotificationStreamQuery struct {
|
|
ProjectID string `query:"projectId,omitempty" description:"Optional project id filter for live notifications."`
|
|
}
|
|
|
|
// NotificationTarget is the dashboard navigation target for a notification.
|
|
type NotificationTarget struct {
|
|
Kind string `json:"kind" enum:"session,pr"`
|
|
SessionID string `json:"sessionId"`
|
|
PRURL string `json:"prUrl,omitempty"`
|
|
}
|
|
|
|
// NotificationResponse is one stored notification returned by the API.
|
|
type NotificationResponse struct {
|
|
ID string `json:"id"`
|
|
SessionID string `json:"sessionId"`
|
|
ProjectID string `json:"projectId"`
|
|
PRURL string `json:"prUrl"`
|
|
Type string `json:"type" enum:"needs_input,ready_to_merge,pr_merged,pr_closed_unmerged"`
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
Status string `json:"status" enum:"unread,read"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
Target NotificationTarget `json:"target"`
|
|
}
|
|
|
|
// ListNotificationsResponse is the body of GET /api/v1/notifications.
|
|
type ListNotificationsResponse struct {
|
|
Notifications []NotificationResponse `json:"notifications"`
|
|
}
|
|
|
|
// PRIDParam is the {id} path parameter shared by the /prs/{id} routes.
|
|
type PRIDParam struct {
|
|
ID string `path:"id" description:"PR number."`
|
|
}
|
|
|
|
// MergePRResponse is the body of POST /api/v1/prs/{id}/merge (200).
|
|
type MergePRResponse struct {
|
|
OK bool `json:"ok"`
|
|
PRNumber int `json:"prNumber"`
|
|
Method string `json:"method"`
|
|
}
|
|
|
|
// ResolveCommentsRequest is the optional body of POST /api/v1/prs/{id}/resolve-comments.
|
|
type ResolveCommentsRequest struct {
|
|
CommentIDs []string `json:"commentIds,omitempty"`
|
|
}
|
|
|
|
// ResolveCommentsResponse is the body of POST /api/v1/prs/{id}/resolve-comments (200).
|
|
type ResolveCommentsResponse struct {
|
|
OK bool `json:"ok"`
|
|
Resolved int `json:"resolved"`
|
|
}
|