Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions components/backend/handlers/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ func RetryWithBackoff(maxRetries int, initialDelay, maxDelay time.Duration, oper
return fmt.Errorf("operation failed after %d retries: %w", maxRetries, lastErr)
}

// ComputeAutoBranch generates the auto-branch name from a session name
// This is the single source of truth for auto-branch naming in the backend
// IMPORTANT: Keep pattern in sync with runner (main.py)
// Pattern: ambient/{session-name}
func ComputeAutoBranch(sessionName string) string {
return fmt.Sprintf("ambient/%s", sessionName)
}

// ValidateSecretAccess checks if the user has permission to perform the given verb on secrets
// Returns an error if the user lacks the required permission
// Accepts kubernetes.Interface for compatibility with dependency injection in tests
Expand Down
159 changes: 149 additions & 10 deletions components/backend/handlers/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,8 @@ func ListSessions(c *gin.Context) {
session.Status = parseStatus(status)
}

session.AutoBranch = ComputeAutoBranch(item.GetName())

sessions = append(sessions, session)
}

Expand Down Expand Up @@ -553,9 +555,10 @@ func CreateSession(c *gin.Context) {
timeout = *req.Timeout
}

// Generate unique name
// Generate unique name (timestamp-based)
// Note: Runner will create branch as "ambient/{session-name}"
timestamp := time.Now().Unix()
name := fmt.Sprintf("agentic-session-%d", timestamp)
name := fmt.Sprintf("session-%d", timestamp)

// Create the custom resource
// Metadata
Expand Down Expand Up @@ -638,8 +641,11 @@ func CreateSession(c *gin.Context) {
arr := make([]map[string]interface{}, 0, len(req.Repos))
for _, r := range req.Repos {
m := map[string]interface{}{"url": r.URL}
if r.Branch != nil {
// Fill in branch if not provided (auto-generate from session name)
if r.Branch != nil && strings.TrimSpace(*r.Branch) != "" {
m["branch"] = *r.Branch
} else {
m["branch"] = ComputeAutoBranch(name)
}
if r.AutoPush != nil {
m["autoPush"] = *r.AutoPush
Expand Down Expand Up @@ -722,9 +728,10 @@ func CreateSession(c *gin.Context) {
// This ensures consistent behavior whether sessions are created via API or kubectl.

c.JSON(http.StatusCreated, gin.H{
"message": "Agentic session created successfully",
"name": name,
"uid": created.GetUID(),
"message": "Agentic session created successfully",
"name": name,
"uid": created.GetUID(),
"autoBranch": ComputeAutoBranch(name),
})
}

Expand Down Expand Up @@ -773,6 +780,8 @@ func GetSession(c *gin.Context) {
session.Status = parseStatus(status)
}

session.AutoBranch = ComputeAutoBranch(sessionName)

c.JSON(http.StatusOK, session)
}

Expand Down Expand Up @@ -1459,19 +1468,78 @@ func RemoveRepo(c *gin.Context) {
repos, _ := spec["repos"].([]interface{})

filteredRepos := []interface{}{}
found := false
foundInSpec := false
for _, r := range repos {
rm, _ := r.(map[string]interface{})
url, _ := rm["url"].(string)
if DeriveRepoFolderFromURL(url) != repoName {
filteredRepos = append(filteredRepos, r)
} else {
found = true
foundInSpec = true
}
}

if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found in session"})
// Also check status.reconciledRepos for repos added directly to runner
// Note: status map is read-only here, not persisted back to CR
status, found, err := unstructured.NestedMap(item.Object, "status")
if !found || err != nil {
log.Printf("Failed to get status: %v", err)
status = make(map[string]interface{}) // Local empty map for safe reads
}

reconciledRepos, found, err := unstructured.NestedSlice(status, "reconciledRepos")
if !found || err != nil {
log.Printf("Failed to get reconciledRepos: %v", err)
reconciledRepos = []interface{}{}
}

foundInReconciled := false
for _, r := range reconciledRepos {
rm, ok := r.(map[string]interface{})
if !ok {
continue
}

name, found, err := unstructured.NestedString(rm, "name")
if found && err == nil && name == repoName {
foundInReconciled = true
break
}

// Also try matching by URL
url, found, err := unstructured.NestedString(rm, "url")
if found && err == nil && DeriveRepoFolderFromURL(url) == repoName {
foundInReconciled = true
break
}
}

// Always call runner to remove from filesystem (if session is running)
// Do this BEFORE checking if repo exists in CR, because it might only be on filesystem
phase, _, _ := unstructured.NestedString(status, "phase")
runnerRemoved := false
if phase == "Running" {
runnerURL := fmt.Sprintf("http://session-%s.%s.svc.cluster.local:8001/repos/remove", sessionName, project)
runnerReq := map[string]string{"name": repoName}
reqBody, _ := json.Marshal(runnerReq)
resp, err := http.Post(runnerURL, "application/json", bytes.NewReader(reqBody))
if err != nil {
log.Printf("Warning: failed to call runner /repos/remove: %v", err)
} else {
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
runnerRemoved = true
log.Printf("Runner successfully removed repo %s from filesystem", repoName)
} else {
body, _ := io.ReadAll(resp.Body)
log.Printf("Runner failed to remove repo %s (status %d): %s", repoName, resp.StatusCode, string(body))
}
}
}

// Allow delete if repo is in CR OR was successfully removed from runner
if !foundInSpec && !foundInReconciled && !runnerRemoved {
c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found in session or runner"})
return
}

Expand Down Expand Up @@ -3069,6 +3137,77 @@ func DiffSessionRepo(c *gin.Context) {
c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), bodyBytes)
}

// GetReposStatus returns current status of all repositories (branches, current branch, etc.)
// GET /api/projects/:projectName/agentic-sessions/:sessionName/repos/status
func GetReposStatus(c *gin.Context) {
project := c.Param("projectName")
session := c.Param("sessionName")

k8sClt, dynClt := GetK8sClientsForRequest(c)
if k8sClt == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"})
c.Abort()
return
}

// Verify user has access to the session using user-scoped K8s client
// This ensures RBAC is enforced before we call the runner
gvr := GetAgenticSessionV1Alpha1Resource()
_, err := dynClt.Resource(gvr).Namespace(project).Get(context.TODO(), session, v1.GetOptions{})
if errors.IsNotFound(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"})
return
}
if err != nil {
log.Printf("GetReposStatus: failed to verify session access: %v", err)
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}

// Call runner's /repos/status endpoint directly
// Authentication flow:
// 1. Backend validated user has access to session (above)
// 2. Backend calls runner as trusted internal service (no auth header forwarding)
// 3. Runner trusts backend's validation
// Port 8001 matches AG-UI Service defined in operator (sessions.go:1384)
// If changing this port, also update: operator containerPort, Service port, and AGUI_PORT env
runnerURL := fmt.Sprintf("http://session-%s.%s.svc.cluster.local:8001/repos/status", session, project)

req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, runnerURL, nil)
if err != nil {
log.Printf("GetReposStatus: failed to create HTTP request: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"})
return
}
// NOTE: Do NOT forward Authorization header to runner (matches pattern of AddWorkflow, AddRepository, RemoveRepo)
// Runner is treated as a trusted backend service; RBAC enforcement happens in backend

client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Printf("GetReposStatus: runner not reachable: %v", err)
// Return empty repos list instead of error for better UX
c.JSON(http.StatusOK, gin.H{"repos": []interface{}{}})
return
}
defer resp.Body.Close()

bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("GetReposStatus: failed to read response body: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read response from runner"})
return
}

if resp.StatusCode != http.StatusOK {
log.Printf("GetReposStatus: runner returned status %d", resp.StatusCode)
c.JSON(http.StatusOK, gin.H{"repos": []interface{}{}})
return
}

c.Data(http.StatusOK, "application/json", bodyBytes)
}

// GetGitStatus returns git status for a directory in the workspace
// GET /api/projects/:projectName/agentic-sessions/:sessionName/git/status?path=artifacts
func GetGitStatus(c *gin.Context) {
Expand Down
2 changes: 2 additions & 0 deletions components/backend/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ func registerRoutes(r *gin.Engine) {
projectGroup.POST("/agentic-sessions/:sessionName/workflow", handlers.SelectWorkflow)
projectGroup.GET("/agentic-sessions/:sessionName/workflow/metadata", handlers.GetWorkflowMetadata)
projectGroup.POST("/agentic-sessions/:sessionName/repos", handlers.AddRepo)
// NOTE: /repos/status must come BEFORE /repos/:repoName to avoid wildcard matching
projectGroup.GET("/agentic-sessions/:sessionName/repos/status", handlers.GetReposStatus)
projectGroup.DELETE("/agentic-sessions/:sessionName/repos/:repoName", handlers.RemoveRepo)
projectGroup.PUT("/agentic-sessions/:sessionName/displayname", handlers.UpdateSessionDisplayName)

Expand Down
3 changes: 3 additions & 0 deletions components/backend/types/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ type AgenticSession struct {
Metadata map[string]interface{} `json:"metadata"`
Spec AgenticSessionSpec `json:"spec"`
Status *AgenticSessionStatus `json:"status,omitempty"`
// Computed field: auto-generated branch name if user doesn't provide one
// IMPORTANT: Keep in sync with runner (main.py) and frontend (add-context-modal.tsx)
AutoBranch string `json:"autoBranch,omitempty"`
}

type AgenticSessionSpec struct {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { BACKEND_URL } from '@/lib/config';
import { buildForwardHeadersAsync } from '@/lib/auth';

export async function GET(
request: Request,
{ params }: { params: Promise<{ name: string; sessionName: string }> },
) {
const { name, sessionName } = await params;
const headers = await buildForwardHeadersAsync(request);

const resp = await fetch(
`${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions/${encodeURIComponent(sessionName)}/repos/status`,
{
method: 'GET',
headers,
}
);

if (!resp.ok) {
return new Response(
JSON.stringify({ error: 'Failed to fetch repos status', repos: [] }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}

const data = await resp.text();
return new Response(data, {
status: resp.status,
headers: { 'Content-Type': 'application/json' }
});
}
Loading
Loading